@rangojs/router 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/types/cache/cf/cf-cache-store.d.ts +30 -16
- package/dist/types/cache/shell-snapshot.d.ts +2 -2
- package/dist/types/cache/types.d.ts +26 -7
- package/dist/types/rsc/shell-capture.d.ts +5 -4
- package/dist/vite/index.js +1 -1
- package/package.json +21 -22
- package/skills/cloudflare/SKILL.md +5 -3
- package/skills/ppr/SKILL.md +11 -7
- package/src/cache/cf/cf-cache-store.ts +118 -62
- package/src/cache/shell-snapshot.ts +2 -2
- package/src/cache/types.ts +27 -7
- package/src/rsc/shell-build-manifest.ts +13 -6
- package/src/rsc/shell-capture.ts +22 -5
|
@@ -30,8 +30,6 @@ import type { KVNamespace, CFCacheReadDebugEvent, CFCacheDebug, CFCacheStoreOpti
|
|
|
30
30
|
export type { KVNamespace, CFCacheReadDebugEvent, CFCacheDebug, CFCacheStoreOptions, };
|
|
31
31
|
export declare class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
32
32
|
readonly supportsPassiveShellReads: true;
|
|
33
|
-
/** True when constructed without KV: the shell family no-ops (see ctor). */
|
|
34
|
-
readonly shellFamilyInert?: boolean;
|
|
35
33
|
readonly defaults?: CacheDefaults;
|
|
36
34
|
readonly keyGenerator?: (ctx: RequestContext<TEnv>, defaultKey: string) => string | Promise<string>;
|
|
37
35
|
private readonly namespace?;
|
|
@@ -43,6 +41,8 @@ export declare class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<T
|
|
|
43
41
|
private readonly kvReadTimeoutMs;
|
|
44
42
|
private readonly debug?;
|
|
45
43
|
private readonly kv?;
|
|
44
|
+
/** True when constructed without KV: no durable tag history (see ctor). */
|
|
45
|
+
readonly tagHistoryInert?: boolean;
|
|
46
46
|
private readonly onRevalidateTag?;
|
|
47
47
|
private readonly tagPurge?;
|
|
48
48
|
private readonly tagInvalidationTtl?;
|
|
@@ -257,15 +257,26 @@ export declare class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<T
|
|
|
257
257
|
*/
|
|
258
258
|
setItem(key: string, value: string, options?: CacheItemOptions): Promise<void>;
|
|
259
259
|
/**
|
|
260
|
-
* Warn once per isolate that
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
260
|
+
* Warn once per isolate that a TAGGED shell landed on a store with no
|
|
261
|
+
* eviction path for it: no KV (markers) and no tagPurge (purge-by-tag).
|
|
262
|
+
* The shell still caches and expires by ttl+swr, but updateTag()/
|
|
263
|
+
* revalidateTag() cannot reach it — silent staleness a consumer who tagged
|
|
264
|
+
* the route clearly did not intend. Fired from putShell (not the
|
|
265
|
+
* constructor) so an untagged edge-only ppr config stays warning-free.
|
|
266
266
|
* @internal
|
|
267
267
|
*/
|
|
268
|
-
private
|
|
268
|
+
private warnShellTagsNoEvictionOnce;
|
|
269
|
+
/**
|
|
270
|
+
* Generation gate for shell writes and the capture scheduler
|
|
271
|
+
* (isTagsInvalidatedSince). With KV it is the durable marker cascade.
|
|
272
|
+
* Without KV there are no markers: in purge mode the per-request memo is
|
|
273
|
+
* the only signal — a capture racing THIS request's updateTag() is still
|
|
274
|
+
* rejected (read-your-own-writes), while cross-request races are bounded
|
|
275
|
+
* by ttl+swr exactly like the data families' purge-mode writes. Without
|
|
276
|
+
* either, fail open (ttl/swr-only semantics, warned at putShell).
|
|
277
|
+
* @internal
|
|
278
|
+
*/
|
|
279
|
+
private isShellGenerationInvalidated;
|
|
269
280
|
/**
|
|
270
281
|
* Get a cached PPR shell entry from Cache API, falling through to KV and
|
|
271
282
|
* promoting a valid KV hit. Both tiers store one envelope so the prelude,
|
|
@@ -278,13 +289,14 @@ export declare class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<T
|
|
|
278
289
|
shouldRevalidate?: boolean;
|
|
279
290
|
} | null>;
|
|
280
291
|
/**
|
|
281
|
-
* Store a PPR shell envelope in Cache API and, when
|
|
282
|
-
* 60-second floor, KV. The shared write is
|
|
283
|
-
* awaited so invalidation rejection can be
|
|
284
|
-
* scheduler. Short-lived shells remain useful
|
|
285
|
-
* them
|
|
292
|
+
* Store a PPR shell envelope in Cache API and, when KV is configured and
|
|
293
|
+
* the retention meets its 60-second floor, KV. The shared write is
|
|
294
|
+
* registered with waitUntil and awaited so invalidation rejection can be
|
|
295
|
+
* acknowledged to the capture scheduler. Short-lived shells remain useful
|
|
296
|
+
* in L1 even though KV rejects them; a KV-less store is L1-only by design
|
|
297
|
+
* (edge-only ppr — see the section comment).
|
|
286
298
|
*/
|
|
287
|
-
putShell(key: string, entry: ShellCacheEntry, ttlSeconds?: number, swrSeconds?: number, tags?: string[]): Promise<"stored" | "invalidated" | void>;
|
|
299
|
+
putShell(key: string, entry: ShellCacheEntry, ttlSeconds?: number, swrSeconds?: number, tags?: string[]): Promise<"stored" | "invalidated" | "uncacheable" | void>;
|
|
288
300
|
/** Rebuild the public shell entry from its validated storage envelope. */
|
|
289
301
|
private shellEnvelopeToEntry;
|
|
290
302
|
/** Build the Cache API representation of the coupled shell envelope. */
|
|
@@ -569,7 +581,9 @@ export declare class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<T
|
|
|
569
581
|
* Shell tag-generation gate (SegmentCacheStore.isTagsInvalidatedSince): the
|
|
570
582
|
* SAME KV markers used by runtime envelopes also evict immutable build shells
|
|
571
583
|
* and captures whose write races updateTag(). Thin public wrapper over the
|
|
572
|
-
*
|
|
584
|
+
* shell generation check (marker >= since, fail open); KV-less it degrades
|
|
585
|
+
* to the per-request memo in purge mode and to false otherwise — see
|
|
586
|
+
* isShellGenerationInvalidated.
|
|
573
587
|
*/
|
|
574
588
|
isTagsInvalidatedSince(tags: string[], sinceMs: number): Promise<boolean>;
|
|
575
589
|
invalidateTags(tags: string[]): Promise<void>;
|
|
@@ -90,7 +90,7 @@ export declare class RecordingShellStore<TEnv = unknown> implements SegmentCache
|
|
|
90
90
|
entry: ShellCacheEntry;
|
|
91
91
|
shouldRevalidate?: boolean;
|
|
92
92
|
} | null>;
|
|
93
|
-
putShell(key: string, entry: ShellCacheEntry, ttlSeconds?: number, swrSeconds?: number, tags?: string[]): Promise<"stored" | "invalidated" | void>;
|
|
93
|
+
putShell(key: string, entry: ShellCacheEntry, ttlSeconds?: number, swrSeconds?: number, tags?: string[]): Promise<"stored" | "invalidated" | "uncacheable" | void>;
|
|
94
94
|
invalidateTags(tags: string[]): Promise<void>;
|
|
95
95
|
}
|
|
96
96
|
/** True iff `store` is a RecordingShellStore (duck-typed across module copies). */
|
|
@@ -181,6 +181,6 @@ export declare class SeededShellStore<TEnv = unknown> implements SegmentCacheSto
|
|
|
181
181
|
entry: ShellCacheEntry;
|
|
182
182
|
shouldRevalidate?: boolean;
|
|
183
183
|
} | null>;
|
|
184
|
-
putShell(key: string, entry: ShellCacheEntry, ttlSeconds?: number, swrSeconds?: number, tags?: string[]): Promise<"stored" | "invalidated" | void>;
|
|
184
|
+
putShell(key: string, entry: ShellCacheEntry, ttlSeconds?: number, swrSeconds?: number, tags?: string[]): Promise<"stored" | "invalidated" | "uncacheable" | void>;
|
|
185
185
|
invalidateTags(tags: string[]): Promise<void>;
|
|
186
186
|
}
|
|
@@ -161,18 +161,37 @@ export interface SegmentCacheStore<TEnv = unknown> {
|
|
|
161
161
|
* @param tags - Optional cache tags for invalidation (participates in
|
|
162
162
|
* invalidateTags via the same tag machinery as the item family)
|
|
163
163
|
* @returns `invalidated` when a generation marker rejected the write,
|
|
164
|
-
* `stored` when acknowledged,
|
|
164
|
+
* `stored` when acknowledged, `uncacheable` when the entry can NEVER be
|
|
165
|
+
* stored under the current configuration (every retry would refuse
|
|
166
|
+
* identically — the capture scheduler backs the key off instead of
|
|
167
|
+
* recapturing per MISS; CFCacheStore returns it for a tag set whose
|
|
168
|
+
* Cache-Tag header overflows in KV-less purge mode), or void for stores
|
|
169
|
+
* without acknowledgements.
|
|
165
170
|
*/
|
|
166
|
-
putShell?(key: string, entry: ShellCacheEntry, ttlSeconds?: number, swrSeconds?: number, tags?: string[]): Promise<"stored" | "invalidated" | void>;
|
|
171
|
+
putShell?(key: string, entry: ShellCacheEntry, ttlSeconds?: number, swrSeconds?: number, tags?: string[]): Promise<"stored" | "invalidated" | "uncacheable" | void>;
|
|
167
172
|
/**
|
|
168
173
|
* Declares the shell family present-but-inert: getShell/putShell exist but
|
|
169
|
-
* no-op (
|
|
170
|
-
* captures whose only write target is inert —
|
|
171
|
-
* dead work that still occupies the
|
|
172
|
-
* (a promise-heavy route bakes for
|
|
173
|
-
* Absent/false means the family,
|
|
174
|
+
* no-op (a custom store whose backing tier is conditionally unavailable).
|
|
175
|
+
* scheduleShellCapture skips captures whose only write target is inert —
|
|
176
|
+
* the background render would be dead work that still occupies the
|
|
177
|
+
* per-isolate serialized capture queue (a promise-heavy route bakes for
|
|
178
|
+
* seconds per MISS with nothing stored). Absent/false means the family,
|
|
179
|
+
* when present, actually stores. The built-in stores never declare it:
|
|
180
|
+
* CFCacheStore is L1-only without KV (edge-only ppr), not inert.
|
|
174
181
|
*/
|
|
175
182
|
shellFamilyInert?: boolean;
|
|
183
|
+
/**
|
|
184
|
+
* Declares isTagsInvalidatedSince present-but-inert: the store implements
|
|
185
|
+
* the method but has no DURABLE invalidation history behind it (a KV-less
|
|
186
|
+
* CFCacheStore answers from the per-request memo at best). Runtime shells
|
|
187
|
+
* tolerate that — purge eviction plus ttl/swr bound their staleness — but
|
|
188
|
+
* a TAGGED build-manifest shell is immutable with no ttl of its own, so
|
|
189
|
+
* serving it on such a store would make updateTag() a permanent no-op for
|
|
190
|
+
* it. The build-shell read-through declines tagged entries on this flag
|
|
191
|
+
* (same declared-intent-cannot-be-honored doctrine as a store missing the
|
|
192
|
+
* method entirely). Absent/false means answers are durably backed.
|
|
193
|
+
*/
|
|
194
|
+
tagHistoryInert?: boolean;
|
|
176
195
|
/**
|
|
177
196
|
* Get a cached function result by key.
|
|
178
197
|
* Returns the serialized value, optional handle data, and staleness flag.
|
|
@@ -126,9 +126,10 @@ export interface ShellCaptureDebugEvent {
|
|
|
126
126
|
* the capture was not attempted
|
|
127
127
|
* - skip-capacity: the isolate capture queue is full; a later request may retry
|
|
128
128
|
* - skip-inert-store: the resolved store's shell family is missing or
|
|
129
|
-
* declared inert (SegmentCacheStore.shellFamilyInert — a
|
|
130
|
-
*
|
|
131
|
-
* background render was not
|
|
129
|
+
* declared inert (SegmentCacheStore.shellFamilyInert — a custom store
|
|
130
|
+
* whose backing tier is unavailable; built-in stores never declare it);
|
|
131
|
+
* nothing could store the result, so the background render was not
|
|
132
|
+
* scheduled at all
|
|
132
133
|
* - skip-queue-timeout: the capture waited past CAPTURE_QUEUE_WAIT_BUDGET_MS
|
|
133
134
|
* behind other captures and was dropped unrun (no backoff — the route is
|
|
134
135
|
* not doomed, the isolate was busy; a later request re-probes). Carries
|
|
@@ -160,7 +161,7 @@ export interface ShellCaptureDebugEvent {
|
|
|
160
161
|
/** A bake-lane loader settled into a shell that uses TTL/SWR-only invalidation. */
|
|
161
162
|
untaggedBake?: true;
|
|
162
163
|
/** Outcome reported by a store that supports shell-write acknowledgements. */
|
|
163
|
-
storeWrite?: "stored" | "invalidated";
|
|
164
|
+
storeWrite?: "stored" | "invalidated" | "uncacheable";
|
|
164
165
|
/** Consecutive failure count in the key's backoff entry, when one exists. */
|
|
165
166
|
backoffFailures?: number;
|
|
166
167
|
/** Ms remaining in the key's backoff window, when one exists. */
|
package/dist/vite/index.js
CHANGED
|
@@ -3685,7 +3685,7 @@ import { resolve } from "node:path";
|
|
|
3685
3685
|
// package.json
|
|
3686
3686
|
var package_default = {
|
|
3687
3687
|
name: "@rangojs/router",
|
|
3688
|
-
version: "0.
|
|
3688
|
+
version: "0.8.0",
|
|
3689
3689
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
3690
3690
|
keywords: [
|
|
3691
3691
|
"react",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rangojs/router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Django-inspired RSC router with composable URL patterns",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -176,20 +176,6 @@
|
|
|
176
176
|
"access": "public",
|
|
177
177
|
"tag": "latest"
|
|
178
178
|
},
|
|
179
|
-
"scripts": {
|
|
180
|
-
"build": "pnpm run build:types && pnpm exec esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm exec esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm exec esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
181
|
-
"build:types": "rm -rf dist/types && pnpm exec tsc -p tsconfig.types.json",
|
|
182
|
-
"prepublishOnly": "pnpm build",
|
|
183
|
-
"typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
|
|
184
|
-
"test": "playwright test",
|
|
185
|
-
"test:preload": "playwright test --config=playwright.preload.config.ts",
|
|
186
|
-
"test:ui": "playwright test --ui",
|
|
187
|
-
"test:hmr-local": "playwright test --project=dev-warmup --project=hmr-basename --project=hmr-prerender --no-deps --workers=1 && RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
|
|
188
|
-
"test:hmr-routes-local": "RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
|
|
189
|
-
"test:unit": "pnpm run build:types && vitest run",
|
|
190
|
-
"test:unit:watch": "vitest",
|
|
191
|
-
"test:unit:rsc": "vitest run --config vitest.rsc.config.ts"
|
|
192
|
-
},
|
|
193
179
|
"dependencies": {
|
|
194
180
|
"@types/debug": "^4.1.12",
|
|
195
181
|
"@vitejs/plugin-rsc": "^0.5.31",
|
|
@@ -206,18 +192,18 @@
|
|
|
206
192
|
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
207
193
|
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
208
194
|
"@playwright/test": "^1.49.1",
|
|
209
|
-
"@shared/e2e": "workspace:*",
|
|
210
195
|
"@testing-library/dom": "^10.4.1",
|
|
211
196
|
"@testing-library/react": "^16.3.2",
|
|
212
197
|
"@types/node": "^24.10.1",
|
|
213
|
-
"@types/react": "
|
|
214
|
-
"@types/react-dom": "
|
|
198
|
+
"@types/react": "^19.2.7",
|
|
199
|
+
"@types/react-dom": "^19.2.3",
|
|
215
200
|
"esbuild": "^0.28.1",
|
|
216
201
|
"happy-dom": "^20.10.1",
|
|
217
202
|
"jiti": "^2.7.0",
|
|
218
|
-
"react": "
|
|
219
|
-
"react-dom": "
|
|
220
|
-
"vitest": "^4.1.9"
|
|
203
|
+
"react": "^19.2.8",
|
|
204
|
+
"react-dom": "^19.2.8",
|
|
205
|
+
"vitest": "^4.1.9",
|
|
206
|
+
"@shared/e2e": "0.0.1"
|
|
221
207
|
},
|
|
222
208
|
"peerDependencies": {
|
|
223
209
|
"@cloudflare/vite-plugin": "^1.42.1",
|
|
@@ -256,5 +242,18 @@
|
|
|
256
242
|
},
|
|
257
243
|
"engines": {
|
|
258
244
|
"node": ">=24.0.0"
|
|
245
|
+
},
|
|
246
|
+
"scripts": {
|
|
247
|
+
"build": "pnpm run build:types && pnpm exec esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm exec esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm exec esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
248
|
+
"build:types": "rm -rf dist/types && pnpm exec tsc -p tsconfig.types.json",
|
|
249
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
|
|
250
|
+
"test": "playwright test",
|
|
251
|
+
"test:preload": "playwright test --config=playwright.preload.config.ts",
|
|
252
|
+
"test:ui": "playwright test --ui",
|
|
253
|
+
"test:hmr-local": "playwright test --project=dev-warmup --project=hmr-basename --project=hmr-prerender --no-deps --workers=1 && RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
|
|
254
|
+
"test:hmr-routes-local": "RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
|
|
255
|
+
"test:unit": "pnpm run build:types && vitest run",
|
|
256
|
+
"test:unit:watch": "vitest",
|
|
257
|
+
"test:unit:rsc": "vitest run --config vitest.rsc.config.ts"
|
|
259
258
|
}
|
|
260
|
-
}
|
|
259
|
+
}
|
|
@@ -130,9 +130,11 @@ export const router = createRouter<AppBindings>({
|
|
|
130
130
|
```
|
|
131
131
|
|
|
132
132
|
PPR shells use Cache API as the per-colo L1 and KV as the durable cross-colo
|
|
133
|
-
L2; a KV hit promotes the coupled shell envelope back into L1. KV
|
|
134
|
-
|
|
135
|
-
|
|
133
|
+
L2; a KV hit promotes the coupled shell envelope back into L1. KV is optional:
|
|
134
|
+
`CFCacheStore` without `kv` runs the shell family L1-only (edge-only ppr —
|
|
135
|
+
each colo captures and serves its own shell). Tag eviction then needs purge
|
|
136
|
+
mode (`tagPurge`); a tagged shell without either warns once and expires by
|
|
137
|
+
ttl+swr only.
|
|
136
138
|
|
|
137
139
|
## Commands
|
|
138
140
|
|
package/skills/ppr/SKILL.md
CHANGED
|
@@ -356,13 +356,17 @@ curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-s
|
|
|
356
356
|
key — no header + a declared `ppr` means look for that warning.
|
|
357
357
|
- On Cloudflare, `CFCacheStore` reads PPR shells from the per-colo Cache API,
|
|
358
358
|
falls through to KV on a miss, and promotes the KV hit back into that colo.
|
|
359
|
-
WITHOUT a KV namespace
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
359
|
+
WITHOUT a KV namespace the family runs L1-only (edge-only ppr): every colo
|
|
360
|
+
captures and serves its own shell from the Cache API. What changes KV-less
|
|
361
|
+
is tag eviction — with `tagPurge` the purge-by-tag evicts shell L1 entries;
|
|
362
|
+
without it a TAGGED shell warns once that `updateTag()` cannot reach it
|
|
363
|
+
(ttl/swr-only freshness). Untagged edge-only ppr is warning-free. Tagged
|
|
364
|
+
BUILD-manifest shells decline on a KV-less store (`tagHistoryInert` — the
|
|
365
|
+
immutable asset could never be evicted), and an over-limit tag set is
|
|
366
|
+
acknowledged `"uncacheable"` so the capture backs off instead of
|
|
367
|
+
re-rendering per MISS. A custom store can still declare `shellFamilyInert`
|
|
368
|
+
to stop captures at the gate (`skip-inert-store` on the debug event); the
|
|
369
|
+
built-in stores never do.
|
|
366
370
|
- Structured capture diagnostics: `createRouter({ debugShellCapture: true })`
|
|
367
371
|
logs one line per capture attempt/skip (outcome, durations, prelude and
|
|
368
372
|
snapshot bytes, backoff state); pass a function to receive each
|
|
@@ -145,11 +145,12 @@ const warnedNoKvReadInvalidation = new Set<string>();
|
|
|
145
145
|
const warnedTagInvalidationTtlFloor = new Set<string>();
|
|
146
146
|
|
|
147
147
|
/**
|
|
148
|
-
* Stores (by namespace) already warned
|
|
149
|
-
*
|
|
150
|
-
*
|
|
148
|
+
* Stores (by namespace) already warned that a TAGGED shell was written on a
|
|
149
|
+
* KV-less store without tagPurge: no markers and no purge means updateTag()
|
|
150
|
+
* cannot reach the shell (freshness is ttl/swr only). Once per isolate, not
|
|
151
|
+
* per capture (CFCacheStore is constructed per request).
|
|
151
152
|
*/
|
|
152
|
-
const
|
|
153
|
+
const warnedShellTagsNoEviction = new Set<string>();
|
|
153
154
|
|
|
154
155
|
/**
|
|
155
156
|
* Stores (by namespace) already warned that tag invalidation is writing KV
|
|
@@ -412,8 +413,6 @@ interface KVResponseEnvelope {
|
|
|
412
413
|
|
|
413
414
|
export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
414
415
|
readonly supportsPassiveShellReads: true = true;
|
|
415
|
-
/** True when constructed without KV: the shell family no-ops (see ctor). */
|
|
416
|
-
readonly shellFamilyInert?: boolean;
|
|
417
416
|
readonly defaults?: CacheDefaults;
|
|
418
417
|
readonly keyGenerator?: (
|
|
419
418
|
ctx: RequestContext<TEnv>,
|
|
@@ -429,6 +428,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
429
428
|
private readonly kvReadTimeoutMs: number;
|
|
430
429
|
private readonly debug?: (event: CFCacheReadDebugEvent) => void;
|
|
431
430
|
private readonly kv?: KVNamespace;
|
|
431
|
+
/** True when constructed without KV: no durable tag history (see ctor). */
|
|
432
|
+
readonly tagHistoryInert?: boolean;
|
|
432
433
|
private readonly onRevalidateTag?: (tags: string[]) => Promise<void>;
|
|
433
434
|
private readonly tagPurge?: (cacheTags: string[]) => Promise<void>;
|
|
434
435
|
private readonly tagInvalidationTtl?: number;
|
|
@@ -485,11 +486,13 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
485
486
|
this.keyGenerator = options.keyGenerator;
|
|
486
487
|
this.waitUntil = (fn) => options.ctx.waitUntil(fn());
|
|
487
488
|
this.kv = options.kv;
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
|
|
489
|
+
// Without KV, isTagsInvalidatedSince has no durable history — it answers
|
|
490
|
+
// from the per-request memo at best. Runtime shells tolerate that (purge
|
|
491
|
+
// eviction + ttl/swr bound the staleness), but an immutable TAGGED
|
|
492
|
+
// build-manifest shell must not serve on such a store: nothing could ever
|
|
493
|
+
// evict it (purge cannot delete a build asset), so the manifest gate
|
|
494
|
+
// declines on this flag (shell-build-manifest.ts).
|
|
495
|
+
this.tagHistoryInert = options.kv ? undefined : true;
|
|
493
496
|
this.onRevalidateTag = options.onRevalidateTag;
|
|
494
497
|
// tagPurge accepts a ready purge function or a credentials object; the
|
|
495
498
|
// object form is normalized through the built-in zone purge client, which
|
|
@@ -506,9 +509,12 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
506
509
|
// marker write throw and break ALL invalidation. Floor it (and warn once);
|
|
507
510
|
// a non-finite/non-positive value falls back to the no-expiry default
|
|
508
511
|
// (markers persist) rather than silently sailing a NaN into expirationTtl.
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
+
// KV-less the option is dead config — no markers to expire, and the
|
|
513
|
+
// retention cap it used to imply is KV-conditional (putShell) — so it is
|
|
514
|
+
// dropped without the KV-floor validation/warning, which would misdirect.
|
|
515
|
+
this.tagInvalidationTtl = options.kv
|
|
516
|
+
? this.sanitizeTagInvalidationTtl(options.tagInvalidationTtl)
|
|
517
|
+
: undefined;
|
|
512
518
|
// tagCacheTtl gates the L1 marker cache via `> 0`. A non-finite value (NaN
|
|
513
519
|
// from `Number(env.UNSET)`) is not null/undefined, so `?? 0` would let it
|
|
514
520
|
// through and silently disable the cache while reading as "configured".
|
|
@@ -1876,40 +1882,75 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1876
1882
|
}
|
|
1877
1883
|
|
|
1878
1884
|
// ============================================================================
|
|
1879
|
-
// Shell Cache Methods (PPR shell resume) — Cache API L1 + KV L2
|
|
1885
|
+
// Shell Cache Methods (PPR shell resume) — Cache API L1 + optional KV L2
|
|
1880
1886
|
// ============================================================================
|
|
1881
1887
|
//
|
|
1882
|
-
// KV
|
|
1883
|
-
//
|
|
1884
|
-
//
|
|
1885
|
-
//
|
|
1888
|
+
// With KV it is the durable, cross-colo shell tier: writes populate both
|
|
1889
|
+
// tiers, a valid KV hit promotes the same coupled envelope into L1, and
|
|
1890
|
+
// shell L1 hits deliberately keep the KV generation-marker check even in
|
|
1891
|
+
// purge mode. A shell's taggedAt is its CAPTURE START, not its write time:
|
|
1892
|
+
// an invalidation can purge while an older capture is still running, then
|
|
1893
|
+
// that capture can land after the purge. The marker check rejects that
|
|
1894
|
+
// resurrection.
|
|
1886
1895
|
//
|
|
1887
|
-
//
|
|
1888
|
-
//
|
|
1889
|
-
//
|
|
1890
|
-
//
|
|
1896
|
+
// Without KV the family is L1-only (edge-only ppr): every colo captures and
|
|
1897
|
+
// serves its own shell from the Cache API. Tag eviction then needs purge
|
|
1898
|
+
// mode (tagPurge) — shell L1 entries carry the same namespaced Cache-Tag
|
|
1899
|
+
// tokens as the data families, read-your-own-writes comes from the
|
|
1900
|
+
// per-request marker memo, and the capture resurrection race narrows to
|
|
1901
|
+
// cross-request timing bounded by ttl+swr (the data families' documented
|
|
1902
|
+
// purge-mode stance). KV-less WITHOUT tagPurge still caches: freshness is
|
|
1903
|
+
// ttl/swr only, and a tagged write warns once that invalidation cannot
|
|
1904
|
+
// reach it (see warnShellTagsNoEvictionOnce).
|
|
1891
1905
|
|
|
1892
1906
|
/**
|
|
1893
|
-
* Warn once per isolate that
|
|
1894
|
-
*
|
|
1895
|
-
*
|
|
1896
|
-
*
|
|
1897
|
-
*
|
|
1898
|
-
*
|
|
1907
|
+
* Warn once per isolate that a TAGGED shell landed on a store with no
|
|
1908
|
+
* eviction path for it: no KV (markers) and no tagPurge (purge-by-tag).
|
|
1909
|
+
* The shell still caches and expires by ttl+swr, but updateTag()/
|
|
1910
|
+
* revalidateTag() cannot reach it — silent staleness a consumer who tagged
|
|
1911
|
+
* the route clearly did not intend. Fired from putShell (not the
|
|
1912
|
+
* constructor) so an untagged edge-only ppr config stays warning-free.
|
|
1899
1913
|
* @internal
|
|
1900
1914
|
*/
|
|
1901
|
-
private
|
|
1915
|
+
private warnShellTagsNoEvictionOnce(): void {
|
|
1902
1916
|
this.warnOncePerNamespace(
|
|
1903
|
-
|
|
1904
|
-
`[CFCacheStore] a ppr
|
|
1905
|
-
`
|
|
1906
|
-
`
|
|
1907
|
-
`
|
|
1908
|
-
`
|
|
1909
|
-
`
|
|
1917
|
+
warnedShellTagsNoEviction,
|
|
1918
|
+
`[CFCacheStore] a ppr shell with tags was stored on a KV-less store ` +
|
|
1919
|
+
`without tagPurge: tag invalidation cannot evict it (no KV markers, ` +
|
|
1920
|
+
`no purge-by-tag), so updateTag()/revalidateTag() will not reach ` +
|
|
1921
|
+
`this shell — it serves until ttl+swr expiry. Configure { kv } for ` +
|
|
1922
|
+
`marker invalidation or { tagPurge } for purge-by-tag eviction; ` +
|
|
1923
|
+
`untagged ppr routes (ttl/swr freshness) are unaffected.`,
|
|
1910
1924
|
);
|
|
1911
1925
|
}
|
|
1912
1926
|
|
|
1927
|
+
/**
|
|
1928
|
+
* Generation gate for shell writes and the capture scheduler
|
|
1929
|
+
* (isTagsInvalidatedSince). With KV it is the durable marker cascade.
|
|
1930
|
+
* Without KV there are no markers: in purge mode the per-request memo is
|
|
1931
|
+
* the only signal — a capture racing THIS request's updateTag() is still
|
|
1932
|
+
* rejected (read-your-own-writes), while cross-request races are bounded
|
|
1933
|
+
* by ttl+swr exactly like the data families' purge-mode writes. Without
|
|
1934
|
+
* either, fail open (ttl/swr-only semantics, warned at putShell).
|
|
1935
|
+
* @internal
|
|
1936
|
+
*/
|
|
1937
|
+
private async isShellGenerationInvalidated(
|
|
1938
|
+
tags: string[] | undefined,
|
|
1939
|
+
since: number | undefined,
|
|
1940
|
+
): Promise<boolean> {
|
|
1941
|
+
if (this.kv) return this.isGloballyInvalidated(tags, since);
|
|
1942
|
+
if (!this.tagPurge || !Array.isArray(tags) || tags.length === 0 || !since)
|
|
1943
|
+
return false;
|
|
1944
|
+
const ctx = _getRequestContext();
|
|
1945
|
+
if (!ctx) return false;
|
|
1946
|
+
const memo = getTagMarkerMemo(ctx, this);
|
|
1947
|
+
for (const tag of tags) {
|
|
1948
|
+
const marker = memo.get(tag);
|
|
1949
|
+
if (marker != null && marker >= since) return true;
|
|
1950
|
+
}
|
|
1951
|
+
return false;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1913
1954
|
/**
|
|
1914
1955
|
* Get a cached PPR shell entry from Cache API, falling through to KV and
|
|
1915
1956
|
* promoting a valid KV hit. Both tiers store one envelope so the prelude,
|
|
@@ -1920,10 +1961,6 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1920
1961
|
async getShell(
|
|
1921
1962
|
key: string,
|
|
1922
1963
|
): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
|
|
1923
|
-
if (!this.kv) {
|
|
1924
|
-
this.warnShellFamilyInertOnce();
|
|
1925
|
-
return null;
|
|
1926
|
-
}
|
|
1927
1964
|
try {
|
|
1928
1965
|
const cache = await this.getCache();
|
|
1929
1966
|
const request = this.keyToRequest(`shell:${key}`);
|
|
@@ -2005,10 +2042,17 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2005
2042
|
return this.kvGetShell(key);
|
|
2006
2043
|
}
|
|
2007
2044
|
|
|
2008
|
-
// Unlike other L1 families, shells always check the durable
|
|
2009
|
-
// marker
|
|
2045
|
+
// Unlike other L1 families, shells with KV always check the durable
|
|
2046
|
+
// generation marker — see the capture-start/purge race documented
|
|
2047
|
+
// above. Without KV there are no markers: L1-only shells adopt the
|
|
2048
|
+
// data families' purge-mode read semantics (a hit that survived the
|
|
2049
|
+
// purge is trusted; the per-request memo masks this request's own
|
|
2050
|
+
// updateTag() writes; entries a purge cannot reach fall back to the
|
|
2051
|
+
// marker check, which fails open KV-less).
|
|
2010
2052
|
const markerStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
|
|
2011
|
-
const invalidated =
|
|
2053
|
+
const invalidated = this.kv
|
|
2054
|
+
? await this.isGloballyInvalidated(value.t, value.ta)
|
|
2055
|
+
: await this.isL1Invalidated(value.t, value.ta, response.headers);
|
|
2012
2056
|
const markerMs = INTERNAL_RANGO_DEBUG
|
|
2013
2057
|
? Date.now() - markerStartedAt
|
|
2014
2058
|
: undefined;
|
|
@@ -2042,11 +2086,12 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2042
2086
|
}
|
|
2043
2087
|
|
|
2044
2088
|
/**
|
|
2045
|
-
* Store a PPR shell envelope in Cache API and, when
|
|
2046
|
-
* 60-second floor, KV. The shared write is
|
|
2047
|
-
* awaited so invalidation rejection can be
|
|
2048
|
-
* scheduler. Short-lived shells remain useful
|
|
2049
|
-
* them
|
|
2089
|
+
* Store a PPR shell envelope in Cache API and, when KV is configured and
|
|
2090
|
+
* the retention meets its 60-second floor, KV. The shared write is
|
|
2091
|
+
* registered with waitUntil and awaited so invalidation rejection can be
|
|
2092
|
+
* acknowledged to the capture scheduler. Short-lived shells remain useful
|
|
2093
|
+
* in L1 even though KV rejects them; a KV-less store is L1-only by design
|
|
2094
|
+
* (edge-only ppr — see the section comment).
|
|
2050
2095
|
*/
|
|
2051
2096
|
async putShell(
|
|
2052
2097
|
key: string,
|
|
@@ -2054,20 +2099,29 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2054
2099
|
ttlSeconds?: number,
|
|
2055
2100
|
swrSeconds?: number,
|
|
2056
2101
|
tags?: string[],
|
|
2057
|
-
): Promise<"stored" | "invalidated" | void> {
|
|
2058
|
-
// KV remains required for durable generation markers and cross-colo reads.
|
|
2059
|
-
if (!this.kv) {
|
|
2060
|
-
this.warnShellFamilyInertOnce();
|
|
2061
|
-
return;
|
|
2062
|
-
}
|
|
2102
|
+
): Promise<"stored" | "invalidated" | "uncacheable" | void> {
|
|
2063
2103
|
if (!this.waitUntil) return;
|
|
2104
|
+
// Same write gate as the data families: in KV-less purge mode an
|
|
2105
|
+
// over-limit tag set has NO eviction path (no tokens, no markers), so the
|
|
2106
|
+
// shell is not cached rather than becoming un-invalidatable. Unlike the
|
|
2107
|
+
// void-returning data puts, this is ACKNOWLEDGED — the capture scheduler
|
|
2108
|
+
// must back the key off (every write would refuse identically), not
|
|
2109
|
+
// treat the capture as stored and re-render on every MISS.
|
|
2110
|
+
if (this.skipUncacheableTagSet(tags)) return "uncacheable";
|
|
2111
|
+
if (!this.kv && !this.tagPurge && Array.isArray(tags) && tags.length > 0) {
|
|
2112
|
+
this.warnShellTagsNoEvictionOnce();
|
|
2113
|
+
}
|
|
2064
2114
|
try {
|
|
2065
2115
|
const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
|
|
2066
2116
|
const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
|
|
2067
2117
|
const totalTtl = ttl + swrWindow;
|
|
2068
2118
|
|
|
2119
|
+
// The tagInvalidationTtl cap exists so a tagged entry can never outlive
|
|
2120
|
+
// its KV markers (an expired marker would resurrect it). Without KV
|
|
2121
|
+
// there are no markers to outlive — capping would just hard-expire the
|
|
2122
|
+
// shell below its declared ttl+swr — so the cap is KV-conditional.
|
|
2069
2123
|
const retentionTtl =
|
|
2070
|
-
tags && tags.length > 0 && this.tagInvalidationTtl
|
|
2124
|
+
tags && tags.length > 0 && this.kv && this.tagInvalidationTtl
|
|
2071
2125
|
? Math.min(totalTtl, this.tagInvalidationTtl)
|
|
2072
2126
|
: totalTtl;
|
|
2073
2127
|
const now = Date.now();
|
|
@@ -2076,14 +2130,14 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2076
2130
|
const taggedAt =
|
|
2077
2131
|
Array.isArray(tags) && tags.length > 0 ? entry.createdAt : undefined;
|
|
2078
2132
|
|
|
2079
|
-
const
|
|
2080
|
-
const
|
|
2133
|
+
const writeKv = !!this.kv && retentionTtl >= KV_MIN_EXPIRATION_TTL;
|
|
2134
|
+
const kvKey = writeKv ? await this.toKVKey(`shell:${key}`) : null;
|
|
2081
2135
|
|
|
2082
2136
|
const write = (async (): Promise<"stored" | "invalidated" | void> => {
|
|
2083
2137
|
if (
|
|
2084
2138
|
tags &&
|
|
2085
2139
|
tags.length > 0 &&
|
|
2086
|
-
(await this.
|
|
2140
|
+
(await this.isShellGenerationInvalidated(tags, entry.createdAt))
|
|
2087
2141
|
) {
|
|
2088
2142
|
this.debugShell(key, "write-invalidated");
|
|
2089
2143
|
return "invalidated";
|
|
@@ -2132,7 +2186,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2132
2186
|
}
|
|
2133
2187
|
})(),
|
|
2134
2188
|
];
|
|
2135
|
-
if (writeKv) {
|
|
2189
|
+
if (writeKv && kvKey !== null) {
|
|
2136
2190
|
writes.push(
|
|
2137
2191
|
(async () => {
|
|
2138
2192
|
try {
|
|
@@ -3029,13 +3083,15 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
3029
3083
|
* Shell tag-generation gate (SegmentCacheStore.isTagsInvalidatedSince): the
|
|
3030
3084
|
* SAME KV markers used by runtime envelopes also evict immutable build shells
|
|
3031
3085
|
* and captures whose write races updateTag(). Thin public wrapper over the
|
|
3032
|
-
*
|
|
3086
|
+
* shell generation check (marker >= since, fail open); KV-less it degrades
|
|
3087
|
+
* to the per-request memo in purge mode and to false otherwise — see
|
|
3088
|
+
* isShellGenerationInvalidated.
|
|
3033
3089
|
*/
|
|
3034
3090
|
async isTagsInvalidatedSince(
|
|
3035
3091
|
tags: string[],
|
|
3036
3092
|
sinceMs: number,
|
|
3037
3093
|
): Promise<boolean> {
|
|
3038
|
-
return this.
|
|
3094
|
+
return this.isShellGenerationInvalidated(tags, sinceMs);
|
|
3039
3095
|
}
|
|
3040
3096
|
|
|
3041
3097
|
async invalidateTags(tags: string[]): Promise<void> {
|
|
@@ -250,7 +250,7 @@ export class RecordingShellStore<
|
|
|
250
250
|
ttlSeconds?: number,
|
|
251
251
|
swrSeconds?: number,
|
|
252
252
|
tags?: string[],
|
|
253
|
-
): Promise<"stored" | "invalidated" | void> {
|
|
253
|
+
): Promise<"stored" | "invalidated" | "uncacheable" | void> {
|
|
254
254
|
return this.inner.putShell?.(key, entry, ttlSeconds, swrSeconds, tags);
|
|
255
255
|
}
|
|
256
256
|
|
|
@@ -506,7 +506,7 @@ export class SeededShellStore<
|
|
|
506
506
|
ttlSeconds?: number,
|
|
507
507
|
swrSeconds?: number,
|
|
508
508
|
tags?: string[],
|
|
509
|
-
): Promise<"stored" | "invalidated" | void> {
|
|
509
|
+
): Promise<"stored" | "invalidated" | "uncacheable" | void> {
|
|
510
510
|
return this.inner.putShell?.(key, entry, ttlSeconds, swrSeconds, tags);
|
|
511
511
|
}
|
|
512
512
|
|
package/src/cache/types.ts
CHANGED
|
@@ -188,7 +188,12 @@ export interface SegmentCacheStore<TEnv = unknown> {
|
|
|
188
188
|
* @param tags - Optional cache tags for invalidation (participates in
|
|
189
189
|
* invalidateTags via the same tag machinery as the item family)
|
|
190
190
|
* @returns `invalidated` when a generation marker rejected the write,
|
|
191
|
-
* `stored` when acknowledged,
|
|
191
|
+
* `stored` when acknowledged, `uncacheable` when the entry can NEVER be
|
|
192
|
+
* stored under the current configuration (every retry would refuse
|
|
193
|
+
* identically — the capture scheduler backs the key off instead of
|
|
194
|
+
* recapturing per MISS; CFCacheStore returns it for a tag set whose
|
|
195
|
+
* Cache-Tag header overflows in KV-less purge mode), or void for stores
|
|
196
|
+
* without acknowledgements.
|
|
192
197
|
*/
|
|
193
198
|
putShell?(
|
|
194
199
|
key: string,
|
|
@@ -196,18 +201,33 @@ export interface SegmentCacheStore<TEnv = unknown> {
|
|
|
196
201
|
ttlSeconds?: number,
|
|
197
202
|
swrSeconds?: number,
|
|
198
203
|
tags?: string[],
|
|
199
|
-
): Promise<"stored" | "invalidated" | void>;
|
|
204
|
+
): Promise<"stored" | "invalidated" | "uncacheable" | void>;
|
|
200
205
|
|
|
201
206
|
/**
|
|
202
207
|
* Declares the shell family present-but-inert: getShell/putShell exist but
|
|
203
|
-
* no-op (
|
|
204
|
-
* captures whose only write target is inert —
|
|
205
|
-
* dead work that still occupies the
|
|
206
|
-
* (a promise-heavy route bakes for
|
|
207
|
-
* Absent/false means the family,
|
|
208
|
+
* no-op (a custom store whose backing tier is conditionally unavailable).
|
|
209
|
+
* scheduleShellCapture skips captures whose only write target is inert —
|
|
210
|
+
* the background render would be dead work that still occupies the
|
|
211
|
+
* per-isolate serialized capture queue (a promise-heavy route bakes for
|
|
212
|
+
* seconds per MISS with nothing stored). Absent/false means the family,
|
|
213
|
+
* when present, actually stores. The built-in stores never declare it:
|
|
214
|
+
* CFCacheStore is L1-only without KV (edge-only ppr), not inert.
|
|
208
215
|
*/
|
|
209
216
|
shellFamilyInert?: boolean;
|
|
210
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Declares isTagsInvalidatedSince present-but-inert: the store implements
|
|
220
|
+
* the method but has no DURABLE invalidation history behind it (a KV-less
|
|
221
|
+
* CFCacheStore answers from the per-request memo at best). Runtime shells
|
|
222
|
+
* tolerate that — purge eviction plus ttl/swr bound their staleness — but
|
|
223
|
+
* a TAGGED build-manifest shell is immutable with no ttl of its own, so
|
|
224
|
+
* serving it on such a store would make updateTag() a permanent no-op for
|
|
225
|
+
* it. The build-shell read-through declines tagged entries on this flag
|
|
226
|
+
* (same declared-intent-cannot-be-honored doctrine as a store missing the
|
|
227
|
+
* method entirely). Absent/false means answers are durably backed.
|
|
228
|
+
*/
|
|
229
|
+
tagHistoryInert?: boolean;
|
|
230
|
+
|
|
211
231
|
/**
|
|
212
232
|
* Get a cached function result by key.
|
|
213
233
|
* Returns the serialized value, optional handle data, and staleness flag.
|
|
@@ -306,20 +306,27 @@ export async function lookupBuildShell(
|
|
|
306
306
|
const entry = record.entry;
|
|
307
307
|
if (record.tags && record.tags.length > 0) {
|
|
308
308
|
const check = store.isTagsInvalidatedSince;
|
|
309
|
-
if (typeof check !== "function") {
|
|
309
|
+
if (typeof check !== "function" || store.tagHistoryInert) {
|
|
310
310
|
// A tagged build entry on a store that cannot answer "was this tag
|
|
311
311
|
// invalidated since the build" must not serve: updateTag() could
|
|
312
|
-
// never evict it.
|
|
312
|
+
// never evict it. That covers a store missing the method AND one
|
|
313
|
+
// whose answers have no durable history behind them
|
|
314
|
+
// (SegmentCacheStore.tagHistoryInert — a KV-less CFCacheStore's
|
|
315
|
+
// memo-only answers would let the immutable asset resurrect on the
|
|
316
|
+
// next request). Declared intent that cannot be honored deserves a
|
|
313
317
|
// diagnostic; the route keeps runtime-capture semantics.
|
|
314
318
|
const key = buildShellManifestKey(url.pathname);
|
|
315
319
|
if (!warnedTagCheckUnsupported.has(key)) {
|
|
316
320
|
warnedTagCheckUnsupported.add(key);
|
|
317
321
|
console.warn(
|
|
318
322
|
`[rango] Build-time shell for "${url.pathname}" carries cache tags, but ` +
|
|
319
|
-
"the app cache store
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
+
"the app cache store cannot answer tag-invalidation history durably " +
|
|
324
|
+
(typeof check !== "function"
|
|
325
|
+
? "(isTagsInvalidatedSince() is not implemented), "
|
|
326
|
+
: "(no durable tag history — a CFCacheStore without a KV namespace), ") +
|
|
327
|
+
"so updateTag() could not evict it. The entry is not served; the route " +
|
|
328
|
+
"keeps runtime shell capture. Use MemorySegmentCacheStore, a KV-backed " +
|
|
329
|
+
"CFCacheStore, or VercelCacheStore (or add the method to your custom store).",
|
|
323
330
|
);
|
|
324
331
|
}
|
|
325
332
|
return null;
|
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -509,9 +509,10 @@ export interface ShellCaptureDebugEvent {
|
|
|
509
509
|
* the capture was not attempted
|
|
510
510
|
* - skip-capacity: the isolate capture queue is full; a later request may retry
|
|
511
511
|
* - skip-inert-store: the resolved store's shell family is missing or
|
|
512
|
-
* declared inert (SegmentCacheStore.shellFamilyInert — a
|
|
513
|
-
*
|
|
514
|
-
* background render was not
|
|
512
|
+
* declared inert (SegmentCacheStore.shellFamilyInert — a custom store
|
|
513
|
+
* whose backing tier is unavailable; built-in stores never declare it);
|
|
514
|
+
* nothing could store the result, so the background render was not
|
|
515
|
+
* scheduled at all
|
|
515
516
|
* - skip-queue-timeout: the capture waited past CAPTURE_QUEUE_WAIT_BUDGET_MS
|
|
516
517
|
* behind other captures and was dropped unrun (no backoff — the route is
|
|
517
518
|
* not doomed, the isolate was busy; a later request re-probes). Carries
|
|
@@ -554,7 +555,7 @@ export interface ShellCaptureDebugEvent {
|
|
|
554
555
|
/** A bake-lane loader settled into a shell that uses TTL/SWR-only invalidation. */
|
|
555
556
|
untaggedBake?: true;
|
|
556
557
|
/** Outcome reported by a store that supports shell-write acknowledgements. */
|
|
557
|
-
storeWrite?: "stored" | "invalidated";
|
|
558
|
+
storeWrite?: "stored" | "invalidated" | "uncacheable";
|
|
558
559
|
/** Consecutive failure count in the key's backoff entry, when one exists. */
|
|
559
560
|
backoffFailures?: number;
|
|
560
561
|
/** Ms remaining in the key's backoff window, when one exists. */
|
|
@@ -1005,7 +1006,8 @@ export function scheduleShellCapture(
|
|
|
1005
1006
|
// serialized queue (a promise-heavy route bakes for seconds per MISS),
|
|
1006
1007
|
// starving captures that CAN store. Skip scheduling entirely when the
|
|
1007
1008
|
// resolved store (same defaulting as captureAndStoreShell) has no shell
|
|
1008
|
-
// family or declared it inert (
|
|
1009
|
+
// family or declared it inert (a custom-store escape hatch; built-in
|
|
1010
|
+
// stores never declare it — a KV-less CFCacheStore stores L1-only).
|
|
1009
1011
|
const scheduledStore = descriptor.store ?? reqCtx._cacheStore;
|
|
1010
1012
|
if (!scheduledStore?.putShell || scheduledStore.shellFamilyInert) {
|
|
1011
1013
|
publishCaptureDebugEvent(descriptor, { key, outcome: "skip-inert-store" });
|
|
@@ -2028,6 +2030,21 @@ async function captureAndStoreShell(
|
|
|
2028
2030
|
);
|
|
2029
2031
|
return "refused";
|
|
2030
2032
|
}
|
|
2033
|
+
if (storeWrite === "uncacheable") {
|
|
2034
|
+
// The store declared the entry unstorable under its current
|
|
2035
|
+
// configuration — every retry would refuse identically, so back
|
|
2036
|
+
// the key off like any refused capture instead of burning a full
|
|
2037
|
+
// serialized render per MISS. Store-neutral on purpose: the
|
|
2038
|
+
// acknowledging store emits its own diagnostic naming the specific
|
|
2039
|
+
// limit (CFCacheStore warns about the over-limit Cache-Tag set).
|
|
2040
|
+
warnCaptureRefusedOnce(
|
|
2041
|
+
capture.key,
|
|
2042
|
+
"the store cannot cache this shell under its current configuration and acknowledged " +
|
|
2043
|
+
"the write as permanently refusable, so the key is backed off. See the cache store's " +
|
|
2044
|
+
"own warning for the specific limit and remedy.",
|
|
2045
|
+
);
|
|
2046
|
+
return "refused";
|
|
2047
|
+
}
|
|
2031
2048
|
} catch (error) {
|
|
2032
2049
|
// Best-effort: a failed put must never throw out of the background task.
|
|
2033
2050
|
reportCacheError(
|