@rangojs/router 0.0.0-experimental.f2d1a2f1 → 0.0.0-experimental.f4a8e8de

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 (1301) hide show
  1. package/AGENTS.md +8 -4
  2. package/README.md +318 -821
  3. package/dist/bin/rango.js +925 -271
  4. package/dist/testing/vitest.js +82 -0
  5. package/dist/types/__internal.d.ts +127 -0
  6. package/dist/types/bin/rango.d.ts +1 -0
  7. package/dist/types/browser/action-coordinator.d.ts +57 -0
  8. package/dist/types/browser/action-fence.d.ts +33 -0
  9. package/dist/types/browser/app-shell.d.ts +34 -0
  10. package/dist/types/browser/app-version.d.ts +6 -0
  11. package/dist/types/browser/connection-warmup.d.ts +31 -0
  12. package/dist/types/browser/cookie-name.d.ts +66 -0
  13. package/dist/types/browser/dev-discovery.d.ts +11 -0
  14. package/dist/types/browser/event-controller.d.ts +231 -0
  15. package/dist/types/browser/history-state.d.ts +26 -0
  16. package/dist/types/browser/index.d.ts +1 -0
  17. package/dist/types/browser/intercept-utils.d.ts +30 -0
  18. package/dist/types/browser/invalidate-client-cache.d.ts +17 -0
  19. package/dist/types/browser/link-interceptor.d.ts +53 -0
  20. package/dist/types/browser/logging.d.ts +33 -0
  21. package/dist/types/browser/merge-segment-loaders.d.ts +38 -0
  22. package/dist/types/browser/navigation-bridge.d.ts +39 -0
  23. package/dist/types/browser/navigation-client.d.ts +17 -0
  24. package/dist/types/browser/navigation-store-handle.d.ts +25 -0
  25. package/dist/types/browser/navigation-store.d.ts +85 -0
  26. package/dist/types/browser/navigation-transaction.d.ts +75 -0
  27. package/dist/types/browser/network-error-handler.d.ts +35 -0
  28. package/dist/types/browser/notify-listeners.d.ts +2 -0
  29. package/dist/types/browser/partial-update.d.ts +61 -0
  30. package/dist/types/browser/prefetch/cache.d.ts +224 -0
  31. package/dist/types/browser/prefetch/default-strategy.d.ts +31 -0
  32. package/dist/types/browser/prefetch/fetch.d.ts +52 -0
  33. package/dist/types/browser/prefetch/invalidation.d.ts +6 -0
  34. package/dist/types/browser/prefetch/loader.d.ts +12 -0
  35. package/dist/types/browser/prefetch/observer.d.ts +24 -0
  36. package/dist/types/browser/prefetch/policy.d.ts +13 -0
  37. package/dist/types/browser/prefetch/queue.d.ts +48 -0
  38. package/dist/types/browser/prefetch/resource-ready.d.ts +28 -0
  39. package/dist/types/browser/prefetch/runtime.d.ts +2 -0
  40. package/dist/types/browser/rango-state.d.ts +56 -0
  41. package/dist/types/browser/react/Link.d.ts +132 -0
  42. package/dist/types/browser/react/NavigationProvider.d.ts +88 -0
  43. package/dist/types/browser/react/ScrollRestoration.d.ts +78 -0
  44. package/dist/types/browser/react/context.d.ts +57 -0
  45. package/dist/types/browser/react/filter-segment-order.d.ts +35 -0
  46. package/dist/types/browser/react/index.d.ts +1 -0
  47. package/dist/types/browser/react/location-state-shared.d.ts +162 -0
  48. package/dist/types/browser/react/location-state.d.ts +29 -0
  49. package/dist/types/browser/react/mount-context.d.ts +23 -0
  50. package/dist/types/browser/react/nonce-context.d.ts +14 -0
  51. package/dist/types/browser/react/shallow-equal.d.ts +5 -0
  52. package/dist/types/browser/react/use-action.d.ts +61 -0
  53. package/dist/types/browser/react/use-handle.d.ts +21 -0
  54. package/dist/types/browser/react/use-href.d.ts +32 -0
  55. package/dist/types/browser/react/use-link-status.d.ts +36 -0
  56. package/dist/types/browser/react/use-mount.d.ts +24 -0
  57. package/dist/types/browser/react/use-navigation.d.ts +15 -0
  58. package/dist/types/browser/react/use-params.d.ts +21 -0
  59. package/dist/types/browser/react/use-pathname.d.ts +13 -0
  60. package/dist/types/browser/react/use-reverse.d.ts +40 -0
  61. package/dist/types/browser/react/use-router.d.ts +23 -0
  62. package/dist/types/browser/react/use-search-params.d.ts +19 -0
  63. package/dist/types/browser/react/use-segments.d.ts +29 -0
  64. package/dist/types/browser/response-adapter.d.ts +58 -0
  65. package/dist/types/browser/rsc-router.d.ts +146 -0
  66. package/dist/types/browser/scroll-restoration.d.ts +103 -0
  67. package/dist/types/browser/segment-reconciler.d.ts +74 -0
  68. package/dist/types/browser/segment-structure-assert.d.ts +16 -0
  69. package/dist/types/browser/server-action-bridge.d.ts +29 -0
  70. package/dist/types/browser/types.d.ts +546 -0
  71. package/dist/types/browser/validate-redirect-origin.d.ts +28 -0
  72. package/dist/types/build/collect-fallback-refs.d.ts +5 -0
  73. package/dist/types/build/generate-manifest.d.ts +106 -0
  74. package/dist/types/build/generate-route-types.d.ts +8 -0
  75. package/dist/types/build/index.d.ts +21 -0
  76. package/dist/types/build/merge-full-manifests.d.ts +3 -0
  77. package/dist/types/build/prefix-tree-utils.d.ts +56 -0
  78. package/dist/types/build/route-trie.d.ts +20 -0
  79. package/dist/types/build/route-types/ast-helpers.d.ts +3 -0
  80. package/dist/types/build/route-types/ast-route-extraction.d.ts +13 -0
  81. package/dist/types/build/route-types/codegen.d.ts +16 -0
  82. package/dist/types/build/route-types/include-resolution.d.ts +74 -0
  83. package/dist/types/build/route-types/param-extraction.d.ts +13 -0
  84. package/dist/types/build/route-types/per-module-writer.d.ts +20 -0
  85. package/dist/types/build/route-types/router-processing.d.ts +81 -0
  86. package/dist/types/build/route-types/scan-filter.d.ts +17 -0
  87. package/dist/types/build/route-types/source-scan.d.ts +13 -0
  88. package/dist/types/build/runtime-discovery.d.ts +24 -0
  89. package/dist/types/cache/background-task.d.ts +21 -0
  90. package/dist/types/cache/cache-error.d.ts +71 -0
  91. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  92. package/dist/types/cache/cache-key-utils.d.ts +43 -0
  93. package/dist/types/cache/cache-policy.d.ts +59 -0
  94. package/dist/types/cache/cache-runtime.d.ts +51 -0
  95. package/dist/types/cache/cache-scope.d.ts +215 -0
  96. package/dist/types/cache/cache-tag.d.ts +79 -0
  97. package/dist/types/cache/cf/cf-base64.d.ts +4 -0
  98. package/dist/types/cache/cf/cf-cache-constants.d.ts +112 -0
  99. package/dist/types/cache/cf/cf-cache-store.d.ts +616 -0
  100. package/dist/types/cache/cf/cf-cache-types.d.ts +365 -0
  101. package/dist/types/cache/cf/cf-kv-utils.d.ts +43 -0
  102. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +15 -0
  103. package/dist/types/cache/cf/cf-zone-purge.d.ts +31 -0
  104. package/dist/types/cache/cf/index.d.ts +4 -0
  105. package/dist/types/cache/document-cache.d.ts +71 -0
  106. package/dist/types/cache/handle-capture.d.ts +23 -0
  107. package/dist/types/cache/handle-snapshot.d.ts +39 -0
  108. package/dist/types/cache/index.d.ts +9 -0
  109. package/dist/types/cache/memory-segment-store.d.ts +163 -0
  110. package/dist/types/cache/profile-registry.d.ts +40 -0
  111. package/dist/types/cache/read-through-swr.d.ts +61 -0
  112. package/dist/types/cache/search-params-filter.d.ts +64 -0
  113. package/dist/types/cache/segment-codec.d.ts +78 -0
  114. package/dist/types/cache/shell-snapshot.d.ts +186 -0
  115. package/dist/types/cache/tag-invalidation.d.ts +74 -0
  116. package/dist/types/cache/taint.d.ts +77 -0
  117. package/dist/types/cache/types.d.ts +503 -0
  118. package/dist/types/cache/vercel/index.d.ts +1 -0
  119. package/dist/types/cache/vercel/vercel-cache-store.d.ts +268 -0
  120. package/dist/types/client-urls/client-root.d.ts +38 -0
  121. package/dist/types/client-urls/client-urls.d.ts +5 -0
  122. package/dist/types/client-urls/navigation.d.ts +38 -0
  123. package/dist/types/client-urls/revalidation-protocol.d.ts +25 -0
  124. package/dist/types/client-urls/server-projection.d.ts +57 -0
  125. package/dist/types/client-urls/types.d.ts +132 -0
  126. package/dist/types/client.d.ts +191 -0
  127. package/dist/types/client.rsc.d.ts +41 -0
  128. package/dist/types/cloudflare/index.d.ts +7 -0
  129. package/dist/types/cloudflare/tracing.d.ts +57 -0
  130. package/dist/types/component-utils.d.ts +46 -0
  131. package/dist/types/components/DefaultDocument.d.ts +13 -0
  132. package/dist/types/context-var.d.ts +84 -0
  133. package/dist/types/debug.d.ts +57 -0
  134. package/dist/types/decode-loader-results.d.ts +42 -0
  135. package/dist/types/default-error-boundary.d.ts +10 -0
  136. package/dist/types/defer.d.ts +89 -0
  137. package/dist/types/deps/browser.d.ts +1 -0
  138. package/dist/types/deps/html-stream-client.d.ts +1 -0
  139. package/dist/types/deps/html-stream-server.d.ts +1 -0
  140. package/dist/types/deps/rsc.d.ts +1 -0
  141. package/dist/types/deps/ssr.d.ts +1 -0
  142. package/dist/types/dev-discovery-protocol.d.ts +5 -0
  143. package/dist/types/encode-kv.d.ts +35 -0
  144. package/dist/types/errors.d.ts +227 -0
  145. package/dist/types/escape-script.d.ts +44 -0
  146. package/dist/types/handle.d.ts +93 -0
  147. package/dist/types/handles/MetaTags.d.ts +17 -0
  148. package/dist/types/handles/Scripts.d.ts +38 -0
  149. package/dist/types/handles/breadcrumbs.d.ts +43 -0
  150. package/dist/types/handles/deferred-resolution.d.ts +53 -0
  151. package/dist/types/handles/is-thenable.d.ts +10 -0
  152. package/dist/types/handles/meta.d.ts +43 -0
  153. package/dist/types/handles/script.d.ts +139 -0
  154. package/dist/types/host/cookie-handler.d.ts +8 -0
  155. package/dist/types/host/errors.d.ts +40 -0
  156. package/dist/types/host/index.d.ts +33 -0
  157. package/dist/types/host/pattern-matcher.d.ts +30 -0
  158. package/dist/types/host/router.d.ts +12 -0
  159. package/dist/types/host/testing.d.ts +41 -0
  160. package/dist/types/host/types.d.ts +148 -0
  161. package/dist/types/host/utils.d.ts +20 -0
  162. package/dist/types/href-client.d.ts +214 -0
  163. package/dist/types/index.d.ts +113 -0
  164. package/dist/types/index.rsc.d.ts +52 -0
  165. package/dist/types/internal-debug.d.ts +1 -0
  166. package/dist/types/loader-redirect.d.ts +18 -0
  167. package/dist/types/loader-store.d.ts +193 -0
  168. package/dist/types/loader.d.ts +18 -0
  169. package/dist/types/loader.rsc.d.ts +18 -0
  170. package/dist/types/missing-id-error.d.ts +1 -0
  171. package/dist/types/outlet-context.d.ts +24 -0
  172. package/dist/types/outlet-provider.d.ts +14 -0
  173. package/dist/types/prerender/build-shell-capture.d.ts +104 -0
  174. package/dist/types/prerender/param-hash.d.ts +6 -0
  175. package/dist/types/prerender/shell-manifest-key.d.ts +18 -0
  176. package/dist/types/prerender/store.d.ts +63 -0
  177. package/dist/types/prerender.d.ts +292 -0
  178. package/dist/types/redirect-origin.d.ts +67 -0
  179. package/dist/types/regex-escape.d.ts +6 -0
  180. package/dist/types/render-error-thrower.d.ts +13 -0
  181. package/dist/types/response-utils.d.ts +35 -0
  182. package/dist/types/reverse.d.ts +206 -0
  183. package/dist/types/root-error-boundary.d.ts +32 -0
  184. package/dist/types/route-content-wrapper.d.ts +81 -0
  185. package/dist/types/route-definition/dsl-helpers.d.ts +130 -0
  186. package/dist/types/route-definition/helper-factories.d.ts +22 -0
  187. package/dist/types/route-definition/helpers-types.d.ts +393 -0
  188. package/dist/types/route-definition/index.d.ts +7 -0
  189. package/dist/types/route-definition/redirect.d.ts +48 -0
  190. package/dist/types/route-definition/resolve-handler-use.d.ts +19 -0
  191. package/dist/types/route-definition/use-item-types.d.ts +1 -0
  192. package/dist/types/route-definition.d.ts +1 -0
  193. package/dist/types/route-map-builder.d.ts +102 -0
  194. package/dist/types/route-name.d.ts +27 -0
  195. package/dist/types/route-types.d.ts +172 -0
  196. package/dist/types/router/basename.d.ts +10 -0
  197. package/dist/types/router/content-negotiation.d.ts +91 -0
  198. package/dist/types/router/debug-manifest.d.ts +7 -0
  199. package/dist/types/router/error-handling.d.ts +110 -0
  200. package/dist/types/router/find-match.d.ts +19 -0
  201. package/dist/types/router/handler-context.d.ts +41 -0
  202. package/dist/types/router/instrument.d.ts +191 -0
  203. package/dist/types/router/intercept-resolution.d.ts +91 -0
  204. package/dist/types/router/lazy-includes.d.ts +26 -0
  205. package/dist/types/router/loader-resolution.d.ts +85 -0
  206. package/dist/types/router/logging.d.ts +41 -0
  207. package/dist/types/router/manifest.d.ts +8 -0
  208. package/dist/types/router/match-api.d.ts +19 -0
  209. package/dist/types/router/match-context.d.ts +184 -0
  210. package/dist/types/router/match-handlers.d.ts +48 -0
  211. package/dist/types/router/match-middleware/background-revalidation.d.ts +113 -0
  212. package/dist/types/router/match-middleware/cache-lookup.d.ts +126 -0
  213. package/dist/types/router/match-middleware/cache-store.d.ts +112 -0
  214. package/dist/types/router/match-middleware/index.d.ts +80 -0
  215. package/dist/types/router/match-middleware/intercept-resolution.d.ts +116 -0
  216. package/dist/types/router/match-middleware/segment-resolution.d.ts +94 -0
  217. package/dist/types/router/match-pipelines.d.ts +103 -0
  218. package/dist/types/router/match-result.d.ts +114 -0
  219. package/dist/types/router/metrics.d.ts +6 -0
  220. package/dist/types/router/middleware-types.d.ts +74 -0
  221. package/dist/types/router/middleware.d.ts +116 -0
  222. package/dist/types/router/navigation-snapshot.d.ts +51 -0
  223. package/dist/types/router/params-util.d.ts +8 -0
  224. package/dist/types/router/parse-pattern.d.ts +38 -0
  225. package/dist/types/router/pattern-matching.d.ts +169 -0
  226. package/dist/types/router/prefetch-cache-ttl.d.ts +27 -0
  227. package/dist/types/router/prefetch-default.d.ts +28 -0
  228. package/dist/types/router/prefetch-limits.d.ts +20 -0
  229. package/dist/types/router/prerender-match.d.ts +53 -0
  230. package/dist/types/router/preview-match.d.ts +22 -0
  231. package/dist/types/router/request-classification.d.ts +104 -0
  232. package/dist/types/router/revalidation.d.ts +64 -0
  233. package/dist/types/router/route-snapshot.d.ts +112 -0
  234. package/dist/types/router/route-trie-builder.d.ts +77 -0
  235. package/dist/types/router/router-context.d.ts +137 -0
  236. package/dist/types/router/router-interfaces.d.ts +461 -0
  237. package/dist/types/router/router-options.d.ts +792 -0
  238. package/dist/types/router/router-registry.d.ts +15 -0
  239. package/dist/types/router/segment-resolution/fresh.d.ts +55 -0
  240. package/dist/types/router/segment-resolution/helpers.d.ts +93 -0
  241. package/dist/types/router/segment-resolution/loader-cache.d.ts +33 -0
  242. package/dist/types/router/segment-resolution/loader-mask.d.ts +44 -0
  243. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +90 -0
  244. package/dist/types/router/segment-resolution/mask-nested.d.ts +53 -0
  245. package/dist/types/router/segment-resolution/revalidation.d.ts +85 -0
  246. package/dist/types/router/segment-resolution/static-store.d.ts +17 -0
  247. package/dist/types/router/segment-resolution/streamed-handler-telemetry.d.ts +16 -0
  248. package/dist/types/router/segment-resolution/view-transition-default.d.ts +27 -0
  249. package/dist/types/router/segment-resolution.d.ts +3 -0
  250. package/dist/types/router/segment-wrappers.d.ts +53 -0
  251. package/dist/types/router/state-cookie-name.d.ts +1 -0
  252. package/dist/types/router/substitute-pattern-params.d.ts +23 -0
  253. package/dist/types/router/telemetry-otel.d.ts +113 -0
  254. package/dist/types/router/telemetry.d.ts +216 -0
  255. package/dist/types/router/timeout.d.ts +107 -0
  256. package/dist/types/router/tracing.d.ts +139 -0
  257. package/dist/types/router/transition-when.d.ts +13 -0
  258. package/dist/types/router/trie-matching.d.ts +32 -0
  259. package/dist/types/router/types.d.ts +98 -0
  260. package/dist/types/router/url-params.d.ts +26 -0
  261. package/dist/types/router.d.ts +7 -0
  262. package/dist/types/rsc/capture-queue.d.ts +49 -0
  263. package/dist/types/rsc/full-payload.d.ts +22 -0
  264. package/dist/types/rsc/handler-context.d.ts +32 -0
  265. package/dist/types/rsc/handler.d.ts +9 -0
  266. package/dist/types/rsc/helpers.d.ts +127 -0
  267. package/dist/types/rsc/index.d.ts +17 -0
  268. package/dist/types/rsc/json-route-result.d.ts +20 -0
  269. package/dist/types/rsc/loader-fetch.d.ts +14 -0
  270. package/dist/types/rsc/manifest-init.d.ts +18 -0
  271. package/dist/types/rsc/nonce.d.ts +28 -0
  272. package/dist/types/rsc/origin-guard.d.ts +50 -0
  273. package/dist/types/rsc/progressive-enhancement.d.ts +19 -0
  274. package/dist/types/rsc/redirect-guard.d.ts +37 -0
  275. package/dist/types/rsc/render-pipeline.d.ts +148 -0
  276. package/dist/types/rsc/response-cache-serve.d.ts +46 -0
  277. package/dist/types/rsc/response-error.d.ts +19 -0
  278. package/dist/types/rsc/response-route-handler.d.ts +29 -0
  279. package/dist/types/rsc/routine-plan.d.ts +124 -0
  280. package/dist/types/rsc/rsc-rendering.d.ts +18 -0
  281. package/dist/types/rsc/runtime-warnings.d.ts +22 -0
  282. package/dist/types/rsc/server-action.d.ts +68 -0
  283. package/dist/types/rsc/shell-build-manifest.d.ts +90 -0
  284. package/dist/types/rsc/shell-capture-constants.d.ts +44 -0
  285. package/dist/types/rsc/shell-capture.d.ts +406 -0
  286. package/dist/types/rsc/shell-serve.d.ts +189 -0
  287. package/dist/types/rsc/ssr-setup.d.ts +73 -0
  288. package/dist/types/rsc/stream-idle.d.ts +60 -0
  289. package/dist/types/rsc/transition-gate.d.ts +23 -0
  290. package/dist/types/rsc/types.d.ts +328 -0
  291. package/dist/types/runtime-env.d.ts +1 -0
  292. package/dist/types/search-params.d.ts +125 -0
  293. package/dist/types/segment-content-promise.d.ts +13 -0
  294. package/dist/types/segment-fragments.d.ts +79 -0
  295. package/dist/types/segment-loader-promise.d.ts +22 -0
  296. package/dist/types/segment-system.d.ts +86 -0
  297. package/dist/types/serialize.d.ts +164 -0
  298. package/dist/types/server/context.d.ts +510 -0
  299. package/dist/types/server/cookie-parse.d.ts +10 -0
  300. package/dist/types/server/cookie-store.d.ts +107 -0
  301. package/dist/types/server/fetchable-loader-store.d.ts +20 -0
  302. package/dist/types/server/handle-store.d.ts +131 -0
  303. package/dist/types/server/loader-registry.d.ts +32 -0
  304. package/dist/types/server/request-context.d.ts +707 -0
  305. package/dist/types/server/root-layout.d.ts +3 -0
  306. package/dist/types/server.d.ts +16 -0
  307. package/dist/types/ssr/index.d.ts +233 -0
  308. package/dist/types/ssr/inject-rsc-eager.d.ts +3 -0
  309. package/dist/types/ssr/preinit-client-references.d.ts +71 -0
  310. package/dist/types/ssr/ssr-root.d.ts +69 -0
  311. package/dist/types/static-handler.d.ts +71 -0
  312. package/dist/types/testing/cache-status.d.ts +63 -0
  313. package/dist/types/testing/collect-handle.d.ts +20 -0
  314. package/dist/types/testing/dispatch.d.ts +127 -0
  315. package/dist/types/testing/dom.entry.d.ts +15 -0
  316. package/dist/types/testing/e2e/fixture.d.ts +37 -0
  317. package/dist/types/testing/e2e/index.d.ts +31 -0
  318. package/dist/types/testing/e2e/matchers.d.ts +17 -0
  319. package/dist/types/testing/e2e/page-helpers.d.ts +86 -0
  320. package/dist/types/testing/e2e/parity.d.ts +111 -0
  321. package/dist/types/testing/e2e/server.d.ts +35 -0
  322. package/dist/types/testing/flight-matchers.d.ts +55 -0
  323. package/dist/types/testing/flight-normalize.d.ts +1 -0
  324. package/dist/types/testing/flight-tree.d.ts +192 -0
  325. package/dist/types/testing/flight.d.ts +115 -0
  326. package/dist/types/testing/flight.entry.d.ts +27 -0
  327. package/dist/types/testing/generated-routes.d.ts +66 -0
  328. package/dist/types/testing/index.d.ts +54 -0
  329. package/dist/types/testing/internal/context.d.ts +225 -0
  330. package/dist/types/testing/internal/flight-client-globals.d.ts +1 -0
  331. package/dist/types/testing/internal/seed-vars.d.ts +30 -0
  332. package/dist/types/testing/render-handler.d.ts +160 -0
  333. package/dist/types/testing/render-route.d.ts +265 -0
  334. package/dist/types/testing/run-loader.d.ts +195 -0
  335. package/dist/types/testing/run-middleware.d.ts +132 -0
  336. package/dist/types/testing/run-transition-when.d.ts +78 -0
  337. package/dist/types/testing/shell-status.d.ts +81 -0
  338. package/dist/types/testing/vitest-stubs/cloudflare-email.d.ts +6 -0
  339. package/dist/types/testing/vitest-stubs/cloudflare-workers.d.ts +13 -0
  340. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +7 -0
  341. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  342. package/dist/types/testing/vitest.d.ts +205 -0
  343. package/dist/types/theme/ThemeProvider.d.ts +13 -0
  344. package/dist/types/theme/ThemeScript.d.ts +45 -0
  345. package/dist/types/theme/constants.d.ts +39 -0
  346. package/dist/types/theme/index.d.ts +29 -0
  347. package/dist/types/theme/theme-context.d.ts +21 -0
  348. package/dist/types/theme/theme-script.d.ts +26 -0
  349. package/dist/types/theme/types.d.ts +162 -0
  350. package/dist/types/theme/use-theme.d.ts +8 -0
  351. package/dist/types/types/boundaries.d.ts +110 -0
  352. package/dist/types/types/cache-types.d.ts +191 -0
  353. package/dist/types/types/error-types.d.ts +114 -0
  354. package/dist/types/types/global-namespace.d.ts +90 -0
  355. package/dist/types/types/handler-context.d.ts +658 -0
  356. package/dist/types/types/index.d.ts +11 -0
  357. package/dist/types/types/loader-types.d.ts +208 -0
  358. package/dist/types/types/request-scope.d.ts +93 -0
  359. package/dist/types/types/route-config.d.ts +105 -0
  360. package/dist/types/types/route-entry.d.ts +98 -0
  361. package/dist/types/types/segments.d.ts +246 -0
  362. package/dist/types/types.d.ts +1 -0
  363. package/dist/types/urls/include-helper.d.ts +17 -0
  364. package/dist/types/urls/include-provider.d.ts +27 -0
  365. package/dist/types/urls/index.d.ts +6 -0
  366. package/dist/types/urls/path-helper-types.d.ts +198 -0
  367. package/dist/types/urls/path-helper.d.ts +12 -0
  368. package/dist/types/urls/pattern-types.d.ts +166 -0
  369. package/dist/types/urls/response-types.d.ts +67 -0
  370. package/dist/types/urls/type-extraction.d.ts +157 -0
  371. package/dist/types/urls/urls-function.d.ts +24 -0
  372. package/dist/types/urls.d.ts +1 -0
  373. package/dist/types/use-loader.d.ts +150 -0
  374. package/dist/types/vercel/index.d.ts +10 -0
  375. package/dist/types/vercel/tracing.d.ts +71 -0
  376. package/dist/types/vite/debug.d.ts +80 -0
  377. package/dist/types/vite/discovery/bundle-postprocess.d.ts +12 -0
  378. package/dist/types/vite/discovery/client-urls-projection.d.ts +53 -0
  379. package/dist/types/vite/discovery/dev-prerender-cache.d.ts +65 -0
  380. package/dist/types/vite/discovery/discover-routers.d.ts +17 -0
  381. package/dist/types/vite/discovery/discovery-errors.d.ts +113 -0
  382. package/dist/types/vite/discovery/gate-state.d.ts +79 -0
  383. package/dist/types/vite/discovery/prerender-collection.d.ts +24 -0
  384. package/dist/types/vite/discovery/route-types-writer.d.ts +32 -0
  385. package/dist/types/vite/discovery/self-gen-tracking.d.ts +22 -0
  386. package/dist/types/vite/discovery/shell-prerender-phase.d.ts +40 -0
  387. package/dist/types/vite/discovery/state.d.ts +170 -0
  388. package/dist/types/vite/discovery/virtual-module-codegen.d.ts +15 -0
  389. package/dist/types/vite/encryption-key.d.ts +2 -0
  390. package/dist/types/vite/index.d.ts +11 -0
  391. package/dist/types/vite/inject-client-debug.d.ts +56 -0
  392. package/dist/types/vite/plugin-types.d.ts +298 -0
  393. package/dist/types/vite/plugins/cjs-to-esm.d.ts +6 -0
  394. package/dist/types/vite/plugins/client-ref-dedup.d.ts +40 -0
  395. package/dist/types/vite/plugins/client-ref-hashing.d.ts +35 -0
  396. package/dist/types/vite/plugins/cloudflare-protocol-stub.d.ts +64 -0
  397. package/dist/types/vite/plugins/expose-action-id.d.ts +18 -0
  398. package/dist/types/vite/plugins/expose-id-utils.d.ts +37 -0
  399. package/dist/types/vite/plugins/expose-ids/export-analysis.d.ts +19 -0
  400. package/dist/types/vite/plugins/expose-ids/handler-transform.d.ts +10 -0
  401. package/dist/types/vite/plugins/expose-ids/loader-transform.d.ts +8 -0
  402. package/dist/types/vite/plugins/expose-ids/router-transform.d.ts +13 -0
  403. package/dist/types/vite/plugins/expose-ids/types.d.ts +29 -0
  404. package/dist/types/vite/plugins/expose-internal-ids.d.ts +16 -0
  405. package/dist/types/vite/plugins/performance-tracks.d.ts +25 -0
  406. package/dist/types/vite/plugins/refresh-cmd.d.ts +20 -0
  407. package/dist/types/vite/plugins/server-ref-hashing.d.ts +24 -0
  408. package/dist/types/vite/plugins/server-reference-pattern.d.ts +1 -0
  409. package/dist/types/vite/plugins/use-cache-transform.d.ts +20 -0
  410. package/dist/types/vite/plugins/vercel-output.d.ts +85 -0
  411. package/dist/types/vite/plugins/version-injector.d.ts +21 -0
  412. package/dist/types/vite/plugins/version-plugin.d.ts +19 -0
  413. package/dist/types/vite/plugins/virtual-entries.d.ts +36 -0
  414. package/dist/types/vite/plugins/virtual-stub-plugin.d.ts +7 -0
  415. package/dist/types/vite/rango.d.ts +29 -0
  416. package/dist/types/vite/router-discovery.d.ts +23 -0
  417. package/dist/types/vite/utils/ast-handler-extract.d.ts +64 -0
  418. package/dist/types/vite/utils/banner.d.ts +2 -0
  419. package/dist/types/vite/utils/bundle-analysis.d.ts +28 -0
  420. package/dist/types/vite/utils/client-chunks.d.ts +55 -0
  421. package/dist/types/vite/utils/directive-prologue.d.ts +16 -0
  422. package/dist/types/vite/utils/forward-user-plugins.d.ts +37 -0
  423. package/dist/types/vite/utils/manifest-utils.d.ts +7 -0
  424. package/dist/types/vite/utils/package-resolution.d.ts +6 -0
  425. package/dist/types/vite/utils/prerender-utils.d.ts +32 -0
  426. package/dist/types/vite/utils/shared-utils.d.ts +67 -0
  427. package/dist/types.backup/__internal.d.ts +127 -0
  428. package/dist/types.backup/bin/rango.d.ts +1 -0
  429. package/dist/types.backup/browser/action-coordinator.d.ts +57 -0
  430. package/dist/types.backup/browser/action-fence.d.ts +33 -0
  431. package/dist/types.backup/browser/app-shell.d.ts +34 -0
  432. package/dist/types.backup/browser/app-version.d.ts +6 -0
  433. package/dist/types.backup/browser/connection-warmup.d.ts +31 -0
  434. package/dist/types.backup/browser/cookie-name.d.ts +66 -0
  435. package/dist/types.backup/browser/event-controller.d.ts +221 -0
  436. package/dist/types.backup/browser/history-state.d.ts +26 -0
  437. package/dist/types.backup/browser/index.d.ts +1 -0
  438. package/dist/types.backup/browser/intercept-utils.d.ts +30 -0
  439. package/dist/types.backup/browser/invalidate-client-cache.d.ts +17 -0
  440. package/dist/types.backup/browser/link-interceptor.d.ts +43 -0
  441. package/dist/types.backup/browser/logging.d.ts +33 -0
  442. package/dist/types.backup/browser/merge-segment-loaders.d.ts +38 -0
  443. package/dist/types.backup/browser/navigation-bridge.d.ts +27 -0
  444. package/dist/types.backup/browser/navigation-client.d.ts +17 -0
  445. package/dist/types.backup/browser/navigation-store-handle.d.ts +25 -0
  446. package/dist/types.backup/browser/navigation-store.d.ts +95 -0
  447. package/dist/types.backup/browser/navigation-transaction.d.ts +75 -0
  448. package/dist/types.backup/browser/network-error-handler.d.ts +35 -0
  449. package/dist/types.backup/browser/partial-update.d.ts +61 -0
  450. package/dist/types.backup/browser/prefetch/cache.d.ts +183 -0
  451. package/dist/types.backup/browser/prefetch/fetch.d.ts +52 -0
  452. package/dist/types.backup/browser/prefetch/observer.d.ts +27 -0
  453. package/dist/types.backup/browser/prefetch/policy.d.ts +13 -0
  454. package/dist/types.backup/browser/prefetch/queue.d.ts +48 -0
  455. package/dist/types.backup/browser/prefetch/resource-ready.d.ts +28 -0
  456. package/dist/types.backup/browser/rango-state.d.ts +52 -0
  457. package/dist/types.backup/browser/react/Link.d.ts +140 -0
  458. package/dist/types.backup/browser/react/NavigationProvider.d.ts +88 -0
  459. package/dist/types.backup/browser/react/ScrollRestoration.d.ts +78 -0
  460. package/dist/types.backup/browser/react/context.d.ts +54 -0
  461. package/dist/types.backup/browser/react/filter-segment-order.d.ts +35 -0
  462. package/dist/types.backup/browser/react/index.d.ts +1 -0
  463. package/dist/types.backup/browser/react/location-state-shared.d.ts +162 -0
  464. package/dist/types.backup/browser/react/location-state.d.ts +29 -0
  465. package/dist/types.backup/browser/react/mount-context.d.ts +23 -0
  466. package/dist/types.backup/browser/react/nonce-context.d.ts +14 -0
  467. package/dist/types.backup/browser/react/shallow-equal.d.ts +5 -0
  468. package/dist/types.backup/browser/react/use-action.d.ts +61 -0
  469. package/dist/types.backup/browser/react/use-handle.d.ts +21 -0
  470. package/dist/types.backup/browser/react/use-href.d.ts +32 -0
  471. package/dist/types.backup/browser/react/use-link-status.d.ts +36 -0
  472. package/dist/types.backup/browser/react/use-mount.d.ts +24 -0
  473. package/dist/types.backup/browser/react/use-navigation.d.ts +15 -0
  474. package/dist/types.backup/browser/react/use-params.d.ts +21 -0
  475. package/dist/types.backup/browser/react/use-pathname.d.ts +13 -0
  476. package/dist/types.backup/browser/react/use-reverse.d.ts +40 -0
  477. package/dist/types.backup/browser/react/use-router.d.ts +23 -0
  478. package/dist/types.backup/browser/react/use-search-params.d.ts +19 -0
  479. package/dist/types.backup/browser/react/use-segments.d.ts +29 -0
  480. package/dist/types.backup/browser/response-adapter.d.ts +58 -0
  481. package/dist/types.backup/browser/rsc-router.d.ts +141 -0
  482. package/dist/types.backup/browser/scroll-restoration.d.ts +103 -0
  483. package/dist/types.backup/browser/segment-reconciler.d.ts +74 -0
  484. package/dist/types.backup/browser/segment-structure-assert.d.ts +16 -0
  485. package/dist/types.backup/browser/server-action-bridge.d.ts +29 -0
  486. package/dist/types.backup/browser/types.d.ts +530 -0
  487. package/dist/types.backup/browser/validate-redirect-origin.d.ts +28 -0
  488. package/dist/types.backup/build/collect-fallback-refs.d.ts +5 -0
  489. package/dist/types.backup/build/generate-manifest.d.ts +100 -0
  490. package/dist/types.backup/build/generate-route-types.d.ts +8 -0
  491. package/dist/types.backup/build/index.d.ts +21 -0
  492. package/dist/types.backup/build/prefix-tree-utils.d.ts +56 -0
  493. package/dist/types.backup/build/route-trie.d.ts +89 -0
  494. package/dist/types.backup/build/route-types/ast-helpers.d.ts +3 -0
  495. package/dist/types.backup/build/route-types/ast-route-extraction.d.ts +13 -0
  496. package/dist/types.backup/build/route-types/codegen.d.ts +16 -0
  497. package/dist/types.backup/build/route-types/include-resolution.d.ts +74 -0
  498. package/dist/types.backup/build/route-types/param-extraction.d.ts +13 -0
  499. package/dist/types.backup/build/route-types/per-module-writer.d.ts +18 -0
  500. package/dist/types.backup/build/route-types/router-processing.d.ts +82 -0
  501. package/dist/types.backup/build/route-types/scan-filter.d.ts +17 -0
  502. package/dist/types.backup/build/route-types/source-scan.d.ts +13 -0
  503. package/dist/types.backup/build/runtime-discovery.d.ts +24 -0
  504. package/dist/types.backup/cache/background-task.d.ts +21 -0
  505. package/dist/types.backup/cache/cache-error.d.ts +71 -0
  506. package/dist/types.backup/cache/cache-key-utils.d.ts +35 -0
  507. package/dist/types.backup/cache/cache-policy.d.ts +59 -0
  508. package/dist/types.backup/cache/cache-runtime.d.ts +51 -0
  509. package/dist/types.backup/cache/cache-scope.d.ts +134 -0
  510. package/dist/types.backup/cache/cache-tag.d.ts +79 -0
  511. package/dist/types.backup/cache/cf/cf-base64.d.ts +4 -0
  512. package/dist/types.backup/cache/cf/cf-cache-constants.d.ts +105 -0
  513. package/dist/types.backup/cache/cf/cf-cache-store.d.ts +481 -0
  514. package/dist/types.backup/cache/cf/cf-cache-types.d.ts +300 -0
  515. package/dist/types.backup/cache/cf/cf-kv-utils.d.ts +22 -0
  516. package/dist/types.backup/cache/cf/cf-tag-marker-memo.d.ts +15 -0
  517. package/dist/types.backup/cache/cf/index.d.ts +3 -0
  518. package/dist/types.backup/cache/document-cache.d.ts +69 -0
  519. package/dist/types.backup/cache/handle-capture.d.ts +23 -0
  520. package/dist/types.backup/cache/handle-snapshot.d.ts +39 -0
  521. package/dist/types.backup/cache/index.d.ts +7 -0
  522. package/dist/types.backup/cache/memory-segment-store.d.ts +163 -0
  523. package/dist/types.backup/cache/profile-registry.d.ts +40 -0
  524. package/dist/types.backup/cache/read-through-swr.d.ts +60 -0
  525. package/dist/types.backup/cache/segment-codec.d.ts +78 -0
  526. package/dist/types.backup/cache/shell-snapshot.d.ts +162 -0
  527. package/dist/types.backup/cache/tag-invalidation.d.ts +74 -0
  528. package/dist/types.backup/cache/taint.d.ts +71 -0
  529. package/dist/types.backup/cache/types.d.ts +407 -0
  530. package/dist/types.backup/cache/vercel/index.d.ts +1 -0
  531. package/dist/types.backup/cache/vercel/vercel-cache-store.d.ts +267 -0
  532. package/dist/types.backup/client.d.ts +184 -0
  533. package/dist/types.backup/client.rsc.d.ts +39 -0
  534. package/dist/types.backup/cloudflare/index.d.ts +7 -0
  535. package/dist/types.backup/cloudflare/tracing.d.ts +53 -0
  536. package/dist/types.backup/component-utils.d.ts +46 -0
  537. package/dist/types.backup/components/DefaultDocument.d.ts +13 -0
  538. package/dist/types.backup/context-var.d.ts +84 -0
  539. package/dist/types.backup/debug.d.ts +57 -0
  540. package/dist/types.backup/decode-loader-results.d.ts +5 -0
  541. package/dist/types.backup/default-error-boundary.d.ts +10 -0
  542. package/dist/types.backup/defer.d.ts +89 -0
  543. package/dist/types.backup/deps/browser.d.ts +1 -0
  544. package/dist/types.backup/deps/html-stream-client.d.ts +1 -0
  545. package/dist/types.backup/deps/html-stream-server.d.ts +1 -0
  546. package/dist/types.backup/deps/rsc.d.ts +1 -0
  547. package/dist/types.backup/deps/ssr.d.ts +1 -0
  548. package/dist/types.backup/encode-kv.d.ts +35 -0
  549. package/dist/types.backup/errors.d.ts +226 -0
  550. package/dist/types.backup/escape-script.d.ts +44 -0
  551. package/dist/types.backup/handle.d.ts +93 -0
  552. package/dist/types.backup/handles/MetaTags.d.ts +17 -0
  553. package/dist/types.backup/handles/Scripts.d.ts +38 -0
  554. package/dist/types.backup/handles/breadcrumbs.d.ts +43 -0
  555. package/dist/types.backup/handles/deferred-resolution.d.ts +53 -0
  556. package/dist/types.backup/handles/is-thenable.d.ts +12 -0
  557. package/dist/types.backup/handles/meta.d.ts +43 -0
  558. package/dist/types.backup/handles/script.d.ts +139 -0
  559. package/dist/types.backup/host/cookie-handler.d.ts +8 -0
  560. package/dist/types.backup/host/errors.d.ts +40 -0
  561. package/dist/types.backup/host/index.d.ts +33 -0
  562. package/dist/types.backup/host/pattern-matcher.d.ts +30 -0
  563. package/dist/types.backup/host/router.d.ts +12 -0
  564. package/dist/types.backup/host/testing.d.ts +41 -0
  565. package/dist/types.backup/host/types.d.ts +148 -0
  566. package/dist/types.backup/host/utils.d.ts +20 -0
  567. package/dist/types.backup/href-client.d.ts +214 -0
  568. package/dist/types.backup/index.d.ts +112 -0
  569. package/dist/types.backup/index.rsc.d.ts +51 -0
  570. package/dist/types.backup/internal-debug.d.ts +1 -0
  571. package/dist/types.backup/loader-store.d.ts +193 -0
  572. package/dist/types.backup/loader.d.ts +18 -0
  573. package/dist/types.backup/loader.rsc.d.ts +18 -0
  574. package/dist/types.backup/missing-id-error.d.ts +1 -0
  575. package/dist/types.backup/outlet-context.d.ts +12 -0
  576. package/dist/types.backup/outlet-provider.d.ts +12 -0
  577. package/dist/types.backup/prerender/build-shell-capture.d.ts +104 -0
  578. package/dist/types.backup/prerender/param-hash.d.ts +6 -0
  579. package/dist/types.backup/prerender/shell-manifest-key.d.ts +18 -0
  580. package/dist/types.backup/prerender/store.d.ts +62 -0
  581. package/dist/types.backup/prerender.d.ts +292 -0
  582. package/dist/types.backup/redirect-origin.d.ts +55 -0
  583. package/dist/types.backup/regex-escape.d.ts +6 -0
  584. package/dist/types.backup/render-error-thrower.d.ts +13 -0
  585. package/dist/types.backup/response-utils.d.ts +35 -0
  586. package/dist/types.backup/reverse.d.ts +206 -0
  587. package/dist/types.backup/root-error-boundary.d.ts +32 -0
  588. package/dist/types.backup/route-content-wrapper.d.ts +40 -0
  589. package/dist/types.backup/route-definition/dsl-helpers.d.ts +130 -0
  590. package/dist/types.backup/route-definition/helper-factories.d.ts +22 -0
  591. package/dist/types.backup/route-definition/helpers-types.d.ts +392 -0
  592. package/dist/types.backup/route-definition/index.d.ts +7 -0
  593. package/dist/types.backup/route-definition/redirect.d.ts +48 -0
  594. package/dist/types.backup/route-definition/resolve-handler-use.d.ts +19 -0
  595. package/dist/types.backup/route-definition/use-item-types.d.ts +1 -0
  596. package/dist/types.backup/route-definition.d.ts +1 -0
  597. package/dist/types.backup/route-map-builder.d.ts +127 -0
  598. package/dist/types.backup/route-name.d.ts +27 -0
  599. package/dist/types.backup/route-types.d.ts +172 -0
  600. package/dist/types.backup/router/basename.d.ts +10 -0
  601. package/dist/types.backup/router/content-negotiation.d.ts +91 -0
  602. package/dist/types.backup/router/debug-manifest.d.ts +7 -0
  603. package/dist/types.backup/router/error-handling.d.ts +76 -0
  604. package/dist/types.backup/router/find-match.d.ts +19 -0
  605. package/dist/types.backup/router/handler-context.d.ts +41 -0
  606. package/dist/types.backup/router/instrument.d.ts +161 -0
  607. package/dist/types.backup/router/intercept-resolution.d.ts +79 -0
  608. package/dist/types.backup/router/lazy-includes.d.ts +26 -0
  609. package/dist/types.backup/router/loader-resolution.d.ts +63 -0
  610. package/dist/types.backup/router/logging.d.ts +41 -0
  611. package/dist/types.backup/router/manifest.d.ts +8 -0
  612. package/dist/types.backup/router/match-api.d.ts +19 -0
  613. package/dist/types.backup/router/match-context.d.ts +184 -0
  614. package/dist/types.backup/router/match-handlers.d.ts +49 -0
  615. package/dist/types.backup/router/match-middleware/background-revalidation.d.ts +113 -0
  616. package/dist/types.backup/router/match-middleware/cache-lookup.d.ts +113 -0
  617. package/dist/types.backup/router/match-middleware/cache-store.d.ts +112 -0
  618. package/dist/types.backup/router/match-middleware/index.d.ts +80 -0
  619. package/dist/types.backup/router/match-middleware/intercept-resolution.d.ts +116 -0
  620. package/dist/types.backup/router/match-middleware/segment-resolution.d.ts +94 -0
  621. package/dist/types.backup/router/match-pipelines.d.ts +103 -0
  622. package/dist/types.backup/router/match-result.d.ts +114 -0
  623. package/dist/types.backup/router/metrics.d.ts +6 -0
  624. package/dist/types.backup/router/middleware-types.d.ts +74 -0
  625. package/dist/types.backup/router/middleware.d.ts +116 -0
  626. package/dist/types.backup/router/navigation-snapshot.d.ts +22 -0
  627. package/dist/types.backup/router/params-util.d.ts +8 -0
  628. package/dist/types.backup/router/parse-pattern.d.ts +38 -0
  629. package/dist/types.backup/router/pattern-matching.d.ts +169 -0
  630. package/dist/types.backup/router/prefetch-cache-ttl.d.ts +27 -0
  631. package/dist/types.backup/router/prefetch-limits.d.ts +20 -0
  632. package/dist/types.backup/router/prerender-match.d.ts +50 -0
  633. package/dist/types.backup/router/preview-match.d.ts +22 -0
  634. package/dist/types.backup/router/request-classification.d.ts +104 -0
  635. package/dist/types.backup/router/revalidation.d.ts +57 -0
  636. package/dist/types.backup/router/route-snapshot.d.ts +112 -0
  637. package/dist/types.backup/router/router-context.d.ts +137 -0
  638. package/dist/types.backup/router/router-interfaces.d.ts +432 -0
  639. package/dist/types.backup/router/router-options.d.ts +738 -0
  640. package/dist/types.backup/router/router-registry.d.ts +15 -0
  641. package/dist/types.backup/router/segment-resolution/fresh.d.ts +55 -0
  642. package/dist/types.backup/router/segment-resolution/helpers.d.ts +93 -0
  643. package/dist/types.backup/router/segment-resolution/loader-cache.d.ts +33 -0
  644. package/dist/types.backup/router/segment-resolution/loader-mask.d.ts +44 -0
  645. package/dist/types.backup/router/segment-resolution/loader-snapshot.d.ts +90 -0
  646. package/dist/types.backup/router/segment-resolution/mask-nested.d.ts +53 -0
  647. package/dist/types.backup/router/segment-resolution/revalidation.d.ts +85 -0
  648. package/dist/types.backup/router/segment-resolution/static-store.d.ts +17 -0
  649. package/dist/types.backup/router/segment-resolution/streamed-handler-telemetry.d.ts +16 -0
  650. package/dist/types.backup/router/segment-resolution/view-transition-default.d.ts +28 -0
  651. package/dist/types.backup/router/segment-resolution.d.ts +3 -0
  652. package/dist/types.backup/router/segment-wrappers.d.ts +53 -0
  653. package/dist/types.backup/router/state-cookie-name.d.ts +1 -0
  654. package/dist/types.backup/router/substitute-pattern-params.d.ts +23 -0
  655. package/dist/types.backup/router/telemetry-otel.d.ts +113 -0
  656. package/dist/types.backup/router/telemetry.d.ts +215 -0
  657. package/dist/types.backup/router/timeout.d.ts +68 -0
  658. package/dist/types.backup/router/tracing.d.ts +125 -0
  659. package/dist/types.backup/router/trie-matching.d.ts +32 -0
  660. package/dist/types.backup/router/types.d.ts +98 -0
  661. package/dist/types.backup/router/url-params.d.ts +26 -0
  662. package/dist/types.backup/router.d.ts +7 -0
  663. package/dist/types.backup/rsc/capture-queue.d.ts +6 -0
  664. package/dist/types.backup/rsc/full-payload.d.ts +22 -0
  665. package/dist/types.backup/rsc/handler-context.d.ts +31 -0
  666. package/dist/types.backup/rsc/handler.d.ts +9 -0
  667. package/dist/types.backup/rsc/helpers.d.ts +213 -0
  668. package/dist/types.backup/rsc/index.d.ts +17 -0
  669. package/dist/types.backup/rsc/json-route-result.d.ts +20 -0
  670. package/dist/types.backup/rsc/loader-fetch.d.ts +14 -0
  671. package/dist/types.backup/rsc/manifest-init.d.ts +18 -0
  672. package/dist/types.backup/rsc/nonce.d.ts +28 -0
  673. package/dist/types.backup/rsc/origin-guard.d.ts +50 -0
  674. package/dist/types.backup/rsc/progressive-enhancement.d.ts +19 -0
  675. package/dist/types.backup/rsc/redirect-guard.d.ts +35 -0
  676. package/dist/types.backup/rsc/response-cache-serve.d.ts +46 -0
  677. package/dist/types.backup/rsc/response-error.d.ts +19 -0
  678. package/dist/types.backup/rsc/response-route-handler.d.ts +29 -0
  679. package/dist/types.backup/rsc/rsc-rendering.d.ts +23 -0
  680. package/dist/types.backup/rsc/runtime-warnings.d.ts +22 -0
  681. package/dist/types.backup/rsc/server-action.d.ts +68 -0
  682. package/dist/types.backup/rsc/shell-build-manifest.d.ts +84 -0
  683. package/dist/types.backup/rsc/shell-capture-constants.d.ts +27 -0
  684. package/dist/types.backup/rsc/shell-capture.d.ts +374 -0
  685. package/dist/types.backup/rsc/shell-serve.d.ts +136 -0
  686. package/dist/types.backup/rsc/ssr-setup.d.ts +48 -0
  687. package/dist/types.backup/rsc/transition-gate.d.ts +27 -0
  688. package/dist/types.backup/rsc/types.d.ts +290 -0
  689. package/dist/types.backup/runtime-env.d.ts +1 -0
  690. package/dist/types.backup/search-params.d.ts +125 -0
  691. package/dist/types.backup/segment-content-promise.d.ts +13 -0
  692. package/dist/types.backup/segment-fragments.d.ts +56 -0
  693. package/dist/types.backup/segment-loader-promise.d.ts +22 -0
  694. package/dist/types.backup/segment-system.d.ts +84 -0
  695. package/dist/types.backup/serialize.d.ts +164 -0
  696. package/dist/types.backup/server/context.d.ts +494 -0
  697. package/dist/types.backup/server/cookie-parse.d.ts +10 -0
  698. package/dist/types.backup/server/cookie-store.d.ts +107 -0
  699. package/dist/types.backup/server/fetchable-loader-store.d.ts +20 -0
  700. package/dist/types.backup/server/handle-store.d.ts +100 -0
  701. package/dist/types.backup/server/loader-registry.d.ts +32 -0
  702. package/dist/types.backup/server/request-context.d.ts +596 -0
  703. package/dist/types.backup/server/root-layout.d.ts +3 -0
  704. package/dist/types.backup/server.d.ts +15 -0
  705. package/dist/types.backup/ssr/index.d.ts +233 -0
  706. package/dist/types.backup/ssr/inject-rsc-eager.d.ts +3 -0
  707. package/dist/types.backup/ssr/preinit-client-references.d.ts +71 -0
  708. package/dist/types.backup/ssr/ssr-root.d.ts +69 -0
  709. package/dist/types.backup/static-handler.d.ts +57 -0
  710. package/dist/types.backup/testing/cache-status.d.ts +63 -0
  711. package/dist/types.backup/testing/collect-handle.d.ts +20 -0
  712. package/dist/types.backup/testing/dispatch.d.ts +123 -0
  713. package/dist/types.backup/testing/dom.entry.d.ts +15 -0
  714. package/dist/types.backup/testing/e2e/fixture.d.ts +37 -0
  715. package/dist/types.backup/testing/e2e/index.d.ts +30 -0
  716. package/dist/types.backup/testing/e2e/matchers.d.ts +17 -0
  717. package/dist/types.backup/testing/e2e/page-helpers.d.ts +62 -0
  718. package/dist/types.backup/testing/e2e/parity.d.ts +111 -0
  719. package/dist/types.backup/testing/e2e/server.d.ts +35 -0
  720. package/dist/types.backup/testing/flight-matchers.d.ts +55 -0
  721. package/dist/types.backup/testing/flight-normalize.d.ts +1 -0
  722. package/dist/types.backup/testing/flight-tree.d.ts +192 -0
  723. package/dist/types.backup/testing/flight.d.ts +115 -0
  724. package/dist/types.backup/testing/flight.entry.d.ts +27 -0
  725. package/dist/types.backup/testing/generated-routes.d.ts +66 -0
  726. package/dist/types.backup/testing/index.d.ts +52 -0
  727. package/dist/types.backup/testing/internal/context.d.ts +225 -0
  728. package/dist/types.backup/testing/internal/flight-client-globals.d.ts +1 -0
  729. package/dist/types.backup/testing/internal/seed-vars.d.ts +30 -0
  730. package/dist/types.backup/testing/render-handler.d.ts +160 -0
  731. package/dist/types.backup/testing/render-route.d.ts +246 -0
  732. package/dist/types.backup/testing/run-loader.d.ts +186 -0
  733. package/dist/types.backup/testing/run-middleware.d.ts +132 -0
  734. package/dist/types.backup/testing/run-transition-when.d.ts +77 -0
  735. package/dist/types.backup/testing/vitest-stubs/cloudflare-email.d.ts +6 -0
  736. package/dist/types.backup/testing/vitest-stubs/cloudflare-workers.d.ts +13 -0
  737. package/dist/types.backup/testing/vitest-stubs/plugin-rsc.d.ts +7 -0
  738. package/dist/types.backup/testing/vitest-stubs/version.d.ts +1 -0
  739. package/dist/types.backup/testing/vitest.d.ts +205 -0
  740. package/dist/types.backup/theme/ThemeProvider.d.ts +13 -0
  741. package/dist/types.backup/theme/ThemeScript.d.ts +45 -0
  742. package/dist/types.backup/theme/constants.d.ts +39 -0
  743. package/dist/types.backup/theme/index.d.ts +29 -0
  744. package/dist/types.backup/theme/theme-context.d.ts +21 -0
  745. package/dist/types.backup/theme/theme-script.d.ts +26 -0
  746. package/dist/types.backup/theme/types.d.ts +162 -0
  747. package/dist/types.backup/theme/use-theme.d.ts +8 -0
  748. package/dist/types.backup/types/boundaries.d.ts +93 -0
  749. package/dist/types.backup/types/cache-types.d.ts +191 -0
  750. package/dist/types.backup/types/error-types.d.ts +114 -0
  751. package/dist/types.backup/types/global-namespace.d.ts +90 -0
  752. package/dist/types.backup/types/handler-context.d.ts +658 -0
  753. package/dist/types.backup/types/index.d.ts +11 -0
  754. package/dist/types.backup/types/loader-types.d.ts +182 -0
  755. package/dist/types.backup/types/request-scope.d.ts +93 -0
  756. package/dist/types.backup/types/route-config.d.ts +105 -0
  757. package/dist/types.backup/types/route-entry.d.ts +95 -0
  758. package/dist/types.backup/types/segments.d.ts +234 -0
  759. package/dist/types.backup/types.d.ts +1 -0
  760. package/dist/types.backup/urls/include-helper.d.ts +17 -0
  761. package/dist/types.backup/urls/include-provider.d.ts +27 -0
  762. package/dist/types.backup/urls/index.d.ts +6 -0
  763. package/dist/types.backup/urls/path-helper-types.d.ts +197 -0
  764. package/dist/types.backup/urls/path-helper.d.ts +12 -0
  765. package/dist/types.backup/urls/pattern-types.d.ts +166 -0
  766. package/dist/types.backup/urls/response-types.d.ts +67 -0
  767. package/dist/types.backup/urls/type-extraction.d.ts +157 -0
  768. package/dist/types.backup/urls/urls-function.d.ts +24 -0
  769. package/dist/types.backup/urls.d.ts +1 -0
  770. package/dist/types.backup/use-loader.d.ts +150 -0
  771. package/dist/types.backup/vercel/index.d.ts +10 -0
  772. package/dist/types.backup/vercel/tracing.d.ts +70 -0
  773. package/dist/types.backup/vite/debug.d.ts +80 -0
  774. package/dist/types.backup/vite/discovery/bundle-postprocess.d.ts +12 -0
  775. package/dist/types.backup/vite/discovery/dev-prerender-cache.d.ts +65 -0
  776. package/dist/types.backup/vite/discovery/discover-routers.d.ts +17 -0
  777. package/dist/types.backup/vite/discovery/discovery-errors.d.ts +113 -0
  778. package/dist/types.backup/vite/discovery/gate-state.d.ts +79 -0
  779. package/dist/types.backup/vite/discovery/prerender-collection.d.ts +24 -0
  780. package/dist/types.backup/vite/discovery/route-types-writer.d.ts +32 -0
  781. package/dist/types.backup/vite/discovery/self-gen-tracking.d.ts +22 -0
  782. package/dist/types.backup/vite/discovery/shell-prerender-phase.d.ts +40 -0
  783. package/dist/types.backup/vite/discovery/state.d.ts +162 -0
  784. package/dist/types.backup/vite/discovery/virtual-module-codegen.d.ts +15 -0
  785. package/dist/types.backup/vite/index.d.ts +11 -0
  786. package/dist/types.backup/vite/inject-client-debug.d.ts +56 -0
  787. package/dist/types.backup/vite/plugin-types.d.ts +298 -0
  788. package/dist/types.backup/vite/plugins/cjs-to-esm.d.ts +6 -0
  789. package/dist/types.backup/vite/plugins/client-ref-dedup.d.ts +40 -0
  790. package/dist/types.backup/vite/plugins/client-ref-hashing.d.ts +35 -0
  791. package/dist/types.backup/vite/plugins/cloudflare-protocol-stub.d.ts +64 -0
  792. package/dist/types.backup/vite/plugins/expose-action-id.d.ts +18 -0
  793. package/dist/types.backup/vite/plugins/expose-id-utils.d.ts +37 -0
  794. package/dist/types.backup/vite/plugins/expose-ids/export-analysis.d.ts +19 -0
  795. package/dist/types.backup/vite/plugins/expose-ids/handler-transform.d.ts +10 -0
  796. package/dist/types.backup/vite/plugins/expose-ids/loader-transform.d.ts +8 -0
  797. package/dist/types.backup/vite/plugins/expose-ids/router-transform.d.ts +13 -0
  798. package/dist/types.backup/vite/plugins/expose-ids/types.d.ts +29 -0
  799. package/dist/types.backup/vite/plugins/expose-internal-ids.d.ts +6 -0
  800. package/dist/types.backup/vite/plugins/performance-tracks.d.ts +25 -0
  801. package/dist/types.backup/vite/plugins/refresh-cmd.d.ts +20 -0
  802. package/dist/types.backup/vite/plugins/use-cache-transform.d.ts +20 -0
  803. package/dist/types.backup/vite/plugins/vercel-output.d.ts +85 -0
  804. package/dist/types.backup/vite/plugins/version-injector.d.ts +21 -0
  805. package/dist/types.backup/vite/plugins/version-plugin.d.ts +19 -0
  806. package/dist/types.backup/vite/plugins/virtual-entries.d.ts +36 -0
  807. package/dist/types.backup/vite/plugins/virtual-stub-plugin.d.ts +7 -0
  808. package/dist/types.backup/vite/rango.d.ts +29 -0
  809. package/dist/types.backup/vite/router-discovery.d.ts +23 -0
  810. package/dist/types.backup/vite/utils/ast-handler-extract.d.ts +64 -0
  811. package/dist/types.backup/vite/utils/banner.d.ts +2 -0
  812. package/dist/types.backup/vite/utils/bundle-analysis.d.ts +28 -0
  813. package/dist/types.backup/vite/utils/client-chunks.d.ts +55 -0
  814. package/dist/types.backup/vite/utils/directive-prologue.d.ts +16 -0
  815. package/dist/types.backup/vite/utils/forward-user-plugins.d.ts +37 -0
  816. package/dist/types.backup/vite/utils/manifest-utils.d.ts +7 -0
  817. package/dist/types.backup/vite/utils/package-resolution.d.ts +6 -0
  818. package/dist/types.backup/vite/utils/prerender-utils.d.ts +32 -0
  819. package/dist/types.backup/vite/utils/shared-utils.d.ts +55 -0
  820. package/dist/vite/index.js +9695 -3592
  821. package/package.json +119 -39
  822. package/skills/api-client/SKILL.md +211 -0
  823. package/skills/breadcrumbs/SKILL.md +82 -5
  824. package/skills/bundle-analysis/SKILL.md +159 -0
  825. package/skills/cache-guide/SKILL.md +232 -34
  826. package/skills/caching/SKILL.md +375 -19
  827. package/skills/catalog.json +283 -0
  828. package/skills/cloudflare/SKILL.md +151 -0
  829. package/skills/cloudflare/agents/openai.yaml +4 -0
  830. package/skills/cloudflare/references/d1-and-local-dev.md +131 -0
  831. package/skills/cloudflare/references/streaming-and-deploy.md +107 -0
  832. package/skills/cloudflare/references/webhooks-and-crypto.md +147 -0
  833. package/skills/comparison/SKILL.md +50 -0
  834. package/skills/comparison/agents/openai.yaml +4 -0
  835. package/skills/comparison/references/framework-comparison.md +844 -0
  836. package/skills/composability/SKILL.md +124 -4
  837. package/skills/css/SKILL.md +76 -0
  838. package/skills/debug-manifest/SKILL.md +5 -11
  839. package/skills/defer-hydration/SKILL.md +235 -0
  840. package/skills/deployment-caching/SKILL.md +176 -0
  841. package/skills/document-cache/SKILL.md +116 -58
  842. package/skills/fonts/SKILL.md +1 -1
  843. package/skills/handler-use/SKILL.md +12 -10
  844. package/skills/hooks/SKILL.md +73 -699
  845. package/skills/hooks/data.md +273 -0
  846. package/skills/hooks/handle-and-actions.md +103 -0
  847. package/skills/hooks/navigation.md +110 -0
  848. package/skills/hooks/outlets.md +62 -0
  849. package/skills/hooks/state.md +228 -0
  850. package/skills/hooks/urls.md +135 -0
  851. package/skills/host-router/SKILL.md +129 -27
  852. package/skills/i18n/SKILL.md +276 -0
  853. package/skills/intercept/SKILL.md +75 -19
  854. package/skills/layout/SKILL.md +40 -19
  855. package/skills/links/SKILL.md +211 -17
  856. package/skills/loader/SKILL.md +230 -25
  857. package/skills/middleware/SKILL.md +58 -13
  858. package/skills/migrate-nextjs/SKILL.md +267 -33
  859. package/skills/migrate-nextjs/backend-host-swap.md +120 -0
  860. package/skills/migrate-react-router/SKILL.md +59 -671
  861. package/skills/migrate-react-router/component-migration.md +196 -0
  862. package/skills/migrate-react-router/data-and-actions.md +225 -0
  863. package/skills/migrate-react-router/route-mapping.md +271 -0
  864. package/skills/mime-routes/SKILL.md +55 -18
  865. package/skills/observability/SKILL.md +231 -0
  866. package/skills/parallel/SKILL.md +50 -10
  867. package/skills/ppr/SKILL.md +904 -0
  868. package/skills/prerender/SKILL.md +130 -68
  869. package/skills/rango/SKILL.md +321 -26
  870. package/skills/react-compiler/SKILL.md +168 -0
  871. package/skills/response-routes/SKILL.md +172 -52
  872. package/skills/route/SKILL.md +123 -9
  873. package/skills/router-setup/SKILL.md +53 -9
  874. package/skills/scripts/SKILL.md +179 -0
  875. package/skills/server-actions/SKILL.md +776 -0
  876. package/skills/shell-manifest/SKILL.md +185 -0
  877. package/skills/streams-and-websockets/SKILL.md +1 -1
  878. package/skills/tailwind/SKILL.md +28 -4
  879. package/skills/testing/SKILL.md +131 -0
  880. package/skills/testing/bindings.md +103 -0
  881. package/skills/testing/cache-prerender.md +172 -0
  882. package/skills/testing/client-components.md +131 -0
  883. package/skills/testing/e2e-parity.md +125 -0
  884. package/skills/testing/flight.md +91 -0
  885. package/skills/testing/handles.md +131 -0
  886. package/skills/testing/loader.md +128 -0
  887. package/skills/testing/middleware.md +99 -0
  888. package/skills/testing/render-handler.md +122 -0
  889. package/skills/testing/response-routes.md +95 -0
  890. package/skills/testing/reverse-and-types.md +85 -0
  891. package/skills/testing/server-actions.md +107 -0
  892. package/skills/testing/server-tree.md +128 -0
  893. package/skills/testing/setup.md +123 -0
  894. package/skills/theme/SKILL.md +1 -1
  895. package/skills/typesafety/SKILL.md +45 -628
  896. package/skills/typesafety/env-and-bindings.md +254 -0
  897. package/skills/typesafety/generated-files-and-cli.md +342 -0
  898. package/skills/typesafety/params-and-search.md +153 -0
  899. package/skills/typesafety/route-types.md +213 -0
  900. package/skills/use-cache/SKILL.md +121 -15
  901. package/skills/vercel/SKILL.md +149 -0
  902. package/skills/view-transitions/SKILL.md +341 -0
  903. package/src/__augment-tests__/augment.ts +81 -0
  904. package/src/__augment-tests__/augmented.check.ts +116 -0
  905. package/src/__internal.ts +0 -65
  906. package/src/browser/action-coordinator.ts +53 -36
  907. package/src/browser/action-fence.ts +47 -0
  908. package/src/browser/app-shell.ts +14 -27
  909. package/src/browser/connection-warmup.ts +134 -0
  910. package/src/browser/cookie-name.ts +140 -0
  911. package/src/browser/dev-discovery.ts +66 -0
  912. package/src/browser/event-controller.ts +400 -168
  913. package/src/browser/history-state.ts +21 -0
  914. package/src/browser/index.ts +3 -3
  915. package/src/browser/invalidate-client-cache.ts +52 -0
  916. package/src/browser/link-interceptor.ts +469 -20
  917. package/src/browser/logging.ts +28 -0
  918. package/src/browser/merge-segment-loaders.ts +6 -4
  919. package/src/browser/navigation-bridge.ts +164 -69
  920. package/src/browser/navigation-client.ts +199 -96
  921. package/src/browser/navigation-store-handle.ts +38 -0
  922. package/src/browser/navigation-store.ts +165 -345
  923. package/src/browser/navigation-transaction.ts +9 -59
  924. package/src/browser/network-error-handler.ts +34 -7
  925. package/src/browser/notify-listeners.ts +22 -0
  926. package/src/browser/partial-update.ts +219 -163
  927. package/src/browser/prefetch/cache.ts +240 -97
  928. package/src/browser/prefetch/default-strategy.ts +74 -0
  929. package/src/browser/prefetch/fetch.ts +317 -51
  930. package/src/browser/prefetch/invalidation.ts +30 -0
  931. package/src/browser/prefetch/loader.ts +111 -0
  932. package/src/browser/prefetch/observer.ts +50 -22
  933. package/src/browser/prefetch/queue.ts +25 -7
  934. package/src/browser/prefetch/runtime.ts +6 -0
  935. package/src/browser/rango-state.ts +177 -114
  936. package/src/browser/react/Link.tsx +115 -75
  937. package/src/browser/react/NavigationProvider.tsx +194 -143
  938. package/src/browser/react/ScrollRestoration.tsx +10 -6
  939. package/src/browser/react/context.ts +4 -0
  940. package/src/browser/react/filter-segment-order.ts +66 -7
  941. package/src/browser/react/index.ts +0 -48
  942. package/src/browser/react/location-state-shared.ts +178 -8
  943. package/src/browser/react/location-state.ts +39 -14
  944. package/src/browser/react/use-action.ts +6 -15
  945. package/src/browser/react/use-handle.ts +17 -14
  946. package/src/browser/react/use-href.tsx +8 -1
  947. package/src/browser/react/use-link-status.ts +33 -8
  948. package/src/browser/react/use-navigation.ts +10 -5
  949. package/src/browser/react/use-params.ts +11 -11
  950. package/src/browser/react/use-reverse.ts +106 -0
  951. package/src/browser/react/use-router.ts +21 -6
  952. package/src/browser/react/use-search-params.ts +0 -5
  953. package/src/browser/react/use-segments.ts +11 -21
  954. package/src/browser/response-adapter.ts +99 -8
  955. package/src/browser/rsc-router.tsx +230 -42
  956. package/src/browser/scroll-restoration.ts +37 -22
  957. package/src/browser/segment-reconciler.ts +31 -21
  958. package/src/browser/segment-structure-assert.ts +2 -2
  959. package/src/browser/server-action-bridge.ts +262 -65
  960. package/src/browser/types.ts +132 -47
  961. package/src/browser/validate-redirect-origin.ts +43 -16
  962. package/src/build/collect-fallback-refs.ts +107 -0
  963. package/src/build/generate-manifest.ts +213 -184
  964. package/src/build/generate-route-types.ts +3 -1
  965. package/src/build/index.ts +11 -3
  966. package/src/build/merge-full-manifests.ts +161 -0
  967. package/src/build/prefix-tree-utils.ts +123 -0
  968. package/src/build/route-trie.ts +43 -280
  969. package/src/build/route-types/ast-route-extraction.ts +15 -8
  970. package/src/build/route-types/codegen.ts +16 -5
  971. package/src/build/route-types/include-resolution.ts +513 -64
  972. package/src/build/route-types/param-extraction.ts +6 -3
  973. package/src/build/route-types/per-module-writer.ts +33 -12
  974. package/src/build/route-types/router-processing.ts +312 -204
  975. package/src/build/route-types/scan-filter.ts +1 -1
  976. package/src/build/route-types/source-scan.ts +216 -0
  977. package/src/build/runtime-discovery.ts +32 -29
  978. package/src/cache/cache-error.ts +104 -0
  979. package/src/cache/cache-exec-scope.ts +47 -0
  980. package/src/cache/cache-key-utils.ts +73 -15
  981. package/src/cache/cache-policy.ts +108 -34
  982. package/src/cache/cache-runtime.ts +566 -121
  983. package/src/cache/cache-scope.ts +383 -105
  984. package/src/cache/cache-tag.ts +149 -0
  985. package/src/cache/cf/cf-base64.ts +33 -0
  986. package/src/cache/cf/cf-cache-constants.ts +134 -0
  987. package/src/cache/cf/cf-cache-store.ts +3028 -391
  988. package/src/cache/cf/cf-cache-types.ts +417 -0
  989. package/src/cache/cf/cf-kv-utils.ts +84 -0
  990. package/src/cache/cf/cf-tag-marker-memo.ts +108 -0
  991. package/src/cache/cf/cf-zone-purge.ts +101 -0
  992. package/src/cache/cf/index.ts +11 -16
  993. package/src/cache/document-cache.ts +184 -53
  994. package/src/cache/handle-snapshot.ts +92 -1
  995. package/src/cache/index.ts +32 -20
  996. package/src/cache/memory-segment-store.ts +286 -37
  997. package/src/cache/profile-registry.ts +46 -31
  998. package/src/cache/read-through-swr.ts +66 -16
  999. package/src/cache/search-params-filter.ts +118 -0
  1000. package/src/cache/segment-codec.ts +80 -25
  1001. package/src/cache/shell-snapshot.ts +516 -0
  1002. package/src/cache/tag-invalidation.ts +230 -0
  1003. package/src/cache/taint.ts +28 -9
  1004. package/src/cache/types.ts +323 -100
  1005. package/src/cache/vercel/index.ts +11 -0
  1006. package/src/cache/vercel/vercel-cache-store.ts +1264 -0
  1007. package/src/client-urls/client-root.tsx +161 -0
  1008. package/src/client-urls/client-urls.ts +665 -0
  1009. package/src/client-urls/navigation.ts +237 -0
  1010. package/src/client-urls/revalidation-protocol.ts +56 -0
  1011. package/src/client-urls/server-projection.ts +563 -0
  1012. package/src/client-urls/types.ts +178 -0
  1013. package/src/client.rsc.tsx +48 -21
  1014. package/src/client.tsx +77 -67
  1015. package/src/cloudflare/index.ts +11 -0
  1016. package/src/cloudflare/tracing.ts +112 -0
  1017. package/src/component-utils.ts +19 -0
  1018. package/src/components/DefaultDocument.tsx +8 -2
  1019. package/src/context-var.ts +18 -6
  1020. package/src/decode-loader-results.ts +160 -0
  1021. package/src/defer.ts +185 -0
  1022. package/src/deps/ssr.ts +4 -2
  1023. package/src/dev-discovery-protocol.ts +11 -0
  1024. package/src/encode-kv.ts +49 -0
  1025. package/src/errors.ts +44 -4
  1026. package/src/escape-script.ts +52 -0
  1027. package/src/handle.ts +67 -37
  1028. package/src/handles/MetaTags.tsx +24 -53
  1029. package/src/handles/Scripts.tsx +183 -0
  1030. package/src/handles/breadcrumbs.ts +35 -8
  1031. package/src/handles/deferred-resolution.ts +134 -0
  1032. package/src/handles/is-thenable.ts +16 -0
  1033. package/src/handles/meta.ts +14 -40
  1034. package/src/handles/script.ts +244 -0
  1035. package/src/host/cookie-handler.ts +9 -60
  1036. package/src/host/errors.ts +13 -22
  1037. package/src/host/index.ts +9 -2
  1038. package/src/host/pattern-matcher.ts +23 -52
  1039. package/src/host/router.ts +107 -99
  1040. package/src/host/testing.ts +40 -27
  1041. package/src/host/types.ts +37 -4
  1042. package/src/host/utils.ts +1 -1
  1043. package/src/href-client.ts +137 -22
  1044. package/src/index.rsc.ts +105 -12
  1045. package/src/index.ts +102 -14
  1046. package/src/internal-debug.ts +11 -10
  1047. package/src/loader-redirect.tsx +47 -0
  1048. package/src/loader-store.ts +500 -0
  1049. package/src/loader.rsc.ts +20 -13
  1050. package/src/loader.ts +12 -11
  1051. package/src/missing-id-error.ts +68 -0
  1052. package/src/outlet-context.ts +12 -0
  1053. package/src/outlet-provider.tsx +16 -6
  1054. package/src/prerender/build-shell-capture.ts +453 -0
  1055. package/src/prerender/param-hash.ts +16 -16
  1056. package/src/prerender/shell-manifest-key.ts +20 -0
  1057. package/src/prerender/store.ts +47 -38
  1058. package/src/prerender.ts +81 -13
  1059. package/src/redirect-origin.ts +143 -0
  1060. package/src/regex-escape.ts +8 -0
  1061. package/src/render-error-thrower.tsx +20 -0
  1062. package/src/response-utils.ts +34 -0
  1063. package/src/reverse.ts +65 -40
  1064. package/src/root-error-boundary.tsx +1 -19
  1065. package/src/route-content-wrapper.tsx +112 -78
  1066. package/src/route-definition/dsl-helpers.ts +300 -313
  1067. package/src/route-definition/helper-factories.ts +28 -140
  1068. package/src/route-definition/helpers-types.ts +92 -62
  1069. package/src/route-definition/index.ts +1 -2
  1070. package/src/route-definition/redirect.ts +44 -11
  1071. package/src/route-definition/resolve-handler-use.ts +6 -1
  1072. package/src/route-definition/use-item-types.ts +29 -0
  1073. package/src/route-map-builder.ts +102 -74
  1074. package/src/route-types.ts +19 -46
  1075. package/src/router/basename.ts +14 -0
  1076. package/src/router/content-negotiation.ts +120 -30
  1077. package/src/router/error-handling.ts +110 -29
  1078. package/src/router/find-match.ts +129 -30
  1079. package/src/router/handler-context.ts +41 -57
  1080. package/src/router/instrument.ts +401 -0
  1081. package/src/router/intercept-resolution.ts +98 -21
  1082. package/src/router/lazy-includes.ts +82 -58
  1083. package/src/router/loader-resolution.ts +401 -85
  1084. package/src/router/logging.ts +0 -6
  1085. package/src/router/manifest.ts +74 -40
  1086. package/src/router/match-api.ts +91 -55
  1087. package/src/router/match-context.ts +0 -22
  1088. package/src/router/match-handlers.ts +268 -171
  1089. package/src/router/match-middleware/background-revalidation.ts +40 -24
  1090. package/src/router/match-middleware/cache-lookup.ts +316 -302
  1091. package/src/router/match-middleware/cache-store.ts +134 -52
  1092. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  1093. package/src/router/match-middleware/segment-resolution.ts +0 -22
  1094. package/src/router/match-pipelines.ts +1 -42
  1095. package/src/router/match-result.ts +76 -79
  1096. package/src/router/metrics.ts +17 -36
  1097. package/src/router/middleware-types.ts +20 -110
  1098. package/src/router/middleware.ts +275 -173
  1099. package/src/router/navigation-snapshot.ts +51 -60
  1100. package/src/router/params-util.ts +23 -0
  1101. package/src/router/parse-pattern.ts +115 -0
  1102. package/src/router/pattern-matching.ts +134 -154
  1103. package/src/router/prefetch-cache-ttl.ts +51 -0
  1104. package/src/router/prefetch-default.ts +59 -0
  1105. package/src/router/prefetch-limits.ts +37 -0
  1106. package/src/router/prerender-match.ts +159 -69
  1107. package/src/router/preview-match.ts +6 -2
  1108. package/src/router/request-classification.ts +50 -69
  1109. package/src/router/revalidation.ts +146 -83
  1110. package/src/router/route-snapshot.ts +14 -3
  1111. package/src/router/route-trie-builder.ts +334 -0
  1112. package/src/router/router-context.ts +6 -29
  1113. package/src/router/router-interfaces.ts +168 -38
  1114. package/src/router/router-options.ts +235 -11
  1115. package/src/router/router-registry.ts +2 -5
  1116. package/src/router/segment-resolution/fresh.ts +163 -88
  1117. package/src/router/segment-resolution/helpers.ts +96 -18
  1118. package/src/router/segment-resolution/loader-cache.ts +187 -39
  1119. package/src/router/segment-resolution/loader-mask.ts +60 -0
  1120. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  1121. package/src/router/segment-resolution/mask-nested.ts +99 -0
  1122. package/src/router/segment-resolution/revalidation.ts +316 -321
  1123. package/src/router/segment-resolution/static-store.ts +55 -15
  1124. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  1125. package/src/router/segment-resolution/view-transition-default.ts +60 -0
  1126. package/src/router/segment-resolution.ts +5 -1
  1127. package/src/router/segment-wrappers.ts +6 -5
  1128. package/src/router/state-cookie-name.ts +33 -0
  1129. package/src/router/substitute-pattern-params.ts +75 -0
  1130. package/src/router/telemetry-otel.ts +171 -200
  1131. package/src/router/telemetry.ts +106 -20
  1132. package/src/router/timeout.ts +53 -22
  1133. package/src/router/tracing.ts +235 -0
  1134. package/src/router/transition-when.ts +76 -0
  1135. package/src/router/trie-matching.ts +170 -64
  1136. package/src/router/types.ts +9 -63
  1137. package/src/router/url-params.ts +13 -5
  1138. package/src/router.ts +248 -77
  1139. package/src/rsc/capture-queue.ts +218 -0
  1140. package/src/rsc/full-payload.ts +77 -0
  1141. package/src/rsc/handler-context.ts +4 -2
  1142. package/src/rsc/handler.ts +562 -284
  1143. package/src/rsc/helpers.ts +179 -9
  1144. package/src/rsc/index.ts +2 -5
  1145. package/src/rsc/json-route-result.ts +38 -0
  1146. package/src/rsc/loader-fetch.ts +116 -40
  1147. package/src/rsc/manifest-init.ts +55 -56
  1148. package/src/rsc/nonce.ts +10 -1
  1149. package/src/rsc/origin-guard.ts +39 -25
  1150. package/src/rsc/progressive-enhancement.ts +377 -84
  1151. package/src/rsc/redirect-guard.ts +102 -0
  1152. package/src/rsc/render-pipeline.ts +611 -0
  1153. package/src/rsc/response-cache-serve.ts +259 -0
  1154. package/src/rsc/response-error.ts +79 -12
  1155. package/src/rsc/response-route-handler.ts +73 -212
  1156. package/src/rsc/routine-plan.ts +359 -0
  1157. package/src/rsc/rsc-rendering.ts +1624 -171
  1158. package/src/rsc/runtime-warnings.ts +23 -10
  1159. package/src/rsc/server-action.ts +420 -139
  1160. package/src/rsc/shell-build-manifest.ts +336 -0
  1161. package/src/rsc/shell-capture-constants.ts +45 -0
  1162. package/src/rsc/shell-capture.ts +2063 -0
  1163. package/src/rsc/shell-serve.ts +337 -0
  1164. package/src/rsc/ssr-setup.ts +111 -16
  1165. package/src/rsc/stream-idle.ts +137 -0
  1166. package/src/rsc/transition-gate.ts +86 -0
  1167. package/src/rsc/types.ts +91 -5
  1168. package/src/runtime-env.ts +18 -0
  1169. package/src/search-params.ts +35 -30
  1170. package/src/segment-fragments.ts +165 -0
  1171. package/src/segment-loader-promise.ts +49 -4
  1172. package/src/segment-system.tsx +441 -153
  1173. package/src/serialize.ts +243 -0
  1174. package/src/server/context.ts +416 -55
  1175. package/src/server/cookie-parse.ts +32 -0
  1176. package/src/server/cookie-store.ts +162 -15
  1177. package/src/server/handle-store.ts +190 -70
  1178. package/src/server/loader-registry.ts +33 -42
  1179. package/src/server/request-context.ts +805 -177
  1180. package/src/server.ts +7 -2
  1181. package/src/ssr/index.tsx +631 -183
  1182. package/src/ssr/inject-rsc-eager.ts +167 -0
  1183. package/src/ssr/preinit-client-references.ts +106 -0
  1184. package/src/ssr/ssr-root.tsx +262 -0
  1185. package/src/static-handler.ts +28 -15
  1186. package/src/testing/cache-status.ts +162 -0
  1187. package/src/testing/collect-handle.ts +46 -0
  1188. package/src/testing/dispatch.ts +951 -0
  1189. package/src/testing/dom.entry.ts +22 -0
  1190. package/src/testing/e2e/fixture.ts +188 -0
  1191. package/src/testing/e2e/index.ts +147 -0
  1192. package/src/testing/e2e/matchers.ts +35 -0
  1193. package/src/testing/e2e/page-helpers.ts +319 -0
  1194. package/src/testing/e2e/parity.ts +400 -0
  1195. package/src/testing/e2e/server.ts +195 -0
  1196. package/src/testing/flight-matchers.ts +97 -0
  1197. package/src/testing/flight-normalize.ts +11 -0
  1198. package/src/testing/flight-runtime.d.ts +57 -0
  1199. package/src/testing/flight-tree.ts +682 -0
  1200. package/src/testing/flight.entry.ts +52 -0
  1201. package/src/testing/flight.ts +257 -0
  1202. package/src/testing/generated-routes.ts +199 -0
  1203. package/src/testing/index.ts +121 -0
  1204. package/src/testing/internal/context.ts +380 -0
  1205. package/src/testing/internal/flight-client-globals.ts +30 -0
  1206. package/src/testing/internal/seed-vars.ts +54 -0
  1207. package/src/testing/render-handler.ts +371 -0
  1208. package/src/testing/render-route.tsx +667 -0
  1209. package/src/testing/run-loader.ts +423 -0
  1210. package/src/testing/run-middleware.ts +219 -0
  1211. package/src/testing/run-transition-when.ts +197 -0
  1212. package/src/testing/shell-status.ts +206 -0
  1213. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  1214. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  1215. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  1216. package/src/testing/vitest-stubs/version.ts +5 -0
  1217. package/src/testing/vitest.ts +305 -0
  1218. package/src/theme/ThemeProvider.tsx +56 -84
  1219. package/src/theme/ThemeScript.tsx +7 -9
  1220. package/src/theme/constants.ts +52 -13
  1221. package/src/theme/index.ts +0 -7
  1222. package/src/theme/theme-context.ts +1 -5
  1223. package/src/theme/theme-script.ts +22 -21
  1224. package/src/theme/use-theme.ts +0 -3
  1225. package/src/types/boundaries.ts +15 -35
  1226. package/src/types/cache-types.ts +13 -4
  1227. package/src/types/error-types.ts +30 -90
  1228. package/src/types/global-namespace.ts +54 -41
  1229. package/src/types/handler-context.ts +121 -30
  1230. package/src/types/index.ts +3 -10
  1231. package/src/types/loader-types.ts +38 -7
  1232. package/src/types/request-scope.ts +8 -22
  1233. package/src/types/route-config.ts +20 -52
  1234. package/src/types/route-entry.ts +3 -6
  1235. package/src/types/segments.ts +147 -14
  1236. package/src/urls/include-helper.ts +38 -64
  1237. package/src/urls/include-provider.ts +71 -0
  1238. package/src/urls/index.ts +2 -11
  1239. package/src/urls/path-helper-types.ts +76 -24
  1240. package/src/urls/path-helper.ts +30 -110
  1241. package/src/urls/pattern-types.ts +101 -19
  1242. package/src/urls/response-types.ts +20 -19
  1243. package/src/urls/type-extraction.ts +98 -154
  1244. package/src/urls/urls-function.ts +1 -19
  1245. package/src/use-loader.tsx +355 -109
  1246. package/src/vercel/index.ts +11 -0
  1247. package/src/vercel/tracing.ts +89 -0
  1248. package/src/vite/debug.ts +102 -3
  1249. package/src/vite/discovery/bundle-postprocess.ts +18 -14
  1250. package/src/vite/discovery/client-urls-projection.ts +322 -0
  1251. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  1252. package/src/vite/discovery/discover-routers.ts +178 -151
  1253. package/src/vite/discovery/discovery-errors.ts +255 -0
  1254. package/src/vite/discovery/gate-state.ts +171 -0
  1255. package/src/vite/discovery/prerender-collection.ts +125 -70
  1256. package/src/vite/discovery/route-types-writer.ts +40 -84
  1257. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  1258. package/src/vite/discovery/shell-prerender-phase.ts +397 -0
  1259. package/src/vite/discovery/state.ts +104 -6
  1260. package/src/vite/discovery/virtual-module-codegen.ts +226 -87
  1261. package/src/vite/encryption-key.ts +29 -0
  1262. package/src/vite/index.ts +12 -0
  1263. package/src/vite/inject-client-debug.ts +88 -0
  1264. package/src/vite/plugin-types.ts +243 -10
  1265. package/src/vite/plugins/cjs-to-esm.ts +16 -19
  1266. package/src/vite/plugins/client-ref-dedup.ts +16 -11
  1267. package/src/vite/plugins/client-ref-hashing.ts +28 -15
  1268. package/src/vite/plugins/cloudflare-protocol-stub.ts +1 -21
  1269. package/src/vite/plugins/expose-action-id.ts +50 -97
  1270. package/src/vite/plugins/expose-id-utils.ts +88 -55
  1271. package/src/vite/plugins/expose-ids/export-analysis.ts +101 -34
  1272. package/src/vite/plugins/expose-ids/handler-transform.ts +11 -90
  1273. package/src/vite/plugins/expose-ids/loader-transform.ts +14 -24
  1274. package/src/vite/plugins/expose-ids/router-transform.ts +118 -29
  1275. package/src/vite/plugins/expose-internal-ids.ts +551 -486
  1276. package/src/vite/plugins/performance-tracks.ts +25 -22
  1277. package/src/vite/plugins/refresh-cmd.ts +1 -1
  1278. package/src/vite/plugins/server-ref-hashing.ts +74 -0
  1279. package/src/vite/plugins/server-reference-pattern.ts +10 -0
  1280. package/src/vite/plugins/use-cache-transform.ts +73 -83
  1281. package/src/vite/plugins/vercel-output.ts +384 -0
  1282. package/src/vite/plugins/version-injector.ts +39 -29
  1283. package/src/vite/plugins/version-plugin.ts +45 -40
  1284. package/src/vite/plugins/virtual-entries.ts +193 -30
  1285. package/src/vite/rango.ts +240 -128
  1286. package/src/vite/router-discovery.ts +1518 -174
  1287. package/src/vite/utils/ast-handler-extract.ts +26 -35
  1288. package/src/vite/utils/bundle-analysis.ts +10 -15
  1289. package/src/vite/utils/client-chunks.ts +184 -0
  1290. package/src/vite/utils/directive-prologue.ts +40 -0
  1291. package/src/vite/utils/forward-user-plugins.ts +171 -0
  1292. package/src/vite/utils/manifest-utils.ts +1 -59
  1293. package/src/vite/utils/package-resolution.ts +2 -73
  1294. package/src/vite/utils/prerender-utils.ts +96 -49
  1295. package/src/vite/utils/shared-utils.ts +156 -43
  1296. package/src/browser/action-response-classifier.ts +0 -99
  1297. package/src/browser/react/use-client-cache.ts +0 -58
  1298. package/src/browser/shallow.ts +0 -40
  1299. package/src/handles/index.ts +0 -7
  1300. package/src/network-error-thrower.tsx +0 -23
  1301. package/src/router/middleware-cookies.ts +0 -55
