@rangojs/router 0.17.0 → 0.18.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 (152) hide show
  1. package/README.md +3 -2
  2. package/dist/types/browser/cookie-name.d.ts +6 -0
  3. package/dist/types/browser/history-state.d.ts +16 -0
  4. package/dist/types/cache/cache-key-utils.d.ts +17 -2
  5. package/dist/types/cache/cache-policy.d.ts +17 -0
  6. package/dist/types/cache/cache-scope.d.ts +98 -14
  7. package/dist/types/cache/cache-tag.d.ts +92 -7
  8. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  9. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  10. package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
  11. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  12. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  13. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
  14. package/dist/types/cache/handle-snapshot.d.ts +4 -3
  15. package/dist/types/cache/index.d.ts +1 -0
  16. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  17. package/dist/types/cache/shell-memo.d.ts +178 -0
  18. package/dist/types/cache/shell-snapshot.d.ts +110 -21
  19. package/dist/types/cache/tag-invalidation.d.ts +30 -3
  20. package/dist/types/cache/types.d.ts +207 -43
  21. package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
  22. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  23. package/dist/types/router/instrument.d.ts +8 -0
  24. package/dist/types/router/middleware-types.d.ts +6 -1
  25. package/dist/types/router/segment-resolution/loader-cache.d.ts +40 -11
  26. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +21 -10
  27. package/dist/types/router/segment-resolution/mask-nested.d.ts +65 -24
  28. package/dist/types/rsc/full-payload.d.ts +14 -0
  29. package/dist/types/rsc/handler.d.ts +1 -1
  30. package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
  31. package/dist/types/rsc/shell-capture-constants.d.ts +53 -6
  32. package/dist/types/rsc/shell-capture.d.ts +146 -15
  33. package/dist/types/rsc/shell-serve.d.ts +140 -17
  34. package/dist/types/rsc/types.d.ts +7 -0
  35. package/dist/types/server/context.d.ts +109 -15
  36. package/dist/types/server/cookie-store.d.ts +21 -0
  37. package/dist/types/server/request-context.d.ts +159 -33
  38. package/dist/types/ssr/index.d.ts +9 -0
  39. package/dist/types/ssr/ssr-root.d.ts +9 -8
  40. package/dist/types/testing/flight.entry.d.ts +7 -0
  41. package/dist/types/testing/run-loader.d.ts +18 -3
  42. package/dist/types/testing/serve-shell-request.d.ts +132 -0
  43. package/dist/types/testing/shell-status.d.ts +23 -10
  44. package/dist/types/types/handler-context.d.ts +14 -1
  45. package/dist/types/urls/pattern-types.d.ts +22 -18
  46. package/dist/types/vite/router-discovery.d.ts +29 -2
  47. package/dist/vite/index.js +46 -9
  48. package/package.json +1 -1
  49. package/skills/breadcrumbs/SKILL.md +17 -7
  50. package/skills/cache-guide/SKILL.md +123 -51
  51. package/skills/caching/SKILL.md +253 -23
  52. package/skills/catalog.json +1 -1
  53. package/skills/client-urls/SKILL.md +11 -6
  54. package/skills/cloudflare/SKILL.md +15 -0
  55. package/skills/comparison/references/framework-comparison.md +44 -32
  56. package/skills/defer-hydration/SKILL.md +50 -30
  57. package/skills/deployment-caching/SKILL.md +5 -2
  58. package/skills/document-cache/SKILL.md +8 -2
  59. package/skills/handler-use/SKILL.md +2 -0
  60. package/skills/hooks/handle-and-actions.md +3 -1
  61. package/skills/hooks/state.md +3 -1
  62. package/skills/loader/SKILL.md +161 -39
  63. package/skills/middleware/SKILL.md +16 -9
  64. package/skills/migrate-nextjs/SKILL.md +67 -36
  65. package/skills/migrate-react-router/SKILL.md +2 -1
  66. package/skills/migrate-react-router/component-migration.md +3 -1
  67. package/skills/migrate-react-router/data-and-actions.md +3 -1
  68. package/skills/migrate-react-router/route-mapping.md +7 -4
  69. package/skills/observability/SKILL.md +64 -0
  70. package/skills/parallel/SKILL.md +13 -8
  71. package/skills/ppr/SKILL.md +655 -363
  72. package/skills/prerender/SKILL.md +27 -14
  73. package/skills/rango/SKILL.md +52 -33
  74. package/skills/route/SKILL.md +12 -7
  75. package/skills/router-setup/SKILL.md +4 -2
  76. package/skills/scripts/SKILL.md +8 -1
  77. package/skills/server-actions/SKILL.md +11 -0
  78. package/skills/shell-manifest/SKILL.md +21 -12
  79. package/skills/testing/SKILL.md +25 -21
  80. package/skills/testing/cache-prerender.md +15 -14
  81. package/skills/testing/loader.md +18 -1
  82. package/skills/testing/serve-shell-request.md +188 -0
  83. package/skills/theme/SKILL.md +52 -4
  84. package/skills/typesafety/env-and-bindings.md +10 -2
  85. package/skills/use-cache/SKILL.md +39 -16
  86. package/skills/vercel/SKILL.md +14 -2
  87. package/skills/view-transitions/SKILL.md +1 -1
  88. package/src/browser/cookie-name.ts +23 -0
  89. package/src/browser/history-state.ts +23 -0
  90. package/src/browser/rsc-router.tsx +5 -0
  91. package/src/cache/cache-key-utils.ts +22 -2
  92. package/src/cache/cache-policy.ts +22 -0
  93. package/src/cache/cache-runtime.ts +34 -7
  94. package/src/cache/cache-scope.ts +345 -33
  95. package/src/cache/cache-tag.ts +375 -11
  96. package/src/cache/cf/cf-base64.ts +36 -4
  97. package/src/cache/cf/cf-cache-constants.ts +12 -0
  98. package/src/cache/cf/cf-cache-store.ts +990 -348
  99. package/src/cache/cf/cf-cache-types.ts +32 -4
  100. package/src/cache/cf/cf-shell-frame.ts +258 -0
  101. package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
  102. package/src/cache/handle-snapshot.ts +4 -3
  103. package/src/cache/index.ts +2 -0
  104. package/src/cache/isolate-tag-memo.ts +308 -0
  105. package/src/cache/shell-memo.ts +341 -0
  106. package/src/cache/shell-snapshot.ts +217 -27
  107. package/src/cache/tag-invalidation.ts +104 -19
  108. package/src/cache/types.ts +211 -43
  109. package/src/cache/vercel/vercel-cache-store.ts +545 -61
  110. package/src/prerender/build-shell-capture.ts +65 -11
  111. package/src/route-definition/dsl-helpers.ts +4 -3
  112. package/src/router/handler-context.ts +38 -4
  113. package/src/router/instrument.ts +54 -2
  114. package/src/router/intercept-resolution.ts +19 -7
  115. package/src/router/loader-resolution.ts +63 -32
  116. package/src/router/match-api.ts +28 -9
  117. package/src/router/match-handlers.ts +5 -0
  118. package/src/router/match-middleware/cache-lookup.ts +17 -0
  119. package/src/router/match-middleware/cache-store.ts +31 -11
  120. package/src/router/middleware-types.ts +6 -1
  121. package/src/router/middleware.ts +23 -4
  122. package/src/router/prerender-match.ts +6 -0
  123. package/src/router/segment-resolution/fresh.ts +18 -9
  124. package/src/router/segment-resolution/helpers.ts +4 -2
  125. package/src/router/segment-resolution/loader-cache.ts +227 -56
  126. package/src/router/segment-resolution/loader-snapshot.ts +203 -51
  127. package/src/router/segment-resolution/mask-nested.ts +136 -42
  128. package/src/router/segment-resolution/revalidation.ts +27 -10
  129. package/src/rsc/full-payload.ts +25 -1
  130. package/src/rsc/handler.ts +14 -2
  131. package/src/rsc/progressive-enhancement.ts +3 -2
  132. package/src/rsc/response-cache-serve.ts +9 -6
  133. package/src/rsc/rsc-rendering.ts +601 -181
  134. package/src/rsc/shell-build-manifest.ts +27 -9
  135. package/src/rsc/shell-capture-constants.ts +60 -6
  136. package/src/rsc/shell-capture.ts +1001 -250
  137. package/src/rsc/shell-serve.ts +316 -34
  138. package/src/rsc/types.ts +7 -0
  139. package/src/server/context.ts +219 -28
  140. package/src/server/cookie-store.ts +87 -62
  141. package/src/server/request-context.ts +263 -38
  142. package/src/ssr/index.tsx +96 -37
  143. package/src/ssr/ssr-root.tsx +9 -8
  144. package/src/testing/flight.entry.ts +14 -0
  145. package/src/testing/run-loader.ts +63 -5
  146. package/src/testing/serve-shell-request.ts +378 -0
  147. package/src/testing/shell-status.ts +34 -12
  148. package/src/theme/ThemeProvider.tsx +7 -6
  149. package/src/types/handler-context.ts +14 -1
  150. package/src/urls/pattern-types.ts +22 -18
  151. package/src/vite/discovery/shell-prerender-phase.ts +9 -0
  152. package/src/vite/router-discovery.ts +14 -4
@@ -35,6 +35,11 @@ import type {
35
35
  CacheItemResult,
36
36
  CacheItemOptions,
37
37
  ShellCacheEntry,
38
+ ShellDocumentRead,
39
+ ShellDocumentReadOptions,
40
+ ShellReadStats,
41
+ ShellSnapshotFailure,
42
+ ShellSnapshotRecord,
38
43
  CacheReadError,
39
44
  } from "../types.js";
40
45
  import { CACHE_READ_ERROR } from "../types.js";
@@ -55,7 +60,30 @@ import {
55
60
  } from "../cache-policy.js";
56
61
  import { reportCacheError, reportingAsync } from "../cache-error.js";
