Files
pubsub/docs/architecture/event-targets.md
glm-5.1 de7fc88f99 Simplify to transport-only: remove call protocol, add EventEnvelope, expand stream operators
- 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
2026-05-01 19:40:25 +00:00

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

  • dispatchEventpublishClient.publish(event.type, serializer.stringify(event.detail))
  • addEventListenersubscribeClient.subscribe(topic), track callbacks per topic
  • removeEventListener → remove callback; if no callbacks remain for topic, subscribeClient.unsubscribe(topic)
  • On message: deserializes with serializer.parse, reconstructs CustomEvent(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

  • BidirectionaldispatchEvent sends over WS, addEventListener receives 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 EventEnvelope JSON ({ 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 (WorkerPoolManager side): dispatches typed messages to workers via worker.postMessage(), receives responses via worker.onmessage
  • Worker thread: dispatches to main thread via parentPort.postMessage(), receives from main thread via globalThis.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 EventEnvelope ensures 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.