@@ -34,59 +34,205 @@ import type {
34
34
  CacheGetResult,
35
35
  CacheItemResult,
36
36
  CacheItemOptions,
37
+ ShellCacheEntry,
38
+ CacheReadError,
37
39
  } from "../types.js";
40
+ import { CACHE_READ_ERROR } from "../types.js";
38
41
  import {
39
42
  _getRequestContext,
40
43
  type RequestContext,
41
44
  } from "../../server/request-context.js";
45
+ import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
42
46
  import { VERSION } from "@rangojs/router:version";
47
+ import {
48
+ isPerClientSignalHeader,
49
+ stripPerClientSignals,
50
+ } from "../../browser/cookie-name.js";
43
51
  import {
44
52
  resolveTtl,
45
53
  resolveSwrWindow,
46
54
  DEFAULT_FUNCTION_TTL,
47
55
  } from "../cache-policy.js";
56
+ import { reportCacheError, reportingAsync } from "../cache-error.js";
57
+ import type { CacheErrorCategory } from "../cache-error.js";
58
+ import { bufferToBase64, base64ToBuffer } from "./cf-base64.js";
59
+ import {
60
+ KV_KEY_PRESERVED_PREFIX_BYTES,
61
+ KV_MAX_KEY_BYTES,
62
+ KV_MIN_EXPIRATION_TTL,
63
+ kvKeyByteLength,
64
+ kvKeyDigest,
65
+ remainingCacheControl,
66
+ truncateToBytes,
67
+ } from "./cf-kv-utils.js";
68
+ import {
69
+ TAG_MARKER_CACHE_PREFIX,
70
+ TAG_MARKER_ABSENT,
71
+ getTagMarkerMemo,
72
+ getTagMarkerInflight,
73
+ } from "./cf-tag-marker-memo.js";
74
+ import { createCloudflareZonePurge } from "./cf-zone-purge.js";
48
75
 
