@nifrajs/core 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/README.md +21 -0
  2. package/dist/durable-adapters.d.ts.map +1 -1
  3. package/dist/durable-adapters.js +26 -3
  4. package/dist/durable-adapters.js.map +1 -1
  5. package/dist/durable-approval.d.ts +56 -0
  6. package/dist/durable-approval.d.ts.map +1 -0
  7. package/dist/durable-approval.js +233 -0
  8. package/dist/durable-approval.js.map +1 -0
  9. package/dist/durable-effect.d.ts +42 -0
  10. package/dist/durable-effect.d.ts.map +1 -0
  11. package/dist/durable-effect.js +234 -0
  12. package/dist/durable-effect.js.map +1 -0
  13. package/dist/durable-execution.d.ts +8 -346
  14. package/dist/durable-execution.d.ts.map +1 -1
  15. package/dist/durable-execution.js +7 -1150
  16. package/dist/durable-execution.js.map +1 -1
  17. package/dist/durable-saga.d.ts +94 -0
  18. package/dist/durable-saga.d.ts.map +1 -0
  19. package/dist/durable-saga.js +550 -0
  20. package/dist/durable-saga.js.map +1 -0
  21. package/dist/edge-kit.d.ts +2 -2
  22. package/dist/edge-kit.d.ts.map +1 -1
  23. package/dist/edge-kit.js +2 -2
  24. package/dist/edge-kit.js.map +1 -1
  25. package/dist/index.d.ts +23 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +18 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/internal/durable-approval-protocol.d.ts +1 -1
  30. package/dist/internal/durable-approval-protocol.d.ts.map +1 -1
  31. package/dist/internal/durable-effect-protocol.d.ts +1 -1
  32. package/dist/internal/durable-effect-protocol.d.ts.map +1 -1
  33. package/dist/internal/durable-saga-protocol.d.ts +1 -1
  34. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  35. package/dist/internal/durable-shared.d.ts +26 -0
  36. package/dist/internal/durable-shared.d.ts.map +1 -0
  37. package/dist/internal/durable-shared.js +131 -0
  38. package/dist/internal/durable-shared.js.map +1 -0
  39. package/dist/internal/durable-types.d.ts +159 -0
  40. package/dist/internal/durable-types.d.ts.map +1 -0
  41. package/dist/internal/durable-types.js +2 -0
  42. package/dist/internal/durable-types.js.map +1 -0
  43. package/dist/internal/route-compiler.d.ts +61 -0
  44. package/dist/internal/route-compiler.d.ts.map +1 -0
  45. package/dist/internal/route-compiler.js +121 -0
  46. package/dist/internal/route-compiler.js.map +1 -0
  47. package/dist/internal/route-execution.d.ts +6 -4
  48. package/dist/internal/route-execution.d.ts.map +1 -1
  49. package/dist/internal/route-execution.js +2 -17
  50. package/dist/internal/route-execution.js.map +1 -1
  51. package/dist/internal/route-lanes.d.ts +35 -0
  52. package/dist/internal/route-lanes.d.ts.map +1 -0
  53. package/dist/internal/route-lanes.js +150 -0
  54. package/dist/internal/route-lanes.js.map +1 -0
  55. package/dist/internal/route-program.d.ts +67 -0
  56. package/dist/internal/route-program.d.ts.map +1 -0
  57. package/dist/internal/route-program.js +199 -0
  58. package/dist/internal/route-program.js.map +1 -0
  59. package/dist/server/bare-error-lane.d.ts +1 -1
  60. package/dist/server/bare-error-lane.d.ts.map +1 -1
  61. package/dist/server/bare-error-lane.js +3 -3
  62. package/dist/server/bare-error-lane.js.map +1 -1
  63. package/dist/server/body-lane.d.ts +13 -0
  64. package/dist/server/body-lane.d.ts.map +1 -0
  65. package/dist/server/body-lane.js +134 -0
  66. package/dist/server/body-lane.js.map +1 -0
  67. package/dist/server/contract.d.ts +40 -14
  68. package/dist/server/contract.d.ts.map +1 -1
  69. package/dist/server/contract.js +7 -0
  70. package/dist/server/contract.js.map +1 -1
  71. package/dist/server/cookies.d.ts.map +1 -1
  72. package/dist/server/cookies.js +16 -2
  73. package/dist/server/cookies.js.map +1 -1
  74. package/dist/server/plugin.d.ts +2 -2
  75. package/dist/server/plugin.d.ts.map +1 -1
  76. package/dist/server/plugin.js +6 -1
  77. package/dist/server/plugin.js.map +1 -1
  78. package/dist/server/registry.d.ts +40 -12
  79. package/dist/server/registry.d.ts.map +1 -1
  80. package/dist/server/request-context.d.ts +4 -28
  81. package/dist/server/request-context.d.ts.map +1 -1
  82. package/dist/server/request-context.js +10 -185
  83. package/dist/server/request-context.js.map +1 -1
  84. package/dist/server/respond.d.ts.map +1 -1
  85. package/dist/server/respond.js +39 -2
  86. package/dist/server/respond.js.map +1 -1
  87. package/dist/server/runtime-core.d.ts +18 -2
  88. package/dist/server/runtime-core.d.ts.map +1 -1
  89. package/dist/server/runtime-core.js +0 -23
  90. package/dist/server/runtime-core.js.map +1 -1
  91. package/dist/server/server-types.d.ts +3 -0
  92. package/dist/server/server-types.d.ts.map +1 -1
  93. package/dist/server/server.d.ts +113 -84
  94. package/dist/server/server.d.ts.map +1 -1
  95. package/dist/server/server.js +506 -639
  96. package/dist/server/server.js.map +1 -1
  97. package/dist/server/validation.d.ts +5 -0
  98. package/dist/server/validation.d.ts.map +1 -0
  99. package/dist/server/validation.js +13 -0
  100. package/dist/server/validation.js.map +1 -0
  101. package/dist/server/ws-bun.d.ts +11 -3
  102. package/dist/server/ws-bun.d.ts.map +1 -1
  103. package/dist/server/ws-bun.js +42 -12
  104. package/dist/server/ws-bun.js.map +1 -1
  105. package/dist/server/ws-hook.d.ts +3 -2
  106. package/dist/server/ws-hook.d.ts.map +1 -1
  107. package/dist/server.d.ts +2 -2
  108. package/dist/server.d.ts.map +1 -1
  109. package/dist/server.js +1 -1
  110. package/dist/server.js.map +1 -1
  111. package/dist/single-copy.d.ts.map +1 -1
  112. package/dist/single-copy.js +3 -1
  113. package/dist/single-copy.js.map +1 -1
  114. package/package.json +6 -3
  115. package/src/durable-adapters.ts +20 -0
  116. package/src/durable-approval.ts +281 -0
  117. package/src/durable-effect.ts +300 -0
  118. package/src/durable-execution.ts +8 -1627
  119. package/src/durable-saga.ts +744 -0
  120. package/src/edge-kit.ts +2 -2
  121. package/src/index.ts +129 -2
  122. package/src/internal/durable-approval-protocol.ts +1 -1
  123. package/src/internal/durable-effect-protocol.ts +1 -1
  124. package/src/internal/durable-saga-protocol.ts +1 -1
  125. package/src/internal/durable-shared.ts +157 -0
  126. package/src/internal/durable-types.ts +194 -0
  127. package/src/internal/route-compiler.ts +234 -0
  128. package/src/internal/route-execution.ts +34 -84
  129. package/src/internal/route-lanes.ts +221 -0
  130. package/src/internal/route-program.ts +404 -0
  131. package/src/server/bare-error-lane.ts +3 -2
  132. package/src/server/body-lane.ts +203 -0
  133. package/src/server/contract.ts +70 -20
  134. package/src/server/cookies.ts +16 -2
  135. package/src/server/plugin.ts +11 -5
  136. package/src/server/registry.ts +71 -16
  137. package/src/server/request-context.ts +7 -262
  138. package/src/server/respond.ts +45 -2
  139. package/src/server/runtime-core.ts +28 -2
  140. package/src/server/server-types.ts +3 -0
  141. package/src/server/server.ts +1152 -1260
  142. package/src/server/validation.ts +19 -0
  143. package/src/server/ws-bun.ts +44 -11
  144. package/src/server/ws-hook.ts +3 -2
  145. package/src/server.ts +7 -2
  146. package/src/single-copy.ts +3 -1
@@ -12,22 +12,18 @@ import { FrameworkError, RouteConfigError } from "../errors.ts"
12
12
  import type { Version } from "../index.ts"
13
13
  import {
14
14
  type AroundCapabilityOptions,
15
- CAPABILITY_GUARD,
16
15
  type CapabilityInterceptor,
17
16
  type CapabilityUseEvent,
18
- createCapabilityGuard,
19
17
  DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS,
20
- normalizeRouteCapabilities,
21
18
  type RegisteredCapabilityInterceptor,
22
19
  } from "../internal/capability-runtime.ts"
23
20
  import {
24
21
  type AssuranceDeclaration,
25
22
  assuranceDeclarationsOf,
26
23
  assuranceEvidenceFor,
27
- NIFRA_ASSURANCE_IDS,
28
- validEvidenceId,
29
24
  } from "../internal/route-assurance.ts"
30
25
  import { type CatalogRoute, RouteCatalog } from "../internal/route-catalog.ts"
