diff --git a/server/src/routes/events.rs b/server/src/routes/events.rs index f09105d..ee09759 100644 --- a/server/src/routes/events.rs +++ b/server/src/routes/events.rs @@ -9,8 +9,8 @@ use std::convert::Infallible; use std::time::Duration; use axum::extract::State; +use axum::response::IntoResponse; use axum::response::sse::{Event, KeepAlive, Sse}; -use futures_util::stream::Stream; use tokio_stream::StreamExt; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::errors::BroadcastStreamRecvError; @@ -22,7 +22,7 @@ use crate::events::EventSender; pub async fn handle_events( user: AuthenticatedWebUser, State(events_tx): State, -) -> Sse>> { +) -> impl IntoResponse { let rx = events_tx.subscribe(); let is_admin = user.is_admin(); let slug = user.slug.clone(); @@ -37,7 +37,9 @@ pub async fn handle_events( // struct with String + enum fields); on the unexpected error // path we drop the event rather than tear down the stream. let json = serde_json::to_string(&evt).ok()?; - Some(Ok(Event::default().event("case").data(json))) + Some(Ok::<_, Infallible>( + Event::default().event("case").data(json), + )) } // Lagged subscribers: skip silently. The next real event or the // user's next navigation will resync state from the filesystem. @@ -47,9 +49,16 @@ pub async fn handle_events( } }); - Sse::new(stream).keep_alive( + let sse = Sse::new(stream).keep_alive( KeepAlive::new() .interval(Duration::from_secs(15)) .text("keepalive"), - ) + ); + + // Tell a reverse proxy (nginx/OpenResty in front of minerva) not to + // buffer this response. Without it, the proxy holds events until the + // response ends — but an SSE stream never ends, so the browser stays + // silent and the page never live-reloads (#12). A direct client + // ignores the header. + ([("X-Accel-Buffering", "no")], sse) } diff --git a/server/tests/sse_integration.rs b/server/tests/sse_integration.rs index 606f76d..9635c07 100644 --- a/server/tests/sse_integration.rs +++ b/server/tests/sse_integration.rs @@ -66,3 +66,27 @@ async fn events_with_session_returns_sse_content_type() { "expected text/event-stream, got {ct:?}" ); } + +// Regression guard for #12: reverse proxies (nginx/OpenResty in front of +// minerva) buffer `text/event-stream` by default, which holds events in +// the proxy until the response ends — but an SSE response never ends, so +// the browser stays silent and the page never live-reloads. The +// `X-Accel-Buffering: no` response header tells the proxy to stream this +// one response unbuffered. A direct client ignores the header harmlessly. +#[tokio::test] +async fn events_disables_proxy_buffering() { + let app = test_app(); + let cookie = common::login(&app, "alice", "s").await; + + let resp = app + .oneshot(get_with_cookie(paths::EVENTS, &cookie)) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + header_opt(&resp, "x-accel-buffering"), + Some("no"), + "SSE response must disable reverse-proxy buffering" + ); +}