@rangojs/router 0.27.0 → 0.29.0

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 (243) hide show
  1. package/dist/bin/rango.js +1 -1
  2. package/dist/types/__internal.d.ts +7 -1
  3. package/dist/types/browser/action-fence.d.ts +2 -1
  4. package/dist/types/browser/event-controller.d.ts +8 -2
  5. package/dist/types/browser/history-state.d.ts +3 -9
  6. package/dist/types/browser/navigation-store.d.ts +8 -6
  7. package/dist/types/browser/partial-update.d.ts +11 -0
  8. package/dist/types/browser/react/filter-segment-order.d.ts +2 -2
  9. package/dist/types/browser/react/use-action.d.ts +7 -2
  10. package/dist/types/browser/react/use-handle.d.ts +19 -16
  11. package/dist/types/browser/react/use-link-status.d.ts +5 -2
  12. package/dist/types/browser/segment-reconciler.d.ts +32 -4
  13. package/dist/types/browser/types.d.ts +24 -5
  14. package/dist/types/browser/wire-protocol.d.ts +26 -0
  15. package/dist/types/cache/cache-key-utils.d.ts +11 -5
  16. package/dist/types/cache/cache-scope.d.ts +34 -22
  17. package/dist/types/cache/cf/cf-cache-constants.d.ts +2 -2
  18. package/dist/types/cache/cf/cf-cache-store.d.ts +47 -65
  19. package/dist/types/cache/cf/cf-kv-utils.d.ts +1 -1
  20. package/dist/types/cache/handle-snapshot.d.ts +9 -4
  21. package/dist/types/cache/segment-codec.d.ts +14 -0
  22. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  23. package/dist/types/cache/taint.d.ts +5 -3
  24. package/dist/types/cache/types.d.ts +7 -3
  25. package/dist/types/cache/vercel/vercel-cache-store.d.ts +8 -6
  26. package/dist/types/prefetch-markers.d.ts +5 -14
  27. package/dist/types/prerender/store.d.ts +31 -0
  28. package/dist/types/prerender/warm-request.d.ts +3 -3
  29. package/dist/types/prerender.d.ts +2 -2
  30. package/dist/types/route-name.d.ts +5 -0
  31. package/dist/types/router/error-handling.d.ts +11 -5
  32. package/dist/types/router/instrument.d.ts +4 -3
  33. package/dist/types/router/intercept-resolution.d.ts +25 -9
  34. package/dist/types/router/match-context.d.ts +6 -11
  35. package/dist/types/router/match-handlers.d.ts +2 -6
  36. package/dist/types/router/match-middleware/background-revalidation.d.ts +13 -22
  37. package/dist/types/router/match-middleware/cache-lookup.d.ts +34 -86
  38. package/dist/types/router/match-middleware/segment-resolution.d.ts +8 -6
  39. package/dist/types/router/navigation-snapshot.d.ts +7 -0
  40. package/dist/types/router/prerender-match.d.ts +21 -6
  41. package/dist/types/router/preview-match.d.ts +2 -7
  42. package/dist/types/router/request-classification.d.ts +5 -0
  43. package/dist/types/router/router-context.d.ts +7 -46
  44. package/dist/types/router/router-interfaces.d.ts +8 -14
  45. package/dist/types/router/segment-resolution/helpers.d.ts +19 -3
  46. package/dist/types/router/segment-resolution/loader-cache.d.ts +3 -3
  47. package/dist/types/router/segment-resolution/loader-mask.d.ts +4 -4
  48. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +20 -18
  49. package/dist/types/router/segment-resolution/resolution-pass.d.ts +95 -0
  50. package/dist/types/router/segment-resolution/revalidation.d.ts +82 -31
  51. package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +2 -0
  52. package/dist/types/router/segment-resolution.d.ts +1 -2
  53. package/dist/types/router/segment-wrappers.d.ts +11 -48
  54. package/dist/types/router/telemetry.d.ts +2 -0
  55. package/dist/types/router/types.d.ts +11 -1
  56. package/dist/types/rsc/full-payload.d.ts +2 -2
  57. package/dist/types/rsc/handler-context.d.ts +6 -6
  58. package/dist/types/rsc/json-route-result.d.ts +5 -9
  59. package/dist/types/rsc/request-stages.d.ts +203 -0
  60. package/dist/types/rsc/response-cache-serve.d.ts +7 -12
  61. package/dist/types/rsc/response-route-handler.d.ts +2 -2
  62. package/dist/types/rsc/routine-plan.d.ts +1 -1
  63. package/dist/types/rsc/server-action.d.ts +2 -2
  64. package/dist/types/rsc/shell-capture-constants.d.ts +1 -1
  65. package/dist/types/rsc/shell-capture.d.ts +132 -15
  66. package/dist/types/rsc/shell-serve.d.ts +37 -12
  67. package/dist/types/server/context.d.ts +9 -8
  68. package/dist/types/server/handle-store.d.ts +16 -0
  69. package/dist/types/server/render-pass.d.ts +72 -0
  70. package/dist/types/server/request-context.d.ts +53 -104
  71. package/dist/types/server/rewrite-trail.d.ts +2 -2
  72. package/dist/types/testing/dispatch.d.ts +35 -18
  73. package/dist/types/testing/render-route.d.ts +43 -8
  74. package/dist/types/testing/serve-shell-request.d.ts +8 -1
  75. package/dist/types/types/cache-types.d.ts +6 -3
  76. package/dist/types/types/error-types.d.ts +30 -3
  77. package/dist/types/types/handler-context.d.ts +9 -1
  78. package/dist/types/types/segments.d.ts +1 -1
  79. package/dist/types/vite/discovery/build-discovery-phase.d.ts +40 -0
  80. package/dist/types/vite/discovery/build-env.d.ts +22 -0
  81. package/dist/types/vite/discovery/bundle-scan-phase.d.ts +12 -0
  82. package/dist/types/vite/discovery/dev-prerender-cache.d.ts +3 -2
  83. package/dist/types/vite/discovery/dev-server-phase.d.ts +26 -0
  84. package/dist/types/vite/discovery/discovery-errors.d.ts +1 -1
  85. package/dist/types/vite/discovery/state.d.ts +12 -0
  86. package/dist/types/vite/plugins/version-plugin.d.ts +1 -1
  87. package/dist/types/vite/plugins/virtual-entries.d.ts +1 -1
  88. package/dist/types/vite/router-discovery.d.ts +8 -47
  89. package/dist/types/vite/temp-rsc-server.d.ts +39 -0
  90. package/dist/types/vite/utils/prerender-utils.d.ts +9 -0
  91. package/dist/types/wire-params.d.ts +39 -0
  92. package/dist/vite/index.js +3822 -3727
  93. package/package.json +1 -1
  94. package/skills/cache-guide/SKILL.md +8 -5
  95. package/skills/caching/SKILL.md +37 -18
  96. package/skills/client-urls/SKILL.md +4 -2
  97. package/skills/document-cache/SKILL.md +13 -1
  98. package/skills/handler-use/SKILL.md +1 -1
  99. package/skills/hooks/SKILL.md +1 -1
  100. package/skills/hooks/data.md +8 -1
  101. package/skills/hooks/handle-and-actions.md +33 -2
  102. package/skills/hooks/navigation.md +121 -15
  103. package/skills/hooks/state.md +6 -4
  104. package/skills/hooks/urls.md +16 -5
  105. package/skills/intercept/SKILL.md +23 -7
  106. package/skills/middleware/SKILL.md +18 -0
  107. package/skills/observability/SKILL.md +30 -0
  108. package/skills/ppr/SKILL.md +18 -3
  109. package/skills/prerender/SKILL.md +39 -15
  110. package/skills/rango/SKILL.md +5 -1
  111. package/skills/server-actions/SKILL.md +15 -1
  112. package/skills/testing/client-components.md +25 -18
  113. package/skills/testing/response-routes.md +6 -4
  114. package/skills/testing/serve-shell-request.md +7 -7
  115. package/skills/use-cache/SKILL.md +46 -8
  116. package/skills/view-transitions/SKILL.md +1 -1
  117. package/src/__internal.ts +7 -1
  118. package/src/browser/action-fence.ts +2 -1
  119. package/src/browser/connection-warmup.ts +3 -1
  120. package/src/browser/event-controller.ts +37 -13
  121. package/src/browser/history-state.ts +6 -12
  122. package/src/browser/navigation-bridge.ts +59 -26
  123. package/src/browser/navigation-client.ts +20 -25
  124. package/src/browser/navigation-store.ts +25 -27
  125. package/src/browser/navigation-transaction.ts +4 -1
  126. package/src/browser/network-error-handler.ts +4 -0
  127. package/src/browser/partial-update.ts +33 -6
  128. package/src/browser/prefetch/cache.ts +2 -1
  129. package/src/browser/prefetch/fetch.ts +8 -16
  130. package/src/browser/prefetch/queue.ts +4 -4
  131. package/src/browser/react/NavigationProvider.tsx +2 -1
  132. package/src/browser/react/filter-segment-order.ts +2 -2
  133. package/src/browser/react/use-action.ts +24 -48
  134. package/src/browser/react/use-handle.ts +22 -18
  135. package/src/browser/react/use-link-status.ts +48 -60
  136. package/src/browser/react/use-navigation.ts +92 -41
  137. package/src/browser/rsc-router.tsx +1 -0
  138. package/src/browser/segment-reconciler.ts +109 -53
  139. package/src/browser/server-action-bridge.ts +81 -61
  140. package/src/browser/types.ts +25 -5
  141. package/src/browser/wire-protocol.ts +43 -0
  142. package/src/cache/cache-key-utils.ts +11 -5
  143. package/src/cache/cache-runtime.ts +132 -75
  144. package/src/cache/cache-scope.ts +110 -64
  145. package/src/cache/cf/cf-cache-constants.ts +2 -2
  146. package/src/cache/cf/cf-cache-store.ts +486 -874
  147. package/src/cache/cf/cf-kv-utils.ts +1 -1
  148. package/src/cache/document-cache.ts +64 -46
  149. package/src/cache/handle-snapshot.ts +19 -7
  150. package/src/cache/read-through-swr.ts +20 -18
  151. package/src/cache/segment-codec.ts +55 -0
  152. package/src/cache/shell-snapshot.ts +2 -2
  153. package/src/cache/tag-invalidation.ts +6 -2
  154. package/src/cache/taint.ts +5 -3
  155. package/src/cache/types.ts +7 -3
  156. package/src/cache/vercel/vercel-cache-store.ts +243 -310
  157. package/src/handles/pushed-by.ts +1 -1
  158. package/src/prefetch-markers.ts +5 -16
  159. package/src/prerender/store.ts +76 -0
  160. package/src/prerender/warm-request.ts +8 -4
  161. package/src/prerender.ts +2 -2
  162. package/src/route-name.ts +8 -0
  163. package/src/router/error-handling.ts +22 -10
  164. package/src/router/instrument.ts +4 -3
  165. package/src/router/intercept-resolution.ts +82 -75
  166. package/src/router/loader-resolution.ts +5 -4
  167. package/src/router/match-api.ts +5 -5
  168. package/src/router/match-context.ts +23 -12
  169. package/src/router/match-handlers.ts +8 -20
  170. package/src/router/match-middleware/background-revalidation.ts +46 -57
  171. package/src/router/match-middleware/cache-lookup.ts +677 -726
  172. package/src/router/match-middleware/cache-store.ts +4 -8
  173. package/src/router/match-middleware/intercept-resolution.ts +3 -23
  174. package/src/router/match-middleware/segment-resolution.ts +34 -61
  175. package/src/router/middleware.ts +69 -11
  176. package/src/router/navigation-snapshot.ts +14 -4
  177. package/src/router/prerender-match.ts +100 -44
  178. package/src/router/preview-match.ts +6 -41
  179. package/src/router/request-classification.ts +13 -7
  180. package/src/router/route-snapshot.ts +2 -5
  181. package/src/router/router-context.ts +10 -95
  182. package/src/router/router-interfaces.ts +8 -14
  183. package/src/router/segment-resolution/helpers.ts +49 -13
  184. package/src/router/segment-resolution/loader-cache.ts +15 -9
  185. package/src/router/segment-resolution/loader-mask.ts +5 -5
  186. package/src/router/segment-resolution/prefetch-deferral.ts +30 -27
  187. package/src/router/segment-resolution/resolution-pass.ts +139 -0
  188. package/src/router/segment-resolution/revalidation.ts +939 -789
  189. package/src/router/segment-resolution/streamed-handler-boundary.ts +3 -0
  190. package/src/router/segment-resolution.ts +4 -13
  191. package/src/router/segment-wrappers.ts +51 -269
  192. package/src/router/telemetry.ts +5 -0
  193. package/src/router/types.ts +11 -1
  194. package/src/router.ts +19 -17
  195. package/src/rsc/full-payload.ts +6 -5
  196. package/src/rsc/handler-context.ts +8 -14
  197. package/src/rsc/handler.ts +126 -507
  198. package/src/rsc/json-route-result.ts +5 -9
  199. package/src/rsc/loader-fetch.ts +6 -5
  200. package/src/rsc/progressive-enhancement.ts +5 -0
  201. package/src/rsc/request-stages.ts +759 -0
  202. package/src/rsc/response-cache-serve.ts +10 -14
  203. package/src/rsc/response-route-handler.ts +13 -5
  204. package/src/rsc/rewrite-passes.ts +2 -1
  205. package/src/rsc/routine-plan.ts +1 -1
  206. package/src/rsc/rsc-rendering.ts +164 -349
  207. package/src/rsc/server-action.ts +87 -113
  208. package/src/rsc/shell-capture-constants.ts +1 -1
  209. package/src/rsc/shell-capture.ts +275 -227
  210. package/src/rsc/shell-serve.ts +121 -18
  211. package/src/rsc/ssr-setup.ts +5 -4
  212. package/src/segment-system.tsx +3 -3
  213. package/src/server/context.ts +22 -25
  214. package/src/server/handle-store.ts +55 -0
  215. package/src/server/render-pass.ts +291 -0
  216. package/src/server/request-context.ts +75 -115
  217. package/src/server/rewrite-trail.ts +6 -4
  218. package/src/ssr/index.tsx +1 -1
  219. package/src/testing/dispatch.ts +226 -710
  220. package/src/testing/e2e/page-helpers.ts +2 -2
  221. package/src/testing/render-route.tsx +199 -13
  222. package/src/testing/serve-shell-request.ts +36 -40
  223. package/src/types/cache-types.ts +6 -3
  224. package/src/types/error-types.ts +30 -3
  225. package/src/types/handler-context.ts +12 -1
  226. package/src/types/segments.ts +1 -1
  227. package/src/vite/discovery/build-discovery-phase.ts +204 -0
  228. package/src/vite/discovery/build-env.ts +148 -0
  229. package/src/vite/discovery/bundle-scan-phase.ts +128 -0
  230. package/src/vite/discovery/dev-prerender-cache.ts +8 -3
  231. package/src/vite/discovery/dev-server-phase.ts +1776 -0
  232. package/src/vite/discovery/discover-routers.ts +1 -1
  233. package/src/vite/discovery/discovery-errors.ts +1 -1
  234. package/src/vite/discovery/prerender-collection.ts +24 -39
  235. package/src/vite/discovery/state.ts +15 -0
  236. package/src/vite/plugins/version-plugin.ts +1 -1
  237. package/src/vite/plugins/virtual-entries.ts +1 -1
  238. package/src/vite/router-discovery.ts +29 -2428
  239. package/src/vite/temp-rsc-server.ts +223 -0
  240. package/src/vite/utils/prerender-utils.ts +47 -0
  241. package/src/wire-params.ts +40 -0
  242. package/dist/types/router/segment-resolution/fresh.d.ts +0 -72
  243. package/src/router/segment-resolution/fresh.ts +0 -945
