@nifrajs/core 5.11.0 → 5.12.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 (79) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/edge-kit.d.ts +1 -1
  3. package/dist/edge-kit.d.ts.map +1 -1
  4. package/dist/edge-kit.js +1 -1
  5. package/dist/edge-kit.js.map +1 -1
  6. package/dist/index.d.ts +3 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/internal/capability-runtime.d.ts.map +1 -1
  11. package/dist/internal/capability-runtime.js +3 -2
  12. package/dist/internal/capability-runtime.js.map +1 -1
  13. package/dist/internal/route-execution.d.ts +6 -4
  14. package/dist/internal/route-execution.d.ts.map +1 -1
  15. package/dist/internal/route-execution.js.map +1 -1
  16. package/dist/internal/route-lanes.d.ts +4 -4
  17. package/dist/internal/route-lanes.d.ts.map +1 -1
  18. package/dist/internal/route-lanes.js +37 -37
  19. package/dist/internal/route-lanes.js.map +1 -1
  20. package/dist/manifest.d.ts.map +1 -1
  21. package/dist/manifest.js +2 -1
  22. package/dist/manifest.js.map +1 -1
  23. package/dist/server/body.d.ts +2 -0
  24. package/dist/server/body.d.ts.map +1 -1
  25. package/dist/server/body.js +2 -0
  26. package/dist/server/body.js.map +1 -1
  27. package/dist/server/core-copies.js +1 -1
  28. package/dist/server/server-types.d.ts +7 -1
  29. package/dist/server/server-types.d.ts.map +1 -1
  30. package/dist/server/server.d.ts +16 -24
  31. package/dist/server/server.d.ts.map +1 -1
  32. package/dist/server/server.js +285 -140
  33. package/dist/server/server.js.map +1 -1
  34. package/dist/server/static-response.d.ts +13 -0
  35. package/dist/server/static-response.d.ts.map +1 -0
  36. package/dist/server/static-response.js +28 -0
  37. package/dist/server/static-response.js.map +1 -0
  38. package/dist/server/websocket.d.ts +23 -7
  39. package/dist/server/websocket.d.ts.map +1 -1
  40. package/dist/server/websocket.js +8 -3
  41. package/dist/server/websocket.js.map +1 -1
  42. package/dist/server/ws-adapter-view.d.ts +3 -3
  43. package/dist/server/ws-adapter-view.d.ts.map +1 -1
  44. package/dist/server/ws-bun.d.ts +4 -4
  45. package/dist/server/ws-bun.d.ts.map +1 -1
  46. package/dist/server/ws-bun.js +12 -5
  47. package/dist/server/ws-bun.js.map +1 -1
  48. package/dist/server/ws-hook.d.ts +4 -4
  49. package/dist/server/ws-hook.d.ts.map +1 -1
  50. package/dist/server.d.ts +2 -1
  51. package/dist/server.d.ts.map +1 -1
  52. package/dist/server.js +1 -0
  53. package/dist/server.js.map +1 -1
  54. package/dist/tool-contract.d.ts +5 -0
  55. package/dist/tool-contract.d.ts.map +1 -1
  56. package/dist/tool-contract.js +6 -3
  57. package/dist/tool-contract.js.map +1 -1
  58. package/dist/ws.d.ts +1 -1
  59. package/dist/ws.d.ts.map +1 -1
  60. package/dist/ws.js.map +1 -1
  61. package/package.json +1 -1
  62. package/src/edge-kit.ts +1 -1
  63. package/src/index.ts +3 -1
  64. package/src/internal/capability-runtime.ts +3 -2
  65. package/src/internal/route-execution.ts +6 -4
  66. package/src/internal/route-lanes.ts +44 -44
  67. package/src/manifest.ts +2 -1
  68. package/src/server/body.ts +3 -0
  69. package/src/server/core-copies.ts +1 -1
  70. package/src/server/server-types.ts +7 -1
  71. package/src/server/server.ts +414 -189
  72. package/src/server/static-response.ts +36 -0
  73. package/src/server/websocket.ts +29 -8
  74. package/src/server/ws-adapter-view.ts +3 -3
  75. package/src/server/ws-bun.ts +15 -9
  76. package/src/server/ws-hook.ts +4 -3
  77. package/src/server.ts +2 -0
  78. package/src/tool-contract.ts +13 -3
  79. package/src/ws.ts +1 -0
