CRITICAL: createPubSub.publish() was dispatching CustomEvent with
just the event type (e.g. 'call.responded') instead of the composite
topic string ('call.responded:uuid-123'). This broke all adapters
that rely on topic-scoped dispatch — Redis subscribe/publish
channels didn't match, and WS server fan-out routing would fail.
Fixed to dispatch with the full type:id composite.
Other fixes:
- Add __ prefix runtime guard in publish() (reserved for control)
- Add Redis barrel re-export to src/index.ts (ADR-002 compliance)
- Clarify WS server: adapter's onclose calls removeConnection
internally; user doesn't need to
- WS client: document null callback no-op, removeEventListener
edge cases (unregistered callback, null callback)
- WS server: document dispatchEvent always returns true
- Redis spec: document in-flight message edge case after unsubscribe
- Worker adapter: rename createMainThreadEventTarget to
createWorkerThreadEventTarget, createWorkerEventTarget to
createWorkerHostEventTarget (fix inverted naming)
- api-surface.md: add PubSub.publish() section documenting the
type:id composite and __ guard
6.7 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
callbackisnull, this is a no-op — no listener is registered and no__subscribeis sent - 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
callbackwas never registered for this type, this is a no-op — no__unsubscribeis sent (the reference count wasn't incremented, so it shouldn't be decremented) - If
callbackisnull, remove all listeners for this type. If no listeners remain after removal, send__unsubscribe - If this was the last registered listener 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