Skip to main content

uarp_sdk/
sse.rs

1//! Server-sent events: incremental frame parsing plus a reconnecting [`Stream`].
2
3use std::pin::Pin;
4use std::sync::Arc;
5use std::task::{Context, Poll};
6use std::time::{Duration, Instant};
7
8use futures_core::Stream;
9use futures_util::StreamExt;
10use serde::de::DeserializeOwned;
11
12use crate::client::{collect_headers, Inner};
13use crate::error::{ApiError, Error, Problem, Result};
14
15/// One decoded `text/event-stream` frame.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Event {
18    /// `id:` field (or the `event_id` carried inside a JSON payload), replayed
19    /// as `Last-Event-ID` when the stream reconnects.
20    pub id: Option<String>,
21    /// `event:` field; defaults to `message` or, when absent, to the `type`
22    /// field inside a JSON data payload.
23    pub event: String,
24    /// Concatenated `data:` lines, without the trailing newline.
25    pub data: String,
26    /// `retry:` field in milliseconds.
27    pub retry: Option<u64>,
28}
29
30impl Event {
31    /// Deserialize `data` as JSON.
32    pub fn json<T: DeserializeOwned>(&self) -> Result<T> {
33        serde_json::from_str(&self.data).map_err(Error::Decode)
34    }
35}
36
37/// Connection-lifecycle states reported by [`EventStream`] via
38/// [`StreamOptions::on_state`].
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum StreamState {
41    /// About to open (or reopen) the HTTP connection. Fired once, before the
42    /// first attempt.
43    Connecting,
44    /// The server answered 2xx and the stream is being read.
45    Connected,
46    /// Waiting on backoff before a reconnect attempt. `attempt` is 1-based.
47    Reconnecting(u32),
48    /// The stream ended without the caller dropping it (terminal frame,
49    /// `[DONE]`, or the reconnect budget exhausted). NOT fired on caller drop.
50    Disconnected,
51}
52
53/// Reconnection and lifecycle behaviour for [`EventStream`]. Every field
54/// defaults to a generic, spec-compliant SSE stream; the platform-specific
55/// knobs (terminal events, an inactivity watchdog, `retry:` pacing) are opt-in
56/// so a caller that passes [`Default::default`] gets standard SSE.
57pub struct StreamOptions {
58    /// Reconnect (replaying `Last-Event-ID`) when the stream ends. Default `true`.
59    pub reconnect: bool,
60    /// Reconnect attempts without progress before giving up. Default `5`.
61    pub max_reconnects: u32,
62    /// Event names that complete the stream WITHOUT reconnecting. Empty by
63    /// default: a generic stream reconnects on end and lets the caller stop it.
64    pub terminal_events: Vec<String>,
65    /// Max silence between reads before the socket is presumed dead and a
66    /// reconnect is attempted. `None` disables the watchdog (EOF owns
67    /// liveness). Mirrors the platform's 300 s inactivity timeout: collapsing
68    /// it with EOF made a silently-dead socket look like a finished stream.
69    pub inactivity_timeout: Option<Duration>,
70    /// Base reconnect interval; a `retry:` field overrides it per stream.
71    pub base_retry: Duration,
72    /// Cap on the reconnect backoff.
73    pub max_backoff: Duration,
74    /// Reconnect budget resets after this long connected without a disconnect,
75    /// so a long healthy stream doesn't carry "this is the Nth retry" baggage.
76    pub stability_reset: Duration,
77    /// Optional connection-lifecycle observer. `Disconnected` is NOT fired when
78    /// the caller drops the stream — only on a natural end. Held in an `Arc` so
79    /// [`StreamOptions`] stays [`Clone`] (it lives inside the cloneable
80    /// [`crate::RequestOptions`]); share state through the closure's captures.
81    pub on_state: Option<Arc<dyn Fn(StreamState) + Send + Sync>>,
82}
83
84impl Default for StreamOptions {
85    fn default() -> Self {
86        Self {
87            reconnect: true,
88            max_reconnects: 5,
89            terminal_events: Vec::new(),
90            inactivity_timeout: None,
91            base_retry: Duration::from_millis(2_000),
92            max_backoff: Duration::from_millis(8_000),
93            stability_reset: Duration::from_millis(60_000),
94            on_state: None,
95        }
96    }
97}
98
99impl Clone for StreamOptions {
100    fn clone(&self) -> Self {
101        Self {
102            reconnect: self.reconnect,
103            max_reconnects: self.max_reconnects,
104            terminal_events: self.terminal_events.clone(),
105            inactivity_timeout: self.inactivity_timeout,
106            base_retry: self.base_retry,
107            max_backoff: self.max_backoff,
108            stability_reset: self.stability_reset,
109            on_state: self.on_state.clone(),
110        }
111    }
112}
113
114impl std::fmt::Debug for StreamOptions {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.debug_struct("StreamOptions")
117            .field("reconnect", &self.reconnect)
118            .field("max_reconnects", &self.max_reconnects)
119            .field("terminal_events", &self.terminal_events)
120            .field("inactivity_timeout", &self.inactivity_timeout)
121            .field("base_retry", &self.base_retry)
122            .field("max_backoff", &self.max_backoff)
123            .field("stability_reset", &self.stability_reset)
124            .field("on_state", &self.on_state.as_ref().map(|_| "<stream state observer>"))
125            .finish()
126    }
127}
128
129/// A live SSE stream.
130///
131/// ```no_run
132/// # use futures_util::StreamExt;
133/// # async fn demo(client: uarp_sdk::Client) -> Result<(), uarp_sdk::Error> {
134/// let mut stream = client.runs().stream_run_events("run-id", &Default::default());
135/// while let Some(event) = stream.next().await {
136///     let event = event?;
137///     if event.event == "run.completed" { break; }
138/// }
139/// # Ok(()) }
140/// ```
141///
142/// Dropping the stream closes the connection.
143pub struct EventStream {
144    inner: Pin<Box<dyn Stream<Item = Result<Event>> + Send>>,
145}
146
147impl std::fmt::Debug for EventStream {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        f.debug_struct("EventStream").finish_non_exhaustive()
150    }
151}
152
153impl EventStream {
154    pub(crate) fn new(
155        client: Arc<Inner>,
156        url: Result<url::Url>,
157        headers: Vec<(String, String)>,
158        options: StreamOptions,
159    ) -> Self {
160        let on_state = options.on_state.clone();
161        let stream = async_stream::try_stream! {
162            let url = url?;
163            let mut last_event_id: Option<String> = None;
164            let mut attempt: u32 = 0;
165            let mut base_retry = options.base_retry;
166
167            if let Some(cb) = &on_state {
168                cb(StreamState::Connecting);
169            }
170
171            'reconnect: loop {
172                // A non-first attempt waits on backoff (and reports it) before
173                // reopening the connection, so `Reconnecting` precedes the
174                // sleep rather than racing it.
175                if attempt > 0 {
176                    if let Some(cb) = &on_state {
177                        cb(StreamState::Reconnecting(attempt));
178                    }
179                    tokio::time::sleep(stream_backoff(attempt, base_retry, options.max_backoff)).await;
180                }
181
182                let mut builder = client
183                    .http
184                    .request(reqwest::Method::GET, url.clone())
185                    .header(reqwest::header::ACCEPT, "text/event-stream")
186                    .header(reqwest::header::USER_AGENT, &client.user_agent)
187                    .headers(client.default_headers.clone());
188                // Same rule as the unary path: an empty key means "no
189                // credentials", and `Bearer ` with nothing after it is a
190                // credential a validating server can refuse.
191                if !client.api_key.is_empty() {
192                    builder = builder.header(
193                        reqwest::header::AUTHORIZATION,
194                        format!("Bearer {}", client.api_key),
195                    );
196                }
197                // On reconnect, replace any spec-supplied `Last-Event-ID` with
198                // the id the last delivered event carried. On the FIRST attempt
199                // (no event delivered yet) the caller-supplied id stays — a
200                // public replay route keys off it.
201                let resumed = &last_event_id;
202                for (name, value) in &headers {
203                    if resumed.is_some() && name.eq_ignore_ascii_case("Last-Event-ID") {
204                        continue;
205                    }
206                    builder = builder.header(name.as_str(), value);
207                }
208                if let Some(id) = resumed {
209                    builder = builder.header("Last-Event-ID", id);
210                }
211
212                let response = match builder.send().await {
213                    Ok(response) => response,
214                    Err(err) => {
215                        // 401 never comes back as a send error; transport errors
216                        // retry like a dropped connection while the budget lasts.
217                        if !options.reconnect || attempt >= options.max_reconnects {
218                            Err(if err.is_timeout() { Error::Timeout } else { Error::Connection(err) })?;
219                        }
220                        attempt += 1;
221                        continue 'reconnect;
222                    }
223                };
224
225                if !response.status().is_success() {
226                    let status = response.status().as_u16();
227                    // 401 always surfaces so the caller can act on it (the app
228                    // treats it as "stop the stream"); any other HTTP error
229                    // retries like a dropped connection while the budget lasts,
230                    // then surfaces.
231                    if status == 401 || !options.reconnect || attempt >= options.max_reconnects {
232                        let response_headers = collect_headers(response.headers());
233                        let problem = response
234                            .bytes()
235                            .await
236                            .ok()
237                            .and_then(|bytes| serde_json::from_slice::<Problem>(&bytes).ok())
238                            .unwrap_or_default();
239                        Err(Error::from(ApiError { status, problem, headers: response_headers }))?;
240                    }
241                    attempt += 1;
242                    continue 'reconnect;
243                }
244
245                if let Some(cb) = &on_state {
246                    cb(StreamState::Connected);
247                }
248
249                // A connection that delivered at least one event counts as
250                // progress and resets the reconnect budget; one that closed
251                // immediately does not, so a flapping server cannot spin here.
252                let mut delivered = false;
253                let mut terminal = false;
254                let mut parser = Parser::default();
255                let mut body = response.bytes_stream();
256                let connected_at = Instant::now();
257
258                'read: loop {
259                    // Per-read inactivity watchdog: a socket that goes silent
260                    // but stays open is NOT a finished stream — release the
261                    // read and reconnect with `Last-Event-ID` rather than
262                    // treating the silence as EOF. `tokio::time::timeout`
263                    // resolves (does not reject), so the caller's task is
264                    // untouched.
265                    let next = if let Some(timeout) = options.inactivity_timeout {
266                        match tokio::time::timeout(timeout, body.next()).await {
267                            Ok(inner) => inner,
268                            Err(_elapsed) => {
269                                if !options.reconnect || attempt >= options.max_reconnects {
270                                    break 'read;
271                                }
272                                attempt += 1;
273                                continue 'reconnect;
274                            }
275                        }
276                    } else {
277                        body.next().await
278                    };
279
280                    let chunk = match next {
281                        None => break 'read, // clean EOF — reconnect below
282                        Some(Err(_err)) => {
283                            // A mid-stream transport error is a dropped
284                            // connection, not a finished stream: reconnect
285                            // while the budget lasts (the error itself is not
286                            // surfaced unless the budget is exhausted).
287                            if !options.reconnect || attempt >= options.max_reconnects {
288                                break 'read;
289                            }
290                            attempt += 1;
291                            continue 'reconnect;
292                        }
293                        Some(Ok(chunk)) => chunk,
294                    };
295
296                    // A healthy connection that survived the stability window
297                    // shouldn't carry "this is the Nth retry" baggage into its
298                    // next disconnect.
299                    if attempt > 0 && connected_at.elapsed() >= options.stability_reset {
300                        attempt = 0;
301                    }
302
303                    let mut hit_terminal = false;
304                    for event in parser.push(&chunk) {
305                        if event.id.is_some() {
306                            last_event_id.clone_from(&event.id);
307                        }
308                        if let Some(retry) = event.retry.filter(|&retry| retry > 0) {
309                            base_retry = Duration::from_millis(retry);
310                        }
311                        delivered = true;
312                        let is_terminal_event = options.terminal_events.iter().any(|name| name == &event.event);
313                        yield event;
314                        if is_terminal_event {
315                            hit_terminal = true;
316                            break;
317                        }
318                    }
319                    // `data: [DONE]` may flush a pending event (returned in the
320                    // `push` above) and then sets `is_done` — a hard terminal,
321                    // no reconnect.
322                    if hit_terminal || parser.is_done() {
323                        terminal = true;
324                        break 'read;
325                    }
326                }
327
328                // Flush a frame left unterminated when the connection closed.
329                // After `[DONE]` or a terminal event the parser state is already
330                // reset, so this returns `None`; otherwise a trailing partial
331                // frame is delivered.
332                if !terminal {
333                    if let Some(event) = parser.finish() {
334                        if event.id.is_some() {
335                            last_event_id.clone_from(&event.id);
336                        }
337                        let is_terminal_event = options.terminal_events.iter().any(|name| name == &event.event);
338                        delivered = true;
339                        yield event;
340                        if is_terminal_event || parser.is_done() {
341                            terminal = true;
342                        }
343                    }
344                }
345
346                if terminal {
347                    if let Some(cb) = &on_state {
348                        cb(StreamState::Disconnected);
349                    }
350                    break 'reconnect;
351                }
352
353                // A clean EOF without a terminal frame is a proxy/socket drop
354                // mid-run, not a finished stream — reconnect with `Last-Event-ID`.
355                if delivered {
356                    attempt = 0;
357                }
358                if !options.reconnect || attempt >= options.max_reconnects {
359                    if let Some(cb) = &on_state {
360                        cb(StreamState::Disconnected);
361                    }
362                    break 'reconnect;
363                }
364                attempt += 1;
365            }
366        };
367        Self {
368            inner: Box::pin(stream),
369        }
370    }
371}
372
373impl Stream for EventStream {
374    type Item = Result<Event>;
375
376    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
377        self.inner.as_mut().poll_next(cx)
378    }
379}
380
381/// Half-deterministic, half-random backoff for SSE reconnects:
382/// `max_sleep/2 + rand(0..max_sleep/2)`, so it climbs with attempts but clients
383/// don't all wake on the same boundary. Mirrors the Kotlin `streamBackoff`;
384/// separate from the unary-retry [`crate::client::backoff`].
385fn stream_backoff(attempt: u32, base: Duration, max: Duration) -> Duration {
386    let base_ms = base.as_millis() as u64;
387    let max_ms = max.as_millis() as u64;
388    let shift = attempt.saturating_sub(1).min(31);
389    let exponential = base_ms.saturating_mul(1u64 << shift);
390    let max_sleep = max_ms.min(exponential);
391    let half = (max_sleep / 2).max(1);
392    // No RNG dependency: the low bits of a v4 UUID are already random, matching
393    // the unary-retry backoff's jitter source.
394    let jitter = (uuid::Uuid::new_v4().as_u128() as u64) % (half + 1);
395    Duration::from_millis(half + jitter)
396}
397
398/// Incremental `text/event-stream` decoder.
399///
400/// Handles the three wire shapes the platform emits — standard
401/// `text/event-stream` frames (`event:`/`id:`/`data:`/`retry:`, blank-line
402/// dispatch), a JSON object carried in an SSE comment
403/// (`:{"type":"…","event_id":"…"}`), and a bare NDJSON line
404/// (`{"type":"…","event_id":"…"}`) — plus the `data: [DONE]` hard terminal.
405/// A frame with no `data:` is not a deliverable event (an `id:`/`retry:`-only
406/// frame updates state only); `id` is per-frame, reset on dispatch.
407///
408/// Buffers bytes rather than text so multi-byte characters split across chunk
409/// boundaries survive.
410#[derive(Default)]
411pub(crate) struct Parser {
412    buffer: Vec<u8>,
413    data: Vec<String>,
414    event: String,
415    id: Option<String>,
416    retry: Option<u64>,
417    has_fields: bool,
418    done: bool,
419}
420
421impl Parser {
422    /// `true` once a `data: [DONE]` frame arrived — the stream terminates
423    /// without reconnecting.
424    pub(crate) fn is_done(&self) -> bool {
425        self.done
426    }
427
428    pub(crate) fn push(&mut self, chunk: &[u8]) -> Vec<Event> {
429        self.buffer.extend_from_slice(chunk);
430        let mut events = Vec::new();
431        while let Some(index) = self.buffer.iter().position(|byte| *byte == b'\n') {
432            let mut line = self.buffer.drain(..=index).collect::<Vec<u8>>();
433            line.pop(); // '\n'
434            if line.last() == Some(&b'\r') {
435                line.pop();
436            }
437            if let Some(event) = self.feed(&String::from_utf8_lossy(&line)) {
438                events.push(event);
439            }
440            if self.done {
441                // `[DONE]` is a hard terminal: stop decoding so a trailing
442                // keep-alive or partial line after it cannot emit.
443                self.buffer.clear();
444                break;
445            }
446        }
447        events
448    }
449
450    /// Flush a frame left unterminated when the connection closed.
451    pub(crate) fn finish(&mut self) -> Option<Event> {
452        if self.done {
453            return None;
454        }
455        if !self.buffer.is_empty() {
456            let line = std::mem::take(&mut self.buffer);
457            self.feed(&String::from_utf8_lossy(&line));
458        }
459        self.dispatch()
460    }
461
462    fn feed(&mut self, line: &str) -> Option<Event> {
463        if line.is_empty() {
464            return self.dispatch();
465        }
466
467        // SSE comment. The platform also carries a JSON payload in a comment
468        // (`:{"type":"…","event_id":"…"}`); that is a self-contained frame.
469        // A bare comment is a keep-alive.
470        if let Some(rest) = line.strip_prefix(':') {
471            let body = rest.trim();
472            if body.starts_with('{') {
473                return Some(self.inline_event(body));
474            }
475            return None;
476        }
477
478        // Bare NDJSON line — a self-contained frame with no field prefix.
479        if line.starts_with('{') {
480            return Some(self.inline_event(line));
481        }
482
483        let (field, value) = match line.find(':') {
484            Some(index) => (
485                &line[..index],
486                line[index + 1..]
487                    .strip_prefix(' ')
488                    .unwrap_or(&line[index + 1..]),
489            ),
490            None => (line, ""),
491        };
492        self.has_fields = true;
493        match field {
494            "event" => self.event = value.to_string(),
495            "data" => {
496                if value == "[DONE]" {
497                    self.done = true;
498                    // Flush a pending event, if any; `[DONE]` itself carries no
499                    // payload (and `dispatch` emits nothing when there is no
500                    // `data:`).
501                    return if !self.data.is_empty() || !self.event.is_empty() || self.id.is_some() {
502                        self.dispatch()
503                    } else {
504                        None
505                    };
506                }
507                self.data.push(value.to_string());
508            }
509            "id" => {
510                if !value.contains('\0') {
511                    self.id = Some(value.to_string());
512                }
513            }
514            "retry" => self.retry = value.parse().ok(),
515            _ => {} // unknown fields are ignored
516        }
517        None
518    }
519
520    fn dispatch(&mut self) -> Option<Event> {
521        if !self.has_fields {
522            return None;
523        }
524        let joined = self.data.join("\n");
525        // A frame with no `data:` is not a deliverable event: an `id:`/`retry:`-
526        // only frame updates state but carries nothing to emit.
527        if joined.is_empty() {
528            self.reset();
529            return None;
530        }
531        let resolved = if !self.event.is_empty() {
532            std::mem::take(&mut self.event)
533        } else {
534            extract_event_type(&joined).unwrap_or_else(|| "message".to_string())
535        };
536        let event = Event {
537            id: self.id.clone(),
538            event: resolved,
539            data: joined,
540            retry: self.retry.take(),
541        };
542        self.reset();
543        Some(event)
544    }
545
546    /// A comment-JSON or NDJSON frame: type and id live inside the JSON body.
547    /// Self-contained — does not mutate parser state.
548    fn inline_event(&self, body: &str) -> Event {
549        Event {
550            id: extract_field(body, "event_id"),
551            event: extract_event_type(body).unwrap_or_else(|| "message".to_string()),
552            data: body.to_string(),
553            retry: None,
554        }
555    }
556
557    fn reset(&mut self) {
558        self.data.clear();
559        self.event.clear();
560        self.id = None;
561        self.retry = None;
562        self.has_fields = false;
563        // `id` is per-frame: the platform's client resets it on dispatch, so an
564        // event's id is only the `id:` its own frame carried (or the `event_id`
565        // inside a JSON payload). The loop captures the emitted id for replay
566        // before this runs, so reconnect still resumes from the last event id.
567    }
568}
569
570/// Pull one string field out of a JSON body WITHOUT fully decoding it — the
571/// stream carries thousands of frames a minute, and a full parse per frame to
572/// learn its `type` is the difference between a smooth stream and a stuttering
573/// one. Honours escaped quotes so a `"` inside a value can't fool it.
574fn extract_field(json: &str, field: &str) -> Option<String> {
575    let mut needle = String::from("\"");
576    needle.push_str(field);
577    needle.push('"');
578    let start = json.find(&needle)?;
579    let bytes = json.as_bytes();
580    let mut i = start + needle.len();
581    while i < bytes.len() && (bytes[i] == b':' || bytes[i] == b' ') {
582        i += 1;
583    }
584    if i >= bytes.len() || bytes[i] != b'"' {
585        return None;
586    }
587    i += 1;
588    let value_start = i;
589    while i < bytes.len() {
590        match bytes[i] {
591            b'\\' => {
592                i += 2;
593                continue;
594            }
595            b'"' => break,
596            _ => i += 1,
597        }
598    }
599    if i <= value_start {
600        return None;
601    }
602    Some(json[value_start..i].to_string())
603}
604
605/// The `type` field of a JSON frame, peeked without decoding.
606fn extract_event_type(json: &str) -> Option<String> {
607    extract_field(json, "type")
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    #[test]
615    fn parses_a_simple_frame() {
616        let mut parser = Parser::default();
617        let events = parser.push(b"event: run.started\ndata: {\"run_id\":\"r1\"}\n\n");
618        assert_eq!(events.len(), 1);
619        assert_eq!(events[0].event, "run.started");
620        assert_eq!(events[0].data, "{\"run_id\":\"r1\"}");
621    }
622
623    #[test]
624    fn joins_multi_line_data_and_defaults_the_name() {
625        let mut parser = Parser::default();
626        let events = parser.push(b"data: one\ndata: two\n\n");
627        assert_eq!(events[0].event, "message");
628        assert_eq!(events[0].data, "one\ntwo");
629    }
630
631    #[test]
632    fn survives_chunk_boundaries_and_crlf() {
633        let mut parser = Parser::default();
634        assert!(parser.push(b"event: par").is_empty());
635        assert!(parser.push(b"tial\r\ndata: {\"a\":").is_empty());
636        let events = parser.push(b"1}\r\n\r\n");
637        assert_eq!(events.len(), 1);
638        assert_eq!(events[0].event, "partial");
639        assert_eq!(events[0].data, "{\"a\":1}");
640    }
641
642    #[test]
643    fn ignores_comments_and_unknown_fields() {
644        let mut parser = Parser::default();
645        let events = parser.push(b": keep-alive\nfoo: bar\ndata: hello\n\n");
646        assert_eq!(events.len(), 1);
647        assert_eq!(events[0].data, "hello");
648    }
649
650    #[test]
651    fn keeps_the_id_field() {
652        let mut parser = Parser::default();
653        let events = parser.push(b"id: 42\ndata: x\n\n");
654        assert_eq!(events[0].id.as_deref(), Some("42"));
655    }
656
657    #[test]
658    fn decodes_a_comment_json_frame() {
659        let mut parser = Parser::default();
660        let events = parser.push(b":{\"type\":\"progress\",\"event_id\":\"2\",\"pct\":10}\n");
661        assert_eq!(events.len(), 1);
662        assert_eq!(events[0].event, "progress");
663        assert_eq!(events[0].id.as_deref(), Some("2"));
664        assert_eq!(events[0].data, "{\"type\":\"progress\",\"event_id\":\"2\",\"pct\":10}");
665    }
666
667    #[test]
668    fn decodes_a_bare_ndjson_line() {
669        let mut parser = Parser::default();
670        let events = parser.push(b"{\"type\":\"progress\",\"event_id\":\"3\",\"pct\":20}\n");
671        assert_eq!(events.len(), 1);
672        assert_eq!(events[0].event, "progress");
673        assert_eq!(events[0].id.as_deref(), Some("3"));
674    }
675
676    #[test]
677    fn done_frame_terminates_without_itself_emitting() {
678        let mut parser = Parser::default();
679        let events = parser.push(b"data: {\"text\":\"hi\"}\n\ndata: [DONE]\n\n");
680        assert_eq!(events.len(), 1, "the [DONE] frame emits nothing on its own");
681        assert_eq!(events[0].data, "{\"text\":\"hi\"}");
682        assert!(parser.is_done());
683    }
684
685    #[test]
686    fn an_id_or_retry_only_frame_emits_nothing() {
687        let mut parser = Parser::default();
688        let events = parser.push(b"id: 99\nretry: 500\n\ndata: later\n\n");
689        assert_eq!(events.len(), 1, "the id/retry-only frame must not emit");
690        assert_eq!(events[0].data, "later");
691        assert_eq!(events[0].id.as_deref(), None, "id is per-frame: not carried from the id-only frame");
692    }
693
694    #[test]
695    fn id_is_per_frame_and_does_not_leak_across_frames() {
696        let mut parser = Parser::default();
697        let events = parser.push(b"id: 4\ndata: first\n\ndata: second\n\n");
698        assert_eq!(events.len(), 2);
699        assert_eq!(events[0].id.as_deref(), Some("4"));
700        assert_eq!(events[1].id.as_deref(), None, "the second frame had no id: line");
701    }
702
703    #[test]
704    fn done_frame_with_only_an_id_emits_nothing() {
705        let mut parser = Parser::default();
706        let events = parser.push(b"id: 7\ndata: [DONE]\n\n");
707        assert!(events.is_empty(), "no data: → nothing to emit, even with id");
708        assert!(parser.is_done());
709    }
710
711    #[derive(serde::Deserialize, PartialEq, Debug)]
712    struct ExpectedEvent {
713        id: Option<String>,
714        event: String,
715        data: String,
716        retry: Option<u64>,
717    }
718
719    #[test]
720    fn decodes_the_shared_mixed_format_fixture_to_the_locked_expected_output() {
721        // Kotlin locks mixed.expected.json; the four SDK ports replay the same
722        // bytes and must match. A parser that dropped comments or unknown lines
723        // (the stock SDK parser) would emit only the standard frames here.
724        // Embedded at compile time so the test is independent of the cwd that
725        // `cargo test` happens to run from.
726        let mixed = include_str!("../../../contract/sse-fixtures/mixed.txt");
727        let expected: Vec<ExpectedEvent> =
728            serde_json::from_str(include_str!("../../../contract/sse-fixtures/mixed.expected.json"))
729                .expect("expected fixture is valid JSON");
730
731        let mut parser = Parser::default();
732        let mut events = parser.push(mixed.as_bytes());
733        if let Some(trailing) = parser.finish() {
734            events.push(trailing);
735        }
736
737        assert_eq!(events.len(), expected.len(), "event count");
738        for (got, want) in events.iter().zip(expected.iter()) {
739            assert_eq!(got.id, want.id, "id mismatch");
740            assert_eq!(got.event, want.event, "event mismatch");
741            assert_eq!(got.data, want.data, "data mismatch");
742            assert_eq!(got.retry, want.retry, "retry mismatch");
743        }
744        assert!(parser.is_done(), "fixture ends with [DONE]");
745    }
746
747    #[test]
748    fn extract_field_honours_escaped_quotes() {
749        let json = r#"{"type":"x","msg":"she said \"hi\""}"#;
750        assert_eq!(extract_field(json, "type").as_deref(), Some("x"));
751        assert_eq!(extract_field(json, "msg").as_deref(), Some(r#"she said \"hi\""#));
752    }
753}