@rangojs/router 0.4.1 → 0.4.3

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 (47) hide show
  1. package/dist/types/browser/navigation-client.d.ts +1 -1
  2. package/dist/types/browser/prefetch/cache.d.ts +20 -8
  3. package/dist/types/cache/cf/cf-cache-store.d.ts +19 -8
  4. package/dist/types/cache/cf/cf-cache-types.d.ts +4 -2
  5. package/dist/types/cache/read-through-swr.d.ts +7 -6
  6. package/dist/types/cloudflare/tracing.d.ts +16 -12
  7. package/dist/types/router/instrument.d.ts +45 -15
  8. package/dist/types/router/telemetry-otel.d.ts +4 -4
  9. package/dist/types/router/timeout.d.ts +12 -1
  10. package/dist/types/router/tracing.d.ts +31 -17
  11. package/dist/types/rsc/capture-queue.d.ts +22 -1
  12. package/dist/types/rsc/shell-capture.d.ts +17 -9
  13. package/dist/types/rsc/stream-idle.d.ts +60 -0
  14. package/dist/types/segment-fragments.d.ts +27 -4
  15. package/dist/types/server/request-context.d.ts +28 -9
  16. package/dist/types/vercel/tracing.d.ts +9 -8
  17. package/dist/vite/index.js +1 -1
  18. package/package.json +1 -1
  19. package/skills/cloudflare/SKILL.md +4 -2
  20. package/skills/observability/SKILL.md +33 -4
  21. package/skills/ppr/SKILL.md +25 -9
  22. package/src/browser/navigation-client.ts +77 -27
  23. package/src/browser/prefetch/cache.ts +40 -10
  24. package/src/browser/prefetch/fetch.ts +5 -0
  25. package/src/browser/rsc-router.tsx +12 -6
  26. package/src/cache/cache-runtime.ts +39 -25
  27. package/src/cache/cache-scope.ts +5 -1
  28. package/src/cache/cf/cf-cache-store.ts +423 -90
  29. package/src/cache/cf/cf-cache-types.ts +4 -2
  30. package/src/cache/document-cache.ts +63 -25
  31. package/src/cache/read-through-swr.ts +22 -16
  32. package/src/cloudflare/tracing.ts +16 -12
  33. package/src/router/instrument.ts +61 -15
  34. package/src/router/segment-resolution/loader-cache.ts +12 -1
  35. package/src/router/segment-resolution/revalidation.ts +16 -1
  36. package/src/router/telemetry-otel.ts +4 -4
  37. package/src/router/timeout.ts +12 -1
  38. package/src/router/tracing.ts +37 -17
  39. package/src/rsc/capture-queue.ts +72 -17
  40. package/src/rsc/handler.ts +171 -73
  41. package/src/rsc/rsc-rendering.ts +61 -6
  42. package/src/rsc/shell-capture.ts +83 -31
  43. package/src/rsc/stream-idle.ts +137 -0
  44. package/src/segment-fragments.ts +45 -4
  45. package/src/server/request-context.ts +29 -8
  46. package/src/testing/dispatch.ts +55 -1
  47. package/src/vercel/tracing.ts +9 -8
@@ -42,6 +42,7 @@ import {
42
42
  _getRequestContext,
43
43
  type RequestContext,
44
44
  } from "../../server/request-context.js";
45
+ import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
45
46
  import { VERSION } from "@rangojs/router:version";
