@rangojs/router 0.0.0-experimental.eb0645d3 → 0.0.0-experimental.f1468e3c

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 (392) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.md +126 -16
  3. package/dist/bin/rango.js +319 -95
  4. package/dist/testing/vitest.js +82 -0
  5. package/dist/vite/index.js +2724 -1053
  6. package/package.json +68 -14
  7. package/skills/api-client/SKILL.md +211 -0
  8. package/skills/breadcrumbs/SKILL.md +64 -2
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +224 -32
  11. package/skills/caching/SKILL.md +279 -17
  12. package/skills/composability/SKILL.md +27 -3
  13. package/skills/css/SKILL.md +76 -0
  14. package/skills/debug-manifest/SKILL.md +4 -2
  15. package/skills/document-cache/SKILL.md +78 -55
  16. package/skills/handler-use/SKILL.md +11 -9
  17. package/skills/hooks/SKILL.md +243 -29
  18. package/skills/host-router/SKILL.md +83 -23
  19. package/skills/i18n/SKILL.md +276 -0
  20. package/skills/intercept/SKILL.md +68 -19
  21. package/skills/layout/SKILL.md +13 -9
  22. package/skills/links/SKILL.md +190 -23
  23. package/skills/loader/SKILL.md +235 -9
  24. package/skills/middleware/SKILL.md +18 -10
  25. package/skills/migrate-nextjs/SKILL.md +43 -19
  26. package/skills/migrate-react-router/SKILL.md +8 -2
  27. package/skills/mime-routes/SKILL.md +28 -1
  28. package/skills/observability/SKILL.md +172 -0
  29. package/skills/parallel/SKILL.md +18 -7
  30. package/skills/prerender/SKILL.md +65 -60
  31. package/skills/rango/SKILL.md +251 -24
  32. package/skills/react-compiler/SKILL.md +168 -0
  33. package/skills/response-routes/SKILL.md +115 -48
  34. package/skills/route/SKILL.md +46 -5
  35. package/skills/router-setup/SKILL.md +30 -8
  36. package/skills/scripts/SKILL.md +179 -0
  37. package/skills/server-actions/SKILL.md +775 -0
  38. package/skills/tailwind/SKILL.md +27 -3
  39. package/skills/testing/SKILL.md +130 -0
  40. package/skills/testing/bindings.md +103 -0
  41. package/skills/testing/cache-prerender.md +127 -0
  42. package/skills/testing/client-components.md +124 -0
  43. package/skills/testing/e2e-parity.md +125 -0
  44. package/skills/testing/flight.md +91 -0
  45. package/skills/testing/handles.md +129 -0
  46. package/skills/testing/loader.md +128 -0
  47. package/skills/testing/middleware.md +99 -0
  48. package/skills/testing/render-handler.md +122 -0
  49. package/skills/testing/response-routes.md +95 -0
  50. package/skills/testing/reverse-and-types.md +84 -0
  51. package/skills/testing/server-actions.md +107 -0
  52. package/skills/testing/server-tree.md +128 -0
  53. package/skills/testing/setup.md +123 -0
  54. package/skills/typesafety/SKILL.md +322 -29
  55. package/skills/use-cache/SKILL.md +57 -14
  56. package/skills/view-transitions/SKILL.md +337 -0
  57. package/src/__augment-tests__/augment.ts +81 -0
  58. package/src/__augment-tests__/augmented.check.ts +116 -0
  59. package/src/__internal.ts +0 -65
  60. package/src/browser/action-coordinator.ts +53 -36
  61. package/src/browser/action-fence.ts +47 -0
  62. package/src/browser/app-shell.ts +39 -0
  63. package/src/browser/connection-warmup.ts +134 -0
  64. package/src/browser/cookie-name.ts +140 -0
  65. package/src/browser/event-controller.ts +192 -150
  66. package/src/browser/history-state.ts +21 -0
  67. package/src/browser/index.ts +3 -3
  68. package/src/browser/invalidate-client-cache.ts +52 -0
  69. package/src/browser/navigation-bridge.ts +94 -25
  70. package/src/browser/navigation-client.ts +121 -84
  71. package/src/browser/navigation-store-handle.ts +38 -0
  72. package/src/browser/navigation-store.ts +115 -67
  73. package/src/browser/navigation-transaction.ts +9 -59
  74. package/src/browser/network-error-handler.ts +34 -7
  75. package/src/browser/partial-update.ts +147 -128
  76. package/src/browser/prefetch/cache.ts +107 -56
  77. package/src/browser/prefetch/fetch.ts +204 -34
  78. package/src/browser/prefetch/queue.ts +6 -3
  79. package/src/browser/rango-state.ts +158 -76
  80. package/src/browser/react/Link.tsx +30 -7
  81. package/src/browser/react/NavigationProvider.tsx +283 -118
  82. package/src/browser/react/ScrollRestoration.tsx +10 -6
  83. package/src/browser/react/deferred-handle-resolution.ts +75 -0
  84. package/src/browser/react/filter-segment-order.ts +66 -7
  85. package/src/browser/react/index.ts +0 -48
  86. package/src/browser/react/location-state-shared.ts +178 -8
  87. package/src/browser/react/location-state.ts +39 -14
  88. package/src/browser/react/use-action.ts +6 -15
  89. package/src/browser/react/use-handle.ts +17 -14
  90. package/src/browser/react/use-href.tsx +8 -1
  91. package/src/browser/react/use-link-status.ts +33 -8
  92. package/src/browser/react/use-navigation.ts +10 -5
  93. package/src/browser/react/use-params.ts +11 -11
  94. package/src/browser/react/use-reverse.ts +106 -0
  95. package/src/browser/react/use-router.ts +25 -3
  96. package/src/browser/react/use-search-params.ts +0 -5
  97. package/src/browser/react/use-segments.ts +11 -21
  98. package/src/browser/response-adapter.ts +99 -8
  99. package/src/browser/rsc-router.tsx +91 -24
  100. package/src/browser/scroll-restoration.ts +30 -17
  101. package/src/browser/segment-structure-assert.ts +2 -2
  102. package/src/browser/server-action-bridge.ts +214 -55
  103. package/src/browser/types.ts +80 -9
  104. package/src/browser/validate-redirect-origin.ts +43 -16
  105. package/src/build/collect-fallback-refs.ts +107 -0
  106. package/src/build/generate-manifest.ts +60 -35
  107. package/src/build/generate-route-types.ts +2 -1
  108. package/src/build/index.ts +8 -2
  109. package/src/build/prefix-tree-utils.ts +123 -0
  110. package/src/build/route-trie.ts +117 -14
  111. package/src/build/route-types/ast-route-extraction.ts +15 -8
  112. package/src/build/route-types/codegen.ts +16 -5
  113. package/src/build/route-types/include-resolution.ts +117 -23
  114. package/src/build/route-types/param-extraction.ts +6 -3
  115. package/src/build/route-types/per-module-writer.ts +22 -6
  116. package/src/build/route-types/router-processing.ts +55 -28
  117. package/src/build/route-types/scan-filter.ts +1 -1
  118. package/src/build/route-types/source-scan.ts +216 -0
  119. package/src/build/runtime-discovery.ts +9 -20
  120. package/src/cache/cache-error.ts +104 -0
  121. package/src/cache/cache-key-utils.ts +29 -13
  122. package/src/cache/cache-policy.ts +108 -34
  123. package/src/cache/cache-runtime.ts +224 -41
  124. package/src/cache/cache-scope.ts +188 -82
  125. package/src/cache/cache-tag.ts +103 -0
  126. package/src/cache/cf/cf-base64.ts +33 -0
  127. package/src/cache/cf/cf-cache-constants.ts +127 -0
  128. package/src/cache/cf/cf-cache-store.ts +1989 -378
  129. package/src/cache/cf/cf-cache-types.ts +349 -0
  130. package/src/cache/cf/cf-kv-utils.ts +46 -0
  131. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  132. package/src/cache/cf/index.ts +6 -16
  133. package/src/cache/document-cache.ts +89 -21
  134. package/src/cache/handle-snapshot.ts +70 -0
  135. package/src/cache/index.ts +10 -20
  136. package/src/cache/memory-segment-store.ts +136 -37
  137. package/src/cache/profile-registry.ts +46 -31
  138. package/src/cache/read-through-swr.ts +56 -12
  139. package/src/cache/segment-codec.ts +9 -17
  140. package/src/cache/tag-invalidation.ts +230 -0
  141. package/src/cache/types.ts +37 -100
  142. package/src/client.rsc.tsx +44 -21
  143. package/src/client.tsx +36 -61
  144. package/src/cloudflare/index.ts +11 -0
  145. package/src/cloudflare/tracing.ts +109 -0
  146. package/src/component-utils.ts +19 -0
  147. package/src/components/DefaultDocument.tsx +8 -2
  148. package/src/context-var.ts +18 -6
  149. package/src/decode-loader-results.ts +52 -0
  150. package/src/defer.ts +196 -0
  151. package/src/deps/ssr.ts +0 -1
  152. package/src/encode-kv.ts +49 -0
  153. package/src/errors.ts +30 -4
  154. package/src/escape-script.ts +52 -0
  155. package/src/handle.ts +31 -23
  156. package/src/handles/MetaTags.tsx +62 -19
  157. package/src/handles/Scripts.tsx +183 -0
  158. package/src/handles/breadcrumbs.ts +37 -8
  159. package/src/handles/is-thenable.ts +19 -0
  160. package/src/handles/meta.ts +51 -40
  161. package/src/handles/script.ts +244 -0
  162. package/src/host/cookie-handler.ts +9 -60
  163. package/src/host/errors.ts +0 -24
  164. package/src/host/index.ts +8 -2
  165. package/src/host/pattern-matcher.ts +23 -52
  166. package/src/host/router.ts +107 -99
  167. package/src/host/testing.ts +40 -27
  168. package/src/host/types.ts +37 -4
  169. package/src/host/utils.ts +1 -1
  170. package/src/href-client.ts +137 -22
  171. package/src/index.rsc.ts +96 -12
  172. package/src/index.ts +94 -14
  173. package/src/internal-debug.ts +11 -10
  174. package/src/loader-store.ts +500 -0
  175. package/src/loader.rsc.ts +20 -13
  176. package/src/loader.ts +12 -11
  177. package/src/missing-id-error.ts +68 -0
  178. package/src/outlet-context.ts +1 -1
  179. package/src/outlet-provider.tsx +1 -5
  180. package/src/prerender/param-hash.ts +16 -16
  181. package/src/prerender/store.ts +32 -37
  182. package/src/prerender.ts +61 -6
  183. package/src/redirect-origin.ts +100 -0
  184. package/src/regex-escape.ts +8 -0
  185. package/src/render-error-thrower.tsx +20 -0
  186. package/src/response-utils.ts +34 -0
  187. package/src/reverse.ts +65 -40
  188. package/src/root-error-boundary.tsx +1 -19
  189. package/src/route-content-wrapper.tsx +19 -77
  190. package/src/route-definition/dsl-helpers.ts +304 -309
  191. package/src/route-definition/helper-factories.ts +28 -140
  192. package/src/route-definition/helpers-types.ts +82 -55
  193. package/src/route-definition/index.ts +1 -2
  194. package/src/route-definition/redirect.ts +44 -11
  195. package/src/route-definition/resolve-handler-use.ts +12 -1
  196. package/src/route-definition/use-item-types.ts +29 -0
  197. package/src/route-map-builder.ts +0 -16
  198. package/src/route-types.ts +19 -46
  199. package/src/router/basename.ts +14 -0
  200. package/src/router/content-negotiation.ts +73 -25
  201. package/src/router/error-handling.ts +45 -18
  202. package/src/router/find-match.ts +44 -23
  203. package/src/router/handler-context.ts +27 -43
  204. package/src/router/instrument.ts +350 -0
  205. package/src/router/intercept-resolution.ts +39 -20
  206. package/src/router/lazy-includes.ts +10 -47
  207. package/src/router/loader-resolution.ts +155 -72
  208. package/src/router/logging.ts +0 -6
  209. package/src/router/manifest.ts +18 -29
  210. package/src/router/match-api.ts +9 -24
  211. package/src/router/match-context.ts +0 -22
  212. package/src/router/match-handlers.ts +58 -58
  213. package/src/router/match-middleware/background-revalidation.ts +40 -24
  214. package/src/router/match-middleware/cache-lookup.ts +159 -285
  215. package/src/router/match-middleware/cache-store.ts +64 -52
  216. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  217. package/src/router/match-middleware/segment-resolution.ts +0 -22
  218. package/src/router/match-pipelines.ts +1 -42
  219. package/src/router/match-result.ts +44 -74
  220. package/src/router/metrics.ts +0 -34
  221. package/src/router/middleware-types.ts +7 -134
  222. package/src/router/middleware.ts +247 -166
  223. package/src/router/navigation-snapshot.ts +0 -51
  224. package/src/router/params-util.ts +23 -0
  225. package/src/router/pattern-matching.ts +85 -94
  226. package/src/router/prefetch-cache-ttl.ts +51 -0
  227. package/src/router/prerender-match.ts +104 -65
  228. package/src/router/preview-match.ts +3 -1
  229. package/src/router/request-classification.ts +28 -62
  230. package/src/router/revalidation.ts +123 -73
  231. package/src/router/route-snapshot.ts +0 -1
  232. package/src/router/router-context.ts +3 -28
  233. package/src/router/router-interfaces.ts +83 -35
  234. package/src/router/router-options.ts +136 -5
  235. package/src/router/router-registry.ts +2 -5
  236. package/src/router/segment-resolution/fresh.ts +97 -84
  237. package/src/router/segment-resolution/helpers.ts +86 -6
  238. package/src/router/segment-resolution/loader-cache.ts +76 -39
  239. package/src/router/segment-resolution/revalidation.ts +272 -320
  240. package/src/router/segment-resolution/static-store.ts +19 -5
  241. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  242. package/src/router/segment-resolution/view-transition-default.ts +56 -0
  243. package/src/router/segment-resolution.ts +5 -1
  244. package/src/router/segment-wrappers.ts +6 -5
  245. package/src/router/state-cookie-name.ts +33 -0
  246. package/src/router/substitute-pattern-params.ts +56 -0
  247. package/src/router/telemetry-otel.ts +161 -199
  248. package/src/router/telemetry.ts +96 -19
  249. package/src/router/timeout.ts +0 -20
  250. package/src/router/tracing.ts +206 -0
  251. package/src/router/trie-matching.ts +162 -64
  252. package/src/router/types.ts +9 -63
  253. package/src/router/url-params.ts +0 -5
  254. package/src/router.ts +110 -55
  255. package/src/rsc/handler-context.ts +3 -2
  256. package/src/rsc/handler.ts +264 -220
  257. package/src/rsc/helpers.ts +100 -6
  258. package/src/rsc/index.ts +2 -5
  259. package/src/rsc/json-route-result.ts +38 -0
  260. package/src/rsc/loader-fetch.ts +114 -38
  261. package/src/rsc/manifest-init.ts +28 -41
  262. package/src/rsc/origin-guard.ts +39 -25
  263. package/src/rsc/progressive-enhancement.ts +117 -11
  264. package/src/rsc/redirect-guard.ts +99 -0
  265. package/src/rsc/response-cache-serve.ts +238 -0
  266. package/src/rsc/response-error.ts +79 -12
  267. package/src/rsc/response-route-handler.ts +88 -188
  268. package/src/rsc/rsc-rendering.ts +98 -76
  269. package/src/rsc/runtime-warnings.ts +23 -10
  270. package/src/rsc/server-action.ts +281 -117
  271. package/src/rsc/ssr-setup.ts +16 -0
  272. package/src/rsc/transition-gate.ts +89 -0
  273. package/src/rsc/types.ts +23 -5
  274. package/src/runtime-env.ts +18 -0
  275. package/src/search-params.ts +35 -30
  276. package/src/segment-loader-promise.ts +31 -4
  277. package/src/segment-system.tsx +254 -143
  278. package/src/serialize.ts +243 -0
  279. package/src/server/context.ts +163 -51
  280. package/src/server/cookie-parse.ts +32 -0
  281. package/src/server/cookie-store.ts +80 -5
  282. package/src/server/handle-store.ts +21 -38
  283. package/src/server/loader-registry.ts +33 -42
  284. package/src/server/request-context.ts +287 -178
  285. package/src/ssr/index.tsx +21 -16
  286. package/src/static-handler.ts +10 -13
  287. package/src/testing/cache-status.ts +162 -0
  288. package/src/testing/collect-handle.ts +40 -0
  289. package/src/testing/dispatch.ts +701 -0
  290. package/src/testing/dom.entry.ts +22 -0
  291. package/src/testing/e2e/fixture.ts +188 -0
  292. package/src/testing/e2e/index.ts +128 -0
  293. package/src/testing/e2e/matchers.ts +35 -0
  294. package/src/testing/e2e/page-helpers.ts +272 -0
  295. package/src/testing/e2e/parity.ts +387 -0
  296. package/src/testing/e2e/server.ts +195 -0
  297. package/src/testing/flight-matchers.ts +97 -0
  298. package/src/testing/flight-normalize.ts +11 -0
  299. package/src/testing/flight-runtime.d.ts +57 -0
  300. package/src/testing/flight-tree.ts +682 -0
  301. package/src/testing/flight.entry.ts +52 -0
  302. package/src/testing/flight.ts +257 -0
  303. package/src/testing/generated-routes.ts +183 -0
  304. package/src/testing/index.ts +105 -0
  305. package/src/testing/internal/context.ts +371 -0
  306. package/src/testing/internal/flight-client-globals.ts +30 -0
  307. package/src/testing/internal/seed-vars.ts +54 -0
  308. package/src/testing/render-handler.ts +357 -0
  309. package/src/testing/render-route.tsx +581 -0
  310. package/src/testing/run-loader.ts +385 -0
  311. package/src/testing/run-middleware.ts +205 -0
  312. package/src/testing/run-transition-when.ts +164 -0
  313. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  314. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  315. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  316. package/src/testing/vitest-stubs/version.ts +5 -0
  317. package/src/testing/vitest.ts +305 -0
  318. package/src/theme/ThemeProvider.tsx +20 -58
  319. package/src/theme/ThemeScript.tsx +7 -9
  320. package/src/theme/constants.ts +52 -13
  321. package/src/theme/index.ts +0 -7
  322. package/src/theme/theme-context.ts +1 -5
  323. package/src/theme/theme-script.ts +22 -21
  324. package/src/theme/use-theme.ts +0 -3
  325. package/src/types/boundaries.ts +0 -35
  326. package/src/types/cache-types.ts +13 -4
  327. package/src/types/error-types.ts +30 -90
  328. package/src/types/global-namespace.ts +54 -41
  329. package/src/types/handler-context.ts +110 -62
  330. package/src/types/index.ts +3 -10
  331. package/src/types/loader-types.ts +11 -9
  332. package/src/types/request-scope.ts +112 -0
  333. package/src/types/route-config.ts +6 -50
  334. package/src/types/route-entry.ts +0 -6
  335. package/src/types/segments.ts +135 -14
  336. package/src/urls/include-helper.ts +9 -56
  337. package/src/urls/index.ts +1 -11
  338. package/src/urls/path-helper-types.ts +29 -12
  339. package/src/urls/path-helper.ts +17 -106
  340. package/src/urls/pattern-types.ts +36 -19
  341. package/src/urls/response-types.ts +22 -29
  342. package/src/urls/type-extraction.ts +58 -139
  343. package/src/urls/urls-function.ts +1 -19
  344. package/src/use-loader.tsx +292 -107
  345. package/src/vite/debug.ts +185 -0
  346. package/src/vite/discovery/bundle-postprocess.ts +8 -7
  347. package/src/vite/discovery/discover-routers.ts +126 -85
  348. package/src/vite/discovery/discovery-errors.ts +194 -0
  349. package/src/vite/discovery/gate-state.ts +171 -0
  350. package/src/vite/discovery/prerender-collection.ts +96 -68
  351. package/src/vite/discovery/route-types-writer.ts +40 -84
  352. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  353. package/src/vite/discovery/state.ts +44 -0
  354. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  355. package/src/vite/index.ts +2 -0
  356. package/src/vite/inject-client-debug.ts +36 -0
  357. package/src/vite/plugin-types.ts +126 -8
  358. package/src/vite/plugins/cjs-to-esm.ts +16 -19
  359. package/src/vite/plugins/client-ref-dedup.ts +16 -11
  360. package/src/vite/plugins/client-ref-hashing.ts +28 -15
  361. package/src/vite/plugins/cloudflare-protocol-stub.ts +1 -21
  362. package/src/vite/plugins/expose-action-id.ts +48 -95
  363. package/src/vite/plugins/expose-id-utils.ts +88 -55
  364. package/src/vite/plugins/expose-ids/export-analysis.ts +101 -34
  365. package/src/vite/plugins/expose-ids/handler-transform.ts +11 -90
  366. package/src/vite/plugins/expose-ids/loader-transform.ts +14 -24
  367. package/src/vite/plugins/expose-ids/router-transform.ts +118 -29
  368. package/src/vite/plugins/expose-internal-ids.ts +505 -486
  369. package/src/vite/plugins/performance-tracks.ts +26 -25
  370. package/src/vite/plugins/refresh-cmd.ts +1 -1
  371. package/src/vite/plugins/use-cache-transform.ts +73 -83
  372. package/src/vite/plugins/version-injector.ts +40 -29
  373. package/src/vite/plugins/version-plugin.ts +37 -40
  374. package/src/vite/plugins/virtual-entries.ts +39 -25
  375. package/src/vite/rango.ts +109 -118
  376. package/src/vite/router-discovery.ts +718 -119
  377. package/src/vite/utils/ast-handler-extract.ts +26 -35
  378. package/src/vite/utils/banner.ts +1 -1
  379. package/src/vite/utils/bundle-analysis.ts +10 -15
  380. package/src/vite/utils/client-chunks.ts +184 -0
  381. package/src/vite/utils/directive-prologue.ts +40 -0
  382. package/src/vite/utils/forward-user-plugins.ts +171 -0
  383. package/src/vite/utils/manifest-utils.ts +4 -59
  384. package/src/vite/utils/package-resolution.ts +20 -52
  385. package/src/vite/utils/prerender-utils.ts +54 -39
  386. package/src/vite/utils/shared-utils.ts +90 -41
  387. package/src/browser/action-response-classifier.ts +0 -99
  388. package/src/browser/react/use-client-cache.ts +0 -58
  389. package/src/browser/shallow.ts +0 -40
  390. package/src/handles/index.ts +0 -7
  391. package/src/network-error-thrower.tsx +0 -23
  392. package/src/router/middleware-cookies.ts +0 -55
