@askrjs/node 0.0.8 → 0.0.11
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 +43 -5
- package/dist/index.d.ts +90 -6
- package/dist/index.js +442 -97
- package/dist/mcp.d.ts +26 -0
- package/dist/mcp.js +129 -60
- package/package.json +14 -12
package/dist/index.js
CHANGED
|
@@ -1,41 +1,150 @@
|
|
|
1
|
-
import {
|
|
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";
|
|
9
|
+
//#region src/client-address.ts
|
|
10
|
+
/**
|
|
11
|
+
* Reserved request header containing the TCP peer address authenticated by the Node adapter.
|
|
12
|
+
* Any value supplied by the HTTP client is overwritten before application dispatch.
|
|
13
|
+
*/
|
|
14
|
+
const CLIENT_ADDRESS_HEADER = "x-askr-client-address";
|
|
15
|
+
/** Normalizes the socket peer address used for the adapter-authenticated request header. */
|
|
16
|
+
function normalizeClientAddress(address) {
|
|
17
|
+
if (!address) return "unknown";
|
|
18
|
+
if (address.toLowerCase().startsWith("::ffff:")) {
|
|
19
|
+
const mapped = address.slice(7);
|
|
20
|
+
if (isIP(mapped) === 4) return mapped;
|
|
21
|
+
}
|
|
22
|
+
return address;
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
7
25
|
//#region src/request.ts
|
|
8
26
|
var NodeRequestError = class extends TypeError {};
|
|
27
|
+
function explicitPort(authority) {
|
|
28
|
+
if (authority.startsWith("[")) {
|
|
29
|
+
const bracket = authority.indexOf("]");
|
|
30
|
+
return bracket >= 0 && authority[bracket + 1] === ":" ? authority.slice(bracket + 2) : void 0;
|
|
31
|
+
}
|
|
32
|
+
const first = authority.indexOf(":");
|
|
33
|
+
return first >= 0 && first === authority.lastIndexOf(":") ? authority.slice(first + 1) : void 0;
|
|
34
|
+
}
|
|
35
|
+
function parseAuthority(value, allowBareIpv6 = false) {
|
|
36
|
+
if (!value || value.trim() !== value) throw new NodeRequestError("Request host must be a non-empty HTTP authority.");
|
|
37
|
+
const bareIpv6 = isIP(value) === 6;
|
|
38
|
+
if (bareIpv6 && !allowBareIpv6) throw new NodeRequestError("IPv6 request hosts must use brackets.");
|
|
39
|
+
const authority = bareIpv6 ? `[${value}]` : value;
|
|
40
|
+
const port = explicitPort(authority);
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = new URL(`http://${authority}`);
|
|
44
|
+
} catch {
|
|
45
|
+
throw new NodeRequestError("Request host is not a valid HTTP authority.");
|
|
46
|
+
}
|
|
47
|
+
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.");
|
|
48
|
+
return {
|
|
49
|
+
hostname: parsed.hostname,
|
|
50
|
+
host: port === void 0 ? parsed.hostname : `${parsed.hostname}:${Number(port)}`
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function prepareBaseUrl(value) {
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = new URL(value);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new TypeError("Node handler baseUrl must be a valid HTTP or HTTPS origin.");
|
|
59
|
+
}
|
|
60
|
+
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.");
|
|
61
|
+
return parsed.origin;
|
|
62
|
+
}
|
|
63
|
+
function prepareNodeHandlerOptions(options) {
|
|
64
|
+
const allowedHosts = (options.allowedHosts ?? []).map((value) => {
|
|
65
|
+
try {
|
|
66
|
+
const parsed = parseAuthority(value, true);
|
|
67
|
+
return explicitPort(isIP(value) === 6 ? `[${value}]` : value) === void 0 ? { hostname: parsed.hostname } : parsed;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new TypeError(`Node handler allowedHosts contains an invalid host: ${error instanceof Error ? error.message : String(error)}`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
const baseUrl = options.baseUrl === void 0 ? void 0 : prepareBaseUrl(options.baseUrl);
|
|
73
|
+
if (!baseUrl && allowedHosts.length === 0) throw new TypeError("Node handling requires baseUrl or an allowedHosts allowlist.");
|
|
74
|
+
return {
|
|
75
|
+
allowedHosts,
|
|
76
|
+
baseUrl,
|
|
77
|
+
hostCache: {}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
9
80
|
function requestHeaders(request) {
|
|
10
81
|
const headers = new Headers();
|
|
11
|
-
for (const
|
|
82
|
+
for (const key in request.headers) {
|
|
83
|
+
const value = request.headers[key];
|
|
12
84
|
if (value === void 0) continue;
|
|
13
|
-
|
|
85
|
+
if (Array.isArray(value)) for (const item of value) headers.append(key, item);
|
|
86
|
+
else headers.set(key, value);
|
|
14
87
|
}
|
|
88
|
+
headers.set(CLIENT_ADDRESS_HEADER, normalizeClientAddress(request.socket.remoteAddress));
|
|
15
89
|
return headers;
|
|
16
90
|
}
|
|
17
|
-
function
|
|
91
|
+
function resolveNodeRequestHref(request, options) {
|
|
18
92
|
const host = request.headers.host;
|
|
19
|
-
if (!options.baseUrl && !options.allowedHosts?.length) throw new NodeRequestError("Node handling requires baseUrl or an allowedHosts allowlist.");
|
|
20
93
|
if (!host) throw new NodeRequestError("Request Host header is required.");
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
94
|
+
let base = options.hostCache.value === host ? options.hostCache.base : void 0;
|
|
95
|
+
if (!base) {
|
|
96
|
+
const authority = parseAuthority(host);
|
|
97
|
+
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.");
|
|
98
|
+
base = options.baseUrl ?? `http://${authority.host}`;
|
|
99
|
+
options.hostCache.value = host;
|
|
100
|
+
options.hostCache.base = base;
|
|
101
|
+
}
|
|
102
|
+
const requestTarget = request.url || "/";
|
|
103
|
+
if (requestTarget === "*") {
|
|
104
|
+
if (request.method === "OPTIONS") return `${base}/*`;
|
|
105
|
+
throw new NodeRequestError("Asterisk-form request targets require OPTIONS.");
|
|
106
|
+
}
|
|
107
|
+
if (requestTarget.startsWith("//")) throw new NodeRequestError("Network-path request targets are not allowed.");
|
|
108
|
+
if (requestTarget.startsWith("/")) {
|
|
109
|
+
if (requestTarget.includes("\\")) throw new NodeRequestError("Request targets must not contain backslashes.");
|
|
110
|
+
if (requestTarget.includes("#")) throw new NodeRequestError("Request targets must not contain fragments.");
|
|
111
|
+
return `${base}${requestTarget}`;
|
|
112
|
+
}
|
|
113
|
+
if (!/^https?:\/\//iu.test(requestTarget)) throw new NodeRequestError("Request target must use origin-form or absolute-form.");
|
|
114
|
+
let target;
|
|
115
|
+
try {
|
|
116
|
+
target = new URL(requestTarget, base);
|
|
117
|
+
} catch {
|
|
118
|
+
throw new NodeRequestError("Request target is not a valid HTTP URL.");
|
|
119
|
+
}
|
|
120
|
+
if (target.origin !== new URL(base).origin) throw new NodeRequestError("Absolute-form request target origin is not allowed.");
|
|
121
|
+
if (target.hash) throw new NodeRequestError("Request targets must not contain fragments.");
|
|
122
|
+
return target.href;
|
|
123
|
+
}
|
|
124
|
+
function resolveNodeRequestUrl(request, options) {
|
|
125
|
+
try {
|
|
126
|
+
return new URL(resolveNodeRequestHref(request, options));
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error instanceof NodeRequestError) throw error;
|
|
129
|
+
throw new NodeRequestError("Request target is not a valid HTTP URL.");
|
|
130
|
+
}
|
|
27
131
|
}
|
|
28
132
|
function requestFromNode(request, options, signal) {
|
|
29
|
-
const target =
|
|
133
|
+
const target = resolveNodeRequestHref(request, options);
|
|
30
134
|
const method = request.method ?? "GET";
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
135
|
+
if (method === "GET" || method === "HEAD") request.resume();
|
|
136
|
+
const body = method === "GET" || method === "HEAD" ? void 0 : request;
|
|
137
|
+
try {
|
|
138
|
+
return new Request(target, {
|
|
139
|
+
method,
|
|
140
|
+
headers: requestHeaders(request),
|
|
141
|
+
body,
|
|
142
|
+
signal,
|
|
143
|
+
...body ? { duplex: "half" } : {}
|
|
144
|
+
});
|
|
145
|
+
} catch {
|
|
146
|
+
throw new NodeRequestError("Node request cannot be represented as a Web Request.");
|
|
147
|
+
}
|
|
39
148
|
}
|
|
40
149
|
//#endregion
|
|
41
150
|
//#region src/response.ts
|
|
@@ -79,7 +188,12 @@ async function writeNodeResponse(response, target, method) {
|
|
|
79
188
|
target.statusCode = response.status;
|
|
80
189
|
target.statusMessage = response.statusText;
|
|
81
190
|
writeHeaders(response, target);
|
|
82
|
-
if (
|
|
191
|
+
if (method === "HEAD") {
|
|
192
|
+
response.body?.cancel("HEAD responses do not include a body").catch(() => void 0);
|
|
193
|
+
target.end();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (!response.body) {
|
|
83
197
|
target.end();
|
|
84
198
|
return;
|
|
85
199
|
}
|
|
@@ -95,7 +209,7 @@ async function writeNodeResponse(response, target, method) {
|
|
|
95
209
|
if (disconnected) return;
|
|
96
210
|
const part = await reader.read();
|
|
97
211
|
if (part.done || disconnected) break;
|
|
98
|
-
if (!target.write(
|
|
212
|
+
if (!target.write(part.value)) await waitForDrain(target);
|
|
99
213
|
}
|
|
100
214
|
if (!disconnected) target.end();
|
|
101
215
|
} catch (error) {
|
|
@@ -104,20 +218,25 @@ async function writeNodeResponse(response, target, method) {
|
|
|
104
218
|
target.destroy(error instanceof Error ? error : void 0);
|
|
105
219
|
} finally {
|
|
106
220
|
target.off("close", onClose);
|
|
221
|
+
reader.releaseLock();
|
|
107
222
|
}
|
|
108
223
|
}
|
|
109
224
|
//#endregion
|
|
110
225
|
//#region src/handler.ts
|
|
111
226
|
function minimalError(response, error) {
|
|
112
|
-
if (response.headersSent) {
|
|
227
|
+
if (response.headersSent || response.destroyed) {
|
|
113
228
|
response.destroy(error instanceof Error ? error : void 0);
|
|
114
229
|
return;
|
|
115
230
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
231
|
+
try {
|
|
232
|
+
const clientError = error instanceof NodeRequestError;
|
|
233
|
+
response.statusCode = clientError ? 400 : 500;
|
|
234
|
+
response.statusMessage = clientError ? "Bad Request" : "Internal Server Error";
|
|
235
|
+
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
236
|
+
response.end(clientError ? "Bad Request" : "Internal Server Error");
|
|
237
|
+
} catch {
|
|
238
|
+
response.destroy(error instanceof Error ? error : void 0);
|
|
239
|
+
}
|
|
121
240
|
}
|
|
122
241
|
function attachAbort(request, response, controller) {
|
|
123
242
|
const abort = () => controller.abort();
|
|
@@ -135,68 +254,148 @@ function attachAbort(request, response, controller) {
|
|
|
135
254
|
response.off("close", abort);
|
|
136
255
|
};
|
|
137
256
|
}
|
|
138
|
-
function
|
|
257
|
+
async function handleNodeRequest(app, preparedOptions, request, response, signal, cleanup, next) {
|
|
258
|
+
try {
|
|
259
|
+
const webRequest = requestFromNode(request, preparedOptions, signal);
|
|
260
|
+
await writeNodeResponse(await app.fetch(webRequest), response, request.method);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (!next) {
|
|
263
|
+
minimalError(response, error);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
next(error);
|
|
268
|
+
} catch (nextError) {
|
|
269
|
+
minimalError(response, nextError);
|
|
270
|
+
}
|
|
271
|
+
} finally {
|
|
272
|
+
cleanup();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Wraps an `@askrjs/server` application as a Node-style request handler.
|
|
277
|
+
*
|
|
278
|
+
* Converts each incoming `IncomingMessage`/`ServerResponse` pair into a web
|
|
279
|
+
* `Request`, dispatches it through `app.fetch`, and writes the resulting web
|
|
280
|
+
* `Response` back to Node. Errors are reported to `next` when provided,
|
|
281
|
+
* otherwise a minimal 400/500 response is written directly.
|
|
282
|
+
*
|
|
283
|
+
* @param app - The application to dispatch requests to.
|
|
284
|
+
* @param options - Options controlling base URL resolution and host validation.
|
|
285
|
+
* @returns A handler usable with `http.createServer` or Connect-style middleware.
|
|
286
|
+
*/
|
|
287
|
+
function createNodeHandler(app, options) {
|
|
288
|
+
const preparedOptions = prepareNodeHandlerOptions(options);
|
|
139
289
|
return (request, response, next) => {
|
|
140
290
|
const controller = new AbortController();
|
|
141
291
|
const cleanup = attachAbort(request, response, controller);
|
|
142
|
-
|
|
292
|
+
handleNodeRequest(app, preparedOptions, request, response, controller.signal, cleanup, next);
|
|
143
293
|
};
|
|
144
294
|
}
|
|
145
295
|
//#endregion
|
|
146
296
|
//#region src/bind.ts
|
|
297
|
+
function isLoopbackHost(host) {
|
|
298
|
+
if (host.toLowerCase() === "localhost") return true;
|
|
299
|
+
const family = isIP(host);
|
|
300
|
+
if (family === 4) return host.split(".", 1)[0] === "127";
|
|
301
|
+
if (family !== 6) return false;
|
|
302
|
+
const normalized = new URL(`http://[${host}]`).hostname;
|
|
303
|
+
return normalized === "[::1]" || normalized.startsWith("[::ffff:7f");
|
|
304
|
+
}
|
|
147
305
|
function resolveBindHost(options) {
|
|
148
306
|
const host = options.host ?? "127.0.0.1";
|
|
149
|
-
if (!(host
|
|
307
|
+
if (!isLoopbackHost(host) && options.allowPublicBind !== true) throw new TypeError(`Refusing to bind non-loopback host ${host} without allowPublicBind: true.`);
|
|
150
308
|
return host;
|
|
151
309
|
}
|
|
310
|
+
function handlerOptionsForHost(options, host) {
|
|
311
|
+
return {
|
|
312
|
+
...options.baseUrl === void 0 ? {} : { baseUrl: options.baseUrl },
|
|
313
|
+
allowedHosts: [.../* @__PURE__ */ new Set([
|
|
314
|
+
host,
|
|
315
|
+
"localhost",
|
|
316
|
+
...options.allowedHosts ?? []
|
|
317
|
+
])]
|
|
318
|
+
};
|
|
319
|
+
}
|
|
152
320
|
function formatHostForUrl(host) {
|
|
153
321
|
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
154
322
|
}
|
|
155
323
|
//#endregion
|
|
324
|
+
//#region src/server-options.ts
|
|
325
|
+
function timeout(value, name) {
|
|
326
|
+
if (value === void 0) return void 0;
|
|
327
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} must be a non-negative safe integer.`);
|
|
328
|
+
return value;
|
|
329
|
+
}
|
|
330
|
+
function applyServerTimeouts(server, options) {
|
|
331
|
+
const requestTimeout = timeout(options.requestTimeout, "requestTimeout");
|
|
332
|
+
const headersTimeout = timeout(options.headersTimeout, "headersTimeout");
|
|
333
|
+
const keepAliveTimeout = timeout(options.keepAliveTimeout, "keepAliveTimeout");
|
|
334
|
+
if (requestTimeout !== void 0) server.requestTimeout = requestTimeout;
|
|
335
|
+
if (headersTimeout !== void 0) server.headersTimeout = headersTimeout;
|
|
336
|
+
if (keepAliveTimeout !== void 0) server.keepAliveTimeout = keepAliveTimeout;
|
|
337
|
+
}
|
|
338
|
+
//#endregion
|
|
156
339
|
//#region src/websocket.ts
|
|
157
340
|
function subscribe(socket, event, listener) {
|
|
158
341
|
socket.on(event, listener);
|
|
159
342
|
return () => socket.off(event, listener);
|
|
160
343
|
}
|
|
344
|
+
function notify(socket, listener, ...values) {
|
|
345
|
+
try {
|
|
346
|
+
listener(...values);
|
|
347
|
+
} catch {
|
|
348
|
+
if (socket.readyState === WebSocket.OPEN) socket.close(1011, "WebSocket listener failed");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
161
351
|
function socketLike(socket) {
|
|
162
352
|
return {
|
|
163
353
|
send: (data) => socket.send(data),
|
|
164
354
|
close: (code, reason) => socket.close(code, reason),
|
|
165
355
|
onMessage: (listener) => subscribe(socket, "message", (data, binary) => {
|
|
166
|
-
if (!binary) listener
|
|
356
|
+
if (!binary) notify(socket, listener, data.toString());
|
|
167
357
|
else {
|
|
168
358
|
const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
|
|
169
|
-
listener
|
|
359
|
+
notify(socket, listener, new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength));
|
|
170
360
|
}
|
|
171
361
|
}),
|
|
172
|
-
onClose: (listener) => subscribe(socket, "close", (code, reason) => listener
|
|
362
|
+
onClose: (listener) => subscribe(socket, "close", (code, reason) => notify(socket, listener, {
|
|
173
363
|
code,
|
|
174
364
|
reason: reason.toString(),
|
|
175
|
-
wasClean: code
|
|
365
|
+
wasClean: code !== 1006
|
|
176
366
|
})),
|
|
177
|
-
onError: (listener) => subscribe(socket, "error", listener)
|
|
367
|
+
onError: (listener) => subscribe(socket, "error", (error) => notify(socket, listener, error))
|
|
178
368
|
};
|
|
179
369
|
}
|
|
370
|
+
function normalizeHttpOrigin(value) {
|
|
371
|
+
const parsed = new URL(value);
|
|
372
|
+
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.");
|
|
373
|
+
return parsed.origin;
|
|
374
|
+
}
|
|
180
375
|
async function readRejectionBody(response, maxBytes) {
|
|
181
376
|
const lengthHeader = response.headers.get("content-length");
|
|
182
377
|
const declaredLength = lengthHeader === null ? void 0 : Number(lengthHeader);
|
|
183
378
|
if (declaredLength !== void 0 && Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
184
|
-
await response.body?.cancel("WebSocket rejection body exceeded maxRejectionBodyBytes");
|
|
379
|
+
await response.body?.cancel("WebSocket rejection body exceeded maxRejectionBodyBytes").catch(() => void 0);
|
|
185
380
|
return;
|
|
186
381
|
}
|
|
187
382
|
if (!response.body) return Buffer.alloc(0);
|
|
188
383
|
const reader = response.body.getReader();
|
|
189
384
|
const chunks = [];
|
|
190
385
|
let length = 0;
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
386
|
+
try {
|
|
387
|
+
while (true) {
|
|
388
|
+
const part = await reader.read();
|
|
389
|
+
if (part.done) return Buffer.concat(chunks, length);
|
|
390
|
+
length += part.value.byteLength;
|
|
391
|
+
if (length > maxBytes) {
|
|
392
|
+
await reader.cancel("WebSocket rejection body exceeded maxRejectionBodyBytes").catch(() => void 0);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
chunks.push(Buffer.from(part.value));
|
|
198
396
|
}
|
|
199
|
-
|
|
397
|
+
} finally {
|
|
398
|
+
reader.releaseLock();
|
|
200
399
|
}
|
|
201
400
|
}
|
|
202
401
|
async function rejectUpgrade(socket, response, maxBodyBytes) {
|
|
@@ -204,31 +403,64 @@ async function rejectUpgrade(socket, response, maxBodyBytes) {
|
|
|
204
403
|
if (!buffered) response = new Response("WebSocket rejection body exceeded configured limit", { status: 500 });
|
|
205
404
|
const body = buffered ?? Buffer.from(await response.arrayBuffer());
|
|
206
405
|
const lines = [`HTTP/1.1 ${response.status} ${response.statusText || "Rejected"}`];
|
|
406
|
+
const cookies = response.headers.getSetCookie();
|
|
207
407
|
response.headers.forEach((value, name) => {
|
|
208
|
-
if (name !== "content-length" && name !== "transfer-encoding") lines.push(`${name}: ${value}`);
|
|
408
|
+
if (name !== "connection" && name !== "content-length" && name !== "set-cookie" && name !== "transfer-encoding" && name !== "upgrade") lines.push(`${name}: ${value}`);
|
|
209
409
|
});
|
|
410
|
+
for (const cookie of cookies) lines.push(`set-cookie: ${cookie}`);
|
|
210
411
|
lines.push(`content-length: ${body.byteLength}`);
|
|
211
412
|
lines.push("connection: close", "", "");
|
|
212
413
|
socket.end(Buffer.concat([Buffer.from(lines.join("\r\n")), body]));
|
|
213
414
|
}
|
|
214
|
-
function installWebSockets(server, app, options
|
|
415
|
+
function installWebSockets(server, app, options, handlerOptions) {
|
|
416
|
+
const closeTimeout = options.closeTimeout ?? 5e3;
|
|
417
|
+
if (!Number.isSafeInteger(closeTimeout) || closeTimeout < 0) throw new TypeError("WebSocket closeTimeout must be a non-negative safe integer.");
|
|
215
418
|
const maxRejectionBodyBytes = options.maxRejectionBodyBytes ?? 65536;
|
|
216
|
-
if (!Number.
|
|
419
|
+
if (!Number.isSafeInteger(maxRejectionBodyBytes) || maxRejectionBodyBytes <= 0) throw new TypeError("WebSocket maxRejectionBodyBytes must be a positive integer.");
|
|
420
|
+
const maxPayload = options.maxPayload ?? 1048576;
|
|
421
|
+
if (!Number.isSafeInteger(maxPayload) || maxPayload < 0) throw new TypeError("WebSocket maxPayload must be a non-negative safe integer.");
|
|
422
|
+
const preparedHandlerOptions = prepareNodeHandlerOptions(handlerOptions);
|
|
423
|
+
const configuredAllowedOrigins = options.allowedOrigins?.map((origin) => {
|
|
424
|
+
try {
|
|
425
|
+
return normalizeHttpOrigin(origin);
|
|
426
|
+
} catch {
|
|
427
|
+
throw new TypeError(`WebSocket allowedOrigins contains an invalid origin: ${origin}`);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
217
430
|
const webSockets = new WebSocketServer({
|
|
218
431
|
noServer: true,
|
|
219
|
-
maxPayload
|
|
432
|
+
maxPayload,
|
|
220
433
|
perMessageDeflate: options.perMessageDeflate ?? false
|
|
221
434
|
});
|
|
222
|
-
|
|
435
|
+
const pending = /* @__PURE__ */ new Map();
|
|
436
|
+
let closing;
|
|
437
|
+
const onUpgrade = (request, socket, head) => {
|
|
438
|
+
if (closing) {
|
|
439
|
+
socket.destroy();
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
223
442
|
const controller = new AbortController();
|
|
443
|
+
const abort = () => controller.abort();
|
|
444
|
+
const cleanupPending = () => {
|
|
445
|
+
pending.delete(socket);
|
|
446
|
+
socket.off("close", abort);
|
|
447
|
+
socket.off("end", abort);
|
|
448
|
+
socket.off("error", abort);
|
|
449
|
+
};
|
|
450
|
+
pending.set(socket, controller);
|
|
451
|
+
socket.once("close", abort);
|
|
452
|
+
socket.once("end", abort);
|
|
453
|
+
socket.once("error", abort);
|
|
224
454
|
let accepted;
|
|
225
|
-
|
|
455
|
+
let upgraded = false;
|
|
456
|
+
Promise.resolve().then(() => requestFromNode(request, preparedHandlerOptions, controller.signal)).then(async (webRequest) => {
|
|
457
|
+
if (controller.signal.aborted) return;
|
|
226
458
|
const originHeader = request.headers.origin;
|
|
227
459
|
const origin = Array.isArray(originHeader) ? void 0 : originHeader;
|
|
228
|
-
const allowedOrigins =
|
|
460
|
+
const allowedOrigins = configuredAllowedOrigins ?? [new URL(webRequest.url).origin];
|
|
229
461
|
let normalizedOrigin;
|
|
230
462
|
try {
|
|
231
|
-
normalizedOrigin = origin ?
|
|
463
|
+
normalizedOrigin = origin ? normalizeHttpOrigin(origin) : void 0;
|
|
232
464
|
} catch {
|
|
233
465
|
normalizedOrigin = void 0;
|
|
234
466
|
}
|
|
@@ -251,44 +483,105 @@ function installWebSockets(server, app, options = {}, handlerOptions = {}) {
|
|
|
251
483
|
return;
|
|
252
484
|
}
|
|
253
485
|
webSockets.handleUpgrade(request, socket, head, (webSocket) => {
|
|
486
|
+
upgraded = true;
|
|
487
|
+
cleanupPending();
|
|
488
|
+
webSocket.on("error", () => void 0);
|
|
254
489
|
webSocket.once("close", () => controller.abort());
|
|
255
|
-
Promise.resolve(accepted.handler(socketLike(webSocket), accepted.context)).catch(() => webSocket.close(1011, "WebSocket handler failed"));
|
|
490
|
+
Promise.resolve().then(() => accepted.handler(socketLike(webSocket), accepted.context)).catch(() => webSocket.close(1011, "WebSocket handler failed"));
|
|
256
491
|
});
|
|
257
|
-
}).catch(() => socket.destroy())
|
|
258
|
-
|
|
492
|
+
}).catch(() => socket.destroy()).finally(() => {
|
|
493
|
+
if (!upgraded) {
|
|
494
|
+
cleanupPending();
|
|
495
|
+
controller.abort();
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
};
|
|
499
|
+
server.on("upgrade", onUpgrade);
|
|
259
500
|
return { close() {
|
|
260
|
-
|
|
261
|
-
|
|
501
|
+
return closing ??= new Promise((resolve, reject) => {
|
|
502
|
+
let settled = false;
|
|
503
|
+
let forceClose;
|
|
504
|
+
const complete = (error) => {
|
|
505
|
+
if (settled) return;
|
|
506
|
+
settled = true;
|
|
507
|
+
if (forceClose) clearTimeout(forceClose);
|
|
508
|
+
if (error) reject(error);
|
|
509
|
+
else resolve();
|
|
510
|
+
};
|
|
511
|
+
server.off("upgrade", onUpgrade);
|
|
512
|
+
for (const [socket, controller] of pending) {
|
|
513
|
+
controller.abort();
|
|
514
|
+
socket.destroy();
|
|
515
|
+
}
|
|
516
|
+
pending.clear();
|
|
517
|
+
for (const socket of webSockets.clients) socket.close(1001, "Server shutting down");
|
|
518
|
+
if (webSockets.clients.size > 0) {
|
|
519
|
+
forceClose = setTimeout(() => {
|
|
520
|
+
for (const socket of webSockets.clients) socket.terminate();
|
|
521
|
+
complete();
|
|
522
|
+
}, closeTimeout);
|
|
523
|
+
forceClose.unref();
|
|
524
|
+
}
|
|
525
|
+
webSockets.close(complete);
|
|
526
|
+
});
|
|
262
527
|
} };
|
|
263
528
|
}
|
|
264
529
|
//#endregion
|
|
265
530
|
//#region src/listen.ts
|
|
531
|
+
/**
|
|
532
|
+
* Starts a Node HTTP server for an `@askrjs/server` application and resolves once it is listening.
|
|
533
|
+
*
|
|
534
|
+
* Optionally installs WebSocket support and wires up graceful shutdown on
|
|
535
|
+
* `options.signal`. Unlike {@link serve}, this does not serve static assets
|
|
536
|
+
* or install OS signal handlers.
|
|
537
|
+
*
|
|
538
|
+
* @param app - The application to serve.
|
|
539
|
+
* @param options - Listen options such as port, host, timeouts, and WebSocket support.
|
|
540
|
+
* @returns A promise resolving to the listening server once it has bound successfully.
|
|
541
|
+
* @example
|
|
542
|
+
* const server = await listen(app, { port: 3000 });
|
|
543
|
+
*/
|
|
266
544
|
function listen(app, options = {}) {
|
|
545
|
+
options.signal?.throwIfAborted();
|
|
267
546
|
const host = resolveBindHost(options);
|
|
268
|
-
const handlerOptions =
|
|
547
|
+
const handlerOptions = handlerOptionsForHost(options, host);
|
|
269
548
|
const server = createServer(createNodeHandler(app, handlerOptions));
|
|
270
|
-
|
|
271
|
-
if (options.headersTimeout !== void 0) server.headersTimeout = options.headersTimeout;
|
|
272
|
-
if (options.keepAliveTimeout !== void 0) server.keepAliveTimeout = options.keepAliveTimeout;
|
|
549
|
+
applyServerTimeouts(server, options);
|
|
273
550
|
const webSockets = options.websocket ? installWebSockets(server, app, options.websocket === true ? {} : options.websocket, handlerOptions) : void 0;
|
|
274
551
|
if (webSockets) {
|
|
275
552
|
const nativeClose = server.close.bind(server);
|
|
276
553
|
server.close = ((callback) => {
|
|
277
|
-
|
|
278
|
-
|
|
554
|
+
let remaining = 2;
|
|
555
|
+
let closeError;
|
|
556
|
+
const complete = (error) => {
|
|
557
|
+
closeError ??= error;
|
|
558
|
+
remaining -= 1;
|
|
559
|
+
if (remaining === 0) callback?.(closeError);
|
|
560
|
+
};
|
|
561
|
+
nativeClose(complete);
|
|
562
|
+
webSockets.close().then(() => complete(), complete);
|
|
563
|
+
return server;
|
|
279
564
|
});
|
|
280
565
|
}
|
|
281
566
|
const close = () => server.close();
|
|
282
|
-
options.signal
|
|
283
|
-
|
|
567
|
+
const abortListener = options.signal ? addAbortListener(options.signal, close) : void 0;
|
|
568
|
+
const disposeAbortListener = () => abortListener?.[Symbol.dispose]();
|
|
569
|
+
server.once("close", disposeAbortListener);
|
|
284
570
|
return new Promise((resolve, reject) => {
|
|
571
|
+
const onClose = () => {
|
|
572
|
+
server.off("error", onError);
|
|
573
|
+
reject(options.signal?.reason ?? /* @__PURE__ */ new Error("Node server closed before it started listening."));
|
|
574
|
+
};
|
|
285
575
|
const onError = (error) => {
|
|
286
|
-
|
|
576
|
+
server.off("close", onClose);
|
|
577
|
+
disposeAbortListener();
|
|
287
578
|
reject(error);
|
|
288
579
|
};
|
|
289
580
|
server.once("error", onError);
|
|
581
|
+
server.once("close", onClose);
|
|
290
582
|
server.listen(options.port ?? 0, host, options.backlog, () => {
|
|
291
583
|
server.off("error", onError);
|
|
584
|
+
server.off("close", onClose);
|
|
292
585
|
resolve(server);
|
|
293
586
|
});
|
|
294
587
|
});
|
|
@@ -296,7 +589,9 @@ function listen(app, options = {}) {
|
|
|
296
589
|
//#endregion
|
|
297
590
|
//#region src/serve.ts
|
|
298
591
|
const mimeTypes = {
|
|
592
|
+
".avif": "image/avif",
|
|
299
593
|
".css": "text/css; charset=utf-8",
|
|
594
|
+
".gif": "image/gif",
|
|
300
595
|
".html": "text/html; charset=utf-8",
|
|
301
596
|
".ico": "image/x-icon",
|
|
302
597
|
".jpeg": "image/jpeg",
|
|
@@ -304,8 +599,14 @@ const mimeTypes = {
|
|
|
304
599
|
".js": "text/javascript; charset=utf-8",
|
|
305
600
|
".json": "application/json; charset=utf-8",
|
|
306
601
|
".mjs": "text/javascript; charset=utf-8",
|
|
602
|
+
".mp4": "video/mp4",
|
|
603
|
+
".pdf": "application/pdf",
|
|
307
604
|
".png": "image/png",
|
|
308
605
|
".svg": "image/svg+xml",
|
|
606
|
+
".txt": "text/plain; charset=utf-8",
|
|
607
|
+
".wasm": "application/wasm",
|
|
608
|
+
".webm": "video/webm",
|
|
609
|
+
".webmanifest": "application/manifest+json; charset=utf-8",
|
|
309
610
|
".webp": "image/webp",
|
|
310
611
|
".woff": "font/woff",
|
|
311
612
|
".woff2": "font/woff2"
|
|
@@ -317,13 +618,36 @@ function isWithinRoot(root, candidate) {
|
|
|
317
618
|
const prefix = root.endsWith(sep) ? root : `${root}${sep}`;
|
|
318
619
|
return candidate === root || candidate.startsWith(prefix);
|
|
319
620
|
}
|
|
621
|
+
/**
|
|
622
|
+
* Serves an `@askrjs/server` application over Node HTTP, with optional static
|
|
623
|
+
* asset serving, WebSocket support, and graceful shutdown on OS signals or an
|
|
624
|
+
* abort signal.
|
|
625
|
+
*
|
|
626
|
+
* Requests for paths with a file extension are first checked against
|
|
627
|
+
* `options.assets.root` (path-traversal safe, following symlinks) and served
|
|
628
|
+
* directly with appropriate `content-type`/`cache-control` headers before
|
|
629
|
+
* falling back to the application handler. HTML responses from the
|
|
630
|
+
* application get a `no-cache` header when they don't already set
|
|
631
|
+
* `cache-control`.
|
|
632
|
+
*
|
|
633
|
+
* @param app - The application to serve; may expose an optional `close()` for cleanup.
|
|
634
|
+
* @param options - Serve options such as port, host, static assets, and shutdown signals.
|
|
635
|
+
* @returns The served application, including its bound `url` and a `close()` for shutdown.
|
|
636
|
+
* @example
|
|
637
|
+
* const app = await serve(myApp, { port: 3000, assets: { root: "./public" } });
|
|
638
|
+
* // ...
|
|
639
|
+
* await app.close();
|
|
640
|
+
*/
|
|
320
641
|
async function serve(app, options = {}) {
|
|
642
|
+
options.signal?.throwIfAborted();
|
|
321
643
|
const host = resolveBindHost(options);
|
|
322
644
|
const root = options.assets ? await realpath(resolve(options.assets.root)) : void 0;
|
|
323
|
-
const handlerOptions =
|
|
645
|
+
const handlerOptions = handlerOptionsForHost(options, host);
|
|
646
|
+
const preparedHandlerOptions = prepareNodeHandlerOptions(handlerOptions);
|
|
324
647
|
const applicationHandler = createNodeHandler({ async fetch(request, dispatchOptions) {
|
|
325
648
|
const result = await app.fetch(request, dispatchOptions);
|
|
326
|
-
|
|
649
|
+
const contentType = result.headers.get("content-type");
|
|
650
|
+
if (!result.headers.has("cache-control") && contentType !== null && /^text\/html(?:;|$)/iu.test(contentType)) {
|
|
327
651
|
const headers = new Headers(result.headers);
|
|
328
652
|
headers.set("cache-control", "no-cache");
|
|
329
653
|
return new Response(result.body, {
|
|
@@ -335,25 +659,29 @@ async function serve(app, options = {}) {
|
|
|
335
659
|
return result;
|
|
336
660
|
} }, handlerOptions);
|
|
337
661
|
const server = createServer(async (request, response) => {
|
|
662
|
+
if (!root) {
|
|
663
|
+
applicationHandler(request, response);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
338
666
|
let pathname;
|
|
339
667
|
try {
|
|
340
|
-
pathname = decodeURIComponent(resolveNodeRequestUrl(request,
|
|
668
|
+
pathname = decodeURIComponent(resolveNodeRequestUrl(request, preparedHandlerOptions).pathname);
|
|
341
669
|
} catch {
|
|
342
670
|
response.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Bad Request");
|
|
343
671
|
return;
|
|
344
672
|
}
|
|
345
673
|
const method = request.method ?? "GET";
|
|
346
|
-
if (
|
|
674
|
+
if ((method === "GET" || method === "HEAD") && isAssetPath(pathname)) {
|
|
347
675
|
const extension = extname(pathname).toLowerCase();
|
|
348
676
|
const unresolvedCandidate = resolve(root, `.${pathname}`);
|
|
349
677
|
const inside = isWithinRoot(root, unresolvedCandidate);
|
|
350
678
|
let candidate;
|
|
351
679
|
let file;
|
|
352
680
|
if (inside && extension !== ".map") try {
|
|
353
|
-
const resolvedCandidate = await realpath(unresolvedCandidate);
|
|
681
|
+
const [resolvedCandidate, candidateFile] = await Promise.all([realpath(unresolvedCandidate), stat(unresolvedCandidate)]);
|
|
354
682
|
if (isWithinRoot(root, resolvedCandidate)) {
|
|
355
683
|
candidate = resolvedCandidate;
|
|
356
|
-
file =
|
|
684
|
+
file = candidateFile;
|
|
357
685
|
}
|
|
358
686
|
} catch {
|
|
359
687
|
candidate = void 0;
|
|
@@ -373,47 +701,64 @@ async function serve(app, options = {}) {
|
|
|
373
701
|
"x-content-type-options": "nosniff"
|
|
374
702
|
});
|
|
375
703
|
if (method === "HEAD") response.end();
|
|
376
|
-
else createReadStream(candidate)
|
|
704
|
+
else pipeline(createReadStream(candidate), response, () => void 0);
|
|
377
705
|
return;
|
|
378
706
|
}
|
|
379
707
|
applicationHandler(request, response);
|
|
380
708
|
});
|
|
381
|
-
|
|
382
|
-
if (options.headersTimeout !== void 0) server.headersTimeout = options.headersTimeout;
|
|
383
|
-
if (options.keepAliveTimeout !== void 0) server.keepAliveTimeout = options.keepAliveTimeout;
|
|
709
|
+
applyServerTimeouts(server, options);
|
|
384
710
|
const webSockets = options.websocket ? installWebSockets(server, app, options.websocket === true ? {} : options.websocket, handlerOptions) : void 0;
|
|
385
711
|
let closing;
|
|
712
|
+
let serverClosed = false;
|
|
713
|
+
server.once("close", () => {
|
|
714
|
+
serverClosed = true;
|
|
715
|
+
});
|
|
386
716
|
const signals = options.signals === false ? [] : options.signals ?? ["SIGINT", "SIGTERM"];
|
|
717
|
+
let abortListener;
|
|
387
718
|
const removeListeners = () => {
|
|
388
|
-
|
|
719
|
+
abortListener?.[Symbol.dispose]();
|
|
389
720
|
for (const signal of signals) process.removeListener(signal, shutdown);
|
|
390
721
|
};
|
|
391
|
-
const close = () => closing ??=
|
|
722
|
+
const close = () => closing ??= (async () => {
|
|
392
723
|
removeListeners();
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (serverError) rejectClose(serverError);
|
|
398
|
-
else resolveClose();
|
|
399
|
-
} catch (error) {
|
|
400
|
-
rejectClose(error);
|
|
724
|
+
const closeServer = new Promise((resolveClose, rejectClose) => {
|
|
725
|
+
if (serverClosed) {
|
|
726
|
+
resolveClose();
|
|
727
|
+
return;
|
|
401
728
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
729
|
+
server.close((error) => error ? rejectClose(error) : resolveClose());
|
|
730
|
+
});
|
|
731
|
+
const errors = (await Promise.allSettled([closeServer, webSockets?.close()])).filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
732
|
+
try {
|
|
733
|
+
await app.close?.();
|
|
734
|
+
} catch (error) {
|
|
735
|
+
errors.push(error);
|
|
736
|
+
}
|
|
737
|
+
if (errors.length === 1) throw errors[0];
|
|
738
|
+
if (errors.length > 1) throw new AggregateError(errors, "Failed to close served application.");
|
|
739
|
+
})();
|
|
406
740
|
const shutdown = () => void close().catch(() => void 0);
|
|
407
|
-
options.signal
|
|
741
|
+
if (options.signal) abortListener = addAbortListener(options.signal, shutdown);
|
|
408
742
|
for (const signal of signals) process.once(signal, shutdown);
|
|
409
743
|
await new Promise((resolveListen, rejectListen) => {
|
|
410
|
-
|
|
744
|
+
const onError = (error) => {
|
|
745
|
+
server.off("close", onClose);
|
|
746
|
+
rejectListen(error);
|
|
747
|
+
};
|
|
748
|
+
const onClose = () => {
|
|
749
|
+
server.off("error", onError);
|
|
750
|
+
rejectListen(options.signal?.reason ?? /* @__PURE__ */ new Error("Node server closed before it started listening."));
|
|
751
|
+
};
|
|
752
|
+
server.once("error", onError);
|
|
753
|
+
server.once("close", onClose);
|
|
411
754
|
server.listen(options.port ?? 0, host, options.backlog, () => {
|
|
412
|
-
server.
|
|
755
|
+
server.off("error", onError);
|
|
756
|
+
server.off("close", onClose);
|
|
413
757
|
resolveListen();
|
|
414
758
|
});
|
|
415
|
-
}).catch((error) => {
|
|
759
|
+
}).catch(async (error) => {
|
|
416
760
|
removeListeners();
|
|
761
|
+
await close().catch(() => void 0);
|
|
417
762
|
throw error;
|
|
418
763
|
});
|
|
419
764
|
const address = server.address();
|
|
@@ -428,4 +773,4 @@ async function serve(app, options = {}) {
|
|
|
428
773
|
});
|
|
429
774
|
}
|
|
430
775
|
//#endregion
|
|
431
|
-
export { createNodeHandler, listen, serve };
|
|
776
|
+
export { CLIENT_ADDRESS_HEADER, createNodeHandler, listen, normalizeClientAddress, serve };
|