@rangojs/router 0.27.0 → 0.28.0

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 (212) hide show
  1. package/dist/bin/rango.js +1 -1
  2. package/dist/types/__internal.d.ts +7 -1
  3. package/dist/types/browser/event-controller.d.ts +2 -1
  4. package/dist/types/browser/history-state.d.ts +3 -9
  5. package/dist/types/browser/navigation-store.d.ts +8 -6
  6. package/dist/types/browser/react/filter-segment-order.d.ts +2 -2
  7. package/dist/types/browser/segment-reconciler.d.ts +6 -4
  8. package/dist/types/browser/types.d.ts +2 -1
  9. package/dist/types/browser/wire-protocol.d.ts +25 -0
  10. package/dist/types/cache/cache-key-utils.d.ts +11 -5
  11. package/dist/types/cache/cache-scope.d.ts +34 -22
  12. package/dist/types/cache/cf/cf-cache-constants.d.ts +2 -2
  13. package/dist/types/cache/cf/cf-cache-store.d.ts +47 -65
  14. package/dist/types/cache/cf/cf-kv-utils.d.ts +1 -1
  15. package/dist/types/cache/handle-snapshot.d.ts +9 -4
  16. package/dist/types/cache/segment-codec.d.ts +14 -0
  17. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  18. package/dist/types/cache/tag-invalidation.d.ts +10 -0
  19. package/dist/types/cache/taint.d.ts +5 -3
  20. package/dist/types/cache/types.d.ts +7 -3
  21. package/dist/types/cache/vercel/vercel-cache-store.d.ts +8 -6
  22. package/dist/types/prefetch-markers.d.ts +5 -14
  23. package/dist/types/prerender/store.d.ts +31 -0
  24. package/dist/types/prerender/warm-request.d.ts +3 -3
  25. package/dist/types/prerender.d.ts +2 -2
  26. package/dist/types/route-name.d.ts +5 -0
  27. package/dist/types/router/error-handling.d.ts +4 -4
  28. package/dist/types/router/instrument.d.ts +4 -3
  29. package/dist/types/router/intercept-resolution.d.ts +25 -9
  30. package/dist/types/router/match-context.d.ts +4 -11
  31. package/dist/types/router/match-handlers.d.ts +2 -6
  32. package/dist/types/router/match-middleware/background-revalidation.d.ts +10 -20
  33. package/dist/types/router/match-middleware/cache-lookup.d.ts +34 -86
  34. package/dist/types/router/match-middleware/segment-resolution.d.ts +8 -6
  35. package/dist/types/router/prerender-match.d.ts +21 -6
  36. package/dist/types/router/preview-match.d.ts +2 -7
  37. package/dist/types/router/request-classification.d.ts +5 -0
  38. package/dist/types/router/router-context.d.ts +7 -46
  39. package/dist/types/router/router-interfaces.d.ts +8 -14
  40. package/dist/types/router/segment-resolution/loader-cache.d.ts +3 -3
  41. package/dist/types/router/segment-resolution/loader-mask.d.ts +4 -4
  42. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +20 -18
  43. package/dist/types/router/segment-resolution/resolution-pass.d.ts +93 -0
  44. package/dist/types/router/segment-resolution/revalidation.d.ts +82 -31
  45. package/dist/types/router/segment-resolution.d.ts +1 -2
  46. package/dist/types/router/segment-wrappers.d.ts +11 -48
  47. package/dist/types/router/telemetry.d.ts +2 -0
  48. package/dist/types/router/types.d.ts +10 -0
  49. package/dist/types/rsc/full-payload.d.ts +2 -2
  50. package/dist/types/rsc/handler-context.d.ts +6 -6
  51. package/dist/types/rsc/json-route-result.d.ts +5 -9
  52. package/dist/types/rsc/request-stages.d.ts +203 -0
  53. package/dist/types/rsc/response-cache-serve.d.ts +7 -12
  54. package/dist/types/rsc/response-route-handler.d.ts +2 -2
  55. package/dist/types/rsc/routine-plan.d.ts +1 -1
  56. package/dist/types/rsc/server-action.d.ts +2 -2
  57. package/dist/types/rsc/shell-capture-constants.d.ts +1 -1
  58. package/dist/types/rsc/shell-capture.d.ts +132 -15
  59. package/dist/types/rsc/shell-serve.d.ts +37 -12
  60. package/dist/types/server/context.d.ts +9 -8
  61. package/dist/types/server/handle-store.d.ts +16 -0
  62. package/dist/types/server/render-pass.d.ts +72 -0
  63. package/dist/types/server/request-context.d.ts +24 -104
  64. package/dist/types/server/rewrite-trail.d.ts +2 -2
  65. package/dist/types/testing/dispatch.d.ts +35 -18
  66. package/dist/types/types/cache-types.d.ts +6 -3
  67. package/dist/types/types/error-types.d.ts +9 -1
  68. package/dist/types/types/handler-context.d.ts +9 -1
  69. package/dist/types/types/segments.d.ts +1 -1
  70. package/dist/types/vite/discovery/build-discovery-phase.d.ts +40 -0
  71. package/dist/types/vite/discovery/build-env.d.ts +22 -0
  72. package/dist/types/vite/discovery/bundle-scan-phase.d.ts +12 -0
  73. package/dist/types/vite/discovery/dev-prerender-cache.d.ts +3 -2
  74. package/dist/types/vite/discovery/dev-server-phase.d.ts +26 -0
  75. package/dist/types/vite/discovery/discovery-errors.d.ts +1 -1
  76. package/dist/types/vite/discovery/state.d.ts +12 -0
  77. package/dist/types/vite/plugins/version-plugin.d.ts +1 -1
  78. package/dist/types/vite/plugins/virtual-entries.d.ts +1 -1
  79. package/dist/types/vite/router-discovery.d.ts +8 -47
  80. package/dist/types/vite/temp-rsc-server.d.ts +39 -0
  81. package/dist/types/vite/utils/prerender-utils.d.ts +9 -0
  82. package/dist/types/wire-params.d.ts +34 -0
  83. package/dist/vite/index.js +3809 -3710
  84. package/package.json +1 -1
  85. package/skills/cache-guide/SKILL.md +8 -5
  86. package/skills/caching/SKILL.md +37 -18
  87. package/skills/document-cache/SKILL.md +13 -1
  88. package/skills/handler-use/SKILL.md +1 -1
  89. package/skills/intercept/SKILL.md +17 -7
  90. package/skills/middleware/SKILL.md +18 -0
  91. package/skills/ppr/SKILL.md +18 -3
  92. package/skills/prerender/SKILL.md +39 -15
  93. package/skills/rango/SKILL.md +5 -1
  94. package/skills/server-actions/SKILL.md +8 -1
  95. package/skills/testing/response-routes.md +6 -4
  96. package/skills/use-cache/SKILL.md +46 -8
  97. package/src/__internal.ts +7 -1
  98. package/src/browser/connection-warmup.ts +3 -1
  99. package/src/browser/event-controller.ts +5 -3
  100. package/src/browser/history-state.ts +6 -12
  101. package/src/browser/navigation-bridge.ts +14 -13
  102. package/src/browser/navigation-client.ts +18 -25
  103. package/src/browser/navigation-store.ts +25 -27
  104. package/src/browser/navigation-transaction.ts +4 -1
  105. package/src/browser/prefetch/cache.ts +2 -1
  106. package/src/browser/prefetch/fetch.ts +8 -16
  107. package/src/browser/prefetch/queue.ts +4 -4
  108. package/src/browser/react/NavigationProvider.tsx +2 -1
  109. package/src/browser/react/filter-segment-order.ts +2 -2
  110. package/src/browser/rsc-router.tsx +1 -0
  111. package/src/browser/segment-reconciler.ts +66 -53
  112. package/src/browser/server-action-bridge.ts +50 -57
  113. package/src/browser/types.ts +2 -1
  114. package/src/browser/wire-protocol.ts +41 -0
  115. package/src/cache/cache-key-utils.ts +11 -5
  116. package/src/cache/cache-runtime.ts +110 -50
  117. package/src/cache/cache-scope.ts +110 -64
  118. package/src/cache/cf/cf-cache-constants.ts +2 -2
  119. package/src/cache/cf/cf-cache-store.ts +486 -874
  120. package/src/cache/cf/cf-kv-utils.ts +1 -1
  121. package/src/cache/document-cache.ts +34 -13
  122. package/src/cache/handle-snapshot.ts +19 -7
  123. package/src/cache/segment-codec.ts +55 -0
  124. package/src/cache/shell-snapshot.ts +2 -2
  125. package/src/cache/tag-invalidation.ts +21 -2
  126. package/src/cache/taint.ts +5 -3
  127. package/src/cache/types.ts +7 -3
  128. package/src/cache/vercel/vercel-cache-store.ts +243 -310
  129. package/src/handles/pushed-by.ts +1 -1
  130. package/src/prefetch-markers.ts +5 -16
  131. package/src/prerender/store.ts +76 -0
  132. package/src/prerender/warm-request.ts +8 -4
  133. package/src/prerender.ts +2 -2
  134. package/src/route-name.ts +8 -0
  135. package/src/router/error-handling.ts +4 -4
  136. package/src/router/instrument.ts +4 -3
  137. package/src/router/intercept-resolution.ts +77 -75
  138. package/src/router/loader-resolution.ts +5 -4
  139. package/src/router/match-api.ts +4 -5
  140. package/src/router/match-context.ts +20 -12
  141. package/src/router/match-handlers.ts +8 -20
  142. package/src/router/match-middleware/background-revalidation.ts +41 -53
  143. package/src/router/match-middleware/cache-lookup.ts +675 -726
  144. package/src/router/match-middleware/cache-store.ts +4 -8
  145. package/src/router/match-middleware/intercept-resolution.ts +3 -23
  146. package/src/router/match-middleware/segment-resolution.ts +34 -61
  147. package/src/router/middleware.ts +69 -11
  148. package/src/router/navigation-snapshot.ts +4 -4
  149. package/src/router/prerender-match.ts +100 -44
  150. package/src/router/preview-match.ts +6 -41
  151. package/src/router/request-classification.ts +13 -7
  152. package/src/router/route-snapshot.ts +2 -5
  153. package/src/router/router-context.ts +10 -95
  154. package/src/router/router-interfaces.ts +8 -14
  155. package/src/router/segment-resolution/loader-cache.ts +14 -9
  156. package/src/router/segment-resolution/loader-mask.ts +5 -5
  157. package/src/router/segment-resolution/prefetch-deferral.ts +30 -27
  158. package/src/router/segment-resolution/resolution-pass.ts +137 -0
  159. package/src/router/segment-resolution/revalidation.ts +897 -776
  160. package/src/router/segment-resolution.ts +4 -13
  161. package/src/router/segment-wrappers.ts +51 -269
  162. package/src/router/telemetry.ts +5 -0
  163. package/src/router/types.ts +10 -0
  164. package/src/router.ts +12 -13
  165. package/src/rsc/full-payload.ts +6 -5
  166. package/src/rsc/handler-context.ts +8 -14
  167. package/src/rsc/handler.ts +126 -507
  168. package/src/rsc/json-route-result.ts +5 -9
  169. package/src/rsc/loader-fetch.ts +6 -5
  170. package/src/rsc/progressive-enhancement.ts +5 -0
  171. package/src/rsc/request-stages.ts +757 -0
  172. package/src/rsc/response-cache-serve.ts +10 -14
  173. package/src/rsc/response-route-handler.ts +13 -5
  174. package/src/rsc/rewrite-passes.ts +2 -1
  175. package/src/rsc/routine-plan.ts +1 -1
  176. package/src/rsc/rsc-rendering.ts +164 -349
  177. package/src/rsc/server-action.ts +87 -113
  178. package/src/rsc/shell-capture-constants.ts +1 -1
  179. package/src/rsc/shell-capture.ts +212 -169
  180. package/src/rsc/shell-serve.ts +121 -18
  181. package/src/rsc/ssr-setup.ts +5 -4
  182. package/src/segment-system.tsx +3 -3
  183. package/src/server/context.ts +22 -25
  184. package/src/server/handle-store.ts +55 -0
  185. package/src/server/render-pass.ts +291 -0
  186. package/src/server/request-context.ts +26 -115
  187. package/src/server/rewrite-trail.ts +6 -4
  188. package/src/ssr/index.tsx +1 -1
  189. package/src/testing/dispatch.ts +223 -710
  190. package/src/testing/e2e/page-helpers.ts +2 -2
  191. package/src/testing/serve-shell-request.ts +23 -36
  192. package/src/types/cache-types.ts +6 -3
  193. package/src/types/error-types.ts +9 -1
  194. package/src/types/handler-context.ts +12 -1
  195. package/src/types/segments.ts +1 -1
  196. package/src/vite/discovery/build-discovery-phase.ts +204 -0
  197. package/src/vite/discovery/build-env.ts +148 -0
  198. package/src/vite/discovery/bundle-scan-phase.ts +128 -0
  199. package/src/vite/discovery/dev-prerender-cache.ts +8 -3
  200. package/src/vite/discovery/dev-server-phase.ts +1776 -0
  201. package/src/vite/discovery/discover-routers.ts +1 -1
  202. package/src/vite/discovery/discovery-errors.ts +1 -1
  203. package/src/vite/discovery/prerender-collection.ts +24 -39
  204. package/src/vite/discovery/state.ts +15 -0
  205. package/src/vite/plugins/version-plugin.ts +1 -1
  206. package/src/vite/plugins/virtual-entries.ts +1 -1
  207. package/src/vite/router-discovery.ts +29 -2428
  208. package/src/vite/temp-rsc-server.ts +223 -0
  209. package/src/vite/utils/prerender-utils.ts +47 -0
  210. package/src/wire-params.ts +35 -0
  211. package/dist/types/router/segment-resolution/fresh.d.ts +0 -72
  212. package/src/router/segment-resolution/fresh.ts +0 -945
