@rangojs/router 0.0.0-experimental.143 → 0.0.0-experimental.145
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/dist/vite/index.js +24 -6
- package/package.json +2 -2
- package/skills/cache-guide/SKILL.md +3 -1
- package/skills/caching/SKILL.md +23 -2
- package/skills/catalog.json +6 -0
- package/skills/defer-hydration/SKILL.md +235 -0
- package/skills/loader/SKILL.md +5 -0
- package/skills/migrate-nextjs/SKILL.md +4 -2
- package/skills/parallel/SKILL.md +2 -0
- package/skills/ppr/SKILL.md +63 -33
- package/skills/rango/SKILL.md +10 -0
- package/skills/use-cache/SKILL.md +12 -2
- package/src/browser/logging.ts +18 -0
- package/src/browser/partial-update.ts +7 -0
- package/src/browser/rsc-router.tsx +43 -0
- package/src/cache/cache-key-utils.ts +29 -0
- package/src/cache/cache-runtime.ts +41 -51
- package/src/cache/cache-scope.ts +2 -17
- package/src/cache/cache-tag.ts +60 -14
- package/src/cache/cf/cf-cache-store.ts +58 -20
- package/src/cache/document-cache.ts +17 -11
- package/src/cache/types.ts +18 -4
- package/src/cache/vercel/vercel-cache-store.ts +15 -20
- package/src/redirect-origin.ts +14 -0
- package/src/route-map-builder.ts +17 -3
- package/src/router/lazy-includes.ts +8 -2
- package/src/router/loader-resolution.ts +14 -2
- package/src/router/match-handlers.ts +11 -6
- package/src/router/middleware.ts +4 -1
- package/src/router/segment-resolution/loader-cache.ts +19 -3
- package/src/router/segment-resolution/loader-mask.ts +4 -11
- package/src/router/segment-resolution/loader-snapshot.ts +14 -6
- package/src/router/segment-resolution/mask-nested.ts +83 -0
- package/src/router/telemetry.ts +9 -1
- package/src/router.ts +7 -8
- package/src/rsc/handler.ts +9 -2
- package/src/rsc/redirect-guard.ts +2 -1
- package/src/rsc/rsc-rendering.ts +122 -18
- package/src/rsc/shell-capture.ts +125 -20
- package/src/rsc/shell-serve.ts +37 -6
- package/src/segment-loader-promise.ts +18 -0
- package/src/segment-system.tsx +90 -6
- package/src/server/context.ts +47 -9
- package/src/server/cookie-store.ts +26 -5
- package/src/server/request-context.ts +22 -0
- package/src/ssr/index.tsx +160 -113
- package/src/ssr/inject-rsc-eager.ts +167 -0
- package/src/testing/dispatch.ts +7 -0
- package/src/vite/index.ts +7 -0
- package/src/vite/inject-client-debug.ts +64 -12
- package/src/vite/router-discovery.ts +9 -1
|
@@ -153,6 +153,13 @@ const locale = cookies().get("locale")?.value ?? "en";
|
|
|
153
153
|
const data = await getCachedData(locale); // locale is now in the cache key
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
+
The guard does not reach into LOADER bodies consumed inside the cached
|
|
157
|
+
function (`await ctx.use(loader)`): loaders always run fresh, so their reads
|
|
158
|
+
are exempt — but the CONSUMED VALUE is captured into the shared cache entry
|
|
159
|
+
like any other computed data. Same rule across `cache()` and the PPR shell:
|
|
160
|
+
handler/cached-scope consumption = baked copy, client-side `useLoader` = live
|
|
161
|
+
(the consumption-lane rule, `/rango` → Invariants).
|
|
162
|
+
|
|
156
163
|
### Side-Effect Guards
|
|
157
164
|
|
|
158
165
|
These ctx methods **throw** inside a `"use cache"` function because their effects
|
|
@@ -346,8 +353,11 @@ export async function getProducts() {
|
|
|
346
353
|
Writes to the same `SegmentCacheStore` as `cache()` DSL, `Static()`, and `Prerender()`.
|
|
347
354
|
One store, one configuration.
|
|
348
355
|
|
|
349
|
-
Cache entries (and `cacheProfiles`) can be tagged via `cache({ tags })` or
|
|
350
|
-
a `"use cache"` function
|
|
356
|
+
Cache entries (and `cacheProfiles`) can be tagged via `cache({ tags })` or runtime
|
|
357
|
+
`cacheTag(...tags)`. `cacheTag` has two forms: inside a `"use cache"` function it
|
|
358
|
+
tags that entry; called during a request render outside `"use cache"` it tags the
|
|
359
|
+
request's document/shell artifact (rides `_requestTags`) instead of throwing. The
|
|
360
|
+
built-in
|
|
351
361
|
`MemorySegmentCacheStore` and `CFCacheStore` index by tag. Invalidate on demand
|
|
352
362
|
with `updateTag(...tags)` (awaitable, read-your-own-writes; for server actions) or
|
|
353
363
|
`revalidateTag(...tags)` (background, non-blocking; for route handlers/webhooks).
|
package/src/browser/logging.ts
CHANGED
|
@@ -63,3 +63,21 @@ export function debugLog(msg: string, ...args: unknown[]): void {
|
|
|
63
63
|
console.log(msg, ...args);
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Boot-sequence debug log: one line per initial-document step (flight decode,
|
|
69
|
+
* handle stream, bridge wiring, initial tree build, hydration commit), each
|
|
70
|
+
* stamped with performance.now() so the gap BEFORE hydrateRoot is visible.
|
|
71
|
+
* The initial document path was otherwise silent — FE debug only started
|
|
72
|
+
* talking at the first soft navigation, so a boot stall (e.g. an await that
|
|
73
|
+
* holds initBrowserApp, and with it hydrateRoot) was invisible.
|
|
74
|
+
*/
|
|
75
|
+
export function bootLog(step: string, details?: Record<string, unknown>): void {
|
|
76
|
+
if (!INTERNAL_RANGO_DEBUG) return;
|
|
77
|
+
const prefix = `[Browser][boot] ${step} @ ${Math.round(performance.now())}ms`;
|
|
78
|
+
if (details) {
|
|
79
|
+
console.log(prefix, details);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
console.log(prefix);
|
|
83
|
+
}
|
|
@@ -395,6 +395,13 @@ export function createPartialUpdater(
|
|
|
395
395
|
return;
|
|
396
396
|
}
|
|
397
397
|
if (mode.type === "action") {
|
|
398
|
+
// An action refetch that lands on missing segments (navigated away /
|
|
399
|
+
// consolidation / HMR) drops rather than refetch-all: the action flow
|
|
400
|
+
// is storeOnly / skipLoadingState, so a full refetch here would fight
|
|
401
|
+
// it. Keep the stale-but-consistent tree; log so the drop is visible.
|
|
402
|
+
debugLog(
|
|
403
|
+
`[Browser] Action refetch: ${missingCount} segments missing; dropping (stale-but-consistent tree kept).`,
|
|
404
|
+
);
|
|
398
405
|
return;
|
|
399
406
|
}
|
|
400
407
|
console.warn(
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
splitInterceptSegments,
|
|
34
34
|
} from "./intercept-utils.js";
|
|
35
35
|
import { createAppShellRef } from "./app-shell.js";
|
|
36
|
+
import { bootLog, IS_BROWSER_DEBUG } from "./logging.js";
|
|
36
37
|
|
|
37
38
|
// Vite HMR types are provided by vite/client
|
|
38
39
|
|
|
@@ -156,6 +157,8 @@ export async function initBrowserApp(
|
|
|
156
157
|
initialTheme,
|
|
157
158
|
} = options;
|
|
158
159
|
|
|
160
|
+
bootLog("initBrowserApp start");
|
|
161
|
+
bootLog("flight decode: awaiting initial payload from document stream");
|
|
159
162
|
const initialPayload =
|
|
160
163
|
await deps.createFromReadableStream<RscPayload>(rscStream);
|
|
161
164
|
|
|
@@ -169,6 +172,14 @@ export async function initBrowserApp(
|
|
|
169
172
|
// Get initial segments and compute history key from current URL
|
|
170
173
|
const initialSegments = (initialPayload.metadata?.segments ??
|
|
171
174
|
[]) as ResolvedSegment[];
|
|
175
|
+
if (IS_BROWSER_DEBUG) {
|
|
176
|
+
bootLog("initial payload decoded", {
|
|
177
|
+
version: initialPayload.metadata?.version,
|
|
178
|
+
routerId: initialPayload.metadata?.routerId,
|
|
179
|
+
segments: initialSegments.map((s) => s.id),
|
|
180
|
+
matched: initialPayload.metadata?.matched,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
172
183
|
const initialHistoryKey = generateHistoryKey(window.location.href);
|
|
173
184
|
|
|
174
185
|
// Create navigation store with history-based caching
|
|
@@ -207,11 +218,24 @@ export async function initBrowserApp(
|
|
|
207
218
|
// This ensures useHandle returns correct data during hydration to avoid mismatch
|
|
208
219
|
// The handles property is an async generator that yields on each push
|
|
209
220
|
if (initialPayload.metadata?.handles) {
|
|
221
|
+
// This for-await consumes the handle generator to completion BEFORE
|
|
222
|
+
// hydrateRoot is called — on a streaming/PPR document the generator only
|
|
223
|
+
// ends when its stream side does, so the per-push logs below are the
|
|
224
|
+
// primary probe for "the document render is holding hydration".
|
|
225
|
+
bootLog("handles: consuming payload handle stream (pre-hydration await)");
|
|
210
226
|
const handlesGenerator = initialPayload.metadata.handles;
|
|
211
227
|
let lastHandleData: Record<string, Record<string, unknown[]>> = {};
|
|
228
|
+
let handlePushes = 0;
|
|
212
229
|
for await (const handleData of handlesGenerator) {
|
|
213
230
|
lastHandleData = handleData;
|
|
231
|
+
if (IS_BROWSER_DEBUG) {
|
|
232
|
+
handlePushes += 1;
|
|
233
|
+
bootLog(`handles: push #${handlePushes}`, {
|
|
234
|
+
segments: Object.keys(handleData),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
214
237
|
}
|
|
238
|
+
bootLog("handles: stream complete", { pushes: handlePushes });
|
|
215
239
|
// Initialize event controller with initial handle state before hydration.
|
|
216
240
|
eventController.setHandleData(
|
|
217
241
|
lastHandleData,
|
|
@@ -221,6 +245,8 @@ export async function initBrowserApp(
|
|
|
221
245
|
// Update the initial cache entry with the processed handleData
|
|
222
246
|
// The cache entry was created by createNavigationStore but without handleData
|
|
223
247
|
store.updateCacheHandleData(initialHistoryKey, lastHandleData);
|
|
248
|
+
} else {
|
|
249
|
+
bootLog("handles: none in payload");
|
|
224
250
|
}
|
|
225
251
|
|
|
226
252
|
// Create composable utilities
|
|
@@ -321,9 +347,17 @@ export async function initBrowserApp(
|
|
|
321
347
|
if (linkInterception) {
|
|
322
348
|
navigationBridge.registerLinkInterception();
|
|
323
349
|
}
|
|
350
|
+
bootLog("bridges registered (action + navigation)");
|
|
324
351
|
|
|
325
352
|
// Build initial tree with rootLayout
|
|
353
|
+
bootLog("building initial segment tree (renderSegments)");
|
|
326
354
|
const initialTree = renderSegments(initialPayload.metadata!.segments);
|
|
355
|
+
if (IS_BROWSER_DEBUG && initialTree instanceof Promise) {
|
|
356
|
+
initialTree.then(
|
|
357
|
+
() => bootLog("initial segment tree settled"),
|
|
358
|
+
(err: unknown) => bootLog("initial segment tree rejected", { err }),
|
|
359
|
+
);
|
|
360
|
+
}
|
|
327
361
|
|
|
328
362
|
// Setup HMR with debounce — burst saves (format-on-save, rapid edits)
|
|
329
363
|
// fire many rsc:update events in quick succession. Without debouncing,
|
|
@@ -491,9 +525,14 @@ export async function initBrowserApp(
|
|
|
491
525
|
};
|
|
492
526
|
browserAppContext = context;
|
|
493
527
|
|
|
528
|
+
bootLog("initBrowserApp complete -- handing off to hydrateRoot");
|
|
494
529
|
return context;
|
|
495
530
|
}
|
|
496
531
|
|
|
532
|
+
// Once-flag so the hydration-commit boot log fires a single time (StrictMode
|
|
533
|
+
// re-runs the root effect; the second flush is not a second hydration).
|
|
534
|
+
let hydrationCommitLogged = false;
|
|
535
|
+
|
|
497
536
|
/**
|
|
498
537
|
* Get the browser app context. Throws if initBrowserApp hasn't been called.
|
|
499
538
|
*/
|
|
@@ -561,6 +600,10 @@ export function Rango(_props: RangoProps): React.ReactElement {
|
|
|
561
600
|
// that does not depend on React internals like __reactFiber.
|
|
562
601
|
React.useEffect(() => {
|
|
563
602
|
document.documentElement.dataset.hydrated = "";
|
|
603
|
+
if (IS_BROWSER_DEBUG && !hydrationCommitLogged) {
|
|
604
|
+
hydrationCommitLogged = true;
|
|
605
|
+
bootLog("hydration commit (root effect flushed)");
|
|
606
|
+
}
|
|
564
607
|
}, []);
|
|
565
608
|
|
|
566
609
|
return (
|
|
@@ -58,3 +58,32 @@ export function sortedRouteParams(
|
|
|
58
58
|
if (!params) return "";
|
|
59
59
|
return encodeKV(Object.entries(params), { sort: true });
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Host-namespaced cache key base: `${host}${pathname}[:params][?search]`.
|
|
64
|
+
*
|
|
65
|
+
* The ONE composition of the host-namespacing rule, shared by the segment tier
|
|
66
|
+
* (cache-scope.ts) and the document tier (document-cache.ts) so the rule cannot
|
|
67
|
+
* drift between them. Host prefixing matters because VercelCacheStore /
|
|
68
|
+
* MemorySegmentCacheStore key by the raw string (only CFCacheStore adds host
|
|
69
|
+
* internally) -- on a single function serving multiple domains an
|
|
70
|
+
* un-namespaced key bleeds tenant A's cached response to tenant B.
|
|
71
|
+
*
|
|
72
|
+
* Output is BYTE-STABLE by contract: changing the composition silently
|
|
73
|
+
* invalidates every persisted cache entry on upgrade. Callers append their own
|
|
74
|
+
* tier-specific suffixes (`:rsc`/`:html`, segment hash) after this base.
|
|
75
|
+
*/
|
|
76
|
+
export function cacheKeyBase(
|
|
77
|
+
host: string,
|
|
78
|
+
pathname: string,
|
|
79
|
+
searchParams?: URLSearchParams,
|
|
80
|
+
params?: Record<string, string>,
|
|
81
|
+
): string {
|
|
82
|
+
const paramStr = sortedRouteParams(params);
|
|
83
|
+
const searchStr = searchParams ? sortedSearchString(searchParams) : "";
|
|
84
|
+
|
|
85
|
+
let key = `${host}${pathname}`;
|
|
86
|
+
if (paramStr) key += `:${paramStr}`;
|
|
87
|
+
if (searchStr) key += `?${searchStr}`;
|
|
88
|
+
return key;
|
|
89
|
+
}
|
|
@@ -412,26 +412,26 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
412
412
|
try {
|
|
413
413
|
const result = await serveCached(cached);
|
|
414
414
|
// Background revalidation — must capture handles if tainted args present.
|
|
415
|
-
// Use an isolated handle store so background pushes don't pollute the
|
|
416
|
-
// live response or throw LateHandlePushError on the completed store.
|
|
417
|
-
// Same isolation pattern as route-level background-revalidation.ts.
|
|
418
415
|
runBackground(requestCtx, async () => {
|
|
419
|
-
// The
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
416
|
+
// The background body runs under a DERIVED context with an OWN
|
|
417
|
+
// _handleStore (the shell-capture isolation pattern —
|
|
418
|
+
// shell-capture.ts attemptCapture): its handle pushes land in the
|
|
419
|
+
// isolated store (captured below, persisted with the entry) while
|
|
420
|
+
// the foreground keeps pushing into the ORIGINAL store, untouched.
|
|
421
|
+
// Derivation matters because the foreground is STILL RENDERING here
|
|
422
|
+
// — runBackground/waitUntil starts the task on the next microtask,
|
|
423
|
+
// not after the response. The previous shape swapped
|
|
424
|
+
// requestCtx._handleStore in place (restore in finally), which
|
|
425
|
+
// routed the whole overlap window's foreground pushes into the
|
|
426
|
+
// background store: lost from the live document AND persisted into
|
|
427
|
+
// the revalidated entry (issue #684, plan 010).
|
|
428
|
+
const bgHandleStore =
|
|
429
|
+
hasTaintedArgs && requestCtx ? createHandleStore() : undefined;
|
|
430
|
+
const bgCtx: typeof requestCtx = bgHandleStore
|
|
431
|
+
? Object.assign(Object.create(requestCtx), {
|
|
432
|
+
_handleStore: bgHandleStore,
|
|
433
|
+
})
|
|
434
|
+
: requestCtx;
|
|
435
435
|
let bgCapture: HandleCapture | undefined;
|
|
436
436
|
let bgStopCapture: (() => void) | undefined;
|
|
437
437
|
if (bgHandleStore) {
|
|
@@ -440,28 +440,15 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
440
440
|
bgStopCapture = c.stop;
|
|
441
441
|
}
|
|
442
442
|
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
// later calls requestCtx.onResponse().
|
|
453
|
-
// 2. requestCtx methods are closure-bound to the original ctx, so
|
|
454
|
-
// neither Object.create() nor a proxy can isolate the stamp.
|
|
455
|
-
// 3. The foreground miss path already stamps requestCtx and catches
|
|
456
|
-
// cookies()/headers() misuse on first execution. The background
|
|
457
|
-
// re-runs the same function with the same request.
|
|
458
|
-
const bgTaintedArgs: unknown[] = [];
|
|
459
|
-
for (const arg of args) {
|
|
460
|
-
if (isTainted(arg)) {
|
|
461
|
-
stampCacheExec(arg as object);
|
|
462
|
-
bgTaintedArgs.push(arg);
|
|
463
|
-
}
|
|
464
|
-
}
|
|
443
|
+
// Tainted args are NOT stamped here, in contrast to the foreground
|
|
444
|
+
// miss path below. The args include the live HandlerContext the
|
|
445
|
+
// still-rendering foreground holds, and INSIDE_CACHE_EXEC is a
|
|
446
|
+
// property stamped onto that SHARED object — so for the whole
|
|
447
|
+
// revalidation window a concurrent foreground ctx.set() /
|
|
448
|
+
// ctx.headers.*() would throw (issue #684, plan 010). requestCtx is
|
|
449
|
+
// not stamped for the same reason. In-fn misuse is already caught
|
|
450
|
+
// by the miss path's stamps on the function's FIRST execution — the
|
|
451
|
+
// background re-runs the same function with the same request.
|
|
465
452
|
|
|
466
453
|
try {
|
|
467
454
|
// Re-establish the request-context ALS so a "use cache" body that
|
|
@@ -469,8 +456,10 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
469
456
|
// getRequestContext().env.ApiKey) resolves during the background
|
|
470
457
|
// revalidation instead of throwing "called outside of a request
|
|
471
458
|
// context". runWithRequestContext sets the store for fn's
|
|
472
|
-
// synchronous kickoff; its async continuations inherit it.
|
|
473
|
-
|
|
459
|
+
// synchronous kickoff; its async continuations inherit it. The
|
|
460
|
+
// DERIVED context goes in, so ambient _handleStore reads inside
|
|
461
|
+
// the body resolve to the isolated store.
|
|
462
|
+
const scoped = runWithRequestContext(bgCtx, () =>
|
|
474
463
|
runWithCacheTagScope(() => fn.apply(this, args)),
|
|
475
464
|
);
|
|
476
465
|
const freshResult = await scoped.result;
|
|
@@ -507,15 +496,9 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
507
496
|
"[use cache] background revalidation failed",
|
|
508
497
|
requestCtx,
|
|
509
498
|
);
|
|
510
|
-
} finally {
|
|
511
|
-
for (const arg of bgTaintedArgs) {
|
|
512
|
-
unstampCacheExec(arg as object);
|
|
513
|
-
}
|
|
514
|
-
// Restore original handle store
|
|
515
|
-
if (originalHandleStore && requestCtx) {
|
|
516
|
-
requestCtx._handleStore = originalHandleStore;
|
|
517
|
-
}
|
|
518
499
|
}
|
|
500
|
+
// No finally: nothing shared was mutated — the derived context and
|
|
501
|
+
// its handle store are garbage after the task settles.
|
|
519
502
|
});
|
|
520
503
|
return result;
|
|
521
504
|
} catch (error) {
|
|
@@ -601,6 +584,13 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
601
584
|
// inside the cached function body (those side effects are lost on hit).
|
|
602
585
|
// Uses ref-counted stamp/unstamp so overlapping executions
|
|
603
586
|
// sharing the same ctx don't clear each other's guards.
|
|
587
|
+
//
|
|
588
|
+
// LOAD-BEARING for the stale-revalidation path above: the background
|
|
589
|
+
// re-execution deliberately does NOT re-stamp (the objects are live
|
|
590
|
+
// foreground state mid-render), relying on THIS stamp having caught in-fn
|
|
591
|
+
// misuse on the function's first execution — an entry only becomes
|
|
592
|
+
// stale-revalidatable because a stamped miss ran clean and stored it. Do
|
|
593
|
+
// not create a "use cache" entry via any path that skips this stamp.
|
|
604
594
|
const taintedArgs: unknown[] = [];
|
|
605
595
|
for (const arg of args) {
|
|
606
596
|
if (isTainted(arg)) {
|
package/src/cache/cache-scope.ts
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
encodeHandles,
|
|
32
32
|
decodeHandles,
|
|
33
33
|
} from "./handle-snapshot.js";
|
|
34
|
-
import {
|
|
34
|
+
import { cacheKeyBase } from "./cache-key-utils.js";
|
|
35
35
|
import {
|
|
36
36
|
DEFAULT_ROUTE_TTL,
|
|
37
37
|
isFiniteNonNegativeSeconds,
|
|
@@ -85,21 +85,6 @@ function validatedSwr(value: number | undefined): number | undefined {
|
|
|
85
85
|
return isValidCacheSeconds(value, "swr") ? value : undefined;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
function getCacheKeyBase(
|
|
89
|
-
host: string,
|
|
90
|
-
pathname: string,
|
|
91
|
-
params?: Record<string, string>,
|
|
92
|
-
searchParams?: URLSearchParams,
|
|
93
|
-
): string {
|
|
94
|
-
const paramStr = sortedRouteParams(params);
|
|
95
|
-
const searchStr = searchParams ? sortedSearchString(searchParams) : "";
|
|
96
|
-
|
|
97
|
-
let key = `${host}${pathname}`;
|
|
98
|
-
if (paramStr) key += `:${paramStr}`;
|
|
99
|
-
if (searchStr) key += `?${searchStr}`;
|
|
100
|
-
return key;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
88
|
function getDefaultRouteCacheKey(
|
|
104
89
|
pathname: string,
|
|
105
90
|
params?: Record<string, string>,
|
|
@@ -113,7 +98,7 @@ function getDefaultRouteCacheKey(
|
|
|
113
98
|
// Intercept navigations get their own cache namespace
|
|
114
99
|
const prefix = isIntercept ? "intercept" : isPartial ? "partial" : "doc";
|
|
115
100
|
|
|
116
|
-
return `${prefix}:${
|
|
101
|
+
return `${prefix}:${cacheKeyBase(host, pathname, searchParams, params)}`;
|
|
117
102
|
}
|
|
118
103
|
|
|
119
104
|
// ============================================================================
|
package/src/cache/cache-tag.ts
CHANGED
|
@@ -38,36 +38,82 @@ export function normalizeTags(tags: Iterable<string>): string[] {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Tag
|
|
42
|
-
* updateTag() / revalidateTag().
|
|
41
|
+
* Tag content for later invalidation via updateTag() / revalidateTag().
|
|
43
42
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
43
|
+
* cacheTag() serves two forms depending on what is active when it runs:
|
|
44
|
+
*
|
|
45
|
+
* 1. Inside a "use cache" function — the DEFAULT. The tags go to the current
|
|
46
|
+
* cache entry; `revalidateTag(tag)` drops that entry. Tags are additive
|
|
47
|
+
* (multiple calls accumulate), and normalizeTag() is the single chokepoint so
|
|
48
|
+
* a padded write matches an unpadded invalidate.
|
|
49
|
+
*
|
|
50
|
+
* 2. Render-callable (#648) — no "use cache" scope active, but a request context
|
|
51
|
+
* is present. The tags record onto the request's DOCUMENT artifact
|
|
52
|
+
* (ctx._requestTags) instead of throwing. The collection layers already exist:
|
|
53
|
+
* PPR shell capture unions _requestTags into the shell entry, the document
|
|
54
|
+
* cache tags the full-page entry with it, and prerender build contexts seed
|
|
55
|
+
* their own set — so a server component that renders into a shell makes
|
|
56
|
+
* `revalidateTag("campaign:spring")` evict that shell with ZERO cache()/"use
|
|
57
|
+
* cache" in its tree. This is PPR's DERIVATIVE invalidation: PPR is
|
|
58
|
+
* execution-PRESERVING (everything still runs underneath; only document bytes
|
|
59
|
+
* are shortcut), so its tags ride this existing instrument rather than a
|
|
60
|
+
* first-class ppr key/tag API. The shell-expiry invariant holds by
|
|
61
|
+
* construction — baked ⇒ evicts (bake-lane loaders execute during capture and
|
|
62
|
+
* record here), hole ⇒ fresh (masked loaders behind a renderable loading()
|
|
63
|
+
* never execute during capture, so nothing under a hole can tag the shell).
|
|
64
|
+
*
|
|
65
|
+
* Inside a cache() DSL segment the render-callable form records at the DOCUMENT
|
|
66
|
+
* level, not the segment (only the "use cache" runtime enters the tag scope) — a
|
|
67
|
+
* documented semantic, not a filtered one. An empty/whitespace-only tag is
|
|
68
|
+
* dropped in both forms (the render-callable form silently, via normalizeTags in
|
|
69
|
+
* recordRequestTags; the scope form with a dev warning).
|
|
70
|
+
*
|
|
71
|
+
* With neither a scope nor a request context, cacheTag() throws.
|
|
46
72
|
*
|
|
47
73
|
* @example
|
|
48
74
|
* ```typescript
|
|
75
|
+
* // Form 1 — inside "use cache":
|
|
49
76
|
* async function getProduct(ctx) {
|
|
50
77
|
* "use cache";
|
|
51
78
|
* cacheTag(`product:${ctx.params.id}`, "products");
|
|
52
79
|
* return db.getProduct(ctx.params.id);
|
|
53
80
|
* }
|
|
81
|
+
*
|
|
82
|
+
* // Form 2 — render-callable, tags the shell/document from a server component:
|
|
83
|
+
* function CampaignBanner() {
|
|
84
|
+
* cacheTag("campaign:spring");
|
|
85
|
+
* return <aside>Spring sale</aside>;
|
|
86
|
+
* }
|
|
54
87
|
* ```
|
|
55
88
|
*/
|
|
56
89
|
export function cacheTag(...tags: string[]): void {
|
|
57
90
|
const store = cacheTagStorage.getStore();
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
91
|
+
if (store) {
|
|
92
|
+
// Form 1: "use cache" scope wins — tag the cache entry (unchanged).
|
|
93
|
+
for (const tag of tags) {
|
|
94
|
+
const normalized = normalizeTag(tag);
|
|
95
|
+
if (normalized === null) {
|
|
96
|
+
if (process.env.NODE_ENV !== "production") {
|
|
97
|
+
console.warn(`[cacheTag] Ignoring empty or whitespace-only tag.`);
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
66
100
|
}
|
|
67
|
-
|
|
101
|
+
store.add(normalized);
|
|
68
102
|
}
|
|
69
|
-
|
|
103
|
+
return;
|
|
70
104
|
}
|
|
105
|
+
|
|
106
|
+
const reqCtx = _getRequestContext();
|
|
107
|
+
if (reqCtx?._requestTags) {
|
|
108
|
+
// Form 2: render-callable — tag the request's document artifact. See the
|
|
109
|
+
// JSDoc above for the composition doctrine and the baked/hole invariant.
|
|
110
|
+
recordRequestTags(tags, reqCtx);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
throw new Error(
|
|
115
|
+
'cacheTag() must be called inside a "use cache" function or during a request render.',
|
|
116
|
+
);
|
|
71
117
|
}
|
|
72
118
|
|
|
73
119
|
export function recordRequestTags(
|
|
@@ -137,6 +137,16 @@ const warnedNoKvReadInvalidation = new Set<string>();
|
|
|
137
137
|
*/
|
|
138
138
|
const warnedTagInvalidationTtlFloor = new Set<string>();
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Stores (by namespace) already warned that tag invalidation is writing KV
|
|
142
|
+
* markers with no expiry (tagInvalidationTtl unset), so the unbounded-growth
|
|
143
|
+
* warning fires once per process rather than once per invalidateTags call
|
|
144
|
+
* (CFCacheStore is constructed per request; invalidateTags runs per marker
|
|
145
|
+
* batch). Distinct from the floor warning: that one only fires for a positive
|
|
146
|
+
* below-floor value, never for the unset (no-expiry) default that this bounds.
|
|
147
|
+
*/
|
|
148
|
+
const warnedNoTagInvalidationTtl = new Set<string>();
|
|
149
|
+
|
|
140
150
|
// ============================================================================
|
|
141
151
|
// Types
|
|
142
152
|
// ============================================================================
|
|
@@ -201,6 +211,8 @@ interface KVShellEnvelope {
|
|
|
201
211
|
po: string | null;
|
|
202
212
|
/** React.version captured at prerender time */
|
|
203
213
|
rv: string;
|
|
214
|
+
/** Build version captured at prerender time (ShellCacheEntry.buildVersion) */
|
|
215
|
+
bv?: string;
|
|
204
216
|
/** createdAt (ms epoch) */
|
|
205
217
|
c: number;
|
|
206
218
|
/** When entry becomes stale (ms epoch) */
|
|
@@ -340,19 +352,30 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
340
352
|
// kv - yet every tagged read still serves stale data with no other signal.
|
|
341
353
|
// Surface that misconfiguration.
|
|
342
354
|
if (!this.kv && (this.tagCacheTtl > 0 || this.onRevalidateTag)) {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
`
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
`{ kv } for distributed tag invalidation.`,
|
|
351
|
-
);
|
|
352
|
-
}
|
|
355
|
+
this.warnOncePerNamespace(
|
|
356
|
+
warnedNoKvReadInvalidation,
|
|
357
|
+
`[CFCacheStore] tagCacheTtl/onRevalidateTag is configured without a KV ` +
|
|
358
|
+
`namespace, so tag invalidation has NO read-side effect: tagged reads ` +
|
|
359
|
+
`are never treated as invalidated and serve stale data. Configure ` +
|
|
360
|
+
`{ kv } for distributed tag invalidation.`,
|
|
361
|
+
);
|
|
353
362
|
}
|
|
354
363
|
}
|
|
355
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Warn about a namespace-scoped misconfiguration once per namespace per
|
|
367
|
+
* isolate. `seen` is the module-level Set for that message family -- Sets
|
|
368
|
+
* are module-level (not instance fields) so re-constructed stores in the
|
|
369
|
+
* same isolate don't re-warn.
|
|
370
|
+
* @internal
|
|
371
|
+
*/
|
|
372
|
+
private warnOncePerNamespace(seen: Set<string>, message: string): void {
|
|
373
|
+
const id = this.namespace ?? "default";
|
|
374
|
+
if (seen.has(id)) return;
|
|
375
|
+
seen.add(id);
|
|
376
|
+
console.warn(message);
|
|
377
|
+
}
|
|
378
|
+
|
|
356
379
|
/**
|
|
357
380
|
* Validate a consumer-supplied tagInvalidationTtl against CF KV's expirationTtl
|
|
358
381
|
* floor. A finite value below KV_MIN_EXPIRATION_TTL is raised to it (with a
|
|
@@ -368,16 +391,13 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
368
391
|
if (value == null) return undefined;
|
|
369
392
|
if (!Number.isFinite(value) || value <= 0) return undefined;
|
|
370
393
|
if (value < KV_MIN_EXPIRATION_TTL) {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
`TTL+SWR or invalidated entries can resurrect when the marker expires.`,
|
|
379
|
-
);
|
|
380
|
-
}
|
|
394
|
+
this.warnOncePerNamespace(
|
|
395
|
+
warnedTagInvalidationTtlFloor,
|
|
396
|
+
`[CFCacheStore] tagInvalidationTtl ${value} is below Cloudflare KV's ` +
|
|
397
|
+
`${KV_MIN_EXPIRATION_TTL}s expirationTtl floor; raising to ` +
|
|
398
|
+
`${KV_MIN_EXPIRATION_TTL}. It must still exceed your largest entry ` +
|
|
399
|
+
`TTL+SWR or invalidated entries can resurrect when the marker expires.`,
|
|
400
|
+
);
|
|
381
401
|
return KV_MIN_EXPIRATION_TTL;
|
|
382
402
|
}
|
|
383
403
|
return value;
|
|
@@ -1655,6 +1675,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1655
1675
|
prelude: envelope.p,
|
|
1656
1676
|
postponed: envelope.po,
|
|
1657
1677
|
reactVersion: envelope.rv,
|
|
1678
|
+
buildVersion: envelope.bv,
|
|
1658
1679
|
initialTheme: envelope.i,
|
|
1659
1680
|
snapshot: envelope.sn,
|
|
1660
1681
|
createdAt: envelope.c,
|
|
@@ -1716,6 +1737,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1716
1737
|
p: entry.prelude,
|
|
1717
1738
|
po: entry.postponed,
|
|
1718
1739
|
rv: entry.reactVersion,
|
|
1740
|
+
bv: entry.buildVersion,
|
|
1719
1741
|
c: entry.createdAt,
|
|
1720
1742
|
s: staleAt,
|
|
1721
1743
|
e: staleAt + swrWindow * 1000,
|
|
@@ -2254,6 +2276,22 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2254
2276
|
const failedTags = new Set<string>();
|
|
2255
2277
|
const errors: unknown[] = [];
|
|
2256
2278
|
if (this.kv) {
|
|
2279
|
+
// Markers written with no expiry (tagInvalidationTtl unset) never expire,
|
|
2280
|
+
// so high-cardinality tags accumulate KV keys unboundedly with no reaper.
|
|
2281
|
+
// Warn once per namespace at the batch entry point (not per marker write,
|
|
2282
|
+
// which would fire once per tag). Kept separate from the floor warning:
|
|
2283
|
+
// that path only fires for a positive below-floor value, never the unset
|
|
2284
|
+
// default sanitizeTagInvalidationTtl passes through as undefined.
|
|
2285
|
+
if (!this.tagInvalidationTtl) {
|
|
2286
|
+
this.warnOncePerNamespace(
|
|
2287
|
+
warnedNoTagInvalidationTtl,
|
|
2288
|
+
`[CFCacheStore] invalidateTags is writing KV markers with no expiry ` +
|
|
2289
|
+
`(tagInvalidationTtl is unset): high-cardinality tags accumulate KV ` +
|
|
2290
|
+
`keys unboundedly (storage + list-scan cost) with no reaper. Set ` +
|
|
2291
|
+
`tagInvalidationTtl above your largest entry TTL+SWR to bound marker ` +
|
|
2292
|
+
`growth; setting it too small resurrects invalidated entries.`,
|
|
2293
|
+
);
|
|
2294
|
+
}
|
|
2257
2295
|
await Promise.all(
|
|
2258
2296
|
tags.map(async (tag) => {
|
|
2259
2297
|
const markerKey = this.tagMarkerKey(tag);
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type RequestContext,
|
|
20
20
|
} from "../server/request-context.js";
|
|
21
21
|
import { mayNeedSSR } from "../rsc/ssr-setup.js";
|
|
22
|
-
import {
|
|
22
|
+
import { cacheKeyBase } from "./cache-key-utils.js";
|
|
23
23
|
import { runBackground } from "./background-task.js";
|
|
24
24
|
import { reportCacheError } from "./cache-error.js";
|
|
25
25
|
|
|
@@ -188,7 +188,13 @@ export interface DocumentCacheOptions<TEnv = any> {
|
|
|
188
188
|
skipPaths?: string[];
|
|
189
189
|
|
|
190
190
|
/**
|
|
191
|
-
* Custom cache key generator
|
|
191
|
+
* Custom cache key generator.
|
|
192
|
+
*
|
|
193
|
+
* Replaces the default `host + pathname + search` key entirely. On a
|
|
194
|
+
* multi-domain deployment served by one function you MUST include `url.host`
|
|
195
|
+
* (or an equivalent tenant discriminator) yourself — the default key is
|
|
196
|
+
* host-namespaced, but a custom generator's output is used verbatim, so
|
|
197
|
+
* omitting host bleeds one hostname's cached response to another.
|
|
192
198
|
*/
|
|
193
199
|
keyGenerator?: (url: URL) => string;
|
|
194
200
|
|
|
@@ -311,17 +317,17 @@ export function createDocumentCacheMiddleware<TEnv = any>(
|
|
|
311
317
|
isPartial && clientSegments ? `:${hashSegmentIds(clientSegments)}` : "";
|
|
312
318
|
const typeSuffix = isRscRequest ? ":rsc" : ":html";
|
|
313
319
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
|
|
320
|
+
// Default key rides the shared host-namespaced base (cacheKeyBase) so the
|
|
321
|
+
// segment tier (cache-scope.ts) and this document tier cannot drift on the
|
|
322
|
+
// host-namespacing rule -- see the contract on cacheKeyBase.
|
|
323
|
+
// The keyGenerator branch is left untouched: a consumer-supplied generator
|
|
324
|
+
// owns its own namespacing (auto-prefixing host would silently change their
|
|
325
|
+
// existing keys and double any host they already include).
|
|
322
326
|
const cacheKey = keyGenerator
|
|
323
327
|
? keyGenerator(url) + segmentHash + typeSuffix
|
|
324
|
-
:
|
|
328
|
+
: cacheKeyBase(url.host, url.pathname, url.searchParams) +
|
|
329
|
+
segmentHash +
|
|
330
|
+
typeSuffix;
|
|
325
331
|
// 1. Check cache
|
|
326
332
|
const cached = await store.getResponse(cacheKey);
|
|
327
333
|
|