- Copy core source from alkhub_ts/packages/core/pubsub/ with import path fixups (typed_event_target.ts → types.ts, .ts → .js extensions) - Make PubSubPublishArgsByKey exported (was private type, needed by barrel) - Add package.json with sub-path exports and optional peer deps (ioredis) - Add tsup.config.ts with multi-entry + splitting for tree-shaking - Add tsconfig.json, vitest.config.ts, .gitignore - Add AGENTS.md with project conventions and adapter checklist - Add architecture docs following taskgraph/alkhub pattern: docs/architecture/README.md, api-surface.md, event-targets.md, iroh-transport.md, build-distribution.md - Add ADRs: 001-graphql-yoga-fork, 002-tree-shake-pattern - Copy migration research doc to docs/research/migration.md - Dual-license MIT OR Apache-2.0 (matching taskgraph)
4.7 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-04-30 |
Event Target Adapters
In-process, Redis, and WebSocket event targets. All implement TypedEventTarget<TEvent>.
Interface Contract
Every adapter must implement:
| Method | Behavior |
|---|---|
addEventListener(type, callback) |
Register listener for event type. Callback receives CustomEvent with typed detail. |
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. |
In-Process (Default)
No adapter needed. createPubSub uses new EventTarget() by default. This works for single-process deployments where all pubsub participants share the same memory.
No explicit InProcessEventTarget class — the web standard EventTarget already implements the interface. Could be formalized later if a name makes the API clearer, but new EventTarget() is already the standard.
Redis
Import: @alkdev/pubsub/event-target-redis
Peer dep: ioredis@^5.0.0 (optional)
createRedisEventTarget
function createRedisEventTarget<TEvent extends TypedEvent>(
args: CreateRedisEventTargetArgs,
): TypedEventTarget<TEvent>;
CreateRedisEventTargetArgs
| Field | Type | Required | Description |
|---|---|---|---|
publishClient |
Redis | Cluster |
Yes | ioredis client for publishing. Can share a connection. |
subscribeClient |
Redis | Cluster |
Yes | ioredis client for subscribing. Must be dedicated — Redis requires subscriber connections to only receive messages. |
serializer |
{ stringify, parse } |
No | Custom serializer. Defaults to JSON. |
How It Works
dispatchEvent→publishClient.publish(event.type, serializer.stringify(event.detail))addEventListener→subscribeClient.subscribe(topic), track callbacks per topicremoveEventListener→ remove callback; if no callbacks remain for topic,subscribeClient.unsubscribe(topic)
Channel Naming
Currently uses raw event type as Redis channel name (e.g., session.status:proj_123). Architecture recommends alk:events:{eventType} prefix but this is not yet implemented. Should be configurable: createRedisEventTarget({ ..., prefix: "alk:events:" }).
Limitations (Current)
- No error handling — connection failures, reconnection, and message parse errors are not handled
- No channel prefix — raw event types as channel names risk collision in shared Redis instances
- No unsubscribe cleanup on client disconnect — if the subscribe client disconnects, registered callbacks remain in the map but will never fire
Test Coverage
5 tests in alkhub (publish path only, mocked ioredis). No tests for subscription-receive path, unsubscribe cleanup, or error handling.
WebSocket
Import: @alkdev/pubsub/event-target-websocket (not yet implemented)
Peer dep: none (WebSocket is a web standard)
Design (Spec from spoke-runner.md)
class WebSocketEventTarget implements TypedEventTarget<any> {
private listeners = new Map<string, Set<(event: CustomEvent) => void>>()
constructor(private ws: WebSocket) {
ws.onmessage = (msg) => {
const { type, payload } = JSON.parse(msg.data as string)
const event = new CustomEvent(type, { detail: payload })
for (const listener of this.listeners.get(type) ?? []) {
listener(event)
}
}
}
dispatchEvent(event: CustomEvent): boolean {
this.ws.send(JSON.stringify({ type: event.type, payload: event.detail }))
return true
}
addEventListener(type: string, listener: (event: CustomEvent) => void): void { ... }
removeEventListener(type: string, listener: (event: CustomEvent) => void): void { ... }
}
Key Properties
- Bidirectional —
dispatchEventsends over WS,addEventListenerreceives from WS - Per-connection — hub creates one per spoke connection
- JSON framing — WebSocket provides native message boundaries (no length-prefix needed)
- No native deps — works in browsers and Node
Gap: Reconnection
WebSocket connections drop. On reconnect, the spoke must re-register with the hub (same hub.register flow). The WebSocketEventTarget itself is per-connection — a new connection means a new event target instance. Reconnection logic belongs to the spoke lifecycle, not the event target.
Gap: Hub-Side Architecture
The hub needs per-connection event target + PendingRequestMap creation on accept, cleanup on disconnect. This is a hub architectural concern, not a pubsub concern. See @alkdev/alkhub_ts/docs/architecture/spoke-runner.md.