@nifrajs/core 5.5.1 → 5.7.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 (209) hide show
  1. package/CHANGELOG.md +246 -0
  2. package/README.md +5 -0
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/binary.d.ts +15 -1
  6. package/dist/binary.d.ts.map +1 -1
  7. package/dist/binary.js +1 -0
  8. package/dist/binary.js.map +1 -1
  9. package/dist/edge-kit.d.ts +1 -0
  10. package/dist/edge-kit.d.ts.map +1 -1
  11. package/dist/edge-kit.js +1 -0
  12. package/dist/edge-kit.js.map +1 -1
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/evidence.d.ts +4 -0
  17. package/dist/evidence.d.ts.map +1 -1
  18. package/dist/evidence.js +4 -0
  19. package/dist/evidence.js.map +1 -1
  20. package/dist/file-type.d.ts +30 -0
  21. package/dist/file-type.d.ts.map +1 -0
  22. package/dist/file-type.js +118 -0
  23. package/dist/file-type.js.map +1 -0
  24. package/dist/index.d.ts +3 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +3 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/internal/hook-origins.d.ts +2 -0
  29. package/dist/internal/hook-origins.d.ts.map +1 -1
  30. package/dist/internal/hook-origins.js +12 -0
  31. package/dist/internal/hook-origins.js.map +1 -1
  32. package/dist/internal/route-program.d.ts +11 -0
  33. package/dist/internal/route-program.d.ts.map +1 -1
  34. package/dist/internal/route-program.js +133 -85
  35. package/dist/internal/route-program.js.map +1 -1
  36. package/dist/reflection.d.ts +19 -1
  37. package/dist/reflection.d.ts.map +1 -1
  38. package/dist/reflection.js +46 -7
  39. package/dist/reflection.js.map +1 -1
  40. package/dist/response-observer.d.ts +1 -0
  41. package/dist/response-observer.d.ts.map +1 -1
  42. package/dist/response-observer.js +1 -0
  43. package/dist/response-observer.js.map +1 -1
  44. package/dist/server/answer-step.d.ts +27 -0
  45. package/dist/server/answer-step.d.ts.map +1 -0
  46. package/dist/server/answer-step.js +33 -0
  47. package/dist/server/answer-step.js.map +1 -0
  48. package/dist/server/bare-error-lane.d.ts +4 -1
  49. package/dist/server/bare-error-lane.d.ts.map +1 -1
  50. package/dist/server/bare-error-lane.js +9 -3
  51. package/dist/server/bare-error-lane.js.map +1 -1
  52. package/dist/server/base64-answers.d.ts +5 -0
  53. package/dist/server/base64-answers.d.ts.map +1 -0
  54. package/dist/server/base64-answers.js +132 -0
  55. package/dist/server/base64-answers.js.map +1 -0
  56. package/dist/server/before-handler.d.ts +10 -0
  57. package/dist/server/before-handler.d.ts.map +1 -0
  58. package/dist/server/before-handler.js +12 -0
  59. package/dist/server/before-handler.js.map +1 -0
  60. package/dist/server/body-lane.d.ts.map +1 -1
  61. package/dist/server/body-lane.js +8 -2
  62. package/dist/server/body-lane.js.map +1 -1
  63. package/dist/server/body-parser.d.ts.map +1 -1
  64. package/dist/server/body-parser.js +2 -5
  65. package/dist/server/body-parser.js.map +1 -1
  66. package/dist/server/body.d.ts +24 -8
  67. package/dist/server/body.d.ts.map +1 -1
  68. package/dist/server/body.js +86 -33
  69. package/dist/server/body.js.map +1 -1
  70. package/dist/server/core-copies.d.ts.map +1 -1
  71. package/dist/server/core-copies.js +5 -2
  72. package/dist/server/core-copies.js.map +1 -1
  73. package/dist/server/http.d.ts.map +1 -1
  74. package/dist/server/http.js +6 -17
  75. package/dist/server/http.js.map +1 -1
  76. package/dist/server/install.d.ts +2 -0
  77. package/dist/server/install.d.ts.map +1 -1
  78. package/dist/server/install.js +2 -0
  79. package/dist/server/install.js.map +1 -1
  80. package/dist/server/json-response.d.ts +3 -0
  81. package/dist/server/json-response.d.ts.map +1 -0
  82. package/dist/server/json-response.js +14 -0
  83. package/dist/server/json-response.js.map +1 -0
  84. package/dist/server/json-text.d.ts +16 -0
  85. package/dist/server/json-text.d.ts.map +1 -0
  86. package/dist/server/json-text.js +34 -0
  87. package/dist/server/json-text.js.map +1 -0
  88. package/dist/server/multipart.d.ts +3 -0
  89. package/dist/server/multipart.d.ts.map +1 -1
  90. package/dist/server/multipart.js +226 -97
  91. package/dist/server/multipart.js.map +1 -1
  92. package/dist/server/node-outcome.d.ts +5 -0
  93. package/dist/server/node-outcome.d.ts.map +1 -1
  94. package/dist/server/node-outcome.js +3 -4
  95. package/dist/server/node-outcome.js.map +1 -1
  96. package/dist/server/proto-guard.d.ts.map +1 -1
  97. package/dist/server/proto-guard.js +39 -4
  98. package/dist/server/proto-guard.js.map +1 -1
  99. package/dist/server/query.d.ts.map +1 -1
  100. package/dist/server/query.js +8 -14
  101. package/dist/server/query.js.map +1 -1
  102. package/dist/server/repeated-prefix.d.ts +17 -0
  103. package/dist/server/repeated-prefix.d.ts.map +1 -0
  104. package/dist/server/repeated-prefix.js +17 -0
  105. package/dist/server/repeated-prefix.js.map +1 -0
  106. package/dist/server/request-context.d.ts.map +1 -1
  107. package/dist/server/request-context.js +5 -5
  108. package/dist/server/request-context.js.map +1 -1
  109. package/dist/server/respond.d.ts +3 -4
  110. package/dist/server/respond.d.ts.map +1 -1
  111. package/dist/server/respond.js +45 -37
  112. package/dist/server/respond.js.map +1 -1
  113. package/dist/server/response-contract-lane.d.ts +12 -2
  114. package/dist/server/response-contract-lane.d.ts.map +1 -1
  115. package/dist/server/response-contract-lane.js +137 -9
  116. package/dist/server/response-contract-lane.js.map +1 -1
  117. package/dist/server/route-template.d.ts +10 -0
  118. package/dist/server/route-template.d.ts.map +1 -0
  119. package/dist/server/route-template.js +21 -0
  120. package/dist/server/route-template.js.map +1 -0
  121. package/dist/server/runtime-core.d.ts +7 -0
  122. package/dist/server/runtime-core.d.ts.map +1 -1
  123. package/dist/server/runtime-core.js +10 -2
  124. package/dist/server/runtime-core.js.map +1 -1
  125. package/dist/server/server-types.d.ts +17 -8
  126. package/dist/server/server-types.d.ts.map +1 -1
  127. package/dist/server/server.d.ts +27 -12
  128. package/dist/server/server.d.ts.map +1 -1
  129. package/dist/server/server.js +326 -108
  130. package/dist/server/server.js.map +1 -1
  131. package/dist/server/sse.d.ts +1 -1
  132. package/dist/server/sse.d.ts.map +1 -1
  133. package/dist/server/sse.js +7 -5
  134. package/dist/server/sse.js.map +1 -1
  135. package/dist/server/text-slots.d.ts +8 -0
  136. package/dist/server/text-slots.d.ts.map +1 -0
  137. package/dist/server/text-slots.js +19 -0
  138. package/dist/server/text-slots.js.map +1 -0
  139. package/dist/server/webhook.d.ts +1 -1
  140. package/dist/server/webhook.d.ts.map +1 -1
  141. package/dist/server/webhook.js +24 -19
  142. package/dist/server/webhook.js.map +1 -1
  143. package/dist/server/websocket.d.ts +18 -3
  144. package/dist/server/websocket.d.ts.map +1 -1
  145. package/dist/server/websocket.js +45 -5
  146. package/dist/server/websocket.js.map +1 -1
  147. package/dist/server/ws-bun.d.ts.map +1 -1
  148. package/dist/server/ws-bun.js +7 -4
  149. package/dist/server/ws-bun.js.map +1 -1
  150. package/dist/server.d.ts +2 -0
  151. package/dist/server.d.ts.map +1 -1
  152. package/dist/server.js +2 -0
  153. package/dist/server.js.map +1 -1
  154. package/dist/single-copy.d.ts +5 -0
  155. package/dist/single-copy.d.ts.map +1 -1
  156. package/dist/single-copy.js +26 -17
  157. package/dist/single-copy.js.map +1 -1
  158. package/dist/tool-cli.d.ts +64 -0
  159. package/dist/tool-cli.d.ts.map +1 -0
  160. package/dist/tool-cli.js +495 -0
  161. package/dist/tool-cli.js.map +1 -0
  162. package/dist/transport-codec.d.ts.map +1 -1
  163. package/dist/transport-codec.js +2 -1
  164. package/dist/transport-codec.js.map +1 -1
  165. package/package.json +11 -1
  166. package/src/assurance.ts +1 -0
  167. package/src/binary.ts +17 -0
  168. package/src/edge-kit.ts +1 -0
  169. package/src/errors.ts +1 -0
  170. package/src/evidence.ts +8 -0
  171. package/src/file-type.ts +130 -0
  172. package/src/index.ts +3 -1
  173. package/src/internal/hook-origins.ts +10 -0
  174. package/src/internal/route-program.ts +261 -93
  175. package/src/reflection.ts +71 -7
  176. package/src/response-observer.ts +6 -0
  177. package/src/server/answer-step.ts +68 -0
  178. package/src/server/bare-error-lane.ts +9 -4
  179. package/src/server/base64-answers.ts +138 -0
  180. package/src/server/before-handler.ts +14 -0
  181. package/src/server/body-lane.ts +19 -9
  182. package/src/server/body-parser.ts +1 -5
  183. package/src/server/body.ts +96 -36
  184. package/src/server/core-copies.ts +5 -2
  185. package/src/server/http.ts +6 -15
  186. package/src/server/install.ts +3 -0
  187. package/src/server/json-response.ts +13 -0
  188. package/src/server/json-text.ts +34 -0
  189. package/src/server/multipart.ts +232 -100
  190. package/src/server/node-outcome.ts +4 -5
  191. package/src/server/proto-guard.ts +39 -4
  192. package/src/server/query.ts +7 -13
  193. package/src/server/repeated-prefix.ts +25 -0
  194. package/src/server/request-context.ts +5 -5
  195. package/src/server/respond.ts +47 -30
  196. package/src/server/response-contract-lane.ts +132 -9
  197. package/src/server/route-template.ts +24 -0
  198. package/src/server/runtime-core.ts +12 -2
  199. package/src/server/server-types.ts +18 -6
  200. package/src/server/server.ts +361 -112
  201. package/src/server/sse.ts +8 -6
  202. package/src/server/text-slots.ts +21 -0
  203. package/src/server/webhook.ts +29 -24
  204. package/src/server/websocket.ts +57 -8
  205. package/src/server/ws-bun.ts +7 -3
  206. package/src/server.ts +2 -0
  207. package/src/single-copy.ts +31 -15
  208. package/src/tool-cli.ts +584 -0
  209. package/src/transport-codec.ts +3 -1
