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.
This commit is contained in:
2026-05-08 03:29:27 +00:00
parent 8c33fa0218
commit bc0c2589c7
7 changed files with 373 additions and 45 deletions

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-05-07
last_updated: 2026-05-08
---
# WebSocket Client Event Target
@@ -50,24 +50,83 @@ ws.onmessage = (msg) => {
};
```
### 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:
```json
{ "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:
```json
{ "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** — `dispatchEvent` sends over WS, `addEventListener` receives from WS
- **Per-connection** — one event target per WebSocket connection
- **Subscription forwarding** — `addEventListener`/`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 create a new `WebSocket` and a new `WebSocketClientEventTarget`. Reconnection logic belongs to the spoke lifecycle, not the event target.
WebSocket connections drop. On reconnect, the spoke must:
The event target itself is per-connection. A new connection means a new instance.
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. **Connection close** — ws.onclose propagates to listeners (error event?)
5. **Multiple listeners** — multiple addEventListener on same topic
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