@rangojs/router 0.0.0-experimental.1b930379 → 0.0.0-experimental.1c0bdfad

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