@@ -50,7 +50,11 @@ import {
50
50
  type RouteEntry,
51
51
  } from "../internal/route-execution.ts"
52
52
  import type { ProgramValidationKind, RouteProgramStage } from "../internal/route-program.ts"
53
- import { compileRouteProgram, executeRouteProgram } from "../internal/route-program.ts"
53
+ import {
54
+ authenticateMount,
55
+ compileRouteProgram,
56
+ executeRouteProgram,
57
+ } from "../internal/route-program.ts"
54
58
  import { isSameOriginRequest } from "../internal/same-origin.ts"
55
59
  import type { ResponseObserverPlugin } from "../response-observer.ts"
56
60
  import {
@@ -65,10 +69,18 @@ import type {
65
69
  StandardResult,
66
70
  StandardSchemaV1,
67
71
  } from "../schema/standard.ts"
68
- import type { AuthenticationStage } from "./auth.ts"
72
+ import { withAnswerSteps } from "./answer-step.ts"
73
+ import {
74
+ type AuthenticationStage,
75
+ isAuthenticatedRequest,
76
+ markAuthenticatedRequest,
77
+ } from "./auth.ts"
69
78
  import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.ts"
79
+ import { LAST_BEFORE_HANDLE } from "./before-handler.ts"
70
80
  import {
71
81
  assertByteLimit,
82
+ clientBodyErrorCode,
83
+ hasTrustedBodyFraming,
72
84
  markTransportCap,
73
85
  markTrustedBodyFraming,
74
86
  type RawBodyReaders,
@@ -151,6 +163,7 @@ import {
151
163
  type StatusResponse,
152
164
  } from "./runtime-core.ts"
153
165
  import { normalizeStaticResponseHeaders, type StaticResponseHeaders } from "./static-headers.ts"
166
+ import { withTextSlots } from "./text-slots.ts"
154
167
  import { plainValidationError } from "./validation.ts"
155
168
 
156
169
  // NodeServeOutcome (the nifra<->node bridge render form) now lives in `./node-outcome.ts`; re-exported
@@ -184,6 +197,7 @@ import {
184
197
  NODE_NATIVE_MOUNT,
185
198
  REPORT_ERROR,
186
199
  RESOLVE_NODE_MOUNT,
200
+ SERVER_LIFECYCLE,
187
201
  } from "./install.ts"
188
202
  import type { EffectLedgerRuntime } from "./ledger-lane.ts"
189
203
  import { jsonLogger, type Logger } from "./logger.ts"
@@ -226,6 +240,7 @@ import type {
226
240
  RunningServer,
227
241
  ServerOptions,
228
242
  StopHook,
243
+ StopOptions,
229
244
  ToolAnnotations,
230
245
  } from "./server-types.ts"
231
246
  import type { SSEInit, TypedSSEStream } from "./sse.ts"
@@ -378,6 +393,8 @@ interface FetchMount<Env = unknown> {
378
393
  readonly fallbackOn404: boolean
379
394
  readonly beforeRoutes: boolean
380
395
  readonly order: number
396
+ /** The `authenticate()` stages registered before this mount, which gate it as they gate a route. */
397
+ readonly authStages: readonly RawAuthStage[]
381
398
  /** Why this child is not analyzed (`MountOptions.opaque`), checked by reflection. */
382
399
  readonly opaque: string | OpaqueMountDeclaration | undefined
383
400
  /** The mounted app (`mount()` only), for reflection to tell a composed child from an opaque one. */
@@ -563,6 +580,7 @@ export type {
563
580
  RunningServer,
564
581
  ServerOptions,
565
582
  StopHook,
583
+ StopOptions,
566
584
  ToolAnnotations,
567
585
  }
568
586
 
@@ -588,6 +606,8 @@ export {
588
606
  export type { IdentityPlugin }
589
607
 
590
608
  const DEFAULT_MAX_BODY_BYTES = 1_000_000
609
+ const BODY_RESPONSE = (response: Response | ResponseResult): Response | ResponseResult => response
610
+ const BODY_ERROR = (error: unknown): Promise<never> => Promise.reject(error)
591
611
  const DEFAULT_DRAIN_MS = 10_000
592
612
  const DRAIN_POLL_MS = 10
593
613
  const STOP_HOOK_TIMEOUT_MS = 5_000
@@ -637,18 +657,104 @@ function hasUnroutedParam(params: Record<string, string>): boolean {
637
657
  */
638
658
  const rawRequestTargets = typeof (globalThis as { Deno?: unknown }).Deno !== "undefined"
639
659
 
640
- /** `req` as Bun or workerd would have delivered it: the same request, its URL resolved as they parse it. */
641
- const withResolvedTarget = (req: Request): Request => new Request(resolveDotSegments(req.url), req)
660
+ /**
661
+ * Bun parses the target only when it can read the Host. Otherwise `Request.url` is the target as sent:
662
+ * relative for a Host with a space or a byte past ASCII or for no Host at all, the raw authority
663
+ * for a port past 65535, and `new URL(req.url)` throws in the app. So Bun is held to the Host check
664
+ * Deno runs.
665
+ */
666
+ const unparsedBunTargets = typeof Bun !== "undefined"
667
+
668
+ /**
669
+ * `req` as Bun or workerd would have delivered it: the same request, its URL resolved as they parse it.
670
+ * `undefined` when the `Request` constructor refuses what the wire carried - a TRACE, CONNECT or TRACK,
671
+ * a lower-case `get` with a body - which {@link resolvedSource} routes instead.
672
+ */
673
+ function withResolvedTarget(req: Request, url: string): Request | undefined {
674
+ let rebuilt: Request
675
+ try {
676
+ rebuilt = new Request(url, req)
677
+ } catch {
678
+ return undefined
679
+ }
680
+ // The same body, so the same framing: a runtime still delimited it.
681
+ if (hasTrustedBodyFraming(req)) markTrustedBodyFraming(rebuilt)
682
+ return rebuilt
683
+ }
684
+
685
+ /**
686
+ * A request no `Request` can be rebuilt from, as a source carrying its resolved URL: it is routed on that
687
+ * URL and gets the 404 or 405 the other runtimes give, where rebuilding it threw out of `fetch`. A
688
+ * handler that reads `c.req` gets the request as Deno delivered it.
689
+ */
690
+ function resolvedSource(req: Request, url: string): RequestSource {
691
+ const source: RequestSource = {
692
+ method: req.method,
693
+ url,
694
+ get headers() {
695
+ return req.headers
696
+ },
697
+ header: (name) => headerOf(req, name),
698
+ get body() {
699
+ return req.body
700
+ },
701
+ arrayBuffer: () => req.arrayBuffer(),
702
+ json: () => req.json(),
703
+ bytes: () => req.bytes(),
704
+ request: req,
705
+ }
706
+ if (hasTrustedBodyFraming(req)) markTrustedBodyFraming(source)
707
+ return source
708
+ }
642
709
 
643
710
  // A Host as RFC 9110 allows it: a reg-name or an IP literal, then an optional port. Deno builds
644
711
  // `Request.url` by appending the target to the Host as received, so `Host: x/admin#` turned a
645
712
  // request for `/public` into one for `/admin`. Node refuses such a Host before routing; so does this.
646
- const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::\d{1,5})?$/i
713
+ const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::(\d{1,5}))?$/i
714
+
715
+ // Clients repeat one Host on every request; comparing to the last valid one is ~7x cheaper than the regex.
716
+ let lastValidHost = ""
717
+
718
+ // A port past 65535, or an IPv6 literal no URL parser takes (`[1:2]`), is refused as Node refuses it:
719
+ // no URL can be built on either, so resolving a dot segment against one threw out of `fetch`.
720
+ const invalidHost = (host: string): boolean => {
721
+ if (host === lastValidHost) return false
722
+ const authority = HOST_AUTHORITY.exec(host)
723
+ if (authority === null) return true
724
+ if (authority[1] !== undefined && Number(authority[1]) > 65_535) return true
725
+ if (host.charCodeAt(0) === 91 /* [ */ && !URL.canParse(`http://${host}/`)) return true
726
+ lastValidHost = host
727
+ return false
728
+ }
647
729
 
648
- // `headerOf`, not `req.headers`: on Deno that reads the Host alone instead of building every header.
649
- const invalidHost = (req: Request): boolean => {
730
+ /**
731
+ * What a request is routed on in place of `req.url`, before anything reads it: `true` refuses its
732
+ * Host (400), a URL replaces the target, `false` keeps it. Deno hands the target over as sent, so
733
+ * its dot segments are resolved; so does Bun when HTTP/1.0 leaves the Host out, and its route table
734
+ * matched `/files/..` raw. Such a request is served as `localhost`, as Node and Deno serve it.
735
+ * `headerOf`, not `req.headers`: on Deno that reads the Host alone instead of building every
736
+ * header.
737
+ */
738
+ const requestTarget = (req: Request): string | boolean => {
739
+ if (!rawRequestTargets && !unparsedBunTargets) return false
650
740
  const host = headerOf(req, "host")
651
- return host !== null && !HOST_AUTHORITY.test(host)
741
+ if (host !== null && invalidHost(host)) {
742
+ if (rawRequestTargets) return true
743
+ // Bun served it anyway: it could not read the Host, so it left the target unparsed, or it
744
+ // built the URL on it - sent twice (joined with ", ", the URL on the first; RFC 9112 section
745
+ // 3.2 answers 400), percent-encoded (`%61.example` routed as `a.example`), or with a
746
+ // sub-delimiter (`a!b`). A request built in process keeps the URL its caller built, unless Bun
747
+ // would have built it so.
748
+ const routed = URL.parse(req.url)?.host
749
+ return routed === undefined || URL.parse(`//${host.split(", ")[0]}`, req.url)?.host === routed
750
+ }
751
+ if (rawRequestTargets) return needsResolving(req.url) && resolveDotSegments(req.url)
752
+ // A request built in process carries an absolute URL whatever its headers say.
753
+ return (
754
+ host === null &&
755
+ req.url.charCodeAt(0) === 47 /* / */ &&
756
+ resolveDotSegments(`http://localhost${req.url}`)
757
+ )
652
758
  }
653
759
 
654
760
  function normalizeMountPrefix(path: string): string {
@@ -681,12 +787,15 @@ function compareMounts<Env>(a: FetchMount<Env>, b: FetchMount<Env>): number {
681
787
  return b.priority - a.priority || b.path.length - a.path.length || a.order - b.order
682
788
  }
683
789
 
790
+ /** Rebase a mounted request's path without losing the authentication mark used by child caches. */
684
791
  function stripMountPrefix(request: Request, prefix: string): Request {
685
792
  if (prefix === "/") return request
686
793
  const url = new URL(request.url)
687
794
  const rest = url.pathname.slice(prefix.length)
688
795
  url.pathname = rest === "" ? "/" : rest
689
- return new Request(url.href, request)
796
+ const childRequest = new Request(url.href, request)
797
+ if (isAuthenticatedRequest(request)) markAuthenticatedRequest(childRequest)
798
+ return childRequest
690
799
  }
691
800
 
692
801
  /** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
@@ -881,8 +990,9 @@ interface HookAuditState {
881
990
  readonly hasCustomLogger: boolean
882
991
  readonly sites: {
883
992
  readonly kind: string
884
- /** Routes that captured the chain so far: merged routes excluded, they keep their own. */
885
- readonly routesAtPush: number
993
+ /** Routes that captured the chain so far: merged routes excluded, they keep their own. -1 once a
994
+ * mount captured it, which no route count matches. */
995
+ routesAtPush: number
886
996
  readonly mergedAtPush: number
887
997
  readonly site: string | undefined
888
998
  }[]
@@ -964,6 +1074,19 @@ const recordScopedHook = (key: object, routesAtPush: number, kind: string): void
964
1074
  })
965
1075
  }
966
1076
 
1077
+ /** `next(value)` now, or once `value` settles. */
1078
+ const thenMaybe = <T, U>(
1079
+ value: MaybePromise<T>,
1080
+ next: (value: T) => MaybePromise<U>,
1081
+ ): MaybePromise<U> => (value instanceof Promise ? value.then(next) : next(value))
1082
+
1083
+ /** A mount registered after an `authenticate()` stage runs it, so the stage covers something. */
1084
+ const noteAuthenticatedMount = (key: object): void => {
1085
+ const state = hookAudit.get(key)
1086
+ if (state === undefined) return
1087
+ for (const site of state.sites) if (site.kind === "authenticate") site.routesAtPush = -1
1088
+ }
1089
+
967
1090
  /** Count routes a `merge()` adopted: they never run this server's order-scoped hooks, so a hook
968
1091
  * followed only by merges covers nothing. */
969
1092
  const noteMergedRoutes = (key: object, count: number): void => {
@@ -1158,8 +1281,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1158
1281
  private readonly acceptInboundDeadlines: boolean
1159
1282
  private readonly maxInboundDeadlineMs: number
1160
1283
  private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
1161
- private readonly gracefulSignals: { readonly drainMs?: number } | undefined
1284
+ private readonly gracefulSignals: StopOptions | undefined
1162
1285
  private readonly stopHooks: StopHook[]
1286
+ private readonly stoppingHooks: StopHook[]
1163
1287
  private readonly fetchMounts: FetchMount<EnvOf<Ctx>>[]
1164
1288
  private mountOrder: number
1165
1289
  /** Number of composed mounts that must run before the parent route table. Keeps legacy mounts off the hot path. */
@@ -1242,15 +1366,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1242
1366
  /** Body/raw response hooks need a framework-payload marker; keep that decision per app. */
1243
1367
  private responseBodyTag: object | undefined
1244
1368
  private readonly responseBodyOwners: Set<object>
1369
+ // General lifecycles need no extra response bookkeeping when neither controls nor observers
1370
+ // use it. Share the fused renderer after the lifecycle, including on body/write routes.
1245
1371
  private readonly finalizeResponse = (result: unknown, set: CtxSet, ctx?: RawContext): Response =>
1246
- toResponse(
1247
- result as HandlerResult,
1248
- set,
1249
- this.responseBodyTag,
1250
- this.staticResponseHeaders,
1251
- // Response hooks write next, and they run for this same request object.
1252
- ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined,
1253
- )
1372
+ set === EMPTY_RESPONSE_CONTROLS && this.onResponseHooks.length === 0
1373
+ ? fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders, true)
1374
+ : toResponse(
1375
+ result as HandlerResult,
1376
+ set,
1377
+ this.responseBodyTag,
1378
+ this.staticResponseHeaders,
1379
+ // Response hooks write next, and they run for this same request object.
1380
+ ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined,
1381
+ this.onResponseHooks.length === 0,
1382
+ )
1254
1383
  private readonly responseRequests: WeakMap<Request, Request>
1255
1384
  /** A request the WebSocket handshake check ran the onRequest hooks on and then passed to HTTP →
1256
1385
  * what the hooks returned, so `fetch` routes that instead of running the hooks a second time. */
@@ -1318,6 +1447,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1318
1447
  this.gracefulSignals =
1319
1448
  options.gracefulSignals === true ? {} : options.gracefulSignals || undefined
1320
1449
  this.stopHooks = []
1450
+ this.stoppingHooks = []
1321
1451
  this.fetchMounts = []
1322
1452
  this.mountOrder = 0
1323
1453
  this.preRouteMountCount = 0
@@ -1393,10 +1523,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1393
1523
  return this
1394
1524
  }
