@nifrajs/node 4.0.2 → 5.1.0
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 +17 -4
- package/dist/index.d.ts +15 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +221 -79
- package/dist/index.js.map +1 -1
- package/dist/open-resolved.d.ts +18 -0
- package/dist/open-resolved.d.ts.map +1 -0
- package/dist/open-resolved.js +64 -0
- package/dist/open-resolved.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +251 -82
- package/src/open-resolved.ts +66 -0
package/dist/index.js
CHANGED
|
@@ -19,8 +19,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
19
19
|
* `Request`/`Response`, plus a Bun-`listen()`-style graceful `stop()`.
|
|
20
20
|
*/
|
|
21
21
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
22
|
-
import {
|
|
23
|
-
import { open, realpath } from "node:fs/promises";
|
|
22
|
+
import { realpath } from "node:fs/promises";
|
|
24
23
|
import { createServer, STATUS_CODES, } from "node:http";
|
|
25
24
|
import { createServer as createHttpsServer } from "node:https";
|
|
26
25
|
import { extname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
@@ -30,6 +29,7 @@ import { FastResponse } from "srvx/node";
|
|
|
30
29
|
import { NODE_BRIDGE_MARKER_KEYS } from "./generated/bridge-markers.js";
|
|
31
30
|
import { hasDotSegment, resolveDotSegments } from "./generated/request-target.js";
|
|
32
31
|
import { claimableWebStream, claimNodeStream } from "./node-stream.js";
|
|
32
|
+
import { openResolved } from "./open-resolved.js";
|
|
33
33
|
const RESOLVE_NODE_MOUNT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.resolveNodeMount);
|
|
34
34
|
/**
|
|
35
35
|
* The `Content-Type` the host runtime's `Response.json` emits - Node's undici uses `application/json`,
|
|
@@ -38,6 +38,15 @@ const RESOLVE_NODE_MOUNT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.resolveNodeMount);
|
|
|
38
38
|
*/
|
|
39
39
|
const JSON_CONTENT_TYPE = Response.json(0).headers.get("content-type") ?? "application/json";
|
|
40
40
|
const INTERNAL_ERROR_BODY = '{"ok":false,"error":"internal_error"}';
|
|
41
|
+
/** A handler's Response whose body was already sent: refused, since its headers were written for the
|
|
42
|
+
* request it already answered. */
|
|
43
|
+
const SENT_BODY_OUTCOME = Object.freeze({
|
|
44
|
+
kind: "json",
|
|
45
|
+
status: 500,
|
|
46
|
+
headers: undefined,
|
|
47
|
+
cookies: undefined,
|
|
48
|
+
body: INTERNAL_ERROR_BODY,
|
|
49
|
+
});
|
|
41
50
|
const EMPTY_BUFFER = Buffer.alloc(0);
|
|
42
51
|
const NODE_RESPONSE_BODY = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseBody);
|
|
43
52
|
const RESPONSE_RESULT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseResult);
|
|
@@ -82,14 +91,16 @@ const NODE_OUTCOME_RUNTIME = {
|
|
|
82
91
|
return {
|
|
83
92
|
kind: "body",
|
|
84
93
|
status: body.status,
|
|
85
|
-
headers:
|
|
94
|
+
headers: applySetToNodeHeaders(body.headers, body.status, set),
|
|
86
95
|
body: isBodylessStatus(body.status) ? new Uint8Array(0) : body.body,
|
|
87
96
|
};
|
|
88
97
|
}
|
|
89
|
-
return nodeOutcomeFromResponse(
|
|
98
|
+
return nodeOutcomeFromResponse(applySetToResponse(result.toResponse(), set));
|
|
90
99
|
}
|
|
91
100
|
if (result instanceof Response) {
|
|
92
|
-
|
|
101
|
+
if (result.bodyUsed)
|
|
102
|
+
return SENT_BODY_OUTCOME;
|
|
103
|
+
return nodeOutcomeFromResponse(applySetToResponse(result, set));
|
|
93
104
|
}
|
|
94
105
|
const status = set.status ?? (result === undefined ? 204 : 200);
|
|
95
106
|
const body = result === undefined || isBodylessStatus(status) ? null : JSON.stringify(result);
|
|
@@ -291,23 +302,65 @@ function installFastResponse() {
|
|
|
291
302
|
return;
|
|
292
303
|
globalThis.Response = DeferringResponse;
|
|
293
304
|
}
|
|
294
|
-
|
|
295
|
-
|
|
305
|
+
/** Headers that describe a Response's body or its framing - core's list, mirrored here because the
|
|
306
|
+
* adapter does not import core: a `c.set` value for one describes a body the handler did not return. */
|
|
307
|
+
const RESPONSE_OWNED_HEADERS = new Set([
|
|
308
|
+
"content-type",
|
|
309
|
+
"content-length",
|
|
310
|
+
"content-encoding",
|
|
311
|
+
"content-range",
|
|
312
|
+
"transfer-encoding",
|
|
313
|
+
]);
|
|
314
|
+
/**
|
|
315
|
+
* `response` with this request's `c.set` headers it lacks and its queued cookies - core's
|
|
316
|
+
* `applySetToResponse` and `writableResponse` rules, mirrored. A Response without a body can be sent
|
|
317
|
+
* any number of times, so the object a handler returns may answer other requests too and is always
|
|
318
|
+
* copied; one with a body can be sent once, so it is this request's and takes the writes in place
|
|
319
|
+
* unless its `Headers` are guarded (`Response.redirect()`, a raw `fetch()` result).
|
|
320
|
+
*/
|
|
321
|
+
function applySetToResponse(response, set) {
|
|
322
|
+
const ambient = response.status >= 200 ? set._headers : undefined;
|
|
323
|
+
const cookies = set._cookies ?? [];
|
|
324
|
+
const added = [];
|
|
325
|
+
if (ambient !== undefined) {
|
|
326
|
+
for (const name in ambient) {
|
|
327
|
+
if (RESPONSE_OWNED_HEADERS.has(name.toLowerCase()) || response.headers.has(name))
|
|
328
|
+
continue;
|
|
329
|
+
added.push([name, ambient[name]]);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (added.length === 0 && cookies.length === 0)
|
|
296
333
|
return response;
|
|
297
|
-
|
|
334
|
+
const write = (target) => {
|
|
335
|
+
for (const [name, value] of added)
|
|
336
|
+
target.headers.set(name, value);
|
|
298
337
|
for (const cookie of cookies)
|
|
299
|
-
|
|
300
|
-
return
|
|
338
|
+
target.headers.append("set-cookie", cookie);
|
|
339
|
+
return target;
|
|
340
|
+
};
|
|
341
|
+
if (response.body === null && response.status >= 200) {
|
|
342
|
+
return write(copiedResponse(response, new Response(null, {
|
|
343
|
+
status: response.status,
|
|
344
|
+
statusText: response.statusText,
|
|
345
|
+
headers: response.headers,
|
|
346
|
+
})));
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
return write(response);
|
|
301
350
|
}
|
|
302
351
|
catch {
|
|
303
|
-
// Guarded `Headers`
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
for (const cookie of cookies)
|
|
307
|
-
clone.headers.append("set-cookie", cookie);
|
|
308
|
-
return clone;
|
|
352
|
+
// Guarded `Headers` reject every write, so the first one throws with nothing applied and the
|
|
353
|
+
// writes land on a mutable copy instead.
|
|
354
|
+
return write(copiedResponse(response, new Response(response.body, response)));
|
|
309
355
|
}
|
|
310
356
|
}
|
|
357
|
+
/** `copy` carrying `original`'s buffered-body marker, so the direct writer still sends those bytes. */
|
|
358
|
+
function copiedResponse(original, copy) {
|
|
359
|
+
const body = Reflect.get(original, NODE_RESPONSE_BODY);
|
|
360
|
+
if (body !== undefined)
|
|
361
|
+
Object.defineProperty(copy, NODE_RESPONSE_BODY, { value: body });
|
|
362
|
+
return copy;
|
|
363
|
+
}
|
|
311
364
|
/** Early exits built outside the handler's finalizer - an `onRequest` hook's response, a mount's,
|
|
312
365
|
* every framework error render. A plain-data carrier takes the `kind: "json"` lane a handler's plain
|
|
313
366
|
* return takes, with no `Response` built and none drained; there is no `c.set` at these sites, which
|
|
@@ -408,12 +461,29 @@ function responseHeaders(response) {
|
|
|
408
461
|
}
|
|
409
462
|
return headers;
|
|
410
463
|
}
|
|
411
|
-
|
|
464
|
+
/** {@link applySetToResponse} for a body the direct writer sends from a header record. */
|
|
465
|
+
function applySetToNodeHeaders(headers, status, set) {
|
|
466
|
+
const ambient = status >= 200 ? set._headers : undefined;
|
|
467
|
+
const cookies = set._cookies;
|
|
468
|
+
const copy = () => Object.assign(Object.create(null), headers);
|
|
469
|
+
let out;
|
|
470
|
+
if (ambient !== undefined) {
|
|
471
|
+
let own;
|
|
472
|
+
for (const name in ambient) {
|
|
473
|
+
const lower = name.toLowerCase();
|
|
474
|
+
if (RESPONSE_OWNED_HEADERS.has(lower))
|
|
475
|
+
continue;
|
|
476
|
+
own ??= new Set(headers === undefined ? [] : Object.keys(headers).map((key) => key.toLowerCase()));
|
|
477
|
+
if (own.has(lower))
|
|
478
|
+
continue;
|
|
479
|
+
own.add(lower);
|
|
480
|
+
out ??= copy();
|
|
481
|
+
out[name] = ambient[name];
|
|
482
|
+
}
|
|
483
|
+
}
|
|
412
484
|
if (cookies === undefined || cookies.length === 0)
|
|
413
|
-
return headers;
|
|
414
|
-
|
|
415
|
-
if (headers !== undefined)
|
|
416
|
-
Object.assign(out, headers);
|
|
485
|
+
return out ?? headers;
|
|
486
|
+
out ??= copy();
|
|
417
487
|
const existing = out["set-cookie"];
|
|
418
488
|
const setCookies = existing === undefined ? [] : typeof existing === "string" ? [existing] : [...existing];
|
|
419
489
|
out["set-cookie"] = [...setCookies, ...cookies];
|
|
@@ -452,7 +522,8 @@ function normalizeHostAuthority(value) {
|
|
|
452
522
|
hostname = value.slice(0, colon);
|
|
453
523
|
port = value.slice(colon + 1);
|
|
454
524
|
}
|
|
455
|
-
|
|
525
|
+
// `_` is a legal reg-name character (RFC 3986) and common in container service names.
|
|
526
|
+
if (hostname.length === 0 || !/^[\w.-]+$/.test(hostname))
|
|
456
527
|
return undefined;
|
|
457
528
|
hostname = hostname.toLowerCase();
|
|
458
529
|
}
|
|
@@ -567,6 +638,7 @@ function staticStateOf(options) {
|
|
|
567
638
|
root,
|
|
568
639
|
prefix,
|
|
569
640
|
immutable: options.immutable !== false,
|
|
641
|
+
hashedFiles: options.hashedFiles === undefined ? undefined : new Set(options.hashedFiles),
|
|
570
642
|
headers: staticHeadersOf(options.headers),
|
|
571
643
|
denyDotfiles: options.dotfiles !== "allow",
|
|
572
644
|
};
|
|
@@ -618,7 +690,8 @@ function staticMatch(state, rawUrl) {
|
|
|
618
690
|
if (contained.startsWith("..") || isAbsolute(contained)) {
|
|
619
691
|
return { reject: new Response("Forbidden", { status: 403 }) };
|
|
620
692
|
}
|
|
621
|
-
|
|
693
|
+
const immutable = state.immutable && (state.hashedFiles === undefined || state.hashedFiles.has(path));
|
|
694
|
+
return { file, immutable };
|
|
622
695
|
}
|
|
623
696
|
/** Strong validator from mtime + size (the nginx scheme): rewriting a file changes at least one. */
|
|
624
697
|
function staticEtag(size, mtimeMs) {
|
|
@@ -693,7 +766,7 @@ function staticByteRange(headers, size, etag, lastModified) {
|
|
|
693
766
|
const end = last === "" ? size - 1 : Math.min(Number(last), size - 1);
|
|
694
767
|
return end < start ? undefined : [start, end];
|
|
695
768
|
}
|
|
696
|
-
async function readStatic(file, state, method, requestHeaders) {
|
|
769
|
+
async function readStatic(file, immutable, state, method, requestHeaders) {
|
|
697
770
|
let handle;
|
|
698
771
|
try {
|
|
699
772
|
// Defense-in-depth: the lexical `..` guard in staticMatch can't catch a symlink INSIDE root that
|
|
@@ -701,20 +774,20 @@ async function readStatic(file, state, method, requestHeaders) {
|
|
|
701
774
|
// never open the requested path and re-resolve it afterwards. A local attacker who can write
|
|
702
775
|
// inside the served tree wins that ordering: swap a link between the open and the lookup and the
|
|
703
776
|
// descriptor already streaming refers to an external file while the lookup answers with a
|
|
704
|
-
// contained path.
|
|
705
|
-
//
|
|
777
|
+
// contained path. openResolved closes the remainder - `realpath` returned a name with no links
|
|
778
|
+
// left in it, so one appearing in any component before the open is an attack, not a layout.
|
|
706
779
|
const [realFile, realRoot] = await Promise.all([realpath(file), realpath(state.root)]);
|
|
707
780
|
if (realFile !== realRoot && !realFile.startsWith(realRoot + sep)) {
|
|
708
781
|
return { kind: "response", response: new Response("Forbidden", { status: 403 }) };
|
|
709
782
|
}
|
|
710
|
-
handle = await
|
|
783
|
+
handle = await openResolved(realFile);
|
|
711
784
|
const stat = await handle.stat();
|
|
712
785
|
if (!stat.isFile()) {
|
|
713
786
|
await handle.close();
|
|
714
787
|
return { kind: "response", response: new Response("Not Found", { status: 404 }) };
|
|
715
788
|
}
|
|
716
789
|
const headers = { ...state.headers };
|
|
717
|
-
if (
|
|
790
|
+
if (immutable && headers["cache-control"] === undefined) {
|
|
718
791
|
headers["cache-control"] = "public, max-age=31536000, immutable";
|
|
719
792
|
}
|
|
720
793
|
headers.etag ??= staticEtag(stat.size, stat.mtimeMs);
|
|
@@ -785,6 +858,13 @@ async function readStatic(file, state, method, requestHeaders) {
|
|
|
785
858
|
// @nifrajs/core, and no-op on a handler that does not expose the seam (a plain `{ fetch }`) or one that
|
|
786
859
|
// is already frozen (the per-request `resolveNodeSource` path supplies the runtime regardless).
|
|
787
860
|
const INSTALL_NODE_DIRECT = Symbol.for("@nifrajs/core/install-node-direct");
|
|
861
|
+
// A nifra server logs what an adapter reports through this seam, as Bun's `listen()` does; a plain
|
|
862
|
+
// `{ fetch }` handler has no logger, so its failures stay a bare 500.
|
|
863
|
+
const REPORT_ERROR = Symbol.for("@nifrajs/core/report-error");
|
|
864
|
+
function errorReporterOf(app) {
|
|
865
|
+
const report = Reflect.get(app, REPORT_ERROR);
|
|
866
|
+
return typeof report === "function" ? (error) => report.call(app, error) : undefined;
|
|
867
|
+
}
|
|
788
868
|
function enableNodeDirect(app) {
|
|
789
869
|
const install = app[INSTALL_NODE_DIRECT];
|
|
790
870
|
if (typeof install !== "function")
|
|
@@ -837,6 +917,7 @@ function clearActiveResponseState() {
|
|
|
837
917
|
export function serve(app, options) {
|
|
838
918
|
activateAsyncContextFrame();
|
|
839
919
|
enableNodeDirect(app);
|
|
920
|
+
const report = errorReporterOf(app);
|
|
840
921
|
if (options.fastResponse === true)
|
|
841
922
|
installFastResponse();
|
|
842
923
|
let inFlight = 0;
|
|
@@ -844,6 +925,15 @@ export function serve(app, options) {
|
|
|
844
925
|
// Upgrade sockets an HTTP route answers instead: Node's server no longer tracks them, so stop()
|
|
845
926
|
// drains and closes them itself.
|
|
846
927
|
const answering = new Set();
|
|
928
|
+
// Upgraded WebSockets: Node's server stops tracking a socket once `ws` takes it over.
|
|
929
|
+
const upgraded = new Set();
|
|
930
|
+
const adopt = (ws) => {
|
|
931
|
+
if (closed)
|
|
932
|
+
return false;
|
|
933
|
+
upgraded.add(ws);
|
|
934
|
+
ws.on("close", () => upgraded.delete(ws));
|
|
935
|
+
return true;
|
|
936
|
+
};
|
|
847
937
|
const protocol = protocolResolver(options.protocol ?? (options.tls === undefined ? undefined : "https"));
|
|
848
938
|
const hostPolicy = hostPolicyOf(options);
|
|
849
939
|
const staticState = options.static !== undefined ? staticStateOf(options.static) : undefined;
|
|
@@ -855,7 +945,7 @@ export function serve(app, options) {
|
|
|
855
945
|
const socket = nodeReq.socket;
|
|
856
946
|
let responseState = socket[ACTIVE_SOCKET_STATE];
|
|
857
947
|
if (responseState === undefined) {
|
|
858
|
-
responseState = { socket, active: 0, parserError: false };
|
|
948
|
+
responseState = { socket, active: 0, parserError: false, report };
|
|
859
949
|
socket[ACTIVE_SOCKET_STATE] = responseState;
|
|
860
950
|
socket.once("close", clearActiveResponseState);
|
|
861
951
|
}
|
|
@@ -870,10 +960,11 @@ export function serve(app, options) {
|
|
|
870
960
|
if (handled instanceof Promise) {
|
|
871
961
|
// `catch` before `finally`: a `finally` alone forwards the rejection to a promise nothing
|
|
872
962
|
// observes, and Node terminates the process on an unhandled rejection by default. Every
|
|
873
|
-
// writer already ends its own failures (`
|
|
874
|
-
// no response can still be built from - the connection is the only thing
|
|
963
|
+
// writer already ends and reports its own failures (`failAndReport`), so anything arriving
|
|
964
|
+
// here is a fault no response can still be built from - the connection is the only thing
|
|
965
|
+
// left to end.
|
|
875
966
|
void handled
|
|
876
|
-
.catch(() =>
|
|
967
|
+
.catch((error) => failAndReport(nodeRes, error))
|
|
877
968
|
.finally(() => {
|
|
878
969
|
inFlight -= 1;
|
|
879
970
|
});
|
|
@@ -881,8 +972,8 @@ export function serve(app, options) {
|
|
|
881
972
|
}
|
|
882
973
|
inFlight -= 1;
|
|
883
974
|
}
|
|
884
|
-
catch {
|
|
885
|
-
|
|
975
|
+
catch (error) {
|
|
976
|
+
failAndReport(nodeRes, error);
|
|
886
977
|
inFlight -= 1;
|
|
887
978
|
}
|
|
888
979
|
};
|
|
@@ -910,36 +1001,48 @@ export function serve(app, options) {
|
|
|
910
1001
|
// the optional `ws` package - lazy-imported (and the server lazily built) on the FIRST real WS
|
|
911
1002
|
// upgrade, so a non-WS Node app never loads `ws`.
|
|
912
1003
|
if (resolveWs !== undefined) {
|
|
913
|
-
|
|
1004
|
+
// One `ws` server per payload cap: its `maxPayload` bounds buffering before a frame is delivered,
|
|
1005
|
+
// and mounted children can declare different caps.
|
|
1006
|
+
const servers = new Map();
|
|
914
1007
|
server.on("upgrade", (nodeReq, socket, head) => {
|
|
915
|
-
void handleUpgrade(app, answering, resolveWs, protocol, hostPolicy, nodeReq, socket, head, (maxPayloadBytes) => {
|
|
916
|
-
|
|
917
|
-
|
|
1008
|
+
void handleUpgrade(app, answering, adopt, resolveWs, protocol, hostPolicy, nodeReq, socket, head, (maxPayloadBytes) => {
|
|
1009
|
+
let wss = servers.get(maxPayloadBytes);
|
|
1010
|
+
if (wss === undefined) {
|
|
1011
|
+
wss = loadWsServer(maxPayloadBytes);
|
|
1012
|
+
servers.set(maxPayloadBytes, wss);
|
|
1013
|
+
}
|
|
1014
|
+
return wss;
|
|
918
1015
|
});
|
|
919
1016
|
});
|
|
920
1017
|
}
|
|
921
1018
|
// Opt-in: own SIGTERM/SIGINT so `docker stop` / Ctrl-C drains in-flight requests
|
|
922
1019
|
// before exit. `stop` is hoisted, so `onSignal` can reference it.
|
|
1020
|
+
const signals = options.signals === true ? {} : options.signals || undefined;
|
|
923
1021
|
const onSignal = () => {
|
|
924
|
-
void stop();
|
|
1022
|
+
void stop(signals);
|
|
925
1023
|
};
|
|
926
1024
|
async function stop({ drainMs = DEFAULT_DRAIN_MS } = {}) {
|
|
927
1025
|
if (closed)
|
|
928
1026
|
return; // idempotent
|
|
929
1027
|
closed = true;
|
|
930
|
-
if (
|
|
1028
|
+
if (signals !== undefined) {
|
|
931
1029
|
// Remove our own handlers so repeated serve()/stop() cycles don't leak listeners.
|
|
932
1030
|
process.removeListener("SIGTERM", onSignal);
|
|
933
1031
|
process.removeListener("SIGINT", onSignal);
|
|
934
1032
|
}
|
|
935
1033
|
server.close(); // stop accepting new connections; existing requests continue
|
|
1034
|
+
// A WebSocket gets a close handshake now and the drain window to finish it.
|
|
1035
|
+
for (const ws of upgraded)
|
|
1036
|
+
ws.close(1001, "server shutting down");
|
|
936
1037
|
const deadline = Date.now() + drainMs;
|
|
937
|
-
while ((inFlight > 0 || answering.size > 0) && Date.now() < deadline) {
|
|
1038
|
+
while ((inFlight > 0 || answering.size > 0 || upgraded.size > 0) && Date.now() < deadline) {
|
|
938
1039
|
await new Promise((r) => setTimeout(r, DRAIN_POLL_MS));
|
|
939
1040
|
}
|
|
940
1041
|
server.closeAllConnections(); // force-close stragglers + idle keep-alive sockets
|
|
941
1042
|
for (const socket of answering)
|
|
942
1043
|
socket.destroy();
|
|
1044
|
+
for (const ws of upgraded)
|
|
1045
|
+
ws.terminate();
|
|
943
1046
|
}
|
|
944
1047
|
return new Promise((resolve) => {
|
|
945
1048
|
// Do not pass an explicit `undefined` hostname. Node accepts it, but Bun's Node-compatible
|
|
@@ -948,7 +1051,7 @@ export function serve(app, options) {
|
|
|
948
1051
|
const onListening = () => {
|
|
949
1052
|
const address = server.address();
|
|
950
1053
|
const port = address !== null && typeof address === "object" ? address.port : options.port;
|
|
951
|
-
if (
|
|
1054
|
+
if (signals !== undefined) {
|
|
952
1055
|
process.once("SIGTERM", onSignal);
|
|
953
1056
|
process.once("SIGINT", onSignal);
|
|
954
1057
|
}
|
|
@@ -969,11 +1072,11 @@ function runNodeSource(app, source, nodeReq, nodeRes, protocol, host, platform)
|
|
|
969
1072
|
try {
|
|
970
1073
|
const outcome = resolveNodeSource.call(app, source, platform, NODE_OUTCOME_RUNTIME);
|
|
971
1074
|
return outcome instanceof Promise
|
|
972
|
-
? outcome.then((settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method), () =>
|
|
1075
|
+
? outcome.then((settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method), (error) => failAndReport(nodeRes, error))
|
|
973
1076
|
: writeOutcomeSafely(outcome, nodeRes, nodeReq.method);
|
|
974
1077
|
}
|
|
975
|
-
catch {
|
|
976
|
-
|
|
1078
|
+
catch (error) {
|
|
1079
|
+
failAndReport(nodeRes, error);
|
|
977
1080
|
return;
|
|
978
1081
|
}
|
|
979
1082
|
}
|
|
@@ -983,23 +1086,23 @@ function runNodeSource(app, source, nodeReq, nodeRes, protocol, host, platform)
|
|
|
983
1086
|
try {
|
|
984
1087
|
const outcome = resolveNode.call(app, request, platform);
|
|
985
1088
|
return outcome instanceof Promise
|
|
986
|
-
? outcome.then((settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method), () =>
|
|
1089
|
+
? outcome.then((settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method), (error) => failAndReport(nodeRes, error))
|
|
987
1090
|
: writeOutcomeSafely(outcome, nodeRes, nodeReq.method);
|
|
988
1091
|
}
|
|
989
|
-
catch {
|
|
990
|
-
|
|
1092
|
+
catch (error) {
|
|
1093
|
+
failAndReport(nodeRes, error);
|
|
991
1094
|
return;
|
|
992
1095
|
}
|
|
993
1096
|
}
|
|
994
1097
|
try {
|
|
995
1098
|
const response = app.fetch(request, platform);
|
|
996
1099
|
return response instanceof Promise
|
|
997
|
-
? response.then((settled) => writeResponseSafely(settled, nodeRes, nodeReq.method), () =>
|
|
1100
|
+
? response.then((settled) => writeResponseSafely(settled, nodeRes, nodeReq.method), (error) => failAndReport(nodeRes, error))
|
|
998
1101
|
: writeResponseSafely(response, nodeRes, nodeReq.method);
|
|
999
1102
|
}
|
|
1000
|
-
catch {
|
|
1001
|
-
//
|
|
1002
|
-
|
|
1103
|
+
catch (error) {
|
|
1104
|
+
// A request hook that throws escapes `fetch`; never leak a stack to the wire.
|
|
1105
|
+
failAndReport(nodeRes, error);
|
|
1003
1106
|
return;
|
|
1004
1107
|
}
|
|
1005
1108
|
}
|
|
@@ -1063,7 +1166,7 @@ function handle(app, nodeReq, nodeRes, getProtocol, staticState, hostPolicy) {
|
|
|
1063
1166
|
if (matched !== "pass") {
|
|
1064
1167
|
if ("reject" in matched)
|
|
1065
1168
|
return writeResponseSafely(matched.reject, nodeRes, nodeReq.method);
|
|
1066
|
-
return readStatic(matched.file, staticState, nodeReq.method ?? "GET", nodeReq.headers).then((outcome) => writeOutcomeSafely(outcome, nodeRes, nodeReq.method), () => failWrite(nodeRes));
|
|
1169
|
+
return readStatic(matched.file, matched.immutable, staticState, nodeReq.method ?? "GET", nodeReq.headers).then((outcome) => writeOutcomeSafely(outcome, nodeRes, nodeReq.method), () => failWrite(nodeRes));
|
|
1067
1170
|
}
|
|
1068
1171
|
}
|
|
1069
1172
|
// A mounted handler may advertise a native Node lane. Core has already checked route precedence
|
|
@@ -1097,13 +1200,13 @@ function handle(app, nodeReq, nodeRes, getProtocol, staticState, hostPolicy) {
|
|
|
1097
1200
|
if (outcome instanceof Promise) {
|
|
1098
1201
|
return outcome.then((handled) => handled === false
|
|
1099
1202
|
? runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform)
|
|
1100
|
-
: undefined, () =>
|
|
1203
|
+
: undefined, (error) => failAndReport(nodeRes, error));
|
|
1101
1204
|
}
|
|
1102
1205
|
if (outcome !== false)
|
|
1103
1206
|
return;
|
|
1104
1207
|
}
|
|
1105
|
-
catch {
|
|
1106
|
-
|
|
1208
|
+
catch (error) {
|
|
1209
|
+
failAndReport(nodeRes, error);
|
|
1107
1210
|
return;
|
|
1108
1211
|
}
|
|
1109
1212
|
}
|
|
@@ -1116,23 +1219,23 @@ function handle(app, nodeReq, nodeRes, getProtocol, staticState, hostPolicy) {
|
|
|
1116
1219
|
try {
|
|
1117
1220
|
const outcome = resolveNode.call(app, request, platform);
|
|
1118
1221
|
return outcome instanceof Promise
|
|
1119
|
-
? outcome.then((settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method), () =>
|
|
1222
|
+
? outcome.then((settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method), (error) => failAndReport(nodeRes, error))
|
|
1120
1223
|
: writeOutcomeSafely(outcome, nodeRes, nodeReq.method);
|
|
1121
1224
|
}
|
|
1122
|
-
catch {
|
|
1123
|
-
|
|
1225
|
+
catch (error) {
|
|
1226
|
+
failAndReport(nodeRes, error);
|
|
1124
1227
|
return;
|
|
1125
1228
|
}
|
|
1126
1229
|
}
|
|
1127
1230
|
try {
|
|
1128
1231
|
const response = app.fetch(request, platform);
|
|
1129
1232
|
return response instanceof Promise
|
|
1130
|
-
? response.then((settled) => writeResponseSafely(settled, nodeRes, nodeReq.method), () =>
|
|
1233
|
+
? response.then((settled) => writeResponseSafely(settled, nodeRes, nodeReq.method), (error) => failAndReport(nodeRes, error))
|
|
1131
1234
|
: writeResponseSafely(response, nodeRes, nodeReq.method);
|
|
1132
1235
|
}
|
|
1133
|
-
catch {
|
|
1134
|
-
//
|
|
1135
|
-
|
|
1236
|
+
catch (error) {
|
|
1237
|
+
// A request hook that throws escapes `fetch`; never leak a stack to the wire.
|
|
1238
|
+
failAndReport(nodeRes, error);
|
|
1136
1239
|
return;
|
|
1137
1240
|
}
|
|
1138
1241
|
}
|
|
@@ -1169,6 +1272,10 @@ function writeInternalError(nodeRes) {
|
|
|
1169
1272
|
function failWrite(nodeRes) {
|
|
1170
1273
|
try {
|
|
1171
1274
|
if (!nodeRes.headersSent) {
|
|
1275
|
+
// The failed write may have staged the response's own headers (cookies among them) with
|
|
1276
|
+
// `setHeaders`; the 500 must not carry them.
|
|
1277
|
+
for (const name of nodeRes.getHeaderNames())
|
|
1278
|
+
nodeRes.removeHeader(name);
|
|
1172
1279
|
writeInternalError(nodeRes);
|
|
1173
1280
|
return;
|
|
1174
1281
|
}
|
|
@@ -1178,26 +1285,43 @@ function failWrite(nodeRes) {
|
|
|
1178
1285
|
}
|
|
1179
1286
|
nodeRes.destroy();
|
|
1180
1287
|
}
|
|
1181
|
-
/** {@link
|
|
1288
|
+
/** {@link failWrite} for a failure the app never answered, logged through the app's own logger. */
|
|
1289
|
+
function failAndReport(nodeRes, error) {
|
|
1290
|
+
const state = Reflect.get(nodeRes, ACTIVE_RESPONSE_STATE);
|
|
1291
|
+
const report = state?.report;
|
|
1292
|
+
try {
|
|
1293
|
+
report?.(error);
|
|
1294
|
+
}
|
|
1295
|
+
catch {
|
|
1296
|
+
// A logger that throws must not keep the 500 from going out.
|
|
1297
|
+
}
|
|
1298
|
+
failWrite(nodeRes);
|
|
1299
|
+
}
|
|
1300
|
+
/** {@link writeNodeOutcome} with {@link failAndReport} behind it, sync throw and async rejection
|
|
1301
|
+
* alike. */
|
|
1182
1302
|
function writeOutcomeSafely(outcome, nodeRes, method) {
|
|
1183
1303
|
try {
|
|
1184
1304
|
const written = writeNodeOutcome(outcome, nodeRes, method);
|
|
1185
|
-
return written instanceof Promise
|
|
1305
|
+
return written instanceof Promise
|
|
1306
|
+
? written.catch((error) => failAndReport(nodeRes, error))
|
|
1307
|
+
: written;
|
|
1186
1308
|
}
|
|
1187
|
-
catch {
|
|
1188
|
-
|
|
1309
|
+
catch (error) {
|
|
1310
|
+
failAndReport(nodeRes, error);
|
|
1189
1311
|
return;
|
|
1190
1312
|
}
|
|
1191
1313
|
}
|
|
1192
|
-
/** {@link writeNodeResponse} with {@link
|
|
1314
|
+
/** {@link writeNodeResponse} with {@link failAndReport} behind it - same contract as
|
|
1193
1315
|
* {@link writeOutcomeSafely}, for the lanes that carry a `Response`. */
|
|
1194
1316
|
function writeResponseSafely(response, nodeRes, method) {
|
|
1195
1317
|
try {
|
|
1196
1318
|
const written = writeNodeResponse(response, nodeRes, method);
|
|
1197
|
-
return written instanceof Promise
|
|
1319
|
+
return written instanceof Promise
|
|
1320
|
+
? written.catch((error) => failAndReport(nodeRes, error))
|
|
1321
|
+
: written;
|
|
1198
1322
|
}
|
|
1199
|
-
catch {
|
|
1200
|
-
|
|
1323
|
+
catch (error) {
|
|
1324
|
+
failAndReport(nodeRes, error);
|
|
1201
1325
|
return;
|
|
1202
1326
|
}
|
|
1203
1327
|
}
|
|
@@ -1896,6 +2020,9 @@ function waitForDrain(nodeRes) {
|
|
|
1896
2020
|
});
|
|
1897
2021
|
}
|
|
1898
2022
|
function writeNodeResponse(response, nodeRes, method) {
|
|
2023
|
+
// A body already sent once answered another request, with headers written for that one.
|
|
2024
|
+
if (response.bodyUsed)
|
|
2025
|
+
throw new TypeError("[nifra] a Response body can be sent only once");
|
|
1899
2026
|
const isHead = method?.toUpperCase() === "HEAD";
|
|
1900
2027
|
// `ServerResponse.setHeaders` (Node 18.14+) takes the Headers object directly and iterates its
|
|
1901
2028
|
// native Symbol.iterator, which - unlike `Headers.forEach`/`.get()` - never comma-joins repeated
|
|
@@ -2123,11 +2250,13 @@ async function loadWsServer(maxPayloadBytes) {
|
|
|
2123
2250
|
try {
|
|
2124
2251
|
const mod = (await import(__rewriteRelativeImportExtension(WS_MODULE_SPECIFIER)));
|
|
2125
2252
|
const Ctor = mod.WebSocketServer ?? mod.default?.WebSocketServer;
|
|
2253
|
+
// `ws` reads `maxPayload: 0` as no limit, so a cap of 0 buffers at most a byte and the message
|
|
2254
|
+
// check in `attachNodeWebSocket` refuses anything but an empty frame.
|
|
2126
2255
|
return Ctor === undefined
|
|
2127
2256
|
? undefined
|
|
2128
2257
|
: new Ctor(maxPayloadBytes === undefined
|
|
2129
2258
|
? { noServer: true }
|
|
2130
|
-
: { noServer: true, maxPayload: maxPayloadBytes });
|
|
2259
|
+
: { noServer: true, maxPayload: Math.max(maxPayloadBytes, 1) });
|
|
2131
2260
|
}
|
|
2132
2261
|
catch {
|
|
2133
2262
|
return undefined; // `ws` not installed - caller responds 501
|
|
@@ -2144,7 +2273,7 @@ const WS_STATUS_TEXT = {
|
|
|
2144
2273
|
};
|
|
2145
2274
|
/** Resolve a Node `upgrade` event: run the nifra upgrade guard, then either reject (write an HTTP error
|
|
2146
2275
|
* to the raw socket) or perform the `ws` upgrade and wire the socket to the handler. */
|
|
2147
|
-
async function handleUpgrade(app, answering, resolveWs, getProtocol, hostPolicy, nodeReq, socket, head, getWss) {
|
|
2276
|
+
async function handleUpgrade(app, answering, adopt, resolveWs, getProtocol, hostPolicy, nodeReq, socket, head, getWss) {
|
|
2148
2277
|
// A peer can reset the socket while the guard is awaited; with no listener that error ends the process.
|
|
2149
2278
|
socket.on("error", ignoreSocketError);
|
|
2150
2279
|
resolveTarget(nodeReq);
|
|
@@ -2201,15 +2330,24 @@ async function handleUpgrade(app, answering, resolveWs, getProtocol, hostPolicy,
|
|
|
2201
2330
|
writeUpgradeRejection(socket, 501, "websocket_unavailable"); // `ws` not installed
|
|
2202
2331
|
return;
|
|
2203
2332
|
}
|
|
2204
|
-
const
|
|
2205
|
-
wss.handleUpgrade(nodeReq, socket, head, (ws) =>
|
|
2333
|
+
const upgrade = outcome;
|
|
2334
|
+
wss.handleUpgrade(nodeReq, socket, head, (ws) => {
|
|
2335
|
+
// An upgrade still resolving when stop() ran must not outlive it.
|
|
2336
|
+
if (!adopt(ws)) {
|
|
2337
|
+
ws.terminate();
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
attachNodeWebSocket(ws, upgrade);
|
|
2341
|
+
});
|
|
2206
2342
|
}
|
|
2207
2343
|
/** Wire a `ws` socket (already open in `handleUpgrade`'s callback) to a nifra WS handler. The Node copy
|
|
2208
2344
|
* of core's `attachWebSocket` - binary frames normalize to `Uint8Array`; a thrown/rejected callback
|
|
2209
2345
|
* routes to `error()` and never crashes the connection. */
|
|
2210
|
-
function attachNodeWebSocket(ws,
|
|
2346
|
+
function attachNodeWebSocket(ws, outcome) {
|
|
2347
|
+
const { handler, data, pubsub, maxPayloadBytes } = outcome;
|
|
2348
|
+
const rawSend = (payload) => ws.send(payload);
|
|
2211
2349
|
const nifra = {
|
|
2212
|
-
send:
|
|
2350
|
+
send: outcome.sender === undefined ? rawSend : outcome.sender(handler, rawSend, reportError),
|
|
2213
2351
|
close: (code, reason) => ws.close(code, reason),
|
|
2214
2352
|
get readyState() {
|
|
2215
2353
|
return ws.readyState;
|
|
@@ -2219,7 +2357,7 @@ function attachNodeWebSocket(ws, handler, data, pubsub) {
|
|
|
2219
2357
|
data,
|
|
2220
2358
|
raw: ws,
|
|
2221
2359
|
};
|
|
2222
|
-
|
|
2360
|
+
function reportError(error) {
|
|
2223
2361
|
if (handler.error === undefined)
|
|
2224
2362
|
return;
|
|
2225
2363
|
try {
|
|
@@ -2230,7 +2368,7 @@ function attachNodeWebSocket(ws, handler, data, pubsub) {
|
|
|
2230
2368
|
catch {
|
|
2231
2369
|
/* the error handler itself failed - last resort, swallow */
|
|
2232
2370
|
}
|
|
2233
|
-
}
|
|
2371
|
+
}
|
|
2234
2372
|
const safe = (call) => {
|
|
2235
2373
|
try {
|
|
2236
2374
|
const r = call();
|
|
@@ -2242,6 +2380,10 @@ function attachNodeWebSocket(ws, handler, data, pubsub) {
|
|
|
2242
2380
|
}
|
|
2243
2381
|
};
|
|
2244
2382
|
ws.on("message", (raw, isBinary) => {
|
|
2383
|
+
if (maxPayloadBytes !== undefined && raw.byteLength > maxPayloadBytes) {
|
|
2384
|
+
ws.close(1009, "message too large");
|
|
2385
|
+
return;
|
|
2386
|
+
}
|
|
2245
2387
|
const payload = isBinary
|
|
2246
2388
|
? new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength)
|
|
2247
2389
|
: raw.toString();
|