ag_ui/client/transport/sse.rs
1//! Decoding `text/event-stream`.
2//!
3//! The encoder lives in `ag-ui-core`; this is the other half. It is a wire
4//! format parser fed by a network, so it assumes nothing about what it is
5//! handed: bytes arrive in arbitrary chunks that split lines, split UTF-8
6//! sequences, and end without the terminating blank line the format calls for.
7//!
8//! What it handles, because real servers and proxies do all of it:
9//!
10//! - `data:` repeated over several lines, rejoined with `\n` — the format's own
11//! way of carrying a payload that contains newlines.
12//! - Comment lines (`: keep-alive`), which proxies inject to hold a connection
13//! open and which dispatch nothing.
14//! - A body that ends without a blank line: [`SseDecoder::finish`] dispatches
15//! the last frame rather than dropping it.
16//! - `\n`, `\r\n` and lone `\r` line endings, including a `\r\n` split across
17//! two chunks.
18//! - A leading UTF-8 byte-order mark.
19//! - A field with no colon, an empty field value, and unknown field names.
20//! - A frame with no `data` field, which the format says not to dispatch.
21//!
22//! What it refuses: invalid UTF-8, and a single frame larger than
23//! [`SseDecoder::max_frame_size`] — an unterminated line is otherwise an
24//! unbounded allocation driven by the other end. The cap is on the frame, not
25//! on the chunk: one read carrying a thousand complete frames is ordinary, and
26//! counting it against a per-frame limit would refuse a well-behaved server.
27//!
28//! ```
29//! use ag_ui::client::transport::SseDecoder;
30//!
31//! let mut decoder = SseDecoder::new();
32//! decoder.push(b": keep-alive\n\ndata: {\"type\":\"RUN_ERROR\",\"mes")?;
33//! assert!(decoder.next_frame()?.is_none());
34//!
35//! decoder.push(b"sage\":\"boom\"}\n\n")?;
36//! let frame = decoder.next_frame()?.expect("a complete frame");
37//! assert_eq!(frame.into_event()?.event_type().as_str(), "RUN_ERROR");
38//! # Ok::<(), ag_ui::client::Error>(())
39//! ```
40
41use std::collections::VecDeque;
42
43use crate::Event;
44use futures_core::Stream;
45use futures_util::StreamExt;
46
47use crate::client::error::{Error, Result};
48
49/// The default cap on one frame, before the decoder gives up: 8 MiB.
50pub const DEFAULT_MAX_FRAME_SIZE: usize = 8 * 1024 * 1024;
51
52/// One decoded `text/event-stream` frame.
53#[derive(Clone, Debug, Default, PartialEq, Eq)]
54pub struct SseFrame {
55 /// The `event:` field, if the server sent one. AG-UI does not use it.
56 pub event: Option<String>,
57 /// The `data:` field, with the trailing newline removed and multiple
58 /// `data:` lines rejoined with `\n`.
59 pub data: String,
60 /// The `id:` field, if the server sent one.
61 pub id: Option<String>,
62 /// The `retry:` field, in milliseconds, if the server sent one.
63 pub retry: Option<u64>,
64}
65
66impl SseFrame {
67 /// Parses the frame's payload as an AG-UI event.
68 pub fn into_event(self) -> Result<Event> {
69 serde_json::from_str(&self.data).map_err(Error::from)
70 }
71}
72
73/// An incremental `text/event-stream` decoder.
74///
75/// Push bytes, pull frames. It owns a buffer for the partial line at the end of
76/// each chunk, which is why it has to be a struct and not a function.
77#[derive(Clone, Debug)]
78pub struct SseDecoder {
79 buffer: Vec<u8>,
80 /// How much of `buffer` has already been handed out as lines.
81 ///
82 /// The consumed prefix is dropped on the next push rather than as each line
83 /// is taken: shifting the remainder once per read instead of once per line
84 /// is what keeps a chunk carrying a thousand frames linear.
85 consumed: usize,
86 data: String,
87 event: Option<String>,
88 id: Option<String>,
89 retry: Option<u64>,
90 max_frame_size: usize,
91 at_start: bool,
92}
93
94impl Default for SseDecoder {
95 fn default() -> Self {
96 Self::new()
97 }
98}
99
100impl SseDecoder {
101 /// A decoder with the default frame size cap.
102 pub fn new() -> Self {
103 Self {
104 buffer: Vec::new(),
105 consumed: 0,
106 data: String::new(),
107 event: None,
108 id: None,
109 retry: None,
110 max_frame_size: DEFAULT_MAX_FRAME_SIZE,
111 at_start: true,
112 }
113 }
114
115 /// Sets the cap on a single frame.
116 #[must_use]
117 pub fn with_max_frame_size(mut self, bytes: usize) -> Self {
118 self.max_frame_size = bytes;
119 self
120 }
121
122 /// The cap on a single frame, in bytes.
123 pub fn max_frame_size(&self) -> usize {
124 self.max_frame_size
125 }
126
127 /// Feeds the decoder a chunk of the response body.
128 ///
129 /// # Errors
130 ///
131 /// [`Error::Decode`] when the frame being assembled exceeds
132 /// [`SseDecoder::max_frame_size`] — a server that never sends a line break
133 /// would otherwise grow this buffer without bound.
134 pub fn push(&mut self, bytes: &[u8]) -> Result<()> {
135 if self.consumed > 0 {
136 self.buffer.drain(..self.consumed);
137 self.consumed = 0;
138 }
139 self.buffer.extend_from_slice(bytes);
140 self.check_frame_size()
141 }
142
143 /// The bytes pushed but not yet handed out as lines.
144 fn pending(&self) -> &[u8] {
145 &self.buffer[self.consumed..]
146 }
147
148 /// Checks the frame under construction against the cap.
149 ///
150 /// What counts is the payload accumulated so far plus the bytes that cannot
151 /// yet *become* a line — everything up to the last line terminator is a
152 /// complete line [`next_frame`](Self::next_frame) will drain, so it does
153 /// not. The cap is on one frame, not on how much a transport happens to
154 /// hand over at once: a single 1 MiB chunk of complete frames is ordinary,
155 /// and rejecting it would break a perfectly well-behaved server.
156 fn check_frame_size(&self) -> Result<()> {
157 let pending = self.pending();
158 let unterminated = match pending.iter().rposition(|b| *b == b'\n' || *b == b'\r') {
159 Some(position) => pending.len() - position - 1,
160 None => pending.len(),
161 };
162 if unterminated + self.data.len() > self.max_frame_size {
163 return Err(Error::decode(format!(
164 "frame exceeded the {} byte limit before a line break",
165 self.max_frame_size
166 )));
167 }
168 Ok(())
169 }
170
171 /// Pulls the next complete frame, if the bytes pushed so far contain one.
172 ///
173 /// # Errors
174 ///
175 /// [`Error::Decode`] when the bytes are not UTF-8.
176 pub fn next_frame(&mut self) -> Result<Option<SseFrame>> {
177 while let Some(line) = self.take_line(false)? {
178 if let Some(frame) = self.process(&line) {
179 return Ok(Some(frame));
180 }
181 // A frame made of ten million `data:` lines never reaches
182 // [`push`]'s check, because every one of them is terminated.
183 if self.data.len() > self.max_frame_size {
184 return Err(Error::decode(format!(
185 "frame exceeded the {} byte limit across its data lines",
186 self.max_frame_size
187 )));
188 }
189 }
190 Ok(None)
191 }
192
193 /// Drains the decoder at the end of the stream.
194 ///
195 /// A body that stops without the blank line that terminates its last frame
196 /// is common enough — a closed connection, a server that forgets — that
197 /// dropping the frame would lose real events. Anything still buffered is
198 /// dispatched here.
199 ///
200 /// # Errors
201 ///
202 /// [`Error::Decode`] when the trailing bytes are not UTF-8.
203 pub fn finish(&mut self) -> Result<Vec<SseFrame>> {
204 let mut frames = Vec::new();
205 while let Some(line) = self.take_line(true)? {
206 if let Some(frame) = self.process(&line) {
207 frames.push(frame);
208 }
209 }
210 if let Some(frame) = self.dispatch() {
211 frames.push(frame);
212 }
213 Ok(frames)
214 }
215
216 /// Takes one complete line, minus its terminator.
217 ///
218 /// At `eof` the trailing bytes count as a line even without a terminator.
219 /// Otherwise a `\r` at the very end of the buffer is held back: it may yet
220 /// turn out to be the first half of a `\r\n` split across two chunks.
221 fn take_line(&mut self, eof: bool) -> Result<Option<String>> {
222 let pending = self.pending();
223 let Some(position) = pending.iter().position(|b| *b == b'\n' || *b == b'\r') else {
224 if eof && !pending.is_empty() {
225 let line = pending.to_vec();
226 self.consumed = self.buffer.len();
227 return self.decode_line(line).map(Some);
228 }
229 return Ok(None);
230 };
231
232 let is_cr = pending[position] == b'\r';
233 if is_cr && position + 1 == pending.len() && !eof {
234 return Ok(None);
235 }
236
237 let width = if is_cr && pending.get(position + 1) == Some(&b'\n') {
238 2
239 } else {
240 1
241 };
242 let line = pending[..position].to_vec();
243 self.consumed += position + width;
244 self.decode_line(line).map(Some)
245 }
246
247 fn decode_line(&mut self, line: Vec<u8>) -> Result<String> {
248 let mut line = String::from_utf8(line)
249 .map_err(|error| Error::decode(format!("stream is not UTF-8: {error}")))?;
250 if self.at_start {
251 self.at_start = false;
252 if let Some(stripped) = line.strip_prefix('\u{feff}') {
253 line = stripped.to_owned();
254 }
255 }
256 Ok(line)
257 }
258
259 /// Applies one line to the frame being built, dispatching on a blank line.
260 fn process(&mut self, line: &str) -> Option<SseFrame> {
261 if line.is_empty() {
262 return self.dispatch();
263 }
264 if line.starts_with(':') {
265 // A comment. Heartbeats are the usual reason one is here.
266 return None;
267 }
268
269 let (field, value) = match line.find(':') {
270 Some(index) => (&line[..index], strip_one_space(&line[index + 1..])),
271 // A field name with no colon has an empty value.
272 None => (line, ""),
273 };
274
275 match field {
276 "data" => {
277 self.data.push_str(value);
278 self.data.push('\n');
279 }
280 "event" => self.event = Some(value.to_owned()),
281 "id" => self.id = Some(value.to_owned()),
282 "retry" => {
283 if let Ok(milliseconds) = value.parse() {
284 self.retry = Some(milliseconds);
285 }
286 }
287 // Unknown fields are ignored, as the format requires.
288 _ => {}
289 }
290 None
291 }
292
293 /// Emits the buffered frame, if it has a payload.
294 fn dispatch(&mut self) -> Option<SseFrame> {
295 let event = self.event.take();
296 let id = self.id.take();
297 let retry = self.retry.take();
298 let mut data = std::mem::take(&mut self.data);
299 if data.is_empty() {
300 // No data field: the format says this dispatches nothing. A frame
301 // of pure comments or a stray blank line lands here.
302 return None;
303 }
304 data.pop();
305 Some(SseFrame {
306 event,
307 data,
308 id,
309 retry,
310 })
311 }
312}
313
314/// Removes the single optional space after a field's colon.
315fn strip_one_space(value: &str) -> &str {
316 value.strip_prefix(' ').unwrap_or(value)
317}
318
319/// Decodes a stream of byte chunks into a stream of events.
320///
321/// This is the adapter between a transport's body stream and the rest of the
322/// crate. Errors from the byte stream become [`Error::Transport`] items and end
323/// the stream; a frame whose payload is not a valid event becomes an error item
324/// and the stream continues, because one malformed event should not silence the
325/// rest of the run.
326pub fn decode_events<S, B, E>(chunks: S) -> impl Stream<Item = Result<Event>>
327where
328 S: Stream<Item = core::result::Result<B, E>>,
329 B: AsRef<[u8]>,
330 E: std::error::Error + Send + Sync + 'static,
331{
332 let state = Decoding {
333 chunks: Box::pin(chunks),
334 decoder: SseDecoder::new(),
335 ready: VecDeque::new(),
336 done: false,
337 };
338
339 futures_util::stream::unfold(state, |mut state| async move {
340 loop {
341 if let Some(item) = state.ready.pop_front() {
342 return Some((item, state));
343 }
344 if state.done {
345 return None;
346 }
347
348 match state.chunks.next().await {
349 Some(Ok(bytes)) => {
350 if let Err(error) = state.decoder.push(bytes.as_ref()) {
351 state.done = true;
352 return Some((Err(error), state));
353 }
354 loop {
355 match state.decoder.next_frame() {
356 Ok(Some(frame)) => state.ready.push_back(frame.into_event()),
357 Ok(None) => break,
358 Err(error) => {
359 state.done = true;
360 state.ready.push_back(Err(error));
361 break;
362 }
363 }
364 }
365 }
366 Some(Err(error)) => {
367 state.done = true;
368 return Some((Err(Error::transport(error)), state));
369 }
370 None => {
371 state.done = true;
372 match state.decoder.finish() {
373 Ok(frames) => state
374 .ready
375 .extend(frames.into_iter().map(SseFrame::into_event)),
376 Err(error) => state.ready.push_back(Err(error)),
377 }
378 }
379 }
380 }
381 })
382}
383
384/// The state [`decode_events`] carries between polls.
385struct Decoding<S> {
386 chunks: std::pin::Pin<Box<S>>,
387 decoder: SseDecoder,
388 ready: VecDeque<Result<Event>>,
389 done: bool,
390}
391
392#[cfg(test)]
393mod tests {
394 use super::SseDecoder;
395
396 /// A body of `frames` identical two-line frames, and the size of one.
397 fn body(frames: usize) -> (String, usize) {
398 let frame = "data: {\"type\":\"CUSTOM\"}\n\n";
399 (frame.repeat(frames), frame.len())
400 }
401
402 #[test]
403 fn taking_a_line_moves_the_cursor_instead_of_the_bytes_behind_it() {
404 // `take_line` used to `split_off` the remainder of the buffer, copying
405 // every byte still unread once per line. One read carrying a thousand
406 // frames — a fast agent filling a TCP window — then cost a thousand
407 // copies of a shrinking buffer, and the total grew with the square of
408 // the frame count. The cursor is what makes it linear, and the buffer
409 // holding still while frames come out of it is how that is visible
410 // without timing anything.
411 let (body, frame_size) = body(64);
412 let mut decoder = SseDecoder::new();
413 decoder.push(body.as_bytes()).expect("pushes");
414 let pushed = decoder.buffer.len();
415
416 for taken in 1..=32 {
417 decoder
418 .next_frame()
419 .expect("decodes")
420 .expect("64 frames went in");
421 assert_eq!(
422 decoder.buffer.len(),
423 pushed,
424 "the unread remainder was recopied after {taken} frames"
425 );
426 assert_eq!(decoder.consumed, taken * frame_size);
427 }
428
429 // The consumed prefix is dropped once, on the next push, rather than
430 // once per line.
431 decoder.push(b"data: x\n\n").expect("pushes");
432 assert_eq!(decoder.consumed, 0);
433 assert_eq!(decoder.buffer.len(), pushed - 32 * frame_size + 9);
434 }
435
436 #[test]
437 fn dropping_the_consumed_prefix_does_not_disturb_what_is_still_buffered() {
438 // That drop rewrites the offset every remaining byte sits at, so the
439 // frames either side of a push have to come out whole and in order.
440 let (body, _) = body(4);
441 let mut decoder = SseDecoder::new();
442 decoder.push(body.as_bytes()).expect("pushes");
443 decoder.next_frame().expect("decodes").expect("a frame");
444 decoder.next_frame().expect("decodes").expect("a frame");
445
446 decoder.push(b"data: last\n\n").expect("pushes");
447 let mut rest = Vec::new();
448 while let Some(frame) = decoder.next_frame().expect("decodes") {
449 rest.push(frame.data);
450 }
451 assert_eq!(
452 rest,
453 [r#"{"type":"CUSTOM"}"#, r#"{"type":"CUSTOM"}"#, "last"]
454 );
455 }
456}