toolkit_contract/runtime/sse.rs
1//! Server-Sent Events (SSE) parser used by streaming clients.
2//!
3//! Translates a byte stream into a stream of typed events. Recognises:
4//! - `data: <json>` — emits `Ok(T)` after JSON-deserializing into `T`.
5//! - `event: error` — the next `data:` is parsed as a `ProblemDetails`
6//! wrapped in [`TransportError::Problem`].
7//! - `event: done` — terminates the stream.
8//!
9//! All other event types are ignored. Comments (lines starting with `:`) and
10//! blank lines are stripped per the SSE spec.
11//!
12//! Accumulated per-line and per-event buffers are bounded by
13//! [`MAX_ACCUMULATED_BYTES`] to protect against a peer that streams an
14//! unbounded line (no terminating `\n`) or an unbounded run of `data:` lines
15//! with no dispatching blank line — otherwise the buffer would grow without
16//! limit for the lifetime of a self-healing, indefinitely-reconnecting client.
17
18use std::collections::VecDeque;
19use std::pin::Pin;
20use std::sync::Arc;
21use std::sync::atomic::{AtomicU64, Ordering};
22use std::task::{Context, Poll};
23
24use bytes::{Bytes, BytesMut};
25use futures_core::Stream;
26use parking_lot::RwLock;
27use serde::de::DeserializeOwned;
28
29use toolkit_canonical_errors::Problem;
30
31use crate::runtime::transport_error::TransportError;
32
33/// Adapter that lifts a `Display`-only error into an `Error + Send + Sync + 'static`
34/// so it can be boxed into [`TransportError::Network`] without losing the
35/// original message.
36#[derive(Debug)]
37struct DisplayError(String);
38impl std::fmt::Display for DisplayError {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 f.write_str(&self.0)
41 }
42}
43impl std::error::Error for DisplayError {}
44
45/// Shared cell holding the latest seen SSE `id:` field. The streaming
46/// client clones this handle before constructing the parser; on stream
47/// interruption it reads the latest ID and re-issues the request with a
48/// `Last-Event-ID` header (per HTML5 `EventSource` spec).
49///
50/// Wraps `Arc<RwLock<Option<String>>>` as a newtype so the underlying lock
51/// implementation isn't part of the public surface — the `parking_lot` vs
52/// `tokio::sync` choice can change without a breaking SDK release.
53#[derive(Clone, Debug, Default)]
54pub struct LastEventId(Arc<RwLock<Option<String>>>);
55
56impl LastEventId {
57 /// Create an empty [`LastEventId`] cell.
58 #[must_use]
59 pub fn empty() -> Self {
60 Self::default()
61 }
62
63 /// Snapshot the latest ID value, if any.
64 #[must_use]
65 pub fn current(&self) -> Option<String> {
66 self.0.read().clone()
67 }
68
69 /// Replace the latest ID. `None` clears the cell (per HTML5 spec, an
70 /// empty `id:` field resets the saved value).
71 pub fn set(&self, value: Option<String>) {
72 *self.0.write() = value;
73 }
74}
75
76/// Maximum bytes the parser accumulates for a not-yet-terminated line ([`SseStream::buf`])
77/// or for a not-yet-dispatched event's `data:` payload ([`SseStream::event_data`])
78/// before treating the peer as protocol-violating and terminating the stream
79/// with [`TransportError::sse`]. Generous enough for any realistic single
80/// event; guards against unbounded memory growth from a misbehaving peer.
81const MAX_ACCUMULATED_BYTES: usize = 16 * 1024 * 1024;
82
83/// Shared monotonic counter bumped every time the parser receives a byte chunk
84/// from the wire — **including** chunks that carry only keepalive comments or
85/// other non-dispatching frames. Streaming clients snapshot it around an idle
86/// wait so a low-data-rate stream kept alive purely by `:keepalive` comments is
87/// recognised as *active* rather than *idle* (the idle timeout must be idle).
88///
89/// Wraps `Arc<AtomicU64>` as a newtype so the atomic choice isn't part of the
90/// public surface.
91#[derive(Clone, Debug, Default)]
92pub struct SseActivity(Arc<AtomicU64>);
93
94impl SseActivity {
95 /// Create a fresh activity counter at generation 0.
96 #[must_use]
97 pub fn new() -> Self {
98 Self::default()
99 }
100
101 /// Current activity generation. Compare two snapshots to detect whether any
102 /// wire chunk arrived in between.
103 #[must_use]
104 pub fn generation(&self) -> u64 {
105 self.0.load(Ordering::Relaxed)
106 }
107
108 /// Record that a wire chunk arrived.
109 fn bump(&self) {
110 self.0.fetch_add(1, Ordering::Relaxed);
111 }
112}
113
114/// Parse an SSE byte stream into a stream of typed events.
115///
116/// `bytes` is typically the byte-stream view of
117/// `toolkit_http::HttpResponse::into_body()` (adapted via
118/// [`crate::runtime::http::body_to_byte_stream`]). Errors from the inner
119/// stream are surfaced as [`TransportError::Network`].
120///
121/// To capture `id:` fields for `Last-Event-ID` reconnect, use
122/// [`parse_sse_stream_with_id`] and pass in a shared cell that the
123/// streaming client can read from.
124pub fn parse_sse_stream<T, S, E>(bytes: S) -> SseStream<T, S>
125where
126 T: DeserializeOwned + 'static,
127 S: Stream<Item = Result<Bytes, E>> + Unpin + 'static,
128 E: std::fmt::Display,
129{
130 parse_sse_stream_with_id(bytes, LastEventId::empty())
131}
132
133/// Same as [`parse_sse_stream`] but accepts a [`LastEventId`] cell that the
134/// parser updates whenever it encounters an `id:` field. Streaming clients
135/// hand the cell into the request-factory closure on reconnect to populate
136/// the `Last-Event-ID` header — per HTML5 `EventSource` spec.
137pub fn parse_sse_stream_with_id<T, S, E>(bytes: S, last_event_id: LastEventId) -> SseStream<T, S>
138where
139 T: DeserializeOwned + 'static,
140 S: Stream<Item = Result<Bytes, E>> + Unpin + 'static,
141 E: std::fmt::Display,
142{
143 SseStream {
144 inner: bytes,
145 buf: BytesMut::with_capacity(4 * 1024),
146 scan_from: 0,
147 pending: VecDeque::new(),
148 event_kind: None,
149 event_data: String::new(),
150 event_id: None,
151 done: false,
152 explicit_done: false,
153 last_event_id,
154 activity: SseActivity::new(),
155 _marker: std::marker::PhantomData,
156 }
157}
158
159/// Iterator yielded by [`parse_sse_stream`].
160pub struct SseStream<T, S> {
161 inner: S,
162 buf: BytesMut,
163 /// Prefix length of `buf`, in bytes, already confirmed to contain no
164 /// unconsumed `\n`. Lets [`find_line_end`] resume scanning from here
165 /// instead of rescanning the whole buffer on every poll — without this, a
166 /// single long unterminated line delivered over many small chunks costs
167 /// `O(total_length²)` instead of `O(total_length)`.
168 scan_from: usize,
169 pending: VecDeque<Result<T, TransportError>>,
170 /// Last `event:` value seen since the previous dispatch. `None` means
171 /// the implicit default `"message"`.
172 event_kind: Option<String>,
173 /// `data:` payload accumulated for the current event, with multiple
174 /// `data:` lines joined by `\n` (per W3C SSE spec).
175 event_data: String,
176 /// Last `id:` value seen for the current event. Per spec, the
177 /// last-event-id persists across dispatches; this field is just the
178 /// per-event scratch used to update [`LastEventId`] on dispatch.
179 event_id: Option<String>,
180 done: bool,
181 /// `true` only when the stream ended because an `event: done` frame was
182 /// actually dispatched — as opposed to `done` being set because the
183 /// underlying byte stream simply closed (peer disconnect, proxy timeout,
184 /// etc.). The streaming client uses this to tell "the peer said it's
185 /// finished" apart from "the connection just ended", so it can treat the
186 /// latter as reconnect-eligible instead of a silent success.
187 explicit_done: bool,
188 last_event_id: LastEventId,
189 activity: SseActivity,
190 _marker: std::marker::PhantomData<fn() -> T>,
191}
192
193impl<T, S> SseStream<T, S> {
194 /// Returns a clone of the shared cell that captures the latest `id:`
195 /// field seen on the stream. The streaming client uses this to populate
196 /// the `Last-Event-ID` header on reconnect.
197 #[must_use]
198 pub fn last_event_id_handle(&self) -> LastEventId {
199 self.last_event_id.clone()
200 }
201
202 /// `true` iff the stream ended because the peer explicitly dispatched an
203 /// `event: done` frame. `false` if the stream ended for any other reason
204 /// (byte stream closed, error) — including while still in progress, so
205 /// callers should only consult this after the stream has yielded `None`.
206 #[must_use]
207 pub fn saw_done_event(&self) -> bool {
208 self.explicit_done
209 }
210
211 /// Returns a clone of the shared wire-activity counter. The streaming client
212 /// snapshots it around an idle-timeout wait so keepalive-only traffic keeps
213 /// the stream alive (see [`SseActivity`]).
214 #[must_use]
215 pub fn activity_handle(&self) -> SseActivity {
216 self.activity.clone()
217 }
218}
219
220// `inner` is bounded by `Unpin` at construction; the rest of the fields are
221// trivially `Unpin`. Implement `Unpin` unconditionally so callers can poll
222// `Pin<&mut SseStream<...>>` without pinning the type itself.
223impl<T, S: Unpin> Unpin for SseStream<T, S> {}
224
225impl<T, S, E> Stream for SseStream<T, S>
226where
227 T: DeserializeOwned + 'static,
228 S: Stream<Item = Result<Bytes, E>> + Unpin + 'static,
229 E: std::fmt::Display,
230{
231 type Item = Result<T, TransportError>;
232
233 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
234 let this = self.get_mut();
235
236 loop {
237 if let Some(item) = this.pending.pop_front() {
238 return Poll::Ready(Some(item));
239 }
240 if this.done {
241 return Poll::Ready(None);
242 }
243
244 match Pin::new(&mut this.inner).poll_next(cx) {
245 Poll::Pending => return Poll::Pending,
246 Poll::Ready(None) => {
247 this.done = true;
248 // Flush any trailing partial buffer as a final line.
249 drain_remaining(
250 &mut this.buf,
251 &mut this.event_kind,
252 &mut this.event_data,
253 &mut this.event_id,
254 &mut this.pending,
255 &this.last_event_id,
256 );
257 }
258 Poll::Ready(Some(Err(e))) => {
259 this.done = true;
260 // `E: Display` only — wrap in a small Display->Error
261 // adapter so the source chain stays intact through
262 // `TransportError::network`.
263 return Poll::Ready(Some(Err(TransportError::network(DisplayError(
264 e.to_string(),
265 )))));
266 }
267 Poll::Ready(Some(Ok(chunk))) => {
268 // Any wire chunk counts as activity — even one carrying only
269 // keepalive comments that dispatch no item — so the idle
270 // timeout in the streaming driver can tell "quiet but alive"
271 // from "truly idle".
272 this.activity.bump();
273 this.buf.extend_from_slice(&chunk);
274 let saw_done = drain_buffer(
275 &mut this.buf,
276 &mut this.scan_from,
277 &mut this.event_kind,
278 &mut this.event_data,
279 &mut this.event_id,
280 &mut this.pending,
281 &this.last_event_id,
282 );
283 if saw_done {
284 this.done = true;
285 this.explicit_done = true;
286 }
287 // Guard against unbounded memory growth: a peer streaming a
288 // line with no terminating `\n`, or a run of `data:` lines
289 // with no dispatching blank line, would otherwise grow
290 // `buf`/`event_data` without limit for the life of a
291 // self-healing, indefinitely-reconnecting client.
292 if this.buf.len() > MAX_ACCUMULATED_BYTES
293 || this.event_data.len() > MAX_ACCUMULATED_BYTES
294 {
295 this.done = true;
296 return Poll::Ready(Some(Err(TransportError::sse(
297 "SSE frame exceeds maximum accumulated size; aborting stream",
298 ))));
299 }
300 }
301 }
302 }
303 }
304}
305
306/// Drain complete lines from `buf`, mutating the per-event accumulator
307/// fields and pushing dispatched events to `out`. Returns `true` iff a
308/// `done` event was dispatched (caller terminates the stream).
309fn drain_buffer<T: DeserializeOwned + 'static>(
310 buf: &mut BytesMut,
311 scan_from: &mut usize,
312 event_kind: &mut Option<String>,
313 event_data: &mut String,
314 event_id: &mut Option<String>,
315 out: &mut VecDeque<Result<T, TransportError>>,
316 last_event_id: &LastEventId,
317) -> bool {
318 let mut saw_done = false;
319 while let Some(line_end) = find_line_end(buf, *scan_from) {
320 let line_bytes = buf.split_to(line_end.consumed);
321 // Bytes after the found `\n` were never examined by this call (the
322 // scan stops at the first match) — resume from scratch for them.
323 *scan_from = 0;
324 // SSE wire format mandates UTF-8 (RFC 8259 § 8.1, EventSource spec).
325 // Surface non-conforming server output as a typed transport error
326 // instead of silently dropping the line — invisible data loss is
327 // worse than a propagated error.
328 match std::str::from_utf8(&line_bytes[..line_end.line_len]) {
329 Ok(line) => {
330 if process_line(line, event_kind, event_data, event_id, out, last_event_id) {
331 saw_done = true;
332 }
333 }
334 Err(e) => {
335 out.push_back(Err(TransportError::sse(format!(
336 "invalid UTF-8 in SSE frame: {e}"
337 ))));
338 }
339 }
340 }
341 // Nothing left to find: everything currently in `buf` is confirmed
342 // newline-free. Remember that so the next poll's `extend_from_slice` only
343 // needs `find_line_end` to scan the newly appended tail.
344 *scan_from = buf.len();
345 saw_done
346}
347
348/// Flush any trailing bytes (without a final `\n`) as one last line, then
349/// — since end-of-stream implies an event boundary — dispatch any
350/// accumulated event.
351fn drain_remaining<T: DeserializeOwned + 'static>(
352 buf: &mut BytesMut,
353 event_kind: &mut Option<String>,
354 event_data: &mut String,
355 _event_id: &mut Option<String>,
356 _out: &mut VecDeque<Result<T, TransportError>>,
357 _last_event_id: &LastEventId,
358) {
359 // Per the W3C EventSource spec, an event that is not terminated by a blank
360 // line before end-of-stream is INCOMPLETE and MUST be discarded. Forcing a
361 // dispatch here would surface a truncated final frame as a (non-transient)
362 // `Serialization` error — both a spec violation and a defeat of reconnect.
363 // A properly framed final event was already dispatched on its blank line,
364 // so anything left in the buffers is a partial event: drop it.
365 buf.clear();
366 event_kind.take();
367 event_data.clear();
368 // Note: `scan_from` is intentionally left untouched here — the stream is
369 // marked `done` by the caller right after this returns, so it is never
370 // consulted again.
371}
372
373/// Process a single (trailing-CR/LF-stripped) SSE line. Returns `true` iff
374/// the line caused a `done` event to be dispatched.
375fn process_line<T: DeserializeOwned + 'static>(
376 raw: &str,
377 event_kind: &mut Option<String>,
378 event_data: &mut String,
379 event_id: &mut Option<String>,
380 out: &mut VecDeque<Result<T, TransportError>>,
381 last_event_id: &LastEventId,
382) -> bool {
383 let line = raw.trim_end_matches(['\r', '\n']);
384
385 // Blank line — dispatch boundary.
386 if line.is_empty() {
387 // Per spec, suppress dispatch when no fields were set since the
388 // last dispatch (e.g. stray blank lines / keepalives).
389 if event_data.is_empty() && event_kind.is_none() {
390 return false;
391 }
392 return dispatch_event(event_kind, event_data, event_id, out, last_event_id);
393 }
394
395 // Comment — ignore.
396 if line.starts_with(':') {
397 return false;
398 }
399
400 if let Some(value) = line.strip_prefix("event:") {
401 *event_kind = Some(value.trim().to_owned());
402 return false;
403 }
404
405 if let Some(value) = line.strip_prefix("data:") {
406 let payload = value.strip_prefix(' ').unwrap_or(value);
407 if !event_data.is_empty() {
408 event_data.push('\n');
409 }
410 event_data.push_str(payload);
411 return false;
412 }
413
414 // SSE `id:` field — capture per event; also propagate to the
415 // connection-level last-event-id (per HTML5 EventSource spec). Empty
416 // `id:` clears the saved value.
417 if let Some(value) = line.strip_prefix("id:") {
418 let id = value.trim().to_owned();
419 if id.is_empty() {
420 *event_id = None;
421 last_event_id.set(None);
422 } else {
423 *event_id = Some(id.clone());
424 last_event_id.set(Some(id));
425 }
426 return false;
427 }
428
429 // `retry:` and other unknown fields — ignore per spec.
430 false
431}
432
433/// Drain the accumulated per-event state into `out`. Returns `true` iff
434/// the dispatched event was a `done` sentinel.
435fn dispatch_event<T: DeserializeOwned + 'static>(
436 event_kind: &mut Option<String>,
437 event_data: &mut String,
438 event_id: &mut Option<String>,
439 out: &mut VecDeque<Result<T, TransportError>>,
440 _last_event_id: &LastEventId,
441) -> bool {
442 let kind = event_kind.take().unwrap_or_else(|| "message".to_owned());
443 let payload = std::mem::take(event_data);
444 // Per spec, last-event-id persists across events — do NOT clear
445 // `event_id` here. The connection-level `LastEventId` cell was already
446 // updated when the `id:` line was parsed.
447 let _ = event_id;
448
449 match kind.as_str() {
450 "done" => true,
451 "error" => {
452 out.push_back(Err(parse_problem(&payload)));
453 false
454 }
455 // The implicit default channel carries the typed payload. An event with
456 // no `event:` field defaults to `"message"` here.
457 "message" => {
458 match serde_json::from_str::<T>(&payload) {
459 Ok(v) => out.push_back(Ok(v)),
460 Err(e) => out.push_back(Err(TransportError::serialization(e))),
461 }
462 false
463 }
464 // Named control events (`heartbeat`, `ping`, custom kinds) are not the
465 // typed data channel — ignore them rather than trying to decode `T`
466 // (which would yield spurious items or serialization errors).
467 _ => false,
468 }
469}
470
471fn parse_problem(payload: &str) -> TransportError {
472 match serde_json::from_str::<Problem>(payload) {
473 Ok(p) => TransportError::problem(p),
474 Err(e) => TransportError::sse(format!("malformed error event: {e}")),
475 }
476}
477
478struct LineEnd {
479 consumed: usize,
480 line_len: usize,
481}
482
483/// Scan for the next `\n`, starting from byte offset `start` (bytes before
484/// `start` are assumed already confirmed newline-free by the caller — see
485/// [`SseStream::scan_from`]).
486fn find_line_end(buf: &[u8], start: usize) -> Option<LineEnd> {
487 for (i, b) in buf[start..].iter().enumerate() {
488 if *b == b'\n' {
489 let abs = start + i;
490 return Some(LineEnd {
491 consumed: abs + 1,
492 line_len: abs,
493 });
494 }
495 }
496 None
497}
498
499#[cfg(test)]
500#[cfg_attr(coverage_nightly, coverage(off))]
501#[allow(clippy::unwrap_used)]
502mod tests {
503 use super::*;
504 use futures_util::stream::{self, StreamExt};
505 use serde::Deserialize;
506
507 #[derive(Debug, Deserialize, PartialEq, Eq)]
508 struct Item {
509 id: u32,
510 }
511
512 fn chunks(parts: &[&str]) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Unpin + use<> {
513 let owned: Vec<Result<Bytes, std::io::Error>> = parts
514 .iter()
515 .map(|s| Ok(Bytes::from(s.to_string())))
516 .collect();
517 Box::pin(stream::iter(owned))
518 }
519
520 #[tokio::test]
521 async fn parses_data_events() {
522 let s = chunks(&[
523 "data: {\"id\":1}\n\n",
524 "data: {\"id\":2}\n\n",
525 "event: done\n\n",
526 ]);
527 let parsed: Vec<_> = parse_sse_stream::<Item, _, _>(s).collect().await;
528 let parsed: Vec<Item> = parsed.into_iter().map(|r| r.unwrap()).collect();
529 assert_eq!(parsed, vec![Item { id: 1 }, Item { id: 2 }]);
530 }
531
532 #[tokio::test]
533 async fn handles_data_split_across_chunks() {
534 let s = chunks(&["data: {\"i", "d\":7}\n\nevent: done\n\n"]);
535 let parsed: Vec<_> = parse_sse_stream::<Item, _, _>(s).collect().await;
536 let parsed: Vec<Item> = parsed.into_iter().map(|r| r.unwrap()).collect();
537 assert_eq!(parsed, vec![Item { id: 7 }]);
538 }
539
540 #[tokio::test]
541 async fn finds_newline_after_many_chunks_with_none() {
542 // Regression test for the incremental `scan_from` optimization (#15):
543 // several chunks arrive with NO newline at all before one finally
544 // completes the line. If `scan_from` bookkeeping were wrong (e.g.
545 // never advanced, or advanced past the eventual `\n`), this would
546 // either loop scanning the same bytes forever or miss the newline.
547 let s = chunks(&["data: {\"i", "d\"", ":", "9", "}", "\n\nevent: done\n\n"]);
548 let parsed: Vec<_> = parse_sse_stream::<Item, _, _>(s).collect().await;
549 let parsed: Vec<Item> = parsed.into_iter().map(|r| r.unwrap()).collect();
550 assert_eq!(parsed, vec![Item { id: 9 }]);
551 }
552
553 #[tokio::test]
554 async fn finds_multiple_newlines_within_one_chunk_after_split() {
555 // After a successful split, `scan_from` must reset to 0 for the
556 // remainder — otherwise a `\n` arriving in the SAME chunk as the one
557 // that completed the prior event would be missed until a later poll
558 // (or never, if no more chunks arrive). Two full events delivered in
559 // ONE chunk exercises exactly that: both must be found and dispatched
560 // within this single `drain_buffer` call.
561 let s = chunks(&["data: {\"id\":1}\n\ndata: {\"id\":2}\n\nevent: done\n\n"]);
562 let parsed: Vec<_> = parse_sse_stream::<Item, _, _>(s).collect().await;
563 let parsed: Vec<Item> = parsed.into_iter().map(|r| r.unwrap()).collect();
564 assert_eq!(parsed, vec![Item { id: 1 }, Item { id: 2 }]);
565 }
566
567 #[tokio::test]
568 async fn aborts_on_unterminated_line_exceeding_max_size() {
569 // A single line with no terminating `\n` that exceeds the cap must
570 // abort the stream with a typed error rather than growing `buf`
571 // without bound (#3 — OOM/DoS guard).
572 let huge = "a".repeat(MAX_ACCUMULATED_BYTES + 1);
573 let s = chunks(&[huge.as_str()]);
574 let parsed: Vec<Result<Item, TransportError>> =
575 parse_sse_stream::<Item, _, _>(s).collect().await;
576 assert_eq!(parsed.len(), 1, "expected exactly one terminal error item");
577 assert!(
578 matches!(parsed[0], Err(TransportError::Sse(_))),
579 "expected TransportError::Sse, got {:?}",
580 parsed[0]
581 );
582 }
583
584 #[tokio::test]
585 async fn saw_done_event_is_false_when_stream_ends_without_done() {
586 // The stream ends cleanly (no error) but WITHOUT an `event: done`
587 // frame — the streaming client relies on `saw_done_event()` to tell
588 // this apart from an explicit done so it can reconnect instead of
589 // silently treating a bare disconnect as success.
590 let s = chunks(&["data: {\"id\":1}\n\n"]);
591 let mut stream = parse_sse_stream::<Item, _, _>(s);
592 let first = stream.next().await;
593 assert!(matches!(first, Some(Ok(Item { id: 1 }))));
594 let second = stream.next().await;
595 assert!(second.is_none(), "expected clean end, got {second:?}");
596 assert!(
597 !stream.saw_done_event(),
598 "stream ended without an explicit `done` event"
599 );
600 }
601
602 #[tokio::test]
603 async fn saw_done_event_is_true_when_done_dispatched() {
604 let s = chunks(&["data: {\"id\":1}\n\n", "event: done\n\n"]);
605 let mut stream = parse_sse_stream::<Item, _, _>(s);
606 assert!(matches!(stream.next().await, Some(Ok(Item { id: 1 }))));
607 assert!(stream.next().await.is_none());
608 assert!(stream.saw_done_event());
609 }
610
611 #[tokio::test]
612 async fn surfaces_error_event_as_problem() {
613 // Canonical RFC 9457 Problem on the `event: error` channel.
614 let problem = serde_json::json!({
615 "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.internal.v1~",
616 "title": "Internal",
617 "status": 500,
618 "detail": "broke",
619 "context": {}
620 });
621 let body = format!("event: error\ndata: {problem}\n\nevent: done\n\n");
622 let s = chunks(&[&body]);
623 let parsed: Vec<_> = parse_sse_stream::<Item, _, _>(s).collect().await;
624 assert_eq!(parsed.len(), 1);
625 match parsed.into_iter().next().unwrap() {
626 Err(TransportError::Problem { problem: p, .. }) => {
627 assert_eq!(p.detail, "broke");
628 assert!(p.problem_type.contains("internal"));
629 }
630 other => panic!("expected Problem, got {other:?}"),
631 }
632 }
633
634 #[tokio::test]
635 async fn ignores_comments_and_blank_lines() {
636 let s = chunks(&[":heartbeat\n\ndata: {\"id\":3}\n\nevent: done\n\n"]);
637 let parsed: Vec<_> = parse_sse_stream::<Item, _, _>(s).collect().await;
638 let parsed: Vec<Item> = parsed.into_iter().map(|r| r.unwrap()).collect();
639 assert_eq!(parsed, vec![Item { id: 3 }]);
640 }
641
642 #[tokio::test]
643 async fn ignores_named_control_events() {
644 // A named event (`ping`) with a JSON body must NOT be decoded as the
645 // typed item; only the implicit `message` channel yields items.
646 let s = chunks(&[
647 "event: ping\ndata: {\"id\":9}\n\n",
648 "data: {\"id\":1}\n\n",
649 "event: done\n\n",
650 ]);
651 let parsed: Vec<_> = parse_sse_stream::<Item, _, _>(s).collect().await;
652 let parsed: Vec<Item> = parsed.into_iter().map(|r| r.unwrap()).collect();
653 assert_eq!(parsed, vec![Item { id: 1 }]);
654 }
655
656 #[tokio::test]
657 async fn discards_truncated_trailing_event_at_eof() {
658 // Stream ends mid-event (no terminating blank line): the incomplete
659 // event is discarded per spec — no item and no serialization error.
660 let s = chunks(&["data: {\"id\":", "5"]);
661 let parsed: Vec<_> = parse_sse_stream::<Item, _, _>(s).collect().await;
662 assert!(parsed.is_empty(), "expected no items, got {parsed:?}");
663 }
664
665 #[tokio::test]
666 async fn malformed_json_yields_serialization_error() {
667 let s = chunks(&["data: not-json\n\nevent: done\n\n"]);
668 let parsed: Vec<_> = parse_sse_stream::<Item, _, _>(s).collect().await;
669 assert_eq!(parsed.len(), 1);
670 match parsed.into_iter().next().unwrap() {
671 Err(TransportError::Serialization(_)) => {}
672 other => panic!("unexpected: {other:?}"),
673 }
674 }
675
676 #[tokio::test]
677 async fn captures_id_field_for_reconnect() {
678 let cell = LastEventId::empty();
679 let s = chunks(&[
680 "id: 42\ndata: {\"id\":1}\n\n",
681 "id: 43\ndata: {\"id\":2}\n\n",
682 "event: done\n\n",
683 ]);
684 let stream = parse_sse_stream_with_id::<Item, _, _>(s, cell.clone());
685 let parsed: Vec<_> = stream.collect().await;
686 let parsed: Vec<Item> = parsed.into_iter().map(|r| r.unwrap()).collect();
687 assert_eq!(parsed, vec![Item { id: 1 }, Item { id: 2 }]);
688 // After all events parsed, the cell holds the last seen id.
689 assert_eq!(cell.current().as_deref(), Some("43"));
690 }
691
692 #[tokio::test]
693 async fn joins_multiple_data_lines_with_newline() {
694 // Two `data:` lines combine to form a single valid JSON object.
695 #[derive(Debug, Deserialize, PartialEq, Eq)]
696 struct Multi {
697 text: String,
698 }
699 // Wire:
700 // data: {"text":
701 // data: "hi"}
702 // <blank>
703 // Joined payload: `{"text":\n "hi"}` — valid JSON.
704 let body = "data: {\"text\":\ndata: \"hi\"}\n\nevent: done\n\n";
705 let s = chunks(&[body]);
706 let parsed: Vec<_> = parse_sse_stream::<Multi, _, _>(s).collect().await;
707 let parsed: Vec<Multi> = parsed.into_iter().map(|r| r.unwrap()).collect();
708 assert_eq!(
709 parsed,
710 vec![Multi {
711 text: "hi".to_owned()
712 }]
713 );
714 }
715
716 #[tokio::test]
717 async fn empty_id_field_clears_saved_value() {
718 // Per HTML5 EventSource spec, an empty `id:` resets the
719 // Last-Event-ID to None (won't be sent on reconnect).
720 let cell = LastEventId::empty();
721 let s = chunks(&[
722 "id: 7\ndata: {\"id\":1}\n\n",
723 "id: \ndata: {\"id\":2}\n\n",
724 "event: done\n\n",
725 ]);
726 let stream = parse_sse_stream_with_id::<Item, _, _>(s, cell.clone());
727 let _: Vec<_> = stream.collect().await;
728 assert!(cell.current().is_none());
729 }
730}