@rangojs/router 0.31.0 → 0.33.0
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 +3 -2
- package/dist/bin/browser-7KIJ5HSV.js +15 -0
- package/dist/bin/chunk-3B7CKBRO.js +463 -0
- package/dist/bin/chunk-524VACCG.js +71 -0
- package/dist/bin/chunk-LD7UBHT4.js +196 -0
- package/dist/bin/chunk-TO6CRWLN.js +1429 -0
- package/dist/bin/chunk-TTXC2U3G.js +2477 -0
- package/dist/bin/client-urls-projection-DQLWPDAT.js +28 -0
- package/dist/bin/forward-user-plugins-URBZKJP4.js +62 -0
- package/dist/bin/rango.js +42 -10143
- package/dist/bin/runtime-EPUDEHO4.js +596 -0
- package/dist/bin/runtime-discovery-CZ2QHT4Q.js +352 -0
- package/dist/bin/state-ACD2MGKJ.js +16 -0
- package/dist/bin/temp-rsc-server-QRR6HD6T.js +3190 -0
- package/dist/types/bin/rango.d.ts +1 -0
- package/dist/types/browser/action-fence.d.ts +20 -0
- package/dist/types/browser/cookie-name.d.ts +51 -10
- package/dist/types/browser/history-state.d.ts +13 -2
- package/dist/types/browser/invalidate-client-cache.d.ts +8 -6
- package/dist/types/browser/navigation-bridge.d.ts +36 -1
- package/dist/types/browser/navigation-client.d.ts +19 -0
- package/dist/types/browser/navigation-store-handle.d.ts +37 -4
- package/dist/types/browser/navigation-store.d.ts +2 -2
- package/dist/types/browser/navigation-transaction.d.ts +0 -1
- package/dist/types/browser/partial-update.d.ts +13 -0
- package/dist/types/browser/prefetch/cache.d.ts +96 -8
- package/dist/types/browser/prefetch/fetch.d.ts +26 -2
- package/dist/types/browser/prefetch/queue.d.ts +3 -2
- package/dist/types/browser/rango-state.d.ts +34 -5
- package/dist/types/browser/react/Link.d.ts +19 -13
- package/dist/types/browser/react/location-state-shared.d.ts +2 -1
- package/dist/types/browser/types.d.ts +25 -0
- package/dist/types/build/generate-route-types.d.ts +1 -1
- package/dist/types/build/route-types/include-resolution.d.ts +9 -1
- package/dist/types/build/route-types/router-processing.d.ts +3 -1
- package/dist/types/cache/cache-key-utils.d.ts +12 -8
- package/dist/types/cache/cache-scope.d.ts +4 -2
- package/dist/types/cache/cf/cf-cache-types.d.ts +9 -5
- package/dist/types/cache/document-cache-policy.d.ts +9 -1
- package/dist/types/cache/memory-segment-store.d.ts +3 -2
- package/dist/types/cache/segment-codec.d.ts +2 -2
- package/dist/types/cache/shell-snapshot.d.ts +1 -1
- package/dist/types/cache/types.d.ts +12 -7
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -1
- package/dist/types/client-urls/types.d.ts +7 -5
- package/dist/types/prefetch-markers.d.ts +9 -0
- package/dist/types/prefetch-source-scope.d.ts +66 -0
- package/dist/types/route-definition/helpers-types.d.ts +8 -5
- package/dist/types/route-definition/redirect.d.ts +5 -0
- package/dist/types/router/intercept-resolution.d.ts +25 -11
- package/dist/types/router/match-context.d.ts +1 -0
- package/dist/types/router/match-handlers.d.ts +1 -1
- package/dist/types/router/middleware-types.d.ts +6 -0
- package/dist/types/router/prefetch-cache-ttl.d.ts +11 -3
- package/dist/types/router/router-context.d.ts +1 -1
- package/dist/types/router/router-options.d.ts +6 -3
- package/dist/types/router/segment-resolution/loader-cache.d.ts +8 -0
- package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +23 -7
- package/dist/types/router/segment-resolution/revalidation.d.ts +19 -0
- package/dist/types/router/source-reads.d.ts +53 -0
- package/dist/types/router/types.d.ts +1 -1
- package/dist/types/rsc/helpers.d.ts +8 -3
- package/dist/types/rsc/prefetch-render-error.d.ts +7 -2
- package/dist/types/rsc/request-stages.d.ts +4 -0
- package/dist/types/rsc/shell-serve.d.ts +6 -1
- package/dist/types/server/context.d.ts +23 -6
- package/dist/types/server/cookie-store.d.ts +15 -1
- package/dist/types/server/document-location-state.d.ts +59 -0
- package/dist/types/server/handle-store.d.ts +2 -0
- package/dist/types/server/request-context.d.ts +65 -12
- package/dist/types/testing/e2e/server.d.ts +16 -1
- package/dist/types/testing/render-route.d.ts +83 -2
- package/dist/types/testing/serve-shell-request.d.ts +15 -3
- package/dist/types/types/handler-context.d.ts +21 -4
- package/dist/types/types/loader-types.d.ts +7 -4
- package/dist/types/types/segments.d.ts +18 -1
- package/dist/types/urls/path-helper-types.d.ts +7 -5
- package/dist/types/vite/temp-rsc-server.d.ts +2 -2
- package/dist/types/vite/utils/forward-user-plugins.d.ts +32 -2
- package/dist/vite/index.js +451 -191
- package/package.json +2 -2
- package/skills/app-structure/SKILL.md +74 -0
- package/skills/app-structure/e-commerce.md +10 -9
- package/skills/app-structure/large-app.md +15 -2
- package/skills/caching/SKILL.md +10 -5
- package/skills/client-urls/SKILL.md +18 -11
- package/skills/comparison/references/framework-comparison.md +16 -10
- package/skills/document-cache/SKILL.md +13 -0
- package/skills/hooks/navigation.md +8 -8
- package/skills/hooks/state.md +132 -12
- package/skills/intercept/SKILL.md +33 -11
- package/skills/layout/SKILL.md +11 -0
- package/skills/links/SKILL.md +101 -4
- package/skills/loader/SKILL.md +49 -22
- package/skills/migrate-nextjs/SKILL.md +8 -2
- package/skills/migrate-react-router/data-and-actions.md +5 -0
- package/skills/ppr/SKILL.md +38 -5
- package/skills/prerender/SKILL.md +4 -1
- package/skills/route/SKILL.md +6 -6
- package/skills/router-setup/SKILL.md +2 -1
- package/skills/server-actions/SKILL.md +49 -8
- package/skills/testing/client-components.md +73 -32
- package/skills/testing/serve-shell-request.md +45 -20
- package/skills/testing/server-actions.md +8 -0
- package/skills/ux-architecture/SKILL.md +67 -15
- package/src/bin/rango.ts +39 -13
- package/src/browser/action-fence.ts +72 -0
- package/src/browser/cookie-name.ts +82 -14
- package/src/browser/history-state.ts +24 -2
- package/src/browser/invalidate-client-cache.ts +9 -7
- package/src/browser/navigation-bridge.ts +164 -32
- package/src/browser/navigation-client.ts +95 -20
- package/src/browser/navigation-store-handle.ts +67 -5
- package/src/browser/navigation-store.ts +86 -17
- package/src/browser/navigation-transaction.ts +0 -6
- package/src/browser/partial-update.ts +31 -12
- package/src/browser/prefetch/cache.ts +188 -11
- package/src/browser/prefetch/fetch.ts +211 -51
- package/src/browser/prefetch/queue.ts +3 -2
- package/src/browser/rango-state.ts +50 -8
- package/src/browser/react/Link.tsx +24 -16
- package/src/browser/react/location-state-shared.ts +8 -1
- package/src/browser/react/use-navigation.ts +1 -1
- package/src/browser/rsc-router.tsx +13 -3
- package/src/browser/server-action-bridge.ts +3 -3
- package/src/browser/types.ts +25 -0
- package/src/build/generate-route-types.ts +1 -0
- package/src/build/route-types/include-resolution.ts +40 -3
- package/src/build/route-types/router-processing.ts +170 -25
- package/src/build/route-types/scan-filter.ts +1 -0
- package/src/build/runtime-discovery.ts +46 -8
- package/src/cache/cache-key-utils.ts +12 -8
- package/src/cache/cache-runtime.ts +5 -1
- package/src/cache/cache-scope.ts +28 -7
- package/src/cache/cf/cf-cache-types.ts +9 -5
- package/src/cache/document-cache-policy.ts +37 -1
- package/src/cache/document-cache.ts +12 -0
- package/src/cache/memory-segment-store.ts +3 -2
- package/src/cache/segment-codec.ts +2 -2
- package/src/cache/shell-snapshot.ts +1 -1
- package/src/cache/types.ts +12 -7
- package/src/cache/vercel/vercel-cache-store.ts +2 -1
- package/src/client-urls/types.ts +7 -5
- package/src/prefetch-markers.ts +10 -0
- package/src/prefetch-source-scope.ts +137 -0
- package/src/route-definition/helpers-types.ts +8 -5
- package/src/route-definition/redirect.ts +5 -0
- package/src/router/intercept-resolution.ts +94 -50
- package/src/router/loader-resolution.ts +8 -6
- package/src/router/match-api.ts +33 -29
- package/src/router/match-context.ts +5 -0
- package/src/router/match-handlers.ts +2 -1
- package/src/router/match-middleware/cache-lookup.ts +39 -14
- package/src/router/match-result.ts +45 -85
- package/src/router/middleware-types.ts +6 -0
- package/src/router/navigation-snapshot.ts +18 -3
- package/src/router/prefetch-cache-ttl.ts +15 -3
- package/src/router/revalidation.ts +63 -13
- package/src/router/router-context.ts +2 -1
- package/src/router/router-options.ts +6 -3
- package/src/router/segment-resolution/loader-cache.ts +21 -3
- package/src/router/segment-resolution/prefetch-deferral.ts +38 -31
- package/src/router/segment-resolution/revalidation.ts +40 -12
- package/src/router/source-reads.ts +122 -0
- package/src/router/types.ts +2 -1
- package/src/router.ts +11 -12
- package/src/rsc/helpers.ts +20 -6
- package/src/rsc/prefetch-render-error.ts +7 -2
- package/src/rsc/progressive-enhancement.ts +8 -2
- package/src/rsc/request-stages.ts +87 -1
- package/src/rsc/rsc-rendering.ts +78 -32
- package/src/rsc/server-action.ts +2 -1
- package/src/rsc/shell-capture.ts +4 -5
- package/src/rsc/shell-serve.ts +12 -1
- package/src/segment-system.tsx +1 -1
- package/src/server/context.ts +37 -6
- package/src/server/cookie-store.ts +22 -1
- package/src/server/document-location-state.ts +334 -0
- package/src/server/handle-store.ts +2 -0
- package/src/server/request-context.ts +137 -33
- package/src/testing/dispatch.ts +6 -0
- package/src/testing/e2e/fixture.ts +4 -12
- package/src/testing/e2e/server.ts +91 -4
- package/src/testing/render-route.tsx +309 -10
- package/src/testing/run-revalidate.ts +11 -1
- package/src/testing/serve-shell-request.ts +22 -3
- package/src/types/handler-context.ts +21 -4
- package/src/types/loader-types.ts +7 -4
- package/src/types/segments.ts +18 -1
- package/src/urls/path-helper-types.ts +7 -5
- package/src/vite/temp-rsc-server.ts +2 -2
- package/src/vite/utils/forward-user-plugins.ts +13 -5
package/src/rsc/server-action.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
setRequestContextParams,
|
|
23
23
|
} from "../server/request-context.js";
|
|
24
24
|
import { printServerError } from "../server/print-server-error.js";
|
|
25
|
+
import { runActionBody } from "../server/cookie-store.js";
|
|
25
26
|
import { observePhase, PHASES } from "../router/instrument.js";
|
|
26
27
|
import { resolveActionRefId } from "../router/is-action.js";
|
|
27
28
|
import { getInterceptSourceHeader } from "../router/navigation-snapshot.js";
|
|
@@ -203,7 +204,7 @@ export async function executeServerAction<TEnv>(
|
|
|
203
204
|
|
|
204
205
|
try {
|
|
205
206
|
loadedAction = await ctx.loadServerAction(actionId);
|
|
206
|
-
let data = await loadedAction!.apply(null, args);
|
|
207
|
+
let data = await runActionBody(() => loadedAction!.apply(null, args));
|
|
207
208
|
|
|
208
209
|
// Intercept redirect Responses: serializing one as the action returnValue
|
|
209
210
|
// would fail, and revalidation would run needlessly.
|
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -552,7 +552,7 @@ const SNAPSHOT_BYTE_ENCODER = new TextEncoder();
|
|
|
552
552
|
|
|
553
553
|
/**
|
|
554
554
|
* Warn once per key that the capture's loader pins exceeded the route's
|
|
555
|
-
* `maxSnapshotBytes` cap, so no document shell was stored
|
|
555
|
+
* `maxSnapshotBytes` cap, so no document shell was stored. Once per
|
|
556
556
|
* key: the route recaptures every backoff window.
|
|
557
557
|
*/
|
|
558
558
|
function warnSnapshotOverCapOnce(
|
|
@@ -2603,8 +2603,7 @@ async function captureAndStoreShell(
|
|
|
2603
2603
|
throw new Error(`[rango] no shell pin for "${segmentKey}"`);
|
|
2604
2604
|
}
|
|
2605
2605
|
} catch (error) {
|
|
2606
|
-
// Unpinned, the loader would run on every HIT over this prelude
|
|
2607
|
-
// (dc-t89): refuse like an encode error.
|
|
2606
|
+
// Unpinned, the loader would run on every HIT over this prelude: refuse like an encode error.
|
|
2608
2607
|
reqCtx._renderErrors?.push(error);
|
|
2609
2608
|
}
|
|
2610
2609
|
}
|
|
@@ -2650,7 +2649,7 @@ async function captureAndStoreShell(
|
|
|
2650
2649
|
}
|
|
2651
2650
|
|
|
2652
2651
|
// Snapshot size guard: `maxSnapshotBytes` bounds the loader pins, and a
|
|
2653
|
-
// document HIT needs every pin its prelude baked
|
|
2652
|
+
// document HIT needs every pin its prelude baked. Over the cap
|
|
2654
2653
|
// the entry keeps the navigation half only (the doc record, or nothing:
|
|
2655
2654
|
// a tombstone that also shadows a build shell) and the attempt refuses.
|
|
2656
2655
|
// The whole entry is bounded separately below, by the store's limit.
|
|
@@ -2897,7 +2896,7 @@ export {
|
|
|
2897
2896
|
|
|
2898
2897
|
// Exported for unit tests that pin the refused-capture backoff policy directly
|
|
2899
2898
|
// (dev cap vs production exponential growth, stored-clears, cold-start re-probe),
|
|
2900
|
-
// and markCaptureBackoff for serveShellHit (a pin that failed to decode
|
|
2899
|
+
// and markCaptureBackoff for serveShellHit (a pin that failed to decode).
|
|
2901
2900
|
// These are the same module-level functions the schedule path uses; a test that
|
|
2902
2901
|
// drove them through a real capture round-trip could not assert the exact window
|
|
2903
2902
|
// arithmetic without a full cold render.
|
package/src/rsc/shell-serve.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
import { routerKeyPrefix } from "../cache/cache-key-utils.js";
|
|
39
39
|
import { reportCacheError } from "../cache/cache-error.js";
|
|
40
40
|
import { appendMetric } from "../router/metrics.js";
|
|
41
|
+
import { escapeJsonForScript } from "../escape-script.js";
|
|
41
42
|
import type { MetricsStore } from "../server/context.js";
|
|
42
43
|
import { WIRE } from "../wire-params.js";
|
|
43
44
|
|
|
@@ -162,12 +163,22 @@ export function withoutShellMissMarker(request: Request): Request | undefined {
|
|
|
162
163
|
* the page. window.stop() runs only when it reloads: it keeps the half-sent
|
|
163
164
|
* Flight stream from being closed by DOMContentLoaded, which would throw
|
|
164
165
|
* React's "Connection closed" (#412) before the reload lands.
|
|
166
|
+
*
|
|
167
|
+
* `restoreCookie` is the request's `_carriedLocationStateCookie`: the HIT's
|
|
168
|
+
* headers already cleared the location state cookie a document redirect left
|
|
169
|
+
* (server/document-location-state.ts), so the script sets it again before it
|
|
170
|
+
* reloads, and the reload carries the state.
|
|
165
171
|
*/
|
|
166
|
-
export function shellReloadScript(): string {
|
|
172
|
+
export function shellReloadScript(restoreCookie?: string): string {
|
|
173
|
+
const restore =
|
|
174
|
+
restoreCookie === undefined
|
|
175
|
+
? ""
|
|
176
|
+
: `document.cookie=${escapeJsonForScript(JSON.stringify(restoreCookie))};`;
|
|
167
177
|
return inlineShellScript(
|
|
168
178
|
"(function(){var u=new URL(location.href);" +
|
|
169
179
|
`if(u.searchParams.has(${JSON.stringify(WIRE.shellMiss)}))return;` +
|
|
170
180
|
`u.searchParams.set(${JSON.stringify(WIRE.shellMiss)},"miss");` +
|
|
181
|
+
restore +
|
|
171
182
|
"try{window.stop()}catch(e){}location.replace(u.href)})()",
|
|
172
183
|
);
|
|
173
184
|
}
|
package/src/segment-system.tsx
CHANGED
|
@@ -318,7 +318,7 @@ function wrapDefaultOutletContent(
|
|
|
318
318
|
* is settled like a flagged one, so the commit shows no fallback. A loader
|
|
319
319
|
* error is left to its read site there, its errorBoundary() fallback riding
|
|
320
320
|
* the throw, as on a navigation: the nearest boundary to the read catches it
|
|
321
|
-
* (#1129
|
|
321
|
+
* (#1129). notFound()/redirect() keep their build-time node.
|
|
322
322
|
*/
|
|
323
323
|
async function buildLoaderStreams(
|
|
324
324
|
loaders: ResolvedSegment[],
|
package/src/server/context.ts
CHANGED
|
@@ -126,9 +126,10 @@ export type EntryPropRender = {
|
|
|
126
126
|
loading?: ReactNode | false;
|
|
127
127
|
/**
|
|
128
128
|
* loading(fallback, { prefetch: false }) with a renderable fallback: a
|
|
129
|
-
* prefetch defers what the fallback covers while the entry's
|
|
130
|
-
* new to the client
|
|
131
|
-
* on both DSL
|
|
129
|
+
* speculative prefetch defers what the fallback covers while the entry's
|
|
130
|
+
* segment is new to the client
|
|
131
|
+
* (router/segment-resolution/prefetch-deferral.ts). Set on both DSL
|
|
132
|
+
* evaluations, whatever `ssr` did to `loading`.
|
|
132
133
|
*/
|
|
133
134
|
loadingPrefetch?: false;
|
|
134
135
|
/**
|
|
@@ -171,8 +172,8 @@ export type LoaderEntry = {
|
|
|
171
172
|
*/
|
|
172
173
|
bake?: true;
|
|
173
174
|
/**
|
|
174
|
-
* loader(Def, { prefetch: false }): a prefetch defers this
|
|
175
|
-
* while its segment is new to the client
|
|
175
|
+
* loader(Def, { prefetch: false }): a speculative prefetch defers this
|
|
176
|
+
* registration while its segment is new to the client
|
|
176
177
|
* (router/segment-resolution/prefetch-deferral.ts).
|
|
177
178
|
*/
|
|
178
179
|
prefetch?: false;
|
|
@@ -211,7 +212,10 @@ export type InterceptSegmentsState = {
|
|
|
211
212
|
* @internal This type is an implementation detail and may change without notice.
|
|
212
213
|
*/
|
|
213
214
|
export type InterceptSelectorContext<TEnv = any> = {
|
|
214
|
-
/**
|
|
215
|
+
/**
|
|
216
|
+
* Where the navigation comes from: the intercept source while one is open.
|
|
217
|
+
* Its `url` never carries a #fragment.
|
|
218
|
+
*/
|
|
215
219
|
from: ServerRouteLocation;
|
|
216
220
|
/** The navigation target. */
|
|
217
221
|
to: ServerRouteLocation;
|
|
@@ -1719,6 +1723,33 @@ export function isInsideLoaderBody(loaderId: string): boolean {
|
|
|
1719
1723
|
return false;
|
|
1720
1724
|
}
|
|
1721
1725
|
|
|
1726
|
+
// The segment of the streamed handler or loader body running here; the
|
|
1727
|
+
// walker moves the shared `_currentSegmentId` on without awaiting either.
|
|
1728
|
+
const segmentPinALS: AsyncLocalStorage<string | undefined> = ((
|
|
1729
|
+
globalThis as any
|
|
1730
|
+
)[Symbol.for("rangojs-router:segment-pin")] ??= new AsyncLocalStorage<
|
|
1731
|
+
string | undefined
|
|
1732
|
+
>());
|
|
1733
|
+
|
|
1734
|
+
/**
|
|
1735
|
+
* Run `fn` as segment `segmentId`'s code: a handle push, a loader kickoff or
|
|
1736
|
+
* a "use cache" call it makes, before or after an await, files under
|
|
1737
|
+
* `segmentId` (segmentPinOf).
|
|
1738
|
+
*/
|
|
1739
|
+
export function runAsSegment<R>(segmentId: string | undefined, fn: () => R): R {
|
|
1740
|
+
return segmentPinALS.run(segmentId, fn);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/**
|
|
1744
|
+
* The segment code here runs for: the innermost runAsSegment scope, else
|
|
1745
|
+
* `ctx`'s pin (a handler the walker awaits).
|
|
1746
|
+
*/
|
|
1747
|
+
export function segmentPinOf(ctx: {
|
|
1748
|
+
_currentSegmentId?: string;
|
|
1749
|
+
}): string | undefined {
|
|
1750
|
+
return segmentPinALS.getStore() ?? ctx._currentSegmentId;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1722
1753
|
// Scope for handle PUSH CALLBACKS (push(() => ...), including async ones).
|
|
1723
1754
|
// A push callback's value is stored as-is; if it is a promise it is NOT tracked
|
|
1724
1755
|
// by handleStore.settled and does not block segment resolution, so a
|
|
@@ -330,7 +330,8 @@ export function invalidateClientCache(): void {
|
|
|
330
330
|
*
|
|
331
331
|
* Sets an internal response header the bridge reads. Idempotent within a
|
|
332
332
|
* request. Inert (a dev warning) outside a request context — there is no
|
|
333
|
-
* automatic invalidation to suppress.
|
|
333
|
+
* automatic invalidation to suppress. It also keeps the state's write part
|
|
334
|
+
* (runActionBody).
|
|
334
335
|
*/
|
|
335
336
|
export function keepClientCache(): void {
|
|
336
337
|
const ctx = _getRequestContext();
|
|
@@ -347,6 +348,26 @@ export function keepClientCache(): void {
|
|
|
347
348
|
ctx._setKeepCacheDirective();
|
|
348
349
|
}
|
|
349
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Run a server action's body (the JS call or a no-JS form post), then bump the
|
|
353
|
+
* rango state's write part for the client that called it: the response's
|
|
354
|
+
* state `Set-Cookie` keeps the client's timestamp and carries a new write part
|
|
355
|
+
* (browser/cookie-name.ts). Every later request of every tab and reload sends
|
|
356
|
+
* the new `X-Rango-State`, so a prefetch or navigation the browser HTTP cache
|
|
357
|
+
* holds from before the write (a response's own `Cache-Control: max-age`)
|
|
358
|
+
* misses, while the client's in-memory caches only go stale, as the action
|
|
359
|
+
* bridge marks them. Bumped however the body ends (returned, threw, or
|
|
360
|
+
* redirected), since the write may have committed either way, and not after
|
|
361
|
+
* keepClientCache() or invalidateClientCache() (whose rotation covers it).
|
|
362
|
+
*/
|
|
363
|
+
export async function runActionBody<T>(body: () => T | Promise<T>): Promise<T> {
|
|
364
|
+
try {
|
|
365
|
+
return await body();
|
|
366
|
+
} finally {
|
|
367
|
+
_getRequestContext()?._rotateStateCookie("write");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
350
371
|
/**
|
|
351
372
|
* Create a CookieStore backed by a RequestContext. Reads go through the
|
|
352
373
|
* context's unguarded `_readCookie()` / `_readCookies()`: cookies() ran the
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Location state on document responses (#1330).
|
|
3
|
+
*
|
|
4
|
+
* A client navigation or an action carries server-set location state in its
|
|
5
|
+
* Flight payload, and a redirect on either becomes a Flight redirect that
|
|
6
|
+
* carries it too. A document request has no such channel for a 3xx: the
|
|
7
|
+
* browser follows it with a plain GET. This module carries the state across
|
|
8
|
+
* that one hop in a short-lived cookie:
|
|
9
|
+
*
|
|
10
|
+
* - `carryLocationStateAcrossRedirect` (rsc/request-stages.ts
|
|
11
|
+
* finalizeRequestResponse) writes it on a document request's same-origin
|
|
12
|
+
* 3xx when the request set location state;
|
|
13
|
+
* - `consumeDocumentLocationState` (rsc/request-stages.ts runRequestPasses)
|
|
14
|
+
* reads it on the next GET document request that navigates to the target
|
|
15
|
+
* pathname (`Sec-Fetch-Dest` a browsing context, where sent), seeds
|
|
16
|
+
* `_locationState` with its entries (so the render attaches them to the
|
|
17
|
+
* bootstrap payload, rsc/rsc-rendering.ts) and clears it. A shell HIT that
|
|
18
|
+
* degrades into a reload after its headers cleared the cookie sets it
|
|
19
|
+
* again from script first (`_carriedLocationStateCookie`), so the reload
|
|
20
|
+
* carries the state.
|
|
21
|
+
*
|
|
22
|
+
* A document that carries location state is per visitor: it is marked
|
|
23
|
+
* `_documentLocationState`, never stored by the document cache or as a PPR
|
|
24
|
+
* shell, and leaves with `Cache-Control: private, no-store`
|
|
25
|
+
* (`markDocumentResponsePrivate`).
|
|
26
|
+
*
|
|
27
|
+
* The value is JSON, not Flight: a cookie holds text, and the client merges
|
|
28
|
+
* it as `history.state` would hold it. `Path=/` because the `__Host-` prefix
|
|
29
|
+
* (https) requires it, which keeps a sibling subdomain from planting state;
|
|
30
|
+
* the target pathname inside the value decides who consumes it.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
isLocationStateKey,
|
|
35
|
+
resolveLocationStateEntries,
|
|
36
|
+
type LocationStateEntry,
|
|
37
|
+
} from "../browser/react/location-state-shared.js";
|
|
38
|
+
import { getRawCookieValue } from "../browser/cookie-name.js";
|
|
39
|
+
import { isExternalRedirect } from "../redirect-origin.js";
|
|
40
|
+
import { isWebSocketUpgradeResponse } from "../response-utils.js";
|
|
41
|
+
import { mayNeedSSR } from "../rsc/ssr-setup.js";
|
|
42
|
+
import { requestHeaders } from "./request-headers.js";
|
|
43
|
+
import {
|
|
44
|
+
serializeCookieValue,
|
|
45
|
+
type RequestContext,
|
|
46
|
+
} from "./request-context.js";
|
|
47
|
+
|
|
48
|
+
/** Seconds the cookie lives: one redirect hop, never a later visit. */
|
|
49
|
+
const DOCUMENT_LOCATION_STATE_MAX_AGE = 60;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Upper bound of one serialized `Set-Cookie` (name, value and attributes).
|
|
53
|
+
* Browsers keep at least 4096 bytes per cookie; the margin covers the
|
|
54
|
+
* attributes some add.
|
|
55
|
+
*/
|
|
56
|
+
const DOCUMENT_LOCATION_STATE_MAX_BYTES = 4000;
|
|
57
|
+
|
|
58
|
+
const COOKIE_SUFFIX = "-ls";
|
|
59
|
+
|
|
60
|
+
const PRIVATE_NO_STORE = "private, no-store";
|
|
61
|
+
|
|
62
|
+
interface CarriedState {
|
|
63
|
+
/** The pathname of the redirect target, the only request that reads it. */
|
|
64
|
+
p: string;
|
|
65
|
+
/** The resolved entries: slot key to value. */
|
|
66
|
+
s: Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The cookie's name for a router: `__Host-` on https. */
|
|
70
|
+
export function documentLocationStateCookieName(
|
|
71
|
+
stateCookieName: string,
|
|
72
|
+
secure: boolean,
|
|
73
|
+
): string {
|
|
74
|
+
return `${secure ? "__Host-" : ""}${stateCookieName}${COOKIE_SUFFIX}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function cookieAttributes(secure: boolean): {
|
|
78
|
+
path: string;
|
|
79
|
+
httpOnly: boolean;
|
|
80
|
+
secure: boolean;
|
|
81
|
+
sameSite: "lax";
|
|
82
|
+
} {
|
|
83
|
+
return { path: "/", httpOnly: true, secure, sameSite: "lax" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A document request: one that renders HTML (mayNeedSSR), not a navigation,
|
|
88
|
+
* an action, a loader fetch or a Flight fetch. Those carry state in their
|
|
89
|
+
* Flight payload, or are not what the browser shows.
|
|
90
|
+
*/
|
|
91
|
+
function isDocumentRequest(request: Request, url: URL): boolean {
|
|
92
|
+
return mayNeedSSR(request, url);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseCarriedState(raw: string): CarriedState | undefined {
|
|
96
|
+
try {
|
|
97
|
+
const parsed: unknown = JSON.parse(decodeURIComponent(raw));
|
|
98
|
+
if (parsed === null || typeof parsed !== "object") return undefined;
|
|
99
|
+
const { p, s } = parsed as Partial<CarriedState>;
|
|
100
|
+
if (typeof p !== "string" || s === null || typeof s !== "object") {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
return { p, s };
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `Sec-Fetch-Dest` values of a request that navigates a browsing context: the
|
|
111
|
+
* one a browser makes when it follows a document redirect.
|
|
112
|
+
*/
|
|
113
|
+
const NAVIGATION_FETCH_DESTS: ReadonlySet<string> = new Set([
|
|
114
|
+
"document",
|
|
115
|
+
"iframe",
|
|
116
|
+
"frame",
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A request that navigates: the browser shows its response. Where the browser
|
|
121
|
+
* sends `Sec-Fetch-Dest`, it must name a browsing context. A same-path
|
|
122
|
+
* `fetch()` with a wildcard `Accept` or a `<link rel=prefetch>` passes
|
|
123
|
+
* mayNeedSSR but sends `empty`, and must not eat the cookie before the
|
|
124
|
+
* navigation it was left for. A request without the header (an older
|
|
125
|
+
* browser, a non-browser client) keeps the mayNeedSSR rule alone.
|
|
126
|
+
*/
|
|
127
|
+
function isNavigationRequest(request: Request): boolean {
|
|
128
|
+
const dest = requestHeaders(request).get("sec-fetch-dest");
|
|
129
|
+
return dest === null || NAVIGATION_FETCH_DESTS.has(dest);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Read the cookie a document redirect left for this request: seed
|
|
134
|
+
* `_locationState` with its slots and clear it. Only a GET document request
|
|
135
|
+
* of the target pathname that navigates consumes it (the request the browser
|
|
136
|
+
* makes for a 301/302/303, see isNavigationRequest); any other request, a
|
|
137
|
+
* form POST to that path or a prefetch of it included, leaves it to its
|
|
138
|
+
* expiry. A malformed value is cleared and ignored, and only `__rsc_ls_*`
|
|
139
|
+
* slots are taken from it (the value comes from the browser).
|
|
140
|
+
*/
|
|
141
|
+
export function consumeDocumentLocationState(
|
|
142
|
+
reqCtx: Pick<
|
|
143
|
+
RequestContext,
|
|
144
|
+
"_locationState" | "_carriedLocationStateCookie" | "setCookie"
|
|
145
|
+
>,
|
|
146
|
+
request: Request,
|
|
147
|
+
url: URL,
|
|
148
|
+
stateCookieName: string,
|
|
149
|
+
): void {
|
|
150
|
+
if (request.method !== "GET") return;
|
|
151
|
+
if (!isDocumentRequest(request, url)) return;
|
|
152
|
+
if (!isNavigationRequest(request)) return;
|
|
153
|
+
const secure = url.protocol === "https:";
|
|
154
|
+
const name = documentLocationStateCookieName(stateCookieName, secure);
|
|
155
|
+
const raw = getRawCookieValue(requestHeaders(request).get("cookie"), name);
|
|
156
|
+
if (raw === null) return;
|
|
157
|
+
const carried = parseCarriedState(raw);
|
|
158
|
+
if (carried && carried.p !== url.pathname) return;
|
|
159
|
+
reqCtx.setCookie(name, "", { ...cookieAttributes(secure), maxAge: 0 });
|
|
160
|
+
if (!carried) return;
|
|
161
|
+
const entries: LocationStateEntry[] = [];
|
|
162
|
+
for (const key of Object.keys(carried.s)) {
|
|
163
|
+
if (!isLocationStateKey(key)) continue;
|
|
164
|
+
entries.push({ __rsc_ls_key: key, __rsc_ls_value: carried.s[key] });
|
|
165
|
+
}
|
|
166
|
+
if (entries.length === 0) return;
|
|
167
|
+
reqCtx._locationState = entries;
|
|
168
|
+
reqCtx._carriedLocationStateCookie = restoreCookieLine(name, raw, secure);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The `document.cookie` line that sets the consumed cookie again, for a
|
|
173
|
+
* shell HIT that reloads after its headers cleared it (rsc/rsc-rendering.ts
|
|
174
|
+
* serveShellHit). Script cannot write an `HttpOnly` cookie, so this one is
|
|
175
|
+
* readable by the page's script for its short life; it carries nothing the
|
|
176
|
+
* page's payload does not already hold.
|
|
177
|
+
*/
|
|
178
|
+
function restoreCookieLine(name: string, raw: string, secure: boolean): string {
|
|
179
|
+
return (
|
|
180
|
+
`${name}=${raw}; Path=/; Max-Age=${DOCUMENT_LOCATION_STATE_MAX_AGE}; ` +
|
|
181
|
+
`SameSite=Lax${secure ? "; Secure" : ""}`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** JSON keeps it as it is: what the cookie can carry without change. */
|
|
186
|
+
function isJsonPlain(value: unknown): boolean {
|
|
187
|
+
if (value === null) return true;
|
|
188
|
+
switch (typeof value) {
|
|
189
|
+
case "string":
|
|
190
|
+
case "boolean":
|
|
191
|
+
return true;
|
|
192
|
+
case "number":
|
|
193
|
+
return Number.isFinite(value);
|
|
194
|
+
case "object": {
|
|
195
|
+
if (Array.isArray(value)) return value.every(isJsonPlain);
|
|
196
|
+
const proto = Object.getPrototypeOf(value);
|
|
197
|
+
if (proto !== Object.prototype && proto !== null) return false;
|
|
198
|
+
return Object.values(value as Record<string, unknown>).every(
|
|
199
|
+
(member) => member !== undefined && isJsonPlain(member),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
default:
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function slotNames(state: Record<string, unknown>): string {
|
|
208
|
+
return Object.keys(state).join(", ");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Write the cookie on a document request's redirect that set location state,
|
|
213
|
+
* and mark the redirect private. Not for a partial or action request (their
|
|
214
|
+
* redirect is a Flight payload), an external or cross-origin target (the
|
|
215
|
+
* cookie would never reach it) or a response that is not a 3xx with a
|
|
216
|
+
* Location. Returns the response to send: a new one when its headers are
|
|
217
|
+
* immutable.
|
|
218
|
+
*/
|
|
219
|
+
export function carryLocationStateAcrossRedirect(
|
|
220
|
+
response: Response,
|
|
221
|
+
reqCtx: Pick<RequestContext, "_locationState">,
|
|
222
|
+
request: Request,
|
|
223
|
+
url: URL,
|
|
224
|
+
stateCookieName: string,
|
|
225
|
+
): Response {
|
|
226
|
+
const entries = reqCtx._locationState;
|
|
227
|
+
if (!entries || entries.length === 0) return response;
|
|
228
|
+
if (!isDocumentRequest(request, url)) return response;
|
|
229
|
+
if (response.status < 300 || response.status >= 400) return response;
|
|
230
|
+
if (isExternalRedirect(response)) return response;
|
|
231
|
+
const location = response.headers.get("Location");
|
|
232
|
+
if (!location) return response;
|
|
233
|
+
let target: URL;
|
|
234
|
+
try {
|
|
235
|
+
target = new URL(location, url);
|
|
236
|
+
} catch {
|
|
237
|
+
return response;
|
|
238
|
+
}
|
|
239
|
+
if (target.origin !== url.origin) return response;
|
|
240
|
+
|
|
241
|
+
let state: Record<string, unknown>;
|
|
242
|
+
try {
|
|
243
|
+
state = resolveLocationStateEntries(entries);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
// A lazy entry's getter threw: the redirect still leaves, without state.
|
|
246
|
+
console.warn(
|
|
247
|
+
"[rango] Location state was dropped from a document redirect: " +
|
|
248
|
+
`resolving it threw (${String(error)}).`,
|
|
249
|
+
);
|
|
250
|
+
return response;
|
|
251
|
+
}
|
|
252
|
+
if (process.env.NODE_ENV !== "production") {
|
|
253
|
+
for (const [key, value] of Object.entries(state)) {
|
|
254
|
+
if (!isJsonPlain(value)) {
|
|
255
|
+
console.warn(
|
|
256
|
+
`[rango] Location state "${key}" crosses a document redirect as JSON, ` +
|
|
257
|
+
"which does not keep this value as it is (a Date, Map, Set, bigint, " +
|
|
258
|
+
"undefined or class instance). Store plain JSON data in state that a " +
|
|
259
|
+
"document request redirects with.",
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const secure = url.protocol === "https:";
|
|
266
|
+
const name = documentLocationStateCookieName(stateCookieName, secure);
|
|
267
|
+
let cookie: string;
|
|
268
|
+
try {
|
|
269
|
+
cookie = serializeCookieValue(
|
|
270
|
+
name,
|
|
271
|
+
JSON.stringify({ p: target.pathname, s: state } satisfies CarriedState),
|
|
272
|
+
{ ...cookieAttributes(secure), maxAge: DOCUMENT_LOCATION_STATE_MAX_AGE },
|
|
273
|
+
);
|
|
274
|
+
} catch {
|
|
275
|
+
console.warn(
|
|
276
|
+
`[rango] Location state (${slotNames(state)}) was dropped from a document ` +
|
|
277
|
+
"redirect: it cannot be serialized as JSON.",
|
|
278
|
+
);
|
|
279
|
+
return response;
|
|
280
|
+
}
|
|
281
|
+
if (cookie.length > DOCUMENT_LOCATION_STATE_MAX_BYTES) {
|
|
282
|
+
console.warn(
|
|
283
|
+
`[rango] Location state (${slotNames(state)}) was dropped from a document ` +
|
|
284
|
+
`redirect: its cookie is ${cookie.length} bytes, over the ` +
|
|
285
|
+
`${DOCUMENT_LOCATION_STATE_MAX_BYTES}-byte limit. Carry less state, or ` +
|
|
286
|
+
"an id the target page loads the rest by.",
|
|
287
|
+
);
|
|
288
|
+
return response;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return withHeaders(response, (headers) => {
|
|
292
|
+
headers.append("Set-Cookie", cookie);
|
|
293
|
+
headers.set("Cache-Control", PRIVATE_NO_STORE);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Apply `write` to the response's headers, or to a copy of the response when
|
|
299
|
+
* they are immutable (a `Response.redirect()`). A WebSocket upgrade is never
|
|
300
|
+
* rebuilt and never carries location state.
|
|
301
|
+
*/
|
|
302
|
+
function withHeaders(
|
|
303
|
+
response: Response,
|
|
304
|
+
write: (headers: Headers) => void,
|
|
305
|
+
): Response {
|
|
306
|
+
if (isWebSocketUpgradeResponse(response)) return response;
|
|
307
|
+
try {
|
|
308
|
+
write(response.headers);
|
|
309
|
+
return response;
|
|
310
|
+
} catch {
|
|
311
|
+
const copy = new Response(response.body, {
|
|
312
|
+
status: response.status,
|
|
313
|
+
statusText: response.statusText,
|
|
314
|
+
headers: new Headers(response.headers),
|
|
315
|
+
});
|
|
316
|
+
write(copy.headers);
|
|
317
|
+
return copy;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* `Cache-Control: private, no-store` on a document that carries location
|
|
323
|
+
* state, after every middleware: a shared cache must never store or replay
|
|
324
|
+
* one visitor's state. Returns the response to send.
|
|
325
|
+
*/
|
|
326
|
+
export function markDocumentResponsePrivate(
|
|
327
|
+
response: Response,
|
|
328
|
+
reqCtx: Pick<RequestContext, "_documentLocationState">,
|
|
329
|
+
): Response {
|
|
330
|
+
if (!reqCtx._documentLocationState) return response;
|
|
331
|
+
return withHeaders(response, (headers) =>
|
|
332
|
+
headers.set("Cache-Control", PRIVATE_NO_STORE),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
@@ -302,6 +302,8 @@ export interface HandleStore {
|
|
|
302
302
|
* so a loader that throws or skips its push shows none rather than the
|
|
303
303
|
* record's. A hole's replays go with them. Any other loader's replays stay
|
|
304
304
|
* (a dependency another entry's HIT replayed), and so do restored copies.
|
|
305
|
+
* A prefetch that defers the loader calls it too (revalidation.ts
|
|
306
|
+
* resolveOwnerLoaders): no run is made, so none of its placeholders stand.
|
|
305
307
|
*/
|
|
306
308
|
settleLoaderRun(loaderId: string): void;
|
|
307
309
|
|