@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.
- package/README.md +46 -8
- package/dist/bin/rango.js +105 -18
- package/dist/vite/index.js +227 -93
- package/package.json +15 -14
- package/skills/hooks/SKILL.md +1 -1
- package/skills/intercept/SKILL.md +79 -0
- package/skills/layout/SKILL.md +62 -2
- package/skills/loader/SKILL.md +94 -1
- package/skills/middleware/SKILL.md +81 -0
- package/skills/parallel/SKILL.md +57 -2
- package/skills/prerender/SKILL.md +187 -17
- package/skills/route/SKILL.md +42 -1
- package/skills/router-setup/SKILL.md +77 -0
- package/src/__internal.ts +1 -1
- package/src/bin/rango.ts +38 -19
- package/src/browser/action-coordinator.ts +97 -0
- package/src/browser/event-controller.ts +25 -27
- package/src/browser/history-state.ts +80 -0
- package/src/browser/intercept-utils.ts +1 -1
- package/src/browser/link-interceptor.ts +0 -3
- package/src/browser/merge-segment-loaders.ts +9 -2
- package/src/browser/navigation-bridge.ts +46 -13
- package/src/browser/navigation-client.ts +32 -61
- package/src/browser/navigation-store.ts +1 -31
- package/src/browser/navigation-transaction.ts +46 -207
- package/src/browser/partial-update.ts +102 -150
- package/src/browser/{prefetch-cache.ts → prefetch/cache.ts} +23 -4
- package/src/browser/{prefetch-fetch.ts → prefetch/fetch.ts} +36 -8
- package/src/browser/prefetch/policy.ts +42 -0
- package/src/browser/{prefetch-queue.ts → prefetch/queue.ts} +10 -3
- package/src/browser/react/Link.tsx +28 -23
- package/src/browser/react/NavigationProvider.tsx +9 -1
- package/src/browser/react/index.ts +2 -6
- package/src/browser/react/location-state-shared.ts +1 -1
- package/src/browser/react/location-state.ts +2 -0
- package/src/browser/react/nonce-context.ts +23 -0
- package/src/browser/react/use-action.ts +9 -1
- package/src/browser/react/use-handle.ts +3 -25
- package/src/browser/react/use-params.ts +2 -4
- package/src/browser/react/use-pathname.ts +2 -3
- package/src/browser/react/use-router.ts +1 -1
- package/src/browser/react/use-search-params.ts +2 -1
- package/src/browser/react/use-segments.ts +7 -60
- package/src/browser/response-adapter.ts +73 -0
- package/src/browser/rsc-router.tsx +29 -23
- package/src/browser/scroll-restoration.ts +10 -7
- package/src/browser/server-action-bridge.ts +115 -96
- package/src/browser/types.ts +1 -31
- package/src/browser/validate-redirect-origin.ts +29 -0
- package/src/build/generate-manifest.ts +5 -0
- package/src/build/generate-route-types.ts +2 -0
- package/src/build/route-types/codegen.ts +13 -4
- package/src/build/route-types/include-resolution.ts +13 -0
- package/src/build/route-types/per-module-writer.ts +15 -3
- package/src/build/route-types/router-processing.ts +45 -3
- package/src/build/runtime-discovery.ts +13 -1
- package/src/cache/background-task.ts +34 -0
- package/src/cache/cache-key-utils.ts +44 -0
- package/src/cache/cache-policy.ts +125 -0
- package/src/cache/cache-runtime.ts +132 -96
- package/src/cache/cache-scope.ts +71 -73
- package/src/cache/cf/cf-cache-store.ts +9 -4
- package/src/cache/document-cache.ts +72 -47
- package/src/cache/handle-capture.ts +81 -0
- package/src/cache/memory-segment-store.ts +18 -7
- package/src/cache/profile-registry.ts +43 -8
- package/src/cache/read-through-swr.ts +134 -0
- package/src/cache/segment-codec.ts +101 -112
- package/src/cache/taint.ts +26 -0
- package/src/client.tsx +53 -30
- package/src/errors.ts +6 -1
- package/src/handle.ts +1 -1
- package/src/handles/MetaTags.tsx +5 -2
- package/src/host/cookie-handler.ts +8 -3
- package/src/host/router.ts +14 -1
- package/src/href-client.ts +3 -1
- package/src/index.rsc.ts +33 -1
- package/src/index.ts +27 -0
- package/src/loader.rsc.ts +12 -4
- package/src/loader.ts +8 -0
- package/src/prerender/store.ts +4 -3
- package/src/prerender.ts +76 -18
- package/src/reverse.ts +11 -7
- package/src/root-error-boundary.tsx +30 -26
- package/src/route-definition/dsl-helpers.ts +9 -6
- package/src/route-definition/redirect.ts +15 -3
- package/src/route-map-builder.ts +38 -2
- package/src/route-name.ts +53 -0
- package/src/route-types.ts +7 -0
- package/src/router/content-negotiation.ts +1 -1
- package/src/router/debug-manifest.ts +16 -3
- package/src/router/handler-context.ts +94 -15
- package/src/router/intercept-resolution.ts +6 -4
- package/src/router/lazy-includes.ts +4 -0
- package/src/router/loader-resolution.ts +1 -0
- package/src/router/logging.ts +100 -3
- package/src/router/manifest.ts +32 -3
- package/src/router/match-api.ts +61 -7
- package/src/router/match-context.ts +3 -0
- package/src/router/match-handlers.ts +185 -11
- package/src/router/match-middleware/background-revalidation.ts +65 -85
- package/src/router/match-middleware/cache-lookup.ts +69 -4
- package/src/router/match-middleware/cache-store.ts +2 -0
- package/src/router/match-pipelines.ts +8 -43
- package/src/router/middleware-types.ts +7 -0
- package/src/router/middleware.ts +93 -8
- package/src/router/pattern-matching.ts +41 -5
- package/src/router/prerender-match.ts +34 -6
- package/src/router/preview-match.ts +7 -1
- package/src/router/revalidation.ts +61 -2
- package/src/router/router-context.ts +15 -0
- package/src/router/router-interfaces.ts +34 -0
- package/src/router/router-options.ts +200 -0
- package/src/router/segment-resolution/fresh.ts +123 -30
- package/src/router/segment-resolution/helpers.ts +19 -0
- package/src/router/segment-resolution/loader-cache.ts +37 -146
- package/src/router/segment-resolution/revalidation.ts +358 -94
- package/src/router/segment-wrappers.ts +3 -0
- package/src/router/telemetry-otel.ts +299 -0
- package/src/router/telemetry.ts +300 -0
- package/src/router/timeout.ts +148 -0
- package/src/router/types.ts +7 -1
- package/src/router.ts +155 -11
- package/src/rsc/handler-context.ts +11 -0
- package/src/rsc/handler.ts +380 -88
- package/src/rsc/helpers.ts +25 -16
- package/src/rsc/loader-fetch.ts +84 -42
- package/src/rsc/origin-guard.ts +141 -0
- package/src/rsc/progressive-enhancement.ts +232 -19
- package/src/rsc/response-route-handler.ts +37 -26
- package/src/rsc/rsc-rendering.ts +12 -5
- package/src/rsc/runtime-warnings.ts +42 -0
- package/src/rsc/server-action.ts +134 -58
- package/src/rsc/types.ts +8 -0
- package/src/search-params.ts +22 -10
- package/src/server/context.ts +53 -5
- package/src/server/fetchable-loader-store.ts +11 -6
- package/src/server/handle-store.ts +66 -9
- package/src/server/loader-registry.ts +11 -46
- package/src/server/request-context.ts +90 -9
- package/src/ssr/index.tsx +63 -27
- package/src/static-handler.ts +7 -0
- package/src/theme/ThemeProvider.tsx +6 -1
- package/src/theme/index.ts +1 -6
- package/src/theme/theme-context.ts +1 -28
- package/src/theme/theme-script.ts +2 -1
- package/src/types/cache-types.ts +5 -0
- package/src/types/error-types.ts +3 -0
- package/src/types/global-namespace.ts +9 -0
- package/src/types/handler-context.ts +35 -13
- package/src/types/loader-types.ts +7 -0
- package/src/types/route-entry.ts +28 -0
- package/src/urls/include-helper.ts +49 -8
- package/src/urls/index.ts +1 -0
- package/src/urls/path-helper-types.ts +30 -12
- package/src/urls/path-helper.ts +17 -2
- package/src/urls/pattern-types.ts +21 -1
- package/src/urls/response-types.ts +27 -2
- package/src/urls/type-extraction.ts +23 -15
- package/src/use-loader.tsx +12 -4
- package/src/vite/discovery/bundle-postprocess.ts +12 -7
- package/src/vite/discovery/discover-routers.ts +30 -18
- package/src/vite/discovery/prerender-collection.ts +24 -27
- package/src/vite/discovery/route-types-writer.ts +7 -7
- package/src/vite/discovery/virtual-module-codegen.ts +5 -2
- package/src/vite/plugins/client-ref-hashing.ts +3 -3
- package/src/vite/plugins/use-cache-transform.ts +91 -3
- package/src/vite/rango.ts +3 -3
- package/src/vite/router-discovery.ts +99 -36
- package/src/vite/utils/prerender-utils.ts +21 -0
- package/src/vite/utils/shared-utils.ts +3 -1
- package/src/browser/request-controller.ts +0 -164
- package/src/href-context.ts +0 -33
- package/src/router.gen.ts +0 -6
- package/src/static-handler.gen.ts +0 -5
- package/src/urls.gen.ts +0 -8
- /package/src/browser/{prefetch-observer.ts → prefetch/observer.ts} +0 -0
package/src/rsc/loader-fetch.ts
CHANGED
|
@@ -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 {
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
params
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
reqCtx._routeName
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
165
|
+
const result = await fn(loaderCtx);
|
|
125
166
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
167
|
+
interface LoaderPayload {
|
|
168
|
+
loaderResult: unknown;
|
|
169
|
+
}
|
|
170
|
+
const loaderPayload: LoaderPayload = { loaderResult: result };
|
|
171
|
+
const rscStream =
|
|
172
|
+
ctx.renderToReadableStream<LoaderPayload>(loaderPayload);
|
|
132
173
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
270
|
+
return createResponseWithMergedHeaders(htmlStream, {
|
|
271
|
+
headers: { "content-type": "text/html;charset=utf-8" },
|
|
272
|
+
});
|
|
273
|
+
};
|
|
118
274
|
|
|
119
|
-
if
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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:
|
|
130
|
-
matched:
|
|
131
|
-
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
|
|
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
|
}
|