@rangojs/router 0.0.0-experimental.31 → 0.0.0-experimental.3232cd17

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