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()) {
            JsonObject eventData = Json.object().put("count", service.due(deckId));
            stream.id(String.valueOf(revision))
                  .event("due")
                  .send(eventData.toJson());
            Thread.sleep(1000);
        }
    });
});
id: 41
event: due
data: {"count":12}

send(data) sends one event, retry(…​) sets the reconnection delay, and comment(text) sends a heartbeat no listener sees. event(name) and id(…​) label the event that the next send writes, and no later one. A named event arrives at the listener registered for that name, and an unnamed one at EventSource.onmessage. The id lets the browser resume with Last-Event-ID. Data spanning several lines becomes one data: line each, which the client joins back together. An id or an event name has no such form, so a line break in either throws IllegalArgumentException, and so does a NUL in an id.

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 stream that sends no retry: line leaves the browser on its own default, which is a few seconds. The application is the one to set the delay, because only a server that closes every stream on deploy knows how soon it wants the browser back.

The retry: line goes out when retry(…​) is called rather than with the next event, because the delay belongs to the stream and not to one event. The delay holds for the rest of the stream and for the connections that follow, until another retry(…​) replaces it. A negative delay throws IllegalArgumentException, since the protocol gives a negative value no meaning and a browser silently ignores it.

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, so none of those three would apply to it. That difference 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. Because SSE needs nothing beyond AppServlet, a deployment on a plain servlet container gets SSE too.

Threads and lifetime

The request holds its thread for as long as the stream lasts. Holding a thread is the price of staying servlet-native. The virtual-thread executor makes many concurrent streams cheap.

A stream ends in one of three ways, and none of them is an error:

  • 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 JVM shutdown on Ctrl-C or SIGTERM closes them as well, beside the server’s own shutdown hook. A stream whose client has stopped reading is blocked in a write, which nothing can close from outside; it is marked closed without waiting for that write, and the server’s stop timeout ends it.

A stream quieter than Jetty’s 30-second connector idle timeout is closed by the server. Sending stream.comment("ping") on a timer keeps the stream alive, and a proxy in front of the server usually needs the same heartbeat.

The whole lifetime is one request, from the handler returning WebResponse.sse(…​) to the stream closing:

The handler returns WebResponse.sse; AppServlet commits the headers
Figure 1. The lifetime of an SSE stream