@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/dist/mcp.js CHANGED
@@ -1,17 +1,20 @@
1
1
  import { createSession } from "./session.js";
2
- import { withContext } from "./context.js";
3
- import { MCP_PROTOCOL_VERSION, MCP_REJECTIONS, MCP_UNKNOWN_TOOL, bundle, isJsonObject, mcpToolResult, readBounded, screenRequest } from "@acid-cat/spec-ts";
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, with no state on the
7
- * server between requests.
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
- /** A web request handler for the MCP server. Each request has its own session, and with `createContext` its own context. */
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
- return async (request) => {
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
- return withContext(options, request, async (context) => {
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
- try {
62
- return await answer(session, message, {
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: { "io.modelcontextprotocol/serverInfo": {
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: { tools: { listChanged: false } },
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
- default: return error(404, id, -32601, `Method not found: ${message.method}`);
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
@@ -1,3 +1,5 @@
1
+ export { webhookDelivery } from "./webhook.js";
2
+ export type { WebhookOptions } from "./webhook.js";
1
3
  /** A web request handler. */
2
4
  export type WebHandler = (request: Request) => Promise<Response> | Response;
3
5
  /**
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
- /** Close the session: accept no new calls, cancel the calls in progress, and wait for them. */
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 {
@@ -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>;