1395
1525
 
1526
+ /**
1527
+ * Register a callback awaited as soon as `stop()` begins, while the server still serves: readiness
1528
+ * should fail from here, and work that outlives a request (a poller, a queue consumer) should stop
1529
+ * taking more. Hung callbacks are bounded.
1530
+ */
1531
+ onStopping(fn: StopHook): this {
1532
+ this.assertConfigurable("onStopping()")
1533
+ if (typeof fn !== "function") throw new TypeError("onStopping callback must be a function")
1534
+ this.stoppingHooks.push(fn)
1535
+ return this
1536
+ }
1537
+
1396
1538
  /**
1397
1539
  * Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
1398
1540
  * routes, so routes can move one at a time while the remaining legacy surface stays live.
1399
- * Mounted responses are outside Nifra's typed route and response-contract checks.
1541
+ * Mounted responses are outside Nifra's typed route and response-contract checks. An
1542
+ * `authenticate()` stage registered before the mount, or a required auth plugin built on one
1543
+ * (`bearer()`, `jwt()`, `basicAuth()`, `apiKey()`), refuses a request before the handler sees it;
1544
+ * mount before the stage to keep the legacy surface public.
1400
1545
  *
1401
1546
  * It carries HTTP only: a WebSocket upgrade under the prefix reaches the handler as an ordinary
1402
1547
  * request and never becomes a socket. Serve that path's sockets with `app.ws()` routes or a
@@ -1418,7 +1563,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1418
1563
  beforeRoutes: false,
1419
1564
  order: this.mountOrder++,
1420
1565
  opaque: options.opaque,
1566
+ authStages: [...this.authStages],
1421
1567
  }
1568
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production" && this.authStages.length > 0)
1569
+ noteAuthenticatedMount(this)
1422
1570
  this.fetchMounts.push(mount)
1423
1571
  this.fetchMounts.sort(compareMounts)
1424
1572
  return this
@@ -1474,7 +1622,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1474
1622
  order: this.mountOrder++,
1475
1623
  opaque: options.opaque,
1476
1624
  app,
1625
+ authStages: [...this.authStages],
1477
1626
  }
1627
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production" && this.authStages.length > 0)
1628
+ noteAuthenticatedMount(this)
1478
1629
  // Mounts run before routes: a request under this prefix that no mount on the way strips comes back
1479
1630
  // here unchanged until the stack overflows, which takes the process down. Dev-time only, like the
1480
1631
  // hook audit: a production define folds this away, and the first dev request would hit it anyway.
@@ -1533,7 +1684,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1533
1684
  * (see {@link ServerOptions.unusedScopedHooks}).
1534
1685
  */