57
62
  import type { CacheErrorCategory } from "../cache-error.js";
58
- import { bufferToBase64, base64ToBuffer } from "./cf-base64.js";
63
+ import {
64
+ bufferToBase64,
65
+ base64ToBuffer,
66
+ base64ToBytes,
67
+ bytesToBase64,
68
+ } from "./cf-base64.js";
69
+ import {
70
+ ShellFrameReader,
71
+ encodeShellFrame,
72
+ encodeShellSnapshot,
73
+ parseShellSnapshot,
74
+ shellFrameToText,
75
+ type ShellFrameHead,
76
+ } from "./cf-shell-frame.js";
77
+ import {
78
+ ShellMemo,
79
+ RecentTagInvalidations,
80
+ DEFAULT_SHELL_MEMO_MS,
81
+ freshReadsWindowMs,
82
+ isShellFresh,
83
+ resolveShellMemoOptions,
84
+ shellHasAnyTag,
85
+ type ResolvedShellMemoOptions,
86
+ } from "../shell-memo.js";
59
87
  import {
60
88
  KV_KEY_PRESERVED_PREFIX_BYTES,
61
89
  KV_MAX_KEY_BYTES,
@@ -70,7 +98,19 @@ import {
70
98
  TAG_MARKER_ABSENT,
71
99
  getTagMarkerMemo,
72
100
  getTagMarkerInflight,
101
+ getShellMarkerReads,
102
+ isRequestMasked,
103
+ maskRequestTags,
73
104
  } from "./cf-tag-marker-memo.js";
105
+ import {
106
+ TagMarkerMemo,
107
+ TagNameHints,
108
+ hintedTags,
109
+ freshReadsRequired,
110
+ recordMarkerRow,
111
+ DEFAULT_CF_MARKER_FRESH_MS,
112
+ DEFAULT_CF_MARKER_MAX_STALE_MS,
113
+ } from "../isolate-tag-memo.js";
74
114
  import { createCloudflareZonePurge } from "./cf-zone-purge.js";
75
115
 
76
116
  // ============================================================================
@@ -94,6 +134,7 @@ import {
94
134
  EDGE_LOOKUP_TIMEOUT_MS,
95
135
  EDGE_READ_TIMEOUT_MS,
96
136
  KV_READ_TIMEOUT_MS,
137
+ SHELL_SNAPSHOT_READ_MIN_TIMEOUT_MS,
97
138
  } from "./cf-cache-constants.js";
98
139
 
99
140
  // Re-export the public constants so consumers/tests importing them from
@@ -267,57 +308,110 @@ interface KVItemEnvelope {
267
308
  }
268
309
 
269
310
  /**
270
- * Coupled Cache API/KV envelope for PPR shell cache entries.
271
- * @internal
311
+ * Key namespace of the shell family. `shell2:` holds the prelude-first frame
312
+ * (cf-shell-frame.ts); `shell:` held the JSON envelope. Every deploy retires
313
+ * the old entries anyway (buildVersion gate); the new namespace also keeps
314
+ * new code from reporting an old body as corrupt and a rollback from parsing
315
+ * a frame. Old keys age out by their TTL.
272
316
  */
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;
317
+ const SHELL_KEY_PREFIX = "shell2:";
318
+
319
+ /** A shell snapshot read's timeout, and its warning's consequence text. */
320
+ interface SnapshotReadBudget {
321
+ ms: number;
322
+ consequence?: string;
323
+ }
324
+
325
+ /** A memoized fresh shell read (shell-memo.ts). */
326
+ interface CFShellMemoValue {
327
+ head: ShellFrameHead;
328
+ prelude: Uint8Array;
329
+ snapshot: ShellSnapshotRecord[] | undefined;
330
+ /** L1 response headers: the KV-less purge-mode marker check reads them. */
331
+ headers?: Headers;
332
+ }
333
+
334
+ /** The per-isolate shell memo shared by every CFCacheStore in the isolate. */
335
+ const cfShellMemo = new ShellMemo<CFShellMemoValue>();
336
+ /** Per-isolate tag-marker values (isolate-tag-memo.ts), keyed per namespace. */
337
+ const cfMarkerMemo = new TagMarkerMemo();
338
+ /** Per-isolate shell-key -> tag-name hints for the marker prefetch. */
339
+ const cfTagHints = new TagNameHints();
340
+
341
+ /** Tags this isolate invalidated recently: they keep shells out of cfShellMemo. */
342
+ const recentShellInvalidations = new RecentTagInvalidations();
343
+
344
+ /**
345
+ * Whether cfShellMemo may keep or serve this shell: still fresh (a stale one
346
+ * stays with the store read, which schedules the recapture), and not tagged
347
+ * at or before an invalidation this isolate is running or just ran.
348
+ */
349
+ function isMemoizableShell(head: ShellFrameHead): boolean {
350
+ return (
351
+ isShellFresh(head.s, head.e) &&
352
+ !recentShellInvalidations.covers(head.t, head.ta)
353
+ );
354
+ }
355
+
356
+ /** @internal Reset the per-isolate shell and marker memos (tests). */
357
+ export function resetCFShellMemoForTests(): void {
358
+ cfShellMemo.clear();
359
+ recentShellInvalidations.clear();
360
+ cfMarkerMemo.clear();
361
+ cfTagHints.clear();
362
+ }
363
+
364
+ /** openShellFrame outcome: the head and prelude, or why the read failed. */
365
+ type OpenedShellFrame =
366
+ | {
367
+ status: "ok";
368
+ head: ShellFrameHead;
369
+ prelude: Uint8Array;
370
+ reader: ShellFrameReader;
371
+ }
372
+ | { status: "corrupt"; error: Error; head?: undefined }
373
+ | { status: "expired"; head: ShellFrameHead }
374
+ | { status: "timeout"; head?: undefined }
375
+ | { status: "invalidated"; head?: undefined };
376
+
377
+ /** The `[CFCacheStore][shell]` trace's timings, from a read's stats. */
378
+ function debugTimings(
379
+ stats: ShellReadStats | undefined,
380
+ ): Pick<CFShellDebugDetails, "matchMs" | "readMs" | "bodyReadMs" | "markerMs"> {
381
+ if (!stats) return {};
382
+ if (stats.tier === "memo")
383
+ return { markerMs: Math.round(stats.markerMs ?? 0) };
384
+ const round = (ms: number | undefined) =>
385
+ ms === undefined ? undefined : Math.round(ms);
386
+ const bodyReadMs =
387
+ stats.headMs === undefined && stats.preludeMs === undefined
388
+ ? undefined
389
+ : (stats.headMs ?? 0) + (stats.preludeMs ?? 0);
390
+ return {
391
+ ...(stats.tier === "kv"
392
+ ? { readMs: round(stats.matchMs) }
393
+ : { matchMs: round(stats.matchMs) }),
394
+ bodyReadMs: round(bodyReadMs),
395
+ markerMs: round(stats.markerMs),
396
+ };
397
+ }
398
+
399
+ /** The public entry shape of a frame head (no prelude, no snapshot). */
400
+ function shellHeadToEntry(head: ShellFrameHead): ShellCacheEntry {
401
+ return {
402
+ ...(head.po !== undefined ? { postponed: head.po } : {}),
403
+ reactVersion: head.rv,
404
+ buildVersion: head.bv,
405
+ initialTheme: head.i,
406
+ docKey: head.dk,
407
+ prunedRecords: head.pr,
408
+ navigationOnly: head.no,
409
+ createdAt: head.c,
410
+ };
318
411
  }
319
412
 
320
413
  type CFShellDebugOutcome =
414
+ | "memo-hit"
321
415
  | "l1-hit"
322
416
  | "l1-miss"
323
417
  | "kv-hit"
@@ -329,13 +423,12 @@ type CFShellDebugOutcome =
329
423
  | "write-invalidated";
330
424
 
331
425
  interface CFShellDebugDetails {
332
- tier?: "l1" | "kv";
426
+ tier?: "memo" | "l1" | "kv";
333
427
  reason?:
334
428
  | "absent"
335
429
  | "timeout"
336
430
  | "error"
337
431
  | "non-200"
338
- | "corrupt"
339
432
  | "malformed"
340
433
  | "expired"
341
434
  | "unavailable";
@@ -349,41 +442,6 @@ interface CFShellDebugDetails {
349
442
  remainingTtl?: number;
350
443
  }
351
444
 
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
445
  /**
388
446
  * KV envelope for document cache entries.
389
447
  * @internal
@@ -426,6 +484,22 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
426
484
  private readonly edgeLookupTimeoutMs: number;
427
485
  private readonly edgeReadTimeoutMs: number;
428
486
  private readonly kvReadTimeoutMs: number;
487
+ /**
488
+ * The shell snapshot read's budgets. `read` (getShell: partial replay, the
489
+ * testing helpers) has nothing committed, so a slow snapshot is a quick
490
+ * miss at kvReadTimeoutMs. `document` (readShellDocument, the serve path)
491
+ * has sent the prelude and cannot finish without the snapshot's doc
492
+ * record; a timeout there reloads the page, so it waits at least
493
+ * SHELL_SNAPSHOT_READ_MIN_TIMEOUT_MS. `kvReadTimeoutMs <= 0` keeps both
494
+ * unbounded.
495
+ */
496
+ private readonly snapshotBudgets: {
497
+ read: SnapshotReadBudget;
498
+ document: SnapshotReadBudget;
499
+ };
500
+ private readonly memo: ResolvedShellMemoOptions;
501
+ /** @internal SegmentCacheStore.freshReadsWindowMs */
502
+ readonly freshReadsWindowMs: number;
429
503
  private readonly debug?: (event: CFCacheReadDebugEvent) => void;
430
504
  private readonly kv?: KVNamespace;
431
505
  /** True when constructed without KV: no durable tag history (see ctor). */
@@ -476,6 +550,28 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
476
550
  options.kvReadTimeoutMs,
477
551
  KV_READ_TIMEOUT_MS,
478
552
  );
553
+ this.snapshotBudgets = {
554
+ read: { ms: this.kvReadTimeoutMs },
555
+ document:
556
+ this.kvReadTimeoutMs > 0
557
+ ? {
558
+ ms: Math.max(
559
+ this.kvReadTimeoutMs,
560
+ SHELL_SNAPSHOT_READ_MIN_TIMEOUT_MS,
561
+ ),
562
+ consequence: "the HIT reloads the page into a cache-miss render",
563
+ }
564
+ : { ms: this.kvReadTimeoutMs },
565
+ };
566
+ this.memo = resolveShellMemoOptions(options.memo, {
567
+ markerFreshMs: DEFAULT_CF_MARKER_FRESH_MS,
568
+ markerMaxStaleMs: DEFAULT_CF_MARKER_MAX_STALE_MS,
569
+ });
570
+ // Without KV there are no markers, so no marker memo to outwait.
571
+ this.freshReadsWindowMs = freshReadsWindowMs(
572
+ this.memo,
573
+ Boolean(options.kv),
574
+ );
479
575
  this.debug =
480
576
  options.debug === true
481
577
  ? (event) =>
@@ -602,11 +698,15 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
602
698
  }
