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:
@@ -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<EventSender>,
|
||||
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||
) -> 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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user