@nifrajs/core 3.3.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (331) hide show
  1. package/README.md +26 -0
  2. package/dist/assurance.d.ts +7 -0
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +39 -3
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +20 -2
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +47 -15
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/diff.d.ts +6 -1
  11. package/dist/diff.d.ts.map +1 -1
  12. package/dist/diff.js +36 -2
  13. package/dist/diff.js.map +1 -1
  14. package/dist/durable-adapters.d.ts.map +1 -1
  15. package/dist/durable-adapters.js +16 -3
  16. package/dist/durable-adapters.js.map +1 -1
  17. package/dist/durable-effect.d.ts.map +1 -1
  18. package/dist/durable-effect.js +6 -3
  19. package/dist/durable-effect.js.map +1 -1
  20. package/dist/edge-kit.d.ts +2 -0
  21. package/dist/edge-kit.d.ts.map +1 -1
  22. package/dist/edge-kit.js +2 -0
  23. package/dist/edge-kit.js.map +1 -1
  24. package/dist/effect-lifecycle.d.ts.map +1 -1
  25. package/dist/effect-lifecycle.js +2 -1
  26. package/dist/effect-lifecycle.js.map +1 -1
  27. package/dist/effect-scope.d.ts.map +1 -1
  28. package/dist/effect-scope.js +1 -1
  29. package/dist/effect-scope.js.map +1 -1
  30. package/dist/errors.d.ts +1 -1
  31. package/dist/errors.d.ts.map +1 -1
  32. package/dist/errors.js.map +1 -1
  33. package/dist/evidence.d.ts +36 -1
  34. package/dist/evidence.d.ts.map +1 -1
  35. package/dist/evidence.js +219 -13
  36. package/dist/evidence.js.map +1 -1
  37. package/dist/idempotency.d.ts +31 -0
  38. package/dist/idempotency.d.ts.map +1 -1
  39. package/dist/idempotency.js +48 -9
  40. package/dist/idempotency.js.map +1 -1
  41. package/dist/index.d.ts +9 -6
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +6 -3
  44. package/dist/index.js.map +1 -1
  45. package/dist/internal/code-unit-order.d.ts +7 -0
  46. package/dist/internal/code-unit-order.d.ts.map +1 -0
  47. package/dist/internal/code-unit-order.js +9 -0
  48. package/dist/internal/code-unit-order.js.map +1 -0
  49. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  50. package/dist/internal/durable-saga-protocol.js +2 -0
  51. package/dist/internal/durable-saga-protocol.js.map +1 -1
  52. package/dist/internal/route-catalog.d.ts +6 -6
  53. package/dist/internal/route-catalog.d.ts.map +1 -1
  54. package/dist/internal/route-catalog.js +20 -18
  55. package/dist/internal/route-catalog.js.map +1 -1
  56. package/dist/internal/route-compiler.d.ts +7 -6
  57. package/dist/internal/route-compiler.d.ts.map +1 -1
  58. package/dist/internal/route-compiler.js +36 -4
  59. package/dist/internal/route-compiler.js.map +1 -1
  60. package/dist/internal/route-execution.d.ts +7 -3
  61. package/dist/internal/route-execution.d.ts.map +1 -1
  62. package/dist/internal/route-execution.js.map +1 -1
  63. package/dist/internal/route-lanes.d.ts +1 -0
  64. package/dist/internal/route-lanes.d.ts.map +1 -1
  65. package/dist/internal/route-lanes.js +34 -29
  66. package/dist/internal/route-lanes.js.map +1 -1
  67. package/dist/internal/route-program.d.ts +8 -2
  68. package/dist/internal/route-program.d.ts.map +1 -1
  69. package/dist/internal/route-program.js +222 -20
  70. package/dist/internal/route-program.js.map +1 -1
  71. package/dist/ledger.d.ts.map +1 -1
  72. package/dist/ledger.js +8 -5
  73. package/dist/ledger.js.map +1 -1
  74. package/dist/manifest.d.ts +1 -1
  75. package/dist/manifest.d.ts.map +1 -1
  76. package/dist/manifest.js +52 -46
  77. package/dist/manifest.js.map +1 -1
  78. package/dist/mount.d.ts +36 -0
  79. package/dist/mount.d.ts.map +1 -1
  80. package/dist/mount.js +32 -0
  81. package/dist/mount.js.map +1 -1
  82. package/dist/reflection.d.ts +26 -0
  83. package/dist/reflection.d.ts.map +1 -1
  84. package/dist/reflection.js +65 -1
  85. package/dist/reflection.js.map +1 -1
  86. package/dist/response-observer.d.ts +1 -1
  87. package/dist/response-observer.d.ts.map +1 -1
  88. package/dist/response-observer.js.map +1 -1
  89. package/dist/router/overlap.d.ts +23 -0
  90. package/dist/router/overlap.d.ts.map +1 -0
  91. package/dist/router/overlap.js +224 -0
  92. package/dist/router/overlap.js.map +1 -0
  93. package/dist/router/pattern.d.ts +117 -2
  94. package/dist/router/pattern.d.ts.map +1 -1
  95. package/dist/router/pattern.js +283 -23
  96. package/dist/router/pattern.js.map +1 -1
  97. package/dist/router/router.d.ts +18 -4
  98. package/dist/router/router.d.ts.map +1 -1
  99. package/dist/router/router.js +51 -44
  100. package/dist/router/router.js.map +1 -1
  101. package/dist/server/auth.d.ts +53 -0
  102. package/dist/server/auth.d.ts.map +1 -0
  103. package/dist/server/auth.js +9 -0
  104. package/dist/server/auth.js.map +1 -0
  105. package/dist/server/bare-error-lane.d.ts +12 -7
  106. package/dist/server/bare-error-lane.d.ts.map +1 -1
  107. package/dist/server/bare-error-lane.js +13 -11
  108. package/dist/server/bare-error-lane.js.map +1 -1
  109. package/dist/server/body-lane.d.ts +20 -2
  110. package/dist/server/body-lane.d.ts.map +1 -1
  111. package/dist/server/body-lane.js +26 -7
  112. package/dist/server/body-lane.js.map +1 -1
  113. package/dist/server/body-parser.d.ts +68 -0
  114. package/dist/server/body-parser.d.ts.map +1 -0
  115. package/dist/server/body-parser.js +172 -0
  116. package/dist/server/body-parser.js.map +1 -0
  117. package/dist/server/body.d.ts.map +1 -1
  118. package/dist/server/body.js +62 -7
  119. package/dist/server/body.js.map +1 -1
  120. package/dist/server/client-ip.d.ts +8 -0
  121. package/dist/server/client-ip.d.ts.map +1 -1
  122. package/dist/server/client-ip.js +8 -0
  123. package/dist/server/client-ip.js.map +1 -1
  124. package/dist/server/context.d.ts +71 -6
  125. package/dist/server/context.d.ts.map +1 -1
  126. package/dist/server/contract.d.ts +15 -5
  127. package/dist/server/contract.d.ts.map +1 -1
  128. package/dist/server/contract.js +25 -4
  129. package/dist/server/contract.js.map +1 -1
  130. package/dist/server/cookies.d.ts +4 -1
  131. package/dist/server/cookies.d.ts.map +1 -1
  132. package/dist/server/cookies.js +40 -2
  133. package/dist/server/cookies.js.map +1 -1
  134. package/dist/server/core-copies.d.ts +2 -0
  135. package/dist/server/core-copies.d.ts.map +1 -0
  136. package/dist/server/core-copies.js +20 -0
  137. package/dist/server/core-copies.js.map +1 -0
  138. package/dist/server/edge.d.ts.map +1 -1
  139. package/dist/server/edge.js +2 -0
  140. package/dist/server/edge.js.map +1 -1
  141. package/dist/server/http.d.ts +11 -0
  142. package/dist/server/http.d.ts.map +1 -1
  143. package/dist/server/http.js +36 -0
  144. package/dist/server/http.js.map +1 -1
  145. package/dist/server/idempotency-lane.d.ts +6 -0
  146. package/dist/server/idempotency-lane.d.ts.map +1 -1
  147. package/dist/server/idempotency-lane.js +130 -73
  148. package/dist/server/idempotency-lane.js.map +1 -1
  149. package/dist/server/install.d.ts +4 -0
  150. package/dist/server/install.d.ts.map +1 -1
  151. package/dist/server/install.js +4 -0
  152. package/dist/server/install.js.map +1 -1
  153. package/dist/server/methods.d.ts +45 -0
  154. package/dist/server/methods.d.ts.map +1 -0
  155. package/dist/server/methods.js +37 -0
  156. package/dist/server/methods.js.map +1 -0
  157. package/dist/server/multipart.d.ts +63 -0
  158. package/dist/server/multipart.d.ts.map +1 -0
  159. package/dist/server/multipart.js +374 -0
  160. package/dist/server/multipart.js.map +1 -0
  161. package/dist/server/node-outcome-hook.d.ts +20 -0
  162. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  163. package/dist/server/node-outcome-hook.js +15 -6
  164. package/dist/server/node-outcome-hook.js.map +1 -1
  165. package/dist/server/not-found-answer.d.ts +40 -0
  166. package/dist/server/not-found-answer.d.ts.map +1 -0
  167. package/dist/server/not-found-answer.js +56 -0
  168. package/dist/server/not-found-answer.js.map +1 -0
  169. package/dist/server/not-found.d.ts +23 -0
  170. package/dist/server/not-found.d.ts.map +1 -0
  171. package/dist/server/not-found.js +81 -0
  172. package/dist/server/not-found.js.map +1 -0
  173. package/dist/server/plugin.d.ts +26 -1
  174. package/dist/server/plugin.d.ts.map +1 -1
  175. package/dist/server/plugin.js.map +1 -1
  176. package/dist/server/proto-guard.d.ts +18 -5
  177. package/dist/server/proto-guard.d.ts.map +1 -1
  178. package/dist/server/proto-guard.js +114 -9
  179. package/dist/server/proto-guard.js.map +1 -1
  180. package/dist/server/query.d.ts +7 -0
  181. package/dist/server/query.d.ts.map +1 -1
  182. package/dist/server/query.js +47 -0
  183. package/dist/server/query.js.map +1 -1
  184. package/dist/server/registry.d.ts +13 -2
  185. package/dist/server/registry.d.ts.map +1 -1
  186. package/dist/server/request-context.d.ts +11 -0
  187. package/dist/server/request-context.d.ts.map +1 -1
  188. package/dist/server/request-context.js +33 -19
  189. package/dist/server/request-context.js.map +1 -1
  190. package/dist/server/request-lineage.d.ts +8 -0
  191. package/dist/server/request-lineage.d.ts.map +1 -0
  192. package/dist/server/request-lineage.js +14 -0
  193. package/dist/server/request-lineage.js.map +1 -0
  194. package/dist/server/request-target.d.ts +31 -0
  195. package/dist/server/request-target.d.ts.map +1 -0
  196. package/dist/server/request-target.js +44 -0
  197. package/dist/server/request-target.js.map +1 -0
  198. package/dist/server/respond.d.ts +1 -1
  199. package/dist/server/respond.d.ts.map +1 -1
  200. package/dist/server/respond.js +16 -4
  201. package/dist/server/respond.js.map +1 -1
  202. package/dist/server/response-contract-lane.d.ts +19 -1
  203. package/dist/server/response-contract-lane.d.ts.map +1 -1
  204. package/dist/server/response-contract-lane.js +59 -14
  205. package/dist/server/response-contract-lane.js.map +1 -1
  206. package/dist/server/response-observer-runtime.d.ts +9 -5
  207. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  208. package/dist/server/response-observer-runtime.js +10 -6
  209. package/dist/server/response-observer-runtime.js.map +1 -1
  210. package/dist/server/runtime-core.d.ts +2 -0
  211. package/dist/server/runtime-core.d.ts.map +1 -1
  212. package/dist/server/runtime-core.js.map +1 -1
  213. package/dist/server/server-types.d.ts +55 -3
  214. package/dist/server/server-types.d.ts.map +1 -1
  215. package/dist/server/server.d.ts +194 -19
  216. package/dist/server/server.d.ts.map +1 -1
  217. package/dist/server/server.js +1342 -274
  218. package/dist/server/server.js.map +1 -1
  219. package/dist/server/sse.d.ts.map +1 -1
  220. package/dist/server/sse.js +10 -7
  221. package/dist/server/sse.js.map +1 -1
  222. package/dist/server/static-headers.d.ts +3 -0
  223. package/dist/server/static-headers.d.ts.map +1 -1
  224. package/dist/server/static-headers.js +19 -1
  225. package/dist/server/static-headers.js.map +1 -1
  226. package/dist/server/transport-lane.d.ts +8 -0
  227. package/dist/server/transport-lane.d.ts.map +1 -1
  228. package/dist/server/transport-lane.js +9 -8
  229. package/dist/server/transport-lane.js.map +1 -1
  230. package/dist/server/validation.d.ts.map +1 -1
  231. package/dist/server/validation.js +5 -1
  232. package/dist/server/validation.js.map +1 -1
  233. package/dist/server/websocket.d.ts +10 -3
  234. package/dist/server/websocket.d.ts.map +1 -1
  235. package/dist/server/websocket.js +7 -4
  236. package/dist/server/websocket.js.map +1 -1
  237. package/dist/server/ws-bun.d.ts +2 -0
  238. package/dist/server/ws-bun.d.ts.map +1 -1
  239. package/dist/server/ws-bun.js +6 -3
  240. package/dist/server/ws-bun.js.map +1 -1
  241. package/dist/server/ws-hook.d.ts +2 -1
  242. package/dist/server/ws-hook.d.ts.map +1 -1
  243. package/dist/server.d.ts +8 -5
  244. package/dist/server.d.ts.map +1 -1
  245. package/dist/server.js +5 -2
  246. package/dist/server.js.map +1 -1
  247. package/dist/single-copy-register.js +5 -0
  248. package/dist/single-copy-register.js.map +1 -1
  249. package/dist/single-copy.d.ts +46 -3
  250. package/dist/single-copy.d.ts.map +1 -1
  251. package/dist/single-copy.js +125 -12
  252. package/dist/single-copy.js.map +1 -1
  253. package/dist/tool-contract.d.ts +23 -0
  254. package/dist/tool-contract.d.ts.map +1 -1
  255. package/dist/tool-contract.js +37 -3
  256. package/dist/tool-contract.js.map +1 -1
  257. package/dist/transport-codec.d.ts.map +1 -1
  258. package/dist/transport-codec.js +2 -1
  259. package/dist/transport-codec.js.map +1 -1
  260. package/package.json +27 -2
  261. package/src/assurance.ts +52 -2
  262. package/src/capabilities.ts +81 -22
  263. package/src/diff.ts +54 -7
  264. package/src/durable-adapters.ts +20 -3
  265. package/src/durable-effect.ts +10 -3
  266. package/src/edge-kit.ts +7 -0
  267. package/src/effect-lifecycle.ts +5 -1
  268. package/src/effect-scope.ts +2 -1
  269. package/src/errors.ts +3 -0
  270. package/src/evidence.ts +302 -12
  271. package/src/idempotency.ts +80 -9
  272. package/src/index.ts +41 -3
  273. package/src/internal/code-unit-order.ts +8 -0
  274. package/src/internal/durable-saga-protocol.ts +2 -0
  275. package/src/internal/route-catalog.ts +19 -18
  276. package/src/internal/route-compiler.ts +62 -13
  277. package/src/internal/route-execution.ts +13 -3
  278. package/src/internal/route-lanes.ts +37 -28
  279. package/src/internal/route-program.ts +434 -20
  280. package/src/ledger.ts +11 -5
  281. package/src/manifest.ts +52 -46
  282. package/src/mount.ts +62 -0
  283. package/src/reflection.ts +84 -1
  284. package/src/response-observer.ts +1 -0
  285. package/src/router/overlap.ts +258 -0
  286. package/src/router/pattern.ts +349 -25
  287. package/src/router/router.ts +76 -51
  288. package/src/server/auth.ts +75 -0
  289. package/src/server/bare-error-lane.ts +12 -11
  290. package/src/server/body-lane.ts +46 -6
  291. package/src/server/body-parser.ts +241 -0
  292. package/src/server/body.ts +82 -9
  293. package/src/server/client-ip.ts +9 -0
  294. package/src/server/context.ts +219 -12
  295. package/src/server/contract.ts +54 -9
  296. package/src/server/cookies.ts +34 -2
  297. package/src/server/core-copies.ts +21 -0
  298. package/src/server/edge.ts +2 -0
  299. package/src/server/http.ts +34 -0
  300. package/src/server/idempotency-lane.ts +147 -72
  301. package/src/server/install.ts +6 -0
  302. package/src/server/methods.ts +143 -0
  303. package/src/server/multipart.ts +424 -0
  304. package/src/server/node-outcome-hook.ts +40 -7
  305. package/src/server/not-found-answer.ts +109 -0
  306. package/src/server/not-found.ts +121 -0
  307. package/src/server/plugin.ts +45 -1
  308. package/src/server/proto-guard.ts +128 -9
  309. package/src/server/query.ts +49 -0
  310. package/src/server/registry.ts +17 -2
  311. package/src/server/request-context.ts +44 -16
  312. package/src/server/request-lineage.ts +15 -0
  313. package/src/server/request-target.ts +46 -0
  314. package/src/server/respond.ts +14 -3
  315. package/src/server/response-contract-lane.ts +97 -16
  316. package/src/server/response-observer-runtime.ts +37 -8
  317. package/src/server/runtime-core.ts +2 -0
  318. package/src/server/server-types.ts +61 -2
  319. package/src/server/server.ts +1798 -355
  320. package/src/server/sse.ts +10 -8
  321. package/src/server/static-headers.ts +22 -1
  322. package/src/server/transport-lane.ts +17 -9
  323. package/src/server/validation.ts +7 -1
  324. package/src/server/websocket.ts +23 -9
  325. package/src/server/ws-bun.ts +8 -3
  326. package/src/server/ws-hook.ts +2 -1
  327. package/src/server.ts +39 -2
  328. package/src/single-copy-register.ts +5 -0
  329. package/src/single-copy.ts +160 -12
  330. package/src/tool-contract.ts +66 -3
  331. package/src/transport-codec.ts +2 -1
