@rangojs/router 0.0.0-experimental.002d056c

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 (305) hide show
  1. package/AGENTS.md +9 -0
  2. package/README.md +899 -0
  3. package/dist/bin/rango.js +1606 -0
  4. package/dist/vite/index.js +5153 -0
  5. package/package.json +177 -0
  6. package/skills/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +262 -0
  8. package/skills/caching/SKILL.md +253 -0
  9. package/skills/composability/SKILL.md +172 -0
  10. package/skills/debug-manifest/SKILL.md +112 -0
  11. package/skills/document-cache/SKILL.md +182 -0
  12. package/skills/fonts/SKILL.md +167 -0
  13. package/skills/hooks/SKILL.md +704 -0
  14. package/skills/host-router/SKILL.md +218 -0
  15. package/skills/intercept/SKILL.md +313 -0
  16. package/skills/layout/SKILL.md +310 -0
  17. package/skills/links/SKILL.md +239 -0
  18. package/skills/loader/SKILL.md +596 -0
  19. package/skills/middleware/SKILL.md +339 -0
  20. package/skills/mime-routes/SKILL.md +128 -0
  21. package/skills/parallel/SKILL.md +305 -0
  22. package/skills/prerender/SKILL.md +643 -0
  23. package/skills/rango/SKILL.md +118 -0
  24. package/skills/response-routes/SKILL.md +411 -0
  25. package/skills/route/SKILL.md +385 -0
  26. package/skills/router-setup/SKILL.md +439 -0
  27. package/skills/tailwind/SKILL.md +129 -0
  28. package/skills/theme/SKILL.md +79 -0
  29. package/skills/typesafety/SKILL.md +623 -0
  30. package/skills/use-cache/SKILL.md +324 -0
  31. package/src/__internal.ts +273 -0
  32. package/src/bin/rango.ts +321 -0
  33. package/src/browser/action-coordinator.ts +97 -0
  34. package/src/browser/action-response-classifier.ts +99 -0
  35. package/src/browser/event-controller.ts +899 -0
  36. package/src/browser/history-state.ts +80 -0
  37. package/src/browser/index.ts +18 -0
  38. package/src/browser/intercept-utils.ts +52 -0
  39. package/src/browser/link-interceptor.ts +141 -0
  40. package/src/browser/logging.ts +55 -0
  41. package/src/browser/merge-segment-loaders.ts +134 -0
  42. package/src/browser/navigation-bridge.ts +638 -0
  43. package/src/browser/navigation-client.ts +261 -0
  44. package/src/browser/navigation-store.ts +806 -0
  45. package/src/browser/navigation-transaction.ts +297 -0
  46. package/src/browser/network-error-handler.ts +61 -0
  47. package/src/browser/partial-update.ts +582 -0
  48. package/src/browser/prefetch/cache.ts +206 -0
  49. package/src/browser/prefetch/fetch.ts +145 -0
  50. package/src/browser/prefetch/observer.ts +65 -0
  51. package/src/browser/prefetch/policy.ts +48 -0
  52. package/src/browser/prefetch/queue.ts +128 -0
  53. package/src/browser/rango-state.ts +112 -0
  54. package/src/browser/react/Link.tsx +368 -0
  55. package/src/browser/react/NavigationProvider.tsx +413 -0
  56. package/src/browser/react/ScrollRestoration.tsx +94 -0
  57. package/src/browser/react/context.ts +59 -0
  58. package/src/browser/react/filter-segment-order.ts +11 -0
  59. package/src/browser/react/index.ts +52 -0
  60. package/src/browser/react/location-state-shared.ts +162 -0
  61. package/src/browser/react/location-state.ts +107 -0
  62. package/src/browser/react/mount-context.ts +37 -0
  63. package/src/browser/react/nonce-context.ts +23 -0
  64. package/src/browser/react/shallow-equal.ts +27 -0
  65. package/src/browser/react/use-action.ts +218 -0
  66. package/src/browser/react/use-client-cache.ts +58 -0
  67. package/src/browser/react/use-handle.ts +162 -0
  68. package/src/browser/react/use-href.tsx +40 -0
  69. package/src/browser/react/use-link-status.ts +135 -0
  70. package/src/browser/react/use-mount.ts +31 -0
  71. package/src/browser/react/use-navigation.ts +99 -0
  72. package/src/browser/react/use-params.ts +65 -0
  73. package/src/browser/react/use-pathname.ts +47 -0
  74. package/src/browser/react/use-router.ts +63 -0
  75. package/src/browser/react/use-search-params.ts +56 -0
  76. package/src/browser/react/use-segments.ts +171 -0
  77. package/src/browser/response-adapter.ts +73 -0
  78. package/src/browser/rsc-router.tsx +464 -0
  79. package/src/browser/scroll-restoration.ts +397 -0
  80. package/src/browser/segment-reconciler.ts +216 -0
  81. package/src/browser/segment-structure-assert.ts +83 -0
  82. package/src/browser/server-action-bridge.ts +667 -0
  83. package/src/browser/shallow.ts +40 -0
  84. package/src/browser/types.ts +547 -0
  85. package/src/browser/validate-redirect-origin.ts +29 -0
  86. package/src/build/generate-manifest.ts +438 -0
  87. package/src/build/generate-route-types.ts +36 -0
  88. package/src/build/index.ts +35 -0
  89. package/src/build/route-trie.ts +265 -0
  90. package/src/build/route-types/ast-helpers.ts +25 -0
  91. package/src/build/route-types/ast-route-extraction.ts +98 -0
  92. package/src/build/route-types/codegen.ts +102 -0
  93. package/src/build/route-types/include-resolution.ts +411 -0
  94. package/src/build/route-types/param-extraction.ts +48 -0
  95. package/src/build/route-types/per-module-writer.ts +128 -0
  96. package/src/build/route-types/router-processing.ts +479 -0
  97. package/src/build/route-types/scan-filter.ts +78 -0
  98. package/src/build/runtime-discovery.ts +231 -0
  99. package/src/cache/background-task.ts +34 -0
  100. package/src/cache/cache-key-utils.ts +44 -0
  101. package/src/cache/cache-policy.ts +125 -0
  102. package/src/cache/cache-runtime.ts +338 -0
  103. package/src/cache/cache-scope.ts +382 -0
  104. package/src/cache/cf/cf-cache-store.ts +982 -0
  105. package/src/cache/cf/index.ts +29 -0
  106. package/src/cache/document-cache.ts +369 -0
  107. package/src/cache/handle-capture.ts +81 -0
  108. package/src/cache/handle-snapshot.ts +41 -0
  109. package/src/cache/index.ts +44 -0
  110. package/src/cache/memory-segment-store.ts +328 -0
  111. package/src/cache/profile-registry.ts +73 -0
  112. package/src/cache/read-through-swr.ts +134 -0
  113. package/src/cache/segment-codec.ts +256 -0
  114. package/src/cache/taint.ts +98 -0
  115. package/src/cache/types.ts +342 -0
  116. package/src/client.rsc.tsx +85 -0
  117. package/src/client.tsx +601 -0
  118. package/src/component-utils.ts +76 -0
  119. package/src/components/DefaultDocument.tsx +27 -0
  120. package/src/context-var.ts +86 -0
  121. package/src/debug.ts +243 -0
  122. package/src/default-error-boundary.tsx +88 -0
  123. package/src/deps/browser.ts +8 -0
  124. package/src/deps/html-stream-client.ts +2 -0
  125. package/src/deps/html-stream-server.ts +2 -0
  126. package/src/deps/rsc.ts +10 -0
  127. package/src/deps/ssr.ts +2 -0
  128. package/src/errors.ts +365 -0
  129. package/src/handle.ts +135 -0
  130. package/src/handles/MetaTags.tsx +246 -0
  131. package/src/handles/breadcrumbs.ts +66 -0
  132. package/src/handles/index.ts +7 -0
  133. package/src/handles/meta.ts +264 -0
  134. package/src/host/cookie-handler.ts +165 -0
  135. package/src/host/errors.ts +97 -0
  136. package/src/host/index.ts +53 -0
  137. package/src/host/pattern-matcher.ts +214 -0
  138. package/src/host/router.ts +352 -0
  139. package/src/host/testing.ts +79 -0
  140. package/src/host/types.ts +146 -0
  141. package/src/host/utils.ts +25 -0
  142. package/src/href-client.ts +222 -0
  143. package/src/index.rsc.ts +233 -0
  144. package/src/index.ts +277 -0
  145. package/src/internal-debug.ts +11 -0
  146. package/src/loader.rsc.ts +89 -0
  147. package/src/loader.ts +64 -0
  148. package/src/network-error-thrower.tsx +23 -0
  149. package/src/outlet-context.ts +15 -0
  150. package/src/outlet-provider.tsx +45 -0
  151. package/src/prerender/param-hash.ts +37 -0
  152. package/src/prerender/store.ts +185 -0
  153. package/src/prerender.ts +463 -0
  154. package/src/reverse.ts +330 -0
  155. package/src/root-error-boundary.tsx +289 -0
  156. package/src/route-content-wrapper.tsx +196 -0
  157. package/src/route-definition/dsl-helpers.ts +934 -0
  158. package/src/route-definition/helper-factories.ts +200 -0
  159. package/src/route-definition/helpers-types.ts +430 -0
  160. package/src/route-definition/index.ts +52 -0
  161. package/src/route-definition/redirect.ts +93 -0
  162. package/src/route-definition.ts +1 -0
  163. package/src/route-map-builder.ts +281 -0
  164. package/src/route-name.ts +53 -0
  165. package/src/route-types.ts +259 -0
  166. package/src/router/content-negotiation.ts +116 -0
  167. package/src/router/debug-manifest.ts +72 -0
  168. package/src/router/error-handling.ts +287 -0
  169. package/src/router/find-match.ts +160 -0
  170. package/src/router/handler-context.ts +451 -0
  171. package/src/router/intercept-resolution.ts +397 -0
  172. package/src/router/lazy-includes.ts +236 -0
  173. package/src/router/loader-resolution.ts +420 -0
  174. package/src/router/logging.ts +251 -0
  175. package/src/router/manifest.ts +269 -0
  176. package/src/router/match-api.ts +620 -0
  177. package/src/router/match-context.ts +266 -0
  178. package/src/router/match-handlers.ts +440 -0
  179. package/src/router/match-middleware/background-revalidation.ts +223 -0
  180. package/src/router/match-middleware/cache-lookup.ts +634 -0
  181. package/src/router/match-middleware/cache-store.ts +295 -0
  182. package/src/router/match-middleware/index.ts +81 -0
  183. package/src/router/match-middleware/intercept-resolution.ts +306 -0
  184. package/src/router/match-middleware/segment-resolution.ts +193 -0
  185. package/src/router/match-pipelines.ts +179 -0
  186. package/src/router/match-result.ts +219 -0
  187. package/src/router/metrics.ts +282 -0
  188. package/src/router/middleware-cookies.ts +55 -0
  189. package/src/router/middleware-types.ts +222 -0
  190. package/src/router/middleware.ts +749 -0
  191. package/src/router/pattern-matching.ts +563 -0
  192. package/src/router/prerender-match.ts +402 -0
  193. package/src/router/preview-match.ts +170 -0
  194. package/src/router/revalidation.ts +289 -0
  195. package/src/router/router-context.ts +320 -0
  196. package/src/router/router-interfaces.ts +452 -0
  197. package/src/router/router-options.ts +592 -0
  198. package/src/router/router-registry.ts +24 -0
  199. package/src/router/segment-resolution/fresh.ts +570 -0
  200. package/src/router/segment-resolution/helpers.ts +263 -0
  201. package/src/router/segment-resolution/loader-cache.ts +198 -0
  202. package/src/router/segment-resolution/revalidation.ts +1242 -0
  203. package/src/router/segment-resolution/static-store.ts +67 -0
  204. package/src/router/segment-resolution.ts +21 -0
  205. package/src/router/segment-wrappers.ts +291 -0
  206. package/src/router/telemetry-otel.ts +299 -0
  207. package/src/router/telemetry.ts +300 -0
  208. package/src/router/timeout.ts +148 -0
  209. package/src/router/trie-matching.ts +239 -0
  210. package/src/router/types.ts +170 -0
  211. package/src/router.ts +1006 -0
  212. package/src/rsc/handler-context.ts +45 -0
  213. package/src/rsc/handler.ts +1089 -0
  214. package/src/rsc/helpers.ts +198 -0
  215. package/src/rsc/index.ts +36 -0
  216. package/src/rsc/loader-fetch.ts +209 -0
  217. package/src/rsc/manifest-init.ts +86 -0
  218. package/src/rsc/nonce.ts +32 -0
  219. package/src/rsc/origin-guard.ts +141 -0
  220. package/src/rsc/progressive-enhancement.ts +379 -0
  221. package/src/rsc/response-error.ts +37 -0
  222. package/src/rsc/response-route-handler.ts +347 -0
  223. package/src/rsc/rsc-rendering.ts +237 -0
  224. package/src/rsc/runtime-warnings.ts +42 -0
  225. package/src/rsc/server-action.ts +348 -0
  226. package/src/rsc/ssr-setup.ts +128 -0
  227. package/src/rsc/types.ts +263 -0
  228. package/src/search-params.ts +230 -0
  229. package/src/segment-system.tsx +454 -0
  230. package/src/server/context.ts +591 -0
  231. package/src/server/cookie-store.ts +190 -0
  232. package/src/server/fetchable-loader-store.ts +37 -0
  233. package/src/server/handle-store.ts +308 -0
  234. package/src/server/loader-registry.ts +133 -0
  235. package/src/server/request-context.ts +920 -0
  236. package/src/server/root-layout.tsx +10 -0
  237. package/src/server/tsconfig.json +14 -0
  238. package/src/server.ts +51 -0
  239. package/src/ssr/index.tsx +365 -0
  240. package/src/static-handler.ts +114 -0
  241. package/src/theme/ThemeProvider.tsx +297 -0
  242. package/src/theme/ThemeScript.tsx +61 -0
  243. package/src/theme/constants.ts +62 -0
  244. package/src/theme/index.ts +48 -0
  245. package/src/theme/theme-context.ts +44 -0
  246. package/src/theme/theme-script.ts +155 -0
  247. package/src/theme/types.ts +182 -0
  248. package/src/theme/use-theme.ts +44 -0
  249. package/src/types/boundaries.ts +158 -0
  250. package/src/types/cache-types.ts +198 -0
  251. package/src/types/error-types.ts +192 -0
  252. package/src/types/global-namespace.ts +100 -0
  253. package/src/types/handler-context.ts +687 -0
  254. package/src/types/index.ts +88 -0
  255. package/src/types/loader-types.ts +183 -0
  256. package/src/types/route-config.ts +170 -0
  257. package/src/types/route-entry.ts +109 -0
  258. package/src/types/segments.ts +148 -0
  259. package/src/types.ts +1 -0
  260. package/src/urls/include-helper.ts +197 -0
  261. package/src/urls/index.ts +53 -0
  262. package/src/urls/path-helper-types.ts +339 -0
  263. package/src/urls/path-helper.ts +329 -0
  264. package/src/urls/pattern-types.ts +95 -0
  265. package/src/urls/response-types.ts +106 -0
  266. package/src/urls/type-extraction.ts +372 -0
  267. package/src/urls/urls-function.ts +98 -0
  268. package/src/urls.ts +1 -0
  269. package/src/use-loader.tsx +354 -0
  270. package/src/vite/discovery/bundle-postprocess.ts +184 -0
  271. package/src/vite/discovery/discover-routers.ts +344 -0
  272. package/src/vite/discovery/prerender-collection.ts +385 -0
  273. package/src/vite/discovery/route-types-writer.ts +258 -0
  274. package/src/vite/discovery/self-gen-tracking.ts +47 -0
  275. package/src/vite/discovery/state.ts +108 -0
  276. package/src/vite/discovery/virtual-module-codegen.ts +203 -0
  277. package/src/vite/index.ts +16 -0
  278. package/src/vite/plugin-types.ts +48 -0
  279. package/src/vite/plugins/cjs-to-esm.ts +93 -0
  280. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  281. package/src/vite/plugins/client-ref-hashing.ts +105 -0
  282. package/src/vite/plugins/expose-action-id.ts +363 -0
  283. package/src/vite/plugins/expose-id-utils.ts +287 -0
  284. package/src/vite/plugins/expose-ids/export-analysis.ts +296 -0
  285. package/src/vite/plugins/expose-ids/handler-transform.ts +179 -0
  286. package/src/vite/plugins/expose-ids/loader-transform.ts +74 -0
  287. package/src/vite/plugins/expose-ids/router-transform.ts +110 -0
  288. package/src/vite/plugins/expose-ids/types.ts +45 -0
  289. package/src/vite/plugins/expose-internal-ids.ts +569 -0
  290. package/src/vite/plugins/refresh-cmd.ts +65 -0
  291. package/src/vite/plugins/use-cache-transform.ts +323 -0
  292. package/src/vite/plugins/version-injector.ts +83 -0
  293. package/src/vite/plugins/version-plugin.ts +266 -0
  294. package/src/vite/plugins/version.d.ts +12 -0
  295. package/src/vite/plugins/virtual-entries.ts +123 -0
  296. package/src/vite/plugins/virtual-stub-plugin.ts +29 -0
  297. package/src/vite/rango.ts +445 -0
  298. package/src/vite/router-discovery.ts +777 -0
  299. package/src/vite/utils/ast-handler-extract.ts +517 -0
  300. package/src/vite/utils/banner.ts +36 -0
  301. package/src/vite/utils/bundle-analysis.ts +137 -0
  302. package/src/vite/utils/manifest-utils.ts +70 -0
  303. package/src/vite/utils/package-resolution.ts +121 -0
  304. package/src/vite/utils/prerender-utils.ts +189 -0
  305. package/src/vite/utils/shared-utils.ts +169 -0
