@nifrajs/core 5.7.0 → 5.9.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 (223) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +9 -4
  3. package/dist/assurance.d.ts +26 -1
  4. package/dist/assurance.d.ts.map +1 -1
  5. package/dist/assurance.js +43 -11
  6. package/dist/assurance.js.map +1 -1
  7. package/dist/canonical-json.d.ts +11 -0
  8. package/dist/canonical-json.d.ts.map +1 -0
  9. package/dist/canonical-json.js +46 -0
  10. package/dist/canonical-json.js.map +1 -0
  11. package/dist/capabilities.d.ts.map +1 -1
  12. package/dist/capabilities.js +1 -8
  13. package/dist/capabilities.js.map +1 -1
  14. package/dist/data.d.ts.map +1 -1
  15. package/dist/data.js +1 -8
  16. package/dist/data.js.map +1 -1
  17. package/dist/diff.d.ts.map +1 -1
  18. package/dist/diff.js +2 -12
  19. package/dist/diff.js.map +1 -1
  20. package/dist/durable-adapters.d.ts +2 -0
  21. package/dist/durable-adapters.d.ts.map +1 -1
  22. package/dist/durable-adapters.js +41 -49
  23. package/dist/durable-adapters.js.map +1 -1
  24. package/dist/durable-saga.d.ts.map +1 -1
  25. package/dist/durable-saga.js +14 -20
  26. package/dist/durable-saga.js.map +1 -1
  27. package/dist/evidence.d.ts.map +1 -1
  28. package/dist/evidence.js +14 -68
  29. package/dist/evidence.js.map +1 -1
  30. package/dist/idempotency.d.ts.map +1 -1
  31. package/dist/idempotency.js +60 -23
  32. package/dist/idempotency.js.map +1 -1
  33. package/dist/index.d.ts +3 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +3 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/internal/control-character.d.ts +3 -0
  38. package/dist/internal/control-character.d.ts.map +1 -0
  39. package/dist/internal/control-character.js +10 -0
  40. package/dist/internal/control-character.js.map +1 -0
  41. package/dist/internal/durable-shared.d.ts.map +1 -1
  42. package/dist/internal/durable-shared.js +4 -1
  43. package/dist/internal/durable-shared.js.map +1 -1
  44. package/dist/internal/hex.d.ts +5 -0
  45. package/dist/internal/hex.d.ts.map +1 -0
  46. package/dist/internal/hex.js +14 -0
  47. package/dist/internal/hex.js.map +1 -0
  48. package/dist/internal/record.d.ts +3 -0
  49. package/dist/internal/record.d.ts.map +1 -0
  50. package/dist/internal/record.js +5 -0
  51. package/dist/internal/record.js.map +1 -0
  52. package/dist/internal/route-compiler.d.ts.map +1 -1
  53. package/dist/internal/route-compiler.js +2 -2
  54. package/dist/internal/route-compiler.js.map +1 -1
  55. package/dist/internal/route-execution.d.ts +4 -4
  56. package/dist/internal/route-execution.d.ts.map +1 -1
  57. package/dist/internal/route-execution.js +4 -0
  58. package/dist/internal/route-execution.js.map +1 -1
  59. package/dist/internal/route-lanes.d.ts +4 -3
  60. package/dist/internal/route-lanes.d.ts.map +1 -1
  61. package/dist/internal/route-lanes.js +16 -33
  62. package/dist/internal/route-lanes.js.map +1 -1
  63. package/dist/internal/schema-parts.d.ts +13 -0
  64. package/dist/internal/schema-parts.d.ts.map +1 -0
  65. package/dist/internal/schema-parts.js +15 -0
  66. package/dist/internal/schema-parts.js.map +1 -0
  67. package/dist/ledger.d.ts.map +1 -1
  68. package/dist/ledger.js +2 -14
  69. package/dist/ledger.js.map +1 -1
  70. package/dist/manifest.d.ts.map +1 -1
  71. package/dist/manifest.js +10 -37
  72. package/dist/manifest.js.map +1 -1
  73. package/dist/open-resolved.d.ts +18 -0
  74. package/dist/open-resolved.d.ts.map +1 -0
  75. package/dist/open-resolved.js +62 -0
  76. package/dist/open-resolved.js.map +1 -0
  77. package/dist/range.js +1 -1
  78. package/dist/range.js.map +1 -1
  79. package/dist/serve-static.d.ts +22 -0
  80. package/dist/serve-static.d.ts.map +1 -0
  81. package/dist/serve-static.js +187 -0
  82. package/dist/serve-static.js.map +1 -0
  83. package/dist/server/bare-error-lane.d.ts +2 -1
  84. package/dist/server/bare-error-lane.d.ts.map +1 -1
  85. package/dist/server/bare-error-lane.js +12 -3
  86. package/dist/server/bare-error-lane.js.map +1 -1
  87. package/dist/server/base64-answers.d.ts.map +1 -1
  88. package/dist/server/base64-answers.js +1 -4
  89. package/dist/server/base64-answers.js.map +1 -1
  90. package/dist/server/body-lane.d.ts.map +1 -1
  91. package/dist/server/body-lane.js +43 -38
  92. package/dist/server/body-lane.js.map +1 -1
  93. package/dist/server/body.d.ts.map +1 -1
  94. package/dist/server/body.js +4 -1
  95. package/dist/server/body.js.map +1 -1
  96. package/dist/server/bridge-markers.d.ts +1 -1
  97. package/dist/server/bridge-markers.js +1 -1
  98. package/dist/server/cookies.d.ts.map +1 -1
  99. package/dist/server/cookies.js +4 -1
  100. package/dist/server/cookies.js.map +1 -1
  101. package/dist/server/core-copies.js +1 -1
  102. package/dist/server/http.d.ts +5 -0
  103. package/dist/server/http.d.ts.map +1 -1
  104. package/dist/server/http.js +13 -0
  105. package/dist/server/http.js.map +1 -1
  106. package/dist/server/idempotency-lane.d.ts.map +1 -1
  107. package/dist/server/idempotency-lane.js +2 -0
  108. package/dist/server/idempotency-lane.js.map +1 -1
  109. package/dist/server/idle-timeout.d.ts +39 -0
  110. package/dist/server/idle-timeout.d.ts.map +1 -0
  111. package/dist/server/idle-timeout.js +95 -0
  112. package/dist/server/idle-timeout.js.map +1 -0
  113. package/dist/server/json-shared.d.ts +23 -0
  114. package/dist/server/json-shared.d.ts.map +1 -0
  115. package/dist/server/json-shared.js +33 -0
  116. package/dist/server/json-shared.js.map +1 -0
  117. package/dist/server/json-text.d.ts +1 -1
  118. package/dist/server/json-text.js +1 -1
  119. package/dist/server/node-outcome.js +1 -1
  120. package/dist/server/node-outcome.js.map +1 -1
  121. package/dist/server/request-context.d.ts +22 -1
  122. package/dist/server/request-context.d.ts.map +1 -1
  123. package/dist/server/request-context.js +44 -4
  124. package/dist/server/request-context.js.map +1 -1
  125. package/dist/server/request-local.d.ts +44 -0
  126. package/dist/server/request-local.d.ts.map +1 -0
  127. package/dist/server/request-local.js +91 -0
  128. package/dist/server/request-local.js.map +1 -0
  129. package/dist/server/respond.js +1 -1
  130. package/dist/server/respond.js.map +1 -1
  131. package/dist/server/response-contract-lane.d.ts +10 -2
  132. package/dist/server/response-contract-lane.d.ts.map +1 -1
  133. package/dist/server/response-contract-lane.js +93 -64
  134. package/dist/server/response-contract-lane.js.map +1 -1
  135. package/dist/server/runtime-core.d.ts +7 -4
  136. package/dist/server/runtime-core.d.ts.map +1 -1
  137. package/dist/server/runtime-core.js +9 -7
  138. package/dist/server/runtime-core.js.map +1 -1
  139. package/dist/server/server-types.d.ts +27 -0
  140. package/dist/server/server-types.d.ts.map +1 -1
  141. package/dist/server/server.d.ts +59 -59
  142. package/dist/server/server.d.ts.map +1 -1
  143. package/dist/server/server.js +869 -512
  144. package/dist/server/server.js.map +1 -1
  145. package/dist/server/transport-lane.d.ts.map +1 -1
  146. package/dist/server/transport-lane.js +2 -0
  147. package/dist/server/transport-lane.js.map +1 -1
  148. package/dist/server/webhook.js +1 -1
  149. package/dist/server/webhook.js.map +1 -1
  150. package/dist/server/websocket.d.ts +3 -1
  151. package/dist/server/websocket.d.ts.map +1 -1
  152. package/dist/server/websocket.js +14 -1
  153. package/dist/server/websocket.js.map +1 -1
  154. package/dist/server/ws-adapter-view.d.ts +54 -0
  155. package/dist/server/ws-adapter-view.d.ts.map +1 -0
  156. package/dist/server/ws-adapter-view.js +7 -0
  157. package/dist/server/ws-adapter-view.js.map +1 -0
  158. package/dist/server.d.ts +2 -1
  159. package/dist/server.d.ts.map +1 -1
  160. package/dist/server.js +2 -1
  161. package/dist/server.js.map +1 -1
  162. package/dist/single-copy.d.ts.map +1 -1
  163. package/dist/single-copy.js +1 -1
  164. package/dist/single-copy.js.map +1 -1
  165. package/dist/static-file.d.ts +156 -4
  166. package/dist/static-file.d.ts.map +1 -1
  167. package/dist/static-file.js +198 -4
  168. package/dist/static-file.js.map +1 -1
  169. package/dist/wire.d.ts.map +1 -1
  170. package/dist/wire.js +1 -3
  171. package/dist/wire.js.map +1 -1
  172. package/package.json +16 -1
  173. package/src/assurance.ts +66 -16
  174. package/src/canonical-json.ts +43 -0
  175. package/src/capabilities.ts +1 -8
  176. package/src/data.ts +2 -8
  177. package/src/diff.ts +2 -12
  178. package/src/durable-adapters.ts +53 -50
  179. package/src/durable-saga.ts +30 -23
  180. package/src/evidence.ts +25 -90
  181. package/src/idempotency.ts +62 -20
  182. package/src/index.ts +13 -2
  183. package/src/internal/control-character.ts +8 -0
  184. package/src/internal/durable-shared.ts +4 -1
  185. package/src/internal/hex.ts +14 -0
  186. package/src/internal/record.ts +4 -0
  187. package/src/internal/route-compiler.ts +2 -2
  188. package/src/internal/route-execution.ts +7 -20
  189. package/src/internal/route-lanes.ts +20 -44
  190. package/src/internal/schema-parts.ts +19 -0
  191. package/src/ledger.ts +2 -16
  192. package/src/manifest.ts +10 -38
  193. package/src/open-resolved.ts +64 -0
  194. package/src/range.ts +1 -1
  195. package/src/serve-static.ts +227 -0
  196. package/src/server/bare-error-lane.ts +11 -2
  197. package/src/server/base64-answers.ts +2 -6
  198. package/src/server/body-lane.ts +58 -51
  199. package/src/server/body.ts +4 -1
  200. package/src/server/bridge-markers.ts +1 -1
  201. package/src/server/cookies.ts +4 -1
  202. package/src/server/core-copies.ts +1 -1
  203. package/src/server/http.ts +12 -0
  204. package/src/server/idempotency-lane.ts +2 -0
  205. package/src/server/idle-timeout.ts +107 -0
  206. package/src/server/json-shared.ts +38 -0
  207. package/src/server/json-text.ts +1 -1
  208. package/src/server/node-outcome.ts +1 -1
  209. package/src/server/request-context.ts +62 -3
  210. package/src/server/request-local.ts +109 -0
  211. package/src/server/respond.ts +1 -1
  212. package/src/server/response-contract-lane.ts +112 -65
  213. package/src/server/runtime-core.ts +10 -8
  214. package/src/server/server-types.ts +27 -0
  215. package/src/server/server.ts +1230 -800
  216. package/src/server/transport-lane.ts +2 -0
  217. package/src/server/webhook.ts +1 -1
  218. package/src/server/websocket.ts +18 -1
  219. package/src/server/ws-adapter-view.ts +63 -0
  220. package/src/server.ts +12 -1
  221. package/src/single-copy.ts +1 -3
  222. package/src/static-file.ts +350 -4
  223. package/src/wire.ts +2 -4
@@ -8,7 +8,7 @@ import { adoptRouteRuntimes, compileRouteOptions } from "../internal/route-compi
8
8
  import { compileRouteExecutionPlan, } from "../internal/route-execution.js";
9
9
  import { authenticateMount, compileRouteProgram, executeRouteProgram, } from "../internal/route-program.js";
10
10
  import { isSameOriginRequest } from "../internal/same-origin.js";
11
- import { decodeRouteParams, expandOptionalParams, } from "../router/pattern.js";
11
+ import { compileRoutePattern, decodeRouteParams, expandOptionalParams, } from "../router/pattern.js";
12
12
  import { EMPTY_PARAMS, METHODS, Router } from "../router/router.js";
13
13
  import { withAnswerSteps } from "./answer-step.js";
14
14
  import { isAuthenticatedRequest, markAuthenticatedRequest, } from "./auth.js";
@@ -22,8 +22,9 @@ import { declaredHeaders, headerObjectOf } from "./headers.js";
22
22
  import { isRoutableMethod, jsonError, pathnameOf, plainError, urlPartsOf, } from "./http.js";
23
23
  import { withStaticNodeHeaders } from "./node-outcome.js";
24
24
  import { queryObjectOf, searchOf } from "./query.js";
