@rangojs/router 0.0.0-experimental.e9c0b2f2 → 0.0.0-experimental.ea9f40f2

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 (222) hide show
  1. package/AGENTS.md +6 -10
  2. package/README.md +289 -938
  3. package/dist/bin/rango.js +271 -46
  4. package/dist/vite/index.js +673 -193
  5. package/package.json +10 -8
  6. package/skills/api-client/SKILL.md +1 -1
  7. package/skills/breadcrumbs/SKILL.md +31 -14
  8. package/skills/cache-guide/SKILL.md +5 -2
  9. package/skills/caching/SKILL.md +59 -4
  10. package/skills/catalog.json +271 -0
  11. package/skills/comparison/SKILL.md +50 -0
  12. package/skills/comparison/agents/openai.yaml +4 -0
  13. package/skills/comparison/references/framework-comparison.md +837 -0
  14. package/skills/composability/SKILL.md +83 -2
  15. package/skills/debug-manifest/SKILL.md +1 -1
  16. package/skills/defer-hydration/SKILL.md +235 -0
  17. package/skills/document-cache/SKILL.md +9 -1
  18. package/skills/fonts/SKILL.md +1 -1
  19. package/skills/handler-use/SKILL.md +8 -8
  20. package/skills/hooks/SKILL.md +54 -892
  21. package/skills/hooks/data.md +273 -0
  22. package/skills/hooks/handle-and-actions.md +103 -0
  23. package/skills/hooks/navigation.md +110 -0
  24. package/skills/hooks/outlets.md +41 -0
  25. package/skills/hooks/state.md +228 -0
  26. package/skills/hooks/urls.md +135 -0
  27. package/skills/host-router/SKILL.md +4 -4
  28. package/skills/i18n/SKILL.md +1 -1
  29. package/skills/intercept/SKILL.md +46 -14
  30. package/skills/layout/SKILL.md +27 -10
  31. package/skills/links/SKILL.md +1 -1
  32. package/skills/loader/SKILL.md +23 -1
  33. package/skills/middleware/SKILL.md +7 -3
  34. package/skills/migrate-nextjs/SKILL.md +167 -6
  35. package/skills/migrate-react-router/SKILL.md +59 -677
  36. package/skills/migrate-react-router/cloudflare-workers.md +129 -0
  37. package/skills/migrate-react-router/component-migration.md +196 -0
  38. package/skills/migrate-react-router/data-and-actions.md +225 -0
  39. package/skills/migrate-react-router/route-mapping.md +271 -0
  40. package/skills/mime-routes/SKILL.md +1 -1
  41. package/skills/observability/SKILL.md +9 -1
  42. package/skills/parallel/SKILL.md +23 -4
  43. package/skills/ppr/SKILL.md +622 -0
  44. package/skills/prerender/SKILL.md +28 -18
  45. package/skills/rango/SKILL.md +84 -25
  46. package/skills/response-routes/SKILL.md +15 -1
  47. package/skills/route/SKILL.md +71 -4
  48. package/skills/router-setup/SKILL.md +14 -3
  49. package/skills/scripts/SKILL.md +1 -1
  50. package/skills/server-actions/SKILL.md +3 -2
  51. package/skills/shell-manifest/SKILL.md +185 -0
  52. package/skills/streams-and-websockets/SKILL.md +1 -1
  53. package/skills/tailwind/SKILL.md +1 -1
  54. package/skills/testing/SKILL.md +2 -1
  55. package/skills/testing/handles.md +4 -2
  56. package/skills/testing/render-handler.md +15 -14
  57. package/skills/testing/reverse-and-types.md +8 -7
  58. package/skills/theme/SKILL.md +1 -1
  59. package/skills/typesafety/SKILL.md +45 -919
  60. package/skills/typesafety/env-and-bindings.md +254 -0
  61. package/skills/typesafety/generated-files-and-cli.md +335 -0
  62. package/skills/typesafety/params-and-search.md +153 -0
  63. package/skills/typesafety/route-types.md +209 -0
  64. package/skills/use-cache/SKILL.md +30 -3
  65. package/skills/vercel/SKILL.md +1 -1
  66. package/skills/view-transitions/SKILL.md +44 -1
  67. package/src/browser/event-controller.ts +62 -10
  68. package/src/browser/logging.ts +28 -0
  69. package/src/browser/merge-segment-loaders.ts +6 -4
  70. package/src/browser/navigation-bridge.ts +65 -16
  71. package/src/browser/navigation-client.ts +32 -2
  72. package/src/browser/navigation-store.ts +128 -14
  73. package/src/browser/network-error-handler.ts +34 -7
  74. package/src/browser/partial-update.ts +76 -17
  75. package/src/browser/prefetch/cache.ts +51 -11
  76. package/src/browser/prefetch/fetch.ts +59 -21
  77. package/src/browser/prefetch/queue.ts +19 -4
  78. package/src/browser/react/Link.tsx +13 -3
  79. package/src/browser/react/NavigationProvider.tsx +108 -4
  80. package/src/browser/response-adapter.ts +38 -9
  81. package/src/browser/rsc-router.tsx +54 -4
  82. package/src/browser/scroll-restoration.ts +7 -5
  83. package/src/browser/segment-reconciler.ts +31 -21
  84. package/src/browser/server-action-bridge.ts +22 -10
  85. package/src/browser/types.ts +54 -1
  86. package/src/build/generate-manifest.ts +155 -131
  87. package/src/build/index.ts +3 -1
  88. package/src/build/route-trie.ts +35 -7
  89. package/src/build/route-types/include-resolution.ts +347 -47
  90. package/src/build/runtime-discovery.ts +4 -1
  91. package/src/cache/cache-key-utils.ts +29 -0
  92. package/src/cache/cache-runtime.ts +262 -71
  93. package/src/cache/cache-scope.ts +2 -17
  94. package/src/cache/cache-tag.ts +60 -14
  95. package/src/cache/cf/cf-cache-store.ts +243 -20
  96. package/src/cache/document-cache.ts +54 -21
  97. package/src/cache/index.ts +1 -0
  98. package/src/cache/memory-segment-store.ts +110 -3
  99. package/src/cache/profile-registry.ts +15 -0
  100. package/src/cache/read-through-swr.ts +15 -1
  101. package/src/cache/segment-codec.ts +4 -4
  102. package/src/cache/shell-snapshot.ts +417 -0
  103. package/src/cache/types.ts +158 -0
  104. package/src/cache/vercel/vercel-cache-store.ts +401 -124
  105. package/src/client.rsc.tsx +0 -3
  106. package/src/client.tsx +0 -3
  107. package/src/cloudflare/tracing.ts +7 -8
  108. package/src/defer.ts +11 -22
  109. package/src/handle.ts +37 -15
  110. package/src/handles/MetaTags.tsx +16 -82
  111. package/src/handles/breadcrumbs.ts +12 -14
  112. package/src/handles/deferred-resolution.ts +127 -0
  113. package/src/handles/is-thenable.ts +7 -8
  114. package/src/handles/meta.ts +7 -44
  115. package/src/host/errors.ts +15 -0
  116. package/src/host/index.ts +1 -0
  117. package/src/index.rsc.ts +8 -2
  118. package/src/index.ts +19 -13
  119. package/src/internal-debug.ts +11 -8
  120. package/src/prerender.ts +17 -4
  121. package/src/redirect-origin.ts +14 -0
  122. package/src/render-error-thrower.tsx +20 -0
  123. package/src/route-content-wrapper.tsx +12 -5
  124. package/src/route-definition/dsl-helpers.ts +21 -32
  125. package/src/route-definition/helper-factories.ts +0 -2
  126. package/src/route-definition/helpers-types.ts +43 -43
  127. package/src/route-definition/index.ts +1 -2
  128. package/src/route-definition/resolve-handler-use.ts +0 -1
  129. package/src/route-definition/use-item-types.ts +3 -6
  130. package/src/route-map-builder.ts +41 -4
  131. package/src/route-types.ts +0 -5
  132. package/src/router/find-match.ts +86 -8
  133. package/src/router/instrument.ts +9 -4
  134. package/src/router/lazy-includes.ts +72 -12
  135. package/src/router/loader-resolution.ts +14 -2
  136. package/src/router/manifest.ts +56 -11
  137. package/src/router/match-api.ts +76 -32
  138. package/src/router/match-handlers.ts +181 -135
  139. package/src/router/match-middleware/background-revalidation.ts +40 -23
  140. package/src/router/match-middleware/cache-store.ts +39 -24
  141. package/src/router/match-result.ts +35 -15
  142. package/src/router/middleware.ts +64 -38
  143. package/src/router/navigation-snapshot.ts +7 -5
  144. package/src/router/parse-pattern.ts +115 -0
  145. package/src/router/pattern-matching.ts +53 -64
  146. package/src/router/prefetch-limits.ts +37 -0
  147. package/src/router/prerender-match.ts +11 -5
  148. package/src/router/preview-match.ts +3 -1
  149. package/src/router/request-classification.ts +23 -8
  150. package/src/router/route-snapshot.ts +14 -2
  151. package/src/router/router-context.ts +3 -1
  152. package/src/router/router-interfaces.ts +32 -1
  153. package/src/router/router-options.ts +30 -0
  154. package/src/router/segment-resolution/fresh.ts +39 -3
  155. package/src/router/segment-resolution/loader-cache.ts +93 -2
  156. package/src/router/segment-resolution/loader-mask.ts +60 -0
  157. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  158. package/src/router/segment-resolution/mask-nested.ts +83 -0
  159. package/src/router/segment-resolution/revalidation.ts +3 -0
  160. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  161. package/src/router/substitute-pattern-params.ts +54 -35
  162. package/src/router/telemetry-otel.ts +6 -8
  163. package/src/router/telemetry.ts +9 -1
  164. package/src/router/tracing.ts +14 -5
  165. package/src/router/trie-matching.ts +19 -11
  166. package/src/router/url-params.ts +13 -0
  167. package/src/router.ts +47 -16
  168. package/src/rsc/full-payload.ts +70 -0
  169. package/src/rsc/handler.ts +60 -33
  170. package/src/rsc/manifest-init.ts +1 -1
  171. package/src/rsc/nonce.ts +10 -1
  172. package/src/rsc/progressive-enhancement.ts +61 -4
  173. package/src/rsc/redirect-guard.ts +2 -1
  174. package/src/rsc/rsc-rendering.ts +429 -37
  175. package/src/rsc/server-action.ts +25 -2
  176. package/src/rsc/shell-capture.ts +1190 -0
  177. package/src/rsc/shell-serve.ts +181 -0
  178. package/src/rsc/transition-gate.ts +89 -0
  179. package/src/rsc/types.ts +30 -0
  180. package/src/segment-loader-promise.ts +18 -0
  181. package/src/segment-system.tsx +149 -14
  182. package/src/server/context.ts +67 -9
  183. package/src/server/cookie-store.ts +73 -1
  184. package/src/server/loader-registry.ts +13 -1
  185. package/src/server/request-context.ts +169 -10
  186. package/src/ssr/index.tsx +462 -178
  187. package/src/ssr/inject-rsc-eager.ts +167 -0
  188. package/src/ssr/ssr-root.tsx +228 -0
  189. package/src/testing/collect-handle.ts +14 -8
  190. package/src/testing/dispatch.ts +152 -40
  191. package/src/testing/generated-routes.ts +27 -11
  192. package/src/testing/index.ts +6 -0
  193. package/src/testing/render-handler.ts +14 -0
  194. package/src/testing/render-route.tsx +13 -10
  195. package/src/testing/run-transition-when.ts +164 -0
  196. package/src/theme/ThemeProvider.tsx +36 -26
  197. package/src/types/handler-context.ts +1 -1
  198. package/src/types/index.ts +2 -0
  199. package/src/types/route-config.ts +19 -7
  200. package/src/types/segments.ts +100 -0
  201. package/src/urls/include-helper.ts +10 -8
  202. package/src/urls/include-provider.ts +71 -0
  203. package/src/urls/index.ts +1 -0
  204. package/src/urls/path-helper-types.ts +44 -12
  205. package/src/urls/path-helper.ts +5 -0
  206. package/src/urls/pattern-types.ts +36 -0
  207. package/src/urls/type-extraction.ts +43 -18
  208. package/src/urls/urls-function.ts +0 -1
  209. package/src/vercel/tracing.ts +7 -7
  210. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  211. package/src/vite/discovery/discover-routers.ts +1 -1
  212. package/src/vite/discovery/discovery-errors.ts +61 -0
  213. package/src/vite/index.ts +7 -0
  214. package/src/vite/inject-client-debug.ts +88 -0
  215. package/src/vite/plugins/vercel-output.ts +114 -25
  216. package/src/vite/plugins/version-injector.ts +22 -7
  217. package/src/vite/plugins/virtual-entries.ts +80 -22
  218. package/src/vite/rango.ts +29 -19
  219. package/src/vite/router-discovery.ts +171 -43
  220. package/src/vite/utils/prerender-utils.ts +17 -4
  221. package/src/vite/utils/shared-utils.ts +47 -0
  222. package/src/network-error-thrower.tsx +0 -18