@@ -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,12 +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" }
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
329
418
 
330
419
  /** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
331
420
  function requireWsRuntime(runtime: WsRuntime | undefined): WsRuntime {
@@ -407,10 +496,13 @@ export type {
407
496
  AdmissionController,
408
497
  AdmissionDecision,
409
498
  FetchHandler,
499
+ ListenTlsOptions,
410
500
  McpPromptDescriptor,
411
501
  McpResourceDescriptor,
412
502
  Middleware,
503
+ MountableApp,
413
504
  MountFetchOptions,
505
+ MountOptions,
414
506
  PromptArgument,
415
507
  PromptMessage,
416
508
  ResponseFinalization,
@@ -435,8 +527,10 @@ export {
435
527
  defineIdentityPlugin,
436
528
  definePlugin,
437
529
  defineRouterPlugin,
530
+ type MethodRoutesPlugin,
438
531
  type NifraPlugin,
439
532
  type PluginTypeCollapsed,
533
+ type WithMethodRoutes,
440
534
  } from "./plugin.ts"
441
535
  export type { IdentityPlugin }
442
536
 
@@ -461,13 +555,31 @@ export { plainValidationError } from "./validation.ts"
461
555
  // importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
462
556
  export { type QueryValue, queryObjectOf, searchOf }
463
557
 
464
- 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 {
465
568
  for (const key in params) {
466
- if (params[key]!.includes("\uFFFD")) return true
569
+ if (unroutedParam(params[key])) return true
467
570
  }
468
571
  return false
469
572
  }
470
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
+
471
583
  function normalizeMountPrefix(path: string): string {
472
584
  if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
473
585
  throw new TypeError("mountFetch path must be an absolute pathname without a query or hash")
@@ -486,6 +598,18 @@ function underMountPrefix(pathname: string, prefix: string): boolean {
486
598
  return prefix === "/" || pathname === prefix || pathname.startsWith(`${prefix}/`)
487
599
  }
488
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
+
489
613
  function stripMountPrefix(request: Request, prefix: string): Request {
490
614
  if (prefix === "/") return request
491
615
  const url = new URL(request.url)
@@ -535,6 +659,88 @@ export type NifraFeatureVersion = FeatureVersionOf<Version>
535
659
  const webResponseOf = (result: Response | ResponseResult): Response =>
536
660
  result instanceof Response ? result : toResponse(result, EMPTY_RESPONSE_CONTROLS)
537
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
+
538
744
  // Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
539
745
  const IDENTITY_RESPONSE = (response: Response | ResponseResult): Response => webResponseOf(response)
540
746
  const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
@@ -660,6 +866,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
660
866
  /** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
661
867
  private readonly wsRouter: Router<WsEntry>
662
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
663
873
  /** In-process pub/sub backing `ws.subscribe(topic)` + `app.publish(topic, data)` (single-instance).
664
874
  * Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime - `undefined` until then, so a
665
875
  * no-WebSocket app never constructs (or bundles) it. */
@@ -669,7 +879,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
669
879
  * rather than delegate to Bun's native `server.publish`. */
670
880
  private wsHasValidatedSend = false
671
881
  /** Bun's native topic broadcast, bound by `listen()` in native-pubsub mode; `app.publish` uses it
672
- * 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. */
673
884
  private nativePublish:
674
885
  | ((topic: string, data: string | ArrayBufferView | ArrayBuffer) => void)
675
886
  | undefined
@@ -679,6 +890,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
679
890
  private readonly trustBodyFraming: boolean
680
891
  private readonly wsMaxPayloadBytes: number
681
892
  private readonly requestTimeoutMs: number
893
+ private readonly wsUpgradeTimeoutMs: number
682
894
  /** Installed by the `responseContract()` plugin; `undefined` = not installed, which is the default
683
895
  * and the state in which a declared `response` schema stays a compile-time contract only. */
684
896
  private responseContractRuntime: ResponseContractRuntime | undefined
@@ -689,7 +901,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
689
901
  private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
690
902
  private readonly gracefulSignals: boolean
691
903
  private readonly stopHooks: StopHook[]
692
- 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
693
908
  /** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
694
909
  private readonly capacityGate: AdmissionController | undefined
695
910
  private readonly onCapabilityUse: ((event: CapabilityUseEvent) => void) | undefined
@@ -703,6 +918,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
703
918
  private idempotencyRuntime: IdempotencyRuntime | undefined
704
919
  /** Installed opt-in runtime for `.tool()`/`.resource()`/`.prompt()`; `undefined` until `.use(mcp())`. */
705
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
706
923
  /** Installed Node-direct renderer for direct `resolveNode()` callers; `undefined` until `.use(nodeDirect())`. */