@@ -16,7 +16,7 @@ declare global {
16
16
  *
17
17
  * Read flow: L1 hit → serve | L1 miss → L2 hit → serve + promote to L1 | both miss → render
18
18
  * With `kvFallThrough`, an L1 copy a tag marker rejects reads as
19
- * an L1 miss: L2 is checked against the same markers (see get(),
19
+ * an L1 miss: L2 is checked against the same markers (see readL1(),
20
20
  * "Tag invalidation"). Off by default: the rejected copy is a miss.
21
21
  * Write flow: L1 write + L2 write (both via waitUntil)
22
22
  *
@@ -485,6 +485,234 @@ interface KVResponseEnvelope {
485
485
  ta?: number;
486
486
  }
487
487
 
488
+ type CFReadOp = "get" | "getItem" | "getResponse";
489
+
490
+ /** The deadlines every KV envelope carries (ms epoch). */
491
+ interface KVDeadlines {
492
+ s: number;
493
+ e: number;
494
+ }
495
+
496
+ /**
497
+ * A family read of CFCacheStore (`get`, `getItem`, `getResponse`, through
498
+ * readL1 and readKv): its keys, its KV envelope check, what an L1 or KV hit
499
+ * serves and the L1 entry a KV hit is promoted to. `kvMethod` names the KV
500
+ * read in errors and evictions.
501
+ */
502
+ interface CFReadFamily<
503
+ E extends KVDeadlines,
504
+ B,
505
+ V,
506
+ Err extends CacheReadError | null,
507
+ > {
508
+ family: "segment" | "item" | "response";
509
+ debugOp: CFReadOp;
510
+ /** What a failed read returns. */
511
+ readError: Err;
512
+ prefix: "" | "fn:" | "doc:";
513
+ version: KeyVersion;
514
+ kvMethod: string;
515
+ promoteLabel: string;
516
+ /** The corrupt-L1 label of a read that claims the revalidation. */
517
+ staleLabel: string;
518
+ /** Report a tag-marker-namespace key and miss (isReservedSegmentKey). */
519
+ rejectsReservedKeys: boolean;
520
+ validate(envelope: E): boolean;
521
+ /** An L1 hit's value: its parsed body, or the document's client Response. */
522
+ fromL1(body: B, shouldRevalidate: boolean, tags?: string[]): V;
523
+ tags(envelope: E): { tags?: string[]; taggedAt?: number };
524
+ /** A KV hit's value; throws when the envelope does not rebuild. */
525
+ fromKv(envelope: E, shouldRevalidate: boolean): V;
526
+ /**
527
+ * A KV hit's L1 entry: its body and headers, with promoteToL1's
528
+ * Cache-Control, the envelope's deadlines and its tag headers.
529
+ */
530
+ toL1(
531
+ envelope: E,
532
+ cacheControl: string,
533
+ tagHeaders: Record<string, string>,
534
+ ): Response;
535
+ }
536
+
537
+ /**
538
+ * Merge the internal tag headers (tagHeaderEntries) onto an existing Headers
539
+ * instance. The from-scratch paths spread them into an object-literal init;
540
+ * the document put/promote paths build a Headers first, so they .set() each
541
+ * entry instead — except `Cache-Tag`, which is APPENDED: a document author
542
+ * may have set their own Cache-Tag, and clobbering it would break their CDN
543
+ * purging. Append produces the comma-merged list CF expects.
544
+ */
545
+ function setTagHeaders(
546
+ headers: Headers,
547
+ tagHeaders: Record<string, string>,
548
+ ): void {
549
+ for (const [name, value] of Object.entries(tagHeaders)) {
550
+ if (name === "Cache-Tag") headers.append(name, value);
551
+ else headers.set(name, value);
552
+ }
553
+ }
554
+
555
+ /**
556
+ * A JSON family's promoted L1 entry, marked HIT as set() marks it. A plain
557
+ * header record: building a Headers and set()-ing each entry costs the KV-hit
558
+ * path a second validation pass per header.
559
+ */
560
+ function jsonL1Entry(
561
+ body: string,
562
+ e: KVDeadlines,
563
+ cacheControl: string,
564
+ tagHeaders: Record<string, string>,
565
+ ): Response {
566
+ return new Response(body, {
567
+ headers: {
568
+ "Content-Type": "application/json",
569
+ "Cache-Control": cacheControl,
570
+ [CACHE_STALE_AT_HEADER]: String(e.s),
571
+ [CACHE_EXPIRES_AT_HEADER]: String(e.e),
572
+ [CACHE_STATUS_HEADER]: "HIT",
573
+ ...tagHeaders,
574
+ },
575
+ });
576
+ }
577
+
578
+ const SEGMENT_READ: CFReadFamily<
579
+ KVSegmentEnvelope,
580
+ CachedEntryData,
581
+ CacheGetResult,
582
+ CacheReadError
583
+ > = {
584
+ family: "segment",
585
+ debugOp: "get",
586
+ // A KV read TIMEOUT stays a miss: a bounded-latency degrade of a
587
+ // likely-healthy read, unlike a failed one.
588
+ readError: CACHE_READ_ERROR,
589
+ prefix: "",
590
+ version: "data",
591
+ kvMethod: "kvGetSegment",
592
+ promoteLabel: "[CFCacheStore] promoteSegmentToL1",
593
+ staleLabel: "get(revalidating)",
594
+ rejectsReservedKeys: true,
595
+ validate: (e) =>
596
+ typeof e.e === "number" && typeof e.s === "number" && e.d != null,
597
+ tags: (e) => e.d,
598
+ fromL1: (data, shouldRevalidate) => ({ data, shouldRevalidate }),
599
+ fromKv: (e, shouldRevalidate) => ({ data: e.d, shouldRevalidate }),
600
+ toL1: (e, cacheControl, tagHeaders) =>
601
+ jsonL1Entry(JSON.stringify(e.d), e, cacheControl, tagHeaders),
602
+ };
603
+
604
+ const ITEM_READ: CFReadFamily<
605
+ KVItemEnvelope,
606
+ { value: string; handles?: string },
607
+ CacheItemResult,
608
+ null
609
+ > = {
610
+ family: "item",
611
+ debugOp: "getItem",
612
+ readError: null,
613
+ prefix: "fn:",
614
+ version: "data",
615
+ kvMethod: "kvGetItem",
616
+ promoteLabel: "[CFCacheStore] promoteItemToL1",
617
+ staleLabel: "getItem",
618
+ rejectsReservedKeys: false,
619
+ validate: (e) =>
620
+ typeof e.v === "string" &&
621
+ typeof e.e === "number" &&
622
+ typeof e.s === "number",
623
+ tags: (e) => ({ tags: e.t, taggedAt: e.ta }),
624
+ fromL1: (data, shouldRevalidate, tags) => ({
625
+ value: data.value,
626
+ handles: data.handles,
627
+ shouldRevalidate,
628
+ tags,
629
+ }),
630
+ fromKv: (e, shouldRevalidate) => ({
631
+ value: e.v,
632
+ handles: e.h,
633
+ shouldRevalidate,
634
+ tags: e.t,
635
+ }),
636
+ toL1: (e, cacheControl, tagHeaders) =>
637
+ jsonL1Entry(
638
+ JSON.stringify({ value: e.v, handles: e.h }),
639
+ e,
640
+ cacheControl,
641
+ tagHeaders,
642
+ ),
643
+ };
644
+
645
+ const RESPONSE_READ: CFReadFamily<
646
+ KVResponseEnvelope,
647
+ Response,
648
+ { response: Response; shouldRevalidate: boolean },
649
+ null
650
+ > = {
651
+ family: "response",
652
+ debugOp: "getResponse",
653
+ readError: null,
654
+ prefix: "doc:",
655
+ version: "document",
656
+ kvMethod: "kvGetResponse",
657
+ promoteLabel: "[CFCacheStore] promoteResponseToL1",
658
+ // Never read: the document returns before the body read.
659
+ staleLabel: "getResponse",
660
+ rejectsReservedKeys: false,
661
+ validate: (e) =>
662
+ typeof e.b === "string" &&
663
+ typeof e.st === "number" &&
664
+ typeof e.e === "number" &&
665
+ typeof e.s === "number" &&
666
+ // stx is optional but, if present, must be a string (feeds Response).
667
+ (e.stx === undefined || typeof e.stx === "string") &&
668
+ // hd must be an array of [name, value] string tuples; a malformed
669
+ // shape would otherwise throw in `new Headers(hd)`. Validate it here
670
+ // so a faulty envelope is a fail-open MISS, never a thrown read.
671
+ Array.isArray(e.hd) &&
672
+ e.hd.every(
673
+ (entry) =>
674
+ Array.isArray(entry) &&
675
+ entry.length === 2 &&
676
+ typeof entry[0] === "string" &&
677
+ typeof entry[1] === "string",
678
+ ),
679
+ tags: (e) => ({ tags: e.t, taggedAt: e.ta }),
680
+ fromL1: (response, shouldRevalidate) => ({ response, shouldRevalidate }),
681
+ // Corrupt/partial base64 throws in atob, an out-of-range `st` in new
682
+ // Response: a faulty entry, which readKv evicts. Per-client signals a stale
683
+ // envelope may carry are stripped (Finding #3), in place so the promotion
684
+ // re-seeds from the filtered `hd`.
685
+ fromKv: (e, shouldRevalidate) => {
686
+ e.hd = e.hd.filter(([name]) => !isPerClientSignalHeader(name));
687
+ return {
688
+ response: new Response(base64ToBuffer(e.b), {
689
+ status: e.st,
690
+ statusText: e.stx,
691
+ headers: new Headers(e.hd),
692
+ }),
693
+ shouldRevalidate,
694
+ };
695
+ },
696
+ // `hd` is client-facing: the internal headers are re-attached and the
697
+ // author's Cache-Control is stashed for toClientResponse, as putResponse does.
698
+ toL1: (e, cacheControl, tagHeaders) => {
699
+ const headers = new Headers(e.hd);
700
+ const originalCacheControl = headers.get("Cache-Control");
701
+ if (originalCacheControl !== null) {
702
+ headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
703
+ }
704
+ headers.set("Cache-Control", cacheControl);
705
+ headers.set(CACHE_STALE_AT_HEADER, String(e.s));
706
+ headers.set(CACHE_EXPIRES_AT_HEADER, String(e.e));
707
+ setTagHeaders(headers, tagHeaders);
708
+ return new Response(base64ToBuffer(e.b), {
709
+ status: e.st,
710
+ statusText: e.stx,
711
+ headers,
712
+ });
713
+ },
714
+ };
715
+
488
716
  // ============================================================================
489
717
  // CFCacheStore Implementation
490
718
  // ============================================================================
@@ -936,8 +1164,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
936
1164
  // A FAST json() rejection (a corrupt body, or a foreign 200 non-JSON
937
1165
  // response that collided on this key) is caught at the thunk and turned into
938
1166
  // a miss, so the caller falls through to L2/KV exactly like a body-timeout
939
- // -- instead of escaping to get()/getItem()'s outer catch, which returns
940
- // null WITHOUT ever consulting KV. The catch lives here, not in
1167
+ // -- instead of escaping to readL1()'s outer catch, which returns the
1168
+ // family's read error WITHOUT ever consulting KV. The catch lives here, not in
941
1169
  // readWithTimeout, so the L2/KV tier keeps propagating a genuine kv.get
942
1170
  // rejection to its own error sink. The `errored` flag lets the caller emit a
943
1171
  // distinct "body-error" debug outcome rather than masquerading as a timeout.
@@ -967,7 +1195,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
967
1195
  * the faulty per-colo entry ONLY when that fall-through found no good copy.
968
1196
  *
969
1197
  * The conditional evict is the load-bearing detail: when KV DOES serve a copy,
970
- * kvGet* has already scheduled a same-key promote (`cache.put`); an eager
1198
+ * readKv has already scheduled a same-key promote (`cache.put`); an eager
971
1199
  * `cache.delete` here would race that put with no CF Cache API ordering
972
1200
  * guarantee and could clobber the freshly-restored entry. So in that case we
973
1201
  * lean on #558's heal-by-overwrite (the non-suppressed fall-through promotes /
@@ -1011,7 +1239,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1011
1239
 
1012
1240
  /**
1013
1241
  * Re-put a stale L1 entry marked REVALIDATING, so concurrent requests serve it
1014
- * without each triggering a revalidation. Shared by get()/getItem().
1242
+ * without each triggering a revalidation (readL1). A JSON family re-puts its
1243
+ * parsed body as a string, without a status text as set() writes it; the
1244
+ * document re-puts a clone (`source`) whose original body still streams to
1245
+ * the client.
1015
1246
  *
1016
1247
  * The write is NON-BLOCKING (waitUntil) and best-effort by design:
1017
1248
  * - It runs in waitUntil, so it never adds the put latency to the served stale
@@ -1034,61 +1265,26 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1034
1265
  private markRevalidating(
1035
1266
  cache: Cache,
1036
1267
  request: Request,
1037
- sourceHeaders: Headers,
1038
- status: number,
1039
- body: string,
1268
+ source: Response,
1269
+ body: BodyInit | null = source.body,
1270
+ statusText = source.statusText,
1040
1271
  ): void {
1041
1272
  const reputNow = Date.now();
1042
- const headers = new Headers(sourceHeaders);
1273
+ const headers = new Headers(source.headers);
1043
1274
  headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
1044
1275
  headers.set(CACHE_REVALIDATING_AT_HEADER, String(reputNow));
1045
1276
  headers.set("Cache-Control", remainingCacheControl(headers, reputNow));
1046
- const markerResponse = new Response(body, { status, headers });
1047
- const write = async (): Promise<void> => {
1048
- try {
1049
- await cache.put(request, markerResponse);
1050
- } catch {
1051
- // Best-effort: a failed marker write must not affect the served read;
1052
- // the entry simply re-arms on the next stale read.
1053
- }
1054
- };
1055
- if (this.waitUntil) this.waitUntil(write);
1056
- else void write();
1057
- }
1058
-
1059
- /**
1060
- * Document-tier counterpart of markRevalidating for getResponse's herd guard.
1061
- * The segment/item tiers JSON-parse the body, so they re-put with a string
1062
- * body; document bodies are streamed verbatim, so we re-put with a CLONED
1063
- * response body (`response.clone()`) supplied by the caller -- the original
1064
- * body still streams to the client while the marker carries the clone. Same
1065
- * REVALIDATING status header, same revalidating-at stamp, same
1066
- * remainingCacheControl re-put math as markRevalidating, so the document tier
1067
- * suppresses concurrent revalidation for the identical MAX_REVALIDATION_INTERVAL
1068
- * window the segment tier does. Best-effort and non-blocking: a failed marker
1069
- * write must not affect the served stale read.
1070
- * @internal
1071
- */
1072
- private markResponseRevalidating(
1073
- cache: Cache,
1074
- request: Request,
1075
- clonedResponse: Response,
1076
- ): void {
1077
- const reputNow = Date.now();
1078
- const headers = new Headers(clonedResponse.headers);
1079
- headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
1080
- headers.set(CACHE_REVALIDATING_AT_HEADER, String(reputNow));
1081
- headers.set("Cache-Control", remainingCacheControl(headers, reputNow));
1082
- const markerResponse = new Response(clonedResponse.body, {
1083
- status: clonedResponse.status,
1084
- statusText: clonedResponse.statusText,
1277
+ const markerResponse = new Response(body, {
1278
+ status: source.status,
1279
+ statusText,
1085
1280
  headers,
1086
1281
  });
1087
1282
  const write = async (): Promise<void> => {
1088
1283
  try {
1089
1284
  await cache.put(request, markerResponse);
1090
1285
  } catch {
1091
- // Best-effort: see markRevalidating.
1286
+ // Best-effort: a failed marker write must not affect the served read;
1287
+ // the entry simply re-arms on the next stale read.
1092
1288
  }
1093
1289
  };
1094
1290
  if (this.waitUntil) this.waitUntil(write);
@@ -1133,84 +1329,89 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1133
1329
  }
1134
1330
 
1135
1331
  /**
1136
- * Get cached entry data by key.
1137
- *
1138
- * Handles SWR atomically:
1332
+ * Get cached entry data by key (readL1). On L1 miss, falls back to KV (L2)
1333
+ * if configured; KV hits are promoted to L1 in the background.
1334
+ */
1335
+ get(key: string): Promise<CacheGetResult | null | CacheReadError> {
1336
+ return this.readL1(key, SEGMENT_READ);
1337
+ }
1338
+
1339
+ /**
1340
+ * The family's debug sink for one read; the document family is outside the
1341
+ * debug surface (op is only get/getItem). Undefined when debug is off, so
1342
+ * the hot path allocates no event.
1343
+ * @internal
1344
+ */
1345
+ private debugSink(
1346
+ op: CFReadOp,
1347
+ key: string,
1348
+ ): ((event: Omit<CFCacheReadDebugEvent, "op" | "key">) => void) | undefined {
1349
+ return this.debug && op !== "getResponse"
1350
+ ? (event) => this.emitDebug({ op, key, ...event })
1351
+ : undefined;
1352
+ }
1353
+
1354
+ /**
1355
+ * The L1 read of a family, which handles SWR atomically:
1139
1356
  * - If stale and not already revalidating, marks as REVALIDATING and returns shouldRevalidate: true
1140
1357
  * - If already REVALIDATING (and recent), returns shouldRevalidate: false
1141
1358
  * - If fresh, returns shouldRevalidate: false
1142
1359
  *
1143
- * On L1 miss, falls back to KV (L2) if configured.
1144
- * KV hits are promoted to L1 in the background.
1360
+ * Every degraded L1 read falls through to KV (readKv). The family methods
1361
+ * return it unwrapped: an async wrapper adds two promises and two microtask
1362
+ * ticks to every read.
1363
+ * @internal
1145
1364
  */
1146
- async get(key: string): Promise<CacheGetResult | null | CacheReadError> {
1147
- if (this.isReservedSegmentKey(key, "cache-read")) return null;
1365
+ private async readL1<
1366
+ E extends KVDeadlines,
1367
+ V,
1368
+ Err extends CacheReadError | null,
1369
+ >(key: string, f: CFReadFamily<E, unknown, V, Err>): Promise<V | null | Err> {
1370
+ if (f.rejectsReservedKeys && this.isReservedSegmentKey(key, "cache-read"))
1371
+ return null;
1372
+ const debug = this.debugSink(f.debugOp, key);
1148
1373
  try {
1149
1374
  const cache = await this.getCache();
1150
- const request = this.keyToRequest(key, "data");
1151
- const matchStart = Date.now();
1375
+ const request = this.keyToRequest(f.prefix + key, f.version);
1376
+ const matchStart = debug ? Date.now() : 0;
1152
1377
  const {
1153
1378
  response,
1154
1379
  timedOut,
1155
1380
  error: matchError,
1156
1381
  } = await this.matchWithTimeout(cache, request);
1157
- const matchMs = Date.now() - matchStart;
1382
+ const matchMs = debug ? Date.now() - matchStart : 0;
1158
1383
 
1159
1384
  if (!response) {
1160
- if (matchError) {
1161
- // A match REJECTION is reported as cache-read and still degrades to
1162
- // L2/KV -- a real KV value (or KV's own CACHE_READ_ERROR) stands on
1163
- // its own. But a null KV result (unconfigured namespace, kv-miss,
1164
- // kv-timeout) is NOT proof of absence under a rejected L1 match:
1165
- // the only real signal this read produced is the failure, so
1166
- // surface CACHE_READ_ERROR instead of a replayable miss (the PPR
1167
- // seeded fallback must render uncached, not substitute the doc
1168
- // record for a partition the store could not actually read).
1385
+ // A match REJECTION is reported as cache-read and still degrades to
1386
+ // L2/KV, but a null KV result is not proof of absence under it: the
1387
+ // only real signal this read produced is the failure, so it returns
1388
+ // the family's read error (CACHE_READ_ERROR for segments). An
1389
+ // abandoned slow match keeps the fail-open latency-budget policy.
1390
+ if (matchError)
1169
1391
  reportCacheError(
1170
1392
  matchError,
1171
1393
  "cache-read",
1172
- "[CFCacheStore] get L1 match",
1394
+ `[CFCacheStore] ${f.debugOp} L1 match`,
1173
1395
  );
1174
- if (this.debug)
1175
- this.emitDebug({
1176
- op: "get",
1177
- key,
1178
- // Distinct from a genuine absence: surface it as match-error so
1179
- // debug agrees with the cache-read already routed to onError,
1180
- // instead of masquerading as l1-miss.
1181
- outcome: "match-error",
1182
- matchMs,
1183
- });
1184
- const kvResult = await this.kvGetSegment(key);
1185
- return kvResult ?? CACHE_READ_ERROR;
1186
- }
1187
- // An abandoned slow match (timedOut) keeps the fail-open latency-budget
1188
- // policy: degrade to L2/KV, and a KV null stays a miss.
1189
- if (this.debug)
1190
- this.emitDebug({
1191
- op: "get",
1192
- key,
1193
- outcome: timedOut ? "match-timeout" : "l1-miss",
1194
- matchMs,
1195
- });
1196
- return this.kvGetSegment(key);
1396
+ debug?.({
1397
+ outcome: matchError
1398
+ ? "match-error"
1399
+ : timedOut
1400
+ ? "match-timeout"
1401
+ : "l1-miss",
1402
+ matchMs,
1403
+ });
1404
+ if (!matchError) return this.readKv(key, f);
1405
+ return (await this.readKv(key, f)) ?? f.readError;
1197
1406
  }
1198
1407
 
1199
1408
  // A non-200 entry (a cached error response, or a foreign response that
1200
- // landed on this key) is not valid segment data; treat it as a miss
1201
- // rather than JSON-parsing garbage and serving it as a hit.
1409
+ // landed on this key) is not valid data: a miss. Its KV read suppresses
1410
+ // revalidation, so a broken L1 entry hit concurrently serves KV-stale,
1411
+ // not a herd (the document read never did).
1202
1412
  if (response.status !== 200) {
1203
- if (this.debug)
1204
- this.emitDebug({
1205
- op: "get",
1206
- key,
1207
- outcome: "non-200",
1208
- status: response.status,
1209
- matchMs,
1210
- });
1211
- // Degraded fall-through: suppress revalidation so a broken L1 entry hit
1212
- // concurrently serves KV-stale, not a herd. See kvGetSegment.
1213
- return this.kvGetSegment(key, { suppressRevalidate: true });
1413
+ debug?.({ outcome: "non-200", status: response.status, matchMs });
1414
+ return this.readKv(key, f, f.family !== "response");
1214
1415
  }
1215
1416
 
1216
1417
  // Tag invalidation: a copy whose tags were invalidated after it was
@@ -1218,39 +1419,31 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1218
1419
  // colo re-renders. With it, it reads as an L1 miss, because the tiers can
1219
1420
  // diverge: another colo may have written KV after the marker (its own
1220
1421
  // re-render, or a router.prerender() warm after updateTag()), and this
1221
- // colo then serves that entry instead of rendering it again.
1222
- // kvGetSegment checks the KV entry against the same markers (this
1223
- // request's memo: no second marker read) and promotes a valid one over
1224
- // this copy. When KV still holds the twin written with this copy, the
1225
- // read costs one KV get and misses as before. The request that
1226
- // invalidated the tag itself keeps the plain miss (declinesKvFallThrough).
1422
+ // colo then serves that entry instead of rendering it again. readKv
1423
+ // checks it against the same markers (this request's memo: no second
1424
+ // marker read). The request that invalidated the tag itself keeps the
1425
+ // plain miss (declinesKvFallThrough). The marker-resolution tail is
1426
+ // measured only under debug.
1227
1427
  const tagInfo = this.readTagInfo(response.headers);
1228
- // Measure the marker-resolution tail (memo -> L1 marker cache -> KV) only
1229
- // when debug is on, so the hot path pays nothing. It is the serial read
1230
- // that sits between matchMs and bodyReadMs for a tagged entry.
1231
- const markerStart = this.debug ? Date.now() : 0;
1428
+ const markerStart = debug ? Date.now() : 0;
1232
1429
  const invalidated = await this.isL1Invalidated(
1233
1430
  tagInfo.tags,
1234
1431
  tagInfo.taggedAt,
1235
1432
  response.headers,
1236
1433
  );
1237
- const markerMs = this.debug ? Date.now() - markerStart : undefined;
1434
+ const markerMs = debug ? Date.now() - markerStart : undefined;
1238
1435
  if (invalidated) {
1239
- if (this.debug)
1240
- this.emitDebug({
1241
- op: "get",
1242
- key,
1243
- outcome: "tag-invalidated",
1244
- status: response.status,
1245
- matchMs,
1246
- markerMs,
1247
- });
1436
+ debug?.({
1437
+ outcome: "tag-invalidated",
1438
+ status: response.status,
1439
+ matchMs,
1440
+ markerMs,
1441
+ });
1248
1442
  return this.declinesKvFallThrough(tagInfo.tags, tagInfo.taggedAt)
1249
1443
  ? null
1250
- : this.kvGetSegment(key);
1444
+ : this.readKv(key, f);
1251
1445
  }
1252
1446
 
1253
- // Read status headers
1254
1447
  const status = response.headers.get(CACHE_STATUS_HEADER);
1255
1448
  const staleAt = Number(
1256
1449
  response.headers.get(CACHE_STALE_AT_HEADER) ?? "0",
@@ -1258,7 +1451,6 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1258
1451
  const revalidatingAt = Number(
1259
1452
  response.headers.get(CACHE_REVALIDATING_AT_HEADER) ?? "0",
1260
1453
  );
1261
-
1262
1454
  const now = Date.now();
1263
1455
  const isStale = staleAt > 0 && now > staleAt;
1264
1456
  // Recency comes from our explicit revalidating-at stamp, not CF's `Age`
@@ -1268,19 +1460,37 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1268
1460
  status === "REVALIDATING" &&
1269
1461
  revalidatingAt > 0 &&
1270
1462
  now - revalidatingAt < MAX_REVALIDATION_INTERVAL * 1000;
1463
+ const shouldRevalidate = isStale && !isRevalidating;
1464
+
1465
+ if (f.family === "response") {
1466
+ // The document body streams through verbatim, so its marker re-put
1467
+ // carries a clone. Integrity relies on the Cache API: cache.put
1468
+ // stores a response atomically or fails, so a truncated body is not
1469
+ // served back; buffering + hashing it would defeat streaming and add
1470
+ // a full read to every hit.
1471
+ if (shouldRevalidate)
1472
+ this.markRevalidating(cache, request, response.clone());
1473
+ return f.fromL1(this.toClientResponse(response), shouldRevalidate);
1474
+ }
1271
1475
 
1272
- // Single emitter for the post-header L1 outcomes. Undefined (so the event
1273
- // object is never allocated) when debug is off; the informational-only
1274
- // `age` header is read lazily inside for the same reason.
1275
- const debugRead = this.debug
1476
+ // Read the body under the edge-read budget BEFORE writing the
1477
+ // REVALIDATING marker: CF can resolve match() fast but stall the body
1478
+ // stream, and reading first bounds the stall and skips marking an entry
1479
+ // that could not be read.
1480
+ const bodyStart = debug ? Date.now() : 0;
1481
+ const {
1482
+ value: body,
1483
+ errored,
1484
+ error,
1485
+ } = await this.readJsonWithTimeout(response);
1486
+ const bodyReadMs = debug ? Date.now() - bodyStart : 0;
1487
+ // The informational-only `age` header is read only under debug.
1488
+ const debugL1 = debug
1276
1489
  ? (
1277
1490
  outcome: CFCacheReadDebugEvent["outcome"],
1278
- bodyReadMs: number,
1279
1491
  shouldRevalidate?: boolean,
1280
1492
  ) =>
1281
- this.emitDebug({
1282
- op: "get",
1283
- key,
1493
+ debug({
1284
1494
  outcome,
1285
1495
  status: response.status,
1286
1496
  cacheStatus: status,
@@ -1295,88 +1505,44 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1295
1505
  bodyReadMs,
1296
1506
  })
1297
1507
  : undefined;
1298
-
1299
- // Case 1: Fresh or already being revalidated - just return data
1300
- if (!isStale || isRevalidating) {
1301
- const bodyStart = Date.now();
1302
- const {
1303
- value: data,
1304
- errored,
1305
- error,
1306
- } = await this.readJsonWithTimeout<CachedEntryData>(response);
1307
- const bodyReadMs = Date.now() - bodyStart;
1308
- if (data === undefined) {
1309
- debugRead?.(errored ? "body-error" : "body-timeout", bodyReadMs);
1310
- // A body-ERROR (corrupt/foreign body) self-heals via healCorruptL1:
1311
- // report cache-corrupt, fall through to L2/KV (which overwrites the
1312
- // bad entry), and evict only if KV had no good copy to promote. A
1313
- // body-TIMEOUT is a degraded read of a likely-valid entry: leave it
1314
- // intact and suppress revalidation so a stalling colo cannot herd.
1315
- if (errored)
1316
- return this.healCorruptL1(cache, request, error, "get", () =>
1317
- this.kvGetSegment(key, { suppressRevalidate: false }),
1318
- );
1319
- return this.kvGetSegment(key, { suppressRevalidate: true });
1320
- }
1321
- debugRead?.(
1322
- isRevalidating ? "l1-revalidating-guarded" : "l1-fresh",
1323
- bodyReadMs,
1324
- false,
1325
- );
1326
- return { data, shouldRevalidate: false };
1327
- }
1328
-
1329
- // Case 2: Stale and needs revalidation.
1330
- // Read the body under the edge-read budget BEFORE writing the REVALIDATING
1331
- // marker. CF can resolve match() fast but stall the body stream; the prior
1332
- // approach teed the stream and awaited cache.put(b1) first, which blocked
1333
- // on that same stalled stream so the read budget could never fire on a
1334
- // stale hit. Reading first bounds the stall and lets us skip marking an
1335
- // entry we could not even read.
1336
- const bodyStart = Date.now();
1337
- const {
1338
- value: data,
1339
- errored,
1340
- error,
1341
- } = await this.readJsonWithTimeout<CachedEntryData>(response);
1342
- const bodyReadMs = Date.now() - bodyStart;
1343
- if (data === undefined) {
1344
- debugRead?.(errored ? "body-error" : "body-timeout", bodyReadMs);
1345
- // Heal + conditionally evict a body-error, suppress a body-timeout; see
1346
- // Case 1.
1508
+ if (body === undefined) {
1509
+ debugL1?.(errored ? "body-error" : "body-timeout");
1510
+ // A body-ERROR (corrupt/foreign body) self-heals: healCorruptL1
1511
+ // reports it and evicts only if KV had no good copy to promote. A
1512
+ // body-TIMEOUT is a degraded read of a likely-valid entry: leave it
1513
+ // and suppress revalidation so a stalling colo cannot herd.
1347
1514
  if (errored)
1348
1515
  return this.healCorruptL1(
1349
1516
  cache,
1350
1517
  request,
1351
1518
  error,
1352
- "get(revalidating)",
1353
- () => this.kvGetSegment(key, { suppressRevalidate: false }),
1519
+ shouldRevalidate ? f.staleLabel : f.debugOp,
1520
+ () => this.readKv(key, f),
1354
1521
  );
1355
- return this.kvGetSegment(key, { suppressRevalidate: true });
1522
+ return this.readKv(key, f, true);
1356
1523
  }
1357
1524
 
1358
- // Mark REVALIDATING so concurrent requests don't all revalidate, then
1359
- // return the stale data. The marker write is non-blocking and best-effort
1360
- // (see markRevalidating) -- it must not add latency to, or fail, the served
1361
- // stale read.
1362
- this.markRevalidating(
1363
- cache,
1364
- request,
1365
- response.headers,
1366
- response.status,
1367
- JSON.stringify(data),
1525
+ if (shouldRevalidate)
1526
+ this.markRevalidating(
1527
+ cache,
1528
+ request,
1529
+ response,
1530
+ JSON.stringify(body),
1531
+ "",
1532
+ );
1533
+ debugL1?.(
1534
+ shouldRevalidate
1535
+ ? "l1-stale-revalidate"
1536
+ : isRevalidating
1537
+ ? "l1-revalidating-guarded"
1538
+ : "l1-fresh",
1539
+ shouldRevalidate,
1368
1540
  );
1369
-
1370
- debugRead?.("l1-stale-revalidate", bodyReadMs, true);
1371
- return { data, shouldRevalidate: true };
1541
+ return f.fromL1(body, shouldRevalidate, tagInfo.tags);
1372
1542
  } catch (error) {
1373
- // reportCacheError logs and routes to onError (cache-read); the debug
1374
- // emit is the separate wrangler-tail signal. Keep both observability paths.
1375
- reportCacheError(error, "cache-read", "[CFCacheStore] get");
1376
- if (this.debug) this.emitDebug({ op: "get", key, outcome: "error" });
1377
- // Distinct from a miss so the PPR replay composition renders uncached
1378
- // instead of substituting the seeded doc record (CACHE_READ_ERROR).
1379
- return CACHE_READ_ERROR;
1543
+ reportCacheError(error, "cache-read", `[CFCacheStore] ${f.debugOp}`);
1544
+ debug?.({ outcome: "error" });
1545
+ return f.readError;
1380
1546
  }
1381
1547
  }
1382
1548
 
@@ -1497,104 +1663,14 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1497
1663
  // ============================================================================
1498
1664
 
1499
1665
  /**
1500
- * Get a cached Response by key (for document-level caching).
1501
- * Returns the response and whether it should be revalidated (SWR).
1502
- * Falls back to KV (L2) on L1 miss.
1666
+ * Get a cached Response by key (for document-level caching): see readL1.
1667
+ * Its body streams to the client unread, outside the debug surface. Falls
1668
+ * back to KV (L2) on L1 miss.
1503
1669
  */
1504
- async getResponse(
1670
+ getResponse(
1505
1671
  key: string,
1506
1672
  ): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
1507
- try {
1508
- const cache = await this.getCache();
1509
- const request = this.keyToRequest(`doc:${key}`, "document");
1510
- // The document path is outside the debug surface (op is only get/getItem),
1511
- // so the match-timeout flag is not surfaced as an event here -- though
1512
- // matchWithTimeout still warns on a slow match. A miss or timeout falls
1513
- // through to the KV document path and then render.
1514
- const { response, error: matchError } = await this.matchWithTimeout(
1515
- cache,
1516
- request,
1517
- );
1518
-
1519
- if (!response || response.status !== 200) {
1520
- // A transient L1 match rejection (matchError set; only ever set when
1521
- // response is undefined) is surfaced as cache-read before degrading to
1522
- // L2/KV -- matching get()/getItem(). A genuine miss or a non-200 hit
1523
- // carries no matchError and reports nothing.
1524
- if (matchError)
1525
- reportCacheError(
1526
- matchError,
1527
- "cache-read",
1528
- "[CFCacheStore] getResponse L1 match",
1529
- );
1530
- return this.kvGetResponse(key);
1531
- }
1532
-
1533
- // Tag invalidation check: a rejected copy reads as an L1 miss (see get()).
1534
- const tagInfo = this.readTagInfo(response.headers);
1535
- if (
1536
- await this.isL1Invalidated(
1537
- tagInfo.tags,
1538
- tagInfo.taggedAt,
1539
- response.headers,
1540
- )
1541
- ) {
1542
- return this.declinesKvFallThrough(tagInfo.tags, tagInfo.taggedAt)
1543
- ? null
1544
- : this.kvGetResponse(key);
1545
- }
1546
-
1547
- // Check staleness
1548
- const staleAt = Number(response.headers.get(CACHE_STALE_AT_HEADER) || 0);
1549
- const now = Date.now();
1550
- const isStale = staleAt > 0 && now > staleAt;
1551
-
1552
- // Thundering-herd guard, mirroring the segment (get) and item (getItem)
1553
- // tiers. Without it, every concurrent stale reader returned
1554
- // shouldRevalidate=true and document-cache.ts scheduled a fresh render for
1555
- // each one. Recency comes from our own revalidating-at stamp, not CF's Age
1556
- // header (see CACHE_REVALIDATING_AT_HEADER); an absent/zero stamp counts as
1557
- // "not recent" so a dropped revalidation re-arms instead of pinning.
1558
- const status = response.headers.get(CACHE_STATUS_HEADER);
1559
- const revalidatingAt = Number(
1560
- response.headers.get(CACHE_REVALIDATING_AT_HEADER) ?? "0",
1561
- );
1562
- const isRevalidating =
1563
- status === "REVALIDATING" &&
1564
- revalidatingAt > 0 &&
1565
- now - revalidatingAt < MAX_REVALIDATION_INTERVAL * 1000;
1566
-
1567
- // L1 document bodies are streamed through verbatim - unlike the segment/
1568
- // item tiers (which JSON-parse and so structurally detect corruption) and
1569
- // the KV doc tier (validated in kvGetResponse, KV being the real partial-
1570
- // read vector). Integrity here relies on the Cache API: cache.put stores a
1571
- // response atomically or fails, so a truncated body is not served back. We
1572
- // deliberately do NOT buffer+hash the body to re-verify it: that would
1573
- // defeat streaming the document and add a full read to every cache hit.
1574
-
1575
- if (isStale && !isRevalidating) {
1576
- // First stale reader within the window: mark REVALIDATING (non-blocking,
1577
- // best-effort) so concurrent readers below see the guard and suppress,
1578
- // then return shouldRevalidate=true so this caller revalidates. Clone the
1579
- // matched response for the marker since its original body must still
1580
- // stream to the client.
1581
- this.markResponseRevalidating(cache, request, response.clone());
1582
- return {
1583
- response: this.toClientResponse(response),
1584
- shouldRevalidate: true,
1585
- };
1586
- }
1587
-
1588
- // Fresh, or stale-but-already-REVALIDATING: serve without scheduling a
1589
- // (re-)revalidation. A recent marker already has a render in flight.
1590
- return {
1591
- response: this.toClientResponse(response),
1592
- shouldRevalidate: false,
1593
- };
1594
- } catch (error) {
1595
- reportCacheError(error, "cache-read", "[CFCacheStore] getResponse");
1596
- return null;
1597
- }
1673
+ return this.readL1(key, RESPONSE_READ);
1598
1674
  }
1599
1675
 
1600
1676
  /**
@@ -1684,7 +1760,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1684
1760
  // toClientResponse before serving.
1685
1761
  headers.set(CACHE_EXPIRES_AT_HEADER, String(staleAt + swrWindow * 1000));
1686
1762
  // Internal tag headers (stripped by toClientResponse before serving).
1687
- this.setTagHeaders(headers, tags, taggedAt);
1763
+ setTagHeaders(headers, this.tagHeaderEntries(tags, taggedAt));
1688
1764
 
1689
1765
  const toCache = new Response(l1Body, {
1690
1766
  status: response.status,
@@ -1756,185 +1832,11 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1756
1832
 
1757
1833
  /**
1758
1834
  * Get a cached function result by key.
1759
- * Follows the same SWR pattern as get() for segment caching.
1835
+ * Follows the same SWR pattern as get() for segment caching (readL1).
1760
1836
  * Falls back to KV (L2) on L1 miss.
1761
1837
  */
1762
- async getItem(key: string): Promise<CacheItemResult | null> {
1763
- try {
1764
- const cache = await this.getCache();
1765
- const request = this.keyToRequest(`fn:${key}`, "data");
1766
- const matchStart = Date.now();
1767
- const {
1768
- response,
1769
- timedOut,
1770
- error: matchError,
1771
- } = await this.matchWithTimeout(cache, request);
1772
- const matchMs = Date.now() - matchStart;
1773
-
1774
- if (!response) {
1775
- // Transient match error reported cache-read; still degrades to L2/KV.
1776
- if (matchError)
1777
- reportCacheError(
1778
- matchError,
1779
- "cache-read",
1780
- "[CFCacheStore] getItem L1 match",
1781
- );
1782
- if (this.debug)
1783
- this.emitDebug({
1784
- op: "getItem",
1785
- key,
1786
- // match-error (rejection) vs l1-miss (absence); see get().
1787
- outcome: matchError
1788
- ? "match-error"
1789
- : timedOut
1790
- ? "match-timeout"
1791
- : "l1-miss",
1792
- matchMs,
1793
- });
1794
- return this.kvGetItem(key);
1795
- }
1796
-
1797
- // Non-200 entry is not a valid cached function result; treat as a miss.
1798
- if (response.status !== 200) {
1799
- if (this.debug)
1800
- this.emitDebug({
1801
- op: "getItem",
1802
- key,
1803
- outcome: "non-200",
1804
- status: response.status,
1805
- matchMs,
1806
- });
1807
- // Degraded fall-through: suppress revalidation so a broken L1 entry hit
1808
- // concurrently serves KV-stale instead of spawning a herd (see get()).
1809
- return this.kvGetItem(key, { suppressRevalidate: true });
1810
- }
1811
-
1812
- // Tag invalidation check: a rejected copy reads as an L1 miss (see
1813
- // get()). Measure the marker-resolution tail only under debug.
1814
- const tagInfo = this.readTagInfo(response.headers);
1815
- const markerStart = this.debug ? Date.now() : 0;
1816
- const invalidated = await this.isL1Invalidated(
1817
- tagInfo.tags,
1818
- tagInfo.taggedAt,
1819
- response.headers,
1820
- );
1821
- const markerMs = this.debug ? Date.now() - markerStart : undefined;
1822
- if (invalidated) {
1823
- if (this.debug)
1824
- this.emitDebug({
1825
- op: "getItem",
1826
- key,
1827
- outcome: "tag-invalidated",
1828
- status: response.status,
1829
- matchMs,
1830
- markerMs,
1831
- });
1832
- return this.declinesKvFallThrough(tagInfo.tags, tagInfo.taggedAt)
1833
- ? null
1834
- : this.kvGetItem(key);
1835
- }
1836
-
1837
- const staleAt = Number(
1838
- response.headers.get(CACHE_STALE_AT_HEADER) ?? "0",
1839
- );
1840
- const status = response.headers.get(CACHE_STATUS_HEADER);
1841
- const revalidatingAt = Number(
1842
- response.headers.get(CACHE_REVALIDATING_AT_HEADER) ?? "0",
1843
- );
1844
-
1845
- const now = Date.now();
1846
- const isStale = staleAt > 0 && now > staleAt;
1847
- // Recency from our explicit stamp, not CF's `Age` header (see get()).
1848
- const isRevalidating =
1849
- status === "REVALIDATING" &&
1850
- revalidatingAt > 0 &&
1851
- now - revalidatingAt < MAX_REVALIDATION_INTERVAL * 1000;
1852
-
1853
- // Single emitter for the post-header L1 outcomes (see get()). Undefined
1854
- // when debug is off, so the event object is never allocated on the hot
1855
- // path; the informational-only `age` header is read lazily inside.
1856
- const debugRead = this.debug
1857
- ? (
1858
- outcome: CFCacheReadDebugEvent["outcome"],
1859
- bodyReadMs: number,
1860
- shouldRevalidate?: boolean,
1861
- ) =>
1862
- this.emitDebug({
1863
- op: "getItem",
1864
- key,
1865
- outcome,
1866
- status: response.status,
1867
- cacheStatus: status,
1868
- staleAt,
1869
- revalidatingAt,
1870
- ageHeader: response.headers.get("age"),
1871
- isStale,
1872
- isRevalidating,
1873
- shouldRevalidate,
1874
- matchMs,
1875
- markerMs,
1876
- bodyReadMs,
1877
- })
1878
- : undefined;
1879
-
1880
- const bodyStart = Date.now();
1881
- const {
1882
- value: data,
1883
- errored,
1884
- error,
1885
- } = await this.readJsonWithTimeout<{
1886
- value: string;
1887
- handles?: string;
1888
- }>(response);
1889
- const bodyReadMs = Date.now() - bodyStart;
1890
- if (data === undefined) {
1891
- debugRead?.(errored ? "body-error" : "body-timeout", bodyReadMs);
1892
- // Heal + conditionally evict a body-error, suppress a body-timeout; see
1893
- // get().
1894
- if (errored)
1895
- return this.healCorruptL1(cache, request, error, "getItem", () =>
1896
- this.kvGetItem(key, { suppressRevalidate: false }),
1897
- );
1898
- return this.kvGetItem(key, { suppressRevalidate: true });
1899
- }
1900
-
1901
- if (!isStale || isRevalidating) {
1902
- debugRead?.(
1903
- isRevalidating ? "l1-revalidating-guarded" : "l1-fresh",
1904
- bodyReadMs,
1905
- false,
1906
- );
1907
- return {
1908
- value: data.value,
1909
- handles: data.handles,
1910
- shouldRevalidate: false,
1911
- tags: tagInfo.tags,
1912
- };
1913
- }
1914
-
1915
- // Stale and needs revalidation -- mark REVALIDATING (non-blocking,
1916
- // best-effort, remaining-ttl) and return the stale value. See get() /
1917
- // markRevalidating for the full rationale.
1918
- this.markRevalidating(
1919
- cache,
1920
- request,
1921
- response.headers,
1922
- 200,
1923
- JSON.stringify(data),
1924
- );
1925
-
1926
- debugRead?.("l1-stale-revalidate", bodyReadMs, true);
1927
- return {
1928
- value: data.value,
1929
- handles: data.handles,
1930
- shouldRevalidate: true,
1931
- tags: tagInfo.tags,
1932
- };
1933
- } catch (error) {
1934
- reportCacheError(error, "cache-read", "[CFCacheStore] getItem");
1935
- if (this.debug) this.emitDebug({ op: "getItem", key, outcome: "error" });
1936
- return null;
1937
- }
1838
+ getItem(key: string): Promise<CacheItemResult | null> {
1839
+ return this.readL1(key, ITEM_READ);
1938
1840
  }
1939
1841
 
1940
1842
  /**
@@ -2340,7 +2242,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2340
2242
  * caller's `tagHints`: cfTagHints may have evicted the key while the memo
2341
2243
  * still holds it. A shell a tag marker rejects is dropped and read from
2342
2244
  * the store too, which may hold a capture from after the marker (see
2343
- * get(), "Tag invalidation"), unless the request's own mask rejected it
2245
+ * readL1(), "Tag invalidation"), unless the request's own mask rejected it
2344
2246
  * (declinesKvFallThrough).
2345
2247
  */
2346
2248
  private async readMemoizedShell(
@@ -3222,27 +3124,6 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3222
3124
  return true;
3223
3125
  }
3224
3126
 
3225
- /**
3226
- * Merge the internal tag headers onto an existing Headers instance. The
3227
- * from-scratch paths spread tagHeaderEntries() into an object-literal init;
3228
- * the document put/promote paths build a Headers first, so they .set() each
3229
- * entry instead — except `Cache-Tag`, which is APPENDED: a document author
3230
- * may have set their own Cache-Tag, and clobbering it would break their CDN
3231
- * purging. Append produces the comma-merged list CF expects.
3232
- */
3233
- private setTagHeaders(
3234
- headers: Headers,
3235
- tags: string[] | undefined,
3236
- taggedAt: number | undefined,
3237
- ): void {
3238
- for (const [name, value] of Object.entries(
3239
- this.tagHeaderEntries(tags, taggedAt),
3240
- )) {
3241
- if (name === "Cache-Tag") headers.append(name, value);
3242
- else headers.set(name, value);
3243
- }
3244
- }
3245
-
3246
3127
  /** Read an entry's tags/taggedAt back from its headers. */
3247
3128
  private readTagInfo(headers: Headers): {
3248
3129
  tags?: string[];
@@ -4024,254 +3905,102 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
4024
3905
  // ============================================================================
4025
3906
 
4026
3907
  /**
4027
- * KV fallback for segment cache reads.
4028
- * Returns null if KV is not configured, entry is missing, or expired.
4029
- * Promotes hits to L1 via waitUntil.
3908
+ * The KV (L2) read behind an L1 miss, for every family: a missing,
3909
+ * timed-out, corrupt (kvGetOrEvict reports and evicts it), expired or
3910
+ * tag-invalidated envelope is a miss, a read failure is the family's read
3911
+ * error, and a hit is promoted to L1 in the background.
3912
+ *
3913
+ * A degraded L1 fall-through (body-timeout / non-200) passes
3914
+ * `suppressRevalidate`: KV has no REVALIDATING herd guard, so N concurrent
3915
+ * degraded reads would otherwise each spawn a render exactly when the colo
3916
+ * is already struggling. The stale data is still served and promoted; only
3917
+ * the revalidation is withheld.
4030
3918
  * @internal
4031
3919
  */
4032
- private async kvGetSegment(
3920
+ private async readKv<
3921
+ E extends KVDeadlines,
3922
+ V,
3923
+ Err extends CacheReadError | null,
3924
+ >(
4033
3925
  key: string,
4034
- opts?: { suppressRevalidate?: boolean },
4035
- ): Promise<CacheGetResult | null | CacheReadError> {
3926
+ f: CFReadFamily<E, unknown, V, Err>,
3927
+ suppressRevalidate = false,
3928
+ ): Promise<V | null | Err> {
4036
3929
  if (!this.kv) return null;
4037
-
3930
+ const debug = this.debugSink(f.debugOp, key);
4038
3931
  try {
4039
- const kvKey = await this.toKVKey(key, "data");
4040
- const { value: envelope, timedOut } =
4041
- await this.kvGetOrEvict<KVSegmentEnvelope>(
4042
- kvKey,
4043
- (e) =>
4044
- typeof e.e === "number" && typeof e.s === "number" && e.d != null,
4045
- "kvGetSegment",
4046
- );
4047
- if (timedOut) {
4048
- // Abandoned slow KV read: no envelope, so no promote-to-L1. Distinct
4049
- // from a genuine kv-miss so the degradation is visible on wrangler tail.
4050
- if (this.debug)
4051
- this.emitDebug({ op: "get", key, outcome: "kv-timeout" });
4052
- return null;
4053
- }
3932
+ const kvKey = await (f.family === "response"
3933
+ ? this.toDocKVKey(key)
3934
+ : this.toKVKey(f.prefix + key, f.version));
3935
+ const { value: envelope, timedOut } = await this.kvGetOrEvict(
3936
+ kvKey,
3937
+ f.validate,
3938
+ f.kvMethod,
3939
+ );
3940
+ // An abandoned slow read is distinct from a genuine kv-miss, so the
3941
+ // degradation is visible on wrangler tail.
4054
3942
  if (!envelope) {
4055
- // Missing key, or a corrupt entry already evicted + reported by
4056
- // kvGetOrEvict. Either way a miss.
4057
- if (this.debug) this.emitDebug({ op: "get", key, outcome: "kv-miss" });
3943
+ debug?.({ outcome: timedOut ? "kv-timeout" : "kv-miss" });
4058
3944
  return null;
4059
3945
  }
4060
-
4061
3946
  const now = Date.now();
4062
-
4063
- // Hard-expired — treat as miss
3947
+ // Hard-expired: a miss.
4064
3948
  if (now > envelope.e) {
4065
- if (this.debug) this.emitDebug({ op: "get", key, outcome: "kv-miss" });
3949
+ debug?.({ outcome: "kv-miss" });
4066
3950
  return null;
4067
3951
  }
4068
-
4069
- // Tag invalidation check (also covers the KV tier, not just L1).
4070
- if (
4071
- await this.isGloballyInvalidated(envelope.d.tags, envelope.d.taggedAt)
4072
- ) {
4073
- if (this.debug)
4074
- this.emitDebug({ op: "get", key, outcome: "tag-invalidated" });
3952
+ // KV entries answer to tag invalidation as L1 copies do.
3953
+ const { tags, taggedAt } = f.tags(envelope);
3954
+ if (await this.isGloballyInvalidated(tags, taggedAt)) {
3955
+ debug?.({ outcome: "tag-invalidated" });
4075
3956
  return null;
4076
3957
  }
4077
-
4078
- // When this is a degraded L1 fall-through (body-timeout / non-200), the
4079
- // caller asks us to suppress revalidation: KV has no REVALIDATING herd
4080
- // guard, so N concurrent degraded reads would otherwise each spawn a
4081
- // render exactly when the colo is already struggling. We still serve the
4082
- // stale data and still promote to L1; only the revalidation is withheld.
4083
3958
  const stale = now > envelope.s;
4084
- const shouldRevalidate = stale && !opts?.suppressRevalidate;
4085
-
4086
- // Promote to L1 in background
4087
- this.promoteSegmentToL1(key, envelope);
4088
-
4089
- if (this.debug)
4090
- this.emitDebug({
4091
- op: "get",
4092
- key,
4093
- outcome: !stale
4094
- ? "kv-fresh"
4095
- : opts?.suppressRevalidate
4096
- ? "kv-stale-suppressed"
4097
- : "kv-stale",
4098
- shouldRevalidate,
4099
- });
4100
- return { data: envelope.d, shouldRevalidate };
4101
- } catch (error) {
4102
- reportCacheError(error, "cache-read", "[CFCacheStore] kvGetSegment");
4103
- if (this.debug) this.emitDebug({ op: "get", key, outcome: "error" });
4104
- // A KV failure is NOT proof of absence: returning null classified it a
4105
- // real miss and let the PPR seeded fallback substitute the doc record
4106
- // for a key partition the store could not actually read. Same sentinel
4107
- // as get()'s own catch — lookupRouteDetailed classifies it `error` and
4108
- // the render stays uncached. (A kvGetOrEvict TIMEOUT above stays null
4109
- // by design: it is a bounded-latency degrade of a likely-healthy read,
4110
- // and serving the equivalent seeded record there is the fallback
4111
- // working as intended, not a masked failure.)
4112
- return CACHE_READ_ERROR;
4113
- }
4114
- }
4115
-
4116
- /**
4117
- * Write segment data to KV.
4118
- *
4119
- * `dataJson` is the already-serialized CachedEntryData body also stored in
4120
- * L1 — embedded as envelope.d without a second JSON.stringify walk.
4121
- * @internal
4122
- */
4123
- private kvSetSegment(
4124
- key: string,
4125
- dataJson: string,
4126
- staleAt: number,
4127
- totalTtl: number,
4128
- swrWindow: number,
4129
- ): void {
4130
- // KV requires expirationTtl >= 60s. Skip write for short-lived entries.
4131
- if (!this.kv || !this.waitUntil || totalTtl < 60) return;
4132
-
4133
- const expiresAt = staleAt + swrWindow * 1000;
4134
- // Same wire shape as JSON.stringify({ d, s, e }) — dataJson is already
4135
- // valid JSON for CachedEntryData, so embedding it avoids re-walking the tree.
4136
- const envelopeJson = `{"d":${dataJson},"s":${staleAt},"e":${expiresAt}}`;
4137
-
4138
- this.waitUntil(() =>
4139
- reportingAsync(
4140
- async () =>
4141
- this.kv!.put(await this.toKVKey(key, "data"), envelopeJson, {
4142
- expirationTtl: totalTtl,
4143
- }),
4144
- "cache-write",
4145
- "[CFCacheStore] kvSetSegment",
4146
- ),
4147
- );
4148
- }
4149
-
4150
- /**
4151
- * Promote segment data from KV to L1 Cache API.
4152
- * @internal
4153
- */
4154
- private promoteSegmentToL1(key: string, envelope: KVSegmentEnvelope): void {
4155
- if (!this.waitUntil) return;
4156
-
4157
- this.waitUntil(() =>
4158
- reportingAsync(
4159
- async () => {
4160
- const now = Date.now();
4161
- const remainingTtl = Math.max(
4162
- 1,
4163
- Math.floor((envelope.e - now) / 1000),
4164
- );
4165
- const cache = await this.getCache();
4166
- const request = this.keyToRequest(key, "data");
4167
-
4168
- const response = new Response(JSON.stringify(envelope.d), {
4169
- headers: {
4170
- "Content-Type": "application/json",
4171
- "Cache-Control": `public, max-age=${remainingTtl}`,
4172
- [CACHE_STALE_AT_HEADER]: String(envelope.s),
4173
- // Carry the hard-expiry deadline so a promoted entry that later
4174
- // goes stale re-puts with the correct remaining ttl (see set()).
4175
- [CACHE_EXPIRES_AT_HEADER]: String(envelope.e),
4176
- [CACHE_STATUS_HEADER]: "HIT",
4177
- // Preserve tags across KV->L1 promotion so the promoted entry
4178
- // stays tag-invalidatable.
4179
- ...this.tagHeaderEntries(envelope.d.tags, envelope.d.taggedAt),
4180
- },
4181
- });
4182
-
4183
- await cache.put(request, response);
4184
- },
4185
- "cache-write",
4186
- "[CFCacheStore] promoteSegmentToL1",
4187
- ),
4188
- );
4189
- }
4190
-
4191
- /**
4192
- * KV fallback for function cache reads.
4193
- * @internal
4194
- */
4195
- private async kvGetItem(
4196
- key: string,
4197
- opts?: { suppressRevalidate?: boolean },
4198
- ): Promise<CacheItemResult | null> {
4199
- if (!this.kv) return null;
4200
-
4201
- try {
4202
- const kvKey = await this.toKVKey(`fn:${key}`, "data");
4203
- const { value: envelope, timedOut } =
4204
- await this.kvGetOrEvict<KVItemEnvelope>(
4205
- kvKey,
4206
- (e) =>
4207
- typeof e.v === "string" &&
4208
- typeof e.e === "number" &&
4209
- typeof e.s === "number",
4210
- "kvGetItem",
3959
+ const shouldRevalidate = stale && !suppressRevalidate;
3960
+ let value: V;
3961
+ try {
3962
+ value = f.fromKv(envelope, shouldRevalidate);
3963
+ } catch (error) {
3964
+ // Only a document envelope can fail to rebuild (RESPONSE_READ).
3965
+ reportCacheError(
3966
+ error,
3967
+ "cache-corrupt",
3968
+ `[CFCacheStore] ${f.kvMethod}: corrupt response envelope, evicting`,
4211
3969
  );
4212
- if (timedOut) {
4213
- if (this.debug)
4214
- this.emitDebug({ op: "getItem", key, outcome: "kv-timeout" });
4215
- return null;
4216
- }
4217
- if (!envelope) {
4218
- if (this.debug)
4219
- this.emitDebug({ op: "getItem", key, outcome: "kv-miss" });
3970
+ this.scheduleKvEvict(kvKey, f.kvMethod);
4220
3971
  return null;
4221
3972
  }
4222
-
4223
- const now = Date.now();
4224
-
4225
- if (now > envelope.e) {
4226
- if (this.debug)
4227
- this.emitDebug({ op: "getItem", key, outcome: "kv-miss" });
4228
- return null;
4229
- }
4230
-
4231
- // Tag invalidation check (also covers the KV tier, not just L1).
4232
- if (await this.isGloballyInvalidated(envelope.t, envelope.ta)) {
4233
- if (this.debug)
4234
- this.emitDebug({ op: "getItem", key, outcome: "tag-invalidated" });
4235
- return null;
4236
- }
4237
-
4238
- // Degraded fall-through suppresses revalidation (no KV herd guard); see
4239
- // kvGetSegment. Still serves stale and still promotes.
4240
- const stale = now > envelope.s;
4241
- const shouldRevalidate = stale && !opts?.suppressRevalidate;
4242
-
4243
- // Promote to L1
4244
- this.promoteItemToL1(key, envelope);
4245
-
4246
- if (this.debug)
4247
- this.emitDebug({
4248
- op: "getItem",
4249
- key,
4250
- outcome: !stale
4251
- ? "kv-fresh"
4252
- : opts?.suppressRevalidate
4253
- ? "kv-stale-suppressed"
4254
- : "kv-stale",
4255
- shouldRevalidate,
4256
- });
4257
- return {
4258
- value: envelope.v,
4259
- handles: envelope.h,
3973
+ this.promoteToL1(key, f, envelope);
3974
+ debug?.({
3975
+ outcome: !stale
3976
+ ? "kv-fresh"
3977
+ : suppressRevalidate
3978
+ ? "kv-stale-suppressed"
3979
+ : "kv-stale",
4260
3980
  shouldRevalidate,
4261
- tags: envelope.t,
4262
- };
3981
+ });
3982
+ return value;
4263
3983
  } catch (error) {
4264
- reportCacheError(error, "cache-read", "[CFCacheStore] kvGetItem");
4265
- if (this.debug) this.emitDebug({ op: "getItem", key, outcome: "error" });
4266
- return null;
3984
+ // A failed KV read (5xx/429/network, left intact), or a promotion that
3985
+ // throws (ctx.waitUntil).
3986
+ reportCacheError(error, "cache-read", `[CFCacheStore] ${f.kvMethod}`);
3987
+ debug?.({ outcome: "error" });
3988
+ return f.readError;
4267
3989
  }
4268
3990
  }
4269
3991
 
4270
3992
  /**
4271
- * Promote function cache data from KV to L1.
3993
+ * Promote a KV hit to L1 in the background, under its remaining ttl, with
3994
+ * its deadlines (so a promoted entry that later goes stale re-puts with the
3995
+ * correct remaining ttl, see set()) and its tags (so it stays
3996
+ * tag-invalidatable). The family builds the entry (toL1).
4272
3997
  * @internal
4273
3998
  */
4274
- private promoteItemToL1(key: string, envelope: KVItemEnvelope): void {
3999
+ private promoteToL1<E extends KVDeadlines>(
4000
+ key: string,
4001
+ f: CFReadFamily<E, unknown, unknown, CacheReadError | null>,
4002
+ envelope: E,
4003
+ ): void {
4275
4004
  if (!this.waitUntil) return;
4276
4005
 
4277
4006
  this.waitUntil(() =>
@@ -4283,170 +4012,53 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
4283
4012
  Math.floor((envelope.e - now) / 1000),
4284
4013
  );
4285
4014
  const cache = await this.getCache();
4286
- const request = this.keyToRequest(`fn:${key}`, "data");
4287
-
4288
- const body = JSON.stringify({
4289
- value: envelope.v,
4290
- handles: envelope.h,
4291
- });
4292
- const response = new Response(body, {
4293
- headers: {
4294
- "Content-Type": "application/json",
4295
- "Cache-Control": `public, max-age=${remainingTtl}`,
4296
- [CACHE_STALE_AT_HEADER]: String(envelope.s),
4297
- // Carry the hard-expiry deadline; see promoteSegmentToL1 / set().
4298
- [CACHE_EXPIRES_AT_HEADER]: String(envelope.e),
4299
- [CACHE_STATUS_HEADER]: "HIT",
4300
- // Preserve tags across KV->L1 promotion (the item tier previously
4301
- // dropped them, permanently disabling tag invalidation here).
4302
- ...this.tagHeaderEntries(envelope.t, envelope.ta),
4303
- },
4304
- });
4305
-
4306
- await cache.put(request, response);
4015
+ const request = this.keyToRequest(f.prefix + key, f.version);
4016
+ const { tags, taggedAt } = f.tags(envelope);
4017
+ await cache.put(
4018
+ request,
4019
+ f.toL1(
4020
+ envelope,
4021
+ `public, max-age=${remainingTtl}`,
4022
+ this.tagHeaderEntries(tags, taggedAt),
4023
+ ),
4024
+ );
4307
4025
  },
4308
4026
  "cache-write",
4309
- "[CFCacheStore] promoteItemToL1",
4027
+ f.promoteLabel,
4310
4028
  ),
4311
4029
  );
4312
4030
  }
4313
4031
 
4314
4032
  /**
4315
- * KV fallback for document cache reads.
4033
+ * Write segment data to KV.
4034
+ *
4035
+ * `dataJson` is the already-serialized CachedEntryData body also stored in
4036
+ * L1 — embedded as envelope.d without a second JSON.stringify walk.
4316
4037
  * @internal
4317
4038
  */
4318
- private async kvGetResponse(
4039
+ private kvSetSegment(
4319
4040
  key: string,
4320
- ): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
4321
- if (!this.kv) return null;
4322
-
4323
- try {
4324
- const kvKey = await this.toDocKVKey(key);
4325
- // The document path is debug-silent (op is only get/getItem): a KV-read
4326
- // timeout here is bounded for resilience parity (kvGetOrEvict applies the
4327
- // budget) but emits no kv-timeout event, so its absence from the debug
4328
- // stream is expected. A null envelope is a miss -- missing key, a budget
4329
- // timeout, or a corrupt entry already evicted + reported by kvGetOrEvict.
4330
- const { value: envelope } = await this.kvGetOrEvict<KVResponseEnvelope>(
4331
- kvKey,
4332
- (e) =>
4333
- typeof e.b === "string" &&
4334
- typeof e.st === "number" &&
4335
- typeof e.e === "number" &&
4336
- typeof e.s === "number" &&
4337
- // stx is optional but, if present, must be a string (feeds Response).
4338
- (e.stx === undefined || typeof e.stx === "string") &&
4339
- // hd must be an array of [name, value] string tuples; a malformed
4340
- // shape would otherwise throw in `new Headers(hd)`. Validate it here
4341
- // so a faulty envelope is a fail-open MISS, never a thrown read.
4342
- Array.isArray(e.hd) &&
4343
- e.hd.every(
4344
- (entry) =>
4345
- Array.isArray(entry) &&
4346
- entry.length === 2 &&
4347
- typeof entry[0] === "string" &&
4348
- typeof entry[1] === "string",
4349
- ),
4350
- "kvGetResponse",
4351
- );
4352
- if (!envelope) return null;
4353
-
4354
- const now = Date.now();
4355
-
4356
- if (now > envelope.e) return null;
4357
-
4358
- // Tag invalidation check (also covers the KV tier, not just L1).
4359
- if (await this.isGloballyInvalidated(envelope.t, envelope.ta)) {
4360
- return null;
4361
- }
4362
-
4363
- const shouldRevalidate = now > envelope.s;
4364
-
4365
- // Reconstruct Response: decode base64 -> binary, rebuild headers/status.
4366
- // Corrupt/partial base64 throws in atob; malformed `hd` or an out-of-range
4367
- // `st` throws in new Headers/new Response. Any of these is a faulty entry,
4368
- // so evict it and miss rather than re-failing every read until TTL.
4369
- let response: Response;
4370
- try {
4371
- // Finding #3 (read side): strip per-client signals a stale envelope may
4372
- // carry. Inside the try so a malformed `hd` evicts (not throws through);
4373
- // mutates `hd` in place so promoteResponseToL1 re-seeds from it too.
4374
- envelope.hd = envelope.hd.filter(
4375
- ([name]) => !isPerClientSignalHeader(name),
4376
- );
4377
- const bodyBuffer = base64ToBuffer(envelope.b);
4378
- const headers = new Headers(envelope.hd);
4379
- response = new Response(bodyBuffer, {
4380
- status: envelope.st,
4381
- statusText: envelope.stx,
4382
- headers,
4383
- });
4384
- } catch (error) {
4385
- reportCacheError(
4386
- error,
4387
- "cache-corrupt",
4388
- "[CFCacheStore] kvGetResponse: corrupt response envelope, evicting",
4389
- );
4390
- this.scheduleKvEvict(kvKey, "kvGetResponse");
4391
- return null;
4392
- }
4393
-
4394
- // Promote to L1
4395
- this.promoteResponseToL1(key, envelope);
4396
-
4397
- return { response, shouldRevalidate };
4398
- } catch (error) {
4399
- reportCacheError(error, "cache-read", "[CFCacheStore] kvGetResponse");
4400
- return null;
4401
- }
4402
- }
4041
+ dataJson: string,
4042
+ staleAt: number,
4043
+ totalTtl: number,
4044
+ swrWindow: number,
4045
+ ): void {
4046
+ // KV requires expirationTtl >= 60s. Skip write for short-lived entries.
4047
+ if (!this.kv || !this.waitUntil || totalTtl < 60) return;
4403
4048
 
4404
- /**
4405
- * Promote document cache data from KV to L1.
4406
- * @internal
4407
- */
4408
- private promoteResponseToL1(key: string, envelope: KVResponseEnvelope): void {
4409
- if (!this.waitUntil) return;
4049
+ const expiresAt = staleAt + swrWindow * 1000;
4050
+ // Same wire shape as JSON.stringify({ d, s, e }) — dataJson is already
4051
+ // valid JSON for CachedEntryData, so embedding it avoids re-walking the tree.
4052
+ const envelopeJson = `{"d":${dataJson},"s":${staleAt},"e":${expiresAt}}`;
4410
4053
 
4411
4054
  this.waitUntil(() =>
4412
4055
  reportingAsync(
4413
- async () => {
4414
- const now = Date.now();
4415
- const remainingTtl = Math.max(
4416
- 1,
4417
- Math.floor((envelope.e - now) / 1000),
4418
- );
4419
- const cache = await this.getCache();
4420
- const request = this.keyToRequest(`doc:${key}`, "document");
4421
-
4422
- const headers = new Headers(envelope.hd);
4423
- const originalCacheControl = headers.get("Cache-Control");
4424
- if (originalCacheControl !== null) {
4425
- headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
4426
- }
4427
- headers.set("Cache-Control", `public, max-age=${remainingTtl}`);
4428
- headers.set(CACHE_STALE_AT_HEADER, String(envelope.s));
4429
- // Carry the hard-expiry deadline so the document herd guard's
4430
- // markResponseRevalidating re-put can compute the remaining window
4431
- // (matches promoteSegmentToL1/promoteItemToL1); without it a stale
4432
- // re-put would floor to max-age=1 and churn the KV-promoted twin.
4433
- headers.set(CACHE_EXPIRES_AT_HEADER, String(envelope.e));
4434
- // Re-attach the internal tag headers (envelope.hd is client-facing
4435
- // and intentionally excludes them) so the promoted entry stays
4436
- // invalidatable.
4437
- this.setTagHeaders(headers, envelope.t, envelope.ta);
4438
-
4439
- const bodyBuffer = base64ToBuffer(envelope.b);
4440
- const response = new Response(bodyBuffer, {
4441
- status: envelope.st,
4442
- statusText: envelope.stx,
4443
- headers,
4444
- });
4445
-
4446
- await cache.put(request, response);
4447
- },
4056
+ async () =>
4057
+ this.kv!.put(await this.toKVKey(key, "data"), envelopeJson, {
4058
+ expirationTtl: totalTtl,
4059
+ }),
4448
4060
  "cache-write",
4449
- "[CFCacheStore] promoteResponseToL1",
4061
+ "[CFCacheStore] kvSetSegment",
4450
4062
  ),
4451
4063
  );
4452
4064
  }