@nifrajs/node 2.14.1 → 3.2.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/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("nifra.response.body");
38
- const RESPONSE_RESULT = Symbol.for("nifra.response.result");
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("nifra.headers.lowercase");
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
- function nodeOutcomeFromResponse(response) {
276
- response = normalizeBodylessResponse(response);
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. Re-confirm the real path is contained before streaming the bytes.
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
- response: new Response(Readable.toWeb(stream), { headers }),
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 {
@@ -643,12 +694,41 @@ export function serve(app, options) {
643
694
  const protocol = protocolResolver(options.protocol);
644
695
  const hostPolicy = hostPolicyOf(options);
645
696
  const staticState = options.static !== undefined ? staticStateOf(options.static) : undefined;
697
+ // Node otherwise destroys a socket as soon as its HTTP parser emits `clientError`. That can
698
+ // discard the response for an already-dispatched request when an understated Content-Length
699
+ // leaves surplus bytes that look like a malformed pipelined request. Keep the parser error
700
+ // connection-scoped and close only after active responses finish, preserving response ordering.
701
+ const activeResponses = new WeakMap();
702
+ const parserErrorSockets = new WeakSet();
646
703
  const server = createServer((nodeReq, nodeRes) => {
704
+ const socket = nodeReq.socket;
705
+ let responses = activeResponses.get(socket);
706
+ if (responses === undefined) {
707
+ responses = new Set();
708
+ activeResponses.set(socket, responses);
709
+ }
710
+ responses.add(nodeRes);
711
+ const releaseResponse = () => {
712
+ const current = activeResponses.get(socket);
713
+ if (current === undefined)
714
+ return;
715
+ current.delete(nodeRes);
716
+ if (current.size === 0)
717
+ activeResponses.delete(socket);
718
+ };
719
+ nodeRes.once("finish", releaseResponse);
720
+ nodeRes.once("close", releaseResponse);
647
721
  inFlight += 1;
648
722
  try {
649
723
  const handled = handle(app, nodeReq, nodeRes, protocol, staticState, hostPolicy);
650
724
  if (handled instanceof Promise) {
651
- void handled.finally(() => {
725
+ // `catch` before `finally`: a `finally` alone forwards the rejection to a promise nothing
726
+ // observes, and Node terminates the process on an unhandled rejection by default. Every
727
+ // writer already ends its own failures (`failWrite`), so anything arriving here is a fault
728
+ // no response can still be built from - the connection is the only thing left to end.
729
+ void handled
730
+ .catch(() => failWrite(nodeRes))
731
+ .finally(() => {
652
732
  inFlight -= 1;
653
733
  });
654
734
  return;
@@ -656,10 +736,35 @@ export function serve(app, options) {
656
736
  inFlight -= 1;
657
737
  }
658
738
  catch {
659
- writeInternalError(nodeRes);
739
+ failWrite(nodeRes);
660
740
  inFlight -= 1;
661
741
  }
662
742
  });
743
+ server.on("clientError", (_error, socket) => {
744
+ if (socket.destroyed)
745
+ return;
746
+ const responses = activeResponses.get(socket);
747
+ if (responses !== undefined && responses.size > 0) {
748
+ if (parserErrorSockets.has(socket))
749
+ return;
750
+ parserErrorSockets.add(socket);
751
+ const closeWhenDrained = () => {
752
+ const current = activeResponses.get(socket);
753
+ if (current === undefined || current.size === 0)
754
+ socket.destroy();
755
+ };
756
+ for (const response of responses) {
757
+ if (response.writableEnded || response.destroyed)
758
+ closeWhenDrained();
759
+ else
760
+ response.once("finish", closeWhenDrained);
761
+ response.once("close", closeWhenDrained);
762
+ }
763
+ return;
764
+ }
765
+ const body = "Bad Request";
766
+ socket.end(`HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`);
767
+ });
663
768
  // WebSocket upgrades (a nifra app exposing the seam): handled on the http server's `upgrade` event via
664
769
  // the optional `ws` package - lazy-imported (and the server lazily built) on the FIRST real WS
665
770
  // upgrade, so a non-WS Node app never loads `ws`.
@@ -713,6 +818,49 @@ export function serve(app, options) {
713
818
  server.listen(options.port, options.hostname, onListening);
714
819
  });
715
820
  }
821
+ /** Run the existing Node-direct/Web bridge after a native mount either was not selected or declined
822
+ * because its optional transport was unavailable. Kept as a top-level helper so ordinary requests
823
+ * do not allocate a per-request closure merely to preserve the fallback. */
824
+ function runNodeSource(app, source, nodeReq, nodeRes, protocol, host, platform) {
825
+ const resolveNodeSource = app.resolveNodeSource;
826
+ if (typeof resolveNodeSource === "function") {
827
+ try {
828
+ const outcome = resolveNodeSource.call(app, source, platform, NODE_OUTCOME_RUNTIME);
829
+ return outcome instanceof Promise
830
+ ? outcome.then((settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method), () => failWrite(nodeRes))
831
+ : writeOutcomeSafely(outcome, nodeRes, nodeReq.method);
832
+ }
833
+ catch {
834
+ failWrite(nodeRes);
835
+ return;
836
+ }
837
+ }
838
+ const request = toWebRequest(nodeReq, protocol, host);
839
+ const resolveNode = app.resolveNode;
840
+ if (typeof resolveNode === "function") {
841
+ try {
842
+ const outcome = resolveNode.call(app, request, platform);
843
+ return outcome instanceof Promise
844
+ ? outcome.then((settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method), () => failWrite(nodeRes))
845
+ : writeOutcomeSafely(outcome, nodeRes, nodeReq.method);
846
+ }
847
+ catch {
848
+ failWrite(nodeRes);
849
+ return;
850
+ }
851
+ }
852
+ try {
853
+ const response = app.fetch(request, platform);
854
+ return response instanceof Promise
855
+ ? response.then((settled) => writeResponseSafely(settled, nodeRes, nodeReq.method), () => failWrite(nodeRes))
856
+ : writeResponseSafely(response, nodeRes, nodeReq.method);
857
+ }
858
+ catch {
859
+ // The app should never throw (nifra returns a 500), but never leak a stack to the wire.
860
+ failWrite(nodeRes);
861
+ return;
862
+ }
863
+ }
716
864
  function handle(app, nodeReq, nodeRes, getProtocol, staticState, hostPolicy) {
717
865
  const host = requestHost(nodeReq, hostPolicy);
718
866
  if (host === undefined) {
@@ -746,27 +894,53 @@ function handle(app, nodeReq, nodeRes, getProtocol, staticState, hostPolicy) {
746
894
  const matched = staticMatch(staticState, nodeReq.url ?? "/");
747
895
  if (matched !== "pass") {
748
896
  if ("reject" in matched)
749
- return writeNodeResponse(matched.reject, nodeRes, nodeReq.method);
750
- return readStatic(matched.file, staticState, nodeReq.method ?? "GET").then((outcome) => writeNodeOutcome(outcome, nodeRes, nodeReq.method), () => writeInternalError(nodeRes));
897
+ return writeResponseSafely(matched.reject, nodeRes, nodeReq.method);
898
+ return readStatic(matched.file, staticState, nodeReq.method ?? "GET").then((outcome) => writeOutcomeSafely(outcome, nodeRes, nodeReq.method), () => failWrite(nodeRes));
751
899
  }
752
900
  }
753
- // Fast path: a nifra app exposes `resolveNode`, which renders a plain-data result as primitives we
754
- // write straight to the socket - skipping the undici `Response` build + body drain (the bulk of the
755
- // Web-bridge cost on Node). A handler-returned `Response`/redirect, 404/405, error, timeout, or any
756
- // `onResponse` hook comes back as `{ kind: "response" }` and takes the same Web path as before, so
757
- // behavior is identical. A plain `{ fetch }` handler (no `resolveNode`) uses the Web path too.
901
+ // A mounted handler may advertise a native Node lane. Core has already checked route precedence
902
+ // and every global lifecycle gate before returning this selection. The handler writes directly to
903
+ // `nodeRes`; `false` means its optional transport was unavailable, so the same untouched source
904
+ // continues through the ordinary Web/direct path below.
758
905
  const resolveNodeSource = app.resolveNodeSource;
759
- if (typeof resolveNodeSource === "function") {
760
- try {
761
- const outcome = resolveNodeSource.call(app, toNodeRequestSource(nodeReq, protocol, host), platform, NODE_OUTCOME_RUNTIME);
762
- return outcome instanceof Promise
763
- ? outcome.then((settled) => writeNodeOutcome(settled, nodeRes, nodeReq.method), () => writeInternalError(nodeRes))
764
- : writeNodeOutcome(outcome, nodeRes, nodeReq.method);
765
- }
766
- catch {
767
- writeInternalError(nodeRes);
768
- return;
906
+ const resolveNodeMount = app[RESOLVE_NODE_MOUNT];
907
+ if (typeof resolveNodeSource === "function" || typeof resolveNodeMount === "function") {
908
+ const nodeSource = toNodeRequestSource(nodeReq, protocol, host);
909
+ if (typeof resolveNodeMount === "function") {
910
+ let selection;
911
+ try {
912
+ selection = resolveNodeMount.call(app, nodeSource, platform);
913
+ }
914
+ catch {
915
+ writeInternalError(nodeRes);
916
+ return;
917
+ }
918
+ if (selection !== undefined) {
919
+ const nativeRequest = {
920
+ method: nodeReq.method ?? "GET",
921
+ url: selection.stripPrefix
922
+ ? stripNodeMountPrefix(`${protocol}://${host}${nodeReq.url ?? "/"}`, selection.path)
923
+ : `${protocol}://${host}${nodeReq.url ?? "/"}`,
924
+ headers: nodeReq.headers,
925
+ raw: nodeReq,
926
+ };
927
+ try {
928
+ const outcome = selection.handler(nativeRequest, nodeRes, platform);
929
+ if (outcome instanceof Promise) {
930
+ return outcome.then((handled) => handled === false
931
+ ? runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform)
932
+ : undefined, () => failWrite(nodeRes));
933
+ }
934
+ if (outcome !== false)
935
+ return;
936
+ }
937
+ catch {
938
+ failWrite(nodeRes);
939
+ return;
940
+ }
941
+ }
769
942
  }
943
+ return runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform);
770
944
  }
771
945
  const request = toWebRequest(nodeReq, protocol, host);
772
946
  const resolveNode = app.resolveNode;
@@ -774,23 +948,23 @@ function handle(app, nodeReq, nodeRes, getProtocol, staticState, hostPolicy) {
774
948
  try {
775
949
  const outcome = resolveNode.call(app, request, platform);
776
950
  return outcome instanceof Promise
777
- ? outcome.then((settled) => writeNodeOutcome(settled, nodeRes, nodeReq.method), () => writeInternalError(nodeRes))
778
- : writeNodeOutcome(outcome, nodeRes, nodeReq.method);
951
+ ? outcome.then((settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method), () => failWrite(nodeRes))
952
+ : writeOutcomeSafely(outcome, nodeRes, nodeReq.method);
779
953
  }
780
954
  catch {
781
- writeInternalError(nodeRes);
955
+ failWrite(nodeRes);
782
956
  return;
783
957
  }
784
958
  }
785
959
  try {
786
960
  const response = app.fetch(request, platform);
787
961
  return response instanceof Promise
788
- ? response.then((settled) => writeNodeResponse(settled, nodeRes, nodeReq.method), () => writeInternalError(nodeRes))
789
- : writeNodeResponse(response, nodeRes, nodeReq.method);
962
+ ? response.then((settled) => writeResponseSafely(settled, nodeRes, nodeReq.method), () => failWrite(nodeRes))
963
+ : writeResponseSafely(response, nodeRes, nodeReq.method);
790
964
  }
791
965
  catch {
792
966
  // The app should never throw (nifra returns a 500), but never leak a stack to the wire.
793
- writeInternalError(nodeRes);
967
+ failWrite(nodeRes);
794
968
  return;
795
969
  }
796
970
  }
@@ -811,6 +985,54 @@ function writeInternalError(nodeRes) {
811
985
  nodeRes.writeHead(500, { "content-type": "application/json" });
812
986
  nodeRes.end(INTERNAL_ERROR_BODY);
813
987
  }
988
+ /**
989
+ * End a request whose WRITE failed, from anywhere a write can fail.
990
+ *
991
+ * `writeHead` throws on an invalid status or a header value carrying CR/LF - reachable whenever an
992
+ * app reflects request data into `status(...)`/`c.set.headers`, which on the Web lane the `Headers`
993
+ * constructor rejects instead. It also throws once the head is already out. Both have to end here:
994
+ * on the async lanes the write runs inside a `.then` callback whose rejection nothing downstream
995
+ * catches (`serve` only observes `finally`), and Node's default for an unhandled rejection is to
996
+ * terminate the process - a route-shaped input turning into a server-wide DoS.
997
+ *
998
+ * A 500 while the head is unsent, otherwise a destroy: there is no way to correct a response whose
999
+ * status line already shipped, and a half-written body must not be left for the client to parse.
1000
+ */
1001
+ function failWrite(nodeRes) {
1002
+ try {
1003
+ if (!nodeRes.headersSent) {
1004
+ writeInternalError(nodeRes);
1005
+ return;
1006
+ }
1007
+ }
1008
+ catch {
1009
+ // The socket went away between the check and the write; fall through to the destroy.
1010
+ }
1011
+ nodeRes.destroy();
1012
+ }
1013
+ /** {@link writeNodeOutcome} with {@link failWrite} behind it, sync throw and async rejection alike. */
1014
+ function writeOutcomeSafely(outcome, nodeRes, method) {
1015
+ try {
1016
+ const written = writeNodeOutcome(outcome, nodeRes, method);
1017
+ return written instanceof Promise ? written.catch(() => failWrite(nodeRes)) : written;
1018
+ }
1019
+ catch {
1020
+ failWrite(nodeRes);
1021
+ return;
1022
+ }
1023
+ }
1024
+ /** {@link writeNodeResponse} with {@link failWrite} behind it - same contract as
1025
+ * {@link writeOutcomeSafely}, for the lanes that carry a `Response`. */
1026
+ function writeResponseSafely(response, nodeRes, method) {
1027
+ try {
1028
+ const written = writeNodeResponse(response, nodeRes, method);
1029
+ return written instanceof Promise ? written.catch(() => failWrite(nodeRes)) : written;
1030
+ }
1031
+ catch {
1032
+ failWrite(nodeRes);
1033
+ return;
1034
+ }
1035
+ }
814
1036
  function writeBadRequest(nodeRes) {
815
1037
  const body = "Bad Request";
816
1038
  nodeRes.writeHead(400, {
@@ -874,13 +1096,27 @@ function writeJsonOutcome(outcome, nodeRes, isHead = false) {
874
1096
  // on every response (and no other runtime chunks a buffered JSON body).
875
1097
  if (outcome.body !== null) {
876
1098
  if (headers["content-type"] === undefined)
877
- headers["content-type"] = JSON_CONTENT_TYPE;
878
- headers["content-length"] = String(Buffer.byteLength(outcome.body));
1099
+ headers["Content-Type"] = JSON_CONTENT_TYPE;
1100
+ else {
1101
+ headers["Content-Type"] = headers["content-type"];
1102
+ delete headers["content-type"];
1103
+ }
1104
+ headers["Content-Length"] = String(Buffer.byteLength(outcome.body));
1105
+ delete headers["content-length"];
879
1106
  }
880
1107
  else if (isBodylessStatus(outcome.status)) {
881
1108
  // 204/205/304 never carry a payload; discard a user/native-hook length even when the body is
882
1109
  // already represented as null so the direct writer cannot advertise bytes that will not ship.
883
1110
  delete headers["content-length"];
1111
+ delete headers["Content-Length"];
1112
+ }
1113
+ else if (!isHead && headers["content-length"] === undefined) {
1114
+ // A body-less render at a status that MAY carry a body - a `redirect()`, above all. Node frames
1115
+ // a `writeHead` + bare `end()` as chunked, so the shortest response the framework emits went out
1116
+ // with a chunk terminator and no length, where every Web-native runtime sends `content-length: 0`.
1117
+ // Declared here so the wire matches them. HEAD is excluded: its length describes the GET's body,
1118
+ // which this lane does not know.
1119
+ headers["Content-Length"] = "0";
884
1120
  }
885
1121
  if (outcome.cookies !== undefined && outcome.cookies.length > 0) {
886
1122
  headers["set-cookie"] = [...outcome.cookies];
@@ -959,6 +1195,14 @@ function toNodeRequestSource(req, protocol, host) {
959
1195
  ? new LeanNodeGetSource(req, method, protocol, host)
960
1196
  : new LazyNodeRequestSource(req, method, protocol, host);
961
1197
  }
1198
+ function stripNodeMountPrefix(url, prefix) {
1199
+ if (prefix === "/")
1200
+ return url;
1201
+ const target = new URL(url);
1202
+ const rest = target.pathname.slice(prefix.length);
1203
+ target.pathname = rest === "" ? "/" : rest;
1204
+ return target.href;
1205
+ }
962
1206
  /**
963
1207
  * Split an origin-form request target ("/path?q#frag") into pathname + search without synthesizing
964
1208
  * an absolute URL first. Mirrors `@nifrajs/core`'s `urlPartsOf` for the origin-form case (kept in
@@ -1056,7 +1300,7 @@ class LazyNodeRequestSource {
1056
1300
  return null;
1057
1301
  if (this.consumedBody !== undefined)
1058
1302
  return this.request.body;
1059
- this.bodyValue ??= Readable.toWeb(this.nodeReq);
1303
+ this.bodyValue ??= claimableWebStream(this.nodeReq, "drain");
1060
1304
  return this.bodyValue;
1061
1305
  }
1062
1306
  arrayBuffer() {
@@ -1126,7 +1370,7 @@ class LazyNodeRequestSource {
1126
1370
  const consumed = this.consumedBody;
1127
1371
  if (consumed !== undefined)
1128
1372
  return streamOfBytes(consumed);
1129
- this.bodyValue ??= Readable.toWeb(this.nodeReq);
1373
+ this.bodyValue ??= claimableWebStream(this.nodeReq, "drain");
1130
1374
  return this.bodyValue;
1131
1375
  }
1132
1376
  get request() {
@@ -1269,7 +1513,7 @@ function makeWebRequest(req, method, url, headers, body) {
1269
1513
  const init = { method, headers };
1270
1514
  if (method !== "GET" && method !== "HEAD") {
1271
1515
  // Stream the body in; `duplex: "half"` is required for a streamed request body.
1272
- init.body = body ?? Readable.toWeb(req);
1516
+ init.body = body ?? claimableWebStream(req, "drain");
1273
1517
  init.duplex = "half";
1274
1518
  }
1275
1519
  return new Request(url, init);
@@ -1406,31 +1650,158 @@ function writeNodeResponse(response, nodeRes, method) {
1406
1650
  // forEach into a fresh plain object plus a second cookie-specific pass. Must run before
1407
1651
  // `writeHead`: headers are already flushed by the time `writeHead` returns.
1408
1652
  nodeRes.setHeaders(response.headers);
1409
- nodeRes.writeHead(response.status);
1410
1653
  if (isHead) {
1654
+ nodeRes.writeHead(response.status);
1411
1655
  nodeRes.end();
1412
1656
  return;
1413
1657
  }
1414
1658
  const directBody = nodeResponseBody(response);
1415
1659
  if (directBody !== undefined) {
1660
+ // Node computes the length itself for `end(data)` while the header is still unflushed, so this
1661
+ // lane already declares one. The three below do not get that for free.
1662
+ nodeRes.writeHead(response.status);
1416
1663
  nodeRes.end(directBody);
1417
1664
  return;
1418
1665
  }
1666
+ // `end()` with no data is framed as chunked instead, so a body-less `Response` - a hand-rolled
1667
+ // redirect, above all - went out with a chunk terminator and no length where every Web-native
1668
+ // runtime sends `content-length: 0`. Declared before `writeHead`, the last point a header can
1669
+ // still be added. Excluded: HEAD, whose length describes the GET's body that this lane does not
1670
+ // know; a status that cannot carry a body; and a length the caller set for itself.
1671
+ const canDeclareLength = !isBodylessStatus(response.status) && !response.headers.has("content-length");
1419
1672
  if (response.body === null) {
1673
+ if (canDeclareLength)
1674
+ nodeRes.setHeader("content-length", "0");
1675
+ nodeRes.writeHead(response.status);
1420
1676
  if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable)
1421
1677
  nodeRes.end();
1422
1678
  return;
1423
1679
  }
1424
- return writeNodeResponseBody(response, nodeRes);
1680
+ // A body that is a Web view over a Node stream (an upstream response relayed by
1681
+ // `@nifrajs/proxy/undici`, say) goes to the socket as the Node stream it already is, skipping the
1682
+ // per-chunk trip through Web objects that the reader loop below pays for. Length is the upstream's
1683
+ // business: it either forwarded a `content-length` or is genuinely streaming.
1684
+ const nodeBody = response.bodyUsed ? null : claimNodeStream(response.body);
1685
+ if (nodeBody !== null) {
1686
+ nodeRes.writeHead(response.status);
1687
+ return pipeNodeResponseBody(nodeBody, nodeRes);
1688
+ }
1689
+ return writeNodeResponseBody(response, nodeRes, canDeclareLength);
1690
+ }
1691
+ /**
1692
+ * Send a claimed Node body straight to the socket.
1693
+ *
1694
+ * `pipe` rather than `pipeline`: the status line is already flushed by here, so the only teardown
1695
+ * still available is a destroy on both ends, and `pipeline`'s per-request `eos` machinery costs
1696
+ * more than it buys at this point - it measured as a net loss against the Web reader loop it
1697
+ * replaces, where plain `pipe` measured as a win on both GET and POST.
1698
+ *
1699
+ * The two destroys below are belt-and-braces, not the mechanism: for a body claimed from
1700
+ * `@nifrajs/proxy`, the request's abort signal already tears the upstream down when the client
1701
+ * hangs up, and an upstream that dies mid-body already reaches the client as a broken transfer
1702
+ * rather than a short `200`. They are kept because `pipe` itself guarantees neither, and the claim
1703
+ * seam is open to any transport - including one that wires no signal.
1704
+ */
1705
+ function pipeNodeResponseBody(body, nodeRes) {
1706
+ return new Promise((resolve) => {
1707
+ let settled = false;
1708
+ const done = () => {
1709
+ if (settled)
1710
+ return;
1711
+ settled = true;
1712
+ if (!body.destroyed)
1713
+ body.destroy();
1714
+ resolve();
1715
+ };
1716
+ // Attached before anything here can destroy `body`, and with `on` rather than `once`, because
1717
+ // destroying an upstream body emits `error` (undici raises `RequestAbortedError` for a request
1718
+ // cut short) and an unhandled `error` on a Node stream terminates the process. Every path below
1719
+ // ends in a destroy, including the ones that run after this listener has already fired once.
1720
+ body.on("error", () => {
1721
+ // Only meaningful before the response completes; afterwards there is nothing left to abort.
1722
+ if (!settled && !nodeRes.destroyed && !nodeRes.writableEnded)
1723
+ nodeRes.destroy();
1724
+ done();
1725
+ });
1726
+ // The client is already gone: `pipe` would wait on `close`/`finish` that have both fired.
1727
+ if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
1728
+ done();
1729
+ return;
1730
+ }
1731
+ nodeRes.once("close", done);
1732
+ nodeRes.once("finish", done);
1733
+ body.pipe(nodeRes);
1734
+ });
1425
1735
  }
1426
- async function writeNodeResponseBody(response, nodeRes) {
1736
+ /**
1737
+ * Send a Web `ReadableStream` body to the socket, declaring a length when the body turns out to be
1738
+ * one already-complete chunk.
1739
+ *
1740
+ * `new Response("hi")` hands its bytes over as a stream, exactly as a live producer does, so the
1741
+ * framing decision looks identical from here and Node picks chunked for both. The two are told apart
1742
+ * by reading one chunk and then giving the stream a microtask to say it is done: a source-backed body
1743
+ * - a string, a `Uint8Array`, a `Blob` - has already enqueued everything and closes inside it, while
1744
+ * a producer still generating does not.
1745
+ *
1746
+ * What is held is one chunk, never the whole body, so a large or endless stream is unaffected in
1747
+ * memory. What is spent is a microtask, and it costs a streaming response no bytes on the wire: Node
1748
+ * does not flush the header until the first write either way, so nothing was going out during it.
1749
+ */
1750
+ async function writeNodeResponseBody(response, nodeRes, canDeclareLength) {
1427
1751
  const reader = response.body.getReader();
1752
+ let first;
1753
+ try {
1754
+ first = await reader.read();
1755
+ }
1756
+ catch {
1757
+ await reader.cancel().catch(() => { });
1758
+ if (!nodeRes.headersSent)
1759
+ writeInternalError(nodeRes);
1760
+ else
1761
+ nodeRes.destroy();
1762
+ return;
1763
+ }
1764
+ if (first.done) {
1765
+ if (canDeclareLength)
1766
+ nodeRes.setHeader("content-length", "0");
1767
+ nodeRes.writeHead(response.status);
1768
+ if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable)
1769
+ nodeRes.end();
1770
+ return;
1771
+ }
1772
+ // Attached with `then` rather than awaited: the point is to observe whether it has settled by the
1773
+ // end of this turn, not to wait for it. The rejection handler is only here so a body that fails
1774
+ // between the two reads is not an unhandled rejection - the drain below awaits the same promise
1775
+ // and is where that failure is actually handled.
1776
+ const pending = reader.read();
1777
+ let settled;
1778
+ pending.then((value) => {
1779
+ settled = value;
1780
+ }, () => { });
1781
+ // A microtask, not a turn of the event loop. It is enough - a source-backed body has already
1782
+ // enqueued everything, so its close is a microtask away on Node and two on Bun, both of which this
1783
+ // covers - and it is short enough to leave the socket's write order alone: yielding a whole turn
1784
+ // here wedged a pipelined keep-alive connection under Bun's `node:http` shim, where the second
1785
+ // response never reached the wire.
1786
+ await new Promise((resolve) => queueMicrotask(resolve));
1787
+ if (settled?.done === true && canDeclareLength && ArrayBuffer.isView(first.value)) {
1788
+ nodeRes.setHeader("content-length", String(first.value.byteLength));
1789
+ nodeRes.writeHead(response.status);
1790
+ if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable)
1791
+ nodeRes.end(first.value);
1792
+ return;
1793
+ }
1794
+ nodeRes.writeHead(response.status);
1795
+ return drainWebResponseBody(reader, nodeRes, first.value, pending);
1796
+ }
1797
+ /** The streaming half of {@link writeNodeResponseBody}, resumed from the chunk and the outstanding
1798
+ * read the length probe already took off the stream. */
1799
+ async function drainWebResponseBody(reader, nodeRes, firstChunk, pending) {
1800
+ let chunk = firstChunk;
1801
+ let outstanding = pending;
1428
1802
  try {
1429
1803
  for (;;) {
1430
- const { done, value } = await reader.read();
1431
- if (done)
1432
- break;
1433
- if (!nodeRes.write(value)) {
1804
+ if (!nodeRes.write(chunk)) {
1434
1805
  if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
1435
1806
  try {
1436
1807
  await reader.cancel();
@@ -1447,6 +1818,11 @@ async function writeNodeResponseBody(response, nodeRes) {
1447
1818
  return;
1448
1819
  }
1449
1820
  }
1821
+ const result = await (outstanding ?? reader.read());
1822
+ outstanding = undefined;
1823
+ if (result.done)
1824
+ break;
1825
+ chunk = result.value;
1450
1826
  }
1451
1827
  }
1452
1828
  catch {