@@ -81,6 +81,7 @@ import { LAST_BEFORE_HANDLE } from "./before-handler.ts"
81
81
  import {
82
82
  assertByteLimit,
83
83
  clientBodyErrorCode,
84
+ DEFAULT_MAX_BODY_BYTES,
84
85
  hasTrustedBodyFraming,
85
86
  markTransportCap,
86
87
  markTrustedBodyFraming,
@@ -100,7 +101,7 @@ import {
100
101
  headerKeysAllLowercase,
101
102
  markLowercaseHeaderKeys,
102
103
  } from "./header-case.ts"
103
- import { declaredHeaders, headerObjectOf } from "./headers.ts"
104
+ import { declaredHeaderNames, declaredHeaders, headerObjectOf } from "./headers.ts"
104
105
  import {
105
106
  isRoutableMethod,
106
107
  jsonError,
@@ -167,6 +168,7 @@ import {
167
168
  type StatusResponse,
168
169
  } from "./runtime-core.ts"
169
170
  import { normalizeStaticResponseHeaders, type StaticResponseHeaders } from "./static-headers.ts"
171
+ import { createStaticResponse, staticResponseDefinition } from "./static-response.ts"
170
172
  import { withTextSlots } from "./text-slots.ts"
171
173
  import { plainValidationError } from "./validation.ts"
172
174
 
@@ -249,7 +251,7 @@ import type {
249
251
  } from "./server-types.ts"
250
252
  import type { SSEInit, TypedSSEStream } from "./sse.ts"
251
253
  import type { SseRuntime } from "./sse-hook.ts"
252
- import type { TopicRegistry, WebSocketHandler, WebSocketUpgradeOutcome } from "./websocket.ts"
254
+ import type { TopicAdapter, WebSocketHandler, WebSocketUpgradeOutcome } from "./websocket.ts"
253
255
  import type { BunWsData } from "./ws-bun.ts"
254
256
  import type { WsRuntime } from "./ws-hook.ts"
255
257
 
@@ -452,7 +454,8 @@ class BunPeerPlatform implements PeerPlatform {
452
454
  }
453
455
 
454
456
  type BunNativeHandler = (request: Request, server: BunPeerServer) => MaybePromise<Response>
455
- type BunNativeMethodTable = Partial<Record<Method, BunNativeHandler>>
457
+ type BunNativeRouteValue = BunNativeHandler | Response
458
+ type BunNativeMethodTable = Partial<Record<Method, BunNativeRouteValue>>
456
459
  // Bun's route table has a slot for each of these; a QUERY entry is ignored and the request falls
457
460
  // through to the table's fetch, so QUERY routes stay on the portable dispatcher.
458
461
  const BUN_TABLE_METHODS: readonly Method[] = METHODS.filter((method) => method !== "QUERY")
@@ -648,7 +651,6 @@ export {
648
651
  } from "./plugin.ts"
649
652
  export type { IdentityPlugin }
650
653
 
651
- const DEFAULT_MAX_BODY_BYTES = 1_000_000
652
654
  const BODY_RESPONSE = (response: Response | ResponseResult): Response | ResponseResult => response
653
655
  const BODY_ERROR = (error: unknown): Promise<never> => Promise.reject(error)
654
656
  const DEFAULT_DRAIN_MS = 10_000
@@ -886,6 +888,98 @@ interface FusedHooks {
886
888
  readonly decorations: Record<PropertyKey, unknown> | undefined
887
889
  }
888
890
 
891
+ interface FusedValidationStage {
892
+ readonly schema: StandardSchemaV1
893
+ readonly kind: "headers" | "cookies" | "params" | "query"
894
+ readonly headerNames?: readonly string[] | undefined
895
+ }
896
+
897
+ type FusedValidationFields = {
898
+ headers: unknown
899
+ cookies: unknown
900
+ params: unknown
901
+ query: unknown
902
+ }
903
+
904
+ /** Compile the non-body validation stages in the same order as the route program. The selected
905
+ * fused lane runs this only for routes whose recovery behavior is the ordinary 422 response. */
906
+ function compileFusedValidationStages(
907
+ schema: RouteSchema | undefined,
908
+ ): readonly FusedValidationStage[] {
909
+ if (schema === undefined) return []
910
+ const stages: FusedValidationStage[] = []
911
+ if (schema.headers !== undefined) {
912
+ stages.push({
913
+ schema: schema.headers,
914
+ kind: "headers",
915
+ headerNames: declaredHeaderNames(schema.headers),
916
+ })
917
+ }
918
+ if (schema.cookies !== undefined) {
919
+ stages.push({ schema: schema.cookies, kind: "cookies" })
920
+ }
921
+ if (schema.params !== undefined) {
922
+ stages.push({ schema: schema.params, kind: "params" })
923
+ }
924
+ if (schema.query !== undefined) {
925
+ stages.push({ schema: schema.query, kind: "query" })
926
+ }
927
+ return stages
928
+ }
929
+
930
+ /** Run a registration-compiled input sequence without allocating a lifecycle-program continuation
931
+ * for each synchronous schema. Only an asynchronous validator creates a Promise continuation. */
932
+ function runFusedValidationStages<T>(
933
+ stages: readonly FusedValidationStage[],
934
+ source: RequestSource,
935
+ ctx: RawContext,
936
+ wrapResponse: Wrap<T>,
937
+ onError: (error: unknown) => MaybePromise<T>,
938
+ next: () => MaybePromise<T>,
939
+ startIndex = 0,
940
+ ): MaybePromise<T> {
941
+ if (stages.length === 0) return next()
942
+ const validationContext: FusedValidationFields = ctx
943
+ for (let index = startIndex; index < stages.length; index++) {
944
+ const stage = stages[index]!
945
+ let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
946
+ try {
947
+ const input =
948
+ stage.kind === "headers"
949
+ ? stage.headerNames === undefined
950
+ ? headerObjectOf(source.headers)
951
+ : declaredHeaders(source.headers, stage.headerNames)
952
+ : stage.kind === "cookies"
953
+ ? ctx.cookies
954
+ : stage.kind === "params"
955
+ ? ctx.params
956
+ : queryObjectOf(ctx[CONTEXT_SEARCH])
957
+ validation = stage.schema["~standard"].validate(input)
958
+ } catch (error) {
959
+ return onError(error)
960
+ }
961
+ if (validation instanceof Promise) {
962
+ return validation.then((result) => {
963
+ if (result.issues !== undefined) return wrapResponse(plainValidationError(result.issues))
964
+ try {
965
+ validationContext[stage.kind] = result.value
966
+ } catch (error) {
967
+ return onError(error)
968
+ }
969
+ return runFusedValidationStages(stages, source, ctx, wrapResponse, onError, next, index + 1)
970
+ }, onError)
971
+ }
972
+ if (validation.issues !== undefined)
973
+ return wrapResponse(plainValidationError(validation.issues))
974
+ try {
975
+ validationContext[stage.kind] = validation.value
976
+ } catch (error) {
977
+ return onError(error)
978
+ }
979
+ }
980
+ return next()
981
+ }
982
+
889
983
  /** What a fused lifecycle route runs for its hook shape on the Web lane: the first derive, or the
890
984
  * handler when the shape has no hooks before it. */
891
985
  type FusedWebStart = (ctx: RawContext, route: FusedHooks, index: number) => MaybePromise<Response>
@@ -1380,17 +1474,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1380
1474
  private wsMountsCheckedAt = -1
1381
1475
  /** Whether an app mounted here takes WebSocket upgrades, as of `wsMountsCheckedAt`. */
1382
1476
  private wsMountsLive = false
1383
- /** In-process pub/sub backing `ws.subscribe(topic)` + `app.publish(topic, data)` (single-instance).
1384
- * Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime - `undefined` until then, so a
1385
- * no-WebSocket app never constructs (or bundles) it. */
1386
- private topics: TopicRegistry | undefined
1477
+ /** Configured topic adapter, or the in-process default created by the first `app.ws()` route. */
1478
+ private topics: TopicAdapter | undefined
1387
1479
  /** True once any `app.ws()` route sets `validateSend: true` - the one case where a broadcast frame
1388
- * is validated (and possibly dropped) per socket, so `app.publish` must stay on the JS registry loop
1480
+ * is validated (and possibly dropped) per socket, so `app.publish` must stay on the JS adapter loop
1389
1481
  * rather than delegate to Bun's native `server.publish`. */
1390
1482
  private wsHasValidatedSend = false
1391
1483
  /** Bun's native topic broadcast, bound by `listen()` in native-pubsub mode; `app.publish` uses it
1392
- * instead of the JS registry. `undefined` off Bun, before `listen()`, with a validated-send route,
1393
- * or when a mounted app takes upgrades. */
1484
+ * instead of the JS registry. `undefined` off Bun, before `listen()`, with a custom topic adapter,
1485
+ * a validated-send route, or when a mounted app takes upgrades. */
1394
1486
  private nativePublish:
1395
1487
  | ((topic: string, data: string | ArrayBufferView | ArrayBuffer) => void)
1396
1488
  | undefined
@@ -1539,7 +1631,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1539
1631
  this.catalog = new RouteCatalog()
1540
1632
  this.wsRouter = new Router<WsEntry>()
1541
1633
  this.wsRoutes = []
1542
- this.topics = undefined
1634
+ this.topics = options.topicAdapter
1543
1635
  const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
1544
1636
  assertByteLimit(maxBodyBytes, "maxBodyBytes")
1545
1637
  this.protoPoisoning = options.protoPoisoning ?? "reject"
@@ -2607,7 +2699,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2607
2699
  )
2608
2700
  }