49
76
  // ============================================================================
50
77
  // Constants
51
78
  // ============================================================================
79
+ //
80
+ // Header names, KV prefixes, and timeout/interval defaults live in
81
+ // cf-cache-constants.ts so collaborator modules can share them without a
82
+ // circular import back to this class. They are re-exported below so existing
83
+ // import paths (`../cf-cache-store`, `./cf-cache-store.js`) still resolve.
84
+ import {
85
+ CACHE_STALE_AT_HEADER,
86
+ CACHE_STATUS_HEADER,
87
+ CACHE_TAGS_HEADER,
88
+ CACHE_TAGGED_AT_HEADER,
89
+ TAG_MARKER_PREFIX,
90
+ CACHE_REVALIDATING_AT_HEADER,
91
+ CACHE_EXPIRES_AT_HEADER,
92
+ CACHE_ORIG_CC_HEADER,
93
+ MAX_REVALIDATION_INTERVAL,
94
+ EDGE_LOOKUP_TIMEOUT_MS,
95
+ EDGE_READ_TIMEOUT_MS,
96
+ KV_READ_TIMEOUT_MS,
97
+ } from "./cf-cache-constants.js";
98
+
99
+ // Re-export the public constants so consumers/tests importing them from
100
+ // cf-cache-store keep working after the move.
101
+ export {
102
+ CACHE_STALE_AT_HEADER,
103
+ CACHE_STATUS_HEADER,
104
+ CACHE_TAGS_HEADER,
105
+ CACHE_TAGGED_AT_HEADER,
106
+ TAG_MARKER_PREFIX,
107
+ CACHE_REVALIDATING_AT_HEADER,
108
+ MAX_REVALIDATION_INTERVAL,
109
+ EDGE_LOOKUP_TIMEOUT_MS,
110
+ EDGE_READ_TIMEOUT_MS,
111
+ KV_READ_TIMEOUT_MS,
112
+ };
113
+
114
+ // The tag-marker prefix/sentinel and per-request memo helpers (with their
115
+ // module-singleton WeakMaps) live in cf-tag-marker-memo.ts; imported above.
52
116
 
53
- /** Header storing timestamp when entry becomes stale */
54
- export const CACHE_STALE_AT_HEADER = "x-edge-cache-stale-at";
117
+ /**
118
+ * Per-request memo of the derived cache-key base URL.
119
+ *
120
+ * deriveBaseUrl() is a pure function of the live request URL, but keyToRequest
121
+ * calls it on EVERY cache operation (each segment/item get/set/delete, each
122
+ * KV->L1 promote, each tag-marker read), so a page composed of many cached
123
+ * entries re-parses the same request.url and re-runs the host validation tens
124
+ * of times. Keying by the request-context object collapses that to one derive
125
+ * per request. Keyed by ctx alone (not by store) because the derived value
126
+ * depends only on the request URL, not on which store asked.
127
+ */
128
+ const derivedBaseUrlMemo = new WeakMap<object, string>();
55
129
 
56
- /** Header storing cache status: HIT | REVALIDATING */
57
- export const CACHE_STATUS_HEADER = "x-edge-cache-status";
130
+ // Pure KV helpers (key byte-length limits, expirationTtl floor, stale-path
131
+ // Cache-Control recompute) live in cf-kv-utils.ts; imported above.
58
132
 
59
133
  /**
60
- * Maximum age in seconds for REVALIDATING status before allowing new revalidation.
61
- * After this period, a stale entry in REVALIDATING status will trigger revalidation again.
62
- * @internal
134
+ * Stores (by namespace) already warned about tag machinery configured without a
135
+ * KV namespace, so the warning fires once per process rather than per request
136
+ * (CFCacheStore is constructed per request).
63
137
  */
64
- export const MAX_REVALIDATION_INTERVAL = 30;
138
+ const warnedNoKvReadInvalidation = new Set<string>();
65
139
 
66
- // ============================================================================
67
- // Types
68
- // ============================================================================
140
+ /**
141
+ * Stores (by namespace) already warned about a tagInvalidationTtl below KV's
142
+ * expirationTtl floor, so the floor warning fires once per process rather than
143
+ * once per request (CFCacheStore is constructed per request).
144
+ */
145
+ const warnedTagInvalidationTtlFloor = new Set<string>();
69
146
 
70
- // Re-exported from the canonical home so cf-cache-store consumers keep
71
- // importing `ExecutionContext` from this module without a second interface
72
- // drifting over time.
73
- export type { ExecutionContext } from "../../types/request-scope.js";
74
- import type { ExecutionContext } from "../../types/request-scope.js";
147
+ /**
148
+ * Stores (by namespace) already warned about the shell family being inert
149
+ * (getShell/putShell no-op without a KV namespace), so a ppr route hitting the
150
+ * silent fail-open warns once per isolate instead of on every request.
151
+ */
152
+ const warnedShellFamilyInert = new Set<string>();
75
153
 
76
154
  /**
77
- * Minimal Cloudflare KV Namespace interface.
78
- * Avoids hard dependency on @cloudflare/workers-types.
155
+ * Stores (by namespace) already warned that tag invalidation is writing KV
156
+ * markers with no expiry (tagInvalidationTtl unset), so the unbounded-growth
157
+ * warning fires once per process rather than once per invalidateTags call
158
+ * (CFCacheStore is constructed per request; invalidateTags runs per marker
159
+ * batch). Distinct from the floor warning: that one only fires for a positive
160
+ * below-floor value, never for the unset (no-expiry) default that this bounds.
79
161
  */
80
- export interface KVNamespace {
81
- get(key: string, options?: { type?: string }): Promise<any>;
82
- put(
83
- key: string,
84
- value: string,
85
- options?: { expirationTtl?: number },
86
- ): Promise<void>;
87
- delete(key: string): Promise<void>;
162
+ const warnedNoTagInvalidationTtl = new Set<string>();
163
+
164
+ /**
165
+ * Stores (by namespace) already warned that an entry's tag set produced a
166
+ * Cache-Tag header over Cloudflare's aggregate limit, so the header was
167
+ * omitted (the entry stays cacheable and marker-invalidatable; it just cannot
168
+ * be evicted per-tag by a purge). Once per process, not per write.
169
+ */
170
+ const warnedCacheTagHeaderOverflow = new Set<string>();
171
+
172
+ /**
173
+ * Stores (by namespace) already warned that an over-limit tag set made an
174
+ * entry UNCACHEABLE in KV-less purge mode: with no Cache-Tag tokens a purge
175
+ * cannot evict it, and with no KV there is no marker fallback either, so
176
+ * caching it would serve stale until TTL while updateTag() reports success.
177
+ * Once per process, not per write.
178
+ */
179
+ const warnedCacheTagOverflowUncacheable = new Set<string>();
180
+
181
+ /**
182
+ * Max length of one emitted `rg:*` Cache-Tag token. Cloudflare caps a purge
183
+ * API tag value at 1,024 characters and the aggregate Cache-Tag header at
184
+ * 16 KB; an application tag is unbounded, so an over-long token is collapsed
185
+ * to a deterministic hash (see boundedTagToken) instead of being allowed to
186
+ * fail the whole L1 write. 256 keeps headers compact while leaving room for
187
+ * long-but-reasonable tag names under any namespace.
188
+ */
189
+ const CACHE_TAG_TOKEN_MAX = 256;
190
+
191
+ /**
192
+ * Cloudflare's documented aggregate Cache-Tag header limit (16 KB). A tagged
193
+ * entry whose tokens would exceed it gets NO Cache-Tag header (plus a
194
+ * once-per-namespace warning) rather than a failed cache write; the read path
195
+ * then falls back to the marker check for that entry (see isL1Invalidated).
196
+ */
197
+ const CACHE_TAG_HEADER_MAX_BYTES = 16 * 1024;
198
+
199
+ /**
200
+ * FNV-1a 64-bit hash of a tag value, hex-encoded. Used to bound over-long
201
+ * Cache-Tag tokens: deterministic (write-time token === purge-time token) and
202
+ * collision-safe in the failure direction — a collision over-purges (an extra
203
+ * eviction, healed by the next render), never serves stale.
204
+ * @internal
205
+ */
206
+ function fnv1a64(input: string): string {
207
+ let hash = 0xcbf29ce484222325n;
208
+ for (let i = 0; i < input.length; i++) {
209
+ hash ^= BigInt(input.charCodeAt(i));
210
+ hash = (hash * 0x100000001b3n) & 0xffffffffffffffffn;
211
+ }
212
+ return hash.toString(16).padStart(16, "0");
88
213
  }
89
214
 
215
+ // ============================================================================
216
+ // Types
217
+ // ============================================================================
218
+ //
219
+ // The shared public types (KVNamespace, CFCacheReadDebugEvent, CFCacheDebug,
220
+ // CFCacheStoreOptions) live in cf-cache-types.ts; imported and re-exported below
221
+ // so existing import paths still resolve. The private KV envelope interfaces
222
+ // stay here with the methods that read/write them.
223
+ import type {
224
+ KVNamespace,
225
+ CFCacheReadDebugEvent,
226
+ CFCacheDebug,
227
+ CFCacheStoreOptions,
228
+ } from "./cf-cache-types.js";
229
+ export type {
230
+ KVNamespace,
231
+ CFCacheReadDebugEvent,
232
+ CFCacheDebug,
233
+ CFCacheStoreOptions,
234
+ };
235
+
90
236
  /**
91
237
  * KV envelope for segment cache entries.
92
238
  * @internal
@@ -107,132 +253,167 @@ interface KVSegmentEnvelope {
107
253
  interface KVItemEnvelope {
108
254
  /** RSC-serialized return value */
109
255
  v: string;
110
- /** Handle data */
111
- h?: Record<string, Record<string, unknown[]>>;
256
+ /** RSC-encoded handle data (see handle-snapshot.ts encodeHandles) */
257
+ h?: string;
112
258
  /** When entry becomes stale (ms epoch) */
113
259
  s: number;
114
260
  /** When entry hard-expires (ms epoch) */
115
261
  e: number;
262
+ /** Cache tags (for distributed tag invalidation) */
263
+ t?: string[];
264
+ /** Timestamp when tags were attached (ms epoch) */
265
+ ta?: number;
116
266
  }
