feat: implement ADR-007 subscription transport — PendingRequestMap.subscribe(), CallHandler dispatch, SSE AsyncGenerator handlers
Add remote subscription support so spokes can consume streaming operations over pubsub transports (WebSocket, Redis). Extract checkAccess to access.ts to break circular dep between call.ts and subscribe.ts.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: stable
|
||||||
last_updated: 2026-05-11
|
last_updated: 2026-05-16
|
||||||
---
|
---
|
||||||
|
|
||||||
# Adapters
|
# Adapters
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: stable
|
||||||
last_updated: 2026-05-11
|
last_updated: 2026-05-16
|
||||||
---
|
---
|
||||||
|
|
||||||
# API Surface
|
# API Surface
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: stable
|
||||||
last_updated: 2026-05-11
|
last_updated: 2026-05-16
|
||||||
---
|
---
|
||||||
|
|
||||||
# Call Protocol
|
# Call Protocol
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: accepted
|
||||||
last_updated: 2026-05-13
|
last_updated: 2026-05-16
|
||||||
---
|
---
|
||||||
|
|
||||||
# ADR-007: Subscription Transport for SSE and Remote Streaming
|
# ADR-007: Subscription Transport for SSE and Remote Streaming
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: stable
|
||||||
last_updated: 2026-05-11
|
last_updated: 2026-05-16
|
||||||
---
|
---
|
||||||
|
|
||||||
# Response Envelopes
|
# Response Envelopes
|
||||||
@@ -514,9 +514,9 @@ The following **code** changes are pending:
|
|||||||
|
|
||||||
| Code | Change | Status |
|
| Code | Change | Status |
|
||||||
|------|--------|--------|
|
|------|--------|--------|
|
||||||
| `src/from_openapi.ts` | Generate `SubscriptionHandler` (AsyncGenerator) for SUBSCRIPTION operations, parse SSE stream, yield per-event | ❌ Not started |
|
| `src/from_openapi.ts` | Generate `SubscriptionHandler` (AsyncGenerator) for SUBSCRIPTION operations, parse SSE stream, yield per-event | ✅ Implemented |
|
||||||
| `src/call.ts` | Add `PendingRequestMap.subscribe()` method using Repeater from `@alkdev/pubsub` | ❌ Not started |
|
| `src/call.ts` | Add `PendingRequestMap.subscribe()` method using Repeater from `@alkdev/pubsub` | ✅ Implemented |
|
||||||
| `src/call.ts` | Update `CallHandler` to dispatch on operation type | ❌ Not started |
|
| `src/call.ts` | Update `CallHandler` to dispatch on operation type | ✅ Implemented |
|
||||||
| `src/subscribe.ts` | Ensure `subscribe()` handles `httpEnvelope` detection for SSE yields | ✅ Already handles envelopes |
|
| `src/subscribe.ts` | Ensure `subscribe()` handles `httpEnvelope` detection for SSE yields | ✅ Already handles envelopes |
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|||||||
27
src/access.ts
Normal file
27
src/access.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { AccessControl, Identity } from "./types.js";
|
||||||
|
|
||||||
|
export 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) {
|
||||||
|
if (!identity.resources) return false;
|
||||||
|
for (const [key, actions] of Object.entries(identity.resources)) {
|
||||||
|
if (key.startsWith(`${resourceType}:`) && actions.includes(resourceAction)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
188
src/call.ts
188
src/call.ts
@@ -1,10 +1,12 @@
|
|||||||
import { Type, type Static } from "@alkdev/typebox";
|
import { Type, type Static } from "@alkdev/typebox";
|
||||||
import { createPubSub, type PubSub } from "@alkdev/pubsub";
|
import { createPubSub, type PubSub, Repeater, type Push, type Stop } from "@alkdev/pubsub";
|
||||||
import { OperationRegistry } from "./registry.js";
|
import { OperationRegistry } from "./registry.js";
|
||||||
|
import { subscribe } from "./subscribe.js";
|
||||||
import { CallError, InfrastructureErrorCode, mapError } from "./error.js";
|
import { CallError, InfrastructureErrorCode, mapError } from "./error.js";
|
||||||
import { ResponseEnvelopeSchema, isResponseEnvelope } from "./response-envelope.js";
|
import { ResponseEnvelopeSchema, isResponseEnvelope } from "./response-envelope.js";
|
||||||
import type { ResponseEnvelope } from "./response-envelope.js";
|
import type { ResponseEnvelope } from "./response-envelope.js";
|
||||||
import type { Identity, OperationContext, AccessControl } from "./types.js";
|
import type { Identity, OperationContext } from "./types.js";
|
||||||
|
import { OperationType } from "./types.js";
|
||||||
|
|
||||||
export const CallEventSchema = {
|
export const CallEventSchema = {
|
||||||
"call.requested": Type.Object({
|
"call.requested": Type.Object({
|
||||||
@@ -49,13 +51,25 @@ type CallPubSubMap = {
|
|||||||
"call.error": CallErrorEvent;
|
"call.error": CallErrorEvent;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PendingRequest {
|
interface PendingCall {
|
||||||
resolve: (value: ResponseEnvelope) => void;
|
resolve: (value: ResponseEnvelope) => void;
|
||||||
reject: (reason: unknown) => void;
|
reject: (reason: unknown) => void;
|
||||||
deadline?: number;
|
deadline?: number;
|
||||||
timer?: ReturnType<typeof setTimeout>;
|
timer?: ReturnType<typeof setTimeout>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SubscriptionState {
|
||||||
|
push: Push<ResponseEnvelope>;
|
||||||
|
stop: Stop;
|
||||||
|
deadline?: number;
|
||||||
|
timer?: ReturnType<typeof setTimeout>;
|
||||||
|
consumerStopped?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingEntry =
|
||||||
|
| { type: "call"; pending: PendingCall }
|
||||||
|
| { type: "subscribe"; state: SubscriptionState };
|
||||||
|
|
||||||
export interface CallHandlerConfig {
|
export interface CallHandlerConfig {
|
||||||
registry: OperationRegistry;
|
registry: OperationRegistry;
|
||||||
callMap: PendingRequestMap;
|
callMap: PendingRequestMap;
|
||||||
@@ -64,7 +78,7 @@ export interface CallHandlerConfig {
|
|||||||
export type CallHandler = (event: CallRequestedEvent) => Promise<void>;
|
export type CallHandler = (event: CallRequestedEvent) => Promise<void>;
|
||||||
|
|
||||||
export class PendingRequestMap {
|
export class PendingRequestMap {
|
||||||
private requests = new Map<string, PendingRequest>();
|
private entries = new Map<string, PendingEntry>();
|
||||||
private pubsub: PubSub<CallPubSubMap>;
|
private pubsub: PubSub<CallPubSubMap>;
|
||||||
|
|
||||||
constructor(eventTarget?: EventTarget) {
|
constructor(eventTarget?: EventTarget) {
|
||||||
@@ -79,11 +93,21 @@ export class PendingRequestMap {
|
|||||||
(async () => {
|
(async () => {
|
||||||
for await (const envelope of respondedIter) {
|
for await (const envelope of respondedIter) {
|
||||||
const responded = envelope.payload;
|
const responded = envelope.payload;
|
||||||
const pending = this.requests.get(responded.requestId);
|
const entry = this.entries.get(responded.requestId);
|
||||||
if (pending) {
|
if (!entry) continue;
|
||||||
if (pending.timer) clearTimeout(pending.timer);
|
|
||||||
this.requests.delete(responded.requestId);
|
if (entry.type === "call") {
|
||||||
pending.resolve(responded.output as ResponseEnvelope);
|
if (entry.pending.timer) clearTimeout(entry.pending.timer);
|
||||||
|
this.entries.delete(responded.requestId);
|
||||||
|
entry.pending.resolve(responded.output as ResponseEnvelope);
|
||||||
|
} else {
|
||||||
|
if (entry.state.timer) {
|
||||||
|
clearTimeout(entry.state.timer);
|
||||||
|
if (entry.state.deadline) {
|
||||||
|
entry.state.timer = this.startSubscriptionTimer(responded.requestId, entry.state.deadline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.state.push(responded.output as ResponseEnvelope);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -92,11 +116,18 @@ export class PendingRequestMap {
|
|||||||
(async () => {
|
(async () => {
|
||||||
for await (const envelope of errorIter) {
|
for await (const envelope of errorIter) {
|
||||||
const err = envelope.payload;
|
const err = envelope.payload;
|
||||||
const pending = this.requests.get(err.requestId);
|
const entry = this.entries.get(err.requestId);
|
||||||
if (pending) {
|
if (!entry) continue;
|
||||||
if (pending.timer) clearTimeout(pending.timer);
|
|
||||||
this.requests.delete(err.requestId);
|
if (entry.type === "call") {
|
||||||
pending.reject(new CallError(err.code, err.message, err.details));
|
if (entry.pending.timer) clearTimeout(entry.pending.timer);
|
||||||
|
this.entries.delete(err.requestId);
|
||||||
|
entry.pending.reject(new CallError(err.code, err.message, err.details));
|
||||||
|
} else {
|
||||||
|
if (entry.state.timer) clearTimeout(entry.state.timer);
|
||||||
|
entry.state.consumerStopped = true;
|
||||||
|
entry.state.stop(new CallError(err.code, err.message, err.details));
|
||||||
|
this.entries.delete(err.requestId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -105,16 +136,34 @@ export class PendingRequestMap {
|
|||||||
(async () => {
|
(async () => {
|
||||||
for await (const envelope of abortedIter) {
|
for await (const envelope of abortedIter) {
|
||||||
const aborted = envelope.payload;
|
const aborted = envelope.payload;
|
||||||
const pending = this.requests.get(aborted.requestId);
|
const entry = this.entries.get(aborted.requestId);
|
||||||
if (pending) {
|
if (!entry) continue;
|
||||||
if (pending.timer) clearTimeout(pending.timer);
|
|
||||||
this.requests.delete(aborted.requestId);
|
if (entry.type === "call") {
|
||||||
pending.reject(new CallError(InfrastructureErrorCode.ABORTED, `Request ${aborted.requestId} was aborted`));
|
if (entry.pending.timer) clearTimeout(entry.pending.timer);
|
||||||
|
this.entries.delete(aborted.requestId);
|
||||||
|
entry.pending.reject(new CallError(InfrastructureErrorCode.ABORTED, `Request ${aborted.requestId} was aborted`));
|
||||||
|
} else {
|
||||||
|
if (entry.state.timer) clearTimeout(entry.state.timer);
|
||||||
|
entry.state.consumerStopped = true;
|
||||||
|
entry.state.stop();
|
||||||
|
this.entries.delete(aborted.requestId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startSubscriptionTimer(requestId: string, deadline: number): ReturnType<typeof setTimeout> {
|
||||||
|
return setTimeout(() => {
|
||||||
|
const entry = this.entries.get(requestId);
|
||||||
|
if (!entry || entry.type !== "subscribe") return;
|
||||||
|
if (entry.state.timer) clearTimeout(entry.state.timer);
|
||||||
|
entry.state.consumerStopped = true;
|
||||||
|
this.pubsub.publish("call.aborted", "", { requestId });
|
||||||
|
entry.state.stop(new CallError(InfrastructureErrorCode.TIMEOUT, `Subscription ${requestId} timed out (idle)`, { deadline }));
|
||||||
|
}, deadline);
|
||||||
|
}
|
||||||
|
|
||||||
async call(
|
async call(
|
||||||
operationId: string,
|
operationId: string,
|
||||||
input: unknown,
|
input: unknown,
|
||||||
@@ -123,17 +172,17 @@ export class PendingRequestMap {
|
|||||||
const requestId = crypto.randomUUID();
|
const requestId = crypto.randomUUID();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const pending: PendingRequest = { resolve, reject };
|
const pending: PendingCall = { resolve, reject };
|
||||||
|
|
||||||
if (options?.deadline) {
|
if (options?.deadline) {
|
||||||
pending.deadline = options.deadline;
|
pending.deadline = options.deadline;
|
||||||
pending.timer = setTimeout(() => {
|
pending.timer = setTimeout(() => {
|
||||||
this.requests.delete(requestId);
|
this.entries.delete(requestId);
|
||||||
reject(new CallError(InfrastructureErrorCode.TIMEOUT, `Request ${requestId} timed out`, { deadline: options.deadline }));
|
reject(new CallError(InfrastructureErrorCode.TIMEOUT, `Request ${requestId} timed out`, { deadline: options.deadline }));
|
||||||
}, options.deadline - Date.now());
|
}, options.deadline - Date.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
this.requests.set(requestId, pending);
|
this.entries.set(requestId, { type: "call", pending });
|
||||||
|
|
||||||
this.pubsub.publish("call.requested", "", {
|
this.pubsub.publish("call.requested", "", {
|
||||||
requestId,
|
requestId,
|
||||||
@@ -146,6 +195,47 @@ export class PendingRequestMap {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subscribe(
|
||||||
|
operationId: string,
|
||||||
|
input: unknown,
|
||||||
|
options?: { parentRequestId?: string; deadline?: number; identity?: Identity },
|
||||||
|
): AsyncIterable<ResponseEnvelope> {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
|
||||||
|
const repeater = new Repeater<ResponseEnvelope>((push: Push<ResponseEnvelope>, stop: Stop) => {
|
||||||
|
const state: SubscriptionState = { push, stop };
|
||||||
|
|
||||||
|
if (options?.deadline) {
|
||||||
|
state.deadline = options.deadline;
|
||||||
|
state.timer = this.startSubscriptionTimer(requestId, options.deadline);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.entries.set(requestId, { type: "subscribe", state });
|
||||||
|
|
||||||
|
this.pubsub.publish("call.requested", "", {
|
||||||
|
requestId,
|
||||||
|
operationId,
|
||||||
|
input,
|
||||||
|
parentRequestId: options?.parentRequestId,
|
||||||
|
deadline: options?.deadline,
|
||||||
|
identity: options?.identity,
|
||||||
|
});
|
||||||
|
|
||||||
|
stop.then(() => {
|
||||||
|
const entry = this.entries.get(requestId);
|
||||||
|
if (entry && entry.type === "subscribe") {
|
||||||
|
if (entry.state.timer) clearTimeout(entry.state.timer);
|
||||||
|
if (!entry.state.consumerStopped) {
|
||||||
|
this.pubsub.publish("call.aborted", "", { requestId });
|
||||||
|
}
|
||||||
|
this.entries.delete(requestId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return repeater;
|
||||||
|
}
|
||||||
|
|
||||||
respond(requestId: string, output: ResponseEnvelope): void {
|
respond(requestId: string, output: ResponseEnvelope): void {
|
||||||
if (!isResponseEnvelope(output)) {
|
if (!isResponseEnvelope(output)) {
|
||||||
throw new Error("PendingRequestMap.respond() requires a ResponseEnvelope. Use isResponseEnvelope() to check values before calling respond().");
|
throw new Error("PendingRequestMap.respond() requires a ResponseEnvelope. Use isResponseEnvelope() to check values before calling respond().");
|
||||||
@@ -166,17 +256,24 @@ export class PendingRequestMap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
abort(requestId: string): void {
|
abort(requestId: string): void {
|
||||||
const pending = this.requests.get(requestId);
|
const entry = this.entries.get(requestId);
|
||||||
if (pending) {
|
if (!entry) return;
|
||||||
if (pending.timer) clearTimeout(pending.timer);
|
|
||||||
this.requests.delete(requestId);
|
if (entry.type === "call") {
|
||||||
|
if (entry.pending.timer) clearTimeout(entry.pending.timer);
|
||||||
|
this.entries.delete(requestId);
|
||||||
this.pubsub.publish("call.aborted", "", { requestId });
|
this.pubsub.publish("call.aborted", "", { requestId });
|
||||||
pending.reject(new CallError(InfrastructureErrorCode.ABORTED, `Request ${requestId} was aborted`));
|
entry.pending.reject(new CallError(InfrastructureErrorCode.ABORTED, `Request ${requestId} was aborted`));
|
||||||
|
} else {
|
||||||
|
if (entry.state.timer) clearTimeout(entry.state.timer);
|
||||||
|
entry.state.consumerStopped = true;
|
||||||
|
this.pubsub.publish("call.aborted", "", { requestId });
|
||||||
|
entry.state.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getPendingCount(): number {
|
getPendingCount(): number {
|
||||||
return this.requests.size;
|
return this.entries.size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,8 +290,19 @@ export function buildCallHandler(config: CallHandlerConfig): CallHandler {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const spec = registry.getSpec(operationId);
|
||||||
|
if (!spec) {
|
||||||
|
throw new CallError(InfrastructureErrorCode.OPERATION_NOT_FOUND, `Operation not found: ${operationId}`, { operationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec.type === OperationType.SUBSCRIPTION) {
|
||||||
|
for await (const envelope of subscribe(registry, operationId, input, context)) {
|
||||||
|
callMap.respond(requestId, envelope);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const envelope = await registry.execute(operationId, input, context);
|
const envelope = await registry.execute(operationId, input, context);
|
||||||
callMap.respond(requestId, envelope);
|
callMap.respond(requestId, envelope);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const spec = registry.getSpec(operationId);
|
const spec = registry.getSpec(operationId);
|
||||||
const callError = mapError(error, spec?.errorSchemas);
|
const callError = mapError(error, spec?.errorSchemas);
|
||||||
@@ -203,29 +311,5 @@ export function buildCallHandler(config: CallHandlerConfig): CallHandler {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export 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) {
|
|
||||||
if (!identity.resources) return false;
|
|
||||||
for (const [key, actions] of Object.entries(identity.resources)) {
|
|
||||||
if (key.startsWith(`${resourceType}:`) && actions.includes(resourceAction)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as Type from "@alkdev/typebox";
|
import * as Type from "@alkdev/typebox";
|
||||||
import { FromSchema } from "./from_schema.js";
|
import { FromSchema } from "./from_schema.js";
|
||||||
import { OperationType, type OperationSpec, type OperationHandler, type OperationContext } from "./types.js";
|
import { OperationType, type OperationSpec, type OperationHandler, type SubscriptionHandler, type OperationContext } from "./types.js";
|
||||||
import { CallError } from "./error.js";
|
import { CallError } from "./error.js";
|
||||||
import { httpEnvelope } from "./response-envelope.js";
|
import { httpEnvelope } from "./response-envelope.js";
|
||||||
|
|
||||||
@@ -51,6 +51,95 @@ export interface HTTPServiceConfig {
|
|||||||
timeout?: number;
|
timeout?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SSEEvent {
|
||||||
|
data: string;
|
||||||
|
eventType: string;
|
||||||
|
lastEventId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSSEFrames(buffer: string): { events: SSEEvent[]; remaining: string } {
|
||||||
|
const events: SSEEvent[] = [];
|
||||||
|
let remaining = "";
|
||||||
|
|
||||||
|
let text = buffer;
|
||||||
|
if (text.charCodeAt(0) === 0xfeff) {
|
||||||
|
text = text.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = text.split(/\r\n|\r|\n/);
|
||||||
|
|
||||||
|
let dataBuffer: string[] = [];
|
||||||
|
let eventType = "";
|
||||||
|
let lastEventId = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
if (i === lines.length - 1) {
|
||||||
|
remaining = line;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line === "") {
|
||||||
|
if (dataBuffer.length > 0) {
|
||||||
|
events.push({
|
||||||
|
data: dataBuffer.join("\n"),
|
||||||
|
eventType: eventType || "message",
|
||||||
|
lastEventId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dataBuffer = [];
|
||||||
|
eventType = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith(":")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colonIndex = line.indexOf(":");
|
||||||
|
if (colonIndex === -1) {
|
||||||
|
const field = line;
|
||||||
|
const value = "";
|
||||||
|
processSSEField(field, value, dataBuffer, (type) => { eventType = type; }, (id) => { lastEventId = id; });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = line.slice(0, colonIndex);
|
||||||
|
let value = line.slice(colonIndex + 1);
|
||||||
|
if (value.startsWith(" ")) {
|
||||||
|
value = value.slice(1);
|
||||||
|
}
|
||||||
|
processSSEField(field, value, dataBuffer, (type) => { eventType = type; }, (id) => { lastEventId = id; });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataBuffer.length > 0) {
|
||||||
|
remaining = dataBuffer.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { events, remaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
function processSSEField(
|
||||||
|
field: string,
|
||||||
|
value: string,
|
||||||
|
dataBuffer: string[],
|
||||||
|
setEventType: (type: string) => void,
|
||||||
|
setLastEventId: (id: string) => void,
|
||||||
|
): void {
|
||||||
|
switch (field) {
|
||||||
|
case "data":
|
||||||
|
dataBuffer.push(value);
|
||||||
|
break;
|
||||||
|
case "event":
|
||||||
|
setEventType(value);
|
||||||
|
break;
|
||||||
|
case "id":
|
||||||
|
setLastEventId(value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveRef(spec: OpenAPISpec, ref: string): unknown {
|
function resolveRef(spec: OpenAPISpec, ref: string): unknown {
|
||||||
if (!ref.startsWith("#/")) {
|
if (!ref.startsWith("#/")) {
|
||||||
throw new Error(`External refs not supported: ${ref}`);
|
throw new Error(`External refs not supported: ${ref}`);
|
||||||
@@ -221,16 +310,109 @@ function getAuthHeaders(config: HTTPServiceConfig): Record<string, string> {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HTTPOperationHandler = OperationHandler<unknown, unknown, OperationContext> | SubscriptionHandler<unknown, unknown, OperationContext>;
|
||||||
|
|
||||||
function createHTTPOperation(
|
function createHTTPOperation(
|
||||||
spec: OpenAPISpec,
|
spec: OpenAPISpec,
|
||||||
operation: OpenAPIOperation,
|
operation: OpenAPIOperation,
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
config: HTTPServiceConfig,
|
config: HTTPServiceConfig,
|
||||||
): OperationSpec & { handler: OperationHandler<unknown, unknown, OperationContext> } {
|
): OperationSpec & { handler: HTTPOperationHandler } {
|
||||||
const operationId = normalizeOperationId(operation, method, path);
|
const operationId = normalizeOperationId(operation, method, path);
|
||||||
const opType = detectOperationType(method, operation);
|
const opType = detectOperationType(method, operation);
|
||||||
const authHeaders = getAuthHeaders(config);
|
const authHeaders = getAuthHeaders(config);
|
||||||
|
const responseHeaders = (): Record<string, string> => ({ ...authHeaders, "Content-Type": "application/json" });
|
||||||
|
|
||||||
|
if (opType === OperationType.SUBSCRIPTION) {
|
||||||
|
const handler: SubscriptionHandler<unknown, unknown, OperationContext> = async function* (input: unknown, context: OperationContext) {
|
||||||
|
const inputObj = (input as Record<string, unknown>) || {};
|
||||||
|
|
||||||
|
let urlPath = path;
|
||||||
|
const queryParams: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(inputObj)) {
|
||||||
|
if (path.includes(`{${key}}`)) {
|
||||||
|
urlPath = urlPath.replace(`{${key}}`, encodeURIComponent(String(value)));
|
||||||
|
} else if (key === "body") {
|
||||||
|
// body not typically used for SSE GET, but supported
|
||||||
|
} else {
|
||||||
|
queryParams[key] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(config.baseUrl + urlPath);
|
||||||
|
for (const [key, value] of Object.entries(queryParams)) {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...authHeaders,
|
||||||
|
"Accept": "text/event-stream",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
headers,
|
||||||
|
signal: config.timeout ? AbortSignal.timeout(config.timeout) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new CallError("EXECUTION_ERROR", `HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body!.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
const responseHeadersObj = Object.fromEntries(response.headers.entries());
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value: chunk } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(chunk, { stream: true });
|
||||||
|
const { events, remaining } = parseSSEFrames(buffer);
|
||||||
|
buffer = remaining;
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.data.trim() === "") continue;
|
||||||
|
let parsedData: unknown = event.data;
|
||||||
|
try {
|
||||||
|
parsedData = JSON.parse(event.data);
|
||||||
|
} catch {
|
||||||
|
// not JSON — yield raw data string
|
||||||
|
}
|
||||||
|
yield httpEnvelope(parsedData, {
|
||||||
|
statusCode: response.status,
|
||||||
|
headers: responseHeadersObj,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: operationId,
|
||||||
|
namespace: config.namespace,
|
||||||
|
version: "1.0.0",
|
||||||
|
type: opType,
|
||||||
|
description: operation.description || operation.summary || `${method.toUpperCase()} ${path}`,
|
||||||
|
tags: operation.tags,
|
||||||
|
inputSchema: buildInputSchema(spec, operation),
|
||||||
|
outputSchema: buildOutputSchema(spec, operation),
|
||||||
|
accessControl: { requiredScopes: [] },
|
||||||
|
handler,
|
||||||
|
_meta: {
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
path,
|
||||||
|
summary: operation.summary,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const handler: OperationHandler<unknown, unknown, OperationContext> = async (input: unknown, context: OperationContext) => {
|
const handler: OperationHandler<unknown, unknown, OperationContext> = async (input: unknown, context: OperationContext) => {
|
||||||
const inputObj = (input as Record<string, unknown>) || {};
|
const inputObj = (input as Record<string, unknown>) || {};
|
||||||
@@ -306,8 +488,8 @@ function createHTTPOperation(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FromOpenAPI(spec: OpenAPISpec, config: HTTPServiceConfig): Array<OperationSpec & { handler: OperationHandler<unknown, unknown, OperationContext> }> {
|
export function FromOpenAPI(spec: OpenAPISpec, config: HTTPServiceConfig): Array<OperationSpec & { handler: HTTPOperationHandler }> {
|
||||||
const operations: Array<OperationSpec & { handler: OperationHandler<unknown, unknown, OperationContext> }> = [];
|
const operations: Array<OperationSpec & { handler: HTTPOperationHandler }> = [];
|
||||||
const basePath = spec.basePath || "";
|
const basePath = spec.basePath || "";
|
||||||
|
|
||||||
for (const [path, methods] of Object.entries(spec.paths)) {
|
for (const [path, methods] of Object.entries(spec.paths)) {
|
||||||
@@ -328,7 +510,7 @@ export function FromOpenAPI(spec: OpenAPISpec, config: HTTPServiceConfig): Array
|
|||||||
return operations;
|
return operations;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function FromOpenAPIFile(path: string, config: HTTPServiceConfig, fs?: OpenAPIFS): Promise<Array<OperationSpec & { handler: OperationHandler<unknown, unknown, OperationContext> }>> {
|
export async function FromOpenAPIFile(path: string, config: HTTPServiceConfig, fs?: OpenAPIFS): Promise<Array<OperationSpec & { handler: HTTPOperationHandler }>> {
|
||||||
let content: string;
|
let content: string;
|
||||||
if (fs) {
|
if (fs) {
|
||||||
content = await fs.readFile(path);
|
content = await fs.readFile(path);
|
||||||
@@ -340,7 +522,7 @@ export async function FromOpenAPIFile(path: string, config: HTTPServiceConfig, f
|
|||||||
return FromOpenAPI(spec, config);
|
return FromOpenAPI(spec, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function FromOpenAPIUrl(url: string, config: HTTPServiceConfig): Promise<Array<OperationSpec & { handler: OperationHandler<unknown, unknown, OperationContext> }>> {
|
export async function FromOpenAPIUrl(url: string, config: HTTPServiceConfig): Promise<Array<OperationSpec & { handler: HTTPOperationHandler }>> {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const spec = await response.json() as OpenAPISpec;
|
const spec = await response.json() as OpenAPISpec;
|
||||||
return FromOpenAPI(spec, config);
|
return FromOpenAPI(spec, config);
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ export { scanOperations } from "./scanner.js";
|
|||||||
export type { OperationManifest, ScannerFS } from "./scanner.js";
|
export type { OperationManifest, ScannerFS } from "./scanner.js";
|
||||||
export { CallError, InfrastructureErrorCode, mapError } from "./error.js";
|
export { CallError, InfrastructureErrorCode, mapError } from "./error.js";
|
||||||
export type { CallErrorCode } from "./error.js";
|
export type { CallErrorCode } from "./error.js";
|
||||||
export { PendingRequestMap, buildCallHandler, checkAccess } from "./call.js";
|
export { PendingRequestMap, buildCallHandler } from "./call.js";
|
||||||
export type { CallEventMap, CallEventMapValue, CallRequestedEvent, CallRespondedEvent, CallAbortedEvent, CallErrorEvent, CallHandler, CallHandlerConfig } from "./call.js";
|
export type { CallEventMap, CallEventMapValue, CallRequestedEvent, CallRespondedEvent, CallAbortedEvent, CallErrorEvent, CallHandler, CallHandlerConfig } from "./call.js";
|
||||||
|
export { checkAccess } from "./access.js";
|
||||||
export { subscribe } from "./subscribe.js";
|
export { subscribe } from "./subscribe.js";
|
||||||
export { createMCPClient, closeMCPClient, MCPClientLoader } from "./from_mcp.js";
|
export { createMCPClient, closeMCPClient, MCPClientLoader } from "./from_mcp.js";
|
||||||
export type { MCPClientConfig, MCPClientWrapper } from "./from_mcp.js";
|
export type { MCPClientConfig, MCPClientWrapper } from "./from_mcp.js";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { KindGuard } from "@alkdev/typebox";
|
|||||||
import { assertIsSchema, validateOrThrow, collectErrors, formatValueErrors } from "./validation.js";
|
import { assertIsSchema, validateOrThrow, collectErrors, formatValueErrors } from "./validation.js";
|
||||||
import { isResponseEnvelope, localEnvelope, type ResponseEnvelope } from "./response-envelope.js";
|
import { isResponseEnvelope, localEnvelope, type ResponseEnvelope } from "./response-envelope.js";
|
||||||
import { CallError, InfrastructureErrorCode } from "./error.js";
|
import { CallError, InfrastructureErrorCode } from "./error.js";
|
||||||
import { checkAccess } from "./call.js";
|
import { checkAccess } from "./access.js";
|
||||||
|
|
||||||
const logger = getLogger("operations:registry");
|
const logger = getLogger("operations:registry");
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { OperationContext, AccessControl } from "./types.js";
|
|||||||
import { OperationRegistry } from "./registry.js";
|
import { OperationRegistry } from "./registry.js";
|
||||||
import { type ResponseEnvelope, isResponseEnvelope, localEnvelope } from "./response-envelope.js";
|
import { type ResponseEnvelope, isResponseEnvelope, localEnvelope } from "./response-envelope.js";
|
||||||
import { CallError, InfrastructureErrorCode } from "./error.js";
|
import { CallError, InfrastructureErrorCode } from "./error.js";
|
||||||
import { checkAccess } from "./call.js";
|
import { checkAccess } from "./access.js";
|
||||||
|
|
||||||
export async function* subscribe(
|
export async function* subscribe(
|
||||||
registry: OperationRegistry,
|
registry: OperationRegistry,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
**Architecture docs**: [ADR-007](../docs/architecture/decisions/007-subscription-transport.md), [call-protocol.md](../docs/architecture/call-protocol.md), [adapters.md](../docs/architecture/adapters.md)
|
**Architecture docs**: [ADR-007](../docs/architecture/decisions/007-subscription-transport.md), [call-protocol.md](../docs/architecture/call-protocol.md), [adapters.md](../docs/architecture/adapters.md)
|
||||||
|
|
||||||
|
**Status**: ✅ Completed (2026-05-16)
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
Three changes, all in source. No new modules needed.
|
Three changes, all in source. No new modules needed.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe("PendingRequestMap", () => {
|
|||||||
const callPromise = map.call("test.op", { value: "hello" });
|
const callPromise = map.call("test.op", { value: "hello" });
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const requestId = [...map["requests"].keys()][0];
|
const requestId = [...map["entries"].keys()][0];
|
||||||
map.respond(requestId, localEnvelope({ result: "world" }, "test.op"));
|
map.respond(requestId, localEnvelope({ result: "world" }, "test.op"));
|
||||||
}, 10);
|
}, 10);
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ describe("PendingRequestMap", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const requestId = [...map["requests"].keys()][0];
|
const requestId = [...map["entries"].keys()][0];
|
||||||
map.respond(requestId, envelope);
|
map.respond(requestId, envelope);
|
||||||
}, 10);
|
}, 10);
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ describe("PendingRequestMap", () => {
|
|||||||
const callPromise = map.call("test.op", { value: "hello" });
|
const callPromise = map.call("test.op", { value: "hello" });
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const requestId = [...map["requests"].keys()][0];
|
const requestId = [...map["entries"].keys()][0];
|
||||||
map.emitError(requestId, "CUSTOM_ERROR", "Something went wrong");
|
map.emitError(requestId, "CUSTOM_ERROR", "Something went wrong");
|
||||||
}, 10);
|
}, 10);
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ describe("PendingRequestMap", () => {
|
|||||||
const callPromise = map.call("test.op", { value: "hello" });
|
const callPromise = map.call("test.op", { value: "hello" });
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const requestId = [...map["requests"].keys()][0];
|
const requestId = [...map["entries"].keys()][0];
|
||||||
map.abort(requestId);
|
map.abort(requestId);
|
||||||
}, 10);
|
}, 10);
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ describe("PendingRequestMap", () => {
|
|||||||
const callPromise = map.call("test.op", { value: "hello" });
|
const callPromise = map.call("test.op", { value: "hello" });
|
||||||
expect(map.getPendingCount()).toBe(1);
|
expect(map.getPendingCount()).toBe(1);
|
||||||
|
|
||||||
const requestId = [...map["requests"].keys()][0];
|
const requestId = [...map["entries"].keys()][0];
|
||||||
map.respond(requestId, localEnvelope({ result: "done" }, "test.op"));
|
map.respond(requestId, localEnvelope({ result: "done" }, "test.op"));
|
||||||
|
|
||||||
await callPromise;
|
await callPromise;
|
||||||
@@ -146,7 +146,7 @@ describe("PendingRequestMap", () => {
|
|||||||
it("respond() accepts a localEnvelope", () => {
|
it("respond() accepts a localEnvelope", () => {
|
||||||
const map = new PendingRequestMap();
|
const map = new PendingRequestMap();
|
||||||
const callPromise = map.call("test.op", {});
|
const callPromise = map.call("test.op", {});
|
||||||
const requestId = [...map["requests"].keys()][0];
|
const requestId = [...map["entries"].keys()][0];
|
||||||
|
|
||||||
expect(() => map.respond(requestId, localEnvelope("data", "test.op"))).not.toThrow();
|
expect(() => map.respond(requestId, localEnvelope("data", "test.op"))).not.toThrow();
|
||||||
});
|
});
|
||||||
@@ -154,7 +154,7 @@ describe("PendingRequestMap", () => {
|
|||||||
it("respond() accepts an httpEnvelope", () => {
|
it("respond() accepts an httpEnvelope", () => {
|
||||||
const map = new PendingRequestMap();
|
const map = new PendingRequestMap();
|
||||||
const callPromise = map.call("test.op", {});
|
const callPromise = map.call("test.op", {});
|
||||||
const requestId = [...map["requests"].keys()][0];
|
const requestId = [...map["entries"].keys()][0];
|
||||||
|
|
||||||
expect(() => map.respond(requestId, httpEnvelope("data", {
|
expect(() => map.respond(requestId, httpEnvelope("data", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
@@ -166,7 +166,7 @@ describe("PendingRequestMap", () => {
|
|||||||
it("respond() accepts an mcpEnvelope", () => {
|
it("respond() accepts an mcpEnvelope", () => {
|
||||||
const map = new PendingRequestMap();
|
const map = new PendingRequestMap();
|
||||||
const callPromise = map.call("test.op", {});
|
const callPromise = map.call("test.op", {});
|
||||||
const requestId = [...map["requests"].keys()][0];
|
const requestId = [...map["entries"].keys()][0];
|
||||||
|
|
||||||
expect(() => map.respond(requestId, mcpEnvelope("data", {
|
expect(() => map.respond(requestId, mcpEnvelope("data", {
|
||||||
isError: false,
|
isError: false,
|
||||||
@@ -180,7 +180,7 @@ describe("PendingRequestMap", () => {
|
|||||||
const callPromise = map.call("test.op", { value: "hello" });
|
const callPromise = map.call("test.op", { value: "hello" });
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const requestId = [...map["requests"].keys()][0];
|
const requestId = [...map["entries"].keys()][0];
|
||||||
map.respond(requestId, localEnvelope(42, "test.op"));
|
map.respond(requestId, localEnvelope(42, "test.op"));
|
||||||
}, 10);
|
}, 10);
|
||||||
|
|
||||||
@@ -259,7 +259,7 @@ describe("CallHandler", () => {
|
|||||||
const callPromise = callMap.call("test.echo", { value: "hello" });
|
const callPromise = callMap.call("test.echo", { value: "hello" });
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.echo",
|
operationId: "test.echo",
|
||||||
input: { value: "hello" },
|
input: { value: "hello" },
|
||||||
});
|
});
|
||||||
@@ -281,7 +281,7 @@ describe("CallHandler", () => {
|
|||||||
const callPromise = callMap.call("test.voidOp", {});
|
const callPromise = callMap.call("test.voidOp", {});
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.voidOp",
|
operationId: "test.voidOp",
|
||||||
input: {},
|
input: {},
|
||||||
});
|
});
|
||||||
@@ -319,7 +319,7 @@ describe("CallHandler", () => {
|
|||||||
const callPromise = callMap.call("test.mcpOp", {});
|
const callPromise = callMap.call("test.mcpOp", {});
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.mcpOp",
|
operationId: "test.mcpOp",
|
||||||
input: {},
|
input: {},
|
||||||
});
|
});
|
||||||
@@ -354,7 +354,7 @@ describe("CallHandler", () => {
|
|||||||
const callPromise = callMap.call("test.httpOp", {});
|
const callPromise = callMap.call("test.httpOp", {});
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.httpOp",
|
operationId: "test.httpOp",
|
||||||
input: {},
|
input: {},
|
||||||
});
|
});
|
||||||
@@ -387,7 +387,7 @@ describe("CallHandler", () => {
|
|||||||
const callPromise = callMap.call("test.throws", {});
|
const callPromise = callMap.call("test.throws", {});
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.throws",
|
operationId: "test.throws",
|
||||||
input: {},
|
input: {},
|
||||||
});
|
});
|
||||||
@@ -417,7 +417,7 @@ describe("CallHandler", () => {
|
|||||||
const callPromise = callMap.call("test.throwsCallError", {});
|
const callPromise = callMap.call("test.throwsCallError", {});
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.throwsCallError",
|
operationId: "test.throwsCallError",
|
||||||
input: {},
|
input: {},
|
||||||
});
|
});
|
||||||
@@ -440,7 +440,7 @@ describe("CallHandler", () => {
|
|||||||
const callPromise = callMap.call("test.nonexistent", {});
|
const callPromise = callMap.call("test.nonexistent", {});
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.nonexistent",
|
operationId: "test.nonexistent",
|
||||||
input: {},
|
input: {},
|
||||||
});
|
});
|
||||||
@@ -473,7 +473,7 @@ describe("CallHandler", () => {
|
|||||||
const callPromise = callMap.call("test.noHandler", {});
|
const callPromise = callMap.call("test.noHandler", {});
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.noHandler",
|
operationId: "test.noHandler",
|
||||||
input: {},
|
input: {},
|
||||||
});
|
});
|
||||||
@@ -497,7 +497,7 @@ describe("CallHandler", () => {
|
|||||||
const callPromise = callMap.call("test.guarded", {}, { identity });
|
const callPromise = callMap.call("test.guarded", {}, { identity });
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.guarded",
|
operationId: "test.guarded",
|
||||||
input: {},
|
input: {},
|
||||||
identity,
|
identity,
|
||||||
@@ -520,7 +520,7 @@ describe("CallHandler", () => {
|
|||||||
const callPromise = callMap.call("test.nonexistent", {});
|
const callPromise = callMap.call("test.nonexistent", {});
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.nonexistent",
|
operationId: "test.nonexistent",
|
||||||
input: {},
|
input: {},
|
||||||
});
|
});
|
||||||
@@ -554,7 +554,7 @@ describe("CallHandler", () => {
|
|||||||
const callPromise = callMap.call("test.defaultsFields", {});
|
const callPromise = callMap.call("test.defaultsFields", {});
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.defaultsFields",
|
operationId: "test.defaultsFields",
|
||||||
input: {},
|
input: {},
|
||||||
});
|
});
|
||||||
@@ -583,7 +583,7 @@ describe("CallHandler", () => {
|
|||||||
const callPromise = callMap.call("test.unknownOutput", {});
|
const callPromise = callMap.call("test.unknownOutput", {});
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.unknownOutput",
|
operationId: "test.unknownOutput",
|
||||||
input: {},
|
input: {},
|
||||||
});
|
});
|
||||||
@@ -615,7 +615,7 @@ describe("CallHandler", () => {
|
|||||||
const callPromise = callMap.call("test.customError", {});
|
const callPromise = callMap.call("test.customError", {});
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.customError",
|
operationId: "test.customError",
|
||||||
input: {},
|
input: {},
|
||||||
});
|
});
|
||||||
@@ -676,7 +676,7 @@ describe("checkAccess resource access control", () => {
|
|||||||
const callPromise = callMap.call("test.guarded", {}, { identity });
|
const callPromise = callMap.call("test.guarded", {}, { identity });
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.guarded",
|
operationId: "test.guarded",
|
||||||
input: {},
|
input: {},
|
||||||
identity,
|
identity,
|
||||||
@@ -701,7 +701,7 @@ describe("checkAccess resource access control", () => {
|
|||||||
const callPromise = callMap.call("test.guarded", {}, { identity });
|
const callPromise = callMap.call("test.guarded", {}, { identity });
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.guarded",
|
operationId: "test.guarded",
|
||||||
input: {},
|
input: {},
|
||||||
identity,
|
identity,
|
||||||
@@ -730,7 +730,7 @@ describe("checkAccess resource access control", () => {
|
|||||||
const callPromise = callMap.call("test.guarded", {}, { identity });
|
const callPromise = callMap.call("test.guarded", {}, { identity });
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.guarded",
|
operationId: "test.guarded",
|
||||||
input: {},
|
input: {},
|
||||||
identity,
|
identity,
|
||||||
@@ -759,7 +759,7 @@ describe("checkAccess resource access control", () => {
|
|||||||
const callPromise = callMap.call("test.guarded", {}, { identity });
|
const callPromise = callMap.call("test.guarded", {}, { identity });
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.guarded",
|
operationId: "test.guarded",
|
||||||
input: {},
|
input: {},
|
||||||
identity,
|
identity,
|
||||||
@@ -788,7 +788,7 @@ describe("checkAccess resource access control", () => {
|
|||||||
const callPromise = callMap.call("test.guarded", {}, { identity });
|
const callPromise = callMap.call("test.guarded", {}, { identity });
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.guarded",
|
operationId: "test.guarded",
|
||||||
input: {},
|
input: {},
|
||||||
identity,
|
identity,
|
||||||
@@ -808,7 +808,7 @@ describe("checkAccess resource access control", () => {
|
|||||||
const callPromise = callMap.call("test.open", {}, { identity });
|
const callPromise = callMap.call("test.open", {}, { identity });
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.open",
|
operationId: "test.open",
|
||||||
input: {},
|
input: {},
|
||||||
identity,
|
identity,
|
||||||
@@ -832,7 +832,7 @@ describe("checkAccess resource access control", () => {
|
|||||||
const callPromise = callMap.call("test.guarded", {}, { identity });
|
const callPromise = callMap.call("test.guarded", {}, { identity });
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
requestId: [...callMap["requests"].keys()][0],
|
requestId: [...callMap["entries"].keys()][0],
|
||||||
operationId: "test.guarded",
|
operationId: "test.guarded",
|
||||||
input: {},
|
input: {},
|
||||||
identity,
|
identity,
|
||||||
@@ -842,3 +842,315 @@ describe("checkAccess resource access control", () => {
|
|||||||
expect(result.data).toEqual({ ok: true });
|
expect(result.data).toEqual({ ok: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PendingRequestMap.subscribe()", () => {
|
||||||
|
it("yields each envelope from call.responded events", async () => {
|
||||||
|
const map = new PendingRequestMap();
|
||||||
|
|
||||||
|
const subscribeIter = map.subscribe("test.stream", { filter: "all" });
|
||||||
|
|
||||||
|
const results: ResponseEnvelope[] = [];
|
||||||
|
const consumePromise = (async () => {
|
||||||
|
for await (const envelope of subscribeIter) {
|
||||||
|
results.push(envelope);
|
||||||
|
if (results.length === 3) break;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
|
||||||
|
const requestId = [...map["entries"].keys()][0];
|
||||||
|
map.respond(requestId, localEnvelope({ event: 1 }, "test.stream"));
|
||||||
|
map.respond(requestId, localEnvelope({ event: 2 }, "test.stream"));
|
||||||
|
map.respond(requestId, localEnvelope({ event: 3 }, "test.stream"));
|
||||||
|
|
||||||
|
await consumePromise;
|
||||||
|
|
||||||
|
expect(results).toHaveLength(3);
|
||||||
|
expect(results[0].data).toEqual({ event: 1 });
|
||||||
|
expect(results[1].data).toEqual({ event: 2 });
|
||||||
|
expect(results[2].data).toEqual({ event: 3 });
|
||||||
|
expect(results[0].meta.source).toBe("local");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes call.aborted when consumer stops iterating", async () => {
|
||||||
|
const map = new PendingRequestMap();
|
||||||
|
|
||||||
|
const subscribeIter = map.subscribe("test.stream", {});
|
||||||
|
|
||||||
|
let abortedReceived = false;
|
||||||
|
const abortedIter = map["pubsub"].subscribe("call.aborted", "");
|
||||||
|
(async () => {
|
||||||
|
for await (const envelope of abortedIter) {
|
||||||
|
abortedReceived = true;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const results: ResponseEnvelope[] = [];
|
||||||
|
const consumePromise = (async () => {
|
||||||
|
for await (const envelope of subscribeIter) {
|
||||||
|
results.push(envelope);
|
||||||
|
if (results.length === 1) break;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
const requestId = [...map["entries"].keys()][0];
|
||||||
|
map.respond(requestId, localEnvelope("first", "test.stream"));
|
||||||
|
|
||||||
|
await consumePromise;
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
|
||||||
|
expect(abortedReceived).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws CallError when call.error event arrives", async () => {
|
||||||
|
const map = new PendingRequestMap();
|
||||||
|
|
||||||
|
const subscribeIter = map.subscribe("test.failing", {});
|
||||||
|
let caughtError: unknown;
|
||||||
|
|
||||||
|
const consumePromise = (async () => {
|
||||||
|
try {
|
||||||
|
for await (const _ of subscribeIter) {
|
||||||
|
// should not reach
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
caughtError = error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
const requestId = [...map["entries"].keys()][0];
|
||||||
|
map.emitError(requestId, "CUSTOM_ERROR", "Subscription failed");
|
||||||
|
|
||||||
|
await consumePromise;
|
||||||
|
|
||||||
|
expect(caughtError).toBeInstanceOf(CallError);
|
||||||
|
expect((caughtError as CallError).code).toBe("CUSTOM_ERROR");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes iterator on call.aborted event", async () => {
|
||||||
|
const map = new PendingRequestMap();
|
||||||
|
|
||||||
|
const subscribeIter = map.subscribe("test.stream", {});
|
||||||
|
let iterationCompleted = false;
|
||||||
|
|
||||||
|
const consumePromise = (async () => {
|
||||||
|
for await (const _ of subscribeIter) {
|
||||||
|
// will receive abort
|
||||||
|
}
|
||||||
|
iterationCompleted = true;
|
||||||
|
})();
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
const requestId = [...map["entries"].keys()][0];
|
||||||
|
|
||||||
|
const entry = map["entries"].get(requestId);
|
||||||
|
expect(entry).toBeDefined();
|
||||||
|
expect(entry!.type).toBe("subscribe");
|
||||||
|
|
||||||
|
map["pubsub"].publish("call.aborted", "", { requestId });
|
||||||
|
|
||||||
|
await consumePromise;
|
||||||
|
expect(iterationCompleted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("times out on idle deadline", async () => {
|
||||||
|
const map = new PendingRequestMap();
|
||||||
|
const deadline = 80;
|
||||||
|
|
||||||
|
const subscribeIter = map.subscribe("test.slow", {}, { deadline });
|
||||||
|
let caughtError: unknown;
|
||||||
|
|
||||||
|
const consumePromise = (async () => {
|
||||||
|
try {
|
||||||
|
for await (const _ of subscribeIter) {
|
||||||
|
// should not receive any events
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
caughtError = error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
await consumePromise;
|
||||||
|
|
||||||
|
expect(caughtError).toBeInstanceOf(CallError);
|
||||||
|
expect((caughtError as CallError).code).toBe(InfrastructureErrorCode.TIMEOUT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets idle timeout on each envelope", async () => {
|
||||||
|
const map = new PendingRequestMap();
|
||||||
|
const deadline = 150;
|
||||||
|
|
||||||
|
const subscribeIter = map.subscribe("test.heartbeat", {}, { deadline });
|
||||||
|
|
||||||
|
const results: ResponseEnvelope[] = [];
|
||||||
|
const consumePromise = (async () => {
|
||||||
|
for await (const envelope of subscribeIter) {
|
||||||
|
results.push(envelope);
|
||||||
|
if (results.length === 3) break;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
const requestId = [...map["entries"].keys()][0];
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
map.respond(requestId, localEnvelope("event1", "test.heartbeat"));
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
map.respond(requestId, localEnvelope("event2", "test.heartbeat"));
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
map.respond(requestId, localEnvelope("event3", "test.heartbeat"));
|
||||||
|
|
||||||
|
await consumePromise;
|
||||||
|
|
||||||
|
expect(results).toHaveLength(3);
|
||||||
|
expect(results[0].data).toBe("event1");
|
||||||
|
expect(results[1].data).toBe("event2");
|
||||||
|
expect(results[2].data).toBe("event3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("abort() closes subscription iterator", async () => {
|
||||||
|
const map = new PendingRequestMap();
|
||||||
|
|
||||||
|
const subscribeIter = map.subscribe("test.stream", {});
|
||||||
|
let iterationCompleted = false;
|
||||||
|
|
||||||
|
const consumePromise = (async () => {
|
||||||
|
for await (const _ of subscribeIter) {
|
||||||
|
// will receive abort
|
||||||
|
}
|
||||||
|
iterationCompleted = true;
|
||||||
|
})();
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
const requestId = [...map["entries"].keys()][0];
|
||||||
|
|
||||||
|
map.abort(requestId);
|
||||||
|
|
||||||
|
await consumePromise;
|
||||||
|
expect(iterationCompleted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks subscribe entries in pending count", async () => {
|
||||||
|
const map = new PendingRequestMap();
|
||||||
|
|
||||||
|
const subscribeIter = map.subscribe("test.stream", {});
|
||||||
|
|
||||||
|
const consumePromise = (async () => {
|
||||||
|
for await (const _ of subscribeIter) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
expect(map.getPendingCount()).toBe(1);
|
||||||
|
|
||||||
|
const requestId = [...map["entries"].keys()][0];
|
||||||
|
map.abort(requestId);
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
|
||||||
|
await consumePromise;
|
||||||
|
expect(map.getPendingCount()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CallHandler SUBSCRIPTION dispatch", () => {
|
||||||
|
it("dispatches SUBSCRIPTION operations to subscribe()", async () => {
|
||||||
|
const registry = new OperationRegistry();
|
||||||
|
registry.register({
|
||||||
|
name: "events",
|
||||||
|
namespace: "test",
|
||||||
|
version: "1.0.0",
|
||||||
|
type: OperationType.SUBSCRIPTION,
|
||||||
|
description: "event stream",
|
||||||
|
inputSchema: Type.Object({}),
|
||||||
|
outputSchema: Type.Unknown(),
|
||||||
|
accessControl: { requiredScopes: [] },
|
||||||
|
handler: async function* (_input: unknown, _context: unknown) {
|
||||||
|
yield "event1";
|
||||||
|
yield "event2";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const callMap = new PendingRequestMap();
|
||||||
|
const handler = buildCallHandler({ registry, callMap });
|
||||||
|
|
||||||
|
const subscribeIter = callMap.subscribe("test.events", {});
|
||||||
|
const results: ResponseEnvelope[] = [];
|
||||||
|
const consumePromise = (async () => {
|
||||||
|
for await (const envelope of subscribeIter) {
|
||||||
|
results.push(envelope);
|
||||||
|
if (results.length === 2) break;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
const requestId = [...callMap["entries"].keys()][0];
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
requestId,
|
||||||
|
operationId: "test.events",
|
||||||
|
input: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await consumePromise;
|
||||||
|
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results[0].data).toBe("event1");
|
||||||
|
expect(results[0].meta.source).toBe("local");
|
||||||
|
expect(results[1].data).toBe("event2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes call.error for SUBSCRIPTION access denied", async () => {
|
||||||
|
const registry = new OperationRegistry();
|
||||||
|
registry.register({
|
||||||
|
name: "guarded",
|
||||||
|
namespace: "test",
|
||||||
|
version: "1.0.0",
|
||||||
|
type: OperationType.SUBSCRIPTION,
|
||||||
|
description: "guarded sub",
|
||||||
|
inputSchema: Type.Object({}),
|
||||||
|
outputSchema: Type.Unknown(),
|
||||||
|
accessControl: { requiredScopes: ["admin"] },
|
||||||
|
handler: async function* (_input: unknown, _context: unknown) {
|
||||||
|
yield "secret";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const callMap = new PendingRequestMap();
|
||||||
|
const handler = buildCallHandler({ registry, callMap });
|
||||||
|
|
||||||
|
const identity: Identity = { id: "user1", scopes: [] };
|
||||||
|
const subscribeIter = callMap.subscribe("test.guarded", {}, { identity });
|
||||||
|
let caughtError: unknown;
|
||||||
|
|
||||||
|
const consumePromise = (async () => {
|
||||||
|
try {
|
||||||
|
for await (const _ of subscribeIter) {
|
||||||
|
// should not reach
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
caughtError = error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
const requestId = [...callMap["entries"].keys()][0];
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
requestId,
|
||||||
|
operationId: "test.guarded",
|
||||||
|
input: {},
|
||||||
|
identity,
|
||||||
|
});
|
||||||
|
|
||||||
|
await consumePromise;
|
||||||
|
|
||||||
|
expect(caughtError).toBeInstanceOf(CallError);
|
||||||
|
expect((caughtError as CallError).code).toBe(InfrastructureErrorCode.ACCESS_DENIED);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { FromOpenAPI } from "../src/from_openapi.js";
|
import { FromOpenAPI, parseSSEFrames } from "../src/from_openapi.js";
|
||||||
import { OperationType } from "../src/types.js";
|
import { OperationType } from "../src/types.js";
|
||||||
|
import type { SubscriptionHandler } from "../src/types.js";
|
||||||
import { CallError } from "../src/error.js";
|
import { CallError } from "../src/error.js";
|
||||||
import { isResponseEnvelope } from "../src/response-envelope.js";
|
import { isResponseEnvelope } from "../src/response-envelope.js";
|
||||||
import { Value } from "@alkdev/typebox/value";
|
import { Value } from "@alkdev/typebox/value";
|
||||||
@@ -396,3 +397,195 @@ describe("FromOpenAPI handler envelope behavior", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseSSEFrames", () => {
|
||||||
|
it("parses a simple SSE event", () => {
|
||||||
|
const buffer = "data: hello\n\n";
|
||||||
|
const { events, remaining } = parseSSEFrames(buffer);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].data).toBe("hello");
|
||||||
|
expect(events[0].eventType).toBe("message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses multiple SSE events", () => {
|
||||||
|
const buffer = "data: first\n\ndata: second\n\n";
|
||||||
|
const { events } = parseSSEFrames(buffer);
|
||||||
|
expect(events).toHaveLength(2);
|
||||||
|
expect(events[0].data).toBe("first");
|
||||||
|
expect(events[1].data).toBe("second");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses multi-line data fields (joined with \\n)", () => {
|
||||||
|
const buffer = "data: line1\ndata: line2\n\n";
|
||||||
|
const { events } = parseSSEFrames(buffer);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].data).toBe("line1\nline2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses event type field", () => {
|
||||||
|
const buffer = "event: custom\ndata: payload\n\n";
|
||||||
|
const { events } = parseSSEFrames(buffer);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].eventType).toBe("custom");
|
||||||
|
expect(events[0].data).toBe("payload");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses id field", () => {
|
||||||
|
const buffer = "id: 42\ndata: payload\n\n";
|
||||||
|
const { events } = parseSSEFrames(buffer);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].lastEventId).toBe("42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores comment lines (starting with :)", () => {
|
||||||
|
const buffer = ": this is a comment\ndata: hello\n\n";
|
||||||
|
const { events } = parseSSEFrames(buffer);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].data).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles CRLF line endings", () => {
|
||||||
|
const buffer = "data: hello\r\n\r\n";
|
||||||
|
const { events } = parseSSEFrames(buffer);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].data).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles CR line endings", () => {
|
||||||
|
const buffer = "data: hello\r\r";
|
||||||
|
const { events } = parseSSEFrames(buffer);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].data).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips BOM at stream start", () => {
|
||||||
|
const buffer = "\uFEFFdata: hello\n\n";
|
||||||
|
const { events } = parseSSEFrames(buffer);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].data).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes single leading space after data: per WHATWG spec", () => {
|
||||||
|
const buffer = "data: two spaces\n\n";
|
||||||
|
const { events } = parseSSEFrames(buffer);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].data).toBe(" two spaces");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles partial lines (returns as remaining)", () => {
|
||||||
|
const buffer = "data: incom";
|
||||||
|
const { events, remaining } = parseSSEFrames(buffer);
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
expect(remaining).toBe("data: incom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty data with empty line dispatch", () => {
|
||||||
|
const buffer = "data:\n\n";
|
||||||
|
const { events } = parseSSEFrames(buffer);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].data).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips events with no data lines (empty dispatch)", () => {
|
||||||
|
const buffer = "event: ping\n\n";
|
||||||
|
const { events } = parseSSEFrames(buffer);
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FromOpenAPI SUBSCRIPTION handler", () => {
|
||||||
|
const config = {
|
||||||
|
namespace: "api",
|
||||||
|
baseUrl: "https://api.example.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
let originalFetch: typeof globalThis.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalFetch = globalThis.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates SubscriptionHandler for SUBSCRIPTION type operations", () => {
|
||||||
|
const ops = FromOpenAPI(simpleSpec as any, config);
|
||||||
|
const streamEvents = ops.find((o) => o.name === "streamEvents")!;
|
||||||
|
expect(streamEvents.type).toBe(OperationType.SUBSCRIPTION);
|
||||||
|
expect(typeof streamEvents.handler).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SSE handler yields events as httpEnvelope", async () => {
|
||||||
|
const sseStream = [
|
||||||
|
"data: {\"event\":\"ping\"}\n\n",
|
||||||
|
"data: {\"event\":\"pong\"}\n\n",
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const chunks = [encoder.encode(sseStream)];
|
||||||
|
|
||||||
|
const reader = {
|
||||||
|
read: vi.fn()
|
||||||
|
.mockResolvedValueOnce({ done: false, value: chunks[0] })
|
||||||
|
.mockResolvedValueOnce({ done: true, value: undefined }),
|
||||||
|
releaseLock: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
headers: new Headers({ "Content-Type": "text/event-stream" }),
|
||||||
|
body: { getReader: () => reader },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ops = FromOpenAPI(simpleSpec as any, config);
|
||||||
|
const streamEvents = ops.find((o) => o.name === "streamEvents")!;
|
||||||
|
const handler = streamEvents.handler as SubscriptionHandler<unknown, unknown, unknown>;
|
||||||
|
|
||||||
|
const results: unknown[] = [];
|
||||||
|
for await (const value of handler({}, {} as any)) {
|
||||||
|
results.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(isResponseEnvelope(results[0])).toBe(true);
|
||||||
|
if (isResponseEnvelope(results[0])) {
|
||||||
|
expect(results[0].meta.source).toBe("http");
|
||||||
|
expect(results[0].data).toEqual({ event: "ping" });
|
||||||
|
const meta = results[0].meta as { statusCode: number; contentType: string };
|
||||||
|
expect(meta.statusCode).toBe(200);
|
||||||
|
expect(meta.contentType).toBe("text/event-stream");
|
||||||
|
}
|
||||||
|
expect(isResponseEnvelope(results[1])).toBe(true);
|
||||||
|
if (isResponseEnvelope(results[1])) {
|
||||||
|
expect(results[1].data).toEqual({ event: "pong" });
|
||||||
|
}
|
||||||
|
expect(reader.releaseLock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SSE handler throws CallError on HTTP error", async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
statusText: "Internal Server Error",
|
||||||
|
headers: new Headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ops = FromOpenAPI(simpleSpec as any, config);
|
||||||
|
const streamEvents = ops.find((o) => o.name === "streamEvents")!;
|
||||||
|
const handler = streamEvents.handler as SubscriptionHandler<unknown, unknown, unknown>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const _ of handler({}, {} as any)) {
|
||||||
|
// should not reach
|
||||||
|
}
|
||||||
|
expect.fail("Expected CallError");
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(CallError);
|
||||||
|
expect((error as CallError).code).toBe("EXECUTION_ERROR");
|
||||||
|
expect((error as CallError).message).toContain("HTTP 500");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user