Files
pubsub/docs/architecture/event-targets.md
glm-5.1 8c025c3433 Set up project structure, source files, and architecture docs
- 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)
2026-04-30 10:20:41 +00:00

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

  • 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)

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

  • 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

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.