1535
1686
  /**
1536
- * Register a dedicated authentication stage for subsequent routes.
1687
+ * Register a dedicated authentication stage for subsequent routes, and for the `mount()` and
1688
+ * `mountFetch()` handlers registered after it: a request it refuses never reaches them.
1537
1689
  *
1538
1690
  * Unlike `derive`, this stage receives a body-blind authentication capsule and is compiled before
1539
1691
  * route validation. Keeping it separate prevents an unrelated derive from accidentally becoming a
@@ -1603,7 +1755,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1603
1755
  fn: H,
1604
1756
  ): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
1605
1757
  this.assertConfigurable("beforeHandle()")
1606
- this.beforeHandleHooks.push(fn as unknown as RawBeforeHandle)
1758
+ const hooks = this.beforeHandleHooks
1759
+ let at = hooks.length
1760
+ if (!LAST_BEFORE_HANDLE.has(fn)) {
1761
+ while (at > 0 && LAST_BEFORE_HANDLE.has(hooks[at - 1]!)) at--
1762
+ }
1763
+ hooks.splice(at, 0, fn as unknown as RawBeforeHandle)
1607
1764
  if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
1608
1765
  recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "beforeHandle")
1609
1766
  tagHook(fn, "beforeHandle", this)
@@ -2440,6 +2597,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2440
2597
  schema: RouteSchema | undefined,
2441
2598
  handler: (context: never) => unknown,
2442
2599
  ): CatalogRoute {
2600
+ schema = withTextSlots(schema)
2443
2601
  // Route provenance for the CLI's capability walk, which sets NIFRA_ROUTE_SITES while it loads the
2444
2602
  // app: the module that registered the route, read where a static scan cannot see the path.
2445
2603
  if (
@@ -2487,6 +2645,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2487
2645
  lanes,
2488
2646
  routeAssurance,
2489
2647
  } = compiled
2648
+ // A schema that writes its answer its own way (`t.bytes()`) wraps every stage that can answer.
2649
+ const stages = withAnswerSteps(schema, method, path, {
2650
+ handler,
2651
+ beforeHandle: this.beforeHandleHooks,
2652
+ afterHandle: this.afterHandleHooks,
2653
+ onError: this.onErrorHooks,
2654
+ })
2655
+ handler = stages.handler
2656
+ const { beforeHandle, afterHandle, onError } = stages
2490
2657
  if (idempotent !== undefined && this.idempotencyRuntime !== undefined) {
2491
2658
  handler = this.idempotencyRuntime.wrapHandler(handler)
2492
2659
  }
@@ -2514,8 +2681,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2514
2681
  ? this.buildFusedBodyDeriveBeforeAfterRunner(
2515
2682
  handler as unknown as InternalHandler,
2516
2683
  this.derives[0]!,
2517
- this.beforeHandleHooks[0]!,
2518
- this.afterHandleHooks[0],
2684
+ beforeHandle[0]!,
2685
+ afterHandle[0],
2519
2686
  schema?.body as StandardSchemaV1,
2520
2687
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2521
2688
  )
@@ -2523,7 +2690,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2523
2690
  ? this.buildFusedDeriveBeforeRunner(
2524
2691
  handler as unknown as InternalHandler,
2525
2692
  this.derives[0]!,
2526
- this.beforeHandleHooks[0]!,
2693
+ beforeHandle[0]!,
2527
2694
  undefined,
2528
2695
  schema?.query,
2529
2696
  bodyLimit ?? UNLIMITED_BODY_BYTES,
@@ -2532,8 +2699,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2532
2699
  ? this.buildFusedDeriveBeforeRunner(
2533
2700
  handler as unknown as InternalHandler,
2534
2701
  this.derives[0]!,
2535
- this.beforeHandleHooks[0]!,
2536
- this.afterHandleHooks[0],
2702
+ beforeHandle[0]!,
2703
+ afterHandle[0],
2537
2704
  schema?.query,
2538
2705
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2539
2706
  )
@@ -2559,8 +2726,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2559
2726
  ? this.buildFusedBodyDeriveBeforeAfter(
2560
2727
  handler as unknown as InternalHandler,
2561
2728
  this.derives[0]!,
2562
- this.beforeHandleHooks[0]!,
2563
- this.afterHandleHooks[0],
2729
+ beforeHandle[0]!,
2730
+ afterHandle[0],
2564
2731
  schema?.body as StandardSchemaV1,
2565
2732
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2566
2733
  )
@@ -2568,7 +2735,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2568
2735
  ? this.buildFusedDeriveBefore(
2569
2736
  handler as unknown as InternalHandler,
2570
2737
  this.derives[0]!,
2571
- this.beforeHandleHooks[0]!,
2738
+ beforeHandle[0]!,
2572
2739
  schema?.query,
2573
2740
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2574
2741
  )
@@ -2576,8 +2743,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2576
2743
  ? this.buildFusedDeriveBeforeAfter(
2577
2744
  handler as unknown as InternalHandler,
2578
2745
  this.derives[0]!,
2579
- this.beforeHandleHooks[0]!,
2580
- this.afterHandleHooks[0]!,
2746
+ beforeHandle[0]!,
2747
+ afterHandle[0]!,
2581
2748
  schema?.query,
2582
2749
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2583
2750
  )
@@ -2587,9 +2754,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2587
2754
  handler: handler as unknown as InternalHandler,
2588
2755
  derives: this.derives,
2589
2756
  authStages: compiled.authStages,
2590
- beforeHandle: this.beforeHandleHooks,
2591
- afterHandle: this.afterHandleHooks,
2592
- onError: this.onErrorHooks,
2757
+ beforeHandle,
2758
+ afterHandle,
2759
+ onError,
2593
2760
  decorations: routeDecorations,
2594
2761
  hasDecorations,
2595
2762
  bodySchema: schema?.body,
@@ -2618,9 +2785,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2618
2785
  authStages: [...compiled.authStages],
2619
2786
  decorations: routeDecorations,
2620
2787
  hasDecorations,
2621
- beforeHandle: [...this.beforeHandleHooks],
2622
- afterHandle: [...this.afterHandleHooks],
2623
- onError: [...this.onErrorHooks],
2788
+ beforeHandle: [...beforeHandle],
2789
+ afterHandle: [...afterHandle],
2790
+ onError: [...onError],
2624
2791
  lifecycleHookLane: lanes.lifecycleHookLane,
2625
2792
  around: [...this.aroundHooks],
2626
2793
  execution,
@@ -3060,6 +3227,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3060
3227
  // Cleanup the source registered (a pool, a queue consumer) belongs to the server that now runs
3061
3228
  // its routes; dropping it would leak the resource past `stop()`.
3062
3229
  this.stopHooks.push(...source.stopHooks)
3230
+ this.stoppingHooks.push(...source.stoppingHooks)
3063
3231
  this.mcpResourceList.push(...source.mcpResourceList)
3064
3232
  this.mcpPromptList.push(...source.mcpPromptList)
3065
3233
  }
@@ -3223,9 +3391,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3223
3391
  fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
3224
3392
  // Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
3225
3393
  // Route what the parsed URL says, so `/users/../admin` is `/admin` on every runtime.
3226
- if (rawRequestTargets) {
3227
- if (invalidHost(req)) return jsonError(400, "invalid_host")
3228
- if (needsResolving(req.url)) req = withResolvedTarget(req)
3394
+ const target = requestTarget(req)
3395
+ if (target === true) return jsonError(400, "invalid_host")
3396
+ if (target !== false) {
3397
+ const rebuilt = withResolvedTarget(req, target)
3398
+ if (rebuilt === undefined) return this.fetchSource(resolvedSource(req, target), platform)
3399
+ req = rebuilt
3229
3400
  }
3230
3401
  // An edge deployment whose only ingress is this method declares its requests runtime-framed at
3231
3402
  // construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
@@ -3387,7 +3558,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3387
3558
  // whatever their prefix lengths. A 404 fallthrough chain stays on the ordinary path.
3388
3559
  const mount =
3389
3560
  this.firstMountUnder(parts.pathname, true) ?? this.firstMountUnder(parts.pathname, false)
3390
- if (mount === undefined || mount.fallbackOn404) return undefined
3561
+ if (mount === undefined || mount.fallbackOn404 || mount.authStages.length > 0) return undefined
3391
3562
  const candidate = (mount.handler as unknown as Record<symbol, unknown>)[NODE_NATIVE_MOUNT]
3392
3563
  return typeof candidate === "function"
3393
3564
  ? {
@@ -3724,11 +3895,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3724
3895
  // The adapter answers a pass by handing this same object to `fetch`.
3725
3896
  const received = req
3726
3897
  // The handshake routes the path `fetch` would: Host checked, dot segments resolved (see `fetch`).
3727
- if (rawRequestTargets) {
3728
- if (invalidHost(req)) {
3729
- return { kind: "reject", response: this.wrapWebResponse(jsonError(400, "invalid_host")) }
3730
- }
3731
- if (needsResolving(req.url)) req = withResolvedTarget(req)
3898
+ const target = requestTarget(req)
3899
+ if (target === true) {
3900
+ return { kind: "reject", response: this.wrapWebResponse(jsonError(400, "invalid_host")) }
3901
+ }
3902
+ if (target !== false) {
3903
+ // A GET carrying a body cannot be rebuilt; `fetch` routes it as the ordinary request it is.
3904
+ const rebuilt = withResolvedTarget(req, target)
3905
+ if (rebuilt === undefined) return WS_PASS
3906
+ req = rebuilt
3732
3907
  }
3733
3908
  // The handshake's caller is the one `fetch` derives: hooks and `upgrade()` see the trusted IP.
3734
3909
  platform = this.trustedPlatform(req, platform)
@@ -3844,17 +4019,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3844
4019
  }
3845
4020
  }
3846
4021
  if (selected === undefined) return WS_PASS
3847
- let childRequest = request
3848
- if (selected.stripPrefix) {
3849
- childRequest = stripMountPrefix(request, selected.path)
3850
- selected.wsPassed ??= new WeakMap()
3851
- selected.wsPassed.set(request, childRequest)
3852
- }
3853
- let child: MaybePromise<WebSocketUpgradeOutcome>
3854
- try {
3855
- child = selected.resolveWebSocketUpgrade!(childRequest, platform)
3856
- } catch {
3857
- return refuse(jsonError(500, "internal_error"))
4022
+ const mount = selected
4023
+ const upgrade = (): MaybePromise<WebSocketUpgradeOutcome> => {
4024
+ let childRequest = request
4025
+ if (mount.stripPrefix) {
4026
+ childRequest = stripMountPrefix(request, mount.path)
4027
+ mount.wsPassed ??= new WeakMap()
4028
+ mount.wsPassed.set(request, childRequest)
4029
+ }
4030
+ try {
4031
+ return mount.resolveWebSocketUpgrade!(childRequest, platform)
4032
+ } catch {
4033
+ return refuse(jsonError(500, "internal_error"))
4034
+ }
3858
4035
  }
3859
4036
  const settle = (outcome: WebSocketUpgradeOutcome): MaybePromise<WebSocketUpgradeOutcome> =>
3860
4037
  outcome.kind === "pass" ||
@@ -3863,7 +4040,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3863
4040
  : outcome.kind === "reject"
3864
4041
  ? refuse(outcome.response)
3865
4042
  : outcome
3866
- return child instanceof Promise ? child.then(settle) : settle(child)
4043
+ const run = (): MaybePromise<WebSocketUpgradeOutcome> => {
4044
+ const child = upgrade()
4045
+ return child instanceof Promise ? child.then(settle) : settle(child)
4046
+ }
4047
+ // Only a child's miss may fall through, never an authentication refusal.
4048
+ return mount.authStages.length === 0
4049
+ ? run()
4050
+ : thenMaybe(
4051
+ authenticateMount(mount.authStages, request, platform, this.logger),
4052
+ (refusal) => (refusal === undefined ? run() : refuse(refusal)),
4053
+ )
3867
4054
  }
3868
4055
  return dispatch(0)
3869
4056
  }
@@ -4252,8 +4439,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4252
4439
  onTimeout: () => T,
4253
4440
  webFast: boolean,
4254
4441
  ): MaybePromise<T> {
4255
- // Routing only needs the pathname. Scan the URL once but keep the two slices in locals instead
4256
- // of allocating the `{ pathname, search }` pair returned by the public helper on every request.
4442
+ // Native delimiter searches keep path/query splitting out of a character-by-character JS loop.
4443
+ // Keep the slices in locals instead of allocating the public helper's pathname/search pair.
4257
4444
  let pathname: string
4258
4445
  let search: string
4259
4446
  // Read the source's split ONCE. `urlParts` is an accessor on the Node request sources that
@@ -4272,20 +4459,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4272
4459
  pathname = "/"
4273
4460
  search = ""
4274
4461
  } else {
4275
- let pathEnd = rawUrl.length
4276
- let searchStart = -1
4277
- let searchEnd = rawUrl.length
4278
- for (let i = start; i < rawUrl.length; i++) {
4279
- const c = rawUrl.charCodeAt(i)
4280
- if (c === 63 /* ? */ && searchStart === -1) {
4281
- pathEnd = i
4282
- searchStart = i
4283
- } else if (c === 35 /* # */) {
4284
- if (searchStart === -1) pathEnd = i
4285
- searchEnd = i
4286
- break
4287
- }
4288
- }
4462
+ const fragment = rawUrl.indexOf("#", start)
4463
+ const searchEnd = fragment === -1 ? rawUrl.length : fragment
4464
+ const question = rawUrl.indexOf("?", start)
4465
+ const searchStart = question !== -1 && question < searchEnd ? question : -1
4466
+ const pathEnd = searchStart === -1 ? searchEnd : searchStart
4289
4467
  pathname = rawUrl.slice(start, pathEnd)