@@ -2,9 +2,12 @@
2
2
  * dispatch — in-process request -> Response for unit/integration tests,
3
3
  * WITHOUT the Flight RSC runtime.
4
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
5
+ * dispatch runs the production handler's request stages
6
+ * (rsc/request-stages.ts: classify, the terminal plans, the global middleware
7
+ * passes, the response route and the response's finalization) so that
8
+ * redirects, 404s, reloads, response routes (path.json / path.text /
9
+ * path.html / ...), and middleware short-circuits behave exactly as in
10
+ * production. It deliberately
8
11
  * does NOT render React Server Components: there is no Flight stream, no SSR,
9
12
  * and no DOM. Hit an RSC (component) route and dispatch throws a clear error
10
13
  * directing you to renderToFlightString/renderServerTree/renderHandler or an e2e test.
@@ -17,19 +20,20 @@
17
20
  * so a global auth middleware can 401/redirect them and middleware-set
18
21
  * cookies/headers merge onto the 308/404 the way createResponseWithMergedHeaders
19
22
  * merges them in production.
20
- * - Response routes (non-RSC) -> serialized Response
23
+ * - Response routes (non-RSC), served by production's handleResponseRoute
24
+ * (rsc/response-route-handler.ts) -> serialized Response
21
25
  * - json: JSON.stringify(result) (bare value) with application/json
22
26
  * - text/html/xml/md: String(result) with the mapped MIME type
23
- * - handler returning or throwing a Response: re-wrapped like
24
- * handleResponseRoute (stub headers/cookies merged, Set-Cookie preserved,
25
- * WebSocket upgrade passed through without reconstruction)
27
+ * - handler returning or throwing a Response: re-wrapped (stub
28
+ * headers/cookies merged, Set-Cookie preserved, WebSocket upgrade passed
29
+ * through without reconstruction)
26
30
  * - handler throwing a non-Response 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)
