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.
This commit is contained in:
2026-04-30 13:46:39 +00:00
parent 9c332529df
commit 04b3464c36
6 changed files with 619 additions and 3 deletions

307
src/call.ts Normal file
View File

@@ -0,0 +1,307 @@
import { Type, type Static } from "@alkdev/typebox";
import { createPubSub, type PubSub } from "./create_pubsub.js";
import { Repeater, type Push, type Stop } from "./repeater.js";
export 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()),
}),
} as const;
export type CallRequestedEvent = Static<typeof CallEventSchema["call.requested"]>;
export type CallRespondedEvent = Static<typeof CallEventSchema["call.responded"]>;
export type CallPartEvent = Static<typeof CallEventSchema["call.part"]>;
export type CallCompletedEvent = Static<typeof CallEventSchema["call.completed"]>;
export type CallAbortedEvent = Static<typeof CallEventSchema["call.aborted"]>;
export type CallErrorEvent = Static<typeof CallEventSchema["call.error"]>;
type CallPubSubMap = {
"call.requested": [CallRequestedEvent];
"call.responded": [string, CallRespondedEvent];
"call.part": [string, CallPartEvent];
"call.completed": [string, CallCompletedEvent];
"call.aborted": [string, CallAbortedEvent];
"call.error": [string, CallErrorEvent];
};
export const CallErrorCode = {
OPERATION_NOT_FOUND: "OPERATION_NOT_FOUND",
ACCESS_DENIED: "ACCESS_DENIED",
VALIDATION_ERROR: "VALIDATION_ERROR",
TIMEOUT: "TIMEOUT",
ABORTED: "ABORTED",
EXECUTION_ERROR: "EXECUTION_ERROR",
UNKNOWN_ERROR: "UNKNOWN_ERROR",
} as const;
export type CallErrorCodeType = (typeof CallErrorCode)[keyof typeof CallErrorCode];
export class CallError extends Error {
readonly code: string;
readonly details?: unknown;
constructor(code: string, message: string, details?: unknown) {
super(message);
this.name = "CallError";
this.code = code;
this.details = details;
}
}
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
deadline?: number;
timer?: ReturnType<typeof setTimeout>;
unsubscribe: () => void;
}
export class PendingRequestMap {
private requests = new Map<string, PendingRequest>();
private pubsub: PubSub<CallPubSubMap>;
constructor(eventTarget?: EventTarget) {
this.pubsub = createPubSub<CallPubSubMap>(
eventTarget ? { eventTarget: eventTarget as any } : undefined,
);
}
async call(
operationId: string,
input: unknown,
options?: { parentRequestId?: string; deadline?: number; identity?: CallRequestedEvent["identity"] },
): Promise<unknown> {
const requestId = crypto.randomUUID();
const respondedIter = this.pubsub.subscribe("call.responded", requestId);
const errorIter = this.pubsub.subscribe("call.error", requestId);
const abortedIter = this.pubsub.subscribe("call.aborted", requestId);
const cleanup = (): void => {
respondedIter.return?.();
errorIter.return?.();
abortedIter.return?.();
};
let timer: ReturnType<typeof setTimeout> | undefined;
if (options?.deadline) {
timer = setTimeout(() => {
cleanup();
this.pubsub.publish("call.aborted", requestId, { requestId });
}, options.deadline - Date.now());
}
this.pubsub.publish("call.requested", {
requestId,
operationId,
input,
parentRequestId: options?.parentRequestId,
deadline: options?.deadline,
identity: options?.identity,
});
try {
const result = await new Promise((resolve, reject) => {
const pending: PendingRequest = {
resolve: (value: unknown) => {
if (timer) clearTimeout(timer);
cleanup();
resolve(value);
},
reject: (reason: unknown) => {
if (timer) clearTimeout(timer);
cleanup();
reject(reason);
},
deadline: options?.deadline,
timer,
unsubscribe: cleanup,
};
this.requests.set(requestId, pending);
(async () => {
for await (const event of respondedIter) {
const responded = event as CallRespondedEvent;
const p = this.requests.get(responded.requestId);
if (p) {
this.requests.delete(responded.requestId);
p.resolve(responded.output);
}
return;
}
})();
(async () => {
for await (const event of errorIter) {
const err = event as CallErrorEvent;
const p = this.requests.get(err.requestId);
if (p) {
this.requests.delete(err.requestId);
p.reject(new CallError(err.code, err.message, err.details));
}
return;
}
})();
(async () => {
for await (const event of abortedIter) {
const aborted = event as CallAbortedEvent;
const p = this.requests.get(aborted.requestId);
if (p) {
this.requests.delete(aborted.requestId);
p.reject(new CallError(CallErrorCode.ABORTED, `Request ${aborted.requestId} was aborted`));
}
return;
}
})();
});
return result;
} finally {
if (timer) clearTimeout(timer);
}
}
subscribe(
operationId: string,
input: unknown,
options?: { parentRequestId?: string; deadline?: number; identity?: CallRequestedEvent["identity"] },
): Repeater<unknown> {
const requestId = crypto.randomUUID();
const map = this;
return new Repeater<unknown>(async function (push: Push<unknown>, stop: Stop) {
map.pubsub.publish("call.requested", {
requestId,
operationId,
input,
parentRequestId: options?.parentRequestId,
deadline: options?.deadline,
identity: options?.identity,
});
const partIter = map.pubsub.subscribe("call.part", requestId);
const completedIter = map.pubsub.subscribe("call.completed", requestId);
const errorIter = map.pubsub.subscribe("call.error", requestId);
let settled = false;
const cleanup = (): void => {
if (!settled) {
settled = true;
map.pubsub.publish("call.aborted", requestId, { requestId });
}
partIter.return?.();
completedIter.return?.();
errorIter.return?.();
};
stop.then(cleanup);
try {
const partPromise = (async (): Promise<never> => {
for await (const event of partIter) {
const part = event as CallPartEvent;
await push(part.output);
}
throw new Error("part stream ended unexpectedly");
})();
const completedPromise = (async () => {
for await (const _ of completedIter) {
return;
}
})();
const errorPromise = (async (): Promise<never> => {
for await (const event of errorIter) {
const err = event as CallErrorEvent;
throw new CallError(err.code, err.message, err.details);
}
throw new Error("error stream ended unexpectedly");
})();
await Promise.race([completedPromise, errorPromise, partPromise]);
} finally {
cleanup();
stop();
}
});
}
respond(requestId: string, output: unknown): void {
this.pubsub.publish("call.responded", requestId, {
requestId,
output,
});
}
part(requestId: string, output: unknown, index?: number): void {
this.pubsub.publish("call.part", requestId, {
requestId,
output,
index,
});
}
complete(requestId: string): void {
this.pubsub.publish("call.completed", requestId, { requestId });
}
emitError(requestId: string, code: string, message: string, details?: unknown): void {
this.pubsub.publish("call.error", requestId, {
requestId,
code,
message,
details,
});
}
abort(requestId: string): void {
const pending = this.requests.get(requestId);
if (pending) {
if (pending.timer) clearTimeout(pending.timer);
this.requests.delete(requestId);
pending.unsubscribe();
this.pubsub.publish("call.aborted", requestId, { requestId });
pending.reject(new CallError(CallErrorCode.ABORTED, `Request ${requestId} was aborted`));
} else {
this.pubsub.publish("call.aborted", requestId, { requestId });
}
}
getPendingCount(): number {
return this.requests.size;
}
}