@rangojs/router 0.0.0-experimental.18 → 0.0.0-experimental.19

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 (177) hide show
  1. package/README.md +46 -8
  2. package/dist/bin/rango.js +105 -18
  3. package/dist/vite/index.js +227 -93
  4. package/package.json +15 -14
  5. package/skills/hooks/SKILL.md +1 -1
  6. package/skills/intercept/SKILL.md +79 -0
  7. package/skills/layout/SKILL.md +62 -2
  8. package/skills/loader/SKILL.md +94 -1
  9. package/skills/middleware/SKILL.md +81 -0
  10. package/skills/parallel/SKILL.md +57 -2
  11. package/skills/prerender/SKILL.md +187 -17
  12. package/skills/route/SKILL.md +42 -1
  13. package/skills/router-setup/SKILL.md +77 -0
  14. package/src/__internal.ts +1 -1
  15. package/src/bin/rango.ts +38 -19
  16. package/src/browser/action-coordinator.ts +97 -0
  17. package/src/browser/event-controller.ts +25 -27
  18. package/src/browser/history-state.ts +80 -0
  19. package/src/browser/intercept-utils.ts +1 -1
  20. package/src/browser/link-interceptor.ts +0 -3
  21. package/src/browser/merge-segment-loaders.ts +9 -2
  22. package/src/browser/navigation-bridge.ts +46 -13
  23. package/src/browser/navigation-client.ts +32 -61
  24. package/src/browser/navigation-store.ts +1 -31
  25. package/src/browser/navigation-transaction.ts +46 -207
  26. package/src/browser/partial-update.ts +102 -150
  27. package/src/browser/{prefetch-cache.ts → prefetch/cache.ts} +23 -4
  28. package/src/browser/{prefetch-fetch.ts → prefetch/fetch.ts} +36 -8
  29. package/src/browser/prefetch/policy.ts +42 -0
  30. package/src/browser/{prefetch-queue.ts → prefetch/queue.ts} +10 -3
  31. package/src/browser/react/Link.tsx +28 -23
  32. package/src/browser/react/NavigationProvider.tsx +9 -1
  33. package/src/browser/react/index.ts +2 -6
  34. package/src/browser/react/location-state-shared.ts +1 -1
  35. package/src/browser/react/location-state.ts +2 -0
  36. package/src/browser/react/nonce-context.ts +23 -0
  37. package/src/browser/react/use-action.ts +9 -1
  38. package/src/browser/react/use-handle.ts +3 -25
  39. package/src/browser/react/use-params.ts +2 -4
  40. package/src/browser/react/use-pathname.ts +2 -3
  41. package/src/browser/react/use-router.ts +1 -1
  42. package/src/browser/react/use-search-params.ts +2 -1
  43. package/src/browser/react/use-segments.ts +7 -60
  44. package/src/browser/response-adapter.ts +73 -0
  45. package/src/browser/rsc-router.tsx +29 -23
  46. package/src/browser/scroll-restoration.ts +10 -7
  47. package/src/browser/server-action-bridge.ts +115 -96
  48. package/src/browser/types.ts +1 -31
  49. package/src/browser/validate-redirect-origin.ts +29 -0
  50. package/src/build/generate-manifest.ts +5 -0
  51. package/src/build/generate-route-types.ts +2 -0
  52. package/src/build/route-types/codegen.ts +13 -4
  53. package/src/build/route-types/include-resolution.ts +13 -0
  54. package/src/build/route-types/per-module-writer.ts +15 -3
  55. package/src/build/route-types/router-processing.ts +45 -3
  56. package/src/build/runtime-discovery.ts +13 -1
  57. package/src/cache/background-task.ts +34 -0
  58. package/src/cache/cache-key-utils.ts +44 -0
  59. package/src/cache/cache-policy.ts +125 -0
  60. package/src/cache/cache-runtime.ts +132 -96
  61. package/src/cache/cache-scope.ts +71 -73
  62. package/src/cache/cf/cf-cache-store.ts +9 -4
  63. package/src/cache/document-cache.ts +72 -47
  64. package/src/cache/handle-capture.ts +81 -0
  65. package/src/cache/memory-segment-store.ts +18 -7
  66. package/src/cache/profile-registry.ts +43 -8
  67. package/src/cache/read-through-swr.ts +134 -0
  68. package/src/cache/segment-codec.ts +101 -112
  69. package/src/cache/taint.ts +26 -0
  70. package/src/client.tsx +53 -30
  71. package/src/errors.ts +6 -1
  72. package/src/handle.ts +1 -1
  73. package/src/handles/MetaTags.tsx +5 -2
  74. package/src/host/cookie-handler.ts +8 -3
  75. package/src/host/router.ts +14 -1
  76. package/src/href-client.ts +3 -1
  77. package/src/index.rsc.ts +33 -1
  78. package/src/index.ts +27 -0
  79. package/src/loader.rsc.ts +12 -4
  80. package/src/loader.ts +8 -0
  81. package/src/prerender/store.ts +4 -3
  82. package/src/prerender.ts +76 -18
  83. package/src/reverse.ts +11 -7
  84. package/src/root-error-boundary.tsx +30 -26
  85. package/src/route-definition/dsl-helpers.ts +9 -6
  86. package/src/route-definition/redirect.ts +15 -3
  87. package/src/route-map-builder.ts +38 -2
  88. package/src/route-name.ts +53 -0
  89. package/src/route-types.ts +7 -0
  90. package/src/router/content-negotiation.ts +1 -1
  91. package/src/router/debug-manifest.ts +16 -3
  92. package/src/router/handler-context.ts +94 -15
  93. package/src/router/intercept-resolution.ts +6 -4
  94. package/src/router/lazy-includes.ts +4 -0
  95. package/src/router/loader-resolution.ts +1 -0
  96. package/src/router/logging.ts +100 -3
  97. package/src/router/manifest.ts +32 -3
  98. package/src/router/match-api.ts +61 -7
  99. package/src/router/match-context.ts +3 -0
  100. package/src/router/match-handlers.ts +185 -11
  101. package/src/router/match-middleware/background-revalidation.ts +65 -85
  102. package/src/router/match-middleware/cache-lookup.ts +69 -4
  103. package/src/router/match-middleware/cache-store.ts +2 -0
  104. package/src/router/match-pipelines.ts +8 -43
  105. package/src/router/middleware-types.ts +7 -0
  106. package/src/router/middleware.ts +93 -8
  107. package/src/router/pattern-matching.ts +41 -5
  108. package/src/router/prerender-match.ts +34 -6
  109. package/src/router/preview-match.ts +7 -1
  110. package/src/router/revalidation.ts +61 -2
  111. package/src/router/router-context.ts +15 -0
  112. package/src/router/router-interfaces.ts +34 -0
  113. package/src/router/router-options.ts +200 -0
  114. package/src/router/segment-resolution/fresh.ts +123 -30
  115. package/src/router/segment-resolution/helpers.ts +19 -0
  116. package/src/router/segment-resolution/loader-cache.ts +37 -146
  117. package/src/router/segment-resolution/revalidation.ts +358 -94
  118. package/src/router/segment-wrappers.ts +3 -0
  119. package/src/router/telemetry-otel.ts +299 -0
  120. package/src/router/telemetry.ts +300 -0
  121. package/src/router/timeout.ts +148 -0
  122. package/src/router/types.ts +7 -1
  123. package/src/router.ts +155 -11
  124. package/src/rsc/handler-context.ts +11 -0
  125. package/src/rsc/handler.ts +380 -88
  126. package/src/rsc/helpers.ts +25 -16
  127. package/src/rsc/loader-fetch.ts +84 -42
  128. package/src/rsc/origin-guard.ts +141 -0
  129. package/src/rsc/progressive-enhancement.ts +232 -19
  130. package/src/rsc/response-route-handler.ts +37 -26
  131. package/src/rsc/rsc-rendering.ts +12 -5
  132. package/src/rsc/runtime-warnings.ts +42 -0
  133. package/src/rsc/server-action.ts +134 -58
  134. package/src/rsc/types.ts +8 -0
  135. package/src/search-params.ts +22 -10
  136. package/src/server/context.ts +53 -5
  137. package/src/server/fetchable-loader-store.ts +11 -6
  138. package/src/server/handle-store.ts +66 -9
  139. package/src/server/loader-registry.ts +11 -46
  140. package/src/server/request-context.ts +90 -9
  141. package/src/ssr/index.tsx +63 -27
  142. package/src/static-handler.ts +7 -0
  143. package/src/theme/ThemeProvider.tsx +6 -1
  144. package/src/theme/index.ts +1 -6
  145. package/src/theme/theme-context.ts +1 -28
  146. package/src/theme/theme-script.ts +2 -1
  147. package/src/types/cache-types.ts +5 -0
  148. package/src/types/error-types.ts +3 -0
  149. package/src/types/global-namespace.ts +9 -0
  150. package/src/types/handler-context.ts +35 -13
  151. package/src/types/loader-types.ts +7 -0
  152. package/src/types/route-entry.ts +28 -0
  153. package/src/urls/include-helper.ts +49 -8
  154. package/src/urls/index.ts +1 -0
  155. package/src/urls/path-helper-types.ts +30 -12
  156. package/src/urls/path-helper.ts +17 -2
  157. package/src/urls/pattern-types.ts +21 -1
  158. package/src/urls/response-types.ts +27 -2
  159. package/src/urls/type-extraction.ts +23 -15
  160. package/src/use-loader.tsx +12 -4
  161. package/src/vite/discovery/bundle-postprocess.ts +12 -7
  162. package/src/vite/discovery/discover-routers.ts +30 -18
  163. package/src/vite/discovery/prerender-collection.ts +24 -27
  164. package/src/vite/discovery/route-types-writer.ts +7 -7
  165. package/src/vite/discovery/virtual-module-codegen.ts +5 -2
  166. package/src/vite/plugins/client-ref-hashing.ts +3 -3
  167. package/src/vite/plugins/use-cache-transform.ts +91 -3
  168. package/src/vite/rango.ts +3 -3
  169. package/src/vite/router-discovery.ts +99 -36
  170. package/src/vite/utils/prerender-utils.ts +21 -0
  171. package/src/vite/utils/shared-utils.ts +3 -1
  172. package/src/browser/request-controller.ts +0 -164
  173. package/src/href-context.ts +0 -33
  174. package/src/router.gen.ts +0 -6
  175. package/src/static-handler.gen.ts +0 -5
  176. package/src/urls.gen.ts +0 -8
  177. /package/src/browser/{prefetch-observer.ts → prefetch/observer.ts} +0 -0
