@rangojs/router 0.2.0 → 0.4.1
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 +19 -1
- package/dist/types/browser/event-controller.d.ts +4 -0
- package/dist/types/browser/link-interceptor.d.ts +17 -7
- package/dist/types/browser/navigation-bridge.d.ts +13 -1
- package/dist/types/browser/notify-listeners.d.ts +2 -0
- package/dist/types/browser/prefetch/cache.d.ts +1 -1
- package/dist/types/browser/prefetch/default-strategy.d.ts +31 -0
- package/dist/types/browser/prefetch/invalidation.d.ts +6 -0
- package/dist/types/browser/prefetch/loader.d.ts +3 -1
- package/dist/types/browser/prefetch/observer.d.ts +3 -6
- package/dist/types/browser/prefetch/runtime.d.ts +0 -1
- package/dist/types/browser/rango-state.d.ts +4 -0
- package/dist/types/browser/react/Link.d.ts +11 -19
- package/dist/types/browser/react/context.d.ts +3 -0
- package/dist/types/browser/rsc-router.d.ts +7 -2
- package/dist/types/browser/types.d.ts +6 -0
- package/dist/types/cache/cache-key-utils.d.ts +11 -3
- package/dist/types/cache/cache-scope.d.ts +82 -3
- package/dist/types/cache/cf/cf-cache-store.d.ts +2 -2
- package/dist/types/cache/index.d.ts +3 -1
- package/dist/types/cache/search-params-filter.d.ts +64 -0
- package/dist/types/cache/shell-snapshot.d.ts +4 -4
- package/dist/types/cache/types.d.ts +36 -2
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.rsc.d.ts +1 -0
- package/dist/types/router/match-middleware/cache-lookup.d.ts +13 -0
- package/dist/types/router/navigation-snapshot.d.ts +29 -0
- package/dist/types/router/prefetch-default.d.ts +28 -0
- package/dist/types/router/router-interfaces.d.ts +7 -0
- package/dist/types/router/router-options.d.ts +54 -0
- package/dist/types/rsc/capture-queue.d.ts +6 -0
- package/dist/types/rsc/shell-build-manifest.d.ts +7 -1
- package/dist/types/rsc/shell-capture.d.ts +15 -7
- package/dist/types/rsc/shell-serve.d.ts +11 -4
- package/dist/types/rsc/types.d.ts +19 -0
- package/dist/types/server/request-context.d.ts +57 -4
- package/dist/types/testing/e2e/index.d.ts +2 -2
- package/dist/types/testing/e2e/page-helpers.d.ts +24 -0
- package/dist/types/testing/render-route.d.ts +10 -1
- package/dist/types/testing/shell-status.d.ts +6 -3
- package/dist/types/vite/plugin-types.d.ts +1 -1
- package/dist/vite/index.js +9 -6
- package/package.json +23 -22
- package/skills/caching/SKILL.md +39 -0
- package/skills/comparison/references/framework-comparison.md +9 -2
- package/skills/links/SKILL.md +24 -0
- package/skills/ppr/SKILL.md +80 -16
- package/skills/router-setup/SKILL.md +9 -0
- package/skills/testing/client-components.md +15 -14
- package/skills/vercel/SKILL.md +1 -1
- package/src/browser/event-controller.ts +110 -11
- package/src/browser/link-interceptor.ts +469 -20
- package/src/browser/navigation-bridge.ts +71 -2
- package/src/browser/navigation-store.ts +24 -1
- package/src/browser/notify-listeners.ts +22 -0
- package/src/browser/prefetch/cache.ts +4 -2
- package/src/browser/prefetch/default-strategy.ts +74 -0
- package/src/browser/prefetch/invalidation.ts +30 -0
- package/src/browser/prefetch/loader.ts +18 -17
- package/src/browser/prefetch/observer.ts +50 -22
- package/src/browser/prefetch/runtime.ts +0 -1
- package/src/browser/rango-state.ts +21 -0
- package/src/browser/react/Link.tsx +111 -101
- package/src/browser/react/NavigationProvider.tsx +1 -0
- package/src/browser/react/context.ts +4 -0
- package/src/browser/rsc-router.tsx +30 -9
- package/src/browser/types.ts +6 -0
- package/src/cache/cache-key-utils.ts +18 -4
- package/src/cache/cache-runtime.ts +7 -2
- package/src/cache/cache-scope.ts +152 -23
- package/src/cache/cf/cf-cache-store.ts +55 -16
- package/src/cache/document-cache.ts +7 -1
- package/src/cache/index.ts +7 -0
- package/src/cache/search-params-filter.ts +118 -0
- package/src/cache/shell-snapshot.ts +8 -4
- package/src/cache/types.ts +39 -2
- package/src/cache/vercel/vercel-cache-store.ts +22 -3
- package/src/index.rsc.ts +4 -0
- package/src/index.ts +6 -0
- package/src/router/match-middleware/cache-lookup.ts +146 -33
- package/src/router/match-middleware/cache-store.ts +70 -0
- package/src/router/navigation-snapshot.ts +46 -6
- package/src/router/prefetch-default.ts +59 -0
- package/src/router/router-interfaces.ts +8 -0
- package/src/router/router-options.ts +59 -1
- package/src/router.ts +7 -0
- package/src/rsc/capture-queue.ts +24 -1
- package/src/rsc/full-payload.ts +1 -0
- package/src/rsc/handler.ts +10 -0
- package/src/rsc/response-cache-serve.ts +1 -1
- package/src/rsc/rsc-rendering.ts +367 -40
- package/src/rsc/shell-build-manifest.ts +8 -1
- package/src/rsc/shell-capture.ts +99 -50
- package/src/rsc/shell-serve.ts +14 -6
- package/src/rsc/types.ts +19 -0
- package/src/server/request-context.ts +62 -3
- package/src/testing/dispatch.ts +21 -3
- package/src/testing/e2e/index.ts +6 -0
- package/src/testing/e2e/page-helpers.ts +47 -0
- package/src/testing/e2e/parity.ts +13 -0
- package/src/testing/render-route.tsx +86 -17
- package/src/testing/shell-status.ts +20 -3
- package/src/vite/plugin-types.ts +1 -1
- package/src/vite/plugins/vercel-output.ts +2 -2
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
setPrefetchConcurrency,
|
|
31
31
|
setPrefetchDecoder,
|
|
32
32
|
} from "./prefetch/loader.js";
|
|
33
|
+
import { setDefaultPrefetchStrategy } from "./prefetch/default-strategy.js";
|
|
33
34
|
import { setAppVersion } from "./app-version.js";
|
|
34
35
|
import {
|
|
35
36
|
isInterceptSegment,
|
|
@@ -67,8 +68,11 @@ export interface InitBrowserAppOptions {
|
|
|
67
68
|
|
|
68
69
|
/**
|
|
69
70
|
* Enable global link interception for SPA navigation.
|
|
70
|
-
* When enabled, clicks on same-origin anchor elements are intercepted
|
|
71
|
-
* and handled via client-side navigation instead of full page loads.
|
|
71
|
+
* When enabled, clicks on eligible same-origin HTML anchor elements are intercepted
|
|
72
|
+
* and handled via client-side navigation instead of full page loads. Plain
|
|
73
|
+
* anchors inside the router basename also follow its default prefetch strategy
|
|
74
|
+
* after hydration. `data-prefetch="false"`/`"none"` opts out; `"true"` allows
|
|
75
|
+
* an application route with a common static-resource suffix.
|
|
72
76
|
*
|
|
73
77
|
* Links rendered with the Link component handle their own navigation
|
|
74
78
|
* regardless of this setting.
|
|
@@ -123,6 +127,8 @@ export interface BrowserAppContext {
|
|
|
123
127
|
warmupEnabled?: boolean;
|
|
124
128
|
/** Whether the hydrated tree should be wrapped in React.StrictMode */
|
|
125
129
|
strictMode?: boolean;
|
|
130
|
+
/** Whether plain-anchor click interception and delegated prefetch are enabled */
|
|
131
|
+
linkInterceptionEnabled?: boolean;
|
|
126
132
|
/** App version for prefetch version mismatch detection */
|
|
127
133
|
version?: string;
|
|
128
134
|
/**
|
|
@@ -195,6 +201,12 @@ export async function initBrowserApp(
|
|
|
195
201
|
}
|
|
196
202
|
const initialHistoryKey = generateHistoryKey(window.location.href);
|
|
197
203
|
|
|
204
|
+
// Resolve the state namespace before the store installs its BroadcastChannel
|
|
205
|
+
// listener. A streaming handle payload can delay hydration below; leaving the
|
|
206
|
+
// default name active during that wait would discard this router's messages.
|
|
207
|
+
const version = initialPayload.metadata?.version;
|
|
208
|
+
initRangoState(version ?? "0", initialPayload.metadata?.stateCookieName);
|
|
209
|
+
|
|
198
210
|
// Create navigation store with history-based caching
|
|
199
211
|
const store = createNavigationStore({
|
|
200
212
|
initialLocation: window.location,
|
|
@@ -270,7 +282,6 @@ export async function initBrowserApp(
|
|
|
270
282
|
// It is set once from the initial payload and not swapped within a session:
|
|
271
283
|
// a cross-app navigation is a full document load (X-RSC-Reload), so the
|
|
272
284
|
// target app establishes its own shell on load.
|
|
273
|
-
const version = initialPayload.metadata?.version;
|
|
274
285
|
const appShellRef = createAppShellRef({
|
|
275
286
|
routerId: initialPayload.metadata?.routerId,
|
|
276
287
|
rootLayout: initialPayload.metadata?.rootLayout,
|
|
@@ -278,11 +289,6 @@ export async function initBrowserApp(
|
|
|
278
289
|
version,
|
|
279
290
|
});
|
|
280
291
|
|
|
281
|
-
// Initialize the rango state cookie for cache invalidation. The build version
|
|
282
|
-
// busts cached prefetches on deploy; the server-resolved cookie name
|
|
283
|
-
// namespaces the cookie so sibling apps on the same origin don't collide
|
|
284
|
-
// (falls back to the bare default prefix if metadata lacks the name).
|
|
285
|
-
initRangoState(version ?? "0", initialPayload.metadata?.stateCookieName);
|
|
286
292
|
setAppVersion(version);
|
|
287
293
|
|
|
288
294
|
// Initialize the in-memory prefetch cache (TTL + max size) and the prefetch
|
|
@@ -297,6 +303,13 @@ export async function initBrowserApp(
|
|
|
297
303
|
if (prefetchConcurrency !== undefined) {
|
|
298
304
|
setPrefetchConcurrency(prefetchConcurrency);
|
|
299
305
|
}
|
|
306
|
+
// Apply the router-wide default Link prefetch strategy. Undefined (older
|
|
307
|
+
// server payload) keeps the module's environment-aware default, which equals
|
|
308
|
+
// the server resolver's default by contract — see default-strategy.ts.
|
|
309
|
+
const defaultPrefetch = initialPayload.metadata?.defaultPrefetch;
|
|
310
|
+
if (defaultPrefetch !== undefined) {
|
|
311
|
+
setDefaultPrefetchStrategy(defaultPrefetch);
|
|
312
|
+
}
|
|
300
313
|
|
|
301
314
|
// Wire the RSC decoder so prefetches decode eagerly and warm the route's
|
|
302
315
|
// client chunks (same createFromFetch the navigation client uses).
|
|
@@ -351,6 +364,8 @@ export async function initBrowserApp(
|
|
|
351
364
|
onUpdate: (update) => store.emitUpdate(update),
|
|
352
365
|
renderSegments,
|
|
353
366
|
version: version,
|
|
367
|
+
defaultPrefetch,
|
|
368
|
+
basename: initialPayload.metadata?.basename,
|
|
354
369
|
});
|
|
355
370
|
|
|
356
371
|
// Connect action redirect → navigation bridge (now that both are initialized)
|
|
@@ -533,6 +548,7 @@ export async function initBrowserApp(
|
|
|
533
548
|
initialTheme: effectiveInitialTheme,
|
|
534
549
|
warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
|
|
535
550
|
strictMode: initialPayload.metadata?.strictMode ?? true,
|
|
551
|
+
linkInterceptionEnabled: linkInterception,
|
|
536
552
|
version,
|
|
537
553
|
appShellRef,
|
|
538
554
|
};
|
|
@@ -606,6 +622,7 @@ export function Rango(_props: RangoProps): React.ReactElement {
|
|
|
606
622
|
warmupEnabled,
|
|
607
623
|
version,
|
|
608
624
|
appShellRef,
|
|
625
|
+
linkInterceptionEnabled,
|
|
609
626
|
} = getBrowserAppContext();
|
|
610
627
|
|
|
611
628
|
// Signal that the React tree has hydrated. useEffect only fires after
|
|
@@ -613,11 +630,15 @@ export function Rango(_props: RangoProps): React.ReactElement {
|
|
|
613
630
|
// that does not depend on React internals like __reactFiber.
|
|
614
631
|
React.useEffect(() => {
|
|
615
632
|
document.documentElement.dataset.hydrated = "";
|
|
633
|
+
const cleanupPrefetch = linkInterceptionEnabled
|
|
634
|
+
? bridge.registerDelegatedPrefetch()
|
|
635
|
+
: undefined;
|
|
616
636
|
if (IS_BROWSER_DEBUG && !hydrationCommitLogged) {
|
|
617
637
|
hydrationCommitLogged = true;
|
|
618
638
|
bootLog("hydration commit (root effect flushed)");
|
|
619
639
|
}
|
|
620
|
-
|
|
640
|
+
return cleanupPrefetch;
|
|
641
|
+
}, [bridge, linkInterceptionEnabled]);
|
|
621
642
|
|
|
622
643
|
return (
|
|
623
644
|
<NavigationProvider
|
package/src/browser/types.ts
CHANGED
|
@@ -82,6 +82,11 @@ export interface RscMetadata {
|
|
|
82
82
|
* Sent on initial render so the browser can configure its prefetch queue.
|
|
83
83
|
*/
|
|
84
84
|
prefetchConcurrency?: number;
|
|
85
|
+
/**
|
|
86
|
+
* Router-wide default prefetch strategy for Links without a `prefetch` prop.
|
|
87
|
+
* Sent on initial render; applied once at init (default-strategy.ts).
|
|
88
|
+
*/
|
|
89
|
+
defaultPrefetch?: import("../router/prefetch-default.js").PrefetchStrategy;
|
|
85
90
|
/**
|
|
86
91
|
* Server-resolved rango state cookie name (`{prefix}_{routerId}`). The client
|
|
87
92
|
* reads it verbatim and binds the rango state cookie to it; composition
|
|
@@ -608,6 +613,7 @@ export interface NavigationBridge {
|
|
|
608
613
|
refresh(): Promise<void>;
|
|
609
614
|
handlePopstate(): Promise<void>;
|
|
610
615
|
registerLinkInterception(): () => void;
|
|
616
|
+
registerDelegatedPrefetch(): () => void;
|
|
611
617
|
/** Current RSC version (live, reflects the latest updateVersion). */
|
|
612
618
|
getVersion(): string | undefined;
|
|
613
619
|
/** Update the RSC version (e.g. after HMR). Clears prefetch cache. */
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { encodeKV } from "../encode-kv.js";
|
|
10
|
+
import type { SearchParamsFilter } from "./search-params-filter.js";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Reserved URL query params that the router owns and must never key the cache
|
|
@@ -34,12 +35,22 @@ function isReservedSearchParam(key: string): boolean {
|
|
|
34
35
|
* Build a sorted, deterministic query string from URLSearchParams,
|
|
35
36
|
* excluding the router's reserved params (see isReservedSearchParam).
|
|
36
37
|
*
|
|
37
|
-
*
|
|
38
|
+
* `filter` is the compiled `cache.searchParams` config (search-params-filter.ts),
|
|
39
|
+
* applied AFTER the reserved-param exclusion and BEFORE the sort, so reserved
|
|
40
|
+
* params can never be re-included (`include: ["__no_cache"]` is a no-op) and
|
|
41
|
+
* surviving params stay order-insensitive. `undefined` means no filtering --
|
|
42
|
+
* that path must stay byte-identical to the pre-filter format (cacheKeyBase
|
|
43
|
+
* output is byte-stable by contract).
|
|
44
|
+
*
|
|
45
|
+
* Returns empty string when no user-facing params survive.
|
|
38
46
|
*/
|
|
39
|
-
export function sortedSearchString(
|
|
47
|
+
export function sortedSearchString(
|
|
48
|
+
searchParams: URLSearchParams,
|
|
49
|
+
filter?: SearchParamsFilter,
|
|
50
|
+
): string {
|
|
40
51
|
const pairs: [string, string][] = [];
|
|
41
52
|
for (const [k, v] of searchParams) {
|
|
42
|
-
if (!isReservedSearchParam(k)) {
|
|
53
|
+
if (!isReservedSearchParam(k) && (filter === undefined || filter(k))) {
|
|
43
54
|
pairs.push([k, v]);
|
|
44
55
|
}
|
|
45
56
|
}
|
|
@@ -77,9 +88,12 @@ export function cacheKeyBase(
|
|
|
77
88
|
pathname: string,
|
|
78
89
|
searchParams?: URLSearchParams,
|
|
79
90
|
params?: Record<string, string>,
|
|
91
|
+
filter?: SearchParamsFilter,
|
|
80
92
|
): string {
|
|
81
93
|
const paramStr = sortedRouteParams(params);
|
|
82
|
-
const searchStr = searchParams
|
|
94
|
+
const searchStr = searchParams
|
|
95
|
+
? sortedSearchString(searchParams, filter)
|
|
96
|
+
: "";
|
|
83
97
|
|
|
84
98
|
let key = `${host}${pathname}`;
|
|
85
99
|
if (paramStr) key += `:${paramStr}`;
|
|
@@ -302,9 +302,14 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
302
302
|
if (ctx._responseType) {
|
|
303
303
|
keyArgs.push(ctx._responseType);
|
|
304
304
|
}
|
|
305
|
-
// Include user-facing search params (exclude internal _rsc*/__
|
|
305
|
+
// Include user-facing search params (exclude internal _rsc*/__
|
|
306
|
+
// params, plus the request's cache.searchParams filter -- same
|
|
307
|
+
// normalization as the URL-keyed tiers).
|
|
306
308
|
if (ctx.searchParams instanceof URLSearchParams) {
|
|
307
|
-
const normalized = sortedSearchString(
|
|
309
|
+
const normalized = sortedSearchString(
|
|
310
|
+
ctx.searchParams,
|
|
311
|
+
requestCtx?._searchParamsFilter,
|
|
312
|
+
);
|
|
308
313
|
if (normalized) {
|
|
309
314
|
keyArgs.push(normalized);
|
|
310
315
|
}
|
package/src/cache/cache-scope.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type { PartialCacheOptions } from "../types.js";
|
|
12
12
|
import type { ResolvedSegment } from "../types.js";
|
|
13
13
|
import type { SegmentCacheStore, CachedEntryData } from "./types.js";
|
|
14
|
+
import { CACHE_READ_ERROR } from "./types.js";
|
|
14
15
|
import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
|
|
15
16
|
import {
|
|
16
17
|
getRequestContext,
|
|
@@ -101,7 +102,7 @@ function getDefaultRouteCacheKey(
|
|
|
101
102
|
? "intercept"
|
|
102
103
|
: (prefixOverride ?? (isPartial ? "partial" : "doc"));
|
|
103
104
|
|
|
104
|
-
return `${prefix}:${cacheKeyBase(host, pathname, searchParams, params)}`;
|
|
105
|
+
return `${prefix}:${cacheKeyBase(host, pathname, searchParams, params, ctx?._searchParamsFilter)}`;
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
// ============================================================================
|
|
@@ -110,6 +111,17 @@ function getDefaultRouteCacheKey(
|
|
|
110
111
|
|
|
111
112
|
const CACHE_HIT_OBSERVERS = new WeakMap<CacheScope, () => void>();
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Discriminated outcome of a route cache lookup — see
|
|
116
|
+
* {@link CacheScope.lookupRouteDetailed} for what each status licenses.
|
|
117
|
+
*/
|
|
118
|
+
export type CacheRouteLookupOutcome =
|
|
119
|
+
| {
|
|
120
|
+
status: "hit";
|
|
121
|
+
result: { segments: ResolvedSegment[]; shouldRevalidate: boolean };
|
|
122
|
+
}
|
|
123
|
+
| { status: "miss" | "bypass" | "error" };
|
|
124
|
+
|
|
113
125
|
/**
|
|
114
126
|
* CacheScope represents a cache boundary in the route tree.
|
|
115
127
|
*
|
|
@@ -225,30 +237,76 @@ export class CacheScope {
|
|
|
225
237
|
return resolveCacheKey(keyFn, this.getStore(), defaultKey, "CacheScope");
|
|
226
238
|
}
|
|
227
239
|
|
|
240
|
+
/**
|
|
241
|
+
* @internal Whether a cache read/write is allowed for the current request:
|
|
242
|
+
* the scope is enabled AND its `condition` (if any) returns true. "read"
|
|
243
|
+
* is consulted by the PPR navigation-replay gate BEFORE any shell-store
|
|
244
|
+
* read so a cache(false)/condition-false route reports `cache-disabled`
|
|
245
|
+
* without spending getShell I/O; "write" gates the capture's snapshot-only
|
|
246
|
+
* doc record (cache-store middleware) under the same semantics as the
|
|
247
|
+
* scope's own store write. Consumer opt-outs are absolute — the seeded
|
|
248
|
+
* fallback must never serve where the consumer refused cached serves.
|
|
249
|
+
*/
|
|
250
|
+
allowsCache(op: "read" | "write"): boolean {
|
|
251
|
+
return this.enabled && this.conditionAllows(op);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* @internal True for scopes minted from the `_shellImplicitCache` marker
|
|
256
|
+
* (createShellImplicitDocScope) — the only construction path that passes
|
|
257
|
+
* the `doc` defaultKeyPrefix; route-derived scopes (createCacheScope) never
|
|
258
|
+
* do. withCacheLookup/withCacheStore use this to tell the implicit doc
|
|
259
|
+
* scope from a route-derived scope: the two compose on the replay serve
|
|
260
|
+
* path and only the route-derived kind gets the seeded fallback /
|
|
261
|
+
* doc-record treatment.
|
|
262
|
+
*/
|
|
263
|
+
get isShellImplicitDocScope(): boolean {
|
|
264
|
+
return this.defaultKeyPrefix === "doc";
|
|
265
|
+
}
|
|
266
|
+
|
|
228
267
|
/**
|
|
229
268
|
* Evaluate the cache `condition` predicate. Returns false (skip the cache
|
|
230
269
|
* operation) when the predicate returns false or throws; returns true when
|
|
231
270
|
* there is no condition or no request context to evaluate it against.
|
|
232
271
|
*/
|
|
272
|
+
/**
|
|
273
|
+
* One WRITE decision per (scope, request), memoized on the request context.
|
|
274
|
+
* A capture render has TWO writers consulting the same predicate — the
|
|
275
|
+
* explicit tier's cacheRoute and the snapshot-only doc record gate
|
|
276
|
+
* (recordShellCaptureDocRecord) — and a true→false flap between the two
|
|
277
|
+
* evaluations recorded a REPLAYABLE canonical snapshot for a render whose
|
|
278
|
+
* real write was refused. The first evaluation pins the answer for the
|
|
279
|
+
* whole render (the capture's derived context during captures). READ
|
|
280
|
+
* decisions stay per-lookup by design: pre-deciding a flappable predicate
|
|
281
|
+
* at the replay gate was the round-2 regression.
|
|
282
|
+
*/
|
|
283
|
+
private readonly writeConditionMemo = new WeakMap<RequestContext, boolean>();
|
|
284
|
+
|
|
233
285
|
private conditionAllows(op: "read" | "write"): boolean {
|
|
234
286
|
if (this.config === false || !this.config.condition) return true;
|
|
235
287
|
const requestCtx = getRequestContext();
|
|
236
288
|
if (!requestCtx) return true;
|
|
289
|
+
if (op === "write") {
|
|
290
|
+
const memoized = this.writeConditionMemo.get(requestCtx);
|
|
291
|
+
if (memoized !== undefined) return memoized;
|
|
292
|
+
}
|
|
293
|
+
let allowed: boolean;
|
|
237
294
|
try {
|
|
238
|
-
|
|
295
|
+
allowed = !!this.config.condition(requestCtx);
|
|
296
|
+
if (!allowed) {
|
|
239
297
|
debugCacheLog(
|
|
240
298
|
`[CacheScope] condition returned false, skipping cache ${op}`,
|
|
241
299
|
);
|
|
242
|
-
return false;
|
|
243
300
|
}
|
|
244
|
-
return true;
|
|
245
301
|
} catch (error) {
|
|
246
302
|
console.error(
|
|
247
303
|
`[CacheScope] condition function threw, skipping cache ${op}:`,
|
|
248
304
|
error,
|
|
249
305
|
);
|
|
250
|
-
|
|
306
|
+
allowed = false;
|
|
251
307
|
}
|
|
308
|
+
if (op === "write") this.writeConditionMemo.set(requestCtx, allowed);
|
|
309
|
+
return allowed;
|
|
252
310
|
}
|
|
253
311
|
|
|
254
312
|
/**
|
|
@@ -267,11 +325,42 @@ export class CacheScope {
|
|
|
267
325
|
segments: ResolvedSegment[];
|
|
268
326
|
shouldRevalidate: boolean;
|
|
269
327
|
} | null> {
|
|
270
|
-
|
|
271
|
-
|
|
328
|
+
const outcome = await this.lookupRouteDetailed(
|
|
329
|
+
pathname,
|
|
330
|
+
params,
|
|
331
|
+
isIntercept,
|
|
332
|
+
);
|
|
333
|
+
return outcome.status === "hit" ? outcome.result : null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* @internal lookupRoute with a discriminated outcome. The PPR replay
|
|
338
|
+
* composition (withCacheLookup) may substitute the seeded doc record ONLY on
|
|
339
|
+
* a true `miss` — the other non-hit outcomes must not be papered over:
|
|
340
|
+
*
|
|
341
|
+
* - `bypass`: the scope refused the read — cache(false), a false
|
|
342
|
+
* `condition()`, or no resolvable store. Consumer opt-outs are absolute;
|
|
343
|
+
* a fallback here would serve cached segments to a request the consumer
|
|
344
|
+
* said must render fresh.
|
|
345
|
+
* - `error`: a throwing consumer key()/store keyGenerator/store.get. The
|
|
346
|
+
* contract on that failure is "render uncached" (see the catch below) —
|
|
347
|
+
* falling back would serve the canonical doc record across a broken key
|
|
348
|
+
* partition, exactly the collision resolveCacheKey's no-default-fallback
|
|
349
|
+
* rule exists to prevent.
|
|
350
|
+
*
|
|
351
|
+
* A corrupt entry that was evicted reads as `miss`: the store was consulted
|
|
352
|
+
* and holds nothing servable, which is the post-eviction truth.
|
|
353
|
+
*/
|
|
354
|
+
async lookupRouteDetailed(
|
|
355
|
+
pathname: string,
|
|
356
|
+
params: Record<string, string>,
|
|
357
|
+
isIntercept?: boolean,
|
|
358
|
+
): Promise<CacheRouteLookupOutcome> {
|
|
359
|
+
if (!this.enabled) return { status: "bypass" };
|
|
360
|
+
if (!this.conditionAllows("read")) return { status: "bypass" };
|
|
272
361
|
|
|
273
362
|
const store = this.getStore();
|
|
274
|
-
if (!store) return
|
|
363
|
+
if (!store) return { status: "bypass" };
|
|
275
364
|
|
|
276
365
|
// Resolve cache key INSIDE the try so a throwing consumer key() (or a
|
|
277
366
|
// store.keyGenerator) degrades to a cache miss (return null -> render
|
|
@@ -285,9 +374,17 @@ export class CacheScope {
|
|
|
285
374
|
|
|
286
375
|
const result = await store.get(key);
|
|
287
376
|
|
|
377
|
+
// Built-in stores swallow backend read failures internally and signal
|
|
378
|
+
// them with CACHE_READ_ERROR — classify as `error`, not `miss`, so the
|
|
379
|
+
// replay composition renders uncached instead of substituting the
|
|
380
|
+
// seeded doc record for a tier whose backend never answered.
|
|
381
|
+
if (result === CACHE_READ_ERROR) {
|
|
382
|
+
return { status: "error" };
|
|
383
|
+
}
|
|
384
|
+
|
|
288
385
|
if (!result) {
|
|
289
386
|
debugCacheLog(`[CacheScope] MISS: ${key}`);
|
|
290
|
-
return
|
|
387
|
+
return { status: "miss" };
|
|
291
388
|
}
|
|
292
389
|
|
|
293
390
|
const { data: cached, shouldRevalidate } = result;
|
|
@@ -320,7 +417,7 @@ export class CacheScope {
|
|
|
320
417
|
.catch((e) =>
|
|
321
418
|
reportCacheError(e, "cache-delete", `[CacheScope] ${key}: evict`),
|
|
322
419
|
);
|
|
323
|
-
return
|
|
420
|
+
return { status: "miss" };
|
|
324
421
|
}
|
|
325
422
|
|
|
326
423
|
// A hit serves content that was tagged at write time, so the document
|
|
@@ -351,16 +448,18 @@ export class CacheScope {
|
|
|
351
448
|
}
|
|
352
449
|
|
|
353
450
|
CACHE_HIT_OBSERVERS.get(this)?.();
|
|
354
|
-
return { segments, shouldRevalidate };
|
|
451
|
+
return { status: "hit", result: { segments, shouldRevalidate } };
|
|
355
452
|
} catch (error) {
|
|
356
453
|
// Covers a store.get() failure AND a throwing consumer key()/keyGenerator
|
|
357
|
-
// (resolveKey). Either way degrade to
|
|
454
|
+
// (resolveKey). Either way degrade to an uncached render — reported as
|
|
455
|
+
// `error`, not `miss`, so the replay composition cannot substitute the
|
|
456
|
+
// seeded doc record for a lookup that never resolved its key partition.
|
|
358
457
|
reportCacheError(
|
|
359
458
|
error,
|
|
360
459
|
"cache-read",
|
|
361
460
|
`[CacheScope] lookup ${key ?? "(key resolution failed)"}`,
|
|
362
461
|
);
|
|
363
|
-
return
|
|
462
|
+
return { status: "error" };
|
|
364
463
|
}
|
|
365
464
|
}
|
|
366
465
|
|
|
@@ -422,6 +521,15 @@ export class CacheScope {
|
|
|
422
521
|
// Resolve cache key early (while request context is available)
|
|
423
522
|
const key = await this.resolveKey(pathname, params, isIntercept);
|
|
424
523
|
|
|
524
|
+
// Doc-namespaced scopes (the shell implicit scope and the capture's
|
|
525
|
+
// composed doc scope) publish the canonical document segment key so
|
|
526
|
+
// captureAndStoreShell can stamp it onto the shell entry (`docKey`).
|
|
527
|
+
// Replay eligibility then requires this exact record — "any segment
|
|
528
|
+
// record" previously counted unusable explicit-tier-keyed records too.
|
|
529
|
+
if (this.defaultKeyPrefix === "doc" && requestCtx._shellImplicitCache) {
|
|
530
|
+
requestCtx._shellImplicitCache.docKey = key;
|
|
531
|
+
}
|
|
532
|
+
|
|
425
533
|
// Resolve tags early (while request context is available, before waitUntil)
|
|
426
534
|
const tags = resolveCacheTags(this.config, requestCtx);
|
|
427
535
|
recordRequestTags(tags, requestCtx);
|
|
@@ -538,6 +646,31 @@ export function createCacheScope(
|
|
|
538
646
|
return new CacheScope(config.options, parent);
|
|
539
647
|
}
|
|
540
648
|
|
|
649
|
+
type ShellImplicitCacheMarker = NonNullable<
|
|
650
|
+
RequestContext["_shellImplicitCache"]
|
|
651
|
+
>;
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Mint the implicit doc-level scope for a `_shellImplicitCache` marker: key
|
|
655
|
+
* resolution under the marker's `doc` namespace against the marker's store,
|
|
656
|
+
* with the marker's onHit wired as the hit observer. Shared by
|
|
657
|
+
* {@link resolveShellImplicitCacheScope} (routes that derived no scope) and
|
|
658
|
+
* the explicit-scope composition sites (capture doc record in the cache-store
|
|
659
|
+
* middleware, seeded replay fallback in withCacheLookup) so both ends of the
|
|
660
|
+
* shell contract resolve the SAME canonical document key.
|
|
661
|
+
*/
|
|
662
|
+
export function createShellImplicitDocScope(
|
|
663
|
+
marker: ShellImplicitCacheMarker,
|
|
664
|
+
): CacheScope {
|
|
665
|
+
const implicitScope = new CacheScope(
|
|
666
|
+
{ ttl: marker.ttl, swr: marker.swr, store: marker.store },
|
|
667
|
+
null,
|
|
668
|
+
marker.keyPrefix,
|
|
669
|
+
);
|
|
670
|
+
if (marker.onHit) CACHE_HIT_OBSERVERS.set(implicitScope, marker.onHit);
|
|
671
|
+
return implicitScope;
|
|
672
|
+
}
|
|
673
|
+
|
|
541
674
|
/**
|
|
542
675
|
* Shell fast path: when the route tree derived NO cache scope and the current
|
|
543
676
|
* request context carries the `_shellImplicitCache` marker (a shell capture,
|
|
@@ -548,9 +681,11 @@ export function createCacheScope(
|
|
|
548
681
|
* (resolveFreshLoadersAndYield).
|
|
549
682
|
*
|
|
550
683
|
* An existing scope — including an explicit cache(false) opt-out — always
|
|
551
|
-
* wins: the consumer's cache() semantics (their ttl/swr/store/condition)
|
|
552
|
-
* never overridden, and cache(false) keeps the tail on the full handler
|
|
553
|
-
* re-run path.
|
|
684
|
+
* wins HERE: the consumer's cache() semantics (their ttl/swr/store/condition)
|
|
685
|
+
* are never overridden, and cache(false) keeps the tail on the full handler
|
|
686
|
+
* re-run path. On the navigation-replay serve path the marker still composes
|
|
687
|
+
* with an explicit scope downstream (withCacheLookup's seeded fallback after
|
|
688
|
+
* an explicit-tier miss) — see `onExplicitHit` on `_shellImplicitCache`.
|
|
554
689
|
*/
|
|
555
690
|
export function resolveShellImplicitCacheScope(
|
|
556
691
|
scope: CacheScope | null,
|
|
@@ -558,11 +693,5 @@ export function resolveShellImplicitCacheScope(
|
|
|
558
693
|
if (scope) return scope;
|
|
559
694
|
const marker = getRequestContext()?._shellImplicitCache;
|
|
560
695
|
if (!marker) return null;
|
|
561
|
-
|
|
562
|
-
{ ttl: marker.ttl, swr: marker.swr, store: marker.store },
|
|
563
|
-
null,
|
|
564
|
-
marker.keyPrefix,
|
|
565
|
-
);
|
|
566
|
-
if (marker.onHit) CACHE_HIT_OBSERVERS.set(implicitScope, marker.onHit);
|
|
567
|
-
return implicitScope;
|
|
696
|
+
return createShellImplicitDocScope(marker);
|
|
568
697
|
}
|
|
@@ -35,7 +35,9 @@ import type {
|
|
|
35
35
|
CacheItemResult,
|
|
36
36
|
CacheItemOptions,
|
|
37
37
|
ShellCacheEntry,
|
|
38
|
+
CacheReadError,
|
|
38
39
|
} from "../types.js";
|
|
40
|
+
import { CACHE_READ_ERROR } from "../types.js";
|
|
39
41
|
import {
|
|
40
42
|
_getRequestContext,
|
|
41
43
|
type RequestContext,
|
|
@@ -286,6 +288,14 @@ interface KVShellEnvelope {
|
|
|
286
288
|
i?: string;
|
|
287
289
|
/** Capture data snapshot: recorded cache-store hits/writes for HIT parity */
|
|
288
290
|
sn?: import("../types.js").ShellSnapshotRecord[];
|
|
291
|
+
/**
|
|
292
|
+
* ShellCacheEntry.docKey. Must round-trip: navigation-replay eligibility
|
|
293
|
+
* requires the exact canonical doc segment record named here — dropping the
|
|
294
|
+
* field reads back as "no consumable record" and every partial navigation
|
|
295
|
+
* reports `no-segment-snapshot` after a KV round trip (the memory store
|
|
296
|
+
* passes the entry by reference, so only envelope stores can lose it).
|
|
297
|
+
*/
|
|
298
|
+
dk?: string;
|
|
289
299
|
/**
|
|
290
300
|
* ShellCacheEntry.handlerLiveHoles. Must round-trip: the serve side arms the
|
|
291
301
|
* handler-free fast path on `!entry.handlerLiveHoles`, so dropping the flag
|
|
@@ -295,6 +305,8 @@ interface KVShellEnvelope {
|
|
|
295
305
|
lh?: boolean;
|
|
296
306
|
/** ShellCacheEntry.transitionWhen; conditional transitions must re-run. */
|
|
297
307
|
tw?: true;
|
|
308
|
+
/** ShellCacheEntry.navigationOnly; its partial-context prelude is not document-safe. */
|
|
309
|
+
no?: true;
|
|
298
310
|
}
|
|
299
311
|
|
|
300
312
|
/**
|
|
@@ -881,7 +893,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
881
893
|
* On L1 miss, falls back to KV (L2) if configured.
|
|
882
894
|
* KV hits are promoted to L1 in the background.
|
|
883
895
|
*/
|
|
884
|
-
async get(key: string): Promise<CacheGetResult | null> {
|
|
896
|
+
async get(key: string): Promise<CacheGetResult | null | CacheReadError> {
|
|
885
897
|
if (this.isReservedSegmentKey(key, "cache-read")) return null;
|
|
886
898
|
try {
|
|
887
899
|
const cache = await this.getCache();
|
|
@@ -895,27 +907,40 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
895
907
|
const matchMs = Date.now() - matchStart;
|
|
896
908
|
|
|
897
909
|
if (!response) {
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
910
|
+
if (matchError) {
|
|
911
|
+
// A match REJECTION is reported as cache-read and still degrades to
|
|
912
|
+
// L2/KV -- a real KV value (or KV's own CACHE_READ_ERROR) stands on
|
|
913
|
+
// its own. But a null KV result (unconfigured namespace, kv-miss,
|
|
914
|
+
// kv-timeout) is NOT proof of absence under a rejected L1 match:
|
|
915
|
+
// the only real signal this read produced is the failure, so
|
|
916
|
+
// surface CACHE_READ_ERROR instead of a replayable miss (the PPR
|
|
917
|
+
// seeded fallback must render uncached, not substitute the doc
|
|
918
|
+
// record for a partition the store could not actually read).
|
|
902
919
|
reportCacheError(
|
|
903
920
|
matchError,
|
|
904
921
|
"cache-read",
|
|
905
922
|
"[CFCacheStore] get L1 match",
|
|
906
923
|
);
|
|
924
|
+
if (this.debug)
|
|
925
|
+
this.emitDebug({
|
|
926
|
+
op: "get",
|
|
927
|
+
key,
|
|
928
|
+
// Distinct from a genuine absence: surface it as match-error so
|
|
929
|
+
// debug agrees with the cache-read already routed to onError,
|
|
930
|
+
// instead of masquerading as l1-miss.
|
|
931
|
+
outcome: "match-error",
|
|
932
|
+
matchMs,
|
|
933
|
+
});
|
|
934
|
+
const kvResult = await this.kvGetSegment(key);
|
|
935
|
+
return kvResult ?? CACHE_READ_ERROR;
|
|
936
|
+
}
|
|
937
|
+
// An abandoned slow match (timedOut) keeps the fail-open latency-budget
|
|
938
|
+
// policy: degrade to L2/KV, and a KV null stays a miss.
|
|
907
939
|
if (this.debug)
|
|
908
940
|
this.emitDebug({
|
|
909
941
|
op: "get",
|
|
910
942
|
key,
|
|
911
|
-
|
|
912
|
-
// surface it as match-error so debug agrees with the cache-read
|
|
913
|
-
// already routed to onError, instead of masquerading as l1-miss.
|
|
914
|
-
outcome: matchError
|
|
915
|
-
? "match-error"
|
|
916
|
-
: timedOut
|
|
917
|
-
? "match-timeout"
|
|
918
|
-
: "l1-miss",
|
|
943
|
+
outcome: timedOut ? "match-timeout" : "l1-miss",
|
|
919
944
|
matchMs,
|
|
920
945
|
});
|
|
921
946
|
return this.kvGetSegment(key);
|
|
@@ -1096,7 +1121,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1096
1121
|
// emit is the separate wrangler-tail signal. Keep both observability paths.
|
|
1097
1122
|
reportCacheError(error, "cache-read", "[CFCacheStore] get");
|
|
1098
1123
|
if (this.debug) this.emitDebug({ op: "get", key, outcome: "error" });
|
|
1099
|
-
|
|
1124
|
+
// Distinct from a miss so the PPR replay composition renders uncached
|
|
1125
|
+
// instead of substituting the seeded doc record (CACHE_READ_ERROR).
|
|
1126
|
+
return CACHE_READ_ERROR;
|
|
1100
1127
|
}
|
|
1101
1128
|
}
|
|
1102
1129
|
|
|
@@ -1817,8 +1844,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1817
1844
|
buildVersion: envelope.bv,
|
|
1818
1845
|
initialTheme: envelope.i,
|
|
1819
1846
|
snapshot: envelope.sn,
|
|
1847
|
+
docKey: envelope.dk,
|
|
1820
1848
|
handlerLiveHoles: envelope.lh,
|
|
1821
1849
|
transitionWhen: envelope.tw,
|
|
1850
|
+
navigationOnly: envelope.no,
|
|
1822
1851
|
createdAt: envelope.c,
|
|
1823
1852
|
},
|
|
1824
1853
|
shouldRevalidate,
|
|
@@ -1905,8 +1934,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1905
1934
|
ta: taggedAt,
|
|
1906
1935
|
i: entry.initialTheme,
|
|
1907
1936
|
sn: entry.snapshot,
|
|
1937
|
+
dk: entry.docKey,
|
|
1908
1938
|
lh: entry.handlerLiveHoles,
|
|
1909
1939
|
tw: entry.transitionWhen,
|
|
1940
|
+
no: entry.navigationOnly,
|
|
1910
1941
|
};
|
|
1911
1942
|
await this.kv!.put(kvKey, JSON.stringify(envelope), {
|
|
1912
1943
|
expirationTtl: retentionTtl,
|
|
@@ -2813,7 +2844,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2813
2844
|
private async kvGetSegment(
|
|
2814
2845
|
key: string,
|
|
2815
2846
|
opts?: { suppressRevalidate?: boolean },
|
|
2816
|
-
): Promise<CacheGetResult | null> {
|
|
2847
|
+
): Promise<CacheGetResult | null | CacheReadError> {
|
|
2817
2848
|
if (!this.kv) return null;
|
|
2818
2849
|
|
|
2819
2850
|
try {
|
|
@@ -2882,7 +2913,15 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2882
2913
|
} catch (error) {
|
|
2883
2914
|
reportCacheError(error, "cache-read", "[CFCacheStore] kvGetSegment");
|
|
2884
2915
|
if (this.debug) this.emitDebug({ op: "get", key, outcome: "error" });
|
|
2885
|
-
|
|
2916
|
+
// A KV failure is NOT proof of absence: returning null classified it a
|
|
2917
|
+
// real miss and let the PPR seeded fallback substitute the doc record
|
|
2918
|
+
// for a key partition the store could not actually read. Same sentinel
|
|
2919
|
+
// as get()'s own catch — lookupRouteDetailed classifies it `error` and
|
|
2920
|
+
// the render stays uncached. (A kvGetOrEvict TIMEOUT above stays null
|
|
2921
|
+
// by design: it is a bounded-latency degrade of a likely-healthy read,
|
|
2922
|
+
// and serving the equivalent seeded record there is the fallback
|
|
2923
|
+
// working as intended, not a masked failure.)
|
|
2924
|
+
return CACHE_READ_ERROR;
|
|
2886
2925
|
}
|
|
2887
2926
|
}
|
|
2888
2927
|
|
|
@@ -327,7 +327,13 @@ export function createDocumentCacheMiddleware<TEnv = any>(
|
|
|
327
327
|
// existing keys and double any host they already include).
|
|
328
328
|
const cacheKey = keyGenerator
|
|
329
329
|
? keyGenerator(url) + segmentHash + typeSuffix
|
|
330
|
-
: cacheKeyBase(
|
|
330
|
+
: cacheKeyBase(
|
|
331
|
+
url.host,
|
|
332
|
+
url.pathname,
|
|
333
|
+
url.searchParams,
|
|
334
|
+
undefined,
|
|
335
|
+
requestCtx?._searchParamsFilter,
|
|
336
|
+
) +
|
|
331
337
|
segmentHash +
|
|
332
338
|
typeSuffix;
|
|
333
339
|
// 1. Check cache
|
package/src/cache/index.ts
CHANGED
|
@@ -7,7 +7,9 @@ export type {
|
|
|
7
7
|
ShellCacheEntry,
|
|
8
8
|
SerializedSegmentData,
|
|
9
9
|
SegmentHandleData,
|
|
10
|
+
CacheReadError,
|
|
10
11
|
} from "./types.js";
|
|
12
|
+
export { CACHE_READ_ERROR } from "./types.js";
|
|
11
13
|
|
|
12
14
|
export { MemorySegmentCacheStore } from "./memory-segment-store.js";
|
|
13
15
|
|
|
@@ -39,6 +41,11 @@ export {
|
|
|
39
41
|
VERCEL_MAX_TAG_BYTES,
|
|
40
42
|
} from "./vercel/index.js";
|
|
41
43
|
|
|
44
|
+
export {
|
|
45
|
+
TRACKING_SEARCH_PARAMS,
|
|
46
|
+
type CacheSearchParams,
|
|
47
|
+
} from "./search-params-filter.js";
|
|
48
|
+
|
|
42
49
|
export { CacheScope, createCacheScope } from "./cache-scope.js";
|
|
43
50
|
|
|
44
51
|
export {
|