@rangojs/router 0.0.0-experimental.19 → 0.0.0-experimental.1c0bdfad

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 (406) hide show
  1. package/AGENTS.md +17 -0
  2. package/README.md +291 -61
  3. package/dist/bin/rango.js +544 -143
  4. package/dist/testing/vitest.js +82 -0
  5. package/dist/vite/index.js +3744 -1329
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +67 -13
  8. package/skills/api-client/SKILL.md +211 -0
  9. package/skills/breadcrumbs/SKILL.md +312 -0
  10. package/skills/bundle-analysis/SKILL.md +159 -0
  11. package/skills/cache-guide/SKILL.md +247 -23
  12. package/skills/caching/SKILL.md +322 -19
  13. package/skills/composability/SKILL.md +27 -2
  14. package/skills/css/SKILL.md +76 -0
  15. package/skills/debug-manifest/SKILL.md +4 -2
  16. package/skills/document-cache/SKILL.md +78 -55
  17. package/skills/handler-use/SKILL.md +364 -0
  18. package/skills/hooks/SKILL.md +282 -60
  19. package/skills/host-router/SKILL.md +278 -0
  20. package/skills/i18n/SKILL.md +276 -0
  21. package/skills/intercept/SKILL.md +50 -6
  22. package/skills/layout/SKILL.md +35 -9
  23. package/skills/links/SKILL.md +249 -17
  24. package/skills/loader/SKILL.md +297 -31
  25. package/skills/middleware/SKILL.md +52 -13
  26. package/skills/migrate-nextjs/SKILL.md +584 -0
  27. package/skills/migrate-react-router/SKILL.md +771 -0
  28. package/skills/mime-routes/SKILL.md +28 -1
  29. package/skills/observability/SKILL.md +172 -0
  30. package/skills/parallel/SKILL.md +203 -7
  31. package/skills/prerender/SKILL.md +155 -111
  32. package/skills/rango/SKILL.md +251 -23
  33. package/skills/react-compiler/SKILL.md +168 -0
  34. package/skills/response-routes/SKILL.md +123 -48
  35. package/skills/route/SKILL.md +104 -9
  36. package/skills/router-setup/SKILL.md +124 -11
  37. package/skills/scripts/SKILL.md +179 -0
  38. package/skills/server-actions/SKILL.md +775 -0
  39. package/skills/streams-and-websockets/SKILL.md +283 -0
  40. package/skills/tailwind/SKILL.md +27 -3
  41. package/skills/testing/SKILL.md +125 -222
  42. package/skills/testing/bindings.md +103 -0
  43. package/skills/testing/cache-prerender.md +127 -0
  44. package/skills/testing/client-components.md +124 -0
  45. package/skills/testing/e2e-parity.md +125 -0
  46. package/skills/testing/flight.md +91 -0
  47. package/skills/testing/handles.md +129 -0
  48. package/skills/testing/loader.md +128 -0
  49. package/skills/testing/middleware.md +99 -0
  50. package/skills/testing/render-handler.md +121 -0
  51. package/skills/testing/response-routes.md +95 -0
  52. package/skills/testing/reverse-and-types.md +84 -0
  53. package/skills/testing/server-actions.md +107 -0
  54. package/skills/testing/server-tree.md +128 -0
  55. package/skills/testing/setup.md +123 -0
  56. package/skills/typesafety/SKILL.md +357 -52
  57. package/skills/use-cache/SKILL.md +46 -14
  58. package/skills/view-transitions/SKILL.md +294 -0
  59. package/src/__augment-tests__/augment.ts +81 -0
  60. package/src/__augment-tests__/augmented.check.ts +116 -0
  61. package/src/__internal.ts +67 -40
  62. package/src/bin/rango.ts +18 -0
  63. package/src/browser/action-coordinator.ts +53 -36
  64. package/src/browser/action-fence.ts +47 -0
  65. package/src/browser/app-shell.ts +39 -0
  66. package/src/browser/app-version.ts +14 -0
  67. package/src/browser/connection-warmup.ts +134 -0
  68. package/src/browser/cookie-name.ts +140 -0
  69. package/src/browser/event-controller.ts +197 -150
  70. package/src/browser/history-state.ts +21 -0
  71. package/src/browser/index.ts +3 -3
  72. package/src/browser/invalidate-client-cache.ts +52 -0
  73. package/src/browser/link-interceptor.ts +4 -0
  74. package/src/browser/navigation-bridge.ts +200 -30
  75. package/src/browser/navigation-client.ts +217 -58
  76. package/src/browser/navigation-store-handle.ts +38 -0
  77. package/src/browser/navigation-store.ts +76 -67
  78. package/src/browser/navigation-transaction.ts +18 -66
  79. package/src/browser/network-error-handler.ts +34 -7
  80. package/src/browser/partial-update.ts +187 -112
  81. package/src/browser/prefetch/cache.ts +312 -30
  82. package/src/browser/prefetch/fetch.ts +344 -47
  83. package/src/browser/prefetch/policy.ts +6 -0
  84. package/src/browser/prefetch/queue.ts +126 -20
  85. package/src/browser/prefetch/resource-ready.ts +77 -0
  86. package/src/browser/rango-state.ts +158 -76
  87. package/src/browser/react/Link.tsx +125 -18
  88. package/src/browser/react/NavigationProvider.tsx +135 -120
  89. package/src/browser/react/ScrollRestoration.tsx +10 -6
  90. package/src/browser/react/context.ts +7 -2
  91. package/src/browser/react/filter-segment-order.ts +66 -7
  92. package/src/browser/react/index.ts +0 -48
  93. package/src/browser/react/location-state-shared.ts +178 -8
  94. package/src/browser/react/location-state.ts +39 -14
  95. package/src/browser/react/use-action.ts +6 -15
  96. package/src/browser/react/use-handle.ts +23 -69
  97. package/src/browser/react/use-href.tsx +8 -1
  98. package/src/browser/react/use-link-status.ts +33 -8
  99. package/src/browser/react/use-navigation.ts +32 -7
  100. package/src/browser/react/use-params.ts +20 -10
  101. package/src/browser/react/use-reverse.ts +106 -0
  102. package/src/browser/react/use-router.ts +46 -11
  103. package/src/browser/react/use-search-params.ts +0 -5
  104. package/src/browser/react/use-segments.ts +11 -21
  105. package/src/browser/response-adapter.ts +80 -5
  106. package/src/browser/rsc-router.tsx +226 -75
  107. package/src/browser/scroll-restoration.ts +54 -42
  108. package/src/browser/segment-reconciler.ts +36 -9
  109. package/src/browser/segment-structure-assert.ts +2 -2
  110. package/src/browser/server-action-bridge.ts +619 -442
  111. package/src/browser/types.ts +115 -11
  112. package/src/browser/validate-redirect-origin.ts +43 -16
  113. package/src/build/collect-fallback-refs.ts +107 -0
  114. package/src/build/generate-manifest.ts +65 -40
  115. package/src/build/generate-route-types.ts +7 -1
  116. package/src/build/index.ts +8 -2
  117. package/src/build/prefix-tree-utils.ts +123 -0
  118. package/src/build/route-trie.ts +182 -37
  119. package/src/build/route-types/ast-route-extraction.ts +15 -8
  120. package/src/build/route-types/codegen.ts +16 -5
  121. package/src/build/route-types/include-resolution.ts +125 -24
  122. package/src/build/route-types/param-extraction.ts +6 -3
  123. package/src/build/route-types/per-module-writer.ts +22 -6
  124. package/src/build/route-types/router-processing.ts +392 -106
  125. package/src/build/route-types/scan-filter.ts +9 -2
  126. package/src/build/route-types/source-scan.ts +216 -0
  127. package/src/build/runtime-discovery.ts +9 -20
  128. package/src/cache/cache-error.ts +104 -0
  129. package/src/cache/cache-key-utils.ts +29 -13
  130. package/src/cache/cache-policy.ts +108 -34
  131. package/src/cache/cache-runtime.ts +214 -48
  132. package/src/cache/cache-scope.ts +236 -89
  133. package/src/cache/cache-tag.ts +103 -0
  134. package/src/cache/cf/cf-base64.ts +33 -0
  135. package/src/cache/cf/cf-cache-constants.ts +127 -0
  136. package/src/cache/cf/cf-cache-store.ts +2224 -171
  137. package/src/cache/cf/cf-cache-types.ts +349 -0
  138. package/src/cache/cf/cf-kv-utils.ts +46 -0
  139. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  140. package/src/cache/cf/index.ts +11 -17
  141. package/src/cache/document-cache.ts +89 -27
  142. package/src/cache/handle-snapshot.ts +70 -0
  143. package/src/cache/index.ts +11 -20
  144. package/src/cache/memory-segment-store.ts +136 -37
  145. package/src/cache/profile-registry.ts +31 -31
  146. package/src/cache/read-through-swr.ts +41 -11
  147. package/src/cache/segment-codec.ts +9 -17
  148. package/src/cache/tag-invalidation.ts +230 -0
  149. package/src/cache/taint.ts +55 -0
  150. package/src/cache/types.ts +37 -100
  151. package/src/client.rsc.tsx +45 -21
  152. package/src/client.tsx +120 -336
  153. package/src/cloudflare/index.ts +11 -0
  154. package/src/cloudflare/tracing.ts +109 -0
  155. package/src/component-utils.ts +19 -0
  156. package/src/components/DefaultDocument.tsx +8 -2
  157. package/src/context-var.ts +84 -2
  158. package/src/debug.ts +2 -2
  159. package/src/decode-loader-results.ts +52 -0
  160. package/src/defer.ts +196 -0
  161. package/src/deps/ssr.ts +0 -1
  162. package/src/encode-kv.ts +49 -0
  163. package/src/errors.ts +30 -4
  164. package/src/escape-script.ts +52 -0
  165. package/src/handle.ts +70 -22
  166. package/src/handles/MetaTags.tsx +56 -19
  167. package/src/handles/Scripts.tsx +183 -0
  168. package/src/handles/breadcrumbs.ts +95 -0
  169. package/src/handles/is-thenable.ts +19 -0
  170. package/src/handles/meta.ts +51 -40
  171. package/src/handles/script.ts +244 -0
  172. package/src/host/cookie-handler.ts +9 -60
  173. package/src/host/errors.ts +0 -24
  174. package/src/host/index.ts +8 -5
  175. package/src/host/pattern-matcher.ts +23 -52
  176. package/src/host/router.ts +107 -99
  177. package/src/host/testing.ts +40 -27
  178. package/src/host/types.ts +37 -4
  179. package/src/host/utils.ts +1 -1
  180. package/src/href-client.ts +137 -22
  181. package/src/index.rsc.ts +79 -29
  182. package/src/index.ts +149 -65
  183. package/src/internal-debug.ts +11 -10
  184. package/src/loader-store.ts +500 -0
  185. package/src/loader.rsc.ts +20 -13
  186. package/src/loader.ts +12 -11
  187. package/src/missing-id-error.ts +68 -0
  188. package/src/outlet-context.ts +1 -1
  189. package/src/outlet-provider.tsx +1 -5
  190. package/src/prerender/param-hash.ts +16 -16
  191. package/src/prerender/store.ts +63 -26
  192. package/src/prerender.ts +198 -82
  193. package/src/redirect-origin.ts +100 -0
  194. package/src/regex-escape.ts +8 -0
  195. package/src/render-error-thrower.tsx +20 -0
  196. package/src/response-utils.ts +62 -0
  197. package/src/reverse.ts +65 -15
  198. package/src/root-error-boundary.tsx +1 -19
  199. package/src/route-content-wrapper.tsx +7 -72
  200. package/src/route-definition/dsl-helpers.ts +469 -276
  201. package/src/route-definition/helper-factories.ts +29 -139
  202. package/src/route-definition/helpers-types.ts +113 -37
  203. package/src/route-definition/index.ts +3 -3
  204. package/src/route-definition/redirect.ts +53 -12
  205. package/src/route-definition/resolve-handler-use.ts +161 -0
  206. package/src/route-definition/use-item-types.ts +32 -0
  207. package/src/route-map-builder.ts +7 -17
  208. package/src/route-types.ts +37 -41
  209. package/src/router/basename.ts +14 -0
  210. package/src/router/content-negotiation.ts +164 -17
  211. package/src/router/error-handling.ts +45 -18
  212. package/src/router/find-match.ts +45 -22
  213. package/src/router/handler-context.ts +110 -39
  214. package/src/router/instrument.ts +350 -0
  215. package/src/router/intercept-resolution.ts +50 -24
  216. package/src/router/lazy-includes.ts +19 -53
  217. package/src/router/loader-resolution.ts +274 -56
  218. package/src/router/logging.ts +5 -8
  219. package/src/router/manifest.ts +49 -45
  220. package/src/router/match-api.ts +121 -205
  221. package/src/router/match-context.ts +0 -22
  222. package/src/router/match-handlers.ts +58 -58
  223. package/src/router/match-middleware/background-revalidation.ts +33 -6
  224. package/src/router/match-middleware/cache-lookup.ts +214 -263
  225. package/src/router/match-middleware/cache-store.ts +73 -33
  226. package/src/router/match-middleware/intercept-resolution.ts +8 -28
  227. package/src/router/match-middleware/segment-resolution.ts +52 -18
  228. package/src/router/match-pipelines.ts +1 -42
  229. package/src/router/match-result.ts +104 -49
  230. package/src/router/metrics.ts +217 -26
  231. package/src/router/middleware-types.ts +24 -110
  232. package/src/router/middleware.ts +384 -197
  233. package/src/router/navigation-snapshot.ts +131 -0
  234. package/src/router/params-util.ts +23 -0
  235. package/src/router/pattern-matching.ts +148 -91
  236. package/src/router/prefetch-cache-ttl.ts +51 -0
  237. package/src/router/prerender-match.ts +199 -56
  238. package/src/router/preview-match.ts +32 -102
  239. package/src/router/request-classification.ts +276 -0
  240. package/src/router/revalidation.ts +144 -74
  241. package/src/router/route-snapshot.ts +244 -0
  242. package/src/router/router-context.ts +8 -28
  243. package/src/router/router-interfaces.ts +129 -36
  244. package/src/router/router-options.ts +185 -23
  245. package/src/router/router-registry.ts +2 -5
  246. package/src/router/segment-resolution/fresh.ts +281 -76
  247. package/src/router/segment-resolution/helpers.ts +116 -31
  248. package/src/router/segment-resolution/loader-cache.ts +63 -37
  249. package/src/router/segment-resolution/revalidation.ts +493 -391
  250. package/src/router/segment-resolution/static-store.ts +19 -5
  251. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  252. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  253. package/src/router/segment-resolution.ts +5 -1
  254. package/src/router/segment-wrappers.ts +8 -5
  255. package/src/router/state-cookie-name.ts +33 -0
  256. package/src/router/substitute-pattern-params.ts +56 -0
  257. package/src/router/telemetry-otel.ts +161 -199
  258. package/src/router/telemetry.ts +96 -19
  259. package/src/router/timeout.ts +0 -20
  260. package/src/router/tracing.ts +206 -0
  261. package/src/router/trie-matching.ts +180 -58
  262. package/src/router/types.ts +10 -63
  263. package/src/router/url-params.ts +44 -0
  264. package/src/router.ts +182 -54
  265. package/src/rsc/handler-context.ts +3 -2
  266. package/src/rsc/handler.ts +702 -460
  267. package/src/rsc/helpers.ts +168 -46
  268. package/src/rsc/index.ts +2 -25
  269. package/src/rsc/json-route-result.ts +38 -0
  270. package/src/rsc/loader-fetch.ts +127 -31
  271. package/src/rsc/manifest-init.ts +33 -42
  272. package/src/rsc/origin-guard.ts +39 -25
  273. package/src/rsc/progressive-enhancement.ts +98 -19
  274. package/src/rsc/redirect-guard.ts +99 -0
  275. package/src/rsc/response-cache-serve.ts +238 -0
  276. package/src/rsc/response-error.ts +79 -12
  277. package/src/rsc/response-route-handler.ts +99 -189
  278. package/src/rsc/rsc-rendering.ts +126 -106
  279. package/src/rsc/runtime-warnings.ts +23 -10
  280. package/src/rsc/server-action.ts +269 -114
  281. package/src/rsc/ssr-setup.ts +144 -0
  282. package/src/rsc/types.ts +34 -6
  283. package/src/runtime-env.ts +18 -0
  284. package/src/search-params.ts +49 -41
  285. package/src/segment-content-promise.ts +67 -0
  286. package/src/segment-loader-promise.ts +149 -0
  287. package/src/segment-system.tsx +281 -129
  288. package/src/serialize.ts +243 -0
  289. package/src/server/context.ts +317 -63
  290. package/src/server/cookie-parse.ts +32 -0
  291. package/src/server/cookie-store.ts +80 -5
  292. package/src/server/handle-store.ts +40 -38
  293. package/src/server/loader-registry.ts +26 -46
  294. package/src/server/request-context.ts +425 -177
  295. package/src/server.ts +6 -0
  296. package/src/ssr/index.tsx +25 -16
  297. package/src/static-handler.ts +27 -18
  298. package/src/testing/cache-status.ts +162 -0
  299. package/src/testing/collect-handle.ts +40 -0
  300. package/src/testing/dispatch.ts +701 -0
  301. package/src/testing/dom.entry.ts +22 -0
  302. package/src/testing/e2e/fixture.ts +188 -0
  303. package/src/testing/e2e/index.ts +128 -0
  304. package/src/testing/e2e/matchers.ts +35 -0
  305. package/src/testing/e2e/page-helpers.ts +272 -0
  306. package/src/testing/e2e/parity.ts +387 -0
  307. package/src/testing/e2e/server.ts +195 -0
  308. package/src/testing/flight-matchers.ts +97 -0
  309. package/src/testing/flight-normalize.ts +11 -0
  310. package/src/testing/flight-runtime.d.ts +57 -0
  311. package/src/testing/flight-tree.ts +682 -0
  312. package/src/testing/flight.entry.ts +52 -0
  313. package/src/testing/flight.ts +257 -0
  314. package/src/testing/generated-routes.ts +183 -0
  315. package/src/testing/index.ts +99 -0
  316. package/src/testing/internal/context.ts +371 -0
  317. package/src/testing/internal/flight-client-globals.ts +30 -0
  318. package/src/testing/internal/seed-vars.ts +54 -0
  319. package/src/testing/render-handler.ts +343 -0
  320. package/src/testing/render-route.tsx +581 -0
  321. package/src/testing/run-loader.ts +385 -0
  322. package/src/testing/run-middleware.ts +205 -0
  323. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  324. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  325. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  326. package/src/testing/vitest-stubs/version.ts +5 -0
  327. package/src/testing/vitest.ts +305 -0
  328. package/src/theme/ThemeProvider.tsx +20 -58
  329. package/src/theme/ThemeScript.tsx +7 -9
  330. package/src/theme/constants.ts +52 -13
  331. package/src/theme/index.ts +3 -19
  332. package/src/theme/theme-context.ts +1 -5
  333. package/src/theme/theme-script.ts +22 -21
  334. package/src/theme/use-theme.ts +0 -3
  335. package/src/types/boundaries.ts +0 -35
  336. package/src/types/cache-types.ts +17 -8
  337. package/src/types/error-types.ts +30 -90
  338. package/src/types/global-namespace.ts +54 -41
  339. package/src/types/handler-context.ts +236 -88
  340. package/src/types/index.ts +1 -10
  341. package/src/types/loader-types.ts +44 -15
  342. package/src/types/request-scope.ts +112 -0
  343. package/src/types/route-config.ts +10 -45
  344. package/src/types/route-entry.ts +19 -7
  345. package/src/types/segments.ts +37 -19
  346. package/src/urls/include-helper.ts +33 -70
  347. package/src/urls/index.ts +1 -11
  348. package/src/urls/path-helper-types.ts +58 -11
  349. package/src/urls/path-helper.ts +57 -111
  350. package/src/urls/pattern-types.ts +48 -19
  351. package/src/urls/response-types.ts +25 -22
  352. package/src/urls/type-extraction.ts +58 -139
  353. package/src/urls/urls-function.ts +1 -18
  354. package/src/use-loader.tsx +346 -89
  355. package/src/vite/debug.ts +185 -0
  356. package/src/vite/discovery/bundle-postprocess.ts +64 -91
  357. package/src/vite/discovery/discover-routers.ts +147 -88
  358. package/src/vite/discovery/discovery-errors.ts +194 -0
  359. package/src/vite/discovery/gate-state.ts +171 -0
  360. package/src/vite/discovery/prerender-collection.ts +247 -145
  361. package/src/vite/discovery/route-types-writer.ts +40 -84
  362. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  363. package/src/vite/discovery/state.ts +61 -13
  364. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  365. package/src/vite/index.ts +10 -3
  366. package/src/vite/inject-client-debug.ts +36 -0
  367. package/src/vite/plugin-types.ts +155 -65
  368. package/src/vite/plugins/cjs-to-esm.ts +16 -19
  369. package/src/vite/plugins/client-ref-dedup.ts +120 -0
  370. package/src/vite/plugins/client-ref-hashing.ts +28 -15
  371. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  372. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  373. package/src/vite/plugins/cloudflare-protocol-stub.ts +194 -0
  374. package/src/vite/plugins/expose-action-id.ts +49 -98
  375. package/src/vite/plugins/expose-id-utils.ts +96 -51
  376. package/src/vite/plugins/expose-ids/export-analysis.ts +101 -34
  377. package/src/vite/plugins/expose-ids/handler-transform.ts +15 -64
  378. package/src/vite/plugins/expose-ids/loader-transform.ts +14 -24
  379. package/src/vite/plugins/expose-ids/router-transform.ts +118 -29
  380. package/src/vite/plugins/expose-internal-ids.ts +553 -317
  381. package/src/vite/plugins/performance-tracks.ts +89 -0
  382. package/src/vite/plugins/refresh-cmd.ts +127 -0
  383. package/src/vite/plugins/use-cache-transform.ts +73 -83
  384. package/src/vite/plugins/version-injector.ts +21 -25
  385. package/src/vite/plugins/version-plugin.ts +46 -37
  386. package/src/vite/plugins/virtual-entries.ts +13 -18
  387. package/src/vite/rango.ts +241 -287
  388. package/src/vite/router-discovery.ts +956 -149
  389. package/src/vite/utils/ast-handler-extract.ts +26 -35
  390. package/src/vite/utils/banner.ts +4 -4
  391. package/src/vite/utils/bundle-analysis.ts +10 -15
  392. package/src/vite/utils/client-chunks.ts +184 -0
  393. package/src/vite/utils/directive-prologue.ts +40 -0
  394. package/src/vite/utils/forward-user-plugins.ts +171 -0
  395. package/src/vite/utils/manifest-utils.ts +4 -59
  396. package/src/vite/utils/package-resolution.ts +20 -52
  397. package/src/vite/utils/prerender-utils.ts +141 -34
  398. package/src/vite/utils/shared-utils.ts +92 -42
  399. package/CLAUDE.md +0 -5
  400. package/src/browser/action-response-classifier.ts +0 -99
  401. package/src/browser/react/use-client-cache.ts +0 -58
  402. package/src/browser/shallow.ts +0 -40
  403. package/src/handles/index.ts +0 -6
  404. package/src/network-error-thrower.tsx +0 -23
  405. package/src/route-definition/route-function.ts +0 -119
  406. package/src/router/middleware-cookies.ts +0 -55
