From f957d9ddc658ebc336acf95b33027ff55d054295 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 1 Jun 2026 13:22:42 +0200 Subject: [PATCH] fix(web): disable reverse-proxy buffering on SSE /events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverse proxies (OpenResty in front of minerva) buffer text/event-stream by default, holding events until the response ends. An SSE stream never ends, so the browser stays silent and WebUI pages never live-reload when reached via app.doctate.de — while a direct minerva.lan:3000 client (bypassing the proxy) works, explaining the desktop/Chromebook split. Emit X-Accel-Buffering: no on the /events response so the proxy streams it unbuffered; a direct client ignores the header. Return type widens from Sse<..> to impl IntoResponse to carry the header tuple. Regression guard: sse_integration asserts the header is present. refs #12 --- server/src/routes/events.rs | 19 ++++++++++++++----- server/tests/sse_integration.rs | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) 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" + ); +}