@rangojs/router 0.0.0-experimental.bd6e11bc → 0.0.0-experimental.bdaf10aa

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 (411) hide show
  1. package/AGENTS.md +8 -4
  2. package/README.md +296 -887
  3. package/dist/bin/rango.js +459 -91
  4. package/dist/testing/vitest.js +36 -2
  5. package/dist/vite/index.js +1708 -414
  6. package/package.json +35 -10
  7. package/skills/api-client/SKILL.md +211 -0
  8. package/skills/breadcrumbs/SKILL.md +82 -5
  9. package/skills/bundle-analysis/SKILL.md +2 -2
  10. package/skills/cache-guide/SKILL.md +14 -9
  11. package/skills/caching/SKILL.md +221 -12
  12. package/skills/catalog.json +271 -0
  13. package/skills/comparison/SKILL.md +50 -0
  14. package/skills/comparison/agents/openai.yaml +4 -0
  15. package/skills/comparison/references/framework-comparison.md +837 -0
  16. package/skills/composability/SKILL.md +83 -2
  17. package/skills/css/SKILL.md +76 -0
  18. package/skills/debug-manifest/SKILL.md +5 -3
  19. package/skills/defer-hydration/SKILL.md +235 -0
  20. package/skills/document-cache/SKILL.md +11 -3
  21. package/skills/fonts/SKILL.md +1 -1
  22. package/skills/handler-use/SKILL.md +9 -9
  23. package/skills/hooks/SKILL.md +73 -900
  24. package/skills/hooks/data.md +273 -0
  25. package/skills/hooks/handle-and-actions.md +103 -0
  26. package/skills/hooks/navigation.md +110 -0
  27. package/skills/hooks/outlets.md +41 -0
  28. package/skills/hooks/state.md +228 -0
  29. package/skills/hooks/urls.md +135 -0
  30. package/skills/host-router/SKILL.md +84 -7
  31. package/skills/i18n/SKILL.md +1 -1
  32. package/skills/intercept/SKILL.md +51 -17
  33. package/skills/layout/SKILL.md +38 -16
  34. package/skills/links/SKILL.md +1 -1
  35. package/skills/loader/SKILL.md +48 -20
  36. package/skills/middleware/SKILL.md +11 -5
  37. package/skills/migrate-nextjs/SKILL.md +203 -20
  38. package/skills/migrate-react-router/SKILL.md +59 -675
  39. package/skills/migrate-react-router/cloudflare-workers.md +129 -0
  40. package/skills/migrate-react-router/component-migration.md +196 -0
  41. package/skills/migrate-react-router/data-and-actions.md +225 -0
  42. package/skills/migrate-react-router/route-mapping.md +271 -0
  43. package/skills/mime-routes/SKILL.md +3 -3
  44. package/skills/observability/SKILL.md +70 -5
  45. package/skills/parallel/SKILL.md +32 -8
  46. package/skills/ppr/SKILL.md +622 -0
  47. package/skills/prerender/SKILL.md +59 -28
  48. package/skills/rango/SKILL.md +124 -50
  49. package/skills/response-routes/SKILL.md +78 -46
  50. package/skills/route/SKILL.md +85 -6
  51. package/skills/router-setup/SKILL.md +41 -6
  52. package/skills/scripts/SKILL.md +179 -0
  53. package/skills/server-actions/SKILL.md +28 -3
  54. package/skills/shell-manifest/SKILL.md +185 -0
  55. package/skills/streams-and-websockets/SKILL.md +1 -1
  56. package/skills/tailwind/SKILL.md +28 -4
  57. package/skills/testing/SKILL.md +68 -654
  58. package/skills/testing/bindings.md +103 -0
  59. package/skills/testing/cache-prerender.md +127 -0
  60. package/skills/testing/client-components.md +124 -0
  61. package/skills/testing/e2e-parity.md +125 -0
  62. package/skills/testing/flight.md +91 -0
  63. package/skills/testing/handles.md +131 -0
  64. package/skills/testing/loader.md +128 -0
  65. package/skills/testing/middleware.md +99 -0
  66. package/skills/testing/render-handler.md +122 -0
  67. package/skills/testing/response-routes.md +95 -0
  68. package/skills/testing/reverse-and-types.md +85 -0
  69. package/skills/testing/server-actions.md +107 -0
  70. package/skills/testing/server-tree.md +128 -0
  71. package/skills/testing/setup.md +123 -0
  72. package/skills/theme/SKILL.md +1 -1
  73. package/skills/typesafety/SKILL.md +45 -918
  74. package/skills/typesafety/env-and-bindings.md +254 -0
  75. package/skills/typesafety/generated-files-and-cli.md +335 -0
  76. package/skills/typesafety/params-and-search.md +153 -0
  77. package/skills/typesafety/route-types.md +209 -0
  78. package/skills/use-cache/SKILL.md +47 -17
  79. package/skills/vercel/SKILL.md +128 -0
  80. package/skills/view-transitions/SKILL.md +44 -1
  81. package/src/__augment-tests__/augmented.check.ts +2 -3
  82. package/src/__internal.ts +0 -65
  83. package/src/browser/action-coordinator.ts +1 -1
  84. package/src/browser/action-fence.ts +47 -0
  85. package/src/browser/app-shell.ts +14 -27
  86. package/src/browser/connection-warmup.ts +134 -0
  87. package/src/browser/cookie-name.ts +140 -0
  88. package/src/browser/event-controller.ts +178 -100
  89. package/src/browser/invalidate-client-cache.ts +52 -0
  90. package/src/browser/logging.ts +28 -0
  91. package/src/browser/merge-segment-loaders.ts +6 -4
  92. package/src/browser/navigation-bridge.ts +81 -68
  93. package/src/browser/navigation-client.ts +115 -70
  94. package/src/browser/navigation-store-handle.ts +38 -0
  95. package/src/browser/navigation-store.ts +153 -88
  96. package/src/browser/navigation-transaction.ts +0 -32
  97. package/src/browser/network-error-handler.ts +34 -7
  98. package/src/browser/partial-update.ts +157 -144
  99. package/src/browser/prefetch/cache.ts +148 -81
  100. package/src/browser/prefetch/fetch.ts +231 -51
  101. package/src/browser/prefetch/queue.ts +25 -7
  102. package/src/browser/rango-state.ts +157 -115
  103. package/src/browser/react/Link.tsx +40 -7
  104. package/src/browser/react/NavigationProvider.tsx +140 -99
  105. package/src/browser/react/ScrollRestoration.tsx +10 -6
  106. package/src/browser/react/filter-segment-order.ts +17 -2
  107. package/src/browser/react/index.ts +0 -51
  108. package/src/browser/react/location-state-shared.ts +14 -15
  109. package/src/browser/react/location-state.ts +0 -1
  110. package/src/browser/react/use-action.ts +6 -15
  111. package/src/browser/react/use-handle.ts +0 -5
  112. package/src/browser/react/use-href.tsx +8 -1
  113. package/src/browser/react/use-link-status.ts +33 -8
  114. package/src/browser/react/use-navigation.ts +10 -5
  115. package/src/browser/react/use-params.ts +0 -2
  116. package/src/browser/react/use-router.ts +6 -4
  117. package/src/browser/react/use-search-params.ts +0 -5
  118. package/src/browser/react/use-segments.ts +0 -13
  119. package/src/browser/response-adapter.ts +74 -8
  120. package/src/browser/rsc-router.tsx +97 -22
  121. package/src/browser/scroll-restoration.ts +15 -8
  122. package/src/browser/segment-reconciler.ts +31 -21
  123. package/src/browser/server-action-bridge.ts +216 -38
  124. package/src/browser/types.ts +94 -22
  125. package/src/browser/validate-redirect-origin.ts +43 -16
  126. package/src/build/generate-manifest.ts +155 -131
  127. package/src/build/generate-route-types.ts +1 -1
  128. package/src/build/index.ts +11 -5
  129. package/src/build/prefix-tree-utils.ts +123 -0
  130. package/src/build/route-trie.ts +152 -22
  131. package/src/build/route-types/ast-route-extraction.ts +15 -8
  132. package/src/build/route-types/codegen.ts +12 -1
  133. package/src/build/route-types/include-resolution.ts +455 -61
  134. package/src/build/route-types/param-extraction.ts +6 -3
  135. package/src/build/route-types/per-module-writer.ts +15 -2
  136. package/src/build/route-types/router-processing.ts +77 -41
  137. package/src/build/route-types/source-scan.ts +105 -7
  138. package/src/build/runtime-discovery.ts +4 -1
  139. package/src/cache/cache-error.ts +104 -0
  140. package/src/cache/cache-key-utils.ts +58 -13
  141. package/src/cache/cache-policy.ts +108 -34
  142. package/src/cache/cache-runtime.ts +454 -101
  143. package/src/cache/cache-scope.ts +159 -54
  144. package/src/cache/cache-tag.ts +149 -0
  145. package/src/cache/cf/cf-base64.ts +33 -0
  146. package/src/cache/cf/cf-cache-constants.ts +127 -0
  147. package/src/cache/cf/cf-cache-store.ts +2170 -377
  148. package/src/cache/cf/cf-cache-types.ts +349 -0
  149. package/src/cache/cf/cf-kv-utils.ts +46 -0
  150. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  151. package/src/cache/cf/index.ts +6 -16
  152. package/src/cache/document-cache.ts +126 -41
  153. package/src/cache/handle-snapshot.ts +70 -0
  154. package/src/cache/index.ts +23 -20
  155. package/src/cache/memory-segment-store.ts +243 -37
  156. package/src/cache/profile-registry.ts +46 -31
  157. package/src/cache/read-through-swr.ts +56 -12
  158. package/src/cache/segment-codec.ts +13 -21
  159. package/src/cache/shell-snapshot.ts +417 -0
  160. package/src/cache/tag-invalidation.ts +230 -0
  161. package/src/cache/types.ts +194 -99
  162. package/src/cache/vercel/index.ts +11 -0
  163. package/src/cache/vercel/vercel-cache-store.ts +1132 -0
  164. package/src/client.rsc.tsx +39 -22
  165. package/src/client.tsx +28 -58
  166. package/src/cloudflare/index.ts +11 -0
  167. package/src/cloudflare/tracing.ts +108 -0
  168. package/src/component-utils.ts +19 -0
  169. package/src/components/DefaultDocument.tsx +8 -2
  170. package/src/context-var.ts +13 -1
  171. package/src/decode-loader-results.ts +18 -2
  172. package/src/defer.ts +185 -0
  173. package/src/deps/ssr.ts +0 -1
  174. package/src/encode-kv.ts +49 -0
  175. package/src/errors.ts +0 -3
  176. package/src/escape-script.ts +52 -0
  177. package/src/handle.ts +57 -40
  178. package/src/handles/MetaTags.tsx +24 -53
  179. package/src/handles/Scripts.tsx +183 -0
  180. package/src/handles/breadcrumbs.ts +35 -8
  181. package/src/handles/deferred-resolution.ts +127 -0
  182. package/src/handles/is-thenable.ts +18 -0
  183. package/src/handles/meta.ts +14 -40
  184. package/src/handles/script.ts +244 -0
  185. package/src/host/cookie-handler.ts +9 -60
  186. package/src/host/errors.ts +13 -22
  187. package/src/host/index.ts +7 -0
  188. package/src/host/pattern-matcher.ts +23 -52
  189. package/src/host/router.ts +1 -65
  190. package/src/host/testing.ts +40 -27
  191. package/src/host/types.ts +6 -2
  192. package/src/href-client.ts +7 -12
  193. package/src/index.rsc.ts +88 -8
  194. package/src/index.ts +90 -16
  195. package/src/internal-debug.ts +11 -10
  196. package/src/loader.rsc.ts +19 -9
  197. package/src/loader.ts +12 -4
  198. package/src/outlet-provider.tsx +1 -5
  199. package/src/prerender/param-hash.ts +16 -16
  200. package/src/prerender/store.ts +32 -37
  201. package/src/prerender.ts +75 -7
  202. package/src/redirect-origin.ts +114 -0
  203. package/src/regex-escape.ts +8 -0
  204. package/src/render-error-thrower.tsx +20 -0
  205. package/src/response-utils.ts +25 -0
  206. package/src/root-error-boundary.tsx +1 -19
  207. package/src/route-content-wrapper.tsx +13 -49
  208. package/src/route-definition/dsl-helpers.ts +60 -53
  209. package/src/route-definition/helper-factories.ts +0 -2
  210. package/src/route-definition/helpers-types.ts +46 -46
  211. package/src/route-definition/index.ts +1 -2
  212. package/src/route-definition/redirect.ts +44 -11
  213. package/src/route-definition/resolve-handler-use.ts +6 -1
  214. package/src/route-definition/use-item-types.ts +3 -6
  215. package/src/route-map-builder.ts +41 -20
  216. package/src/route-types.ts +0 -5
  217. package/src/router/content-negotiation.ts +58 -23
  218. package/src/router/error-handling.ts +44 -17
  219. package/src/router/find-match.ts +129 -30
  220. package/src/router/handler-context.ts +6 -1
  221. package/src/router/instrument.ts +355 -0
  222. package/src/router/intercept-resolution.ts +35 -2
  223. package/src/router/lazy-includes.ts +79 -56
  224. package/src/router/loader-resolution.ts +151 -73
  225. package/src/router/logging.ts +0 -6
  226. package/src/router/manifest.ts +74 -40
  227. package/src/router/match-api.ts +76 -52
  228. package/src/router/match-context.ts +0 -22
  229. package/src/router/match-handlers.ts +181 -178
  230. package/src/router/match-middleware/background-revalidation.ts +40 -24
  231. package/src/router/match-middleware/cache-lookup.ts +115 -194
  232. package/src/router/match-middleware/cache-store.ts +61 -50
  233. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  234. package/src/router/match-middleware/segment-resolution.ts +0 -22
  235. package/src/router/match-pipelines.ts +1 -42
  236. package/src/router/match-result.ts +36 -67
  237. package/src/router/metrics.ts +0 -34
  238. package/src/router/middleware-types.ts +0 -116
  239. package/src/router/middleware.ts +231 -120
  240. package/src/router/navigation-snapshot.ts +7 -56
  241. package/src/router/params-util.ts +23 -0
  242. package/src/router/parse-pattern.ts +115 -0
  243. package/src/router/pattern-matching.ts +99 -152
  244. package/src/router/prefetch-cache-ttl.ts +51 -0
  245. package/src/router/prefetch-limits.ts +37 -0
  246. package/src/router/prerender-match.ts +111 -66
  247. package/src/router/preview-match.ts +3 -1
  248. package/src/router/request-classification.ts +47 -42
  249. package/src/router/revalidation.ts +75 -81
  250. package/src/router/route-snapshot.ts +14 -3
  251. package/src/router/router-context.ts +6 -29
  252. package/src/router/router-interfaces.ts +70 -8
  253. package/src/router/router-options.ts +126 -4
  254. package/src/router/segment-resolution/fresh.ts +104 -80
  255. package/src/router/segment-resolution/helpers.ts +86 -6
  256. package/src/router/segment-resolution/loader-cache.ts +155 -39
  257. package/src/router/segment-resolution/loader-mask.ts +60 -0
  258. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  259. package/src/router/segment-resolution/mask-nested.ts +83 -0
  260. package/src/router/segment-resolution/revalidation.ts +215 -304
  261. package/src/router/segment-resolution/static-store.ts +19 -5
  262. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  263. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  264. package/src/router/segment-resolution.ts +5 -1
  265. package/src/router/segment-wrappers.ts +6 -5
  266. package/src/router/state-cookie-name.ts +33 -0
  267. package/src/router/substitute-pattern-params.ts +54 -35
  268. package/src/router/telemetry-otel.ts +160 -200
  269. package/src/router/telemetry.ts +9 -23
  270. package/src/router/timeout.ts +0 -20
  271. package/src/router/tracing.ts +215 -0
  272. package/src/router/trie-matching.ts +171 -64
  273. package/src/router/types.ts +1 -63
  274. package/src/router/url-params.ts +13 -5
  275. package/src/router.ts +119 -48
  276. package/src/rsc/full-payload.ts +70 -0
  277. package/src/rsc/handler-context.ts +1 -0
  278. package/src/rsc/handler.ts +267 -152
  279. package/src/rsc/helpers.ts +78 -4
  280. package/src/rsc/index.ts +1 -4
  281. package/src/rsc/json-route-result.ts +38 -0
  282. package/src/rsc/loader-fetch.ts +114 -38
  283. package/src/rsc/manifest-init.ts +29 -42
  284. package/src/rsc/nonce.ts +10 -1
  285. package/src/rsc/origin-guard.ts +11 -15
  286. package/src/rsc/progressive-enhancement.ts +120 -13
  287. package/src/rsc/redirect-guard.ts +100 -0
  288. package/src/rsc/response-cache-serve.ts +238 -0
  289. package/src/rsc/response-error.ts +79 -12
  290. package/src/rsc/response-route-handler.ts +58 -141
  291. package/src/rsc/rsc-rendering.ts +492 -49
  292. package/src/rsc/runtime-warnings.ts +14 -0
  293. package/src/rsc/server-action.ts +268 -82
  294. package/src/rsc/shell-capture.ts +1190 -0
  295. package/src/rsc/shell-serve.ts +181 -0
  296. package/src/rsc/transition-gate.ts +89 -0
  297. package/src/rsc/types.ts +45 -3
  298. package/src/runtime-env.ts +18 -0
  299. package/src/search-params.ts +31 -26
  300. package/src/segment-loader-promise.ts +49 -4
  301. package/src/segment-system.tsx +260 -95
  302. package/src/server/context.ts +99 -9
  303. package/src/server/cookie-parse.ts +32 -0
  304. package/src/server/cookie-store.ts +125 -2
  305. package/src/server/handle-store.ts +21 -38
  306. package/src/server/loader-registry.ts +33 -42
  307. package/src/server/request-context.ts +379 -138
  308. package/src/ssr/index.tsx +491 -182
  309. package/src/ssr/inject-rsc-eager.ts +167 -0
  310. package/src/ssr/ssr-root.tsx +228 -0
  311. package/src/static-handler.ts +10 -13
  312. package/src/testing/cache-status.ts +44 -48
  313. package/src/testing/collect-handle.ts +14 -31
  314. package/src/testing/dispatch.ts +533 -160
  315. package/src/testing/e2e/fixture.ts +45 -11
  316. package/src/testing/e2e/index.ts +1 -22
  317. package/src/testing/e2e/matchers.ts +0 -16
  318. package/src/testing/e2e/parity.ts +85 -4
  319. package/src/testing/e2e/server.ts +12 -0
  320. package/src/testing/flight-matchers.ts +7 -14
  321. package/src/testing/flight-normalize.ts +11 -0
  322. package/src/testing/flight-runtime.d.ts +36 -0
  323. package/src/testing/flight-tree.ts +682 -0
  324. package/src/testing/flight.entry.ts +30 -0
  325. package/src/testing/flight.ts +145 -70
  326. package/src/testing/generated-routes.ts +26 -50
  327. package/src/testing/index.ts +18 -19
  328. package/src/testing/internal/context.ts +184 -68
  329. package/src/testing/internal/flight-client-globals.ts +30 -0
  330. package/src/testing/internal/seed-vars.ts +54 -0
  331. package/src/testing/render-handler.ts +357 -0
  332. package/src/testing/render-route.tsx +134 -115
  333. package/src/testing/run-loader.ts +140 -51
  334. package/src/testing/run-middleware.ts +59 -33
  335. package/src/testing/run-transition-when.ts +164 -0
  336. package/src/testing/vitest-stubs/cloudflare-email.ts +1 -1
  337. package/src/testing/vitest-stubs/cloudflare-workers.ts +1 -1
  338. package/src/testing/vitest.ts +138 -16
  339. package/src/theme/ThemeProvider.tsx +56 -84
  340. package/src/theme/ThemeScript.tsx +7 -9
  341. package/src/theme/constants.ts +52 -13
  342. package/src/theme/index.ts +0 -7
  343. package/src/theme/theme-context.ts +1 -5
  344. package/src/theme/theme-script.ts +22 -21
  345. package/src/theme/use-theme.ts +0 -3
  346. package/src/types/boundaries.ts +0 -35
  347. package/src/types/cache-types.ts +13 -4
  348. package/src/types/error-types.ts +30 -90
  349. package/src/types/global-namespace.ts +15 -15
  350. package/src/types/handler-context.ts +45 -15
  351. package/src/types/index.ts +2 -10
  352. package/src/types/loader-types.ts +6 -3
  353. package/src/types/request-scope.ts +8 -22
  354. package/src/types/route-config.ts +20 -52
  355. package/src/types/route-entry.ts +0 -6
  356. package/src/types/segments.ts +100 -13
  357. package/src/urls/include-helper.ts +10 -12
  358. package/src/urls/include-provider.ts +71 -0
  359. package/src/urls/index.ts +2 -8
  360. package/src/urls/path-helper-types.ts +52 -14
  361. package/src/urls/path-helper.ts +5 -54
  362. package/src/urls/pattern-types.ts +36 -0
  363. package/src/urls/type-extraction.ts +76 -42
  364. package/src/urls/urls-function.ts +0 -14
  365. package/src/use-loader.tsx +0 -186
  366. package/src/vercel/index.ts +11 -0
  367. package/src/vercel/tracing.ts +88 -0
  368. package/src/vite/discovery/bundle-postprocess.ts +2 -1
  369. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  370. package/src/vite/discovery/discover-routers.ts +34 -43
  371. package/src/vite/discovery/discovery-errors.ts +61 -0
  372. package/src/vite/discovery/prerender-collection.ts +33 -46
  373. package/src/vite/discovery/state.ts +12 -1
  374. package/src/vite/discovery/virtual-module-codegen.ts +1 -11
  375. package/src/vite/index.ts +9 -0
  376. package/src/vite/inject-client-debug.ts +88 -0
  377. package/src/vite/plugin-types.ts +143 -10
  378. package/src/vite/plugins/cjs-to-esm.ts +8 -12
  379. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  380. package/src/vite/plugins/client-ref-hashing.ts +0 -10
  381. package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
  382. package/src/vite/plugins/expose-action-id.ts +2 -73
  383. package/src/vite/plugins/expose-id-utils.ts +85 -56
  384. package/src/vite/plugins/expose-ids/export-analysis.ts +30 -43
  385. package/src/vite/plugins/expose-ids/handler-transform.ts +5 -31
  386. package/src/vite/plugins/expose-ids/loader-transform.ts +12 -20
  387. package/src/vite/plugins/expose-ids/router-transform.ts +98 -26
  388. package/src/vite/plugins/expose-internal-ids.ts +10 -1
  389. package/src/vite/plugins/performance-tracks.ts +0 -3
  390. package/src/vite/plugins/refresh-cmd.ts +1 -1
  391. package/src/vite/plugins/use-cache-transform.ts +21 -46
  392. package/src/vite/plugins/vercel-output.ts +384 -0
  393. package/src/vite/plugins/version-injector.ts +22 -27
  394. package/src/vite/plugins/version-plugin.ts +6 -66
  395. package/src/vite/plugins/virtual-entries.ts +137 -26
  396. package/src/vite/rango.ts +146 -135
  397. package/src/vite/router-discovery.ts +189 -48
  398. package/src/vite/utils/ast-handler-extract.ts +11 -20
  399. package/src/vite/utils/bundle-analysis.ts +6 -13
  400. package/src/vite/utils/client-chunks.ts +0 -6
  401. package/src/vite/utils/directive-prologue.ts +40 -0
  402. package/src/vite/utils/forward-user-plugins.ts +0 -22
  403. package/src/vite/utils/manifest-utils.ts +4 -75
  404. package/src/vite/utils/package-resolution.ts +1 -73
  405. package/src/vite/utils/prerender-utils.ts +71 -44
  406. package/src/vite/utils/shared-utils.ts +55 -37
  407. package/src/browser/react/use-client-cache.ts +0 -58
  408. package/src/browser/shallow.ts +0 -40
  409. package/src/handles/index.ts +0 -7
  410. package/src/network-error-thrower.tsx +0 -23
  411. package/src/router/middleware-cookies.ts +0 -55
