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:
307
src/call.ts
Normal file
307
src/call.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user