@rangojs/router 0.17.0 → 0.19.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 (293) hide show
  1. package/README.md +3 -2
  2. package/dist/types/browser/cookie-name.d.ts +6 -0
  3. package/dist/types/browser/event-controller.d.ts +3 -3
  4. package/dist/types/browser/history-state.d.ts +18 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +20 -2
  6. package/dist/types/browser/partial-update.d.ts +16 -1
  7. package/dist/types/browser/prefetch/cache.d.ts +6 -6
  8. package/dist/types/browser/react/Link.d.ts +19 -9
  9. package/dist/types/browser/react/context.d.ts +2 -2
  10. package/dist/types/browser/react/location-state-shared.d.ts +95 -56
  11. package/dist/types/browser/react/location-state.d.ts +1 -1
  12. package/dist/types/browser/transition-when.d.ts +80 -0
  13. package/dist/types/browser/types.d.ts +104 -9
  14. package/dist/types/build/generate-manifest.d.ts +8 -0
  15. package/dist/types/cache/background-task.d.ts +24 -2
  16. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  17. package/dist/types/cache/cache-key-utils.d.ts +48 -2
  18. package/dist/types/cache/cache-policy.d.ts +18 -1
  19. package/dist/types/cache/cache-runtime.d.ts +7 -0
  20. package/dist/types/cache/cache-scope.d.ts +192 -26
  21. package/dist/types/cache/cache-tag.d.ts +116 -11
  22. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  23. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  24. package/dist/types/cache/cf/cf-cache-store.d.ts +224 -72
  25. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  26. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  27. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +19 -0
  28. package/dist/types/cache/document-cache.d.ts +10 -0
  29. package/dist/types/cache/handle-snapshot.d.ts +41 -8
  30. package/dist/types/cache/index.d.ts +1 -0
  31. package/dist/types/cache/invalidation-order.d.ts +23 -0
  32. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  33. package/dist/types/cache/request-tag-mask.d.ts +54 -0
  34. package/dist/types/cache/shell-memo.d.ts +178 -0
  35. package/dist/types/cache/shell-snapshot.d.ts +141 -105
  36. package/dist/types/cache/tag-invalidation.d.ts +54 -3
  37. package/dist/types/cache/types.d.ts +267 -96
  38. package/dist/types/cache/vercel/vercel-cache-store.d.ts +123 -18
  39. package/dist/types/client-urls/navigation.d.ts +28 -2
  40. package/dist/types/client-urls/server-projection.d.ts +5 -2
  41. package/dist/types/client-urls/types.d.ts +5 -5
  42. package/dist/types/client.d.ts +1 -1
  43. package/dist/types/index.d.ts +1 -1
  44. package/dist/types/index.rsc.d.ts +1 -1
  45. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  46. package/dist/types/route-definition/helpers-types.d.ts +11 -11
  47. package/dist/types/route-name.d.ts +7 -0
  48. package/dist/types/router/error-handling.d.ts +1 -1
  49. package/dist/types/router/instrument.d.ts +8 -0
  50. package/dist/types/router/intercept-resolution.d.ts +2 -0
  51. package/dist/types/router/match-context.d.ts +5 -0
  52. package/dist/types/router/match-middleware/background-revalidation.d.ts +0 -4
  53. package/dist/types/router/middleware-types.d.ts +6 -1
  54. package/dist/types/router/router-options.d.ts +4 -1
  55. package/dist/types/router/segment-resolution/loader-cache.d.ts +58 -12
  56. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
  57. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +33 -48
  58. package/dist/types/router/segment-resolution/mask-nested.d.ts +73 -24
  59. package/dist/types/router/segment-resolution/view-transition-default.d.ts +7 -5
  60. package/dist/types/router/transition-when.d.ts +24 -10
  61. package/dist/types/rsc/attach-transition-when.d.ts +18 -0
  62. package/dist/types/rsc/full-payload.d.ts +21 -0
  63. package/dist/types/rsc/handler.d.ts +1 -1
  64. package/dist/types/rsc/response-cache-serve.d.ts +15 -12
  65. package/dist/types/rsc/shell-build-manifest.d.ts +12 -3
  66. package/dist/types/rsc/shell-capture-constants.d.ts +116 -12
  67. package/dist/types/rsc/shell-capture.d.ts +202 -74
  68. package/dist/types/rsc/shell-serve.d.ts +144 -50
  69. package/dist/types/rsc/types.d.ts +9 -0
  70. package/dist/types/segment-system.d.ts +7 -0
  71. package/dist/types/server/context.d.ts +155 -33
  72. package/dist/types/server/cookie-store.d.ts +33 -0
  73. package/dist/types/server/handle-store.d.ts +52 -1
  74. package/dist/types/server/request-context.d.ts +210 -63
  75. package/dist/types/server/request-headers.d.ts +44 -0
  76. package/dist/types/server.d.ts +1 -0
  77. package/dist/types/ssr/index.d.ts +9 -0
  78. package/dist/types/ssr/ssr-root.d.ts +9 -8
  79. package/dist/types/testing/e2e/index.d.ts +1 -1
  80. package/dist/types/testing/flight.entry.d.ts +7 -0
  81. package/dist/types/testing/index.d.ts +3 -2
  82. package/dist/types/testing/internal/action-id.d.ts +7 -0
  83. package/dist/types/testing/location-state-key.d.ts +24 -0
  84. package/dist/types/testing/render-route.d.ts +17 -1
  85. package/dist/types/testing/run-loader.d.ts +18 -3
  86. package/dist/types/testing/run-transition-when.d.ts +44 -69
  87. package/dist/types/testing/serve-shell-request.d.ts +133 -0
  88. package/dist/types/testing/shell-status.d.ts +44 -19
  89. package/dist/types/transition-when-ref.d.ts +79 -0
  90. package/dist/types/types/cache-types.d.ts +15 -3
  91. package/dist/types/types/handler-context.d.ts +14 -1
  92. package/dist/types/types/index.d.ts +1 -1
  93. package/dist/types/types/segments.d.ts +86 -71
  94. package/dist/types/urls/path-helper-types.d.ts +3 -4
  95. package/dist/types/urls/pattern-types.d.ts +21 -18
  96. package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
  97. package/dist/types/vite/router-discovery.d.ts +29 -2
  98. package/dist/vite/index.js +1005 -105
  99. package/package.json +1 -1
  100. package/skills/breadcrumbs/SKILL.md +17 -7
  101. package/skills/cache-guide/SKILL.md +175 -76
  102. package/skills/caching/SKILL.md +361 -33
  103. package/skills/catalog.json +1 -1
  104. package/skills/client-urls/SKILL.md +12 -7
  105. package/skills/cloudflare/SKILL.md +15 -0
  106. package/skills/comparison/references/framework-comparison.md +52 -37
  107. package/skills/defer-hydration/SKILL.md +50 -30
  108. package/skills/deployment-caching/SKILL.md +5 -2
  109. package/skills/document-cache/SKILL.md +32 -2
  110. package/skills/handler-use/SKILL.md +2 -0
  111. package/skills/hooks/SKILL.md +3 -1
  112. package/skills/hooks/handle-and-actions.md +3 -1
  113. package/skills/hooks/navigation.md +23 -2
  114. package/skills/hooks/state.md +101 -7
  115. package/skills/intercept/SKILL.md +29 -12
  116. package/skills/loader/SKILL.md +197 -44
  117. package/skills/middleware/SKILL.md +34 -9
  118. package/skills/migrate-nextjs/SKILL.md +68 -37
  119. package/skills/migrate-react-router/SKILL.md +2 -1
  120. package/skills/migrate-react-router/component-migration.md +3 -1
  121. package/skills/migrate-react-router/data-and-actions.md +3 -1
  122. package/skills/migrate-react-router/route-mapping.md +7 -4
  123. package/skills/observability/SKILL.md +65 -0
  124. package/skills/parallel/SKILL.md +13 -8
  125. package/skills/ppr/SKILL.md +715 -371
  126. package/skills/prerender/SKILL.md +30 -17
  127. package/skills/rango/SKILL.md +72 -34
  128. package/skills/route/SKILL.md +14 -7
  129. package/skills/router-setup/SKILL.md +4 -2
  130. package/skills/scripts/SKILL.md +8 -1
  131. package/skills/server-actions/SKILL.md +11 -0
  132. package/skills/shell-manifest/SKILL.md +21 -12
  133. package/skills/testing/SKILL.md +49 -43
  134. package/skills/testing/cache-prerender.md +15 -14
  135. package/skills/testing/client-components.md +45 -3
  136. package/skills/testing/loader.md +19 -1
  137. package/skills/testing/middleware.md +1 -0
  138. package/skills/testing/predicates.md +49 -49
  139. package/skills/testing/render-handler.md +1 -0
  140. package/skills/testing/serve-shell-request.md +202 -0
  141. package/skills/testing/server-actions.md +9 -3
  142. package/skills/theme/SKILL.md +56 -4
  143. package/skills/typesafety/env-and-bindings.md +18 -2
  144. package/skills/use-cache/SKILL.md +91 -23
  145. package/skills/vercel/SKILL.md +14 -2
  146. package/skills/view-transitions/SKILL.md +150 -29
  147. package/src/__augment-tests__/augmented.check.ts +23 -0
  148. package/src/browser/cookie-name.ts +23 -0
  149. package/src/browser/event-controller.ts +4 -4
  150. package/src/browser/history-state.ts +75 -1
  151. package/src/browser/link-interceptor.ts +4 -0
  152. package/src/browser/navigation-bridge.ts +77 -18
  153. package/src/browser/navigation-client.ts +4 -3
  154. package/src/browser/navigation-store.ts +43 -1
  155. package/src/browser/navigation-transaction.ts +31 -11
  156. package/src/browser/partial-update.ts +146 -35
  157. package/src/browser/prefetch/cache.ts +6 -6
  158. package/src/browser/prefetch/fetch.ts +2 -2
  159. package/src/browser/react/Link.tsx +38 -33
  160. package/src/browser/react/NavigationProvider.tsx +2 -2
  161. package/src/browser/react/context.ts +2 -2
  162. package/src/browser/react/location-state-shared.ts +241 -49
  163. package/src/browser/react/location-state.ts +5 -2
  164. package/src/browser/rsc-router.tsx +9 -0
  165. package/src/browser/server-action-bridge.ts +84 -15
  166. package/src/browser/transition-when.ts +205 -0
  167. package/src/browser/types.ts +124 -10
  168. package/src/build/generate-manifest.ts +77 -1
  169. package/src/cache/background-task.ts +55 -2
  170. package/src/cache/cache-exec-scope.ts +48 -1
  171. package/src/cache/cache-key-utils.ts +62 -2
  172. package/src/cache/cache-policy.ts +31 -4
  173. package/src/cache/cache-runtime.ts +209 -129
  174. package/src/cache/cache-scope.ts +573 -69
  175. package/src/cache/cache-tag.ts +475 -19
  176. package/src/cache/cf/cf-base64.ts +36 -4
  177. package/src/cache/cf/cf-cache-constants.ts +12 -0
  178. package/src/cache/cf/cf-cache-store.ts +1094 -376
  179. package/src/cache/cf/cf-cache-types.ts +32 -4
  180. package/src/cache/cf/cf-shell-frame.ts +258 -0
  181. package/src/cache/cf/cf-tag-marker-memo.ts +55 -40
  182. package/src/cache/document-cache.ts +53 -7
  183. package/src/cache/handle-snapshot.ts +53 -9
  184. package/src/cache/index.ts +2 -0
  185. package/src/cache/invalidation-order.ts +84 -0
  186. package/src/cache/isolate-tag-memo.ts +308 -0
  187. package/src/cache/request-tag-mask.ts +147 -0
  188. package/src/cache/shell-memo.ts +341 -0
  189. package/src/cache/shell-snapshot.ts +249 -277
  190. package/src/cache/tag-invalidation.ts +109 -18
  191. package/src/cache/types.ts +274 -102
  192. package/src/cache/vercel/vercel-cache-store.ts +561 -74
  193. package/src/client-urls/client-root.tsx +8 -4
  194. package/src/client-urls/client-urls.ts +8 -6
  195. package/src/client-urls/navigation.ts +55 -4
  196. package/src/client-urls/server-projection.ts +31 -10
  197. package/src/client-urls/types.ts +12 -5
  198. package/src/client.tsx +2 -0
  199. package/src/host/cookie-handler.ts +2 -1
  200. package/src/index.rsc.ts +3 -0
  201. package/src/index.ts +3 -0
  202. package/src/loader-redirect.tsx +7 -3
  203. package/src/prerender/build-shell-capture.ts +173 -110
  204. package/src/route-definition/dsl-helpers.ts +6 -3
  205. package/src/route-definition/helpers-types.ts +11 -11
  206. package/src/route-name.ts +12 -0
  207. package/src/router/content-negotiation.ts +4 -1
  208. package/src/router/error-handling.ts +5 -2
  209. package/src/router/handler-context.ts +42 -5
  210. package/src/router/instrument.ts +54 -2
  211. package/src/router/intercept-resolution.ts +30 -8
  212. package/src/router/loader-resolution.ts +89 -33
  213. package/src/router/logging.ts +5 -3
  214. package/src/router/match-api.ts +89 -42
  215. package/src/router/match-context.ts +5 -0
  216. package/src/router/match-handlers.ts +14 -29
  217. package/src/router/match-middleware/background-revalidation.ts +3 -5
  218. package/src/router/match-middleware/cache-lookup.ts +52 -19
  219. package/src/router/match-middleware/cache-store.ts +36 -10
  220. package/src/router/match-result.ts +1 -0
  221. package/src/router/middleware-types.ts +6 -1
  222. package/src/router/middleware.ts +20 -4
  223. package/src/router/navigation-snapshot.ts +6 -5
  224. package/src/router/prerender-match.ts +10 -0
  225. package/src/router/request-classification.ts +7 -4
  226. package/src/router/router-options.ts +4 -1
  227. package/src/router/segment-resolution/fresh.ts +18 -13
  228. package/src/router/segment-resolution/helpers.ts +4 -2
  229. package/src/router/segment-resolution/loader-cache.ts +366 -70
  230. package/src/router/segment-resolution/loader-mask.ts +3 -4
  231. package/src/router/segment-resolution/loader-snapshot.ts +215 -126
  232. package/src/router/segment-resolution/mask-nested.ts +161 -42
  233. package/src/router/segment-resolution/revalidation.ts +27 -13
  234. package/src/router/segment-resolution/view-transition-default.ts +6 -21
  235. package/src/router/telemetry.ts +6 -3
  236. package/src/router/transition-when.ts +34 -52
  237. package/src/router.ts +2 -1
  238. package/src/rsc/attach-transition-when.ts +47 -0
  239. package/src/rsc/full-payload.ts +47 -3
  240. package/src/rsc/handler.ts +24 -10
  241. package/src/rsc/loader-fetch.ts +2 -1
  242. package/src/rsc/manifest-init.ts +4 -0
  243. package/src/rsc/origin-guard.ts +8 -5
  244. package/src/rsc/progressive-enhancement.ts +10 -32
  245. package/src/rsc/response-cache-serve.ts +69 -78
  246. package/src/rsc/response-route-handler.ts +2 -2
  247. package/src/rsc/rsc-rendering.ts +712 -338
  248. package/src/rsc/server-action.ts +8 -20
  249. package/src/rsc/shell-build-manifest.ts +49 -29
  250. package/src/rsc/shell-capture-constants.ts +182 -12
  251. package/src/rsc/shell-capture.ts +1126 -612
  252. package/src/rsc/shell-serve.ts +331 -85
  253. package/src/rsc/ssr-setup.ts +3 -2
  254. package/src/rsc/types.ts +9 -0
  255. package/src/segment-system.tsx +40 -7
  256. package/src/server/context.ts +328 -72
  257. package/src/server/cookie-store.ts +184 -133
  258. package/src/server/handle-store.ts +245 -27
  259. package/src/server/request-context.ts +364 -100
  260. package/src/server/request-headers.ts +134 -0
  261. package/src/server.ts +4 -0
  262. package/src/ssr/index.tsx +96 -37
  263. package/src/ssr/ssr-root.tsx +9 -8
  264. package/src/testing/dispatch.ts +4 -7
  265. package/src/testing/e2e/index.ts +1 -0
  266. package/src/testing/flight.entry.ts +14 -0
  267. package/src/testing/index.ts +5 -0
  268. package/src/testing/internal/action-id.ts +25 -0
  269. package/src/testing/internal/context.ts +1 -1
  270. package/src/testing/location-state-key.ts +45 -0
  271. package/src/testing/render-handler.ts +1 -1
  272. package/src/testing/render-route.tsx +109 -16
  273. package/src/testing/run-client-revalidate.ts +3 -19
  274. package/src/testing/run-loader.ts +67 -5
  275. package/src/testing/run-transition-when.ts +109 -172
  276. package/src/testing/serve-shell-request.ts +381 -0
  277. package/src/testing/shell-status.ts +73 -47
  278. package/src/theme/ThemeProvider.tsx +7 -6
  279. package/src/transition-when-ref.ts +220 -0
  280. package/src/types/cache-types.ts +15 -3
  281. package/src/types/handler-context.ts +14 -1
  282. package/src/types/index.ts +3 -0
  283. package/src/types/segments.ts +94 -91
  284. package/src/urls/path-helper-types.ts +3 -4
  285. package/src/urls/pattern-types.ts +21 -18
  286. package/src/vite/discovery/discover-routers.ts +3 -0
  287. package/src/vite/discovery/shell-prerender-phase.ts +9 -2
  288. package/src/vite/discovery/virtual-module-codegen.ts +16 -6
  289. package/src/vite/plugins/transition-when-hoist.ts +1064 -0
  290. package/src/vite/rango.ts +2 -0
  291. package/src/vite/router-discovery.ts +89 -9
  292. package/dist/types/rsc/transition-gate.d.ts +0 -23
  293. package/src/rsc/transition-gate.ts +0 -86
