@rangojs/router 0.0.0-experimental.79 → 0.0.0-experimental.7c7e4327

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