@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.
Files changed (120) hide show
  1. package/dist/bin/rango.js +7 -2
  2. package/dist/vite/index.js +47 -6
  3. package/package.json +61 -21
  4. package/skills/cache-guide/SKILL.md +8 -6
  5. package/skills/caching/SKILL.md +148 -1
  6. package/skills/hooks/SKILL.md +38 -27
  7. package/skills/host-router/SKILL.md +16 -2
  8. package/skills/intercept/SKILL.md +4 -2
  9. package/skills/layout/SKILL.md +11 -6
  10. package/skills/loader/SKILL.md +6 -2
  11. package/skills/middleware/SKILL.md +4 -2
  12. package/skills/migrate-nextjs/SKILL.md +38 -16
  13. package/skills/parallel/SKILL.md +9 -4
  14. package/skills/rango/SKILL.md +27 -15
  15. package/skills/route/SKILL.md +4 -2
  16. package/skills/testing/SKILL.md +129 -0
  17. package/skills/testing/bindings.md +89 -0
  18. package/skills/testing/cache-prerender.md +98 -0
  19. package/skills/testing/client-components.md +122 -0
  20. package/skills/testing/e2e-parity.md +125 -0
  21. package/skills/testing/flight.md +89 -0
  22. package/skills/testing/handles.md +129 -0
  23. package/skills/testing/loader.md +128 -0
  24. package/skills/testing/middleware.md +99 -0
  25. package/skills/testing/render-handler.md +118 -0
  26. package/skills/testing/response-routes.md +95 -0
  27. package/skills/testing/reverse-and-types.md +84 -0
  28. package/skills/testing/server-actions.md +107 -0
  29. package/skills/testing/server-tree.md +128 -0
  30. package/skills/testing/setup.md +120 -0
  31. package/skills/use-cache/SKILL.md +9 -7
  32. package/src/browser/action-fence.ts +37 -0
  33. package/src/browser/cookie-name.ts +140 -0
  34. package/src/browser/invalidate-client-cache.ts +52 -0
  35. package/src/browser/navigation-bridge.ts +14 -1
  36. package/src/browser/navigation-client.ts +14 -1
  37. package/src/browser/navigation-store-handle.ts +39 -0
  38. package/src/browser/navigation-store.ts +26 -12
  39. package/src/browser/prefetch/fetch.ts +7 -0
  40. package/src/browser/rango-state.ts +176 -97
  41. package/src/browser/react/index.ts +0 -6
  42. package/src/browser/rsc-router.tsx +12 -4
  43. package/src/browser/server-action-bridge.ts +77 -15
  44. package/src/browser/types.ts +7 -1
  45. package/src/cache/cache-error.ts +104 -0
  46. package/src/cache/cache-policy.ts +95 -1
  47. package/src/cache/cache-runtime.ts +79 -13
  48. package/src/cache/cache-scope.ts +55 -4
  49. package/src/cache/cache-tag.ts +135 -0
  50. package/src/cache/cf/cf-cache-store.ts +2080 -224
  51. package/src/cache/cf/index.ts +15 -1
  52. package/src/cache/document-cache.ts +74 -7
  53. package/src/cache/index.ts +17 -0
  54. package/src/cache/memory-segment-store.ts +164 -14
  55. package/src/cache/tag-invalidation.ts +230 -0
  56. package/src/cache/types.ts +27 -0
  57. package/src/client.rsc.tsx +1 -1
  58. package/src/client.tsx +0 -6
  59. package/src/component-utils.ts +19 -0
  60. package/src/handle.ts +29 -9
  61. package/src/host/testing.ts +43 -14
  62. package/src/index.rsc.ts +29 -1
  63. package/src/index.ts +43 -1
  64. package/src/loader.rsc.ts +24 -3
  65. package/src/loader.ts +16 -2
  66. package/src/prerender.ts +24 -3
  67. package/src/router/basename.ts +14 -0
  68. package/src/router/match-handlers.ts +62 -20
  69. package/src/router/prerender-match.ts +6 -0
  70. package/src/router/router-interfaces.ts +7 -0
  71. package/src/router/router-options.ts +30 -0
  72. package/src/router/segment-resolution/loader-cache.ts +8 -17
  73. package/src/router/state-cookie-name.ts +33 -0
  74. package/src/router/telemetry.ts +99 -0
  75. package/src/router.ts +36 -7
  76. package/src/rsc/handler.ts +13 -1
  77. package/src/rsc/helpers.ts +19 -0
  78. package/src/rsc/progressive-enhancement.ts +2 -0
  79. package/src/rsc/response-route-handler.ts +8 -1
  80. package/src/rsc/rsc-rendering.ts +2 -0
  81. package/src/rsc/types.ts +2 -0
  82. package/src/runtime-env.ts +18 -0
  83. package/src/server/cookie-store.ts +52 -1
  84. package/src/server/request-context.ts +105 -2
  85. package/src/static-handler.ts +25 -3
  86. package/src/testing/cache-status.ts +166 -0
  87. package/src/testing/collect-handle.ts +63 -0
  88. package/src/testing/dispatch.ts +581 -0
  89. package/src/testing/dom.entry.ts +22 -0
  90. package/src/testing/e2e/fixture.ts +188 -0
  91. package/src/testing/e2e/index.ts +149 -0
  92. package/src/testing/e2e/matchers.ts +51 -0
  93. package/src/testing/e2e/page-helpers.ts +272 -0
  94. package/src/testing/e2e/parity.ts +387 -0
  95. package/src/testing/e2e/server.ts +195 -0
  96. package/src/testing/flight-matchers.ts +110 -0
  97. package/src/testing/flight-normalize.ts +38 -0
  98. package/src/testing/flight-runtime.d.ts +57 -0
  99. package/src/testing/flight-tree.ts +682 -0
  100. package/src/testing/flight.entry.ts +52 -0
  101. package/src/testing/flight.ts +234 -0
  102. package/src/testing/generated-routes.ts +223 -0
  103. package/src/testing/index.ts +119 -0
  104. package/src/testing/internal/context.ts +390 -0
  105. package/src/testing/internal/flight-client-globals.ts +30 -0
  106. package/src/testing/internal/seed-vars.ts +80 -0
  107. package/src/testing/render-handler.ts +360 -0
  108. package/src/testing/render-route.tsx +594 -0
  109. package/src/testing/run-loader.ts +474 -0
  110. package/src/testing/run-middleware.ts +231 -0
  111. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  112. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  113. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  114. package/src/testing/vitest-stubs/version.ts +5 -0
  115. package/src/testing/vitest.ts +305 -0
  116. package/src/types/cache-types.ts +13 -4
  117. package/src/types/error-types.ts +5 -1
  118. package/src/types/global-namespace.ts +11 -1
  119. package/src/types/handler-context.ts +16 -5
  120. package/src/browser/react/use-client-cache.ts +0 -58
@@ -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 { getRequestContext } from "../server/request-context.js";
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
- console.error(`[DocumentCache] Revalidation failed:`, error);
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(cacheStream, originalResponse),
413
+ new Response(body, originalResponse),
352
414
  directives.sMaxAge!,
353
415
  directives.staleWhileRevalidate,
416
+ collectRequestTags(requestCtx),
354
417
  );
355
418
  } catch (error) {
356
- console.error(`[DocumentCache] Cache write failed:`, error);
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
- console.error(`[DocumentCache] Error:`, error);
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.
@@ -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
- const body = await response.clone().arrayBuffer();
250
- const headers: [string, string][] = [];
251
- response.headers.forEach((value, name) => {
252
- headers.push([name, value]);
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
- const swrWindow = resolveSwrWindow(swr, this.defaults);
256
- const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
306
+ const swrWindow = resolveSwrWindow(swr, this.defaults);
307
+ const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
257
308
 
258
- this.responseCache.set(key, {
259
- body,
260
- status: response.status,
261
- headers,
262
- expiresAt,
263
- staleAt,
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
  }