@@ -34,6 +34,7 @@ import type {
34
34
  CacheGetResult,
35
35
  CacheItemResult,
36
36
  CacheItemOptions,
37
+ ShellCacheEntry,
37
38
  } from "../types.js";
38
39
  import {
39
40
  _getRequestContext,
@@ -136,6 +137,16 @@ const warnedNoKvReadInvalidation = new Set<string>();
136
137
  */
137
138
  const warnedTagInvalidationTtlFloor = new Set<string>();
138
139
 
140
+ /**
141
+ * Stores (by namespace) already warned that tag invalidation is writing KV
142
+ * markers with no expiry (tagInvalidationTtl unset), so the unbounded-growth
143
+ * warning fires once per process rather than once per invalidateTags call
144
+ * (CFCacheStore is constructed per request; invalidateTags runs per marker
145
+ * batch). Distinct from the floor warning: that one only fires for a positive
146
+ * below-floor value, never for the unset (no-expiry) default that this bounds.
147
+ */
148
+ const warnedNoTagInvalidationTtl = new Set<string>();
149
+
139
150
  // ============================================================================
140
151
  // Types
141
152
  // ============================================================================
@@ -189,6 +200,35 @@ interface KVItemEnvelope {
189
200
  ta?: number;
190
201
  }
191
202
 
203
+ /**
204
+ * KV envelope for PPR shell cache entries.
205
+ * @internal
206
+ */
207
+ interface KVShellEnvelope {
208
+ /** base64-encoded prelude bytes */
209
+ p: string;
210
+ /** postponed state JSON, or null (DATA variant — no holes) */
211
+ po: string | null;
212
+ /** React.version captured at prerender time */
213
+ rv: string;
214
+ /** Build version captured at prerender time (ShellCacheEntry.buildVersion) */
215
+ bv?: string;
216
+ /** createdAt (ms epoch) */
217
+ c: number;
218
+ /** When entry becomes stale (ms epoch) */
219
+ s: number;
220
+ /** When entry hard-expires (ms epoch) */
221
+ e: number;
222
+ /** Cache tags (for distributed tag invalidation) */
223
+ t?: string[];
224
+ /** Timestamp when tags were attached (ms epoch) */
225
+ ta?: number;
226
+ /** initialTheme the capture render was built with (resume theme fidelity) */
227
+ i?: string;
228
+ /** Capture data snapshot: recorded cache-store hits/writes for HIT parity */
229
+ sn?: import("../types.js").ShellSnapshotRecord[];
230
+ }
231
+
192
232
  /**
193
233
  * KV envelope for document cache entries.
194
234
  * @internal
@@ -312,19 +352,30 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
312
352
  // kv - yet every tagged read still serves stale data with no other signal.
313
353
  // Surface that misconfiguration.
314
354
  if (!this.kv && (this.tagCacheTtl > 0 || this.onRevalidateTag)) {
315
- const id = this.namespace ?? "default";
316
- if (!warnedNoKvReadInvalidation.has(id)) {
317
- warnedNoKvReadInvalidation.add(id);
318
- console.warn(
319
- `[CFCacheStore] tagCacheTtl/onRevalidateTag is configured without a KV ` +
320
- `namespace, so tag invalidation has NO read-side effect: tagged reads ` +
321
- `are never treated as invalidated and serve stale data. Configure ` +
322
- `{ kv } for distributed tag invalidation.`,
323
- );
324
- }
355
+ this.warnOncePerNamespace(
356
+ warnedNoKvReadInvalidation,
357
+ `[CFCacheStore] tagCacheTtl/onRevalidateTag is configured without a KV ` +
358
+ `namespace, so tag invalidation has NO read-side effect: tagged reads ` +
359
+ `are never treated as invalidated and serve stale data. Configure ` +
360
+ `{ kv } for distributed tag invalidation.`,
361
+ );
325
362
  }
326
363
  }
327
364
 
365
+ /**
366
+ * Warn about a namespace-scoped misconfiguration once per namespace per
367
+ * isolate. `seen` is the module-level Set for that message family -- Sets
368
+ * are module-level (not instance fields) so re-constructed stores in the
369
+ * same isolate don't re-warn.
370
+ * @internal
371
+ */
372
+ private warnOncePerNamespace(seen: Set<string>, message: string): void {
373
+ const id = this.namespace ?? "default";
374
+ if (seen.has(id)) return;
375
+ seen.add(id);
376
+ console.warn(message);
377
+ }
378
+
328
379
  /**
329
380
  * Validate a consumer-supplied tagInvalidationTtl against CF KV's expirationTtl
330
381
  * floor. A finite value below KV_MIN_EXPIRATION_TTL is raised to it (with a
@@ -340,16 +391,13 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
340
391
  if (value == null) return undefined;
341
392
  if (!Number.isFinite(value) || value <= 0) return undefined;
342
393
  if (value < KV_MIN_EXPIRATION_TTL) {
343
- const id = this.namespace ?? "default";
344
- if (!warnedTagInvalidationTtlFloor.has(id)) {
345
- warnedTagInvalidationTtlFloor.add(id);
346
- console.warn(
347
- `[CFCacheStore] tagInvalidationTtl ${value} is below Cloudflare KV's ` +
348
- `${KV_MIN_EXPIRATION_TTL}s expirationTtl floor; raising to ` +
349
- `${KV_MIN_EXPIRATION_TTL}. It must still exceed your largest entry ` +
350
- `TTL+SWR or invalidated entries can resurrect when the marker expires.`,
351
- );
352
- }
394
+ this.warnOncePerNamespace(
395
+ warnedTagInvalidationTtlFloor,
396
+ `[CFCacheStore] tagInvalidationTtl ${value} is below Cloudflare KV's ` +
397
+ `${KV_MIN_EXPIRATION_TTL}s expirationTtl floor; raising to ` +
398
+ `${KV_MIN_EXPIRATION_TTL}. It must still exceed your largest entry ` +
399
+ `TTL+SWR or invalidated entries can resurrect when the marker expires.`,
400
+ );
353
401
  return KV_MIN_EXPIRATION_TTL;
354
402
  }
355
403
  return value;
@@ -1574,6 +1622,143 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1574
1622
  }
1575
1623
  }
1576
1624
 
1625
+ // ============================================================================
1626
+ // Shell Cache Methods (PPR shell resume) — KV-only in v1
1627
+ // ============================================================================
1628
+ //
1629
+ // Unlike the segment/item/document tiers, the shell family has NO Cache-API L1
1630
+ // tier: the prelude bytes + postponed blob are large and version-coupled, and a
1631
+ // per-colo L1 for them is a deliberate follow-up (see the PPR shell-resume
1632
+ // design doc). Shell entries live only in KV (the global tier), so the family
1633
+ // requires a configured KV namespace; without one, getShell/putShell no-op and
1634
+ // the shell-cache middleware fails open to a full HTML render. Tag invalidation
1635
+ // still applies: shell entries carry tags/taggedAt and are checked against the
1636
+ // same KV markers isGloballyInvalidated() reads for every other tier.
1637
+
1638
+ /**
1639
+ * Get a cached PPR shell entry by key from KV (no L1). Applies the KV read
1640
+ * budget, corrupt-entry eviction, hard-expiry, and tag invalidation exactly
1641
+ * like kvGetItem, minus the L1 promote. SWR is a plain staleness flag — KV has
1642
+ * no REVALIDATING herd guard, so the shell-cache middleware's module-level
1643
+ * in-flight set is the recapture stampede guard.
1644
+ */
1645
+ async getShell(
1646
+ key: string,
1647
+ ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
1648
+ if (!this.kv) return null;
1649
+ try {
1650
+ const kvKey = this.toKVKey(`shell:${key}`);
1651
+ const { value: envelope, timedOut } =
1652
+ await this.kvGetOrEvict<KVShellEnvelope>(
1653
+ kvKey,
1654
+ (e) =>
1655
+ typeof e.p === "string" &&
1656
+ (e.po === null || typeof e.po === "string") &&
1657
+ typeof e.rv === "string" &&
1658
+ typeof e.e === "number" &&
1659
+ typeof e.s === "number",
1660
+ "getShell",
1661
+ );
1662
+ // A timeout, a missing key, or an already-evicted corrupt entry is a miss.
1663
+ if (timedOut || !envelope) return null;
1664
+
1665
+ const now = Date.now();
1666
+ if (now > envelope.e) return null;
1667
+
1668
+ if (await this.isGloballyInvalidated(envelope.t, envelope.ta)) {
1669
+ return null;
1670
+ }
1671
+
1672
+ const shouldRevalidate = envelope.s > 0 && now > envelope.s;
1673
+ return {
1674
+ entry: {
1675
+ prelude: envelope.p,
1676
+ postponed: envelope.po,
1677
+ reactVersion: envelope.rv,
1678
+ buildVersion: envelope.bv,
1679
+ initialTheme: envelope.i,
1680
+ snapshot: envelope.sn,
1681
+ createdAt: envelope.c,
1682
+ },
1683
+ shouldRevalidate,
1684
+ };
1685
+ } catch (error) {
1686
+ reportCacheError(error, "cache-read", "[CFCacheStore] getShell");
1687
+ return null;
1688
+ }
1689
+ }
1690
+
1691
+ /**
1692
+ * Store a PPR shell entry in KV with TTL and optional SWR window. Non-blocking
1693
+ * (waitUntil) like the other KV writes. The tags/taggedAt ride in the envelope
1694
+ * so isGloballyInvalidated() can invalidate the shell via the shared KV markers.
1695
+ */
1696
+ async putShell(
1697
+ key: string,
1698
+ entry: ShellCacheEntry,
1699
+ ttlSeconds?: number,
1700
+ swrSeconds?: number,
1701
+ tags?: string[],
1702
+ ): Promise<void> {
1703
+ // KV-only tier: needs a KV namespace and waitUntil (writes are non-blocking).
1704
+ if (!this.kv || !this.waitUntil) return;
1705
+ try {
1706
+ const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
1707
+ const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
1708
+ const totalTtl = ttl + swrWindow;
1709
+ // KV requires expirationTtl >= 60s; skip a shorter-lived shell rather than
1710
+ // letting kv.put reject inside waitUntil (mirrors setItem/kvSetSegment).
1711
+ if (totalTtl < 60) return;
1712
+
1713
+ const staleAt = Date.now() + ttl * 1000;
1714
+ const taggedAt =
1715
+ Array.isArray(tags) && tags.length > 0 ? Date.now() : undefined;
1716
+
1717
+ const kvKey = this.toKVKey(`shell:${key}`);
1718
+ // A key over the KV limit makes kv.put reject deep inside waitUntil; report
1719
+ // and skip the doomed write (mirrors kvSetSegment).
1720
+ const kvKeyBytes = kvKeyByteLength(kvKey);
1721
+ if (kvKeyBytes > KV_MAX_KEY_BYTES) {
1722
+ reportCacheError(
1723
+ new Error(
1724
+ `shell cache key produces a ${kvKeyBytes}-byte KV key, over the ` +
1725
+ `${KV_MAX_KEY_BYTES}-byte limit; the shell was not persisted.`,
1726
+ ),
1727
+ "cache-write",
1728
+ "[CFCacheStore] putShell",
1729
+ );
1730
+ return;
1731
+ }
1732
+
1733
+ this.waitUntil(() =>
1734
+ reportingAsync(
1735
+ () => {
1736
+ const envelope: KVShellEnvelope = {
1737
+ p: entry.prelude,
1738
+ po: entry.postponed,
1739
+ rv: entry.reactVersion,
1740
+ bv: entry.buildVersion,
1741
+ c: entry.createdAt,
1742
+ s: staleAt,
1743
+ e: staleAt + swrWindow * 1000,
1744
+ t: tags,
1745
+ ta: taggedAt,
1746
+ i: entry.initialTheme,
1747
+ sn: entry.snapshot,
1748
+ };
1749
+ return this.kv!.put(kvKey, JSON.stringify(envelope), {
1750
+ expirationTtl: totalTtl,
1751
+ });
1752
+ },
1753
+ "cache-write",
1754
+ "[CFCacheStore] putShell",
1755
+ ),
1756
+ );
1757
+ } catch (error) {
1758
+ reportCacheError(error, "cache-write", "[CFCacheStore] putShell");
1759
+ }
1760
+ }
1761
+
1577
1762
  // ============================================================================
1578
1763
  // Key Helpers
1579
1764
  // ============================================================================
@@ -2091,6 +2276,22 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2091
2276
  const failedTags = new Set<string>();
2092
2277
  const errors: unknown[] = [];
2093
2278
  if (this.kv) {
2279
+ // Markers written with no expiry (tagInvalidationTtl unset) never expire,
2280
+ // so high-cardinality tags accumulate KV keys unboundedly with no reaper.
2281
+ // Warn once per namespace at the batch entry point (not per marker write,
2282
+ // which would fire once per tag). Kept separate from the floor warning:
2283
+ // that path only fires for a positive below-floor value, never the unset
2284
+ // default sanitizeTagInvalidationTtl passes through as undefined.
2285
+ if (!this.tagInvalidationTtl) {
2286
+ this.warnOncePerNamespace(
2287
+ warnedNoTagInvalidationTtl,
2288
+ `[CFCacheStore] invalidateTags is writing KV markers with no expiry ` +
2289
+ `(tagInvalidationTtl is unset): high-cardinality tags accumulate KV ` +
2290
+ `keys unboundedly (storage + list-scan cost) with no reaper. Set ` +
2291
+ `tagInvalidationTtl above your largest entry TTL+SWR to bound marker ` +
2292
+ `growth; setting it too small resurrects invalidated entries.`,
2293
+ );
2294
+ }
2094
2295
  await Promise.all(
2095
2296
  tags.map(async (tag) => {
2096
2297
  const markerKey = this.tagMarkerKey(tag);
@@ -2267,6 +2468,28 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2267
2468
  if (!this.kv || !this.waitUntil || totalTtl < 60) return;
2268
2469
 
2269
2470
  const kvKey = this.toKVKey(key);
2471
+
2472
+ // Reject an oversized data-segment KV key the same way tag-marker keys are
2473
+ // rejected in invalidateTags(). A key over KV_MAX_KEY_BYTES makes kv.put()
2474
+ // fail, so the segment silently never lands in L2 (KV) and every cold-colo
2475
+ // or TTL-expired read re-renders instead of serving stale. Segment keys can
2476
+ // grow with user-controlled inputs (e.g. a route's search params), so report
2477
+ // a clear, actionable error and skip the doomed write rather than letting it
2478
+ // reject deep inside waitUntil as an opaque cache-write failure.
2479
+ const kvKeyBytes = kvKeyByteLength(kvKey);
2480
+ if (kvKeyBytes > KV_MAX_KEY_BYTES) {
2481
+ reportCacheError(
2482
+ new Error(
2483
+ `cache segment key produces a ${kvKeyBytes}-byte KV key, over the ` +
2484
+ `${KV_MAX_KEY_BYTES}-byte limit; the segment was not persisted to KV (L2). ` +
2485
+ `Reduce the cache-key inputs (e.g. large search params on this route).`,
2486
+ ),
2487
+ "cache-write",
2488
+ "[CFCacheStore] kvSetSegment",
2489
+ );
2490
+ return;
2491
+ }
2492
+
2270
2493
  const expiresAt = staleAt + swrWindow * 1000;
2271
2494
 
2272
2495
  this.waitUntil(() =>
@@ -15,10 +15,11 @@ import type { MiddlewareFn, MiddlewareContext } from "../router/middleware.js";
15
15
  import { hasPerClientSignal } from "../browser/cookie-name.js";
16
16
  import {
17
17
  getRequestContext,
18
+ runWithRequestContext,
18
19
  type RequestContext,
19
20
  } from "../server/request-context.js";
20
21
  import { mayNeedSSR } from "../rsc/ssr-setup.js";
21
- import { sortedSearchString } from "./cache-key-utils.js";
22
+ import { cacheKeyBase } from "./cache-key-utils.js";
22
23
  import { runBackground } from "./background-task.js";
23
24
  import { reportCacheError } from "./cache-error.js";
24
25
 
@@ -126,20 +127,31 @@ function shouldCacheResponse(response: Response): CacheDirectives | null {
126
127
  // ============================================================================
127
128
 
128
129
  /**
129
- * Add cache status header to response for debugging
130
+ * Add the cache-status header (HIT/STALE/MISS) to a response.
131
+ *
132
+ * The response we get here is always a fresh instance — the store rebuilds a
133
+ * new Response per getResponse(), and the miss path wraps a fresh Response
134
+ * around the tee'd body — so mutating its headers in place is safe and avoids
135
+ * cloning every header + allocating a new Response on every cache hit. Only when
136
+ * the headers are immutable (a guarded Response rejects set() with a TypeError)
137
+ * do we fall back to rebuilding the Response with a mutable Headers.
130
138
  */
131
139
  function addCacheStatusHeader(
132
140
  response: Response,
133
141
  status: "HIT" | "STALE" | "MISS",
134
142
  ): Response {
135
- const headers = new Headers(response.headers);
136
- headers.set(CACHE_STATUS_HEADER, status);
137
-
138
- return new Response(response.body, {
139
- status: response.status,
140
- statusText: response.statusText,
141
- headers,
142
- });
143
+ try {
144
+ response.headers.set(CACHE_STATUS_HEADER, status);
145
+ return response;
146
+ } catch {
147
+ const headers = new Headers(response.headers);
148
+ headers.set(CACHE_STATUS_HEADER, status);
149
+ return new Response(response.body, {
150
+ status: response.status,
151
+ statusText: response.statusText,
152
+ headers,
153
+ });
154
+ }
143
155
  }
144
156
 
145
157
  /**
@@ -176,7 +188,13 @@ export interface DocumentCacheOptions<TEnv = any> {
176
188
  skipPaths?: string[];
177
189
 
178
190
  /**
179
- * Custom cache key generator
191
+ * Custom cache key generator.
192
+ *
193
+ * Replaces the default `host + pathname + search` key entirely. On a
194
+ * multi-domain deployment served by one function you MUST include `url.host`
195
+ * (or an equivalent tenant discriminator) yourself — the default key is
196
+ * host-namespaced, but a custom generator's output is used verbatim, so
197
+ * omitting host bleeds one hostname's cached response to another.
180
198
  */
181
199
  keyGenerator?: (url: URL) => string;
182
200
 
@@ -299,17 +317,17 @@ export function createDocumentCacheMiddleware<TEnv = any>(
299
317
  isPartial && clientSegments ? `:${hashSegmentIds(clientSegments)}` : "";
300
318
  const typeSuffix = isRscRequest ? ":rsc" : ":html";
301
319
 
302
- let searchSuffix = "";
303
- if (!keyGenerator) {
304
- const sorted = sortedSearchString(url.searchParams);
305
- if (sorted) {
306
- searchSuffix = `?${sorted}`;
307
- }
308
- }
309
-
320
+ // Default key rides the shared host-namespaced base (cacheKeyBase) so the
321
+ // segment tier (cache-scope.ts) and this document tier cannot drift on the
322
+ // host-namespacing rule -- see the contract on cacheKeyBase.
323
+ // The keyGenerator branch is left untouched: a consumer-supplied generator
324
+ // owns its own namespacing (auto-prefixing host would silently change their
325
+ // existing keys and double any host they already include).
310
326
  const cacheKey = keyGenerator
311
327
  ? keyGenerator(url) + segmentHash + typeSuffix
312
- : `${url.pathname}${searchSuffix}${segmentHash}${typeSuffix}`;
328
+ : cacheKeyBase(url.host, url.pathname, url.searchParams) +
329
+ segmentHash +
330
+ typeSuffix;
313
331
  // 1. Check cache
314
332
  const cached = await store.getResponse(cacheKey);
315
333
 
@@ -330,7 +348,13 @@ export function createDocumentCacheMiddleware<TEnv = any>(
330
348
 
331
349
  runBackground(requestCtx, async () => {
332
350
  try {
333
- const fresh = await next();
351
+ // Re-establish the request-context ALS around the background
352
+ // re-render: next() re-runs the full handler pipeline, and on
353
+ // workerd a waitUntil task runs detached from the request's I/O
354
+ // context, so a handler/component reading getRequestContext() would
355
+ // otherwise throw. Same fix as the route-level/use-cache background
356
+ // revalidation paths.
357
+ const fresh = await runWithRequestContext(requestCtx, () => next());
334
358
  const directives = shouldCacheResponse(fresh);
335
359
 
336
360
  if (directives && fresh.body) {
@@ -348,10 +372,15 @@ export function createDocumentCacheMiddleware<TEnv = any>(
348
372
  log(`[DocumentCache] REVALIDATED ${typeLabel}: ${url.pathname}`);
349
373
  }
350
374
  } catch (error) {
375
+ // Pass requestCtx explicitly: this runs in a detached waitUntil task
376
+ // where the ALS context is gone, so onError only fires if we hand it
377
+ // the captured context (reportCacheError falls back to _getRequestContext
378
+ // otherwise, which is null here).
351
379
  reportCacheError(
352
380
  error,
353
381
  "cache-write",
354
382
  "[DocumentCache] revalidation",
383
+ requestCtx,
355
384
  );
356
385
  }
357
386
  });
@@ -401,10 +430,14 @@ export function createDocumentCacheMiddleware<TEnv = any>(
401
430
  collectRequestTags(requestCtx),
402
431
  );
403
432
  } catch (error) {
433
+ // Detached waitUntil task — pass the captured requestCtx so onError
434
+ // fires even though the ALS context is gone (see the revalidation
435
+ // catch above).
404
436
  reportCacheError(
405
437
  error,
406
438
  "cache-write",
407
439
  "[DocumentCache] cache write",
440
+ requestCtx,
408
441
  );
409
442
  }
410
443
  });
@@ -4,6 +4,7 @@ export type {
4
4
  CacheGetResult,
5
5
  CacheItemResult,
6
6
  CacheItemOptions,
7
+ ShellCacheEntry,
7
8
  SerializedSegmentData,
8
9
  SegmentHandleData,
9
10
  } from "./types.js";