@askrjs/node 0.0.4 → 0.0.6
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 +23 -2
- package/dist/index.d.ts +12 -1
- package/dist/index.js +118 -9
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +31 -1
- package/package.json +5 -2
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`
|
|
@@ -35,12 +35,33 @@ status text, and HEAD responses.
|
|
|
35
35
|
```ts
|
|
36
36
|
import { listen } from "@askrjs/node";
|
|
37
37
|
|
|
38
|
-
const server = await listen(app, {
|
|
38
|
+
const server = await listen(app, {
|
|
39
|
+
port: 3000,
|
|
40
|
+
requestTimeout: 120_000,
|
|
41
|
+
headersTimeout: 60_000,
|
|
42
|
+
keepAliveTimeout: 5_000,
|
|
43
|
+
});
|
|
39
44
|
server.close();
|
|
40
45
|
```
|
|
41
46
|
|
|
42
47
|
Pass an `AbortSignal` to integrate shutdown with your process lifecycle.
|
|
43
48
|
|
|
49
|
+
Enable the built-in `ws` transport with `websocket: true`. It defaults to a
|
|
50
|
+
1 MiB maximum message payload with compression disabled; pass
|
|
51
|
+
`websocket: { maxPayload, perMessageDeflate }` to override those settings.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
router.ws("/echo", (socket) => {
|
|
55
|
+
socket.onMessage((message) => socket.send(message));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const server = await listen(createServerApp({ router }), { websocket: true });
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Route matching, authentication, and middleware complete before the handshake.
|
|
62
|
+
Rejected upgrades preserve the application response, and shutdown closes active
|
|
63
|
+
sockets.
|
|
64
|
+
|
|
44
65
|
## Serve a production application
|
|
45
66
|
|
|
46
67
|
```ts
|
package/dist/index.d.ts
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
import { IncomingMessage, Server, ServerResponse } from "node:http";
|
|
2
|
+
import { PerMessageDeflateOptions } from "ws";
|
|
2
3
|
import { ServerApp } from "@askrjs/server";
|
|
3
4
|
import { AddressInfo } from "node:net";
|
|
4
5
|
//#region src/contracts.d.ts
|
|
6
|
+
interface NodeWebSocketOptions {
|
|
7
|
+
readonly maxPayload?: number;
|
|
8
|
+
readonly perMessageDeflate?: boolean | PerMessageDeflateOptions;
|
|
9
|
+
readonly allowedOrigins?: readonly string[];
|
|
10
|
+
}
|
|
5
11
|
interface NodeHandlerOptions {
|
|
6
12
|
baseUrl?: string;
|
|
13
|
+
allowedHosts?: readonly string[];
|
|
7
14
|
}
|
|
8
15
|
interface ListenOptions {
|
|
9
16
|
port?: number;
|
|
10
17
|
host?: string;
|
|
11
18
|
backlog?: number;
|
|
12
19
|
signal?: AbortSignal;
|
|
20
|
+
requestTimeout?: number;
|
|
21
|
+
headersTimeout?: number;
|
|
22
|
+
keepAliveTimeout?: number;
|
|
23
|
+
websocket?: boolean | NodeWebSocketOptions;
|
|
13
24
|
}
|
|
14
25
|
interface ServeOptions extends ListenOptions {
|
|
15
26
|
readonly assets?: {
|
|
@@ -39,4 +50,4 @@ declare function serve(app: ServerApp & {
|
|
|
39
50
|
close?: () => void | Promise<void>;
|
|
40
51
|
}, options?: ServeOptions): Promise<ServedApplication>;
|
|
41
52
|
//#endregion
|
|
42
|
-
export { ConnectNext, ListenOptions, ListeningServer, NodeHandler, NodeHandlerOptions, ServeOptions, ServedApplication, createNodeHandler, listen, serve };
|
|
53
|
+
export { ConnectNext, ListenOptions, ListeningServer, NodeHandler, NodeHandlerOptions, NodeWebSocketOptions, ServeOptions, ServedApplication, createNodeHandler, listen, serve };
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Readable } from "node:stream";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
+
import { WebSocketServer } from "ws";
|
|
3
4
|
import { createReadStream } from "node:fs";
|
|
4
5
|
import { stat } from "node:fs/promises";
|
|
5
6
|
import { extname, resolve, sep } from "node:path";
|
|
6
7
|
//#region src/request.ts
|
|
8
|
+
var NodeRequestError = class extends TypeError {};
|
|
7
9
|
function requestHeaders(request) {
|
|
8
10
|
const headers = new Headers();
|
|
9
11
|
for (const [key, value] of Object.entries(request.headers)) {
|
|
@@ -13,10 +15,17 @@ function requestHeaders(request) {
|
|
|
13
15
|
return headers;
|
|
14
16
|
}
|
|
15
17
|
function requestFromNode(request, options, signal) {
|
|
16
|
-
const
|
|
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.");
|
|
17
26
|
const method = request.method ?? "GET";
|
|
18
27
|
const body = method === "GET" || method === "HEAD" ? void 0 : Readable.toWeb(request);
|
|
19
|
-
return new Request(
|
|
28
|
+
return new Request(target, {
|
|
20
29
|
method,
|
|
21
30
|
headers: requestHeaders(request),
|
|
22
31
|
body,
|
|
@@ -100,10 +109,11 @@ function minimalError(response, error) {
|
|
|
100
109
|
response.destroy(error instanceof Error ? error : void 0);
|
|
101
110
|
return;
|
|
102
111
|
}
|
|
103
|
-
|
|
104
|
-
response.
|
|
112
|
+
const clientError = error instanceof NodeRequestError;
|
|
113
|
+
response.statusCode = clientError ? 400 : 500;
|
|
114
|
+
response.statusMessage = clientError ? "Bad Request" : "Internal Server Error";
|
|
105
115
|
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
106
|
-
response.end("Internal Server Error");
|
|
116
|
+
response.end(clientError ? "Bad Request" : "Internal Server Error");
|
|
107
117
|
}
|
|
108
118
|
function attachAbort(request, response, controller) {
|
|
109
119
|
const abort = () => controller.abort();
|
|
@@ -129,9 +139,102 @@ function createNodeHandler(app, options = {}) {
|
|
|
129
139
|
};
|
|
130
140
|
}
|
|
131
141
|
//#endregion
|
|
142
|
+
//#region src/websocket.ts
|
|
143
|
+
function subscribe(socket, event, listener) {
|
|
144
|
+
socket.on(event, listener);
|
|
145
|
+
return () => socket.off(event, listener);
|
|
146
|
+
}
|
|
147
|
+
function socketLike(socket) {
|
|
148
|
+
return {
|
|
149
|
+
send: (data) => socket.send(data),
|
|
150
|
+
close: (code, reason) => socket.close(code, reason),
|
|
151
|
+
onMessage: (listener) => subscribe(socket, "message", (data, binary) => {
|
|
152
|
+
if (!binary) listener(data.toString());
|
|
153
|
+
else {
|
|
154
|
+
const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
|
|
155
|
+
listener(new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength));
|
|
156
|
+
}
|
|
157
|
+
}),
|
|
158
|
+
onClose: (listener) => subscribe(socket, "close", (code, reason) => listener({
|
|
159
|
+
code,
|
|
160
|
+
reason: reason.toString(),
|
|
161
|
+
wasClean: code === 1e3
|
|
162
|
+
})),
|
|
163
|
+
onError: (listener) => subscribe(socket, "error", listener)
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
async function rejectUpgrade(socket, response) {
|
|
167
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
168
|
+
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}`);
|
|
171
|
+
lines.push("connection: close", "", "");
|
|
172
|
+
socket.end(Buffer.concat([Buffer.from(lines.join("\r\n")), body]));
|
|
173
|
+
}
|
|
174
|
+
function installWebSockets(server, app, options = {}, handlerOptions = {}) {
|
|
175
|
+
const webSockets = new WebSocketServer({
|
|
176
|
+
noServer: true,
|
|
177
|
+
maxPayload: options.maxPayload ?? 1048576,
|
|
178
|
+
perMessageDeflate: options.perMessageDeflate ?? false
|
|
179
|
+
});
|
|
180
|
+
server.on("upgrade", (request, socket, head) => {
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
let accepted;
|
|
183
|
+
Promise.resolve().then(() => requestFromNode(request, handlerOptions, controller.signal)).then(async (webRequest) => {
|
|
184
|
+
const originHeader = request.headers.origin;
|
|
185
|
+
const origin = Array.isArray(originHeader) ? void 0 : originHeader;
|
|
186
|
+
const allowedOrigins = options.allowedOrigins ?? [new URL(webRequest.url).origin];
|
|
187
|
+
let normalizedOrigin;
|
|
188
|
+
try {
|
|
189
|
+
normalizedOrigin = origin ? new URL(origin).origin : void 0;
|
|
190
|
+
} catch {
|
|
191
|
+
normalizedOrigin = void 0;
|
|
192
|
+
}
|
|
193
|
+
if (!normalizedOrigin || !allowedOrigins.includes(normalizedOrigin)) {
|
|
194
|
+
await rejectUpgrade(socket, new Response("Forbidden", { status: 403 }));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
let marker;
|
|
198
|
+
const response = await app.fetch(webRequest, { websocket: { upgrade: (_request, handler, context) => {
|
|
199
|
+
marker = new Response(null, { status: 200 });
|
|
200
|
+
accepted = {
|
|
201
|
+
handler,
|
|
202
|
+
context,
|
|
203
|
+
response: marker
|
|
204
|
+
};
|
|
205
|
+
return marker;
|
|
206
|
+
} } });
|
|
207
|
+
if (!accepted || response !== accepted.response) {
|
|
208
|
+
await rejectUpgrade(socket, response);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
webSockets.handleUpgrade(request, socket, head, (webSocket) => {
|
|
212
|
+
webSocket.once("close", () => controller.abort());
|
|
213
|
+
Promise.resolve(accepted.handler(socketLike(webSocket), accepted.context)).catch(() => webSocket.close(1011, "WebSocket handler failed"));
|
|
214
|
+
});
|
|
215
|
+
}).catch(() => socket.destroy());
|
|
216
|
+
});
|
|
217
|
+
return { close() {
|
|
218
|
+
for (const socket of webSockets.clients) socket.close(1001, "Server shutting down");
|
|
219
|
+
webSockets.close();
|
|
220
|
+
} };
|
|
221
|
+
}
|
|
222
|
+
//#endregion
|
|
132
223
|
//#region src/listen.ts
|
|
133
224
|
function listen(app, options = {}) {
|
|
134
|
-
const
|
|
225
|
+
const handlerOptions = { allowedHosts: [options.host ?? "127.0.0.1", "localhost"] };
|
|
226
|
+
const server = createServer(createNodeHandler(app, handlerOptions));
|
|
227
|
+
if (options.requestTimeout !== void 0) server.requestTimeout = options.requestTimeout;
|
|
228
|
+
if (options.headersTimeout !== void 0) server.headersTimeout = options.headersTimeout;
|
|
229
|
+
if (options.keepAliveTimeout !== void 0) server.keepAliveTimeout = options.keepAliveTimeout;
|
|
230
|
+
const webSockets = options.websocket ? installWebSockets(server, app, options.websocket === true ? {} : options.websocket, handlerOptions) : void 0;
|
|
231
|
+
if (webSockets) {
|
|
232
|
+
const nativeClose = server.close.bind(server);
|
|
233
|
+
server.close = ((callback) => {
|
|
234
|
+
webSockets.close();
|
|
235
|
+
return nativeClose(callback);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
135
238
|
const close = () => server.close();
|
|
136
239
|
options.signal?.addEventListener("abort", close, { once: true });
|
|
137
240
|
server.once("close", () => options.signal?.removeEventListener("abort", close));
|
|
@@ -169,8 +272,9 @@ function isAssetPath(pathname) {
|
|
|
169
272
|
}
|
|
170
273
|
async function serve(app, options = {}) {
|
|
171
274
|
const root = options.assets ? resolve(options.assets.root) : void 0;
|
|
172
|
-
const
|
|
173
|
-
|
|
275
|
+
const handlerOptions = { allowedHosts: [options.host ?? "127.0.0.1", "localhost"] };
|
|
276
|
+
const applicationHandler = createNodeHandler({ async fetch(request, dispatchOptions) {
|
|
277
|
+
const result = await app.fetch(request, dispatchOptions);
|
|
174
278
|
if (!result.headers.has("cache-control") && result.headers.get("content-type")?.includes("text/html")) {
|
|
175
279
|
const headers = new Headers(result.headers);
|
|
176
280
|
headers.set("cache-control", "no-cache");
|
|
@@ -181,7 +285,7 @@ async function serve(app, options = {}) {
|
|
|
181
285
|
});
|
|
182
286
|
}
|
|
183
287
|
return result;
|
|
184
|
-
} });
|
|
288
|
+
} }, handlerOptions);
|
|
185
289
|
const server = createServer(async (request, response) => {
|
|
186
290
|
let pathname;
|
|
187
291
|
try {
|
|
@@ -220,6 +324,10 @@ async function serve(app, options = {}) {
|
|
|
220
324
|
}
|
|
221
325
|
applicationHandler(request, response);
|
|
222
326
|
});
|
|
327
|
+
if (options.requestTimeout !== void 0) server.requestTimeout = options.requestTimeout;
|
|
328
|
+
if (options.headersTimeout !== void 0) server.headersTimeout = options.headersTimeout;
|
|
329
|
+
if (options.keepAliveTimeout !== void 0) server.keepAliveTimeout = options.keepAliveTimeout;
|
|
330
|
+
const webSockets = options.websocket ? installWebSockets(server, app, options.websocket === true ? {} : options.websocket, handlerOptions) : void 0;
|
|
223
331
|
let closing;
|
|
224
332
|
const signals = options.signals === false ? [] : options.signals ?? ["SIGINT", "SIGTERM"];
|
|
225
333
|
const removeListeners = () => {
|
|
@@ -228,6 +336,7 @@ async function serve(app, options = {}) {
|
|
|
228
336
|
};
|
|
229
337
|
const close = () => closing ??= new Promise((resolveClose, rejectClose) => {
|
|
230
338
|
removeListeners();
|
|
339
|
+
webSockets?.close();
|
|
231
340
|
const finish = async (serverError) => {
|
|
232
341
|
try {
|
|
233
342
|
await app.close?.();
|
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
|
@@ -22,6 +22,11 @@ function connectMcpStdio(mcp, options) {
|
|
|
22
22
|
finish = resolve;
|
|
23
23
|
});
|
|
24
24
|
let ended = false;
|
|
25
|
+
const maxLineBytes = options.maxLineBytes ?? 1048576;
|
|
26
|
+
const maxConcurrency = options.maxConcurrency ?? 16;
|
|
27
|
+
if (!Number.isInteger(maxLineBytes) || maxLineBytes <= 0) throw new TypeError("MCP maxLineBytes must be a positive integer.");
|
|
28
|
+
if (!Number.isInteger(maxConcurrency) || maxConcurrency <= 0) throw new TypeError("MCP maxConcurrency must be a positive integer.");
|
|
29
|
+
let active = 0;
|
|
25
30
|
const write = (message) => new Promise((resolve, reject) => {
|
|
26
31
|
output.write(`${JSON.stringify(message)}\n`, (error) => error ? reject(error) : resolve());
|
|
27
32
|
});
|
|
@@ -37,6 +42,29 @@ function connectMcpStdio(mcp, options) {
|
|
|
37
42
|
};
|
|
38
43
|
options.signal?.addEventListener("abort", () => void close(), { once: true });
|
|
39
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
|
+
}
|
|
56
|
+
if (active >= maxConcurrency) {
|
|
57
|
+
write({
|
|
58
|
+
jsonrpc: "2.0",
|
|
59
|
+
id: null,
|
|
60
|
+
error: {
|
|
61
|
+
code: -32600,
|
|
62
|
+
message: "Too many concurrent requests"
|
|
63
|
+
}
|
|
64
|
+
}).catch(() => void close());
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
active += 1;
|
|
40
68
|
(async () => {
|
|
41
69
|
let message;
|
|
42
70
|
try {
|
|
@@ -78,7 +106,9 @@ function connectMcpStdio(mcp, options) {
|
|
|
78
106
|
if (controller && id !== void 0) controllers.delete(id);
|
|
79
107
|
}
|
|
80
108
|
}
|
|
81
|
-
})().catch((error) => diagnostics.write(`MCP stdio error: ${error instanceof Error ? error.message : String(error)}\n`))
|
|
109
|
+
})().catch((error) => diagnostics.write(`MCP stdio error: ${error instanceof Error ? error.message : String(error)}\n`)).finally(() => {
|
|
110
|
+
active -= 1;
|
|
111
|
+
});
|
|
82
112
|
});
|
|
83
113
|
lines.once("close", () => {
|
|
84
114
|
if (!ended) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askrjs/node",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "Node http adapter for @askrjs/server",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"askr",
|
|
@@ -56,10 +56,13 @@
|
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"@askrjs/auth": ">=0.0.1 <0.1.0",
|
|
59
|
-
"@askrjs/server": ">=0.0.
|
|
59
|
+
"@askrjs/server": ">=0.0.5 <0.1.0",
|
|
60
|
+
"ws": "^8.21.1"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
63
|
+
"@askrjs/schema": ">=0.0.2 <0.1.0",
|
|
62
64
|
"@types/node": "^26.0.0",
|
|
65
|
+
"@types/ws": "^8.18.1",
|
|
63
66
|
"publint": "^0.3.21",
|
|
64
67
|
"typescript": "^6.0.0",
|
|
65
68
|
"vite-plus": "^0.2.4",
|