25
- import { PLATFORM_PEER, RequestContext, readBodyFramed, } from "./request-context.js";
25
+ import { PLATFORM_IDLE_TIMEOUT, PLATFORM_PEER, PLATFORM_REQUEST, RequestContext, readBodyFramed, } from "./request-context.js";
26
26
  import { recordRequestReplacement } from "./request-lineage.js";
27
+ import { carryToCopy } from "./request-local.js";
27
28
  import { needsResolving, resolveDotSegments } from "./request-target.js";
28
29
  import { applyStaticResponseHeaders, buildStaticResponseHeaders, claimHandedOver, fusedRespond, fusedRespondNoSet, handedBackResponse, recordResidentTexts, toResponse, writableResponse, } from "./respond.js";
29
30
  import { CONTEXT_REQUEST, CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, isResponseResult, requestOf, } from "./runtime-core.js";
@@ -39,24 +40,32 @@ import { jsonLogger } from "./logger.js";
39
40
  const BUN_NATIVE_REQUEST_SAFE = Symbol.for("@nifrajs/core/bun-native-request-safe");
40
41
  const functionToString = Function.prototype.toString;
41
42
  const CONTEXTLESS_ARROW = /^(?:async\s*)?\(\s*\)\s*(?::[\s\S]*?)?=>/;
42
- /** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown). */
43
- function bunPeerPlatform(server, req) {
43
+ /**
44
+ * The socket peer Bun observed (`undefined` if unknown) and its connection's idle timeout, as a
45
+ * `Platform` for one request. A class, not an object literal: JSC builds a literal holding a getter
46
+ * or a symbol key on a slow path, measured at about 5x the instructions of this instance.
47
+ */
48
+ class BunPeerPlatform {
49
+ #server;
50
+ #resolved = false;
51
+ #address;
52
+ constructor(server, req, setIdleTimeout) {
53
+ this.#server = server;
54
+ this[PLATFORM_IDLE_TIMEOUT] = setIdleTimeout;
55
+ this[PLATFORM_REQUEST] = req;
56
+ }
44
57
  // Bun's requestIP() costs ~0.5 us per call, about 7% of a bare GET's server time. Keep the
45
58
  // documented raw-peer c.clientIp behavior, but resolve it lazily: most routes never read c.clientIp,
46
59
  // so they should not pay for the socket lookup on every request. A getter also
47
60
  // preserves middleware that inspects the platform argument directly and trust-mode routes, which
48
61
  // resolve the value in deriveClientIp before the handler runs.
49
- let resolved = false;
50
- let address;
51
- return {
52
- get clientIp() {
53
- if (!resolved) {
54
- resolved = true;
55
- address = server.requestIP(req)?.address;
56
- }
57
- return address;
58
- },
59
- };
62
+ get clientIp() {
63
+ if (!this.#resolved) {
64
+ this.#resolved = true;
65
+ this.#address = this.#server.requestIP(this[PLATFORM_REQUEST])?.address;
66
+ }
67
+ return this.#address;
68
+ }
60
69
  }
61
70
  // Bun's route table has a slot for each of these; a QUERY entry is ignored and the request falls
62
71
  // through to the table's fetch, so QUERY routes stay on the portable dispatcher.
@@ -87,6 +96,20 @@ function outranks(other, route) {
87
96
  }
88
97
  return ahead && other.length === route.length;
89
98
  }
99
+ /** Whether one request can match both patterns: no two static segments differ before either ends
100
+ * in a wildcard, and the lengths agree otherwise. Errs toward `true`. */
101
+ function mayShareRequest(a, b) {
102
+ const shared = Math.min(a.length, b.length);
103
+ for (let i = 0; i < shared; i++) {
104
+ const left = a[i];
105
+ const right = b[i];
106
+ if (left.kind === "wildcard" || right.kind === "wildcard")
107
+ return true;
108
+ if (left.kind === "static" && right.kind === "static" && left.value !== right.value)
109
+ return false;
110
+ }
111
+ return a.length === b.length;
112
+ }
90
113
  /**
91
114
  * The key Bun's route table holds `path` under. Bun refuses one with a character outside printable
92
115
  * ASCII, so such a path is spelled as the portable router matches it: percent-encoded static
@@ -138,6 +161,8 @@ const BODY_ERROR = (error) => Promise.reject(error);
138
161
  const DEFAULT_DRAIN_MS = 10_000;
139
162
  const DRAIN_POLL_MS = 10;
140
163
  const STOP_HOOK_TIMEOUT_MS = 5_000;
164
+ /** Bun's own `idleTimeout` when `listen()` is given none. */
165
+ const BUN_IDLE_TIMEOUT_SEC = 10;
141
166
  /** Same-origin check for a WebSocket handshake (CSWSH default). {@link isSameOriginRequest} is the one
142
167
  * owner, shared with the server-function mount in `@nifrajs/web` - the two used to answer differently
143
168
  * for the same request, so a browser that could open a socket was told its POST was cross-origin. */
@@ -173,8 +198,8 @@ function hasUnroutedParam(params) {
173
198
  return false;
174
199
  }
175
200
  /**
176
- * Deno is the runtime that hands an app a `Request` whose `url` is the target as the client sent it;
177
- * Bun, workerd and every `new Request()` parse it first. Only there does `fetch` read the URL up front.
201
+ * Deno's server builds `Request.url` from the Host and target as received, without normalizing the
202
+ * target first. Bun, workerd, and `new Request()` parse it before core sees it.
178
203
  */
179
204
  const rawRequestTargets = typeof globalThis.Deno !== "undefined";
180
205
  /**
@@ -200,6 +225,7 @@ function withResolvedTarget(req, url) {
200
225
  // The same body, so the same framing: a runtime still delimited it.
201
226
  if (hasTrustedBodyFraming(req))
202
227
  markTrustedBodyFraming(rebuilt);
228
+ carryToCopy(req, rebuilt);
203
229
  return rebuilt;
204
230
  }
205
231
  /**
@@ -232,7 +258,8 @@ function resolvedSource(req, url) {
232
258
  // request for `/public` into one for `/admin`. Node refuses such a Host before routing; so does this.
233
259
  const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::(\d{1,5}))?$/i;
234
260
  // Clients repeat one Host on every request; comparing to the last valid one is ~7x cheaper than the regex.
235
- let lastValidHost = "";
261
+ // `null` until one passes, so an empty Host is never taken as the cached one.
262
+ let lastValidHost = null;
236
263
  // A port past 65535, or an IPv6 literal no URL parser takes (`[1:2]`), is refused as Node refuses it:
237
264
  // no URL can be built on either, so resolving a dot segment against one threw out of `fetch`.
238
265
  const invalidHost = (host) => {
@@ -253,26 +280,35 @@ const invalidHost = (host) => {
253
280
  * Host (400), a URL replaces the target, `false` keeps it. Deno hands the target over as sent, so
254
281
  * its dot segments are resolved; so does Bun when HTTP/1.0 leaves the Host out, and its route table
255
282
  * matched `/files/..` raw. Such a request is served as `localhost`, as Node and Deno serve it.
256
- * `headerOf`, not `req.headers`: on Deno that reads the Host alone instead of building every
257
- * header.
283
+ * `headerOf`, not `req.headers`: on Deno it reads Host only when `validateHost` is set, and dot
284
+ * segments are resolved only when `normalizeTarget` is. On Bun only a target that needs resolving
285
+ * is checked unless `validateHost` is set.
258
286
  */
