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

@@ -31,6 +31,10 @@ interface EventEnvelope<TType extends string = string, TPayload = unknown> {
The envelope is the cross-platform serialization contract. All transport adapters serialize/deserialize this format. Domain-specific data goes in `payload`.
### Reserved Event Types
Event types starting with `__` (double underscore) are reserved for adapter control messages (e.g., `__subscribe`, `__unsubscribe`). User code must not define event types with this prefix. Control events use the empty string `""` for the `id` field by convention — they use the `topic` field in their `payload` for routing instead. See [ADR-003](decisions/003-subscription-control-protocol.md).
### Topic Scoping
Topics are scoped by `id` using the `type:id` convention:

View File

@@ -0,0 +1,65 @@
# 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:
1. **Reserved envelope types** — use the existing `EventEnvelope` format with `__`-prefixed types for control messages (`__subscribe`, `__unsubscribe`)
2. **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:
```json
{ "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
1. **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 `EventEnvelope` objects.
2. **Consistent with Redis model** — Redis uses `SUBSCRIBE`/`UNSUBSCRIBE` commands on the same connection as regular data. The control channel shares the wire with data. This is a well-understood pattern.
3. **Simplicity** — no second schema, no framing differentiation, no prefix bytes. Just a convention on `type` naming.
4. **Inspectable**`__subscribe` messages are as debuggable as any other envelope. They can be logged, traced, and inspected with the same tooling.
5. **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:id` pattern 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 `__subscribe` on 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 `addEventListener` again
- 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

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-05-07
last_updated: 2026-05-08
---
# Event Target Adapters
@@ -21,11 +21,41 @@ Every adapter must implement:
Adapters come in two shapes:
- **Symmetric** (single connection) — wraps one connection. Same interface on both sides. Examples: Redis, Iroh spoke, WebSocket client, Worker main-thread.
- **Fan-out** (multi-connection) — manages multiple connections. `dispatchEvent` sends to all; `addEventListener` aggregates from all. Examples: WebSocket server, Iroh hub, Worker pool manager.
- **Symmetric** (single connection) — wraps one connection. Same interface on both sides. Examples: Redis, Iroh spoke, WebSocket client, Worker.
- **Fan-out** (multi-connection) — manages multiple connections. `dispatchEvent` sends only to connections subscribed to the event type (topic-based fan-out); `addEventListener` aggregates from all. Examples: WebSocket server, Iroh hub.
Worker is a symmetric adapter (point-to-point). A Worker pool manager would be a fan-out concern, but that belongs in `@alkdev/operations`, not in this package.
The `createPubSub` layer is topology-agnostic. A hub composes multiple adapters and uses operators to combine streams — this is downstream application logic, not a package boundary.
## Composing Adapters
Adapters compose at the `createPubSub` level, not by nesting adapters. A common pattern:
```
Client (WS spoke) → Server (WS hub) → Redis → Other servers
```
In this topology:
- The WS hub adapter manages spoke connections with topic-based fan-out
- The hub's `createPubSub` instance also has a Redis event target for cross-server communication
- Subscription state at the hub level aggregates: when a spoke subscribes to topic `X`, the hub may also subscribe to `X` on Redis (if no other spoke is already subscribed)
This composition is handled by `createPubSub` bridging multiple event targets, not by adapters wrapping each other. The fan-out adapter's subscription tracking is local to its connected spokes; upstream subscriptions are a `createPubSub` concern.
## Subscription Control Protocol
Fan-out adapters must know which connections are interested in which topics. Symmetric adapters that connect to a fan-out adapter must declare their subscriptions. This is done through **control events**`EventEnvelope` messages with reserved `__`-prefixed types:
- `__subscribe` — sent when a spoke adds the first listener for a topic
- `__unsubscribe` — sent when a spoke removes the last listener for a topic
The `id` field in control events is the empty string (`""`) by convention. Control events use the `topic` field in their payload for routing instead. Control events are handled internally by adapters and never dispatched to user-facing listeners. Event types starting with `__` are reserved — user code must not define event types with this prefix.
See [ADR-003](decisions/003-subscription-control-protocol.md).
This is analogous to Redis's `SUBSCRIBE`/`UNSUBSCRIBE` commands — control messages share the same wire format and connection as data.
## Adapter Docs
| Adapter | Import | Status |

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-05-07
last_updated: 2026-05-08
---
# Iroh Hub Event Target
@@ -38,6 +38,8 @@ Each spoke gets its own read loop that parses length-prefixed JSON messages from
## Connection Lifecycle
Unlike the WebSocket server adapter (where the caller passes connections via `addConnection`), the Iroh hub adapter manages connections automatically via `endpoint.accept()`. This is a deliberate design difference: Iroh QUIC connections are accepted by the endpoint, not passed in by a framework. The hub has no `addConnection`/`removeConnection` API — connections are internal to the adapter.
1. Hub creates `Endpoint` and starts accepting
2. Spoke connects → hub gets `Connection` from `endpoint.accept()`
3. Hub accepts stream → `connection.acceptBi()``SendStream` + `RecvStream`
@@ -47,11 +49,20 @@ Each spoke gets its own read loop that parses length-prefixed JSON messages from
## Fan-Out
As a fan-out adapter, the Iroh hub must implement **topic-based fan-out**`dispatchEvent` sends only to spokes subscribed to that topic, not to all connected spokes. This requires a `subscriptions` map (`Map<string, Set<Spoke>>`) updated by `__subscribe`/`__unsubscribe` control events from spokes. See [ADR-003](../decisions/003-subscription-control-protocol.md) and [WebSocket server adapter](websocket-server.md) for the established pattern.
```ts
dispatchEvent(event) {
// event.type is the full topic string, e.g. "message.sent:conv-123"
// This matches the topics that spokes subscribe to via __subscribe
const message = encodeEnvelope(event.detail);
for (const spoke of this.spokes) {
spoke.sendStream.writeAll(message);
// Send only to spokes subscribed to this topic
const subscribers = this.subscriptions.get(event.type);
if (subscribers) {
for (const spoke of subscribers) {
spoke.sendStream.writeAll(message);
}
}
return true;
}
@@ -60,7 +71,8 @@ dispatchEvent(event) {
## Key Properties
- **Multi-connection** — manages a set of connected spokes
- **Fan-out** — dispatchEvent sends to all connected spokes
- **Topic-based fan-out** — dispatchEvent sends only to spokes subscribed to the event type
- **Subscription tracking** — maintains topic-to-spoke mapping, updated by `__subscribe`/`__unsubscribe` control events
- **Accepts incoming** — endpoint.accept() loop runs continuously
- **Cryptographic identity** — each spoke verified by Ed25519 NodeId
@@ -69,7 +81,7 @@ dispatchEvent(event) {
1. **Binding stability** — same as spoke adapter. `@rayhanadev/iroh` needs testing.
2. **Concurrent accept** — can `endpoint.accept()` handle multiple simultaneous connections?
3. **Stream vs. Connection per spoke** — current design: one bidirectional stream per spoke on a single connection. Alternative: one connection per spoke. Need to benchmark which is better for the expected workload.
4. **1:N fan-out** — for hub to N spokes, each spoke gets its own stream. For true broadcast, `iroh-gossip` would be better (not yet available in TS).
4. **iroh-gossip** — for true broadcast to many spokes, `iroh-gossip` would be more efficient than per-spoke streams. Not yet available in TS. The current subscription-tracked fan-out design works for moderate fan-out; gossip would be an optimization for very large fan-out later.
5. **Connection rejection** — how to reject connections from unknown `NodeId`s.
See [../iroh-transport.md](../iroh-transport.md) for full protocol details, identity, and comparison with WebSocket.

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-05-07
last_updated: 2026-05-08
---
# Iroh Spoke Event Target
@@ -49,12 +49,24 @@ The JSON payload is the `EventEnvelope`:
4. Spoke wraps streams in `IrohSpokeEventTarget`
5. On disconnect: `RecvStream.readExact()` throws, spoke must reconnect
## Subscription Forwarding
Same pattern as [WebSocket Client](websocket-client.md): when `addEventListener` is called for the first listener on a topic, the spoke sends a `__subscribe` control event to the hub. When `removeEventListener` removes the last listener for a topic, the spoke sends `__unsubscribe`. Reference counting ensures `__subscribe` is sent only on the first `addEventListener` for a topic, and `__unsubscribe` only when the last `removeEventListener` for that topic is called. This enables the hub's topic-based fan-out (see [ADR-003](../decisions/003-subscription-control-protocol.md)).
Control events use the existing `EventEnvelope` format:
```json
{ "type": "__subscribe", "id": "", "payload": { "topic": "message.sent:conv-123" } }
{ "type": "__unsubscribe", "id": "", "payload": { "topic": "message.sent:conv-123" } }
```
## Key Properties
- **NAT traversal** — spoke dials hub by `NodeId`, no public IP needed
- **Cryptographic identity** — `Connection.remoteNodeId()` verifies the hub
- **Bidirectional** — `dispatchEvent` writes to `SendStream`, `addEventListener` reads from `RecvStream`
- **Per-connection** — one event target per QUIC connection
- **Subscription forwarding** — `addEventListener`/`removeEventListener` send `__subscribe`/`__unsubscribe` control events to the hub (see [ADR-003](../decisions/003-subscription-control-protocol.md))
## R&D Needed

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

View File

@@ -1,86 +1,232 @@
---
status: draft
last_updated: 2026-05-07
last_updated: 2026-05-08
---
# WebSocket Server Event Target
**Import**: `@alkdev/pubsub/event-target-websocket-server`
**Peer dep**: none (WebSocket is a web standard, but a server framework like Hono may be needed for upgrade handling)
**Peer dep**: none (WebSocket is a web standard)
**Status**: Not yet implemented.
Manages multiple WebSocket connections for the server (hub) side. Handles fan-out: `dispatchEvent` sends to all connected spokes; `addEventListener` aggregates subscriptions across all connections.
Manages multiple WebSocket connections for the server (hub) side. Handles topic-based fan-out: `dispatchEvent` sends to connections subscribed to that topic; `addEventListener` aggregates subscriptions across all connections.
## `createWebSocketServerEventTarget`
```ts
interface WebSocketServerEventTarget<TEvent extends TypedEvent> extends TypedEventTarget<TEvent> {
addConnection(ws: WebSocket): void;
removeConnection(ws: WebSocket): void;
}
function createWebSocketServerEventTarget<TEvent extends TypedEvent>(
options: CreateWebSocketServerEventTargetArgs,
): TypedEventTarget<TEvent>;
): WebSocketServerEventTarget<TEvent>;
```
### `CreateWebSocketServerEventTargetArgs`
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `onConnection` | callback | No | Called when a new spoke connects. Receives the spoke's event target for per-connection customization. |
| `onDisconnection` | callback | No | Called when a spoke disconnects. Receives the spoke's event target for cleanup. |
| `onConnection` | `(spoke: TypedEventTarget<TEvent>, ws: WebSocket) => void` | No | Called when a new spoke connects. Receives the spoke's per-connection event target and the raw `WebSocket`. |
| `onDisconnection` | `(spoke: TypedEventTarget<TEvent>, ws: WebSocket) => void` | No | Called when a spoke disconnects. Receives the spoke's event target and `WebSocket` for cleanup. |
| `maxBufferedAmount` | `number` | No | Per-connection backpressure threshold in bytes. Default: `1_048_576` (1 MB). When a connection's `bufferedAmount` exceeds this, the connection is closed with code `1013` (Try Again Later). |
| `onBackpressure` | `(ws: WebSocket, bufferedAmount: number) => void` | No | Called for observability when a connection exceeds `maxBufferedAmount`, before the connection is closed. Cannot prevent the disconnect. Useful for logging and metrics. |
## How It Works
Unlike the client adapter, the server adapter manages a `Set<WebSocket>` of active connections:
The server adapter manages a `Set<WebSocket>` of active connections and a `Map<string, Set<WebSocket>>` (the `subscriptions` map) tracking which connections are subscribed to which topic strings.
- `dispatchEvent`iterates all connected WebSockets, sends JSON envelope to each
- `addEventListener` → registers local listeners. The server doesn't subscribe to individual spokes — it listens for events from any spoke
- `dispatchEvent`looks up connections subscribed to the event type, sends JSON envelope to each
- `addEventListener` → registers local listeners. The server listens for events from any spoke
- `removeEventListener` → removes local listeners
### Connection Lifecycle
The caller handles the HTTP upgrade (framework-specific) and passes connected `WebSocket` instances to the adapter:
```ts
import { createWebSocketServerEventTarget } from "@alkdev/pubsub/event-target-websocket-server";
const serverTarget = createWebSocketServerEventTarget({});
// Hono example — the adapter doesn't know about Hono
app.get("/ws", (c) => {
return c.upgrade(async (ws) => {
serverTarget.addConnection(ws);
ws.addEventListener("close", () => {
serverTarget.removeConnection(ws);
});
});
});
```
The adapter only handles raw `WebSocket` instances. It does not depend on any server framework (Hono, Express, Bun, Deno, etc.). The caller is responsible for:
- HTTP upgrade
- Passing connected `WebSocket`s to `addConnection`
- Removing connections on close via `removeConnection`
### `addConnection` / `removeConnection`
```ts
addConnection(ws: WebSocket): void
removeConnection(ws: WebSocket): void
```
The server adapter exposes these methods on the returned `WebSocketServerEventTarget` for the caller to register and unregister connections. When a connection is added, the adapter sets up `onmessage` and `onclose` handlers. When removed, it cleans up all topic subscriptions for that connection.
`removeConnection` cleans up internal state (subscription maps, event handlers) but does **not** close the `WebSocket`. The caller is responsible for closing the connection if needed. Typically it's called from a `close` event handler, where the connection is already closing.
### Error Handling
- **Malformed JSON from a spoke** → the message is silently ignored. The adapter logs a warning (via `console.warn` or a configurable logger) and continues processing other messages from that connection. The connection is not closed — a single malformed message should not disconnect a client.
- **Duplicate `__subscribe`** → idempotent. Adding a connection to a topic set it's already in is a no-op. The `Set<WebSocket>` data structure handles this naturally.
- **Invalid topic format in control events** → silently ignored. An empty topic string or malformed topic is logged and discarded.
- **Send failure** → if `ws.send()` throws (connection died between the `bufferedAmount` check and the send), the adapter catches the error, removes the connection from the subscription maps, and fires `onDisconnection`.
- **`onclose` from client** → the adapter removes the connection from all subscription maps and fires `onDisconnection`.
### Concurrency Model
This adapter assumes a single-threaded event loop (Node.js, Bun, Deno, browsers). In environments with worker threads, the caller must ensure `addConnection`/`removeConnection` and `dispatchEvent` are not called concurrently.
### Subscription Tracking
The server adapter maintains a `subscriptions` map (`Map<string, Set<WebSocket>>`) from topic string to subscribed connections. Spokes subscribe to topics they're interested in, and `dispatchEvent` only sends to connections that have subscribed to that topic.
**Why subscription tracking?** Without it, `dispatchEvent` would send every event to all connected spokes, regardless of whether they care about that topic. This wastes bandwidth and — worse — leaks data to clients that shouldn't receive it (e.g., a chat room message sent to clients not in that room).
**Topic scoping alignment:** The `type:id` topic pattern already used by `createPubSub` (e.g., `"message.sent:conv-123"`) is the routing key. A spoke subscribes to `"message.sent:conv-123"`, and the server only sends events for that topic to that spoke. This is the same pattern Redis uses for channel subscriptions — it's just implemented at the adapter level instead of delegated to an external broker.
**Direct messaging:** A spoke subscribing to `"direct:${spokeId}"` effectively creates a "room of one." The server can target that specific spoke by dispatching an event with that topic type. No special API needed — topic scoping handles it.
### Control Protocol
Spokes communicate subscription changes to the hub using control events in the `EventEnvelope` format with reserved `__`-prefixed types:
```json
{ "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 control messages. They are not dispatched to local listeners — they are handled internally by the adapter to update the subscription map.
When a spoke's `addEventListener` is called, the client adapter sends a `__subscribe` control event to the server. When `removeEventListener` is called and no listeners remain for that topic, the client adapter sends an `__unsubscribe` control event. See [WebSocket Client Event Target](websocket-client.md) for the client-side behavior.
### Incoming Messages
Each connected spoke sends JSON envelopes. The server listens on `ws.onmessage` for each connection:
```ts
for (const ws of this.connections) {
ws.onmessage = (msg) => {
const envelope = JSON.parse(msg.data);
const topic = `${envelope.type}:${envelope.id}`;
const event = new CustomEvent(topic, { detail: envelope });
this.dispatchEvent(event); // dispatches to local listeners
};
}
ws.onmessage = (msg) => {
const envelope = JSON.parse(msg.data);
// Control protocol
if (envelope.type === "__subscribe") {
addConnectionToTopic(ws, envelope.payload.topic);
return;
}
if (envelope.type === "__unsubscribe") {
removeConnectionFromTopic(ws, envelope.payload.topic);
return;
}
// Regular event — dispatch to local listeners
const topic = `${envelope.type}:${envelope.id}`;
const event = new CustomEvent(topic, { detail: envelope });
this.dispatchEvent(event);
};
```
### Outgoing Messages (Fan-out)
### Outgoing Messages (Topic-Based Fan-out)
```ts
dispatchEvent(event) {
// event.type is the full topic string, e.g. "message.sent:conv-123"
// This matches the topics that spokes subscribe to via __subscribe
const message = JSON.stringify(event.detail);
for (const ws of this.connections) {
ws.send(message);
// Send only to connections subscribed to this topic
const subscribers = this.subscriptions.get(event.type);
if (subscribers) {
for (const ws of subscribers) {
sendWithBackpression(ws, message);
}
}
return true;
}
```
The routing key for fan-out is the full `CustomEvent.type` string (e.g., `"message.sent:conv-123"`), which matches the topic strings that spokes subscribe to via `__subscribe`. This is the same `type:id` pattern used by `createPubSub`.
**Local listeners:** `dispatchEvent` also delivers to local listeners registered via `addEventListener` on the server itself, via the standard `EventTarget.prototype.dispatchEvent` mechanism. Local listeners use the same `type:id` topic strings.
### Backpressure
WebSocket `send()` never blocks — it silently buffers until memory is exhausted. Unbounded buffering is the primary cause of OOM in production WebSocket servers. This adapter handles backpressure with a configurable threshold policy:
**Default policy: disconnect slow consumers**
1. Before each `ws.send()`, check `ws.bufferedAmount`
2. If `bufferedAmount > maxBufferedAmount` (default 1 MB), close the connection with code `1013` (Try Again Later) — the current event is **not** sent
3. Call `onBackpressure` callback (if provided) before closing, for observability (logging, metrics). The connection is always closed after the callback runs; the callback cannot prevent the disconnect.
**Why disconnect, not drop silently:** A slow consumer that's still subscribed will continue receiving events. Silently dropping doesn't solve the underlying problem — it just delays it. Disconnecting is honest and gives the client a chance to reconnect.
**Why 1 MB default:** Enough headroom for brief network hiccups (a few hundred messages), but low enough to prevent runaway memory growth. This matches the production-tested defaults in uWebSockets.js and is far below Redis's 8 MB soft limit.
**`bufferedAmount` caveats:** In Node.js `ws`, `bufferedAmount` is updated asynchronously and may not reflect the exact current state. This is acceptable for threshold-based backpressure — the check is conservative, not precise.
## Per-Connection Spoke Targets
The server adapter creates a `WebSocketClientEventTarget` for each incoming connection. This allows the hub to target specific spokes if needed (e.g., responding to a specific request).
The server adapter does **not** create `WebSocketClientEventTarget` instances for each connection. The per-connection `TypedEventTarget` available in the `onConnection` callback is a minimal facade that:
- Provides `addEventListener`/`removeEventListener` that listens only for events **received from that specific spoke** — not events from other spokes or from the server's own `dispatchEvent`
- Dispatches events from that spoke to the server's local listeners
Direct messaging to a specific spoke is achieved through topic scoping: `"direct:${spokeId}"`. The spoke subscribes to that topic; the hub dispatches to it.
## Key Properties
- **Fan-out** — dispatchEvent sends to all connected spokes
- **Topic-based fan-out** — dispatchEvent sends only to connections subscribed to the event type, not all connections
- **Aggregate subscription** — addEventListener listens for events from any spoke
- **Connection lifecycle** — manages add/remove of WebSocket connections
- **Connection lifecycle** — `addConnection`/`removeConnection` for the caller to register/unregister WebSocket instances
- **Backpressure protection** — configurable threshold with disconnect policy
- **No native deps** — works with any WebSocket server (Node ws, Bun, Deno, Hono)
- **Framework-agnostic** — takes raw `WebSocket` instances, doesn't handle HTTP upgrade
## Open Questions
## Design Decisions
1. **Server framework coupling** — Should this adapter take a raw `WebSocketServer` or just handle `WebSocket` instances? Raw `WebSocket` instances keeps it framework-agnostic. The caller (hub code) handles the HTTP upgrade and passes connected `WebSocket`s to the adapter.
2. **Backpressure** — What happens when `ws.send()` blocks or buffers? Should there be a max buffer size per connection?
3. **Selective fan-out** — Should `dispatchEvent` always send to all connections, or should there be a way to target a specific spoke?
### ADR: Framework-Agnostic Raw WebSocket Interface
**Decision:** Accept raw `WebSocket` instances via `addConnection`/`removeConnection`, not a `WebSocketServer`.
**Rationale:** The adapter should work with any server framework. Hono, Bun, Deno, Node `ws`, and Cloudflare Workers all produce `WebSocket`-like objects but have incompatible server APIs. By accepting raw `WebSocket` instances, the caller handles the framework-specific HTTP upgrade and passes connected sockets to the adapter.
### ADR: Topic-Based Fan-out with Subscription Tracking
**Decision:** `dispatchEvent` sends only to connections subscribed to the event type, not all connections. Spokes declare subscriptions via `__subscribe`/`__unsubscribe` control events.
**Rationale:**
- Broadcast-all wastes bandwidth and leaks data to uninterested clients
- The `type:id` topic pattern already provides the routing abstraction — topics like `"message.sent:conv-123"` are natural fan-out keys
- This is how Redis works internally (channel subscriptions) — we're just implementing the same pattern at the adapter level
- Direct messaging falls out naturally: a spoke subscribing to `"direct:${spokeId}"` creates a "room of one"
### ADR: Disconnect Slow Consumers
**Decision:** When a connection's `bufferedAmount` exceeds the threshold, close the connection.
**Rationale:**
- Unbounded buffering causes OOM — the most common production failure mode for WebSocket servers
- Silently dropping messages doesn't solve the problem; the slow client keeps receiving new events
- Disconnecting is honest: the client can reconnect and re-subscribe
- This matches the production-proven behavior of uWebSockets.js and Redis's `client-output-buffer-limit`
## Test Plan
1. **Fan-out** — dispatchEvent sends to all connected WebSockets
2. **Incoming aggregation** — messages from any spoke dispatch to local listeners
3. **Connection add/remove** — new connections are tracked, disconnections are cleaned up
4. **Mixed topology** — server adapter and client adapters can communicate bidirectionally
1. **Topic-based fan-out** — dispatchEvent sends only to connections subscribed to that event type
2. **Subscription protocol**`__subscribe`/`__unsubscribe` control events correctly update the subscription map
3. **Incoming aggregation** — messages from any spoke dispatch to local listeners
4. **Connection add/remove** — new connections are tracked, disconnections clean up all subscriptions
5. **Backpressure disconnect** — slow consumers exceeding threshold are disconnected
6. **Backpressure callback**`onBackpressure` is called before disconnecting
7. **Direct messaging** — events dispatched to `"direct:${spokeId}"` reach only the target spoke
8. **Mixed topology** — server adapter and client adapters can communicate bidirectionally