@nifrajs/core 5.5.0 → 5.6.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 (138) hide show
  1. package/CHANGELOG.md +159 -0
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js.map +1 -1
  4. package/dist/evidence.d.ts +2 -0
  5. package/dist/evidence.d.ts.map +1 -1
  6. package/dist/evidence.js +2 -0
  7. package/dist/evidence.js.map +1 -1
  8. package/dist/file-type.d.ts +30 -0
  9. package/dist/file-type.d.ts.map +1 -0
  10. package/dist/file-type.js +118 -0
  11. package/dist/file-type.js.map +1 -0
  12. package/dist/index.d.ts +2 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/internal/route-program.d.ts +11 -0
  17. package/dist/internal/route-program.d.ts.map +1 -1
  18. package/dist/internal/route-program.js +133 -85
  19. package/dist/internal/route-program.js.map +1 -1
  20. package/dist/reflection.d.ts +14 -1
  21. package/dist/reflection.d.ts.map +1 -1
  22. package/dist/reflection.js +34 -7
  23. package/dist/reflection.js.map +1 -1
  24. package/dist/response-observer.d.ts +1 -0
  25. package/dist/response-observer.d.ts.map +1 -1
  26. package/dist/response-observer.js +1 -0
  27. package/dist/response-observer.js.map +1 -1
  28. package/dist/server/bare-error-lane.d.ts +4 -1
  29. package/dist/server/bare-error-lane.d.ts.map +1 -1
  30. package/dist/server/bare-error-lane.js +9 -3
  31. package/dist/server/bare-error-lane.js.map +1 -1
  32. package/dist/server/before-handler.d.ts +10 -0
  33. package/dist/server/before-handler.d.ts.map +1 -0
  34. package/dist/server/before-handler.js +12 -0
  35. package/dist/server/before-handler.js.map +1 -0
  36. package/dist/server/body-lane.d.ts.map +1 -1
  37. package/dist/server/body-lane.js +8 -2
  38. package/dist/server/body-lane.js.map +1 -1
  39. package/dist/server/body-parser.d.ts.map +1 -1
  40. package/dist/server/body-parser.js +2 -5
  41. package/dist/server/body-parser.js.map +1 -1
  42. package/dist/server/body.d.ts +24 -8
  43. package/dist/server/body.d.ts.map +1 -1
  44. package/dist/server/body.js +86 -33
  45. package/dist/server/body.js.map +1 -1
  46. package/dist/server/core-copies.js +1 -1
  47. package/dist/server/http.d.ts.map +1 -1
  48. package/dist/server/http.js +6 -17
  49. package/dist/server/http.js.map +1 -1
  50. package/dist/server/multipart.d.ts +3 -0
  51. package/dist/server/multipart.d.ts.map +1 -1
  52. package/dist/server/multipart.js +226 -97
  53. package/dist/server/multipart.js.map +1 -1
  54. package/dist/server/proto-guard.d.ts.map +1 -1
  55. package/dist/server/proto-guard.js +39 -4
  56. package/dist/server/proto-guard.js.map +1 -1
  57. package/dist/server/query.d.ts.map +1 -1
  58. package/dist/server/query.js +8 -14
  59. package/dist/server/query.js.map +1 -1
  60. package/dist/server/repeated-prefix.d.ts +17 -0
  61. package/dist/server/repeated-prefix.d.ts.map +1 -0
  62. package/dist/server/repeated-prefix.js +17 -0
  63. package/dist/server/repeated-prefix.js.map +1 -0
  64. package/dist/server/request-context.d.ts.map +1 -1
  65. package/dist/server/request-context.js +1 -3
  66. package/dist/server/request-context.js.map +1 -1
  67. package/dist/server/respond.d.ts +1 -3
  68. package/dist/server/respond.d.ts.map +1 -1
  69. package/dist/server/respond.js +21 -16
  70. package/dist/server/respond.js.map +1 -1
  71. package/dist/server/response-contract-lane.d.ts +12 -2
  72. package/dist/server/response-contract-lane.d.ts.map +1 -1
  73. package/dist/server/response-contract-lane.js +135 -8
  74. package/dist/server/response-contract-lane.js.map +1 -1
  75. package/dist/server/runtime-core.d.ts +7 -0
  76. package/dist/server/runtime-core.d.ts.map +1 -1
  77. package/dist/server/runtime-core.js +8 -1
  78. package/dist/server/runtime-core.js.map +1 -1
  79. package/dist/server/server-types.d.ts +6 -3
  80. package/dist/server/server-types.d.ts.map +1 -1
  81. package/dist/server/server.d.ts +6 -2
  82. package/dist/server/server.d.ts.map +1 -1
  83. package/dist/server/server.js +239 -78
  84. package/dist/server/server.js.map +1 -1
  85. package/dist/server/sse.d.ts +1 -1
  86. package/dist/server/sse.d.ts.map +1 -1
  87. package/dist/server/sse.js +5 -4
  88. package/dist/server/sse.js.map +1 -1
  89. package/dist/server/webhook.d.ts +1 -1
  90. package/dist/server/webhook.d.ts.map +1 -1
  91. package/dist/server/webhook.js +24 -19
  92. package/dist/server/webhook.js.map +1 -1
  93. package/dist/server/websocket.d.ts +18 -3
  94. package/dist/server/websocket.d.ts.map +1 -1
  95. package/dist/server/websocket.js +45 -5
  96. package/dist/server/websocket.js.map +1 -1
  97. package/dist/server/ws-bun.d.ts.map +1 -1
  98. package/dist/server/ws-bun.js +12 -3
  99. package/dist/server/ws-bun.js.map +1 -1
  100. package/dist/server.d.ts +1 -0
  101. package/dist/server.d.ts.map +1 -1
  102. package/dist/server.js +1 -0
  103. package/dist/server.js.map +1 -1
  104. package/dist/tool-cli.d.ts +64 -0
  105. package/dist/tool-cli.d.ts.map +1 -0
  106. package/dist/tool-cli.js +495 -0
  107. package/dist/tool-cli.js.map +1 -0
  108. package/package.json +11 -1
  109. package/src/assurance.ts +1 -0
  110. package/src/evidence.ts +4 -0
  111. package/src/file-type.ts +130 -0
  112. package/src/index.ts +2 -1
  113. package/src/internal/route-program.ts +261 -93
  114. package/src/reflection.ts +53 -7
  115. package/src/response-observer.ts +6 -0
  116. package/src/server/bare-error-lane.ts +9 -4
  117. package/src/server/before-handler.ts +14 -0
  118. package/src/server/body-lane.ts +19 -9
  119. package/src/server/body-parser.ts +1 -5
  120. package/src/server/body.ts +96 -36
  121. package/src/server/core-copies.ts +1 -1
  122. package/src/server/http.ts +6 -15
  123. package/src/server/multipart.ts +232 -100
  124. package/src/server/proto-guard.ts +39 -4
  125. package/src/server/query.ts +7 -13
  126. package/src/server/repeated-prefix.ts +25 -0
  127. package/src/server/request-context.ts +1 -3
  128. package/src/server/respond.ts +23 -15
  129. package/src/server/response-contract-lane.ts +130 -8
  130. package/src/server/runtime-core.ts +10 -1
  131. package/src/server/server-types.ts +6 -3
  132. package/src/server/server.ts +265 -76
  133. package/src/server/sse.ts +6 -5
  134. package/src/server/webhook.ts +29 -24
  135. package/src/server/websocket.ts +57 -8
  136. package/src/server/ws-bun.ts +17 -2
  137. package/src/server.ts +1 -0
  138. package/src/tool-cli.ts +584 -0