31
+ * status (RFC 9457 problem+json body with application/problem+json for
32
+ * json routes, text/plain message otherwise)
29
33
  * - content-negotiated route: Vary: Accept appended
30
34
  * - 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
35
+ * hit/SWR/tag write, resolved from the matched entry tree. The write is
36
+ * scheduled via ctx.waitUntil
33
37
  * (a microtask without an executionContext), so a HIT-asserting test must
34
38
  * flush microtasks between the seeding dispatch and the asserting one.
35
39
  * - Global middleware (router.use(...)) AND route-level middleware, with full
@@ -38,10 +42,17 @@
38
42
  * global middleware runs first (so an auth gate can still 401/redirect),
39
43
  * then — if it passes through — an X-RSC-Reload is returned. Route-level
40
44
  * 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 —
45
+ * - A global middleware redirect (3xx + Location) on a partial/action request
46
+ * (?_rsc_partial / ?_rsc_action), and a findMatch() redirect on a partial
47
+ * request: a 204 + X-RSC-Redirect, so fetch() does not auto-follow the 3xx —
44
48
  * identical to production's no-location-state path.
49
+ * - The terminal plans production answers inside the global chain
50
+ * (terminalPlanResponse): a `_rsc_v` version mismatch and a `_rsc_rid` app
51
+ * switch reload (X-RSC-Reload), and an action (?_rsc_action) a rewrite moved
52
+ * to a target that is not a page route throws production's error.
53
+ * - Route params and name are set inside the global chain, after classify, by
54
+ * the stage production's executeRequest runs (recordPlanRoute): global
55
+ * middleware sees them after next(), not before it.
45
56
  * - The open-redirect guard (rsc/redirect-guard.ts) on full (browser-followed)
46
57
  * redirects: a cross-origin Location is rewritten to the basename root unless
47
58
  * redirect(url, { external: true }) opted out, mirroring production's single
@@ -60,7 +71,9 @@
60
71
  * after finalizeResponse (segmentCount 0 / cacheHit false — dispatch renders no
61
72
  * RSC segments and holds no match-cache state), and a thrown non-Response error
62
73
  * emits request.error with phase "routing". All three carry the same requestId
63
- * (getRequestId). Emission is gated entirely on a configured sink; with none,
74
+ * (getRequestId), as does a response-route timeout on a rewritten pass
75
+ * (runRequestPasses carries the id to each pass's request, as in
76
+ * production). Emission is gated entirely on a configured sink; with none,
64
77
  * dispatch does zero new work and stays byte-identical for existing callers.
65
78
  * Lets a consumer unit-test their sink wiring in-process instead of only at e2e.
66
79
  * - Response-route `renderStartMs` timeouts and `onTimeout`: dispatch races the
@@ -96,26 +109,30 @@
96
109
  * request.start/end/error lifecycle above; cover cache/loader telemetry with an
97
110
  * e2e test driving a real RSC request.
98
111
  *
99
- * dispatch reuses router.previewMatch(), which itself runs content negotiation
100
- * and resolves route middleware from the matched entry tree, so dispatch's
101
- * route-middleware collection is exactly the router's, not a re-implementation.
112
+ * Each pass is classified with the handler's classify stage
113
+ * (router/request-classification.ts classifyRequest), which runs content
114
+ * negotiation and resolves route middleware from the matched entry tree, so
115
+ * dispatch's route-middleware collection is exactly the router's, not a
116
+ * re-implementation. The origin guard is not run: it gates only the action,
117
+ * loader and PE-form plans, which classify gives a component route only, and
118
+ * dispatch throws on those before the pass's middleware.
102
119
  */
103
120
 
121
+ import type {
122
+ ClassifyContext,
123
+ RequestStageContext,
124
+ } from "../rsc/request-stages.js";
125
+ import type {
126
+ ClassifyRequestDeps,
127
+ ExecutableRequestPlan,
128
+ RequestPlan,
129
+ } from "../router/request-classification.js";
104
130
  import {
105
131
  createRequestContext,
106
132
  runWithRequestContext,
107
- setRequestContextParams,
108
133
  } from "../server/request-context.js";
109
- import { REWRITE_HEADER, runRewritePasses } from "../rsc/rewrite-passes.js";
110
- import { defineRewrittenFrom } from "../server/rewrite-trail.js";
111
134
  import { resolveRouterVersions } from "../server/build-version-table.js";
