Skip to main content

rig_http/http_client/
framing.rs

1//! Incremental SSE and newline-delimited framing of response bytes.
2//! SSE dispatch requires a blank line; incomplete events are not flushed at EOF.
3//! NDJSON permits a final unterminated line through [`NdjsonFramer::finish`].
4//!
5//! ```
6//! use rig_http::http_client::framing::NdjsonFramer;
7//!
8//! let mut framer = NdjsonFramer::new();
9//! assert_eq!(framer.push(b"{}\n").collect::<Vec<_>>(), vec![b"{}".to_vec()]);
10//! ```
11
12use std::borrow::Cow;
13
14/// How a reply's bytes are split into wire frames.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Framing {
17    /// `text/event-stream`: WHATWG event-stream grammar ([`SseFramer`]).
18    Sse,
19    /// Newline-delimited JSON ([`NdjsonFramer`]).
20    Ndjson,
21    /// The whole body is one frame.
22    Whole,
23}
24
25impl Framing {
26    /// The incremental framer of a streamed reply. A whole body is one
27    /// frame, so it has none.
28    pub fn framer(self) -> Option<Framer> {
29        let lines = match self {
30            Self::Sse => Lines::Sse(SseFramer::new()),
31            Self::Ndjson => Lines::Ndjson(NdjsonFramer::new()),
32            Self::Whole => return None,
33        };
34        Some(Framer { lines })
35    }
36
37    /// Split a whole reply body into the frames a decoder reads: one per SSE
38    /// event with a non-blank `data` field, one per NDJSON line, or the
39    /// whole body.
40    ///
41    /// ```
42    /// use rig_http::http_client::framing::{Framing, WireFrame};
43    ///
44    /// let frames = Framing::Sse.split(b"data: {}\n\ndata: [DONE]\n\n");
45    /// assert_eq!(frames.len(), 2);
46    /// assert_eq!(frames[1].as_str(), "[DONE]");
47    /// ```
48    pub fn split(&self, bytes: &[u8]) -> Vec<WireFrame> {
49        let Some(mut framer) = self.framer() else {
50            return (!bytes.is_empty())
51                .then(|| WireFrame::from_bytes(bytes.to_vec()))
52                .into_iter()
53                .collect();
54        };
55        let mut frames: Vec<WireFrame> = framer.push(bytes).collect();
56        frames.extend(framer.finish());
57        frames
58    }
59}
60
61/// One transport frame, after framing but before decoding.
62///
63/// The transport layer (SSE framer, NDJSON splitter, websocket reader) owns
64/// byte splitting and yields these; a decoder never splits bytes.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum WireFrame {
67    /// Text payload, such as an SSE `data:` field or WebSocket message.
68    Text(String),
69    /// Byte payload, such as an NDJSON line or binary frame.
70    Bytes(Vec<u8>),
71}
72
73impl WireFrame {
74    /// The frame payload as text (lossy for byte frames).
75    pub fn as_str(&self) -> Cow<'_, str> {
76        match self {
77            Self::Text(text) => Cow::Borrowed(text),
78            Self::Bytes(bytes) => String::from_utf8_lossy(bytes),
79        }
80    }
81
82    /// The frame payload's bytes.
83    pub fn as_bytes(&self) -> &[u8] {
84        match self {
85            Self::Text(text) => text.as_bytes(),
86            Self::Bytes(bytes) => bytes,
87        }
88    }
89
90    /// A frame of `bytes`: text when they are UTF-8.
91    pub fn from_bytes(bytes: Vec<u8>) -> Self {
92        match String::from_utf8(bytes) {
93            Ok(text) => Self::Text(text),
94            Err(error) => Self::Bytes(error.into_bytes()),
95        }
96    }
97}
98
99/// The incremental framer of one streamed reply, from [`Framing::framer`].
100#[derive(Debug)]
101pub struct Framer {
102    lines: Lines,
103}
104
105#[derive(Debug)]
106enum Lines {
107    Sse(SseFramer),
108    Ndjson(NdjsonFramer),
109}
110
111impl Framer {
112    /// Feed one transport chunk; yields every frame it completed. An SSE
113    /// event whose data is only whitespace is a heartbeat, not a frame.
114    pub fn push(&mut self, chunk: &[u8]) -> impl Iterator<Item = WireFrame> + '_ {
115        let (sse, ndjson) = match &mut self.lines {
116            Lines::Sse(framer) => (Some(framer.push(chunk)), None),
117            Lines::Ndjson(framer) => (None, Some(framer.push(chunk))),
118        };
119        let events = sse.into_iter().flatten();
120        let events = events.filter(|event| !event.data.trim().is_empty());
121        let lines = ndjson.into_iter().flatten().map(WireFrame::from_bytes);
122        events.map(|event| WireFrame::Text(event.data)).chain(lines)
123    }
124
125    /// The stream ended: NDJSON's unterminated last line. SSE dispatches
126    /// only on a blank line, so an unterminated event is never a frame.
127    pub fn finish(&mut self) -> Option<WireFrame> {
128        match &mut self.lines {
129            Lines::Sse(_) => None,
130            Lines::Ndjson(framer) => framer.finish().map(WireFrame::from_bytes),
131        }
132    }
133
134    /// Bytes pushed but not yet part of a frame, for truncation
135    /// diagnostics: [`SseFramer::pending`] or [`NdjsonFramer::pending`].
136    pub fn pending(&self) -> usize {
137        match &self.lines {
138            Lines::Sse(framer) => framer.pending(),
139            Lines::Ndjson(framer) => framer.pending(),
140        }
141    }
142}
143
144/// One dispatched `text/event-stream` event.
145#[derive(Debug, Clone, Default, PartialEq, Eq)]
146pub struct SseEvent {
147    /// The `event:` field, `"message"` when the stream named none.
148    pub event: Cow<'static, str>,
149    /// The joined `data:` fields, without the terminating newline.
150    pub data: String,
151    /// The stream's last event id, empty when none was ever set.
152    pub id: String,
153    /// Most recent parsed `retry:` value since the previous emitted event.
154    /// This value does not trigger automatic reconnection.
155    pub retry: Option<u64>,
156}
157
158/// The optional leading byte-order mark, which the grammar ignores.
159const BOM: [u8; 3] = [0xef, 0xbb, 0xbf];
160
161/// A push parser for the WHATWG `text/event-stream` grammar.
162#[derive(Debug, Default)]
163pub struct SseFramer {
164    /// Bytes not yet consumed as a complete line.
165    buffer: Vec<u8>,
166    /// How many bytes at the start of `buffer` hold no line end.
167    scanned: usize,
168    /// Events completed by the current `push`.
169    ready: Vec<SseEvent>,
170    event_type: String,
171    data: String,
172    last_event_id: String,
173    retry: Option<u64>,
174    /// How many BOM bytes have matched so far, while the prefix is undecided.
175    bom_prefix: usize,
176    bom_done: bool,
177    /// Bytes of complete lines consumed since the last blank line.
178    since_dispatch: usize,
179}
180
181impl SseFramer {
182    /// A framer at the start of a stream.
183    pub fn new() -> Self {
184        Self::default()
185    }
186
187    /// Feed one transport chunk; yields every event it completed.
188    pub fn push(&mut self, chunk: &[u8]) -> std::vec::Drain<'_, SseEvent> {
189        let chunk = self.strip_bom(chunk);
190        self.buffer.extend_from_slice(chunk);
191        // Taken out, so its lines are read in place while `line` changes the rest.
192        let buffer = std::mem::take(&mut self.buffer);
193        let mut rest = buffer.as_slice();
194        while let Some((line, next)) = terminated_line(rest, self.scanned) {
195            self.scanned = 0;
196            self.since_dispatch = self.since_dispatch.saturating_add(rest.len() - next.len());
197            self.line(&String::from_utf8_lossy(line));
198            rest = next;
199        }
200        // What is left holds no line end, unless its last byte is a `\r`
201        // waiting to see whether a `\n` follows.
202        self.scanned = rest.len().saturating_sub(1);
203        let consumed = buffer.len() - rest.len();
204        self.buffer = buffer;
205        self.buffer.drain(..consumed);
206        self.ready.drain(..)
207    }
208
209    /// Saturating count of buffered bytes and complete lines since the last
210    /// blank line. Excludes any undecided leading BOM prefix; exposes no frame data.
211    pub fn pending(&self) -> usize {
212        self.since_dispatch.saturating_add(self.buffer.len())
213    }
214
215    /// Consume the stream's optional leading BOM, which may itself be split
216    /// across chunks. Returns the chunk with any BOM bytes removed.
217    fn strip_bom<'a>(&mut self, chunk: &'a [u8]) -> &'a [u8] {
218        if self.bom_done {
219            return chunk;
220        }
221        let mut rest = chunk;
222        while let Some(&byte) = rest.first() {
223            if BOM.get(self.bom_prefix) != Some(&byte) {
224                // Not a BOM after all: replay the bytes that matched so far.
225                let matched = std::mem::take(&mut self.bom_prefix);
226                self.bom_done = true;
227                self.buffer
228                    .extend_from_slice(BOM.get(..matched).unwrap_or_default());
229                return rest;
230            }
231            self.bom_prefix += 1;
232            rest = rest.get(1..).unwrap_or_default();
233            if self.bom_prefix == BOM.len() {
234                self.bom_prefix = 0;
235                self.bom_done = true;
236                return rest;
237            }
238        }
239        rest
240    }
241
242    /// Process one complete line of the grammar.
243    fn line(&mut self, line: &str) {
244        if line.is_empty() {
245            self.dispatch();
246            return;
247        }
248        // A line starting with a colon is a comment.
249        if let Some(rest) = line.strip_prefix(':') {
250            let _ = rest;
251            return;
252        }
253        let (field, value) = match line.split_once(':') {
254            Some((field, value)) => (field, value.strip_prefix(' ').unwrap_or(value)),
255            None => (line, ""),
256        };
257        match field {
258            "event" => {
259                self.event_type.clear();
260                self.event_type.push_str(value);
261            }
262            "data" => {
263                self.data.push_str(value);
264                self.data.push('\n');
265            }
266            // A NUL in an id is ignored outright, per the grammar.
267            "id" if !value.contains('\0') => {
268                self.last_event_id.clear();
269                self.last_event_id.push_str(value);
270            }
271            "retry" if !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) => {
272                self.retry = value.parse().ok();
273            }
274            _ => {}
275        }
276    }
277
278    /// A blank line ends an event. An event with no data is not dispatched:
279    /// it only resets the event type, as the grammar says.
280    fn dispatch(&mut self) {
281        self.since_dispatch = 0;
282        if self.data.is_empty() {
283            self.event_type.clear();
284            return;
285        }
286        self.data.pop();
287        let event = if self.event_type.is_empty() {
288            Cow::Borrowed("message")
289        } else {
290            Cow::Owned(std::mem::take(&mut self.event_type))
291        };
292        self.ready.push(SseEvent {
293            event,
294            data: std::mem::take(&mut self.data),
295            id: self.last_event_id.clone(),
296            retry: self.retry.take(),
297        });
298        self.event_type.clear();
299    }
300}
301
302/// Splits nonempty newline-delimited payloads without validating JSON.
303/// A nonempty unterminated last line is available through [`Self::finish`].
304#[derive(Debug, Default)]
305pub struct NdjsonFramer {
306    buffer: Vec<u8>,
307    /// How many bytes at the start of `buffer` hold no line end.
308    scanned: usize,
309    ready: Vec<Vec<u8>>,
310}
311
312impl NdjsonFramer {
313    /// A framer at the start of a stream.
314    pub fn new() -> Self {
315        Self::default()
316    }
317
318    /// Feed one transport chunk; yields every line it completed. Blank lines
319    /// are not frames.
320    pub fn push(&mut self, chunk: &[u8]) -> std::vec::Drain<'_, Vec<u8>> {
321        self.buffer.extend_from_slice(chunk);
322        let mut rest = self.buffer.as_slice();
323        while let Some((line, end)) = split_line(rest, self.scanned, |byte| *byte == b'\n') {
324            self.scanned = 0;
325            let line = line.strip_suffix(b"\r").unwrap_or(line);
326            if !line.is_empty() {
327                self.ready.push(line.to_vec());
328            }
329            rest = end.get(1..).unwrap_or_default();
330        }
331        self.scanned = rest.len();
332        let consumed = self.buffer.len() - rest.len();
333        self.buffer.drain(..consumed);
334        self.ready.drain(..)
335    }
336
337    /// The unterminated last line, if the stream ended with one.
338    pub fn finish(&mut self) -> Option<Vec<u8>> {
339        let line = std::mem::take(&mut self.buffer);
340        (!line.is_empty()).then_some(line)
341    }
342
343    /// Bytes of an unterminated trailing line, for truncation diagnostics.
344    pub fn pending(&self) -> usize {
345        self.buffer.len()
346    }
347}
348
349/// The first complete line in `buffer`, without its line end, and the
350/// bytes after that line end; the first `scanned` bytes hold no line end.
351///
352/// Defers a trailing CR until another byte arrives so a split CRLF is consumed
353/// as one terminator.
354fn terminated_line(buffer: &[u8], scanned: usize) -> Option<(&[u8], &[u8])> {
355    match split_line(buffer, scanned, |byte| *byte == b'\n' || *byte == b'\r')? {
356        (line, [b'\r', b'\n', rest @ ..]) => Some((line, rest)),
357        (_, [b'\r'] | []) => None,
358        (line, [_, rest @ ..]) => Some((line, rest)),
359    }
360}
361
362/// `buffer` split before its first byte that `ends` a line, if it has one;
363/// the first `scanned` bytes hold none.
364fn split_line(buffer: &[u8], scanned: usize, ends: impl Fn(&u8) -> bool) -> Option<(&[u8], &[u8])> {
365    buffer.split_at_checked(scanned + buffer.iter().skip(scanned).position(ends)?)
366}
367
368#[cfg(test)]
369mod tests;