@askrjs/node 0.0.6 → 0.0.7

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
@@ -48,7 +48,8 @@ Pass an `AbortSignal` to integrate shutdown with your process lifecycle.
48
48
 
49
49
  Enable the built-in `ws` transport with `websocket: true`. It defaults to a
50
50
  1 MiB maximum message payload with compression disabled; pass
51
- `websocket: { maxPayload, perMessageDeflate }` to override those settings.
51
+ `websocket: { maxPayload, maxRejectionBodyBytes, perMessageDeflate }` to override
52
+ those settings. Rejected upgrade bodies are capped at 64 KiB by default.
52
53
 
53
54
  ```ts
54
55
  router.ws("/echo", (socket) => {
@@ -76,6 +77,8 @@ await running.close();
76
77
  ```
77
78
 
78
79
  `serve` handles static assets and closes both the HTTP server and the application during shutdown.
80
+ 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.
79
82
 
80
83
  ## MCP over stdio
81
84
 
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ import { AddressInfo } from "node:net";
5
5
  //#region src/contracts.d.ts
6
6
  interface NodeWebSocketOptions {
7
7
  readonly maxPayload?: number;
8
+ readonly maxRejectionBodyBytes?: number;
8
9
  readonly perMessageDeflate?: boolean | PerMessageDeflateOptions;
9
10
  readonly allowedOrigins?: readonly string[];
10
11
  }
