Skip to main content

trillium_client/
sse.rs

1//! Client-side [Server-Sent Events][spec].
2//!
3//! [`Conn::into_sse`] executes a request and interprets the response body as an
4//! `text/event-stream`, yielding a [`Stream`] of [`Event`]s. Unlike
5//! [`into_websocket`][Conn::into_websocket], this is not a protocol upgrade — an event stream is
6//! an ordinary response whose body is read incrementally and parsed line-by-line. It works
7//! identically over HTTP/1.x, HTTP/2, and HTTP/3.
8//!
9//! This is a single-response stream: it ends when the connection closes. It does **not**
10//! implement the [`EventSource`][es] automatic-reconnection behavior (re-issuing the request with
11//! `Last-Event-ID` and honoring server `retry:` timing), which only makes sense for idempotent
12//! event feeds. To retry a dropped request, drive the whole request through a retrying
13//! [`ClientHandler`][crate::ClientHandler].
14//!
15//! [spec]: https://html.spec.whatwg.org/multipage/server-sent-events.html
16//! [es]: https://developer.mozilla.org/en-US/docs/Web/API/EventSource
17
18use crate::Conn;
19use futures_lite::{AsyncRead, stream::Stream};
20use std::{
21    collections::VecDeque,
22    error::Error,
23    fmt::{self, Debug, Display, Formatter},
24    ops::{Deref, DerefMut},
25    pin::Pin,
26    task::{Context, Poll, ready},
27    time::Duration,
28};
29use trillium_http::{KnownHeaderName, Status};
30
31const READ_BUF_LEN: usize = 8 * 1024;
32
33impl Conn {
34    /// Execute this request and interpret the response body as a [Server-Sent Events][spec]
35    /// stream.
36    ///
37    /// This is an *execution* method: it sends the request, setting `Accept: text/event-stream`
38    /// unless the conn already carries an `Accept` other than the default `*/*`, then validates
39    /// that the response has a success status and a `text/event-stream` content-type before
40    /// handing back an [`EventStream`]. As with the browser `EventSource`, a response with no
41    /// content-type is rejected. Calling it on a conn that has already been awaited returns
42    /// [`SseErrorKind::AlreadyExecuted`] — build the conn, then call this; don't await it
43    /// yourself first.
44    ///
45    /// On any failure the returned [`SseError`] still carries the [`Conn`], so the caller can
46    /// inspect the response (status, headers, error body) or convert it back with
47    /// [`From`]/[`Into`]. A caller that knows better than the validation — a server that streams
48    /// events without labelling them — can hand the recovered conn to [`EventStream::new`].
49    ///
50    /// [spec]: https://html.spec.whatwg.org/multipage/server-sent-events.html
51    pub async fn into_sse(mut self) -> Result<EventStream, SseError> {
52        if self.status().is_some() {
53            return Err(SseError::new(self, SseErrorKind::AlreadyExecuted));
54        }
55
56        // try_insert would never fire: the client's default headers already carry `Accept: */*`.
57        // A wildcard is the absence of a preference, so narrow it; anything else was chosen by
58        // the caller and is left alone.
59        let accept = self.request_headers().get_str(KnownHeaderName::Accept);
60        if accept.is_none_or(|accept| accept.trim() == "*/*") {
61            self.request_headers_mut()
62                .insert(KnownHeaderName::Accept, "text/event-stream");
63        }
64
65        if let Err(e) = (&mut self).await {
66            return Err(SseError::new(self, e.into()));
67        }
68
69        let status = self.status().expect("Response did not include status");
70        if !status.is_success() {
71            return Err(SseError::new(self, SseErrorKind::Status(status)));
72        }
73
74        let content_type = self
75            .response_headers()
76            .get_str(KnownHeaderName::ContentType);
77        if !content_type.is_some_and(is_event_stream) {
78            let content_type = content_type.map(String::from);
79            return Err(SseError::new(
80                self,
81                SseErrorKind::UnexpectedContentType(content_type),
82            ));
83        }
84
85        EventStream::new(self)
86    }
87}
88
89/// True if `content_type` names the `text/event-stream` media type, ignoring any parameters
90/// (e.g. `; charset=utf-8`) and ASCII case.
91fn is_event_stream(content_type: &str) -> bool {
92    content_type
93        .split(';')
94        .next()
95        .is_some_and(|media_type| media_type.trim().eq_ignore_ascii_case("text/event-stream"))
96}
97
98/// A single server-sent event.
99///
100/// Field accessors follow the [SSE specification][spec]: [`event_type`](Event::event_type) is
101/// `None` for the default `message` type, [`data`](Event::data) has had its lines joined with
102/// `\n` and the trailing newline removed, and [`id`](Event::id) reflects the most recent `id:`
103/// field seen on the stream (it persists across events, matching `EventSource.lastEventId`).
104///
105/// [spec]: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
106#[derive(Debug, Clone, Eq, PartialEq)]
107pub struct Event {
108    data: String,
109    event_type: Option<String>,
110    id: Option<String>,
111    retry: Option<Duration>,
112}
113
114impl Event {
115    /// The event payload, with multiple `data:` lines joined by `\n`.
116    #[must_use]
117    pub fn data(&self) -> &str {
118        &self.data
119    }
120
121    /// The event type from the `event:` field, or `None` for the default `message` type.
122    #[must_use]
123    pub fn event_type(&self) -> Option<&str> {
124        self.event_type.as_deref()
125    }
126
127    /// The last event id seen on the stream up to and including this event.
128    #[must_use]
129    pub fn id(&self) -> Option<&str> {
130        self.id.as_deref()
131    }
132
133    /// The server-requested reconnection time from a `retry:` field, if one preceded this event.
134    ///
135    /// This is a connection-level directive; because [`EventStream`] does not reconnect, it is
136    /// surfaced purely informationally for callers that implement their own reconnection.
137    #[must_use]
138    pub fn retry(&self) -> Option<Duration> {
139        self.retry
140    }
141}
142
143/// A [`Stream`] of [`Event`]s decoded from a `text/event-stream` response body.
144///
145/// Created by [`Conn::into_sse`]. The stream yields `Result<Event, trillium_http::Error>`; an
146/// error item is an IO failure reading the underlying transport, after which the stream ends.
147/// The stream ends with `None` when the connection closes; an incomplete event at end-of-stream
148/// (no terminating blank line) is discarded per the specification.
149#[derive(Debug)]
150pub struct EventStream {
151    conn: Conn,
152    decoder: Decoder,
153    pending: VecDeque<Event>,
154    read_buf: Box<[u8]>,
155    done: bool,
156}
157
158impl EventStream {
159    /// Read an already-executed [`Conn`]'s response body as an event stream, with no
160    /// validation of its status or content-type.
161    ///
162    /// [`Conn::into_sse`] is the normal entry point; this is the escape hatch for a response that
163    /// fails its checks but that the caller knows is an event stream anyway — typically recovered
164    /// from an [`SseError`] via [`From`]/[`Into`]. The SSE wire format accepts any input, so a
165    /// body that is not actually an event stream yields no events rather than an error.
166    ///
167    /// # Errors
168    ///
169    /// [`SseErrorKind::NoBody`] if the conn has not been executed, so there is no response to
170    /// read.
171    pub fn new(conn: Conn) -> Result<Self, SseError> {
172        if conn.status().is_none() {
173            return Err(SseError::new(conn, SseErrorKind::NoBody));
174        }
175
176        Ok(Self {
177            conn,
178            decoder: Decoder::default(),
179            pending: VecDeque::new(),
180            read_buf: vec![0; READ_BUF_LEN].into_boxed_slice(),
181            done: false,
182        })
183    }
184
185    /// The executed [`Conn`] this stream was created from, for response metadata — status,
186    /// response headers, peer address.
187    pub fn conn(&self) -> &Conn {
188        &self.conn
189    }
190}
191
192impl Stream for EventStream {
193    type Item = trillium_http::Result<Event>;
194
195    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
196        let this = self.get_mut();
197        loop {
198            if let Some(event) = this.pending.pop_front() {
199                return Poll::Ready(Some(Ok(event)));
200            }
201            if this.done {
202                return Poll::Ready(None);
203            }
204
205            let mut response_body = this.conn.response_body();
206            match ready!(Pin::new(&mut response_body).poll_read(cx, &mut this.read_buf)) {
207                // EOF: a trailing event without its blank line is discarded per spec.
208                Ok(0) => {
209                    this.done = true;
210                    return Poll::Ready(None);
211                }
212                Ok(n) => this.decoder.push(&this.read_buf[..n], &mut this.pending),
213                Err(e) => {
214                    this.done = true;
215                    return Poll::Ready(Some(Err(e.into())));
216                }
217            }
218        }
219    }
220}
221
222/// Incremental, allocation-reusing parser for the SSE wire format.
223///
224/// Bytes are fed in arbitrary chunks via [`push`](Decoder::push); completed [`Event`]s are
225/// appended to the caller's queue. Line terminators (CR, LF, CRLF) are handled across chunk
226/// boundaries via `last_char_was_cr`.
227#[derive(Debug, Default)]
228struct Decoder {
229    line: Vec<u8>,
230    last_char_was_cr: bool,
231    data: String,
232    event_type: Option<String>,
233    id: Option<String>,
234    retry: Option<Duration>,
235    has_data: bool,
236}
237
238impl Decoder {
239    fn push(&mut self, bytes: &[u8], out: &mut VecDeque<Event>) {
240        for &byte in bytes {
241            match byte {
242                b'\r' => {
243                    self.line_done(out);
244                    self.last_char_was_cr = true;
245                }
246                b'\n' if self.last_char_was_cr => self.last_char_was_cr = false,
247                b'\n' => self.line_done(out),
248                _ => {
249                    self.last_char_was_cr = false;
250                    self.line.push(byte);
251                }
252            }
253        }
254    }
255
256    fn line_done(&mut self, out: &mut VecDeque<Event>) {
257        if self.line.is_empty() {
258            self.dispatch(out);
259        } else {
260            let mut line = std::mem::take(&mut self.line);
261            self.process_field(&line);
262            line.clear();
263            self.line = line;
264        }
265    }
266
267    fn process_field(&mut self, line: &[u8]) {
268        let (field, value) = match memchr::memchr(b':', line) {
269            Some(0) => return, // leading colon: comment
270            Some(colon) => {
271                let value = &line[colon + 1..];
272                let value = value.strip_prefix(b" ").unwrap_or(value);
273                (&line[..colon], value)
274            }
275            None => (line, &b""[..]),
276        };
277
278        match field {
279            b"event" => self.event_type = Some(String::from_utf8_lossy(value).into_owned()),
280
281            b"data" => {
282                self.data.push_str(&String::from_utf8_lossy(value));
283                self.data.push('\n');
284                self.has_data = true;
285            }
286
287            b"id" => {
288                if !value.contains(&0) {
289                    self.id = Some(String::from_utf8_lossy(value).into_owned());
290                }
291            }
292
293            b"retry" => {
294                if !value.is_empty()
295                    && value.iter().all(u8::is_ascii_digit)
296                    && let Ok(ms) = std::str::from_utf8(value).unwrap_or_default().parse()
297                {
298                    self.retry = Some(Duration::from_millis(ms));
299                }
300            }
301
302            _ => {}
303        }
304    }
305
306    fn dispatch(&mut self, out: &mut VecDeque<Event>) {
307        if !self.has_data {
308            // No data accumulated: reset the data and event-type buffers without dispatching,
309            // but leave `id` (last-event-id) and any pending `retry` intact, per spec.
310            self.data.clear();
311            self.event_type = None;
312            return;
313        }
314
315        if self.data.ends_with('\n') {
316            self.data.pop();
317        }
318
319        out.push_back(Event {
320            data: std::mem::take(&mut self.data),
321            event_type: self.event_type.take().filter(|s| !s.is_empty()),
322            id: self.id.clone(),
323            retry: self.retry.take(),
324        });
325        self.has_data = false;
326    }
327}
328
329/// The kind of error that occurred attempting to open an [`EventStream`].
330#[derive(thiserror::Error, Debug)]
331#[non_exhaustive]
332pub enum SseErrorKind {
333    /// An HTTP error attempting to make the request.
334    #[error(transparent)]
335    Http(#[from] trillium_http::Error),
336
337    /// The response status was not a success (2xx).
338    #[error("Unexpected response status {0} for SSE request")]
339    Status(Status),
340
341    /// The response content-type was not `text/event-stream`, or was absent (`None`).
342    #[error("Unexpected content-type for SSE request: {0:?}")]
343    UnexpectedContentType(Option<String>),
344
345    /// [`Conn::into_sse`] was called on a [`Conn`] that had already been executed (its status is
346    /// already set). The request *is* the execution; build the conn and await `into_sse`
347    /// directly without awaiting first.
348    #[error(
349        "Conn::into_sse called after execution — build the conn and await into_sse instead of \
350         awaiting the conn separately"
351    )]
352    AlreadyExecuted,
353
354    /// [`EventStream::new`] was given a [`Conn`] that has not been executed, so there is no
355    /// response body to read.
356    #[error("SSE conn has no response body to read")]
357    NoBody,
358}
359
360/// An attempt to open an [`EventStream`] via [`Conn::into_sse`] failed.
361///
362/// This dereferences to the [`Conn`] and converts back into it with [`From`]/[`Into`], so the
363/// caller can inspect the response that caused the failure.
364#[derive(Debug)]
365pub struct SseError {
366    /// The kind of error that occurred.
367    pub kind: SseErrorKind,
368    conn: Box<Conn>,
369}
370
371impl SseError {
372    fn new(conn: Conn, kind: SseErrorKind) -> Self {
373        Self {
374            kind,
375            conn: Box::new(conn),
376        }
377    }
378}
379
380impl From<SseError> for Conn {
381    fn from(value: SseError) -> Self {
382        *value.conn
383    }
384}
385
386impl Deref for SseError {
387    type Target = Conn;
388
389    fn deref(&self) -> &Self::Target {
390        &self.conn
391    }
392}
393
394impl DerefMut for SseError {
395    fn deref_mut(&mut self) -> &mut Self::Target {
396        &mut self.conn
397    }
398}
399
400impl Error for SseError {}
401
402impl Display for SseError {
403    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
404        Display::fmt(&self.kind, f)
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    /// Feed `input` to a fresh decoder in one chunk, then again one byte at a time, asserting
413    /// both produce the same events. Splitting per byte exercises the cross-chunk line-terminator
414    /// and field-accumulation state.
415    fn decode(input: &[u8]) -> Vec<Event> {
416        let mut whole = Decoder::default();
417        let mut whole_out = VecDeque::new();
418        whole.push(input, &mut whole_out);
419
420        let mut split = Decoder::default();
421        let mut split_out = VecDeque::new();
422        for byte in input {
423            split.push(&[*byte], &mut split_out);
424        }
425
426        assert_eq!(whole_out, split_out, "chunked decode diverged from whole");
427        whole_out.into()
428    }
429
430    #[test]
431    fn fields_comments_and_terminators() {
432        let events =
433            decode(b": this is a comment\nevent: greeting\ndata: hello\nid: 42\nretry: 3000\n\n");
434        assert_eq!(events.len(), 1);
435        let event = &events[0];
436        assert_eq!(event.data(), "hello");
437        assert_eq!(event.event_type(), Some("greeting"));
438        assert_eq!(event.id(), Some("42"));
439        assert_eq!(event.retry(), Some(Duration::from_millis(3000)));
440    }
441
442    #[test]
443    fn multiline_data_joins_with_newline() {
444        let events = decode(b"data: one\ndata: two\ndata:three\n\n");
445        // Only the single space after the first colon is stripped; "data:three" has none.
446        assert_eq!(events[0].data(), "one\ntwo\nthree");
447    }
448
449    #[test]
450    fn crlf_and_cr_terminators() {
451        let crlf = decode(b"data: a\r\n\r\n");
452        assert_eq!(crlf[0].data(), "a");
453        let cr = decode(b"data: b\r\r");
454        assert_eq!(cr[0].data(), "b");
455    }
456
457    #[test]
458    fn empty_data_line_dispatches_empty_event() {
459        // A bare `data` field (no value) still counts as data and dispatches.
460        let events = decode(b"data\n\n");
461        assert_eq!(events.len(), 1);
462        assert_eq!(events[0].data(), "");
463    }
464
465    #[test]
466    fn blank_lines_without_data_dispatch_nothing() {
467        assert!(decode(b"\n\n\n").is_empty());
468        assert!(decode(b": just a comment\n\n").is_empty());
469    }
470
471    #[test]
472    fn incomplete_trailing_event_is_discarded() {
473        // No terminating blank line: the event is never dispatched.
474        assert!(decode(b"data: pending\n").is_empty());
475    }
476
477    #[test]
478    fn id_persists_across_events_retry_does_not() {
479        let events = decode(b"id: 1\nretry: 500\ndata: a\n\ndata: b\n\n");
480        assert_eq!(events[0].id(), Some("1"));
481        assert_eq!(events[0].retry(), Some(Duration::from_millis(500)));
482        // `id` is the last-event-id and carries forward; `retry` is consumed by the first event.
483        assert_eq!(events[1].id(), Some("1"));
484        assert_eq!(events[1].retry(), None);
485    }
486
487    #[test]
488    fn invalid_retry_is_ignored() {
489        let events = decode(b"retry: not-a-number\ndata: a\n\n");
490        assert_eq!(events[0].retry(), None);
491    }
492}