707
924
  private nodeOutcomeRuntime: NodeOutcomeRuntime | undefined
708
925
  /** Installed streaming runtime for `.sse()` routes; `undefined` until `.use(streaming())`. */
@@ -717,6 +934,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
717
934
  private bunServer: RunningServer | undefined
718
935
  private sealed: boolean
719
936
  private readonly derives: RawDerive[]
937
+ /** Dedicated authentication stages captured by subsequent routes. */
938
+ private readonly authStages: RawAuthStage[]
720
939
  private readonly decorations: Record<string, unknown>
721
940
  private readonly beforeHandleHooks: RawBeforeHandle[]
722
941
  private readonly afterHandleHooks: RawAfterHandle[]
@@ -726,10 +945,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
726
945
  private readonly onNodeRequestHooks: Array<NodeRequestHook | undefined>
727
946
  /** Registration-time eligibility for the Node request twin lane. */
728
947
  private nodeRequestHooksComplete: boolean
948
+ /** True when every request hook is safe to skip on a Bun native route. */
949
+ private bunNativeRequestHooksSafe: boolean
729
950
  private readonly onResponseHooks: RawOnResponse[]
730
951
  private readonly onNodeResponseHooks: Array<NodeResponseHook | undefined>
731
952
  /** Registration-time eligibility for the Node response twin lane. */
732
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
733
956
  /**
734
957
  * True once a bundle registers a RAW `onNodeResponse` twin - one handed the outcome's header
735
958
  * record itself rather than the case-normalizing view. Their names are contractually the wire
@@ -745,6 +968,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
745
968
  * `onResponse` hook would have disabled.
746
969
  */
747
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
748
976
  /** `wrapResponse` for the Web lanes: identity until static headers exist to fold into the
749
977
  * framework's own error/404/timeout renders, which are built outside the header init. */
750
978
  private wrapWebResponse: (response: Response | ResponseResult) => Response
@@ -759,8 +987,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
759
987
  private readonly responseSources: WeakMap<object, Request>
760
988
  /** Memoized NodeRequestContext per plain-`Request` source - see {@link nodeRequestContextOf}. */
761
989
  private readonly nodeContexts: WeakMap<object, NodeRequestContext>
762
- /** Names of plugins/middleware already applied via `use` - for idempotent dedupe. */
763
- private readonly appliedPlugins: Set<string>
990
+ /** Plugins/middleware already applied via `use`, by name - for idempotent dedupe. */
991
+ private readonly appliedPlugins: Map<string, unknown>
764
992
  /** Order-scoped evidence captured by routes registered after an assured plugin. */
765
993
  private readonly activeAssurance: AssuranceDeclaration[]
766
994
  /** App-wide evidence from global hooks; applies retroactively to every route. */
@@ -768,7 +996,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
768
996
  /** App-declared MCP resources / prompts (via {@link resource} / {@link prompt}), read by `nifra mcp`. */
769
997
  private readonly mcpResourceList: McpResourceDescriptor[]
770
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
771
1004
  constructor(options: ServerOptions = {}) {
1005
+ this.options = options
1006
+ this.routePrefix = ""
772
1007
  this.catalog = new RouteCatalog()
773
1008
  this.wsRouter = new Router<WsEntry>()
774
1009
  this.wsRouteCount = 0
@@ -782,6 +1017,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
782
1017
  this.maxBodyBytes = maxBodyBytes
783
1018
  this.wsMaxPayloadBytes = wsMaxPayloadBytes
784
1019
  this.requestTimeoutMs = options.requestTimeoutMs ?? 0
1020
+ this.wsUpgradeTimeoutMs = options.wsUpgradeTimeoutMs ?? DEFAULT_WS_UPGRADE_TIMEOUT_MS
785
1021
  this.clientIpTrust = options.clientIp
786
1022
  this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false
787
1023
  this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000
@@ -792,12 +1028,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
792
1028
  if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 0) {
793
1029
  throw new RangeError("requestTimeoutMs must be a finite non-negative number")
794
1030
  }
1031
+ if (!Number.isFinite(this.wsUpgradeTimeoutMs) || this.wsUpgradeTimeoutMs < 0) {
1032
+ throw new RangeError("wsUpgradeTimeoutMs must be a finite non-negative number")
1033
+ }
795
1034
  if (!Number.isFinite(this.maxInboundDeadlineMs) || this.maxInboundDeadlineMs <= 0) {
796
1035
  throw new RangeError("maxInboundDeadlineMs must be a finite positive number")
797
1036
  }
798
1037
  this.gracefulSignals = options.gracefulSignals ?? false
799
1038
  this.stopHooks = []
800
1039
  this.fetchMounts = []
1040
+ this.mountOrder = 0
1041
+ this.preRouteMountCount = 0
801
1042
  this.capacityGate = options.admission
802
1043
  this.onCapabilityUse = options.onCapabilityUse
803
1044
  this.capabilityInterceptors = []
@@ -820,6 +1061,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
820
1061
  beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined)
821
1062
  }
822
1063
  this.derives = []
1064
+ this.authStages = []
823
1065
  this.decorations = {}
824
1066
  this.beforeHandleHooks = []
825
1067
  this.afterHandleHooks = []
@@ -828,9 +1070,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
828
1070
  this.onRequestHooks = []
829
1071
  this.onNodeRequestHooks = []
830
1072
  this.nodeRequestHooksComplete = true
1073
+ this.bunNativeRequestHooksSafe = true
831
1074
  this.onResponseHooks = []
832
1075
  this.onNodeResponseHooks = []
833
1076
  this.nodeResponseHooksComplete = true
1077
+ this.bunNativeResponseHeadersOnly = true
834
1078
  this.hasRawNodeResponseHook = false
835
1079
  this.onResponseFinalizedHooks = []
836
1080
  this.staticResponseHeaders = undefined
@@ -841,7 +1085,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
841
1085
  this.responseRequests = new WeakMap()
842
1086
  this.responseSources = new WeakMap()
843
1087
  this.nodeContexts = new WeakMap()
844
- this.appliedPlugins = new Set()
1088
+ this.appliedPlugins = new Map()
845
1089
  this.activeAssurance = []
846
1090
  this.globalAssurance = []
847
1091
  this.mcpResourceList = []
@@ -852,7 +1096,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
852
1096
  if (this.sealed) {
853
1097
  throw new FrameworkError(
854
1098
  "SERVER_SEALED",
855
- `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`,
856
1100
  )
857
1101
  }
858
1102
  }
@@ -877,13 +1121,76 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
877
1121
  ): this {
878
1122
  this.assertConfigurable("mountFetch()")
879
1123
  if (typeof handler !== "function") throw new TypeError("mountFetch handler must be a function")
880
- const mount: FetchMount = {
1124
+ const mount: FetchMount<EnvOf<Ctx>> = {
881
1125
  path: normalizeMountPrefix(path),
882
- 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
+ }),
883
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,
884
1189
  }
885
1190
  this.fetchMounts.push(mount)
886
- this.fetchMounts.sort((a, b) => b.path.length - a.path.length)
1191
+ this.fetchMounts.sort(compareMounts)
1192
+ this.preRouteMountCount += 1
1193
+ wsChanges += 1
887
1194
  return this
888
1195
  }
889
1196
 
@@ -921,6 +1228,33 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
921
1228
  * by none before; one added after the last route reaches nothing
922
1229
  * (see {@link ServerOptions.unusedScopedHooks}).
923
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
+
924
1258
  decorate<const K extends string, V>(key: K, value: V): Server<R, Ctx & Record<K, V>, HookOutput> {
925
1259
  this.assertConfigurable("decorate()")
926
1260
  this.decorations[key] = value
@@ -940,6 +1274,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
940
1274
  this.onRequestHooks.push(fn as RawOnRequest)
941
1275
  this.onNodeRequestHooks.push(undefined)
942
1276
  this.nodeRequestHooksComplete = false
1277
+ this.bunNativeRequestHooksSafe = false
943
1278
  return this
944
1279
  }
945
1280
 
@@ -1044,6 +1379,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1044
1379
  this.onResponseHooks.push(fn)
1045
1380
  this.onNodeResponseHooks.push(undefined)
1046
1381
  this.nodeResponseHooksComplete = false
1382
+ this.bunNativeResponseHeadersOnly = false
1047
1383
  return this
1048
1384
  }
1049
1385
 
@@ -1061,7 +1397,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1061
1397
  * app.responseHeaders({ "x-frame-options": "DENY", "referrer-policy": "no-referrer" })
1062
1398
  *
1063
1399
  * They are DEFAULTS: a value the request itself produced (`c.set.headers`, or a response hook)
1064
- * 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
1065
1402
  * name, `__proto__`, or a name the render owns (`content-type`, `content-length`,
1066
1403
  * `transfer-encoding`, `set-cookie`) throws a `TypeError` at wire-up.
1067
1404
  *
@@ -1095,7 +1432,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1095
1432
  // serialized-body marker survives for later `onResponseBody`/`onResponseRaw` observers and the
1096
1433
  // Node writer; reconstructing a `Response` here would strip that marker and reclassify the Node
1097
1434
  // outcome from `json` to a generic `response`. A guarded foreign response takes its clone path.
1098
- const statics = buildStaticResponseHeaders(record)
1435
+ const statics = buildStaticResponseHeaders(record, this.inheritedStatics)
1099
1436
  this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics))
1100
1437
  this.onNodeResponseHooks.push(undefined)
1101
1438
  this.nodeResponseHooksComplete = false
@@ -1105,7 +1442,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1105
1442
  this.staticResponseHeaders === undefined
1106
1443
  ? record
1107
1444
  : { ...this.staticResponseHeaders.record, ...record }
1108
- this.staticResponseHeaders = buildStaticResponseHeaders(merged)
1445
+ this.staticResponseHeaders = buildStaticResponseHeaders(merged, this.inheritedStatics)
1109
1446
  const statics = this.staticResponseHeaders
1110
1447
  this.wrapWebResponse = (response) =>
1111
1448
  applyStaticResponseHeaders(webResponseOf(response), statics)
@@ -1144,6 +1481,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1144
1481
  return this
1145
1482
  }
1146
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>
1147
1494
  /** Enable the opt-in portable response observer methods. */
1148
1495
  use(plugin: ResponseObserverPlugin): this & ResponseObserverMethods
1149
1496
  /**
@@ -1168,7 +1515,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1168
1515
  * Apply a **plugin function** - `(app) => app`, typically built with {@link definePlugin}. It's
1169
1516
  * called with `this` and its result is returned, so an inline plugin's `derive`/`decorate` thread
1170
1517
  * the added context to handlers defined after `use` (the overload is generic over the concrete
1171
- * `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.
1172
1520
  *
1173
1521
  * If the plugin's return type is unpinned - `Server<any, any>`, as a hand-rolled `(app) => app` or a
1174
1522
  * `NifraPlugin<AnyServer, AnyServer>` infers (e.g. an auth plugin whose own types collapsed) - this
@@ -1189,22 +1537,43 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1189
1537
  */
1190
1538
  use<M extends Middleware>(mw: M): Server<R, Ctx, HookOutput | MiddlewareOutputOf<M>>
1191
1539
  use(mw: Middleware): this
1192
- 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)
1193
1548
  this.assertConfigurable("use()")