@@ -27,6 +27,7 @@ declare global {
27
27
  * - KV L2 for cross-colo cache persistence
28
28
  */
29
29
 
30
+ import { requestHeaders } from "../../server/request-headers.js";
30
31
  import type {
31
32
  SegmentCacheStore,
32
33
  CachedEntryData,
@@ -35,6 +36,12 @@ import type {
35
36
  CacheItemResult,
36
37
  CacheItemOptions,
37
38
  ShellCacheEntry,
39
+ ShellDocumentRead,
40
+ ShellDocumentReadOptions,
41
+ ShellEntryHead,
42
+ ShellReadStats,
43
+ ShellSnapshotFailure,
44
+ ShellSnapshotRecord,
38
45
  CacheReadError,
39
46
  } from "../types.js";
40
47
  import { CACHE_READ_ERROR } from "../types.js";
@@ -55,7 +62,30 @@ import {
55
62
  } from "../cache-policy.js";
56
63
  import { reportCacheError, reportingAsync } from "../cache-error.js";
57
64
  import type { CacheErrorCategory } from "../cache-error.js";
58
- import { bufferToBase64, base64ToBuffer } from "./cf-base64.js";
65
+ import {
66
+ bufferToBase64,
67
+ base64ToBuffer,
68
+ base64ToBytes,
69
+ bytesToBase64,
70
+ } from "./cf-base64.js";
71
+ import {
72
+ ShellFrameReader,
73
+ encodeShellFrame,
74
+ encodeShellSnapshot,
75
+ parseShellSnapshot,
76
+ shellFrameToText,
77
+ type ShellFrameHead,
78
+ } from "./cf-shell-frame.js";
79
+ import {
80
+ ShellMemo,
81
+ RecentTagInvalidations,
82
+ DEFAULT_SHELL_MEMO_MS,
83
+ freshReadsWindowMs,
84
+ isShellFresh,
85
+ resolveShellMemoOptions,
86
+ shellHasAnyTag,
87
+ type ResolvedShellMemoOptions,
88
+ } from "../shell-memo.js";
59
89
  import {
60
90
  KV_KEY_PRESERVED_PREFIX_BYTES,
61
91
  KV_MAX_KEY_BYTES,
@@ -70,7 +100,22 @@ import {
70
100
  TAG_MARKER_ABSENT,
71
101
  getTagMarkerMemo,
72
102
  getTagMarkerInflight,
103
+ getShellMarkerReads,
73
104
  } from "./cf-tag-marker-memo.js";
105
+ import {
106
+ gateMarkerRead,
107
+ maskRequestTags,
108
+ maskedForRequest,
109
+ } from "../request-tag-mask.js";
110
+ import {
111
+ TagMarkerMemo,
112
+ TagNameHints,
113
+ hintedTags,
114
+ freshReadsRequired,
115
+ recordMarkerRow,
116
+ DEFAULT_CF_MARKER_FRESH_MS,
117
+ DEFAULT_CF_MARKER_MAX_STALE_MS,
118
+ } from "../isolate-tag-memo.js";
74
119
  import { createCloudflareZonePurge } from "./cf-zone-purge.js";
75
120
 
76
121
  // ============================================================================
@@ -94,6 +139,7 @@ import {
94
139
  EDGE_LOOKUP_TIMEOUT_MS,
95
140
  EDGE_READ_TIMEOUT_MS,
96
141
  KV_READ_TIMEOUT_MS,
142
+ SHELL_SNAPSHOT_READ_MIN_TIMEOUT_MS,
97
143
  } from "./cf-cache-constants.js";
98
144
 
99
145
  // Re-export the public constants so consumers/tests importing them from
@@ -267,57 +313,119 @@ interface KVItemEnvelope {
267
313
  }
268
314
 
269
315
  /**
270
- * Coupled Cache API/KV envelope for PPR shell cache entries.
271
- * @internal
316
+ * Key namespace of the shell family. `shell2:` holds the prelude-first frame
317
+ * (cf-shell-frame.ts); `shell:` held the JSON envelope. Every deploy retires
318
+ * the old entries anyway (buildVersion gate); the new namespace also keeps
319
+ * new code from reporting an old body as corrupt and a rollback from parsing
320
+ * a frame. Old keys age out by their TTL.
272
321
  */
273
- interface CFShellEnvelope {
274
- /**
275
- * base64-encoded prelude bytes. Absent iff `no` (navigationOnly entries
276
- * store no document half — ShellCacheEntry.prelude).
277
- */
278
- p?: string;
279
- /** postponed state JSON, or null (DATA variant — no holes). Absent iff `no`. */
280
- po?: string | null;
281
- /** React.version captured at prerender time */
282
- rv: string;
283
- /** Build version captured at prerender time (ShellCacheEntry.buildVersion) */
284
- bv?: string;
285
- /** Capture-generation start time (ms epoch), used by tag marker checks. */
286
- c: number;
287
- /** When entry becomes stale (ms epoch) */
288
- s: number;
289
- /** When entry hard-expires (ms epoch) */
290
- e: number;
291
- /** Cache tags (for distributed tag invalidation) */
292
- t?: string[];
293
- /** Timestamp when tags were attached (ms epoch) */
294
- ta?: number;
295
- /** initialTheme the capture render was built with (resume theme fidelity) */
296
- i?: string;
297
- /** Capture data snapshot: recorded cache-store hits/writes for HIT parity */
298
- sn?: import("../types.js").ShellSnapshotRecord[];
299
- /**
300
- * ShellCacheEntry.docKey. Must round-trip: navigation-replay eligibility
301
- * requires the exact canonical doc segment record named here — dropping the
302
- * field reads back as "no consumable record" and every partial navigation
303
- * reports `no-segment-snapshot` after a KV round trip (the memory store
304
- * passes the entry by reference, so only envelope stores can lose it).
305
- */
306
- dk?: string;
307
- /**
308
- * ShellCacheEntry.handlerLiveHoles. Must round-trip: the serve side arms the
309
- * handler-free fast path on `!entry.handlerLiveHoles`, so dropping the flag
310
- * here silently fast-pathed handler-live entries after a KV round trip —
311
- * their holes only a handler re-run can fill.
312
- */
313
- lh?: boolean;
314
- /** ShellCacheEntry.transitionWhen; conditional transitions must re-run. */
315
- tw?: true;
316
- /** ShellCacheEntry.navigationOnly; its partial-context prelude is not document-safe. */
317
- no?: true;
322
+ const SHELL_KEY_PREFIX = "shell2:";
323
+
324
+ /** A shell snapshot read's timeout, and its warning's consequence text. */
325
+ interface SnapshotReadBudget {
326
+ ms: number;
327
+ consequence?: string;
328
+ }
329
+
330
+ /** A memoized fresh shell read (shell-memo.ts). */
331
+ interface CFShellMemoValue {
332
+ head: ShellFrameHead;
333
+ prelude: Uint8Array;
334
+ snapshot: ShellSnapshotRecord[] | undefined;
335
+ /** L1 response headers: the KV-less purge-mode marker check reads them. */
336
+ headers?: Headers;
337
+ }
338
+
339
+ /** The per-isolate shell memo shared by every CFCacheStore in the isolate. */
340
+ const cfShellMemo = new ShellMemo<CFShellMemoValue>();
341
+ /** Per-isolate tag-marker values (isolate-tag-memo.ts), keyed per namespace. */
342
+ const cfMarkerMemo = new TagMarkerMemo();
343
+ /** Per-isolate shell-key -> tag-name hints for the marker prefetch. */
344
+ const cfTagHints = new TagNameHints();
345
+
346
+ /** Tags this isolate invalidated recently: they keep shells out of cfShellMemo. */
347
+ const recentShellInvalidations = new RecentTagInvalidations();
348
+
349
+ /**
350
+ * Whether cfShellMemo may keep or serve this shell: still fresh (a stale one
351
+ * stays with the store read, which schedules the recapture), and not tagged
352
+ * at or before an invalidation this isolate is running or just ran.
353
+ */
354
+ function isMemoizableShell(head: ShellFrameHead): boolean {
355
+ return (
356
+ isShellFresh(head.s, head.e) &&
357
+ !recentShellInvalidations.covers(head.t, head.ta)
358
+ );
359
+ }
360
+
361
+ /**
362
+ * @internal Reset the per-isolate shell and marker memos and the once-per-
363
+ * isolate warnings (tests).
364
+ */
365
+ export function resetCFShellMemoForTests(): void {
366
+ cfShellMemo.clear();
367
+ recentShellInvalidations.clear();
368
+ cfMarkerMemo.clear();
369
+ cfTagHints.clear();
370
+ warnedNoKvReadInvalidation.clear();
371
+ warnedTagInvalidationTtlFloor.clear();
372
+ warnedShellTagsNoEviction.clear();
373
+ warnedNoTagInvalidationTtl.clear();
374
+ warnedCacheTagHeaderOverflow.clear();
375
+ warnedCacheTagOverflowUncacheable.clear();
376
+ }
377
+
378
+ /** openShellFrame outcome: the head and prelude, or why the read failed. */
379
+ type OpenedShellFrame =
380
+ | {
381
+ status: "ok";
382
+ head: ShellFrameHead;
383
+ prelude: Uint8Array;
384
+ reader: ShellFrameReader;
385
+ }
386
+ | { status: "corrupt"; error: Error; head?: undefined }
387
+ | { status: "expired"; head: ShellFrameHead }
388
+ | { status: "timeout"; head?: undefined }
389
+ | { status: "invalidated"; head?: undefined };
390
+
391
+ /** The `[CFCacheStore][shell]` trace's timings, from a read's stats. */
392
+ function debugTimings(
393
+ stats: ShellReadStats | undefined,
394
+ ): Pick<CFShellDebugDetails, "matchMs" | "readMs" | "bodyReadMs" | "markerMs"> {
395
+ if (!stats) return {};
396
+ if (stats.tier === "memo")
397
+ return { markerMs: Math.round(stats.markerMs ?? 0) };
398
+ const round = (ms: number | undefined) =>
399
+ ms === undefined ? undefined : Math.round(ms);
400
+ const bodyReadMs =
401
+ stats.headMs === undefined && stats.preludeMs === undefined
402
+ ? undefined
403
+ : (stats.headMs ?? 0) + (stats.preludeMs ?? 0);
404
+ return {
405
+ ...(stats.tier === "kv"
406
+ ? { readMs: round(stats.matchMs) }
407
+ : { matchMs: round(stats.matchMs) }),
408
+ bodyReadMs: round(bodyReadMs),
409
+ markerMs: round(stats.markerMs),
410
+ };
411
+ }
412
+
413
+ /** The public entry shape of a frame head (no prelude, no snapshot). */
414
+ function shellHeadToEntry(head: ShellFrameHead): ShellEntryHead {
415
+ return {
416
+ ...(head.po !== undefined ? { postponed: head.po } : {}),
417
+ reactVersion: head.rv,
418
+ buildVersion: head.bv,
419
+ initialTheme: head.i,
420
+ docKey: head.dk,
421
+ prunedRecords: head.pr,
422
+ navigationOnly: head.no,
423
+ createdAt: head.c,
424
+ };
318
425
  }
319
426
 
320
427
  type CFShellDebugOutcome =
428
+ | "memo-hit"
321
429
  | "l1-hit"
322
430
  | "l1-miss"
323
431
  | "kv-hit"
@@ -329,13 +437,12 @@ type CFShellDebugOutcome =
329
437
  | "write-invalidated";
330
438
 
331
439
  interface CFShellDebugDetails {
332
- tier?: "l1" | "kv";
440
+ tier?: "memo" | "l1" | "kv";
333
441
  reason?:
334
442
  | "absent"
335
443
  | "timeout"
336
444
  | "error"
337
445
  | "non-200"
338
- | "corrupt"
339
446
  | "malformed"
340
447
  | "expired"
341
448
  | "unavailable";
@@ -349,41 +456,6 @@ interface CFShellDebugDetails {
349
456
  remainingTtl?: number;
350
457
  }
351
458
 
352
- /** Validate the coupled PPR shell envelope before any field reaches resume. */
353
- function isShellEnvelope(value: unknown): value is CFShellEnvelope {
354
- if (value == null || typeof value !== "object") return false;
355
- const envelope = value as Partial<CFShellEnvelope>;
356
- return (
357
- // Document half: required unless the envelope is navigationOnly (`no`),
358
- // which stores neither field. Tolerate a legacy navigationOnly envelope
359
- // that still carries them.
360
- (typeof envelope.p === "string" ||
361
- (envelope.p === undefined && envelope.no === true)) &&
362
- (envelope.po === null ||
363
- typeof envelope.po === "string" ||
364
- (envelope.po === undefined && envelope.no === true)) &&
365
- typeof envelope.rv === "string" &&
366
- (envelope.bv === undefined || typeof envelope.bv === "string") &&
367
- typeof envelope.c === "number" &&
368
- Number.isFinite(envelope.c) &&
369
- typeof envelope.s === "number" &&
370
- Number.isFinite(envelope.s) &&
371
- typeof envelope.e === "number" &&
372
- Number.isFinite(envelope.e) &&
373
- (envelope.t === undefined ||
374
- (Array.isArray(envelope.t) &&
375
- envelope.t.every((tag) => typeof tag === "string"))) &&
376
- (envelope.ta === undefined ||
377
- (typeof envelope.ta === "number" && Number.isFinite(envelope.ta))) &&
378
- (envelope.i === undefined || typeof envelope.i === "string") &&
379
- (envelope.sn === undefined || Array.isArray(envelope.sn)) &&
380
- (envelope.dk === undefined || typeof envelope.dk === "string") &&
381
- (envelope.lh === undefined || typeof envelope.lh === "boolean") &&
382
- (envelope.tw === undefined || envelope.tw === true) &&
383
- (envelope.no === undefined || envelope.no === true)
384
- );
385
- }
386
-
387
459
  /**
388
460
  * KV envelope for document cache entries.
389
461
  * @internal
@@ -426,6 +498,22 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
426
498
  private readonly edgeLookupTimeoutMs: number;
427
499
  private readonly edgeReadTimeoutMs: number;
428
500
  private readonly kvReadTimeoutMs: number;
501
+ /**
502
+ * The shell snapshot read's budgets. `read` (getShell: partial replay, the
503
+ * testing helpers) has nothing committed, so a slow snapshot is a quick
504
+ * miss at kvReadTimeoutMs. `document` (readShellDocument, the serve path)
505
+ * has sent the prelude and cannot finish without the snapshot's doc
506
+ * record; a timeout there reloads the page, so it waits at least
507
+ * SHELL_SNAPSHOT_READ_MIN_TIMEOUT_MS. `kvReadTimeoutMs <= 0` keeps both
508
+ * unbounded.
509
+ */
510
+ private readonly snapshotBudgets: {
511
+ read: SnapshotReadBudget;
512
+ document: SnapshotReadBudget;
513
+ };
514
+ private readonly memo: ResolvedShellMemoOptions;
515
+ /** @internal SegmentCacheStore.freshReadsWindowMs */
516
+ readonly freshReadsWindowMs: number;
429
517
  private readonly debug?: (event: CFCacheReadDebugEvent) => void;
430
518
  private readonly kv?: KVNamespace;
431
519
  /** True when constructed without KV: no durable tag history (see ctor). */
@@ -476,6 +564,28 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
476
564
  options.kvReadTimeoutMs,
477
565
  KV_READ_TIMEOUT_MS,
478
566
  );
567
+ this.snapshotBudgets = {
568
+ read: { ms: this.kvReadTimeoutMs },
569
+ document:
570
+ this.kvReadTimeoutMs > 0
571
+ ? {
572
+ ms: Math.max(
573
+ this.kvReadTimeoutMs,
574
+ SHELL_SNAPSHOT_READ_MIN_TIMEOUT_MS,
575
+ ),
576
+ consequence: "the HIT reloads the page into a cache-miss render",
577
+ }
578
+ : { ms: this.kvReadTimeoutMs },
579
+ };
580
+ this.memo = resolveShellMemoOptions(options.memo, {
581
+ markerFreshMs: DEFAULT_CF_MARKER_FRESH_MS,
582
+ markerMaxStaleMs: DEFAULT_CF_MARKER_MAX_STALE_MS,
583
+ });
584
+ // Without KV there are no markers, so no marker memo to outwait.
585
+ this.freshReadsWindowMs = freshReadsWindowMs(
586
+ this.memo,
587
+ Boolean(options.kv),
588
+ );
479
589
  this.debug =
480
590
  options.debug === true
481
591
  ? (event) =>
@@ -487,7 +597,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
487
597
  this.waitUntil = (fn) => options.ctx.waitUntil(fn());
488
598
  this.kv = options.kv;
489
599
  // Without KV, isTagsInvalidatedSince has no durable history — it answers
490
- // from the per-request memo at best. Runtime shells tolerate that (purge
600
+ // from the per-request mask at best. Runtime shells tolerate that (purge
491
601
  // eviction + ttl/swr bound the staleness), but an immutable TAGGED
492
602
  // build-manifest shell must not serve on such a store: nothing could ever
493
603
  // evict it (purge cannot delete a build asset), so the manifest gate
@@ -602,17 +712,23 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
602
712
  }
603
713
  }
604
714
 
605
- /** Build-time-gated shell tier trace for deployed cross-colo diagnostics. */
715
+ /**
716
+ * Build-time-gated shell tier trace for deployed cross-colo diagnostics.
717
+ * A read's `stats` print as its timings, after the other details and before
718
+ * `expiresAt`.
719
+ */
606
720
  private debugShell(
607
721
  key: string,
608
722
  outcome: CFShellDebugOutcome,
609
- details: CFShellDebugDetails = {},
723
+ details: CFShellDebugDetails & { stats?: ShellReadStats } = {},
610
724
  ): void {
611
725
  if (!INTERNAL_RANGO_DEBUG) return;
612
726
  const request = _getRequestContext()?.request as
613
727
  | (Request & { cf?: { colo?: unknown } })
614
728
  | undefined;
615
- const ray = request?.headers.get("cf-ray") ?? undefined;
729
+ const ray = request
730
+ ? (requestHeaders(request).get("cf-ray") ?? undefined)
731
+ : undefined;
616
732
  const cfColo = request?.cf?.colo;
617
733
  const colo =
618
734
  typeof cfColo === "string"
@@ -620,6 +736,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
620
736
  : ray?.includes("-")
621
737
  ? ray.slice(ray.lastIndexOf("-") + 1)
622
738
  : undefined;
739
+ const { stats, expiresAt, ...rest } = details;
623
740
  console.log(
624
741
  `[CFCacheStore][shell] ${JSON.stringify({
625
742
  key,
@@ -627,7 +744,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
627
744
  at: Date.now(),
628
745
  ray,
629
746
  colo,
630
- ...details,
747
+ ...rest,
748
+ ...debugTimings(stats),
749
+ expiresAt,
631
750
  })}`,
632
751
  );
633
752
  }
@@ -723,6 +842,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
723
842
  read: () => Promise<T>,
724
843
  budgetMs: number,
725
844
  label: string,
845
+ consequence = "treating as miss",
726
846
  ): Promise<{ value: T | undefined; timedOut: boolean }> {
727
847
  if (budgetMs <= 0) return { value: await read(), timedOut: false };
728
848
 
@@ -741,7 +861,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
741
861
  ]);
