@acid-cat/core-ts 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,7 @@ export const aircon = defineApp<{ unit: Unit }>({
33
33
  });
34
34
  ```
35
35
 
36
- - `run` gets the input, an `AbortSignal` and the context. It returns `ok(value)` or `fail(failure)`, for example `fail({ _tag: "Offline", since })`. The types check that the value agrees with the output schema, that the failure has a tag of `failures`, and that it has the members of the schema of that tag. If `run` throws, the call fails as `internal`.
36
+ - `run` gets the input, and `{ signal, context, target }`: an `AbortSignal`, the context, and the target of the session, for example `"mcp"` or `"cli"`. It returns `ok(value)` or `fail(failure)`, for example `fail({ _tag: "Offline", since })`. The types check that the value agrees with the output schema, that the failure has a tag of `failures`, and that it has the members of the schema of that tag. If `run` throws, the call fails as `internal`.
37
37
  - With an input schema, `run` gets the output of the schema. With none, it gets an empty object.
38
38
  - A failure schema describes the whole failure object, with its `_tag`.
39
39
  - `availability` is an optional function that returns an observable availability. A session reads it with `getSnapshot` when a call comes in. It subscribes to it only while `onChange` or the handle has listeners.
@@ -66,7 +66,7 @@ export const statusChanged = event({
66
66
  export const aircon = defineApp<{ unit: Unit }>({ targets, capabilities: [powerSet], events: [statusChanged] });
67
67
  ```
68
68
 
69
- - `subscribe` gets the input, and `{ signal, context, cursor, maxAgeMs }`. The signal aborts when the subscription ends, in all ways. Stop the work and release the resources then.
69
+ - `subscribe` gets the input, and `{ signal, context, target, cursor, maxAgeMs }`. `target` is the target of the session. The signal aborts when the subscription ends, in all ways. Stop the work and release the resources then.
70
70
  - Each value is `{ data }`, and optionally `eventId`, `timestamp`, `cursor` and `truncated`. Give the `eventId` of the source if it has one. With none, the session makes a unique id. With no `timestamp`, the session uses the time at which it gets the value.
71
71
  - `data` must agree with the payload schema. The session gives the client the output of the schema, as a copy. With no payload schema, `data` must be an empty object.
72
72
  - If `subscribe` throws before it gives an iterable, the subscription fails as `internal` before it starts. If the iterable throws, or gives data that does not agree with the payload schema, the subscription ends with `internal`. The details go to `onDefect`, not to the client.
