@nifrajs/node 3.5.0 → 4.0.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
@@ -21,12 +21,14 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
21
21
  import { AsyncLocalStorage } from "node:async_hooks";
22
22
  import { constants as FS } from "node:fs";
23
23
  import { open, realpath } from "node:fs/promises";
24
- import { createServer, } from "node:http";
24
+ import { createServer, STATUS_CODES, } from "node:http";
25
+ import { createServer as createHttpsServer } from "node:https";
25
26
  import { extname, isAbsolute, relative, resolve, sep } from "node:path";
26
27
  import { fileURLToPath } from "node:url";
27
28
  // srvx's lazy spec-shaped Response - see nodeOutcomeToResponse for why the bridge uses it.
28
29
  import { FastResponse } from "srvx/node";
29
30
  import { NODE_BRIDGE_MARKER_KEYS } from "./generated/bridge-markers.js";
31
+ import { hasDotSegment, resolveDotSegments } from "./generated/request-target.js";
30
32
  import { claimableWebStream, claimNodeStream } from "./node-stream.js";
31
33
  const RESOLVE_NODE_MOUNT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.resolveNodeMount);
32
34
  /**
@@ -290,11 +292,21 @@ function installFastResponse() {
290
292
  globalThis.Response = DeferringResponse;
291
293
  }
292
294
  function appendCookiesToResponse(response, cookies) {
293
- if (cookies !== undefined) {
295
+ if (cookies === undefined || cookies.length === 0)
296
+ return response;
297
+ try {
294
298
  for (const cookie of cookies)
295
299
  response.headers.append("set-cookie", cookie);
300
+ return response;
301
+ }
302
+ catch {
303
+ // Guarded `Headers` (`Response.redirect()`, a raw `fetch()` result) reject every write, so the
304
+ // first append throws with nothing applied and the cookies land on a mutable copy instead.
305
+ const clone = new Response(response.body, response);
306
+ for (const cookie of cookies)
307
+ clone.headers.append("set-cookie", cookie);
308
+ return clone;
296
309
  }
297
- return response;
298
310
  }
299
311
  /** Early exits built outside the handler's finalizer - an `onRequest` hook's response, a mount's,
300
312
  * every framework error render. A plain-data carrier takes the `kind: "json"` lane a handler's plain
@@ -370,7 +382,11 @@ function nodeOutcomeToResponse(outcome) {
370
382
  pairs.splice(i, 1);
371
383
  }
372
384
  }
373
- const body = isBodylessStatus(outcome.status) ? null : outcome.body;
385
+ const body = isBodylessStatus(outcome.status) || outcome.body === null
386
+ ? null
387
+ : typeof outcome.body === "string"
388
+ ? outcome.body
389
+ : outcome.body.slice();
374
390
  const response = new FastResponse(body, {
375
391
  status: outcome.status,
376
392
  headers: pairs,
@@ -528,6 +544,14 @@ const STATIC_CONTENT_TYPES = {
528
544
  ".txt": "text/plain; charset=utf-8",
529
545
  ".webmanifest": "application/manifest+json",
530
546
  };
547
+ function staticHeadersOf(input) {
548
+ if (input === undefined)
549
+ return undefined;
550
+ const headers = Object.create(null);
551
+ for (const [name, value] of Object.entries(input))
552
+ headers[name.toLowerCase()] = value;
553
+ return headers;
554
+ }
531
555
  function staticStateOf(options) {
532
556
  const root = resolve(typeof options.dir === "string" ? options.dir : fileURLToPath(options.dir));
533
557
  const raw = options.prefix ?? "/assets";
@@ -543,7 +567,7 @@ function staticStateOf(options) {
543
567
  root,
544
568
  prefix,
545
569
  immutable: options.immutable !== false,
546
- headers: options.headers,
570
+ headers: staticHeadersOf(options.headers),
547
571
  denyDotfiles: options.dotfiles !== "allow",
548
572
  };
549
573
  }
@@ -596,7 +620,80 @@ function staticMatch(state, rawUrl) {
596
620
  }
597
621
  return { file };
598
622
  }
599
- async function readStatic(file, state, method) {
623
+ /** Strong validator from mtime + size (the nginx scheme): rewriting a file changes at least one. */
624
+ function staticEtag(size, mtimeMs) {
625
+ return `"${Math.floor(mtimeMs).toString(16)}-${size.toString(16)}"`;
626
+ }
627
+ function weakEtag(value) {
628
+ return value.startsWith("W/") ? value.slice(2) : value;
629
+ }
630
+ /** `If-None-Match` uses the weak comparison (RFC 9110 section 13.1.2): a `W/` prefix is ignored. */
631
+ function etagListMatches(header, etag) {
632
+ const comparable = weakEtag(etag);
633
+ for (const raw of header.split(",")) {
634
+ const candidate = raw.trim();
635
+ if (candidate === "*" || weakEtag(candidate) === comparable)
636
+ return true;
637
+ }
638
+ return false;
639
+ }
640
+ /** Whether the client's cached copy is current. `If-None-Match` wins over `If-Modified-Since`, whose
641
+ * HTTP-date only carries whole seconds. */
642
+ function staticNotModified(headers, etag, lastModified) {
643
+ const ifNoneMatch = headers["if-none-match"];
644
+ if (typeof ifNoneMatch === "string")
645
+ return etagListMatches(ifNoneMatch, etag);
646
+ if (lastModified === undefined)
647
+ return false;
648
+ const ifModifiedSince = headers["if-modified-since"];
649
+ if (typeof ifModifiedSince !== "string")
650
+ return false;
651
+ const since = Date.parse(ifModifiedSince);
652
+ return Number.isFinite(since) && Math.floor(lastModified / 1000) * 1000 <= since;
653
+ }
654
+ function staticIfRangeMatches(value, etag, lastModified) {
655
+ const item = value.trim();
656
+ if (item.startsWith('"') || item.startsWith("W/")) {
657
+ return !item.startsWith("W/") && !etag.startsWith("W/") && item === etag;
658
+ }
659
+ // A date validator must match Last-Modified exactly (RFC 9110 section 13.1.5), not merely be later:
660
+ // a file swapped for one with an older mtime would otherwise splice foreign bytes into a resume.
661
+ if (lastModified === undefined)
662
+ return false;
663
+ const date = Date.parse(item);
664
+ return Number.isFinite(date) && Math.floor(lastModified / 1000) === Math.floor(date / 1000);
665
+ }
666
+ /**
667
+ * One `bytes=` range as inclusive offsets, `"unsatisfiable"` (416), or `undefined` to send the whole
668
+ * file: no header, a malformed or multi-range one (RFC 9110 lets a server ignore Range), or an
669
+ * `If-Range` validator that no longer matches the file.
670
+ */
671
+ function staticByteRange(headers, size, etag, lastModified) {
672
+ const range = headers.range;
673
+ if (typeof range !== "string")
674
+ return undefined;
675
+ const ifRange = headers["if-range"];
676
+ if (typeof ifRange === "string" && !staticIfRangeMatches(ifRange, etag, lastModified)) {
677
+ return undefined;
678
+ }
679
+ const match = /^bytes=(\d*)-(\d*)$/.exec(range.trim());
680
+ if (match === null)
681
+ return undefined;
682
+ const first = match[1];
683
+ const last = match[2];
684
+ if (first === "") {
685
+ if (last === "")
686
+ return undefined;
687
+ const suffix = Number(last);
688
+ return suffix === 0 || size === 0 ? "unsatisfiable" : [Math.max(0, size - suffix), size - 1];
689
+ }
690
+ const start = Number(first);
691
+ if (start >= size)
692
+ return "unsatisfiable";
693
+ const end = last === "" ? size - 1 : Math.min(Number(last), size - 1);
694
+ return end < start ? undefined : [start, end];
695
+ }
696
+ async function readStatic(file, state, method, requestHeaders) {
600
697
  let handle;
601
698
  try {
602
699
  // Defense-in-depth: the lexical `..` guard in staticMatch can't catch a symlink INSIDE root that
@@ -617,26 +714,59 @@ async function readStatic(file, state, method) {
617
714
  return { kind: "response", response: new Response("Not Found", { status: 404 }) };
618
715
  }
619
716
  const headers = { ...state.headers };
717
+ if (state.immutable && headers["cache-control"] === undefined) {
718
+ headers["cache-control"] = "public, max-age=31536000, immutable";
719
+ }
720
+ headers.etag ??= staticEtag(stat.size, stat.mtimeMs);
721
+ headers["last-modified"] ??= stat.mtime.toUTCString();
722
+ // Custom validators are response headers, not decoration: compare the exact values the client saw.
723
+ const etag = headers.etag;
724
+ const parsedLastModified = Date.parse(headers["last-modified"]);
725
+ const lastModified = Number.isFinite(parsedLastModified) ? parsedLastModified : undefined;
726
+ // Revalidation: a current cached copy costs a header-only 304 instead of the whole file.
727
+ if (staticNotModified(requestHeaders, etag, lastModified)) {
728
+ await handle.close();
729
+ delete headers["content-length"];
730
+ delete headers["content-type"];
731
+ return { kind: "response", response: new Response(null, { status: 304, headers }) };
732
+ }
620
733
  headers["content-type"] =
621
734
  STATIC_CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
622
735
  // Never let a client sniff a served file into a more dangerous type (e.g. an .svg as active content).
623
736
  headers["x-content-type-options"] = "nosniff";
624
- headers["content-length"] = String(stat.size);
625
- if (state.immutable && headers["cache-control"] === undefined) {
626
- headers["cache-control"] = "public, max-age=31536000, immutable";
737
+ headers["accept-ranges"] = "bytes";
738
+ // Range applies to GET only (RFC 9110 section 14.2); HEAD reports the whole representation.
739
+ const range = method === "GET" ? staticByteRange(requestHeaders, stat.size, etag, lastModified) : undefined;
740
+ if (range === "unsatisfiable") {
741
+ await handle.close();
742
+ headers["content-range"] = `bytes */${stat.size}`;
743
+ delete headers["content-length"];
744
+ delete headers["content-type"];
745
+ return { kind: "response", response: new Response(null, { status: 416, headers }) };
746
+ }
747
+ let status = 200;
748
+ if (range !== undefined) {
749
+ status = 206;
750
+ headers["content-range"] = `bytes ${range[0]}-${range[1]}/${stat.size}`;
751
+ headers["content-length"] = String(range[1] - range[0] + 1);
752
+ }
753
+ else {
754
+ headers["content-length"] = String(stat.size);
627
755
  }
628
756
  if (method === "HEAD") {
629
757
  await handle.close();
630
758
  return { kind: "response", response: new Response(null, { headers }) };
631
759
  }
632
- const stream = handle.createReadStream();
760
+ const stream = range === undefined
761
+ ? handle.createReadStream()
762
+ : handle.createReadStream({ start: range[0], end: range[1] });
633
763
  return {
634
764
  kind: "response",
635
765
  // Claimable rather than `Readable.toWeb`: served straight from disk to the socket when this
636
766
  // response reaches the writer untouched, and read as an ordinary Web stream by anything that
637
767
  // gets to it first (a middleware that rewrites or compresses the body, say), which refuses
638
768
  // the claim and takes the conversion instead.
639
- response: new Response(claimableWebStream(stream), { headers }),
769
+ response: new Response(claimableWebStream(stream), { status, headers }),
640
770
  };
641
771
  }
642
772
  catch {
@@ -684,6 +814,26 @@ function activateAsyncContextFrame() {
684
814
  asyncContextFrameActivated = true;
685
815
  new AsyncLocalStorage();
686
816
  }
817
+ const ACTIVE_RESPONSE_STATE = Symbol("nifra.node.active-response-state");
818
+ const ACTIVE_RESPONSE_RELEASED = Symbol("nifra.node.active-response-released");
819
+ const ACTIVE_SOCKET_STATE = Symbol("nifra.node.active-socket-state");
820
+ /** Shared finish/close listener - response state carries the per-response release bit. */
821
+ function releaseActiveResponse() {
822
+ const response = this;
823
+ const state = response[ACTIVE_RESPONSE_STATE];
824
+ if (state === undefined || response[ACTIVE_RESPONSE_RELEASED])
825
+ return;
826
+ response[ACTIVE_RESPONSE_RELEASED] = true;
827
+ state.active -= 1;
828
+ if (state.parserError && state.active === 0 && !state.socket.destroyed) {
829
+ state.socket.destroy();
830
+ }
831
+ }
832
+ function clearActiveResponseState() {
833
+ const state = this[ACTIVE_SOCKET_STATE];
834
+ if (state !== undefined)
835
+ state.active = 0;
836
+ }
687
837
  export function serve(app, options) {
688
838
  activateAsyncContextFrame();
689
839
  enableNodeDirect(app);
@@ -691,33 +841,29 @@ export function serve(app, options) {
691
841
  installFastResponse();
692
842
  let inFlight = 0;
693
843
  let closed = false;
694
- const protocol = protocolResolver(options.protocol);
844
+ // Upgrade sockets an HTTP route answers instead: Node's server no longer tracks them, so stop()
845
+ // drains and closes them itself.
846
+ const answering = new Set();
847
+ const protocol = protocolResolver(options.protocol ?? (options.tls === undefined ? undefined : "https"));
695
848
  const hostPolicy = hostPolicyOf(options);
696
849
  const staticState = options.static !== undefined ? staticStateOf(options.static) : undefined;
697
850
  // Node otherwise destroys a socket as soon as its HTTP parser emits `clientError`. That can
698
851
  // discard the response for an already-dispatched request when an understated Content-Length
699
852
  // leaves surplus bytes that look like a malformed pipelined request. Keep the parser error
700
853
  // connection-scoped and close only after active responses finish, preserving response ordering.
701
- const activeResponses = new WeakMap();
702
- const parserErrorSockets = new WeakSet();
703
- const server = createServer((nodeReq, nodeRes) => {
854
+ const onRequest = (nodeReq, nodeRes) => {
704
855
  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);
856
+ let responseState = socket[ACTIVE_SOCKET_STATE];
857
+ if (responseState === undefined) {
858
+ responseState = { socket, active: 0, parserError: false };
859
+ socket[ACTIVE_SOCKET_STATE] = responseState;
860
+ socket.once("close", clearActiveResponseState);
861
+ }
862
+ responseState.active += 1;
863
+ nodeRes[ACTIVE_RESPONSE_STATE] = responseState;
864
+ nodeRes[ACTIVE_RESPONSE_RELEASED] = false;
865
+ nodeRes.once("finish", releaseActiveResponse);
866
+ nodeRes.once("close", releaseActiveResponse);
721
867
  inFlight += 1;
722
868
  try {
723
869
  const handled = handle(app, nodeReq, nodeRes, protocol, staticState, hostPolicy);
@@ -739,27 +885,22 @@ export function serve(app, options) {
739
885
  failWrite(nodeRes);
740
886
  inFlight -= 1;
741
887
  }
742
- });
888
+ };
889
+ const resolveWs = app.resolveWebSocketUpgrade?.bind(app);
890
+ // Only a WebSocket handshake leaves the HTTP parser; any other `Upgrade` (curl's h2c offer) is an
891
+ // ordinary request. Node versions without the option send every upgrade to the `upgrade` event.
892
+ const upgradeOptions = resolveWs === undefined ? {} : { shouldUpgradeCallback: isWebSocketUpgrade };
893
+ const server = options.tls === undefined
894
+ ? createServer(upgradeOptions, onRequest)
895
+ : createHttpsServer({ ...options.tls, ...upgradeOptions }, onRequest);
743
896
  server.on("clientError", (_error, socket) => {
744
897
  if (socket.destroyed)
745
898
  return;
746
- const responses = activeResponses.get(socket);
747
- if (responses !== undefined && responses.size > 0) {
748
- if (parserErrorSockets.has(socket))
899
+ const responseState = socket[ACTIVE_SOCKET_STATE];
900
+ if (responseState !== undefined && responseState.active > 0) {
901
+ if (responseState.parserError)
749
902
  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
- }
903
+ responseState.parserError = true;
763
904
  return;
764
905
  }
765
906
  const body = "Bad Request";
@@ -768,11 +909,10 @@ export function serve(app, options) {
768
909
  // WebSocket upgrades (a nifra app exposing the seam): handled on the http server's `upgrade` event via
769
910
  // the optional `ws` package - lazy-imported (and the server lazily built) on the FIRST real WS
770
911
  // upgrade, so a non-WS Node app never loads `ws`.
771
- const resolveWs = app.resolveWebSocketUpgrade?.bind(app);
772
912
  if (resolveWs !== undefined) {
773
913
  let wssPromise;
774
914
  server.on("upgrade", (nodeReq, socket, head) => {
775
- void handleUpgrade(resolveWs, protocol, hostPolicy, nodeReq, socket, head, (maxPayloadBytes) => {
915
+ void handleUpgrade(app, answering, resolveWs, protocol, hostPolicy, nodeReq, socket, head, (maxPayloadBytes) => {
776
916
  wssPromise ??= loadWsServer(maxPayloadBytes);
777
917
  return wssPromise;
778
918
  });
@@ -794,10 +934,12 @@ export function serve(app, options) {
794
934
  }
795
935
  server.close(); // stop accepting new connections; existing requests continue
796
936
  const deadline = Date.now() + drainMs;
797
- while (inFlight > 0 && Date.now() < deadline) {
937
+ while ((inFlight > 0 || answering.size > 0) && Date.now() < deadline) {
798
938
  await new Promise((r) => setTimeout(r, DRAIN_POLL_MS));
799
939
  }
800
940
  server.closeAllConnections(); // force-close stragglers + idle keep-alive sockets
941
+ for (const socket of answering)
942
+ socket.destroy();
801
943
  }
802
944
  return new Promise((resolve) => {
803
945
  // Do not pass an explicit `undefined` hostname. Node accepts it, but Bun's Node-compatible
@@ -835,7 +977,7 @@ function runNodeSource(app, source, nodeReq, nodeRes, protocol, host, platform)
835
977
  return;
836
978
  }
837
979
  }
838
- const request = toWebRequest(nodeReq, protocol, host);
980
+ const request = toWebRequest(nodeReq, protocol, host, nodeRes);
839
981
  const resolveNode = app.resolveNode;
840
982
  if (typeof resolveNode === "function") {
841
983
  try {
@@ -861,7 +1003,33 @@ function runNodeSource(app, source, nodeReq, nodeRes, protocol, host, platform)
861
1003
  return;
862
1004
  }
863
1005
  }
1006
+ /**
1007
+ * Resolve the target's `.` / `..` segments and backslashes in place, before anything reads it. Node
1008
+ * keeps the target as the client sent it, where Bun and workerd deliver it parsed; resolving it here
1009
+ * routes `/users/../admin` to `/admin` on every runtime, and every later reader - static files,
1010
+ * mounts, the app, `c.req.url` - sees the one path.
1011
+ */
1012
+ const ABSOLUTE_FORM = /^[a-z][a-z\d+.-]*:\/\//i;
1013
+ function resolveTarget(nodeReq) {
1014
+ let target = nodeReq.url;
1015
+ if (target === undefined)
1016
+ return;
1017
+ // Absolute-form (`GET http://host/path`, RFC 9112 section 3.2.2) is routed as its origin-form path
1018
+ // with the Host header, as Bun does. Kept whole, hooks saw `http://hosthttp://...` while the
1019
+ // router matched the full URL against a `/*` route.
1020
+ if (target.charCodeAt(0) !== 47 && ABSOLUTE_FORM.test(target)) {
1021
+ let end = target.indexOf("//") + 2;
1022
+ while (end < target.length && target[end] !== "/" && target[end] !== "?")
1023
+ end++;
1024
+ const rest = target.slice(end);
1025
+ target = rest.charCodeAt(0) === 47 ? rest : `/${rest}`;
1026
+ nodeReq.url = target;
1027
+ }
1028
+ if (hasDotSegment(target))
1029
+ nodeReq.url = resolveDotSegments(target);
1030
+ }
864
1031
  function handle(app, nodeReq, nodeRes, getProtocol, staticState, hostPolicy) {
1032
+ resolveTarget(nodeReq);
865
1033
  const host = requestHost(nodeReq, hostPolicy);
866
1034
  if (host === undefined) {
867
1035
  writeBadRequest(nodeRes);
@@ -895,7 +1063,7 @@ function handle(app, nodeReq, nodeRes, getProtocol, staticState, hostPolicy) {
895
1063
  if (matched !== "pass") {
896
1064
  if ("reject" in matched)
897
1065
  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));
1066
+ return readStatic(matched.file, staticState, nodeReq.method ?? "GET", nodeReq.headers).then((outcome) => writeOutcomeSafely(outcome, nodeRes, nodeReq.method), () => failWrite(nodeRes));
899
1067
  }
900
1068
  }
901
1069
  // A mounted handler may advertise a native Node lane. Core has already checked route precedence
@@ -905,7 +1073,7 @@ function handle(app, nodeReq, nodeRes, getProtocol, staticState, hostPolicy) {
905
1073
  const resolveNodeSource = app.resolveNodeSource;
906
1074
  const resolveNodeMount = app[RESOLVE_NODE_MOUNT];
907
1075
  if (typeof resolveNodeSource === "function" || typeof resolveNodeMount === "function") {
908
- const nodeSource = toNodeRequestSource(nodeReq, protocol, host);
1076
+ const nodeSource = toNodeRequestSource(nodeReq, protocol, host, nodeRes);
909
1077
  if (typeof resolveNodeMount === "function") {
910
1078
  let selection;
911
1079
  try {
@@ -942,7 +1110,7 @@ function handle(app, nodeReq, nodeRes, getProtocol, staticState, hostPolicy) {
942
1110
  }
943
1111
  return runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform);
944
1112
  }
945
- const request = toWebRequest(nodeReq, protocol, host);
1113
+ const request = toWebRequest(nodeReq, protocol, host, nodeRes);
946
1114
  const resolveNode = app.resolveNode;
947
1115
  if (typeof resolveNode === "function") {
948
1116
  try {
@@ -1053,6 +1221,60 @@ function allHeaderKeysLowercase(record) {
1053
1221
  }
1054
1222
  return true;
1055
1223
  }
1224
+ /**
1225
+ * Store one wire header on a literal record. A literal `__proto__` name must not go through plain
1226
+ * assignment - the inherited setter would silently swallow it - so it is defined as an own data
1227
+ * property instead; every other name takes the fast plain store. Same guard core's portable header
1228
+ * view makes over the same record, so a hook-set `__proto__` header round-trips to the wire
1229
+ * exactly as it did through the old normalization copy.
1230
+ */
1231
+ function storeWireHeader(headers, name, value) {
1232
+ if (name === "__proto__") {
1233
+ Object.defineProperty(headers, name, {
1234
+ value,
1235
+ writable: true,
1236
+ enumerable: true,
1237
+ configurable: true,
1238
+ });
1239
+ return;
1240
+ }
1241
+ headers[name] = value;
1242
+ }
1243
+ /** True when an existing header slot can be replaced without throwing or invoking an accessor. */
1244
+ function canReplaceHeaderSlot(record, name) {
1245
+ const descriptor = Object.getOwnPropertyDescriptor(record, name);
1246
+ return descriptor === undefined || ("writable" in descriptor && descriptor.writable === true);
1247
+ }
1248
+ /**
1249
+ * A proven lowercase record can stay on the Node hot path when the writer only has to append its
1250
+ * own framing headers. Non-default framing names and explicit lengths take the copy path: the old
1251
+ * writer moved those names to the end of the record, and retaining that order keeps raw HTTP/1.1
1252
+ * snapshots stable. Core's default JSON type is the one exception - it appends that value before
1253
+ * this writer runs, so recognizing it lets the common hooked route retain the record and append
1254
+ * length in place. An application that explicitly supplies the same default value is indistinguishable
1255
+ * here and follows that same semantics-preserving fast path.
1256
+ */
1257
+ function canWriteJsonHeadersInPlace(source, cookies) {
1258
+ try {
1259
+ if (!Object.isExtensible(source))
1260
+ return false;
1261
+ if (Object.hasOwn(source, "content-length"))
1262
+ return false;
1263
+ if (Object.hasOwn(source, "content-type") && source["content-type"] !== JSON_CONTENT_TYPE) {
1264
+ return false;
1265
+ }
1266
+ if (cookies !== undefined &&
1267
+ cookies.length > 0 &&
1268
+ !canReplaceHeaderSlot(source, "set-cookie")) {
1269
+ return false;
1270
+ }
1271
+ return true;
1272
+ }
1273
+ catch {
1274
+ // A proxy or a hostile descriptor should use the same isolated fallback as mixed-case input.
1275
+ return false;
1276
+ }
1277
+ }
1056
1278
  /**
1057
1279
  * Serialize a node-direct JSON outcome straight to the socket - no undici `Response`, no stream drain.
1058
1280
  * Mirrors `Response.json(data, { status, headers })` byte-for-byte: user headers are lowercased to
@@ -1062,31 +1284,34 @@ function allHeaderKeysLowercase(record) {
1062
1284
  function writeJsonOutcome(outcome, nodeRes, isHead = false) {
1063
1285
  // The outcome's record is the request's own (`c.set.headers`, already mutated by any native
1064
1286
  // response hooks), and its writers - middleware twins and the framework's own additions - emit
1065
- // lowercase names. Confirmed either by core's mark (the native response walk already looked at
1066
- // these keys, so re-walking them here is the same pass twice) or, when the record never went
1067
- // through that walk, by the scan below. Either way the record is then used as-is and the additions
1068
- // below mutate it in place; nothing reads it after the write. Only a mixed-case key (a user's
1069
- // hand-set `X-Foo`) pays the normalization copy, keeping the wire byte-identical to undici's
1070
- // `Headers` lowercasing on every other runtime.
1071
- let headers;
1287
+ // lowercase names. Whether a name needs folding is answered either by core's mark (the native
1288
+ // response walk already looked at these keys, so re-walking them here is the same pass twice)
1289
+ // or, when the record never went through that walk, by the scan below.
1290
+ //
1291
+ // A proven lowercase, extensible record is owned by this request. Keep it in place and append
1292
+ // lowercase framing names directly; the old uppercase rename paid two deletes and demoted the
1293
+ // record to dictionary mode. Mixed-case, frozen, and explicitly framed records take an isolated
1294
+ // copy below, preserving the old source order and protecting caller-owned objects.
1072
1295
  const source = outcome.headers;
1073
- if (source === undefined) {
1074
- // A route that set no headers gets a record the FRAMEWORK alone fills - content-type,
1075
- // content-length, set-cookie - so no attacker-influenced name can reach it and the
1076
- // null-prototype guard the normalization branch needs buys nothing here. It does cost: a
1077
- // null-prototype object never enters V8's fast property mode, and Node's `_storeHeader` walks
1078
- // this record key by key on every response (measured at over twice fastify's share of the same
1079
- // frame on a bare route). A literal keeps it in fast mode.
1080
- headers = {};
1081
- }
1082
- else if (source[LOWERCASE_HEADER_KEYS] === true ||
1083
- allHeaderKeysLowercase(source)) {
1084
- headers = source;
1085
- }
1086
- else {
1087
- headers = Object.create(null);
1088
- for (const [key, value] of Object.entries(source)) {
1089
- headers[key.toLowerCase()] = value;
1296
+ const lowercase = source === undefined ||
1297
+ source[LOWERCASE_HEADER_KEYS] === true ||
1298
+ allHeaderKeysLowercase(source);
1299
+ const inPlace = source === undefined || (lowercase && canWriteJsonHeadersInPlace(source, outcome.cookies));
1300
+ const headers = inPlace && source !== undefined ? source : {};
1301
+ let contentType;
1302
+ let hasLength = false;
1303
+ if (!inPlace && source !== undefined) {
1304
+ for (const key of Object.keys(source)) {
1305
+ const lower = lowercase ? key : key.toLowerCase();
1306
+ if (lower === "content-type") {
1307
+ contentType = source[key];
1308
+ continue;
1309
+ }
1310
+ if (lower === "content-length") {
1311
+ hasLength = true;
1312
+ continue;
1313
+ }
1314
+ storeWireHeader(headers, lower, source[key]);
1090
1315
  }
1091
1316
  }
1092
1317
  // A `null` body is a 204/no-content render - `new Response(null)` carries no Content-Type, so we add
@@ -1095,28 +1320,27 @@ function writeJsonOutcome(outcome, nodeRes, isHead = false) {
1095
1320
  // without it Node falls back to chunked framing, which costs extra wire bytes and client parsing
1096
1321
  // on every response (and no other runtime chunks a buffered JSON body).
1097
1322
  if (outcome.body !== null) {
1098
- if (headers["content-type"] === undefined)
1099
- headers["Content-Type"] = JSON_CONTENT_TYPE;
1323
+ if (inPlace) {
1324
+ if (!Object.hasOwn(headers, "content-type"))
1325
+ headers["content-type"] = JSON_CONTENT_TYPE;
1326
+ headers["content-length"] = String(Buffer.byteLength(outcome.body));
1327
+ }
1100
1328
  else {
1101
- headers["Content-Type"] = headers["content-type"];
1102
- delete headers["content-type"];
1329
+ storeWireHeader(headers, "content-type", contentType ?? JSON_CONTENT_TYPE);
1330
+ storeWireHeader(headers, "content-length", String(Buffer.byteLength(outcome.body)));
1103
1331
  }
1104
- headers["Content-Length"] = String(Buffer.byteLength(outcome.body));
1105
- delete headers["content-length"];
1106
1332
  }
1107
1333
  else if (isBodylessStatus(outcome.status)) {
1108
- // 204/205/304 never carry a payload; discard a user/native-hook length even when the body is
1109
- // already represented as null so the direct writer cannot advertise bytes that will not ship.
1110
- delete headers["content-length"];
1111
- delete headers["Content-Length"];
1334
+ // 204/205/304 never carry a payload; a user/native-hook length is dropped rather than copied,
1335
+ // so the direct writer cannot advertise bytes that will not ship.
1112
1336
  }
1113
- else if (!isHead && headers["content-length"] === undefined) {
1337
+ else if (!isHead && !hasLength) {
1114
1338
  // A body-less render at a status that MAY carry a body - a `redirect()`, above all. Node frames
1115
1339
  // a `writeHead` + bare `end()` as chunked, so the shortest response the framework emits went out
1116
1340
  // with a chunk terminator and no length, where every Web-native runtime sends `content-length: 0`.
1117
1341
  // Declared here so the wire matches them. HEAD is excluded: its length describes the GET's body,
1118
1342
  // which this lane does not know.
1119
- headers["Content-Length"] = "0";
1343
+ headers["content-length"] = "0";
1120
1344
  }
1121
1345
  if (outcome.cookies !== undefined && outcome.cookies.length > 0) {
1122
1346
  headers["set-cookie"] = [...outcome.cookies];
@@ -1184,16 +1408,16 @@ function normalizeProtocol(value) {
1184
1408
  return value;
1185
1409
  throw new Error(`@nifrajs/node: protocol must be "http" or "https" (got ${JSON.stringify(value)})`);
1186
1410
  }
1187
- function toWebRequest(req, protocol, host) {
1411
+ function toWebRequest(req, protocol, host, res) {
1188
1412
  const url = `${protocol}://${host}${req.url ?? "/"}`;
1189
1413
  const method = req.method ?? "GET";
1190
- return makeWebRequest(req, method, url, headerRecordFromNode(req.headers));
1414
+ return makeWebRequest(req, method, url, headerRecordFromNode(req.headers), undefined, res);
1191
1415
  }
1192
- function toNodeRequestSource(req, protocol, host) {
1416
+ function toNodeRequestSource(req, protocol, host, res) {
1193
1417
  const method = req.method ?? "GET";
1194
1418
  return method === "GET" || method === "HEAD"
1195
- ? new LeanNodeGetSource(req, method, protocol, host)
1196
- : new LazyNodeRequestSource(req, method, protocol, host);
1419
+ ? new LeanNodeGetSource(req, method, protocol, host, res)
1420
+ : new LazyNodeRequestSource(req, method, protocol, host, res);
1197
1421
  }
1198
1422
  function stripNodeMountPrefix(url, prefix) {
1199
1423
  if (prefix === "/")
@@ -1269,11 +1493,13 @@ class LazyNodeRequestSource {
1269
1493
  nodeReq;
1270
1494
  protocol;
1271
1495
  host;
1272
- constructor(nodeReq, method, protocol, host) {
1496
+ nodeRes;
1497
+ constructor(nodeReq, method, protocol, host, nodeRes) {
1273
1498
  this.nodeReq = nodeReq;
1274
1499
  this.method = method;
1275
1500
  this.protocol = protocol;
1276
1501
  this.host = host;
1502
+ this.nodeRes = nodeRes;
1277
1503
  }
1278
1504
  /** The absolute URL, built only when something reads it - routing uses `urlParts` instead. */
1279
1505
  get url() {
@@ -1353,12 +1579,24 @@ class LazyNodeRequestSource {
1353
1579
  rawBodyBytes() {
1354
1580
  if (this.consumedBody !== undefined)
1355
1581
  return Promise.resolve(this.consumedBody);
1356
- const handed = this.bodyValue;
1582
+ const handed = this.handedBody();
1357
1583
  if (handed != null) {
1358
1584
  return new Response(handed).arrayBuffer().then((buffer) => new Uint8Array(buffer));
1359
1585
  }
1360
1586
  return this.readNodeBody();
1361
1587
  }
1588
+ /**
1589
+ * The stream the Web `Request` was built on, or - once a hook's `req.clone()` teed it (which locks
1590
+ * the original) - the branch that request kept. Reading the locked original threw, so a middleware
1591
+ * that peeked at the body through a clone turned every downstream body read into a 500.
1592
+ */
1593
+ handedBody() {
1594
+ const handed = this.bodyValue;
1595
+ if (handed == null || !handed.locked)
1596
+ return handed ?? undefined;
1597
+ const owner = this.requestValue;
1598
+ return owner?._real?.body ?? handed;
1599
+ }
1362
1600
  /**
1363
1601
  * The raw body stream, never routed back through `this.request` - post-cap that getter is the
1364
1602
  * shadowed one, which reads back through these very readers. An already-consumed body replays
@@ -1371,18 +1609,18 @@ class LazyNodeRequestSource {
1371
1609
  if (consumed !== undefined)
1372
1610
  return streamOfBytes(consumed);
1373
1611
  this.bodyValue ??= claimableWebStream(this.nodeReq, "drain");
1374
- return this.bodyValue;
1612
+ return this.handedBody() ?? this.bodyValue;
1375
1613
  }
1376
1614
  get request() {
1377
1615
  this.requestValue ??= new LazyWebRequest(this.method, this.url, () => this.headersValue ?? headerRecordFromNode(this.nodeReq.headers), (headers) => {
1378
1616
  if (this.consumedBody !== undefined) {
1379
- const real = makeWebRequest(this.nodeReq, this.method, this.url, headers, this.consumedBody);
1617
+ const real = makeWebRequest(this.nodeReq, this.method, this.url, headers, this.consumedBody, this.nodeRes);
1380
1618
  // Preserve one-shot body semantics if user code asks for `c.req` after nifra already
1381
1619
  // consumed it.
1382
1620
  void real.arrayBuffer().catch(() => { });
1383
1621
  return real;
1384
1622
  }
1385
- return makeWebRequest(this.nodeReq, this.method, this.url, headers, this.body);
1623
+ return makeWebRequest(this.nodeReq, this.method, this.url, headers, this.body, this.nodeRes);
1386
1624
  });
1387
1625
  return this.requestValue;
1388
1626
  }
@@ -1452,11 +1690,13 @@ class LeanNodeGetSource {
1452
1690
  nodeReq;
1453
1691
  protocol;
1454
1692
  host;
1455
- constructor(nodeReq, method, protocol, host) {
1693
+ nodeRes;
1694
+ constructor(nodeReq, method, protocol, host, nodeRes) {
1456
1695
  this.nodeReq = nodeReq;
1457
1696
  this.method = method;
1458
1697
  this.protocol = protocol;
1459
1698
  this.host = host;
1699
+ this.nodeRes = nodeRes;
1460
1700
  }
1461
1701
  /** The absolute URL, built only when something reads it - routing uses `urlParts` instead. */
1462
1702
  get url() {
@@ -1486,7 +1726,7 @@ class LeanNodeGetSource {
1486
1726
  return Promise.reject(new SyntaxError("Unexpected end of JSON input"));
1487
1727
  }
1488
1728
  get request() {
1489
- this.requestValue ??= new LazyWebRequest(this.method, this.url, () => this.headersValue ?? headerRecordFromNode(this.nodeReq.headers), (headers) => makeWebRequest(this.nodeReq, this.method, this.url, headers, null));
1729
+ this.requestValue ??= new LazyWebRequest(this.method, this.url, () => this.headersValue ?? headerRecordFromNode(this.nodeReq.headers), (headers) => makeWebRequest(this.nodeReq, this.method, this.url, headers, null, this.nodeRes));
1490
1730
  return this.requestValue;
1491
1731
  }
1492
1732
  }
@@ -1509,11 +1749,25 @@ function headerRecordFromNode(input) {
1509
1749
  }
1510
1750
  return record;
1511
1751
  }
1512
- function makeWebRequest(req, method, url, headers, body) {
1752
+ function makeWebRequest(req, method, url, headers, body, res) {
1513
1753
  const init = { method, headers };
1754
+ if (res !== undefined) {
1755
+ // `signal` aborts when the client goes away before the response finished, as on Bun and Deno.
1756
+ const disconnect = new AbortController();
1757
+ init.signal = disconnect.signal;
1758
+ const onClose = () => {
1759
+ if (!res.writableFinished)
1760
+ disconnect.abort();
1761
+ };
1762
+ if (res.closed)
1763
+ onClose();
1764
+ else
1765
+ res.once("close", onClose);
1766
+ }
1514
1767
  if (method !== "GET" && method !== "HEAD") {
1515
1768
  // Stream the body in; `duplex: "half"` is required for a streamed request body.
1516
- init.body = body ?? claimableWebStream(req, "drain");
1769
+ init.body =
1770
+ body instanceof Uint8Array ? body.slice() : (body ?? claimableWebStream(req, "drain"));
1517
1771
  init.duplex = "half";
1518
1772
  }
1519
1773
  return new Request(url, init);
@@ -1653,6 +1907,9 @@ function writeNodeResponse(response, nodeRes, method) {
1653
1907
  if (isHead) {
1654
1908
  nodeRes.writeHead(response.status);
1655
1909
  nodeRes.end();
1910
+ // Nothing reads a HEAD answer's body. Cancelled, its source learns the exchange is over, as on
1911
+ // Bun and Deno; left alone, a streaming route's producer runs on with no reader.
1912
+ response.body?.cancel().catch(() => { });
1656
1913
  return;
1657
1914
  }
1658
1915
  const directBody = nodeResponseBody(response);
@@ -1749,6 +2006,22 @@ function pipeNodeResponseBody(body, nodeRes) {
1749
2006
  */
1750
2007
  async function writeNodeResponseBody(response, nodeRes, canDeclareLength) {
1751
2008
  const reader = response.body.getReader();
2009
+ // A client that leaves while the stream is idle never fails a write. Its departure cancels the
2010
+ // stream, as on Bun and Deno, so a producer waiting to send learns the exchange is over.
2011
+ const onClose = () => {
2012
+ reader.cancel().catch(() => { });
2013
+ };
2014
+ nodeRes.once("close", onClose);
2015
+ if (nodeRes.destroyed)
2016
+ onClose();
2017
+ try {
2018
+ await writeNodeResponseChunks(reader, response, nodeRes, canDeclareLength);
2019
+ }
2020
+ finally {
2021
+ nodeRes.removeListener("close", onClose);
2022
+ }
2023
+ }
2024
+ async function writeNodeResponseChunks(reader, response, nodeRes, canDeclareLength) {
1752
2025
  let first;
1753
2026
  try {
1754
2027
  first = await reader.read();
@@ -1871,8 +2144,13 @@ const WS_STATUS_TEXT = {
1871
2144
  };
1872
2145
  /** Resolve a Node `upgrade` event: run the nifra upgrade guard, then either reject (write an HTTP error
1873
2146
  * to the raw socket) or perform the `ws` upgrade and wire the socket to the handler. */
1874
- async function handleUpgrade(resolveWs, getProtocol, hostPolicy, nodeReq, socket, head, getWss) {
2147
+ async function handleUpgrade(app, answering, resolveWs, getProtocol, hostPolicy, nodeReq, socket, head, getWss) {
2148
+ // A peer can reset the socket while the guard is awaited; with no listener that error ends the process.
2149
+ socket.on("error", ignoreSocketError);
2150
+ resolveTarget(nodeReq);
1875
2151
  let outcome;
2152
+ let request;
2153
+ let platform;
1876
2154
  try {
1877
2155
  const host = requestHost(nodeReq, hostPolicy);
1878
2156
  if (host === undefined) {
@@ -1880,15 +2158,38 @@ async function handleUpgrade(resolveWs, getProtocol, hostPolicy, nodeReq, socket
1880
2158
  return;
1881
2159
  }
1882
2160
  const peerAddress = nodeReq.socket.remoteAddress;
1883
- const platform = peerAddress === undefined ? undefined : { clientIp: peerAddress };
1884
- outcome = await resolveWs(toWebRequest(nodeReq, getProtocol(nodeReq), host), platform);
2161
+ platform = peerAddress === undefined ? undefined : { clientIp: peerAddress };
2162
+ // Its signal follows the socket, so a route answered below stops when the client leaves.
2163
+ request = toWebRequest(nodeReq, getProtocol(nodeReq), host, socket);
2164
+ outcome = await resolveWs(request, platform);
1885
2165
  }
1886
2166
  catch {
1887
2167
  writeUpgradeRejection(socket, 500, "internal_error");
1888
2168
  return;
1889
2169
  }
1890
2170
  if (outcome.kind === "pass") {
1891
- writeUpgradeRejection(socket, 404, "not_found"); // upgrade to a path with no WS route
2171
+ // No WS route here: the HTTP route answers, as on Bun. Only a bodiless request can be served from
2172
+ // an upgrade socket - Node parses no body for one, so a POST here (Node without
2173
+ // `shouldUpgradeCallback`) keeps the 404.
2174
+ if (nodeReq.method !== "GET" && nodeReq.method !== "HEAD") {
2175
+ writeUpgradeRejection(socket, 404, "not_found");
2176
+ return;
2177
+ }
2178
+ let response;
2179
+ try {
2180
+ response = await app.fetch(request, platform);
2181
+ }
2182
+ catch {
2183
+ writeUpgradeRejection(socket, 500, "internal_error");
2184
+ return;
2185
+ }
2186
+ answering.add(socket);
2187
+ socket.once("close", () => answering.delete(socket));
2188
+ // Detached from Node's HTTP handling, the socket comes paused and nothing ends it on the client's
2189
+ // FIN. Reading it and ending on `end`, as Node's server does, lets a departure reach the signal.
2190
+ socket.once("end", () => socket.end());
2191
+ socket.resume();
2192
+ await writeSocketResponse(socket, response, nodeReq.method);
1892
2193
  return;
1893
2194
  }
1894
2195
  if (outcome.kind === "reject") {
@@ -1953,6 +2254,11 @@ function attachNodeWebSocket(ws, handler, data, pubsub) {
1953
2254
  ws.on("error", (error) => reportError(error));
1954
2255
  safe(() => handler.open?.(nifra)); // open: the socket is already established here
1955
2256
  }
2257
+ function ignoreSocketError() { }
2258
+ function isWebSocketUpgrade(nodeReq) {
2259
+ // A handshake is a GET (RFC 6455 section 4.1); any other method is an ordinary request with a body.
2260
+ return nodeReq.method === "GET" && /\bwebsocket\b/i.test(nodeReq.headers.upgrade ?? "");
2261
+ }
1956
2262
  /** Write a minimal JSON error response to a raw upgrade socket, then close it. */
1957
2263
  function writeUpgradeRejection(socket, status, error) {
1958
2264
  const body = JSON.stringify({ ok: false, error });
@@ -1974,4 +2280,37 @@ async function writeRejectionResponse(socket, response) {
1974
2280
  socket.write(head + body);
1975
2281
  socket.destroy();
1976
2282
  }
2283
+ /** Answer an upgrade socket with an ordinary response: the body streams as produced, paced by the
2284
+ * socket's backpressure and delimited by the close (RFC 9112 section 6.3), never buffered whole. */
2285
+ async function writeSocketResponse(socket, response, method) {
2286
+ let head = `HTTP/1.1 ${response.status} ${response.statusText || STATUS_CODES[response.status] || "Unknown"}\r\n`;
2287
+ response.headers.forEach((value, key) => {
2288
+ if (key !== "connection" && key !== "keep-alive" && key !== "transfer-encoding") {
2289
+ head += `${key}: ${value}\r\n`;
2290
+ }
2291
+ });
2292
+ socket.write(`${head}Connection: close\r\n\r\n`);
2293
+ if (response.body === null || method === "HEAD") {
2294
+ await response.body?.cancel().catch(() => { });
2295
+ socket.end();
2296
+ return;
2297
+ }
2298
+ const reader = response.body.getReader();
2299
+ try {
2300
+ for (;;) {
2301
+ const chunk = await reader.read();
2302
+ if (chunk.done)
2303
+ break;
2304
+ if (!socket.write(chunk.value) && !(await waitForDrain(socket))) {
2305
+ await reader.cancel();
2306
+ return;
2307
+ }
2308
+ }
2309
+ socket.end();
2310
+ }
2311
+ catch {
2312
+ socket.destroy();
2313
+ await reader.cancel().catch(() => { });
2314
+ }
2315
+ }
1977
2316
  //# sourceMappingURL=index.js.map