@rangojs/router 0.0.0-experimental.259 → 0.0.0-experimental.26

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 (225) hide show
  1. package/README.md +294 -28
  2. package/dist/bin/rango.js +355 -47
  3. package/dist/vite/index.js +1658 -1239
  4. package/package.json +3 -3
  5. package/skills/cache-guide/SKILL.md +9 -5
  6. package/skills/caching/SKILL.md +4 -4
  7. package/skills/document-cache/SKILL.md +2 -2
  8. package/skills/hooks/SKILL.md +40 -29
  9. package/skills/host-router/SKILL.md +218 -0
  10. package/skills/intercept/SKILL.md +79 -0
  11. package/skills/layout/SKILL.md +62 -2
  12. package/skills/loader/SKILL.md +229 -15
  13. package/skills/middleware/SKILL.md +109 -30
  14. package/skills/parallel/SKILL.md +57 -2
  15. package/skills/prerender/SKILL.md +189 -19
  16. package/skills/rango/SKILL.md +1 -2
  17. package/skills/response-routes/SKILL.md +3 -3
  18. package/skills/route/SKILL.md +44 -3
  19. package/skills/router-setup/SKILL.md +80 -3
  20. package/skills/theme/SKILL.md +5 -4
  21. package/skills/typesafety/SKILL.md +59 -16
  22. package/skills/use-cache/SKILL.md +16 -2
  23. package/src/__internal.ts +1 -1
  24. package/src/bin/rango.ts +56 -19
  25. package/src/browser/action-coordinator.ts +97 -0
  26. package/src/browser/event-controller.ts +29 -48
  27. package/src/browser/history-state.ts +80 -0
  28. package/src/browser/intercept-utils.ts +1 -1
  29. package/src/browser/link-interceptor.ts +19 -3
  30. package/src/browser/merge-segment-loaders.ts +9 -2
  31. package/src/browser/navigation-bridge.ts +66 -443
  32. package/src/browser/navigation-client.ts +34 -62
  33. package/src/browser/navigation-store.ts +4 -33
  34. package/src/browser/navigation-transaction.ts +295 -0
  35. package/src/browser/partial-update.ts +103 -151
  36. package/src/browser/prefetch/cache.ts +67 -0
  37. package/src/browser/prefetch/fetch.ts +137 -0
  38. package/src/browser/prefetch/observer.ts +65 -0
  39. package/src/browser/prefetch/policy.ts +42 -0
  40. package/src/browser/prefetch/queue.ts +88 -0
  41. package/src/browser/rango-state.ts +112 -0
  42. package/src/browser/react/Link.tsx +154 -44
  43. package/src/browser/react/NavigationProvider.tsx +32 -0
  44. package/src/browser/react/context.ts +6 -0
  45. package/src/browser/react/filter-segment-order.ts +11 -0
  46. package/src/browser/react/index.ts +2 -6
  47. package/src/browser/react/location-state-shared.ts +29 -11
  48. package/src/browser/react/location-state.ts +6 -4
  49. package/src/browser/react/nonce-context.ts +23 -0
  50. package/src/browser/react/shallow-equal.ts +27 -0
  51. package/src/browser/react/use-action.ts +23 -45
  52. package/src/browser/react/use-client-cache.ts +5 -3
  53. package/src/browser/react/use-handle.ts +21 -64
  54. package/src/browser/react/use-navigation.ts +7 -32
  55. package/src/browser/react/use-params.ts +5 -34
  56. package/src/browser/react/use-pathname.ts +2 -3
  57. package/src/browser/react/use-router.ts +3 -6
  58. package/src/browser/react/use-search-params.ts +2 -1
  59. package/src/browser/react/use-segments.ts +75 -114
  60. package/src/browser/response-adapter.ts +73 -0
  61. package/src/browser/rsc-router.tsx +46 -22
  62. package/src/browser/scroll-restoration.ts +10 -7
  63. package/src/browser/server-action-bridge.ts +458 -405
  64. package/src/browser/types.ts +21 -35
  65. package/src/browser/validate-redirect-origin.ts +29 -0
  66. package/src/build/generate-manifest.ts +38 -13
  67. package/src/build/generate-route-types.ts +4 -0
  68. package/src/build/index.ts +1 -0
  69. package/src/build/route-trie.ts +19 -3
  70. package/src/build/route-types/codegen.ts +13 -4
  71. package/src/build/route-types/include-resolution.ts +13 -0
  72. package/src/build/route-types/per-module-writer.ts +15 -3
  73. package/src/build/route-types/router-processing.ts +170 -18
  74. package/src/build/runtime-discovery.ts +13 -1
  75. package/src/cache/background-task.ts +34 -0
  76. package/src/cache/cache-key-utils.ts +44 -0
  77. package/src/cache/cache-policy.ts +125 -0
  78. package/src/cache/cache-runtime.ts +136 -123
  79. package/src/cache/cache-scope.ts +76 -83
  80. package/src/cache/cf/cf-cache-store.ts +12 -7
  81. package/src/cache/document-cache.ts +93 -69
  82. package/src/cache/handle-capture.ts +81 -0
  83. package/src/cache/index.ts +0 -15
  84. package/src/cache/memory-segment-store.ts +43 -69
  85. package/src/cache/profile-registry.ts +43 -8
  86. package/src/cache/read-through-swr.ts +134 -0
  87. package/src/cache/segment-codec.ts +140 -117
  88. package/src/cache/taint.ts +30 -3
  89. package/src/cache/types.ts +1 -115
  90. package/src/client.rsc.tsx +0 -1
  91. package/src/client.tsx +53 -76
  92. package/src/errors.ts +6 -1
  93. package/src/handle.ts +1 -1
  94. package/src/handles/MetaTags.tsx +5 -2
  95. package/src/host/cookie-handler.ts +8 -3
  96. package/src/host/index.ts +0 -3
  97. package/src/host/router.ts +14 -1
  98. package/src/href-client.ts +3 -1
  99. package/src/index.rsc.ts +53 -10
  100. package/src/index.ts +73 -43
  101. package/src/loader.rsc.ts +12 -4
  102. package/src/loader.ts +8 -0
  103. package/src/prerender/store.ts +60 -18
  104. package/src/prerender.ts +76 -18
  105. package/src/reverse.ts +11 -7
  106. package/src/root-error-boundary.tsx +30 -26
  107. package/src/route-definition/dsl-helpers.ts +9 -6
  108. package/src/route-definition/index.ts +0 -3
  109. package/src/route-definition/redirect.ts +15 -3
  110. package/src/route-map-builder.ts +38 -2
  111. package/src/route-name.ts +53 -0
  112. package/src/route-types.ts +7 -0
  113. package/src/router/content-negotiation.ts +1 -1
  114. package/src/router/debug-manifest.ts +16 -3
  115. package/src/router/handler-context.ts +96 -17
  116. package/src/router/intercept-resolution.ts +6 -4
  117. package/src/router/lazy-includes.ts +4 -0
  118. package/src/router/loader-resolution.ts +6 -11
  119. package/src/router/logging.ts +100 -3
  120. package/src/router/manifest.ts +32 -3
  121. package/src/router/match-api.ts +62 -54
  122. package/src/router/match-context.ts +3 -0
  123. package/src/router/match-handlers.ts +185 -11
  124. package/src/router/match-middleware/background-revalidation.ts +65 -85
  125. package/src/router/match-middleware/cache-lookup.ts +78 -10
  126. package/src/router/match-middleware/cache-store.ts +2 -0
  127. package/src/router/match-pipelines.ts +8 -43
  128. package/src/router/match-result.ts +0 -9
  129. package/src/router/metrics.ts +233 -13
  130. package/src/router/middleware-types.ts +34 -39
  131. package/src/router/middleware.ts +290 -130
  132. package/src/router/pattern-matching.ts +61 -10
  133. package/src/router/prerender-match.ts +36 -6
  134. package/src/router/preview-match.ts +7 -1
  135. package/src/router/revalidation.ts +61 -2
  136. package/src/router/router-context.ts +15 -0
  137. package/src/router/router-interfaces.ts +158 -40
  138. package/src/router/router-options.ts +223 -1
  139. package/src/router/router-registry.ts +5 -2
  140. package/src/router/segment-resolution/fresh.ts +165 -242
  141. package/src/router/segment-resolution/helpers.ts +263 -0
  142. package/src/router/segment-resolution/loader-cache.ts +102 -98
  143. package/src/router/segment-resolution/revalidation.ts +394 -272
  144. package/src/router/segment-resolution/static-store.ts +2 -2
  145. package/src/router/segment-resolution.ts +1 -3
  146. package/src/router/segment-wrappers.ts +3 -0
  147. package/src/router/telemetry-otel.ts +299 -0
  148. package/src/router/telemetry.ts +300 -0
  149. package/src/router/timeout.ts +148 -0
  150. package/src/router/trie-matching.ts +20 -2
  151. package/src/router/types.ts +7 -1
  152. package/src/router.ts +203 -18
  153. package/src/rsc/handler-context.ts +13 -2
  154. package/src/rsc/handler.ts +489 -438
  155. package/src/rsc/helpers.ts +125 -5
  156. package/src/rsc/index.ts +0 -20
  157. package/src/rsc/loader-fetch.ts +84 -42
  158. package/src/rsc/manifest-init.ts +3 -2
  159. package/src/rsc/origin-guard.ts +141 -0
  160. package/src/rsc/progressive-enhancement.ts +245 -19
  161. package/src/rsc/response-route-handler.ts +347 -0
  162. package/src/rsc/rsc-rendering.ts +47 -43
  163. package/src/rsc/runtime-warnings.ts +42 -0
  164. package/src/rsc/server-action.ts +166 -66
  165. package/src/rsc/ssr-setup.ts +128 -0
  166. package/src/rsc/types.ts +20 -2
  167. package/src/search-params.ts +38 -23
  168. package/src/server/context.ts +61 -7
  169. package/src/server/cookie-store.ts +190 -0
  170. package/src/server/fetchable-loader-store.ts +11 -6
  171. package/src/server/handle-store.ts +84 -12
  172. package/src/server/loader-registry.ts +11 -46
  173. package/src/server/request-context.ts +275 -49
  174. package/src/server.ts +6 -0
  175. package/src/ssr/index.tsx +67 -28
  176. package/src/static-handler.ts +7 -0
  177. package/src/theme/ThemeProvider.tsx +6 -1
  178. package/src/theme/index.ts +4 -18
  179. package/src/theme/theme-context.ts +1 -28
  180. package/src/theme/theme-script.ts +2 -1
  181. package/src/types/cache-types.ts +6 -1
  182. package/src/types/error-types.ts +3 -0
  183. package/src/types/global-namespace.ts +22 -0
  184. package/src/types/handler-context.ts +103 -16
  185. package/src/types/index.ts +1 -1
  186. package/src/types/loader-types.ts +9 -6
  187. package/src/types/route-config.ts +17 -26
  188. package/src/types/route-entry.ts +28 -0
  189. package/src/types/segments.ts +0 -5
  190. package/src/urls/include-helper.ts +49 -8
  191. package/src/urls/index.ts +1 -0
  192. package/src/urls/path-helper-types.ts +30 -12
  193. package/src/urls/path-helper.ts +17 -2
  194. package/src/urls/pattern-types.ts +21 -1
  195. package/src/urls/response-types.ts +29 -7
  196. package/src/urls/type-extraction.ts +23 -15
  197. package/src/use-loader.tsx +27 -9
  198. package/src/vite/discovery/bundle-postprocess.ts +32 -52
  199. package/src/vite/discovery/discover-routers.ts +52 -26
  200. package/src/vite/discovery/prerender-collection.ts +58 -41
  201. package/src/vite/discovery/route-types-writer.ts +7 -7
  202. package/src/vite/discovery/state.ts +7 -7
  203. package/src/vite/discovery/virtual-module-codegen.ts +5 -2
  204. package/src/vite/index.ts +10 -51
  205. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  206. package/src/vite/plugins/client-ref-hashing.ts +3 -3
  207. package/src/vite/plugins/expose-internal-ids.ts +4 -3
  208. package/src/vite/plugins/refresh-cmd.ts +65 -0
  209. package/src/vite/plugins/use-cache-transform.ts +91 -3
  210. package/src/vite/plugins/version-plugin.ts +188 -18
  211. package/src/vite/rango.ts +61 -36
  212. package/src/vite/router-discovery.ts +173 -100
  213. package/src/vite/utils/prerender-utils.ts +81 -0
  214. package/src/vite/utils/shared-utils.ts +19 -9
  215. package/skills/testing/SKILL.md +0 -226
  216. package/src/browser/lru-cache.ts +0 -61
  217. package/src/browser/react/prefetch.ts +0 -27
  218. package/src/browser/request-controller.ts +0 -164
  219. package/src/cache/memory-store.ts +0 -253
  220. package/src/href-context.ts +0 -33
  221. package/src/route-definition/route-function.ts +0 -119
  222. package/src/router.gen.ts +0 -6
  223. package/src/static-handler.gen.ts +0 -5
  224. package/src/urls.gen.ts +0 -8
  225. /package/{CLAUDE.md → AGENTS.md} +0 -0
