@askrjs/node 0.0.7 → 0.0.9
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 +36 -5
- package/dist/index.d.ts +6 -5
- package/dist/index.js +379 -96
- package/dist/mcp.js +116 -60
- package/package.json +17 -14
package/README.md
CHANGED
|
@@ -28,7 +28,14 @@ createServer(createNodeHandler(app, { baseUrl: "http://localhost:3000" })).liste
|
|
|
28
28
|
|
|
29
29
|
`createNodeHandler` also works as Connect middleware because it accepts an optional `next`
|
|
30
30
|
callback. It preserves streaming bodies, repeated `Set-Cookie` headers, aborts, backpressure,
|
|
31
|
-
status text, and HEAD responses.
|
|
31
|
+
status text, and HEAD responses. `next` receives adapter failures; application responses, including
|
|
32
|
+
`404`, remain owned by the `ServerApp` and do not fall through.
|
|
33
|
+
|
|
34
|
+
Every handler must have a trusted URL boundary. Pass `baseUrl` when the external origin is fixed,
|
|
35
|
+
or `allowedHosts` when the request `Host` determines the origin. Host names are canonicalized and
|
|
36
|
+
compared case-insensitively; entries without a port allow that host on any port, while entries with
|
|
37
|
+
a port require that exact authority. Absolute-form request targets must retain the trusted origin,
|
|
38
|
+
and ambiguous network-path targets are rejected.
|
|
32
39
|
|
|
33
40
|
## Listen directly
|
|
34
41
|
|
|
@@ -48,8 +55,12 @@ Pass an `AbortSignal` to integrate shutdown with your process lifecycle.
|
|
|
48
55
|
|
|
49
56
|
Enable the built-in `ws` transport with `websocket: true`. It defaults to a
|
|
50
57
|
1 MiB maximum message payload with compression disabled; pass
|
|
51
|
-
`websocket: { maxPayload, maxRejectionBodyBytes, perMessageDeflate }` to override
|
|
52
|
-
those settings. Rejected upgrade bodies are capped at 64 KiB by default.
|
|
58
|
+
`websocket: { closeTimeout, maxPayload, maxRejectionBodyBytes, perMessageDeflate }` to override
|
|
59
|
+
those settings. Rejected upgrade bodies are capped at 64 KiB by default. Shutdown gives clients five
|
|
60
|
+
seconds to complete the close handshake by default, then terminates any remaining connections.
|
|
61
|
+
Upgrades require an `Origin`. The request origin is allowed by default; set
|
|
62
|
+
`websocket.allowedOrigins` to a canonical allowlist when trusted browser origins differ from the
|
|
63
|
+
application origin.
|
|
53
64
|
|
|
54
65
|
```ts
|
|
55
66
|
router.ws("/echo", (socket) => {
|
|
@@ -77,8 +88,26 @@ await running.close();
|
|
|
77
88
|
```
|
|
78
89
|
|
|
79
90
|
`serve` handles static assets and closes both the HTTP server and the application during shutdown.
|
|
91
|
+
When an asset root is configured, extension-bearing `GET` and `HEAD` paths are reserved for static
|
|
92
|
+
files: missing files return `404` without falling through to application routing, source maps are not
|
|
93
|
+
served, and resolved files must remain inside the configured root. Fingerprinted files under
|
|
94
|
+
`/assets/` receive immutable caching; other files receive `no-cache`.
|
|
80
95
|
Both `listen` and `serve` bind to `127.0.0.1` by default. A non-loopback
|
|
81
|
-
`host` also requires `allowPublicBind: true` so public exposure is explicit.
|
|
96
|
+
`host` also requires `allowPublicBind: true` so public exposure is explicit. Public listeners should
|
|
97
|
+
declare every external host name through `allowedHosts`; the bind host and `localhost` remain allowed
|
|
98
|
+
automatically:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
await serve(app, {
|
|
102
|
+
host: "0.0.0.0",
|
|
103
|
+
allowPublicBind: true,
|
|
104
|
+
allowedHosts: ["app.example.com"],
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`listen` and `serve` fail before binding when their `AbortSignal` is already aborted. Later aborts
|
|
109
|
+
start shutdown. `serve().close()` is idempotent, waits for HTTP and WebSocket closure, and then closes
|
|
110
|
+
the application exactly once.
|
|
82
111
|
|
|
83
112
|
## MCP over stdio
|
|
84
113
|
|
|
@@ -90,4 +119,6 @@ await connection.closed;
|
|
|
90
119
|
```
|
|
91
120
|
|
|
92
121
|
Protocol messages use stdin/stdout; diagnostics remain isolated on stderr. Authentication may be
|
|
93
|
-
provided directly or resolved from the process environment for each message.
|
|
122
|
+
provided directly or resolved from the process environment for each message. Closing stdin, calling
|
|
123
|
+
`connection.close()`, or aborting its signal detaches the transport, cancels active requests,
|
|
124
|
+
terminates the MCP session, and prevents late protocol output.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
|
+
import { AddressInfo } from "node:net";
|
|
1
2
|
import { IncomingMessage, Server, ServerResponse } from "node:http";
|
|
2
3
|
import { PerMessageDeflateOptions } from "ws";
|
|
3
4
|
import { ServerApp } from "@askrjs/server";
|
|
4
|
-
import { AddressInfo } from "node:net";
|
|
5
5
|
//#region src/contracts.d.ts
|
|
6
6
|
interface NodeWebSocketOptions {
|
|
7
|
+
readonly closeTimeout?: number;
|
|
7
8
|
readonly maxPayload?: number;
|
|
8
9
|
readonly maxRejectionBodyBytes?: number;
|
|
9
10
|
readonly perMessageDeflate?: boolean | PerMessageDeflateOptions;
|
|
10
11
|
readonly allowedOrigins?: readonly string[];
|
|
11
12
|
}
|
|
12
13
|
interface NodeHandlerOptions {
|
|
13
|
-
baseUrl?: string;
|
|
14
|
-
allowedHosts?: readonly string[];
|
|
14
|
+
readonly baseUrl?: string;
|
|
15
|
+
readonly allowedHosts?: readonly string[];
|
|
15
16
|
}
|
|
16
|
-
interface ListenOptions {
|
|
17
|
+
interface ListenOptions extends NodeHandlerOptions {
|
|
17
18
|
port?: number;
|
|
18
19
|
host?: string;
|
|
19
20
|
allowPublicBind?: boolean;
|
|
@@ -39,7 +40,7 @@ type ConnectNext = (error?: unknown) => void;
|
|
|
39
40
|
type NodeHandler = (request: IncomingMessage, response: ServerResponse, next?: ConnectNext) => void;
|
|
40
41
|
//#endregion
|
|
41
42
|
//#region src/handler.d.ts
|
|
42
|
-
declare function createNodeHandler(app: ServerApp, options
|
|
43
|
+
declare function createNodeHandler(app: ServerApp, options: NodeHandlerOptions): NodeHandler;
|
|
43
44
|
//#endregion
|
|
44
45
|
//#region src/listen.d.ts
|
|
45
46
|
type ListeningServer = Server & {
|
package/dist/index.js
CHANGED
|
@@ -1,41 +1,133 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { addAbortListener } from "node:events";
|
|
2
3
|
import { createServer } from "node:http";
|
|
3
|
-
import { WebSocketServer } from "ws";
|
|
4
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
4
5
|
import { createReadStream } from "node:fs";
|
|
5
6
|
import { realpath, stat } from "node:fs/promises";
|
|
6
7
|
import { extname, resolve, sep } from "node:path";
|
|
8
|
+
import { pipeline } from "node:stream";
|
|
7
9
|
//#region src/request.ts
|
|
8
10
|
var NodeRequestError = class extends TypeError {};
|
|
11
|
+
function explicitPort(authority) {
|
|
12
|
+
if (authority.startsWith("[")) {
|
|
13
|
+
const bracket = authority.indexOf("]");
|
|
14
|
+
return bracket >= 0 && authority[bracket + 1] === ":" ? authority.slice(bracket + 2) : void 0;
|
|
15
|
+
}
|
|
16
|
+
const first = authority.indexOf(":");
|
|
17
|
+
return first >= 0 && first === authority.lastIndexOf(":") ? authority.slice(first + 1) : void 0;
|
|
18
|
+
}
|
|
19
|
+
function parseAuthority(value, allowBareIpv6 = false) {
|
|
20
|
+
if (!value || value.trim() !== value) throw new NodeRequestError("Request host must be a non-empty HTTP authority.");
|
|
21
|
+
const bareIpv6 = isIP(value) === 6;
|
|
22
|
+
if (bareIpv6 && !allowBareIpv6) throw new NodeRequestError("IPv6 request hosts must use brackets.");
|
|
23
|
+
const authority = bareIpv6 ? `[${value}]` : value;
|
|
24
|
+
const port = explicitPort(authority);
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = new URL(`http://${authority}`);
|
|
28
|
+
} catch {
|
|
29
|
+
throw new NodeRequestError("Request host is not a valid HTTP authority.");
|
|
30
|
+
}
|
|
31
|
+
if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash || port !== void 0 && !/^\d+$/u.test(port)) throw new NodeRequestError("Request host is not a valid HTTP authority.");
|
|
32
|
+
return {
|
|
33
|
+
hostname: parsed.hostname,
|
|
34
|
+
host: port === void 0 ? parsed.hostname : `${parsed.hostname}:${Number(port)}`
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function prepareBaseUrl(value) {
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = new URL(value);
|
|
41
|
+
} catch {
|
|
42
|
+
throw new TypeError("Node handler baseUrl must be a valid HTTP or HTTPS origin.");
|
|
43
|
+
}
|
|
44
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) throw new TypeError("Node handler baseUrl must be an HTTP or HTTPS origin.");
|
|
45
|
+
return parsed.origin;
|
|
46
|
+
}
|
|
47
|
+
function prepareNodeHandlerOptions(options) {
|
|
48
|
+
const allowedHosts = (options.allowedHosts ?? []).map((value) => {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = parseAuthority(value, true);
|
|
51
|
+
return explicitPort(isIP(value) === 6 ? `[${value}]` : value) === void 0 ? { hostname: parsed.hostname } : parsed;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new TypeError(`Node handler allowedHosts contains an invalid host: ${error instanceof Error ? error.message : String(error)}`);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
const baseUrl = options.baseUrl === void 0 ? void 0 : prepareBaseUrl(options.baseUrl);
|
|
57
|
+
if (!baseUrl && allowedHosts.length === 0) throw new TypeError("Node handling requires baseUrl or an allowedHosts allowlist.");
|
|
58
|
+
return {
|
|
59
|
+
allowedHosts,
|
|
60
|
+
baseUrl,
|
|
61
|
+
hostCache: {}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
9
64
|
function requestHeaders(request) {
|
|
10
65
|
const headers = new Headers();
|
|
11
|
-
for (const
|
|
66
|
+
for (const key in request.headers) {
|
|
67
|
+
const value = request.headers[key];
|
|
12
68
|
if (value === void 0) continue;
|
|
13
|
-
|
|
69
|
+
if (Array.isArray(value)) for (const item of value) headers.append(key, item);
|
|
70
|
+
else headers.set(key, value);
|
|
14
71
|
}
|
|
15
72
|
return headers;
|
|
16
73
|
}
|
|
17
|
-
function
|
|
74
|
+
function resolveNodeRequestHref(request, options) {
|
|
18
75
|
const host = request.headers.host;
|
|
19
|
-
if (!options.baseUrl && !options.allowedHosts?.length) throw new NodeRequestError("Node handling requires baseUrl or an allowedHosts allowlist.");
|
|
20
76
|
if (!host) throw new NodeRequestError("Request Host header is required.");
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
77
|
+
let base = options.hostCache.value === host ? options.hostCache.base : void 0;
|
|
78
|
+
if (!base) {
|
|
79
|
+
const authority = parseAuthority(host);
|
|
80
|
+
if (options.allowedHosts.length > 0 && !options.allowedHosts.some((allowed) => allowed.host === void 0 ? allowed.hostname === authority.hostname : allowed.host === authority.host)) throw new NodeRequestError("Request Host header is not allowed.");
|
|
81
|
+
base = options.baseUrl ?? `http://${authority.host}`;
|
|
82
|
+
options.hostCache.value = host;
|
|
83
|
+
options.hostCache.base = base;
|
|
84
|
+
}
|
|
85
|
+
const requestTarget = request.url || "/";
|
|
86
|
+
if (requestTarget === "*") {
|
|
87
|
+
if (request.method === "OPTIONS") return `${base}/*`;
|
|
88
|
+
throw new NodeRequestError("Asterisk-form request targets require OPTIONS.");
|
|
89
|
+
}
|
|
90
|
+
if (requestTarget.startsWith("//")) throw new NodeRequestError("Network-path request targets are not allowed.");
|
|
91
|
+
if (requestTarget.startsWith("/")) {
|
|
92
|
+
if (requestTarget.includes("\\")) throw new NodeRequestError("Request targets must not contain backslashes.");
|
|
93
|
+
if (requestTarget.includes("#")) throw new NodeRequestError("Request targets must not contain fragments.");
|
|
94
|
+
return `${base}${requestTarget}`;
|
|
95
|
+
}
|
|
96
|
+
if (!/^https?:\/\//iu.test(requestTarget)) throw new NodeRequestError("Request target must use origin-form or absolute-form.");
|
|
97
|
+
let target;
|
|
98
|
+
try {
|
|
99
|
+
target = new URL(requestTarget, base);
|
|
100
|
+
} catch {
|
|
101
|
+
throw new NodeRequestError("Request target is not a valid HTTP URL.");
|
|
102
|
+
}
|
|
103
|
+
if (target.origin !== new URL(base).origin) throw new NodeRequestError("Absolute-form request target origin is not allowed.");
|
|
104
|
+
if (target.hash) throw new NodeRequestError("Request targets must not contain fragments.");
|
|
105
|
+
return target.href;
|
|
106
|
+
}
|
|
107
|
+
function resolveNodeRequestUrl(request, options) {
|
|
108
|
+
try {
|
|
109
|
+
return new URL(resolveNodeRequestHref(request, options));
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error instanceof NodeRequestError) throw error;
|
|
112
|
+
throw new NodeRequestError("Request target is not a valid HTTP URL.");
|
|
113
|
+
}
|
|
27
114
|
}
|
|
28
115
|
function requestFromNode(request, options, signal) {
|
|
29
|
-
const target =
|
|
116
|
+
const target = resolveNodeRequestHref(request, options);
|
|
30
117
|
const method = request.method ?? "GET";
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
118
|
+
if (method === "GET" || method === "HEAD") request.resume();
|
|
119
|
+
const body = method === "GET" || method === "HEAD" ? void 0 : request;
|
|
120
|
+
try {
|
|
121
|
+
return new Request(target, {
|
|
122
|
+
method,
|
|
123
|
+
headers: requestHeaders(request),
|
|
124
|
+
body,
|
|
125
|
+
signal,
|
|
126
|
+
...body ? { duplex: "half" } : {}
|
|
127
|
+
});
|
|
128
|
+
} catch {
|
|
129
|
+
throw new NodeRequestError("Node request cannot be represented as a Web Request.");
|
|
130
|
+
}
|
|
39
131
|
}
|
|
40
132
|
//#endregion
|
|
41
133
|
//#region src/response.ts
|
|
@@ -79,7 +171,12 @@ async function writeNodeResponse(response, target, method) {
|
|
|
79
171
|
target.statusCode = response.status;
|
|
80
172
|
target.statusMessage = response.statusText;
|
|
81
173
|
writeHeaders(response, target);
|
|
82
|
-
if (
|
|
174
|
+
if (method === "HEAD") {
|
|
175
|
+
response.body?.cancel("HEAD responses do not include a body").catch(() => void 0);
|
|
176
|
+
target.end();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!response.body) {
|
|
83
180
|
target.end();
|
|
84
181
|
return;
|
|
85
182
|
}
|
|
@@ -95,7 +192,7 @@ async function writeNodeResponse(response, target, method) {
|
|
|
95
192
|
if (disconnected) return;
|
|
96
193
|
const part = await reader.read();
|
|
97
194
|
if (part.done || disconnected) break;
|
|
98
|
-
if (!target.write(
|
|
195
|
+
if (!target.write(part.value)) await waitForDrain(target);
|
|
99
196
|
}
|
|
100
197
|
if (!disconnected) target.end();
|
|
101
198
|
} catch (error) {
|
|
@@ -104,20 +201,25 @@ async function writeNodeResponse(response, target, method) {
|
|
|
104
201
|
target.destroy(error instanceof Error ? error : void 0);
|
|
105
202
|
} finally {
|
|
106
203
|
target.off("close", onClose);
|
|
204
|
+
reader.releaseLock();
|
|
107
205
|
}
|
|
108
206
|
}
|
|
109
207
|
//#endregion
|
|
110
208
|
//#region src/handler.ts
|
|
111
209
|
function minimalError(response, error) {
|
|
112
|
-
if (response.headersSent) {
|
|
210
|
+
if (response.headersSent || response.destroyed) {
|
|
113
211
|
response.destroy(error instanceof Error ? error : void 0);
|
|
114
212
|
return;
|
|
115
213
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
214
|
+
try {
|
|
215
|
+
const clientError = error instanceof NodeRequestError;
|
|
216
|
+
response.statusCode = clientError ? 400 : 500;
|
|
217
|
+
response.statusMessage = clientError ? "Bad Request" : "Internal Server Error";
|
|
218
|
+
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
219
|
+
response.end(clientError ? "Bad Request" : "Internal Server Error");
|
|
220
|
+
} catch {
|
|
221
|
+
response.destroy(error instanceof Error ? error : void 0);
|
|
222
|
+
}
|
|
121
223
|
}
|
|
122
224
|
function attachAbort(request, response, controller) {
|
|
123
225
|
const abort = () => controller.abort();
|
|
@@ -135,68 +237,136 @@ function attachAbort(request, response, controller) {
|
|
|
135
237
|
response.off("close", abort);
|
|
136
238
|
};
|
|
137
239
|
}
|
|
138
|
-
function
|
|
240
|
+
async function handleNodeRequest(app, preparedOptions, request, response, signal, cleanup, next) {
|
|
241
|
+
try {
|
|
242
|
+
const webRequest = requestFromNode(request, preparedOptions, signal);
|
|
243
|
+
await writeNodeResponse(await app.fetch(webRequest), response, request.method);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (!next) {
|
|
246
|
+
minimalError(response, error);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
next(error);
|
|
251
|
+
} catch (nextError) {
|
|
252
|
+
minimalError(response, nextError);
|
|
253
|
+
}
|
|
254
|
+
} finally {
|
|
255
|
+
cleanup();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function createNodeHandler(app, options) {
|
|
259
|
+
const preparedOptions = prepareNodeHandlerOptions(options);
|
|
139
260
|
return (request, response, next) => {
|
|
140
261
|
const controller = new AbortController();
|
|
141
262
|
const cleanup = attachAbort(request, response, controller);
|
|
142
|
-
|
|
263
|
+
handleNodeRequest(app, preparedOptions, request, response, controller.signal, cleanup, next);
|
|
143
264
|
};
|
|
144
265
|
}
|
|
145
266
|
//#endregion
|
|
146
267
|
//#region src/bind.ts
|
|
268
|
+
function isLoopbackHost(host) {
|
|
269
|
+
if (host.toLowerCase() === "localhost") return true;
|
|
270
|
+
const family = isIP(host);
|
|
271
|
+
if (family === 4) return host.split(".", 1)[0] === "127";
|
|
272
|
+
if (family !== 6) return false;
|
|
273
|
+
const normalized = new URL(`http://[${host}]`).hostname;
|
|
274
|
+
return normalized === "[::1]" || normalized.startsWith("[::ffff:7f");
|
|
275
|
+
}
|
|
147
276
|
function resolveBindHost(options) {
|
|
148
277
|
const host = options.host ?? "127.0.0.1";
|
|
149
|
-
if (!(host
|
|
278
|
+
if (!isLoopbackHost(host) && options.allowPublicBind !== true) throw new TypeError(`Refusing to bind non-loopback host ${host} without allowPublicBind: true.`);
|
|
150
279
|
return host;
|
|
151
280
|
}
|
|
281
|
+
function handlerOptionsForHost(options, host) {
|
|
282
|
+
return {
|
|
283
|
+
...options.baseUrl === void 0 ? {} : { baseUrl: options.baseUrl },
|
|
284
|
+
allowedHosts: [.../* @__PURE__ */ new Set([
|
|
285
|
+
host,
|
|
286
|
+
"localhost",
|
|
287
|
+
...options.allowedHosts ?? []
|
|
288
|
+
])]
|
|
289
|
+
};
|
|
290
|
+
}
|
|
152
291
|
function formatHostForUrl(host) {
|
|
153
292
|
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
154
293
|
}
|
|
155
294
|
//#endregion
|
|
295
|
+
//#region src/server-options.ts
|
|
296
|
+
function timeout(value, name) {
|
|
297
|
+
if (value === void 0) return void 0;
|
|
298
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} must be a non-negative safe integer.`);
|
|
299
|
+
return value;
|
|
300
|
+
}
|
|
301
|
+
function applyServerTimeouts(server, options) {
|
|
302
|
+
const requestTimeout = timeout(options.requestTimeout, "requestTimeout");
|
|
303
|
+
const headersTimeout = timeout(options.headersTimeout, "headersTimeout");
|
|
304
|
+
const keepAliveTimeout = timeout(options.keepAliveTimeout, "keepAliveTimeout");
|
|
305
|
+
if (requestTimeout !== void 0) server.requestTimeout = requestTimeout;
|
|
306
|
+
if (headersTimeout !== void 0) server.headersTimeout = headersTimeout;
|
|
307
|
+
if (keepAliveTimeout !== void 0) server.keepAliveTimeout = keepAliveTimeout;
|
|
308
|
+
}
|
|
309
|
+
//#endregion
|
|
156
310
|
//#region src/websocket.ts
|
|
157
311
|
function subscribe(socket, event, listener) {
|
|
158
312
|
socket.on(event, listener);
|
|
159
313
|
return () => socket.off(event, listener);
|
|
160
314
|
}
|
|
315
|
+
function notify(socket, listener, ...values) {
|
|
316
|
+
try {
|
|
317
|
+
listener(...values);
|
|
318
|
+
} catch {
|
|
319
|
+
if (socket.readyState === WebSocket.OPEN) socket.close(1011, "WebSocket listener failed");
|
|
320
|
+
}
|
|
321
|
+
}
|
|
161
322
|
function socketLike(socket) {
|
|
162
323
|
return {
|
|
163
324
|
send: (data) => socket.send(data),
|
|
164
325
|
close: (code, reason) => socket.close(code, reason),
|
|
165
326
|
onMessage: (listener) => subscribe(socket, "message", (data, binary) => {
|
|
166
|
-
if (!binary) listener
|
|
327
|
+
if (!binary) notify(socket, listener, data.toString());
|
|
167
328
|
else {
|
|
168
329
|
const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
|
|
169
|
-
listener
|
|
330
|
+
notify(socket, listener, new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength));
|
|
170
331
|
}
|
|
171
332
|
}),
|
|
172
|
-
onClose: (listener) => subscribe(socket, "close", (code, reason) => listener
|
|
333
|
+
onClose: (listener) => subscribe(socket, "close", (code, reason) => notify(socket, listener, {
|
|
173
334
|
code,
|
|
174
335
|
reason: reason.toString(),
|
|
175
|
-
wasClean: code
|
|
336
|
+
wasClean: code !== 1006
|
|
176
337
|
})),
|
|
177
|
-
onError: (listener) => subscribe(socket, "error", listener)
|
|
338
|
+
onError: (listener) => subscribe(socket, "error", (error) => notify(socket, listener, error))
|
|
178
339
|
};
|
|
179
340
|
}
|
|
341
|
+
function normalizeHttpOrigin(value) {
|
|
342
|
+
const parsed = new URL(value);
|
|
343
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) throw new TypeError("WebSocket origins must be HTTP or HTTPS origins.");
|
|
344
|
+
return parsed.origin;
|
|
345
|
+
}
|
|
180
346
|
async function readRejectionBody(response, maxBytes) {
|
|
181
347
|
const lengthHeader = response.headers.get("content-length");
|
|
182
348
|
const declaredLength = lengthHeader === null ? void 0 : Number(lengthHeader);
|
|
183
349
|
if (declaredLength !== void 0 && Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
184
|
-
await response.body?.cancel("WebSocket rejection body exceeded maxRejectionBodyBytes");
|
|
350
|
+
await response.body?.cancel("WebSocket rejection body exceeded maxRejectionBodyBytes").catch(() => void 0);
|
|
185
351
|
return;
|
|
186
352
|
}
|
|
187
353
|
if (!response.body) return Buffer.alloc(0);
|
|
188
354
|
const reader = response.body.getReader();
|
|
189
355
|
const chunks = [];
|
|
190
356
|
let length = 0;
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
357
|
+
try {
|
|
358
|
+
while (true) {
|
|
359
|
+
const part = await reader.read();
|
|
360
|
+
if (part.done) return Buffer.concat(chunks, length);
|
|
361
|
+
length += part.value.byteLength;
|
|
362
|
+
if (length > maxBytes) {
|
|
363
|
+
await reader.cancel("WebSocket rejection body exceeded maxRejectionBodyBytes").catch(() => void 0);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
chunks.push(Buffer.from(part.value));
|
|
198
367
|
}
|
|
199
|
-
|
|
368
|
+
} finally {
|
|
369
|
+
reader.releaseLock();
|
|
200
370
|
}
|
|
201
371
|
}
|
|
202
372
|
async function rejectUpgrade(socket, response, maxBodyBytes) {
|
|
@@ -204,31 +374,64 @@ async function rejectUpgrade(socket, response, maxBodyBytes) {
|
|
|
204
374
|
if (!buffered) response = new Response("WebSocket rejection body exceeded configured limit", { status: 500 });
|
|
205
375
|
const body = buffered ?? Buffer.from(await response.arrayBuffer());
|
|
206
376
|
const lines = [`HTTP/1.1 ${response.status} ${response.statusText || "Rejected"}`];
|
|
377
|
+
const cookies = response.headers.getSetCookie();
|
|
207
378
|
response.headers.forEach((value, name) => {
|
|
208
|
-
if (name !== "content-length" && name !== "transfer-encoding") lines.push(`${name}: ${value}`);
|
|
379
|
+
if (name !== "connection" && name !== "content-length" && name !== "set-cookie" && name !== "transfer-encoding" && name !== "upgrade") lines.push(`${name}: ${value}`);
|
|
209
380
|
});
|
|
381
|
+
for (const cookie of cookies) lines.push(`set-cookie: ${cookie}`);
|
|
210
382
|
lines.push(`content-length: ${body.byteLength}`);
|
|
211
383
|
lines.push("connection: close", "", "");
|
|
212
384
|
socket.end(Buffer.concat([Buffer.from(lines.join("\r\n")), body]));
|
|
213
385
|
}
|
|
214
|
-
function installWebSockets(server, app, options
|
|
386
|
+
function installWebSockets(server, app, options, handlerOptions) {
|
|
387
|
+
const closeTimeout = options.closeTimeout ?? 5e3;
|
|
388
|
+
if (!Number.isSafeInteger(closeTimeout) || closeTimeout < 0) throw new TypeError("WebSocket closeTimeout must be a non-negative safe integer.");
|
|
215
389
|
const maxRejectionBodyBytes = options.maxRejectionBodyBytes ?? 65536;
|
|
216
|
-
if (!Number.
|
|
390
|
+
if (!Number.isSafeInteger(maxRejectionBodyBytes) || maxRejectionBodyBytes <= 0) throw new TypeError("WebSocket maxRejectionBodyBytes must be a positive integer.");
|
|
391
|
+
const maxPayload = options.maxPayload ?? 1048576;
|
|
392
|
+
if (!Number.isSafeInteger(maxPayload) || maxPayload < 0) throw new TypeError("WebSocket maxPayload must be a non-negative safe integer.");
|
|
393
|
+
const preparedHandlerOptions = prepareNodeHandlerOptions(handlerOptions);
|
|
394
|
+
const configuredAllowedOrigins = options.allowedOrigins?.map((origin) => {
|
|
395
|
+
try {
|
|
396
|
+
return normalizeHttpOrigin(origin);
|
|
397
|
+
} catch {
|
|
398
|
+
throw new TypeError(`WebSocket allowedOrigins contains an invalid origin: ${origin}`);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
217
401
|
const webSockets = new WebSocketServer({
|
|
218
402
|
noServer: true,
|
|
219
|
-
maxPayload
|
|
403
|
+
maxPayload,
|
|
220
404
|
perMessageDeflate: options.perMessageDeflate ?? false
|
|
221
405
|
});
|
|
222
|
-
|
|
406
|
+
const pending = /* @__PURE__ */ new Map();
|
|
407
|
+
let closing;
|
|
408
|
+
const onUpgrade = (request, socket, head) => {
|
|
409
|
+
if (closing) {
|
|
410
|
+
socket.destroy();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
223
413
|
const controller = new AbortController();
|
|
414
|
+
const abort = () => controller.abort();
|
|
415
|
+
const cleanupPending = () => {
|
|
416
|
+
pending.delete(socket);
|
|
417
|
+
socket.off("close", abort);
|
|
418
|
+
socket.off("end", abort);
|
|
419
|
+
socket.off("error", abort);
|
|
420
|
+
};
|
|
421
|
+
pending.set(socket, controller);
|
|
422
|
+
socket.once("close", abort);
|
|
423
|
+
socket.once("end", abort);
|
|
424
|
+
socket.once("error", abort);
|
|
224
425
|
let accepted;
|
|
225
|
-
|
|
426
|
+
let upgraded = false;
|
|
427
|
+
Promise.resolve().then(() => requestFromNode(request, preparedHandlerOptions, controller.signal)).then(async (webRequest) => {
|
|
428
|
+
if (controller.signal.aborted) return;
|
|
226
429
|
const originHeader = request.headers.origin;
|
|
227
430
|
const origin = Array.isArray(originHeader) ? void 0 : originHeader;
|
|
228
|
-
const allowedOrigins =
|
|
431
|
+
const allowedOrigins = configuredAllowedOrigins ?? [new URL(webRequest.url).origin];
|
|
229
432
|
let normalizedOrigin;
|
|
230
433
|
try {
|
|
231
|
-
normalizedOrigin = origin ?
|
|
434
|
+
normalizedOrigin = origin ? normalizeHttpOrigin(origin) : void 0;
|
|
232
435
|
} catch {
|
|
233
436
|
normalizedOrigin = void 0;
|
|
234
437
|
}
|
|
@@ -251,44 +454,92 @@ function installWebSockets(server, app, options = {}, handlerOptions = {}) {
|
|
|
251
454
|
return;
|
|
252
455
|
}
|
|
253
456
|
webSockets.handleUpgrade(request, socket, head, (webSocket) => {
|
|
457
|
+
upgraded = true;
|
|
458
|
+
cleanupPending();
|
|
459
|
+
webSocket.on("error", () => void 0);
|
|
254
460
|
webSocket.once("close", () => controller.abort());
|
|
255
|
-
Promise.resolve(accepted.handler(socketLike(webSocket), accepted.context)).catch(() => webSocket.close(1011, "WebSocket handler failed"));
|
|
461
|
+
Promise.resolve().then(() => accepted.handler(socketLike(webSocket), accepted.context)).catch(() => webSocket.close(1011, "WebSocket handler failed"));
|
|
256
462
|
});
|
|
257
|
-
}).catch(() => socket.destroy())
|
|
258
|
-
|
|
463
|
+
}).catch(() => socket.destroy()).finally(() => {
|
|
464
|
+
if (!upgraded) {
|
|
465
|
+
cleanupPending();
|
|
466
|
+
controller.abort();
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
};
|
|
470
|
+
server.on("upgrade", onUpgrade);
|
|
259
471
|
return { close() {
|
|
260
|
-
|
|
261
|
-
|
|
472
|
+
return closing ??= new Promise((resolve, reject) => {
|
|
473
|
+
let settled = false;
|
|
474
|
+
let forceClose;
|
|
475
|
+
const complete = (error) => {
|
|
476
|
+
if (settled) return;
|
|
477
|
+
settled = true;
|
|
478
|
+
if (forceClose) clearTimeout(forceClose);
|
|
479
|
+
if (error) reject(error);
|
|
480
|
+
else resolve();
|
|
481
|
+
};
|
|
482
|
+
server.off("upgrade", onUpgrade);
|
|
483
|
+
for (const [socket, controller] of pending) {
|
|
484
|
+
controller.abort();
|
|
485
|
+
socket.destroy();
|
|
486
|
+
}
|
|
487
|
+
pending.clear();
|
|
488
|
+
for (const socket of webSockets.clients) socket.close(1001, "Server shutting down");
|
|
489
|
+
if (webSockets.clients.size > 0) {
|
|
490
|
+
forceClose = setTimeout(() => {
|
|
491
|
+
for (const socket of webSockets.clients) socket.terminate();
|
|
492
|
+
complete();
|
|
493
|
+
}, closeTimeout);
|
|
494
|
+
forceClose.unref();
|
|
495
|
+
}
|
|
496
|
+
webSockets.close(complete);
|
|
497
|
+
});
|
|
262
498
|
} };
|
|
263
499
|
}
|
|
264
500
|
//#endregion
|
|
265
501
|
//#region src/listen.ts
|
|
266
502
|
function listen(app, options = {}) {
|
|
503
|
+
options.signal?.throwIfAborted();
|
|
267
504
|
const host = resolveBindHost(options);
|
|
268
|
-
const handlerOptions =
|
|
505
|
+
const handlerOptions = handlerOptionsForHost(options, host);
|
|
269
506
|
const server = createServer(createNodeHandler(app, handlerOptions));
|
|
270
|
-
|
|
271
|
-
if (options.headersTimeout !== void 0) server.headersTimeout = options.headersTimeout;
|
|
272
|
-
if (options.keepAliveTimeout !== void 0) server.keepAliveTimeout = options.keepAliveTimeout;
|
|
507
|
+
applyServerTimeouts(server, options);
|
|
273
508
|
const webSockets = options.websocket ? installWebSockets(server, app, options.websocket === true ? {} : options.websocket, handlerOptions) : void 0;
|
|
274
509
|
if (webSockets) {
|
|
275
510
|
const nativeClose = server.close.bind(server);
|
|
276
511
|
server.close = ((callback) => {
|
|
277
|
-
|
|
278
|
-
|
|
512
|
+
let remaining = 2;
|
|
513
|
+
let closeError;
|
|
514
|
+
const complete = (error) => {
|
|
515
|
+
closeError ??= error;
|
|
516
|
+
remaining -= 1;
|
|
517
|
+
if (remaining === 0) callback?.(closeError);
|
|
518
|
+
};
|
|
519
|
+
nativeClose(complete);
|
|
520
|
+
webSockets.close().then(() => complete(), complete);
|
|
521
|
+
return server;
|
|
279
522
|
});
|
|
280
523
|
}
|
|
281
524
|
const close = () => server.close();
|
|
282
|
-
options.signal
|
|
283
|
-
|
|
525
|
+
const abortListener = options.signal ? addAbortListener(options.signal, close) : void 0;
|
|
526
|
+
const disposeAbortListener = () => abortListener?.[Symbol.dispose]();
|
|
527
|
+
server.once("close", disposeAbortListener);
|
|
284
528
|
return new Promise((resolve, reject) => {
|
|
529
|
+
const onClose = () => {
|
|
530
|
+
server.off("error", onError);
|
|
531
|
+
reject(options.signal?.reason ?? /* @__PURE__ */ new Error("Node server closed before it started listening."));
|
|
532
|
+
};
|
|
285
533
|
const onError = (error) => {
|
|
286
|
-
|
|
534
|
+
server.off("close", onClose);
|
|
535
|
+
disposeAbortListener();
|
|
287
536
|
reject(error);
|
|
288
537
|
};
|
|
289
538
|
server.once("error", onError);
|
|
539
|
+
server.once("close", onClose);
|
|
290
540
|
server.listen(options.port ?? 0, host, options.backlog, () => {
|
|
291
541
|
server.off("error", onError);
|
|
542
|
+
server.off("close", onClose);
|
|
292
543
|
resolve(server);
|
|
293
544
|
});
|
|
294
545
|
});
|
|
@@ -296,7 +547,9 @@ function listen(app, options = {}) {
|
|
|
296
547
|
//#endregion
|
|
297
548
|
//#region src/serve.ts
|
|
298
549
|
const mimeTypes = {
|
|
550
|
+
".avif": "image/avif",
|
|
299
551
|
".css": "text/css; charset=utf-8",
|
|
552
|
+
".gif": "image/gif",
|
|
300
553
|
".html": "text/html; charset=utf-8",
|
|
301
554
|
".ico": "image/x-icon",
|
|
302
555
|
".jpeg": "image/jpeg",
|
|
@@ -304,8 +557,14 @@ const mimeTypes = {
|
|
|
304
557
|
".js": "text/javascript; charset=utf-8",
|
|
305
558
|
".json": "application/json; charset=utf-8",
|
|
306
559
|
".mjs": "text/javascript; charset=utf-8",
|
|
560
|
+
".mp4": "video/mp4",
|
|
561
|
+
".pdf": "application/pdf",
|
|
307
562
|
".png": "image/png",
|
|
308
563
|
".svg": "image/svg+xml",
|
|
564
|
+
".txt": "text/plain; charset=utf-8",
|
|
565
|
+
".wasm": "application/wasm",
|
|
566
|
+
".webm": "video/webm",
|
|
567
|
+
".webmanifest": "application/manifest+json; charset=utf-8",
|
|
309
568
|
".webp": "image/webp",
|
|
310
569
|
".woff": "font/woff",
|
|
311
570
|
".woff2": "font/woff2"
|
|
@@ -318,12 +577,15 @@ function isWithinRoot(root, candidate) {
|
|
|
318
577
|
return candidate === root || candidate.startsWith(prefix);
|
|
319
578
|
}
|
|
320
579
|
async function serve(app, options = {}) {
|
|
580
|
+
options.signal?.throwIfAborted();
|
|
321
581
|
const host = resolveBindHost(options);
|
|
322
582
|
const root = options.assets ? await realpath(resolve(options.assets.root)) : void 0;
|
|
323
|
-
const handlerOptions =
|
|
583
|
+
const handlerOptions = handlerOptionsForHost(options, host);
|
|
584
|
+
const preparedHandlerOptions = prepareNodeHandlerOptions(handlerOptions);
|
|
324
585
|
const applicationHandler = createNodeHandler({ async fetch(request, dispatchOptions) {
|
|
325
586
|
const result = await app.fetch(request, dispatchOptions);
|
|
326
|
-
|
|
587
|
+
const contentType = result.headers.get("content-type");
|
|
588
|
+
if (!result.headers.has("cache-control") && contentType !== null && /^text\/html(?:;|$)/iu.test(contentType)) {
|
|
327
589
|
const headers = new Headers(result.headers);
|
|
328
590
|
headers.set("cache-control", "no-cache");
|
|
329
591
|
return new Response(result.body, {
|
|
@@ -335,25 +597,29 @@ async function serve(app, options = {}) {
|
|
|
335
597
|
return result;
|
|
336
598
|
} }, handlerOptions);
|
|
337
599
|
const server = createServer(async (request, response) => {
|
|
600
|
+
if (!root) {
|
|
601
|
+
applicationHandler(request, response);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
338
604
|
let pathname;
|
|
339
605
|
try {
|
|
340
|
-
pathname = decodeURIComponent(resolveNodeRequestUrl(request,
|
|
606
|
+
pathname = decodeURIComponent(resolveNodeRequestUrl(request, preparedHandlerOptions).pathname);
|
|
341
607
|
} catch {
|
|
342
608
|
response.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Bad Request");
|
|
343
609
|
return;
|
|
344
610
|
}
|
|
345
611
|
const method = request.method ?? "GET";
|
|
346
|
-
if (
|
|
612
|
+
if ((method === "GET" || method === "HEAD") && isAssetPath(pathname)) {
|
|
347
613
|
const extension = extname(pathname).toLowerCase();
|
|
348
614
|
const unresolvedCandidate = resolve(root, `.${pathname}`);
|
|
349
615
|
const inside = isWithinRoot(root, unresolvedCandidate);
|
|
350
616
|
let candidate;
|
|
351
617
|
let file;
|
|
352
618
|
if (inside && extension !== ".map") try {
|
|
353
|
-
const resolvedCandidate = await realpath(unresolvedCandidate);
|
|
619
|
+
const [resolvedCandidate, candidateFile] = await Promise.all([realpath(unresolvedCandidate), stat(unresolvedCandidate)]);
|
|
354
620
|
if (isWithinRoot(root, resolvedCandidate)) {
|
|
355
621
|
candidate = resolvedCandidate;
|
|
356
|
-
file =
|
|
622
|
+
file = candidateFile;
|
|
357
623
|
}
|
|
358
624
|
} catch {
|
|
359
625
|
candidate = void 0;
|
|
@@ -373,47 +639,64 @@ async function serve(app, options = {}) {
|
|
|
373
639
|
"x-content-type-options": "nosniff"
|
|
374
640
|
});
|
|
375
641
|
if (method === "HEAD") response.end();
|
|
376
|
-
else createReadStream(candidate)
|
|
642
|
+
else pipeline(createReadStream(candidate), response, () => void 0);
|
|
377
643
|
return;
|
|
378
644
|
}
|
|
379
645
|
applicationHandler(request, response);
|
|
380
646
|
});
|
|
381
|
-
|
|
382
|
-
if (options.headersTimeout !== void 0) server.headersTimeout = options.headersTimeout;
|
|
383
|
-
if (options.keepAliveTimeout !== void 0) server.keepAliveTimeout = options.keepAliveTimeout;
|
|
647
|
+
applyServerTimeouts(server, options);
|
|
384
648
|
const webSockets = options.websocket ? installWebSockets(server, app, options.websocket === true ? {} : options.websocket, handlerOptions) : void 0;
|
|
385
649
|
let closing;
|
|
650
|
+
let serverClosed = false;
|
|
651
|
+
server.once("close", () => {
|
|
652
|
+
serverClosed = true;
|
|
653
|
+
});
|
|
386
654
|
const signals = options.signals === false ? [] : options.signals ?? ["SIGINT", "SIGTERM"];
|
|
655
|
+
let abortListener;
|
|
387
656
|
const removeListeners = () => {
|
|
388
|
-
|
|
657
|
+
abortListener?.[Symbol.dispose]();
|
|
389
658
|
for (const signal of signals) process.removeListener(signal, shutdown);
|
|
390
659
|
};
|
|
391
|
-
const close = () => closing ??=
|
|
660
|
+
const close = () => closing ??= (async () => {
|
|
392
661
|
removeListeners();
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (serverError) rejectClose(serverError);
|
|
398
|
-
else resolveClose();
|
|
399
|
-
} catch (error) {
|
|
400
|
-
rejectClose(error);
|
|
662
|
+
const closeServer = new Promise((resolveClose, rejectClose) => {
|
|
663
|
+
if (serverClosed) {
|
|
664
|
+
resolveClose();
|
|
665
|
+
return;
|
|
401
666
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
667
|
+
server.close((error) => error ? rejectClose(error) : resolveClose());
|
|
668
|
+
});
|
|
669
|
+
const errors = (await Promise.allSettled([closeServer, webSockets?.close()])).filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
670
|
+
try {
|
|
671
|
+
await app.close?.();
|
|
672
|
+
} catch (error) {
|
|
673
|
+
errors.push(error);
|
|
674
|
+
}
|
|
675
|
+
if (errors.length === 1) throw errors[0];
|
|
676
|
+
if (errors.length > 1) throw new AggregateError(errors, "Failed to close served application.");
|
|
677
|
+
})();
|
|
406
678
|
const shutdown = () => void close().catch(() => void 0);
|
|
407
|
-
options.signal
|
|
679
|
+
if (options.signal) abortListener = addAbortListener(options.signal, shutdown);
|
|
408
680
|
for (const signal of signals) process.once(signal, shutdown);
|
|
409
681
|
await new Promise((resolveListen, rejectListen) => {
|
|
410
|
-
|
|
682
|
+
const onError = (error) => {
|
|
683
|
+
server.off("close", onClose);
|
|
684
|
+
rejectListen(error);
|
|
685
|
+
};
|
|
686
|
+
const onClose = () => {
|
|
687
|
+
server.off("error", onError);
|
|
688
|
+
rejectListen(options.signal?.reason ?? /* @__PURE__ */ new Error("Node server closed before it started listening."));
|
|
689
|
+
};
|
|
690
|
+
server.once("error", onError);
|
|
691
|
+
server.once("close", onClose);
|
|
411
692
|
server.listen(options.port ?? 0, host, options.backlog, () => {
|
|
412
|
-
server.
|
|
693
|
+
server.off("error", onError);
|
|
694
|
+
server.off("close", onClose);
|
|
413
695
|
resolveListen();
|
|
414
696
|
});
|
|
415
|
-
}).catch((error) => {
|
|
697
|
+
}).catch(async (error) => {
|
|
416
698
|
removeListeners();
|
|
699
|
+
await close().catch(() => void 0);
|
|
417
700
|
throw error;
|
|
418
701
|
});
|
|
419
702
|
const address = server.address();
|
package/dist/mcp.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { addAbortListener } from "node:events";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
1
3
|
//#region src/mcp.ts
|
|
2
4
|
const anonymous = Object.freeze({
|
|
3
5
|
authenticated: false,
|
|
@@ -10,7 +12,7 @@ function connectMcpStdio(mcp, options) {
|
|
|
10
12
|
const output = options.output ?? process.stdout;
|
|
11
13
|
const diagnostics = options.diagnostics ?? process.stderr;
|
|
12
14
|
const controllers = /* @__PURE__ */ new Map();
|
|
13
|
-
const sessionId =
|
|
15
|
+
const sessionId = randomUUID();
|
|
14
16
|
let finish;
|
|
15
17
|
const closed = new Promise((resolve) => {
|
|
16
18
|
finish = resolve;
|
|
@@ -18,31 +20,73 @@ function connectMcpStdio(mcp, options) {
|
|
|
18
20
|
let ended = false;
|
|
19
21
|
const maxLineBytes = options.maxLineBytes ?? 1048576;
|
|
20
22
|
const maxConcurrency = options.maxConcurrency ?? 16;
|
|
21
|
-
if (!Number.
|
|
22
|
-
if (!Number.
|
|
23
|
+
if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes <= 0) throw new TypeError("MCP maxLineBytes must be a positive integer.");
|
|
24
|
+
if (!Number.isSafeInteger(maxConcurrency) || maxConcurrency <= 0) throw new TypeError("MCP maxConcurrency must be a positive integer.");
|
|
23
25
|
let active = 0;
|
|
24
|
-
let
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
let abortListener;
|
|
27
|
+
let cleanupTransport = () => void 0;
|
|
28
|
+
const report = (error) => {
|
|
29
|
+
try {
|
|
30
|
+
diagnostics.write(`MCP stdio error: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
31
|
+
} catch {}
|
|
32
|
+
};
|
|
33
|
+
const write = (message) => {
|
|
34
|
+
if (ended) return Promise.reject(/* @__PURE__ */ new Error("MCP stdio connection is closed."));
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
output.write(`${JSON.stringify(message)}\n`, (error) => error ? reject(error) : resolve());
|
|
37
|
+
});
|
|
38
|
+
};
|
|
28
39
|
const close = async () => {
|
|
29
40
|
if (ended) return closed;
|
|
30
41
|
ended = true;
|
|
31
|
-
|
|
32
|
-
|
|
42
|
+
cleanupTransport();
|
|
43
|
+
abortListener?.[Symbol.dispose]();
|
|
44
|
+
for (const values of controllers.values()) for (const controller of values) controller.abort();
|
|
33
45
|
controllers.clear();
|
|
34
|
-
|
|
35
|
-
|
|
46
|
+
try {
|
|
47
|
+
mcp.terminateSession(sessionId);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
report(error);
|
|
50
|
+
} finally {
|
|
51
|
+
finish();
|
|
52
|
+
}
|
|
36
53
|
return closed;
|
|
37
54
|
};
|
|
38
|
-
options.signal
|
|
55
|
+
if (options.signal) abortListener = addAbortListener(options.signal, () => void close());
|
|
56
|
+
const writeProtocolError = (code, message) => write({
|
|
57
|
+
jsonrpc: "2.0",
|
|
58
|
+
id: null,
|
|
59
|
+
error: {
|
|
60
|
+
code,
|
|
61
|
+
message
|
|
62
|
+
}
|
|
63
|
+
});
|
|
39
64
|
const handleLine = (line) => {
|
|
65
|
+
if (ended) return;
|
|
66
|
+
let message;
|
|
67
|
+
try {
|
|
68
|
+
message = JSON.parse(line);
|
|
69
|
+
} catch {
|
|
70
|
+
writeProtocolError(-32700, "Parse error").catch(() => void close());
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
|
74
|
+
writeProtocolError(-32600, "Invalid Request").catch(() => void close());
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const value = message;
|
|
78
|
+
if (value.method === "notifications/cancelled" && value.params && typeof value.params === "object") {
|
|
79
|
+
const requestId = value.params.requestId;
|
|
80
|
+
if (typeof requestId === "string" || typeof requestId === "number") for (const controller of controllers.get(requestId) ?? []) controller.abort();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const id = value.id;
|
|
40
84
|
if (active >= maxConcurrency) {
|
|
41
|
-
write({
|
|
85
|
+
if (typeof id === "string" || typeof id === "number") write({
|
|
42
86
|
jsonrpc: "2.0",
|
|
43
|
-
id
|
|
87
|
+
id,
|
|
44
88
|
error: {
|
|
45
|
-
code: -
|
|
89
|
+
code: -32e3,
|
|
46
90
|
message: "Too many concurrent requests"
|
|
47
91
|
}
|
|
48
92
|
}).catch(() => void close());
|
|
@@ -50,51 +94,52 @@ function connectMcpStdio(mcp, options) {
|
|
|
50
94
|
}
|
|
51
95
|
active += 1;
|
|
52
96
|
(async () => {
|
|
53
|
-
|
|
97
|
+
const controller = typeof id === "string" || typeof id === "number" ? new AbortController() : void 0;
|
|
98
|
+
if (controller && (typeof id === "string" || typeof id === "number")) {
|
|
99
|
+
const values = controllers.get(id) ?? /* @__PURE__ */ new Set();
|
|
100
|
+
values.add(controller);
|
|
101
|
+
controllers.set(id, values);
|
|
102
|
+
}
|
|
54
103
|
try {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
await
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
104
|
+
const environment = options.environment ?? process.env;
|
|
105
|
+
const auth = typeof options.auth === "function" ? await options.auth(environment) : options.auth ?? anonymous;
|
|
106
|
+
const result = await mcp.handle(message, {
|
|
107
|
+
dependencies: options.dependencies,
|
|
108
|
+
auth,
|
|
109
|
+
transport: "stdio",
|
|
110
|
+
sessionId,
|
|
111
|
+
supportsPush: true,
|
|
112
|
+
signal: controller?.signal ?? options.signal,
|
|
113
|
+
send: write
|
|
64
114
|
});
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
const environment = options.environment ?? process.env;
|
|
78
|
-
const auth = typeof options.auth === "function" ? await options.auth(environment) : options.auth ?? anonymous;
|
|
79
|
-
const result = await mcp.handle(message, {
|
|
80
|
-
dependencies: options.dependencies,
|
|
81
|
-
auth,
|
|
82
|
-
transport: "stdio",
|
|
83
|
-
sessionId,
|
|
84
|
-
supportsPush: true,
|
|
85
|
-
signal: controller?.signal ?? options.signal,
|
|
86
|
-
send: write
|
|
115
|
+
if (result !== void 0 && !ended) await write(result);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (!ended) {
|
|
118
|
+
report(error);
|
|
119
|
+
if (typeof id === "string" || typeof id === "number") await write({
|
|
120
|
+
jsonrpc: "2.0",
|
|
121
|
+
id,
|
|
122
|
+
error: {
|
|
123
|
+
code: -32603,
|
|
124
|
+
message: "Internal error"
|
|
125
|
+
}
|
|
87
126
|
});
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
if (controller && (typeof id === "string" || typeof id === "number")) {
|
|
130
|
+
const values = controllers.get(id);
|
|
131
|
+
values?.delete(controller);
|
|
132
|
+
if (values?.size === 0) controllers.delete(id);
|
|
91
133
|
}
|
|
92
134
|
}
|
|
93
|
-
})().catch((error) =>
|
|
135
|
+
})().catch((error) => {
|
|
136
|
+
if (!ended) report(error);
|
|
137
|
+
}).finally(() => {
|
|
94
138
|
active -= 1;
|
|
95
139
|
});
|
|
96
140
|
};
|
|
97
141
|
const lineBuffer = Buffer.allocUnsafe(maxLineBytes);
|
|
142
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
98
143
|
let lineBytes = 0;
|
|
99
144
|
let discardingOversizedLine = false;
|
|
100
145
|
const rejectOversizedLine = () => {
|
|
@@ -108,6 +153,7 @@ function connectMcpStdio(mcp, options) {
|
|
|
108
153
|
}).catch(() => void close());
|
|
109
154
|
};
|
|
110
155
|
const onData = (chunk) => {
|
|
156
|
+
if (ended) return;
|
|
111
157
|
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
112
158
|
let offset = 0;
|
|
113
159
|
while (offset < bytes.byteLength) {
|
|
@@ -127,29 +173,39 @@ function connectMcpStdio(mcp, options) {
|
|
|
127
173
|
if (newline < 0) break;
|
|
128
174
|
if (!discardingOversizedLine) {
|
|
129
175
|
const length = lineBytes > 0 && lineBuffer[lineBytes - 1] === 13 ? lineBytes - 1 : lineBytes;
|
|
130
|
-
|
|
176
|
+
try {
|
|
177
|
+
handleLine(decoder.decode(lineBuffer.subarray(0, length)));
|
|
178
|
+
} catch {
|
|
179
|
+
writeProtocolError(-32700, "Parse error").catch(() => void close());
|
|
180
|
+
}
|
|
131
181
|
}
|
|
132
182
|
lineBytes = 0;
|
|
133
183
|
discardingOversizedLine = false;
|
|
134
184
|
offset = newline + 1;
|
|
135
185
|
}
|
|
136
186
|
};
|
|
137
|
-
const finishInput = () =>
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
187
|
+
const finishInput = () => void close();
|
|
188
|
+
const inputError = (error) => {
|
|
189
|
+
report(error);
|
|
190
|
+
close();
|
|
191
|
+
};
|
|
192
|
+
const outputError = (error) => {
|
|
193
|
+
report(error);
|
|
194
|
+
close();
|
|
144
195
|
};
|
|
145
196
|
input.on("data", onData);
|
|
146
197
|
input.once("end", finishInput);
|
|
147
198
|
input.once("close", finishInput);
|
|
148
|
-
|
|
199
|
+
input.once("error", inputError);
|
|
200
|
+
output.once("error", outputError);
|
|
201
|
+
cleanupTransport = () => {
|
|
149
202
|
input.off("data", onData);
|
|
150
203
|
input.off("end", finishInput);
|
|
151
204
|
input.off("close", finishInput);
|
|
205
|
+
input.off("error", inputError);
|
|
206
|
+
output.off("error", outputError);
|
|
152
207
|
};
|
|
208
|
+
if (input.readableEnded || input.destroyed || output.destroyed) close();
|
|
153
209
|
return {
|
|
154
210
|
closed,
|
|
155
211
|
close
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askrjs/node",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"description": "Node http adapter for @askrjs/server",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"askr",
|
|
@@ -43,10 +43,12 @@
|
|
|
43
43
|
"access": "public"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
|
+
"bench": "vitest bench --run",
|
|
47
|
+
"bench:http": "vitest bench --run benches/http.bench.ts",
|
|
46
48
|
"build": "vp pack",
|
|
47
49
|
"fmt": "vp fmt .",
|
|
48
50
|
"lint": "vp lint .",
|
|
49
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
51
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
|
|
50
52
|
"test": "vp test run",
|
|
51
53
|
"test:publint": "publint",
|
|
52
54
|
"pack:check": "node tests/package-artifacts.js",
|
|
@@ -55,20 +57,21 @@
|
|
|
55
57
|
"prepublishOnly": "npm run check"
|
|
56
58
|
},
|
|
57
59
|
"dependencies": {
|
|
58
|
-
"@askrjs/auth": ">=0.0.
|
|
59
|
-
"@askrjs/server": ">=0.0.
|
|
60
|
-
"ws": "^8.
|
|
60
|
+
"@askrjs/auth": ">=0.0.8 <0.1.0",
|
|
61
|
+
"@askrjs/server": ">=0.0.11 <0.1.0",
|
|
62
|
+
"@types/ws": "^8.18.1",
|
|
63
|
+
"ws": "^8.21.3"
|
|
61
64
|
},
|
|
62
65
|
"devDependencies": {
|
|
63
|
-
"@askrjs/schema": ">=0.0.
|
|
64
|
-
"@types/node": "^26.
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"vitest": "^4.0.0"
|
|
66
|
+
"@askrjs/schema": ">=0.0.5 <0.1.0",
|
|
67
|
+
"@types/node": "^26.2.0",
|
|
68
|
+
"publint": "^0.3.23",
|
|
69
|
+
"typescript": "^7.0.2",
|
|
70
|
+
"vite-plus": "^0.2.8",
|
|
71
|
+
"vitest": "^4.1.10"
|
|
70
72
|
},
|
|
71
73
|
"engines": {
|
|
72
|
-
"node": ">=
|
|
73
|
-
}
|
|
74
|
+
"node": ">=24.0.0"
|
|
75
|
+
},
|
|
76
|
+
"packageManager": "npm@12.0.1"
|
|
74
77
|
}
|