@nifrajs/core 5.0.0 → 5.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.
Files changed (123) hide show
  1. package/CHANGELOG.md +2384 -0
  2. package/dist/assurance.d.ts +15 -1
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +97 -71
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +2 -0
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +1 -1
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/edge-kit.d.ts +1 -1
  11. package/dist/edge-kit.d.ts.map +1 -1
  12. package/dist/edge-kit.js.map +1 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/hook-origins.d.ts +17 -1
  18. package/dist/internal/hook-origins.d.ts.map +1 -1
  19. package/dist/internal/hook-origins.js +20 -0
  20. package/dist/internal/hook-origins.js.map +1 -1
  21. package/dist/internal/route-compiler.d.ts +9 -0
  22. package/dist/internal/route-compiler.d.ts.map +1 -1
  23. package/dist/internal/route-compiler.js +95 -13
  24. package/dist/internal/route-compiler.js.map +1 -1
  25. package/dist/internal/route-program.d.ts +2 -0
  26. package/dist/internal/route-program.d.ts.map +1 -1
  27. package/dist/internal/route-program.js +22 -17
  28. package/dist/internal/route-program.js.map +1 -1
  29. package/dist/internal/same-origin.d.ts +11 -1
  30. package/dist/internal/same-origin.d.ts.map +1 -1
  31. package/dist/internal/same-origin.js +8 -0
  32. package/dist/internal/same-origin.js.map +1 -1
  33. package/dist/reflection.d.ts +23 -2
  34. package/dist/reflection.d.ts.map +1 -1
  35. package/dist/reflection.js +57 -1
  36. package/dist/reflection.js.map +1 -1
  37. package/dist/server/body-lane.d.ts +8 -2
  38. package/dist/server/body-lane.d.ts.map +1 -1
  39. package/dist/server/body-lane.js +27 -2
  40. package/dist/server/body-lane.js.map +1 -1
  41. package/dist/server/client-ip.js +6 -5
  42. package/dist/server/client-ip.js.map +1 -1
  43. package/dist/server/context.d.ts +7 -4
  44. package/dist/server/context.d.ts.map +1 -1
  45. package/dist/server/cookies.d.ts.map +1 -1
  46. package/dist/server/cookies.js +13 -1
  47. package/dist/server/cookies.js.map +1 -1
  48. package/dist/server/core-copies.d.ts +7 -1
  49. package/dist/server/core-copies.d.ts.map +1 -1
  50. package/dist/server/core-copies.js +37 -1
  51. package/dist/server/core-copies.js.map +1 -1
  52. package/dist/server/edge.d.ts.map +1 -1
  53. package/dist/server/edge.js +2 -1
  54. package/dist/server/edge.js.map +1 -1
  55. package/dist/server/headers.d.ts +9 -0
  56. package/dist/server/headers.d.ts.map +1 -1
  57. package/dist/server/headers.js +26 -0
  58. package/dist/server/headers.js.map +1 -1
  59. package/dist/server/http.d.ts.map +1 -1
  60. package/dist/server/http.js +5 -1
  61. package/dist/server/http.js.map +1 -1
  62. package/dist/server/response-contract-lane.d.ts +57 -7
  63. package/dist/server/response-contract-lane.d.ts.map +1 -1
  64. package/dist/server/response-contract-lane.js +394 -23
  65. package/dist/server/response-contract-lane.js.map +1 -1
  66. package/dist/server/server-types.d.ts +23 -9
  67. package/dist/server/server-types.d.ts.map +1 -1
  68. package/dist/server/server.d.ts +40 -5
  69. package/dist/server/server.d.ts.map +1 -1
  70. package/dist/server/server.js +188 -43
  71. package/dist/server/server.js.map +1 -1
  72. package/dist/server/websocket.d.ts +11 -0
  73. package/dist/server/websocket.d.ts.map +1 -1
  74. package/dist/server/websocket.js +26 -12
  75. package/dist/server/websocket.js.map +1 -1
  76. package/dist/server/ws-bun.d.ts +7 -1
  77. package/dist/server/ws-bun.d.ts.map +1 -1
  78. package/dist/server/ws-bun.js +42 -2
  79. package/dist/server/ws-bun.js.map +1 -1
  80. package/dist/server/ws-hook.d.ts +9 -3
  81. package/dist/server/ws-hook.d.ts.map +1 -1
  82. package/dist/server.d.ts +1 -1
  83. package/dist/server.d.ts.map +1 -1
  84. package/dist/server.js +1 -1
  85. package/dist/server.js.map +1 -1
  86. package/dist/single-copy.d.ts.map +1 -1
  87. package/dist/single-copy.js +81 -10
  88. package/dist/single-copy.js.map +1 -1
  89. package/dist/wire.d.ts +5 -0
  90. package/dist/wire.d.ts.map +1 -1
  91. package/dist/wire.js +5 -0
  92. package/dist/wire.js.map +1 -1
  93. package/dist/ws.d.ts.map +1 -1
  94. package/dist/ws.js +2 -1
  95. package/dist/ws.js.map +1 -1
  96. package/package.json +3 -2
  97. package/src/assurance.ts +120 -81
  98. package/src/capabilities.ts +3 -1
  99. package/src/edge-kit.ts +1 -1
  100. package/src/index.ts +2 -2
  101. package/src/internal/hook-origins.ts +35 -0
  102. package/src/internal/route-compiler.ts +107 -16
  103. package/src/internal/route-program.ts +39 -20
  104. package/src/internal/same-origin.ts +14 -1
  105. package/src/reflection.ts +61 -1
  106. package/src/server/body-lane.ts +36 -1
  107. package/src/server/client-ip.ts +5 -5
  108. package/src/server/context.ts +7 -4
  109. package/src/server/cookies.ts +12 -1
  110. package/src/server/core-copies.ts +40 -0
  111. package/src/server/edge.ts +2 -1
  112. package/src/server/headers.ts +39 -0
  113. package/src/server/http.ts +7 -1
  114. package/src/server/response-contract-lane.ts +491 -24
  115. package/src/server/server-types.ts +23 -9
  116. package/src/server/server.ts +249 -35
  117. package/src/server/websocket.ts +39 -13
  118. package/src/server/ws-bun.ts +48 -1
  119. package/src/server/ws-hook.ts +9 -2
  120. package/src/server.ts +1 -1
  121. package/src/single-copy.ts +90 -9
  122. package/src/wire.ts +14 -0
  123. package/src/ws.ts +2 -0
