@rangojs/router 0.0.0-experimental.dfdb0387 → 0.0.0-experimental.e16b7c00

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 (237) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/vite/index.js +2106 -842
  4. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  5. package/package.json +13 -8
  6. package/skills/breadcrumbs/SKILL.md +3 -1
  7. package/skills/bundle-analysis/SKILL.md +159 -0
  8. package/skills/cache-guide/SKILL.md +222 -30
  9. package/skills/caching/SKILL.md +188 -8
  10. package/skills/composability/SKILL.md +27 -2
  11. package/skills/document-cache/SKILL.md +78 -55
  12. package/skills/handler-use/SKILL.md +364 -0
  13. package/skills/hooks/SKILL.md +229 -20
  14. package/skills/host-router/SKILL.md +45 -20
  15. package/skills/i18n/SKILL.md +276 -0
  16. package/skills/intercept/SKILL.md +46 -4
  17. package/skills/layout/SKILL.md +28 -7
  18. package/skills/links/SKILL.md +247 -17
  19. package/skills/loader/SKILL.md +219 -9
  20. package/skills/middleware/SKILL.md +47 -12
  21. package/skills/migrate-nextjs/SKILL.md +582 -0
  22. package/skills/migrate-react-router/SKILL.md +769 -0
  23. package/skills/mime-routes/SKILL.md +27 -0
  24. package/skills/observability/SKILL.md +137 -0
  25. package/skills/parallel/SKILL.md +71 -6
  26. package/skills/prerender/SKILL.md +14 -33
  27. package/skills/rango/SKILL.md +236 -22
  28. package/skills/react-compiler/SKILL.md +168 -0
  29. package/skills/response-routes/SKILL.md +66 -9
  30. package/skills/route/SKILL.md +57 -4
  31. package/skills/router-setup/SKILL.md +3 -3
  32. package/skills/server-actions/SKILL.md +751 -0
  33. package/skills/streams-and-websockets/SKILL.md +283 -0
  34. package/skills/typesafety/SKILL.md +319 -27
  35. package/skills/use-cache/SKILL.md +36 -5
  36. package/skills/view-transitions/SKILL.md +294 -0
  37. package/src/__augment-tests__/augment.ts +81 -0
  38. package/src/__augment-tests__/augmented.check.ts +117 -0
  39. package/src/browser/action-coordinator.ts +53 -36
  40. package/src/browser/app-shell.ts +52 -0
  41. package/src/browser/event-controller.ts +86 -70
  42. package/src/browser/history-state.ts +21 -0
  43. package/src/browser/index.ts +3 -3
  44. package/src/browser/navigation-bridge.ts +86 -11
  45. package/src/browser/navigation-client.ts +45 -25
  46. package/src/browser/navigation-store.ts +32 -9
  47. package/src/browser/navigation-transaction.ts +10 -28
  48. package/src/browser/partial-update.ts +61 -28
  49. package/src/browser/prefetch/cache.ts +124 -26
  50. package/src/browser/prefetch/fetch.ts +129 -37
  51. package/src/browser/prefetch/queue.ts +36 -5
  52. package/src/browser/rango-state.ts +53 -13
  53. package/src/browser/react/Link.tsx +18 -13
  54. package/src/browser/react/NavigationProvider.tsx +72 -31
  55. package/src/browser/react/filter-segment-order.ts +51 -7
  56. package/src/browser/react/index.ts +3 -0
  57. package/src/browser/react/location-state-shared.ts +175 -4
  58. package/src/browser/react/location-state.ts +39 -13
  59. package/src/browser/react/use-handle.ts +17 -9
  60. package/src/browser/react/use-navigation.ts +22 -2
  61. package/src/browser/react/use-params.ts +20 -8
  62. package/src/browser/react/use-reverse.ts +106 -0
  63. package/src/browser/react/use-router.ts +22 -2
  64. package/src/browser/react/use-segments.ts +11 -8
  65. package/src/browser/response-adapter.ts +25 -0
  66. package/src/browser/rsc-router.tsx +64 -22
  67. package/src/browser/scroll-restoration.ts +22 -14
  68. package/src/browser/segment-reconciler.ts +10 -14
  69. package/src/browser/segment-structure-assert.ts +2 -2
  70. package/src/browser/server-action-bridge.ts +23 -30
  71. package/src/browser/types.ts +21 -0
  72. package/src/build/collect-fallback-refs.ts +107 -0
  73. package/src/build/generate-manifest.ts +60 -35
  74. package/src/build/generate-route-types.ts +2 -0
  75. package/src/build/index.ts +2 -0
  76. package/src/build/route-trie.ts +52 -25
  77. package/src/build/route-types/codegen.ts +4 -4
  78. package/src/build/route-types/include-resolution.ts +1 -1
  79. package/src/build/route-types/per-module-writer.ts +7 -4
  80. package/src/build/route-types/router-processing.ts +55 -14
  81. package/src/build/route-types/scan-filter.ts +1 -1
  82. package/src/build/route-types/source-scan.ts +118 -0
  83. package/src/build/runtime-discovery.ts +9 -20
  84. package/src/cache/cache-error.ts +104 -0
  85. package/src/cache/cache-policy.ts +95 -1
  86. package/src/cache/cache-runtime.ts +79 -13
  87. package/src/cache/cache-scope.ts +77 -46
  88. package/src/cache/cache-tag.ts +135 -0
  89. package/src/cache/cf/cf-cache-store.ts +1067 -176
  90. package/src/cache/cf/index.ts +4 -1
  91. package/src/cache/document-cache.ts +59 -7
  92. package/src/cache/index.ts +6 -0
  93. package/src/cache/memory-segment-store.ts +158 -14
  94. package/src/cache/tag-invalidation.ts +206 -0
  95. package/src/cache/types.ts +27 -0
  96. package/src/client.rsc.tsx +3 -0
  97. package/src/client.tsx +92 -182
  98. package/src/context-var.ts +5 -5
  99. package/src/decode-loader-results.ts +36 -0
  100. package/src/errors.ts +30 -1
  101. package/src/handle.ts +4 -6
  102. package/src/host/index.ts +2 -2
  103. package/src/host/router.ts +129 -57
  104. package/src/host/types.ts +31 -2
  105. package/src/host/utils.ts +1 -1
  106. package/src/href-client.ts +140 -20
  107. package/src/index.rsc.ts +16 -4
  108. package/src/index.ts +65 -15
  109. package/src/loader-store.ts +500 -0
  110. package/src/loader.rsc.ts +2 -5
  111. package/src/loader.ts +3 -10
  112. package/src/missing-id-error.ts +68 -0
  113. package/src/outlet-context.ts +1 -1
  114. package/src/prerender.ts +4 -4
  115. package/src/response-utils.ts +37 -0
  116. package/src/reverse.ts +65 -36
  117. package/src/route-content-wrapper.tsx +6 -28
  118. package/src/route-definition/dsl-helpers.ts +384 -257
  119. package/src/route-definition/helper-factories.ts +29 -139
  120. package/src/route-definition/helpers-types.ts +100 -28
  121. package/src/route-definition/resolve-handler-use.ts +6 -0
  122. package/src/route-definition/use-item-types.ts +32 -0
  123. package/src/route-types.ts +26 -41
  124. package/src/router/content-negotiation.ts +15 -2
  125. package/src/router/error-handling.ts +1 -1
  126. package/src/router/handler-context.ts +21 -38
  127. package/src/router/intercept-resolution.ts +4 -18
  128. package/src/router/lazy-includes.ts +8 -8
  129. package/src/router/loader-resolution.ts +19 -2
  130. package/src/router/manifest.ts +22 -13
  131. package/src/router/match-api.ts +4 -3
  132. package/src/router/match-handlers.ts +1 -0
  133. package/src/router/match-middleware/cache-lookup.ts +46 -92
  134. package/src/router/match-middleware/cache-store.ts +3 -2
  135. package/src/router/match-result.ts +53 -32
  136. package/src/router/metrics.ts +1 -1
  137. package/src/router/middleware-types.ts +15 -26
  138. package/src/router/middleware.ts +99 -84
  139. package/src/router/pattern-matching.ts +101 -17
  140. package/src/router/prerender-match.ts +3 -1
  141. package/src/router/preview-match.ts +3 -1
  142. package/src/router/request-classification.ts +4 -28
  143. package/src/router/revalidation.ts +58 -2
  144. package/src/router/router-interfaces.ts +45 -28
  145. package/src/router/router-options.ts +25 -1
  146. package/src/router/router-registry.ts +2 -5
  147. package/src/router/segment-resolution/fresh.ts +27 -6
  148. package/src/router/segment-resolution/loader-cache.ts +8 -17
  149. package/src/router/segment-resolution/revalidation.ts +147 -106
  150. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  151. package/src/router/substitute-pattern-params.ts +56 -0
  152. package/src/router/trie-matching.ts +18 -13
  153. package/src/router/types.ts +8 -0
  154. package/src/router/url-params.ts +49 -0
  155. package/src/router.ts +23 -18
  156. package/src/rsc/handler-context.ts +2 -2
  157. package/src/rsc/handler.ts +38 -70
  158. package/src/rsc/helpers.ts +72 -43
  159. package/src/rsc/index.ts +1 -1
  160. package/src/rsc/origin-guard.ts +28 -10
  161. package/src/rsc/progressive-enhancement.ts +4 -0
  162. package/src/rsc/response-route-handler.ts +54 -54
  163. package/src/rsc/rsc-rendering.ts +35 -51
  164. package/src/rsc/runtime-warnings.ts +9 -10
  165. package/src/rsc/server-action.ts +17 -37
  166. package/src/rsc/ssr-setup.ts +16 -0
  167. package/src/rsc/types.ts +8 -2
  168. package/src/search-params.ts +4 -4
  169. package/src/segment-content-promise.ts +67 -0
  170. package/src/segment-loader-promise.ts +122 -0
  171. package/src/segment-system.tsx +132 -116
  172. package/src/serialize.ts +243 -0
  173. package/src/server/context.ts +143 -53
  174. package/src/server/cookie-store.ts +28 -4
  175. package/src/server/request-context.ts +46 -44
  176. package/src/ssr/index.tsx +5 -1
  177. package/src/static-handler.ts +1 -1
  178. package/src/types/cache-types.ts +13 -4
  179. package/src/types/error-types.ts +5 -1
  180. package/src/types/global-namespace.ts +39 -26
  181. package/src/types/handler-context.ts +68 -50
  182. package/src/types/index.ts +1 -0
  183. package/src/types/loader-types.ts +5 -6
  184. package/src/types/request-scope.ts +126 -0
  185. package/src/types/route-entry.ts +11 -0
  186. package/src/types/segments.ts +35 -2
  187. package/src/urls/include-helper.ts +34 -67
  188. package/src/urls/index.ts +0 -3
  189. package/src/urls/path-helper-types.ts +41 -7
  190. package/src/urls/path-helper.ts +17 -52
  191. package/src/urls/pattern-types.ts +36 -19
  192. package/src/urls/response-types.ts +22 -29
  193. package/src/urls/type-extraction.ts +26 -116
  194. package/src/urls/urls-function.ts +1 -5
  195. package/src/use-loader.tsx +413 -42
  196. package/src/vite/debug.ts +185 -0
  197. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  198. package/src/vite/discovery/discover-routers.ts +101 -51
  199. package/src/vite/discovery/discovery-errors.ts +194 -0
  200. package/src/vite/discovery/gate-state.ts +171 -0
  201. package/src/vite/discovery/prerender-collection.ts +67 -26
  202. package/src/vite/discovery/route-types-writer.ts +40 -84
  203. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  204. package/src/vite/discovery/state.ts +33 -0
  205. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  206. package/src/vite/index.ts +2 -0
  207. package/src/vite/plugin-types.ts +67 -0
  208. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  209. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  210. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  211. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  212. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  213. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  214. package/src/vite/plugins/expose-action-id.ts +54 -30
  215. package/src/vite/plugins/expose-id-utils.ts +12 -8
  216. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  217. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  218. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  219. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  220. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  221. package/src/vite/plugins/performance-tracks.ts +29 -25
  222. package/src/vite/plugins/use-cache-transform.ts +65 -50
  223. package/src/vite/plugins/version-injector.ts +39 -23
  224. package/src/vite/plugins/version-plugin.ts +59 -2
  225. package/src/vite/plugins/virtual-entries.ts +2 -2
  226. package/src/vite/rango.ts +116 -29
  227. package/src/vite/router-discovery.ts +750 -100
  228. package/src/vite/utils/ast-handler-extract.ts +15 -15
  229. package/src/vite/utils/banner.ts +1 -1
  230. package/src/vite/utils/bundle-analysis.ts +4 -2
  231. package/src/vite/utils/client-chunks.ts +190 -0
  232. package/src/vite/utils/forward-user-plugins.ts +193 -0
  233. package/src/vite/utils/manifest-utils.ts +21 -5
  234. package/src/vite/utils/package-resolution.ts +41 -1
  235. package/src/vite/utils/prerender-utils.ts +21 -6
  236. package/src/vite/utils/shared-utils.ts +107 -26
  237. package/src/browser/action-response-classifier.ts +0 -99
