@rangojs/router 0.0.0-experimental.1b930379 → 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 (400) hide show
  1. package/AGENTS.md +12 -0
  2. package/README.md +245 -49
  3. package/dist/bin/rango.js +441 -134
  4. package/dist/testing/vitest.js +82 -0
  5. package/dist/vite/index.js +3453 -1240
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +76 -21
  8. package/skills/api-client/SKILL.md +211 -0
  9. package/skills/breadcrumbs/SKILL.md +64 -2
  10. package/skills/bundle-analysis/SKILL.md +159 -0
  11. package/skills/cache-guide/SKILL.md +247 -23
  12. package/skills/caching/SKILL.md +318 -15
  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 +250 -30
  19. package/skills/host-router/SKILL.md +83 -23
  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 +279 -53
  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 +153 -109
  32. package/skills/rango/SKILL.md +251 -22
  33. package/skills/react-compiler/SKILL.md +168 -0
  34. package/skills/response-routes/SKILL.md +123 -48
  35. package/skills/route/SKILL.md +101 -5
  36. package/skills/router-setup/SKILL.md +116 -8
  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 +129 -0
  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 +332 -29
  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 +1 -66
  62. package/src/browser/action-coordinator.ts +53 -36
  63. package/src/browser/action-fence.ts +47 -0
  64. package/src/browser/app-shell.ts +39 -0
  65. package/src/browser/app-version.ts +14 -0
  66. package/src/browser/connection-warmup.ts +134 -0
  67. package/src/browser/cookie-name.ts +140 -0
  68. package/src/browser/event-controller.ts +197 -150
  69. package/src/browser/history-state.ts +21 -0
  70. package/src/browser/index.ts +3 -3
  71. package/src/browser/invalidate-client-cache.ts +52 -0
  72. package/src/browser/navigation-bridge.ts +111 -31
  73. package/src/browser/navigation-client.ts +201 -67
  74. package/src/browser/navigation-store-handle.ts +38 -0
  75. package/src/browser/navigation-store.ts +76 -67
  76. package/src/browser/navigation-transaction.ts +18 -66
  77. package/src/browser/network-error-handler.ts +34 -7
  78. package/src/browser/partial-update.ts +187 -112
  79. package/src/browser/prefetch/cache.ts +230 -35
  80. package/src/browser/prefetch/fetch.ts +338 -39
  81. package/src/browser/prefetch/queue.ts +126 -20
  82. package/src/browser/prefetch/resource-ready.ts +77 -0
  83. package/src/browser/rango-state.ts +158 -76
  84. package/src/browser/react/Link.tsx +111 -16
  85. package/src/browser/react/NavigationProvider.tsx +135 -120
  86. package/src/browser/react/ScrollRestoration.tsx +10 -6
  87. package/src/browser/react/context.ts +7 -2
  88. package/src/browser/react/filter-segment-order.ts +66 -7
  89. package/src/browser/react/index.ts +0 -48
  90. package/src/browser/react/location-state-shared.ts +178 -8
  91. package/src/browser/react/location-state.ts +39 -14
  92. package/src/browser/react/use-action.ts +6 -15
  93. package/src/browser/react/use-handle.ts +23 -69
  94. package/src/browser/react/use-href.tsx +8 -1
  95. package/src/browser/react/use-link-status.ts +33 -8
  96. package/src/browser/react/use-navigation.ts +32 -7
  97. package/src/browser/react/use-params.ts +20 -10
  98. package/src/browser/react/use-reverse.ts +106 -0
  99. package/src/browser/react/use-router.ts +46 -11
  100. package/src/browser/react/use-search-params.ts +0 -5
  101. package/src/browser/react/use-segments.ts +11 -21
  102. package/src/browser/response-adapter.ts +80 -5
  103. package/src/browser/rsc-router.tsx +218 -76
  104. package/src/browser/scroll-restoration.ts +54 -42
  105. package/src/browser/segment-reconciler.ts +36 -9
  106. package/src/browser/segment-structure-assert.ts +2 -2
  107. package/src/browser/server-action-bridge.ts +222 -61
  108. package/src/browser/types.ts +91 -11
  109. package/src/browser/validate-redirect-origin.ts +43 -16
  110. package/src/build/collect-fallback-refs.ts +107 -0
  111. package/src/build/generate-manifest.ts +65 -40
  112. package/src/build/generate-route-types.ts +5 -1
  113. package/src/build/index.ts +8 -2
  114. package/src/build/prefix-tree-utils.ts +123 -0
  115. package/src/build/route-trie.ts +165 -36
  116. package/src/build/route-types/ast-route-extraction.ts +15 -8
  117. package/src/build/route-types/codegen.ts +16 -5
  118. package/src/build/route-types/include-resolution.ts +125 -24
  119. package/src/build/route-types/param-extraction.ts +6 -3
  120. package/src/build/route-types/per-module-writer.ts +22 -6
  121. package/src/build/route-types/router-processing.ts +272 -96
  122. package/src/build/route-types/scan-filter.ts +9 -2
  123. package/src/build/route-types/source-scan.ts +216 -0
  124. package/src/build/runtime-discovery.ts +9 -20
  125. package/src/cache/cache-error.ts +104 -0
  126. package/src/cache/cache-key-utils.ts +29 -13
  127. package/src/cache/cache-policy.ts +108 -34
  128. package/src/cache/cache-runtime.ts +214 -48
  129. package/src/cache/cache-scope.ts +236 -89
  130. package/src/cache/cache-tag.ts +103 -0
  131. package/src/cache/cf/cf-base64.ts +33 -0
  132. package/src/cache/cf/cf-cache-constants.ts +127 -0
  133. package/src/cache/cf/cf-cache-store.ts +2224 -171
  134. package/src/cache/cf/cf-cache-types.ts +349 -0
  135. package/src/cache/cf/cf-kv-utils.ts +46 -0
  136. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  137. package/src/cache/cf/index.ts +11 -17
  138. package/src/cache/document-cache.ts +89 -27
  139. package/src/cache/handle-snapshot.ts +70 -0
  140. package/src/cache/index.ts +11 -20
  141. package/src/cache/memory-segment-store.ts +136 -37
  142. package/src/cache/profile-registry.ts +31 -31
  143. package/src/cache/read-through-swr.ts +41 -11
  144. package/src/cache/segment-codec.ts +9 -17
  145. package/src/cache/tag-invalidation.ts +230 -0
  146. package/src/cache/taint.ts +55 -0
  147. package/src/cache/types.ts +37 -100
  148. package/src/client.rsc.tsx +44 -21
  149. package/src/client.tsx +119 -290
  150. package/src/cloudflare/index.ts +11 -0
  151. package/src/cloudflare/tracing.ts +109 -0
  152. package/src/component-utils.ts +19 -0
  153. package/src/components/DefaultDocument.tsx +8 -2
  154. package/src/context-var.ts +84 -2
  155. package/src/debug.ts +2 -2
  156. package/src/decode-loader-results.ts +52 -0
  157. package/src/defer.ts +196 -0
  158. package/src/deps/ssr.ts +0 -1
  159. package/src/encode-kv.ts +49 -0
  160. package/src/errors.ts +30 -4
  161. package/src/escape-script.ts +52 -0
  162. package/src/handle.ts +70 -22
  163. package/src/handles/MetaTags.tsx +56 -19
  164. package/src/handles/Scripts.tsx +183 -0
  165. package/src/handles/breadcrumbs.ts +37 -8
  166. package/src/handles/is-thenable.ts +19 -0
  167. package/src/handles/meta.ts +51 -40
  168. package/src/handles/script.ts +244 -0
  169. package/src/host/cookie-handler.ts +9 -60
  170. package/src/host/errors.ts +0 -24
  171. package/src/host/index.ts +8 -2
  172. package/src/host/pattern-matcher.ts +23 -52
  173. package/src/host/router.ts +107 -99
  174. package/src/host/testing.ts +40 -27
  175. package/src/host/types.ts +37 -4
  176. package/src/host/utils.ts +1 -1
  177. package/src/href-client.ts +137 -22
  178. package/src/index.rsc.ts +93 -12
  179. package/src/index.ts +133 -15
  180. package/src/internal-debug.ts +11 -10
  181. package/src/loader-store.ts +500 -0
  182. package/src/loader.rsc.ts +20 -13
  183. package/src/loader.ts +12 -11
  184. package/src/missing-id-error.ts +68 -0
  185. package/src/outlet-context.ts +1 -1
  186. package/src/outlet-provider.tsx +1 -5
  187. package/src/prerender/param-hash.ts +16 -16
  188. package/src/prerender/store.ts +37 -41
  189. package/src/prerender.ts +198 -82
  190. package/src/redirect-origin.ts +100 -0
  191. package/src/regex-escape.ts +8 -0
  192. package/src/render-error-thrower.tsx +20 -0
  193. package/src/response-utils.ts +62 -0
  194. package/src/reverse.ts +65 -15
  195. package/src/root-error-boundary.tsx +1 -19
  196. package/src/route-content-wrapper.tsx +7 -72
  197. package/src/route-definition/dsl-helpers.ts +469 -276
  198. package/src/route-definition/helper-factories.ts +29 -139
  199. package/src/route-definition/helpers-types.ts +113 -37
  200. package/src/route-definition/index.ts +3 -0
  201. package/src/route-definition/redirect.ts +53 -12
  202. package/src/route-definition/resolve-handler-use.ts +161 -0
  203. package/src/route-definition/use-item-types.ts +32 -0
  204. package/src/route-map-builder.ts +7 -17
  205. package/src/route-types.ts +37 -41
  206. package/src/router/basename.ts +14 -0
  207. package/src/router/content-negotiation.ts +164 -17
  208. package/src/router/error-handling.ts +45 -18
  209. package/src/router/find-match.ts +45 -22
  210. package/src/router/handler-context.ts +83 -39
  211. package/src/router/instrument.ts +350 -0
  212. package/src/router/intercept-resolution.ts +50 -24
  213. package/src/router/lazy-includes.ts +19 -53
  214. package/src/router/loader-resolution.ts +274 -56
  215. package/src/router/logging.ts +5 -8
  216. package/src/router/manifest.ts +49 -45
  217. package/src/router/match-api.ts +120 -204
  218. package/src/router/match-context.ts +0 -22
  219. package/src/router/match-handlers.ts +58 -58
  220. package/src/router/match-middleware/background-revalidation.ts +33 -6
  221. package/src/router/match-middleware/cache-lookup.ts +214 -263
  222. package/src/router/match-middleware/cache-store.ts +73 -33
  223. package/src/router/match-middleware/intercept-resolution.ts +8 -28
  224. package/src/router/match-middleware/segment-resolution.ts +52 -18
  225. package/src/router/match-pipelines.ts +1 -42
  226. package/src/router/match-result.ts +104 -40
  227. package/src/router/metrics.ts +5 -34
  228. package/src/router/middleware-types.ts +13 -142
  229. package/src/router/middleware.ts +270 -172
  230. package/src/router/navigation-snapshot.ts +131 -0
  231. package/src/router/params-util.ts +23 -0
  232. package/src/router/pattern-matching.ts +132 -90
  233. package/src/router/prefetch-cache-ttl.ts +51 -0
  234. package/src/router/prerender-match.ts +195 -56
  235. package/src/router/preview-match.ts +32 -102
  236. package/src/router/request-classification.ts +276 -0
  237. package/src/router/revalidation.ts +123 -73
  238. package/src/router/route-snapshot.ts +244 -0
  239. package/src/router/router-context.ts +8 -28
  240. package/src/router/router-interfaces.ts +115 -35
  241. package/src/router/router-options.ts +172 -15
  242. package/src/router/router-registry.ts +2 -5
  243. package/src/router/segment-resolution/fresh.ts +264 -77
  244. package/src/router/segment-resolution/helpers.ts +115 -30
  245. package/src/router/segment-resolution/loader-cache.ts +63 -37
  246. package/src/router/segment-resolution/revalidation.ts +474 -385
  247. package/src/router/segment-resolution/static-store.ts +19 -5
  248. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  249. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  250. package/src/router/segment-resolution.ts +5 -1
  251. package/src/router/segment-wrappers.ts +8 -5
  252. package/src/router/state-cookie-name.ts +33 -0
  253. package/src/router/substitute-pattern-params.ts +56 -0
  254. package/src/router/telemetry-otel.ts +161 -199
  255. package/src/router/telemetry.ts +96 -19
  256. package/src/router/timeout.ts +0 -20
  257. package/src/router/tracing.ts +206 -0
  258. package/src/router/trie-matching.ts +163 -59
  259. package/src/router/types.ts +10 -63
  260. package/src/router/url-params.ts +44 -0
  261. package/src/router.ts +163 -55
  262. package/src/rsc/handler-context.ts +3 -2
  263. package/src/rsc/handler.ts +658 -511
  264. package/src/rsc/helpers.ts +168 -46
  265. package/src/rsc/index.ts +2 -5
  266. package/src/rsc/json-route-result.ts +38 -0
  267. package/src/rsc/loader-fetch.ts +127 -31
  268. package/src/rsc/manifest-init.ts +33 -42
  269. package/src/rsc/origin-guard.ts +39 -25
  270. package/src/rsc/progressive-enhancement.ts +77 -11
  271. package/src/rsc/redirect-guard.ts +99 -0
  272. package/src/rsc/response-cache-serve.ts +238 -0
  273. package/src/rsc/response-error.ts +79 -12
  274. package/src/rsc/response-route-handler.ts +99 -189
  275. package/src/rsc/rsc-rendering.ts +105 -72
  276. package/src/rsc/runtime-warnings.ts +23 -10
  277. package/src/rsc/server-action.ts +263 -112
  278. package/src/rsc/ssr-setup.ts +18 -2
  279. package/src/rsc/types.ts +32 -6
  280. package/src/runtime-env.ts +18 -0
  281. package/src/search-params.ts +35 -30
  282. package/src/segment-content-promise.ts +67 -0
  283. package/src/segment-loader-promise.ts +149 -0
  284. package/src/segment-system.tsx +281 -129
  285. package/src/serialize.ts +243 -0
  286. package/src/server/context.ts +309 -61
  287. package/src/server/cookie-parse.ts +32 -0
  288. package/src/server/cookie-store.ts +80 -5
  289. package/src/server/handle-store.ts +40 -38
  290. package/src/server/loader-registry.ts +26 -46
  291. package/src/server/request-context.ts +398 -172
  292. package/src/ssr/index.tsx +25 -16
  293. package/src/static-handler.ts +27 -18
  294. package/src/testing/cache-status.ts +162 -0
  295. package/src/testing/collect-handle.ts +40 -0
  296. package/src/testing/dispatch.ts +701 -0
  297. package/src/testing/dom.entry.ts +22 -0
  298. package/src/testing/e2e/fixture.ts +188 -0
  299. package/src/testing/e2e/index.ts +128 -0
  300. package/src/testing/e2e/matchers.ts +35 -0
  301. package/src/testing/e2e/page-helpers.ts +272 -0
  302. package/src/testing/e2e/parity.ts +387 -0
  303. package/src/testing/e2e/server.ts +195 -0
  304. package/src/testing/flight-matchers.ts +97 -0
  305. package/src/testing/flight-normalize.ts +11 -0
  306. package/src/testing/flight-runtime.d.ts +57 -0
  307. package/src/testing/flight-tree.ts +682 -0
  308. package/src/testing/flight.entry.ts +52 -0
  309. package/src/testing/flight.ts +257 -0
  310. package/src/testing/generated-routes.ts +183 -0
  311. package/src/testing/index.ts +99 -0
  312. package/src/testing/internal/context.ts +371 -0
  313. package/src/testing/internal/flight-client-globals.ts +30 -0
  314. package/src/testing/internal/seed-vars.ts +54 -0
  315. package/src/testing/render-handler.ts +343 -0
  316. package/src/testing/render-route.tsx +581 -0
  317. package/src/testing/run-loader.ts +385 -0
  318. package/src/testing/run-middleware.ts +205 -0
  319. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  320. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  321. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  322. package/src/testing/vitest-stubs/version.ts +5 -0
  323. package/src/testing/vitest.ts +305 -0
  324. package/src/theme/ThemeProvider.tsx +20 -58
  325. package/src/theme/ThemeScript.tsx +7 -9
  326. package/src/theme/constants.ts +52 -13
  327. package/src/theme/index.ts +0 -7
  328. package/src/theme/theme-context.ts +1 -5
  329. package/src/theme/theme-script.ts +22 -21
  330. package/src/theme/use-theme.ts +0 -3
  331. package/src/types/boundaries.ts +0 -35
  332. package/src/types/cache-types.ts +17 -8
  333. package/src/types/error-types.ts +30 -90
  334. package/src/types/global-namespace.ts +54 -41
  335. package/src/types/handler-context.ts +233 -81
  336. package/src/types/index.ts +1 -10
  337. package/src/types/loader-types.ts +44 -15
  338. package/src/types/request-scope.ts +112 -0
  339. package/src/types/route-config.ts +6 -50
  340. package/src/types/route-entry.ts +19 -7
  341. package/src/types/segments.ts +37 -14
  342. package/src/urls/include-helper.ts +33 -70
  343. package/src/urls/index.ts +1 -11
  344. package/src/urls/path-helper-types.ts +58 -11
  345. package/src/urls/path-helper.ts +57 -111
  346. package/src/urls/pattern-types.ts +48 -19
  347. package/src/urls/response-types.ts +25 -22
  348. package/src/urls/type-extraction.ts +58 -139
  349. package/src/urls/urls-function.ts +1 -18
  350. package/src/use-loader.tsx +346 -89
  351. package/src/vite/debug.ts +185 -0
  352. package/src/vite/discovery/bundle-postprocess.ts +36 -38
  353. package/src/vite/discovery/discover-routers.ts +130 -85
  354. package/src/vite/discovery/discovery-errors.ts +194 -0
  355. package/src/vite/discovery/gate-state.ts +171 -0
  356. package/src/vite/discovery/prerender-collection.ts +214 -132
  357. package/src/vite/discovery/route-types-writer.ts +40 -84
  358. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  359. package/src/vite/discovery/state.ts +57 -6
  360. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  361. package/src/vite/index.ts +6 -0
  362. package/src/vite/inject-client-debug.ts +36 -0
  363. package/src/vite/plugin-types.ts +155 -65
  364. package/src/vite/plugins/cjs-to-esm.ts +16 -19
  365. package/src/vite/plugins/client-ref-dedup.ts +16 -11
  366. package/src/vite/plugins/client-ref-hashing.ts +28 -15
  367. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  368. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  369. package/src/vite/plugins/cloudflare-protocol-stub.ts +194 -0
  370. package/src/vite/plugins/expose-action-id.ts +49 -98
  371. package/src/vite/plugins/expose-id-utils.ts +96 -51
  372. package/src/vite/plugins/expose-ids/export-analysis.ts +101 -34
  373. package/src/vite/plugins/expose-ids/handler-transform.ts +15 -64
  374. package/src/vite/plugins/expose-ids/loader-transform.ts +14 -24
  375. package/src/vite/plugins/expose-ids/router-transform.ts +118 -29
  376. package/src/vite/plugins/expose-internal-ids.ts +553 -317
  377. package/src/vite/plugins/performance-tracks.ts +89 -0
  378. package/src/vite/plugins/refresh-cmd.ts +89 -27
  379. package/src/vite/plugins/use-cache-transform.ts +73 -83
  380. package/src/vite/plugins/version-injector.ts +21 -25
  381. package/src/vite/plugins/version-plugin.ts +46 -37
  382. package/src/vite/plugins/virtual-entries.ts +13 -18
  383. package/src/vite/rango.ts +238 -295
  384. package/src/vite/router-discovery.ts +940 -149
  385. package/src/vite/utils/ast-handler-extract.ts +26 -35
  386. package/src/vite/utils/banner.ts +4 -4
  387. package/src/vite/utils/bundle-analysis.ts +10 -15
  388. package/src/vite/utils/client-chunks.ts +184 -0
  389. package/src/vite/utils/directive-prologue.ts +40 -0
  390. package/src/vite/utils/forward-user-plugins.ts +171 -0
  391. package/src/vite/utils/manifest-utils.ts +4 -59
  392. package/src/vite/utils/package-resolution.ts +20 -52
  393. package/src/vite/utils/prerender-utils.ts +81 -34
  394. package/src/vite/utils/shared-utils.ts +92 -42
  395. package/src/browser/action-response-classifier.ts +0 -99
  396. package/src/browser/react/use-client-cache.ts +0 -58
  397. package/src/browser/shallow.ts +0 -40
  398. package/src/handles/index.ts +0 -7
  399. package/src/network-error-thrower.tsx +0 -23
  400. 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,26 +14,29 @@ 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
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";
24
27
 
