@rangojs/router 0.0.0-experimental.121 → 0.0.0-experimental.124
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/bin/rango.js +7 -2
- package/dist/vite/index.js +47 -6
- package/package.json +61 -21
- package/skills/cache-guide/SKILL.md +8 -6
- package/skills/caching/SKILL.md +148 -1
- package/skills/hooks/SKILL.md +38 -27
- package/skills/host-router/SKILL.md +16 -2
- package/skills/intercept/SKILL.md +4 -2
- package/skills/layout/SKILL.md +11 -6
- package/skills/loader/SKILL.md +6 -2
- package/skills/middleware/SKILL.md +4 -2
- package/skills/migrate-nextjs/SKILL.md +38 -16
- package/skills/parallel/SKILL.md +9 -4
- package/skills/rango/SKILL.md +27 -15
- package/skills/route/SKILL.md +4 -2
- package/skills/testing/SKILL.md +129 -0
- package/skills/testing/bindings.md +89 -0
- package/skills/testing/cache-prerender.md +98 -0
- package/skills/testing/client-components.md +122 -0
- package/skills/testing/e2e-parity.md +125 -0
- package/skills/testing/flight.md +89 -0
- package/skills/testing/handles.md +129 -0
- package/skills/testing/loader.md +128 -0
- package/skills/testing/middleware.md +99 -0
- package/skills/testing/render-handler.md +118 -0
- package/skills/testing/response-routes.md +95 -0
- package/skills/testing/reverse-and-types.md +84 -0
- package/skills/testing/server-actions.md +107 -0
- package/skills/testing/server-tree.md +128 -0
- package/skills/testing/setup.md +120 -0
- package/skills/use-cache/SKILL.md +9 -7
- package/src/browser/action-fence.ts +37 -0
- package/src/browser/cookie-name.ts +140 -0
- package/src/browser/invalidate-client-cache.ts +52 -0
- package/src/browser/navigation-bridge.ts +14 -1
- package/src/browser/navigation-client.ts +14 -1
- package/src/browser/navigation-store-handle.ts +39 -0
- package/src/browser/navigation-store.ts +26 -12
- package/src/browser/prefetch/fetch.ts +7 -0
- package/src/browser/rango-state.ts +176 -97
- package/src/browser/react/index.ts +0 -6
- package/src/browser/rsc-router.tsx +12 -4
- package/src/browser/server-action-bridge.ts +77 -15
- package/src/browser/types.ts +7 -1
- package/src/cache/cache-error.ts +104 -0
- package/src/cache/cache-policy.ts +95 -1
- package/src/cache/cache-runtime.ts +79 -13
- package/src/cache/cache-scope.ts +55 -4
- package/src/cache/cache-tag.ts +135 -0
- package/src/cache/cf/cf-cache-store.ts +2080 -224
- package/src/cache/cf/index.ts +15 -1
- package/src/cache/document-cache.ts +74 -7
- package/src/cache/index.ts +17 -0
- package/src/cache/memory-segment-store.ts +164 -14
- package/src/cache/tag-invalidation.ts +230 -0
- package/src/cache/types.ts +27 -0
- package/src/client.rsc.tsx +1 -1
- package/src/client.tsx +0 -6
- package/src/component-utils.ts +19 -0
- package/src/handle.ts +29 -9
- package/src/host/testing.ts +43 -14
- package/src/index.rsc.ts +29 -1
- package/src/index.ts +43 -1
- package/src/loader.rsc.ts +24 -3
- package/src/loader.ts +16 -2
- package/src/prerender.ts +24 -3
- package/src/router/basename.ts +14 -0
- package/src/router/match-handlers.ts +62 -20
- package/src/router/prerender-match.ts +6 -0
- package/src/router/router-interfaces.ts +7 -0
- package/src/router/router-options.ts +30 -0
- package/src/router/segment-resolution/loader-cache.ts +8 -17
- package/src/router/state-cookie-name.ts +33 -0
- package/src/router/telemetry.ts +99 -0
- package/src/router.ts +36 -7
- package/src/rsc/handler.ts +13 -1
- package/src/rsc/helpers.ts +19 -0
- package/src/rsc/progressive-enhancement.ts +2 -0
- package/src/rsc/response-route-handler.ts +8 -1
- package/src/rsc/rsc-rendering.ts +2 -0
- package/src/rsc/types.ts +2 -0
- package/src/runtime-env.ts +18 -0
- package/src/server/cookie-store.ts +52 -1
- package/src/server/request-context.ts +105 -2
- package/src/static-handler.ts +25 -3
- package/src/testing/cache-status.ts +166 -0
- package/src/testing/collect-handle.ts +63 -0
- package/src/testing/dispatch.ts +581 -0
- package/src/testing/dom.entry.ts +22 -0
- package/src/testing/e2e/fixture.ts +188 -0
- package/src/testing/e2e/index.ts +149 -0
- package/src/testing/e2e/matchers.ts +51 -0
- package/src/testing/e2e/page-helpers.ts +272 -0
- package/src/testing/e2e/parity.ts +387 -0
- package/src/testing/e2e/server.ts +195 -0
- package/src/testing/flight-matchers.ts +110 -0
- package/src/testing/flight-normalize.ts +38 -0
- package/src/testing/flight-runtime.d.ts +57 -0
- package/src/testing/flight-tree.ts +682 -0
- package/src/testing/flight.entry.ts +52 -0
- package/src/testing/flight.ts +234 -0
- package/src/testing/generated-routes.ts +223 -0
- package/src/testing/index.ts +119 -0
- package/src/testing/internal/context.ts +390 -0
- package/src/testing/internal/flight-client-globals.ts +30 -0
- package/src/testing/internal/seed-vars.ts +80 -0
- package/src/testing/render-handler.ts +360 -0
- package/src/testing/render-route.tsx +594 -0
- package/src/testing/run-loader.ts +474 -0
- package/src/testing/run-middleware.ts +231 -0
- package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
- package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
- package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
- package/src/testing/vitest-stubs/version.ts +5 -0
- package/src/testing/vitest.ts +305 -0
- package/src/types/cache-types.ts +13 -4
- package/src/types/error-types.ts +5 -1
- package/src/types/global-namespace.ts +11 -1
- package/src/types/handler-context.ts +16 -5
- package/src/browser/react/use-client-cache.ts +0 -58
|
@@ -4,6 +4,8 @@ import type {
|
|
|
4
4
|
RscPayload,
|
|
5
5
|
} from "./types.js";
|
|
6
6
|
import { createPartialUpdater } from "./partial-update.js";
|
|
7
|
+
import { enterActionFence, exitActionFence } from "./action-fence.js";
|
|
8
|
+
import { KEEP_CACHE_HEADER } from "./cookie-name.js";
|
|
7
9
|
import { createNavigationTransaction } from "./navigation-transaction.js";
|
|
8
10
|
import {
|
|
9
11
|
reconcileSegments,
|
|
@@ -156,12 +158,40 @@ export function createServerActionBridge(
|
|
|
156
158
|
|
|
157
159
|
// Start action in event controller - handles lifecycle tracking
|
|
158
160
|
const handle = eventController.startAction(id, args);
|
|
161
|
+
// Whether the action's response carried the keepClientCache() directive.
|
|
162
|
+
// Set when the response arrives; gates the deferred invalidation below.
|
|
163
|
+
let keepCache = false;
|
|
164
|
+
// Single deferred invalidation + fence release, run exactly ONCE however the
|
|
165
|
+
// action terminates (normal, redirect, error, abort, intercept, concurrent).
|
|
166
|
+
// This replaces main's eager clear at action start: every directive-free
|
|
167
|
+
// action invalidates once; keepClientCache() suppresses only the automatic
|
|
168
|
+
// invalidation, so a concurrent directive-free action still invalidates via
|
|
169
|
+
// its own latch. Latched so the finally AND the early SPA-redirect returns
|
|
170
|
+
// (whose Flight stream never settles) can both call it safely.
|
|
171
|
+
let actionFinalized = false;
|
|
172
|
+
// skipInvalidation: the version-mismatch reload terminal released nothing
|
|
173
|
+
// server-side, so it releases the fence without invalidating.
|
|
174
|
+
const finalizeAction = (skipInvalidation = false): void => {
|
|
175
|
+
if (actionFinalized) return;
|
|
176
|
+
actionFinalized = true;
|
|
177
|
+
// finally so a throw in invalidation cannot leak the fence (latch is set).
|
|
178
|
+
try {
|
|
179
|
+
if (!keepCache && !skipInvalidation) {
|
|
180
|
+
store.markCacheAsStaleAndBroadcast();
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
exitActionFence();
|
|
184
|
+
}
|
|
185
|
+
};
|
|
159
186
|
try {
|
|
160
187
|
const segmentState = store.getSegmentState();
|
|
161
188
|
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
store
|
|
189
|
+
// Raise the action fence (replaces the old eager clear). Nothing is wiped,
|
|
190
|
+
// rotated, or broadcast yet: navigations during the flight fetch fresh
|
|
191
|
+
// (no-store) and popstate is treated as SWR, but the decision to
|
|
192
|
+
// invalidate is deferred to the response so a no-op action (keepClientCache)
|
|
193
|
+
// can leave the caches and the jar untouched.
|
|
194
|
+
enterActionFence();
|
|
165
195
|
|
|
166
196
|
// Create temporary references for serialization
|
|
167
197
|
const temporaryReferences = deps.createTemporaryReferenceSet();
|
|
@@ -237,11 +267,22 @@ export function createServerActionBridge(
|
|
|
237
267
|
// abortAllActions() doesn't disrupt the in-progress Flight stream.
|
|
238
268
|
handle.signal.removeEventListener("abort", onHandleAbort);
|
|
239
269
|
|
|
270
|
+
// Did the action call keepClientCache()? If so the deferred invalidation
|
|
271
|
+
// below is suppressed for THIS action (a concurrent directive-free
|
|
272
|
+
// action still invalidates via its own response).
|
|
273
|
+
keepCache = response.headers.get(KEEP_CACHE_HEADER) === "1";
|
|
274
|
+
|
|
240
275
|
// Check for version mismatch - server wants us to reload
|
|
241
276
|
const reloadResult = handleReloadHeader(response, {
|
|
242
277
|
onBlocked: resolveStreamComplete,
|
|
243
|
-
onReload: (url) =>
|
|
244
|
-
log("version mismatch on action, reloading", { reloadUrl: url })
|
|
278
|
+
onReload: (url) => {
|
|
279
|
+
log("version mismatch on action, reloading", { reloadUrl: url });
|
|
280
|
+
// Never-settling terminal (navigates away), so the finally never
|
|
281
|
+
// runs: release the fence here. skipInvalidation — the mismatch
|
|
282
|
+
// short-circuits the action server-side, so nothing mutated and a
|
|
283
|
+
// broadcast would only risk hard-reloading a sibling mid-task.
|
|
284
|
+
finalizeAction(true);
|
|
285
|
+
},
|
|
245
286
|
});
|
|
246
287
|
if (reloadResult) return reloadResult;
|
|
247
288
|
|
|
@@ -253,6 +294,10 @@ export function createServerActionBridge(
|
|
|
253
294
|
if (redirect && redirect !== "blocked" && !handle.signal.aborted) {
|
|
254
295
|
log("action simple redirect", { url: redirect.url });
|
|
255
296
|
handle.complete(undefined);
|
|
297
|
+
// This path returns a never-settling promise, so the finally never
|
|
298
|
+
// runs: invalidate + release the fence here (the mutation committed
|
|
299
|
+
// and we're navigating away). Latched, so the finally is a no-op.
|
|
300
|
+
finalizeAction();
|
|
256
301
|
await dispatchRedirect(redirect.url);
|
|
257
302
|
return new Promise<Response>(() => {});
|
|
258
303
|
}
|
|
@@ -277,6 +322,9 @@ export function createServerActionBridge(
|
|
|
277
322
|
log("action router id mismatch, reloading to re-sync");
|
|
278
323
|
handle.complete(undefined);
|
|
279
324
|
resolveStreamComplete();
|
|
325
|
+
// Never-settling return: release the fence before the reload (the
|
|
326
|
+
// reload resets module state anyway, but stay balanced). Latched.
|
|
327
|
+
finalizeAction();
|
|
280
328
|
window.location.reload();
|
|
281
329
|
return new Promise<Response>(() => {});
|
|
282
330
|
}
|
|
@@ -542,8 +590,9 @@ export function createServerActionBridge(
|
|
|
542
590
|
handle.clearConsolidation();
|
|
543
591
|
|
|
544
592
|
if (scenario.historyKeyChanged) {
|
|
545
|
-
|
|
546
|
-
|
|
593
|
+
// Invalidation is deferred to finalizeAction(); here we only trigger
|
|
594
|
+
// the revalidation refetch of the new route (suppressed on keep).
|
|
595
|
+
if (!scenario.onInterceptRoute && !keepCache) {
|
|
547
596
|
refetchRoute().catch((error) => {
|
|
548
597
|
if (isBackgroundSuppressible(error)) return;
|
|
549
598
|
console.error(
|
|
@@ -555,11 +604,14 @@ export function createServerActionBridge(
|
|
|
555
604
|
break;
|
|
556
605
|
}
|
|
557
606
|
|
|
558
|
-
// Same history key but different pathname - safe to refetch current
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
607
|
+
// Same history key but different pathname - safe to refetch current
|
|
608
|
+
// route. Invalidation is deferred to finalizeAction(); here we only
|
|
609
|
+
// trigger the revalidation refetch (suppressed on keep).
|
|
610
|
+
if (!keepCache) {
|
|
611
|
+
await refetchRoute({
|
|
612
|
+
interceptSourceUrl: store.getInterceptSourceUrl(),
|
|
613
|
+
});
|
|
614
|
+
}
|
|
563
615
|
break;
|
|
564
616
|
}
|
|
565
617
|
|
|
@@ -567,8 +619,11 @@ export function createServerActionBridge(
|
|
|
567
619
|
console.warn(
|
|
568
620
|
`[Browser] Missing segments after action (HMR detected), refetching...`,
|
|
569
621
|
);
|
|
622
|
+
// Repair (not revalidation), so ungated on keepCache: a keep action
|
|
623
|
+
// resolving last must discharge a directive-free sibling's repair.
|
|
624
|
+
// See the keep row in docs/design/rango-state-cookie.md (the all-keep
|
|
625
|
+
// edge, and the benign re-mark-stale-after-refetch end-state delta).
|
|
570
626
|
await refetchRoute({ interceptSourceUrl });
|
|
571
|
-
store.broadcastCacheInvalidation();
|
|
572
627
|
break;
|
|
573
628
|
}
|
|
574
629
|
|
|
@@ -585,11 +640,11 @@ export function createServerActionBridge(
|
|
|
585
640
|
// Clear consolidation tracking before fetch
|
|
586
641
|
handle.clearConsolidation();
|
|
587
642
|
|
|
643
|
+
// Ungated on keepCache, same as hmr-missing above (see the keep row).
|
|
588
644
|
await refetchRoute({
|
|
589
645
|
segments: segmentsToSend,
|
|
590
646
|
interceptSourceUrl,
|
|
591
647
|
});
|
|
592
|
-
store.broadcastCacheInvalidation();
|
|
593
648
|
break;
|
|
594
649
|
}
|
|
595
650
|
|
|
@@ -653,7 +708,9 @@ export function createServerActionBridge(
|
|
|
653
708
|
fullSegments,
|
|
654
709
|
currentHandleData,
|
|
655
710
|
);
|
|
656
|
-
|
|
711
|
+
// Invalidation deferred to finalizeAction() (runs after this caches
|
|
712
|
+
// the fresh segments), suppressed when the action called
|
|
713
|
+
// keepClientCache().
|
|
657
714
|
break;
|
|
658
715
|
}
|
|
659
716
|
}
|
|
@@ -661,6 +718,11 @@ export function createServerActionBridge(
|
|
|
661
718
|
handle.complete(returnData);
|
|
662
719
|
return returnData;
|
|
663
720
|
} finally {
|
|
721
|
+
// The single deferred invalidation + fence release for this action. Runs
|
|
722
|
+
// for every terminal that settles (normal, navigated-away, error, abort,
|
|
723
|
+
// intercept, concurrent); the SPA-redirect paths above already ran it.
|
|
724
|
+
// Latched, so it fires exactly once.
|
|
725
|
+
finalizeAction();
|
|
664
726
|
handle[Symbol.dispose]();
|
|
665
727
|
}
|
|
666
728
|
}
|
package/src/browser/types.ts
CHANGED
|
@@ -71,6 +71,12 @@ export interface RscMetadata {
|
|
|
71
71
|
* Sent on initial render so the browser can configure its cache duration.
|
|
72
72
|
*/
|
|
73
73
|
prefetchCacheTTL?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Server-resolved rango state cookie name (`{prefix}_{routerId}`). The client
|
|
76
|
+
* reads it verbatim and binds the rango state cookie to it; composition
|
|
77
|
+
* happens only server-side.
|
|
78
|
+
*/
|
|
79
|
+
stateCookieName?: string;
|
|
74
80
|
/**
|
|
75
81
|
* Theme configuration from router.
|
|
76
82
|
* Included when theme is enabled in router config.
|
|
@@ -433,9 +439,9 @@ export interface NavigationStore {
|
|
|
433
439
|
hasHistoryCache(historyKey: string): boolean;
|
|
434
440
|
updateCacheHandleData(historyKey: string, handleData: HandleData): void;
|
|
435
441
|
markCacheAsStale(): void;
|
|
442
|
+
markHistoryCacheStale(): void;
|
|
436
443
|
markCacheAsStaleAndBroadcast(): void;
|
|
437
444
|
clearHistoryCache(): void;
|
|
438
|
-
broadcastCacheInvalidation(): void;
|
|
439
445
|
|
|
440
446
|
// Cross-tab refresh callback (set by navigation bridge)
|
|
441
447
|
setCrossTabRefreshCallback(callback: () => void): void;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache error reporting.
|
|
3
|
+
*
|
|
4
|
+
* Caches are best-effort: a read failure degrades to a miss (render fresh) and a
|
|
5
|
+
* write failure degrades to a no-op - they MUST NOT throw up and fail the
|
|
6
|
+
* request. But the failure must still be LOUD: it is logged to the console (so
|
|
7
|
+
* it is visible even in a background waitUntil task or when no hook is wired)
|
|
8
|
+
* AND routed through the router's onError callback (via the request context's
|
|
9
|
+
* deduped _reportBackgroundError) so consumers can observe cache degradation in
|
|
10
|
+
* their own telemetry.
|
|
11
|
+
*
|
|
12
|
+
* The one deliberate exception is the invalidation WRITE verb (updateTag ->
|
|
13
|
+
* store.invalidateTags): a failed durable marker write is rejected so an awaited
|
|
14
|
+
* updateTag() surfaces it (read-your-own-writes honesty). That is not a data
|
|
15
|
+
* read/write and does not go through this helper's swallow-and-degrade path.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { _getRequestContext } from "../server/request-context.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Minimal shape of a request context for error reporting. Passed explicitly by
|
|
22
|
+
* background tasks (waitUntil) where the ALS context is already gone, so the
|
|
23
|
+
* error can still reach the router's onError. Structural to avoid importing the
|
|
24
|
+
* full RequestContext type (request-context.ts imports CacheErrorCategory from
|
|
25
|
+
* here - a mutual type-only reference).
|
|
26
|
+
*/
|
|
27
|
+
export interface CacheErrorReporter {
|
|
28
|
+
_reportBackgroundError?: (
|
|
29
|
+
error: unknown,
|
|
30
|
+
category: CacheErrorCategory,
|
|
31
|
+
) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type CacheErrorCategory =
|
|
35
|
+
/** A read failed (transient infra: KV/Cache API error). Degrade to a miss. */
|
|
36
|
+
| "cache-read"
|
|
37
|
+
/** A write failed. Degrade to a no-op (entry simply not cached). */
|
|
38
|
+
| "cache-write"
|
|
39
|
+
/** A delete/eviction failed. Best-effort. */
|
|
40
|
+
| "cache-delete"
|
|
41
|
+
/**
|
|
42
|
+
* A STORED entry could not be parsed/deserialized (partial KV read, truncated
|
|
43
|
+
* Cache API body, malformed envelope/RSC payload). The entry is faulty and is
|
|
44
|
+
* evicted so subsequent reads do not keep failing on it. Distinct from
|
|
45
|
+
* cache-read so consumers can tell corruption from a transient outage.
|
|
46
|
+
*/
|
|
47
|
+
| "cache-corrupt"
|
|
48
|
+
/** A tag-invalidation side effect failed (e.g. the eager CDN purge hook). */
|
|
49
|
+
| "cache-invalidate"
|
|
50
|
+
/**
|
|
51
|
+
* A background stale-while-revalidate refresh failed (the `"use cache"`
|
|
52
|
+
* read-through path). The stale value was already served; the refresh that
|
|
53
|
+
* would have replaced it errored.
|
|
54
|
+
*/
|
|
55
|
+
| "stale-revalidation";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Report a non-fatal cache error loudly without failing the request: always logs
|
|
59
|
+
* (label + error) and, when a request context is available, routes the error
|
|
60
|
+
* through the router's onError callback. Never throws.
|
|
61
|
+
*
|
|
62
|
+
* `ctx` is for callers running in a detached background task (waitUntil), where
|
|
63
|
+
* the ALS request context is already gone (so `_getRequestContext()` is null):
|
|
64
|
+
* they capture the context up front and pass it here so onError still fires.
|
|
65
|
+
* Foreground callers omit it and fall back to the ALS context.
|
|
66
|
+
*/
|
|
67
|
+
export function reportCacheError(
|
|
68
|
+
error: unknown,
|
|
69
|
+
category: CacheErrorCategory,
|
|
70
|
+
label: string,
|
|
71
|
+
ctx?: CacheErrorReporter,
|
|
72
|
+
): void {
|
|
73
|
+
console.error(`${label}:`, error);
|
|
74
|
+
try {
|
|
75
|
+
const target = ctx ?? _getRequestContext();
|
|
76
|
+
target?._reportBackgroundError?.(error, category);
|
|
77
|
+
} catch {
|
|
78
|
+
// Reporting must never itself break the cache path.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run a best-effort async cache task (typically scheduled via waitUntil), catching
|
|
84
|
+
* any rejection and routing it through reportCacheError so background cache work
|
|
85
|
+
* (non-blocking L1 writes, KV persistence, L1 promotion) reports failures via
|
|
86
|
+
* onError instead of throwing or silently swallowing. Never rejects.
|
|
87
|
+
*
|
|
88
|
+
* Pass `ctx` when the task runs detached (the ALS context is gone) and the
|
|
89
|
+
* failure should still reach onError; omit it to fall back to the ALS context.
|
|
90
|
+
*
|
|
91
|
+
* @example this.waitUntil(() => reportingAsync(() => cache.put(req, res), "cache-write", "[CFCacheStore] L1 write"))
|
|
92
|
+
*/
|
|
93
|
+
export async function reportingAsync(
|
|
94
|
+
task: () => Promise<unknown>,
|
|
95
|
+
category: CacheErrorCategory,
|
|
96
|
+
label: string,
|
|
97
|
+
ctx?: CacheErrorReporter,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
try {
|
|
100
|
+
await task();
|
|
101
|
+
} catch (error) {
|
|
102
|
+
reportCacheError(error, category, label, ctx);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { CacheDefaults, SegmentCacheStore } from "./types.js";
|
|
10
10
|
import { _getRequestContext } from "../server/request-context.js";
|
|
11
11
|
import type { RequestContext } from "../server/request-context.js";
|
|
12
|
+
import { normalizeTags } from "./cache-tag.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Default TTL for route-level cache() DSL and loader cache.
|
|
@@ -108,6 +109,64 @@ export async function resolveCacheKey(
|
|
|
108
109
|
return defaultKey;
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
// ============================================================================
|
|
113
|
+
// Cache Tag Resolution
|
|
114
|
+
// ============================================================================
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Resolve cache tags from a tags option (static array or function of ctx).
|
|
118
|
+
*
|
|
119
|
+
* Fails open: a thrown tag callback falls back to no tags rather than
|
|
120
|
+
* aborting the request. Tags are additive metadata (not identity), so a
|
|
121
|
+
* missing tag does not cause cache collisions, only a missed invalidation.
|
|
122
|
+
*
|
|
123
|
+
* Shared by the cache() DSL (cache-scope) and loader caching (loader-cache)
|
|
124
|
+
* so tag resolution behaves identically across every cache axis.
|
|
125
|
+
*/
|
|
126
|
+
export function resolveTagsOption<TEnv>(
|
|
127
|
+
tags: string[] | ((ctx: RequestContext<TEnv>) => string[]) | undefined,
|
|
128
|
+
ctx: RequestContext<TEnv> | undefined,
|
|
129
|
+
label: string,
|
|
130
|
+
): string[] | undefined {
|
|
131
|
+
if (!tags) return undefined;
|
|
132
|
+
if (typeof tags === "function") {
|
|
133
|
+
if (!ctx) {
|
|
134
|
+
// A dynamic tags function needs the request context to run. Without it
|
|
135
|
+
// (e.g. resolved outside a request, at build/prerender time) the entry is
|
|
136
|
+
// cached UNTAGGED and can never be invalidated - surface that rather than
|
|
137
|
+
// silently dropping the tags, matching the thrown-callback branch below.
|
|
138
|
+
console.warn(
|
|
139
|
+
`[${label}] Dynamic tags function present but no request context; ` +
|
|
140
|
+
`caching without tags (this entry will not be tag-invalidatable).`,
|
|
141
|
+
);
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
return normalizeTagList(tags(ctx));
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error(
|
|
148
|
+
`[${label}] Tags function failed, caching without tags:`,
|
|
149
|
+
error,
|
|
150
|
+
);
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return normalizeTagList(tags);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Normalize a resolved tags array so the WRITE path matches the invalidate path:
|
|
159
|
+
* updateTag()/revalidateTag()/cacheTag() all drop empty/whitespace-only tags via
|
|
160
|
+
* normalizeTag(). Without this, an empty tag attached at write time would enter
|
|
161
|
+
* the store index but could never be invalidated (the verbs normalize it away),
|
|
162
|
+
* and on CFCacheStore would also cost a wasted KV marker read per request.
|
|
163
|
+
* Returns undefined when nothing usable remains, keeping the entry header-free.
|
|
164
|
+
*/
|
|
165
|
+
function normalizeTagList(tags: string[]): string[] | undefined {
|
|
166
|
+
const out = normalizeTags(tags);
|
|
167
|
+
return out.length > 0 ? out : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
111
170
|
// ============================================================================
|
|
112
171
|
// Cache Store Resolution
|
|
113
172
|
// ============================================================================
|
|
@@ -120,6 +179,41 @@ export async function resolveCacheKey(
|
|
|
120
179
|
export function resolveCacheStore(
|
|
121
180
|
explicitStore: SegmentCacheStore | undefined,
|
|
122
181
|
): SegmentCacheStore | null {
|
|
123
|
-
if (explicitStore)
|
|
182
|
+
if (explicitStore) {
|
|
183
|
+
// Register explicit per-scope stores so updateTag()/revalidateTag() can
|
|
184
|
+
// reach them. This is the single chokepoint every cache axis (segment,
|
|
185
|
+
// response, loader) resolves through, so registering here covers them all
|
|
186
|
+
// eagerly - no dependence on whether a tagged write has happened yet. The
|
|
187
|
+
// app-level store is intentionally not registered (always reachable via
|
|
188
|
+
// ctx._cacheStore).
|
|
189
|
+
registerExplicitTaggedStore(explicitStore);
|
|
190
|
+
return explicitStore;
|
|
191
|
+
}
|
|
124
192
|
return _getRequestContext()?._cacheStore ?? null;
|
|
125
193
|
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Upper bound on the per-handler explicit-store registry. A module-singleton
|
|
197
|
+
* store (the recommended pattern) dedupes to a single entry and never approaches
|
|
198
|
+
* this. The cap bounds the niche case of an explicit store constructed PER
|
|
199
|
+
* request/boundary (e.g. a ctx-bound CFCacheStore, which must take a per-request
|
|
200
|
+
* ctx): without it the registry - which intentionally persists across requests so
|
|
201
|
+
* a server action's updateTag() can reach stores a prior render registered -
|
|
202
|
+
* would accumulate one dead instance per request and fan invalidation out to
|
|
203
|
+
* finished execution contexts. LRU-touch on re-resolution keeps a live, re-used
|
|
204
|
+
* store from being evicted by that churn.
|
|
205
|
+
*/
|
|
206
|
+
const EXPLICIT_STORE_REGISTRY_CAP = 64;
|
|
207
|
+
|
|
208
|
+
function registerExplicitTaggedStore(store: SegmentCacheStore): void {
|
|
209
|
+
const set = _getRequestContext()?._explicitTaggedStores;
|
|
210
|
+
if (!set) return;
|
|
211
|
+
// LRU touch: move an already-present store to the most-recent position (Set
|
|
212
|
+
// preserves insertion order) so a store re-resolved every request stays live.
|
|
213
|
+
set.delete(store);
|
|
214
|
+
set.add(store);
|
|
215
|
+
if (set.size > EXPLICIT_STORE_REGISTRY_CAP) {
|
|
216
|
+
const oldest = set.values().next().value;
|
|
217
|
+
if (oldest !== undefined) set.delete(oldest);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -40,6 +40,12 @@ import {
|
|
|
40
40
|
import { startHandleCapture, type HandleCapture } from "./handle-capture.js";
|
|
41
41
|
import { sortedSearchString } from "./cache-key-utils.js";
|
|
42
42
|
import { runBackground } from "./background-task.js";
|
|
43
|
+
import {
|
|
44
|
+
normalizeTags,
|
|
45
|
+
recordRequestTags,
|
|
46
|
+
runWithCacheTagScope,
|
|
47
|
+
} from "./cache-tag.js";
|
|
48
|
+
import { reportCacheError } from "./cache-error.js";
|
|
43
49
|
|
|
44
50
|
/**
|
|
45
51
|
* Convert encodeReply result to a stable string key.
|
|
@@ -74,9 +80,17 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
74
80
|
const store = requestCtx?._cacheStore;
|
|
75
81
|
const resolvedProfileName = profileName || "default";
|
|
76
82
|
|
|
77
|
-
// Bypass: no store or no getItem support
|
|
83
|
+
// Bypass: no store or no getItem support. Still run inside a tag scope so a
|
|
84
|
+
// cacheTag() call inside the function degrades to a no-op rather than
|
|
85
|
+
// throwing "must be called inside a use cache function" - adopting cacheTag()
|
|
86
|
+
// must not hard-fail in apps/tests without an item-capable cache configured.
|
|
78
87
|
if (!store?.getItem) {
|
|
79
|
-
|
|
88
|
+
const scoped = runWithCacheTagScope(() => fn.apply(this, args));
|
|
89
|
+
const result = await scoped.result;
|
|
90
|
+
// Still record the runtime tags into the request set so a cacheTag() in an
|
|
91
|
+
// uncached function tags the document, even with no item-capable store.
|
|
92
|
+
recordRequestTags(scoped.tags, requestCtx);
|
|
93
|
+
return result;
|
|
80
94
|
}
|
|
81
95
|
|
|
82
96
|
// Resolve profile strictly from request-scoped config (set by the
|
|
@@ -159,8 +173,13 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
159
173
|
cacheKey = `use-cache:${id}`;
|
|
160
174
|
}
|
|
161
175
|
} catch {
|
|
162
|
-
// Non-serializable args: run uncached
|
|
163
|
-
|
|
176
|
+
// Non-serializable args: run uncached (within a tag scope so cacheTag()
|
|
177
|
+
// still does not throw). Record runtime tags so the document union still
|
|
178
|
+
// sees them even though this call is not itself cached.
|
|
179
|
+
const scoped = runWithCacheTagScope(() => fn.apply(this, args));
|
|
180
|
+
const result = await scoped.result;
|
|
181
|
+
recordRequestTags(scoped.tags, requestCtx);
|
|
182
|
+
return result;
|
|
164
183
|
}
|
|
165
184
|
|
|
166
185
|
// Cache lookup
|
|
@@ -178,9 +197,20 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
178
197
|
if (r) restoreHandles(r, handleStore);
|
|
179
198
|
}
|
|
180
199
|
}
|
|
200
|
+
// Surface the hit's tags to the request set so a document built from a
|
|
201
|
+
// cached item is still tagged (the function did not re-run, so its
|
|
202
|
+
// runtime cacheTag() tags are only available from the stored entry).
|
|
203
|
+
recordRequestTags(cached.tags, requestCtx);
|
|
181
204
|
return result;
|
|
182
|
-
} catch {
|
|
183
|
-
//
|
|
205
|
+
} catch (error) {
|
|
206
|
+
// The stored value is corrupt/partial (failed RSC deserialize). Report
|
|
207
|
+
// it, then fall through to fresh execution - the miss path below re-runs
|
|
208
|
+
// and setItem() overwrites the faulty entry under the same key (self-heal).
|
|
209
|
+
reportCacheError(
|
|
210
|
+
error,
|
|
211
|
+
"cache-corrupt",
|
|
212
|
+
`[use cache] "${id}" fresh-hit`,
|
|
213
|
+
);
|
|
184
214
|
}
|
|
185
215
|
}
|
|
186
216
|
|
|
@@ -195,6 +225,8 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
195
225
|
if (r) restoreHandles(r, handleStore);
|
|
196
226
|
}
|
|
197
227
|
}
|
|
228
|
+
// Tag the request with the stale entry's tags (see fresh-hit note).
|
|
229
|
+
recordRequestTags(cached.tags, requestCtx);
|
|
198
230
|
// Background revalidation — must capture handles if tainted args present.
|
|
199
231
|
// Use an isolated handle store so background pushes don't pollute the
|
|
200
232
|
// live response or throw LateHandlePushError on the completed store.
|
|
@@ -244,8 +276,18 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
244
276
|
}
|
|
245
277
|
|
|
246
278
|
try {
|
|
247
|
-
const
|
|
279
|
+
const scoped = runWithCacheTagScope(() => fn.apply(this, args));
|
|
280
|
+
const freshResult = await scoped.result;
|
|
248
281
|
bgStopCapture?.();
|
|
282
|
+
// Merge profile/DSL tags with runtime cacheTag() tags, read after
|
|
283
|
+
// awaiting so post-await cacheTag() calls are included. Normalize
|
|
284
|
+
// (drops empty profile tags, matching the invalidate path) + dedupe.
|
|
285
|
+
const freshTags = [
|
|
286
|
+
...new Set(
|
|
287
|
+
normalizeTags([...(profile.tags ?? []), ...scoped.tags]),
|
|
288
|
+
),
|
|
289
|
+
];
|
|
290
|
+
recordRequestTags(freshTags, requestCtx);
|
|
249
291
|
const serialized = await serializeResult(freshResult);
|
|
250
292
|
if (serialized !== null) {
|
|
251
293
|
const encodedHandles = bgCapture?.data
|
|
@@ -255,12 +297,20 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
255
297
|
handles: encodedHandles,
|
|
256
298
|
ttl: profile.ttl,
|
|
257
299
|
swr: profile.swr,
|
|
258
|
-
tags:
|
|
300
|
+
tags: freshTags.length > 0 ? freshTags : undefined,
|
|
259
301
|
});
|
|
260
302
|
}
|
|
261
303
|
} catch (bgError) {
|
|
262
304
|
bgStopCapture?.();
|
|
263
|
-
requestCtx
|
|
305
|
+
// Pass requestCtx explicitly: this runs in a detached background
|
|
306
|
+
// task where the ALS context is gone, so onError can only fire if
|
|
307
|
+
// we hand it the context captured up front.
|
|
308
|
+
reportCacheError(
|
|
309
|
+
bgError,
|
|
310
|
+
"stale-revalidation",
|
|
311
|
+
"[use cache] background revalidation failed",
|
|
312
|
+
requestCtx,
|
|
313
|
+
);
|
|
264
314
|
} finally {
|
|
265
315
|
for (const arg of bgTaintedArgs) {
|
|
266
316
|
unstampCacheExec(arg as object);
|
|
@@ -272,8 +322,14 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
272
322
|
}
|
|
273
323
|
});
|
|
274
324
|
return result;
|
|
275
|
-
} catch {
|
|
276
|
-
//
|
|
325
|
+
} catch (error) {
|
|
326
|
+
// Stale value is corrupt/partial; report and fall through to a fresh
|
|
327
|
+
// execution, which overwrites the faulty entry under the same key.
|
|
328
|
+
reportCacheError(
|
|
329
|
+
error,
|
|
330
|
+
"cache-corrupt",
|
|
331
|
+
`[use cache] "${id}" stale-hit`,
|
|
332
|
+
);
|
|
277
333
|
}
|
|
278
334
|
}
|
|
279
335
|
|
|
@@ -306,8 +362,10 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
306
362
|
}
|
|
307
363
|
|
|
308
364
|
let result: any;
|
|
365
|
+
let scoped: ReturnType<typeof runWithCacheTagScope>;
|
|
309
366
|
try {
|
|
310
|
-
|
|
367
|
+
scoped = runWithCacheTagScope(() => fn.apply(this, args));
|
|
368
|
+
result = await scoped.result;
|
|
311
369
|
} finally {
|
|
312
370
|
// Decrement ref count; symbol is deleted when it reaches zero
|
|
313
371
|
for (const arg of taintedArgs) {
|
|
@@ -320,6 +378,14 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
320
378
|
stopCapture?.();
|
|
321
379
|
}
|
|
322
380
|
|
|
381
|
+
// Merge profile/DSL tags with runtime cacheTag() tags. Read scoped.tags
|
|
382
|
+
// after awaiting result so post-await cacheTag() calls are included.
|
|
383
|
+
// Normalize (drops empty profile tags, matching the invalidate path) + dedupe.
|
|
384
|
+
const allTags = [
|
|
385
|
+
...new Set(normalizeTags([...(profile.tags ?? []), ...scoped!.tags])),
|
|
386
|
+
];
|
|
387
|
+
recordRequestTags(allTags, requestCtx);
|
|
388
|
+
|
|
323
389
|
// Serialize and store — fully non-blocking when waitUntil is available.
|
|
324
390
|
// The response does not need to wait for serialization or the store write.
|
|
325
391
|
const cacheWrite = async () => {
|
|
@@ -333,7 +399,7 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
333
399
|
handles: encodedHandles,
|
|
334
400
|
ttl: profile.ttl,
|
|
335
401
|
swr: profile.swr,
|
|
336
|
-
tags:
|
|
402
|
+
tags: allTags.length > 0 ? allTags : undefined,
|
|
337
403
|
});
|
|
338
404
|
}
|
|
339
405
|
} catch (writeError) {
|