1194
- if (typeof arg === "function") {
1195
- const name = (arg as { pluginName?: string }).pluginName
1196
- if (name !== undefined) {
1197
- if (this.appliedPlugins.has(name)) return this // idempotent: already applied
1198
- 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
1199
1562
  }
1563
+ this.appliedPlugins.set(name, arg)
1564
+ }
1565
+ if (typeof arg === "function") {
1200
1566
  const evidence = assuranceDeclarationsOf(arg)
1201
1567
  const pluginOnly = evidence.filter((item) => item.scope === "plugin")
1202
1568
  this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"))
1203
1569
  this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"))
1204
1570
  this.activeAssurance.push(...pluginOnly)
1571
+ const inherited = this.inheritedPlugins
1572
+ this.inheritedPlugins = undefined
1205
1573
  try {
1206
1574
  return arg(this)
1207
1575
  } finally {
1576
+ this.inheritedPlugins = inherited
1208
1577
  // Remove only this plugin's temporary evidence. Nested assured plugins may deliberately leave
1209
1578
  // subsequent evidence active, so truncating the whole array would lose real ordering semantics.
1210
1579
  for (const item of pluginOnly) {
@@ -1213,10 +1582,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1213
1582
  }
1214
1583
  }
1215
1584
  }
1216
- if (arg.name !== undefined) {
1217
- if (this.appliedPlugins.has(arg.name)) return this
1218
- this.appliedPlugins.add(arg.name)
1219
- }
1220
1585
  const evidence = assuranceDeclarationsOf(arg)
1221
1586
  if (evidence.some((item) => item.scope === "plugin")) {
1222
1587
  throw new Error('route assurance: scope "plugin" may only annotate a plugin function')
@@ -1239,6 +1604,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1239
1604
  this.onRequestHooks.push(arg.onRequest as RawOnRequest)
1240
1605
  this.onNodeRequestHooks.push(arg.onNodeRequest)
1241
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
+ }
1242
1610
  } else if (arg.onNodeRequest !== undefined) {
1243
1611
  throw new TypeError("onNodeRequest() requires a paired onRequest() hook")
1244
1612
  }
@@ -1251,16 +1619,26 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1251
1619
  this.onNodeResponseHooks.push(arg.onNodeResponse)
1252
1620
  if (arg.onNodeResponse === undefined) this.nodeResponseHooksComplete = false
1253
1621
  else this.hasRawNodeResponseHook = true
1622
+ this.bunNativeResponseHeadersOnly = false
1254
1623
  } else if (arg.onNodeResponse !== undefined) {
1255
1624
  throw new TypeError("onNodeResponse() requires a paired onResponse() hook")
1256
1625
  }
1257
1626
  // Before the bundle's own hooks: a bundle declaring both means its static values are the
1258
1627
  // defaults its hook may then override, which is the order a single bundle reads in.
1259
1628
  if (arg.responseHeaders !== undefined) this.responseHeaders(arg.responseHeaders)
1260
- if (arg.onResponseHeaders !== undefined)
1261
- this.responseObserverMethods().onResponseHeaders(arg.onResponseHeaders)
1262
- if (arg.onResponseBody !== undefined)
1263
- 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
+ }
1264
1642
  if (arg.onResponseRaw !== undefined)
1265
1643
  this.responseObserverMethods().onResponseRaw(arg.onResponseRaw)
1266
1644
  if (arg.onResponseFinalized !== undefined) this.onResponseFinalized(arg.onResponseFinalized)
@@ -1589,10 +1967,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1589
1967
  this.topics ??= runtime.createTopics()
1590
1968
  // A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
1591
1969
  // already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
1592
- this.wsRouter.add("GET", path, {
1593
- handler: runtime.wrapHandler(handler as WebSocketHandler),
1594
- })
1970
+ const entry = { handler: runtime.wrapHandler(handler as WebSocketHandler, this.protoPoisoning) }
1971
+ for (const form of expandOptionalParams(path)) this.wsRouter.add("GET", form, entry)
1595
1972
  this.wsRouteCount += 1
1973
+ wsChanges += 1
1596
1974
  return this as never
1597
1975
  }
1598
1976
 
@@ -1639,34 +2017,54 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1639
2017
  * is the "compiled", order-scoped per-route chain.
1640
2018
  */
1641
2019
  register(
1642
- method: Method,
2020
+ method: string,
1643
2021
  path: string,
1644
2022
  schema: RouteSchema | undefined,
1645
2023
  handler: (context: never) => unknown,
1646
2024
  ): void {
1647
- this.assertConfigurable("route registration")
1648
- this.catalog.add(this.prepareRoute(method, path, schema, handler))
2025
+ this.registerBatch([{ method, path, schema, handler }])
1649
2026
  }
1650
2027
 
1651
2028
  /** Register a contract/group route batch atomically. Every route captures the same current chain it
1652
- * 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. */
1653
2033
  registerBatch(
1654
2034
  routes: readonly {
1655
- readonly method: Method
2035
+ readonly method: string
1656
2036
  readonly path: string
1657
2037
  readonly schema: RouteSchema | undefined
1658
2038
  readonly handler: (context: never) => unknown
1659
2039
  }[],
1660
2040
  ): void {
1661
2041
  this.assertConfigurable("route registration")
1662
- const staged = routes.map(({ method, path, schema, handler }) =>
1663
- this.prepareRoute(method, path, schema, handler),
1664
- )
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
+ }
1665
2048
  this.catalog.addBatch(staged)
1666
2049
  }
1667
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
+
1668
2066
  private prepareRoute(
1669
- method: Method,
2067
+ method: string,
1670
2068
  path: string,
1671
2069
  schema: RouteSchema | undefined,
1672
2070
  handler: (context: never) => unknown,
@@ -1684,6 +2082,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1684
2082
  idempotencyRuntime: this.idempotencyRuntime,
1685
2083
  responseContractRuntime: this.responseContractRuntime,
1686
2084
  derives: this.derives,
2085
+ authStages: this.authStages,
1687
2086
  beforeHandleHooks: this.beforeHandleHooks,
1688
2087
  afterHandleHooks: this.afterHandleHooks,
1689
2088
  onErrorHooks: this.onErrorHooks,
@@ -1707,15 +2106,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1707
2106
  lanes,
1708
2107
  routeAssurance,
1709
2108
  } = compiled
2109
+ if (idempotent !== undefined && this.idempotencyRuntime !== undefined) {
2110
+ handler = this.idempotencyRuntime.wrapHandler(handler)
2111
+ }
1710
2112
  const { bare, fusedQuery, fusedBody } = lanes
1711
- const fusedBodyRunner = fusedBody
1712
- ? this.buildFusedBodyRunner(
1713
- handler as unknown as InternalHandler,
1714
- schema?.body as StandardSchemaV1,
1715
- hasDecorations ? routeDecorations : undefined,
1716
- bodyLimit ?? UNLIMITED_BODY_BYTES,
1717
- )
1718
- : undefined
1719
2113
  // Fused lifecycle lanes: derive + before (with or without an after), and body + the same shape.
1720
2114
  // The lane selectors in `selectRouteLanes` are exhaustive about the lifecycleHookLane /
1721
2115
  // body-derive-before-after eligibility, so we only need to check the lane and the hook counts.
@@ -1723,6 +2117,46 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1723
2117
  const isFusedLifecycle = lifecycleHookLane !== undefined && this.derives.length === 1
1724
2118
  const isFusedBodyLifecycle =
1725
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
1726
2160
  const fusedWeb =
1727
2161
  bare && this.aroundHooks.length === 0
1728
2162
  ? this.buildFusedWeb(
@@ -1771,6 +2205,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1771
2205
  schema,
1772
2206
  handler: handler as unknown as InternalHandler,
1773
2207
  derives: this.derives,
2208
+ authStages: compiled.authStages,
1774
2209
  beforeHandle: this.beforeHandleHooks,
1775
2210
  afterHandle: this.afterHandleHooks,
1776
2211
  onError: this.onErrorHooks,
@@ -1799,6 +2234,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1799
2234
  ledgered,
1800
2235
  responseContract,
1801
2236
  derives: [...this.derives],
2237
+ authStages: [...compiled.authStages],
1802
2238
  decorations: routeDecorations,
1803
2239
  hasDecorations,
1804
2240
  beforeHandle: [...this.beforeHandleHooks],
@@ -1813,6 +2249,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1813
2249
  method,
1814
2250
  path,
1815
2251
  schema,
2252
+ ...(responseContract === undefined
2253
+ ? {}
2254
+ : { responseContract: responseContract.runtime.mode }),
1816
2255
  ...(capabilities.length > 0 ? { capabilities } : {}),
1817
2256
  ...(schema?.family === true ? { family: true } : {}),
1818
2257
  }
@@ -1861,10 +2300,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1861
2300
  this.assertConfigurable("responseObserver()")