603
699
  }
604
700
 
605
- /** Build-time-gated shell tier trace for deployed cross-colo diagnostics. */
701
+ /**
702
+ * Build-time-gated shell tier trace for deployed cross-colo diagnostics.
703
+ * A read's `stats` print as its timings, after the other details and before
704
+ * `expiresAt`.
705
+ */
606
706
  private debugShell(
607
707
  key: string,
608
708
  outcome: CFShellDebugOutcome,
609
- details: CFShellDebugDetails = {},
709
+ details: CFShellDebugDetails & { stats?: ShellReadStats } = {},
610
710
  ): void {
611
711
  if (!INTERNAL_RANGO_DEBUG) return;
612
712
  const request = _getRequestContext()?.request as
@@ -620,6 +720,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
620
720
  : ray?.includes("-")
621
721
  ? ray.slice(ray.lastIndexOf("-") + 1)
622
722
  : undefined;
723
+ const { stats, expiresAt, ...rest } = details;
623
724
  console.log(
624
725
  `[CFCacheStore][shell] ${JSON.stringify({
625
726
  key,
@@ -627,7 +728,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
627
728
  at: Date.now(),
628
729
  ray,
629
730
  colo,
630
- ...details,
731
+ ...rest,
732
+ ...debugTimings(stats),
733
+ expiresAt,
631
734
  })}`,
632
735
  );
633
736
  }
@@ -723,6 +826,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
723
826
  read: () => Promise<T>,
724
827
  budgetMs: number,
725
828
  label: string,
829
+ consequence = "treating as miss",
726
830
  ): Promise<{ value: T | undefined; timedOut: boolean }> {
727
831
  if (budgetMs <= 0) return { value: await read(), timedOut: false };
728
832
 
@@ -741,7 +845,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
741
845
  ]);
742
846
  if (result.timedOut) {
743
847
  console.warn(
744
- `[CFCacheStore] ${label} exceeded ${budgetMs}ms; treating as miss`,
848
+ `[CFCacheStore] ${label} exceeded ${budgetMs}ms; ${consequence}`,
745
849
  );
746
850
  return { value: undefined, timedOut: true };
747
851
  }
@@ -1246,7 +1350,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1246
1350
 
1247
1351
  /**
1248
1352
  * Store entry data with TTL and optional SWR window.
1249
- * Uses waitUntil for non-blocking write when available.
1353
+ * Resolves once the L1 write lands, per the SegmentCacheStore.set contract
1354
+ * (also held by waitUntil when available); the KV write stays in the
1355
+ * background.
1250
1356
  * When KV is configured, also persists to L2.
1251
1357
  */
1252
1358
  async set(
@@ -1297,20 +1403,21 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1297
1403
 
1298
1404
  const putPromise = cache.put(request, response);
1299
1405
 
1406
+ let l1Write: Promise<void> | undefined;
1300
1407
  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
1408
+ // These store-level background tasks intentionally omit the
1409
+ // reportingAsync ctx argument: the store is a request-agnostic
1303
1410
  // singleton and this.waitUntil is the execution context's, not a single
1304
1411
  // request's, so a failure is reported console-loud only (it cannot be
1305
1412
  // attributed to one request's onError). The request-scoped tag verbs
1306
1413
  // (revalidateTag / stale-revalidation) DO thread their captured ctx.
1307
- this.waitUntil(() =>
1308
- reportingAsync(
1309
- () => putPromise,
1310
- "cache-write",
1311
- "[CFCacheStore] L1 write",
1312
- ),
1414
+ const put = reportingAsync(
1415
+ () => putPromise,
1416
+ "cache-write",
1417
+ "[CFCacheStore] L1 write",
1313
1418
  );
1419
+ this.waitUntil(() => put);
1420
+ l1Write = put;
1314
1421
  } else {
1315
1422
  // Blocking fallback
1316
1423
  await putPromise;
@@ -1318,6 +1425,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1318
1425
 
1319
1426
  // L2: persist to KV (reuses `body` as envelope.d)
1320
1427
  this.kvSetSegment(key, body, staleAt, totalTtl, swrWindow);
1428
+
1429
+ // The set() contract (SegmentCacheStore.set, #957): resolve once L1 is
1430
+ // readable.
1431
+ await l1Write;
1321
1432
  } catch (error) {
1322
1433
  reportCacheError(error, "cache-write", "[CFCacheStore] set");
1323
1434
  }
@@ -1952,27 +2063,96 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1952
2063
  }
1953
2064
 
1954
2065
  /**
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.
2066
+ * Get a cached PPR shell entry: the public custom-store contract, built on
2067
+ * readShellDocument. It waits for the snapshot and re-encodes the prelude to
2068
+ * the base64 `ShellCacheEntry.prelude` the contract carries (native
2069
+ * `toBase64` in workerd). Partial replay and the testing helpers read
2070
+ * through here; the document serve path reads readShellDocument directly.
2071
+ * A snapshot that failed to read is a miss here: with no committed response
2072
+ * to protect, an entry without its pins is worse than none.
1960
2073
  */