2609
2701
  // A validated-send route makes broadcast bytes route-dependent (drop-on-invalid per socket), so the
2610
- // whole app forgoes native pub/sub and keeps the JS registry loop that runs each send through the sender.
2702
+ // whole app forgoes native pub/sub and keeps the JS adapter loop that runs each send through the sender.
2611
2703
  if (handler.validateSend === true) this.wsHasValidatedSend = true
2612
2704
  this.topics ??= runtime.createTopics()
2613
2705
  // A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
@@ -2637,18 +2729,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2637
2729
 
2638
2730
  /**
2639
2731
  * Broadcast `data` to every WebSocket connection subscribed to `topic` (via `ws.subscribe(topic)`).
2640
- * In-process and **single-instance** (see {@link TopicRegistry}) - a multi-instance deploy must bridge
2641
- * an external fan-out (Redis, a Durable Object) to this. A no-op when nobody is subscribed.
2732
+ * Uses the configured {@link ServerOptions.topicAdapter}, or the in-process topic registry by
2733
+ * default. Await the result when a custom adapter publishes asynchronously.
2642
2734
  */
2643
- publish(topic: string, data: string | ArrayBufferView | ArrayBuffer): void {
2735
+ publish(topic: string, data: string | ArrayBufferView | ArrayBuffer): void | Promise<void> {
2644
2736
  // Native-pubsub mode (Bun, no validated-send route): one native broadcast to every subscriber, no
2645
- // per-connection JS loop. Bound only after `listen()`; before that (or off Bun) the registry runs.
2737
+ // per-connection JS loop. Bound only after `listen()`; before that (or off Bun) the adapter runs.
2646
2738
  if (this.nativePublish !== undefined) {
2647
2739
  this.nativePublish(topic, data)
2648
2740
  return
2649
2741
  }
2650
- // No `app.ws()` yet ⇒ no registry and necessarily no subscribers - a publish is a no-op anyway.
2651
- this.topics?.publish(topic, data)
2742
+ // With no custom adapter and no `app.ws()` route, no registry exists and this is a no-op.
2743
+ return this.topics?.publish(topic, data)
2652
2744
  }
2653
2745
 
2654
2746
  private route(
@@ -2730,6 +2822,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2730
2822
  schema: RouteSchema | undefined,
2731
2823
  handler: (context: never) => unknown,
2732
2824
  ): CatalogRoute {
2825
+ const staticResponse = staticResponseDefinition(handler)
2733
2826
  schema = withTextSlots(schema)
2734
2827
  // Route provenance for the CLI's capability walk, which sets NIFRA_ROUTE_SITES while it loads the
2735
2828
  // app: the module that registered the route, read where a static scan cannot see the path.
@@ -2791,9 +2884,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2791
2884
  handler = this.idempotencyRuntime.wrapHandler(handler)
2792
2885
  }
2793
2886
  const { bare, fusedQuery, fusedBody } = lanes
2794
- // `selectRouteLanes` admits the fused hook lanes only for derive, before and after hooks, so the
2795
- // three arrays below are the route's whole lifecycle.
2796
- const fusedLifecycle = lanes.fusedLane === "hooks" || lanes.fusedLane === "body-hooks"
2887
+ // Composite-input routes and hook-only routes share the lifecycle builders; simple shapes use
2888
+ // their narrower runners.
2889
+ const fusedLifecycle =
2890
+ lanes.fusedLane === "hooks" ||
2891
+ lanes.fusedLane === "inputs" ||
2892
+ (lanes.fusedLane === "query" && schema?.params !== undefined)
2797
2893
  // The fused chains keep the arrays they are built from, and `beforeHandle()` inserts into the live
2798
2894
  // ones ahead of `beforeHandler()` guards, so the entry and the fused chains share one snapshot of
2799
2895
  // this route's.
@@ -2817,16 +2913,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2817
2913
  ? this.buildFusedBodyRunner(
2818
2914
  handler as unknown as InternalHandler,
2819
2915
  schema?.body as StandardSchemaV1,
2916
+ schema?.params,
2820
2917
  hasDecorations ? routeDecorations : undefined,
2821
2918
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2822
2919
  )
2823
2920
  : fusedHooks !== undefined
2824
- ? this.buildFusedLifecycleRunner(
2825
- fusedHooks,
2826
- schema?.query,
2827
- schema?.body as StandardSchemaV1 | undefined,
2828
- bodyLimit ?? UNLIMITED_BODY_BYTES,
2829
- )
2921
+ ? this.buildFusedLifecycleRunner(fusedHooks, schema, bodyLimit ?? UNLIMITED_BODY_BYTES)
2830
2922
  : undefined
2831
2923
  const fusedWeb =
2832
2924
  bare && this.aroundHooks.length === 0
@@ -2840,18 +2932,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2840
2932
  ? this.buildFusedQueryWeb(
2841
2933
  handler as unknown as InternalHandler,
2842
2934
  hasDecorations ? routeDecorations : undefined,
2843
- schema?.query as StandardSchemaV1,
2935
+ schema?.query,
2936
+ schema?.params,
2844
2937
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2845
2938
  )
2846
2939
  : fusedBody
2847
2940
  ? this.buildFusedBodyWeb(fusedBodyRunner as FusedBodyRunner)
2848
2941
  : fusedHooks !== undefined
2849
- ? this.buildFusedLifecycleWeb(
2850
- fusedHooks,
2851
- schema?.query,
2852
- schema?.body as StandardSchemaV1 | undefined,
2853
- bodyLimit ?? UNLIMITED_BODY_BYTES,
2854
- )
2942
+ ? this.buildFusedLifecycleWeb(fusedHooks, schema, bodyLimit ?? UNLIMITED_BODY_BYTES)
2855
2943
  : undefined
2856
2944
  const program = compileRouteProgram({
2857
2945
  schema,
@@ -2880,6 +2968,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2880
2968
  })
2881
2969
  const registeredEntry: RouteEntry = {
2882
2970
  handler: handler as unknown as InternalHandler,
2971
+ staticResponse,
2883
2972
  schema,
2884
2973
  bodyLimit,
2885
2974
  idempotent,
@@ -3346,7 +3435,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3346
3435
  const lane = entry.execution.fusedLane
3347
3436
  // Type-erase the lane so each `===` branch sees the full union, not the narrowed remainder.
3348
3437
  const laneName: string | undefined = lane
3349
- const lifecycle = laneName === "hooks" || laneName === "body-hooks"
3438
+ const lifecycle =
3439
+ laneName === "hooks" ||
3440
+ laneName === "inputs" ||
3441
+ (laneName === "query" && entry.schema?.params !== undefined)
3350
3442
  const hooks: FusedHooks | undefined = lifecycle
3351
3443
  ? {
3352
3444
  handler: entry.handler,
@@ -3362,14 +3454,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3362
3454
  ? this.buildFusedBodyRunner(
3363
3455
  entry.handler,
3364
3456
  entry.schema?.body as StandardSchemaV1,
3457
+ entry.schema?.params,
3365
3458
  entry.hasDecorations ? entry.decorations : undefined,
3366
3459
  entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3367
3460
  )
3368
3461
  : hooks !== undefined
3369
3462
  ? this.buildFusedLifecycleRunner(
3370
3463
  hooks,
3371
- entry.schema?.query,
3372
- entry.schema?.body as StandardSchemaV1 | undefined,
3464
+ entry.schema,
3373
3465
  entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3374
3466
  )
3375
3467
  : undefined
@@ -3380,14 +3472,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3380
3472
  ? this.buildFusedQueryWeb(
3381
3473
  entry.handler,
3382
3474
  entry.hasDecorations ? entry.decorations : undefined,
3383
- entry.schema?.query as StandardSchemaV1,
3475
+ entry.schema?.query,
3476
+ entry.schema?.params,
3384
3477
  entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3385
3478
  )
3386
3479
  : hooks !== undefined
3387
3480
  ? this.buildFusedLifecycleWeb(
3388
3481
  hooks,
3389
- entry.schema?.query,
3390
- entry.schema?.body as StandardSchemaV1 | undefined,
3482
+ entry.schema,
3391
3483
  entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3392
3484
  )
3393
3485
  : this.buildFusedWeb(
@@ -4017,8 +4109,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4017
4109
  const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params)
4018
4110
  if (params === null) return refuse(jsonError(400, "malformed_path"))
4019
4111
  const { handler, chain } = match.payload
4020
- // Non-null: a socket route ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
4021
- const pubsub = this.topics as TopicRegistry
4112
+ // Non-null: a socket route ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + adapter.
4113
+ const pubsub = this.topics as TopicAdapter
4022
4114
  const runtime = this.wsRuntime as WsRuntime
4023
4115
  // CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
4024
4116
  // Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
@@ -5179,12 +5271,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5179
5271
  }
5180
5272
  }
5181
5273
 
5182
- /** Compile the eligible body-only route's parser → validator → handler continuation once. The
5274
+ /** Compile the eligible body route's params → parser → validator → handler continuation once. The
5183
5275
  * bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
5184
5276
  * lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
5185
5277
  private buildFusedBodyRunner(
5186
5278
  handler: InternalHandler,
5187
5279
  bodySchema: StandardSchemaV1,
5280
+ paramsSchema: StandardSchemaV1 | undefined,
5188
5281
  decorations: Record<PropertyKey, unknown> | undefined,
5189
5282
  maxBodyBytes: number,
5190
5283
  ): FusedBodyRunner {
@@ -5268,6 +5361,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5268
5361
  }
5269
5362
  }
5270
5363
 
5364
+ const readBody = <T>(
5365
+ source: RequestSource,
5366
+ ctx: RawContext,
5367
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5368
+ wrapResponse: (response: Response | ResponseResult) => T,
5369
+ ): Promise<T> =>
5370
+ readBodyFramed(
5371
+ source,
5372
+ maxBodyBytes,
5373
+ this.protoPoisoning,
5374
+ (parsed) => runParsed(parsed, ctx, finalize, wrapResponse),
5375
+ wrapResponse,
5376
+ (err) => logError(err, ctx, finalize, wrapResponse),
5377
+ bodySchema,
5378
+ this.formOrigins,
5379
+ )
5380
+
5271
5381
  return <T>(
5272
5382
  source: RequestSource,
5273
5383
  params: Record<string, string>,
@@ -5291,18 +5401,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5291
5401
  maxBodyBytes,
5292
5402
  this.protoPoisoning,
5293
5403
  )
5294
- const finish = (value: unknown): MaybePromise<T> =>
5295
- runParsed(value, ctx, finalize, wrapResponse)
5296
- return readBodyFramed(
5297
- source,
5298
- maxBodyBytes,
5299
- this.protoPoisoning,
5300
- finish,
5301
- wrapResponse,
5302
- (err) => logError(err, ctx, finalize, wrapResponse),
5303
- bodySchema,
5304
- this.formOrigins,
5305
- )
5404
+ if (paramsSchema === undefined) return readBody(source, ctx, finalize, wrapResponse)
5405
+ let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
5406
+ try {
5407
+ validation = paramsSchema["~standard"].validate(ctx.params)
5408
+ } catch (err) {
5409
+ return logError(err, ctx, finalize, wrapResponse)
5410
+ }
5411
+ if (validation instanceof Promise) {
5412
+ return validation.then(
5413
+ (settled) => {
5414
+ if (settled.issues !== undefined)
5415
+ return wrapResponse(plainValidationError(settled.issues))
5416
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: the route's params schema defines this validated output; RawContext erases its route-specific type.
5417
+ ctx.params = settled.value as Record<string, string>
5418
+ return readBody(source, ctx, finalize, wrapResponse)
5419
+ },
5420
+ (err) => logError(err, ctx, finalize, wrapResponse),
5421
+ )
5422
+ }
5423
+ if (validation.issues !== undefined)
5424
+ return wrapResponse(plainValidationError(validation.issues))
5425
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: the route's params schema defines this validated output; RawContext erases its route-specific type.
5426
+ ctx.params = validation.value as Record<string, string>
5427
+ return readBody(source, ctx, finalize, wrapResponse)
5306
5428
  }
5307
5429
  }
5308
5430
 
@@ -5330,17 +5452,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5330
5452
  ) as MaybePromise<Response>
5331
5453
  }
5332
5454
 
5333
- /** The fused Web lane for a route whose only lifecycle step is a query schema: parse + validate +
5334
- * handler + respond in one closure, no lifecycle promise when the validator and handler are sync.
5335
- * Eligibility is decided at registration (see `fusedQuery` in {@link register}); the semantics here
5336
- * are exactly `runQueryOnly`'s for that eligible shape - invalid input returns
5337
- * `validationError(issues)` (recovery hooks disqualify the route from this lane), a thrown
5338
- * `Response` passes through, anything else logs and returns a flat 500, and an async validator
5339
- * falls to a then-chain with the same steps. */
5455
+ /** The fused Web lane for routes with path params and/or a query schema: validate + handler + respond
5456
+ * in one closure, with no lifecycle promise when the validators and handler are sync. */
5340
5457
  private buildFusedQueryWeb(
5341
5458
  handler: InternalHandler,
5342
5459
  decorations: Record<PropertyKey, unknown> | undefined,
5343
- querySchema: StandardSchemaV1,
5460
+ querySchema: StandardSchemaV1 | undefined,
5461
+ paramsSchema: StandardSchemaV1 | undefined,
5344
5462
  maxBodyBytes: number,
5345
5463
  ): FusedWebRunner {
5346
5464
  // Same thrown-value contract as the generic lane (`bare-error-lane.ts` via `bareError`); the fused
@@ -5354,7 +5472,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5354
5472
  this.wrapWebResponse,
5355
5473
  )
5356
5474
  const runHandler = (ctx: RawContext, value: unknown): MaybePromise<Response> => {
5357
- ctx.query = value
5475
+ if (querySchema !== undefined) ctx.query = value
5358
5476
  let result: unknown
5359
5477
  try {
5360
5478
  result = handler(ctx)
@@ -5369,6 +5487,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5369
5487
  }
5370
5488
  return this.respondFused(result, ctx, this.onResponseHooks.length === 0)
5371
5489
  }
5490
+ const runQuery = (ctx: RawContext): MaybePromise<Response> => {
5491
+ if (querySchema === undefined) return runHandler(ctx, undefined)
5492
+ let validation: MaybePromise<StandardResult<unknown>>
5493
+ try {
5494
+ validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
5495
+ } catch (err) {
5496
+ return logError(err, ctx)
5497
+ }
5498
+ if (validation instanceof Promise) {
5499
+ return validation.then(
5500
+ (settled) =>
5501
+ settled.issues !== undefined
5502
+ ? this.wrapWebResponse(plainValidationError(settled.issues))
5503
+ : runHandler(ctx, settled.value),
5504
+ (err) => logError(err, ctx),
5505
+ )
5506
+ }
5507
+ if (validation.issues !== undefined)
5508
+ return this.wrapWebResponse(plainValidationError(validation.issues))
5509
+ return runHandler(ctx, validation.value)
5510
+ }
5372
5511
  return (source, params, search, signal, budget, platform, nativeContext) => {
5373
5512
  const ctx = nativeContext
5374
5513
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
@@ -5383,24 +5522,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5383
5522
  this.protoPoisoning,
5384
5523
  )
5385
5524
  if (decorations !== undefined) Object.assign(ctx, decorations)
5525
+ if (paramsSchema === undefined) return runQuery(ctx)
5386
5526
  let validation: MaybePromise<StandardResult<unknown>>
5387
5527
  try {
5388
- validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
5528
+ validation = paramsSchema["~standard"].validate(ctx.params)
5389
5529
  } catch (err) {
5390
5530
  return logError(err, ctx)
5391
5531
  }
5392
5532
  if (validation instanceof Promise) {
5393
5533
  return validation.then(
5394
- (settled) =>
5395
- settled.issues !== undefined
5396
- ? this.wrapWebResponse(plainValidationError(settled.issues))
5397
- : runHandler(ctx, settled.value),
5534
+ (settled) => {
5535
+ if (settled.issues !== undefined)
5536
+ return this.wrapWebResponse(plainValidationError(settled.issues))
5537
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: the route's params schema defines this validated output; RawContext erases its route-specific type.
5538
+ ctx.params = settled.value as Record<string, string>
5539
+ return runQuery(ctx)
5540
+ },
5398
5541
  (err) => logError(err, ctx),
5399
5542
  )
5400
5543
  }
5401
5544
  if (validation.issues !== undefined)
5402
5545
  return this.wrapWebResponse(plainValidationError(validation.issues))
5403
- return runHandler(ctx, validation.value)
5546
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: the route's params schema defines this validated output; RawContext erases its route-specific type.
5547
+ ctx.params = validation.value as Record<string, string>
5548
+ return runQuery(ctx)
5404
5549
  }
