@rangojs/router 0.0.0-experimental.8a4d0430 → 0.0.0-experimental.8bcfea43

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 (174) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +126 -38
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +1171 -461
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +19 -16
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/cache-guide/SKILL.md +32 -0
  9. package/skills/caching/SKILL.md +45 -4
  10. package/skills/handler-use/SKILL.md +362 -0
  11. package/skills/hooks/SKILL.md +28 -20
  12. package/skills/intercept/SKILL.md +20 -0
  13. package/skills/layout/SKILL.md +22 -0
  14. package/skills/links/SKILL.md +91 -17
  15. package/skills/loader/SKILL.md +88 -45
  16. package/skills/middleware/SKILL.md +34 -3
  17. package/skills/migrate-nextjs/SKILL.md +560 -0
  18. package/skills/migrate-react-router/SKILL.md +765 -0
  19. package/skills/parallel/SKILL.md +185 -0
  20. package/skills/prerender/SKILL.md +110 -68
  21. package/skills/rango/SKILL.md +24 -22
  22. package/skills/response-routes/SKILL.md +8 -0
  23. package/skills/route/SKILL.md +55 -0
  24. package/skills/router-setup/SKILL.md +87 -2
  25. package/skills/streams-and-websockets/SKILL.md +283 -0
  26. package/skills/typesafety/SKILL.md +13 -1
  27. package/src/__internal.ts +1 -1
  28. package/src/browser/app-shell.ts +52 -0
  29. package/src/browser/app-version.ts +14 -0
  30. package/src/browser/event-controller.ts +5 -0
  31. package/src/browser/navigation-bridge.ts +90 -16
  32. package/src/browser/navigation-client.ts +167 -59
  33. package/src/browser/navigation-store.ts +68 -9
  34. package/src/browser/navigation-transaction.ts +11 -9
  35. package/src/browser/partial-update.ts +113 -17
  36. package/src/browser/prefetch/cache.ts +184 -16
  37. package/src/browser/prefetch/fetch.ts +180 -33
  38. package/src/browser/prefetch/policy.ts +6 -0
  39. package/src/browser/prefetch/queue.ts +123 -20
  40. package/src/browser/prefetch/resource-ready.ts +77 -0
  41. package/src/browser/rango-state.ts +53 -13
  42. package/src/browser/react/Link.tsx +81 -9
  43. package/src/browser/react/NavigationProvider.tsx +89 -14
  44. package/src/browser/react/context.ts +7 -2
  45. package/src/browser/react/use-handle.ts +9 -58
  46. package/src/browser/react/use-navigation.ts +22 -2
  47. package/src/browser/react/use-params.ts +11 -1
  48. package/src/browser/react/use-router.ts +29 -9
  49. package/src/browser/rsc-router.tsx +168 -65
  50. package/src/browser/scroll-restoration.ts +41 -42
  51. package/src/browser/segment-reconciler.ts +36 -9
  52. package/src/browser/server-action-bridge.ts +8 -6
  53. package/src/browser/types.ts +49 -5
  54. package/src/build/generate-manifest.ts +6 -6
  55. package/src/build/generate-route-types.ts +3 -0
  56. package/src/build/route-trie.ts +50 -24
  57. package/src/build/route-types/include-resolution.ts +8 -1
  58. package/src/build/route-types/router-processing.ts +223 -74
  59. package/src/build/route-types/scan-filter.ts +8 -1
  60. package/src/cache/cache-runtime.ts +15 -11
  61. package/src/cache/cache-scope.ts +48 -7
  62. package/src/cache/cf/cf-cache-store.ts +455 -15
  63. package/src/cache/cf/index.ts +5 -1
  64. package/src/cache/document-cache.ts +17 -7
  65. package/src/cache/index.ts +1 -0
  66. package/src/cache/taint.ts +55 -0
  67. package/src/client.tsx +84 -230
  68. package/src/context-var.ts +72 -2
  69. package/src/debug.ts +2 -2
  70. package/src/handle.ts +40 -0
  71. package/src/index.rsc.ts +6 -1
  72. package/src/index.ts +49 -6
  73. package/src/outlet-context.ts +1 -1
  74. package/src/prerender/store.ts +5 -4
  75. package/src/prerender.ts +138 -77
  76. package/src/response-utils.ts +28 -0
  77. package/src/reverse.ts +27 -2
  78. package/src/route-definition/dsl-helpers.ts +240 -40
  79. package/src/route-definition/helpers-types.ts +67 -19
  80. package/src/route-definition/index.ts +3 -0
  81. package/src/route-definition/redirect.ts +11 -3
  82. package/src/route-definition/resolve-handler-use.ts +155 -0
  83. package/src/route-map-builder.ts +7 -1
  84. package/src/route-types.ts +18 -0
  85. package/src/router/content-negotiation.ts +100 -1
  86. package/src/router/find-match.ts +4 -2
  87. package/src/router/handler-context.ts +101 -25
  88. package/src/router/intercept-resolution.ts +11 -4
  89. package/src/router/lazy-includes.ts +10 -7
  90. package/src/router/loader-resolution.ts +159 -21
  91. package/src/router/logging.ts +5 -2
  92. package/src/router/manifest.ts +31 -16
  93. package/src/router/match-api.ts +127 -192
  94. package/src/router/match-middleware/background-revalidation.ts +30 -2
  95. package/src/router/match-middleware/cache-lookup.ts +94 -17
  96. package/src/router/match-middleware/cache-store.ts +53 -10
  97. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  98. package/src/router/match-middleware/segment-resolution.ts +61 -5
  99. package/src/router/match-result.ts +104 -10
  100. package/src/router/metrics.ts +6 -1
  101. package/src/router/middleware-types.ts +8 -30
  102. package/src/router/middleware.ts +36 -10
  103. package/src/router/navigation-snapshot.ts +182 -0
  104. package/src/router/pattern-matching.ts +60 -9
  105. package/src/router/prerender-match.ts +110 -10
  106. package/src/router/preview-match.ts +30 -102
  107. package/src/router/request-classification.ts +310 -0
  108. package/src/router/route-snapshot.ts +245 -0
  109. package/src/router/router-context.ts +6 -1
  110. package/src/router/router-interfaces.ts +36 -4
  111. package/src/router/router-options.ts +37 -11
  112. package/src/router/segment-resolution/fresh.ts +198 -20
  113. package/src/router/segment-resolution/helpers.ts +29 -24
  114. package/src/router/segment-resolution/loader-cache.ts +1 -0
  115. package/src/router/segment-resolution/revalidation.ts +438 -300
  116. package/src/router/segment-wrappers.ts +2 -0
  117. package/src/router/trie-matching.ts +10 -4
  118. package/src/router/types.ts +1 -0
  119. package/src/router/url-params.ts +49 -0
  120. package/src/router.ts +60 -8
  121. package/src/rsc/handler.ts +478 -374
  122. package/src/rsc/helpers.ts +69 -41
  123. package/src/rsc/loader-fetch.ts +23 -3
  124. package/src/rsc/manifest-init.ts +5 -1
  125. package/src/rsc/progressive-enhancement.ts +16 -2
  126. package/src/rsc/response-route-handler.ts +14 -1
  127. package/src/rsc/rsc-rendering.ts +19 -1
  128. package/src/rsc/server-action.ts +10 -0
  129. package/src/rsc/ssr-setup.ts +2 -2
  130. package/src/rsc/types.ts +9 -1
  131. package/src/segment-content-promise.ts +67 -0
  132. package/src/segment-loader-promise.ts +122 -0
  133. package/src/segment-system.tsx +109 -23
  134. package/src/server/context.ts +166 -17
  135. package/src/server/handle-store.ts +19 -0
  136. package/src/server/loader-registry.ts +9 -8
  137. package/src/server/request-context.ts +194 -60
  138. package/src/ssr/index.tsx +4 -0
  139. package/src/static-handler.ts +18 -6
  140. package/src/types/cache-types.ts +4 -4
  141. package/src/types/handler-context.ts +137 -65
  142. package/src/types/loader-types.ts +41 -15
  143. package/src/types/request-scope.ts +126 -0
  144. package/src/types/route-entry.ts +19 -1
  145. package/src/types/segments.ts +2 -0
  146. package/src/urls/include-helper.ts +24 -14
  147. package/src/urls/path-helper-types.ts +39 -6
  148. package/src/urls/path-helper.ts +48 -13
  149. package/src/urls/pattern-types.ts +12 -0
  150. package/src/urls/response-types.ts +18 -16
  151. package/src/use-loader.tsx +77 -5
  152. package/src/vite/debug.ts +55 -0
  153. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  154. package/src/vite/discovery/discover-routers.ts +5 -1
  155. package/src/vite/discovery/prerender-collection.ts +128 -74
  156. package/src/vite/discovery/state.ts +13 -6
  157. package/src/vite/index.ts +4 -0
  158. package/src/vite/plugin-types.ts +51 -79
  159. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  160. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  161. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  162. package/src/vite/plugins/expose-action-id.ts +1 -3
  163. package/src/vite/plugins/expose-id-utils.ts +12 -0
  164. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  165. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  166. package/src/vite/plugins/performance-tracks.ts +86 -0
  167. package/src/vite/plugins/refresh-cmd.ts +88 -26
  168. package/src/vite/plugins/version-plugin.ts +13 -1
  169. package/src/vite/rango.ts +204 -217
  170. package/src/vite/router-discovery.ts +335 -64
  171. package/src/vite/utils/banner.ts +4 -4
  172. package/src/vite/utils/package-resolution.ts +41 -1
  173. package/src/vite/utils/prerender-utils.ts +37 -5
  174. package/src/vite/utils/shared-utils.ts +3 -2