25
- // Re-export types and cookie utilities for backward compatibility
28
+ // Re-export types consumed through this module's path.
26
29
  export type {
27
30
  CookieOptions,
28
- CollectedMiddleware,
29
- MiddlewareCollectableEntry,
30
31
  MiddlewareContext,
31
32
  MiddlewareEntry,
32
33
  MiddlewareFn,
33
- ResponseHolder,
34
34
  } from "./middleware-types.js";
35
- export { parseCookies, serializeCookie } from "./middleware-cookies.js";
36
35
 
37
36
  const MIDDLEWARE_METRIC_DEPTH = 1;
38
- /** Ignore post-next() durations below this threshold (measurement noise). */
39
37
  const POST_METRIC_MIN_DURATION_MS = 0.01;
40
38
 
41
- function getMiddlewareMetricBase<TEnv>(
39
+ function getMiddlewareMetricLabel<TEnv>(
42
40
  entry: MiddlewareEntry<TEnv>,
43
41
  ordinal: number,
44
42
  ): string {
@@ -46,24 +44,49 @@ function getMiddlewareMetricBase<TEnv>(
46
44
  const scope = entry.pattern ?? "*";
47
45
 
48
46
  if (handlerName) {
49
- return `${handlerName}@${scope}`;
47
+ return `middleware:${handlerName}@${scope}`;
50
48
  }
51
49
 
52
- return `${scope}#${ordinal + 1}`;
53
- }
54
-
55
- function getMiddlewareMetricLabel<TEnv>(
56
- entry: MiddlewareEntry<TEnv>,
57
- ordinal: number,
58
- ): string {
59
- return `middleware:${getMiddlewareMetricBase(entry, ordinal)}`;
50
+ return `middleware:${scope}#${ordinal + 1}`;
60
51
  }
61
52
 
62
53
  /**
63
- * Parse a route pattern into regex and param names
64
- * 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).
65
88
  */
66
- export function parsePattern(pattern: string): {
89
+ export function compileMiddlewarePattern(pattern: string): {
67
90
  regex: RegExp;
68
91
  paramNames: string[];
69
92
  } {
@@ -71,47 +94,54 @@ export function parsePattern(pattern: string): {
71
94
  return { regex: /^.*$/, paramNames: [] };
72
95
  }
73
96
 
97
+ const normalizedPattern = pattern.startsWith("/") ? pattern : `/${pattern}`;
98
+ const segments = parseRoutePattern(normalizedPattern);
74
99
  const paramNames: string[] = [];
75
100
  let regexStr = "^";
101
+ let hasTrailingWildcard = false;
76
102
 
77
- const parts = pattern.split("/").filter(Boolean);
78
-
79
- for (let i = 0; i < parts.length; i++) {
80
- const part = parts[i];
103
+ for (let i = 0; i < segments.length; i++) {
104
+ const segment = segments[i];
81
105
 
82
- if (part === "*") {
83
- // 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`.
84
111
  regexStr += "(?:/.*)?";
85
- } else if (part.startsWith(":")) {
86
- // Param
87
- const paramName = part.slice(1);
88
- paramNames.push(paramName);
89
- 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
+ }
90
126
  } else {
91
- // Literal
92
- regexStr += "/" + escapeRegex(part);
127
+ // Static literal
128
+ regexStr += "/" + escapeRegExp(segment.value);
93
129
  }
94
130
  }
95
131
 
96
- // If pattern doesn't end with *, match exact or with trailing segments
97
- if (!pattern.endsWith("*")) {
98
- regexStr += "/?$";
99
- } else {
100
- regexStr += "$";
101
- }
132
+ // Without a trailing `*`, match exactly with an optional trailing slash.
133
+ regexStr += hasTrailingWildcard ? "$" : "/?$";
102
134
 
103
135
  return { regex: new RegExp(regexStr), paramNames };
104
136
  }
105
137
 
106
138
  /**
107
- * Escape special regex characters
108
- */
109
- function escapeRegex(str: string): string {
110
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
111
- }
112
-
113
- /**
114
- * 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.
115
145
  */
116
146
  export function extractParams(
117
147
  pathname: string,
@@ -123,7 +153,7 @@ export function extractParams(
123
153
 
124
154
  const params: Record<string, string> = {};
125
155
  for (let i = 0; i < paramNames.length; i++) {
126
- params[paramNames[i]] = match[i + 1] || "";
156
+ params[paramNames[i]] = safeDecodeURIComponent(match[i + 1] || "");
127
157
  }
128
158
  return params;
129
159
  }
@@ -147,7 +177,7 @@ export function createMiddlewareContext<TEnv>(
147
177
  search?: Record<string, unknown>,
148
178
  ) => string,
149
179
  ): MiddlewareContext<TEnv> {
150
- const url = new URL(request.url);
180
+ const url = stripInternalParams(new URL(request.url));
151
181
 
152
182
  // Track the initial response to detect pre/post-next() phase.
153
183
  // Before next(): responseHolder.response === initialResponse (the stub).
@@ -178,14 +208,22 @@ export function createMiddlewareContext<TEnv>(
178
208
  return responseHolder.response;
179
209
  };
180
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();
181
217
  return {
182
218
  request,
183
219
  url,
184
- originalUrl: new URL(request.url),
220
+ originalUrl: reqCtx?.originalUrl ?? new URL(request.url),
185
221
  pathname: url.pathname,
186
222
  searchParams: url.searchParams,
187
223
  env: env as MiddlewareContext<TEnv>["env"],
188
224
  params,
225
+ executionContext: reqCtx?.executionContext,
226
+ waitUntil: reqCtx ? reqCtx.waitUntil.bind(reqCtx) : fireAndForgetWaitUntil,
189
227
  // Getter: re-derives from request context on each access so that global
190
228
  // middleware sees the matched route name after await next().
191
229
  get routeName(): MiddlewareContext<TEnv>["routeName"] {
@@ -203,12 +241,9 @@ export function createMiddlewareContext<TEnv>(
203
241
  get: ((keyOrVar: any) =>
204
242
  contextGet(variables, keyOrVar)) as MiddlewareContext<TEnv>["get"],
205
243
 
206
- set: ((keyOrVar: any, value: unknown) => {
207
- contextSet(variables, keyOrVar, value);
244
+ set: ((keyOrVar: any, value: unknown, options?: any) => {
245
+ contextSet(variables, keyOrVar, value, options);
208
246
  }) as MiddlewareContext<TEnv>["set"],
209
-
210
- var: variables as MiddlewareContext<TEnv>["var"],
211
-
212
247
  header(name: string, value: string): void {
213
248
  // Before next(): delegate to shared RequestContext stub
214
249
  if (isPreNext()) {
@@ -247,9 +282,13 @@ export function createMiddlewareContext<TEnv>(
247
282
 
248
283
  reverse:
249
284
  reverse ??
250
- ((name: string) => {
285
+ ((
286
+ name: string,
287
+ _params?: Record<string, string>,
288
+ _search?: Record<string, unknown>,
289
+ ) => {
251
290
  throw new Error(
252
- `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.`,
253
292
  );
254
293
  }),
255
294
 
@@ -283,9 +322,16 @@ export function matchMiddleware<TEnv>(
283
322
  continue;
284
323
  }
285
324
 
286
- // Check if pathname matches
287
- if (entry.regex.test(pathname)) {
288
- 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
+ }
289
335
  matches.push({ entry, params });
290
336
  }
291
337
  }
@@ -293,6 +339,88 @@ export function matchMiddleware<TEnv>(
293
339
  return matches;
294
340
  }
295
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
+
296
424
  /**
297
425
  * Execute middleware chain
298
426
  *
@@ -301,7 +429,7 @@ export function matchMiddleware<TEnv>(
301
429
  * - `ctx.headers` available before and after `await next()`
302
430
  * - `ctx.header()` shorthand for setting a single header
303
431
  * - Forgiving: if middleware doesn't return, uses the downstream response
304
- * - Short-circuit: return Response to stop chain
432
+ * - Short-circuit: return OR throw a Response to stop chain
305
433
  * - Error catching: try/catch around `next()` works
306
434
  */
307
435
  export async function executeMiddleware<TEnv>(
@@ -331,42 +459,16 @@ export async function executeMiddleware<TEnv>(
331
459
  // End of chain - call actual RSC handler
332
460
  const response = await finalHandler();
333
461
 
334
- // Merge headers set on stub into the real response.
335
- // Use append for Set-Cookie to preserve multiple cookies.
336
- const mergedHeaders = new Headers(response.headers);
337
- stubResponse.headers.forEach((value, name) => {
338
- if (name.toLowerCase() === "set-cookie") {
339
- mergedHeaders.append(name, value);
340
- } else {
341
- mergedHeaders.set(name, value);
342
- }
343
- });
344
- // Also merge shared RequestContext stub (cookies written via cookies().set()).
345
- // Dedup Set-Cookie: an inner executeMiddleware (route-level middleware)
346
- // may have already merged the same reqCtx cookies into the response.
347
- const reqCtx = _getRequestContext();
348
- if (reqCtx) {
349
- const stubCookies = reqCtx.res.headers.getSetCookie();
350
- if (stubCookies.length > 0) {
351
- const existing = new Set(mergedHeaders.getSetCookie());
352
- for (const cookie of stubCookies) {
353
- if (!existing.has(cookie)) {
354
- mergedHeaders.append("set-cookie", cookie);
355
- }
356
- }
357
- }
358
- reqCtx.res.headers.forEach((value, name) => {
359
- if (name !== "set-cookie" && !mergedHeaders.has(name)) {
360
- mergedHeaders.set(name, value);
361
- }
362
- });
462
+ if (isWebSocketUpgradeResponse(response)) {
463
+ responseHolder.response = response;
464
+ return response;
363
465
  }
364
466
 
365
- // Clone response with merged headers (mutable for post-next() modifications)
366
- responseHolder.response = new Response(response.body, {
367
- status: response.status,
368
- statusText: response.statusText,
369
- 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,
370
472
  });
371
473
 
372
474
  return responseHolder.response;
@@ -424,12 +526,28 @@ export async function executeMiddleware<TEnv>(
424
526
  return nextPromise;
425
527
  };
426
528
 
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.
427
535
  let result: Response | void;
428
536
  try {
429
- result = await entry.handler(ctx, wrappedNext);
537
+ result = await observePhase(PHASES.middleware(metricLabel), () =>
538
+ entry.handler(ctx, wrappedNext),
539
+ );
430
540
  } catch (error) {
431
- finishMiddleware();
432
- throw 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
+ }
433
551
  }
434
552
  finishMiddleware();
435
553
 
@@ -450,41 +568,18 @@ export async function executeMiddleware<TEnv>(
450
568
 
451
569
  // Explicit return takes precedence (middleware short-circuit).
452
570
  // Merge stub headers (from ctx.header before this point) and
453
- // RequestContext stub headers (from ctx.setCookie) into the
571
+ // RequestContext stub headers (from cookies().set()) into the
454
572
  // returned Response so they are not lost.
455
573
  if (result instanceof Response) {
456
- const mergedHeaders = new Headers(result.headers);
457
- stubResponse.headers.forEach((value, name) => {
458
- if (name.toLowerCase() === "set-cookie") {
459
- mergedHeaders.append(name, value);
460
- } else if (!mergedHeaders.has(name)) {
461
- mergedHeaders.set(name, value);
462
- }
463
- });
464
- // Also merge shared RequestContext stub (cookies written via setCookie).
465
- // Dedup Set-Cookie: an inner executeMiddleware (route-level middleware)
466
- // may have already merged the same reqCtx cookies into the response.
467
- const reqCtx = _getRequestContext();
468
- if (reqCtx) {
469
- const stubCookies = reqCtx.res.headers.getSetCookie();
470
- if (stubCookies.length > 0) {
471
- const existing = new Set(mergedHeaders.getSetCookie());
472
- for (const cookie of stubCookies) {
473
- if (!existing.has(cookie)) {
474
- mergedHeaders.append("set-cookie", cookie);
475
- }
476
- }
477
- }
478
- reqCtx.res.headers.forEach((value, name) => {
479
- if (name !== "set-cookie" && !mergedHeaders.has(name)) {
480
- mergedHeaders.set(name, value);
481
- }
482
- });
574
+ if (isWebSocketUpgradeResponse(result)) {
575
+ responseHolder.response = result;
576
+ return result;
483
577
  }
484
- const merged = new Response(result.body, {
485
- status: result.status,
486
- statusText: result.statusText,
487
- 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,
488
583
  });
489
584
  responseHolder.response = merged;
490
585
  return merged;
@@ -529,23 +624,12 @@ export async function executeMiddleware<TEnv>(
529
624
  // last merge point (e.g. cookies().set() called after await next()).
530
625
  // The reqCtx stub may have already been partially merged during finalHandler
531
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.
532
630
  const reqCtx = _getRequestContext();
533
- if (reqCtx) {
534
- const stubCookies = reqCtx.res.headers.getSetCookie();
535
- if (stubCookies.length > 0) {
536
- const existingCookies = new Set(finalResponse.headers.getSetCookie());
537
- for (const cookie of stubCookies) {
538
- if (!existingCookies.has(cookie)) {
539
- finalResponse.headers.append("set-cookie", cookie);
540
- }
541
- }
542
- }
543
- // Fill in non-cookie headers that aren't already on the response
544
- reqCtx.res.headers.forEach((value, name) => {
545
- if (name !== "set-cookie" && !finalResponse.headers.has(name)) {
546
- finalResponse.headers.set(name, value);
547
- }
548
- });
631
+ if (reqCtx && !isWebSocketUpgradeResponse(finalResponse)) {
632
+ mergeReqCtxStub(finalResponse.headers, reqCtx);
549
633
  }
550
634
 
551
635
  return finalResponse;
@@ -556,7 +640,7 @@ export async function executeMiddleware<TEnv>(
556
640
  *
557
641
  * Intercepts use a shared stubResponse from the request context. This function:
558
642
  * - Runs middleware in sequence with a simple next() chain
559
- * - 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())
560
644
  * - Returns null if all middleware calls next() - headers set after next() remain on stubResponse
561
645
  *
562
646
  * @param middlewares - Array of middleware functions
@@ -594,6 +678,7 @@ export async function executeInterceptMiddleware<TEnv>(
594
678
  return stubResponse;
595
679
  }
596
680
 
681
+ const ordinal = index;
597
682
  const middleware = middlewares[index++];
598
683
  const ctx = createMiddlewareContext(
599
684
  request,
@@ -615,7 +700,29 @@ export async function executeInterceptMiddleware<TEnv>(
615
700
  return next();
616
701
  };
617
702
 
618
- 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
+ }
619
726
 
620
727
  if (result instanceof Response) {
621
728
  earlyResponse = result;
@@ -639,21 +746,13 @@ export async function executeInterceptMiddleware<TEnv>(
639
746
  });
640
747
 
641
748
  if (hasStubHeaders) {
642
- // Clone and merge headers from stub into early response.
643
- // Only fill in missing headers — the returned Response's explicit
644
- // headers take precedence, matching executeMiddleware behavior.
645
- const mergedHeaders = new Headers(response.headers);
646
- stubResponse.headers.forEach((value, name) => {
647
- if (name.toLowerCase() === "set-cookie") {
648
- mergedHeaders.append(name, value);
649
- } else if (!mergedHeaders.has(name)) {
650
- mergedHeaders.set(name, value);
651
- }
652
- });
653
- return new Response(response.body, {
654
- status: response.status,
655
- statusText: response.statusText,
656
- 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,
657
756
  });
658
757
  }
659
758
  return response;
@@ -694,7 +793,6 @@ export async function executeLoaderMiddleware<TEnv>(
694
793
  regex: null,
695
794
  paramNames: [],
696
795
  handler,
697
- mountPrefix: null,
698
796
  } as MiddlewareEntry<TEnv>,
699
797
  params,
700
798
  }));