@@ -0,0 +1,982 @@
1
+ /// <reference path="../../vite/plugins/version.d.ts" />
2
+
3
+ // Extend CacheStorage with Cloudflare's default cache property
4
+ declare global {
5
+ interface CacheStorage {
6
+ readonly default: Cache;
7
+ }
8
+ }
9
+
10
+ /**
11
+ * Cloudflare Edge Cache Store
12
+ *
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)
21
+ *
22
+ * Features:
23
+ * - Extended TTL for SWR window (max-age = ttl + swr)
24
+ * - Staleness via x-edge-cache-stale-at header
25
+ * - Atomic REVALIDATING status for thundering herd prevention (L1 only)
26
+ * - Non-blocking writes via waitUntil
27
+ * - KV L2 for cross-colo cache persistence
28
+ */
29
+
30
+ import type {
31
+ SegmentCacheStore,
32
+ CachedEntryData,
33
+ CacheDefaults,
34
+ CacheGetResult,
35
+ CacheItemResult,
36
+ CacheItemOptions,
37
+ } from "../types.js";
38
+ import {
39
+ _getRequestContext,
40
+ type RequestContext,
41
+ } from "../../server/request-context.js";
42
+ import { VERSION } from "@rangojs/router:version";
43
+ import {
44
+ resolveTtl,
45
+ resolveSwrWindow,
46
+ DEFAULT_FUNCTION_TTL,
47
+ } from "../cache-policy.js";
48
+
49
+ // ============================================================================
50
+ // Constants
51
+ // ============================================================================
52
+
53
+ /** Header storing timestamp when entry becomes stale */
54
+ export const CACHE_STALE_AT_HEADER = "x-edge-cache-stale-at";
55
+
56
+ /** Header storing cache status: HIT | REVALIDATING */
57
+ export const CACHE_STATUS_HEADER = "x-edge-cache-status";
58
+
59
+ /**
60
+ * Maximum age in seconds for REVALIDATING status before allowing new revalidation.
61
+ * After this period, a stale entry in REVALIDATING status will trigger revalidation again.
62
+ * @internal
63
+ */
64
+ export const MAX_REVALIDATION_INTERVAL = 30;
65
+
66
+ // ============================================================================
67
+ // Types
68
+ // ============================================================================
69
+
70
+ /**
71
+ * Cloudflare Workers ExecutionContext (subset we need)
72
+ */
73
+ export interface ExecutionContext {
74
+ waitUntil(promise: Promise<any>): void;
75
+ passThroughOnException(): void;
76
+ }
77
+
78
+ /**
79
+ * Minimal Cloudflare KV Namespace interface.
80
+ * Avoids hard dependency on @cloudflare/workers-types.
81
+ */
82
+ export interface KVNamespace {
83
+ get(key: string, options?: { type?: string }): Promise<any>;
84
+ put(
85
+ key: string,
86
+ value: string,
87
+ options?: { expirationTtl?: number },
88
+ ): Promise<void>;
89
+ delete(key: string): Promise<void>;
90
+ }
91
+
92
+ /**
93
+ * KV envelope for segment cache entries.
94
+ * @internal
95
+ */
96
+ interface KVSegmentEnvelope {
97
+ /** Cached segment data */
98
+ d: CachedEntryData;
99
+ /** When entry becomes stale (ms epoch) */
100
+ s: number;
101
+ /** When entry hard-expires (ms epoch) */
102
+ e: number;
103
+ }
104
+
105
+ /**
106
+ * KV envelope for function cache entries ("use cache").
107
+ * @internal
108
+ */
109
+ interface KVItemEnvelope {
110
+ /** RSC-serialized return value */
111
+ v: string;
112
+ /** Handle data */
113
+ h?: Record<string, Record<string, unknown[]>>;
114
+ /** When entry becomes stale (ms epoch) */
115
+ s: number;
116
+ /** When entry hard-expires (ms epoch) */
117
+ e: number;
118
+ }
119
+
120
+ /**
121
+ * KV envelope for document cache entries.
122
+ * @internal
123
+ */
124
+ interface KVResponseEnvelope {
125
+ /** Response body as base64-encoded string (safe for binary payloads) */
126
+ b: string;
127
+ /** HTTP status code */
128
+ st: number;
129
+ /** HTTP status text */
130
+ stx: string;
131
+ /** Serialized headers as key-value pairs */
132
+ hd: [string, string][];
133
+ /** When entry becomes stale (ms epoch) */
134
+ s: number;
135
+ /** When entry hard-expires (ms epoch) */
136
+ e: number;
137
+ }
138
+
139
+ export interface CFCacheStoreOptions<TEnv = unknown> {
140
+ /**
141
+ * Cache namespace. If not provided, uses caches.default (recommended).
142
+ * Only set this if you need isolated cache storage.
143
+ */
144
+ namespace?: string;
145
+
146
+ /**
147
+ * Base URL for cache keys.
148
+ *
149
+ * If not provided, derives from request hostname via requestContext:
150
+ * - Production domains → uses `https://{hostname}/`
151
+ * - Dev/preview (localhost, workers.dev, pages.dev) → uses internal fallback URL
152
+ */
153
+ baseUrl?: string;
154
+
155
+ /** Default cache options */
156
+ defaults?: CacheDefaults;
157
+
158
+ /**
159
+ * Cloudflare ExecutionContext for non-blocking cache writes.
160
+ * Pass the `ctx` from your worker's fetch handler.
161
+ *
162
+ * @example
163
+ * ```typescript
164
+ * new CFCacheStore({ ctx: env.ctx })
165
+ * ```
166
+ */
167
+ ctx: ExecutionContext;
168
+
169
+ /**
170
+ * Optional KV namespace for L2 cache persistence.
171
+ *
172
+ * When provided, KV acts as a global fallback behind the per-colo Cache API.
173
+ * On L1 miss, KV is checked and hits are promoted back to L1.
174
+ * On writes, data is persisted to both L1 and KV.
175
+ *
176
+ * @example
177
+ * ```typescript
178
+ * new CFCacheStore({ ctx: env.ctx, kv: env.CACHE_KV })
179
+ * ```
180
+ */
181
+ kv?: KVNamespace;
182
+
183
+ /**
184
+ * Cache version string override. When this changes, all cached entries are
185
+ * effectively invalidated (new keys won't match old entries).
186
+ *
187
+ * Defaults to the auto-generated VERSION from `rsc-router:version` virtual module.
188
+ * Only set this if you need a custom versioning strategy.
189
+ */
190
+ version?: string;
191
+
192
+ /**
193
+ * Custom key generator applied to all cache operations.
194
+ * Receives the full RequestContext (including env) and the default-generated key.
195
+ * Return value becomes the final cache key (unless route overrides with `key` option).
196
+ *
197
+ * @example Using headers for user segmentation
198
+ * ```typescript
199
+ * keyGenerator: (ctx, defaultKey) => {
200
+ * const segment = ctx.request.headers.get('x-user-segment') || 'default';
201
+ * return `${segment}:${defaultKey}`;
202
+ * }
203
+ * ```
204
+ *
205
+ * @example Using env bindings for multi-region
206
+ * ```typescript
207
+ * keyGenerator: (ctx, defaultKey) => {
208
+ * const region = ctx.env.REGION || 'us';
209
+ * return `${region}:${defaultKey}`;
210
+ * }
211
+ * ```
212
+ *
213
+ * @example Using cookies for locale-aware caching
214
+ * ```typescript
215
+ * keyGenerator: (ctx, defaultKey) => {
216
+ * const locale = cookies().get('locale')?.value || 'en';
217
+ * return `${locale}:${defaultKey}`;
218
+ * }
219
+ * ```
220
+ */
221
+ keyGenerator?: (
222
+ ctx: RequestContext<TEnv>,
223
+ defaultKey: string,
224
+ ) => string | Promise<string>;
225
+ }
226
+
227
+ /**
228
+ * Cache status values for the x-edge-cache-status header.
229
+ * @internal
230
+ */
231
+ export type CacheStatus = "HIT" | "REVALIDATING";
232
+
233
+ // ============================================================================
234
+ // CFCacheStore Implementation
235
+ // ============================================================================
236
+
237
+ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
238
+ readonly defaults?: CacheDefaults;
239
+ readonly keyGenerator?: (
240
+ ctx: RequestContext<TEnv>,
241
+ defaultKey: string,
242
+ ) => string | Promise<string>;
243
+
244
+ private readonly namespace?: string;
245
+ private readonly baseUrl: string;
246
+ private readonly waitUntil?: (fn: () => Promise<void>) => void;
247
+ private readonly version?: string;
248
+ private readonly kv?: KVNamespace;
249
+
250
+ constructor(options: CFCacheStoreOptions<TEnv>) {
251
+ if (!options.ctx) {
252
+ throw new Error(
253
+ "[CFCacheStore] ExecutionContext (ctx) is required. " +
254
+ "Pass the Cloudflare ExecutionContext from your worker's fetch handler: " +
255
+ "new CFCacheStore({ ctx: env.ctx })",
256
+ );
257
+ }
258
+
259
+ this.namespace = options.namespace;
260
+ this.baseUrl = options.baseUrl ?? this.deriveBaseUrl();
261
+ this.defaults = options.defaults;
262
+ this.version = options.version ?? VERSION;
263
+ this.keyGenerator = options.keyGenerator;
264
+ this.waitUntil = (fn) => options.ctx.waitUntil(fn());
265
+ this.kv = options.kv;
266
+ }
267
+
268
+ /**
269
+ * Derive base URL from request hostname via requestContext.
270
+ * Uses internal fallback for dev/preview environments and untrusted hostnames.
271
+ * @internal
272
+ */
273
+ private deriveBaseUrl(): string {
274
+ const fallback = "https://rsc-cache.internal.com/";
275
+
276
+ const ctx = _getRequestContext();
277
+ if (!ctx?.request) {
278
+ return fallback;
279
+ }
280
+
281
+ try {
282
+ const url = new URL(ctx.request.url);
283
+ const hostname = url.hostname;
284
+
285
+ // Use fallback for dev/preview environments
286
+ if (
287
+ hostname === "localhost" ||
288
+ hostname === "127.0.0.1" ||
289
+ hostname.endsWith(".workers.dev") ||
290
+ hostname.endsWith(".pages.dev")
291
+ ) {
292
+ return fallback;
293
+ }
294
+
295
+ // Validate hostname: must be a valid domain (alphanumeric, hyphens, dots)
296
+ // to prevent host header injection into cache keys
297
+ if (!/^[a-zA-Z0-9.-]+$/.test(hostname) || hostname.length > 253) {
298
+ return fallback;
299
+ }
300
+
301
+ // Use actual hostname for production
302
+ return `https://${hostname}/`;
303
+ } catch {
304
+ return fallback;
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Get the cache instance - uses caches.default unless namespace is specified.
310
+ * @internal
311
+ */
312
+ private getCache(): Cache | Promise<Cache> {
313
+ if (this.namespace) {
314
+ return caches.open(this.namespace);
315
+ }
316
+ return caches.default;
317
+ }
318
+
319
+ // ============================================================================
320
+ // Segment Cache Methods
321
+ // ============================================================================
322
+
323
+ /**
324
+ * Get cached entry data by key.
325
+ *
326
+ * Handles SWR atomically:
327
+ * - If stale and not already revalidating, marks as REVALIDATING and returns shouldRevalidate: true
328
+ * - If already REVALIDATING (and recent), returns shouldRevalidate: false
329
+ * - If fresh, returns shouldRevalidate: false
330
+ *
331
+ * On L1 miss, falls back to KV (L2) if configured.
332
+ * KV hits are promoted to L1 in the background.
333
+ */
334
+ async get(key: string): Promise<CacheGetResult | null> {
335
+ try {
336
+ const cache = await this.getCache();
337
+ const request = this.keyToRequest(key);
338
+ const response = await cache.match(request);
339
+
340
+ if (!response) {
341
+ return this.kvGetSegment(key);
342
+ }
343
+
344
+ // Read status headers
345
+ const status = response.headers.get(CACHE_STATUS_HEADER);
346
+ const age = Number(response.headers.get("age") ?? "0");
347
+ const staleAt = Number(
348
+ response.headers.get(CACHE_STALE_AT_HEADER) ?? "0",
349
+ );
350
+
351
+ const isStale = staleAt > 0 && Date.now() > staleAt;
352
+ const isRevalidating =
353
+ status === "REVALIDATING" && age < MAX_REVALIDATION_INTERVAL;
354
+
355
+ // Case 1: Fresh or already being revalidated - just return data
356
+ if (!isStale || isRevalidating) {
357
+ const data = (await response.json()) as CachedEntryData;
358
+ return { data, shouldRevalidate: false };
359
+ }
360
+
361
+ // Case 2: Stale and needs revalidation - atomically mark REVALIDATING
362
+ const [b1, b2] = response.body!.tee();
363
+
364
+ const headers = new Headers(response.headers);
365
+ headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
366
+
367
+ // Blocking write - must complete before returning to prevent race
368
+ await cache.put(
369
+ request,
370
+ new Response(b1, { status: response.status, headers }),
371
+ );
372
+
373
+ const data = (await new Response(b2).json()) as CachedEntryData;
374
+ return { data, shouldRevalidate: true };
375
+ } catch (error) {
376
+ console.error("[CFCacheStore] get failed:", error);
377
+ return null;
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Store entry data with TTL and optional SWR window.
383
+ * Uses waitUntil for non-blocking write when available.
384
+ * When KV is configured, also persists to L2.
385
+ */
386
+ async set(
387
+ key: string,
388
+ data: CachedEntryData,
389
+ ttl: number,
390
+ swr?: number,
391
+ ): Promise<void> {
392
+ try {
393
+ const cache = await this.getCache();
394
+ const request = this.keyToRequest(key);
395
+
396
+ // Extended TTL covers SWR window
397
+ const swrWindow = resolveSwrWindow(swr, this.defaults);
398
+ const totalTtl = ttl + swrWindow;
399
+ const staleAt = Date.now() + ttl * 1000;
400
+
401
+ const body = JSON.stringify(data);
402
+ const response = new Response(body, {
403
+ headers: {
404
+ "Content-Type": "application/json",
405
+ "Cache-Control": `public, max-age=${totalTtl}`,
406
+ [CACHE_STALE_AT_HEADER]: String(staleAt),
407
+ [CACHE_STATUS_HEADER]: "HIT",
408
+ },
409
+ });
410
+
411
+ const putPromise = cache.put(request, response);
412
+
413
+ if (this.waitUntil) {
414
+ // Non-blocking write
415
+ this.waitUntil(async () => {
416
+ await putPromise;
417
+ });
418
+ } else {
419
+ // Blocking fallback
420
+ await putPromise;
421
+ }
422
+
423
+ // L2: persist to KV
424
+ this.kvSetSegment(key, data, staleAt, totalTtl);
425
+ } catch (error) {
426
+ console.error("[CFCacheStore] set failed:", error);
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Delete a cached entry from L1 and L2.
432
+ */
433
+ async delete(key: string): Promise<boolean> {
434
+ try {
435
+ const cache = await this.getCache();
436
+ const result = await cache.delete(this.keyToRequest(key));
437
+
438
+ // L2: delete from KV
439
+ if (this.kv && this.waitUntil) {
440
+ const kvKey = this.toKVKey(key);
441
+ this.waitUntil(async () => {
442
+ try {
443
+ await this.kv!.delete(kvKey);
444
+ } catch {
445
+ // KV delete failures are non-critical
446
+ }
447
+ });
448
+ }
449
+
450
+ return result;
451
+ } catch (error) {
452
+ console.error("[CFCacheStore] delete failed:", error);
453
+ return false;
454
+ }
455
+ }
456
+
457
+ // ============================================================================
458
+ // Document Cache Methods
459
+ // ============================================================================
460
+
461
+ /**
462
+ * Get a cached Response by key (for document-level caching).
463
+ * Returns the response and whether it should be revalidated (SWR).
464
+ * Falls back to KV (L2) on L1 miss.
465
+ */
466
+ async getResponse(
467
+ key: string,
468
+ ): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
469
+ try {
470
+ const cache = await this.getCache();
471
+ const request = this.keyToRequest(`doc:${key}`);
472
+ const response = await cache.match(request);
473
+
474
+ if (!response || response.status !== 200) {
475
+ return this.kvGetResponse(key);
476
+ }
477
+
478
+ // Check staleness
479
+ const staleAt = Number(response.headers.get(CACHE_STALE_AT_HEADER) || 0);
480
+ const isStale = staleAt > 0 && Date.now() > staleAt;
481
+
482
+ return {
483
+ response,
484
+ shouldRevalidate: isStale,
485
+ };
486
+ } catch (error) {
487
+ console.error("[CFCacheStore] getResponse failed:", error);
488
+ return null;
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Store a Response with TTL and optional SWR window (for document-level caching).
494
+ * When KV is configured, also persists to L2.
495
+ */
496
+ async putResponse(
497
+ key: string,
498
+ response: Response,
499
+ ttl: number,
500
+ swr?: number,
501
+ ): Promise<void> {
502
+ try {
503
+ const cache = await this.getCache();
504
+ const request = this.keyToRequest(`doc:${key}`);
505
+
506
+ // Extended TTL covers SWR window
507
+ const swrWindow = resolveSwrWindow(swr, this.defaults);
508
+ const totalTtl = ttl + swrWindow;
509
+ const staleAt = Date.now() + ttl * 1000;
510
+
511
+ // Clone body for potential KV write before consuming it for L1
512
+ const [l1Body, kvBody] = this.kv
513
+ ? response.body
514
+ ? response.body.tee()
515
+ : [null, null]
516
+ : [response.body, null];
517
+
518
+ // Clone and add cache headers
519
+ const headers = new Headers(response.headers);
520
+ headers.set("Cache-Control", `public, max-age=${totalTtl}`);
521
+ headers.set(CACHE_STALE_AT_HEADER, String(staleAt));
522
+
523
+ const toCache = new Response(l1Body, {
524
+ status: response.status,
525
+ statusText: response.statusText,
526
+ headers,
527
+ });
528
+
529
+ const putPromise = cache.put(request, toCache);
530
+
531
+ if (this.waitUntil) {
532
+ // Non-blocking write
533
+ this.waitUntil(async () => {
534
+ await putPromise;
535
+ });
536
+ } else {
537
+ // Blocking fallback
538
+ await putPromise;
539
+ }
540
+
541
+ // L2: persist to KV (KV requires expirationTtl >= 60s)
542
+ if (this.kv && this.waitUntil && totalTtl >= 60) {
543
+ const kvKey = this.toKVKey(`doc:${key}`);
544
+ const headersArray: [string, string][] = [];
545
+ response.headers.forEach((v, k) => headersArray.push([k, v]));
546
+ // Read body as ArrayBuffer and encode to base64 to preserve binary payloads
547
+ const bodyBuf = kvBody
548
+ ? await new Response(kvBody).arrayBuffer()
549
+ : new ArrayBuffer(0);
550
+ const bodyBase64 = bufferToBase64(bodyBuf);
551
+
552
+ this.waitUntil(async () => {
553
+ try {
554
+ const envelope: KVResponseEnvelope = {
555
+ b: bodyBase64,
556
+ st: response.status,
557
+ stx: response.statusText,
558
+ hd: headersArray,
559
+ s: staleAt,
560
+ e: staleAt + swrWindow * 1000,
561
+ };
562
+ await this.kv!.put(kvKey, JSON.stringify(envelope), {
563
+ expirationTtl: totalTtl,
564
+ });
565
+ } catch (error) {
566
+ console.error("[CFCacheStore] KV putResponse failed:", error);
567
+ }
568
+ });
569
+ }
570
+ } catch (error) {
571
+ console.error("[CFCacheStore] putResponse failed:", error);
572
+ }
573
+ }
574
+
575
+ // ============================================================================
576
+ // Function Cache Methods (for "use cache" directive)
577
+ // ============================================================================
578
+
579
+ /**
580
+ * Get a cached function result by key.
581
+ * Follows the same SWR pattern as get() for segment caching.
582
+ * Falls back to KV (L2) on L1 miss.
583
+ */
584
+ async getItem(key: string): Promise<CacheItemResult | null> {
585
+ try {
586
+ const cache = await this.getCache();
587
+ const request = this.keyToRequest(`fn:${key}`);
588
+ const response = await cache.match(request);
589
+
590
+ if (!response) return this.kvGetItem(key);
591
+
592
+ const staleAt = Number(
593
+ response.headers.get(CACHE_STALE_AT_HEADER) ?? "0",
594
+ );
595
+ const status = response.headers.get(CACHE_STATUS_HEADER);
596
+ const age = Number(response.headers.get("age") ?? "0");
597
+
598
+ const isStale = staleAt > 0 && Date.now() > staleAt;
599
+ const isRevalidating =
600
+ status === "REVALIDATING" && age < MAX_REVALIDATION_INTERVAL;
601
+
602
+ const data = (await response.json()) as {
603
+ value: string;
604
+ handles?: Record<string, Record<string, unknown[]>>;
605
+ };
606
+
607
+ if (!isStale || isRevalidating) {
608
+ return {
609
+ value: data.value,
610
+ handles: data.handles,
611
+ shouldRevalidate: false,
612
+ };
613
+ }
614
+
615
+ // Stale and needs revalidation — mark REVALIDATING atomically
616
+ const headers = new Headers(response.headers);
617
+ headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
618
+ await cache.put(
619
+ request,
620
+ new Response(JSON.stringify(data), { status: 200, headers }),
621
+ );
622
+
623
+ return {
624
+ value: data.value,
625
+ handles: data.handles,
626
+ shouldRevalidate: true,
627
+ };
628
+ } catch (error) {
629
+ console.error("[CFCacheStore] getItem failed:", error);
630
+ return null;
631
+ }
632
+ }
633
+
634
+ /**
635
+ * Store a function result with TTL and optional SWR window.
636
+ * When KV is configured, also persists to L2.
637
+ */
638
+ async setItem(
639
+ key: string,
640
+ value: string,
641
+ options?: CacheItemOptions,
642
+ ): Promise<void> {
643
+ try {
644
+ const cache = await this.getCache();
645
+ const request = this.keyToRequest(`fn:${key}`);
646
+
647
+ const ttl = resolveTtl(options?.ttl, this.defaults, DEFAULT_FUNCTION_TTL);
648
+ const swrWindow = resolveSwrWindow(options?.swr, this.defaults);
649
+ const totalTtl = ttl + swrWindow;
650
+ const staleAt = Date.now() + ttl * 1000;
651
+
652
+ const body = JSON.stringify({ value, handles: options?.handles });
653
+ const response = new Response(body, {
654
+ headers: {
655
+ "Content-Type": "application/json",
656
+ "Cache-Control": `public, max-age=${totalTtl}`,
657
+ [CACHE_STALE_AT_HEADER]: String(staleAt),
658
+ [CACHE_STATUS_HEADER]: "HIT",
659
+ },
660
+ });
661
+
662
+ const putPromise = cache.put(request, response);
663
+
664
+ if (this.waitUntil) {
665
+ this.waitUntil(async () => {
666
+ await putPromise;
667
+ });
668
+ } else {
669
+ await putPromise;
670
+ }
671
+
672
+ // L2: persist to KV (KV requires expirationTtl >= 60s)
673
+ if (this.kv && this.waitUntil && totalTtl >= 60) {
674
+ const kvKey = this.toKVKey(`fn:${key}`);
675
+ this.waitUntil(async () => {
676
+ try {
677
+ const envelope: KVItemEnvelope = {
678
+ v: value,
679
+ h: options?.handles,
680
+ s: staleAt,
681
+ e: staleAt + swrWindow * 1000,
682
+ };
683
+ await this.kv!.put(kvKey, JSON.stringify(envelope), {
684
+ expirationTtl: totalTtl,
685
+ });
686
+ } catch (error) {
687
+ console.error("[CFCacheStore] KV setItem failed:", error);
688
+ }
689
+ });
690
+ }
691
+ } catch (error) {
692
+ console.error("[CFCacheStore] setItem failed:", error);
693
+ }
694
+ }
695
+
696
+ // ============================================================================
697
+ // Key Helpers
698
+ // ============================================================================
699
+
700
+ /**
701
+ * Convert string key to Request object for CF Cache API.
702
+ * Includes version in URL if specified (for cache invalidation on code changes).
703
+ * @internal
704
+ */
705
+ private keyToRequest(key: string): Request {
706
+ const encodedKey = encodeURIComponent(key);
707
+ // Include version in URL path to invalidate cache when version changes
708
+ const versionPath = this.version ? `v/${this.version}/` : "";
709
+ return new Request(`${this.baseUrl}${versionPath}${encodedKey}`, {
710
+ method: "GET",
711
+ });
712
+ }
713
+
714
+ /**
715
+ * Convert string key to KV key string.
716
+ * Uses same version prefix as Cache API for consistent invalidation.
717
+ * @internal
718
+ */
719
+ private toKVKey(key: string): string {
720
+ const versionPath = this.version ? `v/${this.version}/` : "";
721
+ return `${versionPath}${key}`;
722
+ }
723
+
724
+ // ============================================================================
725
+ // KV L2 Helpers
726
+ // ============================================================================
727
+
728
+ /**
729
+ * KV fallback for segment cache reads.
730
+ * Returns null if KV is not configured, entry is missing, or expired.
731
+ * Promotes hits to L1 via waitUntil.
732
+ * @internal
733
+ */
734
+ private async kvGetSegment(key: string): Promise<CacheGetResult | null> {
735
+ if (!this.kv) return null;
736
+
737
+ try {
738
+ const kvKey = this.toKVKey(key);
739
+ const raw = await this.kv.get(kvKey, { type: "json" });
740
+ if (!raw) return null;
741
+
742
+ const envelope = raw as KVSegmentEnvelope;
743
+ const now = Date.now();
744
+
745
+ // Hard-expired — treat as miss
746
+ if (now > envelope.e) return null;
747
+
748
+ const shouldRevalidate = now > envelope.s;
749
+
750
+ // Promote to L1 in background
751
+ this.promoteSegmentToL1(key, envelope);
752
+
753
+ return { data: envelope.d, shouldRevalidate };
754
+ } catch (error) {
755
+ console.error("[CFCacheStore] KV get failed:", error);
756
+ return null;
757
+ }
758
+ }
759
+
760
+ /**
761
+ * Write segment data to KV.
762
+ * @internal
763
+ */
764
+ private kvSetSegment(
765
+ key: string,
766
+ data: CachedEntryData,
767
+ staleAt: number,
768
+ totalTtl: number,
769
+ ): void {
770
+ // KV requires expirationTtl >= 60s. Skip write for short-lived entries.
771
+ if (!this.kv || !this.waitUntil || totalTtl < 60) return;
772
+
773
+ const kvKey = this.toKVKey(key);
774
+ const swrWindow = totalTtl * 1000 - (staleAt - Date.now());
775
+ const expiresAt = staleAt + swrWindow;
776
+
777
+ this.waitUntil(async () => {
778
+ try {
779
+ const envelope: KVSegmentEnvelope = {
780
+ d: data,
781
+ s: staleAt,
782
+ e: expiresAt,
783
+ };
784
+ await this.kv!.put(kvKey, JSON.stringify(envelope), {
785
+ expirationTtl: totalTtl,
786
+ });
787
+ } catch (error) {
788
+ console.error("[CFCacheStore] KV set failed:", error);
789
+ }
790
+ });
791
+ }
792
+
793
+ /**
794
+ * Promote segment data from KV to L1 Cache API.
795
+ * @internal
796
+ */
797
+ private promoteSegmentToL1(key: string, envelope: KVSegmentEnvelope): void {
798
+ if (!this.waitUntil) return;
799
+
800
+ this.waitUntil(async () => {
801
+ try {
802
+ const now = Date.now();
803
+ const remainingTtl = Math.max(1, Math.floor((envelope.e - now) / 1000));
804
+ const cache = await this.getCache();
805
+ const request = this.keyToRequest(key);
806
+
807
+ const response = new Response(JSON.stringify(envelope.d), {
808
+ headers: {
809
+ "Content-Type": "application/json",
810
+ "Cache-Control": `public, max-age=${remainingTtl}`,
811
+ [CACHE_STALE_AT_HEADER]: String(envelope.s),
812
+ [CACHE_STATUS_HEADER]: "HIT",
813
+ },
814
+ });
815
+
816
+ await cache.put(request, response);
817
+ } catch (error) {
818
+ console.error("[CFCacheStore] L1 promote failed:", error);
819
+ }
820
+ });
821
+ }
822
+
823
+ /**
824
+ * KV fallback for function cache reads.
825
+ * @internal
826
+ */
827
+ private async kvGetItem(key: string): Promise<CacheItemResult | null> {
828
+ if (!this.kv) return null;
829
+
830
+ try {
831
+ const kvKey = this.toKVKey(`fn:${key}`);
832
+ const raw = await this.kv.get(kvKey, { type: "json" });
833
+ if (!raw) return null;
834
+
835
+ const envelope = raw as KVItemEnvelope;
836
+ const now = Date.now();
837
+
838
+ if (now > envelope.e) return null;
839
+
840
+ const shouldRevalidate = now > envelope.s;
841
+
842
+ // Promote to L1
843
+ this.promoteItemToL1(key, envelope);
844
+
845
+ return {
846
+ value: envelope.v,
847
+ handles: envelope.h,
848
+ shouldRevalidate,
849
+ };
850
+ } catch (error) {
851
+ console.error("[CFCacheStore] KV getItem failed:", error);
852
+ return null;
853
+ }
854
+ }
855
+
856
+ /**
857
+ * Promote function cache data from KV to L1.
858
+ * @internal
859
+ */
860
+ private promoteItemToL1(key: string, envelope: KVItemEnvelope): void {
861
+ if (!this.waitUntil) return;
862
+
863
+ this.waitUntil(async () => {
864
+ try {
865
+ const now = Date.now();
866
+ const remainingTtl = Math.max(1, Math.floor((envelope.e - now) / 1000));
867
+ const cache = await this.getCache();
868
+ const request = this.keyToRequest(`fn:${key}`);
869
+
870
+ const body = JSON.stringify({ value: envelope.v, handles: envelope.h });
871
+ const response = new Response(body, {
872
+ headers: {
873
+ "Content-Type": "application/json",
874
+ "Cache-Control": `public, max-age=${remainingTtl}`,
875
+ [CACHE_STALE_AT_HEADER]: String(envelope.s),
876
+ [CACHE_STATUS_HEADER]: "HIT",
877
+ },
878
+ });
879
+
880
+ await cache.put(request, response);
881
+ } catch (error) {
882
+ console.error("[CFCacheStore] L1 item promote failed:", error);
883
+ }
884
+ });
885
+ }
886
+
887
+ /**
888
+ * KV fallback for document cache reads.
889
+ * @internal
890
+ */
891
+ private async kvGetResponse(
892
+ key: string,
893
+ ): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
894
+ if (!this.kv) return null;
895
+
896
+ try {
897
+ const kvKey = this.toKVKey(`doc:${key}`);
898
+ const raw = await this.kv.get(kvKey, { type: "json" });
899
+ if (!raw) return null;
900
+
901
+ const envelope = raw as KVResponseEnvelope;
902
+ const now = Date.now();
903
+
904
+ if (now > envelope.e) return null;
905
+
906
+ const shouldRevalidate = now > envelope.s;
907
+
908
+ // Reconstruct Response (decode base64 → binary)
909
+ const headers = new Headers(envelope.hd);
910
+ const bodyBuffer = base64ToBuffer(envelope.b);
911
+ const response = new Response(bodyBuffer, {
912
+ status: envelope.st,
913
+ statusText: envelope.stx,
914
+ headers,
915
+ });
916
+
917
+ // Promote to L1
918
+ this.promoteResponseToL1(key, envelope);
919
+
920
+ return { response, shouldRevalidate };
921
+ } catch (error) {
922
+ console.error("[CFCacheStore] KV getResponse failed:", error);
923
+ return null;
924
+ }
925
+ }
926
+
927
+ /**
928
+ * Promote document cache data from KV to L1.
929
+ * @internal
930
+ */
931
+ private promoteResponseToL1(key: string, envelope: KVResponseEnvelope): void {
932
+ if (!this.waitUntil) return;
933
+
934
+ this.waitUntil(async () => {
935
+ try {
936
+ const now = Date.now();
937
+ const remainingTtl = Math.max(1, Math.floor((envelope.e - now) / 1000));
938
+ const cache = await this.getCache();
939
+ const request = this.keyToRequest(`doc:${key}`);
940
+
941
+ const headers = new Headers(envelope.hd);
942
+ headers.set("Cache-Control", `public, max-age=${remainingTtl}`);
943
+ headers.set(CACHE_STALE_AT_HEADER, String(envelope.s));
944
+
945
+ const bodyBuffer = base64ToBuffer(envelope.b);
946
+ const response = new Response(bodyBuffer, {
947
+ status: envelope.st,
948
+ statusText: envelope.stx,
949
+ headers,
950
+ });
951
+
952
+ await cache.put(request, response);
953
+ } catch (error) {
954
+ console.error("[CFCacheStore] L1 response promote failed:", error);
955
+ }
956
+ });
957
+ }
958
+ }
959
+
960
+ // ============================================================================
961
+ // Base64 Helpers (binary-safe response body encoding for KV)
962
+ // ============================================================================
963
+
964
+ /** Encode ArrayBuffer to base64 string. */
965
+ function bufferToBase64(buffer: ArrayBuffer): string {
966
+ const bytes = new Uint8Array(buffer);
967
+ let binary = "";
968
+ for (let i = 0; i < bytes.length; i++) {
969
+ binary += String.fromCharCode(bytes[i]!);
970
+ }
971
+ return btoa(binary);
972
+ }
973
+
974
+ /** Decode base64 string to ArrayBuffer. */
975
+ function base64ToBuffer(base64: string): ArrayBuffer {
976
+ const binary = atob(base64);
977
+ const bytes = new Uint8Array(binary.length);
978
+ for (let i = 0; i < binary.length; i++) {
979
+ bytes[i] = binary.charCodeAt(i);
980
+ }
981
+ return bytes.buffer;
982
+ }