@@ -16,7 +16,10 @@ export {
16
16
  type KVNamespace,
17
17
  } from "./cf-cache-store.js";
18
18
 
19
- // Header constants for debugging and inspection
19
+ // Header constants for debugging and inspection. The tag headers
20
+ // (x-edge-cache-tags / x-edge-cache-tagged-at) are intentionally NOT re-exported:
21
+ // they are an internal encoding detail of the store's tag-invalidation check, not
22
+ // a consumer-inspectable contract.
20
23
  export {
21
24
  CACHE_STALE_AT_HEADER,
22
25
  CACHE_STATUS_HEADER,
@@ -12,10 +12,14 @@
12
12
  */
13
13
 
14
14
  import type { MiddlewareFn, MiddlewareContext } from "../router/middleware.js";
15
- import { getRequestContext } from "../server/request-context.js";
15
+ import {
16
+ getRequestContext,
17
+ type RequestContext,
18
+ } from "../server/request-context.js";
16
19
  import { mayNeedSSR } from "../rsc/ssr-setup.js";
17
20
  import { sortedSearchString } from "./cache-key-utils.js";
18
21
  import { runBackground } from "./background-task.js";
22
+ import { reportCacheError } from "./cache-error.js";
19
23
 
20
24
  // ============================================================================
21
25
  // Constants
@@ -24,6 +28,32 @@ import { runBackground } from "./background-task.js";
24
28
  /** Header indicating cache status for debugging */
25
29
  const CACHE_STATUS_HEADER = "x-document-cache-status";
26
30
 
31
+ /**
32
+ * Snapshot the request-scoped tag union for a document cache write. The full-page
33
+ * entry is tagged with every cache tag its content resolved (runtime cacheTag(),
34
+ * "use cache" profile tags, and loader cache tags) so updateTag()/revalidateTag()
35
+ * can invalidate it. Returns undefined when no tags were used, keeping untagged
36
+ * document entries header-free.
37
+ *
38
+ * This is a plain synchronous snapshot. The CALLER must drain the rendered body
39
+ * first (see the cache-write closures): runtime cacheTag()/"use cache" and loader
40
+ * tags are recorded synchronously as each value resolves during render, including
41
+ * Suspense-streamed ones that resolve AFTER the handler-settlement barrier - so
42
+ * the correct barrier is the stream draining (render complete), not _handleStore.
43
+ *
44
+ * Caveat: segment cache({ tags }) DSL tags are recorded inside the deferred
45
+ * cacheRoute waitUntil, which can still run after this snapshot; a document that
46
+ * combines whole-page document caching with segment-DSL tags may miss those (the
47
+ * segment cache entry itself is still correctly tagged and invalidated). Runtime
48
+ * cacheTag()/"use cache" and loader tags are always captured once the body drains.
49
+ */
50
+ function collectRequestTags(
51
+ requestCtx: RequestContext | undefined,
52
+ ): string[] | undefined {
53
+ const tags = requestCtx?._requestTags;
54
+ return tags && tags.size > 0 ? [...tags] : undefined;
55
+ }
56
+
27
57
  /**
28
58
  * Simple hash function for segment IDs.
29
59
  * Creates a short, deterministic hash to differentiate cache keys
@@ -303,17 +333,26 @@ export function createDocumentCacheMiddleware<TEnv = any>(
303
333
  const fresh = await next();
304
334
  const directives = shouldCacheResponse(fresh);
305
335
 
306
- if (directives) {
336
+ if (directives && fresh.body) {
337
+ // Background revalidation: nothing streams to a client, so drain
338
+ // the fresh render fully before snapshotting tags (same
339
+ // render-complete barrier as the miss path).
340
+ const body = await new Response(fresh.body).arrayBuffer();
307
341
  await store.putResponse!(
308
342
  cacheKey,
309
- fresh,
343
+ new Response(body, fresh),
310
344
  directives.sMaxAge!,
311
345
  directives.staleWhileRevalidate,
346
+ collectRequestTags(requestCtx),
312
347
  );
313
348
  log(`[DocumentCache] REVALIDATED ${typeLabel}: ${url.pathname}`);
314
349
  }
315
350
  } catch (error) {
316
- console.error(`[DocumentCache] Revalidation failed:`, error);
351
+ reportCacheError(
352
+ error,
353
+ "cache-write",
354
+ "[DocumentCache] revalidation",
355
+ );
317
356
  }
318
357
  });
319
358
 
@@ -346,14 +385,27 @@ export function createDocumentCacheMiddleware<TEnv = any>(
346
385
  // Clone response for caching (non-blocking)
347
386
  runBackground(requestCtx, async () => {
348
387
  try {
388
+ // Drain the cache copy fully BEFORE snapshotting tags. Tags from
389
+ // Suspense-streamed "use cache"/cacheTag and loaders are recorded as
390
+ // each value resolves during the RSC/HTML render, which completes
391
+ // only when the stream ends - the handler-settlement barrier is too
392
+ // early. Buffering the body (the client streams the other tee branch,
393
+ // unaffected) is the render-complete barrier that keeps the cached
394
+ // body and its tag set consistent.
395
+ const body = await new Response(cacheStream).arrayBuffer();
349
396
  await store.putResponse!(
350
397
  cacheKey,
351
- new Response(cacheStream, originalResponse),
398
+ new Response(body, originalResponse),
352
399
  directives.sMaxAge!,
353
400
  directives.staleWhileRevalidate,
401
+ collectRequestTags(requestCtx),
354
402
  );
355
403
  } catch (error) {
356
- console.error(`[DocumentCache] Cache write failed:`, error);
404
+ reportCacheError(
405
+ error,
406
+ "cache-write",
407
+ "[DocumentCache] cache write",
408
+ );
357
409
  }
358
410
  });
359
411
 
@@ -366,7 +418,7 @@ export function createDocumentCacheMiddleware<TEnv = any>(
366
418
  // No cache headers - pass through
367
419
  return originalResponse;
368
420
  } catch (error) {
369
- console.error(`[DocumentCache] Error:`, error);
421
+ reportCacheError(error, "cache-read", "[DocumentCache] middleware");
370
422
  if (handlerCalled) {
371
423
  // Post-handler failure (e.g. body.tee()): do not call next() again
372
424
  // as that would re-run handler side effects.
@@ -42,3 +42,9 @@ export {
42
42
  createDocumentCacheMiddleware,
43
43
  type DocumentCacheOptions,
44
44
  } from "./document-cache.js";
45
+
46
+ // Cache error reporting. CacheErrorCategory is the discriminator surfaced to a
47
+ // router's onError callback as `metadata.category` for the `cache` phase, so
48
+ // consumers can branch on the failure kind (e.g. distinguish a transient
49
+ // cache-read outage from cache-corrupt self-heal).
50
+ export type { CacheErrorCategory } from "./cache-error.js";
@@ -21,10 +21,13 @@ import {
21
21
  computeExpiration,
22
22
  DEFAULT_FUNCTION_TTL,
23
23
  } from "./cache-policy.js";
24
+ import { reportCacheError } from "./cache-error.js";
24
25
 
25
26
  const CACHE_REGISTRY_KEY = "__rsc_router_segment_cache_registry__";
26
27
  const RESPONSE_CACHE_REGISTRY_KEY = "__rsc_router_response_cache_registry__";
27
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__";
28
31
 
29
32
  /**
30
33
  * Get or create a named Map from a globalThis-backed registry.
@@ -59,6 +62,7 @@ interface CachedItemEntry {
59
62
  handles?: Record<string, SegmentHandleData>;
60
63
  expiresAt: number;
61
64
  staleAt: number;
65
+ tags?: string[];
62
66
  }
63
67
 
64
68
  /**
@@ -73,6 +77,11 @@ export interface MemorySegmentCacheStoreOptions<TEnv = unknown> {
73
77
  * When omitted, the store uses a plain instance-level Map with no
74
78
  * globalThis sharing, which is the safest default for isolation.
75
79
  *
80
+ * Caveat: two instances constructed with the SAME name share all backing maps
81
+ * (data + tag index), but each keeps its OWN `defaults` and `keyGenerator` from
82
+ * its options - those are not shared. Use one instance per name, or keep the
83
+ * options identical, to avoid surprising divergence.
84
+ *
76
85
  * @example
77
86
  * ```typescript
78
87
  * // Two named stores are isolated from each other
@@ -121,6 +130,11 @@ export interface MemorySegmentCacheStoreOptions<TEnv = unknown> {
121
130
  * For production with multiple instances, use a distributed store
122
131
  * like Cloudflare KV or Redis.
123
132
  *
133
+ * Tag-index cleanup is lazy, mirroring the data maps: a tagged entry that
134
+ * expires but is never re-read or invalidated leaves its forward+reverse index
135
+ * entries resident until the key is reused or invalidated. This is bounded by
136
+ * the distinct-tag count and acceptable for a dev/single-instance store.
137
+ *
124
138
  * @example
125
139
  * ```typescript
126
140
  * // Basic usage
@@ -143,6 +157,10 @@ export class MemorySegmentCacheStore<
143
157
  private cache: Map<string, CachedEntryData>;
144
158
  private responseCache: Map<string, CachedResponseEntry>;
145
159
  private itemCache: Map<string, CachedItemEntry>;
160
+ /** tag -> set of prefixed cache keys (seg:key, res:key, item:key) */
161
+ private tagIndex: Map<string, Set<string>>;
162
+ /** prefixed cache key -> set of tags (reverse index for O(tags) unregister) */
163
+ private keyTags: Map<string, Set<string>>;
146
164
  readonly defaults?: CacheDefaults;
147
165
  readonly keyGenerator?: (
148
166
  ctx: RequestContext<TEnv>,
@@ -165,11 +183,21 @@ export class MemorySegmentCacheStore<
165
183
  ITEM_CACHE_REGISTRY_KEY,
166
184
  options.name,
167
185
  );
186
+ this.tagIndex = getNamedMap<Set<string>>(
187
+ TAG_INDEX_REGISTRY_KEY,
188
+ options.name,
189
+ );
190
+ this.keyTags = getNamedMap<Set<string>>(
191
+ KEY_TAGS_REGISTRY_KEY,
192
+ options.name,
193
+ );
168
194
  } else {
169
195
  // Unnamed stores get a plain instance-level Map (no globalThis sharing).
170
196
  this.cache = new Map<string, CachedEntryData>();
171
197
  this.responseCache = new Map<string, CachedResponseEntry>();
172
198
  this.itemCache = new Map<string, CachedItemEntry>();
199
+ this.tagIndex = new Map<string, Set<string>>();
200
+ this.keyTags = new Map<string, Set<string>>();
173
201
  }
