@nifrajs/core 3.5.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 (325) hide show
  1. package/README.md +26 -0
  2. package/dist/assurance.d.ts +7 -0
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +39 -3
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +20 -2
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +47 -15
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/diff.d.ts +2 -1
  11. package/dist/diff.d.ts.map +1 -1
  12. package/dist/diff.js +26 -2
  13. package/dist/diff.js.map +1 -1
  14. package/dist/durable-adapters.d.ts.map +1 -1
  15. package/dist/durable-adapters.js +16 -3
  16. package/dist/durable-adapters.js.map +1 -1
  17. package/dist/durable-effect.d.ts.map +1 -1
  18. package/dist/durable-effect.js +6 -3
  19. package/dist/durable-effect.js.map +1 -1
  20. package/dist/edge-kit.d.ts +2 -0
  21. package/dist/edge-kit.d.ts.map +1 -1
  22. package/dist/edge-kit.js +2 -0
  23. package/dist/edge-kit.js.map +1 -1
  24. package/dist/effect-lifecycle.d.ts.map +1 -1
  25. package/dist/effect-lifecycle.js +2 -1
  26. package/dist/effect-lifecycle.js.map +1 -1
  27. package/dist/effect-scope.d.ts.map +1 -1
  28. package/dist/effect-scope.js +1 -1
  29. package/dist/effect-scope.js.map +1 -1
  30. package/dist/errors.d.ts +1 -1
  31. package/dist/errors.d.ts.map +1 -1
  32. package/dist/errors.js.map +1 -1
  33. package/dist/evidence.d.ts +24 -1
  34. package/dist/evidence.d.ts.map +1 -1
  35. package/dist/evidence.js +168 -13
  36. package/dist/evidence.js.map +1 -1
  37. package/dist/idempotency.d.ts +31 -0
  38. package/dist/idempotency.d.ts.map +1 -1
  39. package/dist/idempotency.js +40 -8
  40. package/dist/idempotency.js.map +1 -1
  41. package/dist/index.d.ts +8 -5
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +5 -2
  44. package/dist/index.js.map +1 -1
  45. package/dist/internal/code-unit-order.d.ts +7 -0
  46. package/dist/internal/code-unit-order.d.ts.map +1 -0
  47. package/dist/internal/code-unit-order.js +9 -0
  48. package/dist/internal/code-unit-order.js.map +1 -0
  49. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  50. package/dist/internal/durable-saga-protocol.js +2 -0
  51. package/dist/internal/durable-saga-protocol.js.map +1 -1
  52. package/dist/internal/route-catalog.d.ts +6 -6
  53. package/dist/internal/route-catalog.d.ts.map +1 -1
  54. package/dist/internal/route-catalog.js +20 -18
  55. package/dist/internal/route-catalog.js.map +1 -1
  56. package/dist/internal/route-compiler.d.ts +7 -6
  57. package/dist/internal/route-compiler.d.ts.map +1 -1
  58. package/dist/internal/route-compiler.js +36 -4
  59. package/dist/internal/route-compiler.js.map +1 -1
  60. package/dist/internal/route-execution.d.ts +7 -3
  61. package/dist/internal/route-execution.d.ts.map +1 -1
  62. package/dist/internal/route-execution.js.map +1 -1
  63. package/dist/internal/route-lanes.d.ts +1 -0
  64. package/dist/internal/route-lanes.d.ts.map +1 -1
  65. package/dist/internal/route-lanes.js +34 -29
  66. package/dist/internal/route-lanes.js.map +1 -1
  67. package/dist/internal/route-program.d.ts +8 -2
  68. package/dist/internal/route-program.d.ts.map +1 -1
  69. package/dist/internal/route-program.js +222 -20
  70. package/dist/internal/route-program.js.map +1 -1
  71. package/dist/ledger.d.ts.map +1 -1
  72. package/dist/ledger.js +8 -5
  73. package/dist/ledger.js.map +1 -1
  74. package/dist/manifest.d.ts.map +1 -1
  75. package/dist/manifest.js +41 -37
  76. package/dist/manifest.js.map +1 -1
  77. package/dist/mount.d.ts +36 -0
  78. package/dist/mount.d.ts.map +1 -1
  79. package/dist/mount.js +32 -0
  80. package/dist/mount.js.map +1 -1
  81. package/dist/reflection.d.ts +26 -0
  82. package/dist/reflection.d.ts.map +1 -1
  83. package/dist/reflection.js +65 -1
  84. package/dist/reflection.js.map +1 -1
  85. package/dist/response-observer.d.ts +1 -1
  86. package/dist/response-observer.d.ts.map +1 -1
  87. package/dist/response-observer.js.map +1 -1
  88. package/dist/router/overlap.d.ts +23 -0
  89. package/dist/router/overlap.d.ts.map +1 -0
  90. package/dist/router/overlap.js +224 -0
  91. package/dist/router/overlap.js.map +1 -0
  92. package/dist/router/pattern.d.ts +117 -2
  93. package/dist/router/pattern.d.ts.map +1 -1
  94. package/dist/router/pattern.js +283 -23
  95. package/dist/router/pattern.js.map +1 -1
  96. package/dist/router/router.d.ts +18 -4
  97. package/dist/router/router.d.ts.map +1 -1
  98. package/dist/router/router.js +51 -44
  99. package/dist/router/router.js.map +1 -1
  100. package/dist/server/auth.d.ts +53 -0
  101. package/dist/server/auth.d.ts.map +1 -0
  102. package/dist/server/auth.js +9 -0
  103. package/dist/server/auth.js.map +1 -0
  104. package/dist/server/bare-error-lane.d.ts +12 -7
  105. package/dist/server/bare-error-lane.d.ts.map +1 -1
  106. package/dist/server/bare-error-lane.js +13 -11
  107. package/dist/server/bare-error-lane.js.map +1 -1
  108. package/dist/server/body-lane.d.ts +20 -2
  109. package/dist/server/body-lane.d.ts.map +1 -1
  110. package/dist/server/body-lane.js +26 -7
  111. package/dist/server/body-lane.js.map +1 -1
  112. package/dist/server/body-parser.d.ts +68 -0
  113. package/dist/server/body-parser.d.ts.map +1 -0
  114. package/dist/server/body-parser.js +172 -0
  115. package/dist/server/body-parser.js.map +1 -0
  116. package/dist/server/body.d.ts.map +1 -1
  117. package/dist/server/body.js +62 -7
  118. package/dist/server/body.js.map +1 -1
  119. package/dist/server/client-ip.d.ts +8 -0
  120. package/dist/server/client-ip.d.ts.map +1 -1
  121. package/dist/server/client-ip.js +8 -0
  122. package/dist/server/client-ip.js.map +1 -1
  123. package/dist/server/context.d.ts +71 -6
  124. package/dist/server/context.d.ts.map +1 -1
  125. package/dist/server/contract.d.ts +15 -5
  126. package/dist/server/contract.d.ts.map +1 -1
  127. package/dist/server/contract.js +25 -4
  128. package/dist/server/contract.js.map +1 -1
  129. package/dist/server/core-copies.d.ts +2 -0
  130. package/dist/server/core-copies.d.ts.map +1 -0
  131. package/dist/server/core-copies.js +20 -0
  132. package/dist/server/core-copies.js.map +1 -0
  133. package/dist/server/edge.d.ts.map +1 -1
  134. package/dist/server/edge.js +2 -0
  135. package/dist/server/edge.js.map +1 -1
  136. package/dist/server/http.d.ts +11 -0
  137. package/dist/server/http.d.ts.map +1 -1
  138. package/dist/server/http.js +36 -0
  139. package/dist/server/http.js.map +1 -1
  140. package/dist/server/idempotency-lane.d.ts +6 -0
  141. package/dist/server/idempotency-lane.d.ts.map +1 -1
  142. package/dist/server/idempotency-lane.js +130 -73
  143. package/dist/server/idempotency-lane.js.map +1 -1
  144. package/dist/server/install.d.ts +4 -0
  145. package/dist/server/install.d.ts.map +1 -1
  146. package/dist/server/install.js +4 -0
  147. package/dist/server/install.js.map +1 -1
  148. package/dist/server/methods.d.ts +45 -0
  149. package/dist/server/methods.d.ts.map +1 -0
  150. package/dist/server/methods.js +37 -0
  151. package/dist/server/methods.js.map +1 -0
  152. package/dist/server/multipart.d.ts +63 -0
  153. package/dist/server/multipart.d.ts.map +1 -0
  154. package/dist/server/multipart.js +374 -0
  155. package/dist/server/multipart.js.map +1 -0
  156. package/dist/server/node-outcome-hook.d.ts +20 -0
  157. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  158. package/dist/server/node-outcome-hook.js +15 -6
  159. package/dist/server/node-outcome-hook.js.map +1 -1
  160. package/dist/server/not-found-answer.d.ts +40 -0
  161. package/dist/server/not-found-answer.d.ts.map +1 -0
  162. package/dist/server/not-found-answer.js +56 -0
  163. package/dist/server/not-found-answer.js.map +1 -0
  164. package/dist/server/not-found.d.ts +23 -0
  165. package/dist/server/not-found.d.ts.map +1 -0
  166. package/dist/server/not-found.js +81 -0
  167. package/dist/server/not-found.js.map +1 -0
  168. package/dist/server/plugin.d.ts +26 -1
  169. package/dist/server/plugin.d.ts.map +1 -1
  170. package/dist/server/plugin.js.map +1 -1
  171. package/dist/server/proto-guard.d.ts +18 -5
  172. package/dist/server/proto-guard.d.ts.map +1 -1
  173. package/dist/server/proto-guard.js +114 -9
  174. package/dist/server/proto-guard.js.map +1 -1
  175. package/dist/server/query.d.ts +7 -0
  176. package/dist/server/query.d.ts.map +1 -1
  177. package/dist/server/query.js +47 -0
  178. package/dist/server/query.js.map +1 -1
  179. package/dist/server/registry.d.ts +13 -2
  180. package/dist/server/registry.d.ts.map +1 -1
  181. package/dist/server/request-context.d.ts +11 -0
  182. package/dist/server/request-context.d.ts.map +1 -1
  183. package/dist/server/request-context.js +33 -19
  184. package/dist/server/request-context.js.map +1 -1
  185. package/dist/server/request-lineage.d.ts +8 -0
  186. package/dist/server/request-lineage.d.ts.map +1 -0
  187. package/dist/server/request-lineage.js +14 -0
  188. package/dist/server/request-lineage.js.map +1 -0
  189. package/dist/server/request-target.d.ts +31 -0
  190. package/dist/server/request-target.d.ts.map +1 -0
  191. package/dist/server/request-target.js +44 -0
  192. package/dist/server/request-target.js.map +1 -0
  193. package/dist/server/respond.d.ts +1 -1
  194. package/dist/server/respond.d.ts.map +1 -1
  195. package/dist/server/respond.js +16 -4
  196. package/dist/server/respond.js.map +1 -1
  197. package/dist/server/response-contract-lane.d.ts +19 -1
  198. package/dist/server/response-contract-lane.d.ts.map +1 -1
  199. package/dist/server/response-contract-lane.js +59 -14
  200. package/dist/server/response-contract-lane.js.map +1 -1
  201. package/dist/server/response-observer-runtime.d.ts +9 -5
  202. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  203. package/dist/server/response-observer-runtime.js +10 -6
  204. package/dist/server/response-observer-runtime.js.map +1 -1
  205. package/dist/server/runtime-core.d.ts +2 -0
  206. package/dist/server/runtime-core.d.ts.map +1 -1
  207. package/dist/server/runtime-core.js.map +1 -1
  208. package/dist/server/server-types.d.ts +53 -3
  209. package/dist/server/server-types.d.ts.map +1 -1
  210. package/dist/server/server.d.ts +191 -19
  211. package/dist/server/server.d.ts.map +1 -1
  212. package/dist/server/server.js +1206 -218
  213. package/dist/server/server.js.map +1 -1
  214. package/dist/server/sse.d.ts.map +1 -1
  215. package/dist/server/sse.js +10 -7
  216. package/dist/server/sse.js.map +1 -1
  217. package/dist/server/static-headers.d.ts +3 -0
  218. package/dist/server/static-headers.d.ts.map +1 -1
  219. package/dist/server/static-headers.js +19 -1
  220. package/dist/server/static-headers.js.map +1 -1
  221. package/dist/server/transport-lane.d.ts +8 -0
  222. package/dist/server/transport-lane.d.ts.map +1 -1
  223. package/dist/server/transport-lane.js +9 -8
  224. package/dist/server/transport-lane.js.map +1 -1
  225. package/dist/server/validation.d.ts.map +1 -1
  226. package/dist/server/validation.js +5 -1
  227. package/dist/server/validation.js.map +1 -1
  228. package/dist/server/websocket.d.ts +10 -3
  229. package/dist/server/websocket.d.ts.map +1 -1
  230. package/dist/server/websocket.js +7 -4
  231. package/dist/server/websocket.js.map +1 -1
  232. package/dist/server/ws-bun.d.ts +2 -0
  233. package/dist/server/ws-bun.d.ts.map +1 -1
  234. package/dist/server/ws-bun.js +6 -3
  235. package/dist/server/ws-bun.js.map +1 -1
  236. package/dist/server/ws-hook.d.ts +2 -1
  237. package/dist/server/ws-hook.d.ts.map +1 -1
  238. package/dist/server.d.ts +7 -4
  239. package/dist/server.d.ts.map +1 -1
  240. package/dist/server.js +4 -1
  241. package/dist/server.js.map +1 -1
  242. package/dist/single-copy-register.js +5 -0
  243. package/dist/single-copy-register.js.map +1 -1
  244. package/dist/single-copy.d.ts +46 -3
  245. package/dist/single-copy.d.ts.map +1 -1
  246. package/dist/single-copy.js +125 -12
  247. package/dist/single-copy.js.map +1 -1
  248. package/dist/tool-contract.d.ts +23 -0
  249. package/dist/tool-contract.d.ts.map +1 -1
  250. package/dist/tool-contract.js +37 -3
  251. package/dist/tool-contract.js.map +1 -1
  252. package/dist/transport-codec.d.ts.map +1 -1
  253. package/dist/transport-codec.js +2 -1
  254. package/dist/transport-codec.js.map +1 -1
  255. package/package.json +27 -2
  256. package/src/assurance.ts +52 -2
  257. package/src/capabilities.ts +81 -22
  258. package/src/diff.ts +31 -6
  259. package/src/durable-adapters.ts +20 -3
  260. package/src/durable-effect.ts +10 -3
  261. package/src/edge-kit.ts +7 -0
  262. package/src/effect-lifecycle.ts +5 -1
  263. package/src/effect-scope.ts +2 -1
  264. package/src/errors.ts +3 -0
  265. package/src/evidence.ts +237 -12
  266. package/src/idempotency.ts +72 -8
  267. package/src/index.ts +40 -3
  268. package/src/internal/code-unit-order.ts +8 -0
  269. package/src/internal/durable-saga-protocol.ts +2 -0
  270. package/src/internal/route-catalog.ts +19 -18
  271. package/src/internal/route-compiler.ts +62 -13
  272. package/src/internal/route-execution.ts +13 -3
  273. package/src/internal/route-lanes.ts +37 -28
  274. package/src/internal/route-program.ts +434 -20
  275. package/src/ledger.ts +11 -5
  276. package/src/manifest.ts +41 -36
  277. package/src/mount.ts +62 -0
  278. package/src/reflection.ts +84 -1
  279. package/src/response-observer.ts +1 -0
  280. package/src/router/overlap.ts +258 -0
  281. package/src/router/pattern.ts +349 -25
  282. package/src/router/router.ts +76 -51
  283. package/src/server/auth.ts +75 -0
  284. package/src/server/bare-error-lane.ts +12 -11
  285. package/src/server/body-lane.ts +46 -6
  286. package/src/server/body-parser.ts +241 -0
  287. package/src/server/body.ts +82 -9
  288. package/src/server/client-ip.ts +9 -0
  289. package/src/server/context.ts +219 -12
  290. package/src/server/contract.ts +54 -9
  291. package/src/server/core-copies.ts +21 -0
  292. package/src/server/edge.ts +2 -0
  293. package/src/server/http.ts +34 -0
  294. package/src/server/idempotency-lane.ts +147 -72
  295. package/src/server/install.ts +6 -0
  296. package/src/server/methods.ts +143 -0
  297. package/src/server/multipart.ts +424 -0
  298. package/src/server/node-outcome-hook.ts +40 -7
  299. package/src/server/not-found-answer.ts +109 -0
  300. package/src/server/not-found.ts +121 -0
  301. package/src/server/plugin.ts +45 -1
  302. package/src/server/proto-guard.ts +128 -9
  303. package/src/server/query.ts +49 -0
  304. package/src/server/registry.ts +17 -2
  305. package/src/server/request-context.ts +44 -16
  306. package/src/server/request-lineage.ts +15 -0
  307. package/src/server/request-target.ts +46 -0
  308. package/src/server/respond.ts +14 -3
  309. package/src/server/response-contract-lane.ts +97 -16
  310. package/src/server/response-observer-runtime.ts +37 -8
  311. package/src/server/runtime-core.ts +2 -0
  312. package/src/server/server-types.ts +59 -2
  313. package/src/server/server.ts +1613 -269
  314. package/src/server/sse.ts +10 -8
  315. package/src/server/static-headers.ts +22 -1
  316. package/src/server/transport-lane.ts +17 -9
  317. package/src/server/validation.ts +7 -1
  318. package/src/server/websocket.ts +23 -9
  319. package/src/server/ws-bun.ts +8 -3
  320. package/src/server/ws-hook.ts +2 -1
  321. package/src/server.ts +38 -2
  322. package/src/single-copy-register.ts +5 -0
  323. package/src/single-copy.ts +160 -12
  324. package/src/tool-contract.ts +66 -3
  325. package/src/transport-codec.ts +2 -1
@@ -31,23 +31,25 @@ import {
31
31
  type InternalHandler,
32
32
  type RawAfterHandle,
33
33
  type RawAround,
34
+ type RawAuthStage,
34
35
  type RawBeforeHandle,
35
36
  type RawDerive,
36
37
  type RawErrorHandler,
37
38
  type RouteEntry,
38
39
  } from "../internal/route-execution.ts"
39
- import type { RouteProgramStage } from "../internal/route-program.ts"
40
+ import type { ProgramValidationKind, RouteProgramStage } from "../internal/route-program.ts"
40
41
  import { compileRouteProgram, executeRouteProgram } from "../internal/route-program.ts"
41
42
  import { isSameOriginRequest } from "../internal/same-origin.ts"
42
43
  import type { ResponseObserverPlugin } from "../response-observer.ts"
43
- import { decodeRouteParams } from "../router/pattern.ts"
44
- import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
44
+ import { decodeRouteParams, expandOptionalParams } from "../router/pattern.ts"
45
+ import { EMPTY_PARAMS, METHODS, type Method, Router } from "../router/router.ts"
45
46
  import type {
46
47
  InferOutput,
47
48
  StandardIssue,
48
49
  StandardResult,
49
50
  StandardSchemaV1,
50
51
  } from "../schema/standard.ts"
52
+ import type { AuthenticationStage } from "./auth.ts"
51
53
  import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.ts"
