@rangojs/router 0.1.0 → 0.1.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/dist/bin/rango.js +11 -1
- package/dist/types/browser/dev-discovery.d.ts +11 -0
- package/dist/types/browser/navigation-store.d.ts +3 -13
- package/dist/types/browser/prefetch/loader.d.ts +10 -0
- package/dist/types/browser/prefetch/runtime.d.ts +3 -0
- package/dist/types/browser/types.d.ts +4 -16
- package/dist/types/build/generate-manifest.d.ts +6 -0
- package/dist/types/cache/cache-scope.d.ts +7 -5
- package/dist/types/cache/cf/cf-cache-store.d.ts +10 -9
- package/dist/types/cache/memory-segment-store.d.ts +6 -6
- package/dist/types/cache/shell-snapshot.d.ts +21 -8
- package/dist/types/cache/types.d.ts +24 -7
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +9 -8
- package/dist/types/dev-discovery-protocol.d.ts +5 -0
- package/dist/types/prerender/store.d.ts +1 -0
- package/dist/types/route-map-builder.d.ts +4 -29
- package/dist/types/router/prerender-match.d.ts +4 -1
- package/dist/types/router/router-interfaces.d.ts +3 -1
- package/dist/types/rsc/handler-context.d.ts +1 -0
- package/dist/types/rsc/render-pipeline.d.ts +3 -2
- package/dist/types/rsc/rsc-rendering.d.ts +5 -10
- package/dist/types/rsc/shell-capture.d.ts +4 -8
- package/dist/types/rsc/types.d.ts +2 -0
- package/dist/types/server/context.d.ts +16 -0
- package/dist/types/server/request-context.d.ts +18 -8
- package/dist/types/server.d.ts +1 -1
- package/dist/types/types/route-entry.d.ts +3 -0
- package/dist/types/vite/discovery/state.d.ts +3 -2
- package/dist/types/vite/utils/manifest-utils.d.ts +1 -1
- package/dist/vite/index.js +194 -107
- package/package.json +7 -2
- package/skills/caching/SKILL.md +1 -1
- package/skills/ppr/SKILL.md +48 -4
- package/src/browser/dev-discovery.ts +66 -0
- package/src/browser/navigation-client.ts +6 -0
- package/src/browser/navigation-store.ts +4 -271
- package/src/browser/partial-update.ts +7 -14
- package/src/browser/prefetch/cache.ts +1 -1
- package/src/browser/prefetch/loader.ts +110 -0
- package/src/browser/prefetch/runtime.ts +7 -0
- package/src/browser/react/Link.tsx +7 -10
- package/src/browser/react/NavigationProvider.tsx +1 -1
- package/src/browser/react/use-router.ts +1 -1
- package/src/browser/rsc-router.tsx +4 -2
- package/src/browser/types.ts +4 -27
- package/src/build/generate-manifest.ts +11 -0
- package/src/cache/cache-scope.ts +17 -6
- package/src/cache/cf/cf-cache-store.ts +63 -39
- package/src/cache/memory-segment-store.ts +17 -6
- package/src/cache/shell-snapshot.ts +45 -15
- package/src/cache/types.ts +23 -6
- package/src/cache/vercel/vercel-cache-store.ts +52 -22
- package/src/dev-discovery-protocol.ts +11 -0
- package/src/prerender/build-shell-capture.ts +7 -1
- package/src/prerender/store.ts +5 -0
- package/src/route-definition/dsl-helpers.ts +7 -2
- package/src/route-map-builder.ts +56 -49
- package/src/router/handler-context.ts +13 -5
- package/src/router/lazy-includes.ts +1 -0
- package/src/router/match-api.ts +8 -4
- package/src/router/prerender-match.ts +19 -2
- package/src/router/router-interfaces.ts +4 -0
- package/src/router/segment-resolution/static-store.ts +36 -10
- package/src/router.ts +34 -3
- package/src/rsc/handler-context.ts +1 -0
- package/src/rsc/handler.ts +3 -1
- package/src/rsc/loader-fetch.ts +2 -2
- package/src/rsc/manifest-init.ts +4 -11
- package/src/rsc/render-pipeline.ts +10 -2
- package/src/rsc/rsc-rendering.ts +207 -146
- package/src/rsc/shell-capture.ts +34 -11
- package/src/rsc/types.ts +2 -0
- package/src/server/context.ts +28 -0
- package/src/server/request-context.ts +32 -10
- package/src/server.ts +0 -2
- package/src/testing/dispatch.ts +1 -0
- package/src/types/route-entry.ts +3 -0
- package/src/urls/include-helper.ts +1 -0
- package/src/urls/path-helper.ts +8 -4
- package/src/vite/discovery/bundle-postprocess.ts +10 -7
- package/src/vite/discovery/discover-routers.ts +7 -66
- package/src/vite/discovery/prerender-collection.ts +13 -2
- package/src/vite/discovery/state.ts +4 -4
- package/src/vite/discovery/virtual-module-codegen.ts +75 -42
- package/src/vite/plugins/version-injector.ts +0 -1
- package/src/vite/plugins/virtual-entries.ts +11 -1
- package/src/vite/router-discovery.ts +132 -22
- package/src/vite/utils/manifest-utils.ts +1 -4
- package/src/vite/utils/shared-utils.ts +7 -0
package/src/browser/types.ts
CHANGED
|
@@ -65,6 +65,8 @@ export interface RscMetadata {
|
|
|
65
65
|
* Used to detect version mismatches after HMR/deployment.
|
|
66
66
|
*/
|
|
67
67
|
version?: string;
|
|
68
|
+
/** Cloudflare dev worker generation used for stale-document convergence. */
|
|
69
|
+
devDiscoveryEpoch?: number;
|
|
68
70
|
/**
|
|
69
71
|
* TTL in milliseconds for the client-side in-memory prefetch cache.
|
|
70
72
|
* Sent on initial render so the browser can configure its cache duration.
|
|
@@ -430,24 +432,10 @@ export type StateListener = () => void;
|
|
|
430
432
|
/**
|
|
431
433
|
* Navigation store interface
|
|
432
434
|
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
* - SegmentState: Internal segment management for partial updates
|
|
435
|
+
* Owns segment state, history snapshots, and partial-update notifications.
|
|
436
|
+
* EventController owns the public navigation lifecycle exposed by hooks.
|
|
436
437
|
*/
|
|
437
438
|
export interface NavigationStore {
|
|
438
|
-
// Public state (for useNavigation hook)
|
|
439
|
-
getState(): NavigationState;
|
|
440
|
-
setState(partial: Partial<NavigationState>): void;
|
|
441
|
-
subscribe(listener: StateListener): () => void;
|
|
442
|
-
|
|
443
|
-
// Inflight action management
|
|
444
|
-
addInflightAction(action: InflightAction): void;
|
|
445
|
-
removeInflightAction(id: string): void;
|
|
446
|
-
|
|
447
|
-
// Action state (for controlling update behavior during server actions)
|
|
448
|
-
isActionInProgress(): boolean;
|
|
449
|
-
setActionInProgress(value: boolean): void;
|
|
450
|
-
|
|
451
439
|
// Internal segment state (for bridges)
|
|
452
440
|
getSegmentState(): SegmentState;
|
|
453
441
|
setPath(path: string): void;
|
|
@@ -459,8 +447,6 @@ export interface NavigationStore {
|
|
|
459
447
|
setHistoryKey(key: string): void;
|
|
460
448
|
/** Monotonic token of the most recently committed navigation. */
|
|
461
449
|
getNavInstance(): number;
|
|
462
|
-
/** Nav-instance token recorded on a cache entry (undefined if absent). */
|
|
463
|
-
getCacheEntryInstance(historyKey: string): number | undefined;
|
|
464
450
|
cacheSegmentsForHistory(
|
|
465
451
|
historyKey: string,
|
|
466
452
|
segments: ResolvedSegment[],
|
|
@@ -506,7 +492,6 @@ export interface NavigationStore {
|
|
|
506
492
|
stale?: boolean,
|
|
507
493
|
handlesPending?: boolean,
|
|
508
494
|
): void;
|
|
509
|
-
markCacheAsStale(): void;
|
|
510
495
|
markHistoryCacheStale(): void;
|
|
511
496
|
markCacheAsStaleAndBroadcast(): void;
|
|
512
497
|
clearHistoryCache(): void;
|
|
@@ -525,14 +510,6 @@ export interface NavigationStore {
|
|
|
525
510
|
// UI update notifications
|
|
526
511
|
onUpdate(callback: UpdateSubscriber): () => void;
|
|
527
512
|
emitUpdate(update: NavigationUpdate): void;
|
|
528
|
-
|
|
529
|
-
// Action state tracking (for useAction hook)
|
|
530
|
-
getActionState(actionId: string): TrackedActionState;
|
|
531
|
-
setActionState(actionId: string, state: Partial<TrackedActionState>): void;
|
|
532
|
-
subscribeToAction(
|
|
533
|
-
actionId: string,
|
|
534
|
-
listener: ActionStateListener,
|
|
535
|
-
): () => void;
|
|
536
513
|
}
|
|
537
514
|
|
|
538
515
|
// ============================================================================
|
|
@@ -115,6 +115,7 @@ async function buildPrefixTreeNode(
|
|
|
115
115
|
passthroughRoutes?: string[],
|
|
116
116
|
responseTypeRoutes?: Record<string, string>,
|
|
117
117
|
routeSearchSchemas?: Record<string, Record<string, string>>,
|
|
118
|
+
routerId?: string,
|
|
118
119
|
): Promise<PrefixTreeNode> {
|
|
119
120
|
// Resolve an async include provider (`() => import("./routes")`) so its routes
|
|
120
121
|
// are walked into the build-time manifest/types/href. Runtime matching still
|
|
@@ -156,6 +157,7 @@ async function buildPrefixTreeNode(
|
|
|
156
157
|
parent: null,
|
|
157
158
|
counters: {},
|
|
158
159
|
mountIndex,
|
|
160
|
+
...(routerId ? { routerId } : {}),
|
|
159
161
|
trackedIncludes, // Enable nested include tracking
|
|
160
162
|
},
|
|
161
163
|
() => {
|
|
@@ -227,6 +229,7 @@ async function buildPrefixTreeNode(
|
|
|
227
229
|
passthroughRoutes,
|
|
228
230
|
responseTypeRoutes,
|
|
229
231
|
routeSearchSchemas,
|
|
232
|
+
routerId,
|
|
230
233
|
),
|
|
231
234
|
);
|
|
232
235
|
|
|
@@ -296,6 +299,12 @@ export async function generateManifestFull<TEnv>(
|
|
|
296
299
|
mountIndex: number = 0,
|
|
297
300
|
options?: {
|
|
298
301
|
urlPrefix?: string;
|
|
302
|
+
/**
|
|
303
|
+
* Owning router id. Threaded into the evaluation store so path() scopes
|
|
304
|
+
* its search-schema/root-scope registrations per router — same-named
|
|
305
|
+
* routes in different routers must not clobber each other.
|
|
306
|
+
*/
|
|
307
|
+
routerId?: string;
|
|
299
308
|
/**
|
|
300
309
|
* Called once per `"use client"` component registered as an
|
|
301
310
|
* errorBoundary/notFoundBoundary fallback, with its client-reference key
|
|
@@ -328,6 +337,7 @@ export async function generateManifestFull<TEnv>(
|
|
|
328
337
|
parent: null,
|
|
329
338
|
counters: {},
|
|
330
339
|
mountIndex,
|
|
340
|
+
...(options?.routerId ? { routerId: options.routerId } : {}),
|
|
331
341
|
trackedIncludes, // Enable include tracking
|
|
332
342
|
// basename sets the initial URL prefix for all path() registrations
|
|
333
343
|
...(options?.urlPrefix ? { urlPrefix: options.urlPrefix } : {}),
|
|
@@ -402,6 +412,7 @@ export async function generateManifestFull<TEnv>(
|
|
|
402
412
|
passthroughRoutes,
|
|
403
413
|
responseTypeRoutes,
|
|
404
414
|
routeSearchSchemas,
|
|
415
|
+
options?.routerId,
|
|
405
416
|
),
|
|
406
417
|
);
|
|
407
418
|
|
package/src/cache/cache-scope.ts
CHANGED
|
@@ -89,6 +89,7 @@ function getDefaultRouteCacheKey(
|
|
|
89
89
|
pathname: string,
|
|
90
90
|
params?: Record<string, string>,
|
|
91
91
|
isIntercept?: boolean,
|
|
92
|
+
prefixOverride?: "doc",
|
|
92
93
|
): string {
|
|
93
94
|
const ctx = getRequestContext();
|
|
94
95
|
const isPartial = ctx?.originalUrl?.searchParams.has("_rsc_partial") ?? false;
|
|
@@ -96,7 +97,9 @@ function getDefaultRouteCacheKey(
|
|
|
96
97
|
const host = ctx?.url.host ?? "localhost";
|
|
97
98
|
|
|
98
99
|
// Intercept navigations get their own cache namespace
|
|
99
|
-
const prefix = isIntercept
|
|
100
|
+
const prefix = isIntercept
|
|
101
|
+
? "intercept"
|
|
102
|
+
: (prefixOverride ?? (isPartial ? "partial" : "doc"));
|
|
100
103
|
|
|
101
104
|
return `${prefix}:${cacheKeyBase(host, pathname, searchParams, params)}`;
|
|
102
105
|
}
|
|
@@ -131,6 +134,7 @@ export class CacheScope {
|
|
|
131
134
|
constructor(
|
|
132
135
|
config: PartialCacheOptions | false,
|
|
133
136
|
parent: CacheScope | null = null,
|
|
137
|
+
private readonly defaultKeyPrefix?: "doc",
|
|
134
138
|
) {
|
|
135
139
|
this.config = config;
|
|
136
140
|
this.parent = parent;
|
|
@@ -209,7 +213,12 @@ export class CacheScope {
|
|
|
209
213
|
params: Record<string, string>,
|
|
210
214
|
isIntercept?: boolean,
|
|
211
215
|
): Promise<string> {
|
|
212
|
-
const defaultKey = getDefaultRouteCacheKey(
|
|
216
|
+
const defaultKey = getDefaultRouteCacheKey(
|
|
217
|
+
pathname,
|
|
218
|
+
params,
|
|
219
|
+
isIntercept,
|
|
220
|
+
this.defaultKeyPrefix,
|
|
221
|
+
);
|
|
213
222
|
const keyFn = this.config !== false ? this.config.key : undefined;
|
|
214
223
|
return resolveCacheKey(keyFn, this.getStore(), defaultKey, "CacheScope");
|
|
215
224
|
}
|
|
@@ -529,10 +538,11 @@ export function createCacheScope(
|
|
|
529
538
|
/**
|
|
530
539
|
* Shell fast path: when the route tree derived NO cache scope and the current
|
|
531
540
|
* request context carries the `_shellImplicitCache` marker (a shell capture,
|
|
532
|
-
*
|
|
533
|
-
* scope so withCacheLookup/withCacheStore treat the WHOLE
|
|
534
|
-
* cache() boundary — the shell entry IS a cache() of the
|
|
535
|
-
* loaders as the live carve-outs
|
|
541
|
+
* an eligible HIT tail, or a normal partial navigation replay), substitute an
|
|
542
|
+
* implicit doc-level scope so withCacheLookup/withCacheStore treat the WHOLE
|
|
543
|
+
* matched route as a cache() boundary — the shell entry IS a cache() of the
|
|
544
|
+
* handler layer, with loaders as the live carve-outs
|
|
545
|
+
* (resolveFreshLoadersAndYield).
|
|
536
546
|
*
|
|
537
547
|
* An existing scope — including an explicit cache(false) opt-out — always
|
|
538
548
|
* wins: the consumer's cache() semantics (their ttl/swr/store/condition) are
|
|
@@ -548,5 +558,6 @@ export function resolveShellImplicitCacheScope(
|
|
|
548
558
|
return new CacheScope(
|
|
549
559
|
{ ttl: marker.ttl, swr: marker.swr, store: marker.store },
|
|
550
560
|
null,
|
|
561
|
+
marker.keyPrefix,
|
|
551
562
|
);
|
|
552
563
|
}
|
|
@@ -272,7 +272,7 @@ interface KVShellEnvelope {
|
|
|
272
272
|
rv: string;
|
|
273
273
|
/** Build version captured at prerender time (ShellCacheEntry.buildVersion) */
|
|
274
274
|
bv?: string;
|
|
275
|
-
/**
|
|
275
|
+
/** Capture-generation start time (ms epoch), used by tag marker checks. */
|
|
276
276
|
c: number;
|
|
277
277
|
/** When entry becomes stale (ms epoch) */
|
|
278
278
|
s: number;
|
|
@@ -293,6 +293,8 @@ interface KVShellEnvelope {
|
|
|
293
293
|
* their holes only a handler re-run can fill.
|
|
294
294
|
*/
|
|
295
295
|
lh?: boolean;
|
|
296
|
+
/** ShellCacheEntry.transitionWhen; conditional transitions must re-run. */
|
|
297
|
+
tw?: true;
|
|
296
298
|
}
|
|
297
299
|
|
|
298
300
|
/**
|
|
@@ -323,6 +325,7 @@ interface KVResponseEnvelope {
|
|
|
323
325
|
// ============================================================================
|
|
324
326
|
|
|
325
327
|
export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
328
|
+
readonly supportsPassiveShellReads: true = true;
|
|
326
329
|
readonly defaults?: CacheDefaults;
|
|
327
330
|
readonly keyGenerator?: (
|
|
328
331
|
ctx: RequestContext<TEnv>,
|
|
@@ -1743,7 +1746,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1743
1746
|
// per-colo L1 for them is a deliberate follow-up (see the PPR shell-resume
|
|
1744
1747
|
// design doc). Shell entries live only in KV (the global tier), so the family
|
|
1745
1748
|
// requires a configured KV namespace; without one, getShell/putShell no-op and
|
|
1746
|
-
// the
|
|
1749
|
+
// the integrated PPR serve path fails open to a full HTML render. Tag invalidation
|
|
1747
1750
|
// still applies: shell entries carry tags/taggedAt and are checked against the
|
|
1748
1751
|
// same KV markers isGloballyInvalidated() reads for every other tier.
|
|
1749
1752
|
|
|
@@ -1772,7 +1775,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1772
1775
|
* Get a cached PPR shell entry by key from KV (no L1). Applies the KV read
|
|
1773
1776
|
* budget, corrupt-entry eviction, hard-expiry, and tag invalidation exactly
|
|
1774
1777
|
* like kvGetItem, minus the L1 promote. SWR is a plain staleness flag — KV has
|
|
1775
|
-
* no REVALIDATING herd guard, so the
|
|
1778
|
+
* no REVALIDATING herd guard, so the capture scheduler's module-level
|
|
1776
1779
|
* in-flight set is the recapture stampede guard.
|
|
1777
1780
|
*/
|
|
1778
1781
|
async getShell(
|
|
@@ -1815,6 +1818,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1815
1818
|
initialTheme: envelope.i,
|
|
1816
1819
|
snapshot: envelope.sn,
|
|
1817
1820
|
handlerLiveHoles: envelope.lh,
|
|
1821
|
+
transitionWhen: envelope.tw,
|
|
1818
1822
|
createdAt: envelope.c,
|
|
1819
1823
|
},
|
|
1820
1824
|
shouldRevalidate,
|
|
@@ -1826,8 +1830,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1826
1830
|
}
|
|
1827
1831
|
|
|
1828
1832
|
/**
|
|
1829
|
-
* Store a PPR shell entry in KV with TTL and optional SWR window.
|
|
1830
|
-
*
|
|
1833
|
+
* Store a PPR shell entry in KV with TTL and optional SWR window. The write is
|
|
1834
|
+
* registered with waitUntil and awaited so invalidation rejection can be
|
|
1835
|
+
* acknowledged to the capture scheduler. The tags/taggedAt ride in the envelope
|
|
1831
1836
|
* so isGloballyInvalidated() can invalidate the shell via the shared KV markers.
|
|
1832
1837
|
*/
|
|
1833
1838
|
async putShell(
|
|
@@ -1836,8 +1841,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1836
1841
|
ttlSeconds?: number,
|
|
1837
1842
|
swrSeconds?: number,
|
|
1838
1843
|
tags?: string[],
|
|
1839
|
-
): Promise<void> {
|
|
1840
|
-
// KV-only tier: needs a KV namespace and waitUntil
|
|
1844
|
+
): Promise<"stored" | "invalidated" | void> {
|
|
1845
|
+
// KV-only tier: needs a KV namespace and waitUntil. The same write promise is
|
|
1846
|
+
// registered for isolate lifetime and awaited so invalidation rejection can
|
|
1847
|
+
// be acknowledged to the capture scheduler.
|
|
1841
1848
|
if (!this.kv) {
|
|
1842
1849
|
this.warnShellFamilyInertOnce();
|
|
1843
1850
|
return;
|
|
@@ -1851,9 +1858,15 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1851
1858
|
// letting kv.put reject inside waitUntil (mirrors setItem/kvSetSegment).
|
|
1852
1859
|
if (totalTtl < 60) return;
|
|
1853
1860
|
|
|
1854
|
-
const
|
|
1861
|
+
const retentionTtl =
|
|
1862
|
+
tags && tags.length > 0 && this.tagInvalidationTtl
|
|
1863
|
+
? Math.min(totalTtl, this.tagInvalidationTtl)
|
|
1864
|
+
: totalTtl;
|
|
1865
|
+
const now = Date.now();
|
|
1866
|
+
const staleAt = now + ttl * 1000;
|
|
1867
|
+
const expiresAt = now + retentionTtl * 1000;
|
|
1855
1868
|
const taggedAt =
|
|
1856
|
-
Array.isArray(tags) && tags.length > 0 ?
|
|
1869
|
+
Array.isArray(tags) && tags.length > 0 ? entry.createdAt : undefined;
|
|
1857
1870
|
|
|
1858
1871
|
const kvKey = this.toKVKey(`shell:${key}`);
|
|
1859
1872
|
// A key over the KV limit makes kv.put reject deep inside waitUntil; report
|
|
@@ -1871,31 +1884,43 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1871
1884
|
return;
|
|
1872
1885
|
}
|
|
1873
1886
|
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
(
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1887
|
+
const write = (async (): Promise<"stored" | "invalidated" | void> => {
|
|
1888
|
+
try {
|
|
1889
|
+
if (
|
|
1890
|
+
tags &&
|
|
1891
|
+
tags.length > 0 &&
|
|
1892
|
+
(await this.isGloballyInvalidated(tags, entry.createdAt))
|
|
1893
|
+
) {
|
|
1894
|
+
return "invalidated";
|
|
1895
|
+
}
|
|
1896
|
+
const envelope: KVShellEnvelope = {
|
|
1897
|
+
p: entry.prelude,
|
|
1898
|
+
po: entry.postponed,
|
|
1899
|
+
rv: entry.reactVersion,
|
|
1900
|
+
bv: entry.buildVersion,
|
|
1901
|
+
c: entry.createdAt,
|
|
1902
|
+
s: staleAt,
|
|
1903
|
+
e: expiresAt,
|
|
1904
|
+
t: tags,
|
|
1905
|
+
ta: taggedAt,
|
|
1906
|
+
i: entry.initialTheme,
|
|
1907
|
+
sn: entry.snapshot,
|
|
1908
|
+
lh: entry.handlerLiveHoles,
|
|
1909
|
+
tw: entry.transitionWhen,
|
|
1910
|
+
};
|
|
1911
|
+
await this.kv!.put(kvKey, JSON.stringify(envelope), {
|
|
1912
|
+
expirationTtl: retentionTtl,
|
|
1913
|
+
});
|
|
1914
|
+
return "stored";
|
|
1915
|
+
} catch (error) {
|
|
1916
|
+
reportCacheError(error, "cache-write", "[CFCacheStore] putShell");
|
|
1917
|
+
return undefined;
|
|
1918
|
+
}
|
|
1919
|
+
})();
|
|
1920
|
+
this.waitUntil(async () => {
|
|
1921
|
+
await write;
|
|
1922
|
+
});
|
|
1923
|
+
return await write;
|
|
1899
1924
|
} catch (error) {
|
|
1900
1925
|
reportCacheError(error, "cache-write", "[CFCacheStore] putShell");
|
|
1901
1926
|
}
|
|
@@ -2610,11 +2635,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2610
2635
|
* eager purge still fires for the whole batch first (it is additive).
|
|
2611
2636
|
*/
|
|
2612
2637
|
/**
|
|
2613
|
-
*
|
|
2614
|
-
*
|
|
2615
|
-
*
|
|
2616
|
-
*
|
|
2617
|
-
* envelope check (identical semantics: marker >= since, fail open).
|
|
2638
|
+
* Shell tag-generation gate (SegmentCacheStore.isTagsInvalidatedSince): the
|
|
2639
|
+
* SAME KV markers used by runtime envelopes also evict immutable build shells
|
|
2640
|
+
* and captures whose write races updateTag(). Thin public wrapper over the
|
|
2641
|
+
* private envelope check (marker >= since, fail open).
|
|
2618
2642
|
*/
|
|
2619
2643
|
async isTagsInvalidatedSince(
|
|
2620
2644
|
tags: string[],
|
|
@@ -188,6 +188,7 @@ const DEFAULT_MAX_ENTRIES = 1000;
|
|
|
188
188
|
export class MemorySegmentCacheStore<
|
|
189
189
|
TEnv = unknown,
|
|
190
190
|
> implements SegmentCacheStore<TEnv> {
|
|
191
|
+
readonly supportsPassiveShellReads: true = true;
|
|
191
192
|
private cache: Map<string, CachedEntryData>;
|
|
192
193
|
private responseCache: Map<string, CachedResponseEntry>;
|
|
193
194
|
private itemCache: Map<string, CachedItemEntry>;
|
|
@@ -197,11 +198,9 @@ export class MemorySegmentCacheStore<
|
|
|
197
198
|
/** prefixed cache key -> set of tags (reverse index for O(tags) unregister) */
|
|
198
199
|
private keyTags: Map<string, Set<string>>;
|
|
199
200
|
/**
|
|
200
|
-
* tag -> epoch ms of its latest invalidateTags() call.
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* these markers against the entry's build-time createdAt. Per-isolate,
|
|
204
|
-
* like every other map here — matching this store's tag semantics.
|
|
201
|
+
* tag -> epoch ms of its latest invalidateTags() call. Runtime capture races
|
|
202
|
+
* and immutable build shells are gated by comparing these markers with the
|
|
203
|
+
* shell generation start. Per-isolate, like every other map here.
|
|
205
204
|
*/
|
|
206
205
|
private tagInvalidatedAt: Map<string, number>;
|
|
207
206
|
readonly defaults?: CacheDefaults;
|
|
@@ -459,7 +458,14 @@ export class MemorySegmentCacheStore<
|
|
|
459
458
|
ttlSeconds?: number,
|
|
460
459
|
swrSeconds?: number,
|
|
461
460
|
tags?: string[],
|
|
462
|
-
): Promise<
|
|
461
|
+
): Promise<"stored" | "invalidated"> {
|
|
462
|
+
if (
|
|
463
|
+
tags &&
|
|
464
|
+
tags.length > 0 &&
|
|
465
|
+
this.tagsInvalidatedSince(tags, entry.createdAt)
|
|
466
|
+
) {
|
|
467
|
+
return "invalidated";
|
|
468
|
+
}
|
|
463
469
|
const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
|
|
464
470
|
const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
|
|
465
471
|
const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
|
|
@@ -470,12 +476,17 @@ export class MemorySegmentCacheStore<
|
|
|
470
476
|
if (tags && tags.length > 0) {
|
|
471
477
|
this.registerTags(tags, prefixedKey);
|
|
472
478
|
}
|
|
479
|
+
return "stored";
|
|
473
480
|
}
|
|
474
481
|
|
|
475
482
|
async isTagsInvalidatedSince(
|
|
476
483
|
tags: string[],
|
|
477
484
|
sinceMs: number,
|
|
478
485
|
): Promise<boolean> {
|
|
486
|
+
return this.tagsInvalidatedSince(tags, sinceMs);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private tagsInvalidatedSince(tags: string[], sinceMs: number): boolean {
|
|
479
490
|
for (const tag of tags) {
|
|
480
491
|
const at = this.tagInvalidatedAt.get(tag);
|
|
481
492
|
// >= so a same-millisecond invalidation wins (freshness over staleness),
|
|
@@ -96,6 +96,9 @@ export class RecordingShellStore<
|
|
|
96
96
|
get keyGenerator(): SegmentCacheStore<TEnv>["keyGenerator"] {
|
|
97
97
|
return this.inner.keyGenerator;
|
|
98
98
|
}
|
|
99
|
+
get supportsPassiveShellReads(): true | undefined {
|
|
100
|
+
return this.inner.supportsPassiveShellReads;
|
|
101
|
+
}
|
|
99
102
|
|
|
100
103
|
private record(
|
|
101
104
|
family: ShellSnapshotRecord["family"],
|
|
@@ -232,8 +235,9 @@ export class RecordingShellStore<
|
|
|
232
235
|
|
|
233
236
|
async getShell(
|
|
234
237
|
key: string,
|
|
238
|
+
options?: { claimRevalidation?: boolean },
|
|
235
239
|
): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
|
|
236
|
-
return this.inner.getShell ? this.inner.getShell(key) : null;
|
|
240
|
+
return this.inner.getShell ? this.inner.getShell(key, options) : null;
|
|
237
241
|
}
|
|
238
242
|
|
|
239
243
|
async putShell(
|
|
@@ -242,7 +246,7 @@ export class RecordingShellStore<
|
|
|
242
246
|
ttlSeconds?: number,
|
|
243
247
|
swrSeconds?: number,
|
|
244
248
|
tags?: string[],
|
|
245
|
-
): Promise<void> {
|
|
249
|
+
): Promise<"stored" | "invalidated" | void> {
|
|
246
250
|
return this.inner.putShell?.(key, entry, ttlSeconds, swrSeconds, tags);
|
|
247
251
|
}
|
|
248
252
|
|
|
@@ -360,28 +364,42 @@ export async function buildShellLoaderSeed(
|
|
|
360
364
|
* the snapshot it serves the recorded value AS FRESH (shouldRevalidate: false —
|
|
361
365
|
* a pinned key must NOT kick SWR background revalidation) so the tail's payload
|
|
362
366
|
* matches the frozen prelude. Every other read falls through to the real store
|
|
363
|
-
* (the holes — masked loaders were never recorded — stay live).
|
|
364
|
-
* through
|
|
365
|
-
* family
|
|
367
|
+
* (the holes — masked loaders were never recorded — stay live). Writes pass
|
|
368
|
+
* through so a live hole may legitimately write, except that `segmentsOnly`
|
|
369
|
+
* fully isolates the segment family: misses do not fall through, and writes or
|
|
370
|
+
* deletes stay local to the navigation overlay. The shell family always passes
|
|
371
|
+
* through. With `segmentsOnly`, item and response reads also pass through so a
|
|
372
|
+
* partial navigation cannot freeze captured data or write a partial result into
|
|
373
|
+
* the canonical document namespace.
|
|
366
374
|
*/
|
|
367
375
|
export class SeededShellStore<
|
|
368
376
|
TEnv = unknown,
|
|
369
377
|
> implements SegmentCacheStore<TEnv> {
|
|
370
|
-
private readonly items
|
|
378
|
+
private readonly items: Map<string, ShellSnapshotItemValue> | undefined;
|
|
371
379
|
private readonly segments = new Map<string, CachedEntryData>();
|
|
372
|
-
private readonly responses
|
|
380
|
+
private readonly responses:
|
|
381
|
+
| Map<string, ShellSnapshotResponseValue>
|
|
382
|
+
| undefined;
|
|
383
|
+
private readonly segmentsOnly: boolean;
|
|
373
384
|
|
|
374
385
|
constructor(
|
|
375
386
|
private readonly inner: SegmentCacheStore<TEnv>,
|
|
376
387
|
snapshot: ShellSnapshotRecord[],
|
|
388
|
+
options?: { segmentsOnly?: boolean },
|
|
377
389
|
) {
|
|
390
|
+
this.segmentsOnly = options?.segmentsOnly === true;
|
|
391
|
+
this.items = this.segmentsOnly ? undefined : new Map();
|
|
392
|
+
this.responses = this.segmentsOnly ? undefined : new Map();
|
|
378
393
|
for (const rec of snapshot) {
|
|
379
|
-
if (rec
|
|
380
|
-
|
|
381
|
-
} else if (rec.family === "segment") {
|
|
394
|
+
if (!rec || typeof rec !== "object") continue;
|
|
395
|
+
if (rec.family === "segment") {
|
|
382
396
|
this.segments.set(rec.key, rec.value as CachedEntryData);
|
|
397
|
+
} else if (this.segmentsOnly) {
|
|
398
|
+
continue;
|
|
399
|
+
} else if (rec.family === "item") {
|
|
400
|
+
this.items?.set(rec.key, rec.value as ShellSnapshotItemValue);
|
|
383
401
|
} else if (rec.family === "response") {
|
|
384
|
-
this.responses
|
|
402
|
+
this.responses?.set(rec.key, rec.value as ShellSnapshotResponseValue);
|
|
385
403
|
}
|
|
386
404
|
// "loader" family records are not store reads — serveShellHit seeds them
|
|
387
405
|
// onto the tail context (_shellLoaderSeed) for the resolveLoaderData
|
|
@@ -395,10 +413,14 @@ export class SeededShellStore<
|
|
|
395
413
|
get keyGenerator(): SegmentCacheStore<TEnv>["keyGenerator"] {
|
|
396
414
|
return this.inner.keyGenerator;
|
|
397
415
|
}
|
|
416
|
+
get supportsPassiveShellReads(): true | undefined {
|
|
417
|
+
return this.inner.supportsPassiveShellReads;
|
|
418
|
+
}
|
|
398
419
|
|
|
399
420
|
async get(key: string): Promise<CacheGetResult | null> {
|
|
400
421
|
const seeded = this.segments.get(key);
|
|
401
422
|
if (seeded) return { data: seeded, shouldRevalidate: false };
|
|
423
|
+
if (this.segmentsOnly) return null;
|
|
402
424
|
return this.inner.get(key);
|
|
403
425
|
}
|
|
404
426
|
|
|
@@ -408,10 +430,17 @@ export class SeededShellStore<
|
|
|
408
430
|
ttl: number,
|
|
409
431
|
swr?: number,
|
|
410
432
|
): Promise<void> {
|
|
433
|
+
if (this.segmentsOnly) {
|
|
434
|
+
this.segments.set(key, data);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
411
437
|
return this.inner.set(key, data, ttl, swr);
|
|
412
438
|
}
|
|
413
439
|
|
|
414
440
|
async delete(key: string): Promise<boolean> {
|
|
441
|
+
if (this.segmentsOnly) {
|
|
442
|
+
return this.segments.delete(key);
|
|
443
|
+
}
|
|
415
444
|
return this.inner.delete(key);
|
|
416
445
|
}
|
|
417
446
|
|
|
@@ -422,7 +451,7 @@ export class SeededShellStore<
|
|
|
422
451
|
async getResponse(
|
|
423
452
|
key: string,
|
|
424
453
|
): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
|
|
425
|
-
const seeded = this.responses
|
|
454
|
+
const seeded = this.responses?.get(key);
|
|
426
455
|
if (seeded) {
|
|
427
456
|
return { response: deserializeResponse(seeded), shouldRevalidate: false };
|
|
428
457
|
}
|
|
@@ -440,7 +469,7 @@ export class SeededShellStore<
|
|
|
440
469
|
}
|
|
441
470
|
|
|
442
471
|
async getItem(key: string): Promise<CacheItemResult | null> {
|
|
443
|
-
const seeded = this.items
|
|
472
|
+
const seeded = this.items?.get(key);
|
|
444
473
|
if (seeded) {
|
|
445
474
|
return {
|
|
446
475
|
value: seeded.value,
|
|
@@ -462,8 +491,9 @@ export class SeededShellStore<
|
|
|
462
491
|
|
|
463
492
|
async getShell(
|
|
464
493
|
key: string,
|
|
494
|
+
options?: { claimRevalidation?: boolean },
|
|
465
495
|
): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
|
|
466
|
-
return this.inner.getShell ? this.inner.getShell(key) : null;
|
|
496
|
+
return this.inner.getShell ? this.inner.getShell(key, options) : null;
|
|
467
497
|
}
|
|
468
498
|
|
|
469
499
|
async putShell(
|
|
@@ -472,7 +502,7 @@ export class SeededShellStore<
|
|
|
472
502
|
ttlSeconds?: number,
|
|
473
503
|
swrSeconds?: number,
|
|
474
504
|
tags?: string[],
|
|
475
|
-
): Promise<void> {
|
|
505
|
+
): Promise<"stored" | "invalidated" | void> {
|
|
476
506
|
return this.inner.putShell?.(key, entry, ttlSeconds, swrSeconds, tags);
|
|
477
507
|
}
|
|
478
508
|
|
package/src/cache/types.ts
CHANGED
|
@@ -36,6 +36,13 @@ export interface CacheGetResult {
|
|
|
36
36
|
* @typeParam TEnv - Platform bindings type (e.g., Cloudflare env)
|
|
37
37
|
*/
|
|
38
38
|
export interface SegmentCacheStore<TEnv = unknown> {
|
|
39
|
+
/**
|
|
40
|
+
* The store honors getShell(..., { claimRevalidation: false }) without
|
|
41
|
+
* claiming an SWR lock. Navigation replay requires this opt-in because it
|
|
42
|
+
* cannot recapture an HTML shell after observing a stale entry.
|
|
43
|
+
*/
|
|
44
|
+
readonly supportsPassiveShellReads?: true;
|
|
45
|
+
|
|
39
46
|
/**
|
|
40
47
|
* Default cache options for this store.
|
|
41
48
|
* Used by cache() boundaries when ttl/swr are not explicitly specified.
|
|
@@ -144,9 +151,12 @@ export interface SegmentCacheStore<TEnv = unknown> {
|
|
|
144
151
|
*
|
|
145
152
|
* Optional: a store that does not implement the shell family disables the
|
|
146
153
|
* shell-cache middleware (it fails open to the normal HTML render path).
|
|
154
|
+
* A passive read reports a stale entry as `shouldRevalidate: true` without
|
|
155
|
+
* claiming store-specific revalidation ownership.
|
|
147
156
|
*/
|
|
148
157
|
getShell?(
|
|
149
158
|
key: string,
|
|
159
|
+
options?: { claimRevalidation?: boolean },
|
|
150
160
|
): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null>;
|
|
151
161
|
|
|
152
162
|
/**
|
|
@@ -160,6 +170,8 @@ export interface SegmentCacheStore<TEnv = unknown> {
|
|
|
160
170
|
* @param swrSeconds - Optional stale-while-revalidate window in seconds
|
|
161
171
|
* @param tags - Optional cache tags for invalidation (participates in
|
|
162
172
|
* invalidateTags via the same tag machinery as the item family)
|
|
173
|
+
* @returns `invalidated` when a generation marker rejected the write,
|
|
174
|
+
* `stored` when acknowledged, or void for stores without acknowledgements.
|
|
163
175
|
*/
|
|
164
176
|
putShell?(
|
|
165
177
|
key: string,
|
|
@@ -167,7 +179,7 @@ export interface SegmentCacheStore<TEnv = unknown> {
|
|
|
167
179
|
ttlSeconds?: number,
|
|
168
180
|
swrSeconds?: number,
|
|
169
181
|
tags?: string[],
|
|
170
|
-
): Promise<void>;
|
|
182
|
+
): Promise<"stored" | "invalidated" | void>;
|
|
171
183
|
|
|
172
184
|
/**
|
|
173
185
|
* Get a cached function result by key.
|
|
@@ -200,10 +212,9 @@ export interface SegmentCacheStore<TEnv = unknown> {
|
|
|
200
212
|
/**
|
|
201
213
|
* True when ANY of `tags` was invalidated (invalidateTags/updateTag) at or
|
|
202
214
|
* after `sinceMs` (>= so a same-millisecond invalidation wins, favouring
|
|
203
|
-
* freshness). Consulted by
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
* markers against the entry's createdAt rather than by deleting anything.
|
|
215
|
+
* freshness). Consulted by build-shell read-through and runtime shell stores:
|
|
216
|
+
* "was it evicted" is answered by tag markers against the entry's createdAt,
|
|
217
|
+
* including when invalidation races a capture that has not been written yet.
|
|
207
218
|
* Optional: without it, TAGGED build entries are not served (untagged ones
|
|
208
219
|
* are unaffected — they are evictable only by deploy/buildVersion anyway).
|
|
209
220
|
* Fail open to `false` on marker-read errors: a transient store fault must
|
|
@@ -307,7 +318,13 @@ export interface ShellCacheEntry {
|
|
|
307
318
|
* their holes always fill.
|
|
308
319
|
*/
|
|
309
320
|
handlerLiveHoles?: boolean;
|
|
310
|
-
/**
|
|
321
|
+
/**
|
|
322
|
+
* The capture encountered transition({ when }). Its effective hold policy is
|
|
323
|
+
* request-dependent, so handler-free document and navigation replay must
|
|
324
|
+
* re-run resolution to collect and evaluate the predicate.
|
|
325
|
+
*/
|
|
326
|
+
transitionWhen?: true;
|
|
327
|
+
/** Capture-generation start time; tag invalidations at or after it win. */
|
|
311
328
|
createdAt: number;
|
|
312
329
|
}
|
|
313
330
|
|