@rangojs/router 0.0.0-experimental.124 → 0.0.0-experimental.126
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 +6 -4
- package/dist/bin/rango.js +3 -4
- package/dist/vite/index.js +315 -68
- package/package.json +19 -18
- package/skills/breadcrumbs/SKILL.md +60 -0
- package/skills/hooks/SKILL.md +2 -2
- package/skills/route/SKILL.md +6 -0
- package/skills/server-actions/SKILL.md +25 -1
- package/skills/testing/SKILL.md +17 -17
- package/skills/testing/cache-prerender.md +29 -3
- package/skills/testing/flight.md +13 -10
- package/skills/testing/render-handler.md +3 -0
- package/skills/testing/server-tree.md +1 -1
- package/skills/testing/setup.md +1 -1
- package/src/__internal.ts +0 -65
- package/src/browser/action-coordinator.ts +1 -1
- package/src/browser/action-fence.ts +10 -0
- package/src/browser/event-controller.ts +1 -83
- package/src/browser/navigation-store-handle.ts +3 -4
- package/src/browser/navigation-store.ts +0 -39
- package/src/browser/navigation-transaction.ts +0 -32
- package/src/browser/partial-update.ts +23 -84
- package/src/browser/prefetch/cache.ts +6 -45
- package/src/browser/prefetch/queue.ts +6 -3
- package/src/browser/rango-state.ts +2 -23
- package/src/browser/react/Link.tsx +0 -2
- package/src/browser/react/NavigationProvider.tsx +2 -1
- package/src/browser/react/ScrollRestoration.tsx +10 -6
- package/src/browser/react/filter-segment-order.ts +0 -2
- package/src/browser/react/index.ts +0 -45
- package/src/browser/react/location-state-shared.ts +0 -13
- package/src/browser/react/location-state.ts +0 -1
- package/src/browser/react/use-action.ts +6 -15
- package/src/browser/react/use-handle.ts +0 -5
- package/src/browser/react/use-link-status.ts +0 -4
- package/src/browser/react/use-navigation.ts +0 -3
- package/src/browser/react/use-params.ts +0 -2
- package/src/browser/react/use-router.ts +2 -1
- package/src/browser/react/use-search-params.ts +0 -5
- package/src/browser/react/use-segments.ts +0 -13
- package/src/browser/rsc-router.tsx +10 -3
- package/src/browser/server-action-bridge.ts +51 -3
- package/src/browser/types.ts +23 -5
- package/src/browser/validate-redirect-origin.ts +43 -16
- package/src/build/index.ts +8 -9
- package/src/build/route-trie.ts +46 -11
- package/src/build/route-types/param-extraction.ts +6 -3
- package/src/build/route-types/router-processing.ts +0 -8
- package/src/cache/cache-policy.ts +0 -54
- package/src/cache/cache-runtime.ts +48 -24
- package/src/cache/cache-scope.ts +0 -27
- package/src/cache/cache-tag.ts +0 -37
- package/src/cache/cf/cf-cache-store.ts +72 -45
- package/src/cache/cf/index.ts +0 -24
- package/src/cache/document-cache.ts +10 -36
- package/src/cache/handle-snapshot.ts +0 -40
- package/src/cache/index.ts +0 -27
- package/src/cache/memory-segment-store.ts +0 -52
- package/src/cache/profile-registry.ts +6 -30
- package/src/cache/read-through-swr.ts +41 -11
- package/src/cache/segment-codec.ts +0 -16
- package/src/cache/types.ts +0 -98
- package/src/client.rsc.tsx +4 -22
- package/src/client.tsx +19 -32
- package/src/context-var.ts +12 -0
- package/src/defer.ts +196 -0
- package/src/deps/ssr.ts +0 -1
- package/src/handle.ts +2 -12
- package/src/handles/MetaTags.tsx +0 -14
- package/src/handles/breadcrumbs.ts +16 -5
- package/src/handles/meta.ts +0 -39
- package/src/host/cookie-handler.ts +0 -36
- package/src/host/errors.ts +0 -24
- package/src/host/index.ts +6 -0
- package/src/host/pattern-matcher.ts +7 -50
- package/src/host/router.ts +1 -65
- package/src/host/testing.ts +0 -16
- package/src/host/types.ts +6 -2
- package/src/href-client.ts +0 -4
- package/src/index.rsc.ts +27 -2
- package/src/index.ts +7 -0
- package/src/internal-debug.ts +2 -4
- package/src/loader.rsc.ts +4 -15
- package/src/loader.ts +3 -9
- package/src/network-error-thrower.tsx +1 -6
- package/src/outlet-provider.tsx +1 -5
- package/src/prerender/param-hash.ts +10 -11
- package/src/prerender/store.ts +23 -30
- package/src/prerender.ts +34 -0
- package/src/redirect-origin.ts +100 -0
- package/src/root-error-boundary.tsx +1 -19
- package/src/route-content-wrapper.tsx +1 -44
- package/src/route-definition/dsl-helpers.ts +7 -19
- package/src/route-definition/helpers-types.ts +3 -3
- package/src/route-definition/redirect.ts +43 -9
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/route-map-builder.ts +0 -16
- package/src/router/content-negotiation.ts +0 -13
- package/src/router/error-handling.ts +12 -16
- package/src/router/find-match.ts +4 -31
- package/src/router/intercept-resolution.ts +10 -1
- package/src/router/lazy-includes.ts +1 -57
- package/src/router/loader-resolution.ts +25 -23
- package/src/router/logging.ts +0 -6
- package/src/router/manifest.ts +1 -25
- package/src/router/match-api.ts +0 -20
- package/src/router/match-context.ts +0 -22
- package/src/router/match-handlers.ts +0 -43
- package/src/router/match-middleware/background-revalidation.ts +0 -7
- package/src/router/match-middleware/cache-lookup.ts +96 -179
- package/src/router/match-middleware/cache-store.ts +0 -31
- package/src/router/match-middleware/intercept-resolution.ts +0 -22
- package/src/router/match-middleware/segment-resolution.ts +0 -22
- package/src/router/match-pipelines.ts +1 -42
- package/src/router/match-result.ts +1 -52
- package/src/router/metrics.ts +0 -34
- package/src/router/middleware-types.ts +0 -116
- package/src/router/middleware.ts +77 -60
- package/src/router/navigation-snapshot.ts +0 -51
- package/src/router/params-util.ts +23 -0
- package/src/router/pattern-matching.ts +5 -56
- package/src/router/prerender-match.ts +56 -51
- package/src/router/request-classification.ts +1 -38
- package/src/router/revalidation.ts +14 -62
- package/src/router/route-snapshot.ts +0 -1
- package/src/router/router-context.ts +0 -27
- package/src/router/router-interfaces.ts +10 -0
- package/src/router/segment-resolution/fresh.ts +25 -57
- package/src/router/segment-resolution/helpers.ts +34 -0
- package/src/router/segment-resolution/loader-cache.ts +35 -23
- package/src/router/segment-resolution/revalidation.ts +188 -283
- package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
- package/src/router/segment-resolution.ts +4 -1
- package/src/router/segment-wrappers.ts +0 -3
- package/src/router/telemetry-otel.ts +0 -20
- package/src/router/telemetry.ts +0 -22
- package/src/router/timeout.ts +0 -20
- package/src/router/trie-matching.ts +66 -45
- package/src/router/types.ts +1 -63
- package/src/router/url-params.ts +0 -5
- package/src/router.ts +8 -11
- package/src/rsc/handler-context.ts +1 -0
- package/src/rsc/handler.ts +20 -4
- package/src/rsc/helpers.ts +71 -3
- package/src/rsc/json-route-result.ts +38 -0
- package/src/rsc/origin-guard.ts +9 -15
- package/src/rsc/progressive-enhancement.ts +10 -1
- package/src/rsc/redirect-guard.ts +99 -0
- package/src/rsc/response-route-handler.ts +23 -18
- package/src/rsc/rsc-rendering.ts +2 -7
- package/src/rsc/runtime-warnings.ts +14 -0
- package/src/rsc/server-action.ts +34 -29
- package/src/rsc/types.ts +6 -3
- package/src/search-params.ts +0 -16
- package/src/segment-loader-promise.ts +14 -2
- package/src/segment-system.tsx +79 -88
- package/src/server/handle-store.ts +7 -24
- package/src/server/loader-registry.ts +5 -24
- package/src/server/request-context.ts +29 -92
- package/src/ssr/index.tsx +14 -14
- package/src/static-handler.ts +2 -27
- package/src/testing/cache-status.ts +44 -48
- package/src/testing/collect-handle.ts +1 -24
- package/src/testing/dispatch.ts +43 -6
- package/src/testing/e2e/index.ts +1 -22
- package/src/testing/e2e/matchers.ts +0 -16
- package/src/testing/flight-matchers.ts +0 -13
- package/src/testing/flight-normalize.ts +3 -30
- package/src/testing/flight.ts +46 -48
- package/src/testing/generated-routes.ts +1 -41
- package/src/testing/index.ts +1 -21
- package/src/testing/internal/context.ts +3 -45
- package/src/testing/internal/seed-vars.ts +0 -26
- package/src/testing/render-handler.ts +31 -61
- package/src/testing/render-route.tsx +75 -103
- package/src/testing/run-loader.ts +0 -96
- package/src/testing/run-middleware.ts +0 -26
- package/src/theme/ThemeProvider.tsx +0 -52
- package/src/theme/ThemeScript.tsx +0 -6
- package/src/theme/constants.ts +0 -12
- package/src/theme/index.ts +0 -7
- package/src/theme/theme-context.ts +1 -5
- package/src/theme/theme-script.ts +0 -14
- package/src/theme/use-theme.ts +0 -3
- package/src/types/boundaries.ts +0 -35
- package/src/types/error-types.ts +25 -89
- package/src/types/global-namespace.ts +4 -14
- package/src/types/handler-context.ts +28 -9
- package/src/types/index.ts +0 -10
- package/src/types/request-scope.ts +0 -19
- package/src/types/route-config.ts +6 -50
- package/src/types/route-entry.ts +0 -6
- package/src/types/segments.ts +0 -13
- package/src/urls/include-helper.ts +0 -4
- package/src/urls/index.ts +0 -6
- package/src/urls/path-helper-types.ts +2 -2
- package/src/urls/path-helper.ts +0 -54
- package/src/urls/urls-function.ts +0 -13
- package/src/use-loader.tsx +0 -186
- package/src/vite/discovery/bundle-postprocess.ts +2 -1
- package/src/vite/discovery/discover-routers.ts +28 -18
- package/src/vite/discovery/prerender-collection.ts +2 -4
- package/src/vite/discovery/state.ts +5 -0
- package/src/vite/discovery/virtual-module-codegen.ts +1 -11
- package/src/vite/plugin-types.ts +35 -9
- package/src/vite/plugins/cjs-to-esm.ts +0 -11
- package/src/vite/plugins/client-ref-dedup.ts +0 -11
- package/src/vite/plugins/client-ref-hashing.ts +0 -10
- package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
- package/src/vite/plugins/expose-action-id.ts +2 -73
- package/src/vite/plugins/expose-id-utils.ts +0 -55
- package/src/vite/plugins/expose-ids/export-analysis.ts +0 -38
- package/src/vite/plugins/expose-ids/handler-transform.ts +0 -15
- package/src/vite/plugins/expose-ids/loader-transform.ts +0 -15
- package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
- package/src/vite/plugins/expose-internal-ids.ts +10 -0
- package/src/vite/plugins/performance-tracks.ts +0 -3
- package/src/vite/plugins/refresh-cmd.ts +1 -1
- package/src/vite/plugins/use-cache-transform.ts +21 -46
- package/src/vite/plugins/version-injector.ts +0 -20
- package/src/vite/plugins/version-plugin.ts +1 -49
- package/src/vite/plugins/virtual-entries.ts +0 -15
- package/src/vite/rango.ts +2 -108
- package/src/vite/router-discovery.ts +9 -1
- package/src/vite/utils/ast-handler-extract.ts +0 -16
- package/src/vite/utils/bundle-analysis.ts +6 -13
- package/src/vite/utils/client-chunks.ts +0 -6
- package/src/vite/utils/forward-user-plugins.ts +0 -22
- package/src/vite/utils/manifest-utils.ts +0 -4
- package/src/vite/utils/package-resolution.ts +1 -73
- package/src/vite/utils/prerender-utils.ts +0 -35
- package/src/vite/utils/shared-utils.ts +3 -35
- package/src/browser/shallow.ts +0 -40
- package/src/handles/index.ts +0 -7
- package/src/router/middleware-cookies.ts +0 -55
package/src/rsc/helpers.ts
CHANGED
|
@@ -11,8 +11,14 @@ import {
|
|
|
11
11
|
import type { RequestContext } from "../server/request-context.js";
|
|
12
12
|
import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
|
|
13
13
|
import { isRedirectResponse } from "../response-utils.js";
|
|
14
|
+
import {
|
|
15
|
+
EXTERNAL_REDIRECT_MARKER,
|
|
16
|
+
isExternalRedirect,
|
|
17
|
+
markExternalRedirect,
|
|
18
|
+
} from "../redirect-origin.js";
|
|
14
19
|
import type { MiddlewareEntry, MiddlewareFn } from "../router/middleware.js";
|
|
15
20
|
import { formatCacheSignalHeader } from "../router/telemetry.js";
|
|
21
|
+
import type { RscPayload } from "./types.js";
|
|
16
22
|
|
|
17
23
|
/**
|
|
18
24
|
* DEVELOPMENT/TEST ONLY. When the debug cache signal gate is on,
|
|
@@ -40,6 +46,10 @@ function applyCacheSignalHeader(target: Headers, ctx: RequestContext): void {
|
|
|
40
46
|
function applyStubHeaders(target: Headers, stub: Headers): void {
|
|
41
47
|
stub.forEach((value, name) => {
|
|
42
48
|
try {
|
|
49
|
+
// The reserved external-redirect marker is internal and never a trust
|
|
50
|
+
// signal; never copy a stub value (e.g. a stray ctx.header() call) onto a
|
|
51
|
+
// browser-facing response. The opt-in is the out-of-band brand.
|
|
52
|
+
if (name.toLowerCase() === EXTERNAL_REDIRECT_MARKER) return;
|
|
43
53
|
if (name.toLowerCase() === "set-cookie") {
|
|
44
54
|
target.append(name, value);
|
|
45
55
|
} else if (!target.has(name)) {
|
|
@@ -63,10 +73,19 @@ function drainOnResponseCallbacks(
|
|
|
63
73
|
const callbacks = ctx._onResponseCallbacks;
|
|
64
74
|
if (callbacks.length === 0) return response;
|
|
65
75
|
ctx._onResponseCallbacks = [];
|
|
76
|
+
// An onResponse callback may return a NEW Response (e.g. to add a header),
|
|
77
|
+
// which drops the out-of-band external-redirect brand (brand is keyed on
|
|
78
|
+
// Response object identity). Preserve a redirect(url, { external: true })
|
|
79
|
+
// opt-in across that rebuild so a callback can't silently neutralize the
|
|
80
|
+
// off-host redirect at the guard chokepoint.
|
|
81
|
+
const wasExternal = isExternalRedirect(response);
|
|
66
82
|
let result = response;
|
|
67
83
|
for (const callback of callbacks) {
|
|
68
84
|
result = callback(result) ?? result;
|
|
69
85
|
}
|
|
86
|
+
if (wasExternal && !isExternalRedirect(result)) {
|
|
87
|
+
markExternalRedirect(result);
|
|
88
|
+
}
|
|
70
89
|
return result;
|
|
71
90
|
}
|
|
72
91
|
|
|
@@ -134,8 +153,20 @@ export function createSimpleRedirectResponse(redirectUrl: string): Response {
|
|
|
134
153
|
|
|
135
154
|
/**
|
|
136
155
|
* Carry over headers from a source redirect Response to a wrapper Response.
|
|
137
|
-
* Skips Location and X-RSC-Redirect (intentionally replaced by the wrapper)
|
|
138
|
-
*
|
|
156
|
+
* Skips Location and X-RSC-Redirect (intentionally replaced by the wrapper) and
|
|
157
|
+
* appends Set-Cookie to avoid clobbering multiple cookie headers.
|
|
158
|
+
*
|
|
159
|
+
* This is a GENERIC copier used by every redirect-rebuild path (PE
|
|
160
|
+
* extractRedirectResponse, the SPA intercept below, the guard's neutralize
|
|
161
|
+
* rebuild), so it has two redirect-specific jobs:
|
|
162
|
+
*
|
|
163
|
+
* 1. NEVER copy the reserved external-redirect header: it is no longer a trust
|
|
164
|
+
* signal (the opt-in is the out-of-band brand), and a forged value from a
|
|
165
|
+
* proxied upstream must not ride a rebuilt response to the browser.
|
|
166
|
+
* 2. Transfer the out-of-band external brand: a rebuilt document-native redirect
|
|
167
|
+
* has to carry the opt-in to the guard chokepoint, which reads and clears it.
|
|
168
|
+
* Without this transfer, redirect(url, { external: true }) would be silently
|
|
169
|
+
* neutralized on any rebuild path (fail-closed, but a feature regression).
|
|
139
170
|
*/
|
|
140
171
|
export function carryOverRedirectHeaders(
|
|
141
172
|
source: Response,
|
|
@@ -144,12 +175,16 @@ export function carryOverRedirectHeaders(
|
|
|
144
175
|
source.headers.forEach((value, name) => {
|
|
145
176
|
const lower = name.toLowerCase();
|
|
146
177
|
if (lower === "location" || lower === "x-rsc-redirect") return;
|
|
178
|
+
if (lower === EXTERNAL_REDIRECT_MARKER) return;
|
|
147
179
|
if (lower === "set-cookie") {
|
|
148
180
|
target.headers.append(name, value);
|
|
149
181
|
} else if (!target.headers.has(name)) {
|
|
150
182
|
target.headers.set(name, value);
|
|
151
183
|
}
|
|
152
184
|
});
|
|
185
|
+
if (isExternalRedirect(source)) {
|
|
186
|
+
markExternalRedirect(target);
|
|
187
|
+
}
|
|
153
188
|
}
|
|
154
189
|
|
|
155
190
|
/**
|
|
@@ -163,28 +198,62 @@ export function interceptRedirectForPartial(
|
|
|
163
198
|
createRedirectFlightResponse: (
|
|
164
199
|
redirectUrl: string,
|
|
165
200
|
locationState?: Record<string, unknown>,
|
|
201
|
+
external?: boolean,
|
|
166
202
|
) => Response,
|
|
167
203
|
): Response | null {
|
|
168
204
|
if (!isRedirectResponse(response)) {
|
|
169
205
|
return null;
|
|
170
206
|
}
|
|
171
207
|
const redirectUrl = response.headers.get("Location")!;
|
|
208
|
+
// redirect(url, { external: true }) marks an explicit off-host redirect via
|
|
209
|
+
// the out-of-band brand (not a wire header). On the SPA/action channel the
|
|
210
|
+
// intent must travel as a Flight payload (metadata.redirect.external) so the
|
|
211
|
+
// client does a scheme-validated hard navigation (location.assign) rather than
|
|
212
|
+
// a partial fetch. The client re-validates the scheme; see partial-update.ts.
|
|
213
|
+
const external = isExternalRedirect(response);
|
|
172
214
|
const locationState = getLocationState();
|
|
173
215
|
let intercepted: Response;
|
|
174
216
|
if (locationState) {
|
|
175
217
|
intercepted = createRedirectFlightResponse(
|
|
176
218
|
redirectUrl,
|
|
177
219
|
resolveLocationStateEntries(locationState),
|
|
220
|
+
external,
|
|
178
221
|
);
|
|
222
|
+
} else if (external) {
|
|
223
|
+
intercepted = createRedirectFlightResponse(redirectUrl, undefined, true);
|
|
179
224
|
} else {
|
|
180
225
|
intercepted = createSimpleRedirectResponse(redirectUrl);
|
|
181
226
|
}
|
|
182
227
|
|
|
183
228
|
carryOverRedirectHeaders(response, intercepted);
|
|
229
|
+
// Defense-in-depth at the SPA browser-facing exit: carryOverRedirectHeaders
|
|
230
|
+
// already refuses to copy the reserved marker, but strip any value that might
|
|
231
|
+
// exist on `intercepted` so a forged header can never ride the 200/204 to the
|
|
232
|
+
// browser. The external intent travels in metadata.redirect.external (Flight),
|
|
233
|
+
// where the client re-validates the scheme.
|
|
234
|
+
try {
|
|
235
|
+
intercepted.headers.delete(EXTERNAL_REDIRECT_MARKER);
|
|
236
|
+
} catch {
|
|
237
|
+
// Immutable headers: the marker was never copied here, so this is inert.
|
|
238
|
+
}
|
|
184
239
|
|
|
185
240
|
return intercepted;
|
|
186
241
|
}
|
|
187
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Attach location state set during a request to a payload's metadata.
|
|
245
|
+
* No-op if no location state was set. Callers must ensure payload.metadata
|
|
246
|
+
* is populated (the non-null assertion holds for the partial/action payloads
|
|
247
|
+
* that reach this helper).
|
|
248
|
+
*/
|
|
249
|
+
export function attachLocationStateIfPresent(payload: RscPayload): void {
|
|
250
|
+
const locationState = getLocationState();
|
|
251
|
+
if (locationState) {
|
|
252
|
+
payload.metadata!.locationState =
|
|
253
|
+
resolveLocationStateEntries(locationState);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
188
257
|
/**
|
|
189
258
|
* Only cache successful responses. Non-200 statuses (errors, redirects) are
|
|
190
259
|
* not cached -- notFound() produces 500 in response routes, and explicit
|
|
@@ -211,7 +280,6 @@ export function buildRouteMiddlewareEntries<TEnv>(
|
|
|
211
280
|
regex: null,
|
|
212
281
|
paramNames: [],
|
|
213
282
|
handler: mw.handler,
|
|
214
|
-
mountPrefix: null,
|
|
215
283
|
} as MiddlewareEntry<TEnv>,
|
|
216
284
|
params: mw.params,
|
|
217
285
|
}));
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared serialization for `json()` response-route results.
|
|
3
|
+
*
|
|
4
|
+
* Kept in its own lightweight module (depends only on `errors.js`) so the
|
|
5
|
+
* `dispatch()` testing primitive can import it WITHOUT dragging in
|
|
6
|
+
* `response-route-handler.ts`'s heavy runtime graph, which transitively reaches
|
|
7
|
+
* a Vite virtual module and breaks a plain (non-Vite) vitest import.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { RouterError } from "../errors.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Serialize a `json()` response-route result, rejecting a nested unresolved
|
|
14
|
+
* Promise (the forgotten-await footgun: `() => ({ data: fetchSomething() })`).
|
|
15
|
+
* `JSON.stringify` would silently emit `{}` for a Promise, shipping empty data;
|
|
16
|
+
* the RSC pipeline awaits nested promises but this path does not. Throwing
|
|
17
|
+
* `RESPONSE_NOT_SERIALIZABLE` makes the failure loud.
|
|
18
|
+
*
|
|
19
|
+
* Shared by the production response-route handler and the `dispatch()` testing
|
|
20
|
+
* primitive so a `dispatch` json test of a forgotten await fails exactly where
|
|
21
|
+
* production 500s, instead of going green.
|
|
22
|
+
*/
|
|
23
|
+
export function stringifyJsonRouteResult(result: unknown): string {
|
|
24
|
+
return JSON.stringify(result, (_key, value) => {
|
|
25
|
+
if (
|
|
26
|
+
value != null &&
|
|
27
|
+
typeof (value as { then?: unknown }).then === "function"
|
|
28
|
+
) {
|
|
29
|
+
throw new RouterError(
|
|
30
|
+
"RESPONSE_NOT_SERIALIZABLE",
|
|
31
|
+
"A json() response route returned a Promise (likely a forgotten " +
|
|
32
|
+
"await). Await async values before returning so they serialize, " +
|
|
33
|
+
"instead of emitting an empty {}.",
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
});
|
|
38
|
+
}
|
package/src/rsc/origin-guard.ts
CHANGED
|
@@ -69,11 +69,8 @@ export type OriginCheckConfig<TEnv = any> =
|
|
|
69
69
|
* Returns true to allow, false to reject.
|
|
70
70
|
*/
|
|
71
71
|
export function defaultOriginCheck(request: Request, url: URL): boolean {
|
|
72
|
-
// 1. Read Origin header (present on all cross-origin requests and
|
|
73
|
-
// same-origin POST/PUT/PATCH/DELETE in modern browsers)
|
|
74
72
|
let requestOrigin = request.headers.get("origin");
|
|
75
73
|
|
|
76
|
-
// 2. Fallback to Referer if Origin is absent (some proxies strip it)
|
|
77
74
|
if (!requestOrigin) {
|
|
78
75
|
const referer = request.headers.get("referer");
|
|
79
76
|
if (referer) {
|
|
@@ -85,23 +82,20 @@ export function defaultOriginCheck(request: Request, url: URL): boolean {
|
|
|
85
82
|
}
|
|
86
83
|
}
|
|
87
84
|
|
|
88
|
-
// 3. No Origin or Referer — allow (can't be browser-initiated CSRF)
|
|
89
85
|
if (!requestOrigin) return true;
|
|
90
86
|
|
|
91
|
-
// "null" origin comes from privacy-sensitive contexts (data: URLs,
|
|
92
|
-
// sandboxed iframes, cross-origin redirects). Reject it.
|
|
93
87
|
if (requestOrigin === "null") return false;
|
|
94
88
|
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
const
|
|
89
|
+
// An Origin/Referer is present, so this is a browser request worth checking.
|
|
90
|
+
// Establish the expected origin from the Host header only -- browsers always
|
|
91
|
+
// send Host alongside Origin (runtimes synthesize it from the HTTP/2
|
|
92
|
+
// :authority), so a missing Host here is anomalous. Fail closed rather than
|
|
93
|
+
// fall back to url.host (derived from the request line) when the trusted Host
|
|
94
|
+
// cannot be established.
|
|
95
|
+
const expectedHost = request.headers.get("host");
|
|
96
|
+
if (!expectedHost) return false;
|
|
102
97
|
|
|
103
|
-
|
|
104
|
-
const expectedOrigin = `${expectedProtocol}//${expectedHost}`;
|
|
98
|
+
const expectedOrigin = `${url.protocol}//${expectedHost}`;
|
|
105
99
|
|
|
106
100
|
return requestOrigin.toLowerCase() === expectedOrigin.toLowerCase();
|
|
107
101
|
}
|
|
@@ -155,6 +155,14 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
155
155
|
} else if (isDirectAction && directActionId) {
|
|
156
156
|
const temporaryReferences = ctx.createTemporaryReferenceSet();
|
|
157
157
|
|
|
158
|
+
// INTENTIONAL JS/PE divergence (do NOT "fix" to match the JS reject path).
|
|
159
|
+
// On the JS path React Flight-encodes the action args, so decodeReply
|
|
160
|
+
// succeeds or a failure means a malformed body (rejected). On the no-JS PE
|
|
161
|
+
// path the browser submits a raw <form action={fn}> POST with NO encoded
|
|
162
|
+
// args, so decodeReply throws by design and the raw FormData IS the action
|
|
163
|
+
// argument (the React form-action convention: fn(formData)). Removing this
|
|
164
|
+
// fallback breaks every unbound no-JS form action (verified: it fails the
|
|
165
|
+
// progressive-enhancement dev+prod e2e suite). See #572 (decided: keep).
|
|
158
166
|
let args: unknown[] = [];
|
|
159
167
|
try {
|
|
160
168
|
args = await ctx.decodeReply(formData, { temporaryReferences });
|
|
@@ -259,7 +267,6 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
259
267
|
warmupEnabled: ctx.router.warmupEnabled,
|
|
260
268
|
initialTheme: requireRequestContext().theme,
|
|
261
269
|
},
|
|
262
|
-
formState: actionResult,
|
|
263
270
|
};
|
|
264
271
|
|
|
265
272
|
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
@@ -277,6 +284,8 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
277
284
|
url,
|
|
278
285
|
undefined,
|
|
279
286
|
);
|
|
287
|
+
// reactFormState carries the useActionState payload via the SSR-option path
|
|
288
|
+
// (renderToReadableStream({ formState })); it does NOT travel on RscPayload.
|
|
280
289
|
const htmlStream = await ssrModule.renderHTML(rscStream, {
|
|
281
290
|
formState: reactFormState,
|
|
282
291
|
nonce,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side open-redirect guard.
|
|
3
|
+
*
|
|
4
|
+
* Applied to the FINAL handler response (the single top-level return in
|
|
5
|
+
* `handler.ts`) so every browser-followed redirect honors the same same-origin
|
|
6
|
+
* rule the client enforces (`browser/validate-redirect-origin.ts`), via the one
|
|
7
|
+
* shared resolver in `redirect-origin.ts`. This is the server half of the
|
|
8
|
+
* client's existing guard: the client can only validate redirects its own JS
|
|
9
|
+
* navigates to (the SPA/fetch channel), so document-native redirects -- a no-JS
|
|
10
|
+
* PE form POST, a full-page GET `match.redirect`, a middleware `redirect()`
|
|
11
|
+
* short-circuit, a response-route 3xx -- reach the browser with no client in the
|
|
12
|
+
* loop. They all funnel through one handler return, so guarding there covers
|
|
13
|
+
* every one and any future redirect exit.
|
|
14
|
+
*
|
|
15
|
+
* Soft (SPA/Flight) redirects are 200/204 responses (`X-RSC-Redirect` header or
|
|
16
|
+
* `metadata.redirect` payload) and are NOT redirect Responses, so they never
|
|
17
|
+
* reach this guard -- they stay validated client-side.
|
|
18
|
+
*
|
|
19
|
+
* Behavior on a `Location` header:
|
|
20
|
+
* - same-origin / relative -> passes through unchanged
|
|
21
|
+
* - `redirect(url, { external: true })` (out-of-band brand present) and an
|
|
22
|
+
* http(s) target -> allowed (explicit, auditable, unforgeable opt-in)
|
|
23
|
+
* - branded but a non-http(s) target (e.g. `javascript:`) -> neutralized: the
|
|
24
|
+
* opt-in waives the same-origin rule, NOT scheme safety
|
|
25
|
+
* - cross-origin without the brand -> Location rewritten to the basename root
|
|
26
|
+
* (a safe same-origin landing, the document analog of the client's "stay put");
|
|
27
|
+
* dev logs the blocked target and points to `{ external: true }`.
|
|
28
|
+
*
|
|
29
|
+
* The opt-in is an out-of-band brand on the Response object (isExternalRedirect),
|
|
30
|
+
* never a wire header: a header is forgeable by an attacker-controlled upstream
|
|
31
|
+
* response a proxy-style response route copies through, which would defeat the
|
|
32
|
+
* guard without app code ever opting in. The reserved header name is stripped
|
|
33
|
+
* defensively so a forged value can never reach the browser.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { isRedirectResponse } from "../response-utils.js";
|
|
37
|
+
import {
|
|
38
|
+
resolveSameOriginRedirect,
|
|
39
|
+
resolveExternalRedirect,
|
|
40
|
+
isExternalRedirect,
|
|
41
|
+
EXTERNAL_REDIRECT_MARKER,
|
|
42
|
+
} from "../redirect-origin.js";
|
|
43
|
+
import { carryOverRedirectHeaders } from "./helpers.js";
|
|
44
|
+
|
|
45
|
+
export function guardOutgoingRedirect(
|
|
46
|
+
response: Response,
|
|
47
|
+
requestOrigin: string,
|
|
48
|
+
basename: string | undefined,
|
|
49
|
+
): Response {
|
|
50
|
+
// Only 3xx + Location responses (document-native redirects) are guarded.
|
|
51
|
+
if (!isRedirectResponse(response)) {
|
|
52
|
+
return response;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The reserved marker is never a trust signal. Strip any value -- forged by a
|
|
56
|
+
// proxied upstream or otherwise -- so it can never reach the browser. Trust
|
|
57
|
+
// comes solely from the out-of-band brand below.
|
|
58
|
+
try {
|
|
59
|
+
response.headers.delete(EXTERNAL_REDIRECT_MARKER);
|
|
60
|
+
} catch {
|
|
61
|
+
// Some platform responses carry immutable headers; the header is inert on
|
|
62
|
+
// the browser, so a failed strip is harmless.
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// isRedirectResponse guarantees a truthy Location.
|
|
66
|
+
const location = response.headers.get("Location")!;
|
|
67
|
+
|
|
68
|
+
// Explicit opt-in via redirect(url, { external: true }): allow an off-host
|
|
69
|
+
// target, but only an http(s) one. external waives the same-origin rule, not
|
|
70
|
+
// scheme safety -- a branded javascript:/data: target falls through to be
|
|
71
|
+
// neutralized so it can never become a scriptable navigation downstream.
|
|
72
|
+
if (isExternalRedirect(response)) {
|
|
73
|
+
if (resolveExternalRedirect(location, requestOrigin) !== null) {
|
|
74
|
+
return response;
|
|
75
|
+
}
|
|
76
|
+
} else if (resolveSameOriginRedirect(location, requestOrigin) !== null) {
|
|
77
|
+
return response;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Cross-origin (or unsafe-scheme external): neutralize to a safe same-origin
|
|
81
|
+
// landing.
|
|
82
|
+
const safeTarget = basename && basename !== "/" ? basename : "/";
|
|
83
|
+
if (process.env.NODE_ENV !== "production") {
|
|
84
|
+
console.error(
|
|
85
|
+
`[rango] Blocked cross-origin redirect to "${location}"; sent to ` +
|
|
86
|
+
`"${safeTarget}" instead. To redirect off-host on purpose, use ` +
|
|
87
|
+
`redirect(url, { external: true }).`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const blocked = new Response(null, {
|
|
92
|
+
status: response.status,
|
|
93
|
+
headers: { Location: safeTarget },
|
|
94
|
+
});
|
|
95
|
+
// Preserve cookies and any other headers (Set-Cookie, Server-Timing, ...);
|
|
96
|
+
// carryOverRedirectHeaders intentionally skips Location.
|
|
97
|
+
carryOverRedirectHeaders(response, blocked);
|
|
98
|
+
return blocked;
|
|
99
|
+
}
|
|
@@ -30,6 +30,12 @@ import {
|
|
|
30
30
|
mergeStubHeadersAndFinalize,
|
|
31
31
|
} from "./helpers.js";
|
|
32
32
|
import { isWebSocketUpgradeResponse } from "../response-utils.js";
|
|
33
|
+
import { stringifyJsonRouteResult } from "./json-route-result.js";
|
|
34
|
+
import {
|
|
35
|
+
EXTERNAL_REDIRECT_MARKER,
|
|
36
|
+
isExternalRedirect,
|
|
37
|
+
markExternalRedirect,
|
|
38
|
+
} from "../redirect-origin.js";
|
|
33
39
|
|
|
34
40
|
export interface ResponseRouteMatch {
|
|
35
41
|
responseType: string;
|
|
@@ -110,16 +116,29 @@ export async function handleResponseRoute<TEnv>(
|
|
|
110
116
|
}
|
|
111
117
|
const headers = new Headers();
|
|
112
118
|
result.headers.forEach((value, key) => {
|
|
119
|
+
// Never copy the reserved external-redirect marker off a handler result.
|
|
120
|
+
// It is not a trust signal -- the opt-in is the out-of-band brand below
|
|
121
|
+
// -- and a proxy-style route returning an attacker-controlled upstream
|
|
122
|
+
// response must not let a forged value ride through to the browser.
|
|
123
|
+
if (key.toLowerCase() === EXTERNAL_REDIRECT_MARKER) return;
|
|
113
124
|
if (key.toLowerCase() === "set-cookie") {
|
|
114
125
|
headers.append(key, value);
|
|
115
126
|
} else {
|
|
116
127
|
headers.set(key, value);
|
|
117
128
|
}
|
|
118
129
|
});
|
|
119
|
-
|
|
130
|
+
const rewrapped = createResponseWithMergedHeaders(result.body, {
|
|
120
131
|
status: result.status,
|
|
121
132
|
headers,
|
|
122
133
|
});
|
|
134
|
+
// Transfer the out-of-band external brand only when the handler result is
|
|
135
|
+
// genuinely branded (a real redirect(url, { external: true })). A proxied
|
|
136
|
+
// upstream Response is never branded, so an attacker cannot opt a response
|
|
137
|
+
// route's redirect out of the same-origin guard by injecting the header.
|
|
138
|
+
if (isExternalRedirect(result)) {
|
|
139
|
+
markExternalRedirect(rewrapped);
|
|
140
|
+
}
|
|
141
|
+
return rewrapped;
|
|
123
142
|
};
|
|
124
143
|
|
|
125
144
|
try {
|
|
@@ -133,23 +152,9 @@ export async function handleResponseRoute<TEnv>(
|
|
|
133
152
|
if (preview.responseType === "json") {
|
|
134
153
|
// Runtime guard: the json() return type rejects nested Promises at
|
|
135
154
|
// compile time, but an `as`-cast or untyped (JS) handler can still slip
|
|
136
|
-
// one through.
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
const body = JSON.stringify(result, (_key, value) => {
|
|
140
|
-
if (
|
|
141
|
-
value != null &&
|
|
142
|
-
typeof (value as { then?: unknown }).then === "function"
|
|
143
|
-
) {
|
|
144
|
-
throw new RouterError(
|
|
145
|
-
"RESPONSE_NOT_SERIALIZABLE",
|
|
146
|
-
"A json() response route returned a Promise (likely a forgotten " +
|
|
147
|
-
"await). Await async values before returning so they serialize, " +
|
|
148
|
-
"instead of emitting an empty {}.",
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
return value;
|
|
152
|
-
});
|
|
155
|
+
// one through. stringifyJsonRouteResult throws a clear error instead of
|
|
156
|
+
// shipping empty data (shared with dispatch() so the two cannot drift).
|
|
157
|
+
const body = stringifyJsonRouteResult(result);
|
|
153
158
|
return createResponseWithMergedHeaders(body, {
|
|
154
159
|
status: 200,
|
|
155
160
|
headers: { "content-type": "application/json;charset=utf-8" },
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -9,9 +9,7 @@
|
|
|
9
9
|
import {
|
|
10
10
|
requireRequestContext,
|
|
11
11
|
setRequestContextParams,
|
|
12
|
-
getLocationState,
|
|
13
12
|
} from "../server/request-context.js";
|
|
14
|
-
import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
|
|
15
13
|
import { appendMetric } from "../router/metrics.js";
|
|
16
14
|
import { getSSRSetup, isRscRequest } from "./ssr-setup.js";
|
|
17
15
|
import type { RscPayload } from "./types.js";
|
|
@@ -19,6 +17,7 @@ import type { MatchResult } from "../types.js";
|
|
|
19
17
|
import {
|
|
20
18
|
createResponseWithMergedHeaders,
|
|
21
19
|
createSimpleRedirectResponse,
|
|
20
|
+
attachLocationStateIfPresent,
|
|
22
21
|
} from "./helpers.js";
|
|
23
22
|
import type { HandlerContext } from "./handler-context.js";
|
|
24
23
|
|
|
@@ -155,11 +154,7 @@ export async function handleRscRendering<TEnv>(
|
|
|
155
154
|
// SSR (full page) requests ignore location state since there's no history.state
|
|
156
155
|
// to write to on a fresh page load.
|
|
157
156
|
if (isPartial && payload.metadata) {
|
|
158
|
-
|
|
159
|
-
if (locationState) {
|
|
160
|
-
payload.metadata.locationState =
|
|
161
|
-
resolveLocationStateEntries(locationState);
|
|
162
|
-
}
|
|
157
|
+
attachLocationStateIfPresent(payload);
|
|
163
158
|
}
|
|
164
159
|
|
|
165
160
|
const metricsStore = reqCtx._metricsStore;
|
|
@@ -39,3 +39,17 @@ export function warnNonRedirectPeResponse(): void {
|
|
|
39
39
|
`ignored — the page will re-render at the current URL instead.`,
|
|
40
40
|
);
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Warn when a non-redirect Response is returned (not thrown) from an action
|
|
45
|
+
* on the JS (fetch) path. A raw Response cannot be serialized into Flight, so
|
|
46
|
+
* it is discarded — mirroring the PE path. Use `throw redirect('/path')` for
|
|
47
|
+
* redirects.
|
|
48
|
+
*/
|
|
49
|
+
export function warnNonRedirectActionResponse(actionId: string): void {
|
|
50
|
+
console.warn(
|
|
51
|
+
`[@rangojs/router] Server action "${actionId}" returned a Response ` +
|
|
52
|
+
`that is not a redirect. Non-redirect Responses cannot be serialized ` +
|
|
53
|
+
`and are ignored. Use \`throw redirect('/path')\` for redirects.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
package/src/rsc/server-action.ts
CHANGED
|
@@ -18,9 +18,7 @@
|
|
|
18
18
|
import {
|
|
19
19
|
requireRequestContext,
|
|
20
20
|
setRequestContextParams,
|
|
21
|
-
getLocationState,
|
|
22
21
|
} from "../server/request-context.js";
|
|
23
|
-
import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
|
|
24
22
|
import { appendMetric } from "../router/metrics.js";
|
|
25
23
|
import type { RscPayload } from "./types.js";
|
|
26
24
|
import {
|
|
@@ -28,21 +26,11 @@ import {
|
|
|
28
26
|
createResponseWithMergedHeaders,
|
|
29
27
|
createSimpleRedirectResponse,
|
|
30
28
|
interceptRedirectForPartial,
|
|
29
|
+
attachLocationStateIfPresent,
|
|
31
30
|
} from "./helpers.js";
|
|
31
|
+
import { warnNonRedirectActionResponse } from "./runtime-warnings.js";
|
|
32
32
|
import type { HandlerContext } from "./handler-context.js";
|
|
33
33
|
|
|
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
34
|
/**
|
|
47
35
|
* Data flowing from action execution to the revalidation phase.
|
|
48
36
|
* When the action completes without redirect/error-boundary, the handler
|
|
@@ -97,7 +85,10 @@ export async function executeServerAction<TEnv>(
|
|
|
97
85
|
args = await ctx.decodeReply(body, { temporaryReferences });
|
|
98
86
|
}
|
|
99
87
|
} catch (error) {
|
|
100
|
-
|
|
88
|
+
// Keep the original error as `cause` for server-side logging, but do not
|
|
89
|
+
// interpolate it into the message: that string can surface to the client
|
|
90
|
+
// and may leak decode internals.
|
|
91
|
+
throw new Error("Failed to decode action arguments", {
|
|
101
92
|
cause: error,
|
|
102
93
|
});
|
|
103
94
|
}
|
|
@@ -109,7 +100,7 @@ export async function executeServerAction<TEnv>(
|
|
|
109
100
|
|
|
110
101
|
try {
|
|
111
102
|
loadedAction = await ctx.loadServerAction(actionId);
|
|
112
|
-
|
|
103
|
+
let data = await loadedAction!.apply(null, args);
|
|
113
104
|
|
|
114
105
|
// Intercept redirect Responses: serializing one as the action returnValue
|
|
115
106
|
// would fail, and revalidation would run needlessly.
|
|
@@ -119,6 +110,14 @@ export async function executeServerAction<TEnv>(
|
|
|
119
110
|
ctx.createRedirectFlightResponse,
|
|
120
111
|
);
|
|
121
112
|
if (intercepted) return intercepted;
|
|
113
|
+
|
|
114
|
+
// Non-redirect Response returned (not thrown): a raw Response cannot be
|
|
115
|
+
// serialized into Flight. Discard it and re-render — mirroring the PE
|
|
116
|
+
// path (progressive-enhancement.ts) so JS and no-JS behave identically.
|
|
117
|
+
if (process.env.NODE_ENV !== "production") {
|
|
118
|
+
warnNonRedirectActionResponse(actionId);
|
|
119
|
+
}
|
|
120
|
+
data = undefined;
|
|
122
121
|
}
|
|
123
122
|
|
|
124
123
|
returnValue = { ok: true, data };
|
|
@@ -224,18 +223,21 @@ export async function executeServerAction<TEnv>(
|
|
|
224
223
|
}
|
|
225
224
|
|
|
226
225
|
// Build continuation for the revalidation phase
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
226
|
+
const actionMeta = loadedAction as
|
|
227
|
+
| { $id?: string; $$id?: string }
|
|
228
|
+
| undefined;
|
|
229
|
+
const resolvedActionId = actionMeta?.$id ?? actionMeta?.$$id ?? actionId;
|
|
231
230
|
|
|
232
231
|
return {
|
|
233
232
|
returnValue,
|
|
234
233
|
actionStatus,
|
|
235
234
|
temporaryReferences,
|
|
236
235
|
actionContext: {
|
|
236
|
+
// Defensive copy of the already-parsed url (avoids re-parsing
|
|
237
|
+
// request.url). actionUrl is persisted into the continuation and later
|
|
238
|
+
// flows into matchPartial, so it must not alias the handler's live url.
|
|
237
239
|
actionId: resolvedActionId,
|
|
238
|
-
actionUrl: new URL(
|
|
240
|
+
actionUrl: new URL(url),
|
|
239
241
|
actionResult: returnValue.data,
|
|
240
242
|
formData: actionFormData,
|
|
241
243
|
},
|
|
@@ -274,8 +276,8 @@ export async function revalidateAfterAction<TEnv>(
|
|
|
274
276
|
);
|
|
275
277
|
|
|
276
278
|
if (!matchResult) {
|
|
277
|
-
// matchPartial returns null when the route is a redirect or
|
|
278
|
-
//
|
|
279
|
+
// matchPartial returns null when the route is a redirect or no previous-URL
|
|
280
|
+
// context could be resolved. Check for redirect first.
|
|
279
281
|
const fullMatch = await ctx.router.match(request, { env });
|
|
280
282
|
setRequestContextParams(fullMatch.params, fullMatch.routeName);
|
|
281
283
|
|
|
@@ -286,14 +288,17 @@ export async function revalidateAfterAction<TEnv>(
|
|
|
286
288
|
return createSimpleRedirectResponse(fullMatch.redirect);
|
|
287
289
|
}
|
|
288
290
|
|
|
289
|
-
// Non-redirect: this branch is only reachable when
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
291
|
+
// Non-redirect: this branch is only reachable when no previous URL could
|
|
292
|
+
// be resolved (neither X-RSC-Router-Client-Path nor a usable Referer), or
|
|
293
|
+
// the previous URL was unparseable (defensive). The client requires
|
|
294
|
+
// isPartial for action responses, so producing a full payload here would
|
|
295
|
+
// be rejected. Return 500 instead.
|
|
293
296
|
throw new Error(
|
|
294
297
|
`[RSC] matchPartial returned null for a non-redirect route ` +
|
|
295
298
|
`during action revalidation (${url.pathname}). This indicates ` +
|
|
296
|
-
`a malformed action request
|
|
299
|
+
`a malformed action request: no previous-URL context could be ` +
|
|
300
|
+
`resolved (neither X-RSC-Router-Client-Path nor a usable Referer), ` +
|
|
301
|
+
`or the previous URL was unparseable.`,
|
|
297
302
|
);
|
|
298
303
|
}
|
|
299
304
|
|
|
@@ -319,7 +324,7 @@ export async function revalidateAfterAction<TEnv>(
|
|
|
319
324
|
returnValue,
|
|
320
325
|
};
|
|
321
326
|
|
|
322
|
-
|
|
327
|
+
attachLocationStateIfPresent(payload);
|
|
323
328
|
|
|
324
329
|
const renderStart = performance.now();
|
|
325
330
|
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
package/src/rsc/types.ts
CHANGED
|
@@ -53,13 +53,16 @@ export interface RscPayload {
|
|
|
53
53
|
basename?: string;
|
|
54
54
|
/** Whether connection warmup is enabled */
|
|
55
55
|
warmupEnabled?: boolean;
|
|
56
|
-
/**
|
|
57
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Server-side redirect with optional state (for partial requests).
|
|
58
|
+
* `external: true` (from redirect(url, { external: true })) tells the client
|
|
59
|
+
* to hard-navigate to an off-host target instead of validating same-origin.
|
|
60
|
+
*/
|
|
61
|
+
redirect?: { url: string; external?: boolean };
|
|
58
62
|
/** Server-set location state to include in history.pushState */
|
|
59
63
|
locationState?: Record<string, unknown>;
|
|
60
64
|
};
|
|
61
65
|
returnValue?: { ok: boolean; data: unknown };
|
|
62
|
-
formState?: unknown;
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
/**
|