1961
2074
  async getShell(
1962
2075
  key: string,
1963
2076
  ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
2077
+ const read = await this.readShellDocumentWithin(
2078
+ key,
2079
+ undefined,
2080
+ this.snapshotBudgets.read,
2081
+ );
2082
+ if (!read) return null;
2083
+ const snapshot = await read.snapshot;
2084
+ if (await read.snapshotFailure) return null;
2085
+ const entry: ShellCacheEntry = { ...read.entry };
2086
+ if (!entry.navigationOnly) entry.prelude = bytesToBase64(read.prelude);
2087
+ if (snapshot) entry.snapshot = snapshot;
2088
+ return { entry, shouldRevalidate: read.shouldRevalidate };
2089
+ }
2090
+
2091
+ /**
2092
+ * @internal Prelude-first read of a shell (SegmentCacheStore
2093
+ * .readShellDocument). Cache API first, then KV; both tiers hold one
2094
+ * cf-shell-frame body, so the prelude, postponed state, snapshot, versions,
2095
+ * and generation metadata cannot mix. Before it resolves it reads only the
2096
+ * head and the prelude's bytes, and the tag-marker read runs alongside the
2097
+ * prelude read (it starts as soon as the head is parsed, and is awaited
2098
+ * before this resolves, so an invalidated shell is never returned). The
2099
+ * snapshot is the rest of the same body, read on its own promise, bounded
2100
+ * by the document read's budget (`snapshotBudgets.document`; getShell uses
2101
+ * `snapshotBudgets.read`). SWR is a plain staleness flag; the capture
2102
+ * scheduler's in-flight set is the recapture stampede guard.
2103
+ */
2104
+ async readShellDocument(
2105
+ key: string,
2106
+ options?: ShellDocumentReadOptions,
2107
+ ): Promise<ShellDocumentRead | null> {
2108
+ return this.readShellDocumentWithin(
2109
+ key,
2110
+ options,
2111
+ this.snapshotBudgets.document,
2112
+ );
2113
+ }
2114
+
2115
+ private async readShellDocumentWithin(
2116
+ key: string,
2117
+ options: ShellDocumentReadOptions | undefined,
2118
+ snapshotBudget: SnapshotReadBudget,
2119
+ ): Promise<ShellDocumentRead | null> {
2120
+ const memoKey = this.shellMemoKey(key);
2121
+ const ctx = _getRequestContext();
2122
+ // A request after the same user's updateTag() reads past the isolate
2123
+ // memos (isolate-tag-memo.ts freshReadsRequired).
2124
+ const freshReads = freshReadsRequired(ctx);
2125
+ const memoized = freshReads
2126
+ ? undefined
2127
+ : cfShellMemo.get(memoKey, this.memo.shellMs);
2128
+ if (memoized) {
2129
+ return this.readMemoizedShell(
2130
+ key,
2131
+ memoKey,
2132
+ memoized,
2133
+ options,
2134
+ snapshotBudget,
2135
+ );
2136
+ }
2137
+ const stats = this.shellReadStats("l1");
2138
+ const l1StartedAt = stats ? performance.now() : 0;
2139
+ const kvFallback = (reason: string): Promise<ShellDocumentRead | null> =>
2140
+ this.kvReadShellDocument(key, snapshotBudget, stats, l1StartedAt, reason);
2141
+ if (stats && freshReads) stats.freshReads = true;
2142
+ this.prefetchShellMarkers(memoKey, options?.tagHints, stats);
2143
+ if (stats && this.memo.shellMs > 0) {
2144
+ stats.memo = { hit: false, bytes: cfShellMemo.size };
2145
+ }
1964
2146
  try {
1965
2147
  const cache = await this.getCache();
1966
- const request = this.keyToRequest(`shell:${key}`);
1967
- const matchStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
2148
+ const request = this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`);
2149
+ const matchStartedAt = stats ? performance.now() : 0;
1968
2150
  const {
1969
2151
  response,
1970
2152
  timedOut,
1971
2153
  error: matchError,
1972
2154
  } = await this.matchWithTimeout(cache, request);
1973
- const matchMs = INTERNAL_RANGO_DEBUG
1974
- ? Date.now() - matchStartedAt
1975
- : undefined;
2155
+ if (stats) stats.matchMs = performance.now() - matchStartedAt;
1976
2156
 
1977
2157
  if (!response) {
1978
2158
  if (matchError) {
@@ -1982,116 +2162,434 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1982
2162
  "[CFCacheStore] getShell L1 match",
1983
2163
  );
1984
2164
  }
1985
- this.debugShell(key, "l1-miss", {
1986
- reason: matchError ? "error" : timedOut ? "timeout" : "absent",
1987
- matchMs,
1988
- });
1989
- return this.kvGetShell(key);
2165
+ const reason = matchError ? "error" : timedOut ? "timeout" : "absent";
2166
+ this.debugShell(key, "l1-miss", { reason, stats });
2167
+ return kvFallback(reason);
1990
2168
  }
1991
- if (response.status !== 200) {
2169
+ if (response.status !== 200 || !response.body) {
1992
2170
  this.debugShell(key, "l1-miss", {
1993
2171
  reason: "non-200",
1994
2172
  status: response.status,
1995
- matchMs,
2173
+ stats,
1996
2174
  });
1997
- return this.kvGetShell(key);
2175
+ return kvFallback("non-200");
1998
2176
  }
1999
2177
 
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)) {
2178
+ // Unlike other L1 families, shells with KV always check the durable
2179
+ // generation marker — see the capture-start/purge race documented
2180
+ // above. Without KV there are no markers: L1-only shells adopt the
2181
+ // data families' purge-mode read semantics (a hit that survived the
2182
+ // purge is trusted; the per-request memo masks this request's own
2183
+ // updateTag() writes; entries a purge cannot reach fall back to the
2184
+ // marker check, which fails open KV-less).
2185
+ const opened = await this.openShellFrame(
2186
+ response.body,
2187
+ this.edgeReadTimeoutMs,
2188
+ "edge cache body read",
2189
+ (head) =>
2190
+ this.kv
2191
+ ? this.isGloballyInvalidated(head.t, head.ta, true)
2192
+ : this.isL1Invalidated(head.t, head.ta, response.headers),
2193
+ stats,
2194
+ );
2195
+ if (opened.status === "corrupt") {
2020
2196
  this.debugShell(key, "l1-miss", {
2021
2197
  reason: "malformed",
2022
- matchMs,
2023
- bodyReadMs,
2198
+ stats,
2024
2199
  });
2025
2200
  return this.healCorruptL1(
2026
2201
  cache,
2027
2202
  request,
2028
- new Error("malformed/partial L1 shell envelope"),
2203
+ opened.error,
2029
2204
  "getShell",
2030
- () => this.kvGetShell(key),
2205
+ () => kvFallback("malformed"),
2031
2206
  );
2032
2207
  }
2033
-
2034
- const now = Date.now();
2035
- if (now > value.e) {
2208
+ if (opened.status === "timeout" || opened.status === "expired") {
2036
2209
  this.debugShell(key, "l1-miss", {
2037
- reason: "expired",
2038
- matchMs,
2039
- bodyReadMs,
2040
- expiresAt: value.e,
2210
+ reason: opened.status,
2211
+ stats,
2212
+ expiresAt: opened.head?.e,
2041
2213
  });
2042
- return this.kvGetShell(key);
2214
+ return kvFallback(opened.status);
2043
2215
  }
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) {
2216
+ if (opened.status === "invalidated") {
2060
2217
  this.debugShell(key, "marker-invalidated", {
2061
2218
  tier: "l1",
2062
- matchMs,
2063
- bodyReadMs,
2064
- markerMs,
2219
+ stats,
2065
2220
  });
2066
2221
  return null;
2067
2222
  }
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
- };
2223
+ const read = this.shellDocumentRead(
2224
+ key,
2225
+ opened,
2226
+ snapshotBudget,
2227
+ stats,
2228
+ response.headers,
2229
+ );
2230
+ if (INTERNAL_RANGO_DEBUG) {
2231
+ this.debugShell(key, "l1-hit", {
2232
+ freshness: read.shouldRevalidate ? "stale" : "fresh",
2233
+ stats,
2234
+ expiresAt: opened.head.e,
2235
+ });
2236
+ }
2237
+ return read;
2081
2238
  } catch (error) {
2082
2239
  reportCacheError(error, "cache-read", "[CFCacheStore] getShell");
2083
2240
  this.debugShell(key, "l1-miss", { reason: "error" });
2084
- return this.kvGetShell(key);
2241
+ return kvFallback("error");
2242
+ }
2243
+ }
2244
+
2245
+ /** @internal SegmentCacheStore.dropShellMemo */
2246
+ dropShellMemo(key: string): void {
2247
+ cfShellMemo.delete(this.shellMemoKey(key));
2248
+ }
2249
+
2250
+ /**
2251
+ * Start the marker reads of the tags this shell key carried last time (and
2252
+ * the route's static tags) alongside the entry read (readShellTagMarker),
2253
+ * so the marker check after the head reuses them. With KV only: without it
2254
+ * there are no markers to read. A wrong hint costs a wasted read; the check
2255
+ * itself uses the head's tags.
2256
+ */
2257
+ private prefetchShellMarkers(
2258
+ memoKey: string,
2259
+ routeTags: readonly string[] | undefined,
2260
+ stats: ShellReadStats | undefined,
2261
+ ): void {
2262
+ if (!this.kv) return;
2263
+ const tags = hintedTags(cfTagHints.get(memoKey), routeTags);
2264
+ if (tags.length === 0) return;
2265
+ if (stats) {
2266
+ stats.markerHinted = tags;
2267
+ stats.markerHintStartedAt = performance.now();
2268
+ }
2269
+ for (const tag of tags) this.readShellTagMarker(tag).catch(() => {});
2270
+ }
2271
+
2272
+ /**
2273
+ * A HIT: remember the entry's tags for the next read's hints, and fill the
2274
+ * marker row. The shell-read marker values stay in their own record: the
2275
+ * request's data reads (the tail's, or a partial navigation's matchPartial
2276
+ * on the same context) read their markers as before.
2277
+ */
2278
+ private recordShellHit(
2279
+ key: string,
2280
+ tags: string[] | undefined,
2281
+ stats: ShellReadStats | undefined,
2282
+ ): void {
2283
+ cfTagHints.remember(this.shellMemoKey(key), tags);
2284
+ if (!stats) return;
2285
+ const ctx = _getRequestContext();
2286
+ if (ctx) {
2287
+ recordMarkerRow(stats, tags, getShellMarkerReads(ctx, this).outcomes);
2288
+ }
2289
+ }
2290
+
2291
+ /** The isolate marker memo's key for a tag: markers are per namespace and version. */
2292
+ private markerMemoKey(tag: string): string {
2293
+ return `${this.namespace ?? ""}\u0000${this.version ?? ""}\u0000${tag}`;
2294
+ }
2295
+
2296
+ /** The memo key: one per namespace, build version, base URL, and shell key. */
2297
+ private shellMemoKey(key: string): string {
2298
+ return `${this.namespace ?? ""}\u0000${this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`).url}`;
2299
+ }
2300
+
2301
+ /**
2302
+ * Serve a memoized shell (shell-memo.ts). The tag-marker check is the only
2303
+ * store I/O left, so it is started first and awaited before anything is
2304
+ * returned: nothing of an invalidated shell reaches the response. A shell
2305
+ * that went stale since it was memoized, or that predates a tag this
2306
+ * isolate is invalidating or just invalidated (RecentTagInvalidations), is
2307
+ * dropped and read from the store (SWR recapture scheduling and each mode's
2308
+ * invalidation semantics stay with the store read). That read keeps the
2309
+ * caller's `tagHints`: cfTagHints may have evicted the key while the memo
2310
+ * still holds it.
2311
+ */
2312
+ private async readMemoizedShell(
2313
+ key: string,
2314
+ memoKey: string,
2315
+ memoized: CFShellMemoValue,
2316
+ options: ShellDocumentReadOptions | undefined,
2317
+ snapshotBudget: SnapshotReadBudget,
2318
+ ): Promise<ShellDocumentRead | null> {
2319
+ const { head } = memoized;
2320
+ if (!isMemoizableShell(head)) {
2321
+ cfShellMemo.delete(memoKey);
2322
+ return this.readShellDocumentWithin(key, options, snapshotBudget);
2323
+ }
2324
+ const stats = this.shellReadStats("memo");
2325
+ const markerStartedAt = stats ? performance.now() : 0;
2326
+ const invalidated = await (this.kv
2327
+ ? this.isGloballyInvalidated(head.t, head.ta, true)
2328
+ : this.isL1Invalidated(
2329
+ head.t,
2330
+ head.ta,
2331
+ memoized.headers ?? new Headers(),
2332
+ ));
2333
+ if (stats) {
2334
+ // Nothing else to wait for: the marker IS the memo hit's critical path.
2335
+ stats.markerMs = stats.markerWaitMs = performance.now() - markerStartedAt;
2336
+ stats.tags = head.t?.length ?? 0;
2337
+ stats.preludeBytes = memoized.prelude.length;
2338
+ }
2339
+ if (invalidated) {
2340
+ cfShellMemo.delete(memoKey);
2341
+ this.debugShell(key, "marker-invalidated", {
2342
+ tier: "memo",
2343
+ stats,
2344
+ });
2345
+ return null;
2346
+ }
2347
+ if (stats) stats.memo = { hit: true, bytes: cfShellMemo.size };
2348
+ this.recordShellHit(key, head.t, stats);
2349
+ this.debugShell(key, "memo-hit", {
2350
+ freshness: "fresh",
2351
+ stats,
2352
+ expiresAt: head.e,
2353
+ });
2354
+ return {
2355
+ entry: shellHeadToEntry(head),
2356
+ prelude: memoized.prelude,
2357
+ shouldRevalidate: false,
2358
+ snapshot: Promise.resolve(memoized.snapshot),
2359
+ ...(stats ? { stats } : {}),
2360
+ };
2361
+ }
2362
+
2363
+ /**
2364
+ * Stats for one shell read, allocated only when the request collects
2365
+ * `debugPerformance` metrics or the build has the internal shell trace on,
2366
+ * so a normal HIT takes no timestamps for them.
2367
+ */
2368
+ private shellReadStats(
2369
+ tier: ShellReadStats["tier"],
2370
+ ): ShellReadStats | undefined {
2371
+ return INTERNAL_RANGO_DEBUG || _getRequestContext()?._metricsStore
2372
+ ? { tier }
2373
+ : undefined;
2374
+ }
2375
+
2376
+ /**
2377
+ * Read a frame's head and prelude under one budget, starting the
2378
+ * tag-marker read (`isInvalidated`) the moment the head is parsed so it
2379
+ * overlaps the prelude read, then await it. Expired heads are rejected
2380
+ * before the marker read starts.
2381
+ */
2382
+ private async openShellFrame(
2383
+ body: ReadableStream<Uint8Array>,
2384
+ budgetMs: number,
2385
+ label: string,
2386
+ isInvalidated: (head: ShellFrameHead) => Promise<boolean>,
2387
+ stats: ShellReadStats | undefined,
2388
+ ): Promise<OpenedShellFrame> {
2389
+ const reader = new ShellFrameReader(body);
2390
+ let marker: Promise<boolean> | undefined;
2391
+ let markerSettledAt = 0;
2392
+ const headStartedAt = stats ? performance.now() : 0;
2393
+ const { value, timedOut } = await this.readWithTimeout(
2394
+ async (): Promise<OpenedShellFrame> => {
2395
+ const head = await reader.readHead();
2396
+ if (!head) {
2397
+ return {
2398
+ status: "corrupt",
2399
+ error: new Error("malformed/partial shell frame head"),
2400
+ };
2401
+ }
2402
+ const markerStartedAt = stats ? performance.now() : 0;
2403
+ if (stats) {
2404
+ stats.headMs = markerStartedAt - headStartedAt;
2405
+ stats.headBytes = reader.headBytes;
2406
+ stats.tags = head.t?.length ?? 0;
2407
+ if (stats.markerHintStartedAt !== undefined) {
2408
+ stats.markerLeadMs = markerStartedAt - stats.markerHintStartedAt;
2409
+ }
2410
+ }
2411
+ if (Date.now() > head.e) return { status: "expired", head };
2412
+ marker = isInvalidated(head).then((invalidated) => {
2413
+ if (stats) {
2414
+ markerSettledAt = performance.now();
2415
+ stats.markerMs = markerSettledAt - markerStartedAt;
2416
+ }
2417
+ return invalidated;
2418
+ });
2419
+ marker.catch(() => {});
2420
+ const prelude = await reader.take(head.pl);
2421
+ if (!prelude) {
2422
+ return {
2423
+ status: "corrupt",
2424
+ error: new Error("shell frame ends inside the prelude"),
2425
+ };
2426
+ }
2427
+ if (stats) {
2428
+ stats.preludeMs = performance.now() - markerStartedAt;
2429
+ stats.preludeBytes = prelude.length;
2430
+ }
2431
+ return { status: "ok", head, prelude, reader };
2432
+ },
2433
+ budgetMs,
2434
+ label,
2435
+ );
2436
+ if (timedOut || !value) {
2437
+ reader.cancel();
2438
+ if (stats) {
2439
+ // The budget ran out: the whole elapsed read, split at the head.
2440
+ const elapsed = performance.now() - headStartedAt;
2441
+ if (stats.headMs === undefined) stats.headMs = elapsed;
2442
+ else stats.preludeMs = elapsed - stats.headMs;
2443
+ }
2444
+ return { status: "timeout" };
2085
2445
  }
2446
+ if (value.status !== "ok") {
2447
+ reader.cancel();
2448
+ return value;
2449
+ }
2450
+ const preludeReadAt = stats ? performance.now() : 0;
2451
+ const invalidated = await marker!;
2452
+ if (stats) {
2453
+ stats.markerWaitMs = Math.max(0, markerSettledAt - preludeReadAt);
2454
+ }
2455
+ if (invalidated) {
2456
+ reader.cancel();
2457
+ return { status: "invalidated" };
2458
+ }
2459
+ return value;
2086
2460
  }
2087
2461
 
2088
2462
  /**
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).
2463
+ * The ShellDocumentRead for an opened frame. Its snapshot promise starts
2464
+ * reading the rest of the body now (off the commit path, not after it),
2465
+ * bounded by `budget` ({@link snapshotReadBudget}), and is registered with
2466
+ * waitUntil so the KV tier's L1 promotion and a corrupt entry's eviction
2467
+ * still run when nothing awaits it (a read that is not served). A snapshot
2468
+ * whose length differs from the head's `sl`, or that fails to parse, evicts
2469
+ * the entry from both tiers and reports cache-corrupt. Every failure
2470
+ * resolves undefined and names itself on `snapshotFailure` (`unavailable`
2471
+ * for a timeout or read error, `corrupt` otherwise): a document HIT then
2472
+ * degrades (rsc-rendering.ts serveShellHit), and getShell misses. Only
2473
+ * a read whose snapshot parsed goes into the per-isolate memo (when still
2474
+ * fresh), and `promote` receives its snapshot bytes (the KV tier's L1
2475
+ * promotion). `headers` are the L1 response's, kept for the KV-less
2476
+ * memo-hit marker check.
2477
+ */
2478
+ private shellDocumentRead(
2479
+ key: string,
2480
+ opened: Extract<OpenedShellFrame, { status: "ok" }>,
2481
+ budget: SnapshotReadBudget,
2482
+ stats: ShellReadStats | undefined,
2483
+ headers?: Headers,
2484
+ promote?: (snapshotBytes: Uint8Array) => void,
2485
+ ): ShellDocumentRead {
2486
+ const { head, prelude, reader } = opened;
2487
+ this.recordShellHit(key, head.t, stats);
2488
+ type SnapshotOutcome = {
2489
+ records?: ShellSnapshotRecord[];
2490
+ failure?: ShellSnapshotFailure;
2491
+ };
2492
+ const outcome = (async (): Promise<SnapshotOutcome> => {
2493
+ const readStartedAt = stats ? performance.now() : 0;
2494
+ const { value: rest, timedOut } = await this.readWithTimeout(
2495
+ () => reader.readRest(),
2496
+ budget.ms,
2497
+ "shell snapshot read",
2498
+ budget.consequence,
2499
+ );
2500
+ if (timedOut || !rest) {
2501
+ reader.cancel();
2502
+ return { failure: "unavailable" };
2503
+ }
2504
+ const parseStartedAt = stats ? performance.now() : 0;
2505
+ let records: ShellSnapshotRecord[] | undefined;
2506
+ try {
2507
+ if (rest.length !== head.sl) {
2508
+ throw new Error(
2509
+ `shell snapshot is ${rest.length} bytes, the head says ${head.sl}`,
2510
+ );
2511
+ }
2512
+ records = parseShellSnapshot(rest);
2513
+ } catch (error) {
2514
+ reportCacheError(
2515
+ error,
2516
+ "cache-corrupt",
2517
+ "[CFCacheStore] getShell: corrupt shell snapshot, evicting",
2518
+ );
2519
+ this.evictShell(key);
2520
+ return { failure: "corrupt" };
2521
+ }
2522
+ if (stats) {
2523
+ stats.snapshot = {
2524
+ readMs: parseStartedAt - readStartedAt,
2525
+ parseMs: performance.now() - parseStartedAt,
2526
+ bytes: rest.length,
2527
+ };
2528
+ }
2529
+ if (isMemoizableShell(head)) {
2530
+ // A view into a larger body chunk would pin that chunk in the memo.
2531
+ const ownPrelude =
2532
+ prelude.byteLength === prelude.buffer.byteLength
2533
+ ? prelude
2534
+ : prelude.slice();
2535
+ cfShellMemo.set(
2536
+ this.shellMemoKey(key),
2537
+ { head, prelude: ownPrelude, snapshot: records, headers },
2538
+ ownPrelude.length + rest.length,
2539
+ this.memo.shellMs,
2540
+ this.memo.shellMaxBytes,
2541
+ );
2542
+ }
2543
+ promote?.(rest);
2544
+ return { records };
2545
+ })().catch((error: unknown): SnapshotOutcome => {
2546
+ reportCacheError(error, "cache-read", "[CFCacheStore] getShell snapshot");
2547
+ return { failure: "unavailable" };
2548
+ });
2549
+ if (this.waitUntil) this.waitUntil(() => outcome.then(() => {}));
2550
+ return {
2551
+ entry: shellHeadToEntry(head),
2552
+ prelude,
2553
+ shouldRevalidate: head.s > 0 && Date.now() > head.s,
2554
+ snapshot: outcome.then(({ records }) => records),
2555
+ snapshotFailure: outcome.then(({ failure }) => failure),
2556
+ ...(stats && { stats }),
2557
+ };
2558
+ }
2559
+
2560
+ /** Delete a shell from both tiers (non-blocking). */
2561
+ private evictShell(key: string): void {
2562
+ const evict = async (): Promise<void> => {
2563
+ const cache = await this.getCache();
2564
+ await reportingAsync(
2565
+ () => cache.delete(this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`)),
2566
+ "cache-delete",
2567
+ "[CFCacheStore] getShell: evict corrupt L1",
2568
+ );
2569
+ if (this.kv) {
2570
+ await this.evictKvKey(
2571
+ await this.toKVKey(`${SHELL_KEY_PREFIX}${key}`),
2572
+ "getShell",
2573
+ );
2574
+ }
2575
+ };
2576
+ if (this.waitUntil) this.waitUntil(evict);
2577
+ else void evict();
2578
+ }
2579
+
2580
+ /**
2581
+ * Store a PPR shell in Cache API and, when KV is configured, KV — the same
2582
+ * cf-shell-frame bytes in both. The shared write is registered with
2583
+ * waitUntil and awaited so invalidation rejection can be acknowledged to
2584
+ * the capture scheduler. A KV-less store is L1-only by design (edge-only
2585
+ * ppr — see the section comment).
2586
+ *
2587
+ * KV rejects an expirationTtl below KV_MIN_EXPIRATION_TTL, so a shorter
2588
+ * retention (a `ppr` ttl + swr under 60, or a shell capped to a short or
2589
+ * aging route cache() entry) is written with that floor: the frame head's deadlines (`s`, `e`) are the
2590
+ * entry's, and every read checks `e` (openShellFrame), so the extra KV
2591
+ * lifetime is never served, and the shell is readable from every colo, not
2592
+ * only from the capturing colo's L1.
2095
2593
  */
2096
2594
  async putShell(
2097
2595
  key: string,
@@ -2111,6 +2609,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2111
2609
  if (!this.kv && !this.tagPurge && Array.isArray(tags) && tags.length > 0) {
2112
2610
  this.warnShellTagsNoEvictionOnce();
2113
2611
  }
2612
+ // This isolate serves its own new capture from the next read on.
2613
+ cfShellMemo.delete(this.shellMemoKey(key));
2614
+ cfTagHints.remember(this.shellMemoKey(key), tags);
2114
2615
  try {
2115
2616
  const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
2116
2617
  const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
@@ -2130,8 +2631,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2130
2631
  const taggedAt =
2131
2632
  Array.isArray(tags) && tags.length > 0 ? entry.createdAt : undefined;
2132
2633
 
2133
- const writeKv = !!this.kv && retentionTtl >= KV_MIN_EXPIRATION_TTL;
2134
- const kvKey = writeKv ? await this.toKVKey(`shell:${key}`) : null;
2634
+ const writeKv = !!this.kv && retentionTtl > 0;
2635
+ const kvKey = writeKv
2636
+ ? await this.toKVKey(`${SHELL_KEY_PREFIX}${key}`)
2637
+ : null;
2135
2638
 
2136
2639
  const write = (async (): Promise<"stored" | "invalidated" | void> => {
2137
2640
  if (
@@ -2143,12 +2646,14 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2143
2646
  return "invalidated";
2144
2647
  }
2145
2648
 
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 } : {}),
2649
+ // A navigationOnly entry stores no document half: no prelude bytes
2650
+ // and no postponed field (an omitted key, not an explicit undefined).
2651
+ const prelude =
2652
+ entry.prelude !== undefined
2653
+ ? base64ToBytes(entry.prelude)
2654
+ : new Uint8Array(0);
2655
+ const snapshotBytes = encodeShellSnapshot(entry.snapshot);
2656
+ const head: ShellFrameHead = {
2152
2657
  rv: entry.reactVersion,
2153
2658
  bv: entry.buildVersion,
2154
2659
  c: entry.createdAt,
@@ -2157,24 +2662,23 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2157
2662
  t: tags,
2158
2663
  ta: taggedAt,
2159
2664
  i: entry.initialTheme,
2160
- sn: entry.snapshot,
2161
2665
  dk: entry.docKey,
2162
- lh: entry.handlerLiveHoles,
2163
- tw: entry.transitionWhen,
2666
+ pr: entry.prunedRecords,
2164
2667
  no: entry.navigationOnly,
2668
+ ...(entry.postponed !== undefined ? { po: entry.postponed } : {}),
2669
+ pl: prelude.length,
2670
+ sl: snapshotBytes?.length ?? 0,
2165
2671
  };
2166
- const body = JSON.stringify(envelope);
2672
+ const frame = encodeShellFrame(head, prelude, snapshotBytes);
2167
2673
  const writes: Promise<boolean>[] = [
2168
2674
  (async () => {
2169
2675
  try {
2170
2676
  const cache = await this.getCache();
2171
2677
  await cache.put(
2172
- this.keyToRequest(`shell:${key}`),
2173
- this.shellEnvelopeResponse(body, envelope),
2678
+ this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`),
2679
+ this.shellFrameResponse(frame, head),
2174
2680
  );
2175
- this.debugShell(key, "l1-stored", {
2176
- expiresAt: envelope.e,
2177
- });
2681
+ this.debugShell(key, "l1-stored", { expiresAt: head.e });
2178
2682
  return true;
2179
2683
  } catch (error) {
2180
2684
  reportCacheError(
@@ -2190,12 +2694,15 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2190
2694
  writes.push(
2191
2695
  (async () => {
2192
2696
  try {
2193
- await this.kv!.put(kvKey, body, {
2194
- expirationTtl: retentionTtl,
2195
- });
2196
- this.debugShell(key, "kv-stored", {
2197
- expiresAt: envelope.e,
2697
+ await this.kv!.put(kvKey, shellFrameToText(frame), {
2698
+ // Whole seconds, at least KV's floor: the frame head keeps
2699
+ // the exact deadlines the reads check.
2700
+ expirationTtl: Math.max(
2701
+ KV_MIN_EXPIRATION_TTL,
2702
+ Math.ceil(retentionTtl),
2703
+ ),
2198
2704
  });
2705
+ this.debugShell(key, "kv-stored", { expiresAt: head.e });
2199
2706
  return true;
2200
2707
  } catch (error) {
2201
2708
  reportCacheError(
@@ -2219,107 +2726,134 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2219
2726
  }
2220
2727
  }
2221
2728
 
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,
2729
+ /** The Cache API representation of a shell frame. */
2730
+ private shellFrameResponse(
2731
+ frame: Uint8Array<ArrayBuffer>,
2732
+ head: ShellFrameHead,
2243
2733
  ): Response {
2244
- const remainingTtl = Math.max(
2245
- 1,
2246
- Math.floor((envelope.e - Date.now()) / 1000),
2247
- );
2248
- return new Response(body, {
2734
+ const remainingTtl = Math.max(1, Math.floor((head.e - Date.now()) / 1000));
2735
+ return new Response(frame, {
2249
2736
  headers: {
2250
- "Content-Type": "application/json",
2737
+ "Content-Type": "application/octet-stream",
2251
2738
  "Cache-Control": `public, max-age=${remainingTtl}`,
2252
- [CACHE_STALE_AT_HEADER]: String(envelope.s),
2253
- [CACHE_EXPIRES_AT_HEADER]: String(envelope.e),
2739
+ [CACHE_STALE_AT_HEADER]: String(head.s),
2740
+ [CACHE_EXPIRES_AT_HEADER]: String(head.e),
2254
2741
  [CACHE_STATUS_HEADER]: "HIT",
2255
- ...this.tagHeaderEntries(envelope.t, envelope.ta),
2742
+ ...this.tagHeaderEntries(head.t, head.ta),
2256
2743
  },
2257
2744
  });
2258
2745
  }
2259
2746
 
2260
- /** KV shell fallback with corruption checks and background L1 promotion. */
2261
- private async kvGetShell(
2747
+ /**
2748
+ * KV shell fallback: the same prelude-first read, then L1 promotion. After
2749
+ * an L1 attempt, `stats` is that read's and gains the L1 attempt's time
2750
+ * (`l1MissMs`) before its per-tier fields restart for KV.
2751
+ */
2752
+ private async kvReadShellDocument(
2262
2753
  key: string,
2263
- ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
2754
+ snapshotBudget: SnapshotReadBudget,
2755
+ stats?: ShellReadStats,
2756
+ l1StartedAt = 0,
2757
+ l1MissReason?: string,
2758
+ ): Promise<ShellDocumentRead | null> {
2264
2759
  if (!this.kv) return null;
2760
+ if (stats) {
2761
+ stats.l1MissMs = performance.now() - l1StartedAt;
2762
+ stats.l1MissReason = l1MissReason;
2763
+ stats.tier = "kv";
2764
+ stats.matchMs = stats.headMs = stats.preludeMs = undefined;
2765
+ stats.markerMs = stats.markerWaitMs = undefined;
2766
+ stats.headBytes = stats.preludeBytes = stats.tags = undefined;
2767
+ } else {
2768
+ stats = this.shellReadStats("kv");
2769
+ }
2265
2770
  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) {
2771
+ const readStartedAt = stats ? performance.now() : 0;
2772
+ // One kvReadTimeoutMs budget covers opening the value and reading its
2773
+ // head and prelude; the remainder after the open bounds the frame read.
2774
+ const deadline = Date.now() + this.kvReadTimeoutMs;
2775
+ const kvKey = await this.toKVKey(`${SHELL_KEY_PREFIX}${key}`);
2776
+ // A transient read REJECTION propagates to the catch below (reported
2777
+ // cache-read, the entry left intact); only a malformed frame on a body
2778
+ // that WAS read is corruption that evicts.
2779
+ const { value: raw, timedOut } = await this.readWithTimeout<unknown>(
2780
+ () => this.kv!.get(kvKey, { type: "stream" }),
2781
+ this.kvReadTimeoutMs,
2782
+ "KV read",
2783
+ );
2784
+ if (stats) stats.matchMs = performance.now() - readStartedAt;
2785
+ if (timedOut || raw == null) {
2278
2786
  this.debugShell(key, "kv-miss", {
2279
2787
  reason: timedOut ? "timeout" : "unavailable",
2280
- readMs,
2788
+ stats,
2281
2789
  });
2282
2790
  return null;
2283
2791
  }
2284
-
2285
- const now = Date.now();
2286
- if (now > envelope.e) {
2792
+ // A binding that ignores `type` (a test shim) hands back the stored
2793
+ // string itself.
2794
+ const body =
2795
+ raw instanceof ReadableStream
2796
+ ? (raw as ReadableStream<Uint8Array>)
2797
+ : new Response(raw as BodyInit).body!;
2798
+ const opened = await this.openShellFrame(
2799
+ body,
2800
+ // readWithTimeout treats <= 0 as "no budget": keep a spent budget
2801
+ // spent (1 ms) rather than unbounded.
2802
+ this.kvReadTimeoutMs > 0 ? Math.max(1, deadline - Date.now()) : 0,
2803
+ "KV read",
2804
+ (head) => this.isGloballyInvalidated(head.t, head.ta, true),
2805
+ stats,
2806
+ );
2807
+ if (opened.status === "corrupt") {
2808
+ reportCacheError(
2809
+ opened.error,
2810
+ "cache-corrupt",
2811
+ "[CFCacheStore] getShell: malformed shell frame in KV, evicting",
2812
+ );
2813
+ this.scheduleKvEvict(kvKey, "getShell");
2287
2814
  this.debugShell(key, "kv-miss", {
2288
- reason: "expired",
2289
- readMs,
2290
- expiresAt: envelope.e,
2815
+ reason: "malformed",
2816
+ stats,
2291
2817
  });
2292
2818
  return null;
2293
2819
  }
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) {
2820
+ if (opened.status === "timeout" || opened.status === "expired") {
2821
+ this.debugShell(key, "kv-miss", {
2822
+ reason: opened.status,
2823
+ stats,
2824
+ expiresAt: opened.head?.e,
2825
+ });
2826
+ return null;
2827
+ }
2828
+ if (opened.status === "invalidated") {
2303
2829
  this.debugShell(key, "marker-invalidated", {
2304
2830
  tier: "kv",
2305
- readMs,
2306
- markerMs,
2831
+ stats,
2307
2832
  });
2308
2833
  return null;
2309
2834
  }
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
- };
2835
+ const read = this.shellDocumentRead(
2836
+ key,
2837
+ opened,
2838
+ snapshotBudget,
2839
+ stats,
2840
+ undefined,
2841
+ (snapshotBytes) =>
2842
+ this.promoteShellToL1(
2843
+ key,
2844
+ opened.head,
2845
+ opened.prelude,
2846
+ snapshotBytes,
2847
+ ),
2848
+ );
2849
+ if (INTERNAL_RANGO_DEBUG) {
2850
+ this.debugShell(key, "kv-hit", {
2851
+ freshness: read.shouldRevalidate ? "stale" : "fresh",
2852
+ stats,
2853
+ expiresAt: opened.head.e,
2854
+ });
2855
+ }
2856
+ return read;
2323
2857
  } catch (error) {
2324
2858
  reportCacheError(error, "cache-read", "[CFCacheStore] kvGetShell");
2325
2859
  this.debugShell(key, "kv-miss", { reason: "error" });
@@ -2328,24 +2862,28 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2328
2862
  }
2329
2863
 
2330
2864
  /** Promote a valid KV shell into the per-colo Cache API tier. */
2331
- private promoteShellToL1(key: string, envelope: CFShellEnvelope): void {
2865
+ private promoteShellToL1(
2866
+ key: string,
2867
+ head: ShellFrameHead,
2868
+ prelude: Uint8Array,
2869
+ snapshotBytes: Uint8Array,
2870
+ ): void {
2332
2871
  if (!this.waitUntil) return;
2333
2872
  this.waitUntil(() =>
2334
2873
  reportingAsync(
2335
2874
  async () => {
2336
- if (Date.now() > envelope.e) return;
2875
+ if (Date.now() > head.e) return;
2337
2876
  const cache = await this.getCache();
2338
- const body = JSON.stringify(envelope);
2339
2877
  await cache.put(
2340
- this.keyToRequest(`shell:${key}`),
2341
- this.shellEnvelopeResponse(body, envelope),
2878
+ this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`),
2879
+ this.shellFrameResponse(
2880
+ encodeShellFrame(head, prelude, snapshotBytes),
2881
+ head,
2882
+ ),
2342
2883
  );
2343
2884
  this.debugShell(key, "kv-promoted", {
2344
- remainingTtl: Math.max(
2345
- 1,
2346
- Math.floor((envelope.e - Date.now()) / 1000),
2347
- ),
2348
- expiresAt: envelope.e,
2885
+ remainingTtl: Math.max(1, Math.floor((head.e - Date.now()) / 1000)),
2886
+ expiresAt: head.e,
2349
2887
  });
2350
2888
  },
2351
2889
  "cache-write",
@@ -2692,6 +3230,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2692
3230
  private async isGloballyInvalidated(
2693
3231
  tags: string[] | undefined,
2694
3232
  taggedAt: number | undefined,
3233
+ isolateMemo = false,
2695
3234
  ): Promise<boolean> {
2696
3235
  // Array.isArray (not just truthiness): a non-array tags value - direct store
2697
3236
  // misuse like setItem(k, v, { tags: "products" }), or a skewed KV envelope -
@@ -2704,7 +3243,11 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2704
3243
  const inflight = ctx ? getTagMarkerInflight(ctx, this) : undefined;
2705
3244
  try {
2706
3245
  const markers = await Promise.all(
2707
- tags.map((tag) => this.readTagMarker(tag, memo, inflight)),
3246
+ tags.map((tag) =>
3247
+ isolateMemo
3248
+ ? this.readShellTagMarker(tag)
3249
+ : this.readTagMarker(tag, memo, inflight),
3250
+ ),
2708
3251
  );
2709
3252
  for (const marker of markers) {
2710
3253
  if (marker != null && marker >= taggedAt) return true;
@@ -2733,7 +3276,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2733
3276
  * entry Cache-Tag tokens (`headers`): an entry a purge cannot reach — one
2734
3277
  * written before the tokens existed, or whose tag set overflowed the
2735
3278
  * Cache-Tag header limit — keeps the full marker check, or purge mode
2736
- * would serve it stale until TTL with no eviction path.
3279
+ * would serve it stale until TTL with no eviction path. KV-less there are
3280
+ * no markers, so it gets the per-request memo check like any hit (#973).
2737
3281
  *
2738
3282
  * Without tagPurge this is the full marker cascade. KV-tier reads and
2739
3283
  * shells always use isGloballyInvalidated directly: purge cannot reach KV,
@@ -2747,7 +3291,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2747
3291
  ): Promise<boolean> {
2748
3292
  if (!this.tagPurge) return this.isGloballyInvalidated(tags, taggedAt);
2749
3293
  if (!Array.isArray(tags) || tags.length === 0 || !taggedAt) return false;
2750
- if (!this.hasEntryCacheTags(headers)) {
3294
+ if (this.kv && !this.hasEntryCacheTags(headers)) {
2751
3295
  return this.isGloballyInvalidated(tags, taggedAt);
2752
3296
  }
2753
3297
  const ctx = _getRequestContext();
@@ -2803,6 +3347,55 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2803
3347
  return this.fetchTagMarker(tag, memo);
2804
3348
  }
2805
3349
 
3350
+ /**
3351
+ * A PPR shell read's marker for `tag`: this request's own value if it has
3352
+ * one (its invalidateTags() writes, or a store read), else through the
3353
+ * per-isolate marker memo (isolate-tag-memo.ts, stale-while-revalidate
3354
+ * under `markerFreshMs` / `markerMaxStaleMs`; a request carrying the
3355
+ * fresh-reads cookie reads L1/KV). The value is kept in the request's
3356
+ * shell-read record, never in the per-request memo the data families read,
3357
+ * HIT or MISS. A store read still lands in the per-request memo, as any
3358
+ * marker read does; it is not stale. A timed-out read fails open for this
3359
+ * request and is not memoized.
3360
+ */
3361
+ private readShellTagMarker(tag: string): Promise<number | null> {
3362
+ const ctx = _getRequestContext();
3363
+ const memo = ctx ? getTagMarkerMemo(ctx, this) : undefined;
3364
+ if (memo?.has(tag)) return Promise.resolve(memo.get(tag) ?? null);
3365
+ const shell = ctx ? getShellMarkerReads(ctx, this) : undefined;
3366
+ const pending = shell?.reads.get(tag);
3367
+ if (pending) return pending;
3368
+ const read = cfMarkerMemo.readThrough(
3369
+ this.markerMemoKey(tag),
3370
+ async (background) => {
3371
+ const outcome = { timedOut: false, fromRequest: false };
3372
+ const value = await this.fetchTagMarker(
3373
+ tag,
3374
+ background ? undefined : memo,
3375
+ outcome,
3376
+ );
3377
+ return {
3378
+ value,
3379
+ memoize: !outcome.timedOut && !outcome.fromRequest,
3380
+ };
3381
+ },
3382
+ {
3383
+ freshMs: this.memo.markerFreshMs,
3384
+ maxStaleMs: this.memo.markerMaxStaleMs,
3385
+ bypass: freshReadsRequired(ctx),
3386
+ keepAlive: this.waitUntil
3387
+ ? (refresh) => this.waitUntil!(() => refresh)
3388
+ : undefined,
3389
+ onOutcome:
3390
+ shell && (INTERNAL_RANGO_DEBUG || ctx?._metricsStore)
3391
+ ? (outcome) => shell.outcomes.set(tag, outcome)
3392
+ : undefined,
3393
+ },
3394
+ );
3395
+ shell?.reads.set(tag, read);
3396
+ return read;
3397
+ }
3398
+
2806
3399
  /**
2807
3400
  * Uncached body of readTagMarker: L1 (per-colo Cache API, opt-in via
2808
3401
  * tagCacheTtl) -> KV. Writes the resolved value back into the memo.
@@ -2811,19 +3404,33 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2811
3404
  private async fetchTagMarker(
2812
3405
  tag: string,
2813
3406
  memo: Map<string, number | null> | undefined,
3407
+ read?: { timedOut: boolean; fromRequest?: boolean },
2814
3408
  ): Promise<number | null> {
2815
3409
  // Write the resolved marker into the memo WITHOUT clobbering a value a
2816
3410
  // 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
3411
+ // sibling slots in parallel, so a slot's updateTag() can land its
3412
+ // invalidatedAt into the memo while this read is still in flight;
3413
+ // overwriting it with our (pre-invalidation) read result would break
2820
3414
  // read-your-own-writes for the rest of the request. If the tag was memoized
2821
3415
  // mid-read, that value wins and is returned. Without a memo, the read result
2822
3416
  // stands as-is.
2823
- const memoize = (read: number | null): number | null => {
2824
- if (memo && memo.has(tag)) return memo.get(tag) ?? null;
2825
- memo?.set(tag, read);
2826
- return read;
3417
+ //
3418
+ // A mask memoized mid-read stays in this request: maskTagsForRequest
3419
+ // wrote it before the KV put was confirmed, and a failed put must not
3420
+ // reach other requests (#973). So it is not written to L1 here, and
3421
+ // `read.fromRequest` keeps the shell read from storing it in the isolate
3422
+ // marker memo; writeTagInvalidation writes both after the put. A value
3423
+ // another read memoized mid-read came from KV or L1 and is published.
3424
+ let fromRequest = false;
3425
+ const memoize = (value: number | null): number | null => {
3426
+ if (memo && memo.has(tag)) {
3427
+ const ctx = _getRequestContext();
3428
+ fromRequest = ctx !== undefined && isRequestMasked(ctx, this, tag);
3429
+ if (read) read.fromRequest = fromRequest;
3430
+ return memo.get(tag) ?? null;
3431
+ }
3432
+ memo?.set(tag, value);
3433
+ return value;
2827
3434
  };
2828
3435
 
2829
3436
  // L1 (per-colo) marker cache - opt-in via tagCacheTtl. Bounded by the same
@@ -2871,6 +3478,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2871
3478
  "tag marker KV read",
2872
3479
  );
2873
3480
  if (timedOut) {
3481
+ if (read) read.timedOut = true;
2874
3482
  // Memoize the fail-open result so the rest of this request is consistent
2875
3483
  // (and does not re-pay the timeout per segment sharing the tag).
2876
3484
  return memoize(null);
@@ -2878,10 +3486,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2878
3486
  const value = raw != null ? Number(raw) : null;
2879
3487
  const resolved = memoize(value);
2880
3488
 
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) {
3489
+ // Populate L1 for subsequent reads in this colo (non-blocking), from KV's
3490
+ // answer only. A value memoized mid-read is skipped: KV's answer predates
3491
+ // it, and the memo's may be an unconfirmed mask (see memoize above).
3492
+ if (this.tagCacheTtl > 0 && !fromRequest) {
2885
3493
  const put = () => this.putTagMarkerL1(tag, resolved);
2886
3494
  if (this.waitUntil) this.waitUntil(put);
2887
3495
  else void put();
@@ -3056,29 +3664,6 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3056
3664
  }
3057
3665
  }
3058
3666
 
3059
- /**
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
3667
  /**
3083
3668
  * Shell tag-generation gate (SegmentCacheStore.isTagsInvalidatedSince): the
3084
3669
  * SAME KV markers used by runtime envelopes also evict immutable build shells
@@ -3094,12 +3679,77 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3094
3679
  return this.isShellGenerationInvalidated(tags, sinceMs);
3095
3680
  }
3096
3681
 
3682
+ /**
3683
+ * Invalidate every entry tagged with any of `tags`. Receives the whole batch
3684
+ * from one updateTag()/revalidateTag() call so the eager-purge hook fires
3685
+ * ONCE (one CDN purge request, not one per tag). Before its first await it
3686
+ * masks the tags for the rest of the request (maskTagsForRequest), which is
3687
+ * all revalidateTag() waits for. For each tag it then records the KV marker
3688
+ * (the durable cross-colo truth that reads compare taggedAt against) and
3689
+ * writes the fresh marker straight into this colo's L1 (write-through, NOT
3690
+ * delete - a delete would let the next read re-read a not-yet-converged KV
3691
+ * value and re-arm the stale window). In purge mode (tagPurge) it then
3692
+ * AWAITS the consumer's purge-by-tag call with the entry Cache-Tags — the
3693
+ * eviction the per-read marker skip on L1 hits relies on. Finally fires
3694
+ * onRevalidateTag with the namespaced lookup Cache-Tags so a consumer purge
3695
+ * evicts the cached lookups in other colos promptly (otherwise they converge
3696
+ * within tagCacheTtl).
3697
+ *
3698
+ * Durable-write integrity: the write-through other requests read (the
3699
+ * isolate marker memo and L1) for a tag runs ONLY after that tag's KV marker
3700
+ * write is confirmed. If any KV write fails (transient error; over-limit
3701
+ * keys are normalized by toKVKey rather than rejected), this rejects with
3702
+ * the failed tags so an awaiting updateTag() surfaces the failure instead of
3703
+ * silently reporting success while other requests/colos serve stale data.
3704
+ * The purge and onRevalidateTag still fire for the whole batch once the
3705
+ * puts settle, whether or not one failed (they are additive); the rejection
3706
+ * follows them.
3707
+ */
3097
3708
  async invalidateTags(tags: string[]): Promise<void> {
3098
3709
  if (tags.length === 0) return;
3099
3710
  const invalidatedAt = Date.now();
3711
+ this.maskTagsForRequest(tags, invalidatedAt);
3712
+ // The invalidating isolate drops its memoized shells for these tags at
3713
+ // once; with KV every isolate's per-read marker check rejects the rest.
3714
+ // Reads already in flight must not memoize them again while the markers
3715
+ // and the purge are written (KV-less purge mode has no marker to reject
3716
+ // that copy): the record holds them out until one window after this
3717
+ // settles, at least the default window so a store with the memo off still
3718
+ // covers another store's reads in this isolate.
3719
+ cfShellMemo.deleteWhere((shell) => shellHasAnyTag(shell.head.t, tags));
3720
+ recentShellInvalidations.begin(tags, invalidatedAt);
3721
+ try {
3722
+ await this.writeTagInvalidation(tags, invalidatedAt);
3723
+ } finally {
3724
+ recentShellInvalidations.settle(
3725
+ tags,
3726
+ Math.max(this.memo.shellMs, DEFAULT_SHELL_MEMO_MS),
3727
+ );
3728
+ }
3729
+ }
3730
+
3731
+ /**
3732
+ * The request-local half of invalidateTags(), run before its first await
3733
+ * (#973): this request's marker memo takes the new marker, so every later
3734
+ * read in the request (data families, purge-mode L1 hits, shell reads)
3735
+ * treats the tags as invalidated at `invalidatedAt` without waiting for the
3736
+ * KV put or the purge that revalidateTag() leaves to waitUntil. Written
3737
+ * before the durable write is confirmed: if that write fails, this request
3738
+ * pays extra misses, never a stale read, and later requests read the
3739
+ * durable state. Skipped where no read consults the memo (neither KV nor
3740
+ * tagPurge).
3741
+ */
3742
+ private maskTagsForRequest(tags: string[], invalidatedAt: number): void {
3743
+ if (!this.kv && !this.tagPurge) return;
3100
3744
  const ctx = _getRequestContext();
3101
- const memo = ctx ? getTagMarkerMemo(ctx, this) : undefined;
3745
+ if (ctx) maskRequestTags(ctx, this, tags, invalidatedAt);
3746
+ }
3102
3747
 
3748
+ /** invalidateTags' marker writes, tag purge, and hooks. */
3749
+ private async writeTagInvalidation(
3750
+ tags: string[],
3751
+ invalidatedAt: number,
3752
+ ): Promise<void> {
3103
3753
  if (!this.kv && !this.onRevalidateTag && !this.tagPurge) {
3104
3754
  console.warn(
3105
3755
  `[CFCacheStore] invalidateTags had no effect: configure a KV namespace ` +
@@ -3110,6 +3760,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3110
3760
 
3111
3761
  const failedTags = new Set<string>();
3112
3762
  const errors: unknown[] = [];
3763
+ const lookupMarkerCacheActive = Boolean(this.kv) && this.tagCacheTtl > 0;
3113
3764
  if (this.kv) {
3114
3765
  // Markers written with no expiry (tagInvalidationTtl unset) never expire,
3115
3766
  // so high-cardinality tags accumulate KV keys unboundedly with no reaper.
@@ -3145,25 +3796,16 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3145
3796
  }
3146
3797
  }),
3147
3798
  );
3148
- }
3149
3799
 
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) {
3800
+ // Write-through (isolate marker memo + L1) only for tags with a
3801
+ // confirmed durable marker; this request's own memo was written up
3802
+ // front (maskTagsForRequest). The L1 Cache API writes are independent,
3803
+ // so fan them out in parallel rather than awaiting each.
3163
3804
  const l1Writes: Promise<void>[] = [];
3164
3805
  for (const tag of tags) {
3165
3806
  if (failedTags.has(tag)) continue;
3166
- memo?.set(tag, invalidatedAt);
3807
+ // Same isolate: later requests here see the invalidation at once.
3808
+ cfMarkerMemo.store(this.markerMemoKey(tag), invalidatedAt);
3167
3809
  if (lookupMarkerCacheActive) {
3168
3810
  l1Writes.push(
3169
3811
  this.putTagMarkerL1(tag, invalidatedAt, { critical: true }),