5405
5550
  }
5406
5551
 
@@ -5625,13 +5770,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5625
5770
  /**
5626
5771
  * Compile a fused lifecycle route's entry closure: parse (body) → validate → the shared stage chain
5627
5772
  * of its hook shape (derive → before → handler → after → respond, see {@link fusedWebStages}) in a
5628
- * single frame, where the generic program walks staged hook loops. `selectRouteLanes` admits only
5629
- * derive, before and after hooks - no params or headers schema, onError, around, decorations,
5630
- * response contract or recovery hook, and never a query schema beside a body schema - so the behavior
5631
- * is the compiled program's: the same stage order, the same thrown-value contract (`Response` is
5632
- * control flow, anything else logs and 500s), the same `c.set` semantics and `fusedRespond` render.
5633
- * The route's own closure holds only its `FusedHooks` and schemas and takes the request's context as
5634
- * an argument, so a request allocates no step closures.
5773
+ * single frame, where the generic program walks staged hook loops. `selectRouteLanes` admits ordinary
5774
+ * validation stages and derive/before/after/error hooks, while excluding auth-first ordering, custom
5775
+ * validation recovery, outer wrappers and response contracts. The shared input sequence preserves
5776
+ * the program's validation order around body parsing; the hook chain preserves thrown-value, `c.set`
5777
+ * and response-rendering semantics. The route's closure holds its `FusedHooks` and schemas, so a
5778
+ * request allocates no step closures.
5635
5779
  */