52
54
  import {
53
55
  assertByteLimit,
@@ -56,20 +58,33 @@ import {
56
58
  type RawBodyReaders,
57
59
  UNLIMITED_BODY_BYTES,
58
60
  } from "./body.ts"
59
- import { type ClientIpTrust, resolveClientIp } from "./client-ip.ts"
61
+ import {
62
+ type ClientIpTrust,
63
+ NIFRA_PLATFORM_CLIENT_IP_DERIVED,
64
+ resolveClientIp,
65
+ } from "./client-ip.ts"
60
66
  import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
67
+ import "./core-copies.ts"
61
68
  import {
62
69
  hasLowercaseHeaderKeysMark,
63
70
  headerKeysAllLowercase,
64
71
  markLowercaseHeaderKeys,
65
72
  } from "./header-case.ts"
66
73
  import { headerObjectOf } from "./headers.ts"
67
- import { jsonError, pathnameOf, plainError, type UrlParts, urlPartsOf } from "./http.ts"
74
+ import {
75
+ isRoutableMethod,
76
+ jsonError,
77
+ pathnameOf,
78
+ plainError,
79
+ type UrlParts,
80
+ urlPartsOf,
81
+ } from "./http.ts"
68
82
  import { type NodeServeOutcome, withStaticNodeHeaders } from "./node-outcome.ts"
69
83
  import type {
70
84
  NodeOutcomeRuntime,
71
85
  NodeRequestContext,
72
86
  NodeRequestHook,
87
+ NodeResponseBodyHook,
73
88
  NodeResponseContext,
74
89
  NodeResponseHook,
75
90
  ResponseBodyHook,
@@ -78,7 +93,14 @@ import type {
78
93
  ResponseHeadersView,
79
94
  } from "./node-outcome-hook.ts"
80
95
  import { type QueryValue, queryObjectOf, searchOf } from "./query.ts"
81
- import { RequestContext, readBodyFramed } from "./request-context.ts"
96
+ import {
97
+ type PeerPlatform,
98
+ PLATFORM_PEER,
99
+ RequestContext,
100
+ readBodyFramed,
101
+ } from "./request-context.ts"
102
+ import { recordRequestReplacement } from "./request-lineage.ts"
103
+ import { hasDotSegment, resolveDotSegments } from "./request-target.ts"
82
104
  import {
83
105
  applyStaticResponseHeaders,
84
106
  buildStaticResponseHeaders,
@@ -114,6 +136,7 @@ import { plainValidationError } from "./validation.ts"
114
136
  export type {
115
137
  NodeRequestContext,
116
138
  NodeRequestHook,
139
+ NodeResponseBodyHook,
117
140
  NodeResponseContext,
118
141
  NodeResponseHook,
119
142
  NodeServeOutcome,
@@ -123,12 +146,15 @@ export type {
123
146
  ResponseHeadersView,
124
147
  }
125
148
 
149
+ import { NIFRA_BACKEND_WS_RUNTIME } from "../mount.ts"
126
150
  import type { IdempotencyRuntime } from "./idempotency-lane.ts"
127
151
  import {
152
+ GET_WS_RUNTIME,
128
153
  INSTALL_EFFECT_LEDGER,
129
154
  INSTALL_IDEMPOTENCY,
130
155
  INSTALL_MCP,
131
156
  INSTALL_NODE_DIRECT,
157
+ INSTALL_NOT_FOUND,
132
158
  INSTALL_RESPONSE_CONTRACT,
133
159
  INSTALL_RESPONSE_OBSERVER,
134
160
  INSTALL_SSE,
@@ -139,16 +165,20 @@ import {
139
165
  import type { EffectLedgerRuntime } from "./ledger-lane.ts"
140
166
  import { jsonLogger, type Logger } from "./logger.ts"
141
167
  import type { McpRuntime } from "./mcp-hook.ts"
168
+ import type { NotFoundLane } from "./not-found-answer.ts"
142
169
  import type {
143
170
  ContextPlugin,
144
171
  IdentityPlugin,
172
+ MethodRoutesPlugin,
145
173
  PluginTypeCollapsed,
146
174
  ServerTypeUnpinned,
175
+ WithMethodRoutes,
147
176
  } from "./plugin.ts"
148
177
  import type {
149
178
  AddRoute,
150
179
  EmptyRegistry,
151
180
  OutputOf,
181
+ PrefixRegistry,
152
182
  Registry,
153
183
  RouteInfoFor,
154
184
  WsRouteInfoFor,
@@ -157,10 +187,13 @@ import type {
157
187
  AdmissionController,
158
188
  AdmissionDecision,
159
189
  FetchHandler,
190
+ ListenTlsOptions,
160
191
  McpPromptDescriptor,
161
192
  McpResourceDescriptor,
162
193
  Middleware,
194
+ MountableApp,
163
195
  MountFetchOptions,
196
+ MountOptions,
164
197
  PromptArgument,
165
198
  PromptMessage,
166
199
  ResponseFinalization,
@@ -183,6 +216,11 @@ import type { WsRuntime } from "./ws-hook.ts"
183
216
 
184
217
  export type MaybePromise<T> = T | Promise<T>
185
218
 
219
+ // CORS uses this marker to say its request hook only handles OPTIONS preflight. Bun can keep
220
+ // ordinary registered routes on its native table and send unmatched OPTIONS requests through the
221
+ // normal fetch fallback where the hook still runs.
222
+ const BUN_NATIVE_REQUEST_SAFE = Symbol.for("@nifrajs/core/bun-native-request-safe")
223
+
186
224
  /**
187
225
  * Internal request view. A real Web `Request` already satisfies this shape, so Web/edge runtimes pass
188
226
  * their `Request` **directly** (zero wrapper allocation on the hot path - `request` is simply absent and
@@ -234,15 +272,18 @@ export interface RawContext {
234
272
  readonly request: Request
235
273
  readonly json: (body: unknown, init?: ResponseInit | number) => Response
236
274
  readonly text: (body: string, init?: ResponseInit | number) => Response
237
- // Writable: the lifecycle replaces it with the validated/coerced value when a `params` schema is
238
- // declared (handlers still see it `readonly` via the public `Context` interface).
275
+ // Writable: the lifecycle replaces each with the validated/coerced value when its schema is
276
+ // declared (handlers still see them `readonly` via the public `Context` interface).
239
277
  params: Record<string, string>
240
278
  headers: Record<string, string>
241
279
  query: unknown
242
- readonly cookies: Readonly<Record<string, string>>
280
+ cookies: Readonly<Record<string, string>>
243
281
  body: unknown
244
282
  readonly set: ResponseControls
245
283
  readonly [CONTEXT_SET]: () => CtxSet | undefined
284
+ /** The last `json()` reply and the value it was built from, for the response-contract lane. */
285
+ readonly jsonReply?: Response | undefined
286
+ readonly jsonBody?: unknown
246
287
  readonly [CONTEXT_SEARCH]: string
247
288
  readonly signal: AbortSignal
248
289
  readonly budget: RequestBudget
@@ -266,13 +307,21 @@ interface WsEntry {
266
307
  readonly handler: WebSocketHandler
267
308
  }
268
309
 
310
+ /** Structural view of the socket-peer lookup on the Bun `Server`, so any Bun `Server` (WS or not)
311
+ * satisfies it. */
312
+ interface BunPeerServer {
313
+ requestIP(request: Request): { readonly address: string } | null
314
+ }
315
+
269
316
  /** Structural view of the Bun `Server` the `fetch` 2nd arg exposes (`upgrade` + the socket peer). */
270
- interface BunUpgradeServer {
317
+ interface BunUpgradeServer extends BunPeerServer {
271
318
  upgrade(request: Request, options?: { data?: BunWsData }): boolean
272
- requestIP(request: Request): { readonly address: string } | null
273
319
  }
274
320
 
275
- type MountedFetchHandler = (request: Request, platform?: Platform) => MaybePromise<Response>
321
+ type MountedFetchHandler<Env = unknown> = (
322
+ request: Request,
323
+ platform?: Platform<Env>,
324
+ ) => MaybePromise<Response>
276
325
 
277
326
  /** Structural native mount contract. The serving adapter owns the concrete Node request/response
278
327
  * types; the kernel only selects a handler after proving that taking this lane cannot skip its
@@ -290,21 +339,31 @@ interface NativeMountSelection {
290
339
  readonly stripPrefix: boolean
291
340
  }
292
341
 
293
- interface FetchMount {
342
+ interface FetchMount<Env = unknown> {
294
343
  readonly path: string
295
- readonly handler: MountedFetchHandler
344
+ readonly handler: MountedFetchHandler<Env>
345
+ readonly resolveWebSocketUpgrade?: (
346
+ request: Request,
347
+ platform?: Platform<Env>,
348
+ ) => MaybePromise<WebSocketUpgradeOutcome>
349
+ /** Set with the resolver: the runtime the child's WebSocket upgrades need (see `[GET_WS_RUNTIME]`). */
350
+ readonly wsRuntime?: (seen: Set<unknown>) => WsRuntime | null | undefined
296
351
  readonly stripPrefix: boolean
352
+ readonly priority: number
353
+ readonly fallbackOn404: boolean
354
+ readonly beforeRoutes: boolean
355
+ readonly order: number
356
+ /** The declared reason this child is not analyzed (`MountOptions.opaque`), checked by reflection. */
357
+ readonly opaque: string | undefined
358
+ /** The mounted app (`mount()` only), for reflection to tell a composed child from an opaque one. */
359
+ readonly app?: object
297
360
  }
298
361
 
299
- /** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown).
300
- * Typed structurally on `requestIP` alone so any Bun `Server` (WS or not) satisfies it. */
301
- function bunPeerPlatform(
302
- server: { requestIP(request: Request): { readonly address: string } | null },
303
- req: Request,
304
- ): Platform {
305
- // Bun's requestIP() is surprisingly expensive (~20 us on the SSR benchmark machine). Keep the
362
+ /** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown). */
363
+ function bunPeerPlatform(server: BunPeerServer, req: Request): Platform {
364
+ // Bun's requestIP() costs ~0.5 us per call, about 7% of a bare GET's server time. Keep the
306
365
  // documented raw-peer c.clientIp behavior, but resolve it lazily: most routes never read c.clientIp,
307
- // and paying for the socket lookup on every request erased Bun's native HTTP advantage. A getter also
366
+ // so they should not pay for the socket lookup on every request. A getter also
308
367
  // preserves middleware that inspects the platform argument directly and trust-mode routes, which
309
368
  // resolve the value in deriveClientIp before the handler runs.
310
369
  let resolved = false
@@ -320,13 +379,42 @@ function bunPeerPlatform(
320
379
  }
321
380
  }
322
381
 
323
- type BunNativeHandler = (request: Request) => MaybePromise<Response>
382
+ type BunNativeHandler = (request: Request, server: BunPeerServer) => MaybePromise<Response>
324
383
  type BunNativeMethodTable = Partial<Record<Method, BunNativeHandler>>
325
384
  type BunNativeRoutes = Record<string, BunNativeMethodTable>
326
385
  type BunRequestWithParams = Request & { readonly params?: Record<string, string> }
327
386
 
387
+ /**
388
+ * Whether the portable router gives `other` a request that `route` also matches: the two patterns can
389
+ * match one path, and `other` is the more specific at the first segment where they differ. `route`
390
+ * holds static and `:param` segments only; `other` has a wildcard or a part-literal segment.
391
+ *
392
+ * It errs toward `true`. A segment that is part literal, part parameter is taken to match whatever
393
+ * `route` has in that position.
394
+ */
395
+ function outranks(
396
+ other: CatalogRoute["pattern"]["segments"],
397
+ route: CatalogRoute["pattern"]["segments"],
398
+ ): boolean {
399
+ let ahead = false
400
+ for (let i = 0; i < route.length; i++) {
401
+ const theirs = other[i]
402
+ if (theirs === undefined) return false
403
+ if (theirs.kind === "wildcard") return ahead
404
+ const ours = route[i]!
405
+ if (ours.kind === "static") {
406
+ if (theirs.kind === "static" ? theirs.value !== ours.value : !ahead) return false
407
+ } else if (theirs.kind !== "param") ahead = true
408
+ }
409
+ return ahead && other.length === route.length
410
+ }
411
+
328
412
  const WS_PASS: WebSocketUpgradeOutcome = { kind: "pass" }
329
413
  const DEFAULT_WS_UPGRADE_TIMEOUT_MS = 10_000
414
+ /** Counts `ws()` routes and `mount()`s across every app in this copy of core: the only
415
+ * changes that can make a mounted app take WebSocket upgrades. An app's answer to that question
416
+ * (`mountsTakeUpgrades`) is kept until this moves. */
417
+ let wsChanges = 0
330
418
 
331
419
  /** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
332
420
  function requireWsRuntime(runtime: WsRuntime | undefined): WsRuntime {
@@ -408,10 +496,13 @@ export type {
408
496
  AdmissionController,
409
497
  AdmissionDecision,
410
498
  FetchHandler,
499
+ ListenTlsOptions,
411
500
  McpPromptDescriptor,
412
501
  McpResourceDescriptor,
413
502
  Middleware,
503
+ MountableApp,
414
504
  MountFetchOptions,
505
+ MountOptions,
415
506
  PromptArgument,
416
507
  PromptMessage,
417
508
  ResponseFinalization,
@@ -436,8 +527,10 @@ export {
436
527
  defineIdentityPlugin,
437
528
  definePlugin,
438
529
  defineRouterPlugin,
530
+ type MethodRoutesPlugin,
439
531
  type NifraPlugin,
440
532
  type PluginTypeCollapsed,
533
+ type WithMethodRoutes,
441
534
  } from "./plugin.ts"
442
535
  export type { IdentityPlugin }
443
536
 
@@ -462,13 +555,31 @@ export { plainValidationError } from "./validation.ts"
462
555
  // importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
463
556
  export { type QueryValue, queryObjectOf, searchOf }
464
557
 
465
- function hasReplacementParam(params: Record<string, string>): boolean {
558
+ /**
559
+ * A param value Bun's native route table matched but the portable router must decide: an invalid
560
+ * escape, or a piece of path Bun matched raw while its own parsed URL says otherwise - a `.` / `..`
561
+ * segment (Bun hands `%2e` over decoded), or a backslash, which the parsed URL reads as `/`.
562
+ */
563
+ const unroutedParam = (value: string | undefined): boolean =>
564
+ value !== undefined &&
565
+ (value.includes("\uFFFD") || value.includes("\\") || value === "." || value === "..")
566
+
567
+ function hasUnroutedParam(params: Record<string, string>): boolean {
466
568
  for (const key in params) {
467
- if (params[key]!.includes("\uFFFD")) return true
569
+ if (unroutedParam(params[key])) return true
468
570
  }
469
571
  return false
470
572
  }
471
573
 
574
+ /**
575
+ * Deno is the runtime that hands an app a `Request` whose `url` is the target as the client sent it;
576
+ * Bun, workerd and every `new Request()` parse it first. Only there does `fetch` read the URL up front.
577
+ */
578
+ const rawRequestTargets = typeof (globalThis as { Deno?: unknown }).Deno !== "undefined"
579
+
580
+ /** `req` as Bun or workerd would have delivered it: the same request, its URL's dot segments resolved. */
581
+ const withResolvedTarget = (req: Request): Request => new Request(resolveDotSegments(req.url), req)
582
+
472
583
  function normalizeMountPrefix(path: string): string {
473
584
  if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
474
585
  throw new TypeError("mountFetch path must be an absolute pathname without a query or hash")
@@ -487,6 +598,18 @@ function underMountPrefix(pathname: string, prefix: string): boolean {
487
598
  return prefix === "/" || pathname === prefix || pathname.startsWith(`${prefix}/`)
488
599
  }
489
600
 
601
+ function mountPriorityOf(priority: number | undefined): number {
602
+ if (priority === undefined) return 0
603
+ if (!Number.isSafeInteger(priority)) {
604
+ throw new TypeError("mount priority must be a safe integer")
605
+ }
606
+ return priority
607
+ }
608
+
609
+ function compareMounts<Env>(a: FetchMount<Env>, b: FetchMount<Env>): number {
610
+ return b.priority - a.priority || b.path.length - a.path.length || a.order - b.order
611
+ }
612
+
490
613
  function stripMountPrefix(request: Request, prefix: string): Request {
491
614
  if (prefix === "/") return request
492
615
  const url = new URL(request.url)
@@ -536,6 +659,88 @@ export type NifraFeatureVersion = FeatureVersionOf<Version>
536
659
  const webResponseOf = (result: Response | ResponseResult): Response =>
537
660
  result instanceof Response ? result : toResponse(result, EMPTY_RESPONSE_CONTROLS)
538
661
 
662
+ /** Which requests a scoped (group/merged) hook runs for, judged from the request the hook sees. */
663
+ type HookScope = (req: { readonly method: string; readonly url: string }) => boolean
664
+
665
+ /** A `group(prefix)` hook's scope: every request whose path is the prefix or lies under it - served,
666
+ * 404, 405, or preflight alike (Hono's `use("/api/*")` reach). The router matches static segments
667
+ * byte-exactly and case-sensitively, with the same path scanner used here, so no spelling a group
668
+ * route answers to can fall outside this test. */
669
+ const underPrefix = (prefix: string): HookScope => {
670
+ const nested = `${prefix}/`
671
+ return (req) => {
672
+ const path = pathnameOf(req.url)
673
+ return path === prefix || path.startsWith(nested)
674
+ }
675
+ }
676
+
677
+ /** A merged server's hook scope: exactly the routes MERGED, snapshotted into a dedicated matcher so
678
+ * the guard never reflects whatever the source's own catalog grows into afterwards. */
679
+ const servedBy = (routes: readonly CatalogRoute[]): HookScope => {
680
+ const scope = new RouteCatalog()
681
+ scope.addBatch(routes)
682
+ return (req) => scope.find(req.method, pathnameOf(req.url)).found
683
+ }
684
+
685
+ type AdoptedHook = ((first: never, second: never) => unknown) | undefined
686
+
687
+ /** Gate adopted hooks to `inScope` (`undefined`: adopt them unchanged). The request is a request
688
+ * hook's first argument (`reqAt` 0) and every response-side hook's second; out of scope a hook
689
+ * declines, except a Web response hook, which must hand the response on (`passFirst`). An
690
+ * `undefined` slot marks an unpaired Node twin, position-aligned with its Web list, and stays
691
+ * `undefined` - wrapping it would fabricate a twin that never existed. */
692
+ const scopeHooks = <H extends AdoptedHook>(
693
+ hooks: readonly H[],
694
+ inScope: HookScope | undefined,
695
+ reqAt: 0 | 1,
696
+ passFirst?: true,
697
+ ): readonly H[] =>
698
+ inScope === undefined
699
+ ? hooks
700
+ : hooks.map((hook) =>
701
+ hook === undefined
702
+ ? hook
703
+ : (((first: never, second: never) =>
704
+ inScope(reqAt === 0 ? first : second)
705
+ ? hook(first, second)
706
+ : passFirst && first) as H),
707
+ )
708
+
709
+ /** A `group()` prefix: `/`-led segments of RFC 3986 `pchar` minus `%` and `:`, none of them `.` or
710
+ * `..`. That is text the router compares byte-for-byte as static, so a prefix can never smuggle a
711
+ * param, a wildcard, or an escape, and never spells a path (dot or empty segment, trailing slash)
712
+ * that no normalized client URL reaches. Segments are delimited by a `/` the class excludes, so the
713
+ * match is linear. */
714
+ const GROUP_PREFIX = /^(?:\/(?!\.\.?(?:\/|$))[\w.~!$&'()+,;=@-]+)+$/
715
+
716
+ /** The order-scoped chain a group scope inherits - copied, so what the group adds stays its own. */
717
+ const GROUP_CHAIN = [
718
+ "derives",
719
+ "authStages",
720
+ "beforeHandleHooks",
721
+ "afterHandleHooks",
722
+ "onErrorHooks",
723
+ "aroundHooks",
724
+ "activeAssurance",
725
+ // Inherited so a group route computes `authenticated` (idempotency principal scoping) exactly as a
726
+ // parent route would; only the evidence the group adds is folded at adoption.
727
+ "globalAssurance",
728
+ "capabilityInterceptors",
729
+ "capabilityObservers",
730
+ ] as const
731
+
732
+ /** Installed runtimes: a group scope inherits them, and adoption hoists a source's into a server that
733
+ * has none, so composing servers cannot silently disable a safety lane. */
734
+ const SERVER_RUNTIMES = [
735
+ "responseContractRuntime",
736
+ "idempotencyRuntime",
737
+ "effectLedgerRuntime",
738
+ "mcpRuntime",
739
+ "nodeOutcomeRuntime",
740
+ "sseRuntime",
741
+ "wsRuntime",
742
+ ] as const
743
+
539
744
  // Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
540
745
  const IDENTITY_RESPONSE = (response: Response | ResponseResult): Response => webResponseOf(response)
541
746
  const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
@@ -661,6 +866,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
661
866
  /** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
662
867
  private readonly wsRouter: Router<WsEntry>
663
868
  private wsRouteCount: number
869
+ /** `wsChanges` when `wsMountsLive` was last worked out; -1 until then. */
870
+ private wsMountsCheckedAt = -1
871
+ /** Whether an app mounted here takes WebSocket upgrades, as of `wsMountsCheckedAt`. */
872
+ private wsMountsLive = false
664
873
  /** In-process pub/sub backing `ws.subscribe(topic)` + `app.publish(topic, data)` (single-instance).
665
874
  * Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime - `undefined` until then, so a
666
875
  * no-WebSocket app never constructs (or bundles) it. */
@@ -670,7 +879,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
670
879
  * rather than delegate to Bun's native `server.publish`. */
671
880
  private wsHasValidatedSend = false
672
881
  /** Bun's native topic broadcast, bound by `listen()` in native-pubsub mode; `app.publish` uses it
673
- * instead of the JS registry. `undefined` off Bun, before `listen()`, or with a validated-send route. */
882
+ * instead of the JS registry. `undefined` off Bun, before `listen()`, with a validated-send route,
883
+ * or when a mounted app takes upgrades. */
674
884
  private nativePublish:
675
885
  | ((topic: string, data: string | ArrayBufferView | ArrayBuffer) => void)
676
886
  | undefined
@@ -691,7 +901,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
691
901
  private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
692
902
  private readonly gracefulSignals: boolean
693
903
  private readonly stopHooks: StopHook[]
694
- private readonly fetchMounts: FetchMount[]
904
+ private readonly fetchMounts: FetchMount<EnvOf<Ctx>>[]
905
+ private mountOrder: number
906
+ /** Number of composed mounts that must run before the parent route table. Keeps legacy mounts off the hot path. */
907
+ private preRouteMountCount: number
695
908
  /** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
696
909
  private readonly capacityGate: AdmissionController | undefined
697
910
  private readonly onCapabilityUse: ((event: CapabilityUseEvent) => void) | undefined
@@ -705,6 +918,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
705
918
  private idempotencyRuntime: IdempotencyRuntime | undefined
706
919
  /** Installed opt-in runtime for `.tool()`/`.resource()`/`.prompt()`; `undefined` until `.use(mcp())`. */
707
920
  private mcpRuntime: McpRuntime | undefined
921
+ /** Installed answer for a request no route matched; `undefined` (the plain 404) until `.use(notFound())`. */
922
+ private notFoundLane: NotFoundLane | undefined
708
923
  /** Installed Node-direct renderer for direct `resolveNode()` callers; `undefined` until `.use(nodeDirect())`. */
709
924
  private nodeOutcomeRuntime: NodeOutcomeRuntime | undefined
710
925
  /** Installed streaming runtime for `.sse()` routes; `undefined` until `.use(streaming())`. */
@@ -719,6 +934,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
719
934
  private bunServer: RunningServer | undefined
720
935
  private sealed: boolean
721
936
  private readonly derives: RawDerive[]
937
+ /** Dedicated authentication stages captured by subsequent routes. */
938
+ private readonly authStages: RawAuthStage[]
722
939
  private readonly decorations: Record<string, unknown>
723
940
  private readonly beforeHandleHooks: RawBeforeHandle[]
724
941
  private readonly afterHandleHooks: RawAfterHandle[]
@@ -728,10 +945,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
728
945
  private readonly onNodeRequestHooks: Array<NodeRequestHook | undefined>
729
946
  /** Registration-time eligibility for the Node request twin lane. */
730
947
  private nodeRequestHooksComplete: boolean
948
+ /** True when every request hook is safe to skip on a Bun native route. */
949
+ private bunNativeRequestHooksSafe: boolean
731
950
  private readonly onResponseHooks: RawOnResponse[]
732
951
  private readonly onNodeResponseHooks: Array<NodeResponseHook | undefined>
733
952
  /** Registration-time eligibility for the Node response twin lane. */
734
953
  private nodeResponseHooksComplete: boolean
954
+ /** True when every response hook only mutates headers and can run after a Bun fused response. */
955
+ private bunNativeResponseHeadersOnly: boolean
735
956
  /**
736
957
  * True once a bundle registers a RAW `onNodeResponse` twin - one handed the outcome's header
737
958
  * record itself rather than the case-normalizing view. Their names are contractually the wire
@@ -747,6 +968,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
747
968
  * `onResponse` hook would have disabled.
748
969
  */
749
970
  private staticResponseHeaders: StaticResponseHeaders | undefined
971
+ /** A group's view of the static headers its enclosing scopes declared before it was created. */
972
+ private declare inheritedStatics: Readonly<Record<string, string>> | undefined
973
+ /** A group's view of the plugins its enclosing scopes applied before it was created; unset while
974
+ * a plugin applies, so its own dependency `use()` shares the parent's copy. */
975
+ private declare inheritedPlugins: ReadonlyMap<string, unknown> | undefined
750
976
  /** `wrapResponse` for the Web lanes: identity until static headers exist to fold into the
751
977
  * framework's own error/404/timeout renders, which are built outside the header init. */
752
978
  private wrapWebResponse: (response: Response | ResponseResult) => Response
@@ -761,8 +987,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
761
987
  private readonly responseSources: WeakMap<object, Request>
762
988
  /** Memoized NodeRequestContext per plain-`Request` source - see {@link nodeRequestContextOf}. */
763
989
  private readonly nodeContexts: WeakMap<object, NodeRequestContext>
764
- /** Names of plugins/middleware already applied via `use` - for idempotent dedupe. */
765
- private readonly appliedPlugins: Set<string>
990
+ /** Plugins/middleware already applied via `use`, by name - for idempotent dedupe. */
991
+ private readonly appliedPlugins: Map<string, unknown>
766
992
  /** Order-scoped evidence captured by routes registered after an assured plugin. */
767
993
  private readonly activeAssurance: AssuranceDeclaration[]
768
994
  /** App-wide evidence from global hooks; applies retroactively to every route. */
@@ -770,7 +996,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
770
996
  /** App-declared MCP resources / prompts (via {@link resource} / {@link prompt}), read by `nifra mcp`. */
771
997
  private readonly mcpResourceList: McpResourceDescriptor[]
772
998
  private readonly mcpPromptList: McpPromptDescriptor[]
999
+ /** Kept so a {@link group} scope is built with exactly this server's settings: fused lanes read
1000
+ * several of them at request time, so a group route must never run under different ones. */
1001
+ private readonly options: ServerOptions
1002
+ /** Static prefix joined onto every route this server registers; non-empty only on a group scope. */
1003
+ private routePrefix: string
773
1004
  constructor(options: ServerOptions = {}) {
1005
+ this.options = options
1006
+ this.routePrefix = ""
774
1007
  this.catalog = new RouteCatalog()
775
1008
  this.wsRouter = new Router<WsEntry>()
776
1009
  this.wsRouteCount = 0
@@ -804,6 +1037,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
804
1037
  this.gracefulSignals = options.gracefulSignals ?? false
805
1038
  this.stopHooks = []
806
1039
  this.fetchMounts = []
1040
+ this.mountOrder = 0
1041
+ this.preRouteMountCount = 0
807
1042
  this.capacityGate = options.admission
808
1043
  this.onCapabilityUse = options.onCapabilityUse
809
1044
  this.capabilityInterceptors = []
@@ -826,6 +1061,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
826
1061
  beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined)
827
1062
  }
828
1063
  this.derives = []
1064
+ this.authStages = []
829
1065
  this.decorations = {}
830
1066
  this.beforeHandleHooks = []
831
1067
  this.afterHandleHooks = []
@@ -834,9 +1070,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
834
1070
  this.onRequestHooks = []
835
1071
  this.onNodeRequestHooks = []
836
1072
  this.nodeRequestHooksComplete = true
1073
+ this.bunNativeRequestHooksSafe = true
837
1074
  this.onResponseHooks = []
838
1075
  this.onNodeResponseHooks = []
839
1076
  this.nodeResponseHooksComplete = true
1077
+ this.bunNativeResponseHeadersOnly = true
840
1078
  this.hasRawNodeResponseHook = false
841
1079
  this.onResponseFinalizedHooks = []
842
1080
  this.staticResponseHeaders = undefined
@@ -847,7 +1085,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
847
1085
  this.responseRequests = new WeakMap()
848
1086
  this.responseSources = new WeakMap()
849
1087
  this.nodeContexts = new WeakMap()
850
- this.appliedPlugins = new Set()
1088
+ this.appliedPlugins = new Map()
851
1089
  this.activeAssurance = []
852
1090
  this.globalAssurance = []
853
1091
  this.mcpResourceList = []
@@ -858,7 +1096,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
858
1096
  if (this.sealed) {
859
1097
  throw new FrameworkError(
860
1098
  "SERVER_SEALED",
861
- `server configuration is sealed after listen(); call ${operation} before listen()`,
1099
+ `server configuration is sealed after listen() (a group once its builder returns); call ${operation} before`,
862
1100
  )
863
1101
  }
864
1102
  }
@@ -883,13 +1121,76 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
883
1121
  ): this {
884
1122
  this.assertConfigurable("mountFetch()")
885
1123
  if (typeof handler !== "function") throw new TypeError("mountFetch handler must be a function")
886
- const mount: FetchMount = {
1124
+ const mount: FetchMount<EnvOf<Ctx>> = {
887
1125
  path: normalizeMountPrefix(path),
888
- handler: handler as MountedFetchHandler,
1126
+ handler: handler as MountedFetchHandler<EnvOf<Ctx>>,
1127
+ stripPrefix: options.stripPrefix === true,
1128
+ priority: mountPriorityOf(options.priority),
1129
+ fallbackOn404: options.fallbackOn === 404,
1130
+ beforeRoutes: false,
1131
+ order: this.mountOrder++,
1132
+ opaque: options.opaque,
1133
+ }
1134
+ this.fetchMounts.push(mount)
1135
+ this.fetchMounts.sort(compareMounts)
1136
+ return this
1137
+ }
1138
+
1139
+ /**
1140
+ * Compose a child fetch app under a literal path prefix.
1141
+ *
1142
+ * Composed mounts run after the app-global request phase and before the parent's route table. This
1143
+ * makes them safe to use in an SSR shell whose `/*` page route would otherwise swallow an API mount.
1144
+ * If the child exposes `resolveWebSocketUpgrade`, the same mount also participates in adapter-owned
1145
+ * WebSocket upgrades; the serving adapter still upgrades its original socket, while the child sees
1146
+ * the mounted request path (stripped when requested).
1147
+ *
1148
+ * `fallbackOn: 404` is deliberately limited to GET/HEAD/OPTIONS at dispatch time. Those methods are
1149
+ * replayable without buffering an untrusted request body; POST/PUT/PATCH and streaming requests
1150
+ * never get retried after a child has had a chance to consume them.
1151
+ */
1152
+ mount(options: MountOptions<EnvOf<Ctx>>): this {
1153
+ this.assertConfigurable("mount()")
1154
+ if (options === null || typeof options !== "object") {
1155
+ throw new TypeError("mount() requires { path, app }")
1156
+ }
1157
+ const app = options.app as MountableApp<EnvOf<Ctx>> & Record<symbol, unknown>
1158
+ if (app === null || typeof app !== "object") {
1159
+ throw new TypeError("mount() app must be an object with fetch()")
1160
+ }
1161
+ if (typeof app.fetch !== "function") {
1162
+ throw new TypeError("mount() app.fetch must be a function")
1163
+ }
1164
+ const resolver =
1165
+ typeof app.resolveWebSocketUpgrade === "function"
1166
+ ? app.resolveWebSocketUpgrade.bind(app)
1167
+ : undefined
1168
+ // Bun needs a websocket callback table in the parent process. A composed Nifra app names the
1169
+ // runtime its upgrades need through this internal seam, which `listen()` asks (see
1170
+ // `[GET_WS_RUNTIME]`); a resolver without it names none. Adapters that wire standard sockets use
1171
+ // the resolver outcome's own `attach`.
1172
+ const getRuntime = app[GET_WS_RUNTIME] ?? app[NIFRA_BACKEND_WS_RUNTIME]
1173
+ const mount: FetchMount<EnvOf<Ctx>> = {
1174
+ path: normalizeMountPrefix(options.path),
1175
+ handler: app.fetch.bind(app) as MountedFetchHandler<EnvOf<Ctx>>,
1176
+ ...(resolver === undefined
1177
+ ? {}
1178
+ : {
1179
+ resolveWebSocketUpgrade: resolver,
1180
+ wsRuntime: typeof getRuntime === "function" ? getRuntime.bind(app) : () => null,
1181
+ }),
889
1182
  stripPrefix: options.stripPrefix === true,
1183
+ priority: mountPriorityOf(options.priority),
1184
+ fallbackOn404: options.fallbackOn === 404,
1185
+ beforeRoutes: true,
1186
+ order: this.mountOrder++,
1187
+ opaque: options.opaque,
1188
+ app,
890
1189
  }
891
1190
  this.fetchMounts.push(mount)
892
- this.fetchMounts.sort((a, b) => b.path.length - a.path.length)
1191
+ this.fetchMounts.sort(compareMounts)
1192
+ this.preRouteMountCount += 1
1193
+ wsChanges += 1
893
1194
  return this
894
1195
  }
895
1196
 
@@ -927,6 +1228,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
927
1228
  * by none before; one added after the last route reaches nothing
928
1229
  * (see {@link ServerOptions.unusedScopedHooks}).
929
1230
  */
1231
+ /**
1232
+ * Register a dedicated authentication stage for subsequent routes.
1233
+ *
1234
+ * Unlike `derive`, this stage receives a body-blind authentication capsule and is compiled before
1235
+ * route validation. Keeping it separate prevents an unrelated derive from accidentally becoming a
1236
+ * security gate and lets the route compiler retain a protected fast lane.
1237
+ */
1238
+ authenticate<Principal>(
1239
+ stage: AuthenticationStage<Principal, EnvOf<Ctx>>,
1240
+ ): Server<R, Ctx & { readonly principal: Principal }, HookOutput> {
1241
+ this.assertConfigurable("authenticate()")
1242
+ if (stage === null || typeof stage !== "object") {
1243
+ throw new TypeError("authenticate() requires an authentication stage")
1244
+ }
1245
+ if (typeof stage.id !== "string" || stage.id.trim() === "") {
1246
+ throw new TypeError("authenticate() stage id must be a non-empty string")
1247
+ }
1248
+ if (typeof stage.run !== "function") {
1249
+ throw new TypeError("authenticate() stage run must be a function")
1250
+ }
1251
+ if (stage.mode !== undefined && stage.mode !== "sync" && stage.mode !== "async") {
1252
+ throw new TypeError('authenticate() stage mode must be "sync" or "async"')
1253
+ }
1254
+ this.authStages.push(stage as unknown as RawAuthStage)
1255
+ return this as unknown as Server<R, Ctx & { readonly principal: Principal }, HookOutput>
1256
+ }
1257
+
930
1258
  decorate<const K extends string, V>(key: K, value: V): Server<R, Ctx & Record<K, V>, HookOutput> {
931
1259
  this.assertConfigurable("decorate()")
932
1260
  this.decorations[key] = value
@@ -946,6 +1274,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
946
1274
  this.onRequestHooks.push(fn as RawOnRequest)
947
1275
  this.onNodeRequestHooks.push(undefined)
948
1276
  this.nodeRequestHooksComplete = false
1277
+ this.bunNativeRequestHooksSafe = false
949
1278
  return this
950
1279
  }
951
1280
 
@@ -1050,6 +1379,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1050
1379
  this.onResponseHooks.push(fn)
1051
1380
  this.onNodeResponseHooks.push(undefined)
1052
1381
  this.nodeResponseHooksComplete = false
1382
+ this.bunNativeResponseHeadersOnly = false
1053
1383
  return this
1054
1384
  }
1055
1385
 
@@ -1067,7 +1397,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1067
1397
  * app.responseHeaders({ "x-frame-options": "DENY", "referrer-policy": "no-referrer" })
1068
1398
  *
1069
1399
  * They are DEFAULTS: a value the request itself produced (`c.set.headers`, or a response hook)
1070
- * wins, whatever casing it used. Names are lowercased once here; a non-string value, an invalid
1400
+ * wins, whatever casing it used. In a group, a name the group declares replaces the value its
1401
+ * enclosing scopes declared before the group, on the group's routes. Names are lowercased once here; a non-string value, an invalid
1071
1402
  * name, `__proto__`, or a name the render owns (`content-type`, `content-length`,
1072
1403
  * `transfer-encoding`, `set-cookie`) throws a `TypeError` at wire-up.
1073
1404
  *
@@ -1101,7 +1432,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1101
1432
  // serialized-body marker survives for later `onResponseBody`/`onResponseRaw` observers and the
1102
1433
  // Node writer; reconstructing a `Response` here would strip that marker and reclassify the Node
1103
1434
  // outcome from `json` to a generic `response`. A guarded foreign response takes its clone path.
1104
- const statics = buildStaticResponseHeaders(record)
1435
+ const statics = buildStaticResponseHeaders(record, this.inheritedStatics)
1105
1436
  this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics))
1106
1437
  this.onNodeResponseHooks.push(undefined)
1107
1438
  this.nodeResponseHooksComplete = false
@@ -1111,7 +1442,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1111
1442
  this.staticResponseHeaders === undefined
1112
1443
  ? record
1113
1444
  : { ...this.staticResponseHeaders.record, ...record }
1114
- this.staticResponseHeaders = buildStaticResponseHeaders(merged)
1445
+ this.staticResponseHeaders = buildStaticResponseHeaders(merged, this.inheritedStatics)
1115
1446
  const statics = this.staticResponseHeaders
1116
1447
  this.wrapWebResponse = (response) =>
1117
1448
  applyStaticResponseHeaders(webResponseOf(response), statics)
@@ -1150,6 +1481,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1150
1481
  return this
1151
1482
  }
1152
1483
 
1484
+ /**
1485
+ * Apply `all()` or `method()` from `@nifrajs/core/methods`: one handler registered under several
1486
+ * methods, or under a method outside the standard seven. The standard methods join the registry.
1487
+ *
1488
+ * Declared first on purpose. The plugin's handler is typed against this app's context, which
1489
+ * TypeScript reads from the first overload it tries.
1490
+ */
1491
+ use<M extends string, Path extends string, S extends RouteSchema, Output>(
1492
+ plugin: MethodRoutesPlugin<M, Path, S, Output, Ctx>,
1493
+ ): Server<WithMethodRoutes<R, M, Path, S, Output, HookOutput>, Ctx, HookOutput>
1153
1494
  /** Enable the opt-in portable response observer methods. */
1154
1495
  use(plugin: ResponseObserverPlugin): this & ResponseObserverMethods
1155
1496
  /**
@@ -1174,7 +1515,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1174
1515
  * Apply a **plugin function** - `(app) => app`, typically built with {@link definePlugin}. It's
1175
1516
  * called with `this` and its result is returned, so an inline plugin's `derive`/`decorate` thread
1176
1517
  * the added context to handlers defined after `use` (the overload is generic over the concrete
1177
- * `this`). A named plugin already applied is skipped (idempotent dedupe).
1518
+ * `this`). A named plugin already applied is skipped (idempotent dedupe), however it was configured,
1519
+ * so a plugin meant to stack - a second, stricter guard - needs a name of its own per instance.
1178
1520
  *
1179
1521
  * If the plugin's return type is unpinned - `Server<any, any>`, as a hand-rolled `(app) => app` or a
1180
1522
  * `NifraPlugin<AnyServer, AnyServer>` infers (e.g. an auth plugin whose own types collapsed) - this
@@ -1195,22 +1537,43 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1195
1537
  */
1196
1538
  use<M extends Middleware>(mw: M): Server<R, Ctx, HookOutput | MiddlewareOutputOf<M>>
1197
1539
  use(mw: Middleware): this
1198
- use(arg: Middleware | ((app: this) => AnyServer)): AnyServer {
1540
+ use(
1541
+ input:
1542
+ | Middleware
1543
+ | ((app: this) => AnyServer)
1544
+ | MethodRoutesPlugin<string, string, RouteSchema, unknown, Ctx>,
1545
+ ): AnyServer {
1546
+ // A method-routes plugin is an ordinary plugin function; only its type is opaque.
1547
+ const arg = input as Middleware | ((app: this) => AnyServer)
1199
1548
  this.assertConfigurable("use()")
1200
- if (typeof arg === "function") {
1201
- const name = (arg as { pluginName?: string }).pluginName
1202
- if (name !== undefined) {
1203
- if (this.appliedPlugins.has(name)) return this // idempotent: already applied
1204
- this.appliedPlugins.add(name)
1549
+ const name = typeof arg === "function" ? (arg as { pluginName?: string }).pluginName : arg.name
1550
+ if (name !== undefined) {
1551
+ if (this.appliedPlugins.has(name)) {
1552
+ const parent = this.inheritedPlugins?.get(name)
1553
+ // A group's builder passing another instance of a plugin its parent applied would have it
1554
+ // dropped, configuration and all. A plugin's own dependency use() shares the parent's copy.
1555
+ if (parent !== undefined && parent !== arg) {
1556
+ throw new RouteConfigError(
1557
+ "PLUGIN_RECONFIGURED",
1558
+ `use() of ${JSON.stringify(name)} in a group: its parent applied another instance`,
1559
+ )
1560
+ }
1561
+ return this // idempotent: already applied
1205
1562
  }
1563
+ this.appliedPlugins.set(name, arg)
1564
+ }
1565
+ if (typeof arg === "function") {
1206
1566
  const evidence = assuranceDeclarationsOf(arg)
1207
1567
  const pluginOnly = evidence.filter((item) => item.scope === "plugin")
1208
1568
  this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"))
1209
1569
  this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"))
1210
1570
  this.activeAssurance.push(...pluginOnly)
1571
+ const inherited = this.inheritedPlugins
1572
+ this.inheritedPlugins = undefined
1211
1573
  try {
1212
1574
  return arg(this)
1213
1575
  } finally {
1576
+ this.inheritedPlugins = inherited
1214
1577
  // Remove only this plugin's temporary evidence. Nested assured plugins may deliberately leave
1215
1578
  // subsequent evidence active, so truncating the whole array would lose real ordering semantics.
1216
1579
  for (const item of pluginOnly) {
@@ -1219,10 +1582,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1219
1582
  }
1220
1583
  }
1221
1584
  }
1222
- if (arg.name !== undefined) {
1223
- if (this.appliedPlugins.has(arg.name)) return this
1224
- this.appliedPlugins.add(arg.name)
1225
- }
1226
1585
  const evidence = assuranceDeclarationsOf(arg)
1227
1586
  if (evidence.some((item) => item.scope === "plugin")) {
1228
1587
  throw new Error('route assurance: scope "plugin" may only annotate a plugin function')
@@ -1245,6 +1604,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1245
1604
  this.onRequestHooks.push(arg.onRequest as RawOnRequest)
1246
1605
  this.onNodeRequestHooks.push(arg.onNodeRequest)
1247
1606
  if (arg.onNodeRequest === undefined) this.nodeRequestHooksComplete = false
1607
+ if (!(arg as unknown as Record<symbol, unknown>)[BUN_NATIVE_REQUEST_SAFE]) {
1608
+ this.bunNativeRequestHooksSafe = false
1609
+ }
1248
1610
  } else if (arg.onNodeRequest !== undefined) {
1249
1611
  throw new TypeError("onNodeRequest() requires a paired onRequest() hook")
1250
1612
  }
@@ -1257,16 +1619,26 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1257
1619
  this.onNodeResponseHooks.push(arg.onNodeResponse)
1258
1620
  if (arg.onNodeResponse === undefined) this.nodeResponseHooksComplete = false
1259
1621
  else this.hasRawNodeResponseHook = true
1622
+ this.bunNativeResponseHeadersOnly = false
1260
1623
  } else if (arg.onNodeResponse !== undefined) {
1261
1624
  throw new TypeError("onNodeResponse() requires a paired onResponse() hook")
1262
1625
  }
1263
1626
  // Before the bundle's own hooks: a bundle declaring both means its static values are the
1264
1627
  // defaults its hook may then override, which is the order a single bundle reads in.
1265
1628
  if (arg.responseHeaders !== undefined) this.responseHeaders(arg.responseHeaders)
1266
- if (arg.onResponseHeaders !== undefined)
1267
- this.responseObserverMethods().onResponseHeaders(arg.onResponseHeaders)
1268
- if (arg.onResponseBody !== undefined)
1269
- this.responseObserverMethods().onResponseBody(arg.onResponseBody)
1629
+ if (arg.onResponseHeaders !== undefined) {
1630
+ this.responseObserverMethods().onResponseHeaders(
1631
+ arg.onResponseHeaders,
1632
+ arg.onNodeResponseHeaders,
1633
+ )
1634
+ } else if (arg.onNodeResponseHeaders !== undefined) {
1635
+ throw new TypeError("onNodeResponseHeaders() requires a paired onResponseHeaders() hook")
1636
+ }
1637
+ if (arg.onResponseBody !== undefined) {
1638
+ this.responseObserverMethods().onResponseBody(arg.onResponseBody, arg.onNodeResponseBody)
1639
+ } else if (arg.onNodeResponseBody !== undefined) {
1640
+ throw new TypeError("onNodeResponseBody() requires a paired onResponseBody() hook")
1641
+ }
1270
1642
  if (arg.onResponseRaw !== undefined)
1271
1643
  this.responseObserverMethods().onResponseRaw(arg.onResponseRaw)
1272
1644
  if (arg.onResponseFinalized !== undefined) this.onResponseFinalized(arg.onResponseFinalized)
@@ -1595,10 +1967,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1595
1967
  this.topics ??= runtime.createTopics()
1596
1968
  // A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
1597
1969
  // already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
1598
- this.wsRouter.add("GET", path, {
1599
- handler: runtime.wrapHandler(handler as WebSocketHandler),
1600
- })
1970
+ const entry = { handler: runtime.wrapHandler(handler as WebSocketHandler, this.protoPoisoning) }
1971
+ for (const form of expandOptionalParams(path)) this.wsRouter.add("GET", form, entry)
1601
1972
  this.wsRouteCount += 1
1973
+ wsChanges += 1
1602
1974
  return this as never
1603
1975
  }
1604
1976
 
@@ -1645,34 +2017,54 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1645
2017
  * is the "compiled", order-scoped per-route chain.
1646
2018
  */
1647
2019
  register(
1648
- method: Method,
2020
+ method: string,
1649
2021
  path: string,
1650
2022
  schema: RouteSchema | undefined,
1651
2023
  handler: (context: never) => unknown,
1652
2024
  ): void {
1653
- this.assertConfigurable("route registration")
1654
- this.catalog.add(this.prepareRoute(method, path, schema, handler))
2025
+ this.registerBatch([{ method, path, schema, handler }])
1655
2026
  }
1656
2027
 
1657
2028
  /** Register a contract/group route batch atomically. Every route captures the same current chain it
1658
- * would capture through {@link register}; no route becomes visible unless the full batch validates. */
2029
+ * would capture through {@link register}; no route becomes visible unless the full batch validates.
2030
+ *
2031
+ * A path ending in optional parameters (`/users/:id?`) is one catalog route per concrete pattern,
2032
+ * so reflection, OpenAPI, evidence and the typed client all see ordinary routes. */
1659
2033
  registerBatch(
1660
2034
  routes: readonly {
1661
- readonly method: Method
2035
+ readonly method: string
1662
2036
  readonly path: string
1663
2037
  readonly schema: RouteSchema | undefined
1664
2038
  readonly handler: (context: never) => unknown
1665
2039
  }[],
1666
2040
  ): void {
1667
2041
  this.assertConfigurable("route registration")
1668
- const staged = routes.map(({ method, path, schema, handler }) =>
1669
- this.prepareRoute(method, path, schema, handler),
1670
- )
2042
+ const staged: CatalogRoute[] = []
2043
+ for (const { method, path, schema, handler } of routes) {
2044
+ for (const form of expandOptionalParams(this.prefixed(path))) {
2045
+ staged.push(this.prepareRoute(method, form, schema, handler))
2046
+ }
2047
+ }
1671
2048
  this.catalog.addBatch(staged)
1672
2049
  }
1673
2050
 
2051
+ /** Join a group scope's prefix BEFORE compilation, so the pattern, the reflected descriptor, the
2052
+ * capability guard, the ledger record, and assurance path matching all see the one served path. */
2053
+ private prefixed(path: string): string {
2054
+ const prefix = this.routePrefix
2055
+ if (prefix === "") return path
2056
+ if (path === "/") return prefix
2057
+ // Checked here, not left to the pattern compiler: `"/api" + "users"` is a valid-looking path
2058
+ // (`/apiusers`) that would silently escape the group instead of failing.
2059
+ if (path[0] === "/") return prefix + path
2060
+ throw new RouteConfigError(
2061
+ "INVALID_PATH",
2062
+ `group(${JSON.stringify(prefix)}) route path must start with "/": ${JSON.stringify(path)}`,
2063
+ )
2064
+ }
2065
+
1674
2066
  private prepareRoute(
1675
- method: Method,
2067
+ method: string,
1676
2068
  path: string,
1677
2069
  schema: RouteSchema | undefined,
1678
2070
  handler: (context: never) => unknown,
@@ -1690,6 +2082,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1690
2082
  idempotencyRuntime: this.idempotencyRuntime,
1691
2083
  responseContractRuntime: this.responseContractRuntime,
1692
2084
  derives: this.derives,
2085
+ authStages: this.authStages,
1693
2086
  beforeHandleHooks: this.beforeHandleHooks,
1694
2087
  afterHandleHooks: this.afterHandleHooks,
1695
2088
  onErrorHooks: this.onErrorHooks,
@@ -1713,15 +2106,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1713
2106
  lanes,
1714
2107
  routeAssurance,
1715
2108
  } = compiled
2109
+ if (idempotent !== undefined && this.idempotencyRuntime !== undefined) {
2110
+ handler = this.idempotencyRuntime.wrapHandler(handler)
2111
+ }
1716
2112
  const { bare, fusedQuery, fusedBody } = lanes
1717
- const fusedBodyRunner = fusedBody
1718
- ? this.buildFusedBodyRunner(
1719
- handler as unknown as InternalHandler,
1720
- schema?.body as StandardSchemaV1,
1721
- hasDecorations ? routeDecorations : undefined,
1722
- bodyLimit ?? UNLIMITED_BODY_BYTES,
1723
- )
1724
- : undefined
1725
2113
  // Fused lifecycle lanes: derive + before (with or without an after), and body + the same shape.
1726
2114
  // The lane selectors in `selectRouteLanes` are exhaustive about the lifecycleHookLane /
1727
2115
  // body-derive-before-after eligibility, so we only need to check the lane and the hook counts.
@@ -1729,6 +2117,46 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1729
2117
  const isFusedLifecycle = lifecycleHookLane !== undefined && this.derives.length === 1
1730
2118
  const isFusedBodyLifecycle =
1731
2119
  lanes.fusedLane === "body-derive-before" || lanes.fusedLane === "body-derive-before-after"
2120
+ // The generic runners below are the Node-direct twins of the fused Web lanes built further
2121
+ // down: same single-frame lifecycle, finalizing through the caller's own `finalize` instead of
2122
+ // building a `Response`, so the `!webFast` dispatcher prefers them over the generic program
2123
+ // exactly as it prefers the body-only runner. Only the original body-only lane may feed the Web
2124
+ // wrapper; every other runner here preserves its derive/before/after stages.
2125
+ const fusedBodyRunner = fusedBody
2126
+ ? this.buildFusedBodyRunner(
2127
+ handler as unknown as InternalHandler,
2128
+ schema?.body as StandardSchemaV1,
2129
+ hasDecorations ? routeDecorations : undefined,
2130
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
2131
+ )
2132
+ : isFusedBodyLifecycle
2133
+ ? this.buildFusedBodyDeriveBeforeAfterRunner(
2134
+ handler as unknown as InternalHandler,
2135
+ this.derives[0]!,
2136
+ this.beforeHandleHooks[0]!,
2137
+ this.afterHandleHooks[0],
2138
+ schema?.body as StandardSchemaV1,
2139
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
2140
+ )
2141
+ : isFusedLifecycle && lifecycleHookLane === "derive-before"
2142
+ ? this.buildFusedDeriveBeforeRunner(
2143
+ handler as unknown as InternalHandler,
2144
+ this.derives[0]!,
2145
+ this.beforeHandleHooks[0]!,
2146
+ undefined,
2147
+ schema?.query,
2148
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
2149
+ )
2150
+ : isFusedLifecycle && lifecycleHookLane === "derive-before-after"
2151
+ ? this.buildFusedDeriveBeforeRunner(
2152
+ handler as unknown as InternalHandler,
2153
+ this.derives[0]!,
2154
+ this.beforeHandleHooks[0]!,
2155
+ this.afterHandleHooks[0],
2156
+ schema?.query,
2157
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
2158
+ )
2159
+ : undefined
1732
2160
  const fusedWeb =
1733
2161
  bare && this.aroundHooks.length === 0
1734
2162
  ? this.buildFusedWeb(
@@ -1777,6 +2205,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1777
2205
  schema,
1778
2206
  handler: handler as unknown as InternalHandler,
1779
2207
  derives: this.derives,
2208
+ authStages: compiled.authStages,
1780
2209
  beforeHandle: this.beforeHandleHooks,
1781
2210
  afterHandle: this.afterHandleHooks,
1782
2211
  onError: this.onErrorHooks,
@@ -1805,6 +2234,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1805
2234
  ledgered,
1806
2235
  responseContract,
1807
2236
  derives: [...this.derives],
2237
+ authStages: [...compiled.authStages],
1808
2238
  decorations: routeDecorations,
1809
2239
  hasDecorations,
1810
2240
  beforeHandle: [...this.beforeHandleHooks],
@@ -1819,6 +2249,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1819
2249
  method,
1820
2250
  path,
1821
2251
  schema,
2252
+ ...(responseContract === undefined
2253
+ ? {}
2254
+ : { responseContract: responseContract.runtime.mode }),
1822
2255
  ...(capabilities.length > 0 ? { capabilities } : {}),
1823
2256
  ...(schema?.family === true ? { family: true } : {}),
1824
2257
  }
@@ -1867,10 +2300,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1867
2300
  this.assertConfigurable("responseObserver()")
1868
2301
  const methods = runtime.install({
1869
2302
  assertConfigurable: (operation) => this.assertConfigurable(operation),
1870
- addResponseHook: (web, node) => {
2303
+ addResponseHook: (web, node, headerOnly = false) => {
1871
2304
  this.onResponseHooks.push(web)
1872
2305
  this.onNodeResponseHooks.push(node)
1873
2306
  if (node === undefined) this.nodeResponseHooksComplete = false
2307
+ if (!headerOnly) this.bunNativeResponseHeadersOnly = false
1874
2308
  },
1875
2309
  enableResponseBodyTagging: () => {
1876
2310
  if (this.responseBodyTag === undefined) {
@@ -1897,6 +2331,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1897
2331
  this.mcpRuntime = runtime
1898
2332
  }
1899
2333
 
2334
+ /** @internal Symbol-keyed install seam for the `notFound()` plugin. Off the public typed surface. */
2335
+ [INSTALL_NOT_FOUND](lane: NotFoundLane): void {
2336
+ this.assertConfigurable("notFound()")
2337
+ this.notFoundLane = lane
2338
+ }
2339
+
1900
2340
  /** @internal Symbol-keyed install seam for the `nodeDirect()` plugin. Off the public typed surface. */
1901
2341
  [INSTALL_NODE_DIRECT](runtime: NodeOutcomeRuntime): void {
1902
2342
  this.assertConfigurable("nodeDirect()")
@@ -1915,6 +2355,41 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1915
2355
  this.wsRuntime = runtime
1916
2356
  }
1917
2357
 
2358
+ /**
2359
+ * @internal The runtime Bun serves this app's WebSocket upgrades with, asked by `listen()` and by a
2360
+ * parent that mounts this app: `undefined` while nothing here can take one, `null` when something
2361
+ * can but no runtime is at hand. A mounted Nifra app answers through this same seam, so mounting an
2362
+ * app without a WebSocket route needs no runtime; a resolver without the seam cannot say, so it
2363
+ * counts. `seen` ends a mount cycle (an app mounted under itself to alias a prefix).
2364
+ */
2365
+ [GET_WS_RUNTIME](seen?: Set<unknown>): WsRuntime | null | undefined {
2366
+ return this.wsRouteCount > 0 ? this.wsRuntime : this.mountedWsRuntime(seen)
2367
+ }
2368
+
2369
+ /** The `[GET_WS_RUNTIME]` answer for the apps mounted here alone, whatever this app's own routes. */
2370
+ private mountedWsRuntime(seen: Set<unknown> = new Set()): WsRuntime | null | undefined {
2371
+ if (seen.has(this)) return
2372
+ seen.add(this)
2373
+ let found: WsRuntime | null | undefined
2374
+ for (const mount of this.fetchMounts) {
2375
+ const runtime = mount.wsRuntime?.(seen)
2376
+ if (runtime === undefined) continue
2377
+ found = this.wsRuntime ?? runtime
2378
+ if (found) return found
2379
+ }
2380
+ return found
2381
+ }
2382
+
2383
+ /** Whether an app mounted here takes WebSocket upgrades. Worked out again only after a `ws()` or a
2384
+ * `mount()` somewhere, so the upgrade check on a plain request costs one comparison. */
2385
+ private mountsTakeUpgrades(): boolean {
2386
+ if (this.wsMountsCheckedAt !== wsChanges) {
2387
+ this.wsMountsLive = this.mountedWsRuntime() !== undefined
2388
+ this.wsMountsCheckedAt = wsChanges
2389
+ }
2390
+ return this.wsMountsLive
2391
+ }
2392
+
1918
2393
  /**
1919
2394
  * Merge another server's routes into this one - the composition escape hatch for large apps.
1920
2395
  *
@@ -1943,6 +2418,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1943
2418
  other: Server<R2, Ctx2, HookOutput2>,
1944
2419
  ): Server<R & R2, Ctx, HookOutput> {
1945
2420
  this.assertConfigurable("merge()")
2421
+ if (!(other instanceof Server)) {
2422
+ // A server from another copy of this package reaches this copy's private request state through
2423
+ // symbols it does not share, and would fail per request. The boot warning names the copies.
2424
+ throw new TypeError("merge() requires a server() from this copy of @nifrajs/core")
2425
+ }
2426
+ if (this.routePrefix !== "") {
2427
+ // The merged routes were compiled under their own paths; adopting them here would publish them
2428
+ // OUTSIDE the prefix while looking like part of the group.
2429
+ throw new RouteConfigError(
2430
+ "INVALID_PATH",
2431
+ "merge() inside a group cannot prefix compiled routes - merge on the parent",
2432
+ )
2433
+ }
1946
2434
  const source = other as unknown as Server<Registry, EmptyContext>
1947
2435
  if (source.wsRouteCount > 0) {
1948
2436
  throw new RouteConfigError(
@@ -1950,83 +2438,190 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1950
2438
  "merge() does not carry WebSocket routes - register .ws() routes on the parent server",
1951
2439
  )
1952
2440
  }
2441
+ this.adopt(source, undefined, 0)
2442
+ return this as unknown as Server<R & R2, Ctx, HookOutput>
2443
+ }
2444
+
2445
+ /**
2446
+ * Declare routes under a static path prefix, with the prefix in the typed client, `routes()`,
2447
+ * OpenAPI, capability events, and the effect ledger - the counterpart of Hono's `route()`,
2448
+ * Elysia's `group()`, and Fastify's `register({ prefix })`.
2449
+ *
2450
+ * app
2451
+ * .derive(session)
2452
+ * .group("/admin", (admin) =>
2453
+ * admin.beforeHandle(requireAdmin).get("/users", listUsers).get("/users/:id", getUser),
2454
+ * )
2455
+ * .get("/", home) // not covered by requireAdmin
2456
+ *
2457
+ * The builder receives a scope that INHERITS this server's route chain as it stands at the call
2458
+ * (`derive`/`decorate`/`authenticate`/`beforeHandle`/`afterHandle`/`onError`/`around`, assurance,
2459
+ * capability interceptors, installed runtimes, applied plugins), exactly as a route declared here
2460
+ * would. A plugin this server already applied is in effect for the group: the group's `use()` of
2461
+ * that same instance does nothing, and its `use()` of another instance under the same name throws a
2462
+ * `RouteConfigError` (`PLUGIN_RECONFIGURED`), because that instance could never replace the parent's.
2463
+ * A plugin the group applies still shares the parent's copy of a dependency it `use()`s itself.
2464
+ * What it adds stays in the group: its chain additions apply only to its own routes, and its
2465
+ * request/response hooks (`onRequest`, `onResponse`, `responseHeaders`, `onResponseFinalized`, and
2466
+ * middleware bundles that use them) run only for requests whose path is the prefix or below it.
2467
+ * Per-route authorization belongs in `authenticate`/`beforeHandle`, which are compiled into each
2468
+ * route; request hooks are path prefilters.
2469
+ *
2470
+ * The prefix is static text - `"/api"`, `"/api/v1"` - validated when declared: no params,
2471
+ * wildcards, empty or dot segments, percent-escapes, or trailing slash. A group's `"/"` route
2472
+ * serves the prefix itself. Groups nest (`"/api"` then `"/v1"` serves `/api/v1/...`).
2473
+ *
2474
+ * Fail closed: the builder must synchronously return the scope it was given; a collision with an
2475
+ * existing route throws `RouteConfigError` and adds none of the group's routes; `.ws()`, mounts,
2476
+ * MCP `.tool()`s, and `merge()` inside a group are refused (register them on the parent). Once the
2477
+ * builder returns, the scope is closed - a leaked reference cannot add routes later.
2478
+ */
2479
+ group<const P extends string, R2 extends Registry, Ctx2, HookOutput2>(
2480
+ prefix: P,
2481
+ build: (group: Server<EmptyRegistry, Ctx, HookOutput>) => Server<R2, Ctx2, HookOutput2>,
2482
+ ): Server<R & PrefixRegistry<P, R2>, Ctx, HookOutput> {
2483
+ this.assertConfigurable("group()")
2484
+ if (typeof prefix !== "string" || !GROUP_PREFIX.test(prefix)) {
2485
+ throw new RouteConfigError(
2486
+ "INVALID_PATH",
2487
+ `group() prefix must be a static path like "/api/v1": ${JSON.stringify(prefix)}`,
2488
+ )
2489
+ }
2490
+ const fullPrefix = this.routePrefix + prefix
2491
+ const scope = new Server<Registry, EmptyContext>(this.options)
2492
+ scope.routePrefix = fullPrefix
2493
+ scope.inheritedStatics = { ...this.inheritedStatics, ...this.staticResponseHeaders?.record }
2494
+ for (const key of GROUP_CHAIN) (scope[key] as unknown[]).push(...this[key])
2495
+ for (const key of SERVER_RUNTIMES)
2496
+ (scope as unknown as Record<string, unknown>)[key] = this[key]
2497
+ Object.assign(scope.decorations, this.decorations)
2498
+ // A plugin already applied here is already in effect for the group (its chain was copied above
2499
+ // and its request hooks are app-wide), so re-applying it would run it twice per request. The
2500
+ // group's own plugins are NOT copied back: they are scoped, and a parent `use()` of the same
2501
+ // plugin must still apply it to the parent's routes. The response observer is the exception: it
2502
+ // installs methods bound to the server it is applied to, so the scope must be free to install its
2503
+ // own (re-applying it registers no hook, so nothing can run twice).
2504
+ for (const [name, plugin] of this.appliedPlugins) {
2505
+ if (name !== "nifra:response-observer") scope.appliedPlugins.set(name, plugin)
2506
+ }
2507
+ scope.inheritedPlugins = new Map(scope.appliedPlugins)
2508
+ let built: unknown
2509
+ try {
2510
+ built = build(scope as never)
2511
+ // Only `listen()` seals a server, so a scope sealed here was served on its own - without this
2512
+ // server's request hooks - which a group must never be.
2513
+ if (scope.sealed) built = undefined
2514
+ } finally {
2515
+ // Closed on every exit, including a throw: an async continuation or a leaked reference must
2516
+ // not register routes that nothing will ever adopt.
2517
+ scope.sealed = true
2518
+ }
2519
+ const label = `group(${JSON.stringify(fullPrefix)})`
2520
+ if (built !== scope) {
2521
+ throw new TypeError(`${label} builder must return its group synchronously`)
2522
+ }
2523
+ if (
2524
+ scope.wsRouteCount > 0 ||
2525
+ scope.fetchMounts.length > 0 ||
2526
+ scope.catalog.entries().some((route) => route.descriptor.tool !== undefined)
2527
+ ) {
2528
+ throw new RouteConfigError(
2529
+ "INVALID_PATH",
2530
+ `${label} cannot hold ws(), mount, or MCP tool routes - add them to the parent`,
2531
+ )
2532
+ }
2533
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2534
+ sealHookAudit(scope, scope.catalog.size, this.logger)
2535
+ this.adopt(scope, fullPrefix, this.globalAssurance.length)
2536
+ return this as unknown as Server<R & PrefixRegistry<P, R2>, Ctx, HookOutput>
2537
+ }
2538
+
2539
+ /**
2540
+ * Take over another server's routes and hooks - the shared core of {@link merge} and {@link group}.
2541
+ * `prefix` selects group semantics: hooks are scoped to requests under the prefix (response hooks
2542
+ * included), and the source's global assurance past `inheritedGlobal` (what it copied from this
2543
+ * server) is folded into its routes. Without a prefix, merge semantics: request hooks are scoped to
2544
+ * the source's routes when it has any, response hooks are appended app-wide.
2545
+ */
2546
+ private adopt(
2547
+ source: Server<Registry, EmptyContext>,
2548
+ prefix: string | undefined,
2549
+ inheritedGlobal: number,
2550
+ ): void {
1953
2551
  const sourceRoutes = source.catalog.entries()
1954
2552
  // Hooks and global assurance stay LOCAL to a group that has routes; a route-less group is a
1955
2553
  // middleware bundle whose hooks can only mean app-wide intent, so it keeps the global append.
1956
- const scoped = sourceRoutes.length > 0
1957
- // A group's global assurance rides its (route-scoped) hooks: folded into each merged route's
1958
- // own evidence rather than the parent's global list, so `routes()` never claims the group's
2554
+ // A prefix group is always local: its hooks mean "under this path", routes or not.
2555
+ const scoped = prefix !== undefined || sourceRoutes.length > 0
2556
+ const ownGlobal = source.globalAssurance.slice(inheritedGlobal)
2557
+ // A group's global assurance rides its (scoped) hooks: folded into each adopted route's own
2558
+ // evidence rather than the parent's global list, so `routes()` never claims the group's
1959
2559
  // enforcement for parent routes the group's hooks do not see.
1960
2560
  const foldedAssurance =
1961
- scoped && source.globalAssurance.length > 0
2561
+ scoped && ownGlobal.length > 0
1962
2562
  ? (route: CatalogRoute): CatalogRoute => ({
1963
2563
  ...route,
1964
- assurance: [...route.assurance, ...source.globalAssurance],
2564
+ assurance: [...route.assurance, ...ownGlobal],
1965
2565
  })
1966
2566
  : (route: CatalogRoute): CatalogRoute => route
1967
2567
  this.catalog.addBatch(
1968
2568
  sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(route))),
1969
2569
  )
1970
2570
  // Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
1971
- // runtime object supplies the generic execution machinery. Preserve a group's installed runtime
1972
- // when the parent has none so merging cannot silently disable a safety lane. If the parent already
1973
- // has a runtime, either implementation can execute every resolved entry because route-specific
1974
- // options were pinned during registration.
1975
- this.responseContractRuntime ??= source.responseContractRuntime
1976
- this.idempotencyRuntime ??= source.idempotencyRuntime
1977
- this.effectLedgerRuntime ??= source.effectLedgerRuntime
1978
- this.mcpRuntime ??= source.mcpRuntime
1979
- this.nodeOutcomeRuntime ??= source.nodeOutcomeRuntime
1980
- this.sseRuntime ??= source.sseRuntime
1981
- this.wsRuntime ??= source.wsRuntime
1982
- if (scoped && source.onRequestHooks.length > 0) {
1983
- // Snapshot the group's routes into a dedicated matcher: the guard must reflect what was
1984
- // MERGED, not whatever the group's own catalog grows into afterwards. One probe against it
1985
- // gates each group hook to requests the group would serve; everything else passes untouched.
1986
- const scope = new RouteCatalog()
1987
- scope.addBatch(sourceRoutes)
1988
- this.onRequestHooks.push(
1989
- ...source.onRequestHooks.map(
1990
- (hook): RawOnRequest =>
1991
- (req, platform) =>
1992
- scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined,
1993
- ),
1994
- )
1995
- // An `undefined` slot marks an unpaired hook (position-aligned with `onRequestHooks`) and
1996
- // must stay `undefined` - wrapping it would fabricate a Node twin that never existed.
1997
- this.onNodeRequestHooks.push(
1998
- ...source.onNodeRequestHooks.map((hook): NodeRequestHook | undefined =>
1999
- hook === undefined
2000
- ? undefined
2001
- : (req, platform) =>
2002
- scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined,
2003
- ),
2004
- )
2005
- } else {
2006
- this.onRequestHooks.push(...source.onRequestHooks)
2007
- this.onNodeRequestHooks.push(...source.onNodeRequestHooks)
2008
- }
2571
+ // runtime object supplies the generic execution machinery. If the parent already has a runtime,
2572
+ // either implementation can execute every resolved entry because route-specific options were
2573
+ // pinned during registration.
2574
+ const runtimes = this as unknown as Record<string, unknown>
2575
+ for (const key of SERVER_RUNTIMES) runtimes[key] ??= source[key]
2576
+ // Which requests a scoped hook sees: under the prefix for a group; for a merged server with
2577
+ // routes, a snapshot of exactly the routes MERGED (not whatever its own catalog grows into later).
2578
+ const inScope =
2579
+ prefix !== undefined
2580
+ ? underPrefix(prefix)
2581
+ : scoped && source.onRequestHooks.length > 0
2582
+ ? servedBy(sourceRoutes)
2583
+ : undefined
2584
+ this.onRequestHooks.push(...scopeHooks(source.onRequestHooks, inScope, 0))
2585
+ this.onNodeRequestHooks.push(...scopeHooks(source.onNodeRequestHooks, inScope, 0))
2009
2586
  this.nodeRequestHooksComplete &&= source.nodeRequestHooksComplete
2010
- // The group's static declarations came before its own response hooks, so they are folded in
2011
- // first - and fold themselves into a hook here if this server already has one (same ordering
2012
- // rule as a direct `responseHeaders()` call).
2013
- if (source.staticResponseHeaders !== undefined) {
2014
- this.addStaticResponseHeaders({ ...source.staticResponseHeaders.record })
2015
- }
2016
- this.onResponseHooks.push(...source.onResponseHooks)
2017
- this.onNodeResponseHooks.push(...source.onNodeResponseHooks)
2587
+ this.bunNativeRequestHooksSafe &&= source.bunNativeRequestHooksSafe
2588
+ // A merged server's response hooks are app-wide; a group's run only under its prefix.
2589
+ const responseScope = prefix === undefined ? undefined : inScope
2590
+ const statics = source.staticResponseHeaders
2591
+ if (statics !== undefined) {
2592
+ if (responseScope === undefined) {
2593
+ // The group's static declarations came before its own response hooks, so they are folded in
2594
+ // first - and fold themselves into a hook here if this server already has one (same ordering
2595
+ // rule as a direct `responseHeaders()` call).
2596
+ this.addStaticResponseHeaders({ ...statics.record })
2597
+ } else {
2598
+ // The static tier is folded into EVERY response this server builds, so a group's static
2599
+ // headers cannot join it. A hook ahead of the group's own (the sealed scope is discarded
2600
+ // after adoption) keeps their meaning - defaults a value already on the response wins over.
2601
+ source.onResponseHooks.unshift((response) => applyStaticResponseHeaders(response, statics))
2602
+ source.onNodeResponseHooks.unshift(undefined)
2603
+ source.nodeResponseHooksComplete = false
2604
+ }
2605
+ }
2606
+ this.onResponseHooks.push(...scopeHooks(source.onResponseHooks, responseScope, 1, true))
2607
+ this.onNodeResponseHooks.push(...scopeHooks(source.onNodeResponseHooks, responseScope, 1))
2608
+ this.onResponseFinalizedHooks.push(
2609
+ ...scopeHooks(source.onResponseFinalizedHooks, responseScope, 1),
2610
+ )
2018
2611
  this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete
2612
+ this.bunNativeResponseHeadersOnly &&= source.bunNativeResponseHeadersOnly
2019
2613
  this.hasRawNodeResponseHook ||= source.hasRawNodeResponseHook
2020
- this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks)
2021
2614
  if (source.responseBodyTag !== undefined) {
2022
2615
  const owner = this.enableResponseBodyTagging()
2023
2616
  this.responseBodyOwners.add(source.responseBodyTag)
2024
2617
  source.responseBodyOwners.add(owner)
2025
2618
  }
2026
- if (!scoped) this.globalAssurance.push(...source.globalAssurance)
2619
+ if (!scoped) this.globalAssurance.push(...ownGlobal)
2620
+ // Cleanup the source registered (a pool, a queue consumer) belongs to the server that now runs
2621
+ // its routes; dropping it would leak the resource past `stop()`.
2622
+ this.stopHooks.push(...source.stopHooks)
2027
2623
  this.mcpResourceList.push(...source.mcpResourceList)
2028
2624
  this.mcpPromptList.push(...source.mcpPromptList)
2029
- return this as unknown as Server<R & R2, Ctx, HookOutput>
2030
2625
  }
2031
2626
 
2032
2627
  /** A fused renderer closes over runtime services to keep its seven-argument JSC fast path. Merging
@@ -2039,9 +2634,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2039
2634
  const lane = entry.execution.fusedLane
2040
2635
  // Type-erase the lane so each `===` branch sees the full union, not the narrowed remainder.
2041
2636
  const laneName: string | undefined = lane
2042
- // Body+lifecycle routes have a fused Web renderer, but no body-only Node renderer: the latter
2043
- // would bypass derive/before/after because the Node-direct dispatcher prefers `fusedBody` over
2044
- // the generic execution plan. Only the original body-only lane may populate this slot.
2637
+ // Body+lifecycle routes have a fused Web renderer, and - unlike before - a generic fused
2638
+ // runner too: it preserves derive/before/after (it IS the lifecycle, single-framed), so the
2639
+ // Node-direct dispatcher's preference for the generic slot over the execution plan stays
2640
+ // semantics-preserving. Only the original body-only lane may feed the Web wrapper below.
2641
+ const derive = entry.derives[0]
2642
+ const before = entry.beforeHandle[0]
2643
+ const after = entry.afterHandle[0]
2045
2644
  const fusedBody =
2046
2645
  laneName === "body"
2047
2646
  ? this.buildFusedBodyRunner(
@@ -2050,10 +2649,49 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2050
2649
  entry.hasDecorations ? entry.decorations : undefined,
2051
2650
  entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2052
2651
  )
2053
- : undefined
2054
- const derive = entry.derives[0]
2055
- const before = entry.beforeHandle[0]
2056
- const after = entry.afterHandle[0]
2652
+ : laneName === "body-derive-before" && derive !== undefined && before !== undefined
2653
+ ? this.buildFusedBodyDeriveBeforeAfterRunner(
2654
+ entry.handler,
2655
+ derive,
2656
+ before,
2657
+ undefined,
2658
+ entry.schema?.body as StandardSchemaV1,
2659
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2660
+ )
2661
+ : laneName === "body-derive-before-after" &&
2662
+ derive !== undefined &&
2663
+ before !== undefined &&
2664
+ after !== undefined
2665
+ ? this.buildFusedBodyDeriveBeforeAfterRunner(
2666
+ entry.handler,
2667
+ derive,
2668
+ before,
2669
+ after,
2670
+ entry.schema?.body as StandardSchemaV1,
2671
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2672
+ )
2673
+ : laneName === "derive-before" && derive !== undefined && before !== undefined
2674
+ ? this.buildFusedDeriveBeforeRunner(
2675
+ entry.handler,
2676
+ derive,
2677
+ before,
2678
+ undefined,
2679
+ entry.schema?.query,
2680
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2681
+ )
2682
+ : laneName === "derive-before-after" &&
2683
+ derive !== undefined &&
2684
+ before !== undefined &&
2685
+ after !== undefined
2686
+ ? this.buildFusedDeriveBeforeRunner(
2687
+ entry.handler,
2688
+ derive,
2689
+ before,
2690
+ after,
2691
+ entry.schema?.query,
2692
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2693
+ )
2694
+ : undefined
2057
2695
  const fusedWeb =
2058
2696
  laneName === "body"
2059
2697
  ? this.buildFusedBodyWeb(fusedBody as FusedBodyRunner)
@@ -2143,6 +2781,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2143
2781
  * Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
2144
2782
  */
2145
2783
  fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
2784
+ // Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
2785
+ // Route what the parsed URL says, so `/users/../admin` is `/admin` on every runtime.
2786
+ if (rawRequestTargets && hasDotSegment(req.url)) req = withResolvedTarget(req)
2146
2787
  // An edge deployment whose only ingress is this method declares its requests runtime-framed at
2147
2788
  // construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
2148
2789
  if (this.trustBodyFraming) markTrustedBodyFraming(req)
@@ -2198,19 +2839,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2198
2839
  source: RequestSource,
2199
2840
  entry: RouteEntry,
2200
2841
  params: Record<string, string>,
2842
+ platform: Platform,
2201
2843
  ): MaybePromise<Response> {
2202
- if (this.capacityGate === undefined) return this.fetchMatchedInner(source, entry, params)
2203
- return this.admitGated(requestOf(source), () => this.fetchMatchedInner(source, entry, params))
2844
+ if (this.capacityGate === undefined) {
2845
+ return this.fetchMatchedInner(source, entry, params, platform)
2846
+ }
2847
+ return this.admitGated(requestOf(source), () =>
2848
+ this.fetchMatchedInner(source, entry, params, platform),
2849
+ )
2204
2850
  }
2205
2851
 
2206
2852
  private fetchMatchedInner(
2207
2853
  source: RequestSource,
2208
2854
  entry: RouteEntry,
2209
2855
  params: Record<string, string>,
2856
+ platform: Platform,
2210
2857
  ): MaybePromise<Response> {
2211
2858
  const outcome = this.runMatched(
2212
2859
  source,
2213
- undefined,
2860
+ platform,
2214
2861
  entry,
2215
2862
  params,
2216
2863
  undefined,
@@ -2289,18 +2936,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2289
2936
 
2290
2937
  const parts = source.urlParts ?? urlPartsOf(source.url)
2291
2938
  const match = this.catalog.find(source.method, parts.pathname)
2292
- if (match.found) return undefined
2939
+ if (match.found || !isRoutableMethod(source.method)) return undefined
2940
+
2941
+ // The mount the ordinary path reaches first: every `mount()` runs before any `mountFetch()`,
2942
+ // whatever their prefix lengths. A 404 fallthrough chain stays on the ordinary path.
2943
+ const mount =
2944
+ this.firstMountUnder(parts.pathname, true) ?? this.firstMountUnder(parts.pathname, false)
2945
+ if (mount === undefined || mount.fallbackOn404) return undefined
2946
+ const candidate = (mount.handler as unknown as Record<symbol, unknown>)[NODE_NATIVE_MOUNT]
2947
+ return typeof candidate === "function"
2948
+ ? {
2949
+ handler: candidate as NativeMountHandler,
2950
+ path: mount.path,
2951
+ stripPrefix: mount.stripPrefix,
2952
+ }
2953
+ : undefined
2954
+ }
2293
2955
 
2956
+ private firstMountUnder(
2957
+ pathname: string,
2958
+ beforeRoutes: boolean,
2959
+ ): FetchMount<EnvOf<Ctx>> | undefined {
2294
2960
  for (const mount of this.fetchMounts) {
2295
- if (!underMountPrefix(parts.pathname, mount.path)) continue
2296
- const candidate = (mount.handler as unknown as Record<symbol, unknown>)[NODE_NATIVE_MOUNT]
2297
- return typeof candidate === "function"
2298
- ? {
2299
- handler: candidate as NativeMountHandler,
2300
- path: mount.path,
2301
- stripPrefix: mount.stripPrefix,
2302
- }
2303
- : undefined
2961
+ if (mount.beforeRoutes === beforeRoutes && underMountPrefix(pathname, mount.path))
2962
+ return mount
2304
2963
  }
2305
2964
  return undefined
2306
2965
  }
@@ -2468,6 +3127,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2468
3127
  const context: NodeResponseContext = {
2469
3128
  status: outcome.status,
2470
3129
  headers,
3130
+ headersAreLowercase: !this.hasRawNodeResponseHook && lowercaseKeys,
2471
3131
  cookies: outcome.kind === "json" ? outcome.cookies : undefined,
2472
3132
  body: outcome.body,
2473
3133
  }
@@ -2609,8 +3269,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2609
3269
  req: Request,
2610
3270
  platform?: Platform<EnvOf<Ctx>>,
2611
3271
  ): MaybePromise<WebSocketUpgradeOutcome> {
2612
- if (this.wsRouteCount === 0) return WS_PASS
3272
+ // Before any header is read: Deno only materializes a request's headers when asked, and that
3273
+ // costs a plain request measurably.
3274
+ if (this.wsRouteCount === 0 && !this.mountsTakeUpgrades()) return WS_PASS
2613
3275
  if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") return WS_PASS
3276
+ // A WebSocket handshake is a GET (RFC 6455 section 4.1). Any other method with an Upgrade header
3277
+ // is an ordinary request: its HTTP route answers it, and normal routing refuses an unknown token.
3278
+ if (req.method !== "GET") return WS_PASS
3279
+ // The handshake routes the path `fetch` would: dot segments resolved (see `fetch`).
3280
+ if (rawRequestTargets && hasDotSegment(req.url)) req = withResolvedTarget(req)
3281
+ // The handshake's caller is the one `fetch` derives: hooks and `upgrade()` see the trusted IP.
3282
+ platform = this.trustedPlatform(req, platform)
2614
3283
 
2615
3284
  const timeoutMs =
2616
3285
  this.wsUpgradeTimeoutMs === 0
@@ -2708,7 +3377,61 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2708
3377
  }
2709
3378
  }
2710
3379
 
2711
- if (this.onRequestHooks.length === 0) return resolveMatched(req)
3380
+ const resolveMounted = (request: Request): MaybePromise<WebSocketUpgradeOutcome> => {
3381
+ // Native pub/sub was chosen because no mounted app took upgrades when `listen()` ran. One that
3382
+ // gains a WebSocket route later stays out: its sockets would share this server's Bun topics.
3383
+ if (this.nativePublish !== undefined) return WS_PASS
3384
+ const pathname = urlPartsOf(request.url).pathname
3385
+ const dispatch = (start: number): MaybePromise<WebSocketUpgradeOutcome> => {
3386
+ let selected: FetchMount<EnvOf<Ctx>> | undefined
3387
+ let selectedIndex = start
3388
+ for (let i = start; i < this.fetchMounts.length; i++) {
3389
+ const candidate = this.fetchMounts[i]!
3390
+ if (
3391
+ candidate.resolveWebSocketUpgrade !== undefined &&
3392
+ underMountPrefix(pathname, candidate.path)
3393
+ ) {
3394
+ selected = candidate
3395
+ selectedIndex = i
3396
+ break
3397
+ }
3398
+ }
3399
+ if (selected === undefined) return WS_PASS
3400
+ const childRequest = selected.stripPrefix
3401
+ ? stripMountPrefix(request, selected.path)
3402
+ : request
3403
+ let child: MaybePromise<WebSocketUpgradeOutcome>
3404
+ try {
3405
+ child = selected.resolveWebSocketUpgrade!(childRequest, platform)
3406
+ } catch {
3407
+ return { kind: "reject", response: jsonError(500, "internal_error") }
3408
+ }
3409
+ const settle = (outcome: WebSocketUpgradeOutcome): MaybePromise<WebSocketUpgradeOutcome> =>
3410
+ outcome.kind === "pass" ||
3411
+ (outcome.kind === "reject" && outcome.response.status === 404 && selected!.fallbackOn404)
3412
+ ? dispatch(selectedIndex + 1)
3413
+ : outcome
3414
+ return child instanceof Promise ? child.then(settle) : settle(child)
3415
+ }
3416
+ return dispatch(0)
3417
+ }
3418
+
3419
+ const resolveRoutes = (request: Request): MaybePromise<WebSocketUpgradeOutcome> => {
3420
+ const local = resolveMatched(request)
3421
+ if (local instanceof Promise) {
3422
+ return local.then((outcome) =>
3423
+ outcome.kind === "pass" ? resolveMounted(request) : outcome,
3424
+ )
3425
+ }
3426
+ return local.kind === "pass" ? resolveMounted(request) : local
3427
+ }
3428
+
3429
+ const resolveWithDeadline = (request: Request): MaybePromise<WebSocketUpgradeOutcome> => {
3430
+ const outcome = resolveRoutes(request)
3431
+ return outcome instanceof Promise ? bounded(outcome, onTimeout) : outcome
3432
+ }
3433
+
3434
+ if (this.onRequestHooks.length === 0) return resolveWithDeadline(req)
2712
3435
  let hooked: MaybePromise<Request | Response>
2713
3436
  try {
2714
3437
  hooked = this.runWebSocketRequestHooks(req, platform)
@@ -2718,13 +3441,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2718
3441
  if (!(hooked instanceof Promise)) {
2719
3442
  return hooked instanceof Response
2720
3443
  ? { kind: "reject", response: hooked }
2721
- : resolveMatched(hooked)
3444
+ : resolveWithDeadline(hooked)
2722
3445
  }
2723
3446
  const pending = hooked.then(
2724
3447
  (result) =>
2725
3448
  result instanceof Response
2726
3449
  ? { kind: "reject" as const, response: result }
2727
- : resolveMatched(result),
3450
+ : resolveWithDeadline(result),
2728
3451
  () => ({ kind: "reject" as const, response: jsonError(500, "internal_error") }),
2729
3452
  )
2730
3453
  return bounded(
@@ -2744,7 +3467,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2744
3467
  if (o.kind === "pass")
2745
3468
  return this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>)
2746
3469
  if (o.kind === "reject") return o.response
2747
- return server.upgrade(req, { data: { handler: o.handler, data: o.data } })
3470
+ return server.upgrade(req, {
3471
+ data: { handler: o.handler, data: o.data, pubsub: o.pubsub },
3472
+ })
2748
3473
  ? undefined
2749
3474
  : jsonError(426, "upgrade_required")
2750
3475
  }
@@ -2766,15 +3491,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2766
3491
  * whose `clientIp` is the derived caller. Only called when a trust declaration is configured. */
2767
3492
  private deriveClientIp(
2768
3493
  source: RequestSource,
2769
- platform: Platform | undefined,
2770
- ): Platform | undefined {
3494
+ platform: Platform<EnvOf<Ctx>> | undefined,
3495
+ ): Platform<EnvOf<Ctx>> | undefined {
2771
3496
  const derived = resolveClientIp(platform?.clientIp, requestOf(source), this.clientIpTrust)
2772
3497
  return { ...platform, clientIp: derived }
2773
3498
  }
2774
3499
 
3500
+ private trustedPlatform(
3501
+ source: RequestSource,
3502
+ platform: Platform<EnvOf<Ctx>> | undefined,
3503
+ ): Platform<EnvOf<Ctx>> | undefined {
3504
+ return this.clientIpTrust === undefined ||
3505
+ (platform as { [NIFRA_PLATFORM_CLIENT_IP_DERIVED]?: unknown } | undefined)?.[
3506
+ NIFRA_PLATFORM_CLIENT_IP_DERIVED
3507
+ ] === true
3508
+ ? platform
3509
+ : this.deriveClientIp(source, platform)
3510
+ }
3511
+
2775
3512
  private dispatch<T>(
2776
3513
  source: RequestSource,
2777
- platform: Platform | undefined,
3514
+ platform: Platform<EnvOf<Ctx>> | undefined,
2778
3515
  finalize: (result: unknown, set: CtxSet) => T,
2779
3516
  wrapResponse: (response: Response | ResponseResult) => T,
2780
3517
  onTimeout: () => T,
@@ -2785,11 +3522,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2785
3522
  // Resolve the trust declaration into the platform's `clientIp` ONCE, here at the shared funnel, so
2786
3523
  // `c.clientIp` (and every hook/derive downstream) sees the derived caller. No config ⇒ the raw
2787
3524
  // socket peer the adapter supplied passes through untouched (a one-property no-op on the hot path).
2788
- const resolved =
2789
- this.clientIpTrust === undefined ? platform : this.deriveClientIp(source, platform)
3525
+ // A platform an enclosing nifra server already resolved (an in-process call from an SSR loader)
3526
+ // keeps its `clientIp`: the synthesized request carries none of the visitor's forwarding headers.
3527
+ const resolved = this.trustedPlatform(source, platform)
2790
3528
  // onRequest hooks may be async, so a hooked app takes the async path; with no hooks (the common
2791
3529
  // case) routing stays synchronous, letting a bare route resolve with no lifecycle promise at all.
2792
- if (this.onRequestHooks.length === 0) {
3530
+ // A token no route can be registered under skips the hooks as well: it cannot match, so routing
3531
+ // answers it 404 or 405 and no hook reads a method string it would misjudge.
3532
+ if (this.onRequestHooks.length === 0 || !isRoutableMethod(source.method)) {
2793
3533
  return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast)
2794
3534
  }
2795
3535
  if (!webFast && this.canUseNodeRequestHooks()) {
@@ -2802,7 +3542,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2802
3542
  * Web `Request`; arbitrary request rewrites and full Web hooks use {@link runWithOnRequest}. */
2803
3543
  private runWithNodeRequest<T>(
2804
3544
  source: RequestSource,
2805
- platform: Platform | undefined,
3545
+ platform: Platform<EnvOf<Ctx>> | undefined,
2806
3546
  finalize: (result: unknown, set: CtxSet) => T,
2807
3547
  wrapResponse: (response: Response | ResponseResult) => T,
2808
3548
  onTimeout: () => T,
@@ -2836,7 +3576,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2836
3576
  nextIndex: number,
2837
3577
  request: NodeRequestContext,
2838
3578
  source: RequestSource,
2839
- platform: Platform | undefined,
3579
+ platform: Platform<EnvOf<Ctx>> | undefined,
2840
3580
  finalize: (result: unknown, set: CtxSet) => T,
2841
3581
  wrapResponse: (response: Response | ResponseResult) => T,
2842
3582
  onTimeout: () => T,
@@ -2864,7 +3604,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2864
3604
  */
2865
3605
  private runWithOnRequest<T>(
2866
3606
  source: RequestSource,
2867
- platform: Platform | undefined,
3607
+ platform: Platform<EnvOf<Ctx>> | undefined,
2868
3608
  finalize: (result: unknown, set: CtxSet) => T,
2869
3609
  wrapResponse: (response: Response | ResponseResult) => T,
2870
3610
  onTimeout: () => T,
@@ -2878,7 +3618,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2878
3618
  if (source !== originalRequest) this.responseSources.set(source as object, originalRequest)
2879
3619
  let current: RequestSource = source
2880
3620
  for (let i = 0; i < hooks.length; i++) {
2881
- const outcome = (hooks[i] as RawOnRequest)(requestOf(current), platform)
3621
+ const seen = requestOf(current)
3622
+ const outcome = (hooks[i] as RawOnRequest)(seen, platform)
2882
3623
  if (outcome instanceof Promise) {
2883
3624
  return outcome.then((early) =>
2884
3625
  this.continueOnRequest(
@@ -2895,6 +3636,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2895
3636
  )
2896
3637
  }
2897
3638
  if (outcome instanceof Request) {
3639
+ if (outcome !== seen) recordRequestReplacement(outcome, seen)
2898
3640
  current = outcome
2899
3641
  if (outcome !== originalRequest) this.responseRequests.set(originalRequest, outcome)
2900
3642
  continue
@@ -2911,24 +3653,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2911
3653
  nextIndex: number,
2912
3654
  originalRequest: Request,
2913
3655
  sourceAtAwait: RequestSource,
2914
- platform: Platform | undefined,
3656
+ platform: Platform<EnvOf<Ctx>> | undefined,
2915
3657
  finalize: (result: unknown, set: CtxSet) => T,
2916
3658
  wrapResponse: (response: Response | ResponseResult) => T,
2917
3659
  onTimeout: () => T,
2918
3660
  webFast: boolean,
2919
3661
  ): Promise<T> {
2920
3662
  let current = sourceAtAwait
3663
+ let seen = requestOf(sourceAtAwait)
2921
3664
  let early = first
2922
3665
  let index = nextIndex
2923
3666
  for (;;) {
2924
3667
  if (early instanceof Request) {
3668
+ if (early !== seen) recordRequestReplacement(early, seen)
2925
3669
  current = early
2926
3670
  if (early !== originalRequest) this.responseRequests.set(originalRequest, early)
2927
3671
  } else if (early !== undefined) {
2928
3672
  return wrapResponse(early)
2929
3673
  }
2930
3674
  if (index >= this.onRequestHooks.length) break
2931
- const outcome = (this.onRequestHooks[index] as RawOnRequest)(requestOf(current), platform)
3675
+ seen = requestOf(current)
3676
+ const outcome = (this.onRequestHooks[index] as RawOnRequest)(seen, platform)
2932
3677
  early = outcome instanceof Promise ? await outcome : outcome
2933
3678
  index++
2934
3679
  }
@@ -3005,7 +3750,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3005
3750
  */
3006
3751
  private routeAndRun<T>(
3007
3752
  source: RequestSource,
3008
- platform: Platform | undefined,
3753
+ platform: Platform<EnvOf<Ctx>> | undefined,
3009
3754
  finalize: (result: unknown, set: CtxSet) => T,
3010
3755
  wrapResponse: (response: Response | ResponseResult) => T,
3011
3756
  onTimeout: () => T,
@@ -3049,9 +3794,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3049
3794
  search = searchStart === -1 ? "" : rawUrl.slice(searchStart, searchEnd)
3050
3795
  }
3051
3796
  }
3797
+ // Explicit composed mounts run before the parent's route table. This is what lets an SSR shell
3798
+ // mount an API at `/api` without its catch-all page route swallowing the request. Legacy
3799
+ // `mountFetch()` stays behind typed routes and is checked in the existing branch below.
3800
+ if (this.preRouteMountCount > 0) {
3801
+ const mounted = this.fetchMount(pathname, source, platform, true)
3802
+ if (mounted !== undefined) {
3803
+ return mounted instanceof Promise
3804
+ ? mounted.then((response) => wrapResponse(response))
3805
+ : wrapResponse(mounted)
3806
+ }
3807
+ }
3808
+
3052
3809
  const match = this.catalog.find(source.method, pathname)
3053
3810
  if (!match.found) {
3054
- const mounted = this.fetchMount(pathname, source, platform)
3811
+ const mounted = this.fetchMount(pathname, source, platform, false)
3055
3812
  if (mounted !== undefined) {
3056
3813
  return mounted instanceof Promise
3057
3814
  ? mounted.then((response) => wrapResponse(response))
@@ -3064,7 +3821,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3064
3821
  plainError(405, "method_not_allowed", { allow: match.allowed.join(", ") }),
3065
3822
  )
3066
3823
  }
3067
- return wrapResponse(plainError(404, "not_found"))
3824
+ const lane = this.notFoundLane
3825
+ return lane === undefined
3826
+ ? wrapResponse(plainError(404, "not_found"))
3827
+ : lane(this, source, pathname, platform, wrapResponse, onTimeout)
3068
3828
  }
3069
3829
 
3070
3830
  // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
@@ -3090,17 +3850,48 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3090
3850
  private fetchMount(
3091
3851
  pathname: string,
3092
3852
  source: RequestSource,
3093
- platform: Platform | undefined,
3853
+ platform: Platform<EnvOf<Ctx>> | undefined,
3854
+ beforeRoutes: boolean,
3094
3855
  ): MaybePromise<Response> | undefined {
3095
- for (const mount of this.fetchMounts) {
3096
- if (!underMountPrefix(pathname, mount.path)) continue
3097
- const request = requestOf(source)
3098
- return mount.handler(
3099
- mount.stripPrefix ? stripMountPrefix(request, mount.path) : request,
3100
- platform,
3101
- )
3856
+ const request = requestOf(source)
3857
+ let first = -1
3858
+ for (let i = 0; i < this.fetchMounts.length; i++) {
3859
+ const mount = this.fetchMounts[i]!
3860
+ if (mount.beforeRoutes !== beforeRoutes || !underMountPrefix(pathname, mount.path)) continue
3861
+ first = i
3862
+ break
3102
3863
  }
3103
- return undefined
3864
+ // A mounted handler is arbitrary code that may read the method loosely, so it is only handed a
3865
+ // token a route could be registered under; anything else is left to the route table to refuse.
3866
+ if (first === -1 || !isRoutableMethod(source.method)) return undefined
3867
+
3868
+ // A request body is a one-shot stream. Only methods whose request semantics are replayable are
3869
+ // eligible for 404 fallthrough; this intentionally refuses POST/PUT/PATCH even when the body is
3870
+ // empty because an adapter may expose a delayed stream or a protocol-specific side effect.
3871
+ const replayable =
3872
+ source.method === "GET" || source.method === "HEAD" || source.method === "OPTIONS"
3873
+ const dispatch = (index: number, fallback?: Response): MaybePromise<Response> => {
3874
+ let mount: FetchMount<EnvOf<Ctx>> | undefined
3875
+ for (let i = index; i < this.fetchMounts.length; i++) {
3876
+ const candidate = this.fetchMounts[i]!
3877
+ if (candidate.beforeRoutes === beforeRoutes && underMountPrefix(pathname, candidate.path)) {
3878
+ mount = candidate
3879
+ index = i
3880
+ break
3881
+ }
3882
+ }
3883
+ if (mount === undefined) return fallback ?? jsonError(404, "not_found")
3884
+ const childRequest = mount.stripPrefix ? stripMountPrefix(request, mount.path) : request
3885
+ const outcome = mount.handler(childRequest, platform)
3886
+ if (!replayable || !mount.fallbackOn404) return outcome
3887
+ if (outcome instanceof Promise) {
3888
+ return outcome.then((response) =>
3889
+ response.status === 404 ? dispatch(index + 1, response) : response,
3890
+ )
3891
+ }
3892
+ return outcome.status === 404 ? dispatch(index + 1, outcome) : outcome
3893
+ }
3894
+ return dispatch(first)
3104
3895
  }
3105
3896
 
3106
3897
  /** Run a route that has already been matched by the runtime or Nifra's portable router. */
@@ -3124,8 +3915,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3124
3915
  // handler can reach `c.req`; that lane already enforces `entry.bodyLimit`. Installing the full
3125
3916
  // direct-reader cap on `c.req` here would allocate bound readers, closures, and a stream wrapper
3126
3917
  // for a body that is already consumed. Keep the lazy transport cap for raw-body routes, where a
3127
- // user read is the only framework-owned body boundary.
3128
- if (entry.bodyLimit !== undefined && entry.schema?.body === undefined) {
3918
+ // user read is the only framework-owned body boundary, and for the auth-first lane, whose
3919
+ // derive and beforeHandle hooks reach `c.req` before the body is read.
3920
+ if (
3921
+ entry.bodyLimit !== undefined &&
3922
+ (entry.schema?.body === undefined || entry.program.authBeforeValidation)
3923
+ ) {
3129
3924
  const method = source.method
3130
3925
  if (method !== "GET" && method !== "HEAD") markTransportCap(source, entry.bodyLimit)
3131
3926
  }
@@ -3639,6 +4434,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3639
4434
  finish,
3640
4435
  wrapResponse,
3641
4436
  (err) => logError(err, ctx, finalize, wrapResponse),
4437
+ bodySchema,
3642
4438
  )
3643
4439
  }
3644
4440
  }
@@ -4208,24 +5004,455 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4208
5004
  onParsed,
4209
5005
  (response) => this.wrapWebResponse(response),
4210
5006
  (err) => Promise.resolve(logError(err, ctx)),
5007
+ bodySchema,
4211
5008
  ) as MaybePromise<Response>
4212
5009
  }
4213
5010
  }
4214
5011
 
4215
- private bareError<T>(
4216
- err: unknown,
4217
- ctx: RawContext,
4218
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
4219
- wrapResponse: (response: Response | ResponseResult) => T,
4220
- ): T {
4221
- // The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
4222
- // lane and the fused body runner. `responseSet` and the logger are injected because they reach
4223
- // into class state; the closure is allocated only on the throw path, never on the hot lane.
5012
+ /**
5013
+ * Generic (finalize/wrapResponse-parameterized) fused runner for the derive-before(-after) lane:
5014
+ * the Node-direct counterpart of `buildFusedDeriveBefore` / `buildFusedDeriveBeforeAfter`.
5015
+ *
5016
+ * The Web fused builders above return a `Response`, so the Node dispatcher (`webFast === false`)
5017
+ * cannot use them and a derive+before route falls back to the generic route program there - two
5018
+ * staged loops, per-stage dispatch, and the async-continuation machinery - while Bun rides the
5019
+ * single closure. That asymmetry is the realistic-route Node deficit against Fastify: this runner
5020
+ * closes it by running validate → derive → before → handler → (after) in one frame and finalizing
5021
+ * through the caller's own `finalize` (the Node outcome on the direct lane, `toResponse` on Web),
5022
+ * so the same registration feeds both runtimes with no per-lane copy of the lifecycle to drift.
5023
+ *
5024
+ * Semantics mirror the generic program stage-for-stage for the shapes this lane admits (exactly
5025
+ * one derive, one beforeHandle, zero-or-one afterHandle, no onError/around/decorations/contracts/
5026
+ * recovery hooks - enforced by `selectRouteLanes`): thrown `Response`/`status(...)` are control
5027
+ * flow through `wrapResponse`/`finalize`, anything else logs + 500s via the shared `bareError`
5028
+ * lane (identical to `handleLifecycleError` with no `onError` hooks), and a terminal `finalize`
5029
+ * throw (e.g. an unstringifiable handler return on the Node lane) is caught exactly as
5030
+ * `finishLifecycleContract` catches it. Step functions close over registration state only and take
5031
+ * the request state as arguments, so the hot path allocates no per-request closures.
5032
+ */
5033
+ private buildFusedDeriveBeforeRunner(
5034
+ handler: InternalHandler,
5035
+ derive: RawDerive,
5036
+ before: RawBeforeHandle,
5037
+ after: RawAfterHandle | undefined,
5038
+ querySchema: StandardSchemaV1 | undefined,
5039
+ maxBodyBytes: number,
5040
+ ): FusedBodyRunner {
5041
+ const logError = <T>(
5042
+ err: unknown,
5043
+ ctx: RawContext,
5044
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5045
+ wrapResponse: (response: Response | ResponseResult) => T,
5046
+ ): T => this.bareError(err, ctx, finalize, wrapResponse)
5047
+
5048
+ const runFinish = <T>(
5049
+ result: unknown,
5050
+ ctx: RawContext,
5051
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5052
+ wrapResponse: (response: Response | ResponseResult) => T,
5053
+ ): MaybePromise<T> => {
5054
+ if (after === undefined) {
5055
+ try {
5056
+ return finalize(result, responseSet(ctx), ctx)
5057
+ } catch (err) {
5058
+ return logError(err, ctx, finalize, wrapResponse)
5059
+ }
5060
+ }
5061
+ let transformed: unknown
5062
+ try {
5063
+ transformed = after(result, ctx)
5064
+ } catch (err) {
5065
+ return logError(err, ctx, finalize, wrapResponse)
5066
+ }
5067
+ if (transformed instanceof Promise) {
5068
+ return transformed.then(
5069
+ (value) => {
5070
+ try {
5071
+ return finalize(value, responseSet(ctx), ctx)
5072
+ } catch (err) {
5073
+ return logError(err, ctx, finalize, wrapResponse)
5074
+ }
5075
+ },
5076
+ (err) => logError(err, ctx, finalize, wrapResponse),
5077
+ )
5078
+ }
5079
+ try {
5080
+ return finalize(transformed, responseSet(ctx), ctx)
5081
+ } catch (err) {
5082
+ return logError(err, ctx, finalize, wrapResponse)
5083
+ }
5084
+ }
5085
+
5086
+ const finalizeEarly = <T>(
5087
+ result: unknown,
5088
+ ctx: RawContext,
5089
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5090
+ wrapResponse: (response: Response | ResponseResult) => T,
5091
+ ): T => {
5092
+ try {
5093
+ return finalize(result, responseSet(ctx), ctx)
5094
+ } catch (err) {
5095
+ return logError(err, ctx, finalize, wrapResponse)
5096
+ }
5097
+ }
5098
+
5099
+ const runHandler = <T>(
5100
+ ctx: RawContext,
5101
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5102
+ wrapResponse: (response: Response | ResponseResult) => T,
5103
+ ): MaybePromise<T> => {
5104
+ let result: unknown
5105
+ try {
5106
+ result = handler(ctx)
5107
+ } catch (err) {
5108
+ return logError(err, ctx, finalize, wrapResponse)
5109
+ }
5110
+ if (result instanceof Promise) {
5111
+ return result.then(
5112
+ (value) => runFinish(value, ctx, finalize, wrapResponse),
5113
+ (err) => logError(err, ctx, finalize, wrapResponse),
5114
+ )
5115
+ }
5116
+ return runFinish(result, ctx, finalize, wrapResponse)
5117
+ }
5118
+
5119
+ const runBefore = <T>(
5120
+ ctx: RawContext,
5121
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5122
+ wrapResponse: (response: Response | ResponseResult) => T,
5123
+ ): MaybePromise<T> => {
5124
+ let early: unknown
5125
+ try {
5126
+ early = before(ctx)
5127
+ } catch (err) {
5128
+ return logError(err, ctx, finalize, wrapResponse)
5129
+ }
5130
+ if (early instanceof Promise) {
5131
+ return early.then(
5132
+ (settled) =>
5133
+ settled === undefined
5134
+ ? runHandler(ctx, finalize, wrapResponse)
5135
+ : finalizeEarly(settled, ctx, finalize, wrapResponse),
5136
+ (err) => logError(err, ctx, finalize, wrapResponse),
5137
+ )
5138
+ }
5139
+ if (early === undefined) return runHandler(ctx, finalize, wrapResponse)
5140
+ return finalizeEarly(early, ctx, finalize, wrapResponse)
5141
+ }
5142
+
5143
+ const runDerive = <T>(
5144
+ ctx: RawContext,
5145
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5146
+ wrapResponse: (response: Response | ResponseResult) => T,
5147
+ ): MaybePromise<T> => {
5148
+ let derived: unknown
5149
+ try {
5150
+ derived = derive(ctx)
5151
+ } catch (err) {
5152
+ return logError(err, ctx, finalize, wrapResponse)
5153
+ }
5154
+ if (derived instanceof Promise) {
5155
+ return derived.then(
5156
+ (settled) => {
5157
+ if (isResponseResult(settled) || settled instanceof Response) {
5158
+ return finalizeEarly(settled, ctx, finalize, wrapResponse)
5159
+ }
5160
+ Object.assign(ctx, settled as object)
5161
+ return runBefore(ctx, finalize, wrapResponse)
5162
+ },
5163
+ (err) => logError(err, ctx, finalize, wrapResponse),
5164
+ )
5165
+ }
5166
+ if (isResponseResult(derived) || derived instanceof Response) {
5167
+ return finalizeEarly(derived, ctx, finalize, wrapResponse)
5168
+ }
5169
+ Object.assign(ctx, derived as object)
5170
+ return runBefore(ctx, finalize, wrapResponse)
5171
+ }
5172
+
5173
+ const runValidated = <T>(
5174
+ validation: StandardResult<unknown> | Promise<StandardResult<unknown>>,
5175
+ ctx: RawContext,
5176
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5177
+ wrapResponse: (response: Response | ResponseResult) => T,
5178
+ ): MaybePromise<T> => {
5179
+ if (validation instanceof Promise) {
5180
+ return validation.then(
5181
+ (settled) => {
5182
+ if (settled.issues !== undefined)
5183
+ return wrapResponse(plainValidationError(settled.issues))
5184
+ ctx.query = settled.value
5185
+ return runDerive(ctx, finalize, wrapResponse)
5186
+ },
5187
+ (err) => logError(err, ctx, finalize, wrapResponse),
5188
+ )
5189
+ }
5190
+ if (validation.issues !== undefined)
5191
+ return wrapResponse(plainValidationError(validation.issues))
5192
+ ctx.query = validation.value
5193
+ return runDerive(ctx, finalize, wrapResponse)
5194
+ }
5195
+
5196
+ return <T>(
5197
+ source: RequestSource,
5198
+ params: Record<string, string>,
5199
+ search: string | undefined,
5200
+ signal: AbortSignal,
5201
+ budget: RequestBudget,
5202
+ platform: Platform | undefined,
5203
+ nativeContext: boolean,
5204
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5205
+ wrapResponse: (response: Response | ResponseResult) => T,
5206
+ ): MaybePromise<T> => {
5207
+ const ctx = nativeContext
5208
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
5209
+ : new RequestContext(
5210
+ source,
5211
+ params,
5212
+ search,
5213
+ signal,
5214
+ budget,
5215
+ platform,
5216
+ maxBodyBytes,
5217
+ this.protoPoisoning,
5218
+ )
5219
+ if (querySchema === undefined) return runDerive(ctx, finalize, wrapResponse)
5220
+ let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
5221
+ try {
5222
+ validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
5223
+ } catch (err) {
5224
+ return logError(err, ctx, finalize, wrapResponse)
5225
+ }
5226
+ return runValidated(validation, ctx, finalize, wrapResponse)
5227
+ }
5228
+ }
5229
+
5230
+ /**
5231
+ * Generic fused runner for the body+derive-before(-after) lane: the Node-direct counterpart of
5232
+ * `buildFusedBodyDeriveBeforeAfter`. Same contract as `buildFusedDeriveBeforeRunner` above - one
5233
+ * frame running parse → validate → derive → before → handler → (after) through the caller's own
5234
+ * `finalize` - with the body read ahead of validation, exactly as the generic program orders its
5235
+ * body stage before the derive/before stages. `readBodyFramed` keeps the shared bounded-read,
5236
+ * content-type, and prototype-poisoning contracts; the 415/400 renders take `wrapResponse`
5237
+ * directly, which is what the generic lane's wrap-after-identity amounts to.
5238
+ */
5239
+ private buildFusedBodyDeriveBeforeAfterRunner(
5240
+ handler: InternalHandler,
5241
+ derive: RawDerive,
5242
+ before: RawBeforeHandle,
5243
+ after: RawAfterHandle | undefined,
5244
+ bodySchema: StandardSchemaV1,
5245
+ maxBodyBytes: number,
5246
+ ): FusedBodyRunner {
5247
+ const logError = <T>(
5248
+ err: unknown,
5249
+ ctx: RawContext,
5250
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5251
+ wrapResponse: (response: Response | ResponseResult) => T,
5252
+ ): T => this.bareError(err, ctx, finalize, wrapResponse)
5253
+
5254
+ const runFinish = <T>(
5255
+ result: unknown,
5256
+ ctx: RawContext,
5257
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5258
+ wrapResponse: (response: Response | ResponseResult) => T,
5259
+ ): MaybePromise<T> => {
5260
+ if (after === undefined) {
5261
+ try {
5262
+ return finalize(result, responseSet(ctx), ctx)
5263
+ } catch (err) {
5264
+ return logError(err, ctx, finalize, wrapResponse)
5265
+ }
5266
+ }
5267
+ let transformed: unknown
5268
+ try {
5269
+ transformed = after(result, ctx)
5270
+ } catch (err) {
5271
+ return logError(err, ctx, finalize, wrapResponse)
5272
+ }
5273
+ if (transformed instanceof Promise) {
5274
+ return transformed.then(
5275
+ (value) => {
5276
+ try {
5277
+ return finalize(value, responseSet(ctx), ctx)
5278
+ } catch (err) {
5279
+ return logError(err, ctx, finalize, wrapResponse)
5280
+ }
5281
+ },
5282
+ (err) => logError(err, ctx, finalize, wrapResponse),
5283
+ )
5284
+ }
5285
+ try {
5286
+ return finalize(transformed, responseSet(ctx), ctx)
5287
+ } catch (err) {
5288
+ return logError(err, ctx, finalize, wrapResponse)
5289
+ }
5290
+ }
5291
+
5292
+ const finalizeEarly = <T>(
5293
+ result: unknown,
5294
+ ctx: RawContext,
5295
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5296
+ wrapResponse: (response: Response | ResponseResult) => T,
5297
+ ): T => {
5298
+ try {
5299
+ return finalize(result, responseSet(ctx), ctx)
5300
+ } catch (err) {
5301
+ return logError(err, ctx, finalize, wrapResponse)
5302
+ }
5303
+ }
5304
+
5305
+ const runHandler = <T>(
5306
+ ctx: RawContext,
5307
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5308
+ wrapResponse: (response: Response | ResponseResult) => T,
5309
+ ): MaybePromise<T> => {
5310
+ let result: unknown
5311
+ try {
5312
+ result = handler(ctx)
5313
+ } catch (err) {
5314
+ return logError(err, ctx, finalize, wrapResponse)
5315
+ }
5316
+ if (result instanceof Promise) {
5317
+ return result.then(
5318
+ (value) => runFinish(value, ctx, finalize, wrapResponse),
5319
+ (err) => logError(err, ctx, finalize, wrapResponse),
5320
+ )
5321
+ }
5322
+ return runFinish(result, ctx, finalize, wrapResponse)
5323
+ }
5324
+
5325
+ const runBefore = <T>(
5326
+ ctx: RawContext,
5327
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5328
+ wrapResponse: (response: Response | ResponseResult) => T,
5329
+ ): MaybePromise<T> => {
5330
+ let early: unknown
5331
+ try {
5332
+ early = before(ctx)
5333
+ } catch (err) {
5334
+ return logError(err, ctx, finalize, wrapResponse)
5335
+ }
5336
+ if (early instanceof Promise) {
5337
+ return early.then(
5338
+ (settled) =>
5339
+ settled === undefined
5340
+ ? runHandler(ctx, finalize, wrapResponse)
5341
+ : finalizeEarly(settled, ctx, finalize, wrapResponse),
5342
+ (err) => logError(err, ctx, finalize, wrapResponse),
5343
+ )
5344
+ }
5345
+ if (early === undefined) return runHandler(ctx, finalize, wrapResponse)
5346
+ return finalizeEarly(early, ctx, finalize, wrapResponse)
5347
+ }
5348
+
5349
+ const runDerive = <T>(
5350
+ ctx: RawContext,
5351
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5352
+ wrapResponse: (response: Response | ResponseResult) => T,
5353
+ ): MaybePromise<T> => {
5354
+ let derived: unknown
5355
+ try {
5356
+ derived = derive(ctx)
5357
+ } catch (err) {
5358
+ return logError(err, ctx, finalize, wrapResponse)
5359
+ }
5360
+ if (derived instanceof Promise) {
5361
+ return derived.then(
5362
+ (settled) => {
5363
+ if (isResponseResult(settled) || settled instanceof Response) {
5364
+ return finalizeEarly(settled, ctx, finalize, wrapResponse)
5365
+ }
5366
+ Object.assign(ctx, settled as object)
5367
+ return runBefore(ctx, finalize, wrapResponse)
5368
+ },
5369
+ (err) => logError(err, ctx, finalize, wrapResponse),
5370
+ )
5371
+ }
5372
+ if (isResponseResult(derived) || derived instanceof Response) {
5373
+ return finalizeEarly(derived, ctx, finalize, wrapResponse)
5374
+ }
5375
+ Object.assign(ctx, derived as object)
5376
+ return runBefore(ctx, finalize, wrapResponse)
5377
+ }
5378
+
5379
+ const onParsed = <T>(
5380
+ parsed: unknown,
5381
+ ctx: RawContext,
5382
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5383
+ wrapResponse: (response: Response | ResponseResult) => T,
5384
+ ): MaybePromise<T> => {
5385
+ let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
5386
+ try {
5387
+ validation = bodySchema["~standard"].validate(parsed)
5388
+ } catch (err) {
5389
+ return logError(err, ctx, finalize, wrapResponse)
5390
+ }
5391
+ if (validation instanceof Promise) {
5392
+ return validation.then(
5393
+ (settled) => {
5394
+ if (settled.issues !== undefined)
5395
+ return wrapResponse(plainValidationError(settled.issues))
5396
+ ctx.body = settled.value
5397
+ return runDerive(ctx, finalize, wrapResponse)
5398
+ },
5399
+ (err) => logError(err, ctx, finalize, wrapResponse),
5400
+ )
5401
+ }
5402
+ if (validation.issues !== undefined)
5403
+ return wrapResponse(plainValidationError(validation.issues))
5404
+ ctx.body = validation.value
5405
+ return runDerive(ctx, finalize, wrapResponse)
5406
+ }
5407
+
5408
+ return <T>(
5409
+ source: RequestSource,
5410
+ params: Record<string, string>,
5411
+ search: string | undefined,
5412
+ signal: AbortSignal,
5413
+ budget: RequestBudget,
5414
+ platform: Platform | undefined,
5415
+ nativeContext: boolean,
5416
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5417
+ wrapResponse: (response: Response | ResponseResult) => T,
5418
+ ): MaybePromise<T> => {
5419
+ const ctx = nativeContext
5420
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
5421
+ : new RequestContext(
5422
+ source,
5423
+ params,
5424
+ search,
5425
+ signal,
5426
+ budget,
5427
+ platform,
5428
+ maxBodyBytes,
5429
+ this.protoPoisoning,
5430
+ )
5431
+ return readBodyFramed(
5432
+ source,
5433
+ maxBodyBytes,
5434
+ this.protoPoisoning,
5435
+ (parsed) => onParsed(parsed, ctx, finalize, wrapResponse),
5436
+ wrapResponse,
5437
+ (err) => logError(err, ctx, finalize, wrapResponse),
5438
+ bodySchema,
5439
+ )
5440
+ }
5441
+ }
5442
+
5443
+ private bareError<T>(
5444
+ err: unknown,
5445
+ ctx: RawContext,
5446
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5447
+ wrapResponse: (response: Response | ResponseResult) => T,
5448
+ ): T {
5449
+ // The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
5450
+ // lane and the fused body runner. `responseSet` and the logger are injected because they reach
5451
+ // into class state; the closure is allocated only on the throw path, never on the hot lane.
4224
5452
  return renderBareError(
4225
5453
  err,
4226
5454
  ctx,
4227
5455
  finalize,
4228
- wrapResponse,
4229
5456
  responseSet,
4230
5457
  (e, c) => this.logRequestError(e, c),
4231
5458
  () => this.internalErrorResponse(wrapResponse),
@@ -4275,19 +5502,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4275
5502
  // @ts-expect-error TS6133 -- invoked structurally by the general route program
4276
5503
  private validateProgramStage(
4277
5504
  entry: RouteEntry,
4278
- stage: Extract<RouteProgramStage, { kind: "headers" | "params" | "body" | "query" }>,
5505
+ stage: Extract<RouteProgramStage, { kind: ProgramValidationKind }>,
4279
5506
  source: RequestSource,
4280
5507
  ctx: RawContext,
4281
5508
  ): MaybePromise<Response | ResponseResult | undefined> {
5509
+ if (stage.kind === "body") return this.readProgramBody(entry, source, ctx)
4282
5510
  const input =
4283
5511
  stage.kind === "headers"
4284
5512
  ? headerObjectOf(source.headers)
4285
- : stage.kind === "params"
4286
- ? ctx.params
4287
- : stage.kind === "query"
4288
- ? queryObjectOf(ctx[CONTEXT_SEARCH])
4289
- : undefined
4290
- if (stage.kind === "body") return this.readProgramBody(entry, source, ctx)
5513
+ : stage.kind === "query"
5514
+ ? queryObjectOf(ctx[CONTEXT_SEARCH])
5515
+ : ctx[stage.kind]
4291
5516
  const validation = stage.schema["~standard"].validate(input)
4292
5517
  return validation instanceof Promise
4293
5518
  ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, stage.kind))
@@ -4362,6 +5587,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4362
5587
  (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
4363
5588
  wrapResponse,
4364
5589
  (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
5590
+ entry.schema?.body,
4365
5591
  )
4366
5592
  }
4367
5593
 
@@ -4726,7 +5952,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4726
5952
  try {
4727
5953
  const contract = entry.responseContract
4728
5954
  if (contract === undefined) return finalize(result, responseSet(ctx))
4729
- const checked = contract.runtime.check(contract.schema, result)
5955
+ const checked = contract.runtime.check(contract.definition, result, ctx)
4730
5956
  if (checked instanceof Promise) {
4731
5957
  return checked.then(
4732
5958
  (outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome),
@@ -4780,13 +6006,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4780
6006
  wrapResponse: (response: Response | ResponseResult) => T,
4781
6007
  ): MaybePromise<T> {
4782
6008
  // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
4783
- // an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
4784
- // or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
4785
- // any handler or loader.
4786
- if (err instanceof Response) return wrapResponse(err)
6009
+ // an action throws an error page. Send it as the same Response returned would be sent (Remix/
6010
+ // SvelteKit semantics): as built, plus the cookies the request queued, so a session set or
6011
+ // cleared before `throw redirect(...)` / `requireSession(...)` ships. Don't run onError or log
6012
+ // it as a 500.
6013
+ if (err instanceof Response) return finalize(err, responseSet(ctx))
4787
6014
  // Same rule for a thrown `status(...)`, but through `finalize` rather than `wrapResponse`: the
4788
6015
  // value is still plain data, so the ordinary JSON lane renders it and no `Response` is built.
4789
- if (isResponseResult(err)) return finalize(err, responseSet(ctx))
6016
+ if (isResponseResult(err))
6017
+ return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, err)
4790
6018
  if (entry.onError.length === 0) {
4791
6019
  // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
4792
6020
  // (redacting) logger. Body-read failures and around-hook failures land here too.
@@ -4846,6 +6074,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4846
6074
  validate,
4847
6075
  (response) => response,
4848
6076
  (error) => Promise.reject(error),
6077
+ bodySchema,
4849
6078
  )
4850
6079
  }
4851
6080
 
@@ -4855,13 +6084,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4855
6084
  entry: RouteEntry,
4856
6085
  result: StandardResult<unknown>,
4857
6086
  ctx: RawContext,
4858
- kind: "body" | "query" | "params" | "headers",
6087
+ kind: ProgramValidationKind,
4859
6088
  ): MaybePromise<Response | ResponseResult | undefined> {
4860
6089
  const assign = (value: unknown): void => {
4861
- if (kind === "body") ctx.body = value
4862
- else if (kind === "query") ctx.query = value
4863
- else if (kind === "headers") ctx.headers = value as Record<string, string>
4864
- else ctx.params = value as Record<string, string>
6090
+ ;(ctx as unknown as Record<ProgramValidationKind, unknown>)[kind] = value
4865
6091
  }
4866
6092
  if (result.issues === undefined) {
4867
6093
  assign(result.value)
@@ -4880,22 +6106,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4880
6106
 
4881
6107
  private finishLifecycleValidationRecovery(
4882
6108
  entry: RouteEntry,
4883
- kind: "body" | "query" | "params" | "headers",
6109
+ kind: ProgramValidationKind,
4884
6110
  issues: ReadonlyArray<StandardIssue>,
4885
6111
  recovery: unknown,
4886
6112
  assign: (value: unknown) => void,
4887
6113
  ): MaybePromise<Response | ResponseResult | undefined> {
4888
6114
  if (recovery === undefined) return plainValidationError(issues)
4889
6115
  if (recovery instanceof Response) return recovery
4890
- const schema =
4891
- kind === "body"
4892
- ? entry.schema?.body
4893
- : kind === "query"
4894
- ? entry.schema?.query
4895
- : kind === "params"
4896
- ? entry.schema?.params
4897
- : entry.schema?.headers
4898
- const retried = schema!["~standard"].validate(recovery)
6116
+ const retried = entry.schema![kind]!["~standard"].validate(recovery)
4899
6117
  if (retried instanceof Promise) {
4900
6118
  return retried.then((settled) => {
4901
6119
  if (settled.issues !== undefined) return plainValidationError(settled.issues)
@@ -4916,6 +6134,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4916
6134
  fused: FusedWebRunner | undefined,
4917
6135
  signal: AbortSignal | undefined,
4918
6136
  budget: RequestBudget | undefined,
6137
+ peer: PeerPlatform,
6138
+ fallback: BunNativeHandler,
4919
6139
  ): BunNativeHandler {
4920
6140
  // This callback is reached only from Bun's compiled native route table. Bun has already parsed
4921
6141
  // the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
@@ -4924,7 +6144,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4924
6144
  // The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
4925
6145
  // marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
4926
6146
  // reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
4927
- const bodyLimit = entry.schema?.body === undefined ? entry.bodyLimit : undefined
6147
+ const bodyLimit =
6148
+ entry.schema?.body === undefined || entry.program.authBeforeValidation
6149
+ ? entry.bodyLimit
6150
+ : undefined
4928
6151
  const inner = fused
4929
6152
  const capped: FusedWebRunner | undefined =
4930
6153
  inner === undefined || bodyLimit === undefined
@@ -4934,68 +6157,90 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4934
6157
  if (method !== "GET" && method !== "HEAD") markTransportCap(source, bodyLimit)
4935
6158
  return inner(source, params, search, fusedSignal, fusedBudget, platform, nativeContext)
4936
6159
  }
6160
+ const nativeResponseHooks = this.onResponseHooks.length > 0 && this.bunNativeResponseHeadersOnly
6161
+ const finishNative = (request: Request, response: Response): MaybePromise<Response> => {
6162
+ if (!nativeResponseHooks) return response
6163
+ return this.applyOnResponseAndFinalize(response, request)
6164
+ }
6165
+ const runNative = (
6166
+ request: Request,
6167
+ params: Record<string, string>,
6168
+ ): MaybePromise<Response> => {
6169
+ const outcome = capped!(request, params, undefined, signal!, budget!, peer, true)
6170
+ return outcome instanceof Promise
6171
+ ? outcome.then((response) => finishNative(request, response))
6172
+ : finishNative(request, outcome)
6173
+ }
4937
6174
  if (paramNames.length === 0) {
4938
6175
  if (capped === undefined) {
4939
6176
  return (request) => {
4940
6177
  markFramed(request)
4941
- return this.fetchMatched(request, entry, EMPTY_PARAMS)
6178
+ return this.fetchMatched(request, entry, EMPTY_PARAMS, peer)
4942
6179
  }
4943
6180
  }
4944
6181
  if (this.acceptInboundDeadlines) {
4945
6182
  return (request) => {
4946
6183
  markFramed(request)
4947
6184
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
4948
- ? this.fetchMatched(request, entry, EMPTY_PARAMS)
4949
- : capped(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
6185
+ ? this.fetchMatched(request, entry, EMPTY_PARAMS, peer)
6186
+ : capped(request, EMPTY_PARAMS, undefined, signal!, budget!, peer, true)
4950
6187
  }
4951
6188
  }
4952
6189
  return (request) => {
4953
6190
  markFramed(request)
4954
- return capped(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
6191
+ return runNative(request, EMPTY_PARAMS)
4955
6192
  }
4956
6193
  }
4957
6194
 
4958
6195
  const malformed =
4959
6196
  paramNames.length === 1
4960
- ? (params: Record<string, string>) => params[paramNames[0]!]?.includes("\uFFFD") === true
4961
- : hasReplacementParam
6197
+ ? (params: Record<string, string>) => unroutedParam(params[paramNames[0]!])
6198
+ : hasUnroutedParam
4962
6199
  if (capped === undefined) {
4963
- return (request) => {
6200
+ return (request, server) => {
4964
6201
  markFramed(request)
4965
6202
  const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
4966
- if (malformed(params)) return this.fetchSource(request)
4967
- return this.fetchMatched(request, entry, params)
6203
+ if (malformed(params)) return fallback(request, server)
6204
+ return this.fetchMatched(request, entry, params, peer)
4968
6205
  }
4969
6206
  }
4970
6207
  if (this.acceptInboundDeadlines) {
4971
- return (request) => {
6208
+ return (request, server) => {
4972
6209
  markFramed(request)
4973
6210
  const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
4974
- if (malformed(params)) return this.fetchSource(request)
6211
+ if (malformed(params)) return fallback(request, server)
4975
6212
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
4976
- ? this.fetchMatched(request, entry, params)
4977
- : capped(request, params, undefined, signal!, budget!, undefined, true)
6213
+ ? this.fetchMatched(request, entry, params, peer)
6214
+ : capped(request, params, undefined, signal!, budget!, peer, true)
4978
6215
  }
4979
6216
  }
4980
- return (request) => {
6217
+ return (request, server) => {
4981
6218
  markFramed(request)
4982
6219
  const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
4983
- if (malformed(params)) return this.fetchSource(request)
4984
- return capped(request, params, undefined, signal!, budget!, undefined, true)
6220
+ if (malformed(params)) return fallback(request, server)
6221
+ return runNative(request, params)
4985
6222
  }
4986
6223
  }
4987
6224
 
4988
6225
  /** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
4989
6226
  * hooks or WebSockets retain the single portable dispatcher because those features must run before
4990
- * route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
4991
- * capture semantics; static and `:param` routes take the native lane. */
4992
- private buildBunNativeRoutes(): BunNativeRoutes | undefined {
6227
+ * route selection/upgrade.
6228
+ *
6229
+ * Bun and the portable router agree on which of two static-and-`:param` paths wins a request, so
6230
+ * the table is kept to those paths. Where Bun would otherwise pass a request on to a less specific
6231
+ * path, the more specific one carries `fallback`, the portable dispatcher, under that method.
6232
+ * `peer` is the platform every request served from the table shares. */
6233
+ private buildBunNativeRoutes(
6234
+ fallback: BunNativeHandler,
6235
+ peer: PeerPlatform,
6236
+ ): BunNativeRoutes | undefined {
4993
6237
  // A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
4994
6238
  // bypasses - so an app that declares trust routes through the fetch lane (where `c.clientIp`
4995
6239
  // resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
4996
6240
  if (
4997
- this.onRequestHooks.length > 0 ||
6241
+ (this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe) ||
4998
6242
  this.wsRouteCount > 0 ||
6243
+ this.preRouteMountCount > 0 ||
4999
6244
  this.clientIpTrust !== undefined
5000
6245
  ) {
5001
6246
  return undefined
@@ -5004,7 +6249,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5004
6249
  const routes: BunNativeRoutes = Object.create(null) as BunNativeRoutes
5005
6250
  const mayUseFusedNative =
5006
6251
  this.requestTimeoutMs === 0 &&
5007
- this.onResponseHooks.length === 0 &&
6252
+ (this.onResponseHooks.length === 0 || this.bunNativeResponseHeadersOnly) &&
5008
6253
  this.onResponseFinalizedHooks.length === 0 &&
5009
6254
  // The capacity gate must wrap every request; the fused lane bypasses fetchMatched, so enabling
5010
6255
  // admission drops fusion (native matching stays) and routes through the gated matched lane.
@@ -5012,23 +6257,75 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5012
6257
  const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined
5013
6258
  const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined
5014
6259
  let count = 0
5015
- for (const { method, path, pattern, entry } of this.catalog.entries()) {
5016
- if (pattern.segments.some((segment) => segment.kind === "wildcard")) continue
5017
- let methods = routes[path]
5018
- if (methods === undefined) {
5019
- methods = Object.create(null) as BunNativeMethodTable
5020
- routes[path] = methods
6260
+ const all = this.catalog.entries()
6261
+ // A path with a wildcard or a part-literal segment cannot go in the table: Bun reads `/:name.json`
6262
+ // as one parameter named `name.json`. These are filed under each run of static segments they
6263
+ // start with, which is where a table path they could outrank looks for them.
6264
+ const under = new Map<string, CatalogRoute[]>()
6265
+ const lead = all.map((route) => {
6266
+ const segments = route.pattern.segments
6267
+ const plain = segments.every(
6268
+ (segment) => segment.kind === "static" || segment.kind === "param",
6269
+ )
6270
+ let prefix = ""
6271
+ for (const segment of segments) {
6272
+ if (!plain) {
6273
+ let bucket = under.get(prefix)
6274
+ if (bucket === undefined) {
6275
+ bucket = []
6276
+ under.set(prefix, bucket)
6277
+ }
6278
+ bucket.push(route)
6279
+ }
6280
+ if (segment.kind !== "static") break
6281
+ prefix += `/${segment.value}`
6282
+ }
6283
+ return plain ? prefix : undefined
6284
+ })
6285
+ // A preflight hook is intentionally handled by the fallback fetch path. Keeping OPTIONS out
6286
+ // of the native lane means Bun dispatches it through CORS's onRequest hook even when the
6287
+ // same path has native GET/POST handlers.
6288
+ const preflightOnFallback = this.onRequestHooks.length > 0 && this.bunNativeRequestHooksSafe
6289
+ // Where Bun could pass a request down: the methods served natively on a path with a parameter,
6290
+ // as segment count + method. Two table paths match one request only at the same segment count.
6291
+ const below = new Set<string>()
6292
+ const tableAt = (path: string): BunNativeMethodTable => {
6293
+ routes[path] ??= Object.create(null) as BunNativeMethodTable
6294
+ return routes[path]
6295
+ }
6296
+ for (let i = 0; i < all.length; i++) {
6297
+ const { method, path, pattern, entry } = all[i]!
6298
+ const run = lead[i]
6299
+ // The route is served from the table unless its path cannot be there, Bun's table has no slot
6300
+ // for its method, or a path outside the table outranks it: Bun would hand this route a request
6301
+ // the portable router gives to that one. An idempotent route stays off it too: its lanes run on
6302
+ // a buffered copy of the request, and Bun names the peer only of the request it delivered, so
6303
+ // the platform the table shares could not answer `c.clientIp` there.
6304
+ if (
6305
+ run === undefined ||
6306
+ entry.idempotent !== undefined ||
6307
+ !METHODS.includes(method as Method) ||
6308
+ (preflightOnFallback && method === "OPTIONS") ||
6309
+ under.get(run)?.some((other) => outranks(other.pattern.segments, pattern.segments))
6310
+ ) {
6311
+ continue
5021
6312
  }
5022
6313
  const paramNames = pattern.paramNames
5023
6314
  const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined
5024
- methods[method] = this.compileBunNativeHandler(
6315
+ tableAt(path)[method as Method] = this.compileBunNativeHandler(
5025
6316
  entry,
5026
6317
  paramNames,
5027
6318
  fused,
5028
6319
  unboundedSignal,
5029
6320
  unboundedBudget,
6321
+ peer,
6322
+ fallback,
5030
6323
  )
5031
6324
  count += 1
6325
+ if (paramNames.length > 0) {
6326
+ below.add(pattern.segments.length + method)
6327
+ if (method === "GET") below.add(`${pattern.segments.length}HEAD`)
6328
+ }
5032
6329
  }
5033
6330
  if (count === 0) return undefined
5034
6331
  // RFC 9110 §9.3.2: a GET route answers HEAD with identical status + headers (Bun strips the
@@ -5039,6 +6336,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5039
6336
  const methods = routes[path]!
5040
6337
  if (methods.GET !== undefined) methods.HEAD ??= methods.GET
5041
6338
  }
6339
+ // Bun picks among the paths its table holds for the request's method, so a path with no entry
6340
+ // for the method lets the request through to a less specific one. The portable router stops at
6341
+ // the most specific path and answers 405. Every path the table could hold therefore gets the
6342
+ // portable dispatcher under each method a less specific path might serve.
6343
+ for (let i = 0; i < all.length; i++) {
6344
+ if (lead[i] === undefined) continue
6345
+ const { path, pattern } = all[i]!
6346
+ for (const method of METHODS) {
6347
+ if (below.has(pattern.segments.length + method)) tableAt(path)[method] ??= fallback
6348
+ }
6349
+ }
5042
6350
  return routes
5043
6351
  }
5044
6352
 
@@ -5063,6 +6371,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5063
6371
  * connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
5064
6372
  * this above their slowest expected response, which is why it is a first-class option and not a
5065
6373
  * reason to drop down to `Bun.serve`. `0` disables the timeout entirely; max 255.
6374
+ *
6375
+ * `tls` serves HTTPS directly: `app.listen(443, { tls: { cert, key } })` with PEM text or bytes.
6376
+ * Requests then arrive with `https:` URLs. Behind a proxy or a platform that terminates TLS, leave
6377
+ * it unset.
5066
6378
  */
5067
6379
  listen(
5068
6380
  port: number,
@@ -5070,6 +6382,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5070
6382
  readonly reusePort?: boolean
5071
6383
  readonly hostname?: string
5072
6384
  readonly idleTimeoutSec?: number
6385
+ readonly tls?: ListenTlsOptions
5073
6386
  },
5074
6387
  ): RunningServer {
5075
6388
  if (typeof Bun === "undefined") {
@@ -5097,34 +6410,63 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5097
6410
  // because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
5098
6411
  // Native pub/sub when the app has WS routes and none validate outbound frames: `ws.subscribe` and
5099
6412
  // `app.publish` go through Bun's own (uWebSockets) broadcast instead of the JS registry loop.
5100
- const nativePubsub = this.wsRouteCount > 0 && !this.wsHasValidatedSend
5101
- const wsHandlers =
5102
- this.wsRouteCount === 0
5103
- ? undefined
5104
- : (this.wsRuntime as WsRuntime).bunHandlers(this.topics as TopicRegistry, nativePubsub)
5105
- const reusePort = options?.reusePort === true
5106
- // Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
5107
- // on some option paths, and omitting is what selects its 0.0.0.0 default.
5108
- const bind = options?.hostname === undefined ? {} : { hostname: options.hostname }
5109
- // Same reasoning as `bind`: omit rather than pass undefined, so Bun's own default applies.
5110
- const idle =
5111
- options?.idleTimeoutSec === undefined ? {} : { idleTimeout: options.idleTimeoutSec }
5112
- const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined
6413
+ // A mounted app counts only while it has a WebSocket route (see `[GET_WS_RUNTIME]`), so composing
6414
+ // one without any needs no runtime here.
6415
+ const wsRuntime = this[GET_WS_RUNTIME]()
6416
+ if (wsRuntime === null) {
6417
+ throw new FrameworkError(
6418
+ "INVALID_WS_RUNTIME",
6419
+ "a Bun server with mounted WebSocket routes needs the websocket() runtime installed on the parent or child app",
6420
+ )
6421
+ }
6422
+ // Native pub/sub is safe only when every upgrade belongs to this app. A mounted child that takes
6423
+ // upgrades owns a different TopicRegistry, so force the per-connection JS dispatcher and carry
6424
+ // that registry in the upgrade outcome instead of accidentally broadcasting child sockets through
6425
+ // the parent.
6426
+ const nativePubsub =
6427
+ this.wsRouteCount > 0 && !this.mountsTakeUpgrades() && !this.wsHasValidatedSend
6428
+ const wsHandlers = wsRuntime?.bunHandlers(this.topics ?? wsRuntime.createTopics(), nativePubsub)
6429
+ // Spread rather than pass `hostname: undefined` (or an undefined idle timeout or TLS) - Bun treats
6430
+ // an explicit undefined as a value on some option paths, and omitting is what selects its own
6431
+ // defaults, 0.0.0.0 among them.
6432
+ const listening = {
6433
+ port,
6434
+ reusePort: options?.reusePort === true,
6435
+ ...(options?.hostname === undefined ? {} : { hostname: options.hostname }),
6436
+ ...(options?.idleTimeoutSec === undefined ? {} : { idleTimeout: options.idleTimeoutSec }),
6437
+ ...(options?.tls === undefined ? {} : { tls: options.tls }),
6438
+ // A hook that throws rejects `fetch`; Bun's own answer to that is a development page carrying
6439
+ // the message, stack and source whenever NODE_ENV is not "production".
6440
+ error: (err: unknown) => {
6441
+ emitRequestErrorLog(this.logger, this.errorLogDetail, err, undefined)
6442
+ return jsonError(500, "internal_error")
6443
+ },
6444
+ }
6445
+ const fallback: BunNativeHandler = (req, server) => {
6446
+ // Bun has already framed and bounded this request body in its HTTP parser. Mark the
6447
+ // source before the portable fallback runs so body schemas use Bun's native `json()`
6448
+ // reader instead of the defensive arrayBuffer/decode path reserved for caller-built
6449
+ // Requests. Native route handlers mark themselves in compileBunNativeHandler; this
6450
+ // covers every request the native lane does not serve.
6451
+ markTrustedBodyFraming(req)
6452
+ return this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>)
6453
+ }
6454
+ // Bun names the socket peer of a request when its server is asked. A request served from the
6455
+ // native table carries this one platform, which asks only when a handler reads `c.clientIp`.
6456
+ const peer: PeerPlatform = {
6457
+ [PLATFORM_PEER]: (request) =>
6458
+ (running as unknown as BunPeerServer).requestIP(request as Request)?.address,
6459
+ }
6460
+ const nativeRoutes =
6461
+ wsHandlers === undefined ? this.buildBunNativeRoutes(fallback, peer) : undefined
5113
6462
  const running = (wsHandlers === undefined
5114
6463
  ? Bun.serve({
5115
- port,
5116
- reusePort,
5117
- ...bind,
5118
- ...idle,
6464
+ ...listening,
5119
6465
  ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
5120
- fetch: (req: Request, server) =>
5121
- this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>),
6466
+ fetch: fallback,
5122
6467
  })
5123
6468
  : Bun.serve<BunWsData>({
5124
- port,
5125
- reusePort,
5126
- ...bind,
5127
- ...idle,
6469
+ ...listening,
5128
6470
  fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
5129
6471
  // Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
5130
6472
  // `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
@@ -5140,7 +6482,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5140
6482
  })) as unknown as RunningServer
5141
6483
  this.bunServer = running
5142
6484
  // Bind `app.publish` to Bun's native broadcast now that the server handle exists. Guarded on the
5143
- // method's presence so a runtime whose handle lacks it simply keeps the registry path.
6485
+ // method's presence so a runtime whose handle lacks it simply keeps the registry path. Cleared
6486
+ // first: a mounted app may have gained a WebSocket route since an earlier `listen()`.
6487
+ this.nativePublish = undefined
5144
6488
  if (nativePubsub && typeof running.publish === "function") {
5145
6489
  const native = running.publish.bind(running)
5146
6490
  this.nativePublish = (topic, data) => {