@nifrajs/core 3.5.0 → 4.0.2

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 (325) hide show
  1. package/README.md +26 -0
  2. package/dist/assurance.d.ts +7 -0
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +39 -3
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +20 -2
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +47 -15
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/diff.d.ts +2 -1
  11. package/dist/diff.d.ts.map +1 -1
  12. package/dist/diff.js +26 -2
  13. package/dist/diff.js.map +1 -1
  14. package/dist/durable-adapters.d.ts.map +1 -1
  15. package/dist/durable-adapters.js +16 -3
  16. package/dist/durable-adapters.js.map +1 -1
  17. package/dist/durable-effect.d.ts.map +1 -1
  18. package/dist/durable-effect.js +6 -3
  19. package/dist/durable-effect.js.map +1 -1
  20. package/dist/edge-kit.d.ts +2 -0
  21. package/dist/edge-kit.d.ts.map +1 -1
  22. package/dist/edge-kit.js +2 -0
  23. package/dist/edge-kit.js.map +1 -1
  24. package/dist/effect-lifecycle.d.ts.map +1 -1
  25. package/dist/effect-lifecycle.js +2 -1
  26. package/dist/effect-lifecycle.js.map +1 -1
  27. package/dist/effect-scope.d.ts.map +1 -1
  28. package/dist/effect-scope.js +1 -1
  29. package/dist/effect-scope.js.map +1 -1
  30. package/dist/errors.d.ts +1 -1
  31. package/dist/errors.d.ts.map +1 -1
  32. package/dist/errors.js.map +1 -1
  33. package/dist/evidence.d.ts +24 -1
  34. package/dist/evidence.d.ts.map +1 -1
  35. package/dist/evidence.js +168 -13
  36. package/dist/evidence.js.map +1 -1
  37. package/dist/idempotency.d.ts +31 -0
  38. package/dist/idempotency.d.ts.map +1 -1
  39. package/dist/idempotency.js +40 -8
  40. package/dist/idempotency.js.map +1 -1
  41. package/dist/index.d.ts +8 -5
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +5 -2
  44. package/dist/index.js.map +1 -1
  45. package/dist/internal/code-unit-order.d.ts +7 -0
  46. package/dist/internal/code-unit-order.d.ts.map +1 -0
  47. package/dist/internal/code-unit-order.js +9 -0
  48. package/dist/internal/code-unit-order.js.map +1 -0
  49. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  50. package/dist/internal/durable-saga-protocol.js +2 -0
  51. package/dist/internal/durable-saga-protocol.js.map +1 -1
  52. package/dist/internal/route-catalog.d.ts +6 -6
  53. package/dist/internal/route-catalog.d.ts.map +1 -1
  54. package/dist/internal/route-catalog.js +20 -18
  55. package/dist/internal/route-catalog.js.map +1 -1
  56. package/dist/internal/route-compiler.d.ts +7 -6
  57. package/dist/internal/route-compiler.d.ts.map +1 -1
  58. package/dist/internal/route-compiler.js +36 -4
  59. package/dist/internal/route-compiler.js.map +1 -1
  60. package/dist/internal/route-execution.d.ts +7 -3
  61. package/dist/internal/route-execution.d.ts.map +1 -1
  62. package/dist/internal/route-execution.js.map +1 -1
  63. package/dist/internal/route-lanes.d.ts +1 -0
  64. package/dist/internal/route-lanes.d.ts.map +1 -1
  65. package/dist/internal/route-lanes.js +34 -29
  66. package/dist/internal/route-lanes.js.map +1 -1
  67. package/dist/internal/route-program.d.ts +8 -2
  68. package/dist/internal/route-program.d.ts.map +1 -1
  69. package/dist/internal/route-program.js +222 -20
  70. package/dist/internal/route-program.js.map +1 -1
  71. package/dist/ledger.d.ts.map +1 -1
  72. package/dist/ledger.js +8 -5
  73. package/dist/ledger.js.map +1 -1
  74. package/dist/manifest.d.ts.map +1 -1
  75. package/dist/manifest.js +41 -37
  76. package/dist/manifest.js.map +1 -1
  77. package/dist/mount.d.ts +36 -0
  78. package/dist/mount.d.ts.map +1 -1
  79. package/dist/mount.js +32 -0
  80. package/dist/mount.js.map +1 -1
  81. package/dist/reflection.d.ts +26 -0
  82. package/dist/reflection.d.ts.map +1 -1
  83. package/dist/reflection.js +65 -1
  84. package/dist/reflection.js.map +1 -1
  85. package/dist/response-observer.d.ts +1 -1
  86. package/dist/response-observer.d.ts.map +1 -1
  87. package/dist/response-observer.js.map +1 -1
  88. package/dist/router/overlap.d.ts +23 -0
  89. package/dist/router/overlap.d.ts.map +1 -0
  90. package/dist/router/overlap.js +224 -0
  91. package/dist/router/overlap.js.map +1 -0
  92. package/dist/router/pattern.d.ts +117 -2
  93. package/dist/router/pattern.d.ts.map +1 -1
  94. package/dist/router/pattern.js +283 -23
  95. package/dist/router/pattern.js.map +1 -1
  96. package/dist/router/router.d.ts +18 -4
  97. package/dist/router/router.d.ts.map +1 -1
  98. package/dist/router/router.js +51 -44
  99. package/dist/router/router.js.map +1 -1
  100. package/dist/server/auth.d.ts +53 -0
  101. package/dist/server/auth.d.ts.map +1 -0
  102. package/dist/server/auth.js +9 -0
  103. package/dist/server/auth.js.map +1 -0
  104. package/dist/server/bare-error-lane.d.ts +12 -7
  105. package/dist/server/bare-error-lane.d.ts.map +1 -1
  106. package/dist/server/bare-error-lane.js +13 -11
  107. package/dist/server/bare-error-lane.js.map +1 -1
  108. package/dist/server/body-lane.d.ts +20 -2
  109. package/dist/server/body-lane.d.ts.map +1 -1
  110. package/dist/server/body-lane.js +26 -7
  111. package/dist/server/body-lane.js.map +1 -1
  112. package/dist/server/body-parser.d.ts +68 -0
  113. package/dist/server/body-parser.d.ts.map +1 -0
  114. package/dist/server/body-parser.js +172 -0
  115. package/dist/server/body-parser.js.map +1 -0
  116. package/dist/server/body.d.ts.map +1 -1
  117. package/dist/server/body.js +62 -7
  118. package/dist/server/body.js.map +1 -1
  119. package/dist/server/client-ip.d.ts +8 -0
  120. package/dist/server/client-ip.d.ts.map +1 -1
  121. package/dist/server/client-ip.js +8 -0
  122. package/dist/server/client-ip.js.map +1 -1
  123. package/dist/server/context.d.ts +71 -6
  124. package/dist/server/context.d.ts.map +1 -1
  125. package/dist/server/contract.d.ts +15 -5
  126. package/dist/server/contract.d.ts.map +1 -1
  127. package/dist/server/contract.js +25 -4
  128. package/dist/server/contract.js.map +1 -1
  129. package/dist/server/core-copies.d.ts +2 -0
  130. package/dist/server/core-copies.d.ts.map +1 -0
  131. package/dist/server/core-copies.js +20 -0
  132. package/dist/server/core-copies.js.map +1 -0
  133. package/dist/server/edge.d.ts.map +1 -1
  134. package/dist/server/edge.js +2 -0
  135. package/dist/server/edge.js.map +1 -1
  136. package/dist/server/http.d.ts +11 -0
  137. package/dist/server/http.d.ts.map +1 -1
  138. package/dist/server/http.js +36 -0
  139. package/dist/server/http.js.map +1 -1
  140. package/dist/server/idempotency-lane.d.ts +6 -0
  141. package/dist/server/idempotency-lane.d.ts.map +1 -1
  142. package/dist/server/idempotency-lane.js +130 -73
  143. package/dist/server/idempotency-lane.js.map +1 -1
  144. package/dist/server/install.d.ts +4 -0
  145. package/dist/server/install.d.ts.map +1 -1
  146. package/dist/server/install.js +4 -0
  147. package/dist/server/install.js.map +1 -1
  148. package/dist/server/methods.d.ts +45 -0
  149. package/dist/server/methods.d.ts.map +1 -0
  150. package/dist/server/methods.js +37 -0
  151. package/dist/server/methods.js.map +1 -0
  152. package/dist/server/multipart.d.ts +63 -0
  153. package/dist/server/multipart.d.ts.map +1 -0
  154. package/dist/server/multipart.js +374 -0
  155. package/dist/server/multipart.js.map +1 -0
  156. package/dist/server/node-outcome-hook.d.ts +20 -0
  157. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  158. package/dist/server/node-outcome-hook.js +15 -6
  159. package/dist/server/node-outcome-hook.js.map +1 -1
  160. package/dist/server/not-found-answer.d.ts +40 -0
  161. package/dist/server/not-found-answer.d.ts.map +1 -0
  162. package/dist/server/not-found-answer.js +56 -0
  163. package/dist/server/not-found-answer.js.map +1 -0
  164. package/dist/server/not-found.d.ts +23 -0
  165. package/dist/server/not-found.d.ts.map +1 -0
  166. package/dist/server/not-found.js +81 -0
  167. package/dist/server/not-found.js.map +1 -0
  168. package/dist/server/plugin.d.ts +26 -1
  169. package/dist/server/plugin.d.ts.map +1 -1
  170. package/dist/server/plugin.js.map +1 -1
  171. package/dist/server/proto-guard.d.ts +18 -5
  172. package/dist/server/proto-guard.d.ts.map +1 -1
  173. package/dist/server/proto-guard.js +114 -9
  174. package/dist/server/proto-guard.js.map +1 -1
  175. package/dist/server/query.d.ts +7 -0
  176. package/dist/server/query.d.ts.map +1 -1
  177. package/dist/server/query.js +47 -0
  178. package/dist/server/query.js.map +1 -1
  179. package/dist/server/registry.d.ts +13 -2
  180. package/dist/server/registry.d.ts.map +1 -1
  181. package/dist/server/request-context.d.ts +11 -0
  182. package/dist/server/request-context.d.ts.map +1 -1
  183. package/dist/server/request-context.js +33 -19
  184. package/dist/server/request-context.js.map +1 -1
  185. package/dist/server/request-lineage.d.ts +8 -0
  186. package/dist/server/request-lineage.d.ts.map +1 -0
  187. package/dist/server/request-lineage.js +14 -0
  188. package/dist/server/request-lineage.js.map +1 -0
  189. package/dist/server/request-target.d.ts +31 -0
  190. package/dist/server/request-target.d.ts.map +1 -0
  191. package/dist/server/request-target.js +44 -0
  192. package/dist/server/request-target.js.map +1 -0
  193. package/dist/server/respond.d.ts +1 -1
  194. package/dist/server/respond.d.ts.map +1 -1
  195. package/dist/server/respond.js +16 -4
  196. package/dist/server/respond.js.map +1 -1
  197. package/dist/server/response-contract-lane.d.ts +19 -1
  198. package/dist/server/response-contract-lane.d.ts.map +1 -1
  199. package/dist/server/response-contract-lane.js +59 -14
  200. package/dist/server/response-contract-lane.js.map +1 -1
  201. package/dist/server/response-observer-runtime.d.ts +9 -5
  202. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  203. package/dist/server/response-observer-runtime.js +10 -6
  204. package/dist/server/response-observer-runtime.js.map +1 -1
  205. package/dist/server/runtime-core.d.ts +2 -0
  206. package/dist/server/runtime-core.d.ts.map +1 -1
  207. package/dist/server/runtime-core.js.map +1 -1
  208. package/dist/server/server-types.d.ts +53 -3
  209. package/dist/server/server-types.d.ts.map +1 -1
  210. package/dist/server/server.d.ts +191 -19
  211. package/dist/server/server.d.ts.map +1 -1
  212. package/dist/server/server.js +1206 -218
  213. package/dist/server/server.js.map +1 -1
  214. package/dist/server/sse.d.ts.map +1 -1
  215. package/dist/server/sse.js +10 -7
  216. package/dist/server/sse.js.map +1 -1
  217. package/dist/server/static-headers.d.ts +3 -0
  218. package/dist/server/static-headers.d.ts.map +1 -1
  219. package/dist/server/static-headers.js +19 -1
  220. package/dist/server/static-headers.js.map +1 -1
  221. package/dist/server/transport-lane.d.ts +8 -0
  222. package/dist/server/transport-lane.d.ts.map +1 -1
  223. package/dist/server/transport-lane.js +9 -8
  224. package/dist/server/transport-lane.js.map +1 -1
  225. package/dist/server/validation.d.ts.map +1 -1
  226. package/dist/server/validation.js +5 -1
  227. package/dist/server/validation.js.map +1 -1
  228. package/dist/server/websocket.d.ts +10 -3
  229. package/dist/server/websocket.d.ts.map +1 -1
  230. package/dist/server/websocket.js +7 -4
  231. package/dist/server/websocket.js.map +1 -1
  232. package/dist/server/ws-bun.d.ts +2 -0
  233. package/dist/server/ws-bun.d.ts.map +1 -1
  234. package/dist/server/ws-bun.js +6 -3
  235. package/dist/server/ws-bun.js.map +1 -1
  236. package/dist/server/ws-hook.d.ts +2 -1
  237. package/dist/server/ws-hook.d.ts.map +1 -1
  238. package/dist/server.d.ts +7 -4
  239. package/dist/server.d.ts.map +1 -1
  240. package/dist/server.js +4 -1
  241. package/dist/server.js.map +1 -1
  242. package/dist/single-copy-register.js +5 -0
  243. package/dist/single-copy-register.js.map +1 -1
  244. package/dist/single-copy.d.ts +46 -3
  245. package/dist/single-copy.d.ts.map +1 -1
  246. package/dist/single-copy.js +125 -12
  247. package/dist/single-copy.js.map +1 -1
  248. package/dist/tool-contract.d.ts +23 -0
  249. package/dist/tool-contract.d.ts.map +1 -1
  250. package/dist/tool-contract.js +37 -3
  251. package/dist/tool-contract.js.map +1 -1
  252. package/dist/transport-codec.d.ts.map +1 -1
  253. package/dist/transport-codec.js +2 -1
  254. package/dist/transport-codec.js.map +1 -1
  255. package/package.json +27 -2
  256. package/src/assurance.ts +52 -2
  257. package/src/capabilities.ts +81 -22
  258. package/src/diff.ts +31 -6
  259. package/src/durable-adapters.ts +20 -3
  260. package/src/durable-effect.ts +10 -3
  261. package/src/edge-kit.ts +7 -0
  262. package/src/effect-lifecycle.ts +5 -1
  263. package/src/effect-scope.ts +2 -1
  264. package/src/errors.ts +3 -0
  265. package/src/evidence.ts +237 -12
  266. package/src/idempotency.ts +72 -8
  267. package/src/index.ts +40 -3
  268. package/src/internal/code-unit-order.ts +8 -0
  269. package/src/internal/durable-saga-protocol.ts +2 -0
  270. package/src/internal/route-catalog.ts +19 -18
  271. package/src/internal/route-compiler.ts +62 -13
  272. package/src/internal/route-execution.ts +13 -3
  273. package/src/internal/route-lanes.ts +37 -28
  274. package/src/internal/route-program.ts +434 -20
  275. package/src/ledger.ts +11 -5
  276. package/src/manifest.ts +41 -36
  277. package/src/mount.ts +62 -0
  278. package/src/reflection.ts +84 -1
  279. package/src/response-observer.ts +1 -0
  280. package/src/router/overlap.ts +258 -0
  281. package/src/router/pattern.ts +349 -25
  282. package/src/router/router.ts +76 -51
  283. package/src/server/auth.ts +75 -0
  284. package/src/server/bare-error-lane.ts +12 -11
  285. package/src/server/body-lane.ts +46 -6
  286. package/src/server/body-parser.ts +241 -0
  287. package/src/server/body.ts +82 -9
  288. package/src/server/client-ip.ts +9 -0
  289. package/src/server/context.ts +219 -12
  290. package/src/server/contract.ts +54 -9
  291. package/src/server/core-copies.ts +21 -0
  292. package/src/server/edge.ts +2 -0
  293. package/src/server/http.ts +34 -0
  294. package/src/server/idempotency-lane.ts +147 -72
  295. package/src/server/install.ts +6 -0
  296. package/src/server/methods.ts +143 -0
  297. package/src/server/multipart.ts +424 -0
  298. package/src/server/node-outcome-hook.ts +40 -7
  299. package/src/server/not-found-answer.ts +109 -0
  300. package/src/server/not-found.ts +121 -0
  301. package/src/server/plugin.ts +45 -1
  302. package/src/server/proto-guard.ts +128 -9
  303. package/src/server/query.ts +49 -0
  304. package/src/server/registry.ts +17 -2
  305. package/src/server/request-context.ts +44 -16
  306. package/src/server/request-lineage.ts +15 -0
  307. package/src/server/request-target.ts +46 -0
  308. package/src/server/respond.ts +14 -3
  309. package/src/server/response-contract-lane.ts +97 -16
  310. package/src/server/response-observer-runtime.ts +37 -8
  311. package/src/server/runtime-core.ts +2 -0
  312. package/src/server/server-types.ts +59 -2
  313. package/src/server/server.ts +1613 -269
  314. package/src/server/sse.ts +10 -8
  315. package/src/server/static-headers.ts +22 -1
  316. package/src/server/transport-lane.ts +17 -9
  317. package/src/server/validation.ts +7 -1
  318. package/src/server/websocket.ts +23 -9
  319. package/src/server/ws-bun.ts +8 -3
  320. package/src/server/ws-hook.ts +2 -1
  321. package/src/server.ts +38 -2
  322. package/src/single-copy-register.ts +5 -0
  323. package/src/single-copy.ts +160 -12
  324. package/src/tool-contract.ts +66 -3
  325. package/src/transport-codec.ts +2 -1
