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 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user