26
+ import { compileRouteOptions } from "../internal/route-compiler.ts"
31
27
  import {
32
28
  compileRouteExecutionPlan,
33
29
  type FusedBodyRunner,
@@ -40,9 +36,11 @@ import {
40
36
  type RawErrorHandler,
41
37
  type RouteEntry,
42
38
  } from "../internal/route-execution.ts"
39
+ import type { RouteProgramStage } from "../internal/route-program.ts"
40
+ import { compileRouteProgram, executeRouteProgram } from "../internal/route-program.ts"
43
41
  import { isSameOriginRequest } from "../internal/same-origin.ts"
44
42
  import type { ResponseObserverPlugin } from "../response-observer.ts"
45
- import { compileRoutePattern, decodeRouteParams } from "../router/pattern.ts"
43
+ import { decodeRouteParams } from "../router/pattern.ts"
46
44
  import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
47
45
  import type {
48
46
  InferOutput,
@@ -79,14 +77,8 @@ import type {
79
77
  ResponseHeadersHook,
80
78
  ResponseHeadersView,
81
79
  } from "./node-outcome-hook.ts"
82
- import {
83
- isUrlEncodedForm,
84
- type QueryValue,
85
- queryObjectOf,
86
- readBoundedForm,
87
- searchOf,
88
- } from "./query.ts"
89
- import { RequestContext, readBodyFramed, readBoundedJsonSource } from "./request-context.ts"
80
+ import { type QueryValue, queryObjectOf, searchOf } from "./query.ts"
81
+ import { RequestContext, readBodyFramed } from "./request-context.ts"
90
82
  import {
91
83
  applyStaticResponseHeaders,
92
84
  buildStaticResponseHeaders,
@@ -112,9 +104,10 @@ import {
112
104
  isResponseResult,
113
105
  type ResponseResult,
114
106
  requestOf,
115
- status,
107
+ type StatusResponse,
116
108
  } from "./runtime-core.ts"
117
109
  import { normalizeStaticResponseHeaders, type StaticResponseHeaders } from "./static-headers.ts"
110
+ import { plainValidationError } from "./validation.ts"
118
111
 
119
112
  // NodeServeOutcome (the nifra<->node bridge render form) now lives in `./node-outcome.ts`; re-exported
120
113
  // so existing importers keep resolving it from the server module.
@@ -143,7 +136,7 @@ import {
143
136
  NODE_NATIVE_MOUNT,
144
137
  RESOLVE_NODE_MOUNT,
145
138
  } from "./install.ts"
146
- import type { EffectLedgerRuntime, ResolvedEffectLedger } from "./ledger-lane.ts"
139
+ import type { EffectLedgerRuntime } from "./ledger-lane.ts"
147
140
  import { jsonLogger, type Logger } from "./logger.ts"
148
141
  import type { McpRuntime } from "./mcp-hook.ts"
149
142
  import type {
@@ -373,6 +366,31 @@ type ResponseOf<S extends RouteSchema> = S extends { response: infer R extends S
373
366
  ? InferOutput<R> | Response | ResponseResult
374
367
  : HandlerResult
375
368
 
369
+ /** A typed status result is control flow, not a context extension. */
370
+ type StatusResponseOf<T> = T extends StatusResponse<number, unknown> ? T : never
371
+
372
+ type ContextExtensionPart<T> = T extends object ? T : EmptyContext
373
+ type ContextExtensionOf<T> = [Exclude<Awaited<T>, Response | ResponseResult>] extends [never]
374
+ ? EmptyContext
375
+ : ContextExtensionPart<Exclude<Awaited<T>, Response | ResponseResult>>
376
+
377
+ /** Keep concrete lifecycle return values for route inference, but don't let an untyped hook widen every route to unknown. */
378
+ type HookOutputOf<T> = unknown extends Awaited<T> ? never : Exclude<Awaited<T>, void>
379
+
380
+ type ReturnOfHookField<M, K extends PropertyKey> = M extends {
381
+ readonly [P in K]?: infer F
382
+ }
383
+ ? F extends (...args: never[]) => infer R
384
+ ? HookOutputOf<R>
385
+ : never
386
+ : never
387
+
388
+ type MiddlewareOutputOf<M> =
389
+ | ReturnOfHookField<M, "beforeHandle">
390
+ | ReturnOfHookField<M, "afterHandle">
391
+ | ReturnOfHookField<M, "onError">
392
+ | ReturnOfHookField<M, "around">
393
+
376
394
  /**
377
395
  * Public handler shape: context typed from the path, the (optional) schema, and
378
396
  * any accumulated middleware context `Ctx` (from `derive`/`decorate`).
@@ -406,7 +424,7 @@ export type {
406
424
  // A plugin operates over arbitrary Server shapes; `any` here is the standard framework escape hatch
407
425
  // (the precise threading happens at the `use` call site, which is generic over the *concrete* `this`).
408
426
  // biome-ignore lint/suspicious/noExplicitAny: plugins are generic over any Server's Registry/Context
409
- export type AnyServer = Server<any, any>
427
+ export type AnyServer = Server<any, any, any>
410
428
 
411
429
  // Plugin definers + their types now live in `./plugin.ts`; re-exported here so `.use()` callers and
412
430
  // existing importers keep resolving them from the server module.
@@ -432,28 +450,13 @@ const STOP_HOOK_TIMEOUT_MS = 5_000
432
450
  * for the same request, so a browser that could open a socket was told its POST was cross-origin. */
433
451
  const wsSameOrigin = isSameOriginRequest
434
452
 
435
- function validationIssues(issues: ReadonlyArray<StandardIssue>): {
436
- ok: false
437
- error: string
438
- issues: unknown[]
439
- } {
440
- const serialized = issues.map((issue) => {
441
- const path = issue.path?.map((seg) => String(typeof seg === "object" ? seg.key : seg))
442
- return path !== undefined ? { message: issue.message, path } : { message: issue.message }
443
- })
444
- return { ok: false, error: "validation", issues: serialized }
445
- }
446
-
447
- /** The 422 as plain data - the shape every lane's response wrapper takes, so a rejected body costs
448
- * what an accepted one costs: on Node it is written straight to the socket with a `content-length`
449
- * instead of being built as a `Response` and drained back out. */
450
- export function plainValidationError(issues: ReadonlyArray<StandardIssue>): ResponseResult {
451
- return status(422, validationIssues(issues))
452
- }
453
-
454
453
  // `jsonError`, `urlPartsOf`, `pathnameOf` moved to `./http.ts` (a dependency-free leaf shared with the
455
454
  // opt-in request lanes); re-exported so existing importers keep resolving from here.
456
455
  export { pathnameOf, urlPartsOf } from "./http.ts"
456
+ /** The 422 as plain data - the shape every lane's response wrapper takes, so a rejected body costs
457
+ * what an accepted one costs: on Node it is written straight to the socket with a `content-length`
458
+ * instead of being built as a `Response` and drained back out. */
459
+ export { plainValidationError } from "./validation.ts"
457
460
  // Query-string + urlencoded-form parsing now lives in `./query.ts`; re-exported so existing
458
461
  // importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
459
462
  export { type QueryValue, queryObjectOf, searchOf }
@@ -503,21 +506,6 @@ function responseSet(ctx: RawContext): CtxSet {
503
506
  return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS
504
507
  }
505
508
 
506
- /**
507
- * A `derive` that answered with a response rather than a context extension - `status(...)`, or a bare
508
- * `Response`. The request ends there, on the same lane a `beforeHandle`'s early return takes.
509
- *
510
- * `derive` is the one stage that could not exit early: `beforeHandle` says "done" by returning a
511
- * value, but a derive's return value IS the extension, so the only way out was `throw`ing a
512
- * `Response` - which measured as the single most expensive way to answer (the `Response` object
513
- * costs ~40% of the rejection lane; see `PlainRender`). Neither shape had a meaning here before:
514
- * `Object.assign(ctx, response)` copies nothing (a `Response`'s properties live on its prototype),
515
- * so a derive that returned one silently fell through to the handler it meant to prevent.
516
- */
517
- function isDeriveEarlyExit(extension: unknown): boolean {
518
- return isResponseResult(extension) || extension instanceof Response
519
- }
520
-
521
509
  function isContextlessNoArgArrow(handler: (context: never) => unknown): boolean {
522
510
  if (handler.length !== 0) return false
523
511
  try {
@@ -657,7 +645,7 @@ const sealHookAudit = (
657
645
  * app.decorate("db", db).derive((c) => ({ user: auth(c) }))
658
646
  * .get("/me", (c) => c.user) // c.user + c.db are typed
659
647
  */
660
- export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
648
+ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, HookOutput = never> {
661
649
  /**
662
650
  * Which copy of `@nifrajs/core` declared this type. Two copies in one build (a linked sibling repo,
663
651
  * a hoisting split) produce two unrelated `Server` types whose assignment error is a wall of
@@ -676,6 +664,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
676
664
  * Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime - `undefined` until then, so a
677
665
  * no-WebSocket app never constructs (or bundles) it. */
678
666
  private topics: TopicRegistry | undefined
667
+ /** True once any `app.ws()` route sets `validateSend: true` - the one case where a broadcast frame
668
+ * is validated (and possibly dropped) per socket, so `app.publish` must stay on the JS registry loop
669
+ * rather than delegate to Bun's native `server.publish`. */
670
+ private wsHasValidatedSend = false
671
+ /** Bun's native topic broadcast, bound by `listen()` in native-pubsub mode; `app.publish` uses it
672
+ * instead of the JS registry. `undefined` off Bun, before `listen()`, or with a validated-send route. */
673
+ private nativePublish:
674
+ | ((topic: string, data: string | ArrayBufferView | ArrayBuffer) => void)
675
+ | undefined
679
676
  private readonly maxBodyBytes: number
680
677
  private readonly protoPoisoning: "reject" | "strip" | "ignore"
681
678
  /** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
@@ -901,14 +898,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
901
898
  * takes, where `throw new Response(...)` pays ~40% of the rejection lane to build the object. That
902
899
  * branch is control flow, so it does not widen the derived context type.
903
900
  */
904
- derive<D extends object>(
905
- fn: (context: Context & Ctx) => MaybePromise<D | ResponseResult | Response>,
906
- ): Server<R, Ctx & D> {
901
+ derive<H extends (context: Context & Ctx) => MaybePromise<unknown>>(
902
+ fn: H,
903
+ ): Server<
904
+ R,
905
+ Ctx & ContextExtensionOf<ReturnType<H>>,
906
+ HookOutput | StatusResponseOf<Awaited<ReturnType<H>>>
907
+ > {
907
908
  this.assertConfigurable("derive()")
908
909
  this.derives.push(fn as unknown as RawDerive)
909
910
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
910
911
  recordScopedHook(this, this.catalog.size, "derive")
911
- return this as unknown as Server<R, Ctx & D>
912
+ return this as unknown as Server<
913
+ R,
914
+ Ctx & ContextExtensionOf<ReturnType<H>>,
915
+ HookOutput | StatusResponseOf<Awaited<ReturnType<H>>>
916
+ >
912
917
  }
913
918
 
914
919
  /**
@@ -916,12 +921,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
916
921
  * by none before; one added after the last route reaches nothing
917
922
  * (see {@link ServerOptions.unusedScopedHooks}).
918
923
  */
919
- decorate<const K extends string, V>(key: K, value: V): Server<R, Ctx & Record<K, V>> {
924
+ decorate<const K extends string, V>(key: K, value: V): Server<R, Ctx & Record<K, V>, HookOutput> {
920
925
  this.assertConfigurable("decorate()")
921
926
  this.decorations[key] = value
922
927
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
923
928
  recordScopedHook(this, this.catalog.size, "decorate")
924
- return this as unknown as Server<R, Ctx & Record<K, V>>
929
+ return this as unknown as Server<R, Ctx & Record<K, V>, HookOutput>
925
930
  }
926
931
 
927
932
  /**
@@ -943,12 +948,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
943
948
  * it is snapshotted into every route declared *after* this call and covers none declared before, so
944
949
  * one added after the last route reaches nothing (see {@link ServerOptions.unusedScopedHooks}).
945
950
  */
946
- beforeHandle(fn: (context: Context & Ctx) => MaybePromise<unknown>): this {
951
+ beforeHandle<H extends (context: Context & Ctx) => MaybePromise<unknown>>(
952
+ fn: H,
953
+ ): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
947
954
  this.assertConfigurable("beforeHandle()")
948
955
  this.beforeHandleHooks.push(fn as unknown as RawBeforeHandle)
949
956
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
950
957
  recordScopedHook(this, this.catalog.size, "beforeHandle")
951
- return this
958
+ return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
952
959
  }
953
960
 
954
961
  /**
@@ -959,12 +966,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
959
966
  * Order-scoped: captured by every route declared *after* this call, none before; one added after the
960
967
  * last route reaches nothing (see {@link ServerOptions.unusedScopedHooks}).
961
968
  */
962
- around(fn: <T>(context: Context & Ctx, next: () => MaybePromise<T>) => MaybePromise<T>): this {
969
+ around<
970
+ H extends (context: Context & Ctx, next: () => MaybePromise<unknown>) => MaybePromise<unknown>,
971
+ >(fn: H): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
963
972
  this.assertConfigurable("around()")
964
973
  this.aroundHooks.push(fn as unknown as RawAround)
965
974
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
966
975
  recordScopedHook(this, this.catalog.size, "around")
967
- return this
976
+ return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
968
977
  }
969
978
 
970
979
  /**
@@ -1006,23 +1015,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1006
1015
  /** Transform the handler's result before it is serialized. Order-scoped: captured by routes declared
1007
1016
  * *after* this call, none before; one added after the last route reaches nothing (see
1008
1017
  * {@link ServerOptions.unusedScopedHooks}). */
1009
- afterHandle(fn: (result: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
1018
+ afterHandle<H extends (result: unknown, context: Context & Ctx) => MaybePromise<unknown>>(
1019
+ fn: H,
1020
+ ): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
1010
1021
  this.assertConfigurable("afterHandle()")
1011
1022
  this.afterHandleHooks.push(fn as unknown as RawAfterHandle)
1012
1023
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1013
1024
  recordScopedHook(this, this.catalog.size, "afterHandle")
1014
- return this
1025
+ return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
1015
1026
  }
1016
1027
 
1017
1028
  /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500).
1018
1029
  * Order-scoped: captured by routes declared *after* this call, none before; one added after the last
1019
1030
  * route reaches nothing (see {@link ServerOptions.unusedScopedHooks}). */
1020
- onError(fn: (error: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
1031
+ onError<H extends (error: unknown, context: Context & Ctx) => MaybePromise<unknown>>(
1032
+ fn: H,
1033
+ ): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
1021
1034
  this.assertConfigurable("onError()")
1022
1035
  this.onErrorHooks.push(fn as unknown as RawErrorHandler)
1023
1036
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1024
1037
  recordScopedHook(this, this.catalog.size, "onError")
1025
- return this
1038
+ return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
1026
1039
  }
1027
1040
 
1028
1041
  /** Transform every outgoing response - success, error, 404, 405, short-circuit. Global. */
@@ -1133,14 +1146,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1133
1146
 
1134
1147
  /** Enable the opt-in portable response observer methods. */
1135
1148
  use(plugin: ResponseObserverPlugin): this & ResponseObserverMethods
1136
- /**
1137
- * Apply a type-**identity** plugin ({@link IdentityPlugin}, from {@link defineIdentityPlugin}) - it
1138
- * registers routes/hooks but doesn't change the types, so this returns `this` with the route registry
1139
- * and context fully intact. This overload exists specifically so a named identity plugin (e.g.
1140
- * `@nifrajs/better-auth`) threads the registry: its `& { pluginName }` intersection would otherwise
1141
- * defeat the generic inference of the transforming overload below and collapse the result to `any`.
1142
- */
1143
- use(plugin: IdentityPlugin): this
1144
1149
  /**
1145
1150
  * Apply a **context** plugin ({@link ContextPlugin}, from {@link defineContextPlugin}) - it adds `D`
1146
1151
  * to the handler context and changes nothing else, so the route registry `R` and the existing context
@@ -1148,7 +1153,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1148
1153
  * `(app: this) => Out` overload below cannot infer through a *generic* plugin signature: it erases the
1149
1154
  * plugin's own type parameters to their constraints, which silently widens `R` to `Registry`.
1150
1155
  */
1151
- use<D extends object>(plugin: ContextPlugin<D>): Server<R, Ctx & D>
1156
+ use<D extends object>(plugin: ContextPlugin<D>): Server<R, Ctx & D, HookOutput>
1157
+ /**
1158
+ * Apply a type-**identity** plugin ({@link IdentityPlugin}, from {@link defineIdentityPlugin}) - it
1159
+ * registers routes/hooks but doesn't change the types, so this returns `this` with the route registry
1160
+ * and context fully intact. This overload exists specifically so a named identity plugin (e.g.
1161
+ * `@nifrajs/better-auth`) threads the registry: its `& { pluginName }` intersection would otherwise
1162
+ * defeat the generic inference of the transforming overload below and collapse the result to `any`.
1163
+ */
1164
+ use<AddedHookOutput = never>(
1165
+ plugin: IdentityPlugin<AddedHookOutput>,
1166
+ ): Server<R, Ctx, HookOutput | AddedHookOutput>
1152
1167
  /**
1153
1168
  * Apply a **plugin function** - `(app) => app`, typically built with {@link definePlugin}. It's
1154
1169
  * called with `this` and its result is returned, so an inline plugin's `derive`/`decorate` thread
@@ -1172,6 +1187,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1172
1187
  * the last route reaches nothing - {@link ServerOptions.unusedScopedHooks} logs that at seal. A named
1173
1188
  * bundle already applied is skipped (idempotent).
1174
1189
  */
1190
+ use<M extends Middleware>(mw: M): Server<R, Ctx, HookOutput | MiddlewareOutputOf<M>>
1175
1191
  use(mw: Middleware): this
1176
1192
  use(arg: Middleware | ((app: this) => AnyServer)): AnyServer {
1177
1193
  this.assertConfigurable("use()")
@@ -1256,16 +1272,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1256
1272
  path: Path,
1257
1273
  schema: S,
1258
1274
  handler: H,
1259
- ): Server<AddRoute<R, "GET", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
1275
+ ): Server<
1276
+ AddRoute<R, "GET", Path, RouteInfoFor<Path, S, OutputOf<H>, HookOutput>>,
1277
+ Ctx,
1278
+ HookOutput
1279
+ >
1260
1280
  get<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
1261
1281
  path: Path,
1262
1282
  handler: H,
1263
- ): Server<AddRoute<R, "GET", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
1283
+ ): Server<
1284
+ AddRoute<R, "GET", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>, HookOutput>>,
1285
+ Ctx,
1286
+ HookOutput
1287
+ >
1264
1288
  get(
1265
1289
  path: string,
1266
1290
  schemaOrHandler: RouteSchema | ErasedHandler,
1267
1291
  handler?: ErasedHandler,
1268
- ): Server<Registry, Ctx> {
1292
+ ): Server<Registry, Ctx, HookOutput> {
1269
1293
  return this.route("GET", path, schemaOrHandler, handler)
1270
1294
  }
1271
1295
 
@@ -1298,12 +1322,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1298
1322
  stream: TypedSSEStream<InferOutput<S["sse"]>>,
1299
1323
  ) => void | Promise<void>,
1300
1324
  init?: SSEInit,
1301
- ): Server<AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response>>, Ctx> {
1325
+ ): Server<
1326
+ AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response, HookOutput>>,
1327
+ Ctx,
1328
+ HookOutput
1329
+ > {
1302
1330
  const handler = (context: Context<Path, S> & Ctx): Response =>
1303
1331
  requireSseRuntime(this.sseRuntime).response(context, (stream) => run(context, stream), init)
1304
1332
  return this.route("GET", path, schema, handler as unknown as ErasedHandler) as Server<
1305
- AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response>>,
1306
- Ctx
1333
+ AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response, HookOutput>>,
1334
+ Ctx,
1335
+ HookOutput
1307
1336
  >
1308
1337
  }
1309
1338
 
@@ -1311,16 +1340,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1311
1340
  path: Path,
1312
1341
  schema: S,
1313
1342
  handler: H,
1314
- ): Server<AddRoute<R, "POST", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
1343
+ ): Server<
1344
+ AddRoute<R, "POST", Path, RouteInfoFor<Path, S, OutputOf<H>, HookOutput>>,
1345
+ Ctx,
1346
+ HookOutput
1347
+ >
1315
1348
  post<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
1316
1349
  path: Path,
1317
1350
  handler: H,
1318
- ): Server<AddRoute<R, "POST", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
1351
+ ): Server<
1352
+ AddRoute<R, "POST", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>, HookOutput>>,
1353
+ Ctx,
1354
+ HookOutput
1355
+ >
1319
1356
  post(
1320
1357
  path: string,
1321
1358
  schemaOrHandler: RouteSchema | ErasedHandler,
1322
1359
  handler?: ErasedHandler,
1323
- ): Server<Registry, Ctx> {
1360
+ ): Server<Registry, Ctx, HookOutput> {
1324
1361
  return this.route("POST", path, schemaOrHandler, handler)
1325
1362
  }
1326
1363
 
@@ -1328,16 +1365,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1328
1365
  path: Path,
1329
1366
  schema: S,
1330
1367
  handler: H,
1331
- ): Server<AddRoute<R, "PUT", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
1368
+ ): Server<
1369
+ AddRoute<R, "PUT", Path, RouteInfoFor<Path, S, OutputOf<H>, HookOutput>>,
1370
+ Ctx,
1371
+ HookOutput
1372
+ >
1332
1373
  put<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
1333
1374
  path: Path,
1334
1375
  handler: H,
1335
- ): Server<AddRoute<R, "PUT", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
1376
+ ): Server<
1377
+ AddRoute<R, "PUT", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>, HookOutput>>,
1378
+ Ctx,
1379
+ HookOutput
1380
+ >
1336
1381
  put(
1337
1382
  path: string,
1338
1383
  schemaOrHandler: RouteSchema | ErasedHandler,
1339
1384
  handler?: ErasedHandler,
1340
- ): Server<Registry, Ctx> {
1385
+ ): Server<Registry, Ctx, HookOutput> {
1341
1386
  return this.route("PUT", path, schemaOrHandler, handler)
1342
1387
  }
1343
1388
 
@@ -1345,16 +1390,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1345
1390
  path: Path,
1346
1391
  schema: S,
1347
1392
  handler: H,
1348
- ): Server<AddRoute<R, "PATCH", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
1393
+ ): Server<
1394
+ AddRoute<R, "PATCH", Path, RouteInfoFor<Path, S, OutputOf<H>, HookOutput>>,
1395
+ Ctx,
1396
+ HookOutput
1397
+ >
1349
1398
  patch<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
1350
1399
  path: Path,
1351
1400
  handler: H,
1352
- ): Server<AddRoute<R, "PATCH", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
1401
+ ): Server<
1402
+ AddRoute<R, "PATCH", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>, HookOutput>>,
1403
+ Ctx,
1404
+ HookOutput
1405
+ >
1353
1406
  patch(
1354
1407
  path: string,
1355
1408
  schemaOrHandler: RouteSchema | ErasedHandler,
1356
1409
  handler?: ErasedHandler,
1357
- ): Server<Registry, Ctx> {
1410
+ ): Server<Registry, Ctx, HookOutput> {
1358
1411
  return this.route("PATCH", path, schemaOrHandler, handler)
1359
1412
  }
1360
1413
 
@@ -1362,16 +1415,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1362
1415
  path: Path,
1363
1416
  schema: S,
1364
1417
  handler: H,
1365
- ): Server<AddRoute<R, "DELETE", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
1418
+ ): Server<
1419
+ AddRoute<R, "DELETE", Path, RouteInfoFor<Path, S, OutputOf<H>, HookOutput>>,
1420
+ Ctx,
1421
+ HookOutput
1422
+ >
1366
1423
  delete<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
1367
1424
  path: Path,
1368
1425
  handler: H,
1369
- ): Server<AddRoute<R, "DELETE", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
1426
+ ): Server<
1427
+ AddRoute<R, "DELETE", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>, HookOutput>>,
1428
+ Ctx,
1429
+ HookOutput
1430
+ >
1370
1431
  delete(
1371
1432
  path: string,
1372
1433
  schemaOrHandler: RouteSchema | ErasedHandler,
1373
1434
  handler?: ErasedHandler,
1374
- ): Server<Registry, Ctx> {
1435
+ ): Server<Registry, Ctx, HookOutput> {
1375
1436
  return this.route("DELETE", path, schemaOrHandler, handler)
1376
1437
  }
1377
1438
 
@@ -1414,10 +1475,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1414
1475
  S["output"] extends StandardSchemaV1
1415
1476
  ? { body: S["input"]; response: S["output"] }
1416
1477
  : { body: S["input"] },
1417
- OutputOf<H>
1478
+ OutputOf<H>,
1479
+ HookOutput
1418
1480
  >
1419
1481
  >,
1420
- Ctx
1482
+ Ctx,
1483
+ HookOutput
1421
1484
  >
1422
1485
  tool(
1423
1486
  name: string,
@@ -1428,7 +1491,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1428
1491
  annotations?: ToolAnnotations
1429
1492
  },
1430
1493
  handler: (input: unknown, ctx: Context & Ctx) => unknown,