@@ -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 {
@@ -67,8 +71,11 @@ import type {
67
71
  } from "../schema/standard.ts"
68
72
  import type { AuthenticationStage } from "./auth.ts"
69
73
  import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.ts"
74
+ import { LAST_BEFORE_HANDLE } from "./before-handler.ts"
70
75
  import {
71
76
  assertByteLimit,
77
+ clientBodyErrorCode,
78
+ hasTrustedBodyFraming,
72
79
  markTransportCap,
73
80
  markTrustedBodyFraming,
74
81
  type RawBodyReaders,
@@ -378,6 +385,8 @@ interface FetchMount<Env = unknown> {
378
385
  readonly fallbackOn404: boolean
379
386
  readonly beforeRoutes: boolean
380
387
  readonly order: number
388
+ /** The `authenticate()` stages registered before this mount, which gate it as they gate a route. */
389
+ readonly authStages: readonly RawAuthStage[]
381
390
  /** Why this child is not analyzed (`MountOptions.opaque`), checked by reflection. */
382
391
  readonly opaque: string | OpaqueMountDeclaration | undefined
383
392
  /** The mounted app (`mount()` only), for reflection to tell a composed child from an opaque one. */
@@ -588,6 +597,8 @@ export {
588
597
  export type { IdentityPlugin }
589
598
 
590
599
  const DEFAULT_MAX_BODY_BYTES = 1_000_000
600
+ const BODY_RESPONSE = (response: Response | ResponseResult): Response | ResponseResult => response
601
+ const BODY_ERROR = (error: unknown): Promise<never> => Promise.reject(error)
591
602
  const DEFAULT_DRAIN_MS = 10_000
592
603
  const DRAIN_POLL_MS = 10
593
604
  const STOP_HOOK_TIMEOUT_MS = 5_000
@@ -637,18 +648,104 @@ function hasUnroutedParam(params: Record<string, string>): boolean {
637
648
  */
638
649
  const rawRequestTargets = typeof (globalThis as { Deno?: unknown }).Deno !== "undefined"
639
650
 
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)
651
+ /**
652
+ * Bun parses the target only when it can read the Host. Otherwise `Request.url` is the target as sent:
653
+ * relative for a Host with a space or a byte past ASCII or for no Host at all, the raw authority
654
+ * for a port past 65535, and `new URL(req.url)` throws in the app. So Bun is held to the Host check
655
+ * Deno runs.
656
+ */
657
+ const unparsedBunTargets = typeof Bun !== "undefined"
658
+
659
+ /**
660
+ * `req` as Bun or workerd would have delivered it: the same request, its URL resolved as they parse it.
661
+ * `undefined` when the `Request` constructor refuses what the wire carried - a TRACE, CONNECT or TRACK,
662
+ * a lower-case `get` with a body - which {@link resolvedSource} routes instead.
663
+ */
664
+ function withResolvedTarget(req: Request, url: string): Request | undefined {
665
+ let rebuilt: Request
666
+ try {
667
+ rebuilt = new Request(url, req)
668
+ } catch {
669
+ return undefined
670
+ }
671
+ // The same body, so the same framing: a runtime still delimited it.
672
+ if (hasTrustedBodyFraming(req)) markTrustedBodyFraming(rebuilt)
673
+ return rebuilt
674
+ }
675
+
676
+ /**
677
+ * A request no `Request` can be rebuilt from, as a source carrying its resolved URL: it is routed on that
678
+ * URL and gets the 404 or 405 the other runtimes give, where rebuilding it threw out of `fetch`. A
679
+ * handler that reads `c.req` gets the request as Deno delivered it.
680
+ */
681
+ function resolvedSource(req: Request, url: string): RequestSource {
682
+ const source: RequestSource = {
683
+ method: req.method,
684
+ url,
685
+ get headers() {
686
+ return req.headers
687
+ },
688
+ header: (name) => headerOf(req, name),
689
+ get body() {
690
+ return req.body
691
+ },
692
+ arrayBuffer: () => req.arrayBuffer(),
693
+ json: () => req.json(),
694
+ bytes: () => req.bytes(),
695
+ request: req,
696
+ }
697
+ if (hasTrustedBodyFraming(req)) markTrustedBodyFraming(source)
698
+ return source
699
+ }
642
700
 
643
701
  // A Host as RFC 9110 allows it: a reg-name or an IP literal, then an optional port. Deno builds
644
702
  // `Request.url` by appending the target to the Host as received, so `Host: x/admin#` turned a
645
703
  // 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
704
+ const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::(\d{1,5}))?$/i
705
+
706
+ // Clients repeat one Host on every request; comparing to the last valid one is ~7x cheaper than the regex.
707
+ let lastValidHost = ""
708
+
709
+ // A port past 65535, or an IPv6 literal no URL parser takes (`[1:2]`), is refused as Node refuses it:
710
+ // no URL can be built on either, so resolving a dot segment against one threw out of `fetch`.
711
+ const invalidHost = (host: string): boolean => {
712
+ if (host === lastValidHost) return false
713
+ const authority = HOST_AUTHORITY.exec(host)
714
+ if (authority === null) return true
715
+ if (authority[1] !== undefined && Number(authority[1]) > 65_535) return true
716
+ if (host.charCodeAt(0) === 91 /* [ */ && !URL.canParse(`http://${host}/`)) return true
717
+ lastValidHost = host
718
+ return false
719
+ }
647
720
 
648
- // `headerOf`, not `req.headers`: on Deno that reads the Host alone instead of building every header.
649
- const invalidHost = (req: Request): boolean => {
721
+ /**
722
+ * What a request is routed on in place of `req.url`, before anything reads it: `true` refuses its
723
+ * Host (400), a URL replaces the target, `false` keeps it. Deno hands the target over as sent, so
724
+ * its dot segments are resolved; so does Bun when HTTP/1.0 leaves the Host out, and its route table
725
+ * matched `/files/..` raw. Such a request is served as `localhost`, as Node and Deno serve it.
726
+ * `headerOf`, not `req.headers`: on Deno that reads the Host alone instead of building every
727
+ * header.
728
+ */
729
+ const requestTarget = (req: Request): string | boolean => {
730
+ if (!rawRequestTargets && !unparsedBunTargets) return false
650
731
  const host = headerOf(req, "host")
651
- return host !== null && !HOST_AUTHORITY.test(host)
732
+ if (host !== null && invalidHost(host)) {
733
+ if (rawRequestTargets) return true
734
+ // Bun served it anyway: it could not read the Host, so it left the target unparsed, or it
735
+ // built the URL on it - sent twice (joined with ", ", the URL on the first; RFC 9112 section
736
+ // 3.2 answers 400), percent-encoded (`%61.example` routed as `a.example`), or with a
737
+ // sub-delimiter (`a!b`). A request built in process keeps the URL its caller built, unless Bun
738
+ // would have built it so.
739
+ const routed = URL.parse(req.url)?.host
740
+ return routed === undefined || URL.parse(`//${host.split(", ")[0]}`, req.url)?.host === routed
741
+ }
742
+ if (rawRequestTargets) return needsResolving(req.url) && resolveDotSegments(req.url)
743
+ // A request built in process carries an absolute URL whatever its headers say.
744
+ return (
745
+ host === null &&
746
+ req.url.charCodeAt(0) === 47 /* / */ &&
747
+ resolveDotSegments(`http://localhost${req.url}`)
748
+ )
652
749
  }
653
750
 
654
751
  function normalizeMountPrefix(path: string): string {
@@ -881,8 +978,9 @@ interface HookAuditState {
881
978
  readonly hasCustomLogger: boolean
882
979
  readonly sites: {
883
980
  readonly kind: string
884
- /** Routes that captured the chain so far: merged routes excluded, they keep their own. */
885
- readonly routesAtPush: number
981
+ /** Routes that captured the chain so far: merged routes excluded, they keep their own. -1 once a
982
+ * mount captured it, which no route count matches. */
983
+ routesAtPush: number
886
984
  readonly mergedAtPush: number
887
985
  readonly site: string | undefined
888
986
  }[]
@@ -964,6 +1062,19 @@ const recordScopedHook = (key: object, routesAtPush: number, kind: string): void
964
1062
  })
965
1063
  }
966
1064
 
1065
+ /** `next(value)` now, or once `value` settles. */
1066
+ const thenMaybe = <T, U>(
1067
+ value: MaybePromise<T>,
1068
+ next: (value: T) => MaybePromise<U>,
1069
+ ): MaybePromise<U> => (value instanceof Promise ? value.then(next) : next(value))
1070
+
1071
+ /** A mount registered after an `authenticate()` stage runs it, so the stage covers something. */
1072
+ const noteAuthenticatedMount = (key: object): void => {
1073
+ const state = hookAudit.get(key)
1074
+ if (state === undefined) return
1075
+ for (const site of state.sites) if (site.kind === "authenticate") site.routesAtPush = -1
1076
+ }
1077
+
967
1078
  /** Count routes a `merge()` adopted: they never run this server's order-scoped hooks, so a hook
968
1079
  * followed only by merges covers nothing. */
969
1080
  const noteMergedRoutes = (key: object, count: number): void => {
@@ -1242,15 +1353,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1242
1353
  /** Body/raw response hooks need a framework-payload marker; keep that decision per app. */
1243
1354
  private responseBodyTag: object | undefined
1244
1355
  private readonly responseBodyOwners: Set<object>
1356
+ // General lifecycles need no extra response bookkeeping when neither controls nor observers
1357
+ // use it. Share the fused renderer after the lifecycle, including on body/write routes.
1245
1358
  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
- )
1359
+ set === EMPTY_RESPONSE_CONTROLS && this.onResponseHooks.length === 0
1360
+ ? fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders, true)
1361
+ : toResponse(
1362
+ result as HandlerResult,
1363
+ set,
1364
+ this.responseBodyTag,
1365
+ this.staticResponseHeaders,
1366
+ // Response hooks write next, and they run for this same request object.
1367
+ ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined,
1368
+ )
1254
1369
  private readonly responseRequests: WeakMap<Request, Request>
1255
1370
  /** A request the WebSocket handshake check ran the onRequest hooks on and then passed to HTTP →
1256
1371
  * what the hooks returned, so `fetch` routes that instead of running the hooks a second time. */
@@ -1396,7 +1511,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1396
1511
  /**
1397
1512
  * Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
1398
1513
  * 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.
1514
+ * Mounted responses are outside Nifra's typed route and response-contract checks. An
1515
+ * `authenticate()` stage registered before the mount, or a required auth plugin built on one
1516
+ * (`bearer()`, `jwt()`, `basicAuth()`, `apiKey()`), refuses a request before the handler sees it;
1517
+ * mount before the stage to keep the legacy surface public.
1400
1518
  *
1401
1519
  * It carries HTTP only: a WebSocket upgrade under the prefix reaches the handler as an ordinary
1402
1520
  * request and never becomes a socket. Serve that path's sockets with `app.ws()` routes or a
@@ -1418,7 +1536,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1418
1536
  beforeRoutes: false,
1419
1537
  order: this.mountOrder++,
1420
1538
  opaque: options.opaque,
1539
+ authStages: [...this.authStages],
1421
1540
  }
1541
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production" && this.authStages.length > 0)
1542
+ noteAuthenticatedMount(this)
1422
1543
  this.fetchMounts.push(mount)
1423
1544
  this.fetchMounts.sort(compareMounts)
1424
1545
  return this
@@ -1474,7 +1595,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1474
1595
  order: this.mountOrder++,
1475
1596
  opaque: options.opaque,
1476
1597
  app,
1598
+ authStages: [...this.authStages],
1477
1599
  }
1600
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production" && this.authStages.length > 0)
1601
+ noteAuthenticatedMount(this)
1478
1602
  // Mounts run before routes: a request under this prefix that no mount on the way strips comes back
1479
1603
  // here unchanged until the stack overflows, which takes the process down. Dev-time only, like the
1480
1604
  // hook audit: a production define folds this away, and the first dev request would hit it anyway.
@@ -1533,7 +1657,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1533
1657
  * (see {@link ServerOptions.unusedScopedHooks}).
1534
1658
  */
1535
1659
  /**
1536
- * Register a dedicated authentication stage for subsequent routes.
1660
+ * Register a dedicated authentication stage for subsequent routes, and for the `mount()` and
1661
+ * `mountFetch()` handlers registered after it: a request it refuses never reaches them.
1537
1662
  *
1538
1663
  * Unlike `derive`, this stage receives a body-blind authentication capsule and is compiled before
1539
1664
  * route validation. Keeping it separate prevents an unrelated derive from accidentally becoming a
@@ -1603,7 +1728,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1603
1728
  fn: H,
1604
1729
  ): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
1605
1730
  this.assertConfigurable("beforeHandle()")
1606
- this.beforeHandleHooks.push(fn as unknown as RawBeforeHandle)
1731
+ const hooks = this.beforeHandleHooks
1732
+ let at = hooks.length
1733
+ if (!LAST_BEFORE_HANDLE.has(fn)) {
1734
+ while (at > 0 && LAST_BEFORE_HANDLE.has(hooks[at - 1]!)) at--
1735
+ }
1736
+ hooks.splice(at, 0, fn as unknown as RawBeforeHandle)
1607
1737
  if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
1608
1738
  recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "beforeHandle")
1609
1739
  tagHook(fn, "beforeHandle", this)
@@ -3223,9 +3353,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3223
3353
  fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
3224
3354
  // Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
3225
3355
  // 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)
3356
+ const target = requestTarget(req)
3357
+ if (target === true) return jsonError(400, "invalid_host")
3358
+ if (target !== false) {
3359
+ const rebuilt = withResolvedTarget(req, target)
3360
+ if (rebuilt === undefined) return this.fetchSource(resolvedSource(req, target), platform)
3361
+ req = rebuilt
3229
3362
  }
3230
3363
  // An edge deployment whose only ingress is this method declares its requests runtime-framed at
3231
3364
  // construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
@@ -3387,7 +3520,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3387
3520
  // whatever their prefix lengths. A 404 fallthrough chain stays on the ordinary path.
3388
3521
  const mount =
3389
3522
  this.firstMountUnder(parts.pathname, true) ?? this.firstMountUnder(parts.pathname, false)
3390
- if (mount === undefined || mount.fallbackOn404) return undefined
3523
+ if (mount === undefined || mount.fallbackOn404 || mount.authStages.length > 0) return undefined
3391
3524
  const candidate = (mount.handler as unknown as Record<symbol, unknown>)[NODE_NATIVE_MOUNT]
3392
3525
  return typeof candidate === "function"
3393
3526
  ? {
@@ -3724,11 +3857,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3724
3857
  // The adapter answers a pass by handing this same object to `fetch`.
3725
3858
  const received = req
3726
3859
  // 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)
3860
+ const target = requestTarget(req)
3861
+ if (target === true) {
3862
+ return { kind: "reject", response: this.wrapWebResponse(jsonError(400, "invalid_host")) }
3863
+ }
3864
+ if (target !== false) {
3865
+ // A GET carrying a body cannot be rebuilt; `fetch` routes it as the ordinary request it is.
3866
+ const rebuilt = withResolvedTarget(req, target)
3867
+ if (rebuilt === undefined) return WS_PASS
3868
+ req = rebuilt
3732
3869
  }
3733
3870
  // The handshake's caller is the one `fetch` derives: hooks and `upgrade()` see the trusted IP.
3734
3871
  platform = this.trustedPlatform(req, platform)
@@ -3844,18 +3981,28 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3844
3981
  }
3845
3982
  }
3846
3983
  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"))
3984
+ const mount = selected
3985
+ const upgrade = (): MaybePromise<WebSocketUpgradeOutcome> => {
3986
+ let childRequest = request
3987
+ if (mount.stripPrefix) {
3988
+ childRequest = stripMountPrefix(request, mount.path)
3989
+ mount.wsPassed ??= new WeakMap()
3990
+ mount.wsPassed.set(request, childRequest)
3991
+ }
3992
+ try {
3993
+ return mount.resolveWebSocketUpgrade!(childRequest, platform)
3994
+ } catch {
3995
+ return refuse(jsonError(500, "internal_error"))
3996
+ }
3858
3997
  }
3998
+ const child =
3999
+ mount.authStages.length === 0
4000
+ ? upgrade()
4001
+ : thenMaybe(
4002
+ authenticateMount(mount.authStages, request, platform, this.logger),
4003
+ (refusal): MaybePromise<WebSocketUpgradeOutcome> =>
4004
+ refusal === undefined ? upgrade() : refuse(refusal),
4005
+ )
3859
4006
  const settle = (outcome: WebSocketUpgradeOutcome): MaybePromise<WebSocketUpgradeOutcome> =>
3860
4007
  outcome.kind === "pass" ||
3861
4008
  (outcome.kind === "reject" && outcome.response.status === 404 && selected!.fallbackOn404)
@@ -4252,8 +4399,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4252
4399
  onTimeout: () => T,
4253
4400
  webFast: boolean,
4254
4401
  ): 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.
4402
+ // Native delimiter searches keep path/query splitting out of a character-by-character JS loop.
4403
+ // Keep the slices in locals instead of allocating the public helper's pathname/search pair.
4257
4404
  let pathname: string
4258
4405
  let search: string
4259
4406
  // Read the source's split ONCE. `urlParts` is an accessor on the Node request sources that
@@ -4272,20 +4419,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4272
4419
  pathname = "/"
4273
4420
  search = ""
4274
4421
  } 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
- }
4422
+ const fragment = rawUrl.indexOf("#", start)
4423
+ const searchEnd = fragment === -1 ? rawUrl.length : fragment
4424
+ const question = rawUrl.indexOf("?", start)
4425
+ const searchStart = question !== -1 && question < searchEnd ? question : -1
4426
+ const pathEnd = searchStart === -1 ? searchEnd : searchStart
4289
4427
  pathname = rawUrl.slice(start, pathEnd)
4290
4428
  search = searchStart === -1 ? "" : rawUrl.slice(searchStart, searchEnd)
4291
4429
  }
@@ -4377,10 +4515,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4377
4515
  }
4378
4516
  }
4379
4517
  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)
4518
+ const selected = mount
4519
+ const run = (): MaybePromise<Response> => {
4520
+ const childRequest = selected.stripPrefix
4521
+ ? (selected.wsPassed?.get(request) ?? stripMountPrefix(request, selected.path))
4522
+ : request
4523
+ return selected.handler(childRequest, platform)
4524
+ }
4525
+ const outcome =
4526
+ mount.authStages.length === 0
4527
+ ? run()
4528
+ : thenMaybe(
4529
+ authenticateMount(mount.authStages, request, platform, this.logger),
4530
+ (refusal) => refusal ?? run(),
4531
+ )
4384
4532
  if (!replayable || !mount.fallbackOn404) return outcome
4385
4533
  if (outcome instanceof Promise) {
4386
4534
  return outcome.then((response) =>
@@ -5857,7 +6005,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5857
6005
  finalize,
5858
6006
  responseSet,
5859
6007
  (e, c) => this.logRequestError(e, c),
5860
- () => this.internalErrorResponse(wrapResponse),
6008
+ wrapResponse,
5861
6009
  )
5862
6010
  }
5863
6011
 
@@ -5993,6 +6141,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5993
6141
  wrapResponse,
5994
6142
  (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
5995
6143
  entry.schema?.body,
6144
+ this.formOrigins,
5996
6145
  )
5997
6146
  }
5998
6147
 
@@ -6365,7 +6514,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6365
6514
  const checked = contract.runtime.check(contract.definition, result, ctx)
6366
6515
  if (checked instanceof Promise) {
6367
6516
  return checked.then(
6368
- (outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome),
6517
+ (outcome) => {
6518
+ // The render runs outside the try below: a throw (a BigInt, a cycle) must still reach onError.
6519
+ try {
6520
+ return this.finishContractOutcome(ctx, finalize, wrapResponse, outcome)
6521
+ } catch (err) {
6522
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
6523
+ }
6524
+ },
6369
6525
  (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
6370
6526
  )
6371
6527
  }
@@ -6428,6 +6584,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6428
6584
  if (err instanceof Response || isResponseResult(err))
6429
6585
  return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, err)
6430
6586
  if (entry.onError.length === 0) {
6587
+ const clientCode = clientBodyErrorCode(err)
6588
+ if (clientCode !== undefined) return wrapResponse(plainError(400, clientCode))
6431
6589
  // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
6432
6590
  // (redacting) logger. Body-read failures and around-hook failures land here too.
6433
6591
  this.logRequestError(err, ctx)
@@ -6516,6 +6674,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6516
6674
  return wrapResponse(plainError(500, "internal_error"))
6517
6675
  }
6518
6676
  }
6677
+ const clientCode = clientBodyErrorCode(err)
6678
+ if (clientCode !== undefined) return wrapResponse(plainError(400, clientCode))
6519
6679
  this.logRequestError(err, ctx)
6520
6680
  return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback()
6521
6681
  }
@@ -6538,18 +6698,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6538
6698
  // second continuation, while retaining the exact bounded-read, content-type, and poisoning
6539
6699
  // contracts shared by every body route.
6540
6700
  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")
6701
+ // Reject outside the parser's JSON catch so a custom validator throw reaches onError.
6702
+ try {
6703
+ const validation = bodySchema["~standard"].validate(parsed)
6704
+ return validation instanceof Promise
6705
+ ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, "body"))
6706
+ : this.applyLifecycleValidation(entry, validation, ctx, "body")
6707
+ } catch (error) {
6708
+ return Promise.reject(error)
6709
+ }
6545
6710
  }
6546
6711
  return readBodyFramed(
6547
6712
  req,
6548
6713
  bodyLimit ?? UNLIMITED_BODY_BYTES,
6549
6714
  this.protoPoisoning,
6550
6715
  validate,
6551
- (response) => response,
6552
- (error) => Promise.reject(error),
6716
+ BODY_RESPONSE,
6717
+ BODY_ERROR,
6553
6718
  bodySchema,
6554
6719
  this.formOrigins,
6555
6720
  )
@@ -6563,15 +6728,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6563
6728
  ctx: RawContext,
6564
6729
  kind: ProgramValidationKind,
6565
6730
  ): MaybePromise<Response | ResponseResult | undefined> {
6566
- const assign = (value: unknown): void => {
6567
- ;(ctx as unknown as Record<ProgramValidationKind, unknown>)[kind] = value
6568
- }
6569
6731
  if (result.issues === undefined) {
6570
- assign(result.value)
6732
+ // biome-ignore lint/plugin/noChainedTypeAssertions lint/plugin/requireSafetyCommentForTypeAssertion: RawContext carries every ProgramValidationKind slot; this instruction assigns its validated value before handlers run.
6733
+ ;(ctx as unknown as Record<ProgramValidationKind, unknown>)[kind] = result.value
6571
6734
  return undefined
6572
6735
  }
6573
6736
  const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
6574
6737
  if (hook === undefined) return plainValidationError(result.issues)
6738
+ const assign = (value: unknown): void => {
6739
+ ;(ctx as unknown as Record<ProgramValidationKind, unknown>)[kind] = value
6740
+ }
6575
6741
  const attempted = hook(result.issues, ctx as unknown as Context, kind)
6576
6742
  if (attempted instanceof Promise) {
6577
6743
  return attempted.then((recovery) =>
@@ -6618,14 +6784,34 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6618
6784
  // the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
6619
6785
  // the portable/adapted source contract (those sources are never marked).
6620
6786
  const markFramed = (request: Request): void => markTrustedBodyFraming(request)
6621
- // The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
6787
+ // The direct native lanes bypass `runMatched`, so the route's transport byte cap must be
6622
6788
  // 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.
6789
+ // reads uncapped. The fallback branches go through `fetchMatched` -> `runMatched`, which marks.
6624
6790
  const bodyLimit =
6625
6791
  entry.schema?.body === undefined || entry.program.authBeforeValidation
6626
6792
  ? entry.bodyLimit
6627
6793
  : undefined
6628
- const inner = fused
6794
+ // The complete plan already preserves general validation, hooks, recovery, around, and ledgers.
6795
+ // When native admission is safe, use it directly even if the route has no closed-shape fusion.
6796
+ // Idempotency still needs runMatched's request preparation and is excluded from the table.
6797
+ const inner: FusedWebRunner | undefined =
6798
+ fused ??
6799
+ (signal && budget
6800
+ ? (source, params, search, activeSignal, activeBudget, platform, nativeContext) =>
6801
+ entry.execution.run(
6802
+ this,
6803
+ entry,
6804
+ source,
6805
+ params,
6806
+ search,
6807
+ activeSignal,
6808
+ activeBudget,
6809
+ platform,
6810
+ nativeContext,
6811
+ this.finalizeResponse,
6812
+ this.wrapWebResponse,
6813
+ )
6814
+ : undefined)
6629
6815
  const capped: FusedWebRunner | undefined =
6630
6816
  inner === undefined || bodyLimit === undefined
6631
6817
  ? inner
@@ -6808,7 +6994,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6808
6994
  }
6809
6995
  const paramNames = pattern.paramNames
6810
6996
  const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined
6811
- tableAt(path, pattern.segments)[method as Method] = this.compileBunNativeHandler(
6997
+ const handler = this.compileBunNativeHandler(
6812
6998
  entry,
6813
6999
  paramNames,
6814
7000
  fused,
@@ -6817,6 +7003,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6817
7003
  peer,
6818
7004
  fallback,
6819
7005
  )
7006
+ // The table matched the target as sent; one `fetch` refuses or rewrites goes through it.
7007
+ tableAt(path, pattern.segments)[method as Method] = (request, server) =>
7008
+ requestTarget(request) ? fallback(request, server) : handler(request, server)
6820
7009
  count += 1
6821
7010
  if (paramNames.length > 0) {
6822
7011
  below.add(pattern.segments.length + method)
package/src/server/sse.ts CHANGED
@@ -66,7 +66,7 @@ export interface SSEInit {
66
66
  * Bytes of sent events one client may leave unread, default 1 MiB. Past it the client is dropped:
67
67
  * `signal` aborts, the queued frames are released, and later sends are no-ops, so a reader that
68
68
  * stalls cannot hold server memory without limit. Raise it for a burst larger than this sent before
69
- * the client can read; `Infinity` turns the limit off.
69
+ * the client can read. A single frame over the cap is also refused; `Infinity` turns the limit off.
70
70
  */
71
71
  readonly maxBufferedBytes?: number
72
72
  }
@@ -158,9 +158,10 @@ export function sse(
158
158
  const stream: SSEStream = {
159
159
  send(message) {
160
160
  if (closed) return
161
- // The queue only shrinks as the runtime writes to the socket, so a backlog past the cap
162
- // is a client that stopped reading. Erroring the body releases the queue and the socket.
163
- if ((controller.desiredSize ?? 0) < 0) {
161
+ const frame = encoder.encode(formatMessage(message))
162
+ // Check the incoming frame too: a lone oversized event has no later send to trigger
163
+ // overflow detection. Erroring the body releases its queue and stops the producer.
164
+ if (frame.byteLength > (controller.desiredSize ?? 0)) {
164
165
  closed = true
165
166
  teardown()
166
167
  const reason = new Error("sse_client_too_slow")
@@ -168,7 +169,7 @@ export function sse(
168
169
  controller.error(reason)
169
170
  return
170
171
  }
171
- controller.enqueue(encoder.encode(formatMessage(message)))
172
+ controller.enqueue(frame)
172
173
  },
173
174
  close,
174
175
  signal: stopped.signal,