259
- const requestTarget = (req) => {
260
- if (!rawRequestTargets && !unparsedBunTargets)
287
+ const requestTarget = (req, validateHost, normalizeTarget) => {
288
+ if (rawRequestTargets) {
289
+ const host = validateHost ? headerOf(req, "host") : null;
290
+ if (host !== null && invalidHost(host))
291
+ return true;
292
+ if (!normalizeTarget || !needsResolving(req.url))
293
+ return false;
294
+ // Deno can preserve an unparseable absolute URL when Host is malformed. Do not route its
295
+ // unresolved dot segments through the raw-target fallback.
296
+ if (!URL.canParse(req.url, "http://localhost"))
297
+ return true;
298
+ return resolveDotSegments(req.url);
299
+ }
300
+ if (!(unparsedBunTargets && (validateHost || needsResolving(req.url))))
261
301
  return false;
262
302
  const host = headerOf(req, "host");
263
303
  if (host !== null && invalidHost(host)) {
264
- if (rawRequestTargets)
265
- return true;
266
304
  // Bun served it anyway: it could not read the Host, so it left the target unparsed, or it
267
305
  // built the URL on it - sent twice (joined with ", ", the URL on the first; RFC 9112 section
268
- // 3.2 answers 400), percent-encoded (`%61.example` routed as `a.example`), or with a
269
- // sub-delimiter (`a!b`). A request built in process keeps the URL its caller built, unless Bun
306
+ // 3.2 answers 400), percent-encoded (%61.example routed as a.example), or with a
307
+ // sub-delimiter (a!b). A request built in process keeps the URL its caller built, unless Bun
270
308
  // would have built it so.
271
309
  const routed = URL.parse(req.url)?.host;
272
310
  return routed === undefined || URL.parse(`//${host.split(", ")[0]}`, req.url)?.host === routed;
273
311
  }
274
- if (rawRequestTargets)
275
- return needsResolving(req.url) && resolveDotSegments(req.url);
276
312
  // A request built in process carries an absolute URL whatever its headers say.
277
313
  return (host === null &&
278
314
  req.url.charCodeAt(0) === 47 /* / */ &&
@@ -306,7 +342,8 @@ function mountPriorityOf(priority) {
306
342
  function compareMounts(a, b) {
307
343
  return b.priority - a.priority || b.path.length - a.path.length || a.order - b.order;
308
344
  }
309
- /** Rebase a mounted request's path without losing the authentication mark used by child caches. */
345
+ /** Rebase a mounted request's path without losing the authentication mark used by child caches, or
346
+ * its request locals. */
310
347
  function stripMountPrefix(request, prefix) {
311
348
  if (prefix === "/")
312
349
  return request;
@@ -316,11 +353,39 @@ function stripMountPrefix(request, prefix) {
316
353
  const childRequest = new Request(url.href, request);
317
354
  if (isAuthenticatedRequest(request))
318
355
  markAuthenticatedRequest(childRequest);
356
+ carryToCopy(request, childRequest);
319
357
  return childRequest;
320
358
  }
321
359
  function responseSet(ctx) {
322
360
  return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS;
323
361
  }
362
+ /** The error hooks of the many fused routes that have none, shared so each does not allocate its own. */
363
+ const NO_ERROR_HOOKS = [];
364
+ const WEB_STAGES = new WeakMap();
365
+ const NODE_STAGES = new WeakMap();
366
+ const hookCount = (hooks) => hooks.length > 1 ? 2 : hooks.length;
367
+ /**
368
+ * The fused stage chain of a hook shape, built once per server and handed to every route with the same
369
+ * number of derive, before and after hooks (none, one or several of each). The chain reads a route's
370
+ * hooks and handler from the `FusedHooks` it is called with, so sharing it changes no answer.
371
+ */
372
+ function sharedStages(cache, owner, route, build) {
373
+ const derives = hookCount(route.derives);
374
+ const befores = hookCount(route.befores);
375
+ const afters = hookCount(route.afters);
376
+ const key = derives * 9 + befores * 3 + afters;
377
+ let chains = cache.get(owner);
378
+ if (chains === undefined) {
379
+ chains = new Map();
380
+ cache.set(owner, chains);
381
+ }
382
+ let stages = chains.get(key);
383
+ if (stages === undefined) {
384
+ stages = build(derives, befores, afters);
385
+ chains.set(key, stages);
386
+ }
387
+ return stages;
388
+ }
324
389
  function isContextlessNoArgArrow(handler) {
325
390
  if (handler.length !== 0)
326
391
  return false;
@@ -653,6 +718,8 @@ export class Server {
653
718
  formOrigins;
654
719
  /** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
655
720
  trustBodyFraming;
721
+ validateHost;
722
+ normalizeTarget;
656
723
  wsMaxPayloadBytes;
657
724
  requestTimeoutMs;
658
725
  wsUpgradeTimeoutMs;
@@ -785,6 +852,8 @@ export class Server {
785
852
  this.protoPoisoning = options.protoPoisoning ?? "reject";
786
853
  this.formOrigins = options.formOrigins;
787
854
  this.trustBodyFraming = options.trustBodyFraming ?? false;
855
+ this.validateHost = options.validateHost ?? false;
856
+ this.normalizeTarget = options.normalizeTarget ?? false;
788
857
  const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes;
789
858
  assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes");
790
859
  this.maxBodyBytes = maxBodyBytes;
@@ -1639,39 +1708,42 @@ export class Server {
1639
1708
  handler = this.idempotencyRuntime.wrapHandler(handler);
1640
1709
  }
1641
1710
  const { bare, fusedQuery, fusedBody } = lanes;
1642
- // Fused lifecycle lanes: derive + before (with or without an after), and body + the same shape.
1643
- // The lane selectors in `selectRouteLanes` are exhaustive about the lifecycleHookLane /
1644
- // body-derive-before-after eligibility, so we only need to check the lane and the hook counts.
1645
- const lifecycleHookLane = lanes.lifecycleHookLane;
1646
- const isFusedLifecycle = lifecycleHookLane !== undefined && this.derives.length === 1;
1647
- const isFusedBodyLifecycle = lanes.fusedLane === "body-derive-before" || lanes.fusedLane === "body-derive-before-after";
1648
- // The generic runners below are the Node-direct twins of the fused Web lanes built further
1649
- // down: same single-frame lifecycle, finalizing through the caller's own `finalize` instead of
1650
- // building a `Response`, so the `!webFast` dispatcher prefers them over the generic program
1651
- // exactly as it prefers the body-only runner. Only the original body-only lane may feed the Web
1652
- // wrapper; every other runner here preserves its derive/before/after stages.
1711
+ // `selectRouteLanes` admits the fused hook lanes only for derive, before and after hooks, so the
1712
+ // three arrays below are the route's whole lifecycle.
1713
+ const fusedLifecycle = lanes.fusedLane === "hooks" || lanes.fusedLane === "body-hooks";
1714
+ // The fused chains keep the arrays they are built from, and `beforeHandle()` inserts into the live
1715
+ // ones ahead of `beforeHandler()` guards, so the entry and the fused chains share one snapshot of
1716
+ // this route's.
1717
+ const derivesSnapshot = [...this.derives];
1718
+ const beforesSnapshot = [...beforeHandle];
1719
+ const aftersSnapshot = [...afterHandle];
1720
+ const onErrorSnapshot = onError.length === 0 ? NO_ERROR_HOOKS : [...onError];
1721
+ const fusedHooks = fusedLifecycle
1722
+ ? {
1723
+ handler: handler,
1724
+ derives: derivesSnapshot,
1725
+ befores: beforesSnapshot,
1726
+ afters: aftersSnapshot,
1727
+ errors: onErrorSnapshot,
1728
+ decorations: hasDecorations ? routeDecorations : undefined,
1729
+ }
1730
+ : undefined;
1731
+ // The Node-direct dispatcher (`!webFast`) prefers these runners over the generic program. Only the
1732
+ // body-only runner also feeds the Web wrapper; the lifecycle lanes build their own Web closure.
1653
1733
  const fusedBodyRunner = fusedBody
1654
1734
  ? this.buildFusedBodyRunner(handler, schema?.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? UNLIMITED_BODY_BYTES)
1655
- : isFusedBodyLifecycle
1656
- ? this.buildFusedBodyDeriveBeforeAfterRunner(handler, this.derives[0], beforeHandle[0], afterHandle[0], schema?.body, bodyLimit ?? UNLIMITED_BODY_BYTES)
1657
- : isFusedLifecycle && lifecycleHookLane === "derive-before"
1658
- ? this.buildFusedDeriveBeforeRunner(handler, this.derives[0], beforeHandle[0], undefined, schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1659
- : isFusedLifecycle && lifecycleHookLane === "derive-before-after"
1660
- ? this.buildFusedDeriveBeforeRunner(handler, this.derives[0], beforeHandle[0], afterHandle[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1661
- : undefined;
1735
+ : fusedHooks !== undefined
1736
+ ? this.buildFusedLifecycleRunner(fusedHooks, schema?.query, schema?.body, bodyLimit ?? UNLIMITED_BODY_BYTES)
1737
+ : undefined;
1662
1738
  const fusedWeb = bare && this.aroundHooks.length === 0
1663
1739
  ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ?? UNLIMITED_BODY_BYTES)
1664
1740
  : fusedQuery
1665
1741
  ? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1666
1742
  : fusedBody
1667
1743
  ? this.buildFusedBodyWeb(fusedBodyRunner)
1668
- : isFusedBodyLifecycle
1669
- ? this.buildFusedBodyDeriveBeforeAfter(handler, this.derives[0], beforeHandle[0], afterHandle[0], schema?.body, bodyLimit ?? UNLIMITED_BODY_BYTES)
1670
- : isFusedLifecycle && lifecycleHookLane === "derive-before"
1671
- ? this.buildFusedDeriveBefore(handler, this.derives[0], beforeHandle[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1672
- : isFusedLifecycle && lifecycleHookLane === "derive-before-after"
1673
- ? this.buildFusedDeriveBeforeAfter(handler, this.derives[0], beforeHandle[0], afterHandle[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1674
- : undefined;
1744
+ : fusedHooks !== undefined
1745
+ ? this.buildFusedLifecycleWeb(fusedHooks, schema?.query, schema?.body, bodyLimit ?? UNLIMITED_BODY_BYTES)
1746
+ : undefined;
1675
1747
  const program = compileRouteProgram({
1676
1748
  schema,
1677
1749
  handler: handler,
@@ -1703,13 +1775,13 @@ export class Server {
1703
1775
  idempotent,
1704
1776
  ledgered,
1705
1777
  responseContract,
1706
- derives: [...this.derives],
1778
+ derives: derivesSnapshot,
1707
1779
  authStages: [...compiled.authStages],
1708
1780
  decorations: routeDecorations,
1709
1781
  hasDecorations,
1710
- beforeHandle: [...beforeHandle],
1711
- afterHandle: [...afterHandle],
1712
- onError: [...onError],
1782
+ beforeHandle: beforesSnapshot,
1783
+ afterHandle: aftersSnapshot,
1784
+ onError: onErrorSnapshot,
1713
1785
  lifecycleHookLane: lanes.lifecycleHookLane,
1714
1786
  around: [...this.aroundHooks],
1715
1787
  execution,
@@ -2110,49 +2182,29 @@ export class Server {
2110
2182
  const lane = entry.execution.fusedLane;
2111
2183
  // Type-erase the lane so each `===` branch sees the full union, not the narrowed remainder.
2112
2184
  const laneName = lane;
2113
- // Body+lifecycle routes have a fused Web renderer, and - unlike before - a generic fused
2114
- // runner too: it preserves derive/before/after (it IS the lifecycle, single-framed), so the
2115
- // Node-direct dispatcher's preference for the generic slot over the execution plan stays
2116
- // semantics-preserving. Only the original body-only lane may feed the Web wrapper below.
2117
- const derive = entry.derives[0];
2118
- const before = entry.beforeHandle[0];
2119
- const after = entry.afterHandle[0];
2185
+ const lifecycle = laneName === "hooks" || laneName === "body-hooks";
2186
+ const hooks = lifecycle
2187
+ ? {
2188
+ handler: entry.handler,
2189
+ derives: entry.derives,
2190
+ befores: entry.beforeHandle,
2191
+ afters: entry.afterHandle,
2192
+ errors: entry.onError,
2193
+ decorations: entry.hasDecorations ? entry.decorations : undefined,
2194
+ }
2195
+ : undefined;
2120
2196
  const fusedBody = laneName === "body"
2121
2197
  ? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
2122
- : laneName === "body-derive-before" && derive !== undefined && before !== undefined
2123
- ? this.buildFusedBodyDeriveBeforeAfterRunner(entry.handler, derive, before, undefined, entry.schema?.body, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
2124
- : laneName === "body-derive-before-after" &&
2125
- derive !== undefined &&
2126
- before !== undefined &&
2127
- after !== undefined
2128
- ? this.buildFusedBodyDeriveBeforeAfterRunner(entry.handler, derive, before, after, entry.schema?.body, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
2129
- : laneName === "derive-before" && derive !== undefined && before !== undefined
2130
- ? this.buildFusedDeriveBeforeRunner(entry.handler, derive, before, undefined, entry.schema?.query, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
2131
- : laneName === "derive-before-after" &&
2132
- derive !== undefined &&
2133
- before !== undefined &&
2134
- after !== undefined
2135
- ? this.buildFusedDeriveBeforeRunner(entry.handler, derive, before, after, entry.schema?.query, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
2136
- : undefined;
2198
+ : hooks !== undefined
2199
+ ? this.buildFusedLifecycleRunner(hooks, entry.schema?.query, entry.schema?.body, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
2200
+ : undefined;
2137
2201
  const fusedWeb = laneName === "body"
2138
2202
  ? this.buildFusedBodyWeb(fusedBody)
2139
2203
  : laneName === "query"
2140
2204
  ? this.buildFusedQueryWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, entry.schema?.query, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
2141
- : laneName === "body-derive-before" && derive !== undefined && before !== undefined
2142
- ? this.buildFusedBodyDeriveBeforeAfter(entry.handler, derive, before, undefined, entry.schema?.body, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
2143
- : laneName === "body-derive-before-after" &&
2144
- derive !== undefined &&
2145
- before !== undefined &&
2146
- after !== undefined
2147
- ? this.buildFusedBodyDeriveBeforeAfter(entry.handler, derive, before, after, entry.schema?.body, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
2148
- : laneName === "derive-before" && derive !== undefined && before !== undefined
2149
- ? this.buildFusedDeriveBefore(entry.handler, derive, before, entry.schema?.query, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
2150
- : laneName === "derive-before-after" &&
2151
- derive !== undefined &&
2152
- before !== undefined &&
2153
- after !== undefined
2154
- ? this.buildFusedDeriveBeforeAfter(entry.handler, derive, before, after, entry.schema?.query, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
2155
- : this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler), entry.bodyLimit ?? UNLIMITED_BODY_BYTES);
2205
+ : hooks !== undefined
2206
+ ? this.buildFusedLifecycleWeb(hooks, entry.schema?.query, entry.schema?.body, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
2207
+ : this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler), entry.bodyLimit ?? UNLIMITED_BODY_BYTES);
2156
2208
  return {
2157
2209
  ...route,
2158
2210
  entry: {
@@ -2185,7 +2237,7 @@ export class Server {
2185
2237
  fetch(req, platform) {
2186
2238
  // Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
2187
2239
  // Route what the parsed URL says, so `/users/../admin` is `/admin` on every runtime.
2188
- const target = requestTarget(req);
2240
+ const target = requestTarget(req, this.validateHost, this.normalizeTarget);
2189
2241
  if (target === true)
2190
2242
  return jsonError(400, "invalid_host");
2191
2243
  if (target !== false) {
@@ -2235,6 +2287,23 @@ export class Server {
2235
2287
  }
2236
2288
  return this.admitGated(requestOf(source), () => this.fetchMatchedInner(source, entry, params, platform));
2237
2289
  }
2290
+ /** {@link fetchMatched} for an app whose `onRequest` hooks run before the route does: the hooks see
2291
+ * the request first, and the route Bun matched runs unless one of them replaced the request. */
2292
+ fetchMatchedHooked(source, entry, params, platform) {
2293
+ if (this.capacityGate === undefined) {
2294
+ return this.fetchMatchedHookedInner(source, entry, params, platform);
2295
+ }
2296
+ return this.admitGated(source, () => this.fetchMatchedHookedInner(source, entry, params, platform));
2297
+ }
2298
+ fetchMatchedHookedInner(source, entry, params, platform) {
2299
+ const outcome = this.runWithOnRequest(source, this.trustedPlatform(source, platform), this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, true, { entry, params });
2300
+ if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
2301
+ return outcome;
2302
+ }
2303
+ return outcome instanceof Promise
2304
+ ? outcome.then((response) => this.applyOnResponseAndFinalize(response, this.takeResponseRequest(source)))
2305
+ : this.applyOnResponseAndFinalize(outcome, this.takeResponseRequest(source));
2306
+ }
2238
2307
  fetchMatchedInner(source, entry, params, platform) {
2239
2308
  const outcome = this.runMatched(source, platform, entry, params, undefined, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, true);
2240
2309
  if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
@@ -2594,7 +2663,7 @@ export class Server {
2594
2663
  // The adapter answers a pass by handing this same object to `fetch`.
2595
2664
  const received = req;
2596
2665
  // The handshake routes the path `fetch` would: Host checked, dot segments resolved (see `fetch`).
2597
- const target = requestTarget(req);
2666
+ const target = requestTarget(req, this.validateHost, this.normalizeTarget);
2598
2667
  if (target === true) {
2599
2668
  return { kind: "reject", response: this.wrapWebResponse(jsonError(400, "invalid_host")) };
2600
2669
  }
@@ -2783,10 +2852,10 @@ export class Server {
2783
2852
  /** Bun `fetch` when WS routes exist: try a WS upgrade first, else run the normal HTTP lifecycle.
2784
2853
  * `undefined` ⇒ Bun owns the upgraded socket; a `Response` ⇒ a normal reply or a rejected upgrade.
2785
2854
  * (The socket dispatch itself lives in `ws-bun.ts`, loaded via `@nifrajs/core/ws`.) */
2786
- bunFetchWithWebSocket(req, server) {
2855
+ bunFetchWithWebSocket(req, server, setIdleTimeout) {
2787
2856
  const handle = (o) => {
2788
2857
  if (o.kind === "pass")
2789
- return this.fetch(req, bunPeerPlatform(server, req));
2858
+ return this.fetch(req, new BunPeerPlatform(server, req, setIdleTimeout));
2790
2859
  if (o.kind === "reject")
2791
2860
  return o.response;
2792
2861
  // A copy: the Bun socket keeps per-connection state on it, and a hand-built outcome may be shared.
@@ -2794,7 +2863,7 @@ export class Server {
2794
2863
  ? undefined
2795
2864
  : jsonError(426, "upgrade_required");
2796
2865
  };
2797
- const outcome = this.resolveWebSocketUpgrade(req, bunPeerPlatform(server, req));
2866
+ const outcome = this.resolveWebSocketUpgrade(req, new BunPeerPlatform(server, req, setIdleTimeout));
2798
2867
  return outcome instanceof Promise ? outcome.then(handle) : handle(outcome);
2799
2868
  }
2800
2869
  /**
@@ -2895,7 +2964,7 @@ export class Server {
2895
2964
  * at ~13% of a realistic request. The first hook that returns a Promise hands the REMAINING
2896
2965
  * hooks to the async continuation; behavior is identical.
2897
2966
  */
2898
- runWithOnRequest(source, platform, finalize, wrapResponse, onTimeout, webFast) {
2967
+ runWithOnRequest(source, platform, finalize, wrapResponse, onTimeout, webFast, matched) {
2899
2968
  const hooks = this.onRequestHooks;
2900
2969
  const originalRequest = requestOf(source);
2901
2970
  // A Web Request is already the exact object visible to the hooks and the response walk. Only
@@ -2914,7 +2983,7 @@ export class Server {
2914
2983
  return this.onRequestFailure(err, current, platform, finalize, wrapResponse);
2915
2984
  }
2916
2985
  if (outcome instanceof Promise) {
2917
- return outcome.then((early) => this.continueOnRequest(early, i + 1, originalRequest, current, platform, finalize, wrapResponse, onTimeout, webFast), (err) => this.onRequestFailure(err, current, platform, finalize, wrapResponse));
2986
+ return outcome.then((early) => this.continueOnRequest(early, i + 1, originalRequest, current, platform, finalize, wrapResponse, onTimeout, webFast, matched), (err) => this.onRequestFailure(err, current, platform, finalize, wrapResponse));
2918
2987
  }
2919
2988
  if (outcome instanceof Request) {
2920
2989
  if (outcome !== seen)
@@ -2927,11 +2996,11 @@ export class Server {
2927
2996
  if (outcome !== undefined)
2928
2997
  return earlyAnswer(outcome, wrapResponse);
2929
2998
  }
2930
- return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast);
2999
+ return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast, current === originalRequest ? matched : undefined);
2931
3000
  }
2932
3001
  /** Async tail of {@link runWithOnRequest}: applies the first awaited hook's outcome, then runs
2933
3002
  * the remaining hooks (awaiting freely - we're already async here). */
2934
- async continueOnRequest(first, nextIndex, originalRequest, sourceAtAwait, platform, finalize, wrapResponse, onTimeout, webFast) {
3003
+ async continueOnRequest(first, nextIndex, originalRequest, sourceAtAwait, platform, finalize, wrapResponse, onTimeout, webFast, matched) {
2935
3004
  let current = sourceAtAwait;
2936
3005
  let seen = requestOf(sourceAtAwait);
2937
3006
  let early = first;
@@ -2959,7 +3028,7 @@ export class Server {
2959
3028
  }
2960
3029
  index++;
2961
3030
  }
2962
- return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast);
3031
+ return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast, current === originalRequest ? matched : undefined);
2963
3032
  }
2964
3033
  takeResponseRequest(source) {
2965
3034
  const tracked = this.responseSources.get(source);
@@ -3028,7 +3097,12 @@ export class Server {
3028
3097
  * (selected by its compiled plan), so a sync handler produces its result with zero promise allocations;
3029
3098
  * routes with validation/hooks keep the full compiled route program, unchanged.
3030
3099
  */
3031
- routeAndRun(source, platform, finalize, wrapResponse, onTimeout, webFast) {
3100
+ routeAndRun(source, platform, finalize, wrapResponse, onTimeout, webFast,
3101
+ // The route Bun's table already matched for this very request, which routing would find again.
3102
+ matched) {
3103
+ if (matched !== undefined) {
3104
+ return this.runMatched(source, platform, matched.entry, matched.params, undefined, finalize, wrapResponse, onTimeout, webFast);
3105
+ }
3032
3106
  // Native delimiter searches keep path/query splitting out of a character-by-character JS loop.
3033
3107
  // Keep the slices in locals instead of allocating the public helper's pathname/search pair.
3034
3108
  let pathname;
@@ -3518,53 +3592,244 @@ export class Server {
3518
3592
  };
3519
3593
  }
3520
3594
  /**
3521
- * Compile a route's `derive + before` lifecycle to a single closure: the per-request walk through
3522
- * `runSync` → `runHooksSync` is a stack of 3 frames and 2 `Object.assign`s for what is by far the
3523
- * most common middleware shape (auth/trace/id derivation, an auth/policy `beforeHandle`). Folding
3524
- * the same steps into one closure hits the same monomorphic inline-cache site as `buildFusedWeb`,
3525
- * and `Object.assign(ctx, deriveResult)` becomes one extra property write on the context's already
3526
- * hot hidden class. Behavior is byte-identical to the compiled route program: the same thrown-value
3527
- * contract (`Response` is control flow, anything else logs + 500), the same `c.set` semantics, the
3528
- * same `fusedRespond` for the rendered body. The `LifecycleExecutionLane` guarantee
3529
- * (`schema.params === undefined && schema.headers === undefined && derives === 1 &&
3530
- * beforeHandle === 1 && afterHandle === 0 && onError === 0 && !hasResponseContract &&
3531
- * !hasDecorations`) is enforced by `selectRouteLanes`; this lane is selected ONLY when all of those
3532
- * hold. A `query` schema (the route declares `{ query }` alongside the hooks) is validated FIRST,
3533
- * mirroring the compiled route program's stage order; the fused builder runs validate → derive →
3534
- * before → handler in one frame.
3595
+ * The stage chain a hook shape runs - derive, before, handler, after, respond - for the Web lane,
3596
+ * built once per server and shared by every route with the same number of hooks in each phase (none,
3597
+ * one or several). The chain reads a route's handler and hooks from its `FusedHooks` per request, so
3598
+ * a route's own closure (see {@link buildFusedLifecycleWeb}) holds only that record and its schemas,
3599
+ * and memory grows with the number of distinct shapes, not routes or hook functions.
3535
3600
  */
3536
- buildFusedDeriveBefore(handler, derive, before, querySchema, maxBodyBytes) {
3537
- return this.buildFusedDeriveBeforeAfter(handler, derive, before, undefined, querySchema, maxBodyBytes);
3538
- }
3539
- // Both hook variants share one registered chain; request contexts stay in arguments.
3540
- buildFusedDeriveBeforeAfter(handler, derive, before, after, querySchema, maxBodyBytes) {
3541
- const logError = (err, ctx) => this.bareError(err, ctx, (result, set) => this.wrapWebResponse(toResponse(result, set)), this.wrapWebResponse);
3542
- // Continuations capture route state once; each request supplies its own context.
3543
- const runAfter = (result, ctx) => {
3544
- if (after === undefined)
3545
- return this.respondFused(result, ctx, this.onResponseHooks.length === 0);
3546
- let transformed;
3601
+ fusedWebStages(deriveCount, beforeCount, afterCount) {
3602
+ let runAfter;
3603
+ if (afterCount === 0) {
3604
+ runAfter = (result, ctx, route) => this.respondHooks(result, ctx, route, this.onResponseHooks.length === 0);
3605
+ }
3606
+ else if (afterCount === 1) {
3607
+ runAfter = (result, ctx, route) => {
3608
+ let transformed;
3609
+ try {
3610
+ transformed = route.afters[0](result, ctx);
3611
+ }
3612
+ catch (err) {
3613
+ return this.fusedWebRouteFail(err, ctx, route);
3614
+ }
3615
+ if (transformed instanceof Promise) {
3616
+ return transformed.then((value) => this.respondHooks(value, ctx, route, this.onResponseHooks.length === 0), (err) => this.fusedWebRouteFail(err, ctx, route));
3617
+ }
3618
+ return this.respondHooks(transformed, ctx, route, this.onResponseHooks.length === 0);
3619
+ };
3620
+ }
3621
+ else {
3622
+ runAfter = (result, ctx, route, index) => {
3623
+ const lastAfter = route.afters.length - 1;
3624
+ for (;;) {
3625
+ let transformed;
3626
+ try {
3627
+ transformed = route.afters[index](result, ctx);
3628
+ }
3629
+ catch (err) {
3630
+ return this.fusedWebRouteFail(err, ctx, route);
3631
+ }
3632
+ if (transformed instanceof Promise) {
3633
+ return transformed.then((value) => index < lastAfter
3634
+ ? runAfter(value, ctx, route, index + 1)
3635
+ : this.respondHooks(value, ctx, route, this.onResponseHooks.length === 0), (err) => this.fusedWebRouteFail(err, ctx, route));
3636
+ }
3637
+ if (index === lastAfter) {
3638
+ return this.respondHooks(transformed, ctx, route, this.onResponseHooks.length === 0);
3639
+ }
3640
+ result = transformed;
3641
+ index++;
3642
+ }
3643
+ };
3644
+ }
3645
+ const runHandler = (ctx, route) => {
3646
+ let result;
3547
3647
  try {
3548
- transformed = after(result, ctx);
3648
+ result = route.handler(ctx);
3549
3649
  }
3550
3650
  catch (err) {
3551
- return logError(err, ctx);
3651
+ return this.fusedWebRouteFail(err, ctx, route);
3552
3652
  }
3553
- if (transformed instanceof Promise) {
3554
- return transformed.then((value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
3653
+ if (result instanceof Promise) {
3654
+ return result.then((value) => runAfter(value, ctx, route, 0), (err) => this.fusedWebRouteFail(err, ctx, route));
3555
3655
  }
3556
- return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0);
3656
+ return runAfter(result, ctx, route, 0);
3557
3657
  };
3658
+ let runBefore;
3659
+ if (beforeCount === 0) {
3660
+ runBefore = runHandler;
3661
+ }
3662
+ else if (beforeCount === 1) {
3663
+ runBefore = (ctx, route) => {
3664
+ let early;
3665
+ try {
3666
+ early = route.befores[0](ctx);
3667
+ }
3668
+ catch (err) {
3669
+ return this.fusedWebRouteFail(err, ctx, route);
3670
+ }
3671
+ if (early instanceof Promise) {
3672
+ return early.then((settled) => settled === undefined
3673
+ ? runHandler(ctx, route, 0)
3674
+ : this.respondHooks(settled, ctx, route), (err) => this.fusedWebRouteFail(err, ctx, route));
3675
+ }
3676
+ if (early === undefined)
3677
+ return runHandler(ctx, route, 0);
3678
+ return this.respondHooks(early, ctx, route);
3679
+ };
3680
+ }
3681
+ else {
3682
+ runBefore = (ctx, route, index) => {
3683
+ const lastBefore = route.befores.length - 1;
3684
+ for (;;) {
3685
+ let early;
3686
+ try {
3687
+ early = route.befores[index](ctx);
3688
+ }
3689
+ catch (err) {
3690
+ return this.fusedWebRouteFail(err, ctx, route);
3691
+ }
3692
+ if (early instanceof Promise) {
3693
+ return early.then((settled) => {
3694
+ if (settled !== undefined)
3695
+ return this.respondHooks(settled, ctx, route);
3696
+ return index < lastBefore
3697
+ ? runBefore(ctx, route, index + 1)
3698
+ : runHandler(ctx, route, 0);
3699
+ }, (err) => this.fusedWebRouteFail(err, ctx, route));
3700
+ }
3701
+ if (early !== undefined)
3702
+ return this.respondHooks(early, ctx, route);
3703
+ if (index === lastBefore)
3704
+ return runHandler(ctx, route, 0);
3705
+ index++;
3706
+ }
3707
+ };
3708
+ }
3709
+ let runDerive;
3710
+ if (deriveCount === 0) {
3711
+ runDerive = runBefore;
3712
+ }
3713
+ else if (deriveCount === 1) {
3714
+ runDerive = (ctx, route) => {
3715
+ let derived;
3716
+ try {
3717
+ derived = route.derives[0](ctx);
3718
+ }
3719
+ catch (err) {
3720
+ return this.fusedWebRouteFail(err, ctx, route);
3721
+ }
3722
+ if (derived instanceof Promise) {
3723
+ return derived.then((settled) => {
3724
+ if (isResponseResult(settled) || settled instanceof Response) {
3725
+ return this.respondHooks(settled, ctx, route);
3726
+ }
3727
+ try {
3728
+ Object.assign(ctx, settled);
3729
+ }
3730
+ catch (err) {
3731
+ return this.fusedWebRouteFail(err, ctx, route);
3732
+ }
3733
+ return runBefore(ctx, route, 0);
3734
+ }, (err) => this.fusedWebRouteFail(err, ctx, route));
3735
+ }
3736
+ if (isResponseResult(derived) || derived instanceof Response) {
3737
+ return this.respondHooks(derived, ctx, route);
3738
+ }
3739
+ try {
3740
+ Object.assign(ctx, derived);
3741
+ }
3742
+ catch (err) {
3743
+ return this.fusedWebRouteFail(err, ctx, route);
3744
+ }
3745
+ return runBefore(ctx, route, 0);
3746
+ };
3747
+ }
3748
+ else {
3749
+ runDerive = (ctx, route, index) => {
3750
+ const lastDerive = route.derives.length - 1;
3751
+ for (;;) {
3752
+ let derived;
3753
+ try {
3754
+ derived = route.derives[index](ctx);
3755
+ }
3756
+ catch (err) {
3757
+ return this.fusedWebRouteFail(err, ctx, route);
3758
+ }
3759
+ if (derived instanceof Promise) {
3760
+ return derived.then((settled) => {
3761
+ if (isResponseResult(settled) || settled instanceof Response) {
3762
+ return this.respondHooks(settled, ctx, route);
3763
+ }
3764
+ try {
3765
+ Object.assign(ctx, settled);
3766
+ }
3767
+ catch (err) {
3768
+ return this.fusedWebRouteFail(err, ctx, route);
3769
+ }
3770
+ return index < lastDerive
3771
+ ? runDerive(ctx, route, index + 1)
3772
+ : runBefore(ctx, route, 0);
3773
+ }, (err) => this.fusedWebRouteFail(err, ctx, route));
3774
+ }
3775
+ if (isResponseResult(derived) || derived instanceof Response) {
3776
+ return this.respondHooks(derived, ctx, route);
3777
+ }
3778
+ try {
3779
+ Object.assign(ctx, derived);
3780
+ }
3781
+ catch (err) {
3782
+ return this.fusedWebRouteFail(err, ctx, route);
3783
+ }
3784
+ if (index === lastDerive)
3785
+ return runBefore(ctx, route, 0);
3786
+ index++;
3787
+ }
3788
+ };
3789
+ }
3790
+ return runDerive;
3791
+ }
3792
+ /**
3793
+ * Compile a fused lifecycle route's entry closure: parse (body) → validate → the shared stage chain
3794
+ * of its hook shape (derive → before → handler → after → respond, see {@link fusedWebStages}) in a
3795
+ * single frame, where the generic program walks staged hook loops. `selectRouteLanes` admits only
3796
+ * derive, before and after hooks - no params or headers schema, onError, around, decorations,
3797
+ * response contract or recovery hook, and never a query schema beside a body schema - so the behavior
3798
+ * is the compiled program's: the same stage order, the same thrown-value contract (`Response` is
3799
+ * control flow, anything else logs and 500s), the same `c.set` semantics and `fusedRespond` render.
3800
+ * The route's own closure holds only its `FusedHooks` and schemas and takes the request's context as
3801
+ * an argument, so a request allocates no step closures.
3802
+ */
3803
+ /**
3804
+ * Keep the common one-derive/one-before shape bound to its hooks. Inline its synchronous path to
3805
+ * avoid stage calls; retain helper closures only for promise continuations.
3806
+ */
3807
+ buildFusedLifecycleWebSingle(handler, derive, before, after, maxBodyBytes) {
3808
+ const runAfter = after === undefined
3809
+ ? (result, ctx) => this.respondFused(result, ctx, this.onResponseHooks.length === 0)
3810
+ : (result, ctx) => {
3811
+ let transformed;
3812
+ try {
3813
+ transformed = after(result, ctx);
3814
+ }
3815
+ catch (err) {
3816
+ return this.fusedWebFail(err, ctx);
3817
+ }
3818
+ if (transformed instanceof Promise) {
3819
+ return transformed.then((value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0), (err) => this.fusedWebFail(err, ctx));
3820
+ }
3821
+ return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0);
3822
+ };
3558
3823
  const runHandler = (ctx) => {
3559
3824
  let result;
3560
3825
  try {
3561
3826
  result = handler(ctx);
3562
3827
  }
3563
3828
  catch (err) {
3564
- return logError(err, ctx);
3829
+ return this.fusedWebFail(err, ctx);
3565
3830
  }
3566
3831
  if (result instanceof Promise) {
3567
- return result.then((value) => runAfter(value, ctx), (err) => logError(err, ctx));
3832
+ return result.then((value) => runAfter(value, ctx), (err) => this.fusedWebFail(err, ctx));
3568
3833
  }
3569
3834
  return runAfter(result, ctx);
3570
3835
  };
@@ -3574,461 +3839,463 @@ export class Server {
3574
3839
  early = before(ctx);
3575
3840
  }
3576
3841
  catch (err) {
3577
- return logError(err, ctx);
3842
+ return this.fusedWebFail(err, ctx);
3578
3843
  }
3579
3844
  if (early instanceof Promise) {
3580
- return early.then((settled) => {
3581
- if (settled === undefined)
3582
- return runHandler(ctx);
3583
- return this.respondFused(settled, ctx);
3584
- }, (err) => logError(err, ctx));
3845
+ return early.then((settled) => (settled === undefined ? runHandler(ctx) : this.respondFused(settled, ctx)), (err) => this.fusedWebFail(err, ctx));
3585
3846
  }
3586
3847
  if (early === undefined)
3587
3848
  return runHandler(ctx);
3588
3849
  return this.respondFused(early, ctx);
3589
3850
  };
3590
- const runDerive = (ctx) => {
3851
+ return (source, params, search, signal, budget, platform, nativeContext) => {
3852
+ const ctx = nativeContext
3853
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3854
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
3591
3855
  let derived;
3592
3856
  try {
3593
3857
  derived = derive(ctx);
3594
3858
  }
3595
3859
  catch (err) {
3596
- return logError(err, ctx);
3860
+ return this.fusedWebFail(err, ctx);
3597
3861
  }
3598
3862
  if (derived instanceof Promise) {
3599
3863
  return derived.then((settled) => {
3600
- if (isResponseResult(settled) || settled instanceof Response) {
3864
+ if (isResponseResult(settled) || settled instanceof Response)
3601
3865
  return this.respondFused(settled, ctx);
3866
+ try {
3867
+ Object.assign(ctx, settled);
3868
+ }
3869
+ catch (err) {
3870
+ return this.fusedWebFail(err, ctx);
3602
3871
  }
3603
- Object.assign(ctx, settled);
3604
3872
  return runBefore(ctx);
3605
- }, (err) => logError(err, ctx));
3873
+ }, (err) => this.fusedWebFail(err, ctx));
3606
3874
  }
3607
- if (isResponseResult(derived) || derived instanceof Response) {
3875
+ if (isResponseResult(derived) || derived instanceof Response)
3608
3876
  return this.respondFused(derived, ctx);
3877
+ let early;
3878
+ let result;
3879
+ let transformed;
3880
+ let handlerPromise;
3881
+ try {
3882
+ Object.assign(ctx, derived);
3883
+ early = before(ctx);
3884
+ if (early === undefined) {
3885
+ result = handler(ctx);
3886
+ if (result instanceof Promise)
3887
+ handlerPromise = result;
3888
+ else if (after !== undefined)
3889
+ transformed = after(result, ctx);
3890
+ }
3891
+ }
3892
+ catch (err) {
3893
+ return this.fusedWebFail(err, ctx);
3894
+ }
3895
+ if (early instanceof Promise) {
3896
+ return early.then((settled) => (settled === undefined ? runHandler(ctx) : this.respondFused(settled, ctx)), (err) => this.fusedWebFail(err, ctx));
3609
3897
  }
3610
- Object.assign(ctx, derived);
3611
- return runBefore(ctx);
3898
+ if (early !== undefined)
3899
+ return this.respondFused(early, ctx);
3900
+ if (handlerPromise !== undefined) {
3901
+ return handlerPromise.then((value) => after === undefined
3902
+ ? this.respondFused(value, ctx, this.onResponseHooks.length === 0)
3903
+ : runAfter(value, ctx), (err) => this.fusedWebFail(err, ctx));
3904
+ }
3905
+ if (after === undefined)
3906
+ return this.respondFused(result, ctx, this.onResponseHooks.length === 0);
3907
+ if (transformed instanceof Promise) {
3908
+ return transformed.then((value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0), (err) => this.fusedWebFail(err, ctx));
3909
+ }
3910
+ return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0);
3612
3911
  };
3912
+ }
3913
+ buildFusedLifecycleWeb(route, querySchema, bodySchema, maxBodyBytes) {
3914
+ if (querySchema === undefined &&
3915
+ bodySchema === undefined &&
3916
+ route.errors.length === 0 &&
3917
+ route.decorations === undefined &&
3918
+ route.derives.length === 1 &&
3919
+ route.befores.length === 1 &&
3920
+ route.afters.length <= 1) {
3921
+ const derive = route.derives[0];
3922
+ const before = route.befores[0];
3923
+ if (derive !== undefined && before !== undefined) {
3924
+ return this.buildFusedLifecycleWebSingle(route.handler, derive, before, route.afters[0], maxBodyBytes);
3925
+ }
3926
+ }
3927
+ const runDerive = sharedStages(WEB_STAGES, this, route, (d, b, a) => this.fusedWebStages(d, b, a));
3928
+ const decorations = route.decorations;
3929
+ if (bodySchema !== undefined) {
3930
+ const onParsed = (parsed, ctx) => {
3931
+ let validation;
3932
+ try {
3933
+ validation = bodySchema["~standard"].validate(parsed);
3934
+ }
3935
+ catch (err) {
3936
+ return this.fusedWebRouteFail(err, ctx, route);
3937
+ }
3938
+ if (validation instanceof Promise) {
3939
+ return validation.then((settled) => {
3940
+ if (settled.issues !== undefined)
3941
+ return this.wrapWebResponse(plainValidationError(settled.issues));
3942
+ ctx.body = settled.value;
3943
+ return runDerive(ctx, route, 0);
3944
+ }, (err) => this.fusedWebRouteFail(err, ctx, route));
3945
+ }
3946
+ if (validation.issues !== undefined)
3947
+ return this.wrapWebResponse(plainValidationError(validation.issues));
3948
+ ctx.body = validation.value;
3949
+ return runDerive(ctx, route, 0);
3950
+ };
3951
+ return (source, params, search, signal, budget, platform, nativeContext) => {
3952
+ const ctx = nativeContext
3953
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3954
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
3955
+ if (decorations !== undefined)
3956
+ Object.assign(ctx, decorations);
3957
+ return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, (parsed) => onParsed(parsed, ctx), this.wrapWebResponse, (err) => this.fusedWebRouteFail(err, ctx, route), bodySchema, this.formOrigins);
3958
+ };
3959
+ }
3613
3960
  return (source, params, search, signal, budget, platform, nativeContext) => {
3614
3961
  const ctx = nativeContext
3615
3962
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3616
3963
  : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
3964
+ if (decorations !== undefined)
3965
+ Object.assign(ctx, decorations);
3617
3966
  if (querySchema === undefined)
3618
- return runDerive(ctx);
3967
+ return runDerive(ctx, route, 0);
3619
3968
  let validation;
3620
3969
  try {
3621
3970
  validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
3622
3971
  }
3623
3972
  catch (err) {
3624
- return logError(err, ctx);
3973
+ return this.fusedWebRouteFail(err, ctx, route);
3625
3974
  }
3626
3975
  if (validation instanceof Promise) {
3627
3976
  return validation.then((settled) => {
3628
3977
  if (settled.issues !== undefined)
3629
3978
  return this.wrapWebResponse(plainValidationError(settled.issues));
3630
3979
  ctx.query = settled.value;
3631
- return runDerive(ctx);
3632
- }, (err) => logError(err, ctx));
3980
+ return runDerive(ctx, route, 0);
3981
+ }, (err) => this.fusedWebRouteFail(err, ctx, route));
3633
3982
  }
3634
3983
  if (validation.issues !== undefined)
3635
3984
  return this.wrapWebResponse(plainValidationError(validation.issues));
3636
3985
  ctx.query = validation.value;
3637
- return runDerive(ctx);
3986
+ return runDerive(ctx, route, 0);
3638
3987
  };
3639
3988
  }
3640
3989
  /**
3641
- * Body + lifecycle hooks fused: parse → validate → derive → before → handler → (after) in one
3642
- * closure, the same win `buildFusedBodyRunner` gives for the no-hooks body shape. The shape is
3643
- * `lifecycleHookLane` PLUS a body schema with no other validation conflicts; the lane is selected
3644
- * ONLY when `schema.body !== undefined && schema.query/params/headers === undefined &&
3645
- * schema.onValidationError === undefined && !defaultOnValidationError && !hasIdempotency &&
3646
- * !hasLedger && derives === 1 && beforeHandle === 1 && afterHandle <= 1 && onError === 0 &&
3647
- * !hasResponseContract && !hasDecorations && around === 0`. The same single `querySchema === undefined`
3648
- * trick used in the no-body lane lets the build path stay simple: a query + body route falls back
3649
- * to the generic program (this is rare in practice - validated query + validated body is usually a
3650
- * "search results" route with a middleware chain on top, and the generic lane is fine there).
3990
+ * The Node-direct twin of {@link fusedWebStages}: the same chain, finalizing through the caller's
3991
+ * own `finalize`, shared by every route with the same hook shape. A terminal `finalize` throw (an
3992
+ * unstringifiable handler return on the Node lane) is caught as `finishLifecycleContract` catches it.
3651
3993
  */
3652
- buildFusedBodyDeriveBeforeAfter(handler, derive, before, after, bodySchema, maxBodyBytes) {
3653
- const logError = (err, ctx) => this.bareError(err, ctx, (result, set) => this.wrapWebResponse(toResponse(result, set)), this.wrapWebResponse);
3654
- return (source, params, search, signal, budget, platform, nativeContext) => {
3655
- const ctx = nativeContext
3656
- ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3657
- : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
3658
- const runHandlerValidated = () => {
3659
- const runAfter = (result) => {
3660
- if (after === undefined) {
3661
- return this.respondFused(result, ctx, this.onResponseHooks.length === 0);
3662
- }
3994
+ fusedNodeStages(deriveCount, beforeCount, afterCount) {
3995
+ const finishResult = (result, ctx, route, finalize, wrapResponse) => {
3996
+ try {
3997
+ return finalize(result, responseSet(ctx), ctx);
3998
+ }
3999
+ catch (err) {
4000
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse);
4001
+ }
4002
+ };
4003
+ let runAfter;
4004
+ if (afterCount === 0) {
4005
+ runAfter = (result, ctx, route, finalize, wrapResponse) => finishResult(result, ctx, route, finalize, wrapResponse);
4006
+ }
4007
+ else if (afterCount === 1) {
4008
+ runAfter = (result, ctx, route, finalize, wrapResponse) => {
4009
+ let transformed;
4010
+ try {
4011
+ transformed = route.afters[0](result, ctx);
4012
+ }
4013
+ catch (err) {
4014
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse);
4015
+ }
4016
+ if (transformed instanceof Promise) {
4017
+ return transformed.then((value) => finishResult(value, ctx, route, finalize, wrapResponse), (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse));
4018
+ }
4019
+ return finishResult(transformed, ctx, route, finalize, wrapResponse);
4020
+ };
4021
+ }
4022
+ else {
4023
+ runAfter = (result, ctx, route, finalize, wrapResponse, index) => {
4024
+ const lastAfter = route.afters.length - 1;
4025
+ for (;;) {
3663
4026
  let transformed;
3664
4027
  try {
3665
- transformed = after(result, ctx);
4028
+ transformed = route.afters[index](result, ctx);
3666
4029
  }
3667
4030
  catch (err) {
3668
- return logError(err, ctx);
4031
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse);
3669
4032
  }
3670
4033
  if (transformed instanceof Promise) {
3671
- return transformed.then((value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
4034
+ return transformed.then((value) => index < lastAfter
4035
+ ? runAfter(value, ctx, route, finalize, wrapResponse, index + 1)
4036
+ : finishResult(value, ctx, route, finalize, wrapResponse), (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse));
3672
4037
  }
3673
- return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0);
3674
- };
3675
- const runHandler = () => {
3676
- let result;
3677
- try {
3678
- result = handler(ctx);
3679
- }
3680
- catch (err) {
3681
- return logError(err, ctx);
3682
- }
3683
- if (result instanceof Promise) {
3684
- return result.then((value) => runAfter(value), (err) => logError(err, ctx));
3685
- }
3686
- return runAfter(result);
3687
- };
3688
- const runBefore = () => {
4038
+ if (index === lastAfter)
4039
+ return finishResult(transformed, ctx, route, finalize, wrapResponse);
4040
+ result = transformed;
4041
+ index++;
4042
+ }
4043
+ };
4044
+ }
4045
+ const runHandler = (ctx, route, finalize, wrapResponse) => {
4046
+ let result;
4047
+ try {
4048
+ result = route.handler(ctx);
4049
+ }
4050
+ catch (err) {
4051
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse);
4052
+ }
4053
+ if (result instanceof Promise) {
4054
+ return result.then((value) => runAfter(value, ctx, route, finalize, wrapResponse, 0), (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse));
4055
+ }
4056
+ return runAfter(result, ctx, route, finalize, wrapResponse, 0);
4057
+ };
4058
+ let runBefore;
4059
+ if (beforeCount === 0) {
4060
+ runBefore = runHandler;
4061
+ }
4062
+ else if (beforeCount === 1) {
4063
+ runBefore = (ctx, route, finalize, wrapResponse) => {
4064
+ let early;
4065
+ try {
4066
+ early = route.befores[0](ctx);
4067
+ }
4068
+ catch (err) {
4069
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse);
4070
+ }
4071
+ if (early instanceof Promise) {
4072
+ return early.then((settled) => settled === undefined
4073
+ ? runHandler(ctx, route, finalize, wrapResponse, 0)
4074
+ : finishResult(settled, ctx, route, finalize, wrapResponse), (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse));
4075
+ }
4076
+ if (early === undefined)
4077
+ return runHandler(ctx, route, finalize, wrapResponse, 0);
4078
+ return finishResult(early, ctx, route, finalize, wrapResponse);
4079
+ };
4080
+ }
4081
+ else {
4082
+ runBefore = (ctx, route, finalize, wrapResponse, index) => {
4083
+ const lastBefore = route.befores.length - 1;
4084
+ for (;;) {
3689
4085
  let early;
3690
4086
  try {
3691
- early = before(ctx);
4087
+ early = route.befores[index](ctx);
3692
4088
  }
3693
4089
  catch (err) {
3694
- return logError(err, ctx);
4090
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse);
3695
4091
  }
3696
4092
  if (early instanceof Promise) {
3697
4093
  return early.then((settled) => {
3698
- if (settled === undefined)
3699
- return runHandler();
3700
- return this.respondFused(settled, ctx);
3701
- }, (err) => logError(err, ctx));
4094
+ if (settled !== undefined)
4095
+ return finishResult(settled, ctx, route, finalize, wrapResponse);
4096
+ return index < lastBefore
4097
+ ? runBefore(ctx, route, finalize, wrapResponse, index + 1)
4098
+ : runHandler(ctx, route, finalize, wrapResponse, 0);
4099
+ }, (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse));
3702
4100
  }
3703
- if (early === undefined)
3704
- return runHandler();
3705
- return this.respondFused(early, ctx);
3706
- };
4101
+ if (early !== undefined)
4102
+ return finishResult(early, ctx, route, finalize, wrapResponse);
4103
+ if (index === lastBefore)
4104
+ return runHandler(ctx, route, finalize, wrapResponse, 0);
4105
+ index++;
4106
+ }
4107
+ };
4108
+ }
4109
+ let runDerive;
4110
+ if (deriveCount === 0) {
4111
+ runDerive = runBefore;
4112
+ }
4113
+ else if (deriveCount === 1) {
4114
+ runDerive = (ctx, route, finalize, wrapResponse) => {
3707
4115
  let derived;
3708
4116
  try {
3709
- derived = derive(ctx);
4117
+ derived = route.derives[0](ctx);
3710
4118
  }
3711
4119
  catch (err) {
3712
- return logError(err, ctx);
4120
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse);
3713
4121
  }
3714
4122
  if (derived instanceof Promise) {
3715
4123
  return derived.then((settled) => {
3716
4124
  if (isResponseResult(settled) || settled instanceof Response) {
3717
- return this.respondFused(settled, ctx);
4125
+ return finishResult(settled, ctx, route, finalize, wrapResponse);
3718
4126
  }
3719
- Object.assign(ctx, settled);
3720
- return runBefore();
3721
- }, (err) => logError(err, ctx));
4127
+ try {
4128
+ Object.assign(ctx, settled);
4129
+ }
4130
+ catch (err) {
4131
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse);
4132
+ }
4133
+ return runBefore(ctx, route, finalize, wrapResponse, 0);
4134
+ }, (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse));
3722
4135
  }
3723
4136
  if (isResponseResult(derived) || derived instanceof Response) {
3724
- return this.respondFused(derived, ctx);
4137
+ return finishResult(derived, ctx, route, finalize, wrapResponse);
3725
4138
  }
3726
- Object.assign(ctx, derived);
3727
- return runBefore();
4139
+ try {
4140
+ Object.assign(ctx, derived);
4141
+ }
4142
+ catch (err) {
4143
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse);
4144
+ }
4145
+ return runBefore(ctx, route, finalize, wrapResponse, 0);
3728
4146
  };
3729
- const onParsed = (parsed) => {
4147
+ }
4148
+ else {
4149
+ runDerive = (ctx, route, finalize, wrapResponse, index) => {
4150
+ const lastDerive = route.derives.length - 1;
4151
+ for (;;) {
4152
+ let derived;
4153
+ try {
4154
+ derived = route.derives[index](ctx);
4155
+ }
4156
+ catch (err) {
4157
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse);
4158
+ }
4159
+ if (derived instanceof Promise) {
4160
+ return derived.then((settled) => {
4161
+ if (isResponseResult(settled) || settled instanceof Response) {
4162
+ return finishResult(settled, ctx, route, finalize, wrapResponse);
4163
+ }
4164
+ try {
4165
+ Object.assign(ctx, settled);
4166
+ }
4167
+ catch (err) {
4168
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse);
4169
+ }
4170
+ return index < lastDerive
4171
+ ? runDerive(ctx, route, finalize, wrapResponse, index + 1)
4172
+ : runBefore(ctx, route, finalize, wrapResponse, 0);
4173
+ }, (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse));
4174
+ }
4175
+ if (isResponseResult(derived) || derived instanceof Response) {
4176
+ return finishResult(derived, ctx, route, finalize, wrapResponse);
4177
+ }
4178
+ try {
4179
+ Object.assign(ctx, derived);
4180
+ }
4181
+ catch (err) {
4182
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse);
4183
+ }
4184
+ if (index === lastDerive)
4185
+ return runBefore(ctx, route, finalize, wrapResponse, 0);
4186
+ index++;
4187
+ }
4188
+ };
4189
+ }
4190
+ return runDerive;
4191
+ }
4192
+ /**
4193
+ * The Node-direct twin of {@link buildFusedLifecycleWeb}: the same steps, finalizing through the
4194
+ * caller's own `finalize` (the Node outcome on the direct lane) instead of building a `Response`, so
4195
+ * the `!webFast` dispatcher runs these routes in one frame rather than the generic program. A
4196
+ * terminal `finalize` throw (an unstringifiable handler return on the Node lane) is caught as
4197
+ * `finishLifecycleContract` catches it; the body's 415/400 renders take `wrapResponse` directly.
4198
+ */
4199
+ buildFusedLifecycleRunner(route, querySchema, bodySchema, maxBodyBytes) {
4200
+ const runDerive = sharedStages(NODE_STAGES, this, route, (d, b, a) => this.fusedNodeStages(d, b, a));
4201
+ const decorations = route.decorations;
4202
+ if (bodySchema !== undefined) {
4203
+ const onParsed = (parsed, ctx, finalize, wrapResponse) => {
3730
4204
  let validation;
3731
4205
  try {
3732
4206
  validation = bodySchema["~standard"].validate(parsed);
3733
4207
  }
3734
4208
  catch (err) {
3735
- return logError(err, ctx);
4209
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse);
3736
4210
  }
3737
4211
  if (validation instanceof Promise) {
3738
4212
  return validation.then((settled) => {
3739
4213
  if (settled.issues !== undefined)
3740
- return this.wrapWebResponse(plainValidationError(settled.issues));
4214
+ return wrapResponse(plainValidationError(settled.issues));
3741
4215
  ctx.body = settled.value;
3742
- return runHandlerValidated();
3743
- }, (err) => logError(err, ctx));
4216
+ return runDerive(ctx, route, finalize, wrapResponse, 0);
4217
+ }, (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse));
3744
4218
  }
