@nifrajs/core 3.3.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (331) 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 +6 -1
  11. package/dist/diff.d.ts.map +1 -1
  12. package/dist/diff.js +36 -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 +36 -1
  34. package/dist/evidence.d.ts.map +1 -1
  35. package/dist/evidence.js +219 -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 +48 -9
  40. package/dist/idempotency.js.map +1 -1
  41. package/dist/index.d.ts +9 -6
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +6 -3
  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 +1 -1
  75. package/dist/manifest.d.ts.map +1 -1
  76. package/dist/manifest.js +52 -46
  77. package/dist/manifest.js.map +1 -1
  78. package/dist/mount.d.ts +36 -0
  79. package/dist/mount.d.ts.map +1 -1
  80. package/dist/mount.js +32 -0
  81. package/dist/mount.js.map +1 -1
  82. package/dist/reflection.d.ts +26 -0
  83. package/dist/reflection.d.ts.map +1 -1
  84. package/dist/reflection.js +65 -1
  85. package/dist/reflection.js.map +1 -1
  86. package/dist/response-observer.d.ts +1 -1
  87. package/dist/response-observer.d.ts.map +1 -1
  88. package/dist/response-observer.js.map +1 -1
  89. package/dist/router/overlap.d.ts +23 -0
  90. package/dist/router/overlap.d.ts.map +1 -0
  91. package/dist/router/overlap.js +224 -0
  92. package/dist/router/overlap.js.map +1 -0
  93. package/dist/router/pattern.d.ts +117 -2
  94. package/dist/router/pattern.d.ts.map +1 -1
  95. package/dist/router/pattern.js +283 -23
  96. package/dist/router/pattern.js.map +1 -1
  97. package/dist/router/router.d.ts +18 -4
  98. package/dist/router/router.d.ts.map +1 -1
  99. package/dist/router/router.js +51 -44
  100. package/dist/router/router.js.map +1 -1
  101. package/dist/server/auth.d.ts +53 -0
  102. package/dist/server/auth.d.ts.map +1 -0
  103. package/dist/server/auth.js +9 -0
  104. package/dist/server/auth.js.map +1 -0
  105. package/dist/server/bare-error-lane.d.ts +12 -7
  106. package/dist/server/bare-error-lane.d.ts.map +1 -1
  107. package/dist/server/bare-error-lane.js +13 -11
  108. package/dist/server/bare-error-lane.js.map +1 -1
  109. package/dist/server/body-lane.d.ts +20 -2
  110. package/dist/server/body-lane.d.ts.map +1 -1
  111. package/dist/server/body-lane.js +26 -7
  112. package/dist/server/body-lane.js.map +1 -1
  113. package/dist/server/body-parser.d.ts +68 -0
  114. package/dist/server/body-parser.d.ts.map +1 -0
  115. package/dist/server/body-parser.js +172 -0
  116. package/dist/server/body-parser.js.map +1 -0
  117. package/dist/server/body.d.ts.map +1 -1
  118. package/dist/server/body.js +62 -7
  119. package/dist/server/body.js.map +1 -1
  120. package/dist/server/client-ip.d.ts +8 -0
  121. package/dist/server/client-ip.d.ts.map +1 -1
  122. package/dist/server/client-ip.js +8 -0
  123. package/dist/server/client-ip.js.map +1 -1
  124. package/dist/server/context.d.ts +71 -6
  125. package/dist/server/context.d.ts.map +1 -1
  126. package/dist/server/contract.d.ts +15 -5
  127. package/dist/server/contract.d.ts.map +1 -1
  128. package/dist/server/contract.js +25 -4
  129. package/dist/server/contract.js.map +1 -1
  130. package/dist/server/cookies.d.ts +4 -1
  131. package/dist/server/cookies.d.ts.map +1 -1
  132. package/dist/server/cookies.js +40 -2
  133. package/dist/server/cookies.js.map +1 -1
  134. package/dist/server/core-copies.d.ts +2 -0
  135. package/dist/server/core-copies.d.ts.map +1 -0
  136. package/dist/server/core-copies.js +20 -0
  137. package/dist/server/core-copies.js.map +1 -0
  138. package/dist/server/edge.d.ts.map +1 -1
  139. package/dist/server/edge.js +2 -0
  140. package/dist/server/edge.js.map +1 -1
  141. package/dist/server/http.d.ts +11 -0
  142. package/dist/server/http.d.ts.map +1 -1
  143. package/dist/server/http.js +36 -0
  144. package/dist/server/http.js.map +1 -1
  145. package/dist/server/idempotency-lane.d.ts +6 -0
  146. package/dist/server/idempotency-lane.d.ts.map +1 -1
  147. package/dist/server/idempotency-lane.js +130 -73
  148. package/dist/server/idempotency-lane.js.map +1 -1
  149. package/dist/server/install.d.ts +4 -0
  150. package/dist/server/install.d.ts.map +1 -1
  151. package/dist/server/install.js +4 -0
  152. package/dist/server/install.js.map +1 -1
  153. package/dist/server/methods.d.ts +45 -0
  154. package/dist/server/methods.d.ts.map +1 -0
  155. package/dist/server/methods.js +37 -0
  156. package/dist/server/methods.js.map +1 -0
  157. package/dist/server/multipart.d.ts +63 -0
  158. package/dist/server/multipart.d.ts.map +1 -0
  159. package/dist/server/multipart.js +374 -0
  160. package/dist/server/multipart.js.map +1 -0
  161. package/dist/server/node-outcome-hook.d.ts +20 -0
  162. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  163. package/dist/server/node-outcome-hook.js +15 -6
  164. package/dist/server/node-outcome-hook.js.map +1 -1
  165. package/dist/server/not-found-answer.d.ts +40 -0
  166. package/dist/server/not-found-answer.d.ts.map +1 -0
  167. package/dist/server/not-found-answer.js +56 -0
  168. package/dist/server/not-found-answer.js.map +1 -0
  169. package/dist/server/not-found.d.ts +23 -0
  170. package/dist/server/not-found.d.ts.map +1 -0
  171. package/dist/server/not-found.js +81 -0
  172. package/dist/server/not-found.js.map +1 -0
  173. package/dist/server/plugin.d.ts +26 -1
  174. package/dist/server/plugin.d.ts.map +1 -1
  175. package/dist/server/plugin.js.map +1 -1
  176. package/dist/server/proto-guard.d.ts +18 -5
  177. package/dist/server/proto-guard.d.ts.map +1 -1
  178. package/dist/server/proto-guard.js +114 -9
  179. package/dist/server/proto-guard.js.map +1 -1
  180. package/dist/server/query.d.ts +7 -0
  181. package/dist/server/query.d.ts.map +1 -1
  182. package/dist/server/query.js +47 -0
  183. package/dist/server/query.js.map +1 -1
  184. package/dist/server/registry.d.ts +13 -2
  185. package/dist/server/registry.d.ts.map +1 -1
  186. package/dist/server/request-context.d.ts +11 -0
  187. package/dist/server/request-context.d.ts.map +1 -1
  188. package/dist/server/request-context.js +33 -19
  189. package/dist/server/request-context.js.map +1 -1
  190. package/dist/server/request-lineage.d.ts +8 -0
  191. package/dist/server/request-lineage.d.ts.map +1 -0
  192. package/dist/server/request-lineage.js +14 -0
  193. package/dist/server/request-lineage.js.map +1 -0
  194. package/dist/server/request-target.d.ts +31 -0
  195. package/dist/server/request-target.d.ts.map +1 -0
  196. package/dist/server/request-target.js +44 -0
  197. package/dist/server/request-target.js.map +1 -0
  198. package/dist/server/respond.d.ts +1 -1
  199. package/dist/server/respond.d.ts.map +1 -1
  200. package/dist/server/respond.js +16 -4
  201. package/dist/server/respond.js.map +1 -1
  202. package/dist/server/response-contract-lane.d.ts +19 -1
  203. package/dist/server/response-contract-lane.d.ts.map +1 -1
  204. package/dist/server/response-contract-lane.js +59 -14
  205. package/dist/server/response-contract-lane.js.map +1 -1
  206. package/dist/server/response-observer-runtime.d.ts +9 -5
  207. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  208. package/dist/server/response-observer-runtime.js +10 -6
  209. package/dist/server/response-observer-runtime.js.map +1 -1
  210. package/dist/server/runtime-core.d.ts +2 -0
  211. package/dist/server/runtime-core.d.ts.map +1 -1
  212. package/dist/server/runtime-core.js.map +1 -1
  213. package/dist/server/server-types.d.ts +55 -3
  214. package/dist/server/server-types.d.ts.map +1 -1
  215. package/dist/server/server.d.ts +194 -19
  216. package/dist/server/server.d.ts.map +1 -1
  217. package/dist/server/server.js +1342 -274
  218. package/dist/server/server.js.map +1 -1
  219. package/dist/server/sse.d.ts.map +1 -1
  220. package/dist/server/sse.js +10 -7
  221. package/dist/server/sse.js.map +1 -1
  222. package/dist/server/static-headers.d.ts +3 -0
  223. package/dist/server/static-headers.d.ts.map +1 -1
  224. package/dist/server/static-headers.js +19 -1
  225. package/dist/server/static-headers.js.map +1 -1
  226. package/dist/server/transport-lane.d.ts +8 -0
  227. package/dist/server/transport-lane.d.ts.map +1 -1
  228. package/dist/server/transport-lane.js +9 -8
  229. package/dist/server/transport-lane.js.map +1 -1
  230. package/dist/server/validation.d.ts.map +1 -1
  231. package/dist/server/validation.js +5 -1
  232. package/dist/server/validation.js.map +1 -1
  233. package/dist/server/websocket.d.ts +10 -3
  234. package/dist/server/websocket.d.ts.map +1 -1
  235. package/dist/server/websocket.js +7 -4
  236. package/dist/server/websocket.js.map +1 -1
  237. package/dist/server/ws-bun.d.ts +2 -0
  238. package/dist/server/ws-bun.d.ts.map +1 -1
  239. package/dist/server/ws-bun.js +6 -3
  240. package/dist/server/ws-bun.js.map +1 -1
  241. package/dist/server/ws-hook.d.ts +2 -1
  242. package/dist/server/ws-hook.d.ts.map +1 -1
  243. package/dist/server.d.ts +8 -5
  244. package/dist/server.d.ts.map +1 -1
  245. package/dist/server.js +5 -2
  246. package/dist/server.js.map +1 -1
  247. package/dist/single-copy-register.js +5 -0
  248. package/dist/single-copy-register.js.map +1 -1
  249. package/dist/single-copy.d.ts +46 -3
  250. package/dist/single-copy.d.ts.map +1 -1
  251. package/dist/single-copy.js +125 -12
  252. package/dist/single-copy.js.map +1 -1
  253. package/dist/tool-contract.d.ts +23 -0
  254. package/dist/tool-contract.d.ts.map +1 -1
  255. package/dist/tool-contract.js +37 -3
  256. package/dist/tool-contract.js.map +1 -1
  257. package/dist/transport-codec.d.ts.map +1 -1
  258. package/dist/transport-codec.js +2 -1
  259. package/dist/transport-codec.js.map +1 -1
  260. package/package.json +27 -2
  261. package/src/assurance.ts +52 -2
  262. package/src/capabilities.ts +81 -22
  263. package/src/diff.ts +54 -7
  264. package/src/durable-adapters.ts +20 -3
  265. package/src/durable-effect.ts +10 -3
  266. package/src/edge-kit.ts +7 -0
  267. package/src/effect-lifecycle.ts +5 -1
  268. package/src/effect-scope.ts +2 -1
  269. package/src/errors.ts +3 -0
  270. package/src/evidence.ts +302 -12
  271. package/src/idempotency.ts +80 -9
  272. package/src/index.ts +41 -3
  273. package/src/internal/code-unit-order.ts +8 -0
  274. package/src/internal/durable-saga-protocol.ts +2 -0
  275. package/src/internal/route-catalog.ts +19 -18
  276. package/src/internal/route-compiler.ts +62 -13
  277. package/src/internal/route-execution.ts +13 -3
  278. package/src/internal/route-lanes.ts +37 -28
  279. package/src/internal/route-program.ts +434 -20
  280. package/src/ledger.ts +11 -5
  281. package/src/manifest.ts +52 -46
  282. package/src/mount.ts +62 -0
  283. package/src/reflection.ts +84 -1
  284. package/src/response-observer.ts +1 -0
  285. package/src/router/overlap.ts +258 -0
  286. package/src/router/pattern.ts +349 -25
  287. package/src/router/router.ts +76 -51
  288. package/src/server/auth.ts +75 -0
  289. package/src/server/bare-error-lane.ts +12 -11
  290. package/src/server/body-lane.ts +46 -6
  291. package/src/server/body-parser.ts +241 -0
  292. package/src/server/body.ts +82 -9
  293. package/src/server/client-ip.ts +9 -0
  294. package/src/server/context.ts +219 -12
  295. package/src/server/contract.ts +54 -9
  296. package/src/server/cookies.ts +34 -2
  297. package/src/server/core-copies.ts +21 -0
  298. package/src/server/edge.ts +2 -0
  299. package/src/server/http.ts +34 -0
  300. package/src/server/idempotency-lane.ts +147 -72
  301. package/src/server/install.ts +6 -0
  302. package/src/server/methods.ts +143 -0
  303. package/src/server/multipart.ts +424 -0
  304. package/src/server/node-outcome-hook.ts +40 -7
  305. package/src/server/not-found-answer.ts +109 -0
  306. package/src/server/not-found.ts +121 -0
  307. package/src/server/plugin.ts +45 -1
  308. package/src/server/proto-guard.ts +128 -9
  309. package/src/server/query.ts +49 -0
  310. package/src/server/registry.ts +17 -2
  311. package/src/server/request-context.ts +44 -16
  312. package/src/server/request-lineage.ts +15 -0
  313. package/src/server/request-target.ts +46 -0
  314. package/src/server/respond.ts +14 -3
  315. package/src/server/response-contract-lane.ts +97 -16
  316. package/src/server/response-observer-runtime.ts +37 -8
  317. package/src/server/runtime-core.ts +2 -0
  318. package/src/server/server-types.ts +61 -2
  319. package/src/server/server.ts +1798 -355
  320. package/src/server/sse.ts +10 -8
  321. package/src/server/static-headers.ts +22 -1
  322. package/src/server/transport-lane.ts +17 -9
  323. package/src/server/validation.ts +7 -1
  324. package/src/server/websocket.ts +23 -9
  325. package/src/server/ws-bun.ts +8 -3
  326. package/src/server/ws-hook.ts +2 -1
  327. package/src/server.ts +39 -2
  328. package/src/single-copy-register.ts +5 -0
  329. package/src/single-copy.ts +160 -12
  330. package/src/tool-contract.ts +66 -3
  331. 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,7 +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" };
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;
50
88
  /** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
51
89
  function requireWsRuntime(runtime) {
52
90
  if (runtime === undefined) {
@@ -87,13 +125,27 @@ export { plainValidationError } from "./validation.js";
87
125
  // Query-string + urlencoded-form parsing now lives in `./query.ts`; re-exported so existing
88
126
  // importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
89
127
  export { queryObjectOf, searchOf };
90
- 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) {
91
136
  for (const key in params) {
92
- if (params[key].includes("\uFFFD"))
137
+ if (unroutedParam(params[key]))
93
138
  return true;
94
139
  }
95
140
  return false;
96
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);
97
149
  function normalizeMountPrefix(path) {
98
150
  if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
99
151
  throw new TypeError("mountFetch path must be an absolute pathname without a query or hash");
@@ -111,6 +163,17 @@ function normalizeMountPrefix(path) {
111
163
  function underMountPrefix(pathname, prefix) {
112
164
  return prefix === "/" || pathname === prefix || pathname.startsWith(`${prefix}/`);
113
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
+ }
114
177
  function stripMountPrefix(request, prefix) {
115
178
  if (prefix === "/")
116
179
  return request;
@@ -139,6 +202,68 @@ function isContextlessNoArgArrow(handler) {
139
202
  * these renders happen where no request context exists - before routing, or after it was abandoned.
140
203
  */
