@acid-cat/core-ts 0.9.0 → 0.10.1
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 +152 -12
- package/dist/app.d.ts +73 -1
- package/dist/app.js +35 -4
- package/dist/cli.d.ts +4 -4
- package/dist/cli.js +116 -18
- package/dist/clock.d.ts +14 -0
- package/dist/clock.js +25 -0
- package/dist/context.d.ts +8 -0
- package/dist/context.js +15 -11
- package/dist/http.d.ts +4 -0
- package/dist/http.js +136 -32
- package/dist/index.d.ts +13 -5
- package/dist/index.js +5 -2
- package/dist/mcp-events.d.ts +90 -0
- package/dist/mcp-events.js +130 -0
- package/dist/mcp-poll.d.ts +28 -0
- package/dist/mcp-poll.js +181 -0
- package/dist/mcp-stdio.d.ts +13 -0
- package/dist/mcp-stdio.js +96 -16
- package/dist/mcp.d.ts +40 -4
- package/dist/mcp.js +103 -25
- package/dist/node.d.ts +2 -0
- package/dist/node.js +6 -3
- package/dist/session.d.ts +58 -3
- package/dist/session.js +144 -5
- package/dist/stream.d.ts +25 -0
- package/dist/stream.js +62 -0
- package/dist/subscription.d.ts +39 -0
- package/dist/subscription.js +144 -0
- package/dist/webhook.d.ts +40 -0
- package/dist/webhook.js +384 -0
- package/dist/webmcp.js +1 -2
- package/package.json +9 -7
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,16 @@ 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
|
}
|
|
126
|
+
/**
|
|
127
|
+
* True if `maxAgeMs` is absent, or an integer that is 0 or more (spec/events.md#resuming). A session rejects another
|
|
128
|
+
* value as `invalid-input`, after it checks the id of the event.
|
|
129
|
+
*/
|
|
130
|
+
export declare const isMaxAgeMs: (maxAgeMs: number | undefined) => boolean;
|
|
76
131
|
/** Open a session for a target. */
|
|
77
132
|
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
|
+
// 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}.`);
|
|
@@ -24,6 +36,11 @@ function outsideView(capability) {
|
|
|
24
36
|
invokeExternal: reject
|
|
25
37
|
};
|
|
26
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* True if `maxAgeMs` is absent, or an integer that is 0 or more (spec/events.md#resuming). A session rejects another
|
|
41
|
+
* value as `invalid-input`, after it checks the id of the event.
|
|
42
|
+
*/
|
|
43
|
+
const isMaxAgeMs = (maxAgeMs) => maxAgeMs === void 0 || Number.isSafeInteger(maxAgeMs) && maxAgeMs >= 0;
|
|
27
44
|
const always = {
|
|
28
45
|
getSnapshot: () => AVAILABLE,
|
|
29
46
|
subscribe: () => ({ unsubscribe() {} })
|
|
@@ -34,7 +51,7 @@ function createSession(app, options) {
|
|
|
34
51
|
const running = /* @__PURE__ */ new Set();
|
|
35
52
|
const listeners = /* @__PURE__ */ new Set();
|
|
36
53
|
let subscriptions = [];
|
|
37
|
-
const onDefect = options.onDefect ?? ((id, cause) => console.error(`A capability failed with a defect: ${id}`, cause));
|
|
54
|
+
const onDefect = options.onDefect ?? ((id, cause) => console.error(`A capability or an event failed with a defect: ${id}`, cause));
|
|
38
55
|
const served = /* @__PURE__ */ new Map();
|
|
39
56
|
for (const capability of app.capabilities) {
|
|
40
57
|
if (!capability.audience.includes(options.target)) continue;
|
|
@@ -53,7 +70,13 @@ function createSession(app, options) {
|
|
|
53
70
|
if (lifetime.signal.aborted || signal?.aborted) return callFailure("cancelled", "The call was cancelled.");
|
|
54
71
|
const availability = entry.availability.getSnapshot();
|
|
55
72
|
if (!availability.available) return callFailure("unavailable", availability.reason);
|
|
56
|
-
|
|
73
|
+
let prepared;
|
|
74
|
+
try {
|
|
75
|
+
prepared = await prepare();
|
|
76
|
+
} catch (cause) {
|
|
77
|
+
onDefect(id, cause);
|
|
78
|
+
return callFailure("internal", "The capability failed.");
|
|
79
|
+
}
|
|
57
80
|
if ("issues" in prepared) return callFailure("invalid-input", issueMessage(prepared.issues));
|
|
58
81
|
const controller = new AbortController();
|
|
59
82
|
const abort = () => controller.abort();
|
|
@@ -145,6 +168,103 @@ function createSession(app, options) {
|
|
|
145
168
|
invokeExternal
|
|
146
169
|
});
|
|
147
170
|
}
|
|
171
|
+
const servedEvents = /* @__PURE__ */ new Map();
|
|
172
|
+
for (const event of app.events ?? []) {
|
|
173
|
+
if (!event.audience.includes(options.target)) continue;
|
|
174
|
+
if (served.has(event.id) || servedEvents.has(event.id)) throw new Error(`The id ${event.id} is declared two times.`);
|
|
175
|
+
servedEvents.set(event.id, event);
|
|
176
|
+
}
|
|
177
|
+
const bufferLimit = options.bufferLimit ?? 256;
|
|
178
|
+
/**
|
|
179
|
+
* Start a subscription to an event that the session serves: the decoded input, then the provider, with cancellation.
|
|
180
|
+
* The result is the occurrences, or the call failure before the first occurrence.
|
|
181
|
+
*/
|
|
182
|
+
async function start(id, prepare, check, subscribeOptions = {}) {
|
|
183
|
+
const { signal } = subscribeOptions;
|
|
184
|
+
const cancelled = () => lifetime.signal.aborted || signal?.aborted === true;
|
|
185
|
+
if (cancelled()) return callFailure("cancelled", "The subscription was cancelled.");
|
|
186
|
+
if (!isMaxAgeMs(subscribeOptions.maxAgeMs)) return callFailure("invalid-input", "maxAgeMs must be an integer that is 0 or more.");
|
|
187
|
+
let prepared;
|
|
188
|
+
try {
|
|
189
|
+
prepared = await prepare();
|
|
190
|
+
} catch (cause) {
|
|
191
|
+
onDefect(id, cause);
|
|
192
|
+
return callFailure("internal", "The event failed.");
|
|
193
|
+
}
|
|
194
|
+
if (cancelled()) return callFailure("cancelled", "The subscription was cancelled.");
|
|
195
|
+
if ("issues" in prepared) return callFailure("invalid-input", issueMessage(prepared.issues));
|
|
196
|
+
const controller = new AbortController();
|
|
197
|
+
let source;
|
|
198
|
+
try {
|
|
199
|
+
source = prepared.start({
|
|
200
|
+
signal: controller.signal,
|
|
201
|
+
context: options.context,
|
|
202
|
+
...subscribeOptions.cursor === void 0 ? {} : { cursor: subscribeOptions.cursor },
|
|
203
|
+
...subscribeOptions.maxAgeMs === void 0 ? {} : { maxAgeMs: subscribeOptions.maxAgeMs }
|
|
204
|
+
});
|
|
205
|
+
if (!isAsyncIterable(source)) throw new TypeError("subscribe did not give an async iterable.");
|
|
206
|
+
} catch (cause) {
|
|
207
|
+
onDefect(id, cause);
|
|
208
|
+
return callFailure("internal", "The event failed.");
|
|
209
|
+
}
|
|
210
|
+
const complete = () => running.complete();
|
|
211
|
+
const cancel = () => running.cancel();
|
|
212
|
+
const running = runSubscription({
|
|
213
|
+
name: id,
|
|
214
|
+
source,
|
|
215
|
+
check,
|
|
216
|
+
controller,
|
|
217
|
+
limit: bufferLimit,
|
|
218
|
+
onDefect: (cause) => onDefect(id, cause),
|
|
219
|
+
onEnd: () => {
|
|
220
|
+
lifetime.signal.removeEventListener("abort", complete);
|
|
221
|
+
signal?.removeEventListener("abort", cancel);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
lifetime.signal.addEventListener("abort", complete);
|
|
225
|
+
signal?.addEventListener("abort", cancel);
|
|
226
|
+
return running.occurrences;
|
|
227
|
+
}
|
|
228
|
+
/** A subscription with an input that is not trusted. The data of each occurrence is a copy in its JSON form. */
|
|
229
|
+
async function subscribe(id, input, subscribeOptions) {
|
|
230
|
+
if (lifetime.signal.aborted || subscribeOptions?.signal?.aborted === true) return callFailure("cancelled", "The subscription was cancelled.");
|
|
231
|
+
const event = servedEvents.get(id);
|
|
232
|
+
if (event === void 0) return callFailure("unknown-capability", `There is no event ${id}.`);
|
|
233
|
+
return start(id, () => event.decode(input), (data) => checkPayload(event.payload, data, (value) => value), subscribeOptions);
|
|
234
|
+
}
|
|
235
|
+
/** Reject a call failure, as an event handle does. */
|
|
236
|
+
const opened = async (subscription) => {
|
|
237
|
+
const settled = await subscription;
|
|
238
|
+
if ("kind" in settled) throw new CallFailureError(settled);
|
|
239
|
+
return settled;
|
|
240
|
+
};
|
|
241
|
+
/** The typed handle of an event that the session serves, or the handle outside the view. */
|
|
242
|
+
function eventHandle(event) {
|
|
243
|
+
if (servedEvents.get(event.id) !== event) return eventOutsideView(event);
|
|
244
|
+
const source = (input, subscription) => event.input === void 0 ? event.subscribe({}, subscription) : event.subscribe(input, subscription);
|
|
245
|
+
const check = (data) => checkPayload(event.payload, data, () => data);
|
|
246
|
+
return {
|
|
247
|
+
id: event.id,
|
|
248
|
+
description: event.description,
|
|
249
|
+
audience: [options.target],
|
|
250
|
+
subscribe: (input, subscribeOptions) => {
|
|
251
|
+
if (lifetime.signal.aborted || subscribeOptions?.signal?.aborted === true) return Promise.reject(new CallFailureError(callFailure("cancelled", "The subscription was cancelled.")));
|
|
252
|
+
return opened(start(event.id, () => Promise.resolve({ start: (subscription) => source(input, subscription) }), check, subscribeOptions));
|
|
253
|
+
},
|
|
254
|
+
subscribeExternal: (input, subscribeOptions) => opened(subscribe(event.id, input, subscribeOptions))
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const eventHandles = /* @__PURE__ */ new Map();
|
|
258
|
+
for (const [id, event] of servedEvents) {
|
|
259
|
+
const subscribeExternal = (input, subscribeOptions) => opened(subscribe(id, input, subscribeOptions));
|
|
260
|
+
eventHandles.set(id, {
|
|
261
|
+
id,
|
|
262
|
+
description: event.description,
|
|
263
|
+
audience: [options.target],
|
|
264
|
+
subscribe: subscribeExternal,
|
|
265
|
+
subscribeExternal
|
|
266
|
+
});
|
|
267
|
+
}
|
|
148
268
|
const unsubscribeAll = () => {
|
|
149
269
|
subscriptions.forEach((subscription) => subscription.unsubscribe());
|
|
150
270
|
subscriptions = [];
|
|
@@ -163,6 +283,9 @@ function createSession(app, options) {
|
|
|
163
283
|
},
|
|
164
284
|
handles,
|
|
165
285
|
handle,
|
|
286
|
+
eventHandles,
|
|
287
|
+
eventHandle,
|
|
288
|
+
subscribe,
|
|
166
289
|
call: (capability, input, callOptions) => call(capability, input, callOptions?.signal),
|
|
167
290
|
signal: lifetime.signal,
|
|
168
291
|
invoke: (id, input, callOptions) => invoke(id, input, callOptions?.signal),
|
|
@@ -185,6 +308,23 @@ function createSession(app, options) {
|
|
|
185
308
|
function issueMessage(issues) {
|
|
186
309
|
return issues.map((issue) => `${(issue.path ?? []).map((key) => typeof key === "object" ? String(key.key) : String(key)).join(".") || "input"}: ${issue.message}`).join("; ");
|
|
187
310
|
}
|
|
311
|
+
/** True if a value is an async iterable. */
|
|
312
|
+
function isAsyncIterable(value) {
|
|
313
|
+
return typeof value === "object" && value !== null && Symbol.asyncIterator in value && typeof value[Symbol.asyncIterator] === "function";
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Check the data of an occurrence against the payload schema, and give a copy of the output of the schema. With no
|
|
317
|
+
* payload schema, the data must be an empty object, and `empty` gives the value. Data that does not agree is a defect.
|
|
318
|
+
*/
|
|
319
|
+
async function checkPayload(schema, data, empty) {
|
|
320
|
+
if (schema === void 0) {
|
|
321
|
+
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.");
|
|
322
|
+
return structuredClone(empty(data));
|
|
323
|
+
}
|
|
324
|
+
const checked = await schema["~standard"].validate(data);
|
|
325
|
+
if (checked.issues !== void 0) throw new Error(`The data of an occurrence does not agree with the payload schema: ${checked.issues[0]?.message}`);
|
|
326
|
+
return structuredClone(checked.value);
|
|
327
|
+
}
|
|
188
328
|
/** An observable whose values are copies, so that a client cannot change the data of the provider. */
|
|
189
329
|
function detached(source) {
|
|
190
330
|
return {
|
|
@@ -251,5 +391,4 @@ async function checkFailure(failure, tag, schemas) {
|
|
|
251
391
|
...checked.value
|
|
252
392
|
};
|
|
253
393
|
}
|
|
254
|
-
|
|
255
|
-
export { createSession };
|
|
394
|
+
export { createSession, isMaxAgeMs };
|
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,62 @@
|
|
|
1
|
+
// 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
|
+
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>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { CallFailureError, callFailure, occurrenceOf } from "@acid-cat/spec-ts";
|
|
2
|
+
// src/subscription.ts
|
|
3
|
+
/**
|
|
4
|
+
* One subscription (spec/events.md): the pump that reads the occurrences of the provider, the bounded buffer for a slow
|
|
5
|
+
* client, and the iterator that the client reads.
|
|
6
|
+
*/
|
|
7
|
+
/** The number of occurrences that a subscription keeps for a client that reads slowly, if the session gives no limit. */
|
|
8
|
+
const DEFAULT_BUFFER_LIMIT = 256;
|
|
9
|
+
/** The message of the call failure `internal` of a subscription. It shows no internal data. */
|
|
10
|
+
const INTERNAL_MESSAGE = "The event failed.";
|
|
11
|
+
const DONE = {
|
|
12
|
+
done: true,
|
|
13
|
+
value: void 0
|
|
14
|
+
};
|
|
15
|
+
/** Start the pump of a subscription, and give the iterator of the client. */
|
|
16
|
+
function runSubscription(parts) {
|
|
17
|
+
const buffer = [];
|
|
18
|
+
const waiters = [];
|
|
19
|
+
/** True when the buffer removed occurrences: the next occurrence that the client reads has `truncated: true`. */
|
|
20
|
+
let dropped = false;
|
|
21
|
+
/** How the source ended. The client still reads the occurrences in the buffer before it sees the end. */
|
|
22
|
+
let ending;
|
|
23
|
+
/** True when the client sees only `done: true`. */
|
|
24
|
+
let closed = false;
|
|
25
|
+
let stopped = false;
|
|
26
|
+
let stopSource = () => {};
|
|
27
|
+
const take = (occurrence) => {
|
|
28
|
+
if (!dropped) return occurrence;
|
|
29
|
+
dropped = false;
|
|
30
|
+
return {
|
|
31
|
+
...occurrence,
|
|
32
|
+
truncated: true
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
/** Give each waiting `next()` an occurrence or the end, while there are some. */
|
|
36
|
+
const settle = () => {
|
|
37
|
+
for (let waiter = waiters.shift(); waiter !== void 0; waiter = waiters.shift()) {
|
|
38
|
+
const occurrence = buffer.shift();
|
|
39
|
+
if (occurrence !== void 0) waiter.resolve({
|
|
40
|
+
done: false,
|
|
41
|
+
value: take(occurrence)
|
|
42
|
+
});
|
|
43
|
+
else if (closed) waiter.resolve(DONE);
|
|
44
|
+
else if (ending !== void 0) {
|
|
45
|
+
closed = true;
|
|
46
|
+
if (ending.failure === void 0) waiter.resolve(DONE);
|
|
47
|
+
else waiter.reject(ending.failure);
|
|
48
|
+
} else {
|
|
49
|
+
waiters.unshift(waiter);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
/** Stop the provider one time: abort its signal, and close its iterator. */
|
|
55
|
+
const stop = () => {
|
|
56
|
+
if (stopped) return;
|
|
57
|
+
stopped = true;
|
|
58
|
+
parts.controller.abort();
|
|
59
|
+
stopSource();
|
|
60
|
+
parts.onEnd();
|
|
61
|
+
};
|
|
62
|
+
const end = (how) => {
|
|
63
|
+
if (stopped) return;
|
|
64
|
+
ending = how;
|
|
65
|
+
stop();
|
|
66
|
+
settle();
|
|
67
|
+
};
|
|
68
|
+
const cancel = () => {
|
|
69
|
+
if (closed) return;
|
|
70
|
+
closed = true;
|
|
71
|
+
buffer.length = 0;
|
|
72
|
+
stop();
|
|
73
|
+
settle();
|
|
74
|
+
};
|
|
75
|
+
const push = (occurrence) => {
|
|
76
|
+
buffer.push(occurrence);
|
|
77
|
+
if (buffer.length > parts.limit) {
|
|
78
|
+
buffer.shift();
|
|
79
|
+
dropped = true;
|
|
80
|
+
}
|
|
81
|
+
settle();
|
|
82
|
+
};
|
|
83
|
+
(async () => {
|
|
84
|
+
try {
|
|
85
|
+
const iterator = parts.source[Symbol.asyncIterator]();
|
|
86
|
+
stopSource = () => void Promise.resolve().then(() => iterator.return?.()).catch(() => void 0);
|
|
87
|
+
if (stopped) stopSource();
|
|
88
|
+
for (;;) {
|
|
89
|
+
const step = await iterator.next();
|
|
90
|
+
if (stopped) return;
|
|
91
|
+
if (step.done === true) return end({});
|
|
92
|
+
const emitted = checkEmitted(step.value);
|
|
93
|
+
const data = await parts.check(emitted.data);
|
|
94
|
+
if (stopped) return;
|
|
95
|
+
push(occurrenceOf(parts.name, {
|
|
96
|
+
...emitted,
|
|
97
|
+
data
|
|
98
|
+
}, () => crypto.randomUUID(), () => /* @__PURE__ */ new Date()));
|
|
99
|
+
}
|
|
100
|
+
} catch (cause) {
|
|
101
|
+
if (stopped) return;
|
|
102
|
+
parts.onDefect(cause);
|
|
103
|
+
end({ failure: new CallFailureError(callFailure("internal", INTERNAL_MESSAGE)) });
|
|
104
|
+
}
|
|
105
|
+
})();
|
|
106
|
+
const occurrences = {
|
|
107
|
+
next: () => new Promise((resolve, reject) => {
|
|
108
|
+
waiters.push({
|
|
109
|
+
resolve,
|
|
110
|
+
reject
|
|
111
|
+
});
|
|
112
|
+
settle();
|
|
113
|
+
}),
|
|
114
|
+
return: () => {
|
|
115
|
+
cancel();
|
|
116
|
+
return Promise.resolve(DONE);
|
|
117
|
+
},
|
|
118
|
+
[Symbol.asyncIterator]: () => occurrences,
|
|
119
|
+
[Symbol.asyncDispose]: () => {
|
|
120
|
+
cancel();
|
|
121
|
+
return Promise.resolve();
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
occurrences,
|
|
126
|
+
cancel,
|
|
127
|
+
complete: () => end({})
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Check the members of a value that the provider gives, beside its data. The provider is code of the application, but
|
|
132
|
+
* plain JavaScript can give any value. A value that is not valid is a defect.
|
|
133
|
+
*/
|
|
134
|
+
function checkEmitted(value) {
|
|
135
|
+
const object = value;
|
|
136
|
+
if (typeof object !== "object" || object === null) throw new TypeError("The provider gave a value that is not an object.");
|
|
137
|
+
const { eventId, timestamp, cursor, truncated } = value;
|
|
138
|
+
if (eventId !== void 0 && (typeof eventId !== "string" || eventId === "")) throw new TypeError("The eventId of an occurrence is not a string.");
|
|
139
|
+
if (timestamp !== void 0 && (typeof timestamp !== "string" || Number.isNaN(Date.parse(timestamp)))) throw new TypeError("The timestamp of an occurrence is not a date and time.");
|
|
140
|
+
if (cursor !== void 0 && typeof cursor !== "string") throw new TypeError("The cursor of an occurrence is not a string.");
|
|
141
|
+
if (truncated !== void 0 && truncated !== true) throw new TypeError("The truncated member of an occurrence is not true.");
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
export { DEFAULT_BUFFER_LIMIT, runSubscription };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Clock } from "./clock.js";
|
|
2
|
+
import type { WebhookDelivery } from "./mcp-events.js";
|
|
3
|
+
/** The settings of webhook delivery. Each limit has the default of spec/bindings/mcp.md#webhook. */
|
|
4
|
+
export interface WebhookOptions {
|
|
5
|
+
/**
|
|
6
|
+
* The allowlist: a URL for which this function gives `true` gets deliveries with no verification. With no function,
|
|
7
|
+
* each pair of principal and URL needs a verification.
|
|
8
|
+
*/
|
|
9
|
+
readonly allow?: ((url: URL) => boolean) | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Connect also to addresses that are not globally routable, for example `127.0.0.1` or `10.0.0.1`. Only for tests and
|
|
12
|
+
* private deployments. The default is `false`.
|
|
13
|
+
*/
|
|
14
|
+
readonly allowPrivateNetworks?: boolean | undefined;
|
|
15
|
+
/** Certificates of authorities to trust beside the default ones, in PEM form. For example the authority of a test receiver. */
|
|
16
|
+
readonly ca?: string | Buffer | readonly (string | Buffer)[] | undefined;
|
|
17
|
+
/** The maximum lifetime of a subscription, in milliseconds. The default is 1 hour. */
|
|
18
|
+
readonly maxTtlMs?: number | undefined;
|
|
19
|
+
/** The minimum lifetime of a subscription, in milliseconds. The default is 1 minute. */
|
|
20
|
+
readonly minTtlMs?: number | undefined;
|
|
21
|
+
/** The maximum number of webhook subscriptions of one principal. The default is 100. */
|
|
22
|
+
readonly maxSubscriptions?: number | undefined;
|
|
23
|
+
/** The maximum number of attempts to send one delivery. The default is 5. */
|
|
24
|
+
readonly attempts?: number | undefined;
|
|
25
|
+
/** The time in which the server makes the attempts of one delivery, in milliseconds. The default is 15 minutes. */
|
|
26
|
+
readonly retryWindowMs?: number | undefined;
|
|
27
|
+
/** The time that the server waits for each answer, in milliseconds. The default is 5 seconds. */
|
|
28
|
+
readonly timeoutMs?: number | undefined;
|
|
29
|
+
/** The time after a change of the secret in which the server signs with the old secret and the new secret. The default is 5 minutes. */
|
|
30
|
+
readonly rotationGraceMs?: number | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* The wait before the next attempt, after attempt number `attempt` (1 for the first attempt) failed, in milliseconds.
|
|
33
|
+
* The default is 10 seconds, multiplied by 3 for each attempt: 10 s, 30 s, 90 s and 270 s.
|
|
34
|
+
*/
|
|
35
|
+
readonly retryDelayMs?: ((attempt: number) => number) | undefined;
|
|
36
|
+
/** The clock of lifetimes and retries. The default is the clock of the system. */
|
|
37
|
+
readonly clock?: Clock | undefined;
|
|
38
|
+
}
|
|
39
|
+
/** Make webhook delivery for `mcpHandler` (option `webhooks`). */
|
|
40
|
+
export declare function webhookDelivery(options?: WebhookOptions): WebhookDelivery;
|