@@ -7,25 +7,62 @@
7
7
  * and middleware short-circuits behave exactly as in production. It deliberately
8
8
  * does NOT render React Server Components: there is no Flight stream, no SSR,
9
9
  * and no DOM. Hit an RSC (component) route and dispatch throws a clear error
10
- * directing you to renderServer()/renderToFlightString or an e2e test.
10
+ * directing you to renderToFlightString/renderServerTree/renderHandler or an e2e test.
11
11
  *
12
12
  * What dispatch DOES support:
13
13
  * - Trailing-slash and other findMatch() redirects -> 308 with Location
14
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.
15
20
  * - Response routes (non-RSC) -> serialized Response
16
- * - json: JSON.stringify({ data }) with application/json
21
+ * - json: JSON.stringify(result) (bare value) with application/json
17
22
  * - text/html/xml/md: String(result) with the mapped MIME type
18
- * - handler returning a Response: passed through
23
+ * - handler returning a Response: re-wrapped like
24
+ * handleResponseRoute (stub headers/cookies merged, Set-Cookie preserved,
25
+ * WebSocket upgrade passed through without reconstruction)
19
26
  * - handler throwing an error: typed 500 / RouterError
20
- * status, matching handleResponseRoute (JSON error envelope for json
21
- * routes, text/plain message otherwise)
27
+ * status, matching handleResponseRoute (RFC 9457 problem+json body with
28
+ * application/problem+json for json routes, text/plain message otherwise)
22
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.
23
35
  * - Global middleware (router.use(...)) AND route-level middleware, with full