174
202
  this.defaults = options?.defaults;
175
203
  this.keyGenerator = options?.keyGenerator;
@@ -184,6 +212,7 @@ export class MemorySegmentCacheStore<
184
212
 
185
213
  // Check expiration
186
214
  if (Date.now() > cached.expiresAt) {
215
+ this.unregisterTags(`seg:${key}`);
187
216
  this.cache.delete(key);
188
217
  return null;
189
218
  }
@@ -204,10 +233,18 @@ export class MemorySegmentCacheStore<
204
233
  ...data,
205
234
  expiresAt: Date.now() + ttl * 1000,
206
235
  };
236
+ const prefixedKey = `seg:${key}`;
237
+ // Always drop stale tag mappings before writing so an overwrite with
238
+ // different (or no) tags cannot leave the previous tags pointing here.
239
+ this.unregisterTags(prefixedKey);
207
240
  this.cache.set(key, entry);
241
+ if (data.tags && data.tags.length > 0) {
242
+ this.registerTags(data.tags, prefixedKey);
243
+ }
208
244
  }
209
245
 
210
246
  async delete(key: string): Promise<boolean> {
247
+ this.unregisterTags(`seg:${key}`);
211
248
  return this.cache.delete(key);
212
249
  }
213
250
 
@@ -215,6 +252,8 @@ export class MemorySegmentCacheStore<
215
252
  this.cache.clear();
