- 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.
4.3 KiB
ADR-003: Subscription Control Protocol for Fan-Out Adapters
Status: Accepted Date: 2026-05-08
Context
Fan-out adapters (WebSocket server, Iroh hub, Worker pool) send events to multiple connected clients. A naive implementation broadcasts every event to all connections. This wastes bandwidth and — for many use cases — leaks data to clients that shouldn't receive it (e.g., a chat room message sent to clients not in that room).
The type:id topic scoping pattern already provides the right routing abstraction ("message.sent:conv-123" is a natural fan-out key). But the server side needs to know which clients are interested in which topics. This requires a subscription control protocol: spokes must tell the hub what they want to receive.
Two design options were considered:
- Reserved envelope types — use the existing
EventEnvelopeformat with__-prefixed types for control messages (__subscribe,__unsubscribe) - Separate control channel — a distinct message schema for control, separate from data messages
Decision
Use reserved envelope types (__subscribe / __unsubscribe) within the existing EventEnvelope format.
Control events:
{ "type": "__subscribe", "id": "", "payload": { "topic": "message.sent:conv-123" } }
{ "type": "__unsubscribe", "id": "", "payload": { "topic": "message.sent:conv-123" } }
Convention: event types starting with __ are reserved for adapter control. They are handled internally and never dispatched to user-facing listeners. The id field in control events is the empty string "" by convention — control events use the topic field in their payload for routing instead of the type:id scoping pattern.
Rationale
-
Single message format — one serialization format between spoke and hub. No need to distinguish "is this a control message or a data message?" before parsing. Both are valid
EventEnvelopeobjects. -
Consistent with Redis model — Redis uses
SUBSCRIBE/UNSUBSCRIBEcommands on the same connection as regular data. The control channel shares the wire with data. This is a well-understood pattern. -
Simplicity — no second schema, no framing differentiation, no prefix bytes. Just a convention on
typenaming. -
Inspectable —
__subscribemessages are as debuggable as any other envelope. They can be logged, traced, and inspected with the same tooling. -
Extensible — new control types can be added (
__ping,__ack, etc.) without schema changes.
Decision: Topic-Based Fan-Out (Not Broadcast-All)
dispatchEvent on fan-out adapters sends only to connections subscribed to the event type, not all connections. This is the correct default because:
- Broadcasting all events to all connections wastes bandwidth at O(n) per event
- Broadcasting leaks data (e.g., a private message sent to all connected clients)
- The
type:idpattern already provides the routing key - Redis (in our existing adapter) already does topic-level filtering natively
- uWebSockets.js, Socket.io, and Centrifugo all implement topic/room-based fan-out
This requires fan-out adapters to maintain a subscriptions map (Map<string, Set<Connection>>) from topic string to subscribed connections. The routing key for fan-out is the full topic string (e.g., "message.sent:conv-123"), which is the CustomEvent.type — the same type:id pattern used by createPubSub.
Delivery Semantics
__subscribe and __unsubscribe are fire-and-forget. They use the same reliable transport as data messages (WebSocket, QUIC stream, etc.). Since these transports guarantee in-order delivery, subscription control messages are either delivered or the connection drops (triggering reconnection and re-subscription). No acknowledgment mechanism is needed.
Consequences
- Spokes must declare their interests via
addEventListener(which triggers__subscribeon the wire) - Fan-out adapters maintain subscription state (topic → connections map)
- On disconnection, all subscriptions for that connection are cleaned up
- On reconnection, the spoke must re-subscribe by calling
addEventListeneragain - The
__prefix convention must be documented and reserved — user event types must not start with__ - Direct messaging is a natural consequence:
"direct:${spokeId}"is just a topic with one subscriber