@askrjs/node 0.0.5 → 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
@@ -23,7 +23,7 @@ const app = createServerApp({
23
23
  routes: [{ path: "/health", handler: () => json({ status: "ok" }) }],
24
24
  });
25
25
 
26
- createServer(createNodeHandler(app)).listen(3000);
26
+ createServer(createNodeHandler(app, { baseUrl: "http://localhost:3000" })).listen(3000);
27
27
  ```
28
28
 
29
29
  `createNodeHandler` also works as Connect middleware because it accepts an optional `next`
@@ -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,14 +5,18 @@ 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;
10
+ readonly allowedOrigins?: readonly string[];
9
11
  }
10
12
  interface NodeHandlerOptions {
11
13
  baseUrl?: string;
14
+ allowedHosts?: readonly string[];
12
15
  }
13
16
  interface ListenOptions {
14
17
  port?: number;
15
18
  host?: string;
19
+ allowPublicBind?: boolean;
16
20
  backlog?: number;
17
21
  signal?: AbortSignal;
18
22
  requestTimeout?: number;
package/dist/index.js CHANGED
@@ -2,9 +2,10 @@ 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
+ var NodeRequestError = class extends TypeError {};
8
9
  function requestHeaders(request) {
9
10
  const headers = new Headers();
10
11
  for (const [key, value] of Object.entries(request.headers)) {
@@ -13,11 +14,22 @@ function requestHeaders(request) {
13
14
  }
14
15
  return headers;
15
16
  }
17
+ function resolveNodeRequestUrl(request, options) {
18
+ const host = request.headers.host;
19
+ if (!options.baseUrl && !options.allowedHosts?.length) throw new NodeRequestError("Node handling requires baseUrl or an allowedHosts allowlist.");
20
+ 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;
27
+ }
16
28
  function requestFromNode(request, options, signal) {
17
- const base = options.baseUrl ?? `http://${request.headers.host ?? "localhost"}`;
29
+ const target = resolveNodeRequestUrl(request, options);
18
30
  const method = request.method ?? "GET";
19
31
  const body = method === "GET" || method === "HEAD" ? void 0 : Readable.toWeb(request);