5636
5780
  /**
5637
5781
  * Keep the common one-derive/one-before shape bound to its hooks. Inline its synchronous path to
@@ -5781,12 +5925,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5781
5925
 
5782
5926
  private buildFusedLifecycleWeb(
5783
5927
  route: FusedHooks,
5784
- querySchema: StandardSchemaV1 | undefined,
5785
- bodySchema: StandardSchemaV1 | undefined,
5928
+ schema: RouteSchema | undefined,
5786
5929
  maxBodyBytes: number,
5787
5930
  ): FusedWebRunner {
5931
+ const validationStages = compileFusedValidationStages(schema)
5932
+ const bodySchema = schema?.body
5788
5933
  if (
5789
- querySchema === undefined &&
5934
+ validationStages.length === 0 &&
5790
5935
  bodySchema === undefined &&
5791
5936
  route.errors.length === 0 &&
5792
5937
  route.decorations === undefined &&
@@ -5810,30 +5955,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5810
5955
  this.fusedWebStages(d, b, a),
5811
5956
  )
5812
5957
  const decorations = route.decorations
5813
- if (bodySchema !== undefined) {
5814
- const onParsed = (parsed: unknown, ctx: RawContext): MaybePromise<Response> => {
5815
- let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
5816
- try {
5817
- validation = bodySchema["~standard"].validate(parsed)
5818
- } catch (err) {
5819
- return this.fusedWebRouteFail(err, ctx, route)
5820
- }
5821
- if (validation instanceof Promise) {
5822
- return validation.then(
5823
- (settled) => {
5824
- if (settled.issues !== undefined)
5825
- return this.wrapWebResponse(plainValidationError(settled.issues))
5826
- ctx.body = settled.value
5827
- return runDerive(ctx, route, 0)
5828
- },
5829
- (err) => this.fusedWebRouteFail(err, ctx, route),
5830
- )
5831
- }
5832
- if (validation.issues !== undefined)
5833
- return this.wrapWebResponse(plainValidationError(validation.issues))
5834
- ctx.body = validation.value
5835
- return runDerive(ctx, route, 0)
5836
- }
5958
+ if (bodySchema === undefined) {
5837
5959
  return (source, params, search, signal, budget, platform, nativeContext) => {
5838
5960
  const ctx = nativeContext
5839
5961
  ? RequestContext.native(
@@ -5855,36 +5977,40 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5855
5977
  this.protoPoisoning,
5856
5978
  )
5857
5979
  if (decorations !== undefined) Object.assign(ctx, decorations)
5858
- return readBodyFramed(
5980
+ if (validationStages.length === 0) return runDerive(ctx, route, 0)
5981
+ return runFusedValidationStages(
5982
+ validationStages,
5859
5983
  source,
5860
- maxBodyBytes,
5861
- this.protoPoisoning,
5862
- (parsed) => onParsed(parsed, ctx),
5984
+ ctx,
5863
5985
  this.wrapWebResponse,
5864
5986
  (err) => this.fusedWebRouteFail(err, ctx, route),
5865
- bodySchema,
5866
- this.formOrigins,
5867
- ) as MaybePromise<Response>
5987
+ () => runDerive(ctx, route, 0),
5988
+ )
5868
5989
  }
5869
5990
  }
5870
- return (source, params, search, signal, budget, platform, nativeContext) => {
5871
- const ctx = nativeContext
5872
- ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
5873
- : new RequestContext(
5991
+ const queryIndex =
5992
+ schema?.query === undefined ? validationStages.length : validationStages.length - 1
5993
+ const beforeBodyStages = validationStages.slice(0, queryIndex)
5994
+ const afterBodyStages = validationStages.slice(queryIndex)
5995
+ const runAfterBody = (source: RequestSource, ctx: RawContext): MaybePromise<Response> =>
5996
+ afterBodyStages.length === 0
5997
+ ? runDerive(ctx, route, 0)
5998
+ : runFusedValidationStages(
5999
+ afterBodyStages,
5874
6000
  source,
5875
- params,
5876
- search,
5877
- signal,
5878
- budget,
5879
- platform,
5880
- maxBodyBytes,
5881
- this.protoPoisoning,
6001
+ ctx,
6002
+ this.wrapWebResponse,
6003
+ (err) => this.fusedWebRouteFail(err, ctx, route),
6004
+ () => runDerive(ctx, route, 0),
5882
6005
  )
5883
- if (decorations !== undefined) Object.assign(ctx, decorations)
5884
- if (querySchema === undefined) return runDerive(ctx, route, 0)
5885
- let validation: MaybePromise<StandardResult<unknown>>
6006
+ const onParsed = (
6007
+ parsed: unknown,
6008
+ source: RequestSource,
6009
+ ctx: RawContext,
6010
+ ): MaybePromise<Response> => {
6011
+ let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
5886
6012
  try {
5887
- validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
6013
+ validation = bodySchema["~standard"].validate(parsed)
5888
6014
  } catch (err) {
5889
6015
  return this.fusedWebRouteFail(err, ctx, route)
5890
6016
  }
@@ -5893,16 +6019,51 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5893
6019
  (settled) => {
5894
6020
  if (settled.issues !== undefined)
5895
6021
  return this.wrapWebResponse(plainValidationError(settled.issues))
5896
- ctx.query = settled.value
5897
- return runDerive(ctx, route, 0)
6022
+ ctx.body = settled.value
6023
+ return runAfterBody(source, ctx)
5898
6024
  },
5899
6025
  (err) => this.fusedWebRouteFail(err, ctx, route),
5900
6026
  )
5901
6027
  }
5902
6028
  if (validation.issues !== undefined)
5903
6029
  return this.wrapWebResponse(plainValidationError(validation.issues))
5904
- ctx.query = validation.value
5905
- return runDerive(ctx, route, 0)
6030
+ ctx.body = validation.value
6031
+ return runAfterBody(source, ctx)
6032
+ }
6033
+ const readBody = (source: RequestSource, ctx: RawContext): Promise<Response> =>
6034
+ readBodyFramed(
6035
+ source,
6036
+ maxBodyBytes,
6037
+ this.protoPoisoning,
6038
+ (parsed) => onParsed(parsed, source, ctx),
6039
+ this.wrapWebResponse,
6040
+ (err) => this.fusedWebRouteFail(err, ctx, route),
6041
+ bodySchema,
6042
+ this.formOrigins,
6043
+ )
6044
+ return (source, params, search, signal, budget, platform, nativeContext) => {
6045
+ const ctx = nativeContext
6046
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
6047
+ : new RequestContext(
6048
+ source,
6049
+ params,
6050
+ search,
6051
+ signal,
6052
+ budget,
6053
+ platform,
6054
+ maxBodyBytes,
6055
+ this.protoPoisoning,
6056
+ )
6057
+ if (decorations !== undefined) Object.assign(ctx, decorations)
6058
+ if (beforeBodyStages.length === 0) return readBody(source, ctx)
6059
+ return runFusedValidationStages(
6060
+ beforeBodyStages,
6061
+ source,
6062
+ ctx,
6063
+ this.wrapWebResponse,
6064
+ (err) => this.fusedWebRouteFail(err, ctx, route),
6065
+ () => readBody(source, ctx),
6066
+ )
5906
6067
  }
5907
6068
  }
5908
6069
 
@@ -6154,44 +6315,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6154
6315
  */
