@decocms/blocks 7.63.1 → 7.64.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.
@@ -0,0 +1,287 @@
1
+ import { RequestContext } from "./requestContext";
2
+
3
+ /** Storage only. Values are serialized by the caller; expiration is absolute epoch ms. */
4
+ export interface CacheStorage {
5
+ get(key: string): Promise<string | null>;
6
+ set(key: string, value: string, expiresAt: number): Promise<void>;
7
+ delete(key: string): Promise<void>;
8
+ }
9
+
10
+ export interface CacheStorageContext {
11
+ storage: CacheStorage | null;
12
+ /** Include site, deployment and content revision. Never use a tenant-wide global binding. */
13
+ scope: string;
14
+ /** Bypass both memory and shared storage for previews or private requests. */
15
+ disabled?: boolean;
16
+ waitUntil?: (work: Promise<unknown>) => void;
17
+ }
18
+
19
+ const BAG_KEY = "deco:cache-storage";
20
+
21
+ export function bindCacheStorage(context: CacheStorageContext): void {
22
+ RequestContext.setBag(BAG_KEY, context);
23
+ }
24
+
25
+ export function getCacheStorageContext(): CacheStorageContext | undefined {
26
+ return RequestContext.getBag<CacheStorageContext>(BAG_KEY);
27
+ }
28
+
29
+ /** Keep optional cache work alive on Workers, without letting storage errors fail a request. */
30
+ export function cacheBackground(work: Promise<unknown>): void {
31
+ const safe = work.catch(() => {});
32
+ getCacheStorageContext()?.waitUntil?.(safe);
33
+ }
34
+
35
+ export async function hashCacheKey(key: string): Promise<string> {
36
+ const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key));
37
+ return Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, "0")).join("");
38
+ }
39
+
40
+ interface StoredValue {
41
+ value: string;
42
+ expiresAt: number;
43
+ }
44
+
45
+ function decode(raw: string | null): string | null {
46
+ if (raw === null) return null;
47
+ try {
48
+ const entry = JSON.parse(raw) as StoredValue;
49
+ return typeof entry.value === "string" &&
50
+ Number.isFinite(entry.expiresAt) &&
51
+ Date.now() < entry.expiresAt
52
+ ? entry.value
53
+ : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /** Bounded LRU adapter, useful in Node, development and tests. */
60
+ export function createMemoryCacheStorage(maxBytes = 32 * 1024 * 1024): CacheStorage {
61
+ const entries = new Map<string, string>();
62
+ let bytes = 0;
63
+ function remove(key: string) {
64
+ const old = entries.get(key);
65
+ if (old !== undefined) bytes -= (key.length + old.length) * 2;
66
+ entries.delete(key);
67
+ }
68
+ return {
69
+ async get(key) {
70
+ const raw = entries.get(key);
71
+ const value = decode(raw ?? null);
72
+ if (value === null) remove(key);
73
+ else {
74
+ entries.delete(key);
75
+ entries.set(key, raw!);
76
+ }
77
+ return value;
78
+ },
79
+ async set(key, value, expiresAt) {
80
+ remove(key);
81
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return;
82
+ const raw = JSON.stringify({ value, expiresAt });
83
+ const size = (key.length + raw.length) * 2;
84
+ if (size > maxBytes) return;
85
+ entries.set(key, raw);
86
+ bytes += size;
87
+ while (bytes > maxBytes) remove(entries.keys().next().value!);
88
+ },
89
+ async delete(key) {
90
+ remove(key);
91
+ },
92
+ };
93
+ }
94
+
95
+ /** Structural binding type; the core has no Cloudflare package dependency. */
96
+ export interface CacheKVNamespace {
97
+ get(key: string): Promise<string | null>;
98
+ put(key: string, value: string, options: { expirationTtl: number }): Promise<void>;
99
+ delete(key: string): Promise<void>;
100
+ }
101
+
102
+ export function createKVCacheStorage(
103
+ kv: CacheKVNamespace,
104
+ prefix = "deco-cache:v1:",
105
+ ): CacheStorage {
106
+ const keyFor = async (key: string) => prefix + (await hashCacheKey(key));
107
+ return {
108
+ async get(key) {
109
+ return decode(await kv.get(await keyFor(key)));
110
+ },
111
+ async set(key, value, expiresAt) {
112
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return;
113
+ const raw = JSON.stringify({ value, expiresAt });
114
+ // Leave space below KV's 25 MiB value limit. Oversized cache entries are optional.
115
+ if (new TextEncoder().encode(raw).byteLength > 25 * 1024 * 1024) return;
116
+ await kv.put(await keyFor(key), raw, {
117
+ expirationTtl: Math.max(60, Math.ceil((expiresAt - Date.now()) / 1000)),
118
+ });
119
+ },
120
+ async delete(key) {
121
+ await kv.delete(await keyFor(key));
122
+ },
123
+ };
124
+ }
125
+
126
+ /** The app supplies the native cache and its origin; no global API detection in consumers. */
127
+ export function createWebCacheStorage(
128
+ cache: Pick<Cache, "match" | "put" | "delete">,
129
+ origin: string,
130
+ ): CacheStorage {
131
+ const requestFor = async (key: string) =>
132
+ new Request(new URL(`/__deco_cache/${await hashCacheKey(key)}`, origin));
133
+ return {
134
+ async get(key) {
135
+ const response = await cache.match(await requestFor(key));
136
+ return decode(response ? await response.text() : null);
137
+ },
138
+ async set(key, value, expiresAt) {
139
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return;
140
+ await cache.put(
141
+ await requestFor(key),
142
+ new Response(JSON.stringify({ value, expiresAt }), {
143
+ headers: {
144
+ "Cache-Control": `public, max-age=${Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000))}`,
145
+ },
146
+ }),
147
+ );
148
+ },
149
+ async delete(key) {
150
+ await cache.delete(await requestFor(key));
151
+ },
152
+ };
153
+ }
154
+
155
+ /** Reject values JSON would silently change, including dates, functions and circular references. */
156
+ function serialize(value: unknown): string | undefined {
157
+ const seen = new Set<object>();
158
+ function check(v: unknown): void {
159
+ if (v === null || typeof v === "string" || typeof v === "boolean") return;
160
+ if (typeof v === "number" && Number.isFinite(v)) return;
161
+ // Optional object fields are common in section props. JSON omits them safely.
162
+ if (v === undefined) return;
163
+ if (typeof v !== "object" || seen.has(v)) throw new Error("Unsupported cached value");
164
+ if (
165
+ !Array.isArray(v) &&
166
+ Object.getPrototypeOf(v) !== Object.prototype &&
167
+ Object.getPrototypeOf(v) !== null
168
+ ) {
169
+ throw new Error("Unsupported cached object");
170
+ }
171
+ seen.add(v);
172
+ for (const item of Object.values(v)) check(item);
173
+ seen.delete(v);
174
+ }
175
+ check(value);
176
+ return JSON.stringify(value);
177
+ }
178
+
179
+ /**
180
+ * Typed cache with a bounded, decoded memory tier. All remote access uses CacheStorage.
181
+ * Call key() once per operation so a content change cannot redirect an in-flight write.
182
+ * In-flight promises remain in the calling isolate; only completed results are persisted.
183
+ */
184
+ export function createCacheStore<T>(
185
+ namespace: string,
186
+ maxEntries = 200,
187
+ maxBytes = 4 * 1024 * 1024,
188
+ sizeOf?: (value: T) => number,
189
+ ) {
190
+ const local = new Map<string, { value: T; expiresAt: number; bytes: number }>();
191
+ let bytes = 0;
192
+ let generation = 0;
193
+ function remove(key: string) {
194
+ const entry = local.get(key);
195
+ if (entry) bytes -= entry.bytes;
196
+ local.delete(key);
197
+ }
198
+ function remember(key: string, value: T, expiresAt: number) {
199
+ remove(key);
200
+ let size: number;
201
+ try {
202
+ size = sizeOf
203
+ ? sizeOf(value)
204
+ : Math.max(512, (key.length + (JSON.stringify(value)?.length ?? 0)) * 2);
205
+ } catch {
206
+ size = 1024;
207
+ }
208
+ if (size > maxBytes) return;
209
+ local.set(key, { value, expiresAt, bytes: size });
210
+ bytes += size;
211
+ while (local.size > 0 && (local.size > maxEntries || bytes > maxBytes))
212
+ remove(local.keys().next().value!);
213
+ }
214
+ function get(key: string): T | undefined {
215
+ if (getCacheStorageContext()?.disabled) return undefined;
216
+ const entry = local.get(key);
217
+ if (!entry) return undefined;
218
+ if (Date.now() >= entry.expiresAt) {
219
+ remove(key);
220
+ return undefined;
221
+ }
222
+ local.delete(key);
223
+ local.set(key, entry);
224
+ return entry.value;
225
+ }
226
+ return {
227
+ key(raw: string): string {
228
+ return JSON.stringify([getCacheStorageContext()?.scope ?? "local", namespace, raw]);
229
+ },
230
+ get,
231
+ async read(key: string): Promise<T | undefined> {
232
+ const hit = get(key);
233
+ if (hit !== undefined) return hit;
234
+ const context = getCacheStorageContext();
235
+ if (!context?.storage || context.disabled) return undefined;
236
+ const gen = generation;
237
+ try {
238
+ const raw = await context.storage.get(key);
239
+ if (gen !== generation) return undefined;
240
+ const concurrent = get(key);
241
+ if (concurrent !== undefined) return concurrent;
242
+ if (raw === null) return undefined;
243
+ const entry = JSON.parse(raw) as { value: T; expiresAt: number };
244
+ if (!Number.isFinite(entry.expiresAt) || Date.now() >= entry.expiresAt) return undefined;
245
+ remember(key, entry.value, entry.expiresAt);
246
+ return entry.value;
247
+ } catch {
248
+ return undefined;
249
+ }
250
+ },
251
+ set(key: string, value: T, expiresAt: number, persist = true): void {
252
+ if (
253
+ getCacheStorageContext()?.disabled ||
254
+ !Number.isFinite(expiresAt) ||
255
+ expiresAt <= Date.now()
256
+ )
257
+ return;
258
+ remember(key, value, expiresAt);
259
+ const storage = getCacheStorageContext()?.storage;
260
+ if (!persist || !storage || !Number.isFinite(expiresAt)) return;
261
+ try {
262
+ const raw = serialize({ value, expiresAt });
263
+ if (raw !== undefined) cacheBackground(storage.set(key, raw, expiresAt));
264
+ } catch {
265
+ /* Non-serializable results remain in the memory tier. */
266
+ }
267
+ },
268
+ delete(key: string): void {
269
+ remove(key);
270
+ },
271
+ /** Local eviction only. Shared invalidation uses a new scope/version or storage.delete(). */
272
+ clear(): void {
273
+ local.clear();
274
+ bytes = 0;
275
+ generation++;
276
+ },
277
+ get size(): number {
278
+ return local.size;
279
+ },
280
+ get estimatedBytes(): number {
281
+ return bytes;
282
+ },
283
+ *entries(): IterableIterator<[string, T]> {
284
+ for (const [key, entry] of local) yield [key, entry.value];
285
+ },
286
+ };
287
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Server-side loader caching with stale-while-revalidate + stale-if-error.
3
3
  *
4
- * Provides an in-memory cache layer for commerce loaders during SSR.
4
+ * Caches commerce loader results in memory and optional app-provided storage.
5
5
  * Supports:
6
6
  * - Single-flight dedup (identical concurrent requests share one fetch)
7
7
  * - SWR: serve stale immediately, refresh in background
@@ -19,6 +19,7 @@ import {
19
19
  withTracing,
20
20
  } from "../middleware/observability";
21
21
  import { type CacheProfileName, loaderCacheOptions } from "./cacheHeaders";
22
+ import { cacheBackground, createCacheStore, getCacheStorageContext } from "./cacheStorage";
22
23
  import { withInflightTimeout } from "./inflightTimeout";
23
24
  import { RequestContext } from "./requestContext";
24
25
 
@@ -29,14 +30,8 @@ import { RequestContext } from "./requestContext";
29
30
  // pattern already used in `../cms/blockSource.ts`.
30
31
  declare const __DECO_BUILD_HASH__: string | undefined;
31
32
 
32
- // Prefixes every in-memory loader cache key with the build hash, mirroring the
33
- // edge cache's `__v`. This is defense-in-depth, NOT the primary invalidation
34
- // lever: the cache Map is per-isolate, so a fresh isolate spun for a new deploy
35
- // already starts empty, and a warm isolate keeps running its old bundle (hence
36
- // its old BUILD) until it is recycled — the prefix cannot flush that. It only
37
- // matters if this store ever becomes shared across builds. Gated on truthiness
38
- // (empty string ⇒ unversioned) to match getBuildHash()/getDeploymentId() in
39
- // workerEntry.ts / blockSource.ts.
33
+ // Include the build version even when called outside the TanStack worker. The
34
+ // request-scoped store additionally separates sites, content revisions and segments.
40
35
  const BUILD =
41
36
  typeof __DECO_BUILD_HASH__ !== "undefined" && __DECO_BUILD_HASH__ ? __DECO_BUILD_HASH__ : "";
42
37
 
@@ -56,6 +51,8 @@ export interface CachedLoaderOptions {
56
51
  maxAge?: number;
57
52
  /** How long to serve stale on origin error, in ms. Default: 0 (no error fallback). */
58
53
  staleIfError?: number;
54
+ /** Maximum stale-while-revalidate window in ms. Default: 5 minutes. */
55
+ staleWhileRevalidate?: number;
59
56
  /** Key function to generate a cache key from loader props. Default: JSON.stringify. */
60
57
  keyFn?: (props: unknown) => string;
61
58
  }
@@ -138,8 +135,12 @@ export function getLoaderCacheMaxBytes(): number {
138
135
  return maxCacheBytes;
139
136
  }
140
137
 
141
- const cache = new Map<string, CacheEntry>();
142
- let cacheBytes = 0;
138
+ const cache = createCacheStore<CacheEntry>(
139
+ "loaders",
140
+ Infinity,
141
+ Infinity,
142
+ (entry) => entry.estimatedBytes,
143
+ );
143
144
 
144
145
  // Bumped by clearLoaderCache(). A loader invocation captures this at entry and
145
146
  // only writes its result back if the generation is unchanged when it settles —
@@ -177,26 +178,22 @@ function estimateBytes(value: unknown): number {
177
178
  * background-refresh callers route through here, which is why the size metric
178
179
  * is emitted at this chokepoint rather than at each call site.
179
180
  */
180
- function setCacheEntry<T>(key: string, entry: CacheEntry<T>, name: string) {
181
+ function setCacheEntry<T>(key: string, entry: CacheEntry<T>, name: string, retention: number) {
181
182
  recordCacheSizeMetric(entry.estimatedBytes, name);
182
- const prev = cache.get(key);
183
- if (prev) cacheBytes -= prev.estimatedBytes;
184
- cacheBytes += entry.estimatedBytes;
185
- cache.set(key, entry);
183
+ cache.set(key, entry, entry.createdAt + retention);
186
184
  }
187
185
 
188
186
  function deleteCacheEntry(key: string) {
189
- const prev = cache.get(key);
190
- if (!prev) return;
191
- cacheBytes -= prev.estimatedBytes;
192
187
  cache.delete(key);
193
188
  }
194
189
 
195
190
  function evictIfNeeded() {
191
+ let cacheBytes = cache.estimatedBytes;
196
192
  if (cacheBytes <= maxCacheBytes) return;
197
193
  const oldest = [...cache.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
198
- for (const [key] of oldest) {
194
+ for (const [key, entry] of oldest) {
199
195
  deleteCacheEntry(key);
196
+ cacheBytes -= entry.estimatedBytes;
200
197
  if (cacheBytes <= maxCacheBytes) break;
201
198
  }
202
199
  }
@@ -237,7 +234,13 @@ export function createCachedLoader<TProps, TResult>(
237
234
  optionsOrProfile: CachedLoaderOptions | CacheProfileName,
238
235
  ): (props: TProps) => Promise<TResult> {
239
236
  const resolved = resolveOptions(optionsOrProfile);
240
- const { policy, maxAge = DEFAULT_MAX_AGE, staleIfError = 0, keyFn = JSON.stringify } = resolved;
237
+ const {
238
+ policy,
239
+ maxAge = DEFAULT_MAX_AGE,
240
+ staleIfError = 0,
241
+ staleWhileRevalidate = 300_000,
242
+ keyFn = JSON.stringify,
243
+ } = resolved;
241
244
 
242
245
  const env = typeof globalThis.process !== "undefined" ? globalThis.process.env : undefined;
243
246
  const isDev = env?.DECO_CACHE_DISABLE === "true" || env?.NODE_ENV === "development";
@@ -247,14 +250,17 @@ export function createCachedLoader<TProps, TResult>(
247
250
  console.warn(
248
251
  `[cachedLoader] ${name}: maxAge=${Math.round(maxAge / 1000)}s is very long — ` +
249
252
  `upstream changes take that long to propagate. Prefer a short window and use ` +
250
- `POST /_cache/purge-loaders (or a redeploy) for immediate invalidation.`,
253
+ `short TTLs for shared storage; the purge-loaders endpoint clears memory only.`,
251
254
  );
252
255
  }
253
256
 
254
257
  if (policy === "no-store") return loaderFn;
255
258
 
256
259
  return async (props: TProps): Promise<TResult> => {
257
- const cacheKey = `${BUILD}::${name}::${keyFn(props)}`;
260
+ const cacheKey = cache.key(`${BUILD}::${name}::${keyFn(props)}`);
261
+ const retention =
262
+ maxAge +
263
+ Math.max(staleIfError, policy === "stale-while-revalidate" ? staleWhileRevalidate : 0);
258
264
  // Snapshot the cache generation; a purge during this invocation bumps it,
259
265
  // and the deferred writes below skip repopulating a just-cleared entry.
260
266
  const gen = cacheGeneration;
@@ -295,7 +301,14 @@ export function createCachedLoader<TProps, TResult>(
295
301
  return promise;
296
302
  }
297
303
 
298
- const entry = cache.get(cacheKey) as CacheEntry<TResult> | undefined;
304
+ let entry = cache.get(cacheKey) as CacheEntry<TResult> | undefined;
305
+ if (!entry && getCacheStorageContext()?.storage) {
306
+ entry = (await cache.read(cacheKey)) as CacheEntry<TResult> | undefined;
307
+ evictIfNeeded();
308
+ }
309
+ // Another caller may have started the origin fetch while storage was being read.
310
+ const pending = inflightRequests.get(cacheKey);
311
+ if (pending) return pending as Promise<TResult>;
299
312
  const now = Date.now();
300
313
  const isStale = entry ? now - entry.createdAt > maxAge : true;
301
314
 
@@ -314,45 +327,52 @@ export function createCachedLoader<TProps, TResult>(
314
327
  return entry.value;
315
328
  }
316
329
 
317
- if (entry && isStale && !entry.refreshing) {
330
+ if (
331
+ entry &&
332
+ isStale &&
333
+ now - entry.createdAt <= maxAge + staleWhileRevalidate &&
334
+ !entry.refreshing
335
+ ) {
318
336
  // Stale-while-revalidate hit: serve stale, refresh in background.
319
337
  recordCacheMetric(true, name, "STALE-HIT", "cachedLoader");
320
338
  recordLoaderMetric(name, 0, "STALE-HIT");
321
339
  entry.refreshing = true;
322
- loaderFn(props)
323
- .then((result) => {
324
- // Skip the write if a purge cleared the cache mid-refresh — otherwise
325
- // we'd re-insert pre-purge data the purge was meant to drop.
326
- if (gen !== cacheGeneration) return;
327
- setCacheEntry(
328
- cacheKey,
329
- {
330
- value: result,
331
- createdAt: Date.now(),
332
- refreshing: false,
333
- estimatedBytes: estimateBytes(result),
334
- },
335
- name,
336
- );
337
- evictIfNeeded();
338
- })
339
- .catch(() => {
340
- // Background refresh failed — entry stays stale.
341
- // If past the SIE window, evict so we don't serve indefinitely stale data.
342
- entry.refreshing = false;
343
- if (staleIfError > 0 && now - entry.createdAt > maxAge + staleIfError) {
344
- deleteCacheEntry(cacheKey);
345
- }
346
- });
340
+ cacheBackground(
341
+ Promise.resolve()
342
+ .then(() => loaderFn(props))
343
+ .then((result) => {
344
+ // Skip the write if a purge cleared the cache mid-refresh — otherwise
345
+ // we'd re-insert pre-purge data the purge was meant to drop.
346
+ if (gen !== cacheGeneration) return;
347
+ setCacheEntry(
348
+ cacheKey,
349
+ {
350
+ value: result,
351
+ createdAt: Date.now(),
352
+ refreshing: false,
353
+ estimatedBytes: estimateBytes(result),
354
+ },
355
+ name,
356
+ retention,
357
+ );
358
+ evictIfNeeded();
359
+ })
360
+ .catch(() => {
361
+ // Background refresh failed — entry stays stale.
362
+ // If past the SIE window, evict so we don't serve indefinitely stale data.
363
+ entry.refreshing = false;
364
+ if (staleIfError > 0 && now - entry.createdAt > maxAge + staleIfError) {
365
+ deleteCacheEntry(cacheKey);
366
+ }
367
+ }),
368
+ );
347
369
  return entry.value;
348
370
  }
349
371
 
350
- if (entry) {
351
- // Past SIE window — still serve the stale value once but mark
352
- // the decision as STALE-ERROR so dashboards can distinguish
353
- // this from healthy SWR.
354
- recordCacheMetric(true, name, "STALE-ERROR", "cachedLoader");
355
- recordLoaderMetric(name, 0, "STALE-ERROR");
372
+ if (entry && now - entry.createdAt <= maxAge + staleWhileRevalidate) {
373
+ // A background refresh is already running in this isolate.
374
+ recordCacheMetric(true, name, "STALE-HIT", "cachedLoader");
375
+ recordLoaderMetric(name, 0, "STALE-HIT");
356
376
  return entry.value;
357
377
  }
358
378
  }
@@ -382,6 +402,7 @@ export function createCachedLoader<TProps, TResult>(
382
402
  estimatedBytes: estimateBytes(result),
383
403
  },
384
404
  name,
405
+ retention,
385
406
  );
386
407
  evictIfNeeded();
387
408
  }
@@ -390,7 +411,7 @@ export function createCachedLoader<TProps, TResult>(
390
411
  .catch((err) => {
391
412
  // SIE fallback: if we have a stale entry within the error window, return it
392
413
  if (staleIfError > 0 && entry) {
393
- const age = now - entry.createdAt;
414
+ const age = Date.now() - entry.createdAt;
394
415
  if (age < maxAge + staleIfError) {
395
416
  console.warn(
396
417
  `[cachedLoader] ${name}: origin error, serving stale entry (age=${Math.round(age / 1000)}s, sie=${Math.round(staleIfError / 1000)}s)`,
@@ -468,7 +489,7 @@ export function createCachedLoaderFromModule<TProps, TResult>(
468
489
  // Explicit null → the loader declared this call uncacheable: run fresh.
469
490
  if (keyPart === null) return mod.default(props, req);
470
491
 
471
- const key = `${BUILD}::${name}::${keyPart}`;
492
+ const key = cache.key(`${BUILD}::${name}::${keyPart}`);
472
493
  const existing = moduleInflight.get(key) as Promise<TResult> | undefined;
473
494
  if (existing) return existing;
474
495
 
@@ -512,15 +533,13 @@ export function createLoaderEntry<TProps = any, TResult = any>(
512
533
  /**
513
534
  * Clear all cached entries in THIS isolate. Used both by decofile hot-reload
514
535
  * (`@decocms/blocks-admin`) and by the `POST /_cache/purge-loaders` route — an
515
- * escape hatch to invalidate immediately (e.g. after an out-of-band Magento/
516
- * catalog sync) without waiting out the TTL. Per-isolate: the route must be hit
517
- * for every isolate; a redeploy replaces isolates wholesale. Bumps the cache
536
+ * memory eviction hook. Shared storage entries expire by TTL and may hydrate
537
+ * memory again. Deployment/content scopes prevent reuse across versions. Bumps the cache
518
538
  * generation so any loader in flight at purge time won't repopulate a cleared
519
539
  * entry with pre-purge data.
520
540
  */
521
541
  export function clearLoaderCache() {
522
542
  cache.clear();
523
- cacheBytes = 0;
524
543
  inflightRequests.clear();
525
544
  moduleInflight.clear();
526
545
  cacheGeneration++;
@@ -531,7 +550,7 @@ export function getLoaderCacheStats() {
531
550
  return {
532
551
  entries: cache.size,
533
552
  inflight: inflightRequests.size,
534
- estimatedBytes: cacheBytes,
553
+ estimatedBytes: cache.estimatedBytes,
535
554
  maxBytes: maxCacheBytes,
536
555
  };
537
556
  }