@acid-cat/core-ts 0.10.0 → 0.10.2

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 CHANGED
@@ -78,6 +78,7 @@ export const aircon = defineApp<{ unit: Unit }>({ targets, capabilities: [powerS
78
78
  ## Write the catalog
79
79
 
80
80
  ```ts
81
+ import { writeFile } from "node:fs/promises";
81
82
  import { renderCatalog } from "@acid-cat/core-ts";
82
83
 
83
84
  const toJsonSchema = (schema) => z.toJSONSchema(schema, { target: "draft-2020-12" });
@@ -248,7 +249,7 @@ for await (const occurrence of await status.subscribe({})) {
248
249
  }
249
250
  ```
250
251
 
251
- - The promise of `subscribe` resolves when the subscription has started. Before that, it rejects with a `CallFailureError`: `unknown-capability` for an event that the session does not serve, `invalid-input` for an input that does not agree with the schema, and `cancelled` when the session closes or the signal aborts.
252
+ - The promise of `subscribe` resolves when the subscription has started. Before that, it rejects with a `CallFailureError`: `unknown-capability` for an event that the session does not serve, `invalid-input` for an input that does not agree with the schema or a `maxAgeMs` that is not an integer of 0 or more, and `cancelled` when the session closes or the signal aborts.
252
253
  - The result is an async iterator of occurrences: `eventId`, `name`, `timestamp`, `data`, and `cursor` and `truncated` if they are present. `return()` cancels the subscription, also while a `next()` waits: that `next()` resolves with `done: true`. `await using` also cancels it at the end of the block.
253
254
  - The options are `signal`, `cursor` and `maxAgeMs`.
254
255
  - When the session closes, each subscription is complete. With `internal`, `next()` rejects with a `CallFailureError`, then resolves with `done: true`.
@@ -274,7 +275,7 @@ const handler = httpHandler(aircon, {
274
275
 
275
276
  ### Command line
276
277
 
277
- The command line has one command for each capability in the view of the `cli` target. Each top-level property of the input is a flag with the same name, for example `--celsius 22` or `--celsius=22`. A boolean property is a switch, for example `--on`. A property with an array of strings, numbers or values of a string enum is a repeated flag: each occurrence gives one item, in the order of the flags. A repeated flag that occurs once can also give the whole array as JSON text. Other properties take JSON text. For example, with the command line of the air conditioner, [`examples/aircon/core-ts/cli.ts`](../../examples/aircon/core-ts/cli.ts):
278
+ The command line has one command for each capability in the view of the `cli` target. The words of the id give the path of the command. A shared prefix only contains other commands: if the path of a capability is the start of the path of another capability, for example `aircon.status` and `aircon.status.get`, `runCli` rejects and `cliMain` stops the program with an error when it starts. Each top-level property of the input is a flag with the same name, for example `--celsius 22` or `--celsius=22`. A boolean property is a switch, for example `--on`. A property with an array of strings, numbers or values of a string enum is a repeated flag: each occurrence gives one item, in the order of the flags. A repeated flag that occurs once can also give the whole array as JSON text. Other properties take JSON text. For example, with the command line of the air conditioner, [`examples/aircon/core-ts/cli.ts`](../../examples/aircon/core-ts/cli.ts):
278
279
 
279
280
  ```sh
280
281
  node cli.ts aircon temperature set --celsius 22
package/dist/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { canonicalText } from "@acid-cat/catalog";
2
2
  import { isJsonObject, objectInput } from "@acid-cat/spec-ts";
3
- //#region src/app.ts
3
+ // src/app.ts
4
4
  /**
5
5
  * Capabilities and applications in plain TypeScript. An application writes its own catalog.
6
6
  * See spec/catalog.md.
@@ -127,5 +127,4 @@ function write(app, toJsonSchema) {
127
127
  }
128
128
  /** The catalog of an application, as text in canonical form. */
129
129
  const renderCatalog = (app, options) => canonicalText(catalogOf(app, options));
130
- //#endregion
131
130
  export { capabilitiesFor, catalogOf, defineApp, defineCapability, defineEvent, eventsFor, renderCatalog };
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createSession } from "./session.js";
2
2
  import { CLI_EXIT_CODES, CLI_RESUME_FLAGS, CLI_WATCH_EXIT_CODES, CallFailureError, bundle, cliEventUsage, cliExitCode, cliFlags, cliJsonArray, cliRepeatedItems, cliResolve, cliUsage, commandPath, isCallFailure, isJsonObject } from "@acid-cat/spec-ts";
3
- //#region src/cli.ts
3
+ // src/cli.ts
4
4
  /**
5
5
  * The CLI binding (spec/bindings/cli.md): a command line made from the catalog.
6
6
  */
@@ -42,6 +42,10 @@ async function runCli(app, options, args, io) {
42
42
  path: commandPath(id)
43
43
  }));
44
44
  for (const { path } of commands) if (path[0] !== void 0 && GENERIC_COMMANDS.includes(path[0])) throw new Error(`The command ${path.join(" ")} has the same name as a command of the command line.`);
45
+ for (const { path } of commands) {
46
+ const longer = commands.find((other) => other.path.length > path.length && path.every((word, index) => other.path[index] === word));
47
+ if (longer !== void 0) throw new Error(`The command ${path.join(" ")} is also the start of the command ${longer.path.join(" ")}. A shared prefix only contains other commands.`);
48
+ }
45
49
  for (const [id, event] of Object.entries(session.view.events ?? {})) {
46
50
  const properties = event.input === void 0 ? void 0 : bundle(event.input, types).properties;
47
51
  const taken = CLI_RESUME_FLAGS.find((flag) => isJsonObject(properties) && flag in properties);
@@ -293,5 +297,4 @@ function usage(name, path, description, schema) {
293
297
  description
294
298
  ].join("\n");
295
299
  }
296
- //#endregion
297
300
  export { main, runCli };
package/dist/clock.js CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/clock.ts
1
+ // src/clock.ts
2
2
  /** The clock of the system: `Date.now` and `setTimeout`. */
3
3
  const systemClock = {
4
4
  now: () => Date.now(),
@@ -22,5 +22,4 @@ function sleep(clock, ms, signal) {
22
22
  signal.addEventListener("abort", stop, { once: true });
23
23
  });
24
24
  }
25
- //#endregion
26
25
  export { sleep, systemClock };
package/dist/context.js CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/context.ts
1
+ // src/context.ts
2
2
  /**
3
3
  * The context of one request, and the function that releases it if the handler made it. A response with a stream, such
4
4
  * as the stream of a subscription, releases the context when the stream ends.
@@ -24,5 +24,4 @@ async function release(value) {
24
24
  }
25
25
  /** Tell if a value is a function. A function can take any arguments, and its result is not known. */
26
26
  const isFunction = (value) => typeof value === "function";
27
- //#endregion
28
27
  export { openContext };
package/dist/http.js CHANGED
@@ -1,8 +1,8 @@
1
- import { createSession } from "./session.js";
2
1
  import { openContext } from "./context.js";
2
+ import { createSession } from "./session.js";
3
3
  import { idlePuller, textStream } from "./stream.js";
4
4
  import { CallFailureError, KEEPALIVE_MS, SSE_MEDIA_TYPE, callFailure, httpEventMessage, httpStatus, isCallFailure, readBounded, screenRequest } from "@acid-cat/spec-ts";
5
- //#region src/http.ts
5
+ // src/http.ts
6
6
  /**
7
7
  * The HTTP binding (spec/bindings/http.md): JSON through HTTP.
8
8
  */
@@ -42,13 +42,15 @@ function httpHandler(app, options) {
42
42
  if (request.method === "GET" && path === "/catalog" && options.serveCatalog !== false) return json(200, session.view);
43
43
  const route = request.method === "POST" ? postRoute(path) : void 0;
44
44
  if (route === void 0) return json(404, callFailure("unknown-capability", "There is no such route."));
45
+ const id = decodedId(route.id);
46
+ if (id === void 0) return json(404, callFailure("unknown-capability", "There is no such route."));
45
47
  let input;
46
48
  try {
47
49
  input = body.text === "" ? {} : JSON.parse(body.text);
48
50
  } catch {
49
- return json(422, callFailure("invalid-input", "The request body is not JSON."));
51
+ const failure = notJson(session, route.kind, id, request.signal);
52
+ return json(httpStatus(failure), failureBody(failure));
50
53
  }
51
- const id = decodeURIComponent(route.id);
52
54
  if (route.kind === "capability") {
53
55
  const outcome = await session.invoke(id, input, { signal: request.signal });
54
56
  return json(httpStatus(outcome), isCallFailure(outcome) ? failureBody(outcome) : outcome);
@@ -75,17 +77,38 @@ function postRoute(path) {
75
77
  id: path.slice(8)
76
78
  };
77
79
  }
80
+ /** The id of a route with its escapes decoded, or `undefined` if an escape is malformed, as in `%E0`. */
81
+ function decodedId(encoded) {
82
+ try {
83
+ return decodeURIComponent(encoded);
84
+ } catch {
85
+ return;
86
+ }
87
+ }
88
+ /**
89
+ * The call failure of a request whose body is not JSON. The kinds before `invalid-input` apply first
90
+ * (spec/invocation.md#call-failures, spec/events.md#subscribing): `cancelled`, `unknown-capability`, then `unavailable`
91
+ * for a capability.
92
+ */
93
+ function notJson(session, kind, id, signal) {
94
+ if (session.signal.aborted || signal.aborted) return callFailure("cancelled", "The call was cancelled.");
95
+ if (kind === "event") {
96
+ if (!session.eventHandles.has(id)) return callFailure("unknown-capability", `There is no event ${id}.`);
97
+ } else {
98
+ const handle = session.handles.get(id);
99
+ if (handle === void 0) return callFailure("unknown-capability", `There is no capability ${id}.`);
100
+ const availability = handle.availability.getSnapshot();
101
+ if (!availability.available) return callFailure("unavailable", availability.reason);
102
+ }
103
+ return callFailure("invalid-input", "The request body is not JSON.");
104
+ }
78
105
  /**
79
106
  * Subscribe to an event (spec/bindings/http.md#events). A call failure before the first occurrence is a JSON answer.
80
107
  * After that, the answer is a stream of server-sent events, which closes the session when it ends.
81
108
  */
82
109
  async function subscribe(session, id, input, url, request, stream) {
83
110
  const maxAge = url.searchParams.get("maxAgeMs");
84
- const maxAgeMs = maxAge === null ? void 0 : Number(maxAge);
85
- if (maxAgeMs !== void 0 && (maxAge?.trim() === "" || !Number.isInteger(maxAgeMs) || maxAgeMs < 0)) return {
86
- response: json(422, callFailure("invalid-input", "maxAgeMs must be a whole number of milliseconds.")),
87
- streaming: false
88
- };
111
+ const maxAgeMs = maxAge === null ? void 0 : /^[0-9]+$/.test(maxAge) ? Number(maxAge) : NaN;
89
112
  const cursor = request.headers.get("Last-Event-ID") ?? void 0;
90
113
  const subscribed = await session.subscribe(id, input, {
91
114
  signal: request.signal,
@@ -151,5 +174,4 @@ function rejection(reason) {
151
174
  headers
152
175
  });
153
176
  }
154
- //#endregion
155
177
  export { httpHandler };
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { capabilitiesFor, catalogOf, defineApp, defineCapability, defineEvent, eventsFor, renderCatalog } from "./app.js";
2
- import { DEFAULT_BUFFER_LIMIT } from "./subscription.js";
3
2
  import { createSession } from "./session.js";
4
- import { systemClock } from "./clock.js";
3
+ import { DEFAULT_BUFFER_LIMIT } from "./subscription.js";
5
4
  import { mcpHandler } from "./mcp.js";
5
+ import { systemClock } from "./clock.js";
6
6
  import { main as main$1, runMcpStdio } from "./mcp-stdio.js";
7
7
  import { httpHandler } from "./http.js";
8
8
  import { main, runCli } from "./cli.js";
@@ -51,6 +51,9 @@ export declare const invalidParams: (message: string) => {
51
51
  /**
52
52
  * Read the subscription parameters (spec/bindings/mcp.md#events): the id, the input, the cursor and `maxAgeMs`. An
53
53
  * absent cursor and a `null` cursor are the same. The input is `{}` when `arguments` is absent.
54
+ *
55
+ * This function does not check `maxAgeMs`: an unknown id comes before an invalid `maxAgeMs` (spec/events.md#subscribing),
56
+ * so the session checks it after the id. A `maxAgeMs` that is not a number is `NaN`, which the session rejects.
54
57
  */
55
58
  export declare function subscriptionParams(params: JsonObject): {
56
59
  readonly error: JsonRpcError;
@@ -1,6 +1,6 @@
1
1
  import { idlePuller } from "./stream.js";
2
2
  import { CallFailureError, MCP_EVENTS_EXTENSION, MCP_EVENT_ERRORS, MCP_SUBSCRIPTION_ID, bundle, mcpEventEntry, mcpEventParams, mcpSubscribeError } from "@acid-cat/spec-ts";
3
- //#region src/mcp-events.ts
3
+ // src/mcp-events.ts
4
4
  /**
5
5
  * Events through MCP (spec/bindings/mcp.md#events): the MCP events extension. This module serves push delivery, and
6
6
  * declares the parts that poll and webhook delivery share. The HTTP and the stdio transports share these functions.
@@ -36,16 +36,18 @@ const invalidParams = (message) => ({ error: {
36
36
  /**
37
37
  * Read the subscription parameters (spec/bindings/mcp.md#events): the id, the input, the cursor and `maxAgeMs`. An
38
38
  * absent cursor and a `null` cursor are the same. The input is `{}` when `arguments` is absent.
39
+ *
40
+ * This function does not check `maxAgeMs`: an unknown id comes before an invalid `maxAgeMs` (spec/events.md#subscribing),
41
+ * so the session checks it after the id. A `maxAgeMs` that is not a number is `NaN`, which the session rejects.
39
42
  */
40
43
  function subscriptionParams(params) {
41
44
  const { name, arguments: input, cursor, maxAgeMs } = params;
42
45
  if (cursor !== void 0 && cursor !== null && typeof cursor !== "string") return invalidParams("cursor must be a string or null.");
43
- if (maxAgeMs !== void 0 && maxAgeMs !== null && (typeof maxAgeMs !== "number" || !Number.isFinite(maxAgeMs) || maxAgeMs < 0)) return invalidParams("maxAgeMs must be a number of milliseconds.");
44
46
  return {
45
47
  name: typeof name === "string" ? name : "",
46
48
  input: input ?? {},
47
49
  cursor: typeof cursor === "string" ? cursor : void 0,
48
- maxAgeMs: typeof maxAgeMs === "number" ? maxAgeMs : void 0
50
+ maxAgeMs: maxAgeMs === void 0 || maxAgeMs === null ? void 0 : typeof maxAgeMs === "number" ? maxAgeMs : NaN
49
51
  };
50
52
  }
51
53
  /** The message of the call failure that ends a subscription. A subscription ends only with `internal`. */
@@ -125,5 +127,4 @@ async function startEventStream(session, id, params, settings) {
125
127
  cancel: () => void subscribed.return()
126
128
  };
127
129
  }
128
- //#endregion
129
130
  export { endMessage, eventCapabilities, forbidden, invalidParams, listEvents, servesEvents, startEventStream, subscriptionParams, unsupportedWebhook };
package/dist/mcp-poll.js CHANGED
@@ -1,7 +1,8 @@
1
- import { endMessage, invalidParams, subscriptionParams } from "./mcp-events.js";
2
1
  import { systemClock } from "./clock.js";
2
+ import { endMessage, invalidParams, subscriptionParams } from "./mcp-events.js";
3
+ import { isMaxAgeMs } from "./session.js";
3
4
  import { MCP_EVENT_ERRORS, MCP_POLL_DEFAULTS, canonicalJson, decodePollCursor, encodePollCursor, mcpSubscribeError } from "@acid-cat/spec-ts";
4
- //#region src/mcp-poll.ts
5
+ // src/mcp-poll.ts
5
6
  /**
6
7
  * Poll delivery through MCP (spec/bindings/mcp.md#poll): `events/poll` reads the occurrences of a poll lease. A lease
7
8
  * holds one subscription, in a session of its own, and a buffer of its occurrences. The HTTP and the stdio transports
@@ -55,12 +56,14 @@ var PollLeases = class {
55
56
  const position = read.cursor === void 0 ? void 0 : decodePollCursor(read.cursor);
56
57
  if (lease === void 0) {
57
58
  const provider = read.cursor === void 0 ? void 0 : position?.provider;
58
- const started = this.#start(key, read.name, read.input, provider, provider === void 0 ? void 0 : read.maxAgeMs, open);
59
+ const maxAgeMs = provider === void 0 && isMaxAgeMs(read.maxAgeMs) ? void 0 : read.maxAgeMs;
60
+ const started = this.#start(key, read.name, read.input, provider, maxAgeMs, open);
59
61
  this.#starting.set(key, started);
60
62
  const answer = await started.finally(() => this.#starting.delete(key));
61
63
  if ("error" in answer) return answer;
62
64
  return this.#result([], this.#cursorOf(answer, 0), read.cursor !== void 0 && provider === void 0, false);
63
65
  }
66
+ if (!isMaxAgeMs(read.maxAgeMs)) return invalidParams("maxAgeMs must be an integer that is 0 or more.");
64
67
  this.#renew(lease);
65
68
  if (read.cursor === void 0) return this.#result([], this.#cursorOf(lease, lease.newest), false, false);
66
69
  if (position?.lease === lease.id) return this.#read(lease, position.position, maxEvents, read.cursor);
@@ -175,5 +178,4 @@ function withoutTruncated(occurrence) {
175
178
  const { truncated: _, ...rest } = occurrence;
176
179
  return { ...rest };
177
180
  }
178
- //#endregion
179
181
  export { PollLeases };
package/dist/mcp-stdio.js CHANGED
@@ -1,9 +1,9 @@
1
- import { createSession } from "./session.js";
1
+ import { hasObjectOutput, listTools } from "./mcp.js";
2
2
  import { eventCapabilities, listEvents, servesEvents, startEventStream, unsupportedWebhook } from "./mcp-events.js";
3
3
  import { PollLeases } from "./mcp-poll.js";
4
- import { hasObjectOutput, listTools } from "./mcp.js";
4
+ import { createSession } from "./session.js";
5
5
  import { KEEPALIVE_MS, MCP_PROTOCOL_VERSION, MCP_UNKNOWN_TOOL, isJsonObject, mcpToolResult } from "@acid-cat/spec-ts";
6
- //#region src/mcp-stdio.ts
6
+ // src/mcp-stdio.ts
7
7
  /**
8
8
  * The MCP binding through standard input and standard output (spec/bindings/mcp.md#stdio). The process is one session.
9
9
  * The server serves revision 2026-07-28, and the `initialize` handshake of revisions 2025-11-25 and 2025-06-18.
@@ -321,5 +321,4 @@ var StdioServer = class {
321
321
  this.#write(`${JSON.stringify(message)}\n`);
322
322
  }
323
323
  };
324
- //#endregion
325
324
  export { main, runMcpStdio };
package/dist/mcp.js CHANGED
@@ -1,10 +1,10 @@
1
- import { createSession } from "./session.js";
2
1
  import { openContext } from "./context.js";
3
- import { textStream } from "./stream.js";
4
2
  import { eventCapabilities, forbidden, listEvents, servesEvents, startEventStream, unsupportedWebhook } from "./mcp-events.js";
5
3
  import { PollLeases } from "./mcp-poll.js";
4
+ import { createSession } from "./session.js";
5
+ import { textStream } from "./stream.js";
6
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";
7
- //#region src/mcp.ts
7
+ // src/mcp.ts
8
8
  /**
9
9
  * The MCP binding (spec/bindings/mcp.md): an MCP server through HTTP, revision 2026-07-28. The server keeps no state
10
10
  * between requests, except poll leases and webhook subscriptions.
@@ -253,5 +253,4 @@ const messages = {
253
253
  "too-large": "The request body is too large.",
254
254
  "not-utf8": "The request body is not UTF-8 JSON."
255
255
  };
256
- //#endregion
257
256
  export { hasObjectOutput, listTools, mcpHandler };
package/dist/node.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { webhookDelivery } from "./webhook.js";
2
2
  import { createServer } from "node:http";
3
- //#region src/node.ts
3
+ // src/node.ts
4
4
  /**
5
5
  * The functions that need Node.js. The main entry point, `@acid-cat/core-ts`, works in all runtimes.
6
6
  *
@@ -172,5 +172,4 @@ function bodyOf(incoming) {
172
172
  }
173
173
  }, { highWaterMark: 0 });
174
174
  }
175
- //#endregion
176
175
  export { serve, webhookDelivery };
package/dist/session.d.ts CHANGED
@@ -123,5 +123,10 @@ export interface SessionOptions<Context> {
123
123
  */
124
124
  readonly bufferLimit?: number | undefined;
125
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;
126
131
  /** Open a session for a target. */
127
132
  export declare function createSession<Context>(app: App<Context>, options: SessionOptions<Context>): Session<Context>;
package/dist/session.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { catalogOf } from "./app.js";
2
2
  import { runSubscription } from "./subscription.js";
3
3
  import { AVAILABLE, CallFailureError, callFailure, isCallFailure, viewOf } from "@acid-cat/spec-ts";
4
- //#region src/session.ts
4
+ // src/session.ts
5
5
  /**
6
6
  * Sessions and handles (spec/invocation.md, spec/bindings/typescript.md).
7
7
  */
@@ -36,6 +36,11 @@ function outsideView(capability) {
36
36
  invokeExternal: reject
37
37
  };
38
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;
39
44
  const always = {
40
45
  getSnapshot: () => AVAILABLE,
41
46
  subscribe: () => ({ unsubscribe() {} })
@@ -65,7 +70,13 @@ function createSession(app, options) {
65
70
  if (lifetime.signal.aborted || signal?.aborted) return callFailure("cancelled", "The call was cancelled.");
66
71
  const availability = entry.availability.getSnapshot();
67
72
  if (!availability.available) return callFailure("unavailable", availability.reason);
68
- const prepared = await prepare();
73
+ let prepared;
74
+ try {
75
+ prepared = await prepare();
76
+ } catch (cause) {
77
+ onDefect(id, cause);
78
+ return callFailure("internal", "The capability failed.");
79
+ }
69
80
  if ("issues" in prepared) return callFailure("invalid-input", issueMessage(prepared.issues));
70
81
  const controller = new AbortController();
71
82
  const abort = () => controller.abort();
@@ -172,6 +183,7 @@ function createSession(app, options) {
172
183
  const { signal } = subscribeOptions;
173
184
  const cancelled = () => lifetime.signal.aborted || signal?.aborted === true;
174
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.");
175
187
  let prepared;
176
188
  try {
177
189
  prepared = await prepare();
@@ -379,5 +391,4 @@ async function checkFailure(failure, tag, schemas) {
379
391
  ...checked.value
380
392
  };
381
393
  }
382
- //#endregion
383
- export { createSession };
394
+ export { createSession, isMaxAgeMs };
package/dist/stream.js CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/stream.ts
1
+ // src/stream.ts
2
2
  /**
3
3
  * A function that pulls the next step of a subscription. It gives `idle` when no step comes within `idleMs`
4
4
  * milliseconds. The `next()` that waits is kept for the next pull, so no occurrence is lost.
@@ -59,5 +59,4 @@ function textStream(produce, cancel, release) {
59
59
  }
60
60
  }, { highWaterMark: 0 });
61
61
  }
62
- //#endregion
63
62
  export { idlePuller, textStream };
@@ -1,5 +1,5 @@
1
1
  import { CallFailureError, callFailure, occurrenceOf } from "@acid-cat/spec-ts";
2
- //#region src/subscription.ts
2
+ // src/subscription.ts
3
3
  /**
4
4
  * One subscription (spec/events.md): the pump that reads the occurrences of the provider, the bounded buffer for a slow
5
5
  * client, and the iterator that the client reads.
@@ -141,5 +141,4 @@ function checkEmitted(value) {
141
141
  if (truncated !== void 0 && truncated !== true) throw new TypeError("The truncated member of an occurrence is not true.");
142
142
  return value;
143
143
  }
144
- //#endregion
145
144
  export { DEFAULT_BUFFER_LIMIT, runSubscription };
package/dist/webhook.js CHANGED
@@ -1,12 +1,13 @@
1
- import { endMessage, invalidParams, subscriptionParams } from "./mcp-events.js";
2
1
  import { sleep, systemClock } from "./clock.js";
2
+ import { endMessage, invalidParams, subscriptionParams } from "./mcp-events.js";
3
+ import { isMaxAgeMs } from "./session.js";
3
4
  import { MCP_EVENT_ERRORS, MCP_WEBHOOK_DEFAULTS, canonicalJson, isGloballyRoutable, isJsonObject, mcpSubscribeError, webhookGrantMs, webhookSecretBytes, webhookSignature, webhookSubscriptionId } from "@acid-cat/spec-ts";
4
5
  import { randomBytes, timingSafeEqual } from "node:crypto";
5
6
  import { lookup } from "node:dns";
6
7
  import { request } from "node:https";
7
8
  import { isIP } from "node:net";
8
9
  import { rootCertificates } from "node:tls";
9
- //#region src/webhook.ts
10
+ // src/webhook.ts
10
11
  /**
11
12
  * Webhook delivery through MCP (spec/bindings/mcp.md#webhook), on Node.js. The server sends the occurrences of each
12
13
  * webhook subscription in HTTP `POST` requests to a URL of the client, signed as Standard Webhooks specifies.
@@ -139,6 +140,7 @@ function webhookDelivery(options = {}) {
139
140
  secrets: [secret]
140
141
  }, webhookIdOf("verification"), body), body, network);
141
142
  if ("failure" in posted) return posted.failure;
143
+ if (posted.status >= 300 && posted.status < 400) return "challenge_failed";
142
144
  if (posted.status < 200 || posted.status >= 300) return statusError(posted.status);
143
145
  if (!sameText(challengeOf(posted.text), challenge)) return "challenge_failed";
144
146
  verified.add(pair);
@@ -164,7 +166,7 @@ function webhookDelivery(options = {}) {
164
166
  max: settings.maxSubscriptions
165
167
  }
166
168
  } };
167
- const id = await webhookSubscriptionId(principal, url.href, request.name, request.input);
169
+ const id = await webhookSubscriptionId(principal, request.rawUrl, request.name, request.input);
168
170
  const own = await open();
169
171
  const subscribed = await own.session.subscribe(request.name, request.input, {
170
172
  ...request.cursor === void 0 ? {} : { cursor: request.cursor },
@@ -207,15 +209,11 @@ function webhookDelivery(options = {}) {
207
209
  const read = readSubscribe(params);
208
210
  if ("error" in read) return read;
209
211
  const { url, secret } = read;
210
- const key = canonicalJson([
211
- principal,
212
- url.href,
213
- read.name,
214
- read.input
215
- ]);
212
+ const key = subscriptionKey(principal, read.rawUrl, read.name, read.input);
216
213
  await starting.get(key);
217
214
  const existing = subscriptions.get(key);
218
215
  if (existing !== void 0) {
216
+ if (!isMaxAgeMs(read.maxAgeMs)) return invalidParams("maxAgeMs must be an integer that is 0 or more.");
219
217
  if (existing.secret !== secret) {
220
218
  existing.previous = {
221
219
  secret: existing.secret,
@@ -234,13 +232,7 @@ function webhookDelivery(options = {}) {
234
232
  const { name, arguments: input = {}, delivery } = params;
235
233
  const url = isJsonObject(delivery) ? delivery["url"] : void 0;
236
234
  if (typeof name !== "string" || typeof url !== "string") return Promise.resolve(invalidParams("events/unsubscribe needs name and delivery.url."));
237
- const parsed = URL.canParse(url) ? new URL(url).href : url;
238
- const subscription = subscriptions.get(canonicalJson([
239
- principal,
240
- parsed,
241
- name,
242
- input
243
- ]));
235
+ const subscription = subscriptions.get(subscriptionKey(principal, url, name, input));
244
236
  if (subscription === void 0) return Promise.resolve({ error: {
245
237
  code: MCP_EVENT_ERRORS.notFound,
246
238
  message: "There is no such subscription.",
@@ -256,6 +248,13 @@ function webhookDelivery(options = {}) {
256
248
  }
257
249
  };
258
250
  }
251
+ /** The key of a webhook subscription: the principal, `delivery.url` as the client sent it, the id, and the input. */
252
+ const subscriptionKey = (principal, rawUrl, name, input) => canonicalJson([
253
+ principal,
254
+ rawUrl,
255
+ name,
256
+ input
257
+ ]);
259
258
  /** Read and check the parameters of `events/subscribe`. */
260
259
  function readSubscribe(params) {
261
260
  const read = subscriptionParams(params);
@@ -270,6 +269,7 @@ function readSubscribe(params) {
270
269
  ...read,
271
270
  ttlMs,
272
271
  url: new URL(url),
272
+ rawUrl: url,
273
273
  secret
274
274
  };
275
275
  }
@@ -381,5 +381,4 @@ function failureOf(error) {
381
381
  if (/^(ERR_TLS|ERR_SSL|CERT_|UNABLE_TO_|DEPTH_ZERO|SELF_SIGNED|HOSTNAME_MISMATCH)/.test(code) || /certificate|SSL|TLS/i.test(error.message)) return "tls_error";
382
382
  return "connection_refused";
383
383
  }
384
- //#endregion
385
384
  export { webhookDelivery };
package/dist/webmcp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CallFailureError, bundle, isCallFailure } from "@acid-cat/spec-ts";
2
- //#region src/webmcp.ts
2
+ // src/webmcp.ts
3
3
  /**
4
4
  * The WebMCP binding (spec/bindings/webmcp.md): register capabilities as WebMCP tools in a web page.
5
5
  */
@@ -136,5 +136,4 @@ function installWebMcp(session, options = {}) {
136
136
  }
137
137
  };
138
138
  }
139
- //#endregion
140
139
  export { findHost, installWebMcp };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acid-cat/core-ts",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Serve AcidCat capabilities and events in plain TypeScript, with no Effect dependency.",
5
5
  "keywords": [
6
6
  "acidcat",
@@ -36,16 +36,16 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@acid-cat/catalog": "0.10.0",
40
- "@acid-cat/mcp-handshake": "0.10.0",
41
- "@acid-cat/spec-ts": "0.10.0"
39
+ "@acid-cat/catalog": "0.10.2",
40
+ "@acid-cat/mcp-handshake": "0.10.2",
41
+ "@acid-cat/spec-ts": "0.10.2"
42
42
  },
43
43
  "devDependencies": {
44
- "@acid-cat/cli": "0.10.0",
45
- "@types/node": "26.6.5",
44
+ "@acid-cat/cli": "0.10.2",
45
+ "@types/node": "26.6.6",
46
46
  "esbuild": "0.28.2",
47
- "graphql": "17.0.2",
48
- "playwright": "1.64.0",
47
+ "graphql": "17.1.0",
48
+ "playwright": "1.65.0",
49
49
  "typescript": "7.0.2",
50
50
  "zod": "4.6.7"
51
51
  }