@@ -7,31 +7,38 @@ import { compileRouteOptions } from "../internal/route-compiler.js";
7
7
  import { compileRouteExecutionPlan, } from "../internal/route-execution.js";
8
8
  import { compileRouteProgram, executeRouteProgram } from "../internal/route-program.js";
9
9
  import { isSameOriginRequest } from "../internal/same-origin.js";
10
- import { decodeRouteParams } from "../router/pattern.js";
11
- import { EMPTY_PARAMS, Router } from "../router/router.js";
10
+ import { decodeRouteParams, expandOptionalParams } from "../router/pattern.js";
11
+ import { EMPTY_PARAMS, METHODS, Router } from "../router/router.js";
12
12
  import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.js";
13
13
  import { assertByteLimit, markTransportCap, markTrustedBodyFraming, UNLIMITED_BODY_BYTES, } from "./body.js";
14
- import { resolveClientIp } from "./client-ip.js";
14
+ import { NIFRA_PLATFORM_CLIENT_IP_DERIVED, resolveClientIp, } from "./client-ip.js";
15
+ import "./core-copies.js";
15
16
  import { hasLowercaseHeaderKeysMark, headerKeysAllLowercase, markLowercaseHeaderKeys, } from "./header-case.js";
16
17
  import { headerObjectOf } from "./headers.js";
17
- import { jsonError, pathnameOf, plainError, urlPartsOf } from "./http.js";
18
+ import { isRoutableMethod, jsonError, pathnameOf, plainError, urlPartsOf, } from "./http.js";
18
19
  import { withStaticNodeHeaders } from "./node-outcome.js";
19
20
  import { queryObjectOf, searchOf } from "./query.js";
20
- import { RequestContext, readBodyFramed } from "./request-context.js";
21
+ import { PLATFORM_PEER, RequestContext, readBodyFramed, } from "./request-context.js";
22
+ import { recordRequestReplacement } from "./request-lineage.js";
23
+ import { hasDotSegment, resolveDotSegments } from "./request-target.js";
21
24
  import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, toResponse, } from "./respond.js";
22
25
  import { CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, isResponseResult, requestOf, } from "./runtime-core.js";
23
26
  import { normalizeStaticResponseHeaders } from "./static-headers.js";
24
27
  import { plainValidationError } from "./validation.js";
25
- import { INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_RESPONSE_CONTRACT, INSTALL_RESPONSE_OBSERVER, INSTALL_SSE, INSTALL_WS, NODE_NATIVE_MOUNT, RESOLVE_NODE_MOUNT, } from "./install.js";
28
+ import { NIFRA_BACKEND_WS_RUNTIME } from "../mount.js";
29
+ import { GET_WS_RUNTIME, INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_NOT_FOUND, INSTALL_RESPONSE_CONTRACT, INSTALL_RESPONSE_OBSERVER, INSTALL_SSE, INSTALL_WS, NODE_NATIVE_MOUNT, RESOLVE_NODE_MOUNT, } from "./install.js";
26
30
  import { jsonLogger } from "./logger.js";
31
+ // CORS uses this marker to say its request hook only handles OPTIONS preflight. Bun can keep
32
+ // ordinary registered routes on its native table and send unmatched OPTIONS requests through the
33
+ // normal fetch fallback where the hook still runs.
34
+ const BUN_NATIVE_REQUEST_SAFE = Symbol.for("@nifrajs/core/bun-native-request-safe");
27
35
  const functionToString = Function.prototype.toString;
28
36
  const CONTEXTLESS_ARROW = /^(?:async\s*)?\(\s*\)\s*(?::[\s\S]*?)?=>/;
29
- /** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown).
30
- * Typed structurally on `requestIP` alone so any Bun `Server` (WS or not) satisfies it. */
37
+ /** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown). */
31
38
  function bunPeerPlatform(server, req) {
32
- // Bun's requestIP() is surprisingly expensive (~20 us on the SSR benchmark machine). Keep the
39
+ // Bun's requestIP() costs ~0.5 us per call, about 7% of a bare GET's server time. Keep the
33
40
  // documented raw-peer c.clientIp behavior, but resolve it lazily: most routes never read c.clientIp,
34
- // and paying for the socket lookup on every request erased Bun's native HTTP advantage. A getter also
41
+ // so they should not pay for the socket lookup on every request. A getter also
35
42
  // preserves middleware that inspects the platform argument directly and trust-mode routes, which
36
43
  // resolve the value in deriveClientIp before the handler runs.
37
44
  let resolved = false;
@@ -46,8 +53,38 @@ function bunPeerPlatform(server, req) {
46
53
  },
47
54
  };
48
55
  }
56
+ /**
57
+ * Whether the portable router gives `other` a request that `route` also matches: the two patterns can
58
+ * match one path, and `other` is the more specific at the first segment where they differ. `route`
59
+ * holds static and `:param` segments only; `other` has a wildcard or a part-literal segment.
60
+ *
61
+ * It errs toward `true`. A segment that is part literal, part parameter is taken to match whatever
62
+ * `route` has in that position.
63
+ */
64
+ function outranks(other, route) {
65
+ let ahead = false;
66
+ for (let i = 0; i < route.length; i++) {
67
+ const theirs = other[i];
68
+ if (theirs === undefined)
69
+ return false;
70
+ if (theirs.kind === "wildcard")
71
+ return ahead;
72
+ const ours = route[i];
73
+ if (ours.kind === "static") {
74
+ if (theirs.kind === "static" ? theirs.value !== ours.value : !ahead)
75
+ return false;
76
+ }
77
+ else if (theirs.kind !== "param")
78
+ ahead = true;
79
+ }
80
+ return ahead && other.length === route.length;
81
+ }
49
82
  const WS_PASS = { kind: "pass" };
50
83
  const DEFAULT_WS_UPGRADE_TIMEOUT_MS = 10_000;
84
+ /** Counts `ws()` routes and `mount()`s across every app in this copy of core: the only
85
+ * changes that can make a mounted app take WebSocket upgrades. An app's answer to that question
86
+ * (`mountsTakeUpgrades`) is kept until this moves. */
87
+ let wsChanges = 0;
51
88
  /** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
52
89
  function requireWsRuntime(runtime) {
53
90
  if (runtime === undefined) {
@@ -88,13 +125,27 @@ export { plainValidationError } from "./validation.js";
88
125
  // Query-string + urlencoded-form parsing now lives in `./query.ts`; re-exported so existing
89
126
  // importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
90
127
  export { queryObjectOf, searchOf };
91
- function hasReplacementParam(params) {
128
+ /**
129
+ * A param value Bun's native route table matched but the portable router must decide: an invalid
130
+ * escape, or a piece of path Bun matched raw while its own parsed URL says otherwise - a `.` / `..`
131
+ * segment (Bun hands `%2e` over decoded), or a backslash, which the parsed URL reads as `/`.
132
+ */
133
+ const unroutedParam = (value) => value !== undefined &&
134
+ (value.includes("\uFFFD") || value.includes("\\") || value === "." || value === "..");
135
+ function hasUnroutedParam(params) {
92
136
  for (const key in params) {
93
- if (params[key].includes("\uFFFD"))
137
+ if (unroutedParam(params[key]))
94
138
  return true;
95
139
  }
96
140
  return false;
97
141
  }
