@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
package/src/cache/cf/index.ts
CHANGED
|
@@ -13,13 +13,27 @@
|
|
|
13
13
|
export {
|
|
14
14
|
CFCacheStore,
|
|
15
15
|
type CFCacheStoreOptions,
|
|
16
|
+
type CFCacheDebug,
|
|
17
|
+
type CFCacheReadDebugEvent,
|
|
16
18
|
type KVNamespace,
|
|
17
19
|
} from "./cf-cache-store.js";
|
|
18
20
|
|
|
19
|
-
// Header constants for debugging and inspection
|
|
21
|
+
// Header constants for debugging and inspection. The tag headers
|
|
22
|
+
// (x-edge-cache-tags / x-edge-cache-tagged-at) are intentionally NOT re-exported:
|
|
23
|
+
// they are an internal encoding detail of the store's tag-invalidation check, not
|
|
24
|
+
// a consumer-inspectable contract.
|
|
20
25
|
export {
|
|
21
26
|
CACHE_STALE_AT_HEADER,
|
|
22
27
|
CACHE_STATUS_HEADER,
|
|
28
|
+
CACHE_REVALIDATING_AT_HEADER,
|
|
29
|
+
} from "./cf-cache-store.js";
|
|
30
|
+
|
|
31
|
+
// Default latency-budget values, exported so the CFCacheStoreOptions JSDoc
|
|
32
|
+
// {@link}s resolve and consumers can derive margins from the defaults.
|
|
33
|
+
export {
|
|
34
|
+
EDGE_LOOKUP_TIMEOUT_MS,
|
|
35
|
+
EDGE_READ_TIMEOUT_MS,
|
|
36
|
+
KV_READ_TIMEOUT_MS,
|
|
23
37
|
} from "./cf-cache-store.js";
|
|
24
38
|
|
|
25
39
|
// Internal exports (re-exported for backwards compatibility, marked @internal in source)
|
|
@@ -12,10 +12,15 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { MiddlewareFn, MiddlewareContext } from "../router/middleware.js";
|
|
15
|
-
import {
|
|
15
|
+
import { hasPerClientSignal } from "../browser/cookie-name.js";
|
|
16
|
+
import {
|
|
17
|
+
getRequestContext,
|
|
18
|
+
type RequestContext,
|
|
19
|
+
} from "../server/request-context.js";
|
|
16
20
|
import { mayNeedSSR } from "../rsc/ssr-setup.js";
|
|
17
21
|
import { sortedSearchString } from "./cache-key-utils.js";
|
|
18
22
|
import { runBackground } from "./background-task.js";
|
|
23
|
+
import { reportCacheError } from "./cache-error.js";
|
|
19
24
|
|
|
20
25
|
// ============================================================================
|
|
21
26
|
// Constants
|
|
@@ -24,6 +29,36 @@ import { runBackground } from "./background-task.js";
|
|
|
24
29
|
/** Header indicating cache status for debugging */
|
|
25
30
|
const CACHE_STATUS_HEADER = "x-document-cache-status";
|
|
26
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Snapshot the request-scoped tag union for a document cache write. The full-page
|
|
34
|
+
* entry is tagged with every cache tag its content resolved (runtime cacheTag(),
|
|
35
|
+
* "use cache" profile tags, and loader cache tags) so updateTag()/revalidateTag()
|
|
36
|
+
* can invalidate it. Returns undefined when no tags were used, keeping untagged
|
|
37
|
+
* document entries header-free.
|
|
38
|
+
*
|
|
39
|
+
* This is a plain synchronous snapshot. The CALLER must drain the rendered body
|
|
40
|
+
* first (see the cache-write closures): runtime cacheTag()/"use cache" and loader
|
|
41
|
+
* tags are recorded synchronously as each value resolves during render, including
|
|
42
|
+
* Suspense-streamed ones that resolve AFTER the handler-settlement barrier - so
|
|
43
|
+
* the correct barrier is the stream draining (render complete), not _handleStore.
|
|
44
|
+
*
|
|
45
|
+
* Caveat: this applies only to the segment cache WRITE path. When a segment is
|
|
46
|
+
* cached for the first time, its cache({ tags }) DSL tags are recorded inside the
|
|
47
|
+
* deferred cacheRoute waitUntil, which can still run after this snapshot; a
|
|
48
|
+
* document that combines whole-page document caching with first-write segment-DSL
|
|
49
|
+
* tags may miss those (the segment cache entry itself is still correctly tagged
|
|
50
|
+
* and invalidated). On a segment-cache HIT the entry's tags are recorded
|
|
51
|
+
* synchronously during lookupRoute, before this snapshot, so they are captured.
|
|
52
|
+
* Runtime cacheTag()/"use cache" and loader tags are always captured once the
|
|
53
|
+
* body drains.
|
|
54
|
+
*/
|
|
55
|
+
function collectRequestTags(
|
|
56
|
+
requestCtx: RequestContext | undefined,
|
|
57
|
+
): string[] | undefined {
|
|
58
|
+
const tags = requestCtx?._requestTags;
|
|
59
|
+
return tags && tags.size > 0 ? [...tags] : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
27
62
|
/**
|
|
28
63
|
* Simple hash function for segment IDs.
|
|
29
64
|
* Creates a short, deterministic hash to differentiate cache keys
|
|
@@ -87,6 +122,16 @@ function shouldCacheResponse(response: Response): CacheDirectives | null {
|
|
|
87
122
|
return null;
|
|
88
123
|
}
|
|
89
124
|
|
|
125
|
+
// Never cache a per-client signal into a SHARED response store. A Set-Cookie
|
|
126
|
+
// (e.g. a rango state rotation from invalidateClientCache(), or any cookie a
|
|
127
|
+
// loader set) would be replayed to every client on a hit — pinning them to
|
|
128
|
+
// one value and even rolling a rotated client back to a prior one. The
|
|
129
|
+
// x-rango-keep-cache directive header is the mirror image: a replayed "keep"
|
|
130
|
+
// would suppress invalidation for every replayed client. Refuse both.
|
|
131
|
+
if (hasPerClientSignal(response.headers)) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
90
135
|
const cacheControl = response.headers.get("Cache-Control");
|
|
91
136
|
return parseCacheControl(cacheControl);
|
|
92
137
|
}
|
|
@@ -303,17 +348,26 @@ export function createDocumentCacheMiddleware<TEnv = any>(
|
|
|
303
348
|
const fresh = await next();
|
|
304
349
|
const directives = shouldCacheResponse(fresh);
|
|
305
350
|
|
|
306
|
-
if (directives) {
|
|
351
|
+
if (directives && fresh.body) {
|
|
352
|
+
// Background revalidation: nothing streams to a client, so drain
|
|
353
|
+
// the fresh render fully before snapshotting tags (same
|
|
354
|
+
// render-complete barrier as the miss path).
|
|
355
|
+
const body = await new Response(fresh.body).arrayBuffer();
|
|
307
356
|
await store.putResponse!(
|
|
308
357
|
cacheKey,
|
|
309
|
-
fresh,
|
|
358
|
+
new Response(body, fresh),
|
|
310
359
|
directives.sMaxAge!,
|
|
311
360
|
directives.staleWhileRevalidate,
|
|
361
|
+
collectRequestTags(requestCtx),
|
|
312
362
|
);
|
|
313
363
|
log(`[DocumentCache] REVALIDATED ${typeLabel}: ${url.pathname}`);
|
|
314
364
|
}
|
|
315
365
|
} catch (error) {
|
|
316
|
-
|
|
366
|
+
reportCacheError(
|
|
367
|
+
error,
|
|
368
|
+
"cache-write",
|
|
369
|
+
"[DocumentCache] revalidation",
|
|
370
|
+
);
|
|
317
371
|
}
|
|
318
372
|
});
|
|
319
373
|
|
|
@@ -346,14 +400,27 @@ export function createDocumentCacheMiddleware<TEnv = any>(
|
|
|
346
400
|
// Clone response for caching (non-blocking)
|
|
347
401
|
runBackground(requestCtx, async () => {
|
|
348
402
|
try {
|
|
403
|
+
// Drain the cache copy fully BEFORE snapshotting tags. Tags from
|
|
404
|
+
// Suspense-streamed "use cache"/cacheTag and loaders are recorded as
|
|
405
|
+
// each value resolves during the RSC/HTML render, which completes
|
|
406
|
+
// only when the stream ends - the handler-settlement barrier is too
|
|
407
|
+
// early. Buffering the body (the client streams the other tee branch,
|
|
408
|
+
// unaffected) is the render-complete barrier that keeps the cached
|
|
409
|
+
// body and its tag set consistent.
|
|
410
|
+
const body = await new Response(cacheStream).arrayBuffer();
|
|
349
411
|
await store.putResponse!(
|
|
350
412
|
cacheKey,
|
|
351
|
-
new Response(
|
|
413
|
+
new Response(body, originalResponse),
|
|
352
414
|
directives.sMaxAge!,
|
|
353
415
|
directives.staleWhileRevalidate,
|
|
416
|
+
collectRequestTags(requestCtx),
|
|
354
417
|
);
|
|
355
418
|
} catch (error) {
|
|
356
|
-
|
|
419
|
+
reportCacheError(
|
|
420
|
+
error,
|
|
421
|
+
"cache-write",
|
|
422
|
+
"[DocumentCache] cache write",
|
|
423
|
+
);
|
|
357
424
|
}
|
|
358
425
|
});
|
|
359
426
|
|
|
@@ -366,7 +433,7 @@ export function createDocumentCacheMiddleware<TEnv = any>(
|
|
|
366
433
|
// No cache headers - pass through
|
|
367
434
|
return originalResponse;
|
|
368
435
|
} catch (error) {
|
|
369
|
-
|
|
436
|
+
reportCacheError(error, "cache-read", "[DocumentCache] middleware");
|
|
370
437
|
if (handlerCalled) {
|
|
371
438
|
// Post-handler failure (e.g. body.tee()): do not call next() again
|
|
372
439
|
// as that would re-run handler side effects.
|
package/src/cache/index.ts
CHANGED
|
@@ -17,6 +17,11 @@ export type {
|
|
|
17
17
|
CachedEntryData,
|
|
18
18
|
CachedEntryResult,
|
|
19
19
|
CacheGetResult,
|
|
20
|
+
// The getItem()/setItem() signature types on SegmentCacheStore. Exported
|
|
21
|
+
// alongside CacheGetResult so a consumer implementing a custom store can name
|
|
22
|
+
// every type its interface methods use, not just the segment-read result.
|
|
23
|
+
CacheItemResult,
|
|
24
|
+
CacheItemOptions,
|
|
20
25
|
SerializedSegmentData,
|
|
21
26
|
SegmentHandleData,
|
|
22
27
|
CacheConfig,
|
|
@@ -29,9 +34,15 @@ export { MemorySegmentCacheStore } from "./memory-segment-store.js";
|
|
|
29
34
|
export {
|
|
30
35
|
CFCacheStore,
|
|
31
36
|
type CFCacheStoreOptions,
|
|
37
|
+
type CFCacheDebug,
|
|
38
|
+
type CFCacheReadDebugEvent,
|
|
32
39
|
type KVNamespace,
|
|
33
40
|
CACHE_STALE_AT_HEADER,
|
|
34
41
|
CACHE_STATUS_HEADER,
|
|
42
|
+
CACHE_REVALIDATING_AT_HEADER,
|
|
43
|
+
EDGE_LOOKUP_TIMEOUT_MS,
|
|
44
|
+
EDGE_READ_TIMEOUT_MS,
|
|
45
|
+
KV_READ_TIMEOUT_MS,
|
|
35
46
|
} from "./cf/index.js";
|
|
36
47
|
|
|
37
48
|
// Cache scope
|
|
@@ -42,3 +53,9 @@ export {
|
|
|
42
53
|
createDocumentCacheMiddleware,
|
|
43
54
|
type DocumentCacheOptions,
|
|
44
55
|
} from "./document-cache.js";
|
|
56
|
+
|
|
57
|
+
// Cache error reporting. CacheErrorCategory is the discriminator surfaced to a
|
|
58
|
+
// router's onError callback as `metadata.category` for the `cache` phase, so
|
|
59
|
+
// consumers can branch on the failure kind (e.g. distinguish a transient
|
|
60
|
+
// cache-read outage from cache-corrupt self-heal).
|
|
61
|
+
export type { CacheErrorCategory } from "./cache-error.js";
|
|
@@ -14,16 +14,20 @@ import type {
|
|
|
14
14
|
CacheItemOptions,
|
|
15
15
|
} from "./types.js";
|
|
16
16
|
import type { RequestContext } from "../server/request-context.js";
|
|
17
|
+
import { isPerClientSignalHeader } from "../browser/cookie-name.js";
|
|
17
18
|
import {
|
|
18
19
|
resolveTtl,
|
|
19
20
|
resolveSwrWindow,
|
|
20
21
|
computeExpiration,
|
|
21
22
|
DEFAULT_FUNCTION_TTL,
|
|
22
23
|
} from "./cache-policy.js";
|
|
24
|
+
import { reportCacheError } from "./cache-error.js";
|
|
23
25
|
|
|
24
26
|
const CACHE_REGISTRY_KEY = "__rsc_router_segment_cache_registry__";
|
|
25
27
|
const RESPONSE_CACHE_REGISTRY_KEY = "__rsc_router_response_cache_registry__";
|
|
26
28
|
const ITEM_CACHE_REGISTRY_KEY = "__rsc_router_item_cache_registry__";
|
|
29
|
+
const TAG_INDEX_REGISTRY_KEY = "__rsc_router_tag_index_registry__";
|
|
30
|
+
const KEY_TAGS_REGISTRY_KEY = "__rsc_router_key_tags_registry__";
|
|
27
31
|
|
|
28
32
|
/**
|
|
29
33
|
* Get or create a named Map from a globalThis-backed registry.
|
|
@@ -60,6 +64,7 @@ interface CachedItemEntry {
|
|
|
60
64
|
handles?: string;
|
|
61
65
|
expiresAt: number;
|
|
62
66
|
staleAt: number;
|
|
67
|
+
tags?: string[];
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
/**
|
|
@@ -74,6 +79,11 @@ export interface MemorySegmentCacheStoreOptions<TEnv = unknown> {
|
|
|
74
79
|
* When omitted, the store uses a plain instance-level Map with no
|
|
75
80
|
* globalThis sharing, which is the safest default for isolation.
|
|
76
81
|
*
|
|
82
|
+
* Caveat: two instances constructed with the SAME name share all backing maps
|
|
83
|
+
* (data + tag index), but each keeps its OWN `defaults` and `keyGenerator` from
|
|
84
|
+
* its options - those are not shared. Use one instance per name, or keep the
|
|
85
|
+
* options identical, to avoid surprising divergence.
|
|
86
|
+
*
|
|
77
87
|
* @example
|
|
78
88
|
* ```typescript
|
|
79
89
|
* // Two named stores are isolated from each other
|
|
@@ -122,6 +132,11 @@ export interface MemorySegmentCacheStoreOptions<TEnv = unknown> {
|
|
|
122
132
|
* For production with multiple instances, use a distributed store
|
|
123
133
|
* like Cloudflare KV or Redis.
|
|
124
134
|
*
|
|
135
|
+
* Tag-index cleanup is lazy, mirroring the data maps: a tagged entry that
|
|
136
|
+
* expires but is never re-read or invalidated leaves its forward+reverse index
|
|
137
|
+
* entries resident until the key is reused or invalidated. This is bounded by
|
|
138
|
+
* the distinct-tag count and acceptable for a dev/single-instance store.
|
|
139
|
+
*
|
|
125
140
|
* @example
|
|
126
141
|
* ```typescript
|
|
127
142
|
* // Basic usage
|
|
@@ -144,6 +159,10 @@ export class MemorySegmentCacheStore<
|
|
|
144
159
|
private cache: Map<string, CachedEntryData>;
|
|
145
160
|
private responseCache: Map<string, CachedResponseEntry>;
|
|
146
161
|
private itemCache: Map<string, CachedItemEntry>;
|
|
162
|
+
/** tag -> set of prefixed cache keys (seg:key, res:key, item:key) */
|
|
163
|
+
private tagIndex: Map<string, Set<string>>;
|
|
164
|
+
/** prefixed cache key -> set of tags (reverse index for O(tags) unregister) */
|
|
165
|
+
private keyTags: Map<string, Set<string>>;
|
|
147
166
|
readonly defaults?: CacheDefaults;
|
|
148
167
|
readonly keyGenerator?: (
|
|
149
168
|
ctx: RequestContext<TEnv>,
|
|
@@ -166,11 +185,21 @@ export class MemorySegmentCacheStore<
|
|
|
166
185
|
ITEM_CACHE_REGISTRY_KEY,
|
|
167
186
|
options.name,
|
|
168
187
|
);
|
|
188
|
+
this.tagIndex = getNamedMap<Set<string>>(
|
|
189
|
+
TAG_INDEX_REGISTRY_KEY,
|
|
190
|
+
options.name,
|
|
191
|
+
);
|
|
192
|
+
this.keyTags = getNamedMap<Set<string>>(
|
|
193
|
+
KEY_TAGS_REGISTRY_KEY,
|
|
194
|
+
options.name,
|
|
195
|
+
);
|
|
169
196
|
} else {
|
|
170
197
|
// Unnamed stores get a plain instance-level Map (no globalThis sharing).
|
|
171
198
|
this.cache = new Map<string, CachedEntryData>();
|
|
172
199
|
this.responseCache = new Map<string, CachedResponseEntry>();
|
|
173
200
|
this.itemCache = new Map<string, CachedItemEntry>();
|
|
201
|
+
this.tagIndex = new Map<string, Set<string>>();
|
|
202
|
+
this.keyTags = new Map<string, Set<string>>();
|
|
174
203
|
}
|
|
175
204
|
this.defaults = options?.defaults;
|
|
176
205
|
this.keyGenerator = options?.keyGenerator;
|
|
@@ -185,6 +214,7 @@ export class MemorySegmentCacheStore<
|
|
|
185
214
|
|
|
186
215
|
// Check expiration
|
|
187
216
|
if (Date.now() > cached.expiresAt) {
|
|
217
|
+
this.unregisterTags(`seg:${key}`);
|
|
188
218
|
this.cache.delete(key);
|
|
189
219
|
return null;
|
|
190
220
|
}
|
|
@@ -205,10 +235,18 @@ export class MemorySegmentCacheStore<
|
|
|
205
235
|
...data,
|
|
206
236
|
expiresAt: Date.now() + ttl * 1000,
|
|
207
237
|
};
|
|
238
|
+
const prefixedKey = `seg:${key}`;
|
|
239
|
+
// Always drop stale tag mappings before writing so an overwrite with
|
|
240
|
+
// different (or no) tags cannot leave the previous tags pointing here.
|
|
241
|
+
this.unregisterTags(prefixedKey);
|
|
208
242
|
this.cache.set(key, entry);
|
|
243
|
+
if (data.tags && data.tags.length > 0) {
|
|
244
|
+
this.registerTags(data.tags, prefixedKey);
|
|
245
|
+
}
|
|
209
246
|
}
|
|
210
247
|
|
|
211
248
|
async delete(key: string): Promise<boolean> {
|
|
249
|
+
this.unregisterTags(`seg:${key}`);
|
|
212
250
|
return this.cache.delete(key);
|
|
213
251
|
}
|
|
214
252
|
|
|
@@ -216,6 +254,8 @@ export class MemorySegmentCacheStore<
|
|
|
216
254
|
this.cache.clear();
|
|
217
255
|
this.responseCache.clear();
|
|
218
256
|
this.itemCache.clear();
|
|
257
|
+
this.tagIndex.clear();
|
|
258
|
+
this.keyTags.clear();
|
|
219
259
|
}
|
|
220
260
|
|
|
221
261
|
async getResponse(
|
|
@@ -225,6 +265,7 @@ export class MemorySegmentCacheStore<
|
|
|
225
265
|
if (!cached) return null;
|
|
226
266
|
|
|
227
267
|
if (Date.now() > cached.expiresAt) {
|
|
268
|
+
this.unregisterTags(`res:${key}`);
|
|
228
269
|
this.responseCache.delete(key);
|
|
229
270
|
return null;
|
|
230
271
|
}
|
|
@@ -245,23 +286,45 @@ export class MemorySegmentCacheStore<
|
|
|
245
286
|
response: Response,
|
|
246
287
|
ttl: number,
|
|
247
288
|
swr?: number,
|
|
289
|
+
tags?: string[],
|
|
248
290
|
): Promise<void> {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
291
|
+
try {
|
|
292
|
+
// arrayBuffer() can reject (e.g. an already-consumed body). A write
|
|
293
|
+
// failure must degrade to a no-op (entry simply not cached), never throw
|
|
294
|
+
// up and fail the request.
|
|
295
|
+
const body = await response.clone().arrayBuffer();
|
|
296
|
+
// Defense-in-depth (Finding #3): never persist a per-client signal into a
|
|
297
|
+
// shared store. The document-cache chokepoint already refuses these, but
|
|
298
|
+
// putResponse is public and reachable directly (e.g. tag-revalidation
|
|
299
|
+
// re-puts), so strip them here too.
|
|
300
|
+
const headers: [string, string][] = [];
|
|
301
|
+
response.headers.forEach((value, name) => {
|
|
302
|
+
if (isPerClientSignalHeader(name)) return;
|
|
303
|
+
headers.push([name, value]);
|
|
304
|
+
});
|
|
254
305
|
|
|
255
|
-
|
|
256
|
-
|
|
306
|
+
const swrWindow = resolveSwrWindow(swr, this.defaults);
|
|
307
|
+
const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
|
|
257
308
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
309
|
+
const prefixedKey = `res:${key}`;
|
|
310
|
+
this.unregisterTags(prefixedKey);
|
|
311
|
+
this.responseCache.set(key, {
|
|
312
|
+
body,
|
|
313
|
+
status: response.status,
|
|
314
|
+
headers,
|
|
315
|
+
expiresAt,
|
|
316
|
+
staleAt,
|
|
317
|
+
});
|
|
318
|
+
if (tags && tags.length > 0) {
|
|
319
|
+
this.registerTags(tags, prefixedKey);
|
|
320
|
+
}
|
|
321
|
+
} catch (error) {
|
|
322
|
+
reportCacheError(
|
|
323
|
+
error,
|
|
324
|
+
"cache-write",
|
|
325
|
+
"[MemorySegmentCacheStore] putResponse",
|
|
326
|
+
);
|
|
327
|
+
}
|
|
265
328
|
}
|
|
266
329
|
|
|
267
330
|
async getItem(key: string): Promise<CacheItemResult | null> {
|
|
@@ -270,6 +333,7 @@ export class MemorySegmentCacheStore<
|
|
|
270
333
|
|
|
271
334
|
const now = Date.now();
|
|
272
335
|
if (now > cached.expiresAt) {
|
|
336
|
+
this.unregisterTags(`item:${key}`);
|
|
273
337
|
this.itemCache.delete(key);
|
|
274
338
|
return null;
|
|
275
339
|
}
|
|
@@ -279,6 +343,7 @@ export class MemorySegmentCacheStore<
|
|
|
279
343
|
value: cached.value,
|
|
280
344
|
handles: cached.handles,
|
|
281
345
|
shouldRevalidate: isStale,
|
|
346
|
+
tags: cached.tags,
|
|
282
347
|
};
|
|
283
348
|
}
|
|
284
349
|
|
|
@@ -290,12 +355,95 @@ export class MemorySegmentCacheStore<
|
|
|
290
355
|
const ttl = resolveTtl(options?.ttl, this.defaults, DEFAULT_FUNCTION_TTL);
|
|
291
356
|
const swrWindow = resolveSwrWindow(options?.swr, this.defaults);
|
|
292
357
|
const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
|
|
358
|
+
const prefixedKey = `item:${key}`;
|
|
359
|
+
this.unregisterTags(prefixedKey);
|
|
293
360
|
this.itemCache.set(key, {
|
|
294
361
|
value,
|
|
295
362
|
handles: options?.handles,
|
|
296
363
|
expiresAt,
|
|
297
364
|
staleAt,
|
|
365
|
+
tags: options?.tags,
|
|
298
366
|
});
|
|
367
|
+
if (options?.tags && options.tags.length > 0) {
|
|
368
|
+
this.registerTags(options.tags, prefixedKey);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Invalidate every cache entry (segment, response, item) tagged with any of
|
|
374
|
+
* `tags`. Entries are dropped immediately; the next read is a miss and
|
|
375
|
+
* re-renders fresh. This is the store-level primitive both updateTag() and
|
|
376
|
+
* revalidateTag() delegate to. (In-process, so there is nothing to batch
|
|
377
|
+
* beyond looping the tags.)
|
|
378
|
+
*/
|
|
379
|
+
async invalidateTags(tags: string[]): Promise<void> {
|
|
380
|
+
for (const tag of tags) {
|
|
381
|
+
const keys = this.tagIndex.get(tag);
|
|
382
|
+
if (!keys || keys.size === 0) continue;
|
|
383
|
+
|
|
384
|
+
// Snapshot the keys before mutating the index inside the loop.
|
|
385
|
+
const prefixedKeys = [...keys];
|
|
386
|
+
|
|
387
|
+
for (const prefixedKey of prefixedKeys) {
|
|
388
|
+
const colonIdx = prefixedKey.indexOf(":");
|
|
389
|
+
const prefix = prefixedKey.slice(0, colonIdx);
|
|
390
|
+
const rawKey = prefixedKey.slice(colonIdx + 1);
|
|
391
|
+
|
|
392
|
+
if (prefix === "seg") {
|
|
393
|
+
this.cache.delete(rawKey);
|
|
394
|
+
} else if (prefix === "res") {
|
|
395
|
+
this.responseCache.delete(rawKey);
|
|
396
|
+
} else if (prefix === "item") {
|
|
397
|
+
this.itemCache.delete(rawKey);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Drop this key from every tag set it belonged to, not just `tag`.
|
|
401
|
+
this.unregisterTags(prefixedKey);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Register `tags` for a prefixed cache key in both the forward
|
|
408
|
+
* (tag -> keys) and reverse (key -> tags) indexes.
|
|
409
|
+
* Callers must call unregisterTags() first to clear stale mappings.
|
|
410
|
+
* @internal
|
|
411
|
+
*/
|
|
412
|
+
private registerTags(tags: string[], prefixedKey: string): void {
|
|
413
|
+
let tagSet = this.keyTags.get(prefixedKey);
|
|
414
|
+
if (!tagSet) {
|
|
415
|
+
tagSet = new Set();
|
|
416
|
+
this.keyTags.set(prefixedKey, tagSet);
|
|
417
|
+
}
|
|
418
|
+
for (const tag of tags) {
|
|
419
|
+
tagSet.add(tag);
|
|
420
|
+
let keys = this.tagIndex.get(tag);
|
|
421
|
+
if (!keys) {
|
|
422
|
+
keys = new Set();
|
|
423
|
+
this.tagIndex.set(tag, keys);
|
|
424
|
+
}
|
|
425
|
+
keys.add(prefixedKey);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Remove a prefixed cache key from every tag set it belongs to.
|
|
431
|
+
* Uses the reverse index so this is O(tags-per-key), not O(total-tags).
|
|
432
|
+
* @internal
|
|
433
|
+
*/
|
|
434
|
+
private unregisterTags(prefixedKey: string): void {
|
|
435
|
+
const tagSet = this.keyTags.get(prefixedKey);
|
|
436
|
+
if (!tagSet) return;
|
|
437
|
+
for (const tag of tagSet) {
|
|
438
|
+
const keys = this.tagIndex.get(tag);
|
|
439
|
+
if (keys) {
|
|
440
|
+
keys.delete(prefixedKey);
|
|
441
|
+
if (keys.size === 0) {
|
|
442
|
+
this.tagIndex.delete(tag);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
this.keyTags.delete(prefixedKey);
|
|
299
447
|
}
|
|
300
448
|
|
|
301
449
|
/**
|
|
@@ -325,5 +473,7 @@ export class MemorySegmentCacheStore<
|
|
|
325
473
|
delete (globalThis as any)[CACHE_REGISTRY_KEY];
|
|
326
474
|
delete (globalThis as any)[RESPONSE_CACHE_REGISTRY_KEY];
|
|
327
475
|
delete (globalThis as any)[ITEM_CACHE_REGISTRY_KEY];
|
|
476
|
+
delete (globalThis as any)[TAG_INDEX_REGISTRY_KEY];
|
|
477
|
+
delete (globalThis as any)[KEY_TAGS_REGISTRY_KEY];
|
|
328
478
|
}
|
|
329
479
|
}
|