sse.rs
114 lignes · 3728 octets
//! Helper to turn a [`NotificationBus`](super::NotificationBus) subscription //! into an HTTP Server-Sent Events response. //! //! Wraps the usual boilerplate (JSON-serialising each event, attaching a //! keep-alive, converting errors into client-friendly ends of stream) in a //! single call so handlers stay trivial: //! //! ```no_run //! use axum::response::sse::Sse; //! use axum::response::IntoResponse; //! use std::sync::Arc; //! use rustwarden_core::notifications::{InMemoryBus, NotificationBus, sse::sse_response}; //! //! #[derive(Clone, serde::Serialize)] //! struct MyEvent { msg: String } //! //! async fn stream_handler( //! bus: axum::extract::Extension<Arc<dyn NotificationBus<MyEvent>>>, //! ) -> impl IntoResponse { //! let filter = Arc::new(|_: &MyEvent| true); //! sse_response(bus.subscribe(filter)) //! } //! ``` use axum::response::sse::{Event, KeepAlive, Sse}; use futures::stream::{Stream, StreamExt}; use serde::Serialize; use std::convert::Infallible; use std::time::Duration; /// Default keep-alive interval (30 s) — matches most reverse-proxy idle timeouts. pub const DEFAULT_SSE_KEEPALIVE: Duration = Duration::from_secs(30); /// Wrap a stream of serializable events into an axum [`Sse`] response with /// JSON payloads and a 30-second keep-alive. /// /// Serialization errors for individual events are logged and the event is /// dropped from the output stream; the connection stays open for subsequent /// events. pub fn sse_response<T, S>(stream: S) -> Sse<impl Stream<Item = Result<Event, Infallible>>> where T: Serialize + Send + 'static, S: Stream<Item = T> + Send + 'static, { let events = stream.filter_map(|event| async move { match Event::default().json_data(&event) { Ok(e) => Some(Ok::<_, Infallible>(e)), Err(err) => { tracing::warn!("Dropping SSE event that failed to serialize: {}", err); None } } }); Sse::new(events).keep_alive(KeepAlive::new().interval(DEFAULT_SSE_KEEPALIVE)) } #[cfg(test)] mod tests { use super::*; use crate::notifications::{EventFilter, InMemoryBus, NotificationBus}; use axum::body::to_bytes; use axum::response::IntoResponse; use serde::{Deserialize, Serialize}; use std::sync::Arc; #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] struct Event { tag: String, count: u32, } fn pass_all<T>() -> EventFilter<T> { Arc::new(|_| true) } #[tokio::test] async fn sse_response_emits_serialized_events_then_keepalive_stops_stream() { let bus: InMemoryBus<Event> = InMemoryBus::new(4); let stream = bus.subscribe(pass_all()); bus.publish(Event { tag: "a".into(), count: 1, }) .await; bus.publish(Event { tag: "b".into(), count: 2, }) .await; // Close the bus so the subscription stream terminates naturally. drop(bus); let response = sse_response(stream).into_response(); let status = response.status(); assert!(status.is_success(), "expected 2xx, got {status}"); // Read the whole body — bounded because the stream terminates once // the bus is dropped. let body = response.into_body(); let bytes = to_bytes(body, 64 * 1024).await.unwrap(); let text = std::str::from_utf8(&bytes).unwrap(); assert!( text.contains(r#""tag":"a""#), "body must contain first event, got: {text}" ); assert!( text.contains(r#""tag":"b""#), "body must contain second event, got: {text}" ); } }
GitRust