@@ -14,9 +14,20 @@
14
14
  import { getLoaderLazy } from "../server/loader-registry.js";
15
15
  import { executeLoaderMiddleware } from "../router/middleware.js";
16
16
  import { requireRequestContext } from "../server/request-context.js";
17
- import { createReverseFunction } from "../router/handler-context.js";
18
- import { getGlobalRouteMap } from "../route-map-builder.js";
19
- import { createResponseWithMergedHeaders } from "./helpers.js";
17
+ import {
18
+ createReverseFunction,
19
+ stripInternalParams,
20
+ } from "../router/handler-context.js";
21
+ import {
22
+ getGlobalRouteMap,
23
+ getSearchSchema,
24
+ isRouteRootScoped,
25
+ } from "../route-map-builder.js";
26
+ import { parseSearchParams } from "../search-params.js";
27
+ import {
28
+ createResponseWithMergedHeaders,
29
+ finalizeResponse,
30
+ } from "./helpers.js";
20
31
  import type { HandlerContext } from "./handler-context.js";
21
32
 
22
33
  export async function handleLoaderFetch<TEnv>(
@@ -44,6 +55,15 @@ export async function handleLoaderFetch<TEnv>(
44
55
  );
45
56
  }
46
57
 
58
+ // Non-fetchable loaders are registered for SSR ctx.use() only.
59
+ // They must not be callable through the standalone _rsc_loader endpoint.
60
+ if (!registeredLoader.fetchable) {
61
+ return createResponseWithMergedHeaders(
62
+ `Loader "${loaderId}" is not fetchable`,
63
+ { status: 403 },
64
+ );
65
+ }
66
+
47
67
  // Parse params, body, and formData based on request method and content type