20
- return new Request(new URL(request.url ?? "/", base), {
32
+ return new Request(target, {
21
33
  method,
22
34
  headers: requestHeaders(request),
23
35
  body,
@@ -101,10 +113,11 @@ function minimalError(response, error) {
101
113
  response.destroy(error instanceof Error ? error : void 0);
102
114
  return;
103
115
  }
104
- response.statusCode = 500;
105
- response.statusMessage = "Internal Server Error";
116
+ const clientError = error instanceof NodeRequestError;
117
+ response.statusCode = clientError ? 400 : 500;
118
+ response.statusMessage = clientError ? "Bad Request" : "Internal Server Error";
106
119
  response.setHeader("content-type", "text/plain; charset=utf-8");
107
- response.end("Internal Server Error");
120
+ response.end(clientError ? "Bad Request" : "Internal Server Error");
108
121
  }
109
122
  function attachAbort(request, response, controller) {
110
123
  const abort = () => controller.abort();
@@ -130,6 +143,16 @@ function createNodeHandler(app, options = {}) {
130
143
  };
131
144
  }
132
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
133
156
  //#region src/websocket.ts
134
157
  function subscribe(socket, event, listener) {
135
158
  socket.on(event, listener);
@@ -154,15 +177,43 @@ function socketLike(socket) {
154
177
  onError: (listener) => subscribe(socket, "error", listener)
155
178
  };
156
179
  }
157
- async function rejectUpgrade(socket, response) {
158
- 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());
159
206
  const lines = [`HTTP/1.1 ${response.status} ${response.statusText || "Rejected"}`];
160
- response.headers.forEach((value, name) => lines.push(`${name}: ${value}`));
161
- 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}`);
162
211
  lines.push("connection: close", "", "");
163
212
  socket.end(Buffer.concat([Buffer.from(lines.join("\r\n")), body]));
164
213
  }
165
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.");
166
217
  const webSockets = new WebSocketServer({
167
218
  noServer: true,
168
219
  maxPayload: options.maxPayload ?? 1048576,
@@ -172,6 +223,19 @@ function installWebSockets(server, app, options = {}, handlerOptions = {}) {
172
223
  const controller = new AbortController();
173
224
  let accepted;
174
225
  Promise.resolve().then(() => requestFromNode(request, handlerOptions, controller.signal)).then(async (webRequest) => {
226
+ const originHeader = request.headers.origin;
227
+ const origin = Array.isArray(originHeader) ? void 0 : originHeader;
228
+ const allowedOrigins = options.allowedOrigins ?? [new URL(webRequest.url).origin];
229
+ let normalizedOrigin;
230
+ try {
231
+ normalizedOrigin = origin ? new URL(origin).origin : void 0;
232
+ } catch {
233
+ normalizedOrigin = void 0;
234
+ }
235
+ if (!normalizedOrigin || !allowedOrigins.includes(normalizedOrigin)) {
236
+ await rejectUpgrade(socket, new Response("Forbidden", { status: 403 }), maxRejectionBodyBytes);
237
+ return;
238
+ }
175
239
  let marker;
176
240
  const response = await app.fetch(webRequest, { websocket: { upgrade: (_request, handler, context) => {
177
241
  marker = new Response(null, { status: 200 });
@@ -183,7 +247,7 @@ function installWebSockets(server, app, options = {}, handlerOptions = {}) {
183
247
  return marker;
184
248
  } } });
185
249
  if (!accepted || response !== accepted.response) {
186
- await rejectUpgrade(socket, response);
250
+ await rejectUpgrade(socket, response, maxRejectionBodyBytes);
187
251
  return;
188
252
  }
189
253
  webSockets.handleUpgrade(request, socket, head, (webSocket) => {
@@ -200,11 +264,13 @@ function installWebSockets(server, app, options = {}, handlerOptions = {}) {
200
264
  //#endregion
201
265
  //#region src/listen.ts
202
266
  function listen(app, options = {}) {
203
- const server = createServer(createNodeHandler(app));
267
+ const host = resolveBindHost(options);
268
+ const handlerOptions = { allowedHosts: [host, "localhost"] };
269
+ const server = createServer(createNodeHandler(app, handlerOptions));
204
270
  if (options.requestTimeout !== void 0) server.requestTimeout = options.requestTimeout;
205
271
  if (options.headersTimeout !== void 0) server.headersTimeout = options.headersTimeout;
206
272
  if (options.keepAliveTimeout !== void 0) server.keepAliveTimeout = options.keepAliveTimeout;
207
- const webSockets = options.websocket ? installWebSockets(server, app, options.websocket === true ? {} : options.websocket) : void 0;
273
+ const webSockets = options.websocket ? installWebSockets(server, app, options.websocket === true ? {} : options.websocket, handlerOptions) : void 0;
208
274
  if (webSockets) {
209
275
  const nativeClose = server.close.bind(server);
210
276
  server.close = ((callback) => {
@@ -221,7 +287,7 @@ function listen(app, options = {}) {
221
287
  reject(error);
222
288
  };
223
289
  server.once("error", onError);
224
- server.listen(options.port ?? 0, options.host, options.backlog, () => {
290
+ server.listen(options.port ?? 0, host, options.backlog, () => {
225
291
  server.off("error", onError);
226
292
  resolve(server);
227
293
  });
@@ -247,8 +313,14 @@ const mimeTypes = {
247
313
  function isAssetPath(pathname) {
248
314
  return extname(pathname) !== "";
249
315
  }
316
+ function isWithinRoot(root, candidate) {
317
+ const prefix = root.endsWith(sep) ? root : `${root}${sep}`;
318
+ return candidate === root || candidate.startsWith(prefix);
319
+ }
250
320
  async function serve(app, options = {}) {
251
- const root = options.assets ? resolve(options.assets.root) : void 0;
321
+ const host = resolveBindHost(options);
322
+ const root = options.assets ? await realpath(resolve(options.assets.root)) : void 0;
323
+ const handlerOptions = { allowedHosts: [host, "localhost"] };
252
324
  const applicationHandler = createNodeHandler({ async fetch(request, dispatchOptions) {
253
325
  const result = await app.fetch(request, dispatchOptions);
254
326
  if (!result.headers.has("cache-control") && result.headers.get("content-type")?.includes("text/html")) {
@@ -261,11 +333,11 @@ async function serve(app, options = {}) {
261
333
  });
262
334
  }
263
335
  return result;
264
- } });
336
+ } }, handlerOptions);
265
337
  const server = createServer(async (request, response) => {
266
338
  let pathname;
267
339
  try {
268
- pathname = decodeURIComponent(new URL(request.url ?? "/", "http://askr.local").pathname);
340
+ pathname = decodeURIComponent(resolveNodeRequestUrl(request, handlerOptions).pathname);
269
341
  } catch {
270
342
  response.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Bad Request");
271
343
  return;
@@ -273,15 +345,21 @@ async function serve(app, options = {}) {
273
345
  const method = request.method ?? "GET";
274
346
  if (root && (method === "GET" || method === "HEAD") && isAssetPath(pathname)) {
275
347
  const extension = extname(pathname).toLowerCase();
276
- const candidate = resolve(root, `.${pathname}`);
277
- const inside = candidate.startsWith(`${root}${sep}`);
348
+ const unresolvedCandidate = resolve(root, `.${pathname}`);
349
+ const inside = isWithinRoot(root, unresolvedCandidate);
350
+ let candidate;
278
351
  let file;
279
352
  if (inside && extension !== ".map") try {
280
- file = await stat(candidate);
353
+ const resolvedCandidate = await realpath(unresolvedCandidate);
354
+ if (isWithinRoot(root, resolvedCandidate)) {
355
+ candidate = resolvedCandidate;
356
+ file = await stat(candidate);
357
+ }
281
358
  } catch {
359
+ candidate = void 0;
282
360
  file = void 0;
283
361
  }
284
- if (!file?.isFile()) {
362
+ if (!candidate || !file?.isFile()) {
285
363
  response.writeHead(404, {
286
364
  "content-type": "text/plain; charset=utf-8",
287
365
  "x-content-type-options": "nosniff"
@@ -303,7 +381,7 @@ async function serve(app, options = {}) {
303
381
  if (options.requestTimeout !== void 0) server.requestTimeout = options.requestTimeout;
304
382
  if (options.headersTimeout !== void 0) server.headersTimeout = options.headersTimeout;
305
383
  if (options.keepAliveTimeout !== void 0) server.keepAliveTimeout = options.keepAliveTimeout;
306
- const webSockets = options.websocket ? installWebSockets(server, app, options.websocket === true ? {} : options.websocket) : void 0;
384
+ const webSockets = options.websocket ? installWebSockets(server, app, options.websocket === true ? {} : options.websocket, handlerOptions) : void 0;
307
385
  let closing;
308
386
  const signals = options.signals === false ? [] : options.signals ?? ["SIGINT", "SIGTERM"];
309
387
  const removeListeners = () => {
@@ -330,7 +408,7 @@ async function serve(app, options = {}) {
330
408
  for (const signal of signals) process.once(signal, shutdown);
331
409
  await new Promise((resolveListen, rejectListen) => {
332
410
  server.once("error", rejectListen);
333
- server.listen(options.port ?? 0, options.host ?? "127.0.0.1", options.backlog, () => {
411
+ server.listen(options.port ?? 0, host, options.backlog, () => {
334
412
  server.removeListener("error", rejectListen);
335
413
  resolveListen();
336
414
  });
@@ -343,10 +421,9 @@ async function serve(app, options = {}) {
343
421
  await close();
344
422
  throw new Error("serve requires a TCP address.");
345
423
  }
346
- const host = options.host ?? "127.0.0.1";
347
424
  return Object.freeze({
348
425
  server,
349
- url: `http://${host}:${address.port}`,
426
+ url: `http://${formatHostForUrl(host)}:${address.port}`,
350
427
  close
351
428
  });