@@ -97,6 +97,7 @@ Standard Schema does not convert to JSON Schema. Give the converter of your sche
97
97
  | `mcpStdioMain(app, options)`, `runMcpStdio(…)` | [MCP](../../spec/bindings/mcp.md#stdio) through stdio | The MCP server of the process, or a promise that resolves when the input ends |
98
98
  | `httpHandler(app, options)` | [HTTP](../../spec/bindings/http.md) | `(request: Request) => Promise<Response>` |
99
99
  | `cliMain(app, options)`, `runCli(…)` | [CLI](../../spec/bindings/cli.md) | The command line of the process, or an exit code |
100
+ | `httpClient(url, options)` | [CLI](../../spec/bindings/cli.md#server), [HTTP](../../spec/bindings/http.md) | A client of the HTTP binding of a server, for the option `remote` of the command line |
100
101
  | `installWebMcp(session)` | [WebMCP](../../spec/bindings/webmcp.md) | Tools in the page, with a status. WebMCP serves no events |
101
102
  | `serve(handler, options)`, from `@acid-cat/core-ts/node` | MCP and HTTP | A Node.js server for the web handlers. See [Serve on Node.js](#serve-on-nodejs) |
102
103
 
@@ -115,7 +116,7 @@ The options `bufferLimit` of `createSession`, `httpHandler`, `mcpHandler` and `r
115
116
 
116
117
  ### MCP poll and webhook delivery
117
118
 
118
- A poll lease and a webhook subscription continue after the request that started them. Each one holds its subscription in a session of its own. Through HTTP, that session has the context of the request that started it: the shared `context`, or a new one from `createContext`. Through stdio, it has the `context` of the process. The session closes when the lease or the webhook subscription ends. This state is in memory: when the process stops, it is lost, and clients start again with their cursors.
119
+ A poll lease and a webhook subscription continue after the request that started them. Each one holds its subscription in a session of its own. Through HTTP, that session has the context of the request that started it: the shared `context`, or the context that `createContext` made for that request. Through stdio, it has the `context` of the process. The session closes when the lease or the webhook subscription ends. This state is in memory: when the process stops, it is lost, and clients start again with their cursors.
119
120
 
120
121
  `handler.close()` of `mcpHandler` ends all leases and webhook subscriptions. `await using handler = mcpHandler(…)` calls it at the end of the block. Through stdio, the end of standard input ends them.
121
122
 
@@ -273,6 +274,30 @@ const handler = httpHandler(aircon, {
273
274
  });
274
275
  ```
275
276
 
277
+ `createContext` can refuse a request: it gives `refuse(status, message, headers?)` in place of a context. Use it to authenticate the caller, and give the caller to the capabilities in the context:
278
+
279
+ ```ts
280
+ import { httpHandler, refuse } from "@acid-cat/core-ts";
281
+
282
+ const handler = httpHandler(aircon, {
283
+ allowedOrigins: [],
284
+ toJsonSchema,
285
+ createContext: async (request) => {
286
+ const caller = await accounts.caller(request.headers.get("Authorization"));
287
+ if (caller === undefined) return refuse(401, "Give a valid token.", { "WWW-Authenticate": "Bearer" });
288
+ return { unit, caller };
289
+ },
290
+ });
291
+ ```
292
+
293
+ - A refusal answers the request with its status, its headers, the media type `application/json`, and the body `{ "message" }`. The handler opens no session. No capability runs, and no subscription starts. The message shows no internal data.
294
+ - The status of a refusal is an integer from 400 to 599. `refuse` throws a `RangeError` for another status.
295
+ - `isRefusal(value)` tells if a value is a refusal that `refuse` made.
296
+ - The handler releases the context after the answer, or when the stream of a subscription ends.
297
+ - Through MCP, a poll lease or a webhook subscription uses the context of the request that started it. The handler calls `createContext` once for each request. It releases the context when the answer is done and each lease and webhook subscription of the request has ended.
298
+ - Set the option `principal` of `mcpHandler` too, from the same caller. A poll lease is keyed by the principal, the event and the input: with no principal, two callers that poll the same event with the same input share one lease, and the second caller reads the occurrences of the session of the first caller.
299
+ - The command line and MCP through stdio have one context for the program.
300
+
276
301
  ### Command line
277
302
 
278
303
  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):
@@ -316,6 +341,27 @@ const report: CliCommand<{ unit: Unit }> = {
316
341
  cliMain(aircon, { name: "aircon", version: "0.1.0", context: { unit }, toJsonSchema, description: "Control the air conditioner.", commands: [report] });
317
342
  ```
318
343
 
344
+ ### Command line of a server
345
+
346
+ The command line can serve a view of an application that runs on a server ([Server](../../spec/bindings/cli.md#server)). Give the option `remote` of `cliMain` and `runCli` a client of the HTTP binding of the server: `httpClient(url, { headers?, fetch? })`. The commands, the flags and the help still come from the view in the program. Each call is `POST {url}/capabilities/{id}`, `list` gets the availability from `GET {url}/capabilities`, and `watch` is `POST {url}/events/{id}`, with the cursor in `Last-Event-ID` and `maxAgeMs` in the query.
347
+
348
+ - A capability that the server does not list is unavailable in `list`.
349
+ - A server that does not answer is the call failure `unavailable`, with exit code 4. An answer that the HTTP binding does not give is `internal`, with exit code 5.
350
+ - An interrupt (`SIGINT`) closes the request, so the server cancels the call or the subscription. The exit code is 130.
351
+ - The client sends `headers` with each request, for example a token in `Authorization`. The command line never writes them. `httpClient` throws when the URL is not an `http` or `https` URL, when it has a user name, a password, a query or a fragment, and when a header is not valid. The error does not contain the value of a header.
352
+
353
+ Serve the HTTP binding with the target of the command line, so that both have the same view. [`examples/aircon/core-ts/serve.ts`](../../examples/aircon/core-ts/serve.ts) serves it, and [`examples/aircon/core-ts/cli.ts`](../../examples/aircon/core-ts/cli.ts) sends to it when `AIRCON_URL` is set:
354
+
355
+ ```ts
356
+ // On the server.
357
+ serve({ "/cli": httpHandler(aircon, { allowedOrigins: [], target: "cli", base: "/cli", context: { unit }, toJsonSchema }) }, { hostname: "0.0.0.0", port: 8080 });
358
+
359
+ // In the command line.
360
+ cliMain(aircon, { name: "aircon", version: "0.1.0", context: { unit: new Unit() }, toJsonSchema, remote: httpClient("http://pi.local:8080/cli", { headers: { Authorization: `Bearer ${token}` } }) });
361
+ ```
362
+
363
+ The client works alone too: `capabilities()`, `invoke(id, input, { signal })` and `subscribe(id, input, { signal, cursor, maxAgeMs })`. No method rejects: each one gives the call failure.
364
+
319
365
  ## Examples and tests
320
366
 
321
367
  - [`examples/aircon/core-ts`](../../examples/aircon/README.md) is an air conditioner written with this package, with its command line. [`examples/aircon/apis`](../../examples/aircon/README.md#apis) builds GraphQL, Cap'n Web and Elysia APIs on it, each beside the HTTP binding.
package/dist/app.d.ts CHANGED
@@ -23,10 +23,12 @@ export type FailureOf<Failures> = {
23
23
  export type FailureSchemas<Failures> = {
24
24
  readonly [Tag in keyof Failures]: StandardSchemaV1<unknown, Failures[Tag]>;
25
25
  };
26
- /** What `run` receives beside the input: the signal of the call, and the context. */
26
+ /** What `run` receives beside the input: the signal of the call, the context, and the target of the session. */
27
27
  export interface Call<Context> {
28
28
  readonly signal: AbortSignal;
29
29
  readonly context: Context;
30
+ /** The target of the session, for example `"mcp"` or `"cli"`. */
31
+ readonly target: string;
30
32
  }
31
33
  /** The function of a capability. Its result has the types of the output schema and of the failure schemas. */
32
34
  export type Run<Context, Input, Value, Failures> = (input: Input, call: Call<Context>) => Promise<Result<NoInfer<Value>, NoInfer<FailureOf<Failures>>>> | Result<NoInfer<Value>, NoInfer<FailureOf<Failures>>>;
@@ -122,12 +124,15 @@ export interface CapabilityFactory<Context> {
122
124
  */
123
125
  export declare const capabilitiesFor: <Context>() => CapabilityFactory<Context>;
124
126
  /**
125
- * What `subscribe` receives beside the input: the signal of the subscription, the context, and where the subscription
126
- * starts (spec/events.md#resuming). The signal aborts when the subscription ends, in all ways.
127
+ * What `subscribe` receives beside the input: the signal of the subscription, the context, the target of the session,
128
+ * and where the subscription starts (spec/events.md#resuming). The signal aborts when the subscription ends, in all
129
+ * ways.
127
130
  */
128
131
  export interface Subscription<Context> {
129
132
  readonly signal: AbortSignal;
130
133
  readonly context: Context;
134
+ /** The target of the session, for example `"mcp"` or `"cli"`. */
135
+ readonly target: string;
131
136
  /** The cursor of the client. With none, the subscription starts now. */
132
137
  readonly cursor?: string | undefined;
133
138
  /** The client wants no occurrence that is older than this number of milliseconds. */
package/dist/cli.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { App, ToJsonSchema } from "./app.js";
2
+ import type { HttpClient } from "./http-client.js";
2
3
  export interface CliOptions<Context> {
3
4
  readonly name: string;
4
5
  readonly version: string;
@@ -16,6 +17,13 @@ export interface CliOptions<Context> {
16
17
  * the command of a capability (spec/bindings/cli.md#commands).
17
18
  */
18
19
  readonly commands?: readonly CliCommand<Context>[] | undefined;
20
+ /**
21
+ * The HTTP binding of the application in another process or on another machine (spec/bindings/cli.md#server). With
22
+ * it, the command line sends each call, the availability of `list` and each subscription of `watch` to that server.
23
+ * The commands still come from the view of `target` in this program. Serve the HTTP binding with the same target, for
24
+ * example `httpHandler(app, { target: "cli", … })`. The commands of the application still get `context`.
25
+ */
26
+ readonly remote?: HttpClient | undefined;
19
27
  }
20
28
  export interface CliIo {
21
29
  readonly stdout: (text: string) => void;
@@ -32,7 +40,10 @@ export interface CliCommand<Context> {
32
40
  readonly context: Context;
33
41
  }): number | Promise<number>;
34
42
  }
35
- /** Run the command line as the main program of the process. An interrupt (`SIGINT`) cancels the call or the subscription. */
36
- export declare function main<Context>(app: App<Context>, options: CliOptions<Context>): void;
43
+ /**
44
+ * Run the command line as the main program of the process. An interrupt (`SIGINT`) cancels the call or the subscription.
45
+ * The default arguments are the arguments of the process.
46
+ */
47
+ export declare function main<Context>(app: App<Context>, options: CliOptions<Context>, args?: readonly string[]): void;
37
48
  /** Run the command line with arguments. The result is the exit code (spec/bindings/cli.md#outcomes). */
38
49
  export declare function runCli<Context>(app: App<Context>, options: CliOptions<Context>, args: readonly string[], io: CliIo): Promise<number>;
package/dist/cli.js CHANGED
@@ -1,9 +1,40 @@
1
+ import { catalogOf } from "./app.js";
1
2
  import { createSession } from "./session.js";
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
+ import { CLI_EXIT_CODES, CLI_RESUME_FLAGS, CLI_WATCH_EXIT_CODES, CallFailureError, bundle, cliEventUsage, cliExitCode, cliFlags, cliJsonArray, cliRepeatedItems, cliResolve, cliUsage, commandPath, isCallFailure, isJsonObject, unavailable, viewOf } from "@acid-cat/spec-ts";
3
4
  // src/cli.ts
4
5
  /**
5
6
  * The CLI binding (spec/bindings/cli.md): a command line made from the catalog.
6
7
  */
8
+ function localProvider(app, options) {
9
+ const session = createSession(app, {
10
+ target: options.target ?? "cli",
11
+ context: options.context,
12
+ toJsonSchema: options.toJsonSchema
13
+ });
14
+ return {
15
+ get view() {
16
+ return session.view;
17
+ },
18
+ availability: async () => new Map([...session.handles.values()].map((handle) => [handle.id, handle.availability.getSnapshot()])),
19
+ invoke: (id, input, signal) => session.invoke(id, input, { signal }),
20
+ subscribe: (id, input, subscribeOptions) => session.subscribe(id, input, subscribeOptions),
21
+ dispose: () => session.dispose()
22
+ };
23
+ }
24
+ function remoteProvider(app, options, client) {
25
+ const view = viewOf(catalogOf(app, { toJsonSchema: options.toJsonSchema }), options.target ?? "cli");
26
+ return {
27
+ view,
28
+ availability: async (signal) => {
29
+ const served = await client.capabilities({ signal });
30
+ if ("kind" in served) return served;
31
+ return new Map(Object.keys(view.capabilities).map((id) => [id, served.get(id) ?? unavailable("The server does not serve this capability.")]));
32
+ },
33
+ invoke: (id, input, signal) => client.invoke(id, input, { signal }),
34
+ subscribe: (id, input, subscribeOptions) => client.subscribe(id, input, subscribeOptions),
35
+ dispose: async () => void 0
36
+ };
37
+ }
7
38
  /** The commands of the command line itself. No other command can have their names. */
8
39
  const GENERIC_COMMANDS = [
9
40
  "call",
@@ -17,11 +48,14 @@ var UsageError = class extends Error {
17
48
  this.exitCode = exitCode;
18
49
  }
19
50
  };
20
- /** Run the command line as the main program of the process. An interrupt (`SIGINT`) cancels the call or the subscription. */
21
- function main(app, options) {
51
+ /**
52
+ * Run the command line as the main program of the process. An interrupt (`SIGINT`) cancels the call or the subscription.
53
+ * The default arguments are the arguments of the process.
54
+ */
55
+ function main(app, options, args = process.argv.slice(2)) {
22
56
  const interrupt = new AbortController();
23
57
  process.once("SIGINT", () => interrupt.abort());
24
- runCli(app, options, process.argv.slice(2), {
58
+ runCli(app, options, args, {
25
59
  stdout: (text) => process.stdout.write(`${text}\n`),
26
60
  stderr: (text) => process.stderr.write(`${text}\n`),
27
61
  signal: interrupt.signal
@@ -31,13 +65,10 @@ function main(app, options) {
31
65
  }
32
66
  /** Run the command line with arguments. The result is the exit code (spec/bindings/cli.md#outcomes). */
33
67
  async function runCli(app, options, args, io) {
34
- const session = createSession(app, {
35
- target: options.target ?? "cli",
36
- context: options.context,
37
- toJsonSchema: options.toJsonSchema
38
- });
39
- const types = session.view.$defs ?? {};
40
- const commands = Object.keys(session.view.capabilities).map((id) => ({
68
+ const provider = options.remote === void 0 ? localProvider(app, options) : remoteProvider(app, options, options.remote);
69
+ const view = provider.view;
70
+ const types = view.$defs ?? {};
71
+ const commands = Object.keys(view.capabilities).map((id) => ({
41
72
  id,
42
73
  path: commandPath(id)
43
74
  }));
@@ -46,7 +77,7 @@ async function runCli(app, options, args, io) {
46
77
  const longer = commands.find((other) => other.path.length > path.length && path.every((word, index) => other.path[index] === word));
47
78
  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
79
  }
49
- for (const [id, event] of Object.entries(session.view.events ?? {})) {
80
+ for (const [id, event] of Object.entries(view.events ?? {})) {
50
81
  const properties = event.input === void 0 ? void 0 : bundle(event.input, types).properties;
51
82
  const taken = CLI_RESUME_FLAGS.find((flag) => isJsonObject(properties) && flag in properties);
52
83
  if (taken !== void 0) throw new Error(`The input of the event ${id} has a property ${taken}, which is a flag of watch.`);
@@ -71,7 +102,7 @@ async function runCli(app, options, args, io) {
71
102
  const [id, ...args] = rest;
72
103
  const { flagArgs, resume } = resumeFlags(args);
73
104
  if (id === void 0) throw new UsageError("Give the id of an event.", CLI_EXIT_CODES["invalid-input"]);
74
- const event = session.view.events?.[id];
105
+ const event = view.events?.[id];
75
106
  let input = {};
76
107
  if (event !== void 0) {
77
108
  const schema = bundle(event.input ?? { type: "object" }, types);
@@ -97,7 +128,7 @@ async function runCli(app, options, args, io) {
97
128
  }
98
129
  }
99
130
  }
100
- const subscribed = await session.subscribe(id, input, {
131
+ const subscribed = await provider.subscribe(id, input, {
101
132
  signal: io.signal,
102
133
  ...resume
103
134
  });
@@ -120,7 +151,7 @@ async function runCli(app, options, args, io) {
120
151
  const [first, ...rest] = args;
121
152
  if (first === void 0 || first === "--help" || first === "-h") {
122
153
  const description = options.description ?? app.description ?? app.title ?? options.name;
123
- io.stdout(help(options.name, description, commands.map(({ path }) => path.join(" ")), Object.keys(session.view.events ?? {}).length > 0, added));
154
+ io.stdout(help(options.name, description, commands.map(({ path }) => path.join(" ")), Object.keys(view.events ?? {}).length > 0, added));
124
155
  return CLI_EXIT_CODES.value;
125
156
  }
126
157
  if (first === "--version" || first === "-v") {
@@ -128,16 +159,15 @@ async function runCli(app, options, args, io) {
128
159
  return CLI_EXIT_CODES.value;
129
160
  }
130
161
  if (first === "list") {
131
- io.stdout(JSON.stringify([...[...session.handles.values()].map((handle) => {
132
- const input = viewCapability(session.view, handle.id).input;
133
- return {
134
- kind: "capability",
135
- id: handle.id,
136
- description: handle.description,
137
- ...handle.availability.getSnapshot(),
138
- usage: cliUsage(options.name, handle.id, input === void 0 ? void 0 : bundle(input, types))
139
- };
140
- }), ...Object.entries(session.view.events ?? {}).map(([id, event]) => ({
162
+ const availability = await provider.availability(io.signal);
163
+ if ("kind" in availability) return print(availability);
164
+ io.stdout(JSON.stringify([...Object.entries(view.capabilities).map(([id, capability]) => ({
165
+ kind: "capability",
166
+ id,
167
+ description: capability.description,
168
+ ...availability.get(id) ?? unavailable("There is no such capability."),
169
+ usage: cliUsage(options.name, id, capability.input === void 0 ? void 0 : bundle(capability.input, types))
170
+ })), ...Object.entries(view.events ?? {}).map(([id, event]) => ({
141
171
  kind: "event",
142
172
  id,
143
173
  description: event.description,
@@ -152,12 +182,23 @@ async function runCli(app, options, args, io) {
152
182
  try {
153
183
  input = JSON.parse(text);
154
184
  } catch {
185
+ if (provider.view.capabilities[id] === void 0) return print({
186
+ kind: "unknown-capability",
187
+ message: `There is no capability ${id}.`
188
+ });
189
+ const availability = await provider.availability(io.signal);
190
+ if ("kind" in availability) return print(availability);
191
+ const state = availability.get(id);
192
+ if (state !== void 0 && !state.available) return print({
193
+ kind: "unavailable",
194
+ message: state.reason
195
+ });
155
196
  return print({
156
197
  kind: "invalid-input",
157
198
  message: "The input is not JSON."
158
199
  });
159
200
  }
160
- return print(await session.invoke(id, input, { signal: io.signal }));
201
+ return print(await provider.invoke(id, input, io.signal));
161
202
  }
162
203
  if (first === "watch") return await watch(rest);
163
204
  const command = added.find(({ name }) => name === first);
@@ -167,20 +208,20 @@ async function runCli(app, options, args, io) {
167
208
  });
168
209
  const match = commands.filter(({ path }) => path.every((word, index) => args[index] === word)).sort((a, b) => b.path.length - a.path.length)[0];
169
210
  if (match === void 0) throw new UsageError(`There is no command ${args.filter((arg) => !arg.startsWith("-")).join(" ")}.`, CLI_EXIT_CODES["unknown-capability"]);
170
- const capability = viewCapability(session.view, match.id);
211
+ const capability = viewCapability(view, match.id);
171
212
  const schema = bundle(capability.input ?? { type: "object" }, types);
172
213
  const flagArgs = args.slice(match.path.length);
173
214
  if (flagArgs.includes("--help") || flagArgs.includes("-h")) {
174
215
  io.stdout(usage(options.name, match.path, capability.description, schema));
175
216
  return CLI_EXIT_CODES.value;
176
217
  }
177
- return print(await session.invoke(match.id, parseFlags(flagArgs, schema), { signal: io.signal }));
218
+ return print(await provider.invoke(match.id, parseFlags(flagArgs, schema), io.signal));
178
219
  } catch (error) {
179
220
  if (!(error instanceof UsageError)) throw error;
180
221
  io.stderr(error.message);
181
222
  return error.exitCode;
182
223
  } finally {
183
- await session.dispose();
224
+ await provider.dispose();
184
225
  }
185
226
  }
186
227
  /**
package/dist/context.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The context of the web handlers: one context that all requests share, or a new context for each request.
2
+ * The context of the web handlers: one context that all requests share, or a new context for each request. The
3
+ * function that makes the context of a request can refuse the request (spec/bindings/README.md#remote-bindings).
3
4
  */
4
5
  export type ContextOption<Context> = {
5
6
  /** The context of all requests. */
@@ -9,18 +10,45 @@ export type ContextOption<Context> = {
9
10
  /**
10
11
  * Make the context of one request. The handler calls it for each request that passes the screening. When the
11
12
  * context has `Symbol.asyncDispose` or `Symbol.dispose`, the handler calls it after the response, as a `using`
12
- * declaration does.
13
+ * declaration does. Through MCP, a poll lease or a webhook subscription that the request starts keeps the context:
14
+ * the handler calls it when the last of them ends. Give a `Refusal` from `refuse` to answer the request with no session: for example `401` when
15
+ * the request has no valid token.
13
16
  */
14
- readonly createContext: (request: Request) => Context | Promise<Context>;
17
+ readonly createContext: (request: Request) => Context | Refusal | Promise<Context | Refusal>;
15
18
  readonly context?: undefined;
16
19
  };
17
- /** Give the context of one request to `use`, and release it afterwards if the handler made it. */
18
- export declare function withContext<Context, A>(option: ContextOption<Context>, request: Request, use: (context: Context) => Promise<A>): Promise<A>;
20
+ /** The key that marks a refusal. A context cannot have it by accident. */
21
+ declare const refusalKey: unique symbol;
22
+ /** The answer that refuses a request before the handler opens a session. `refuse` makes it. */
23
+ export interface Refusal {
24
+ readonly [refusalKey]: true;
25
+ /** The status of the answer, for example `401` or `403`. */
26
+ readonly status: number;
27
+ /** The text of the answer, in its JSON body `{ "message" }`. Show no internal data. */
28
+ readonly message: string;
29
+ /** The other headers of the answer, for example `WWW-Authenticate`. */
30
+ readonly headers: Headers;
31
+ }
19
32
  /**
20
- * The context of one request, and the function that releases it if the handler made it. A response with a stream, such
21
- * as the stream of a subscription, releases the context when the stream ends.
33
+ * Refuse a request: `createContext` gives the result instead of a context. The handler answers with `status`, the media
34
+ * type `application/json`, the body `{ "message": message }`, and `headers`. No capability runs, and no subscription
35
+ * starts. `status` must be an integer from 400 to 599: an other value throws a `RangeError`.
22
36
  */
23
- export declare function openContext<Context>(option: ContextOption<Context>, request: Request): Promise<{
37
+ export declare function refuse(status: number, message: string, headers?: HeadersInit): Refusal;
38
+ /** Tell if a value is a refusal that `refuse` made. */
39
+ export declare function isRefusal(value: unknown): value is Refusal;
40
+ /** The answer of a refused request. */
41
+ export declare function refusalResponse(refusal: Refusal): Response;
42
+ /** The context of one request, and the function that releases it if the handler made it. */
43
+ export interface OpenedContext<Context> {
24
44
  readonly context: Context;
25
45
  readonly release: () => Promise<void>;
46
+ }
47
+ /**
48
+ * The context of one request, or the refusal of the request. A response with a stream, such as the stream of a
49
+ * subscription, releases the context when the stream ends.
50
+ */
51
+ export declare function openContext<Context>(option: ContextOption<Context>, request: Request): Promise<OpenedContext<Context> | {
52
+ readonly refusal: Refusal;
26
53
  }>;
54
+ export {};
package/dist/context.js CHANGED
@@ -1,17 +1,47 @@
1
1
  // src/context.ts
2
+ /** The key that marks a refusal. A context cannot have it by accident. */
3
+ const refusalKey = Symbol("acid-cat.refusal");
2
4
  /**
3
- * The context of one request, and the function that releases it if the handler made it. A response with a stream, such
4
- * as the stream of a subscription, releases the context when the stream ends.
5
+ * Refuse a request: `createContext` gives the result instead of a context. The handler answers with `status`, the media
6
+ * type `application/json`, the body `{ "message": message }`, and `headers`. No capability runs, and no subscription
7
+ * starts. `status` must be an integer from 400 to 599: an other value throws a `RangeError`.
8
+ */
9
+ function refuse(status, message, headers) {
10
+ if (!Number.isInteger(status) || status < 400 || status > 599) throw new RangeError(`The status of a refusal must be an integer from 400 to 599, not ${status}.`);
11
+ return {
12
+ [refusalKey]: true,
13
+ status,
14
+ message,
15
+ headers: new Headers(headers)
16
+ };
17
+ }
18
+ /** Tell if a value is a refusal that `refuse` made. */
19
+ function isRefusal(value) {
20
+ return typeof value === "object" && value !== null && refusalKey in value && value[refusalKey] === true && "status" in value && typeof value.status === "number" && "message" in value && typeof value.message === "string" && "headers" in value && value.headers instanceof Headers;
21
+ }
22
+ /** The answer of a refused request. */
23
+ function refusalResponse(refusal) {
24
+ const headers = new Headers(refusal.headers);
25
+ headers.set("Content-Type", "application/json");
26
+ return new Response(JSON.stringify({ message: refusal.message }), {
27
+ status: refusal.status,
28
+ headers
29
+ });
30
+ }
31
+ /**
32
+ * The context of one request, or the refusal of the request. A response with a stream, such as the stream of a
33
+ * subscription, releases the context when the stream ends.
5
34
  */
6
35
  async function openContext(option, request) {
7
36
  if (option.createContext === void 0) return {
8
37
  context: option.context,
9
38
  release: () => Promise.resolve()
10
39
  };
11
- const context = await option.createContext(request);
40
+ const made = await option.createContext(request);
41
+ if (isRefusal(made)) return { refusal: made };
12
42
  return {
13
- context,
14
- release: () => release(context)
43
+ context: made,
44
+ release: () => release(made)
15
45
  };
16
46
  }
17
47
  /** Call `Symbol.asyncDispose` or `Symbol.dispose` of a value, if it has one. */
@@ -24,4 +54,4 @@ async function release(value) {
24
54
  }
25
55
  /** Tell if a value is a function. A function can take any arguments, and its result is not known. */
26
56
  const isFunction = (value) => typeof value === "function";
27
- export { openContext };
57
+ export { isRefusal, openContext, refusalResponse, refuse };
@@ -0,0 +1,62 @@
1
+ import type { Availability, CallFailure, Occurrence, Outcome } from "@acid-cat/spec-ts";
2
+ export interface HttpClientOptions {
3
+ /**
4
+ * The headers to send with each request, for example `{ Authorization: "Bearer …" }`. The client never writes them in
5
+ * a message or an error.
6
+ */
7
+ readonly headers?: Readonly<Record<string, string>> | undefined;
8
+ /** The function that sends the requests. The default is the global `fetch`. */
9
+ readonly fetch?: typeof fetch | undefined;
10
+ }
11
+ export interface HttpSubscribeOptions {
12
+ /** Abort to close the request. The server then cancels the subscription, and the occurrences end. */
13
+ readonly signal?: AbortSignal | undefined;
14
+ /** The cursor to resume after, sent as `Last-Event-ID`. */
15
+ readonly cursor?: string | undefined;
16
+ /** The oldest occurrence to replay, in milliseconds, sent as the query parameter `maxAgeMs`. */
17
+ readonly maxAgeMs?: number | undefined;
18
+ }
19
+ /**
20
+ * A client of an HTTP binding at a base URL. No method rejects: a server that does not answer is the call failure
21
+ * `unavailable`, an answer that the HTTP binding does not give is `internal`, and an abort is `cancelled`.
22
+ */
23
+ export interface HttpClient {
24
+ /** The base URL, without its last `/`. */
25
+ readonly base: string;
26
+ /** The capabilities of the view of the server, by id, with their availability: `GET {base}/capabilities`. */
27
+ capabilities(options?: {
28
+ readonly signal?: AbortSignal | undefined;
29
+ }): Promise<ReadonlyMap<string, Availability> | CallFailure>;
30
+ /** Call a capability: `POST {base}/capabilities/{id}`. Abort the signal to close the request: the server then cancels the call. */
31
+ invoke(id: string, input: unknown, options?: {
32
+ readonly signal?: AbortSignal | undefined;
33
+ }): Promise<Outcome<unknown, unknown>>;
34
+ /**
35
+ * Subscribe to an event: `POST {base}/events/{id}`. The result is the occurrences, or the call failure before the first
36
+ * occurrence. The occurrences reject with a `CallFailureError` when the subscription ends with a call failure, and end
37
+ * with no error when the signal aborts.
38
+ */
39
+ subscribe(id: string, input: unknown, options?: HttpSubscribeOptions): Promise<AsyncIterable<Occurrence> | CallFailure>;
40
+ }
41
+ /**
42
+ * A client of the HTTP binding at `url`, an `http` or `https` URL, for example `http://pi.local:8080/cli`. It throws
43
+ * when the URL or a header is not valid. The error does not contain the value of a header.
44
+ */
45
+ export declare function httpClient(url: string, options?: HttpClientOptions): HttpClient;
46
+ /** A message of a stream of server-sent events. */
47
+ interface Message {
48
+ readonly event: string;
49
+ readonly data: string;
50
+ }
51
+ /**
52
+ * The parser of a stream of server-sent events (https://html.spec.whatwg.org/multipage/server-sent-events.html): lines
53
+ * end with CRLF, LF or CR, a line that starts with `:` is a comment, and a blank line ends a message. It reads the
54
+ * fields `event` and `data`, and ignores the others, also `id`: the cursor of an occurrence is in its data. It gives a
55
+ * message with no data only when its event is `complete`.
56
+ */
57
+ export declare class SseParser {
58
+ #private;
59
+ /** Give the next text of the stream. The result is the messages that it ends. */
60
+ push(text: string): Message[];
61
+ }
62
+ export {};
@@ -0,0 +1,265 @@
1
+ import { AVAILABLE, CALL_FAILURE_KINDS, CallFailureError, SSE_MEDIA_TYPE, callFailure, isJsonObject, isOccurrence, unavailable } from "@acid-cat/spec-ts";
2
+ // src/http-client.ts
3
+ /**
4
+ * A client of the HTTP binding (spec/bindings/http.md). The command line uses it to call the capabilities and follow
5
+ * the events of an application that runs in another process or on another machine (spec/bindings/cli.md#server).
6
+ */
7
+ /** The largest answer that the client reads, in bytes. */
8
+ const MAXIMUM_ANSWER = 67108864;
9
+ /**
10
+ * A client of the HTTP binding at `url`, an `http` or `https` URL, for example `http://pi.local:8080/cli`. It throws
11
+ * when the URL or a header is not valid. The error does not contain the value of a header.
12
+ */
13
+ function httpClient(url, options = {}) {
14
+ let parsed;
15
+ try {
16
+ parsed = new URL(url);
17
+ } catch {
18
+ throw new Error("The URL of the server is not a URL.");
19
+ }
20
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("The URL of the server is not an http or https URL.");
21
+ if (parsed.search !== "" || parsed.hash !== "") throw new Error("The URL of the server must not have a query or a fragment.");
22
+ if (parsed.username !== "" || parsed.password !== "") throw new Error("The URL of the server must not have a user name or a password. Give them in a header.");
23
+ const base = `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "");
24
+ const headers = new Headers();
25
+ for (const [name, value] of Object.entries(options.headers ?? {})) try {
26
+ headers.append(name, value);
27
+ } catch {
28
+ throw new Error(`The header ${/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) ? name : "with that name"} is not valid.`);
29
+ }
30
+ const send = options.fetch ?? fetch;
31
+ const notAnswering = () => callFailure("unavailable", `The server at ${base} does not answer.`);
32
+ /** Send a request. The result is the response, or the call failure when the server does not answer or the signal aborts. */
33
+ async function request(method, path, init) {
34
+ if (aborted(init.signal)) return cancelled();
35
+ const requestHeaders = new Headers(headers);
36
+ requestHeaders.set("Accept", "application/json, text/event-stream");
37
+ if (init.lastEventId !== void 0) try {
38
+ requestHeaders.set("Last-Event-ID", init.lastEventId);
39
+ } catch {
40
+ return callFailure("invalid-input", "The cursor cannot be sent in the header Last-Event-ID.");
41
+ }
42
+ let body;
43
+ if (method === "POST") {
44
+ requestHeaders.set("Content-Type", "application/json");
45
+ body = JSON.stringify(init.body ?? {});
46
+ }
47
+ try {
48
+ return await send(`${base}${path}`, {
49
+ method,
50
+ headers: requestHeaders,
51
+ ...body === void 0 ? {} : { body },
52
+ ...init.signal === void 0 ? {} : { signal: init.signal }
53
+ });
54
+ } catch {
55
+ return aborted(init.signal) ? cancelled() : notAnswering();
56
+ }
57
+ }
58
+ /** The status and the JSON body of an answer, or the call failure when it is not JSON. */
59
+ async function read(response, signal) {
60
+ const text = await boundedText(response, signal);
61
+ if (typeof text !== "string") return text;
62
+ try {
63
+ return {
64
+ status: response.status,
65
+ body: JSON.parse(text)
66
+ };
67
+ } catch {
68
+ return notAnAnswer();
69
+ }
70
+ }
71
+ return {
72
+ base,
73
+ async capabilities(callOptions = {}) {
74
+ const response = await request("GET", "/capabilities", { signal: callOptions.signal });
75
+ if (!(response instanceof Response)) return response;
76
+ const answer = await read(response, callOptions.signal);
77
+ if (!("status" in answer)) return answer;
78
+ if (answer.status !== 200) return failureOf(answer.body);
79
+ if (!Array.isArray(answer.body)) return notAnAnswer();
80
+ const served = /* @__PURE__ */ new Map();
81
+ for (const item of answer.body) {
82
+ const entry = availabilityEntry(item);
83
+ if (entry === void 0) return notAnAnswer();
84
+ served.set(entry.id, entry.availability);
85
+ }
86
+ return served;
87
+ },
88
+ async invoke(id, input, callOptions = {}) {
89
+ const response = await request("POST", `/capabilities/${encodeURIComponent(id)}`, {
90
+ body: input,
91
+ signal: callOptions.signal
92
+ });
93
+ if (!(response instanceof Response)) return response;
94
+ const answer = await read(response, callOptions.signal);
95
+ if (!("status" in answer)) return answer;
96
+ if (answer.status !== 200) return failureOf(answer.body);
97
+ const { body } = answer;
98
+ if (isJsonObject(body) && body.ok === true && "value" in body) return {
99
+ ok: true,
100
+ value: body.value
101
+ };
102
+ if (isJsonObject(body) && body.ok === false && "error" in body) return {
103
+ ok: false,
104
+ error: body.error
105
+ };
106
+ return notAnAnswer();
107
+ },
108
+ async subscribe(id, input, subscribeOptions = {}) {
109
+ const query = subscribeOptions.maxAgeMs === void 0 ? "" : `?maxAgeMs=${encodeURIComponent(String(subscribeOptions.maxAgeMs))}`;
110
+ const response = await request("POST", `/events/${encodeURIComponent(id)}${query}`, {
111
+ body: input,
112
+ signal: subscribeOptions.signal,
113
+ lastEventId: subscribeOptions.cursor
114
+ });
115
+ if (!(response instanceof Response)) return response;
116
+ if (response.status !== 200) {
117
+ const answer = await read(response, subscribeOptions.signal);
118
+ return "status" in answer ? failureOf(answer.body) : answer;
119
+ }
120
+ if ((response.headers.get("Content-Type") ?? "").split(";")[0]?.trim().toLowerCase() !== SSE_MEDIA_TYPE || response.body === null) {
121
+ await response.body?.cancel().catch(() => void 0);
122
+ return notAnAnswer();
123
+ }
124
+ return occurrencesOf(response.body, subscribeOptions.signal);
125
+ }
126
+ };
127
+ }
128
+ /** True if the signal aborted. A function, so that TypeScript does not keep a test of the signal from before an await. */
129
+ const aborted = (signal) => signal?.aborted === true;
130
+ const cancelled = () => callFailure("cancelled", "The call was cancelled.");
131
+ const notAnAnswer = () => callFailure("internal", "The server gave an answer that the HTTP binding does not give.");
132
+ const broken = () => callFailure("internal", "The connection to the server broke.");
133
+ const isCallFailureKind = (value) => CALL_FAILURE_KINDS.some((kind) => kind === value);
134
+ /** The call failure in the body of an answer, or `internal` when the body is not a call failure. */
135
+ function failureOf(body) {
136
+ if (isJsonObject(body) && isCallFailureKind(body.kind) && typeof body.message === "string") return callFailure(body.kind, body.message);
137
+ return notAnAnswer();
138
+ }
139
+ /** One item of `GET {base}/capabilities`, or `undefined` when it is not one. */
140
+ function availabilityEntry(item) {
141
+ if (!isJsonObject(item) || typeof item.id !== "string") return void 0;
142
+ if (item.available === true) return {
143
+ id: item.id,
144
+ availability: AVAILABLE
145
+ };
146
+ if (item.available === false && typeof item.reason === "string") return {
147
+ id: item.id,
148
+ availability: unavailable(item.reason)
149
+ };
150
+ }
151
+ /** The text of a body, at most `MAXIMUM_ANSWER` bytes, or the call failure when it cannot be read. */
152
+ async function boundedText(response, signal) {
153
+ if (response.body === null) return "";
154
+ const reader = response.body.getReader();
155
+ const decoder = new TextDecoder();
156
+ let text = "";
157
+ let size = 0;
158
+ try {
159
+ for (;;) {
160
+ const chunk = await reader.read();
161
+ if (chunk.done) return text + decoder.decode();
162
+ size += chunk.value.byteLength;
163
+ if (size > MAXIMUM_ANSWER) {
164
+ await reader.cancel().catch(() => void 0);
165
+ return notAnAnswer();
166
+ }
167
+ text += decoder.decode(chunk.value, { stream: true });
168
+ }
169
+ } catch {
170
+ return aborted(signal) ? cancelled() : broken();
171
+ }
172
+ }
173
+ /**
174
+ * The parser of a stream of server-sent events (https://html.spec.whatwg.org/multipage/server-sent-events.html): lines
175
+ * end with CRLF, LF or CR, a line that starts with `:` is a comment, and a blank line ends a message. It reads the
176
+ * fields `event` and `data`, and ignores the others, also `id`: the cursor of an occurrence is in its data. It gives a
177
+ * message with no data only when its event is `complete`.
178
+ */
179
+ var SseParser = class {
180
+ #buffer = "";
181
+ #event = "";
182
+ #data = [];
183
+ #started = false;
184
+ /** Give the next text of the stream. The result is the messages that it ends. */
185
+ push(text) {
186
+ this.#buffer += text;
187
+ if (!this.#started && this.#buffer.length > 0) {
188
+ this.#started = true;
189
+ if (this.#buffer.startsWith("")) this.#buffer = this.#buffer.slice(1);
190
+ }
191
+ const messages = [];
192
+ for (;;) {
193
+ const end = this.#buffer.search(/\r\n|\n|\r/);
194
+ if (end < 0) break;
195
+ if (this.#buffer[end] === "\r" && end === this.#buffer.length - 1) break;
196
+ const line = this.#buffer.slice(0, end);
197
+ this.#buffer = this.#buffer.slice(end + (this.#buffer.startsWith("\r\n", end) ? 2 : 1));
198
+ const message = this.#line(line);
199
+ if (message !== void 0) messages.push(message);
200
+ }
201
+ return messages;
202
+ }
203
+ #line(line) {
204
+ if (line === "") {
205
+ const message = {
206
+ event: this.#event === "" ? "message" : this.#event,
207
+ data: this.#data.join("\n")
208
+ };
209
+ const empty = this.#data.length === 0;
210
+ this.#event = "";
211
+ this.#data = [];
212
+ return empty && message.event !== "complete" ? void 0 : message;
213
+ }
214
+ if (line.startsWith(":")) return void 0;
215
+ const colon = line.indexOf(":");
216
+ const field = colon < 0 ? line : line.slice(0, colon);
217
+ const raw = colon < 0 ? "" : line.slice(colon + 1);
218
+ const value = raw.startsWith(" ") ? raw.slice(1) : raw;
219
+ if (field === "event") this.#event = value;
220
+ else if (field === "data") this.#data.push(value);
221
+ }
222
+ };
223
+ /** Parse JSON text, or give `undefined` when it is not JSON. */
224
+ function parseJson(text) {
225
+ try {
226
+ return { value: JSON.parse(text) };
227
+ } catch {
228
+ return;
229
+ }
230
+ }
231
+ /**
232
+ * The occurrences of a stream of server-sent events of the HTTP binding. `next` is an occurrence, `complete` the end,
233
+ * and `error` the end with its call failure. A stream that ends with no `complete` or `error` is `internal`.
234
+ */
235
+ async function* occurrencesOf(body, signal) {
236
+ const reader = body.getReader();
237
+ const decoder = new TextDecoder();
238
+ const parser = new SseParser();
239
+ try {
240
+ for (;;) {
241
+ let chunk;
242
+ try {
243
+ chunk = await reader.read();
244
+ } catch {
245
+ if (aborted(signal)) return;
246
+ throw new CallFailureError(broken());
247
+ }
248
+ if (chunk.done) {
249
+ if (aborted(signal)) return;
250
+ throw new CallFailureError(broken());
251
+ }
252
+ for (const message of parser.push(decoder.decode(chunk.value, { stream: true }))) {
253
+ const data = parseJson(message.data);
254
+ if (message.event === "next") {
255
+ if (data === void 0 || !isOccurrence(data.value)) throw new CallFailureError(notAnAnswer());
256
+ yield data.value;
257
+ } else if (message.event === "complete") return;
258
+ else if (message.event === "error") throw new CallFailureError(data === void 0 ? notAnAnswer() : failureOf(data.value));
259
+ }
260
+ }
261
+ } finally {
262
+ await reader.cancel().catch(() => void 0);
263
+ }
264
+ }
265
+ export { SseParser, httpClient };
package/dist/http.d.ts CHANGED
@@ -19,5 +19,8 @@ export interface HttpSettings {
19
19
  /** The number of occurrences that each subscription keeps for a client that reads slowly. The default is 256. */
20
20
  readonly bufferLimit?: number | undefined;
21
21
  }
22
- /** A web request handler for the API. Each request has its own session, and with `createContext` its own context. */
22
+ /**
23
+ * A web request handler for the API. Each request has its own session, and with `createContext` its own context.
24
+ * `createContext` can refuse a request: then the handler answers with the refusal, and opens no session.
25
+ */
23
26
  export declare function httpHandler<Context>(app: App<Context>, options: HttpOptions<Context>): (request: Request) => Promise<Response>;
package/dist/http.js CHANGED
@@ -1,4 +1,4 @@
1
- import { openContext } from "./context.js";
1
+ import { openContext, refusalResponse } from "./context.js";
2
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";
@@ -6,7 +6,10 @@ import { CallFailureError, KEEPALIVE_MS, SSE_MEDIA_TYPE, callFailure, httpEventM
6
6
  /**
7
7
  * The HTTP binding (spec/bindings/http.md): JSON through HTTP.
8
8
  */
9
- /** A web request handler for the API. Each request has its own session, and with `createContext` its own context. */
9
+ /**
10
+ * A web request handler for the API. Each request has its own session, and with `createContext` its own context.
11
+ * `createContext` can refuse a request: then the handler answers with the refusal, and opens no session.
12
+ */
10
13
  function httpHandler(app, options) {
11
14
  const maximumBytes = options.maximumBytes ?? 1048576;
12
15
  return async (request) => {
@@ -22,6 +25,7 @@ function httpHandler(app, options) {
22
25
  const url = new URL(request.url);
23
26
  const path = url.pathname.slice((options.base ?? "").length);
24
27
  const opened = await openContext(options, request);
28
+ if ("refusal" in opened) return refusalResponse(opened.refusal);
25
29
  const session = createSession(app, {
26
30
  target: options.target ?? "api",
27
31
  context: opened.context,
package/dist/index.d.ts CHANGED
@@ -16,9 +16,12 @@ export { main as mcpStdioMain, runMcpStdio } from "./mcp-stdio.js";
16
16
  export type { McpStdioIo, McpStdioOptions } from "./mcp-stdio.js";
17
17
  export { httpHandler } from "./http.js";
18
18
  export type { HttpOptions, HttpSettings } from "./http.js";
19
- export type { ContextOption } from "./context.js";
19
+ export { isRefusal, refuse } from "./context.js";
20
+ export type { ContextOption, Refusal } from "./context.js";
20
21
  export { main as cliMain, runCli } from "./cli.js";
21
22
  export type { CliCommand, CliIo, CliOptions } from "./cli.js";
23
+ export { httpClient } from "./http-client.js";
24
+ export type { HttpClient, HttpClientOptions, HttpSubscribeOptions } from "./http-client.js";
22
25
  export { findHost, installWebMcp } from "./webmcp.js";
23
26
  export type { Installation, ModelContext, ModelContextTool, Phase, Status } from "./webmcp.js";
24
27
  export type { StandardSchemaV1 } from "./standard-schema.js";
package/dist/index.js CHANGED
@@ -5,8 +5,10 @@ import { mcpHandler } from "./mcp.js";
5
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
+ import { isRefusal, refuse } from "./context.js";
8
9
  import { main, runCli } from "./cli.js";
10
+ import { httpClient } from "./http-client.js";
9
11
  import { findHost, installWebMcp } from "./webmcp.js";
10
12
  import { AVAILABLE, CallFailureError, fail, ok, unavailable } from "@acid-cat/spec-ts";
11
13
  import { mcpHandshake } from "@acid-cat/mcp-handshake";
12
- export { AVAILABLE, CallFailureError, DEFAULT_BUFFER_LIMIT, capabilitiesFor, catalogOf, main as cliMain, createSession, defineApp, defineCapability, defineEvent, eventsFor, fail, findHost, httpHandler, installWebMcp, mcpHandler, mcpHandshake, main$1 as mcpStdioMain, ok, renderCatalog, runCli, runMcpStdio, systemClock, unavailable };
14
+ export { AVAILABLE, CallFailureError, DEFAULT_BUFFER_LIMIT, capabilitiesFor, catalogOf, main as cliMain, createSession, defineApp, defineCapability, defineEvent, eventsFor, fail, findHost, httpClient, httpHandler, installWebMcp, isRefusal, mcpHandler, mcpHandshake, main$1 as mcpStdioMain, ok, refuse, renderCatalog, runCli, runMcpStdio, systemClock, unavailable };
package/dist/mcp.d.ts CHANGED
@@ -53,8 +53,10 @@ export interface McpHandler {
53
53
  }
54
54
  /**
55
55
  * A web request handler for the MCP server. Each request has its own session, and with `createContext` its own
56
- * context. Each poll lease and each webhook subscription also has its own session, opened with the context of the
57
- * request that started it.
56
+ * context. `createContext` can refuse a request: then the handler answers with the refusal, and opens no session.
57
+ * Each poll lease and each webhook subscription also has its own session, with the context of the request that
58
+ * started it. The handler releases that context when the answer is done and each of those leases and subscriptions
59
+ * has ended.
58
60
  */
59
61
  export declare function mcpHandler<Context>(app: App<Context>, options: McpOptions<Context>): McpHandler;
60
62
  /** The tools of the available capabilities in the view of a session (spec/bindings/mcp.md#tools). */
package/dist/mcp.js CHANGED
@@ -1,4 +1,4 @@
1
- import { openContext } from "./context.js";
1
+ import { openContext, refusalResponse } from "./context.js";
2
2
  import { eventCapabilities, forbidden, listEvents, servesEvents, startEventStream, unsupportedWebhook } from "./mcp-events.js";
3
3
  import { PollLeases } from "./mcp-poll.js";
4
4
  import { createSession } from "./session.js";
@@ -28,8 +28,10 @@ function readRequest(body) {
28
28
  }
29
29
  /**
30
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.
31
+ * context. `createContext` can refuse a request: then the handler answers with the refusal, and opens no session.
32
+ * Each poll lease and each webhook subscription also has its own session, with the context of the request that
33
+ * started it. The handler releases that context when the answer is done and each of those leases and subscriptions
34
+ * has ended.
33
35
  */
34
36
  function mcpHandler(app, options) {
35
37
  const maximumBytes = options.maximumBytes ?? 1048576;
@@ -53,19 +55,17 @@ function mcpHandler(app, options) {
53
55
  } catch {
54
56
  return rejection("not-utf8");
55
57
  }
56
- const version = request.headers.get("MCP-Protocol-Version");
58
+ const opened = await openContext(options, request);
59
+ if ("refusal" in opened) return refusalResponse(opened.refusal);
60
+ const unanswerable = revisionError(request, message);
61
+ if (unanswerable !== void 0) {
62
+ await opened.release();
63
+ return unanswerable;
64
+ }
57
65
  const id = message.id ?? null;
58
- if (version === null) return error(400, id, -32020, "MCP-Protocol-Version header is required");
59
- if (version !== MCP_PROTOCOL_VERSION) return error(400, id, -32022, `Unsupported protocol version ${version}`, { supported: [MCP_PROTOCOL_VERSION] });
60
- if (message.id === void 0) return new Response(null, {
61
- status: 200,
62
- headers: { "Content-Type": "application/json" }
63
- });
64
- const metaVersion = message.protocolVersion;
65
- if (metaVersion === void 0) return error(400, id, -32602, "Required request metadata is missing");
66
- if (metaVersion !== version) return error(400, id, -32020, "MCP-Protocol-Version header does not match request metadata");
66
+ let holders = 0;
67
67
  const open = async () => {
68
- const opened = await openContext(options, request);
68
+ holders += 1;
69
69
  const session = createSession(app, {
70
70
  target: options.target ?? "mcp",
71
71
  context: opened.context,
@@ -74,8 +74,12 @@ function mcpHandler(app, options) {
74
74
  });
75
75
  let closing;
76
76
  const close = () => closing ??= (async () => {
77
- await session.dispose();
78
- await opened.release();
77
+ try {
78
+ await session.dispose();
79
+ } finally {
80
+ holders -= 1;
81
+ if (holders === 0) await opened.release();
82
+ }
79
83
  })();
80
84
  return {
81
85
  session,
@@ -85,7 +89,7 @@ function mcpHandler(app, options) {
85
89
  const { session, close } = await open();
86
90
  let streaming = false;
87
91
  try {
88
- if (message.method === "events/stream" && message.id !== null && servesEvents(session)) {
92
+ if (message.method === "events/stream" && message.id !== null && message.id !== void 0 && servesEvents(session)) {
89
93
  const started = await startEventStream(session, message.id, message.params, {
90
94
  heartbeatMs: options.heartbeatMs ?? KEEPALIVE_MS,
91
95
  resultMeta: serverInfoOf(options),
@@ -127,6 +131,23 @@ function mcpHandler(app, options) {
127
131
  [Symbol.asyncDispose]: closeAll
128
132
  });
129
133
  }
134
+ /**
135
+ * The error answer of a request that does not name revision 2026-07-28 correctly, or the empty answer of a
136
+ * notification. `undefined` for a request that the server answers.
137
+ */
138
+ function revisionError(request, message) {
139
+ const version = request.headers.get("MCP-Protocol-Version");
140
+ const id = message.id ?? null;
141
+ if (version === null) return error(400, id, -32020, "MCP-Protocol-Version header is required");
142
+ if (version !== MCP_PROTOCOL_VERSION) return error(400, id, -32022, `Unsupported protocol version ${version}`, { supported: [MCP_PROTOCOL_VERSION] });
143
+ if (message.id === void 0) return new Response(null, {
144
+ status: 200,
145
+ headers: { "Content-Type": "application/json" }
146
+ });
147
+ const metaVersion = message.protocolVersion;
148
+ if (metaVersion === void 0) return error(400, id, -32602, "Required request metadata is missing");
149
+ if (metaVersion !== version) return error(400, id, -32020, "MCP-Protocol-Version header does not match request metadata");
150
+ }
130
151
  /** The methods whose state outlives the request (spec/bindings/mcp.md#subscriptions-that-outlive-a-request). */
131
152
  const LONG_LIVED = /* @__PURE__ */ new Set([
132
153
  "events/poll",
package/dist/session.js CHANGED
@@ -86,7 +86,8 @@ function createSession(app, options) {
86
86
  if (lifetime.signal.aborted || signal?.aborted) controller.abort();
87
87
  const work = Promise.resolve().then(() => prepared.start({
88
88
  signal: controller.signal,
89
- context: options.context
89
+ context: options.context,
90
+ target: options.target
90
91
  })).catch((cause) => {
91
92
  onDefect(id, cause);
92
93
  return callFailure("internal", "The capability failed.");
@@ -199,6 +200,7 @@ function createSession(app, options) {
199
200
  source = prepared.start({
200
201
  signal: controller.signal,
201
202
  context: options.context,
203
+ target: options.target,
202
204
  ...subscribeOptions.cursor === void 0 ? {} : { cursor: subscribeOptions.cursor },
203
205
  ...subscribeOptions.maxAgeMs === void 0 ? {} : { maxAgeMs: subscribeOptions.maxAgeMs }
204
206
  });
package/dist/webhook.js CHANGED
@@ -35,7 +35,9 @@ function webhookDelivery(options = {}) {
35
35
  const subscriptions = /* @__PURE__ */ new Map();
36
36
  /** The subscriptions that start, by key. A second request with the same key waits for the first. */
37
37
  const starting = /* @__PURE__ */ new Map();
38
- /** The pairs of principal and URL that agreed to get deliveries, while the process runs. */
38
+ /** The new subscriptions of each principal that are starting now, so that they count for the limit. */
39
+ const admitted = /* @__PURE__ */ new Map();
40
+ /** The pairs of principal and URL, as the client sent it, that agreed to get deliveries, while the process runs. */
39
41
  const verified = /* @__PURE__ */ new Set();
40
42
  const pumps = /* @__PURE__ */ new Set();
41
43
  let closed = false;
@@ -127,8 +129,8 @@ function webhookDelivery(options = {}) {
127
129
  subscription.cancelExpiry = clock.schedule(() => void end(subscription), lifetime);
128
130
  }
129
131
  /** Verify that the endpoint agrees to get deliveries for this principal. The result is the reason of a failure. */
130
- async function verify(principal, url, id, secret) {
131
- const pair = canonicalJson([principal, url.href]);
132
+ async function verify(principal, rawUrl, url, id, secret) {
133
+ const pair = canonicalJson([principal, rawUrl]);
132
134
  if (verified.has(pair) || options.allow?.(url) === true) return void 0;
133
135
  const challenge = randomBytes(24).toString("base64url");
134
136
  const body = JSON.stringify({
@@ -158,7 +160,7 @@ function webhookDelivery(options = {}) {
158
160
  } });
159
161
  /** Start a new subscription: subscribe in a session of its own, verify the endpoint, then send the occurrences. */
160
162
  async function start(principal, key, url, secret, request, open) {
161
- if ([...subscriptions.values()].filter((each) => each.principal === principal).length >= settings.maxSubscriptions) return { error: {
163
+ if ([...subscriptions.values()].filter((each) => each.principal === principal).length + (admitted.get(principal) ?? 0) >= settings.maxSubscriptions) return { error: {
162
164
  code: MCP_EVENT_ERRORS.resourceExhausted,
163
165
  message: "Too many webhook subscriptions.",
164
166
  data: {
@@ -166,6 +168,24 @@ function webhookDelivery(options = {}) {
166
168
  max: settings.maxSubscriptions
167
169
  }
168
170
  } };
171
+ admitted.set(principal, (admitted.get(principal) ?? 0) + 1);
172
+ let held = true;
173
+ /** Stop counting the subscription as starting. It runs once: when the subscription exists, or when the start fails. */
174
+ const release = () => {
175
+ if (!held) return;
176
+ held = false;
177
+ const left = (admitted.get(principal) ?? 1) - 1;
178
+ if (left === 0) admitted.delete(principal);
179
+ else admitted.set(principal, left);
180
+ };
181
+ try {
182
+ return await startAdmitted(principal, key, url, secret, request, open, release);
183
+ } finally {
184
+ release();
185
+ }
186
+ }
187
+ /** Start a subscription that the limit admitted. `release` runs in the same step that adds the subscription. */
188
+ async function startAdmitted(principal, key, url, secret, request, open, release) {
169
189
  const id = await webhookSubscriptionId(principal, request.rawUrl, request.name, request.input);
170
190
  const own = await open();
171
191
  const subscribed = await own.session.subscribe(request.name, request.input, {
@@ -176,7 +196,7 @@ function webhookDelivery(options = {}) {
176
196
  await own.close();
177
197
  return { error: mcpSubscribeError(subscribed) };
178
198
  }
179
- const reason = closed ? "connection_refused" : await verify(principal, url, id, secret);
199
+ const reason = closed ? "connection_refused" : await verify(principal, request.rawUrl, url, id, secret);
180
200
  if (reason !== void 0 || closed) {
181
201
  await subscribed.return();
182
202
  await own.close();
@@ -198,6 +218,7 @@ function webhookDelivery(options = {}) {
198
218
  lastDeliveryAt: null,
199
219
  lastError: null
200
220
  };
221
+ release();
201
222
  subscriptions.set(key, subscription);
202
223
  grant(subscription, request.ttlMs);
203
224
  const work = pump(subscription).finally(() => pumps.delete(work));
@@ -210,7 +231,7 @@ function webhookDelivery(options = {}) {
210
231
  if ("error" in read) return read;
211
232
  const { url, secret } = read;
212
233
  const key = subscriptionKey(principal, read.rawUrl, read.name, read.input);
213
- await starting.get(key);
234
+ for (let pending = starting.get(key); pending !== void 0; pending = starting.get(key)) await pending.catch(() => void 0);
214
235
  const existing = subscriptions.get(key);
215
236
  if (existing !== void 0) {
216
237
  if (!isMaxAgeMs(read.maxAgeMs)) return invalidParams("maxAgeMs must be an integer that is 0 or more.");
@@ -226,7 +247,9 @@ function webhookDelivery(options = {}) {
226
247
  }
227
248
  const started = start(principal, key, url, secret, read, open);
228
249
  starting.set(key, started);
229
- return started.finally(() => starting.delete(key));
250
+ return started.finally(() => {
251
+ if (starting.get(key) === started) starting.delete(key);
252
+ });
230
253
  },
231
254
  unsubscribe(principal, params) {
232
255
  const { name, arguments: input = {}, delivery } = params;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acid-cat/core-ts",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "description": "Serve AcidCat capabilities and events in plain TypeScript, with no Effect dependency.",
5
5
  "keywords": [
6
6
  "acidcat",
@@ -36,12 +36,12 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@acid-cat/catalog": "0.10.2",
40
- "@acid-cat/mcp-handshake": "0.10.2",
41
- "@acid-cat/spec-ts": "0.10.2"
39
+ "@acid-cat/catalog": "0.11.0",
40
+ "@acid-cat/mcp-handshake": "0.11.0",
41
+ "@acid-cat/spec-ts": "0.11.0"
42
42
  },
43
43
  "devDependencies": {
44
- "@acid-cat/cli": "0.10.2",
44
+ "@acid-cat/cli": "0.11.0",
45
45
  "@types/node": "26.6.6",
46
46
  "esbuild": "0.28.2",
47
47
  "graphql": "17.1.0",