@@ -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 {
@@ -60,12 +67,71 @@ export const MAX_REVALIDATION_INTERVAL = 30;
60
67
  // Types
61
68
  // ============================================================================
62
69
 
70
+ // Re-exported from the canonical home so cf-cache-store consumers keep
71
+ // importing `ExecutionContext` from this module without a second interface
72
+ // drifting over time.
73
+ export type { ExecutionContext } from "../../types/request-scope.js";
74
+ import type { ExecutionContext } from "../../types/request-scope.js";
75
+
76
+ /**
77
+ * Minimal Cloudflare KV Namespace interface.
78
+ * Avoids hard dependency on @cloudflare/workers-types.
79
+ */
80
+ export interface KVNamespace {
81
+ get(key: string, options?: { type?: string }): Promise<any>;
82
+ put(
83
+ key: string,
84
+ value: string,
85
+ options?: { expirationTtl?: number },
86
+ ): Promise<void>;
87
+ delete(key: string): Promise<void>;
88
+ }
89
+
90
+ /**
91
+ * KV envelope for segment cache entries.
92
+ * @internal
93
+ */
94
+ interface KVSegmentEnvelope {
95
+ /** Cached segment data */
96
+ d: CachedEntryData;
97
+ /** When entry becomes stale (ms epoch) */
98
+ s: number;
99
+ /** When entry hard-expires (ms epoch) */
100
+ e: number;
101
+ }
102
+
103
+ /**
104
+ * KV envelope for function cache entries ("use cache").
105
+ * @internal
106
+ */
107
+ interface KVItemEnvelope {
108
+ /** RSC-serialized return value */
109
+ v: string;
110
+ /** Handle data */
111
+ h?: Record<string, Record<string, unknown[]>>;
112
+ /** When entry becomes stale (ms epoch) */
113
+ s: number;
114
+ /** When entry hard-expires (ms epoch) */
115
+ e: number;
116
+ }
117
+
63
118
  /**
64
- * Cloudflare Workers ExecutionContext (subset we need)
119
+ * KV envelope for document cache entries.
120
+ * @internal
65
121
  */
66
- export interface ExecutionContext {
67
- waitUntil(promise: Promise<any>): void;
68
- passThroughOnException(): void;
122
+ interface KVResponseEnvelope {
123
+ /** Response body as base64-encoded string (safe for binary payloads) */
124
+ b: string;
125
+ /** HTTP status code */
126
+ st: number;
127
+ /** HTTP status text */
128
+ stx: string;
129
+ /** Serialized headers as key-value pairs */
130
+ hd: [string, string][];
131
+ /** When entry becomes stale (ms epoch) */
132
+ s: number;
133
+ /** When entry hard-expires (ms epoch) */
134
+ e: number;
69
135
  }
70
136
 
71
137
  export interface CFCacheStoreOptions<TEnv = unknown> {
@@ -98,6 +164,20 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
98
164
  */
99
165
  ctx: ExecutionContext;
100
166
 
167
+ /**
168
+ * Optional KV namespace for L2 cache persistence.
169
+ *
170
+ * When provided, KV acts as a global fallback behind the per-colo Cache API.
171
+ * On L1 miss, KV is checked and hits are promoted back to L1.
172
+ * On writes, data is persisted to both L1 and KV.
173
+ *
174
+ * @example
175
+ * ```typescript
176
+ * new CFCacheStore({ ctx: env.ctx, kv: env.CACHE_KV })
177
+ * ```
178
+ */
179
+ kv?: KVNamespace;
180
+
101
181
  /**
102
182
  * Cache version string override. When this changes, all cached entries are
103
183
  * effectively invalidated (new keys won't match old entries).
@@ -163,6 +243,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
163
243
  private readonly baseUrl: string;
164
244
  private readonly waitUntil?: (fn: () => Promise<void>) => void;
165
245
  private readonly version?: string;
246
+ private readonly kv?: KVNamespace;
166
247
 
167
248
  constructor(options: CFCacheStoreOptions<TEnv>) {
168
249
  if (!options.ctx) {
@@ -179,6 +260,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
179
260
  this.version = options.version ?? VERSION;
180
261
  this.keyGenerator = options.keyGenerator;
181
262
  this.waitUntil = (fn) => options.ctx.waitUntil(fn());
263
+ this.kv = options.kv;
182
264
  }
183
265
 
184
266
  /**
@@ -232,6 +314,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
232
314
  return caches.default;
233
315
  }
234
316
 
317
+ // ============================================================================
318
+ // Segment Cache Methods
319
+ // ============================================================================
320
+
235
321
  /**
236
322
  * Get cached entry data by key.
237
323
  *
@@ -240,7 +326,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
240
326
  * - If already REVALIDATING (and recent), returns shouldRevalidate: false
241
327
  * - If fresh, returns shouldRevalidate: false
242
328
  *
243
- * The atomic mark prevents thundering herd - only first request triggers revalidation.
329
+ * On L1 miss, falls back to KV (L2) if configured.
330
+ * KV hits are promoted to L1 in the background.
244
331
  */
245
332
  async get(key: string): Promise<CacheGetResult | null> {
246
333
  try {
@@ -249,7 +336,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
249
336
  const response = await cache.match(request);
250
337
 
251
338
  if (!response) {
252
- return null;
339
+ return this.kvGetSegment(key);
253
340
  }
254
341
 
255
342
  // Read status headers
@@ -292,6 +379,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
292
379
  /**
293
380
  * Store entry data with TTL and optional SWR window.
294
381
  * Uses waitUntil for non-blocking write when available.
382
+ * When KV is configured, also persists to L2.
295
383
  */
296
384
  async set(
297
385
  key: string,
@@ -308,7 +396,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
308
396
  const totalTtl = ttl + swrWindow;
309
397
  const staleAt = Date.now() + ttl * 1000;
310
398
 
311
- const response = new Response(JSON.stringify(data), {
399
+ const body = JSON.stringify(data);
400
+ const response = new Response(body, {
312
401
  headers: {
313
402
  "Content-Type": "application/json",
314
403
  "Cache-Control": `public, max-age=${totalTtl}`,
@@ -328,18 +417,35 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
328
417
  // Blocking fallback
329
418
  await putPromise;
330
419
  }
420
+
421
+ // L2: persist to KV
422
+ this.kvSetSegment(key, data, staleAt, totalTtl);
331
423
  } catch (error) {
332
424
  console.error("[CFCacheStore] set failed:", error);
333
425
  }
334
426
  }
335
427
 
336
428
  /**
337
- * Delete a cached entry
429
+ * Delete a cached entry from L1 and L2.
338
430
  */
339
431
  async delete(key: string): Promise<boolean> {
340
432
  try {
341
433
  const cache = await this.getCache();
342
- return await cache.delete(this.keyToRequest(key));
434
+ const result = await cache.delete(this.keyToRequest(key));
435
+
436
+ // L2: delete from KV
437
+ if (this.kv && this.waitUntil) {
438
+ const kvKey = this.toKVKey(key);
439
+ this.waitUntil(async () => {
440
+ try {
441
+ await this.kv!.delete(kvKey);
442
+ } catch {
443
+ // KV delete failures are non-critical
444
+ }
445
+ });
446
+ }
447
+
448
+ return result;
343
449
  } catch (error) {
344
450
  console.error("[CFCacheStore] delete failed:", error);
345
451
  return false;
@@ -353,6 +459,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
353
459
  /**
354
460
  * Get a cached Response by key (for document-level caching).
355
461
  * Returns the response and whether it should be revalidated (SWR).
462
+ * Falls back to KV (L2) on L1 miss.
356
463
  */
357
464
  async getResponse(
358
465
  key: string,
@@ -363,7 +470,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
363
470
  const response = await cache.match(request);
364
471
 
365
472
  if (!response || response.status !== 200) {
366
- return null;
473
+ return this.kvGetResponse(key);
367
474
  }
368
475
 
369
476
  // Check staleness
@@ -382,6 +489,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
382
489
 
383
490
  /**
384
491
  * Store a Response with TTL and optional SWR window (for document-level caching).
492
+ * When KV is configured, also persists to L2.
385
493
  */
386
494
  async putResponse(
387
495
  key: string,
@@ -398,12 +506,19 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
398
506
  const totalTtl = ttl + swrWindow;
399
507
  const staleAt = Date.now() + ttl * 1000;
400
508
 
509
+ // Clone body for potential KV write before consuming it for L1
510
+ const [l1Body, kvBody] = this.kv
511
+ ? response.body
512
+ ? response.body.tee()
513
+ : [null, null]
514
+ : [response.body, null];
515
+
401
516
  // Clone and add cache headers
402
517
  const headers = new Headers(response.headers);
403
518
  headers.set("Cache-Control", `public, max-age=${totalTtl}`);
404
519
  headers.set(CACHE_STALE_AT_HEADER, String(staleAt));
405
520
 
406
- const toCache = new Response(response.body, {
521
+ const toCache = new Response(l1Body, {
407
522
  status: response.status,
408
523
  statusText: response.statusText,
409
524
  headers,
@@ -420,6 +535,36 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
420
535
  // Blocking fallback
421
536
  await putPromise;
422
537
  }
538
+
539
+ // L2: persist to KV (KV requires expirationTtl >= 60s)
540
+ if (this.kv && this.waitUntil && totalTtl >= 60) {
541
+ const kvKey = this.toKVKey(`doc:${key}`);
542
+ const headersArray: [string, string][] = [];
543
+ response.headers.forEach((v, k) => headersArray.push([k, v]));
544
+ // Read body as ArrayBuffer and encode to base64 to preserve binary payloads
545
+ const bodyBuf = kvBody
546
+ ? await new Response(kvBody).arrayBuffer()
547
+ : new ArrayBuffer(0);
548
+ const bodyBase64 = bufferToBase64(bodyBuf);
549
+
550
+ this.waitUntil(async () => {
551
+ try {
552
+ const envelope: KVResponseEnvelope = {
553
+ b: bodyBase64,
554
+ st: response.status,
555
+ stx: response.statusText,
556
+ hd: headersArray,
557
+ s: staleAt,
558
+ e: staleAt + swrWindow * 1000,
559
+ };
560
+ await this.kv!.put(kvKey, JSON.stringify(envelope), {
561
+ expirationTtl: totalTtl,
562
+ });
563
+ } catch (error) {
564
+ console.error("[CFCacheStore] KV putResponse failed:", error);
565
+ }
566
+ });
567
+ }
423
568
  } catch (error) {
424
569
  console.error("[CFCacheStore] putResponse failed:", error);
425
570
  }
@@ -432,6 +577,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
432
577
  /**
433
578
  * Get a cached function result by key.
434
579
  * Follows the same SWR pattern as get() for segment caching.
580
+ * Falls back to KV (L2) on L1 miss.
435
581
  */
436
582
  async getItem(key: string): Promise<CacheItemResult | null> {
437
583
  try {
@@ -439,7 +585,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
439
585
  const request = this.keyToRequest(`fn:${key}`);
440
586
  const response = await cache.match(request);
441
587
 
442
- if (!response) return null;
588
+ if (!response) return this.kvGetItem(key);
443
589
 
444
590
  const staleAt = Number(
445
591
  response.headers.get(CACHE_STALE_AT_HEADER) ?? "0",
@@ -485,6 +631,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
485
631
 
486
632
  /**
487
633
  * Store a function result with TTL and optional SWR window.
634
+ * When KV is configured, also persists to L2.
488
635
  */
489
636
  async setItem(
490
637
  key: string,
@@ -519,11 +666,35 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
519
666
  } else {
520
667
  await putPromise;
521
668
  }
669
+
670
+ // L2: persist to KV (KV requires expirationTtl >= 60s)
671
+ if (this.kv && this.waitUntil && totalTtl >= 60) {
672
+ const kvKey = this.toKVKey(`fn:${key}`);
673
+ this.waitUntil(async () => {
674
+ try {
675
+ const envelope: KVItemEnvelope = {
676
+ v: value,
677
+ h: options?.handles,
678
+ s: staleAt,
679
+ e: staleAt + swrWindow * 1000,
680
+ };
681
+ await this.kv!.put(kvKey, JSON.stringify(envelope), {
682
+ expirationTtl: totalTtl,
683
+ });
684
+ } catch (error) {
685
+ console.error("[CFCacheStore] KV setItem failed:", error);
686
+ }
687
+ });
688
+ }
522
689
  } catch (error) {
523
690
  console.error("[CFCacheStore] setItem failed:", error);
524
691
  }
525
692
  }
526
693
 
694
+ // ============================================================================
695
+ // Key Helpers
696
+ // ============================================================================
697
+
527
698
  /**
528
699
  * Convert string key to Request object for CF Cache API.
529
700
  * Includes version in URL if specified (for cache invalidation on code changes).
@@ -537,4 +708,273 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
537
708
  method: "GET",
538
709
  });
539
710
  }
711
+
712
+ /**
713
+ * Convert string key to KV key string.
714
+ * Uses same version prefix as Cache API for consistent invalidation.
715
+ * @internal
716
+ */
717
+ private toKVKey(key: string): string {
718
+ const versionPath = this.version ? `v/${this.version}/` : "";
719
+ return `${versionPath}${key}`;
720
+ }
721
+
722
+ // ============================================================================
723
+ // KV L2 Helpers
724
+ // ============================================================================
725
+
726
+ /**
727
+ * KV fallback for segment cache reads.
728
+ * Returns null if KV is not configured, entry is missing, or expired.
729
+ * Promotes hits to L1 via waitUntil.
730
+ * @internal
731
+ */
732
+ private async kvGetSegment(key: string): Promise<CacheGetResult | null> {
733
+ if (!this.kv) return null;
734
+
735
+ try {
736
+ const kvKey = this.toKVKey(key);
737
+ const raw = await this.kv.get(kvKey, { type: "json" });
738
+ if (!raw) return null;
739
+
740
+ const envelope = raw as KVSegmentEnvelope;
741
+ const now = Date.now();
742
+
743
+ // Hard-expired — treat as miss
744
+ if (now > envelope.e) return null;
745
+
746
+ const shouldRevalidate = now > envelope.s;
747
+
748
+ // Promote to L1 in background
749
+ this.promoteSegmentToL1(key, envelope);
750
+
751
+ return { data: envelope.d, shouldRevalidate };
752
+ } catch (error) {
753
+ console.error("[CFCacheStore] KV get failed:", error);
754
+ return null;
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Write segment data to KV.
760
+ * @internal
761
+ */
762
+ private kvSetSegment(
763
+ key: string,
764
+ data: CachedEntryData,
765
+ staleAt: number,
766
+ totalTtl: number,
767
+ ): void {
768
+ // KV requires expirationTtl >= 60s. Skip write for short-lived entries.
769
+ if (!this.kv || !this.waitUntil || totalTtl < 60) return;
770
+
771
+ const kvKey = this.toKVKey(key);
772
+ const swrWindow = totalTtl * 1000 - (staleAt - Date.now());
773
+ const expiresAt = staleAt + swrWindow;
774
+
775
+ this.waitUntil(async () => {
776
+ try {
777
+ const envelope: KVSegmentEnvelope = {
778
+ d: data,
779
+ s: staleAt,
780
+ e: expiresAt,
781
+ };
782
+ await this.kv!.put(kvKey, JSON.stringify(envelope), {
783
+ expirationTtl: totalTtl,
784
+ });
785
+ } catch (error) {
786
+ console.error("[CFCacheStore] KV set failed:", error);
787
+ }
788
+ });
789
+ }
790
+
791
+ /**
792
+ * Promote segment data from KV to L1 Cache API.
793
+ * @internal
794
+ */
795
+ private promoteSegmentToL1(key: string, envelope: KVSegmentEnvelope): void {
796
+ if (!this.waitUntil) return;
797
+
798
+ this.waitUntil(async () => {
799
+ try {
800
+ const now = Date.now();
801
+ const remainingTtl = Math.max(1, Math.floor((envelope.e - now) / 1000));
802
+ const cache = await this.getCache();
803
+ const request = this.keyToRequest(key);
804
+
805
+ const response = new Response(JSON.stringify(envelope.d), {
806
+ headers: {
807
+ "Content-Type": "application/json",
808
+ "Cache-Control": `public, max-age=${remainingTtl}`,
809
+ [CACHE_STALE_AT_HEADER]: String(envelope.s),
810
+ [CACHE_STATUS_HEADER]: "HIT",
811
+ },
812
+ });
813
+
814
+ await cache.put(request, response);
815
+ } catch (error) {
816
+ console.error("[CFCacheStore] L1 promote failed:", error);
817
+ }
818
+ });
819
+ }
820
+
821
+ /**
822
+ * KV fallback for function cache reads.
823
+ * @internal
824
+ */
825
+ private async kvGetItem(key: string): Promise<CacheItemResult | null> {
826
+ if (!this.kv) return null;
827
+
828
+ try {
829
+ const kvKey = this.toKVKey(`fn:${key}`);
830
+ const raw = await this.kv.get(kvKey, { type: "json" });
831
+ if (!raw) return null;
832
+
833
+ const envelope = raw as KVItemEnvelope;
834
+ const now = Date.now();
835
+
836
+ if (now > envelope.e) return null;
837
+
838
+ const shouldRevalidate = now > envelope.s;
839
+
840
+ // Promote to L1
841
+ this.promoteItemToL1(key, envelope);
842
+
843
+ return {
844
+ value: envelope.v,
845
+ handles: envelope.h,
846
+ shouldRevalidate,
847
+ };
848
+ } catch (error) {
849
+ console.error("[CFCacheStore] KV getItem failed:", error);
850
+ return null;
851
+ }
852
+ }
853
+
854
+ /**
855
+ * Promote function cache data from KV to L1.
856
+ * @internal
857
+ */
858
+ private promoteItemToL1(key: string, envelope: KVItemEnvelope): void {
859
+ if (!this.waitUntil) return;
860
+
861
+ this.waitUntil(async () => {
862
+ try {
863
+ const now = Date.now();
864
+ const remainingTtl = Math.max(1, Math.floor((envelope.e - now) / 1000));
865
+ const cache = await this.getCache();
866
+ const request = this.keyToRequest(`fn:${key}`);
867
+
868
+ const body = JSON.stringify({ value: envelope.v, handles: envelope.h });
869
+ const response = new Response(body, {
870
+ headers: {
871
+ "Content-Type": "application/json",
872
+ "Cache-Control": `public, max-age=${remainingTtl}`,
873
+ [CACHE_STALE_AT_HEADER]: String(envelope.s),
874
+ [CACHE_STATUS_HEADER]: "HIT",
875
+ },
876
+ });
877
+
878
+ await cache.put(request, response);
879
+ } catch (error) {
880
+ console.error("[CFCacheStore] L1 item promote failed:", error);
881
+ }
882
+ });
883
+ }
884
+
885
+ /**
886
+ * KV fallback for document cache reads.
887
+ * @internal
888
+ */
889
+ private async kvGetResponse(
890
+ key: string,
891
+ ): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
892
+ if (!this.kv) return null;
893
+
894
+ try {
895
+ const kvKey = this.toKVKey(`doc:${key}`);
896
+ const raw = await this.kv.get(kvKey, { type: "json" });
897
+ if (!raw) return null;
898
+
899
+ const envelope = raw as KVResponseEnvelope;
900
+ const now = Date.now();
901
+
902
+ if (now > envelope.e) return null;
903
+
904
+ const shouldRevalidate = now > envelope.s;
905
+
906
+ // Reconstruct Response (decode base64 → binary)
907
+ const headers = new Headers(envelope.hd);
908
+ const bodyBuffer = base64ToBuffer(envelope.b);
909
+ const response = new Response(bodyBuffer, {
910
+ status: envelope.st,
911
+ statusText: envelope.stx,
912
+ headers,
913
+ });
914
+
915
+ // Promote to L1
916
+ this.promoteResponseToL1(key, envelope);
917
+
918
+ return { response, shouldRevalidate };
919
+ } catch (error) {
920
+ console.error("[CFCacheStore] KV getResponse failed:", error);
921
+ return null;
922
+ }
923
+ }
924
+
925
+ /**
926
+ * Promote document cache data from KV to L1.
927
+ * @internal
928
+ */
929
+ private promoteResponseToL1(key: string, envelope: KVResponseEnvelope): void {
930
+ if (!this.waitUntil) return;
931
+
932
+ this.waitUntil(async () => {
933
+ try {
934
+ const now = Date.now();
935
+ const remainingTtl = Math.max(1, Math.floor((envelope.e - now) / 1000));
936
+ const cache = await this.getCache();
937
+ const request = this.keyToRequest(`doc:${key}`);
938
+
939
+ const headers = new Headers(envelope.hd);
940
+ headers.set("Cache-Control", `public, max-age=${remainingTtl}`);
941
+ headers.set(CACHE_STALE_AT_HEADER, String(envelope.s));
942
+
943
+ const bodyBuffer = base64ToBuffer(envelope.b);
944
+ const response = new Response(bodyBuffer, {
945
+ status: envelope.st,
946
+ statusText: envelope.stx,
947
+ headers,
948
+ });
949
+
950
+ await cache.put(request, response);
951
+ } catch (error) {
952
+ console.error("[CFCacheStore] L1 response promote failed:", error);
953
+ }
954
+ });
955
+ }
956
+ }
957
+
958
+ // ============================================================================
959
+ // Base64 Helpers (binary-safe response body encoding for KV)
960
+ // ============================================================================
961
+
962
+ /** Encode ArrayBuffer to base64 string. */
963
+ function bufferToBase64(buffer: ArrayBuffer): string {
964
+ const bytes = new Uint8Array(buffer);
965
+ let binary = "";
966
+ for (let i = 0; i < bytes.length; i++) {
967
+ binary += String.fromCharCode(bytes[i]!);
968
+ }
969
+ return btoa(binary);
970
+ }
971
+
972
+ /** Decode base64 string to ArrayBuffer. */
973
+ function base64ToBuffer(base64: string): ArrayBuffer {
974
+ const binary = atob(base64);
975
+ const bytes = new Uint8Array(binary.length);
976
+ for (let i = 0; i < binary.length; i++) {
977
+ bytes[i] = binary.charCodeAt(i);
978
+ }
979
+ return bytes.buffer;
540
980
  }
@@ -10,7 +10,11 @@
10
10
  */
11
11
 
12
12
  // Public API
13
- export { CFCacheStore, type CFCacheStoreOptions } from "./cf-cache-store.js";
13
+ export {
14
+ CFCacheStore,
15
+ type CFCacheStoreOptions,
16
+ type KVNamespace,
17
+ } from "./cf-cache-store.js";
14
18
 
15
19
  // Header constants for debugging and inspection
16
20
  export {