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:
@@ -90,7 +90,7 @@ The `Repeater` automatically cleans up its `addEventListener` when the consumer
|
||||
|--------|--------|-------------|
|
||||
| `EventEnvelope<TType, TPayload>` | `types.ts` | Cross-platform envelope: `{ type, id, payload }`. JSON-serializable. |
|
||||
| `TypedEvent<TType, TDetail>` | `types.ts` | Event with typed `type` and `detail`. Omits `CustomEvent`'s untyped fields. |
|
||||
| `TypedEventTarget<TEvent>` | `types.ts` | Extends `EventTarget` with typed `addEventListener`, `dispatchEvent`, `removeEventListener`. |
|
||||
| `TypedEventTarget<TEvent>` | `types.ts` | Extends `EventTarget` with typed `addEventListener`, `dispatchEvent`, `removeEventListener`. All adapters' `dispatchEvent` returns `true` (events are non-cancelable). |
|
||||
| `TypedEventListener<TEvent>` | `types.ts` | `(evt: TEvent) => void` |
|
||||
| `TypedEventListenerObject<TEvent>` | `types.ts` | `{ handleEvent(object: TEvent): void }` |
|
||||
| `TypedEventListenerOrEventListenerObject<TEvent>` | `types.ts` | Union of the above |
|
||||
@@ -99,6 +99,27 @@ The `Repeater` automatically cleans up its `addEventListener` when the consumer
|
||||
| `PubSubEvent<TEventMap, TType>` | `create_pubsub.ts` | Derived `TypedEvent` for a specific event type, with `detail` as `EventEnvelope<TType, TPayload>` |
|
||||
| `PubSubEventTarget<TEventMap>` | `create_pubsub.ts` | `TypedEventTarget<PubSubEvent<...>>` |
|
||||
|
||||
## Adapter Lifecycle
|
||||
|
||||
All transport adapters provide a `close()` method for graceful teardown. After `close()`:
|
||||
|
||||
- The adapter is unusable (no-op for `addEventListener`, `removeEventListener`, `dispatchEvent`)
|
||||
- All subscriptions are cleaned up (Redis channels unsubscribed, `__unsubscribe` sent for WebSocket topics, callbacks cleared)
|
||||
- Intercepted handlers are restored to their originals
|
||||
- The underlying transport (Redis connection, WebSocket, Worker) is **not** destroyed — the caller owns it
|
||||
|
||||
`close()` is idempotent. Calling it multiple times is safe.
|
||||
|
||||
Adapter return types reflect this:
|
||||
|
||||
| Adapter | Return type |
|
||||
|---------|-------------|
|
||||
| Redis | `RedisEventTarget<TEvent>` (extends `TypedEventTarget<TEvent>`, adds `close()`) |
|
||||
| WebSocket Client | `WebSocketClientEventTarget<TEvent>` (extends `TypedEventTarget<TEvent>`, adds `close()`) |
|
||||
| WebSocket Server | `WebSocketServerEventTarget<TEvent>` (extends `TypedEventTarget<TEvent>`, adds `addConnection`, `removeConnection`, `close()`) |
|
||||
| Worker Host | `WorkerHostEventTarget<TEvent>` (extends `TypedEventTarget<TEvent>`, adds `close()`) |
|
||||
| Worker Thread | `WorkerThreadEventTarget<TEvent>` (extends `TypedEventTarget<TEvent>`, adds `close()`) |
|
||||
|
||||
## Operators
|
||||
|
||||
All operators work with any `AsyncIterable`. Operators that return `Repeater` provide backpressure-aware push semantics.
|
||||
|
||||
@@ -28,22 +28,25 @@ No logger dependency. No TypeBox dependency (call protocol and schemas moved to
|
||||
types.ts # TypedEvent, TypedEventTarget, EventEnvelope
|
||||
create_pubsub.ts # createPubSub factory (adapted from graphql-yoga)
|
||||
operators.ts # filter, map, pipe, take, reduce, toArray,
|
||||
# batch, dedupe, window, flat, groupBy, chain, join
|
||||
# batch, dedupe, window, flat, groupBy, chain, join
|
||||
repeater.ts # Inlined from @repeaterjs/repeater (MIT)
|
||||
event-target-redis.ts # createRedisEventTarget (peer dep: ioredis)
|
||||
# Future adapters (each is its own entry point + peer dep island):
|
||||
# event-target-websocket.ts # (peer dep: none, web standard)
|
||||
# event-target-worker.ts # (peer dep: none, web standard)
|
||||
event-target-websocket-client.ts # createWebSocketClientEventTarget
|
||||
event-target-websocket-server.ts # createWebSocketServerEventTarget, WebSocketLike, SpokeEventTarget
|
||||
event-target-worker.ts # createWorkerHostEventTarget, createWorkerThreadEventTarget
|
||||
# Future adapters:
|
||||
# event-target-iroh.ts # (peer dep: @rayhanadev/iroh)
|
||||
test/
|
||||
create_pubsub.test.ts
|
||||
operators.test.ts
|
||||
event-target-redis.test.ts
|
||||
# event-target-websocket.test.ts
|
||||
# event-target-worker.test.ts
|
||||
# event-target-iroh.test.ts
|
||||
event-target-websocket-client.test.ts
|
||||
event-target-websocket-server.test.ts
|
||||
event-target-worker.test.ts
|
||||
integration-pubsub-redis.test.ts
|
||||
integration-websocket.test.ts
|
||||
docs/
|
||||
architecture.md
|
||||
architecture/
|
||||
architecture/
|
||||
research/
|
||||
package.json
|
||||
@@ -61,7 +64,8 @@ We use explicit sub-path exports rather than barrel-only + tree-shaking. Each ad
|
||||
"exports": {
|
||||
".": { ... },
|
||||
"./event-target-redis": { ... },
|
||||
"./event-target-websocket": { ... },
|
||||
"./event-target-websocket-client": { ... },
|
||||
"./event-target-websocket-server": { ... },
|
||||
"./event-target-worker": { ... },
|
||||
"./event-target-iroh": { ... }
|
||||
}
|
||||
|
||||
@@ -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) |
|
||||
Reference in New Issue
Block a user