@acid-cat/core-ts 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +165 -11
- package/dist/app.d.ts +73 -1
- package/dist/app.js +34 -2
- package/dist/cli.d.ts +4 -4
- package/dist/cli.js +111 -16
- package/dist/clock.d.ts +14 -0
- package/dist/clock.js +26 -0
- package/dist/context.d.ts +8 -0
- package/dist/context.js +14 -9
- package/dist/http.d.ts +4 -0
- package/dist/http.js +112 -30
- package/dist/index.d.ts +15 -5
- package/dist/index.js +6 -2
- package/dist/mcp-events.d.ts +87 -0
- package/dist/mcp-events.js +129 -0
- package/dist/mcp-poll.d.ts +28 -0
- package/dist/mcp-poll.js +179 -0
- package/dist/mcp-stdio.d.ts +41 -0
- package/dist/mcp-stdio.js +325 -0
- package/dist/mcp.d.ts +51 -2
- package/dist/mcp.js +105 -24
- package/dist/node.d.ts +2 -0
- package/dist/node.js +5 -1
- package/dist/session.d.ts +53 -3
- package/dist/session.js +129 -1
- package/dist/stream.d.ts +25 -0
- package/dist/stream.js +63 -0
- package/dist/subscription.d.ts +39 -0
- package/dist/subscription.js +145 -0
- package/dist/webhook.d.ts +40 -0
- package/dist/webhook.js +385 -0
- package/package.json +7 -5
package/dist/mcp.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { createSession } from "./session.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { openContext } from "./context.js";
|
|
3
|
+
import { textStream } from "./stream.js";
|
|
4
|
+
import { eventCapabilities, forbidden, listEvents, servesEvents, startEventStream, unsupportedWebhook } from "./mcp-events.js";
|
|
5
|
+
import { PollLeases } from "./mcp-poll.js";
|
|
6
|
+
import { KEEPALIVE_MS, MCP_PROTOCOL_VERSION, MCP_REJECTIONS, MCP_UNKNOWN_TOOL, SSE_MEDIA_TYPE, bundle, isJsonObject, mcpToolResult, readBounded, screenRequest, sseMessage } from "@acid-cat/spec-ts";
|
|
4
7
|
//#region src/mcp.ts
|
|
5
8
|
/**
|
|
6
|
-
* The MCP binding (spec/bindings/mcp.md): an MCP server through HTTP, revision 2026-07-28
|
|
7
|
-
*
|
|
9
|
+
* The MCP binding (spec/bindings/mcp.md): an MCP server through HTTP, revision 2026-07-28. The server keeps no state
|
|
10
|
+
* between requests, except poll leases and webhook subscriptions.
|
|
8
11
|
*/
|
|
9
12
|
/**
|
|
10
13
|
* Read the members of a JSON-RPC request from a parsed body. A member that does not have its type is left out. An
|
|
11
14
|
* `id` that is not a string or a number is `null`.
|
|
12
15
|
*/
|
|
13
16
|
function readRequest(body) {
|
|
14
|
-
if (!isJsonObject(body)) return {};
|
|
17
|
+
if (!isJsonObject(body)) return { params: {} };
|
|
15
18
|
const { id, method, params } = body;
|
|
16
19
|
const meta = isJsonObject(params) ? params["_meta"] : void 0;
|
|
17
20
|
return {
|
|
@@ -19,13 +22,22 @@ function readRequest(body) {
|
|
|
19
22
|
method: typeof method === "string" ? method : void 0,
|
|
20
23
|
name: isJsonObject(params) && typeof params["name"] === "string" ? params["name"] : void 0,
|
|
21
24
|
arguments: isJsonObject(params) ? params["arguments"] : void 0,
|
|
25
|
+
params: isJsonObject(params) ? params : {},
|
|
22
26
|
protocolVersion: isJsonObject(meta) ? meta["io.modelcontextprotocol/protocolVersion"] : void 0
|
|
23
27
|
};
|
|
24
28
|
}
|
|
25
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* A web request handler for the MCP server. Each request has its own session, and with `createContext` its own
|
|
31
|
+
* context. Each poll lease and each webhook subscription also has its own session, opened with the context of the
|
|
32
|
+
* request that started it.
|
|
33
|
+
*/
|
|
26
34
|
function mcpHandler(app, options) {
|
|
27
35
|
const maximumBytes = options.maximumBytes ?? 1048576;
|
|
28
|
-
|
|
36
|
+
const leases = new PollLeases(options.poll, options.clock);
|
|
37
|
+
const closeAll = async () => {
|
|
38
|
+
await Promise.allSettled([leases.close(), options.webhooks?.close()]);
|
|
39
|
+
};
|
|
40
|
+
const handle = async (request) => {
|
|
29
41
|
const screened = screenRequest(request, {
|
|
30
42
|
methods: ["POST"],
|
|
31
43
|
allowedOrigins: options.allowedOrigins,
|
|
@@ -52,30 +64,91 @@ function mcpHandler(app, options) {
|
|
|
52
64
|
const metaVersion = message.protocolVersion;
|
|
53
65
|
if (metaVersion === void 0) return error(400, id, -32602, "Required request metadata is missing");
|
|
54
66
|
if (metaVersion !== version) return error(400, id, -32020, "MCP-Protocol-Version header does not match request metadata");
|
|
55
|
-
|
|
67
|
+
const open = async () => {
|
|
68
|
+
const opened = await openContext(options, request);
|
|
56
69
|
const session = createSession(app, {
|
|
57
70
|
target: options.target ?? "mcp",
|
|
58
|
-
context,
|
|
59
|
-
toJsonSchema: options.toJsonSchema
|
|
71
|
+
context: opened.context,
|
|
72
|
+
toJsonSchema: options.toJsonSchema,
|
|
73
|
+
...options.bufferLimit === void 0 ? {} : { bufferLimit: options.bufferLimit }
|
|
60
74
|
});
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
...options,
|
|
64
|
-
instructions: options.instructions ?? app.description
|
|
65
|
-
}, request.signal);
|
|
66
|
-
} finally {
|
|
75
|
+
let closing;
|
|
76
|
+
const close = () => closing ??= (async () => {
|
|
67
77
|
await session.dispose();
|
|
78
|
+
await opened.release();
|
|
79
|
+
})();
|
|
80
|
+
return {
|
|
81
|
+
session,
|
|
82
|
+
close
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
const { session, close } = await open();
|
|
86
|
+
let streaming = false;
|
|
87
|
+
try {
|
|
88
|
+
if (message.method === "events/stream" && message.id !== null && servesEvents(session)) {
|
|
89
|
+
const started = await startEventStream(session, message.id, message.params, {
|
|
90
|
+
heartbeatMs: options.heartbeatMs ?? KEEPALIVE_MS,
|
|
91
|
+
resultMeta: serverInfoOf(options),
|
|
92
|
+
signal: request.signal
|
|
93
|
+
});
|
|
94
|
+
if ("error" in started) return error(200, id, started.error.code, started.error.message, started.error.data);
|
|
95
|
+
const { messages, cancel } = started;
|
|
96
|
+
const produce = async () => (await messages())?.map((each) => sseMessage({ data: JSON.stringify(each) }));
|
|
97
|
+
const body = textStream(produce, cancel, close);
|
|
98
|
+
streaming = true;
|
|
99
|
+
return new Response(body, {
|
|
100
|
+
status: 200,
|
|
101
|
+
headers: {
|
|
102
|
+
"Content-Type": SSE_MEDIA_TYPE,
|
|
103
|
+
"Cache-Control": "no-store"
|
|
104
|
+
}
|
|
105
|
+
});
|
|
68
106
|
}
|
|
69
|
-
|
|
107
|
+
if (servesEvents(session) && message.method !== void 0 && LONG_LIVED.has(message.method)) {
|
|
108
|
+
const principal = await options.principal?.(request);
|
|
109
|
+
const answered = await longLived(message.method, message.params, principal, open, leases, options.webhooks);
|
|
110
|
+
if ("error" in answered) return error(200, id, answered.error.code, answered.error.message, answered.error.data);
|
|
111
|
+
return reply(id, {
|
|
112
|
+
_meta: serverInfoOf(options),
|
|
113
|
+
resultType: "complete",
|
|
114
|
+
...answered.result
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return await answer(session, message, {
|
|
118
|
+
...options,
|
|
119
|
+
instructions: options.instructions ?? app.description
|
|
120
|
+
}, request.signal);
|
|
121
|
+
} finally {
|
|
122
|
+
if (!streaming) await close();
|
|
123
|
+
}
|
|
70
124
|
};
|
|
125
|
+
return Object.assign(handle, {
|
|
126
|
+
close: closeAll,
|
|
127
|
+
[Symbol.asyncDispose]: closeAll
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** The methods whose state outlives the request (spec/bindings/mcp.md#subscriptions-that-outlive-a-request). */
|
|
131
|
+
const LONG_LIVED = /* @__PURE__ */ new Set([
|
|
132
|
+
"events/poll",
|
|
133
|
+
"events/subscribe",
|
|
134
|
+
"events/unsubscribe"
|
|
135
|
+
]);
|
|
136
|
+
/** Answer `events/poll`, `events/subscribe` or `events/unsubscribe`. */
|
|
137
|
+
async function longLived(method, params, principal, open, leases, webhooks) {
|
|
138
|
+
if (method === "events/poll") return leases.poll(principal, params, open);
|
|
139
|
+
if (webhooks === void 0) return { error: unsupportedWebhook };
|
|
140
|
+
if (principal === void 0) return { error: forbidden };
|
|
141
|
+
return method === "events/subscribe" ? webhooks.subscribe(principal, params, open) : webhooks.unsubscribe(principal, params);
|
|
71
142
|
}
|
|
143
|
+
/** The `_meta` of a result: the information about the server. */
|
|
144
|
+
const serverInfoOf = (options) => ({ "io.modelcontextprotocol/serverInfo": {
|
|
145
|
+
name: options.name,
|
|
146
|
+
version: options.version
|
|
147
|
+
} });
|
|
72
148
|
async function answer(session, message, options, signal) {
|
|
73
149
|
const id = message.id ?? null;
|
|
74
150
|
const complete = (result, list = false) => reply(id, {
|
|
75
|
-
_meta:
|
|
76
|
-
name: options.name,
|
|
77
|
-
version: options.version
|
|
78
|
-
} },
|
|
151
|
+
_meta: serverInfoOf(options),
|
|
79
152
|
resultType: "complete",
|
|
80
153
|
...list ? {
|
|
81
154
|
ttlMs: 0,
|
|
@@ -86,7 +159,10 @@ async function answer(session, message, options, signal) {
|
|
|
86
159
|
switch (message.method) {
|
|
87
160
|
case "server/discover": return complete({
|
|
88
161
|
supportedVersions: [MCP_PROTOCOL_VERSION],
|
|
89
|
-
capabilities: {
|
|
162
|
+
capabilities: {
|
|
163
|
+
tools: { listChanged: false },
|
|
164
|
+
...eventCapabilities(session)
|
|
165
|
+
},
|
|
90
166
|
...options.instructions === void 0 ? {} : { instructions: options.instructions }
|
|
91
167
|
}, true);
|
|
92
168
|
case "tools/list": return complete({ tools: listTools(session) }, true);
|
|
@@ -97,9 +173,13 @@ async function answer(session, message, options, signal) {
|
|
|
97
173
|
const outcome = await session.invoke(name, message.arguments ?? {}, { signal });
|
|
98
174
|
return complete(mcpToolResult(outcome, hasObjectOutput(session, name)));
|
|
99
175
|
}
|
|
100
|
-
|
|
176
|
+
case "events/list":
|
|
177
|
+
if (!servesEvents(session)) break;
|
|
178
|
+
return complete({ events: listEvents(session, options.webhooks !== void 0) }, true);
|
|
101
179
|
}
|
|
180
|
+
return error(404, id, -32601, `Method not found: ${message.method}`);
|
|
102
181
|
}
|
|
182
|
+
/** The tools of the available capabilities in the view of a session (spec/bindings/mcp.md#tools). */
|
|
103
183
|
function listTools(session) {
|
|
104
184
|
const types = session.view.$defs ?? {};
|
|
105
185
|
return [...session.handles.values()].filter((handle) => handle.availability.getSnapshot().available).map((handle) => {
|
|
@@ -115,6 +195,7 @@ function listTools(session) {
|
|
|
115
195
|
};
|
|
116
196
|
});
|
|
117
197
|
}
|
|
198
|
+
/** True if the bundled output schema of a capability has `"type": "object"`. */
|
|
118
199
|
function hasObjectOutput(session, id) {
|
|
119
200
|
const output = session.view.capabilities[id]?.output;
|
|
120
201
|
return output !== void 0 && bundle(output, session.view.$defs ?? {}).type === "object";
|
|
@@ -173,4 +254,4 @@ const messages = {
|
|
|
173
254
|
"not-utf8": "The request body is not UTF-8 JSON."
|
|
174
255
|
};
|
|
175
256
|
//#endregion
|
|
176
|
-
export { mcpHandler };
|
|
257
|
+
export { hasObjectOutput, listTools, mcpHandler };
|
package/dist/node.d.ts
CHANGED
package/dist/node.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { webhookDelivery } from "./webhook.js";
|
|
1
2
|
import { createServer } from "node:http";
|
|
2
3
|
//#region src/node.ts
|
|
3
4
|
/**
|
|
4
5
|
* The functions that need Node.js. The main entry point, `@acid-cat/core-ts`, works in all runtimes.
|
|
5
6
|
*
|
|
7
|
+
* `webhookDelivery` sends the occurrences of webhook subscriptions of the MCP server through HTTP, with `node:https`.
|
|
8
|
+
*
|
|
6
9
|
* `serve` serves web request handlers, `(request: Request) => Promise<Response>`, such as the handlers of `mcpHandler`
|
|
7
10
|
* and `httpHandler`, on Node.js, with only `node:http`. The server serves plain HTTP/1.1. For HTTPS, HTTP/2 or
|
|
8
11
|
* middleware, use a library for web handlers.
|
|
@@ -107,6 +110,7 @@ async function send(response, outgoing) {
|
|
|
107
110
|
outgoing.end();
|
|
108
111
|
return;
|
|
109
112
|
}
|
|
113
|
+
outgoing.flushHeaders();
|
|
110
114
|
const reader = response.body.getReader();
|
|
111
115
|
const cancel = () => void reader.cancel().catch(() => void 0);
|
|
112
116
|
outgoing.on("close", cancel);
|
|
@@ -169,4 +173,4 @@ function bodyOf(incoming) {
|
|
|
169
173
|
}, { highWaterMark: 0 });
|
|
170
174
|
}
|
|
171
175
|
//#endregion
|
|
172
|
-
export { serve };
|
|
176
|
+
export { serve, webhookDelivery };
|
package/dist/session.d.ts
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
|
-
import type { Availability, CatalogDocument, Outcome, Result } from "@acid-cat/spec-ts";
|
|
2
|
-
import type { App, Capability, FailureOf, Observable, ToJsonSchema } from "./app.js";
|
|
1
|
+
import type { Availability, CallFailure, CatalogDocument, Outcome, Result } from "@acid-cat/spec-ts";
|
|
2
|
+
import type { App, Capability, EventDefinition, FailureOf, Observable, ToJsonSchema } from "./app.js";
|
|
3
|
+
import type { Occurrences } from "./subscription.js";
|
|
4
|
+
/** The options of a subscription (spec/bindings/typescript.md#event-handles). */
|
|
5
|
+
export interface SubscribeOptions {
|
|
6
|
+
/** Cancel the subscription. */
|
|
7
|
+
readonly signal?: AbortSignal | undefined;
|
|
8
|
+
/** Start after this position (spec/events.md#resuming). */
|
|
9
|
+
readonly cursor?: string | undefined;
|
|
10
|
+
/** Send no occurrence that is older than this number of milliseconds. */
|
|
11
|
+
readonly maxAgeMs?: number | undefined;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The handle of an event (spec/bindings/typescript.md#event-handles). `session.eventHandle` gives a handle with the
|
|
15
|
+
* types of the event. A handle of `session.eventHandles` has the input type `never`: only `subscribeExternal` can
|
|
16
|
+
* subscribe with it, because it decodes the input.
|
|
17
|
+
*/
|
|
18
|
+
export interface EventHandle<Input = never, Payload = unknown> {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly description: string;
|
|
21
|
+
readonly audience: readonly string[];
|
|
22
|
+
/**
|
|
23
|
+
* A subscription with an input that is already valid. The promise resolves when the subscription has started. A call
|
|
24
|
+
* failure before the first occurrence rejects with a `CallFailureError`.
|
|
25
|
+
*/
|
|
26
|
+
subscribe(input: Input, options?: SubscribeOptions): Promise<Occurrences<Payload>>;
|
|
27
|
+
/** A subscription with an input that is not trusted. The handle validates it first. */
|
|
28
|
+
subscribeExternal(input: unknown, options?: SubscribeOptions): Promise<Occurrences<unknown>>;
|
|
29
|
+
}
|
|
30
|
+
/** The handle of an event, with the types of its schemas. `session.eventHandle` gives it. */
|
|
31
|
+
export type EventHandleOf<E> = E extends EventDefinition<never, infer Input, infer Payload, string> ? EventHandle<Input, Payload> : never;
|
|
3
32
|
/**
|
|
4
33
|
* The handle of a capability (spec/bindings/typescript.md#handles). `Input`, `Value`, `Failure` and `State` are the
|
|
5
34
|
* types of the schemas of the capability. `session.handle` gives a handle with these types. A handle of
|
|
@@ -47,6 +76,19 @@ export interface Session<Context = unknown> {
|
|
|
47
76
|
readonly call: <Input, Value, Failures, State, Id extends string>(capability: Capability<Context, Input, Value, Failures, State, Id>, input: unknown, options?: {
|
|
48
77
|
readonly signal?: AbortSignal | undefined;
|
|
49
78
|
}) => Promise<Result<Value, FailureOf<Failures>>>;
|
|
79
|
+
/** The handles of the events in the view, by id. Bindings use them. */
|
|
80
|
+
readonly eventHandles: ReadonlyMap<string, EventHandle>;
|
|
81
|
+
/**
|
|
82
|
+
* The handle of an event, with the types of the event. Only the event object that the session serves gets a handle
|
|
83
|
+
* that subscribes to it. Any other event, also one with the same id, gets a handle whose subscriptions reject as
|
|
84
|
+
* `unknown-capability`.
|
|
85
|
+
*/
|
|
86
|
+
readonly eventHandle: <Input, Payload, Id extends string>(event: EventDefinition<Context, Input, Payload, Id>) => EventHandle<Input, Payload>;
|
|
87
|
+
/**
|
|
88
|
+
* A subscription with an input that is not trusted. The promise never rejects: it gives the occurrences, or the call
|
|
89
|
+
* failure before the first occurrence.
|
|
90
|
+
*/
|
|
91
|
+
readonly subscribe: (id: string, input: unknown, options?: SubscribeOptions) => Promise<Occurrences<unknown> | CallFailure>;
|
|
50
92
|
/** A call with an input that is not trusted. The outcome is in its JSON form. The promise never rejects. */
|
|
51
93
|
readonly invoke: (id: string, input: unknown, options?: {
|
|
52
94
|
readonly signal?: AbortSignal | undefined;
|
|
@@ -60,7 +102,10 @@ export interface Session<Context = unknown> {
|
|
|
60
102
|
};
|
|
61
103
|
/** The signal of the session. It aborts when the session closes. */
|
|
62
104
|
readonly signal: AbortSignal;
|
|
63
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Close the session: accept no new calls and subscriptions, cancel the calls in progress, end each subscription as
|
|
107
|
+
* complete, and wait for the calls.
|
|
108
|
+
*/
|
|
64
109
|
readonly dispose: () => Promise<void>;
|
|
65
110
|
/** Close the session, as `dispose` does, at the end of the block of `await using session = createSession(…)`. */
|
|
66
111
|
readonly [Symbol.asyncDispose]: () => Promise<void>;
|
|
@@ -72,6 +117,11 @@ export interface SessionOptions<Context> {
|
|
|
72
117
|
readonly toJsonSchema: ToJsonSchema;
|
|
73
118
|
/** Record a defect. The default writes it to the console. */
|
|
74
119
|
readonly onDefect?: ((id: string, cause: unknown) => void) | undefined;
|
|
120
|
+
/**
|
|
121
|
+
* The number of occurrences that each subscription keeps for a client that reads slowly. When the limit is full, the
|
|
122
|
+
* oldest occurrence goes, and the next occurrence that the client reads has `truncated: true`. The default is 256.
|
|
123
|
+
*/
|
|
124
|
+
readonly bufferLimit?: number | undefined;
|
|
75
125
|
}
|
|
76
126
|
/** Open a session for a target. */
|
|
77
127
|
export declare function createSession<Context>(app: App<Context>, options: SessionOptions<Context>): Session<Context>;
|
package/dist/session.js
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import { catalogOf } from "./app.js";
|
|
2
|
+
import { runSubscription } from "./subscription.js";
|
|
2
3
|
import { AVAILABLE, CallFailureError, callFailure, isCallFailure, viewOf } from "@acid-cat/spec-ts";
|
|
3
4
|
//#region src/session.ts
|
|
4
5
|
/**
|
|
5
6
|
* Sessions and handles (spec/invocation.md, spec/bindings/typescript.md).
|
|
6
7
|
*/
|
|
8
|
+
/** The handle of an event that the session does not serve. Each subscription rejects as `unknown-capability`. */
|
|
9
|
+
function eventOutsideView(event) {
|
|
10
|
+
const reject = () => Promise.reject(new CallFailureError(callFailure("unknown-capability", `There is no event ${event.id}.`)));
|
|
11
|
+
return {
|
|
12
|
+
id: event.id,
|
|
13
|
+
description: event.description,
|
|
14
|
+
audience: [],
|
|
15
|
+
subscribe: reject,
|
|
16
|
+
subscribeExternal: reject
|
|
17
|
+
};
|
|
18
|
+
}
|
|
7
19
|
/** The handle of a capability that the session does not serve. */
|
|
8
20
|
function outsideView(capability) {
|
|
9
21
|
const failure = callFailure("unknown-capability", `There is no capability ${capability.id}.`);
|
|
@@ -34,7 +46,7 @@ function createSession(app, options) {
|
|
|
34
46
|
const running = /* @__PURE__ */ new Set();
|
|
35
47
|
const listeners = /* @__PURE__ */ new Set();
|
|
36
48
|
let subscriptions = [];
|
|
37
|
-
const onDefect = options.onDefect ?? ((id, cause) => console.error(`A capability failed with a defect: ${id}`, cause));
|
|
49
|
+
const onDefect = options.onDefect ?? ((id, cause) => console.error(`A capability or an event failed with a defect: ${id}`, cause));
|
|
38
50
|
const served = /* @__PURE__ */ new Map();
|
|
39
51
|
for (const capability of app.capabilities) {
|
|
40
52
|
if (!capability.audience.includes(options.target)) continue;
|
|
@@ -145,6 +157,102 @@ function createSession(app, options) {
|
|
|
145
157
|
invokeExternal
|
|
146
158
|
});
|
|
147
159
|
}
|
|
160
|
+
const servedEvents = /* @__PURE__ */ new Map();
|
|
161
|
+
for (const event of app.events ?? []) {
|
|
162
|
+
if (!event.audience.includes(options.target)) continue;
|
|
163
|
+
if (served.has(event.id) || servedEvents.has(event.id)) throw new Error(`The id ${event.id} is declared two times.`);
|
|
164
|
+
servedEvents.set(event.id, event);
|
|
165
|
+
}
|
|
166
|
+
const bufferLimit = options.bufferLimit ?? 256;
|
|
167
|
+
/**
|
|
168
|
+
* Start a subscription to an event that the session serves: the decoded input, then the provider, with cancellation.
|
|
169
|
+
* The result is the occurrences, or the call failure before the first occurrence.
|
|
170
|
+
*/
|
|
171
|
+
async function start(id, prepare, check, subscribeOptions = {}) {
|
|
172
|
+
const { signal } = subscribeOptions;
|
|
173
|
+
const cancelled = () => lifetime.signal.aborted || signal?.aborted === true;
|
|
174
|
+
if (cancelled()) return callFailure("cancelled", "The subscription was cancelled.");
|
|
175
|
+
let prepared;
|
|
176
|
+
try {
|
|
177
|
+
prepared = await prepare();
|
|
178
|
+
} catch (cause) {
|
|
179
|
+
onDefect(id, cause);
|
|
180
|
+
return callFailure("internal", "The event failed.");
|
|
181
|
+
}
|
|
182
|
+
if (cancelled()) return callFailure("cancelled", "The subscription was cancelled.");
|
|
183
|
+
if ("issues" in prepared) return callFailure("invalid-input", issueMessage(prepared.issues));
|
|
184
|
+
const controller = new AbortController();
|
|
185
|
+
let source;
|
|
186
|
+
try {
|
|
187
|
+
source = prepared.start({
|
|
188
|
+
signal: controller.signal,
|
|
189
|
+
context: options.context,
|
|
190
|
+
...subscribeOptions.cursor === void 0 ? {} : { cursor: subscribeOptions.cursor },
|
|
191
|
+
...subscribeOptions.maxAgeMs === void 0 ? {} : { maxAgeMs: subscribeOptions.maxAgeMs }
|
|
192
|
+
});
|
|
193
|
+
if (!isAsyncIterable(source)) throw new TypeError("subscribe did not give an async iterable.");
|
|
194
|
+
} catch (cause) {
|
|
195
|
+
onDefect(id, cause);
|
|
196
|
+
return callFailure("internal", "The event failed.");
|
|
197
|
+
}
|
|
198
|
+
const complete = () => running.complete();
|
|
199
|
+
const cancel = () => running.cancel();
|
|
200
|
+
const running = runSubscription({
|
|
201
|
+
name: id,
|
|
202
|
+
source,
|
|
203
|
+
check,
|
|
204
|
+
controller,
|
|
205
|
+
limit: bufferLimit,
|
|
206
|
+
onDefect: (cause) => onDefect(id, cause),
|
|
207
|
+
onEnd: () => {
|
|
208
|
+
lifetime.signal.removeEventListener("abort", complete);
|
|
209
|
+
signal?.removeEventListener("abort", cancel);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
lifetime.signal.addEventListener("abort", complete);
|
|
213
|
+
signal?.addEventListener("abort", cancel);
|
|
214
|
+
return running.occurrences;
|
|
215
|
+
}
|
|
216
|
+
/** A subscription with an input that is not trusted. The data of each occurrence is a copy in its JSON form. */
|
|
217
|
+
async function subscribe(id, input, subscribeOptions) {
|
|
218
|
+
if (lifetime.signal.aborted || subscribeOptions?.signal?.aborted === true) return callFailure("cancelled", "The subscription was cancelled.");
|
|
219
|
+
const event = servedEvents.get(id);
|
|
220
|
+
if (event === void 0) return callFailure("unknown-capability", `There is no event ${id}.`);
|
|
221
|
+
return start(id, () => event.decode(input), (data) => checkPayload(event.payload, data, (value) => value), subscribeOptions);
|
|
222
|
+
}
|
|
223
|
+
/** Reject a call failure, as an event handle does. */
|
|
224
|
+
const opened = async (subscription) => {
|
|
225
|
+
const settled = await subscription;
|
|
226
|
+
if ("kind" in settled) throw new CallFailureError(settled);
|
|
227
|
+
return settled;
|
|
228
|
+
};
|
|
229
|
+
/** The typed handle of an event that the session serves, or the handle outside the view. */
|
|
230
|
+
function eventHandle(event) {
|
|
231
|
+
if (servedEvents.get(event.id) !== event) return eventOutsideView(event);
|
|
232
|
+
const source = (input, subscription) => event.input === void 0 ? event.subscribe({}, subscription) : event.subscribe(input, subscription);
|
|
233
|
+
const check = (data) => checkPayload(event.payload, data, () => data);
|
|
234
|
+
return {
|
|
235
|
+
id: event.id,
|
|
236
|
+
description: event.description,
|
|
237
|
+
audience: [options.target],
|
|
238
|
+
subscribe: (input, subscribeOptions) => {
|
|
239
|
+
if (lifetime.signal.aborted || subscribeOptions?.signal?.aborted === true) return Promise.reject(new CallFailureError(callFailure("cancelled", "The subscription was cancelled.")));
|
|
240
|
+
return opened(start(event.id, () => Promise.resolve({ start: (subscription) => source(input, subscription) }), check, subscribeOptions));
|
|
241
|
+
},
|
|
242
|
+
subscribeExternal: (input, subscribeOptions) => opened(subscribe(event.id, input, subscribeOptions))
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const eventHandles = /* @__PURE__ */ new Map();
|
|
246
|
+
for (const [id, event] of servedEvents) {
|
|
247
|
+
const subscribeExternal = (input, subscribeOptions) => opened(subscribe(id, input, subscribeOptions));
|
|
248
|
+
eventHandles.set(id, {
|
|
249
|
+
id,
|
|
250
|
+
description: event.description,
|
|
251
|
+
audience: [options.target],
|
|
252
|
+
subscribe: subscribeExternal,
|
|
253
|
+
subscribeExternal
|
|
254
|
+
});
|
|
255
|
+
}
|
|
148
256
|
const unsubscribeAll = () => {
|
|
149
257
|
subscriptions.forEach((subscription) => subscription.unsubscribe());
|
|
150
258
|
subscriptions = [];
|
|
@@ -163,6 +271,9 @@ function createSession(app, options) {
|
|
|
163
271
|
},
|
|
164
272
|
handles,
|
|
165
273
|
handle,
|
|
274
|
+
eventHandles,
|
|
275
|
+
eventHandle,
|
|
276
|
+
subscribe,
|
|
166
277
|
call: (capability, input, callOptions) => call(capability, input, callOptions?.signal),
|
|
167
278
|
signal: lifetime.signal,
|
|
168
279
|
invoke: (id, input, callOptions) => invoke(id, input, callOptions?.signal),
|
|
@@ -185,6 +296,23 @@ function createSession(app, options) {
|
|
|
185
296
|
function issueMessage(issues) {
|
|
186
297
|
return issues.map((issue) => `${(issue.path ?? []).map((key) => typeof key === "object" ? String(key.key) : String(key)).join(".") || "input"}: ${issue.message}`).join("; ");
|
|
187
298
|
}
|
|
299
|
+
/** True if a value is an async iterable. */
|
|
300
|
+
function isAsyncIterable(value) {
|
|
301
|
+
return typeof value === "object" && value !== null && Symbol.asyncIterator in value && typeof value[Symbol.asyncIterator] === "function";
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Check the data of an occurrence against the payload schema, and give a copy of the output of the schema. With no
|
|
305
|
+
* payload schema, the data must be an empty object, and `empty` gives the value. Data that does not agree is a defect.
|
|
306
|
+
*/
|
|
307
|
+
async function checkPayload(schema, data, empty) {
|
|
308
|
+
if (schema === void 0) {
|
|
309
|
+
if (typeof data !== "object" || data === null || Array.isArray(data) || Object.keys(data).length > 0) throw new Error("The data of an occurrence of an event with no payload schema is not an empty object.");
|
|
310
|
+
return structuredClone(empty(data));
|
|
311
|
+
}
|
|
312
|
+
const checked = await schema["~standard"].validate(data);
|
|
313
|
+
if (checked.issues !== void 0) throw new Error(`The data of an occurrence does not agree with the payload schema: ${checked.issues[0]?.message}`);
|
|
314
|
+
return structuredClone(checked.value);
|
|
315
|
+
}
|
|
188
316
|
/** An observable whose values are copies, so that a client cannot change the data of the provider. */
|
|
189
317
|
function detached(source) {
|
|
190
318
|
return {
|
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streams of messages for the bindings that serve events through a connection: the next occurrence or a sign that the
|
|
3
|
+
* stream is idle, and a web stream of text.
|
|
4
|
+
*/
|
|
5
|
+
import type { Occurrence } from "@acid-cat/spec-ts";
|
|
6
|
+
import type { Occurrences } from "./subscription.js";
|
|
7
|
+
/** One pull of a subscription: the next step, the error that ends it, or `idle` when nothing came in time. */
|
|
8
|
+
export type Pulled<Payload> = {
|
|
9
|
+
readonly idle: true;
|
|
10
|
+
} | {
|
|
11
|
+
readonly step: IteratorResult<Occurrence<Payload>, undefined>;
|
|
12
|
+
} | {
|
|
13
|
+
readonly error: unknown;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* A function that pulls the next step of a subscription. It gives `idle` when no step comes within `idleMs`
|
|
17
|
+
* milliseconds. The `next()` that waits is kept for the next pull, so no occurrence is lost.
|
|
18
|
+
*/
|
|
19
|
+
export declare function idlePuller<Payload>(occurrences: Occurrences<Payload>, idleMs: number): () => Promise<Pulled<Payload>>;
|
|
20
|
+
/**
|
|
21
|
+
* A web stream of text. The stream calls `produce` each time that the client can take more: it gives the next messages,
|
|
22
|
+
* or `undefined` at the end. When the client cancels the stream, the stream calls `cancel`. `release` runs one time, when
|
|
23
|
+
* the stream ends in any way.
|
|
24
|
+
*/
|
|
25
|
+
export declare function textStream(produce: () => Promise<readonly string[] | undefined>, cancel: () => void, release: () => Promise<void>): ReadableStream<Uint8Array>;
|
package/dist/stream.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
//#region src/stream.ts
|
|
2
|
+
/**
|
|
3
|
+
* A function that pulls the next step of a subscription. It gives `idle` when no step comes within `idleMs`
|
|
4
|
+
* milliseconds. The `next()` that waits is kept for the next pull, so no occurrence is lost.
|
|
5
|
+
*/
|
|
6
|
+
function idlePuller(occurrences, idleMs) {
|
|
7
|
+
let pending;
|
|
8
|
+
return async () => {
|
|
9
|
+
const next = pending ??= occurrences.next().then((step) => ({ step }), (error) => ({ error }));
|
|
10
|
+
let timer;
|
|
11
|
+
const idle = new Promise((resolve) => {
|
|
12
|
+
timer = setTimeout(() => resolve({ idle: true }), idleMs);
|
|
13
|
+
});
|
|
14
|
+
try {
|
|
15
|
+
const pulled = await Promise.race([next, idle]);
|
|
16
|
+
if (!("idle" in pulled)) pending = void 0;
|
|
17
|
+
return pulled;
|
|
18
|
+
} finally {
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A web stream of text. The stream calls `produce` each time that the client can take more: it gives the next messages,
|
|
25
|
+
* or `undefined` at the end. When the client cancels the stream, the stream calls `cancel`. `release` runs one time, when
|
|
26
|
+
* the stream ends in any way.
|
|
27
|
+
*/
|
|
28
|
+
function textStream(produce, cancel, release) {
|
|
29
|
+
const encoder = new TextEncoder();
|
|
30
|
+
let released = false;
|
|
31
|
+
let cancelled = false;
|
|
32
|
+
const releaseOnce = async () => {
|
|
33
|
+
if (released) return;
|
|
34
|
+
released = true;
|
|
35
|
+
await release();
|
|
36
|
+
};
|
|
37
|
+
return new ReadableStream({
|
|
38
|
+
async pull(controller) {
|
|
39
|
+
let messages;
|
|
40
|
+
try {
|
|
41
|
+
messages = await produce();
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (!cancelled) controller.error(error);
|
|
44
|
+
await releaseOnce();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (cancelled) return;
|
|
48
|
+
if (messages === void 0) {
|
|
49
|
+
controller.close();
|
|
50
|
+
await releaseOnce();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
controller.enqueue(encoder.encode(messages.join("")));
|
|
54
|
+
},
|
|
55
|
+
async cancel() {
|
|
56
|
+
cancelled = true;
|
|
57
|
+
cancel();
|
|
58
|
+
await releaseOnce();
|
|
59
|
+
}
|
|
60
|
+
}, { highWaterMark: 0 });
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
export { idlePuller, textStream };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Emitted, Occurrence } from "@acid-cat/spec-ts";
|
|
2
|
+
/**
|
|
3
|
+
* The occurrences of one subscription, as an async iterator (spec/bindings/typescript.md#event-handles). `return()`
|
|
4
|
+
* cancels the subscription, also while a `next()` waits: that `next()` then resolves with `done: true`. A `for await`
|
|
5
|
+
* loop that stops early calls `return()`. `await using` also cancels the subscription at the end of its block.
|
|
6
|
+
*/
|
|
7
|
+
export interface Occurrences<Payload> extends AsyncIterable<Occurrence<Payload>>, AsyncDisposable {
|
|
8
|
+
next(): Promise<IteratorResult<Occurrence<Payload>, undefined>>;
|
|
9
|
+
return(): Promise<IteratorResult<Occurrence<Payload>, undefined>>;
|
|
10
|
+
[Symbol.asyncIterator](): Occurrences<Payload>;
|
|
11
|
+
}
|
|
12
|
+
/** The number of occurrences that a subscription keeps for a client that reads slowly, if the session gives no limit. */
|
|
13
|
+
export declare const DEFAULT_BUFFER_LIMIT = 256;
|
|
14
|
+
export interface SubscriptionParts<Source, Payload> {
|
|
15
|
+
/** The id of the event. */
|
|
16
|
+
readonly name: string;
|
|
17
|
+
/** The occurrences of the provider. The pump starts to read them at once. */
|
|
18
|
+
readonly source: AsyncIterable<Emitted<Source>>;
|
|
19
|
+
/** Check the data of one occurrence against the payload schema, and give a copy in its JSON form. It throws if the data does not agree. */
|
|
20
|
+
readonly check: (data: Source) => Promise<Payload>;
|
|
21
|
+
/** The controller of the signal of the provider. The subscription aborts it when it ends. */
|
|
22
|
+
readonly controller: AbortController;
|
|
23
|
+
/** The maximum number of occurrences that wait for the client. */
|
|
24
|
+
readonly limit: number;
|
|
25
|
+
/** Record a defect of the provider. */
|
|
26
|
+
readonly onDefect: (cause: unknown) => void;
|
|
27
|
+
/** Called one time, when the subscription ends. */
|
|
28
|
+
readonly onEnd: () => void;
|
|
29
|
+
}
|
|
30
|
+
/** A subscription that runs: the iterator of the client, and the two ends that the session and the client cause. */
|
|
31
|
+
export interface Running<Payload> {
|
|
32
|
+
readonly occurrences: Occurrences<Payload>;
|
|
33
|
+
/** End the subscription as cancelled: the client sees `done: true` at once. */
|
|
34
|
+
readonly cancel: () => void;
|
|
35
|
+
/** End the subscription as complete: the client reads the occurrences that wait, then sees `done: true`. */
|
|
36
|
+
readonly complete: () => void;
|
|
37
|
+
}
|
|
38
|
+
/** Start the pump of a subscription, and give the iterator of the client. */
|
|
39
|
+
export declare function runSubscription<Source, Payload>(parts: SubscriptionParts<Source, Payload>): Running<Payload>;
|