@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 +40 -0
- package/dist/cli.js +14 -7
- package/dist/node.d.ts +39 -0
- package/dist/node.js +172 -0
- package/package.json +8 -4
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,
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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.
|
|
36
|
-
"@acid-cat/spec-ts": "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.
|
|
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",
|