@@ -1,10 +1,10 @@
1
1
  import { admitDeadline, createRequestBudget, createUnboundedRequestBudget, NIFRA_DEADLINE_HEADER, } from "../budget.js";
2
2
  import { FrameworkError, RouteConfigError } from "../errors.js";
3
3
  import { DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS, } from "../internal/capability-runtime.js";
4
- import { adoptStaticTags, enterPlugin, leavePlugin, tagAdoption, tagHook, tagScoped, tagStatic, } from "../internal/hook-origins.js";
5
- import { assuranceDeclarationsOf, assuranceEvidenceFor, } from "../internal/route-assurance.js";
4
+ import { adoptStaticTags, enterPlugin, leavePlugin, tagAdoption, tagHook, tagMergeAuthGap, tagRouteSite, tagScoped, tagStatic, } from "../internal/hook-origins.js";
5
+ import { assuranceDeclarationsOf, assuranceEvidenceFor, NIFRA_ASSURANCE_IDS, } from "../internal/route-assurance.js";
6
6
  import { RouteCatalog } from "../internal/route-catalog.js";
7
- import { compileRouteOptions } from "../internal/route-compiler.js";
7
+ import { adoptRouteRuntimes, compileRouteOptions } from "../internal/route-compiler.js";
8
8
  import { compileRouteExecutionPlan, } from "../internal/route-execution.js";
9
9
  import { compileRouteProgram, executeRouteProgram } from "../internal/route-program.js";
10
10
  import { isSameOriginRequest } from "../internal/same-origin.js";
@@ -13,9 +13,9 @@ import { EMPTY_PARAMS, METHODS, Router } from "../router/router.js";
13
13
  import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.js";
14
14
  import { assertByteLimit, markTransportCap, markTrustedBodyFraming, UNLIMITED_BODY_BYTES, } from "./body.js";
15
15
  import { NIFRA_PLATFORM_CLIENT_IP_DERIVED, resolveClientIp, } from "./client-ip.js";
16
- import "./core-copies.js";
16
+ import { mergeRefusal, recordCopyVersion } from "./core-copies.js";
17
17
  import { hasLowercaseHeaderKeysMark, headerKeysAllLowercase, markLowercaseHeaderKeys, } from "./header-case.js";
18
- import { headerObjectOf } from "./headers.js";
18
+ import { declaredHeaders, headerObjectOf } from "./headers.js";
19
19
  import { isRoutableMethod, jsonError, pathnameOf, plainError, urlPartsOf, } from "./http.js";
20
20
  import { withStaticNodeHeaders } from "./node-outcome.js";
21
21
  import { queryObjectOf, searchOf } from "./query.js";
@@ -285,6 +285,7 @@ const RESPONSE_TIMEOUT = () => jsonError(503, "request_timeout");
285
285
  // node-compat `process`, so `typeof process` does NOT gate it out there, yet reading `process.env`