1862
2301
  const methods = runtime.install({
1863
2302
  assertConfigurable: (operation) => this.assertConfigurable(operation),
1864
- addResponseHook: (web, node) => {
2303
+ addResponseHook: (web, node, headerOnly = false) => {
1865
2304
  this.onResponseHooks.push(web)
1866
2305
  this.onNodeResponseHooks.push(node)
1867
2306
  if (node === undefined) this.nodeResponseHooksComplete = false
2307
+ if (!headerOnly) this.bunNativeResponseHeadersOnly = false
1868
2308
  },
1869
2309
  enableResponseBodyTagging: () => {
1870
2310
  if (this.responseBodyTag === undefined) {
@@ -1891,6 +2331,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1891
2331
  this.mcpRuntime = runtime
1892
2332
  }
1893
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
+
1894
2340
  /** @internal Symbol-keyed install seam for the `nodeDirect()` plugin. Off the public typed surface. */
1895
2341
  [INSTALL_NODE_DIRECT](runtime: NodeOutcomeRuntime): void {
1896
2342
  this.assertConfigurable("nodeDirect()")
@@ -1909,6 +2355,41 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1909
2355
  this.wsRuntime = runtime
1910
2356
  }
1911
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
+
1912
2393
  /**
1913
2394
  * Merge another server's routes into this one - the composition escape hatch for large apps.
1914
2395
  *
@@ -1937,6 +2418,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1937
2418
  other: Server<R2, Ctx2, HookOutput2>,
1938
2419
  ): Server<R & R2, Ctx, HookOutput> {
1939
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
+ }
1940
2434
  const source = other as unknown as Server<Registry, EmptyContext>
1941
2435
  if (source.wsRouteCount > 0) {
1942
2436
  throw new RouteConfigError(
@@ -1944,83 +2438,190 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1944
2438
  "merge() does not carry WebSocket routes - register .ws() routes on the parent server",
1945
2439
  )
1946
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 {
1947
2551
  const sourceRoutes = source.catalog.entries()
1948
2552
  // Hooks and global assurance stay LOCAL to a group that has routes; a route-less group is a
1949
2553
  // middleware bundle whose hooks can only mean app-wide intent, so it keeps the global append.
1950
- const scoped = sourceRoutes.length > 0
1951
- // A group's global assurance rides its (route-scoped) hooks: folded into each merged route's
1952
- // 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
1953
2559
  // enforcement for parent routes the group's hooks do not see.
1954
2560
  const foldedAssurance =
1955
- scoped && source.globalAssurance.length > 0
2561
+ scoped && ownGlobal.length > 0
1956
2562
  ? (route: CatalogRoute): CatalogRoute => ({
1957
2563
  ...route,
1958
- assurance: [...route.assurance, ...source.globalAssurance],
2564
+ assurance: [...route.assurance, ...ownGlobal],
1959
2565
  })
1960
2566
  : (route: CatalogRoute): CatalogRoute => route
1961
2567
  this.catalog.addBatch(
1962
2568
  sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(route))),
1963
2569
  )
1964
2570
  // Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
1965
- // runtime object supplies the generic execution machinery. Preserve a group's installed runtime
1966
- // when the parent has none so merging cannot silently disable a safety lane. If the parent already
1967
- // has a runtime, either implementation can execute every resolved entry because route-specific
1968
- // options were pinned during registration.
1969
- this.responseContractRuntime ??= source.responseContractRuntime
1970
- this.idempotencyRuntime ??= source.idempotencyRuntime
1971
- this.effectLedgerRuntime ??= source.effectLedgerRuntime
1972
- this.mcpRuntime ??= source.mcpRuntime
1973
- this.nodeOutcomeRuntime ??= source.nodeOutcomeRuntime
1974
- this.sseRuntime ??= source.sseRuntime
1975
- this.wsRuntime ??= source.wsRuntime
1976
- if (scoped && source.onRequestHooks.length > 0) {
1977
- // Snapshot the group's routes into a dedicated matcher: the guard must reflect what was
1978
- // MERGED, not whatever the group's own catalog grows into afterwards. One probe against it
1979
- // gates each group hook to requests the group would serve; everything else passes untouched.
1980
- const scope = new RouteCatalog()
1981
- scope.addBatch(sourceRoutes)
1982
- this.onRequestHooks.push(
1983
- ...source.onRequestHooks.map(
1984
- (hook): RawOnRequest =>
1985
- (req, platform) =>
1986
- scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined,
1987
- ),
1988
- )
1989
- // An `undefined` slot marks an unpaired hook (position-aligned with `onRequestHooks`) and
1990
- // must stay `undefined` - wrapping it would fabricate a Node twin that never existed.
1991
- this.onNodeRequestHooks.push(
1992
- ...source.onNodeRequestHooks.map((hook): NodeRequestHook | undefined =>
1993
- hook === undefined
1994
- ? undefined
1995
- : (req, platform) =>
1996
- scope.find(req.method, pathnameOf(req.url)).found ? hook(req, platform) : undefined,
1997
- ),
1998
- )
1999
- } else {
2000
- this.onRequestHooks.push(...source.onRequestHooks)
2001
- this.onNodeRequestHooks.push(...source.onNodeRequestHooks)
2002
- }
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))
2003
2586
  this.nodeRequestHooksComplete &&= source.nodeRequestHooksComplete
2004
- // The group's static declarations came before its own response hooks, so they are folded in
2005
- // first - and fold themselves into a hook here if this server already has one (same ordering
2006
- // rule as a direct `responseHeaders()` call).
2007
- if (source.staticResponseHeaders !== undefined) {
2008
- this.addStaticResponseHeaders({ ...source.staticResponseHeaders.record })
2009
- }
2010
- this.onResponseHooks.push(...source.onResponseHooks)
2011
- 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
+ )
2012
2611
  this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete
2612
+ this.bunNativeResponseHeadersOnly &&= source.bunNativeResponseHeadersOnly
2013
2613
  this.hasRawNodeResponseHook ||= source.hasRawNodeResponseHook
2014
- this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks)
2015
2614
  if (source.responseBodyTag !== undefined) {
2016
2615
  const owner = this.enableResponseBodyTagging()
2017
2616
  this.responseBodyOwners.add(source.responseBodyTag)
2018
2617
  source.responseBodyOwners.add(owner)
2019
2618
  }
2020
- 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)
2021
2623
  this.mcpResourceList.push(...source.mcpResourceList)
2022
2624
  this.mcpPromptList.push(...source.mcpPromptList)
2023
- return this as unknown as Server<R & R2, Ctx, HookOutput>
2024
2625
  }
2025
2626
 
2026
2627
  /** A fused renderer closes over runtime services to keep its seven-argument JSC fast path. Merging
@@ -2033,9 +2634,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2033
2634
  const lane = entry.execution.fusedLane
2034
2635
  // Type-erase the lane so each `===` branch sees the full union, not the narrowed remainder.
2035
2636
  const laneName: string | undefined = lane
2036
- // Body+lifecycle routes have a fused Web renderer, but no body-only Node renderer: the latter
2037
- // would bypass derive/before/after because the Node-direct dispatcher prefers `fusedBody` over
2038
- // 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]
2039
2644
  const fusedBody =
2040
2645
  laneName === "body"
2041
2646
  ? this.buildFusedBodyRunner(
@@ -2044,10 +2649,49 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2044
2649
  entry.hasDecorations ? entry.decorations : undefined,
2045
2650
  entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2046
2651
  )
2047
- : undefined
2048
- const derive = entry.derives[0]
2049
- const before = entry.beforeHandle[0]
2050
- 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
2051
2695
  const fusedWeb =
2052
2696
  laneName === "body"
2053
2697
  ? this.buildFusedBodyWeb(fusedBody as FusedBodyRunner)
@@ -2137,6 +2781,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2137
2781
  * Bun/Node/Deno omit it (then `c.env` is `undefined` and `c.waitUntil` runs fire-and-forget).
2138
2782
  */
2139
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)
2140
2787
  // An edge deployment whose only ingress is this method declares its requests runtime-framed at
2141
2788
  // construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
2142
2789
  if (this.trustBodyFraming) markTrustedBodyFraming(req)
@@ -2192,19 +2839,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2192
2839
  source: RequestSource,
2193
2840
  entry: RouteEntry,
2194
2841
  params: Record<string, string>,
2842
+ platform: Platform,
2195
2843
  ): MaybePromise<Response> {
2196
- if (this.capacityGate === undefined) return this.fetchMatchedInner(source, entry, params)
2197
- 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
+ )
2198
2850
  }
2199
2851
 
2200
2852
  private fetchMatchedInner(
2201
2853
  source: RequestSource,
2202
2854
  entry: RouteEntry,
2203
2855
  params: Record<string, string>,
2856
+ platform: Platform,
2204
2857
  ): MaybePromise<Response> {
2205
2858
  const outcome = this.runMatched(
2206
2859
  source,
2207
- undefined,
2860
+ platform,
2208
2861
  entry,
2209
2862
  params,
2210
2863
  undefined,
@@ -2283,18 +2936,30 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2283
2936
 
2284
2937
  const parts = source.urlParts ?? urlPartsOf(source.url)
2285
2938
  const match = this.catalog.find(source.method, parts.pathname)
2286
- 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
+ }
2287
2955
 
2956
+ private firstMountUnder(
2957
+ pathname: string,
2958
+ beforeRoutes: boolean,
2959
+ ): FetchMount<EnvOf<Ctx>> | undefined {
2288
2960
  for (const mount of this.fetchMounts) {
2289
- if (!underMountPrefix(parts.pathname, mount.path)) continue
2290
- const candidate = (mount.handler as unknown as Record<symbol, unknown>)[NODE_NATIVE_MOUNT]
2291
- return typeof candidate === "function"
2292
- ? {
2293
- handler: candidate as NativeMountHandler,
2294
- path: mount.path,
2295
- stripPrefix: mount.stripPrefix,
2296
- }
2297
- : undefined
2961
+ if (mount.beforeRoutes === beforeRoutes && underMountPrefix(pathname, mount.path))
2962
+ return mount
2298
2963
  }
2299
2964
  return undefined
2300
2965
  }
@@ -2462,6 +3127,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2462
3127
  const context: NodeResponseContext = {
2463
3128
  status: outcome.status,
2464
3129
  headers,
3130
+ headersAreLowercase: !this.hasRawNodeResponseHook && lowercaseKeys,
2465
3131
  cookies: outcome.kind === "json" ? outcome.cookies : undefined,
2466
3132
  body: outcome.body,
2467
3133
  }
@@ -2558,124 +3224,294 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2558
3224
  * `data`). Runs the route's `upgrade(c)` guard in a real request context. Synchronous unless
2559
3225
  * `upgrade()` is async; a throw rejects with a flat 500 (no detail leaked).
2560
3226
  */
2561
- resolveWebSocketUpgrade(
3227
+ private runWebSocketRequestHooks(
2562
3228
  req: Request,
2563
3229
  platform?: Platform<EnvOf<Ctx>>,
2564
- ): MaybePromise<WebSocketUpgradeOutcome> {
2565
- if (this.wsRouteCount === 0) return WS_PASS
2566
- if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") return WS_PASS
2567
- const url = urlPartsOf(req.url)
2568
- const match = this.wsRouter.find("GET", url.pathname)
2569
- if (!match.found) return WS_PASS // upgrade header, no WS route here → normal routing decides
2570
- // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
2571
- // already did and made every plain dynamic route pay for unrelated static path bytes.
2572
- const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params)
2573
- if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
2574
- const handler = match.payload.handler
2575
- // Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
2576
- const pubsub = this.topics as TopicRegistry
2577
- const attach = (this.wsRuntime as WsRuntime).attach
2578
- // CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
2579
- // Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
2580
- // blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
2581
- const origin = req.headers.get("origin")
2582
- if (handler.allowedOrigins !== undefined) {
2583
- const allowed =
2584
- typeof handler.allowedOrigins === "function"
2585
- ? handler.allowedOrigins(origin)
2586
- : origin !== null && handler.allowedOrigins.includes(origin)
2587
- if (!allowed) return { kind: "reject", response: jsonError(403, "forbidden_origin") }
2588
- } else if (origin !== null && !wsSameOrigin(origin, req)) {
2589
- // Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake - the
2590
- // CSWSH case, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser
2591
- // clients send no `Origin` and pass; same-origin browsers pass. Set `allowedOrigins` to permit
2592
- // specific cross-origin clients (or `() => true` for a genuinely public socket).
2593
- return { kind: "reject", response: jsonError(403, "forbidden_origin") }
2594
- }
2595
- if (handler.upgrade === undefined) {
2596
- return {
2597
- kind: "upgrade",
2598
- handler,
2599
- data: undefined,
2600
- pubsub,
2601
- attach,
2602
- maxPayloadBytes: this.wsMaxPayloadBytes,
3230
+ ): MaybePromise<Request | Response> {
3231
+ let current = req
3232
+ for (let i = 0; i < this.onRequestHooks.length; i++) {
3233
+ const hook = this.onRequestHooks[i]
3234
+ if (hook === undefined) return current
3235
+ const outcome = hook(current, platform)
3236
+ if (outcome instanceof Promise) {
3237
+ return outcome.then((first) =>
3238
+ this.continueWebSocketRequestHooks(first, i + 1, current, platform),
3239
+ )
2603
3240
  }
3241
+ if (outcome instanceof Request) {
3242
+ current = outcome
3243
+ continue
3244
+ }
3245
+ if (outcome !== undefined) return outcome
2604
3246
  }
2605
- const upgradeSignal = getNeverAbortSignal()
2606
- const ctx = new RequestContext(
2607
- req,
2608
- params,
2609
- url.search,
2610
- upgradeSignal,
2611
- createUnboundedRequestBudget(upgradeSignal),
2612
- platform,
2613
- this.maxBodyBytes,
2614
- this.protoPoisoning,
2615
- )
2616
- const settle = (value: unknown): WebSocketUpgradeOutcome =>
2617
- value instanceof Response
2618
- ? { kind: "reject", response: value }
2619
- : {
2620
- kind: "upgrade",
2621
- handler,
2622
- data: value,
2623
- pubsub,
2624
- attach,
2625
- maxPayloadBytes: this.wsMaxPayloadBytes,
2626
- }
2627
- try {
2628
- const result = handler.upgrade(ctx as unknown as WebSocketContext<EnvOf<Ctx>>)
2629
- return result instanceof Promise
2630
- ? result.then(settle, () => ({
2631
- kind: "reject" as const,
2632
- response: jsonError(500, "internal_error"),
2633
- }))
2634
- : settle(result)
2635
- } catch {
2636
- return { kind: "reject", response: jsonError(500, "internal_error") }
2637
- }
3247
+ return current
2638
3248
  }
2639
3249
 
2640
- /** Bun `fetch` when WS routes exist: try a WS upgrade first, else run the normal HTTP lifecycle.
2641
- * `undefined` ⇒ Bun owns the upgraded socket; a `Response` ⇒ a normal reply or a rejected upgrade.
2642
- * (The socket dispatch itself lives in `ws-bun.ts`, loaded via `@nifrajs/core/ws`.) */
2643
- private bunFetchWithWebSocket(
2644
- req: Request,
2645
- server: BunUpgradeServer,
2646
- ): MaybePromise<Response | undefined> {
2647
- const handle = (o: WebSocketUpgradeOutcome): MaybePromise<Response | undefined> => {
2648
- if (o.kind === "pass")
2649
- return this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>)
2650
- if (o.kind === "reject") return o.response
2651
- return server.upgrade(req, { data: { handler: o.handler, data: o.data } })
2652
- ? undefined
2653
- : jsonError(426, "upgrade_required")
3250
+ private async continueWebSocketRequestHooks(
3251
+ first: OnRequestResult,
3252
+ start: number,
3253
+ current: Request,
3254
+ platform?: Platform<EnvOf<Ctx>>,
3255
+ ): Promise<Request | Response> {
3256
+ let next = current
3257
+ let outcome = first
3258
+ for (let i = start; ; i++) {
3259
+ if (outcome instanceof Request) next = outcome
3260
+ else if (outcome !== undefined) return outcome
3261
+ if (i >= this.onRequestHooks.length) return next
3262
+ const hook = this.onRequestHooks[i]
3263
+ if (hook === undefined) return next
3264
+ outcome = await hook(next, platform)
2654
3265
  }
2655
- const outcome = this.resolveWebSocketUpgrade(req)
2656
- return outcome instanceof Promise ? outcome.then(handle) : handle(outcome)
2657
3266
  }
2658
3267
 
2659
- /**
2660
- * The shared lifecycle, generic over how the final value is rendered: `finalize` turns a handler's
2661
- * result + `set` into the output `T` (`toResponse` → a Web `Response`; `toNodeOutcome` → node-direct
2662
- * primitives), `wrapResponse` lifts an early/error `Response` into that same `T`, and `onTimeout`
2663
- * produces the 503. The Web `fetch` and `resolveNode` are thin callers over this one routing +
2664
- * context + lifecycle implementation - no duplication across the trust boundary.
2665
- */
2666
- /** Apply the `clientIp` trust declaration to the adapter's raw socket peer, returning a platform
2667
- * whose `clientIp` is the derived caller. Only called when a trust declaration is configured. */
2668
- private deriveClientIp(
2669
- source: RequestSource,
2670
- platform: Platform | undefined,
2671
- ): Platform | undefined {
3268
+ resolveWebSocketUpgrade(
3269
+ req: Request,
3270
+ platform?: Platform<EnvOf<Ctx>>,
3271
+ ): MaybePromise<WebSocketUpgradeOutcome> {
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
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)
3283
+
3284
+ const timeoutMs =
3285
+ this.wsUpgradeTimeoutMs === 0
3286
+ ? 0
3287
+ : this.requestTimeoutMs > 0
3288
+ ? Math.min(this.wsUpgradeTimeoutMs, this.requestTimeoutMs)
3289
+ : this.wsUpgradeTimeoutMs
3290
+ const controller = timeoutMs > 0 ? new AbortController() : undefined
3291
+ const signal = controller?.signal ?? getNeverAbortSignal()
3292
+ const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : undefined
3293
+ const timeoutResponse = jsonError(503, "request_timeout")
3294
+ const onTimeout = (): WebSocketUpgradeOutcome => ({
3295
+ kind: "reject",
3296
+ response: timeoutResponse,
3297
+ })
3298
+ const bounded = <T>(work: Promise<T>, onDone: () => T): Promise<T> =>
3299
+ controller === undefined
3300
+ ? work
3301
+ : this.withTimeout(work, controller, onDone, Math.max(0, deadline! - Date.now()))
3302
+
3303
+ const resolveMatched = (request: Request): MaybePromise<WebSocketUpgradeOutcome> => {
3304
+ const url = urlPartsOf(request.url)
3305
+ const match = this.wsRouter.find("GET", url.pathname)
3306
+ if (!match.found) return WS_PASS // upgrade header, no WS route here → normal routing decides
3307
+ // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
3308
+ // already did and made every plain dynamic route pay for unrelated static path bytes.
3309
+ const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params)
3310
+ if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
3311
+ const handler = match.payload.handler
3312
+ // Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
3313
+ const pubsub = this.topics as TopicRegistry
3314
+ const attach = (this.wsRuntime as WsRuntime).attach
3315
+ // CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
3316
+ // Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
3317
+ // blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
3318
+ const origin = request.headers.get("origin")
3319
+ if (handler.allowedOrigins !== undefined) {
3320
+ const allowed =
3321
+ typeof handler.allowedOrigins === "function"
3322
+ ? handler.allowedOrigins(origin)
3323
+ : origin !== null && handler.allowedOrigins.includes(origin)
3324
+ if (!allowed) return { kind: "reject", response: jsonError(403, "forbidden_origin") }
3325
+ } else if (origin !== null && !wsSameOrigin(origin, request)) {
3326
+ // Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake - the
3327
+ // CSWSH case, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser
3328
+ // clients send no `Origin` and pass; same-origin browsers pass. Set `allowedOrigins` to permit
3329
+ // specific cross-origin clients (or `() => true` for a genuinely public socket).
3330
+ return { kind: "reject", response: jsonError(403, "forbidden_origin") }
3331
+ }
3332
+ if (handler.upgrade === undefined) {
3333
+ return {
3334
+ kind: "upgrade",
3335
+ handler,
3336
+ data: undefined,
3337
+ pubsub,
3338
+ attach,
3339
+ maxPayloadBytes: this.wsMaxPayloadBytes,
3340
+ }
3341
+ }
3342
+ const budget =
3343
+ controller === undefined
3344
+ ? createUnboundedRequestBudget(signal)
3345
+ : createRequestBudget({ deadline: deadline!, signal })
3346
+ const ctx = new RequestContext(
3347
+ request,
3348
+ params,
3349
+ url.search,
3350
+ signal,
3351
+ budget,
3352
+ platform,
3353
+ this.maxBodyBytes,
3354
+ this.protoPoisoning,
3355
+ )
3356
+ const settle = (value: unknown): WebSocketUpgradeOutcome =>
3357
+ value instanceof Response
3358
+ ? { kind: "reject", response: value }
3359
+ : {
3360
+ kind: "upgrade",
3361
+ handler,
3362
+ data: value,
3363
+ pubsub,
3364
+ attach,
3365
+ maxPayloadBytes: this.wsMaxPayloadBytes,
3366
+ }
3367
+ try {
3368
+ const result = handler.upgrade(ctx as unknown as WebSocketContext<EnvOf<Ctx>>)
3369
+ if (!(result instanceof Promise)) return settle(result)
3370
+ const pending = result.then(settle, () => ({
3371
+ kind: "reject" as const,
3372
+ response: jsonError(500, "internal_error"),
3373
+ }))
3374
+ return bounded(pending, onTimeout)
3375
+ } catch {
3376
+ return { kind: "reject", response: jsonError(500, "internal_error") }
3377
+ }
3378
+ }
3379
+
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)
3435
+ let hooked: MaybePromise<Request | Response>
3436
+ try {
3437
+ hooked = this.runWebSocketRequestHooks(req, platform)
3438
+ } catch {
3439
+ return { kind: "reject", response: jsonError(500, "internal_error") }
3440
+ }
3441
+ if (!(hooked instanceof Promise)) {
3442
+ return hooked instanceof Response
3443
+ ? { kind: "reject", response: hooked }
3444
+ : resolveWithDeadline(hooked)
3445
+ }
3446
+ const pending = hooked.then(
3447
+ (result) =>
3448
+ result instanceof Response
3449
+ ? { kind: "reject" as const, response: result }
3450
+ : resolveWithDeadline(result),
3451
+ () => ({ kind: "reject" as const, response: jsonError(500, "internal_error") }),
3452
+ )
3453
+ return bounded(
3454
+ Promise.resolve(pending).then((result) => result),
3455
+ onTimeout,
3456
+ )
3457
+ }
3458
+
3459
+ /** Bun `fetch` when WS routes exist: try a WS upgrade first, else run the normal HTTP lifecycle.
3460
+ * `undefined` ⇒ Bun owns the upgraded socket; a `Response` ⇒ a normal reply or a rejected upgrade.
3461
+ * (The socket dispatch itself lives in `ws-bun.ts`, loaded via `@nifrajs/core/ws`.) */
3462
+ private bunFetchWithWebSocket(
3463
+ req: Request,
3464
+ server: BunUpgradeServer,
3465
+ ): MaybePromise<Response | undefined> {
3466
+ const handle = (o: WebSocketUpgradeOutcome): MaybePromise<Response | undefined> => {
3467
+ if (o.kind === "pass")
3468
+ return this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>)
3469
+ if (o.kind === "reject") return o.response
3470
+ return server.upgrade(req, {
3471
+ data: { handler: o.handler, data: o.data, pubsub: o.pubsub },
3472
+ })
3473
+ ? undefined
3474
+ : jsonError(426, "upgrade_required")
3475
+ }
3476
+ const outcome = this.resolveWebSocketUpgrade(
3477
+ req,
3478
+ bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>,
3479
+ )
3480
+ return outcome instanceof Promise ? outcome.then(handle) : handle(outcome)
3481
+ }
3482
+
3483
+ /**
3484
+ * The shared lifecycle, generic over how the final value is rendered: `finalize` turns a handler's
3485
+ * result + `set` into the output `T` (`toResponse` → a Web `Response`; `toNodeOutcome` → node-direct
3486
+ * primitives), `wrapResponse` lifts an early/error `Response` into that same `T`, and `onTimeout`
3487
+ * produces the 503. The Web `fetch` and `resolveNode` are thin callers over this one routing +
3488
+ * context + lifecycle implementation - no duplication across the trust boundary.
3489
+ */
3490
+ /** Apply the `clientIp` trust declaration to the adapter's raw socket peer, returning a platform
3491
+ * whose `clientIp` is the derived caller. Only called when a trust declaration is configured. */
3492
+ private deriveClientIp(
3493
+ source: RequestSource,
3494
+ platform: Platform<EnvOf<Ctx>> | undefined,
3495
+ ): Platform<EnvOf<Ctx>> | undefined {
2672
3496
  const derived = resolveClientIp(platform?.clientIp, requestOf(source), this.clientIpTrust)
2673
3497
  return { ...platform, clientIp: derived }
2674
3498
  }
2675
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
+
2676
3512
  private dispatch<T>(
2677
3513
  source: RequestSource,
2678
- platform: Platform | undefined,
3514
+ platform: Platform<EnvOf<Ctx>> | undefined,
2679
3515
  finalize: (result: unknown, set: CtxSet) => T,
2680
3516
  wrapResponse: (response: Response | ResponseResult) => T,
2681
3517
  onTimeout: () => T,
@@ -2686,11 +3522,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2686
3522
  // Resolve the trust declaration into the platform's `clientIp` ONCE, here at the shared funnel, so
2687
3523
  // `c.clientIp` (and every hook/derive downstream) sees the derived caller. No config ⇒ the raw
2688
3524
  // socket peer the adapter supplied passes through untouched (a one-property no-op on the hot path).
2689
- const resolved =
2690
- 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)
2691
3528
  // onRequest hooks may be async, so a hooked app takes the async path; with no hooks (the common
2692
3529
  // case) routing stays synchronous, letting a bare route resolve with no lifecycle promise at all.
2693
- 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)) {
2694
3533
  return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast)
