Commits
Tarun Sukhani authored 856dee3f238
feat: add Server-Sent Events (SSE) support via Controller.openSSE() (PF-16, release 1.12.24)
Adds a first-class SSE producer API on top of Play's existing
chunked-transfer-encoding primitives. Eliminates the duplication where
every consumer (jclaw is the canonical example with two controllers
running ~70 lines of SSE plumbing each) reinvents the same data:/event:/
heartbeat/disconnect-detection scaffolding manually.
API surface (all on the new SseStream class):
* Sending events
- send(Object data) → data: <gson(data)>\n\n
- sendEvent(String name, Object data) → event:name\ndata:<gson>\n\n
- sendId(String id) → id: <id>\n\n
- sendComment(String text) → : <text>\n\n (heartbeats, debug)
- setRetry(Duration interval) → retry: <ms>\n\n
- sendRaw(byte[]/String) → bypass framing for pre-formatted bus
payloads (jclaw NotificationBus pattern)
* Lifecycle
- heartbeat(Duration) → schedule periodic keep-alive comment
- timeout(Duration) → auto-close at deadline
- onClose(Runnable) → cleanup hooks (subscription unwind)
- close() / isClosed() / completion()
Disconnect detection is heartbeat-based for v1 (the channel.closeFuture
hook gets messy across HTTP/1.1 and HTTP/2's per-stream subchannels;
worst-case detection latency = heartbeat interval, which is exactly the
30s jclaw uses today). Every send method silently no-ops when closed,
so callers don't need defensive `if (closed) return` checks before
publishing — the stream handles that.
Wire framing is spec-compliant (WHATWG/EventSource): multi-line data
splits across multiple `data:` lines, single-line fields strip \n/\r,
empty payloads are dropped (avoid 0\r\n\r\n premature termination).
JSON encoding via a per-class Gson instance, mirroring RenderJson.
Controller integration (Controller.openSSE):
- Sets Content-Type: text/event-stream
- Sets Cache-Control: no-cache + X-Accel-Buffering: no (proxy hint)
- Marks response.chunked = true
- Returns the SseStream — does NOT throw a Result, so the controller
stays alive (typically calling await(stream.completion()) for
long-lived subscription endpoints, or just stream.close() and
natural method return for per-request streams)
Validated against jclaw's actual patterns (~/Programming/jclaw):
* ApiEventsController.stream() — 25 lines collapses to ~5:
var sse = openSSE().heartbeat(...).timeout(...);
Runnable unsubscribe = NotificationBus.subscribe(sse::sendRaw);
sse.onClose(unsubscribe::run);
await(sse.completion());
* ApiChatController.streamChat() — cancelled+streamDone+heartbeatFuture
boilerplate disappears; writeSse / writeSseToken helpers become
sse.send(...) / sse.sendEvent("token", Map.of("content", content)).
Per-token Gson cost is ~50ns vs the ~10ms LLM-token cadence, so the
hand-tuned escape-no-Gson path is no longer needed.
Files:
* framework/src/play/mvc/SseStream.java (new) — runtime
* framework/src/play/mvc/Controller.java — openSSE() entry point
* framework/test-src/play/mvc/SseStreamTest.java (new) — 22 unit tests
* framework/test-src/integration/SseFunctionalTest.java (new) — 2 e2e
tests through real Netty (chunked + Transfer-Encoding + LastHttpContent)
* framework/test-src/integration/testapp/app/controllers/TestController.java
+ conf/routes — adds /events endpoint that the integration test drives
Tests:
* SseStreamTest — 22/22 (framing + lifecycle + escapes)
* SseFunctionalTest — 2/2 (real Netty, h1.1 chunked transfer)
* Http2FunctionalTest — 3/3 no regression
* Http3FunctionalTest — 1/1 no regression
* TestPlaySecret + TestAutotestUrl — 10/10 no regression
Out of scope for this commit (intentional v1 cuts):
* Channel.closeFuture-based disconnect detection (h1.1 + h2 nuances) —
heartbeat path covers the same case with bounded latency
* #{eventSource} template tag — modern apps consume SSE through their JS
framework directly (jclaw uses Nuxt's composable pattern); a server-
side template tag would be makework
* Last-Event-ID resume hook — the API can sendId(...), but rebuilding
state from a client-supplied resume ID is application logic, not
framework concern
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>