fix: add close() lifecycle methods to all adapters, fix WS client handler preservation, add Worker thread context guard

- 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
This commit is contained in:
2026-05-08 16:19:16 +00:00
parent 96ec2456e1
commit a12c52b407
13 changed files with 483 additions and 24 deletions

View File

@@ -16,6 +16,7 @@ Every adapter must implement:
| `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
@@ -56,14 +57,35 @@ 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](in-process.md) | (default, no import) | Implemented (built-in `EventTarget`) |
| [Redis](redis.md) | `@alkdev/pubsub/event-target-redis` | Implemented. Needs tests. |
| [WebSocket Client](websocket-client.md) | `@alkdev/pubsub/event-target-websocket-client` | Not yet implemented |
| [WebSocket Server](websocket-server.md) | `@alkdev/pubsub/event-target-websocket-server` | Not yet implemented |
| [Worker](worker.md) | `@alkdev/pubsub/event-target-worker` | Not yet implemented (R&D on Node vs Web Worker) |
| [Iroh Spoke](iroh-spoke.md) | `@alkdev/pubsub/event-target-iroh-spoke` | Deferred (pending fork of iroh-ts) |
| [Iroh Hub](iroh-hub.md) | `@alkdev/pubsub/event-target-iroh-hub` | Deferred (pending fork of iroh-ts) |
| [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) |