4290
4468
  search = searchStart === -1 ? "" : rawUrl.slice(searchStart, searchEnd)
4291
4469
  }
@@ -4377,17 +4555,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4377
4555
  }
4378
4556
  }
4379
4557
  if (mount === undefined) return fallback ?? jsonError(404, "not_found")
4380
- const childRequest = mount.stripPrefix
4381
- ? (mount.wsPassed?.get(request) ?? stripMountPrefix(request, mount.path))
4382
- : request
4383
- const outcome = mount.handler(childRequest, platform)
4384
- if (!replayable || !mount.fallbackOn404) return outcome
4385
- if (outcome instanceof Promise) {
4386
- return outcome.then((response) =>
4387
- response.status === 404 ? dispatch(index + 1, response) : response,
4388
- )
4558
+ const selected = mount
4559
+ const run = (): MaybePromise<Response> => {
4560
+ const reused = selected.stripPrefix ? selected.wsPassed?.get(request) : undefined
4561
+ const childRequest = selected.stripPrefix
4562
+ ? (reused ?? stripMountPrefix(request, selected.path))
4563
+ : request
4564
+ // HTTP authentication may have marked the original since the WebSocket pass.
4565
+ if (reused !== undefined && isAuthenticatedRequest(request)) {
4566
+ markAuthenticatedRequest(reused)
4567
+ }
4568
+ const outcome = selected.handler(childRequest, platform)
4569
+ if (!replayable || !selected.fallbackOn404) return outcome
4570
+ if (outcome instanceof Promise) {
4571
+ return outcome.then((response) =>
4572
+ response.status === 404 ? dispatch(index + 1, response) : response,
4573
+ )
4574
+ }
4575
+ return outcome.status === 404 ? dispatch(index + 1, outcome) : outcome
4389
4576
  }
4390
- return outcome.status === 404 ? dispatch(index + 1, outcome) : outcome
4577
+ // Keep gate refusals outside the child's 404 fallthrough path.
4578
+ return mount.authStages.length === 0
4579
+ ? run()
4580
+ : thenMaybe(
4581
+ authenticateMount(mount.authStages, request, platform, this.logger),
4582
+ (refusal) => refusal ?? run(),
4583
+ )
4391
4584
  }