46
47
  import {
47
48
  isPerClientSignalHeader,
@@ -262,10 +263,10 @@ interface KVItemEnvelope {
262
263
  }
263
264
 
264
265
  /**
265
- * KV envelope for PPR shell cache entries.
266
+ * Coupled Cache API/KV envelope for PPR shell cache entries.
266
267
  * @internal
267
268
  */
268
- interface KVShellEnvelope {
269
+ interface CFShellEnvelope {
269
270
  /** base64-encoded prelude bytes */
270
271
  p: string;
271
272
  /** postponed state JSON, or null (DATA variant — no holes) */
@@ -309,6 +310,67 @@ interface KVShellEnvelope {
309
310
  no?: true;
310
311
  }
311
312
 
313
+ type CFShellDebugOutcome =
314
+ | "l1-hit"
315
+ | "l1-miss"
316
+ | "kv-hit"
317
+ | "kv-miss"
318
+ | "kv-promoted"
319
+ | "marker-invalidated"
320
+ | "l1-stored"
321
+ | "kv-stored"
322
+ | "write-invalidated";
323
+
324
+ interface CFShellDebugDetails {
325
+ tier?: "l1" | "kv";
326
+ reason?:
327
+ | "absent"
328
+ | "timeout"
329
+ | "error"
330
+ | "non-200"
331
+ | "corrupt"
332
+ | "malformed"
333
+ | "expired"
334
+ | "unavailable";
335
+ freshness?: "fresh" | "stale";
336
+ status?: number;
337
+ matchMs?: number;
338
+ bodyReadMs?: number;
339
+ markerMs?: number;
340
+ readMs?: number;
341
+ expiresAt?: number;
342
+ remainingTtl?: number;
343
+ }
344
+
345
+ /** Validate the coupled PPR shell envelope before any field reaches resume. */
346
+ function isShellEnvelope(value: unknown): value is CFShellEnvelope {
347
+ if (value == null || typeof value !== "object") return false;
348
+ const envelope = value as Partial<CFShellEnvelope>;
349
+ return (
350
+ typeof envelope.p === "string" &&
351
+ (envelope.po === null || typeof envelope.po === "string") &&
352
+ typeof envelope.rv === "string" &&
353
+ (envelope.bv === undefined || typeof envelope.bv === "string") &&
354
+ typeof envelope.c === "number" &&
355
+ Number.isFinite(envelope.c) &&
356
+ typeof envelope.s === "number" &&
357
+ Number.isFinite(envelope.s) &&
358
+ typeof envelope.e === "number" &&
359
+ Number.isFinite(envelope.e) &&
360
+ (envelope.t === undefined ||
361
+ (Array.isArray(envelope.t) &&
362
+ envelope.t.every((tag) => typeof tag === "string"))) &&
363
+ (envelope.ta === undefined ||
364
+ (typeof envelope.ta === "number" && Number.isFinite(envelope.ta))) &&
365
+ (envelope.i === undefined || typeof envelope.i === "string") &&
366
+ (envelope.sn === undefined || Array.isArray(envelope.sn)) &&
367
+ (envelope.dk === undefined || typeof envelope.dk === "string") &&
368
+ (envelope.lh === undefined || typeof envelope.lh === "boolean") &&
369
+ (envelope.tw === undefined || envelope.tw === true) &&
370
+ (envelope.no === undefined || envelope.no === true)
371
+ );
372
+ }
373
+
312
374
  /**
313
375
  * KV envelope for document cache entries.
314
376
  * @internal
@@ -515,6 +577,36 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
515
577
  }
516
578
  }
517
579
 
580
+ /** Build-time-gated shell tier trace for deployed cross-colo diagnostics. */
581
+ private debugShell(
582
+ key: string,
583
+ outcome: CFShellDebugOutcome,
584
+ details: CFShellDebugDetails = {},
585
+ ): void {
586
+ if (!INTERNAL_RANGO_DEBUG) return;
587
+ const request = _getRequestContext()?.request as
588
+ | (Request & { cf?: { colo?: unknown } })
589
+ | undefined;
590
+ const ray = request?.headers.get("cf-ray") ?? undefined;
591
+ const cfColo = request?.cf?.colo;
592
+ const colo =
593
+ typeof cfColo === "string"
594
+ ? cfColo
595
+ : ray?.includes("-")
596
+ ? ray.slice(ray.lastIndexOf("-") + 1)
597
+ : undefined;
598
+ console.log(
599
+ `[CFCacheStore][shell] ${JSON.stringify({
600
+ key,
601
+ outcome,
602
+ at: Date.now(),
603
+ ray,
604
+ colo,
605
+ ...details,
606
+ })}`,
607
+ );
608
+ }
609
+
518
610
  /**
519
611
  * Resolve the cache-key base URL for the current cache operation.
520
612
  * Prefers an explicit `baseUrl` option; otherwise derives it from the live
@@ -1765,17 +1857,18 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1765
1857
  }
1766
1858
 
1767
1859
  // ============================================================================
1768
- // Shell Cache Methods (PPR shell resume) — KV-only in v1
1860
+ // Shell Cache Methods (PPR shell resume) — Cache API L1 + KV L2
1769
1861
  // ============================================================================
1770
1862
  //
1771
- // Unlike the segment/item/document tiers, the shell family has NO Cache-API L1
1772
- // tier: the prelude bytes + postponed blob are large and version-coupled, and a
1773
- // per-colo L1 for them is a deliberate follow-up (see the PPR shell-resume
1774
- // design doc). Shell entries live only in KV (the global tier), so the family
1775
- // requires a configured KV namespace; without one, getShell/putShell no-op and
1776
- // the integrated PPR serve path fails open to a full HTML render. Tag invalidation
1777
- // still applies: shell entries carry tags/taggedAt and are checked against the
1778
- // same KV markers isGloballyInvalidated() reads for every other tier.
1863
+ // KV remains the durable, cross-colo shell tier. Cache API is a per-colo
1864
+ // read-through accelerator: writes populate both tiers, and a valid KV hit
1865
+ // promotes the same coupled envelope into L1. The family still requires KV;
1866
+ // without it, getShell/putShell no-op and PPR fails open to a full HTML render.
1867
+ //
1868
+ // Shell L1 hits deliberately keep the KV generation-marker check even in
1869
+ // purge mode. A shell's taggedAt is its CAPTURE START, not its write time: an
1870
+ // invalidation can purge while an older capture is still running, then that
1871
+ // capture can land after the purge. The marker check rejects that resurrection.
1779
1872
 
1780
1873
  /**
1781
1874
  * Warn once per isolate that the shell family is inert: getShell/putShell
@@ -1799,10 +1892,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1799
1892
  }
1800
1893
 
1801
1894
  /**
1802
- * Get a cached PPR shell entry by key from KV (no L1). Applies the KV read
1803
- * budget, corrupt-entry eviction, hard-expiry, and tag invalidation exactly
1804
- * like kvGetItem, minus the L1 promote. SWR is a plain staleness flag — KV has
1805
- * no REVALIDATING herd guard, so the capture scheduler's module-level
1895
+ * Get a cached PPR shell entry from Cache API, falling through to KV and
1896
+ * promoting a valid KV hit. Both tiers store one envelope so the prelude,
1897
+ * postponed state, snapshot, versions, and generation metadata cannot mix.
1898
+ * SWR remains a plain staleness flag; the capture scheduler's module-level
1806
1899
  * in-flight set is the recapture stampede guard.
1807
1900
  */
1808
1901
  async getShell(
@@ -1813,56 +1906,128 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1813
1906
  return null;
1814
1907
  }
1815
1908
  try {
1816
- const kvKey = this.toKVKey(`shell:${key}`);
1817
- const { value: envelope, timedOut } =
1818
- await this.kvGetOrEvict<KVShellEnvelope>(
1819
- kvKey,
1820
- (e) =>
1821
- typeof e.p === "string" &&
1822
- (e.po === null || typeof e.po === "string") &&
1823
- typeof e.rv === "string" &&
1824
- typeof e.e === "number" &&
1825
- typeof e.s === "number",
1909
+ const cache = await this.getCache();
1910
+ const request = this.keyToRequest(`shell:${key}`);
1911
+ const matchStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
1912
+ const {
1913
+ response,
1914
+ timedOut,
1915
+ error: matchError,
1916
+ } = await this.matchWithTimeout(cache, request);
1917
+ const matchMs = INTERNAL_RANGO_DEBUG
1918
+ ? Date.now() - matchStartedAt
1919
+ : undefined;
1920
+
1921
+ if (!response) {
1922
+ if (matchError) {
1923
+ reportCacheError(
1924
+ matchError,
1925
+ "cache-read",
1926
+ "[CFCacheStore] getShell L1 match",
1927
+ );
1928
+ }
1929
+ this.debugShell(key, "l1-miss", {
1930
+ reason: matchError ? "error" : timedOut ? "timeout" : "absent",
1931
+ matchMs,
1932
+ });
1933
+ return this.kvGetShell(key);
1934
+ }
1935
+ if (response.status !== 200) {
1936
+ this.debugShell(key, "l1-miss", {
1937
+ reason: "non-200",
1938
+ status: response.status,
1939
+ matchMs,
1940
+ });
1941
+ return this.kvGetShell(key);
1942
+ }
1943
+
1944
+ const bodyStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
1945
+ const { value, errored, error } =
1946
+ await this.readJsonWithTimeout<unknown>(response);
1947
+ const bodyReadMs = INTERNAL_RANGO_DEBUG
1948
+ ? Date.now() - bodyStartedAt
1949
+ : undefined;
1950
+ if (value === undefined) {
1951
+ this.debugShell(key, "l1-miss", {
1952
+ reason: errored ? "corrupt" : "timeout",
1953
+ matchMs,
1954
+ bodyReadMs,
1955
+ });
1956
+ if (errored) {
1957
+ return this.healCorruptL1(cache, request, error, "getShell", () =>
1958
+ this.kvGetShell(key),
1959
+ );
1960
+ }
1961
+ return this.kvGetShell(key);
1962
+ }
1963
+ if (!isShellEnvelope(value)) {
1964
+ this.debugShell(key, "l1-miss", {
1965
+ reason: "malformed",
1966
+ matchMs,
1967
+ bodyReadMs,
1968
+ });
1969
+ return this.healCorruptL1(
1970
+ cache,
1971
+ request,
1972
+ new Error("malformed/partial L1 shell envelope"),
1826
1973
  "getShell",
1974
+ () => this.kvGetShell(key),
1827
1975
  );
1828
- // A timeout, a missing key, or an already-evicted corrupt entry is a miss.
1829
- if (timedOut || !envelope) return null;
1976
+ }
1830
1977
 
1831
1978
  const now = Date.now();
1832
- if (now > envelope.e) return null;
1979
+ if (now > value.e) {
1980
+ this.debugShell(key, "l1-miss", {
1981
+ reason: "expired",
1982
+ matchMs,
1983
+ bodyReadMs,
1984
+ expiresAt: value.e,
1985
+ });
1986
+ return this.kvGetShell(key);
1987
+ }
1833
1988
 
1834
- if (await this.isGloballyInvalidated(envelope.t, envelope.ta)) {
1989
+ // Unlike other L1 families, shells always check the durable generation
1990
+ // marker. See the capture-start/purge race documented above.
1991
+ const markerStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
1992
+ const invalidated = await this.isGloballyInvalidated(value.t, value.ta);
1993
+ const markerMs = INTERNAL_RANGO_DEBUG
1994
+ ? Date.now() - markerStartedAt
1995
+ : undefined;
1996
+ if (invalidated) {
1997
+ this.debugShell(key, "marker-invalidated", {
1998
+ tier: "l1",
1999
+ matchMs,
2000
+ bodyReadMs,
2001
+ markerMs,
2002
+ });
1835
2003
  return null;
1836
2004
  }
1837
2005
 
1838
- const shouldRevalidate = envelope.s > 0 && now > envelope.s;
2006
+ const shouldRevalidate = value.s > 0 && now > value.s;
2007
+ this.debugShell(key, "l1-hit", {
2008
+ freshness: shouldRevalidate ? "stale" : "fresh",
2009
+ matchMs,
2010
+ bodyReadMs,
2011
+ markerMs,
2012
+ expiresAt: value.e,
2013
+ });
1839
2014
  return {
1840
- entry: {
1841
- prelude: envelope.p,
1842
- postponed: envelope.po,
1843
- reactVersion: envelope.rv,
1844
- buildVersion: envelope.bv,
1845
- initialTheme: envelope.i,
1846
- snapshot: envelope.sn,
1847
- docKey: envelope.dk,
1848
- handlerLiveHoles: envelope.lh,
1849
- transitionWhen: envelope.tw,
1850
- navigationOnly: envelope.no,
1851
- createdAt: envelope.c,
1852
- },
2015
+ entry: this.shellEnvelopeToEntry(value),
1853
2016
  shouldRevalidate,
1854
2017
  };
1855
2018
  } catch (error) {
1856
2019
  reportCacheError(error, "cache-read", "[CFCacheStore] getShell");
1857
- return null;
2020
+ this.debugShell(key, "l1-miss", { reason: "error" });
2021
+ return this.kvGetShell(key);
1858
2022
  }
1859
2023
  }
1860
2024
 
1861
2025
  /**
1862
- * Store a PPR shell entry in KV with TTL and optional SWR window. The write is
1863
- * registered with waitUntil and awaited so invalidation rejection can be
1864
- * acknowledged to the capture scheduler. The tags/taggedAt ride in the envelope
1865
- * so isGloballyInvalidated() can invalidate the shell via the shared KV markers.
2026
+ * Store a PPR shell envelope in Cache API and, when its retention meets KV's
2027
+ * 60-second floor, KV. The shared write is registered with waitUntil and
2028
+ * awaited so invalidation rejection can be acknowledged to the capture
2029
+ * scheduler. Short-lived shells remain useful in L1 even though KV rejects
2030
+ * them.
1866
2031
  */
1867
2032
  async putShell(
1868
2033
  key: string,
@@ -1871,9 +2036,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1871
2036
  swrSeconds?: number,
1872
2037
  tags?: string[],
1873
2038
  ): Promise<"stored" | "invalidated" | void> {
1874
- // KV-only tier: needs a KV namespace and waitUntil. The same write promise is
1875
- // registered for isolate lifetime and awaited so invalidation rejection can
1876
- // be acknowledged to the capture scheduler.
2039
+ // KV remains required for durable generation markers and cross-colo reads.
1877
2040
  if (!this.kv) {
1878
2041
  this.warnShellFamilyInertOnce();
1879
2042
  return;
@@ -1883,9 +2046,6 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1883
2046
  const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
1884
2047
  const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
1885
2048
  const totalTtl = ttl + swrWindow;
1886
- // KV requires expirationTtl >= 60s; skip a shorter-lived shell rather than
1887
- // letting kv.put reject inside waitUntil (mirrors setItem/kvSetSegment).
1888
- if (totalTtl < 60) return;
1889
2049
 
1890
2050
  const retentionTtl =
1891
2051
  tags && tags.length > 0 && this.tagInvalidationTtl
@@ -1898,55 +2058,93 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1898
2058
  Array.isArray(tags) && tags.length > 0 ? entry.createdAt : undefined;
1899
2059
 
1900
2060
  const kvKey = this.toKVKey(`shell:${key}`);
1901
- // A key over the KV limit makes kv.put reject deep inside waitUntil; report
1902
- // and skip the doomed write (mirrors kvSetSegment).
2061
+ let writeKv = retentionTtl >= KV_MIN_EXPIRATION_TTL;
1903
2062
  const kvKeyBytes = kvKeyByteLength(kvKey);
1904
2063
  if (kvKeyBytes > KV_MAX_KEY_BYTES) {
1905
2064
  reportCacheError(
1906
2065
  new Error(
1907
2066
  `shell cache key produces a ${kvKeyBytes}-byte KV key, over the ` +
1908
- `${KV_MAX_KEY_BYTES}-byte limit; the shell was not persisted.`,
2067
+ `${KV_MAX_KEY_BYTES}-byte limit; the shell was not persisted to KV (L2).`,
1909
2068
  ),
1910
2069
  "cache-write",
1911
2070
  "[CFCacheStore] putShell",
1912
2071
  );
1913
- return;
2072
+ writeKv = false;
1914
2073
  }
1915
2074
 
1916
2075
  const write = (async (): Promise<"stored" | "invalidated" | void> => {
1917
- try {
1918
- if (
1919
- tags &&
1920
- tags.length > 0 &&
1921
- (await this.isGloballyInvalidated(tags, entry.createdAt))
1922
- ) {
1923
- return "invalidated";
1924
- }
1925
- const envelope: KVShellEnvelope = {
1926
- p: entry.prelude,
1927
- po: entry.postponed,
1928
- rv: entry.reactVersion,
1929
- bv: entry.buildVersion,
1930
- c: entry.createdAt,
1931
- s: staleAt,
1932
- e: expiresAt,
1933
- t: tags,
1934
- ta: taggedAt,
1935
- i: entry.initialTheme,
1936
- sn: entry.snapshot,
1937
- dk: entry.docKey,
1938
- lh: entry.handlerLiveHoles,
1939
- tw: entry.transitionWhen,
1940
- no: entry.navigationOnly,
1941
- };
1942
- await this.kv!.put(kvKey, JSON.stringify(envelope), {
1943
- expirationTtl: retentionTtl,
1944
- });
1945
- return "stored";
1946
- } catch (error) {
1947
- reportCacheError(error, "cache-write", "[CFCacheStore] putShell");
1948
- return undefined;
2076
+ if (
2077
+ tags &&
2078
+ tags.length > 0 &&
2079
+ (await this.isGloballyInvalidated(tags, entry.createdAt))
2080
+ ) {
2081
+ this.debugShell(key, "write-invalidated");
2082
+ return "invalidated";
2083
+ }
2084
+
2085
+ const envelope: CFShellEnvelope = {
2086
+ p: entry.prelude,
2087
+ po: entry.postponed,
2088
+ rv: entry.reactVersion,
2089
+ bv: entry.buildVersion,
2090
+ c: entry.createdAt,
2091
+ s: staleAt,
2092
+ e: expiresAt,
2093
+ t: tags,
2094
+ ta: taggedAt,
2095
+ i: entry.initialTheme,
2096
+ sn: entry.snapshot,
2097
+ dk: entry.docKey,
2098
+ lh: entry.handlerLiveHoles,
2099
+ tw: entry.transitionWhen,
2100
+ no: entry.navigationOnly,
2101
+ };
2102
+ const body = JSON.stringify(envelope);
2103
+ const writes: Promise<boolean>[] = [
2104
+ (async () => {
2105
+ try {
2106
+ const cache = await this.getCache();
2107
+ await cache.put(
2108
+ this.keyToRequest(`shell:${key}`),
2109
+ this.shellEnvelopeResponse(body, envelope),
2110
+ );
2111
+ this.debugShell(key, "l1-stored", {
2112
+ expiresAt: envelope.e,
2113
+ });
2114
+ return true;
2115
+ } catch (error) {
2116
+ reportCacheError(
2117
+ error,
2118
+ "cache-write",
2119
+ "[CFCacheStore] putShell L1",
2120
+ );
2121
+ return false;
2122
+ }
2123
+ })(),
2124
+ ];
2125
+ if (writeKv) {
2126
+ writes.push(
2127
+ (async () => {
2128
+ try {
2129
+ await this.kv!.put(kvKey, body, {
2130
+ expirationTtl: retentionTtl,
2131
+ });
2132
+ this.debugShell(key, "kv-stored", {
2133
+ expiresAt: envelope.e,
2134
+ });
2135
+ return true;
2136
+ } catch (error) {
2137
+ reportCacheError(
2138
+ error,
2139
+ "cache-write",
2140
+ "[CFCacheStore] putShell L2",
2141
+ );
2142
+ return false;
2143
+ }
2144
+ })(),
2145
+ );
1949
2146
  }
2147
+ return (await Promise.all(writes)).some(Boolean) ? "stored" : undefined;
1950
2148
  })();
1951
2149
  this.waitUntil(async () => {
1952
2150
  await write;
@@ -1957,6 +2155,141 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1957
2155
  }
1958
2156
  }
1959
2157
 
2158
+ /** Rebuild the public shell entry from its validated storage envelope. */
2159
+ private shellEnvelopeToEntry(envelope: CFShellEnvelope): ShellCacheEntry {
2160
+ return {
2161
+ prelude: envelope.p,
2162
+ postponed: envelope.po,
2163
+ reactVersion: envelope.rv,
2164
+ buildVersion: envelope.bv,
2165
+ initialTheme: envelope.i,
2166
+ snapshot: envelope.sn,
2167
+ docKey: envelope.dk,
2168
+ handlerLiveHoles: envelope.lh,
2169
+ transitionWhen: envelope.tw,
2170
+ navigationOnly: envelope.no,
2171
+ createdAt: envelope.c,
2172
+ };
2173
+ }
2174
+
2175
+ /** Build the Cache API representation of the coupled shell envelope. */
2176
+ private shellEnvelopeResponse(
2177
+ body: string,
2178
+ envelope: CFShellEnvelope,
2179
+ ): Response {
2180
+ const remainingTtl = Math.max(
2181
+ 1,
2182
+ Math.floor((envelope.e - Date.now()) / 1000),
2183
+ );
2184
+ return new Response(body, {
2185
+ headers: {
2186
+ "Content-Type": "application/json",
2187
+ "Cache-Control": `public, max-age=${remainingTtl}`,
2188
+ [CACHE_STALE_AT_HEADER]: String(envelope.s),
2189
+ [CACHE_EXPIRES_AT_HEADER]: String(envelope.e),
2190
+ [CACHE_STATUS_HEADER]: "HIT",
2191
+ ...this.tagHeaderEntries(envelope.t, envelope.ta),
2192
+ },
2193
+ });
2194
+ }
2195
+
2196
+ /** KV shell fallback with corruption checks and background L1 promotion. */
2197
+ private async kvGetShell(
2198
+ key: string,
2199
+ ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
2200
+ if (!this.kv) return null;
2201
+ try {
2202
+ const readStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
2203
+ const kvKey = this.toKVKey(`shell:${key}`);
2204
+ const { value: envelope, timedOut } =
2205
+ await this.kvGetOrEvict<CFShellEnvelope>(
2206
+ kvKey,
2207
+ isShellEnvelope,
2208
+ "getShell",
2209
+ );
2210
+ const readMs = INTERNAL_RANGO_DEBUG
2211
+ ? Date.now() - readStartedAt
2212
+ : undefined;
2213
+ if (timedOut || !envelope) {
2214
+ this.debugShell(key, "kv-miss", {
2215
+ reason: timedOut ? "timeout" : "unavailable",
2216
+ readMs,
2217
+ });
2218
+ return null;
2219
+ }
2220
+
2221
+ const now = Date.now();
2222
+ if (now > envelope.e) {
2223
+ this.debugShell(key, "kv-miss", {
2224
+ reason: "expired",
2225
+ readMs,
2226
+ expiresAt: envelope.e,
2227
+ });
2228
+ return null;
2229
+ }
2230
+ const markerStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
2231
+ const invalidated = await this.isGloballyInvalidated(
2232
+ envelope.t,
2233
+ envelope.ta,
2234
+ );
2235
+ const markerMs = INTERNAL_RANGO_DEBUG
2236
+ ? Date.now() - markerStartedAt
2237
+ : undefined;
2238
+ if (invalidated) {
2239
+ this.debugShell(key, "marker-invalidated", {
2240
+ tier: "kv",
2241
+ readMs,
2242
+ markerMs,
2243
+ });
2244
+ return null;
2245
+ }
2246
+
2247
+ const shouldRevalidate = envelope.s > 0 && now > envelope.s;
2248
+ this.debugShell(key, "kv-hit", {
2249
+ freshness: shouldRevalidate ? "stale" : "fresh",
2250
+ readMs,
2251
+ markerMs,
2252
+ expiresAt: envelope.e,
2253
+ });
2254
+ this.promoteShellToL1(key, envelope);
2255
+ return {
2256
+ entry: this.shellEnvelopeToEntry(envelope),
2257
+ shouldRevalidate,
2258
+ };
2259
+ } catch (error) {
2260
+ reportCacheError(error, "cache-read", "[CFCacheStore] kvGetShell");
2261
+ this.debugShell(key, "kv-miss", { reason: "error" });
2262
+ return null;
2263
+ }
2264
+ }
2265
+
2266
+ /** Promote a valid KV shell into the per-colo Cache API tier. */
2267
+ private promoteShellToL1(key: string, envelope: CFShellEnvelope): void {
2268
+ if (!this.waitUntil) return;
2269
+ this.waitUntil(() =>
2270
+ reportingAsync(
2271
+ async () => {
2272
+ if (Date.now() > envelope.e) return;
2273
+ const cache = await this.getCache();
2274
+ const body = JSON.stringify(envelope);
2275
+ await cache.put(
2276
+ this.keyToRequest(`shell:${key}`),
2277
+ this.shellEnvelopeResponse(body, envelope),
2278
+ );
2279
+ this.debugShell(key, "kv-promoted", {
2280
+ remainingTtl: Math.max(
2281
+ 1,
2282
+ Math.floor((envelope.e - Date.now()) / 1000),
2283
+ ),
2284
+ expiresAt: envelope.e,
2285
+ });
2286
+ },
2287
+ "cache-write",
2288
+ "[CFCacheStore] promoteShellToL1",
2289
+ ),
2290
+ );
2291
+ }
2292
+
1960
2293
  // ============================================================================
1961
2294
  // Key Helpers
1962
2295
  // ============================================================================
@@ -228,8 +228,10 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
228
228
  * per-request memo is consulted (synchronously, no KV read), so a request
229
229
  * that ran `updateTag()` still masks its own not-yet-purged entries
230
230
  * (read-your-own-writes).
231
- * - The KV tier and PPR shells still use the KV markers those are written
232
- * regardless, and purge cannot reach KV so keep `kv` configured for L2.
231
+ * - The KV tier and PPR shell reads still use the KV markers. Runtime shell
232
+ * L1 entries are purgeable, but their taggedAt is the capture-start time: a
233
+ * capture can finish after an invalidation purge, so eviction alone cannot
234
+ * reject that older generation. Keep `kv` configured for shell L2 + markers.
233
235
  * Without `kv`, purge mode is the ONLY tag invalidation and the store is
234
236
  * L1-only: a supported configuration (previously tag invalidation without
235
237
  * KV had no read-side effect at all). One KV-less caveat: an entry whose