Skip to main content

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}