- Resolve OQ1: WS server accepts raw WebSocket instances via addConnection/removeConnection (framework-agnostic, not coupled to Hono/Express/Bun/Deno) - Resolve OQ2: Backpressure handled by disconnecting slow consumers at configurable threshold (default 1MB), with onBackpressure callback for observability - Resolve OQ3: Topic-based fan-out with subscription tracking instead of broadcast-all; spokes send __subscribe/__unsubscribe control events; direct messaging via 'direct:' topic pattern Add ADR-003 for subscription control protocol decision. Update all fan-out adapters (WS server, Iroh hub) and spoke adapters (WS client, Iroh spoke) with subscription tracking/forwarding. Fix routing key ambiguity (full topic string, not event type alone). Add error handling, composition, and reserved type sections. Clarify Worker as symmetric-only.
6.3 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-05-08 |
WebSocket Client Event Target
Import: @alkdev/pubsub/event-target-websocket-client
Peer dep: none (WebSocket is a web standard)
Status: Not yet implemented.
Wraps a single WebSocket connection for the client (spoke) side. Bidirectional — can both send and receive events.
createWebSocketClientEventTarget
function createWebSocketClientEventTarget<TEvent extends TypedEvent>(
ws: WebSocket,
): TypedEventTarget<TEvent>;
Takes an already-connected WebSocket. The caller is responsible for connection lifecycle (including reconnection — see below).
Protocol
WebSocket provides native message boundaries (no length-prefix needed). Each message is a JSON-serialized EventEnvelope:
{ "type": "call.responded", "id": "uuid-123", "payload": { "output": 42 } }
Sending (dispatchEvent)
dispatchEvent(event) {
this.ws.send(JSON.stringify(event.detail));
// event.detail is the EventEnvelope { type, id, payload }
return true;
}
Receiving (addEventListener)
ws.onmessage = (msg) => {
const envelope = JSON.parse(msg.data);
const topic = `${envelope.type}:${envelope.id}`;
const event = new CustomEvent(topic, { detail: envelope });
// dispatch to local listeners
};
Subscription Forwarding
When the spoke calls addEventListener to subscribe to a topic, the client adapter must notify the server that this topic is now of interest. Similarly, removeEventListener must notify the server when no local listeners remain for a topic.
This is how the server adapter knows which events to forward to this spoke. Without subscription forwarding, the server would have to broadcast all events to all connections, wasting bandwidth and leaking data.
addEventListener
When addEventListener(type, callback) is called:
- Register the local listener (standard
EventTargetbehavior) - If this is the first listener for this topic (no previous listeners registered), send a
__subscribecontrol event to the server:
{ "type": "__subscribe", "id": "", "payload": { "topic": "call.responded:uuid-123" } }
The id field is the empty string ("") for control events by convention. The id field in EventEnvelope is normally required for topic scoping, but control events use the topic field in their payload instead. Implementations must not attempt topic scoping on control events (no addEventListener("__subscribe", cb) should match).
removeEventListener
When removeEventListener(type, callback) is called:
- Remove the local listener (standard
EventTargetbehavior) - If no listeners remain for this topic, send an
__unsubscribecontrol event:
{ "type": "__unsubscribe", "id": "", "payload": { "topic": "call.responded:uuid-123" } }
Subscription Reference Counting
The adapter tracks how many local listeners are registered per topic. __subscribe is sent only on the first addEventListener for a topic, and __unsubscribe only when the last removeEventListener for that topic is called. This avoids redundant subscription control messages.
Fire-and-Forget Semantics
__subscribe and __unsubscribe are fire-and-forget. Since WebSocket is a reliable ordered protocol, messages are either delivered or the connection drops (triggering reconnection and re-subscription). No acknowledgment mechanism is needed.
Why Control Events Instead of a Separate Protocol
The __subscribe/__unsubscribe control events use the same EventEnvelope format as regular events. This avoids introducing a second message schema on the same connection. The convention is simple: event types starting with __ are reserved for control messages and are handled internally by adapters, not dispatched to user-facing listeners.
This is analogous to how Redis uses SUBSCRIBE/UNSUBSCRIBE commands on the same connection as regular PUBLISH — the control channel shares the wire with data.
Key Properties
- Bidirectional —
dispatchEventsends over WS,addEventListenerreceives from WS - Per-connection — one event target per WebSocket connection
- Subscription forwarding —
addEventListener/removeEventListenersend__subscribe/__unsubscribecontrol events to the server, enabling topic-based fan-out - JSON framing — WebSocket provides native message boundaries
- No native deps — works in browsers and Node
- Envelope serialization — sends/receives the full
EventEnvelopeJSON
Error Handling
- Malformed JSON from server → silently ignored. The adapter logs a warning and continues processing other messages. The connection is not closed.
- Control events received from server → control events (
__subscribe,__unsubscribe) are not expected from the server on the client adapter. They are silently ignored if received. The server does not subscribe to topics; only spokes do. ws.send()failure → ifsend()throws (connection dropped between operations), the error propagates to the caller. The adapter does not retry.
Reconnection
WebSocket connections drop. On reconnect, the spoke must:
- Create a new
WebSocket - Create a new
WebSocketClientEventTarget - Re-subscribe to all topics of interest (calling
addEventListenerfor each)
Reconnection logic belongs to the spoke lifecycle, not the event target. The event target itself is per-connection. A new connection means a new instance. The new instance's addEventListener calls will automatically send fresh __subscribe control events to the server.
Test Plan
- Send path — dispatchEvent serializes envelope and calls ws.send
- Receive path — ws.onmessage parses envelope, creates CustomEvent, dispatches to listeners
- Topic scoping — type:id topics correctly formed from envelope
- Subscription forwarding — addEventListener sends __subscribe on first listener for a topic
- Subscription dedup — multiple addEventListener for the same topic sends only one __subscribe
- Unsubscription forwarding — removeEventListener sends __unsubscribe when no listeners remain
- Connection close — ws.onclose propagates to listeners (error event?)
- Reconnection — new connection + new event target + re-subscribe restores all subscriptions
- Multiple listeners — multiple addEventListener on same topic receives events correctly