24
36
  * next()/short-circuit/throw-Response/header+cookie-merge fidelity.
25
37
  * - Partial (client-navigation) requests to a RESPONSE route (?_rsc_partial):
26
38
  * global middleware runs first (so an auth gate can still 401/redirect),
27
39
  * then — if it passes through — an X-RSC-Reload is returned. Route-level
28
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
+ * - createRouter({ telemetry }) match-transaction lifecycle: request.start opens
59
+ * the transaction before the global middleware chain, request.end closes it
60
+ * after finalizeResponse (segmentCount 0 / cacheHit false — dispatch renders no
61
+ * RSC segments and holds no match-cache state), and a thrown non-Response error
62
+ * emits request.error with phase "routing". All three carry the same requestId
63
+ * (getRequestId). Emission is gated entirely on a configured sink; with none,
64
+ * dispatch does zero new work and stays byte-identical for existing callers.
65
+ * Lets a consumer unit-test their sink wiring in-process instead of only at e2e.
29
66
  *
30
67
  * What dispatch DOES NOT support (and why):
31
68
  * - RSC component routes — rendering requires the Flight serializer + React
@@ -33,10 +70,27 @@
33
70
  * This includes partial requests that resolve to a component route.
34
71
  * - Server actions (?_rsc_action) — RSC protocol concerns handled by
