@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
@@ -0,0 +1,701 @@
1
+ /**
2
+ * dispatch — in-process request -> Response for unit/integration tests,
3
+ * WITHOUT the Flight RSC runtime.
4
+ *
5
+ * dispatch runs the router's real matching and middleware execution so that
6
+ * redirects, 404s, response routes (path.json / path.text / path.html / ...),
7
+ * and middleware short-circuits behave exactly as in production. It deliberately
8
+ * does NOT render React Server Components: there is no Flight stream, no SSR,
9
+ * and no DOM. Hit an RSC (component) route and dispatch throws a clear error
10
+ * directing you to renderToFlightString/renderServerTree/renderHandler or an e2e test.
11
+ *
12
+ * What dispatch DOES support:
13
+ * - Trailing-slash and other findMatch() redirects -> 308 with Location
14
+ * - Unmatched paths -> 404 Response
15
+ * Both the 308 and the 404 are produced INSIDE the global middleware chain
16
+ * (mirroring production, where coreHandler runs wrapped by executeMiddleware),
17
+ * so a global auth middleware can 401/redirect them and middleware-set
18
+ * cookies/headers merge onto the 308/404 the way createResponseWithMergedHeaders
19
+ * merges them in production.
20
+ * - Response routes (non-RSC) -> serialized Response
21
+ * - json: JSON.stringify(result) (bare value) with application/json
22
+ * - text/html/xml/md: String(result) with the mapped MIME type
23
+ * - handler returning a Response: re-wrapped like
24
+ * handleResponseRoute (stub headers/cookies merged, Set-Cookie preserved,
25
+ * WebSocket upgrade passed through without reconstruction)
26
+ * - handler throwing an error: typed 500 / RouterError
27
+ * status, matching handleResponseRoute (RFC 9457 problem+json body with
28
+ * application/problem+json for json routes, text/plain message otherwise)
29
+ * - content-negotiated route: Vary: Accept appended
30
+ * - cached response route (cache({...})): getResponse/putResponse
31
+ * hit/SWR/tag write, resolved from the matched entry tree exactly as
32
+ * handleResponseRoute does. The write is scheduled via ctx.waitUntil
33
+ * (a microtask without an executionContext), so a HIT-asserting test must
34
+ * flush microtasks between the seeding dispatch and the asserting one.
35
+ * - Global middleware (router.use(...)) AND route-level middleware, with full
36
+ * next()/short-circuit/throw-Response/header+cookie-merge fidelity.
37
+ * - Partial (client-navigation) requests to a RESPONSE route (?_rsc_partial):
38
+ * global middleware runs first (so an auth gate can still 401/redirect),
39
+ * then — if it passes through — an X-RSC-Reload is returned. Route-level
40
+ * middleware is skipped on a partial, exactly as production skips it.
41
+ * - A middleware redirect (3xx + Location) on a partial/action request
42
+ * (?_rsc_partial / ?_rsc_action): converted to a 204 + X-RSC-Redirect via the
43
+ * real interceptRedirectForPartial, so fetch() does not auto-follow the 3xx —
44
+ * identical to production's no-location-state path.
45
+ * - The open-redirect guard (rsc/redirect-guard.ts) on full (browser-followed)
46
+ * redirects: a cross-origin Location is rewritten to the basename root unless
47
+ * redirect(url, { external: true }) opted out, mirroring production's single
48
+ * handler chokepoint. Soft partial/action redirects are 204 and pass through.
49
+ * - createRouter({ onError }) for CACHE / background-error degradation. dispatch
50
+ * wires the request context's _reportBackgroundError to the router's onError the
51
+ * same way the production RSC handler does, so a cache-read/cache-write/
52
+ * stale-revalidation failure on a cached response route (a throwing route
53
+ * cache({ key })/cache({ tags }), or a custom store whose keyGenerator/
54
+ * getResponse/putResponse throws) fires onError with phase "cache" and
55
+ * metadata.category, while the request still degrades-to-miss exactly as before.
56
+ * (A thrown response-route HANDLER error is the one onError path NOT covered —
57
+ * see "DOES NOT support" below.)
58
+ *
59
+ * What dispatch DOES NOT support (and why):
60
+ * - RSC component routes — rendering requires the Flight serializer + React
61
+ * server runtime, which is the boundary this primitive is defined to avoid.
62
+ * This includes partial requests that resolve to a component route.
63
+ * - Server actions (?_rsc_action) — RSC protocol concerns handled by
64
+ * router.fetch().
65
+ * - createRouter({ onError }) on a thrown response-route HANDLER error: the
66
+ * error is serialized into the same typed 500 / RouterError Response as
67
+ * production, but onError is NOT invoked for that path here. Cover handler-error
68
+ * onError side effects with an e2e test. (This is the unchanged boundary; cache
69
+ * and other background errors DO route through onError — see below.)
70
+ * - Location-state-carrying redirects on a partial/action request: production
71
+ * embeds a Flight payload (createRedirectFlightResponse) so the client can
72
+ * restore location state across the redirect. dispatch is RSC-free, so it
73
+ * cannot emit that Flight stream. It falls back to the no-state behavior — a
74
+ * 204 + X-RSC-Redirect via createSimpleRedirectResponse — dropping the
75
+ * embedded location state. The 204 status, the X-RSC-Redirect header, and the
76
+ * merged cookies/headers all match production; only the Flight-embedded
77
+ * location-state entries are absent. Cover location-state restoration across a
78
+ * partial redirect with an e2e test.
79
+ *
80
+ * dispatch reuses router.previewMatch(), which itself runs content negotiation
81
+ * and resolves route middleware from the matched entry tree, so dispatch's
82
+ * route-middleware collection is exactly the router's, not a re-implementation.
83
+ */
84
+
85
+ import {
86
+ createRequestContext,
87
+ runWithRequestContext,
88
+ setRequestContextParams,
89
+ } from "../server/request-context.js";
90
+ import { executeMiddleware, matchMiddleware } from "../router/middleware.js";
91
+ import type {
92
+ MiddlewareEntry,
93
+ MiddlewareFn,
94
+ } from "../router/middleware-types.js";
95
+ import {
96
+ createReverseFunction,
97
+ stripInternalParams,
98
+ } from "../router/handler-context.js";
99
+ import { NOCACHE_SYMBOL } from "../cache/taint.js";
100
+ import type { SegmentCacheStore } from "../cache/types.js";
101
+ import type { CacheProfile } from "../cache/profile-registry.js";
102
+ // cache-scope is loaded LAZILY inside the response-route cache path (below):
103
+ // its module graph pulls @vitejs/plugin-rsc/rsc (via segment-codec), which the
104
+ // non-Vite unit-test runner cannot resolve. A static import here would drag that
105
+ // onto the whole testing barrel's eager graph and break every consumer suite
106
+ // that imports `@rangojs/router/testing` without mocking plugin-rsc.
107
+ import type { EntryData } from "../server/context.js";
108
+ import { setRouterManifest } from "../route-map-builder.js";
109
+ import { RESPONSE_TYPE_MIME } from "../router/content-negotiation.js";
110
+ import { RouterError } from "../errors.js";
111
+ import { createProblemDetails } from "../rsc/response-error.js";
112
+ import {
113
+ createResponseWithMergedHeaders,
114
+ createSimpleRedirectResponse,
115
+ finalizeResponse,
116
+ interceptRedirectForPartial,
117
+ mergeStubHeadersAndFinalize,
118
+ } from "../rsc/helpers.js";
119
+ import { guardOutgoingRedirect } from "../rsc/redirect-guard.js";
120
+ import { stringifyJsonRouteResult } from "../rsc/json-route-result.js";
121
+ import {
122
+ EXTERNAL_REDIRECT_MARKER,
123
+ isExternalRedirect,
124
+ markExternalRedirect,
125
+ } from "../redirect-origin.js";
126
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
127
+ import { invokeOnError } from "../router/error-handling.js";
128
+ import type { OnErrorCallback } from "../types/error-types.js";
129
+ import type { Rango } from "../router/router-interfaces.js";
130
+
131
+ /**
132
+ * The internal subset of the router surface dispatch depends on. The public
133
+ * `Rango` router carries these members at runtime (they are declared on the
134
+ * internal interface), so dispatch accepts a public `Rango` and reads them
135
+ * through this shape — the consumer never needs a cast.
136
+ */
137
+ interface DispatchableRouter<TEnv> {
138
+ id?: string;
139
+ routerId?: string;
140
+ routeMap: Record<string, unknown>;
141
+ middleware: MiddlewareEntry<TEnv>[];
142
+ onError?: OnErrorCallback<TEnv>;
143
+ findMatch(pathname: string): {
144
+ redirectTo?: string;
145
+ routeKey?: string;
146
+ params?: Record<string, string>;
147
+ } | null;
148
+ previewMatch(
149
+ request: Request,
150
+ input?: { env?: TEnv },
151
+ ): Promise<{
152
+ routeMiddleware?: Array<{
153
+ handler: MiddlewareFn<TEnv>;
154
+ params: Record<string, string>;
155
+ }>;
156
+ responseType?: string;
157
+ handler?: Function;
158
+ params?: Record<string, string>;
159
+ routeKey?: string;
160
+ negotiated?: boolean;
161
+ manifestEntry?: EntryData;
162
+ } | null>;
163
+ basename?: string;
164
+ cache?:
165
+ | { enabled?: boolean; store?: SegmentCacheStore }
166
+ | ((
167
+ env: TEnv,
168
+ executionContext: unknown,
169
+ ) => { enabled?: boolean; store?: SegmentCacheStore });
170
+ cacheProfiles?: Record<string, CacheProfile>;
171
+ }
172
+
173
+ /**
174
+ * Options for dispatch.
175
+ */
176
+ export interface DispatchOptions<TEnv = any> {
177
+ /** The request to dispatch: a `Request`, or a URL string (absolute or path). */
178
+ request: Request | string;
179
+ /** Environment bindings forwarded to matching and middleware. */
180
+ env?: TEnv;
181
+ }
182
+
183
+ const DEFAULT_ORIGIN = "http://localhost/";
184
+
185
+ function toRequest(request: Request | string): Request {
186
+ if (request instanceof Request) return request;
187
+ return new Request(new URL(request, DEFAULT_ORIGIN));
188
+ }
189
+
190
+ /**
191
+ * Serialize a NON-Response response-route handler result, mirroring the
192
+ * router's handleResponseRoute() contract:
193
+ * - "json" serializes the value (bare) with application/json, rejecting a nested
194
+ * unresolved Promise via the shared stringifyJsonRouteResult guard,
195
+ * - text/html/xml/md stringify with the mapped MIME type.
196
+ *
197
+ * A handler-returned Response is NOT routed here — callHandler re-wraps it via
198
+ * rewrapHandlerResponse (mirroring handleResponseRoute's rewrapResponse) so the
199
+ * WebSocket-upgrade bypass and Set-Cookie-preserving header merge match
200
+ * production.
201
+ */
202
+ function serializeResponseRouteResult(
203
+ result: unknown,
204
+ responseType: string,
205
+ ): Response {
206
+ if (responseType === "json") {
207
+ // Serialize through the SAME guard production uses: a nested unresolved
208
+ // Promise (forgotten await) throws RESPONSE_NOT_SERIALIZABLE here, caught by
209
+ // callHandler's catch and mapped to the identical typed 500 production
210
+ // returns -- so a dispatch json test fails exactly where production would,
211
+ // instead of silently emitting {} and passing.
212
+ return new Response(stringifyJsonRouteResult(result), {
213
+ status: 200,
214
+ headers: { "content-type": "application/json;charset=utf-8" },
215
+ });
216
+ }
217
+
218
+ if (Object.hasOwn(RESPONSE_TYPE_MIME, responseType)) {
219
+ return new Response(String(result), {
220
+ status: 200,
221
+ headers: {
222
+ "content-type": `${RESPONSE_TYPE_MIME[responseType]};charset=utf-8`,
223
+ },
224
+ });
225
+ }
226
+
227
+ throw new Error(
228
+ `dispatch(): response route handler for "${responseType}" must return a ` +
229
+ `Response object, got ${typeof result}. Binary/streaming response types ` +
230
+ `(image, stream, any) must return a Response explicitly.`,
231
+ );
232
+ }
233
+
234
+ /**
235
+ * Serialize a thrown handler error into the same typed Response the router's
236
+ * handleResponseRoute() catch block produces:
237
+ * - "json" routes return an RFC 9457 problem+json body (application/problem+json),
238
+ * - all other types return a text/plain body (the RouterError message verbatim,
239
+ * the Error message in dev, else "Internal Server Error").
240
+ *
241
+ * `status` is the effective HTTP status resolved by the caller (RouterError.status
242
+ * or 500, overridden by ctx.setStatus()); it governs both the HTTP status and the
243
+ * problem body's `status`/`title` members. Reuses the production
244
+ * createProblemDetails so the error body is byte-identical rather than re-derived.
245
+ */
246
+ function serializeResponseRouteError(
247
+ error: unknown,
248
+ responseType: string,
249
+ status: number,
250
+ ): Response {
251
+ const isDev = process.env.NODE_ENV !== "production";
252
+
253
+ if (responseType === "json") {
254
+ return new Response(
255
+ JSON.stringify(createProblemDetails(error, status, isDev)),
256
+ {
257
+ status,
258
+ headers: { "content-type": "application/problem+json;charset=utf-8" },
259
+ },
260
+ );
261
+ }
262
+
263
+ const message =
264
+ error instanceof RouterError
265
+ ? error.message
266
+ : isDev && error instanceof Error
267
+ ? error.message
268
+ : "Internal Server Error";
269
+ return new Response(message, {
270
+ status,
271
+ headers: { "content-type": "text/plain;charset=utf-8" },
272
+ });
273
+ }
274
+
275
+ /**
276
+ * Re-wrap a handler-returned Response, byte-identical to handleResponseRoute's
277
+ * rewrapResponse:
278
+ * - A WebSocket upgrade (status 101 or a `webSocket` property) is returned via
279
+ * mergeStubHeadersAndFinalize WITHOUT reconstruction — the Response
280
+ * constructor rejects status 101, and an upgrade response's headers/socket
281
+ * must not be rebuilt.
282
+ * - Otherwise headers are copied into a fresh Headers (Set-Cookie appended to
283
+ * preserve duplicates, others set) and the Response is rebuilt through
284
+ * createResponseWithMergedHeaders so stub headers/cookies, the ctx.setStatus
285
+ * override, and onResponse callbacks merge exactly as in production. statusText
286
+ * is intentionally dropped (production does not carry it across the re-wrap).
287
+ *
288
+ * Must run inside runWithRequestContext (reads the ambient request context via
289
+ * the helpers), which callHandler guarantees.
290
+ */
291
+ function rewrapHandlerResponse(result: Response): Response {
292
+ if (isWebSocketUpgradeResponse(result)) {
293
+ return mergeStubHeadersAndFinalize(result);
294
+ }
295
+ const headers = new Headers();
296
+ result.headers.forEach((value, key) => {
297
+ // Mirror production: never copy the reserved external-redirect marker off a
298
+ // handler result (it is not a trust signal; the opt-in is the out-of-band
299
+ // brand transferred below).
300
+ if (key.toLowerCase() === EXTERNAL_REDIRECT_MARKER) return;
301
+ if (key.toLowerCase() === "set-cookie") {
302
+ headers.append(key, value);
303
+ } else {
304
+ headers.set(key, value);
305
+ }
306
+ });
307
+ const rewrapped = createResponseWithMergedHeaders(result.body, {
308
+ status: result.status,
309
+ headers,
310
+ });
311
+ // Mirror production's rewrapResponse: transfer the out-of-band external brand
312
+ // only from a genuinely branded result (a real redirect(url, { external:
313
+ // true })), never from a proxied upstream's forged header.
314
+ if (isExternalRedirect(result)) {
315
+ markExternalRedirect(rewrapped);
316
+ }
317
+ return rewrapped;
318
+ }
319
+
320
+ /**
321
+ * Run a request through the router in-process and return the Response.
322
+ *
323
+ * @example
324
+ * ```ts
325
+ * const router = createRouter<Env>({}).routes(urls(({ path }) => [
326
+ * path.json("/api/health", () => ({ ok: true }), { name: "health" }),
327
+ * ]));
328
+ *
329
+ * const res = await dispatch(router, { request: "/api/health" });
330
+ * expect(res.status).toBe(200);
331
+ * expect(await res.json()).toEqual({ ok: true });
332
+ * ```
333
+ */
334
+ export async function dispatch<TEnv = any>(
335
+ publicRouter: Rango<TEnv, any>,
336
+ opts: DispatchOptions<TEnv>,
337
+ ): Promise<Response> {
338
+ // The public Rango type intentionally hides the matching internals; read them
339
+ // through the dispatchable shape (present at runtime). Consumers pass their
340
+ // real router with no cast.
341
+ const router = publicRouter as unknown as DispatchableRouter<TEnv>;
342
+ const req = toRequest(opts.request);
343
+ const url = new URL(req.url);
344
+ const env = (opts.env ?? {}) as TEnv;
345
+
346
+ // Seed the per-router manifest so reverse() resolves during handler execution.
347
+ const routerId = router.id ?? router.routerId;
348
+ if (routerId) {
349
+ setRouterManifest(routerId, router.routeMap as Record<string, string>);
350
+ }
351
+
352
+ // findMatch carries trailing-slash/redirect targets and null on no match.
353
+ // previewMatch swallows redirects, so detect them here first.
354
+ const match = router.findMatch(url.pathname);
355
+ const redirectTo = match?.redirectTo;
356
+ const isUnmatched = !match;
357
+
358
+ // previewMatch resolves responseType, the response-route handler, and the
359
+ // route middleware from the matched entry tree (with content negotiation).
360
+ // Skip it for a redirect/unmatched path — there is no response route to
361
+ // resolve, and previewMatch would return null / a redirect marker anyway.
362
+ const preview =
363
+ redirectTo || isUnmatched ? null : await router.previewMatch(req, { env });
364
+
365
+ // A bare match with no responseType is an RSC route. The RSC-route throw is a
366
+ // hard boundary of this primitive (no Flight runtime), distinct from the
367
+ // 308/404 outcomes below, so it stays a pre-middleware guard.
368
+ const responseType = preview?.responseType;
369
+ const handler = preview?.handler;
370
+ const params = preview?.params ?? match?.params ?? {};
371
+ const routeKey = preview?.routeKey ?? match?.routeKey;
372
+
373
+ if (
374
+ !redirectTo &&
375
+ !isUnmatched &&
376
+ (!responseType || typeof handler !== "function")
377
+ ) {
378
+ throw new Error(
379
+ `dispatch() does not render RSC routes — the route matched at ` +
380
+ `"${url.pathname}" is a React Server Component route, not a response ` +
381
+ `route. Use renderHandler/renderServerTree/renderToFlightString or an ` +
382
+ `e2e test to exercise component rendering.`,
383
+ );
384
+ }
385
+
386
+ const variables: Record<string, unknown> = {};
387
+
388
+ // Resolve the router's cache store the way the production handler does, so a
389
+ // "use cache" inside a response-route handler reaches the request-scope
390
+ // (NOCACHE) detection below instead of bypassing on a missing store.
391
+ let cacheStore: SegmentCacheStore | undefined;
392
+ const cacheOption = router.cache;
393
+ if (cacheOption && !url.searchParams.has("__no_cache")) {
394
+ const cacheConfig =
395
+ typeof cacheOption === "function"
396
+ ? cacheOption(env, undefined)
397
+ : cacheOption;
398
+ if (cacheConfig.enabled !== false) cacheStore = cacheConfig.store;
399
+ }
400
+
401
+ const requestContext = createRequestContext<TEnv>({
402
+ env,
403
+ request: req,
404
+ url,
405
+ variables,
406
+ cacheStore,
407
+ cacheProfiles: router.cacheProfiles,
408
+ });
409
+ // Wire background error reporting so cache degradation (reportCacheError ->
410
+ // _reportBackgroundError) reaches the router's onError, mirroring the production
411
+ // RSC handler (rsc/handler.ts). Without this, dispatch could not observe onError.
412
+ requestContext._reportBackgroundError = (error, category) => {
413
+ if (error != null && typeof error === "object") {
414
+ if (requestContext._reportedErrors.has(error)) return;
415
+ requestContext._reportedErrors.add(error);
416
+ }
417
+ invokeOnError(
418
+ router.onError,
419
+ error,
420
+ "cache",
421
+ { request: req, url, metadata: { category } },
422
+ "RSC",
423
+ );
424
+ };
425
+ // Match production: the RSC handler stores the router's basename on the
426
+ // request context (handler.ts), and redirect() prefixes root-relative URLs
427
+ // with it. Mirror it so basename-redirect tests behave as they do in a real
428
+ // mounted app instead of always seeing no prefix.
429
+ requestContext._basename = router.basename;
430
+
431
+ // Match production's response-route reverse EXACTLY: the real handler builds
432
+ // it from the route map alone (response-route-handler.ts), with NO matched
433
+ // routeKey or params. Passing routeKey/params here would auto-fill params from
434
+ // the matched route, so ctx.reverse("name") could pass in a test while the
435
+ // real handler throws for the missing param.
436
+ const reverse = createReverseFunction(
437
+ router.routeMap as Record<string, string>,
438
+ ) as (
439
+ name: string,
440
+ p?: Record<string, string>,
441
+ search?: Record<string, unknown>,
442
+ ) => string;
443
+
444
+ const isPartial = url.searchParams.has("_rsc_partial");
445
+ const isAction = url.searchParams.has("_rsc_action");
446
+
447
+ return runWithRequestContext(requestContext, async () => {
448
+ // Set params before middleware/handler run, so global middleware sees
449
+ // ctx.params (production sets them during matching, before middleware).
450
+ // On a redirect/unmatched path there are no route params.
451
+ if (routeKey !== undefined) {
452
+ setRequestContextParams(params, routeKey);
453
+ } else {
454
+ requestContext.params = params;
455
+ }
456
+
457
+ // The response-route handler (with its own route middleware) lives inside
458
+ // coreHandler below, mirroring production where handleResponseRoute is
459
+ // nested inside coreHandler. Built lazily so a redirect/404 path never
460
+ // touches it.
461
+ const callResponseRoute = async (): Promise<Response> => {
462
+ // Match production: a partial (client-navigation) request to a response
463
+ // route is short-circuited to X-RSC-Reload (handleResponseRoute), BEFORE
464
+ // route-level middleware runs. Route-level middleware is skipped on a
465
+ // partial, exactly as production skips it.
466
+ const partialFinalHandler = async (): Promise<Response> =>
467
+ createResponseWithMergedHeaders(null, {
468
+ status: 200,
469
+ headers: {
470
+ "X-RSC-Reload": stripInternalParams(url).toString(),
471
+ "content-type": "text/x-component;charset=utf-8",
472
+ },
473
+ });
474
+
475
+ const cleanUrl = new URL(req.url);
476
+ for (const key of [...cleanUrl.searchParams.keys()]) {
477
+ if (key.startsWith("_rsc")) cleanUrl.searchParams.delete(key);
478
+ }
479
+
480
+ // Lightweight response-handler context mirroring handleResponseRoute.
481
+ const responseHandlerCtx = {
482
+ request: req,
483
+ params,
484
+ env,
485
+ searchParams: cleanUrl.searchParams,
486
+ url: cleanUrl,
487
+ originalUrl: requestContext.originalUrl,
488
+ pathname: url.pathname,
489
+ reverse,
490
+ get: requestContext.get,
491
+ header: (name: string, value: string) =>
492
+ requestContext.header(name, value),
493
+ waitUntil: requestContext.waitUntil.bind(requestContext),
494
+ executionContext: requestContext.executionContext,
495
+ _responseType: responseType,
496
+ };
497
+ // Brand as request-scoped so a "use cache" inside a response-route handler
498
+ // is detected as a request-scope violation here exactly as in production
499
+ // (response-route-handler.ts brands the same shape).
500
+ (responseHandlerCtx as Record<symbol, unknown>)[NOCACHE_SYMBOL] = true;
501
+
502
+ const callHandler = async (): Promise<Response> => {
503
+ let merged: Response;
504
+ try {
505
+ const result = await (handler as Function)(responseHandlerCtx);
506
+ if (result instanceof Response) {
507
+ // Handler returned a Response: mirror handleResponseRoute's
508
+ // rewrapResponse (WebSocket-upgrade bypass + Set-Cookie-preserving
509
+ // header rebuild, statusText dropped) rather than the generic
510
+ // createResponseWithMergedHeaders re-wrap below.
511
+ merged = rewrapHandlerResponse(result);
512
+ } else {
513
+ // Route the serialized (json/text/...) body through the SAME
514
+ // production finalizer the RSC handler uses, so ctx.onResponse()
515
+ // callbacks fire and stub headers/cookies + the ctx.setStatus
516
+ // override merge identically to production. Runs inside
517
+ // runWithRequestContext, so _getRequestContext() resolves here.
518
+ const serialized = serializeResponseRouteResult(
519
+ result,
520
+ responseType as string,
521
+ );
522
+ merged = createResponseWithMergedHeaders(serialized.body, {
523
+ status: serialized.status,
524
+ headers: serialized.headers,
525
+ });
526
+ }
527
+ } catch (error) {
528
+ // Mirror handleResponseRoute's catch: a genuine handler error becomes
529
+ // the router's typed 500 / RouterError-status Response (NOT a rejected
530
+ // promise). Middleware short-circuit via thrown Response is handled by
531
+ // executeMiddleware and never reaches here.
532
+ const derivedStatus =
533
+ error instanceof RouterError ? error.status : 500;
534
+ // Resolve the effective status the way createResponseWithMergedHeaders
535
+ // (below) will (ctx.res.status override) BEFORE building the problem
536
+ // body, so the body's status/title match the actual HTTP status when a
537
+ // handler called ctx.setStatus() before throwing — exactly as
538
+ // handleResponseRoute resolves it.
539
+ const status =
540
+ requestContext.res.status !== 200
541
+ ? requestContext.res.status
542
+ : derivedStatus;
543
+ const serialized = serializeResponseRouteError(
544
+ error,
545
+ responseType as string,
546
+ status,
547
+ );
548
+ merged = createResponseWithMergedHeaders(serialized.body, {
549
+ status: serialized.status,
550
+ headers: serialized.headers,
551
+ });
552
+ }
553
+
554
+ // Append Vary: Accept on content-negotiated responses, matching
555
+ // handleResponseRoute's callHandlerWithVary. Skipped on WebSocket
556
+ // upgrade responses (immutable headers, Vary meaningless for a 101).
557
+ if (preview?.negotiated && !isWebSocketUpgradeResponse(merged)) {
558
+ merged.headers.append("Vary", "Accept");
559
+ }
560
+
561
+ return merged;
562
+ };
563
+
564
+ // On a partial request the reload IS the terminal handler and route
565
+ // middleware is skipped; otherwise the response-route handler is wrapped
566
+ // by route-level middleware (production order: route middleware runs
567
+ // inside handleResponseRoute, after the global chain).
568
+ if (isPartial) {
569
+ return partialFinalHandler();
570
+ }
571
+
572
+ // executeHandler = callHandler wrapped by route-level middleware, exactly
573
+ // the unit the production response cache wraps (response-route-handler.ts).
574
+ const executeHandler = (): Promise<Response> => {
575
+ const routeMiddlewareEntries = (preview?.routeMiddleware ?? []).map(
576
+ (mw) => ({
577
+ entry: {
578
+ pattern: null,
579
+ regex: null,
580
+ paramNames: [],
581
+ handler: mw.handler,
582
+ } as MiddlewareEntry<TEnv>,
583
+ params: mw.params,
584
+ }),
585
+ );
586
+ if (routeMiddlewareEntries.length === 0) {
587
+ return callHandler();
588
+ }
589
+ return executeMiddleware<TEnv>(
590
+ routeMiddlewareEntries,
591
+ req,
592
+ env,
593
+ variables,
594
+ callHandler,
595
+ reverse,
596
+ );
597
+ };
598
+
599
+ // Response-route cache path: resolved through the SAME shared serve leaf
600
+ // (rsc/response-cache-serve.ts) production uses, so a cached
601
+ // path.json/path.text route hits/SWRs/writes tags through dispatch exactly
602
+ // as in production — and the two can never drift. Resolved from the matched
603
+ // entry tree (preview.manifestEntry), which previewMatch surfaces for
604
+ // response routes.
605
+ const manifestEntry = preview?.manifestEntry;
606
+ if (manifestEntry) {
607
+ // Lazy so the testing barrel's eager graph stays plugin-rsc-free (see the
608
+ // import note above): the leaf takes createCacheScope/resolveCacheTags as
609
+ // INJECTED deps so it never imports plugin-rsc; we hand it the lazily
610
+ // imported pair here, only once a response route actually matched.
611
+ const cacheScopeMod = await import("../cache/cache-scope.js");
612
+ const { serveResponseRouteWithCache } =
613
+ await import("../rsc/response-cache-serve.js");
614
+ // requestContext is RequestContext<TEnv>; the leaf is typed against the
615
+ // default-env RequestContext (it reads only env-agnostic config).
616
+ // Assignable in the router's own tsc but not when a consumer pins a
617
+ // concrete Env — cast to the leaf's param type.
618
+ const cached = await serveResponseRouteWithCache({
619
+ reqCtx: requestContext as Parameters<
620
+ typeof serveResponseRouteWithCache
621
+ >[0]["reqCtx"],
622
+ manifestEntry,
623
+ responseType: responseType as string,
624
+ url,
625
+ executeHandler,
626
+ deps: {
627
+ createCacheScope: cacheScopeMod.createCacheScope,
628
+ resolveCacheTags: cacheScopeMod.resolveCacheTags,
629
+ },
630
+ });
631
+ if (cached !== undefined) return cached;
632
+ }
633
+
634
+ return executeHandler().then(finalizeResponse);
635
+ };
636
+
637
+ // coreHandler is the single terminal the global middleware chain wraps,
638
+ // mirroring production's coreHandler (handler.ts): a trailing-slash/redirect
639
+ // 308, an unmatched-path 404, or the response route. Both the 308 and the
640
+ // 404 are produced via createResponseWithMergedHeaders so middleware-set
641
+ // cookies/headers merge onto them, identical to production's
642
+ // rsc-rendering.ts redirect path — and because they sit inside the chain, a
643
+ // global middleware that short-circuits (e.g. an auth 401) runs first and
644
+ // wins, never reaching the 308/404.
645
+ const coreHandler = async (): Promise<Response> => {
646
+ if (redirectTo) {
647
+ return createResponseWithMergedHeaders(null, {
648
+ status: 308,
649
+ headers: { Location: redirectTo + url.search },
650
+ });
651
+ }
652
+ if (isUnmatched) {
653
+ return createResponseWithMergedHeaders("Not Found", {
654
+ status: 404,
655
+ headers: { "content-type": "text/plain;charset=utf-8" },
656
+ });
657
+ }
658
+ return callResponseRoute();
659
+ };
660
+
661
+ // Global (pattern-matched) middleware wraps coreHandler, exactly as
662
+ // production wraps coreHandler with executeMiddleware (handler.ts).
663
+ const globalMatches = matchMiddleware(url.pathname, router.middleware);
664
+ const mwResponse =
665
+ globalMatches.length === 0
666
+ ? await coreHandler()
667
+ : await executeMiddleware<TEnv>(
668
+ globalMatches,
669
+ req,
670
+ env,
671
+ variables,
672
+ coreHandler,
673
+ reverse,
674
+ );
675
+
676
+ // Match production's global-chain exit (handler.ts): on a partial/action
677
+ // request a middleware 3xx redirect is converted to a Flight-safe response
678
+ // so fetch() does not auto-follow it; every path then drains onResponse
679
+ // callbacks via finalizeResponse. dispatch is RSC-free, so the
680
+ // createRedirectFlightResponse stand-in falls back to the no-state
681
+ // 204 + X-RSC-Redirect (see the location-state divergence in the header).
682
+ let finalResponse: Response;
683
+ if (isPartial || isAction) {
684
+ const intercepted = interceptRedirectForPartial(
685
+ mwResponse,
686
+ (redirectUrl) => createSimpleRedirectResponse(redirectUrl),
687
+ );
688
+ finalResponse = finalizeResponse(intercepted ?? mwResponse);
689
+ } else {
690
+ finalResponse = finalizeResponse(mwResponse);
691
+ }
692
+
693
+ // Mirror production's single open-redirect chokepoint (handler.ts): every
694
+ // browser-followed (3xx + Location) redirect is same-origin guarded before
695
+ // it leaves -- a cross-origin Location is rewritten to the basename root
696
+ // unless redirect(url, { external: true }) opted out. Soft partial/action
697
+ // redirects are 204 + X-RSC-Redirect and pass through untouched (the client
698
+ // validates them), so this is a no-op for them.
699
+ return guardOutgoingRedirect(finalResponse, url.origin, router.basename);
700
+ });
701
+ }