216
253
  this.responseCache.clear();
217
254
  this.itemCache.clear();
255
+ this.tagIndex.clear();
256
+ this.keyTags.clear();
218
257
  }
219
258
 
220
259
  async getResponse(
@@ -224,6 +263,7 @@ export class MemorySegmentCacheStore<
224
263
  if (!cached) return null;
225
264
 
226
265
  if (Date.now() > cached.expiresAt) {
266
+ this.unregisterTags(`res:${key}`);
227
267
  this.responseCache.delete(key);
228
268
  return null;
229
269
  }
@@ -244,23 +284,40 @@ export class MemorySegmentCacheStore<
244
284
  response: Response,
245
285
  ttl: number,
246
286
  swr?: number,
287
+ tags?: string[],
247
288
  ): Promise<void> {
248
- const body = await response.clone().arrayBuffer();
249
- const headers: [string, string][] = [];
250
- response.headers.forEach((value, name) => {
251
- headers.push([name, value]);
252
- });
289
+ try {
290
+ // arrayBuffer() can reject (e.g. an already-consumed body). A write
291
+ // failure must degrade to a no-op (entry simply not cached), never throw
292
+ // up and fail the request.
293
+ const body = await response.clone().arrayBuffer();
294
+ const headers: [string, string][] = [];
295
+ response.headers.forEach((value, name) => {
296
+ headers.push([name, value]);
297
+ });
253
298
 
254
- const swrWindow = resolveSwrWindow(swr, this.defaults);
255
- const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
299
+ const swrWindow = resolveSwrWindow(swr, this.defaults);
300
+ const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
256
301
 
257
- this.responseCache.set(key, {
258
- body,
259
- status: response.status,
260
- headers,
261
- expiresAt,
262
- staleAt,
263
- });
302
+ const prefixedKey = `res:${key}`;
303
+ this.unregisterTags(prefixedKey);
304
+ this.responseCache.set(key, {
305
+ body,
306
+ status: response.status,
307
+ headers,
308
+ expiresAt,
309
+ staleAt,
310
+ });
311
+ if (tags && tags.length > 0) {
312
+ this.registerTags(tags, prefixedKey);
313
+ }
314
+ } catch (error) {
315
+ reportCacheError(
316
+ error,
317
+ "cache-write",
318
+ "[MemorySegmentCacheStore] putResponse",
319
+ );
320
+ }
264
321
  }
