Skip to main content

gproxy_transform/transform/common/
sse.rs

1/// A provider-neutral SSE transport frame.
2///
3/// This is framing, not a model event IR. Pair modules still own event payload
4/// conversion after JSON decoding.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct SseFrame {
7    pub event: Option<String>,
8    pub data: String,
9}
10
11impl SseFrame {
12    pub fn data(data: impl Into<String>) -> Self {
13        Self {
14            event: None,
15            data: data.into(),
16        }
17    }
18
19    pub fn event(event: impl Into<String>, data: impl Into<String>) -> Self {
20        Self {
21            event: Some(event.into()),
22            data: data.into(),
23        }
24    }
25
26    pub fn encode(&self) -> String {
27        let mut encoded = String::new();
28        if let Some(event) = &self.event {
29            encoded.push_str("event: ");
30            encoded.push_str(event);
31            encoded.push('\n');
32        }
33        for line in self.data.lines() {
34            encoded.push_str("data: ");
35            encoded.push_str(line);
36            encoded.push('\n');
37        }
38        encoded.push('\n');
39        encoded
40    }
41}
42
43/// Incremental SSE frame decoder: feed raw body chunks, drain complete frames.
44/// Tolerates CRLF, multi-line `data:`, and frames split across chunk
45/// boundaries — including chunks split inside a multi-byte UTF-8 character.
46/// Comments, `id:` and `retry:` lines are framing noise (dropped).
47#[derive(Debug, Default)]
48pub struct SseDecoder {
49    buf: String,
50    utf8: super::utf8::Utf8StreamDecoder,
51    limits: SseLimits,
52}
53
54/// Bounds for untrusted SSE input. Both limits include framing bytes.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct SseLimits {
57    /// Maximum bytes allowed in one blank-line-delimited SSE frame.
58    pub max_frame_bytes: usize,
59    /// Maximum bytes retained while waiting for complete SSE frames.
60    pub max_buffer_bytes: usize,
61}
62
63impl Default for SseLimits {
64    fn default() -> Self {
65        Self {
66            max_frame_bytes: 1024 * 1024,
67            max_buffer_bytes: 8 * 1024 * 1024,
68        }
69    }
70}
71
72impl SseDecoder {
73    /// Create a decoder with the default untrusted-input limits.
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    /// Create a decoder with explicit frame and buffer limits.
79    pub fn with_limits(limits: SseLimits) -> Self {
80        Self {
81            limits,
82            ..Self::default()
83        }
84    }
85
86    /// Append a chunk and return all complete (blank-line-terminated) frames.
87    /// SSE is text by definition; genuinely invalid UTF-8 is replaced lossily,
88    /// while an incomplete trailing sequence waits for the next chunk.
89    ///
90    /// Frames are parsed by BORROWING slices of the buffer behind an advancing
91    /// cursor; consumed bytes are drained once at the end. (The former
92    /// per-frame `drain(..).collect::<String>()` walked the frame char by char
93    /// AND memmoved the buffer tail per frame — the top CPU hotspot of every
94    /// streaming profile.)
95    pub fn push(
96        &mut self,
97        chunk: &[u8],
98    ) -> Result<Vec<SseFrame>, crate::transform::TransformError> {
99        self.push_inner(chunk)
100    }
101
102    fn push_inner(
103        &mut self,
104        chunk: &[u8],
105    ) -> Result<Vec<SseFrame>, crate::transform::TransformError> {
106        let pending = self.buf.len().saturating_add(chunk.len());
107        self.check_limit("buffer", self.limits.max_buffer_bytes, pending)?;
108        self.utf8.decode_into(chunk, &mut self.buf);
109        self.check_limit("buffer", self.limits.max_buffer_bytes, self.buf.len())?;
110        if self.buf.contains('\r') {
111            self.buf = self.buf.replace("\r\n", "\n");
112        }
113        let mut frames = Vec::new();
114        let mut cursor = 0;
115        while let Some(pos) = self.buf[cursor..].find("\n\n") {
116            let end = cursor + pos + 2;
117            self.check_limit("frame", self.limits.max_frame_bytes, end - cursor)?;
118            if let Some(frame) = parse_frame(&self.buf[cursor..end]) {
119                frames.push(frame);
120            }
121            cursor = end;
122        }
123        if cursor > 0 {
124            self.buf.drain(..cursor);
125        }
126        self.check_limit("frame", self.limits.max_frame_bytes, self.buf.len())?;
127        Ok(frames)
128    }
129
130    /// Drain a trailing, unterminated frame at end of stream (some upstreams
131    /// omit the final blank line).
132    pub fn finish(&mut self) -> Result<Option<SseFrame>, crate::transform::TransformError> {
133        self.finish_inner()
134    }
135
136    fn finish_inner(&mut self) -> Result<Option<SseFrame>, crate::transform::TransformError> {
137        self.utf8.flush(&mut self.buf);
138        self.check_limit("frame", self.limits.max_frame_bytes, self.buf.len())?;
139        let raw = std::mem::take(&mut self.buf);
140        Ok(parse_frame(&raw))
141    }
142
143    fn check_limit(
144        &self,
145        limit: &'static str,
146        max_bytes: usize,
147        actual_bytes: usize,
148    ) -> Result<(), crate::transform::TransformError> {
149        if actual_bytes <= max_bytes {
150            Ok(())
151        } else {
152            Err(crate::transform::TransformError::StreamLimitExceeded {
153                limit,
154                max_bytes,
155                actual_bytes,
156            })
157        }
158    }
159}
160
161fn parse_frame(raw: &str) -> Option<SseFrame> {
162    let mut event = None;
163    let mut data_lines: Vec<&str> = Vec::new();
164    for line in raw.lines() {
165        if let Some(rest) = line.strip_prefix("event:") {
166            event = Some(rest.trim_start().to_owned());
167        } else if let Some(rest) = line.strip_prefix("data:") {
168            data_lines.push(rest.strip_prefix(' ').unwrap_or(rest));
169        }
170    }
171    if data_lines.is_empty() {
172        return None;
173    }
174    Some(SseFrame {
175        event,
176        data: data_lines.join("\n"),
177    })
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn frame_split_across_chunks() {
186        let mut d = SseDecoder::new();
187        assert!(d.push(b"event: ping\nda").unwrap().is_empty());
188        let frames = d.push(b"ta: {\"a\":1}\n\n: comment\ndata: x").unwrap();
189        assert_eq!(frames.len(), 1);
190        assert_eq!(frames[0].event.as_deref(), Some("ping"));
191        assert_eq!(frames[0].data, "{\"a\":1}");
192        // trailing unterminated frame surfaces on finish()
193        assert_eq!(d.finish().unwrap().unwrap().data, "x");
194    }
195
196    #[test]
197    fn crlf_and_multiline_data() {
198        let mut d = SseDecoder::new();
199        let frames = d.push(b"data: l1\r\ndata: l2\r\n\r\n").unwrap();
200        assert_eq!(frames.len(), 1);
201        assert_eq!(frames[0].data, "l1\nl2");
202    }
203
204    #[test]
205    fn multibyte_char_split_across_chunks() {
206        // "data: 汉字\n\n" split inside the 3-byte "汉" — must not yield U+FFFD.
207        let mut d = SseDecoder::new();
208        let bytes = "data: 汉字\n\n".as_bytes();
209        assert!(d.push(&bytes[..7]).unwrap().is_empty()); // cuts "汉" after 1 byte
210        let frames = d.push(&bytes[7..]).unwrap();
211        assert_eq!(frames.len(), 1);
212        assert_eq!(frames[0].data, "汉字");
213    }
214
215    #[test]
216    fn rejects_oversized_frame_and_buffer() {
217        let limits = SseLimits {
218            max_frame_bytes: 16,
219            max_buffer_bytes: 32,
220        };
221        let mut d = SseDecoder::with_limits(limits);
222        assert!(d.push(b"data: 12345678901\n\n").is_err());
223
224        let mut d = SseDecoder::with_limits(limits);
225        assert!(d.push(&[b'x'; 33]).is_err());
226    }
227}