Files
pubsub/docs/architecture/event-targets/websocket-client.md
glm-5.1 bc0c2589c7 Resolve WebSocket event target open questions, add subscription control protocol
- 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.
2026-05-08 03:29:27 +00:00

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:

  1. Register the local listener (standard EventTarget behavior)
  2. If this is the first listener for this topic (no previous listeners registered), send a __subscribe control 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:

  1. Remove the local listener (standard EventTarget behavior)
  2. If no listeners remain for this topic, send an __unsubscribe control 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

  • BidirectionaldispatchEvent sends over WS, addEventListener receives from WS
  • Per-connection — one event target per WebSocket connection
  • Subscription forwardingaddEventListener/removeEventListener send __subscribe/__unsubscribe control 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 EventEnvelope JSON

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 → if send() 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:

  1. Create a new WebSocket
  2. Create a new WebSocketClientEventTarget
  3. Re-subscribe to all topics of interest (calling addEventListener for 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

  1. Send path — dispatchEvent serializes envelope and calls ws.send
  2. Receive path — ws.onmessage parses envelope, creates CustomEvent, dispatches to listeners
  3. Topic scoping — type:id topics correctly formed from envelope
  4. Subscription forwarding — addEventListener sends __subscribe on first listener for a topic
  5. Subscription dedup — multiple addEventListener for the same topic sends only one __subscribe
  6. Unsubscription forwarding — removeEventListener sends __unsubscribe when no listeners remain
  7. Connection close — ws.onclose propagates to listeners (error event?)
  8. Reconnection — new connection + new event target + re-subscribe restores all subscriptions
  9. Multiple listeners — multiple addEventListener on same topic receives events correctly