- Add close() to Redis, WS Client, WS Server, Worker Host, Worker Thread adapters for graceful teardown (cleanup subscriptions, restore handlers, clear maps) - WS Client now saves/restores original onmessage (consistent with WS Server) - WS Client dispatchEvent/addEventListener/removeEventListener are no-ops after close() - WS Server close() removes all connections and clears local listeners - Redis close() unsubscribes all channels and removes message listener - Worker Host/Thread close() restore original onmessage and clear callbacks - Worker Thread throws clear error if globalThis.postMessage is unavailable - Add double-call guard to WS Server removeConnection - Export new adapter interface types (RedisEventTarget, WebSocketClientEventTarget, etc.) - Add sideEffects: false to package.json for tree-shaking - Update architecture docs: lifecycle section, close() contract, adapter status updates - 22 new tests covering close(), handler restoration, idempotency, and context guard
5.7 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-05-08 |
Event Target Adapters
All adapters implement the TypedEventTarget<TEvent> interface and use the EventEnvelope format ({ type, id, payload }) as the serialization contract.
Interface Contract
Every adapter must implement:
| Method | Behavior |
|---|---|
addEventListener(type, callback) |
Register listener for event type. Callback receives CustomEvent with typed detail (an EventEnvelope). |
dispatchEvent(event) |
Send/dispatch event. Returns boolean (always true for non-cancelable events). |
removeEventListener(type, callback) |
Unregister listener. Clean up underlying subscription when no listeners remain for a topic. |
close() |
Teardown: clean up all subscriptions, restore any intercepted handlers, remove message listeners. Adapter is unusable after close(). Idempotent. Does not destroy the underlying transport (which the caller owns). |
Topology Model
Adapters come in two shapes:
- 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.
dispatchEventsends only to connections subscribed to the event type (topic-based fan-out);addEventListeneraggregates 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
createPubSubinstance 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 toXon 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.
This is analogous to Redis's SUBSCRIBE/UNSUBSCRIBE commands — control messages share the same wire format and connection as data.
Lifecycle
All adapters that acquire resources (handler interception, message listeners, subscriptions) provide a close() method for graceful teardown. close() is idempotent — calling it more than once is a no-op.
close() does not destroy the underlying transport (Redis connection, WebSocket, Worker). The caller owns the transport and decides when to disconnect it. close() only cleans up the adapter's own state:
- Removes message listeners from the transport
- Restores any original
onmessage/onclosehandlers that were intercepted - Unsubscribes from all Redis channels / sends
__unsubscribefor all active topics - Clears internal maps (subscription tracking, callbacks)
After close(), the adapter is unusable: addEventListener, removeEventListener, and dispatchEvent become no-ops. This is intentional — the caller should create a new adapter if they need to reconnect.
| Adapter | What close() does |
|---|---|
| Redis | Unsubscribes all channels, removes message listener, clears callback map |
| WebSocket Client | Sends __unsubscribe for all active topics, restores original onmessage, clears callback map |
| WebSocket Server | Removes all connections (restoring their original handlers, firing onDisconnection), clears local listener map |
| Worker Host | Restores original worker.onmessage, clears callback map |
| Worker Thread | Restores original globalThis.onmessage, clears callback map |
Adapter Docs
| Adapter | Import | Status |
|---|---|---|
| In-Process | (default, no import) | Implemented (built-in EventTarget) |
| Redis | @alkdev/pubsub/event-target-redis |
Implemented |
| WebSocket Client | @alkdev/pubsub/event-target-websocket-client |
Implemented |
| WebSocket Server | @alkdev/pubsub/event-target-websocket-server |
Implemented |
| Worker | @alkdev/pubsub/event-target-worker |
Implemented |
| Iroh Spoke | @alkdev/pubsub/event-target-iroh-spoke |
Deferred (pending fork of iroh-ts) |
| Iroh Hub | @alkdev/pubsub/event-target-iroh-hub |
Deferred (pending fork of iroh-ts) |