tachyon_web/http/response/
sse.rs1use 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#[derive(Debug, Default, Clone)]
49#[allow(clippy::struct_field_names)] pub 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 #[must_use]
61 pub fn new() -> Self {
62 Self::default()
63 }
64
65 #[must_use]
67 pub fn data(mut self, data: impl Into<String>) -> Self {
68 self.data = Some(data.into());
69 self
70 }
71
72 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 #[must_use]
84 pub fn event(mut self, event: impl Into<String>) -> Self {
85 self.event = Some(event.into());
86 self
87 }
88
89 #[must_use]
91 pub fn id(mut self, id: impl Into<String>) -> Self {
92 self.id = Some(id.into());
93 self
94 }
95
96 #[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 #[must_use]
106 pub fn comment(mut self, comment: impl Into<String>) -> Self {
107 self.comment = Some(comment.into());
108 self
109 }
110
111 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#[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 #[must_use]
162 pub fn new() -> Self {
163 Self::default()
164 }
165
166 #[must_use]
169 pub const fn interval(mut self, interval: std::time::Duration) -> Self {
170 self.interval = interval;
171 self
172 }
173
174 #[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 #[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 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 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#[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 pub const fn new(stream: S) -> Self {
276 Self {
277 stream,
278 keep_alive: None,
279 }
280 }
281
282 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 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 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 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 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}