pub use axum::response::sse::{Event, KeepAlive, Sse};
#[cfg(feature = "ws")]
use std::convert::Infallible;
#[cfg(feature = "ws")]
use std::future::Future;
use std::time::Duration;
pub fn keep_alive() -> KeepAlive {
KeepAlive::new().interval(Duration::from_secs(15))
}
#[cfg(feature = "ws")]
pub fn from_subscriber(
subscriber: crate::channels::Subscriber,
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>> + use<>> {
use tokio_stream::StreamExt;
let stream = subscriber
.into_stream()
.map(|msg| Ok(Event::default().data(msg.into_string())));
Sse::new(stream).keep_alive(keep_alive())
}
#[cfg(feature = "ws")]
const GAP_EVENT: &str = "gap";
#[cfg(feature = "ws")]
const GAP_MARKER: &str = "{\"gap\":true}";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct EventId {
pub epoch: u64,
pub seq: u64,
}
impl std::fmt::Display for EventId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}", self.epoch, self.seq)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EventIdParseError;
impl std::fmt::Display for EventIdParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("invalid event id: expected \"{epoch}.{seq}\"")
}
}
impl std::error::Error for EventIdParseError {}
impl std::str::FromStr for EventId {
type Err = EventIdParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (epoch, seq) = s.split_once('.').ok_or(EventIdParseError)?;
Ok(Self {
epoch: epoch.parse().map_err(|_| EventIdParseError)?,
seq: seq.parse().map_err(|_| EventIdParseError)?,
})
}
}
#[must_use]
pub fn last_event_id(headers: &axum::http::HeaderMap) -> Option<EventId> {
headers
.get("last-event-id")?
.to_str()
.ok()?
.trim()
.parse()
.ok()
}
pub struct LastEventId(pub Option<EventId>);
impl<S> axum::extract::FromRequestParts<S> for LastEventId
where
S: Send + Sync,
{
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
Ok(Self(last_event_id(&parts.headers)))
}
}
#[cfg(feature = "ws")]
pub fn stream_resumable(
state: &crate::AppState,
topic: &str,
last_event_id: Option<EventId>,
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>> + use<>> {
use futures::StreamExt as _;
use tokio::sync::broadcast::error::RecvError;
let crate::channels::ResumeHandle {
subscriber,
replay,
gap,
next_live_id,
resumable,
epoch,
} = state.channels().resume(topic, last_event_id);
let missed_on_live_only = !resumable && last_event_id.is_some();
let mut prefix: Vec<Result<Event, Infallible>> = Vec::with_capacity(replay.len() + 1);
if gap || missed_on_live_only {
prefix.push(Ok(Event::default().event(GAP_EVENT).data(GAP_MARKER)));
}
for sequenced in replay {
prefix.push(Ok(Event::default()
.id(EventId {
epoch,
seq: sequenced.id,
}
.to_string())
.data(sequenced.message.into_string())));
}
let live = futures::stream::unfold(
(subscriber, next_live_id),
move |(mut subscriber, next_seq)| async move {
match subscriber.recv().await {
Ok(msg) => {
let event = Event::default()
.id(EventId {
epoch,
seq: next_seq,
}
.to_string())
.data(msg.into_string());
Some((Ok(event), (subscriber, next_seq.saturating_add(1))))
}
Err(RecvError::Lagged(skipped)) => {
let event = Event::default().event(GAP_EVENT).data(GAP_MARKER);
Some((Ok(event), (subscriber, next_seq.saturating_add(skipped))))
}
Err(RecvError::Closed) => None,
}
},
);
let stream = futures::stream::iter(prefix).chain(live);
Sse::new(stream).keep_alive(keep_alive())
}
#[cfg(feature = "ws")]
pub fn stream(
state: &crate::AppState,
topic: &str,
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>> + use<>> {
from_subscriber(state.channels().subscribe(topic))
}
#[cfg(feature = "ws")]
pub async fn stream_authorized<E, F, Fut>(
state: &crate::AppState,
topic: &str,
authorize: F,
) -> Result<Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>> + use<E, F, Fut>>, E>
where
F: FnOnce(String) -> Fut,
Fut: Future<Output = Result<(), E>>,
{
let subscriber = state
.channels()
.subscribe_authorized(topic, authorize)
.await?;
Ok(from_subscriber(subscriber))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_keep_alive_default() {
let ka = keep_alive();
let debug_str = format!("{ka:?}");
assert!(debug_str.contains("KeepAlive"));
}
#[cfg(feature = "ws")]
#[tokio::test]
async fn stream_helper_builds_sse_from_app_state_channels() {
let state = crate::AppState::for_test();
let _sse = stream(&state, "lobby");
}
#[test]
fn last_event_id_parses_valid_header() {
let mut headers = axum::http::HeaderMap::new();
headers.insert("last-event-id", "7.42".parse().unwrap());
assert_eq!(last_event_id(&headers), Some(EventId { epoch: 7, seq: 42 }));
}
#[test]
fn last_event_id_trims_whitespace() {
let mut headers = axum::http::HeaderMap::new();
headers.insert("last-event-id", " 3.7 ".parse().unwrap());
assert_eq!(last_event_id(&headers), Some(EventId { epoch: 3, seq: 7 }));
}
#[test]
fn last_event_id_returns_none_for_absent_or_unparseable() {
let empty = axum::http::HeaderMap::new();
assert_eq!(last_event_id(&empty), None);
let mut bad = axum::http::HeaderMap::new();
bad.insert("last-event-id", "not-a-number".parse().unwrap());
assert_eq!(last_event_id(&bad), None);
let mut legacy = axum::http::HeaderMap::new();
legacy.insert("last-event-id", "42".parse().unwrap());
assert_eq!(last_event_id(&legacy), None);
}
#[cfg(feature = "ws")]
#[tokio::test]
async fn stream_resumable_builds_sse_from_app_state_channels() {
let state = crate::AppState::for_test();
let _sse = stream_resumable(&state, "lobby", Some(EventId { epoch: 0, seq: 3 }));
let _cold = stream_resumable(&state, "lobby", None);
}
#[cfg(feature = "ws")]
#[tokio::test]
async fn stream_authorized_rejects_before_subscription() {
let state = crate::AppState::for_test();
let result = stream_authorized(&state, "private", |topic| async move {
assert_eq!(topic, "private");
Err::<(), &'static str>("denied")
})
.await;
assert!(matches!(result, Err("denied")));
assert!(!state.channels().snapshot().contains_key("private"));
}
#[cfg(feature = "ws")]
async fn collect_body(body: axum::body::Body, idle: Duration) -> String {
use http_body_util::BodyExt as _;
let mut body = body;
let mut raw = Vec::new();
while let Ok(Some(Ok(frame))) = tokio::time::timeout(idle, body.frame()).await {
if let Some(data) = frame.data_ref() {
raw.extend_from_slice(data);
}
}
String::from_utf8_lossy(&raw).into_owned()
}
#[cfg(feature = "ws")]
struct LiveOnlyBackend(crate::channels::LocalChannelsBackend);
#[cfg(feature = "ws")]
impl crate::channels::ChannelsBackend for LiveOnlyBackend {
fn publish(
&self,
topic: &str,
msg: crate::channels::ChannelMessage,
) -> Result<usize, crate::channels::ChannelPublishError> {
self.0.publish(topic, msg)
}
fn ensure_topic(
&self,
topic: &str,
) -> std::sync::Arc<tokio::sync::broadcast::Sender<crate::channels::ChannelMessage>>
{
self.0.ensure_topic(topic)
}
fn subscribe(&self, topic: &str) -> crate::channels::Subscriber {
self.0.subscribe(topic)
}
fn channel_count(&self) -> usize {
self.0.channel_count()
}
fn gc(&self) {
self.0.gc();
}
fn snapshot(&self) -> std::collections::HashMap<String, crate::channels::ChannelStats> {
self.0.snapshot()
}
}
#[cfg(feature = "ws")]
#[tokio::test]
async fn stream_resumable_live_only_backend_signals_gap_on_resume_request() {
use crate::channels::{Channels, LocalChannelsBackend};
let mut state = crate::AppState::for_test();
state.channels = Channels::with_backend(LiveOnlyBackend(LocalChannelsBackend::new(32)));
let sse = stream_resumable(&state, "feed", Some(EventId { epoch: 1, seq: 7 }));
let body = axum::response::IntoResponse::into_response(sse).into_body();
let raw = collect_body(body, Duration::from_millis(150)).await;
assert!(
raw.contains("event: gap") && raw.contains("\"gap\":true"),
"live-only resume must emit a gap sentinel, got: {raw:?}"
);
assert!(
!raw.contains("id:"),
"the gap sentinel carries no id: {raw:?}"
);
let cold = stream_resumable(&state, "feed", None);
let cold_body = axum::response::IntoResponse::into_response(cold).into_body();
let cold_raw = collect_body(cold_body, Duration::from_millis(150)).await;
assert!(
!cold_raw.contains("event: gap"),
"cold connect must not gap on a live-only backend: {cold_raw:?}"
);
}
#[cfg(feature = "ws")]
#[tokio::test]
async fn stream_resumable_live_lag_emits_gap_and_keeps_ids_dense() {
let mut state = crate::AppState::for_test();
state.channels = crate::channels::Channels::new(2);
let topic = "lag";
let sse = stream_resumable(&state, topic, None);
for i in 1..=10 {
state
.channels()
.publish(topic, format!("e{i}"))
.expect("publish should not fail");
}
let body = axum::response::IntoResponse::into_response(sse).into_body();
let raw = collect_body(body, Duration::from_millis(200)).await;
let gap_pos = raw
.find("event: gap")
.unwrap_or_else(|| panic!("a broadcast lag must emit a gap sentinel: {raw:?}"));
assert!(raw[gap_pos..].contains("\"gap\":true"));
let id9 = raw
.find(".9")
.unwrap_or_else(|| panic!("post-lag seq 9 must appear: {raw:?}"));
let id10 = raw
.find(".10")
.unwrap_or_else(|| panic!("post-lag seq 10 must appear: {raw:?}"));
assert!(
gap_pos < id9 && id9 < id10,
"gap must precede the dense post-lag ids, in order: {raw:?}"
);
assert!(
raw.contains("data: e9") && raw.contains("data: e10"),
"post-lag payloads must match the real published ids: {raw:?}"
);
}
}