Files
pubsub/docs/architecture/call-protocol.md
glm-5.1 04b3464c36 Add call protocol module with streaming support
New sub-path export @alkdev/pubsub/call providing:
- CallEventSchema (TypeBox schemas) for call.requested/responded/part/completed/aborted/error
- PendingRequestMap with call() (request/response) and subscribe() (streaming via Repeater)
- CallError class and CallErrorCode constants
- Scoped topic subscriptions (call.responded:{requestId}) to avoid O(n) fanout
- subscribe() yields call.part events until call.completed or call.error,
  with automatic call.aborted on consumer break

Also adds @alkdev/typebox as runtime dependency and architecture doc.
2026-04-30 13:46:39 +00:00

11 KiB

status, last_updated
status last_updated
draft 2026-04-30

Call Protocol

Unified event-based protocol for request/response and streaming operations. Built on @alkdev/pubsub's TypedEventTarget and Repeater primitives.

Overview

The call protocol provides a single event-based mechanism that works identically whether the operation is local (in-process), remote (hub/spoke over WebSocket or Iroh), or streamed (subscription). It is transport-agnostic — the same event shapes, same requestId correlation, same PendingRequestMap. Only the EventTarget changes.

Two consumption patterns share the same protocol:

  • call(): Publish call.requested, subscribe to response events scoped by requestId, resolve on first response → Promise<TOutput>
  • subscribe(): Publish call.requested, subscribe to call.part events scoped by requestId, yield each part until call.completed or call.errorRepeater<TOutput>

Both use call.requested as the trigger. The operationId and operation.type on the handler side determine which pattern applies. The protocol itself doesn't distinguish — it's the handler that decides whether to respond once (respond()) or stream (part() + complete()).

Event Types

All events use TypeBox schemas, compatible with @alkdev/pubsub's PubSubPublishArgsByKey. Schemas are exported as CallEventSchema for runtime validation.

CallEventSchema

const CallEventSchema = {
  "call.requested": Type.Object({
    requestId: Type.String(),
    operationId: Type.String(),
    input: Type.Unknown(),
    parentRequestId: Type.Optional(Type.String()),
    deadline: Type.Optional(Type.Number()),
    identity: Type.Optional(Type.Object({
      id: Type.String(),
      scopes: Type.Array(Type.String()),
      resources: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String()))),
    })),
  }),
  "call.responded": Type.Object({
    requestId: Type.String(),
    output: Type.Unknown(),
  }),
  "call.part": Type.Object({
    requestId: Type.String(),
    output: Type.Unknown(),
    index: Type.Optional(Type.Number()),
  }),
  "call.completed": Type.Object({
    requestId: Type.String(),
  }),
  "call.aborted": Type.Object({
    requestId: Type.String(),
  }),
  "call.error": Type.Object({
    requestId: Type.String(),
    code: Type.String(),
    message: Type.String(),
    details: Type.Optional(Type.Unknown()),
  }),
}

Topic Scoping

Response events are scoped by requestId using pubsub's built-in topic scoping:

Event Publish Subscribe (caller) Subscribe (handler)
call.requested pubsub.publish("call.requested", event) Unscoped: pubsub.subscribe("call.requested")
call.responded pubsub.publish("call.responded", requestId, event) Scoped: pubsub.subscribe("call.responded", requestId)
call.part pubsub.publish("call.part", requestId, event) Scoped: pubsub.subscribe("call.part", requestId)
call.completed pubsub.publish("call.completed", requestId, event) Scoped: pubsub.subscribe("call.completed", requestId)
call.aborted pubsub.publish("call.aborted", requestId, event) Scoped: pubsub.subscribe("call.aborted", requestId) Scoped: pubsub.subscribe("call.aborted", requestId)
call.error pubsub.publish("call.error", requestId, event) Scoped: pubsub.subscribe("call.error", requestId)

This gives every requestId its own event channel. On Redis, this maps to call.responded:{uuid} channels. On WebSocket or Iroh, the topic string is a routing key. In-process, it's a CustomEvent with type: "call.responded:{uuid}".

Why scoped instead of unscoped + manual matching? Scoped topics avoid O(n) fanout. A caller only receives events for its own request. This matters especially on Redis (pub/sub channels) and Iroh (topic订阅), where unscoped subscriptions would deliver every response to every listener.

Event Flow

Call (request/response)

Caller                              Handler
  │                                    │
  │─── call.requested ───────────────>│
  │     {requestId, operationId,       │
  │      input, identity, deadline}    │
  │                                    │
  │<── call.responded:{requestId} ────│
  │     {requestId, output}           │

On error:

  │<── call.error:{requestId} ───────│
  │     {requestId, code, message,    │
  │      details}                     │

On timeout or caller cancellation:

  │─── call.aborted:{requestId} ────>│
  │     {requestId}                   │

Subscribe (request/stream)

Caller                              Handler
  │                                    │
  │─── call.requested ───────────────>│
  │     {requestId, operationId,       │
  │      input, identity}             │
  │                                    │
  │<── call.part:{requestId} ────────│
  │     {requestId, output, index?}   │
  │                                    │
  │<── call.part:{requestId} ────────│
  │     {requestId, output, index?}   │
  │                                    │
  │<── call.completed:{requestId} ────│  ← stream ends normally
  │     {requestId}                   │

