@askrjs/node 0.0.6 → 0.0.8
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 +4 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +73 -20
- package/dist/mcp.js +56 -22
- package/package.json +4 -3
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
|
|
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
|
|
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
|
|
167
|
-
const
|
|
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) =>
|
|
170
|
-
|
|
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
|
|
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,
|
|
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
|
|
275
|
-
const
|
|
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(
|
|
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
|
|
301
|
-
const inside =
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"description": "Node http adapter for @askrjs/server",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"askr",
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"vitest": "^4.0.0"
|
|
70
70
|
},
|
|
71
71
|
"engines": {
|
|
72
|
-
"node": ">=
|
|
73
|
-
}
|
|
72
|
+
"node": ">=24.0.0"
|
|
73
|
+
},
|
|
74
|
+
"packageManager": "npm@12.0.1"
|
|
74
75
|
}
|