142
+ /**
143
+ * Deno is the runtime that hands an app a `Request` whose `url` is the target as the client sent it;
144
+ * Bun, workerd and every `new Request()` parse it first. Only there does `fetch` read the URL up front.
145
+ */
146
+ const rawRequestTargets = typeof globalThis.Deno !== "undefined";
147
+ /** `req` as Bun or workerd would have delivered it: the same request, its URL's dot segments resolved. */
148
+ const withResolvedTarget = (req) => new Request(resolveDotSegments(req.url), req);
98
149
  function normalizeMountPrefix(path) {
99
150
  if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
100
151
  throw new TypeError("mountFetch path must be an absolute pathname without a query or hash");
@@ -112,6 +163,17 @@ function normalizeMountPrefix(path) {
112
163
  function underMountPrefix(pathname, prefix) {
113
164
  return prefix === "/" || pathname === prefix || pathname.startsWith(`${prefix}/`);
114
165
  }
166
+ function mountPriorityOf(priority) {
167
+ if (priority === undefined)
168
+ return 0;
169
+ if (!Number.isSafeInteger(priority)) {
170
+ throw new TypeError("mount priority must be a safe integer");
171
+ }
172
+ return priority;
173
+ }
174
+ function compareMounts(a, b) {
175
+ return b.priority - a.priority || b.path.length - a.path.length || a.order - b.order;
176
+ }
115
177
  function stripMountPrefix(request, prefix) {
116
178
  if (prefix === "/")
117
179
  return request;
@@ -140,6 +202,68 @@ function isContextlessNoArgArrow(handler) {
140
202
  * these renders happen where no request context exists - before routing, or after it was abandoned.
141
203
  */
142
204
  const webResponseOf = (result) => result instanceof Response ? result : toResponse(result, EMPTY_RESPONSE_CONTROLS);
205
+ /** A `group(prefix)` hook's scope: every request whose path is the prefix or lies under it - served,
206
+ * 404, 405, or preflight alike (Hono's `use("/api/*")` reach). The router matches static segments
207
+ * byte-exactly and case-sensitively, with the same path scanner used here, so no spelling a group
208
+ * route answers to can fall outside this test. */
209
+ const underPrefix = (prefix) => {
210
+ const nested = `${prefix}/`;
211
+ return (req) => {
212
+ const path = pathnameOf(req.url);
213
+ return path === prefix || path.startsWith(nested);
214
+ };
215
+ };
216
+ /** A merged server's hook scope: exactly the routes MERGED, snapshotted into a dedicated matcher so
217
+ * the guard never reflects whatever the source's own catalog grows into afterwards. */
218
+ const servedBy = (routes) => {
219
+ const scope = new RouteCatalog();
220
+ scope.addBatch(routes);
221
+ return (req) => scope.find(req.method, pathnameOf(req.url)).found;
222
+ };
223
+ /** Gate adopted hooks to `inScope` (`undefined`: adopt them unchanged). The request is a request
224
+ * hook's first argument (`reqAt` 0) and every response-side hook's second; out of scope a hook
225
+ * declines, except a Web response hook, which must hand the response on (`passFirst`). An
226
+ * `undefined` slot marks an unpaired Node twin, position-aligned with its Web list, and stays
227
+ * `undefined` - wrapping it would fabricate a twin that never existed. */
228
+ const scopeHooks = (hooks, inScope, reqAt, passFirst) => inScope === undefined
229
+ ? hooks
230
+ : hooks.map((hook) => hook === undefined
231
+ ? hook
232
+ : ((first, second) => inScope(reqAt === 0 ? first : second)
233
+ ? hook(first, second)
234
+ : passFirst && first));
235
+ /** A `group()` prefix: `/`-led segments of RFC 3986 `pchar` minus `%` and `:`, none of them `.` or
236
+ * `..`. That is text the router compares byte-for-byte as static, so a prefix can never smuggle a
237
+ * param, a wildcard, or an escape, and never spells a path (dot or empty segment, trailing slash)
238
+ * that no normalized client URL reaches. Segments are delimited by a `/` the class excludes, so the
239
+ * match is linear. */
240
+ const GROUP_PREFIX = /^(?:\/(?!\.\.?(?:\/|$))[\w.~!$&'()+,;=@-]+)+$/;
241
+ /** The order-scoped chain a group scope inherits - copied, so what the group adds stays its own. */
242
+ const GROUP_CHAIN = [
243
+ "derives",
244
+ "authStages",
245
+ "beforeHandleHooks",
246
+ "afterHandleHooks",
247
+ "onErrorHooks",
248
+ "aroundHooks",
249
+ "activeAssurance",
250
+ // Inherited so a group route computes `authenticated` (idempotency principal scoping) exactly as a
251
+ // parent route would; only the evidence the group adds is folded at adoption.
252
+ "globalAssurance",
253
+ "capabilityInterceptors",
254
+ "capabilityObservers",
255
+ ];
256
+ /** Installed runtimes: a group scope inherits them, and adoption hoists a source's into a server that
257
+ * has none, so composing servers cannot silently disable a safety lane. */
258
+ const SERVER_RUNTIMES = [
259
+ "responseContractRuntime",
260
+ "idempotencyRuntime",
261
+ "effectLedgerRuntime",
262
+ "mcpRuntime",
263
+ "nodeOutcomeRuntime",
264
+ "sseRuntime",
265
+ "wsRuntime",
266
+ ];
143
267
  // Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
144
268
  const IDENTITY_RESPONSE = (response) => webResponseOf(response);
145
269
  const RESPONSE_TIMEOUT = () => jsonError(503, "request_timeout");
@@ -232,6 +356,10 @@ export class Server {
232
356
  /** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
233
357
  wsRouter;
234
358
  wsRouteCount;
359
+ /** `wsChanges` when `wsMountsLive` was last worked out; -1 until then. */
360
+ wsMountsCheckedAt = -1;
361
+ /** Whether an app mounted here takes WebSocket upgrades, as of `wsMountsCheckedAt`. */
362
+ wsMountsLive = false;
235
363
  /** In-process pub/sub backing `ws.subscribe(topic)` + `app.publish(topic, data)` (single-instance).
236
364
  * Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime - `undefined` until then, so a
237
365
  * no-WebSocket app never constructs (or bundles) it. */
@@ -241,7 +369,8 @@ export class Server {
241
369
  * rather than delegate to Bun's native `server.publish`. */
242
370
  wsHasValidatedSend = false;
243
371
  /** Bun's native topic broadcast, bound by `listen()` in native-pubsub mode; `app.publish` uses it
244
- * instead of the JS registry. `undefined` off Bun, before `listen()`, or with a validated-send route. */
372
+ * instead of the JS registry. `undefined` off Bun, before `listen()`, with a validated-send route,
373
+ * or when a mounted app takes upgrades. */
245
374
  nativePublish;
246
375
  maxBodyBytes;
247
376
  protoPoisoning;
@@ -261,6 +390,9 @@ export class Server {
261
390
  gracefulSignals;
262
391
  stopHooks;
263
392
  fetchMounts;
393
+ mountOrder;
394
+ /** Number of composed mounts that must run before the parent route table. Keeps legacy mounts off the hot path. */
395
+ preRouteMountCount;
264
396
  /** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
265
397
  capacityGate;
266
398
  onCapabilityUse;
@@ -274,6 +406,8 @@ export class Server {
274
406
  idempotencyRuntime;
275
407
  /** Installed opt-in runtime for `.tool()`/`.resource()`/`.prompt()`; `undefined` until `.use(mcp())`. */
276
408
  mcpRuntime;
409
+ /** Installed answer for a request no route matched; `undefined` (the plain 404) until `.use(notFound())`. */
410
+ notFoundLane;
277
411
  /** Installed Node-direct renderer for direct `resolveNode()` callers; `undefined` until `.use(nodeDirect())`. */
278
412
  nodeOutcomeRuntime;
279
413
  /** Installed streaming runtime for `.sse()` routes; `undefined` until `.use(streaming())`. */
@@ -288,6 +422,8 @@ export class Server {
288
422
  bunServer;
289
423
  sealed;
290
424
  derives;
425
+ /** Dedicated authentication stages captured by subsequent routes. */
426
+ authStages;
291
427
  decorations;
292
428
  beforeHandleHooks;
293
429
  afterHandleHooks;
@@ -297,10 +433,14 @@ export class Server {
297
433
  onNodeRequestHooks;
298
434
  /** Registration-time eligibility for the Node request twin lane. */
299
435
  nodeRequestHooksComplete;
436
+ /** True when every request hook is safe to skip on a Bun native route. */
437
+ bunNativeRequestHooksSafe;
300
438
  onResponseHooks;
301
439
  onNodeResponseHooks;
302
440
  /** Registration-time eligibility for the Node response twin lane. */
303
441
  nodeResponseHooksComplete;
442
+ /** True when every response hook only mutates headers and can run after a Bun fused response. */
443
+ bunNativeResponseHeadersOnly;
304
444
  /**
305
445
  * True once a bundle registers a RAW `onNodeResponse` twin - one handed the outcome's header
306
446
  * record itself rather than the case-normalizing view. Their names are contractually the wire
@@ -329,7 +469,7 @@ export class Server {
329
469
  responseSources;
330
470
  /** Memoized NodeRequestContext per plain-`Request` source - see {@link nodeRequestContextOf}. */
331
471
  nodeContexts;
332
- /** Names of plugins/middleware already applied via `use` - for idempotent dedupe. */
472
+ /** Plugins/middleware already applied via `use`, by name - for idempotent dedupe. */
333
473
  appliedPlugins;
334
474
  /** Order-scoped evidence captured by routes registered after an assured plugin. */
335
475
  activeAssurance;
@@ -338,7 +478,14 @@ export class Server {
338
478
  /** App-declared MCP resources / prompts (via {@link resource} / {@link prompt}), read by `nifra mcp`. */
339
479
  mcpResourceList;
340
480
  mcpPromptList;
481
+ /** Kept so a {@link group} scope is built with exactly this server's settings: fused lanes read
482
+ * several of them at request time, so a group route must never run under different ones. */
483
+ options;
484
+ /** Static prefix joined onto every route this server registers; non-empty only on a group scope. */
485
+ routePrefix;
341
486
  constructor(options = {}) {
487
+ this.options = options;
488
+ this.routePrefix = "";
342
489
  this.catalog = new RouteCatalog();
343
490
  this.wsRouter = new Router();
344
491
  this.wsRouteCount = 0;
@@ -372,6 +519,8 @@ export class Server {
372
519
  this.gracefulSignals = options.gracefulSignals ?? false;
373
520
  this.stopHooks = [];
374
521
  this.fetchMounts = [];
522
+ this.mountOrder = 0;
523
+ this.preRouteMountCount = 0;
375
524
  this.capacityGate = options.admission;
376
525
  this.onCapabilityUse = options.onCapabilityUse;
377
526
  this.capabilityInterceptors = [];
@@ -394,6 +543,7 @@ export class Server {
394
543
  beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined);
395
544
  }
396
545
  this.derives = [];
546
+ this.authStages = [];
397
547
  this.decorations = {};
398
548
  this.beforeHandleHooks = [];
399
549
  this.afterHandleHooks = [];
@@ -402,9 +552,11 @@ export class Server {
402
552
  this.onRequestHooks = [];
403
553
  this.onNodeRequestHooks = [];
404
554
  this.nodeRequestHooksComplete = true;
555
+ this.bunNativeRequestHooksSafe = true;
405
556
  this.onResponseHooks = [];
406
557
  this.onNodeResponseHooks = [];
407
558
  this.nodeResponseHooksComplete = true;
559
+ this.bunNativeResponseHeadersOnly = true;
408
560
  this.hasRawNodeResponseHook = false;
409
561
  this.onResponseFinalizedHooks = [];
410
562
  this.staticResponseHeaders = undefined;
@@ -415,7 +567,7 @@ export class Server {
415
567
  this.responseRequests = new WeakMap();
416
568
  this.responseSources = new WeakMap();
417
569
  this.nodeContexts = new WeakMap();
418
- this.appliedPlugins = new Set();
570
+ this.appliedPlugins = new Map();
419
571
  this.activeAssurance = [];
420
572
  this.globalAssurance = [];
421
573
  this.mcpResourceList = [];
@@ -423,7 +575,7 @@ export class Server {
423
575
  }
424
576
  assertConfigurable(operation) {
425
577
  if (this.sealed) {
426
- throw new FrameworkError("SERVER_SEALED", `server configuration is sealed after listen(); call ${operation} before listen()`);
578
+ throw new FrameworkError("SERVER_SEALED", `server configuration is sealed after listen() (a group once its builder returns); call ${operation} before`);
427
579
  }
428
580
  }
429
581
  /** Register a callback awaited after graceful drain and server stop. Hung callbacks are bounded. */
@@ -447,9 +599,70 @@ export class Server {
447
599
  path: normalizeMountPrefix(path),
448
600
  handler: handler,
449
601
  stripPrefix: options.stripPrefix === true,
602
+ priority: mountPriorityOf(options.priority),
603
+ fallbackOn404: options.fallbackOn === 404,
604
+ beforeRoutes: false,
605
+ order: this.mountOrder++,
606
+ opaque: options.opaque,
607
+ };
608
+ this.fetchMounts.push(mount);
609
+ this.fetchMounts.sort(compareMounts);
610
+ return this;
611
+ }
612
+ /**
613
+ * Compose a child fetch app under a literal path prefix.
614
+ *
615
+ * Composed mounts run after the app-global request phase and before the parent's route table. This
616
+ * makes them safe to use in an SSR shell whose `/*` page route would otherwise swallow an API mount.
617
+ * If the child exposes `resolveWebSocketUpgrade`, the same mount also participates in adapter-owned
618
+ * WebSocket upgrades; the serving adapter still upgrades its original socket, while the child sees
619
+ * the mounted request path (stripped when requested).
620
+ *
621
+ * `fallbackOn: 404` is deliberately limited to GET/HEAD/OPTIONS at dispatch time. Those methods are
622
+ * replayable without buffering an untrusted request body; POST/PUT/PATCH and streaming requests
623
+ * never get retried after a child has had a chance to consume them.
624
+ */
625
+ mount(options) {
626
+ this.assertConfigurable("mount()");
627
+ if (options === null || typeof options !== "object") {
628
+ throw new TypeError("mount() requires { path, app }");
629
+ }
630
+ const app = options.app;
631
+ if (app === null || typeof app !== "object") {
632
+ throw new TypeError("mount() app must be an object with fetch()");
633
+ }
634
+ if (typeof app.fetch !== "function") {
635
+ throw new TypeError("mount() app.fetch must be a function");
636
+ }
637
+ const resolver = typeof app.resolveWebSocketUpgrade === "function"
638
+ ? app.resolveWebSocketUpgrade.bind(app)
639
+ : undefined;
640
+ // Bun needs a websocket callback table in the parent process. A composed Nifra app names the
641
+ // runtime its upgrades need through this internal seam, which `listen()` asks (see
642
+ // `[GET_WS_RUNTIME]`); a resolver without it names none. Adapters that wire standard sockets use
643
+ // the resolver outcome's own `attach`.
644
+ const getRuntime = app[GET_WS_RUNTIME] ?? app[NIFRA_BACKEND_WS_RUNTIME];
645
+ const mount = {
646
+ path: normalizeMountPrefix(options.path),
647
+ handler: app.fetch.bind(app),
648
+ ...(resolver === undefined
649
+ ? {}
650
+ : {
651
+ resolveWebSocketUpgrade: resolver,
652
+ wsRuntime: typeof getRuntime === "function" ? getRuntime.bind(app) : () => null,
653
+ }),
654
+ stripPrefix: options.stripPrefix === true,
655
+ priority: mountPriorityOf(options.priority),
656
+ fallbackOn404: options.fallbackOn === 404,
657
+ beforeRoutes: true,
658
+ order: this.mountOrder++,
659
+ opaque: options.opaque,
660
+ app,
450
661
  };
451
662
  this.fetchMounts.push(mount);
452
- this.fetchMounts.sort((a, b) => b.path.length - a.path.length);
663
+ this.fetchMounts.sort(compareMounts);
664
+ this.preRouteMountCount += 1;
665
+ wsChanges += 1;
453
666
  return this;
454
667
  }
455
668
  /**
@@ -475,6 +688,30 @@ export class Server {
475
688
  * by none before; one added after the last route reaches nothing
476
689
  * (see {@link ServerOptions.unusedScopedHooks}).
477
690
  */
691
+ /**
692
+ * Register a dedicated authentication stage for subsequent routes.
693
+ *
694
+ * Unlike `derive`, this stage receives a body-blind authentication capsule and is compiled before
695
+ * route validation. Keeping it separate prevents an unrelated derive from accidentally becoming a
696
+ * security gate and lets the route compiler retain a protected fast lane.
697
+ */
698
+ authenticate(stage) {
699
+ this.assertConfigurable("authenticate()");
700
+ if (stage === null || typeof stage !== "object") {
701
+ throw new TypeError("authenticate() requires an authentication stage");
702
+ }
703
+ if (typeof stage.id !== "string" || stage.id.trim() === "") {
704
+ throw new TypeError("authenticate() stage id must be a non-empty string");
705
+ }
706
+ if (typeof stage.run !== "function") {
707
+ throw new TypeError("authenticate() stage run must be a function");
708
+ }
709
+ if (stage.mode !== undefined && stage.mode !== "sync" && stage.mode !== "async") {
710
+ throw new TypeError('authenticate() stage mode must be "sync" or "async"');
711
+ }
712
+ this.authStages.push(stage);
713
+ return this;
714
+ }
478
715
  decorate(key, value) {
479
716
  this.assertConfigurable("decorate()");
480
717
  this.decorations[key] = value;
@@ -491,6 +728,7 @@ export class Server {
491
728
  this.onRequestHooks.push(fn);
492
729
  this.onNodeRequestHooks.push(undefined);
493
730
  this.nodeRequestHooksComplete = false;
731
+ this.bunNativeRequestHooksSafe = false;
494
732
  return this;
495
733
  }
496
734
  /**
@@ -575,6 +813,7 @@ export class Server {
575
813
  this.onResponseHooks.push(fn);
576
814
  this.onNodeResponseHooks.push(undefined);
577
815
  this.nodeResponseHooksComplete = false;
816
+ this.bunNativeResponseHeadersOnly = false;
578
817
  return this;
579
818
  }
580
819
  /**
@@ -591,7 +830,8 @@ export class Server {
591
830
  * app.responseHeaders({ "x-frame-options": "DENY", "referrer-policy": "no-referrer" })
592
831
  *
593
832
  * They are DEFAULTS: a value the request itself produced (`c.set.headers`, or a response hook)
594
- * wins, whatever casing it used. Names are lowercased once here; a non-string value, an invalid
833
+ * wins, whatever casing it used. In a group, a name the group declares replaces the value its
834
+ * enclosing scopes declared before the group, on the group's routes. Names are lowercased once here; a non-string value, an invalid
595
835
  * name, `__proto__`, or a name the render owns (`content-type`, `content-length`,
596
836
  * `transfer-encoding`, `set-cookie`) throws a `TypeError` at wire-up.
597
837
  *
@@ -623,7 +863,7 @@ export class Server {
623
863
  // serialized-body marker survives for later `onResponseBody`/`onResponseRaw` observers and the
624
864
  // Node writer; reconstructing a `Response` here would strip that marker and reclassify the Node
625
865
  // outcome from `json` to a generic `response`. A guarded foreign response takes its clone path.
626
- const statics = buildStaticResponseHeaders(record);
866
+ const statics = buildStaticResponseHeaders(record, this.inheritedStatics);
627
867
  this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics));
628
868
  this.onNodeResponseHooks.push(undefined);
629
869
  this.nodeResponseHooksComplete = false;
@@ -632,7 +872,7 @@ export class Server {
632
872
  const merged = this.staticResponseHeaders === undefined
633
873
  ? record
634
874
  : { ...this.staticResponseHeaders.record, ...record };
635
- this.staticResponseHeaders = buildStaticResponseHeaders(merged);
875
+ this.staticResponseHeaders = buildStaticResponseHeaders(merged, this.inheritedStatics);
636
876
  const statics = this.staticResponseHeaders;
637
877
  this.wrapWebResponse = (response) => applyStaticResponseHeaders(webResponseOf(response), statics);
638
878
  this.webResponseTimeout = () => applyStaticResponseHeaders(RESPONSE_TIMEOUT(), statics);
@@ -660,24 +900,36 @@ export class Server {
660
900
  this.onResponseFinalizedHooks.push(fn);
661
901
  return this;
662
902
  }
663
- use(arg) {
903
+ use(input) {
904
+ // A method-routes plugin is an ordinary plugin function; only its type is opaque.
905
+ const arg = input;
664
906
  this.assertConfigurable("use()");
665
- if (typeof arg === "function") {
666
- const name = arg.pluginName;
667
- if (name !== undefined) {
668
- if (this.appliedPlugins.has(name))
669
- return this; // idempotent: already applied
670
- this.appliedPlugins.add(name);
907
+ const name = typeof arg === "function" ? arg.pluginName : arg.name;
908
+ if (name !== undefined) {
909
+ if (this.appliedPlugins.has(name)) {
910
+ const parent = this.inheritedPlugins?.get(name);
911
+ // A group's builder passing another instance of a plugin its parent applied would have it
912
+ // dropped, configuration and all. A plugin's own dependency use() shares the parent's copy.
913
+ if (parent !== undefined && parent !== arg) {
914
+ throw new RouteConfigError("PLUGIN_RECONFIGURED", `use() of ${JSON.stringify(name)} in a group: its parent applied another instance`);
915
+ }
916
+ return this; // idempotent: already applied
671
917
  }
918
+ this.appliedPlugins.set(name, arg);
919
+ }
920
+ if (typeof arg === "function") {
672
921
  const evidence = assuranceDeclarationsOf(arg);
673
922
  const pluginOnly = evidence.filter((item) => item.scope === "plugin");
674
923
  this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"));
675
924
  this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"));
676
925
  this.activeAssurance.push(...pluginOnly);
926
+ const inherited = this.inheritedPlugins;
927
+ this.inheritedPlugins = undefined;
677
928
  try {
678
929
  return arg(this);
679
930
  }
680
931
  finally {
932
+ this.inheritedPlugins = inherited;
681
933
  // Remove only this plugin's temporary evidence. Nested assured plugins may deliberately leave
682
934
  // subsequent evidence active, so truncating the whole array would lose real ordering semantics.
683
935
  for (const item of pluginOnly) {
@@ -687,11 +939,6 @@ export class Server {
687
939
  }
688
940
  }
689
941
  }
690
- if (arg.name !== undefined) {
691
- if (this.appliedPlugins.has(arg.name))
692
- return this;
693
- this.appliedPlugins.add(arg.name);
694
- }
695
942
  const evidence = assuranceDeclarationsOf(arg);
696
943
  if (evidence.some((item) => item.scope === "plugin")) {
697
944
  throw new Error('route assurance: scope "plugin" may only annotate a plugin function');
@@ -713,6 +960,9 @@ export class Server {
713
960
  this.onNodeRequestHooks.push(arg.onNodeRequest);
714
961
  if (arg.onNodeRequest === undefined)
715
962
  this.nodeRequestHooksComplete = false;
963
+ if (!arg[BUN_NATIVE_REQUEST_SAFE]) {
964
+ this.bunNativeRequestHooksSafe = false;
965
+ }
716
966
  }
717
967
  else if (arg.onNodeRequest !== undefined) {
718
968
  throw new TypeError("onNodeRequest() requires a paired onRequest() hook");
@@ -731,6 +981,7 @@ export class Server {
731
981
  this.nodeResponseHooksComplete = false;
732
982
  else
733
983
  this.hasRawNodeResponseHook = true;
984
+ this.bunNativeResponseHeadersOnly = false;
734
985
  }
735
986
  else if (arg.onNodeResponse !== undefined) {
736
987
  throw new TypeError("onNodeResponse() requires a paired onResponse() hook");
@@ -739,10 +990,18 @@ export class Server {
739
990
  // defaults its hook may then override, which is the order a single bundle reads in.
740
991
  if (arg.responseHeaders !== undefined)
741
992
  this.responseHeaders(arg.responseHeaders);
742
- if (arg.onResponseHeaders !== undefined)
743
- this.responseObserverMethods().onResponseHeaders(arg.onResponseHeaders);
744
- if (arg.onResponseBody !== undefined)
745
- this.responseObserverMethods().onResponseBody(arg.onResponseBody);
993
+ if (arg.onResponseHeaders !== undefined) {
994
+ this.responseObserverMethods().onResponseHeaders(arg.onResponseHeaders, arg.onNodeResponseHeaders);
995
+ }
996
+ else if (arg.onNodeResponseHeaders !== undefined) {
997
+ throw new TypeError("onNodeResponseHeaders() requires a paired onResponseHeaders() hook");
998
+ }
999
+ if (arg.onResponseBody !== undefined) {
1000
+ this.responseObserverMethods().onResponseBody(arg.onResponseBody, arg.onNodeResponseBody);
1001
+ }
1002
+ else if (arg.onNodeResponseBody !== undefined) {
1003
+ throw new TypeError("onNodeResponseBody() requires a paired onResponseBody() hook");
1004
+ }
746
1005
  if (arg.onResponseRaw !== undefined)
747
1006
  this.responseObserverMethods().onResponseRaw(arg.onResponseRaw);
748
1007
  if (arg.onResponseFinalized !== undefined)
@@ -861,10 +1120,11 @@ export class Server {
861
1120
  this.topics ??= runtime.createTopics();
862
1121
  // A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
863
1122
  // already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
864
- this.wsRouter.add("GET", path, {
865
- handler: runtime.wrapHandler(handler),
866
- });
1123
+ const entry = { handler: runtime.wrapHandler(handler, this.protoPoisoning) };
1124
+ for (const form of expandOptionalParams(path))
1125
+ this.wsRouter.add("GET", form, entry);
867
1126
  this.wsRouteCount += 1;
1127
+ wsChanges += 1;
868
1128
  return this;
869
1129
  }
870
1130
  /**
@@ -904,16 +1164,37 @@ export class Server {
904
1164
  * is the "compiled", order-scoped per-route chain.
905
1165
  */
906
1166
  register(method, path, schema, handler) {
907
- this.assertConfigurable("route registration");
908
- this.catalog.add(this.prepareRoute(method, path, schema, handler));
1167
+ this.registerBatch([{ method, path, schema, handler }]);
909
1168
  }
910
1169
  /** Register a contract/group route batch atomically. Every route captures the same current chain it
911
- * would capture through {@link register}; no route becomes visible unless the full batch validates. */
1170
+ * would capture through {@link register}; no route becomes visible unless the full batch validates.
1171
+ *
1172
+ * A path ending in optional parameters (`/users/:id?`) is one catalog route per concrete pattern,
1173
+ * so reflection, OpenAPI, evidence and the typed client all see ordinary routes. */
912
1174
  registerBatch(routes) {
913
1175
  this.assertConfigurable("route registration");
914
- const staged = routes.map(({ method, path, schema, handler }) => this.prepareRoute(method, path, schema, handler));
1176
+ const staged = [];
1177
+ for (const { method, path, schema, handler } of routes) {
1178
+ for (const form of expandOptionalParams(this.prefixed(path))) {
1179
+ staged.push(this.prepareRoute(method, form, schema, handler));
1180
+ }
1181
+ }
915
1182
  this.catalog.addBatch(staged);
916
1183
  }
1184
+ /** Join a group scope's prefix BEFORE compilation, so the pattern, the reflected descriptor, the
1185
+ * capability guard, the ledger record, and assurance path matching all see the one served path. */
1186
+ prefixed(path) {
1187
+ const prefix = this.routePrefix;
1188
+ if (prefix === "")
1189
+ return path;
1190
+ if (path === "/")
1191
+ return prefix;
1192
+ // Checked here, not left to the pattern compiler: `"/api" + "users"` is a valid-looking path
1193
+ // (`/apiusers`) that would silently escape the group instead of failing.
1194
+ if (path[0] === "/")
1195
+ return prefix + path;
1196
+ throw new RouteConfigError("INVALID_PATH", `group(${JSON.stringify(prefix)}) route path must start with "/": ${JSON.stringify(path)}`);
1197
+ }
917
1198
  prepareRoute(method, path, schema, handler) {
918
1199
  const compiled = compileRouteOptions({
919
1200
  maxBodyBytes: this.maxBodyBytes,
@@ -927,6 +1208,7 @@ export class Server {
927
1208
  idempotencyRuntime: this.idempotencyRuntime,
928
1209
  responseContractRuntime: this.responseContractRuntime,
929
1210
  derives: this.derives,
1211
+ authStages: this.authStages,
930
1212
  beforeHandleHooks: this.beforeHandleHooks,
931
1213
  afterHandleHooks: this.afterHandleHooks,
932
1214
  onErrorHooks: this.onErrorHooks,
@@ -934,16 +1216,30 @@ export class Server {
934
1216
  defaultOnValidationError: this.defaultOnValidationError,
935
1217
  }, method, path, schema, handler);
936
1218
  const { pattern, bodyLimit, capabilities, routeDecorations, hasDecorations, idempotent, ledgered, responseContract, lanes, routeAssurance, } = compiled;
1219
+ if (idempotent !== undefined && this.idempotencyRuntime !== undefined) {
1220
+ handler = this.idempotencyRuntime.wrapHandler(handler);
1221
+ }
937
1222
  const { bare, fusedQuery, fusedBody } = lanes;
938
- const fusedBodyRunner = fusedBody
939
- ? this.buildFusedBodyRunner(handler, schema?.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? UNLIMITED_BODY_BYTES)
940
- : undefined;
941
1223
  // Fused lifecycle lanes: derive + before (with or without an after), and body + the same shape.
942
1224
  // The lane selectors in `selectRouteLanes` are exhaustive about the lifecycleHookLane /
943
1225
  // body-derive-before-after eligibility, so we only need to check the lane and the hook counts.
944
1226
  const lifecycleHookLane = lanes.lifecycleHookLane;
945
1227
  const isFusedLifecycle = lifecycleHookLane !== undefined && this.derives.length === 1;
946
1228
  const isFusedBodyLifecycle = lanes.fusedLane === "body-derive-before" || lanes.fusedLane === "body-derive-before-after";
1229
+ // The generic runners below are the Node-direct twins of the fused Web lanes built further
1230
+ // down: same single-frame lifecycle, finalizing through the caller's own `finalize` instead of
1231
+ // building a `Response`, so the `!webFast` dispatcher prefers them over the generic program
1232
+ // exactly as it prefers the body-only runner. Only the original body-only lane may feed the Web
1233
+ // wrapper; every other runner here preserves its derive/before/after stages.
1234
+ const fusedBodyRunner = fusedBody
1235
+ ? this.buildFusedBodyRunner(handler, schema?.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? UNLIMITED_BODY_BYTES)
1236
+ : isFusedBodyLifecycle
1237
+ ? this.buildFusedBodyDeriveBeforeAfterRunner(handler, this.derives[0], this.beforeHandleHooks[0], this.afterHandleHooks[0], schema?.body, bodyLimit ?? UNLIMITED_BODY_BYTES)
1238
+ : isFusedLifecycle && lifecycleHookLane === "derive-before"
1239
+ ? this.buildFusedDeriveBeforeRunner(handler, this.derives[0], this.beforeHandleHooks[0], undefined, schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1240
+ : isFusedLifecycle && lifecycleHookLane === "derive-before-after"
1241
+ ? this.buildFusedDeriveBeforeRunner(handler, this.derives[0], this.beforeHandleHooks[0], this.afterHandleHooks[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1242
+ : undefined;
947
1243
  const fusedWeb = bare && this.aroundHooks.length === 0
948
1244
  ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ?? UNLIMITED_BODY_BYTES)
949
1245
  : fusedQuery
@@ -961,6 +1257,7 @@ export class Server {
961
1257
  schema,
962
1258
  handler: handler,
963
1259
  derives: this.derives,
1260
+ authStages: compiled.authStages,
964
1261
  beforeHandle: this.beforeHandleHooks,
965
1262
  afterHandle: this.afterHandleHooks,
966
1263
  onError: this.onErrorHooks,
@@ -988,6 +1285,7 @@ export class Server {
988
1285
  ledgered,
989
1286
  responseContract,
990
1287
  derives: [...this.derives],
1288
+ authStages: [...compiled.authStages],
991
1289
  decorations: routeDecorations,
992
1290
  hasDecorations,
993
1291
  beforeHandle: [...this.beforeHandleHooks],
@@ -1002,6 +1300,9 @@ export class Server {
1002
1300
  method,
1003
1301
  path,
1004
1302
  schema,
1303
+ ...(responseContract === undefined
1304
+ ? {}
1305
+ : { responseContract: responseContract.runtime.mode }),
1005
1306
  ...(capabilities.length > 0 ? { capabilities } : {}),
1006
1307
  ...(schema?.family === true ? { family: true } : {}),
1007
1308
  };
@@ -1029,11 +1330,13 @@ export class Server {
1029
1330
  this.assertConfigurable("responseObserver()");
1030
1331
  const methods = runtime.install({
1031
1332
  assertConfigurable: (operation) => this.assertConfigurable(operation),
1032
- addResponseHook: (web, node) => {
1333
+ addResponseHook: (web, node, headerOnly = false) => {
1033
1334
  this.onResponseHooks.push(web);
1034
1335
  this.onNodeResponseHooks.push(node);
1035
1336
  if (node === undefined)
1036
1337
  this.nodeResponseHooksComplete = false;
1338
+ if (!headerOnly)
1339
+ this.bunNativeResponseHeadersOnly = false;
1037
1340
  },
1038
1341
  enableResponseBodyTagging: () => {
1039
1342
  if (this.responseBodyTag === undefined) {
@@ -1057,6 +1360,11 @@ export class Server {
1057
1360
  this.assertConfigurable("mcp()");
1058
1361
  this.mcpRuntime = runtime;
1059
1362
  }
1363
+ /** @internal Symbol-keyed install seam for the `notFound()` plugin. Off the public typed surface. */
1364
+ [INSTALL_NOT_FOUND](lane) {
1365
+ this.assertConfigurable("notFound()");
1366
+ this.notFoundLane = lane;
1367
+ }
1060
1368
  /** @internal Symbol-keyed install seam for the `nodeDirect()` plugin. Off the public typed surface. */
1061
1369
  [INSTALL_NODE_DIRECT](runtime) {
1062
1370
  this.assertConfigurable("nodeDirect()");
@@ -1072,6 +1380,41 @@ export class Server {
1072
1380
  this.assertConfigurable("websocket()");
1073
1381
  this.wsRuntime = runtime;
1074
1382
  }
1383
+ /**
1384
+ * @internal The runtime Bun serves this app's WebSocket upgrades with, asked by `listen()` and by a
1385
+ * parent that mounts this app: `undefined` while nothing here can take one, `null` when something
1386
+ * can but no runtime is at hand. A mounted Nifra app answers through this same seam, so mounting an
1387
+ * app without a WebSocket route needs no runtime; a resolver without the seam cannot say, so it
1388
+ * counts. `seen` ends a mount cycle (an app mounted under itself to alias a prefix).
1389
+ */
1390
+ [GET_WS_RUNTIME](seen) {
1391
+ return this.wsRouteCount > 0 ? this.wsRuntime : this.mountedWsRuntime(seen);
1392
+ }
1393
+ /** The `[GET_WS_RUNTIME]` answer for the apps mounted here alone, whatever this app's own routes. */
1394
+ mountedWsRuntime(seen = new Set()) {
1395
+ if (seen.has(this))
1396
+ return;
1397
+ seen.add(this);
1398
+ let found;
1399
+ for (const mount of this.fetchMounts) {
1400
+ const runtime = mount.wsRuntime?.(seen);
1401
+ if (runtime === undefined)
1402
+ continue;
1403
+ found = this.wsRuntime ?? runtime;
1404
+ if (found)
1405
+ return found;
1406
+ }
1407
+ return found;
1408
+ }
1409
+ /** Whether an app mounted here takes WebSocket upgrades. Worked out again only after a `ws()` or a
1410
+ * `mount()` somewhere, so the upgrade check on a plain request costs one comparison. */
1411
+ mountsTakeUpgrades() {
1412
+ if (this.wsMountsCheckedAt !== wsChanges) {
1413
+ this.wsMountsLive = this.mountedWsRuntime() !== undefined;
1414
+ this.wsMountsCheckedAt = wsChanges;
1415
+ }
1416
+ return this.wsMountsLive;
1417
+ }
1075
1418
  /**
1076
1419
  * Merge another server's routes into this one - the composition escape hatch for large apps.
1077
1420
  *
@@ -1098,75 +1441,188 @@ export class Server {
1098
1441
  */
1099
1442
  merge(other) {
1100
1443
  this.assertConfigurable("merge()");
1444
+ if (!(other instanceof Server)) {
1445
+ // A server from another copy of this package reaches this copy's private request state through
1446
+ // symbols it does not share, and would fail per request. The boot warning names the copies.
1447
+ throw new TypeError("merge() requires a server() from this copy of @nifrajs/core");
1448
+ }
1449
+ if (this.routePrefix !== "") {
1450
+ // The merged routes were compiled under their own paths; adopting them here would publish them
1451
+ // OUTSIDE the prefix while looking like part of the group.
1452
+ throw new RouteConfigError("INVALID_PATH", "merge() inside a group cannot prefix compiled routes - merge on the parent");
1453
+ }
1101
1454
  const source = other;
1102
1455
  if (source.wsRouteCount > 0) {
1103
1456
  throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes - register .ws() routes on the parent server");
1104
1457
  }
1458
+ this.adopt(source, undefined, 0);
1459
+ return this;
1460
+ }
1461
+ /**
1462
+ * Declare routes under a static path prefix, with the prefix in the typed client, `routes()`,
1463
+ * OpenAPI, capability events, and the effect ledger - the counterpart of Hono's `route()`,
1464
+ * Elysia's `group()`, and Fastify's `register({ prefix })`.
1465
+ *
1466
+ * app
1467
+ * .derive(session)
1468
+ * .group("/admin", (admin) =>
1469
+ * admin.beforeHandle(requireAdmin).get("/users", listUsers).get("/users/:id", getUser),
1470
+ * )
1471
+ * .get("/", home) // not covered by requireAdmin
1472
+ *
1473
+ * The builder receives a scope that INHERITS this server's route chain as it stands at the call
1474
+ * (`derive`/`decorate`/`authenticate`/`beforeHandle`/`afterHandle`/`onError`/`around`, assurance,
1475
+ * capability interceptors, installed runtimes, applied plugins), exactly as a route declared here
1476
+ * would. A plugin this server already applied is in effect for the group: the group's `use()` of
1477
+ * that same instance does nothing, and its `use()` of another instance under the same name throws a
1478
+ * `RouteConfigError` (`PLUGIN_RECONFIGURED`), because that instance could never replace the parent's.
1479
+ * A plugin the group applies still shares the parent's copy of a dependency it `use()`s itself.
1480
+ * What it adds stays in the group: its chain additions apply only to its own routes, and its
1481
+ * request/response hooks (`onRequest`, `onResponse`, `responseHeaders`, `onResponseFinalized`, and
1482
+ * middleware bundles that use them) run only for requests whose path is the prefix or below it.
1483
+ * Per-route authorization belongs in `authenticate`/`beforeHandle`, which are compiled into each
1484
+ * route; request hooks are path prefilters.
1485
+ *
1486
+ * The prefix is static text - `"/api"`, `"/api/v1"` - validated when declared: no params,
1487
+ * wildcards, empty or dot segments, percent-escapes, or trailing slash. A group's `"/"` route
1488
+ * serves the prefix itself. Groups nest (`"/api"` then `"/v1"` serves `/api/v1/...`).
1489
+ *
1490
+ * Fail closed: the builder must synchronously return the scope it was given; a collision with an
1491
+ * existing route throws `RouteConfigError` and adds none of the group's routes; `.ws()`, mounts,
1492
+ * MCP `.tool()`s, and `merge()` inside a group are refused (register them on the parent). Once the
1493
+ * builder returns, the scope is closed - a leaked reference cannot add routes later.
1494
+ */
1495
+ group(prefix, build) {
1496
+ this.assertConfigurable("group()");
1497
+ if (typeof prefix !== "string" || !GROUP_PREFIX.test(prefix)) {
1498
+ throw new RouteConfigError("INVALID_PATH", `group() prefix must be a static path like "/api/v1": ${JSON.stringify(prefix)}`);
1499
+ }
1500
+ const fullPrefix = this.routePrefix + prefix;
1501
+ const scope = new Server(this.options);
1502
+ scope.routePrefix = fullPrefix;
1503
+ scope.inheritedStatics = { ...this.inheritedStatics, ...this.staticResponseHeaders?.record };
1504
+ for (const key of GROUP_CHAIN)
1505
+ scope[key].push(...this[key]);
1506
+ for (const key of SERVER_RUNTIMES)
1507
+ scope[key] = this[key];
1508
+ Object.assign(scope.decorations, this.decorations);
1509
+ // A plugin already applied here is already in effect for the group (its chain was copied above
1510
+ // and its request hooks are app-wide), so re-applying it would run it twice per request. The
1511
+ // group's own plugins are NOT copied back: they are scoped, and a parent `use()` of the same
1512
+ // plugin must still apply it to the parent's routes. The response observer is the exception: it
1513
+ // installs methods bound to the server it is applied to, so the scope must be free to install its
1514
+ // own (re-applying it registers no hook, so nothing can run twice).
1515
+ for (const [name, plugin] of this.appliedPlugins) {
1516
+ if (name !== "nifra:response-observer")
1517
+ scope.appliedPlugins.set(name, plugin);
1518
+ }
1519
+ scope.inheritedPlugins = new Map(scope.appliedPlugins);
1520
+ let built;
1521
+ try {
1522
+ built = build(scope);
1523
+ // Only `listen()` seals a server, so a scope sealed here was served on its own - without this
1524
+ // server's request hooks - which a group must never be.
1525
+ if (scope.sealed)
1526
+ built = undefined;
1527
+ }
1528
+ finally {
1529
+ // Closed on every exit, including a throw: an async continuation or a leaked reference must
1530
+ // not register routes that nothing will ever adopt.
1531
+ scope.sealed = true;
1532
+ }
1533
+ const label = `group(${JSON.stringify(fullPrefix)})`;
1534
+ if (built !== scope) {
1535
+ throw new TypeError(`${label} builder must return its group synchronously`);
1536
+ }
1537
+ if (scope.wsRouteCount > 0 ||
1538
+ scope.fetchMounts.length > 0 ||
1539
+ scope.catalog.entries().some((route) => route.descriptor.tool !== undefined)) {
1540
+ throw new RouteConfigError("INVALID_PATH", `${label} cannot hold ws(), mount, or MCP tool routes - add them to the parent`);
1541
+ }
1542
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1543
+ sealHookAudit(scope, scope.catalog.size, this.logger);
1544
+ this.adopt(scope, fullPrefix, this.globalAssurance.length);
1545
+ return this;
1546
+ }
1547
+ /**
1548
+ * Take over another server's routes and hooks - the shared core of {@link merge} and {@link group}.
1549
+ * `prefix` selects group semantics: hooks are scoped to requests under the prefix (response hooks
1550
+ * included), and the source's global assurance past `inheritedGlobal` (what it copied from this
1551
+ * server) is folded into its routes. Without a prefix, merge semantics: request hooks are scoped to
1552
+ * the source's routes when it has any, response hooks are appended app-wide.
1553
+ */
1554
+ adopt(source, prefix, inheritedGlobal) {
1105
1555
  const sourceRoutes = source.catalog.entries();
1106
1556
  // Hooks and global assurance stay LOCAL to a group that has routes; a route-less group is a
1107
1557
  // middleware bundle whose hooks can only mean app-wide intent, so it keeps the global append.
1108
- const scoped = sourceRoutes.length > 0;
1109
- // A group's global assurance rides its (route-scoped) hooks: folded into each merged route's
1110
- // own evidence rather than the parent's global list, so `routes()` never claims the group's
1558
+ // A prefix group is always local: its hooks mean "under this path", routes or not.
1559
+ const scoped = prefix !== undefined || sourceRoutes.length > 0;
1560
+ const ownGlobal = source.globalAssurance.slice(inheritedGlobal);
1561
+ // A group's global assurance rides its (scoped) hooks: folded into each adopted route's own
1562
+ // evidence rather than the parent's global list, so `routes()` never claims the group's
1111
1563
  // enforcement for parent routes the group's hooks do not see.
1112
- const foldedAssurance = scoped && source.globalAssurance.length > 0
1564
+ const foldedAssurance = scoped && ownGlobal.length > 0
1113
1565
  ? (route) => ({
1114
1566
  ...route,
1115
- assurance: [...route.assurance, ...source.globalAssurance],
1567
+ assurance: [...route.assurance, ...ownGlobal],
1116
1568
  })
1117
1569
  : (route) => route;
1118
1570
  this.catalog.addBatch(sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(route))));
1119
1571
  // Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
1120
- // runtime object supplies the generic execution machinery. Preserve a group's installed runtime
1121
- // when the parent has none so merging cannot silently disable a safety lane. If the parent already
1122
- // has a runtime, either implementation can execute every resolved entry because route-specific
1123
- // options were pinned during registration.
1124
- this.responseContractRuntime ??= source.responseContractRuntime;
1125
- this.idempotencyRuntime ??= source.idempotencyRuntime;
1126
- this.effectLedgerRuntime ??= source.effectLedgerRuntime;
1127
- this.mcpRuntime ??= source.mcpRuntime;
1128
- this.nodeOutcomeRuntime ??= source.nodeOutcomeRuntime;
1129
- this.sseRuntime ??= source.sseRuntime;
1130
- this.wsRuntime ??= source.wsRuntime;
1131
- if (scoped && source.onRequestHooks.length > 0) {
1132
- // Snapshot the group's routes into a dedicated matcher: the guard must reflect what was
1133
- // MERGED, not whatever the group's own catalog grows into afterwards. One probe against it
1134
- // gates each group hook to requests the group would serve; everything else passes untouched.
1135
- const scope = new RouteCatalog();
1136
- scope.addBatch(sourceRoutes);
1137
- this.onRequestHooks.push(...source.onRequestHooks.map((hook) => (req, platform) => scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined));
1138
- // An `undefined` slot marks an unpaired hook (position-aligned with `onRequestHooks`) and
1139
- // must stay `undefined` - wrapping it would fabricate a Node twin that never existed.
1140
- this.onNodeRequestHooks.push(...source.onNodeRequestHooks.map((hook) => hook === undefined
1141
- ? undefined
1142
- : (req, platform) => scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined));
1143
- }
1144
- else {
1145
- this.onRequestHooks.push(...source.onRequestHooks);
1146
- this.onNodeRequestHooks.push(...source.onNodeRequestHooks);
1147
- }
1572
+ // runtime object supplies the generic execution machinery. If the parent already has a runtime,
1573
+ // either implementation can execute every resolved entry because route-specific options were
1574
+ // pinned during registration.
1575
+ const runtimes = this;
1576
+ for (const key of SERVER_RUNTIMES)
1577
+ runtimes[key] ??= source[key];
1578
+ // Which requests a scoped hook sees: under the prefix for a group; for a merged server with
1579
+ // routes, a snapshot of exactly the routes MERGED (not whatever its own catalog grows into later).
1580
+ const inScope = prefix !== undefined
1581
+ ? underPrefix(prefix)
1582
+ : scoped && source.onRequestHooks.length > 0
1583
+ ? servedBy(sourceRoutes)
1584
+ : undefined;
1585
+ this.onRequestHooks.push(...scopeHooks(source.onRequestHooks, inScope, 0));
1586
+ this.onNodeRequestHooks.push(...scopeHooks(source.onNodeRequestHooks, inScope, 0));
1148
1587
  this.nodeRequestHooksComplete &&= source.nodeRequestHooksComplete;
1149
- // The group's static declarations came before its own response hooks, so they are folded in
1150
- // first - and fold themselves into a hook here if this server already has one (same ordering
1151
- // rule as a direct `responseHeaders()` call).
1152
- if (source.staticResponseHeaders !== undefined) {
1153
- this.addStaticResponseHeaders({ ...source.staticResponseHeaders.record });
1154
- }
1155
- this.onResponseHooks.push(...source.onResponseHooks);
1156
- this.onNodeResponseHooks.push(...source.onNodeResponseHooks);
1588
+ this.bunNativeRequestHooksSafe &&= source.bunNativeRequestHooksSafe;
1589
+ // A merged server's response hooks are app-wide; a group's run only under its prefix.
1590
+ const responseScope = prefix === undefined ? undefined : inScope;
1591
+ const statics = source.staticResponseHeaders;
1592
+ if (statics !== undefined) {
1593
+ if (responseScope === undefined) {
1594
+ // The group's static declarations came before its own response hooks, so they are folded in
1595
+ // first - and fold themselves into a hook here if this server already has one (same ordering
1596
+ // rule as a direct `responseHeaders()` call).
1597
+ this.addStaticResponseHeaders({ ...statics.record });
1598
+ }
1599
+ else {
1600
+ // The static tier is folded into EVERY response this server builds, so a group's static
1601
+ // headers cannot join it. A hook ahead of the group's own (the sealed scope is discarded
1602
+ // after adoption) keeps their meaning - defaults a value already on the response wins over.
1603
+ source.onResponseHooks.unshift((response) => applyStaticResponseHeaders(response, statics));
1604
+ source.onNodeResponseHooks.unshift(undefined);
1605
+ source.nodeResponseHooksComplete = false;
1606
+ }
1607
+ }
1608
+ this.onResponseHooks.push(...scopeHooks(source.onResponseHooks, responseScope, 1, true));
1609
+ this.onNodeResponseHooks.push(...scopeHooks(source.onNodeResponseHooks, responseScope, 1));
1610
+ this.onResponseFinalizedHooks.push(...scopeHooks(source.onResponseFinalizedHooks, responseScope, 1));
1157
1611
  this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete;
1612
+ this.bunNativeResponseHeadersOnly &&= source.bunNativeResponseHeadersOnly;
1158
1613
  this.hasRawNodeResponseHook ||= source.hasRawNodeResponseHook;
1159
- this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks);
1160
1614
  if (source.responseBodyTag !== undefined) {
1161
1615
  const owner = this.enableResponseBodyTagging();
1162
1616
  this.responseBodyOwners.add(source.responseBodyTag);
1163
1617
  source.responseBodyOwners.add(owner);
1164
1618
  }
1165
1619
  if (!scoped)
1166
- this.globalAssurance.push(...source.globalAssurance);
1620
+ this.globalAssurance.push(...ownGlobal);
1621
+ // Cleanup the source registered (a pool, a queue consumer) belongs to the server that now runs
1622
+ // its routes; dropping it would leak the resource past `stop()`.
1623
+ this.stopHooks.push(...source.stopHooks);
1167
1624
  this.mcpResourceList.push(...source.mcpResourceList);
1168
1625
  this.mcpPromptList.push(...source.mcpPromptList);
1169
- return this;
1170
1626
  }
1171
1627
  /** A fused renderer closes over runtime services to keep its seven-argument JSC fast path. Merging
1172
1628
  * rebinds that closure once to the executing server; generic plans already receive the runtime. */
@@ -1179,15 +1635,30 @@ export class Server {
1179
1635
  const lane = entry.execution.fusedLane;
1180
1636
  // Type-erase the lane so each `===` branch sees the full union, not the narrowed remainder.
1181
1637
  const laneName = lane;
1182
- // Body+lifecycle routes have a fused Web renderer, but no body-only Node renderer: the latter
1183
- // would bypass derive/before/after because the Node-direct dispatcher prefers `fusedBody` over
1184
- // the generic execution plan. Only the original body-only lane may populate this slot.
1185
- const fusedBody = laneName === "body"
1186
- ? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
1187
- : undefined;
1638
+ // Body+lifecycle routes have a fused Web renderer, and - unlike before - a generic fused
1639
+ // runner too: it preserves derive/before/after (it IS the lifecycle, single-framed), so the
1640
+ // Node-direct dispatcher's preference for the generic slot over the execution plan stays
1641
+ // semantics-preserving. Only the original body-only lane may feed the Web wrapper below.
1188
1642
  const derive = entry.derives[0];
1189
1643
  const before = entry.beforeHandle[0];
1190
1644
  const after = entry.afterHandle[0];
1645
+ const fusedBody = laneName === "body"
1646
+ ? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
1647
+ : laneName === "body-derive-before" && derive !== undefined && before !== undefined
1648
+ ? this.buildFusedBodyDeriveBeforeAfterRunner(entry.handler, derive, before, undefined, entry.schema?.body, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
1649
+ : laneName === "body-derive-before-after" &&
1650
+ derive !== undefined &&
1651
+ before !== undefined &&
1652
+ after !== undefined
1653
+ ? this.buildFusedBodyDeriveBeforeAfterRunner(entry.handler, derive, before, after, entry.schema?.body, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
1654
+ : laneName === "derive-before" && derive !== undefined && before !== undefined
1655
+ ? this.buildFusedDeriveBeforeRunner(entry.handler, derive, before, undefined, entry.schema?.query, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
1656
+ : laneName === "derive-before-after" &&
1657
+ derive !== undefined &&
1658
+ before !== undefined &&
1659
+ after !== undefined
1660
+ ? this.buildFusedDeriveBeforeRunner(entry.handler, derive, before, after, entry.schema?.query, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
1661
+ : undefined;
1191
1662
  const fusedWeb = laneName === "body"
1192
1663
  ? this.buildFusedBodyWeb(fusedBody)
1193
1664
  : laneName === "query"
@@ -1237,6 +1708,10 @@ export class Server {
1237
1708
  * Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
1238
1709
  */
1239
1710
  fetch(req, platform) {
1711
+ // Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
1712
+ // Route what the parsed URL says, so `/users/../admin` is `/admin` on every runtime.
1713
+ if (rawRequestTargets && hasDotSegment(req.url))
1714
+ req = withResolvedTarget(req);
1240
1715
  // An edge deployment whose only ingress is this method declares its requests runtime-framed at
1241
1716
  // construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
1242
1717
  if (this.trustBodyFraming)
@@ -1272,13 +1747,14 @@ export class Server {
1272
1747
  }
1273
1748
  /** Web response path when Bun already matched the route. The lifecycle and response hooks remain
1274
1749
  * exactly the same as {@link fetchSource}; only portable URL scanning + trie lookup are skipped. */
1275
- fetchMatched(source, entry, params) {
1276
- if (this.capacityGate === undefined)
1277
- return this.fetchMatchedInner(source, entry, params);
1278
- return this.admitGated(requestOf(source), () => this.fetchMatchedInner(source, entry, params));
1750
+ fetchMatched(source, entry, params, platform) {
1751
+ if (this.capacityGate === undefined) {
1752
+ return this.fetchMatchedInner(source, entry, params, platform);
1753
+ }
1754
+ return this.admitGated(requestOf(source), () => this.fetchMatchedInner(source, entry, params, platform));
1279
1755
  }
1280
- fetchMatchedInner(source, entry, params) {
1281
- const outcome = this.runMatched(source, undefined, entry, params, undefined, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, true);
1756
+ fetchMatchedInner(source, entry, params, platform) {
1757
+ const outcome = this.runMatched(source, platform, entry, params, undefined, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, true);
1282
1758
  if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
1283
1759
  return outcome;
1284
1760
  }
@@ -1344,19 +1820,26 @@ export class Server {
1344
1820
  }
1345
1821
  const parts = source.urlParts ?? urlPartsOf(source.url);
1346
1822
  const match = this.catalog.find(source.method, parts.pathname);
1347
- if (match.found)
1823
+ if (match.found || !isRoutableMethod(source.method))
1348
1824
  return undefined;
1825
+ // The mount the ordinary path reaches first: every `mount()` runs before any `mountFetch()`,
1826
+ // whatever their prefix lengths. A 404 fallthrough chain stays on the ordinary path.
1827
+ const mount = this.firstMountUnder(parts.pathname, true) ?? this.firstMountUnder(parts.pathname, false);
1828
+ if (mount === undefined || mount.fallbackOn404)
1829
+ return undefined;
1830
+ const candidate = mount.handler[NODE_NATIVE_MOUNT];
1831
+ return typeof candidate === "function"
1832
+ ? {
1833
+ handler: candidate,
1834
+ path: mount.path,
1835
+ stripPrefix: mount.stripPrefix,
1836
+ }
1837
+ : undefined;
1838
+ }
1839
+ firstMountUnder(pathname, beforeRoutes) {
1349
1840
  for (const mount of this.fetchMounts) {
1350
- if (!underMountPrefix(parts.pathname, mount.path))
1351
- continue;
1352
- const candidate = mount.handler[NODE_NATIVE_MOUNT];
1353
- return typeof candidate === "function"
1354
- ? {
1355
- handler: candidate,
1356
- path: mount.path,
1357
- stripPrefix: mount.stripPrefix,
1358
- }
1359
- : undefined;
1841
+ if (mount.beforeRoutes === beforeRoutes && underMountPrefix(pathname, mount.path))
1842
+ return mount;
1360
1843
  }
1361
1844
  return undefined;
1362
1845
  }
@@ -1495,6 +1978,7 @@ export class Server {
1495
1978
  const context = {
1496
1979
  status: outcome.status,
1497
1980
  headers,
1981
+ headersAreLowercase: !this.hasRawNodeResponseHook && lowercaseKeys,
1498
1982
  cookies: outcome.kind === "json" ? outcome.cookies : undefined,
1499
1983
  body: outcome.body,
1500
1984
  };
@@ -1612,10 +2096,21 @@ export class Server {
1612
2096
  }
1613
2097
  }
1614
2098
  resolveWebSocketUpgrade(req, platform) {
1615
- if (this.wsRouteCount === 0)
2099
+ // Before any header is read: Deno only materializes a request's headers when asked, and that
2100
+ // costs a plain request measurably.
2101
+ if (this.wsRouteCount === 0 && !this.mountsTakeUpgrades())
1616
2102
  return WS_PASS;
1617
2103
  if (req.headers.get("upgrade")?.toLowerCase() !== "websocket")
1618
2104
  return WS_PASS;
2105
+ // A WebSocket handshake is a GET (RFC 6455 section 4.1). Any other method with an Upgrade header
2106
+ // is an ordinary request: its HTTP route answers it, and normal routing refuses an unknown token.
2107
+ if (req.method !== "GET")
2108
+ return WS_PASS;
2109
+ // The handshake routes the path `fetch` would: dot segments resolved (see `fetch`).
2110
+ if (rawRequestTargets && hasDotSegment(req.url))
2111
+ req = withResolvedTarget(req);
2112
+ // The handshake's caller is the one `fetch` derives: hooks and `upgrade()` see the trusted IP.
2113
+ platform = this.trustedPlatform(req, platform);
1619
2114
  const timeoutMs = this.wsUpgradeTimeoutMs === 0
1620
2115
  ? 0
1621
2116
  : this.requestTimeoutMs > 0
@@ -1702,8 +2197,57 @@ export class Server {
1702
2197
  return { kind: "reject", response: jsonError(500, "internal_error") };
1703
2198
  }
1704
2199
  };
2200
+ const resolveMounted = (request) => {
2201
+ // Native pub/sub was chosen because no mounted app took upgrades when `listen()` ran. One that
2202
+ // gains a WebSocket route later stays out: its sockets would share this server's Bun topics.
2203
+ if (this.nativePublish !== undefined)
2204
+ return WS_PASS;
2205
+ const pathname = urlPartsOf(request.url).pathname;
2206
+ const dispatch = (start) => {
2207
+ let selected;
2208
+ let selectedIndex = start;
2209
+ for (let i = start; i < this.fetchMounts.length; i++) {
2210
+ const candidate = this.fetchMounts[i];
2211
+ if (candidate.resolveWebSocketUpgrade !== undefined &&
2212
+ underMountPrefix(pathname, candidate.path)) {
2213
+ selected = candidate;
2214
+ selectedIndex = i;
2215
+ break;
2216
+ }
2217
+ }
2218
+ if (selected === undefined)
2219
+ return WS_PASS;
2220
+ const childRequest = selected.stripPrefix
2221
+ ? stripMountPrefix(request, selected.path)
2222
+ : request;
2223
+ let child;
2224
+ try {
2225
+ child = selected.resolveWebSocketUpgrade(childRequest, platform);
2226
+ }
2227
+ catch {
2228
+ return { kind: "reject", response: jsonError(500, "internal_error") };
2229
+ }
2230
+ const settle = (outcome) => outcome.kind === "pass" ||
2231
+ (outcome.kind === "reject" && outcome.response.status === 404 && selected.fallbackOn404)
2232
+ ? dispatch(selectedIndex + 1)
2233
+ : outcome;
2234
+ return child instanceof Promise ? child.then(settle) : settle(child);
2235
+ };
2236
+ return dispatch(0);
2237
+ };
2238
+ const resolveRoutes = (request) => {
2239
+ const local = resolveMatched(request);
2240
+ if (local instanceof Promise) {
2241
+ return local.then((outcome) => outcome.kind === "pass" ? resolveMounted(request) : outcome);
2242
+ }
2243
+ return local.kind === "pass" ? resolveMounted(request) : local;
2244
+ };
2245
+ const resolveWithDeadline = (request) => {
2246
+ const outcome = resolveRoutes(request);
2247
+ return outcome instanceof Promise ? bounded(outcome, onTimeout) : outcome;
2248
+ };
1705
2249
  if (this.onRequestHooks.length === 0)
1706
- return resolveMatched(req);
2250
+ return resolveWithDeadline(req);
1707
2251
  let hooked;
1708
2252
  try {
1709
2253
  hooked = this.runWebSocketRequestHooks(req, platform);
@@ -1714,11 +2258,11 @@ export class Server {
1714
2258
  if (!(hooked instanceof Promise)) {
1715
2259
  return hooked instanceof Response
1716
2260
  ? { kind: "reject", response: hooked }
1717
- : resolveMatched(hooked);
2261
+ : resolveWithDeadline(hooked);
1718
2262
  }
1719
2263
  const pending = hooked.then((result) => result instanceof Response
1720
2264
  ? { kind: "reject", response: result }
1721
- : resolveMatched(result), () => ({ kind: "reject", response: jsonError(500, "internal_error") }));
2265
+ : resolveWithDeadline(result), () => ({ kind: "reject", response: jsonError(500, "internal_error") }));
1722
2266
  return bounded(Promise.resolve(pending).then((result) => result), onTimeout);
1723
2267
  }
1724
2268
  /** Bun `fetch` when WS routes exist: try a WS upgrade first, else run the normal HTTP lifecycle.
@@ -1730,7 +2274,9 @@ export class Server {
1730
2274
  return this.fetch(req, bunPeerPlatform(server, req));
1731
2275
  if (o.kind === "reject")
1732
2276
  return o.response;
1733
- return server.upgrade(req, { data: { handler: o.handler, data: o.data } })
2277
+ return server.upgrade(req, {
2278
+ data: { handler: o.handler, data: o.data, pubsub: o.pubsub },
2279
+ })
1734
2280
  ? undefined
1735
2281
  : jsonError(426, "upgrade_required");
1736
2282
  };
@@ -1750,6 +2296,12 @@ export class Server {
1750
2296
  const derived = resolveClientIp(platform?.clientIp, requestOf(source), this.clientIpTrust);
1751
2297
  return { ...platform, clientIp: derived };
1752
2298
  }
2299
+ trustedPlatform(source, platform) {
2300
+ return this.clientIpTrust === undefined ||
2301
+ platform?.[NIFRA_PLATFORM_CLIENT_IP_DERIVED] === true
2302
+ ? platform
2303
+ : this.deriveClientIp(source, platform);
2304
+ }
1753
2305
  dispatch(source, platform, finalize, wrapResponse, onTimeout,
1754
2306
  // True only from the Web `fetch` path - unlocks each route's fused lane, whose output type IS
1755
2307
  // `Response` (`T = Response` there by construction; the node path always passes false).
@@ -1757,10 +2309,14 @@ export class Server {
1757
2309
  // Resolve the trust declaration into the platform's `clientIp` ONCE, here at the shared funnel, so
1758
2310
  // `c.clientIp` (and every hook/derive downstream) sees the derived caller. No config ⇒ the raw
1759
2311
  // socket peer the adapter supplied passes through untouched (a one-property no-op on the hot path).
1760
- const resolved = this.clientIpTrust === undefined ? platform : this.deriveClientIp(source, platform);
2312
+ // A platform an enclosing nifra server already resolved (an in-process call from an SSR loader)
2313
+ // keeps its `clientIp`: the synthesized request carries none of the visitor's forwarding headers.
2314
+ const resolved = this.trustedPlatform(source, platform);
1761
2315
  // onRequest hooks may be async, so a hooked app takes the async path; with no hooks (the common
1762
2316
  // case) routing stays synchronous, letting a bare route resolve with no lifecycle promise at all.
1763
- if (this.onRequestHooks.length === 0) {
2317
+ // A token no route can be registered under skips the hooks as well: it cannot match, so routing
2318
+ // answers it 404 or 405 and no hook reads a method string it would misjudge.
2319
+ if (this.onRequestHooks.length === 0 || !isRoutableMethod(source.method)) {
1764
2320
  return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast);
1765
2321
  }
1766
2322
  if (!webFast && this.canUseNodeRequestHooks()) {
@@ -1816,11 +2372,14 @@ export class Server {
1816
2372
  this.responseSources.set(source, originalRequest);
1817
2373
  let current = source;
1818
2374
  for (let i = 0; i < hooks.length; i++) {
1819
- const outcome = hooks[i](requestOf(current), platform);
2375
+ const seen = requestOf(current);
2376
+ const outcome = hooks[i](seen, platform);
1820
2377
  if (outcome instanceof Promise) {
1821
2378
  return outcome.then((early) => this.continueOnRequest(early, i + 1, originalRequest, current, platform, finalize, wrapResponse, onTimeout, webFast));
1822
2379
  }
1823
2380
  if (outcome instanceof Request) {
2381
+ if (outcome !== seen)
2382
+ recordRequestReplacement(outcome, seen);
1824
2383
  current = outcome;
1825
2384
  if (outcome !== originalRequest)
1826
2385
  this.responseRequests.set(originalRequest, outcome);
@@ -1835,10 +2394,13 @@ export class Server {
1835
2394
  * the remaining hooks (awaiting freely - we're already async here). */
1836
2395
  async continueOnRequest(first, nextIndex, originalRequest, sourceAtAwait, platform, finalize, wrapResponse, onTimeout, webFast) {
1837
2396
  let current = sourceAtAwait;
2397
+ let seen = requestOf(sourceAtAwait);
1838
2398
  let early = first;
1839
2399
  let index = nextIndex;
1840
2400
  for (;;) {
1841
2401
  if (early instanceof Request) {
2402
+ if (early !== seen)
2403
+ recordRequestReplacement(early, seen);
1842
2404
  current = early;
1843
2405
  if (early !== originalRequest)
1844
2406
  this.responseRequests.set(originalRequest, early);
@@ -1848,7 +2410,8 @@ export class Server {
1848
2410
  }
1849
2411
  if (index >= this.onRequestHooks.length)
1850
2412
  break;
1851
- const outcome = this.onRequestHooks[index](requestOf(current), platform);
2413
+ seen = requestOf(current);
2414
+ const outcome = this.onRequestHooks[index](seen, platform);
1852
2415
  early = outcome instanceof Promise ? await outcome : outcome;
1853
2416
  index++;
1854
2417
  }
@@ -1964,9 +2527,20 @@ export class Server {
1964
2527
  search = searchStart === -1 ? "" : rawUrl.slice(searchStart, searchEnd);
1965
2528
  }
1966
2529
  }
2530
+ // Explicit composed mounts run before the parent's route table. This is what lets an SSR shell
2531
+ // mount an API at `/api` without its catch-all page route swallowing the request. Legacy
2532
+ // `mountFetch()` stays behind typed routes and is checked in the existing branch below.
2533
+ if (this.preRouteMountCount > 0) {
2534
+ const mounted = this.fetchMount(pathname, source, platform, true);
2535
+ if (mounted !== undefined) {
2536
+ return mounted instanceof Promise
2537
+ ? mounted.then((response) => wrapResponse(response))
2538
+ : wrapResponse(mounted);
2539
+ }
2540
+ }
1967
2541
  const match = this.catalog.find(source.method, pathname);
1968
2542
  if (!match.found) {
1969
- const mounted = this.fetchMount(pathname, source, platform);
2543
+ const mounted = this.fetchMount(pathname, source, platform, false);
1970
2544
  if (mounted !== undefined) {
1971
2545
  return mounted instanceof Promise
1972
2546
  ? mounted.then((response) => wrapResponse(response))
@@ -1978,7 +2552,10 @@ export class Server {
1978
2552
  // and every other name in that record is lowercase (the Web lane normalizes either way).
1979
2553
  plainError(405, "method_not_allowed", { allow: match.allowed.join(", ") }));
1980
2554
  }
1981
- return wrapResponse(plainError(404, "not_found"));
2555
+ const lane = this.notFoundLane;
2556
+ return lane === undefined
2557
+ ? wrapResponse(plainError(404, "not_found"))
2558
+ : lane(this, source, pathname, platform, wrapResponse, onTimeout);
1982
2559
  }
1983
2560
  // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
1984
2561
  // already did and made every plain dynamic route pay for unrelated static path bytes.
@@ -1988,14 +2565,46 @@ export class Server {
1988
2565
  }
1989
2566
  return this.runMatched(source, platform, match.payload, params, search, finalize, wrapResponse, onTimeout, webFast);
1990
2567
  }
1991
- fetchMount(pathname, source, platform) {
1992
- for (const mount of this.fetchMounts) {
1993
- if (!underMountPrefix(pathname, mount.path))
2568
+ fetchMount(pathname, source, platform, beforeRoutes) {
2569
+ const request = requestOf(source);
2570
+ let first = -1;
2571
+ for (let i = 0; i < this.fetchMounts.length; i++) {
2572
+ const mount = this.fetchMounts[i];
2573
+ if (mount.beforeRoutes !== beforeRoutes || !underMountPrefix(pathname, mount.path))
1994
2574
  continue;
1995
- const request = requestOf(source);
1996
- return mount.handler(mount.stripPrefix ? stripMountPrefix(request, mount.path) : request, platform);
2575
+ first = i;
2576
+ break;
1997
2577
  }
1998
- return undefined;
2578
+ // A mounted handler is arbitrary code that may read the method loosely, so it is only handed a
2579
+ // token a route could be registered under; anything else is left to the route table to refuse.
2580
+ if (first === -1 || !isRoutableMethod(source.method))
2581
+ return undefined;
2582
+ // A request body is a one-shot stream. Only methods whose request semantics are replayable are
2583
+ // eligible for 404 fallthrough; this intentionally refuses POST/PUT/PATCH even when the body is
2584
+ // empty because an adapter may expose a delayed stream or a protocol-specific side effect.
2585
+ const replayable = source.method === "GET" || source.method === "HEAD" || source.method === "OPTIONS";
2586
+ const dispatch = (index, fallback) => {
2587
+ let mount;
2588
+ for (let i = index; i < this.fetchMounts.length; i++) {
2589
+ const candidate = this.fetchMounts[i];
2590
+ if (candidate.beforeRoutes === beforeRoutes && underMountPrefix(pathname, candidate.path)) {
2591
+ mount = candidate;
2592
+ index = i;
2593
+ break;
2594
+ }
2595
+ }
2596
+ if (mount === undefined)
2597
+ return fallback ?? jsonError(404, "not_found");
2598
+ const childRequest = mount.stripPrefix ? stripMountPrefix(request, mount.path) : request;
2599
+ const outcome = mount.handler(childRequest, platform);
2600
+ if (!replayable || !mount.fallbackOn404)
2601
+ return outcome;
2602
+ if (outcome instanceof Promise) {
2603
+ return outcome.then((response) => response.status === 404 ? dispatch(index + 1, response) : response);
2604
+ }
2605
+ return outcome.status === 404 ? dispatch(index + 1, outcome) : outcome;
2606
+ };
2607
+ return dispatch(first);
1999
2608
  }
2000
2609
  /** Run a route that has already been matched by the runtime or Nifra's portable router. */
2001
2610
  runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
@@ -2008,8 +2617,10 @@ export class Server {
2008
2617
  // handler can reach `c.req`; that lane already enforces `entry.bodyLimit`. Installing the full
2009
2618
  // direct-reader cap on `c.req` here would allocate bound readers, closures, and a stream wrapper
2010
2619
  // for a body that is already consumed. Keep the lazy transport cap for raw-body routes, where a
2011
- // user read is the only framework-owned body boundary.
2012
- if (entry.bodyLimit !== undefined && entry.schema?.body === undefined) {
2620
+ // user read is the only framework-owned body boundary, and for the auth-first lane, whose
2621
+ // derive and beforeHandle hooks reach `c.req` before the body is read.
2622
+ if (entry.bodyLimit !== undefined &&
2623
+ (entry.schema?.body === undefined || entry.program.authBeforeValidation)) {
2013
2624
  const method = source.method;
2014
2625
  if (method !== "GET" && method !== "HEAD")
2015
2626
  markTransportCap(source, entry.bodyLimit);
@@ -2256,7 +2867,7 @@ export class Server {
2256
2867
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2257
2868
  : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
2258
2869
  const finish = (value) => runParsed(value, ctx, finalize, wrapResponse);
2259
- return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, finish, wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse));
2870
+ return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, finish, wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse), bodySchema);
2260
2871
  };
2261
2872
  }
2262
2873
  /** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
@@ -2628,14 +3239,294 @@ export class Server {
2628
3239
  ctx.body = validation.value;
2629
3240
  return runHandlerValidated();
2630
3241
  };
2631
- return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, onParsed, (response) => this.wrapWebResponse(response), (err) => Promise.resolve(logError(err, ctx)));
3242
+ return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, onParsed, (response) => this.wrapWebResponse(response), (err) => Promise.resolve(logError(err, ctx)), bodySchema);
3243
+ };
3244
+ }
3245
+ /**
3246
+ * Generic (finalize/wrapResponse-parameterized) fused runner for the derive-before(-after) lane:
3247
+ * the Node-direct counterpart of `buildFusedDeriveBefore` / `buildFusedDeriveBeforeAfter`.
3248
+ *
3249
+ * The Web fused builders above return a `Response`, so the Node dispatcher (`webFast === false`)
3250
+ * cannot use them and a derive+before route falls back to the generic route program there - two
3251
+ * staged loops, per-stage dispatch, and the async-continuation machinery - while Bun rides the
3252
+ * single closure. That asymmetry is the realistic-route Node deficit against Fastify: this runner
3253
+ * closes it by running validate → derive → before → handler → (after) in one frame and finalizing
3254
+ * through the caller's own `finalize` (the Node outcome on the direct lane, `toResponse` on Web),
3255
+ * so the same registration feeds both runtimes with no per-lane copy of the lifecycle to drift.
3256
+ *
3257
+ * Semantics mirror the generic program stage-for-stage for the shapes this lane admits (exactly
3258
+ * one derive, one beforeHandle, zero-or-one afterHandle, no onError/around/decorations/contracts/
3259
+ * recovery hooks - enforced by `selectRouteLanes`): thrown `Response`/`status(...)` are control
3260
+ * flow through `wrapResponse`/`finalize`, anything else logs + 500s via the shared `bareError`
3261
+ * lane (identical to `handleLifecycleError` with no `onError` hooks), and a terminal `finalize`
3262
+ * throw (e.g. an unstringifiable handler return on the Node lane) is caught exactly as
3263
+ * `finishLifecycleContract` catches it. Step functions close over registration state only and take
3264
+ * the request state as arguments, so the hot path allocates no per-request closures.
3265
+ */
3266
+ buildFusedDeriveBeforeRunner(handler, derive, before, after, querySchema, maxBodyBytes) {
3267
+ const logError = (err, ctx, finalize, wrapResponse) => this.bareError(err, ctx, finalize, wrapResponse);
3268
+ const runFinish = (result, ctx, finalize, wrapResponse) => {
3269
+ if (after === undefined) {
3270
+ try {
3271
+ return finalize(result, responseSet(ctx), ctx);
3272
+ }
3273
+ catch (err) {
3274
+ return logError(err, ctx, finalize, wrapResponse);
3275
+ }
3276
+ }
3277
+ let transformed;
3278
+ try {
3279
+ transformed = after(result, ctx);
3280
+ }
3281
+ catch (err) {
3282
+ return logError(err, ctx, finalize, wrapResponse);
3283
+ }
3284
+ if (transformed instanceof Promise) {
3285
+ return transformed.then((value) => {
3286
+ try {
3287
+ return finalize(value, responseSet(ctx), ctx);
3288
+ }
3289
+ catch (err) {
3290
+ return logError(err, ctx, finalize, wrapResponse);
3291
+ }
3292
+ }, (err) => logError(err, ctx, finalize, wrapResponse));
3293
+ }
3294
+ try {
3295
+ return finalize(transformed, responseSet(ctx), ctx);
3296
+ }
3297
+ catch (err) {
3298
+ return logError(err, ctx, finalize, wrapResponse);
3299
+ }
3300
+ };
3301
+ const finalizeEarly = (result, ctx, finalize, wrapResponse) => {
3302
+ try {
3303
+ return finalize(result, responseSet(ctx), ctx);
3304
+ }
3305
+ catch (err) {
3306
+ return logError(err, ctx, finalize, wrapResponse);
3307
+ }
3308
+ };
3309
+ const runHandler = (ctx, finalize, wrapResponse) => {
3310
+ let result;
3311
+ try {
3312
+ result = handler(ctx);
3313
+ }
3314
+ catch (err) {
3315
+ return logError(err, ctx, finalize, wrapResponse);
3316
+ }
3317
+ if (result instanceof Promise) {
3318
+ return result.then((value) => runFinish(value, ctx, finalize, wrapResponse), (err) => logError(err, ctx, finalize, wrapResponse));
3319
+ }
3320
+ return runFinish(result, ctx, finalize, wrapResponse);
3321
+ };
3322
+ const runBefore = (ctx, finalize, wrapResponse) => {
3323
+ let early;
3324
+ try {
3325
+ early = before(ctx);
3326
+ }
3327
+ catch (err) {
3328
+ return logError(err, ctx, finalize, wrapResponse);
3329
+ }
3330
+ if (early instanceof Promise) {
3331
+ return early.then((settled) => settled === undefined
3332
+ ? runHandler(ctx, finalize, wrapResponse)
3333
+ : finalizeEarly(settled, ctx, finalize, wrapResponse), (err) => logError(err, ctx, finalize, wrapResponse));
3334
+ }
3335
+ if (early === undefined)
3336
+ return runHandler(ctx, finalize, wrapResponse);
3337
+ return finalizeEarly(early, ctx, finalize, wrapResponse);
3338
+ };
3339
+ const runDerive = (ctx, finalize, wrapResponse) => {
3340
+ let derived;
3341
+ try {
3342
+ derived = derive(ctx);
3343
+ }
3344
+ catch (err) {
3345
+ return logError(err, ctx, finalize, wrapResponse);
3346
+ }
3347
+ if (derived instanceof Promise) {
3348
+ return derived.then((settled) => {
3349
+ if (isResponseResult(settled) || settled instanceof Response) {
3350
+ return finalizeEarly(settled, ctx, finalize, wrapResponse);
3351
+ }
3352
+ Object.assign(ctx, settled);
3353
+ return runBefore(ctx, finalize, wrapResponse);
3354
+ }, (err) => logError(err, ctx, finalize, wrapResponse));
3355
+ }
3356
+ if (isResponseResult(derived) || derived instanceof Response) {
3357
+ return finalizeEarly(derived, ctx, finalize, wrapResponse);
3358
+ }
3359
+ Object.assign(ctx, derived);
3360
+ return runBefore(ctx, finalize, wrapResponse);
3361
+ };
3362
+ const runValidated = (validation, ctx, finalize, wrapResponse) => {
3363
+ if (validation instanceof Promise) {
3364
+ return validation.then((settled) => {
3365
+ if (settled.issues !== undefined)
3366
+ return wrapResponse(plainValidationError(settled.issues));
3367
+ ctx.query = settled.value;
3368
+ return runDerive(ctx, finalize, wrapResponse);
3369
+ }, (err) => logError(err, ctx, finalize, wrapResponse));
3370
+ }
3371
+ if (validation.issues !== undefined)
3372
+ return wrapResponse(plainValidationError(validation.issues));
3373
+ ctx.query = validation.value;
3374
+ return runDerive(ctx, finalize, wrapResponse);
3375
+ };
3376
+ return (source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
3377
+ const ctx = nativeContext
3378
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3379
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
3380
+ if (querySchema === undefined)
3381
+ return runDerive(ctx, finalize, wrapResponse);
3382
+ let validation;
3383
+ try {
3384
+ validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
3385
+ }
3386
+ catch (err) {
3387
+ return logError(err, ctx, finalize, wrapResponse);
3388
+ }
3389
+ return runValidated(validation, ctx, finalize, wrapResponse);
3390
+ };
3391
+ }
3392
+ /**
3393
+ * Generic fused runner for the body+derive-before(-after) lane: the Node-direct counterpart of
3394
+ * `buildFusedBodyDeriveBeforeAfter`. Same contract as `buildFusedDeriveBeforeRunner` above - one
3395
+ * frame running parse → validate → derive → before → handler → (after) through the caller's own
3396
+ * `finalize` - with the body read ahead of validation, exactly as the generic program orders its
3397
+ * body stage before the derive/before stages. `readBodyFramed` keeps the shared bounded-read,
3398
+ * content-type, and prototype-poisoning contracts; the 415/400 renders take `wrapResponse`
3399
+ * directly, which is what the generic lane's wrap-after-identity amounts to.
3400
+ */
3401
+ buildFusedBodyDeriveBeforeAfterRunner(handler, derive, before, after, bodySchema, maxBodyBytes) {
3402
+ const logError = (err, ctx, finalize, wrapResponse) => this.bareError(err, ctx, finalize, wrapResponse);
3403
+ const runFinish = (result, ctx, finalize, wrapResponse) => {
3404
+ if (after === undefined) {
3405
+ try {
3406
+ return finalize(result, responseSet(ctx), ctx);
3407
+ }
3408
+ catch (err) {
3409
+ return logError(err, ctx, finalize, wrapResponse);
3410
+ }
3411
+ }
3412
+ let transformed;
3413
+ try {
3414
+ transformed = after(result, ctx);
3415
+ }
3416
+ catch (err) {
3417
+ return logError(err, ctx, finalize, wrapResponse);
3418
+ }
3419
+ if (transformed instanceof Promise) {
3420
+ return transformed.then((value) => {
3421
+ try {
3422
+ return finalize(value, responseSet(ctx), ctx);
3423
+ }
3424
+ catch (err) {
3425
+ return logError(err, ctx, finalize, wrapResponse);
3426
+ }
3427
+ }, (err) => logError(err, ctx, finalize, wrapResponse));
3428
+ }
3429
+ try {
3430
+ return finalize(transformed, responseSet(ctx), ctx);
3431
+ }
3432
+ catch (err) {
3433
+ return logError(err, ctx, finalize, wrapResponse);
3434
+ }
3435
+ };
3436
+ const finalizeEarly = (result, ctx, finalize, wrapResponse) => {
3437
+ try {
3438
+ return finalize(result, responseSet(ctx), ctx);
3439
+ }
3440
+ catch (err) {
3441
+ return logError(err, ctx, finalize, wrapResponse);
3442
+ }
3443
+ };
3444
+ const runHandler = (ctx, finalize, wrapResponse) => {
3445
+ let result;
3446
+ try {
3447
+ result = handler(ctx);
3448
+ }
3449
+ catch (err) {
3450
+ return logError(err, ctx, finalize, wrapResponse);
3451
+ }
3452
+ if (result instanceof Promise) {
3453
+ return result.then((value) => runFinish(value, ctx, finalize, wrapResponse), (err) => logError(err, ctx, finalize, wrapResponse));
3454
+ }
3455
+ return runFinish(result, ctx, finalize, wrapResponse);
3456
+ };
3457
+ const runBefore = (ctx, finalize, wrapResponse) => {
3458
+ let early;
3459
+ try {
3460
+ early = before(ctx);
3461
+ }
3462
+ catch (err) {
3463
+ return logError(err, ctx, finalize, wrapResponse);
3464
+ }
3465
+ if (early instanceof Promise) {
3466
+ return early.then((settled) => settled === undefined
3467
+ ? runHandler(ctx, finalize, wrapResponse)
3468
+ : finalizeEarly(settled, ctx, finalize, wrapResponse), (err) => logError(err, ctx, finalize, wrapResponse));
3469
+ }
3470
+ if (early === undefined)
3471
+ return runHandler(ctx, finalize, wrapResponse);
3472
+ return finalizeEarly(early, ctx, finalize, wrapResponse);
3473
+ };
3474
+ const runDerive = (ctx, finalize, wrapResponse) => {
3475
+ let derived;
3476
+ try {
3477
+ derived = derive(ctx);
3478
+ }
3479
+ catch (err) {
3480
+ return logError(err, ctx, finalize, wrapResponse);
3481
+ }
3482
+ if (derived instanceof Promise) {
3483
+ return derived.then((settled) => {
3484
+ if (isResponseResult(settled) || settled instanceof Response) {
3485
+ return finalizeEarly(settled, ctx, finalize, wrapResponse);
3486
+ }
3487
+ Object.assign(ctx, settled);
3488
+ return runBefore(ctx, finalize, wrapResponse);
3489
+ }, (err) => logError(err, ctx, finalize, wrapResponse));
3490
+ }
3491
+ if (isResponseResult(derived) || derived instanceof Response) {
3492
+ return finalizeEarly(derived, ctx, finalize, wrapResponse);
3493
+ }
3494
+ Object.assign(ctx, derived);
3495
+ return runBefore(ctx, finalize, wrapResponse);
3496
+ };
3497
+ const onParsed = (parsed, ctx, finalize, wrapResponse) => {
3498
+ let validation;
3499
+ try {
3500
+ validation = bodySchema["~standard"].validate(parsed);
3501
+ }
3502
+ catch (err) {
3503
+ return logError(err, ctx, finalize, wrapResponse);
3504
+ }
3505
+ if (validation instanceof Promise) {
3506
+ return validation.then((settled) => {
3507
+ if (settled.issues !== undefined)
3508
+ return wrapResponse(plainValidationError(settled.issues));
3509
+ ctx.body = settled.value;
3510
+ return runDerive(ctx, finalize, wrapResponse);
3511
+ }, (err) => logError(err, ctx, finalize, wrapResponse));
3512
+ }
3513
+ if (validation.issues !== undefined)
3514
+ return wrapResponse(plainValidationError(validation.issues));
3515
+ ctx.body = validation.value;
3516
+ return runDerive(ctx, finalize, wrapResponse);
3517
+ };
3518
+ return (source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
3519
+ const ctx = nativeContext
3520
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3521
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
3522
+ return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, (parsed) => onParsed(parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse), bodySchema);
2632
3523
  };
2633
3524
  }
2634
3525
  bareError(err, ctx, finalize, wrapResponse) {
2635
3526
  // The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
2636
3527
  // lane and the fused body runner. `responseSet` and the logger are injected because they reach
2637
3528
  // into class state; the closure is allocated only on the throw path, never on the hot lane.
2638
- return renderBareError(err, ctx, finalize, wrapResponse, responseSet, (e, c) => this.logRequestError(e, c), () => this.internalErrorResponse(wrapResponse));
3529
+ return renderBareError(err, ctx, finalize, responseSet, (e, c) => this.logRequestError(e, c), () => this.internalErrorResponse(wrapResponse));
2639
3530
  }
2640
3531
  /**
2641
3532
  * Keep the ordinary thrown-error response independent from the caught value. Deliberate Response
@@ -2665,15 +3556,13 @@ export class Server {
2665
3556
  /** Bind a compiled validation stage to its stable schema and the existing recovery contract. */
2666
3557
  // @ts-expect-error TS6133 -- invoked structurally by the general route program
2667
3558
  validateProgramStage(entry, stage, source, ctx) {
2668
- const input = stage.kind === "headers"
2669
- ? headerObjectOf(source.headers)
2670
- : stage.kind === "params"
2671
- ? ctx.params
2672
- : stage.kind === "query"
2673
- ? queryObjectOf(ctx[CONTEXT_SEARCH])
2674
- : undefined;
2675
3559
  if (stage.kind === "body")
2676
3560
  return this.readProgramBody(entry, source, ctx);
3561
+ const input = stage.kind === "headers"
3562
+ ? headerObjectOf(source.headers)
3563
+ : stage.kind === "query"
3564
+ ? queryObjectOf(ctx[CONTEXT_SEARCH])
3565
+ : ctx[stage.kind];
2677
3566
  const validation = stage.schema["~standard"].validate(input);
2678
3567
  return validation instanceof Promise
2679
3568
  ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, stage.kind))
@@ -2713,7 +3602,7 @@ export class Server {
2713
3602
  }
2714
3603
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2715
3604
  runBodyOnly(entry, source, ctx, finalize, wrapResponse) {
2716
- return readBodyFramed(source, entry.bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
3605
+ return readBodyFramed(source, entry.bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse), entry.schema?.body);
2717
3606
  }
2718
3607
  /** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
2719
3608
  * streaming fallback. A method (not per-request closures) so the hot path allocates nothing
@@ -2961,7 +3850,7 @@ export class Server {
2961
3850
  const contract = entry.responseContract;
2962
3851
  if (contract === undefined)
2963
3852
  return finalize(result, responseSet(ctx));
2964
- const checked = contract.runtime.check(contract.schema, result);
3853
+ const checked = contract.runtime.check(contract.definition, result, ctx);
2965
3854
  if (checked instanceof Promise) {
2966
3855
  return checked.then((outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2967
3856
  }
@@ -2996,15 +3885,16 @@ export class Server {
2996
3885
  * loop, which may await, drops into {@link runErrorHooks}. */
2997
3886
  handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
2998
3887
  // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
2999
- // an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
3000
- // or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
3001
- // any handler or loader.
3888
+ // an action throws an error page. Send it as the same Response returned would be sent (Remix/
3889
+ // SvelteKit semantics): as built, plus the cookies the request queued, so a session set or
3890
+ // cleared before `throw redirect(...)` / `requireSession(...)` ships. Don't run onError or log
3891
+ // it as a 500.
3002
3892
  if (err instanceof Response)
3003
- return wrapResponse(err);
3893
+ return finalize(err, responseSet(ctx));
3004
3894
  // Same rule for a thrown `status(...)`, but through `finalize` rather than `wrapResponse`: the
3005
3895
  // value is still plain data, so the ordinary JSON lane renders it and no `Response` is built.
3006
3896
  if (isResponseResult(err))
3007
- return finalize(err, responseSet(ctx));
3897
+ return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, err);
3008
3898
  if (entry.onError.length === 0) {
3009
3899
  // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
3010
3900
  // (redacting) logger. Body-read failures and around-hook failures land here too.
@@ -3043,20 +3933,14 @@ export class Server {
3043
3933
  ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, "body"))
3044
3934
  : this.applyLifecycleValidation(entry, validation, ctx, "body");
3045
3935
  };
3046
- return readBodyFramed(req, bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, validate, (response) => response, (error) => Promise.reject(error));
3936
+ return readBodyFramed(req, bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, validate, (response) => response, (error) => Promise.reject(error), bodySchema);
3047
3937
  }
3048
3938
  /** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
3049
3939
  * before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
3050
3940
  applyLifecycleValidation(entry, result, ctx, kind) {
3051
3941
  const assign = (value) => {
3052
- if (kind === "body")
3053
- ctx.body = value;
3054
- else if (kind === "query")
3055
- ctx.query = value;
3056
- else if (kind === "headers")
3057
- ctx.headers = value;
3058
- else
3059
- ctx.params = value;
3942
+ ;
3943
+ ctx[kind] = value;
3060
3944
  };
3061
3945
  if (result.issues === undefined) {
3062
3946
  assign(result.value);
@@ -3076,14 +3960,7 @@ export class Server {
3076
3960
  return plainValidationError(issues);
3077
3961
  if (recovery instanceof Response)
3078
3962
  return recovery;
3079
- const schema = kind === "body"
3080
- ? entry.schema?.body
3081
- : kind === "query"
3082
- ? entry.schema?.query
3083
- : kind === "params"
3084
- ? entry.schema?.params
3085
- : entry.schema?.headers;
3086
- const retried = schema["~standard"].validate(recovery);
3963
+ const retried = entry.schema[kind]["~standard"].validate(recovery);
3087
3964
  if (retried instanceof Promise) {
3088
3965
  return retried.then((settled) => {
3089
3966
  if (settled.issues !== undefined)
@@ -3099,7 +3976,7 @@ export class Server {
3099
3976
  }
3100
3977
  /** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
3101
3978
  * semantics remain in the plan; this closure only supplies native params and deadline fallback. */
3102
- compileBunNativeHandler(entry, paramNames, fused, signal, budget) {
3979
+ compileBunNativeHandler(entry, paramNames, fused, signal, budget, peer, fallback) {
3103
3980
  // This callback is reached only from Bun's compiled native route table. Bun has already parsed
3104
3981
  // the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
3105
3982
  // the portable/adapted source contract (those sources are never marked).
@@ -3107,7 +3984,9 @@ export class Server {
3107
3984
  // The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
3108
3985
  // marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
3109
3986
  // reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
3110
- const bodyLimit = entry.schema?.body === undefined ? entry.bodyLimit : undefined;
3987
+ const bodyLimit = entry.schema?.body === undefined || entry.program.authBeforeValidation
3988
+ ? entry.bodyLimit
3989
+ : undefined;
3111
3990
  const inner = fused;
3112
3991
  const capped = inner === undefined || bodyLimit === undefined
3113
3992
  ? inner
@@ -3117,73 +3996,90 @@ export class Server {
3117
3996
  markTransportCap(source, bodyLimit);
3118
3997
  return inner(source, params, search, fusedSignal, fusedBudget, platform, nativeContext);
3119
3998
  };
3999
+ const nativeResponseHooks = this.onResponseHooks.length > 0 && this.bunNativeResponseHeadersOnly;
4000
+ const finishNative = (request, response) => {
4001
+ if (!nativeResponseHooks)
4002
+ return response;
4003
+ return this.applyOnResponseAndFinalize(response, request);
4004
+ };
4005
+ const runNative = (request, params) => {
4006
+ const outcome = capped(request, params, undefined, signal, budget, peer, true);
4007
+ return outcome instanceof Promise
4008
+ ? outcome.then((response) => finishNative(request, response))
4009
+ : finishNative(request, outcome);
4010
+ };
3120
4011
  if (paramNames.length === 0) {
3121
4012
  if (capped === undefined) {
3122
4013
  return (request) => {
3123
4014
  markFramed(request);
3124
- return this.fetchMatched(request, entry, EMPTY_PARAMS);
4015
+ return this.fetchMatched(request, entry, EMPTY_PARAMS, peer);
3125
4016
  };
3126
4017
  }
3127
4018
  if (this.acceptInboundDeadlines) {
3128
4019
  return (request) => {
3129
4020
  markFramed(request);
3130
4021
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
3131
- ? this.fetchMatched(request, entry, EMPTY_PARAMS)
3132
- : capped(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
4022
+ ? this.fetchMatched(request, entry, EMPTY_PARAMS, peer)
4023
+ : capped(request, EMPTY_PARAMS, undefined, signal, budget, peer, true);
3133
4024
  };
3134
4025
  }
3135
4026
  return (request) => {
3136
4027
  markFramed(request);
3137
- return capped(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
4028
+ return runNative(request, EMPTY_PARAMS);
3138
4029
  };
3139
4030
  }
3140
4031
  const malformed = paramNames.length === 1
3141
- ? (params) => params[paramNames[0]]?.includes("\uFFFD") === true
3142
- : hasReplacementParam;
4032
+ ? (params) => unroutedParam(params[paramNames[0]])
4033
+ : hasUnroutedParam;
3143
4034
  if (capped === undefined) {
3144
- return (request) => {
4035
+ return (request, server) => {
3145
4036
  markFramed(request);
3146
4037
  const params = request.params ?? EMPTY_PARAMS;
3147
4038
  if (malformed(params))
3148
- return this.fetchSource(request);
3149
- return this.fetchMatched(request, entry, params);
4039
+ return fallback(request, server);
4040
+ return this.fetchMatched(request, entry, params, peer);
3150
4041
  };
3151
4042
  }
3152
4043
  if (this.acceptInboundDeadlines) {
3153
- return (request) => {
4044
+ return (request, server) => {
3154
4045
  markFramed(request);
3155
4046
  const params = request.params ?? EMPTY_PARAMS;
3156
4047
  if (malformed(params))
3157
- return this.fetchSource(request);
4048
+ return fallback(request, server);
3158
4049
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
3159
- ? this.fetchMatched(request, entry, params)
3160
- : capped(request, params, undefined, signal, budget, undefined, true);
4050
+ ? this.fetchMatched(request, entry, params, peer)
4051
+ : capped(request, params, undefined, signal, budget, peer, true);
3161
4052
  };
3162
4053
  }
3163
- return (request) => {
4054
+ return (request, server) => {
3164
4055
  markFramed(request);
3165
4056
  const params = request.params ?? EMPTY_PARAMS;
3166
4057
  if (malformed(params))
3167
- return this.fetchSource(request);
3168
- return capped(request, params, undefined, signal, budget, undefined, true);
4058
+ return fallback(request, server);
4059
+ return runNative(request, params);
3169
4060
  };
3170
4061
  }
3171
4062
  /** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
3172
4063
  * hooks or WebSockets retain the single portable dispatcher because those features must run before
3173
- * route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
3174
- * capture semantics; static and `:param` routes take the native lane. */
3175
- buildBunNativeRoutes() {
4064
+ * route selection/upgrade.
4065
+ *
4066
+ * Bun and the portable router agree on which of two static-and-`:param` paths wins a request, so
4067
+ * the table is kept to those paths. Where Bun would otherwise pass a request on to a less specific
4068
+ * path, the more specific one carries `fallback`, the portable dispatcher, under that method.
4069
+ * `peer` is the platform every request served from the table shares. */
4070
+ buildBunNativeRoutes(fallback, peer) {
3176
4071
  // A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
3177
4072
  // bypasses - so an app that declares trust routes through the fetch lane (where `c.clientIp`
3178
4073
  // resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
3179
- if (this.onRequestHooks.length > 0 ||
4074
+ if ((this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe) ||
3180
4075
  this.wsRouteCount > 0 ||
4076
+ this.preRouteMountCount > 0 ||
3181
4077
  this.clientIpTrust !== undefined) {
3182
4078
  return undefined;
3183
4079
  }
3184
4080
  const routes = Object.create(null);
3185
4081
  const mayUseFusedNative = this.requestTimeoutMs === 0 &&
3186
- this.onResponseHooks.length === 0 &&
4082
+ (this.onResponseHooks.length === 0 || this.bunNativeResponseHeadersOnly) &&
3187
4083
  this.onResponseFinalizedHooks.length === 0 &&
3188
4084
  // The capacity gate must wrap every request; the fused lane bypasses fetchMatched, so enabling
3189
4085
  // admission drops fusion (native matching stays) and routes through the gated matched lane.
@@ -3191,18 +4087,65 @@ export class Server {
3191
4087
  const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined;
3192
4088
  const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined;
3193
4089
  let count = 0;
3194
- for (const { method, path, pattern, entry } of this.catalog.entries()) {
3195
- if (pattern.segments.some((segment) => segment.kind === "wildcard"))
4090
+ const all = this.catalog.entries();
4091
+ // A path with a wildcard or a part-literal segment cannot go in the table: Bun reads `/:name.json`
4092
+ // as one parameter named `name.json`. These are filed under each run of static segments they
4093
+ // start with, which is where a table path they could outrank looks for them.
4094
+ const under = new Map();
4095
+ const lead = all.map((route) => {
4096
+ const segments = route.pattern.segments;
4097
+ const plain = segments.every((segment) => segment.kind === "static" || segment.kind === "param");
4098
+ let prefix = "";
4099
+ for (const segment of segments) {
4100
+ if (!plain) {
4101
+ let bucket = under.get(prefix);
4102
+ if (bucket === undefined) {
4103
+ bucket = [];
4104
+ under.set(prefix, bucket);
4105
+ }
4106
+ bucket.push(route);
4107
+ }
4108
+ if (segment.kind !== "static")
4109
+ break;
4110
+ prefix += `/${segment.value}`;
4111
+ }
4112
+ return plain ? prefix : undefined;
4113
+ });
4114
+ // A preflight hook is intentionally handled by the fallback fetch path. Keeping OPTIONS out
4115
+ // of the native lane means Bun dispatches it through CORS's onRequest hook even when the
4116
+ // same path has native GET/POST handlers.
4117
+ const preflightOnFallback = this.onRequestHooks.length > 0 && this.bunNativeRequestHooksSafe;
4118
+ // Where Bun could pass a request down: the methods served natively on a path with a parameter,
4119
+ // as segment count + method. Two table paths match one request only at the same segment count.
4120
+ const below = new Set();
4121
+ const tableAt = (path) => {
4122
+ routes[path] ??= Object.create(null);
4123
+ return routes[path];
4124
+ };
4125
+ for (let i = 0; i < all.length; i++) {
4126
+ const { method, path, pattern, entry } = all[i];
4127
+ const run = lead[i];
4128
+ // The route is served from the table unless its path cannot be there, Bun's table has no slot
4129
+ // for its method, or a path outside the table outranks it: Bun would hand this route a request
4130
+ // the portable router gives to that one. An idempotent route stays off it too: its lanes run on
4131
+ // a buffered copy of the request, and Bun names the peer only of the request it delivered, so
4132
+ // the platform the table shares could not answer `c.clientIp` there.
4133
+ if (run === undefined ||
4134
+ entry.idempotent !== undefined ||
4135
+ !METHODS.includes(method) ||
4136
+ (preflightOnFallback && method === "OPTIONS") ||
4137
+ under.get(run)?.some((other) => outranks(other.pattern.segments, pattern.segments))) {
3196
4138
  continue;
3197
- let methods = routes[path];
3198
- if (methods === undefined) {
3199
- methods = Object.create(null);
3200
- routes[path] = methods;
3201
4139
  }
3202
4140
  const paramNames = pattern.paramNames;
3203
4141
  const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined;
3204
- methods[method] = this.compileBunNativeHandler(entry, paramNames, fused, unboundedSignal, unboundedBudget);
4142
+ tableAt(path)[method] = this.compileBunNativeHandler(entry, paramNames, fused, unboundedSignal, unboundedBudget, peer, fallback);
3205
4143
  count += 1;
4144
+ if (paramNames.length > 0) {
4145
+ below.add(pattern.segments.length + method);
4146
+ if (method === "GET")
4147
+ below.add(`${pattern.segments.length}HEAD`);
4148
+ }
3206
4149
  }
3207
4150
  if (count === 0)
3208
4151
  return undefined;
@@ -3215,6 +4158,19 @@ export class Server {
3215
4158
  if (methods.GET !== undefined)
3216
4159
  methods.HEAD ??= methods.GET;
3217
4160
  }
4161
+ // Bun picks among the paths its table holds for the request's method, so a path with no entry
4162
+ // for the method lets the request through to a less specific one. The portable router stops at
4163
+ // the most specific path and answers 405. Every path the table could hold therefore gets the
4164
+ // portable dispatcher under each method a less specific path might serve.
4165
+ for (let i = 0; i < all.length; i++) {
4166
+ if (lead[i] === undefined)
4167
+ continue;
4168
+ const { path, pattern } = all[i];
4169
+ for (const method of METHODS) {
4170
+ if (below.has(pattern.segments.length + method))
4171
+ tableAt(path)[method] ??= fallback;
4172
+ }
4173
+ }
3218
4174
  return routes;
3219
4175
  }
3220
4176
  /**
@@ -3238,6 +4194,10 @@ export class Server {
3238
4194
  * connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
3239
4195
  * this above their slowest expected response, which is why it is a first-class option and not a
3240
4196
  * reason to drop down to `Bun.serve`. `0` disables the timeout entirely; max 255.
4197
+ *
4198
+ * `tls` serves HTTPS directly: `app.listen(443, { tls: { cert, key } })` with PEM text or bytes.
4199
+ * Requests then arrive with `https:` URLs. Behind a proxy or a platform that terminates TLS, leave
4200
+ * it unset.
3241
4201
  */
3242
4202
  listen(port, options) {
3243
4203
  if (typeof Bun === "undefined") {
@@ -3262,31 +4222,57 @@ export class Server {
3262
4222
  // because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
3263
4223
  // Native pub/sub when the app has WS routes and none validate outbound frames: `ws.subscribe` and
3264
4224
  // `app.publish` go through Bun's own (uWebSockets) broadcast instead of the JS registry loop.
3265
- const nativePubsub = this.wsRouteCount > 0 && !this.wsHasValidatedSend;
3266
- const wsHandlers = this.wsRouteCount === 0
3267
- ? undefined
3268
- : this.wsRuntime.bunHandlers(this.topics, nativePubsub);
3269
- const reusePort = options?.reusePort === true;
3270
- // Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
3271
- // on some option paths, and omitting is what selects its 0.0.0.0 default.
3272
- const bind = options?.hostname === undefined ? {} : { hostname: options.hostname };
3273
- // Same reasoning as `bind`: omit rather than pass undefined, so Bun's own default applies.
3274
- const idle = options?.idleTimeoutSec === undefined ? {} : { idleTimeout: options.idleTimeoutSec };
3275
- const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined;
4225
+ // A mounted app counts only while it has a WebSocket route (see `[GET_WS_RUNTIME]`), so composing
4226
+ // one without any needs no runtime here.
4227
+ const wsRuntime = this[GET_WS_RUNTIME]();
4228
+ if (wsRuntime === null) {
4229
+ throw new FrameworkError("INVALID_WS_RUNTIME", "a Bun server with mounted WebSocket routes needs the websocket() runtime installed on the parent or child app");
4230
+ }
4231
+ // Native pub/sub is safe only when every upgrade belongs to this app. A mounted child that takes
4232
+ // upgrades owns a different TopicRegistry, so force the per-connection JS dispatcher and carry
4233
+ // that registry in the upgrade outcome instead of accidentally broadcasting child sockets through
4234
+ // the parent.
4235
+ const nativePubsub = this.wsRouteCount > 0 && !this.mountsTakeUpgrades() && !this.wsHasValidatedSend;
4236
+ const wsHandlers = wsRuntime?.bunHandlers(this.topics ?? wsRuntime.createTopics(), nativePubsub);
4237
+ // Spread rather than pass `hostname: undefined` (or an undefined idle timeout or TLS) - Bun treats
4238
+ // an explicit undefined as a value on some option paths, and omitting is what selects its own
4239
+ // defaults, 0.0.0.0 among them.
4240
+ const listening = {
4241
+ port,
4242
+ reusePort: options?.reusePort === true,
4243
+ ...(options?.hostname === undefined ? {} : { hostname: options.hostname }),
4244
+ ...(options?.idleTimeoutSec === undefined ? {} : { idleTimeout: options.idleTimeoutSec }),
4245
+ ...(options?.tls === undefined ? {} : { tls: options.tls }),
4246
+ // A hook that throws rejects `fetch`; Bun's own answer to that is a development page carrying
4247
+ // the message, stack and source whenever NODE_ENV is not "production".
4248
+ error: (err) => {
4249
+ emitRequestErrorLog(this.logger, this.errorLogDetail, err, undefined);
4250
+ return jsonError(500, "internal_error");
4251
+ },
4252
+ };
4253
+ const fallback = (req, server) => {
4254
+ // Bun has already framed and bounded this request body in its HTTP parser. Mark the
4255
+ // source before the portable fallback runs so body schemas use Bun's native `json()`
4256
+ // reader instead of the defensive arrayBuffer/decode path reserved for caller-built
4257
+ // Requests. Native route handlers mark themselves in compileBunNativeHandler; this
4258
+ // covers every request the native lane does not serve.
4259
+ markTrustedBodyFraming(req);
4260
+ return this.fetch(req, bunPeerPlatform(server, req));
4261
+ };
4262
+ // Bun names the socket peer of a request when its server is asked. A request served from the
4263
+ // native table carries this one platform, which asks only when a handler reads `c.clientIp`.
4264
+ const peer = {
4265
+ [PLATFORM_PEER]: (request) => running.requestIP(request)?.address,
4266
+ };
4267
+ const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes(fallback, peer) : undefined;
3276
4268
  const running = (wsHandlers === undefined
3277
4269
  ? Bun.serve({
3278
- port,
3279
- reusePort,
3280
- ...bind,
3281
- ...idle,
4270
+ ...listening,
3282
4271
  ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
3283
- fetch: (req, server) => this.fetch(req, bunPeerPlatform(server, req)),
4272
+ fetch: fallback,
3284
4273
  })
3285
4274
  : Bun.serve({
3286
- port,
3287
- reusePort,
3288
- ...bind,
3289
- ...idle,
4275
+ ...listening,
3290
4276
  fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
3291
4277
  // Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
3292
4278
  // `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
@@ -3302,7 +4288,9 @@ export class Server {
3302
4288
  }));
3303
4289
  this.bunServer = running;
3304
4290
  // Bind `app.publish` to Bun's native broadcast now that the server handle exists. Guarded on the
3305
- // method's presence so a runtime whose handle lacks it simply keeps the registry path.
4291
+ // method's presence so a runtime whose handle lacks it simply keeps the registry path. Cleared
4292
+ // first: a mounted app may have gained a WebSocket route since an earlier `listen()`.
4293
+ this.nativePublish = undefined;
3306
4294
  if (nativePubsub && typeof running.publish === "function") {
3307
4295
  const native = running.publish.bind(running);
3308
4296
  this.nativePublish = (topic, data) => {