@@ -8,33 +8,44 @@
8
8
  */
9
9
 
10
10
  import { createElement } from "react";
11
- import { RouteNotFoundError, RouterError } from "../errors.js";
11
+ import { RouteNotFoundError } from "../errors.js";
12
12
  import { matchMiddleware, executeMiddleware } from "../router/middleware.js";
13
13
  import {
14
14
  runWithRequestContext,
15
15
  setRequestContextParams,
16
16
  requireRequestContext,
17
17
  createRequestContext,
18
- getLocationState,
19
18
  } from "../server/request-context.js";
20
- import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
21
19
  import * as rscDeps from "@vitejs/plugin-rsc/rsc";
22
20
 
23
- import type { RscPayload, CreateRSCHandlerOptions } from "./types.js";
21
+ import type {
22
+ RscPayload,
23
+ CreateRSCHandlerOptions,
24
+ LoadSSRModule,
25
+ SSRModule,
26
+ } from "./types.js";
24
27
  import {
25
28
  createResponseWithMergedHeaders,
26
- createSimpleRedirectResponse,
29
+ finalizeResponse,
30
+ interceptRedirectForPartial,
31
+ buildRouteMiddlewareEntries,
27
32
  } from "./helpers.js";
33
+ import {
34
+ handleResponseRoute,
35
+ type ResponseRouteMatch,
36
+ } from "./response-route-handler.js";
28
37
  import { generateNonce, nonce as nonceToken } from "./nonce.js";
