@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 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?: NodeHandlerOptions): NodeHandler;
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 { Readable } from "node:stream";
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 [key, value] of Object.entries(request.headers)) {
66
+ for (const key in request.headers) {
67
+ const value = request.headers[key];
12
68
  if (value === void 0) continue;
13
- headers.set(key, Array.isArray(value) ? value.join(", ") : value);
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 resolveNodeRequestUrl(request, options) {
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
- const hostName = host.startsWith("[") ? host.slice(1, host.indexOf("]")) : host.split(":", 1)[0];
22
- if (options.allowedHosts?.length && !options.allowedHosts.some((allowed) => allowed === host || allowed === hostName)) throw new NodeRequestError("Request Host header is not allowed.");
23
- const base = options.baseUrl ?? `http://${host}`;
24
- const target = new URL(request.url ?? "/", base);
25
- if (/^https?:\/\//i.test(request.url ?? "") && target.origin !== new URL(base).origin) throw new NodeRequestError("Absolute-form request target origin is not allowed.");
26
- return target;
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 = resolveNodeRequestUrl(request, options);
116
+ const target = resolveNodeRequestHref(request, options);
30
117
  const method = request.method ?? "GET";
31
- const body = method === "GET" || method === "HEAD" ? void 0 : Readable.toWeb(request);
32
- return new Request(target, {
33
- method,
34
- headers: requestHeaders(request),
35
- body,
36
- signal,
37
- ...body ? { duplex: "half" } : {}
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 (!response.body || method === "HEAD") {
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(Buffer.from(part.value))) await waitForDrain(target);
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
- const clientError = error instanceof NodeRequestError;
117
- response.statusCode = clientError ? 400 : 500;
118
- response.statusMessage = clientError ? "Bad Request" : "Internal Server Error";
119
- response.setHeader("content-type", "text/plain; charset=utf-8");
120
- response.end(clientError ? "Bad Request" : "Internal Server Error");
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 createNodeHandler(app, options = {}) {
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
- Promise.resolve().then(() => requestFromNode(request, options, controller.signal)).then((webRequest) => app.fetch(webRequest)).then((webResponse) => writeNodeResponse(webResponse, response, request.method)).catch((error) => next ? next(error) : minimalError(response, error)).finally(cleanup);
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 === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host)) && options.allowPublicBind !== true) throw new TypeError(`Refusing to bind non-loopback host ${host} without allowPublicBind: true.`);
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(data.toString());
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(new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength));
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 === 1e3
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
- while (true) {
192
- const part = await reader.read();
193
- if (part.done) return Buffer.concat(chunks, length);
194
- length += part.value.byteLength;
195
- if (length > maxBytes) {
196
- await reader.cancel("WebSocket rejection body exceeded maxRejectionBodyBytes");
197
- return;
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
- chunks.push(Buffer.from(part.value));
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 = {}, handlerOptions = {}) {
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.isInteger(maxRejectionBodyBytes) || maxRejectionBodyBytes <= 0) throw new TypeError("WebSocket maxRejectionBodyBytes must be a positive integer.");
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: options.maxPayload ?? 1048576,
403
+ maxPayload,
220
404
  perMessageDeflate: options.perMessageDeflate ?? false
221
405
  });
222
- server.on("upgrade", (request, socket, head) => {
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
- Promise.resolve().then(() => requestFromNode(request, handlerOptions, controller.signal)).then(async (webRequest) => {
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 = options.allowedOrigins ?? [new URL(webRequest.url).origin];
431
+ const allowedOrigins = configuredAllowedOrigins ?? [new URL(webRequest.url).origin];
229
432
  let normalizedOrigin;
230
433
  try {
231
- normalizedOrigin = origin ? new URL(origin).origin : void 0;
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
- for (const socket of webSockets.clients) socket.close(1001, "Server shutting down");
261
- webSockets.close();
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 = { allowedHosts: [host, "localhost"] };
505
+ const handlerOptions = handlerOptionsForHost(options, host);
269
506
  const server = createServer(createNodeHandler(app, handlerOptions));
270
- if (options.requestTimeout !== void 0) server.requestTimeout = options.requestTimeout;
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
- webSockets.close();
278
- return nativeClose(callback);
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?.addEventListener("abort", close, { once: true });
283
- server.once("close", () => options.signal?.removeEventListener("abort", close));
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
- options.signal?.removeEventListener("abort", close);
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 = { allowedHosts: [host, "localhost"] };
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
- if (!result.headers.has("cache-control") && result.headers.get("content-type")?.includes("text/html")) {
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, handlerOptions).pathname);
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 (root && (method === "GET" || method === "HEAD") && isAssetPath(pathname)) {
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 = await stat(candidate);
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).pipe(response);
642
+ else pipeline(createReadStream(candidate), response, () => void 0);
377
643
  return;
378
644
  }
379
645
  applicationHandler(request, response);
380
646
  });
381
- if (options.requestTimeout !== void 0) server.requestTimeout = options.requestTimeout;
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
- options.signal?.removeEventListener("abort", shutdown);
657
+ abortListener?.[Symbol.dispose]();
389
658
  for (const signal of signals) process.removeListener(signal, shutdown);
390
659
  };
391
- const close = () => closing ??= new Promise((resolveClose, rejectClose) => {
660
+ const close = () => closing ??= (async () => {
392
661
  removeListeners();
393
- webSockets?.close();
394
- const finish = async (serverError) => {
395
- try {
396
- await app.close?.();
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
- if (!server.listening) finish();
404
- else server.close((error) => void finish(error));
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?.addEventListener("abort", shutdown, { once: true });
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
- server.once("error", rejectListen);
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.removeListener("error", rejectListen);
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 = crypto.randomUUID();
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.isInteger(maxLineBytes) || maxLineBytes <= 0) throw new TypeError("MCP maxLineBytes must be a positive integer.");
22
- if (!Number.isInteger(maxConcurrency) || maxConcurrency <= 0) throw new TypeError("MCP maxConcurrency must be a positive integer.");
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 cleanupInput = () => void 0;
25
- const write = (message) => new Promise((resolve, reject) => {
26
- output.write(`${JSON.stringify(message)}\n`, (error) => error ? reject(error) : resolve());
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
- cleanupInput();
32
- for (const controller of controllers.values()) controller.abort();
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
- mcp.terminateSession(sessionId);
35
- finish();
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?.addEventListener("abort", () => void close(), { once: true });
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: null,
87
+ id,
44
88
  error: {
45
- code: -32600,
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
- let message;
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
- message = JSON.parse(line);
56
- } catch {
57
- await write({
58
- jsonrpc: "2.0",
59
- id: null,
60
- error: {
61
- code: -32700,
62
- message: "Parse error"
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
- return;
66
- }
67
- if (message && typeof message === "object") {
68
- const value = message;
69
- if (value.method === "notifications/cancelled" && value.params && typeof value.params === "object") {
70
- const requestId = value.params.requestId;
71
- if (typeof requestId === "string" || typeof requestId === "number") controllers.get(requestId)?.abort();
72
- }
73
- const id = value.id;
74
- const controller = typeof id === "string" || typeof id === "number" ? new AbortController() : void 0;
75
- if (controller && (typeof id === "string" || typeof id === "number")) controllers.set(id, controller);
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
- if (result !== void 0) await write(result);
89
- } finally {
90
- if (controller && id !== void 0) controllers.delete(id);
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) => diagnostics.write(`MCP stdio error: ${error instanceof Error ? error.message : String(error)}\n`)).finally(() => {
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
- handleLine(lineBuffer.subarray(0, length).toString("utf8"));
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
- if (!ended) {
139
- if (!discardingOversizedLine && lineBytes > 0) handleLine(lineBuffer.subarray(0, lineBytes).toString("utf8"));
140
- ended = true;
141
- cleanupInput();
142
- finish();
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
- cleanupInput = () => {
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.7",
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.1 <0.1.0",
59
- "@askrjs/server": ">=0.0.5 <0.1.0",
60
- "ws": "^8.21.1"
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.2 <0.1.0",
64
- "@types/node": "^26.0.0",
65
- "@types/ws": "^8.18.1",
66
- "publint": "^0.3.21",
67
- "typescript": "^6.0.0",
68
- "vite-plus": "^0.2.4",
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": ">=18"
73
- }
74
+ "node": ">=24.0.0"
75
+ },
76
+ "packageManager": "npm@12.0.1"
74
77
  }