fix(web): disable reverse-proxy buffering on SSE /events

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
This commit is contained in:
2026-06-01 13:22:42 +02:00
parent 5e86cb59b0
commit f957d9ddc6
2 changed files with 38 additions and 5 deletions
+14 -5
View File
@@ -9,8 +9,8 @@ use std::convert::Infallible;
use std::time::Duration; use std::time::Duration;
use axum::extract::State; use axum::extract::State;
use axum::response::IntoResponse;
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use futures_util::stream::Stream;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::wrappers::errors::BroadcastStreamRecvError; use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
@@ -22,7 +22,7 @@ use crate::events::EventSender;
pub async fn handle_events( pub async fn handle_events(
user: AuthenticatedWebUser, user: AuthenticatedWebUser,
State(events_tx): State<EventSender>, State(events_tx): State<EventSender>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> { ) -> impl IntoResponse {
let rx = events_tx.subscribe(); let rx = events_tx.subscribe();
let is_admin = user.is_admin(); let is_admin = user.is_admin();
let slug = user.slug.clone(); let slug = user.slug.clone();
@@ -37,7 +37,9 @@ pub async fn handle_events(
// struct with String + enum fields); on the unexpected error // struct with String + enum fields); on the unexpected error
// path we drop the event rather than tear down the stream. // path we drop the event rather than tear down the stream.
let json = serde_json::to_string(&evt).ok()?; 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 // Lagged subscribers: skip silently. The next real event or the
// user's next navigation will resync state from the filesystem. // 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() KeepAlive::new()
.interval(Duration::from_secs(15)) .interval(Duration::from_secs(15))
.text("keepalive"), .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)
} }
+24
View File
@@ -66,3 +66,27 @@ async fn events_with_session_returns_sse_content_type() {
"expected text/event-stream, got {ct:?}" "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"
);
}