@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.
@@ -40,7 +40,12 @@ import {
40
40
  type EffectLedgerOptions,
41
41
  type RequestLedger,
42
42
  } from "../ledger.ts"
43
- import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
43
+ import {
44
+ type CompiledRoutePattern,
45
+ compileRoutePattern,
46
+ decodeRouteParams,
47
+ } from "../router/pattern.ts"
48
+ import { EMPTY_PARAMS, type Method, Router, type RouterMatch } from "../router/router.ts"
44
49
  import type {
45
50
  InferOutput,
46
51
  StandardIssue,
@@ -199,6 +204,35 @@ interface ResolvedEffectLedger {
199
204
  readonly declared: readonly string[]
200
205
  }
201
206
 
207
+ type RouteExecutionRunner = <T, R extends Registry, Ctx>(
208
+ runtime: Server<R, Ctx>,
209
+ entry: RouteEntry,
210
+ source: RequestSource,
211
+ params: Record<string, string>,
212
+ search: string | undefined,
213
+ signal: AbortSignal,
214
+ budget: RequestBudget,
215
+ platform: Platform | undefined,
216
+ finalize: (result: unknown, set: CtxSet) => T,
217
+ wrapResponse: (response: Response) => T,
218
+ ) => MaybePromise<T>
219
+
220
+ type ContextRouteRunner = <T, R extends Registry, Ctx>(
221
+ runtime: Server<R, Ctx>,
222
+ entry: RouteEntry,
223
+ source: RequestSource,
224
+ ctx: RawContext,
225
+ finalize: (result: unknown, set: CtxSet) => T,
226
+ wrapResponse: (response: Response) => T,
227
+ ) => MaybePromise<T>
228
+
229
+ /** Registration-compiled route behavior. Every adapter invokes the same runner; the optional fused
230
+ * renderer is only a response-format specialization of that same selected route semantics. */
231
+ interface RouteExecutionPlan {
232
+ readonly run: RouteExecutionRunner
233
+ readonly fusedWeb: FusedWebRunner | undefined
234
+ }
235
+
202
236
  interface RouteEntry {
203
237
  readonly handler: InternalHandler
204
238
  readonly schema: RouteSchema | undefined
@@ -219,33 +253,75 @@ interface RouteEntry {
219
253
  readonly onError: ReadonlyArray<RawErrorHandler>
220
254
  /** Wraps the matched route lifecycle. Empty for the common no-around path. */
221
255
  readonly around: ReadonlyArray<RawAround>
222
- /**
223
- * Precomputed: the route has no body/query schema and no derive/beforeHandle/afterHandle/onError
224
- * hooks — so its lifecycle reduces to `finalize(handler(ctx), set)` with simple error handling, no
225
- * `await` unless the handler itself is async. Such routes take the **synchronous fast path**
226
- * ({@link Server.runBare}), which skips the `async` machinery of the full {@link Server.runLifecycle}
227
- * (≈2 promise frames/req — the per-request tax codegen routers avoid; nifra avoids it here without
228
- * `eval`, so it stays edge-safe). Static decorations are still applied (they're a sync `Object.assign`).
229
- */
230
- readonly bare: boolean
231
- /**
232
- * A narrower bare-route path for syntactic zero-parameter arrow handlers (`() => ...`). Unlike
233
- * `handler.length === 0`, this intentionally excludes `function () { arguments }`, rest params,
234
- * default params, bound/native functions, and anything else that can observe the passed context.
235
- */
236
- readonly contextlessBare: boolean
237
- /** Precomputed body-schema-only path: JSON body validation + handler, no other lifecycle hooks. */
238
- readonly bodyOnly: boolean
239
- /** Precomputed query-schema-only path: query validation + handler, no other lifecycle hooks. */
240
- readonly queryOnly: boolean
241
- /**
242
- * Registration-time-fused Web lane for a {@link bare} route with no `around` hooks: context (when
243
- * the handler can observe one), handler, and the JSON respond collapsed into ONE monomorphic
244
- * closure — no per-request branch ladder, no `responseSet` lookup, no codegen (`new Function`
245
- * measured equivalent to closures on JSC). Only the Web finalizer
246
- * (`fetch`) uses it — the node-direct path keeps the generic lifecycle.
247
- */
248
- readonly fusedWeb: FusedWebRunner | undefined
256
+ /** The single immutable execution decision consumed by portable, Node-direct, and Bun-native paths. */
257
+ readonly execution: RouteExecutionPlan
258
+ }
259
+
260
+ /** One canonical runtime route fact. The catalog owns matching, reflection, assurance, tool metadata,
261
+ * replay, and native compilation input so batch registration has one commit point. */
262
+ interface CatalogRoute {
263
+ readonly method: Method
264
+ readonly path: string
265
+ readonly pattern: CompiledRoutePattern
266
+ readonly entry: RouteEntry
267
+ readonly descriptor: RouteDescriptor
268
+ readonly assurance: readonly AssuranceDeclaration[]
269
+ }
270
+
271
+ /**
272
+ * Runtime route catalog. Single-route registration mutates directly; multi-route registration replays
273
+ * the existing catalog plus the candidate batch into a staged router, then swaps the complete state only
274
+ * after every route validates. Failed `implement()`/`merge()` batches therefore leave matching and
275
+ * reflection unchanged.
276
+ */
277
+ class RouteCatalog {
278
+ private matcher = new Router<RouteEntry>()
279
+ private records: CatalogRoute[] = []
280
+ /** Allocation-free reflection view for the common no-assurance case. Derived only at commit time. */
281
+ private descriptors: RouteDescriptor[] = []
282
+ private assurancePresent = false
283
+
284
+ add(route: CatalogRoute): void {
285
+ this.matcher.add(route.method, route.pattern, route.entry)
286
+ this.records.push(route)
287
+ this.descriptors.push(route.descriptor)
288
+ if (route.assurance.length > 0) this.assurancePresent = true
289
+ }
290
+
291
+ addBatch(routes: readonly CatalogRoute[]): void {
292
+ if (routes.length === 0) return
293
+ const nextRecords = this.records.concat(routes)
294
+ const nextDescriptors = this.descriptors.concat(routes.map(({ descriptor }) => descriptor))
295
+ const nextAssurancePresent =
296
+ this.assurancePresent || routes.some((route) => route.assurance.length > 0)
297
+ const staged = new Router<RouteEntry>()
298
+ for (const route of this.records) staged.add(route.method, route.pattern, route.entry)
299
+ for (const route of routes) staged.add(route.method, route.pattern, route.entry)
300
+ this.matcher = staged
301
+ this.records = nextRecords
302
+ this.descriptors = nextDescriptors
303
+ this.assurancePresent = nextAssurancePresent
304
+ }
305
+
306
+ find(method: string, path: string): RouterMatch<RouteEntry> {
307
+ return this.matcher.find(method, path)
308
+ }
309
+
310
+ entries(): readonly CatalogRoute[] {
311
+ return this.records
312
+ }
313
+
314
+ routeDescriptors(): ReadonlyArray<RouteDescriptor> {
315
+ return this.descriptors
316
+ }
317
+
318
+ lastDescriptor(): RouteDescriptor | undefined {
319
+ return this.records[this.records.length - 1]?.descriptor
320
+ }
321
+
322
+ hasAssurance(): boolean {
323
+ return this.assurancePresent
324
+ }
249
325
  }
250
326
 
251
327
  /** The fused Web lane: same inputs `routeAndRun` would hand the generic path, a `Response` out. */
@@ -793,27 +869,6 @@ async function readBoundedForm(
793
869
  return out
794
870
  }
795
871
 
796
- function decodeParams(raw: Record<string, string>): Record<string, string> | null {
797
- let out: Record<string, string> | undefined
798
- for (const key in raw) {
799
- const value = raw[key]!
800
- // `decodeURIComponent` only changes strings containing `%`; for the common
801
- // unencoded param (`/users/123`) skip the call and its throw-check entirely —
802
- // semantically identical (no `%` ⇒ no escapes to decode), ~3× cheaper on that path. If every
803
- // param is already plain, return the router's per-request object directly and skip a clone.
804
- if (!value.includes("%")) {
805
- continue
806
- }
807
- try {
808
- out ??= { ...raw }
809
- out[key] = decodeURIComponent(value)
810
- } catch {
811
- return null
812
- }
813
- }
814
- return out ?? raw
815
- }
816
-
817
872
  function hasReplacementParam(params: Record<string, string>): boolean {
818
873
  for (const key in params) {
819
874
  if (params[key]!.includes("\uFFFD")) return true
@@ -1289,7 +1344,7 @@ function appendCookiesToNodeHeaders(
1289
1344
  * .get("/me", (c) => c.user) // c.user + c.db are typed
1290
1345
  */
1291
1346
  export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1292
- private readonly router: Router<RouteEntry>
1347
+ private readonly catalog: RouteCatalog
1293
1348
  /** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
1294
1349
  private readonly wsRouter: Router<WsEntry>
1295
1350
  private wsRouteCount: number
@@ -1297,7 +1352,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1297
1352
  * Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime — `undefined` until then, so a
1298
1353
  * no-WebSocket app never constructs (or bundles) it. */
1299
1354
  private topics: TopicRegistry | undefined
1300
- private readonly routeList: RouteDescriptor[]
1301
1355
  private readonly maxBodyBytes: number
1302
1356
  private readonly wsMaxPayloadBytes: number
1303
1357
  private readonly requestTimeoutMs: number
@@ -1319,6 +1373,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1319
1373
  /** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
1320
1374
  private readonly defaultOnValidationError?: RouteSchema["onValidationError"]
1321
1375
  private bunServer: RunningServer | undefined
1376
+ private sealed: boolean
1322
1377
  private readonly derives: RawDerive[]
1323
1378
  private readonly decorations: Record<string, unknown>
1324
1379
  private readonly beforeHandleHooks: RawBeforeHandle[]
@@ -1338,26 +1393,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1338
1393
  /** App-declared MCP resources / prompts (via {@link resource} / {@link prompt}), read by `nifra mcp`. */
1339
1394
  private readonly mcpResourceList: McpResourceDescriptor[]
1340
1395
  private readonly mcpPromptList: McpPromptDescriptor[]
1341
- /**
1342
- * Every registered route's raw router entry + descriptor, in order — the replay source for
1343
- * {@link merge}. The entry already carries the chains captured at registration (derives,
1344
- * decorations, before/after/around/onError), so a merged route behaves exactly as it did on
1345
- * the server that defined it.
1346
- */
1347
- private readonly rawEntries: Array<{
1348
- readonly method: Method
1349
- readonly path: string
1350
- readonly entry: RouteEntry
1351
- readonly descriptor: RouteDescriptor
1352
- readonly assurance: readonly AssuranceDeclaration[]
1353
- }>
1354
-
1355
1396
  constructor(options: ServerOptions = {}) {
1356
- this.router = new Router<RouteEntry>()
1397
+ this.catalog = new RouteCatalog()
1357
1398
  this.wsRouter = new Router<WsEntry>()
1358
1399
  this.wsRouteCount = 0
1359
1400
  this.topics = undefined
1360
- this.routeList = []
1361
1401
  this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
1362
1402
  this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes
1363
1403
  this.requestTimeoutMs = options.requestTimeoutMs ?? 0
@@ -1398,6 +1438,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1398
1438
  this.logger = options.logger ?? jsonLogger()
1399
1439
  this.defaultOnValidationError = options.onValidationError
1400
1440
  this.bunServer = undefined
1441
+ this.sealed = false
1401
1442
  this.derives = []
1402
1443
  this.decorations = {}
1403
1444
  this.beforeHandleHooks = []
@@ -1413,17 +1454,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1413
1454
  this.globalAssurance = []
1414
1455
  this.mcpResourceList = []
1415
1456
  this.mcpPromptList = []
1416
- this.rawEntries = []
1457
+ }
1458
+
1459
+ private assertConfigurable(operation: string): void {
1460
+ if (this.sealed) {
1461
+ throw new FrameworkError(
1462
+ "SERVER_SEALED",
1463
+ `server configuration is sealed after listen(); call ${operation} before listen()`,
1464
+ )
1465
+ }
1417
1466
  }
1418
1467
 
1419
1468
  /** Add a per-request, computed context extension for subsequent routes. */
1420
1469
  derive<D extends object>(fn: (context: Context & Ctx) => MaybePromise<D>): Server<R, Ctx & D> {
1470
+ this.assertConfigurable("derive()")
1421
1471
  this.derives.push(fn as unknown as RawDerive)
1422
1472
  return this as unknown as Server<R, Ctx & D>
1423
1473
  }
1424
1474
 
1425
1475
  /** Add a static context value for subsequent routes. */
1426
1476
  decorate<const K extends string, V>(key: K, value: V): Server<R, Ctx & Record<K, V>> {
1477
+ this.assertConfigurable("decorate()")
1427
1478
  this.decorations[key] = value
1428
1479
  return this as unknown as Server<R, Ctx & Record<K, V>>
1429
1480
  }
@@ -1435,12 +1486,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1435
1486
  onRequest(
1436
1487
  fn: (req: Request, platform?: Platform<EnvOf<Ctx>>) => MaybePromise<OnRequestResult>,
1437
1488
  ): this {
1489
+ this.assertConfigurable("onRequest()")
1438
1490
  this.onRequestHooks.push(fn as RawOnRequest)
1439
1491
  return this
1440
1492
  }
1441
1493
 
1442
1494
  /** Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped. */
1443
1495
  beforeHandle(fn: (context: Context & Ctx) => MaybePromise<unknown>): this {
1496
+ this.assertConfigurable("beforeHandle()")
1444
1497
  this.beforeHandleHooks.push(fn as unknown as RawBeforeHandle)
1445
1498
  return this
1446
1499
  }
@@ -1451,24 +1504,28 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1451
1504
  * a Web `Response`. The first registered wrapper is outermost.
1452
1505
  */
1453
1506
  around(fn: <T>(context: Context & Ctx, next: () => MaybePromise<T>) => MaybePromise<T>): this {
1507
+ this.assertConfigurable("around()")
1454
1508
  this.aroundHooks.push(fn as unknown as RawAround)
1455
1509
  return this
1456
1510
  }
1457
1511
 
1458
1512
  /** Transform the handler's result before it is serialized. Order-scoped. */
1459
1513
  afterHandle(fn: (result: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
1514
+ this.assertConfigurable("afterHandle()")
1460
1515
  this.afterHandleHooks.push(fn as unknown as RawAfterHandle)
1461
1516
  return this
1462
1517
  }
1463
1518
 
1464
1519
  /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500). Order-scoped. */
1465
1520
  onError(fn: (error: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
1521
+ this.assertConfigurable("onError()")
1466
1522
  this.onErrorHooks.push(fn as unknown as RawErrorHandler)
1467
1523
  return this
1468
1524
  }
1469
1525
 
1470
1526
  /** Transform every outgoing response — success, error, 404, 405, short-circuit. Global. */
1471
1527
  onResponse(fn: (response: Response, req: Request) => MaybePromise<Response>): this {
1528
+ this.assertConfigurable("onResponse()")
1472
1529
  this.onResponseHooks.push(fn)
1473
1530
  return this
1474
1531
  }
@@ -1477,6 +1534,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1477
1534
  onResponseFinalized(
1478
1535
  fn: (outcome: ResponseFinalization, req: Request) => MaybePromise<void>,
1479
1536
  ): this {
1537
+ this.assertConfigurable("onResponseFinalized()")
1480
1538
  this.onResponseFinalizedHooks.push(fn)
1481
1539
  return this
1482
1540
  }
@@ -1504,6 +1562,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1504
1562
  */
1505
1563
  use(mw: Middleware): this
1506
1564
  use(arg: Middleware | ((app: this) => AnyServer)): AnyServer {
1565
+ this.assertConfigurable("use()")
1507
1566
  if (typeof arg === "function") {
1508
1567
  const name = (arg as { pluginName?: string }).pluginName
1509
1568
  if (name !== undefined) {
@@ -1720,7 +1779,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1720
1779
  this.register("POST", path, routeSchema, run as (context: never) => unknown)
1721
1780
  // Tag the just-registered descriptor as an MCP tool. `tool` is readonly on RouteDescriptor (an
1722
1781
  // introspection field), so write it through a narrow mutable view — not `any`.
1723
- const lastRoute = this.routeList[this.routeList.length - 1]
1782
+ const lastRoute = this.catalog.lastDescriptor()
1724
1783
  if (lastRoute) {
1725
1784
  ;(lastRoute as { tool?: RouteDescriptor["tool"] }).tool = {
1726
1785
  name,
@@ -1742,6 +1801,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1742
1801
  config: { readonly name: string; readonly description?: string; readonly mimeType?: string },
1743
1802
  read: McpResourceDescriptor["read"],
1744
1803
  ): Server<R, Ctx> {
1804
+ this.assertConfigurable("resource()")
1745
1805
  this.mcpResourceList.push({
1746
1806
  uri,
1747
1807
  name: config.name,
@@ -1761,6 +1821,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1761
1821
  config: { readonly description: string; readonly arguments?: readonly PromptArgument[] },
1762
1822
  handler: McpPromptDescriptor["handler"],
1763
1823
  ): Server<R, Ctx> {
1824
+ this.assertConfigurable("prompt()")
1764
1825
  this.mcpPromptList.push({
1765
1826
  name,
1766
1827
  description: config.description,
@@ -1793,6 +1854,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1793
1854
  path: string,
1794
1855
  handler: WebSocketHandler<Data, EnvOf<Ctx>, Schema>,
1795
1856
  ): this {
1857
+ this.assertConfigurable("ws()")
1796
1858
  // Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
1797
1859
  // bundle it. Registration is the loud, early failure point — never the first connection.
1798
1860
  const runtime = requireWsRuntime()
@@ -1848,6 +1910,34 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1848
1910
  schema: RouteSchema | undefined,
1849
1911
  handler: (context: never) => unknown,
1850
1912
  ): void {
1913
+ this.assertConfigurable("route registration")
1914
+ this.catalog.add(this.prepareRoute(method, path, schema, handler))
1915
+ }
1916
+
1917
+ /** Register a contract/group route batch atomically. Every route captures the same current chain it
1918
+ * would capture through {@link register}; no route becomes visible unless the full batch validates. */
1919
+ registerBatch(
1920
+ routes: readonly {
1921
+ readonly method: Method
1922
+ readonly path: string
1923
+ readonly schema: RouteSchema | undefined
1924
+ readonly handler: (context: never) => unknown
1925
+ }[],
1926
+ ): void {
1927
+ this.assertConfigurable("route registration")
1928
+ const staged = routes.map(({ method, path, schema, handler }) =>
1929
+ this.prepareRoute(method, path, schema, handler),
1930
+ )
1931
+ this.catalog.addBatch(staged)
1932
+ }
1933
+
1934
+ private prepareRoute(
1935
+ method: Method,
1936
+ path: string,
1937
+ schema: RouteSchema | undefined,
1938
+ handler: (context: never) => unknown,
1939
+ ): CatalogRoute {
1940
+ const pattern = compileRoutePattern(path)
1851
1941
  const capabilities = normalizeRouteCapabilities(schema?.capabilities)
1852
1942
  const handlerAssurance = assuranceDeclarationsOf(handler as unknown as object)
1853
1943
  const invalidHandlerScope = handlerAssurance.find(
@@ -1902,6 +1992,31 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1902
1992
  isContextlessNoArgArrow(handler),
1903
1993
  )
1904
1994
  : undefined
1995
+ const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler)
1996
+ const lane = bare
1997
+ ? "bare"
1998
+ : schema?.body !== undefined &&
1999
+ schema.query === undefined &&
2000
+ this.derives.length === 0 &&
2001
+ this.beforeHandleHooks.length === 0 &&
2002
+ this.afterHandleHooks.length === 0 &&
2003
+ this.onErrorHooks.length === 0
2004
+ ? "body"
2005
+ : schema?.body === undefined &&
2006
+ schema?.query !== undefined &&
2007
+ this.derives.length === 0 &&
2008
+ this.beforeHandleHooks.length === 0 &&
2009
+ this.afterHandleHooks.length === 0 &&
2010
+ this.onErrorHooks.length === 0
2011
+ ? "query"
2012
+ : "lifecycle"
2013
+ const execution = this.compileExecutionPlan(
2014
+ lane,
2015
+ contextless,
2016
+ this.aroundHooks.length > 0,
2017
+ ledgered !== undefined,
2018
+ fusedWeb,
2019
+ )
1905
2020
  const entry: RouteEntry = {
1906
2021
  // (context: never) => unknown -> InternalHandler: the framework invokes it
1907
2022
  // with the concrete RawContext the typed handler expects, so this is sound.
@@ -1909,7 +2024,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1909
2024
  schema,
1910
2025
  idempotent,
1911
2026
  ledgered,
1912
- fusedWeb,
1913
2027
  derives: [...this.derives],
1914
2028
  decorations: routeDecorations,
1915
2029
  hasDecorations,
@@ -1917,35 +2031,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1917
2031
  afterHandle: [...this.afterHandleHooks],
1918
2032
  onError: [...this.onErrorHooks],
1919
2033
  around: [...this.aroundHooks],
1920
- // Sync-fast-path eligibility (see RouteEntry.bare). Validation + every hook kind must be absent;
1921
- // decorations and generic around wrappers are fine: around wraps the inner bare route in
1922
- // `routeAndRun`, while the inner validation/derive/before/after/onError lifecycle remains empty.
1923
- // Most GET/no-schema routes qualify.
1924
- bare,
1925
- contextlessBare: bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler),
1926
- bodyOnly:
1927
- schema?.body !== undefined &&
1928
- schema.query === undefined &&
1929
- this.derives.length === 0 &&
1930
- this.beforeHandleHooks.length === 0 &&
1931
- this.afterHandleHooks.length === 0 &&
1932
- this.onErrorHooks.length === 0,
1933
- queryOnly:
1934
- schema?.body === undefined &&
1935
- schema?.query !== undefined &&
1936
- this.derives.length === 0 &&
1937
- this.beforeHandleHooks.length === 0 &&
1938
- this.afterHandleHooks.length === 0 &&
1939
- this.onErrorHooks.length === 0,
1940
- }
1941
- this.router.add(method, path, entry)
2034
+ execution,
2035
+ }
1942
2036
  const descriptor: RouteDescriptor = {
1943
2037
  method,
1944
2038
  path,
1945
2039
  schema,
1946
2040
  ...(capabilities.length > 0 ? { capabilities } : {}),
1947
2041
  }
1948
- this.routeList.push(descriptor)
1949
2042
  const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance, ...handlerAssurance]
1950
2043
  if (schema?.body !== undefined) {
1951
2044
  routeAssurance.push(
@@ -1967,13 +2060,124 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1967
2060
  }),
1968
2061
  )
1969
2062
  }
1970
- this.rawEntries.push({
2063
+ return {
1971
2064
  method,
1972
2065
  path,
2066
+ pattern,
1973
2067
  entry,
1974
2068
  descriptor,
1975
2069
  assurance: Object.freeze(routeAssurance),
1976
- })
2070
+ }
2071
+ }
2072
+
2073
+ /** Collapse route-invariant lifecycle decisions into one runner at registration. The request path
2074
+ * performs no eligibility ladder: it supplies request state to this already-selected plan. */
2075
+ private compileExecutionPlan(
2076
+ lane: "bare" | "body" | "query" | "lifecycle",
2077
+ contextless: boolean,
2078
+ hasAround: boolean,
2079
+ hasLedger: boolean,
2080
+ fusedWeb: FusedWebRunner | undefined,
2081
+ ): RouteExecutionPlan {
2082
+ if (contextless) {
2083
+ const run: RouteExecutionRunner = (
2084
+ runtime,
2085
+ entry,
2086
+ source,
2087
+ params,
2088
+ search,
2089
+ signal,
2090
+ budget,
2091
+ platform,
2092
+ finalize,
2093
+ wrapResponse,
2094
+ ) =>
2095
+ runtime.runContextlessBare(
2096
+ entry,
2097
+ source,
2098
+ params,
2099
+ search,
2100
+ signal,
2101
+ budget,
2102
+ platform,
2103
+ finalize,
2104
+ wrapResponse,
2105
+ )
2106
+ return Object.freeze({ run, fusedWeb })
2107
+ }
2108
+
2109
+ let inner: ContextRouteRunner
2110
+ switch (lane) {
2111
+ case "bare":
2112
+ inner = (runtime, entry, _source, ctx, finalize, wrapResponse) =>
2113
+ runtime.runBare(entry, ctx, finalize, wrapResponse)
2114
+ break
2115
+ case "body":
2116
+ inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
2117
+ runtime.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
2118
+ break
2119
+ case "query":
2120
+ inner = (runtime, entry, _source, ctx, finalize, wrapResponse) =>
2121
+ runtime.runQueryOnly(entry, ctx, finalize, wrapResponse)
2122
+ break
2123
+ default:
2124
+ inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
2125
+ runtime.runLifecycle(entry, source, ctx, finalize, wrapResponse)
2126
+ }
2127
+ const execute: ContextRouteRunner = hasAround
2128
+ ? (runtime, entry, source, ctx, finalize, wrapResponse) =>
2129
+ runtime.runWithAround(
2130
+ entry,
2131
+ ctx,
2132
+ () => inner(runtime, entry, source, ctx, finalize, wrapResponse),
2133
+ finalize,
2134
+ wrapResponse,
2135
+ )
2136
+ : inner
2137
+ const run: RouteExecutionRunner = (
2138
+ runtime,
2139
+ entry,
2140
+ source,
2141
+ params,
2142
+ search,
2143
+ signal,
2144
+ budget,
2145
+ platform,
2146
+ finalize,
2147
+ wrapResponse,
2148
+ ) => {
2149
+ const ctx = new RequestContext(
2150
+ source,
2151
+ params,
2152
+ search,
2153
+ signal,
2154
+ budget,
2155
+ platform,
2156
+ runtime.maxBodyBytes,
2157
+ )
2158
+ let ledger: RequestLedger | undefined
2159
+ if (hasLedger) {
2160
+ const resolved = entry.ledgered as ResolvedEffectLedger
2161
+ ledger = createRequestLedger({
2162
+ method: resolved.method,
2163
+ path: resolved.path,
2164
+ declared: resolved.declared,
2165
+ maxEntries: resolved.maxEntries,
2166
+ chain: resolved.chain,
2167
+ })
2168
+ attachEffectLedger(ctx, ledger)
2169
+ }
2170
+ let outcome = execute(runtime, entry, source, ctx, finalize, wrapResponse)
2171
+ if (ledger !== undefined) {
2172
+ const active = ledger
2173
+ const resolved = entry.ledgered as ResolvedEffectLedger
2174
+ outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) =>
2175
+ runtime.settleEffectLedger(active, resolved, value),
2176
+ )
2177
+ }
2178
+ return outcome
2179
+ }
2180
+ return Object.freeze({ run, fusedWeb })
1977
2181
  }
1978
2182
 
1979
2183
  /** Resolve a route's declared idempotency into a pinned store + defaults, or `undefined` when off. */
@@ -2074,6 +2278,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2074
2278
  * those on the parent).
2075
2279
  */
2076
2280
  merge<R2 extends Registry, Ctx2>(other: Server<R2, Ctx2>): Server<R & R2, Ctx> {
2281
+ this.assertConfigurable("merge()")
2077
2282
  const source = other as unknown as Server<Registry, EmptyContext>
2078
2283
  if (source.wsRouteCount > 0) {
2079
2284
  throw new RouteConfigError(
@@ -2081,11 +2286,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2081
2286
  "merge() does not carry WebSocket routes — register .ws() routes on the parent server",
2082
2287
  )
2083
2288
  }
2084
- for (const { method, path, entry, descriptor, assurance } of source.rawEntries) {
2085
- this.router.add(method, path, entry) // throws RouteConfigError on a duplicate — fail closed
2086
- this.routeList.push(descriptor) // shared ref on purpose: carries later .tool() tagging
2087
- this.rawEntries.push({ method, path, entry, descriptor, assurance })
2088
- }
2289
+ this.catalog.addBatch(source.catalog.entries().map((route) => this.bindFusedRuntime(route)))
2089
2290
  this.onRequestHooks.push(...source.onRequestHooks)
2090
2291
  this.onResponseHooks.push(...source.onResponseHooks)
2091
2292
  this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks)
@@ -2095,6 +2296,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2095
2296
  return this as unknown as Server<R & R2, Ctx>
2096
2297
  }
2097
2298
 
2299
+ /** A fused renderer closes over runtime services to keep its seven-argument JSC fast path. Merging
2300
+ * rebinds that closure once to the executing server; generic plans already receive the runtime. */
2301
+ private bindFusedRuntime(route: CatalogRoute): CatalogRoute {
2302
+ const { entry } = route
2303
+ if (entry.execution.fusedWeb === undefined) return route
2304
+ const fusedWeb = this.buildFusedWeb(
2305
+ entry.handler,
2306
+ entry.hasDecorations ? entry.decorations : undefined,
2307
+ isContextlessNoArgArrow(entry.handler),
2308
+ )
2309
+ return {
2310
+ ...route,
2311
+ entry: {
2312
+ ...entry,
2313
+ execution: Object.freeze({ ...entry.execution, fusedWeb }),
2314
+ },
2315
+ }
2316
+ }
2317
+
2098
2318
  /**
2099
2319
  * Enumerate the registered routes (method, path, input schemas), in registration
2100
2320
  * order. Powers `toOpenAPI` and other introspection; the router trie itself no
@@ -2102,10 +2322,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2102
2322
  */
2103
2323
  routes(): ReadonlyArray<RouteDescriptor> {
2104
2324
  if (this.activeAssurance.length === 0 && this.globalAssurance.length === 0) {
2105
- const anyRouteEvidence = this.rawEntries.some((entry) => entry.assurance.length > 0)
2106
- if (!anyRouteEvidence) return this.routeList
2325
+ if (!this.catalog.hasAssurance()) {
2326
+ return this.catalog.routeDescriptors()
2327
+ }
2107
2328
  }
2108
- return this.rawEntries.map(({ method, path, descriptor, assurance }) => {
2329
+ return this.catalog.entries().map(({ method, path, descriptor, assurance }) => {
2109
2330
  const effective = assuranceEvidenceFor([...assurance, ...this.globalAssurance], method, path)
2110
2331
  return effective.length > 0 ? { ...descriptor, assurance: effective } : descriptor
2111
2332
  })
@@ -2136,7 +2357,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2136
2357
  platform?: Platform<EnvOf<Ctx>>,
2137
2358
  ): MaybePromise<Response> {
2138
2359
  // Non-`async` on purpose: `dispatch` may return a `Response` *synchronously* (the bare-route fast
2139
- // path, see RouteEntry.bare), and an `async fetch` would wrap every such result in a redundant
2360
+ // path, selected by the compiled execution plan), and an `async fetch` would wrap every such result in a redundant
2140
2361
  // promise + microtask. Returning `Response | Promise<Response>` matches Web/edge handlers, while
2141
2362
  // `await app.fetch(...)` continues to work exactly as before.
2142
2363
  const outcome = this.dispatch<Response>(
@@ -2262,7 +2483,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2262
2483
  ? response.then((settled) => ({ kind: "response", response: settled }))
2263
2484
  : { kind: "response", response }
2264
2485
  }
2265
- // May resolve **synchronously** for a bare route + sync handler (RouteEntry.bare) — the `@nifrajs/node`
2486
+ // May resolve **synchronously** for a compiled bare route + sync handler - the `@nifrajs/node`
2266
2487
  // adapter `await`s the result, so it transparently handles either; the sync case allocates no promise
2267
2488
  // at all on the Node hot path.
2268
2489
  return this.dispatch<NodeServeOutcome>(
@@ -2294,7 +2515,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2294
2515
  if (!match.found) return WS_PASS // upgrade header, no WS route here → normal routing decides
2295
2516
  // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
2296
2517
  // already did and made every plain dynamic route pay for unrelated static path bytes.
2297
- const params = match.params === EMPTY_PARAMS ? match.params : decodeParams(match.params)
2518
+ const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params)
2298
2519
  if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
2299
2520
  const handler = match.payload.handler
2300
2521
  // Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ the runtime created the registry.
@@ -2475,7 +2696,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2475
2696
 
2476
2697
  /**
2477
2698
  * Route → build context → run. Synchronous through to the handler for a **bare** route
2478
- * ({@link RouteEntry.bare}), so a sync handler produces its result with zero promise allocations;
2699
+ * (selected by its compiled plan), so a sync handler produces its result with zero promise allocations;
2479
2700
  * routes with validation/hooks keep the full async {@link runLifecycle}, unchanged.
2480
2701
  */
2481
2702
  private routeAndRun<T>(
@@ -2487,7 +2708,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2487
2708
  webFast: boolean,
2488
2709
  ): MaybePromise<T> {
2489
2710
  const url = urlPartsOf(source.url)
2490
- const match = this.router.find(source.method, url.pathname)
2711
+ const match = this.catalog.find(source.method, url.pathname)
2491
2712
  if (!match.found) {
2492
2713
  if (match.reason === "method-not-allowed") {
2493
2714
  return wrapResponse(
@@ -2499,7 +2720,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2499
2720
 
2500
2721
  // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
2501
2722
  // already did and made every plain dynamic route pay for unrelated static path bytes.
2502
- const params = match.params === EMPTY_PARAMS ? match.params : decodeParams(match.params)
2723
+ const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params)
2503
2724
  if (params === null) {
2504
2725
  return wrapResponse(jsonError(400, "malformed_path"))
2505
2726
  }
@@ -2671,7 +2892,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2671
2892
  return wrapResponse(response)
2672
2893
  }
2673
2894
 
2674
- /** Run a matched route through its selected execution lane (fused/bare/bodyOnly/queryOnly/lifecycle). */
2895
+ /** Supply request-specific deadline state to the route's precompiled execution plan. */
2675
2896
  private runMatchedLanes<T>(
2676
2897
  source: RequestSource,
2677
2898
  platform: Platform | undefined,
@@ -2717,95 +2938,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2717
2938
  controller === undefined
2718
2939
  ? getUnboundedRequestBudget()
2719
2940
  : createRequestBudget({ deadline: admission!.deadline as number, signal })
2720
- // A bare route runs synchronously (no schema/derives/hooks); everything else keeps the full async
2721
- // lifecycle. `runBare` returns a value directly for a sync handler — no promise.
2722
- let outcome: MaybePromise<T>
2723
- if (webFast && entry.fusedWeb !== undefined) {
2724
- // Fused Web lane (bare route, no around): one closure end to end. Sound cast — webFast is
2725
- // passed only by the `fetch` path, where T = Response by construction.
2726
- outcome = entry.fusedWeb(
2727
- source,
2728
- params,
2729
- search,
2730
- signal,
2731
- budget,
2732
- platform,
2733
- false,
2734
- ) as MaybePromise<T>
2735
- } else if (entry.contextlessBare) {
2736
- outcome = this.runContextlessBare(
2737
- entry,
2738
- source,
2739
- params,
2740
- search,
2741
- signal,
2742
- budget,
2743
- platform,
2744
- finalize,
2745
- wrapResponse,
2746
- )
2747
- } else {
2748
- // Build the context up front so `onError` always has it. Query, cookies, headers, and body helpers
2749
- // stay lazy, but their accessors live on the prototype instead of allocating per-request closures.
2750
- const ctx = new RequestContext(
2751
- source,
2752
- params,
2753
- search,
2754
- signal,
2755
- budget,
2756
- platform,
2757
- this.maxBodyBytes,
2758
- )
2759
- // Ledgered routes (capabilities + effectLedger) carry a bounded per-request ledger; the
2760
- // `useCapability` beacon appends to it, and the settle step below seals + sinks it. The route
2761
- // was forced off the fused/contextless lanes at registration, so this branch always runs.
2762
- let ledger: RequestLedger | undefined
2763
- if (entry.ledgered !== undefined) {
2764
- ledger = createRequestLedger({
2765
- method: entry.ledgered.method,
2766
- path: entry.ledgered.path,
2767
- declared: entry.ledgered.declared,
2768
- maxEntries: entry.ledgered.maxEntries,
2769
- chain: entry.ledgered.chain,
2770
- })
2771
- attachEffectLedger(ctx, ledger)
2772
- }
2773
-
2774
- if (entry.around.length === 0) {
2775
- outcome = entry.bare
2776
- ? this.runBare(entry, ctx, finalize, wrapResponse)
2777
- : entry.bodyOnly
2778
- ? this.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
2779
- : entry.queryOnly
2780
- ? this.runQueryOnly(entry, ctx, finalize, wrapResponse)
2781
- : this.runLifecycle(entry, source, ctx, finalize, wrapResponse)
2782
- } else {
2783
- outcome = this.runWithAround(
2784
- entry,
2785
- ctx,
2786
- () =>
2787
- entry.bare
2788
- ? this.runBare(entry, ctx, finalize, wrapResponse)
2789
- : entry.bodyOnly
2790
- ? this.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
2791
- : entry.queryOnly
2792
- ? this.runQueryOnly(entry, ctx, finalize, wrapResponse)
2793
- : this.runLifecycle(entry, source, ctx, finalize, wrapResponse),
2794
- finalize,
2795
- wrapResponse,
2796
- )
2797
- }
2798
- // Settle step: seal the ledger and hand it to the sink BEFORE the response is released, on
2799
- // success and error responses alike (partial work is exactly what an audit must capture). The
2800
- // lanes above resolve handler errors to responses, so a plain resolve arm is sufficient.
2801
- if (ledger !== undefined) {
2802
- const resolved = entry.ledgered as ResolvedEffectLedger
2803
- const active = ledger
2804
- outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) =>
2805
- this.settleEffectLedger(active, resolved, value),
2806
- )
2807
- }
2808
- }
2941
+ const plan = entry.execution
2942
+ const outcome: MaybePromise<T> =
2943
+ webFast && plan.fusedWeb !== undefined
2944
+ ? (plan.fusedWeb(
2945
+ source,
2946
+ params,
2947
+ search,
2948
+ signal,
2949
+ budget,
2950
+ platform,
2951
+ false,
2952
+ ) as MaybePromise<T>)
2953
+ : plan.run(
2954
+ this,
2955
+ entry,
2956
+ source,
2957
+ params,
2958
+ search,
2959
+ signal,
2960
+ budget,
2961
+ platform,
2962
+ finalize,
2963
+ wrapResponse,
2964
+ )
2809
2965
  // The request timeout only bounds work that is actually pending — a synchronous (bare) result is
2810
2966
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
2811
2967
  if (controller !== undefined && outcome instanceof Promise) {
@@ -2923,7 +3079,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2923
3079
  }
2924
3080
 
2925
3081
  /**
2926
- * The synchronous fast path for a {@link RouteEntry.bare} route: apply static decorations, call the
3082
+ * The synchronous fast path selected by a route's execution plan: apply static decorations, call the
2927
3083
  * handler, render the result — **no `await`** unless the handler itself returns a promise. It mirrors
2928
3084
  * the bare slice of {@link runLifecycle} (which a bare route would otherwise no-op through) and shares
2929
3085
  * {@link logRequestError}; a bare route has no `onError` hooks, so error handling is fully synchronous
@@ -2956,7 +3112,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2956
3112
  /** Bare-route error rendering — identical to {@link runLifecycle}'s catch minus the (absent) onError
2957
3113
  * loop: a thrown `Response` is returned as deliberate control flow; anything else is logged + 500. */
2958
3114
  /**
2959
- * Build a route's fused Web lane (see {@link RouteEntry.fusedWeb}). Composition happens once at
3115
+ * Build a route's fused Web renderer. Composition happens once at
2960
3116
  * registration; the returned closure is what every request to the route runs. Behavior is
2961
3117
  * byte-identical to the generic `runBare`/`runContextlessBare` + `toResponse` pair — same
2962
3118
  * decoration order, same error routing (thrown `Response` = control flow; anything else logs and
@@ -2967,7 +3123,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2967
3123
  decorations: Record<PropertyKey, unknown> | undefined,
2968
3124
  contextless: boolean,
2969
3125
  ): FusedWebRunner {
2970
- const maxBodyBytes = this.maxBodyBytes
2971
3126
  const logError = (err: unknown, ctx: RawContext): Response => {
2972
3127
  if (err instanceof Response) return err
2973
3128
  this.logRequestError(err, ctx)
@@ -2985,8 +3140,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2985
3140
  return logError(
2986
3141
  err,
2987
3142
  nativeContext
2988
- ? RequestContext.native(source, params, maxBodyBytes)
2989
- : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes),
3143
+ ? RequestContext.native(source, params, this.maxBodyBytes)
3144
+ : new RequestContext(
3145
+ source,
3146
+ params,
3147
+ search,
3148
+ signal,
3149
+ budget,
3150
+ platform,
3151
+ this.maxBodyBytes,
3152
+ ),
2990
3153
  )
2991
3154
  }
2992
3155
  if (result instanceof Promise) {
@@ -2994,7 +3157,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2994
3157
  logError(
2995
3158
  err,
2996
3159
  nativeContext
2997
- ? RequestContext.native(source, params, maxBodyBytes)
3160
+ ? RequestContext.native(source, params, this.maxBodyBytes)
2998
3161
  : new RequestContext(
2999
3162
  source,
3000
3163
  params,
@@ -3002,7 +3165,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3002
3165
  signal,
3003
3166
  budget,
3004
3167
  platform,
3005
- maxBodyBytes,
3168
+ this.maxBodyBytes,
3006
3169
  ),
3007
3170
  ),
3008
3171
  )
@@ -3012,8 +3175,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3012
3175
  }
3013
3176
  return (source, params, search, signal, budget, platform, nativeContext) => {
3014
3177
  const ctx = nativeContext
3015
- ? RequestContext.native(source, params, maxBodyBytes)
3016
- : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes)
3178
+ ? RequestContext.native(source, params, this.maxBodyBytes)
3179
+ : new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)
3017
3180
  if (decorations !== undefined) Object.assign(ctx, decorations)
3018
3181
  let result: unknown
3019
3182
  try {
@@ -3488,7 +3651,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3488
3651
  ): Promise<T> {
3489
3652
  try {
3490
3653
  if (entry.schema?.body !== undefined) {
3491
- const bodyError = await this.readAndValidateBody(source, entry.schema.body, ctx)
3654
+ const bodyError = await this.readAndValidateBody(source, entry, ctx)
3492
3655
  if (bodyError !== undefined) return wrapResponse(bodyError)
3493
3656
  }
3494
3657
  if (entry.schema?.query !== undefined) {
@@ -3499,8 +3662,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3499
3662
  queryObjectOf(ctx[CONTEXT_SEARCH]),
3500
3663
  )
3501
3664
  const result = validation instanceof Promise ? await validation : validation
3502
- if (result.issues !== undefined) return wrapResponse(validationError(result.issues))
3503
- ctx.query = result.value
3665
+ const queryError = await this.applyLifecycleValidation(entry, result, ctx, "query")
3666
+ if (queryError !== undefined) return wrapResponse(queryError)
3504
3667
  }
3505
3668
 
3506
3669
  // Context extensions: static decorations, then per-request derives.
@@ -3581,7 +3744,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3581
3744
 
3582
3745
  private async readAndValidateBody(
3583
3746
  req: RequestSource,
3584
- schema: StandardSchemaV1,
3747
+ entry: RouteEntry,
3585
3748
  ctx: RawContext,
3586
3749
  ): Promise<Response | undefined> {
3587
3750
  const contentType = headerOf(req, "content-type") ?? ""
@@ -3599,10 +3762,36 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3599
3762
  // fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
3600
3763
  return jsonError(415, "unsupported_media_type")
3601
3764
  }
3602
- const validation = schema["~standard"].validate(parsed)
3765
+ const validation = entry.schema!.body!["~standard"].validate(parsed)
3603
3766
  const result = validation instanceof Promise ? await validation : validation
3604
- if (result.issues !== undefined) return validationError(result.issues)
3605
- ctx.body = result.value
3767
+ return this.applyLifecycleValidation(entry, result, ctx, "body")
3768
+ }
3769
+
3770
+ /** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
3771
+ * before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
3772
+ private async applyLifecycleValidation(
3773
+ entry: RouteEntry,
3774
+ result: StandardResult<unknown>,
3775
+ ctx: RawContext,
3776
+ kind: "body" | "query",
3777
+ ): Promise<Response | undefined> {
3778
+ if (result.issues === undefined) {
3779
+ if (kind === "body") ctx.body = result.value
3780
+ else ctx.query = result.value
3781
+ return undefined
3782
+ }
3783
+ const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
3784
+ if (hook === undefined) return validationError(result.issues)
3785
+ const attempted = hook(result.issues, ctx as unknown as Context, kind)
3786
+ const recovery = attempted instanceof Promise ? await attempted : attempted
3787
+ if (recovery === undefined) return validationError(result.issues)
3788
+ if (recovery instanceof Response) return recovery
3789
+ const schema = kind === "body" ? entry.schema?.body : entry.schema?.query
3790
+ const retried = schema!["~standard"].validate(recovery)
3791
+ const settled = retried instanceof Promise ? await retried : retried
3792
+ if (settled.issues !== undefined) return validationError(settled.issues)
3793
+ if (kind === "body") ctx.body = settled.value
3794
+ else ctx.query = settled.value
3606
3795
  return undefined
3607
3796
  }
3608
3797
 
@@ -3624,6 +3813,55 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3624
3813
  return readBoundedJsonSource(req, this.maxBodyBytes)
3625
3814
  }
3626
3815
 
3816
+ /** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
3817
+ * semantics remain in the plan; this closure only supplies native params and deadline fallback. */
3818
+ private compileBunNativeHandler(
3819
+ entry: RouteEntry,
3820
+ paramNames: readonly string[],
3821
+ fused: FusedWebRunner | undefined,
3822
+ signal: AbortSignal | undefined,
3823
+ budget: RequestBudget | undefined,
3824
+ ): BunNativeHandler {
3825
+ if (paramNames.length === 0) {
3826
+ if (fused === undefined) {
3827
+ return (request) => this.fetchMatched(request, entry, EMPTY_PARAMS)
3828
+ }
3829
+ if (this.acceptInboundDeadlines) {
3830
+ return (request) =>
3831
+ request.headers.get(NIFRA_DEADLINE_HEADER) !== null
3832
+ ? this.fetchMatched(request, entry, EMPTY_PARAMS)
3833
+ : fused(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
3834
+ }
3835
+ return (request) => fused(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
3836
+ }
3837
+
3838
+ const malformed =
3839
+ paramNames.length === 1
3840
+ ? (params: Record<string, string>) => params[paramNames[0]!]?.includes("\uFFFD") === true
3841
+ : hasReplacementParam
3842
+ if (fused === undefined) {
3843
+ return (request) => {
3844
+ const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3845
+ if (malformed(params)) return this.fetchSource(request)
3846
+ return this.fetchMatched(request, entry, params)
3847
+ }
3848
+ }
3849
+ if (this.acceptInboundDeadlines) {
3850
+ return (request) => {
3851
+ const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3852
+ if (malformed(params)) return this.fetchSource(request)
3853
+ return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
3854
+ ? this.fetchMatched(request, entry, params)
3855
+ : fused(request, params, undefined, signal!, budget!, undefined, true)
3856
+ }
3857
+ }
3858
+ return (request) => {
3859
+ const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3860
+ if (malformed(params)) return this.fetchSource(request)
3861
+ return fused(request, params, undefined, signal!, budget!, undefined, true)
3862
+ }
3863
+ }
3864
+
3627
3865
  /** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
3628
3866
  * hooks or WebSockets retain the single portable dispatcher because those features must run before
3629
3867
  * route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
@@ -3642,143 +3880,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3642
3880
  const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined
3643
3881
  const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined
3644
3882
  let count = 0
3645
- for (const { method, path, entry } of this.rawEntries) {
3646
- if (path.includes("*")) continue
3883
+ for (const { method, path, pattern, entry } of this.catalog.entries()) {
3884
+ if (pattern.segments.some((segment) => segment.kind === "wildcard")) continue
3647
3885
  let methods = routes[path]
3648
3886
  if (methods === undefined) {
3649
3887
  methods = Object.create(null) as BunNativeMethodTable
3650
3888
  routes[path] = methods
3651
3889
  }
3652
- const paramNames = path
3653
- .split("/")
3654
- .filter((segment) => segment.charCodeAt(0) === 58 /* : */)
3655
- .map((segment) => segment.slice(1))
3656
- const fused = mayUseFusedNative ? entry.fusedWeb : undefined
3657
-
3658
- if (fused !== undefined) {
3659
- // Deliberately choose deadline-aware vs zero-admission closures HERE instead of branching on
3660
- // `acceptInboundDeadlines` per request. JSC can then flatten the default closure through the
3661
- // handler + Response.json; retaining that route-invariant branch cost ~7% on a dynamic GET.
3662
- if (paramNames.length === 0) {
3663
- methods[method] = this.acceptInboundDeadlines
3664
- ? (request) =>
3665
- request.headers.get(NIFRA_DEADLINE_HEADER) !== null
3666
- ? this.fetchMatched(request, entry, EMPTY_PARAMS)
3667
- : fused(
3668
- request,
3669
- EMPTY_PARAMS,
3670
- undefined,
3671
- unboundedSignal as AbortSignal,
3672
- unboundedBudget as RequestBudget,
3673
- undefined,
3674
- true,
3675
- )
3676
- : (request) =>
3677
- fused(
3678
- request,
3679
- EMPTY_PARAMS,
3680
- undefined,
3681
- unboundedSignal as AbortSignal,
3682
- unboundedBudget as RequestBudget,
3683
- undefined,
3684
- true,
3685
- )
3686
- } else if (paramNames.length === 1) {
3687
- const paramName = paramNames[0]!
3688
- const handler = entry.handler
3689
- const decorations = entry.hasDecorations ? entry.decorations : undefined
3690
- const fail = (error: unknown, ctx: RawContext): Response => {
3691
- if (error instanceof Response) return error
3692
- this.logRequestError(error, ctx)
3693
- return jsonError(500, "internal_error")
3694
- }
3695
- methods[method] = this.acceptInboundDeadlines
3696
- ? (request) => {
3697
- const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3698
- if (params[paramName]?.includes("\uFFFD") === true) return this.fetchSource(request)
3699
- if (request.headers.get(NIFRA_DEADLINE_HEADER) !== null) {
3700
- return this.fetchMatched(request, entry, params)
3701
- }
3702
- const ctx = RequestContext.native(request, params, this.maxBodyBytes)
3703
- if (decorations !== undefined) Object.assign(ctx, decorations)
3704
- let result: unknown
3705
- try {
3706
- result = handler(ctx)
3707
- } catch (error) {
3708
- return fail(error, ctx)
3709
- }
3710
- return result instanceof Promise
3711
- ? result.then(
3712
- (value) => fusedRespond(value, ctx),
3713
- (error) => fail(error, ctx),
3714
- )
3715
- : fusedRespond(result, ctx)
3716
- }
3717
- : (request) => {
3718
- const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3719
- if (params[paramName]?.includes("\uFFFD") === true) return this.fetchSource(request)
3720
- const ctx = RequestContext.native(request, params, this.maxBodyBytes)
3721
- if (decorations !== undefined) Object.assign(ctx, decorations)
3722
- let result: unknown
3723
- try {
3724
- result = handler(ctx)
3725
- } catch (error) {
3726
- return fail(error, ctx)
3727
- }
3728
- return result instanceof Promise
3729
- ? result.then(
3730
- (value) => fusedRespond(value, ctx),
3731
- (error) => fail(error, ctx),
3732
- )
3733
- : fusedRespond(result, ctx)
3734
- }
3735
- } else {
3736
- methods[method] = this.acceptInboundDeadlines
3737
- ? (request) => {
3738
- const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3739
- if (hasReplacementParam(params)) return this.fetchSource(request)
3740
- return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
3741
- ? this.fetchMatched(request, entry, params)
3742
- : fused(
3743
- request,
3744
- params,
3745
- undefined,
3746
- unboundedSignal as AbortSignal,
3747
- unboundedBudget as RequestBudget,
3748
- undefined,
3749
- true,
3750
- )
3751
- }
3752
- : (request) => {
3753
- const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3754
- if (hasReplacementParam(params)) return this.fetchSource(request)
3755
- return fused(
3756
- request,
3757
- params,
3758
- undefined,
3759
- unboundedSignal as AbortSignal,
3760
- unboundedBudget as RequestBudget,
3761
- undefined,
3762
- true,
3763
- )
3764
- }
3765
- }
3766
- } else if (paramNames.length === 0) {
3767
- methods[method] = (request) => this.fetchMatched(request, entry, EMPTY_PARAMS)
3768
- } else if (paramNames.length === 1) {
3769
- const paramName = paramNames[0]!
3770
- methods[method] = (request) => {
3771
- const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3772
- if (params[paramName]?.includes("\uFFFD") === true) return this.fetchSource(request)
3773
- return this.fetchMatched(request, entry, params)
3774
- }
3775
- } else {
3776
- methods[method] = (request) => {
3777
- const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3778
- if (hasReplacementParam(params)) return this.fetchSource(request)
3779
- return this.fetchMatched(request, entry, params)
3780
- }
3781
- }
3890
+ const paramNames = pattern.paramNames
3891
+ const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined
3892
+ methods[method] = this.compileBunNativeHandler(
3893
+ entry,
3894
+ paramNames,
3895
+ fused,
3896
+ unboundedSignal,
3897
+ unboundedBudget,
3898
+ )
3782
3899
  count += 1
3783
3900
  }
3784
3901
  return count === 0 ? undefined : routes
@@ -3858,6 +3975,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3858
3975
  },
3859
3976
  })) as unknown as RunningServer
3860
3977
  this.bunServer = running
3978
+ this.sealed = true
3861
3979
  if (this.gracefulSignals) this.installSignalHandlers()
3862
3980
  return running
3863
3981
  }