3745
4219
  if (validation.issues !== undefined)
3746
- return this.wrapWebResponse(plainValidationError(validation.issues));
4220
+ return wrapResponse(plainValidationError(validation.issues));
3747
4221
  ctx.body = validation.value;
3748
- return runHandlerValidated();
4222
+ return runDerive(ctx, route, finalize, wrapResponse, 0);
3749
4223
  };
3750
- return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, onParsed, (response) => this.wrapWebResponse(response), (err) => Promise.resolve(logError(err, ctx)), bodySchema, this.formOrigins);
3751
- };
3752
- }
3753
- /**
3754
- * Generic (finalize/wrapResponse-parameterized) fused runner for the derive-before(-after) lane:
3755
- * the Node-direct counterpart of `buildFusedDeriveBefore` / `buildFusedDeriveBeforeAfter`.
3756
- *
3757
- * The Web fused builders above return a `Response`, so the Node dispatcher (`webFast === false`)
3758
- * cannot use them and a derive+before route falls back to the generic route program there - two
3759
- * staged loops, per-stage dispatch, and the async-continuation machinery - while Bun rides the
3760
- * single closure. That asymmetry is the realistic-route Node deficit against Fastify: this runner
3761
- * closes it by running validate → derive → before → handler → (after) in one frame and finalizing
3762
- * through the caller's own `finalize` (the Node outcome on the direct lane, `toResponse` on Web),
3763
- * so the same registration feeds both runtimes with no per-lane copy of the lifecycle to drift.
3764
- *
3765
- * Semantics mirror the generic program stage-for-stage for the shapes this lane admits (exactly
3766
- * one derive, one beforeHandle, zero-or-one afterHandle, no onError/around/decorations/contracts/
3767
- * recovery hooks - enforced by `selectRouteLanes`): thrown `Response`/`status(...)` are control
3768
- * flow through `wrapResponse`/`finalize`, anything else logs + 500s via the shared `bareError`
3769
- * lane (identical to `handleLifecycleError` with no `onError` hooks), and a terminal `finalize`
3770
- * throw (e.g. an unstringifiable handler return on the Node lane) is caught exactly as
3771
- * `finishLifecycleContract` catches it. Step functions close over registration state only and take
3772
- * the request state as arguments, so the hot path allocates no per-request closures.
3773
- */
3774
- buildFusedDeriveBeforeRunner(handler, derive, before, after, querySchema, maxBodyBytes) {
3775
- const logError = (err, ctx, finalize, wrapResponse) => this.bareError(err, ctx, finalize, wrapResponse);
3776
- const runFinish = (result, ctx, finalize, wrapResponse) => {
3777
- if (after === undefined) {
3778
- try {
3779
- return finalize(result, responseSet(ctx), ctx);
3780
- }
3781
- catch (err) {
3782
- return logError(err, ctx, finalize, wrapResponse);
3783
- }
3784
- }
3785
- let transformed;
3786
- try {
3787
- transformed = after(result, ctx);
3788
- }
3789
- catch (err) {
3790
- return logError(err, ctx, finalize, wrapResponse);
3791
- }
3792
- if (transformed instanceof Promise) {
3793
- return transformed.then((value) => {
3794
- try {
3795
- return finalize(value, responseSet(ctx), ctx);
3796
- }
3797
- catch (err) {
3798
- return logError(err, ctx, finalize, wrapResponse);
3799
- }
3800
- }, (err) => logError(err, ctx, finalize, wrapResponse));
3801
- }
3802
- try {
3803
- return finalize(transformed, responseSet(ctx), ctx);
3804
- }
3805
- catch (err) {
3806
- return logError(err, ctx, finalize, wrapResponse);
3807
- }
3808
- };
3809
- const finalizeEarly = (result, ctx, finalize, wrapResponse) => {
3810
- try {
3811
- return finalize(result, responseSet(ctx), ctx);
3812
- }
3813
- catch (err) {
3814
- return logError(err, ctx, finalize, wrapResponse);
3815
- }
3816
- };
3817
- const runHandler = (ctx, finalize, wrapResponse) => {
3818
- let result;
3819
- try {
3820
- result = handler(ctx);
3821
- }
3822
- catch (err) {
3823
- return logError(err, ctx, finalize, wrapResponse);
3824
- }
3825
- if (result instanceof Promise) {
3826
- return result.then((value) => runFinish(value, ctx, finalize, wrapResponse), (err) => logError(err, ctx, finalize, wrapResponse));
3827
- }
3828
- return runFinish(result, ctx, finalize, wrapResponse);
3829
- };
3830
- const runBefore = (ctx, finalize, wrapResponse) => {
3831
- let early;
3832
- try {
3833
- early = before(ctx);
3834
- }
3835
- catch (err) {
3836
- return logError(err, ctx, finalize, wrapResponse);
3837
- }
3838
- if (early instanceof Promise) {
3839
- return early.then((settled) => settled === undefined
3840
- ? runHandler(ctx, finalize, wrapResponse)
3841
- : finalizeEarly(settled, ctx, finalize, wrapResponse), (err) => logError(err, ctx, finalize, wrapResponse));
3842
- }
3843
- if (early === undefined)
3844
- return runHandler(ctx, finalize, wrapResponse);
3845
- return finalizeEarly(early, ctx, finalize, wrapResponse);
3846
- };
3847
- const runDerive = (ctx, finalize, wrapResponse) => {
3848
- let derived;
3849
- try {
3850
- derived = derive(ctx);
3851
- }
3852
- catch (err) {
3853
- return logError(err, ctx, finalize, wrapResponse);
3854
- }
3855
- if (derived instanceof Promise) {
3856
- return derived.then((settled) => {
3857
- if (isResponseResult(settled) || settled instanceof Response) {
3858
- return finalizeEarly(settled, ctx, finalize, wrapResponse);
3859
- }
3860
- Object.assign(ctx, settled);
3861
- return runBefore(ctx, finalize, wrapResponse);
3862
- }, (err) => logError(err, ctx, finalize, wrapResponse));
3863
- }
3864
- if (isResponseResult(derived) || derived instanceof Response) {
3865
- return finalizeEarly(derived, ctx, finalize, wrapResponse);
3866
- }
3867
- Object.assign(ctx, derived);
3868
- return runBefore(ctx, finalize, wrapResponse);
3869
- };
4224
+ return (source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
4225
+ const ctx = nativeContext
4226
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
4227
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
4228
+ if (decorations !== undefined)
4229
+ Object.assign(ctx, decorations);
4230
+ return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, (parsed) => onParsed(parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse), bodySchema, this.formOrigins);
4231
+ };
4232
+ }
3870
4233
  const runValidated = (validation, ctx, finalize, wrapResponse) => {
3871
4234
  if (validation instanceof Promise) {
3872
4235
  return validation.then((settled) => {
3873
4236
  if (settled.issues !== undefined)
3874
4237
  return wrapResponse(plainValidationError(settled.issues));
3875
4238
  ctx.query = settled.value;
3876
- return runDerive(ctx, finalize, wrapResponse);
3877
- }, (err) => logError(err, ctx, finalize, wrapResponse));
4239
+ return runDerive(ctx, route, finalize, wrapResponse, 0);
4240
+ }, (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse));
3878
4241
  }