35
72
  * router.fetch().
36
- * - ctx.onError() callbacks on a thrown response-route handler error: the
73
+ * - createRouter({ onError }) on a thrown response-route HANDLER error: the
37
74
  * error is serialized into the same typed 500 / RouterError Response as
38
- * production, but registered onError handlers are NOT invoked here. Cover
39
- * onError side effects with an e2e test.
75
+ * production, but onError is NOT invoked for that path here. Cover handler-error
76
+ * onError side effects with an e2e test. (This is the unchanged boundary; cache
77
+ * and other background errors DO route through onError — see below.)
78
+ * - Location-state-carrying redirects on a partial/action request: production
79
+ * embeds a Flight payload (createRedirectFlightResponse) so the client can
80
+ * restore location state across the redirect. dispatch is RSC-free, so it
81
+ * cannot emit that Flight stream. It falls back to the no-state behavior — a
82
+ * 204 + X-RSC-Redirect via createSimpleRedirectResponse — dropping the
83
+ * embedded location state. The 204 status, the X-RSC-Redirect header, and the
84
+ * merged cookies/headers all match production; only the Flight-embedded
85
+ * location-state entries are absent. Cover location-state restoration across a
86
+ * partial redirect with an e2e test.
87
+ * - Telemetry cache.decision / loader.* / handler.error events: these fire from
88
+ * the real match()/matchPartial() + RSC render + loader pipeline (match-
89
+ * handlers.ts, loader-resolution.ts, segment-resolution), none of which
90
+ * dispatch runs. Synthesizing them here would be faking (the events would not
91
+ * reflect a real cache lookup or loader run), so dispatch emits only the
92
+ * request.start/end/error lifecycle above; cover cache/loader telemetry with an
93
+ * e2e test driving a real RSC request.
40
94
  *
41
95
  * dispatch reuses router.previewMatch(), which itself runs content negotiation
42
96
  * and resolves route middleware from the matched entry tree, so dispatch's