742
862
  if (result.timedOut) {
743
863
  console.warn(
744
- `[CFCacheStore] ${label} exceeded ${budgetMs}ms; treating as miss`,
864
+ `[CFCacheStore] ${label} exceeded ${budgetMs}ms; ${consequence}`,
745
865
  );
746
866
  return { value: undefined, timedOut: true };
747
867
  }
@@ -1246,7 +1366,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1246
1366
 
1247
1367
  /**
1248
1368
  * Store entry data with TTL and optional SWR window.
1249
- * Uses waitUntil for non-blocking write when available.
1369
+ * Resolves once the L1 write lands, per the SegmentCacheStore.set contract
1370
+ * (also held by waitUntil when available); the KV write stays in the
1371
+ * background.
1250
1372
  * When KV is configured, also persists to L2.
1251
1373
  */
1252
1374
  async set(
@@ -1297,20 +1419,21 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1297
1419
 
1298
1420
  const putPromise = cache.put(request, response);
1299
1421
 
1422
+ let l1Write: Promise<void> | undefined;
1300
1423
  if (this.waitUntil) {
1301
- // Non-blocking write. These store-level background tasks intentionally
1302
- // omit the reportingAsync ctx argument: the store is a request-agnostic
1424
+ // These store-level background tasks intentionally omit the
1425
+ // reportingAsync ctx argument: the store is a request-agnostic
1303
1426
  // singleton and this.waitUntil is the execution context's, not a single
1304
1427
  // request's, so a failure is reported console-loud only (it cannot be
1305
1428
  // attributed to one request's onError). The request-scoped tag verbs
1306
1429
  // (revalidateTag / stale-revalidation) DO thread their captured ctx.
1307
- this.waitUntil(() =>
1308
- reportingAsync(
1309
- () => putPromise,
1310
- "cache-write",
1311
- "[CFCacheStore] L1 write",
1312
- ),
1430
+ const put = reportingAsync(
1431
+ () => putPromise,
1432
+ "cache-write",
1433
+ "[CFCacheStore] L1 write",
1313
1434
  );
1435
+ this.waitUntil(() => put);
1436
+ l1Write = put;
1314
1437
  } else {
1315
1438
  // Blocking fallback
1316
1439
  await putPromise;
@@ -1318,6 +1441,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1318
1441
 
1319
1442
  // L2: persist to KV (reuses `body` as envelope.d)
1320
1443
  this.kvSetSegment(key, body, staleAt, totalTtl, swrWindow);
1444
+
1445
+ // The set() contract (SegmentCacheStore.set, #957): resolve once L1 is
1446
+ // readable.
1447
+ await l1Write;
1321
1448
  } catch (error) {
1322
1449
  reportCacheError(error, "cache-write", "[CFCacheStore] set");
1323
1450
  }
@@ -1897,7 +2024,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1897
2024
  // serves its own shell from the Cache API. Tag eviction then needs purge
1898
2025
  // mode (tagPurge) — shell L1 entries carry the same namespaced Cache-Tag
1899
2026
  // tokens as the data families, read-your-own-writes comes from the
1900
- // per-request marker memo, and the capture resurrection race narrows to
2027
+ // per-request tag mask, and the capture resurrection race narrows to
1901
2028
  // cross-request timing bounded by ttl+swr (the data families' documented
1902
2029
  // purge-mode stance). KV-less WITHOUT tagPurge still caches: freshness is
1903
2030
  // ttl/swr only, and a tagged write warns once that invalidation cannot
@@ -1927,7 +2054,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1927
2054
  /**
1928
2055
  * Generation gate for shell writes and the capture scheduler
1929
2056
  * (isTagsInvalidatedSince). With KV it is the durable marker cascade.
1930
- * Without KV there are no markers: in purge mode the per-request memo is
2057
+ * Without KV there are no markers: in purge mode the per-request mask is
1931
2058
  * the only signal — a capture racing THIS request's updateTag() is still
1932
2059
  * rejected (read-your-own-writes), while cross-request races are bounded
1933
2060
  * by ttl+swr exactly like the data families' purge-mode writes. Without
@@ -1941,38 +2068,100 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1941
2068
  if (this.kv) return this.isGloballyInvalidated(tags, since);
1942
2069
  if (!this.tagPurge || !Array.isArray(tags) || tags.length === 0 || !since)
1943
2070
  return false;
1944
- const ctx = _getRequestContext();
1945
- if (!ctx) return false;
1946
- const memo = getTagMarkerMemo(ctx, this);
1947
- for (const tag of tags) {
1948
- const marker = memo.get(tag);
1949
- if (marker != null && marker >= since) return true;
1950
- }
1951
- return false;
2071
+ return maskedForRequest(_getRequestContext(), this, tags, since);
1952
2072
  }
1953
2073
 
1954
2074
  /**
1955
- * Get a cached PPR shell entry from Cache API, falling through to KV and
1956
- * promoting a valid KV hit. Both tiers store one envelope so the prelude,
1957
- * postponed state, snapshot, versions, and generation metadata cannot mix.
1958
- * SWR remains a plain staleness flag; the capture scheduler's module-level
1959
- * in-flight set is the recapture stampede guard.
2075
+ * Get a cached PPR shell entry: the public custom-store contract, built on
2076
+ * readShellDocument. It waits for the snapshot and re-encodes the prelude to
2077
+ * the base64 `ShellCacheEntry.prelude` the contract carries (native
2078
+ * `toBase64` in workerd). Partial replay and the testing helpers read
2079
+ * through here; the document serve path reads readShellDocument directly.
2080
+ * A snapshot that failed to read is a miss here: with no committed response
2081
+ * to protect, an entry without its pins is worse than none.
1960
2082
  */
1961
2083
  async getShell(
1962
2084
  key: string,
1963
2085
  ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
2086
+ const read = await this.readShellDocumentWithin(
2087
+ key,
2088
+ undefined,
2089
+ this.snapshotBudgets.read,
2090
+ );
2091
+ if (!read) return null;
2092
+ const snapshot = await read.snapshot;
2093
+ if (await read.snapshotFailure) return null;
2094
+ // A frame with no snapshot bytes carries no records.
2095
+ const entry: ShellCacheEntry = { ...read.entry, snapshot: snapshot ?? [] };
2096
+ if (!entry.navigationOnly) entry.prelude = bytesToBase64(read.prelude);
2097
+ return { entry, shouldRevalidate: read.shouldRevalidate };
2098
+ }
2099
+
2100
+ /**
2101
+ * @internal Prelude-first read of a shell (SegmentCacheStore
2102
+ * .readShellDocument). Cache API first, then KV; both tiers hold one
2103
+ * cf-shell-frame body, so the prelude, postponed state, snapshot, versions,
2104
+ * and generation metadata cannot mix. Before it resolves it reads only the
2105
+ * head and the prelude's bytes, and the tag-marker read runs alongside the
2106
+ * prelude read (it starts as soon as the head is parsed, and is awaited
2107
+ * before this resolves, so an invalidated shell is never returned). The
2108
+ * snapshot is the rest of the same body, read on its own promise, bounded
2109
+ * by the document read's budget (`snapshotBudgets.document`; getShell uses
2110
+ * `snapshotBudgets.read`). SWR is a plain staleness flag; the capture
2111
+ * scheduler's in-flight set is the recapture stampede guard.
2112
+ */
2113
+ async readShellDocument(
2114
+ key: string,
2115
+ options?: ShellDocumentReadOptions,
2116
+ ): Promise<ShellDocumentRead | null> {
2117
+ return this.readShellDocumentWithin(
2118
+ key,
2119
+ options,
2120
+ this.snapshotBudgets.document,
2121
+ );
2122
+ }
2123
+
2124
+ private async readShellDocumentWithin(
2125
+ key: string,
2126
+ options: ShellDocumentReadOptions | undefined,
2127
+ snapshotBudget: SnapshotReadBudget,
2128
+ ): Promise<ShellDocumentRead | null> {
2129
+ const memoKey = this.shellMemoKey(key);
2130
+ const ctx = _getRequestContext();
2131
+ // A request after the same user's updateTag() reads past the isolate
2132
+ // memos (isolate-tag-memo.ts freshReadsRequired).
2133
+ const freshReads = freshReadsRequired(ctx);
2134
+ const memoized = freshReads
2135
+ ? undefined
2136
+ : cfShellMemo.get(memoKey, this.memo.shellMs);
2137
+ if (memoized) {
2138
+ return this.readMemoizedShell(
2139
+ key,
2140
+ memoKey,
2141
+ memoized,
2142
+ options,
2143
+ snapshotBudget,
2144
+ );
2145
+ }
2146
+ const stats = this.shellReadStats("l1");
2147
+ const l1StartedAt = stats ? performance.now() : 0;
2148
+ const kvFallback = (reason: string): Promise<ShellDocumentRead | null> =>
2149
+ this.kvReadShellDocument(key, snapshotBudget, stats, l1StartedAt, reason);
2150
+ if (stats && freshReads) stats.freshReads = true;
2151
+ this.prefetchShellMarkers(memoKey, options?.tagHints, stats);
2152
+ if (stats && this.memo.shellMs > 0) {
2153
+ stats.memo = { hit: false, bytes: cfShellMemo.size };
2154
+ }
1964
2155
  try {
1965
2156
  const cache = await this.getCache();
1966
- const request = this.keyToRequest(`shell:${key}`);
1967
- const matchStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
2157
+ const request = this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`);
2158
+ const matchStartedAt = stats ? performance.now() : 0;
1968
2159
  const {
1969
2160
  response,
1970
2161
  timedOut,
1971
2162
  error: matchError,
1972
2163
  } = await this.matchWithTimeout(cache, request);
1973
- const matchMs = INTERNAL_RANGO_DEBUG
1974
- ? Date.now() - matchStartedAt
1975
- : undefined;
2164
+ if (stats) stats.matchMs = performance.now() - matchStartedAt;
1976
2165
 
1977
2166
  if (!response) {
1978
2167
  if (matchError) {
@@ -1982,116 +2171,434 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1982
2171
  "[CFCacheStore] getShell L1 match",
1983
2172
  );
1984
2173
  }
1985
- this.debugShell(key, "l1-miss", {
1986
- reason: matchError ? "error" : timedOut ? "timeout" : "absent",
1987
- matchMs,
1988
- });
1989
- return this.kvGetShell(key);
2174
+ const reason = matchError ? "error" : timedOut ? "timeout" : "absent";
2175
+ this.debugShell(key, "l1-miss", { reason, stats });
2176
+ return kvFallback(reason);
1990
2177
  }
1991
- if (response.status !== 200) {
2178
+ if (response.status !== 200 || !response.body) {
1992
2179
  this.debugShell(key, "l1-miss", {
1993
2180
  reason: "non-200",
1994
2181
  status: response.status,
1995
- matchMs,
2182
+ stats,
1996
2183
  });
1997
- return this.kvGetShell(key);
2184
+ return kvFallback("non-200");
1998
2185
  }
1999
2186
 
2000
- const bodyStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
2001
- const { value, errored, error } =
2002
- await this.readJsonWithTimeout<unknown>(response);
2003
- const bodyReadMs = INTERNAL_RANGO_DEBUG
2004
- ? Date.now() - bodyStartedAt
2005
- : undefined;
2006
- if (value === undefined) {
2007
- this.debugShell(key, "l1-miss", {
2008
- reason: errored ? "corrupt" : "timeout",
2009
- matchMs,
2010
- bodyReadMs,
2011
- });
2012
- if (errored) {
2013
- return this.healCorruptL1(cache, request, error, "getShell", () =>
2014
- this.kvGetShell(key),
2015
- );
2016
- }
2017
- return this.kvGetShell(key);
2018
- }
2019
- if (!isShellEnvelope(value)) {
2187
+ // Unlike other L1 families, shells with KV always check the durable
2188
+ // generation marker — see the capture-start/purge race documented
2189
+ // above. Without KV there are no markers: L1-only shells adopt the
2190
+ // data families' purge-mode read semantics (a hit that survived the
2191
+ // purge is trusted; the per-request mask covers this request's own
2192
+ // updateTag() writes; entries a purge cannot reach fall back to the
2193
+ // marker check, which fails open KV-less).
2194
+ const opened = await this.openShellFrame(
2195
+ response.body,
2196
+ this.edgeReadTimeoutMs,
2197
+ "edge cache body read",
2198
+ (head) =>
2199
+ this.kv
2200
+ ? this.isGloballyInvalidated(head.t, head.ta, true)
2201
+ : this.isL1Invalidated(head.t, head.ta, response.headers),
2202
+ stats,
2203
+ );
2204
+ if (opened.status === "corrupt") {
2020
2205
  this.debugShell(key, "l1-miss", {
2021
2206
  reason: "malformed",
2022
- matchMs,
2023
- bodyReadMs,
2207
+ stats,
2024
2208
  });
2025
2209
  return this.healCorruptL1(
2026
2210
  cache,
2027
2211
  request,
2028
- new Error("malformed/partial L1 shell envelope"),
2212
+ opened.error,
2029
2213
  "getShell",
2030
- () => this.kvGetShell(key),
2214
+ () => kvFallback("malformed"),
2031
2215
  );
2032
2216
  }
2033
-
2034
- const now = Date.now();
2035
- if (now > value.e) {
2217
+ if (opened.status === "timeout" || opened.status === "expired") {
2036
2218
  this.debugShell(key, "l1-miss", {
2037
- reason: "expired",
2038
- matchMs,
2039
- bodyReadMs,
2040
- expiresAt: value.e,
2219
+ reason: opened.status,
2220
+ stats,
2221
+ expiresAt: opened.head?.e,
2041
2222
  });
2042
- return this.kvGetShell(key);
2223
+ return kvFallback(opened.status);
2043
2224
  }
2044
-
2045
- // Unlike other L1 families, shells with KV always check the durable
2046
- // generation marker — see the capture-start/purge race documented
2047
- // above. Without KV there are no markers: L1-only shells adopt the
2048
- // data families' purge-mode read semantics (a hit that survived the
2049
- // purge is trusted; the per-request memo masks this request's own
2050
- // updateTag() writes; entries a purge cannot reach fall back to the
2051
- // marker check, which fails open KV-less).
2052
- const markerStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
2053
- const invalidated = this.kv
2054
- ? await this.isGloballyInvalidated(value.t, value.ta)
2055
- : await this.isL1Invalidated(value.t, value.ta, response.headers);
2056
- const markerMs = INTERNAL_RANGO_DEBUG
2057
- ? Date.now() - markerStartedAt
2058
- : undefined;
2059
- if (invalidated) {
2225
+ if (opened.status === "invalidated") {
2060
2226
  this.debugShell(key, "marker-invalidated", {
2061
2227
  tier: "l1",
2062
- matchMs,
2063
- bodyReadMs,
2064
- markerMs,
2228
+ stats,
2065
2229
  });
2066
2230
  return null;
2067
2231
  }
2068
-
2069
- const shouldRevalidate = value.s > 0 && now > value.s;
2070
- this.debugShell(key, "l1-hit", {
2071
- freshness: shouldRevalidate ? "stale" : "fresh",
2072
- matchMs,
2073
- bodyReadMs,
2074
- markerMs,
2075
- expiresAt: value.e,
2076
- });
2077
- return {
2078
- entry: this.shellEnvelopeToEntry(value),
2079
- shouldRevalidate,
2080
- };
2232
+ const read = this.shellDocumentRead(
2233
+ key,
2234
+ opened,
2235
+ snapshotBudget,
2236
+ stats,
2237
+ response.headers,
2238
+ );
2239
+ if (INTERNAL_RANGO_DEBUG) {
2240
+ this.debugShell(key, "l1-hit", {
2241
+ freshness: read.shouldRevalidate ? "stale" : "fresh",
2242
+ stats,
2243
+ expiresAt: opened.head.e,
2244
+ });
2245
+ }
2246
+ return read;
2081
2247
  } catch (error) {
2082
2248
  reportCacheError(error, "cache-read", "[CFCacheStore] getShell");
2083
2249
  this.debugShell(key, "l1-miss", { reason: "error" });
2084
- return this.kvGetShell(key);
2250
+ return kvFallback("error");
2251
+ }
2252
+ }
2253
+
2254
+ /** @internal SegmentCacheStore.dropShellMemo */
2255
+ dropShellMemo(key: string): void {
2256
+ cfShellMemo.delete(this.shellMemoKey(key));
2257
+ }
2258
+
2259
+ /**
2260
+ * Start the marker reads of the tags this shell key carried last time (and
2261
+ * the route's static tags) alongside the entry read (readShellTagMarker),
2262
+ * so the marker check after the head reuses them. With KV only: without it
2263
+ * there are no markers to read. A wrong hint costs a wasted read; the check
2264
+ * itself uses the head's tags.
2265
+ */
2266
+ private prefetchShellMarkers(
2267
+ memoKey: string,
2268
+ routeTags: readonly string[] | undefined,
2269
+ stats: ShellReadStats | undefined,
2270
+ ): void {
2271
+ if (!this.kv) return;
2272
+ const tags = hintedTags(cfTagHints.get(memoKey), routeTags);
2273
+ if (tags.length === 0) return;
2274
+ if (stats) {
2275
+ stats.markerHinted = tags;
2276
+ stats.markerHintStartedAt = performance.now();
2277
+ }
2278
+ for (const tag of tags) this.readShellTagMarker(tag).catch(() => {});
2279
+ }
2280
+
2281
+ /**
2282
+ * A HIT: remember the entry's tags for the next read's hints, and fill the
2283
+ * marker row. The shell-read marker values stay in their own record: the
2284
+ * request's data reads (the tail's, or a partial navigation's matchPartial
2285
+ * on the same context) read their markers as before.
2286
+ */
2287
+ private recordShellHit(
2288
+ key: string,
2289
+ tags: string[] | undefined,
2290
+ stats: ShellReadStats | undefined,
2291
+ ): void {
2292
+ cfTagHints.remember(this.shellMemoKey(key), tags);
2293
+ if (!stats) return;
2294
+ const ctx = _getRequestContext();
2295
+ if (ctx) {
2296
+ recordMarkerRow(stats, tags, getShellMarkerReads(ctx, this).outcomes);
2297
+ }
2298
+ }
2299
+
2300
+ /** The isolate marker memo's key for a tag: markers are per namespace and version. */
2301
+ private markerMemoKey(tag: string): string {
2302
+ return `${this.namespace ?? ""}\u0000${this.version ?? ""}\u0000${tag}`;
2303
+ }
2304
+
2305
+ /** The memo key: one per namespace, build version, base URL, and shell key. */
2306
+ private shellMemoKey(key: string): string {
2307
+ return `${this.namespace ?? ""}\u0000${this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`).url}`;
2308
+ }
2309
+
2310
+ /**
2311
+ * Serve a memoized shell (shell-memo.ts). The tag-marker check is the only
2312
+ * store I/O left, so it is started first and awaited before anything is
2313
+ * returned: nothing of an invalidated shell reaches the response. A shell
2314
+ * that went stale since it was memoized, or that predates a tag this
2315
+ * isolate is invalidating or just invalidated (RecentTagInvalidations), is
2316
+ * dropped and read from the store (SWR recapture scheduling and each mode's
2317
+ * invalidation semantics stay with the store read). That read keeps the
2318
+ * caller's `tagHints`: cfTagHints may have evicted the key while the memo
2319
+ * still holds it.
2320
+ */
2321
+ private async readMemoizedShell(
2322
+ key: string,
2323
+ memoKey: string,
2324
+ memoized: CFShellMemoValue,
2325
+ options: ShellDocumentReadOptions | undefined,
2326
+ snapshotBudget: SnapshotReadBudget,
2327
+ ): Promise<ShellDocumentRead | null> {
2328
+ const { head } = memoized;
2329
+ if (!isMemoizableShell(head)) {
2330
+ cfShellMemo.delete(memoKey);
2331
+ return this.readShellDocumentWithin(key, options, snapshotBudget);
2332
+ }
2333
+ const stats = this.shellReadStats("memo");
2334
+ const markerStartedAt = stats ? performance.now() : 0;
2335
+ const invalidated = await (this.kv
2336
+ ? this.isGloballyInvalidated(head.t, head.ta, true)
2337
+ : this.isL1Invalidated(
2338
+ head.t,
2339
+ head.ta,
2340
+ memoized.headers ?? new Headers(),
2341
+ ));
2342
+ if (stats) {
2343
+ // Nothing else to wait for: the marker IS the memo hit's critical path.
2344
+ stats.markerMs = stats.markerWaitMs = performance.now() - markerStartedAt;
2345
+ stats.tags = head.t?.length ?? 0;
2346
+ stats.preludeBytes = memoized.prelude.length;
2347
+ }
2348
+ if (invalidated) {
2349
+ cfShellMemo.delete(memoKey);
2350
+ this.debugShell(key, "marker-invalidated", {
2351
+ tier: "memo",
2352
+ stats,
2353
+ });
2354
+ return null;
2355
+ }
2356
+ if (stats) stats.memo = { hit: true, bytes: cfShellMemo.size };
2357
+ this.recordShellHit(key, head.t, stats);
2358
+ this.debugShell(key, "memo-hit", {
2359
+ freshness: "fresh",
2360
+ stats,
2361
+ expiresAt: head.e,
2362
+ });
2363
+ return {
2364
+ entry: shellHeadToEntry(head),
2365
+ prelude: memoized.prelude,
2366
+ shouldRevalidate: false,
2367
+ snapshot: Promise.resolve(memoized.snapshot),
2368
+ ...(stats ? { stats } : {}),
2369
+ };
2370
+ }
2371
+
2372
+ /**
2373
+ * Stats for one shell read, allocated only when the request collects
2374
+ * `debugPerformance` metrics or the build has the internal shell trace on,
2375
+ * so a normal HIT takes no timestamps for them.
2376
+ */
2377
+ private shellReadStats(
2378
+ tier: ShellReadStats["tier"],
2379
+ ): ShellReadStats | undefined {
2380
+ return INTERNAL_RANGO_DEBUG || _getRequestContext()?._metricsStore
2381
+ ? { tier }
2382
+ : undefined;
2383
+ }
2384
+
2385
+ /**
2386
+ * Read a frame's head and prelude under one budget, starting the
2387
+ * tag-marker read (`isInvalidated`) the moment the head is parsed so it
2388
+ * overlaps the prelude read, then await it. Expired heads are rejected
2389
+ * before the marker read starts.
2390
+ */
2391
+ private async openShellFrame(
2392
+ body: ReadableStream<Uint8Array>,
2393
+ budgetMs: number,
2394
+ label: string,
2395
+ isInvalidated: (head: ShellFrameHead) => Promise<boolean>,
2396
+ stats: ShellReadStats | undefined,
2397
+ ): Promise<OpenedShellFrame> {
2398
+ const reader = new ShellFrameReader(body);
2399
+ let marker: Promise<boolean> | undefined;
2400
+ let markerSettledAt = 0;
2401
+ const headStartedAt = stats ? performance.now() : 0;
2402
+ const { value, timedOut } = await this.readWithTimeout(
2403
+ async (): Promise<OpenedShellFrame> => {
2404
+ const head = await reader.readHead();
2405
+ if (!head) {
2406
+ return {
2407
+ status: "corrupt",
2408
+ error: new Error("malformed/partial shell frame head"),
2409
+ };
2410
+ }
2411
+ const markerStartedAt = stats ? performance.now() : 0;
2412
+ if (stats) {
2413
+ stats.headMs = markerStartedAt - headStartedAt;
2414
+ stats.headBytes = reader.headBytes;
2415
+ stats.tags = head.t?.length ?? 0;
2416
+ if (stats.markerHintStartedAt !== undefined) {
2417
+ stats.markerLeadMs = markerStartedAt - stats.markerHintStartedAt;
2418
+ }
2419
+ }
2420
+ if (Date.now() > head.e) return { status: "expired", head };
2421
+ marker = isInvalidated(head).then((invalidated) => {
2422
+ if (stats) {
2423
+ markerSettledAt = performance.now();
2424
+ stats.markerMs = markerSettledAt - markerStartedAt;
2425
+ }
2426
+ return invalidated;
2427
+ });
2428
+ marker.catch(() => {});
2429
+ const prelude = await reader.take(head.pl);
2430
+ if (!prelude) {
2431
+ return {
2432
+ status: "corrupt",
2433
+ error: new Error("shell frame ends inside the prelude"),
2434
+ };
2435
+ }
2436
+ if (stats) {
2437
+ stats.preludeMs = performance.now() - markerStartedAt;
2438
+ stats.preludeBytes = prelude.length;
2439
+ }
2440
+ return { status: "ok", head, prelude, reader };
2441
+ },
2442
+ budgetMs,
2443
+ label,
2444
+ );
2445
+ if (timedOut || !value) {
2446
+ reader.cancel();
2447
+ if (stats) {
2448
+ // The budget ran out: the whole elapsed read, split at the head.
2449
+ const elapsed = performance.now() - headStartedAt;
2450
+ if (stats.headMs === undefined) stats.headMs = elapsed;
2451
+ else stats.preludeMs = elapsed - stats.headMs;
2452
+ }
2453
+ return { status: "timeout" };
2454
+ }
2455
+ if (value.status !== "ok") {
2456
+ reader.cancel();
2457
+ return value;
2085
2458
  }
2459
+ const preludeReadAt = stats ? performance.now() : 0;
2460
+ const invalidated = await marker!;
2461
+ if (stats) {
2462
+ stats.markerWaitMs = Math.max(0, markerSettledAt - preludeReadAt);
2463
+ }
2464
+ if (invalidated) {
2465
+ reader.cancel();
2466
+ return { status: "invalidated" };
2467
+ }
2468
+ return value;
2469
+ }
2470
+
2471
+ /**
2472
+ * The ShellDocumentRead for an opened frame. Its snapshot promise starts
2473
+ * reading the rest of the body now (off the commit path, not after it),
2474
+ * bounded by `budget` ({@link snapshotReadBudget}), and is registered with
2475
+ * waitUntil so the KV tier's L1 promotion and a corrupt entry's eviction
2476
+ * still run when nothing awaits it (a read that is not served). A snapshot
2477
+ * whose length differs from the head's `sl`, or that fails to parse, evicts
2478
+ * the entry from both tiers and reports cache-corrupt. Every failure
2479
+ * resolves undefined and names itself on `snapshotFailure` (`unavailable`
2480
+ * for a timeout or read error, `corrupt` otherwise): a document HIT then
2481
+ * degrades (rsc-rendering.ts serveShellHit), and getShell misses. Only
2482
+ * a read whose snapshot parsed goes into the per-isolate memo (when still
2483
+ * fresh), and `promote` receives its snapshot bytes (the KV tier's L1
2484
+ * promotion). `headers` are the L1 response's, kept for the KV-less
2485
+ * memo-hit marker check.
2486
+ */
2487
+ private shellDocumentRead(
2488
+ key: string,
2489
+ opened: Extract<OpenedShellFrame, { status: "ok" }>,
2490
+ budget: SnapshotReadBudget,
2491
+ stats: ShellReadStats | undefined,
2492
+ headers?: Headers,
2493
+ promote?: (snapshotBytes: Uint8Array) => void,
2494
+ ): ShellDocumentRead {
2495
+ const { head, prelude, reader } = opened;
2496
+ this.recordShellHit(key, head.t, stats);
2497
+ type SnapshotOutcome = {
2498
+ records?: ShellSnapshotRecord[];
2499
+ failure?: ShellSnapshotFailure;
2500
+ };
2501
+ const outcome = (async (): Promise<SnapshotOutcome> => {
2502
+ const readStartedAt = stats ? performance.now() : 0;
2503
+ const { value: rest, timedOut } = await this.readWithTimeout(
2504
+ () => reader.readRest(),
2505
+ budget.ms,
2506
+ "shell snapshot read",
2507
+ budget.consequence,
2508
+ );
2509
+ if (timedOut || !rest) {
2510
+ reader.cancel();
2511
+ return { failure: "unavailable" };
2512
+ }
2513
+ const parseStartedAt = stats ? performance.now() : 0;
2514
+ let records: ShellSnapshotRecord[] | undefined;
2515
+ try {
2516
+ if (rest.length !== head.sl) {
2517
+ throw new Error(
2518
+ `shell snapshot is ${rest.length} bytes, the head says ${head.sl}`,
2519
+ );
2520
+ }
2521
+ records = parseShellSnapshot(rest);
2522
+ } catch (error) {
2523
+ reportCacheError(
2524
+ error,
2525
+ "cache-corrupt",
2526
+ "[CFCacheStore] getShell: corrupt shell snapshot, evicting",
2527
+ );
2528
+ this.evictShell(key);
2529
+ return { failure: "corrupt" };
2530
+ }
2531
+ if (stats) {
2532
+ stats.snapshot = {
2533
+ readMs: parseStartedAt - readStartedAt,
2534
+ parseMs: performance.now() - parseStartedAt,
2535
+ bytes: rest.length,
2536
+ };
2537
+ }
2538
+ if (isMemoizableShell(head)) {
2539
+ // A view into a larger body chunk would pin that chunk in the memo.
2540
+ const ownPrelude =
2541
+ prelude.byteLength === prelude.buffer.byteLength
2542
+ ? prelude
2543
+ : prelude.slice();
2544
+ cfShellMemo.set(
2545
+ this.shellMemoKey(key),
2546
+ { head, prelude: ownPrelude, snapshot: records, headers },
2547
+ ownPrelude.length + rest.length,
2548
+ this.memo.shellMs,
2549
+ this.memo.shellMaxBytes,
2550
+ );
2551
+ }
2552
+ promote?.(rest);
2553
+ return { records };
2554
+ })().catch((error: unknown): SnapshotOutcome => {
2555
+ reportCacheError(error, "cache-read", "[CFCacheStore] getShell snapshot");
2556
+ return { failure: "unavailable" };
2557
+ });
2558
+ if (this.waitUntil) this.waitUntil(() => outcome.then(() => {}));
2559
+ return {
2560
+ entry: shellHeadToEntry(head),
2561
+ prelude,
2562
+ shouldRevalidate: head.s > 0 && Date.now() > head.s,
2563
+ snapshot: outcome.then(({ records }) => records),
2564
+ snapshotFailure: outcome.then(({ failure }) => failure),
2565
+ ...(stats && { stats }),
2566
+ };
2567
+ }
2568
+
2569
+ /** Delete a shell from both tiers (non-blocking). */
2570
+ private evictShell(key: string): void {
2571
+ const evict = async (): Promise<void> => {
2572
+ const cache = await this.getCache();
2573
+ await reportingAsync(
2574
+ () => cache.delete(this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`)),
2575
+ "cache-delete",
2576
+ "[CFCacheStore] getShell: evict corrupt L1",
2577
+ );
2578
+ if (this.kv) {
2579
+ await this.evictKvKey(
2580
+ await this.toKVKey(`${SHELL_KEY_PREFIX}${key}`),
2581
+ "getShell",
2582
+ );
2583
+ }
2584
+ };
2585
+ if (this.waitUntil) this.waitUntil(evict);
2586
+ else void evict();
2086
2587
  }
2087
2588
 
2088
2589
  /**
2089
- * Store a PPR shell envelope in Cache API and, when KV is configured and
2090
- * the retention meets its 60-second floor, KV. The shared write is
2091
- * registered with waitUntil and awaited so invalidation rejection can be
2092
- * acknowledged to the capture scheduler. Short-lived shells remain useful
2093
- * in L1 even though KV rejects them; a KV-less store is L1-only by design
2094
- * (edge-only ppr — see the section comment).
2590
+ * Store a PPR shell in Cache API and, when KV is configured, KV — the same
2591
+ * cf-shell-frame bytes in both. The shared write is registered with
2592
+ * waitUntil and awaited so invalidation rejection can be acknowledged to
2593
+ * the capture scheduler. A KV-less store is L1-only by design (edge-only
2594
+ * ppr — see the section comment).
2595
+ *
2596
+ * KV rejects an expirationTtl below KV_MIN_EXPIRATION_TTL, so a shorter
2597
+ * retention (a `ppr` ttl + swr under 60, or a shell capped to a short or
2598
+ * aging route cache() entry) is written with that floor: the frame head's deadlines (`s`, `e`) are the
2599
+ * entry's, and every read checks `e` (openShellFrame), so the extra KV
2600
+ * lifetime is never served, and the shell is readable from every colo, not
2601
+ * only from the capturing colo's L1.
2095
2602
  */
2096
2603
  async putShell(
2097
2604
  key: string,
@@ -2111,6 +2618,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2111
2618
  if (!this.kv && !this.tagPurge && Array.isArray(tags) && tags.length > 0) {
2112
2619
  this.warnShellTagsNoEvictionOnce();
2113
2620
  }
2621
+ // This isolate serves its own new capture from the next read on.
2622
+ cfShellMemo.delete(this.shellMemoKey(key));
2623
+ cfTagHints.remember(this.shellMemoKey(key), tags);
2114
2624
  try {
2115
2625
  const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
2116
2626
  const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
@@ -2130,8 +2640,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2130
2640
  const taggedAt =
2131
2641
  Array.isArray(tags) && tags.length > 0 ? entry.createdAt : undefined;
2132
2642
 
2133
- const writeKv = !!this.kv && retentionTtl >= KV_MIN_EXPIRATION_TTL;
2134
- const kvKey = writeKv ? await this.toKVKey(`shell:${key}`) : null;
2643
+ const writeKv = !!this.kv && retentionTtl > 0;
2644
+ const kvKey = writeKv
2645
+ ? await this.toKVKey(`${SHELL_KEY_PREFIX}${key}`)
2646
+ : null;
2135
2647
 
2136
2648
  const write = (async (): Promise<"stored" | "invalidated" | void> => {
2137
2649
  if (
@@ -2143,12 +2655,14 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2143
2655
  return "invalidated";
2144
2656
  }
2145
2657
 
2146
- const envelope: CFShellEnvelope = {
2147
- // Presence-keyed: a navigationOnly entry has no document half, and an
2148
- // omitted key (vs an explicit undefined) also keeps it out of the
2149
- // serialized JSON.
2150
- ...(entry.prelude !== undefined ? { p: entry.prelude } : {}),
2151
- ...(entry.postponed !== undefined ? { po: entry.postponed } : {}),
2658
+ // A navigationOnly entry stores no document half: no prelude bytes
2659
+ // and no postponed field (an omitted key, not an explicit undefined).
2660
+ const prelude =
2661
+ entry.prelude !== undefined
2662
+ ? base64ToBytes(entry.prelude)
2663
+ : new Uint8Array(0);
2664
+ const snapshotBytes = encodeShellSnapshot(entry.snapshot);
2665
+ const head: ShellFrameHead = {
2152
2666
  rv: entry.reactVersion,
2153
2667
  bv: entry.buildVersion,
2154
2668
  c: entry.createdAt,
@@ -2157,24 +2671,23 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2157
2671
  t: tags,
2158
2672
  ta: taggedAt,
2159
2673
  i: entry.initialTheme,
2160
- sn: entry.snapshot,
2161
2674
  dk: entry.docKey,
2162
- lh: entry.handlerLiveHoles,
2163
- tw: entry.transitionWhen,
2675
+ pr: entry.prunedRecords,
2164
2676
  no: entry.navigationOnly,
2677
+ ...(entry.postponed !== undefined ? { po: entry.postponed } : {}),
2678
+ pl: prelude.length,
2679
+ sl: snapshotBytes?.length ?? 0,
2165
2680
  };
2166
- const body = JSON.stringify(envelope);
2681
+ const frame = encodeShellFrame(head, prelude, snapshotBytes);
2167
2682
  const writes: Promise<boolean>[] = [
2168
2683
  (async () => {
2169
2684
  try {
2170
2685
  const cache = await this.getCache();
2171
2686
  await cache.put(
2172
- this.keyToRequest(`shell:${key}`),
2173
- this.shellEnvelopeResponse(body, envelope),
2687
+ this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`),
2688
+ this.shellFrameResponse(frame, head),
2174
2689
  );
2175
- this.debugShell(key, "l1-stored", {
2176
- expiresAt: envelope.e,
2177
- });
2690
+ this.debugShell(key, "l1-stored", { expiresAt: head.e });
2178
2691
  return true;
2179
2692
  } catch (error) {
2180
2693
  reportCacheError(
@@ -2190,12 +2703,15 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2190
2703
  writes.push(
2191
2704
  (async () => {
2192
2705
  try {
2193
- await this.kv!.put(kvKey, body, {
2194
- expirationTtl: retentionTtl,
2195
- });
2196
- this.debugShell(key, "kv-stored", {
2197
- expiresAt: envelope.e,
2706
+ await this.kv!.put(kvKey, shellFrameToText(frame), {
2707
+ // Whole seconds, at least KV's floor: the frame head keeps
2708
+ // the exact deadlines the reads check.
2709
+ expirationTtl: Math.max(
2710
+ KV_MIN_EXPIRATION_TTL,
2711
+ Math.ceil(retentionTtl),
2712
+ ),
2198
2713
  });
2714
+ this.debugShell(key, "kv-stored", { expiresAt: head.e });
2199
2715
  return true;
2200
2716
  } catch (error) {
2201
2717
  reportCacheError(
@@ -2219,107 +2735,134 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2219
2735
  }
2220
2736
  }
2221
2737
 
2222
- /** Rebuild the public shell entry from its validated storage envelope. */
2223
- private shellEnvelopeToEntry(envelope: CFShellEnvelope): ShellCacheEntry {
2224
- return {
2225
- prelude: envelope.p,
2226
- postponed: envelope.po,
2227
- reactVersion: envelope.rv,
2228
- buildVersion: envelope.bv,
2229
- initialTheme: envelope.i,
2230
- snapshot: envelope.sn,
2231
- docKey: envelope.dk,
2232
- handlerLiveHoles: envelope.lh,
2233
- transitionWhen: envelope.tw,
2234
- navigationOnly: envelope.no,
2235
- createdAt: envelope.c,
2236
- };
2237
- }
2238
-
2239
- /** Build the Cache API representation of the coupled shell envelope. */
2240
- private shellEnvelopeResponse(
2241
- body: string,
2242
- envelope: CFShellEnvelope,
2738
+ /** The Cache API representation of a shell frame. */
2739
+ private shellFrameResponse(
2740
+ frame: Uint8Array<ArrayBuffer>,
2741
+ head: ShellFrameHead,
2243
2742
  ): Response {
2244
- const remainingTtl = Math.max(
2245
- 1,
2246
- Math.floor((envelope.e - Date.now()) / 1000),
2247
- );
2248
- return new Response(body, {
2743
+ const remainingTtl = Math.max(1, Math.floor((head.e - Date.now()) / 1000));
2744
+ return new Response(frame, {
2249
2745
  headers: {
2250
- "Content-Type": "application/json",
2746
+ "Content-Type": "application/octet-stream",
2251
2747
  "Cache-Control": `public, max-age=${remainingTtl}`,
2252
- [CACHE_STALE_AT_HEADER]: String(envelope.s),
2253
- [CACHE_EXPIRES_AT_HEADER]: String(envelope.e),
2748
+ [CACHE_STALE_AT_HEADER]: String(head.s),
2749
+ [CACHE_EXPIRES_AT_HEADER]: String(head.e),
2254
2750
  [CACHE_STATUS_HEADER]: "HIT",
2255
- ...this.tagHeaderEntries(envelope.t, envelope.ta),
2751
+ ...this.tagHeaderEntries(head.t, head.ta),
2256
2752
  },
2257
2753
  });
2258
2754
  }
2259
2755
 
2260
- /** KV shell fallback with corruption checks and background L1 promotion. */
2261
- private async kvGetShell(
2756
+ /**
2757
+ * KV shell fallback: the same prelude-first read, then L1 promotion. After
2758
+ * an L1 attempt, `stats` is that read's and gains the L1 attempt's time
2759
+ * (`l1MissMs`) before its per-tier fields restart for KV.
2760
+ */
2761
+ private async kvReadShellDocument(
2262
2762
  key: string,
2263
- ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
2763
+ snapshotBudget: SnapshotReadBudget,
2764
+ stats?: ShellReadStats,
2765
+ l1StartedAt = 0,
2766
+ l1MissReason?: string,
2767
+ ): Promise<ShellDocumentRead | null> {
2264
2768
  if (!this.kv) return null;
2769
+ if (stats) {
2770
+ stats.l1MissMs = performance.now() - l1StartedAt;
2771
+ stats.l1MissReason = l1MissReason;
2772
+ stats.tier = "kv";
2773
+ stats.matchMs = stats.headMs = stats.preludeMs = undefined;
2774
+ stats.markerMs = stats.markerWaitMs = undefined;
2775
+ stats.headBytes = stats.preludeBytes = stats.tags = undefined;
2776
+ } else {
2777
+ stats = this.shellReadStats("kv");
2778
+ }
2265
2779
  try {
2266
- const readStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
2267
- const kvKey = await this.toKVKey(`shell:${key}`);
2268
- const { value: envelope, timedOut } =
2269
- await this.kvGetOrEvict<CFShellEnvelope>(
2270
- kvKey,
2271
- isShellEnvelope,
2272
- "getShell",
2273
- );
2274
- const readMs = INTERNAL_RANGO_DEBUG
2275
- ? Date.now() - readStartedAt
2276
- : undefined;
2277
- if (timedOut || !envelope) {
2780
+ const readStartedAt = stats ? performance.now() : 0;
2781
+ // One kvReadTimeoutMs budget covers opening the value and reading its
2782
+ // head and prelude; the remainder after the open bounds the frame read.
2783
+ const deadline = Date.now() + this.kvReadTimeoutMs;
2784
+ const kvKey = await this.toKVKey(`${SHELL_KEY_PREFIX}${key}`);
2785
+ // A transient read REJECTION propagates to the catch below (reported
2786
+ // cache-read, the entry left intact); only a malformed frame on a body
2787
+ // that WAS read is corruption that evicts.
2788
+ const { value: raw, timedOut } = await this.readWithTimeout<unknown>(
2789
+ () => this.kv!.get(kvKey, { type: "stream" }),
2790
+ this.kvReadTimeoutMs,
2791
+ "KV read",
2792
+ );
2793
+ if (stats) stats.matchMs = performance.now() - readStartedAt;
2794
+ if (timedOut || raw == null) {
2278
2795
  this.debugShell(key, "kv-miss", {
2279
2796
  reason: timedOut ? "timeout" : "unavailable",
2280
- readMs,
2797
+ stats,
2281
2798
  });
2282
2799
  return null;
2283
2800
  }
2284
-
2285
- const now = Date.now();
2286
- if (now > envelope.e) {
2801
+ // A binding that ignores `type` (a test shim) hands back the stored
2802
+ // string itself.
2803
+ const body =
2804
+ raw instanceof ReadableStream
2805
+ ? (raw as ReadableStream<Uint8Array>)
2806
+ : new Response(raw as BodyInit).body!;
2807
+ const opened = await this.openShellFrame(
2808
+ body,
2809
+ // readWithTimeout treats <= 0 as "no budget": keep a spent budget
2810
+ // spent (1 ms) rather than unbounded.
2811
+ this.kvReadTimeoutMs > 0 ? Math.max(1, deadline - Date.now()) : 0,
2812
+ "KV read",
2813
+ (head) => this.isGloballyInvalidated(head.t, head.ta, true),
2814
+ stats,
2815
+ );
2816
+ if (opened.status === "corrupt") {
2817
+ reportCacheError(
2818
+ opened.error,
2819
+ "cache-corrupt",
2820
+ "[CFCacheStore] getShell: malformed shell frame in KV, evicting",
2821
+ );
2822
+ this.scheduleKvEvict(kvKey, "getShell");
2287
2823
  this.debugShell(key, "kv-miss", {
2288
- reason: "expired",
2289
- readMs,
2290
- expiresAt: envelope.e,
2824
+ reason: "malformed",
2825
+ stats,
2291
2826
  });
2292
2827
  return null;
2293
2828
  }
2294
- const markerStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
2295
- const invalidated = await this.isGloballyInvalidated(
2296
- envelope.t,
2297
- envelope.ta,
2298
- );
2299
- const markerMs = INTERNAL_RANGO_DEBUG
2300
- ? Date.now() - markerStartedAt
2301
- : undefined;
2302
- if (invalidated) {
2829
+ if (opened.status === "timeout" || opened.status === "expired") {
2830
+ this.debugShell(key, "kv-miss", {
2831
+ reason: opened.status,
2832
+ stats,
2833
+ expiresAt: opened.head?.e,
2834
+ });
2835
+ return null;
2836
+ }
2837
+ if (opened.status === "invalidated") {
2303
2838
  this.debugShell(key, "marker-invalidated", {
2304
2839
  tier: "kv",
2305
- readMs,
2306
- markerMs,
2840
+ stats,
2307
2841
  });
2308
2842
  return null;
2309
2843
  }
2310
-
2311
- const shouldRevalidate = envelope.s > 0 && now > envelope.s;
2312
- this.debugShell(key, "kv-hit", {
2313
- freshness: shouldRevalidate ? "stale" : "fresh",
2314
- readMs,
2315
- markerMs,
2316
- expiresAt: envelope.e,
2317
- });
2318
- this.promoteShellToL1(key, envelope);
2319
- return {
2320
- entry: this.shellEnvelopeToEntry(envelope),
2321
- shouldRevalidate,
2322
- };
2844
+ const read = this.shellDocumentRead(
2845
+ key,
2846
+ opened,
2847
+ snapshotBudget,
2848
+ stats,
2849
+ undefined,
2850
+ (snapshotBytes) =>
2851
+ this.promoteShellToL1(
2852
+ key,
2853
+ opened.head,
2854
+ opened.prelude,
2855
+ snapshotBytes,
2856
+ ),
2857
+ );
2858
+ if (INTERNAL_RANGO_DEBUG) {
2859
+ this.debugShell(key, "kv-hit", {
2860
+ freshness: read.shouldRevalidate ? "stale" : "fresh",
2861
+ stats,
2862
+ expiresAt: opened.head.e,
2863
+ });
2864
+ }
2865
+ return read;
2323
2866
  } catch (error) {
2324
2867
  reportCacheError(error, "cache-read", "[CFCacheStore] kvGetShell");
2325
2868
  this.debugShell(key, "kv-miss", { reason: "error" });
@@ -2328,24 +2871,28 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2328
2871
  }
2329
2872
 
2330
2873
  /** Promote a valid KV shell into the per-colo Cache API tier. */
2331
- private promoteShellToL1(key: string, envelope: CFShellEnvelope): void {
2874
+ private promoteShellToL1(
2875
+ key: string,
2876
+ head: ShellFrameHead,
2877
+ prelude: Uint8Array,
2878
+ snapshotBytes: Uint8Array,
2879
+ ): void {
2332
2880
  if (!this.waitUntil) return;
2333
2881
  this.waitUntil(() =>
2334
2882
  reportingAsync(
2335
2883
  async () => {
2336
- if (Date.now() > envelope.e) return;
2884
+ if (Date.now() > head.e) return;
2337
2885
  const cache = await this.getCache();
2338
- const body = JSON.stringify(envelope);
2339
2886
  await cache.put(
2340
- this.keyToRequest(`shell:${key}`),
2341
- this.shellEnvelopeResponse(body, envelope),
2887
+ this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`),
2888
+ this.shellFrameResponse(
2889
+ encodeShellFrame(head, prelude, snapshotBytes),
2890
+ head,
2891
+ ),
2342
2892
  );
2343
2893
  this.debugShell(key, "kv-promoted", {
2344
- remainingTtl: Math.max(
2345
- 1,
2346
- Math.floor((envelope.e - Date.now()) / 1000),
2347
- ),
2348
- expiresAt: envelope.e,
2894
+ remainingTtl: Math.max(1, Math.floor((head.e - Date.now()) / 1000)),
2895
+ expiresAt: head.e,
2349
2896
  });
2350
2897
  },
2351
2898
  "cache-write",
@@ -2692,6 +3239,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2692
3239
  private async isGloballyInvalidated(
2693
3240
  tags: string[] | undefined,
2694
3241
  taggedAt: number | undefined,
3242
+ isolateMemo = false,
2695
3243
  ): Promise<boolean> {
2696
3244
  // Array.isArray (not just truthiness): a non-array tags value - direct store
2697
3245
  // misuse like setItem(k, v, { tags: "products" }), or a skewed KV envelope -
@@ -2700,12 +3248,21 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2700
3248
  if (!this.kv || !Array.isArray(tags) || tags.length === 0 || !taggedAt)
2701
3249
  return false;
2702
3250
  const ctx = _getRequestContext();
3251
+ // This request's own invalidations first (#973): no marker read needed.
3252
+ if (maskedForRequest(ctx, this, tags, taggedAt)) return true;
2703
3253
  const memo = ctx ? getTagMarkerMemo(ctx, this) : undefined;
2704
3254
  const inflight = ctx ? getTagMarkerInflight(ctx, this) : undefined;
2705
3255
  try {
2706
3256
  const markers = await Promise.all(
2707
- tags.map((tag) => this.readTagMarker(tag, memo, inflight)),
3257
+ tags.map((tag) =>
3258
+ isolateMemo
3259
+ ? this.readShellTagMarker(tag)
3260
+ : this.readTagMarker(tag, memo, inflight),
3261
+ ),
2708
3262
  );
3263
+ // A read in flight when this request invalidated one of the tags (a
3264
+ // sibling segment's updateTag()) resolved to the marker before it.
3265
+ if (maskedForRequest(ctx, this, tags, taggedAt)) return true;
2709
3266
  for (const marker of markers) {
2710
3267
  if (marker != null && marker >= taggedAt) return true;
2711
3268
  }
@@ -2725,15 +3282,16 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2725
3282
  * configured), invalidateTags() evicts L1 entries via a Cloudflare
2726
3283
  * purge-by-tag call, so a hit that SURVIVED is trusted without a per-read
2727
3284
  * marker lookup — that skipped lookup is the entire point of purge mode.
2728
- * Only the per-request memo is consulted (synchronous, no KV read) so a
2729
- * request that ran updateTag() still masks its own entries during the purge
2730
- * propagation window (read-your-own-writes).
3285
+ * Only the request's mask and its memo are consulted (synchronous, no KV
3286
+ * read) so a request that ran updateTag() still misses its own entries
3287
+ * during the purge propagation window (read-your-own-writes).
2731
3288
  *
2732
3289
  * The trust is conditional on the entry actually CARRYING this store's
2733
3290
  * entry Cache-Tag tokens (`headers`): an entry a purge cannot reach — one
2734
3291
  * written before the tokens existed, or whose tag set overflowed the
2735
3292
  * Cache-Tag header limit — keeps the full marker check, or purge mode
2736
- * would serve it stale until TTL with no eviction path.
3293
+ * would serve it stale until TTL with no eviction path. KV-less there are
3294
+ * no markers, so it gets the per-request mask check like any hit (#973).
2737
3295
  *
2738
3296
  * Without tagPurge this is the full marker cascade. KV-tier reads and
2739
3297
  * shells always use isGloballyInvalidated directly: purge cannot reach KV,
@@ -2747,11 +3305,13 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2747
3305
  ): Promise<boolean> {
2748
3306
  if (!this.tagPurge) return this.isGloballyInvalidated(tags, taggedAt);
2749
3307
  if (!Array.isArray(tags) || tags.length === 0 || !taggedAt) return false;
2750
- if (!this.hasEntryCacheTags(headers)) {
3308
+ if (this.kv && !this.hasEntryCacheTags(headers)) {
2751
3309
  return this.isGloballyInvalidated(tags, taggedAt);
2752
3310
  }
2753
3311
  const ctx = _getRequestContext();
2754
3312
  if (!ctx) return false;
3313
+ if (maskedForRequest(ctx, this, tags, taggedAt)) return true;
3314
+ // Markers this request read from KV for other entries.
2755
3315
  const memo = getTagMarkerMemo(ctx, this);
2756
3316
  for (const tag of tags) {
2757
3317
  const marker = memo.get(tag);
@@ -2768,9 +3328,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2768
3328
  /**
2769
3329
  * Read a tag's latest invalidation timestamp (or null if never invalidated)
2770
3330
  * through the cascade: per-request memo -> per-colo L1 cache (only when
2771
- * tagCacheTtl > 0) -> KV (the global truth). The memo is always consulted
2772
- * first so it stays authoritative within a request (read-your-own-writes),
2773
- * and every KV/L1 result is written back into the memo. A Cache API miss
3331
+ * tagCacheTtl > 0) -> KV (the global truth). Every KV/L1 result is written
3332
+ * back into the memo, so the request reads one value per tag. A Cache API miss
2774
3333
  * always falls through to KV; absence is represented by a cached sentinel,
2775
3334
  * never by a miss.
2776
3335
  *
@@ -2803,6 +3362,52 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2803
3362
  return this.fetchTagMarker(tag, memo);
2804
3363
  }
2805
3364
 
3365
+ /**
3366
+ * A PPR shell read's marker for `tag`: this request's own store read if it
3367
+ * has one, else through the per-isolate marker memo (isolate-tag-memo.ts,
3368
+ * stale-while-revalidate
3369
+ * under `markerFreshMs` / `markerMaxStaleMs`; a request carrying the
3370
+ * fresh-reads cookie reads L1/KV). The value is kept in the request's
3371
+ * shell-read record, never in the per-request memo the data families read,
3372
+ * HIT or MISS. A store read still lands in the per-request memo, as any
3373
+ * marker read does; it is not stale. A timed-out read fails open for this
3374
+ * request and is not memoized.
3375
+ */
3376
+ private readShellTagMarker(tag: string): Promise<number | null> {
3377
+ const ctx = _getRequestContext();
3378
+ const memo = ctx ? getTagMarkerMemo(ctx, this) : undefined;
3379
+ if (memo?.has(tag)) return Promise.resolve(memo.get(tag) ?? null);
3380
+ const shell = ctx ? getShellMarkerReads(ctx, this) : undefined;
3381
+ const pending = shell?.reads.get(tag);
3382
+ if (pending) return pending;
3383
+ const read = cfMarkerMemo.readThrough(
3384
+ this.markerMemoKey(tag),
3385
+ async (background) => {
3386
+ const outcome = { timedOut: false, masked: false };
3387
+ const value = await this.fetchTagMarker(
3388
+ tag,
3389
+ background ? undefined : memo,
3390
+ outcome,
3391
+ );
3392
+ return { value, memoize: !outcome.timedOut && !outcome.masked };
3393
+ },
3394
+ {
3395
+ freshMs: this.memo.markerFreshMs,
3396
+ maxStaleMs: this.memo.markerMaxStaleMs,
3397
+ bypass: freshReadsRequired(ctx),
3398
+ keepAlive: this.waitUntil
3399
+ ? (refresh) => this.waitUntil!(() => refresh)
3400
+ : undefined,
3401
+ onOutcome:
3402
+ shell && (INTERNAL_RANGO_DEBUG || ctx?._metricsStore)
3403
+ ? (outcome) => shell.outcomes.set(tag, outcome)
3404
+ : undefined,
3405
+ },
3406
+ );
3407
+ shell?.reads.set(tag, read);
3408
+ return read;
3409
+ }
3410
+
2806
3411
  /**
2807
3412
  * Uncached body of readTagMarker: L1 (per-colo Cache API, opt-in via
2808
3413
  * tagCacheTtl) -> KV. Writes the resolved value back into the memo.
@@ -2811,19 +3416,18 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2811
3416
  private async fetchTagMarker(
2812
3417
  tag: string,
2813
3418
  memo: Map<string, number | null> | undefined,
3419
+ read?: { timedOut: boolean; masked?: boolean },
3420
+ /** false: populate no L1 marker (isInvalidatedSincePastMemo). */
3421
+ publish = true,
2814
3422
  ): Promise<number | null> {
2815
- // Write the resolved marker into the memo WITHOUT clobbering a value a
2816
- // concurrent invalidateTags() wrote during our await. The router resolves
2817
- // sibling slots in parallel, so a slot's updateTag() can land the
2818
- // authoritative invalidatedAt into the memo while this read is still in
2819
- // flight; overwriting it with our (pre-invalidation) read result would break
2820
- // read-your-own-writes for the rest of the request. If the tag was memoized
2821
- // mid-read, that value wins and is returned. Without a memo, the read result
2822
- // stands as-is.
2823
- const memoize = (read: number | null): number | null => {
3423
+ // Write the resolved marker into the memo WITHOUT clobbering a value
3424
+ // another read of the tag memoized during our await: that value wins and
3425
+ // is returned, so the request keeps one value per tag. Without a memo,
3426
+ // the read result stands.
3427
+ const memoize = (value: number | null): number | null => {
2824
3428
  if (memo && memo.has(tag)) return memo.get(tag) ?? null;
2825
- memo?.set(tag, read);
2826
- return read;
3429
+ memo?.set(tag, value);
3430
+ return value;
2827
3431
  };
2828
3432
 
2829
3433
  // L1 (per-colo) marker cache - opt-in via tagCacheTtl. Bounded by the same
@@ -2871,6 +3475,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2871
3475
  "tag marker KV read",
2872
3476
  );
2873
3477
  if (timedOut) {
3478
+ if (read) read.timedOut = true;
2874
3479
  // Memoize the fail-open result so the rest of this request is consistent
2875
3480
  // (and does not re-pay the timeout per segment sharing the tag).
2876
3481
  return memoize(null);
@@ -2878,10 +3483,17 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2878
3483
  const value = raw != null ? Number(raw) : null;
2879
3484
  const resolved = memoize(value);
2880
3485
 
2881
- // Populate L1 for subsequent reads in this colo (non-blocking). Use the
2882
- // resolved (memo-aware) value so a marker invalidated mid-read is not
2883
- // re-cached stale into this colo's L1.
2884
- if (this.tagCacheTtl > 0) {
3486
+ // A value read while this request invalidates the tag may predate the
3487
+ // invalidation's KV put: an L1 put of it could land after the put's own
3488
+ // write-through and hide the invalidation from the colo. The request
3489
+ // itself answers from its mask (#973). A shell read also skips the isolate
3490
+ // marker memo through `read.masked`; that memo keeps the higher value, so
3491
+ // the skip is conservative there.
3492
+ const masked = maskedForRequest(_getRequestContext(), this, [tag], 0);
3493
+ if (read) read.masked = masked;
3494
+
3495
+ // Populate L1 for subsequent reads in this colo (non-blocking).
3496
+ if (this.tagCacheTtl > 0 && !masked && publish) {
2885
3497
  const put = () => this.putTagMarkerL1(tag, resolved);
2886
3498
  if (this.waitUntil) this.waitUntil(put);
2887
3499
  else void put();
@@ -3057,49 +3669,163 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3057
3669
  }
3058
3670
 
3059
3671
  /**
3060
- * Invalidate every entry tagged with any of `tags`. Receives the whole batch
3061
- * from one updateTag()/revalidateTag() call so the eager-purge hook fires
3062
- * ONCE (one CDN purge request, not one per tag). For each tag: records the KV
3063
- * marker (the durable cross-colo truth that reads compare taggedAt against),
3064
- * writes the fresh marker straight into this colo's L1 (write-through, NOT
3065
- * delete - a delete would let the next read re-read a not-yet-converged KV
3066
- * value and re-arm the stale window), and memoizes it for same-request
3067
- * read-your-own-writes. In purge mode (tagPurge) it then AWAITS the
3068
- * consumer's purge-by-tag call with the entry Cache-Tags — the eviction the
3069
- * per-read marker skip on L1 hits relies on. Finally fires onRevalidateTag
3070
- * with the namespaced lookup Cache-Tags so a consumer purge evicts the
3071
- * cached lookups in other colos promptly (otherwise they converge within
3072
- * tagCacheTtl).
3073
- *
3074
- * Durable-write integrity: the in-memory write-through (memo + L1) for a tag
3075
- * runs ONLY after that tag's KV marker write is confirmed. If any KV write
3076
- * fails (transient error; over-limit keys are normalized by toKVKey rather
3077
- * than rejected), this rejects with the
3078
- * failed tags so an awaiting updateTag() surfaces the failure instead of
3079
- * silently reporting success while other requests/colos serve stale data. The
3080
- * eager purge still fires for the whole batch first (it is additive).
3081
- */
3082
- /**
3083
- * Shell tag-generation gate (SegmentCacheStore.isTagsInvalidatedSince): the
3084
- * SAME KV markers used by runtime envelopes also evict immutable build shells
3085
- * and captures whose write races updateTag(). Thin public wrapper over the
3086
- * shell generation check (marker >= since, fail open); KV-less it degrades
3087
- * to the per-request memo in purge mode and to false otherwise — see
3672
+ * SegmentCacheStore.isTagsInvalidatedSince: the SAME KV markers used by
3673
+ * runtime envelopes also evict immutable build shells, and gate the writes
3674
+ * of cache executions that started before an invalidation (#977,
3675
+ * tag-invalidation.ts predatesInvalidation). Marker >= since. A marker read
3676
+ * that fails or times out answers false (fail open), or true for the write
3677
+ * gate (`failClosed`). With KV each marker is read past the per-request
3678
+ * memo (isInvalidatedSincePastMemo); KV-less it degrades to the per-request
3679
+ * mask in purge mode and to false otherwise — see
3088
3680
  * isShellGenerationInvalidated.
3089
3681
  */
3090
3682
  async isTagsInvalidatedSince(
3091
3683
  tags: string[],
3092
3684
  sinceMs: number,
3685
+ options?: { failClosed?: boolean },
3093
3686
  ): Promise<boolean> {
3094
- return this.isShellGenerationInvalidated(tags, sinceMs);
3687
+ if (!this.kv) return this.isShellGenerationInvalidated(tags, sinceMs);
3688
+ return this.isInvalidatedSincePastMemo(
3689
+ tags,
3690
+ sinceMs,
3691
+ options?.failClosed === true,
3692
+ );
3095
3693
  }
3096
3694
 
3695
+ /**
3696
+ * isTagsInvalidatedSince with KV, never answered from a marker value this
3697
+ * request read before the asking gate. A write gate asks after an
3698
+ * execution that can run for seconds, and the per-request memo can hold
3699
+ * the marker the request read before it (a stale hit reads its entry's
3700
+ * markers, then its refresh runs): answered from it, another isolate's
3701
+ * invalidation in between let the refresh write.
3702
+ *
3703
+ * - This request's own invalidations answer from its mask, checked again
3704
+ * once the reads are back: a read in flight when the request invalidated
3705
+ * one of the tags resolved to the marker before it.
3706
+ * - Each tag is read through gateMarkerRead (request-tag-mask.ts): gates
3707
+ * asking while a read is in flight share it; a settled read is not
3708
+ * reused.
3709
+ * - The read returns its own value from L1 (tagCacheTtl) or KV, not one an
3710
+ * older read of the tag memoized while it was in flight, and fills the
3711
+ * memo only when it holds nothing, so a build shell's check still serves
3712
+ * the request's later reads. A timed-out read fills nothing.
3713
+ * - It publishes to L1 unless the request masked the tag
3714
+ * (fetchTagMarker), and never without a request context (a detached
3715
+ * waitUntil task), where a mask cannot be told apart.
3716
+ * - A read that fails or times out answers `failClosed`: the write gate
3717
+ * counts it as an invalidation, other callers as none.
3718
+ * @internal
3719
+ */
3720
+ private async isInvalidatedSincePastMemo(
3721
+ tags: string[],
3722
+ since: number,
3723
+ failClosed: boolean,
3724
+ ): Promise<boolean> {
3725
+ if (!Array.isArray(tags) || tags.length === 0 || !since) return false;
3726
+ const ctx = _getRequestContext();
3727
+ if (maskedForRequest(ctx, this, tags, since)) return true;
3728
+ const memo = ctx ? getTagMarkerMemo(ctx, this) : undefined;
3729
+ try {
3730
+ const reads = await Promise.all(
3731
+ tags.map((tag) =>
3732
+ gateMarkerRead(ctx, this, tag, since, async () => {
3733
+ const outcome = { timedOut: false };
3734
+ const marker = await this.fetchTagMarker(
3735
+ tag,
3736
+ undefined,
3737
+ outcome,
3738
+ ctx !== undefined,
3739
+ );
3740
+ if (outcome.timedOut) return { marker, answered: false };
3741
+ if (memo && !memo.has(tag)) memo.set(tag, marker);
3742
+ return { marker, answered: true };
3743
+ }),
3744
+ ),
3745
+ );
3746
+ if (maskedForRequest(ctx, this, tags, since)) return true;
3747
+ return reads.some(
3748
+ ({ marker, answered }) =>
3749
+ (!answered && failClosed) || (marker != null && marker >= since),
3750
+ );
3751
+ } catch (error) {
3752
+ reportCacheError(
3753
+ error,
3754
+ "cache-read",
3755
+ "[CFCacheStore] tag invalidation check",
3756
+ );
3757
+ return failClosed;
3758
+ }
3759
+ }
3760
+
3761
+ /**
3762
+ * Invalidate every entry tagged with any of `tags`. Receives the whole batch
3763
+ * from one updateTag()/revalidateTag() call so the eager-purge hook fires
3764
+ * ONCE (one CDN purge request, not one per tag). Before its first await it
3765
+ * masks the tags for the rest of the request (maskTagsForRequest), which is
3766
+ * all revalidateTag() waits for. For each tag it then records the KV marker
3767
+ * (the durable cross-colo truth that reads compare taggedAt against) and
3768
+ * writes the fresh marker straight into this colo's L1 (write-through, NOT
3769
+ * delete - a delete would let the next read re-read a not-yet-converged KV
3770
+ * value and re-arm the stale window). In purge mode (tagPurge) it then
3771
+ * AWAITS the consumer's purge-by-tag call with the entry Cache-Tags — the
3772
+ * eviction the per-read marker skip on L1 hits relies on. Finally fires
3773
+ * onRevalidateTag with the namespaced lookup Cache-Tags so a consumer purge
3774
+ * evicts the cached lookups in other colos promptly (otherwise they converge
3775
+ * within tagCacheTtl).
3776
+ *
3777
+ * Durable-write integrity: the write-through other requests read (the
3778
+ * isolate marker memo and L1) for a tag runs ONLY after that tag's KV marker
3779
+ * write is confirmed. If any KV write fails (transient error; over-limit
3780
+ * keys are normalized by toKVKey rather than rejected), this rejects with
3781
+ * the failed tags so an awaiting updateTag() surfaces the failure instead of
3782
+ * silently reporting success while other requests/colos serve stale data.
3783
+ * The purge and onRevalidateTag still fire for the whole batch once the
3784
+ * puts settle, whether or not one failed (they are additive); the rejection
3785
+ * follows them.
3786
+ */
3097
3787
  async invalidateTags(tags: string[]): Promise<void> {
3098
3788
  if (tags.length === 0) return;
3099
3789
  const invalidatedAt = Date.now();
3790
+ this.maskTagsForRequest(tags, invalidatedAt);
3791
+ // The invalidating isolate drops its memoized shells for these tags at
3792
+ // once; with KV every isolate's per-read marker check rejects the rest.
3793
+ // Reads already in flight must not memoize them again while the markers
3794
+ // and the purge are written (KV-less purge mode has no marker to reject
3795
+ // that copy): the record holds them out until one window after this
3796
+ // settles, at least the default window so a store with the memo off still
3797
+ // covers another store's reads in this isolate.
3798
+ cfShellMemo.deleteWhere((shell) => shellHasAnyTag(shell.head.t, tags));
3799
+ recentShellInvalidations.begin(tags, invalidatedAt);
3800
+ try {
3801
+ await this.writeTagInvalidation(tags, invalidatedAt);
3802
+ } finally {
3803
+ recentShellInvalidations.settle(
3804
+ tags,
3805
+ Math.max(this.memo.shellMs, DEFAULT_SHELL_MEMO_MS),
3806
+ );
3807
+ }
3808
+ }
3809
+
3810
+ /**
3811
+ * The request-local half of invalidateTags(), run before its first await
3812
+ * (#973): the request masks the tags (request-tag-mask.ts), so every later
3813
+ * read in the request (data families, purge-mode L1 hits, shell reads)
3814
+ * treats them as invalidated at `invalidatedAt` without waiting for the KV
3815
+ * put or the purge that revalidateTag() leaves to waitUntil. Skipped where
3816
+ * no read consults the mask (neither KV nor tagPurge).
3817
+ */
3818
+ private maskTagsForRequest(tags: string[], invalidatedAt: number): void {
3819
+ if (!this.kv && !this.tagPurge) return;
3100
3820
  const ctx = _getRequestContext();
3101
- const memo = ctx ? getTagMarkerMemo(ctx, this) : undefined;
3821
+ if (ctx) maskRequestTags(ctx, this, tags, invalidatedAt);
3822
+ }
3102
3823
 
3824
+ /** invalidateTags' marker writes, tag purge, and hooks. */
3825
+ private async writeTagInvalidation(
3826
+ tags: string[],
3827
+ invalidatedAt: number,
3828
+ ): Promise<void> {
3103
3829
  if (!this.kv && !this.onRevalidateTag && !this.tagPurge) {
3104
3830
  console.warn(
3105
3831
  `[CFCacheStore] invalidateTags had no effect: configure a KV namespace ` +
@@ -3110,6 +3836,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3110
3836
 
3111
3837
  const failedTags = new Set<string>();
3112
3838
  const errors: unknown[] = [];
3839
+ const lookupMarkerCacheActive = Boolean(this.kv) && this.tagCacheTtl > 0;
3113
3840
  if (this.kv) {
3114
3841
  // Markers written with no expiry (tagInvalidationTtl unset) never expire,
3115
3842
  // so high-cardinality tags accumulate KV keys unboundedly with no reaper.
@@ -3145,25 +3872,16 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3145
3872
  }
3146
3873
  }),
3147
3874
  );
3148
- }
3149
3875
 
3150
- // Write-through memo + L1 only for tags with a confirmed durable marker, and
3151
- // only when KV is configured. Markers are read exclusively through
3152
- // isGloballyInvalidated(), which short-circuits to "not invalidated" when
3153
- // !this.kv; writing memo/L1 markers without KV would be dead state no read
3154
- // path ever consults — EXCEPT the memo in purge mode: isL1Invalidated()
3155
- // consults it (and only it) on every L1 hit, so a KV-less purge-mode store
3156
- // still writes the memo for same-request read-your-own-writes. The
3157
- // onRevalidateTag purge below still fires regardless (it is additive and
3158
- // external to the marker cascade). The memo write is synchronous
3159
- // (read-your-own-writes); the L1 Cache API writes are independent, so fan
3160
- // them out in parallel rather than awaiting each.
3161
- const lookupMarkerCacheActive = Boolean(this.kv) && this.tagCacheTtl > 0;
3162
- if (this.kv || this.tagPurge) {
3876
+ // Write-through (isolate marker memo + L1) only for tags with a
3877
+ // confirmed durable marker; this request's own mask was set up front
3878
+ // (maskTagsForRequest). The L1 Cache API writes are independent,
3879
+ // so fan them out in parallel rather than awaiting each.
3163
3880
  const l1Writes: Promise<void>[] = [];
3164
3881
  for (const tag of tags) {
3165
3882
  if (failedTags.has(tag)) continue;
3166
- memo?.set(tag, invalidatedAt);
3883
+ // Same isolate: later requests here see the invalidation at once.
3884
+ cfMarkerMemo.store(this.markerMemoKey(tag), invalidatedAt);
3167
3885
  if (lookupMarkerCacheActive) {
3168
3886
  l1Writes.push(
3169
3887
  this.putTagMarkerL1(tag, invalidatedAt, { critical: true }),