@@ -15,6 +16,7 @@ interface NodeHandlerOptions {
15
16
  interface ListenOptions {
16
17
  port?: number;
17
18
  host?: string;
19
+ allowPublicBind?: boolean;
18
20
  backlog?: number;
19
21
  signal?: AbortSignal;
20
22
  requestTimeout?: number;
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { Readable } from "node:stream";
2
2
  import { createServer } from "node:http";
3
3
  import { WebSocketServer } from "ws";
4
4
  import { createReadStream } from "node:fs";
5
- import { stat } from "node:fs/promises";
5
+ import { realpath, stat } from "node:fs/promises";
6
6
  import { extname, resolve, sep } from "node:path";
7
7
  //#region src/request.ts
8
8
  var NodeRequestError = class extends TypeError {};
@@ -14,7 +14,7 @@ function requestHeaders(request) {
14
14
  }
15
15
  return headers;
16
16
  }
17
- function requestFromNode(request, options, signal) {
17
+ function resolveNodeRequestUrl(request, options) {
18
18
  const host = request.headers.host;
19
19
  if (!options.baseUrl && !options.allowedHosts?.length) throw new NodeRequestError("Node handling requires baseUrl or an allowedHosts allowlist.");
20
20
  if (!host) throw new NodeRequestError("Request Host header is required.");
@@ -23,6 +23,10 @@ function requestFromNode(request, options, signal) {
23
23
  const base = options.baseUrl ?? `http://${host}`;
24
24
  const target = new URL(request.url ?? "/", base);
25
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;
27
+ }
28
+ function requestFromNode(request, options, signal) {
29
+ const target = resolveNodeRequestUrl(request, options);
26
30
  const method = request.method ?? "GET";
27
31
  const body = method === "GET" || method === "HEAD" ? void 0 : Readable.toWeb(request);
28
32
  return new Request(target, {
@@ -139,6 +143,16 @@ function createNodeHandler(app, options = {}) {
139
143
  };
140
144
  }
141
145
  //#endregion
146
+ //#region src/bind.ts
147
+ function resolveBindHost(options) {
148
+ 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.`);
150
+ return host;
151
+ }
152
+ function formatHostForUrl(host) {
153
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
154
+ }
155
+ //#endregion
142
156
  //#region src/websocket.ts
143
157
  function subscribe(socket, event, listener) {
144
158
  socket.on(event, listener);
@@ -163,15 +177,43 @@ function socketLike(socket) {
163
177
  onError: (listener) => subscribe(socket, "error", listener)
164
178
  };
165
179
  }
166
- async function rejectUpgrade(socket, response) {
167
- const body = Buffer.from(await response.arrayBuffer());
180
+ async function readRejectionBody(response, maxBytes) {
181
+ const lengthHeader = response.headers.get("content-length");
182
+ const declaredLength = lengthHeader === null ? void 0 : Number(lengthHeader);
183
+ if (declaredLength !== void 0 && Number.isFinite(declaredLength) && declaredLength > maxBytes) {
184
+ await response.body?.cancel("WebSocket rejection body exceeded maxRejectionBodyBytes");
185
+ return;
186
+ }
187
+ if (!response.body) return Buffer.alloc(0);
188
+ const reader = response.body.getReader();
189
+ const chunks = [];
190
+ 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;
198
+ }
199
+ chunks.push(Buffer.from(part.value));
200
+ }
201
+ }
202
+ async function rejectUpgrade(socket, response, maxBodyBytes) {
203
+ const buffered = await readRejectionBody(response, maxBodyBytes);
204
+ if (!buffered) response = new Response("WebSocket rejection body exceeded configured limit", { status: 500 });
205
+ const body = buffered ?? Buffer.from(await response.arrayBuffer());
168
206
  const lines = [`HTTP/1.1 ${response.status} ${response.statusText || "Rejected"}`];
169
- response.headers.forEach((value, name) => lines.push(`${name}: ${value}`));
170
- if (!response.headers.has("content-length")) lines.push(`content-length: ${body.byteLength}`);
207
+ response.headers.forEach((value, name) => {
208
+ if (name !== "content-length" && name !== "transfer-encoding") lines.push(`${name}: ${value}`);
209
+ });
210
+ lines.push(`content-length: ${body.byteLength}`);
171
211
  lines.push("connection: close", "", "");
172
212
  socket.end(Buffer.concat([Buffer.from(lines.join("\r\n")), body]));
173
213
  }
174
214
  function installWebSockets(server, app, options = {}, handlerOptions = {}) {
215
+ const maxRejectionBodyBytes = options.maxRejectionBodyBytes ?? 65536;
216
+ if (!Number.isInteger(maxRejectionBodyBytes) || maxRejectionBodyBytes <= 0) throw new TypeError("WebSocket maxRejectionBodyBytes must be a positive integer.");
175
217
  const webSockets = new WebSocketServer({
176
218
  noServer: true,
177
219
  maxPayload: options.maxPayload ?? 1048576,
@@ -191,7 +233,7 @@ function installWebSockets(server, app, options = {}, handlerOptions = {}) {
191
233
  normalizedOrigin = void 0;
192
234
  }
193
235
  if (!normalizedOrigin || !allowedOrigins.includes(normalizedOrigin)) {
194
- await rejectUpgrade(socket, new Response("Forbidden", { status: 403 }));
236
+ await rejectUpgrade(socket, new Response("Forbidden", { status: 403 }), maxRejectionBodyBytes);
195
237
  return;
196
238
  }
197
239
  let marker;
@@ -205,7 +247,7 @@ function installWebSockets(server, app, options = {}, handlerOptions = {}) {
205
247
  return marker;
206
248
  } } });
207
249
  if (!accepted || response !== accepted.response) {
208
- await rejectUpgrade(socket, response);
250
+ await rejectUpgrade(socket, response, maxRejectionBodyBytes);
209
251
  return;
210
252
  }
211
253
  webSockets.handleUpgrade(request, socket, head, (webSocket) => {
@@ -222,7 +264,8 @@ function installWebSockets(server, app, options = {}, handlerOptions = {}) {
222
264
  //#endregion
223
265
  //#region src/listen.ts
224
266
  function listen(app, options = {}) {
225
- const handlerOptions = { allowedHosts: [options.host ?? "127.0.0.1", "localhost"] };
267
+ const host = resolveBindHost(options);
268
+ const handlerOptions = { allowedHosts: [host, "localhost"] };
226
269
  const server = createServer(createNodeHandler(app, handlerOptions));
227
270
  if (options.requestTimeout !== void 0) server.requestTimeout = options.requestTimeout;
228
271
  if (options.headersTimeout !== void 0) server.headersTimeout = options.headersTimeout;
@@ -244,7 +287,7 @@ function listen(app, options = {}) {
244
287
  reject(error);
245
288
  };
246
289
  server.once("error", onError);
247
- server.listen(options.port ?? 0, options.host, options.backlog, () => {
290
+ server.listen(options.port ?? 0, host, options.backlog, () => {
248
291
  server.off("error", onError);
249
292
  resolve(server);
250
293
  });
@@ -270,9 +313,14 @@ const mimeTypes = {
270
313
  function isAssetPath(pathname) {
271
314
  return extname(pathname) !== "";
272
315
  }
316
+ function isWithinRoot(root, candidate) {
317
+ const prefix = root.endsWith(sep) ? root : `${root}${sep}`;
318
+ return candidate === root || candidate.startsWith(prefix);
319
+ }
273
320
  async function serve(app, options = {}) {
274
- const root = options.assets ? resolve(options.assets.root) : void 0;
275
- const handlerOptions = { allowedHosts: [options.host ?? "127.0.0.1", "localhost"] };
321
+ const host = resolveBindHost(options);
322
+ const root = options.assets ? await realpath(resolve(options.assets.root)) : void 0;
323
+ const handlerOptions = { allowedHosts: [host, "localhost"] };
276
324
  const applicationHandler = createNodeHandler({ async fetch(request, dispatchOptions) {
277
325
  const result = await app.fetch(request, dispatchOptions);
278
326
  if (!result.headers.has("cache-control") && result.headers.get("content-type")?.includes("text/html")) {
@@ -289,7 +337,7 @@ async function serve(app, options = {}) {
289
337
  const server = createServer(async (request, response) => {
290
338
  let pathname;
291
339
  try {
292
- pathname = decodeURIComponent(new URL(request.url ?? "/", "http://askr.local").pathname);
340
+ pathname = decodeURIComponent(resolveNodeRequestUrl(request, handlerOptions).pathname);
293
341
  } catch {
294
342
  response.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Bad Request");
295
343
  return;
@@ -297,15 +345,21 @@ async function serve(app, options = {}) {
297
345
  const method = request.method ?? "GET";
298
346
  if (root && (method === "GET" || method === "HEAD") && isAssetPath(pathname)) {
299
347
  const extension = extname(pathname).toLowerCase();
300
- const candidate = resolve(root, `.${pathname}`);
301
- const inside = candidate.startsWith(`${root}${sep}`);
348
+ const unresolvedCandidate = resolve(root, `.${pathname}`);
349
+ const inside = isWithinRoot(root, unresolvedCandidate);
350
+ let candidate;
302
351
  let file;
303
352
  if (inside && extension !== ".map") try {
304
- file = await stat(candidate);
353
+ const resolvedCandidate = await realpath(unresolvedCandidate);
354
+ if (isWithinRoot(root, resolvedCandidate)) {
355
+ candidate = resolvedCandidate;
356
+ file = await stat(candidate);
357
+ }
305
358
  } catch {
359
+ candidate = void 0;
306
360
  file = void 0;
307
361
  }
308
- if (!file?.isFile()) {
362
+ if (!candidate || !file?.isFile()) {
309
363
  response.writeHead(404, {
310
364
  "content-type": "text/plain; charset=utf-8",
311
365
  "x-content-type-options": "nosniff"
@@ -354,7 +408,7 @@ async function serve(app, options = {}) {
354
408
  for (const signal of signals) process.once(signal, shutdown);
355
409
  await new Promise((resolveListen, rejectListen) => {
356
410
  server.once("error", rejectListen);
357
- server.listen(options.port ?? 0, options.host ?? "127.0.0.1", options.backlog, () => {
411
+ server.listen(options.port ?? 0, host, options.backlog, () => {
358
412
  server.removeListener("error", rejectListen);
359
413
  resolveListen();
360
414
  });
@@ -367,10 +421,9 @@ async function serve(app, options = {}) {
367
421
  await close();
368
422
  throw new Error("serve requires a TCP address.");
369
423
  }
370
- const host = options.host ?? "127.0.0.1";
371
424
  return Object.freeze({
372
425
  server,
373
- url: `http://${host}:${address.port}`,
426
+ url: `http://${formatHostForUrl(host)}:${address.port}`,
374
427
  close
375
428
  });
376
429
  }
package/dist/mcp.js CHANGED
@@ -1,4 +1,3 @@
1
- import { createInterface } from "node:readline";
2
1
  //#region src/mcp.ts
3
2
  const anonymous = Object.freeze({
4
3
  authenticated: false,
@@ -10,11 +9,6 @@ function connectMcpStdio(mcp, options) {
10
9
  const input = options.input ?? process.stdin;
11
10
  const output = options.output ?? process.stdout;
12
11
  const diagnostics = options.diagnostics ?? process.stderr;
13
- const lines = createInterface({
14
- input,
15
- crlfDelay: Infinity,
16
- terminal: false
17
- });
18
12
  const controllers = /* @__PURE__ */ new Map();
19
13
  const sessionId = crypto.randomUUID();
20
14
  let finish;
@@ -27,13 +21,14 @@ function connectMcpStdio(mcp, options) {
27
21
  if (!Number.isInteger(maxLineBytes) || maxLineBytes <= 0) throw new TypeError("MCP maxLineBytes must be a positive integer.");
28
22
  if (!Number.isInteger(maxConcurrency) || maxConcurrency <= 0) throw new TypeError("MCP maxConcurrency must be a positive integer.");
29
23
  let active = 0;
24
+ let cleanupInput = () => void 0;
30
25
  const write = (message) => new Promise((resolve, reject) => {
31
26
  output.write(`${JSON.stringify(message)}\n`, (error) => error ? reject(error) : resolve());
32
27
  });
33
28
  const close = async () => {
34
29
  if (ended) return closed;
35
30
  ended = true;
36
- lines.close();
31
+ cleanupInput();
37
32
  for (const controller of controllers.values()) controller.abort();
38
33
  controllers.clear();
39
34
  mcp.terminateSession(sessionId);
@@ -41,18 +36,7 @@ function connectMcpStdio(mcp, options) {
41
36
  return closed;
42
37
  };
43
38
  options.signal?.addEventListener("abort", () => void close(), { once: true });
44
- lines.on("line", (line) => {
45
- if (Buffer.byteLength(line) > maxLineBytes) {
46
- write({
47
- jsonrpc: "2.0",
48
- id: null,
49
- error: {
50
- code: -32600,
51
- message: "Request line exceeds the configured limit"
52
- }
53
- }).catch(() => void close());
54
- return;
55
- }
39
+ const handleLine = (line) => {
56
40
  if (active >= maxConcurrency) {
57
41
  write({
58
42
  jsonrpc: "2.0",
@@ -109,13 +93,63 @@ function connectMcpStdio(mcp, options) {
109
93
  })().catch((error) => diagnostics.write(`MCP stdio error: ${error instanceof Error ? error.message : String(error)}\n`)).finally(() => {
110
94
  active -= 1;
111
95
  });
112
- });
113
- lines.once("close", () => {
96
+ };
97
+ const lineBuffer = Buffer.allocUnsafe(maxLineBytes);
98
+ let lineBytes = 0;
99
+ let discardingOversizedLine = false;
100
+ const rejectOversizedLine = () => {
101
+ write({
102
+ jsonrpc: "2.0",
103
+ id: null,
104
+ error: {
105
+ code: -32600,
106
+ message: "Request line exceeds the configured limit"
107
+ }
108
+ }).catch(() => void close());
109
+ };
110
+ const onData = (chunk) => {
111
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
112
+ let offset = 0;
113
+ while (offset < bytes.byteLength) {
114
+ const newline = bytes.indexOf(10, offset);
115
+ const end = newline < 0 ? bytes.byteLength : newline;
116
+ const segmentBytes = end - offset;
117
+ if (!discardingOversizedLine) {
118
+ if (lineBytes + segmentBytes > maxLineBytes) {
119
+ discardingOversizedLine = true;
120
+ lineBytes = 0;
121
+ rejectOversizedLine();
122
+ } else if (segmentBytes > 0) {
123
+ bytes.copy(lineBuffer, lineBytes, offset, end);
124
+ lineBytes += segmentBytes;
125
+ }
126
+ }
127
+ if (newline < 0) break;
128
+ if (!discardingOversizedLine) {
129
+ const length = lineBytes > 0 && lineBuffer[lineBytes - 1] === 13 ? lineBytes - 1 : lineBytes;
130
+ handleLine(lineBuffer.subarray(0, length).toString("utf8"));
131
+ }
132
+ lineBytes = 0;
133
+ discardingOversizedLine = false;
134
+ offset = newline + 1;
135
+ }
136
+ };
137
+ const finishInput = () => {
114
138
  if (!ended) {
139
+ if (!discardingOversizedLine && lineBytes > 0) handleLine(lineBuffer.subarray(0, lineBytes).toString("utf8"));
115
140
  ended = true;
141
+ cleanupInput();
116
142
  finish();
117
143
  }
118
- });
144
+ };
145
+ input.on("data", onData);
146
+ input.once("end", finishInput);
147
+ input.once("close", finishInput);
148
+ cleanupInput = () => {
149
+ input.off("data", onData);
150
+ input.off("end", finishInput);
151
+ input.off("close", finishInput);
152
+ };
119
153
  return {
120
154
  closed,
121
155
  close
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askrjs/node",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Node http adapter for @askrjs/server",
5
5
  "keywords": [
6
6
  "askr",