- Remove src/call.ts (PendingRequestMap, CallEventSchema, CallError) — call protocol belongs in @alkdev/operations
- Add EventEnvelope type ({ type, id, payload }) as the cross-platform serialization contract
- Simplify createPubSub: replace PubSubPublishArgsByKey tuple model with PubSubEventMap; publish(type, id, payload) and subscribe(type, id) use explicit id for topic scoping
- Update Redis adapter to serialize/deserialize full EventEnvelope
- Expand operators: add take, reduce, toArray, batch, dedupe, window, flat, groupBy, chain, join
- Remove @alkdev/typebox runtime dependency (was only used by call.ts)
- Remove ./call sub-path export from package.json and tsup config
- Update all architecture docs to reflect transport-only scope, add Worker adapter, remove call protocol references
- Remove docs/architecture/call-protocol.md
- Update AGENTS.md with new source layout and transport-only principle
6.7 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-05-01 |
Event Target Adapters
In-process, Redis, WebSocket, and Worker 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 (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. |
All adapters use the EventEnvelope format ({ type, id, payload }) as the serialization contract. Adapters that cross process boundaries (Redis, WebSocket, Iroh) serialize/deserialize the full envelope as JSON.
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)- On message: deserializes with
serializer.parse, reconstructsCustomEvent(channel, { detail: envelope })
The detail of the CustomEvent dispatched to local listeners is the full EventEnvelope object ({ type, id, payload }).
Channel Naming
Currently uses the topic string directly as the 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
No tests yet (test directory is empty). Previous alkhub had 5 Redis tests (publish path only, mocked ioredis).
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 envelope = JSON.parse(msg.data as string) // { type, id, payload }
const topic = `${envelope.type}:${envelope.id}`
const event = new CustomEvent(topic, { detail: envelope })
for (const listener of this.listeners.get(topic) ?? []) {
listener(event)
}
}
}
dispatchEvent(event: CustomEvent): boolean {
this.ws.send(JSON.stringify(event.detail)) // sends { type, id, payload }
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
- Envelope serialization — sends/receives the full
EventEnvelopeJSON ({ type, id, payload })
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.
Worker
Import: @alkdev/pubsub/event-target-worker (not yet implemented)
Peer dep: none (Web Worker API is standard)
Design
A WorkerEventTarget implementing TypedEventTarget over postMessage/onmessage. This enables createPubSub to work across Web Worker boundaries.
The worker message protocol uses the EventEnvelope format:
{ "type": "call.responded", "id": "uuid-123", "payload": { "output": 42 } }
Two-Sided Design
- Main thread (
WorkerPoolManagerside): dispatches typed messages to workers viaworker.postMessage(), receives responses viaworker.onmessage - Worker thread: dispatches to main thread via
parentPort.postMessage(), receives from main thread viaglobalThis.onmessage
Both sides wrap postMessage/onmessage to implement the TypedEventTarget interface:
// Main thread side
const workerEventTarget = createWorkerEventTarget(worker);
// Worker thread side
const mainEventTarget = createMainThreadEventTarget();
Key Properties
- Bidirectional — both sides can publish and subscribe
- Per-worker — each worker gets its own event target
- Structured clone — Web Workers use structured clone algorithm for serialization, but JSON-serializable
EventEnvelopeensures cross-platform compatibility - No native deps — works in any environment with Web Worker support
Relationship to Taskgraph / Operations
The worker event target enables distributed operation execution. Workers can subscribe to call.requested events and publish call.responded events through the event target, allowing @alkdev/operations to dispatch work to worker threads via the same pubsub transport.