@rangojs/router 0.0.0-experimental.bd6e11bc → 0.0.0-experimental.bdaf10aa

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 (411) hide show
  1. package/AGENTS.md +8 -4
  2. package/README.md +296 -887
  3. package/dist/bin/rango.js +459 -91
  4. package/dist/testing/vitest.js +36 -2
  5. package/dist/vite/index.js +1708 -414
  6. package/package.json +35 -10
  7. package/skills/api-client/SKILL.md +211 -0
  8. package/skills/breadcrumbs/SKILL.md +82 -5
  9. package/skills/bundle-analysis/SKILL.md +2 -2
  10. package/skills/cache-guide/SKILL.md +14 -9
  11. package/skills/caching/SKILL.md +221 -12
  12. package/skills/catalog.json +271 -0
  13. package/skills/comparison/SKILL.md +50 -0
  14. package/skills/comparison/agents/openai.yaml +4 -0
  15. package/skills/comparison/references/framework-comparison.md +837 -0
  16. package/skills/composability/SKILL.md +83 -2
  17. package/skills/css/SKILL.md +76 -0
  18. package/skills/debug-manifest/SKILL.md +5 -3
  19. package/skills/defer-hydration/SKILL.md +235 -0
  20. package/skills/document-cache/SKILL.md +11 -3
  21. package/skills/fonts/SKILL.md +1 -1
  22. package/skills/handler-use/SKILL.md +9 -9
  23. package/skills/hooks/SKILL.md +73 -900
  24. package/skills/hooks/data.md +273 -0
  25. package/skills/hooks/handle-and-actions.md +103 -0
  26. package/skills/hooks/navigation.md +110 -0
  27. package/skills/hooks/outlets.md +41 -0
  28. package/skills/hooks/state.md +228 -0
  29. package/skills/hooks/urls.md +135 -0
  30. package/skills/host-router/SKILL.md +84 -7
  31. package/skills/i18n/SKILL.md +1 -1
  32. package/skills/intercept/SKILL.md +51 -17
  33. package/skills/layout/SKILL.md +38 -16
  34. package/skills/links/SKILL.md +1 -1
  35. package/skills/loader/SKILL.md +48 -20
  36. package/skills/middleware/SKILL.md +11 -5
  37. package/skills/migrate-nextjs/SKILL.md +203 -20
  38. package/skills/migrate-react-router/SKILL.md +59 -675
  39. package/skills/migrate-react-router/cloudflare-workers.md +129 -0
  40. package/skills/migrate-react-router/component-migration.md +196 -0
  41. package/skills/migrate-react-router/data-and-actions.md +225 -0
  42. package/skills/migrate-react-router/route-mapping.md +271 -0
  43. package/skills/mime-routes/SKILL.md +3 -3
  44. package/skills/observability/SKILL.md +70 -5
  45. package/skills/parallel/SKILL.md +32 -8
  46. package/skills/ppr/SKILL.md +622 -0
  47. package/skills/prerender/SKILL.md +59 -28
  48. package/skills/rango/SKILL.md +124 -50
  49. package/skills/response-routes/SKILL.md +78 -46
  50. package/skills/route/SKILL.md +85 -6
  51. package/skills/router-setup/SKILL.md +41 -6
  52. package/skills/scripts/SKILL.md +179 -0
  53. package/skills/server-actions/SKILL.md +28 -3
  54. package/skills/shell-manifest/SKILL.md +185 -0
  55. package/skills/streams-and-websockets/SKILL.md +1 -1
  56. package/skills/tailwind/SKILL.md +28 -4
  57. package/skills/testing/SKILL.md +68 -654
  58. package/skills/testing/bindings.md +103 -0
  59. package/skills/testing/cache-prerender.md +127 -0
  60. package/skills/testing/client-components.md +124 -0
  61. package/skills/testing/e2e-parity.md +125 -0
  62. package/skills/testing/flight.md +91 -0
  63. package/skills/testing/handles.md +131 -0
  64. package/skills/testing/loader.md +128 -0
  65. package/skills/testing/middleware.md +99 -0
  66. package/skills/testing/render-handler.md +122 -0
  67. package/skills/testing/response-routes.md +95 -0
  68. package/skills/testing/reverse-and-types.md +85 -0
  69. package/skills/testing/server-actions.md +107 -0
  70. package/skills/testing/server-tree.md +128 -0
  71. package/skills/testing/setup.md +123 -0
  72. package/skills/theme/SKILL.md +1 -1
  73. package/skills/typesafety/SKILL.md +45 -918
  74. package/skills/typesafety/env-and-bindings.md +254 -0
  75. package/skills/typesafety/generated-files-and-cli.md +335 -0
  76. package/skills/typesafety/params-and-search.md +153 -0
  77. package/skills/typesafety/route-types.md +209 -0
  78. package/skills/use-cache/SKILL.md +47 -17
  79. package/skills/vercel/SKILL.md +128 -0
  80. package/skills/view-transitions/SKILL.md +44 -1
  81. package/src/__augment-tests__/augmented.check.ts +2 -3
  82. package/src/__internal.ts +0 -65
  83. package/src/browser/action-coordinator.ts +1 -1
  84. package/src/browser/action-fence.ts +47 -0
  85. package/src/browser/app-shell.ts +14 -27
  86. package/src/browser/connection-warmup.ts +134 -0
  87. package/src/browser/cookie-name.ts +140 -0
  88. package/src/browser/event-controller.ts +178 -100
  89. package/src/browser/invalidate-client-cache.ts +52 -0
  90. package/src/browser/logging.ts +28 -0
  91. package/src/browser/merge-segment-loaders.ts +6 -4
  92. package/src/browser/navigation-bridge.ts +81 -68
  93. package/src/browser/navigation-client.ts +115 -70
  94. package/src/browser/navigation-store-handle.ts +38 -0
  95. package/src/browser/navigation-store.ts +153 -88
  96. package/src/browser/navigation-transaction.ts +0 -32
  97. package/src/browser/network-error-handler.ts +34 -7
  98. package/src/browser/partial-update.ts +157 -144
  99. package/src/browser/prefetch/cache.ts +148 -81
  100. package/src/browser/prefetch/fetch.ts +231 -51
  101. package/src/browser/prefetch/queue.ts +25 -7
  102. package/src/browser/rango-state.ts +157 -115
  103. package/src/browser/react/Link.tsx +40 -7
  104. package/src/browser/react/NavigationProvider.tsx +140 -99
  105. package/src/browser/react/ScrollRestoration.tsx +10 -6
  106. package/src/browser/react/filter-segment-order.ts +17 -2
  107. package/src/browser/react/index.ts +0 -51
  108. package/src/browser/react/location-state-shared.ts +14 -15
  109. package/src/browser/react/location-state.ts +0 -1
  110. package/src/browser/react/use-action.ts +6 -15
  111. package/src/browser/react/use-handle.ts +0 -5
  112. package/src/browser/react/use-href.tsx +8 -1
  113. package/src/browser/react/use-link-status.ts +33 -8
  114. package/src/browser/react/use-navigation.ts +10 -5
  115. package/src/browser/react/use-params.ts +0 -2
  116. package/src/browser/react/use-router.ts +6 -4
  117. package/src/browser/react/use-search-params.ts +0 -5
  118. package/src/browser/react/use-segments.ts +0 -13
  119. package/src/browser/response-adapter.ts +74 -8
  120. package/src/browser/rsc-router.tsx +97 -22
  121. package/src/browser/scroll-restoration.ts +15 -8
  122. package/src/browser/segment-reconciler.ts +31 -21
  123. package/src/browser/server-action-bridge.ts +216 -38
  124. package/src/browser/types.ts +94 -22
  125. package/src/browser/validate-redirect-origin.ts +43 -16
  126. package/src/build/generate-manifest.ts +155 -131
  127. package/src/build/generate-route-types.ts +1 -1
  128. package/src/build/index.ts +11 -5
  129. package/src/build/prefix-tree-utils.ts +123 -0
  130. package/src/build/route-trie.ts +152 -22
  131. package/src/build/route-types/ast-route-extraction.ts +15 -8
  132. package/src/build/route-types/codegen.ts +12 -1
  133. package/src/build/route-types/include-resolution.ts +455 -61
  134. package/src/build/route-types/param-extraction.ts +6 -3
  135. package/src/build/route-types/per-module-writer.ts +15 -2
  136. package/src/build/route-types/router-processing.ts +77 -41
  137. package/src/build/route-types/source-scan.ts +105 -7
  138. package/src/build/runtime-discovery.ts +4 -1
  139. package/src/cache/cache-error.ts +104 -0
  140. package/src/cache/cache-key-utils.ts +58 -13
  141. package/src/cache/cache-policy.ts +108 -34
  142. package/src/cache/cache-runtime.ts +454 -101
  143. package/src/cache/cache-scope.ts +159 -54
  144. package/src/cache/cache-tag.ts +149 -0
  145. package/src/cache/cf/cf-base64.ts +33 -0
  146. package/src/cache/cf/cf-cache-constants.ts +127 -0
  147. package/src/cache/cf/cf-cache-store.ts +2170 -377
  148. package/src/cache/cf/cf-cache-types.ts +349 -0
  149. package/src/cache/cf/cf-kv-utils.ts +46 -0
  150. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  151. package/src/cache/cf/index.ts +6 -16
  152. package/src/cache/document-cache.ts +126 -41
  153. package/src/cache/handle-snapshot.ts +70 -0
  154. package/src/cache/index.ts +23 -20
  155. package/src/cache/memory-segment-store.ts +243 -37
  156. package/src/cache/profile-registry.ts +46 -31
  157. package/src/cache/read-through-swr.ts +56 -12
  158. package/src/cache/segment-codec.ts +13 -21
  159. package/src/cache/shell-snapshot.ts +417 -0
  160. package/src/cache/tag-invalidation.ts +230 -0
  161. package/src/cache/types.ts +194 -99
  162. package/src/cache/vercel/index.ts +11 -0
  163. package/src/cache/vercel/vercel-cache-store.ts +1132 -0
  164. package/src/client.rsc.tsx +39 -22
  165. package/src/client.tsx +28 -58
  166. package/src/cloudflare/index.ts +11 -0
  167. package/src/cloudflare/tracing.ts +108 -0
  168. package/src/component-utils.ts +19 -0
  169. package/src/components/DefaultDocument.tsx +8 -2
  170. package/src/context-var.ts +13 -1
  171. package/src/decode-loader-results.ts +18 -2
  172. package/src/defer.ts +185 -0
  173. package/src/deps/ssr.ts +0 -1
  174. package/src/encode-kv.ts +49 -0
  175. package/src/errors.ts +0 -3
  176. package/src/escape-script.ts +52 -0
  177. package/src/handle.ts +57 -40
  178. package/src/handles/MetaTags.tsx +24 -53
  179. package/src/handles/Scripts.tsx +183 -0
  180. package/src/handles/breadcrumbs.ts +35 -8
  181. package/src/handles/deferred-resolution.ts +127 -0
  182. package/src/handles/is-thenable.ts +18 -0
  183. package/src/handles/meta.ts +14 -40
  184. package/src/handles/script.ts +244 -0
  185. package/src/host/cookie-handler.ts +9 -60
  186. package/src/host/errors.ts +13 -22
  187. package/src/host/index.ts +7 -0
  188. package/src/host/pattern-matcher.ts +23 -52
  189. package/src/host/router.ts +1 -65
  190. package/src/host/testing.ts +40 -27
  191. package/src/host/types.ts +6 -2
  192. package/src/href-client.ts +7 -12
  193. package/src/index.rsc.ts +88 -8
  194. package/src/index.ts +90 -16
  195. package/src/internal-debug.ts +11 -10
  196. package/src/loader.rsc.ts +19 -9
  197. package/src/loader.ts +12 -4
  198. package/src/outlet-provider.tsx +1 -5
  199. package/src/prerender/param-hash.ts +16 -16
  200. package/src/prerender/store.ts +32 -37
  201. package/src/prerender.ts +75 -7
  202. package/src/redirect-origin.ts +114 -0
  203. package/src/regex-escape.ts +8 -0
  204. package/src/render-error-thrower.tsx +20 -0
  205. package/src/response-utils.ts +25 -0
  206. package/src/root-error-boundary.tsx +1 -19
  207. package/src/route-content-wrapper.tsx +13 -49
  208. package/src/route-definition/dsl-helpers.ts +60 -53
  209. package/src/route-definition/helper-factories.ts +0 -2
  210. package/src/route-definition/helpers-types.ts +46 -46
  211. package/src/route-definition/index.ts +1 -2
  212. package/src/route-definition/redirect.ts +44 -11
  213. package/src/route-definition/resolve-handler-use.ts +6 -1
  214. package/src/route-definition/use-item-types.ts +3 -6
  215. package/src/route-map-builder.ts +41 -20
  216. package/src/route-types.ts +0 -5
  217. package/src/router/content-negotiation.ts +58 -23
  218. package/src/router/error-handling.ts +44 -17
  219. package/src/router/find-match.ts +129 -30
  220. package/src/router/handler-context.ts +6 -1
  221. package/src/router/instrument.ts +355 -0
  222. package/src/router/intercept-resolution.ts +35 -2
  223. package/src/router/lazy-includes.ts +79 -56
  224. package/src/router/loader-resolution.ts +151 -73
  225. package/src/router/logging.ts +0 -6
  226. package/src/router/manifest.ts +74 -40
  227. package/src/router/match-api.ts +76 -52
  228. package/src/router/match-context.ts +0 -22
  229. package/src/router/match-handlers.ts +181 -178
  230. package/src/router/match-middleware/background-revalidation.ts +40 -24
  231. package/src/router/match-middleware/cache-lookup.ts +115 -194
  232. package/src/router/match-middleware/cache-store.ts +61 -50
  233. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  234. package/src/router/match-middleware/segment-resolution.ts +0 -22
  235. package/src/router/match-pipelines.ts +1 -42
  236. package/src/router/match-result.ts +36 -67
  237. package/src/router/metrics.ts +0 -34
  238. package/src/router/middleware-types.ts +0 -116
  239. package/src/router/middleware.ts +231 -120
  240. package/src/router/navigation-snapshot.ts +7 -56
  241. package/src/router/params-util.ts +23 -0
  242. package/src/router/parse-pattern.ts +115 -0
  243. package/src/router/pattern-matching.ts +99 -152
  244. package/src/router/prefetch-cache-ttl.ts +51 -0
  245. package/src/router/prefetch-limits.ts +37 -0
  246. package/src/router/prerender-match.ts +111 -66
  247. package/src/router/preview-match.ts +3 -1
  248. package/src/router/request-classification.ts +47 -42
  249. package/src/router/revalidation.ts +75 -81
  250. package/src/router/route-snapshot.ts +14 -3
  251. package/src/router/router-context.ts +6 -29
  252. package/src/router/router-interfaces.ts +70 -8
  253. package/src/router/router-options.ts +126 -4
  254. package/src/router/segment-resolution/fresh.ts +104 -80
  255. package/src/router/segment-resolution/helpers.ts +86 -6
  256. package/src/router/segment-resolution/loader-cache.ts +155 -39
  257. package/src/router/segment-resolution/loader-mask.ts +60 -0
  258. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  259. package/src/router/segment-resolution/mask-nested.ts +83 -0
  260. package/src/router/segment-resolution/revalidation.ts +215 -304
  261. package/src/router/segment-resolution/static-store.ts +19 -5
  262. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  263. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  264. package/src/router/segment-resolution.ts +5 -1
  265. package/src/router/segment-wrappers.ts +6 -5
  266. package/src/router/state-cookie-name.ts +33 -0
  267. package/src/router/substitute-pattern-params.ts +54 -35
  268. package/src/router/telemetry-otel.ts +160 -200
  269. package/src/router/telemetry.ts +9 -23
  270. package/src/router/timeout.ts +0 -20
  271. package/src/router/tracing.ts +215 -0
  272. package/src/router/trie-matching.ts +171 -64
  273. package/src/router/types.ts +1 -63
  274. package/src/router/url-params.ts +13 -5
  275. package/src/router.ts +119 -48
  276. package/src/rsc/full-payload.ts +70 -0
  277. package/src/rsc/handler-context.ts +1 -0
  278. package/src/rsc/handler.ts +267 -152
  279. package/src/rsc/helpers.ts +78 -4
  280. package/src/rsc/index.ts +1 -4
  281. package/src/rsc/json-route-result.ts +38 -0
  282. package/src/rsc/loader-fetch.ts +114 -38
  283. package/src/rsc/manifest-init.ts +29 -42
  284. package/src/rsc/nonce.ts +10 -1
  285. package/src/rsc/origin-guard.ts +11 -15
  286. package/src/rsc/progressive-enhancement.ts +120 -13
  287. package/src/rsc/redirect-guard.ts +100 -0
  288. package/src/rsc/response-cache-serve.ts +238 -0
  289. package/src/rsc/response-error.ts +79 -12
  290. package/src/rsc/response-route-handler.ts +58 -141
  291. package/src/rsc/rsc-rendering.ts +492 -49
  292. package/src/rsc/runtime-warnings.ts +14 -0
  293. package/src/rsc/server-action.ts +268 -82
  294. package/src/rsc/shell-capture.ts +1190 -0
  295. package/src/rsc/shell-serve.ts +181 -0
  296. package/src/rsc/transition-gate.ts +89 -0
  297. package/src/rsc/types.ts +45 -3
  298. package/src/runtime-env.ts +18 -0
  299. package/src/search-params.ts +31 -26
  300. package/src/segment-loader-promise.ts +49 -4
  301. package/src/segment-system.tsx +260 -95
  302. package/src/server/context.ts +99 -9
  303. package/src/server/cookie-parse.ts +32 -0
  304. package/src/server/cookie-store.ts +125 -2
  305. package/src/server/handle-store.ts +21 -38
  306. package/src/server/loader-registry.ts +33 -42
  307. package/src/server/request-context.ts +379 -138
  308. package/src/ssr/index.tsx +491 -182
  309. package/src/ssr/inject-rsc-eager.ts +167 -0
  310. package/src/ssr/ssr-root.tsx +228 -0
  311. package/src/static-handler.ts +10 -13
  312. package/src/testing/cache-status.ts +44 -48
  313. package/src/testing/collect-handle.ts +14 -31
  314. package/src/testing/dispatch.ts +533 -160
  315. package/src/testing/e2e/fixture.ts +45 -11
  316. package/src/testing/e2e/index.ts +1 -22
  317. package/src/testing/e2e/matchers.ts +0 -16
  318. package/src/testing/e2e/parity.ts +85 -4
  319. package/src/testing/e2e/server.ts +12 -0
  320. package/src/testing/flight-matchers.ts +7 -14
  321. package/src/testing/flight-normalize.ts +11 -0
  322. package/src/testing/flight-runtime.d.ts +36 -0
  323. package/src/testing/flight-tree.ts +682 -0
  324. package/src/testing/flight.entry.ts +30 -0
  325. package/src/testing/flight.ts +145 -70
  326. package/src/testing/generated-routes.ts +26 -50
  327. package/src/testing/index.ts +18 -19
  328. package/src/testing/internal/context.ts +184 -68
  329. package/src/testing/internal/flight-client-globals.ts +30 -0
  330. package/src/testing/internal/seed-vars.ts +54 -0
  331. package/src/testing/render-handler.ts +357 -0
  332. package/src/testing/render-route.tsx +134 -115
  333. package/src/testing/run-loader.ts +140 -51
  334. package/src/testing/run-middleware.ts +59 -33
  335. package/src/testing/run-transition-when.ts +164 -0
  336. package/src/testing/vitest-stubs/cloudflare-email.ts +1 -1
  337. package/src/testing/vitest-stubs/cloudflare-workers.ts +1 -1
  338. package/src/testing/vitest.ts +138 -16
  339. package/src/theme/ThemeProvider.tsx +56 -84
  340. package/src/theme/ThemeScript.tsx +7 -9
  341. package/src/theme/constants.ts +52 -13
  342. package/src/theme/index.ts +0 -7
  343. package/src/theme/theme-context.ts +1 -5
  344. package/src/theme/theme-script.ts +22 -21
  345. package/src/theme/use-theme.ts +0 -3
  346. package/src/types/boundaries.ts +0 -35
  347. package/src/types/cache-types.ts +13 -4
  348. package/src/types/error-types.ts +30 -90
  349. package/src/types/global-namespace.ts +15 -15
  350. package/src/types/handler-context.ts +45 -15
  351. package/src/types/index.ts +2 -10
  352. package/src/types/loader-types.ts +6 -3
  353. package/src/types/request-scope.ts +8 -22
  354. package/src/types/route-config.ts +20 -52
  355. package/src/types/route-entry.ts +0 -6
  356. package/src/types/segments.ts +100 -13
  357. package/src/urls/include-helper.ts +10 -12
  358. package/src/urls/include-provider.ts +71 -0
  359. package/src/urls/index.ts +2 -8
  360. package/src/urls/path-helper-types.ts +52 -14
  361. package/src/urls/path-helper.ts +5 -54
  362. package/src/urls/pattern-types.ts +36 -0
  363. package/src/urls/type-extraction.ts +76 -42
  364. package/src/urls/urls-function.ts +0 -14
  365. package/src/use-loader.tsx +0 -186
  366. package/src/vercel/index.ts +11 -0
  367. package/src/vercel/tracing.ts +88 -0
  368. package/src/vite/discovery/bundle-postprocess.ts +2 -1
  369. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  370. package/src/vite/discovery/discover-routers.ts +34 -43
  371. package/src/vite/discovery/discovery-errors.ts +61 -0
  372. package/src/vite/discovery/prerender-collection.ts +33 -46
  373. package/src/vite/discovery/state.ts +12 -1
  374. package/src/vite/discovery/virtual-module-codegen.ts +1 -11
  375. package/src/vite/index.ts +9 -0
  376. package/src/vite/inject-client-debug.ts +88 -0
  377. package/src/vite/plugin-types.ts +143 -10
  378. package/src/vite/plugins/cjs-to-esm.ts +8 -12
  379. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  380. package/src/vite/plugins/client-ref-hashing.ts +0 -10
  381. package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
  382. package/src/vite/plugins/expose-action-id.ts +2 -73
  383. package/src/vite/plugins/expose-id-utils.ts +85 -56
  384. package/src/vite/plugins/expose-ids/export-analysis.ts +30 -43
  385. package/src/vite/plugins/expose-ids/handler-transform.ts +5 -31
  386. package/src/vite/plugins/expose-ids/loader-transform.ts +12 -20
  387. package/src/vite/plugins/expose-ids/router-transform.ts +98 -26
  388. package/src/vite/plugins/expose-internal-ids.ts +10 -1
  389. package/src/vite/plugins/performance-tracks.ts +0 -3
  390. package/src/vite/plugins/refresh-cmd.ts +1 -1
  391. package/src/vite/plugins/use-cache-transform.ts +21 -46
  392. package/src/vite/plugins/vercel-output.ts +384 -0
  393. package/src/vite/plugins/version-injector.ts +22 -27
  394. package/src/vite/plugins/version-plugin.ts +6 -66
  395. package/src/vite/plugins/virtual-entries.ts +137 -26
  396. package/src/vite/rango.ts +146 -135
  397. package/src/vite/router-discovery.ts +189 -48
  398. package/src/vite/utils/ast-handler-extract.ts +11 -20
  399. package/src/vite/utils/bundle-analysis.ts +6 -13
  400. package/src/vite/utils/client-chunks.ts +0 -6
  401. package/src/vite/utils/directive-prologue.ts +40 -0
  402. package/src/vite/utils/forward-user-plugins.ts +0 -22
  403. package/src/vite/utils/manifest-utils.ts +4 -75
  404. package/src/vite/utils/package-resolution.ts +1 -73
  405. package/src/vite/utils/prerender-utils.ts +71 -44
  406. package/src/vite/utils/shared-utils.ts +55 -37
  407. package/src/browser/react/use-client-cache.ts +0 -58
  408. package/src/browser/shallow.ts +0 -40
  409. package/src/handles/index.ts +0 -7
  410. package/src/network-error-thrower.tsx +0 -23
  411. package/src/router/middleware-cookies.ts +0 -55