265
322
 
266
323
  async getItem(key: string): Promise<CacheItemResult | null> {
@@ -269,6 +326,7 @@ export class MemorySegmentCacheStore<
269
326
 
270
327
  const now = Date.now();
271
328
  if (now > cached.expiresAt) {
329
+ this.unregisterTags(`item:${key}`);
272
330
  this.itemCache.delete(key);
273
331
  return null;
274
332
  }
@@ -278,6 +336,7 @@ export class MemorySegmentCacheStore<
278
336
  value: cached.value,
279
337
  handles: cached.handles,
280
338
  shouldRevalidate: isStale,
339
+ tags: cached.tags,
281
340
  };
282
341
  }
283
342
 
@@ -289,12 +348,95 @@ export class MemorySegmentCacheStore<
289
348
  const ttl = resolveTtl(options?.ttl, this.defaults, DEFAULT_FUNCTION_TTL);
290
349
  const swrWindow = resolveSwrWindow(options?.swr, this.defaults);
291
350
  const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
351
+ const prefixedKey = `item:${key}`;
352
+ this.unregisterTags(prefixedKey);
292
353
  this.itemCache.set(key, {
293
354
  value,
294
355
  handles: options?.handles,
295
356
  expiresAt,
296
357
  staleAt,
358
+ tags: options?.tags,
297
359
  });
