Skip to main content

tachyon_web/http/response/
sse.rs

1//! Server-Sent Events (SSE), mirroring `axum::response::sse`.
2//!
3//! ```rust,no_run
4//! use tachyon_web::response::sse::{Event, Sse};
5//! use tachyon_web::{Router, get};
6//! use futures_core::Stream;
7//! use std::convert::Infallible;
8//! use std::pin::Pin;
9//! use std::task::{Context, Poll};
10//!
11//! // A minimal, self-contained `Stream` yielding one event then finishing —
12//! // in real code this would typically be a channel receiver or a `Stream`
13//! // built with `tokio_stream`/`futures_util`'s combinators.
14//! struct OnceStream(Option<Event>);
15//!
16//! impl Stream for OnceStream {
17//!     type Item = Result<Event, Infallible>;
18//!     fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
19//!         Poll::Ready(self.0.take().map(Ok))
20//!     }
21//! }
22//!
23//! async fn handler() -> Sse<OnceStream> {
24//!     Sse::new(OnceStream(Some(Event::new().data("hello"))))
25//! }
26//!
27//! let _app: Router<()> = Router::new().route("/events", get(handler));
28//! ```
29//!
30//! Requires the `sse` feature.
31
32use crate::http::error::Error;
33use crate::http::response::{Body, IntoResponse};
34use bytes::Bytes;
35use futures_core::Stream;
36use hyper::body::Frame;
37use hyper::header::{CACHE_CONTROL, CONTENT_TYPE, HeaderValue};
38use hyper::{Response, StatusCode};
39use std::fmt::Write as _;
40use std::pin::Pin;
41use std::task::{Context, Poll};
42
43/// A single Server-Sent Event.
44///
45/// Build one with the fluent setters and yield it from the stream passed to
46/// [`Sse::new`]. Multi-line `data`/`comment` values are automatically split
47/// across multiple wire-format lines, per the SSE spec.
48#[derive(Debug, Default, Clone)]
49#[allow(clippy::struct_field_names)] // `event` is the correct SSE wire-format field name.
50pub struct Event {
51    event: Option<String>,
52    data: Option<String>,
53    id: Option<String>,
54    retry_ms: Option<u64>,
55    comment: Option<String>,
56}
57
58impl Event {
59    /// Creates an empty event — add fields with the setters below.
60    #[must_use]
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Sets the event's `data` field (the `data: ...` line(s)).
66    #[must_use]
67    pub fn data(mut self, data: impl Into<String>) -> Self {
68        self.data = Some(data.into());
69        self
70    }
71
72    /// JSON-encodes `data` and sets it as the event's `data` field, matching
73    /// `axum::response::sse::Event::json_data`.
74    ///
75    /// # Errors
76    /// Returns an error if `data` cannot be serialized to JSON.
77    pub fn json_data(self, data: impl serde::Serialize) -> serde_json::Result<Self> {
78        Ok(self.data(serde_json::to_string(&data)?))
79    }
80
81    /// Sets the event's `event` field (the event type/name). Must not contain
82    /// `\n` or `\r` — those aren't split across lines like `data`/`comment`.
83    #[must_use]
84    pub fn event(mut self, event: impl Into<String>) -> Self {
85        self.event = Some(event.into());
86        self
87    }
88
89    /// Sets the event's `id` field. Must not contain `\n` or `\r`.
90    #[must_use]
91    pub fn id(mut self, id: impl Into<String>) -> Self {
92        self.id = Some(id.into());
93        self
94    }
95
96    /// Sets the client's reconnection delay (the `retry: <ms>` line).
97    #[must_use]
98    pub fn retry(mut self, duration: std::time::Duration) -> Self {
99        self.retry_ms = Some(u64::try_from(duration.as_millis()).unwrap_or(u64::MAX));
100        self
101    }
102
103    /// Sets a comment line (`: ...`), ignored by clients but useful as a
104    /// keep-alive ping to stop idle proxies from closing the connection.
105    #[must_use]
106    pub fn comment(mut self, comment: impl Into<String>) -> Self {
107        self.comment = Some(comment.into());
108        self
109    }
110
111    /// Serializes this event into SSE wire format, terminated by a blank line.
112    fn write_to(&self, buf: &mut String) {
113        if let Some(comment) = &self.comment {
114            for line in comment.split('\n') {
115                let _ = writeln!(buf, ": {line}");
116            }
117        }
118        if let Some(event) = &self.event {
119            let _ = writeln!(buf, "event: {event}");
120        }
121        if let Some(data) = &self.data {
122            for line in data.split('\n') {
123                let _ = writeln!(buf, "data: {line}");
124            }
125        }
126        if let Some(id) = &self.id {
127            let _ = writeln!(buf, "id: {id}");
128        }
129        if let Some(retry_ms) = self.retry_ms {
130            let _ = writeln!(buf, "retry: {retry_ms}");
131        }
132        buf.push('\n');
133    }
134}
135
136/// Configures periodic keep-alive comment pings for an otherwise-idle
137/// [`Sse`] stream, matching `axum::response::sse::KeepAlive`.
138///
139/// Some intermediary proxies/load balancers close connections that go quiet
140/// for too long; interleaving a harmless `: <text>` comment line (ignored by
141/// SSE clients) at a regular interval keeps the connection alive without the
142/// caller's own stream needing to know about it.
143#[derive(Debug, Clone)]
144pub struct KeepAlive {
145    event: Event,
146    interval: std::time::Duration,
147}
148
149impl Default for KeepAlive {
150    fn default() -> Self {
151        Self {
152            event: Event::new().comment(""),
153            interval: std::time::Duration::from_secs(15),
154        }
155    }
156}
157
158impl KeepAlive {
159    /// Creates a `KeepAlive` with the default 15-second interval and an
160    /// empty comment ping.
161    #[must_use]
162    pub fn new() -> Self {
163        Self::default()
164    }
165
166    /// Sets how long the stream may stay idle before a keep-alive ping is
167    /// sent.
168    #[must_use]
169    pub const fn interval(mut self, interval: std::time::Duration) -> Self {
170        self.interval = interval;
171        self
172    }
173
174    /// Sets the keep-alive ping's comment text (sent as `: <text>`).
175    #[must_use]
176    pub fn text(mut self, text: impl Into<String>) -> Self {
177        self.event = Event::new().comment(text);
178        self
179    }
180
181    /// Sets the exact [`Event`] sent as the keep-alive ping, for cases where
182    /// a comment alone isn't enough (e.g. clients that key off `event:`).
183    #[must_use]
184    pub fn event(mut self, event: Event) -> Self {
185        self.event = event;
186        self
187    }
188}
189
190pin_project_lite::pin_project! {
191    /// Wraps a stream, injecting `keep_alive.event` whenever the inner stream
192    /// hasn't produced an item for `keep_alive.interval`.
193    struct KeepAliveStream<S> {
194        #[pin]
195        stream: S,
196        interval: tokio::time::Interval,
197        comment_event: Event,
198    }
199}
200
201impl<S, E> Stream for KeepAliveStream<S>
202where
203    S: Stream<Item = Result<Event, E>>,
204{
205    type Item = Result<Event, E>;
206
207    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
208        let this = self.project();
209        match this.stream.poll_next(cx) {
210            Poll::Ready(item) => {
211                this.interval.reset();
212                Poll::Ready(item)
213            }
214            Poll::Pending => this.interval.poll_tick(cx).map(|_| {
215                this.interval.reset();
216                Some(Ok(this.comment_event.clone()))
217            }),
218        }
219    }
220}
221
222pin_project_lite::pin_project! {
223    /// An SSE response body: adapts a `Stream<Item = Result<Event, E>>` into
224    /// the `text/event-stream` wire format.
225    struct EventStreamBody<S> {
226        #[pin]
227        stream: S,
228    }
229}
230
231impl<S, E> hyper::body::Body for EventStreamBody<S>
232where
233    S: Stream<Item = Result<Event, E>>,
234    E: Into<Error>,
235{
236    type Data = Bytes;
237    type Error = Error;
238
239    fn poll_frame(
240        self: Pin<&mut Self>,
241        cx: &mut Context<'_>,
242    ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
243        let this = self.project();
244        match this.stream.poll_next(cx) {
245            Poll::Ready(Some(Ok(event))) => {
246                let mut buf = String::with_capacity(64);
247                event.write_to(&mut buf);
248                Poll::Ready(Some(Ok(Frame::data(Bytes::from(buf)))))
249            }
250            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e.into()))),
251            Poll::Ready(None) => Poll::Ready(None),
252            Poll::Pending => Poll::Pending,
253        }
254    }
255}
256
257/// A Server-Sent Events response, matching `axum::response::sse::Sse`.
258///
259/// Sets `Content-Type: text/event-stream` and `Cache-Control: no-cache`, then
260/// streams each item of the wrapped stream in SSE wire format as it becomes
261/// available — nothing is buffered.
262#[must_use]
263#[derive(Debug, Clone)]
264pub struct Sse<S> {
265    stream: S,
266    keep_alive: Option<KeepAlive>,
267}
268
269impl<S, E> Sse<S>
270where
271    S: Stream<Item = Result<Event, E>> + Send + 'static,
272    E: Into<Error> + 'static,
273{
274    /// Creates an SSE response from a stream of events.
275    pub const fn new(stream: S) -> Self {
276        Self {
277            stream,
278            keep_alive: None,
279        }
280    }
281
282    /// Enables periodic keep-alive comment pings on this stream — see
283    /// [`KeepAlive`].
284    pub fn keep_alive(mut self, keep_alive: KeepAlive) -> Self {
285        self.keep_alive = Some(keep_alive);
286        self
287    }
288}
289
290impl<S, E> IntoResponse for Sse<S>
291where
292    S: Stream<Item = Result<Event, E>> + Send + 'static,
293    E: Into<Error> + 'static,
294{
295    fn into_response(self) -> Response<Body> {
296        let body = if let Some(keep_alive) = self.keep_alive {
297            Body::stream(EventStreamBody {
298                stream: KeepAliveStream {
299                    stream: self.stream,
300                    // `tokio::time::interval`'s first tick always fires immediately —
301                    // start the clock one interval in the future instead, so the first
302                    // keep-alive ping only fires after the stream has actually been
303                    // idle for `keep_alive.interval`, matching the documented behavior.
304                    interval: tokio::time::interval_at(
305                        tokio::time::Instant::now() + keep_alive.interval,
306                        keep_alive.interval,
307                    ),
308                    comment_event: keep_alive.event,
309                },
310            })
311        } else {
312            Body::stream(EventStreamBody {
313                stream: self.stream,
314            })
315        };
316        let mut resp = Response::new(body);
317        *resp.status_mut() = StatusCode::OK;
318        let _ = resp
319            .headers_mut()
320            .insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
321        let _ = resp
322            .headers_mut()
323            .insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
324        resp
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    #![allow(clippy::unwrap_used)]
331    use super::*;
332    use std::convert::Infallible;
333
334    #[allow(clippy::needless_pass_by_value)]
335    fn wire_format(event: Event) -> String {
336        let mut buf = String::new();
337        event.write_to(&mut buf);
338        buf
339    }
340
341    #[test]
342    fn test_simple_data_event() {
343        let s = wire_format(Event::new().data("hello"));
344        assert_eq!(s, "data: hello\n\n");
345    }
346
347    #[test]
348    fn test_event_with_name_and_id() {
349        let s = wire_format(Event::new().event("update").data("payload").id("42"));
350        assert_eq!(s, "event: update\ndata: payload\nid: 42\n\n");
351    }
352
353    #[test]
354    fn test_multiline_data_split_across_lines() {
355        let s = wire_format(Event::new().data("line1\nline2"));
356        assert_eq!(s, "data: line1\ndata: line2\n\n");
357    }
358
359    #[test]
360    fn test_comment_only_event() {
361        let s = wire_format(Event::new().comment("keep-alive"));
362        assert_eq!(s, ": keep-alive\n\n");
363    }
364
365    #[test]
366    fn test_retry_field() {
367        let s = wire_format(Event::new().retry(std::time::Duration::from_secs(5)));
368        assert_eq!(s, "retry: 5000\n\n");
369    }
370
371    #[test]
372    fn test_json_data() {
373        #[derive(serde::Serialize)]
374        struct Payload {
375            n: u32,
376        }
377        let event = Event::new().json_data(Payload { n: 7 }).unwrap();
378        assert_eq!(wire_format(event), "data: {\"n\":7}\n\n");
379    }
380
381    #[tokio::test]
382    async fn test_sse_response_headers_and_body() {
383        use http_body_util::BodyExt;
384
385        let events: [Result<Event, Infallible>; 2] = [
386            Ok(Event::new().data("first")),
387            Ok(Event::new().data("second")),
388        ];
389        let stream = tokio_stream::iter(events);
390
391        let resp = Sse::new(stream).into_response();
392        assert_eq!(
393            resp.headers().get(CONTENT_TYPE).unwrap(),
394            "text/event-stream"
395        );
396        assert_eq!(resp.headers().get(CACHE_CONTROL).unwrap(), "no-cache");
397
398        let body = resp.into_body().collect().await.unwrap().to_bytes();
399        assert_eq!(&body[..], b"data: first\n\ndata: second\n\n");
400    }
401
402    #[tokio::test(start_paused = true)]
403    async fn test_keep_alive_pings_idle_stream() {
404        use http_body_util::BodyExt;
405        use std::time::Duration;
406
407        // A stream that never produces anything on its own — any output must
408        // come from the keep-alive ping.
409        struct NeverStream;
410        impl Stream for NeverStream {
411            type Item = Result<Event, Infallible>;
412            fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
413                Poll::Pending
414            }
415        }
416
417        let resp = Sse::new(NeverStream)
418            .keep_alive(
419                KeepAlive::new()
420                    .interval(Duration::from_secs(1))
421                    .text("ping"),
422            )
423            .into_response();
424
425        let mut body = resp.into_body();
426        tokio::time::advance(Duration::from_secs(1)).await;
427        let frame = body.frame().await.unwrap().unwrap();
428        let data = frame.into_data().unwrap();
429        assert_eq!(&data[..], b": ping\n\n");
430    }
431
432    #[tokio::test(start_paused = true)]
433    async fn test_keep_alive_does_not_ping_before_first_interval_elapses() {
434        use std::time::Duration;
435
436        struct NeverStream;
437        impl Stream for NeverStream {
438            type Item = Result<Event, Infallible>;
439            fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
440                Poll::Pending
441            }
442        }
443
444        // Constructed directly (rather than through `Sse::into_response`) so this
445        // test exercises `KeepAliveStream::poll_next` itself without also routing
446        // through the `Body`/`EventStreamBody` wire-format layer.
447        let interval = Duration::from_secs(1);
448        let mut kas = std::pin::pin!(KeepAliveStream {
449            stream: NeverStream,
450            interval: tokio::time::interval_at(tokio::time::Instant::now() + interval, interval),
451            comment_event: Event::new().comment("ping"),
452        });
453
454        let waker = futures::task::noop_waker();
455        let mut cx = Context::from_waker(&waker);
456        // Polling immediately (no time advanced) must not yield a ping — the
457        // stream hasn't been idle for a full interval yet. This regression-tests
458        // `tokio::time::interval`'s "first tick fires immediately" behavior,
459        // which previously leaked through as a spurious ping.
460        assert!(
461            kas.as_mut().poll_next(&mut cx).is_pending(),
462            "keep-alive must not fire before the configured interval elapses"
463        );
464
465        tokio::time::advance(interval).await;
466        assert!(
467            kas.as_mut().poll_next(&mut cx).is_ready(),
468            "keep-alive must fire once the interval has actually elapsed"
469        );
470    }
471}