2695
3534
  }
2696
3535
  if (!webFast && this.canUseNodeRequestHooks()) {
@@ -2703,7 +3542,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2703
3542
  * Web `Request`; arbitrary request rewrites and full Web hooks use {@link runWithOnRequest}. */
2704
3543
  private runWithNodeRequest<T>(
2705
3544
  source: RequestSource,
2706
- platform: Platform | undefined,
3545
+ platform: Platform<EnvOf<Ctx>> | undefined,
2707
3546
  finalize: (result: unknown, set: CtxSet) => T,
2708
3547
  wrapResponse: (response: Response | ResponseResult) => T,
2709
3548
  onTimeout: () => T,
@@ -2737,7 +3576,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2737
3576
  nextIndex: number,
2738
3577
  request: NodeRequestContext,
2739
3578
  source: RequestSource,
2740
- platform: Platform | undefined,
3579
+ platform: Platform<EnvOf<Ctx>> | undefined,
2741
3580
  finalize: (result: unknown, set: CtxSet) => T,
2742
3581
  wrapResponse: (response: Response | ResponseResult) => T,
2743
3582
  onTimeout: () => T,
@@ -2765,7 +3604,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2765
3604
  */
2766
3605
  private runWithOnRequest<T>(
2767
3606
  source: RequestSource,
2768
- platform: Platform | undefined,
3607
+ platform: Platform<EnvOf<Ctx>> | undefined,
2769
3608
  finalize: (result: unknown, set: CtxSet) => T,
2770
3609
  wrapResponse: (response: Response | ResponseResult) => T,
2771
3610
  onTimeout: () => T,
@@ -2779,7 +3618,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2779
3618
  if (source !== originalRequest) this.responseSources.set(source as object, originalRequest)
2780
3619
  let current: RequestSource = source
2781
3620
  for (let i = 0; i < hooks.length; i++) {
2782
- const outcome = (hooks[i] as RawOnRequest)(requestOf(current), platform)
3621
+ const seen = requestOf(current)
3622
+ const outcome = (hooks[i] as RawOnRequest)(seen, platform)
2783
3623
  if (outcome instanceof Promise) {
2784
3624
  return outcome.then((early) =>
2785
3625
  this.continueOnRequest(
@@ -2796,6 +3636,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2796
3636
  )
2797
3637
  }
2798
3638
  if (outcome instanceof Request) {
3639
+ if (outcome !== seen) recordRequestReplacement(outcome, seen)
2799
3640
  current = outcome
2800
3641
  if (outcome !== originalRequest) this.responseRequests.set(originalRequest, outcome)
2801
3642
  continue
@@ -2812,24 +3653,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2812
3653
  nextIndex: number,
2813
3654
  originalRequest: Request,
2814
3655
  sourceAtAwait: RequestSource,
2815
- platform: Platform | undefined,
3656
+ platform: Platform<EnvOf<Ctx>> | undefined,
2816
3657
  finalize: (result: unknown, set: CtxSet) => T,
2817
3658
  wrapResponse: (response: Response | ResponseResult) => T,
2818
3659
  onTimeout: () => T,
2819
3660
  webFast: boolean,
2820
3661
  ): Promise<T> {
2821
3662
  let current = sourceAtAwait
3663
+ let seen = requestOf(sourceAtAwait)
2822
3664
  let early = first
2823
3665
  let index = nextIndex
2824
3666
  for (;;) {
2825
3667
  if (early instanceof Request) {
3668
+ if (early !== seen) recordRequestReplacement(early, seen)
2826
3669
  current = early
2827
3670
  if (early !== originalRequest) this.responseRequests.set(originalRequest, early)
2828
3671
  } else if (early !== undefined) {
2829
3672
  return wrapResponse(early)
2830
3673
  }
2831
3674
  if (index >= this.onRequestHooks.length) break
2832
- const outcome = (this.onRequestHooks[index] as RawOnRequest)(requestOf(current), platform)
3675
+ seen = requestOf(current)
3676
+ const outcome = (this.onRequestHooks[index] as RawOnRequest)(seen, platform)
2833
3677
  early = outcome instanceof Promise ? await outcome : outcome
2834
3678
  index++
2835
3679
  }
@@ -2906,7 +3750,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2906
3750
  */
2907
3751
  private routeAndRun<T>(
2908
3752
  source: RequestSource,
2909
- platform: Platform | undefined,
3753
+ platform: Platform<EnvOf<Ctx>> | undefined,
2910
3754
  finalize: (result: unknown, set: CtxSet) => T,
2911
3755
  wrapResponse: (response: Response | ResponseResult) => T,
2912
3756
  onTimeout: () => T,
@@ -2950,9 +3794,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2950
3794
  search = searchStart === -1 ? "" : rawUrl.slice(searchStart, searchEnd)
2951
3795
  }
2952
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
+
2953
3809
  const match = this.catalog.find(source.method, pathname)
2954
3810
  if (!match.found) {
2955
- const mounted = this.fetchMount(pathname, source, platform)
3811
+ const mounted = this.fetchMount(pathname, source, platform, false)
2956
3812
  if (mounted !== undefined) {
2957
3813
  return mounted instanceof Promise
2958
3814
  ? mounted.then((response) => wrapResponse(response))
@@ -2965,7 +3821,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2965
3821
  plainError(405, "method_not_allowed", { allow: match.allowed.join(", ") }),
2966
3822
  )
2967
3823
  }
2968
- 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)
2969
3828
  }
2970
3829
 
2971
3830
  // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
@@ -2991,17 +3850,48 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2991
3850
  private fetchMount(
2992
3851
  pathname: string,
2993
3852
  source: RequestSource,
2994
- platform: Platform | undefined,
3853
+ platform: Platform<EnvOf<Ctx>> | undefined,
3854
+ beforeRoutes: boolean,
2995
3855
  ): MaybePromise<Response> | undefined {
2996
- for (const mount of this.fetchMounts) {
2997
- if (!underMountPrefix(pathname, mount.path)) continue
2998
- const request = requestOf(source)
2999
- return mount.handler(
3000
- mount.stripPrefix ? stripMountPrefix(request, mount.path) : request,
3001
- platform,
3002
- )
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
3003
3863
  }
3004
- 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)
3005
3895
  }
3006
3896
 
3007
3897
  /** Run a route that has already been matched by the runtime or Nifra's portable router. */
@@ -3025,8 +3915,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3025
3915
  // handler can reach `c.req`; that lane already enforces `entry.bodyLimit`. Installing the full
3026
3916
  // direct-reader cap on `c.req` here would allocate bound readers, closures, and a stream wrapper
3027
3917
  // for a body that is already consumed. Keep the lazy transport cap for raw-body routes, where a
3028
- // user read is the only framework-owned body boundary.
3029
- 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
+ ) {
3030
3924
  const method = source.method
3031
3925
  if (method !== "GET" && method !== "HEAD") markTransportCap(source, entry.bodyLimit)
3032
3926
  }
@@ -3540,6 +4434,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3540
4434
  finish,
3541
4435
  wrapResponse,
3542
4436
  (err) => logError(err, ctx, finalize, wrapResponse),
4437
+ bodySchema,
3543
4438
  )
3544
4439
  }