141
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
+ ];
142
267
  // Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
143
268
  const IDENTITY_RESPONSE = (response) => webResponseOf(response);
144
269
  const RESPONSE_TIMEOUT = () => jsonError(503, "request_timeout");
@@ -231,6 +356,10 @@ export class Server {
231
356
  /** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
232
357
  wsRouter;
233
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;
234
363
  /** In-process pub/sub backing `ws.subscribe(topic)` + `app.publish(topic, data)` (single-instance).
235
364
  * Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime - `undefined` until then, so a
236
365
  * no-WebSocket app never constructs (or bundles) it. */
@@ -240,7 +369,8 @@ export class Server {
240
369
  * rather than delegate to Bun's native `server.publish`. */
241
370
  wsHasValidatedSend = false;
242
371
  /** Bun's native topic broadcast, bound by `listen()` in native-pubsub mode; `app.publish` uses it
243
- * 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. */
244
374
  nativePublish;
245
375
  maxBodyBytes;
246
376
  protoPoisoning;
@@ -248,6 +378,7 @@ export class Server {
248
378
  trustBodyFraming;
249
379
  wsMaxPayloadBytes;
250
380
  requestTimeoutMs;
381
+ wsUpgradeTimeoutMs;
251
382
  /** Installed by the `responseContract()` plugin; `undefined` = not installed, which is the default
252
383
  * and the state in which a declared `response` schema stays a compile-time contract only. */
253
384
  responseContractRuntime;
@@ -259,6 +390,9 @@ export class Server {
259
390
  gracefulSignals;
260
391
  stopHooks;
261
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;
262
396
  /** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
263
397
  capacityGate;
264
398
  onCapabilityUse;
@@ -272,6 +406,8 @@ export class Server {
272
406
  idempotencyRuntime;
273
407
  /** Installed opt-in runtime for `.tool()`/`.resource()`/`.prompt()`; `undefined` until `.use(mcp())`. */
274
408
  mcpRuntime;
409
+ /** Installed answer for a request no route matched; `undefined` (the plain 404) until `.use(notFound())`. */
410
+ notFoundLane;
275
411
  /** Installed Node-direct renderer for direct `resolveNode()` callers; `undefined` until `.use(nodeDirect())`. */
276
412
  nodeOutcomeRuntime;
277
413
  /** Installed streaming runtime for `.sse()` routes; `undefined` until `.use(streaming())`. */
@@ -286,6 +422,8 @@ export class Server {
286
422
  bunServer;
287
423
  sealed;
288
424
  derives;
425
+ /** Dedicated authentication stages captured by subsequent routes. */
426
+ authStages;
289
427
  decorations;
290
428
  beforeHandleHooks;
291
429
  afterHandleHooks;
@@ -295,10 +433,14 @@ export class Server {
295
433
  onNodeRequestHooks;
296
434
  /** Registration-time eligibility for the Node request twin lane. */
297
435
  nodeRequestHooksComplete;
436
+ /** True when every request hook is safe to skip on a Bun native route. */
437
+ bunNativeRequestHooksSafe;
298
438
  onResponseHooks;
299
439
  onNodeResponseHooks;
300
440
  /** Registration-time eligibility for the Node response twin lane. */
301
441
  nodeResponseHooksComplete;
442
+ /** True when every response hook only mutates headers and can run after a Bun fused response. */
443
+ bunNativeResponseHeadersOnly;
302
444
  /**
303
445
  * True once a bundle registers a RAW `onNodeResponse` twin - one handed the outcome's header
304
446
  * record itself rather than the case-normalizing view. Their names are contractually the wire
@@ -327,7 +469,7 @@ export class Server {
327
469
  responseSources;
328
470
  /** Memoized NodeRequestContext per plain-`Request` source - see {@link nodeRequestContextOf}. */
329
471
  nodeContexts;
330
- /** Names of plugins/middleware already applied via `use` - for idempotent dedupe. */
472
+ /** Plugins/middleware already applied via `use`, by name - for idempotent dedupe. */
331
473
  appliedPlugins;
332
474
  /** Order-scoped evidence captured by routes registered after an assured plugin. */
333
475
  activeAssurance;
@@ -336,7 +478,14 @@ export class Server {
336
478
  /** App-declared MCP resources / prompts (via {@link resource} / {@link prompt}), read by `nifra mcp`. */
337
479
  mcpResourceList;
338
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;
339
486
  constructor(options = {}) {
487
+ this.options = options;
488
+ this.routePrefix = "";
340
489
  this.catalog = new RouteCatalog();
341
490
  this.wsRouter = new Router();
342
491
  this.wsRouteCount = 0;
@@ -350,6 +499,7 @@ export class Server {
350
499
  this.maxBodyBytes = maxBodyBytes;
351
500
  this.wsMaxPayloadBytes = wsMaxPayloadBytes;
352
501
  this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
502
+ this.wsUpgradeTimeoutMs = options.wsUpgradeTimeoutMs ?? DEFAULT_WS_UPGRADE_TIMEOUT_MS;
353
503
  this.clientIpTrust = options.clientIp;
354
504
  this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false;
355
505
  this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000;
@@ -360,12 +510,17 @@ export class Server {
360
510
  if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 0) {
361
511
  throw new RangeError("requestTimeoutMs must be a finite non-negative number");
362
512
  }
513
+ if (!Number.isFinite(this.wsUpgradeTimeoutMs) || this.wsUpgradeTimeoutMs < 0) {
514
+ throw new RangeError("wsUpgradeTimeoutMs must be a finite non-negative number");
515
+ }
363
516
  if (!Number.isFinite(this.maxInboundDeadlineMs) || this.maxInboundDeadlineMs <= 0) {
364
517
  throw new RangeError("maxInboundDeadlineMs must be a finite positive number");
365
518
  }
366
519
  this.gracefulSignals = options.gracefulSignals ?? false;
367
520
  this.stopHooks = [];
368
521
  this.fetchMounts = [];
522
+ this.mountOrder = 0;
523
+ this.preRouteMountCount = 0;
369
524
  this.capacityGate = options.admission;
370
525
  this.onCapabilityUse = options.onCapabilityUse;
371
526
  this.capabilityInterceptors = [];
@@ -388,6 +543,7 @@ export class Server {
388
543
  beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined);
389
544
  }
390
545
  this.derives = [];
546
+ this.authStages = [];
391
547
  this.decorations = {};
392
548
  this.beforeHandleHooks = [];
393
549
  this.afterHandleHooks = [];
@@ -396,9 +552,11 @@ export class Server {
396
552
  this.onRequestHooks = [];
397
553
  this.onNodeRequestHooks = [];
398
554
  this.nodeRequestHooksComplete = true;
555
+ this.bunNativeRequestHooksSafe = true;
399
556
  this.onResponseHooks = [];
400
557
  this.onNodeResponseHooks = [];
401
558
  this.nodeResponseHooksComplete = true;
559
+ this.bunNativeResponseHeadersOnly = true;
402
560
  this.hasRawNodeResponseHook = false;
403
561
  this.onResponseFinalizedHooks = [];
404
562
  this.staticResponseHeaders = undefined;
@@ -409,7 +567,7 @@ export class Server {
409
567
  this.responseRequests = new WeakMap();
410
568
  this.responseSources = new WeakMap();
411
569
  this.nodeContexts = new WeakMap();
412
- this.appliedPlugins = new Set();
570
+ this.appliedPlugins = new Map();
413
571
  this.activeAssurance = [];
414
572
  this.globalAssurance = [];
415
573
  this.mcpResourceList = [];
@@ -417,7 +575,7 @@ export class Server {
417
575
  }
418
576
  assertConfigurable(operation) {
419
577
  if (this.sealed) {
420
- 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`);
421
579
  }
422
580
  }
423
581
  /** Register a callback awaited after graceful drain and server stop. Hung callbacks are bounded. */
@@ -441,9 +599,70 @@ export class Server {
441
599
  path: normalizeMountPrefix(path),
442
600
  handler: handler,
443
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,
444
607
  };
445
608
  this.fetchMounts.push(mount);
446
- this.fetchMounts.sort((a, b) => b.path.length - a.path.length);
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,
661
+ };
662
+ this.fetchMounts.push(mount);
663
+ this.fetchMounts.sort(compareMounts);
664
+ this.preRouteMountCount += 1;
665
+ wsChanges += 1;
447
666
  return this;
448
667
  }
449
668
  /**
@@ -469,6 +688,30 @@ export class Server {
469
688
  * by none before; one added after the last route reaches nothing
470
689
  * (see {@link ServerOptions.unusedScopedHooks}).
471
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
+ }
472
715
  decorate(key, value) {
473
716
  this.assertConfigurable("decorate()");
474
717
  this.decorations[key] = value;
@@ -485,6 +728,7 @@ export class Server {
485
728
  this.onRequestHooks.push(fn);
486
729
  this.onNodeRequestHooks.push(undefined);
487
730
  this.nodeRequestHooksComplete = false;
731
+ this.bunNativeRequestHooksSafe = false;
488
732
  return this;
489
733
  }
490
734
  /**
@@ -569,6 +813,7 @@ export class Server {
569
813
  this.onResponseHooks.push(fn);
570
814
  this.onNodeResponseHooks.push(undefined);
571
815
  this.nodeResponseHooksComplete = false;
816
+ this.bunNativeResponseHeadersOnly = false;
572
817
  return this;
573
818
  }
574
819
  /**
@@ -585,7 +830,8 @@ export class Server {
585
830
  * app.responseHeaders({ "x-frame-options": "DENY", "referrer-policy": "no-referrer" })
586
831
  *
587
832
  * They are DEFAULTS: a value the request itself produced (`c.set.headers`, or a response hook)
588
- * 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
589
835
  * name, `__proto__`, or a name the render owns (`content-type`, `content-length`,
590
836
  * `transfer-encoding`, `set-cookie`) throws a `TypeError` at wire-up.
591
837
  *
@@ -617,7 +863,7 @@ export class Server {
617
863
  // serialized-body marker survives for later `onResponseBody`/`onResponseRaw` observers and the
618
864
  // Node writer; reconstructing a `Response` here would strip that marker and reclassify the Node
619
865
  // outcome from `json` to a generic `response`. A guarded foreign response takes its clone path.
620
- const statics = buildStaticResponseHeaders(record);
866
+ const statics = buildStaticResponseHeaders(record, this.inheritedStatics);
621
867
  this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics));
622
868
  this.onNodeResponseHooks.push(undefined);
623
869
  this.nodeResponseHooksComplete = false;
@@ -626,7 +872,7 @@ export class Server {
626
872
  const merged = this.staticResponseHeaders === undefined
627
873
  ? record
628
874
  : { ...this.staticResponseHeaders.record, ...record };
629
- this.staticResponseHeaders = buildStaticResponseHeaders(merged);
875
+ this.staticResponseHeaders = buildStaticResponseHeaders(merged, this.inheritedStatics);
630
876
  const statics = this.staticResponseHeaders;
631
877
  this.wrapWebResponse = (response) => applyStaticResponseHeaders(webResponseOf(response), statics);
632
878
  this.webResponseTimeout = () => applyStaticResponseHeaders(RESPONSE_TIMEOUT(), statics);
@@ -654,24 +900,36 @@ export class Server {
654
900
  this.onResponseFinalizedHooks.push(fn);
655
901
  return this;
656
902
  }
657
- use(arg) {
903
+ use(input) {
904
+ // A method-routes plugin is an ordinary plugin function; only its type is opaque.
905
+ const arg = input;
658
906
  this.assertConfigurable("use()");
659
- if (typeof arg === "function") {
660
- const name = arg.pluginName;
661
- if (name !== undefined) {
662
- if (this.appliedPlugins.has(name))
663
- return this; // idempotent: already applied
664
- 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
665
917
  }
918
+ this.appliedPlugins.set(name, arg);
919
+ }
920
+ if (typeof arg === "function") {
666
921
  const evidence = assuranceDeclarationsOf(arg);
667
922
  const pluginOnly = evidence.filter((item) => item.scope === "plugin");
668
923
  this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"));
669
924
  this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"));
670
925
  this.activeAssurance.push(...pluginOnly);
926
+ const inherited = this.inheritedPlugins;
927
+ this.inheritedPlugins = undefined;
671
928
  try {
672
929
  return arg(this);
673
930
  }
674
931
  finally {
932
+ this.inheritedPlugins = inherited;
675
933
  // Remove only this plugin's temporary evidence. Nested assured plugins may deliberately leave
676
934
  // subsequent evidence active, so truncating the whole array would lose real ordering semantics.
677
935
  for (const item of pluginOnly) {
@@ -681,11 +939,6 @@ export class Server {
681
939
  }
682
940
  }
683
941
  }
684
- if (arg.name !== undefined) {
685
- if (this.appliedPlugins.has(arg.name))
686
- return this;
687
- this.appliedPlugins.add(arg.name);
688
- }
689
942
  const evidence = assuranceDeclarationsOf(arg);
690
943
  if (evidence.some((item) => item.scope === "plugin")) {
691
944
  throw new Error('route assurance: scope "plugin" may only annotate a plugin function');
@@ -707,6 +960,9 @@ export class Server {
707
960
  this.onNodeRequestHooks.push(arg.onNodeRequest);
708
961
  if (arg.onNodeRequest === undefined)
709
962
  this.nodeRequestHooksComplete = false;
963
+ if (!arg[BUN_NATIVE_REQUEST_SAFE]) {
964
+ this.bunNativeRequestHooksSafe = false;
965
+ }
710
966
  }
711
967
  else if (arg.onNodeRequest !== undefined) {
712
968
  throw new TypeError("onNodeRequest() requires a paired onRequest() hook");
@@ -725,6 +981,7 @@ export class Server {
725
981
  this.nodeResponseHooksComplete = false;
726
982
  else
727
983
  this.hasRawNodeResponseHook = true;
984
+ this.bunNativeResponseHeadersOnly = false;
728
985
  }
729
986
  else if (arg.onNodeResponse !== undefined) {
730
987
  throw new TypeError("onNodeResponse() requires a paired onResponse() hook");
@@ -733,10 +990,18 @@ export class Server {
733
990
  // defaults its hook may then override, which is the order a single bundle reads in.
734
991
  if (arg.responseHeaders !== undefined)
735
992
  this.responseHeaders(arg.responseHeaders);
736
- if (arg.onResponseHeaders !== undefined)
737
- this.responseObserverMethods().onResponseHeaders(arg.onResponseHeaders);
738
- if (arg.onResponseBody !== undefined)
739
- 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
+ }
740
1005
  if (arg.onResponseRaw !== undefined)
741
1006
  this.responseObserverMethods().onResponseRaw(arg.onResponseRaw);
742
1007
  if (arg.onResponseFinalized !== undefined)
@@ -855,10 +1120,11 @@ export class Server {
855
1120
  this.topics ??= runtime.createTopics();
856
1121
  // A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
857
1122
  // already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
858
- this.wsRouter.add("GET", path, {
859
- handler: runtime.wrapHandler(handler),
860
- });
1123
+ const entry = { handler: runtime.wrapHandler(handler, this.protoPoisoning) };
1124
+ for (const form of expandOptionalParams(path))
1125
+ this.wsRouter.add("GET", form, entry);
861
1126
  this.wsRouteCount += 1;
1127
+ wsChanges += 1;
862
1128
  return this;
863
1129
  }
864
1130
  /**
@@ -898,16 +1164,37 @@ export class Server {
898
1164
  * is the "compiled", order-scoped per-route chain.
899
1165
  */
900
1166
  register(method, path, schema, handler) {
901
- this.assertConfigurable("route registration");
902
- this.catalog.add(this.prepareRoute(method, path, schema, handler));
1167
+ this.registerBatch([{ method, path, schema, handler }]);
903
1168
  }
904
1169
  /** Register a contract/group route batch atomically. Every route captures the same current chain it
905
- * 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. */
906
1174
  registerBatch(routes) {
907
1175
  this.assertConfigurable("route registration");
908
- 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
+ }
909
1182
  this.catalog.addBatch(staged);
910
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
+ }
911
1198
  prepareRoute(method, path, schema, handler) {
912
1199
  const compiled = compileRouteOptions({
913
1200
  maxBodyBytes: this.maxBodyBytes,
@@ -921,6 +1208,7 @@ export class Server {
921
1208
  idempotencyRuntime: this.idempotencyRuntime,
922
1209
  responseContractRuntime: this.responseContractRuntime,
923
1210
  derives: this.derives,
1211
+ authStages: this.authStages,
924
1212
  beforeHandleHooks: this.beforeHandleHooks,
925
1213
  afterHandleHooks: this.afterHandleHooks,
926
1214
  onErrorHooks: this.onErrorHooks,
@@ -928,16 +1216,30 @@ export class Server {
928
1216
  defaultOnValidationError: this.defaultOnValidationError,
929
1217
  }, method, path, schema, handler);
930
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
+ }
931
1222
  const { bare, fusedQuery, fusedBody } = lanes;
932
- const fusedBodyRunner = fusedBody
933
- ? this.buildFusedBodyRunner(handler, schema?.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? UNLIMITED_BODY_BYTES)
934
- : undefined;
935
1223
  // Fused lifecycle lanes: derive + before (with or without an after), and body + the same shape.
936
1224
  // The lane selectors in `selectRouteLanes` are exhaustive about the lifecycleHookLane /
937
1225
  // body-derive-before-after eligibility, so we only need to check the lane and the hook counts.
938
1226
  const lifecycleHookLane = lanes.lifecycleHookLane;
939
1227
  const isFusedLifecycle = lifecycleHookLane !== undefined && this.derives.length === 1;
940
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;
941
1243
  const fusedWeb = bare && this.aroundHooks.length === 0
942
1244
  ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ?? UNLIMITED_BODY_BYTES)
943
1245
  : fusedQuery
@@ -955,6 +1257,7 @@ export class Server {
955
1257
  schema,
956
1258
  handler: handler,
957
1259
  derives: this.derives,
1260
+ authStages: compiled.authStages,
958
1261
  beforeHandle: this.beforeHandleHooks,
959
1262
  afterHandle: this.afterHandleHooks,
960
1263
  onError: this.onErrorHooks,
@@ -982,6 +1285,7 @@ export class Server {
982
1285
  ledgered,
983
1286
  responseContract,
984
1287
  derives: [...this.derives],
1288
+ authStages: [...compiled.authStages],
985
1289
  decorations: routeDecorations,
986
1290
  hasDecorations,
987
1291
  beforeHandle: [...this.beforeHandleHooks],
@@ -996,6 +1300,9 @@ export class Server {
996
1300
  method,
997
1301
  path,
998
1302
  schema,
1303
+ ...(responseContract === undefined
1304
+ ? {}
1305
+ : { responseContract: responseContract.runtime.mode }),
999
1306
  ...(capabilities.length > 0 ? { capabilities } : {}),
1000
1307
  ...(schema?.family === true ? { family: true } : {}),
1001
1308
  };
@@ -1023,11 +1330,13 @@ export class Server {
1023
1330
  this.assertConfigurable("responseObserver()");
1024
1331
  const methods = runtime.install({
1025
1332
  assertConfigurable: (operation) => this.assertConfigurable(operation),
1026
- addResponseHook: (web, node) => {
1333
+ addResponseHook: (web, node, headerOnly = false) => {
1027
1334
  this.onResponseHooks.push(web);
1028
1335
  this.onNodeResponseHooks.push(node);
1029
1336
  if (node === undefined)
1030
1337
  this.nodeResponseHooksComplete = false;
1338
+ if (!headerOnly)
1339
+ this.bunNativeResponseHeadersOnly = false;
1031
1340
  },
1032
1341
  enableResponseBodyTagging: () => {
1033
1342
  if (this.responseBodyTag === undefined) {
@@ -1051,6 +1360,11 @@ export class Server {
1051
1360
  this.assertConfigurable("mcp()");
1052
1361
  this.mcpRuntime = runtime;
1053
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
+ }
1054
1368
  /** @internal Symbol-keyed install seam for the `nodeDirect()` plugin. Off the public typed surface. */
1055
1369
  [INSTALL_NODE_DIRECT](runtime) {
1056
1370
  this.assertConfigurable("nodeDirect()");
@@ -1066,6 +1380,41 @@ export class Server {
1066
1380
  this.assertConfigurable("websocket()");
1067
1381
  this.wsRuntime = runtime;
1068
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
+ }
1069
1418
  /**
1070
1419
  * Merge another server's routes into this one - the composition escape hatch for large apps.
1071
1420
  *
@@ -1092,75 +1441,188 @@ export class Server {
1092
1441
  */
1093
1442
  merge(other) {
1094
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
+ }
1095
1454
  const source = other;
1096
1455
  if (source.wsRouteCount > 0) {
1097
1456
  throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes - register .ws() routes on the parent server");
1098
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) {
1099
1555
  const sourceRoutes = source.catalog.entries();
1100
1556
  // Hooks and global assurance stay LOCAL to a group that has routes; a route-less group is a
1101
1557
  // middleware bundle whose hooks can only mean app-wide intent, so it keeps the global append.
1102
- const scoped = sourceRoutes.length > 0;
1103
- // A group's global assurance rides its (route-scoped) hooks: folded into each merged route's
1104
- // 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
1105
1563
  // enforcement for parent routes the group's hooks do not see.
1106
- const foldedAssurance = scoped && source.globalAssurance.length > 0
1564
+ const foldedAssurance = scoped && ownGlobal.length > 0
1107
1565
  ? (route) => ({
1108
1566
  ...route,
1109
- assurance: [...route.assurance, ...source.globalAssurance],
1567
+ assurance: [...route.assurance, ...ownGlobal],
1110
1568
  })
1111
1569
  : (route) => route;
1112
1570
  this.catalog.addBatch(sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(route))));
1113
1571
  // Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
1114
- // runtime object supplies the generic execution machinery. Preserve a group's installed runtime
1115
- // when the parent has none so merging cannot silently disable a safety lane. If the parent already
1116
- // has a runtime, either implementation can execute every resolved entry because route-specific
1117
- // options were pinned during registration.
1118
- this.responseContractRuntime ??= source.responseContractRuntime;
1119
- this.idempotencyRuntime ??= source.idempotencyRuntime;
1120
- this.effectLedgerRuntime ??= source.effectLedgerRuntime;
1121
- this.mcpRuntime ??= source.mcpRuntime;
1122
- this.nodeOutcomeRuntime ??= source.nodeOutcomeRuntime;
1123
- this.sseRuntime ??= source.sseRuntime;
1124
- this.wsRuntime ??= source.wsRuntime;
1125
- if (scoped && source.onRequestHooks.length > 0) {
1126
- // Snapshot the group's routes into a dedicated matcher: the guard must reflect what was
1127
- // MERGED, not whatever the group's own catalog grows into afterwards. One probe against it
1128
- // gates each group hook to requests the group would serve; everything else passes untouched.
1129
- const scope = new RouteCatalog();
1130
- scope.addBatch(sourceRoutes);
1131
- this.onRequestHooks.push(...source.onRequestHooks.map((hook) => (req, platform) => scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined));
1132
- // An `undefined` slot marks an unpaired hook (position-aligned with `onRequestHooks`) and
1133
- // must stay `undefined` - wrapping it would fabricate a Node twin that never existed.
1134
- this.onNodeRequestHooks.push(...source.onNodeRequestHooks.map((hook) => hook === undefined
1135
- ? undefined
1136
- : (req, platform) => scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined));
1137
- }
1138
- else {
1139
- this.onRequestHooks.push(...source.onRequestHooks);
1140
- this.onNodeRequestHooks.push(...source.onNodeRequestHooks);
1141
- }
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));
1142
1587
  this.nodeRequestHooksComplete &&= source.nodeRequestHooksComplete;
1143
- // The group's static declarations came before its own response hooks, so they are folded in
1144
- // first - and fold themselves into a hook here if this server already has one (same ordering
1145
- // rule as a direct `responseHeaders()` call).
1146
- if (source.staticResponseHeaders !== undefined) {
1147
- this.addStaticResponseHeaders({ ...source.staticResponseHeaders.record });
1148
- }
1149
- this.onResponseHooks.push(...source.onResponseHooks);
1150
- 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));
1151
1611
  this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete;
1612
+ this.bunNativeResponseHeadersOnly &&= source.bunNativeResponseHeadersOnly;
1152
1613
  this.hasRawNodeResponseHook ||= source.hasRawNodeResponseHook;
1153
- this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks);
1154
1614
  if (source.responseBodyTag !== undefined) {
1155
1615
  const owner = this.enableResponseBodyTagging();
1156
1616
  this.responseBodyOwners.add(source.responseBodyTag);
1157
1617
  source.responseBodyOwners.add(owner);
1158
1618
  }
1159
1619
  if (!scoped)
1160
- 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);
1161
1624
  this.mcpResourceList.push(...source.mcpResourceList);
1162
1625
  this.mcpPromptList.push(...source.mcpPromptList);
1163
- return this;
1164
1626
  }
1165
1627
  /** A fused renderer closes over runtime services to keep its seven-argument JSC fast path. Merging
1166
1628
  * rebinds that closure once to the executing server; generic plans already receive the runtime. */
@@ -1173,15 +1635,30 @@ export class Server {
1173
1635
  const lane = entry.execution.fusedLane;
1174
1636
  // Type-erase the lane so each `===` branch sees the full union, not the narrowed remainder.
1175
1637
  const laneName = lane;
1176
- // Body+lifecycle routes have a fused Web renderer, but no body-only Node renderer: the latter
1177
- // would bypass derive/before/after because the Node-direct dispatcher prefers `fusedBody` over
1178
- // the generic execution plan. Only the original body-only lane may populate this slot.
1179
- const fusedBody = laneName === "body"
1180
- ? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
1181
- : 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.
1182
1642
  const derive = entry.derives[0];
1183
1643
  const before = entry.beforeHandle[0];
1184
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;
1185
1662
  const fusedWeb = laneName === "body"
1186
1663
  ? this.buildFusedBodyWeb(fusedBody)
1187
1664
  : laneName === "query"
@@ -1231,6 +1708,10 @@ export class Server {
1231
1708
  * Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
1232
1709
  */
1233
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);
1234
1715
  // An edge deployment whose only ingress is this method declares its requests runtime-framed at
1235
1716
  // construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
1236
1717
  if (this.trustBodyFraming)
@@ -1266,13 +1747,14 @@ export class Server {
1266
1747
  }
1267
1748
  /** Web response path when Bun already matched the route. The lifecycle and response hooks remain
1268
1749
  * exactly the same as {@link fetchSource}; only portable URL scanning + trie lookup are skipped. */
1269
- fetchMatched(source, entry, params) {
1270
- if (this.capacityGate === undefined)
1271
- return this.fetchMatchedInner(source, entry, params);
1272
- 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));
1273
1755
  }
1274
- fetchMatchedInner(source, entry, params) {
1275
- 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);
1276
1758
  if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
1277
1759
  return outcome;
1278
1760
  }
@@ -1338,19 +1820,26 @@ export class Server {
1338
1820
  }
1339
1821
  const parts = source.urlParts ?? urlPartsOf(source.url);
1340
1822
  const match = this.catalog.find(source.method, parts.pathname);
1341
- if (match.found)
1823
+ if (match.found || !isRoutableMethod(source.method))
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)
1342
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) {
1343
1840
  for (const mount of this.fetchMounts) {
1344
- if (!underMountPrefix(parts.pathname, mount.path))
1345
- continue;
1346
- const candidate = mount.handler[NODE_NATIVE_MOUNT];
1347
- return typeof candidate === "function"
1348
- ? {
1349
- handler: candidate,
1350
- path: mount.path,
1351
- stripPrefix: mount.stripPrefix,
1352
- }
1353
- : undefined;
1841
+ if (mount.beforeRoutes === beforeRoutes && underMountPrefix(pathname, mount.path))
1842
+ return mount;
1354
1843
  }
1355
1844
  return undefined;
1356
1845
  }
@@ -1489,6 +1978,7 @@ export class Server {
1489
1978
  const context = {
1490
1979
  status: outcome.status,
1491
1980
  headers,
1981
+ headersAreLowercase: !this.hasRawNodeResponseHook && lowercaseKeys,
1492
1982
  cookies: outcome.kind === "json" ? outcome.cookies : undefined,
1493
1983
  body: outcome.body,
1494
1984
  };
@@ -1570,76 +2060,210 @@ export class Server {
1570
2060
  * `data`). Runs the route's `upgrade(c)` guard in a real request context. Synchronous unless
1571
2061
  * `upgrade()` is async; a throw rejects with a flat 500 (no detail leaked).
1572
2062
  */
2063
+ runWebSocketRequestHooks(req, platform) {
2064
+ let current = req;
2065
+ for (let i = 0; i < this.onRequestHooks.length; i++) {
2066
+ const hook = this.onRequestHooks[i];
2067
+ if (hook === undefined)
2068
+ return current;
2069
+ const outcome = hook(current, platform);
2070
+ if (outcome instanceof Promise) {
2071
+ return outcome.then((first) => this.continueWebSocketRequestHooks(first, i + 1, current, platform));
2072
+ }
2073
+ if (outcome instanceof Request) {
2074
+ current = outcome;
2075
+ continue;
2076
+ }
2077
+ if (outcome !== undefined)
2078
+ return outcome;
2079
+ }
2080
+ return current;
2081
+ }
2082
+ async continueWebSocketRequestHooks(first, start, current, platform) {
2083
+ let next = current;
2084
+ let outcome = first;
2085
+ for (let i = start;; i++) {
2086
+ if (outcome instanceof Request)
2087
+ next = outcome;
2088
+ else if (outcome !== undefined)
2089
+ return outcome;
2090
+ if (i >= this.onRequestHooks.length)
2091
+ return next;
2092
+ const hook = this.onRequestHooks[i];
2093
+ if (hook === undefined)
2094
+ return next;
2095
+ outcome = await hook(next, platform);
2096
+ }
2097
+ }
1573
2098
  resolveWebSocketUpgrade(req, platform) {
1574
- 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())
1575
2102
  return WS_PASS;
1576
2103
  if (req.headers.get("upgrade")?.toLowerCase() !== "websocket")
1577
2104
  return WS_PASS;
1578
- const url = urlPartsOf(req.url);
1579
- const match = this.wsRouter.find("GET", url.pathname);
1580
- if (!match.found)
1581
- return WS_PASS; // upgrade header, no WS route here → normal routing decides
1582
- // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
1583
- // already did and made every plain dynamic route pay for unrelated static path bytes.
1584
- const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params);
1585
- if (params === null)
1586
- return { kind: "reject", response: jsonError(400, "malformed_path") };
1587
- const handler = match.payload.handler;
1588
- // Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
1589
- const pubsub = this.topics;
1590
- const attach = this.wsRuntime.attach;
1591
- // CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
1592
- // Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
1593
- // blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
1594
- const origin = req.headers.get("origin");
1595
- if (handler.allowedOrigins !== undefined) {
1596
- const allowed = typeof handler.allowedOrigins === "function"
1597
- ? handler.allowedOrigins(origin)
1598
- : origin !== null && handler.allowedOrigins.includes(origin);
1599
- if (!allowed)
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);
2114
+ const timeoutMs = this.wsUpgradeTimeoutMs === 0
2115
+ ? 0
2116
+ : this.requestTimeoutMs > 0
2117
+ ? Math.min(this.wsUpgradeTimeoutMs, this.requestTimeoutMs)
2118
+ : this.wsUpgradeTimeoutMs;
2119
+ const controller = timeoutMs > 0 ? new AbortController() : undefined;
2120
+ const signal = controller?.signal ?? getNeverAbortSignal();
2121
+ const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : undefined;
2122
+ const timeoutResponse = jsonError(503, "request_timeout");
2123
+ const onTimeout = () => ({
2124
+ kind: "reject",
2125
+ response: timeoutResponse,
2126
+ });
2127
+ const bounded = (work, onDone) => controller === undefined
2128
+ ? work
2129
+ : this.withTimeout(work, controller, onDone, Math.max(0, deadline - Date.now()));
2130
+ const resolveMatched = (request) => {
2131
+ const url = urlPartsOf(request.url);
2132
+ const match = this.wsRouter.find("GET", url.pathname);
2133
+ if (!match.found)
2134
+ return WS_PASS; // upgrade header, no WS route here → normal routing decides
2135
+ // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
2136
+ // already did and made every plain dynamic route pay for unrelated static path bytes.
2137
+ const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params);
2138
+ if (params === null)
2139
+ return { kind: "reject", response: jsonError(400, "malformed_path") };
2140
+ const handler = match.payload.handler;
2141
+ // Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
2142
+ const pubsub = this.topics;
2143
+ const attach = this.wsRuntime.attach;
2144
+ // CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
2145
+ // Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
2146
+ // blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
2147
+ const origin = request.headers.get("origin");
2148
+ if (handler.allowedOrigins !== undefined) {
2149
+ const allowed = typeof handler.allowedOrigins === "function"
2150
+ ? handler.allowedOrigins(origin)
2151
+ : origin !== null && handler.allowedOrigins.includes(origin);
2152
+ if (!allowed)
2153
+ return { kind: "reject", response: jsonError(403, "forbidden_origin") };
2154
+ }
2155
+ else if (origin !== null && !wsSameOrigin(origin, request)) {
2156
+ // Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake - the
2157
+ // CSWSH case, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser
2158
+ // clients send no `Origin` and pass; same-origin browsers pass. Set `allowedOrigins` to permit
2159
+ // specific cross-origin clients (or `() => true` for a genuinely public socket).
1600
2160
  return { kind: "reject", response: jsonError(403, "forbidden_origin") };
1601
- }
1602
- else if (origin !== null && !wsSameOrigin(origin, req)) {
1603
- // Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake - the
1604
- // CSWSH case, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser
1605
- // clients send no `Origin` and pass; same-origin browsers pass. Set `allowedOrigins` to permit
1606
- // specific cross-origin clients (or `() => true` for a genuinely public socket).
1607
- return { kind: "reject", response: jsonError(403, "forbidden_origin") };
1608
- }
1609
- if (handler.upgrade === undefined) {
1610
- return {
1611
- kind: "upgrade",
1612
- handler,
1613
- data: undefined,
1614
- pubsub,
1615
- attach,
1616
- maxPayloadBytes: this.wsMaxPayloadBytes,
1617
- };
1618
- }
1619
- const upgradeSignal = getNeverAbortSignal();
1620
- const ctx = new RequestContext(req, params, url.search, upgradeSignal, createUnboundedRequestBudget(upgradeSignal), platform, this.maxBodyBytes, this.protoPoisoning);
1621
- const settle = (value) => value instanceof Response
1622
- ? { kind: "reject", response: value }
1623
- : {
1624
- kind: "upgrade",
1625
- handler,
1626
- data: value,
1627
- pubsub,
1628
- attach,
1629
- maxPayloadBytes: this.wsMaxPayloadBytes,
1630
- };
1631
- try {
1632
- const result = handler.upgrade(ctx);
1633
- return result instanceof Promise
1634
- ? result.then(settle, () => ({
2161
+ }
2162
+ if (handler.upgrade === undefined) {
2163
+ return {
2164
+ kind: "upgrade",
2165
+ handler,
2166
+ data: undefined,
2167
+ pubsub,
2168
+ attach,
2169
+ maxPayloadBytes: this.wsMaxPayloadBytes,
2170
+ };
2171
+ }
2172
+ const budget = controller === undefined
2173
+ ? createUnboundedRequestBudget(signal)
2174
+ : createRequestBudget({ deadline: deadline, signal });
2175
+ const ctx = new RequestContext(request, params, url.search, signal, budget, platform, this.maxBodyBytes, this.protoPoisoning);
2176
+ const settle = (value) => value instanceof Response
2177
+ ? { kind: "reject", response: value }
2178
+ : {
2179
+ kind: "upgrade",
2180
+ handler,
2181
+ data: value,
2182
+ pubsub,
2183
+ attach,
2184
+ maxPayloadBytes: this.wsMaxPayloadBytes,
2185
+ };
2186
+ try {
2187
+ const result = handler.upgrade(ctx);
2188
+ if (!(result instanceof Promise))
2189
+ return settle(result);
2190
+ const pending = result.then(settle, () => ({
1635
2191
  kind: "reject",
1636
2192
  response: jsonError(500, "internal_error"),
1637
- }))
1638
- : settle(result);
2193
+ }));
2194
+ return bounded(pending, onTimeout);
2195
+ }
2196
+ catch {
2197
+ return { kind: "reject", response: jsonError(500, "internal_error") };
2198
+ }
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
+ };
2249
+ if (this.onRequestHooks.length === 0)
2250
+ return resolveWithDeadline(req);
2251
+ let hooked;
2252
+ try {
2253
+ hooked = this.runWebSocketRequestHooks(req, platform);
1639
2254
  }
1640
2255
  catch {
1641
2256
  return { kind: "reject", response: jsonError(500, "internal_error") };
1642
2257
  }
2258
+ if (!(hooked instanceof Promise)) {
2259
+ return hooked instanceof Response
2260
+ ? { kind: "reject", response: hooked }
2261
+ : resolveWithDeadline(hooked);
2262
+ }
2263
+ const pending = hooked.then((result) => result instanceof Response
2264
+ ? { kind: "reject", response: result }
2265
+ : resolveWithDeadline(result), () => ({ kind: "reject", response: jsonError(500, "internal_error") }));
2266
+ return bounded(Promise.resolve(pending).then((result) => result), onTimeout);
1643
2267
  }
1644
2268
  /** Bun `fetch` when WS routes exist: try a WS upgrade first, else run the normal HTTP lifecycle.
1645
2269
  * `undefined` ⇒ Bun owns the upgraded socket; a `Response` ⇒ a normal reply or a rejected upgrade.
@@ -1650,11 +2274,13 @@ export class Server {
1650
2274
  return this.fetch(req, bunPeerPlatform(server, req));
1651
2275
  if (o.kind === "reject")
1652
2276
  return o.response;
1653
- 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
+ })
1654
2280
  ? undefined
1655
2281
  : jsonError(426, "upgrade_required");
1656
2282
  };
1657
- const outcome = this.resolveWebSocketUpgrade(req);
2283
+ const outcome = this.resolveWebSocketUpgrade(req, bunPeerPlatform(server, req));
1658
2284
  return outcome instanceof Promise ? outcome.then(handle) : handle(outcome);
1659
2285
  }
1660
2286
  /**
@@ -1670,6 +2296,12 @@ export class Server {
1670
2296
  const derived = resolveClientIp(platform?.clientIp, requestOf(source), this.clientIpTrust);
1671
2297
  return { ...platform, clientIp: derived };
1672
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
+ }
1673
2305
  dispatch(source, platform, finalize, wrapResponse, onTimeout,
1674
2306
  // True only from the Web `fetch` path - unlocks each route's fused lane, whose output type IS
1675
2307
  // `Response` (`T = Response` there by construction; the node path always passes false).
@@ -1677,10 +2309,14 @@ export class Server {
1677
2309
  // Resolve the trust declaration into the platform's `clientIp` ONCE, here at the shared funnel, so
1678
2310
  // `c.clientIp` (and every hook/derive downstream) sees the derived caller. No config ⇒ the raw
1679
2311
  // socket peer the adapter supplied passes through untouched (a one-property no-op on the hot path).
1680
- 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);
1681
2315
  // onRequest hooks may be async, so a hooked app takes the async path; with no hooks (the common
1682
2316
  // case) routing stays synchronous, letting a bare route resolve with no lifecycle promise at all.
1683
- 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)) {
1684
2320
  return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast);
1685
2321
  }
1686
2322
  if (!webFast && this.canUseNodeRequestHooks()) {
@@ -1736,11 +2372,14 @@ export class Server {
1736
2372
  this.responseSources.set(source, originalRequest);
1737
2373
  let current = source;
1738
2374
  for (let i = 0; i < hooks.length; i++) {
1739
- const outcome = hooks[i](requestOf(current), platform);
2375
+ const seen = requestOf(current);
2376
+ const outcome = hooks[i](seen, platform);
1740
2377
  if (outcome instanceof Promise) {
1741
2378
  return outcome.then((early) => this.continueOnRequest(early, i + 1, originalRequest, current, platform, finalize, wrapResponse, onTimeout, webFast));
1742
2379
  }
1743
2380
  if (outcome instanceof Request) {
2381
+ if (outcome !== seen)
2382
+ recordRequestReplacement(outcome, seen);
1744
2383
  current = outcome;
1745
2384
  if (outcome !== originalRequest)
1746
2385
  this.responseRequests.set(originalRequest, outcome);
@@ -1755,10 +2394,13 @@ export class Server {
1755
2394
  * the remaining hooks (awaiting freely - we're already async here). */
1756
2395
  async continueOnRequest(first, nextIndex, originalRequest, sourceAtAwait, platform, finalize, wrapResponse, onTimeout, webFast) {
1757
2396
  let current = sourceAtAwait;
2397
+ let seen = requestOf(sourceAtAwait);
1758
2398
  let early = first;
1759
2399
  let index = nextIndex;
1760
2400
  for (;;) {
1761
2401
  if (early instanceof Request) {
2402
+ if (early !== seen)
2403
+ recordRequestReplacement(early, seen);
1762
2404
  current = early;
1763
2405
  if (early !== originalRequest)
1764
2406
  this.responseRequests.set(originalRequest, early);
@@ -1768,7 +2410,8 @@ export class Server {
1768
2410
  }
1769
2411
  if (index >= this.onRequestHooks.length)
1770
2412
  break;
1771
- const outcome = this.onRequestHooks[index](requestOf(current), platform);
2413
+ seen = requestOf(current);
2414
+ const outcome = this.onRequestHooks[index](seen, platform);
1772
2415
  early = outcome instanceof Promise ? await outcome : outcome;
1773
2416
  index++;
1774
2417
  }
@@ -1884,9 +2527,20 @@ export class Server {
1884
2527
  search = searchStart === -1 ? "" : rawUrl.slice(searchStart, searchEnd);
1885
2528
  }
1886
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
+ }
1887
2541
  const match = this.catalog.find(source.method, pathname);
1888
2542
  if (!match.found) {
1889
- const mounted = this.fetchMount(pathname, source, platform);
2543
+ const mounted = this.fetchMount(pathname, source, platform, false);
1890
2544
  if (mounted !== undefined) {
1891
2545
  return mounted instanceof Promise
1892
2546
  ? mounted.then((response) => wrapResponse(response))
@@ -1898,7 +2552,10 @@ export class Server {
1898
2552
  // and every other name in that record is lowercase (the Web lane normalizes either way).
1899
2553
  plainError(405, "method_not_allowed", { allow: match.allowed.join(", ") }));
1900
2554
  }
1901
- 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);
1902
2559
  }
1903
2560
  // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
1904
2561
  // already did and made every plain dynamic route pay for unrelated static path bytes.
@@ -1908,14 +2565,46 @@ export class Server {
1908
2565
  }
1909
2566
  return this.runMatched(source, platform, match.payload, params, search, finalize, wrapResponse, onTimeout, webFast);
1910
2567
  }
1911
- fetchMount(pathname, source, platform) {
1912
- for (const mount of this.fetchMounts) {
1913
- 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))
1914
2574
  continue;
1915
- const request = requestOf(source);
1916
- return mount.handler(mount.stripPrefix ? stripMountPrefix(request, mount.path) : request, platform);
2575
+ first = i;
2576
+ break;
1917
2577
  }
1918
- 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);
1919
2608
  }
1920
2609
  /** Run a route that has already been matched by the runtime or Nifra's portable router. */
1921
2610
  runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
@@ -1928,8 +2617,10 @@ export class Server {
1928
2617
  // handler can reach `c.req`; that lane already enforces `entry.bodyLimit`. Installing the full
1929
2618
  // direct-reader cap on `c.req` here would allocate bound readers, closures, and a stream wrapper
1930
2619
  // for a body that is already consumed. Keep the lazy transport cap for raw-body routes, where a
1931
- // user read is the only framework-owned body boundary.
1932
- 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)) {
1933
2624
  const method = source.method;
1934
2625
  if (method !== "GET" && method !== "HEAD")
1935
2626
  markTransportCap(source, entry.bodyLimit);
@@ -2176,7 +2867,7 @@ export class Server {
2176
2867
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2177
2868
  : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
2178
2869
  const finish = (value) => runParsed(value, ctx, finalize, wrapResponse);
2179
- 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);
2180
2871
  };
2181
2872
  }
2182
2873
  /** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
@@ -2548,14 +3239,294 @@ export class Server {
2548
3239
  ctx.body = validation.value;
2549
3240
  return runHandlerValidated();
2550
3241
  };
2551
- 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);
2552
3523
  };
2553
3524
  }
2554
3525
  bareError(err, ctx, finalize, wrapResponse) {
2555
3526
  // The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
2556
3527
  // lane and the fused body runner. `responseSet` and the logger are injected because they reach
2557
3528
  // into class state; the closure is allocated only on the throw path, never on the hot lane.
2558
- 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));
2559
3530
  }
2560
3531
  /**
2561
3532
  * Keep the ordinary thrown-error response independent from the caught value. Deliberate Response
@@ -2585,15 +3556,13 @@ export class Server {
2585
3556
  /** Bind a compiled validation stage to its stable schema and the existing recovery contract. */
2586
3557
  // @ts-expect-error TS6133 -- invoked structurally by the general route program
2587
3558
  validateProgramStage(entry, stage, source, ctx) {
2588
- const input = stage.kind === "headers"
2589
- ? headerObjectOf(source.headers)
2590
- : stage.kind === "params"
2591
- ? ctx.params
2592
- : stage.kind === "query"
2593
- ? queryObjectOf(ctx[CONTEXT_SEARCH])
2594
- : undefined;
2595
3559
  if (stage.kind === "body")
2596
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];
2597
3566
  const validation = stage.schema["~standard"].validate(input);
2598
3567
  return validation instanceof Promise
2599
3568
  ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, stage.kind))
@@ -2633,7 +3602,7 @@ export class Server {
2633
3602
  }
2634
3603
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2635
3604
  runBodyOnly(entry, source, ctx, finalize, wrapResponse) {
2636
- 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);
2637
3606
  }
2638
3607
  /** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
2639
3608
  * streaming fallback. A method (not per-request closures) so the hot path allocates nothing
@@ -2881,7 +3850,7 @@ export class Server {
2881
3850
  const contract = entry.responseContract;
2882
3851
  if (contract === undefined)
2883
3852
  return finalize(result, responseSet(ctx));
2884
- const checked = contract.runtime.check(contract.schema, result);
3853
+ const checked = contract.runtime.check(contract.definition, result, ctx);
2885
3854
  if (checked instanceof Promise) {
2886
3855
  return checked.then((outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2887
3856
  }
@@ -2916,15 +3885,16 @@ export class Server {
2916
3885
  * loop, which may await, drops into {@link runErrorHooks}. */
2917
3886
  handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
2918
3887
  // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
2919
- // an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
2920
- // or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
2921
- // 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.
2922
3892
  if (err instanceof Response)
2923
- return wrapResponse(err);
3893
+ return finalize(err, responseSet(ctx));
2924
3894
  // Same rule for a thrown `status(...)`, but through `finalize` rather than `wrapResponse`: the
2925
3895
  // value is still plain data, so the ordinary JSON lane renders it and no `Response` is built.
2926
3896
  if (isResponseResult(err))
2927
- return finalize(err, responseSet(ctx));
3897
+ return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, err);
2928
3898
  if (entry.onError.length === 0) {
2929
3899
  // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
2930
3900
  // (redacting) logger. Body-read failures and around-hook failures land here too.
@@ -2963,20 +3933,14 @@ export class Server {
2963
3933
  ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, "body"))
2964
3934
  : this.applyLifecycleValidation(entry, validation, ctx, "body");
2965
3935
  };
2966
- 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);
2967
3937
  }
2968
3938
  /** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
2969
3939
  * before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
2970
3940
  applyLifecycleValidation(entry, result, ctx, kind) {
2971
3941
  const assign = (value) => {
2972
- if (kind === "body")
2973
- ctx.body = value;
2974
- else if (kind === "query")
2975
- ctx.query = value;
2976
- else if (kind === "headers")
2977
- ctx.headers = value;
2978
- else
2979
- ctx.params = value;
3942
+ ;
3943
+ ctx[kind] = value;
2980
3944
  };
2981
3945
  if (result.issues === undefined) {
2982
3946
  assign(result.value);
@@ -2996,14 +3960,7 @@ export class Server {
2996
3960
  return plainValidationError(issues);
2997
3961
  if (recovery instanceof Response)
2998
3962
  return recovery;
2999
- const schema = kind === "body"
3000
- ? entry.schema?.body
3001
- : kind === "query"
3002
- ? entry.schema?.query
3003
- : kind === "params"
3004
- ? entry.schema?.params
3005
- : entry.schema?.headers;
3006
- const retried = schema["~standard"].validate(recovery);
3963
+ const retried = entry.schema[kind]["~standard"].validate(recovery);
3007
3964
  if (retried instanceof Promise) {
3008
3965
  return retried.then((settled) => {
3009
3966
  if (settled.issues !== undefined)
@@ -3019,7 +3976,7 @@ export class Server {
3019
3976
  }
3020
3977
  /** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
3021
3978
  * semantics remain in the plan; this closure only supplies native params and deadline fallback. */
3022
- compileBunNativeHandler(entry, paramNames, fused, signal, budget) {
3979
+ compileBunNativeHandler(entry, paramNames, fused, signal, budget, peer, fallback) {
3023
3980
  // This callback is reached only from Bun's compiled native route table. Bun has already parsed
3024
3981
  // the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
3025
3982
  // the portable/adapted source contract (those sources are never marked).
@@ -3027,7 +3984,9 @@ export class Server {
3027
3984
  // The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
3028
3985
  // marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
3029
3986
  // reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
3030
- const bodyLimit = entry.schema?.body === undefined ? entry.bodyLimit : undefined;
3987
+ const bodyLimit = entry.schema?.body === undefined || entry.program.authBeforeValidation
3988
+ ? entry.bodyLimit
3989
+ : undefined;
3031
3990
  const inner = fused;
3032
3991
  const capped = inner === undefined || bodyLimit === undefined
3033
3992
  ? inner
@@ -3037,73 +3996,90 @@ export class Server {
3037
3996
  markTransportCap(source, bodyLimit);
3038
3997
  return inner(source, params, search, fusedSignal, fusedBudget, platform, nativeContext);
3039
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
+ };
3040
4011
  if (paramNames.length === 0) {
3041
4012
  if (capped === undefined) {
3042
4013
  return (request) => {
3043
4014
  markFramed(request);
3044
- return this.fetchMatched(request, entry, EMPTY_PARAMS);
4015
+ return this.fetchMatched(request, entry, EMPTY_PARAMS, peer);
3045
4016
  };
3046
4017
  }
3047
4018
  if (this.acceptInboundDeadlines) {
3048
4019
  return (request) => {
3049
4020
  markFramed(request);
3050
4021
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
3051
- ? this.fetchMatched(request, entry, EMPTY_PARAMS)
3052
- : 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);
3053
4024
  };
3054
4025
  }
3055
4026
  return (request) => {
3056
4027
  markFramed(request);
3057
- return capped(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
4028
+ return runNative(request, EMPTY_PARAMS);
3058
4029
  };
3059
4030
  }
3060
4031
  const malformed = paramNames.length === 1
3061
- ? (params) => params[paramNames[0]]?.includes("\uFFFD") === true
3062
- : hasReplacementParam;
4032
+ ? (params) => unroutedParam(params[paramNames[0]])
4033
+ : hasUnroutedParam;
3063
4034
  if (capped === undefined) {
3064
- return (request) => {
4035
+ return (request, server) => {
3065
4036
  markFramed(request);
3066
4037
  const params = request.params ?? EMPTY_PARAMS;
3067
4038
  if (malformed(params))
3068
- return this.fetchSource(request);
3069
- return this.fetchMatched(request, entry, params);
4039
+ return fallback(request, server);
4040
+ return this.fetchMatched(request, entry, params, peer);
3070
4041
  };
3071
4042
  }
3072
4043
  if (this.acceptInboundDeadlines) {
3073
- return (request) => {
4044
+ return (request, server) => {
3074
4045
  markFramed(request);
3075
4046
  const params = request.params ?? EMPTY_PARAMS;
3076
4047
  if (malformed(params))
3077
- return this.fetchSource(request);
4048
+ return fallback(request, server);
3078
4049
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
3079
- ? this.fetchMatched(request, entry, params)
3080
- : capped(request, params, undefined, signal, budget, undefined, true);
4050
+ ? this.fetchMatched(request, entry, params, peer)
4051
+ : capped(request, params, undefined, signal, budget, peer, true);
3081
4052
  };
3082
4053
  }
3083
- return (request) => {
4054
+ return (request, server) => {
3084
4055
  markFramed(request);
3085
4056
  const params = request.params ?? EMPTY_PARAMS;
3086
4057
  if (malformed(params))
3087
- return this.fetchSource(request);
3088
- return capped(request, params, undefined, signal, budget, undefined, true);
4058
+ return fallback(request, server);
4059
+ return runNative(request, params);
3089
4060
  };
3090
4061
  }
3091
4062
  /** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
3092
4063
  * hooks or WebSockets retain the single portable dispatcher because those features must run before
3093
- * route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
3094
- * capture semantics; static and `:param` routes take the native lane. */
3095
- 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) {
3096
4071
  // A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
3097
4072
  // bypasses - so an app that declares trust routes through the fetch lane (where `c.clientIp`
3098
4073
  // resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
3099
- if (this.onRequestHooks.length > 0 ||
4074
+ if ((this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe) ||
3100
4075
  this.wsRouteCount > 0 ||
4076
+ this.preRouteMountCount > 0 ||
3101
4077
  this.clientIpTrust !== undefined) {
3102
4078
  return undefined;
3103
4079
  }
3104
4080
  const routes = Object.create(null);
3105
4081
  const mayUseFusedNative = this.requestTimeoutMs === 0 &&
3106
- this.onResponseHooks.length === 0 &&
4082
+ (this.onResponseHooks.length === 0 || this.bunNativeResponseHeadersOnly) &&
3107
4083
  this.onResponseFinalizedHooks.length === 0 &&
3108
4084
  // The capacity gate must wrap every request; the fused lane bypasses fetchMatched, so enabling
3109
4085
  // admission drops fusion (native matching stays) and routes through the gated matched lane.
@@ -3111,18 +4087,65 @@ export class Server {
3111
4087
  const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined;
3112
4088
  const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined;
3113
4089
  let count = 0;
3114
- for (const { method, path, pattern, entry } of this.catalog.entries()) {
3115
- 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))) {
3116
4138
  continue;
3117
- let methods = routes[path];
3118
- if (methods === undefined) {
3119
- methods = Object.create(null);
3120
- routes[path] = methods;
3121
4139
  }
3122
4140
  const paramNames = pattern.paramNames;
3123
4141
  const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined;
3124
- methods[method] = this.compileBunNativeHandler(entry, paramNames, fused, unboundedSignal, unboundedBudget);
4142
+ tableAt(path)[method] = this.compileBunNativeHandler(entry, paramNames, fused, unboundedSignal, unboundedBudget, peer, fallback);
3125
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
+ }
3126
4149
  }
3127
4150
  if (count === 0)
3128
4151
  return undefined;
@@ -3135,6 +4158,19 @@ export class Server {
3135
4158
  if (methods.GET !== undefined)
3136
4159
  methods.HEAD ??= methods.GET;
3137
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
+ }
3138
4174
  return routes;
3139
4175
  }
3140
4176
  /**
@@ -3158,6 +4194,10 @@ export class Server {
3158
4194
  * connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
3159
4195
  * this above their slowest expected response, which is why it is a first-class option and not a
3160
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.
3161
4201
  */
3162
4202
  listen(port, options) {
3163
4203
  if (typeof Bun === "undefined") {
@@ -3182,31 +4222,57 @@ export class Server {
3182
4222
  // because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
3183
4223
  // Native pub/sub when the app has WS routes and none validate outbound frames: `ws.subscribe` and
3184
4224
  // `app.publish` go through Bun's own (uWebSockets) broadcast instead of the JS registry loop.
3185
- const nativePubsub = this.wsRouteCount > 0 && !this.wsHasValidatedSend;
3186
- const wsHandlers = this.wsRouteCount === 0
3187
- ? undefined
3188
- : this.wsRuntime.bunHandlers(this.topics, nativePubsub);
3189
- const reusePort = options?.reusePort === true;
3190
- // Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
3191
- // on some option paths, and omitting is what selects its 0.0.0.0 default.
3192
- const bind = options?.hostname === undefined ? {} : { hostname: options.hostname };
3193
- // Same reasoning as `bind`: omit rather than pass undefined, so Bun's own default applies.
3194
- const idle = options?.idleTimeoutSec === undefined ? {} : { idleTimeout: options.idleTimeoutSec };
3195
- 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;
3196
4268
  const running = (wsHandlers === undefined
3197
4269
  ? Bun.serve({
3198
- port,
3199
- reusePort,
3200
- ...bind,
3201
- ...idle,
4270
+ ...listening,
3202
4271
  ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
3203
- fetch: (req, server) => this.fetch(req, bunPeerPlatform(server, req)),
4272
+ fetch: fallback,
3204
4273
  })
3205
4274
  : Bun.serve({
3206
- port,
3207
- reusePort,
3208
- ...bind,
3209
- ...idle,
4275
+ ...listening,
3210
4276
  fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
3211
4277
  // Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
3212
4278
  // `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
@@ -3222,7 +4288,9 @@ export class Server {
3222
4288
  }));
3223
4289
  this.bunServer = running;
3224
4290
  // Bind `app.publish` to Bun's native broadcast now that the server handle exists. Guarded on the
3225
- // 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;
3226
4294
  if (nativePubsub && typeof running.publish === "function") {
3227
4295
  const native = running.publish.bind(running);
3228
4296
  this.nativePublish = (topic, data) => {