6155
6316
  private buildFusedLifecycleRunner(
6156
6317
  route: FusedHooks,
6157
- querySchema: StandardSchemaV1 | undefined,
6158
- bodySchema: StandardSchemaV1 | undefined,
6318
+ schema: RouteSchema | undefined,
6159
6319
  maxBodyBytes: number,
6160
6320
  ): FusedBodyRunner {
6321
+ const validationStages = compileFusedValidationStages(schema)
6322
+ const bodySchema = schema?.body
6161
6323
  const runDerive = sharedStages(NODE_STAGES, this, route, (d, b, a) =>
6162
6324
  this.fusedNodeStages(d, b, a),
6163
6325
  )
6164
6326
  const decorations = route.decorations
6165
6327
 
6166
- if (bodySchema !== undefined) {
6167
- const onParsed = <T>(
6168
- parsed: unknown,
6169
- ctx: RawContext,
6170
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
6171
- wrapResponse: (response: Response | ResponseResult) => T,
6172
- ): MaybePromise<T> => {
6173
- let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
6174
- try {
6175
- validation = bodySchema["~standard"].validate(parsed)
6176
- } catch (err) {
6177
- return this.fusedFail(err, ctx, route, finalize, wrapResponse)
6178
- }
6179
- if (validation instanceof Promise) {
6180
- return validation.then(
6181
- (settled) => {
6182
- if (settled.issues !== undefined)
6183
- return wrapResponse(plainValidationError(settled.issues))
6184
- ctx.body = settled.value
6185
- return runDerive(ctx, route, finalize, wrapResponse, 0)
6186
- },
6187
- (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse),
6188
- )
6189
- }
6190
- if (validation.issues !== undefined)
6191
- return wrapResponse(plainValidationError(validation.issues))
6192
- ctx.body = validation.value
6193
- return runDerive(ctx, route, finalize, wrapResponse, 0)
6194
- }
6328
+ if (bodySchema === undefined) {
6195
6329
  return <T>(
6196
6330
  source: RequestSource,
6197
6331
  params: Record<string, string>,
@@ -6223,41 +6357,84 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6223
6357
  this.protoPoisoning,
6224
6358
  )
6225
6359
  if (decorations !== undefined) Object.assign(ctx, decorations)
6226
- return readBodyFramed(
6360
+ if (validationStages.length === 0) return runDerive(ctx, route, finalize, wrapResponse, 0)
6361
+ return runFusedValidationStages(
6362
+ validationStages,
6227
6363
  source,
6228
- maxBodyBytes,
6229
- this.protoPoisoning,
6230
- (parsed) => onParsed(parsed, ctx, finalize, wrapResponse),
6364
+ ctx,
6231
6365
  wrapResponse,
6232
6366
  (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse),
6233
- bodySchema,
6234
- this.formOrigins,
6367
+ () => runDerive(ctx, route, finalize, wrapResponse, 0),
6235
6368
  )
6236
6369
  }
6237
6370
  }
6238
6371
 
6239
- const runValidated = <T>(
6240
- validation: StandardResult<unknown> | Promise<StandardResult<unknown>>,
6372
+ const queryIndex =
6373
+ schema?.query === undefined ? validationStages.length : validationStages.length - 1
6374
+ const beforeBodyStages = validationStages.slice(0, queryIndex)
6375
+ const afterBodyStages = validationStages.slice(queryIndex)
6376
+ const runAfterBody = <T>(
6377
+ source: RequestSource,
6378
+ ctx: RawContext,
6379
+ finalize: Finalize<T>,
6380
+ wrapResponse: Wrap<T>,
6381
+ ): MaybePromise<T> =>
6382
+ afterBodyStages.length === 0
6383
+ ? runDerive(ctx, route, finalize, wrapResponse, 0)
6384
+ : runFusedValidationStages(
6385
+ afterBodyStages,
6386
+ source,
6387
+ ctx,
6388
+ wrapResponse,
6389
+ (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse),
6390
+ () => runDerive(ctx, route, finalize, wrapResponse, 0),
6391
+ )
6392
+
6393
+ const onParsed = <T>(
6394
+ parsed: unknown,
6395
+ source: RequestSource,
6241
6396
  ctx: RawContext,
6242
6397
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
6243
6398
  wrapResponse: (response: Response | ResponseResult) => T,
6244
6399
  ): MaybePromise<T> => {
6400
+ let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
6401
+ try {
6402
+ validation = bodySchema["~standard"].validate(parsed)
6403
+ } catch (err) {
6404
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse)
6405
+ }
6245
6406
  if (validation instanceof Promise) {
6246
6407
  return validation.then(
6247
6408
  (settled) => {
6248
6409
  if (settled.issues !== undefined)
6249
6410
  return wrapResponse(plainValidationError(settled.issues))
6250
- ctx.query = settled.value
6251
- return runDerive(ctx, route, finalize, wrapResponse, 0)
6411
+ ctx.body = settled.value
6412
+ return runAfterBody(source, ctx, finalize, wrapResponse)
6252
6413
  },
6253
6414
  (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse),
6254
6415
  )
6255
6416
  }
6256
6417
  if (validation.issues !== undefined)
6257
6418
  return wrapResponse(plainValidationError(validation.issues))
6258
- ctx.query = validation.value
6259
- return runDerive(ctx, route, finalize, wrapResponse, 0)
6419
+ ctx.body = validation.value
6420
+ return runAfterBody(source, ctx, finalize, wrapResponse)
6260
6421
  }
6422
+ const readBody = <T>(
6423
+ source: RequestSource,
6424
+ ctx: RawContext,
6425
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
6426
+ wrapResponse: (response: Response | ResponseResult) => T,
6427
+ ): Promise<T> =>
6428
+ readBodyFramed(
6429
+ source,
6430
+ maxBodyBytes,
6431
+ this.protoPoisoning,
6432
+ (parsed) => onParsed(parsed, source, ctx, finalize, wrapResponse),
6433
+ wrapResponse,
6434
+ (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse),
6435
+ bodySchema,
6436
+ this.formOrigins,
6437
+ )
6261
6438
 
6262
6439
  return <T>(
6263
6440
  source: RequestSource,
@@ -6283,14 +6460,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6283
6460
  this.protoPoisoning,
6284
6461
  )
6285
6462
  if (decorations !== undefined) Object.assign(ctx, decorations)
6286
- if (querySchema === undefined) return runDerive(ctx, route, finalize, wrapResponse, 0)
6287
- let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
6288
- try {
6289
- validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
6290
- } catch (err) {
6291
- return this.fusedFail(err, ctx, route, finalize, wrapResponse)
6292
- }
6293
- return runValidated(validation, ctx, finalize, wrapResponse)
6463
+ if (beforeBodyStages.length === 0) return readBody(source, ctx, finalize, wrapResponse)
6464
+ return runFusedValidationStages(
6465
+ beforeBodyStages,
6466
+ source,
6467
+ ctx,
6468
+ wrapResponse,
6469
+ (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse),
6470
+ () => readBody(source, ctx, finalize, wrapResponse),
6471
+ )
6294
6472
  }
6295
6473
  }
