@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
@@ -12,6 +12,8 @@ import {
12
12
  getLocationState,
13
13
  } from "../server/request-context.js";
14
14
  import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
15
+ import { appendMetric } from "../router/metrics.js";
16
+ import { getSSRSetup } from "./ssr-setup.js";
15
17
  import type { RscPayload } from "./types.js";
16
18
  import {
17
19
  createResponseWithMergedHeaders,
@@ -28,13 +30,10 @@ export async function handleRscRendering<TEnv>(
28
30
  handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
29
31
  nonce: string | undefined,
30
32
  ): Promise<Response> {
31
- // Retrieve handler-level timing from variables
32
33
  const reqCtx = requireRequestContext();
33
- const handlerTimingArr: string[] = reqCtx.var.__handlerTiming || [];
34
- const handlerStart: number = reqCtx.var.__handlerStart || 0;
35
34
 
36
35
  let payload: RscPayload;
37
- let serverTiming: string | undefined;
36
+ let hasInterceptSlots = false;
38
37
 
39
38
  if (isPartial) {
40
39
  // Partial render (navigation)
@@ -52,8 +51,6 @@ export async function handleRscRendering<TEnv>(
52
51
  return createSimpleRedirectResponse(match.redirect);
53
52
  }
54
53
 
55
- serverTiming = match.serverTiming;
56
-
57
54
  payload = {
58
55
  metadata: {
59
56
  pathname: url.pathname,
@@ -71,7 +68,8 @@ export async function handleRscRendering<TEnv>(
71
68
  };
72
69
  } else {
73
70
  setRequestContextParams(result.params, result.routeName);
74
- serverTiming = result.serverTiming;
71
+
72
+ hasInterceptSlots = !!result.slots;
75
73
 
76
74
  payload = {
77
75
  metadata: {
@@ -109,6 +107,7 @@ export async function handleRscRendering<TEnv>(
109
107
  const nonLoaderSegments = match.segments.filter(
110
108
  (s) => s.type !== "loader",
111
109
  );
110
+ handleStore.seal();
112
111
  await handleStore.settled;
113
112
  const { serializeSegments } = await import("../cache/segment-codec.js");
114
113
  const serializedSegments = await serializeSegments(nonLoaderSegments);
@@ -129,8 +128,6 @@ export async function handleRscRendering<TEnv>(
129
128
  { headers: { "Content-Type": "application/json" } },
130
129
  );
131
130
  } else {
132
- serverTiming = match.serverTiming;
133
-
134
131
  payload = {
135
132
  // Initial SSR can reconstruct the tree from segments + rootLayout,
136
133
  // so we omit root to avoid sending the same structure twice.
@@ -163,68 +160,75 @@ export async function handleRscRendering<TEnv>(
163
160
  }
164
161
  }
165
162
 
163
+ const metricsStore = reqCtx._metricsStore;
164
+ const renderStart = performance.now();
165
+
166
166
  // Serialize to RSC stream
167
167
  const rscSerializeStart = performance.now();
168
168
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
169
169
  const rscSerializeDur = performance.now() - rscSerializeStart;
170
+ // This measures synchronous stream creation, not end-to-end stream consumption.
171
+ appendMetric(
172
+ metricsStore,
173
+ "rsc-serialize",
174
+ rscSerializeStart,
175
+ rscSerializeDur,
176
+ );
170
177
 
171
178
  // Determine if this is an RSC request or HTML request.
172
179
  // Partial requests (_rsc_partial) are always RSC -- they come from client-side
173
- // navigation or <link rel="prefetch">. Chrome sends Accept: text/html for
174
- // prefetch links despite as="fetch", so we cannot rely on Accept alone.
180
+ // navigation or prefetch fetch(). We cannot rely on Accept alone since some
181
+ // browsers may send Accept: text/html for non-HTML requests.
175
182
  const isRscRequest =
176
183
  isPartial ||
177
184
  (!request.headers.get("accept")?.includes("text/html") &&
178
185
  !url.searchParams.has("__html")) ||
179
186
  url.searchParams.has("__rsc");
180
187
 
181
- // Build complete Server-Timing: handler phases + match/manifest + RSC serialize
182
- const timingParts: string[] = [...handlerTimingArr];
183
- if (serverTiming) {
184
- timingParts.push(serverTiming);
185
- }
186
- timingParts.push(`rsc-serialize;dur=${rscSerializeDur.toFixed(2)}`);
187
-
188
188
  if (isRscRequest) {
189
- const fullTiming = timingParts.join(", ");
189
+ const renderDur = performance.now() - renderStart;
190
+ appendMetric(metricsStore, "render:total", renderStart, renderDur);
190
191
  const rscHeaders: Record<string, string> = {
191
192
  "content-type": "text/x-component;charset=utf-8",
192
- vary: "accept",
193
+ vary: "accept, X-Rango-State, X-RSC-Router-Client-Path",
193
194
  };
194
- if (fullTiming) {
195
- rscHeaders["Server-Timing"] = fullTiming;
195
+ // Enable browser HTTP caching for prefetch responses only.
196
+ // Requires X-Rango-Prefetch header (sent by Link prefetch fetch),
197
+ // non-intercept context (intercept responses depend on source page),
198
+ // and a configured cache-control value (false disables caching).
199
+ const isPrefetch = request.headers.has("X-Rango-Prefetch");
200
+ if (isPrefetch && isPartial && !hasInterceptSlots) {
201
+ const cc = ctx.router.prefetchCacheControl;
202
+ if (cc) {
203
+ rscHeaders["cache-control"] = cc;
204
+ }
196
205
  }
197
206
  return createResponseWithMergedHeaders(rscStream, {
198
207
  headers: rscHeaders,
199
208
  });
200
209
  }
201
210
 
202
- // Delegate to SSR for HTML response
203
- const ssrModuleStart = performance.now();
204
- const ssrModule = await ctx.loadSSRModule();
205
- const ssrModuleDur = performance.now() - ssrModuleStart;
206
- timingParts.push(`ssr-module-load;dur=${ssrModuleDur.toFixed(2)}`);
211
+ // Delegate to SSR for HTML response (reuse early setup if available)
212
+ const [ssrModule, streamMode] = await getSSRSetup(
213
+ ctx,
214
+ request,
215
+ env,
216
+ url,
217
+ metricsStore,
218
+ );
207
219
 
208
220
  const ssrRenderStart = performance.now();
209
- const htmlStream = await ssrModule.renderHTML(rscStream, { nonce });
221
+ const htmlStream = await ssrModule.renderHTML(rscStream, {
222
+ nonce,
223
+ streamMode,
224
+ });
210
225
  const ssrRenderDur = performance.now() - ssrRenderStart;
211
- timingParts.push(`ssr-render-html;dur=${ssrRenderDur.toFixed(2)}`);
226
+ appendMetric(metricsStore, "ssr-render-html", ssrRenderStart, ssrRenderDur);
212
227
 
213
- // Add total handler duration
214
- if (handlerStart) {
215
- const totalHandler = performance.now() - handlerStart;
216
- timingParts.push(`handler-total;dur=${totalHandler.toFixed(2)}`);
217
- }
218
-
219
- const fullTiming = timingParts.join(", ");
220
- const htmlHeaders: Record<string, string> = {
221
- "content-type": "text/html;charset=utf-8",
222
- };
223
- if (fullTiming) {
224
- htmlHeaders["Server-Timing"] = fullTiming;
225
- }
228
+ const renderDur = performance.now() - renderStart;
229
+ appendMetric(metricsStore, "render:total", renderStart, renderDur);
226
230
 
227
231
  return createResponseWithMergedHeaders(htmlStream, {
228
- headers: htmlHeaders,
232
+ headers: { "content-type": "text/html;charset=utf-8" },
229
233
  });
230
234
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Runtime guardrail warnings (dev-only).
3
+ *
4
+ * W3: PE action redirect / Response handling.
5
+ */
6
+
7
+ import {
8
+ createResponseWithMergedHeaders,
9
+ carryOverRedirectHeaders,
10
+ } from "./helpers.js";
11
+
12
+ // W3 -----------------------------------------------------------------------
13
+
14
+ /**
15
+ * Extract a redirect Response from a thrown or returned value.
16
+ * Returns a redirect Response to send to the client, or null if the value
17
+ * is not a redirect Response.
18
+ */
19
+ export function extractRedirectResponse(value: unknown): Response | null {
20
+ if (!(value instanceof Response)) return null;
21
+ const location = value.headers.get("Location");
22
+ if (value.status >= 300 && value.status < 400 && location) {
23
+ const redirect = createResponseWithMergedHeaders(null, {
24
+ status: value.status,
25
+ headers: { Location: location },
26
+ });
27
+ carryOverRedirectHeaders(value, redirect);
28
+ return redirect;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Warn when a non-redirect Response is returned from an action during PE.
35
+ */
36
+ export function warnNonRedirectPeResponse(): void {
37
+ console.warn(
38
+ `[rango] Server action returned a non-redirect Response during ` +
39
+ `progressive enhancement (no-JS) request. The Response will be ` +
40
+ `ignored — the page will re-render at the current URL instead.`,
41
+ );
42
+ }
@@ -1,9 +1,18 @@
1
1
  /**
2
2
  * Server Action Handler
3
3
  *
4
- * Handles server action execution and post-action revalidation.
5
- * Decodes action arguments, executes the action, handles redirects
6
- * and error boundaries, then revalidates affected segments.
4
+ * Handles server action execution and post-action revalidation as two
5
+ * separate phases:
6
+ *
7
+ * 1. executeServerAction — decodes args, runs the action, handles redirects
8
+ * and error boundaries. Returns either a final Response (redirect/error)
9
+ * or an ActionContinuation for the revalidation phase.
10
+ *
11
+ * 2. revalidateAfterAction — takes the continuation, matches affected
12
+ * segments, builds the RSC payload, and returns the Flight response.
13
+ *
14
+ * The handler (handler.ts) runs the action BEFORE route middleware, then
15
+ * wraps revalidation inside route middleware — identical to a normal render.
7
16
  */
8
17
 
9
18
  import {
@@ -12,22 +21,62 @@ import {
12
21
  getLocationState,
13
22
  } from "../server/request-context.js";
14
23
  import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
24
+ import { appendMetric } from "../router/metrics.js";
15
25
  import type { RscPayload } from "./types.js";
16
26
  import {
17
27
  hasBodyContent,
18
28
  createResponseWithMergedHeaders,
19
29
  createSimpleRedirectResponse,
30
+ carryOverRedirectHeaders,
20
31
  } from "./helpers.js";
21
32
  import type { HandlerContext } from "./handler-context.js";
22
33
 
23
- export async function handleServerAction<TEnv>(
34
+ /**
35
+ * Attach location state set during the action to a payload's metadata.
36
+ * No-op if no location state was set.
37
+ */
38
+ function attachLocationState(payload: RscPayload): void {
39
+ const locationState = getLocationState();
40
+ if (locationState) {
41
+ payload.metadata!.locationState =
42
+ resolveLocationStateEntries(locationState);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Data flowing from action execution to the revalidation phase.
48
+ * When the action completes without redirect/error-boundary, the handler
49
+ * passes this to route middleware → revalidateAfterAction.
50
+ */
51
+ export interface ActionContinuation {
52
+ returnValue: { ok: boolean; data: unknown };
53
+ actionStatus: number;
54
+ temporaryReferences: ReturnType<
55
+ HandlerContext["createTemporaryReferenceSet"]
56
+ >;
57
+ actionContext: {
58
+ actionId: string;
59
+ actionUrl: URL;
60
+ actionResult: unknown;
61
+ formData?: FormData;
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Phase 1: Execute the server action.
67
+ *
68
+ * Decodes arguments, runs the action, handles redirects and error
69
+ * boundaries. Returns a final Response (redirect, error boundary render)
70
+ * or an ActionContinuation for the revalidation phase.
71
+ */
72
+ export async function executeServerAction<TEnv>(
24
73
  ctx: HandlerContext<TEnv>,
25
74
  request: Request,
26
75
  env: TEnv,
27
76
  url: URL,
28
77
  actionId: string,
29
78
  handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
30
- ): Promise<Response> {
79
+ ): Promise<Response | ActionContinuation> {
31
80
  const temporaryReferences = ctx.createTemporaryReferenceSet();
32
81
 
33
82
  // Decode action arguments from request body
@@ -70,15 +119,17 @@ export async function handleServerAction<TEnv>(
70
119
  const isRedirect = data.status >= 300 && data.status < 400 && redirectUrl;
71
120
  if (isRedirect) {
72
121
  const locationState = getLocationState();
122
+ let redirect: Response;
73
123
  if (locationState) {
74
- // Redirect with state: needs Flight payload to carry state
75
- return ctx.createRedirectFlightResponse(
124
+ redirect = ctx.createRedirectFlightResponse(
76
125
  redirectUrl,
77
126
  resolveLocationStateEntries(locationState),
78
127
  );
128
+ } else {
129
+ redirect = createSimpleRedirectResponse(redirectUrl);
79
130
  }
80
- // Simple redirect: short-circuit with a header, no RSC serialization
81
- return createSimpleRedirectResponse(redirectUrl);
131
+ carryOverRedirectHeaders(data, redirect);
132
+ return redirect;
82
133
  }
83
134
  }
84
135
 
@@ -91,28 +142,58 @@ export async function handleServerAction<TEnv>(
91
142
  error.status >= 300 && error.status < 400 && redirectUrl;
92
143
  if (isRedirect) {
93
144
  const locationState = getLocationState();
145
+ let redirect: Response;
94
146
  if (locationState) {
95
- return ctx.createRedirectFlightResponse(
147
+ redirect = ctx.createRedirectFlightResponse(
96
148
  redirectUrl,
97
149
  resolveLocationStateEntries(locationState),
98
150
  );
151
+ } else {
152
+ redirect = createSimpleRedirectResponse(redirectUrl);
99
153
  }
100
- return createSimpleRedirectResponse(redirectUrl);
154
+ carryOverRedirectHeaders(error, redirect);
155
+ return redirect;
156
+ }
157
+
158
+ // Non-redirect Response thrown from action — this will be treated
159
+ // as a regular error and routed to the error boundary. Warn in dev
160
+ // since the intent is likely a redirect with a missing Location header.
161
+ if (process.env.NODE_ENV !== "production") {
162
+ console.warn(
163
+ `[@rangojs/router] Server action "${actionId}" threw a Response ` +
164
+ `(status ${error.status}) that is not a redirect. ` +
165
+ `Non-redirect Responses are treated as errors. ` +
166
+ `Use \`throw redirect('/path')\` for redirects.`,
167
+ );
101
168
  }
102
169
  }
103
170
 
104
171
  returnValue = { ok: false, data: error };
105
172
  actionStatus = 500;
106
173
 
107
- // Try to render error boundary
108
- const errorResult = await ctx.router.matchError(
109
- request,
110
- { env },
111
- error,
112
- "route",
113
- );
174
+ // Try to render error boundary.
175
+ // Report the action error first so it is not lost if matchError throws.
176
+ let errorResult;
177
+ try {
178
+ errorResult = await ctx.router.matchError(
179
+ request,
180
+ { env },
181
+ error,
182
+ "route",
183
+ );
184
+ } catch (matchErr) {
185
+ // matchError failed — report the original action error as unhandled,
186
+ // then let the matchError failure propagate.
187
+ ctx.callOnError(error, "action", {
188
+ request,
189
+ url,
190
+ env,
191
+ actionId,
192
+ handledByBoundary: false,
193
+ });
194
+ throw matchErr;
195
+ }
114
196
 
115
- // Report the action error (handledByBoundary indicates if error boundary will render)
116
197
  ctx.callOnError(error, "action", {
117
198
  request,
118
199
  url,
@@ -138,6 +219,10 @@ export async function handleServerAction<TEnv>(
138
219
  returnValue,
139
220
  };
140
221
 
222
+ // Intentionally omit attachLocationState for error payloads:
223
+ // location state is a success-only semantic. Error boundary responses
224
+ // update the error UI but should not mutate browser history state.
225
+
141
226
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
142
227
  temporaryReferences,
143
228
  });
@@ -149,17 +234,49 @@ export async function handleServerAction<TEnv>(
149
234
  }
150
235
  }
151
236
 
152
- // Revalidate after action
237
+ // Build continuation for the revalidation phase
153
238
  const resolvedActionId =
154
239
  (loadedAction as { $id?: string; $$id?: string } | undefined)?.$id ??
155
240
  (loadedAction as { $$id?: string } | undefined)?.$$id ??
156
241
  actionId;
157
- const actionContext = {
158
- actionId: resolvedActionId,
159
- actionUrl: new URL(request.url),
160
- actionResult: returnValue.data,
161
- formData: actionFormData,
242
+
243
+ return {
244
+ returnValue,
245
+ actionStatus,
246
+ temporaryReferences,
247
+ actionContext: {
248
+ actionId: resolvedActionId,
249
+ actionUrl: new URL(request.url),
250
+ actionResult: returnValue.data,
251
+ formData: actionFormData,
252
+ },
162
253
  };
254
+ }
255
+
256
+ /**
257
+ * Phase 2: Revalidate after action.
258
+ *
259
+ * Matches affected segments, builds the RSC payload, and returns the
260
+ * Flight response. Called inside route middleware (same as a normal render).
261
+ *
262
+ * Invariant: the response payload MUST have isPartial: true. The client
263
+ * (server-action-bridge) rejects non-partial payloads because partial
264
+ * reconciliation requires matched/diff semantics that full renders don't
265
+ * provide. Redirects are the only non-partial outcome and are handled via
266
+ * X-RSC-Redirect headers before Flight deserialization.
267
+ */
268
+ export async function revalidateAfterAction<TEnv>(
269
+ ctx: HandlerContext<TEnv>,
270
+ request: Request,
271
+ env: TEnv,
272
+ url: URL,
273
+ handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
274
+ continuation: ActionContinuation,
275
+ ): Promise<Response> {
276
+ const { returnValue, actionStatus, temporaryReferences, actionContext } =
277
+ continuation;
278
+ const reqCtx = requireRequestContext();
279
+ const metricsStore = reqCtx._metricsStore;
163
280
 
164
281
  const matchResult = await ctx.router.matchPartial(
165
282
  request,
@@ -168,7 +285,8 @@ export async function handleServerAction<TEnv>(
168
285
  );
169
286
 
170
287
  if (!matchResult) {
171
- // Fall back to full render
288
+ // matchPartial returns null when the route is a redirect or the request
289
+ // is missing required headers (previousUrl). Check for redirect first.
172
290
  const fullMatch = await ctx.router.match(request, { env });
173
291
  setRequestContextParams(fullMatch.params, fullMatch.routeName);
174
292
 
@@ -179,43 +297,20 @@ export async function handleServerAction<TEnv>(
179
297
  return createSimpleRedirectResponse(fullMatch.redirect);
180
298
  }
181
299
 
182
- const serverTiming = fullMatch.serverTiming;
183
-
184
- const payload: RscPayload = {
185
- metadata: {
186
- pathname: url.pathname,
187
- segments: fullMatch.segments,
188
- matched: fullMatch.matched,
189
- diff: fullMatch.diff,
190
- rootLayout: ctx.router.rootLayout,
191
- handles: handleStore.stream(),
192
- version: ctx.version,
193
- },
194
- returnValue,
195
- };
196
-
197
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
198
- temporaryReferences,
199
- });
200
-
201
- const headers: Record<string, string> = {
202
- "content-type": "text/x-component;charset=utf-8",
203
- };
204
- if (serverTiming) {
205
- headers["Server-Timing"] = serverTiming;
206
- }
207
-
208
- return createResponseWithMergedHeaders(rscStream, {
209
- status: actionStatus,
210
- headers,
211
- });
300
+ // Non-redirect: this branch is only reachable when the action request
301
+ // is missing the X-RSC-Router-Client-Path header (defensive). The
302
+ // client requires isPartial for action responses, so producing a full
303
+ // payload here would be rejected. Return 500 instead.
304
+ throw new Error(
305
+ `[RSC] matchPartial returned null for a non-redirect route ` +
306
+ `during action revalidation (${url.pathname}). This indicates ` +
307
+ `a malformed action request (missing X-RSC-Router-Client-Path header).`,
308
+ );
212
309
  }
213
310
 
214
311
  // Return updated segments
215
312
  setRequestContextParams(matchResult.params, matchResult.routeName);
216
313
 
217
- const serverTiming = matchResult.serverTiming;
218
-
219
314
  const payload: RscPayload = {
220
315
  metadata: {
221
316
  pathname: url.pathname,
@@ -230,19 +325,24 @@ export async function handleServerAction<TEnv>(
230
325
  returnValue,
231
326
  };
232
327
 
328
+ attachLocationState(payload);
329
+
330
+ const renderStart = performance.now();
233
331
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
234
332
  temporaryReferences,
235
333
  });
236
-
237
- const actionHeaders: Record<string, string> = {
238
- "content-type": "text/x-component;charset=utf-8",
239
- };
240
- if (serverTiming) {
241
- actionHeaders["Server-Timing"] = serverTiming;
242
- }
334
+ const rscSerializeDur = performance.now() - renderStart;
335
+ // This measures synchronous stream creation, not end-to-end stream consumption.
336
+ appendMetric(metricsStore, "rsc-serialize", renderStart, rscSerializeDur);
337
+ appendMetric(
338
+ metricsStore,
339
+ "render:total",
340
+ renderStart,
341
+ performance.now() - renderStart,
342
+ );
243
343
 
244
344
  return createResponseWithMergedHeaders(rscStream, {
245
345
  status: actionStatus,
246
- headers: actionHeaders,
346
+ headers: { "content-type": "text/x-component;charset=utf-8" },
247
347
  });
248
348
  }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * SSR Setup Utilities
3
+ *
4
+ * Manages early kickoff and retrieval of SSR module loading and stream mode
5
+ * resolution. Both operations are request-scoped but independent of route
6
+ * matching, so they can run in parallel with segment resolution.
7
+ */
8
+
9
+ import type { HandlerContext } from "./handler-context.js";
10
+ import type { SSRModule } from "./types.js";
11
+ import type { SSRStreamMode } from "../router/router-options.js";
12
+ import type { MetricsStore } from "../server/context.js";
13
+ import { appendMetric } from "../router/metrics.js";
14
+ import { _getRequestContext } from "../server/request-context.js";
15
+
16
+ export type SSRSetup = readonly [SSRModule, SSRStreamMode];
17
+
18
+ /**
19
+ * Key used to stash the early SSR setup promise on request variables.
20
+ * Read back via `getSSRSetup`.
21
+ */
22
+ export const SSR_SETUP_VAR = "__ssrSetup";
23
+
24
+ /**
25
+ * Start loading the SSR module and resolving the stream mode in parallel.
26
+ * When a `getMetricsStore` getter is provided, records individual
27
+ * `ssr:module-load` and `ssr:stream-mode` metrics (the getter is called
28
+ * lazily so stores created after kickoff are still captured). Without a
29
+ * getter the promises run bare — no `.then()` microtasks, no
30
+ * `performance.now()` calls — keeping the non-debug hot path lean.
31
+ */
32
+ export function startSSRSetup<TEnv>(
33
+ ctx: HandlerContext<TEnv>,
34
+ request: Request,
35
+ env: TEnv,
36
+ url: URL,
37
+ getMetricsStore?: () => MetricsStore | undefined,
38
+ ): Promise<SSRSetup> {
39
+ if (!getMetricsStore) {
40
+ return Promise.all([
41
+ ctx.loadSSRModule(),
42
+ ctx.resolveStreamMode(request, env, url),
43
+ ]);
44
+ }
45
+ const start = performance.now();
46
+ return Promise.all([
47
+ ctx.loadSSRModule().then((mod) => {
48
+ appendMetric(
49
+ getMetricsStore(),
50
+ "ssr:module-load",
51
+ start,
52
+ performance.now() - start,
53
+ );
54
+ return mod;
55
+ }),
56
+ ctx.resolveStreamMode(request, env, url).then((mode) => {
57
+ appendMetric(
58
+ getMetricsStore(),
59
+ "ssr:stream-mode",
60
+ start,
61
+ performance.now() - start,
62
+ );
63
+ return mode;
64
+ }),
65
+ ]);
66
+ }
67
+
68
+ /**
69
+ * Retrieve the SSR setup result. Returns the early-kicked-off promise
70
+ * when available (stashed on request variables), otherwise starts a
71
+ * fresh setup.
72
+ */
73
+ export function getSSRSetup<TEnv>(
74
+ ctx: HandlerContext<TEnv>,
75
+ request: Request,
76
+ env: TEnv,
77
+ url: URL,
78
+ metricsStore: MetricsStore | undefined,
79
+ ): Promise<SSRSetup> {
80
+ const early = _getRequestContext()?.var?.[SSR_SETUP_VAR] as
81
+ | Promise<SSRSetup>
82
+ | undefined;
83
+ if (early) return early;
84
+ return startSSRSetup(
85
+ ctx,
86
+ request,
87
+ env,
88
+ url,
89
+ metricsStore ? () => metricsStore : undefined,
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Classify whether a request may require SSR (HTML rendering).
95
+ *
96
+ * Returns false for requests that are definitively RSC-only, loader fetches,
97
+ * prerender collection, or Accept-based RSC (no text/html). This mirrors
98
+ * the isRscRequest decision in rsc-rendering.ts.
99
+ *
100
+ * Note: response/mime routes are excluded by the caller — this function
101
+ * runs after previewMatch() classifies the route type.
102
+ */
103
+ export function mayNeedSSR(request: Request, url: URL): boolean {
104
+ if (
105
+ url.searchParams.has("_rsc_partial") ||
106
+ url.searchParams.has("_rsc_action") ||
107
+ request.headers.has("rsc-action") ||
108
+ url.searchParams.has("_rsc_loader") ||
109
+ url.searchParams.has("__rsc") ||
110
+ url.searchParams.has("__prerender_collect")
111
+ ) {
112
+ return false;
113
+ }
114
+
115
+ // Mirror the Accept-based RSC decision from rsc-rendering.ts:
116
+ // if Accept is present and does not include text/html (and no __html override),
117
+ // the response will be RSC, not HTML.
118
+ const accept = request.headers.get("accept");
119
+ if (
120
+ accept &&
121
+ !accept.includes("text/html") &&
122
+ !url.searchParams.has("__html")
123
+ ) {
124
+ return false;
125
+ }
126
+
127
+ return true;
128
+ }