@@ -34,67 +34,139 @@ import type {
34
34
  CacheGetResult,
35
35
  CacheItemResult,
36
36
  CacheItemOptions,
37
+ ShellCacheEntry,
37
38
  } from "../types.js";
38
39
  import {
39
40
  _getRequestContext,
40
41
  type RequestContext,
41
42
  } from "../../server/request-context.js";
42
43
  import { VERSION } from "@rangojs/router:version";
44
+ import {
45
+ isPerClientSignalHeader,
46
+ stripPerClientSignals,
47
+ } from "../../browser/cookie-name.js";
43
48
  import {
44
49
  resolveTtl,
45
50
  resolveSwrWindow,
46
51
  DEFAULT_FUNCTION_TTL,
47
52
  } from "../cache-policy.js";
53
+ import { reportCacheError, reportingAsync } from "../cache-error.js";
54
+ import type { CacheErrorCategory } from "../cache-error.js";
55
+ import { bufferToBase64, base64ToBuffer } from "./cf-base64.js";
56
+ import {
57
+ KV_MAX_KEY_BYTES,
58
+ KV_MIN_EXPIRATION_TTL,
59
+ kvKeyByteLength,
60
+ remainingCacheControl,
61
+ } from "./cf-kv-utils.js";
62
+ import {
63
+ TAG_MARKER_CACHE_PREFIX,
64
+ TAG_MARKER_ABSENT,
65
+ getTagMarkerMemo,
66
+ getTagMarkerInflight,
67
+ } from "./cf-tag-marker-memo.js";
48
68
 
49
69
  // ============================================================================
50
70
  // Constants
51
71
  // ============================================================================
72
+ //
73
+ // Header names, KV prefixes, and timeout/interval defaults live in
74
+ // cf-cache-constants.ts so collaborator modules can share them without a
75
+ // circular import back to this class. They are re-exported below so existing
76
+ // import paths (`../cf-cache-store`, `./cf-cache-store.js`) still resolve.
77
+ import {
78
+ CACHE_STALE_AT_HEADER,
79
+ CACHE_STATUS_HEADER,
80
+ CACHE_TAGS_HEADER,
81
+ CACHE_TAGGED_AT_HEADER,
82
+ TAG_MARKER_PREFIX,
83
+ CACHE_REVALIDATING_AT_HEADER,
84
+ CACHE_EXPIRES_AT_HEADER,
85
+ CACHE_ORIG_CC_HEADER,
86
+ MAX_REVALIDATION_INTERVAL,
87
+ EDGE_LOOKUP_TIMEOUT_MS,
88
+ EDGE_READ_TIMEOUT_MS,
89
+ KV_READ_TIMEOUT_MS,
90
+ } from "./cf-cache-constants.js";
91
+
92
+ // Re-export the public constants so consumers/tests importing them from
93
+ // cf-cache-store keep working after the move.
94
+ export {
95
+ CACHE_STALE_AT_HEADER,
96
+ CACHE_STATUS_HEADER,
97
+ CACHE_TAGS_HEADER,
98
+ CACHE_TAGGED_AT_HEADER,
99
+ TAG_MARKER_PREFIX,
100
+ CACHE_REVALIDATING_AT_HEADER,
101
+ MAX_REVALIDATION_INTERVAL,
102
+ EDGE_LOOKUP_TIMEOUT_MS,
103
+ EDGE_READ_TIMEOUT_MS,
104
+ KV_READ_TIMEOUT_MS,
105
+ };
106
+
107
+ // The tag-marker prefix/sentinel and per-request memo helpers (with their
108
+ // module-singleton WeakMaps) live in cf-tag-marker-memo.ts; imported above.
52
109
 
53
- /** Header storing timestamp when entry becomes stale */
54
- export const CACHE_STALE_AT_HEADER = "x-edge-cache-stale-at";
110
+ /**
111
+ * Per-request memo of the derived cache-key base URL.
112
+ *
113
+ * deriveBaseUrl() is a pure function of the live request URL, but keyToRequest
114
+ * calls it on EVERY cache operation (each segment/item get/set/delete, each
115
+ * KV->L1 promote, each tag-marker read), so a page composed of many cached
116
+ * entries re-parses the same request.url and re-runs the host validation tens
117
+ * of times. Keying by the request-context object collapses that to one derive
118
+ * per request. Keyed by ctx alone (not by store) because the derived value
119
+ * depends only on the request URL, not on which store asked.
120
+ */
121
+ const derivedBaseUrlMemo = new WeakMap<object, string>();
55
122
 
56
- /** Header storing cache status: HIT | REVALIDATING */
57
- export const CACHE_STATUS_HEADER = "x-edge-cache-status";
123
+ // Pure KV helpers (key byte-length limits, expirationTtl floor, stale-path
124
+ // Cache-Control recompute) live in cf-kv-utils.ts; imported above.
58
125
 
59
126
  /**
60
- * Header stashing the route author's original Cache-Control on L1 document
61
- * entries. putResponse/promoteResponseToL1 overwrite Cache-Control with a long
62
- * `max-age` so the CF Cache API retains the entry across the whole SWR window;
63
- * getResponse restores this original value before serving so the client and any
64
- * upstream CDN see the author's intended directive, not the internal edge TTL.
127
+ * Stores (by namespace) already warned about tag machinery configured without a
128
+ * KV namespace, so the warning fires once per process rather than per request
129
+ * (CFCacheStore is constructed per request).
65
130
  */
66
- const CACHE_ORIG_CC_HEADER = "x-edge-cache-orig-cc";
131
+ const warnedNoKvReadInvalidation = new Set<string>();
67
132
 
68
133
  /**
69
- * Maximum age in seconds for REVALIDATING status before allowing new revalidation.
70
- * After this period, a stale entry in REVALIDATING status will trigger revalidation again.
71
- * @internal
134
+ * Stores (by namespace) already warned about a tagInvalidationTtl below KV's
135
+ * expirationTtl floor, so the floor warning fires once per process rather than
136
+ * once per request (CFCacheStore is constructed per request).
72
137
  */
73
- export const MAX_REVALIDATION_INTERVAL = 30;
138
+ const warnedTagInvalidationTtlFloor = new Set<string>();
139
+
140
+ /**
141
+ * Stores (by namespace) already warned that tag invalidation is writing KV
142
+ * markers with no expiry (tagInvalidationTtl unset), so the unbounded-growth
143
+ * warning fires once per process rather than once per invalidateTags call
144
+ * (CFCacheStore is constructed per request; invalidateTags runs per marker
145
+ * batch). Distinct from the floor warning: that one only fires for a positive
146
+ * below-floor value, never for the unset (no-expiry) default that this bounds.
147
+ */
148
+ const warnedNoTagInvalidationTtl = new Set<string>();
74
149
 
75
150
  // ============================================================================
76
151
  // Types
77
152
  // ============================================================================
78
-
79
- // Re-exported from the canonical home so cf-cache-store consumers keep
80
- // importing `ExecutionContext` from this module without a second interface
81
- // drifting over time.
82
- export type { ExecutionContext } from "../../types/request-scope.js";
83
- import type { ExecutionContext } from "../../types/request-scope.js";
84
-
85
- /**
86
- * Minimal Cloudflare KV Namespace interface.
87
- * Avoids hard dependency on @cloudflare/workers-types.
88
- */
89
- export interface KVNamespace {
90
- get(key: string, options?: { type?: string }): Promise<any>;
91
- put(
92
- key: string,
93
- value: string,
94
- options?: { expirationTtl?: number },
95
- ): Promise<void>;
96
- delete(key: string): Promise<void>;
97
- }
153
+ //
154
+ // The shared public types (KVNamespace, CFCacheReadDebugEvent, CFCacheDebug,
155
+ // CFCacheStoreOptions) live in cf-cache-types.ts; imported and re-exported below
156
+ // so existing import paths still resolve. The private KV envelope interfaces
157
+ // stay here with the methods that read/write them.
158
+ import type {
159
+ KVNamespace,
160
+ CFCacheReadDebugEvent,
161
+ CFCacheDebug,
162
+ CFCacheStoreOptions,
163
+ } from "./cf-cache-types.js";
164
+ export type {
165
+ KVNamespace,
166
+ CFCacheReadDebugEvent,
167
+ CFCacheDebug,
168
+ CFCacheStoreOptions,
169
+ };
98
170
 
99
171
  /**
100
172
  * KV envelope for segment cache entries.
@@ -116,12 +188,45 @@ interface KVSegmentEnvelope {
116
188
  interface KVItemEnvelope {
117
189
  /** RSC-serialized return value */
118
190
  v: string;
119
- /** Handle data */
120
- h?: Record<string, Record<string, unknown[]>>;
191
+ /** RSC-encoded handle data (see handle-snapshot.ts encodeHandles) */
192
+ h?: string;
193
+ /** When entry becomes stale (ms epoch) */
194
+ s: number;
195
+ /** When entry hard-expires (ms epoch) */
196
+ e: number;
197
+ /** Cache tags (for distributed tag invalidation) */
198
+ t?: string[];
199
+ /** Timestamp when tags were attached (ms epoch) */
200
+ ta?: number;
201
+ }
202
+
203
+ /**
204
+ * KV envelope for PPR shell cache entries.
205
+ * @internal
206
+ */
207
+ interface KVShellEnvelope {
208
+ /** base64-encoded prelude bytes */
209
+ p: string;
210
+ /** postponed state JSON, or null (DATA variant — no holes) */
211
+ po: string | null;
212
+ /** React.version captured at prerender time */
213
+ rv: string;
214
+ /** Build version captured at prerender time (ShellCacheEntry.buildVersion) */
215
+ bv?: string;
216
+ /** createdAt (ms epoch) */
217
+ c: number;
121
218
  /** When entry becomes stale (ms epoch) */
122
219
  s: number;
123
220
  /** When entry hard-expires (ms epoch) */
124
221
  e: number;
222
+ /** Cache tags (for distributed tag invalidation) */
223
+ t?: string[];
224
+ /** Timestamp when tags were attached (ms epoch) */
225
+ ta?: number;
226
+ /** initialTheme the capture render was built with (resume theme fidelity) */
227
+ i?: string;
228
+ /** Capture data snapshot: recorded cache-store hits/writes for HIT parity */
229
+ sn?: import("../types.js").ShellSnapshotRecord[];
125
230
  }
126
231
 
127
232
  /**
@@ -135,108 +240,18 @@ interface KVResponseEnvelope {
135
240
  st: number;
136
241
  /** HTTP status text */
137
242
  stx: string;
138
- /** Serialized headers as key-value pairs */
243
+ /** Serialized headers as key-value pairs (client-facing; no internal headers) */
139
244
  hd: [string, string][];
140
245
  /** When entry becomes stale (ms epoch) */
141
246
  s: number;
142
247
  /** When entry hard-expires (ms epoch) */
143
248
  e: number;
249
+ /** Cache tags (for distributed tag invalidation) */
250
+ t?: string[];
251
+ /** Timestamp when tags were attached (ms epoch) */
252
+ ta?: number;
144
253
  }
145
254
 
146
- export interface CFCacheStoreOptions<TEnv = unknown> {
147
- /**
148
- * Cache namespace. If not provided, uses caches.default (recommended).
149
- * Only set this if you need isolated cache storage.
150
- */
151
- namespace?: string;
152
-
153
- /**
154
- * Base URL for cache keys.
155
- *
156
- * If not provided, derives from request hostname via requestContext:
157
- * - Production domains → uses `https://{hostname}/`
158
- * - Dev/preview (localhost, workers.dev, pages.dev) → uses internal fallback URL
159
- */
160
- baseUrl?: string;
161
-
162
- /** Default cache options */
163
- defaults?: CacheDefaults;
164
-
165
- /**
166
- * Cloudflare ExecutionContext for non-blocking cache writes.
167
- * Pass the `ctx` from your worker's fetch handler.
168
- *
169
- * @example
170
- * ```typescript
171
- * new CFCacheStore({ ctx: env.ctx })
172
- * ```
173
- */
174
- ctx: ExecutionContext;
175
-
176
- /**
177
- * Optional KV namespace for L2 cache persistence.
178
- *
179
- * When provided, KV acts as a global fallback behind the per-colo Cache API.
180
- * On L1 miss, KV is checked and hits are promoted back to L1.
181
- * On writes, data is persisted to both L1 and KV.
182
- *
183
- * @example
184
- * ```typescript
185
- * new CFCacheStore({ ctx: env.ctx, kv: env.CACHE_KV })
186
- * ```
187
- */
188
- kv?: KVNamespace;
189
-
190
- /**
191
- * Cache version string override. When this changes, all cached entries are
192
- * effectively invalidated (new keys won't match old entries).
193
- *
194
- * Defaults to the auto-generated VERSION from the `@rangojs/router:version` virtual module.
195
- * Only set this if you need a custom versioning strategy.
196
- */
197
- version?: string;
198
-
199
- /**
200
- * Custom key generator applied to all cache operations.
201
- * Receives the full RequestContext (including env) and the default-generated key.
202
- * Return value becomes the final cache key (unless route overrides with `key` option).
203
- *
204
- * @example Using headers for user segmentation
205
- * ```typescript
206
- * keyGenerator: (ctx, defaultKey) => {
207
- * const segment = ctx.request.headers.get('x-user-segment') || 'default';
208
- * return `${segment}:${defaultKey}`;
209
- * }
210
- * ```
211
- *
212
- * @example Using env bindings for multi-region
213
- * ```typescript
214
- * keyGenerator: (ctx, defaultKey) => {
215
- * const region = ctx.env.REGION || 'us';
216
- * return `${region}:${defaultKey}`;
217
- * }
218
- * ```
219
- *
220
- * @example Using cookies for locale-aware caching
221
- * ```typescript
222
- * keyGenerator: (ctx, defaultKey) => {
223
- * const locale = cookies().get('locale')?.value || 'en';
224
- * return `${locale}:${defaultKey}`;
225
- * }
226
- * ```
227
- */
228
- keyGenerator?: (
229
- ctx: RequestContext<TEnv>,
230
- defaultKey: string,
231
- ) => string | Promise<string>;
232
- }
233
-
234
- /**
235
- * Cache status values for the x-edge-cache-status header.
236
- * @internal
237
- */
238
- export type CacheStatus = "HIT" | "REVALIDATING";
239
-
240
255
  // ============================================================================
241
256
  // CFCacheStore Implementation
242
257
  // ============================================================================
@@ -249,10 +264,17 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
249
264
  ) => string | Promise<string>;
250
265
 
251
266
  private readonly namespace?: string;
252
- private readonly baseUrl: string;
267
+ private readonly explicitBaseUrl?: string;
253
268
  private readonly waitUntil?: (fn: () => Promise<void>) => void;
254
269
  private readonly version?: string;
270
+ private readonly edgeLookupTimeoutMs: number;
271
+ private readonly edgeReadTimeoutMs: number;
272
+ private readonly kvReadTimeoutMs: number;
273
+ private readonly debug?: (event: CFCacheReadDebugEvent) => void;
255
274
  private readonly kv?: KVNamespace;
275
+ private readonly onRevalidateTag?: (tags: string[]) => Promise<void>;
276
+ private readonly tagInvalidationTtl?: number;
277
+ private readonly tagCacheTtl: number;
256
278
 
