Server-Sent Events
WebResponse.sse(…) answers with a text/event-stream instead of a body, one flushed event per call:
app.get("/decks/{deckId}/events", req -> {
long deckId = req.pathParamLong("deckId");
return WebResponse.sse(stream -> {
while (stream.isOpen()) {
Json.JsonObject eventData = Json.obj().put("count", service.due(deckId));
stream.id(String.valueOf(revision))
.send("due", eventData.toJson());
Thread.sleep(1000);
}
});
});
id: 41
event: due
data: {"count":12}
send(data) sends an unnamed event, send(event, data) a named one, id(…) labels the event that follows so the browser can resume with Last-Event-ID, retry(…) sets the reconnection delay, and comment(text) sends a heartbeat no listener sees.
Data spanning several lines becomes one data: line each, which the client joins back together.
Reconnection
retry(Duration) sets the delay the browser waits before it comes back after the stream drops, written as one retry: line in milliseconds.
stream.retry(Duration.ofSeconds(2));
A browser that is told nothing reconnects on a default of its own, a few seconds. Saying it is the application’s business: a server that closes every stream on deploy knows how soon it wants the browser back, and nothing else does.
The line goes out where it is called rather than with the next event, because the delay belongs to the stream and not to one event. It holds for the connections that follow, until another one is sent. A negative delay throws, since the protocol gives one no meaning and a browser ignores it in silence.
id(…) is the other half of a resumed stream: the browser sends the last id it saw back as Last-Event-ID, and the handler reads it with req.header("Last-Event-ID").
Why SSE is in core and WebSocket is not
SSE is an ordinary get route answering in a different shape, not a registration of its own.
app.routes() therefore lists it, filters cover it, and the request logger reports it when the stream ends.
A WebSocket upgrade leaves servlet dispatch, and none of that would still apply, which is the whole reason SSE is in core and WebSocket is not.
Where a socket is what the application actually needs, spider-silk-jetty-websocket maps one, on Jetty and under its own name.
It also means a deployment on a plain servlet container gets SSE too, since AppServlet needs nothing else.
Threads and lifetime
The request holds its thread for as long as the stream lasts. That is the price of staying servlet-native, and the virtual-thread executor is what makes many concurrent streams cheap.
Ending a stream is not an error, and there are three ways it happens:
-
the handler returns,
-
the client goes away: the write that discovers it throws
SseStream.Closed, which ends the handler where it stands, -
app.stop()closes every stream still open, before Jetty is asked to drain, because a stream is a request in flight that would otherwise never finish.
A stream quieter than Jetty’s 30-second connector idle timeout is closed by the server.
stream.comment("ping") on a timer is what keeps it alive, and proxies in front usually want the same.
The whole lifetime is one request, from the handler returning WebResponse.sse(…) to the stream closing: