Initial package implementation: operations registry, call protocol, and adapters
Extracted from alkhub_ts packages/core/operations/ and packages/core/mcp/. - Runtime-agnostic (injected fs/env deps, no Deno globals) - Direct @logtape/logtape import instead of logger wrapper - PendingRequestMap with pubsub-wired call protocol - Peer-dep isolation for MCP adapter (sub-path export) - Schema const naming convention (XSchema + X type alias) - 68 tests passing, build + lint + test all green
This commit is contained in:
249
src/call.ts
Normal file
249
src/call.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { Type, type Static } from "@alkdev/typebox";
|
||||
import { createPubSub, type PubSub } from "@alkdev/pubsub";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
import { OperationRegistry } from "./registry.js";
|
||||
import { CallError, InfrastructureErrorCode, mapError } from "./error.js";
|
||||
import { validateOrThrow } from "./validation.js";
|
||||
import type { IOperationDefinition, Identity, OperationContext, AccessControl } from "./types.js";
|
||||
|
||||
const logger = getLogger("operations:call");
|
||||
|
||||
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.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 CallAbortedEvent = Static<typeof CallEventSchema["call.aborted"]>;
|
||||
export type CallErrorEvent = Static<typeof CallEventSchema["call.error"]>;
|
||||
export type CallEventMapValue = CallRequestedEvent | CallRespondedEvent | CallAbortedEvent | CallErrorEvent;
|
||||
|
||||
export const CallEventMap = CallEventSchema;
|
||||
|
||||
type CallPubSubMap = {
|
||||
"call.requested": [CallRequestedEvent];
|
||||
"call.responded": [CallRespondedEvent];
|
||||
"call.aborted": [CallAbortedEvent];
|
||||
"call.error": [CallErrorEvent];
|
||||
};
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
deadline?: number;
|
||||
timer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export interface CallHandlerConfig {
|
||||
registry: OperationRegistry;
|
||||
eventTarget?: EventTarget;
|
||||
}
|
||||
|
||||
export type CallHandler = (event: CallRequestedEvent) => Promise<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
|
||||
);
|
||||
this.setupSubscriptions();
|
||||
}
|
||||
|
||||
private setupSubscriptions(): void {
|
||||
const respondedIter = this.pubsub.subscribe("call.responded");
|
||||
(async () => {
|
||||
for await (const event of respondedIter) {
|
||||
const responded = event as CallRespondedEvent;
|
||||
const pending = this.requests.get(responded.requestId);
|
||||
if (pending) {
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
this.requests.delete(responded.requestId);
|
||||
pending.resolve(responded.output);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
const errorIter = this.pubsub.subscribe("call.error");
|
||||
(async () => {
|
||||
for await (const event of errorIter) {
|
||||
const err = event as CallErrorEvent;
|
||||
const pending = this.requests.get(err.requestId);
|
||||
if (pending) {
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
this.requests.delete(err.requestId);
|
||||
pending.reject(new CallError(err.code, err.message, err.details));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
const abortedIter = this.pubsub.subscribe("call.aborted");
|
||||
(async () => {
|
||||
for await (const event of abortedIter) {
|
||||
const aborted = event as CallAbortedEvent;
|
||||
const pending = this.requests.get(aborted.requestId);
|
||||
if (pending) {
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
this.requests.delete(aborted.requestId);
|
||||
pending.reject(new CallError(InfrastructureErrorCode.ABORTED, `Request ${aborted.requestId} was aborted`));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async call(
|
||||
operationId: string,
|
||||
input: unknown,
|
||||
options?: { parentRequestId?: string; deadline?: number; identity?: Identity },
|
||||
): Promise<unknown> {
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const pending: PendingRequest = { resolve, reject };
|
||||
|
||||
if (options?.deadline) {
|
||||
pending.deadline = options.deadline;
|
||||
pending.timer = setTimeout(() => {
|
||||
this.requests.delete(requestId);
|
||||
reject(new CallError(InfrastructureErrorCode.TIMEOUT, `Request ${requestId} timed out`, { deadline: options.deadline }));
|
||||
}, options.deadline - Date.now());
|
||||
}
|
||||
|
||||
this.requests.set(requestId, pending);
|
||||
|
||||
this.pubsub.publish("call.requested", {
|
||||
requestId,
|
||||
operationId,
|
||||
input,
|
||||
parentRequestId: options?.parentRequestId,
|
||||
deadline: options?.deadline,
|
||||
identity: options?.identity,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
respond(requestId: string, output: unknown): void {
|
||||
this.pubsub.publish("call.responded", {
|
||||
requestId,
|
||||
output,
|
||||
});
|
||||
}
|
||||
|
||||
emitError(requestId: string, code: string, message: string, details?: unknown): void {
|
||||
this.pubsub.publish("call.error", {
|
||||
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);
|
||||
this.pubsub.publish("call.aborted", { requestId });
|
||||
pending.reject(new CallError(InfrastructureErrorCode.ABORTED, `Request ${requestId} was aborted`));
|
||||
}
|
||||
}
|
||||
|
||||
getPendingCount(): number {
|
||||
return this.requests.size;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCallHandler(config: CallHandlerConfig): CallHandler {
|
||||
const { registry } = config;
|
||||
|
||||
return async (event: CallRequestedEvent): Promise<void> => {
|
||||
const { requestId, operationId, input, identity } = event;
|
||||
|
||||
try {
|
||||
const operation = registry.get(operationId);
|
||||
|
||||
if (!operation) {
|
||||
throw new CallError(
|
||||
InfrastructureErrorCode.OPERATION_NOT_FOUND,
|
||||
`Operation not found: ${operationId}`,
|
||||
{ operationId },
|
||||
);
|
||||
}
|
||||
|
||||
const accessControl: AccessControl = operation.accessControl as AccessControl;
|
||||
|
||||
if (identity && !checkAccess(accessControl, identity)) {
|
||||
throw new CallError(
|
||||
InfrastructureErrorCode.ACCESS_DENIED,
|
||||
`Access denied for operation: ${operationId}`,
|
||||
{ requiredScopes: accessControl.requiredScopes },
|
||||
);
|
||||
}
|
||||
|
||||
const context: OperationContext = {
|
||||
requestId,
|
||||
parentRequestId: event.parentRequestId,
|
||||
identity,
|
||||
};
|
||||
|
||||
validateOrThrow(operation.inputSchema, input, `Input validation for ${operationId}`);
|
||||
|
||||
await operation.handler(input, context);
|
||||
|
||||
} catch (error) {
|
||||
const callError = mapError(error);
|
||||
throw callError;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function checkAccess(accessControl: AccessControl, identity: Identity): boolean {
|
||||
const { requiredScopes, requiredScopesAny, resourceType, resourceAction } = accessControl;
|
||||
|
||||
if (requiredScopes.length > 0) {
|
||||
const hasAll = requiredScopes.every((scope: string) => identity.scopes.includes(scope));
|
||||
if (!hasAll) return false;
|
||||
}
|
||||
|
||||
if (requiredScopesAny && requiredScopesAny.length > 0) {
|
||||
const hasAny = requiredScopesAny.some((scope: string) => identity.scopes.includes(scope));
|
||||
if (!hasAny) return false;
|
||||
}
|
||||
|
||||
if (resourceType && resourceAction && identity.resources) {
|
||||
for (const [key, actions] of Object.entries(identity.resources)) {
|
||||
if (key.startsWith(`${resourceType}:`) && actions.includes(resourceAction)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user