@nifrajs/core 1.12.0 → 1.13.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.
@@ -4,6 +4,7 @@ import { canonicalizeIdempotencyBody, computeIdempotencyFingerprint, DEFAULT_IDE
4
4
  import { CAPABILITY_GUARD, createCapabilityGuard, normalizeRouteCapabilities, } from "../internal/capability-runtime.js";
5
5
  import { assuranceDeclarationsOf, assuranceEvidenceFor, NIFRA_ASSURANCE_IDS, } from "../internal/route-assurance.js";
6
6
  import { attachEffectLedger, createRequestLedger, DEFAULT_MAX_ENTRIES, } from "../ledger.js";
7
+ import { compileRoutePattern, decodeRouteParams, } from "../router/pattern.js";
7
8
  import { EMPTY_PARAMS, Router } from "../router/router.js";
8
9
  import { drainCapped, parseContentLength, readBoundedBytes } from "./body.js";
9
10
  import { parseCookies, serializeCookie } from "./cookies.js";
@@ -35,6 +36,57 @@ function isResponseResult(value) {
35
36
  const fallbackWaitUntil = (promise) => {
36
37
  void promise.catch(() => { });
37
38
  };
39
+ /**
40
+ * Runtime route catalog. Single-route registration mutates directly; multi-route registration replays
41
+ * the existing catalog plus the candidate batch into a staged router, then swaps the complete state only
42
+ * after every route validates. Failed `implement()`/`merge()` batches therefore leave matching and
43
+ * reflection unchanged.
44
+ */
45
+ class RouteCatalog {
46
+ matcher = new Router();
47
+ records = [];
48
+ /** Allocation-free reflection view for the common no-assurance case. Derived only at commit time. */
49
+ descriptors = [];
50
+ assurancePresent = false;
51
+ add(route) {
52
+ this.matcher.add(route.method, route.pattern, route.entry);
53
+ this.records.push(route);
54
+ this.descriptors.push(route.descriptor);
55
+ if (route.assurance.length > 0)
56
+ this.assurancePresent = true;
57
+ }
58
+ addBatch(routes) {
59
+ if (routes.length === 0)
60
+ return;
61
+ const nextRecords = this.records.concat(routes);
62
+ const nextDescriptors = this.descriptors.concat(routes.map(({ descriptor }) => descriptor));
63
+ const nextAssurancePresent = this.assurancePresent || routes.some((route) => route.assurance.length > 0);
64
+ const staged = new Router();
65
+ for (const route of this.records)
66
+ staged.add(route.method, route.pattern, route.entry);
67
+ for (const route of routes)
68
+ staged.add(route.method, route.pattern, route.entry);
69
+ this.matcher = staged;
70
+ this.records = nextRecords;
71
+ this.descriptors = nextDescriptors;
72
+ this.assurancePresent = nextAssurancePresent;
73
+ }
74
+ find(method, path) {
75
+ return this.matcher.find(method, path);
76
+ }
77
+ entries() {
78
+ return this.records;
79
+ }
80
+ routeDescriptors() {
81
+ return this.descriptors;
82
+ }
83
+ lastDescriptor() {
84
+ return this.records[this.records.length - 1]?.descriptor;
85
+ }
86
+ hasAssurance() {
87
+ return this.assurancePresent;
88
+ }
89
+ }
38
90
  const WS_PASS = { kind: "pass" };
39
91
  /** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
40
92
  function requireWsRuntime() {
@@ -270,27 +322,6 @@ async function readBoundedForm(req, maxBytes) {
270
322
  }
271
323
  return out;
272
324
  }
273
- function decodeParams(raw) {
274
- let out;
275
- for (const key in raw) {
276
- const value = raw[key];
277
- // `decodeURIComponent` only changes strings containing `%`; for the common
278
- // unencoded param (`/users/123`) skip the call and its throw-check entirely —
279
- // semantically identical (no `%` ⇒ no escapes to decode), ~3× cheaper on that path. If every
280
- // param is already plain, return the router's per-request object directly and skip a clone.
281
- if (!value.includes("%")) {
282
- continue;
283
- }
284
- try {
285
- out ??= { ...raw };
286
- out[key] = decodeURIComponent(value);
287
- }
288
- catch {
289
- return null;
290
- }
291
- }
292
- return out ?? raw;
293
- }
294
325
  function hasReplacementParam(params) {
295
326
  for (const key in params) {
296
327
  if (params[key].includes("\uFFFD"))
@@ -645,7 +676,7 @@ function appendCookiesToNodeHeaders(headers, cookies) {
645
676
  * .get("/me", (c) => c.user) // c.user + c.db are typed
646
677
  */
647
678
  export class Server {
648
- router;
679
+ catalog;
649
680
  /** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
650
681
  wsRouter;
651
682
  wsRouteCount;
@@ -653,7 +684,6 @@ export class Server {
653
684
  * Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime — `undefined` until then, so a
654
685
  * no-WebSocket app never constructs (or bundles) it. */
655
686
  topics;
656
- routeList;
657
687
  maxBodyBytes;
658
688
  wsMaxPayloadBytes;
659
689
  requestTimeoutMs;
@@ -673,6 +703,7 @@ export class Server {
673
703
  /** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
674
704
  defaultOnValidationError;
675
705
  bunServer;
706
+ sealed;
676
707
  derives;
677
708
  decorations;
678
709
  beforeHandleHooks;
@@ -692,19 +723,11 @@ export class Server {
692
723
  /** App-declared MCP resources / prompts (via {@link resource} / {@link prompt}), read by `nifra mcp`. */
693
724
  mcpResourceList;
694
725
  mcpPromptList;
695
- /**
696
- * Every registered route's raw router entry + descriptor, in order — the replay source for
697
- * {@link merge}. The entry already carries the chains captured at registration (derives,
698
- * decorations, before/after/around/onError), so a merged route behaves exactly as it did on
699
- * the server that defined it.
700
- */
701
- rawEntries;
702
726
  constructor(options = {}) {
703
- this.router = new Router();
727
+ this.catalog = new RouteCatalog();
704
728
  this.wsRouter = new Router();
705
729
  this.wsRouteCount = 0;
706
730
  this.topics = undefined;
707
- this.routeList = [];
708
731
  this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
709
732
  this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes;
710
733
  this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
@@ -746,6 +769,7 @@ export class Server {
746
769
  this.logger = options.logger ?? jsonLogger();
747
770
  this.defaultOnValidationError = options.onValidationError;
748
771
  this.bunServer = undefined;
772
+ this.sealed = false;
749
773
  this.derives = [];
750
774
  this.decorations = {};
751
775
  this.beforeHandleHooks = [];
@@ -761,15 +785,21 @@ export class Server {
761
785
  this.globalAssurance = [];
762
786
  this.mcpResourceList = [];
763
787
  this.mcpPromptList = [];
764
- this.rawEntries = [];
788
+ }
789
+ assertConfigurable(operation) {
790
+ if (this.sealed) {
791
+ throw new FrameworkError("SERVER_SEALED", `server configuration is sealed after listen(); call ${operation} before listen()`);
792
+ }
765
793
  }
766
794
  /** Add a per-request, computed context extension for subsequent routes. */
767
795
  derive(fn) {
796
+ this.assertConfigurable("derive()");
768
797
  this.derives.push(fn);
769
798
  return this;
770
799
  }
771
800
  /** Add a static context value for subsequent routes. */
772
801
  decorate(key, value) {
802
+ this.assertConfigurable("decorate()");
773
803
  this.decorations[key] = value;
774
804
  return this;
775
805
  }
@@ -778,11 +808,13 @@ export class Server {
778
808
  * `Request` to continue routing with a rewritten method/URL/headers. Global.
779
809
  */
780
810
  onRequest(fn) {
811
+ this.assertConfigurable("onRequest()");
781
812
  this.onRequestHooks.push(fn);
782
813
  return this;
783
814
  }
784
815
  /** Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped. */
785
816
  beforeHandle(fn) {
817
+ this.assertConfigurable("beforeHandle()");
786
818
  this.beforeHandleHooks.push(fn);
787
819
  return this;
788
820
  }
@@ -792,30 +824,36 @@ export class Server {
792
824
  * a Web `Response`. The first registered wrapper is outermost.
793
825
  */
794
826
  around(fn) {
827
+ this.assertConfigurable("around()");
795
828
  this.aroundHooks.push(fn);
796
829
  return this;
797
830
  }
798
831
  /** Transform the handler's result before it is serialized. Order-scoped. */
799
832
  afterHandle(fn) {
833
+ this.assertConfigurable("afterHandle()");
800
834
  this.afterHandleHooks.push(fn);
801
835
  return this;
802
836
  }
803
837
  /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500). Order-scoped. */
804
838
  onError(fn) {
839
+ this.assertConfigurable("onError()");
805
840
  this.onErrorHooks.push(fn);
806
841
  return this;
807
842
  }
808
843
  /** Transform every outgoing response — success, error, 404, 405, short-circuit. Global. */
809
844
  onResponse(fn) {
845
+ this.assertConfigurable("onResponse()");
810
846
  this.onResponseHooks.push(fn);
811
847
  return this;
812
848
  }
813
849
  /** Observe the terminal response after all transformations. Observers are ordered and fail-open. */
814
850
  onResponseFinalized(fn) {
851
+ this.assertConfigurable("onResponseFinalized()");
815
852
  this.onResponseFinalizedHooks.push(fn);
816
853
  return this;
817
854
  }
818
855
  use(arg) {
856
+ this.assertConfigurable("use()");
819
857
  if (typeof arg === "function") {
820
858
  const name = arg.pluginName;
821
859
  if (name !== undefined) {
@@ -918,7 +956,7 @@ export class Server {
918
956
  this.register("POST", path, routeSchema, run);
919
957
  // Tag the just-registered descriptor as an MCP tool. `tool` is readonly on RouteDescriptor (an
920
958
  // introspection field), so write it through a narrow mutable view — not `any`.
921
- const lastRoute = this.routeList[this.routeList.length - 1];
959
+ const lastRoute = this.catalog.lastDescriptor();
922
960
  if (lastRoute) {
923
961
  ;
924
962
  lastRoute.tool = {
@@ -936,6 +974,7 @@ export class Server {
936
974
  * for the resource half of MCP.
937
975
  */
938
976
  resource(uri, config, read) {
977
+ this.assertConfigurable("resource()");
939
978
  this.mcpResourceList.push({
940
979
  uri,
941
980
  name: config.name,
@@ -950,6 +989,7 @@ export class Server {
950
989
  * `handler` receives the caller's arguments and returns the rendered messages.
951
990
  */
952
991
  prompt(name, config, handler) {
992
+ this.assertConfigurable("prompt()");
953
993
  this.mcpPromptList.push({
954
994
  name,
955
995
  description: config.description,
@@ -976,6 +1016,7 @@ export class Server {
976
1016
  * app.ws("/chat", { open: (ws) => ws.send("hi"), message: (ws, data) => ws.send(data) })
977
1017
  */
978
1018
  ws(path, handler) {
1019
+ this.assertConfigurable("ws()");
979
1020
  // Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
980
1021
  // bundle it. Registration is the loud, early failure point — never the first connection.
981
1022
  const runtime = requireWsRuntime();
@@ -1019,6 +1060,18 @@ export class Server {
1019
1060
  * is the "compiled", order-scoped per-route chain.
1020
1061
  */
1021
1062
  register(method, path, schema, handler) {
1063
+ this.assertConfigurable("route registration");
1064
+ this.catalog.add(this.prepareRoute(method, path, schema, handler));
1065
+ }
1066
+ /** Register a contract/group route batch atomically. Every route captures the same current chain it
1067
+ * would capture through {@link register}; no route becomes visible unless the full batch validates. */
1068
+ registerBatch(routes) {
1069
+ this.assertConfigurable("route registration");
1070
+ const staged = routes.map(({ method, path, schema, handler }) => this.prepareRoute(method, path, schema, handler));
1071
+ this.catalog.addBatch(staged);
1072
+ }
1073
+ prepareRoute(method, path, schema, handler) {
1074
+ const pattern = compileRoutePattern(path);
1022
1075
  const capabilities = normalizeRouteCapabilities(schema?.capabilities);
1023
1076
  const handlerAssurance = assuranceDeclarationsOf(handler);
1024
1077
  const invalidHandlerScope = handlerAssurance.find((declaration) => declaration.scope !== "plugin");
@@ -1052,6 +1105,25 @@ export class Server {
1052
1105
  const fusedWeb = bare && this.aroundHooks.length === 0
1053
1106
  ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler))
1054
1107
  : undefined;
1108
+ const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler);
1109
+ const lane = bare
1110
+ ? "bare"
1111
+ : schema?.body !== undefined &&
1112
+ schema.query === undefined &&
1113
+ this.derives.length === 0 &&
1114
+ this.beforeHandleHooks.length === 0 &&
1115
+ this.afterHandleHooks.length === 0 &&
1116
+ this.onErrorHooks.length === 0
1117
+ ? "body"
1118
+ : schema?.body === undefined &&
1119
+ schema?.query !== undefined &&
1120
+ this.derives.length === 0 &&
1121
+ this.beforeHandleHooks.length === 0 &&
1122
+ this.afterHandleHooks.length === 0 &&
1123
+ this.onErrorHooks.length === 0
1124
+ ? "query"
1125
+ : "lifecycle";
1126
+ const execution = this.compileExecutionPlan(lane, contextless, this.aroundHooks.length > 0, ledgered !== undefined, fusedWeb);
1055
1127
  const entry = {
1056
1128
  // (context: never) => unknown -> InternalHandler: the framework invokes it
1057
1129
  // with the concrete RawContext the typed handler expects, so this is sound.
@@ -1059,7 +1131,6 @@ export class Server {
1059
1131
  schema,
1060
1132
  idempotent,
1061
1133
  ledgered,
1062
- fusedWeb,
1063
1134
  derives: [...this.derives],
1064
1135
  decorations: routeDecorations,
1065
1136
  hasDecorations,
@@ -1067,33 +1138,14 @@ export class Server {
1067
1138
  afterHandle: [...this.afterHandleHooks],
1068
1139
  onError: [...this.onErrorHooks],
1069
1140
  around: [...this.aroundHooks],
1070
- // Sync-fast-path eligibility (see RouteEntry.bare). Validation + every hook kind must be absent;
1071
- // decorations and generic around wrappers are fine: around wraps the inner bare route in
1072
- // `routeAndRun`, while the inner validation/derive/before/after/onError lifecycle remains empty.
1073
- // Most GET/no-schema routes qualify.
1074
- bare,
1075
- contextlessBare: bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler),
1076
- bodyOnly: schema?.body !== undefined &&
1077
- schema.query === undefined &&
1078
- this.derives.length === 0 &&
1079
- this.beforeHandleHooks.length === 0 &&
1080
- this.afterHandleHooks.length === 0 &&
1081
- this.onErrorHooks.length === 0,
1082
- queryOnly: schema?.body === undefined &&
1083
- schema?.query !== undefined &&
1084
- this.derives.length === 0 &&
1085
- this.beforeHandleHooks.length === 0 &&
1086
- this.afterHandleHooks.length === 0 &&
1087
- this.onErrorHooks.length === 0,
1141
+ execution,
1088
1142
  };
1089
- this.router.add(method, path, entry);
1090
1143
  const descriptor = {
1091
1144
  method,
1092
1145
  path,
1093
1146
  schema,
1094
1147
  ...(capabilities.length > 0 ? { capabilities } : {}),
1095
1148
  };
1096
- this.routeList.push(descriptor);
1097
1149
  const routeAssurance = [...this.activeAssurance, ...handlerAssurance];
1098
1150
  if (schema?.body !== undefined) {
1099
1151
  routeAssurance.push(Object.freeze({
@@ -1111,13 +1163,62 @@ export class Server {
1111
1163
  scope: "plugin",
1112
1164
  }));
1113
1165
  }
1114
- this.rawEntries.push({
1166
+ return {
1115
1167
  method,
1116
1168
  path,
1169
+ pattern,
1117
1170
  entry,
1118
1171
  descriptor,
1119
1172
  assurance: Object.freeze(routeAssurance),
1120
- });
1173
+ };
1174
+ }
1175
+ /** Collapse route-invariant lifecycle decisions into one runner at registration. The request path
1176
+ * performs no eligibility ladder: it supplies request state to this already-selected plan. */
1177
+ compileExecutionPlan(lane, contextless, hasAround, hasLedger, fusedWeb) {
1178
+ if (contextless) {
1179
+ const run = (runtime, entry, source, params, search, signal, budget, platform, finalize, wrapResponse) => runtime.runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
1180
+ return Object.freeze({ run, fusedWeb });
1181
+ }
1182
+ let inner;
1183
+ switch (lane) {
1184
+ case "bare":
1185
+ inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runBare(entry, ctx, finalize, wrapResponse);
1186
+ break;
1187
+ case "body":
1188
+ inner = (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runBodyOnly(entry, source, ctx, finalize, wrapResponse);
1189
+ break;
1190
+ case "query":
1191
+ inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runQueryOnly(entry, ctx, finalize, wrapResponse);
1192
+ break;
1193
+ default:
1194
+ inner = (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runLifecycle(entry, source, ctx, finalize, wrapResponse);
1195
+ }
1196
+ const execute = hasAround
1197
+ ? (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runWithAround(entry, ctx, () => inner(runtime, entry, source, ctx, finalize, wrapResponse), finalize, wrapResponse)
1198
+ : inner;
1199
+ const run = (runtime, entry, source, params, search, signal, budget, platform, finalize, wrapResponse) => {
1200
+ const ctx = new RequestContext(source, params, search, signal, budget, platform, runtime.maxBodyBytes);
1201
+ let ledger;
1202
+ if (hasLedger) {
1203
+ const resolved = entry.ledgered;
1204
+ ledger = createRequestLedger({
1205
+ method: resolved.method,
1206
+ path: resolved.path,
1207
+ declared: resolved.declared,
1208
+ maxEntries: resolved.maxEntries,
1209
+ chain: resolved.chain,
1210
+ });
1211
+ attachEffectLedger(ctx, ledger);
1212
+ }
1213
+ let outcome = execute(runtime, entry, source, ctx, finalize, wrapResponse);
1214
+ if (ledger !== undefined) {
1215
+ const active = ledger;
1216
+ const resolved = entry.ledgered;
1217
+ outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) => runtime.settleEffectLedger(active, resolved, value));
1218
+ }
1219
+ return outcome;
1220
+ };
1221
+ return Object.freeze({ run, fusedWeb });
1121
1222
  }
1122
1223
  /** Resolve a route's declared idempotency into a pinned store + defaults, or `undefined` when off. */
1123
1224
  resolveIdempotency(schema, authenticated) {
@@ -1191,15 +1292,12 @@ export class Server {
1191
1292
  * those on the parent).
1192
1293
  */
1193
1294
  merge(other) {
1295
+ this.assertConfigurable("merge()");
1194
1296
  const source = other;
1195
1297
  if (source.wsRouteCount > 0) {
1196
1298
  throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes — register .ws() routes on the parent server");
1197
1299
  }
1198
- for (const { method, path, entry, descriptor, assurance } of source.rawEntries) {
1199
- this.router.add(method, path, entry); // throws RouteConfigError on a duplicate — fail closed
1200
- this.routeList.push(descriptor); // shared ref on purpose: carries later .tool() tagging
1201
- this.rawEntries.push({ method, path, entry, descriptor, assurance });
1202
- }
1300
+ this.catalog.addBatch(source.catalog.entries().map((route) => this.bindFusedRuntime(route)));
1203
1301
  this.onRequestHooks.push(...source.onRequestHooks);
1204
1302
  this.onResponseHooks.push(...source.onResponseHooks);
1205
1303
  this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks);
@@ -1208,6 +1306,21 @@ export class Server {
1208
1306
  this.mcpPromptList.push(...source.mcpPromptList);
1209
1307
  return this;
1210
1308
  }
1309
+ /** A fused renderer closes over runtime services to keep its seven-argument JSC fast path. Merging
1310
+ * rebinds that closure once to the executing server; generic plans already receive the runtime. */
1311
+ bindFusedRuntime(route) {
1312
+ const { entry } = route;
1313
+ if (entry.execution.fusedWeb === undefined)
1314
+ return route;
1315
+ const fusedWeb = this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler));
1316
+ return {
1317
+ ...route,
1318
+ entry: {
1319
+ ...entry,
1320
+ execution: Object.freeze({ ...entry.execution, fusedWeb }),
1321
+ },
1322
+ };
1323
+ }
1211
1324
  /**
1212
1325
  * Enumerate the registered routes (method, path, input schemas), in registration
1213
1326
  * order. Powers `toOpenAPI` and other introspection; the router trie itself no
@@ -1215,11 +1328,11 @@ export class Server {
1215
1328
  */
1216
1329
  routes() {
1217
1330
  if (this.activeAssurance.length === 0 && this.globalAssurance.length === 0) {
1218
- const anyRouteEvidence = this.rawEntries.some((entry) => entry.assurance.length > 0);
1219
- if (!anyRouteEvidence)
1220
- return this.routeList;
1331
+ if (!this.catalog.hasAssurance()) {
1332
+ return this.catalog.routeDescriptors();
1333
+ }
1221
1334
  }
1222
- return this.rawEntries.map(({ method, path, descriptor, assurance }) => {
1335
+ return this.catalog.entries().map(({ method, path, descriptor, assurance }) => {
1223
1336
  const effective = assuranceEvidenceFor([...assurance, ...this.globalAssurance], method, path);
1224
1337
  return effective.length > 0 ? { ...descriptor, assurance: effective } : descriptor;
1225
1338
  });
@@ -1242,7 +1355,7 @@ export class Server {
1242
1355
  }
1243
1356
  fetchSourceInner(source, platform) {
1244
1357
  // Non-`async` on purpose: `dispatch` may return a `Response` *synchronously* (the bare-route fast
1245
- // path, see RouteEntry.bare), and an `async fetch` would wrap every such result in a redundant
1358
+ // path, selected by the compiled execution plan), and an `async fetch` would wrap every such result in a redundant
1246
1359
  // promise + microtask. Returning `Response | Promise<Response>` matches Web/edge handlers, while
1247
1360
  // `await app.fetch(...)` continues to work exactly as before.
1248
1361
  const outcome = this.dispatch(source, platform, toResponse, IDENTITY_RESPONSE, RESPONSE_TIMEOUT, true);
@@ -1332,7 +1445,7 @@ export class Server {
1332
1445
  ? response.then((settled) => ({ kind: "response", response: settled }))
1333
1446
  : { kind: "response", response };
1334
1447
  }
1335
- // May resolve **synchronously** for a bare route + sync handler (RouteEntry.bare) — the `@nifrajs/node`
1448
+ // May resolve **synchronously** for a compiled bare route + sync handler - the `@nifrajs/node`
1336
1449
  // adapter `await`s the result, so it transparently handles either; the sync case allocates no promise
1337
1450
  // at all on the Node hot path.
1338
1451
  return this.dispatch(source, platform, toNodeOutcome, NODE_FROM_RESPONSE, NODE_TIMEOUT, false);
@@ -1356,7 +1469,7 @@ export class Server {
1356
1469
  return WS_PASS; // upgrade header, no WS route here → normal routing decides
1357
1470
  // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
1358
1471
  // already did and made every plain dynamic route pay for unrelated static path bytes.
1359
- const params = match.params === EMPTY_PARAMS ? match.params : decodeParams(match.params);
1472
+ const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params);
1360
1473
  if (params === null)
1361
1474
  return { kind: "reject", response: jsonError(400, "malformed_path") };
1362
1475
  const handler = match.payload.handler;
@@ -1495,12 +1608,12 @@ export class Server {
1495
1608
  }
1496
1609
  /**
1497
1610
  * Route → build context → run. Synchronous through to the handler for a **bare** route
1498
- * ({@link RouteEntry.bare}), so a sync handler produces its result with zero promise allocations;
1611
+ * (selected by its compiled plan), so a sync handler produces its result with zero promise allocations;
1499
1612
  * routes with validation/hooks keep the full async {@link runLifecycle}, unchanged.
1500
1613
  */
1501
1614
  routeAndRun(source, platform, finalize, wrapResponse, onTimeout, webFast) {
1502
1615
  const url = urlPartsOf(source.url);
1503
- const match = this.router.find(source.method, url.pathname);
1616
+ const match = this.catalog.find(source.method, url.pathname);
1504
1617
  if (!match.found) {
1505
1618
  if (match.reason === "method-not-allowed") {
1506
1619
  return wrapResponse(jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }));
@@ -1509,7 +1622,7 @@ export class Server {
1509
1622
  }
1510
1623
  // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
1511
1624
  // already did and made every plain dynamic route pay for unrelated static path bytes.
1512
- const params = match.params === EMPTY_PARAMS ? match.params : decodeParams(match.params);
1625
+ const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params);
1513
1626
  if (params === null) {
1514
1627
  return wrapResponse(jsonError(400, "malformed_path"));
1515
1628
  }
@@ -1627,7 +1740,7 @@ export class Server {
1627
1740
  }
1628
1741
  return wrapResponse(response);
1629
1742
  }
1630
- /** Run a matched route through its selected execution lane (fused/bare/bodyOnly/queryOnly/lifecycle). */
1743
+ /** Supply request-specific deadline state to the route's precompiled execution plan. */
1631
1744
  runMatchedLanes(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
1632
1745
  // Translate the absolute wire deadline once, clamp it to local policy, then use the resulting
1633
1746
  // duration for both c.signal and c.budget. A client can only shorten work, never extend it.
@@ -1661,62 +1774,10 @@ export class Server {
1661
1774
  const budget = controller === undefined
1662
1775
  ? getUnboundedRequestBudget()
1663
1776
  : createRequestBudget({ deadline: admission.deadline, signal });
1664
- // A bare route runs synchronously (no schema/derives/hooks); everything else keeps the full async
1665
- // lifecycle. `runBare` returns a value directly for a sync handler — no promise.
1666
- let outcome;
1667
- if (webFast && entry.fusedWeb !== undefined) {
1668
- // Fused Web lane (bare route, no around): one closure end to end. Sound cast — webFast is
1669
- // passed only by the `fetch` path, where T = Response by construction.
1670
- outcome = entry.fusedWeb(source, params, search, signal, budget, platform, false);
1671
- }
1672
- else if (entry.contextlessBare) {
1673
- outcome = this.runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
1674
- }
1675
- else {
1676
- // Build the context up front so `onError` always has it. Query, cookies, headers, and body helpers
1677
- // stay lazy, but their accessors live on the prototype instead of allocating per-request closures.
1678
- const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
1679
- // Ledgered routes (capabilities + effectLedger) carry a bounded per-request ledger; the
1680
- // `useCapability` beacon appends to it, and the settle step below seals + sinks it. The route
1681
- // was forced off the fused/contextless lanes at registration, so this branch always runs.
1682
- let ledger;
1683
- if (entry.ledgered !== undefined) {
1684
- ledger = createRequestLedger({
1685
- method: entry.ledgered.method,
1686
- path: entry.ledgered.path,
1687
- declared: entry.ledgered.declared,
1688
- maxEntries: entry.ledgered.maxEntries,
1689
- chain: entry.ledgered.chain,
1690
- });
1691
- attachEffectLedger(ctx, ledger);
1692
- }
1693
- if (entry.around.length === 0) {
1694
- outcome = entry.bare
1695
- ? this.runBare(entry, ctx, finalize, wrapResponse)
1696
- : entry.bodyOnly
1697
- ? this.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
1698
- : entry.queryOnly
1699
- ? this.runQueryOnly(entry, ctx, finalize, wrapResponse)
1700
- : this.runLifecycle(entry, source, ctx, finalize, wrapResponse);
1701
- }
1702
- else {
1703
- outcome = this.runWithAround(entry, ctx, () => entry.bare
1704
- ? this.runBare(entry, ctx, finalize, wrapResponse)
1705
- : entry.bodyOnly
1706
- ? this.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
1707
- : entry.queryOnly
1708
- ? this.runQueryOnly(entry, ctx, finalize, wrapResponse)
1709
- : this.runLifecycle(entry, source, ctx, finalize, wrapResponse), finalize, wrapResponse);
1710
- }
1711
- // Settle step: seal the ledger and hand it to the sink BEFORE the response is released, on
1712
- // success and error responses alike (partial work is exactly what an audit must capture). The
1713
- // lanes above resolve handler errors to responses, so a plain resolve arm is sufficient.
1714
- if (ledger !== undefined) {
1715
- const resolved = entry.ledgered;
1716
- const active = ledger;
1717
- outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) => this.settleEffectLedger(active, resolved, value));
1718
- }
1719
- }
1777
+ const plan = entry.execution;
1778
+ const outcome = webFast && plan.fusedWeb !== undefined
1779
+ ? plan.fusedWeb(source, params, search, signal, budget, platform, false)
1780
+ : plan.run(this, entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
1720
1781
  // The request timeout only bounds work that is actually pending — a synchronous (bare) result is
1721
1782
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
1722
1783
  if (controller !== undefined && outcome instanceof Promise) {
@@ -1775,7 +1836,7 @@ export class Server {
1775
1836
  return wrapResponse(jsonError(500, "internal_error"));
1776
1837
  }
1777
1838
  /**
1778
- * The synchronous fast path for a {@link RouteEntry.bare} route: apply static decorations, call the
1839
+ * The synchronous fast path selected by a route's execution plan: apply static decorations, call the
1779
1840
  * handler, render the result — **no `await`** unless the handler itself returns a promise. It mirrors
1780
1841
  * the bare slice of {@link runLifecycle} (which a bare route would otherwise no-op through) and shares
1781
1842
  * {@link logRequestError}; a bare route has no `onError` hooks, so error handling is fully synchronous
@@ -1801,14 +1862,13 @@ export class Server {
1801
1862
  /** Bare-route error rendering — identical to {@link runLifecycle}'s catch minus the (absent) onError
1802
1863
  * loop: a thrown `Response` is returned as deliberate control flow; anything else is logged + 500. */
1803
1864
  /**
1804
- * Build a route's fused Web lane (see {@link RouteEntry.fusedWeb}). Composition happens once at
1865
+ * Build a route's fused Web renderer. Composition happens once at
1805
1866
  * registration; the returned closure is what every request to the route runs. Behavior is
1806
1867
  * byte-identical to the generic `runBare`/`runContextlessBare` + `toResponse` pair — same
1807
1868
  * decoration order, same error routing (thrown `Response` = control flow; anything else logs and
1808
1869
  * 500s), same respond semantics (the lifecycle parity suite pins it).
1809
1870
  */
1810
1871
  buildFusedWeb(handler, decorations, contextless) {
1811
- const maxBodyBytes = this.maxBodyBytes;
1812
1872
  const logError = (err, ctx) => {
1813
1873
  if (err instanceof Response)
1814
1874
  return err;
@@ -1826,21 +1886,21 @@ export class Server {
1826
1886
  }
1827
1887
  catch (err) {
1828
1888
  return logError(err, nativeContext
1829
- ? RequestContext.native(source, params, maxBodyBytes)
1830
- : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes));
1889
+ ? RequestContext.native(source, params, this.maxBodyBytes)
1890
+ : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes));
1831
1891
  }
1832
1892
  if (result instanceof Promise) {
1833
1893
  return result.then(fusedRespondNoSet, (err) => logError(err, nativeContext
1834
- ? RequestContext.native(source, params, maxBodyBytes)
1835
- : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes)));
1894
+ ? RequestContext.native(source, params, this.maxBodyBytes)
1895
+ : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)));
1836
1896
  }
1837
1897
  return fusedRespondNoSet(result);
1838
1898
  };
1839
1899
  }
1840
1900
  return (source, params, search, signal, budget, platform, nativeContext) => {
1841
1901
  const ctx = nativeContext
1842
- ? RequestContext.native(source, params, maxBodyBytes)
1843
- : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes);
1902
+ ? RequestContext.native(source, params, this.maxBodyBytes)
1903
+ : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
1844
1904
  if (decorations !== undefined)
1845
1905
  Object.assign(ctx, decorations);
1846
1906
  let result;
@@ -2180,7 +2240,7 @@ export class Server {
2180
2240
  async runLifecycle(entry, source, ctx, finalize, wrapResponse) {
2181
2241
  try {
2182
2242
  if (entry.schema?.body !== undefined) {
2183
- const bodyError = await this.readAndValidateBody(source, entry.schema.body, ctx);
2243
+ const bodyError = await this.readAndValidateBody(source, entry, ctx);
2184
2244
  if (bodyError !== undefined)
2185
2245
  return wrapResponse(bodyError);
2186
2246
  }
@@ -2190,9 +2250,9 @@ export class Server {
2190
2250
  // validateStandard wrapper alloc.
2191
2251
  const validation = entry.schema.query["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
2192
2252
  const result = validation instanceof Promise ? await validation : validation;
2193
- if (result.issues !== undefined)
2194
- return wrapResponse(validationError(result.issues));
2195
- ctx.query = result.value;
2253
+ const queryError = await this.applyLifecycleValidation(entry, result, ctx, "query");
2254
+ if (queryError !== undefined)
2255
+ return wrapResponse(queryError);
2196
2256
  }
2197
2257
  // Context extensions: static decorations, then per-request derives.
2198
2258
  // Each hook below only awaits when it actually returns a Promise — a sync hook
@@ -2262,7 +2322,7 @@ export class Server {
2262
2322
  stack: err instanceof Error ? err.stack : undefined,
2263
2323
  });
2264
2324
  }
2265
- async readAndValidateBody(req, schema, ctx) {
2325
+ async readAndValidateBody(req, entry, ctx) {
2266
2326
  const contentType = headerOf(req, "content-type") ?? "";
2267
2327
  let parsed;
2268
2328
  if (contentType === "application/json" || contentType.includes("application/json")) {
@@ -2282,11 +2342,38 @@ export class Server {
2282
2342
  // fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
2283
2343
  return jsonError(415, "unsupported_media_type");
2284
2344
  }
2285
- const validation = schema["~standard"].validate(parsed);
2345
+ const validation = entry.schema.body["~standard"].validate(parsed);
2286
2346
  const result = validation instanceof Promise ? await validation : validation;
2287
- if (result.issues !== undefined)
2347
+ return this.applyLifecycleValidation(entry, result, ctx, "body");
2348
+ }
2349
+ /** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
2350
+ * before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
2351
+ async applyLifecycleValidation(entry, result, ctx, kind) {
2352
+ if (result.issues === undefined) {
2353
+ if (kind === "body")
2354
+ ctx.body = result.value;
2355
+ else
2356
+ ctx.query = result.value;
2357
+ return undefined;
2358
+ }
2359
+ const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError;
2360
+ if (hook === undefined)
2288
2361
  return validationError(result.issues);
2289
- ctx.body = result.value;
2362
+ const attempted = hook(result.issues, ctx, kind);
2363
+ const recovery = attempted instanceof Promise ? await attempted : attempted;
2364
+ if (recovery === undefined)
2365
+ return validationError(result.issues);
2366
+ if (recovery instanceof Response)
2367
+ return recovery;
2368
+ const schema = kind === "body" ? entry.schema?.body : entry.schema?.query;
2369
+ const retried = schema["~standard"].validate(recovery);
2370
+ const settled = retried instanceof Promise ? await retried : retried;
2371
+ if (settled.issues !== undefined)
2372
+ return validationError(settled.issues);
2373
+ if (kind === "body")
2374
+ ctx.body = settled.value;
2375
+ else
2376
+ ctx.query = settled.value;
2290
2377
  return undefined;
2291
2378
  }
2292
2379
  /**
@@ -2306,6 +2393,48 @@ export class Server {
2306
2393
  async readBoundedJson(req) {
2307
2394
  return readBoundedJsonSource(req, this.maxBodyBytes);
2308
2395
  }
2396
+ /** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
2397
+ * semantics remain in the plan; this closure only supplies native params and deadline fallback. */
2398
+ compileBunNativeHandler(entry, paramNames, fused, signal, budget) {
2399
+ if (paramNames.length === 0) {
2400
+ if (fused === undefined) {
2401
+ return (request) => this.fetchMatched(request, entry, EMPTY_PARAMS);
2402
+ }
2403
+ if (this.acceptInboundDeadlines) {
2404
+ return (request) => request.headers.get(NIFRA_DEADLINE_HEADER) !== null
2405
+ ? this.fetchMatched(request, entry, EMPTY_PARAMS)
2406
+ : fused(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
2407
+ }
2408
+ return (request) => fused(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
2409
+ }
2410
+ const malformed = paramNames.length === 1
2411
+ ? (params) => params[paramNames[0]]?.includes("\uFFFD") === true
2412
+ : hasReplacementParam;
2413
+ if (fused === undefined) {
2414
+ return (request) => {
2415
+ const params = request.params ?? EMPTY_PARAMS;
2416
+ if (malformed(params))
2417
+ return this.fetchSource(request);
2418
+ return this.fetchMatched(request, entry, params);
2419
+ };
2420
+ }
2421
+ if (this.acceptInboundDeadlines) {
2422
+ return (request) => {
2423
+ const params = request.params ?? EMPTY_PARAMS;
2424
+ if (malformed(params))
2425
+ return this.fetchSource(request);
2426
+ return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
2427
+ ? this.fetchMatched(request, entry, params)
2428
+ : fused(request, params, undefined, signal, budget, undefined, true);
2429
+ };
2430
+ }
2431
+ return (request) => {
2432
+ const params = request.params ?? EMPTY_PARAMS;
2433
+ if (malformed(params))
2434
+ return this.fetchSource(request);
2435
+ return fused(request, params, undefined, signal, budget, undefined, true);
2436
+ };
2437
+ }
2309
2438
  /** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
2310
2439
  * hooks or WebSockets retain the single portable dispatcher because those features must run before
2311
2440
  * route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
@@ -2323,119 +2452,17 @@ export class Server {
2323
2452
  const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined;
2324
2453
  const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined;
2325
2454
  let count = 0;
2326
- for (const { method, path, entry } of this.rawEntries) {
2327
- if (path.includes("*"))
2455
+ for (const { method, path, pattern, entry } of this.catalog.entries()) {
2456
+ if (pattern.segments.some((segment) => segment.kind === "wildcard"))
2328
2457
  continue;
2329
2458
  let methods = routes[path];
2330
2459
  if (methods === undefined) {
2331
2460
  methods = Object.create(null);
2332
2461
  routes[path] = methods;
2333
2462
  }
2334
- const paramNames = path
2335
- .split("/")
2336
- .filter((segment) => segment.charCodeAt(0) === 58 /* : */)
2337
- .map((segment) => segment.slice(1));
2338
- const fused = mayUseFusedNative ? entry.fusedWeb : undefined;
2339
- if (fused !== undefined) {
2340
- // Deliberately choose deadline-aware vs zero-admission closures HERE instead of branching on
2341
- // `acceptInboundDeadlines` per request. JSC can then flatten the default closure through the
2342
- // handler + Response.json; retaining that route-invariant branch cost ~7% on a dynamic GET.
2343
- if (paramNames.length === 0) {
2344
- methods[method] = this.acceptInboundDeadlines
2345
- ? (request) => request.headers.get(NIFRA_DEADLINE_HEADER) !== null
2346
- ? this.fetchMatched(request, entry, EMPTY_PARAMS)
2347
- : fused(request, EMPTY_PARAMS, undefined, unboundedSignal, unboundedBudget, undefined, true)
2348
- : (request) => fused(request, EMPTY_PARAMS, undefined, unboundedSignal, unboundedBudget, undefined, true);
2349
- }
2350
- else if (paramNames.length === 1) {
2351
- const paramName = paramNames[0];
2352
- const handler = entry.handler;
2353
- const decorations = entry.hasDecorations ? entry.decorations : undefined;
2354
- const fail = (error, ctx) => {
2355
- if (error instanceof Response)
2356
- return error;
2357
- this.logRequestError(error, ctx);
2358
- return jsonError(500, "internal_error");
2359
- };
2360
- methods[method] = this.acceptInboundDeadlines
2361
- ? (request) => {
2362
- const params = request.params ?? EMPTY_PARAMS;
2363
- if (params[paramName]?.includes("\uFFFD") === true)
2364
- return this.fetchSource(request);
2365
- if (request.headers.get(NIFRA_DEADLINE_HEADER) !== null) {
2366
- return this.fetchMatched(request, entry, params);
2367
- }
2368
- const ctx = RequestContext.native(request, params, this.maxBodyBytes);
2369
- if (decorations !== undefined)
2370
- Object.assign(ctx, decorations);
2371
- let result;
2372
- try {
2373
- result = handler(ctx);
2374
- }
2375
- catch (error) {
2376
- return fail(error, ctx);
2377
- }
2378
- return result instanceof Promise
2379
- ? result.then((value) => fusedRespond(value, ctx), (error) => fail(error, ctx))
2380
- : fusedRespond(result, ctx);
2381
- }
2382
- : (request) => {
2383
- const params = request.params ?? EMPTY_PARAMS;
2384
- if (params[paramName]?.includes("\uFFFD") === true)
2385
- return this.fetchSource(request);
2386
- const ctx = RequestContext.native(request, params, this.maxBodyBytes);
2387
- if (decorations !== undefined)
2388
- Object.assign(ctx, decorations);
2389
- let result;
2390
- try {
2391
- result = handler(ctx);
2392
- }
2393
- catch (error) {
2394
- return fail(error, ctx);
2395
- }
2396
- return result instanceof Promise
2397
- ? result.then((value) => fusedRespond(value, ctx), (error) => fail(error, ctx))
2398
- : fusedRespond(result, ctx);
2399
- };
2400
- }
2401
- else {
2402
- methods[method] = this.acceptInboundDeadlines
2403
- ? (request) => {
2404
- const params = request.params ?? EMPTY_PARAMS;
2405
- if (hasReplacementParam(params))
2406
- return this.fetchSource(request);
2407
- return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
2408
- ? this.fetchMatched(request, entry, params)
2409
- : fused(request, params, undefined, unboundedSignal, unboundedBudget, undefined, true);
2410
- }
2411
- : (request) => {
2412
- const params = request.params ?? EMPTY_PARAMS;
2413
- if (hasReplacementParam(params))
2414
- return this.fetchSource(request);
2415
- return fused(request, params, undefined, unboundedSignal, unboundedBudget, undefined, true);
2416
- };
2417
- }
2418
- }
2419
- else if (paramNames.length === 0) {
2420
- methods[method] = (request) => this.fetchMatched(request, entry, EMPTY_PARAMS);
2421
- }
2422
- else if (paramNames.length === 1) {
2423
- const paramName = paramNames[0];
2424
- methods[method] = (request) => {
2425
- const params = request.params ?? EMPTY_PARAMS;
2426
- if (params[paramName]?.includes("\uFFFD") === true)
2427
- return this.fetchSource(request);
2428
- return this.fetchMatched(request, entry, params);
2429
- };
2430
- }
2431
- else {
2432
- methods[method] = (request) => {
2433
- const params = request.params ?? EMPTY_PARAMS;
2434
- if (hasReplacementParam(params))
2435
- return this.fetchSource(request);
2436
- return this.fetchMatched(request, entry, params);
2437
- };
2438
- }
2463
+ const paramNames = pattern.paramNames;
2464
+ const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined;
2465
+ methods[method] = this.compileBunNativeHandler(entry, paramNames, fused, unboundedSignal, unboundedBudget);
2439
2466
  count += 1;
2440
2467
  }
2441
2468
  return count === 0 ? undefined : routes;
@@ -2507,6 +2534,7 @@ export class Server {
2507
2534
  },
2508
2535
  }));
2509
2536
  this.bunServer = running;
2537
+ this.sealed = true;
2510
2538
  if (this.gracefulSignals)
2511
2539
  this.installSignalHandlers();
2512
2540
  return running;