SSE & Realtime — Hypermedia Push on ePHPm
Status: the two enabling primitives —
ephpm_kv_wait()and streaming brotli compression — shipped (PRs #184 and #185). The SSE hub described in the second half is Planned — not yet implemented, targeted at v0.7.
Why realtime is suddenly ePHPm-shaped
Hypermedia frameworks built on Server-Sent Events —
Datastar, htmx SSE, Turbo Streams — turn the
backend into a render-and-push loop: state changes, the server
re-renders a fragment, every connected browser morphs it into the DOM.
That workload is awkward on classic PHP-FPM (no long-lived connections,
no shared state, an external Redis for pub/sub, a proxy that buffers)
and almost native on ePHPm: worker mode already carries long-lived
streamed responses (send_response_stream), and the in-process KV
store already gives every worker thread the same state at ~100 ns per
op. The Pixelboard demo
(ephpm/datastar-demo) proved
a real multiplayer Datastar app runs on a stock v0.5.0 binary with no
server changes at all.
Two gaps kept it from being great. Both are now closed:
Shipped: ephpm_kv_wait() — push, not poll
Before: the only fan-out pattern was version polling — every SSE connection’s worker polled a KV version key every ~100 ms (latency floor = poll interval, idle CPU ∝ connected clients).
Now: ephpm_kv_wait(string $key, int $last_version, int $timeout_ms)
blocks the worker until the key is written (sub-millisecond wakeup) or
the timeout fires (keepalive tick). Idle cost is zero for the waiting
connections and zero for KV writers that nobody is waiting on — the
write path pays a single atomic load until the first wait in the
process. See the KV guide for semantics and the
SSE loop idiom.
Shipped: streaming brotli for SSE
Before: streamed worker responses always went out identity-encoded, so a Datastar “fat re-render” paid its full size on the wire every event.
Now: [server.response] compression_streaming = "sse" wraps
text/event-stream responses in a single brotli encoder whose window
persists for the stream’s lifetime, flushed per event so every event
decodes the moment it arrives. Because successive re-renders of the
same elements are nearly identical, event N compresses against events
N−1, N−2, … and collapses to a small delta — measured on the demo: a
60 s session of ~15.6 KB re-renders averaged ~37 bytes per event on the
wire (~420× fewer bytes; numbers in PR #185). Default is
"off", which keeps the streamed path byte-for-byte identical to
v0.5.0. See the configuration reference.
Planned — not yet implemented: the SSE hub (v0.7 target)
The remaining constraint is structural: one SSE connection parks one
worker thread for its whole lifetime, so [php] worker_count caps
concurrent viewers, and short action requests compete with streams for
the same pool. Fine for tens-to-hundreds of clients; wrong shape for
thousands of dashboard viewers who all see the same rendered
fragments.
A PHP execution context is thread-bound, so “detaching” a stream from its worker is off the table. The design that fits ePHPm’s architecture is a server-side SSE hub:
- Rust owns the connections. A new SAPI surface (sketch:
ephpm_sse_publish(topic, event)plus a router-level topic-subscribe endpoint) lets hyper hold the N client connections directly — no worker parked per viewer. - Render once, fan out. On a KV change (via the same watch
machinery behind
ephpm_kv_wait), the hub asks one worker to render the fragment once, then broadcasts the resulting bytes to all N subscribers — through the per-connection streaming brotli encoders shipped above. - Viewers decouple from
worker_count. Worker threads go back to being a render pool; connection count becomes a hyper/file-descriptor problem, which Rust is good at.
Open design questions (why this is a page and not a PR): topic registry lifecycle and auth, per-subscriber backpressure policy (drop vs. coalesce vs. disconnect slow readers), event replay/last-event-id semantics, and how per-vhost isolation maps onto topics in multi-tenant mode. Nothing below this heading exists in the code today.