@@ -60,13 +114,36 @@ import {
60
114
  import { NOCACHE_SYMBOL } from "../cache/taint.js";
61
115
  import type { SegmentCacheStore } from "../cache/types.js";
62
116
  import type { CacheProfile } from "../cache/profile-registry.js";
117
+ // cache-scope is loaded LAZILY inside the response-route cache path (below):
118
+ // its module graph pulls @vitejs/plugin-rsc/rsc (via segment-codec), which the
119
+ // non-Vite unit-test runner cannot resolve. A static import here would drag that
120
+ // onto the whole testing barrel's eager graph and break every consumer suite
121
+ // that imports `@rangojs/router/testing` without mocking plugin-rsc.
122
+ import type { EntryData } from "../server/context.js";
63
123
  import { setRouterManifest } from "../route-map-builder.js";
64
124
  import { RESPONSE_TYPE_MIME } from "../router/content-negotiation.js";
65
125
  import { RouterError } from "../errors.js";
66
- import { createResponseErrorPayload } from "../rsc/response-error.js";
67
- import { createResponseWithMergedHeaders } from "../rsc/helpers.js";
126
+ import { createProblemDetails } from "../rsc/response-error.js";
127
+ import {
128
+ createResponseWithMergedHeaders,
129
+ createSimpleRedirectResponse,
130
+ finalizeResponse,
131
+ interceptRedirectForPartial,
132
+ mergeStubHeadersAndFinalize,
133
+ } from "../rsc/helpers.js";
134
+ import { guardOutgoingRedirect } from "../rsc/redirect-guard.js";
135
+ import { stringifyJsonRouteResult } from "../rsc/json-route-result.js";
136
+ import {
137
+ EXTERNAL_REDIRECT_MARKER,
138
+ isExternalRedirect,
139
+ markExternalRedirect,
140
+ } from "../redirect-origin.js";
68
141
  import { isWebSocketUpgradeResponse } from "../response-utils.js";
142
+ import { invokeOnError } from "../router/error-handling.js";
143
+ import type { OnErrorCallback } from "../types/error-types.js";
69
144
  import type { Rango } from "../router/router-interfaces.js";
145
+ import { getRequestId, resolveSink, safeEmit } from "../router/telemetry.js";
146
+ import type { TelemetrySink } from "../router/telemetry.js";
70
147
 
71
148
  /**
72
149
  * The internal subset of the router surface dispatch depends on. The public
@@ -79,11 +156,19 @@ interface DispatchableRouter<TEnv> {
79
156
  routerId?: string;
80
157
  routeMap: Record<string, unknown>;
81
158
  middleware: MiddlewareEntry<TEnv>[];
82
- findMatch(pathname: string): {
159
+ onError?: OnErrorCallback<TEnv>;
160
+ /**
161
+ * Optional telemetry sink from createRouter({ telemetry }) (RangoInternal
162
+ * field). dispatch emits the match-transaction lifecycle events onto it so a
163
+ * consumer can unit-test their sink wiring in-process; undefined keeps dispatch
164
+ * byte-identical for every existing caller.
165
+ */
166
+ telemetry?: TelemetrySink;
167
+ findMatch(pathname: string): Promise<{
83
168
  redirectTo?: string;
84
169
  routeKey?: string;
85
170
  params?: Record<string, string>;
86
- } | null;
171
+ } | null>;
87
172
  previewMatch(
88
173
  request: Request,
89
174
  input?: { env?: TEnv },
@@ -97,6 +182,7 @@ interface DispatchableRouter<TEnv> {
97
182
  params?: Record<string, string>;
98
183
  routeKey?: string;
99
184
  negotiated?: boolean;
185
+ manifestEntry?: EntryData;
100
186
  } | null>;
101
187
  basename?: string;
102
188
  cache?:
@@ -112,6 +198,8 @@ interface DispatchableRouter<TEnv> {
112
198
  * Options for dispatch.
113
199
  */
114
200
  export interface DispatchOptions<TEnv = any> {
201
+ /** The request to dispatch: a `Request`, or a URL string (absolute or path). */
202
+ request: Request | string;
115
203
  /** Environment bindings forwarded to matching and middleware. */
116
204
  env?: TEnv;
117
205
  }
@@ -124,20 +212,28 @@ function toRequest(request: Request | string): Request {
124
212
  }
125
213
 
126
214
  /**
127
- * Serialize a response-route handler result, mirroring the router's
128
- * handleResponseRoute() contract:
129
- * - a returned Response is passed through unchanged,
130
- * - "json" wraps the value as JSON.stringify({ data }) with application/json,
215
+ * Serialize a NON-Response response-route handler result, mirroring the
216
+ * router's handleResponseRoute() contract:
217
+ * - "json" serializes the value (bare) with application/json, rejecting a nested
218
+ * unresolved Promise via the shared stringifyJsonRouteResult guard,
131
219
  * - text/html/xml/md stringify with the mapped MIME type.
220
+ *
221
+ * A handler-returned Response is NOT routed here — callHandler re-wraps it via
222
+ * rewrapHandlerResponse (mirroring handleResponseRoute's rewrapResponse) so the
223
+ * WebSocket-upgrade bypass and Set-Cookie-preserving header merge match
224
+ * production.
132
225
  */
133
226
  function serializeResponseRouteResult(
134
227
  result: unknown,
135
228
  responseType: string,
136
229
  ): Response {
137
- if (result instanceof Response) return result;
138
-
139
230
  if (responseType === "json") {
140
- return new Response(JSON.stringify({ data: result }), {
231
+ // Serialize through the SAME guard production uses: a nested unresolved
232
+ // Promise (forgotten await) throws RESPONSE_NOT_SERIALIZABLE here, caught by
233
+ // callHandler's catch and mapped to the identical typed 500 production
234
+ // returns -- so a dispatch json test fails exactly where production would,
235
+ // instead of silently emitting {} and passing.
236
+ return new Response(stringifyJsonRouteResult(result), {
141
237
  status: 200,
142
238
  headers: { "content-type": "application/json;charset=utf-8" },
143
239
  });
@@ -162,27 +258,28 @@ function serializeResponseRouteResult(
162
258
  /**
163
259
  * Serialize a thrown handler error into the same typed Response the router's
164
260
  * handleResponseRoute() catch block produces:
165
- * - status is the RouterError.status, else 500,
166
- * - "json" routes return { error: <createResponseErrorPayload> } as JSON,
261
+ * - "json" routes return an RFC 9457 problem+json body (application/problem+json),
167
262
  * - all other types return a text/plain body (the RouterError message verbatim,
168
263
  * the Error message in dev, else "Internal Server Error").
169
264
  *
170
- * Reuses the production createResponseErrorPayload so the JSON error envelope
171
- * is byte-identical rather than re-derived.
265
+ * `status` is the effective HTTP status resolved by the caller (RouterError.status
266
+ * or 500, overridden by ctx.setStatus()); it governs both the HTTP status and the
267
+ * problem body's `status`/`title` members. Reuses the production
268
+ * createProblemDetails so the error body is byte-identical rather than re-derived.
172
269
  */
173
270
  function serializeResponseRouteError(
174
271
  error: unknown,
175
272
  responseType: string,
273
+ status: number,
176
274
  ): Response {
177
275
  const isDev = process.env.NODE_ENV !== "production";
178
- const status = error instanceof RouterError ? error.status : 500;
179
276
 
180
277
  if (responseType === "json") {
181
278
  return new Response(
182
- JSON.stringify({ error: createResponseErrorPayload(error, isDev) }),
279
+ JSON.stringify(createProblemDetails(error, status, isDev)),
183
280
  {
184
281
  status,
185
- headers: { "content-type": "application/json;charset=utf-8" },
282
+ headers: { "content-type": "application/problem+json;charset=utf-8" },
186
283
  },
187
284
  );
188
285
  }
@@ -199,6 +296,51 @@ function serializeResponseRouteError(
199
296
  });
200
297
  }
201
298
 
299
+ /**
300
+ * Re-wrap a handler-returned Response, byte-identical to handleResponseRoute's
301
+ * rewrapResponse:
302
+ * - A WebSocket upgrade (status 101 or a `webSocket` property) is returned via
303
+ * mergeStubHeadersAndFinalize WITHOUT reconstruction — the Response
304
+ * constructor rejects status 101, and an upgrade response's headers/socket
305
+ * must not be rebuilt.
306
+ * - Otherwise headers are copied into a fresh Headers (Set-Cookie appended to
307
+ * preserve duplicates, others set) and the Response is rebuilt through
308
+ * createResponseWithMergedHeaders so stub headers/cookies, the ctx.setStatus
309
+ * override, and onResponse callbacks merge exactly as in production. statusText
310
+ * is intentionally dropped (production does not carry it across the re-wrap).
311
+ *
312
+ * Must run inside runWithRequestContext (reads the ambient request context via
313
+ * the helpers), which callHandler guarantees.
314
+ */
315
+ function rewrapHandlerResponse(result: Response): Response {
316
+ if (isWebSocketUpgradeResponse(result)) {
317
+ return mergeStubHeadersAndFinalize(result);
318
+ }
319
+ const headers = new Headers();
320
+ result.headers.forEach((value, key) => {
321
+ // Mirror production: never copy the reserved external-redirect marker off a
322
+ // handler result (it is not a trust signal; the opt-in is the out-of-band
323
+ // brand transferred below).
324
+ if (key.toLowerCase() === EXTERNAL_REDIRECT_MARKER) return;
325
+ if (key.toLowerCase() === "set-cookie") {
326
+ headers.append(key, value);
327
+ } else {
328
+ headers.set(key, value);
329
+ }
330
+ });
331
+ const rewrapped = createResponseWithMergedHeaders(result.body, {
332
+ status: result.status,
333
+ headers,
334
+ });
335
+ // Mirror production's rewrapResponse: transfer the out-of-band external brand
336
+ // only from a genuinely branded result (a real redirect(url, { external:
337
+ // true })), never from a proxied upstream's forged header.
338
+ if (isExternalRedirect(result)) {
339
+ markExternalRedirect(rewrapped);
340
+ }
341
+ return rewrapped;
342
+ }
343
+
202
344
  /**
203
345
  * Run a request through the router in-process and return the Response.
204
346
  *
@@ -208,21 +350,20 @@ function serializeResponseRouteError(
208
350
  * path.json("/api/health", () => ({ ok: true }), { name: "health" }),
209
351
  * ]));
210
352
  *
211
- * const res = await dispatch(router, "/api/health");
353
+ * const res = await dispatch(router, { request: "/api/health" });
212
354
  * expect(res.status).toBe(200);
213
- * expect(await res.json()).toEqual({ data: { ok: true } });
355
+ * expect(await res.json()).toEqual({ ok: true });
214
356
  * ```
215
357
  */
216
358
  export async function dispatch<TEnv = any>(
217
359
  publicRouter: Rango<TEnv, any>,
218
- request: Request | string,
219
- opts: DispatchOptions<TEnv> = {},
360
+ opts: DispatchOptions<TEnv>,
220
361
  ): Promise<Response> {
221
362
  // The public Rango type intentionally hides the matching internals; read them
222
363
  // through the dispatchable shape (present at runtime). Consumers pass their
223
364
  // real router with no cast.
224
365
  const router = publicRouter as unknown as DispatchableRouter<TEnv>;
225
- const req = toRequest(request);
366
+ const req = toRequest(opts.request);
226
367
  const url = new URL(req.url);
227
368
  const env = (opts.env ?? {}) as TEnv;
228
369
 
@@ -234,39 +375,35 @@ export async function dispatch<TEnv = any>(
234
375
 
235
376
  // findMatch carries trailing-slash/redirect targets and null on no match.
236
377
  // previewMatch swallows redirects, so detect them here first.
237
- const match = router.findMatch(url.pathname);
238
-
239
- if (match?.redirectTo) {
240
- return new Response(null, {
241
- status: 308,
242
- headers: { Location: match.redirectTo + url.search },
243
- });
244
- }
245
-
246
- if (!match) {
247
- return new Response("Not Found", {
248
- status: 404,
249
- headers: { "content-type": "text/plain;charset=utf-8" },
250
- });
251
- }
378
+ const match = await router.findMatch(url.pathname);
379
+ const redirectTo = match?.redirectTo;
380
+ const isUnmatched = !match;
252
381
 
253
382
  // previewMatch resolves responseType, the response-route handler, and the
254
383
  // route middleware from the matched entry tree (with content negotiation).
255
- const preview = await router.previewMatch(req, { env });
256
-
257
- // No preview (e.g. resolved to a redirect inside previewMatch) — fall back to
258
- // the findMatch result. A bare match with no responseType is an RSC route.
384
+ // Skip it for a redirect/unmatched path — there is no response route to
385
+ // resolve, and previewMatch would return null / a redirect marker anyway.
386
+ const preview =
387
+ redirectTo || isUnmatched ? null : await router.previewMatch(req, { env });
388
+
389
+ // A bare match with no responseType is an RSC route. The RSC-route throw is a
390
+ // hard boundary of this primitive (no Flight runtime), distinct from the
391
+ // 308/404 outcomes below, so it stays a pre-middleware guard.
259
392
  const responseType = preview?.responseType;
260
393
  const handler = preview?.handler;
261
- const params = preview?.params ?? match.params ?? {};
262
- const routeKey = preview?.routeKey ?? match.routeKey;
263
-
264
- if (!responseType || typeof handler !== "function") {
394
+ const params = preview?.params ?? match?.params ?? {};
395
+ const routeKey = preview?.routeKey ?? match?.routeKey;
396
+
397
+ if (
398
+ !redirectTo &&
399
+ !isUnmatched &&
400
+ (!responseType || typeof handler !== "function")
401
+ ) {
265
402
  throw new Error(
266
403
  `dispatch() does not render RSC routes — the route matched at ` +
267
404
  `"${url.pathname}" is a React Server Component route, not a response ` +
268
- `route. Use renderServer()/renderToFlightString or an e2e test to ` +
269
- `exercise component rendering.`,
405
+ `route. Use renderHandler/renderServerTree/renderToFlightString or an ` +
406
+ `e2e test to exercise component rendering.`,
270
407
  );
271
408
  }
272
409
 
@@ -293,6 +430,22 @@ export async function dispatch<TEnv = any>(
293
430
  cacheStore,
294
431
  cacheProfiles: router.cacheProfiles,
295
432
  });
433
+ // Wire background error reporting so cache degradation (reportCacheError ->
434
+ // _reportBackgroundError) reaches the router's onError, mirroring the production
435
+ // RSC handler (rsc/handler.ts). Without this, dispatch could not observe onError.
436
+ requestContext._reportBackgroundError = (error, category) => {
437
+ if (error != null && typeof error === "object") {
438
+ if (requestContext._reportedErrors.has(error)) return;
439
+ requestContext._reportedErrors.add(error);
440
+ }
441
+ invokeOnError(
442
+ router.onError,
443
+ error,
444
+ "cache",
445
+ { request: req, url, metadata: { category } },
446
+ "RSC",
447
+ );
448
+ };
296
449
  // Match production: the RSC handler stores the router's basename on the
297
450
  // request context (handler.ts), and redirect() prefixes root-relative URLs
298
451
  // with it. Mirror it so basename-redirect tests behave as they do in a real
@@ -312,129 +465,349 @@ export async function dispatch<TEnv = any>(
312
465
  search?: Record<string, unknown>,
313
466
  ) => string;
314
467
 
468
+ const isPartial = url.searchParams.has("_rsc_partial");
469
+ const isAction = url.searchParams.has("_rsc_action");
470
+
471
+ // Telemetry: mirror the router's match-transaction lifecycle onto the
472
+ // configured sink so a consumer can unit-test createRouter({ telemetry })
473
+ // wiring in-process (the dogfood gap the RSC-free dispatch left — see
474
+ // tests/cloudflare-basic/test/cache-status.test.ts). Every emit is gated on
475
+ // `sink` truthiness: with no sink configured dispatch does zero new work and
476
+ // stays byte-identical for existing callers. Only request.start/end/error are
477
+ // reachable here — cache.decision and loader.* originate in the real match()/
478
+ // matchPartial() + RSC render pipeline dispatch deliberately does not run
479
+ // (module header), so fabricating them would violate the no-fake rule.
480
+ const sink = router.telemetry;
481
+ const telemetryRequestId = sink ? getRequestId(req) : undefined;
482
+ const telemetryStart = sink ? performance.now() : 0;
483
+
315
484
  return runWithRequestContext(requestContext, async () => {
316
485
  // Set params before middleware/handler run, so global middleware sees
317
486
  // ctx.params (production sets them during matching, before middleware).
487
+ // On a redirect/unmatched path there are no route params.
318
488
  if (routeKey !== undefined) {
319
489
  setRequestContextParams(params, routeKey);
320
490
  } else {
321
491
  requestContext.params = params;
322
492
  }
323
493
 
324
- // Match production: a partial (client-navigation) request to a response
325
- // route is short-circuited to X-RSC-Reload, but ONLY after GLOBAL
326
- // (app-level) middleware has run. In production the partial check lives
327
- // inside coreHandler (handler.ts wraps it with executeMiddleware) and,
328
- // within handleResponseRoute, precedes the route-level middleware
329
- // (response-route-handler.ts). So global middleware (e.g. an auth gate)
330
- // can still 401/redirect a partial request; only when it calls next()
331
- // through does the reload get emitted. Route-level middleware is skipped
332
- // on a partial, exactly as production skips it.
333
- const isPartial = url.searchParams.has("_rsc_partial");
334
- const partialFinalHandler = async (): Promise<Response> =>
335
- createResponseWithMergedHeaders(null, {
336
- status: 200,
337
- headers: {
338
- "X-RSC-Reload": stripInternalParams(url).toString(),
339
- "content-type": "text/x-component;charset=utf-8",
340
- },
341
- });
494
+ // The response-route handler (with its own route middleware) lives inside
495
+ // coreHandler below, mirroring production where handleResponseRoute is
496
+ // nested inside coreHandler. Built lazily so a redirect/404 path never
497
+ // touches it.
498
+ const callResponseRoute = async (): Promise<Response> => {
499
+ // Match production: a partial (client-navigation) request to a response
500
+ // route is short-circuited to X-RSC-Reload (handleResponseRoute), BEFORE
501
+ // route-level middleware runs. Route-level middleware is skipped on a
502
+ // partial, exactly as production skips it.
503
+ const partialFinalHandler = async (): Promise<Response> =>
504
+ createResponseWithMergedHeaders(null, {
505
+ status: 200,
506
+ headers: {
507
+ "X-RSC-Reload": stripInternalParams(url).toString(),
508
+ "content-type": "text/x-component;charset=utf-8",
509
+ },
510
+ });
511
+
512
+ const cleanUrl = new URL(req.url);
513
+ for (const key of [...cleanUrl.searchParams.keys()]) {
514
+ if (key.startsWith("_rsc")) cleanUrl.searchParams.delete(key);
515
+ }
342
516
 
343
- const cleanUrl = new URL(req.url);
344
- for (const key of [...cleanUrl.searchParams.keys()]) {
345
- if (key.startsWith("_rsc")) cleanUrl.searchParams.delete(key);
346
- }
517
+ // Lightweight response-handler context mirroring handleResponseRoute.
518
+ const responseHandlerCtx = {
519
+ request: req,
520
+ params,
521
+ env,
522
+ searchParams: cleanUrl.searchParams,
523
+ url: cleanUrl,
524
+ originalUrl: requestContext.originalUrl,
525
+ pathname: url.pathname,
526
+ reverse,
527
+ get: requestContext.get,
528
+ header: (name: string, value: string) =>
529
+ requestContext.header(name, value),
530
+ waitUntil: requestContext.waitUntil.bind(requestContext),
531
+ executionContext: requestContext.executionContext,
532
+ _responseType: responseType,
533
+ };
534
+ // Brand as request-scoped so a "use cache" inside a response-route handler
535
+ // is detected as a request-scope violation here exactly as in production
536
+ // (response-route-handler.ts brands the same shape).
537
+ (responseHandlerCtx as Record<symbol, unknown>)[NOCACHE_SYMBOL] = true;
538
+
539
+ const callHandler = async (): Promise<Response> => {
540
+ let merged: Response;
541
+ try {
542
+ const result = await (handler as Function)(responseHandlerCtx);
543
+ if (result instanceof Response) {
544
+ // Handler returned a Response: mirror handleResponseRoute's
545
+ // rewrapResponse (WebSocket-upgrade bypass + Set-Cookie-preserving
546
+ // header rebuild, statusText dropped) rather than the generic
547
+ // createResponseWithMergedHeaders re-wrap below.
548
+ merged = rewrapHandlerResponse(result);
549
+ } else {
550
+ // Route the serialized (json/text/...) body through the SAME
551
+ // production finalizer the RSC handler uses, so ctx.onResponse()
552
+ // callbacks fire and stub headers/cookies + the ctx.setStatus
553
+ // override merge identically to production. Runs inside
554
+ // runWithRequestContext, so _getRequestContext() resolves here.
555
+ const serialized = serializeResponseRouteResult(
556
+ result,
557
+ responseType as string,
558
+ );
559
+ merged = createResponseWithMergedHeaders(serialized.body, {
560
+ status: serialized.status,
561
+ headers: serialized.headers,
562
+ });
563
+ }
564
+ } catch (error) {
565
+ // Mirror handleResponseRoute's catch: a genuine handler error becomes
566
+ // the router's typed 500 / RouterError-status Response (NOT a rejected
567
+ // promise). Middleware short-circuit via thrown Response is handled by
568
+ // executeMiddleware and never reaches here.
569
+ const derivedStatus =
570
+ error instanceof RouterError ? error.status : 500;
571
+ // Resolve the effective status the way createResponseWithMergedHeaders
572
+ // (below) will (ctx.res.status override) BEFORE building the problem
573
+ // body, so the body's status/title match the actual HTTP status when a
574
+ // handler called ctx.setStatus() before throwing — exactly as
575
+ // handleResponseRoute resolves it.
576
+ const status =
577
+ requestContext.res.status !== 200
578
+ ? requestContext.res.status
579
+ : derivedStatus;
580
+ const serialized = serializeResponseRouteError(
581
+ error,
582
+ responseType as string,
583
+ status,
584
+ );
585
+ merged = createResponseWithMergedHeaders(serialized.body, {
586
+ status: serialized.status,
587
+ headers: serialized.headers,
588
+ });
589
+ }
590
+
591
+ // Append Vary: Accept on content-negotiated responses, matching
592
+ // handleResponseRoute's callHandlerWithVary. Skipped on WebSocket
593
+ // upgrade responses (immutable headers, Vary meaningless for a 101).
594
+ if (preview?.negotiated && !isWebSocketUpgradeResponse(merged)) {
595
+ merged.headers.append("Vary", "Accept");
596
+ }
597
+
598
+ return merged;
599
+ };
600
+
601
+ // On a partial request the reload IS the terminal handler and route
602
+ // middleware is skipped; otherwise the response-route handler is wrapped
603
+ // by route-level middleware (production order: route middleware runs
604
+ // inside handleResponseRoute, after the global chain).
605
+ if (isPartial) {
606
+ return partialFinalHandler();
607
+ }
347
608
 
348
- // Lightweight response-handler context mirroring handleResponseRoute.
349
- const responseHandlerCtx = {
350
- request: req,
351
- params,
352
- env,
353
- searchParams: cleanUrl.searchParams,
354
- url: cleanUrl,
355
- originalUrl: requestContext.originalUrl,
356
- pathname: url.pathname,
357
- reverse,
358
- get: requestContext.get,
359
- header: (name: string, value: string) =>
360
- requestContext.header(name, value),
361
- waitUntil: requestContext.waitUntil.bind(requestContext),
362
- executionContext: requestContext.executionContext,
363
- _responseType: responseType,
609
+ // executeHandler = callHandler wrapped by route-level middleware, exactly
610
+ // the unit the production response cache wraps (response-route-handler.ts).
611
+ const executeHandler = (): Promise<Response> => {
612
+ const routeMiddlewareEntries = (preview?.routeMiddleware ?? []).map(
613
+ (mw) => ({
614
+ entry: {
615
+ pattern: null,
616
+ regex: null,
617
+ paramNames: [],
618
+ handler: mw.handler,
619
+ } as MiddlewareEntry<TEnv>,
620
+ params: mw.params,
621
+ }),
622
+ );
623
+ if (routeMiddlewareEntries.length === 0) {
624
+ return callHandler();
625
+ }
626
+ return executeMiddleware<TEnv>(
627
+ routeMiddlewareEntries,
628
+ req,
629
+ env,
630
+ variables,
631
+ callHandler,
632
+ reverse,
633
+ );
634
+ };
635
+
636
+ // Response-route cache path: resolved through the SAME shared serve leaf
637
+ // (rsc/response-cache-serve.ts) production uses, so a cached
638
+ // path.json/path.text route hits/SWRs/writes tags through dispatch exactly
639
+ // as in production — and the two can never drift. Resolved from the matched
640
+ // entry tree (preview.manifestEntry), which previewMatch surfaces for
641
+ // response routes.
642
+ const manifestEntry = preview?.manifestEntry;
643
+ if (manifestEntry) {
644
+ // Lazy so the testing barrel's eager graph stays plugin-rsc-free (see the
645
+ // import note above): the leaf takes createCacheScope/resolveCacheTags as
646
+ // INJECTED deps so it never imports plugin-rsc; we hand it the lazily
647
+ // imported pair here, only once a response route actually matched.
648
+ const cacheScopeMod = await import("../cache/cache-scope.js");
649
+ const { serveResponseRouteWithCache } =
650
+ await import("../rsc/response-cache-serve.js");
651
+ // requestContext is RequestContext<TEnv>; the leaf is typed against the
652
+ // default-env RequestContext (it reads only env-agnostic config).
653
+ // Assignable in the router's own tsc but not when a consumer pins a
654
+ // concrete Env — cast to the leaf's param type.
655
+ const cached = await serveResponseRouteWithCache({
656
+ reqCtx: requestContext as Parameters<
657
+ typeof serveResponseRouteWithCache
658
+ >[0]["reqCtx"],
659
+ manifestEntry,
660
+ responseType: responseType as string,
661
+ url,
662
+ executeHandler,
663
+ deps: {
664
+ createCacheScope: cacheScopeMod.createCacheScope,
665
+ resolveCacheTags: cacheScopeMod.resolveCacheTags,
666
+ },
667
+ });
668
+ if (cached !== undefined) return cached;
669
+ }
670
+
671
+ return executeHandler().then(finalizeResponse);
364
672
  };
365
- // Brand as request-scoped so a "use cache" inside a response-route handler
366
- // is detected as a request-scope violation here exactly as in production
367
- // (response-route-handler.ts brands the same shape).
368
- (responseHandlerCtx as Record<symbol, unknown>)[NOCACHE_SYMBOL] = true;
369
-
370
- const callHandler = async (): Promise<Response> => {
371
- let serialized: Response;
372
- try {
373
- const result = await (handler as Function)(responseHandlerCtx);
374
- serialized = serializeResponseRouteResult(result, responseType);
375
- } catch (error) {
376
- // Mirror handleResponseRoute's catch: a genuine handler error becomes
377
- // the router's typed 500 / RouterError-status Response (NOT a rejected
378
- // promise). Middleware short-circuit via thrown Response is handled by
379
- // executeMiddleware and never reaches here.
380
- serialized = serializeResponseRouteError(error, responseType);
673
+
674
+ // coreHandler is the single terminal the global middleware chain wraps,
675
+ // mirroring production's coreHandler (handler.ts): a trailing-slash/redirect
676
+ // 308, an unmatched-path 404, or the response route. Both the 308 and the
677
+ // 404 are produced via createResponseWithMergedHeaders so middleware-set
678
+ // cookies/headers merge onto them, identical to production's
679
+ // rsc-rendering.ts redirect path — and because they sit inside the chain, a
680
+ // global middleware that short-circuits (e.g. an auth 401) runs first and
681
+ // wins, never reaching the 308/404.
682
+ const coreHandler = async (): Promise<Response> => {
683
+ if (redirectTo) {
684
+ return createResponseWithMergedHeaders(null, {
685
+ status: 308,
686
+ headers: { Location: redirectTo + url.search },
687
+ });
688
+ }
689
+ if (isUnmatched) {
690
+ return createResponseWithMergedHeaders("Not Found", {
691
+ status: 404,
692
+ headers: { "content-type": "text/plain;charset=utf-8" },
693
+ });
381
694
  }
695
+ return callResponseRoute();
696
+ };
382
697
 
383
- // Route through the SAME production finalizer the RSC handler uses, so
384
- // ctx.onResponse() callbacks fire and stub headers/cookies + ctx.setStatus
385
- // merge identically to production (handleResponseRoute also goes through
386
- // createResponseWithMergedHeaders). Runs inside runWithRequestContext, so
387
- // _getRequestContext() resolves to this request's context.
388
- const merged = createResponseWithMergedHeaders(serialized.body, {
389
- status: serialized.status,
390
- statusText: serialized.statusText,
391
- headers: serialized.headers,
698
+ // request.start opens the match transaction, mirroring match-handlers.ts.
699
+ // transaction is always "match" (dispatch has no matchPartial split);
700
+ // isPartial carries the ?_rsc_partial signal the same way production does.
701
+ if (sink) {
702
+ safeEmit(resolveSink(sink), {
703
+ type: "request.start",
704
+ timestamp: telemetryStart,
705
+ requestId: telemetryRequestId,
706
+ method: req.method,
707
+ pathname: url.pathname,
708
+ transaction: "match",
709
+ isPartial,
392
710
  });
711
+ }
393
712
 
394
- // Append Vary: Accept on content-negotiated responses, matching
395
- // handleResponseRoute's callHandlerWithVary. Skipped on WebSocket upgrade
396
- // responses (immutable headers, Vary meaningless for a 101).
397
- if (preview?.negotiated && !isWebSocketUpgradeResponse(merged)) {
398
- merged.headers.append("Vary", "Accept");
713
+ try {
714
+ // Global (pattern-matched) middleware wraps coreHandler, exactly as
715
+ // production wraps coreHandler with executeMiddleware (handler.ts).
716
+ const globalMatches = matchMiddleware(url.pathname, router.middleware);
717
+ const mwResponse =
718
+ globalMatches.length === 0
719
+ ? await coreHandler()
720
+ : await executeMiddleware<TEnv>(
721
+ globalMatches,
722
+ req,
723
+ env,
724
+ variables,
725
+ coreHandler,
726
+ reverse,
727
+ );
728
+
729
+ // Match production's global-chain exit (handler.ts): on a partial/action
730
+ // request a middleware 3xx redirect is converted to a Flight-safe response
731
+ // so fetch() does not auto-follow it; every path then drains onResponse
732
+ // callbacks via finalizeResponse. dispatch is RSC-free, so the
733
+ // createRedirectFlightResponse stand-in falls back to the no-state
734
+ // 204 + X-RSC-Redirect (see the location-state divergence in the header).
735
+ let finalResponse: Response;
736
+ if (isPartial || isAction) {
737
+ const intercepted = interceptRedirectForPartial(
738
+ mwResponse,
739
+ (redirectUrl) => createSimpleRedirectResponse(redirectUrl),
740
+ );
741
+ finalResponse = finalizeResponse(intercepted ?? mwResponse);
742
+ } else {
743
+ finalResponse = finalizeResponse(mwResponse);
399
744
  }
400
745
 
401
- return merged;
402
- };
746
+ // request.end closes the transaction. dispatch produces no RSC segments and
747
+ // holds no match-cache state, so segmentCount/cacheHit are 0/false — the
748
+ // same shape production emits for its own redirect (segment-less) result.
749
+ if (sink) {
750
+ safeEmit(resolveSink(sink), {
751
+ type: "request.end",
752
+ timestamp: performance.now(),
753
+ requestId: telemetryRequestId,
754
+ method: req.method,
755
+ pathname: url.pathname,
756
+ transaction: "match",
757
+ durationMs: performance.now() - telemetryStart,
758
+ segmentCount: 0,
759
+ cacheHit: false,
760
+ // dispatch's final response IS built before request.end (unlike
761
+ // match()/matchPartial(), whose Response is built after), so stamp its
762
+ // status — the same field a thrown-Response short-circuit carries.
763
+ status: finalResponse.status,
764
+ });
765
+ }
403
766
 
404
- // On a partial request the reload IS the terminal handler; otherwise the
405
- // response-route handler is. Either way global middleware wraps it.
406
- const finalHandler = isPartial ? partialFinalHandler : callHandler;
407
-
408
- // Combine global (pattern-matched) middleware with route middleware,
409
- // preserving the router's order: global runs before route-level. Route
410
- // middleware is skipped on a partial request (production returns the
411
- // reload before handleResponseRoute reaches its route middleware).
412
- const globalMatches = matchMiddleware(url.pathname, router.middleware);
413
- const routeMiddlewareEntries = isPartial
414
- ? []
415
- : (preview?.routeMiddleware ?? []).map((mw) => ({
416
- entry: {
417
- pattern: null,
418
- regex: null,
419
- paramNames: [],
420
- handler: mw.handler,
421
- mountPrefix: null,
422
- } as MiddlewareEntry<TEnv>,
423
- params: mw.params,
424
- }));
425
- const allMiddleware = [...globalMatches, ...routeMiddlewareEntries];
426
-
427
- if (allMiddleware.length === 0) {
428
- return finalHandler();
767
+ // Mirror production's single open-redirect chokepoint (handler.ts): every
768
+ // browser-followed (3xx + Location) redirect is same-origin guarded before
769
+ // it leaves -- a cross-origin Location is rewritten to the basename root
770
+ // unless redirect(url, { external: true }) opted out. Soft partial/action
771
+ // redirects are 204 + X-RSC-Redirect and pass through untouched (the client
772
+ // validates them), so this is a no-op for them.
773
+ return guardOutgoingRedirect(finalResponse, url.origin, router.basename);
774
+ } catch (error) {
775
+ if (sink) {
776
+ if (error instanceof Response) {
777
+ // executeMiddleware absorbs a middleware-thrown Response and returns it
778
+ // (middleware.ts:566), so a thrown Response never actually reaches this
779
+ // level in dispatch. Defensive: if one ever does it is a completed
780
+ // request from the consumer's seat (a short-circuit redirect), so mirror
781
+ // plan 002 / match-handlers.ts and emit request.end, not request.error.
782
+ safeEmit(resolveSink(sink), {
783
+ type: "request.end",
784
+ timestamp: performance.now(),
785
+ requestId: telemetryRequestId,
786
+ method: req.method,
787
+ pathname: url.pathname,
788
+ transaction: "match",
789
+ durationMs: performance.now() - telemetryStart,
790
+ segmentCount: 0,
791
+ cacheHit: false,
792
+ // Carry the short-circuit Response's status (parity with
793
+ // match-handlers.ts's thrown-Response request.end).
794
+ status: error.status,
795
+ });
796
+ } else {
797
+ safeEmit(resolveSink(sink), {
798
+ type: "request.error",
799
+ timestamp: performance.now(),
800
+ requestId: telemetryRequestId,
801
+ method: req.method,
802
+ pathname: url.pathname,
803
+ transaction: "match",
804
+ error: error instanceof Error ? error : new Error(String(error)),
805
+ phase: "routing",
806
+ durationMs: performance.now() - telemetryStart,
807
+ });
808
+ }
809
+ }
810
+ throw error;
429
811
  }
430
-
431
- return executeMiddleware<TEnv>(
432
- allMiddleware,
433
- req,
434
- env,
435
- variables,
436
- finalHandler,
437
- reverse,
438
- );
439
812
  });
440
813
  }