3545
4440
  }
@@ -4109,10 +5004,442 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4109
5004
  onParsed,
4110
5005
  (response) => this.wrapWebResponse(response),
4111
5006
  (err) => Promise.resolve(logError(err, ctx)),
5007
+ bodySchema,
4112
5008
  ) as MaybePromise<Response>
4113
5009
  }
4114
5010
  }
4115
5011
 
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
+
4116
5443
  private bareError<T>(
4117
5444
  err: unknown,
4118
5445
  ctx: RawContext,
@@ -4126,7 +5453,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4126
5453
  err,
4127
5454
  ctx,
4128
5455
  finalize,
4129
- wrapResponse,
4130
5456
  responseSet,
4131
5457
  (e, c) => this.logRequestError(e, c),
4132
5458
  () => this.internalErrorResponse(wrapResponse),
@@ -4176,19 +5502,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4176
5502
  // @ts-expect-error TS6133 -- invoked structurally by the general route program
4177
5503
  private validateProgramStage(
4178
5504
  entry: RouteEntry,
4179
- stage: Extract<RouteProgramStage, { kind: "headers" | "params" | "body" | "query" }>,
5505
+ stage: Extract<RouteProgramStage, { kind: ProgramValidationKind }>,
4180
5506
  source: RequestSource,
4181
5507
  ctx: RawContext,
4182
5508
  ): MaybePromise<Response | ResponseResult | undefined> {
5509
+ if (stage.kind === "body") return this.readProgramBody(entry, source, ctx)
4183
5510
  const input =
4184
5511
  stage.kind === "headers"
4185
5512
  ? headerObjectOf(source.headers)
4186
- : stage.kind === "params"
4187
- ? ctx.params
4188
- : stage.kind === "query"
4189
- ? queryObjectOf(ctx[CONTEXT_SEARCH])
4190
- : undefined
4191
- if (stage.kind === "body") return this.readProgramBody(entry, source, ctx)
5513
+ : stage.kind === "query"
5514
+ ? queryObjectOf(ctx[CONTEXT_SEARCH])
5515
+ : ctx[stage.kind]
4192
5516
  const validation = stage.schema["~standard"].validate(input)
4193
5517
  return validation instanceof Promise
4194
5518
  ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, stage.kind))
@@ -4263,6 +5587,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4263
5587
  (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
4264
5588
  wrapResponse,
4265
5589
  (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
5590
+ entry.schema?.body,
4266
5591
  )
4267
5592
  }
4268
5593
 
@@ -4627,7 +5952,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4627
5952
  try {
4628
5953
  const contract = entry.responseContract
4629
5954
  if (contract === undefined) return finalize(result, responseSet(ctx))
4630
- const checked = contract.runtime.check(contract.schema, result)
5955
+ const checked = contract.runtime.check(contract.definition, result, ctx)
4631
5956
  if (checked instanceof Promise) {
4632
5957
  return checked.then(
4633
5958
  (outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome),
@@ -4681,13 +6006,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4681
6006
  wrapResponse: (response: Response | ResponseResult) => T,
4682
6007
  ): MaybePromise<T> {
4683
6008
  // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
4684
- // an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
4685
- // or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
4686
- // any handler or loader.
4687
- 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))
4688
6014
  // Same rule for a thrown `status(...)`, but through `finalize` rather than `wrapResponse`: the
4689
6015
  // value is still plain data, so the ordinary JSON lane renders it and no `Response` is built.
4690
- if (isResponseResult(err)) return finalize(err, responseSet(ctx))
6016
+ if (isResponseResult(err))
6017
+ return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, err)
4691
6018
  if (entry.onError.length === 0) {
4692
6019
  // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
4693
6020
  // (redacting) logger. Body-read failures and around-hook failures land here too.
@@ -4747,6 +6074,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4747
6074
  validate,
4748
6075
  (response) => response,
4749
6076
  (error) => Promise.reject(error),
6077
+ bodySchema,
4750
6078
  )