117
267
 
118
268
  /**
119
- * KV envelope for document cache entries.
269
+ * Coupled Cache API/KV envelope for PPR shell cache entries.
120
270
  * @internal
121
271
  */
122
- interface KVResponseEnvelope {
123
- /** Response body as base64-encoded string (safe for binary payloads) */
124
- b: string;
125
- /** HTTP status code */
126
- st: number;
127
- /** HTTP status text */
128
- stx: string;
129
- /** Serialized headers as key-value pairs */
130
- hd: [string, string][];
272
+ interface CFShellEnvelope {
273
+ /**
274
+ * base64-encoded prelude bytes. Absent iff `no` (navigationOnly entries
275
+ * store no document half — ShellCacheEntry.prelude).
276
+ */
277
+ p?: string;
278
+ /** postponed state JSON, or null (DATA variant — no holes). Absent iff `no`. */
279
+ po?: string | null;
280
+ /** React.version captured at prerender time */
281
+ rv: string;
282
+ /** Build version captured at prerender time (ShellCacheEntry.buildVersion) */
283
+ bv?: string;
284
+ /** Capture-generation start time (ms epoch), used by tag marker checks. */
285
+ c: number;
131
286
  /** When entry becomes stale (ms epoch) */
132
287
  s: number;
133
288
  /** When entry hard-expires (ms epoch) */
134
289
  e: number;
135
- }
136
-
137
- export interface CFCacheStoreOptions<TEnv = unknown> {
138
- /**
139
- * Cache namespace. If not provided, uses caches.default (recommended).
140
- * Only set this if you need isolated cache storage.
141
- */
142
- namespace?: string;
143
-
144
- /**
145
- * Base URL for cache keys.
146
- *
147
- * If not provided, derives from request hostname via requestContext:
148
- * - Production domains → uses `https://{hostname}/`
149
- * - Dev/preview (localhost, workers.dev, pages.dev) → uses internal fallback URL
150
- */
151
- baseUrl?: string;
152
-
153
- /** Default cache options */
154
- defaults?: CacheDefaults;
155
-
290
+ /** Cache tags (for distributed tag invalidation) */
291
+ t?: string[];
292
+ /** Timestamp when tags were attached (ms epoch) */
293
+ ta?: number;
294
+ /** initialTheme the capture render was built with (resume theme fidelity) */
295
+ i?: string;
296
+ /** Capture data snapshot: recorded cache-store hits/writes for HIT parity */
297
+ sn?: import("../types.js").ShellSnapshotRecord[];
156
298
  /**
157
- * Cloudflare ExecutionContext for non-blocking cache writes.
158
- * Pass the `ctx` from your worker's fetch handler.
159
- *
160
- * @example
161
- * ```typescript
162
- * new CFCacheStore({ ctx: env.ctx })
163
- * ```
299
+ * ShellCacheEntry.docKey. Must round-trip: navigation-replay eligibility
300
+ * requires the exact canonical doc segment record named here — dropping the
301
+ * field reads back as "no consumable record" and every partial navigation
302
+ * reports `no-segment-snapshot` after a KV round trip (the memory store
303
+ * passes the entry by reference, so only envelope stores can lose it).
164
304
  */
165
- ctx: ExecutionContext;
166
-
305
+ dk?: string;
167
306
  /**
168
- * Optional KV namespace for L2 cache persistence.
169
- *
170
- * When provided, KV acts as a global fallback behind the per-colo Cache API.
171
- * On L1 miss, KV is checked and hits are promoted back to L1.
172
- * On writes, data is persisted to both L1 and KV.
173
- *
174
- * @example
175
- * ```typescript
176
- * new CFCacheStore({ ctx: env.ctx, kv: env.CACHE_KV })
177
- * ```
307
+ * ShellCacheEntry.handlerLiveHoles. Must round-trip: the serve side arms the
308
+ * handler-free fast path on `!entry.handlerLiveHoles`, so dropping the flag
309
+ * here silently fast-pathed handler-live entries after a KV round trip
310
+ * their holes only a handler re-run can fill.
178
311
  */
179
- kv?: KVNamespace;
312
+ lh?: boolean;
313
+ /** ShellCacheEntry.transitionWhen; conditional transitions must re-run. */
314
+ tw?: true;
315
+ /** ShellCacheEntry.navigationOnly; its partial-context prelude is not document-safe. */
316
+ no?: true;
317
+ }
180
318
 
181
- /**
182
- * Cache version string override. When this changes, all cached entries are
183
- * effectively invalidated (new keys won't match old entries).
184
- *
185
- * Defaults to the auto-generated VERSION from `rsc-router:version` virtual module.
186
- * Only set this if you need a custom versioning strategy.
187
- */
188
- version?: string;
319
+ type CFShellDebugOutcome =
320
+ | "l1-hit"
321
+ | "l1-miss"
322
+ | "kv-hit"
323
+ | "kv-miss"
324
+ | "kv-promoted"
325
+ | "marker-invalidated"
326
+ | "l1-stored"
327
+ | "kv-stored"
328
+ | "write-invalidated";
329
+
330
+ interface CFShellDebugDetails {
331
+ tier?: "l1" | "kv";
332
+ reason?:
333
+ | "absent"
334
+ | "timeout"
335
+ | "error"
336
+ | "non-200"
337
+ | "corrupt"
338
+ | "malformed"
339
+ | "expired"
340
+ | "unavailable";
341
+ freshness?: "fresh" | "stale";
342
+ status?: number;
343
+ matchMs?: number;
344
+ bodyReadMs?: number;
345
+ markerMs?: number;
346
+ readMs?: number;
347
+ expiresAt?: number;
348
+ remainingTtl?: number;
349
+ }
189
350
 
190
- /**
191
- * Custom key generator applied to all cache operations.
192
- * Receives the full RequestContext (including env) and the default-generated key.
193
- * Return value becomes the final cache key (unless route overrides with `key` option).
194
- *
195
- * @example Using headers for user segmentation
196
- * ```typescript
197
- * keyGenerator: (ctx, defaultKey) => {
198
- * const segment = ctx.request.headers.get('x-user-segment') || 'default';
199
- * return `${segment}:${defaultKey}`;
200
- * }
201
- * ```
202
- *
203
- * @example Using env bindings for multi-region
204
- * ```typescript
205
- * keyGenerator: (ctx, defaultKey) => {
206
- * const region = ctx.env.REGION || 'us';
207
- * return `${region}:${defaultKey}`;
208
- * }
209
- * ```
210
- *
211
- * @example Using cookies for locale-aware caching
212
- * ```typescript
213
- * keyGenerator: (ctx, defaultKey) => {
214
- * const locale = cookies().get('locale')?.value || 'en';
215
- * return `${locale}:${defaultKey}`;
216
- * }
217
- * ```
218
- */
219
- keyGenerator?: (
220
- ctx: RequestContext<TEnv>,
221
- defaultKey: string,
222
- ) => string | Promise<string>;
351
+ /** Validate the coupled PPR shell envelope before any field reaches resume. */
352
+ function isShellEnvelope(value: unknown): value is CFShellEnvelope {
353
+ if (value == null || typeof value !== "object") return false;
354
+ const envelope = value as Partial<CFShellEnvelope>;
355
+ return (
356
+ // Document half: required unless the envelope is navigationOnly (`no`),
357
+ // which stores neither field. Tolerate a legacy navigationOnly envelope
358
+ // that still carries them.
359
+ (typeof envelope.p === "string" ||
360
+ (envelope.p === undefined && envelope.no === true)) &&
361
+ (envelope.po === null ||
362
+ typeof envelope.po === "string" ||
363
+ (envelope.po === undefined && envelope.no === true)) &&
364
+ typeof envelope.rv === "string" &&
365
+ (envelope.bv === undefined || typeof envelope.bv === "string") &&
366
+ typeof envelope.c === "number" &&
367
+ Number.isFinite(envelope.c) &&
368
+ typeof envelope.s === "number" &&
369
+ Number.isFinite(envelope.s) &&
370
+ typeof envelope.e === "number" &&
371
+ Number.isFinite(envelope.e) &&
372
+ (envelope.t === undefined ||
373
+ (Array.isArray(envelope.t) &&
374
+ envelope.t.every((tag) => typeof tag === "string"))) &&
375
+ (envelope.ta === undefined ||
376
+ (typeof envelope.ta === "number" && Number.isFinite(envelope.ta))) &&
377
+ (envelope.i === undefined || typeof envelope.i === "string") &&
378
+ (envelope.sn === undefined || Array.isArray(envelope.sn)) &&
379
+ (envelope.dk === undefined || typeof envelope.dk === "string") &&
380
+ (envelope.lh === undefined || typeof envelope.lh === "boolean") &&
381
+ (envelope.tw === undefined || envelope.tw === true) &&
382
+ (envelope.no === undefined || envelope.no === true)
383
+ );
223
384
  }
224
385
 
225
386
  /**
226
- * Cache status values for the x-edge-cache-status header.
387
+ * KV envelope for document cache entries.
227
388
  * @internal
228
389
  */
229
- export type CacheStatus = "HIT" | "REVALIDATING";
390
+ interface KVResponseEnvelope {
391
+ /** Response body as base64-encoded string (safe for binary payloads) */
392
+ b: string;
393
+ /** HTTP status code */
394
+ st: number;
395
+ /** HTTP status text */
396
+ stx: string;
397
+ /** Serialized headers as key-value pairs (client-facing; no internal headers) */
398
+ hd: [string, string][];
399
+ /** When entry becomes stale (ms epoch) */
400
+ s: number;
401
+ /** When entry hard-expires (ms epoch) */
402
+ e: number;
403
+ /** Cache tags (for distributed tag invalidation) */
404
+ t?: string[];
405
+ /** Timestamp when tags were attached (ms epoch) */
406
+ ta?: number;
407
+ }
230
408
 
231
409
  // ============================================================================
232
410
  // CFCacheStore Implementation
233
411
  // ============================================================================
234
412
 
235
413
  export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
414
+ readonly supportsPassiveShellReads: true = true;
415
+ /** True when constructed without KV: the shell family no-ops (see ctor). */
416
+ readonly shellFamilyInert?: boolean;
236
417
  readonly defaults?: CacheDefaults;
237
418
  readonly keyGenerator?: (
238
419
  ctx: RequestContext<TEnv>,
@@ -240,10 +421,18 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
240
421
  ) => string | Promise<string>;
241
422
 
242
423
  private readonly namespace?: string;
243
- private readonly baseUrl: string;
424
+ private readonly explicitBaseUrl?: string;
244
425
  private readonly waitUntil?: (fn: () => Promise<void>) => void;
245
426
  private readonly version?: string;
427
+ private readonly edgeLookupTimeoutMs: number;
428
+ private readonly edgeReadTimeoutMs: number;
429
+ private readonly kvReadTimeoutMs: number;
430
+ private readonly debug?: (event: CFCacheReadDebugEvent) => void;
246
431
  private readonly kv?: KVNamespace;
432
+ private readonly onRevalidateTag?: (tags: string[]) => Promise<void>;
433
+ private readonly tagPurge?: (cacheTags: string[]) => Promise<void>;
434
+ private readonly tagInvalidationTtl?: number;
435
+ private readonly tagCacheTtl: number;
247
436
 
248
437
  constructor(options: CFCacheStoreOptions<TEnv>) {
249
438
  if (!options.ctx) {
@@ -255,52 +444,251 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
255
444
  }
256
445
 
257
446
  this.namespace = options.namespace;
258
- this.baseUrl = options.baseUrl ?? this.deriveBaseUrl();
447
+ // Base URL is resolved lazily per cache operation (see resolveBaseUrl).
448
+ // The store is constructed before the per-request context ALS is entered
449
+ // (the cache factory runs ahead of runWithRequestContext in the handler),
450
+ // so deriving the host here would always miss the request and fall back to
451
+ // the internal host. Only the explicit override can be captured eagerly.
452
+ this.explicitBaseUrl = options.baseUrl;
259
453
  this.defaults = options.defaults;
260
454
  this.version = options.version ?? VERSION;
455
+ // Coalesce only finite numbers to the override; a non-finite value (NaN from
456
+ // `Number(env.UNSET)`, or Infinity) would otherwise sail past `?? DEFAULT`
457
+ // (which only replaces null/undefined) into setTimeout, where NaN/Infinity
458
+ // are spec-coerced to ~1ms and silently turn the budget into a near-100%
459
+ // false-miss on that tier. A genuine finite 0 or negative still passes
460
+ // through and disables the budget per the documented `<= 0` contract.
461
+ const finiteBudget = (
462
+ value: number | undefined,
463
+ fallback: number,
464
+ ): number =>
465
+ typeof value === "number" && Number.isFinite(value) ? value : fallback;
466
+ this.edgeLookupTimeoutMs = finiteBudget(
467
+ options.edgeLookupTimeoutMs,
468
+ EDGE_LOOKUP_TIMEOUT_MS,
469
+ );
470
+ this.edgeReadTimeoutMs = finiteBudget(
471
+ options.edgeReadTimeoutMs,
472
+ EDGE_READ_TIMEOUT_MS,
473
+ );
474
+ this.kvReadTimeoutMs = finiteBudget(
475
+ options.kvReadTimeoutMs,
476
+ KV_READ_TIMEOUT_MS,
477
+ );
478
+ this.debug =
479
+ options.debug === true
480
+ ? (event) =>
481
+ console.log(`[CFCacheStore:debug] ${JSON.stringify(event)}`)
482
+ : typeof options.debug === "function"
483
+ ? options.debug
484
+ : undefined;
261
485
  this.keyGenerator = options.keyGenerator;
262
486
  this.waitUntil = (fn) => options.ctx.waitUntil(fn());
263
487
  this.kv = options.kv;
488
+ // The shell family requires KV (getShell/putShell no-op without it — see
489
+ // warnShellFamilyInertOnce). Declaring it lets scheduleShellCapture skip
490
+ // captures whose write could only no-op instead of burning a background
491
+ // render per MISS that still occupies the serialized capture queue.
492
+ this.shellFamilyInert = options.kv ? undefined : true;
493
+ this.onRevalidateTag = options.onRevalidateTag;
494
+ // tagPurge accepts a ready purge function or a credentials object; the
495
+ // object form is normalized through the built-in zone purge client, which
496
+ // validates zoneId/apiToken eagerly so an unset env var fails at
497
+ // construction instead of on the first updateTag().
498
+ this.tagPurge =
499
+ typeof options.tagPurge === "function"
500
+ ? options.tagPurge
501
+ : options.tagPurge
502
+ ? createCloudflareZonePurge(options.tagPurge)
503
+ : undefined;
504
+ // tagInvalidationTtl feeds KV's expirationTtl, which CF rejects below
505
+ // KV_MIN_EXPIRATION_TTL (60s) -- a too-small finite value would make EVERY
506
+ // marker write throw and break ALL invalidation. Floor it (and warn once);
507
+ // a non-finite/non-positive value falls back to the no-expiry default
508
+ // (markers persist) rather than silently sailing a NaN into expirationTtl.
509
+ this.tagInvalidationTtl = this.sanitizeTagInvalidationTtl(
510
+ options.tagInvalidationTtl,
511
+ );
512
+ // tagCacheTtl gates the L1 marker cache via `> 0`. A non-finite value (NaN
513
+ // from `Number(env.UNSET)`) is not null/undefined, so `?? 0` would let it
514
+ // through and silently disable the cache while reading as "configured".
515
+ // finiteBudget coerces non-finite/null/undefined to 0; the `> 0` guard then
516
+ // collapses a finite non-positive value to the documented 0 = disabled.
517
+ const tagCacheTtl = finiteBudget(options.tagCacheTtl, 0);
518
+ this.tagCacheTtl = tagCacheTtl > 0 ? tagCacheTtl : 0;
519
+
520
+ // Read-side tag invalidation requires KV: isGloballyInvalidated() compares an
521
+ // entry's taggedAt against the per-tag KV marker and short-circuits to "not
522
+ // invalidated" when no KV namespace is configured. A consumer who wires the
523
+ // tag machinery (tagCacheTtl for L1 markers, or onRevalidateTag for CDN purge)
524
+ // but omits kv gets only the purge fired - marker writes are skipped without
525
+ // kv - yet every tagged read still serves stale data with no other signal.
526
+ // Surface that misconfiguration. Exception: with tagPurge configured (purge
527
+ // mode) L1 eviction is the purge itself, so a KV-less store is a supported
528
+ // L1-only configuration, not a silent no-op.
529
+ if (
530
+ !this.kv &&
531
+ !this.tagPurge &&
532
+ (this.tagCacheTtl > 0 || this.onRevalidateTag)
533
+ ) {
534
+ this.warnOncePerNamespace(
535
+ warnedNoKvReadInvalidation,
536
+ `[CFCacheStore] tagCacheTtl/onRevalidateTag is configured without a KV ` +
537
+ `namespace, so tag invalidation has NO read-side effect: tagged reads ` +
538
+ `are never treated as invalidated and serve stale data. Configure ` +
539
+ `{ kv } for distributed tag invalidation.`,
540
+ );
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Warn about a namespace-scoped misconfiguration once per namespace per
546
+ * isolate. `seen` is the module-level Set for that message family -- Sets
547
+ * are module-level (not instance fields) so re-constructed stores in the
548
+ * same isolate don't re-warn.
549
+ * @internal
550
+ */
551
+ private warnOncePerNamespace(seen: Set<string>, message: string): void {
552
+ const id = this.namespace ?? "default";
553
+ if (seen.has(id)) return;
554
+ seen.add(id);
555
+ console.warn(message);
556
+ }
557
+
558
+ /**
559
+ * Validate a consumer-supplied tagInvalidationTtl against CF KV's expirationTtl
560
+ * floor. A finite value below KV_MIN_EXPIRATION_TTL is raised to it (with a
561
+ * one-time warning) so invalidation keeps working instead of every marker
562
+ * write throwing; a non-finite or non-positive value returns undefined (the
563
+ * no-expiry default). The warning still notes the sizing rule: the TTL must
564
+ * exceed the largest entry TTL+SWR or invalidated entries can resurrect.
565
+ * @internal
566
+ */
567
+ private sanitizeTagInvalidationTtl(
568
+ value: number | undefined,
569
+ ): number | undefined {
570
+ if (value == null) return undefined;
571
+ if (!Number.isFinite(value) || value <= 0) return undefined;
572
+ if (value < KV_MIN_EXPIRATION_TTL) {
573
+ this.warnOncePerNamespace(
574
+ warnedTagInvalidationTtlFloor,
575
+ `[CFCacheStore] tagInvalidationTtl ${value} is below Cloudflare KV's ` +
576
+ `${KV_MIN_EXPIRATION_TTL}s expirationTtl floor; raising to ` +
577
+ `${KV_MIN_EXPIRATION_TTL}. It must still exceed your largest entry ` +
578
+ `TTL+SWR or invalidated entries can resurrect when the marker expires.`,
579
+ );
580
+ return KV_MIN_EXPIRATION_TTL;
581
+ }
582
+ return value;
583
+ }
584
+
585
+ /**
586
+ * Emit a debug event if `debug` is enabled. Swallows sink errors so a faulty
587
+ * debug callback can never break a cache read.
588
+ * @internal
589
+ */
590
+ private emitDebug(event: CFCacheReadDebugEvent): void {
591
+ if (!this.debug) return;
592
+ try {
593
+ this.debug(event);
594
+ } catch {
595
+ // A broken debug sink must not affect the request.
596
+ }
597
+ }
598
+
599
+ /** Build-time-gated shell tier trace for deployed cross-colo diagnostics. */
600
+ private debugShell(
601
+ key: string,
602
+ outcome: CFShellDebugOutcome,
603
+ details: CFShellDebugDetails = {},
604
+ ): void {
605
+ if (!INTERNAL_RANGO_DEBUG) return;
606
+ const request = _getRequestContext()?.request as
607
+ | (Request & { cf?: { colo?: unknown } })
608
+ | undefined;
609
+ const ray = request?.headers.get("cf-ray") ?? undefined;
610
+ const cfColo = request?.cf?.colo;
611
+ const colo =
612
+ typeof cfColo === "string"
613
+ ? cfColo
614
+ : ray?.includes("-")
615
+ ? ray.slice(ray.lastIndexOf("-") + 1)
616
+ : undefined;
617
+ console.log(
618
+ `[CFCacheStore][shell] ${JSON.stringify({
619
+ key,
620
+ outcome,
621
+ at: Date.now(),
622
+ ray,
623
+ colo,
624
+ ...details,
625
+ })}`,
626
+ );
627
+ }
628
+
629
+ /**
630
+ * Resolve the cache-key base URL for the current cache operation.
631
+ * Prefers an explicit `baseUrl` option; otherwise derives it from the live
632
+ * request. Called per operation (from keyToRequest), which runs inside the
633
+ * request-context ALS, so deriveBaseUrl sees the request and can use the
634
+ * production host instead of the internal fallback.
635
+ * @internal
636
+ */
637
+ private resolveBaseUrl(): string {
638
+ return this.explicitBaseUrl ?? this.deriveBaseUrl();
264
639
  }
265
640
 
266
641
  /**
267
642
  * Derive base URL from request hostname via requestContext.
268
643
  * Uses internal fallback for dev/preview environments and untrusted hostnames.
644
+ * Must run inside the request context (invoked lazily via resolveBaseUrl).
269
645
  * @internal
270
646
  */
271
647
  private deriveBaseUrl(): string {
272
- const fallback = "https://rsc-cache.internal.com/";
648
+ const fallback = "https://rsc-dummy-host-1.com/";
273
649
 
274
650
  const ctx = _getRequestContext();
275
651
  if (!ctx?.request) {
276
652
  return fallback;
277
653
  }
278
654
 
279
- try {
280
- const url = new URL(ctx.request.url);
281
- const hostname = url.hostname;
282
-
283
- // Use fallback for dev/preview environments
284
- if (
285
- hostname === "localhost" ||
286
- hostname === "127.0.0.1" ||
287
- hostname.endsWith(".workers.dev") ||
288
- hostname.endsWith(".pages.dev")
289
- ) {
290
- return fallback;
291
- }
655
+ // The result is deterministic per request, but keyToRequest calls this on
656
+ // every cache operation; memoize per request context (see derivedBaseUrlMemo).
657
+ const memoized = derivedBaseUrlMemo.get(ctx);
658
+ if (memoized !== undefined) {
659
+ return memoized;
660
+ }
292
661
 
293
- // Validate hostname: must be a valid domain (alphanumeric, hyphens, dots)
294
- // to prevent host header injection into cache keys
295
- if (!/^[a-zA-Z0-9.-]+$/.test(hostname) || hostname.length > 253) {
662
+ const derived = ((): string => {
663
+ try {
664
+ const url = new URL(ctx.request.url);
665
+ const hostname = url.hostname;
666
+
667
+ // Use fallback for dev/preview environments
668
+ if (
669
+ hostname === "localhost" ||
670
+ hostname === "127.0.0.1" ||
671
+ hostname.endsWith(".workers.dev") ||
672
+ hostname.endsWith(".pages.dev")
673
+ ) {
674
+ return fallback;
675
+ }
676
+
677
+ // Validate hostname: must be a valid domain (alphanumeric, hyphens, dots)
678
+ // to prevent host header injection into cache keys
679
+ if (!/^[a-zA-Z0-9.-]+$/.test(hostname) || hostname.length > 253) {
680
+ return fallback;
681
+ }
682
+
683
+ // Use actual hostname for production
684
+ return `https://${hostname}/`;
685
+ } catch {
296
686
  return fallback;
297
687
  }
688
+ })();
298
689
 
299
- // Use actual hostname for production
300
- return `https://${hostname}/`;
301
- } catch {
302
- return fallback;
303
- }
690
+ derivedBaseUrlMemo.set(ctx, derived);
691
+ return derived;
304
692
  }
305
693
 
306
694
  /**
@@ -314,10 +702,297 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
314
702
  return caches.default;
315
703
  }
316
704
 
705
+ /**
706
+ * Race an async cache read against a latency budget. Shared by all three read
707
+ * tiers (L1 match, L1 body, L2/KV) so the timeout policy lives in one place:
708
+ * on timeout it returns `{ value: undefined, timedOut: true }` and logs
709
+ * `${label} exceeded ${budgetMs}ms; treating as miss`; the abandoned read is
710
+ * left to settle in the background (late rejection swallowed) rather than
711
+ * aborted, since the underlying CF primitives expose no cancellation. A budget
712
+ * <= 0 disables the bound and awaits the read directly. `read` is a thunk so
713
+ * the disabled path and the raced path start the read identically.
714
+ * @internal
715
+ */
716
+ private async readWithTimeout<T>(
717
+ read: () => Promise<T>,
718
+ budgetMs: number,
719
+ label: string,
720
+ ): Promise<{ value: T | undefined; timedOut: boolean }> {
721
+ if (budgetMs <= 0) return { value: await read(), timedOut: false };
722
+
723
+ let timer: ReturnType<typeof setTimeout> | undefined;
724
+ const timeout = new Promise<{ timedOut: true }>((resolve) => {
725
+ timer = setTimeout(() => resolve({ timedOut: true }), budgetMs);
726
+ });
727
+ try {
728
+ const readPromise = read();
729
+ // The losing branch keeps running; ensure a late rejection can't surface
730
+ // as an unhandled rejection once we've stopped awaiting it.
731
+ readPromise.catch(() => {});
732
+ const result = await Promise.race([
733
+ readPromise.then((value) => ({ timedOut: false as const, value })),
734
+ timeout,
735
+ ]);
736
+ if (result.timedOut) {
737
+ console.warn(
738
+ `[CFCacheStore] ${label} exceeded ${budgetMs}ms; treating as miss`,
739
+ );
740
+ return { value: undefined, timedOut: true };
741
+ }
742
+ return { value: result.value, timedOut: false };
743
+ } finally {
744
+ if (timer) clearTimeout(timer);
745
+ }
746
+ }
747
+
748
+ /**
749
+ * Read from the L1 edge cache under the edgeLookupTimeoutMs budget. A `match`
750
+ * slower than the budget is abandoned and reported as a miss
751
+ * (`{ response: undefined, timedOut: true }`) so a degraded colo cannot stall
752
+ * the request; callers fall through to their normal miss path (L2/KV or
753
+ * render). The `timedOut` flag lets callers distinguish an abandoned slow
754
+ * match from a genuine miss for debug reporting; `error` is set when the
755
+ * `match` itself rejected (a transient L1 infra error) so the caller can
756
+ * report it as cache-read while still degrading to L2/KV -- distinct from a
757
+ * genuine miss (no entry), which sets neither flag.
758
+ * @internal
759
+ */
760
+ private async matchWithTimeout(
761
+ cache: Cache,
762
+ request: Request,
763
+ ): Promise<{
764
+ response: Response | undefined;
765
+ timedOut: boolean;
766
+ error?: unknown;
767
+ }> {
768
+ let matchError: unknown;
769
+ const { value, timedOut } = await this.readWithTimeout(
770
+ // A fast match rejection is caught at the thunk and reported as a miss
771
+ // (response undefined), so the caller falls through to L2/KV rather than
772
+ // escaping to the outer catch -- symmetric with the body-read thunk. The
773
+ // error is captured (not swallowed) so the caller can surface it via
774
+ // onError as a cache-read degradation.
775
+ () =>
776
+ cache.match(request).catch((e) => {
777
+ matchError = e;
778
+ return undefined;
779
+ }),
780
+ this.edgeLookupTimeoutMs,
781
+ "edge cache lookup",
782
+ );
783
+ return { response: value, timedOut, error: matchError };
784
+ }
785
+
786
+ /**
787
+ * Read and JSON-parse a matched L1 Response's body under the edgeReadTimeoutMs
788
+ * budget. CF resolves `match()` with a lazily-streamed body, so the latency
789
+ * tail surfaces here -- after matchWithTimeout has already passed -- not in the
790
+ * match itself. On timeout `undefined` is returned so the caller falls through
791
+ * to L2/KV or render.
792
+ * @internal
793
+ */
794
+ private async readJsonWithTimeout<T>(
795
+ response: Response,
796
+ ): Promise<{ value: T | undefined; errored: boolean; error?: unknown }> {
797
+ // A FAST json() rejection (a corrupt body, or a foreign 200 non-JSON
798
+ // response that collided on this key) is caught at the thunk and turned into
799
+ // a miss, so the caller falls through to L2/KV exactly like a body-timeout
800
+ // -- instead of escaping to get()/getItem()'s outer catch, which returns
801
+ // null WITHOUT ever consulting KV. The catch lives here, not in
802
+ // readWithTimeout, so the L2/KV tier keeps propagating a genuine kv.get
803
+ // rejection to its own error sink. The `errored` flag lets the caller emit a
804
+ // distinct "body-error" debug outcome rather than masquerading as a timeout.
805
+ // On a TIMEOUT the json() promise is still pending, so the catch has not
806
+ // fired: errored stays false and the outcome is correctly a body-timeout. A
807
+ // late rejection after the timeout only mutates the closure flag, which the
808
+ // already-returned object no longer reads.
809
+ let errored = false;
810
+ let error: unknown;
811
+ const { value } = await this.readWithTimeout<T | undefined>(
812
+ () =>
813
+ (response.json() as Promise<T>).catch((e) => {
814
+ errored = true;
815
+ error = e;
816
+ return undefined;
817
+ }),
818
+ this.edgeReadTimeoutMs,
819
+ "edge cache body read",
820
+ );
821
+ return { value, errored, error };
822
+ }
823
+
824
+ /**
825
+ * Self-heal a corrupt L1 entry, then return the fall-through result. Reports
826
+ * the corruption as cache-corrupt (so an onError consumer sees it distinctly
827
+ * from a transient outage), runs the caller's L2/KV fall-through, and evicts
828
+ * the faulty per-colo entry ONLY when that fall-through found no good copy.
829
+ *
830
+ * The conditional evict is the load-bearing detail: when KV DOES serve a copy,
831
+ * kvGet* has already scheduled a same-key promote (`cache.put`); an eager
832
+ * `cache.delete` here would race that put with no CF Cache API ordering
833
+ * guarantee and could clobber the freshly-restored entry. So in that case we
834
+ * lean on #558's heal-by-overwrite (the non-suppressed fall-through promotes /
835
+ * a fresh render re-`set`s over the bad entry) and skip the delete. Only when
836
+ * this request's fall-through found no copy (=== null) is the eager evict
837
+ * scheduled -- useful then, since nothing else will overwrite the poison entry.
838
+ * A null fall-through can also be a KV-read TIMEOUT rather than a genuine miss:
839
+ * a concurrent request that read KV successfully may be promoting the same key,
840
+ * and this evict could race it. That is benign -- the worst case is one wasted
841
+ * colo-local promote, never a wrong served value, and the next read self-heals
842
+ * -- so we accept it rather than suppressing the evict on a timeout (which
843
+ * would strand the poison entry when KV really is empty). The evict is
844
+ * non-blocking (waitUntil) so it never adds latency to the degraded read.
845
+ * @internal
846
+ */
847
+ private async healCorruptL1<T>(
848
+ cache: Cache,
849
+ request: Request,
850
+ error: unknown,
851
+ label: string,
852
+ fallThrough: () => Promise<T | null>,
853
+ ): Promise<T | null> {
854
+ reportCacheError(
855
+ error ?? new Error("corrupt/partial L1 body"),
856
+ "cache-corrupt",
857
+ `[CFCacheStore] ${label}: corrupt L1 body`,
858
+ );
859
+ const result = await fallThrough();
860
+ if (result === null) {
861
+ const evict = (): Promise<void> =>
862
+ reportingAsync(
863
+ () => cache.delete(request),
864
+ "cache-delete",
865
+ `[CFCacheStore] ${label}: evict corrupt L1`,
866
+ );
867
+ if (this.waitUntil) this.waitUntil(evict);
868
+ else void evict();
869
+ }
870
+ return result;
871
+ }
872
+
873
+ /**
874
+ * Re-put a stale L1 entry marked REVALIDATING, so concurrent requests serve it
875
+ * without each triggering a revalidation. Shared by get()/getItem().
876
+ *
877
+ * The write is NON-BLOCKING (waitUntil) and best-effort by design:
878
+ * - It runs in waitUntil, so it never adds the put latency to the served stale
879
+ * read and a put failure can never turn that good read into a miss. The put
880
+ * is still initiated synchronously (this.waitUntil invokes its callback
881
+ * immediately), so concurrent readers see the marker land at the same time an
882
+ * awaited write would -- awaiting only blocks the current request.
883
+ * - The background revalidation's fresh set() is gated behind a full re-render,
884
+ * so it lands well after this put; a stale-clobbers-fresh race would require
885
+ * this single put to be slower than that entire render+set, and self-heals
886
+ * within MAX_REVALIDATION_INTERVAL.
887
+ *
888
+ * Cache-Control is recomputed to the REMAINING ttl from the stored hard-expiry
889
+ * deadline (see remainingCacheControl), not copied from the original
890
+ * full-window header -- copying it would restart CF retention on every re-arm
891
+ * and pin a perpetually-failing entry past hard-expiry. A legacy/tampered entry
892
+ * without a valid deadline floors to max-age=1 and self-heals via KV.
893
+ * @internal
894
+ */
895
+ private markRevalidating(
896
+ cache: Cache,
897
+ request: Request,
898
+ sourceHeaders: Headers,
899
+ status: number,
900
+ body: string,
901
+ ): void {
902
+ const reputNow = Date.now();
903
+ const headers = new Headers(sourceHeaders);
904
+ headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
905
+ headers.set(CACHE_REVALIDATING_AT_HEADER, String(reputNow));
906
+ headers.set("Cache-Control", remainingCacheControl(headers, reputNow));
907
+ const markerResponse = new Response(body, { status, headers });
908
+ const write = async (): Promise<void> => {
909
+ try {
910
+ await cache.put(request, markerResponse);
911
+ } catch {
912
+ // Best-effort: a failed marker write must not affect the served read;
913
+ // the entry simply re-arms on the next stale read.
914
+ }
915
+ };
916
+ if (this.waitUntil) this.waitUntil(write);
917
+ else void write();
918
+ }
919
+
920
+ /**
921
+ * Document-tier counterpart of markRevalidating for getResponse's herd guard.
922
+ * The segment/item tiers JSON-parse the body, so they re-put with a string
923
+ * body; document bodies are streamed verbatim, so we re-put with a CLONED
924
+ * response body (`response.clone()`) supplied by the caller -- the original
925
+ * body still streams to the client while the marker carries the clone. Same
926
+ * REVALIDATING status header, same revalidating-at stamp, same
927
+ * remainingCacheControl re-put math as markRevalidating, so the document tier
928
+ * suppresses concurrent revalidation for the identical MAX_REVALIDATION_INTERVAL
929
+ * window the segment tier does. Best-effort and non-blocking: a failed marker
930
+ * write must not affect the served stale read.
931
+ * @internal
932
+ */
933
+ private markResponseRevalidating(
934
+ cache: Cache,
935
+ request: Request,
936
+ clonedResponse: Response,
937
+ ): void {
938
+ const reputNow = Date.now();
939
+ const headers = new Headers(clonedResponse.headers);
940
+ headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
941
+ headers.set(CACHE_REVALIDATING_AT_HEADER, String(reputNow));
942
+ headers.set("Cache-Control", remainingCacheControl(headers, reputNow));
943
+ const markerResponse = new Response(clonedResponse.body, {
944
+ status: clonedResponse.status,
945
+ statusText: clonedResponse.statusText,
946
+ headers,
947
+ });
948
+ const write = async (): Promise<void> => {
949
+ try {
950
+ await cache.put(request, markerResponse);
951
+ } catch {
952
+ // Best-effort: see markRevalidating.
953
+ }
954
+ };
955
+ if (this.waitUntil) this.waitUntil(write);
956
+ else void write();
957
+ }
958
+
317
959
  // ============================================================================
318
960
  // Segment Cache Methods
319
961
  // ============================================================================
320
962
 
963
+ /**
964
+ * Guard the segment tier against a `keyGenerator` that returns a key colliding
965
+ * with a reserved tag-marker namespace: `__tag__/` (the KV marker key) or
966
+ * `__tagmarker__/` (the L1 Cache API marker request). The item/doc tiers are
967
+ * internally prefixed (`fn:`/`doc:`) so only the bare segment key can collide;
968
+ * a collision would let a segment write clobber - or a segment read/delete
969
+ * evict - a live tag marker, silently breaking invalidation. Report loudly
970
+ * (so a misconfigured keyGenerator surfaces immediately) and treat the segment
971
+ * operation as a miss/no-op rather than corrupting the marker namespace.
972
+ * @internal
973
+ */
974
+ private isReservedSegmentKey(
975
+ key: string,
976
+ category: CacheErrorCategory,
977
+ ): boolean {
978
+ const reserved = key.startsWith(TAG_MARKER_PREFIX)
979
+ ? TAG_MARKER_PREFIX
980
+ : key.startsWith(TAG_MARKER_CACHE_PREFIX)
981
+ ? TAG_MARKER_CACHE_PREFIX
982
+ : null;
983
+ if (!reserved) return false;
984
+ reportCacheError(
985
+ new Error(
986
+ `segment key "${key}" collides with the reserved "${reserved}" ` +
987
+ `tag-marker namespace; the operation is ignored. Fix the store ` +
988
+ `keyGenerator so it does not produce keys with this prefix.`,
989
+ ),
990
+ category,
991
+ "[CFCacheStore] reserved key",
992
+ );
993
+ return true;
994
+ }
995
+
321
996
  /**
322
997
  * Get cached entry data by key.
323
998
  *
@@ -329,50 +1004,237 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
329
1004
  * On L1 miss, falls back to KV (L2) if configured.
330
1005
  * KV hits are promoted to L1 in the background.
331
1006
  */
332
- async get(key: string): Promise<CacheGetResult | null> {
1007
+ async get(key: string): Promise<CacheGetResult | null | CacheReadError> {
1008
+ if (this.isReservedSegmentKey(key, "cache-read")) return null;
333
1009
  try {
334
1010
  const cache = await this.getCache();
335
1011
  const request = this.keyToRequest(key);
336
- const response = await cache.match(request);
1012
+ const matchStart = Date.now();
1013
+ const {
1014
+ response,
1015
+ timedOut,
1016
+ error: matchError,
1017
+ } = await this.matchWithTimeout(cache, request);
1018
+ const matchMs = Date.now() - matchStart;
337
1019
 
338
1020
  if (!response) {
1021
+ if (matchError) {
1022
+ // A match REJECTION is reported as cache-read and still degrades to
1023
+ // L2/KV -- a real KV value (or KV's own CACHE_READ_ERROR) stands on
1024
+ // its own. But a null KV result (unconfigured namespace, kv-miss,
1025
+ // kv-timeout) is NOT proof of absence under a rejected L1 match:
1026
+ // the only real signal this read produced is the failure, so
1027
+ // surface CACHE_READ_ERROR instead of a replayable miss (the PPR
1028
+ // seeded fallback must render uncached, not substitute the doc
1029
+ // record for a partition the store could not actually read).
1030
+ reportCacheError(
1031
+ matchError,
1032
+ "cache-read",
1033
+ "[CFCacheStore] get L1 match",
1034
+ );
1035
+ if (this.debug)
1036
+ this.emitDebug({
1037
+ op: "get",
1038
+ key,
1039
+ // Distinct from a genuine absence: surface it as match-error so
1040
+ // debug agrees with the cache-read already routed to onError,
1041
+ // instead of masquerading as l1-miss.
1042
+ outcome: "match-error",
1043
+ matchMs,
1044
+ });
1045
+ const kvResult = await this.kvGetSegment(key);
1046
+ return kvResult ?? CACHE_READ_ERROR;
1047
+ }
1048
+ // An abandoned slow match (timedOut) keeps the fail-open latency-budget
1049
+ // policy: degrade to L2/KV, and a KV null stays a miss.
1050
+ if (this.debug)
1051
+ this.emitDebug({
1052
+ op: "get",
1053
+ key,
1054
+ outcome: timedOut ? "match-timeout" : "l1-miss",
1055
+ matchMs,
1056
+ });
339
1057
  return this.kvGetSegment(key);
340
1058
  }
341
1059
 
1060
+ // A non-200 entry (a cached error response, or a foreign response that
1061
+ // landed on this key) is not valid segment data; treat it as a miss
1062
+ // rather than JSON-parsing garbage and serving it as a hit.
1063
+ if (response.status !== 200) {
1064
+ if (this.debug)
1065
+ this.emitDebug({
1066
+ op: "get",
1067
+ key,
1068
+ outcome: "non-200",
1069
+ status: response.status,
1070
+ matchMs,
1071
+ });
1072
+ // Degraded fall-through: suppress revalidation so a broken L1 entry hit
1073
+ // concurrently serves KV-stale, not a herd. See kvGetSegment.
1074
+ return this.kvGetSegment(key, { suppressRevalidate: true });
1075
+ }
1076
+
1077
+ // Tag invalidation: an entry whose tags were invalidated after it was
1078
+ // cached is treated as a miss, so the next render re-populates it. We
1079
+ // return null (re-render locally) rather than falling through to KV. In
1080
+ // the common case the L1 entry and its KV twin were written together with
1081
+ // the same taggedAt, so kvGetSegment's own tag check would miss too and a
1082
+ // fall-through is pure cost. The tiers CAN diverge -- another colo may have
1083
+ // already re-rendered and written a fresher KV envelope -- in which case a
1084
+ // fall-through could serve that copy instead of re-rendering here.
1085
+ // Capturing that cross-colo optimization is a deferred follow-up, not a
1086
+ // correctness gap: this colo's next read after its own re-render self-heals.
1087
+ const tagInfo = this.readTagInfo(response.headers);
1088
+ // Measure the marker-resolution tail (memo -> L1 marker cache -> KV) only
1089
+ // when debug is on, so the hot path pays nothing. It is the serial read
1090
+ // that sits between matchMs and bodyReadMs for a tagged entry.
1091
+ const markerStart = this.debug ? Date.now() : 0;
1092
+ const invalidated = await this.isL1Invalidated(
1093
+ tagInfo.tags,
1094
+ tagInfo.taggedAt,
1095
+ response.headers,
1096
+ );
1097
+ const markerMs = this.debug ? Date.now() - markerStart : undefined;
1098
+ if (invalidated) {
1099
+ if (this.debug)
1100
+ this.emitDebug({
1101
+ op: "get",
1102
+ key,
1103
+ outcome: "tag-invalidated",
1104
+ status: response.status,
1105
+ matchMs,
1106
+ markerMs,
1107
+ });
1108
+ return null;
1109
+ }
1110
+
342
1111
  // Read status headers
343
1112
  const status = response.headers.get(CACHE_STATUS_HEADER);
344
- const age = Number(response.headers.get("age") ?? "0");
345
1113
  const staleAt = Number(
346
1114
  response.headers.get(CACHE_STALE_AT_HEADER) ?? "0",
347
1115
  );
1116
+ const revalidatingAt = Number(
1117
+ response.headers.get(CACHE_REVALIDATING_AT_HEADER) ?? "0",
1118
+ );
348
1119
 
349
- const isStale = staleAt > 0 && Date.now() > staleAt;
1120
+ const now = Date.now();
1121
+ const isStale = staleAt > 0 && now > staleAt;
1122
+ // Recency comes from our explicit revalidating-at stamp, not CF's `Age`
1123
+ // header (see CACHE_REVALIDATING_AT_HEADER). An absent/zero stamp counts
1124
+ // as "not recent" so a dropped revalidation re-arms instead of pinning.
350
1125
  const isRevalidating =
351
- status === "REVALIDATING" && age < MAX_REVALIDATION_INTERVAL;
1126
+ status === "REVALIDATING" &&
1127
+ revalidatingAt > 0 &&
1128
+ now - revalidatingAt < MAX_REVALIDATION_INTERVAL * 1000;
1129
+
1130
+ // Single emitter for the post-header L1 outcomes. Undefined (so the event
1131
+ // object is never allocated) when debug is off; the informational-only
1132
+ // `age` header is read lazily inside for the same reason.
1133
+ const debugRead = this.debug
1134
+ ? (
1135
+ outcome: CFCacheReadDebugEvent["outcome"],
1136
+ bodyReadMs: number,
1137
+ shouldRevalidate?: boolean,
1138
+ ) =>
1139
+ this.emitDebug({
1140
+ op: "get",
1141
+ key,
1142
+ outcome,
1143
+ status: response.status,
1144
+ cacheStatus: status,
1145
+ staleAt,
1146
+ revalidatingAt,
1147
+ ageHeader: response.headers.get("age"),
1148
+ isStale,
1149
+ isRevalidating,
1150
+ shouldRevalidate,
1151
+ matchMs,
1152
+ markerMs,
1153
+ bodyReadMs,
1154
+ })
1155
+ : undefined;
352
1156
 
353
1157
  // Case 1: Fresh or already being revalidated - just return data
354
1158
  if (!isStale || isRevalidating) {
355
- const data = (await response.json()) as CachedEntryData;
1159
+ const bodyStart = Date.now();
1160
+ const {
1161
+ value: data,
1162
+ errored,
1163
+ error,
1164
+ } = await this.readJsonWithTimeout<CachedEntryData>(response);
1165
+ const bodyReadMs = Date.now() - bodyStart;
1166
+ if (data === undefined) {
1167
+ debugRead?.(errored ? "body-error" : "body-timeout", bodyReadMs);
1168
+ // A body-ERROR (corrupt/foreign body) self-heals via healCorruptL1:
1169
+ // report cache-corrupt, fall through to L2/KV (which overwrites the
1170
+ // bad entry), and evict only if KV had no good copy to promote. A
1171
+ // body-TIMEOUT is a degraded read of a likely-valid entry: leave it
1172
+ // intact and suppress revalidation so a stalling colo cannot herd.
1173
+ if (errored)
1174
+ return this.healCorruptL1(cache, request, error, "get", () =>
1175
+ this.kvGetSegment(key, { suppressRevalidate: false }),
1176
+ );
1177
+ return this.kvGetSegment(key, { suppressRevalidate: true });
1178
+ }
1179
+ debugRead?.(
1180
+ isRevalidating ? "l1-revalidating-guarded" : "l1-fresh",
1181
+ bodyReadMs,
1182
+ false,
1183
+ );
356
1184
  return { data, shouldRevalidate: false };
357
1185
  }
358
1186
 
359
- // Case 2: Stale and needs revalidation - atomically mark REVALIDATING
360
- const [b1, b2] = response.body!.tee();
361
-
362
- const headers = new Headers(response.headers);
363
- headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
1187
+ // Case 2: Stale and needs revalidation.
1188
+ // Read the body under the edge-read budget BEFORE writing the REVALIDATING
1189
+ // marker. CF can resolve match() fast but stall the body stream; the prior
1190
+ // approach teed the stream and awaited cache.put(b1) first, which blocked
1191
+ // on that same stalled stream so the read budget could never fire on a
1192
+ // stale hit. Reading first bounds the stall and lets us skip marking an
1193
+ // entry we could not even read.
1194
+ const bodyStart = Date.now();
1195
+ const {
1196
+ value: data,
1197
+ errored,
1198
+ error,
1199
+ } = await this.readJsonWithTimeout<CachedEntryData>(response);
1200
+ const bodyReadMs = Date.now() - bodyStart;
1201
+ if (data === undefined) {
1202
+ debugRead?.(errored ? "body-error" : "body-timeout", bodyReadMs);
1203
+ // Heal + conditionally evict a body-error, suppress a body-timeout; see
1204
+ // Case 1.
1205
+ if (errored)
1206
+ return this.healCorruptL1(
1207
+ cache,
1208
+ request,
1209
+ error,
1210
+ "get(revalidating)",
1211
+ () => this.kvGetSegment(key, { suppressRevalidate: false }),
1212
+ );
1213
+ return this.kvGetSegment(key, { suppressRevalidate: true });
1214
+ }
364
1215
 
365
- // Blocking write - must complete before returning to prevent race
366
- await cache.put(
1216
+ // Mark REVALIDATING so concurrent requests don't all revalidate, then
1217
+ // return the stale data. The marker write is non-blocking and best-effort
1218
+ // (see markRevalidating) -- it must not add latency to, or fail, the served
1219
+ // stale read.
1220
+ this.markRevalidating(
1221
+ cache,
367
1222
  request,
368
- new Response(b1, { status: response.status, headers }),
1223
+ response.headers,
1224
+ response.status,
1225
+ JSON.stringify(data),
369
1226
  );
370
1227
 
371
- const data = (await new Response(b2).json()) as CachedEntryData;
1228
+ debugRead?.("l1-stale-revalidate", bodyReadMs, true);
372
1229
  return { data, shouldRevalidate: true };
373
1230
  } catch (error) {
374
- console.error("[CFCacheStore] get failed:", error);
375
- return null;
1231
+ // reportCacheError logs and routes to onError (cache-read); the debug
1232
+ // emit is the separate wrangler-tail signal. Keep both observability paths.
1233
+ reportCacheError(error, "cache-read", "[CFCacheStore] get");
1234
+ if (this.debug) this.emitDebug({ op: "get", key, outcome: "error" });
1235
+ // Distinct from a miss so the PPR replay composition renders uncached
1236
+ // instead of substituting the seeded doc record (CACHE_READ_ERROR).
1237
+ return CACHE_READ_ERROR;
376
1238
  }
377
1239
  }
378
1240
 
@@ -387,6 +1249,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
387
1249
  ttl: number,
388
1250
  swr?: number,
389
1251
  ): Promise<void> {
1252
+ if (this.isReservedSegmentKey(key, "cache-write")) return;
1253
+ if (this.skipUncacheableTagSet(data.tags)) return;
390
1254
  try {
391
1255
  const cache = await this.getCache();
392
1256
  const request = this.keyToRequest(key);
@@ -396,32 +1260,60 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
396
1260
  const totalTtl = ttl + swrWindow;
397
1261
  const staleAt = Date.now() + ttl * 1000;
398
1262
 
399
- const body = JSON.stringify(data);
1263
+ // Stamp the tag timestamp at write time and carry it (with the tags)
1264
+ // into both the L1 body and the KV envelope so reads can run the
1265
+ // invalidation check.
1266
+ const taggedAt =
1267
+ Array.isArray(data.tags) && data.tags.length > 0
1268
+ ? Date.now()
1269
+ : undefined;
1270
+ const dataToStore: CachedEntryData = taggedAt
1271
+ ? { ...data, taggedAt }
1272
+ : data;
1273
+
1274
+ // Serialize the segment payload exactly once: L1 stores the JSON body
1275
+ // directly, and kvSetSegment embeds the same string as envelope.d so the
1276
+ // (potentially large) Flight/segment tree is not walked a second time.
1277
+ const body = JSON.stringify(dataToStore);
400
1278
  const response = new Response(body, {
401
1279
  headers: {
402
1280
  "Content-Type": "application/json",
403
1281
  "Cache-Control": `public, max-age=${totalTtl}`,
404
1282
  [CACHE_STALE_AT_HEADER]: String(staleAt),
1283
+ // Absolute hard-expiry deadline so a stale-path re-put can recompute a
1284
+ // shrinking max-age instead of restarting retention (see
1285
+ // remainingCacheControl / CACHE_EXPIRES_AT_HEADER).
1286
+ [CACHE_EXPIRES_AT_HEADER]: String(staleAt + swrWindow * 1000),
405
1287
  [CACHE_STATUS_HEADER]: "HIT",
1288
+ ...this.tagHeaderEntries(dataToStore.tags, taggedAt),
406
1289
  },
407
1290
  });
408
1291
 
409
1292
  const putPromise = cache.put(request, response);
410
1293
 
411
1294
  if (this.waitUntil) {
412
- // Non-blocking write
413
- this.waitUntil(async () => {
414
- await putPromise;
415
- });
1295
+ // Non-blocking write. These store-level background tasks intentionally
1296
+ // omit the reportingAsync ctx argument: the store is a request-agnostic
1297
+ // singleton and this.waitUntil is the execution context's, not a single
1298
+ // request's, so a failure is reported console-loud only (it cannot be
1299
+ // attributed to one request's onError). The request-scoped tag verbs
1300
+ // (revalidateTag / stale-revalidation) DO thread their captured ctx.
1301
+ this.waitUntil(() =>
1302
+ reportingAsync(
1303
+ () => putPromise,
1304
+ "cache-write",
1305
+ "[CFCacheStore] L1 write",
1306
+ ),
1307
+ );
416
1308
  } else {
417
1309
  // Blocking fallback
418
1310
  await putPromise;
419
1311
  }
420
1312
 
421
- // L2: persist to KV
422
- this.kvSetSegment(key, data, staleAt, totalTtl);
1313
+ // L2: persist to KV (reuses `body` as envelope.d)
1314
+ this.kvSetSegment(key, body, staleAt, totalTtl, swrWindow);
423
1315
  } catch (error) {
424
- console.error("[CFCacheStore] set failed:", error);
1316
+ reportCacheError(error, "cache-write", "[CFCacheStore] set");
425
1317
  }
426
1318
  }
427
1319
 
@@ -429,25 +1321,26 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
429
1321
  * Delete a cached entry from L1 and L2.
430
1322
  */
431
1323
  async delete(key: string): Promise<boolean> {
1324
+ if (this.isReservedSegmentKey(key, "cache-delete")) return false;
432
1325
  try {
433
1326
  const cache = await this.getCache();
434
1327
  const result = await cache.delete(this.keyToRequest(key));
435
1328
 
436
1329
  // L2: delete from KV
437
1330
  if (this.kv && this.waitUntil) {
438
- const kvKey = this.toKVKey(key);
439
- this.waitUntil(async () => {
440
- try {
441
- await this.kv!.delete(kvKey);
442
- } catch {
443
- // KV delete failures are non-critical
444
- }
445
- });
1331
+ const kvKey = await this.toKVKey(key);
1332
+ this.waitUntil(() =>
1333
+ reportingAsync(
1334
+ () => this.kv!.delete(kvKey),
1335
+ "cache-delete",
1336
+ "[CFCacheStore] delete L2",
1337
+ ),
1338
+ );
446
1339
  }
447
1340
 
448
1341
  return result;
449
1342
  } catch (error) {
450
- console.error("[CFCacheStore] delete failed:", error);
1343
+ reportCacheError(error, "cache-delete", "[CFCacheStore] delete");
451
1344
  return false;
452
1345
  }
453
1346
  }
@@ -467,26 +1360,132 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
467
1360
  try {
468
1361
  const cache = await this.getCache();
469
1362
  const request = this.keyToRequest(`doc:${key}`);
470
- const response = await cache.match(request);
1363
+ // The document path is outside the debug surface (op is only get/getItem),
1364
+ // so the match-timeout flag is not surfaced as an event here -- though
1365
+ // matchWithTimeout still warns on a slow match. A miss or timeout falls
1366
+ // through to the KV document path and then render.
1367
+ const { response, error: matchError } = await this.matchWithTimeout(
1368
+ cache,
1369
+ request,
1370
+ );
471
1371
 
472
1372
  if (!response || response.status !== 200) {
1373
+ // A transient L1 match rejection (matchError set; only ever set when
1374
+ // response is undefined) is surfaced as cache-read before degrading to
1375
+ // L2/KV -- matching get()/getItem(). A genuine miss or a non-200 hit
1376
+ // carries no matchError and reports nothing.
1377
+ if (matchError)
1378
+ reportCacheError(
1379
+ matchError,
1380
+ "cache-read",
1381
+ "[CFCacheStore] getResponse L1 match",
1382
+ );
473
1383
  return this.kvGetResponse(key);
474
1384
  }
475
1385
 
1386
+ // Tag invalidation check (treat invalidated entry as a miss).
1387
+ const tagInfo = this.readTagInfo(response.headers);
1388
+ if (
1389
+ await this.isL1Invalidated(
1390
+ tagInfo.tags,
1391
+ tagInfo.taggedAt,
1392
+ response.headers,
1393
+ )
1394
+ ) {
1395
+ return null;
1396
+ }
1397
+
476
1398
  // Check staleness
477
1399
  const staleAt = Number(response.headers.get(CACHE_STALE_AT_HEADER) || 0);
478
- const isStale = staleAt > 0 && Date.now() > staleAt;
1400
+ const now = Date.now();
1401
+ const isStale = staleAt > 0 && now > staleAt;
1402
+
1403
+ // Thundering-herd guard, mirroring the segment (get) and item (getItem)
1404
+ // tiers. Without it, every concurrent stale reader returned
1405
+ // shouldRevalidate=true and document-cache.ts scheduled a fresh render for
1406
+ // each one. Recency comes from our own revalidating-at stamp, not CF's Age
1407
+ // header (see CACHE_REVALIDATING_AT_HEADER); an absent/zero stamp counts as
1408
+ // "not recent" so a dropped revalidation re-arms instead of pinning.
1409
+ const status = response.headers.get(CACHE_STATUS_HEADER);
1410
+ const revalidatingAt = Number(
1411
+ response.headers.get(CACHE_REVALIDATING_AT_HEADER) ?? "0",
1412
+ );
1413
+ const isRevalidating =
1414
+ status === "REVALIDATING" &&
1415
+ revalidatingAt > 0 &&
1416
+ now - revalidatingAt < MAX_REVALIDATION_INTERVAL * 1000;
1417
+
1418
+ // L1 document bodies are streamed through verbatim - unlike the segment/
1419
+ // item tiers (which JSON-parse and so structurally detect corruption) and
1420
+ // the KV doc tier (validated in kvGetResponse, KV being the real partial-
1421
+ // read vector). Integrity here relies on the Cache API: cache.put stores a
1422
+ // response atomically or fails, so a truncated body is not served back. We
1423
+ // deliberately do NOT buffer+hash the body to re-verify it: that would
1424
+ // defeat streaming the document and add a full read to every cache hit.
1425
+
1426
+ if (isStale && !isRevalidating) {
1427
+ // First stale reader within the window: mark REVALIDATING (non-blocking,
1428
+ // best-effort) so concurrent readers below see the guard and suppress,
1429
+ // then return shouldRevalidate=true so this caller revalidates. Clone the
1430
+ // matched response for the marker since its original body must still
1431
+ // stream to the client.
1432
+ this.markResponseRevalidating(cache, request, response.clone());
1433
+ return {
1434
+ response: this.toClientResponse(response),
1435
+ shouldRevalidate: true,
1436
+ };
1437
+ }
479
1438
 
1439
+ // Fresh, or stale-but-already-REVALIDATING: serve without scheduling a
1440
+ // (re-)revalidation. A recent marker already has a render in flight.
480
1441
  return {
481
- response,
482
- shouldRevalidate: isStale,
1442
+ response: this.toClientResponse(response),
1443
+ shouldRevalidate: false,
483
1444
  };
484
1445
  } catch (error) {
485
- console.error("[CFCacheStore] getResponse failed:", error);
1446
+ reportCacheError(error, "cache-read", "[CFCacheStore] getResponse");
486
1447
  return null;
487
1448
  }
488
1449
  }
489
1450
 
1451
+ /**
1452
+ * Strip internal edge headers and restore the author's Cache-Control before a
1453
+ * cached document Response is served to a client. L1 entries carry the
1454
+ * internal staleness/status headers and a rewritten Cache-Control; none of
1455
+ * those should reach the browser or an upstream CDN.
1456
+ */
1457
+ private toClientResponse(response: Response): Response {
1458
+ const headers = new Headers(response.headers);
1459
+ const originalCacheControl = headers.get(CACHE_ORIG_CC_HEADER);
1460
+ if (originalCacheControl !== null) {
1461
+ headers.set("Cache-Control", originalCacheControl);
1462
+ } else {
1463
+ headers.delete("Cache-Control");
1464
+ }
1465
+ headers.delete(CACHE_ORIG_CC_HEADER);
1466
+ headers.delete(CACHE_STALE_AT_HEADER);
1467
+ headers.delete(CACHE_STATUS_HEADER);
1468
+ headers.delete(CACHE_TAGS_HEADER);
1469
+ headers.delete(CACHE_TAGGED_AT_HEADER);
1470
+ // Remove OUR namespaced tokens from Cache-Tag while preserving any the
1471
+ // document author set (setTagHeaders appended ours onto theirs). The
1472
+ // author's tags may be load-bearing for their own CDN purging; ours are
1473
+ // internal storage bookkeeping and must not leak to clients.
1474
+ this.stripInternalCacheTags(headers);
1475
+ // Internal stale-path bookkeeping (hard-expiry deadline + REVALIDATING
1476
+ // stamp). Carried on doc L1 entries for the herd guard; never serve them.
1477
+ headers.delete(CACHE_EXPIRES_AT_HEADER);
1478
+ headers.delete(CACHE_REVALIDATING_AT_HEADER);
1479
+ // Finding #3 (read side): strip per-client signals a pre-fix or
1480
+ // pinned-version L1 entry may carry. See the read-side note in the design doc.
1481
+ stripPerClientSignals(headers);
1482
+ return new Response(response.body, {
1483
+ status: response.status,
1484
+ statusText: response.statusText,
1485
+ headers,
1486
+ });
1487
+ }
1488
+
490
1489
  /**
491
1490
  * Store a Response with TTL and optional SWR window (for document-level caching).
492
1491
  * When KV is configured, also persists to L2.
@@ -496,7 +1495,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
496
1495
  response: Response,
497
1496
  ttl: number,
498
1497
  swr?: number,
1498
+ tags?: string[],
499
1499
  ): Promise<void> {
1500
+ if (this.skipUncacheableTagSet(tags)) return;
500
1501
  try {
501
1502
  const cache = await this.getCache();
502
1503
  const request = this.keyToRequest(`doc:${key}`);
@@ -505,6 +1506,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
505
1506
  const swrWindow = resolveSwrWindow(swr, this.defaults);
506
1507
  const totalTtl = ttl + swrWindow;
507
1508
  const staleAt = Date.now() + ttl * 1000;
1509
+ const taggedAt =
1510
+ Array.isArray(tags) && tags.length > 0 ? Date.now() : undefined;
508
1511
 
509
1512
  // Clone body for potential KV write before consuming it for L1
510
1513
  const [l1Body, kvBody] = this.kv
@@ -513,10 +1516,27 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
513
1516
  : [null, null]
514
1517
  : [response.body, null];
515
1518
 
516
- // Clone and add cache headers
1519
+ // Clone and add cache headers. The author's Cache-Control is stashed and
1520
+ // replaced with a long max-age so the CF Cache API holds the entry across
1521
+ // the SWR window; getResponse restores the original before serving.
517
1522
  const headers = new Headers(response.headers);
1523
+ // Finding #3: never persist a per-client signal in the shared L1 entry
1524
+ // (the platform's Set-Cookie rejection is unverified and ignores the
1525
+ // directive anyway). See stripPerClientSignals.
1526
+ stripPerClientSignals(headers);
1527
+ const originalCacheControl = response.headers.get("Cache-Control");
1528
+ if (originalCacheControl !== null) {
1529
+ headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
1530
+ }
518
1531
  headers.set("Cache-Control", `public, max-age=${totalTtl}`);
519
1532
  headers.set(CACHE_STALE_AT_HEADER, String(staleAt));
1533
+ // Absolute hard-expiry deadline so a stale-path REVALIDATING re-put can
1534
+ // recompute a shrinking max-age (remainingCacheControl) instead of
1535
+ // restarting retention. Mirrors set()/setItem(). Stripped by
1536
+ // toClientResponse before serving.
1537
+ headers.set(CACHE_EXPIRES_AT_HEADER, String(staleAt + swrWindow * 1000));
1538
+ // Internal tag headers (stripped by toClientResponse before serving).
1539
+ this.setTagHeaders(headers, tags, taggedAt);
520
1540
 
521
1541
  const toCache = new Response(l1Body, {
522
1542
  status: response.status,
@@ -528,9 +1548,13 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
528
1548
 
529
1549
  if (this.waitUntil) {
530
1550
  // Non-blocking write
531
- this.waitUntil(async () => {
532
- await putPromise;
533
- });
1551
+ this.waitUntil(() =>
1552
+ reportingAsync(
1553
+ () => putPromise,
1554
+ "cache-write",
1555
+ "[CFCacheStore] L1 write",
1556
+ ),
1557
+ );
534
1558
  } else {
535
1559
  // Blocking fallback
536
1560
  await putPromise;
@@ -538,35 +1562,43 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
538
1562
 
539
1563
  // L2: persist to KV (KV requires expirationTtl >= 60s)
540
1564
  if (this.kv && this.waitUntil && totalTtl >= 60) {
541
- const kvKey = this.toKVKey(`doc:${key}`);
1565
+ const kvKey = await this.toDocKVKey(key);
1566
+ // Finding #3: never persist a per-client signal in the KV envelope.
542
1567
  const headersArray: [string, string][] = [];
543
- response.headers.forEach((v, k) => headersArray.push([k, v]));
1568
+ response.headers.forEach((v, k) => {
1569
+ if (isPerClientSignalHeader(k)) return;
1570
+ headersArray.push([k, v]);
1571
+ });
544
1572
  // Read body as ArrayBuffer and encode to base64 to preserve binary payloads
545
1573
  const bodyBuf = kvBody
546
1574
  ? await new Response(kvBody).arrayBuffer()
547
1575
  : new ArrayBuffer(0);
548
1576
  const bodyBase64 = bufferToBase64(bodyBuf);
549
1577
 
550
- this.waitUntil(async () => {
551
- try {
552
- const envelope: KVResponseEnvelope = {
553
- b: bodyBase64,
554
- st: response.status,
555
- stx: response.statusText,
556
- hd: headersArray,
557
- s: staleAt,
558
- e: staleAt + swrWindow * 1000,
559
- };
560
- await this.kv!.put(kvKey, JSON.stringify(envelope), {
561
- expirationTtl: totalTtl,
562
- });
563
- } catch (error) {
564
- console.error("[CFCacheStore] KV putResponse failed:", error);
565
- }
566
- });
1578
+ this.waitUntil(() =>
1579
+ reportingAsync(
1580
+ () => {
1581
+ const envelope: KVResponseEnvelope = {
1582
+ b: bodyBase64,
1583
+ st: response.status,
1584
+ stx: response.statusText,
1585
+ hd: headersArray,
1586
+ s: staleAt,
1587
+ e: staleAt + swrWindow * 1000,
1588
+ t: tags,
1589
+ ta: taggedAt,
1590
+ };
1591
+ return this.kv!.put(kvKey, JSON.stringify(envelope), {
1592
+ expirationTtl: totalTtl,
1593
+ });
1594
+ },
1595
+ "cache-write",
1596
+ "[CFCacheStore] kvPutResponse",
1597
+ ),
1598
+ );
567
1599
  }
568
1600
  } catch (error) {
569
- console.error("[CFCacheStore] putResponse failed:", error);
1601
+ reportCacheError(error, "cache-write", "[CFCacheStore] putResponse");
570
1602
  }
571
1603
  }
572
1604
 
@@ -583,48 +1615,174 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
583
1615
  try {
584
1616
  const cache = await this.getCache();
585
1617
  const request = this.keyToRequest(`fn:${key}`);
586
- const response = await cache.match(request);
1618
+ const matchStart = Date.now();
1619
+ const {
1620
+ response,
1621
+ timedOut,
1622
+ error: matchError,
1623
+ } = await this.matchWithTimeout(cache, request);
1624
+ const matchMs = Date.now() - matchStart;
587
1625
 
588
- if (!response) return this.kvGetItem(key);
1626
+ if (!response) {
1627
+ // Transient match error reported cache-read; still degrades to L2/KV.
1628
+ if (matchError)
1629
+ reportCacheError(
1630
+ matchError,
1631
+ "cache-read",
1632
+ "[CFCacheStore] getItem L1 match",
1633
+ );
1634
+ if (this.debug)
1635
+ this.emitDebug({
1636
+ op: "getItem",
1637
+ key,
1638
+ // match-error (rejection) vs l1-miss (absence); see get().
1639
+ outcome: matchError
1640
+ ? "match-error"
1641
+ : timedOut
1642
+ ? "match-timeout"
1643
+ : "l1-miss",
1644
+ matchMs,
1645
+ });
1646
+ return this.kvGetItem(key);
1647
+ }
1648
+
1649
+ // Non-200 entry is not a valid cached function result; treat as a miss.
1650
+ if (response.status !== 200) {
1651
+ if (this.debug)
1652
+ this.emitDebug({
1653
+ op: "getItem",
1654
+ key,
1655
+ outcome: "non-200",
1656
+ status: response.status,
1657
+ matchMs,
1658
+ });
1659
+ // Degraded fall-through: suppress revalidation so a broken L1 entry hit
1660
+ // concurrently serves KV-stale instead of spawning a herd (see get()).
1661
+ return this.kvGetItem(key, { suppressRevalidate: true });
1662
+ }
1663
+
1664
+ // Tag invalidation check (treat invalidated entry as a miss). Measure the
1665
+ // marker-resolution tail only under debug (see get()).
1666
+ const tagInfo = this.readTagInfo(response.headers);
1667
+ const markerStart = this.debug ? Date.now() : 0;
1668
+ const invalidated = await this.isL1Invalidated(
1669
+ tagInfo.tags,
1670
+ tagInfo.taggedAt,
1671
+ response.headers,
1672
+ );
1673
+ const markerMs = this.debug ? Date.now() - markerStart : undefined;
1674
+ if (invalidated) {
1675
+ if (this.debug)
1676
+ this.emitDebug({
1677
+ op: "getItem",
1678
+ key,
1679
+ outcome: "tag-invalidated",
1680
+ status: response.status,
1681
+ matchMs,
1682
+ markerMs,
1683
+ });
1684
+ return null;
1685
+ }
589
1686
 
590
1687
  const staleAt = Number(
591
1688
  response.headers.get(CACHE_STALE_AT_HEADER) ?? "0",
592
1689
  );
593
1690
  const status = response.headers.get(CACHE_STATUS_HEADER);
594
- const age = Number(response.headers.get("age") ?? "0");
1691
+ const revalidatingAt = Number(
1692
+ response.headers.get(CACHE_REVALIDATING_AT_HEADER) ?? "0",
1693
+ );
595
1694
 
596
- const isStale = staleAt > 0 && Date.now() > staleAt;
1695
+ const now = Date.now();
1696
+ const isStale = staleAt > 0 && now > staleAt;
1697
+ // Recency from our explicit stamp, not CF's `Age` header (see get()).
597
1698
  const isRevalidating =
598
- status === "REVALIDATING" && age < MAX_REVALIDATION_INTERVAL;
599
-
600
- const data = (await response.json()) as {
1699
+ status === "REVALIDATING" &&
1700
+ revalidatingAt > 0 &&
1701
+ now - revalidatingAt < MAX_REVALIDATION_INTERVAL * 1000;
1702
+
1703
+ // Single emitter for the post-header L1 outcomes (see get()). Undefined
1704
+ // when debug is off, so the event object is never allocated on the hot
1705
+ // path; the informational-only `age` header is read lazily inside.
1706
+ const debugRead = this.debug
1707
+ ? (
1708
+ outcome: CFCacheReadDebugEvent["outcome"],
1709
+ bodyReadMs: number,
1710
+ shouldRevalidate?: boolean,
1711
+ ) =>
1712
+ this.emitDebug({
1713
+ op: "getItem",
1714
+ key,
1715
+ outcome,
1716
+ status: response.status,
1717
+ cacheStatus: status,
1718
+ staleAt,
1719
+ revalidatingAt,
1720
+ ageHeader: response.headers.get("age"),
1721
+ isStale,
1722
+ isRevalidating,
1723
+ shouldRevalidate,
1724
+ matchMs,
1725
+ markerMs,
1726
+ bodyReadMs,
1727
+ })
1728
+ : undefined;
1729
+
1730
+ const bodyStart = Date.now();
1731
+ const {
1732
+ value: data,
1733
+ errored,
1734
+ error,
1735
+ } = await this.readJsonWithTimeout<{
601
1736
  value: string;
602
- handles?: Record<string, Record<string, unknown[]>>;
603
- };
1737
+ handles?: string;
1738
+ }>(response);
1739
+ const bodyReadMs = Date.now() - bodyStart;
1740
+ if (data === undefined) {
1741
+ debugRead?.(errored ? "body-error" : "body-timeout", bodyReadMs);
1742
+ // Heal + conditionally evict a body-error, suppress a body-timeout; see
1743
+ // get().
1744
+ if (errored)
1745
+ return this.healCorruptL1(cache, request, error, "getItem", () =>
1746
+ this.kvGetItem(key, { suppressRevalidate: false }),
1747
+ );
1748
+ return this.kvGetItem(key, { suppressRevalidate: true });
1749
+ }
604
1750
 
605
1751
  if (!isStale || isRevalidating) {
1752
+ debugRead?.(
1753
+ isRevalidating ? "l1-revalidating-guarded" : "l1-fresh",
1754
+ bodyReadMs,
1755
+ false,
1756
+ );
606
1757
  return {
607
1758
  value: data.value,
608
1759
  handles: data.handles,
609
1760
  shouldRevalidate: false,
1761
+ tags: tagInfo.tags,
610
1762
  };
611
1763
  }
612
1764
 
613
- // Stale and needs revalidation mark REVALIDATING atomically
614
- const headers = new Headers(response.headers);
615
- headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
616
- await cache.put(
1765
+ // Stale and needs revalidation -- mark REVALIDATING (non-blocking,
1766
+ // best-effort, remaining-ttl) and return the stale value. See get() /
1767
+ // markRevalidating for the full rationale.
1768
+ this.markRevalidating(
1769
+ cache,
617
1770
  request,
618
- new Response(JSON.stringify(data), { status: 200, headers }),
1771
+ response.headers,
1772
+ 200,
1773
+ JSON.stringify(data),
619
1774
  );
620
1775
 
1776
+ debugRead?.("l1-stale-revalidate", bodyReadMs, true);
621
1777
  return {
622
1778
  value: data.value,
623
1779
  handles: data.handles,
624
1780
  shouldRevalidate: true,
1781
+ tags: tagInfo.tags,
625
1782
  };
626
1783
  } catch (error) {
627
- console.error("[CFCacheStore] getItem failed:", error);
1784
+ reportCacheError(error, "cache-read", "[CFCacheStore] getItem");
1785
+ if (this.debug) this.emitDebug({ op: "getItem", key, outcome: "error" });
628
1786
  return null;
629
1787
  }
630
1788
  }
@@ -638,6 +1796,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
638
1796
  value: string,
639
1797
  options?: CacheItemOptions,
640
1798
  ): Promise<void> {
1799
+ if (this.skipUncacheableTagSet(options?.tags)) return;
641
1800
  try {
642
1801
  const cache = await this.getCache();
643
1802
  const request = this.keyToRequest(`fn:${key}`);
@@ -647,64 +1806,514 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
647
1806
  const totalTtl = ttl + swrWindow;
648
1807
  const staleAt = Date.now() + ttl * 1000;
649
1808
 
650
- const body = JSON.stringify({ value, handles: options?.handles });
1809
+ const tags = options?.tags;
1810
+ const taggedAt =
1811
+ Array.isArray(tags) && tags.length > 0 ? Date.now() : undefined;
1812
+
1813
+ // Serialize value/handles once; L1 body and KV envelope.v/h share the
1814
+ // escaped strings so a large RSC payload is not re-escaped for L2.
1815
+ const valueJson = JSON.stringify(value);
1816
+ const handlesJson =
1817
+ options?.handles !== undefined
1818
+ ? JSON.stringify(options.handles)
1819
+ : undefined;
1820
+ const body =
1821
+ handlesJson !== undefined
1822
+ ? `{"value":${valueJson},"handles":${handlesJson}}`
1823
+ : `{"value":${valueJson}}`;
651
1824
  const response = new Response(body, {
652
1825
  headers: {
653
1826
  "Content-Type": "application/json",
654
1827
  "Cache-Control": `public, max-age=${totalTtl}`,
655
1828
  [CACHE_STALE_AT_HEADER]: String(staleAt),
1829
+ // Absolute hard-expiry deadline; see set() / remainingCacheControl.
1830
+ [CACHE_EXPIRES_AT_HEADER]: String(staleAt + swrWindow * 1000),
656
1831
  [CACHE_STATUS_HEADER]: "HIT",
1832
+ ...this.tagHeaderEntries(tags, taggedAt),
657
1833
  },
658
1834
  });
659
1835
 
660
1836
  const putPromise = cache.put(request, response);
661
1837
 
662
1838
  if (this.waitUntil) {
663
- this.waitUntil(async () => {
664
- await putPromise;
665
- });
1839
+ this.waitUntil(() =>
1840
+ reportingAsync(
1841
+ () => putPromise,
1842
+ "cache-write",
1843
+ "[CFCacheStore] L1 write",
1844
+ ),
1845
+ );
666
1846
  } else {
667
1847
  await putPromise;
668
1848
  }
669
1849
 
670
- // L2: persist to KV (KV requires expirationTtl >= 60s)
1850
+ // L2: persist to KV (KV requires expirationTtl >= 60s). Wire shape matches
1851
+ // JSON.stringify(KVItemEnvelope); field names differ from L1 so we assemble
1852
+ // from the pre-escaped value/handles pieces rather than re-stringifying.
671
1853
  if (this.kv && this.waitUntil && totalTtl >= 60) {
672
- const kvKey = this.toKVKey(`fn:${key}`);
673
- this.waitUntil(async () => {
674
- try {
675
- const envelope: KVItemEnvelope = {
676
- v: value,
677
- h: options?.handles,
678
- s: staleAt,
679
- e: staleAt + swrWindow * 1000,
680
- };
681
- await this.kv!.put(kvKey, JSON.stringify(envelope), {
682
- expirationTtl: totalTtl,
683
- });
684
- } catch (error) {
685
- console.error("[CFCacheStore] KV setItem failed:", error);
686
- }
687
- });
1854
+ const kvKey = await this.toKVKey(`fn:${key}`);
1855
+ const expiresAt = staleAt + swrWindow * 1000;
1856
+ let envelopeJson = `{"v":${valueJson}`;
1857
+ if (handlesJson !== undefined) envelopeJson += `,"h":${handlesJson}`;
1858
+ envelopeJson += `,"s":${staleAt},"e":${expiresAt}`;
1859
+ if (tags !== undefined) envelopeJson += `,"t":${JSON.stringify(tags)}`;
1860
+ if (taggedAt !== undefined) envelopeJson += `,"ta":${taggedAt}`;
1861
+ envelopeJson += `}`;
1862
+ this.waitUntil(() =>
1863
+ reportingAsync(
1864
+ () =>
1865
+ this.kv!.put(kvKey, envelopeJson, {
1866
+ expirationTtl: totalTtl,
1867
+ }),
1868
+ "cache-write",
1869
+ "[CFCacheStore] kvSetItem",
1870
+ ),
1871
+ );
688
1872
  }
689
1873
  } catch (error) {
690
- console.error("[CFCacheStore] setItem failed:", error);
1874
+ reportCacheError(error, "cache-write", "[CFCacheStore] setItem");
691
1875
  }
692
1876
  }
693
1877
 
694
1878
  // ============================================================================
695
- // Key Helpers
1879
+ // Shell Cache Methods (PPR shell resume) — Cache API L1 + KV L2
696
1880
  // ============================================================================
1881
+ //
1882
+ // KV remains the durable, cross-colo shell tier. Cache API is a per-colo
1883
+ // read-through accelerator: writes populate both tiers, and a valid KV hit
1884
+ // promotes the same coupled envelope into L1. The family still requires KV;
1885
+ // without it, getShell/putShell no-op and PPR fails open to a full HTML render.
1886
+ //
1887
+ // Shell L1 hits deliberately keep the KV generation-marker check even in
1888
+ // purge mode. A shell's taggedAt is its CAPTURE START, not its write time: an
1889
+ // invalidation can purge while an older capture is still running, then that
1890
+ // capture can land after the purge. The marker check rejects that resurrection.
697
1891
 
698
1892
  /**
699
- * Convert string key to Request object for CF Cache API.
700
- * Includes version in URL if specified (for cache invalidation on code changes).
1893
+ * Warn once per isolate that the shell family is inert: getShell/putShell
1894
+ * are ONLY called for routes that declared the `ppr` path option, so firing
1895
+ * here (not in the constructor) scopes the warning to apps that actually
1896
+ * use PPR — a KV-less CFCacheStore is a perfectly fine config otherwise.
1897
+ * Without it, the correctness-first fail-open (issue #651) is invisible:
1898
+ * every ppr route is a permanent MISS with zero diagnostics.
1899
+ * @internal
1900
+ */
1901
+ private warnShellFamilyInertOnce(): void {
1902
+ this.warnOncePerNamespace(
1903
+ warnedShellFamilyInert,
1904
+ `[CFCacheStore] a ppr route resolved to this store, but no KV namespace ` +
1905
+ `is configured, so the shell family (getShell/putShell) is a no-op: ` +
1906
+ `every ppr route stays a permanent shell MISS (the page still serves ` +
1907
+ `via a full render). Bind a KV namespace and pass it — ` +
1908
+ `new CFCacheStore({ ctx, kv: env.CACHE_KV }) — or use a shell-capable ` +
1909
+ `store via createRouter({ cache }).`,
1910
+ );
1911
+ }
1912
+
1913
+ /**
1914
+ * Get a cached PPR shell entry from Cache API, falling through to KV and
1915
+ * promoting a valid KV hit. Both tiers store one envelope so the prelude,
1916
+ * postponed state, snapshot, versions, and generation metadata cannot mix.
1917
+ * SWR remains a plain staleness flag; the capture scheduler's module-level
1918
+ * in-flight set is the recapture stampede guard.
1919
+ */
1920
+ async getShell(
1921
+ key: string,
1922
+ ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
1923
+ if (!this.kv) {
1924
+ this.warnShellFamilyInertOnce();
1925
+ return null;
1926
+ }
1927
+ try {
1928
+ const cache = await this.getCache();
1929
+ const request = this.keyToRequest(`shell:${key}`);
1930
+ const matchStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
1931
+ const {
1932
+ response,
1933
+ timedOut,
1934
+ error: matchError,
1935
+ } = await this.matchWithTimeout(cache, request);
1936
+ const matchMs = INTERNAL_RANGO_DEBUG
1937
+ ? Date.now() - matchStartedAt
1938
+ : undefined;
1939
+
1940
+ if (!response) {
1941
+ if (matchError) {
1942
+ reportCacheError(
1943
+ matchError,
1944
+ "cache-read",
1945
+ "[CFCacheStore] getShell L1 match",
1946
+ );
1947
+ }
1948
+ this.debugShell(key, "l1-miss", {
1949
+ reason: matchError ? "error" : timedOut ? "timeout" : "absent",
1950
+ matchMs,
1951
+ });
1952
+ return this.kvGetShell(key);
1953
+ }
1954
+ if (response.status !== 200) {
1955
+ this.debugShell(key, "l1-miss", {
1956
+ reason: "non-200",
1957
+ status: response.status,
1958
+ matchMs,
1959
+ });
1960
+ return this.kvGetShell(key);
1961
+ }
1962
+
1963
+ const bodyStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
1964
+ const { value, errored, error } =
1965
+ await this.readJsonWithTimeout<unknown>(response);
1966
+ const bodyReadMs = INTERNAL_RANGO_DEBUG
1967
+ ? Date.now() - bodyStartedAt
1968
+ : undefined;
1969
+ if (value === undefined) {
1970
+ this.debugShell(key, "l1-miss", {
1971
+ reason: errored ? "corrupt" : "timeout",
1972
+ matchMs,
1973
+ bodyReadMs,
1974
+ });
1975
+ if (errored) {
1976
+ return this.healCorruptL1(cache, request, error, "getShell", () =>
1977
+ this.kvGetShell(key),
1978
+ );
1979
+ }
1980
+ return this.kvGetShell(key);
1981
+ }
1982
+ if (!isShellEnvelope(value)) {
1983
+ this.debugShell(key, "l1-miss", {
1984
+ reason: "malformed",
1985
+ matchMs,
1986
+ bodyReadMs,
1987
+ });
1988
+ return this.healCorruptL1(
1989
+ cache,
1990
+ request,
1991
+ new Error("malformed/partial L1 shell envelope"),
1992
+ "getShell",
1993
+ () => this.kvGetShell(key),
1994
+ );
1995
+ }
1996
+
1997
+ const now = Date.now();
1998
+ if (now > value.e) {
1999
+ this.debugShell(key, "l1-miss", {
2000
+ reason: "expired",
2001
+ matchMs,
2002
+ bodyReadMs,
2003
+ expiresAt: value.e,
2004
+ });
2005
+ return this.kvGetShell(key);
2006
+ }
2007
+
2008
+ // Unlike other L1 families, shells always check the durable generation
2009
+ // marker. See the capture-start/purge race documented above.
2010
+ const markerStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
2011
+ const invalidated = await this.isGloballyInvalidated(value.t, value.ta);
2012
+ const markerMs = INTERNAL_RANGO_DEBUG
2013
+ ? Date.now() - markerStartedAt
2014
+ : undefined;
2015
+ if (invalidated) {
2016
+ this.debugShell(key, "marker-invalidated", {
2017
+ tier: "l1",
2018
+ matchMs,
2019
+ bodyReadMs,
2020
+ markerMs,
2021
+ });
2022
+ return null;
2023
+ }
2024
+
2025
+ const shouldRevalidate = value.s > 0 && now > value.s;
2026
+ this.debugShell(key, "l1-hit", {
2027
+ freshness: shouldRevalidate ? "stale" : "fresh",
2028
+ matchMs,
2029
+ bodyReadMs,
2030
+ markerMs,
2031
+ expiresAt: value.e,
2032
+ });
2033
+ return {
2034
+ entry: this.shellEnvelopeToEntry(value),
2035
+ shouldRevalidate,
2036
+ };
2037
+ } catch (error) {
2038
+ reportCacheError(error, "cache-read", "[CFCacheStore] getShell");
2039
+ this.debugShell(key, "l1-miss", { reason: "error" });
2040
+ return this.kvGetShell(key);
2041
+ }
2042
+ }
2043
+
2044
+ /**
2045
+ * Store a PPR shell envelope in Cache API and, when its retention meets KV's
2046
+ * 60-second floor, KV. The shared write is registered with waitUntil and
2047
+ * awaited so invalidation rejection can be acknowledged to the capture
2048
+ * scheduler. Short-lived shells remain useful in L1 even though KV rejects
2049
+ * them.
2050
+ */
2051
+ async putShell(
2052
+ key: string,
2053
+ entry: ShellCacheEntry,
2054
+ ttlSeconds?: number,
2055
+ swrSeconds?: number,
2056
+ tags?: string[],
2057
+ ): Promise<"stored" | "invalidated" | void> {
2058
+ // KV remains required for durable generation markers and cross-colo reads.
2059
+ if (!this.kv) {
2060
+ this.warnShellFamilyInertOnce();
2061
+ return;
2062
+ }
2063
+ if (!this.waitUntil) return;
2064
+ try {
2065
+ const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
2066
+ const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
2067
+ const totalTtl = ttl + swrWindow;
2068
+
2069
+ const retentionTtl =
2070
+ tags && tags.length > 0 && this.tagInvalidationTtl
2071
+ ? Math.min(totalTtl, this.tagInvalidationTtl)
2072
+ : totalTtl;
2073
+ const now = Date.now();
2074
+ const staleAt = now + ttl * 1000;
2075
+ const expiresAt = now + retentionTtl * 1000;
2076
+ const taggedAt =
2077
+ Array.isArray(tags) && tags.length > 0 ? entry.createdAt : undefined;
2078
+
2079
+ const kvKey = await this.toKVKey(`shell:${key}`);
2080
+ const writeKv = retentionTtl >= KV_MIN_EXPIRATION_TTL;
2081
+
2082
+ const write = (async (): Promise<"stored" | "invalidated" | void> => {
2083
+ if (
2084
+ tags &&
2085
+ tags.length > 0 &&
2086
+ (await this.isGloballyInvalidated(tags, entry.createdAt))
2087
+ ) {
2088
+ this.debugShell(key, "write-invalidated");
2089
+ return "invalidated";
2090
+ }
2091
+
2092
+ const envelope: CFShellEnvelope = {
2093
+ // Presence-keyed: a navigationOnly entry has no document half, and an
2094
+ // omitted key (vs an explicit undefined) also keeps it out of the
2095
+ // serialized JSON.
2096
+ ...(entry.prelude !== undefined ? { p: entry.prelude } : {}),
2097
+ ...(entry.postponed !== undefined ? { po: entry.postponed } : {}),
2098
+ rv: entry.reactVersion,
2099
+ bv: entry.buildVersion,
2100
+ c: entry.createdAt,
2101
+ s: staleAt,
2102
+ e: expiresAt,
2103
+ t: tags,
2104
+ ta: taggedAt,
2105
+ i: entry.initialTheme,
2106
+ sn: entry.snapshot,
2107
+ dk: entry.docKey,
2108
+ lh: entry.handlerLiveHoles,
2109
+ tw: entry.transitionWhen,
2110
+ no: entry.navigationOnly,
2111
+ };
2112
+ const body = JSON.stringify(envelope);
2113
+ const writes: Promise<boolean>[] = [
2114
+ (async () => {
2115
+ try {
2116
+ const cache = await this.getCache();
2117
+ await cache.put(
2118
+ this.keyToRequest(`shell:${key}`),
2119
+ this.shellEnvelopeResponse(body, envelope),
2120
+ );
2121
+ this.debugShell(key, "l1-stored", {
2122
+ expiresAt: envelope.e,
2123
+ });
2124
+ return true;
2125
+ } catch (error) {
2126
+ reportCacheError(
2127
+ error,
2128
+ "cache-write",
2129
+ "[CFCacheStore] putShell L1",
2130
+ );
2131
+ return false;
2132
+ }
2133
+ })(),
2134
+ ];
2135
+ if (writeKv) {
2136
+ writes.push(
2137
+ (async () => {
2138
+ try {
2139
+ await this.kv!.put(kvKey, body, {
2140
+ expirationTtl: retentionTtl,
2141
+ });
2142
+ this.debugShell(key, "kv-stored", {
2143
+ expiresAt: envelope.e,
2144
+ });
2145
+ return true;
2146
+ } catch (error) {
2147
+ reportCacheError(
2148
+ error,
2149
+ "cache-write",
2150
+ "[CFCacheStore] putShell L2",
2151
+ );
2152
+ return false;
2153
+ }
2154
+ })(),
2155
+ );
2156
+ }
2157
+ return (await Promise.all(writes)).some(Boolean) ? "stored" : undefined;
2158
+ })();
2159
+ this.waitUntil(async () => {
2160
+ await write;
2161
+ });
2162
+ return await write;
2163
+ } catch (error) {
2164
+ reportCacheError(error, "cache-write", "[CFCacheStore] putShell");
2165
+ }
2166
+ }
2167
+
2168
+ /** Rebuild the public shell entry from its validated storage envelope. */
2169
+ private shellEnvelopeToEntry(envelope: CFShellEnvelope): ShellCacheEntry {
2170
+ return {
2171
+ prelude: envelope.p,
2172
+ postponed: envelope.po,
2173
+ reactVersion: envelope.rv,
2174
+ buildVersion: envelope.bv,
2175
+ initialTheme: envelope.i,
2176
+ snapshot: envelope.sn,
2177
+ docKey: envelope.dk,
2178
+ handlerLiveHoles: envelope.lh,
2179
+ transitionWhen: envelope.tw,
2180
+ navigationOnly: envelope.no,
2181
+ createdAt: envelope.c,
2182
+ };
2183
+ }
2184
+
2185
+ /** Build the Cache API representation of the coupled shell envelope. */
2186
+ private shellEnvelopeResponse(
2187
+ body: string,
2188
+ envelope: CFShellEnvelope,
2189
+ ): Response {
2190
+ const remainingTtl = Math.max(
2191
+ 1,
2192
+ Math.floor((envelope.e - Date.now()) / 1000),
2193
+ );
2194
+ return new Response(body, {
2195
+ headers: {
2196
+ "Content-Type": "application/json",
2197
+ "Cache-Control": `public, max-age=${remainingTtl}`,
2198
+ [CACHE_STALE_AT_HEADER]: String(envelope.s),
2199
+ [CACHE_EXPIRES_AT_HEADER]: String(envelope.e),
2200
+ [CACHE_STATUS_HEADER]: "HIT",
2201
+ ...this.tagHeaderEntries(envelope.t, envelope.ta),
2202
+ },
2203
+ });
2204
+ }
2205
+
2206
+ /** KV shell fallback with corruption checks and background L1 promotion. */
2207
+ private async kvGetShell(
2208
+ key: string,
2209
+ ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
2210
+ if (!this.kv) return null;
2211
+ try {
2212
+ const readStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
2213
+ const kvKey = await this.toKVKey(`shell:${key}`);
2214
+ const { value: envelope, timedOut } =
2215
+ await this.kvGetOrEvict<CFShellEnvelope>(
2216
+ kvKey,
2217
+ isShellEnvelope,
2218
+ "getShell",
2219
+ );
2220
+ const readMs = INTERNAL_RANGO_DEBUG
2221
+ ? Date.now() - readStartedAt
2222
+ : undefined;
2223
+ if (timedOut || !envelope) {
2224
+ this.debugShell(key, "kv-miss", {
2225
+ reason: timedOut ? "timeout" : "unavailable",
2226
+ readMs,
2227
+ });
2228
+ return null;
2229
+ }
2230
+
2231
+ const now = Date.now();
2232
+ if (now > envelope.e) {
2233
+ this.debugShell(key, "kv-miss", {
2234
+ reason: "expired",
2235
+ readMs,
2236
+ expiresAt: envelope.e,
2237
+ });
2238
+ return null;
2239
+ }
2240
+ const markerStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
2241
+ const invalidated = await this.isGloballyInvalidated(
2242
+ envelope.t,
2243
+ envelope.ta,
2244
+ );
2245
+ const markerMs = INTERNAL_RANGO_DEBUG
2246
+ ? Date.now() - markerStartedAt
2247
+ : undefined;
2248
+ if (invalidated) {
2249
+ this.debugShell(key, "marker-invalidated", {
2250
+ tier: "kv",
2251
+ readMs,
2252
+ markerMs,
2253
+ });
2254
+ return null;
2255
+ }
2256
+
2257
+ const shouldRevalidate = envelope.s > 0 && now > envelope.s;
2258
+ this.debugShell(key, "kv-hit", {
2259
+ freshness: shouldRevalidate ? "stale" : "fresh",
2260
+ readMs,
2261
+ markerMs,
2262
+ expiresAt: envelope.e,
2263
+ });
2264
+ this.promoteShellToL1(key, envelope);
2265
+ return {
2266
+ entry: this.shellEnvelopeToEntry(envelope),
2267
+ shouldRevalidate,
2268
+ };
2269
+ } catch (error) {
2270
+ reportCacheError(error, "cache-read", "[CFCacheStore] kvGetShell");
2271
+ this.debugShell(key, "kv-miss", { reason: "error" });
2272
+ return null;
2273
+ }
2274
+ }
2275
+
2276
+ /** Promote a valid KV shell into the per-colo Cache API tier. */
2277
+ private promoteShellToL1(key: string, envelope: CFShellEnvelope): void {
2278
+ if (!this.waitUntil) return;
2279
+ this.waitUntil(() =>
2280
+ reportingAsync(
2281
+ async () => {
2282
+ if (Date.now() > envelope.e) return;
2283
+ const cache = await this.getCache();
2284
+ const body = JSON.stringify(envelope);
2285
+ await cache.put(
2286
+ this.keyToRequest(`shell:${key}`),
2287
+ this.shellEnvelopeResponse(body, envelope),
2288
+ );
2289
+ this.debugShell(key, "kv-promoted", {
2290
+ remainingTtl: Math.max(
2291
+ 1,
2292
+ Math.floor((envelope.e - Date.now()) / 1000),
2293
+ ),
2294
+ expiresAt: envelope.e,
2295
+ });
2296
+ },
2297
+ "cache-write",
2298
+ "[CFCacheStore] promoteShellToL1",
2299
+ ),
2300
+ );
2301
+ }
2302
+
2303
+ // ============================================================================
2304
+ // Key Helpers
2305
+ // ============================================================================
2306
+
2307
+ /**
2308
+ * Convert string key to Request object for CF Cache API.
2309
+ * Includes version in URL if specified (for cache invalidation on code changes).
701
2310
  * @internal
702
2311
  */
703
2312
  private keyToRequest(key: string): Request {
704
2313
  const encodedKey = encodeURIComponent(key);
705
2314
  // Include version in URL path to invalidate cache when version changes
706
2315
  const versionPath = this.version ? `v/${this.version}/` : "";
707
- return new Request(`${this.baseUrl}${versionPath}${encodedKey}`, {
2316
+ return new Request(`${this.resolveBaseUrl()}${versionPath}${encodedKey}`, {
708
2317
  method: "GET",
709
2318
  });
710
2319
  }
@@ -712,11 +2321,866 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
712
2321
  /**
713
2322
  * Convert string key to KV key string.
714
2323
  * Uses same version prefix as Cache API for consistent invalidation.
2324
+ *
2325
+ * Single chokepoint for EVERY KV family (segments, items, shells, documents
2326
+ * via toDocKVKey, tag markers via tagMarkerKey): a composed key over
2327
+ * Cloudflare KV's 512-byte limit is normalized to a preserved readable
2328
+ * prefix plus a SHA-256-derived 128-bit digest of the FULL key. Without
2329
+ * this, kv.put/get reject with `414 ... exceeds key length limit of 512`
2330
+ * and the entry silently never reaches L2 (observed in production for
2331
+ * "use cache" items whose serialized args — e.g. a CMS query object — blow
2332
+ * the cap; autobarn pilot). Keys are opaque storage identifiers, so
2333
+ * normalization is semantics-preserving as long as distinct logical keys
2334
+ * stay distinct: colliding requires an identical 400-byte prefix AND a
2335
+ * 128-bit SHA-256 collision. Deterministic, so every family's read, write,
2336
+ * and delete paths agree on the stored key.
715
2337
  * @internal
716
2338
  */
717
- private toKVKey(key: string): string {
2339
+ private async toKVKey(key: string): Promise<string> {
718
2340
  const versionPath = this.version ? `v/${this.version}/` : "";
719
- return `${versionPath}${key}`;
2341
+ const composed = `${versionPath}${key}`;
2342
+ if (kvKeyByteLength(composed) <= KV_MAX_KEY_BYTES) return composed;
2343
+ const prefix = truncateToBytes(composed, KV_KEY_PRESERVED_PREFIX_BYTES);
2344
+ return `${prefix}~${await kvKeyDigest(composed)}`;
2345
+ }
2346
+
2347
+ /**
2348
+ * Host token for the current request, used to namespace the document KV key.
2349
+ * Derived from the same resolveBaseUrl() that namespaces the L1 (Cache API)
2350
+ * tier, so a doc entry's KV twin lands under the identical host bucket.
2351
+ * Falls back to "_" if the base URL cannot be parsed (it always carries a
2352
+ * trailing-slash origin, so parsing succeeds in practice).
2353
+ * @internal
2354
+ */
2355
+ private docKVHost(): string {
2356
+ try {
2357
+ return new URL(this.resolveBaseUrl()).host || "_";
2358
+ } catch {
2359
+ return "_";
2360
+ }
2361
+ }
2362
+
2363
+ /**
2364
+ * Convert a document key to its host-namespaced KV key. The L1 tier already
2365
+ * namespaces document entries by host via keyToRequest/resolveBaseUrl, but the
2366
+ * KV fallback keyed only on `doc:{key}`, so two hosts serving the same path
2367
+ * could collide on the KV tier (one host serving another's cached document).
2368
+ * Prefixing the host closes that cross-host collision. Deterministic per
2369
+ * (host, key). Segment/fn/tag-marker KV keys keep toKVKey unchanged: tag
2370
+ * markers are intentionally global (invalidation must cross hosts), and the
2371
+ * document tier is the one with a request-host context here.
2372
+ * @internal
2373
+ */
2374
+ private toDocKVKey(key: string): Promise<string> {
2375
+ return this.toKVKey(`h/${this.docKVHost()}/doc:${key}`);
2376
+ }
2377
+
2378
+ /**
2379
+ * Best-effort delete of a single KV key, reporting (not swallowing) a delete
2380
+ * failure as cache-delete. Used by the corrupt-entry self-heal paths.
2381
+ * @internal
2382
+ */
2383
+ private async evictKvKey(kvKey: string, label: string): Promise<void> {
2384
+ try {
2385
+ await this.kv!.delete(kvKey);
2386
+ } catch (error) {
2387
+ reportCacheError(
2388
+ error,
2389
+ "cache-delete",
2390
+ `[CFCacheStore] ${label}: evict failed`,
2391
+ );
2392
+ }
2393
+ }
2394
+
2395
+ /**
2396
+ * Schedule a corrupt-entry KV eviction as a NON-BLOCKING background task
2397
+ * (waitUntil) instead of awaiting it on the request path. The corrupt read has
2398
+ * already resolved to a miss; awaiting an unbounded kv.delete here would re-add
2399
+ * exactly the multi-second stall the read budgets exist to prevent when the KV
2400
+ * namespace is degraded. evictKvKey never rejects (it reports its own failure),
2401
+ * so the fire-and-forget fallback is safe when no waitUntil is available.
2402
+ * @internal
2403
+ */
2404
+ private scheduleKvEvict(kvKey: string, label: string): void {
2405
+ const evict = (): Promise<void> => this.evictKvKey(kvKey, label);
2406
+ if (this.waitUntil) this.waitUntil(evict);
2407
+ else void evict();
2408
+ }
2409
+
2410
+ /**
2411
+ * KV-get a JSON envelope, EVICTING the key only when it is genuinely corrupt.
2412
+ *
2413
+ * Reads as { type: "text" }, NOT { type: "json" }, on purpose: the "json" form
2414
+ * fuses the network read and the JSON parse, so a transient KV outage (5xx/429/
2415
+ * network blip) is indistinguishable from a malformed body and would delete a
2416
+ * still-good cross-colo entry - a self-inflicted miss storm. Reading text lets a
2417
+ * transient read error propagate to the caller's outer catch (reported
2418
+ * cache-read, the entry left intact); only a JSON.parse failure on a body that
2419
+ * WAS successfully read - or an envelope that parses but fails `validate`
2420
+ * (fields missing from a truncated write) - is true corruption that evicts +
2421
+ * reports cache-corrupt. A MISSING key (kv.get -> null) is a normal miss.
2422
+ * @internal
2423
+ */
2424
+ private async kvGetOrEvict<T>(
2425
+ kvKey: string,
2426
+ validate: (envelope: T) => boolean,
2427
+ label: string,
2428
+ ): Promise<{ value: T | null; timedOut: boolean }> {
2429
+ // Bound the read with the KV latency budget (inherited from #558) so a
2430
+ // degraded namespace cannot pin the request. readWithTimeout reports
2431
+ // timedOut on budget expiry; a transient read REJECTION (5xx/429/network)
2432
+ // instead propagates out to the caller's outer catch (reported cache-read,
2433
+ // the entry left intact) -- deliberately NOT caught as corruption.
2434
+ const { value: raw, timedOut } = await this.readWithTimeout<unknown>(
2435
+ () => this.kv!.get(kvKey, { type: "text" }),
2436
+ this.kvReadTimeoutMs,
2437
+ "KV read",
2438
+ );
2439
+ if (timedOut) return { value: null, timedOut: true };
2440
+ if (raw == null) return { value: null, timedOut: false }; // missing = miss
2441
+
2442
+ // Real CF KV with { type: "text" } returns a string: parse + structurally
2443
+ // validate it; a parse/validate failure on a successfully-read body is the
2444
+ // only true corruption (evict + cache-corrupt). A KV binding that already
2445
+ // returns a parsed object (some shims/tests) is used as-is.
2446
+ let envelope: T;
2447
+ if (typeof raw === "string") {
2448
+ try {
2449
+ envelope = JSON.parse(raw) as T;
2450
+ } catch (error) {
2451
+ reportCacheError(
2452
+ error,
2453
+ "cache-corrupt",
2454
+ `[CFCacheStore] ${label}: corrupt JSON in KV, evicting`,
2455
+ );
2456
+ this.scheduleKvEvict(kvKey, label);
2457
+ return { value: null, timedOut: false };
2458
+ }
2459
+ } else {
2460
+ envelope = raw as T;
2461
+ }
2462
+
2463
+ // A body that parses to null or a primitive ('null', '42', 'true', '"x"')
2464
+ // is not a valid envelope. Guard it BEFORE validate(): the property-reading
2465
+ // validators throw on a null/primitive rather than returning false, which
2466
+ // would escape to the caller's outer catch as a transient cache-read and
2467
+ // leave the bad key un-evicted (re-failing every read until its KV TTL). The
2468
+ // typeof check short-circuits validate() so it only ever runs on an object.
2469
+ if (
2470
+ envelope == null ||
2471
+ typeof envelope !== "object" ||
2472
+ !validate(envelope)
2473
+ ) {
2474
+ reportCacheError(
2475
+ new Error("malformed/partial KV envelope"),
2476
+ "cache-corrupt",
2477
+ `[CFCacheStore] ${label}: malformed envelope, evicting`,
2478
+ );
2479
+ this.scheduleKvEvict(kvKey, label);
2480
+ return { value: null, timedOut: false };
2481
+ }
2482
+ return { value: envelope, timedOut: false };
2483
+ }
2484
+
2485
+ // ============================================================================
2486
+ // Tag Invalidation (single-store: markers live in this.kv)
2487
+ // ============================================================================
2488
+
2489
+ /** KV key for a tag's invalidation marker. */
2490
+ private tagMarkerKey(tag: string): Promise<string> {
2491
+ return this.toKVKey(`${TAG_MARKER_PREFIX}${tag}`);
2492
+ }
2493
+
2494
+ /**
2495
+ * Header entries carrying an entry's tags (JSON-encoded, comma-safe) and the
2496
+ * timestamp they were attached. Returns an empty object when there are no
2497
+ * tags so untagged entries stay header-free and skip the invalidation check.
2498
+ *
2499
+ * Also stamps the namespaced `Cache-Tag` header (see entryCacheTags) so a
2500
+ * Cloudflare purge-by-tag can evict the entry — the mechanism purge mode
2501
+ * (tagPurge) relies on. Written unconditionally (not only in purge mode):
2502
+ * it costs a small header and makes existing entries purgeable the moment a
2503
+ * consumer turns purge mode on, with no re-render needed.
2504
+ */
2505
+ private tagHeaderEntries(
2506
+ tags: string[] | undefined,
2507
+ taggedAt: number | undefined,
2508
+ ): Record<string, string> {
2509
+ if (!Array.isArray(tags) || tags.length === 0 || !taggedAt) return {};
2510
+ const entries: Record<string, string> = {
2511
+ // encodeURIComponent so the value is pure ASCII: HTTP header values are
2512
+ // ByteStrings, but JSON.stringify leaves codepoints > U+00FF (emoji/CJK)
2513
+ // verbatim, which makes new Response({ headers }) throw and the outer
2514
+ // try/catch silently drop the whole entry from cache. Decoded in
2515
+ // readTagInfo. The L1 marker Cache-Tag path encodes for the same reason.
2516
+ [CACHE_TAGS_HEADER]: encodeURIComponent(JSON.stringify(tags)),
2517
+ [CACHE_TAGGED_AT_HEADER]: String(taggedAt),
2518
+ };
2519
+ // Over Cloudflare's aggregate Cache-Tag limit the header is OMITTED — the
2520
+ // entry still caches and stays marker-invalidatable; it just cannot be
2521
+ // purge-evicted per tag (isL1Invalidated falls back to the marker check
2522
+ // for such entries, so purge mode stays correct). Emitting a header over
2523
+ // the limit would instead risk failing the whole L1 write. The KV-LESS
2524
+ // purge-mode combination never reaches here: there the marker fallback
2525
+ // has no KV to consult, so skipUncacheableTagSet rejects the write first.
2526
+ const cacheTag = this.entryCacheTagHeader(tags);
2527
+ if (cacheTag !== null) {
2528
+ entries["Cache-Tag"] = cacheTag;
2529
+ } else {
2530
+ this.warnOncePerNamespace(
2531
+ warnedCacheTagHeaderOverflow,
2532
+ `[CFCacheStore] an entry's ${tags.length} tags produce a Cache-Tag ` +
2533
+ `header over Cloudflare's ${CACHE_TAG_HEADER_MAX_BYTES}-byte ` +
2534
+ `limit; the header was omitted. The entry stays cacheable and ` +
2535
+ `marker-invalidatable, but a purge-by-tag cannot evict it (purge ` +
2536
+ `mode falls back to the marker check for it). Reduce the number ` +
2537
+ `of tags per entry.`,
2538
+ );
2539
+ }
2540
+ return entries;
2541
+ }
2542
+
2543
+ /**
2544
+ * Joined entry Cache-Tag header value for `tags`, or null when it would
2545
+ * exceed Cloudflare's aggregate header limit. Tokens are pure ASCII
2546
+ * (encodeURIComponent output), so .length is bytes.
2547
+ * @internal
2548
+ */
2549
+ private entryCacheTagHeader(tags: string[]): string | null {
2550
+ const joined = this.entryCacheTags(tags).join(",");
2551
+ return joined.length <= CACHE_TAG_HEADER_MAX_BYTES ? joined : null;
2552
+ }
2553
+
2554
+ /**
2555
+ * Write-path gate for the one configuration where an over-limit tag set has
2556
+ * NO invalidation path: purge mode WITHOUT KV. There the entry Cache-Tag
2557
+ * tokens are the only eviction mechanism, and a tag set whose header
2558
+ * overflows CACHE_TAG_HEADER_MAX_BYTES gets no tokens — a purge could never
2559
+ * evict the entry and there is no KV marker fallback, so it would serve
2560
+ * stale until TTL while updateTag() reports success. Returns true (and
2561
+ * warns once) so the caller SKIPS caching: the route simply renders fresh,
2562
+ * which is the fail-safe direction. With KV configured the omitted-header
2563
+ * entry falls back to the marker check (see tagHeaderEntries), and KV-less
2564
+ * MARKER mode keeps its documented no-read-side-invalidation semantics —
2565
+ * neither is gated.
2566
+ * @internal
2567
+ */
2568
+ private skipUncacheableTagSet(tags: string[] | undefined): boolean {
2569
+ if (!this.tagPurge || this.kv) return false;
2570
+ if (!Array.isArray(tags) || tags.length === 0) return false;
2571
+ if (this.entryCacheTagHeader(tags) !== null) return false;
2572
+ this.warnOncePerNamespace(
2573
+ warnedCacheTagOverflowUncacheable,
2574
+ `[CFCacheStore] an entry's ${tags.length} tags produce a Cache-Tag ` +
2575
+ `header over Cloudflare's ${CACHE_TAG_HEADER_MAX_BYTES}-byte limit. ` +
2576
+ `In purge mode without KV those tokens are the only invalidation ` +
2577
+ `path, so the entry was NOT cached (it renders fresh instead of ` +
2578
+ `becoming un-invalidatable). Reduce the number of tags per entry, ` +
2579
+ `or configure { kv } to get the marker fallback.`,
2580
+ );
2581
+ return true;
2582
+ }
2583
+
2584
+ /**
2585
+ * Merge the internal tag headers onto an existing Headers instance. The
2586
+ * from-scratch paths spread tagHeaderEntries() into an object-literal init;
2587
+ * the document put/promote paths build a Headers first, so they .set() each
2588
+ * entry instead — except `Cache-Tag`, which is APPENDED: a document author
2589
+ * may have set their own Cache-Tag, and clobbering it would break their CDN
2590
+ * purging. Append produces the comma-merged list CF expects.
2591
+ */
2592
+ private setTagHeaders(
2593
+ headers: Headers,
2594
+ tags: string[] | undefined,
2595
+ taggedAt: number | undefined,
2596
+ ): void {
2597
+ for (const [name, value] of Object.entries(
2598
+ this.tagHeaderEntries(tags, taggedAt),
2599
+ )) {
2600
+ if (name === "Cache-Tag") headers.append(name, value);
2601
+ else headers.set(name, value);
2602
+ }
2603
+ }
2604
+
2605
+ /** Read an entry's tags/taggedAt back from its headers. */
2606
+ private readTagInfo(headers: Headers): {
2607
+ tags?: string[];
2608
+ taggedAt?: number;
2609
+ } {
2610
+ const rawTags = headers.get(CACHE_TAGS_HEADER);
2611
+ const rawTaggedAt = headers.get(CACHE_TAGGED_AT_HEADER);
2612
+ if (!rawTags || !rawTaggedAt) return {};
2613
+ try {
2614
+ const taggedAt = Number(rawTaggedAt);
2615
+ // A corrupt/non-numeric tagged-at header yields NaN. isGloballyInvalidated
2616
+ // short-circuits on a falsy taggedAt (NaN is falsy), so returning
2617
+ // { taggedAt: NaN } would make the entry permanently NON-invalidatable -
2618
+ // a revalidateTag could never evict it. Treat a non-finite stamp the same
2619
+ // as the missing-header case (untagged): drop both tags and taggedAt so the
2620
+ // entry is re-rendered/re-tagged rather than silently un-invalidatable.
2621
+ if (!Number.isFinite(taggedAt)) return {};
2622
+ return {
2623
+ tags: JSON.parse(decodeURIComponent(rawTags)) as string[],
2624
+ taggedAt,
2625
+ };
2626
+ } catch {
2627
+ return {};
2628
+ }
2629
+ }
2630
+
2631
+ /**
2632
+ * Whether an entry tagged at `taggedAt` with `tags` has been invalidated since.
2633
+ * Reads the per-tag invalidation markers from KV and returns true if any tag's
2634
+ * latest invalidation is at or after taggedAt (>= so a same-millisecond
2635
+ * invalidate wins, favouring freshness over staleness). Fails open: KV errors
2636
+ * never turn a hit into a wrongful miss-storm beyond this single read.
2637
+ */
2638
+ private async isGloballyInvalidated(
2639
+ tags: string[] | undefined,
2640
+ taggedAt: number | undefined,
2641
+ ): Promise<boolean> {
2642
+ // Array.isArray (not just truthiness): a non-array tags value - direct store
2643
+ // misuse like setItem(k, v, { tags: "products" }), or a skewed KV envelope -
2644
+ // must fail safe to "not invalidated" rather than throwing `.map` on every
2645
+ // read (which the outer catch would mis-report as a transient cache-read).
2646
+ if (!this.kv || !Array.isArray(tags) || tags.length === 0 || !taggedAt)
2647
+ return false;
2648
+ const ctx = _getRequestContext();
2649
+ const memo = ctx ? getTagMarkerMemo(ctx, this) : undefined;
2650
+ const inflight = ctx ? getTagMarkerInflight(ctx, this) : undefined;
2651
+ try {
2652
+ const markers = await Promise.all(
2653
+ tags.map((tag) => this.readTagMarker(tag, memo, inflight)),
2654
+ );
2655
+ for (const marker of markers) {
2656
+ if (marker != null && marker >= taggedAt) return true;
2657
+ }
2658
+ return false;
2659
+ } catch (error) {
2660
+ reportCacheError(
2661
+ error,
2662
+ "cache-read",
2663
+ "[CFCacheStore] tag invalidation check",
2664
+ );
2665
+ return false;
2666
+ }
2667
+ }
2668
+
2669
+ /**
2670
+ * Tag-invalidation check for an L1 (Cache API) hit. In purge mode (tagPurge
2671
+ * configured), invalidateTags() evicts L1 entries via a Cloudflare
2672
+ * purge-by-tag call, so a hit that SURVIVED is trusted without a per-read
2673
+ * marker lookup — that skipped lookup is the entire point of purge mode.
2674
+ * Only the per-request memo is consulted (synchronous, no KV read) so a
2675
+ * request that ran updateTag() still masks its own entries during the purge
2676
+ * propagation window (read-your-own-writes).
2677
+ *
2678
+ * The trust is conditional on the entry actually CARRYING this store's
2679
+ * entry Cache-Tag tokens (`headers`): an entry a purge cannot reach — one
2680
+ * written before the tokens existed, or whose tag set overflowed the
2681
+ * Cache-Tag header limit — keeps the full marker check, or purge mode
2682
+ * would serve it stale until TTL with no eviction path.
2683
+ *
2684
+ * Without tagPurge this is the full marker cascade. KV-tier reads and
2685
+ * shells always use isGloballyInvalidated directly: purge cannot reach KV,
2686
+ * so the markers stay their invalidation mechanism.
2687
+ * @internal
2688
+ */
2689
+ private async isL1Invalidated(
2690
+ tags: string[] | undefined,
2691
+ taggedAt: number | undefined,
2692
+ headers: Headers,
2693
+ ): Promise<boolean> {
2694
+ if (!this.tagPurge) return this.isGloballyInvalidated(tags, taggedAt);
2695
+ if (!Array.isArray(tags) || tags.length === 0 || !taggedAt) return false;
2696
+ if (!this.hasEntryCacheTags(headers)) {
2697
+ return this.isGloballyInvalidated(tags, taggedAt);
2698
+ }
2699
+ const ctx = _getRequestContext();
2700
+ if (!ctx) return false;
2701
+ const memo = getTagMarkerMemo(ctx, this);
2702
+ for (const tag of tags) {
2703
+ const marker = memo.get(tag);
2704
+ if (marker != null && marker >= taggedAt) return true;
2705
+ }
2706
+ return false;
2707
+ }
2708
+
2709
+ /** Synthetic Cache API request for a tag's L1-cached invalidation marker. */
2710
+ private tagMarkerRequest(tag: string): Request {
2711
+ return this.keyToRequest(`${TAG_MARKER_CACHE_PREFIX}${tag}`);
2712
+ }
2713
+
2714
+ /**
2715
+ * Read a tag's latest invalidation timestamp (or null if never invalidated)
2716
+ * through the cascade: per-request memo -> per-colo L1 cache (only when
2717
+ * tagCacheTtl > 0) -> KV (the global truth). The memo is always consulted
2718
+ * first so it stays authoritative within a request (read-your-own-writes),
2719
+ * and every KV/L1 result is written back into the memo. A Cache API miss
2720
+ * always falls through to KV; absence is represented by a cached sentinel,
2721
+ * never by a miss.
2722
+ *
2723
+ * Concurrent reads of the same tag within a request share one in-flight read
2724
+ * (the resolved-value memo only collapses sequential reads; parallel segment
2725
+ * loading would otherwise issue one KV read per concurrent reader).
2726
+ * @internal
2727
+ */
2728
+ private async readTagMarker(
2729
+ tag: string,
2730
+ memo: Map<string, number | null> | undefined,
2731
+ inflight: Map<string, Promise<number | null>> | undefined,
2732
+ ): Promise<number | null> {
2733
+ if (memo && memo.has(tag)) return memo.get(tag) ?? null;
2734
+
2735
+ // Collapse concurrent (not-yet-resolved) reads of this tag onto one promise.
2736
+ if (inflight) {
2737
+ const pending = inflight.get(tag);
2738
+ if (pending) return pending;
2739
+ const read = this.fetchTagMarker(tag, memo);
2740
+ inflight.set(tag, read);
2741
+ try {
2742
+ return await read;
2743
+ } finally {
2744
+ // Resolved values now live in the memo; drop the in-flight entry.
2745
+ inflight.delete(tag);
2746
+ }
2747
+ }
2748
+
2749
+ return this.fetchTagMarker(tag, memo);
2750
+ }
2751
+
2752
+ /**
2753
+ * Uncached body of readTagMarker: L1 (per-colo Cache API, opt-in via
2754
+ * tagCacheTtl) -> KV. Writes the resolved value back into the memo.
2755
+ * @internal
2756
+ */
2757
+ private async fetchTagMarker(
2758
+ tag: string,
2759
+ memo: Map<string, number | null> | undefined,
2760
+ ): Promise<number | null> {
2761
+ // Write the resolved marker into the memo WITHOUT clobbering a value a
2762
+ // concurrent invalidateTags() wrote during our await. The router resolves
2763
+ // sibling slots in parallel, so a slot's updateTag() can land the
2764
+ // authoritative invalidatedAt into the memo while this read is still in
2765
+ // flight; overwriting it with our (pre-invalidation) read result would break
2766
+ // read-your-own-writes for the rest of the request. If the tag was memoized
2767
+ // mid-read, that value wins and is returned. Without a memo, the read result
2768
+ // stands as-is.
2769
+ const memoize = (read: number | null): number | null => {
2770
+ if (memo && memo.has(tag)) return memo.get(tag) ?? null;
2771
+ memo?.set(tag, read);
2772
+ return read;
2773
+ };
2774
+
2775
+ // L1 (per-colo) marker cache - opt-in via tagCacheTtl. Bounded by the same
2776
+ // edge budgets as data reads (inherited from #558) so a degraded colo cannot
2777
+ // stall a tagged read; a miss, timeout, or error all fall through to KV.
2778
+ if (this.tagCacheTtl > 0) {
2779
+ try {
2780
+ const cache = await this.getCache();
2781
+ const { response: hit, error: matchError } =
2782
+ await this.matchWithTimeout(cache, this.tagMarkerRequest(tag));
2783
+ // A transient match REJECTION is captured (not thrown) by
2784
+ // matchWithTimeout; surface it as cache-read like the data read paths
2785
+ // before falling through to KV, rather than silently dropping it.
2786
+ if (matchError)
2787
+ reportCacheError(
2788
+ matchError,
2789
+ "cache-read",
2790
+ "[CFCacheStore] tag marker L1 match",
2791
+ );
2792
+ if (hit) {
2793
+ const { value: body } = await this.readWithTimeout(
2794
+ () => hit.text(),
2795
+ this.edgeReadTimeoutMs,
2796
+ "tag marker L1 body read",
2797
+ );
2798
+ if (body !== undefined) {
2799
+ const value = body === TAG_MARKER_ABSENT ? null : Number(body);
2800
+ return memoize(value);
2801
+ }
2802
+ }
2803
+ } catch {
2804
+ // Fall through to KV on any L1 read error.
2805
+ }
2806
+ }
2807
+
2808
+ // KV (global truth), bounded by the KV budget. On TIMEOUT fail OPEN: treat
2809
+ // the marker as absent (-> entry not invalidated -> served) so a degraded
2810
+ // namespace cannot pin every tagged read behind a slow global lookup. A
2811
+ // transient REJECTION instead propagates to isGloballyInvalidated's catch
2812
+ // (reported cache-read), which also fails open. Either way one slow tag
2813
+ // never amplifies into a per-segment stall.
2814
+ const { value: raw, timedOut } = await this.readWithTimeout<string | null>(
2815
+ async () => this.kv!.get(await this.tagMarkerKey(tag), { type: "text" }),
2816
+ this.kvReadTimeoutMs,
2817
+ "tag marker KV read",
2818
+ );
2819
+ if (timedOut) {
2820
+ // Memoize the fail-open result so the rest of this request is consistent
2821
+ // (and does not re-pay the timeout per segment sharing the tag).
2822
+ return memoize(null);
2823
+ }
2824
+ const value = raw != null ? Number(raw) : null;
2825
+ const resolved = memoize(value);
2826
+
2827
+ // Populate L1 for subsequent reads in this colo (non-blocking). Use the
2828
+ // resolved (memo-aware) value so a marker invalidated mid-read is not
2829
+ // re-cached stale into this colo's L1.
2830
+ if (this.tagCacheTtl > 0) {
2831
+ const put = () => this.putTagMarkerL1(tag, resolved);
2832
+ if (this.waitUntil) this.waitUntil(put);
2833
+ else void put();
2834
+ }
2835
+ return resolved;
2836
+ }
2837
+
2838
+ /**
2839
+ * Namespace token used inside every `rg:*` Cache-Tag. encodeURIComponent'd
2840
+ * like the tag values: a raw namespace containing a comma would split into
2841
+ * bogus tokens inside the comma-delimited Cache-Tag header — breaking both
2842
+ * stripInternalCacheTags (internal tokens would leak to clients) and the
2843
+ * purge match (the purged tag string never equals any stored token).
2844
+ * @internal
2845
+ */
2846
+ private nsToken(): string {
2847
+ return encodeURIComponent(this.namespace ?? "default");
2848
+ }
2849
+
2850
+ /**
2851
+ * Cloudflare Cache-Tags written on a tag's L1 marker entry, namespaced per
2852
+ * store so purges never collide with other Cache-Tags in the zone. Three
2853
+ * tiers, broad to specific:
2854
+ * rg:{ns} - everything this store cached (deploy/nuclear reset)
2855
+ * rg:{ns}:lk - all tag-lookup markers
2856
+ * rg:{ns}:lk:{tag} - this tag's lookup (the normal updateTag purge target)
2857
+ * The namespace and tag value are encodeURIComponent'd so commas/spaces
2858
+ * can't corrupt the comma-delimited Cache-Tag header.
2859
+ * @internal
2860
+ */
2861
+ private lookupCacheTags(tag: string): string[] {
2862
+ const ns = this.nsToken();
2863
+ return [`rg:${ns}`, `rg:${ns}:lk`, this.lookupPurgeTag(tag)];
2864
+ }
2865
+
2866
+ /**
2867
+ * Build one `{prefix}{tag}` Cache-Tag token, bounded to CACHE_TAG_TOKEN_MAX:
2868
+ * an over-long encoded tag collapses to `{prefix}h:{fnv1a64(tag)}` so a
2869
+ * legally-long application tag can never blow Cloudflare's per-tag purge
2870
+ * limit (1,024 chars) or bloat the header. Deterministic, so the write-time
2871
+ * token and the invalidate-time purge token always agree.
2872
+ * @internal
2873
+ */
2874
+ private boundedTagToken(prefix: string, tag: string): string {
2875
+ const token = `${prefix}${encodeURIComponent(tag)}`;
2876
+ if (token.length <= CACHE_TAG_TOKEN_MAX) return token;
2877
+ return `${prefix}h:${fnv1a64(tag)}`;
2878
+ }
2879
+
2880
+ /** The specific Cache-Tag a consumer purges to evict tag `tag`'s lookup. */
2881
+ private lookupPurgeTag(tag: string): string {
2882
+ return this.boundedTagToken(`rg:${this.nsToken()}:lk:`, tag);
2883
+ }
2884
+
2885
+ /**
2886
+ * Cloudflare Cache-Tags written on a tagged DATA entry (segment/item/doc),
2887
+ * mirroring the lookup-marker tiers but under `:e` (entry):
2888
+ * rg:{ns} - everything this store cached (deploy/nuclear reset)
2889
+ * rg:{ns}:e - all data entries
2890
+ * rg:{ns}:e:{t} - entries carrying tag `t` (the purge-mode invalidation
2891
+ * target; see CFCacheStoreOptions.tagPurge)
2892
+ * Namespace and tag value encodeURIComponent'd like the lookup tier.
2893
+ * @internal
2894
+ */
2895
+ private entryCacheTags(tags: string[]): string[] {
2896
+ const ns = this.nsToken();
2897
+ return [
2898
+ `rg:${ns}`,
2899
+ `rg:${ns}:e`,
2900
+ ...tags.map((tag) => this.entryPurgeTag(tag)),
2901
+ ];
2902
+ }
2903
+
2904
+ /** The specific Cache-Tag purged to evict entries carrying tag `tag`. */
2905
+ private entryPurgeTag(tag: string): string {
2906
+ return this.boundedTagToken(`rg:${this.nsToken()}:e:`, tag);
2907
+ }
2908
+
2909
+ /**
2910
+ * Whether an L1 entry's stored headers carry this store's entry Cache-Tag
2911
+ * tokens — i.e. whether a purge-by-tag can actually evict it. False for an
2912
+ * entry written before this feature existed, or one whose tag set exceeded
2913
+ * CACHE_TAG_HEADER_MAX_BYTES (header omitted). Purge mode only trusts
2914
+ * entries a purge can reach; the rest keep the marker check.
2915
+ * @internal
2916
+ */
2917
+ private hasEntryCacheTags(headers: Headers): boolean {
2918
+ const raw = headers.get("Cache-Tag");
2919
+ if (raw === null) return false;
2920
+ const tier = `rg:${this.nsToken()}:e`;
2921
+ return raw.split(",").some((token) => {
2922
+ const trimmed = token.trim();
2923
+ return trimmed === tier || trimmed.startsWith(`${tier}:`);
2924
+ });
2925
+ }
2926
+
2927
+ /**
2928
+ * Drop this store's namespaced tokens (`rg:{ns}` and `rg:{ns}:*`) from a
2929
+ * Cache-Tag header, keeping author-set tokens intact. Deletes the header
2930
+ * when nothing remains. Serve-path counterpart of setTagHeaders' append.
2931
+ * An author token that exactly equals a reserved `rg:{ns}` tier is stripped
2932
+ * too — `rg:` is this store's documented-reserved Cache-Tag prefix.
2933
+ * @internal
2934
+ */
2935
+ private stripInternalCacheTags(headers: Headers): void {
2936
+ const raw = headers.get("Cache-Tag");
2937
+ if (raw === null) return;
2938
+ const ns = this.nsToken();
2939
+ const kept = raw
2940
+ .split(",")
2941
+ .map((token) => token.trim())
2942
+ .filter(
2943
+ (token) =>
2944
+ token.length > 0 &&
2945
+ token !== `rg:${ns}` &&
2946
+ !token.startsWith(`rg:${ns}:`),
2947
+ );
2948
+ if (kept.length > 0) headers.set("Cache-Tag", kept.join(","));
2949
+ else headers.delete("Cache-Tag");
2950
+ }
2951
+
2952
+ /**
2953
+ * Write a tag marker value into the per-colo L1 Cache API with tagCacheTtl.
2954
+ * `null` is stored as the TAG_MARKER_ABSENT sentinel so "no marker yet" is
2955
+ * cacheable (most tags are never invalidated - that is where the read savings
2956
+ * come from). The entry also carries a namespaced Cache-Tag so an external
2957
+ * purge-by-tag (via onRevalidateTag) can evict it across colos promptly,
2958
+ * rather than waiting out tagCacheTtl. Best-effort.
2959
+ * @internal
2960
+ */
2961
+ private async putTagMarkerL1(
2962
+ tag: string,
2963
+ value: number | null,
2964
+ opts?: { critical?: boolean },
2965
+ ): Promise<void> {
2966
+ if (this.tagCacheTtl <= 0) return;
2967
+ try {
2968
+ const cache = await this.getCache();
2969
+ const body = value != null ? String(value) : TAG_MARKER_ABSENT;
2970
+ await cache.put(
2971
+ this.tagMarkerRequest(tag),
2972
+ new Response(body, {
2973
+ headers: {
2974
+ "Cache-Control": `public, max-age=${this.tagCacheTtl}`,
2975
+ "Cache-Tag": this.lookupCacheTags(tag).join(","),
2976
+ },
2977
+ }),
2978
+ );
2979
+ } catch (error) {
2980
+ // The read-path populate is best-effort: a failed populate just means the
2981
+ // next read consults KV. The invalidation WRITE-THROUGH (critical) is not
2982
+ // - silently swallowing it would leave this colo's stale marker (often the
2983
+ // ABSENT sentinel) authoritative for tagCacheTtl while updateTag reports
2984
+ // success. Surface it, and best-effort delete the L1 marker so the next
2985
+ // read re-reads KV, which already holds the fresh marker (written before
2986
+ // this write-through in invalidateTags).
2987
+ if (opts?.critical) {
2988
+ reportCacheError(
2989
+ error,
2990
+ "cache-invalidate",
2991
+ "[CFCacheStore] tag marker L1 write-through",
2992
+ );
2993
+ await reportingAsync(
2994
+ async () => {
2995
+ const cache = await this.getCache();
2996
+ await cache.delete(this.tagMarkerRequest(tag));
2997
+ },
2998
+ "cache-delete",
2999
+ "[CFCacheStore] tag marker L1 evict after failed write-through",
3000
+ );
3001
+ }
3002
+ }
3003
+ }
3004
+
3005
+ /**
3006
+ * Invalidate every entry tagged with any of `tags`. Receives the whole batch
3007
+ * from one updateTag()/revalidateTag() call so the eager-purge hook fires
3008
+ * ONCE (one CDN purge request, not one per tag). For each tag: records the KV
3009
+ * marker (the durable cross-colo truth that reads compare taggedAt against),
3010
+ * writes the fresh marker straight into this colo's L1 (write-through, NOT
3011
+ * delete - a delete would let the next read re-read a not-yet-converged KV
3012
+ * value and re-arm the stale window), and memoizes it for same-request
3013
+ * read-your-own-writes. In purge mode (tagPurge) it then AWAITS the
3014
+ * consumer's purge-by-tag call with the entry Cache-Tags — the eviction the
3015
+ * per-read marker skip on L1 hits relies on. Finally fires onRevalidateTag
3016
+ * with the namespaced lookup Cache-Tags so a consumer purge evicts the
3017
+ * cached lookups in other colos promptly (otherwise they converge within
3018
+ * tagCacheTtl).
3019
+ *
3020
+ * Durable-write integrity: the in-memory write-through (memo + L1) for a tag
3021
+ * runs ONLY after that tag's KV marker write is confirmed. If any KV write
3022
+ * fails (transient error; over-limit keys are normalized by toKVKey rather
3023
+ * than rejected), this rejects with the
3024
+ * failed tags so an awaiting updateTag() surfaces the failure instead of
3025
+ * silently reporting success while other requests/colos serve stale data. The
3026
+ * eager purge still fires for the whole batch first (it is additive).
3027
+ */
3028
+ /**
3029
+ * Shell tag-generation gate (SegmentCacheStore.isTagsInvalidatedSince): the
3030
+ * SAME KV markers used by runtime envelopes also evict immutable build shells
3031
+ * and captures whose write races updateTag(). Thin public wrapper over the
3032
+ * private envelope check (marker >= since, fail open).
3033
+ */
3034
+ async isTagsInvalidatedSince(
3035
+ tags: string[],
3036
+ sinceMs: number,
3037
+ ): Promise<boolean> {
3038
+ return this.isGloballyInvalidated(tags, sinceMs);
3039
+ }
3040
+
3041
+ async invalidateTags(tags: string[]): Promise<void> {
3042
+ if (tags.length === 0) return;
3043
+ const invalidatedAt = Date.now();
3044
+ const ctx = _getRequestContext();
3045
+ const memo = ctx ? getTagMarkerMemo(ctx, this) : undefined;
3046
+
3047
+ if (!this.kv && !this.onRevalidateTag && !this.tagPurge) {
3048
+ console.warn(
3049
+ `[CFCacheStore] invalidateTags had no effect: configure a KV namespace ` +
3050
+ `for distributed invalidation, a tagPurge hook for purge-by-tag ` +
3051
+ `eviction, or an onRevalidateTag hook.`,
3052
+ );
3053
+ }
3054
+
3055
+ const failedTags = new Set<string>();
3056
+ const errors: unknown[] = [];
3057
+ if (this.kv) {
3058
+ // Markers written with no expiry (tagInvalidationTtl unset) never expire,
3059
+ // so high-cardinality tags accumulate KV keys unboundedly with no reaper.
3060
+ // Warn once per namespace at the batch entry point (not per marker write,
3061
+ // which would fire once per tag). Kept separate from the floor warning:
3062
+ // that path only fires for a positive below-floor value, never the unset
3063
+ // default sanitizeTagInvalidationTtl passes through as undefined.
3064
+ if (!this.tagInvalidationTtl) {
3065
+ this.warnOncePerNamespace(
3066
+ warnedNoTagInvalidationTtl,
3067
+ `[CFCacheStore] invalidateTags is writing KV markers with no expiry ` +
3068
+ `(tagInvalidationTtl is unset): high-cardinality tags accumulate KV ` +
3069
+ `keys unboundedly (storage + list-scan cost) with no reaper. Set ` +
3070
+ `tagInvalidationTtl above your largest entry TTL+SWR to bound marker ` +
3071
+ `growth; setting it too small resurrects invalidated entries.`,
3072
+ );
3073
+ }
3074
+ await Promise.all(
3075
+ tags.map(async (tag) => {
3076
+ // An over-limit tag no longer rejects: tagMarkerKey normalizes
3077
+ // through toKVKey, and the marker read path derives the key the same
3078
+ // way, so oversized tags invalidate correctly instead of erroring.
3079
+ const markerKey = await this.tagMarkerKey(tag);
3080
+ try {
3081
+ await this.kv!.put(markerKey, String(invalidatedAt), {
3082
+ ...(this.tagInvalidationTtl
3083
+ ? { expirationTtl: this.tagInvalidationTtl }
3084
+ : {}),
3085
+ });
3086
+ } catch (error) {
3087
+ failedTags.add(tag);
3088
+ errors.push(error);
3089
+ }
3090
+ }),
3091
+ );
3092
+ }
3093
+
3094
+ // Write-through memo + L1 only for tags with a confirmed durable marker, and
3095
+ // only when KV is configured. Markers are read exclusively through
3096
+ // isGloballyInvalidated(), which short-circuits to "not invalidated" when
3097
+ // !this.kv; writing memo/L1 markers without KV would be dead state no read
3098
+ // path ever consults — EXCEPT the memo in purge mode: isL1Invalidated()
3099
+ // consults it (and only it) on every L1 hit, so a KV-less purge-mode store
3100
+ // still writes the memo for same-request read-your-own-writes. The
3101
+ // onRevalidateTag purge below still fires regardless (it is additive and
3102
+ // external to the marker cascade). The memo write is synchronous
3103
+ // (read-your-own-writes); the L1 Cache API writes are independent, so fan
3104
+ // them out in parallel rather than awaiting each.
3105
+ const lookupMarkerCacheActive = Boolean(this.kv) && this.tagCacheTtl > 0;
3106
+ if (this.kv || this.tagPurge) {
3107
+ const l1Writes: Promise<void>[] = [];
3108
+ for (const tag of tags) {
3109
+ if (failedTags.has(tag)) continue;
3110
+ memo?.set(tag, invalidatedAt);
3111
+ if (lookupMarkerCacheActive) {
3112
+ l1Writes.push(
3113
+ this.putTagMarkerL1(tag, invalidatedAt, { critical: true }),
3114
+ );
3115
+ }
3116
+ }
3117
+ if (l1Writes.length > 0) await Promise.all(l1Writes);
3118
+ }
3119
+
3120
+ // Purge mode: evict the tagged L1 entries across every colo via the
3121
+ // consumer's purge-by-tag call. AWAITED, and a failure is correctness-
3122
+ // bearing (unlike onRevalidateTag): with the per-read marker lookup skipped
3123
+ // on L1 hits, a dropped purge leaves L1 serving stale until TTL — so it
3124
+ // surfaces through updateTag() like a failed marker write. Fired for the
3125
+ // whole batch regardless of marker outcome (purging is additive and
3126
+ // idempotent; a retry re-runs both). Lookup Cache-Tags ride along when the
3127
+ // L1 marker cache is active (tagCacheTtl > 0 AND kv — without kv no lookup
3128
+ // entries are ever written) so a single purge call also converges other
3129
+ // colos' cached lookups (no separate onRevalidateTag needed).
3130
+ let purgeError: unknown;
3131
+ if (this.tagPurge) {
3132
+ const purgeTags = tags.flatMap((tag) =>
3133
+ lookupMarkerCacheActive
3134
+ ? [this.entryPurgeTag(tag), this.lookupPurgeTag(tag)]
3135
+ : [this.entryPurgeTag(tag)],
3136
+ );
3137
+ try {
3138
+ await this.tagPurge(purgeTags);
3139
+ } catch (error) {
3140
+ purgeError = error ?? new Error("tagPurge rejected");
3141
+ reportCacheError(
3142
+ purgeError,
3143
+ "cache-invalidate",
3144
+ "[CFCacheStore] tagPurge hook",
3145
+ );
3146
+ }
3147
+ }
3148
+
3149
+ // One batched eager purge of the lookup markers for the whole call. Fired
3150
+ // regardless of KV write outcome (it is additive and uses pure string ops).
3151
+ if (this.onRevalidateTag) {
3152
+ try {
3153
+ await this.onRevalidateTag(tags.map((tag) => this.lookupPurgeTag(tag)));
3154
+ } catch (error) {
3155
+ reportCacheError(
3156
+ error,
3157
+ "cache-invalidate",
3158
+ "[CFCacheStore] onRevalidateTag hook",
3159
+ );
3160
+ }
3161
+ }
3162
+
3163
+ if (failedTags.size > 0 || purgeError) {
3164
+ const parts: string[] = [];
3165
+ if (failedTags.size > 0) {
3166
+ parts.push(
3167
+ `${failedTags.size}/${tags.length} tag marker write(s) failed: ` +
3168
+ `${[...failedTags].join(", ")}`,
3169
+ );
3170
+ }
3171
+ if (purgeError) {
3172
+ parts.push(
3173
+ `the tagPurge purge-by-tag call failed (tagged L1 entries stay ` +
3174
+ `stale until TTL)`,
3175
+ );
3176
+ }
3177
+ const err = new Error(
3178
+ `[CFCacheStore] ${parts.join("; ")}. Those tags may still serve ` +
3179
+ `stale data across requests/colos; retry the invalidation.`,
3180
+ );
3181
+ (err as Error & { cause?: unknown }).cause = errors[0] ?? purgeError;
3182
+ throw err;
3183
+ }
720
3184
  }
721
3185
 
722
3186
  // ============================================================================
@@ -729,63 +3193,122 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
729
3193
  * Promotes hits to L1 via waitUntil.
730
3194
  * @internal
731
3195
  */
732
- private async kvGetSegment(key: string): Promise<CacheGetResult | null> {
3196
+ private async kvGetSegment(
3197
+ key: string,
3198
+ opts?: { suppressRevalidate?: boolean },
3199
+ ): Promise<CacheGetResult | null | CacheReadError> {
733
3200
  if (!this.kv) return null;
734
3201
 
735
3202
  try {
736
- const kvKey = this.toKVKey(key);
737
- const raw = await this.kv.get(kvKey, { type: "json" });
738
- if (!raw) return null;
3203
+ const kvKey = await this.toKVKey(key);
3204
+ const { value: envelope, timedOut } =
3205
+ await this.kvGetOrEvict<KVSegmentEnvelope>(
3206
+ kvKey,
3207
+ (e) =>
3208
+ typeof e.e === "number" && typeof e.s === "number" && e.d != null,
3209
+ "kvGetSegment",
3210
+ );
3211
+ if (timedOut) {
3212
+ // Abandoned slow KV read: no envelope, so no promote-to-L1. Distinct
3213
+ // from a genuine kv-miss so the degradation is visible on wrangler tail.
3214
+ if (this.debug)
3215
+ this.emitDebug({ op: "get", key, outcome: "kv-timeout" });
3216
+ return null;
3217
+ }
3218
+ if (!envelope) {
3219
+ // Missing key, or a corrupt entry already evicted + reported by
3220
+ // kvGetOrEvict. Either way a miss.
3221
+ if (this.debug) this.emitDebug({ op: "get", key, outcome: "kv-miss" });
3222
+ return null;
3223
+ }
739
3224
 
740
- const envelope = raw as KVSegmentEnvelope;
741
3225
  const now = Date.now();
742
3226
 
743
3227
  // Hard-expired — treat as miss
744
- if (now > envelope.e) return null;
3228
+ if (now > envelope.e) {
3229
+ if (this.debug) this.emitDebug({ op: "get", key, outcome: "kv-miss" });
3230
+ return null;
3231
+ }
745
3232
 
746
- const shouldRevalidate = now > envelope.s;
3233
+ // Tag invalidation check (also covers the KV tier, not just L1).
3234
+ if (
3235
+ await this.isGloballyInvalidated(envelope.d.tags, envelope.d.taggedAt)
3236
+ ) {
3237
+ if (this.debug)
3238
+ this.emitDebug({ op: "get", key, outcome: "tag-invalidated" });
3239
+ return null;
3240
+ }
3241
+
3242
+ // When this is a degraded L1 fall-through (body-timeout / non-200), the
3243
+ // caller asks us to suppress revalidation: KV has no REVALIDATING herd
3244
+ // guard, so N concurrent degraded reads would otherwise each spawn a
3245
+ // render exactly when the colo is already struggling. We still serve the
3246
+ // stale data and still promote to L1; only the revalidation is withheld.
3247
+ const stale = now > envelope.s;
3248
+ const shouldRevalidate = stale && !opts?.suppressRevalidate;
747
3249
 
748
3250
  // Promote to L1 in background
749
3251
  this.promoteSegmentToL1(key, envelope);
750
3252
 
3253
+ if (this.debug)
3254
+ this.emitDebug({
3255
+ op: "get",
3256
+ key,
3257
+ outcome: !stale
3258
+ ? "kv-fresh"
3259
+ : opts?.suppressRevalidate
3260
+ ? "kv-stale-suppressed"
3261
+ : "kv-stale",
3262
+ shouldRevalidate,
3263
+ });
751
3264
  return { data: envelope.d, shouldRevalidate };
752
3265
  } catch (error) {
753
- console.error("[CFCacheStore] KV get failed:", error);
754
- return null;
3266
+ reportCacheError(error, "cache-read", "[CFCacheStore] kvGetSegment");
3267
+ if (this.debug) this.emitDebug({ op: "get", key, outcome: "error" });
3268
+ // A KV failure is NOT proof of absence: returning null classified it a
3269
+ // real miss and let the PPR seeded fallback substitute the doc record
3270
+ // for a key partition the store could not actually read. Same sentinel
3271
+ // as get()'s own catch — lookupRouteDetailed classifies it `error` and
3272
+ // the render stays uncached. (A kvGetOrEvict TIMEOUT above stays null
3273
+ // by design: it is a bounded-latency degrade of a likely-healthy read,
3274
+ // and serving the equivalent seeded record there is the fallback
3275
+ // working as intended, not a masked failure.)
3276
+ return CACHE_READ_ERROR;
755
3277
  }
756
3278
  }
757
3279
 
758
3280
  /**
759
3281
  * Write segment data to KV.
3282
+ *
3283
+ * `dataJson` is the already-serialized CachedEntryData body also stored in
3284
+ * L1 — embedded as envelope.d without a second JSON.stringify walk.
760
3285
  * @internal
761
3286
  */
762
3287
  private kvSetSegment(
763
3288
  key: string,
764
- data: CachedEntryData,
3289
+ dataJson: string,
765
3290
  staleAt: number,
766
3291
  totalTtl: number,
3292
+ swrWindow: number,
767
3293
  ): void {
768
3294
  // KV requires expirationTtl >= 60s. Skip write for short-lived entries.
769
3295
  if (!this.kv || !this.waitUntil || totalTtl < 60) return;
770
3296
 
771
- const kvKey = this.toKVKey(key);
772
- const swrWindow = totalTtl * 1000 - (staleAt - Date.now());
773
- const expiresAt = staleAt + swrWindow;
774
-
775
- this.waitUntil(async () => {
776
- try {
777
- const envelope: KVSegmentEnvelope = {
778
- d: data,
779
- s: staleAt,
780
- e: expiresAt,
781
- };
782
- await this.kv!.put(kvKey, JSON.stringify(envelope), {
783
- expirationTtl: totalTtl,
784
- });
785
- } catch (error) {
786
- console.error("[CFCacheStore] KV set failed:", error);
787
- }
788
- });
3297
+ const expiresAt = staleAt + swrWindow * 1000;
3298
+ // Same wire shape as JSON.stringify({ d, s, e }) — dataJson is already
3299
+ // valid JSON for CachedEntryData, so embedding it avoids re-walking the tree.
3300
+ const envelopeJson = `{"d":${dataJson},"s":${staleAt},"e":${expiresAt}}`;
3301
+
3302
+ this.waitUntil(() =>
3303
+ reportingAsync(
3304
+ async () =>
3305
+ this.kv!.put(await this.toKVKey(key), envelopeJson, {
3306
+ expirationTtl: totalTtl,
3307
+ }),
3308
+ "cache-write",
3309
+ "[CFCacheStore] kvSetSegment",
3310
+ ),
3311
+ );
789
3312
  }
790
3313
 
791
3314
  /**
@@ -795,58 +3318,115 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
795
3318
  private promoteSegmentToL1(key: string, envelope: KVSegmentEnvelope): void {
796
3319
  if (!this.waitUntil) return;
797
3320
 
798
- this.waitUntil(async () => {
799
- try {
800
- const now = Date.now();
801
- const remainingTtl = Math.max(1, Math.floor((envelope.e - now) / 1000));
802
- const cache = await this.getCache();
803
- const request = this.keyToRequest(key);
804
-
805
- const response = new Response(JSON.stringify(envelope.d), {
806
- headers: {
807
- "Content-Type": "application/json",
808
- "Cache-Control": `public, max-age=${remainingTtl}`,
809
- [CACHE_STALE_AT_HEADER]: String(envelope.s),
810
- [CACHE_STATUS_HEADER]: "HIT",
811
- },
812
- });
813
-
814
- await cache.put(request, response);
815
- } catch (error) {
816
- console.error("[CFCacheStore] L1 promote failed:", error);
817
- }
818
- });
3321
+ this.waitUntil(() =>
3322
+ reportingAsync(
3323
+ async () => {
3324
+ const now = Date.now();
3325
+ const remainingTtl = Math.max(
3326
+ 1,
3327
+ Math.floor((envelope.e - now) / 1000),
3328
+ );
3329
+ const cache = await this.getCache();
3330
+ const request = this.keyToRequest(key);
3331
+
3332
+ const response = new Response(JSON.stringify(envelope.d), {
3333
+ headers: {
3334
+ "Content-Type": "application/json",
3335
+ "Cache-Control": `public, max-age=${remainingTtl}`,
3336
+ [CACHE_STALE_AT_HEADER]: String(envelope.s),
3337
+ // Carry the hard-expiry deadline so a promoted entry that later
3338
+ // goes stale re-puts with the correct remaining ttl (see set()).
3339
+ [CACHE_EXPIRES_AT_HEADER]: String(envelope.e),
3340
+ [CACHE_STATUS_HEADER]: "HIT",
3341
+ // Preserve tags across KV->L1 promotion so the promoted entry
3342
+ // stays tag-invalidatable.
3343
+ ...this.tagHeaderEntries(envelope.d.tags, envelope.d.taggedAt),
3344
+ },
3345
+ });
3346
+
3347
+ await cache.put(request, response);
3348
+ },
3349
+ "cache-write",
3350
+ "[CFCacheStore] promoteSegmentToL1",
3351
+ ),
3352
+ );
819
3353
  }
820
3354
 
821
3355
  /**
822
3356
  * KV fallback for function cache reads.
823
3357
  * @internal
824
3358
  */
825
- private async kvGetItem(key: string): Promise<CacheItemResult | null> {
3359
+ private async kvGetItem(
3360
+ key: string,
3361
+ opts?: { suppressRevalidate?: boolean },
3362
+ ): Promise<CacheItemResult | null> {
826
3363
  if (!this.kv) return null;
827
3364
 
828
3365
  try {
829
- const kvKey = this.toKVKey(`fn:${key}`);
830
- const raw = await this.kv.get(kvKey, { type: "json" });
831
- if (!raw) return null;
3366
+ const kvKey = await this.toKVKey(`fn:${key}`);
3367
+ const { value: envelope, timedOut } =
3368
+ await this.kvGetOrEvict<KVItemEnvelope>(
3369
+ kvKey,
3370
+ (e) =>
3371
+ typeof e.v === "string" &&
3372
+ typeof e.e === "number" &&
3373
+ typeof e.s === "number",
3374
+ "kvGetItem",
3375
+ );
3376
+ if (timedOut) {
3377
+ if (this.debug)
3378
+ this.emitDebug({ op: "getItem", key, outcome: "kv-timeout" });
3379
+ return null;
3380
+ }
3381
+ if (!envelope) {
3382
+ if (this.debug)
3383
+ this.emitDebug({ op: "getItem", key, outcome: "kv-miss" });
3384
+ return null;
3385
+ }
832
3386
 
833
- const envelope = raw as KVItemEnvelope;
834
3387
  const now = Date.now();
835
3388
 
836
- if (now > envelope.e) return null;
3389
+ if (now > envelope.e) {
3390
+ if (this.debug)
3391
+ this.emitDebug({ op: "getItem", key, outcome: "kv-miss" });
3392
+ return null;
3393
+ }
837
3394
 
838
- const shouldRevalidate = now > envelope.s;
3395
+ // Tag invalidation check (also covers the KV tier, not just L1).
3396
+ if (await this.isGloballyInvalidated(envelope.t, envelope.ta)) {
3397
+ if (this.debug)
3398
+ this.emitDebug({ op: "getItem", key, outcome: "tag-invalidated" });
3399
+ return null;
3400
+ }
3401
+
3402
+ // Degraded fall-through suppresses revalidation (no KV herd guard); see
3403
+ // kvGetSegment. Still serves stale and still promotes.
3404
+ const stale = now > envelope.s;
3405
+ const shouldRevalidate = stale && !opts?.suppressRevalidate;
839
3406
 
840
3407
  // Promote to L1
841
3408
  this.promoteItemToL1(key, envelope);
842
3409
 
3410
+ if (this.debug)
3411
+ this.emitDebug({
3412
+ op: "getItem",
3413
+ key,
3414
+ outcome: !stale
3415
+ ? "kv-fresh"
3416
+ : opts?.suppressRevalidate
3417
+ ? "kv-stale-suppressed"
3418
+ : "kv-stale",
3419
+ shouldRevalidate,
3420
+ });
843
3421
  return {
844
3422
  value: envelope.v,
845
3423
  handles: envelope.h,
846
3424
  shouldRevalidate,
3425
+ tags: envelope.t,
847
3426
  };
848
3427
  } catch (error) {
849
- console.error("[CFCacheStore] KV getItem failed:", error);
3428
+ reportCacheError(error, "cache-read", "[CFCacheStore] kvGetItem");
3429
+ if (this.debug) this.emitDebug({ op: "getItem", key, outcome: "error" });
850
3430
  return null;
851
3431
  }
852
3432
  }
@@ -858,28 +3438,41 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
858
3438
  private promoteItemToL1(key: string, envelope: KVItemEnvelope): void {
859
3439
  if (!this.waitUntil) return;
860
3440
 
861
- this.waitUntil(async () => {
862
- try {
863
- const now = Date.now();
864
- const remainingTtl = Math.max(1, Math.floor((envelope.e - now) / 1000));
865
- const cache = await this.getCache();
866
- const request = this.keyToRequest(`fn:${key}`);
867
-
868
- const body = JSON.stringify({ value: envelope.v, handles: envelope.h });
869
- const response = new Response(body, {
870
- headers: {
871
- "Content-Type": "application/json",
872
- "Cache-Control": `public, max-age=${remainingTtl}`,
873
- [CACHE_STALE_AT_HEADER]: String(envelope.s),
874
- [CACHE_STATUS_HEADER]: "HIT",
875
- },
876
- });
877
-
878
- await cache.put(request, response);
879
- } catch (error) {
880
- console.error("[CFCacheStore] L1 item promote failed:", error);
881
- }
882
- });
3441
+ this.waitUntil(() =>
3442
+ reportingAsync(
3443
+ async () => {
3444
+ const now = Date.now();
3445
+ const remainingTtl = Math.max(
3446
+ 1,
3447
+ Math.floor((envelope.e - now) / 1000),
3448
+ );
3449
+ const cache = await this.getCache();
3450
+ const request = this.keyToRequest(`fn:${key}`);
3451
+
3452
+ const body = JSON.stringify({
3453
+ value: envelope.v,
3454
+ handles: envelope.h,
3455
+ });
3456
+ const response = new Response(body, {
3457
+ headers: {
3458
+ "Content-Type": "application/json",
3459
+ "Cache-Control": `public, max-age=${remainingTtl}`,
3460
+ [CACHE_STALE_AT_HEADER]: String(envelope.s),
3461
+ // Carry the hard-expiry deadline; see promoteSegmentToL1 / set().
3462
+ [CACHE_EXPIRES_AT_HEADER]: String(envelope.e),
3463
+ [CACHE_STATUS_HEADER]: "HIT",
3464
+ // Preserve tags across KV->L1 promotion (the item tier previously
3465
+ // dropped them, permanently disabling tag invalidation here).
3466
+ ...this.tagHeaderEntries(envelope.t, envelope.ta),
3467
+ },
3468
+ });
3469
+
3470
+ await cache.put(request, response);
3471
+ },
3472
+ "cache-write",
3473
+ "[CFCacheStore] promoteItemToL1",
3474
+ ),
3475
+ );
883
3476
  }
884
3477
 
885
3478
  /**
@@ -892,32 +3485,82 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
892
3485
  if (!this.kv) return null;
893
3486
 
894
3487
  try {
895
- const kvKey = this.toKVKey(`doc:${key}`);
896
- const raw = await this.kv.get(kvKey, { type: "json" });
897
- if (!raw) return null;
3488
+ const kvKey = await this.toDocKVKey(key);
3489
+ // The document path is debug-silent (op is only get/getItem): a KV-read
3490
+ // timeout here is bounded for resilience parity (kvGetOrEvict applies the
3491
+ // budget) but emits no kv-timeout event, so its absence from the debug
3492
+ // stream is expected. A null envelope is a miss -- missing key, a budget
3493
+ // timeout, or a corrupt entry already evicted + reported by kvGetOrEvict.
3494
+ const { value: envelope } = await this.kvGetOrEvict<KVResponseEnvelope>(
3495
+ kvKey,
3496
+ (e) =>
3497
+ typeof e.b === "string" &&
3498
+ typeof e.st === "number" &&
3499
+ typeof e.e === "number" &&
3500
+ typeof e.s === "number" &&
3501
+ // stx is optional but, if present, must be a string (feeds Response).
3502
+ (e.stx === undefined || typeof e.stx === "string") &&
3503
+ // hd must be an array of [name, value] string tuples; a malformed
3504
+ // shape would otherwise throw in `new Headers(hd)`. Validate it here
3505
+ // so a faulty envelope is a fail-open MISS, never a thrown read.
3506
+ Array.isArray(e.hd) &&
3507
+ e.hd.every(
3508
+ (entry) =>
3509
+ Array.isArray(entry) &&
3510
+ entry.length === 2 &&
3511
+ typeof entry[0] === "string" &&
3512
+ typeof entry[1] === "string",
3513
+ ),
3514
+ "kvGetResponse",
3515
+ );
3516
+ if (!envelope) return null;
898
3517
 
899
- const envelope = raw as KVResponseEnvelope;
900
3518
  const now = Date.now();
901
3519
 
902
3520
  if (now > envelope.e) return null;
903
3521
 
3522
+ // Tag invalidation check (also covers the KV tier, not just L1).
3523
+ if (await this.isGloballyInvalidated(envelope.t, envelope.ta)) {
3524
+ return null;
3525
+ }
3526
+
904
3527
  const shouldRevalidate = now > envelope.s;
905
3528
 
906
- // Reconstruct Response (decode base64 binary)
907
- const headers = new Headers(envelope.hd);
908
- const bodyBuffer = base64ToBuffer(envelope.b);
909
- const response = new Response(bodyBuffer, {
910
- status: envelope.st,
911
- statusText: envelope.stx,
912
- headers,
913
- });
3529
+ // Reconstruct Response: decode base64 -> binary, rebuild headers/status.
3530
+ // Corrupt/partial base64 throws in atob; malformed `hd` or an out-of-range
3531
+ // `st` throws in new Headers/new Response. Any of these is a faulty entry,
3532
+ // so evict it and miss rather than re-failing every read until TTL.
3533
+ let response: Response;
3534
+ try {
3535
+ // Finding #3 (read side): strip per-client signals a stale envelope may
3536
+ // carry. Inside the try so a malformed `hd` evicts (not throws through);
3537
+ // mutates `hd` in place so promoteResponseToL1 re-seeds from it too.
3538
+ envelope.hd = envelope.hd.filter(
3539
+ ([name]) => !isPerClientSignalHeader(name),
3540
+ );
3541
+ const bodyBuffer = base64ToBuffer(envelope.b);
3542
+ const headers = new Headers(envelope.hd);
3543
+ response = new Response(bodyBuffer, {
3544
+ status: envelope.st,
3545
+ statusText: envelope.stx,
3546
+ headers,
3547
+ });
3548
+ } catch (error) {
3549
+ reportCacheError(
3550
+ error,
3551
+ "cache-corrupt",
3552
+ "[CFCacheStore] kvGetResponse: corrupt response envelope, evicting",
3553
+ );
3554
+ this.scheduleKvEvict(kvKey, "kvGetResponse");
3555
+ return null;
3556
+ }
914
3557
 
915
3558
  // Promote to L1
916
3559
  this.promoteResponseToL1(key, envelope);
917
3560
 
918
3561
  return { response, shouldRevalidate };
919
3562
  } catch (error) {
920
- console.error("[CFCacheStore] KV getResponse failed:", error);
3563
+ reportCacheError(error, "cache-read", "[CFCacheStore] kvGetResponse");
921
3564
  return null;
922
3565
  }
923
3566
  }
@@ -929,52 +3572,46 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
929
3572
  private promoteResponseToL1(key: string, envelope: KVResponseEnvelope): void {
930
3573
  if (!this.waitUntil) return;
931
3574
 
932
- this.waitUntil(async () => {
933
- try {
934
- const now = Date.now();
935
- const remainingTtl = Math.max(1, Math.floor((envelope.e - now) / 1000));
936
- const cache = await this.getCache();
937
- const request = this.keyToRequest(`doc:${key}`);
938
-
939
- const headers = new Headers(envelope.hd);
940
- headers.set("Cache-Control", `public, max-age=${remainingTtl}`);
941
- headers.set(CACHE_STALE_AT_HEADER, String(envelope.s));
942
-
943
- const bodyBuffer = base64ToBuffer(envelope.b);
944
- const response = new Response(bodyBuffer, {
945
- status: envelope.st,
946
- statusText: envelope.stx,
947
- headers,
948
- });
949
-
950
- await cache.put(request, response);
951
- } catch (error) {
952
- console.error("[CFCacheStore] L1 response promote failed:", error);
953
- }
954
- });
955
- }
956
- }
957
-
958
- // ============================================================================
959
- // Base64 Helpers (binary-safe response body encoding for KV)
960
- // ============================================================================
961
-
962
- /** Encode ArrayBuffer to base64 string. */
963
- function bufferToBase64(buffer: ArrayBuffer): string {
964
- const bytes = new Uint8Array(buffer);
965
- let binary = "";
966
- for (let i = 0; i < bytes.length; i++) {
967
- binary += String.fromCharCode(bytes[i]!);
968
- }
969
- return btoa(binary);
970
- }
971
-
972
- /** Decode base64 string to ArrayBuffer. */
973
- function base64ToBuffer(base64: string): ArrayBuffer {
974
- const binary = atob(base64);
975
- const bytes = new Uint8Array(binary.length);
976
- for (let i = 0; i < binary.length; i++) {
977
- bytes[i] = binary.charCodeAt(i);
3575
+ this.waitUntil(() =>
3576
+ reportingAsync(
3577
+ async () => {
3578
+ const now = Date.now();
3579
+ const remainingTtl = Math.max(
3580
+ 1,
3581
+ Math.floor((envelope.e - now) / 1000),
3582
+ );
3583
+ const cache = await this.getCache();
3584
+ const request = this.keyToRequest(`doc:${key}`);
3585
+
3586
+ const headers = new Headers(envelope.hd);
3587
+ const originalCacheControl = headers.get("Cache-Control");
3588
+ if (originalCacheControl !== null) {
3589
+ headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
3590
+ }
3591
+ headers.set("Cache-Control", `public, max-age=${remainingTtl}`);
3592
+ headers.set(CACHE_STALE_AT_HEADER, String(envelope.s));
3593
+ // Carry the hard-expiry deadline so the document herd guard's
3594
+ // markResponseRevalidating re-put can compute the remaining window
3595
+ // (matches promoteSegmentToL1/promoteItemToL1); without it a stale
3596
+ // re-put would floor to max-age=1 and churn the KV-promoted twin.
3597
+ headers.set(CACHE_EXPIRES_AT_HEADER, String(envelope.e));
3598
+ // Re-attach the internal tag headers (envelope.hd is client-facing
3599
+ // and intentionally excludes them) so the promoted entry stays
3600
+ // invalidatable.
3601
+ this.setTagHeaders(headers, envelope.t, envelope.ta);
3602
+
3603
+ const bodyBuffer = base64ToBuffer(envelope.b);
3604
+ const response = new Response(bodyBuffer, {
3605
+ status: envelope.st,
3606
+ statusText: envelope.stx,
3607
+ headers,
3608
+ });
3609
+
3610
+ await cache.put(request, response);
3611
+ },
3612
+ "cache-write",
3613
+ "[CFCacheStore] promoteResponseToL1",
3614
+ ),
3615
+ );
978
3616
  }
979
- return bytes.buffer;
980
3617
  }