@acid-cat/core-ts 0.7.0 → 0.8.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
@@ -62,9 +62,35 @@ Standard Schema does not convert to JSON Schema. Give the converter of your sche
62
62
  | `httpHandler(app, options)` | [HTTP](../../spec/bindings/http.md) | `(request: Request) => Promise<Response>` |
63
63
  | `cliMain(app, options)`, `runCli(…)` | [CLI](../../spec/bindings/cli.md) | The command line of the process, or an exit code |
64
64
  | `installWebMcp(session)` | [WebMCP](../../spec/bindings/webmcp.md) | Tools in the page, with a status |
65
+ | `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) |
65
66
 
66
67
  When the signal of a request aborts, `mcpHandler` and `httpHandler` cancel the call of the request.
67
68
 
69
+ ### Serve on Node.js
70
+
71
+ `@acid-cat/core-ts/node` serves web handlers on Node.js, with only `node:http`. It has no other dependency.
72
+
73
+ ```ts
74
+ import { serve } from "@acid-cat/core-ts/node";
75
+
76
+ const mcp = mcpHandler(aircon, { name: "aircon", version: "1.0.0", allowedOrigins: [], toJsonSchema, context: { unit } });
77
+ const http = httpHandler(aircon, { allowedOrigins: [], toJsonSchema, context: { unit } });
78
+
79
+ const server = await serve({ "/mcp": mcp, "/": http }, { port: 3000 });
80
+ console.log(server.url); // http://127.0.0.1:3000
81
+ ```
82
+
83
+ `serve(handler, options)` takes one handler for all paths, or handlers by path prefix. The longest prefix that matches gets the request, and the prefix `/` matches all paths. The MCP handler answers on all paths, so give it only its own prefix. A path that no prefix matches gets `404`. A handler that rejects gets `500`.
84
+
85
+ | Option | Default | Use |
86
+ | --- | --- | --- |
87
+ | `port` | `3000` | The port. With `0`, the system chooses a free port. `server.port` gives it |
88
+ | `hostname` | `127.0.0.1` | The address. Only the same computer can connect. Use `0.0.0.0` or `::` to accept connections from the network |
89
+
90
+ `serve` resolves when the server listens. `server.close()` stops it, and `await using server = await serve(…)` closes it at the end of the block. When a client closes its connection, the signal of its request aborts, so the handler cancels the call.
91
+
92
+ `serve` gives a plain HTTP/1.1 server. For more, use a library for web handlers, for example [srvx](https://srvx.h3.dev): HTTPS, HTTP/2, middleware, or the same server code on Bun and Deno. Give it the handler. Make sure that the library aborts the signal of a request when the client closes the connection. If it does not, a closed connection does not cancel the call.
93
+
68
94
  ### Typed calls and handles
69
95
 
70
96
  `createSession` gives a `Session<Context>`. It calls a capability with the types of the capability in two ways. Both take the capability object, so the types come from its schemas:
@@ -102,6 +128,20 @@ const handler = httpHandler(aircon, {
102
128
  });
103
129
  ```
104
130
 
131
+ ### Command line
132
+
133
+ 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):
134
+
135
+ ```sh
136
+ node cli.ts aircon temperature set --celsius 22
137
+ node cli.ts aircon schedule set --days mon --days fri --hours 7 --hours 18 # {"days":["mon","fri"],"hours":[7,18]}
138
+ node cli.ts aircon schedule set --days '["mon","fri"]' --hours '[7,18]' # the same input, as JSON text
139
+ node cli.ts aircon schedule set --days '[]' # an empty array
140
+ node cli.ts list # the usage of each command
141
+ ```
142
+
143
+ `list` gives a usage line for each capability. The usage of a repeated flag ends with `...`, for example `--days <mon|tue|wed|thu|fri|sat|sun>... [--hours <integer>...]`. See the [CLI binding](../../spec/bindings/cli.md#commands) for all the rules.
144
+
105
145
  ### Commands of the application
106
146
 
107
147
  The option `commands` of `cliMain` and `runCli` adds commands of the application beside the commands of the capabilities, `call` and `list`. A command has a `name`, a `description` for the help, and `run(args, io)`. `run` gets the arguments after the name of the command, and `io` with `stdout`, `stderr`, `signal` and the `context`. It returns the exit code. The name must not be `call`, `list`, the name of another command of the application, or the first word of the command of a capability. If it is, `runCli` throws. The option `description` gives the text at the top of the help. The default is the description of the application, then its title, then the name of the program.
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createSession } from "./session.js";
2
- import { CLI_EXIT_CODES, REFERENCE, bundle, cliExitCode, cliFlags, cliUsage, commandPath, isCallFailure, isJsonObject } from "@acid-cat/spec-ts";
2
+ import { CLI_EXIT_CODES, bundle, cliExitCode, cliFlags, cliJsonArray, cliRepeatedItems, cliResolve, cliUsage, commandPath, isCallFailure, isJsonObject } from "@acid-cat/spec-ts";
3
3
  //#region src/cli.ts