@@ -1,15 +1,10 @@
1
1
  /// <reference types="vite/types/importMeta.d.ts" />
2
- /**
3
- * Middleware Execution
4
- *
5
- * True middleware that wraps the entire RSC handler.
6
- * - `await next()` returns actual Response
7
- * - Can modify response headers
8
- * - Can catch errors from RSC rendering
9
- * - Forgiving API: if middleware doesn't return, original response is used
10
- */
11
2
 
12
3
  import { contextGet, contextSet } from "../context-var.js";
4
+ import { escapeRegExp } from "../regex-escape.js";
5
+ import { parsePattern as parseRoutePattern } from "./pattern-matching.js";
6
+ import { safeDecodeURIComponent } from "./url-params.js";
7
+ import { fireAndForgetWaitUntil } from "../types/request-scope.js";
13
8
  import type {
14
9
  CollectedMiddleware,
15
10
  MiddlewareCollectableEntry,
@@ -19,46 +14,79 @@ import type {
19
14
  ResponseHolder,
20
15
  } from "./middleware-types.js";
21
16
  import { _getRequestContext } from "../server/request-context.js";
17
+ import {
18
+ EXTERNAL_REDIRECT_MARKER,
19
+ isExternalRedirect,
20
+ markExternalRedirect,
21
+ } from "../redirect-origin.js";
22
22
  import { isAutoGeneratedRouteName } from "../route-name.js";
23
+ import { appendMetric, createMetricsStore } from "./metrics.js";
24
+ import { observePhase, PHASES } from "./instrument.js";
25
+ import { stripInternalParams } from "./handler-context.js";
26
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
23
27
 
24
- // Re-export types and cookie utilities for backward compatibility
28
+ // Re-export types consumed through this module's path.
25
29
  export type {
26
30
  CookieOptions,
27
- CollectedMiddleware,
28
- MiddlewareCollectableEntry,
29
31
  MiddlewareContext,
30
32
  MiddlewareEntry,
31
33
  MiddlewareFn,
32
- ResponseHolder,
33
34
  } from "./middleware-types.js";
34
- export { parseCookies, serializeCookie } from "./middleware-cookies.js";
35
-
36
- // W5: Deduplicate by function reference so each distinct middleware warns once,
37
- // regardless of whether it is named or anonymous.
38
- let warnedRedirectMiddleware = new WeakSet<Function>();
39
-
40
- function warnCtxSetBeforeRedirect(handler: Function): void {
41
- if (warnedRedirectMiddleware.has(handler)) return;
42
- warnedRedirectMiddleware.add(handler);
43
- const label = handler.name || "(anonymous)";
44
- console.warn(
45
- `[rango] Route middleware "${label}" called ctx.set() then returned a ` +
46
- `redirect. Context variables are per-request and won't be available ` +
47
- `on the redirect target. Use cookies to persist state across ` +
48
- `redirects, or move ctx.set() to the target route's middleware.`,
49
- );
50
- }
51
35
 
52
- /** Reset W5 deduplication state (for tests only). */
53
- export function _resetW5Warnings(): void {
54
- warnedRedirectMiddleware = new WeakSet();
36
+ const MIDDLEWARE_METRIC_DEPTH = 1;
37
+ const POST_METRIC_MIN_DURATION_MS = 0.01;
38
+
39
+ function getMiddlewareMetricLabel<TEnv>(
40
+ entry: MiddlewareEntry<TEnv>,
41
+ ordinal: number,
42
+ ): string {
43
+ const handlerName = entry.handler.name?.trim();
44
+ const scope = entry.pattern ?? "*";
45
+
46
+ if (handlerName) {
47
+ return `middleware:${handlerName}@${scope}`;
48
+ }
49
+
50
+ return `middleware:${scope}#${ordinal + 1}`;
55
51
  }
56
52
 
57
53
  /**
58
- * Parse a route pattern into regex and param names
59
- * Supports: *, /path, /path/*, /path/:param, /path/:param/*
54
+ * Compile a middleware scope pattern to a regex + param names.
55
+ *
56
+ * Middleware scopes reuse the route pattern parser (`parsePattern` from
57
+ * pattern-matching.ts), so they support the same param forms as routes —
58
+ * optional (`:x?`), constrained (`:x(en|gb)`), and suffix (`:x.html`) — in
59
+ * addition to the trailing-`*` wildcard middleware relies on. Before this
60
+ * unification the middleware-side parser handled only static, bare `:param`,
61
+ * and trailing `*`, so e.g. `router.use("/:locale(en|gb)/*", mw)` silently
62
+ * named the param "locale(en|gb)" and never enforced the constraint.
63
+ *
64
+ * Middleware matching semantics deliberately differ from route matching, so we
65
+ * emit the regex here rather than route through `compilePattern`:
66
+ * - `*` alone matches every path (`/^.*$/`).
67
+ * - A trailing `*` segment is an OPTIONAL subtree match (`(?:/.*)?`): `/admin/*`
68
+ * matches `/admin`, `/admin/`, and `/admin/users`. It contributes no param
69
+ * name (unlike route wildcards, which capture `*`).
70
+ * - A NON-trailing `*` is also OPTIONAL (`(?:/.*)?`), matching zero-or-more
71
+ * intermediate segments: `/a/<star>/b` matches both `/a/b` and `/a/x/b`. This
72
+ * mirrors the pre-unification parser, which compiled every `*` part as
73
+ * optional regardless of position.
74
+ * - A pattern without a trailing `*` tolerates a trailing slash (`/?$`).
75
+ * - Constraints are baked into the regex as an alternation so `matchMiddleware`
76
+ * (a bare `regex.test`) enforces them without extra validation. Constraint
77
+ * values are matched against the raw (still URL-encoded) path segment, which
78
+ * matches the pre-unification middleware behavior (it never decoded for
79
+ * matching); the constraint string is regex-escaped so values like `en.gb`
80
+ * are treated literally.
81
+ *
82
+ * The route segment parser only recognizes `/`-prefixed segments, but the
83
+ * pre-unification middleware parser split on `/` and dropped empty parts, so a
84
+ * leading slash was irrelevant: `use("admin/*")` and `use("/admin/*")` scoped
85
+ * identically. Normalize a non-`*` pattern to have a leading slash before
86
+ * parsing so that behavior is preserved (without it, `parseRoutePattern("admin/*")`
87
+ * drops the static `admin` and the scope explodes to every path).
60
88
  */
61
- export function parsePattern(pattern: string): {
89
+ export function compileMiddlewarePattern(pattern: string): {
62
90
  regex: RegExp;
63
91
  paramNames: string[];
64
92
  } {
@@ -66,47 +94,54 @@ export function parsePattern(pattern: string): {
66
94
  return { regex: /^.*$/, paramNames: [] };
67
95
  }
68
96
 
97
+ const normalizedPattern = pattern.startsWith("/") ? pattern : `/${pattern}`;
98
+ const segments = parseRoutePattern(normalizedPattern);
69
99
  const paramNames: string[] = [];
70
100
  let regexStr = "^";
101
+ let hasTrailingWildcard = false;
71
102
 
72
- const parts = pattern.split("/").filter(Boolean);
103
+ for (let i = 0; i < segments.length; i++) {
104
+ const segment = segments[i];
73
105
 
74
- for (let i = 0; i < parts.length; i++) {
75
- const part = parts[i];
76
-
77
- if (part === "*") {
78
- // Wildcard - match rest of path
106
+ if (segment.type === "wildcard") {
107
+ // Optional subtree match (parity with the original middleware parser,
108
+ // which compiled every `*` as `(?:/.*)?`). A trailing `*` matches the
109
+ // subtree; a non-trailing `*` matches zero-or-more intermediate segments,
110
+ // so `/a/<star>/b` still matches `/a/b`.
79
111
  regexStr += "(?:/.*)?";
80
- } else if (part.startsWith(":")) {
81
- // Param
82
- const paramName = part.slice(1);
83
- paramNames.push(paramName);
84
- regexStr += "/([^/]+)";
112
+ if (i === segments.length - 1) {
113
+ hasTrailingWildcard = true;
114
+ }
115
+ } else if (segment.type === "param") {
116
+ paramNames.push(segment.value);
117
+ const suffixPattern = segment.suffix ? escapeRegExp(segment.suffix) : "";
118
+ const valuePattern = segment.constraint
119
+ ? `(${segment.constraint.map(escapeRegExp).join("|")})`
120
+ : "([^/]+)";
121
+ if (segment.optional) {
122
+ regexStr += `(?:/${valuePattern}${suffixPattern})?`;
123
+ } else {
124
+ regexStr += `/${valuePattern}${suffixPattern}`;
125
+ }
85
126
  } else {
86
- // Literal
87
- regexStr += "/" + escapeRegex(part);
127
+ // Static literal
128
+ regexStr += "/" + escapeRegExp(segment.value);
88
129
  }
89
130
  }
90
131
 
91
- // If pattern doesn't end with *, match exact or with trailing segments
92
- if (!pattern.endsWith("*")) {
93
- regexStr += "/?$";
94
- } else {
95
- regexStr += "$";
96
- }
132
+ // Without a trailing `*`, match exactly with an optional trailing slash.
133
+ regexStr += hasTrailingWildcard ? "$" : "/?$";
97
134
 
98
135
  return { regex: new RegExp(regexStr), paramNames };
99
136
  }
100
137
 
101
138
  /**
102
- * Escape special regex characters
103
- */
104
- function escapeRegex(str: string): string {
105
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
106
- }
107
-
108
- /**
109
- * Extract params from a pathname using a pattern's regex and param names
139
+ * Extract params from a pathname using a pattern's regex and param names.
140
+ *
141
+ * Values are URL-decoded so apps see the raw string (e.g. "ivo@example.com")
142
+ * instead of the percent-encoded form ("ivo%40example.com"). This matches the
143
+ * contract assumed by ctx.reverse (which re-encodes) and aligns with
144
+ * Express/React Router/Fastify/Koa.
110
145
  */
111
146
  export function extractParams(
112
147
  pathname: string,
@@ -118,7 +153,7 @@ export function extractParams(
118
153
 
119
154
  const params: Record<string, string> = {};
120
155
  for (let i = 0; i < paramNames.length; i++) {
121
- params[paramNames[i]] = match[i + 1] || "";
156
+ params[paramNames[i]] = safeDecodeURIComponent(match[i + 1] || "");
122
157
  }
123
158
  return params;
124
159
  }
@@ -142,7 +177,7 @@ export function createMiddlewareContext<TEnv>(
142
177
  search?: Record<string, unknown>,
143
178
  ) => string,
144
179
  ): MiddlewareContext<TEnv> {
145
- const url = new URL(request.url);
180
+ const url = stripInternalParams(new URL(request.url));
146
181
 
147
182
  // Track the initial response to detect pre/post-next() phase.
148
183
  // Before next(): responseHolder.response === initialResponse (the stub).
@@ -158,13 +193,37 @@ export function createMiddlewareContext<TEnv>(
158
193
  // Cookie operations are handled by the standalone cookies() function which
159
194
  // delegates to the shared RequestContext internally.
160
195
  // The runtime implementation - types are enforced at call sites via MiddlewareContext<TEnv>
196
+ // Internal helper: resolve the current response (stub before next(), real after).
197
+ // Not exposed on the public MiddlewareContext type — use ctx.headers instead.
198
+ const getResponse = (): Response => {
199
+ if (isPreNext()) {
200
+ const reqCtx = _getRequestContext();
201
+ if (reqCtx) return reqCtx.res;
202
+ }
203
+ if (!responseHolder.response) {
204
+ throw new Error(
205
+ "Response is not available - responseHolder was not initialized",
206
+ );
207
+ }
208
+ return responseHolder.response;
209
+ };
210
+
211
+ // Capture reqCtx once: the request-scoped platform fields
212
+ // (originalUrl, executionContext, waitUntil) are immutable per request,
213
+ // so snapshotting beats re-reading ALS on every access. The lazy getters
214
+ // below (routeName, theme, setTheme) stay lazy because those can change
215
+ // during `await next()`.
216
+ const reqCtx = _getRequestContext();
161
217
  return {
162
218
  request,
163
219
  url,
220
+ originalUrl: reqCtx?.originalUrl ?? new URL(request.url),
164
221
  pathname: url.pathname,
165
222
  searchParams: url.searchParams,
166
223
  env: env as MiddlewareContext<TEnv>["env"],
167
224
  params,
225
+ executionContext: reqCtx?.executionContext,
226
+ waitUntil: reqCtx ? reqCtx.waitUntil.bind(reqCtx) : fireAndForgetWaitUntil,
168
227
  // Getter: re-derives from request context on each access so that global
169
228
  // middleware sees the matched route name after await next().
170
229
  get routeName(): MiddlewareContext<TEnv>["routeName"] {
@@ -175,33 +234,16 @@ export function createMiddlewareContext<TEnv>(
175
234
  ) as MiddlewareContext<TEnv>["routeName"];
176
235
  },
177
236
 
178
- get res(): Response {
179
- // Before next(): return shared RequestContext stub so headers
180
- // set via ctx.header() are visible on ctx.res.
181
- if (isPreNext()) {
182
- const reqCtx = _getRequestContext();
183
- if (reqCtx) return reqCtx.res;
184
- }
185
- if (!responseHolder.response) {
186
- throw new Error(
187
- "ctx.res is not available - responseHolder was not initialized",
188
- );
189
- }
190
- return responseHolder.response;
191
- },
192
- set res(_: Response) {
193
- throw new Error(
194
- "ctx.res is read-only. Use ctx.header() to set response headers, or cookies() for cookie mutations.",
195
- );
237
+ get headers(): Headers {
238
+ return getResponse().headers;
196
239
  },
197
240
 
198
241
  get: ((keyOrVar: any) =>
199
242
  contextGet(variables, keyOrVar)) as MiddlewareContext<TEnv>["get"],
200
243
 
201
- set: ((keyOrVar: any, value: unknown) => {
202
- contextSet(variables, keyOrVar, value);
244
+ set: ((keyOrVar: any, value: unknown, options?: any) => {
245
+ contextSet(variables, keyOrVar, value, options);
203
246
  }) as MiddlewareContext<TEnv>["set"],
204
-
205
247
  header(name: string, value: string): void {
206
248
  // Before next(): delegate to shared RequestContext stub
207
249
  if (isPreNext()) {
@@ -220,13 +262,43 @@ export function createMiddlewareContext<TEnv>(
220
262
  responseHolder.response.headers.set(name, value);
221
263
  },
222
264
 
265
+ get theme(): MiddlewareContext<TEnv>["theme"] {
266
+ return _getRequestContext()?.theme;
267
+ },
268
+
269
+ get setTheme(): MiddlewareContext<TEnv>["setTheme"] {
270
+ return _getRequestContext()?.setTheme;
271
+ },
272
+
273
+ setLocationState(entries) {
274
+ const reqCtx = _getRequestContext();
275
+ if (!reqCtx) {
276
+ throw new Error(
277
+ "setLocationState() is not available outside a request context",
278
+ );
279
+ }
280
+ reqCtx.setLocationState(entries);
281
+ },
282
+
223
283
  reverse:
224
284
  reverse ??
225
- ((name: string) => {
285
+ ((
286
+ name: string,
287
+ _params?: Record<string, string>,
288
+ _search?: Record<string, unknown>,
289
+ ) => {
226
290
  throw new Error(
227
- `ctx.reverse() is not available - route map was not provided to middleware context`,
291
+ `ctx.reverse(${JSON.stringify(name)}) is not available: no route map is bound to this middleware context.`,
228
292
  );
229
293
  }),
294
+
295
+ debugPerformance(): void {
296
+ const reqCtx = _getRequestContext();
297
+ if (reqCtx) {
298
+ reqCtx._debugPerformance = true;
299
+ reqCtx._metricsStore ??= createMetricsStore(true);
300
+ }
301
+ },
230
302
  };
231
303
  }
232
304
 
@@ -250,9 +322,16 @@ export function matchMiddleware<TEnv>(
250
322
  continue;
251
323
  }
252
324
 
253
- // Check if pathname matches
254
- if (entry.regex.test(pathname)) {
255
- const params = extractParams(pathname, entry.regex, entry.paramNames);
325
+ // Run the scope regex ONCE per entry. The old code ran test() then, on a
326
+ // hit, extractParams' match() — a second full pass over the same string for
327
+ // every matching entry on the per-request hot path. The regexes carry no
328
+ // `g` flag, so there is no lastIndex statefulness across this single match.
329
+ const m = pathname.match(entry.regex);
330
+ if (m) {
331
+ const params: Record<string, string> = {};
332
+ for (let i = 0; i < entry.paramNames.length; i++) {
333
+ params[entry.paramNames[i]] = safeDecodeURIComponent(m[i + 1] || "");
334
+ }
256
335
  matches.push({ entry, params });
257
336
  }
258
337
  }
@@ -260,15 +339,97 @@ export function matchMiddleware<TEnv>(
260
339
  return matches;
261
340
  }
262
341
 
342
+ // Set-Cookie is appended; for other headers stubOverridesNonCookie=true
343
+ // overwrites (chain ran to completion), false fills only missing slots (an
344
+ // explicit short-circuit Response's own headers win).
345
+ function mergeStubHeaders(
346
+ target: Headers,
347
+ stub: Headers,
348
+ stubOverridesNonCookie: boolean,
349
+ ): void {
350
+ stub.forEach((value, name) => {
351
+ // The reserved external-redirect marker is internal and never a trust
352
+ // signal; never copy a stub value (e.g. a stray ctx.header() call) onto a
353
+ // browser-facing response. The opt-in is the out-of-band brand.
354
+ if (name.toLowerCase() === EXTERNAL_REDIRECT_MARKER) return;
355
+ if (name.toLowerCase() === "set-cookie") {
356
+ target.append(name, value);
357
+ } else if (stubOverridesNonCookie || !target.has(name)) {
358
+ target.set(name, value);
359
+ }
360
+ });
361
+ }
362
+
363
+ // Set-Cookie is deduped so a nested inner executeMiddleware that already merged
364
+ // the same reqCtx cookies does not duplicate them; other headers fill if missing.
365
+ function mergeReqCtxStub(
366
+ target: Headers,
367
+ reqCtx: ReturnType<typeof _getRequestContext>,
368
+ ): void {
369
+ if (!reqCtx) return;
370
+ const stubCookies = reqCtx.res.headers.getSetCookie();
371
+ if (stubCookies.length > 0) {
372
+ const existing = new Set(target.getSetCookie());
373
+ for (const cookie of stubCookies) {
374
+ if (!existing.has(cookie)) {
375
+ target.append("set-cookie", cookie);
376
+ }
377
+ }
378
+ }
379
+ reqCtx.res.headers.forEach((value, name) => {
380
+ // Never propagate the reserved external-redirect marker (see mergeStubHeaders).
381
+ if (name.toLowerCase() === EXTERNAL_REDIRECT_MARKER) return;
382
+ if (name !== "set-cookie" && !target.has(name)) {
383
+ target.set(name, value);
384
+ }
385
+ });
386
+ }
387
+
388
+ // Clone `base` with stub headers merged into a fresh Headers (the clone keeps
389
+ // the body mutable for post-next() modifications). Set-Cookie is always
390
+ // appended; other headers obey stubOverridesNonCookie (see mergeStubHeaders).
391
+ // mergeReqCtx folds in RequestContext stub cookies/headers; the intercept
392
+ // short-circuit path passes false (its reqCtx headers are not merged here),
393
+ // which is the one deliberate divergence between the call sites.
394
+ function mergeResponse(
395
+ base: Response,
396
+ stub: Headers,
397
+ opts: { stubOverridesNonCookie: boolean; mergeReqCtx: boolean },
398
+ ): Response {
399
+ const mergedHeaders = new Headers(base.headers);
400
+ // The reserved external-redirect marker is never a trust signal and must never
401
+ // reach the browser. The guard strips it on 3xx redirects; strip it here too so
402
+ // a forged value cannot ride a non-3xx middleware response (which the 3xx-only
403
+ // guard would not touch) to the client. The opt-in is the out-of-band brand.
404
+ mergedHeaders.delete(EXTERNAL_REDIRECT_MARKER);
405
+ mergeStubHeaders(mergedHeaders, stub, opts.stubOverridesNonCookie);
406
+ if (opts.mergeReqCtx) {
407
+ mergeReqCtxStub(mergedHeaders, _getRequestContext());
408
+ }
409
+ const merged = new Response(base.body, {
410
+ status: base.status,
411
+ statusText: base.statusText,
412
+ headers: mergedHeaders,
413
+ });
414
+ // Transfer the out-of-band external-redirect brand across this rebuild: a
415
+ // middleware short-circuit `return redirect(url, { external: true })` reaches
416
+ // the open-redirect guard only after this merge, and the brand lives on the
417
+ // Response object, not in its headers.
418
+ if (isExternalRedirect(base)) {
419
+ markExternalRedirect(merged);
420
+ }
421
+ return merged;
422
+ }
423
+
263
424
  /**
264
425
  * Execute middleware chain
265
426
  *
266
427
  * Features:
267
428
  * - `await next()` returns actual Response
268
- * - `ctx.res` available after `await next()` (like Hono's `c.res`)
269
- * - `ctx.header()` shorthand for setting headers
270
- * - Forgiving: if middleware doesn't return, uses `ctx.res`
271
- * - Short-circuit: return Response to stop chain
429
+ * - `ctx.headers` available before and after `await next()`
430
+ * - `ctx.header()` shorthand for setting a single header
431
+ * - Forgiving: if middleware doesn't return, uses the downstream response
432
+ * - Short-circuit: return OR throw a Response to stop chain
272
433
  * - Error catching: try/catch around `next()` works
273
434
  */
274
435
  export async function executeMiddleware<TEnv>(
@@ -298,40 +459,22 @@ export async function executeMiddleware<TEnv>(
298
459
  // End of chain - call actual RSC handler
299
460
  const response = await finalHandler();
300
461
 
301
- // Merge headers set on stub into the real response.
302
- // Use append for Set-Cookie to preserve multiple cookies.
303
- const mergedHeaders = new Headers(response.headers);
304
- stubResponse.headers.forEach((value, name) => {
305
- if (name.toLowerCase() === "set-cookie") {
306
- mergedHeaders.append(name, value);
307
- } else {
308
- mergedHeaders.set(name, value);
309
- }
310
- });
311
- // Also merge shared RequestContext stub (cookies written via cookies().set()).
312
- // Set-Cookie duplication is prevented by createResponseWithMergedHeaders
313
- // draining Set-Cookie from ctx.res after merging (helpers.ts).
314
- const reqCtx = _getRequestContext();
315
- if (reqCtx) {
316
- reqCtx.res.headers.forEach((value, name) => {
317
- if (name.toLowerCase() === "set-cookie") {
318
- mergedHeaders.append(name, value);
319
- } else if (!mergedHeaders.has(name)) {
320
- mergedHeaders.set(name, value);
321
- }
322
- });
462
+ if (isWebSocketUpgradeResponse(response)) {
463
+ responseHolder.response = response;
464
+ return response;
323
465
  }
324
466
 
325
- // Clone response with merged headers (mutable for post-next() modifications)
326
- responseHolder.response = new Response(response.body, {
327
- status: response.status,
328
- statusText: response.statusText,
329
- headers: mergedHeaders,
467
+ // Chain ran to completion: stub headers overwrite (stubOverridesNonCookie)
468
+ // and reqCtx stub headers are merged in.
469
+ responseHolder.response = mergeResponse(response, stubResponse.headers, {
470
+ stubOverridesNonCookie: true,
471
+ mergeReqCtx: true,
330
472
  });
331
473
 
332
474
  return responseHolder.response;
333
475
  }
334
476
 
477
+ const middlewareOrdinal = index;
335
478
  const { entry, params } = middlewares[index++];
336
479
  const ctx = createMiddlewareContext(
337
480
  request,
@@ -341,71 +484,102 @@ export async function executeMiddleware<TEnv>(
341
484
  responseHolder,
342
485
  reverse,
343
486
  );
487
+ const metricStart = performance.now();
488
+ const metricLabel = getMiddlewareMetricLabel(entry, middlewareOrdinal);
489
+ let middlewareFinished = false;
490
+ const finishMiddleware = () => {
491
+ if (!middlewareFinished) {
492
+ middlewareFinished = true;
493
+ appendMetric(
494
+ _getRequestContext()?._metricsStore,
495
+ `${metricLabel}:pre`,
496
+ metricStart,
497
+ performance.now() - metricStart,
498
+ MIDDLEWARE_METRIC_DEPTH,
499
+ );
500
+ }
501
+ };
344
502
 
345
503
  // Track if next() was called and capture its Promise.
346
504
  // Guard against double-calling: a second call would re-enter the
347
505
  // downstream chain and overwrite responseHolder.response.
348
506
  let nextPromise: Promise<Response> | null = null;
507
+ let nextResolvedAt: number | undefined;
349
508
  const wrappedNext = (): Promise<Response> => {
350
509
  if (nextPromise) {
351
510
  throw new Error(
352
511
  `[@rangojs/router] Middleware called next() more than once.`,
353
512
  );
354
513
  }
355
- nextPromise = next();
514
+ finishMiddleware();
515
+ const downstream = next();
516
+ nextPromise = downstream.then(
517
+ (res) => {
518
+ nextResolvedAt = performance.now();
519
+ return res;
520
+ },
521
+ (err) => {
522
+ nextResolvedAt = performance.now();
523
+ throw err;
524
+ },
525
+ );
356
526
  return nextPromise;
357
527
  };
358
528
 
359
- // W5: track whether ctx.set() is called during this middleware
360
- let ctxSetCalled = false;
361
- if (process.env.NODE_ENV !== "production") {
362
- const originalSet = ctx.set;
363
- ctx.set = ((...args: any[]) => {
364
- ctxSetCalled = true;
365
- return (originalSet as Function).apply(ctx, args);
366
- }) as typeof ctx.set;
529
+ // Wrap the middleware (including its downstream next() chain) in its span
530
+ // via the unified phase API. metric:false — the middleware's perf metric is
531
+ // its exclusive pre/post own-time, recorded directly above and below, finer
532
+ // than a single wrap. Spans nest by async context, so this onions
533
+ // middleware-over-middleware and the core handler underneath. Pass-through
534
+ // when neither surface is active.
535
+ let result: Response | void;
536
+ try {
537
+ result = await observePhase(PHASES.middleware(metricLabel), () =>
538
+ entry.handler(ctx, wrappedNext),
539
+ );
540
+ } catch (error) {
541
+ // Thrown Response is short-circuit control flow, not an error.
542
+ // Fall through to the `if (result instanceof Response)` branch below
543
+ // so stub headers and request-context cookies merge as they do for
544
+ // an explicit `return new Response(...)`. Real errors propagate.
545
+ if (error instanceof Response) {
546
+ result = error;
547
+ } else {
548
+ finishMiddleware();
549
+ throw error;
550
+ }
551
+ }
552
+ finishMiddleware();
553
+
554
+ // Record post-next() processing time when middleware did work after
555
+ // the downstream chain resolved (e.g. adding headers, logging).
556
+ if (nextResolvedAt !== undefined) {
557
+ const postDur = performance.now() - nextResolvedAt;
558
+ if (postDur > POST_METRIC_MIN_DURATION_MS) {
559
+ appendMetric(
560
+ _getRequestContext()?._metricsStore,
561
+ `${metricLabel}:post`,
562
+ nextResolvedAt,
563
+ postDur,
564
+ MIDDLEWARE_METRIC_DEPTH,
565
+ );
566
+ }
367
567
  }
368
-
369
- const result = await entry.handler(ctx, wrappedNext);
370
568
 
371
569
  // Explicit return takes precedence (middleware short-circuit).
372
570
  // Merge stub headers (from ctx.header before this point) and
373
- // RequestContext stub headers (from ctx.setCookie) into the
571
+ // RequestContext stub headers (from cookies().set()) into the
374
572
  // returned Response so they are not lost.
375
573
  if (result instanceof Response) {
376
- // W5: warn if ctx.set() was called but middleware returned a redirect
377
- if (
378
- process.env.NODE_ENV !== "production" &&
379
- ctxSetCalled &&
380
- result.status >= 300 &&
381
- result.status < 400
382
- ) {
383
- warnCtxSetBeforeRedirect(entry.handler);
384
- }
385
-
386
- const mergedHeaders = new Headers(result.headers);
387
- stubResponse.headers.forEach((value, name) => {
388
- if (name.toLowerCase() === "set-cookie") {
389
- mergedHeaders.append(name, value);
390
- } else if (!mergedHeaders.has(name)) {
391
- mergedHeaders.set(name, value);
392
- }
393
- });
394
- // Also merge shared RequestContext stub (cookies written via setCookie)
395
- const reqCtx = _getRequestContext();
396
- if (reqCtx) {
397
- reqCtx.res.headers.forEach((value, name) => {
398
- if (name.toLowerCase() === "set-cookie") {
399
- mergedHeaders.append(name, value);
400
- } else if (!mergedHeaders.has(name)) {
401
- mergedHeaders.set(name, value);
402
- }
403
- });
574
+ if (isWebSocketUpgradeResponse(result)) {
575
+ responseHolder.response = result;
576
+ return result;
404
577
  }
405
- const merged = new Response(result.body, {
406
- status: result.status,
407
- statusText: result.statusText,
408
- headers: mergedHeaders,
578
+ // Explicit short-circuit: the returned Response's own headers win
579
+ // (stubOverridesNonCookie=false); reqCtx stub headers still merge in.
580
+ const merged = mergeResponse(result, stubResponse.headers, {
581
+ stubOverridesNonCookie: false,
582
+ mergeReqCtx: true,
409
583
  });
410
584
  responseHolder.response = merged;
411
585
  return merged;
@@ -424,19 +598,6 @@ export async function executeMiddleware<TEnv>(
424
598
  // If middleware called next(), await it and return the response
425
599
  if (nextPromise) {
426
600
  await nextPromise;
427
-
428
- // W5: warn if ctx.set() was called but the downstream response is a redirect.
429
- // The ctx.set() values will be lost because the redirect navigates away.
430
- if (
431
- process.env.NODE_ENV !== "production" &&
432
- ctxSetCalled &&
433
- responseHolder.response &&
434
- responseHolder.response.status >= 300 &&
435
- responseHolder.response.status < 400
436
- ) {
437
- warnCtxSetBeforeRedirect(entry.handler);
438
- }
439
-
440
601
  return responseHolder.response!;
441
602
  }
442
603
 
@@ -459,6 +620,18 @@ export async function executeMiddleware<TEnv>(
459
620
  throw new Error("No response generated by middleware chain");
460
621
  }
461
622
 
623
+ // Final re-merge: capture any RequestContext stub headers added after the
624
+ // last merge point (e.g. cookies().set() called after await next()).
625
+ // The reqCtx stub may have already been partially merged during finalHandler
626
+ // or early-return paths; only append *new* Set-Cookie entries to avoid dupes.
627
+ //
628
+ // Skip for upgrade responses: upgrade headers are semantically immutable and
629
+ // set-cookie on an upgrade is not meaningful.
630
+ const reqCtx = _getRequestContext();
631
+ if (reqCtx && !isWebSocketUpgradeResponse(finalResponse)) {
632
+ mergeReqCtxStub(finalResponse.headers, reqCtx);
633
+ }
634
+
462
635
  return finalResponse;
463
636
  }
464
637
 
@@ -467,7 +640,7 @@ export async function executeMiddleware<TEnv>(
467
640
  *
468
641
  * Intercepts use a shared stubResponse from the request context. This function:
469
642
  * - Runs middleware in sequence with a simple next() chain
470
- * - Returns Response if any middleware short-circuits (returns Response or redirects BEFORE next())
643
+ * - Returns Response if any middleware short-circuits (returns OR throws a Response, or redirects, BEFORE next())
471
644
  * - Returns null if all middleware calls next() - headers set after next() remain on stubResponse
472
645
  *
473
646
  * @param middlewares - Array of middleware functions
@@ -505,6 +678,7 @@ export async function executeInterceptMiddleware<TEnv>(
505
678
  return stubResponse;
506
679
  }
507
680
 
681
+ const ordinal = index;
508
682
  const middleware = middlewares[index++];
509
683
  const ctx = createMiddlewareContext(
510
684
  request,
@@ -526,7 +700,29 @@ export async function executeInterceptMiddleware<TEnv>(
526
700
  return next();
527
701
  };
528
702
 
529
- const result = await middleware(ctx, guardedNext);
703
+ // Span-wrap each intercept middleware as rango.middleware (metric:false —
704
+ // intercept runs inside the render phase already metered by render:total, so
705
+ // it contributes a span but no separate perf metric). Bare MiddlewareFns
706
+ // have no pattern, so the label is scoped to "*" like a pattern-less entry.
707
+ const label = getMiddlewareMetricLabel(
708
+ { handler: middleware, pattern: null } as MiddlewareEntry<TEnv>,
709
+ ordinal,
710
+ );
711
+
712
+ let result: Response | void;
713
+ try {
714
+ result = await observePhase(PHASES.middleware(label), () =>
715
+ middleware(ctx, guardedNext),
716
+ );
717
+ } catch (error) {
718
+ // Thrown Response is short-circuit control flow, parity with the
719
+ // explicit-return path below. Real errors propagate.
720
+ if (error instanceof Response) {
721
+ result = error;
722
+ } else {
723
+ throw error;
724
+ }
725
+ }
530
726
 
531
727
  if (result instanceof Response) {
532
728
  earlyResponse = result;
@@ -550,21 +746,13 @@ export async function executeInterceptMiddleware<TEnv>(
550
746
  });
551
747
 
552
748
  if (hasStubHeaders) {
553
- // Clone and merge headers from stub into early response.
554
- // Only fill in missing headers — the returned Response's explicit
555
- // headers take precedence, matching executeMiddleware behavior.
556
- const mergedHeaders = new Headers(response.headers);
557
- stubResponse.headers.forEach((value, name) => {
558
- if (name.toLowerCase() === "set-cookie") {
559
- mergedHeaders.append(name, value);
560
- } else if (!mergedHeaders.has(name)) {
561
- mergedHeaders.set(name, value);
562
- }
563
- });
564
- return new Response(response.body, {
565
- status: response.status,
566
- statusText: response.statusText,
567
- headers: mergedHeaders,
749
+ // Only fill in missing headers — the returned Response's explicit headers
750
+ // take precedence (stubOverridesNonCookie=false), matching executeMiddleware.
751
+ // mergeReqCtx=false: the intercept path deliberately does NOT merge reqCtx
752
+ // stub headers here (pinned by intercept-middleware-headers.test.ts).
753
+ return mergeResponse(response, stubResponse.headers, {
754
+ stubOverridesNonCookie: false,
755
+ mergeReqCtx: false,
568
756
  });
569
757
  }
570
758
  return response;
@@ -605,7 +793,6 @@ export async function executeLoaderMiddleware<TEnv>(
605
793
  regex: null,
606
794
  paramNames: [],
607
795
  handler,
608
- mountPrefix: null,
609
796
  } as MiddlewareEntry<TEnv>,
610
797
  params,
611
798
  }));