@rangojs/router 0.0.0-experimental.15 → 0.0.0-experimental.151

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