4392
4585
  return dispatch(first)
4393
4586
  }
@@ -5857,7 +6050,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5857
6050
  finalize,
5858
6051
  responseSet,
5859
6052
  (e, c) => this.logRequestError(e, c),
5860
- () => this.internalErrorResponse(wrapResponse),
6053
+ wrapResponse,
5861
6054
  )
5862
6055
  }
5863
6056
 
@@ -5993,6 +6186,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5993
6186
  wrapResponse,
5994
6187
  (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
5995
6188
  entry.schema?.body,
6189
+ this.formOrigins,
5996
6190
  )
5997
6191
  }
5998
6192
 
@@ -6365,7 +6559,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6365
6559
  const checked = contract.runtime.check(contract.definition, result, ctx)
6366
6560
  if (checked instanceof Promise) {
6367
6561
  return checked.then(
6368
- (outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome),
6562
+ (outcome) => {
6563
+ // The render runs outside the try below: a throw (a BigInt, a cycle) must still reach onError.
6564
+ try {
6565
+ return this.finishContractOutcome(ctx, finalize, wrapResponse, outcome)
6566
+ } catch (err) {
6567
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
6568
+ }
6569
+ },
6369
6570
  (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
6370
6571
  )
6371
6572
  }
@@ -6428,6 +6629,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6428
6629
  if (err instanceof Response || isResponseResult(err))
6429
6630
  return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, err)