352
429
  }
package/dist/mcp.d.ts CHANGED
@@ -10,6 +10,8 @@ interface McpStdioOptions<Dependencies = undefined> {
10
10
  signal?: AbortSignal;
11
11
  auth?: AuthContext | ((environment: NodeJS.ProcessEnv) => AuthContext | Promise<AuthContext>);
12
12
  environment?: NodeJS.ProcessEnv;
13
+ maxLineBytes?: number;
14
+ maxConcurrency?: number;
13
15
  }
14
16
  interface McpStdioConnection {
15
17
  readonly closed: Promise<void>;
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;
@@ -22,13 +16,19 @@ function connectMcpStdio(mcp, options) {
22
16
  finish = resolve;
23
17
  });
24
18
  let ended = false;
19
+ const maxLineBytes = options.maxLineBytes ?? 1048576;
20
+ 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
+ let active = 0;
24
+ let cleanupInput = () => void 0;
25
25
  const write = (message) => new Promise((resolve, reject) => {
26
26
  output.write(`${JSON.stringify(message)}\n`, (error) => error ? reject(error) : resolve());
27
27
  });
28
28
  const close = async () => {
29
29
  if (ended) return closed;
30
30
  ended = true;
31
- lines.close();
31
+ cleanupInput();
32
32
  for (const controller of controllers.values()) controller.abort();
33
33
  controllers.clear();
34
34
  mcp.terminateSession(sessionId);
@@ -36,7 +36,19 @@ function connectMcpStdio(mcp, options) {
36
36
  return closed;
37
37
  };
38
38
  options.signal?.addEventListener("abort", () => void close(), { once: true });
39
- lines.on("line", (line) => {
39
+ const handleLine = (line) => {
40
+ if (active >= maxConcurrency) {
41
+ write({
42
+ jsonrpc: "2.0",
43
+ id: null,
44
+ error: {
45
+ code: -32600,
46
+ message: "Too many concurrent requests"
47
+ }
48
+ }).catch(() => void close());
49
+ return;
50
+ }
51
+ active += 1;
40
52
  (async () => {
41
53
  let message;
42
54
  try {
@@ -78,14 +90,66 @@ function connectMcpStdio(mcp, options) {
78
90
  if (controller && id !== void 0) controllers.delete(id);
79
91
  }
80
92
  }
81
- })().catch((error) => diagnostics.write(`MCP stdio error: ${error instanceof Error ? error.message : String(error)}\n`));
82
- });
83
- lines.once("close", () => {
93
+ })().catch((error) => diagnostics.write(`MCP stdio error: ${error instanceof Error ? error.message : String(error)}\n`)).finally(() => {
94
+ active -= 1;
95
+ });
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 = () => {
84
138
  if (!ended) {
139
+ if (!discardingOversizedLine && lineBytes > 0) handleLine(lineBuffer.subarray(0, lineBytes).toString("utf8"));
85
140
  ended = true;
141
+ cleanupInput();
86
142
  finish();
87
143
  }
88
- });
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
+ };
89
153
  return {
90
154
  closed,
91
155
  close
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askrjs/node",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Node http adapter for @askrjs/server",
5
5
  "keywords": [
6
6
  "askr",
@@ -60,6 +60,7 @@
60
60
  "ws": "^8.21.1"
61
61
  },
62
62
  "devDependencies": {
63
+ "@askrjs/schema": ">=0.0.2 <0.1.0",
63
64
  "@types/node": "^26.0.0",
64
65
  "@types/ws": "^8.18.1",
65
66
  "publint": "^0.3.21",