112
- import { executeMiddleware, matchMiddleware } from "../router/middleware.js";
113
- import type {
114
- MiddlewareEntry,
115
- MiddlewareFn,
116
- } from "../router/middleware-types.js";
117
- import { createReverseFunction } from "../router/handler-context.js";
118
- import { NOCACHE_SYMBOL } from "../cache/taint.js";
135
+ import type { MiddlewareEntry } from "../router/middleware-types.js";
119
136
  import {
120
137
  compileSearchParamsFilter,
121
138
  type CacheSearchParams,
@@ -123,40 +140,17 @@ import {
123
140
  } from "../cache/search-params-filter.js";
124
141
  import type { SegmentCacheStore } from "../cache/types.js";
125
142
  import type { CacheProfile } from "../cache/profile-registry.js";
126
- // cache-scope is loaded LAZILY inside the response-route cache path (below):
127
- // its module graph pulls @vitejs/plugin-rsc/rsc (via segment-codec), which the
128
- // non-Vite unit-test runner cannot resolve. A static import here would drag that
129
- // onto the whole testing barrel's eager graph and break every consumer suite
130
- // that imports `@rangojs/router/testing` without mocking plugin-rsc.
131
- import type { EntryData } from "../server/context.js";
132
143
  import { ensureRouterTrie } from "../rsc/manifest-init.js";
133
- import { RESPONSE_TYPE_MIME } from "../router/content-negotiation.js";
134
- import { RouterError } from "../errors.js";
135
- import { createProblemDetails } from "../rsc/response-error.js";
136
144
  import {
137
- createReloadResponse,
138
145
  createResponseWithMergedHeaders,
139
146
  createSimpleRedirectResponse,
140
- finalizeResponse,
141
- interceptRedirectForPartial,
142
- rewrapResponseRouteResponse,
143
147
  } from "../rsc/helpers.js";
144
- import { guardOutgoingRedirect } from "../rsc/redirect-guard.js";
145
- import { stringifyJsonRouteResult } from "../rsc/json-route-result.js";
146
- import { isWebSocketUpgradeResponse, trySetHeader } from "../response-utils.js";
147
- import { invokeOnError } from "../router/error-handling.js";
148
148
  import type { OnErrorCallback } from "../types/error-types.js";
149
149
  import type { Rango } from "../router/router-interfaces.js";
150
150
  import { getRequestId, resolveSink, safeEmit } from "../router/telemetry.js";
151
151
  import type { TelemetrySink } from "../router/telemetry.js";
152
- import {
153
- RouterTimeoutError,
154
- createDefaultTimeoutResponse,
155
- isTimeoutEnabled,
156
- withTimeout,
157
- } from "../router/timeout.js";
158
152
  import type { OnTimeoutCallback, ResolvedTimeouts } from "../router/timeout.js";
159
- import { applyStreamIdleTimeout } from "../rsc/stream-idle.js";
153
+ import { WIRE } from "../wire-params.js";
160
154
 
161
155
  /**
162
156
  * The internal subset of the router surface dispatch depends on. The public
@@ -181,26 +175,7 @@ interface DispatchableRouter<TEnv> {
181
175
  telemetry?: TelemetrySink;
182
176
  timeouts?: ResolvedTimeouts;
183
177
  onTimeout?: OnTimeoutCallback<TEnv>;
184
- findMatch(pathname: string): Promise<{
185
- redirectTo?: string;
186
- routeKey?: string;
187
- params?: Record<string, string>;
188
- } | null>;
189
- previewMatch(
190
- request: Request,
191
- input?: { env?: TEnv },
192
- ): Promise<{
193
- routeMiddleware?: Array<{
194
- handler: MiddlewareFn<TEnv>;
195
- params: Record<string, string>;
196
- }>;
197
- responseType?: string;
198
- handler?: Function;
199
- params?: Record<string, string>;
200
- routeKey?: string;
201
- negotiated?: boolean;
202
- manifestEntry?: EntryData;
203
- } | null>;
178
+ findMatch: ClassifyRequestDeps<TEnv>["findMatch"];
204
179
  basename?: string;
205
180
  cache?:
206
181
  | {
@@ -236,153 +211,44 @@ function toRequest(request: Request | string): Request {
236
211
  return new Request(new URL(request, DEFAULT_ORIGIN));
237
212
  }
238
213
 
214
+ type RequestStages = typeof import("../rsc/request-stages.js");
215
+
216
+ /** The plan modes dispatch answers; the others render a component. */
217
+ const ANSWERS_MODE: Record<RequestPlan["mode"], boolean> = {
218
+ redirect: true,
219
+ response: true,
220
+ "version-mismatch": true,
221
+ "app-switch": true,
222
+ "full-render": false,
223
+ "partial-render": false,
224
+ action: false,
225
+ loader: false,
226
+ "pe-render": false,
227
+ };
228
+
239
229
  /**
240
- * Serialize a NON-Response response-route handler result, mirroring the
241
- * router's handleResponseRoute() contract:
242
- * - "json" serializes the value (bare) with application/json, rejecting a nested
243
- * unresolved Promise via the shared stringifyJsonRouteResult guard,
244
- * - text/html/xml/md stringify with the mapped MIME type.
245
- *
246
- * A handler-returned Response is NOT routed here — callHandler re-wraps it via
247
- * rewrapHandlerResponse (mirroring handleResponseRoute's rewrapResponse) so the
248
- * WebSocket-upgrade bypass and Set-Cookie-preserving header merge match
249
- * production.
230
+ * Classify one pass (the visitor's URL, or a rewrite target) with the
231
+ * handler's classify stage. A plan that renders a component throws: that is
232
+ * a hard boundary of this primitive (no Flight runtime), so it comes before
233
+ * the pass's global middleware, ahead of any middleware side effect. An
234
+ * unmatched path is answered (404).
250
235
  */
251
- function serializeResponseRouteResult(
252
- result: unknown,
253
- responseType: string,
254
- ): Response {
255
- if (responseType === "json") {
256
- // Serialize through the SAME guard production uses: a nested unresolved
257
- // Promise (forgotten await) throws RESPONSE_NOT_SERIALIZABLE here, caught by
258
- // callHandler's catch and mapped to the identical typed 500 production
259
- // returns -- so a dispatch json test fails exactly where production would,
260
- // instead of silently emitting {} and passing.
261
- return new Response(stringifyJsonRouteResult(result), {
262
- status: 200,
263
- headers: { "content-type": "application/json;charset=utf-8" },
264
- });
265
- }
266
-
267
- if (Object.hasOwn(RESPONSE_TYPE_MIME, responseType)) {
268
- return new Response(String(result), {
269
- status: 200,
270
- headers: {
271
- "content-type": `${RESPONSE_TYPE_MIME[responseType]};charset=utf-8`,
272
- },
273
- });
274
- }
275
-
236
+ async function classifyPass<TEnv>(
237
+ stages: RequestStages,
238
+ request: Request,
239
+ url: URL,
240
+ ctx: ClassifyContext<TEnv>,
241
+ ): Promise<RequestPlan<TEnv>> {
242
+ const plan = await stages.classify(request, url, ctx);
243
+ if (ANSWERS_MODE[plan.mode] || stages.isUnmatchedPlan(plan)) return plan;
276
244
  throw new Error(
277
- `dispatch(): response route handler for "${responseType}" must return a ` +
278
- `Response object, got ${typeof result}. Binary/streaming response types ` +
279
- `(image, stream, any) must return a Response explicitly.`,
245
+ `dispatch() does not render RSC routes — the route matched at ` +
246
+ `"${url.pathname}" is a React Server Component route, not a response ` +
247
+ `route. Use renderHandler/renderServerTree/renderToFlightString or an ` +
248
+ `e2e test to exercise component rendering.`,
280
249
  );
281
250
  }
282
251
 
283
- /**
284
- * Serialize a thrown handler error into the same typed Response the router's
285
- * handleResponseRoute() catch block produces:
286
- * - "json" routes return an RFC 9457 problem+json body (application/problem+json),
287
- * - all other types return a text/plain body (the RouterError message verbatim,
288
- * the Error message in dev, else "Internal Server Error").
289
- *
290
- * `status` is the effective HTTP status resolved by the caller (RouterError.status
291
- * or 500, overridden by ctx.setStatus()); it governs both the HTTP status and the
292
- * problem body's `status`/`title` members. Reuses the production
293
- * createProblemDetails so the error body is byte-identical rather than re-derived.
294
- */
295
- function serializeResponseRouteError(
296
- error: unknown,
297
- responseType: string,
298
- status: number,
299
- ): Response {
300
- const isDev = process.env.NODE_ENV !== "production";
301
-
302
- if (responseType === "json") {
303
- return new Response(
304
- JSON.stringify(createProblemDetails(error, status, isDev)),
305
- {
306
- status,
307
- headers: { "content-type": "application/problem+json;charset=utf-8" },
308
- },
309
- );
310
- }
311
-
312
- const message =
313
- error instanceof RouterError
314
- ? error.message
315
- : isDev && error instanceof Error
316
- ? error.message
317
- : "Internal Server Error";
318
- return new Response(message, {
319
- status,
320
- headers: { "content-type": "text/plain;charset=utf-8" },
321
- });
322
- }
323
-
324
- /** What one pass of a dispatch matched (the visitor's URL, or a rewrite target). */
325
- interface DispatchPass<TEnv> {
326
- req: Request;
327
- url: URL;
328
- redirectTo?: string;
329
- isUnmatched: boolean;
330
- preview: Awaited<ReturnType<DispatchableRouter<TEnv>["previewMatch"]>>;
331
- responseType?: string;
332
- handler?: Function;
333
- params: Record<string, string>;
334
- routeKey?: string;
335
- }
336
-
337
- async function matchDispatchPass<TEnv>(
338
- router: DispatchableRouter<TEnv>,
339
- req: Request,
340
- env: TEnv,
341
- ): Promise<DispatchPass<TEnv>> {
342
- const url = new URL(req.url);
343
- // findMatch carries trailing-slash/redirect targets and null on no match.
344
- // previewMatch swallows redirects, so detect them here first.
345
- const match = await router.findMatch(url.pathname);
346
- const redirectTo = match?.redirectTo;
347
- const isUnmatched = !match;
348
-
349
- // previewMatch resolves responseType, the response-route handler, and the
350
- // route middleware from the matched entry tree (with content negotiation).
351
- // Skip it for a redirect/unmatched path — there is no response route to
352
- // resolve, and previewMatch would return null / a redirect marker anyway.
353
- const preview =
354
- redirectTo || isUnmatched ? null : await router.previewMatch(req, { env });
355
-
356
- // A bare match with no responseType is an RSC route. The RSC-route throw is a
357
- // hard boundary of this primitive (no Flight runtime), distinct from the
358
- // 308/404 outcomes, so it stays a pre-middleware guard.
359
- const responseType = preview?.responseType;
360
- const handler = preview?.handler;
361
- if (
362
- !redirectTo &&
363
- !isUnmatched &&
364
- (!responseType || typeof handler !== "function")
365
- ) {
366
- throw new Error(
367
- `dispatch() does not render RSC routes — the route matched at ` +
368
- `"${url.pathname}" is a React Server Component route, not a response ` +
369
- `route. Use renderHandler/renderServerTree/renderToFlightString or an ` +
370
- `e2e test to exercise component rendering.`,
371
- );
372
- }
373
- return {
374
- req,
375
- url,
376
- redirectTo,
377
- isUnmatched,
378
- preview,
379
- responseType,
380
- handler,
381
- params: preview?.params ?? match?.params ?? {},
382
- routeKey: preview?.routeKey ?? match?.routeKey,
383
- };
384
- }
385
-
386
252
  /**
387
253
  * Run a request through the router in-process and return the Response.
388
254
  *
@@ -412,16 +278,49 @@ export async function dispatch<TEnv = any>(
412
278
  const routerId = router.id ?? router.routerId;
413
279
  await ensureRouterTrie(router);
414
280
 
415
- // The visitor's URL is matched before any middleware runs, so an RSC route
416
- // fails here, before a middleware side effect. A rewrite target is matched
417
- // the same way, at the start of its pass (below).
418
- const firstPass = await matchDispatchPass(router, req, env);
281
+ const stages = await loadRequestStages();
282
+ // The store keys with the router's versions, resolved as the production
283
+ // handler resolves them (rsc/handler.ts).
284
+ const versions = resolveRouterVersions(routerId);
285
+ const ctx: RequestStageContext<TEnv> & ClassifyContext<TEnv> = {
286
+ router: {
287
+ id: routerId ?? "",
288
+ findMatch: router.findMatch,
289
+ basename: router.basename,
290
+ middleware: router.middleware,
291
+ onError: router.onError,
292
+ telemetry: router.telemetry,
293
+ timeouts: router.timeouts,
294
+ onTimeout: router.onTimeout,
295
+ },
296
+ callOnError: stages.createErrorReporter<TEnv>(router),
297
+ // A response-route handler's own throw is serialized into the same typed
298
+ // Response as production but not reported (module header).
299
+ reportResponseRouteError: () => {},
300
+ getRequiredRouteMap: () => router.routeMap as Record<string, string>,
301
+ // No Flight runtime: the no-state 204 + X-RSC-Redirect, dropping any
302
+ // location state (module header). `external` is forwarded so
303
+ // redirect(url, { external: true }) keeps its off-host target.
304
+ createRedirectFlightResponse: (redirectUrl, _locationState, external) =>
305
+ createSimpleRedirectResponse(redirectUrl, {
306
+ requestOrigin: url.origin,
307
+ basename: router.basename,
308
+ external,
309
+ }),
310
+ // The `_rsc_v` reload check reads the DOCUMENT version, as the handler's.
311
+ version: versions.document,
312
+ };
313
+
314
+ // The visitor's URL is classified before any middleware runs, so an RSC
315
+ // route fails here, before a middleware side effect. A rewrite target is
316
+ // classified the same way, at the start of its pass (below).
317
+ let plan = await classifyPass(stages, req, url, ctx);
419
318
 
420
319
  const variables: Record<string, unknown> = {};
421
320
 
422
321
  // Resolve the router's cache store the way the production handler does, so a
423
322
  // "use cache" inside a response-route handler reaches the request-scope
424
- // (NOCACHE) detection below instead of bypassing on a missing store.
323
+ // (NOCACHE) detection instead of bypassing on a missing store.
425
324
  let cacheStore: SegmentCacheStore | undefined;
426
325
  let searchParamsFilter: SearchParamsFilter | undefined;
427
326
  const cacheOption = router.cache;
@@ -444,26 +343,11 @@ export async function dispatch<TEnv = any>(
444
343
  cacheStore,
445
344
  searchParamsFilter,
446
345
  cacheProfiles: router.cacheProfiles,
447
- // The store keys with the router's versions, resolved as the production
448
- // handler resolves them (rsc/handler.ts).
449
- versions: resolveRouterVersions(routerId),
346
+ versions,
450
347
  });
451
- // Wire background error reporting so cache degradation (reportCacheError ->
452
- // _reportBackgroundError) reaches the router's onError, mirroring the production
453
- // RSC handler (rsc/handler.ts). Without this, dispatch could not observe onError.
454
- requestContext._reportBackgroundError = (error, category) => {
455
- if (error != null && typeof error === "object") {
456
- if (requestContext._reportedErrors.has(error)) return;
457
- requestContext._reportedErrors.add(error);
458
- }
459
- invokeOnError(
460
- router.onError,
461
- error,
462
- "cache",
463
- { request: req, url, metadata: { category } },
464
- "RSC",
465
- );
466
- };
348
+ // Cache degradation (reportCacheError -> _reportBackgroundError) reaches the
349
+ // router's onError, as in the production handler.
350
+ stages.reportBackgroundErrors(requestContext, req, url, ctx.callOnError);
467
351
  // Match production: the RSC handler stores the router's basename on the
468
352
  // request context (handler.ts), and redirect() prefixes root-relative URLs
469
353
  // with it. Mirror it so basename-redirect tests behave as they do in a real
@@ -471,21 +355,7 @@ export async function dispatch<TEnv = any>(
471
355
  requestContext._basename = router.basename;
472
356
  requestContext._routerId = routerId;
473
357
 
474
- // Match production's response-route reverse EXACTLY: the real handler builds
475
- // it from the route map alone (response-route-handler.ts), with NO matched
476
- // routeKey or params. Passing routeKey/params here would auto-fill params from
477
- // the matched route, so ctx.reverse("name") could pass in a test while the
478
- // real handler throws for the missing param.
479
- const reverse = createReverseFunction(
480
- router.routeMap as Record<string, string>,
481
- ) as (
482
- name: string,
483
- p?: Record<string, string>,
484
- search?: Record<string, unknown>,
485
- ) => string;
486
-
487
- const isPartial = url.searchParams.has("_rsc_partial");
488
- const isAction = url.searchParams.has("_rsc_action");
358
+ const isPartial = url.searchParams.has(WIRE.partial);
489
359
 
490
360
  // Telemetry: mirror the router's match-transaction lifecycle onto the
491
361
  // configured sink so a consumer can unit-test createRouter({ telemetry })
@@ -501,307 +371,6 @@ export async function dispatch<TEnv = any>(
501
371
  const telemetryStart = sink ? performance.now() : 0;
502
372
 
503
373
  return runWithRequestContext(requestContext, async () => {
504
- // Set params before middleware/handler run, so global middleware sees
505
- // ctx.params (production sets them during matching, before middleware).
506
- // On a redirect/unmatched path there are no route params.
507
- const recordPassParams = (pass: DispatchPass<TEnv>): void => {
508
- if (pass.routeKey !== undefined) {
509
- setRequestContextParams(pass.params, pass.routeKey);
510
- } else {
511
- requestContext.params = pass.params;
512
- }
513
- };
514
-
515
- // The response-route handler (with its own route middleware) lives inside
516
- // coreHandler below, mirroring production where handleResponseRoute is
517
- // nested inside coreHandler. Built lazily so a redirect/404 path never
518
- // touches it.
519
- const callResponseRoute = async (
520
- pass: DispatchPass<TEnv>,
521
- ): Promise<Response> => {
522
- const { req, url, preview, params, responseType, handler } = pass;
523
- // Match production: a partial (client-navigation) request to a response
524
- // route is short-circuited to X-RSC-Reload (handleResponseRoute), BEFORE
525
- // route-level middleware runs. Route-level middleware is skipped on a
526
- // partial, exactly as production skips it.
527
- const partialFinalHandler = async (): Promise<Response> =>
528
- createReloadResponse(requestContext, url);
529
-
530
- const cleanUrl = new URL(req.url);
531
- for (const key of [...cleanUrl.searchParams.keys()]) {
532
- if (key.startsWith("_rsc")) cleanUrl.searchParams.delete(key);
533
- }
534
-
535
- // Lightweight response-handler context mirroring handleResponseRoute.
536
- const responseHandlerCtx = {
537
- request: req,
538
- params,
539
- env,
540
- searchParams: cleanUrl.searchParams,
541
- url: cleanUrl,
542
- originalUrl: requestContext.originalUrl,
543
- pathname: url.pathname,
544
- reverse,
545
- get: requestContext.get,
546
- header: (name: string, value: string) =>
547
- requestContext.header(name, value),
548
- waitUntil: requestContext.waitUntil.bind(requestContext),
549
- executionContext: requestContext.executionContext,
550
- _responseType: responseType,
551
- };
552
- // Brand as request-scoped so a "use cache" inside a response-route handler
553
- // is detected as a request-scope violation here exactly as in production
554
- // (response-route-handler.ts brands the same shape).
555
- defineRewrittenFrom(responseHandlerCtx, requestContext);
556
- (responseHandlerCtx as Record<symbol, unknown>)[NOCACHE_SYMBOL] = true;
557
-
558
- const callHandler = async (): Promise<Response> => {
559
- let merged: Response;
560
- try {
561
- let result: unknown;
562
- try {
563
- result = await (handler as Function)(responseHandlerCtx);
564
- } catch (error) {
565
- if (!(error instanceof Response)) throw error;
566
- result = error;
567
- }
568
- if (result instanceof Response) {
569
- merged = rewrapResponseRouteResponse(result);
570
- } else {
571
- // Route the serialized (json/text/...) body through the SAME
572
- // production finalizer the RSC handler uses, so ctx.onResponse()
573
- // callbacks fire and stub headers/cookies + the ctx.setStatus
574
- // override merge identically to production. Runs inside
575
- // runWithRequestContext, so _getRequestContext() resolves here.
576
- const serialized = serializeResponseRouteResult(
577
- result,
578
- responseType as string,
579
- );
580
- merged = createResponseWithMergedHeaders(serialized.body, {
581
- status: serialized.status,
582
- headers: serialized.headers,
583
- });
584
- }
585
- } catch (error) {
586
- // Mirror handleResponseRoute's catch: a genuine handler error becomes
587
- // the router's typed 500 / RouterError-status Response (NOT a rejected
588
- // promise). Middleware short-circuit via thrown Response is handled by
589
- // executeMiddleware and never reaches here.
590
- const derivedStatus =
591
- error instanceof RouterError ? error.status : 500;
592
- // Resolve the effective status the way createResponseWithMergedHeaders
593
- // (below) will (ctx.res.status override) BEFORE building the problem
594
- // body, so the body's status/title match the actual HTTP status when a
595
- // handler called ctx.setStatus() before throwing — exactly as
596
- // handleResponseRoute resolves it.
597
- const status =
598
- requestContext.res.status !== 200
599
- ? requestContext.res.status
600
- : derivedStatus;
601
- const serialized = serializeResponseRouteError(
602
- error,
603
- responseType as string,
604
- status,
605
- );
606
- merged = createResponseWithMergedHeaders(serialized.body, {
607
- status: serialized.status,
608
- headers: serialized.headers,
609
- });
610
- }
611
-
612
- // Append Vary: Accept on content-negotiated responses, matching
613
- // handleResponseRoute's callHandlerWithVary. Skipped on WebSocket
614
- // upgrade responses (immutable headers, Vary meaningless for a 101).
615
- if (preview?.negotiated && !isWebSocketUpgradeResponse(merged)) {
616
- merged.headers.append("Vary", "Accept");
617
- }
618
-
619
- return merged;
620
- };
621
-
622
- // On a partial request the reload IS the terminal handler and route
623
- // middleware is skipped; otherwise the response-route handler is wrapped
624
- // by route-level middleware (production order: route middleware runs
625
- // inside handleResponseRoute, after the global chain).
626
- if (isPartial) {
627
- return partialFinalHandler();
628
- }
629
-
630
- // executeHandler = callHandler wrapped by route-level middleware, exactly
631
- // the unit the production response cache wraps (response-route-handler.ts).
632
- const executeHandler = (): Promise<Response> => {
633
- const routeMiddlewareEntries = (preview?.routeMiddleware ?? []).map(
634
- (mw) => ({
635
- entry: {
636
- pattern: null,
637
- regex: null,
638
- paramNames: [],
639
- handler: mw.handler,
640
- } as MiddlewareEntry<TEnv>,
641
- params: mw.params,
642
- }),
643
- );
644
- if (routeMiddlewareEntries.length === 0) {
645
- return callHandler();
646
- }
647
- return executeMiddleware<TEnv>(
648
- routeMiddlewareEntries,
649
- req,
650
- env,
651
- variables,
652
- callHandler,
653
- reverse,
654
- );
655
- };
656
-
657
- // Response-route cache path: resolved through the SAME shared serve leaf
658
- // (rsc/response-cache-serve.ts) production uses, so a cached
659
- // path.json/path.text route hits/SWRs/writes tags through dispatch exactly
660
- // as in production — and the two can never drift. Resolved from the matched
661
- // entry tree (preview.manifestEntry), which previewMatch surfaces for
662
- // response routes.
663
- const manifestEntry = preview?.manifestEntry;
664
- if (manifestEntry) {
665
- // Lazy so the testing barrel's eager graph stays plugin-rsc-free (see the
666
- // import note above): the leaf takes createCacheScope as an INJECTED
667
- // dep so it never imports plugin-rsc; we hand it the lazily imported
668
- // builder here, only once a response route actually matched.
669
- const cacheScopeMod = await import("../cache/cache-scope.js");
670
- const { serveResponseRouteWithCache } =
671
- await import("../rsc/response-cache-serve.js");
672
- // requestContext is RequestContext<TEnv>; the leaf is typed against the
673
- // default-env RequestContext (it reads only env-agnostic config).
674
- // Assignable in the router's own tsc but not when a consumer pins a
675
- // concrete Env — cast to the leaf's param type.
676
- const cached = await serveResponseRouteWithCache({
677
- reqCtx: requestContext as Parameters<
678
- typeof serveResponseRouteWithCache
679
- >[0]["reqCtx"],
680
- manifestEntry,
681
- responseType: responseType as string,
682
- url,
683
- executeHandler,
684
- deps: { createCacheScope: cacheScopeMod.createCacheScope },
685
- });
686
- if (cached !== undefined) return cached;
687
- }
688
-
689
- return executeHandler().then(finalizeResponse);
690
- };
691
-
692
- // The renderStartMs deadline wraps ONLY the response-route unit, mirroring
693
- // handler.ts:845 where withTimeout(handleResponseRoute(...)) runs INSIDE
694
- // coreRequestHandler — after the global middleware chain has already
695
- // entered. Wrapping the outer chain (as an earlier version did) counted
696
- // global middleware time toward the deadline, so a slow global auth
697
- // middleware could 504 a request production serves. On timeout the 504/
698
- // onTimeout Response is RETURNED from coreHandler so it flows back out
699
- // through the global chain and finalizeResponse, identical to production
700
- // (handler.ts:857 returns handleTimeoutResponse into the same next() path).
701
- const runResponseRoute = async (
702
- pass: DispatchPass<TEnv>,
703
- ): Promise<Response> => {
704
- const { routeKey } = pass;
705
- const responseWork = callResponseRoute(pass);
706
- const renderOutcome = await withTimeout(
707
- responseWork,
708
- router.timeouts?.renderStartMs,
709
- "render-start",
710
- );
711
- if (!renderOutcome.timedOut) return renderOutcome.result;
712
-
713
- // The race's losing side keeps executing after the timeout wins; its
714
- // eventual Response is dropped. Swallow any rejection (no
715
- // unhandled-rejection surfacing) and cancel the orphaned body stream so,
716
- // under vitest, the abandoned handler cannot leak into the next test.
717
- // Production abandons it too (handler.ts:845); this is a runner-scoped
718
- // cleanup, not an observable-behavior change.
719
- void responseWork
720
- .then(
721
- (orphan) => orphan.body?.cancel(),
722
- () => {},
723
- )
724
- .catch(() => {});
725
-
726
- const durationMs = renderOutcome.durationMs;
727
- const timeoutError = new RouterTimeoutError("render-start", durationMs);
728
- // Field-for-field parity with handleTimeoutResponse's callOnError
729
- // (handler.ts:254): env is passed through (was previously dropped). render
730
- // is always absent — dispatch runs no Flight/HTML driver, so there is no
731
- // foreground cursor to snapshot.
732
- invokeOnError(
733
- router.onError,
734
- timeoutError,
735
- "handler",
736
- {
737
- request: req,
738
- url,
739
- env,
740
- routeKey,
741
- handledByBoundary: false,
742
- metadata: { timeout: true, phase: "render-start", durationMs },
743
- },
744
- "RSC",
745
- );
746
- if (sink) {
747
- safeEmit(resolveSink(sink), {
748
- type: "request.timeout",
749
- timestamp: performance.now(),
750
- requestId: telemetryRequestId,
751
- phase: "render-start",
752
- pathname: url.pathname,
753
- routeKey,
754
- durationMs,
755
- customHandler: router.onTimeout !== undefined,
756
- });
757
- }
758
- if (router.onTimeout) {
759
- try {
760
- return await router.onTimeout({
761
- phase: "render-start",
762
- request: req,
763
- url,
764
- env,
765
- routeKey,
766
- durationMs,
767
- });
768
- } catch (error) {
769
- if (process.env.NODE_ENV !== "production") {
770
- console.error("[dispatch] onTimeout callback error:", error);
771
- }
772
- return createDefaultTimeoutResponse("render-start");
773
- }
774
- }
775
- return createDefaultTimeoutResponse("render-start");
776
- };
777
-
778
- // coreHandler is the single terminal the global middleware chain wraps,
779
- // mirroring production's coreHandler (handler.ts): a trailing-slash/redirect
780
- // 308, an unmatched-path 404, or the response route. Both the 308 and the
781
- // 404 are produced via createResponseWithMergedHeaders so middleware-set
782
- // cookies/headers merge onto them, identical to production's
783
- // rsc-rendering.ts redirect path — and because they sit inside the chain, a
784
- // global middleware that short-circuits (e.g. an auth 401) runs first and
785
- // wins, never reaching the 308/404. The renderStartMs deadline lives inside
786
- // runResponseRoute (below the chain), never covering the 308/404 paths —
787
- // production only wraps the response-route plan (handler.ts:834-856).
788
- const coreHandler = async (pass: DispatchPass<TEnv>): Promise<Response> => {
789
- const { redirectTo, isUnmatched, url } = pass;
790
- if (redirectTo) {
791
- return createResponseWithMergedHeaders(null, {
792
- status: 308,
793
- headers: { Location: redirectTo + url.search },
794
- });
795
- }
796
- if (isUnmatched) {
797
- return createResponseWithMergedHeaders("Not Found", {
798
- status: 404,
799
- headers: { "content-type": "text/plain;charset=utf-8" },
800
- });
801
- }
802
- return runResponseRoute(pass);
803
- };
804
-
805
374
  // request.start opens the match transaction, mirroring match-handlers.ts.
806
375
  // transaction is always "match" (dispatch has no matchPartial split);
807
376
  // isPartial carries the ?_rsc_partial signal the same way production does.
@@ -818,168 +387,85 @@ export async function dispatch<TEnv = any>(
818
387
  }
819
388
 
820
389
  try {
821
- // Global (pattern-matched) middleware wraps coreHandler, exactly as
822
- // production wraps coreHandler with executeMiddleware (handler.ts:568).
823
- // The renderStartMs deadline is deliberately NOT applied here — it wraps
824
- // only the response-route unit inside coreHandler (see runResponseRoute),
825
- // mirroring handler.ts:845 so global middleware time is never counted
826
- // toward the deadline.
827
- // A throw rewrite() re-dispatches the same request context as the
828
- // target, through the production handler's pass loop
829
- // (rsc/rewrite-passes.ts). Each pass matches its URL and builds its own
830
- // core handler.
831
- let passIndex = 0;
832
- const { response: mwResponse, rewrittenTo } = await runRewritePasses({
833
- reqCtx: requestContext,
834
- request: req,
835
- url,
836
- routeMap: () => router.routeMap as Record<string, string>,
837
- basename: router.basename,
838
- runPass: async (passRequest) => {
839
- const pass =
840
- passIndex++ === 0
841
- ? firstPass
842
- : await matchDispatchPass(router, passRequest, env);
843
- recordPassParams(pass);
844
- const globalMatches = matchMiddleware(
845
- pass.url.pathname,
846
- router.middleware,
847
- );
848
- const passCore = (): Promise<Response> => coreHandler(pass);
849
- return globalMatches.length === 0
850
- ? passCore()
851
- : executeMiddleware<TEnv>(
852
- globalMatches,
853
- pass.req,
390
+ // The production handler's global middleware passes, terminal plans,
391
+ // response route and finalization (rsc/request-stages.ts).
392
+ const { response, rewrittenTo, hasMiddleware } =
393
+ await stages.runRequestPasses({
394
+ ctx,
395
+ reqCtx: requestContext,
396
+ request: req,
397
+ url,
398
+ env,
399
+ variables,
400
+ beforePass: async (passRequest, passUrl, index) => {
401
+ if (index > 0) {
402
+ plan = await classifyPass(stages, passRequest, passUrl, ctx);
403
+ }
404
+ },
405
+ // The core the global chain wraps, as the handler's
406
+ // coreRequestHandler: the terminal plans, then a findMatch()
407
+ // redirect's 308, the response route, or a 404. The 308 and 404 go
408
+ // through createResponseWithMergedHeaders, so middleware
409
+ // cookies/headers merge onto them as production's render path
410
+ // merges them.
411
+ core: async (passRequest, passUrl) => {
412
+ const terminal = stages.terminalPlanResponse(plan, passUrl, ctx);
413
+ if (terminal) return terminal;
414
+ const executable = plan as ExecutableRequestPlan<TEnv>;
415
+ stages.recordPlanRoute(executable);
416
+ if (executable.mode === "redirect") {
417
+ return createResponseWithMergedHeaders(null, {
418
+ status: 308,
419
+ headers: { Location: executable.redirectUrl },
420
+ });
421
+ }
422
+ if (executable.mode === "response") {
423
+ return stages.serveResponseRoute(ctx, {
424
+ match: stages.responseRouteMatch(executable),
425
+ routeKey: executable.route.routeKey,
426
+ request: passRequest,
427
+ url: passUrl,
854
428
  env,
855
429
  variables,
856
- passCore,
857
- reverse,
858
- );
859
- },
860
- report: (error, at) => {
861
- invokeOnError(
862
- router.onError,
863
- error,
864
- "routing",
865
- { request: at.request, url: at.url, env, handledByBoundary: false },
866
- "RSC",
867
- );
868
- },
869
- });
870
-
871
- // Match production's global-chain exit (handler.ts): on a partial/action
872
- // request a middleware 3xx redirect is converted to a Flight-safe response
873
- // so fetch() does not auto-follow it; every path then drains onResponse
874
- // callbacks via finalizeResponse. dispatch is RSC-free, so the
875
- // createRedirectFlightResponse stand-in falls back to the no-state
876
- // 204 + X-RSC-Redirect (see the location-state divergence in the header).
877
- let finalResponse: Response;
878
- if (isPartial || isAction) {
879
- const softOpts = {
880
- requestOrigin: url.origin,
881
- basename: router.basename,
882
- };
883
- // Pass `external` through: interceptRedirectForPartial forwards the
884
- // out-of-band brand as the third callback arg (helpers.ts). Dropping it
885
- // would neutralize redirect(url, { external: true }) to "/" in dispatch
886
- // while production preserves it via createRedirectFlightResponse.
887
- const intercepted = interceptRedirectForPartial(
888
- mwResponse,
889
- (redirectUrl, _locationState, external) =>
890
- createSimpleRedirectResponse(redirectUrl, {
891
- ...softOpts,
892
- external,
893
- }),
894
- softOpts,
895
- );
896
- finalResponse = finalizeResponse(intercepted ?? mwResponse);
897
- } else {
898
- finalResponse = finalizeResponse(mwResponse);
899
- }
900
- if (rewrittenTo !== undefined) {
901
- trySetHeader(finalResponse, REWRITE_HEADER, rewrittenTo);
902
- }
903
-
904
- // request.end closes the transaction. dispatch produces no RSC segments and
905
- // holds no match-cache state, so segmentCount/cacheHit are 0/false — the
906
- // same shape production emits for its own redirect (segment-less) result.
907
- if (sink) {
908
- safeEmit(resolveSink(sink), {
909
- type: "request.end",
910
- timestamp: performance.now(),
911
- requestId: telemetryRequestId,
912
- method: req.method,
913
- pathname: url.pathname,
914
- transaction: "match",
915
- durationMs: performance.now() - telemetryStart,
916
- segmentCount: 0,
917
- cacheHit: false,
918
- // dispatch's final response IS built before request.end (unlike
919
- // match()/matchPartial(), whose Response is built after), so stamp its
920
- // status — the same field a thrown-Response short-circuit carries.
921
- status: finalResponse.status,
922
- });
923
- }
924
-
925
- // Mirror production's single open-redirect chokepoint (handler.ts): every
926
- // browser-followed (3xx + Location) redirect is same-origin guarded before
927
- // it leaves -- a cross-origin Location is rewritten to the basename root
928
- // unless redirect(url, { external: true }) opted out. Soft partial/action
929
- // redirects are already resolved at createSimpleRedirectResponse time.
930
- const guardedResponse = guardOutgoingRedirect(
931
- finalResponse,
932
- url.origin,
933
- router.basename,
934
- );
935
-
936
- // Mirror production's stream-idle watchdog (handler.ts response tail),
937
- // same gating and reporting — dispatch is the userland dogfood surface
938
- // for timeouts.streamIdleMs, so the primitive carries the real wiring,
939
- // not a stub. Websocket check FIRST (an upgrade response's body getter
940
- // must never be poked); onTimeout does not apply mid-stream.
941
- const streamIdleMs = router.timeouts?.streamIdleMs;
942
- if (
943
- isTimeoutEnabled(streamIdleMs) &&
944
- !isWebSocketUpgradeResponse(guardedResponse) &&
945
- guardedResponse.body
946
- ) {
947
- return applyStreamIdleTimeout(
948
- guardedResponse,
949
- streamIdleMs!,
950
- (trip) => {
951
- invokeOnError(
952
- router.onError,
953
- trip.error,
954
- "handler",
955
- {
956
- request: req,
957
- url,
958
- env,
959
- handledByBoundary: false,
960
- metadata: {
961
- timeout: true,
962
- phase: "stream-idle",
963
- durationMs: trip.totalMs,
964
- },
965
- },
966
- "RSC",
967
- );
968
- if (sink) {
969
- safeEmit(resolveSink(sink), {
970
- type: "request.timeout",
971
- timestamp: performance.now(),
972
- requestId: telemetryRequestId,
973
- phase: "stream-idle",
974
- pathname: url.pathname,
975
- durationMs: trip.totalMs,
976
- customHandler: false,
430
+ abandon: abandonTimedOut,
977
431
  });
978
432
  }
433
+ return createResponseWithMergedHeaders("Not Found", {
434
+ status: 404,
435
+ headers: { "content-type": "text/plain;charset=utf-8" },
436
+ });
979
437
  },
980
- );
981
- }
982
- return guardedResponse;
438
+ });
439
+
440
+ return stages.finalizeRequestResponse(response, {
441
+ ctx,
442
+ reqCtx: requestContext,
443
+ request: req,
444
+ url,
445
+ env,
446
+ hasMiddleware,
447
+ rewrittenTo,
448
+ // request.end closes the transaction. dispatch produces no RSC
449
+ // segments and holds no match-cache state, so segmentCount/cacheHit
450
+ // are 0/false — the same shape production emits for its own redirect
451
+ // (segment-less) result. The response is built before request.end
452
+ // (unlike match()/matchPartial()), so it carries the status.
453
+ beforeGuard: (finalResponse) => {
454
+ if (!sink) return;
455
+ safeEmit(resolveSink(sink), {
456
+ type: "request.end",
457
+ timestamp: performance.now(),
458
+ requestId: telemetryRequestId,
459
+ method: req.method,
460
+ pathname: url.pathname,
461
+ transaction: "match",
462
+ durationMs: performance.now() - telemetryStart,
463
+ segmentCount: 0,
464
+ cacheHit: false,
465
+ status: finalResponse.status,
466
+ });
467
+ },
468
+ });
983
469
  } catch (error) {
984
470
  if (sink) {
985
471
  if (error instanceof Response) {
@@ -1020,3 +506,30 @@ export async function dispatch<TEnv = any>(
1020
506
  }
1021
507
  });
1022
508
  }
509
+
510
+ let requestStages: Promise<RequestStages> | undefined;
511
+
512
+ /**
513
+ * The request stages, loaded on the first dispatch so the testing barrel's
514
+ * eager graph stays what a suite that never dispatches loads. Neither graph
515
+ * loads @vitejs/plugin-rsc (__tests__/testing-module-graph.test.ts). Held
516
+ * after the first load: a dynamic import per dispatch costs a module-runner
517
+ * lookup.
518
+ */
519
+ function loadRequestStages(): Promise<RequestStages> {
520
+ return (requestStages ??= import("../rsc/request-stages.js"));
521
+ }
522
+
523
+ /**
524
+ * A timed-out response route's work keeps running. Swallow its rejection and
525
+ * cancel its body so, under a test runner, the abandoned handler cannot leak
526
+ * into the next test. The production handler leaves it running.
527
+ */
528
+ function abandonTimedOut(work: Promise<Response>): void {
529
+ void work
530
+ .then(
531
+ (orphan) => orphan.body?.cancel(),
532
+ () => {},
533
+ )
534
+ .catch(() => {});
535
+ }