48
68
  let loaderParams: Record<string, string> = {};
49
69
  let loaderBody: unknown = undefined;
@@ -90,51 +110,73 @@ export async function handleLoaderFetch<TEnv>(
90
110
  }
91
111
  }
92
112
 
93
- // Execute the loader with middleware
113
+ // Execute the loader with middleware.
114
+ // finalizeResponse drains onResponse callbacks that middleware short-circuits
115
+ // may leave behind (executeLoaderMiddleware does not finalize them itself).
94
116
  try {
95
117
  const { fn, middleware } = registeredLoader;
96
118
 
97
- return await executeLoaderMiddleware(
98
- middleware,
99
- request,
100
- env,
101
- loaderParams,
102
- variables,
103
- async () => {
104
- const reqCtx = requireRequestContext();
105
- // Merge route params (from previewMatch) with explicit loader params.
106
- // Explicit params take precedence over route-matched params.
107
- const mergedParams = {
108
- ...(routeParams ?? {}),
109
- ...loaderParams,
110
- };
111
- const loaderCtx: any = {
112
- ...reqCtx,
113
- params: mergedParams,
114
- body: loaderBody,
115
- method: request.method,
116
- reverse: createReverseFunction(
117
- getGlobalRouteMap(),
118
- reqCtx._routeName,
119
- mergedParams,
120
- ),
121
- ...(loaderFormData ? { formData: loaderFormData } : {}),
122
- };
119
+ return finalizeResponse(
120
+ await executeLoaderMiddleware(
121
+ middleware,
122
+ request,
123
+ env,
124
+ loaderParams,
125
+ variables,
126
+ async () => {
127
+ const reqCtx = requireRequestContext();
128
+ // Merge route params (from previewMatch) with explicit loader params.
129
+ // Explicit params take precedence over route-matched params.
130
+ const resolvedRouteParams = routeParams ?? {};
131
+ const mergedParams = {
132
+ ...resolvedRouteParams,
133
+ ...loaderParams,
134
+ };
135
+ // Strip _rsc_* transport params so loaders see the same
136
+ // url/searchParams as during SSR/navigation.
137
+ const cleanUrl = stripInternalParams(url);
138
+ const cleanSearchParams = cleanUrl.searchParams;
139
+ const searchSchema = reqCtx._routeName
140
+ ? getSearchSchema(reqCtx._routeName)
141
+ : undefined;
142
+ const loaderCtx: any = {
143
+ ...reqCtx,
144
+ url: cleanUrl,
145
+ pathname: cleanUrl.pathname,
146
+ searchParams: cleanSearchParams,
147
+ search: searchSchema
148
+ ? parseSearchParams(cleanSearchParams, searchSchema)
149
+ : {},
150
+ params: mergedParams,
151
+ routeParams: resolvedRouteParams,
152
+ body: loaderBody,
153
+ method: request.method,
154
+ reverse: createReverseFunction(
155
+ getGlobalRouteMap(),
156
+ reqCtx._routeName,
157
+ mergedParams,
158
+ reqCtx._routeName
159
+ ? isRouteRootScoped(reqCtx._routeName)
160
+ : undefined,
161
+ ),
162
+ ...(loaderFormData ? { formData: loaderFormData } : {}),
163
+ };
123
164
 
124
- const result = await fn(loaderCtx);
165
+ const result = await fn(loaderCtx);
125
166
 
126
- interface LoaderPayload {
127
- loaderResult: unknown;
128
- }
129
- const loaderPayload: LoaderPayload = { loaderResult: result };
130
- const rscStream =
131
- ctx.renderToReadableStream<LoaderPayload>(loaderPayload);
167
+ interface LoaderPayload {
168
+ loaderResult: unknown;
169
+ }
170
+ const loaderPayload: LoaderPayload = { loaderResult: result };
171
+ const rscStream =
172
+ ctx.renderToReadableStream<LoaderPayload>(loaderPayload);
132
173
 
133
- return createResponseWithMergedHeaders(rscStream, {
134
- headers: { "content-type": "text/x-component;charset=utf-8" },
135
- });
136
- },
137
- createReverseFunction(ctx.getRequiredRouteMap()),
174
+ return createResponseWithMergedHeaders(rscStream, {
175
+ headers: { "content-type": "text/x-component;charset=utf-8" },
176
+ });
177
+ },
178
+ createReverseFunction(ctx.getRequiredRouteMap()),
179
+ ),
138
180
  );
139
181
  } catch (error) {
140
182
  const err = error instanceof Error ? error : new Error(String(error));
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Origin Guard
3
+ *
4
+ * Cross-origin request protection for server actions, loader fetches, and
5
+ * progressive enhancement form submissions. Validates that the Origin header
6
+ * (or Referer fallback) matches the request Host before executing.
7
+ *
8
+ * Requests without an Origin or Referer header are allowed — same-origin
9
+ * navigations, bookmarks, and non-browser clients don't send Origin.
10
+ */
11
+
12
+ /**
13
+ * Request phase that triggered the origin check.
14
+ */
15
+ export type OriginCheckPhase = "action" | "loader" | "pe-form";
16
+
17
+ /**
18
+ * Context passed to the originCheck callback.
19
+ */
20
+ export interface OriginCheckContext<TEnv = any> {
21
+ request: Request;
22
+ url: URL;
23
+ env: TEnv;
24
+ routerId: string;
25
+ phase: OriginCheckPhase;
26
+ /** Run the built-in conservative check (Origin/Referer vs Host + url.protocol). */
27
+ defaultCheck: () => boolean;
28
+ }
29
+
30
+ /**
31
+ * Configuration for the origin check.
32
+ *
33
+ * - `true` (default) — built-in conservative check
34
+ * - `false` — disabled
35
+ * - function — custom control; return true to allow, false to reject with
36
+ * default 403, or a Response for custom rejection
37
+ */
38
+ export type OriginCheckConfig<TEnv = any> =
39
+ | boolean
40
+ | ((
41
+ ctx: OriginCheckContext<TEnv>,
42
+ ) => boolean | Response | Promise<boolean | Response>);
43
+
44
+ /**
45
+ * Built-in conservative origin check.
46
+ * Compares Origin (or Referer fallback) against Host + url.protocol.
47
+ * Does NOT trust X-Forwarded-Host/Proto headers.
48
+ *
49
+ * Returns true to allow, false to reject.
50
+ */
51
+ export function defaultOriginCheck(request: Request, url: URL): boolean {
52
+ // 1. Read Origin header (present on all cross-origin requests and
53
+ // same-origin POST/PUT/PATCH/DELETE in modern browsers)
54
+ let requestOrigin = request.headers.get("origin");
55
+
56
+ // 2. Fallback to Referer if Origin is absent (some proxies strip it)
57
+ if (!requestOrigin) {
58
+ const referer = request.headers.get("referer");
59
+ if (referer) {
60
+ try {
61
+ requestOrigin = new URL(referer).origin;
62
+ } catch {
63
+ // Malformed referer — treat as absent
64
+ }
65
+ }
66
+ }
67
+
68
+ // 3. No Origin or Referer — allow (can't be browser-initiated CSRF)
69
+ if (!requestOrigin) return true;
70
+
71
+ // "null" origin comes from privacy-sensitive contexts (data: URLs,
72
+ // sandboxed iframes, cross-origin redirects). Reject it.
73
+ if (requestOrigin === "null") return false;
74
+
75
+ // 4. Determine expected host from Host header or URL.
76
+ // X-Forwarded-Host/Proto are NOT used — they are client-controllable
77
+ // unless a trusted proxy strips them. On standard deployments (Cloudflare
78
+ // Workers, Node behind nginx/caddy) the Host header is already correct.
79
+ // For non-standard setups, use the custom function escape hatch.
80
+ const expectedHost = request.headers.get("host") || url.host;
81
+ const expectedProtocol = url.protocol;
82
+
83
+ // 5. Build expected origin and compare (case-insensitive)
84
+ const expectedOrigin = `${expectedProtocol}//${expectedHost}`;
85
+
86
+ return requestOrigin.toLowerCase() === expectedOrigin.toLowerCase();
87
+ }
88
+
89
+ function createForbiddenResponse(request: Request): Response {
90
+ const isDev = process.env.NODE_ENV !== "production";
91
+ const body = isDev
92
+ ? "Forbidden: Origin mismatch. The request origin does not match the server host. " +
93
+ `Set originCheck: false in createRouter() to disable this check. ` +
94
+ `(Origin: ${request.headers.get("origin") ?? "none"}, ` +
95
+ `Host: ${request.headers.get("host") ?? "none"})`
96
+ : "Forbidden";
97
+
98
+ return new Response(body, {
99
+ status: 403,
100
+ headers: { "X-Rango-Origin-Check": "failed" },
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Configuration-aware origin check dispatcher.
106
+ * Builds the OriginCheckContext and delegates to the configured check.
107
+ */
108
+ export async function checkRequestOrigin<TEnv = any>(
109
+ request: Request,
110
+ url: URL,
111
+ config: OriginCheckConfig<TEnv> | undefined,
112
+ env: TEnv,
113
+ routerId: string,
114
+ phase: OriginCheckPhase,
115
+ ): Promise<Response | null> {
116
+ // Disabled by explicit opt-out
117
+ if (config === false) return null;
118
+
119
+ // Default: built-in validation (config === true or undefined)
120
+ if (config === true || config === undefined) {
121
+ const allowed = defaultOriginCheck(request, url);
122
+ if (allowed) return null;
123
+ return createForbiddenResponse(request);
124
+ }
125
+
126
+ // Custom function — build context and call
127
+ const ctx: OriginCheckContext<TEnv> = {
128
+ request,
129
+ url,
130
+ env,
131
+ routerId,
132
+ phase,
133
+ defaultCheck: () => defaultOriginCheck(request, url),
134
+ };
135
+
136
+ const result = await config(ctx);
137
+
138
+ if (result instanceof Response) return result;
139
+ if (result === true) return null;
140
+ return createForbiddenResponse(request);
141
+ }
@@ -10,9 +10,32 @@ import {
10
10
  requireRequestContext,
11
11
  setRequestContextParams,
12
12
  } from "../server/request-context.js";
13
+ import type { MiddlewareFn } from "../router/middleware.js";
14
+ import { executeMiddleware } from "../router/middleware.js";
13
15
  import type { RscPayload, ReactFormState } from "./types.js";
14
- import { createResponseWithMergedHeaders } from "./helpers.js";
16
+ import {
17
+ createResponseWithMergedHeaders,
18
+ finalizeResponse,
19
+ buildRouteMiddlewareEntries,
20
+ } from "./helpers.js";
15
21
  import type { HandlerContext } from "./handler-context.js";
22
+ import {
23
+ extractRedirectResponse,
24
+ warnNonRedirectPeResponse,
25
+ } from "./runtime-warnings.js";
26
+
27
+ export interface PeRouteMiddlewareInfo {
28
+ routeMiddleware?: Array<{
29
+ handler: MiddlewareFn;
30
+ params: Record<string, string>;
31
+ }>;
32
+ variables: Record<string, any>;
33
+ routeReverse?: (
34
+ name: string,
35
+ params?: Record<string, string>,
36
+ search?: Record<string, unknown>,
37
+ ) => string;
38
+ }
16
39
 
17
40
  export async function handleProgressiveEnhancement<TEnv>(
18
41
  ctx: HandlerContext<TEnv>,
@@ -22,6 +45,7 @@ export async function handleProgressiveEnhancement<TEnv>(
22
45
  isAction: boolean,
23
46
  handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
24
47
  nonce: string | undefined,
48
+ routeMwInfo?: PeRouteMiddlewareInfo,
25
49
  ): Promise<Response | null> {
26
50
  const contentType = request.headers.get("content-type") || "";
27
51
  const isFormSubmission =
@@ -32,8 +56,42 @@ export async function handleProgressiveEnhancement<TEnv>(
32
56
  return null;
33
57
  }
34
58
 
35
- // Clone the request to read FormData without consuming it
36
- const formData = await request.clone().formData();
59
+ // Clone the request to read FormData without consuming it.
60
+ // Wrap in try-catch so malformed POST bodies are reported as action
61
+ // errors, not routing errors from the outer catch in handler.ts.
62
+ let formData: FormData;
63
+ try {
64
+ formData = await request.clone().formData();
65
+ } catch (error) {
66
+ // Attempt error boundary rendering so the user sees a meaningful page.
67
+ const errorHtml = await renderPeErrorBoundary(
68
+ ctx,
69
+ request,
70
+ env,
71
+ url,
72
+ error,
73
+ handleStore,
74
+ nonce,
75
+ );
76
+ if (errorHtml) {
77
+ ctx.callOnError(error, "action", {
78
+ request,
79
+ url,
80
+ env,
81
+ handledByBoundary: true,
82
+ });
83
+ return errorHtml;
84
+ }
85
+
86
+ ctx.callOnError(error, "action", {
87
+ request,
88
+ url,
89
+ env,
90
+ handledByBoundary: false,
91
+ });
92
+ console.error("[RSC] Progressive enhancement form parse error:", error);
93
+ return createResponseWithMergedHeaders(null, { status: 400 });
94
+ }
37
95
 
38
96
  // Look for React's progressive enhancement hidden fields
39
97
  let isDirectAction = false;
@@ -58,14 +116,37 @@ export async function handleProgressiveEnhancement<TEnv>(
58
116
  let reactFormState: ReactFormState | null = null;
59
117
 
60
118
  if (isUseActionState) {
119
+ // Decode and extract action identity before execution so error
120
+ // handlers can report actionId even when the action throws.
121
+ let useActionStateId: string | undefined;
61
122
  try {
62
123
  const boundAction = await ctx.decodeAction(formData);
124
+ // React's custom .bind() preserves $$id on server references.
125
+ useActionStateId = (boundAction as { $$id?: string }).$$id ?? undefined;
63
126
  actionResult = await boundAction();
64
127
  } catch (error) {
128
+ // Handle thrown redirect (e.g., throw redirect('/path'))
129
+ const redirectResponse = extractRedirectResponse(error);
130
+ if (redirectResponse) return redirectResponse;
131
+
132
+ // Attempt error boundary rendering for the PE path
133
+ const errorHtml = await renderPeErrorBoundary(
134
+ ctx,
135
+ request,
136
+ env,
137
+ url,
138
+ error,
139
+ handleStore,
140
+ nonce,
141
+ useActionStateId,
142
+ );
143
+ if (errorHtml) return errorHtml;
144
+
65
145
  ctx.callOnError(error, "action", {
66
146
  request,
67
147
  url,
68
148
  env,
149
+ actionId: useActionStateId,
69
150
  handledByBoundary: false,
70
151
  });
71
152
  console.error("[RSC] Progressive enhancement action error:", error);
@@ -84,6 +165,23 @@ export async function handleProgressiveEnhancement<TEnv>(
84
165
  const loadedAction = await ctx.loadServerAction(directActionId);
85
166
  actionResult = await loadedAction.apply(null, args);
86
167
  } catch (error) {
168
+ // Handle thrown redirect (e.g., throw redirect('/path'))
169
+ const redirectResponse = extractRedirectResponse(error);
170
+ if (redirectResponse) return redirectResponse;
171
+
172
+ // Attempt error boundary rendering for the PE path
173
+ const errorHtml = await renderPeErrorBoundary(
174
+ ctx,
175
+ request,
176
+ env,
177
+ url,
178
+ error,
179
+ handleStore,
180
+ nonce,
181
+ directActionId,
182
+ );
183
+ if (errorHtml) return errorHtml;
184
+
87
185
  ctx.callOnError(error, "action", {
88
186
  request,
89
187
  url,
@@ -95,6 +193,20 @@ export async function handleProgressiveEnhancement<TEnv>(
95
193
  }
96
194
  }
97
195
 
196
+ // Handle Response returned from action during PE.
197
+ // In the JS path, executeServerAction intercepts redirect Responses and
198
+ // short-circuits. The PE path must handle them too.
199
+ if (actionResult instanceof Response) {
200
+ const redirectResponse = extractRedirectResponse(actionResult);
201
+ if (redirectResponse) return redirectResponse;
202
+ // W3: Non-redirect Response — discard it so it doesn't flow into
203
+ // decodeFormState or the re-render payload.
204
+ if (process.env.NODE_ENV !== "production") {
205
+ warnNonRedirectPeResponse();
206
+ }
207
+ actionResult = undefined;
208
+ }
209
+
98
210
  // Decode form state for useActionState progressive enhancement
99
211
  try {
100
212
  reactFormState = await ctx.decodeFormState(actionResult, formData);
@@ -108,28 +220,126 @@ export async function handleProgressiveEnhancement<TEnv>(
108
220
  console.error("[RSC] Failed to decode form state:", error);
109
221
  }
110
222
 
111
- // Re-render the page and return HTML
112
- const renderRequest = new Request(url.toString(), {
113
- method: "GET",
114
- headers: new Headers({ accept: "text/html" }),
115
- });
223
+ // Re-render the page and return HTML.
224
+ // Route middleware wraps the render so context variables, headers, and
225
+ // cookies set by route middleware are available during re-render — matching
226
+ // the behavior of JS-enabled requests.
227
+ const renderPage = async (): Promise<Response> => {
228
+ const renderRequest = new Request(url.toString(), {
229
+ method: "GET",
230
+ headers: new Headers({ accept: "text/html" }),
231
+ });
232
+
233
+ const match = await ctx.router.match(renderRequest, { env });
234
+
235
+ if (match.redirect) {
236
+ return createResponseWithMergedHeaders(null, {
237
+ status: 308,
238
+ headers: { Location: match.redirect },
239
+ });
240
+ }
241
+
242
+ const payload: RscPayload = {
243
+ metadata: {
244
+ pathname: url.pathname,
245
+ segments: match.segments,
246
+ matched: match.matched,
247
+ diff: match.diff,
248
+ isPartial: false,
249
+ rootLayout: ctx.router.rootLayout,
250
+ handles: handleStore.stream(),
251
+ version: ctx.version,
252
+ themeConfig: ctx.router.themeConfig,
253
+ warmupEnabled: ctx.router.warmupEnabled,
254
+ initialTheme: requireRequestContext().theme,
255
+ },
256
+ formState: actionResult,
257
+ };
258
+
259
+ const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
260
+ const [ssrModule, streamMode] = await Promise.all([
261
+ ctx.loadSSRModule(),
262
+ ctx.resolveStreamMode(request, env, url),
263
+ ]);
264
+ const htmlStream = await ssrModule.renderHTML(rscStream, {
265
+ formState: reactFormState,
266
+ nonce,
267
+ streamMode,
268
+ });
116
269
 
117
- const match = await ctx.router.match(renderRequest, { env });
270
+ return createResponseWithMergedHeaders(htmlStream, {
271
+ headers: { "content-type": "text/html;charset=utf-8" },
272
+ });
273
+ };
118
274
 
119
- if (match.redirect) {
120
- return createResponseWithMergedHeaders(null, {
121
- status: 308,
122
- headers: { Location: match.redirect },
275
+ // Execute route middleware wrapping the render, if any.
276
+ // finalizeResponse drains onResponse callbacks that middleware short-circuits
277
+ // may leave behind (executeMiddleware does not finalize them itself).
278
+ if (routeMwInfo?.routeMiddleware && routeMwInfo.routeMiddleware.length > 0) {
279
+ return finalizeResponse(
280
+ await executeMiddleware(
281
+ buildRouteMiddlewareEntries(routeMwInfo.routeMiddleware),
282
+ request,
283
+ env,
284
+ routeMwInfo.variables,
285
+ renderPage,
286
+ routeMwInfo.routeReverse,
287
+ ),
288
+ );
289
+ }
290
+
291
+ return renderPage();
292
+ }
293
+
294
+ /**
295
+ * Attempt to render an error boundary as full HTML for the PE path.
296
+ * Returns null if no error boundary is found (caller falls through to
297
+ * normal page re-render).
298
+ */
299
+ async function renderPeErrorBoundary<TEnv>(
300
+ ctx: HandlerContext<TEnv>,
301
+ request: Request,
302
+ env: TEnv,
303
+ url: URL,
304
+ error: unknown,
305
+ handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
306
+ nonce: string | undefined,
307
+ actionId?: string | null,
308
+ ): Promise<Response | null> {
309
+ let errorResult;
310
+ try {
311
+ errorResult = await ctx.router.matchError(request, { env }, error, "route");
312
+ } catch (matchErr) {
313
+ ctx.callOnError(error, "action", {
314
+ request,
315
+ url,
316
+ env,
317
+ actionId: actionId ?? undefined,
318
+ handledByBoundary: false,
123
319
  });
320
+ throw matchErr;
124
321
  }
125
322
 
323
+ if (!errorResult) return null;
324
+
325
+ ctx.callOnError(error, "action", {
326
+ request,
327
+ url,
328
+ env,
329
+ actionId: actionId ?? undefined,
330
+ handledByBoundary: true,
331
+ });
332
+
333
+ setRequestContextParams(errorResult.params, errorResult.routeName);
334
+
126
335
  const payload: RscPayload = {
127
336
  metadata: {
128
337
  pathname: url.pathname,
129
- segments: match.segments,
130
- matched: match.matched,
131
- diff: match.diff,
338
+ segments: errorResult.segments,
339
+ matched: errorResult.matched,
340
+ diff: errorResult.diff,
132
341
  isPartial: false,
342
+ isError: true,
133
343
  rootLayout: ctx.router.rootLayout,
134
344
  handles: handleStore.stream(),
135
345
  version: ctx.version,
@@ -137,17 +347,20 @@ export async function handleProgressiveEnhancement<TEnv>(
137
347
  warmupEnabled: ctx.router.warmupEnabled,
138
348
  initialTheme: requireRequestContext().theme,
139
349
  },
140
- formState: actionResult,
141
350
  };
142
351
 
143
352
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
144
- const ssrModule = await ctx.loadSSRModule();
353
+ const [ssrModule, streamMode] = await Promise.all([
354
+ ctx.loadSSRModule(),
355
+ ctx.resolveStreamMode(request, env, url),
356
+ ]);
145
357
  const htmlStream = await ssrModule.renderHTML(rscStream, {
146
- formState: reactFormState,
147
358
  nonce,
359
+ streamMode,
148
360
  });
149
361
 
150
362
  return createResponseWithMergedHeaders(htmlStream, {
363
+ status: 500,
151
364
  headers: { "content-type": "text/html;charset=utf-8" },
152
365
  });
153
366
  }