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