@rangojs/router 0.0.0-experimental.120 → 0.0.0-experimental.122

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