@rangojs/router 0.17.0 → 0.18.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/types/browser/cookie-name.d.ts +6 -0
- package/dist/types/browser/history-state.d.ts +16 -0
- package/dist/types/cache/cache-key-utils.d.ts +17 -2
- package/dist/types/cache/cache-policy.d.ts +17 -0
- package/dist/types/cache/cache-scope.d.ts +98 -14
- package/dist/types/cache/cache-tag.d.ts +92 -7
- package/dist/types/cache/cf/cf-base64.d.ts +6 -0
- package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
- package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
- package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
- package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
- package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
- package/dist/types/cache/handle-snapshot.d.ts +4 -3
- package/dist/types/cache/index.d.ts +1 -0
- package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
- package/dist/types/cache/shell-memo.d.ts +178 -0
- package/dist/types/cache/shell-snapshot.d.ts +110 -21
- package/dist/types/cache/tag-invalidation.d.ts +30 -3
- package/dist/types/cache/types.d.ts +207 -43
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
- package/dist/types/prerender/build-shell-capture.d.ts +11 -4
- package/dist/types/router/instrument.d.ts +8 -0
- package/dist/types/router/middleware-types.d.ts +6 -1
- package/dist/types/router/segment-resolution/loader-cache.d.ts +40 -11
- package/dist/types/router/segment-resolution/loader-snapshot.d.ts +21 -10
- package/dist/types/router/segment-resolution/mask-nested.d.ts +65 -24
- package/dist/types/rsc/full-payload.d.ts +14 -0
- package/dist/types/rsc/handler.d.ts +1 -1
- package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
- package/dist/types/rsc/shell-capture-constants.d.ts +53 -6
- package/dist/types/rsc/shell-capture.d.ts +146 -15
- package/dist/types/rsc/shell-serve.d.ts +140 -17
- package/dist/types/rsc/types.d.ts +7 -0
- package/dist/types/server/context.d.ts +109 -15
- package/dist/types/server/cookie-store.d.ts +21 -0
- package/dist/types/server/request-context.d.ts +159 -33
- package/dist/types/ssr/index.d.ts +9 -0
- package/dist/types/ssr/ssr-root.d.ts +9 -8
- package/dist/types/testing/flight.entry.d.ts +7 -0
- package/dist/types/testing/run-loader.d.ts +18 -3
- package/dist/types/testing/serve-shell-request.d.ts +132 -0
- package/dist/types/testing/shell-status.d.ts +23 -10
- package/dist/types/types/handler-context.d.ts +14 -1
- package/dist/types/urls/pattern-types.d.ts +22 -18
- package/dist/types/vite/router-discovery.d.ts +29 -2
- package/dist/vite/index.js +46 -9
- package/package.json +1 -1
- package/skills/breadcrumbs/SKILL.md +17 -7
- package/skills/cache-guide/SKILL.md +123 -51
- package/skills/caching/SKILL.md +253 -23
- package/skills/catalog.json +1 -1
- package/skills/client-urls/SKILL.md +11 -6
- package/skills/cloudflare/SKILL.md +15 -0
- package/skills/comparison/references/framework-comparison.md +44 -32
- package/skills/defer-hydration/SKILL.md +50 -30
- package/skills/deployment-caching/SKILL.md +5 -2
- package/skills/document-cache/SKILL.md +8 -2
- package/skills/handler-use/SKILL.md +2 -0
- package/skills/hooks/handle-and-actions.md +3 -1
- package/skills/hooks/state.md +3 -1
- package/skills/loader/SKILL.md +161 -39
- package/skills/middleware/SKILL.md +16 -9
- package/skills/migrate-nextjs/SKILL.md +67 -36
- package/skills/migrate-react-router/SKILL.md +2 -1
- package/skills/migrate-react-router/component-migration.md +3 -1
- package/skills/migrate-react-router/data-and-actions.md +3 -1
- package/skills/migrate-react-router/route-mapping.md +7 -4
- package/skills/observability/SKILL.md +64 -0
- package/skills/parallel/SKILL.md +13 -8
- package/skills/ppr/SKILL.md +655 -363
- package/skills/prerender/SKILL.md +27 -14
- package/skills/rango/SKILL.md +52 -33
- package/skills/route/SKILL.md +12 -7
- package/skills/router-setup/SKILL.md +4 -2
- package/skills/scripts/SKILL.md +8 -1
- package/skills/server-actions/SKILL.md +11 -0
- package/skills/shell-manifest/SKILL.md +21 -12
- package/skills/testing/SKILL.md +25 -21
- package/skills/testing/cache-prerender.md +15 -14
- package/skills/testing/loader.md +18 -1
- package/skills/testing/serve-shell-request.md +188 -0
- package/skills/theme/SKILL.md +52 -4
- package/skills/typesafety/env-and-bindings.md +10 -2
- package/skills/use-cache/SKILL.md +39 -16
- package/skills/vercel/SKILL.md +14 -2
- package/skills/view-transitions/SKILL.md +1 -1
- package/src/browser/cookie-name.ts +23 -0
- package/src/browser/history-state.ts +23 -0
- package/src/browser/rsc-router.tsx +5 -0
- package/src/cache/cache-key-utils.ts +22 -2
- package/src/cache/cache-policy.ts +22 -0
- package/src/cache/cache-runtime.ts +34 -7
- package/src/cache/cache-scope.ts +345 -33
- package/src/cache/cache-tag.ts +375 -11
- package/src/cache/cf/cf-base64.ts +36 -4
- package/src/cache/cf/cf-cache-constants.ts +12 -0
- package/src/cache/cf/cf-cache-store.ts +990 -348
- package/src/cache/cf/cf-cache-types.ts +32 -4
- package/src/cache/cf/cf-shell-frame.ts +258 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
- package/src/cache/handle-snapshot.ts +4 -3
- package/src/cache/index.ts +2 -0
- package/src/cache/isolate-tag-memo.ts +308 -0
- package/src/cache/shell-memo.ts +341 -0
- package/src/cache/shell-snapshot.ts +217 -27
- package/src/cache/tag-invalidation.ts +104 -19
- package/src/cache/types.ts +211 -43
- package/src/cache/vercel/vercel-cache-store.ts +545 -61
- package/src/prerender/build-shell-capture.ts +65 -11
- package/src/route-definition/dsl-helpers.ts +4 -3
- package/src/router/handler-context.ts +38 -4
- package/src/router/instrument.ts +54 -2
- package/src/router/intercept-resolution.ts +19 -7
- package/src/router/loader-resolution.ts +63 -32
- package/src/router/match-api.ts +28 -9
- package/src/router/match-handlers.ts +5 -0
- package/src/router/match-middleware/cache-lookup.ts +17 -0
- package/src/router/match-middleware/cache-store.ts +31 -11
- package/src/router/middleware-types.ts +6 -1
- package/src/router/middleware.ts +23 -4
- package/src/router/prerender-match.ts +6 -0
- package/src/router/segment-resolution/fresh.ts +18 -9
- package/src/router/segment-resolution/helpers.ts +4 -2
- package/src/router/segment-resolution/loader-cache.ts +227 -56
- package/src/router/segment-resolution/loader-snapshot.ts +203 -51
- package/src/router/segment-resolution/mask-nested.ts +136 -42
- package/src/router/segment-resolution/revalidation.ts +27 -10
- package/src/rsc/full-payload.ts +25 -1
- package/src/rsc/handler.ts +14 -2
- package/src/rsc/progressive-enhancement.ts +3 -2
- package/src/rsc/response-cache-serve.ts +9 -6
- package/src/rsc/rsc-rendering.ts +601 -181
- package/src/rsc/shell-build-manifest.ts +27 -9
- package/src/rsc/shell-capture-constants.ts +60 -6
- package/src/rsc/shell-capture.ts +1001 -250
- package/src/rsc/shell-serve.ts +316 -34
- package/src/rsc/types.ts +7 -0
- package/src/server/context.ts +219 -28
- package/src/server/cookie-store.ts +87 -62
- package/src/server/request-context.ts +263 -38
- package/src/ssr/index.tsx +96 -37
- package/src/ssr/ssr-root.tsx +9 -8
- package/src/testing/flight.entry.ts +14 -0
- package/src/testing/run-loader.ts +63 -5
- package/src/testing/serve-shell-request.ts +378 -0
- package/src/testing/shell-status.ts +34 -12
- package/src/theme/ThemeProvider.tsx +7 -6
- package/src/types/handler-context.ts +14 -1
- package/src/urls/pattern-types.ts +22 -18
- package/src/vite/discovery/shell-prerender-phase.ts +9 -0
- package/src/vite/router-discovery.ts +14 -4
package/src/server/context.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
import { invariant, DslContextError } from "../errors";
|
|
14
14
|
import type { DefaultRouteName } from "../types/global-namespace.js";
|
|
15
15
|
import type { ContextVar } from "../context-var.js";
|
|
16
|
+
import { PPR_LANE_HINT } from "../rsc/shell-capture-constants.js";
|
|
16
17
|
import {
|
|
17
18
|
getCacheExecScope,
|
|
18
19
|
type CacheExecScope,
|
|
@@ -134,6 +135,15 @@ export type LoaderEntry = {
|
|
|
134
135
|
* appears on navigation-lane entries.
|
|
135
136
|
*/
|
|
136
137
|
awaitBeforeFlush?: true;
|
|
138
|
+
/**
|
|
139
|
+
* loader(Def, { ssr: false }) on every DSL evaluation, document and
|
|
140
|
+
* navigation: the loader's PPR bake lane (loader-cache.ts
|
|
141
|
+
* resolveLoaderData). The bake/seed key rides on it, so a client
|
|
142
|
+
* navigation's shell replay pins exactly the loaders a document HIT pins,
|
|
143
|
+
* whatever the entry's loading(). awaitBeforeFlush cannot carry that: a
|
|
144
|
+
* navigation evaluation never has it.
|
|
145
|
+
*/
|
|
146
|
+
bake?: true;
|
|
137
147
|
};
|
|
138
148
|
|
|
139
149
|
/**
|
|
@@ -846,20 +856,19 @@ const loaderScopeALS: AsyncLocalStorage<{ active: true }> = ((
|
|
|
846
856
|
// Purity-only scope: marks that a loader FUNCTION BODY is executing, regardless
|
|
847
857
|
// of how the loader was invoked (DSL via runInsideLoaderScope, or handler-
|
|
848
858
|
// invoked via ctx.use). Consulted by isInsideCacheScope() to exempt
|
|
849
|
-
// request-scoped reads, by getCurrentLoaderBodyId() for guard-warning
|
|
850
|
-
// attribution
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
// gating (which must distinguish DSL from handler-invoked loaders) is
|
|
854
|
-
// unchanged.
|
|
859
|
+
// request-scoped reads, and by getCurrentLoaderBodyId() for guard-warning
|
|
860
|
+
// attribution. It deliberately does NOT affect isInsideLoaderScope(), so
|
|
861
|
+
// rendered()/barrier/deadlock gating (which must distinguish DSL from
|
|
862
|
+
// handler-invoked loaders) is unchanged.
|
|
855
863
|
interface LoaderBodyScope {
|
|
856
864
|
active: true;
|
|
857
865
|
loaderId?: string;
|
|
858
|
-
handlerInvoked?: boolean;
|
|
859
866
|
/** The body scope this one was entered from (a ctx.use(Loader) chain). */
|
|
860
867
|
parent?: LoaderBodyScope;
|
|
861
868
|
/** The "use cache" scope the body was entered in (assertNonCacheableReadAllowed). */
|
|
862
869
|
execScope?: CacheExecScope;
|
|
870
|
+
/** The tags this execution records (#964, cache-tag.ts "Recorded-tag sets"). */
|
|
871
|
+
tags?: Set<string>;
|
|
863
872
|
}
|
|
864
873
|
const LOADER_BODY_SCOPE_KEY = Symbol.for("rangojs-router:loader-body-scope");
|
|
865
874
|
const loaderBodyScopeALS: AsyncLocalStorage<LoaderBodyScope> = ((
|
|
@@ -868,8 +877,9 @@ const loaderBodyScopeALS: AsyncLocalStorage<LoaderBodyScope> = ((
|
|
|
868
877
|
|
|
869
878
|
/**
|
|
870
879
|
* Check if the current execution is inside a cache() DSL boundary.
|
|
871
|
-
* Returns false inside loader execution
|
|
872
|
-
*
|
|
880
|
+
* Returns false inside loader execution: a route cache() does not store
|
|
881
|
+
* loader values, so non-cacheable reads are safe. A loader bound with its own
|
|
882
|
+
* cache() stores its value; recordLoaderIdentityRead guards that.
|
|
873
883
|
*/
|
|
874
884
|
export function isInsideCacheScope(): boolean {
|
|
875
885
|
if (RangoContext.getStore()?.insideCacheScope !== true) return false;
|
|
@@ -885,11 +895,115 @@ export function isInsideCacheScope(): boolean {
|
|
|
885
895
|
return true;
|
|
886
896
|
}
|
|
887
897
|
|
|
898
|
+
/**
|
|
899
|
+
* Throw if called during the ACTIVE background shell-capture render
|
|
900
|
+
* (`_shellCaptureRun` true on the derived request context built by
|
|
901
|
+
* shell-capture.ts). The captured shell is shared across every user hitting
|
|
902
|
+
* the URL, so a request-scoped read here would bake one user's cookies,
|
|
903
|
+
* headers, or per-request variables into markup served to others — same
|
|
904
|
+
* hazard as the cache scopes above, at the document tier. DSL segment loaders
|
|
905
|
+
* need no exemption: the live lane is masked (never executed) during capture,
|
|
906
|
+
* and the bake lane is exactly what this guard exists for.
|
|
907
|
+
*
|
|
908
|
+
* It covers everything the capture waits for, not only synchronous handler
|
|
909
|
+
* code: a promise the handler passes or pushes, an async server component,
|
|
910
|
+
* and a loader the handler awaits (`await ctx.use(Loader)`) all run under the
|
|
911
|
+
* capture context and all bake. The last one used to be exempt (the
|
|
912
|
+
* consumption-lane rule, #672/#674): while a HIT re-ran handlers, a slot
|
|
913
|
+
* handler's `ctx.use` of an identity loader rendered per visitor on the HIT.
|
|
914
|
+
* Now every HIT replays the capture's copy, so the exemption would bake the
|
|
915
|
+
* capturing request's identity into every visitor's page. The cache()
|
|
916
|
+
* guards above keep it (a cache() HIT is a separate tier).
|
|
917
|
+
*
|
|
918
|
+
* Keys off `_shellCaptureRun`, NOT the `_shellCapture` descriptor: the descriptor
|
|
919
|
+
* is also present during the FOREGROUND render (it means "a capture is wanted"),
|
|
920
|
+
* and the foreground must read cookies/headers normally to serve the real user.
|
|
921
|
+
* Only the derived capture context sets `_shellCaptureRun`.
|
|
922
|
+
*
|
|
923
|
+
* Applies only to the READ surfaces (cookies(), headers(), a { cache: false }
|
|
924
|
+
* variable, and the theme reads through tripShellCaptureGuard) whose values
|
|
925
|
+
* become markup. Response directives
|
|
926
|
+
* (invalidateClientCache(), keepClientCache()) stay callable: during capture
|
|
927
|
+
* they are header effects on a discarded response, and on the live HIT path
|
|
928
|
+
* the full pipeline runs so their headers flow to the client normally.
|
|
929
|
+
*
|
|
930
|
+
* The throw can be caught by the code that made the read, so the guard also
|
|
931
|
+
* flags the capture context; the capture refuses on the flag
|
|
932
|
+
* (shell-capture.ts refuseOnCaptureGuard), nothing is stored, and every
|
|
933
|
+
* request keeps getting the normal axis-1 render.
|
|
934
|
+
*/
|
|
935
|
+
export function assertNotInsideShellCapture(
|
|
936
|
+
ctx: unknown,
|
|
937
|
+
fnName: string,
|
|
938
|
+
): void {
|
|
939
|
+
if (!tripShellCaptureGuard(ctx, `${fnName}()`, REQUEST_READ_CAPTURE_FIX)) {
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
const what =
|
|
943
|
+
fnName === "cookies"
|
|
944
|
+
? "cookies"
|
|
945
|
+
: fnName === "headers"
|
|
946
|
+
? "headers"
|
|
947
|
+
: "per-request variables";
|
|
948
|
+
throw new Error(
|
|
949
|
+
`${fnName}() cannot be called while capturing a shared shell ` +
|
|
950
|
+
`(ppr shell capture). The captured shell is served to every user ` +
|
|
951
|
+
`of this URL, so request-scoped data read here would leak one user's ` +
|
|
952
|
+
`${what} to others. Read it ` +
|
|
953
|
+
`inside a loader without ssr: false and consume it with useLoader, e.g. ` +
|
|
954
|
+
`createLoader(async () => getUser(cookies().get("session")?.value)). ` +
|
|
955
|
+
PPR_LANE_HINT,
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* The fix the capture refusal warning (shell-capture.ts refuseOnCaptureGuard)
|
|
961
|
+
* gives for a cookies()/headers()/{ cache: false } variable read. Recorded
|
|
962
|
+
* with the trip (tripShellCaptureGuard), so each guarded read carries its own
|
|
963
|
+
* fix; the theme reads (cookie-store.ts assertThemeReadAllowed) record theirs.
|
|
964
|
+
*/
|
|
965
|
+
const REQUEST_READ_CAPTURE_FIX: string =
|
|
966
|
+
"Read it in a loader without ssr: false and consume it with useLoader under " +
|
|
967
|
+
"loading() or an inline <Suspense> (a live hole). A promise the handler passes " +
|
|
968
|
+
"or pushes does not help: the capture waits for it.";
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* True when `ctx` is the active capture render (assertNotInsideShellCapture
|
|
972
|
+
* above). On true the capture context is flagged with the read (`surface`,
|
|
973
|
+
* e.g. "cookies()", "ctx.theme") and the fix the refusal warning gives, plus
|
|
974
|
+
* the loader body (if any) that made the read, and the caller throws.
|
|
975
|
+
*/
|
|
976
|
+
export function tripShellCaptureGuard(
|
|
977
|
+
ctx: unknown,
|
|
978
|
+
surface: string,
|
|
979
|
+
fix: string,
|
|
980
|
+
): boolean {
|
|
981
|
+
if (
|
|
982
|
+
ctx === null ||
|
|
983
|
+
typeof ctx !== "object" ||
|
|
984
|
+
(ctx as { _shellCaptureRun?: unknown })._shellCaptureRun !== true
|
|
985
|
+
) {
|
|
986
|
+
return false;
|
|
987
|
+
}
|
|
988
|
+
// Record WHICH loader body (if any) made the read, so the refusal warning
|
|
989
|
+
// can name the real source instead of hardcoding a lane (issue #672).
|
|
990
|
+
const flagged = ctx as {
|
|
991
|
+
_shellCaptureGuardTripped?: { surface: string; fix: string };
|
|
992
|
+
_shellCaptureGuardTrippedLoaderId?: string;
|
|
993
|
+
};
|
|
994
|
+
flagged._shellCaptureGuardTripped = { surface, fix };
|
|
995
|
+
flagged._shellCaptureGuardTrippedLoaderId = getCurrentLoaderBodyId();
|
|
996
|
+
return true;
|
|
997
|
+
}
|
|
998
|
+
|
|
888
999
|
/**
|
|
889
1000
|
* Read guard for a non-cacheable variable (`createVar({ cache: false })` or a
|
|
890
1001
|
* `ctx.set(..., { cache: false })` write). Callers check isNonCacheable() first
|
|
891
1002
|
* so ordinary reads never reach the scope lookups.
|
|
892
1003
|
*
|
|
1004
|
+
* Throws during a PPR shell capture (`requestCtx` is the ambient request
|
|
1005
|
+
* context; assertNotInsideShellCapture): the shell is shared per host+URL.
|
|
1006
|
+
*
|
|
893
1007
|
* Throws inside a "use cache" body: the key does not include the value, so the
|
|
894
1008
|
* first caller's value would be stored and served to later callers (#925). A
|
|
895
1009
|
* loader body entered INSIDE the cached function is exempt, the same baked-copy
|
|
@@ -900,15 +1014,18 @@ export function isInsideCacheScope(): boolean {
|
|
|
900
1014
|
*/
|
|
901
1015
|
export function assertNonCacheableReadAllowed(
|
|
902
1016
|
keyOrVar: string | ContextVar<unknown>,
|
|
1017
|
+
requestCtx?: unknown,
|
|
903
1018
|
): void {
|
|
1019
|
+
// A shell capture bakes what it reads into a page every visitor gets.
|
|
1020
|
+
assertNotInsideShellCapture(requestCtx, "ctx.get");
|
|
904
1021
|
const execScope = getCacheExecScope();
|
|
1022
|
+
const variable = ` for a non-cacheable variable${typeof keyOrVar === "string" ? ` "${keyOrVar}"` : ""}`;
|
|
905
1023
|
if (
|
|
906
1024
|
execScope !== undefined &&
|
|
907
1025
|
loaderBodyScopeALS.getStore()?.execScope !== execScope
|
|
908
1026
|
) {
|
|
909
|
-
const name = typeof keyOrVar === "string" ? ` "${keyOrVar}"` : "";
|
|
910
1027
|
throw new Error(
|
|
911
|
-
`ctx.get()
|
|
1028
|
+
`ctx.get()${variable} cannot be called inside a "use cache" function. ` +
|
|
912
1029
|
`The variable was created with { cache: false } or set with { cache: false }, ` +
|
|
913
1030
|
`and the cache key does not include its value, so the first caller's value ` +
|
|
914
1031
|
`would be served to later callers. Read it before calling the cached function ` +
|
|
@@ -922,6 +1039,88 @@ export function assertNonCacheableReadAllowed(
|
|
|
922
1039
|
`and its value would be stale on cache hit. Move the read outside the cached scope.`,
|
|
923
1040
|
);
|
|
924
1041
|
}
|
|
1042
|
+
recordLoaderIdentityRead(`ctx.get()${variable}`);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/** How the identity error words a read: a function call or a property read. */
|
|
1046
|
+
export type LoaderIdentityReadVerb = "called" | "read";
|
|
1047
|
+
|
|
1048
|
+
/** A request-identity read a loader execution made (recordLoaderIdentityRead). */
|
|
1049
|
+
export interface LoaderIdentityRead {
|
|
1050
|
+
/**
|
|
1051
|
+
* The read as the error names it: "cookies()", "headers()",
|
|
1052
|
+
* `ctx.get() for a non-cacheable variable "x"`, "ctx.theme" or
|
|
1053
|
+
* "getRequestContext().theme".
|
|
1054
|
+
*/
|
|
1055
|
+
surface: string;
|
|
1056
|
+
/** "called" for a function, "read" for a property (the theme getters). */
|
|
1057
|
+
verb: LoaderIdentityReadVerb;
|
|
1058
|
+
/** The loader body that made the read. */
|
|
1059
|
+
bodyId: string | undefined;
|
|
1060
|
+
/**
|
|
1061
|
+
* The cached loader whose value carries a read another loader made
|
|
1062
|
+
* (loader-cache.ts identity mark): its readers read it through `via`.
|
|
1063
|
+
*/
|
|
1064
|
+
via?: string;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Where cache-tag.ts installs its recorder at module init (same key there):
|
|
1069
|
+
* it owns the per-execution recorded sets, and importing it here would be a
|
|
1070
|
+
* cycle.
|
|
1071
|
+
*/
|
|
1072
|
+
const IDENTITY_READ_RECORDER_KEY = Symbol.for(
|
|
1073
|
+
"rangojs-router:identity-read-recorder",
|
|
1074
|
+
);
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Record a request-identity read (cookies(), headers(), a non-cacheable
|
|
1078
|
+
* ctx.get()) on the current loader execution (#972).
|
|
1079
|
+
*
|
|
1080
|
+
* isInsideCacheScope() exempts loader bodies because a route cache() never
|
|
1081
|
+
* stores their values. A loader bound with its own cache() does: with no key()
|
|
1082
|
+
* and no store keyGenerator its entry is keyed by loader, host, path and
|
|
1083
|
+
* params only, and an enclosing route cache() key does not partition it
|
|
1084
|
+
* (#974). A cookies() read there stored the first visitor's session and served
|
|
1085
|
+
* it to everyone for the TTL. The read is recorded on the execution, not
|
|
1086
|
+
* thrown at the call: a reader that starts the loader before its binding does
|
|
1087
|
+
* runs it outside any fill, and the binding's MISS then reuses that run. The
|
|
1088
|
+
* fill checks what its execution recorded, through the same links as its tags
|
|
1089
|
+
* (cache-tag.ts recordedIdentityRead), so both orders fail the same way.
|
|
1090
|
+
* Response directives do not record: a key cannot make a skipped body's side
|
|
1091
|
+
* effect reach a HIT.
|
|
1092
|
+
*/
|
|
1093
|
+
export function recordLoaderIdentityRead(
|
|
1094
|
+
surface: string,
|
|
1095
|
+
verb: LoaderIdentityReadVerb = "called",
|
|
1096
|
+
): void {
|
|
1097
|
+
const recorder = (globalThis as Record<symbol, unknown>)[
|
|
1098
|
+
IDENTITY_READ_RECORDER_KEY
|
|
1099
|
+
] as ((surface: string, verb: LoaderIdentityReadVerb) => void) | undefined;
|
|
1100
|
+
recorder?.(surface, verb);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** The error a loader cache() fill with no declared key fails with (#972). */
|
|
1104
|
+
export function loaderCacheIdentityError(
|
|
1105
|
+
read: LoaderIdentityRead,
|
|
1106
|
+
cachedLoaderId: string,
|
|
1107
|
+
): Error {
|
|
1108
|
+
const filling = `while filling its own cache() entry with no key()`;
|
|
1109
|
+
const where =
|
|
1110
|
+
read.via !== undefined && read.via !== cachedLoaderId
|
|
1111
|
+
? `inside loader "${read.bodyId}", which loader "${cachedLoaderId}" reads through another loader ("${read.via}") ${filling}`
|
|
1112
|
+
: read.bodyId !== undefined && read.bodyId !== cachedLoaderId
|
|
1113
|
+
? `inside loader "${read.bodyId}", which loader "${cachedLoaderId}" reads ${filling}`
|
|
1114
|
+
: `inside loader "${cachedLoaderId}", whose own cache() has no key()`;
|
|
1115
|
+
return new Error(
|
|
1116
|
+
`${read.surface} cannot be ${read.verb} ${where}. ` +
|
|
1117
|
+
`The entry is keyed by loader, host, path and params only, so it is ` +
|
|
1118
|
+
`shared across users: request-scoped data (cookies, headers, ` +
|
|
1119
|
+
`non-cacheable variables) read here would be stored from one request ` +
|
|
1120
|
+
`and served to everyone. Add a key that includes the value, or drop ` +
|
|
1121
|
+
`the loader's cache():\n\n` +
|
|
1122
|
+
` cache({ ttl: 60, key: (ctx) => \`session:\${cookies().get("session")?.value}\` })`,
|
|
1123
|
+
);
|
|
925
1124
|
}
|
|
926
1125
|
|
|
927
1126
|
/**
|
|
@@ -952,7 +1151,7 @@ export function latchCachedHeaderScope(
|
|
|
952
1151
|
/** True inside ANY loader execution — DSL loader scope or a loader body
|
|
953
1152
|
* (however invoked). The "loaders always re-run fresh" exemptions key off
|
|
954
1153
|
* this. */
|
|
955
|
-
function isInsideAnyLoaderScope(): boolean {
|
|
1154
|
+
export function isInsideAnyLoaderScope(): boolean {
|
|
956
1155
|
return (
|
|
957
1156
|
loaderScopeALS.getStore()?.active === true ||
|
|
958
1157
|
loaderBodyScopeALS.getStore()?.active === true
|
|
@@ -1105,24 +1304,29 @@ export function runInsideLoaderScope<T>(fn: () => T): T {
|
|
|
1105
1304
|
export function runInsideLoaderBodyScope<T>(
|
|
1106
1305
|
fn: () => T,
|
|
1107
1306
|
loaderId?: string,
|
|
1108
|
-
|
|
1307
|
+
tags?: Set<string>,
|
|
1109
1308
|
): T {
|
|
1110
1309
|
return loaderBodyScopeALS.run(
|
|
1111
1310
|
{
|
|
1112
1311
|
active: true,
|
|
1113
1312
|
loaderId,
|
|
1114
|
-
handlerInvoked,
|
|
1115
1313
|
parent: loaderBodyScopeALS.getStore(),
|
|
1116
1314
|
execScope: getCacheExecScope(),
|
|
1315
|
+
tags,
|
|
1117
1316
|
},
|
|
1118
1317
|
fn,
|
|
1119
1318
|
);
|
|
1120
1319
|
}
|
|
1121
1320
|
|
|
1321
|
+
/** The innermost loader body's execution tag set (runInsideLoaderBodyScope). */
|
|
1322
|
+
export function getLoaderBodyTags(): Set<string> | undefined {
|
|
1323
|
+
return loaderBodyScopeALS.getStore()?.tags;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1122
1326
|
/**
|
|
1123
1327
|
* The $$id of the loader whose body is currently executing, or undefined
|
|
1124
1328
|
* outside any loader body. Used by the shell-capture identity guard
|
|
1125
|
-
* (
|
|
1329
|
+
* (assertNotInsideShellCapture) so its refusal warning can name the loader that read
|
|
1126
1330
|
* cookies()/headers() instead of blaming a lane it cannot see — the old
|
|
1127
1331
|
* hardcoded "bake-lane loader" text misled a live-lane debugging session
|
|
1128
1332
|
* (issue #672, secondary).
|
|
@@ -1144,19 +1348,6 @@ export function isInsideLoaderBody(loaderId: string): boolean {
|
|
|
1144
1348
|
return false;
|
|
1145
1349
|
}
|
|
1146
1350
|
|
|
1147
|
-
/**
|
|
1148
|
-
* True while a HANDLER-invoked loader body (`await ctx.use(Loader)` from a
|
|
1149
|
-
* handler, not the DSL segment funnel) is executing. The consumption-lane
|
|
1150
|
-
* rule keys off this: handler consumption yields a BAKED copy in every shared
|
|
1151
|
-
* artifact — cache(), "use cache", and the PPR shell — so the shell-capture
|
|
1152
|
-
* identity guard (cookie-store.ts) permits cookies()/headers() here, exactly
|
|
1153
|
-
* like the cache-purity guards do. DSL segment loaders (live lane masked at
|
|
1154
|
-
* capture, bake lane guarded) never set the flag.
|
|
1155
|
-
*/
|
|
1156
|
-
export function isInsideHandlerInvokedLoaderBody(): boolean {
|
|
1157
|
-
return loaderBodyScopeALS.getStore()?.handlerInvoked === true;
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
1351
|
// Scope for handle PUSH CALLBACKS (push(() => ...), including async ones).
|
|
1161
1352
|
// A push callback's value is stored as-is; if it is a promise it is NOT tracked
|
|
1162
1353
|
// by handleStore.settled and does not block segment resolution, so a
|
|
@@ -11,11 +11,11 @@ import type { CookieOptions } from "../router/middleware-types.js";
|
|
|
11
11
|
import { getRequestContext, _getRequestContext } from "./request-context.js";
|
|
12
12
|
import {
|
|
13
13
|
isInsideCacheScope,
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
assertNotInsideShellCapture,
|
|
15
|
+
tripShellCaptureGuard,
|
|
16
|
+
recordLoaderIdentityRead,
|
|
16
17
|
} from "./context.js";
|
|
17
18
|
import { isInsideCacheExecScope } from "../cache/cache-exec-scope.js";
|
|
18
|
-
import { PPR_LANE_HINT } from "../rsc/shell-capture-constants.js";
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* A single cookie entry returned by get() and getAll().
|
|
@@ -113,9 +113,13 @@ type HeadersIterator<T> = IterableIterator<T>;
|
|
|
113
113
|
* (Scar: the previous INSIDE_CACHE_EXEC stamp on the shared RequestContext
|
|
114
114
|
* made exactly that parallel read throw for the cached fetch's whole
|
|
115
115
|
* execution window — see cache-exec-scope.ts.)
|
|
116
|
+
*
|
|
117
|
+
* The loader cache() identity reads (#972) are recorded by the read methods
|
|
118
|
+
* instead (createCookieStore, the headers() view), so a write alone is not one.
|
|
116
119
|
*/
|
|
117
120
|
function assertNotInsideCacheContext(fnName: string): void {
|
|
118
|
-
|
|
121
|
+
const scope = refusingCacheScope();
|
|
122
|
+
if (scope === "use cache") {
|
|
119
123
|
throw new Error(
|
|
120
124
|
`${fnName}() cannot be called inside a "use cache" function. ` +
|
|
121
125
|
`Request-scoped data (cookies, headers) varies per request but is not ` +
|
|
@@ -126,7 +130,7 @@ function assertNotInsideCacheContext(fnName: string): void {
|
|
|
126
130
|
` const data = await getCachedData(locale); // locale is now in the cache key`,
|
|
127
131
|
);
|
|
128
132
|
}
|
|
129
|
-
if (
|
|
133
|
+
if (scope === "cache()") {
|
|
130
134
|
throw new Error(
|
|
131
135
|
`${fnName}() cannot be called inside a cache() boundary. ` +
|
|
132
136
|
`A cache() scope caches everything except loaders, so request-scoped ` +
|
|
@@ -139,72 +143,89 @@ function assertNotInsideCacheContext(fnName: string): void {
|
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
*
|
|
164
|
-
*
|
|
146
|
+
* The cache scope that refuses a request-scoped read here, or undefined. One
|
|
147
|
+
* owner of the predicates and their order (assertNotInsideCacheContext above),
|
|
148
|
+
* so cookies()/headers() and the theme reads refuse in the same places.
|
|
149
|
+
*/
|
|
150
|
+
function refusingCacheScope(): "use cache" | "cache()" | undefined {
|
|
151
|
+
if (isInsideCacheExecScope()) return "use cache";
|
|
152
|
+
if (isInsideCacheScope()) return "cache()";
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The fix for a theme read that a cache() boundary or a ppr capture refuses. */
|
|
157
|
+
const THEME_READ_FIX =
|
|
158
|
+
"On ppr and cache() routes, read the theme with useTheme() in a client " +
|
|
159
|
+
"component, or in a live loader (no ssr: false) with cookies().get(<storageKey>). " +
|
|
160
|
+
"The <html> theme class needs neither: the theme script sets it before paint. " +
|
|
161
|
+
"See the /theme skill (node_modules/@rangojs/router/skills/theme/SKILL.md).";
|
|
162
|
+
|
|
163
|
+
/** The public reads of the visitor's theme cookie that assertThemeReadAllowed guards. */
|
|
164
|
+
export type ThemeReadSurface = "ctx.theme" | "getRequestContext().theme";
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Guard for a read of the visitor's theme: the handler and middleware
|
|
168
|
+
* `ctx.theme` (handler-context.ts, middleware.ts) and `getRequestContext().theme`
|
|
169
|
+
* (request-context.ts). The theme is the visitor's theme cookie, so it is an
|
|
170
|
+
* identity read like cookies(): it refuses in the same scopes, in the same
|
|
171
|
+
* order (refusingCacheScope, then tripShellCaptureGuard in context.ts), with
|
|
172
|
+
* the same exemption (a loader body under cache(); at capture there is none).
|
|
173
|
+
* Before #971 each read was a plain cookie read, so a ppr shell, a cache()
|
|
174
|
+
* entry or a "use cache" entry stored the first visitor's theme and served it
|
|
175
|
+
* to every later visitor.
|
|
165
176
|
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* keepClientCache()) stay callable: during capture they are header effects on
|
|
169
|
-
* a discarded response, and on the live HIT path the full pipeline runs so their
|
|
170
|
-
* headers flow to the client normally.
|
|
177
|
+
* A read the guards allow is recorded on the current loader execution, as a
|
|
178
|
+
* cookies() read method records it (#972, recordLoaderIdentityRead).
|
|
171
179
|
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
180
|
+
* `ctx` is the request context at read time, as cookies() reads it. The
|
|
181
|
+
* router's own payload read (payloadInitialTheme, rsc/full-payload.ts) goes
|
|
182
|
+
* through the unguarded `_readTheme()`.
|
|
175
183
|
*/
|
|
176
|
-
function
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
)
|
|
184
|
+
export function assertThemeReadAllowed(
|
|
185
|
+
ctx: unknown,
|
|
186
|
+
surface: ThemeReadSurface,
|
|
187
|
+
): void {
|
|
188
|
+
const scope = refusingCacheScope();
|
|
189
|
+
if (scope === "use cache") {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`${surface} cannot be read inside a "use cache" function. The theme ` +
|
|
192
|
+
`comes from the visitor's cookie and is not in the cache key, so the ` +
|
|
193
|
+
`first caller's theme would be served to later callers. Read it ` +
|
|
194
|
+
`before the cached function and pass it in as an argument.`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (scope === "cache()") {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`${surface} cannot be read inside a cache() boundary. The theme comes ` +
|
|
200
|
+
`from the visitor's cookie, so the first visitor's theme would be ` +
|
|
201
|
+
`stored in the shared entry and served to everyone. ${THEME_READ_FIX}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (tripShellCaptureGuard(ctx, surface, THEME_READ_FIX)) {
|
|
195
205
|
throw new Error(
|
|
196
|
-
`${
|
|
197
|
-
`
|
|
198
|
-
`
|
|
199
|
-
`${fnName === "cookies" ? "cookies" : "headers"} to others. Read it ` +
|
|
200
|
-
`inside a loader without ssr: false and consume it with useLoader, e.g. ` +
|
|
201
|
-
`createLoader(async () => getUser(cookies().get("session")?.value)). ` +
|
|
202
|
-
PPR_LANE_HINT,
|
|
206
|
+
`${surface} cannot be read while capturing a shared shell (ppr shell ` +
|
|
207
|
+
`capture). The captured shell is served to every visitor of this URL, ` +
|
|
208
|
+
`so the capturing visitor's theme would reach everyone. ${THEME_READ_FIX}`,
|
|
203
209
|
);
|
|
204
210
|
}
|
|
211
|
+
// An identity read like a cookies() read method: a loader cache() fill with
|
|
212
|
+
// no key() refuses an execution that made one (#972).
|
|
213
|
+
recordLoaderIdentityRead(surface, "read");
|
|
205
214
|
}
|
|
206
215
|
|
|
207
216
|
const HEADERS_MUTATION_METHODS = new Set(["set", "append", "delete"]);
|
|
217
|
+
// Reading one records the read (#972), like the cookies() read methods: a
|
|
218
|
+
// headers() view taken outside a loader and read inside one still counts.
|
|
219
|
+
const HEADERS_READ_PROPS = new Set<string | symbol>([
|
|
220
|
+
"get",
|
|
221
|
+
"has",
|
|
222
|
+
"entries",
|
|
223
|
+
"keys",
|
|
224
|
+
"values",
|
|
225
|
+
"forEach",
|
|
226
|
+
"getSetCookie",
|
|
227
|
+
Symbol.iterator,
|
|
228
|
+
]);
|
|
208
229
|
|
|
209
230
|
/**
|
|
210
231
|
* Get the original request headers (read-only).
|
|
@@ -227,6 +248,7 @@ export function headers(): ReadonlyHeaders {
|
|
|
227
248
|
assertNotInsideShellCapture(ctx, "headers");
|
|
228
249
|
return new Proxy(ctx.request.headers, {
|
|
229
250
|
get(target, prop, receiver) {
|
|
251
|
+
if (HEADERS_READ_PROPS.has(prop)) recordLoaderIdentityRead("headers()");
|
|
230
252
|
if (typeof prop === "string" && HEADERS_MUTATION_METHODS.has(prop)) {
|
|
231
253
|
return () => {
|
|
232
254
|
throw new Error(
|
|
@@ -307,11 +329,13 @@ function createCookieStore(ctx: {
|
|
|
307
329
|
}): CookieStore {
|
|
308
330
|
return {
|
|
309
331
|
get(name: string): Cookie | undefined {
|
|
332
|
+
recordLoaderIdentityRead("cookies()");
|
|
310
333
|
const value = ctx.cookie(name);
|
|
311
334
|
return value !== undefined ? { name, value } : undefined;
|
|
312
335
|
},
|
|
313
336
|
|
|
314
337
|
getAll(name?: string): Cookie[] {
|
|
338
|
+
recordLoaderIdentityRead("cookies()");
|
|
315
339
|
const all = ctx.cookies();
|
|
316
340
|
if (name !== undefined) {
|
|
317
341
|
const value = all[name];
|
|
@@ -321,6 +345,7 @@ function createCookieStore(ctx: {
|
|
|
321
345
|
},
|
|
322
346
|
|
|
323
347
|
has(name: string): boolean {
|
|
348
|
+
recordLoaderIdentityRead("cookies()");
|
|
324
349
|
return ctx.cookie(name) !== undefined;
|
|
325
350
|
},
|
|
326
351
|
|