3879
4242
  if (validation.issues !== undefined)
3880
4243
  return wrapResponse(plainValidationError(validation.issues));
3881
4244
  ctx.query = validation.value;
3882
- return runDerive(ctx, finalize, wrapResponse);
4245
+ return runDerive(ctx, route, finalize, wrapResponse, 0);
3883
4246
  };
3884
4247
  return (source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
3885
4248
  const ctx = nativeContext
3886
4249
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3887
4250
  : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
4251
+ if (decorations !== undefined)
4252
+ Object.assign(ctx, decorations);
3888
4253
  if (querySchema === undefined)
3889
- return runDerive(ctx, finalize, wrapResponse);
4254
+ return runDerive(ctx, route, finalize, wrapResponse, 0);
3890
4255
  let validation;
3891
4256
  try {
3892
4257
  validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
3893
4258
  }
3894
4259
  catch (err) {
3895
- return logError(err, ctx, finalize, wrapResponse);
4260
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse);
3896
4261
  }
3897
4262
  return runValidated(validation, ctx, finalize, wrapResponse);
3898
4263
  };
3899
4264
  }
3900
- /**
3901
- * Generic fused runner for the body+derive-before(-after) lane: the Node-direct counterpart of
3902
- * `buildFusedBodyDeriveBeforeAfter`. Same contract as `buildFusedDeriveBeforeRunner` above - one
3903
- * frame running parse → validate → derive → before → handler → (after) through the caller's own
3904
- * `finalize` - with the body read ahead of validation, exactly as the generic program orders its
3905
- * body stage before the derive/before stages. `readBodyFramed` keeps the shared bounded-read,
3906
- * content-type, and prototype-poisoning contracts; the 415/400 renders take `wrapResponse`
3907
- * directly, which is what the generic lane's wrap-after-identity amounts to.
3908
- */
3909
- buildFusedBodyDeriveBeforeAfterRunner(handler, derive, before, after, bodySchema, maxBodyBytes) {
3910
- const logError = (err, ctx, finalize, wrapResponse) => this.bareError(err, ctx, finalize, wrapResponse);
3911
- const runFinish = (result, ctx, finalize, wrapResponse) => {
3912
- if (after === undefined) {
3913
- try {
3914
- return finalize(result, responseSet(ctx), ctx);
3915
- }
3916
- catch (err) {
3917
- return logError(err, ctx, finalize, wrapResponse);
3918
- }
3919
- }
3920
- let transformed;
3921
- try {
3922
- transformed = after(result, ctx);
3923
- }
3924
- catch (err) {
3925
- return logError(err, ctx, finalize, wrapResponse);
3926
- }
3927
- if (transformed instanceof Promise) {
3928
- return transformed.then((value) => {
3929
- try {
3930
- return finalize(value, responseSet(ctx), ctx);
3931
- }
3932
- catch (err) {
3933
- return logError(err, ctx, finalize, wrapResponse);
3934
- }
3935
- }, (err) => logError(err, ctx, finalize, wrapResponse));
3936
- }
3937
- try {
3938
- return finalize(transformed, responseSet(ctx), ctx);
3939
- }
3940
- catch (err) {
3941
- return logError(err, ctx, finalize, wrapResponse);
3942
- }
3943
- };
3944
- const finalizeEarly = (result, ctx, finalize, wrapResponse) => {
3945
- try {
3946
- return finalize(result, responseSet(ctx), ctx);
3947
- }
3948
- catch (err) {
3949
- return logError(err, ctx, finalize, wrapResponse);
3950
- }
3951
- };
3952
- const runHandler = (ctx, finalize, wrapResponse) => {
3953
- let result;
3954
- try {
3955
- result = handler(ctx);
3956
- }
3957
- catch (err) {
3958
- return logError(err, ctx, finalize, wrapResponse);
3959
- }
3960
- if (result instanceof Promise) {
3961
- return result.then((value) => runFinish(value, ctx, finalize, wrapResponse), (err) => logError(err, ctx, finalize, wrapResponse));
3962
- }
3963
- return runFinish(result, ctx, finalize, wrapResponse);
3964
- };
3965
- const runBefore = (ctx, finalize, wrapResponse) => {
3966
- let early;
3967
- try {
3968
- early = before(ctx);
3969
- }
3970
- catch (err) {
3971
- return logError(err, ctx, finalize, wrapResponse);
3972
- }
3973
- if (early instanceof Promise) {
3974
- return early.then((settled) => settled === undefined
3975
- ? runHandler(ctx, finalize, wrapResponse)
3976
- : finalizeEarly(settled, ctx, finalize, wrapResponse), (err) => logError(err, ctx, finalize, wrapResponse));
3977
- }
3978
- if (early === undefined)
3979
- return runHandler(ctx, finalize, wrapResponse);
3980
- return finalizeEarly(early, ctx, finalize, wrapResponse);
3981
- };
3982
- const runDerive = (ctx, finalize, wrapResponse) => {
3983
- let derived;
3984
- try {
3985
- derived = derive(ctx);
3986
- }
3987
- catch (err) {
3988
- return logError(err, ctx, finalize, wrapResponse);
3989
- }
3990
- if (derived instanceof Promise) {
3991
- return derived.then((settled) => {
3992
- if (isResponseResult(settled) || settled instanceof Response) {
3993
- return finalizeEarly(settled, ctx, finalize, wrapResponse);
3994
- }
3995
- Object.assign(ctx, settled);
3996
- return runBefore(ctx, finalize, wrapResponse);
3997
- }, (err) => logError(err, ctx, finalize, wrapResponse));
3998
- }
3999
- if (isResponseResult(derived) || derived instanceof Response) {
4000
- return finalizeEarly(derived, ctx, finalize, wrapResponse);
4001
- }
4002
- Object.assign(ctx, derived);
4003
- return runBefore(ctx, finalize, wrapResponse);
4004
- };
4005
- const onParsed = (parsed, ctx, finalize, wrapResponse) => {
4006
- let validation;
4265
+ /** The thrown-value render of the Web fused lifecycle lanes for a route with no error hooks: a
4266
+ * `Response` is control flow, anything else is logged and answered with a plain 500. */
4267
+ fusedWebFail(err, ctx) {
4268
+ return this.bareError(err, ctx, this.finalizeResponse, this.wrapWebResponse);
4269
+ }
4270
+ /** {@link fusedWebFail} for a fused route that may have error hooks. */
4271
+ fusedWebRouteFail(err, ctx, route) {
4272
+ return this.fusedFail(err, ctx, route, this.finalizeResponse, this.wrapWebResponse);
4273
+ }
4274
+ /** The thrown-value render of a fused lifecycle route. A `Response` or `status(...)` is control flow
4275
+ * that no error hook sees, unless it cannot render, which is a fault like any other. Anything else
4276
+ * goes to the route's error hooks; with none it is logged and answered with a plain 500. */
4277
+ fusedFail(err, ctx, route, finalize, wrapResponse) {
4278
+ if (route.errors.length === 0)
4279
+ return this.bareError(err, ctx, finalize, wrapResponse);
4280
+ let fault = err;
4281
+ if (err instanceof Response || isResponseResult(err)) {
4007
4282
  try {
4008
- validation = bodySchema["~standard"].validate(parsed);
4009
- }
4010
- catch (err) {
4011
- return logError(err, ctx, finalize, wrapResponse);
4283
+ return finalize(err, responseSet(ctx), ctx);
4012
4284
  }
4013
- if (validation instanceof Promise) {
4014
- return validation.then((settled) => {
4015
- if (settled.issues !== undefined)
4016
- return wrapResponse(plainValidationError(settled.issues));
4017
- ctx.body = settled.value;
4018
- return runDerive(ctx, finalize, wrapResponse);
4019
- }, (err) => logError(err, ctx, finalize, wrapResponse));
4285
+ catch (renderError) {
4286
+ fault = renderError;
4020
4287
  }
4021
- if (validation.issues !== undefined)
4022
- return wrapResponse(plainValidationError(validation.issues));
4023
- ctx.body = validation.value;
4024
- return runDerive(ctx, finalize, wrapResponse);
4025
- };
4026
- return (source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
4027
- const ctx = nativeContext
4028
- ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
4029
- : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
4030
- return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, (parsed) => onParsed(parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse), bodySchema, this.formOrigins);
4031
- };
4288
+ }
4289
+ return this.runErrorHooks(route.errors, undefined, fault, ctx, (result, set) => finalize(result, set, ctx), wrapResponse);
4290
+ }
4291
+ /** {@link respondFused} for a fused lifecycle route, whose failures reach its error hooks. */
4292
+ respondHooks(result, ctx, route, unstamped) {
4293
+ try {
4294
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, unstamped);
4295
+ }
4296
+ catch (err) {
4297
+ return this.fusedWebRouteFail(err, ctx, route);
4298
+ }
4032
4299
  }