On stream error:

  │<── call.error:{requestId} ───────│
  │     {requestId, code, message}    │

On caller cancellation (consumer breaks out of for await):

  │─── call.aborted:{requestId} ────>│
  │     {requestId}                   │

Nesting

Nested calls include parentRequestId to track the call chain:

  │─── call.requested ───────────────>│  {requestId: A, parentRequestId: P}

This enables call graph reconstruction and abort cascading — every nested call includes its parent's requestId.

PendingRequestMap

The primary consumer interface. Wraps createPubSub internally and manages the full call/subscribe lifecycle.

Construction

const callMap = new PendingRequestMap(eventTarget?)
  • Creates an internal PubSub<CallPubSubMap>
  • If eventTarget is provided, passes it to createPubSub for transport-level event routing

call(operationId, input, options?)Promise<unknown>

  1. Generate requestId via crypto.randomUUID()
  2. Subscribe to call.responded:{requestId}, call.error:{requestId}, call.aborted:{requestId} (scoped)
  3. If deadline is set, start a timeout timer that publishes call.aborted on expiry
  4. Publish call.requested
  5. Return a Promise — resolves on call.responded, rejects on call.error or call.aborted
  6. Cleanup: close all scoped subscriptions on settlement

subscribe(operationId, input, options?)Repeater<unknown>

  1. Generate requestId via crypto.randomUUID()
  2. Publish call.requested
  3. Create scoped subscriptions: call.part:{requestId}, call.completed:{requestId}, call.error:{requestId}
  4. Return a Repeater that:
    • Yields output from each call.part event
    • Completes on call.completed
    • Rejects on call.error
    • On consumer break (Repeater stop), publishes call.aborted:{requestId} and closes all subscriptions

This means consumers can use operators:

const stream = callMap.subscribe("events.live", { topic: "sensors" });
const filtered = pipe(stream, filter(isRelevant), map(transform));
for await (const value of filtered) {
  // handle each filtered/mapped stream value
}

Handler-side methods

Method Description
respond(requestId, output) Publish call.responded:{requestId} — single response for call
part(requestId, output, index?) Publish call.part:{requestId} — next chunk in subscription stream
complete(requestId) Publish call.completed:{requestId} — stream ended normally
emitError(requestId, code, message, details?) Publish call.error:{requestId} — error response
abort(requestId) Publish call.aborted:{requestId} — caller cancellation

Transport Mapping

Same protocol, same event shapes, same PendingRequestMap — different EventTarget:

Transport Use Case EventTarget impl
In-process Local operations Browser EventTarget (default)
Redis Cross-process events RedisEventTarget from @alkdev/pubsub/event-target-redis
WebSocket Hub ↔ spoke bidirectional WebSocketEventTarget (future)
Iroh P2P QUIC IrohEventTarget (future)
SSE Server → client streaming SSEEventTarget (future)

Error Model

CallError

class CallError extends Error {
  readonly code: string;
  readonly details?: unknown;
}

Infrastructure Error Codes

Code When Details
OPERATION_NOT_FOUND No operation matches operationId { operationId: string }
ACCESS_DENIED Missing scopes { requiredScopes?: string[] }
VALIDATION_ERROR Input fails schema check Wrapped from Value.Errors
TIMEOUT Deadline exceeded { deadline: number }
ABORTED Call/stream cancelled
EXECUTION_ERROR Handler threw, no errorSchemas match { message: string }
UNKNOWN_ERROR Non-Error thrown { raw: string }

TypeBox Schemas and Validation

All event shapes are defined as TypeBox schemas in CallEventSchema. Consumers can use Value.Check() or Value.Errors() from @alkdev/typebox for runtime validation:

import { Value } from "@alkdev/typebox";
import { CallEventSchema } from "@alkdev/pubsub/call";

if (!Value.Check(CallEventSchema["call.requested"], incoming)) {
  const errors = [...Value.Errors(CallEventSchema["call.requested"], incoming)];
  // reject with VALIDATION_ERROR
}

This enables validation on the Iroh and SSE transports where incoming data is untrusted JSON.

Relationship to @alkdev/operations

@alkdev/operations provides the OperationRegistry, access control, and handler dispatch. It uses @alkdev/pubsub/call for:

  • PendingRequestMap — call/subscribe client interface
  • CallEventSchema — runtime validation of incoming events
  • CallError and CallErrorCode — error construction and matching
  • Type exports — CallRequestedEvent, etc. for handler signatures

The CallHandler in operations receives call.requested events, looks up the operation, validates input, checks access, and dispatches to the handler. For query/mutation handlers, it calls respond(). For subscription handlers, it calls part() and complete().

Operators and Stream Composition

Since subscribe() returns a Repeater<unknown> (which implements AsyncIterable), all pubsub operators work on streams:

import { pipe, filter, map } from "@alkdev/pubsub";

const stream = callMap.subscribe("events.live", { topic: "sensors" });
const filtered = pipe(
  stream,
  filter((e) => e.priority > 5),
  map((e) => ({ ...e, enriched: true })),
);

This works the same regardless of whether the stream source is in-process, remote via Redis, or remote via Iroh/SSE.