4751
6079
  }
4752
6080
 
@@ -4756,13 +6084,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4756
6084
  entry: RouteEntry,
4757
6085
  result: StandardResult<unknown>,
4758
6086
  ctx: RawContext,
4759
- kind: "body" | "query" | "params" | "headers",
6087
+ kind: ProgramValidationKind,
4760
6088
  ): MaybePromise<Response | ResponseResult | undefined> {
4761
6089
  const assign = (value: unknown): void => {
4762
- if (kind === "body") ctx.body = value
4763
- else if (kind === "query") ctx.query = value
4764
- else if (kind === "headers") ctx.headers = value as Record<string, string>
4765
- else ctx.params = value as Record<string, string>
6090
+ ;(ctx as unknown as Record<ProgramValidationKind, unknown>)[kind] = value
4766
6091
  }
4767
6092
  if (result.issues === undefined) {
4768
6093
  assign(result.value)
@@ -4781,22 +6106,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4781
6106
 
4782
6107
  private finishLifecycleValidationRecovery(
4783
6108
  entry: RouteEntry,
4784
- kind: "body" | "query" | "params" | "headers",
6109
+ kind: ProgramValidationKind,
4785
6110
  issues: ReadonlyArray<StandardIssue>,
4786
6111
  recovery: unknown,
4787
6112
  assign: (value: unknown) => void,
4788
6113
  ): MaybePromise<Response | ResponseResult | undefined> {
4789
6114
  if (recovery === undefined) return plainValidationError(issues)
4790
6115
  if (recovery instanceof Response) return recovery
4791
- const schema =
4792
- kind === "body"
4793
- ? entry.schema?.body
4794
- : kind === "query"
4795
- ? entry.schema?.query
4796
- : kind === "params"
4797
- ? entry.schema?.params
4798
- : entry.schema?.headers
4799
- const retried = schema!["~standard"].validate(recovery)
6116
+ const retried = entry.schema![kind]!["~standard"].validate(recovery)
4800
6117
  if (retried instanceof Promise) {
4801
6118
  return retried.then((settled) => {
4802
6119
  if (settled.issues !== undefined) return plainValidationError(settled.issues)
@@ -4817,6 +6134,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4817
6134
  fused: FusedWebRunner | undefined,
4818
6135
  signal: AbortSignal | undefined,
4819
6136
  budget: RequestBudget | undefined,
6137
+ peer: PeerPlatform,
6138
+ fallback: BunNativeHandler,
4820
6139
  ): BunNativeHandler {
4821
6140
  // This callback is reached only from Bun's compiled native route table. Bun has already parsed
4822
6141
  // the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
@@ -4825,7 +6144,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4825
6144
  // The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
4826
6145
  // marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
4827
6146
  // reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
4828
- 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
4829
6151
  const inner = fused
4830
6152
  const capped: FusedWebRunner | undefined =
4831
6153
  inner === undefined || bodyLimit === undefined
@@ -4835,68 +6157,90 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4835
6157
  if (method !== "GET" && method !== "HEAD") markTransportCap(source, bodyLimit)
4836
6158
  return inner(source, params, search, fusedSignal, fusedBudget, platform, nativeContext)
4837
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
+ }
4838
6174
  if (paramNames.length === 0) {
4839
6175
  if (capped === undefined) {
4840
6176
  return (request) => {
4841
6177
  markFramed(request)
4842
- return this.fetchMatched(request, entry, EMPTY_PARAMS)
6178
+ return this.fetchMatched(request, entry, EMPTY_PARAMS, peer)
4843
6179
  }
4844
6180
  }
4845
6181
  if (this.acceptInboundDeadlines) {
4846
6182
  return (request) => {
4847
6183
  markFramed(request)
4848
6184
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
4849
- ? this.fetchMatched(request, entry, EMPTY_PARAMS)
4850
- : 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)
4851
6187
  }
4852
6188
  }
4853
6189
  return (request) => {
4854
6190
  markFramed(request)
4855
- return capped(request, EMPTY_PARAMS, undefined, signal!, budget!, undefined, true)
6191
+ return runNative(request, EMPTY_PARAMS)
4856
6192
  }
4857
6193
  }
4858
6194
 
4859
6195
  const malformed =
4860
6196
  paramNames.length === 1
4861
- ? (params: Record<string, string>) => params[paramNames[0]!]?.includes("\uFFFD") === true
4862
- : hasReplacementParam
6197
+ ? (params: Record<string, string>) => unroutedParam(params[paramNames[0]!])
6198
+ : hasUnroutedParam
4863
6199
  if (capped === undefined) {
4864
- return (request) => {
6200
+ return (request, server) => {
4865
6201
  markFramed(request)
4866
6202
  const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
4867
- if (malformed(params)) return this.fetchSource(request)
4868
- return this.fetchMatched(request, entry, params)
6203
+ if (malformed(params)) return fallback(request, server)
6204
+ return this.fetchMatched(request, entry, params, peer)
4869
6205
  }
4870
6206
  }
4871
6207
  if (this.acceptInboundDeadlines) {
4872
- return (request) => {
6208
+ return (request, server) => {
4873
6209
  markFramed(request)
4874
6210
  const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
4875
- if (malformed(params)) return this.fetchSource(request)
6211
+ if (malformed(params)) return fallback(request, server)
4876
6212
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
4877
- ? this.fetchMatched(request, entry, params)
4878
- : capped(request, params, undefined, signal!, budget!, undefined, true)
6213
+ ? this.fetchMatched(request, entry, params, peer)
6214
+ : capped(request, params, undefined, signal!, budget!, peer, true)
4879
6215
  }
4880
6216
  }
4881
- return (request) => {
6217
+ return (request, server) => {
4882
6218
  markFramed(request)
4883
6219
  const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
4884
- if (malformed(params)) return this.fetchSource(request)
4885
- return capped(request, params, undefined, signal!, budget!, undefined, true)
6220
+ if (malformed(params)) return fallback(request, server)
6221
+ return runNative(request, params)
4886
6222
  }
4887
6223
  }
4888
6224
 
4889
6225
  /** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
4890
6226
  * hooks or WebSockets retain the single portable dispatcher because those features must run before
4891
- * route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
4892
- * capture semantics; static and `:param` routes take the native lane. */
4893
- 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 {
4894
6237
  // A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
4895
6238
  // bypasses - so an app that declares trust routes through the fetch lane (where `c.clientIp`
4896
6239
  // resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
4897
6240
  if (
4898
- this.onRequestHooks.length > 0 ||
6241
+ (this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe) ||
4899
6242
  this.wsRouteCount > 0 ||
6243
+ this.preRouteMountCount > 0 ||
4900
6244
  this.clientIpTrust !== undefined
4901
6245
  ) {
4902
6246
  return undefined
@@ -4905,7 +6249,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4905
6249
  const routes: BunNativeRoutes = Object.create(null) as BunNativeRoutes
4906
6250
  const mayUseFusedNative =
4907
6251
  this.requestTimeoutMs === 0 &&
4908
- this.onResponseHooks.length === 0 &&
6252
+ (this.onResponseHooks.length === 0 || this.bunNativeResponseHeadersOnly) &&
4909
6253
  this.onResponseFinalizedHooks.length === 0 &&
4910
6254
  // The capacity gate must wrap every request; the fused lane bypasses fetchMatched, so enabling
4911
6255
  // admission drops fusion (native matching stays) and routes through the gated matched lane.
@@ -4913,23 +6257,75 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4913
6257
  const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined
4914
6258
  const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined
4915
6259
  let count = 0
4916
- for (const { method, path, pattern, entry } of this.catalog.entries()) {
4917
- if (pattern.segments.some((segment) => segment.kind === "wildcard")) continue
4918
- let methods = routes[path]
4919
- if (methods === undefined) {
4920
- methods = Object.create(null) as BunNativeMethodTable
4921
- 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
4922
6312
  }
4923
6313
  const paramNames = pattern.paramNames
4924
6314
  const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined
4925
- methods[method] = this.compileBunNativeHandler(
6315
+ tableAt(path)[method as Method] = this.compileBunNativeHandler(
4926
6316
  entry,
4927
6317
  paramNames,
4928
6318
  fused,
4929
6319
  unboundedSignal,
4930
6320
  unboundedBudget,
6321
+ peer,
6322
+ fallback,
4931
6323
  )
4932
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
+ }
4933
6329
  }
4934
6330
  if (count === 0) return undefined
4935
6331
  // RFC 9110 §9.3.2: a GET route answers HEAD with identical status + headers (Bun strips the
@@ -4940,6 +6336,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4940
6336
  const methods = routes[path]!
4941
6337
  if (methods.GET !== undefined) methods.HEAD ??= methods.GET
4942
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
+ }
4943
6350
  return routes
4944
6351
  }
4945
6352
 
@@ -4964,6 +6371,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4964
6371
  * connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
4965
6372
  * this above their slowest expected response, which is why it is a first-class option and not a
4966
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.
4967
6378
  */
4968
6379
  listen(
4969
6380
  port: number,
@@ -4971,6 +6382,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4971
6382
  readonly reusePort?: boolean
4972
6383
  readonly hostname?: string
4973
6384
  readonly idleTimeoutSec?: number
6385
+ readonly tls?: ListenTlsOptions
4974
6386
  },
4975
6387
  ): RunningServer {
4976
6388
  if (typeof Bun === "undefined") {
@@ -4998,34 +6410,63 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4998
6410
  // because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
4999
6411
  // Native pub/sub when the app has WS routes and none validate outbound frames: `ws.subscribe` and
5000
6412
  // `app.publish` go through Bun's own (uWebSockets) broadcast instead of the JS registry loop.
5001
- const nativePubsub = this.wsRouteCount > 0 && !this.wsHasValidatedSend
5002
- const wsHandlers =
5003
- this.wsRouteCount === 0
5004
- ? undefined
5005
- : (this.wsRuntime as WsRuntime).bunHandlers(this.topics as TopicRegistry, nativePubsub)
5006
- const reusePort = options?.reusePort === true
5007
- // Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
5008
- // on some option paths, and omitting is what selects its 0.0.0.0 default.
5009
- const bind = options?.hostname === undefined ? {} : { hostname: options.hostname }
5010
- // Same reasoning as `bind`: omit rather than pass undefined, so Bun's own default applies.
5011
- const idle =
5012
- options?.idleTimeoutSec === undefined ? {} : { idleTimeout: options.idleTimeoutSec }
5013
- 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
5014
6462
  const running = (wsHandlers === undefined
5015
6463
  ? Bun.serve({
5016
- port,
5017
- reusePort,
5018
- ...bind,
5019
- ...idle,
6464
+ ...listening,
5020
6465
  ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
5021
- fetch: (req: Request, server) =>
5022
- this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>),
6466
+ fetch: fallback,
5023
6467
  })
5024
6468
  : Bun.serve<BunWsData>({
5025
- port,
5026
- reusePort,
5027
- ...bind,
5028
- ...idle,
6469
+ ...listening,
5029
6470
  fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
5030
6471
  // Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
5031
6472
  // `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
@@ -5041,7 +6482,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5041
6482
  })) as unknown as RunningServer
5042
6483
  this.bunServer = running
5043
6484
  // Bind `app.publish` to Bun's native broadcast now that the server handle exists. Guarded on the
5044
- // 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
5045
6488
  if (nativePubsub && typeof running.publish === "function") {
5046
6489
  const native = running.publish.bind(running)
5047
6490
  this.nativePublish = (topic, data) => {