6296
6474
 
@@ -7300,6 +7478,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7300
7478
  fallback: BunNativeHandler,
7301
7479
  peer: PeerPlatform,
7302
7480
  platformFor?: (request: Request, server: BunPeerServer) => Platform<EnvOf<Ctx>>,
7481
+ allowStaticResponses = true,
7303
7482
  ): BunNativeRoutes | undefined {
7304
7483
  const hooked = this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe
7305
7484
  if (
@@ -7320,6 +7499,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7320
7499
  // The capacity gate must wrap every request; the fused lane bypasses fetchMatched, so enabling
7321
7500
  // admission drops fusion (native matching stays) and routes through the gated matched lane.
7322
7501
  this.capacityGate === undefined
7502
+ // A response value skips every request callback, so every policy that could inspect it must be absent.
7503
+ const mayUseStaticResponse =
7504
+ allowStaticResponses &&
7505
+ !this.validateHost &&
7506
+ !this.normalizeTarget &&
7507
+ this.requestTimeoutMs === 0 &&
7508
+ !this.acceptInboundDeadlines &&
7509
+ this.capacityGate === undefined &&
7510
+ this.onRequestHooks.length === 0 &&
7511
+ this.onResponseHooks.length === 0 &&
7512
+ this.onResponseFinalizedHooks.length === 0 &&
7513
+ this.staticResponseHeaders === undefined &&
7514
+ this.responseBodyTag === undefined
7323
7515
  const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined
7324
7516
  const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined
7325
7517
  let count = 0
@@ -7385,7 +7577,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7385
7577
  return routes[key]
7386
7578
  }
7387
7579
  for (let i = 0; i < all.length; i++) {
7388
- const { method, path, pattern, entry } = all[i]!
7580
+ const route = all[i]!
7581
+ const { method, path, pattern, entry } = route
7389
7582
  const run = lead[i]
7390
7583
  // The route is served from the table unless its path cannot be there, Bun's table has no slot
7391
7584
  // for its method, or a path outside the table outranks it: Bun would hand this route a request
@@ -7402,6 +7595,34 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7402
7595
  ) {
7403
7596
  continue
7404
7597
  }
7598
+ const staticResponse = entry.staticResponse
7599
+ if (
7600
+ mayUseStaticResponse &&
7601
+ staticResponse !== undefined &&
7602
+ (method === "GET" || method === "HEAD") &&
7603
+ // Bun 1.3 can misframe null-body status codes in its native static table.
7604
+ staticResponse.status !== 204 &&
7605
+ staticResponse.status !== 205 &&
7606
+ staticResponse.status !== 304 &&
7607
+ pattern.paramNames.length === 0 &&
7608
+ entry.schema === undefined &&
7609
+ entry.idempotent === undefined &&
7610
+ entry.ledgered === undefined &&
7611
+ entry.responseContract === undefined &&
7612
+ entry.derives.length === 0 &&
7613
+ entry.authStages.length === 0 &&
7614
+ !entry.hasDecorations &&
7615
+ entry.beforeHandle.length === 0 &&
7616
+ entry.afterHandle.length === 0 &&
7617
+ entry.onError.length === 0 &&
7618
+ entry.around.length === 0 &&
7619
+ route.assurance.length === 0 &&
7620
+ (route.descriptor.capabilities?.length ?? 0) === 0
7621
+ ) {
7622
+ tableAt(path, pattern.segments)[method as Method] = createStaticResponse(staticResponse)
7623
+ count += 1
7624
+ continue
7625
+ }
7405
7626
  const paramNames = pattern.paramNames
7406
7627
  const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined
7407
7628
  const handler = this.compileBunNativeHandler(
@@ -7519,8 +7740,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7519
7740
  // matched route's handler, set by `server.upgrade`.
7520
7741
  // With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime - non-null
7521
7742
  // because a socket route means ws() ran, and ws() requires the runtime at registration.
7522
- // Native pub/sub when the app has WS routes and none validate outbound frames: `ws.subscribe` and
7523
- // `app.publish` go through Bun's own (uWebSockets) broadcast instead of the JS registry loop.
7743
+ // Native pub/sub when the app has WS routes, no custom adapter, and none validate outbound
7744
+ // frames: `ws.subscribe` and `app.publish` go through Bun's own (uWebSockets) broadcast.
7524
7745
  // A mounted app counts only while it has a WebSocket route (see `[GET_WS_RUNTIME]`), so composing
7525
7746
  // one without any needs no runtime here.
7526
7747
  const wsRuntime = this[GET_WS_RUNTIME]()
@@ -7531,11 +7752,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7531
7752
  )
7532
7753
  }