4
4
  /**
5
5
  * The CLI binding (spec/bindings/cli.md): a command line made from the catalog.
@@ -119,21 +119,32 @@ function parseFlags(args, schema) {
119
119
  const properties = objectOrEmpty(schema.properties);
120
120
  const definitions = objectOrEmpty(schema.$defs);
121
121
  const input = {};
122
+ const repeated = /* @__PURE__ */ new Map();
122
123
  const remaining = args.values();
123
124
  for (const arg of remaining) {
124
125
  if (!arg.startsWith("--")) throw new UsageError(`Unexpected argument ${arg}.`, CLI_EXIT_CODES["invalid-input"]);
125
126
  const [name = "", inline] = arg.slice(2).split(/=(.*)/s, 2);
126
127
  const property = properties[name];
127
128
  if (property === void 0) throw new UsageError(`Unknown flag --${name}.`, CLI_EXIT_CODES["invalid-input"]);
128
- const type = resolve(objectOrEmpty(property), definitions);
129
+ const type = cliResolve(objectOrEmpty(property), definitions);
129
130
  if (type.type === "boolean") {
130
131
  input[name] = inline === void 0 ? true : inline === "true";
131
132
  continue;
132
133
  }
133
134
  const text = inline ?? remaining.next().value;
134
135
  if (text === void 0) throw new UsageError(`The flag --${name} needs a value.`, CLI_EXIT_CODES["invalid-input"]);
135
- input[name] = valueOf(name, text, type);
136
+ const items = cliRepeatedItems(type, definitions);
137
+ if (items === void 0) input[name] = valueOf(name, text, type);
138
+ else {
139
+ const flag = repeated.get(name) ?? {
140
+ items,
141
+ values: []
142
+ };
143
+ flag.values.push(text);
144
+ repeated.set(name, flag);
145
+ }
136
146
  }
147
+ for (const [name, { items, values }] of repeated) input[name] = cliJsonArray(values) ?? values.map((text) => valueOf(name, text, items));
137
148
  const required = Array.isArray(schema.required) ? schema.required.filter((name) => typeof name === "string") : [];
138
149
  for (const name of required) if (!(name in input)) throw new UsageError(`The flag --${name} is required.`, CLI_EXIT_CODES["invalid-input"]);
139
150
  return input;
@@ -155,10 +166,6 @@ function valueOf(name, text, type) {
155
166
  throw new UsageError(`The flag --${name} must be JSON.`, CLI_EXIT_CODES["invalid-input"]);
156
167
  }
157
168
  }
158
- function resolve(property, definitions) {
159
- const name = typeof property.$ref === "string" ? REFERENCE.exec(property.$ref)?.[1] : void 0;
160
- return name === void 0 ? property : objectOrEmpty(definitions[name] ?? property);
161
- }
162
169
  /** A value as a JSON object, or an empty object when it is not one. */
163
170
  const objectOrEmpty = (value) => isJsonObject(value) ? value : {};
164
171
  /** The capability `id` of a view. The view has it, because the session serves it. */