@@ -7,12 +7,14 @@
7
7
  */
8
8
 
9
9
  import {
10
- requireRequestContext,
10
+ getRequestContext,
11
11
  setRequestContextParams,
12
12
  } from "../server/request-context.js";
13
13
  import { getSSRSetup } from "./ssr-setup.js";
14
14
  import type { MiddlewareFn } from "../router/middleware.js";
15
15
  import { executeMiddleware } from "../router/middleware.js";
16
+ import { observePhase, PHASES } from "../router/instrument.js";
17
+ import { gateTransitions } from "./transition-gate.js";
16
18
  import type { RscPayload, ReactFormState } from "./types.js";
17
19
  import {
18
20
  createResponseWithMergedHeaders,
@@ -44,7 +46,7 @@ export async function handleProgressiveEnhancement<TEnv>(
44
46
  env: TEnv,
45
47
  url: URL,
46
48
  isAction: boolean,
47
- handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
49
+ handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
48
50
  nonce: string | undefined,
49
51
  routeMwInfo?: PeRouteMiddlewareInfo,
50
52
  ): Promise<Response | null> {
@@ -115,6 +117,13 @@ export async function handleProgressiveEnhancement<TEnv>(
115
117
  // Execute action and return HTML
116
118
  let actionResult: unknown = undefined;
117
119
  let reactFormState: ReactFormState | null = null;
120
+ // Status for the fall-through re-render after a boundaryless action error.
121
+ // When an action throws and NO error boundary matches, the PE path re-renders
122
+ // the page (below) the same way it would after a successful action. Without
123
+ // this the re-render serves HTTP 200, diverging from the JS path which serves
124
+ // 500 (server-action.ts sets actionStatus=500 for the same boundaryless error).
125
+ // 500 is carried only into the final HTML response, never the redirect branch.
126
+ let boundarylessErrorStatus: number | undefined;
118
127
 
119
128
  if (isUseActionState) {
120
129
  // Decode and extract action identity before execution so error
@@ -124,7 +133,11 @@ export async function handleProgressiveEnhancement<TEnv>(
124
133
  const boundAction = await ctx.decodeAction(formData);
125
134
  // React's custom .bind() preserves $$id on server references.
126
135
  useActionStateId = (boundAction as { $$id?: string }).$$id ?? undefined;
127
- actionResult = await boundAction();
136
+ // Meter the no-JS form action as the action phase, same as the JS path.
137
+ actionResult = await observePhase(
138
+ PHASES.action(useActionStateId ?? "useActionState"),
139
+ () => boundAction(),
140
+ );
128
141
  } catch (error) {
129
142
  // Handle thrown redirect (e.g., throw redirect('/path'))
130
143
  const redirectResponse = extractRedirectResponse(error);
@@ -140,6 +153,7 @@ export async function handleProgressiveEnhancement<TEnv>(
140
153
  handleStore,
141
154
  nonce,
142
155
  useActionStateId,
156
+ true, // an action ran and threw
143
157
  );
144
158
  if (errorHtml) return errorHtml;
145
159
 
@@ -151,10 +165,21 @@ export async function handleProgressiveEnhancement<TEnv>(
151
165
  handledByBoundary: false,
152
166
  });
153
167
  console.error("[RSC] Progressive enhancement action error:", error);
168
+ // No boundary matched — the fall-through re-render must carry 500 to match
169
+ // the JS path's boundaryless-error status (server-action.ts).
170
+ boundarylessErrorStatus = 500;
154
171
  }
155
172
  } else if (isDirectAction && directActionId) {
156
173
  const temporaryReferences = ctx.createTemporaryReferenceSet();
157
174
 
175
+ // INTENTIONAL JS/PE divergence (do NOT "fix" to match the JS reject path).
176
+ // On the JS path React Flight-encodes the action args, so decodeReply
177
+ // succeeds or a failure means a malformed body (rejected). On the no-JS PE
178
+ // path the browser submits a raw <form action={fn}> POST with NO encoded
179
+ // args, so decodeReply throws by design and the raw FormData IS the action
180
+ // argument (the React form-action convention: fn(formData)). Removing this
181
+ // fallback breaks every unbound no-JS form action (verified: it fails the
182
+ // progressive-enhancement dev+prod e2e suite). See #572 (decided: keep).
158
183
  let args: unknown[] = [];
159
184
  try {
160
185
  args = await ctx.decodeReply(formData, { temporaryReferences });
@@ -164,7 +189,9 @@ export async function handleProgressiveEnhancement<TEnv>(
164
189
 
165
190
  try {
166
191
  const loadedAction = await ctx.loadServerAction(directActionId);
167
- actionResult = await loadedAction.apply(null, args);
192
+ actionResult = await observePhase(PHASES.action(directActionId), () =>
193
+ loadedAction.apply(null, args),
194
+ );
168
195
  } catch (error) {
169
196
  // Handle thrown redirect (e.g., throw redirect('/path'))
170
197
  const redirectResponse = extractRedirectResponse(error);
@@ -180,6 +207,7 @@ export async function handleProgressiveEnhancement<TEnv>(
180
207
  handleStore,
181
208
  nonce,
182
209
  directActionId,
210
+ true, // an action ran and threw
183
211
  );
184
212
  if (errorHtml) return errorHtml;
185
213
 
@@ -191,6 +219,9 @@ export async function handleProgressiveEnhancement<TEnv>(
191
219
  handledByBoundary: false,
192
220
  });
193
221
  console.error("[RSC] Progressive enhancement action error:", error);
222
+ // No boundary matched — the fall-through re-render must carry 500 to match
223
+ // the JS path's boundaryless-error status (server-action.ts).
224
+ boundarylessErrorStatus = 500;
194
225
  }
195
226
  }
196
227
 
@@ -226,11 +257,29 @@ export async function handleProgressiveEnhancement<TEnv>(
226
257
  // cookies set by route middleware are available during re-render — matching
227
258
  // the behavior of JS-enabled requests.
228
259
  const renderPage = async (): Promise<Response> => {
260
+ // Preserve the original POST request's headers (Authorization, Cookie,
261
+ // custom headers) so loaders that read request headers/cookies behave
262
+ // identically under PE and the JS action path. Drop body-framing headers
263
+ // from the bodyless GET and force the HTML accept.
264
+ const headers = new Headers(request.headers);
265
+ headers.delete("content-type");
266
+ headers.delete("content-length");
267
+ headers.delete("content-encoding");
268
+ headers.delete("transfer-encoding");
269
+ headers.set("accept", "text/html");
229
270
  const renderRequest = new Request(url.toString(), {
230
271
  method: "GET",
231
- headers: new Headers({ accept: "text/html" }),
272
+ headers,
232
273
  });
233
274
 
275
+ // JS/PE parity: this is an action's revalidation render, so mark it BEFORE
276
+ // matching — a stale `foregroundOnAction` cache entry must re-execute in the
277
+ // foreground during the re-render, exactly as the JS path's
278
+ // revalidateAfterAction does. The transition({ when }) gate fields below are
279
+ // set post-match (the gate reads them after rendering); foregroundOnAction
280
+ // reads _inActionRevalidation during the match, so it must be set here.
281
+ getRequestContext()._inActionRevalidation = true;
282
+
234
283
  const match = await ctx.router.match(renderRequest, { env });
235
284
 
236
285
  if (match.redirect) {
@@ -240,24 +289,40 @@ export async function handleProgressiveEnhancement<TEnv>(
240
289
  });
241
290
  }
242
291
 
292
+ // Expose the no-JS action to the transition({ when }) gate. currentUrl/Params
293
+ // are absent on this full-render path (no navigation snapshot); useActionState
294
+ // ids are block-scoped, so only a direct action id is available here.
295
+ // actionUrl is the page the action was submitted from (this request's url).
296
+ const peReqCtx = getRequestContext();
297
+ peReqCtx._gateActionId = directActionId ?? undefined;
298
+ peReqCtx._gateActionUrl = new URL(url);
299
+ peReqCtx._gateActionResult = actionResult;
300
+ peReqCtx._gateFormData = formData;
301
+
243
302
  const payload: RscPayload = {
244
303
  metadata: {
245
304
  pathname: url.pathname,
246
305
  routerId: ctx.router.id,
247
306
  basename: ctx.router.basename,
248
- segments: match.segments,
307
+ segments: gateTransitions(
308
+ match.segments,
309
+ getRequestContext(),
310
+ ctx.router.onError,
311
+ ),
249
312
  matched: match.matched,
250
313
  diff: match.diff,
314
+ resolvedIds: match.resolvedIds,
251
315
  params: match.params,
252
316
  isPartial: false,
253
317
  rootLayout: ctx.router.rootLayout,
254
318
  handles: handleStore.stream(),
255
319
  version: ctx.version,
320
+ stateCookieName: ctx.router.resolvedStateCookieName,
256
321
  themeConfig: ctx.router.themeConfig,
257
322
  warmupEnabled: ctx.router.warmupEnabled,
258
- initialTheme: requireRequestContext().theme,
323
+ strictMode: ctx.router.strictMode,
324
+ initialTheme: getRequestContext().theme,
259
325
  },
260
- formState: actionResult,
261
326
  };
262
327
 
263
328
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
@@ -275,6 +340,8 @@ export async function handleProgressiveEnhancement<TEnv>(
275
340
  url,
276
341
  undefined,
277
342
  );
343
+ // reactFormState carries the useActionState payload via the SSR-option path
344
+ // (renderToReadableStream({ formState })); it does NOT travel on RscPayload.
278
345
  const htmlStream = await ssrModule.renderHTML(rscStream, {
279
346
  formState: reactFormState,
280
347
  nonce,
@@ -282,6 +349,13 @@ export async function handleProgressiveEnhancement<TEnv>(
282
349
  });
283
350
 
284
351
  return createResponseWithMergedHeaders(htmlStream, {
352
+ // boundarylessErrorStatus is set only when the action threw and no error
353
+ // boundary matched; it makes the re-render carry 500 like the JS path.
354
+ // The redirect branch above returns before this, so a redirect re-render
355
+ // keeps its 308 and is never overridden.
356
+ ...(boundarylessErrorStatus !== undefined
357
+ ? { status: boundarylessErrorStatus }
358
+ : {}),
285
359
  headers: { "content-type": "text/html;charset=utf-8" },
286
360
  });
287
361
  };
@@ -316,10 +390,25 @@ async function renderPeErrorBoundary<TEnv>(
316
390
  env: TEnv,
317
391
  url: URL,
318
392
  error: unknown,
319
- handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
393
+ handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
320
394
  nonce: string | undefined,
321
395
  actionId?: string | null,
396
+ // True when an action actually ran and threw (vs a malformed form body, where
397
+ // no action executed). Drives _inActionRevalidation for JS/PE parity — it must
398
+ // NOT be inferred from actionId, since a useActionState bound action can run
399
+ // and throw with no $$id (actionId === undefined) yet still be an action error.
400
+ actionRan = false,
322
401
  ): Promise<Response | null> {
402
+ // JS/PE parity for an action-triggered error re-render: a stale
403
+ // `foregroundOnAction` cache entry inside the error boundary must foreground
404
+ // too, exactly as the JS path (revalidateAfterAction sets this unconditionally
405
+ // before rendering the error boundary). Set BEFORE matchError (the cached fn
406
+ // runs during it). Gated on actionRan, NOT actionId — a malformed form body
407
+ // (actionRan=false) ran no action and must keep SWR.
408
+ if (actionRan) {
409
+ getRequestContext()._inActionRevalidation = true;
410
+ }
411
+
323
412
  let errorResult;
324
413
  try {
325
414
  errorResult = await ctx.router.matchError(request, { env }, error, "route");
@@ -346,23 +435,40 @@ async function renderPeErrorBoundary<TEnv>(
346
435
 
347
436
  setRequestContextParams(errorResult.params, errorResult.routeName);
348
437
 
438
+ // Only the failing action id + URL are in scope here (no formData/actionResult
439
+ // thread into this helper). Expose the URL only when the action id is known:
440
+ // this helper also handles malformed form bodies before action detection, and
441
+ // those should not look like action-triggered renders to transition({ when }).
442
+ if (actionId != null) {
443
+ const peErrCtx = getRequestContext();
444
+ peErrCtx._gateActionId = actionId;
445
+ peErrCtx._gateActionUrl = new URL(url);
446
+ }
447
+
349
448
  const payload: RscPayload = {
350
449
  metadata: {
351
450
  pathname: url.pathname,
352
451
  routerId: ctx.router.id,
353
452
  basename: ctx.router.basename,
354
- segments: errorResult.segments,
453
+ segments: gateTransitions(
454
+ errorResult.segments,
455
+ getRequestContext(),
456
+ ctx.router.onError,
457
+ ),
355
458
  matched: errorResult.matched,
356
459
  diff: errorResult.diff,
460
+ resolvedIds: errorResult.resolvedIds,
357
461
  params: errorResult.params,
358
462
  isPartial: false,
359
463
  isError: true,
360
464
  rootLayout: ctx.router.rootLayout,
361
465
  handles: handleStore.stream(),
362
466
  version: ctx.version,
467
+ stateCookieName: ctx.router.resolvedStateCookieName,
363
468
  themeConfig: ctx.router.themeConfig,
364
469
  warmupEnabled: ctx.router.warmupEnabled,
365
- initialTheme: requireRequestContext().theme,
470
+ strictMode: ctx.router.strictMode,
471
+ initialTheme: getRequestContext().theme,
366
472
  },
367
473
  };
368
474
 
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Server-side open-redirect guard.
3
+ *
4
+ * Applied to the FINAL handler response (the single top-level return in
5
+ * `handler.ts`) so every browser-followed redirect honors the same same-origin
6
+ * rule the client enforces (`browser/validate-redirect-origin.ts`), via the one
7
+ * shared resolver in `redirect-origin.ts`. This is the server half of the
8
+ * client's existing guard: the client can only validate redirects its own JS
9
+ * navigates to (the SPA/fetch channel), so document-native redirects -- a no-JS
10
+ * PE form POST, a full-page GET `match.redirect`, a middleware `redirect()`
11
+ * short-circuit, a response-route 3xx -- reach the browser with no client in the
12
+ * loop. They all funnel through one handler return, so guarding there covers
13
+ * every one and any future redirect exit.
14
+ *
15
+ * Soft (SPA/Flight) redirects are 200/204 responses (`X-RSC-Redirect` header or
16
+ * `metadata.redirect` payload) and are NOT redirect Responses, so they never
17
+ * reach this guard -- they stay validated client-side.
18
+ *
19
+ * Behavior on a `Location` header:
20
+ * - same-origin / relative -> passes through unchanged
21
+ * - `redirect(url, { external: true })` (out-of-band brand present) and an
22
+ * http(s) target -> allowed (explicit, auditable, unforgeable opt-in)
23
+ * - branded but a non-http(s) target (e.g. `javascript:`) -> neutralized: the
24
+ * opt-in waives the same-origin rule, NOT scheme safety
25
+ * - cross-origin without the brand -> Location rewritten to the basename root
26
+ * (a safe same-origin landing, the document analog of the client's "stay put");
27
+ * dev logs the blocked target and points to `{ external: true }`.
28
+ *
29
+ * The opt-in is an out-of-band brand on the Response object (isExternalRedirect),
30
+ * never a wire header: a header is forgeable by an attacker-controlled upstream
31
+ * response a proxy-style response route copies through, which would defeat the
32
+ * guard without app code ever opting in. The reserved header name is stripped
33
+ * defensively so a forged value can never reach the browser.
34
+ */
35
+
36
+ import { isRedirectResponse } from "../response-utils.js";
37
+ import {
38
+ resolveSameOriginRedirect,
39
+ resolveExternalRedirect,
40
+ isExternalRedirect,
41
+ EXTERNAL_REDIRECT_MARKER,
42
+ } from "../redirect-origin.js";
43
+ import { carryOverRedirectHeaders } from "./helpers.js";
44
+
45
+ export function guardOutgoingRedirect(
46
+ response: Response,
47
+ requestOrigin: string,
48
+ basename: string | undefined,
49
+ ): Response {
50
+ // Only 3xx + Location responses (document-native redirects) are guarded.
51
+ if (!isRedirectResponse(response)) {
52
+ return response;
53
+ }
54
+
55
+ // The reserved marker is never a trust signal. Strip any value -- forged by a
56
+ // proxied upstream or otherwise -- so it can never reach the browser. Trust
57
+ // comes solely from the out-of-band brand below.
58
+ try {
59
+ response.headers.delete(EXTERNAL_REDIRECT_MARKER);
60
+ } catch {
61
+ // Some platform responses carry immutable headers; the header is inert on
62
+ // the browser, so a failed strip is harmless.
63
+ }
64
+
65
+ // isRedirectResponse guarantees a truthy Location.
66
+ const location = response.headers.get("Location")!;
67
+
68
+ // Explicit opt-in via redirect(url, { external: true }): allow an off-host
69
+ // target, but only an http(s) one. external waives the same-origin rule, not
70
+ // scheme safety -- a branded javascript:/data: target falls through to be
71
+ // neutralized so it can never become a scriptable navigation downstream.
72
+ if (isExternalRedirect(response)) {
73
+ if (resolveExternalRedirect(location, requestOrigin) !== null) {
74
+ return response;
75
+ }
76
+ } else if (resolveSameOriginRedirect(location, requestOrigin) !== null) {
77
+ return response;
78
+ }
79
+
80
+ // Cross-origin (or unsafe-scheme external): neutralize to a safe same-origin
81
+ // landing.
82
+ const safeTarget = basename && basename !== "/" ? basename : "/";
83
+ if (process.env.NODE_ENV !== "production") {
84
+ console.error(
85
+ `[rango] Blocked cross-origin redirect to "${location}"; sent to ` +
86
+ `"${safeTarget}" instead. To redirect off-host on purpose, use ` +
87
+ `redirect(url, { external: true }).`,
88
+ );
89
+ }
90
+
91
+ const blocked = new Response(null, {
92
+ status: response.status,
93
+ headers: { Location: safeTarget },
94
+ });
95
+ // Preserve cookies and any other headers (Set-Cookie, Server-Timing, ...);
96
+ // carryOverRedirectHeaders intentionally skips Location.
97
+ carryOverRedirectHeaders(response, blocked);
98
+ return blocked;
99
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Shared response-route cache serve.
3
+ *
4
+ * Owns the single response-cache contract — cache-scope resolution from the
5
+ * matched entry tree, condition eval, key resolution (route key() > store
6
+ * keyGenerator > default), tag resolution, pre-handler-callback timing, and the
7
+ * fresh-hit / SWR-revalidate / miss-write branches — for BOTH the production
8
+ * response-route handler (rsc/response-route-handler.ts) and the dispatch testing
9
+ * primitive (testing/dispatch.ts), so the two can never drift.
10
+ *
11
+ * Plugin-rsc hazard: cache-scope.ts pulls @vitejs/plugin-rsc (via segment-codec),
12
+ * which the non-Vite unit-test runner cannot resolve, and this module is on the
13
+ * testing barrel's EAGER graph (dispatch imports it). So `createCacheScope` and
14
+ * `resolveCacheTags` are NOT imported here at runtime — they are INJECTED by the
15
+ * caller (production imports them statically; dispatch lazy-imports them only once
16
+ * a response route matches). The only runtime imports here are plugin-rsc-free
17
+ * (helpers' isCacheableStatus/finalizeResponse, traverseBack); cache-scope is a
18
+ * type-only import (erased at build).
19
+ */
20
+
21
+ import type { CacheScope } from "../cache/cache-scope.js";
22
+ import type { PartialCacheOptions } from "../types.js";
23
+ import type { RequestContext } from "../server/request-context.js";
24
+ import type { SegmentCacheStore } from "../cache/types.js";
25
+ import type { EntryCacheConfig, EntryData } from "../server/context.js";
26
+ import { traverseBack } from "../router/pattern-matching.js";
27
+ import { isCacheableStatus, finalizeResponse } from "./helpers.js";
28
+ import { reportCacheError } from "../cache/cache-error.js";
29
+
30
+ /** Injected cache-scope builders (kept off this module's runtime import graph). */
31
+ export interface CacheScopeDeps {
32
+ createCacheScope: (
33
+ config: EntryCacheConfig | undefined,
34
+ parent?: CacheScope | null,
35
+ ) => CacheScope | null;
36
+ resolveCacheTags: (
37
+ config: PartialCacheOptions | false,
38
+ ctx: RequestContext | undefined,
39
+ ) => string[] | undefined;
40
+ }
41
+
42
+ export interface ServeResponseRouteWithCacheArgs {
43
+ reqCtx: RequestContext;
44
+ manifestEntry: EntryData;
45
+ responseType: string;
46
+ url: URL;
47
+ /** callHandler wrapped by route-level middleware — the unit the cache wraps. */
48
+ executeHandler: () => Promise<Response>;
49
+ deps: CacheScopeDeps;
50
+ }
51
+
52
+ /**
53
+ * Serve a response route through its cache, or return `undefined` when no cache
54
+ * applies (no scope, disabled, condition false, or store lacks get/putResponse)
55
+ * so the caller falls through to a plain `executeHandler()` run.
56
+ *
57
+ * Must run inside runWithRequestContext (reads the ambient request context via
58
+ * the helpers and reqCtx.waitUntil for background writes).
59
+ */
60
+ export async function serveResponseRouteWithCache(
61
+ args: ServeResponseRouteWithCacheArgs,
62
+ ): Promise<Response | undefined> {
63
+ const { reqCtx, manifestEntry, responseType, url, executeHandler, deps } =
64
+ args;
65
+
66
+ let cacheScope: CacheScope | null = null;
67
+ for (const entry of traverseBack(manifestEntry)) {
68
+ if (entry.cache) {
69
+ cacheScope = deps.createCacheScope(entry.cache, cacheScope);
70
+ }
71
+ }
72
+
73
+ if (!cacheScope?.enabled) return undefined;
74
+
75
+ // Evaluate condition — skip the response cache when condition returns false.
76
+ let conditionPassed = true;
77
+ if (cacheScope.config !== false && cacheScope.config.condition) {
78
+ try {
79
+ conditionPassed = !!cacheScope.config.condition(reqCtx);
80
+ } catch {
81
+ conditionPassed = false;
82
+ }
83
+ }
84
+
85
+ const store = cacheScope.getStore() ?? reqCtx._cacheStore;
86
+ if (!conditionPassed || !store?.getResponse || !store?.putResponse) {
87
+ return undefined;
88
+ }
89
+
90
+ // Build cache key with the response:{type}: prefix (avoids collision with
91
+ // segment keys); include host + url.search so query-driven and multi-host
92
+ // responses cache separately.
93
+ let cacheKey = `response:${responseType}:${url.host}${url.pathname}${url.search}`;
94
+
95
+ // Priority 1: route-level key() (full override). Priority 2: store-level
96
+ // keyGenerator (modifies the default key).
97
+ //
98
+ // A CONFIGURED key()/keyGenerator that THROWS must DEGRADE TO A MISS, not fall
99
+ // back to the broad default key. The default key
100
+ // `response:${type}:${host}${path}${search}` is intentionally broad; if the
101
+ // configured key encodes tenant/user/auth state, falling back to the broad key
102
+ // would cache PERSONALIZED output under it and serve it cross-user (cache
103
+ // poisoning). Mirrors the segment-cache behavior (cache-scope.ts lookupRoute):
104
+ // a throwing key degrades to a cache miss, never a collision onto the default
105
+ // slot. The no-key default path is left untouched (the broad key is correct
106
+ // when no key is configured).
107
+ let keyResolutionFailed = false;
108
+ if (cacheScope.config !== false && cacheScope.config.key) {
109
+ try {
110
+ const customKey = await cacheScope.config.key(reqCtx);
111
+ cacheKey = `response:${customKey}`;
112
+ } catch (error) {
113
+ keyResolutionFailed = true;
114
+ reportCacheError(
115
+ error,
116
+ "cache-read",
117
+ "[ResponseCache] Key resolution failed",
118
+ reqCtx,
119
+ );
120
+ }
121
+ } else if (store.keyGenerator) {
122
+ try {
123
+ cacheKey = await store.keyGenerator(reqCtx, cacheKey);
124
+ } catch (error) {
125
+ keyResolutionFailed = true;
126
+ reportCacheError(
127
+ error,
128
+ "cache-read",
129
+ "[ResponseCache] keyGenerator failed",
130
+ reqCtx,
131
+ );
132
+ }
133
+ }
134
+
135
+ // Degrade to a MISS: return undefined so the caller runs the route UNCACHED.
136
+ // This early-returns BEFORE _onResponseCallbacks is saved/cleared below, so the
137
+ // pre-handler onResponse callbacks are still intact for the uncached run.
138
+ if (keyResolutionFailed) {
139
+ return undefined;
140
+ }
141
+
142
+ // Resolve cache tags for this document entry (static or dynamic) while the
143
+ // request context is available, so the stored entry is tag-invalidatable.
144
+ const responseTags = deps.resolveCacheTags(cacheScope.config, reqCtx);
145
+
146
+ // Pre-handler callbacks (registered by app-level middleware before the cache
147
+ // block) are saved and the live array is cleared:
148
+ // createResponseWithMergedHeaders inside the handler eagerly drains whatever is
149
+ // in _onResponseCallbacks, so handler-registered callbacks bake into the cached
150
+ // artifact, while these pre-handler callbacks are applied once per serve on
151
+ // every path (hit + miss).
152
+ const savedCallbacks = reqCtx._onResponseCallbacks;
153
+ reqCtx._onResponseCallbacks = [];
154
+ const applyPreHandlerCallbacks = (response: Response): Response => {
155
+ let result = response;
156
+ for (const callback of savedCallbacks) {
157
+ result = callback(result) ?? result;
158
+ }
159
+ return result;
160
+ };
161
+
162
+ const putFresh = (
163
+ store2: SegmentCacheStore,
164
+ fresh: Response,
165
+ ): Promise<void> =>
166
+ store2.putResponse!(
167
+ cacheKey,
168
+ fresh.clone(),
169
+ cacheScope!.ttl,
170
+ cacheScope!.swr,
171
+ responseTags,
172
+ );
173
+
174
+ try {
175
+ const cached = await store.getResponse(cacheKey);
176
+ if (cached && isCacheableStatus(cached.response.status)) {
177
+ if (!cached.shouldRevalidate) {
178
+ return applyPreHandlerCallbacks(cached.response);
179
+ }
180
+ // Stale hit (SWR): return cached, revalidate in background.
181
+ reqCtx.waitUntil(async () => {
182
+ try {
183
+ const fresh = finalizeResponse(await executeHandler());
184
+ if (isCacheableStatus(fresh.status)) await putFresh(store, fresh);
185
+ } catch (error) {
186
+ reportCacheError(
187
+ error,
188
+ "stale-revalidation",
189
+ "[ResponseCache] background revalidation",
190
+ reqCtx,
191
+ );
192
+ }
193
+ });
194
+ return applyPreHandlerCallbacks(cached.response);
195
+ }
196
+ } catch (error) {
197
+ reportCacheError(
198
+ error,
199
+ "cache-read",
200
+ "[ResponseCache] Cache lookup failed",
201
+ reqCtx,
202
+ );
203
+ }
204
+
205
+ // Cache miss: execute the handler and cache the result.
206
+ const response = finalizeResponse(await executeHandler());
207
+ if (isCacheableStatus(response.status)) {
208
+ // Clone SYNCHRONOUSLY here, before returning. The original `response` is
209
+ // handed back to the middleware chain, where mergeResponse rebuilds it as
210
+ // `new Response(response.body, ...)`. Deferring the clone into the waitUntil
211
+ // callback (putFresh(response), which clones inside the async body) raced
212
+ // that rebuild: the background clone() and the foreground body read could
213
+ // interleave and throw "Response body object should not be disturbed or
214
+ // locked" (a flaky 500). Teeing now keeps the returned body independent of
215
+ // the cache write. The SWR path above is unaffected (its `fresh` is created
216
+ // inside the background callback and never returned to the caller).
217
+ const toCache = response.clone();
218
+ reqCtx.waitUntil(async () => {
219
+ try {
220
+ await store.putResponse!(
221
+ cacheKey,
222
+ toCache,
223
+ cacheScope!.ttl,
224
+ cacheScope!.swr,
225
+ responseTags,
226
+ );
227
+ } catch (error) {
228
+ reportCacheError(
229
+ error,
230
+ "cache-write",
231
+ "[ResponseCache] Cache write failed",
232
+ reqCtx,
233
+ );
234
+ }
235
+ });
236
+ }
237
+ return applyPreHandlerCallbacks(response);
238
+ }