0047: Live data signals — eventSourceSignal, webSocketSignal
Status: Proposed
Context
ADR 0039 lifted persistence + lifecycle state into signals. ADR 0040
lifted the observer APIs. ADRs 0041–0042 lifted system preferences and
capabilities. The remaining ubiquitous live-data sources are
Server-Sent Events (EventSource) and WebSocket — the two
push-data primitives the platform ships. Apps wire them by hand today,
and the wiring is consistently wrong in two ways:
- Reconnect on page-lifecycle changes. Apps open the connection
eagerly, leave it open while the page is hidden / bfcached, and then
discover the connection is stale on resume. Or they over-correct and
never reconnect at all. The new lifecycle signals from ADR 0039
(
pageVisibilitySignal,bfcacheRestoreSignal) are exactly the right reconnect triggers, but every app re-derives the same wiring. - Trust boundary. Incoming messages are written into app state
without validation — the same security pattern that motivated
broadcastSignal's requiredvalidatepredicate. A malicious or compromised server can poison the signal with arbitrary structured data; an unvalidated receiver carries that data straight into templates.
This ADR ships two named primitives that handle both concerns. The
streaming-control-flow helper progressively(asyncIterable, view) is
explicitly deferred to its own ADR — it touches each() /
hydration / ADR 0006's streaming SSR machinery and deserves the room.
Decision
Add two live-data signal constructors to @purityjs/core:
export type LiveValidator<T> = (value: unknown) => value is T;
export type LiveReconnectPolicy = 'never' | 'on-visible' | 'always';
export interface EventSourceSignalOptions<T> {
initialValue: T;
validate: LiveValidator<T>;
eventName?: string; // default 'message'
withCredentials?: boolean;
parse?: (raw: string) => unknown; // default JSON.parse
reconnect?: LiveReconnectPolicy; // default 'on-visible'
}
export function eventSourceSignal<T>(
url: string | URL,
options: EventSourceSignalOptions<T>,
): ComputedAccessor<T>;
export type WebSocketSignal<T> = ComputedAccessor<T> & {
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void;
readyState(): 'connecting' | 'open' | 'closing' | 'closed';
};
export interface WebSocketSignalOptions<T> {
initialValue: T;
validate: LiveValidator<T>;
protocols?: string | string[];
parse?: (raw: MessageEvent['data']) => unknown;
reconnect?: LiveReconnectPolicy; // default 'on-visible'
}
export function webSocketSignal<T>(
url: string | URL,
options: WebSocketSignalOptions<T>,
): WebSocketSignal<T>;
Trust-boundary validation
Both functions require a validate predicate. The contract mirrors
ADR 0039's BroadcastValidator<T>: incoming messages flow through
parse(raw) first (default JSON.parse), then through
validate(parsed). Values that fail the predicate are dropped, and a
console.warn is emitted so attack signal is visible in dev. Parse
errors are caught and warned identically. Local writes via
webSocketSignal.send() skip validation — same-tab writes don't
cross the trust boundary.
Reconnect policy
Both functions take a reconnect option:
'on-visible'(default) — Open the connection when the page is visible. Close when hidden. Force a clean reconnect on eachbfcacheRestoreSignaltick (the underlying connection is paused during bfcache freeze; refreshing it on resume avoids stale state). Battery-friendly, matches what production apps mean by "keep this feed live."'always'— Stay open regardless of page state. Still reconnects onbfcacheRestoreSignalfor the same freeze-reset reason. Appropriate for kiosks / dashboards.'never'— Open once on construction; never reopen. Caller owns the lifecycle entirely.
The wiring uses pageVisibilitySignal and bfcacheRestoreSignal
from ADR 0039 — every app's "should I be connected right now?"
heuristic in two lines, exercising the exact subsystem that ADR
shipped.
send() on a closed/closing socket
webSocketSignal(...) returns a ComputedAccessor<T> extended with
two methods. Calling .send(data) when readyState() !== 'open'
logs a console.warn and silently drops the message. Rationale:
- Matches
localSignal's quota-error pattern (log + survive). - Lets fire-and-forget telemetry stay synchronous without
try/catchat every call site. - The
readyState()accessor is there for callers that need to pre-check or queue manually.
Buffering writes until the socket opens is explicitly out of
scope — unbounded queue if the socket never opens; better as a
follow-up option flag (buffered: true) than the default.
SSR + unavailable-platform fallbacks
Server contexts and browsers without EventSource / WebSocket
return inert constants — compute(() => options.initialValue) for
eventSourceSignal; for webSocketSignal, a ComputedAccessor
returning initialValue plus a .send() no-op and a
.readyState() that always returns 'closed'. No connection
attempted, no validator invoked. Isomorphic code reads naturally
without typeof window guards.
Explicit non-features
- No exponential-backoff retry. When the spec-mandated
EventSource retry exhausts or a WebSocket closes due to error /
network drop, the signal stays closed (apart from the bfcache /
visibility reconnect triggers above). Apps that need
network-error retry instantiate raw
WebSocket/EventSource. - No buffered
send(). Closed-socketsend()warns and drops. Queue-until-open is a follow-up option. - No cross-tab dedup. Each call opens its own connection. Apps
that want one socket fanned out to many UI consumers use a
module-scoped
state()+ one outerwebSocketSignaland read derived signals. - No URL-keyed caching. Same trade-off as
intersectionSignal/resizeSignal(ADR 0040). Predictable lifetimes beat ambiguous cache eviction. - No
progressively(asyncIterable, view)streaming control-flow in this ADR. That primitive integrates witheach(), hydration, and ADR 0006's streaming SSR pipeline. Reserve its own ADR. - No explicit
disconnect()on the returned accessor. The reconnect wiring + bfcache reset cover the typical lifecycles. Apps that need hard early disconnect use rawWebSocket/EventSource.
Consequences
Positive:
- Two named primitives close the live-data gap and exercise the lifecycle signals shipped in ADR 0039 with a real consumer.
validaterequired from day one — same lesson asbroadcastSignal. No "we'll add validation later" trapdoor.webSocketSignal.send()+.readyState()keep the bidirectional flow honest while preserving the signal-as-read-only spirit (validate-on-receive only).- SSR-safe by construction. Tree-shakes per primitive.
Negative:
- API surface grows by two functions + four exported types. Mitigated by the same grouping argument as the prior signal ADRs: one shape, one trust-boundary contract, one reconnect policy — nothing new to learn after the first.
- The
on-visibledefault introduces a subtle dependency on ADR 0039's lifecycle signals being correct. A bug inpageVisibilitySignalwould cause silent reconnect storms. That risk is bounded — the lifecycle ADR ships per-signal singletons with full test coverage, and the reconnect helper consumes them via the existingwatchplumbing. WebSocketSignal<T>is an augmentedComputedAccessor— the first signal in the codebase that's not a pure accessor. The precedent matchesLazyResourceAccessor(resource()exposes.refresh()/.mutate()), so the pattern is already in the family.
Neutral:
- Tests cover SSR constants + mocked
EventSource/WebSocketwith the four lifecycle paths (initial open, hidden close, visible reopen, bfcache reconnect), validator drop with warn, parse-error drop with warn, send-while-closed warn + drop, readyState transitions. jsdom doesn't ship either API in usable form, so tests install controllable mocks. - Bundle delta: ~600 bytes gzipped for both combined (rough estimate from comparable shapes).
Alternatives considered
Optional validate with a (v): v is T => true default.
Rejected: same as broadcastSignal's breaking-change follow-up.
Network-sourced data crosses a trust boundary; making validation
opt-in defeats the point because the path of least resistance
ships an unvalidated receiver.
reconnect default of 'always'. Rejected: doesn't exercise
the lifecycle signals at all, burns battery on backgrounded tabs,
and the only apps it benefits (kiosks) are the ones most likely to
already configure their own policy. Opting in to 'always' is a
one-line override.
Buffer send() writes until the socket opens. Rejected for
the default: introduces an unbounded queue if the socket never
opens. Worth a follow-up buffered: true option flag once apps
exercise the un-buffered shape.
Throw on send() to a closed socket. Rejected: forces every
fire-and-forget call site (telemetry, presence pings) to wrap in
try/catch. The signal-as-eventual-consistency model prefers
log+drop, matching localSignal's quota-error handling.
Auto-reconnect with exponential backoff after network errors.
Rejected for v1: backoff policy is the kind of thing every app
disagrees about (cap, jitter, max attempts, give-up signal). Apps
that need it wrap raw WebSocket; the signal layer handles the
page-lifecycle case which is the universal one.
Cache one connection per URL. Rejected: same trade-off as
intersectionSignal from ADR 0040. URL alone isn't a unique key
once protocols, validate, parse, and reconnect are factored
in; caching by the full options bag is opaque and apps that want
sharing build it via a module-scope wrapper.
One liveSignal(adapter) factory taking a transport plugin.
Rejected: forces every consumer to know about the transport
abstraction even for the single-transport case. EventSource and
WebSocket have meaningfully different surfaces (send + readyState
exist only for WebSocket; SSE has built-in retry, WS doesn't). Two
named constructors keep the call sites honest.