29
38
  import { VERSION } from "@rangojs/router:version";
30
39
  import type { ErrorPhase } from "../types.js";
31
40
  import type { RouterRequestInput } from "../router/router-interfaces.js";
32
41
  import { invokeOnError } from "../router/error-handling.js";
33
- import { createReverseFunction } from "../router/handler-context.js";
34
- import { contextGet, contextSet } from "../context-var.js";
35
- import { NOCACHE_SYMBOL } from "../cache/taint.js";
36
- import { traverseBack } from "../router/pattern-matching.js";
37
- import { createCacheScope } from "../cache/cache-scope.js";
42
+ import {
43
+ createReverseFunction,
44
+ stripInternalParams,
45
+ } from "../router/handler-context.js";
46
+ import { getRouterContext } from "../router/router-context.js";
47
+ import { resolveSink, safeEmit } from "../router/telemetry.js";
48
+ import { contextSet } from "../context-var.js";
38
49
  import {
39
50
  hasCachedManifest,
40
51
  getRouteTrie,
@@ -44,12 +55,33 @@ import {
44
55
  getRouterTrie,
45
56
  } from "../route-map-builder.js";
46
57
  import type { HandlerContext } from "./handler-context.js";
47
- import { createResponseErrorPayload } from "./response-error.js";
48
58
  import { buildRouterTrieFromUrlpatterns } from "./manifest-init.js";
49
59
  import { handleProgressiveEnhancement } from "./progressive-enhancement.js";
50
- import { handleServerAction } from "./server-action.js";
60
+ import {
61
+ executeServerAction,
62
+ revalidateAfterAction,
63
+ type ActionContinuation,
64
+ } from "./server-action.js";
51
65
  import { handleLoaderFetch } from "./loader-fetch.js";
66
+ import { checkRequestOrigin, type OriginCheckPhase } from "./origin-guard.js";
52
67
  import { handleRscRendering } from "./rsc-rendering.js";
68
+ import {
69
+ withTimeout,
70
+ RouterTimeoutError,
71
+ createDefaultTimeoutResponse,
72
+ type TimeoutPhase,
73
+ } from "../router/timeout.js";
74
+ import {
75
+ createMetricsStore,
76
+ appendMetric,
77
+ buildMetricsTiming,
78
+ } from "../router/metrics.js";
79
+ import {
80
+ startSSRSetup,
81
+ getSSRSetup,
82
+ mayNeedSSR,
83
+ SSR_SETUP_VAR,
84
+ } from "./ssr-setup.js";
53
85
 
54
86
  /**
55
87
  * Create an RSC request handler.
@@ -84,13 +116,6 @@ import { handleRscRendering } from "./rsc-rendering.js";
84
116
  * });
85
117
  * ```
86
118
  */
87
- // Only cache successful responses. Non-200 statuses (errors, redirects) are
88
- // not cached — notFound() produces 500 in response routes, and explicit
89
- // non-200 Responses are rare enough that caching them would be surprising.
90
- function isCacheableStatus(status: number): boolean {
91
- return status === 200;
92
- }
93
-
94
119
  export function createRSCHandler<
95
120
  TEnv = unknown,
96
121
  TRoutes extends Record<string, string> = Record<string, string>,
@@ -108,18 +133,28 @@ export function createRSCHandler<
108
133
  decodeFormState,
109
134
  } = deps;
110
135
 
111
- // Use provided loadSSRModule or default to vite RSC module loader
112
- const loadSSRModule =
136
+ // Use provided loadSSRModule or default to vite RSC module loader.
137
+ // In production the SSR module is stable across requests, so memoize
138
+ // the dynamic import to avoid repeated module resolution overhead.
139
+ // In dev mode Vite may hot-reload the module, so skip memoization.
140
+ const rawLoadSSRModule: LoadSSRModule =
113
141
  options.loadSSRModule ??
114
142
  (() => import.meta.viteRsc.loadModule("ssr", "index"));
115
-
116
- // Track errors already reported to onError to prevent double-reporting
117
- // when errors are caught by a phase-specific handler and re-thrown.
118
- const reportedErrors = new WeakSet<object>();
143
+ let _ssrModulePromise: Promise<SSRModule> | undefined;
144
+ const loadSSRModule: LoadSSRModule =
145
+ process.env.NODE_ENV === "production"
146
+ ? () =>
147
+ (_ssrModulePromise ??= rawLoadSSRModule().catch((err) => {
148
+ _ssrModulePromise = undefined;
149
+ throw err;
150
+ }))
151
+ : rawLoadSSRModule;
119
152
 
120
153
  /**
121
- * Wrapper for invokeOnError that binds the router's onError callback.
122
- * Uses the shared utility from router/error-handling.ts for consistent behavior.
154
+ * Per-request error reporter that deduplicates via the ALS request context.
155
+ *
156
+ * Uses the same _reportedErrors WeakSet as the router layer so errors
157
+ * that propagate across layers are only reported once per request.
123
158
  */
124
159
  function callOnError(
125
160
  error: unknown,
@@ -127,6 +162,7 @@ export function createRSCHandler<
127
162
  context: Parameters<typeof invokeOnError<TEnv>>[3],
128
163
  ): void {
129
164
  if (error != null && typeof error === "object") {
165
+ const reportedErrors = requireRequestContext()._reportedErrors;
130
166
  if (reportedErrors.has(error)) return;
131
167
  reportedErrors.add(error);
132
168
  }
@@ -143,6 +179,72 @@ export function createRSCHandler<
143
179
  return routeMap;
144
180
  }
145
181
 