6430
6631
  if (entry.onError.length === 0) {
6632
+ const clientCode = clientBodyErrorCode(err)
6633
+ if (clientCode !== undefined) return wrapResponse(plainError(400, clientCode))
6431
6634
  // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
6432
6635
  // (redacting) logger. Body-read failures and around-hook failures land here too.
6433
6636
  this.logRequestError(err, ctx)
@@ -6516,6 +6719,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6516
6719
  return wrapResponse(plainError(500, "internal_error"))
6517
6720
  }
6518
6721
  }
6722
+ const clientCode = clientBodyErrorCode(err)
6723
+ if (clientCode !== undefined) return wrapResponse(plainError(400, clientCode))
6519
6724
  this.logRequestError(err, ctx)
6520
6725
  return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback()
6521
6726
  }
@@ -6538,18 +6743,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6538
6743
  // second continuation, while retaining the exact bounded-read, content-type, and poisoning
6539
6744
  // contracts shared by every body route.
6540
6745
  const validate = (parsed: unknown): MaybePromise<Response | ResponseResult | undefined> => {
6541
- const validation = bodySchema["~standard"].validate(parsed)
6542
- return validation instanceof Promise
6543
- ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, "body"))
6544
- : this.applyLifecycleValidation(entry, validation, ctx, "body")
6746
+ // Reject outside the parser's JSON catch so a custom validator throw reaches onError.
6747
+ try {
6748
+ const validation = bodySchema["~standard"].validate(parsed)
6749
+ return validation instanceof Promise
6750
+ ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, "body"))
6751
+ : this.applyLifecycleValidation(entry, validation, ctx, "body")
6752
+ } catch (error) {
6753
+ return Promise.reject(error)
6754
+ }
6545
6755
  }
6546
6756
  return readBodyFramed(
6547
6757
  req,
6548
6758
  bodyLimit ?? UNLIMITED_BODY_BYTES,
6549
6759
  this.protoPoisoning,
6550
6760
  validate,
6551
- (response) => response,
6552
- (error) => Promise.reject(error),
6761
+ BODY_RESPONSE,
6762
+ BODY_ERROR,
6553
6763
  bodySchema,
6554
6764
  this.formOrigins,
6555
6765
  )
@@ -6563,15 +6773,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6563
6773
  ctx: RawContext,
6564
6774
  kind: ProgramValidationKind,