7533
7754
  // Native pub/sub is safe only when every upgrade belongs to this app. A mounted child that takes
7534
- // upgrades owns a different TopicRegistry, so force the per-connection JS dispatcher and carry
7535
- // that registry in the upgrade outcome instead of accidentally broadcasting child sockets through
7755
+ // upgrades owns a different topic adapter, so force the per-connection JS dispatcher and carry
7756
+ // that adapter in the upgrade outcome instead of accidentally broadcasting child sockets through
7536
7757
  // the parent.
7537
7758
  const nativePubsub =
7538
- this.wsRoutes.length > 0 && !this.mountsTakeUpgrades() && !this.wsHasValidatedSend
7759
+ this.wsRoutes.length > 0 &&
7760
+ this.options.topicAdapter === undefined &&
7761
+ !this.mountsTakeUpgrades() &&
7762
+ !this.wsHasValidatedSend
7539
7763
  const wsHandlers = wsRuntime?.bunHandlers(
7540
7764
  this.topics ?? wsRuntime.createTopics(),
7541
7765
  nativePubsub,
@@ -7599,6 +7823,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7599
7823
  (request, server) =>
7600
7824
  // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: `fallback` hands `fetch` the same platform, which holds no environment.
7601
7825
  new BunPeerPlatform(server, request, setIdleTimeout) as Platform<EnvOf<Ctx>>,
7826
+ options?.static === undefined,
7602
7827
  )
7603
7828
  const first = options?.static
7604
7829
  const before = <S, A>(
@@ -7619,7 +7844,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7619
7844
  const methods = nativeRoutes[path]!
7620
7845
  for (const method of BUN_TABLE_METHODS) {
7621
7846
  const handler = methods[method]
7622
- if (handler !== undefined) methods[method] = before(handler)
7847
+ if (typeof handler === "function") methods[method] = before(handler)
7623
7848
  }
7624
7849
  }
7625
7850
  }