182
+ /**
183
+ * Handle a timeout by reporting the error, emitting telemetry,
184
+ * and returning either the custom onTimeout response or a default 504.
185
+ */
186
+ async function handleTimeoutResponse(
187
+ request: Request,
188
+ env: TEnv,
189
+ url: URL,
190
+ phase: TimeoutPhase,
191
+ durationMs: number,
192
+ routeKey?: string,
193
+ actionId?: string,
194
+ ): Promise<Response> {
195
+ const timeoutError = new RouterTimeoutError(phase, durationMs);
196
+
197
+ callOnError(timeoutError, phase === "action" ? "action" : "handler", {
198
+ request,
199
+ url,
200
+ env,
201
+ routeKey,
202
+ actionId,
203
+ handledByBoundary: false,
204
+ metadata: { timeout: true, phase, durationMs },
205
+ });
206
+
207
+ try {
208
+ const routerCtx = getRouterContext();
209
+ if (routerCtx?.telemetry) {
210
+ safeEmit(resolveSink(routerCtx.telemetry), {
211
+ type: "request.timeout" as const,
212
+ timestamp: performance.now(),
213
+ requestId: routerCtx.requestId,
214
+ phase,
215
+ pathname: url.pathname,
216
+ routeKey,
217
+ actionId,
218
+ durationMs,
219
+ customHandler: !!router.onTimeout,
220
+ });
221
+ }
222
+ } catch {
223
+ // Router context may not be available
224
+ }
225
+
226
+ if (router.onTimeout) {
227
+ try {
228
+ return await router.onTimeout({
229
+ phase,
230
+ request,
231
+ url,
232
+ env,
233
+ routeKey,
234
+ actionId,
235
+ durationMs,
236
+ });
237
+ } catch (e) {
238
+ if (process.env.NODE_ENV !== "production") {
239
+ console.error("[RSC] onTimeout callback error:", e);
240
+ }
241
+ return createDefaultTimeoutResponse(phase);
242
+ }
243
+ }
244
+
245
+ return createDefaultTimeoutResponse(phase);
246
+ }
247
+
146
248
  /**
147
249
  * Build a 200 Flight response that carries a redirect URL and optional state.
148
250
  * Used when a partial/action request results in a redirect -- fetch
@@ -167,7 +269,8 @@ export function createRSCHandler<
167
269
  });
168
270
  }
169
271
 
170
- // Bundle shared dependencies for extracted handler functions
272
+ // Bundle shared dependencies for extracted handler functions.
273
+ // callOnError reads from ALS so it's inherently per-request scoped.
171
274
  const handlerCtx: HandlerContext<TEnv> = {
172
275
  router,
173
276
  version,
@@ -181,6 +284,11 @@ export function createRSCHandler<
181
284
  callOnError,
182
285
  getRequiredRouteMap,
183
286
  createRedirectFlightResponse,
287
+ resolveStreamMode: async (request, env, url) => {
288
+ const resolver = router.ssr?.resolveStreaming;
289
+ if (!resolver) return "stream";
290
+ return resolver({ request, env, url });
291
+ },
184
292
  };
185
293
 
186
294
  return async function handler(
@@ -188,6 +296,11 @@ export function createRSCHandler<
188
296
  input: RouterRequestInput<TEnv> = {},
189
297
  ): Promise<Response> {
190
298
  const handlerStart = performance.now();
299
+ // Create the metrics store at handler start so handler:total has startTime=0
300
+ // and all metrics are relative to the request entry point.
301
+ const earlyMetricsStore = router.debugPerformance
302
+ ? createMetricsStore(true, handlerStart)
303
+ : undefined;
191
304
 
192
305
  const { env = {} as TEnv, vars: initialVars, ctx: executionCtx } = input;
193
306
 
@@ -287,9 +400,6 @@ export function createRSCHandler<
287
400
  }
288
401
  const manifestCacheDur = performance.now() - manifestCacheStart;
289
402
 
290
- // Note: Route map for useHref() is loaded lazily via getGlobalRouteMap()
291
- // This allows it to include all routes from lazy includes after manifest loading
292
-
293
403
  // Create unified request context with all methods
294
404
  // Includes: stub response, handle store, loader memoization, use(), cookies, headers, cache store
295
405
  // params starts empty, populated after route matching via setRequestContextParams
@@ -300,9 +410,27 @@ export function createRSCHandler<
300
410
  url,
301
411
  variables,
302
412
  cacheStore,
413
+ cacheProfiles: router.cacheProfiles,
303
414
  executionContext: executionCtx,
304
415
  themeConfig: router.themeConfig,
305
416
  });
417
+ if (earlyMetricsStore) {
418
+ requestContext._debugPerformance = true;
419
+ requestContext._metricsStore = earlyMetricsStore;
420
+ }
421
+ // Wire background error reporting so "use cache" and other subsystems
422
+ // can surface non-fatal errors through the router's onError callback.
423
+ requestContext._reportBackgroundError = (
424
+ error: unknown,
425
+ category: string,
426
+ ) => {
427
+ callOnError(error, "cache", {
428
+ request,
429
+ url,
430
+ metadata: { category },
431
+ });
432
+ };
433
+
306
434
  const ctxCreateDur = performance.now() - ctxCreateStart;
307
435
 
308
436
  // Accumulate handler-level timing for Server-Timing header
@@ -331,6 +459,7 @@ export function createRSCHandler<
331
459
  };
332
460
 
333
461
  // Execute middleware chain if any, otherwise call core handler directly
462
+ let response: Response;
334
463
  if (matchedMiddleware.length > 0) {
335
464
  const mwResponse = await executeMiddleware(
336
465
  matchedMiddleware,
@@ -341,30 +470,60 @@ export function createRSCHandler<
341
470
  createReverseFunction(getRequiredRouteMap()),
342
471
  );
343
472
 
344
- // If global middleware returned a redirect during a partial (SPA)
345
- // request, intercept it. fetch auto-follows 3xx, so we must signal
346
- // the redirect via our own mechanism instead.
347
- // - With state: Flight payload (200) so location state survives.
348
- // - Without state: 204 + X-RSC-Redirect header (lightweight).
349
- const isPartial = url.searchParams.has("_rsc_partial");
350
- const redirectUrl = mwResponse.headers.get("Location");
351
- const isRedirect =
352
- mwResponse.status >= 300 && mwResponse.status < 400 && redirectUrl;
353
- if (isPartial && isRedirect) {
354
- const locationState = getLocationState();
355
- if (locationState) {
356
- return createRedirectFlightResponse(
357
- redirectUrl,
358
- resolveLocationStateEntries(locationState),
359
- );
360
- }
361
- return createSimpleRedirectResponse(redirectUrl);
473
+ if (
474
+ url.searchParams.has("_rsc_partial") ||
475
+ url.searchParams.has("_rsc_action")
476
+ ) {
477
+ const intercepted = interceptRedirectForPartial(
478
+ mwResponse,
479
+ createRedirectFlightResponse,
480
+ );
481
+ response = intercepted ?? finalizeResponse(mwResponse);
482
+ } else {
483
+ response = finalizeResponse(mwResponse);
362
484
  }
485
+ } else {
486
+ response = await coreHandler();
487
+ }
363
488
 
364
- return mwResponse;
489
+ // Finalize metrics after all middleware (including post-next work)
490
+ // has completed so :post spans are captured in the timeline.
491
+ // Handler timing parts are always emitted (even without debug metrics)
492
+ // so non-debug requests still get bootstrap Server-Timing entries.
493
+ const handlerTimingArr: string[] = variables.__handlerTiming || [];
494
+ // Preserve any existing Server-Timing set by response routes or middleware
495
+ const existingTiming = response.headers.get("Server-Timing");
496
+ const timingParts = existingTiming
497
+ ? [existingTiming, ...handlerTimingArr]
498
+ : [...handlerTimingArr];
499
+
500
+ const metricsStore = requestContext._metricsStore;
501
+ if (metricsStore) {
502
+ // When the store was created at handler start (earlyMetricsStore),
503
+ // handler:total covers the full request. When ctx.debugPerformance()
504
+ // created the store mid-request, use its requestStart to avoid a
505
+ // negative startTime offset.
506
+ const totalStart = earlyMetricsStore
507
+ ? handlerStart
508
+ : metricsStore.requestStart;
509
+ appendMetric(
510
+ metricsStore,
511
+ "handler:total",
512
+ totalStart,
513
+ performance.now() - totalStart,
514
+ );
515
+ const metricsTiming = buildMetricsTiming(
516
+ request.method,
517
+ url.pathname,
518
+ metricsStore,
519
+ );
520
+ if (metricsTiming) timingParts.push(metricsTiming);
365
521
  }
366
522
 
367
- return coreHandler();
523
+ const fullTiming = timingParts.join(", ");
524
+ if (fullTiming) response.headers.set("Server-Timing", fullTiming);
525
+
526
+ return response;
368
527
  });
369
528
  };
370
529
 
@@ -376,7 +535,6 @@ export function createRSCHandler<
376
535
  variables: Record<string, any>,
377
536
  nonce: string | undefined,
378
537
  ): Promise<Response> {
379
- // First, check for route-level middleware
380
538
  const previewStart = performance.now();
381
539
  const preview = await router.previewMatch(request, { env });
382
540
  const previewDur = performance.now() - previewStart;
@@ -384,286 +542,224 @@ export function createRSCHandler<
384
542
  handlerTiming.push(`handler-preview-match;dur=${previewDur.toFixed(2)}`);
385
543
  // Response route short-circuit: skip entire RSC pipeline
386
544
  if (preview?.responseType && preview.handler) {
387
- const isPartial = url.searchParams.has("_rsc_partial");
388
-
389
- // Partial requests (client-side navigation) to response routes
390
- // get X-RSC-Reload to trigger hard navigation in the browser
391
- if (isPartial) {
392
- const cleanUrl = new URL(url);
393
- cleanUrl.searchParams.delete("_rsc_partial");
394
- cleanUrl.searchParams.delete("_rsc_segments");
395
- cleanUrl.searchParams.delete("_rsc_v");
396
- cleanUrl.searchParams.delete("_rsc_stale");
397
- cleanUrl.searchParams.delete("_rsc_action");
398
- cleanUrl.searchParams.delete("_rsc_prev");
399
-
400
- return createResponseWithMergedHeaders(null, {
401
- status: 200,
402
- headers: {
403
- "X-RSC-Reload": cleanUrl.toString(),
404
- "content-type": "text/x-component;charset=utf-8",
405
- },
406
- });
545
+ const responseOutcome = await withTimeout(
546
+ handleResponseRoute(
547
+ handlerCtx,
548
+ preview as ResponseRouteMatch,
549
+ request,
550
+ env,
551
+ url,
552
+ variables,
553
+ ),
554
+ router.timeouts.renderStartMs,
555
+ "render-start",
556
+ );
557
+ if (responseOutcome.timedOut) {
558
+ return handleTimeoutResponse(
559
+ request,
560
+ env,
561
+ url,
562
+ "render-start",
563
+ responseOutcome.durationMs,
564
+ preview?.routeKey,
565
+ );
407
566
  }
567
+ return responseOutcome.result;
568
+ }
408
569
 
409
- // Build lightweight context for response handler
410
- const reqCtx = requireRequestContext();
411
- const responseHandlerCtx = {
570
+ // Kick off SSR module loading + stream mode resolution in parallel with
571
+ // segment resolution. Placed after the response-route short-circuit so
572
+ // response/mime routes never pay for SSR work.
573
+ if (mayNeedSSR(request, url)) {
574
+ variables[SSR_SETUP_VAR] = startSSRSetup(
575
+ handlerCtx,
412
576
  request,
413
- params: preview.params || {},
414
577
  env,
415
- searchParams: url.searchParams,
416
578
  url,
417
- pathname: url.pathname,
418
- href: (name: string, hrefParams?: Record<string, string>) => {
419
- if (name.startsWith("/")) {
420
- if (!hrefParams) return name;
421
- return name.replace(/:([^/]+)/g, (_, key) => {
422
- const value = hrefParams[key];
423
- if (value === undefined)
424
- throw new Error(`Missing param "${key}" for path "${name}"`);
425
- return encodeURIComponent(value);
579
+ router.debugPerformance
580
+ ? () => requireRequestContext()._metricsStore
581
+ : undefined,
582
+ );
583
+ }
584
+
585
+ const routeReverse = createReverseFunction(getRequiredRouteMap());
586
+
587
+ const isAction =
588
+ request.headers.has("rsc-action") || url.searchParams.has("_rsc_action");
589
+ const isLoaderFetch = url.searchParams.has("_rsc_loader");
590
+ const actionId =
591
+ request.headers.get("rsc-action") || url.searchParams.get("_rsc_action");
592
+
593
+ // Origin guard: reject cross-origin actions, loader fetches, and
594
+ // PE form submissions before any execution. Regular page navigations
595
+ // (GET without _rsc_loader/_rsc_action) are not affected.
596
+ const originPhase: OriginCheckPhase | null = isAction
597
+ ? "action"
598
+ : isLoaderFetch
599
+ ? "loader"
600
+ : request.method === "POST"
601
+ ? "pe-form"
602
+ : null;
603
+ if (originPhase) {
604
+ const originResult = await checkRequestOrigin(
605
+ request,
606
+ url,
607
+ router.originCheck,
608
+ env,
609
+ router.id,
610
+ originPhase,
611
+ );
612
+ if (originResult) {
613
+ const originError = new Error(
614
+ `Origin check rejected: ${request.headers.get("origin") ?? "none"} vs ${request.headers.get("host") ?? "none"}`,
615
+ );
616
+ originError.name = "OriginCheckError";
617
+
618
+ callOnError(originError, "origin", {
619
+ request,
620
+ url,
621
+ env,
622
+ handledByBoundary: false,
623
+ metadata: {
624
+ phase: originPhase,
625
+ origin: request.headers.get("origin"),
626
+ host: request.headers.get("host"),
627
+ },
628
+ });
629
+
630
+ try {
631
+ const routerCtx = getRouterContext();
632
+ if (routerCtx?.telemetry) {
633
+ safeEmit(resolveSink(routerCtx.telemetry), {
634
+ type: "request.origin-rejected" as const,
635
+ timestamp: performance.now(),
636
+ requestId: routerCtx.requestId,
637
+ method: request.method,
638
+ pathname: url.pathname,
639
+ phase: originPhase,
640
+ origin: request.headers.get("origin"),
641
+ host: request.headers.get("host"),
426
642
  });
427
643
  }
428
- return name;
429
- },
430
- get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
431
- header: (name: string, value: string) => reqCtx.header(name, value),
432
- setCookie: (name: string, value: string, options?: any) =>
433
- reqCtx.setCookie(name, value, options),
434
- _responseType: preview.responseType,
435
- };
436
- // Brand with taint symbol so "use cache" detects it as request-scoped
437
- // and extracts route-identifying properties (params, pathname, _responseType)
438
- (responseHandlerCtx as any)[NOCACHE_SYMBOL] = true;
439
-
440
- // Call handler directly, wrapped by route middleware if present
441
- const callHandler = async () => {
442
- // JSON response routes: wrap in { data } / { error } envelope
443
- if (preview.responseType === "json") {
444
- const errorCtx = { request, url, env };
445
- try {
446
- const result = await (preview.handler as Function)(
447
- responseHandlerCtx,
448
- );
449
- if (result instanceof Response) {
450
- const mergedHeaders: Record<string, string> = {};
451
- result.headers.forEach((value, key) => {
452
- mergedHeaders[key] = value;
453
- });
454
- return createResponseWithMergedHeaders(result.body, {
455
- status: result.status,
456
- headers: mergedHeaders,
457
- });
458
- }
459
- return createResponseWithMergedHeaders(
460
- JSON.stringify({ data: result }),
461
- {
462
- status: 200,
463
- headers: { "content-type": "application/json;charset=utf-8" },
464
- },
465
- );
466
- } catch (error) {
467
- callOnError(error, "handler", errorCtx);
468
- const isDev = process.env.NODE_ENV !== "production";
469
- const status = error instanceof RouterError ? error.status : 500;
470
- return createResponseWithMergedHeaders(
471
- JSON.stringify({
472
- error: createResponseErrorPayload(error, isDev),
473
- }),
474
- {
475
- status,
476
- headers: { "content-type": "application/json;charset=utf-8" },
477
- },
478
- );
479
- }
644
+ } catch {
645
+ // Router context may not be available
480
646
  }
481
647
 
482
- // Non-JSON response routes: catch errors and return plain Response
483
- const errorCtx = { request, url, env };
484
- try {
485
- const result = await (preview.handler as Function)(
486
- responseHandlerCtx,
487
- );
648
+ return originResult;
649
+ }
650
+ }
488
651
 
489
- if (result instanceof Response) {
490
- // Handler returned a Response directly -- pass through
491
- const mergedHeaders: Record<string, string> = {};
492
- result.headers.forEach((value, key) => {
493
- mergedHeaders[key] = value;
494
- });
495
- return createResponseWithMergedHeaders(result.body, {
496
- status: result.status,
497
- headers: mergedHeaders,
498
- });
499
- }
652
+ // Get handle store from request context
653
+ const handleStore = requireRequestContext()._handleStore;
500
654
 
501
- // Auto-wrap based on response type tag
502
- switch (preview.responseType) {
503
- case "text":
504
- return createResponseWithMergedHeaders(String(result), {
505
- status: 200,
506
- headers: { "content-type": "text/plain;charset=utf-8" },
507
- });
508
- case "html":
509
- return createResponseWithMergedHeaders(String(result), {
510
- status: 200,
511
- headers: { "content-type": "text/html;charset=utf-8" },
512
- });
513
- case "xml":
514
- return createResponseWithMergedHeaders(String(result), {
515
- status: 200,
516
- headers: { "content-type": "application/xml;charset=utf-8" },
517
- });
518
- case "md":
519
- return createResponseWithMergedHeaders(String(result), {
520
- status: 200,
521
- headers: { "content-type": "text/markdown;charset=utf-8" },
522
- });
523
- default:
524
- // image, stream, any -- must return Response
525
- throw new Error(
526
- `Response route handler for "${preview.responseType}" must return a Response object, got ${typeof result}`,
527
- );
528
- }
529
- } catch (error) {
530
- callOnError(error, "handler", errorCtx);
531
- const isDev = process.env.NODE_ENV !== "production";
532
- const status = error instanceof RouterError ? error.status : 500;
533
- const message =
534
- error instanceof RouterError
535
- ? error.message
536
- : isDev && error instanceof Error
537
- ? error.message
538
- : "Internal Server Error";
539
- return createResponseWithMergedHeaders(message, {
540
- status,
541
- headers: { "content-type": "text/plain;charset=utf-8" },
655
+ // Wire up error reporting for late streaming-handle failures
656
+ // (LateHandlePushError: handle pushed after stream completion).
657
+ // Without this, these errors are only caught by React's error boundary
658
+ // and never reach the router's onError callback or telemetry.
659
+ handleStore.onError = (error: Error) => {
660
+ const reqCtx = requireRequestContext();
661
+ callOnError(error, "handler", {
662
+ request,
663
+ url,
664
+ routeKey: reqCtx._routeName,
665
+ params: reqCtx.params as Record<string, string>,
666
+ handledByBoundary: true,
667
+ });
668
+ try {
669
+ const routerCtx = getRouterContext();
670
+ if (routerCtx?.telemetry) {
671
+ safeEmit(resolveSink(routerCtx.telemetry), {
672
+ type: "handler.error" as const,
673
+ timestamp: performance.now(),
674
+ requestId: routerCtx.requestId,
675
+ error,
676
+ handledByBoundary: true,
677
+ pathname: url.pathname,
678
+ routeKey: reqCtx._routeName,
679
+ params: reqCtx.params as Record<string, string>,
542
680
  });
543
681
  }
544
- };
682
+ } catch {
683
+ // Router context may not be available (e.g. prerender path)
684
+ }
685
+ };
545
686
 
546
- // Wrap callHandler to append Vary: Accept on content-negotiated responses
547
- const callHandlerWithVary = async () => {
548
- const response = await callHandler();
549
- if (preview.negotiated) {
550
- response.headers.append("Vary", "Accept");
551
- }
552
- return response;
553
- };
687
+ // Set route params early so all execution paths can access ctx.params.
688
+ if (preview?.params) {
689
+ setRequestContextParams(preview.params, preview.routeKey);
690
+ }
554
691
 
555
- // Wrap with response caching if cache() config is present
556
- const executeHandler = async () => {
557
- if (preview.routeMiddleware && preview.routeMiddleware.length > 0) {
558
- const middlewareEntries = preview.routeMiddleware.map((mw) => ({
559
- entry: {
560
- pattern: null,
561
- regex: null,
562
- paramNames: [],
563
- handler: mw.handler,
564
- mountPrefix: null,
565
- },
566
- params: mw.params,
567
- }));
568
- return executeMiddleware(
569
- middlewareEntries,
692
+ // Progressive enhancement runs before the normal action/render paths.
693
+ // Route middleware wraps the PE re-render so handlers see the same
694
+ // context variables regardless of JS/no-JS transport.
695
+ const progressiveResult = await handleProgressiveEnhancement(
696
+ handlerCtx,
697
+ request,
698
+ env,
699
+ url,
700
+ isAction,
701
+ handleStore,
702
+ nonce,
703
+ {
704
+ routeMiddleware: preview?.routeMiddleware,
705
+ variables,
706
+ routeReverse,
707
+ },
708
+ );
709
+ if (progressiveResult) {
710
+ return progressiveResult;
711
+ }
712
+
713
+ // --- Action execution: runs BEFORE route middleware ---
714
+ // Route middleware wraps rendering only. For actions, the action runs
715
+ // first in the global middleware context, then route middleware wraps
716
+ // the revalidation pass (identical to a normal render).
717
+ let actionContinuation: ActionContinuation | undefined;
718
+ if (isAction && actionId) {
719
+ try {
720
+ const actionOutcome = await withTimeout(
721
+ executeServerAction(
722
+ handlerCtx,
570
723
  request,
571
724
  env,
572
- variables,
573
- callHandlerWithVary,
574
- createReverseFunction(getRequiredRouteMap()),
725
+ url,
726
+ actionId,
727
+ handleStore,
728
+ ),
729
+ router.timeouts.actionMs,
730
+ "action",
731
+ );
732
+ if (actionOutcome.timedOut) {
733
+ return handleTimeoutResponse(
734
+ request,
735
+ env,
736
+ url,
737
+ "action",
738
+ actionOutcome.durationMs,
739
+ preview?.routeKey,
740
+ actionId,
575
741
  );
576
742
  }
577
- return callHandlerWithVary();
578
- };
579
-
580
- // Resolve cache config from entry tree (same pattern as match-api.ts)
581
- if (preview.manifestEntry) {
582
- const entries = [...traverseBack(preview.manifestEntry)];
583
- let cacheScope: ReturnType<typeof createCacheScope> = null;
584
- for (const entry of entries) {
585
- if (entry.cache) {
586
- cacheScope = createCacheScope(entry.cache, cacheScope);
587
- }
588
- }
589
-
590
- if (cacheScope?.enabled) {
591
- const store = cacheScope.getStore() ?? reqCtx._cacheStore;
592
- if (store?.getResponse && store?.putResponse) {
593
- // Build cache key with response:{type}: prefix to avoid collision
594
- // with segment keys and differentiate between response types
595
- let cacheKey = `response:${preview.responseType}:${url.pathname}`;
596
- if (store.keyGenerator) {
597
- try {
598
- cacheKey = await store.keyGenerator(reqCtx, cacheKey);
599
- } catch {
600
- // Fall back to default key on keyGenerator failure
601
- }
602
- }
603
-
604
- try {
605
- const cached = await store.getResponse(cacheKey);
606
-
607
- if (cached && isCacheableStatus(cached.response.status)) {
608
- if (!cached.shouldRevalidate) {
609
- // Fresh hit
610
- return cached.response;
611
- }
612
-
613
- // Stale hit (SWR) - return cached, revalidate in background
614
- reqCtx.waitUntil(async () => {
615
- try {
616
- const fresh = await executeHandler();
617
- if (isCacheableStatus(fresh.status)) {
618
- await store.putResponse!(
619
- cacheKey,
620
- fresh,
621
- cacheScope!.ttl,
622
- cacheScope!.swr,
623
- );
624
- }
625
- } catch (error) {
626
- console.error(
627
- `[ResponseCache] Revalidation failed:`,
628
- error,
629
- );
630
- }
631
- });
632
-
633
- return cached.response;
634
- }
635
- } catch (error) {
636
- console.error(`[ResponseCache] Cache lookup failed:`, error);
637
- }
638
-
639
- // Cache miss - execute handler and cache the result
640
- const response = await executeHandler();
641
-
642
- if (isCacheableStatus(response.status)) {
643
- reqCtx.waitUntil(async () => {
644
- try {
645
- await store.putResponse!(
646
- cacheKey,
647
- response.clone(),
648
- cacheScope!.ttl,
649
- cacheScope!.swr,
650
- );
651
- } catch (error) {
652
- console.error(`[ResponseCache] Cache write failed:`, error);
653
- }
654
- });
655
- }
656
-
657
- return response;
658
- }
659
- }
743
+ const result = actionOutcome.result;
744
+ // Response means redirect or error boundary — done.
745
+ if (result instanceof Response) return result;
746
+ actionContinuation = result;
747
+ } catch (error) {
748
+ callOnError(error, "action", {
749
+ request,
750
+ url,
751
+ env,
752
+ actionId,
753
+ handledByBoundary: false,
754
+ });
755
+ console.error(`[RSC] Action error:`, error);
756
+ throw error;
660
757
  }
661
-
662
- return executeHandler();
663
758
  }
664
759
 
665
- // Wrap RSC handler to append Vary: Accept on content-negotiated routes
666
- const rscHandler = async () => {
760
+ // --- Rendering (action revalidation or navigation) ---
761
+ // Route middleware wraps this same code path for both cases.
762
+ const renderHandler = async () => {
667
763
  const response = await coreRequestHandlerInner(
668
764
  request,
669
765
  env,
@@ -672,6 +768,8 @@ export function createRSCHandler<
672
768
  nonce,
673
769
  preview?.params,
674
770
  preview?.routeKey,
771
+ handleStore,
772
+ actionContinuation,
675
773
  );
676
774
  if (preview?.negotiated) {
677
775
  response.headers.append("Vary", "Accept");
@@ -679,57 +777,58 @@ export function createRSCHandler<
679
777
  return response;
680
778
  };
681
779
 
682
- if (preview?.routeMiddleware && preview.routeMiddleware.length > 0) {
683
- // Convert route middleware to app middleware format for execution
684
- const middlewareEntries = preview.routeMiddleware.map((mw) => ({
685
- entry: {
686
- pattern: null,
687
- regex: null,
688
- paramNames: [],
689
- handler: mw.handler,
690
- mountPrefix: null,
691
- },
692
- params: mw.params,
693
- }));
694
-
695
- // Execute route middleware wrapping the actual request handling
696
- const mwResponse = await executeMiddleware(
697
- middlewareEntries,
698
- request,
699
- env,
700
- variables,
701
- rscHandler,
702
- createReverseFunction(getRequiredRouteMap()),
703
- );
780
+ // Wrap the render path (with or without route middleware) in a
781
+ // renderStartMs timeout so slow renders are caught before output.
782
+ const executeRender = async (): Promise<Response> => {
783
+ if (preview?.routeMiddleware && preview.routeMiddleware.length > 0) {
784
+ const mwResponse = await executeMiddleware(
785
+ buildRouteMiddlewareEntries<TEnv>(preview.routeMiddleware),
786
+ request,
787
+ env,
788
+ variables,
789
+ renderHandler,
790
+ routeReverse,
791
+ );
704
792
 
705
- // If route middleware returned a redirect during a partial (SPA)
706
- // request, intercept it. fetch auto-follows 3xx, so we must signal
707
- // the redirect via our own mechanism instead.
708
- // - With state: Flight payload (200) so location state survives.
709
- // - Without state: 204 + X-RSC-Redirect header (lightweight).
710
- const isPartial = url.searchParams.has("_rsc_partial");
711
- const mwRedirectUrl = mwResponse.headers.get("Location");
712
- const isMwRedirect =
713
- mwResponse.status >= 300 && mwResponse.status < 400 && mwRedirectUrl;
714
- if (isPartial && isMwRedirect) {
715
- const locationState = getLocationState();
716
- if (locationState) {
717
- return createRedirectFlightResponse(
718
- mwRedirectUrl,
719
- resolveLocationStateEntries(locationState),
793
+ if (
794
+ url.searchParams.has("_rsc_partial") ||
795
+ url.searchParams.has("_rsc_action")
796
+ ) {
797
+ const intercepted = interceptRedirectForPartial(
798
+ mwResponse,
799
+ createRedirectFlightResponse,
720
800
  );
801
+ if (intercepted) return intercepted;
721
802
  }
722
- return createSimpleRedirectResponse(mwRedirectUrl);
803
+
804
+ return finalizeResponse(mwResponse);
723
805
  }
724
806
 
725
- return mwResponse;
726
- }
807
+ // No route middleware, proceed directly
808
+ return renderHandler();
809
+ };
727
810
 
728
- // No route middleware, proceed directly
729
- return rscHandler();
811
+ const renderOutcome = await withTimeout(
812
+ executeRender(),
813
+ router.timeouts.renderStartMs,
814
+ "render-start",
815
+ );
816
+ if (renderOutcome.timedOut) {
817
+ return handleTimeoutResponse(
818
+ request,
819
+ env,
820
+ url,
821
+ "render-start",
822
+ renderOutcome.durationMs,
823
+ preview?.routeKey,
824
+ );
825
+ }
826
+ return renderOutcome.result;
730
827
  }
731
828
 
732
- // Inner request handler (actual RSC logic, wrapped by route middleware if any)
829
+ // Inner request handler: rendering logic wrapped by route middleware.
830
+ // Handles action revalidation (when actionContinuation is present),
831
+ // loader fetches, and regular RSC rendering.
733
832
  async function coreRequestHandlerInner(
734
833
  request: Request,
735
834
  env: TEnv,
@@ -738,12 +837,12 @@ export function createRSCHandler<
738
837
  nonce: string | undefined,
739
838
  routeParams?: Record<string, string>,
740
839
  routeKey?: string,
840
+ handleStore?: ReturnType<typeof requireRequestContext>["_handleStore"],
841
+ actionContinuation?: ActionContinuation,
741
842
  ): Promise<Response> {
742
843
  const isPartial = url.searchParams.has("_rsc_partial");
743
844
  const isAction =
744
845
  request.headers.has("rsc-action") || url.searchParams.has("_rsc_action");
745
- const actionId =
746
- request.headers.get("rsc-action") || url.searchParams.get("_rsc_action");
747
846
 
748
847
  // Version mismatch detection - client may have stale code after HMR/deployment
749
848
  // If versions don't match, tell the client to reload
@@ -753,19 +852,10 @@ export function createRSCHandler<
753
852
  `[RSC] Version mismatch: client=${clientVersion}, server=${version}. Forcing reload.`,
754
853
  );
755
854
 
756
- // Clean URL by removing RSC params
757
- const cleanUrl = new URL(url);
758
- cleanUrl.searchParams.delete("_rsc_partial");
759
- cleanUrl.searchParams.delete("_rsc_segments");
760
- cleanUrl.searchParams.delete("_rsc_v");
761
- cleanUrl.searchParams.delete("_rsc_stale");
762
- cleanUrl.searchParams.delete("_rsc_action");
763
- cleanUrl.searchParams.delete("_rsc_prev");
764
-
765
855
  // For actions, reload current page (referer) if same origin.
766
856
  // For navigation, load the target URL.
767
857
  // Validate referer origin to prevent open redirect via crafted header.
768
- let reloadUrl = cleanUrl.toString();
858
+ let reloadUrl = stripInternalParams(url).toString();
769
859
  if (isAction) {
770
860
  const referer = request.headers.get("referer");
771
861
  if (referer) {
@@ -818,57 +908,27 @@ export function createRSCHandler<
818
908
  );
819
909
  }
820
910
 
821
- // Get handle store from request context (created at start of request)
822
- const handleStore = requireRequestContext()._handleStore;
911
+ const store = handleStore ?? requireRequestContext()._handleStore;
823
912
 
824
913
  try {
825
- // Set route params early so all execution paths (progressive enhancement,
826
- // server actions, loader fetches) can access ctx.params via getRequestContext().
827
- // Previously this was only done for JS actions, leaving PE actions with empty params.
914
+ // Route params were already set in coreRequestHandler, but set again
915
+ // for callers that enter coreRequestHandlerInner directly.
828
916
  if (routeParams) {
829
917
  setRequestContextParams(routeParams, routeKey);
830
918
  }
831
919
 
832
920
  // ============================================================================
833
- // PROGRESSIVE ENHANCEMENT: No-JS Form Submissions
921
+ // ACTION REVALIDATION (action already executed, revalidate segments)
834
922
  // ============================================================================
835
- const progressiveResult = await handleProgressiveEnhancement(
836
- handlerCtx,
837
- request,
838
- env,
839
- url,
840
- isAction,
841
- handleStore,
842
- nonce,
843
- );
844
- if (progressiveResult) {
845
- return progressiveResult;
846
- }
847
-
848
- // ============================================================================
849
- // SERVER ACTION EXECUTION (JavaScript-enabled client)
850
- // ============================================================================
851
- if (isAction && actionId) {
852
- try {
853
- return await handleServerAction(
854
- handlerCtx,
855
- request,
856
- env,
857
- url,
858
- actionId,
859
- handleStore,
860
- );
861
- } catch (error) {
862
- callOnError(error, "action", {
863
- request,
864
- url,
865
- env,
866
- actionId,
867
- handledByBoundary: false,
868
- });
869
- console.error(`[RSC] Action error:`, error);
870
- throw error;
871
- }
923
+ if (actionContinuation) {
924
+ return await revalidateAfterAction(
925
+ handlerCtx,
926
+ request,
927
+ env,
928
+ url,
929
+ store,
930
+ actionContinuation,
931
+ );
872
932
  }
873
933
 
874
934
  // ============================================================================
@@ -896,7 +956,7 @@ export function createRSCHandler<
896
956
  env,
897
957
  url,
898
958
  isPartial,
899
- handleStore,
959
+ store,
900
960
  nonce,
901
961
  );
902
962
  } catch (error) {
@@ -912,39 +972,21 @@ export function createRSCHandler<
912
972
  `[RSC] Route handler at ${url.pathname} returned a Response during client-side navigation. ` +
913
973
  `Falling back to hard navigation. Use data-external on the <Link> to avoid the extra round-trip.`,
914
974
  );
915
- const cleanUrl = new URL(url);
916
- cleanUrl.searchParams.delete("_rsc_partial");
917
- cleanUrl.searchParams.delete("_rsc_segments");
918
- cleanUrl.searchParams.delete("_rsc_v");
919
- cleanUrl.searchParams.delete("_rsc_stale");
920
- cleanUrl.searchParams.delete("_rsc_action");
921
- cleanUrl.searchParams.delete("_rsc_prev");
922
975
  return createResponseWithMergedHeaders(null, {
923
976
  status: 200,
924
977
  headers: {
925
- "X-RSC-Reload": cleanUrl.toString(),
978
+ "X-RSC-Reload": stripInternalParams(url).toString(),
926
979
  "content-type": "text/x-component;charset=utf-8",
927
980
  },
928
981
  });
929
982
  }
930
983
 
931
- // For partial requests: intercept redirects. HTTP 3xx redirects are
932
- // auto-followed by fetch, which would hit the target URL without
933
- // _rsc_partial and render a full HTML page the client can't parse.
934
- // - With state: Flight payload (200) so location state survives.
935
- // - Without state: 204 + X-RSC-Redirect header (lightweight).
936
- const redirectUrl = error.headers.get("Location");
937
- const isRedirect =
938
- error.status >= 300 && error.status < 400 && redirectUrl;
939
- if (isPartial && isRedirect) {
940
- const locationState = getLocationState();
941
- if (locationState) {
942
- return createRedirectFlightResponse(
943
- redirectUrl,
944
- resolveLocationStateEntries(locationState),
945
- );
946
- }
947
- return createSimpleRedirectResponse(redirectUrl);
984
+ if (isPartial) {
985
+ const intercepted = interceptRedirectForPartial(
986
+ error,
987
+ createRedirectFlightResponse,
988
+ );
989
+ if (intercepted) return intercepted;
948
990
  }
949
991
 
950
992
  return error;
@@ -988,7 +1030,7 @@ export function createRSCHandler<
988
1030
  diff: [],
989
1031
  isPartial: false,
990
1032
  rootLayout: router.rootLayout,
991
- handles: handleStore.stream(),
1033
+ handles: store.stream(),
992
1034
  version,
993
1035
  themeConfig: router.themeConfig,
994
1036
  warmupEnabled: router.warmupEnabled,
@@ -1014,9 +1056,18 @@ export function createRSCHandler<
1014
1056
  });
1015
1057
  }
1016
1058
 
1017
- // Delegate to SSR for HTML response
1018
- const ssrModule = await loadSSRModule();
1019
- const htmlStream = await ssrModule.renderHTML(rscStream, { nonce });
1059
+ // Delegate to SSR for HTML response (reuse early setup if available)
1060
+ const [ssrModule, streamMode] = await getSSRSetup(
1061
+ handlerCtx,
1062
+ request,
1063
+ env,
1064
+ url,
1065
+ requireRequestContext()._metricsStore,
1066
+ );
1067
+ const htmlStream = await ssrModule.renderHTML(rscStream, {
1068
+ nonce,
1069
+ streamMode,
1070
+ });
1020
1071
 
1021
1072
  return createResponseWithMergedHeaders(htmlStream, {
1022
1073
  status: 404,