360
+ if (options?.tags && options.tags.length > 0) {
361
+ this.registerTags(options.tags, prefixedKey);
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Invalidate every cache entry (segment, response, item) tagged with any of
367
+ * `tags`. Entries are dropped immediately; the next read is a miss and
368
+ * re-renders fresh. This is the store-level primitive both updateTag() and
369
+ * revalidateTag() delegate to. (In-process, so there is nothing to batch
370
+ * beyond looping the tags.)
371
+ */
372
+ async invalidateTags(tags: string[]): Promise<void> {
373
+ for (const tag of tags) {
374
+ const keys = this.tagIndex.get(tag);
375
+ if (!keys || keys.size === 0) continue;
376
+
377
+ // Snapshot the keys before mutating the index inside the loop.
378
+ const prefixedKeys = [...keys];
379
+
380
+ for (const prefixedKey of prefixedKeys) {
381
+ const colonIdx = prefixedKey.indexOf(":");
382
+ const prefix = prefixedKey.slice(0, colonIdx);
383
+ const rawKey = prefixedKey.slice(colonIdx + 1);
384
+
385
+ if (prefix === "seg") {
386
+ this.cache.delete(rawKey);
387
+ } else if (prefix === "res") {
388
+ this.responseCache.delete(rawKey);
389
+ } else if (prefix === "item") {
390
+ this.itemCache.delete(rawKey);
391
+ }
392
+
393
+ // Drop this key from every tag set it belonged to, not just `tag`.
394
+ this.unregisterTags(prefixedKey);
395
+ }
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Register `tags` for a prefixed cache key in both the forward
401
+ * (tag -> keys) and reverse (key -> tags) indexes.
402
+ * Callers must call unregisterTags() first to clear stale mappings.
403
+ * @internal
404
+ */
405
+ private registerTags(tags: string[], prefixedKey: string): void {
406
+ let tagSet = this.keyTags.get(prefixedKey);
407
+ if (!tagSet) {
408
+ tagSet = new Set();
409
+ this.keyTags.set(prefixedKey, tagSet);
410
+ }
411
+ for (const tag of tags) {
412
+ tagSet.add(tag);
413
+ let keys = this.tagIndex.get(tag);
414
+ if (!keys) {
415
+ keys = new Set();
416
+ this.tagIndex.set(tag, keys);
417
+ }
418
+ keys.add(prefixedKey);
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Remove a prefixed cache key from every tag set it belongs to.
424
+ * Uses the reverse index so this is O(tags-per-key), not O(total-tags).
425
+ * @internal
426
+ */
427
+ private unregisterTags(prefixedKey: string): void {
428
+ const tagSet = this.keyTags.get(prefixedKey);
429
+ if (!tagSet) return;
430
+ for (const tag of tagSet) {
431
+ const keys = this.tagIndex.get(tag);
432
+ if (keys) {
433
+ keys.delete(prefixedKey);
434
+ if (keys.size === 0) {
435
+ this.tagIndex.delete(tag);
436
+ }
437
+ }
438
+ }
439
+ this.keyTags.delete(prefixedKey);
298
440
  }
299
441
 
300
442
  /**
@@ -324,5 +466,7 @@ export class MemorySegmentCacheStore<
324
466
  delete (globalThis as any)[CACHE_REGISTRY_KEY];
325
467
  delete (globalThis as any)[RESPONSE_CACHE_REGISTRY_KEY];
326
468
  delete (globalThis as any)[ITEM_CACHE_REGISTRY_KEY];
469
+ delete (globalThis as any)[TAG_INDEX_REGISTRY_KEY];
470
+ delete (globalThis as any)[KEY_TAGS_REGISTRY_KEY];
327
471
  }
328
472
  }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Cache Tag Invalidation API
3
+ *
4
+ * Two on-demand invalidation verbs, mirroring the distinction popularized by
5
+ * Next.js so consumers can pick the right consistency model:
6
+ *
7
+ * - updateTag(...tags): read-your-own-writes. Awaitable - resolves only after
8
+ * in-process invalidation across every configured store completes. Use in a
9
+ * Server Action and `await` it before the action re-renders, so the action's
10
+ * own response reflects the mutation.
11
+ *
12
+ * - revalidateTag(...tags): fire-and-forget via waitUntil - the response is not
13
+ * blocked. Use in Route Handlers / webhooks. NOTE: both verbs hard-purge; the
14
+ * only difference is awaitability. revalidateTag does NOT serve stale content -
15
+ * the next read after the invalidation lands is a hard miss that re-renders.
16
+ * (The name mirrors Next.js, where it is SWR; here it is background-purge.)
17
+ *
18
+ * Both fan out across the app-level store (ctx._cacheStore) and every explicit
19
+ * per-scope store from cache({ store }) registered for this handler
20
+ * (ctx._explicitTaggedStores), calling the store-level invalidateTags()
21
+ * primitive for each tag. A single configured store (the common case) owns its
22
+ * own tag index and distributed invalidation - there is no separate
23
+ * tag-invalidation store.
24
+ */
25
+
26
+ import { _getRequestContext } from "../server/request-context.js";
27
+ import { reportingAsync } from "./cache-error.js";
28
+ import { normalizeTags } from "./cache-tag.js";
29
+ import type { SegmentCacheStore } from "./types.js";
30
+
31
+ /**
32
+ * Collect every store that may hold entries tagged for this request's handler:
33
+ * the app-level store plus all explicit per-scope stores (deduplicated). Splits
34
+ * them into tag-capable (implement invalidateTags()) and not, so callers can
35
+ * warn about configured stores whose tagged entries will NOT be invalidated.
36
+ */
37
+ function collectStores(): {
38
+ capable: SegmentCacheStore[];
39
+ incapable: number;
40
+ } {
41
+ const ctx = _getRequestContext();
42
+ const stores = new Set<SegmentCacheStore>();
43
+ if (ctx?._cacheStore) stores.add(ctx._cacheStore);
44
+ if (ctx?._explicitTaggedStores) {
45
+ for (const store of ctx._explicitTaggedStores) stores.add(store);
46
+ }
47
+ const capable: SegmentCacheStore[] = [];
48
+ let incapable = 0;
49
+ for (const store of stores) {
50
+ if (typeof store.invalidateTags === "function") capable.push(store);
51
+ else incapable++;
52
+ }
53
+ return { capable, incapable };
54
+ }
55
+
56
+ /**
57
+ * Production-visible warning. A misconfigured store silently dropping
58
+ * invalidations is a data-correctness footgun, so this surfaces in every
59
+ * environment (not dev-only).
60
+ */
61
+ function warnNoTagStore(fn: string, tags: string[]): void {
62
+ console.warn(
63
+ `[${fn}] No tag-capable cache store is configured; tags ` +
64
+ `[${tags.join(", ")}] were not invalidated. The configured store must ` +
65
+ `implement invalidateTags() (the built-in MemorySegmentCacheStore and ` +
66
+ `CFCacheStore do).`,
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Production-visible warning for mixed-store configs: at least one configured
72
+ * store does not support tag invalidation, so its tagged entries (if any) are
73
+ * left stale even though other stores were invalidated.
74
+ */
75
+ function warnPartialTagStore(fn: string, incapable: number): void {
76
+ console.warn(
77
+ `[${fn}] ${incapable} configured cache store(s) do not implement ` +
78
+ `invalidateTags(); their tagged entries were NOT invalidated. Use a ` +
79
+ `tag-capable store (e.g. MemorySegmentCacheStore / CFCacheStore) for any ` +
80
+ `cache({ store }) boundary whose entries you invalidate by tag.`,
81
+ );
82
+ }
83
+
84
+ async function invalidateAcross(
85
+ stores: SegmentCacheStore[],
86
+ tags: string[],
87
+ ): Promise<void> {
88
+ // One invalidateTags(tags) call per store: the store receives the whole tag
89
+ // batch so it can do a single CDN purge request rather than one per tag.
90
+ //
91
+ // allSettled, not all: a store's invalidateTags() can reject (e.g. CFCacheStore
92
+ // surfaces a failed durable KV marker write). With Promise.all, the first
93
+ // rejection would short-circuit and the other stores' outcomes would go
94
+ // unobserved. Attempt every store, then surface a combined error so an awaited
95
+ // updateTag() still rejects (read-your-own-writes honesty) without masking the
96
+ // stores that did succeed.
97
+ const results = await Promise.allSettled(
98
+ stores.map((store) => store.invalidateTags!(tags)),
99
+ );
100
+ const errors = results
101
+ .filter((r): r is PromiseRejectedResult => r.status === "rejected")
102
+ .map((r) => r.reason);
103
+ if (errors.length > 0) {
104
+ const err = new Error(
105
+ `[tag invalidation] ${errors.length}/${stores.length} store(s) failed to ` +
106
+ `invalidate tags [${tags.join(", ")}]; their entries may still serve ` +
107
+ `stale data. Retry the invalidation.`,
108
+ );
109
+ (err as Error & { cause?: unknown }).cause = errors[0];
110
+ throw err;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Immediately expire every cache entry tagged with any of `tags`, resolving
116
+ * once in-process invalidation across all configured stores completes.
117
+ *
118
+ * Read-your-own-writes: because the returned promise resolves before you return
119
+ * from a Server Action, awaiting it guarantees the action's own re-render (and
120
+ * any subsequent read) sees fresh data.
121
+ *
122
+ * @example
123
+ * ```typescript
124
+ * async function updateProduct(formData: FormData) {
125
+ * "use server";
126
+ * await db.updateProduct(formData);
127
+ * await updateTag("products"); // next render is fresh
128
+ * }
129
+ * ```
130
+ */
131
+ export async function updateTag(...tags: string[]): Promise<void> {
132
+ const valid = normalizeTags(tags);
133
+ if (valid.length === 0) return;
134
+
135
+ const { capable, incapable } = collectStores();
136
+ if (capable.length === 0) {
137
+ warnNoTagStore("updateTag", valid);
138
+ return;
139
+ }
140
+ if (incapable > 0) warnPartialTagStore("updateTag", incapable);
141
+
142
+ await invalidateAcross(capable, valid);
143
+ }
144
+
145
+ /**
146
+ * Invalidate every cache entry tagged with any of `tags` in the background,
147
+ * without blocking the current response (fire-and-forget via waitUntil).
148
+ *
149
+ * This is NOT stale-while-revalidate: like updateTag() it hard-purges, so the
150
+ * next read after the invalidation lands is a miss that re-renders fresh. The
151
+ * only difference from updateTag() is awaitability - revalidateTag() defers the
152
+ * purge off the response path and is not awaited.
153
+ *
154
+ * Use in Route Handlers / webhooks. For read-your-own-writes inside a Server
155
+ * Action, use updateTag() instead so the action's own response is fresh.
156
+ *
157
+ * Fire-and-forget: because this returns void and runs in the background, a
158
+ * failed durable marker write (e.g. a transient KV outage) is NOT surfaced to
159
+ * the caller. It IS reported - logged loudly and routed through the router's
160
+ * `onError` callback (phase `cache`, `metadata.category === "cache-invalidate"`)
161
+ * via reportingAsync - so the failure is observable in telemetry even though it
162
+ * cannot be awaited. If you need the invalidation to be CONFIRMED (and to retry
163
+ * on failure), use `await updateTag()` instead, which rejects when a store's
164
+ * durable write fails.
165
+ *
166
+ * @example
167
+ * ```typescript
168
+ * // route handler invoked by an external webhook
169
+ * export async function POST() {
170
+ * "use server";
171
+ * revalidateTag("products");
172
+ * return new Response("ok");
173
+ * }
174
+ * ```
175
+ */
176
+ export function revalidateTag(...tags: string[]): void {
177
+ const valid = normalizeTags(tags);
178
+ if (valid.length === 0) return;
179
+
180
+ const { capable, incapable } = collectStores();
181
+ if (capable.length === 0) {
182
+ warnNoTagStore("revalidateTag", valid);
183
+ return;
184
+ }
185
+ if (incapable > 0) warnPartialTagStore("revalidateTag", incapable);
186
+
187
+ const ctx = _getRequestContext();
188
+ // reportingAsync never rejects: it catches a failed durable write and routes
189
+ // it through reportCacheError (loud log + onError). This is the only place a
190
+ // revalidateTag failure can be observed, since it is not awaitable. Pass ctx
191
+ // explicitly - the run executes in a detached waitUntil where the ALS context
192
+ // is gone, so onError fires only if we hand it the captured context.
193
+ const run = () =>
194
+ reportingAsync(
195
+ () => invalidateAcross(capable, valid),
196
+ "cache-invalidate",
197
+ "[revalidateTag] background invalidation",
198
+ ctx,
199
+ );
200
+ if (ctx?.waitUntil) {
201
+ ctx.waitUntil(run);
202
+ } else {
203
+ // No request context (e.g. called outside ALS): best-effort background run.
204
+ void run();
205
+ }
206
+ }