package/dist/node.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ /** A web request handler. */
2
+ export type WebHandler = (request: Request) => Promise<Response> | Response;
3
+ /**
4
+ * Handlers by path prefix, for example `{ "/mcp": mcp, "/api": http }`. A prefix matches its own path and the paths
5
+ * below it: `/api` matches `/api` and `/api/status`, but not `/apis`. The longest prefix that matches gets the request.
6
+ * The prefix `/` matches all paths. The handler gets the full URL of the request.
7
+ */
8
+ export interface Routes {
9
+ readonly [prefix: string]: WebHandler;
10
+ }
11
+ export interface ServeOptions {
12
+ /** The port. The default is `3000`. With `0`, the system chooses a free port. */
13
+ readonly port?: number | undefined;
14
+ /**
15
+ * The address to listen on. The default is `127.0.0.1`, so that only the same computer can connect. Use `0.0.0.0`
16
+ * or `::` to accept connections from the network.
17
+ */
18
+ readonly hostname?: string | undefined;
19
+ }
20
+ /** A server that runs. `await using` closes it at the end of its block. */
21
+ export interface Served extends AsyncDisposable {
22
+ /** The base URL, for example `http://127.0.0.1:3000`. */
23
+ readonly url: string;
24
+ /** The port that the server listens on. */
25
+ readonly port: number;
26
+ /** Stop accepting connections, and resolve when the open requests are complete. */
27
+ readonly close: () => Promise<void>;
28
+ }
29
+ /**
30
+ * Serve one handler for all paths, or handlers by path prefix. The promise resolves when the server listens, and rejects
31
+ * when it cannot listen, for example when the port is in use.
32
+ *
33
+ * - The signal of each request aborts when the client closes the connection before the response is complete. A handler
34
+ * that follows the signal then cancels its work.
35
+ * - A path that no prefix matches gets `404`. A handler that throws or rejects gets `500`. A request that is not valid
36
+ * HTTP for a URL gets `400`.
37
+ * - The body of a request is a stream that reads from the connection only when the handler reads it.
38
+ */
39
+ export declare function serve(handler: WebHandler | Routes, options?: ServeOptions): Promise<Served>;
package/dist/node.js ADDED
@@ -0,0 +1,172 @@
1
+ import { createServer } from "node:http";
2
+ //#region src/node.ts
3
+ /**
4
+ * The functions that need Node.js. The main entry point, `@acid-cat/core-ts`, works in all runtimes.
5
+ *
6
+ * `serve` serves web request handlers, `(request: Request) => Promise<Response>`, such as the handlers of `mcpHandler`
7
+ * and `httpHandler`, on Node.js, with only `node:http`. The server serves plain HTTP/1.1. For HTTPS, HTTP/2 or
8
+ * middleware, use a library for web handlers.
9
+ */
10
+ /**
11
+ * Serve one handler for all paths, or handlers by path prefix. The promise resolves when the server listens, and rejects
12
+ * when it cannot listen, for example when the port is in use.
13
+ *
14
+ * - The signal of each request aborts when the client closes the connection before the response is complete. A handler
15
+ * that follows the signal then cancels its work.
16
+ * - A path that no prefix matches gets `404`. A handler that throws or rejects gets `500`. A request that is not valid
17
+ * HTTP for a URL gets `400`.
18
+ * - The body of a request is a stream that reads from the connection only when the handler reads it.
19
+ */
20
+ function serve(handler, options = {}) {
21
+ const route = router(handler);
22
+ const hostname = options.hostname ?? "127.0.0.1";
23
+ const server = createServer((incoming, outgoing) => {
24
+ answer(incoming, outgoing, route);
25
+ });
26
+ return new Promise((resolve, reject) => {
27
+ server.once("error", reject);
28
+ server.listen(options.port ?? 3e3, hostname, () => {
29
+ server.off("error", reject);
30
+ const address = server.address();
31
+ const port = typeof address === "object" && address !== null ? address.port : 0;
32
+ const close = () => new Promise((done) => server.close(() => done()));
33
+ resolve({
34
+ url: `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`,
35
+ port,
36
+ close,
37
+ [Symbol.asyncDispose]: close
38
+ });
39
+ });
40
+ });
41
+ }
42
+ /** The handler of a path, or `undefined` when no prefix matches. */
43
+ function router(handler) {
44
+ if (typeof handler === "function") return () => handler;
45
+ const prefixes = Object.keys(handler).sort((a, b) => b.length - a.length);
46
+ const matches = (path, prefix) => prefix.endsWith("/") ? path.startsWith(prefix) || path === prefix.slice(0, -1) : path === prefix || path.startsWith(`${prefix}/`);
47
+ return (path) => {
48
+ const prefix = prefixes.find((candidate) => matches(path, candidate));
49
+ return prefix === void 0 ? void 0 : handler[prefix];
50
+ };
51
+ }
52
+ async function answer(incoming, outgoing, route) {
53
+ const abort = new AbortController();
54
+ outgoing.on("close", () => {
55
+ if (!outgoing.writableFinished) abort.abort();
56
+ });
57
+ let request;
58
+ try {
59
+ request = requestOf(incoming, abort.signal);
60
+ } catch {
61
+ outgoing.writeHead(400).end();
62
+ return;
63
+ }
64
+ const handler = route(new URL(request.url).pathname);
65
+ if (handler === void 0) {
66
+ outgoing.writeHead(404).end();
67
+ return;
68
+ }
69
+ try {
70
+ await send(await handler(request), outgoing);
71
+ } catch {
72
+ if (outgoing.headersSent) outgoing.destroy();
73
+ else outgoing.writeHead(500).end();
74
+ }
75
+ }
76
+ /** The web request of an incoming request. It throws when the request has no valid URL. */
77
+ function requestOf(incoming, signal) {
78
+ const url = new URL(incoming.url ?? "/", `http://${incoming.headers.host ?? "localhost"}`);
79
+ const headers = new Headers();
80
+ for (const [name, value] of Object.entries(incoming.headers)) if (Array.isArray(value)) value.forEach((item) => headers.append(name, item));
81
+ else if (value !== void 0) headers.set(name, value);
82
+ const method = incoming.method ?? "GET";
83
+ const init = {
84
+ method,
85
+ headers,
86
+ signal
87
+ };
88
+ if (method === "GET" || method === "HEAD") return new Request(url, init);
89
+ const withBody = {
90
+ ...init,
91
+ body: bodyOf(incoming),
92
+ duplex: "half"
93
+ };
94
+ return new Request(url, withBody);
95
+ }
96
+ /**
97
+ * Write a web response. The body waits when the connection is full. When the client closes the connection, the body
98
+ * is cancelled.
99
+ */
100
+ async function send(response, outgoing) {
101
+ const headers = {};
102
+ for (const [name, value] of response.headers) if (name !== "set-cookie") headers[name] = value;
103
+ const cookies = response.headers.getSetCookie();
104
+ if (cookies.length > 0) headers["set-cookie"] = cookies;
105
+ outgoing.writeHead(response.status, response.statusText, headers);
106
+ if (response.body === null) {
107
+ outgoing.end();
108
+ return;
109
+ }
110
+ const reader = response.body.getReader();
111
+ const cancel = () => void reader.cancel().catch(() => void 0);
112
+ outgoing.on("close", cancel);
113
+ try {
114
+ for (;;) {
115
+ const chunk = await reader.read();
116
+ if (chunk.done) break;
117
+ if (outgoing.destroyed) return;
118
+ if (!outgoing.write(chunk.value)) await drained(outgoing);
119
+ }
120
+ outgoing.end();
121
+ } finally {
122
+ outgoing.off("close", cancel);
123
+ }
124
+ }
125
+ /** Resolve when the connection can take more data, or when it closes. */
126
+ function drained(outgoing) {
127
+ return new Promise((resolve) => {
128
+ const done = () => {
129
+ outgoing.off("drain", done);
130
+ outgoing.off("close", done);
131
+ resolve();
132
+ };
133
+ outgoing.on("drain", done);
134
+ outgoing.on("close", done);
135
+ });
136
+ }
137
+ /**
138
+ * The body of an incoming request, as a web stream. The stream reads the request only when the handler reads the body,
139
+ * and the request pauses after each chunk until the handler reads again. A request that the handler does not read stays
140
+ * unread, so that Node.js discards it after the response. When the handler cancels the stream, the request discards the
141
+ * rest of its body.
142
+ */
143
+ function bodyOf(incoming) {
144
+ let open = true;
145
+ let reading = false;
146
+ const close = (end) => {
147
+ if (open) end();
148
+ open = false;
149
+ };
150
+ return new ReadableStream({
151
+ pull(controller) {
152
+ if (!reading) {
153
+ reading = true;
154
+ incoming.on("data", (chunk) => {
155
+ if (!open) return;
156
+ if (!(chunk instanceof Uint8Array)) return close(() => controller.error(/* @__PURE__ */ new TypeError("a chunk of the request body is not bytes")));
157
+ controller.enqueue(chunk);
158
+ incoming.pause();
159
+ });
160
+ incoming.on("end", () => close(() => controller.close()));
161
+ incoming.on("error", (error) => close(() => controller.error(error)));
162
+ }
163
+ incoming.resume();
164
+ },
165
+ cancel() {
166
+ open = false;
167
+ incoming.resume();
168
+ }
169
+ }, { highWaterMark: 0 });
170
+ }
171
+ //#endregion
172
+ export { serve };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acid-cat/core-ts",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Serve AcidCat capabilities in plain TypeScript, with no Effect dependency.",
5
5
  "keywords": [
6
6
  "acidcat",
@@ -26,17 +26,21 @@
26
26
  ".": {
27
27
  "types": "./dist/index.d.ts",
28
28
  "default": "./dist/index.js"
29
+ },
30
+ "./node": {
31
+ "types": "./dist/node.d.ts",
32
+ "default": "./dist/node.js"
29
33
  }
30
34
  },
31
35
  "publishConfig": {
32
36
  "access": "public"
33
37
  },
34
38
  "dependencies": {
35
- "@acid-cat/catalog": "0.7.0",
36
- "@acid-cat/spec-ts": "0.7.0"
39
+ "@acid-cat/catalog": "0.8.0",
40
+ "@acid-cat/spec-ts": "0.8.0"
37
41
  },
38
42
  "devDependencies": {
39
- "@acid-cat/cli": "0.7.0",
43
+ "@acid-cat/cli": "0.8.0",
40
44
  "@types/node": "26.6.5",
41
45
  "esbuild": "0.28.2",
42
46
  "playwright": "1.64.0",