257
279
  constructor(options: CFCacheStoreOptions<TEnv>) {
258
280
  if (!options.ctx) {
@@ -264,52 +286,200 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
264
286
  }
265
287
 
266
288
  this.namespace = options.namespace;
267
- this.baseUrl = options.baseUrl ?? this.deriveBaseUrl();
289
+ // Base URL is resolved lazily per cache operation (see resolveBaseUrl).
290
+ // The store is constructed before the per-request context ALS is entered
291
+ // (the cache factory runs ahead of runWithRequestContext in the handler),
292
+ // so deriving the host here would always miss the request and fall back to
293
+ // the internal host. Only the explicit override can be captured eagerly.
294
+ this.explicitBaseUrl = options.baseUrl;
268
295
  this.defaults = options.defaults;
269
296
  this.version = options.version ?? VERSION;
297
+ // Coalesce only finite numbers to the override; a non-finite value (NaN from
298
+ // `Number(env.UNSET)`, or Infinity) would otherwise sail past `?? DEFAULT`
299
+ // (which only replaces null/undefined) into setTimeout, where NaN/Infinity
300
+ // are spec-coerced to ~1ms and silently turn the budget into a near-100%
301
+ // false-miss on that tier. A genuine finite 0 or negative still passes
302
+ // through and disables the budget per the documented `<= 0` contract.
303
+ const finiteBudget = (
304
+ value: number | undefined,
305
+ fallback: number,
306
+ ): number =>
307
+ typeof value === "number" && Number.isFinite(value) ? value : fallback;
308
+ this.edgeLookupTimeoutMs = finiteBudget(
309
+ options.edgeLookupTimeoutMs,
310
+ EDGE_LOOKUP_TIMEOUT_MS,
311
+ );
312
+ this.edgeReadTimeoutMs = finiteBudget(
313
+ options.edgeReadTimeoutMs,
314
+ EDGE_READ_TIMEOUT_MS,
315
+ );
316
+ this.kvReadTimeoutMs = finiteBudget(
317
+ options.kvReadTimeoutMs,
318
+ KV_READ_TIMEOUT_MS,
319
+ );
320
+ this.debug =
321
+ options.debug === true
322
+ ? (event) =>
323
+ console.log(`[CFCacheStore:debug] ${JSON.stringify(event)}`)
324
+ : typeof options.debug === "function"
325
+ ? options.debug
326
+ : undefined;
270
327
  this.keyGenerator = options.keyGenerator;
271
328
  this.waitUntil = (fn) => options.ctx.waitUntil(fn());
272
329
  this.kv = options.kv;
330
+ this.onRevalidateTag = options.onRevalidateTag;
331
+ // tagInvalidationTtl feeds KV's expirationTtl, which CF rejects below
332
+ // KV_MIN_EXPIRATION_TTL (60s) -- a too-small finite value would make EVERY
333
+ // marker write throw and break ALL invalidation. Floor it (and warn once);
334
+ // a non-finite/non-positive value falls back to the no-expiry default
335
+ // (markers persist) rather than silently sailing a NaN into expirationTtl.
336
+ this.tagInvalidationTtl = this.sanitizeTagInvalidationTtl(
337
+ options.tagInvalidationTtl,
338
+ );
339
+ // tagCacheTtl gates the L1 marker cache via `> 0`. A non-finite value (NaN
340
+ // from `Number(env.UNSET)`) is not null/undefined, so `?? 0` would let it
341
+ // through and silently disable the cache while reading as "configured".
342
+ // finiteBudget coerces non-finite/null/undefined to 0; the `> 0` guard then
343
+ // collapses a finite non-positive value to the documented 0 = disabled.
344
+ const tagCacheTtl = finiteBudget(options.tagCacheTtl, 0);
345
+ this.tagCacheTtl = tagCacheTtl > 0 ? tagCacheTtl : 0;
346
+
347
+ // Read-side tag invalidation requires KV: isGloballyInvalidated() compares an
348
+ // entry's taggedAt against the per-tag KV marker and short-circuits to "not
349
+ // invalidated" when no KV namespace is configured. A consumer who wires the
350
+ // tag machinery (tagCacheTtl for L1 markers, or onRevalidateTag for CDN purge)
351
+ // but omits kv gets only the purge fired - marker writes are skipped without
352
+ // kv - yet every tagged read still serves stale data with no other signal.
353
+ // Surface that misconfiguration.
354
+ if (!this.kv && (this.tagCacheTtl > 0 || this.onRevalidateTag)) {
355
+ this.warnOncePerNamespace(
356
+ warnedNoKvReadInvalidation,
357
+ `[CFCacheStore] tagCacheTtl/onRevalidateTag is configured without a KV ` +
358
+ `namespace, so tag invalidation has NO read-side effect: tagged reads ` +
359
+ `are never treated as invalidated and serve stale data. Configure ` +
360
+ `{ kv } for distributed tag invalidation.`,
361
+ );
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Warn about a namespace-scoped misconfiguration once per namespace per
367
+ * isolate. `seen` is the module-level Set for that message family -- Sets
368
+ * are module-level (not instance fields) so re-constructed stores in the
369
+ * same isolate don't re-warn.
370
+ * @internal
371
+ */
372
+ private warnOncePerNamespace(seen: Set<string>, message: string): void {
373
+ const id = this.namespace ?? "default";
374
+ if (seen.has(id)) return;
375
+ seen.add(id);
376
+ console.warn(message);
377
+ }
378
+
379
+ /**
380
+ * Validate a consumer-supplied tagInvalidationTtl against CF KV's expirationTtl
381
+ * floor. A finite value below KV_MIN_EXPIRATION_TTL is raised to it (with a
382
+ * one-time warning) so invalidation keeps working instead of every marker
383
+ * write throwing; a non-finite or non-positive value returns undefined (the
384
+ * no-expiry default). The warning still notes the sizing rule: the TTL must
385
+ * exceed the largest entry TTL+SWR or invalidated entries can resurrect.
386
+ * @internal
387
+ */
388
+ private sanitizeTagInvalidationTtl(
389
+ value: number | undefined,
390
+ ): number | undefined {
391
+ if (value == null) return undefined;
392
+ if (!Number.isFinite(value) || value <= 0) return undefined;
393
+ if (value < KV_MIN_EXPIRATION_TTL) {
394
+ this.warnOncePerNamespace(
395
+ warnedTagInvalidationTtlFloor,
396
+ `[CFCacheStore] tagInvalidationTtl ${value} is below Cloudflare KV's ` +
397
+ `${KV_MIN_EXPIRATION_TTL}s expirationTtl floor; raising to ` +
398
+ `${KV_MIN_EXPIRATION_TTL}. It must still exceed your largest entry ` +
399
+ `TTL+SWR or invalidated entries can resurrect when the marker expires.`,
400
+ );
401
+ return KV_MIN_EXPIRATION_TTL;
402
+ }
403
+ return value;
404
+ }
405
+
406
+ /**
407
+ * Emit a debug event if `debug` is enabled. Swallows sink errors so a faulty
408
+ * debug callback can never break a cache read.
409
+ * @internal
410
+ */
411
+ private emitDebug(event: CFCacheReadDebugEvent): void {
412
+ if (!this.debug) return;
413
+ try {
414
+ this.debug(event);
415
+ } catch {
416
+ // A broken debug sink must not affect the request.
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Resolve the cache-key base URL for the current cache operation.
422
+ * Prefers an explicit `baseUrl` option; otherwise derives it from the live
423
+ * request. Called per operation (from keyToRequest), which runs inside the
424
+ * request-context ALS, so deriveBaseUrl sees the request and can use the
425
+ * production host instead of the internal fallback.
426
+ * @internal
427
+ */
428
+ private resolveBaseUrl(): string {
429
+ return this.explicitBaseUrl ?? this.deriveBaseUrl();
273
430
  }
274
431
 
275
432
  /**
276
433
  * Derive base URL from request hostname via requestContext.
277
434
  * Uses internal fallback for dev/preview environments and untrusted hostnames.
435
+ * Must run inside the request context (invoked lazily via resolveBaseUrl).
278
436
  * @internal
279
437
  */
280
438
  private deriveBaseUrl(): string {
281
- const fallback = "https://rsc-cache.internal.com/";
439
+ const fallback = "https://rsc-dummy-host-1.com/";
282
440
 
283
441
  const ctx = _getRequestContext();
284
442
  if (!ctx?.request) {
285
443
  return fallback;
286
444
  }
287
445
 
288
- try {
289
- const url = new URL(ctx.request.url);
290
- const hostname = url.hostname;
446
+ // The result is deterministic per request, but keyToRequest calls this on
447
+ // every cache operation; memoize per request context (see derivedBaseUrlMemo).
448
+ const memoized = derivedBaseUrlMemo.get(ctx);
449
+ if (memoized !== undefined) {
450
+ return memoized;
451
+ }
291
452
 
292
- // Use fallback for dev/preview environments
293
- if (
294
- hostname === "localhost" ||
295
- hostname === "127.0.0.1" ||
296
- hostname.endsWith(".workers.dev") ||
297
- hostname.endsWith(".pages.dev")
298
- ) {
299
- return fallback;
300
- }
453
+ const derived = ((): string => {
454
+ try {
455
+ const url = new URL(ctx.request.url);
456
+ const hostname = url.hostname;
457
+
458
+ // Use fallback for dev/preview environments
459
+ if (
460
+ hostname === "localhost" ||
461
+ hostname === "127.0.0.1" ||
462
+ hostname.endsWith(".workers.dev") ||
463
+ hostname.endsWith(".pages.dev")
464
+ ) {
465
+ return fallback;
466
+ }
467
+
468
+ // Validate hostname: must be a valid domain (alphanumeric, hyphens, dots)
469
+ // to prevent host header injection into cache keys
470
+ if (!/^[a-zA-Z0-9.-]+$/.test(hostname) || hostname.length > 253) {
471
+ return fallback;
472
+ }
301
473
 
302
- // Validate hostname: must be a valid domain (alphanumeric, hyphens, dots)
303
- // to prevent host header injection into cache keys
304
- if (!/^[a-zA-Z0-9.-]+$/.test(hostname) || hostname.length > 253) {
474
+ // Use actual hostname for production
475
+ return `https://${hostname}/`;
476
+ } catch {
305
477
  return fallback;
306
478
  }
479
+ })();
307
480
 
308
- // Use actual hostname for production
309
- return `https://${hostname}/`;
310
- } catch {
311
- return fallback;
312
- }
481
+ derivedBaseUrlMemo.set(ctx, derived);
482
+ return derived;
313
483
  }
314
484
 
315
485
  /**
@@ -323,10 +493,297 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
323
493
  return caches.default;
324
494
  }
325
495
 
496
+ /**
497
+ * Race an async cache read against a latency budget. Shared by all three read
498
+ * tiers (L1 match, L1 body, L2/KV) so the timeout policy lives in one place:
499
+ * on timeout it returns `{ value: undefined, timedOut: true }` and logs
500
+ * `${label} exceeded ${budgetMs}ms; treating as miss`; the abandoned read is
501
+ * left to settle in the background (late rejection swallowed) rather than
502
+ * aborted, since the underlying CF primitives expose no cancellation. A budget
503
+ * <= 0 disables the bound and awaits the read directly. `read` is a thunk so
504
+ * the disabled path and the raced path start the read identically.
505
+ * @internal
506
+ */
507
+ private async readWithTimeout<T>(
508
+ read: () => Promise<T>,
509
+ budgetMs: number,
510
+ label: string,
511
+ ): Promise<{ value: T | undefined; timedOut: boolean }> {
512
+ if (budgetMs <= 0) return { value: await read(), timedOut: false };
513
+
514
+ let timer: ReturnType<typeof setTimeout> | undefined;
515
+ const timeout = new Promise<{ timedOut: true }>((resolve) => {
516
+ timer = setTimeout(() => resolve({ timedOut: true }), budgetMs);
517
+ });
518
+ try {
519
+ const readPromise = read();
520
+ // The losing branch keeps running; ensure a late rejection can't surface
521
+ // as an unhandled rejection once we've stopped awaiting it.
522
+ readPromise.catch(() => {});
523
+ const result = await Promise.race([
524
+ readPromise.then((value) => ({ timedOut: false as const, value })),
525
+ timeout,
526
+ ]);
527
+ if (result.timedOut) {
528
+ console.warn(
529
+ `[CFCacheStore] ${label} exceeded ${budgetMs}ms; treating as miss`,
530
+ );
531
+ return { value: undefined, timedOut: true };
532
+ }
533
+ return { value: result.value, timedOut: false };
534
+ } finally {
535
+ if (timer) clearTimeout(timer);
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Read from the L1 edge cache under the edgeLookupTimeoutMs budget. A `match`
541
+ * slower than the budget is abandoned and reported as a miss
542
+ * (`{ response: undefined, timedOut: true }`) so a degraded colo cannot stall
543
+ * the request; callers fall through to their normal miss path (L2/KV or
544
+ * render). The `timedOut` flag lets callers distinguish an abandoned slow
545
+ * match from a genuine miss for debug reporting; `error` is set when the
546
+ * `match` itself rejected (a transient L1 infra error) so the caller can
547
+ * report it as cache-read while still degrading to L2/KV -- distinct from a
548
+ * genuine miss (no entry), which sets neither flag.
549
+ * @internal
550
+ */
551
+ private async matchWithTimeout(
552
+ cache: Cache,
553
+ request: Request,
554
+ ): Promise<{
555
+ response: Response | undefined;
556
+ timedOut: boolean;
557
+ error?: unknown;
558
+ }> {
559
+ let matchError: unknown;
560
+ const { value, timedOut } = await this.readWithTimeout(
561
+ // A fast match rejection is caught at the thunk and reported as a miss
562
+ // (response undefined), so the caller falls through to L2/KV rather than
563
+ // escaping to the outer catch -- symmetric with the body-read thunk. The
564
+ // error is captured (not swallowed) so the caller can surface it via
565
+ // onError as a cache-read degradation.
566
+ () =>
567
+ cache.match(request).catch((e) => {
568
+ matchError = e;
569
+ return undefined;
570
+ }),
571
+ this.edgeLookupTimeoutMs,
572
+ "edge cache lookup",
573
+ );
574
+ return { response: value, timedOut, error: matchError };
575
+ }
576
+
577
+ /**
578
+ * Read and JSON-parse a matched L1 Response's body under the edgeReadTimeoutMs
579
+ * budget. CF resolves `match()` with a lazily-streamed body, so the latency
580
+ * tail surfaces here -- after matchWithTimeout has already passed -- not in the
581
+ * match itself. On timeout `undefined` is returned so the caller falls through
582
+ * to L2/KV or render.
583
+ * @internal
584
+ */
585
+ private async readJsonWithTimeout<T>(
586
+ response: Response,
587
+ ): Promise<{ value: T | undefined; errored: boolean; error?: unknown }> {
588
+ // A FAST json() rejection (a corrupt body, or a foreign 200 non-JSON
589
+ // response that collided on this key) is caught at the thunk and turned into
590
+ // a miss, so the caller falls through to L2/KV exactly like a body-timeout
591
+ // -- instead of escaping to get()/getItem()'s outer catch, which returns
592
+ // null WITHOUT ever consulting KV. The catch lives here, not in
593
+ // readWithTimeout, so the L2/KV tier keeps propagating a genuine kv.get
594
+ // rejection to its own error sink. The `errored` flag lets the caller emit a
595
+ // distinct "body-error" debug outcome rather than masquerading as a timeout.
596
+ // On a TIMEOUT the json() promise is still pending, so the catch has not
597
+ // fired: errored stays false and the outcome is correctly a body-timeout. A
598
+ // late rejection after the timeout only mutates the closure flag, which the
599
+ // already-returned object no longer reads.
600
+ let errored = false;
601
+ let error: unknown;
602
+ const { value } = await this.readWithTimeout<T | undefined>(
603
+ () =>
604
+ (response.json() as Promise<T>).catch((e) => {
605
+ errored = true;
606
+ error = e;
607
+ return undefined;
608
+ }),
609
+ this.edgeReadTimeoutMs,
610
+ "edge cache body read",
611
+ );
612
+ return { value, errored, error };
613
+ }
614
+
615
+ /**
616
+ * Self-heal a corrupt L1 entry, then return the fall-through result. Reports
617
+ * the corruption as cache-corrupt (so an onError consumer sees it distinctly
618
+ * from a transient outage), runs the caller's L2/KV fall-through, and evicts
619
+ * the faulty per-colo entry ONLY when that fall-through found no good copy.
620
+ *
621
+ * The conditional evict is the load-bearing detail: when KV DOES serve a copy,
622
+ * kvGet* has already scheduled a same-key promote (`cache.put`); an eager
623
+ * `cache.delete` here would race that put with no CF Cache API ordering
624
+ * guarantee and could clobber the freshly-restored entry. So in that case we
625
+ * lean on #558's heal-by-overwrite (the non-suppressed fall-through promotes /
626
+ * a fresh render re-`set`s over the bad entry) and skip the delete. Only when
627
+ * this request's fall-through found no copy (=== null) is the eager evict
628
+ * scheduled -- useful then, since nothing else will overwrite the poison entry.
629
+ * A null fall-through can also be a KV-read TIMEOUT rather than a genuine miss:
630
+ * a concurrent request that read KV successfully may be promoting the same key,
631
+ * and this evict could race it. That is benign -- the worst case is one wasted
632
+ * colo-local promote, never a wrong served value, and the next read self-heals
633
+ * -- so we accept it rather than suppressing the evict on a timeout (which
634
+ * would strand the poison entry when KV really is empty). The evict is
635
+ * non-blocking (waitUntil) so it never adds latency to the degraded read.
636
+ * @internal
637
+ */
638
+ private async healCorruptL1<T>(
639
+ cache: Cache,
640
+ request: Request,
641
+ error: unknown,
642
+ label: string,
643
+ fallThrough: () => Promise<T | null>,
644
+ ): Promise<T | null> {
645
+ reportCacheError(
646
+ error ?? new Error("corrupt/partial L1 body"),
647
+ "cache-corrupt",
648
+ `[CFCacheStore] ${label}: corrupt L1 body`,
649
+ );
650
+ const result = await fallThrough();
651
+ if (result === null) {
652
+ const evict = (): Promise<void> =>
653
+ reportingAsync(
654
+ () => cache.delete(request),
655
+ "cache-delete",
656
+ `[CFCacheStore] ${label}: evict corrupt L1`,
657
+ );
658
+ if (this.waitUntil) this.waitUntil(evict);
659
+ else void evict();
660
+ }
661
+ return result;
662
+ }
663
+
664
+ /**
665
+ * Re-put a stale L1 entry marked REVALIDATING, so concurrent requests serve it
666
+ * without each triggering a revalidation. Shared by get()/getItem().
667
+ *
668
+ * The write is NON-BLOCKING (waitUntil) and best-effort by design:
669
+ * - It runs in waitUntil, so it never adds the put latency to the served stale
670
+ * read and a put failure can never turn that good read into a miss. The put
671
+ * is still initiated synchronously (this.waitUntil invokes its callback
672
+ * immediately), so concurrent readers see the marker land at the same time an
673
+ * awaited write would -- awaiting only blocks the current request.
674
+ * - The background revalidation's fresh set() is gated behind a full re-render,
675
+ * so it lands well after this put; a stale-clobbers-fresh race would require
676
+ * this single put to be slower than that entire render+set, and self-heals
677
+ * within MAX_REVALIDATION_INTERVAL.
678
+ *
679
+ * Cache-Control is recomputed to the REMAINING ttl from the stored hard-expiry
680
+ * deadline (see remainingCacheControl), not copied from the original
681
+ * full-window header -- copying it would restart CF retention on every re-arm
682
+ * and pin a perpetually-failing entry past hard-expiry. A legacy/tampered entry
683
+ * without a valid deadline floors to max-age=1 and self-heals via KV.
684
+ * @internal
685
+ */
686
+ private markRevalidating(
687
+ cache: Cache,
688
+ request: Request,
689
+ sourceHeaders: Headers,
690
+ status: number,
691
+ body: string,
692
+ ): void {
693
+ const reputNow = Date.now();
694
+ const headers = new Headers(sourceHeaders);
695
+ headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
696
+ headers.set(CACHE_REVALIDATING_AT_HEADER, String(reputNow));
697
+ headers.set("Cache-Control", remainingCacheControl(headers, reputNow));
698
+ const markerResponse = new Response(body, { status, headers });
699
+ const write = async (): Promise<void> => {
700
+ try {
701
+ await cache.put(request, markerResponse);
702
+ } catch {
703
+ // Best-effort: a failed marker write must not affect the served read;
704
+ // the entry simply re-arms on the next stale read.
705
+ }
706
+ };
707
+ if (this.waitUntil) this.waitUntil(write);
708
+ else void write();
709
+ }
710
+
711
+ /**
712
+ * Document-tier counterpart of markRevalidating for getResponse's herd guard.
713
+ * The segment/item tiers JSON-parse the body, so they re-put with a string
714
+ * body; document bodies are streamed verbatim, so we re-put with a CLONED
715
+ * response body (`response.clone()`) supplied by the caller -- the original
716
+ * body still streams to the client while the marker carries the clone. Same
717
+ * REVALIDATING status header, same revalidating-at stamp, same
718
+ * remainingCacheControl re-put math as markRevalidating, so the document tier
719
+ * suppresses concurrent revalidation for the identical MAX_REVALIDATION_INTERVAL
720
+ * window the segment tier does. Best-effort and non-blocking: a failed marker
721
+ * write must not affect the served stale read.
722
+ * @internal
723
+ */
724
+ private markResponseRevalidating(
725
+ cache: Cache,
726
+ request: Request,
727
+ clonedResponse: Response,
728
+ ): void {
729
+ const reputNow = Date.now();
730
+ const headers = new Headers(clonedResponse.headers);
731
+ headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
732
+ headers.set(CACHE_REVALIDATING_AT_HEADER, String(reputNow));
733
+ headers.set("Cache-Control", remainingCacheControl(headers, reputNow));
734
+ const markerResponse = new Response(clonedResponse.body, {
735
+ status: clonedResponse.status,
736
+ statusText: clonedResponse.statusText,
737
+ headers,
738
+ });
739
+ const write = async (): Promise<void> => {
740
+ try {
741
+ await cache.put(request, markerResponse);
742
+ } catch {
743
+ // Best-effort: see markRevalidating.
744
+ }
745
+ };
746
+ if (this.waitUntil) this.waitUntil(write);
747
+ else void write();
748
+ }
749
+
326
750
  // ============================================================================
327
751
  // Segment Cache Methods
328
752
  // ============================================================================
329
753
 
754
+ /**
755
+ * Guard the segment tier against a `keyGenerator` that returns a key colliding
756
+ * with a reserved tag-marker namespace: `__tag__/` (the KV marker key) or
757
+ * `__tagmarker__/` (the L1 Cache API marker request). The item/doc tiers are
758
+ * internally prefixed (`fn:`/`doc:`) so only the bare segment key can collide;
759
+ * a collision would let a segment write clobber - or a segment read/delete
760
+ * evict - a live tag marker, silently breaking invalidation. Report loudly
761
+ * (so a misconfigured keyGenerator surfaces immediately) and treat the segment
762
+ * operation as a miss/no-op rather than corrupting the marker namespace.
763
+ * @internal
764
+ */
765
+ private isReservedSegmentKey(
766
+ key: string,
767
+ category: CacheErrorCategory,
768
+ ): boolean {
769
+ const reserved = key.startsWith(TAG_MARKER_PREFIX)
770
+ ? TAG_MARKER_PREFIX
771
+ : key.startsWith(TAG_MARKER_CACHE_PREFIX)
772
+ ? TAG_MARKER_CACHE_PREFIX
773
+ : null;
774
+ if (!reserved) return false;
775
+ reportCacheError(
776
+ new Error(
777
+ `segment key "${key}" collides with the reserved "${reserved}" ` +
778
+ `tag-marker namespace; the operation is ignored. Fix the store ` +
779
+ `keyGenerator so it does not produce keys with this prefix.`,
780
+ ),
781
+ category,
782
+ "[CFCacheStore] reserved key",
783
+ );
784
+ return true;
785
+ }
786
+
330
787
  /**
331
788
  * Get cached entry data by key.
332
789
  *
@@ -339,48 +796,219 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
339
796
  * KV hits are promoted to L1 in the background.
340
797
  */
341
798
  async get(key: string): Promise<CacheGetResult | null> {
799
+ if (this.isReservedSegmentKey(key, "cache-read")) return null;
342
800
  try {
343
801
  const cache = await this.getCache();
344
802
  const request = this.keyToRequest(key);
345
- const response = await cache.match(request);
803
+ const matchStart = Date.now();
804
+ const {
805
+ response,
806
+ timedOut,
807
+ error: matchError,
808
+ } = await this.matchWithTimeout(cache, request);
809
+ const matchMs = Date.now() - matchStart;
346
810
 
347
811
  if (!response) {
812
+ // A transient L1 match error (matchError set) is reported as cache-read
813
+ // but, like a genuine miss or an abandoned slow match (timedOut), still
814
+ // degrades to L2/KV rather than failing the read.
815
+ if (matchError)
816
+ reportCacheError(
817
+ matchError,
818
+ "cache-read",
819
+ "[CFCacheStore] get L1 match",
820
+ );
821
+ if (this.debug)
822
+ this.emitDebug({
823
+ op: "get",
824
+ key,
825
+ // A match REJECTION (matchError) is distinct from a genuine absence:
826
+ // surface it as match-error so debug agrees with the cache-read
827
+ // already routed to onError, instead of masquerading as l1-miss.
828
+ outcome: matchError
829
+ ? "match-error"
830
+ : timedOut
831
+ ? "match-timeout"
832
+ : "l1-miss",
833
+ matchMs,
834
+ });
348
835
  return this.kvGetSegment(key);
349
836
  }
350
837
 
838
+ // A non-200 entry (a cached error response, or a foreign response that
839
+ // landed on this key) is not valid segment data; treat it as a miss
840
+ // rather than JSON-parsing garbage and serving it as a hit.
841
+ if (response.status !== 200) {
842
+ if (this.debug)
843
+ this.emitDebug({
844
+ op: "get",
845
+ key,
846
+ outcome: "non-200",
847
+ status: response.status,
848
+ matchMs,
849
+ });
850
+ // Degraded fall-through: suppress revalidation so a broken L1 entry hit
851
+ // concurrently serves KV-stale, not a herd. See kvGetSegment.
852
+ return this.kvGetSegment(key, { suppressRevalidate: true });
853
+ }
854
+
855
+ // Tag invalidation: an entry whose tags were invalidated after it was
856
+ // cached is treated as a miss, so the next render re-populates it. We
857
+ // return null (re-render locally) rather than falling through to KV. In
858
+ // the common case the L1 entry and its KV twin were written together with
859
+ // the same taggedAt, so kvGetSegment's own tag check would miss too and a
860
+ // fall-through is pure cost. The tiers CAN diverge -- another colo may have
861
+ // already re-rendered and written a fresher KV envelope -- in which case a
862
+ // fall-through could serve that copy instead of re-rendering here.
863
+ // Capturing that cross-colo optimization is a deferred follow-up, not a
864
+ // correctness gap: this colo's next read after its own re-render self-heals.
865
+ const tagInfo = this.readTagInfo(response.headers);
866
+ // Measure the marker-resolution tail (memo -> L1 marker cache -> KV) only
867
+ // when debug is on, so the hot path pays nothing. It is the serial read
868
+ // that sits between matchMs and bodyReadMs for a tagged entry.
869
+ const markerStart = this.debug ? Date.now() : 0;
870
+ const invalidated = await this.isGloballyInvalidated(
871
+ tagInfo.tags,
872
+ tagInfo.taggedAt,
873
+ );
874
+ const markerMs = this.debug ? Date.now() - markerStart : undefined;
875
+ if (invalidated) {
876
+ if (this.debug)
877
+ this.emitDebug({
878
+ op: "get",
879
+ key,
880
+ outcome: "tag-invalidated",
881
+ status: response.status,
882
+ matchMs,
883
+ markerMs,
884
+ });
885
+ return null;
886
+ }
887
+
351
888
  // Read status headers
352
889
  const status = response.headers.get(CACHE_STATUS_HEADER);
353
- const age = Number(response.headers.get("age") ?? "0");
354
890
  const staleAt = Number(
355
891
  response.headers.get(CACHE_STALE_AT_HEADER) ?? "0",
356
892
  );
893
+ const revalidatingAt = Number(
894
+ response.headers.get(CACHE_REVALIDATING_AT_HEADER) ?? "0",
895
+ );
357
896
 
358
- const isStale = staleAt > 0 && Date.now() > staleAt;
897
+ const now = Date.now();
898
+ const isStale = staleAt > 0 && now > staleAt;
899
+ // Recency comes from our explicit revalidating-at stamp, not CF's `Age`
900
+ // header (see CACHE_REVALIDATING_AT_HEADER). An absent/zero stamp counts
901
+ // as "not recent" so a dropped revalidation re-arms instead of pinning.
359
902
  const isRevalidating =
360
- status === "REVALIDATING" && age < MAX_REVALIDATION_INTERVAL;
903
+ status === "REVALIDATING" &&
904
+ revalidatingAt > 0 &&
905
+ now - revalidatingAt < MAX_REVALIDATION_INTERVAL * 1000;
906
+
907
+ // Single emitter for the post-header L1 outcomes. Undefined (so the event
908
+ // object is never allocated) when debug is off; the informational-only
909
+ // `age` header is read lazily inside for the same reason.
910
+ const debugRead = this.debug
911
+ ? (
912
+ outcome: CFCacheReadDebugEvent["outcome"],
913
+ bodyReadMs: number,
914
+ shouldRevalidate?: boolean,
915
+ ) =>
916
+ this.emitDebug({
917
+ op: "get",
918
+ key,
919
+ outcome,
920
+ status: response.status,
921
+ cacheStatus: status,
922
+ staleAt,
923
+ revalidatingAt,
924
+ ageHeader: response.headers.get("age"),
925
+ isStale,
926
+ isRevalidating,
927
+ shouldRevalidate,
928
+ matchMs,
929
+ markerMs,
930
+ bodyReadMs,
931
+ })
932
+ : undefined;
361
933
 
362
934
  // Case 1: Fresh or already being revalidated - just return data
363
935
  if (!isStale || isRevalidating) {
364
- const data = (await response.json()) as CachedEntryData;
936
+ const bodyStart = Date.now();
937
+ const {
938
+ value: data,
939
+ errored,
940
+ error,
941
+ } = await this.readJsonWithTimeout<CachedEntryData>(response);
942
+ const bodyReadMs = Date.now() - bodyStart;
943
+ if (data === undefined) {
944
+ debugRead?.(errored ? "body-error" : "body-timeout", bodyReadMs);
945
+ // A body-ERROR (corrupt/foreign body) self-heals via healCorruptL1:
946
+ // report cache-corrupt, fall through to L2/KV (which overwrites the
947
+ // bad entry), and evict only if KV had no good copy to promote. A
948
+ // body-TIMEOUT is a degraded read of a likely-valid entry: leave it
949
+ // intact and suppress revalidation so a stalling colo cannot herd.
950
+ if (errored)
951
+ return this.healCorruptL1(cache, request, error, "get", () =>
952
+ this.kvGetSegment(key, { suppressRevalidate: false }),
953
+ );
954
+ return this.kvGetSegment(key, { suppressRevalidate: true });
955
+ }
956
+ debugRead?.(
957
+ isRevalidating ? "l1-revalidating-guarded" : "l1-fresh",
958
+ bodyReadMs,
959
+ false,
960
+ );
365
961
  return { data, shouldRevalidate: false };
366
962
  }
367
963
 
368
- // Case 2: Stale and needs revalidation - atomically mark REVALIDATING
369
- const [b1, b2] = response.body!.tee();
370
-
371
- const headers = new Headers(response.headers);
372
- headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
964
+ // Case 2: Stale and needs revalidation.
965
+ // Read the body under the edge-read budget BEFORE writing the REVALIDATING
966
+ // marker. CF can resolve match() fast but stall the body stream; the prior
967
+ // approach teed the stream and awaited cache.put(b1) first, which blocked
968
+ // on that same stalled stream so the read budget could never fire on a
969
+ // stale hit. Reading first bounds the stall and lets us skip marking an
970
+ // entry we could not even read.
971
+ const bodyStart = Date.now();
972
+ const {
973
+ value: data,
974
+ errored,
975
+ error,
976
+ } = await this.readJsonWithTimeout<CachedEntryData>(response);
977
+ const bodyReadMs = Date.now() - bodyStart;
978
+ if (data === undefined) {
979
+ debugRead?.(errored ? "body-error" : "body-timeout", bodyReadMs);
980
+ // Heal + conditionally evict a body-error, suppress a body-timeout; see
981
+ // Case 1.
982
+ if (errored)
983
+ return this.healCorruptL1(
984
+ cache,
985
+ request,
986
+ error,
987
+ "get(revalidating)",
988
+ () => this.kvGetSegment(key, { suppressRevalidate: false }),
989
+ );
990
+ return this.kvGetSegment(key, { suppressRevalidate: true });
991
+ }
373
992
 
374
- // Blocking write - must complete before returning to prevent race
375
- await cache.put(
993
+ // Mark REVALIDATING so concurrent requests don't all revalidate, then
994
+ // return the stale data. The marker write is non-blocking and best-effort
995
+ // (see markRevalidating) -- it must not add latency to, or fail, the served
996
+ // stale read.
997
+ this.markRevalidating(
998
+ cache,
376
999
  request,
377
- new Response(b1, { status: response.status, headers }),
1000
+ response.headers,
1001
+ response.status,
1002
+ JSON.stringify(data),
378
1003
  );
379
1004
 
380
- const data = (await new Response(b2).json()) as CachedEntryData;
1005
+ debugRead?.("l1-stale-revalidate", bodyReadMs, true);
381
1006
  return { data, shouldRevalidate: true };
382
1007
  } catch (error) {
383
- console.error("[CFCacheStore] get failed:", error);
1008
+ // reportCacheError logs and routes to onError (cache-read); the debug
1009
+ // emit is the separate wrangler-tail signal. Keep both observability paths.
1010
+ reportCacheError(error, "cache-read", "[CFCacheStore] get");
1011
+ if (this.debug) this.emitDebug({ op: "get", key, outcome: "error" });
384
1012
  return null;
385
1013
  }
386
1014
  }
@@ -396,6 +1024,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
396
1024
  ttl: number,
397
1025
  swr?: number,
398
1026
  ): Promise<void> {
1027
+ if (this.isReservedSegmentKey(key, "cache-write")) return;
399
1028
  try {
400
1029
  const cache = await this.getCache();
401
1030
  const request = this.keyToRequest(key);
@@ -405,32 +1034,57 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
405
1034
  const totalTtl = ttl + swrWindow;
406
1035
  const staleAt = Date.now() + ttl * 1000;
407
1036
 
408
- const body = JSON.stringify(data);
1037
+ // Stamp the tag timestamp at write time and carry it (with the tags)
1038
+ // into both the L1 body and the KV envelope so reads can run the
1039
+ // invalidation check.
1040
+ const taggedAt =
1041
+ Array.isArray(data.tags) && data.tags.length > 0
1042
+ ? Date.now()
1043
+ : undefined;
1044
+ const dataToStore: CachedEntryData = taggedAt
1045
+ ? { ...data, taggedAt }
1046
+ : data;
1047
+
1048
+ const body = JSON.stringify(dataToStore);
409
1049
  const response = new Response(body, {
410
1050
  headers: {
411
1051
  "Content-Type": "application/json",
412
1052
  "Cache-Control": `public, max-age=${totalTtl}`,
413
1053
  [CACHE_STALE_AT_HEADER]: String(staleAt),
1054
+ // Absolute hard-expiry deadline so a stale-path re-put can recompute a
1055
+ // shrinking max-age instead of restarting retention (see
1056
+ // remainingCacheControl / CACHE_EXPIRES_AT_HEADER).
1057
+ [CACHE_EXPIRES_AT_HEADER]: String(staleAt + swrWindow * 1000),
414
1058
  [CACHE_STATUS_HEADER]: "HIT",
1059
+ ...this.tagHeaderEntries(dataToStore.tags, taggedAt),
415
1060
  },
416
1061
  });
417
1062
 
418
1063
  const putPromise = cache.put(request, response);
419
1064
 
420
1065
  if (this.waitUntil) {
421
- // Non-blocking write
422
- this.waitUntil(async () => {
423
- await putPromise;
424
- });
1066
+ // Non-blocking write. These store-level background tasks intentionally
1067
+ // omit the reportingAsync ctx argument: the store is a request-agnostic
1068
+ // singleton and this.waitUntil is the execution context's, not a single
1069
+ // request's, so a failure is reported console-loud only (it cannot be
1070
+ // attributed to one request's onError). The request-scoped tag verbs
1071
+ // (revalidateTag / stale-revalidation) DO thread their captured ctx.
1072
+ this.waitUntil(() =>
1073
+ reportingAsync(
1074
+ () => putPromise,
1075
+ "cache-write",
1076
+ "[CFCacheStore] L1 write",
1077
+ ),
1078
+ );
425
1079
  } else {
426
1080
  // Blocking fallback
427
1081
  await putPromise;
428
1082
  }
429
1083
 
430
1084
  // L2: persist to KV
431
- this.kvSetSegment(key, data, staleAt, totalTtl, swrWindow);
1085
+ this.kvSetSegment(key, dataToStore, staleAt, totalTtl, swrWindow);
432
1086
  } catch (error) {
433
- console.error("[CFCacheStore] set failed:", error);
1087
+ reportCacheError(error, "cache-write", "[CFCacheStore] set");
434
1088
  }
435
1089
  }
436
1090
 
@@ -438,6 +1092,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
438
1092
  * Delete a cached entry from L1 and L2.
439
1093
  */
440
1094
  async delete(key: string): Promise<boolean> {
1095
+ if (this.isReservedSegmentKey(key, "cache-delete")) return false;
441
1096
  try {
442
1097
  const cache = await this.getCache();
443
1098
  const result = await cache.delete(this.keyToRequest(key));
@@ -445,18 +1100,18 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
445
1100
  // L2: delete from KV
446
1101
  if (this.kv && this.waitUntil) {
447
1102
  const kvKey = this.toKVKey(key);
448
- this.waitUntil(async () => {
449
- try {
450
- await this.kv!.delete(kvKey);
451
- } catch {
452
- // KV delete failures are non-critical
453
- }
454
- });
1103
+ this.waitUntil(() =>
1104
+ reportingAsync(
1105
+ () => this.kv!.delete(kvKey),
1106
+ "cache-delete",
1107
+ "[CFCacheStore] delete L2",
1108
+ ),
1109
+ );
455
1110
  }
456
1111
 
457
1112
  return result;
458
1113
  } catch (error) {
459
- console.error("[CFCacheStore] delete failed:", error);
1114
+ reportCacheError(error, "cache-delete", "[CFCacheStore] delete");
460
1115
  return false;
461
1116
  }
462
1117
  }
@@ -476,22 +1131,84 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
476
1131
  try {
477
1132
  const cache = await this.getCache();
478
1133
  const request = this.keyToRequest(`doc:${key}`);
479
- const response = await cache.match(request);
1134
+ // The document path is outside the debug surface (op is only get/getItem),
1135
+ // so the match-timeout flag is not surfaced as an event here -- though
1136
+ // matchWithTimeout still warns on a slow match. A miss or timeout falls
1137
+ // through to the KV document path and then render.
1138
+ const { response, error: matchError } = await this.matchWithTimeout(
1139
+ cache,
1140
+ request,
1141
+ );
480
1142
 
481
1143
  if (!response || response.status !== 200) {
1144
+ // A transient L1 match rejection (matchError set; only ever set when
1145
+ // response is undefined) is surfaced as cache-read before degrading to
1146
+ // L2/KV -- matching get()/getItem(). A genuine miss or a non-200 hit
1147
+ // carries no matchError and reports nothing.
1148
+ if (matchError)
1149
+ reportCacheError(
1150
+ matchError,
1151
+ "cache-read",
1152
+ "[CFCacheStore] getResponse L1 match",
1153
+ );
482
1154
  return this.kvGetResponse(key);
483
1155
  }
484
1156
 
1157
+ // Tag invalidation check (treat invalidated entry as a miss).
1158
+ const tagInfo = this.readTagInfo(response.headers);
1159
+ if (await this.isGloballyInvalidated(tagInfo.tags, tagInfo.taggedAt)) {
1160
+ return null;
1161
+ }
1162
+
485
1163
  // Check staleness
486
1164
  const staleAt = Number(response.headers.get(CACHE_STALE_AT_HEADER) || 0);
487
- const isStale = staleAt > 0 && Date.now() > staleAt;
1165
+ const now = Date.now();
1166
+ const isStale = staleAt > 0 && now > staleAt;
1167
+
1168
+ // Thundering-herd guard, mirroring the segment (get) and item (getItem)
1169
+ // tiers. Without it, every concurrent stale reader returned
1170
+ // shouldRevalidate=true and document-cache.ts scheduled a fresh render for
1171
+ // each one. Recency comes from our own revalidating-at stamp, not CF's Age
1172
+ // header (see CACHE_REVALIDATING_AT_HEADER); an absent/zero stamp counts as
1173
+ // "not recent" so a dropped revalidation re-arms instead of pinning.
1174
+ const status = response.headers.get(CACHE_STATUS_HEADER);
1175
+ const revalidatingAt = Number(
1176
+ response.headers.get(CACHE_REVALIDATING_AT_HEADER) ?? "0",
1177
+ );
1178
+ const isRevalidating =
1179
+ status === "REVALIDATING" &&
1180
+ revalidatingAt > 0 &&
1181
+ now - revalidatingAt < MAX_REVALIDATION_INTERVAL * 1000;
1182
+
1183
+ // L1 document bodies are streamed through verbatim - unlike the segment/
1184
+ // item tiers (which JSON-parse and so structurally detect corruption) and
1185
+ // the KV doc tier (validated in kvGetResponse, KV being the real partial-
1186
+ // read vector). Integrity here relies on the Cache API: cache.put stores a
1187
+ // response atomically or fails, so a truncated body is not served back. We
1188
+ // deliberately do NOT buffer+hash the body to re-verify it: that would
1189
+ // defeat streaming the document and add a full read to every cache hit.
1190
+
1191
+ if (isStale && !isRevalidating) {
1192
+ // First stale reader within the window: mark REVALIDATING (non-blocking,
1193
+ // best-effort) so concurrent readers below see the guard and suppress,
1194
+ // then return shouldRevalidate=true so this caller revalidates. Clone the
1195
+ // matched response for the marker since its original body must still
1196
+ // stream to the client.
1197
+ this.markResponseRevalidating(cache, request, response.clone());
1198
+ return {
1199
+ response: this.toClientResponse(response),
1200
+ shouldRevalidate: true,
1201
+ };
1202
+ }
488
1203
 
1204
+ // Fresh, or stale-but-already-REVALIDATING: serve without scheduling a
1205
+ // (re-)revalidation. A recent marker already has a render in flight.
489
1206
  return {
490
1207
  response: this.toClientResponse(response),
491
- shouldRevalidate: isStale,
1208
+ shouldRevalidate: false,
492
1209
  };
493
1210
  } catch (error) {
494
- console.error("[CFCacheStore] getResponse failed:", error);
1211
+ reportCacheError(error, "cache-read", "[CFCacheStore] getResponse");
495
1212
  return null;
496
1213
  }
497
1214
  }
@@ -513,6 +1230,15 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
513
1230
  headers.delete(CACHE_ORIG_CC_HEADER);
514
1231
  headers.delete(CACHE_STALE_AT_HEADER);
515
1232
  headers.delete(CACHE_STATUS_HEADER);
1233
+ headers.delete(CACHE_TAGS_HEADER);
1234
+ headers.delete(CACHE_TAGGED_AT_HEADER);
1235
+ // Internal stale-path bookkeeping (hard-expiry deadline + REVALIDATING
1236
+ // stamp). Carried on doc L1 entries for the herd guard; never serve them.
1237
+ headers.delete(CACHE_EXPIRES_AT_HEADER);
1238
+ headers.delete(CACHE_REVALIDATING_AT_HEADER);
1239
+ // Finding #3 (read side): strip per-client signals a pre-fix or
1240
+ // pinned-version L1 entry may carry. See the read-side note in the design doc.
1241
+ stripPerClientSignals(headers);
516
1242
  return new Response(response.body, {
517
1243
  status: response.status,
518
1244
  statusText: response.statusText,
@@ -529,6 +1255,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
529
1255
  response: Response,
530
1256
  ttl: number,
531
1257
  swr?: number,
1258
+ tags?: string[],
532
1259
  ): Promise<void> {
533
1260
  try {
534
1261
  const cache = await this.getCache();
@@ -538,6 +1265,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
538
1265
  const swrWindow = resolveSwrWindow(swr, this.defaults);
539
1266
  const totalTtl = ttl + swrWindow;
540
1267
  const staleAt = Date.now() + ttl * 1000;
1268
+ const taggedAt =
1269
+ Array.isArray(tags) && tags.length > 0 ? Date.now() : undefined;
541
1270
 
542
1271
  // Clone body for potential KV write before consuming it for L1
543
1272
  const [l1Body, kvBody] = this.kv
@@ -550,12 +1279,23 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
550
1279
  // replaced with a long max-age so the CF Cache API holds the entry across
551
1280
  // the SWR window; getResponse restores the original before serving.
552
1281
  const headers = new Headers(response.headers);
1282
+ // Finding #3: never persist a per-client signal in the shared L1 entry
1283
+ // (the platform's Set-Cookie rejection is unverified and ignores the
1284
+ // directive anyway). See stripPerClientSignals.
1285
+ stripPerClientSignals(headers);
553
1286
  const originalCacheControl = response.headers.get("Cache-Control");
554
1287
  if (originalCacheControl !== null) {
555
1288
  headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
556
1289
  }
557
1290
  headers.set("Cache-Control", `public, max-age=${totalTtl}`);
558
1291
  headers.set(CACHE_STALE_AT_HEADER, String(staleAt));
1292
+ // Absolute hard-expiry deadline so a stale-path REVALIDATING re-put can
1293
+ // recompute a shrinking max-age (remainingCacheControl) instead of
1294
+ // restarting retention. Mirrors set()/setItem(). Stripped by
1295
+ // toClientResponse before serving.
1296
+ headers.set(CACHE_EXPIRES_AT_HEADER, String(staleAt + swrWindow * 1000));
1297
+ // Internal tag headers (stripped by toClientResponse before serving).
1298
+ this.setTagHeaders(headers, tags, taggedAt);
559
1299
 
560
1300
  const toCache = new Response(l1Body, {
561
1301
  status: response.status,
@@ -567,9 +1307,13 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
567
1307
 
568
1308
  if (this.waitUntil) {
569
1309
  // Non-blocking write
570
- this.waitUntil(async () => {
571
- await putPromise;
572
- });
1310
+ this.waitUntil(() =>
1311
+ reportingAsync(
1312
+ () => putPromise,
1313
+ "cache-write",
1314
+ "[CFCacheStore] L1 write",
1315
+ ),
1316
+ );
573
1317
  } else {
574
1318
  // Blocking fallback
575
1319
  await putPromise;
@@ -577,35 +1321,43 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
577
1321
 
578
1322
  // L2: persist to KV (KV requires expirationTtl >= 60s)
579
1323
  if (this.kv && this.waitUntil && totalTtl >= 60) {
580
- const kvKey = this.toKVKey(`doc:${key}`);
1324
+ const kvKey = this.toDocKVKey(key);
1325
+ // Finding #3: never persist a per-client signal in the KV envelope.
581
1326
  const headersArray: [string, string][] = [];
582
- response.headers.forEach((v, k) => headersArray.push([k, v]));
1327
+ response.headers.forEach((v, k) => {
1328
+ if (isPerClientSignalHeader(k)) return;
1329
+ headersArray.push([k, v]);
1330
+ });
583
1331
  // Read body as ArrayBuffer and encode to base64 to preserve binary payloads
584
1332
  const bodyBuf = kvBody
585
1333
  ? await new Response(kvBody).arrayBuffer()
586
1334
  : new ArrayBuffer(0);
587
1335
  const bodyBase64 = bufferToBase64(bodyBuf);
588
1336
 
589
- this.waitUntil(async () => {
590
- try {
591
- const envelope: KVResponseEnvelope = {
592
- b: bodyBase64,
593
- st: response.status,
594
- stx: response.statusText,
595
- hd: headersArray,
596
- s: staleAt,
597
- e: staleAt + swrWindow * 1000,
598
- };
599
- await this.kv!.put(kvKey, JSON.stringify(envelope), {
600
- expirationTtl: totalTtl,
601
- });
602
- } catch (error) {
603
- console.error("[CFCacheStore] KV putResponse failed:", error);
604
- }
605
- });
1337
+ this.waitUntil(() =>
1338
+ reportingAsync(
1339
+ () => {
1340
+ const envelope: KVResponseEnvelope = {
1341
+ b: bodyBase64,
1342
+ st: response.status,
1343
+ stx: response.statusText,
1344
+ hd: headersArray,
1345
+ s: staleAt,
1346
+ e: staleAt + swrWindow * 1000,
1347
+ t: tags,
1348
+ ta: taggedAt,
1349
+ };
1350
+ return this.kv!.put(kvKey, JSON.stringify(envelope), {
1351
+ expirationTtl: totalTtl,
1352
+ });
1353
+ },
1354
+ "cache-write",
1355
+ "[CFCacheStore] kvPutResponse",
1356
+ ),
1357
+ );
606
1358
  }
607
1359
  } catch (error) {
608
- console.error("[CFCacheStore] putResponse failed:", error);
1360
+ reportCacheError(error, "cache-write", "[CFCacheStore] putResponse");
609
1361
  }
610
1362
  }
611
1363
 
@@ -622,48 +1374,173 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
622
1374
  try {
623
1375
  const cache = await this.getCache();
624
1376
  const request = this.keyToRequest(`fn:${key}`);
625
- const response = await cache.match(request);
1377
+ const matchStart = Date.now();
1378
+ const {
1379
+ response,
1380
+ timedOut,
1381
+ error: matchError,
1382
+ } = await this.matchWithTimeout(cache, request);
1383
+ const matchMs = Date.now() - matchStart;
1384
+
1385
+ if (!response) {
1386
+ // Transient match error reported cache-read; still degrades to L2/KV.
1387
+ if (matchError)
1388
+ reportCacheError(
1389
+ matchError,
1390
+ "cache-read",
1391
+ "[CFCacheStore] getItem L1 match",
1392
+ );
1393
+ if (this.debug)
1394
+ this.emitDebug({
1395
+ op: "getItem",
1396
+ key,
1397
+ // match-error (rejection) vs l1-miss (absence); see get().
1398
+ outcome: matchError
1399
+ ? "match-error"
1400
+ : timedOut
1401
+ ? "match-timeout"
1402
+ : "l1-miss",
1403
+ matchMs,
1404
+ });
1405
+ return this.kvGetItem(key);
1406
+ }
626
1407
 
627
- if (!response) return this.kvGetItem(key);
1408
+ // Non-200 entry is not a valid cached function result; treat as a miss.
1409
+ if (response.status !== 200) {
1410
+ if (this.debug)
1411
+ this.emitDebug({
1412
+ op: "getItem",
1413
+ key,
1414
+ outcome: "non-200",
1415
+ status: response.status,
1416
+ matchMs,
1417
+ });
1418
+ // Degraded fall-through: suppress revalidation so a broken L1 entry hit
1419
+ // concurrently serves KV-stale instead of spawning a herd (see get()).
1420
+ return this.kvGetItem(key, { suppressRevalidate: true });
1421
+ }
1422
+
1423
+ // Tag invalidation check (treat invalidated entry as a miss). Measure the
1424
+ // marker-resolution tail only under debug (see get()).
1425
+ const tagInfo = this.readTagInfo(response.headers);
1426
+ const markerStart = this.debug ? Date.now() : 0;
1427
+ const invalidated = await this.isGloballyInvalidated(
1428
+ tagInfo.tags,
1429
+ tagInfo.taggedAt,
1430
+ );
1431
+ const markerMs = this.debug ? Date.now() - markerStart : undefined;
1432
+ if (invalidated) {
1433
+ if (this.debug)
1434
+ this.emitDebug({
1435
+ op: "getItem",
1436
+ key,
1437
+ outcome: "tag-invalidated",
1438
+ status: response.status,
1439
+ matchMs,
1440
+ markerMs,
1441
+ });
1442
+ return null;
1443
+ }
628
1444
 
629
1445
  const staleAt = Number(
630
1446
  response.headers.get(CACHE_STALE_AT_HEADER) ?? "0",
631
1447
  );
632
1448
  const status = response.headers.get(CACHE_STATUS_HEADER);
633
- const age = Number(response.headers.get("age") ?? "0");
1449
+ const revalidatingAt = Number(
1450
+ response.headers.get(CACHE_REVALIDATING_AT_HEADER) ?? "0",
1451
+ );
634
1452
 
635
- const isStale = staleAt > 0 && Date.now() > staleAt;
1453
+ const now = Date.now();
1454
+ const isStale = staleAt > 0 && now > staleAt;
1455
+ // Recency from our explicit stamp, not CF's `Age` header (see get()).
636
1456
  const isRevalidating =
637
- status === "REVALIDATING" && age < MAX_REVALIDATION_INTERVAL;
638
-
639
- const data = (await response.json()) as {
1457
+ status === "REVALIDATING" &&
1458
+ revalidatingAt > 0 &&
1459
+ now - revalidatingAt < MAX_REVALIDATION_INTERVAL * 1000;
1460
+
1461
+ // Single emitter for the post-header L1 outcomes (see get()). Undefined
1462
+ // when debug is off, so the event object is never allocated on the hot
1463
+ // path; the informational-only `age` header is read lazily inside.
1464
+ const debugRead = this.debug
1465
+ ? (
1466
+ outcome: CFCacheReadDebugEvent["outcome"],
1467
+ bodyReadMs: number,
1468
+ shouldRevalidate?: boolean,
1469
+ ) =>
1470
+ this.emitDebug({
1471
+ op: "getItem",
1472
+ key,
1473
+ outcome,
1474
+ status: response.status,
1475
+ cacheStatus: status,
1476
+ staleAt,
1477
+ revalidatingAt,
1478
+ ageHeader: response.headers.get("age"),
1479
+ isStale,
1480
+ isRevalidating,
1481
+ shouldRevalidate,
1482
+ matchMs,
1483
+ markerMs,
1484
+ bodyReadMs,
1485
+ })
1486
+ : undefined;
1487
+
1488
+ const bodyStart = Date.now();
1489
+ const {
1490
+ value: data,
1491
+ errored,
1492
+ error,
1493
+ } = await this.readJsonWithTimeout<{
640
1494
  value: string;
641
- handles?: Record<string, Record<string, unknown[]>>;
642
- };
1495
+ handles?: string;
1496
+ }>(response);
1497
+ const bodyReadMs = Date.now() - bodyStart;
1498
+ if (data === undefined) {
1499
+ debugRead?.(errored ? "body-error" : "body-timeout", bodyReadMs);
1500
+ // Heal + conditionally evict a body-error, suppress a body-timeout; see
1501
+ // get().
1502
+ if (errored)
1503
+ return this.healCorruptL1(cache, request, error, "getItem", () =>
1504
+ this.kvGetItem(key, { suppressRevalidate: false }),
1505
+ );
1506
+ return this.kvGetItem(key, { suppressRevalidate: true });
1507
+ }
643
1508
 
644
1509
  if (!isStale || isRevalidating) {
1510
+ debugRead?.(
1511
+ isRevalidating ? "l1-revalidating-guarded" : "l1-fresh",
1512
+ bodyReadMs,
1513
+ false,
1514
+ );
645
1515
  return {
646
1516
  value: data.value,
647
1517
  handles: data.handles,
648
1518
  shouldRevalidate: false,
1519
+ tags: tagInfo.tags,
649
1520
  };
650
1521
  }
651
1522
 
652
- // Stale and needs revalidation mark REVALIDATING atomically
653
- const headers = new Headers(response.headers);
654
- headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
655
- await cache.put(
1523
+ // Stale and needs revalidation -- mark REVALIDATING (non-blocking,
1524
+ // best-effort, remaining-ttl) and return the stale value. See get() /
1525
+ // markRevalidating for the full rationale.
1526
+ this.markRevalidating(
1527
+ cache,
656
1528
  request,
657
- new Response(JSON.stringify(data), { status: 200, headers }),
1529
+ response.headers,
1530
+ 200,
1531
+ JSON.stringify(data),
658
1532
  );
659
1533
 
1534
+ debugRead?.("l1-stale-revalidate", bodyReadMs, true);
660
1535
  return {
661
1536
  value: data.value,
662
1537
  handles: data.handles,
663
1538
  shouldRevalidate: true,
1539
+ tags: tagInfo.tags,
664
1540
  };
665
1541
  } catch (error) {
666
- console.error("[CFCacheStore] getItem failed:", error);
1542
+ reportCacheError(error, "cache-read", "[CFCacheStore] getItem");
1543
+ if (this.debug) this.emitDebug({ op: "getItem", key, outcome: "error" });
667
1544
  return null;
668
1545
  }
669
1546
  }
@@ -686,22 +1563,33 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
686
1563
  const totalTtl = ttl + swrWindow;
687
1564
  const staleAt = Date.now() + ttl * 1000;
688
1565
 
1566
+ const tags = options?.tags;
1567
+ const taggedAt =
1568
+ Array.isArray(tags) && tags.length > 0 ? Date.now() : undefined;
1569
+
689
1570
  const body = JSON.stringify({ value, handles: options?.handles });
690
1571
  const response = new Response(body, {
691
1572
  headers: {
692
1573
  "Content-Type": "application/json",
693
1574
  "Cache-Control": `public, max-age=${totalTtl}`,
694
1575
  [CACHE_STALE_AT_HEADER]: String(staleAt),
1576
+ // Absolute hard-expiry deadline; see set() / remainingCacheControl.
1577
+ [CACHE_EXPIRES_AT_HEADER]: String(staleAt + swrWindow * 1000),
695
1578
  [CACHE_STATUS_HEADER]: "HIT",
1579
+ ...this.tagHeaderEntries(tags, taggedAt),
696
1580
  },
697
1581
  });
698
1582
 
699
1583
  const putPromise = cache.put(request, response);
700
1584
 
701
1585
  if (this.waitUntil) {
702
- this.waitUntil(async () => {
703
- await putPromise;
704
- });
1586
+ this.waitUntil(() =>
1587
+ reportingAsync(
1588
+ () => putPromise,
1589
+ "cache-write",
1590
+ "[CFCacheStore] L1 write",
1591
+ ),
1592
+ );
705
1593
  } else {
706
1594
  await putPromise;
707
1595
  }
@@ -709,24 +1597,165 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
709
1597
  // L2: persist to KV (KV requires expirationTtl >= 60s)
710
1598
  if (this.kv && this.waitUntil && totalTtl >= 60) {
711
1599
  const kvKey = this.toKVKey(`fn:${key}`);
712
- this.waitUntil(async () => {
713
- try {
714
- const envelope: KVItemEnvelope = {
715
- v: value,
716
- h: options?.handles,
1600
+ this.waitUntil(() =>
1601
+ reportingAsync(
1602
+ () => {
1603
+ const envelope: KVItemEnvelope = {
1604
+ v: value,
1605
+ h: options?.handles,
1606
+ s: staleAt,
1607
+ e: staleAt + swrWindow * 1000,
1608
+ t: tags,
1609
+ ta: taggedAt,
1610
+ };
1611
+ return this.kv!.put(kvKey, JSON.stringify(envelope), {
1612
+ expirationTtl: totalTtl,
1613
+ });
1614
+ },
1615
+ "cache-write",
1616
+ "[CFCacheStore] kvSetItem",
1617
+ ),
1618
+ );
1619
+ }
1620
+ } catch (error) {
1621
+ reportCacheError(error, "cache-write", "[CFCacheStore] setItem");
1622
+ }
1623
+ }
1624
+
1625
+ // ============================================================================
1626
+ // Shell Cache Methods (PPR shell resume) — KV-only in v1
1627
+ // ============================================================================
1628
+ //
1629
+ // Unlike the segment/item/document tiers, the shell family has NO Cache-API L1
1630
+ // tier: the prelude bytes + postponed blob are large and version-coupled, and a
1631
+ // per-colo L1 for them is a deliberate follow-up (see the PPR shell-resume
1632
+ // design doc). Shell entries live only in KV (the global tier), so the family
1633
+ // requires a configured KV namespace; without one, getShell/putShell no-op and
1634
+ // the shell-cache middleware fails open to a full HTML render. Tag invalidation
1635
+ // still applies: shell entries carry tags/taggedAt and are checked against the
1636
+ // same KV markers isGloballyInvalidated() reads for every other tier.
1637
+
1638
+ /**
1639
+ * Get a cached PPR shell entry by key from KV (no L1). Applies the KV read
1640
+ * budget, corrupt-entry eviction, hard-expiry, and tag invalidation exactly
1641
+ * like kvGetItem, minus the L1 promote. SWR is a plain staleness flag — KV has
1642
+ * no REVALIDATING herd guard, so the shell-cache middleware's module-level
1643
+ * in-flight set is the recapture stampede guard.
1644
+ */
1645
+ async getShell(
1646
+ key: string,
1647
+ ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
1648
+ if (!this.kv) return null;
1649
+ try {
1650
+ const kvKey = this.toKVKey(`shell:${key}`);
1651
+ const { value: envelope, timedOut } =
1652
+ await this.kvGetOrEvict<KVShellEnvelope>(
1653
+ kvKey,
1654
+ (e) =>
1655
+ typeof e.p === "string" &&
1656
+ (e.po === null || typeof e.po === "string") &&
1657
+ typeof e.rv === "string" &&
1658
+ typeof e.e === "number" &&
1659
+ typeof e.s === "number",
1660
+ "getShell",
1661
+ );
1662
+ // A timeout, a missing key, or an already-evicted corrupt entry is a miss.
1663
+ if (timedOut || !envelope) return null;
1664
+
1665
+ const now = Date.now();
1666
+ if (now > envelope.e) return null;
1667
+
1668
+ if (await this.isGloballyInvalidated(envelope.t, envelope.ta)) {
1669
+ return null;
1670
+ }
1671
+
1672
+ const shouldRevalidate = envelope.s > 0 && now > envelope.s;
1673
+ return {
1674
+ entry: {
1675
+ prelude: envelope.p,
1676
+ postponed: envelope.po,
1677
+ reactVersion: envelope.rv,
1678
+ buildVersion: envelope.bv,
1679
+ initialTheme: envelope.i,
1680
+ snapshot: envelope.sn,
1681
+ createdAt: envelope.c,
1682
+ },
1683
+ shouldRevalidate,
1684
+ };
1685
+ } catch (error) {
1686
+ reportCacheError(error, "cache-read", "[CFCacheStore] getShell");
1687
+ return null;
1688
+ }
1689
+ }
1690
+
1691
+ /**
1692
+ * Store a PPR shell entry in KV with TTL and optional SWR window. Non-blocking
1693
+ * (waitUntil) like the other KV writes. The tags/taggedAt ride in the envelope
1694
+ * so isGloballyInvalidated() can invalidate the shell via the shared KV markers.
1695
+ */
1696
+ async putShell(
1697
+ key: string,
1698
+ entry: ShellCacheEntry,
1699
+ ttlSeconds?: number,
1700
+ swrSeconds?: number,
1701
+ tags?: string[],
1702
+ ): Promise<void> {
1703
+ // KV-only tier: needs a KV namespace and waitUntil (writes are non-blocking).
1704
+ if (!this.kv || !this.waitUntil) return;
1705
+ try {
1706
+ const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
1707
+ const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
1708
+ const totalTtl = ttl + swrWindow;
1709
+ // KV requires expirationTtl >= 60s; skip a shorter-lived shell rather than
1710
+ // letting kv.put reject inside waitUntil (mirrors setItem/kvSetSegment).
1711
+ if (totalTtl < 60) return;
1712
+
1713
+ const staleAt = Date.now() + ttl * 1000;
1714
+ const taggedAt =
1715
+ Array.isArray(tags) && tags.length > 0 ? Date.now() : undefined;
1716
+
1717
+ const kvKey = this.toKVKey(`shell:${key}`);
1718
+ // A key over the KV limit makes kv.put reject deep inside waitUntil; report
1719
+ // and skip the doomed write (mirrors kvSetSegment).
1720
+ const kvKeyBytes = kvKeyByteLength(kvKey);
1721
+ if (kvKeyBytes > KV_MAX_KEY_BYTES) {
1722
+ reportCacheError(
1723
+ new Error(
1724
+ `shell cache key produces a ${kvKeyBytes}-byte KV key, over the ` +
1725
+ `${KV_MAX_KEY_BYTES}-byte limit; the shell was not persisted.`,
1726
+ ),
1727
+ "cache-write",
1728
+ "[CFCacheStore] putShell",
1729
+ );
1730
+ return;
1731
+ }
1732
+
1733
+ this.waitUntil(() =>
1734
+ reportingAsync(
1735
+ () => {
1736
+ const envelope: KVShellEnvelope = {
1737
+ p: entry.prelude,
1738
+ po: entry.postponed,
1739
+ rv: entry.reactVersion,
1740
+ bv: entry.buildVersion,
1741
+ c: entry.createdAt,
717
1742
  s: staleAt,
718
1743
  e: staleAt + swrWindow * 1000,
1744
+ t: tags,
1745
+ ta: taggedAt,
1746
+ i: entry.initialTheme,
1747
+ sn: entry.snapshot,
719
1748
  };
720
- await this.kv!.put(kvKey, JSON.stringify(envelope), {
1749
+ return this.kv!.put(kvKey, JSON.stringify(envelope), {
721
1750
  expirationTtl: totalTtl,
722
1751
  });
723
- } catch (error) {
724
- console.error("[CFCacheStore] KV setItem failed:", error);
725
- }
726
- });
727
- }
1752
+ },
1753
+ "cache-write",
1754
+ "[CFCacheStore] putShell",
1755
+ ),
1756
+ );
728
1757
  } catch (error) {
729
- console.error("[CFCacheStore] setItem failed:", error);
1758
+ reportCacheError(error, "cache-write", "[CFCacheStore] putShell");
730
1759
  }
731
1760
  }
732
1761
 
@@ -743,7 +1772,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
743
1772
  const encodedKey = encodeURIComponent(key);
744
1773
  // Include version in URL path to invalidate cache when version changes
745
1774
  const versionPath = this.version ? `v/${this.version}/` : "";
746
- return new Request(`${this.baseUrl}${versionPath}${encodedKey}`, {
1775
+ return new Request(`${this.resolveBaseUrl()}${versionPath}${encodedKey}`, {
747
1776
  method: "GET",
748
1777
  });
749
1778
  }
@@ -758,6 +1787,586 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
758
1787
  return `${versionPath}${key}`;
759
1788
  }
760
1789
 
1790
+ /**
1791
+ * Host token for the current request, used to namespace the document KV key.
1792
+ * Derived from the same resolveBaseUrl() that namespaces the L1 (Cache API)
1793
+ * tier, so a doc entry's KV twin lands under the identical host bucket.
1794
+ * Falls back to "_" if the base URL cannot be parsed (it always carries a
1795
+ * trailing-slash origin, so parsing succeeds in practice).
1796
+ * @internal
1797
+ */
1798
+ private docKVHost(): string {
1799
+ try {
1800
+ return new URL(this.resolveBaseUrl()).host || "_";
1801
+ } catch {
1802
+ return "_";
1803
+ }
1804
+ }
1805
+
1806
+ /**
1807
+ * Convert a document key to its host-namespaced KV key. The L1 tier already
1808
+ * namespaces document entries by host via keyToRequest/resolveBaseUrl, but the
1809
+ * KV fallback keyed only on `doc:{key}`, so two hosts serving the same path
1810
+ * could collide on the KV tier (one host serving another's cached document).
1811
+ * Prefixing the host closes that cross-host collision. Deterministic per
1812
+ * (host, key). Segment/fn/tag-marker KV keys keep toKVKey unchanged: tag
1813
+ * markers are intentionally global (invalidation must cross hosts), and the
1814
+ * document tier is the one with a request-host context here.
1815
+ * @internal
1816
+ */
1817
+ private toDocKVKey(key: string): string {
1818
+ return this.toKVKey(`h/${this.docKVHost()}/doc:${key}`);
1819
+ }
1820
+
1821
+ /**
1822
+ * Best-effort delete of a single KV key, reporting (not swallowing) a delete
1823
+ * failure as cache-delete. Used by the corrupt-entry self-heal paths.
1824
+ * @internal
1825
+ */
1826
+ private async evictKvKey(kvKey: string, label: string): Promise<void> {
1827
+ try {
1828
+ await this.kv!.delete(kvKey);
1829
+ } catch (error) {
1830
+ reportCacheError(
1831
+ error,
1832
+ "cache-delete",
1833
+ `[CFCacheStore] ${label}: evict failed`,
1834
+ );
1835
+ }
1836
+ }
1837
+
1838
+ /**
1839
+ * Schedule a corrupt-entry KV eviction as a NON-BLOCKING background task
1840
+ * (waitUntil) instead of awaiting it on the request path. The corrupt read has
1841
+ * already resolved to a miss; awaiting an unbounded kv.delete here would re-add
1842
+ * exactly the multi-second stall the read budgets exist to prevent when the KV
1843
+ * namespace is degraded. evictKvKey never rejects (it reports its own failure),
1844
+ * so the fire-and-forget fallback is safe when no waitUntil is available.
1845
+ * @internal
1846
+ */
1847
+ private scheduleKvEvict(kvKey: string, label: string): void {
1848
+ const evict = (): Promise<void> => this.evictKvKey(kvKey, label);
1849
+ if (this.waitUntil) this.waitUntil(evict);
1850
+ else void evict();
1851
+ }
1852
+
1853
+ /**
1854
+ * KV-get a JSON envelope, EVICTING the key only when it is genuinely corrupt.
1855
+ *
1856
+ * Reads as { type: "text" }, NOT { type: "json" }, on purpose: the "json" form
1857
+ * fuses the network read and the JSON parse, so a transient KV outage (5xx/429/
1858
+ * network blip) is indistinguishable from a malformed body and would delete a
1859
+ * still-good cross-colo entry - a self-inflicted miss storm. Reading text lets a
1860
+ * transient read error propagate to the caller's outer catch (reported
1861
+ * cache-read, the entry left intact); only a JSON.parse failure on a body that
1862
+ * WAS successfully read - or an envelope that parses but fails `validate`
1863
+ * (fields missing from a truncated write) - is true corruption that evicts +
1864
+ * reports cache-corrupt. A MISSING key (kv.get -> null) is a normal miss.
1865
+ * @internal
1866
+ */
1867
+ private async kvGetOrEvict<T>(
1868
+ kvKey: string,
1869
+ validate: (envelope: T) => boolean,
1870
+ label: string,
1871
+ ): Promise<{ value: T | null; timedOut: boolean }> {
1872
+ // Bound the read with the KV latency budget (inherited from #558) so a
1873
+ // degraded namespace cannot pin the request. readWithTimeout reports
1874
+ // timedOut on budget expiry; a transient read REJECTION (5xx/429/network)
1875
+ // instead propagates out to the caller's outer catch (reported cache-read,
1876
+ // the entry left intact) -- deliberately NOT caught as corruption.
1877
+ const { value: raw, timedOut } = await this.readWithTimeout<unknown>(
1878
+ () => this.kv!.get(kvKey, { type: "text" }),
1879
+ this.kvReadTimeoutMs,
1880
+ "KV read",
1881
+ );
1882
+ if (timedOut) return { value: null, timedOut: true };
1883
+ if (raw == null) return { value: null, timedOut: false }; // missing = miss
1884
+
1885
+ // Real CF KV with { type: "text" } returns a string: parse + structurally
1886
+ // validate it; a parse/validate failure on a successfully-read body is the
1887
+ // only true corruption (evict + cache-corrupt). A KV binding that already
1888
+ // returns a parsed object (some shims/tests) is used as-is.
1889
+ let envelope: T;
1890
+ if (typeof raw === "string") {
1891
+ try {
1892
+ envelope = JSON.parse(raw) as T;
1893
+ } catch (error) {
1894
+ reportCacheError(
1895
+ error,
1896
+ "cache-corrupt",
1897
+ `[CFCacheStore] ${label}: corrupt JSON in KV, evicting`,
1898
+ );
1899
+ this.scheduleKvEvict(kvKey, label);
1900
+ return { value: null, timedOut: false };
1901
+ }
1902
+ } else {
1903
+ envelope = raw as T;
1904
+ }
1905
+
1906
+ // A body that parses to null or a primitive ('null', '42', 'true', '"x"')
1907
+ // is not a valid envelope. Guard it BEFORE validate(): the property-reading
1908
+ // validators throw on a null/primitive rather than returning false, which
1909
+ // would escape to the caller's outer catch as a transient cache-read and
1910
+ // leave the bad key un-evicted (re-failing every read until its KV TTL). The
1911
+ // typeof check short-circuits validate() so it only ever runs on an object.
1912
+ if (
1913
+ envelope == null ||
1914
+ typeof envelope !== "object" ||
1915
+ !validate(envelope)
1916
+ ) {
1917
+ reportCacheError(
1918
+ new Error("malformed/partial KV envelope"),
1919
+ "cache-corrupt",
1920
+ `[CFCacheStore] ${label}: malformed envelope, evicting`,
1921
+ );
1922
+ this.scheduleKvEvict(kvKey, label);
1923
+ return { value: null, timedOut: false };
1924
+ }
1925
+ return { value: envelope, timedOut: false };
1926
+ }
1927
+
1928
+ // ============================================================================
1929
+ // Tag Invalidation (single-store: markers live in this.kv)
1930
+ // ============================================================================
1931
+
1932
+ /** KV key for a tag's invalidation marker. */
1933
+ private tagMarkerKey(tag: string): string {
1934
+ return this.toKVKey(`${TAG_MARKER_PREFIX}${tag}`);
1935
+ }
1936
+
1937
+ /**
1938
+ * Header entries carrying an entry's tags (JSON-encoded, comma-safe) and the
1939
+ * timestamp they were attached. Returns an empty object when there are no
1940
+ * tags so untagged entries stay header-free and skip the invalidation check.
1941
+ */
1942
+ private tagHeaderEntries(
1943
+ tags: string[] | undefined,
1944
+ taggedAt: number | undefined,
1945
+ ): Record<string, string> {
1946
+ if (!Array.isArray(tags) || tags.length === 0 || !taggedAt) return {};
1947
+ return {
1948
+ // encodeURIComponent so the value is pure ASCII: HTTP header values are
1949
+ // ByteStrings, but JSON.stringify leaves codepoints > U+00FF (emoji/CJK)
1950
+ // verbatim, which makes new Response({ headers }) throw and the outer
1951
+ // try/catch silently drop the whole entry from cache. Decoded in
1952
+ // readTagInfo. The L1 marker Cache-Tag path encodes for the same reason.
1953
+ [CACHE_TAGS_HEADER]: encodeURIComponent(JSON.stringify(tags)),
1954
+ [CACHE_TAGGED_AT_HEADER]: String(taggedAt),
1955
+ };
1956
+ }
1957
+
1958
+ /**
1959
+ * Merge the internal tag headers onto an existing Headers instance. The
1960
+ * from-scratch paths spread tagHeaderEntries() into an object-literal init;
1961
+ * the document put/promote paths build a Headers first, so they .set() each
1962
+ * entry instead.
1963
+ */
1964
+ private setTagHeaders(
1965
+ headers: Headers,
1966
+ tags: string[] | undefined,
1967
+ taggedAt: number | undefined,
1968
+ ): void {
1969
+ for (const [name, value] of Object.entries(
1970
+ this.tagHeaderEntries(tags, taggedAt),
1971
+ )) {
1972
+ headers.set(name, value);
1973
+ }
1974
+ }
1975
+
1976
+ /** Read an entry's tags/taggedAt back from its headers. */
1977
+ private readTagInfo(headers: Headers): {
1978
+ tags?: string[];
1979
+ taggedAt?: number;
1980
+ } {
1981
+ const rawTags = headers.get(CACHE_TAGS_HEADER);
1982
+ const rawTaggedAt = headers.get(CACHE_TAGGED_AT_HEADER);
1983
+ if (!rawTags || !rawTaggedAt) return {};
1984
+ try {
1985
+ const taggedAt = Number(rawTaggedAt);
1986
+ // A corrupt/non-numeric tagged-at header yields NaN. isGloballyInvalidated
1987
+ // short-circuits on a falsy taggedAt (NaN is falsy), so returning
1988
+ // { taggedAt: NaN } would make the entry permanently NON-invalidatable -
1989
+ // a revalidateTag could never evict it. Treat a non-finite stamp the same
1990
+ // as the missing-header case (untagged): drop both tags and taggedAt so the
1991
+ // entry is re-rendered/re-tagged rather than silently un-invalidatable.
1992
+ if (!Number.isFinite(taggedAt)) return {};
1993
+ return {
1994
+ tags: JSON.parse(decodeURIComponent(rawTags)) as string[],
1995
+ taggedAt,
1996
+ };
1997
+ } catch {
1998
+ return {};
1999
+ }
2000
+ }
2001
+
2002
+ /**
2003
+ * Whether an entry tagged at `taggedAt` with `tags` has been invalidated since.
2004
+ * Reads the per-tag invalidation markers from KV and returns true if any tag's
2005
+ * latest invalidation is at or after taggedAt (>= so a same-millisecond
2006
+ * invalidate wins, favouring freshness over staleness). Fails open: KV errors
2007
+ * never turn a hit into a wrongful miss-storm beyond this single read.
2008
+ */
2009
+ private async isGloballyInvalidated(
2010
+ tags: string[] | undefined,
2011
+ taggedAt: number | undefined,
2012
+ ): Promise<boolean> {
2013
+ // Array.isArray (not just truthiness): a non-array tags value - direct store
2014
+ // misuse like setItem(k, v, { tags: "products" }), or a skewed KV envelope -
2015
+ // must fail safe to "not invalidated" rather than throwing `.map` on every
2016
+ // read (which the outer catch would mis-report as a transient cache-read).
2017
+ if (!this.kv || !Array.isArray(tags) || tags.length === 0 || !taggedAt)
2018
+ return false;
2019
+ const ctx = _getRequestContext();
2020
+ const memo = ctx ? getTagMarkerMemo(ctx, this) : undefined;
2021
+ const inflight = ctx ? getTagMarkerInflight(ctx, this) : undefined;
2022
+ try {
2023
+ const markers = await Promise.all(
2024
+ tags.map((tag) => this.readTagMarker(tag, memo, inflight)),
2025
+ );
2026
+ for (const marker of markers) {
2027
+ if (marker != null && marker >= taggedAt) return true;
2028
+ }
2029
+ return false;
2030
+ } catch (error) {
2031
+ reportCacheError(
2032
+ error,
2033
+ "cache-read",
2034
+ "[CFCacheStore] tag invalidation check",
2035
+ );
2036
+ return false;
2037
+ }
2038
+ }
2039
+
2040
+ /** Synthetic Cache API request for a tag's L1-cached invalidation marker. */
2041
+ private tagMarkerRequest(tag: string): Request {
2042
+ return this.keyToRequest(`${TAG_MARKER_CACHE_PREFIX}${tag}`);
2043
+ }
2044
+
2045
+ /**
2046
+ * Read a tag's latest invalidation timestamp (or null if never invalidated)
2047
+ * through the cascade: per-request memo -> per-colo L1 cache (only when
2048
+ * tagCacheTtl > 0) -> KV (the global truth). The memo is always consulted
2049
+ * first so it stays authoritative within a request (read-your-own-writes),
2050
+ * and every KV/L1 result is written back into the memo. A Cache API miss
2051
+ * always falls through to KV; absence is represented by a cached sentinel,
2052
+ * never by a miss.
2053
+ *
2054
+ * Concurrent reads of the same tag within a request share one in-flight read
2055
+ * (the resolved-value memo only collapses sequential reads; parallel segment
2056
+ * loading would otherwise issue one KV read per concurrent reader).
2057
+ * @internal
2058
+ */
2059
+ private async readTagMarker(
2060
+ tag: string,
2061
+ memo: Map<string, number | null> | undefined,
2062
+ inflight: Map<string, Promise<number | null>> | undefined,
2063
+ ): Promise<number | null> {
2064
+ if (memo && memo.has(tag)) return memo.get(tag) ?? null;
2065
+
2066
+ // Collapse concurrent (not-yet-resolved) reads of this tag onto one promise.
2067
+ if (inflight) {
2068
+ const pending = inflight.get(tag);
2069
+ if (pending) return pending;
2070
+ const read = this.fetchTagMarker(tag, memo);
2071
+ inflight.set(tag, read);
2072
+ try {
2073
+ return await read;
2074
+ } finally {
2075
+ // Resolved values now live in the memo; drop the in-flight entry.
2076
+ inflight.delete(tag);
2077
+ }
2078
+ }
2079
+
2080
+ return this.fetchTagMarker(tag, memo);
2081
+ }
2082
+
2083
+ /**
2084
+ * Uncached body of readTagMarker: L1 (per-colo Cache API, opt-in via
2085
+ * tagCacheTtl) -> KV. Writes the resolved value back into the memo.
2086
+ * @internal
2087
+ */
2088
+ private async fetchTagMarker(
2089
+ tag: string,
2090
+ memo: Map<string, number | null> | undefined,
2091
+ ): Promise<number | null> {
2092
+ // Write the resolved marker into the memo WITHOUT clobbering a value a
2093
+ // concurrent invalidateTags() wrote during our await. The router resolves
2094
+ // sibling slots in parallel, so a slot's updateTag() can land the
2095
+ // authoritative invalidatedAt into the memo while this read is still in
2096
+ // flight; overwriting it with our (pre-invalidation) read result would break
2097
+ // read-your-own-writes for the rest of the request. If the tag was memoized
2098
+ // mid-read, that value wins and is returned. Without a memo, the read result
2099
+ // stands as-is.
2100
+ const memoize = (read: number | null): number | null => {
2101
+ if (memo && memo.has(tag)) return memo.get(tag) ?? null;
2102
+ memo?.set(tag, read);
2103
+ return read;
2104
+ };
2105
+
2106
+ // L1 (per-colo) marker cache - opt-in via tagCacheTtl. Bounded by the same
2107
+ // edge budgets as data reads (inherited from #558) so a degraded colo cannot
2108
+ // stall a tagged read; a miss, timeout, or error all fall through to KV.
2109
+ if (this.tagCacheTtl > 0) {
2110
+ try {
2111
+ const cache = await this.getCache();
2112
+ const { response: hit, error: matchError } =
2113
+ await this.matchWithTimeout(cache, this.tagMarkerRequest(tag));
2114
+ // A transient match REJECTION is captured (not thrown) by
2115
+ // matchWithTimeout; surface it as cache-read like the data read paths
2116
+ // before falling through to KV, rather than silently dropping it.
2117
+ if (matchError)
2118
+ reportCacheError(
2119
+ matchError,
2120
+ "cache-read",
2121
+ "[CFCacheStore] tag marker L1 match",
2122
+ );
2123
+ if (hit) {
2124
+ const { value: body } = await this.readWithTimeout(
2125
+ () => hit.text(),
2126
+ this.edgeReadTimeoutMs,
2127
+ "tag marker L1 body read",
2128
+ );
2129
+ if (body !== undefined) {
2130
+ const value = body === TAG_MARKER_ABSENT ? null : Number(body);
2131
+ return memoize(value);
2132
+ }
2133
+ }
2134
+ } catch {
2135
+ // Fall through to KV on any L1 read error.
2136
+ }
2137
+ }
2138
+
2139
+ // KV (global truth), bounded by the KV budget. On TIMEOUT fail OPEN: treat
2140
+ // the marker as absent (-> entry not invalidated -> served) so a degraded
2141
+ // namespace cannot pin every tagged read behind a slow global lookup. A
2142
+ // transient REJECTION instead propagates to isGloballyInvalidated's catch
2143
+ // (reported cache-read), which also fails open. Either way one slow tag
2144
+ // never amplifies into a per-segment stall.
2145
+ const { value: raw, timedOut } = await this.readWithTimeout<string | null>(
2146
+ () => this.kv!.get(this.tagMarkerKey(tag), { type: "text" }),
2147
+ this.kvReadTimeoutMs,
2148
+ "tag marker KV read",
2149
+ );
2150
+ if (timedOut) {
2151
+ // Memoize the fail-open result so the rest of this request is consistent
2152
+ // (and does not re-pay the timeout per segment sharing the tag).
2153
+ return memoize(null);
2154
+ }
2155
+ const value = raw != null ? Number(raw) : null;
2156
+ const resolved = memoize(value);
2157
+
2158
+ // Populate L1 for subsequent reads in this colo (non-blocking). Use the
2159
+ // resolved (memo-aware) value so a marker invalidated mid-read is not
2160
+ // re-cached stale into this colo's L1.
2161
+ if (this.tagCacheTtl > 0) {
2162
+ const put = () => this.putTagMarkerL1(tag, resolved);
2163
+ if (this.waitUntil) this.waitUntil(put);
2164
+ else void put();
2165
+ }
2166
+ return resolved;
2167
+ }
2168
+
2169
+ /**
2170
+ * Cloudflare Cache-Tags written on a tag's L1 marker entry, namespaced per
2171
+ * store so purges never collide with other Cache-Tags in the zone. Three
2172
+ * tiers, broad to specific:
2173
+ * rg:{ns} - everything this store cached (deploy/nuclear reset)
2174
+ * rg:{ns}:lk - all tag-lookup markers
2175
+ * rg:{ns}:lk:{tag} - this tag's lookup (the normal updateTag purge target)
2176
+ * The tag value is encodeURIComponent'd so commas/spaces can't corrupt the
2177
+ * comma-delimited Cache-Tag header.
2178
+ * @internal
2179
+ */
2180
+ private lookupCacheTags(tag: string): string[] {
2181
+ const ns = this.namespace ?? "default";
2182
+ return [`rg:${ns}`, `rg:${ns}:lk`, this.lookupPurgeTag(tag)];
2183
+ }
2184
+
2185
+ /** The specific Cache-Tag a consumer purges to evict tag `tag`'s lookup. */
2186
+ private lookupPurgeTag(tag: string): string {
2187
+ const ns = this.namespace ?? "default";
2188
+ return `rg:${ns}:lk:${encodeURIComponent(tag)}`;
2189
+ }
2190
+
2191
+ /**
2192
+ * Write a tag marker value into the per-colo L1 Cache API with tagCacheTtl.
2193
+ * `null` is stored as the TAG_MARKER_ABSENT sentinel so "no marker yet" is
2194
+ * cacheable (most tags are never invalidated - that is where the read savings
2195
+ * come from). The entry also carries a namespaced Cache-Tag so an external
2196
+ * purge-by-tag (via onRevalidateTag) can evict it across colos promptly,
2197
+ * rather than waiting out tagCacheTtl. Best-effort.
2198
+ * @internal
2199
+ */
2200
+ private async putTagMarkerL1(
2201
+ tag: string,
2202
+ value: number | null,
2203
+ opts?: { critical?: boolean },
2204
+ ): Promise<void> {
2205
+ if (this.tagCacheTtl <= 0) return;
2206
+ try {
2207
+ const cache = await this.getCache();
2208
+ const body = value != null ? String(value) : TAG_MARKER_ABSENT;
2209
+ await cache.put(
2210
+ this.tagMarkerRequest(tag),
2211
+ new Response(body, {
2212
+ headers: {
2213
+ "Cache-Control": `public, max-age=${this.tagCacheTtl}`,
2214
+ "Cache-Tag": this.lookupCacheTags(tag).join(","),
2215
+ },
2216
+ }),
2217
+ );
2218
+ } catch (error) {
2219
+ // The read-path populate is best-effort: a failed populate just means the
2220
+ // next read consults KV. The invalidation WRITE-THROUGH (critical) is not
2221
+ // - silently swallowing it would leave this colo's stale marker (often the
2222
+ // ABSENT sentinel) authoritative for tagCacheTtl while updateTag reports
2223
+ // success. Surface it, and best-effort delete the L1 marker so the next
2224
+ // read re-reads KV, which already holds the fresh marker (written before
2225
+ // this write-through in invalidateTags).
2226
+ if (opts?.critical) {
2227
+ reportCacheError(
2228
+ error,
2229
+ "cache-invalidate",
2230
+ "[CFCacheStore] tag marker L1 write-through",
2231
+ );
2232
+ await reportingAsync(
2233
+ async () => {
2234
+ const cache = await this.getCache();
2235
+ await cache.delete(this.tagMarkerRequest(tag));
2236
+ },
2237
+ "cache-delete",
2238
+ "[CFCacheStore] tag marker L1 evict after failed write-through",
2239
+ );
2240
+ }
2241
+ }
2242
+ }
2243
+
2244
+ /**
2245
+ * Invalidate every entry tagged with any of `tags`. Receives the whole batch
2246
+ * from one updateTag()/revalidateTag() call so the eager-purge hook fires
2247
+ * ONCE (one CDN purge request, not one per tag). For each tag: records the KV
2248
+ * marker (the durable cross-colo truth that reads compare taggedAt against),
2249
+ * writes the fresh marker straight into this colo's L1 (write-through, NOT
2250
+ * delete - a delete would let the next read re-read a not-yet-converged KV
2251
+ * value and re-arm the stale window), and memoizes it for same-request
2252
+ * read-your-own-writes. Finally fires onRevalidateTag with the namespaced
2253
+ * lookup Cache-Tags so a consumer purge evicts the cached lookups in other
2254
+ * colos promptly (otherwise they converge within tagCacheTtl).
2255
+ *
2256
+ * Durable-write integrity: the in-memory write-through (memo + L1) for a tag
2257
+ * runs ONLY after that tag's KV marker write is confirmed. If any KV write
2258
+ * fails (transient error, or an over-512-byte key), this rejects with the
2259
+ * failed tags so an awaiting updateTag() surfaces the failure instead of
2260
+ * silently reporting success while other requests/colos serve stale data. The
2261
+ * eager purge still fires for the whole batch first (it is additive).
2262
+ */
2263
+ async invalidateTags(tags: string[]): Promise<void> {
2264
+ if (tags.length === 0) return;
2265
+ const invalidatedAt = Date.now();
2266
+ const ctx = _getRequestContext();
2267
+ const memo = ctx ? getTagMarkerMemo(ctx, this) : undefined;
2268
+
2269
+ if (!this.kv && !this.onRevalidateTag) {
2270
+ console.warn(
2271
+ `[CFCacheStore] invalidateTags had no effect: configure a KV namespace ` +
2272
+ `for distributed invalidation, or an onRevalidateTag hook.`,
2273
+ );
2274
+ }
2275
+
2276
+ const failedTags = new Set<string>();
2277
+ const errors: unknown[] = [];
2278
+ if (this.kv) {
2279
+ // Markers written with no expiry (tagInvalidationTtl unset) never expire,
2280
+ // so high-cardinality tags accumulate KV keys unboundedly with no reaper.
2281
+ // Warn once per namespace at the batch entry point (not per marker write,
2282
+ // which would fire once per tag). Kept separate from the floor warning:
2283
+ // that path only fires for a positive below-floor value, never the unset
2284
+ // default sanitizeTagInvalidationTtl passes through as undefined.
2285
+ if (!this.tagInvalidationTtl) {
2286
+ this.warnOncePerNamespace(
2287
+ warnedNoTagInvalidationTtl,
2288
+ `[CFCacheStore] invalidateTags is writing KV markers with no expiry ` +
2289
+ `(tagInvalidationTtl is unset): high-cardinality tags accumulate KV ` +
2290
+ `keys unboundedly (storage + list-scan cost) with no reaper. Set ` +
2291
+ `tagInvalidationTtl above your largest entry TTL+SWR to bound marker ` +
2292
+ `growth; setting it too small resurrects invalidated entries.`,
2293
+ );
2294
+ }
2295
+ await Promise.all(
2296
+ tags.map(async (tag) => {
2297
+ const markerKey = this.tagMarkerKey(tag);
2298
+ const markerKeyBytes = kvKeyByteLength(markerKey);
2299
+ if (markerKeyBytes > KV_MAX_KEY_BYTES) {
2300
+ failedTags.add(tag);
2301
+ errors.push(
2302
+ new Error(
2303
+ `tag "${tag}" produces a ${markerKeyBytes}-byte KV ` +
2304
+ `marker key, over the ${KV_MAX_KEY_BYTES}-byte limit`,
2305
+ ),
2306
+ );
2307
+ return;
2308
+ }
2309
+ try {
2310
+ await this.kv!.put(markerKey, String(invalidatedAt), {
2311
+ ...(this.tagInvalidationTtl
2312
+ ? { expirationTtl: this.tagInvalidationTtl }
2313
+ : {}),
2314
+ });
2315
+ } catch (error) {
2316
+ failedTags.add(tag);
2317
+ errors.push(error);
2318
+ }
2319
+ }),
2320
+ );
2321
+ }
2322
+
2323
+ // Write-through memo + L1 only for tags with a confirmed durable marker, and
2324
+ // only when KV is configured. Markers are read exclusively through
2325
+ // isGloballyInvalidated(), which short-circuits to "not invalidated" when
2326
+ // !this.kv; writing memo/L1 markers without KV would be dead state no read
2327
+ // path ever consults. The onRevalidateTag purge below still fires regardless
2328
+ // (it is additive and external to the marker cascade). The memo write is
2329
+ // synchronous (read-your-own-writes); the L1 Cache API writes are
2330
+ // independent, so fan them out in parallel rather than awaiting each.
2331
+ if (this.kv) {
2332
+ const l1Writes: Promise<void>[] = [];
2333
+ for (const tag of tags) {
2334
+ if (failedTags.has(tag)) continue;
2335
+ memo?.set(tag, invalidatedAt);
2336
+ if (this.tagCacheTtl > 0) {
2337
+ l1Writes.push(
2338
+ this.putTagMarkerL1(tag, invalidatedAt, { critical: true }),
2339
+ );
2340
+ }
2341
+ }
2342
+ if (l1Writes.length > 0) await Promise.all(l1Writes);
2343
+ }
2344
+
2345
+ // One batched eager purge of the lookup markers for the whole call. Fired
2346
+ // regardless of KV write outcome (it is additive and uses pure string ops).
2347
+ if (this.onRevalidateTag) {
2348
+ try {
2349
+ await this.onRevalidateTag(tags.map((tag) => this.lookupPurgeTag(tag)));
2350
+ } catch (error) {
2351
+ reportCacheError(
2352
+ error,
2353
+ "cache-invalidate",
2354
+ "[CFCacheStore] onRevalidateTag hook",
2355
+ );
2356
+ }
2357
+ }
2358
+
2359
+ if (failedTags.size > 0) {
2360
+ const err = new Error(
2361
+ `[CFCacheStore] ${failedTags.size}/${tags.length} tag marker write(s) ` +
2362
+ `failed: ${[...failedTags].join(", ")}. Those tags may still serve ` +
2363
+ `stale data across requests/colos; retry the invalidation.`,
2364
+ );
2365
+ (err as Error & { cause?: unknown }).cause = errors[0];
2366
+ throw err;
2367
+ }
2368
+ }
2369
+
761
2370
  // ============================================================================
762
2371
  // KV L2 Helpers
763
2372
  // ============================================================================
@@ -768,28 +2377,78 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
768
2377
  * Promotes hits to L1 via waitUntil.
769
2378
  * @internal
770
2379
  */
771
- private async kvGetSegment(key: string): Promise<CacheGetResult | null> {
2380
+ private async kvGetSegment(
2381
+ key: string,
2382
+ opts?: { suppressRevalidate?: boolean },
2383
+ ): Promise<CacheGetResult | null> {
772
2384
  if (!this.kv) return null;
773
2385
 
774
2386
  try {
775
2387
  const kvKey = this.toKVKey(key);
776
- const raw = await this.kv.get(kvKey, { type: "json" });
777
- if (!raw) return null;
2388
+ const { value: envelope, timedOut } =
2389
+ await this.kvGetOrEvict<KVSegmentEnvelope>(
2390
+ kvKey,
2391
+ (e) =>
2392
+ typeof e.e === "number" && typeof e.s === "number" && e.d != null,
2393
+ "kvGetSegment",
2394
+ );
2395
+ if (timedOut) {
2396
+ // Abandoned slow KV read: no envelope, so no promote-to-L1. Distinct
2397
+ // from a genuine kv-miss so the degradation is visible on wrangler tail.
2398
+ if (this.debug)
2399
+ this.emitDebug({ op: "get", key, outcome: "kv-timeout" });
2400
+ return null;
2401
+ }
2402
+ if (!envelope) {
2403
+ // Missing key, or a corrupt entry already evicted + reported by
2404
+ // kvGetOrEvict. Either way a miss.
2405
+ if (this.debug) this.emitDebug({ op: "get", key, outcome: "kv-miss" });
2406
+ return null;
2407
+ }
778
2408
 
779
- const envelope = raw as KVSegmentEnvelope;
780
2409
  const now = Date.now();
781
2410
 
782
2411
  // Hard-expired — treat as miss
783
- if (now > envelope.e) return null;
2412
+ if (now > envelope.e) {
2413
+ if (this.debug) this.emitDebug({ op: "get", key, outcome: "kv-miss" });
2414
+ return null;
2415
+ }
784
2416
 
785
- const shouldRevalidate = now > envelope.s;
2417
+ // Tag invalidation check (also covers the KV tier, not just L1).
2418
+ if (
2419
+ await this.isGloballyInvalidated(envelope.d.tags, envelope.d.taggedAt)
2420
+ ) {
2421
+ if (this.debug)
2422
+ this.emitDebug({ op: "get", key, outcome: "tag-invalidated" });
2423
+ return null;
2424
+ }
2425
+
2426
+ // When this is a degraded L1 fall-through (body-timeout / non-200), the
2427
+ // caller asks us to suppress revalidation: KV has no REVALIDATING herd
2428
+ // guard, so N concurrent degraded reads would otherwise each spawn a
2429
+ // render exactly when the colo is already struggling. We still serve the
2430
+ // stale data and still promote to L1; only the revalidation is withheld.
2431
+ const stale = now > envelope.s;
2432
+ const shouldRevalidate = stale && !opts?.suppressRevalidate;
786
2433
 
787
2434
  // Promote to L1 in background
788
2435
  this.promoteSegmentToL1(key, envelope);
789
2436
 
2437
+ if (this.debug)
2438
+ this.emitDebug({
2439
+ op: "get",
2440
+ key,
2441
+ outcome: !stale
2442
+ ? "kv-fresh"
2443
+ : opts?.suppressRevalidate
2444
+ ? "kv-stale-suppressed"
2445
+ : "kv-stale",
2446
+ shouldRevalidate,
2447
+ });
790
2448
  return { data: envelope.d, shouldRevalidate };
791
2449
  } catch (error) {
792
- console.error("[CFCacheStore] KV get failed:", error);
2450
+ reportCacheError(error, "cache-read", "[CFCacheStore] kvGetSegment");
2451
+ if (this.debug) this.emitDebug({ op: "get", key, outcome: "error" });
793
2452
  return null;
794
2453
  }
795
2454
  }
@@ -809,22 +2468,46 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
809
2468
  if (!this.kv || !this.waitUntil || totalTtl < 60) return;
810
2469
 
811
2470
  const kvKey = this.toKVKey(key);
2471
+
2472
+ // Reject an oversized data-segment KV key the same way tag-marker keys are
2473
+ // rejected in invalidateTags(). A key over KV_MAX_KEY_BYTES makes kv.put()
2474
+ // fail, so the segment silently never lands in L2 (KV) and every cold-colo
2475
+ // or TTL-expired read re-renders instead of serving stale. Segment keys can
2476
+ // grow with user-controlled inputs (e.g. a route's search params), so report
2477
+ // a clear, actionable error and skip the doomed write rather than letting it
2478
+ // reject deep inside waitUntil as an opaque cache-write failure.
2479
+ const kvKeyBytes = kvKeyByteLength(kvKey);
2480
+ if (kvKeyBytes > KV_MAX_KEY_BYTES) {
2481
+ reportCacheError(
2482
+ new Error(
2483
+ `cache segment key produces a ${kvKeyBytes}-byte KV key, over the ` +
2484
+ `${KV_MAX_KEY_BYTES}-byte limit; the segment was not persisted to KV (L2). ` +
2485
+ `Reduce the cache-key inputs (e.g. large search params on this route).`,
2486
+ ),
2487
+ "cache-write",
2488
+ "[CFCacheStore] kvSetSegment",
2489
+ );
2490
+ return;
2491
+ }
2492
+
812
2493
  const expiresAt = staleAt + swrWindow * 1000;
813
2494
 
814
- this.waitUntil(async () => {
815
- try {
816
- const envelope: KVSegmentEnvelope = {
817
- d: data,
818
- s: staleAt,
819
- e: expiresAt,
820
- };
821
- await this.kv!.put(kvKey, JSON.stringify(envelope), {
822
- expirationTtl: totalTtl,
823
- });
824
- } catch (error) {
825
- console.error("[CFCacheStore] KV set failed:", error);
826
- }
827
- });
2495
+ this.waitUntil(() =>
2496
+ reportingAsync(
2497
+ () => {
2498
+ const envelope: KVSegmentEnvelope = {
2499
+ d: data,
2500
+ s: staleAt,
2501
+ e: expiresAt,
2502
+ };
2503
+ return this.kv!.put(kvKey, JSON.stringify(envelope), {
2504
+ expirationTtl: totalTtl,
2505
+ });
2506
+ },
2507
+ "cache-write",
2508
+ "[CFCacheStore] kvSetSegment",
2509
+ ),
2510
+ );
828
2511
  }
829
2512
 
830
2513
  /**
@@ -834,58 +2517,115 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
834
2517
  private promoteSegmentToL1(key: string, envelope: KVSegmentEnvelope): void {
835
2518
  if (!this.waitUntil) return;
836
2519
 
837
- this.waitUntil(async () => {
838
- try {
839
- const now = Date.now();
840
- const remainingTtl = Math.max(1, Math.floor((envelope.e - now) / 1000));
841
- const cache = await this.getCache();
842
- const request = this.keyToRequest(key);
843
-
844
- const response = new Response(JSON.stringify(envelope.d), {
845
- headers: {
846
- "Content-Type": "application/json",
847
- "Cache-Control": `public, max-age=${remainingTtl}`,
848
- [CACHE_STALE_AT_HEADER]: String(envelope.s),
849
- [CACHE_STATUS_HEADER]: "HIT",
850
- },
851
- });
852
-
853
- await cache.put(request, response);
854
- } catch (error) {
855
- console.error("[CFCacheStore] L1 promote failed:", error);
856
- }
857
- });
2520
+ this.waitUntil(() =>
2521
+ reportingAsync(
2522
+ async () => {
2523
+ const now = Date.now();
2524
+ const remainingTtl = Math.max(
2525
+ 1,
2526
+ Math.floor((envelope.e - now) / 1000),
2527
+ );
2528
+ const cache = await this.getCache();
2529
+ const request = this.keyToRequest(key);
2530
+
2531
+ const response = new Response(JSON.stringify(envelope.d), {
2532
+ headers: {
2533
+ "Content-Type": "application/json",
2534
+ "Cache-Control": `public, max-age=${remainingTtl}`,
2535
+ [CACHE_STALE_AT_HEADER]: String(envelope.s),
2536
+ // Carry the hard-expiry deadline so a promoted entry that later
2537
+ // goes stale re-puts with the correct remaining ttl (see set()).
2538
+ [CACHE_EXPIRES_AT_HEADER]: String(envelope.e),
2539
+ [CACHE_STATUS_HEADER]: "HIT",
2540
+ // Preserve tags across KV->L1 promotion so the promoted entry
2541
+ // stays tag-invalidatable.
2542
+ ...this.tagHeaderEntries(envelope.d.tags, envelope.d.taggedAt),
2543
+ },
2544
+ });
2545
+
2546
+ await cache.put(request, response);
2547
+ },
2548
+ "cache-write",
2549
+ "[CFCacheStore] promoteSegmentToL1",
2550
+ ),
2551
+ );
858
2552
  }
859
2553
 
860
2554
  /**
861
2555
  * KV fallback for function cache reads.
862
2556
  * @internal
863
2557
  */
864
- private async kvGetItem(key: string): Promise<CacheItemResult | null> {
2558
+ private async kvGetItem(
2559
+ key: string,
2560
+ opts?: { suppressRevalidate?: boolean },
2561
+ ): Promise<CacheItemResult | null> {
865
2562
  if (!this.kv) return null;
866
2563
 
867
2564
  try {
868
2565
  const kvKey = this.toKVKey(`fn:${key}`);
869
- const raw = await this.kv.get(kvKey, { type: "json" });
870
- if (!raw) return null;
2566
+ const { value: envelope, timedOut } =
2567
+ await this.kvGetOrEvict<KVItemEnvelope>(
2568
+ kvKey,
2569
+ (e) =>
2570
+ typeof e.v === "string" &&
2571
+ typeof e.e === "number" &&
2572
+ typeof e.s === "number",
2573
+ "kvGetItem",
2574
+ );
2575
+ if (timedOut) {
2576
+ if (this.debug)
2577
+ this.emitDebug({ op: "getItem", key, outcome: "kv-timeout" });
2578
+ return null;
2579
+ }
2580
+ if (!envelope) {
2581
+ if (this.debug)
2582
+ this.emitDebug({ op: "getItem", key, outcome: "kv-miss" });
2583
+ return null;
2584
+ }
871
2585
 
872
- const envelope = raw as KVItemEnvelope;
873
2586
  const now = Date.now();
874
2587
 
875
- if (now > envelope.e) return null;
2588
+ if (now > envelope.e) {
2589
+ if (this.debug)
2590
+ this.emitDebug({ op: "getItem", key, outcome: "kv-miss" });
2591
+ return null;
2592
+ }
876
2593
 
877
- const shouldRevalidate = now > envelope.s;
2594
+ // Tag invalidation check (also covers the KV tier, not just L1).
2595
+ if (await this.isGloballyInvalidated(envelope.t, envelope.ta)) {
2596
+ if (this.debug)
2597
+ this.emitDebug({ op: "getItem", key, outcome: "tag-invalidated" });
2598
+ return null;
2599
+ }
2600
+
2601
+ // Degraded fall-through suppresses revalidation (no KV herd guard); see
2602
+ // kvGetSegment. Still serves stale and still promotes.
2603
+ const stale = now > envelope.s;
2604
+ const shouldRevalidate = stale && !opts?.suppressRevalidate;
878
2605
 
879
2606
  // Promote to L1
880
2607
  this.promoteItemToL1(key, envelope);
881
2608
 
2609
+ if (this.debug)
2610
+ this.emitDebug({
2611
+ op: "getItem",
2612
+ key,
2613
+ outcome: !stale
2614
+ ? "kv-fresh"
2615
+ : opts?.suppressRevalidate
2616
+ ? "kv-stale-suppressed"
2617
+ : "kv-stale",
2618
+ shouldRevalidate,
2619
+ });
882
2620
  return {
883
2621
  value: envelope.v,
884
2622
  handles: envelope.h,
885
2623
  shouldRevalidate,
2624
+ tags: envelope.t,
886
2625
  };
887
2626
  } catch (error) {
888
- console.error("[CFCacheStore] KV getItem failed:", error);
2627
+ reportCacheError(error, "cache-read", "[CFCacheStore] kvGetItem");
2628
+ if (this.debug) this.emitDebug({ op: "getItem", key, outcome: "error" });
889
2629
  return null;
890
2630
  }
891
2631
  }
@@ -897,28 +2637,41 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
897
2637
  private promoteItemToL1(key: string, envelope: KVItemEnvelope): void {
898
2638
  if (!this.waitUntil) return;
899
2639
 
900
- this.waitUntil(async () => {
901
- try {
902
- const now = Date.now();
903
- const remainingTtl = Math.max(1, Math.floor((envelope.e - now) / 1000));
904
- const cache = await this.getCache();
905
- const request = this.keyToRequest(`fn:${key}`);
906
-
907
- const body = JSON.stringify({ value: envelope.v, handles: envelope.h });
908
- const response = new Response(body, {
909
- headers: {
910
- "Content-Type": "application/json",
911
- "Cache-Control": `public, max-age=${remainingTtl}`,
912
- [CACHE_STALE_AT_HEADER]: String(envelope.s),
913
- [CACHE_STATUS_HEADER]: "HIT",
914
- },
915
- });
916
-
917
- await cache.put(request, response);
918
- } catch (error) {
919
- console.error("[CFCacheStore] L1 item promote failed:", error);
920
- }
921
- });
2640
+ this.waitUntil(() =>
2641
+ reportingAsync(
2642
+ async () => {
2643
+ const now = Date.now();
2644
+ const remainingTtl = Math.max(
2645
+ 1,
2646
+ Math.floor((envelope.e - now) / 1000),
2647
+ );
2648
+ const cache = await this.getCache();
2649
+ const request = this.keyToRequest(`fn:${key}`);
2650
+
2651
+ const body = JSON.stringify({
2652
+ value: envelope.v,
2653
+ handles: envelope.h,
2654
+ });
2655
+ const response = new Response(body, {
2656
+ headers: {
2657
+ "Content-Type": "application/json",
2658
+ "Cache-Control": `public, max-age=${remainingTtl}`,
2659
+ [CACHE_STALE_AT_HEADER]: String(envelope.s),
2660
+ // Carry the hard-expiry deadline; see promoteSegmentToL1 / set().
2661
+ [CACHE_EXPIRES_AT_HEADER]: String(envelope.e),
2662
+ [CACHE_STATUS_HEADER]: "HIT",
2663
+ // Preserve tags across KV->L1 promotion (the item tier previously
2664
+ // dropped them, permanently disabling tag invalidation here).
2665
+ ...this.tagHeaderEntries(envelope.t, envelope.ta),
2666
+ },
2667
+ });
2668
+
2669
+ await cache.put(request, response);
2670
+ },
2671
+ "cache-write",
2672
+ "[CFCacheStore] promoteItemToL1",
2673
+ ),
2674
+ );
922
2675
  }
923
2676
 
924
2677
  /**
@@ -931,32 +2684,82 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
931
2684
  if (!this.kv) return null;
932
2685
 
933
2686
  try {
934
- const kvKey = this.toKVKey(`doc:${key}`);
935
- const raw = await this.kv.get(kvKey, { type: "json" });
936
- if (!raw) return null;
2687
+ const kvKey = this.toDocKVKey(key);
2688
+ // The document path is debug-silent (op is only get/getItem): a KV-read
2689
+ // timeout here is bounded for resilience parity (kvGetOrEvict applies the
2690
+ // budget) but emits no kv-timeout event, so its absence from the debug
2691
+ // stream is expected. A null envelope is a miss -- missing key, a budget
2692
+ // timeout, or a corrupt entry already evicted + reported by kvGetOrEvict.
2693
+ const { value: envelope } = await this.kvGetOrEvict<KVResponseEnvelope>(
2694
+ kvKey,
2695
+ (e) =>
2696
+ typeof e.b === "string" &&
2697
+ typeof e.st === "number" &&
2698
+ typeof e.e === "number" &&
2699
+ typeof e.s === "number" &&
2700
+ // stx is optional but, if present, must be a string (feeds Response).
2701
+ (e.stx === undefined || typeof e.stx === "string") &&
2702
+ // hd must be an array of [name, value] string tuples; a malformed
2703
+ // shape would otherwise throw in `new Headers(hd)`. Validate it here
2704
+ // so a faulty envelope is a fail-open MISS, never a thrown read.
2705
+ Array.isArray(e.hd) &&
2706
+ e.hd.every(
2707
+ (entry) =>
2708
+ Array.isArray(entry) &&
2709
+ entry.length === 2 &&
2710
+ typeof entry[0] === "string" &&
2711
+ typeof entry[1] === "string",
2712
+ ),
2713
+ "kvGetResponse",
2714
+ );
2715
+ if (!envelope) return null;
937
2716
 
938
- const envelope = raw as KVResponseEnvelope;
939
2717
  const now = Date.now();
940
2718
 
941
2719
  if (now > envelope.e) return null;
942
2720
 
2721
+ // Tag invalidation check (also covers the KV tier, not just L1).
2722
+ if (await this.isGloballyInvalidated(envelope.t, envelope.ta)) {
2723
+ return null;
2724
+ }
2725
+
943
2726
  const shouldRevalidate = now > envelope.s;
944
2727
 
945
- // Reconstruct Response (decode base64 binary)
946
- const headers = new Headers(envelope.hd);
947
- const bodyBuffer = base64ToBuffer(envelope.b);
948
- const response = new Response(bodyBuffer, {
949
- status: envelope.st,
950
- statusText: envelope.stx,
951
- headers,
952
- });
2728
+ // Reconstruct Response: decode base64 -> binary, rebuild headers/status.
2729
+ // Corrupt/partial base64 throws in atob; malformed `hd` or an out-of-range
2730
+ // `st` throws in new Headers/new Response. Any of these is a faulty entry,
2731
+ // so evict it and miss rather than re-failing every read until TTL.
2732
+ let response: Response;
2733
+ try {
2734
+ // Finding #3 (read side): strip per-client signals a stale envelope may
2735
+ // carry. Inside the try so a malformed `hd` evicts (not throws through);
2736
+ // mutates `hd` in place so promoteResponseToL1 re-seeds from it too.
2737
+ envelope.hd = envelope.hd.filter(
2738
+ ([name]) => !isPerClientSignalHeader(name),
2739
+ );
2740
+ const bodyBuffer = base64ToBuffer(envelope.b);
2741
+ const headers = new Headers(envelope.hd);
2742
+ response = new Response(bodyBuffer, {
2743
+ status: envelope.st,
2744
+ statusText: envelope.stx,
2745
+ headers,
2746
+ });
2747
+ } catch (error) {
2748
+ reportCacheError(
2749
+ error,
2750
+ "cache-corrupt",
2751
+ "[CFCacheStore] kvGetResponse: corrupt response envelope, evicting",
2752
+ );
2753
+ this.scheduleKvEvict(kvKey, "kvGetResponse");
2754
+ return null;
2755
+ }
953
2756
 
954
2757
  // Promote to L1
955
2758
  this.promoteResponseToL1(key, envelope);
956
2759
 
957
2760
  return { response, shouldRevalidate };
958
2761
  } catch (error) {
959
- console.error("[CFCacheStore] KV getResponse failed:", error);
2762
+ reportCacheError(error, "cache-read", "[CFCacheStore] kvGetResponse");
960
2763
  return null;
961
2764
  }
962
2765
  }
@@ -968,56 +2771,46 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
968
2771
  private promoteResponseToL1(key: string, envelope: KVResponseEnvelope): void {
969
2772
  if (!this.waitUntil) return;
970
2773
 
971
- this.waitUntil(async () => {
972
- try {
973
- const now = Date.now();
974
- const remainingTtl = Math.max(1, Math.floor((envelope.e - now) / 1000));
975
- const cache = await this.getCache();
976
- const request = this.keyToRequest(`doc:${key}`);
977
-
978
- const headers = new Headers(envelope.hd);
979
- const originalCacheControl = headers.get("Cache-Control");
980
- if (originalCacheControl !== null) {
981
- headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
982
- }
983
- headers.set("Cache-Control", `public, max-age=${remainingTtl}`);
984
- headers.set(CACHE_STALE_AT_HEADER, String(envelope.s));
985
-
986
- const bodyBuffer = base64ToBuffer(envelope.b);
987
- const response = new Response(bodyBuffer, {
988
- status: envelope.st,
989
- statusText: envelope.stx,
990
- headers,
991
- });
992
-
993
- await cache.put(request, response);
994
- } catch (error) {
995
- console.error("[CFCacheStore] L1 response promote failed:", error);
996
- }
997
- });
998
- }
999
- }
1000
-
1001
- // ============================================================================
1002
- // Base64 Helpers (binary-safe response body encoding for KV)
1003
- // ============================================================================
1004
-
1005
- /** Encode ArrayBuffer to base64 string. */
1006
- function bufferToBase64(buffer: ArrayBuffer): string {
1007
- const bytes = new Uint8Array(buffer);
1008
- let binary = "";
1009
- for (let i = 0; i < bytes.length; i++) {
1010
- binary += String.fromCharCode(bytes[i]!);
1011
- }
1012
- return btoa(binary);
1013
- }
1014
-
1015
- /** Decode base64 string to ArrayBuffer. */
1016
- function base64ToBuffer(base64: string): ArrayBuffer {
1017
- const binary = atob(base64);
1018
- const bytes = new Uint8Array(binary.length);
1019
- for (let i = 0; i < binary.length; i++) {
1020
- bytes[i] = binary.charCodeAt(i);
2774
+ this.waitUntil(() =>
2775
+ reportingAsync(
2776
+ async () => {
2777
+ const now = Date.now();
2778
+ const remainingTtl = Math.max(
2779
+ 1,
2780
+ Math.floor((envelope.e - now) / 1000),
2781
+ );
2782
+ const cache = await this.getCache();
2783
+ const request = this.keyToRequest(`doc:${key}`);
2784
+
2785
+ const headers = new Headers(envelope.hd);
2786
+ const originalCacheControl = headers.get("Cache-Control");
2787
+ if (originalCacheControl !== null) {
2788
+ headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
2789
+ }
2790
+ headers.set("Cache-Control", `public, max-age=${remainingTtl}`);
2791
+ headers.set(CACHE_STALE_AT_HEADER, String(envelope.s));
2792
+ // Carry the hard-expiry deadline so the document herd guard's
2793
+ // markResponseRevalidating re-put can compute the remaining window
2794
+ // (matches promoteSegmentToL1/promoteItemToL1); without it a stale
2795
+ // re-put would floor to max-age=1 and churn the KV-promoted twin.
2796
+ headers.set(CACHE_EXPIRES_AT_HEADER, String(envelope.e));
2797
+ // Re-attach the internal tag headers (envelope.hd is client-facing
2798
+ // and intentionally excludes them) so the promoted entry stays
2799
+ // invalidatable.
2800
+ this.setTagHeaders(headers, envelope.t, envelope.ta);
2801
+
2802
+ const bodyBuffer = base64ToBuffer(envelope.b);
2803
+ const response = new Response(bodyBuffer, {
2804
+ status: envelope.st,
2805
+ statusText: envelope.stx,
2806
+ headers,
2807
+ });
2808
+
2809
+ await cache.put(request, response);
2810
+ },
2811
+ "cache-write",
2812
+ "[CFCacheStore] promoteResponseToL1",
2813
+ ),
2814
+ );
1021
2815
  }
1022
- return bytes.buffer;
1023
2816
  }