6565
6775
  ): MaybePromise<Response | ResponseResult | undefined> {
6566
- const assign = (value: unknown): void => {
6567
- ;(ctx as unknown as Record<ProgramValidationKind, unknown>)[kind] = value
6568
- }
6569
6776
  if (result.issues === undefined) {
6570
- assign(result.value)
6777
+ // biome-ignore lint/plugin/noChainedTypeAssertions lint/plugin/requireSafetyCommentForTypeAssertion: RawContext carries every ProgramValidationKind slot; this instruction assigns its validated value before handlers run.
6778
+ ;(ctx as unknown as Record<ProgramValidationKind, unknown>)[kind] = result.value
6571
6779
  return undefined
6572
6780
  }
6573
6781
  const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
6574
6782
  if (hook === undefined) return plainValidationError(result.issues)
6783
+ const assign = (value: unknown): void => {
6784
+ ;(ctx as unknown as Record<ProgramValidationKind, unknown>)[kind] = value
6785
+ }
6575
6786
  const attempted = hook(result.issues, ctx as unknown as Context, kind)
6576
6787
  if (attempted instanceof Promise) {
6577
6788
  return attempted.then((recovery) =>
@@ -6618,14 +6829,34 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6618
6829
  // the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
6619
6830
  // the portable/adapted source contract (those sources are never marked).
6620
6831
  const markFramed = (request: Request): void => markTrustedBodyFraming(request)
6621
- // The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
6832
+ // The direct native lanes bypass `runMatched`, so the route's transport byte cap must be
6622
6833
  // marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
6623
- // reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
6834
+ // reads uncapped. The fallback branches go through `fetchMatched` -> `runMatched`, which marks.
6624
6835
  const bodyLimit =
6625
6836
  entry.schema?.body === undefined || entry.program.authBeforeValidation
6626
6837
  ? entry.bodyLimit
6627
6838
  : undefined
6628
- const inner = fused
6839
+ // The complete plan already preserves general validation, hooks, recovery, around, and ledgers.
6840
+ // When native admission is safe, use it directly even if the route has no closed-shape fusion.
6841
+ // Idempotency still needs runMatched's request preparation and is excluded from the table.
6842
+ const inner: FusedWebRunner | undefined =
6843
+ fused ??
6844
+ (signal && budget
6845
+ ? (source, params, search, activeSignal, activeBudget, platform, nativeContext) =>
6846
+ entry.execution.run(
6847
+ this,
6848
+ entry,
6849
+ source,
6850
+ params,
6851
+ search,
6852
+ activeSignal,
6853
+ activeBudget,
6854
+ platform,
6855
+ nativeContext,
6856
+ this.finalizeResponse,
6857
+ this.wrapWebResponse,
6858
+ )
6859
+ : undefined)
6629
6860
  const capped: FusedWebRunner | undefined =
6630
6861
  inner === undefined || bodyLimit === undefined
6631
6862
  ? inner
@@ -6808,7 +7039,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6808
7039
  }
6809
7040
  const paramNames = pattern.paramNames
6810
7041
  const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined
6811
- tableAt(path, pattern.segments)[method as Method] = this.compileBunNativeHandler(
7042
+ const handler = this.compileBunNativeHandler(
6812
7043
  entry,
6813
7044
  paramNames,
6814
7045
  fused,
@@ -6817,6 +7048,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6817
7048
  peer,
6818
7049
  fallback,
6819
7050
  )
7051
+ // The table matched the target as sent; one `fetch` refuses or rewrites goes through it.
7052
+ tableAt(path, pattern.segments)[method as Method] = (request, server) =>
7053
+ requestTarget(request) ? fallback(request, server) : handler(request, server)
6820
7054
  count += 1
6821
7055
  if (paramNames.length > 0) {
6822
7056
  below.add(pattern.segments.length + method)
@@ -7016,11 +7250,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7016
7250
  * The Bun semantics: poll `pendingRequests` (awaiting
7017
7251
  * `stop()`'s promise drops in-flight requests), and decide graceful-vs-forced in
7018
7252
  * ONE call (Bun can't escalate an already-graceful `stop()` to a forced close).
7019
- * New connections may be accepted during the drain window; in a real deploy the
7020
- * load balancer has already stopped routing here, and `drainMs` bounds it. Open WebSockets
7021
- * get a 1001 close at once and the same window to finish it, before any `onStop` hook runs.
7253
+ * New connections may be accepted during the drain window; `delayMs` is the time a load balancer
7254
+ * gets to stop routing here first. The `onStopping` hooks run before that delay, the `onStop`
7255
+ * hooks after the drain. Open WebSockets get a 1001 close when the drain starts and the same
7256
+ * window to finish it; a socket opened after `stop()` began is closed at once.
7022
7257
  */
7023
- async stop({ drainMs = DEFAULT_DRAIN_MS }: { drainMs?: number } = {}): Promise<void> {
7258
+ async stop({ drainMs = DEFAULT_DRAIN_MS, delayMs = 0 }: StopOptions = {}): Promise<void> {
7024
7259
  const onSignal = this.signalHandler
7025
7260
  if (onSignal !== undefined) {
7026
7261
  this.signalHandler = undefined
@@ -7029,6 +7264,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7029
7264
  const server = this.bunServer
7030
7265
  if (server === undefined) return
7031
7266
  this.bunServer = undefined
7267
+ await this[SERVER_LIFECYCLE]("stopping")
7268
+ if (delayMs > 0) await Bun.sleep(delayMs)
7032
7269
  const sockets = this.bunSockets
7033
7270
  this.bunSockets = undefined
7034
7271
  if (sockets !== undefined) for (const ws of sockets) ws.close(1001)
@@ -7038,16 +7275,28 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7038
7275
  await Bun.sleep(DRAIN_POLL_MS)
7039
7276
  }
7040
7277
  server.stop(busy()) // force-close iff stragglers remain past the deadline
7041
- if (this.stopHooks.length === 0) return
7042
- const callbacks = this.stopHooks.map((hook) => Promise.resolve().then(hook))
7043
- const settled = Promise.allSettled(callbacks)
7278
+ await this[SERVER_LIFECYCLE]("stopped")
7279
+ }
7280
+
7281
+ /**
7282
+ * @internal Run the `onStopping` or the `onStop` hooks, each bounded. A runtime adapter that drains
7283
+ * its own listener calls it on either side of the drain, as `stop()` does here.
7284
+ */
7285
+ async [SERVER_LIFECYCLE](phase: "stopping" | "stopped"): Promise<void> {
7286
+ const hooks = phase === "stopping" ? this.stoppingHooks : this.stopHooks
7287
+ if (hooks.length === 0) return
7288
+ let timer: ReturnType<typeof setTimeout> | undefined
7044
7289
  await Promise.race([
7045
- settled,
7046
- new Promise<void>((resolve) => setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)),
7290
+ Promise.allSettled(hooks.map((hook) => Promise.resolve().then(hook))),
7291
+ new Promise<void>((resolve) => {
7292
+ timer = setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)
7293
+ }),
7047
7294
  ])
7295
+ // A pending bound would hold the event loop open after every hook settled.
7296
+ clearTimeout(timer)
7048
7297
  }
7049
7298
 
7050
- private installSignalHandlers(drain: { readonly drainMs?: number }): void {
7299
+ private installSignalHandlers(drain: StopOptions): void {
7051
7300
  // Drain, then let the process exit naturally - the stopped server no longer
7052
7301
  // holds the event loop open. Opt-in (`gracefulSignals`), so taking over the
7053
7302
  // signals is consented; we don't force `process.exit`.