--- status: draft last_updated: 2026-05-08 --- # Event Target Adapters All adapters implement the `TypedEventTarget` 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. `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. ## 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`/`onclose` handlers that were intercepted - Unsubscribes from all Redis channels / sends `__unsubscribe` for 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](event-targets/in-process.md) | (default, no import) | Implemented (built-in `EventTarget`) | | [Redis](event-targets/redis.md) | `@alkdev/pubsub/event-target-redis` | Implemented | | [WebSocket Client](event-targets/websocket-client.md) | `@alkdev/pubsub/event-target-websocket-client` | Implemented | | [WebSocket Server](event-targets/websocket-server.md) | `@alkdev/pubsub/event-target-websocket-server` | Implemented | | [Worker](event-targets/worker.md) | `@alkdev/pubsub/event-target-worker` | Implemented | | [Iroh Spoke](iroh-transport.md) | `@alkdev/pubsub/event-target-iroh-spoke` | Deferred (pending fork of iroh-ts) | | [Iroh Hub](iroh-transport.md) | `@alkdev/pubsub/event-target-iroh-hub` | Deferred (pending fork of iroh-ts) |