286
286
  // throws `NotCapable` without `--allow-env`. The flag short-circuits before the env read on Deno (audit
287
287
  // off) while a production define still folds `<flag> && false` to `false` on every other runtime.
288
+ // It must live in this module: imported from another, Bun leaves `<flag>,` at every folded guard.
288
289
  const hookAuditRuntime = typeof globalThis.Deno === "undefined";
289
290
  // This module's own path, captured once from a sentinel stack. Every internal frame of an
290
291
  // order-scoped hook push (the public method, its `use()`-bundle dispatcher, the recorder) lives here,
@@ -293,9 +294,10 @@ const SERVER_MODULE_PATH = /* @__PURE__ */ (() => {
293
294
  const frame = new Error().stack?.split("\n")[1];
294
295
  return frame?.match(/\(?([^()\s]+):\d+:\d+\)?\s*$/)?.[1];
295
296
  })();
296
- /** One `file:line:col` frame for the caller of an order-scoped hook method, for the unused-hook
297
- * report. Only ever called behind the `unusedScopedHooks !== "off"` guard, so the `Error` (the sole
298
- * non-trivial cost) is never constructed when the check is disabled. */
297
+ /** One `file:line:col` frame for the caller of an order-scoped hook method or a route registration,
298
+ * for the unused-hook report and route provenance. Only ever called behind a development guard that
299
+ * also requires the feature to be on, so the `Error` (the sole non-trivial cost) is never constructed
300
+ * otherwise. */
299
301
  const captureCallerSite = () => {
300
302
  const stack = new Error().stack;
301
303
  if (stack === undefined)
@@ -328,7 +330,14 @@ function mountLeadsBack(from, target, path, mountsOf, seen) {
328
330
  const PAGES_DECLARED = /* @__PURE__ */ Symbol.for("nifra.web.pagesDeclared");
329
331
  /** Start tracking one server's order-scoped hook pushes (once, from the constructor). */
330
332
  const beginHookAudit = (key, policy, hasCustomLogger) => {
331
- hookAudit.set(key, { policy, hasCustomLogger, sites: [], checked: false });
333
+ hookAudit.set(key, {
334
+ policy,
335
+ hasCustomLogger,
336
+ sites: [],
337
+ lateInstalls: [],
338
+ merged: 0,
339
+ checked: false,
340
+ });
332
341
  };
333
342
  /** Record one order-scoped hook push: the route count at that moment (so `routesAtPush === total` at
334
343
  * seal means no route was declared after it) and the caller's frame. Hooks a WebSocket handshake runs
@@ -342,7 +351,52 @@ const recordScopedHook = (key, routesAtPush, kind) => {
342
351
  const state = hookAudit.get(key);
343
352
  if (state === undefined || state.policy === "off")
344
353
  return;
345
- state.sites.push({ kind, routesAtPush, site: captureCallerSite() });
354
+ state.sites.push({
355
+ kind,
356
+ routesAtPush: routesAtPush - state.merged,
357
+ mergedAtPush: state.merged,
358
+ site: captureCallerSite(),
359
+ });
360
+ };
361
+ /** Count routes a `merge()` adopted: they never run this server's order-scoped hooks, so a hook
362
+ * followed only by merges covers nothing. */
363
+ const noteMergedRoutes = (key, count) => {
364
+ const state = hookAudit.get(key);
365
+ if (state !== undefined)
366
+ state.merged += count;
367
+ };
368
+ /** Record a `merge()` whose server authenticates none of its routes while this server's order-scoped
369
+ * auth - `authenticate()` stages, or `authenticated` evidence scoped to subsequent routes - would
370
+ * cover some of them. A server that authenticates any of its own routes chose its own auth, so its
371
+ * unauthenticated routes are taken as public on purpose. `nifra check` reports what this records. */
372
+ const noteMergeAuthGap = (target, authStages, active, routes, sourceGlobal) => {
373
+ const parentAuth = active.filter((item) => item.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED);
374
+ if (authStages.length === 0 && parentAuth.length === 0)
375
+ return;
376
+ const authenticates = (route) => route.assurance.some((item) => item.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED) ||
377
+ assuranceEvidenceFor(sourceGlobal, route.method, route.path).some((item) => item.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED);
378
+ if (routes.some(authenticates))
379
+ return;
380
+ const covered = routes.filter((route) => authStages.length > 0 ||
381
+ assuranceEvidenceFor(parentAuth, route.method, route.path).length > 0);
382
+ if (covered.length === 0)
383
+ return;
384
+ tagMergeAuthGap(target, {
385
+ site: captureCallerSite(),
386
+ auth: [
387
+ ...authStages.map((stage) => `authenticate(${stage.id})`),
388
+ ...new Set(parentAuth.map((item) => item.source)),
389
+ ],
390
+ routes: covered.map((route) => `${route.method} ${route.path}`),
391
+ });
392
+ };
393
+ /** Record a registration-time plugin (`responseContract()`, `effectLedger()`) installed after routes
394
+ * it would have covered, named as `METHOD /path`. Reported with the dead hooks at seal. */
395
+ const recordLateInstall = (key, plugin, routes) => {
396
+ const state = hookAudit.get(key);
397
+ if (state === undefined || state.policy === "off" || routes.length === 0)
398
+ return;
399
+ state.lateInstalls.push({ plugin, site: captureCallerSite(), routes });
346
400
  };
347
401
  /** Hook provenance for an adoption (dev only, see `internal/hook-origins.ts`): which scope each
348
402
  * wrapper just appended to a hook list gates its hook by. `pairs` holds each source list with the target list it
@@ -368,17 +422,37 @@ const sealHookAudit = (key, total, sockets, logger) => {
368
422
  state.checked = true;
369
423
  if (state.policy === "off" || total + sockets === 0)
370
424
  return;
425
+ const own = total - state.merged;
371
426
  const dead = state.sites.filter((hook) => hook.routesAtPush ===
372
427
  (hook.kind === "around" || hook.kind === "aroundCapability" || hook.kind === "afterHandle"
373
- ? total
374
- : total + sockets));
375
- if (dead.length === 0)
428
+ ? own
429
+ : own + sockets));
430
+ const parts = [];
431
+ if (dead.length > 0) {
432
+ const mergedAfter = (hook) => state.merged > hook.mergedAtPush;
433
+ const detail = dead
434
+ .map((hook) => ` ${hook.kind}() at ${hook.site ?? "<unknown>"}${mergedAfter(hook) ? " (only merge()d routes follow it)" : ""}`)
435
+ .join("\n");
436
+ parts.push(`[nifra] ${dead.length} order-scoped hook(s) apply to no route - they were added after the ` +
437
+ `last route was registered, and register() snapshots the chain into each route as it is ` +
438
+ `declared:\n${detail}\nMove them before the routes they should cover. App-global hooks ` +
439
+ `(onRequest/onResponse) are not order-scoped and can be added at any time.` +
440
+ (dead.some(mergedAfter)
441
+ ? " A merge()d route keeps the hooks of the server that declared it and never runs this " +
442
+ "server's: wire the hook on the merged server, or declare those routes in a group()."
443
+ : ""));
444
+ }
445
+ for (const late of state.lateInstalls) {
446
+ const shown = late.routes.slice(0, 5).join(", ");
447
+ const more = late.routes.length > 5 ? ` and ${late.routes.length - 5} more` : "";
448
+ parts.push(`[nifra] ${late.plugin}() at ${late.site ?? "<unknown>"} was installed after ` +
449
+ `${late.routes.length} route(s) it would have covered: ${shown}${more}. It is decided per ` +
450
+ `route at registration and merge(), so those routes run without it. Install it before the ` +
451
+ `routes and merge() calls it should cover.`);
452
+ }
453
+ if (parts.length === 0)
376
454
  return;
377
- const detail = dead.map((hook) => ` ${hook.kind}() at ${hook.site ?? "<unknown>"}`).join("\n");
378
- const message = `[nifra] ${dead.length} order-scoped hook(s) apply to no route - they were added after the ` +
379
- `last route was registered, and register() snapshots the chain into each route as it is ` +
380
- `declared:\n${detail}\nMove them before the routes they should cover. App-global hooks ` +
381
- `(onRequest/onResponse) are not order-scoped and can be added at any time.`;
455
+ const message = parts.join("\n");
382
456
  if (state.policy === "error")
383
457
  throw new FrameworkError("UNUSED_SCOPED_HOOKS", message);
384
458
  if (state.hasCustomLogger)
@@ -419,6 +493,7 @@ export class Server {
419
493
  nativePublish;
420
494
  maxBodyBytes;
421
495
  protoPoisoning;
496
+ formOrigins;
422
497
  /** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
423
498
  trustBodyFraming;
424
499
  wsMaxPayloadBytes;
@@ -543,6 +618,7 @@ export class Server {
543
618
  const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
544
619
  assertByteLimit(maxBodyBytes, "maxBodyBytes");
545
620
  this.protoPoisoning = options.protoPoisoning ?? "reject";
621
+ this.formOrigins = options.formOrigins;
546
622
  this.trustBodyFraming = options.trustBodyFraming ?? false;
547
623
  const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes;
548
624
  assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes");
@@ -598,6 +674,7 @@ export class Server {
598
674
  // for the bundler to eliminate; a non-bundled production run skips it the same way.
599
675
  if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
600
676
  beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined);
677
+ recordCopyVersion();
601
678
  }
602
679
  this.derives = [];
603
680
  this.authStages = [];
@@ -648,6 +725,10 @@ export class Server {
648
725
  * Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
649
726
  * routes, so routes can move one at a time while the remaining legacy surface stays live.
650
727
  * Mounted responses are outside Nifra's typed route and response-contract checks.
728
+ *
729
+ * It carries HTTP only: a WebSocket upgrade under the prefix reaches the handler as an ordinary
730
+ * request and never becomes a socket. Serve that path's sockets with `app.ws()` routes or a
731
+ * mounted child app, or keep the legacy server running beside this one for them.
651
732
  */
652
733
  mountFetch(path, handler, options = {}) {
653
734
  this.assertConfigurable("mountFetch()");
@@ -763,6 +844,11 @@ export class Server {
763
844
  * Unlike `derive`, this stage receives a body-blind authentication capsule and is compiled before
764
845
  * route validation. Keeping it separate prevents an unrelated derive from accidentally becoming a
765
846
  * security gate and lets the route compiler retain a protected fast lane.
847
+ *
848
+ * A stage that throws, or returns anything but `authenticated(principal)` or `rejected(...)`, fails
849
+ * closed with 503 `{ ok: false, error: "auth_unavailable" }`. The cause goes where a handler's error
850
+ * goes: to `onError`, whose response replaces the 503, and when no hook answers, to the logger. A
851
+ * thrown `Response` or `status(...)` is sent as thrown, as it is from a handler.
766
852
  */
767
853
  authenticate(stage) {
768
854
  this.assertConfigurable("authenticate()");
@@ -779,8 +865,10 @@ export class Server {
779
865
  throw new TypeError('authenticate() stage mode must be "sync" or "async"');
780
866
  }
781
867
  this.authStages.push(stage);
782
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
868
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
869
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "authenticate");
783
870
  tagHook(stage, "authenticate", this);
871
+ }
784
872
  return this;
785
873
  }
786
874
  decorate(key, value) {
@@ -877,9 +965,10 @@ export class Server {
877
965
  }
878
966
  return this;
879
967
  }
880
- /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500).
881
- * Order-scoped: captured by routes declared *after* this call, none before; one added after the last
882
- * route reaches nothing (see {@link ServerOptions.unusedScopedHooks}). */
968
+ /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500, or 503
969
+ * `auth_unavailable` for a failed {@link authenticate} stage). Order-scoped: captured by routes
970
+ * declared *after* this call, none before; one added after the last route reaches nothing (see
971
+ * {@link ServerOptions.unusedScopedHooks}). */
883
972
  onError(fn) {
884
973
  this.assertConfigurable("onError()");
885
974
  this.onErrorHooks.push(fn);
@@ -1311,6 +1400,15 @@ export class Server {
1311
1400
  throw new RouteConfigError("INVALID_PATH", `group(${JSON.stringify(prefix)}) route path must start with "/": ${JSON.stringify(path)}`);
1312
1401
  }
1313
1402
  prepareRoute(method, path, schema, handler) {
1403
+ // Route provenance for the CLI's capability walk, which sets NIFRA_ROUTE_SITES while it loads the
1404
+ // app: the module that registered the route, read where a static scan cannot see the path.
1405
+ if (hookAuditRuntime &&
1406
+ process.env.NODE_ENV !== "production" &&
1407
+ process.env.NIFRA_ROUTE_SITES === "1") {
1408
+ const site = captureCallerSite();
1409
+ if (site !== undefined)
1410
+ tagRouteSite(this, `${method.toUpperCase()} ${path}`, site);
1411
+ }
1314
1412
  const compiled = compileRouteOptions({
1315
1413
  maxBodyBytes: this.maxBodyBytes,
1316
1414
  activeAssurance: this.activeAssurance,
@@ -1434,6 +1532,12 @@ export class Server {
1434
1532
  [INSTALL_RESPONSE_CONTRACT](runtime) {
1435
1533
  this.assertConfigurable("responseContract()");
1436
1534
  this.responseContractRuntime = runtime;
1535
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1536
+ recordLateInstall(this, "responseContract", this.catalog
1537
+ .entries()
1538
+ .filter(({ entry }) => entry.responseContract === undefined &&
1539
+ (entry.schema?.response !== undefined || entry.schema?.errors !== undefined))
1540
+ .map((route) => `${route.method} ${route.path}`));
1437
1541
  }
1438
1542
  /** @internal Symbol-keyed install seam for the response observer plugin. */
1439
1543
  [INSTALL_RESPONSE_OBSERVER](runtime) {
@@ -1555,13 +1659,40 @@ export class Server {
1555
1659
  * registered before a `derive`). Fail closed: a path+method collision throws
1556
1660
  * `RouteConfigError` at merge time, and a group with WebSocket routes or mounts is refused
1557
1661
  * (register those on the parent).
1662
+ *
1663
+ * What this server's `.use()`, made BEFORE the merge, covers on a merged route versus a
1664
+ * `group()` route:
1665
+ *
1666
+ * | installed on this server | group() route | merge()d route |
1667
+ * | ------------------------------------------ | ------------- | ------------------------------ |
1668
+ * | derive/decorate/beforeHandle/afterHandle, | yes | no - wire it on the merged |
1669
+ * | onError/around, authenticate, route-scoped | | server |
1670
+ * | assurance | | |
1671
+ * | onRequest/onResponse/responseHeaders hooks | yes | yes (app-wide) |
1672
+ * | responseContract() | yes | yes, when the route declares a |
1673
+ * | | | response and its own server |
1674
+ * | | | installed no contract |
1675
+ * | effectLedger() | yes | yes, when the route declares |
1676
+ * | | | capabilities and has no ledger |
1677
+ * | idempotency() | yes | the merged server's own; a |
1678
+ * | | | route declaring it without one |
1679
+ * | | | is refused at registration |
1680
+ *
1681
+ * The response contract and the ledger can only add a check or a record, so a merged route is
1682
+ * recompiled under them. A runtime installed AFTER the merge covers neither kind of route.
1683
+ *
1684
+ * In development, {@link ServerOptions.unusedScopedHooks} reports a hook followed only by merges and
1685
+ * a contract or ledger installed after the routes it would cover; `nifra check` reports a merge whose
1686
+ * server authenticates none of its routes under this server's `authenticate()` (NF-C043).
1558
1687
  */
1559
1688
  merge(other) {
1560
1689
  this.assertConfigurable("merge()");
1561
1690
  if (!(other instanceof Server)) {
1562
1691
  // A server from another copy of this package reaches this copy's private request state through
1563
1692
  // symbols it does not share, and would fail per request. The boot warning names the copies.
1564
- throw new TypeError("merge() requires a server() from this copy of @nifrajs/core");
1693
+ throw new TypeError(hookAuditRuntime && process.env.NODE_ENV !== "production"
1694
+ ? mergeRefusal()
1695
+ : "merge() requires a server() from this copy of @nifrajs/core");
1565
1696
  }
1566
1697
  if (this.routePrefix !== "") {
1567
1698
  // The merged routes were compiled under their own paths; adopting them here would publish them
@@ -1572,7 +1703,11 @@ export class Server {
1572
1703
  if (source.wsRoutes.length > 0 || source.fetchMounts.length > 0) {
1573
1704
  throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes or mounts - register them on the parent server");
1574
1705
  }
1706
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1707
+ noteMergeAuthGap(this, this.authStages, this.activeAssurance, source.catalog.entries(), source.globalAssurance);
1575
1708
  this.adopt(source, undefined, 0);
1709
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1710
+ noteMergedRoutes(this, source.catalog.size);
1576
1711
  return this;
1577
1712
  }
1578
1713
  /**
@@ -1590,7 +1725,7 @@ export class Server {
1590
1725
  * The builder receives a scope that INHERITS this server's route chain as it stands at the call
1591
1726
  * (`derive`/`decorate`/`authenticate`/`beforeHandle`/`afterHandle`/`onError`/`around`, assurance,
1592
1727
  * capability interceptors, installed runtimes, applied plugins), exactly as a route declared here
1593
- * would. A plugin this server already applied is in effect for the group: the group's `use()` of
1728
+ * would. {@link merge} documents how that differs for a merged server. A plugin this server already applied is in effect for the group: the group's `use()` of
1594
1729
  * that same instance does nothing, and its `use()` of another instance under the same name throws a
1595
1730
  * `RouteConfigError` (`PLUGIN_RECONFIGURED`), because that instance could never replace the parent's.
1596
1731
  * A plugin the group applies still shares the parent's copy of a dependency it `use()`s itself.
@@ -1684,7 +1819,7 @@ export class Server {
1684
1819
  assurance: [...route.assurance, ...ownGlobal],
1685
1820
  })
1686
1821
  : (route) => route;
1687
- this.catalog.addBatch(sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(route))));
1822
+ this.catalog.addBatch(sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(adoptRouteRuntimes(route, this.responseContractRuntime, this.effectLedgerRuntime)))));
1688
1823
  // Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
1689
1824
  // runtime object supplies the generic execution machinery. If the parent already has a runtime,
1690
1825
  // either implementation can execute every resolved entry because route-specific options were
@@ -2310,6 +2445,7 @@ export class Server {
2310
2445
  pubsub,
2311
2446
  attach: runtime.attach,
2312
2447
  maxPayloadBytes: this.wsMaxPayloadBytes,
2448
+ sender: runtime.sender,
2313
2449
  };
2314
2450
  };
2315
2451
  const wrap = (response) => finalize(response, EMPTY_RESPONSE_CONTROLS);
@@ -2410,9 +2546,8 @@ export class Server {
2410
2546
  return this.fetch(req, bunPeerPlatform(server, req));
2411
2547
  if (o.kind === "reject")
2412
2548
  return o.response;
2413
- return server.upgrade(req, {
2414
- data: { handler: o.handler, data: o.data, pubsub: o.pubsub },
2415
- })
2549
+ // A copy: the Bun socket keeps per-connection state on it, and a hand-built outcome may be shared.
2550
+ return server.upgrade(req, { data: { ...o } })
2416
2551
  ? undefined
2417
2552
  : jsonError(426, "upgrade_required");
2418
2553
  };
@@ -2845,6 +2980,11 @@ export class Server {
2845
2980
  [INSTALL_EFFECT_LEDGER](runtime) {
2846
2981
  this.assertConfigurable("effectLedger()");
2847
2982
  this.effectLedgerRuntime = runtime;
2983
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2984
+ recordLateInstall(this, "effectLedger", this.catalog
2985
+ .entries()
2986
+ .filter(({ entry, descriptor }) => entry.ledgered === undefined && (descriptor.capabilities?.length ?? 0) > 0)
2987
+ .map((route) => `${route.method} ${route.path}`));
2848
2988
  }
2849
2989
  /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
2850
2990
  * successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
@@ -3053,7 +3193,7 @@ export class Server {
3053
3193
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3054
3194
  : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
3055
3195
  const finish = (value) => runParsed(value, ctx, finalize, wrapResponse);
3056
- return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, finish, wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse), bodySchema);
3196
+ return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, finish, wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse), bodySchema, this.formOrigins);
3057
3197
  };
3058
3198
  }
3059
3199
  /** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
@@ -3340,7 +3480,7 @@ export class Server {
3340
3480
  ctx.body = validation.value;
3341
3481
  return runHandlerValidated();
3342
3482
  };
3343
- return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, onParsed, (response) => this.wrapWebResponse(response), (err) => Promise.resolve(logError(err, ctx)), bodySchema);
3483
+ return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, onParsed, (response) => this.wrapWebResponse(response), (err) => Promise.resolve(logError(err, ctx)), bodySchema, this.formOrigins);
3344
3484
  };
3345
3485
  }
3346
3486
  /**
@@ -3620,7 +3760,7 @@ export class Server {
3620
3760
  const ctx = nativeContext
3621
3761
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3622
3762
  : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
3623
- return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, (parsed) => onParsed(parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse), bodySchema);
3763
+ return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, (parsed) => onParsed(parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse), bodySchema, this.formOrigins);
3624
3764
  };
3625
3765
  }
3626
3766
  bareError(err, ctx, finalize, wrapResponse) {
@@ -3660,7 +3800,9 @@ export class Server {
3660
3800
  if (stage.kind === "body")
3661
3801
  return this.readProgramBody(entry, source, ctx);
3662
3802
  const input = stage.kind === "headers"
3663
- ? headerObjectOf(source.headers)
3803
+ ? stage.names === undefined
3804
+ ? headerObjectOf(source.headers)
3805
+ : declaredHeaders(source.headers, stage.names)
3664
3806
  : stage.kind === "query"
3665
3807
  ? queryObjectOf(ctx[CONTEXT_SEARCH])
3666
3808
  : ctx[stage.kind];
@@ -3678,8 +3820,8 @@ export class Server {
3678
3820
  }
3679
3821
  /** Keep thrown Response/status and redacted 500 semantics in one existing error boundary. */
3680
3822
  // @ts-expect-error TS6133 -- invoked structurally by the general route program
3681
- handleProgramError(entry, error, ctx, finalize, wrapResponse) {
3682
- return this.handleLifecycleError(entry, error, ctx, finalize, wrapResponse);
3823
+ handleProgramError(entry, error, ctx, finalize, wrapResponse, fallback) {
3824
+ return this.handleLifecycleError(entry, error, ctx, finalize, wrapResponse, fallback);
3683
3825
  }
3684
3826
  /** Finish a successful program result through response-contract enforcement and finalization. */
3685
3827
  // @ts-expect-error TS6133 -- invoked structurally by the general route program
@@ -3988,7 +4130,9 @@ export class Server {
3988
4130
  * result a microtask even when every branch it takes returns immediately, which is the common case
3989
4131
  * here - a thrown `status(...)`/`Response`, or a route with no `onError` hook at all. Only the hook
3990
4132
  * loop, which may await, drops into {@link runErrorHooks}. */
3991
- handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
4133
+ handleLifecycleError(entry, err, ctx, finalize, wrapResponse,
4134
+ // The answer when no hook does; the default 500 unless a stage owns a different fail-closed status.
4135
+ fallback) {
3992
4136
  // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
3993
4137
  // an action throws an error page. Send it as the same Response returned would be sent (Remix/
3994
4138
  // SvelteKit semantics): as built, plus the cookies the request queued, so a session set or
@@ -4003,13 +4147,13 @@ export class Server {
4003
4147
  // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
4004
4148
  // (redacting) logger. Body-read failures and around-hook failures land here too.
4005
4149
  this.logRequestError(err, ctx);
4006
- return wrapResponse(plainError(500, "internal_error"));
4150
+ return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback();
4007
4151
  }
4008
- return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse);
4152
+ return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse, fallback);
4009
4153
  }
4010
4154
  /** The `onError` chain, split off {@link handleLifecycleError} so its `await` costs only the routes
4011
4155
  * that registered a hook. A hook may return a custom response; otherwise the default 500 stands. */
4012
- async runErrorHooks(entry, err, ctx, finalize, wrapResponse) {
4156
+ async runErrorHooks(entry, err, ctx, finalize, wrapResponse, fallback) {
4013
4157
  // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
4014
4158
  // the `await` and stay heap-allocated on both engines.
4015
4159
  for (let i = 0; i < entry.onError.length; i++) {
@@ -4031,7 +4175,7 @@ export class Server {
4031
4175
  }
4032
4176
  }
4033
4177
  this.logRequestError(err, ctx);
4034
- return wrapResponse(plainError(500, "internal_error"));
4178
+ return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback();
4035
4179
  }
4036
4180
  /** Log an unhandled request error to the (redacting) logger - shared by the compiled route program and the
4037
4181
  * bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
@@ -4049,7 +4193,7 @@ export class Server {
4049
4193
  ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, "body"))
4050
4194
  : this.applyLifecycleValidation(entry, validation, ctx, "body");
4051
4195
  };
4052
- return readBodyFramed(req, bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, validate, (response) => response, (error) => Promise.reject(error), bodySchema);
4196
+ return readBodyFramed(req, bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, validate, (response) => response, (error) => Promise.reject(error), bodySchema, this.formOrigins);
4053
4197
  }
4054
4198
  /** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
4055
4199
  * before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
@@ -4356,7 +4500,7 @@ export class Server {
4356
4500
  // that registry in the upgrade outcome instead of accidentally broadcasting child sockets through
4357
4501
  // the parent.
4358
4502
  const nativePubsub = this.wsRoutes.length > 0 && !this.mountsTakeUpgrades() && !this.wsHasValidatedSend;
4359
- const wsHandlers = wsRuntime?.bunHandlers(this.topics ?? wsRuntime.createTopics(), nativePubsub);
4503
+ const wsHandlers = wsRuntime?.bunHandlers(this.topics ?? wsRuntime.createTopics(), nativePubsub, this);
4360
4504
  // Spread rather than pass `hostname: undefined` (or an undefined idle timeout or TLS) - Bun treats
4361
4505
  // an explicit undefined as a value on some option paths, and omitting is what selects its own
4362
4506
  // defaults, 0.0.0.0 among them.
@@ -4402,8 +4546,9 @@ export class Server {
4402
4546
  // `unknown` params bridge a TS structural-variance quirk. Round-trip covered by websocket.test.ts.
4403
4547
  websocket: {
4404
4548
  // Cap inbound frames so a huge message can't be buffered/JSON-parsed into memory; the runtime
4405
- // closes an over-cap connection before the handler runs. Default 1 MB (maxBodyBytes).
4406
- maxPayloadLength: this.wsMaxPayloadBytes,
4549
+ // closes an over-cap connection before the handler runs. Default 1 MB (maxBodyBytes). One
4550
+ // limit per listener: the largest cap among this app and the apps mounted on it.
4551
+ maxPayloadLength: wsHandlers.maxPayloadLength,
4407
4552
  open: (ws) => wsHandlers.open(ws),
4408
4553
  message: (ws, message) => wsHandlers.message(ws, message),
4409
4554
  close: (ws, code, reason) => wsHandlers.close(ws, code, reason),