1431
- ): Server<Registry, Ctx> {
1494
+ ): Server<Registry, Ctx, HookOutput> {
1432
1495
  const plan = requireMcpRuntime(this.mcpRuntime).tool(
1433
1496
  name,
1434
1497
  config,
@@ -1441,7 +1504,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1441
1504
  if (lastRoute) {
1442
1505
  ;(lastRoute as { tool?: RouteDescriptor["tool"] }).tool = plan.descriptor
1443
1506
  }
1444
- return this as unknown as Server<Registry, Ctx>
1507
+ return this as unknown as Server<Registry, Ctx, HookOutput>
1445
1508
  }
1446
1509
 
1447
1510
  /**
@@ -1454,7 +1517,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1454
1517
  uri: string,
1455
1518
  config: { readonly name: string; readonly description?: string; readonly mimeType?: string },
1456
1519
  read: McpResourceDescriptor["read"],
1457
- ): Server<R, Ctx> {
1520
+ ): Server<R, Ctx, HookOutput> {
1458
1521
  this.assertConfigurable("resource()")
1459
1522
  this.mcpResourceList.push(requireMcpRuntime(this.mcpRuntime).resource(uri, config, read))
1460
1523
  return this
@@ -1468,7 +1531,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1468
1531
  name: string,
1469
1532
  config: { readonly description: string; readonly arguments?: readonly PromptArgument[] },
1470
1533
  handler: McpPromptDescriptor["handler"],
1471
- ): Server<R, Ctx> {
1534
+ ): Server<R, Ctx, HookOutput> {
1472
1535
  this.assertConfigurable("prompt()")
1473
1536
  this.mcpPromptList.push(requireMcpRuntime(this.mcpRuntime).prompt(name, config, handler))
1474
1537
  return this
@@ -1508,8 +1571,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1508
1571
  path: Path,
1509
1572
  handler: WebSocketHandler<Data, EnvOf<Ctx>, Schema, Send>,
1510
1573
  ): string extends Path
1511
- ? Server<R, Ctx>
1512
- : Server<AddRoute<R, "WS", Path, WsRouteInfoFor<Path, Schema, Send>>, Ctx> {
1574
+ ? Server<R, Ctx, HookOutput>
1575
+ : Server<AddRoute<R, "WS", Path, WsRouteInfoFor<Path, Schema, Send>>, Ctx, HookOutput> {
1513
1576
  this.assertConfigurable("ws()")
1514
1577
  // Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
1515
1578
  // bundle it. Registration is the loud, early failure point - never the first connection.
@@ -1520,6 +1583,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1520
1583
  `route WS ${path}: validateSend requires sendSchema`,
1521
1584
  )
1522
1585
  }
1586
+ // A validated-send route makes broadcast bytes route-dependent (drop-on-invalid per socket), so the
1587
+ // whole app forgoes native pub/sub and keeps the JS registry loop that runs each send through the sender.
1588
+ if (handler.validateSend === true) this.wsHasValidatedSend = true
1523
1589
  this.topics ??= runtime.createTopics()
1524
1590
  // A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
1525
1591
  // already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
@@ -1536,6 +1602,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1536
1602
  * an external fan-out (Redis, a Durable Object) to this. A no-op when nobody is subscribed.
1537
1603
  */
1538
1604
  publish(topic: string, data: string | ArrayBufferView | ArrayBuffer): void {
1605
+ // Native-pubsub mode (Bun, no validated-send route): one native broadcast to every subscriber, no
1606
+ // per-connection JS loop. Bound only after `listen()`; before that (or off Bun) the registry runs.
1607
+ if (this.nativePublish !== undefined) {
1608
+ this.nativePublish(topic, data)
1609
+ return
1610
+ }
1539
1611
  // No `app.ws()` yet ⇒ no registry and necessarily no subscribers - a publish is a no-op anyway.
1540
1612
  this.topics?.publish(topic, data)
1541
1613
  }
@@ -1545,7 +1617,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1545
1617
  path: string,
1546
1618
  schemaOrHandler: RouteSchema | ErasedHandler,
1547
1619
  handler?: ErasedHandler,
1548
- ): Server<Registry, Ctx> {
1620
+ ): Server<Registry, Ctx, HookOutput> {
1549
1621
  let rawHandler: ErasedHandler
1550
1622
  let schema: RouteSchema | undefined
1551
1623
  if (handler !== undefined) {
@@ -1558,7 +1630,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1558
1630
  this.register(method, path, schema, rawHandler)
1559
1631
  // The accumulated registry type is compile-time only; the same instance
1560
1632
  // carries every route, so the public methods re-type `this` per call.
1561
- return this as unknown as Server<Registry, Ctx>
1633
+ return this as unknown as Server<Registry, Ctx, HookOutput>
1562
1634
  }
1563
1635
 
1564
1636
  /**
@@ -1599,162 +1671,58 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1599
1671
  schema: RouteSchema | undefined,
1600
1672
  handler: (context: never) => unknown,
1601
1673
  ): CatalogRoute {
1602
- const pattern = compileRoutePattern(path)
1603
- // Resolved here, once: `undefined` past this point means the route declared `"unlimited"`, NOT
1604
- // "nothing declared". Readers fall back to UNLIMITED_BODY_BYTES accordingly.
1605
- let bodyLimit: number | undefined = this.maxBodyBytes
1606
- const invalidBodyLimit = (message: string): never => {
1607
- throw new RouteConfigError("INVALID_BODY_LIMIT", `route ${method} ${path}: ${message}`)
1608
- }
1609
- if (schema?.bodyLimitReason !== undefined && schema.bodyLimit !== "unlimited") {
1610
- invalidBodyLimit('bodyLimitReason is only valid with bodyLimit: "unlimited"')
1611
- }
1612
- if (schema?.bodyLimit === "unlimited") {
1613
- if (
1614
- typeof schema.bodyLimitReason !== "string" ||
1615
- schema.bodyLimitReason.trim().length === 0
1616
- ) {
1617
- invalidBodyLimit('bodyLimit: "unlimited" requires a non-empty bodyLimitReason')
1618
- }
1619
- if (schema.body !== undefined) {
1620
- invalidBodyLimit(
1621
- 'bodyLimit: "unlimited" cannot be used with a body schema; use a finite cap',
1622
- )
1623
- }
1624
- if (schema.idempotency !== undefined) {
1625
- invalidBodyLimit('bodyLimit: "unlimited" cannot be used with idempotency')
1626
- }
1627
- bodyLimit = undefined
1628
- } else if (schema?.bodyLimit !== undefined) {
1629
- try {
1630
- assertByteLimit(schema.bodyLimit, "route bodyLimit")
1631
- } catch {
1632
- invalidBodyLimit('bodyLimit must be a non-negative safe integer or "unlimited"')
1633
- }
1634
- bodyLimit = schema.bodyLimit
1635
- }
1636
- const capabilities = normalizeRouteCapabilities(schema?.capabilities)
1637
- const handlerAssurance = assuranceDeclarationsOf(handler as unknown as object)
1638
- const invalidHandlerScope = handlerAssurance.find(
1639
- (declaration) => declaration.scope !== "plugin",
1640
- )
1641
- if (invalidHandlerScope !== undefined) {
1642
- throw new RouteConfigError(
1643
- "INVALID_ASSURANCE",
1644
- `route handler assurance must use plugin scope (received ${invalidHandlerScope.scope})`,
1645
- )
1646
- }
1647
- const authenticated = assuranceEvidenceFor(
1648
- [...this.activeAssurance, ...handlerAssurance, ...this.globalAssurance],
1649
- method,
1650
- path,
1651
- ).some((evidence) => evidence.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED)
1652
- const routeDecorations: Record<PropertyKey, unknown> = { ...this.decorations }
1653
- if (capabilities.length > 0) {
1654
- routeDecorations[CAPABILITY_GUARD] = createCapabilityGuard(
1655
- capabilities,
1656
- method,
1657
- path,
1658
- this.onCapabilityUse,
1659
- this.idempotencyRuntime?.trackEffect,
1660
- Object.freeze([...this.capabilityInterceptors]),
1661
- Object.freeze([...this.capabilityObservers]),
1662
- )
1663
- }
1664
- const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0
1665
- // An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
1666
- // never takes the fused/native fast path - force it onto the portable matched lane (which routes
1667
- // through `fetchMatched`, where the dedupe wrapper lives). Fail closed: a route may not declare
1668
- // idempotency unless the idempotency runtime is installed, so the safety gate can never be silently
1669
- // dropped by a missing plugin.
1670
- if (schema?.idempotency !== undefined && this.idempotencyRuntime === undefined) {
1671
- throw new RouteConfigError(
1672
- "INVALID_IDEMPOTENCY",
1673
- "route declares idempotency but the idempotency plugin is not installed; add .use(idempotency())",
1674
- )
1675
- }
1676
- const idempotent = this.idempotencyRuntime?.resolve(schema, authenticated, this.maxBodyBytes)
1677
- // A ledgered route (capabilities declared + `.use(effectLedger())`) needs a per-request
1678
- // context to carry the ledger and a settle step to seal + sink it, so it too leaves the
1679
- // fused/contextless fast path. Resolved per route, at registration - like the capability guard.
1680
- const ledgered: ResolvedEffectLedger | undefined = this.effectLedgerRuntime?.resolve(
1681
- capabilities,
1674
+ const compiled = compileRouteOptions(
1675
+ {
1676
+ maxBodyBytes: this.maxBodyBytes,
1677
+ activeAssurance: this.activeAssurance,
1678
+ globalAssurance: this.globalAssurance,
1679
+ decorations: this.decorations,
1680
+ onCapabilityUse: this.onCapabilityUse,
1681
+ capabilityInterceptors: this.capabilityInterceptors,
1682
+ capabilityObservers: this.capabilityObservers,
1683
+ effectLedgerRuntime: this.effectLedgerRuntime,
1684
+ idempotencyRuntime: this.idempotencyRuntime,
1685
+ responseContractRuntime: this.responseContractRuntime,
1686
+ derives: this.derives,
1687
+ beforeHandleHooks: this.beforeHandleHooks,
1688
+ afterHandleHooks: this.afterHandleHooks,
1689
+ onErrorHooks: this.onErrorHooks,
1690
+ aroundHooks: this.aroundHooks,
1691
+ defaultOnValidationError: this.defaultOnValidationError,
1692
+ },
1682
1693
  method,
1683
1694
  path,
1695
+ schema,
1696
+ handler,
1684
1697
  )
1685
- // A checked response schema needs the handler's VALUE before it becomes bytes, and the fused and
1686
- // native lanes exist precisely to skip that step. So a contracted route leaves them - the same
1687
- // structural trade an idempotent route makes above. The check itself is not the cost: a compiled
1688
- // validator measures ~100ns/response, within benchmark noise on any route these lanes already
1689
- // exclude (middleware, derives, lifecycle hooks).
1690
- const runtime = this.responseContractRuntime
1691
- const contracted =
1692
- runtime !== undefined && schema?.response !== undefined
1693
- ? { runtime, schema: schema.response }
1694
- : undefined
1695
- const bare =
1696
- schema?.params === undefined &&
1697
- schema?.headers === undefined &&
1698
- schema?.body === undefined &&
1699
- schema?.query === undefined &&
1700
- idempotent === undefined &&
1701
- ledgered === undefined &&
1702
- contracted === undefined &&
1703
- this.derives.length === 0 &&
1704
- this.beforeHandleHooks.length === 0 &&
1705
- this.afterHandleHooks.length === 0 &&
1706
- this.onErrorHooks.length === 0
1707
- // A route whose ONLY lifecycle step is a query schema can fuse too: the parse + validate +
1708
- // handler + respond collapse into one closure with no lifecycle promise on the sync path. The
1709
- // guards mirror the `query` lane below PLUS everything the fused dispatch skips: around hooks,
1710
- // the idempotency/ledger wrappers, and validation-error recovery (schema or server default) -
1711
- // the fused invalid path is exactly `validationError(issues)`, so any recovery semantics keep
1712
- // the generic lane.
1713
- const fusedQuery =
1714
- !bare &&
1715
- contracted === undefined &&
1716
- schema?.query !== undefined &&
1717
- schema.body === undefined &&
1718
- schema.params === undefined &&
1719
- schema.headers === undefined &&
1720
- schema.onValidationError === undefined &&
1721
- this.defaultOnValidationError === undefined &&
1722
- idempotent === undefined &&
1723
- ledgered === undefined &&
1724
- this.derives.length === 0 &&
1725
- this.beforeHandleHooks.length === 0 &&
1726
- this.afterHandleHooks.length === 0 &&
1727
- this.onErrorHooks.length === 0 &&
1728
- this.aroundHooks.length === 0
1729
- const bodyOnly =
1730
- contracted === undefined &&
1731
- schema?.body !== undefined &&
1732
- schema.query === undefined &&
1733
- schema.params === undefined &&
1734
- schema.headers === undefined &&
1735
- this.derives.length === 0 &&
1736
- this.beforeHandleHooks.length === 0 &&
1737
- this.afterHandleHooks.length === 0 &&
1738
- this.onErrorHooks.length === 0
1739
- const fusedBody =
1740
- !bare &&
1741
- bodyOnly &&
1742
- schema.onValidationError === undefined &&
1743
- this.defaultOnValidationError === undefined &&
1744
- idempotent === undefined &&
1745
- ledgered === undefined &&
1746
- this.aroundHooks.length === 0
1747
- // Body-only routes still need an async body read, but eligible routes can compile the validation +
1748
- // handler continuation once. The runner keeps the bounded parser and all error semantics while
1749
- // avoiding the generic entry/schema/lifecycle dispatch on Bun, Deno, and Node-direct.
1698
+ const {
1699
+ pattern,
1700
+ bodyLimit,
1701
+ capabilities,
1702
+ routeDecorations,
1703
+ hasDecorations,
1704
+ idempotent,
1705
+ ledgered,
1706
+ responseContract,
1707
+ lanes,
1708
+ routeAssurance,
1709
+ } = compiled
1710
+ const { bare, fusedQuery, fusedBody } = lanes
1750
1711
  const fusedBodyRunner = fusedBody
1751
1712
  ? this.buildFusedBodyRunner(
1752
1713
  handler as unknown as InternalHandler,
1753
- schema.body as StandardSchemaV1,
1714
+ schema?.body as StandardSchemaV1,
1754
1715
  hasDecorations ? routeDecorations : undefined,
1755
1716
  bodyLimit ?? UNLIMITED_BODY_BYTES,
1756
1717
  )
1757
1718
  : undefined
1719
+ // Fused lifecycle lanes: derive + before (with or without an after), and body + the same shape.
1720
+ // The lane selectors in `selectRouteLanes` are exhaustive about the lifecycleHookLane /
1721
+ // body-derive-before-after eligibility, so we only need to check the lane and the hook counts.
1722
+ const lifecycleHookLane = lanes.lifecycleHookLane
1723
+ const isFusedLifecycle = lifecycleHookLane !== undefined && this.derives.length === 1
1724
+ const isFusedBodyLifecycle =
1725
+ lanes.fusedLane === "body-derive-before" || lanes.fusedLane === "body-derive-before-after"
1758
1726
  const fusedWeb =
1759
1727
  bare && this.aroundHooks.length === 0
1760
1728
  ? this.buildFusedWeb(
@@ -1767,87 +1735,79 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1767
1735
  ? this.buildFusedQueryWeb(
1768
1736
  handler as unknown as InternalHandler,
1769
1737
  hasDecorations ? routeDecorations : undefined,
1770
- schema.query as StandardSchemaV1,
1738
+ schema?.query as StandardSchemaV1,
1771
1739
  bodyLimit ?? UNLIMITED_BODY_BYTES,
1772
1740
  )
1773
1741
  : fusedBody
1774
1742
  ? this.buildFusedBodyWeb(fusedBodyRunner as FusedBodyRunner)
1775
- : undefined
1776
- const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler)
1777
- // The body and query lanes finalize the handler's result themselves, so a contracted route takes
1778
- // the general lifecycle lane instead - one place owns the check rather than three.
1779
- const lane = bare
1780
- ? "bare"
1781
- : bodyOnly
1782
- ? "body"
1783
- : contracted === undefined &&
1784
- schema?.body === undefined &&
1785
- schema?.query !== undefined &&
1786
- schema.params === undefined &&
1787
- schema.headers === undefined &&
1788
- this.derives.length === 0 &&
1789
- this.beforeHandleHooks.length === 0 &&
1790
- this.afterHandleHooks.length === 0 &&
1791
- this.onErrorHooks.length === 0
1792
- ? "query"
1793
- : "lifecycle"
1794
- // Lifecycle routes with no params schema are the common middleware shape: a derive/before hook
1795
- // plus an optional query or body schema. Select their complete validation stage at registration
1796
- // so the request path never re-checks params/body presence. Parameter-schema routes retain the
1797
- // generic lifecycle runner until their more involved recovery matrix is selected explicitly.
1798
- const lifecycleLane =
1799
- lane !== "lifecycle" || schema?.params !== undefined || schema?.headers !== undefined
1800
- ? undefined
1801
- : schema?.body !== undefined
1802
- ? schema.query !== undefined
1803
- ? "body-query"
1804
- : "body"
1805
- : schema?.query !== undefined
1806
- ? "query"
1807
- : "hooks"
1808
- // The realistic middleware shape is commonly exactly one synchronous-or-async derive followed
1809
- // by one before hook. Keep the generic runner for every route that can observe decorations,
1810
- // after hooks, error hooks, or response contracts; this lane only removes the two per-request
1811
- // hook-loop dispatches and preserves the same async continuations and error handling.
1812
- const lifecycleHookLane =
1813
- lane === "lifecycle" &&
1814
- contracted === undefined &&
1815
- !hasDecorations &&
1816
- this.derives.length === 1 &&
1817
- this.beforeHandleHooks.length === 1 &&
1818
- this.afterHandleHooks.length === 0 &&
1819
- this.onErrorHooks.length === 0
1820
- ? "derive-before"
1821
- : undefined
1743
+ : isFusedBodyLifecycle
1744
+ ? this.buildFusedBodyDeriveBeforeAfter(
1745
+ handler as unknown as InternalHandler,
1746
+ this.derives[0]!,
1747
+ this.beforeHandleHooks[0]!,
1748
+ this.afterHandleHooks[0],
1749
+ schema?.body as StandardSchemaV1,
1750
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
1751
+ )
1752
+ : isFusedLifecycle && lifecycleHookLane === "derive-before"
1753
+ ? this.buildFusedDeriveBefore(
1754
+ handler as unknown as InternalHandler,
1755
+ this.derives[0]!,
1756
+ this.beforeHandleHooks[0]!,
1757
+ schema?.query,
1758
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
1759
+ )
1760
+ : isFusedLifecycle && lifecycleHookLane === "derive-before-after"
1761
+ ? this.buildFusedDeriveBeforeAfter(
1762
+ handler as unknown as InternalHandler,
1763
+ this.derives[0]!,
1764
+ this.beforeHandleHooks[0]!,
1765
+ this.afterHandleHooks[0]!,
1766
+ schema?.query,
1767
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
1768
+ )
1769
+ : undefined
1770
+ const program = compileRouteProgram({
1771
+ schema,
1772
+ handler: handler as unknown as InternalHandler,
1773
+ derives: this.derives,
1774
+ beforeHandle: this.beforeHandleHooks,
1775
+ afterHandle: this.afterHandleHooks,
1776
+ onError: this.onErrorHooks,
1777
+ decorations: routeDecorations,
1778
+ hasDecorations,
1779
+ bodySchema: schema?.body,
1780
+ bodyLimit,
1781
+ responseContract,
1782
+ })
1783
+ const contextless =
1784
+ lanes.bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler)
1822
1785
  const execution = compileRouteExecutionPlan({
1823
- lane,
1786
+ lane: lanes.lane,
1824
1787
  contextless,
1825
1788
  hasAround: this.aroundHooks.length > 0,
1826
1789
  hasLedger: ledgered !== undefined,
1827
- lifecycleLane,
1828
1790
  fusedWeb,
1829
1791
  fusedBody: fusedBodyRunner,
1830
- fusedLane:
1831
- fusedWeb === undefined ? undefined : fusedQuery ? "query" : fusedBody ? "body" : "bare",
1792
+ fusedLane: lanes.fusedLane,
1832
1793
  })
1833
1794
  const registeredEntry: RouteEntry = {
1834
- // (context: never) => unknown -> InternalHandler: the framework invokes it
1835
- // with the concrete RawContext the typed handler expects, so this is sound.
1836
1795
  handler: handler as unknown as InternalHandler,
1837
1796
  schema,
1838
1797
  bodyLimit,
1839
1798
  idempotent,
1840
1799
  ledgered,
1841
- responseContract: contracted,
1800
+ responseContract,
1842
1801
  derives: [...this.derives],
1843
1802
  decorations: routeDecorations,
1844
1803
  hasDecorations,
1845
1804
  beforeHandle: [...this.beforeHandleHooks],
1846
1805
  afterHandle: [...this.afterHandleHooks],
1847
1806
  onError: [...this.onErrorHooks],
1848
- lifecycleHookLane,
1807
+ lifecycleHookLane: lanes.lifecycleHookLane,
1849
1808
  around: [...this.aroundHooks],
1850
1809
  execution,
1810
+ program,
1851
1811
  }
1852
1812
  const descriptor: RouteDescriptor = {
1853
1813
  method,
@@ -1856,72 +1816,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1856
1816
  ...(capabilities.length > 0 ? { capabilities } : {}),
1857
1817
  ...(schema?.family === true ? { family: true } : {}),
1858
1818
  }
1859
- const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance, ...handlerAssurance]
1860
- if (contracted?.runtime.mode === "enforce" && schema?.response !== undefined) {
1861
- routeAssurance.push(
1862
- Object.freeze({
1863
- id: NIFRA_ASSURANCE_IDS.RESPONSE_CONTRACT,
1864
- source: "response-contract",
1865
- scope: "plugin",
1866
- }),
1867
- )
1868
- }
1869
- // Inline `schema.assurance`: the route DECLARES its enforcement evidence adjacent to the handler, so an
1870
- // in-handler-guarded route satisfies a policy `require:` clause without a `withRouteAssurance` middleware
1871
- // rewrite. Each id becomes route-scoped `declared` evidence (invalid ids fail closed at registration).
1872
- for (const id of schema?.assurance ?? []) {
1873
- if (!validEvidenceId(id)) {
1874
- throw new Error(
1875
- `route assurance: invalid evidence id ${JSON.stringify(id)} on ${method} ${path} (use lowercase dot/dash segments)`,
1876
- )
1877
- }
1878
- const declared = { id, source: "declared", scope: "plugin" } as AssuranceDeclaration
1879
- Object.defineProperty(declared, "provenance", {
1880
- value: "declared",
1881
- enumerable: false,
1882
- configurable: false,
1883
- writable: false,
1884
- })
1885
- routeAssurance.push(Object.freeze(declared))
1886
- }
1887
- // Two ways a route proves its body is bounded, both enforced by the core at read time:
1888
- // `schema.body` (the validated read is bounded) and an explicit finite `schema.bodyLimit` (the
1889
- // transport cap shadows every reader, including a raw `c.req.body` stream that a Content-Length
1890
- // middleware cannot bound).
1891
- // `bodyLimit: "unlimited"` is rejected above for schema and idempotency routes. The remaining
1892
- // exemption is schema-less streaming/upload work, so it publishes NOTHING: there is no finite
1893
- // framework byte ceiling for assurance to attest and the application protocol owns that bound.
1894
- // The server-wide `maxBodyBytes` default is not evidence either. It applies to every route
1895
- // whether or not anyone thought about it, so publishing it would make the id pass everywhere and
1896
- // prove nothing; this evidence marks a bound the route chose.
1897
- const unlimitedBody = schema?.bodyLimit === "unlimited"
1898
- if (!unlimitedBody && (schema?.body !== undefined || typeof schema?.bodyLimit === "number")) {
1899
- routeAssurance.push(
1900
- Object.freeze({
1901
- id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
1902
- source: "route-schema",
1903
- scope: "plugin",
1904
- }),
1905
- )
1906
- }
1907
- // Declaring `schema.idempotency` is evidence for request replay only. It deliberately never proves
1908
- // durable command execution; that stronger evidence belongs to a command/outbox adapter.
1909
- if (schema?.idempotency !== undefined) {
1910
- routeAssurance.push(
1911
- Object.freeze({
1912
- id: NIFRA_ASSURANCE_IDS.IDEMPOTENCY_KEY,
1913
- source: "route-schema",
1914
- scope: "plugin",
1915
- }),
1916
- )
1917
- }
1918
1819
  return {
1919
1820
  method,
1920
1821
  path,
1921
1822
  pattern,
1922
1823
  entry: registeredEntry,
1923
1824
  descriptor,
1924
- assurance: Object.freeze(routeAssurance),
1825
+ assurance: routeAssurance,
1925
1826
  }
1926
1827
  }
1927
1828
 
@@ -2032,7 +1933,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2032
1933
  * `RouteConfigError` at merge time, and a group with WebSocket routes is refused (register
2033
1934
  * those on the parent).
2034
1935
  */
2035
- merge<R2 extends Registry, Ctx2>(other: Server<R2, Ctx2>): Server<R & R2, Ctx> {
1936
+ merge<R2 extends Registry, Ctx2, HookOutput2>(
1937
+ other: Server<R2, Ctx2, HookOutput2>,
1938
+ ): Server<R & R2, Ctx, HookOutput> {
2036
1939
  this.assertConfigurable("merge()")
2037
1940
  const source = other as unknown as Server<Registry, EmptyContext>
2038
1941
  if (source.wsRouteCount > 0) {
@@ -2117,7 +2020,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2117
2020
  if (!scoped) this.globalAssurance.push(...source.globalAssurance)
2118
2021
  this.mcpResourceList.push(...source.mcpResourceList)
2119
2022
  this.mcpPromptList.push(...source.mcpPromptList)
2120
- return this as unknown as Server<R & R2, Ctx>
2023
+ return this as unknown as Server<R & R2, Ctx, HookOutput>
2121
2024
  }
2122
2025
 
2123
2026
  /** A fused renderer closes over runtime services to keep its seven-argument JSC fast path. Merging
@@ -2127,8 +2030,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2127
2030
  if (entry.execution.fusedWeb === undefined) return route
2128
2031
  // Rebuild with the SAME builder that produced the closure - rebinding a query-fused route as
2129
2032
  // bare would silently drop its validation.
2033
+ const lane = entry.execution.fusedLane
2034
+ // Type-erase the lane so each `===` branch sees the full union, not the narrowed remainder.
2035
+ const laneName: string | undefined = lane
2036
+ // Body+lifecycle routes have a fused Web renderer, but no body-only Node renderer: the latter
2037
+ // would bypass derive/before/after because the Node-direct dispatcher prefers `fusedBody` over
2038
+ // the generic execution plan. Only the original body-only lane may populate this slot.
2130
2039
  const fusedBody =
2131
- entry.execution.fusedLane === "body"
2040
+ laneName === "body"
2132
2041
  ? this.buildFusedBodyRunner(
2133
2042
  entry.handler,
2134
2043
  entry.schema?.body as StandardSchemaV1,
@@ -2136,22 +2045,66 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2136
2045
  entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2137
2046
  )
2138
2047
  : undefined
2048
+ const derive = entry.derives[0]
2049
+ const before = entry.beforeHandle[0]
2050
+ const after = entry.afterHandle[0]
2139
2051
  const fusedWeb =
2140
- entry.execution.fusedLane === "body"
2052
+ laneName === "body"
2141
2053
  ? this.buildFusedBodyWeb(fusedBody as FusedBodyRunner)
2142
- : entry.execution.fusedLane === "query"
2054
+ : laneName === "query"
2143
2055
  ? this.buildFusedQueryWeb(
2144
2056
  entry.handler,
2145
2057
  entry.hasDecorations ? entry.decorations : undefined,
2146
2058
  entry.schema?.query as StandardSchemaV1,
2147
2059
  entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2148
2060
  )
2149
- : this.buildFusedWeb(
2150
- entry.handler,
2151
- entry.hasDecorations ? entry.decorations : undefined,
2152
- isContextlessNoArgArrow(entry.handler),
2153
- entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2154
- )
2061
+ : laneName === "body-derive-before" && derive !== undefined && before !== undefined
2062
+ ? this.buildFusedBodyDeriveBeforeAfter(
2063
+ entry.handler,
2064
+ derive,
2065
+ before,
2066
+ undefined,
2067
+ entry.schema?.body as StandardSchemaV1,
2068
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2069
+ )
2070
+ : laneName === "body-derive-before-after" &&
2071
+ derive !== undefined &&
2072
+ before !== undefined &&
2073
+ after !== undefined
2074
+ ? this.buildFusedBodyDeriveBeforeAfter(
2075
+ entry.handler,
2076
+ derive,
2077
+ before,
2078
+ after,
2079
+ entry.schema?.body as StandardSchemaV1,
2080
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2081
+ )
2082
+ : laneName === "derive-before" && derive !== undefined && before !== undefined
2083
+ ? this.buildFusedDeriveBefore(
2084
+ entry.handler,
2085
+ derive,
2086
+ before,
2087
+ entry.schema?.query,
2088
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2089
+ )
2090
+ : laneName === "derive-before-after" &&
2091
+ derive !== undefined &&
2092
+ before !== undefined &&
2093
+ after !== undefined
2094
+ ? this.buildFusedDeriveBeforeAfter(
2095
+ entry.handler,
2096
+ derive,
2097
+ before,
2098
+ after,
2099
+ entry.schema?.query,
2100
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2101
+ )
2102
+ : this.buildFusedWeb(
2103
+ entry.handler,
2104
+ entry.hasDecorations ? entry.decorations : undefined,
2105
+ isContextlessNoArgArrow(entry.handler),
2106
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2107
+ )
2155
2108
  return {
2156
2109
  ...route,
2157
2110
  entry: {
@@ -2949,7 +2902,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2949
2902
  /**
2950
2903
  * Route → build context → run. Synchronous through to the handler for a **bare** route
2951
2904
  * (selected by its compiled plan), so a sync handler produces its result with zero promise allocations;
2952
- * routes with validation/hooks keep the full async {@link runLifecycle}, unchanged.
2905
+ * routes with validation/hooks keep the full compiled route program, unchanged.
2953
2906
  */
2954
2907
  private routeAndRun<T>(
2955
2908
  source: RequestSource,
@@ -3219,7 +3172,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3219
3172
  /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
3220
3173
  * successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
3221
3174
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3222
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3223
3175
  private runContextlessBare<T>(
3224
3176
  entry: RouteEntry,
3225
3177
  source: RequestSource,
@@ -3290,19 +3242,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3290
3242
  this.protoPoisoning,
3291
3243
  )
3292
3244
  this.logRequestError(err, ctx)
3293
- return wrapResponse(plainError(500, "internal_error"))
3245
+ return this.internalErrorResponse(wrapResponse)
3294
3246
  }
3295
3247
 
3296
3248
  /**
3297
3249
  * The synchronous fast path selected by a route's execution plan: apply static decorations, call the
3298
3250
  * handler, render the result - **no `await`** unless the handler itself returns a promise. It mirrors
3299
- * the bare slice of {@link runLifecycle} (which a bare route would otherwise no-op through) and shares
3251
+ * the same error boundary as the compiled route program (which a bare route would otherwise no-op through) and shares
3300
3252
  * {@link logRequestError}; a bare route has no `onError` hooks, so error handling is fully synchronous
3301
3253
  * (a thrown `Response` is control flow; anything else is a logged flat 500). This is where nifra skips
3302
3254
  * the per-request async-frame tax - the same win codegen routers get, but without `eval`.
3303
3255
  */
3304
3256
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3305
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3306
3257
  private runBare<T>(
3307
3258
  entry: RouteEntry,
3308
3259
  ctx: RawContext,
@@ -3326,7 +3277,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3326
3277
  return finalize(result, responseSet(ctx))
3327
3278
  }
3328
3279
 
3329
- /** Bare-route error rendering - identical to {@link runLifecycle}'s catch minus the (absent) onError
3280
+ /** Bare-route error rendering - identical to the compiled route program's catch minus the (absent) onError
3330
3281
  * loop: a thrown `Response` is returned as deliberate control flow; anything else is logged + 500. */
3331
3282
  /**
3332
3283
  * Build a route's fused Web renderer. Composition happens once at
@@ -3704,1010 +3655,968 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3704
3655
  }
3705
3656
  }
3706
3657
 
3707
- private bareError<T>(
3708
- err: unknown,
3709
- ctx: RawContext,
3710
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3711
- wrapResponse: (response: Response | ResponseResult) => T,
3712
- ): T {
3713
- // The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
3714
- // lane and the fused body runner. `responseSet` and the logger are injected because they reach
3715
- // into class state; the closure is allocated only on the throw path, never on the hot lane.
3716
- return renderBareError(err, ctx, finalize, wrapResponse, responseSet, (e, c) =>
3717
- this.logRequestError(e, c),
3718
- )
3719
- }
3720
-
3721
- // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3722
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3723
- private runWithAround<T>(
3724
- entry: RouteEntry,
3725
- ctx: RawContext,
3726
- run: () => MaybePromise<T>,
3727
- finalize: (result: unknown, set: CtxSet) => T,
3728
- wrapResponse: (response: Response | ResponseResult) => T,
3729
- ): MaybePromise<T> {
3730
- let outcome: MaybePromise<T>
3731
- try {
3732
- outcome = this.runAround(entry.around, ctx, run)
3733
- } catch (err) {
3734
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3735
- }
3736
- return outcome instanceof Promise
3737
- ? outcome.catch((err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse))
3738
- : outcome
3739
- }
3740
-
3741
- private runAround<T>(
3742
- hooks: ReadonlyArray<RawAround>,
3743
- ctx: RawContext,
3744
- run: () => MaybePromise<T>,
3745
- ): MaybePromise<T> {
3746
- const dispatch = (index: number): MaybePromise<T> => {
3747
- if (index >= hooks.length) return run()
3748
- const hook = hooks[index]!
3749
- let called = false
3750
- return hook(ctx, () => {
3751
- if (called) throw new Error("around next() called multiple times")
3752
- called = true
3753
- return dispatch(index + 1)
3754
- })
3658
+ /**
3659
+ * Compile a route's `derive + before` lifecycle to a single closure: the per-request walk through
3660
+ * `runSync` → `runHooksSync` is a stack of 3 frames and 2 `Object.assign`s for what is by far the
3661
+ * most common middleware shape (auth/trace/id derivation, an auth/policy `beforeHandle`). Folding
3662
+ * the same steps into one closure hits the same monomorphic inline-cache site as `buildFusedWeb`,
3663
+ * and `Object.assign(ctx, deriveResult)` becomes one extra property write on the context's already
3664
+ * hot hidden class. Behavior is byte-identical to the compiled route program: the same thrown-value
3665
+ * contract (`Response` is control flow, anything else logs + 500), the same `c.set` semantics, the
3666
+ * same `fusedRespond` for the rendered body. The `LifecycleExecutionLane` guarantee
3667
+ * (`schema.params === undefined && schema.headers === undefined && derives === 1 &&
3668
+ * beforeHandle === 1 && afterHandle === 0 && onError === 0 && !hasResponseContract &&
3669
+ * !hasDecorations`) is enforced by `selectRouteLanes`; this lane is selected ONLY when all of those
3670
+ * hold. A `query` schema (the route declares `{ query }` alongside the hooks) is validated FIRST,
3671
+ * mirroring the compiled route program's stage order; the fused builder runs validate → derive →
3672
+ * before → handler in one frame.
3673
+ */
3674
+ private buildFusedDeriveBefore(
3675
+ handler: InternalHandler,
3676
+ derive: RawDerive,
3677
+ before: RawBeforeHandle,
3678
+ querySchema: StandardSchemaV1 | undefined,
3679
+ maxBodyBytes: number,
3680
+ ): FusedWebRunner {
3681
+ const logError = (err: unknown, ctx: RawContext): Response =>
3682
+ this.bareError(
3683
+ err,
3684
+ ctx,
3685
+ (result, set) => this.wrapWebResponse(toResponse(result, set)),
3686
+ this.wrapWebResponse,
3687
+ )
3688
+ return (source, params, search, signal, budget, platform, nativeContext) => {
3689
+ const ctx = nativeContext
3690
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3691
+ : new RequestContext(
3692
+ source,
3693
+ params,
3694
+ search,
3695
+ signal,
3696
+ budget,
3697
+ platform,
3698
+ maxBodyBytes,
3699
+ this.protoPoisoning,
3700
+ )
3701
+ const runHandler = (): MaybePromise<Response> => {
3702
+ let result: unknown
3703
+ try {
3704
+ result = handler(ctx)
3705
+ } catch (err) {
3706
+ return logError(err, ctx)
3707
+ }
3708
+ if (result instanceof Promise) {
3709
+ return result.then(
3710
+ (value) =>
3711
+ fusedRespond(
3712
+ value,
3713
+ ctx,
3714
+ this.responseBodyTag,
3715
+ this.staticResponseHeaders,
3716
+ this.onResponseHooks.length === 0,
3717
+ ),
3718
+ (err) => logError(err, ctx),
3719
+ )
3720
+ }
3721
+ return fusedRespond(
3722
+ result,
3723
+ ctx,
3724
+ this.responseBodyTag,
3725
+ this.staticResponseHeaders,
3726
+ this.onResponseHooks.length === 0,
3727
+ )
3728
+ }
3729
+ const runBefore = (): MaybePromise<Response> => {
3730
+ let early: unknown
3731
+ try {
3732
+ early = before(ctx)
3733
+ } catch (err) {
3734
+ return logError(err, ctx)
3735
+ }
3736
+ if (early instanceof Promise) {
3737
+ return early.then(
3738
+ (settled) => {
3739
+ if (settled === undefined) return runHandler()
3740
+ return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
3741
+ },
3742
+ (err) => logError(err, ctx),
3743
+ )
3744
+ }
3745
+ if (early === undefined) return runHandler()
3746
+ return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders)
3747
+ }
3748
+ const runDerive = (): MaybePromise<Response> => {
3749
+ let derived: unknown
3750
+ try {
3751
+ derived = derive(ctx)
3752
+ } catch (err) {
3753
+ return logError(err, ctx)
3754
+ }
3755
+ if (derived instanceof Promise) {
3756
+ return derived.then(
3757
+ (settled) => {
3758
+ if (isResponseResult(settled) || settled instanceof Response) {
3759
+ return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
3760
+ }
3761
+ Object.assign(ctx, settled as object)
3762
+ return runBefore()
3763
+ },
3764
+ (err) => logError(err, ctx),
3765
+ )
3766
+ }
3767
+ if (isResponseResult(derived) || derived instanceof Response) {
3768
+ return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders)
3769
+ }
3770
+ Object.assign(ctx, derived as object)
3771
+ return runBefore()
3772
+ }
3773
+ if (querySchema === undefined) return runDerive()
3774
+ let validation: MaybePromise<StandardResult<unknown>>
3775
+ try {
3776
+ validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
3777
+ } catch (err) {
3778
+ return logError(err, ctx)
3779
+ }
3780
+ if (validation instanceof Promise) {
3781
+ return validation.then(
3782
+ (settled) => {
3783
+ if (settled.issues !== undefined)
3784
+ return this.wrapWebResponse(plainValidationError(settled.issues))
3785
+ ctx.query = settled.value
3786
+ return runDerive()
3787
+ },
3788
+ (err) => logError(err, ctx),
3789
+ )
3790
+ }
3791
+ if (validation.issues !== undefined)
3792
+ return this.wrapWebResponse(plainValidationError(validation.issues))
3793
+ ctx.query = validation.value
3794
+ return runDerive()
3755
3795
  }
3756
- return dispatch(0)
3757
3796
  }
3758
3797
 
3759
- // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3760
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3761
- private runBodyOnly<T>(
3762
- entry: RouteEntry,
3763
- source: RequestSource,
3764
- ctx: RawContext,
3765
- finalize: (result: unknown, set: CtxSet) => T,
3766
- wrapResponse: (response: Response | ResponseResult) => T,
3767
- ): Promise<T> {
3768
- return readBodyFramed(
3769
- source,
3770
- entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3771
- this.protoPoisoning,
3772
- (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
3773
- wrapResponse,
3774
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3775
- )
3776
- }
3777
-
3778
- /** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
3779
- * streaming fallback. A method (not per-request closures) so the hot path allocates nothing
3780
- * beyond the one `.then` continuation. */
3781
- private finishBodyOnly<T>(
3782
- entry: RouteEntry,
3783
- parsed: unknown,
3784
- ctx: RawContext,
3785
- finalize: (result: unknown, set: CtxSet) => T,
3786
- wrapResponse: (response: Response | ResponseResult) => T,
3787
- ): MaybePromise<T> {
3788
- try {
3789
- const bodySchema = entry.schema!.body!
3790
- const validation = bodySchema["~standard"].validate(parsed)
3791
- if (validation instanceof Promise) {
3792
- return validation.then(
3793
- (result) => {
3794
- try {
3795
- const outcome = this.applyBodyValidation(entry, result, ctx, finalize, wrapResponse)
3796
- return outcome instanceof Promise
3797
- ? outcome.catch((err) =>
3798
- this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3799
- )
3800
- : outcome
3801
- } catch (err) {
3802
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3803
- }
3804
- },
3805
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3806
- )
3807
- }
3808
- const outcome = this.applyBodyValidation(entry, validation, ctx, finalize, wrapResponse)
3809
- return outcome instanceof Promise
3810
- ? outcome.catch((err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse))
3811
- : outcome
3812
- } catch (err) {
3813
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3814
- }
3815
- }
3816
-
3817
- private executeHandler<T>(
3818
- entry: RouteEntry,
3819
- ctx: RawContext,
3820
- finalize: (result: unknown, set: CtxSet) => T,
3821
- wrapResponse?: (response: Response | ResponseResult) => T,
3822
- ): MaybePromise<T> {
3823
- if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
3824
- const handlerOutput = entry.handler(ctx)
3825
- if (handlerOutput instanceof Promise) {
3826
- return handlerOutput.then(
3827
- (value) => finalize(value, responseSet(ctx)),
3828
- (err) => {
3829
- if (wrapResponse) {
3830
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3831
- }
3832
- throw err
3833
- },
3798
+ /**
3799
+ * Same shape as `buildFusedDeriveBefore`, plus one `afterHandle` step. The closure runs (validate) →
3800
+ * derive → before → handler → after in one frame, the shape the realistic middleware route lands on
3801
+ * when it also wants a response transform (e.g. attaching a request id to a successful payload's
3802
+ * headers). `LifecycleExecutionLane` and `selectRouteLanes` guarantee `afterHandle === 1`, the
3803
+ * throw contract is the same as the generic program, and the `after` step is the final transform +
3804
+ * respond the generic program already does.
3805
+ */
3806
+ private buildFusedDeriveBeforeAfter(
3807
+ handler: InternalHandler,
3808
+ derive: RawDerive,
3809
+ before: RawBeforeHandle,
3810
+ after: RawAfterHandle,
3811
+ querySchema: StandardSchemaV1 | undefined,
3812
+ maxBodyBytes: number,
3813
+ ): FusedWebRunner {
3814
+ const logError = (err: unknown, ctx: RawContext): Response =>
3815
+ this.bareError(
3816
+ err,
3817
+ ctx,
3818
+ (result, set) => this.wrapWebResponse(toResponse(result, set)),
3819
+ this.wrapWebResponse,
3834
3820
  )
3835
- }
3836
- return finalize(handlerOutput, responseSet(ctx))
3837
- }
3838
-
3839
- private handleValidationErrorRecovery<T>(
3840
- entry: RouteEntry,
3841
- recovery: unknown,
3842
- ctx: RawContext,
3843
- finalize: (result: unknown, set: CtxSet) => T,
3844
- wrapResponse: (response: Response | ResponseResult) => T,
3845
- originalIssues: ReadonlyArray<StandardIssue>,
3846
- kind: "body" | "query",
3847
- ): MaybePromise<T> {
3848
- if (recovery !== undefined) {
3849
- if (recovery instanceof Response) {
3850
- return wrapResponse(recovery)
3821
+ return (source, params, search, signal, budget, platform, nativeContext) => {
3822
+ const ctx = nativeContext
3823
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3824
+ : new RequestContext(
3825
+ source,
3826
+ params,
3827
+ search,
3828
+ signal,
3829
+ budget,
3830
+ platform,
3831
+ maxBodyBytes,
3832
+ this.protoPoisoning,
3833
+ )
3834
+ const runAfter = (result: unknown): MaybePromise<Response> => {
3835
+ let transformed: unknown
3836
+ try {
3837
+ transformed = after(result, ctx)
3838
+ } catch (err) {
3839
+ return logError(err, ctx)
3840
+ }
3841
+ if (transformed instanceof Promise) {
3842
+ return transformed.then(
3843
+ (value) =>
3844
+ fusedRespond(
3845
+ value,
3846
+ ctx,
3847
+ this.responseBodyTag,
3848
+ this.staticResponseHeaders,
3849
+ this.onResponseHooks.length === 0,
3850
+ ),
3851
+ (err) => logError(err, ctx),
3852
+ )
3853
+ }
3854
+ return fusedRespond(
3855
+ transformed,
3856
+ ctx,
3857
+ this.responseBodyTag,
3858
+ this.staticResponseHeaders,
3859
+ this.onResponseHooks.length === 0,
3860
+ )
3851
3861
  }
3852
- if (kind === "body" && entry.schema?.body) {
3853
- const validation = entry.schema.body["~standard"].validate(recovery)
3854
- if (validation instanceof Promise) {
3855
- return validation.then((settled) => {
3856
- if (settled.issues !== undefined)
3857
- return wrapResponse(plainValidationError(settled.issues))
3858
- ctx.body = settled.value
3859
- return this.executeHandler(entry, ctx, finalize)
3860
- })
3862
+ const runHandler = (): MaybePromise<Response> => {
3863
+ let result: unknown
3864
+ try {
3865
+ result = handler(ctx)
3866
+ } catch (err) {
3867
+ return logError(err, ctx)
3861
3868
  }
3862
- if (validation.issues !== undefined)
3863
- return wrapResponse(plainValidationError(validation.issues))
3864
- ctx.body = validation.value
3865
- return this.executeHandler(entry, ctx, finalize)
3869
+ if (result instanceof Promise) {
3870
+ return result.then(
3871
+ (value) => runAfter(value),
3872
+ (err) => logError(err, ctx),
3873
+ )
3874
+ }
3875
+ return runAfter(result)
3866
3876
  }
3867
- if (kind === "query" && entry.schema?.query) {
3868
- const validation = entry.schema.query["~standard"].validate(recovery)
3869
- if (validation instanceof Promise) {
3870
- return validation.then(
3877
+ const runBefore = (): MaybePromise<Response> => {
3878
+ let early: unknown
3879
+ try {
3880
+ early = before(ctx)
3881
+ } catch (err) {
3882
+ return logError(err, ctx)
3883
+ }
3884
+ if (early instanceof Promise) {
3885
+ return early.then(
3871
3886
  (settled) => {
3872
- if (settled.issues !== undefined)
3873
- return wrapResponse(plainValidationError(settled.issues))
3874
- ctx.query = settled.value
3875
- return this.executeHandler(entry, ctx, finalize, wrapResponse)
3887
+ if (settled === undefined) return runHandler()
3888
+ return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
3876
3889
  },
3877
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3890
+ (err) => logError(err, ctx),
3878
3891
  )
3879
3892
  }
3880
- if (validation.issues !== undefined)
3881
- return wrapResponse(plainValidationError(validation.issues))
3882
- ctx.query = validation.value
3883
- return this.executeHandler(entry, ctx, finalize, wrapResponse)
3893
+ if (early === undefined) return runHandler()
3894
+ return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders)
3884
3895
  }
3885
- }
3886
- return wrapResponse(plainValidationError(originalIssues))
3887
- }
3888
-
3889
- private applyBodyValidation<T>(
3890
- entry: RouteEntry,
3891
- result: StandardResult<unknown>,
3892
- ctx: RawContext,
3893
- finalize: (result: unknown, set: CtxSet) => T,
3894
- wrapResponse: (response: Response | ResponseResult) => T,
3895
- ): MaybePromise<T> {
3896
- if (result.issues !== undefined) {
3897
- const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
3898
- if (hook) {
3899
- const recovery = hook(result.issues, ctx as unknown as Context, "body")
3900
- if (recovery instanceof Promise) {
3901
- return recovery.then((rec) =>
3902
- this.handleValidationErrorRecovery(
3903
- entry,
3904
- rec,
3905
- ctx,
3906
- finalize,
3907
- wrapResponse,
3908
- result.issues!,
3909
- "body",
3910
- ),
3896
+ const runDerive = (): MaybePromise<Response> => {
3897
+ let derived: unknown
3898
+ try {
3899
+ derived = derive(ctx)
3900
+ } catch (err) {
3901
+ return logError(err, ctx)
3902
+ }
3903
+ if (derived instanceof Promise) {
3904
+ return derived.then(
3905
+ (settled) => {
3906
+ if (isResponseResult(settled) || settled instanceof Response) {
3907
+ return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
3908
+ }
3909
+ Object.assign(ctx, settled as object)
3910
+ return runBefore()
3911
+ },
3912
+ (err) => logError(err, ctx),
3911
3913
  )
3912
3914
  }
3913
- return this.handleValidationErrorRecovery(
3914
- entry,
3915
- recovery,
3916
- ctx,
3917
- finalize,
3918
- wrapResponse,
3919
- result.issues,
3920
- "body",
3921
- )
3915
+ if (isResponseResult(derived) || derived instanceof Response) {
3916
+ return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders)
3917
+ }
3918
+ Object.assign(ctx, derived as object)
3919
+ return runBefore()
3920
+ }
3921
+ if (querySchema === undefined) return runDerive()
3922
+ let validation: MaybePromise<StandardResult<unknown>>
3923
+ try {
3924
+ validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
3925
+ } catch (err) {
3926
+ return logError(err, ctx)
3922
3927
  }
3923
- return wrapResponse(plainValidationError(result.issues))
3924
- }
3925
- ctx.body = result.value
3926
- return this.executeHandler(entry, ctx, finalize)
3927
- }
3928
-
3929
- // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3930
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3931
- private runQueryOnly<T>(
3932
- entry: RouteEntry,
3933
- ctx: RawContext,
3934
- finalize: (result: unknown, set: CtxSet) => T,
3935
- wrapResponse: (response: Response | ResponseResult) => T,
3936
- ): MaybePromise<T> {
3937
- try {
3938
- // Call the validator directly for the raw StandardResult (read `.issues`/`.value`) - skip
3939
- // `validateStandard`'s per-request wrapper-object allocation, mirroring the bodyOnly path.
3940
- const validation = entry.schema!.query!["~standard"].validate(
3941
- queryObjectOf(ctx[CONTEXT_SEARCH]),
3942
- )
3943
3928
  if (validation instanceof Promise) {
3944
3929
  return validation.then(
3945
3930
  (settled) => {
3946
- try {
3947
- const outcome = this.applyQueryValidation(entry, settled, ctx, finalize, wrapResponse)
3948
- return outcome instanceof Promise
3949
- ? outcome.catch((err) =>
3950
- this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3951
- )
3952
- : outcome
3953
- } catch (err) {
3954
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3955
- }
3931
+ if (settled.issues !== undefined)
3932
+ return this.wrapWebResponse(plainValidationError(settled.issues))
3933
+ ctx.query = settled.value
3934
+ return runDerive()
3956
3935
  },
3957
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3958
- )
3959
- }
3960
- return this.applyQueryValidation(entry, validation, ctx, finalize, wrapResponse)
3961
- } catch (err) {
3962
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3963
- }
3964
- }
3965
-
3966
- /** Validate-result → set `ctx.query` → run handler. A method (not a per-request closure), the
3967
- * query analogue of {@link applyBodyValidation}. */
3968
- private applyQueryValidation<T>(
3969
- entry: RouteEntry,
3970
- result: StandardResult<unknown>,
3971
- ctx: RawContext,
3972
- finalize: (result: unknown, set: CtxSet) => T,
3973
- wrapResponse: (response: Response | ResponseResult) => T,
3974
- ): MaybePromise<T> {
3975
- if (result.issues !== undefined) {
3976
- const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
3977
- if (hook) {
3978
- const recovery = hook(result.issues, ctx as unknown as Context, "query")
3979
- if (recovery instanceof Promise) {
3980
- return recovery.then((rec) =>
3981
- this.handleValidationErrorRecovery(
3982
- entry,
3983
- rec,
3984
- ctx,
3985
- finalize,
3986
- wrapResponse,
3987
- result.issues!,
3988
- "query",
3989
- ),
3990
- )
3991
- }
3992
- return this.handleValidationErrorRecovery(
3993
- entry,
3994
- recovery,
3995
- ctx,
3996
- finalize,
3997
- wrapResponse,
3998
- result.issues,
3999
- "query",
3936
+ (err) => logError(err, ctx),
4000
3937
  )
4001
3938
  }
4002
- return wrapResponse(plainValidationError(result.issues))
3939
+ if (validation.issues !== undefined)
3940
+ return this.wrapWebResponse(plainValidationError(validation.issues))
3941
+ ctx.query = validation.value
3942
+ return runDerive()
4003
3943
  }
4004
- ctx.query = result.value
4005
- return this.executeHandler(entry, ctx, finalize, wrapResponse)
4006
3944
  }
4007
3945
 
4008
3946
  /**
4009
- * Thread the response through each global `onResponse` hook. Stays SYNCHRONOUS until a hook
4010
- * actually returns a Promise - an `async` version forced a promise + microtask on EVERY response
4011
- * of any app with an onResponse hook (cors/securityHeaders/etag/timing all use onResponse), the
4012
- * same ~13%/req tax the onRequest walk was de-async'd to avoid. The first async hook hands the
4013
- * rest to {@link continueOnResponse}.
3947
+ * Body + lifecycle hooks fused: parse → validate → derive → before → handler → (after) in one
3948
+ * closure, the same win `buildFusedBodyRunner` gives for the no-hooks body shape. The shape is
3949
+ * `lifecycleHookLane` PLUS a body schema with no other validation conflicts; the lane is selected
3950
+ * ONLY when `schema.body !== undefined && schema.query/params/headers === undefined &&
3951
+ * schema.onValidationError === undefined && !defaultOnValidationError && !hasIdempotency &&
3952
+ * !hasLedger && derives === 1 && beforeHandle === 1 && afterHandle <= 1 && onError === 0 &&
3953
+ * !hasResponseContract && !hasDecorations && around === 0`. The same single `querySchema === undefined`
3954
+ * trick used in the no-body lane lets the build path stay simple: a query + body route falls back
3955
+ * to the generic program (this is rare in practice - validated query + validated body is usually a
3956
+ * "search results" route with a middleware chain on top, and the generic lane is fine there).
4014
3957
  */
4015
- private applyOnResponse(response: Response, req: Request): MaybePromise<Response> {
4016
- const hooks = this.onResponseHooks
4017
- let current = response
4018
- for (let i = 0; i < hooks.length; i++) {
4019
- const next = (hooks[i] as RawOnResponse)(current, req)
4020
- if (next instanceof Promise) {
4021
- return next.then((settled) => this.continueOnResponse(settled, i + 1, req))
4022
- }
4023
- current = next
4024
- }
4025
- return current
4026
- }
4027
-
4028
- private applyOnResponseAndFinalize(response: Response, req: Request): MaybePromise<Response> {
4029
- try {
4030
- const transformed = this.applyOnResponse(response, req)
4031
- return transformed instanceof Promise
4032
- ? transformed.then(
4033
- (settled) => this.completeResponseFinalization({ response: settled }, req),
4034
- (error) => this.failResponseFinalization(response, error, req),
3958
+ private buildFusedBodyDeriveBeforeAfter(
3959
+ handler: InternalHandler,
3960
+ derive: RawDerive,
3961
+ before: RawBeforeHandle,
3962
+ after: RawAfterHandle | undefined,
3963
+ bodySchema: StandardSchemaV1,
3964
+ maxBodyBytes: number,
3965
+ ): FusedWebRunner {
3966
+ const logError = (err: unknown, ctx: RawContext): Response =>
3967
+ this.bareError(
3968
+ err,
3969
+ ctx,
3970
+ (result, set) => this.wrapWebResponse(toResponse(result, set)),
3971
+ this.wrapWebResponse,
3972
+ )
3973
+ return (source, params, search, signal, budget, platform, nativeContext) => {
3974
+ const ctx = nativeContext
3975
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3976
+ : new RequestContext(
3977
+ source,
3978
+ params,
3979
+ search,
3980
+ signal,
3981
+ budget,
3982
+ platform,
3983
+ maxBodyBytes,
3984
+ this.protoPoisoning,
4035
3985
  )
4036
- : this.completeResponseFinalization({ response: transformed }, req)
4037
- } catch (error) {
4038
- return this.failResponseFinalization(response, error, req)
4039
- }
4040
- }
4041
-
4042
- private completeResponseFinalization(
4043
- outcome: ResponseFinalization,
4044
- req: Request,
4045
- ): MaybePromise<Response> {
4046
- const notified = this.notifyResponseFinalized(outcome, req)
4047
- return notified instanceof Promise ? notified.then(() => outcome.response) : outcome.response
4048
- }
4049
-
4050
- private failResponseFinalization(
4051
- response: Response,
4052
- error: unknown,
4053
- req: Request,
4054
- ): Promise<never> {
4055
- const notified = this.notifyResponseFinalized({ response, error }, req)
4056
- if (notified instanceof Promise) {
4057
- return notified.then(() => {
4058
- throw error
4059
- })
4060
- }
4061
- // A hook failure must surface as a REJECTION even when every prior step ran synchronously:
4062
- // `fetch()` may now resolve without a promise, but its failure contract stays promise-shaped -
4063
- // a synchronous throw here would escape `Promise.resolve(app.fetch(...))` bridges and
4064
- // `.then()`-style callers entirely instead of reaching their rejection handling.
4065
- return Promise.reject(error)
4066
- }
4067
-
4068
- /** Notify terminal observers in order while isolating both sync and async failures. */
4069
- private notifyResponseFinalized(outcome: ResponseFinalization, req: Request): MaybePromise<void> {
4070
- let pending: Promise<void> | undefined
4071
- for (const hook of this.onResponseFinalizedHooks) {
4072
- if (pending !== undefined) {
4073
- pending = pending.then(async () => {
3986
+ const runHandlerValidated = (): MaybePromise<Response> => {
3987
+ const runAfter = (result: unknown): MaybePromise<Response> => {
3988
+ if (after === undefined) {
3989
+ return fusedRespond(
3990
+ result,
3991
+ ctx,
3992
+ this.responseBodyTag,
3993
+ this.staticResponseHeaders,
3994
+ this.onResponseHooks.length === 0,
3995
+ )
3996
+ }
3997
+ let transformed: unknown
4074
3998
  try {
4075
- await hook(outcome, req)
4076
- } catch {
4077
- // Terminal observation must never change request behavior.
3999
+ transformed = after(result, ctx)
4000
+ } catch (err) {
4001
+ return logError(err, ctx)
4078
4002
  }
4079
- })
4080
- continue
4081
- }
4082
- try {
4083
- const result = hook(outcome, req)
4084
- if (result instanceof Promise) pending = result.catch(() => {})
4085
- } catch {
4086
- // Terminal observation must never change request behavior.
4087
- }
4088
- }
4089
- return pending
4090
- }
4091
-
4092
- /** Async tail of {@link applyOnResponse}: runs the remaining hooks once one has gone async. */
4093
- private async continueOnResponse(
4094
- response: Response,
4095
- nextIndex: number,
4096
- req: Request,
4097
- ): Promise<Response> {
4098
- let current = response
4099
- for (let i = nextIndex; i < this.onResponseHooks.length; i++) {
4100
- const next = (this.onResponseHooks[i] as RawOnResponse)(current, req)
4101
- current = next instanceof Promise ? await next : next
4102
- }
4103
- return current
4104
- }
4105
-
4106
- /**
4107
- * Bound the response time. On timeout we abort `ctx.signal` (so cancellation-aware
4108
- * handlers can bail) and return 503; the in-flight work keeps running but its
4109
- * result is discarded - JS can't forcibly cancel a promise.
4110
- */
4111
- private async withTimeout<T>(
4112
- work: Promise<T>,
4113
- controller: AbortController,
4114
- onTimeout: () => T,
4115
- timeoutMs: number,
4116
- ): Promise<T> {
4117
- let timer: ReturnType<typeof setTimeout> | undefined
4118
- const timeout = new Promise<T>((resolve) => {
4119
- timer = setTimeout(() => {
4120
- controller.abort()
4121
- resolve(onTimeout())
4122
- }, timeoutMs)
4123
- })
4124
- try {
4125
- return await Promise.race([work, timeout])
4126
- } finally {
4127
- if (timer !== undefined) clearTimeout(timer)
4128
- }
4129
- }
4130
-
4131
- /**
4132
- * The post-match lifecycle: validate → derive → beforeHandle → handler → afterHandle, with onError.
4133
- * Generic over the render: a success value goes through `finalize(result, set)` (a Web `Response`, or
4134
- * node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
4135
- */
4136
- /** Synchronous until a validator, lifecycle hook, handler, or contract check actually returns a Promise. */
4137
- // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
4138
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
4139
- private runLifecycle<T>(
4140
- entry: RouteEntry,
4141
- source: RequestSource,
4142
- ctx: RawContext,
4143
- finalize: (result: unknown, set: CtxSet) => T,
4144
- wrapResponse: (response: Response | ResponseResult) => T,
4145
- ): MaybePromise<T> {
4146
- try {
4147
- const headersSchema = entry.schema?.headers
4148
- if (headersSchema !== undefined) {
4149
- const validation = headersSchema["~standard"].validate(headerObjectOf(source.headers))
4150
- if (validation instanceof Promise) {
4151
- return validation.then(
4152
- (result) =>
4153
- this.runLifecycleAfterHeadersResult(
4154
- entry,
4155
- source,
4156
- ctx,
4157
- finalize,
4158
- wrapResponse,
4159
- result,
4160
- ),
4161
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4003
+ if (transformed instanceof Promise) {
4004
+ return transformed.then(
4005
+ (value) =>
4006
+ fusedRespond(
4007
+ value,
4008
+ ctx,
4009
+ this.responseBodyTag,
4010
+ this.staticResponseHeaders,
4011
+ this.onResponseHooks.length === 0,
4012
+ ),
4013
+ (err) => logError(err, ctx),
4014
+ )
4015
+ }
4016
+ return fusedRespond(
4017
+ transformed,
4018
+ ctx,
4019
+ this.responseBodyTag,
4020
+ this.staticResponseHeaders,
4021
+ this.onResponseHooks.length === 0,
4162
4022
  )
4163
4023
  }
4164
- return this.runLifecycleAfterHeadersResult(
4165
- entry,
4166
- source,
4167
- ctx,
4168
- finalize,
4169
- wrapResponse,
4170
- validation,
4171
- )
4172
- }
4173
- const paramsSchema = entry.schema?.params
4174
- if (paramsSchema !== undefined) {
4175
- const validation = paramsSchema["~standard"].validate(ctx.params)
4176
- if (validation instanceof Promise) {
4177
- return validation.then(
4178
- (result) =>
4179
- this.runLifecycleAfterParamsResult(
4180
- entry,
4181
- source,
4182
- ctx,
4183
- finalize,
4184
- wrapResponse,
4185
- result,
4186
- ),
4187
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4188
- )
4024
+ const runHandler = (): MaybePromise<Response> => {
4025
+ let result: unknown
4026
+ try {
4027
+ result = handler(ctx)
4028
+ } catch (err) {
4029
+ return logError(err, ctx)
4030
+ }
4031
+ if (result instanceof Promise) {
4032
+ return result.then(
4033
+ (value) => runAfter(value),
4034
+ (err) => logError(err, ctx),
4035
+ )
4036
+ }
4037
+ return runAfter(result)
4189
4038
  }
4190
- return this.runLifecycleAfterParamsResult(
4191
- entry,
4192
- source,
4193
- ctx,
4194
- finalize,
4195
- wrapResponse,
4196
- validation,
4197
- )
4198
- }
4199
- // Registration already knows whether this route has body/params/query validation. Most real
4200
- // read routes have no body or params schema, so skip the generic stage ladder and go straight to
4201
- // query validation + lifecycle hooks. The fallback below remains the complete path for routes
4202
- // with body/params combinations and validation recovery.
4203
- if (entry.schema?.body === undefined) {
4204
- return this.runQueryAndLifecycle(entry, ctx, finalize, wrapResponse)
4205
- }
4206
- return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, undefined)
4207
- } catch (err) {
4208
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4209
- }
4210
- }
4211
-
4212
- private runLifecycleAfterHeadersResult<T>(
4213
- entry: RouteEntry,
4214
- source: RequestSource,
4215
- ctx: RawContext,
4216
- finalize: (result: unknown, set: CtxSet) => T,
4217
- wrapResponse: (response: Response | ResponseResult) => T,
4218
- result: StandardResult<unknown>,
4219
- ): MaybePromise<T> {
4220
- try {
4221
- const headersError = this.applyLifecycleValidation(entry, result, ctx, "headers")
4222
- if (headersError instanceof Promise) {
4223
- return headersError.then(
4224
- (error) =>
4225
- error === undefined
4226
- ? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
4227
- : wrapResponse(error),
4228
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4229
- )
4039
+ const runBefore = (): MaybePromise<Response> => {
4040
+ let early: unknown
4041
+ try {
4042
+ early = before(ctx)
4043
+ } catch (err) {
4044
+ return logError(err, ctx)
4045
+ }
4046
+ if (early instanceof Promise) {
4047
+ return early.then(
4048
+ (settled) => {
4049
+ if (settled === undefined) return runHandler()
4050
+ return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
4051
+ },
4052
+ (err) => logError(err, ctx),
4053
+ )
4054
+ }
4055
+ if (early === undefined) return runHandler()
4056
+ return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders)
4057
+ }
4058
+ let derived: unknown
4059
+ try {
4060
+ derived = derive(ctx)
4061
+ } catch (err) {
4062
+ return logError(err, ctx)
4063
+ }
4064
+ if (derived instanceof Promise) {
4065
+ return derived.then(
4066
+ (settled) => {
4067
+ if (isResponseResult(settled) || settled instanceof Response) {
4068
+ return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
4069
+ }
4070
+ Object.assign(ctx, settled as object)
4071
+ return runBefore()
4072
+ },
4073
+ (err) => logError(err, ctx),
4074
+ )
4075
+ }
4076
+ if (isResponseResult(derived) || derived instanceof Response) {
4077
+ return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders)
4078
+ }
4079
+ Object.assign(ctx, derived as object)
4080
+ return runBefore()
4230
4081
  }
4231
- return headersError === undefined
4232
- ? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
4233
- : wrapResponse(headersError)
4234
- } catch (err) {
4235
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4082
+ const onParsed = (parsed: unknown): MaybePromise<Response> => {
4083
+ let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
4084
+ try {
4085
+ validation = bodySchema["~standard"].validate(parsed)
4086
+ } catch (err) {
4087
+ return logError(err, ctx)
4088
+ }
4089
+ if (validation instanceof Promise) {
4090
+ return validation.then(
4091
+ (settled) => {
4092
+ if (settled.issues !== undefined)
4093
+ return this.wrapWebResponse(plainValidationError(settled.issues))
4094
+ ctx.body = settled.value
4095
+ return runHandlerValidated()
4096
+ },
4097
+ (err) => logError(err, ctx),
4098
+ )
4099
+ }
4100
+ if (validation.issues !== undefined)
4101
+ return this.wrapWebResponse(plainValidationError(validation.issues))
4102
+ ctx.body = validation.value
4103
+ return runHandlerValidated()
4104
+ }
4105
+ return readBodyFramed(
4106
+ source,
4107
+ maxBodyBytes,
4108
+ this.protoPoisoning,
4109
+ onParsed,
4110
+ (response) => this.wrapWebResponse(response),
4111
+ (err) => Promise.resolve(logError(err, ctx)),
4112
+ ) as MaybePromise<Response>
4236
4113
  }
4237
4114
  }
4238
4115
 
4239
- private runLifecycleAfterHeaders<T>(
4240
- entry: RouteEntry,
4241
- source: RequestSource,
4116
+ private bareError<T>(
4117
+ err: unknown,
4242
4118
  ctx: RawContext,
4243
- finalize: (result: unknown, set: CtxSet) => T,
4119
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
4244
4120
  wrapResponse: (response: Response | ResponseResult) => T,
4245
- ): MaybePromise<T> {
4246
- const paramsSchema = entry.schema?.params
4247
- if (paramsSchema === undefined) {
4248
- if (entry.schema?.body === undefined)
4249
- return this.runQueryAndLifecycle(entry, ctx, finalize, wrapResponse)
4250
- return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, undefined)
4251
- }
4252
- const validation = paramsSchema["~standard"].validate(ctx.params)
4253
- if (validation instanceof Promise) {
4254
- return validation.then(
4255
- (result) =>
4256
- this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, result),
4257
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4258
- )
4259
- }
4260
- return this.runLifecycleAfterParamsResult(
4261
- entry,
4262
- source,
4121
+ ): T {
4122
+ // The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
4123
+ // lane and the fused body runner. `responseSet` and the logger are injected because they reach
4124
+ // into class state; the closure is allocated only on the throw path, never on the hot lane.
4125
+ return renderBareError(
4126
+ err,
4263
4127
  ctx,
4264
4128
  finalize,
4265
4129
  wrapResponse,
4266
- validation,
4130
+ responseSet,
4131
+ (e, c) => this.logRequestError(e, c),
4132
+ () => this.internalErrorResponse(wrapResponse),
4267
4133
  )
4268
4134
  }
4269
4135
 
4270
- /** Registration-specialized query lifecycle: query validation (including recovery) → hooks. */
4271
- private runLifecycleQuery<T>(
4136
+ /**
4137
+ * Keep the ordinary thrown-error response independent from the caught value. Deliberate Response
4138
+ * control flow is handled by `renderBareError`; every other throw gets this fixed public envelope.
4139
+ */
4140
+ private internalErrorResponse<T>(wrapResponse: (response: Response | ResponseResult) => T): T {
4141
+ return wrapResponse(plainError(500, "internal_error"))
4142
+ }
4143
+
4144
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
4145
+ private runWithAround<T>(
4272
4146
  entry: RouteEntry,
4273
4147
  ctx: RawContext,
4148
+ run: () => MaybePromise<T>,
4274
4149
  finalize: (result: unknown, set: CtxSet) => T,
4275
4150
  wrapResponse: (response: Response | ResponseResult) => T,
4276
4151
  ): MaybePromise<T> {
4152
+ let outcome: MaybePromise<T>
4277
4153
  try {
4278
- // The execution plan only selects this runner when the route has a query schema. Keeping the
4279
- // non-null access here removes the per-request schema-presence branch from realistic GETs.
4280
- const validation = entry.schema!.query!["~standard"].validate(
4281
- queryObjectOf(ctx[CONTEXT_SEARCH]),
4282
- )
4283
- if (validation instanceof Promise) {
4284
- return validation.then(
4285
- (result) => {
4286
- try {
4287
- return this.runQueryAndLifecycleResult(entry, ctx, finalize, wrapResponse, result)
4288
- } catch (err) {
4289
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4290
- }
4291
- },
4292
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4293
- )
4294
- }
4295
- return this.runQueryAndLifecycleResult(entry, ctx, finalize, wrapResponse, validation)
4154
+ outcome = this.runAround(entry.around, ctx, run)
4296
4155
  } catch (err) {
4297
4156
  return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4298
4157
  }
4158
+ return outcome instanceof Promise
4159
+ ? outcome.catch((err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse))
4160
+ : outcome
4299
4161
  }
4300
4162
 
4301
- /** Registration-specialized body lifecycle: bounded body validation (including recovery) → hooks. */
4163
+ /** Execute the registration-compiled general stage program. */
4302
4164
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
4303
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
4304
- private runLifecycleBody<T>(
4165
+ private runProgram<T>(
4305
4166
  entry: RouteEntry,
4306
4167
  source: RequestSource,
4307
4168
  ctx: RawContext,
4308
4169
  finalize: (result: unknown, set: CtxSet) => T,
4309
4170
  wrapResponse: (response: Response | ResponseResult) => T,
4310
4171
  ): MaybePromise<T> {
4311
- return this.readAndValidateBody(source, entry, ctx).then(
4312
- (bodyError) =>
4313
- bodyError === undefined
4314
- ? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
4315
- : wrapResponse(bodyError),
4316
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4317
- )
4172
+ return executeRouteProgram(this, entry, entry.program, source, ctx, finalize, wrapResponse)
4318
4173
  }
4319
4174
 
4320
- /** Registration-specialized body + query lifecycle: bounded body validation → query validation → hooks. */
4321
- // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
4322
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
4323
- private runLifecycleBodyQuery<T>(
4175
+ /** Bind a compiled validation stage to its stable schema and the existing recovery contract. */
4176
+ // @ts-expect-error TS6133 -- invoked structurally by the general route program
4177
+ private validateProgramStage(
4324
4178
  entry: RouteEntry,
4179
+ stage: Extract<RouteProgramStage, { kind: "headers" | "params" | "body" | "query" }>,
4325
4180
  source: RequestSource,
4326
4181
  ctx: RawContext,
4327
- finalize: (result: unknown, set: CtxSet) => T,
4328
- wrapResponse: (response: Response | ResponseResult) => T,
4329
- ): MaybePromise<T> {
4330
- return this.readAndValidateBody(source, entry, ctx).then(
4331
- (bodyError) =>
4332
- bodyError === undefined
4333
- ? this.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
4334
- : wrapResponse(bodyError),
4335
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4336
- )
4182
+ ): MaybePromise<Response | ResponseResult | undefined> {
4183
+ const input =
4184
+ stage.kind === "headers"
4185
+ ? headerObjectOf(source.headers)
4186
+ : stage.kind === "params"
4187
+ ? ctx.params
4188
+ : stage.kind === "query"
4189
+ ? queryObjectOf(ctx[CONTEXT_SEARCH])
4190
+ : undefined
4191
+ if (stage.kind === "body") return this.readProgramBody(entry, source, ctx)
4192
+ const validation = stage.schema["~standard"].validate(input)
4193
+ return validation instanceof Promise
4194
+ ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, stage.kind))
4195
+ : this.applyLifecycleValidation(entry, validation, ctx, stage.kind)
4337
4196
  }
4338
4197
 
4339
- /** Registration-specialized no-body lifecycle: query validation (if present) → derives/hooks. */
4340
- private runQueryAndLifecycle<T>(
4198
+ /** Preserve the existing bounded body reader and its fail-closed framing/prototype checks. */
4199
+ private readProgramBody(
4341
4200
  entry: RouteEntry,
4201
+ source: RequestSource,
4342
4202
  ctx: RawContext,
4343
- finalize: (result: unknown, set: CtxSet) => T,
4344
- wrapResponse: (response: Response | ResponseResult) => T,
4345
- ): MaybePromise<T> {
4346
- const querySchema = entry.schema?.query
4347
- return querySchema === undefined
4348
- ? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
4349
- : this.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
4203
+ ): Promise<Response | ResponseResult | undefined> {
4204
+ const bodySchema = entry.program.bodySchema
4205
+ if (bodySchema === undefined) return Promise.resolve(plainError(415, "unsupported_media_type"))
4206
+ return this.readAndValidateBody(source, entry, ctx, bodySchema, entry.program.bodyLimit)
4350
4207
  }
4351
4208
 
4352
- private runQueryAndLifecycleResult<T>(
4209
+ /** Keep thrown Response/status and redacted 500 semantics in one existing error boundary. */
4210
+ // @ts-expect-error TS6133 -- invoked structurally by the general route program
4211
+ private handleProgramError<T>(
4353
4212
  entry: RouteEntry,
4213
+ error: unknown,
4354
4214
  ctx: RawContext,
4355
4215
  finalize: (result: unknown, set: CtxSet) => T,
4356
4216
  wrapResponse: (response: Response | ResponseResult) => T,
4357
- result: StandardResult<unknown>,
4358
4217
  ): MaybePromise<T> {
4359
- const queryError = this.applyLifecycleValidation(entry, result, ctx, "query")
4360
- if (queryError instanceof Promise) {
4361
- return queryError.then(
4362
- (error) =>
4363
- error === undefined
4364
- ? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
4365
- : wrapResponse(error),
4366
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4367
- )
4368
- }
4369
- if (queryError !== undefined) return wrapResponse(queryError)
4370
- return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
4218
+ return this.handleLifecycleError(entry, error, ctx, finalize, wrapResponse)
4371
4219
  }
4372
4220
 
4373
- private runLifecycleAfterParamsResult<T>(
4221
+ /** Finish a successful program result through response-contract enforcement and finalization. */
4222
+ // @ts-expect-error TS6133 -- invoked structurally by the general route program
4223
+ private finishProgramResult<T>(
4374
4224
  entry: RouteEntry,
4375
- source: RequestSource,
4376
4225
  ctx: RawContext,
4226
+ result: unknown,
4377
4227
  finalize: (result: unknown, set: CtxSet) => T,
4378
4228
  wrapResponse: (response: Response | ResponseResult) => T,
4379
- result: StandardResult<unknown>,
4380
4229
  ): MaybePromise<T> {
4381
- try {
4382
- return this.runLifecycleAfterParams(
4383
- entry,
4384
- source,
4385
- ctx,
4386
- finalize,
4387
- wrapResponse,
4388
- this.applyLifecycleValidation(entry, result, ctx, "params"),
4389
- )
4390
- } catch (err) {
4391
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4392
- }
4230
+ return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result)
4393
4231
  }
4394
4232
 
4395
- private runLifecycleAfterParams<T>(
4396
- entry: RouteEntry,
4397
- source: RequestSource,
4233
+ private runAround<T>(
4234
+ hooks: ReadonlyArray<RawAround>,
4398
4235
  ctx: RawContext,
4399
- finalize: (result: unknown, set: CtxSet) => T,
4400
- wrapResponse: (response: Response | ResponseResult) => T,
4401
- paramsError: MaybePromise<Response | ResponseResult | undefined>,
4236
+ run: () => MaybePromise<T>,
4402
4237
  ): MaybePromise<T> {
4403
- if (paramsError instanceof Promise) {
4404
- return paramsError.then(
4405
- (error) =>
4406
- this.runLifecycleAfterParamsSettled(entry, source, ctx, finalize, wrapResponse, error),
4407
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4408
- )
4238
+ const dispatch = (index: number): MaybePromise<T> => {
4239
+ if (index >= hooks.length) return run()
4240
+ const hook = hooks[index]!
4241
+ let called = false
4242
+ return hook(ctx, () => {
4243
+ if (called) throw new Error("around next() called multiple times")
4244
+ called = true
4245
+ return dispatch(index + 1)
4246
+ })
4409
4247
  }
4410
- return this.runLifecycleAfterParamsSettled(
4411
- entry,
4412
- source,
4413
- ctx,
4414
- finalize,
4415
- wrapResponse,
4416
- paramsError,
4417
- )
4248
+ return dispatch(0)
4418
4249
  }
4419
4250
 
4420
- private runLifecycleAfterParamsSettled<T>(
4251
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
4252
+ private runBodyOnly<T>(
4421
4253
  entry: RouteEntry,
4422
4254
  source: RequestSource,
4423
4255
  ctx: RawContext,
4424
4256
  finalize: (result: unknown, set: CtxSet) => T,
4425
4257
  wrapResponse: (response: Response | ResponseResult) => T,
4426
- paramsError: Response | ResponseResult | undefined,
4427
- ): MaybePromise<T> {
4428
- if (paramsError !== undefined) return wrapResponse(paramsError)
4429
- if (entry.schema?.body !== undefined) {
4430
- return this.readAndValidateBody(source, entry, ctx).then(
4431
- (bodyError) => this.runLifecycleAfterBody(entry, ctx, finalize, wrapResponse, bodyError),
4432
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4433
- )
4434
- }
4435
- return this.runLifecycleAfterBody(entry, ctx, finalize, wrapResponse, undefined)
4436
- }
4437
-
4438
- private runLifecycleAfterBody<T>(
4439
- entry: RouteEntry,
4440
- ctx: RawContext,
4441
- finalize: (result: unknown, set: CtxSet) => T,
4442
- wrapResponse: (response: Response | ResponseResult) => T,
4443
- bodyError: Response | ResponseResult | undefined,
4444
- ): MaybePromise<T> {
4445
- if (bodyError !== undefined) return wrapResponse(bodyError)
4446
- const querySchema = entry.schema?.query
4447
- if (querySchema === undefined) return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
4448
- const validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
4449
- if (validation instanceof Promise) {
4450
- return validation.then(
4451
- (result) => this.runLifecycleAfterQueryResult(entry, ctx, finalize, wrapResponse, result),
4452
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4453
- )
4454
- }
4455
- return this.runLifecycleAfterQueryResult(entry, ctx, finalize, wrapResponse, validation)
4258
+ ): Promise<T> {
4259
+ return readBodyFramed(
4260
+ source,
4261
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
4262
+ this.protoPoisoning,
4263
+ (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
4264
+ wrapResponse,
4265
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4266
+ )
4456
4267
  }
4457
4268
 
4458
- private runLifecycleAfterQueryResult<T>(
4269
+ /** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
4270
+ * streaming fallback. A method (not per-request closures) so the hot path allocates nothing
4271
+ * beyond the one `.then` continuation. */
4272
+ private finishBodyOnly<T>(
4459
4273
  entry: RouteEntry,
4274
+ parsed: unknown,
4460
4275
  ctx: RawContext,
4461
4276
  finalize: (result: unknown, set: CtxSet) => T,
4462
4277
  wrapResponse: (response: Response | ResponseResult) => T,
4463
- result: StandardResult<unknown>,
4464
4278
  ): MaybePromise<T> {
4465
4279
  try {
4466
- const queryError = this.applyLifecycleValidation(entry, result, ctx, "query")
4467
- if (queryError instanceof Promise) {
4468
- return queryError.then(
4469
- (error) => this.runLifecycleAfterQuery(entry, ctx, finalize, wrapResponse, error),
4280
+ const bodySchema = entry.schema!.body!
4281
+ const validation = bodySchema["~standard"].validate(parsed)
4282
+ if (validation instanceof Promise) {
4283
+ return validation.then(
4284
+ (result) => {
4285
+ try {
4286
+ const outcome = this.applyBodyValidation(entry, result, ctx, finalize, wrapResponse)
4287
+ return outcome instanceof Promise
4288
+ ? outcome.catch((err) =>
4289
+ this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4290
+ )
4291
+ : outcome
4292
+ } catch (err) {
4293
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4294
+ }
4295
+ },
4470
4296
  (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4471
4297
  )
4472
4298
  }
4473
- return this.runLifecycleAfterQuery(entry, ctx, finalize, wrapResponse, queryError)
4299
+ const outcome = this.applyBodyValidation(entry, validation, ctx, finalize, wrapResponse)
4300
+ return outcome instanceof Promise
4301
+ ? outcome.catch((err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse))
4302
+ : outcome
4474
4303
  } catch (err) {
4475
4304
  return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4476
4305
  }
4477
4306
  }
4478
4307
 
4479
- private runLifecycleAfterQuery<T>(
4308
+ private executeHandler<T>(
4480
4309
  entry: RouteEntry,
4481
4310
  ctx: RawContext,
4482
4311
  finalize: (result: unknown, set: CtxSet) => T,
4483
- wrapResponse: (response: Response | ResponseResult) => T,
4484
- queryError: Response | ResponseResult | undefined,
4312
+ wrapResponse?: (response: Response | ResponseResult) => T,
4485
4313
  ): MaybePromise<T> {
4486
- if (queryError !== undefined) return wrapResponse(queryError)
4487
- return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
4314
+ if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
4315
+ const handlerOutput = entry.handler(ctx)
4316
+ if (handlerOutput instanceof Promise) {
4317
+ return handlerOutput.then(
4318
+ (value) => finalize(value, responseSet(ctx)),
4319
+ (err) => {
4320
+ if (wrapResponse) {
4321
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4322
+ }
4323
+ throw err
4324
+ },
4325
+ )
4326
+ }
4327
+ return finalize(handlerOutput, responseSet(ctx))
4488
4328
  }
4489
4329
 
4490
- private runLifecycleHooks<T>(
4330
+ private handleValidationErrorRecovery<T>(
4491
4331
  entry: RouteEntry,
4332
+ recovery: unknown,
4492
4333
  ctx: RawContext,
4493
4334
  finalize: (result: unknown, set: CtxSet) => T,
4494
4335
  wrapResponse: (response: Response | ResponseResult) => T,
4336
+ originalIssues: ReadonlyArray<StandardIssue>,
4337
+ kind: "body" | "query",
4495
4338
  ): MaybePromise<T> {
4496
- if (entry.lifecycleHookLane === "derive-before") {
4497
- return this.runLifecycleDeriveBefore(entry, ctx, finalize, wrapResponse)
4498
- }
4499
- try {
4500
- if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
4501
- for (let i = 0; i < entry.derives.length; i++) {
4502
- const extension = entry.derives[i]!(ctx)
4503
- if (extension instanceof Promise) {
4504
- return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, i, extension)
4339
+ if (recovery !== undefined) {
4340
+ if (recovery instanceof Response) {
4341
+ return wrapResponse(recovery)
4342
+ }
4343
+ if (kind === "body" && entry.schema?.body) {
4344
+ const validation = entry.schema.body["~standard"].validate(recovery)
4345
+ if (validation instanceof Promise) {
4346
+ return validation.then((settled) => {
4347
+ if (settled.issues !== undefined)
4348
+ return wrapResponse(plainValidationError(settled.issues))
4349
+ ctx.body = settled.value
4350
+ return this.executeHandler(entry, ctx, finalize)
4351
+ })
4505
4352
  }
4506
- if (isDeriveEarlyExit(extension)) return finalize(extension, responseSet(ctx))
4507
- Object.assign(ctx, extension)
4353
+ if (validation.issues !== undefined)
4354
+ return wrapResponse(plainValidationError(validation.issues))
4355
+ ctx.body = validation.value
4356
+ return this.executeHandler(entry, ctx, finalize)
4508
4357
  }
4509
- for (let i = 0; i < entry.beforeHandle.length; i++) {
4510
- const outcome = entry.beforeHandle[i]!(ctx)
4511
- if (outcome instanceof Promise) {
4512
- return this.continueLifecycleAfterBefore(entry, ctx, finalize, wrapResponse, i, outcome)
4358
+ if (kind === "query" && entry.schema?.query) {
4359
+ const validation = entry.schema.query["~standard"].validate(recovery)
4360
+ if (validation instanceof Promise) {
4361
+ return validation.then(
4362
+ (settled) => {
4363
+ if (settled.issues !== undefined)
4364
+ return wrapResponse(plainValidationError(settled.issues))
4365
+ ctx.query = settled.value
4366
+ return this.executeHandler(entry, ctx, finalize, wrapResponse)
4367
+ },
4368
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4369
+ )
4513
4370
  }
4514
- if (outcome !== undefined) return finalize(outcome, responseSet(ctx))
4371
+ if (validation.issues !== undefined)
4372
+ return wrapResponse(plainValidationError(validation.issues))
4373
+ ctx.query = validation.value
4374
+ return this.executeHandler(entry, ctx, finalize, wrapResponse)
4515
4375
  }
4516
- const result = entry.handler(ctx)
4517
- if (result instanceof Promise) {
4518
- return result.then(
4519
- (value) => this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, value),
4520
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4376
+ }
4377
+ return wrapResponse(plainValidationError(originalIssues))
4378
+ }
4379
+
4380
+ private applyBodyValidation<T>(
4381
+ entry: RouteEntry,
4382
+ result: StandardResult<unknown>,
4383
+ ctx: RawContext,
4384
+ finalize: (result: unknown, set: CtxSet) => T,
4385
+ wrapResponse: (response: Response | ResponseResult) => T,
4386
+ ): MaybePromise<T> {
4387
+ if (result.issues !== undefined) {
4388
+ const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
4389
+ if (hook) {
4390
+ const recovery = hook(result.issues, ctx as unknown as Context, "body")
4391
+ if (recovery instanceof Promise) {
4392
+ return recovery.then((rec) =>
4393
+ this.handleValidationErrorRecovery(
4394
+ entry,
4395
+ rec,
4396
+ ctx,
4397
+ finalize,
4398
+ wrapResponse,
4399
+ result.issues!,
4400
+ "body",
4401
+ ),
4402
+ )
4403
+ }
4404
+ return this.handleValidationErrorRecovery(
4405
+ entry,
4406
+ recovery,
4407
+ ctx,
4408
+ finalize,
4409
+ wrapResponse,
4410
+ result.issues,
4411
+ "body",
4521
4412
  )
4522
4413
  }
4523
- return this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, result)
4524
- } catch (err) {
4525
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4414
+ return wrapResponse(plainValidationError(result.issues))
4526
4415
  }
4416
+ ctx.body = result.value
4417
+ return this.executeHandler(entry, ctx, finalize)
4527
4418
  }
4528
4419
 
4529
- /** Registration-specialized derive → before → handler lifecycle. */
4530
- private runLifecycleDeriveBefore<T>(
4420
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
4421
+ private runQueryOnly<T>(
4531
4422
  entry: RouteEntry,
4532
4423
  ctx: RawContext,
4533
4424
  finalize: (result: unknown, set: CtxSet) => T,
4534
4425
  wrapResponse: (response: Response | ResponseResult) => T,
4535
4426
  ): MaybePromise<T> {
4536
4427
  try {
4537
- const extension = entry.derives[0]!(ctx)
4538
- if (extension instanceof Promise) {
4539
- return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, 0, extension)
4540
- }
4541
- if (isDeriveEarlyExit(extension)) return finalize(extension, responseSet(ctx))
4542
- Object.assign(ctx, extension)
4543
-
4544
- const outcome = entry.beforeHandle[0]!(ctx)
4545
- if (outcome instanceof Promise) {
4546
- return this.continueLifecycleAfterBefore(entry, ctx, finalize, wrapResponse, 0, outcome)
4547
- }
4548
- if (outcome !== undefined)
4549
- return this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, outcome)
4550
-
4551
- const result = entry.handler(ctx)
4552
- if (result instanceof Promise) {
4553
- return result.then(
4554
- (value) => this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, value),
4428
+ // Call the validator directly for the raw StandardResult (read `.issues`/`.value`) - skip
4429
+ // `validateStandard`'s per-request wrapper-object allocation, mirroring the bodyOnly path.
4430
+ const validation = entry.schema!.query!["~standard"].validate(
4431
+ queryObjectOf(ctx[CONTEXT_SEARCH]),
4432
+ )
4433
+ if (validation instanceof Promise) {
4434
+ return validation.then(
4435
+ (settled) => {
4436
+ try {
4437
+ const outcome = this.applyQueryValidation(entry, settled, ctx, finalize, wrapResponse)
4438
+ return outcome instanceof Promise
4439
+ ? outcome.catch((err) =>
4440
+ this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4441
+ )
4442
+ : outcome
4443
+ } catch (err) {
4444
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4445
+ }
4446
+ },
4555
4447
  (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4556
4448
  )
4557
4449
  }
4558
- return this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, result)
4450
+ return this.applyQueryValidation(entry, validation, ctx, finalize, wrapResponse)
4559
4451
  } catch (err) {
4560
4452
  return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4561
4453
  }
4562
4454
  }
4563
4455
 
4564
- /** Finalize the specialized lane without rechecking its registration-proven invariants. */
4565
- private finishSimpleLifecycleResult<T>(
4456
+ /** Validate-result → set `ctx.query` → run handler. A method (not a per-request closure), the
4457
+ * query analogue of {@link applyBodyValidation}. */
4458
+ private applyQueryValidation<T>(
4566
4459
  entry: RouteEntry,
4460
+ result: StandardResult<unknown>,
4567
4461
  ctx: RawContext,
4568
4462
  finalize: (result: unknown, set: CtxSet) => T,
4569
4463
  wrapResponse: (response: Response | ResponseResult) => T,
4570
- result: unknown,
4571
4464
  ): MaybePromise<T> {
4572
- try {
4573
- return finalize(result, responseSet(ctx))
4574
- } catch (err) {
4575
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4465
+ if (result.issues !== undefined) {
4466
+ const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
4467
+ if (hook) {
4468
+ const recovery = hook(result.issues, ctx as unknown as Context, "query")
4469
+ if (recovery instanceof Promise) {
4470
+ return recovery.then((rec) =>
4471
+ this.handleValidationErrorRecovery(
4472
+ entry,
4473
+ rec,
4474
+ ctx,
4475
+ finalize,
4476
+ wrapResponse,
4477
+ result.issues!,
4478
+ "query",
4479
+ ),
4480
+ )
4481
+ }
4482
+ return this.handleValidationErrorRecovery(
4483
+ entry,
4484
+ recovery,
4485
+ ctx,
4486
+ finalize,
4487
+ wrapResponse,
4488
+ result.issues,
4489
+ "query",
4490
+ )
4491
+ }
4492
+ return wrapResponse(plainValidationError(result.issues))
4576
4493
  }
4494
+ ctx.query = result.value
4495
+ return this.executeHandler(entry, ctx, finalize, wrapResponse)
4577
4496
  }
4578
4497
 
4579
- private async continueLifecycleAfterDerive<T>(
4580
- entry: RouteEntry,
4581
- ctx: RawContext,
4582
- finalize: (result: unknown, set: CtxSet) => T,
4583
- wrapResponse: (response: Response | ResponseResult) => T,
4584
- firstIndex: number,
4585
- first: Promise<unknown>,
4586
- ): Promise<T> {
4587
- try {
4588
- const settled = await first
4589
- if (isDeriveEarlyExit(settled)) return finalize(settled, responseSet(ctx))
4590
- Object.assign(ctx, settled)
4591
- for (let i = firstIndex + 1; i < entry.derives.length; i++) {
4592
- const raw = entry.derives[i]!(ctx)
4593
- const extension = raw instanceof Promise ? await raw : raw
4594
- if (isDeriveEarlyExit(extension)) return finalize(extension, responseSet(ctx))
4595
- Object.assign(ctx, extension)
4498
+ /**
4499
+ * Thread the response through each global `onResponse` hook. Stays SYNCHRONOUS until a hook
4500
+ * actually returns a Promise - an `async` version forced a promise + microtask on EVERY response
4501
+ * of any app with an onResponse hook (cors/securityHeaders/etag/timing all use onResponse), the
4502
+ * same ~13%/req tax the onRequest walk was de-async'd to avoid. The first async hook hands the
4503
+ * rest to {@link continueOnResponse}.
4504
+ */
4505
+ private applyOnResponse(response: Response, req: Request): MaybePromise<Response> {
4506
+ const hooks = this.onResponseHooks
4507
+ let current = response
4508
+ for (let i = 0; i < hooks.length; i++) {
4509
+ const next = (hooks[i] as RawOnResponse)(current, req)
4510
+ if (next instanceof Promise) {
4511
+ return next.then((settled) => this.continueOnResponse(settled, i + 1, req))
4596
4512
  }
4597
- return await this.runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse)
4598
- } catch (err) {
4599
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4513
+ current = next
4600
4514
  }
4515
+ return current
4601
4516
  }
4602
4517
 
4603
- private async runLifecycleHooksAsync<T>(
4604
- entry: RouteEntry,
4605
- ctx: RawContext,
4606
- finalize: (result: unknown, set: CtxSet) => T,
4607
- wrapResponse: (response: Response | ResponseResult) => T,
4608
- ): Promise<T> {
4518
+ private applyOnResponseAndFinalize(response: Response, req: Request): MaybePromise<Response> {
4609
4519
  try {
4610
- // Indexed, not `for...of`. An array iterator declared inside an async function stays live
4611
- // across the `await` below, so neither engine can sink it - unlike the sync lanes above,
4612
- // where V8 elides it. Measured cost of the iterator form: 43ns per hook on Bun, ~9ns on
4613
- // Node, on a chain whose hooks return synchronously (the common shape).
4614
- for (let i = 0; i < entry.beforeHandle.length; i++) {
4615
- const outcome = entry.beforeHandle[i]!(ctx)
4616
- const early = outcome instanceof Promise ? await outcome : outcome
4617
- if (early !== undefined) return finalize(early, responseSet(ctx))
4618
- }
4619
- return await this.runLifecycleHandlerAsync(entry, ctx, finalize, wrapResponse)
4620
- } catch (err) {
4621
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4520
+ const transformed = this.applyOnResponse(response, req)
4521
+ return transformed instanceof Promise
4522
+ ? transformed.then(
4523
+ (settled) => this.completeResponseFinalization({ response: settled }, req),
4524
+ (error) => this.failResponseFinalization(response, error, req),
4525
+ )
4526
+ : this.completeResponseFinalization({ response: transformed }, req)
4527
+ } catch (error) {
4528
+ return this.failResponseFinalization(response, error, req)
4622
4529
  }
4623
4530
  }
4624
4531
 
4625
- private async continueLifecycleAfterBefore<T>(
4626
- entry: RouteEntry,
4627
- ctx: RawContext,
4628
- finalize: (result: unknown, set: CtxSet) => T,
4629
- wrapResponse: (response: Response | ResponseResult) => T,
4630
- firstIndex: number,
4631
- first: Promise<unknown>,
4632
- ): Promise<T> {
4633
- try {
4634
- const firstOutcome = await first
4635
- if (firstOutcome !== undefined) return finalize(firstOutcome, responseSet(ctx))
4636
- for (let i = firstIndex + 1; i < entry.beforeHandle.length; i++) {
4637
- const outcome = entry.beforeHandle[i]!(ctx)
4638
- const early = outcome instanceof Promise ? await outcome : outcome
4639
- if (early !== undefined) return finalize(early, responseSet(ctx))
4640
- }
4641
- return await this.runLifecycleHandlerAsync(entry, ctx, finalize, wrapResponse)
4642
- } catch (err) {
4643
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4644
- }
4532
+ private completeResponseFinalization(
4533
+ outcome: ResponseFinalization,
4534
+ req: Request,
4535
+ ): MaybePromise<Response> {
4536
+ const notified = this.notifyResponseFinalized(outcome, req)
4537
+ return notified instanceof Promise ? notified.then(() => outcome.response) : outcome.response
4645
4538
  }
4646
4539
 
4647
- private async runLifecycleHandlerAsync<T>(
4648
- entry: RouteEntry,
4649
- ctx: RawContext,
4650
- finalize: (result: unknown, set: CtxSet) => T,
4651
- wrapResponse: (response: Response | ResponseResult) => T,
4652
- ): Promise<T> {
4653
- try {
4654
- const output = entry.handler(ctx)
4655
- const result = output instanceof Promise ? await output : output
4656
- return await this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, result)
4657
- } catch (err) {
4658
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4540
+ private failResponseFinalization(
4541
+ response: Response,
4542
+ error: unknown,
4543
+ req: Request,
4544
+ ): Promise<never> {
4545
+ const notified = this.notifyResponseFinalized({ response, error }, req)
4546
+ if (notified instanceof Promise) {
4547
+ return notified.then(() => {
4548
+ throw error
4549
+ })
4659
4550
  }
4551
+ // A hook failure must surface as a REJECTION even when every prior step ran synchronously:
4552
+ // `fetch()` may now resolve without a promise, but its failure contract stays promise-shaped -
4553
+ // a synchronous throw here would escape `Promise.resolve(app.fetch(...))` bridges and
4554
+ // `.then()`-style callers entirely instead of reaching their rejection handling.
4555
+ return Promise.reject(error)
4660
4556
  }
4661
4557
 
4662
- private finishLifecycleResult<T>(
4663
- entry: RouteEntry,
4664
- ctx: RawContext,
4665
- finalize: (result: unknown, set: CtxSet) => T,
4666
- wrapResponse: (response: Response | ResponseResult) => T,
4667
- initial: unknown,
4668
- ): MaybePromise<T> {
4669
- try {
4670
- let result = initial
4671
- for (let i = 0; i < entry.afterHandle.length; i++) {
4672
- const transformed = entry.afterHandle[i]!(result, ctx)
4673
- if (transformed instanceof Promise) {
4674
- return this.continueLifecycleAfterHandle(
4675
- entry,
4676
- ctx,
4677
- finalize,
4678
- wrapResponse,
4679
- i,
4680
- transformed,
4681
- )
4682
- }
4683
- result = transformed
4558
+ /** Notify terminal observers in order while isolating both sync and async failures. */
4559
+ private notifyResponseFinalized(outcome: ResponseFinalization, req: Request): MaybePromise<void> {
4560
+ let pending: Promise<void> | undefined
4561
+ for (const hook of this.onResponseFinalizedHooks) {
4562
+ if (pending !== undefined) {
4563
+ pending = pending.then(async () => {
4564
+ try {
4565
+ await hook(outcome, req)
4566
+ } catch {
4567
+ // Terminal observation must never change request behavior.
4568
+ }
4569
+ })
4570
+ continue
4571
+ }
4572
+ try {
4573
+ const result = hook(outcome, req)
4574
+ if (result instanceof Promise) pending = result.catch(() => {})
4575
+ } catch {
4576
+ // Terminal observation must never change request behavior.
4684
4577
  }
4685
- return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result)
4686
- } catch (err) {
4687
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4688
4578
  }
4579
+ return pending
4689
4580
  }
4690
4581
 
4691
- private async continueLifecycleAfterHandle<T>(
4692
- entry: RouteEntry,
4693
- ctx: RawContext,
4694
- finalize: (result: unknown, set: CtxSet) => T,
4695
- wrapResponse: (response: Response | ResponseResult) => T,
4696
- firstIndex: number,
4697
- first: Promise<unknown>,
4582
+ /** Async tail of {@link applyOnResponse}: runs the remaining hooks once one has gone async. */
4583
+ private async continueOnResponse(
4584
+ response: Response,
4585
+ nextIndex: number,
4586
+ req: Request,
4587
+ ): Promise<Response> {
4588
+ let current = response
4589
+ for (let i = nextIndex; i < this.onResponseHooks.length; i++) {
4590
+ const next = (this.onResponseHooks[i] as RawOnResponse)(current, req)
4591
+ current = next instanceof Promise ? await next : next
4592
+ }
4593
+ return current
4594
+ }
4595
+
4596
+ /**
4597
+ * Bound the response time. On timeout we abort `ctx.signal` (so cancellation-aware
4598
+ * handlers can bail) and return 503; the in-flight work keeps running but its
4599
+ * result is discarded - JS can't forcibly cancel a promise.
4600
+ */
4601
+ private async withTimeout<T>(
4602
+ work: Promise<T>,
4603
+ controller: AbortController,
4604
+ onTimeout: () => T,
4605
+ timeoutMs: number,
4698
4606
  ): Promise<T> {
4607
+ let timer: ReturnType<typeof setTimeout> | undefined
4608
+ const timeout = new Promise<T>((resolve) => {
4609
+ timer = setTimeout(() => {
4610
+ controller.abort()
4611
+ resolve(onTimeout())
4612
+ }, timeoutMs)
4613
+ })
4699
4614
  try {
4700
- let result = await first
4701
- for (let i = firstIndex + 1; i < entry.afterHandle.length; i++) {
4702
- const transformed = entry.afterHandle[i]!(result, ctx)
4703
- result = transformed instanceof Promise ? await transformed : transformed
4704
- }
4705
- return await this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result)
4706
- } catch (err) {
4707
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4615
+ return await Promise.race([work, timeout])
4616
+ } finally {
4617
+ if (timer !== undefined) clearTimeout(timer)
4708
4618
  }
4709
4619
  }
4710
-
4711
4620
  private finishLifecycleContract<T>(
4712
4621
  entry: RouteEntry,
4713
4622
  ctx: RawContext,
@@ -4808,35 +4717,37 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4808
4717
  return wrapResponse(plainError(500, "internal_error"))
4809
4718
  }
4810
4719
 
4811
- /** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
4720
+ /** Log an unhandled request error to the (redacting) logger - shared by the compiled route program and the
4812
4721
  * bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
4813
4722
  private logRequestError(err: unknown, ctx: RawContext): void {
4814
4723
  emitRequestErrorLog(this.logger, this.errorLogDetail, err, ctx)
4815
4724
  }
4816
4725
 
4817
- private async readAndValidateBody(
4726
+ private readAndValidateBody(
4818
4727
  req: RequestSource,
4819
4728
  entry: RouteEntry,
4820
4729
  ctx: RawContext,
4730
+ bodySchema: StandardSchemaV1 = entry.schema?.body as StandardSchemaV1,
4731
+ bodyLimit: number | undefined = entry.bodyLimit,
4821
4732
  ): Promise<Response | ResponseResult | undefined> {
4822
- const contentType = headerOf(req, "content-type") ?? ""
4823
- let parsed: unknown
4824
- if (contentType === "application/json" || contentType.includes("application/json")) {
4825
- const json = await this.readBoundedJson(req, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
4826
- if (isResponseResult(json)) return json
4827
- parsed = json
4828
- } else if (isUrlEncodedForm(contentType)) {
4829
- const form = await readBoundedForm(req, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
4830
- if (isResponseResult(form)) return form
4831
- parsed = form
4832
- } else {
4833
- // multipart/form-data (file uploads) stays 415 on the schema path by design - files don't
4834
- // fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
4835
- return plainError(415, "unsupported_media_type")
4733
+ // Keep the generic lifecycle body stage on the same framed parser/continuation as the body-only
4734
+ // lane. This avoids an async wrapper that first awaits parsing and then starts validation in a
4735
+ // second continuation, while retaining the exact bounded-read, content-type, and poisoning
4736
+ // contracts shared by every body route.
4737
+ const validate = (parsed: unknown): MaybePromise<Response | ResponseResult | undefined> => {
4738
+ const validation = bodySchema["~standard"].validate(parsed)
4739
+ return validation instanceof Promise
4740
+ ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, "body"))
4741
+ : this.applyLifecycleValidation(entry, validation, ctx, "body")
4836
4742
  }
4837
- const validation = entry.schema!.body!["~standard"].validate(parsed)
4838
- const result = validation instanceof Promise ? await validation : validation
4839
- return this.applyLifecycleValidation(entry, result, ctx, "body")
4743
+ return readBodyFramed(
4744
+ req,
4745
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
4746
+ this.protoPoisoning,
4747
+ validate,
4748
+ (response) => response,
4749
+ (error) => Promise.reject(error),
4750
+ )
4840
4751
  }
4841
4752
 
4842
4753
  /** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
@@ -4898,36 +4809,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4898
4809
  return undefined
4899
4810
  }
4900
4811
 
4901
- /**
4902
- * Read + parse the JSON body, capped at `maxBodyBytes` and screened by the server's
4903
- * `protoPoisoning` policy. Rejects a `Content-Length` over the cap *before* buffering,
4904
- * re-checks what was actually delivered after the read, and aborts a chunked or
4905
- * length-less body mid-stream once the running byte count exceeds the cap - so a lying
4906
- * or absent length can't force us to buffer an oversized payload.
4907
- */
4908
- private readBoundedJson(
4909
- req: RequestSource,
4910
- maxBodyBytes?: number,
4911
- ): Promise<unknown | ResponseResult>
4912
- private readBoundedJson<T>(
4913
- req: RequestSource,
4914
- maxBodyBytes: number,
4915
- onResult: (parsed: unknown | ResponseResult) => T | Promise<T>,
4916
- onError: (error: unknown) => T | Promise<T>,
4917
- ): Promise<T>
4918
- private readBoundedJson<T>(
4919
- req: RequestSource,
4920
- maxBodyBytes = this.maxBodyBytes,
4921
- onResult?: (parsed: unknown | ResponseResult) => T | Promise<T>,
4922
- onError?: (error: unknown) => T | Promise<T>,
4923
- ): Promise<unknown | ResponseResult | T> {
4924
- // Not `async`: this is a pass-through, and wrapping the lane's own promise in a coroutine
4925
- // frame costs an extra microtask hop on every JSON body.
4926
- return onResult === undefined
4927
- ? readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning)
4928
- : readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning, onResult, onError!)
4929
- }
4930
-
4931
4812
  /** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
4932
4813
  * semantics remain in the plan; this closure only supplies native params and deadline fallback. */
4933
4814
  private compileBunNativeHandler(
@@ -5115,10 +4996,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
5115
4996
  // matched route's handler, set by `server.upgrade`.
5116
4997
  // With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime - non-null
5117
4998
  // because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
4999
+ // Native pub/sub when the app has WS routes and none validate outbound frames: `ws.subscribe` and
5000
+ // `app.publish` go through Bun's own (uWebSockets) broadcast instead of the JS registry loop.
5001
+ const nativePubsub = this.wsRouteCount > 0 && !this.wsHasValidatedSend
5118
5002
  const wsHandlers =
5119
5003
  this.wsRouteCount === 0
5120
5004
  ? undefined
5121
- : (this.wsRuntime as WsRuntime).bunHandlers(this.topics as TopicRegistry)
5005
+ : (this.wsRuntime as WsRuntime).bunHandlers(this.topics as TopicRegistry, nativePubsub)
5122
5006
  const reusePort = options?.reusePort === true
5123
5007
  // Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
5124
5008
  // on some option paths, and omitting is what selects its 0.0.0.0 default.
@@ -5156,6 +5040,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
5156
5040
  },
5157
5041
  })) as unknown as RunningServer
5158
5042
  this.bunServer = running
5043
+ // Bind `app.publish` to Bun's native broadcast now that the server handle exists. Guarded on the
5044
+ // method's presence so a runtime whose handle lacks it simply keeps the registry path.
5045
+ if (nativePubsub && typeof running.publish === "function") {
5046
+ const native = running.publish.bind(running)
5047
+ this.nativePublish = (topic, data) => {
5048
+ native(topic, data)
5049
+ }
5050
+ }
5159
5051
  this.sealed = true
5160
5052
  if (this.gracefulSignals) this.installSignalHandlers()
5161
5053
  return running