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