@nifrajs/node 2.14.1 → 3.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 +7 -1
- package/dist/generated/bridge-markers.d.ts +11 -0
- package/dist/generated/bridge-markers.d.ts.map +1 -0
- package/dist/generated/bridge-markers.js +14 -0
- package/dist/generated/bridge-markers.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +372 -50
- package/dist/index.js.map +1 -1
- package/dist/node-stream.d.ts +60 -0
- package/dist/node-stream.d.ts.map +1 -0
- package/dist/node-stream.js +174 -0
- package/dist/node-stream.js.map +1 -0
- package/package.json +1 -1
- package/src/generated/bridge-markers.ts +14 -0
- package/src/index.ts +457 -62
- package/src/node-stream.ts +212 -0
package/dist/index.js
CHANGED
|
@@ -19,13 +19,16 @@ 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 { constants as FS } from "node:fs";
|
|
22
23
|
import { open, realpath } from "node:fs/promises";
|
|
23
24
|
import { createServer, } from "node:http";
|
|
24
25
|
import { extname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
25
|
-
import { Readable } from "node:stream";
|
|
26
26
|
import { fileURLToPath } from "node:url";
|
|
27
27
|
// srvx's lazy spec-shaped Response - see nodeOutcomeToResponse for why the bridge uses it.
|
|
28
28
|
import { FastResponse } from "srvx/node";
|
|
29
|
+
import { NODE_BRIDGE_MARKER_KEYS } from "./generated/bridge-markers.js";
|
|
30
|
+
import { claimableWebStream, claimNodeStream } from "./node-stream.js";
|
|
31
|
+
const RESOLVE_NODE_MOUNT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.resolveNodeMount);
|
|
29
32
|
/**
|
|
30
33
|
* The `Content-Type` the host runtime's `Response.json` emits - Node's undici uses `application/json`,
|
|
31
34
|
* Bun uses `application/json;charset=utf-8`. Probed once at module load (zero per-request cost) so the
|
|
@@ -34,12 +37,12 @@ import { FastResponse } from "srvx/node";
|
|
|
34
37
|
const JSON_CONTENT_TYPE = Response.json(0).headers.get("content-type") ?? "application/json";
|
|
35
38
|
const INTERNAL_ERROR_BODY = '{"ok":false,"error":"internal_error"}';
|
|
36
39
|
const EMPTY_BUFFER = Buffer.alloc(0);
|
|
37
|
-
const NODE_RESPONSE_BODY = Symbol.for(
|
|
38
|
-
const RESPONSE_RESULT = Symbol.for(
|
|
40
|
+
const NODE_RESPONSE_BODY = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseBody);
|
|
41
|
+
const RESPONSE_RESULT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseResult);
|
|
39
42
|
/** Core's proof that a header record's names are already the lowercase wire spelling - set once per
|
|
40
43
|
* request by the native response walk, which had to look at the same keys anyway. Declared by key
|
|
41
44
|
* rather than imported: the same cross-package convention as the two marks above. */
|
|
42
|
-
const LOWERCASE_HEADER_KEYS = Symbol.for(
|
|
45
|
+
const LOWERCASE_HEADER_KEYS = Symbol.for(NODE_BRIDGE_MARKER_KEYS.lowercaseHeaderKeys);
|
|
43
46
|
function isBodylessStatus(status) {
|
|
44
47
|
return status === 204 || status === 205 || status === 304;
|
|
45
48
|
}
|
|
@@ -59,6 +62,19 @@ function normalizeBodylessResponse(response) {
|
|
|
59
62
|
const NODE_OUTCOME_RUNTIME = {
|
|
60
63
|
toOutcome(result, set) {
|
|
61
64
|
if (isResponseResult(result)) {
|
|
65
|
+
// A plain render never builds a `Response` - same `kind: "json"` lane a handler's return takes.
|
|
66
|
+
const plain = result.plain;
|
|
67
|
+
if (plain !== undefined) {
|
|
68
|
+
return {
|
|
69
|
+
kind: "json",
|
|
70
|
+
status: plain.status,
|
|
71
|
+
headers: plainRenderHeaders(plain.headers, set._headers),
|
|
72
|
+
cookies: set._cookies,
|
|
73
|
+
body: plain.body === undefined || isBodylessStatus(plain.status)
|
|
74
|
+
? null
|
|
75
|
+
: JSON.stringify(plain.body),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
62
78
|
const body = result.toNodeBody?.();
|
|
63
79
|
if (body !== undefined) {
|
|
64
80
|
return {
|
|
@@ -91,6 +107,14 @@ const NODE_OUTCOME_RUNTIME = {
|
|
|
91
107
|
}),
|
|
92
108
|
jsonContentType: JSON_CONTENT_TYPE,
|
|
93
109
|
};
|
|
110
|
+
/** Mirror of core's `plainRenderHeaders`: the render's own headers on top of `c.set.headers`, always
|
|
111
|
+
* a fresh record when it has any, because the writers below mutate what they are handed and a
|
|
112
|
+
* `status(...)` value is commonly hoisted and reused across requests. */
|
|
113
|
+
function plainRenderHeaders(own, ambient) {
|
|
114
|
+
if (own === undefined)
|
|
115
|
+
return ambient;
|
|
116
|
+
return ambient === undefined ? { ...own } : { ...ambient, ...own };
|
|
117
|
+
}
|
|
94
118
|
function isResponseResult(value) {
|
|
95
119
|
return (typeof value === "object" &&
|
|
96
120
|
value !== null &&
|
|
@@ -272,8 +296,27 @@ function appendCookiesToResponse(response, cookies) {
|
|
|
272
296
|
}
|
|
273
297
|
return response;
|
|
274
298
|
}
|
|
275
|
-
|
|
276
|
-
|
|
299
|
+
/** Early exits built outside the handler's finalizer - an `onRequest` hook's response, a mount's,
|
|
300
|
+
* every framework error render. A plain-data carrier takes the `kind: "json"` lane a handler's plain
|
|
301
|
+
* return takes, with no `Response` built and none drained; there is no `c.set` at these sites, which
|
|
302
|
+
* is why they are wrapped rather than finalized. */
|
|
303
|
+
function nodeOutcomeFromResponse(result) {
|
|
304
|
+
if (!(result instanceof Response)) {
|
|
305
|
+
const plain = result.plain;
|
|
306
|
+
if (plain !== undefined) {
|
|
307
|
+
return {
|
|
308
|
+
kind: "json",
|
|
309
|
+
status: plain.status,
|
|
310
|
+
headers: plain.headers === undefined ? undefined : { ...plain.headers },
|
|
311
|
+
cookies: undefined,
|
|
312
|
+
body: plain.body === undefined || isBodylessStatus(plain.status)
|
|
313
|
+
? null
|
|
314
|
+
: JSON.stringify(plain.body),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
return nodeOutcomeFromResponse(result.toResponse());
|
|
318
|
+
}
|
|
319
|
+
const response = normalizeBodylessResponse(result);
|
|
277
320
|
if (!response.bodyUsed) {
|
|
278
321
|
const body = response[NODE_RESPONSE_BODY];
|
|
279
322
|
if (typeof body === "string" || body instanceof Uint8Array) {
|
|
@@ -556,19 +599,23 @@ function staticMatch(state, rawUrl) {
|
|
|
556
599
|
async function readStatic(file, state, method) {
|
|
557
600
|
let handle;
|
|
558
601
|
try {
|
|
559
|
-
handle = await open(file, "r");
|
|
560
|
-
const stat = await handle.stat();
|
|
561
|
-
if (!stat.isFile()) {
|
|
562
|
-
await handle.close();
|
|
563
|
-
return { kind: "response", response: new Response("Not Found", { status: 404 }) };
|
|
564
|
-
}
|
|
565
602
|
// Defense-in-depth: the lexical `..` guard in staticMatch can't catch a symlink INSIDE root that
|
|
566
|
-
// points outside it.
|
|
603
|
+
// points outside it. Resolve first, check containment on the RESOLVED name, and open that -
|
|
604
|
+
// never open the requested path and re-resolve it afterwards. A local attacker who can write
|
|
605
|
+
// inside the served tree wins that ordering: swap a link between the open and the lookup and the
|
|
606
|
+
// descriptor already streaming refers to an external file while the lookup answers with a
|
|
607
|
+
// contained path. `O_NOFOLLOW` closes the remainder on the final component - `realpath` returned
|
|
608
|
+
// a name with no links left in it, so one appearing before the open is an attack, not a layout.
|
|
567
609
|
const [realFile, realRoot] = await Promise.all([realpath(file), realpath(state.root)]);
|
|
568
610
|
if (realFile !== realRoot && !realFile.startsWith(realRoot + sep)) {
|
|
569
|
-
await handle.close();
|
|
570
611
|
return { kind: "response", response: new Response("Forbidden", { status: 403 }) };
|
|
571
612
|
}
|
|
613
|
+
handle = await open(realFile, FS.O_RDONLY | FS.O_NOFOLLOW);
|
|
614
|
+
const stat = await handle.stat();
|
|
615
|
+
if (!stat.isFile()) {
|
|
616
|
+
await handle.close();
|
|
617
|
+
return { kind: "response", response: new Response("Not Found", { status: 404 }) };
|
|
618
|
+
}
|
|
572
619
|
const headers = { ...state.headers };
|
|
573
620
|
headers["content-type"] =
|
|
574
621
|
STATIC_CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
@@ -585,7 +632,11 @@ async function readStatic(file, state, method) {
|
|
|
585
632
|
const stream = handle.createReadStream();
|
|
586
633
|
return {
|
|
587
634
|
kind: "response",
|
|
588
|
-
|
|
635
|
+
// Claimable rather than `Readable.toWeb`: served straight from disk to the socket when this
|
|
636
|
+
// response reaches the writer untouched, and read as an ordinary Web stream by anything that
|
|
637
|
+
// gets to it first (a middleware that rewrites or compresses the body, say), which refuses
|
|
638
|
+
// the claim and takes the conversion instead.
|
|
639
|
+
response: new Response(claimableWebStream(stream), { headers }),
|
|
589
640
|
};
|
|
590
641
|
}
|
|
591
642
|
catch {
|
|
@@ -648,7 +699,13 @@ export function serve(app, options) {
|
|
|
648
699
|
try {
|
|
649
700
|
const handled = handle(app, nodeReq, nodeRes, protocol, staticState, hostPolicy);
|
|
650
701
|
if (handled instanceof Promise) {
|
|
651
|
-
|
|
702
|
+
// `catch` before `finally`: a `finally` alone forwards the rejection to a promise nothing
|
|
703
|
+
// observes, and Node terminates the process on an unhandled rejection by default. Every
|
|
704
|
+
// writer already ends its own failures (`failWrite`), so anything arriving here is a fault
|
|
705
|
+
// no response can still be built from - the connection is the only thing left to end.
|
|
706
|
+
void handled
|
|
707
|
+
.catch(() => failWrite(nodeRes))
|
|
708
|
+
.finally(() => {
|
|
652
709
|
inFlight -= 1;
|
|
653
710
|
});
|
|
654
711
|
return;
|
|
@@ -656,7 +713,7 @@ export function serve(app, options) {
|
|
|
656
713
|
inFlight -= 1;
|
|
657
714
|
}
|
|
658
715
|
catch {
|
|
659
|
-
|
|
716
|
+
failWrite(nodeRes);
|
|
660
717
|
inFlight -= 1;
|
|
661
718
|
}
|
|
662
719
|
});
|
|
@@ -713,6 +770,49 @@ export function serve(app, options) {
|
|
|
713
770
|
server.listen(options.port, options.hostname, onListening);
|
|
714
771
|
});
|
|
715
772
|
}
|
|
773
|
+
/** Run the existing Node-direct/Web bridge after a native mount either was not selected or declined
|
|
774
|
+
* because its optional transport was unavailable. Kept as a top-level helper so ordinary requests
|
|
775
|
+
* do not allocate a per-request closure merely to preserve the fallback. */
|
|
776
|
+
function runNodeSource(app, source, nodeReq, nodeRes, protocol, host, platform) {
|
|
777
|
+
const resolveNodeSource = app.resolveNodeSource;
|
|
778
|
+
if (typeof resolveNodeSource === "function") {
|
|
779
|
+
try {
|
|
780
|
+
const outcome = resolveNodeSource.call(app, source, platform, NODE_OUTCOME_RUNTIME);
|
|
781
|
+
return outcome instanceof Promise
|
|
782
|
+
? outcome.then((settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method), () => failWrite(nodeRes))
|
|
783
|
+
: writeOutcomeSafely(outcome, nodeRes, nodeReq.method);
|
|
784
|
+
}
|
|
785
|
+
catch {
|
|
786
|
+
failWrite(nodeRes);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
const request = toWebRequest(nodeReq, protocol, host);
|
|
791
|
+
const resolveNode = app.resolveNode;
|
|
792
|
+
if (typeof resolveNode === "function") {
|
|
793
|
+
try {
|
|
794
|
+
const outcome = resolveNode.call(app, request, platform);
|
|
795
|
+
return outcome instanceof Promise
|
|
796
|
+
? outcome.then((settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method), () => failWrite(nodeRes))
|
|
797
|
+
: writeOutcomeSafely(outcome, nodeRes, nodeReq.method);
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
failWrite(nodeRes);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
try {
|
|
805
|
+
const response = app.fetch(request, platform);
|
|
806
|
+
return response instanceof Promise
|
|
807
|
+
? response.then((settled) => writeResponseSafely(settled, nodeRes, nodeReq.method), () => failWrite(nodeRes))
|
|
808
|
+
: writeResponseSafely(response, nodeRes, nodeReq.method);
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
// The app should never throw (nifra returns a 500), but never leak a stack to the wire.
|
|
812
|
+
failWrite(nodeRes);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
716
816
|
function handle(app, nodeReq, nodeRes, getProtocol, staticState, hostPolicy) {
|
|
717
817
|
const host = requestHost(nodeReq, hostPolicy);
|
|
718
818
|
if (host === undefined) {
|
|
@@ -746,27 +846,53 @@ function handle(app, nodeReq, nodeRes, getProtocol, staticState, hostPolicy) {
|
|
|
746
846
|
const matched = staticMatch(staticState, nodeReq.url ?? "/");
|
|
747
847
|
if (matched !== "pass") {
|
|
748
848
|
if ("reject" in matched)
|
|
749
|
-
return
|
|
750
|
-
return readStatic(matched.file, staticState, nodeReq.method ?? "GET").then((outcome) =>
|
|
849
|
+
return writeResponseSafely(matched.reject, nodeRes, nodeReq.method);
|
|
850
|
+
return readStatic(matched.file, staticState, nodeReq.method ?? "GET").then((outcome) => writeOutcomeSafely(outcome, nodeRes, nodeReq.method), () => failWrite(nodeRes));
|
|
751
851
|
}
|
|
752
852
|
}
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
// behavior is identical. A plain `{ fetch }` handler (no `resolveNode`) uses the Web path too.
|
|
853
|
+
// A mounted handler may advertise a native Node lane. Core has already checked route precedence
|
|
854
|
+
// and every global lifecycle gate before returning this selection. The handler writes directly to
|
|
855
|
+
// `nodeRes`; `false` means its optional transport was unavailable, so the same untouched source
|
|
856
|
+
// continues through the ordinary Web/direct path below.
|
|
758
857
|
const resolveNodeSource = app.resolveNodeSource;
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
858
|
+
const resolveNodeMount = app[RESOLVE_NODE_MOUNT];
|
|
859
|
+
if (typeof resolveNodeSource === "function" || typeof resolveNodeMount === "function") {
|
|
860
|
+
const nodeSource = toNodeRequestSource(nodeReq, protocol, host);
|
|
861
|
+
if (typeof resolveNodeMount === "function") {
|
|
862
|
+
let selection;
|
|
863
|
+
try {
|
|
864
|
+
selection = resolveNodeMount.call(app, nodeSource, platform);
|
|
865
|
+
}
|
|
866
|
+
catch {
|
|
867
|
+
writeInternalError(nodeRes);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (selection !== undefined) {
|
|
871
|
+
const nativeRequest = {
|
|
872
|
+
method: nodeReq.method ?? "GET",
|
|
873
|
+
url: selection.stripPrefix
|
|
874
|
+
? stripNodeMountPrefix(`${protocol}://${host}${nodeReq.url ?? "/"}`, selection.path)
|
|
875
|
+
: `${protocol}://${host}${nodeReq.url ?? "/"}`,
|
|
876
|
+
headers: nodeReq.headers,
|
|
877
|
+
raw: nodeReq,
|
|
878
|
+
};
|
|
879
|
+
try {
|
|
880
|
+
const outcome = selection.handler(nativeRequest, nodeRes, platform);
|
|
881
|
+
if (outcome instanceof Promise) {
|
|
882
|
+
return outcome.then((handled) => handled === false
|
|
883
|
+
? runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform)
|
|
884
|
+
: undefined, () => failWrite(nodeRes));
|
|
885
|
+
}
|
|
886
|
+
if (outcome !== false)
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
failWrite(nodeRes);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
769
894
|
}
|
|
895
|
+
return runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform);
|
|
770
896
|
}
|
|
771
897
|
const request = toWebRequest(nodeReq, protocol, host);
|
|
772
898
|
const resolveNode = app.resolveNode;
|
|
@@ -774,23 +900,23 @@ function handle(app, nodeReq, nodeRes, getProtocol, staticState, hostPolicy) {
|
|
|
774
900
|
try {
|
|
775
901
|
const outcome = resolveNode.call(app, request, platform);
|
|
776
902
|
return outcome instanceof Promise
|
|
777
|
-
? outcome.then((settled) =>
|
|
778
|
-
:
|
|
903
|
+
? outcome.then((settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method), () => failWrite(nodeRes))
|
|
904
|
+
: writeOutcomeSafely(outcome, nodeRes, nodeReq.method);
|
|
779
905
|
}
|
|
780
906
|
catch {
|
|
781
|
-
|
|
907
|
+
failWrite(nodeRes);
|
|
782
908
|
return;
|
|
783
909
|
}
|
|
784
910
|
}
|
|
785
911
|
try {
|
|
786
912
|
const response = app.fetch(request, platform);
|
|
787
913
|
return response instanceof Promise
|
|
788
|
-
? response.then((settled) =>
|
|
789
|
-
:
|
|
914
|
+
? response.then((settled) => writeResponseSafely(settled, nodeRes, nodeReq.method), () => failWrite(nodeRes))
|
|
915
|
+
: writeResponseSafely(response, nodeRes, nodeReq.method);
|
|
790
916
|
}
|
|
791
917
|
catch {
|
|
792
918
|
// The app should never throw (nifra returns a 500), but never leak a stack to the wire.
|
|
793
|
-
|
|
919
|
+
failWrite(nodeRes);
|
|
794
920
|
return;
|
|
795
921
|
}
|
|
796
922
|
}
|
|
@@ -811,6 +937,54 @@ function writeInternalError(nodeRes) {
|
|
|
811
937
|
nodeRes.writeHead(500, { "content-type": "application/json" });
|
|
812
938
|
nodeRes.end(INTERNAL_ERROR_BODY);
|
|
813
939
|
}
|
|
940
|
+
/**
|
|
941
|
+
* End a request whose WRITE failed, from anywhere a write can fail.
|
|
942
|
+
*
|
|
943
|
+
* `writeHead` throws on an invalid status or a header value carrying CR/LF - reachable whenever an
|
|
944
|
+
* app reflects request data into `status(...)`/`c.set.headers`, which on the Web lane the `Headers`
|
|
945
|
+
* constructor rejects instead. It also throws once the head is already out. Both have to end here:
|
|
946
|
+
* on the async lanes the write runs inside a `.then` callback whose rejection nothing downstream
|
|
947
|
+
* catches (`serve` only observes `finally`), and Node's default for an unhandled rejection is to
|
|
948
|
+
* terminate the process - a route-shaped input turning into a server-wide DoS.
|
|
949
|
+
*
|
|
950
|
+
* A 500 while the head is unsent, otherwise a destroy: there is no way to correct a response whose
|
|
951
|
+
* status line already shipped, and a half-written body must not be left for the client to parse.
|
|
952
|
+
*/
|
|
953
|
+
function failWrite(nodeRes) {
|
|
954
|
+
try {
|
|
955
|
+
if (!nodeRes.headersSent) {
|
|
956
|
+
writeInternalError(nodeRes);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
catch {
|
|
961
|
+
// The socket went away between the check and the write; fall through to the destroy.
|
|
962
|
+
}
|
|
963
|
+
nodeRes.destroy();
|
|
964
|
+
}
|
|
965
|
+
/** {@link writeNodeOutcome} with {@link failWrite} behind it, sync throw and async rejection alike. */
|
|
966
|
+
function writeOutcomeSafely(outcome, nodeRes, method) {
|
|
967
|
+
try {
|
|
968
|
+
const written = writeNodeOutcome(outcome, nodeRes, method);
|
|
969
|
+
return written instanceof Promise ? written.catch(() => failWrite(nodeRes)) : written;
|
|
970
|
+
}
|
|
971
|
+
catch {
|
|
972
|
+
failWrite(nodeRes);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
/** {@link writeNodeResponse} with {@link failWrite} behind it - same contract as
|
|
977
|
+
* {@link writeOutcomeSafely}, for the lanes that carry a `Response`. */
|
|
978
|
+
function writeResponseSafely(response, nodeRes, method) {
|
|
979
|
+
try {
|
|
980
|
+
const written = writeNodeResponse(response, nodeRes, method);
|
|
981
|
+
return written instanceof Promise ? written.catch(() => failWrite(nodeRes)) : written;
|
|
982
|
+
}
|
|
983
|
+
catch {
|
|
984
|
+
failWrite(nodeRes);
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
814
988
|
function writeBadRequest(nodeRes) {
|
|
815
989
|
const body = "Bad Request";
|
|
816
990
|
nodeRes.writeHead(400, {
|
|
@@ -882,6 +1056,14 @@ function writeJsonOutcome(outcome, nodeRes, isHead = false) {
|
|
|
882
1056
|
// already represented as null so the direct writer cannot advertise bytes that will not ship.
|
|
883
1057
|
delete headers["content-length"];
|
|
884
1058
|
}
|
|
1059
|
+
else if (!isHead && headers["content-length"] === undefined) {
|
|
1060
|
+
// A body-less render at a status that MAY carry a body - a `redirect()`, above all. Node frames
|
|
1061
|
+
// a `writeHead` + bare `end()` as chunked, so the shortest response the framework emits went out
|
|
1062
|
+
// with a chunk terminator and no length, where every Web-native runtime sends `content-length: 0`.
|
|
1063
|
+
// Declared here so the wire matches them. HEAD is excluded: its length describes the GET's body,
|
|
1064
|
+
// which this lane does not know.
|
|
1065
|
+
headers["content-length"] = "0";
|
|
1066
|
+
}
|
|
885
1067
|
if (outcome.cookies !== undefined && outcome.cookies.length > 0) {
|
|
886
1068
|
headers["set-cookie"] = [...outcome.cookies];
|
|
887
1069
|
}
|
|
@@ -959,6 +1141,14 @@ function toNodeRequestSource(req, protocol, host) {
|
|
|
959
1141
|
? new LeanNodeGetSource(req, method, protocol, host)
|
|
960
1142
|
: new LazyNodeRequestSource(req, method, protocol, host);
|
|
961
1143
|
}
|
|
1144
|
+
function stripNodeMountPrefix(url, prefix) {
|
|
1145
|
+
if (prefix === "/")
|
|
1146
|
+
return url;
|
|
1147
|
+
const target = new URL(url);
|
|
1148
|
+
const rest = target.pathname.slice(prefix.length);
|
|
1149
|
+
target.pathname = rest === "" ? "/" : rest;
|
|
1150
|
+
return target.href;
|
|
1151
|
+
}
|
|
962
1152
|
/**
|
|
963
1153
|
* Split an origin-form request target ("/path?q#frag") into pathname + search without synthesizing
|
|
964
1154
|
* an absolute URL first. Mirrors `@nifrajs/core`'s `urlPartsOf` for the origin-form case (kept in
|
|
@@ -1056,7 +1246,7 @@ class LazyNodeRequestSource {
|
|
|
1056
1246
|
return null;
|
|
1057
1247
|
if (this.consumedBody !== undefined)
|
|
1058
1248
|
return this.request.body;
|
|
1059
|
-
this.bodyValue ??=
|
|
1249
|
+
this.bodyValue ??= claimableWebStream(this.nodeReq, "drain");
|
|
1060
1250
|
return this.bodyValue;
|
|
1061
1251
|
}
|
|
1062
1252
|
arrayBuffer() {
|
|
@@ -1126,7 +1316,7 @@ class LazyNodeRequestSource {
|
|
|
1126
1316
|
const consumed = this.consumedBody;
|
|
1127
1317
|
if (consumed !== undefined)
|
|
1128
1318
|
return streamOfBytes(consumed);
|
|
1129
|
-
this.bodyValue ??=
|
|
1319
|
+
this.bodyValue ??= claimableWebStream(this.nodeReq, "drain");
|
|
1130
1320
|
return this.bodyValue;
|
|
1131
1321
|
}
|
|
1132
1322
|
get request() {
|
|
@@ -1269,7 +1459,7 @@ function makeWebRequest(req, method, url, headers, body) {
|
|
|
1269
1459
|
const init = { method, headers };
|
|
1270
1460
|
if (method !== "GET" && method !== "HEAD") {
|
|
1271
1461
|
// Stream the body in; `duplex: "half"` is required for a streamed request body.
|
|
1272
|
-
init.body = body ??
|
|
1462
|
+
init.body = body ?? claimableWebStream(req, "drain");
|
|
1273
1463
|
init.duplex = "half";
|
|
1274
1464
|
}
|
|
1275
1465
|
return new Request(url, init);
|
|
@@ -1406,31 +1596,158 @@ function writeNodeResponse(response, nodeRes, method) {
|
|
|
1406
1596
|
// forEach into a fresh plain object plus a second cookie-specific pass. Must run before
|
|
1407
1597
|
// `writeHead`: headers are already flushed by the time `writeHead` returns.
|
|
1408
1598
|
nodeRes.setHeaders(response.headers);
|
|
1409
|
-
nodeRes.writeHead(response.status);
|
|
1410
1599
|
if (isHead) {
|
|
1600
|
+
nodeRes.writeHead(response.status);
|
|
1411
1601
|
nodeRes.end();
|
|
1412
1602
|
return;
|
|
1413
1603
|
}
|
|
1414
1604
|
const directBody = nodeResponseBody(response);
|
|
1415
1605
|
if (directBody !== undefined) {
|
|
1606
|
+
// Node computes the length itself for `end(data)` while the header is still unflushed, so this
|
|
1607
|
+
// lane already declares one. The three below do not get that for free.
|
|
1608
|
+
nodeRes.writeHead(response.status);
|
|
1416
1609
|
nodeRes.end(directBody);
|
|
1417
1610
|
return;
|
|
1418
1611
|
}
|
|
1612
|
+
// `end()` with no data is framed as chunked instead, so a body-less `Response` - a hand-rolled
|
|
1613
|
+
// redirect, above all - went out with a chunk terminator and no length where every Web-native
|
|
1614
|
+
// runtime sends `content-length: 0`. Declared before `writeHead`, the last point a header can
|
|
1615
|
+
// still be added. Excluded: HEAD, whose length describes the GET's body that this lane does not
|
|
1616
|
+
// know; a status that cannot carry a body; and a length the caller set for itself.
|
|
1617
|
+
const canDeclareLength = !isBodylessStatus(response.status) && !response.headers.has("content-length");
|
|
1419
1618
|
if (response.body === null) {
|
|
1619
|
+
if (canDeclareLength)
|
|
1620
|
+
nodeRes.setHeader("content-length", "0");
|
|
1621
|
+
nodeRes.writeHead(response.status);
|
|
1420
1622
|
if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable)
|
|
1421
1623
|
nodeRes.end();
|
|
1422
1624
|
return;
|
|
1423
1625
|
}
|
|
1424
|
-
|
|
1626
|
+
// A body that is a Web view over a Node stream (an upstream response relayed by
|
|
1627
|
+
// `@nifrajs/proxy/undici`, say) goes to the socket as the Node stream it already is, skipping the
|
|
1628
|
+
// per-chunk trip through Web objects that the reader loop below pays for. Length is the upstream's
|
|
1629
|
+
// business: it either forwarded a `content-length` or is genuinely streaming.
|
|
1630
|
+
const nodeBody = response.bodyUsed ? null : claimNodeStream(response.body);
|
|
1631
|
+
if (nodeBody !== null) {
|
|
1632
|
+
nodeRes.writeHead(response.status);
|
|
1633
|
+
return pipeNodeResponseBody(nodeBody, nodeRes);
|
|
1634
|
+
}
|
|
1635
|
+
return writeNodeResponseBody(response, nodeRes, canDeclareLength);
|
|
1636
|
+
}
|
|
1637
|
+
/**
|
|
1638
|
+
* Send a claimed Node body straight to the socket.
|
|
1639
|
+
*
|
|
1640
|
+
* `pipe` rather than `pipeline`: the status line is already flushed by here, so the only teardown
|
|
1641
|
+
* still available is a destroy on both ends, and `pipeline`'s per-request `eos` machinery costs
|
|
1642
|
+
* more than it buys at this point - it measured as a net loss against the Web reader loop it
|
|
1643
|
+
* replaces, where plain `pipe` measured as a win on both GET and POST.
|
|
1644
|
+
*
|
|
1645
|
+
* The two destroys below are belt-and-braces, not the mechanism: for a body claimed from
|
|
1646
|
+
* `@nifrajs/proxy`, the request's abort signal already tears the upstream down when the client
|
|
1647
|
+
* hangs up, and an upstream that dies mid-body already reaches the client as a broken transfer
|
|
1648
|
+
* rather than a short `200`. They are kept because `pipe` itself guarantees neither, and the claim
|
|
1649
|
+
* seam is open to any transport - including one that wires no signal.
|
|
1650
|
+
*/
|
|
1651
|
+
function pipeNodeResponseBody(body, nodeRes) {
|
|
1652
|
+
return new Promise((resolve) => {
|
|
1653
|
+
let settled = false;
|
|
1654
|
+
const done = () => {
|
|
1655
|
+
if (settled)
|
|
1656
|
+
return;
|
|
1657
|
+
settled = true;
|
|
1658
|
+
if (!body.destroyed)
|
|
1659
|
+
body.destroy();
|
|
1660
|
+
resolve();
|
|
1661
|
+
};
|
|
1662
|
+
// Attached before anything here can destroy `body`, and with `on` rather than `once`, because
|
|
1663
|
+
// destroying an upstream body emits `error` (undici raises `RequestAbortedError` for a request
|
|
1664
|
+
// cut short) and an unhandled `error` on a Node stream terminates the process. Every path below
|
|
1665
|
+
// ends in a destroy, including the ones that run after this listener has already fired once.
|
|
1666
|
+
body.on("error", () => {
|
|
1667
|
+
// Only meaningful before the response completes; afterwards there is nothing left to abort.
|
|
1668
|
+
if (!settled && !nodeRes.destroyed && !nodeRes.writableEnded)
|
|
1669
|
+
nodeRes.destroy();
|
|
1670
|
+
done();
|
|
1671
|
+
});
|
|
1672
|
+
// The client is already gone: `pipe` would wait on `close`/`finish` that have both fired.
|
|
1673
|
+
if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
|
|
1674
|
+
done();
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
nodeRes.once("close", done);
|
|
1678
|
+
nodeRes.once("finish", done);
|
|
1679
|
+
body.pipe(nodeRes);
|
|
1680
|
+
});
|
|
1425
1681
|
}
|
|
1426
|
-
|
|
1682
|
+
/**
|
|
1683
|
+
* Send a Web `ReadableStream` body to the socket, declaring a length when the body turns out to be
|
|
1684
|
+
* one already-complete chunk.
|
|
1685
|
+
*
|
|
1686
|
+
* `new Response("hi")` hands its bytes over as a stream, exactly as a live producer does, so the
|
|
1687
|
+
* framing decision looks identical from here and Node picks chunked for both. The two are told apart
|
|
1688
|
+
* by reading one chunk and then giving the stream a microtask to say it is done: a source-backed body
|
|
1689
|
+
* - a string, a `Uint8Array`, a `Blob` - has already enqueued everything and closes inside it, while
|
|
1690
|
+
* a producer still generating does not.
|
|
1691
|
+
*
|
|
1692
|
+
* What is held is one chunk, never the whole body, so a large or endless stream is unaffected in
|
|
1693
|
+
* memory. What is spent is a microtask, and it costs a streaming response no bytes on the wire: Node
|
|
1694
|
+
* does not flush the header until the first write either way, so nothing was going out during it.
|
|
1695
|
+
*/
|
|
1696
|
+
async function writeNodeResponseBody(response, nodeRes, canDeclareLength) {
|
|
1427
1697
|
const reader = response.body.getReader();
|
|
1698
|
+
let first;
|
|
1699
|
+
try {
|
|
1700
|
+
first = await reader.read();
|
|
1701
|
+
}
|
|
1702
|
+
catch {
|
|
1703
|
+
await reader.cancel().catch(() => { });
|
|
1704
|
+
if (!nodeRes.headersSent)
|
|
1705
|
+
writeInternalError(nodeRes);
|
|
1706
|
+
else
|
|
1707
|
+
nodeRes.destroy();
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
if (first.done) {
|
|
1711
|
+
if (canDeclareLength)
|
|
1712
|
+
nodeRes.setHeader("content-length", "0");
|
|
1713
|
+
nodeRes.writeHead(response.status);
|
|
1714
|
+
if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable)
|
|
1715
|
+
nodeRes.end();
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
// Attached with `then` rather than awaited: the point is to observe whether it has settled by the
|
|
1719
|
+
// end of this turn, not to wait for it. The rejection handler is only here so a body that fails
|
|
1720
|
+
// between the two reads is not an unhandled rejection - the drain below awaits the same promise
|
|
1721
|
+
// and is where that failure is actually handled.
|
|
1722
|
+
const pending = reader.read();
|
|
1723
|
+
let settled;
|
|
1724
|
+
pending.then((value) => {
|
|
1725
|
+
settled = value;
|
|
1726
|
+
}, () => { });
|
|
1727
|
+
// A microtask, not a turn of the event loop. It is enough - a source-backed body has already
|
|
1728
|
+
// enqueued everything, so its close is a microtask away on Node and two on Bun, both of which this
|
|
1729
|
+
// covers - and it is short enough to leave the socket's write order alone: yielding a whole turn
|
|
1730
|
+
// here wedged a pipelined keep-alive connection under Bun's `node:http` shim, where the second
|
|
1731
|
+
// response never reached the wire.
|
|
1732
|
+
await new Promise((resolve) => queueMicrotask(resolve));
|
|
1733
|
+
if (settled?.done === true && canDeclareLength && ArrayBuffer.isView(first.value)) {
|
|
1734
|
+
nodeRes.setHeader("content-length", String(first.value.byteLength));
|
|
1735
|
+
nodeRes.writeHead(response.status);
|
|
1736
|
+
if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable)
|
|
1737
|
+
nodeRes.end(first.value);
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
nodeRes.writeHead(response.status);
|
|
1741
|
+
return drainWebResponseBody(reader, nodeRes, first.value, pending);
|
|
1742
|
+
}
|
|
1743
|
+
/** The streaming half of {@link writeNodeResponseBody}, resumed from the chunk and the outstanding
|
|
1744
|
+
* read the length probe already took off the stream. */
|
|
1745
|
+
async function drainWebResponseBody(reader, nodeRes, firstChunk, pending) {
|
|
1746
|
+
let chunk = firstChunk;
|
|
1747
|
+
let outstanding = pending;
|
|
1428
1748
|
try {
|
|
1429
1749
|
for (;;) {
|
|
1430
|
-
|
|
1431
|
-
if (done)
|
|
1432
|
-
break;
|
|
1433
|
-
if (!nodeRes.write(value)) {
|
|
1750
|
+
if (!nodeRes.write(chunk)) {
|
|
1434
1751
|
if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
|
|
1435
1752
|
try {
|
|
1436
1753
|
await reader.cancel();
|
|
@@ -1447,6 +1764,11 @@ async function writeNodeResponseBody(response, nodeRes) {
|
|
|
1447
1764
|
return;
|
|
1448
1765
|
}
|
|
1449
1766
|
}
|
|
1767
|
+
const result = await (outstanding ?? reader.read());
|
|
1768
|
+
outstanding = undefined;
|
|
1769
|
+
if (result.done)
|
|
1770
|
+
break;
|
|
1771
|
+
chunk = result.value;
|
|
1450
1772
|
}
|
|
1451
1773
|
}
|
|
1452
1774
|
catch {
|