4033
4300
  bareError(err, ctx, finalize, wrapResponse) {
4034
4301
  // The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
@@ -4554,11 +4821,12 @@ export class Server {
4554
4821
  }
4555
4822
  /** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
4556
4823
  * semantics remain in the plan; this closure only supplies native params and deadline fallback. */
4557
- compileBunNativeHandler(entry, paramNames, fused, signal, budget, peer, fallback) {
4824
+ compileBunNativeHandler(entry, method, paramNames, fused, signal, budget, peer, fallback, hookedPlatform) {
4558
4825
  // This callback is reached only from Bun's compiled native route table. Bun has already parsed
4559
4826
  // the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
4560
- // the portable/adapted source contract (those sources are never marked).
4561
- const markFramed = (request) => markTrustedBodyFraming(request);
4827
+ // the portable/adapted source contract (those sources are never marked). Bun hands a GET or
4828
+ // HEAD slot no body, so it skips the symbol write on every request.
4829
+ const markFramed = method === "GET" || method === "HEAD" ? () => undefined : markTrustedBodyFraming;
4562
4830
  // The direct native lanes bypass `runMatched`, so the route's transport byte cap must be
4563
4831
  // marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
4564
4832
  // reads uncapped. The fallback branches go through `fetchMatched` -> `runMatched`, which marks.
@@ -4572,7 +4840,9 @@ export class Server {
4572
4840
  (signal && budget
4573
4841
  ? (source, params, search, activeSignal, activeBudget, platform, nativeContext) => entry.execution.run(this, entry, source, params, search, activeSignal, activeBudget, platform, nativeContext, this.finalizeResponse, this.wrapWebResponse)
4574
4842
  : undefined);
4575
- const capped = inner === undefined || bodyLimit === undefined
4843
+ // The cap is never marked on a GET or HEAD, the only methods a GET or HEAD slot is handed, so those
4844
+ // slots skip the wrapper and its read of the method on every request.
4845
+ const capped = inner === undefined || bodyLimit === undefined || method === "GET" || method === "HEAD"
4576
4846
  ? inner
4577
4847
  : (source, params, search, fusedSignal, fusedBudget, platform, nativeContext) => {
4578
4848
  const method = source.method;
@@ -4600,6 +4870,12 @@ export class Server {
4600
4870
  : finishNative(request, outcome);
4601
4871
  };
4602
4872
  if (paramNames.length === 0) {
4873
+ if (hookedPlatform !== undefined) {
4874
+ return (request, server) => {
4875
+ markFramed(request);
4876
+ return this.fetchMatchedHooked(request, entry, EMPTY_PARAMS, hookedPlatform(request, server));
4877
+ };
4878
+ }
4603
4879
  if (capped === undefined) {
4604
4880
  return (request) => {
4605
4881
  markFramed(request);
@@ -4622,6 +4898,16 @@ export class Server {
4622
4898
  const malformed = paramNames.length === 1
4623
4899
  ? (params) => unroutedParam(params[paramNames[0]])
4624
4900
  : hasUnroutedParam;
4901
+ if (hookedPlatform !== undefined) {
4902
+ return (request, server) => {
4903
+ markFramed(request);
4904
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Bun's route table hands each handler a Request carrying the `params` it matched.
4905
+ const params = request.params ?? EMPTY_PARAMS;
4906
+ if (malformed(params))
4907
+ return fallback(request, server);
4908
+ return this.fetchMatchedHooked(request, entry, params, hookedPlatform(request, server));
4909
+ };
4910
+ }
4625
4911
  if (capped === undefined) {
4626
4912
  return (request, server) => {
4627
4913
  markFramed(request);
@@ -4650,26 +4936,25 @@ export class Server {
4650
4936
  return runNative(request, params);
4651
4937
  };
4652
4938
  }
4653
- /** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
4654
- * hooks or WebSockets retain the single portable dispatcher because those features must run before
4655
- * route selection/upgrade.
4939
+ /** Compile portable route registrations into Bun's native route table. Paths a composed mount or
4940
+ * a WebSocket handshake may reach stay with the portable dispatcher; provably disjoint HTTP routes
4941
+ * keep native matching. Request hooks run before the matched route and rewrites return to dispatch.
4656
4942
  *
4657
4943
  * Bun and the portable router agree on which of two static-and-`:param` paths wins a request, so
4658
4944
  * the table is kept to those paths. Where Bun would otherwise pass a request on to a less specific
4659
4945
  * path, the more specific one carries `fallback`, the portable dispatcher, under that method.
4660
4946
  * `peer` is the platform every request served from the table shares. */
4661
- buildBunNativeRoutes(fallback, peer) {
4662
- // A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
4663
- // bypasses - so an app that declares trust routes through the fetch lane (where `c.clientIp`
4664
- // resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
4665
- if ((this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe) ||
4666
- this.wsRoutes.length > 0 ||
4667
- this.preRouteMountCount > 0 ||
4668
- this.clientIpTrust !== undefined) {
4947
+ buildBunNativeRoutes(fallback, peer, platformFor) {
4948
+ const hooked = this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe;
4949
+ if ((hooked && platformFor === undefined) ||
4950
+ // A hook may rewrite a handshake onto a socket path, which only the dispatcher's upgrade check follows.
4951
+ (hooked && this.wsRoutes.length > 0) ||
4952
+ this.mountsTakeUpgrades()) {
4669
4953
  return undefined;
4670
4954
  }
4671
4955
  const routes = Object.create(null);
4672
- const mayUseFusedNative = this.requestTimeoutMs === 0 &&
4956
+ const mayUseFusedNative = !hooked &&
4957
+ this.requestTimeoutMs === 0 &&
4673
4958
  (this.onResponseHooks.length === 0 || this.bunNativeResponseHeadersOnly) &&
4674
4959
  this.onResponseFinalizedHooks.length === 0 &&
4675
4960
  // The capacity gate must wrap every request; the fused lane bypasses fetchMatched, so enabling
@@ -4679,28 +4964,49 @@ export class Server {
4679
4964
  const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined;
4680
4965
  let count = 0;
4681
4966
  const all = this.catalog.entries();
4967
+ const preRouteMounts = this.fetchMounts.filter((mount) => mount.beforeRoutes);
4682
4968
  // A path with a wildcard or a part-literal segment cannot go in the table: Bun reads `/:name.json`
4683
4969
  // as one parameter named `name.json`. These are filed under each run of static segments they
4684
4970
  // start with, which is where a table path they could outrank looks for them.
4685
4971
  const under = new Map();
4972
+ const fileUnder = (segments) => {
4973
+ let prefix = "";
4974
+ for (const segment of segments) {
4975
+ let bucket = under.get(prefix);
4976
+ if (bucket === undefined) {
4977
+ bucket = [];
4978
+ under.set(prefix, bucket);
4979
+ }
4980
+ bucket.push(segments);
4981
+ if (segment.kind !== "static")
4982
+ break;
4983
+ prefix += `/${segment.value}`;
4984
+ }
4985
+ };
4986
+ // A handshake is a GET the portable dispatcher gives to a socket route before any HTTP route, so a
4987
+ // socket path is outside the table like a wildcard path, and a GET route a request may share with
4988
+ // one stays with the dispatcher.
4989
+ const sockets = this.wsRoutes.map((route) => compileRoutePattern(route.path).segments);
4990
+ for (const segments of sockets)
4991
+ fileUnder(segments);
4686
4992
  const lead = all.map((route) => {
4687
4993
  const segments = route.pattern.segments;
4688
4994
  const plain = segments.every((segment) => segment.kind === "static" || segment.kind === "param");
4995
+ if (!plain)
4996
+ fileUnder(segments);
4689
4997
  let prefix = "";
4690
4998
  for (const segment of segments) {
4691
- if (!plain) {
4692
- let bucket = under.get(prefix);
4693
- if (bucket === undefined) {
4694
- bucket = [];
4695
- under.set(prefix, bucket);
4696
- }
4697
- bucket.push(route);
4698
- }
4699
4999
  if (segment.kind !== "static")
4700
5000
  break;
4701
5001
  prefix += `/${segment.value}`;
4702
5002
  }
4703
- return plain ? prefix : undefined;
5003
+ // Mounts take every method under their literal prefix before route matching. A parameter may
5004
+ // fill the rest of that prefix, but a static route ending above the mount cannot reach it.
5005
+ // Conservative by design: only the leading static run proves a dynamic route disjoint.
5006
+ const mayHitMount = preRouteMounts.some((mount) => underMountPrefix(prefix, mount.path) ||
5007
+ (route.pattern.paramNames.length > 0 &&
5008
+ (prefix === "" || underMountPrefix(mount.path, prefix))));
5009
+ return plain && !mayHitMount ? prefix : undefined;
4704
5010
  });
4705
5011
  // A preflight hook is intentionally handled by the fallback fetch path. Keeping OPTIONS out
4706
5012
  // of the native lane means Bun dispatches it through CORS's onRequest hook even when the
@@ -4726,14 +5032,21 @@ export class Server {
4726
5032
  entry.idempotent !== undefined ||
4727
5033
  !BUN_TABLE_METHODS.includes(method) ||
4728
5034
  (preflightOnFallback && method === "OPTIONS") ||
4729
- under.get(run)?.some((other) => outranks(other.pattern.segments, pattern.segments))) {
5035
+ under.get(run)?.some((other) => outranks(other, pattern.segments)) ||
5036
+ (method === "GET" && sockets.some((socket) => mayShareRequest(socket, pattern.segments)))) {
4730
5037
  continue;
4731
5038
  }
4732
5039
  const paramNames = pattern.paramNames;
4733
5040
  const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined;
4734
- const handler = this.compileBunNativeHandler(entry, paramNames, fused, unboundedSignal, unboundedBudget, peer, fallback);
4735
- // The table matched the target as sent; one `fetch` refuses or rewrites goes through it.
4736
- tableAt(path, pattern.segments)[method] = (request, server) => requestTarget(request) ? fallback(request, server) : handler(request, server);
5041
+ const handler = this.compileBunNativeHandler(entry, method, paramNames, fused, unboundedSignal, unboundedBudget, peer, fallback, hooked ? platformFor : undefined);
5042
+ // The table matched the target as sent; one `fetch` refuses or rewrites goes through it. Left
5043
+ // off, `validateHost` costs nothing here: the table holds no wildcard, a `..` parameter falls
5044
+ // back, and reading the Host on every request measured about 6% of a bare route.
5045
+ tableAt(path, pattern.segments)[method] = this.validateHost
5046
+ ? (request, server) => requestTarget(request, true, this.normalizeTarget)
5047
+ ? fallback(request, server)
5048
+ : handler(request, server)
5049
+ : handler;
4737
5050
  count += 1;
4738
5051
  if (paramNames.length > 0) {
4739
5052
  below.add(pattern.segments.length + method);
@@ -4787,11 +5100,17 @@ export class Server {
4787
5100
  * nothing - so any endpoint slower than 10s (a render, an export, a long upstream call) has its
4788
5101
  * connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
4789
5102
  * this above their slowest expected response, which is why it is a first-class option and not a
4790
- * reason to drop down to `Bun.serve`. `0` disables the timeout entirely; max 255.
5103
+ * reason to drop down to `Bun.serve`, or hold those requests' connections alone with
5104
+ * `withIdleTimeout()` from `@nifrajs/core/idle-timeout`. `0` disables the timeout entirely; max 255.
4791
5105
  *
4792
5106
  * `tls` serves HTTPS directly: `app.listen(443, { tls: { cert, key } })` with PEM text or bytes.
4793
5107
  * Requests then arrive with `https:` URLs. Behind a proxy or a platform that terminates TLS, leave
4794
5108
  * it unset.
5109
+ *
5110
+ * `static` sees every request before the app does, the ones Bun's route table serves included, so
5111
+ * a file it answers wins over any route at the same path. It returns the file's response, or
5112
+ * `undefined` to let the app route the request. No hook of the app runs for a request it answers.
5113
+ * `@nifrajs/web`'s Bun build serves its client bundle and public files this way.
4795
5114
  */
4796
5115
  listen(port, options) {
4797
5116
  if (typeof Bun === "undefined") {
@@ -4844,6 +5163,10 @@ export class Server {
4844
5163
  return jsonError(500, "internal_error");
4845
5164
  },
4846
5165
  };
5166
+ const idleTimeoutSec = options?.idleTimeoutSec ?? BUN_IDLE_TIMEOUT_SEC;
5167
+ const setIdleTimeout = (request, seconds) =>
5168
+ // biome-ignore lint/plugin/noChainedTypeAssertions lint/plugin/requireSafetyCommentForTypeAssertion: `running` is the Bun `Server` this call started (`RunningServer` publishes a subset of it), and every source a Bun listener routes is a `Request`.
5169
+ running.timeout(request, seconds ?? idleTimeoutSec);
4847
5170
  const fallback = (req, server) => {
4848
5171
  // Bun has already framed and bounded this request body in its HTTP parser. Mark the
4849
5172
  // source before the portable fallback runs so body schemas use Bun's native `json()`
@@ -4851,25 +5174,59 @@ export class Server {
4851
5174
  // Requests. Native route handlers mark themselves in compileBunNativeHandler; this
4852
5175
  // covers every request the native lane does not serve.
4853
5176
  markTrustedBodyFraming(req);
4854
- return this.fetch(req, bunPeerPlatform(server, req));
5177
+ return this.fetch(req, new BunPeerPlatform(server, req, setIdleTimeout));
4855
5178
  };
4856
5179
  // Bun names the socket peer of a request when its server is asked. A request served from the
4857
5180
  // native table carries this one platform, which asks only when a handler reads `c.clientIp`.
5181
+ const socketPeer = (request) => running.requestIP(request)?.address;
5182
+ // A `clientIp` trust declaration resolves here too, from the request's headers, when a handler reads
5183
+ // the address; only a declaration of zero hops names the socket peer.
5184
+ const trust = this.clientIpTrust;
4858
5185
  const peer = {
4859
- [PLATFORM_PEER]: (request) => running.requestIP(request)?.address,
5186
+ [PLATFORM_PEER]: trust === undefined
5187
+ ? socketPeer
5188
+ : (request) => resolveClientIp(!("header" in trust) && trust.trustedHops === 0 ? socketPeer(request) : undefined,
5189
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: every source a Bun listener routes is a `Request`.
5190
+ request, trust),
5191
+ [PLATFORM_IDLE_TIMEOUT]: setIdleTimeout,
4860
5192
  };
4861
- const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes(fallback, peer) : undefined;
5193
+ const nativeRoutes = this.buildBunNativeRoutes(fallback, peer, (request, server) =>
5194
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: `fallback` hands `fetch` the same platform, which holds no environment.
5195
+ new BunPeerPlatform(server, request, setIdleTimeout));
5196
+ const first = options?.static;
5197
+ const before = (inner) => first === undefined
5198
+ ? inner
5199
+ : (req, server) => {
5200
+ const answer = first(req);
5201
+ if (answer === undefined)
5202
+ return inner(req, server);
5203
+ return answer instanceof Promise
5204
+ ? answer.then((response) => response ?? inner(req, server))
5205
+ : answer;
5206
+ };
5207
+ // Bun runs a table handler without `fetch`, so each one gets the same front as the fallback.
5208
+ if (first !== undefined && nativeRoutes !== undefined) {
5209
+ for (const path in nativeRoutes) {
5210
+ const methods = nativeRoutes[path];
5211
+ for (const method of BUN_TABLE_METHODS) {
5212
+ const handler = methods[method];
5213
+ if (handler !== undefined)
5214
+ methods[method] = before(handler);
5215
+ }
5216
+ }
5217
+ }
4862
5218
  const sockets = new Set();
4863
5219
  this.bunSockets = sockets;
4864
5220
  const running = (wsHandlers === undefined
4865
5221
  ? Bun.serve({
4866
5222
  ...listening,
4867
5223
  ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
4868
- fetch: fallback,
5224
+ fetch: before(fallback),
4869
5225
  })
4870
5226
  : Bun.serve({
4871
5227
  ...listening,
4872
- fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
5228
+ ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
5229
+ fetch: before((req, server) => this.bunFetchWithWebSocket(req, server, setIdleTimeout)),
4873
5230
  // Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
4874
5231
  // `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
4875
5232
  // `unknown` params bridge a TS structural-variance quirk. Round-trip covered by websocket.test.ts.