@decocms/blocks 7.63.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.63.0",
3
+ "version": "7.64.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -43,6 +43,8 @@
43
43
  "./sdk/abTesting": "./src/sdk/abTesting.ts",
44
44
  "./sdk/analytics": "./src/sdk/analytics.ts",
45
45
  "./sdk/cachedLoader": "./src/sdk/cachedLoader.ts",
46
+ "./sdk/responseCache": "./src/sdk/responseCache.ts",
47
+ "./sdk/cacheStorage": "./src/sdk/cacheStorage.ts",
46
48
  "./sdk/cacheHeaders": "./src/sdk/cacheHeaders.ts",
47
49
  "./sdk/clx": "./src/sdk/clx.ts",
48
50
  "./sdk/cn": "./src/sdk/cn.ts",
package/src/cms/index.ts CHANGED
@@ -84,8 +84,8 @@ export type {
84
84
  CommerceLoader,
85
85
  DanglingReferenceHandler,
86
86
  DecoPageResult,
87
- DeferredSection,
88
87
  Deferred,
88
+ DeferredSection,
89
89
  MatcherContext,
90
90
  PageSeo,
91
91
  ResolvedSection,
@@ -93,12 +93,12 @@ export type {
93
93
  } from "./resolve";
94
94
  export {
95
95
  addSkipResolveType,
96
+ asResolved,
96
97
  BOT_UA_SUBSTRINGS,
97
98
  cacheDeferredRawProps,
98
99
  clearCommerceLoaders,
99
100
  evaluateMatcher,
100
101
  extractSeoFromProps,
101
- asResolved,
102
102
  extractSeoFromSections,
103
103
  getAsyncRenderingConfig,
104
104
  getDeferredRawProps,
@@ -107,6 +107,7 @@ export {
107
107
  isEagerRequest,
108
108
  isSeoSection,
109
109
  onBeforeResolve,
110
+ readDeferredRawProps,
110
111
  reExtractRawProps,
111
112
  registerBotPattern,
112
113
  registerCommerceLoader,
@@ -1,5 +1,7 @@
1
1
  import { getMatchersOverride, getRuleOverrideId, hasMatchersOverride } from "../matchers/override";
2
2
  import { getMeter, MetricNames, withTracing } from "../middleware/observability";
3
+ import { createCacheStore } from "../sdk/cacheStorage";
4
+ import { detectDevice } from "../sdk/detectDevice";
3
5
  import { djb2Hex } from "../sdk/djb2";
4
6
  import { stickyDecide } from "../sdk/experiments";
5
7
  import { parseSegmentCookie, SEGMENT_COOKIE, type StoredFlag, trafficToPct } from "../sdk/flags";
@@ -15,7 +17,6 @@ import {
15
17
  registerActionSchemas,
16
18
  registerLoaderSchemas,
17
19
  } from "./schema";
18
- import { detectDevice } from "../sdk/detectDevice";
19
20
  import { isLayoutSection, markSectionDegraded, runSingleSectionLoader } from "./sectionLoaders";
20
21
 
21
22
  // globalThis-backed: share state across Vite server function split modules
@@ -273,10 +274,13 @@ function isAlwaysDeferSection(key: string): boolean {
273
274
  // ---------------------------------------------------------------------------
274
275
 
275
276
  const DEFERRED_PROPS_TTL = 120_000; // 2 minutes
276
- const deferredRawPropsCache = new Map<string, { rawProps: Record<string, unknown>; ts: number }>();
277
+ const deferredRawPropsCache = createCacheStore<{ rawProps: Record<string, unknown>; ts: number }>(
278
+ "deferred-props",
279
+ 500,
280
+ );
277
281
 
278
282
  function deferredPropsCacheKey(pagePath: string, component: string, index: number): string {
279
- return `${pagePath}::${component}::${index}`;
283
+ return deferredRawPropsCache.key(`${pagePath}::${component}::${index}`);
280
284
  }
281
285
 
282
286
  export function cacheDeferredRawProps(
@@ -286,15 +290,17 @@ export function cacheDeferredRawProps(
286
290
  rawProps: Record<string, unknown>,
287
291
  ): void {
288
292
  const key = deferredPropsCacheKey(pagePath, component, index);
289
- deferredRawPropsCache.set(key, { rawProps, ts: Date.now() });
293
+ deferredRawPropsCache.set(key, { rawProps, ts: Date.now() }, Date.now() + DEFERRED_PROPS_TTL);
294
+ }
290
295
 
291
- // Lazy eviction: remove expired entries when cache grows
292
- if (deferredRawPropsCache.size > 500) {
293
- const now = Date.now();
294
- for (const [k, v] of deferredRawPropsCache) {
295
- if (now - v.ts > DEFERRED_PROPS_TTL) deferredRawPropsCache.delete(k);
296
- }
297
- }
296
+ /** Async form used when a deferred request may run in a different isolate. */
297
+ export async function readDeferredRawProps(
298
+ pagePath: string,
299
+ component: string,
300
+ index: number,
301
+ ): Promise<Record<string, unknown> | null> {
302
+ const entry = await deferredRawPropsCache.read(deferredPropsCacheKey(pagePath, component, index));
303
+ return entry?.rawProps ?? null;
298
304
  }
299
305
 
300
306
  export function getDeferredRawProps(
@@ -1148,7 +1154,7 @@ interface ResolvedSectionsCache {
1148
1154
  expiresAt: number;
1149
1155
  }
1150
1156
 
1151
- const resolvedLayoutCache = new Map<string, ResolvedSectionsCache>();
1157
+ const resolvedLayoutCache = createCacheStore<ResolvedSectionsCache>("resolved-layouts");
1152
1158
  const resolvedLayoutInflight = new Map<string, Promise<ResolvedSection[]>>();
1153
1159
 
1154
1160
  /**
@@ -1178,8 +1184,8 @@ export function layoutCacheKey(blockKey: string, matcherCtx?: MatcherContext): s
1178
1184
  return `${blockKey}::${detectDevice(matcherCtx?.userAgent ?? "")}`;
1179
1185
  }
1180
1186
 
1181
- function getCachedResolvedLayout(blockKey: string): ResolvedSection[] | null {
1182
- const entry = resolvedLayoutCache.get(blockKey);
1187
+ async function getCachedResolvedLayout(blockKey: string): Promise<ResolvedSection[] | null> {
1188
+ const entry = await resolvedLayoutCache.read(blockKey);
1183
1189
  if (!entry) return null;
1184
1190
  if (Date.now() > entry.expiresAt) {
1185
1191
  resolvedLayoutCache.delete(blockKey);
@@ -1189,10 +1195,14 @@ function getCachedResolvedLayout(blockKey: string): ResolvedSection[] | null {
1189
1195
  }
1190
1196
 
1191
1197
  function setCachedResolvedLayout(blockKey: string, sections: ResolvedSection[]): void {
1192
- resolvedLayoutCache.set(blockKey, {
1193
- sections,
1194
- expiresAt: Date.now() + RESOLVE_CACHE_TTL,
1195
- });
1198
+ resolvedLayoutCache.set(
1199
+ blockKey,
1200
+ {
1201
+ sections,
1202
+ expiresAt: Date.now() + RESOLVE_CACHE_TTL,
1203
+ },
1204
+ Date.now() + RESOLVE_CACHE_TTL,
1205
+ );
1196
1206
  }
1197
1207
 
1198
1208
  /**
@@ -2259,8 +2269,8 @@ async function resolveDecoPageImpl(
2259
2269
  const layoutKey = isRawSectionLayout(section);
2260
2270
 
2261
2271
  if (layoutKey) {
2262
- const cacheKey = layoutCacheKey(layoutKey, rctx.matcherCtx);
2263
- const cached = getCachedResolvedLayout(cacheKey);
2272
+ const cacheKey = resolvedLayoutCache.key(layoutCacheKey(layoutKey, rctx.matcherCtx));
2273
+ const cached = await getCachedResolvedLayout(cacheKey);
2264
2274
  if (cached) return cached;
2265
2275
 
2266
2276
  const inflight = resolvedLayoutInflight.get(cacheKey);
@@ -2357,8 +2367,8 @@ export async function resolvePageSections(
2357
2367
  const layoutKey = isRawSectionLayout(section);
2358
2368
 
2359
2369
  if (layoutKey) {
2360
- const cacheKey = layoutCacheKey(layoutKey, rctx.matcherCtx);
2361
- const cached = getCachedResolvedLayout(cacheKey);
2370
+ const cacheKey = resolvedLayoutCache.key(layoutCacheKey(layoutKey, rctx.matcherCtx));
2371
+ const cached = await getCachedResolvedLayout(cacheKey);
2362
2372
  if (cached) return cached;
2363
2373
 
2364
2374
  const inflight = resolvedLayoutInflight.get(cacheKey);
@@ -2449,7 +2459,7 @@ export async function resolveDeferredSectionFull(
2449
2459
  // rawProps may be stripped from the client payload — resolve from cache or page
2450
2460
  const rawProps =
2451
2461
  ds.rawProps ??
2452
- getDeferredRawProps(pagePath, ds.component, ds.index) ??
2462
+ (await readDeferredRawProps(pagePath, ds.component, ds.index)) ??
2453
2463
  (await reExtractRawProps(pagePath, ds.component, ds.index, matcherCtx));
2454
2464
 
2455
2465
  if (!rawProps) return null;
@@ -11,8 +11,8 @@
11
11
 
12
12
  import { RequestContext } from "@decocms/blocks/sdk/requestContext";
13
13
  import { getCacheProfile } from "../sdk/cacheHeaders";
14
+ import { cacheBackground, createCacheStore, getCacheStorageContext } from "../sdk/cacheStorage";
14
15
  import { detectDevice } from "../sdk/detectDevice";
15
- import { djb2 } from "../sdk/djb2";
16
16
  import { withInflightTimeout } from "../sdk/inflightTimeout";
17
17
  import { withTracing } from "../sdk/observability";
18
18
  import type { ResolvedSection } from "./resolve";
@@ -91,6 +91,8 @@ export function getDegradedSections(): string[] {
91
91
 
92
92
  interface CacheableSectionConfig {
93
93
  maxAge: number;
94
+ /** Bounded stale window in milliseconds. Default: 5 minutes. */
95
+ staleWhileRevalidate?: number;
94
96
  }
95
97
 
96
98
  export type CacheableSectionInput =
@@ -113,19 +115,10 @@ interface SectionCacheEntry {
113
115
  refreshing: boolean;
114
116
  }
115
117
 
116
- const sectionLoaderCache = new Map<string, SectionCacheEntry>();
118
+ const sectionLoaderCache = createCacheStore<SectionCacheEntry>("sections");
117
119
  const sectionLoaderInflight = new Map<string, Promise<ResolvedSection>>();
118
- const MAX_SECTION_CACHE_ENTRIES = 200;
119
-
120
- function evictSectionCacheIfNeeded() {
121
- if (sectionLoaderCache.size <= MAX_SECTION_CACHE_ENTRIES) return;
122
- const oldest = [...sectionLoaderCache.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
123
- const toDelete = oldest.slice(0, sectionLoaderCache.size - MAX_SECTION_CACHE_ENTRIES);
124
- for (const [key] of toDelete) sectionLoaderCache.delete(key);
125
- }
126
-
127
120
  function sectionCacheKey(component: string, props: Record<string, unknown>): string {
128
- return `${component}::${djb2(JSON.stringify(props))}`;
121
+ return `${component}::${JSON.stringify(props)}`;
129
122
  }
130
123
 
131
124
  /**
@@ -142,18 +135,24 @@ export function registerCacheableSections(configs: Record<string, CacheableSecti
142
135
  }
143
136
  }
144
137
 
145
- function runCacheableSectionLoader(
138
+ async function runCacheableSectionLoader(
146
139
  section: ResolvedSection,
147
140
  loader: SectionLoaderFn,
148
141
  request: Request,
149
142
  config: CacheableSectionConfig,
150
143
  ): Promise<ResolvedSection> {
151
- const key = sectionCacheKey(section.component, section.props as Record<string, unknown>);
144
+ const key = sectionLoaderCache.key(
145
+ sectionCacheKey(section.component, section.props as Record<string, unknown>),
146
+ );
152
147
 
153
148
  const existing = sectionLoaderInflight.get(key);
154
149
  if (existing) return existing;
155
150
 
156
- const entry = sectionLoaderCache.get(key);
151
+ const entry = getCacheStorageContext()?.storage
152
+ ? await sectionLoaderCache.read(key)
153
+ : sectionLoaderCache.get(key);
154
+ const pending = sectionLoaderInflight.get(key);
155
+ if (pending) return pending;
157
156
  const now = Date.now();
158
157
  const isStale = entry ? now - entry.createdAt > config.maxAge : true;
159
158
 
@@ -163,18 +162,25 @@ function runCacheableSectionLoader(
163
162
 
164
163
  if (entry && isStale && !entry.refreshing) {
165
164
  entry.refreshing = true;
166
- void Promise.resolve(loader(section.props as Record<string, unknown>, request))
167
- .then((enrichedProps) => {
168
- const enriched = { ...section, props: enrichedProps };
169
- sectionLoaderCache.set(key, {
170
- section: enriched,
171
- createdAt: Date.now(),
172
- refreshing: false,
173
- });
174
- })
175
- .catch(() => {
176
- entry.refreshing = false;
177
- });
165
+ cacheBackground(
166
+ Promise.resolve()
167
+ .then(() => loader(section.props as Record<string, unknown>, request))
168
+ .then((enrichedProps) => {
169
+ const enriched = { ...section, props: enrichedProps };
170
+ sectionLoaderCache.set(
171
+ key,
172
+ {
173
+ section: enriched,
174
+ createdAt: Date.now(),
175
+ refreshing: false,
176
+ },
177
+ Date.now() + config.maxAge + (config.staleWhileRevalidate ?? 300_000),
178
+ );
179
+ })
180
+ .catch(() => {
181
+ entry.refreshing = false;
182
+ }),
183
+ );
178
184
  return Promise.resolve(entry.section);
179
185
  }
180
186
 
@@ -184,12 +190,15 @@ function runCacheableSectionLoader(
184
190
  (async () => {
185
191
  const enrichedProps = await loader(section.props as Record<string, unknown>, request);
186
192
  const enriched = { ...section, props: enrichedProps };
187
- sectionLoaderCache.set(key, {
188
- section: enriched,
189
- createdAt: Date.now(),
190
- refreshing: false,
191
- });
192
- evictSectionCacheIfNeeded();
193
+ sectionLoaderCache.set(
194
+ key,
195
+ {
196
+ section: enriched,
197
+ createdAt: Date.now(),
198
+ refreshing: false,
199
+ },
200
+ Date.now() + config.maxAge + (config.staleWhileRevalidate ?? 300_000),
201
+ );
193
202
  return enriched;
194
203
  })(),
195
204
  `sectionLoader ${key}`,
@@ -267,7 +276,7 @@ interface CachedSection {
267
276
  expiresAt: number;
268
277
  }
269
278
 
270
- const layoutCache = new Map<string, CachedSection>();
279
+ const layoutCache = createCacheStore<CachedSection>("layout-loaders");
271
280
  const layoutInflight = new Map<string, Promise<ResolvedSection>>();
272
281
 
273
282
  /**
@@ -336,8 +345,8 @@ export function layoutLoaderCacheKey(component: string, request?: Request): stri
336
345
  return `${component}::${detectDevice(request?.headers?.get?.("user-agent") ?? "")}`;
337
346
  }
338
347
 
339
- function getCachedLayout(cacheKey: string): ResolvedSection | null {
340
- const entry = layoutCache.get(cacheKey);
348
+ async function getCachedLayout(cacheKey: string): Promise<ResolvedSection | null> {
349
+ const entry = await layoutCache.read(cacheKey);
341
350
  if (!entry) return null;
342
351
  if (Date.now() > entry.expiresAt) {
343
352
  layoutCache.delete(cacheKey);
@@ -347,21 +356,25 @@ function getCachedLayout(cacheKey: string): ResolvedSection | null {
347
356
  }
348
357
 
349
358
  function setCachedLayout(cacheKey: string, section: ResolvedSection): void {
350
- layoutCache.set(cacheKey, {
351
- section,
352
- expiresAt: Date.now() + LAYOUT_CACHE_TTL,
353
- });
359
+ layoutCache.set(
360
+ cacheKey,
361
+ {
362
+ section,
363
+ expiresAt: Date.now() + LAYOUT_CACHE_TTL,
364
+ },
365
+ Date.now() + LAYOUT_CACHE_TTL,
366
+ );
354
367
  }
355
368
 
356
369
  /**
357
370
  * Run a layout section's loader with in-flight dedup + TTL cache.
358
371
  */
359
- function resolveLayoutSection(
372
+ async function resolveLayoutSection(
360
373
  section: ResolvedSection,
361
374
  loader: SectionLoaderFn,
362
375
  request: Request,
363
376
  ): Promise<ResolvedSection> {
364
- const key = layoutLoaderCacheKey(section.component, request);
377
+ const key = layoutCache.key(layoutLoaderCacheKey(section.component, request));
365
378
  const { index } = section;
366
379
 
367
380
  // Re-apply the caller's page-specific index onto a fresh object so the
@@ -369,7 +382,7 @@ function resolveLayoutSection(
369
382
  const withIndex = (s: ResolvedSection): ResolvedSection =>
370
383
  index !== undefined ? { ...s, index } : s;
371
384
 
372
- const cached = getCachedLayout(key);
385
+ const cached = await getCachedLayout(key);
373
386
  if (cached) return Promise.resolve(withIndex(cached));
374
387
 
375
388
  const existing = layoutInflight.get(key);
@@ -283,8 +283,7 @@ export const MetricNames = {
283
283
  // the metric name already provides the deco.cache.* namespace.
284
284
  CACHE_REQUESTS: "deco.cache.requests",
285
285
  // Size in bytes of the values moving through a cache, dimensioned by `op`.
286
- // Exponential (base-2) aggregation — see METRIC_METADATA for the scale and
287
- // why an explicit-bounds histogram is the wrong shape here.
286
+ // Bucketed in decades of bytes — see CACHE_SIZE_BUCKET_BOUNDARIES_BYTES.
288
287
  CACHE_SIZE: "deco.cache.size",
289
288
  RESOLVE_DURATION: "deco.cms.resolve.duration",
290
289
  LOADER_DURATION: "deco.loader.duration",
@@ -327,6 +326,29 @@ export const DURATION_BUCKET_BOUNDARIES_SECONDS: readonly number[] = [
327
326
  10,
328
327
  ];
329
328
 
329
+ /**
330
+ * Bucket boundaries, in BYTES, for {@link MetricNames.CACHE_SIZE}.
331
+ *
332
+ * Decades, matching the `http.server.response.body.size` histogram already
333
+ * flowing through the same ingestor — a size distribution is read in orders of
334
+ * magnitude, not in linear steps, and reusing the house convention means the
335
+ * two byte histograms are directly comparable.
336
+ *
337
+ * The range is bounded on both ends by the loader cache itself: entries are
338
+ * floored at `MIN_ENTRY_BYTES` (512 B) and the store is capped at 32 MB, which
339
+ * is why the last bound is the cap rather than another decade — the top bucket
340
+ * then reads as "single entry at/near the whole cache budget", which is the
341
+ * thing worth alerting on.
342
+ */
343
+ export const CACHE_SIZE_BUCKET_BOUNDARIES_BYTES: readonly number[] = [
344
+ 1_000,
345
+ 10_000,
346
+ 100_000,
347
+ 1_000_000,
348
+ 10_000_000,
349
+ 33_554_432,
350
+ ];
351
+
330
352
  /**
331
353
  * Per-metric metadata emitted in the OTLP payload's `description` and
332
354
  * `unit` fields. Durations are normalized to **seconds at the source** (OTel
@@ -347,16 +369,6 @@ export const METRIC_METADATA: Record<
347
369
  description: string;
348
370
  unit: string;
349
371
  boundaries?: readonly number[];
350
- /**
351
- * Histogram aggregation. Omitted means `"explicit"` — bucket into
352
- * `boundaries`. `"exponential"` selects an OTel base-2 exponential
353
- * histogram at {@link scale}, where bucket `i` covers
354
- * `(2^(2^-scale))^i .. ^(i+1)]`. Adapters that don't implement it are free
355
- * to keep treating the metric as an ordinary histogram.
356
- */
357
- aggregation?: "explicit" | "exponential";
358
- /** Exponential-histogram scale. Fixed per metric; adapters never rescale. */
359
- scale?: number;
360
372
  }
361
373
  > = {
362
374
  [MetricNames.HTTP_SERVER_REQUEST_DURATION]: {
@@ -376,15 +388,7 @@ export const METRIC_METADATA: Record<
376
388
  [MetricNames.CACHE_SIZE]: {
377
389
  description: "Size in bytes of values written to / read from a cache, dimensioned by op.",
378
390
  unit: "By",
379
- // Exponential rather than explicit bounds: entries span 512 B (the
380
- // cachedLoader floor) to the 32 MB cap — five decades. Any fixed bound set
381
- // wide enough for the top is useless at the bottom.
382
- aggregation: "exponential",
383
- // scale -1 => base 4, so buckets land on 512 B · 2 K · 8 K · 32 K · 128 K ·
384
- // 512 K · 2 M · 8 M · 32 M: ~9 buckets over the real range, ~1.7 per
385
- // decade. scale 0 (base 2) would double the series count without saying
386
- // anything new about a size distribution.
387
- scale: -1,
391
+ boundaries: CACHE_SIZE_BUCKET_BOUNDARIES_BYTES,
388
392
  },
389
393
  [MetricNames.RESOLVE_DURATION]: {
390
394
  description: "Duration of `deco.cms.resolvePage` — CMS route to block tree resolution.",
@@ -0,0 +1,242 @@
1
+ // @vitest-environment node
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { clearLoaderCache, createCachedLoader } from "./cachedLoader";
4
+ import {
5
+ bindCacheStorage,
6
+ type CacheStorage,
7
+ createCacheStore,
8
+ createKVCacheStorage,
9
+ createMemoryCacheStorage,
10
+ createWebCacheStorage,
11
+ } from "./cacheStorage";
12
+ import { RequestContext } from "./requestContext";
13
+ import { createResponseCache } from "./responseCache";
14
+
15
+ function fakeKV() {
16
+ const values = new Map<string, string>();
17
+ return {
18
+ values,
19
+ get: vi.fn(async (key: string) => values.get(key) ?? null),
20
+ put: vi.fn(async (key: string, value: string, _opts: { expirationTtl: number }) => {
21
+ values.set(key, value);
22
+ }),
23
+ delete: vi.fn(async (key: string) => {
24
+ values.delete(key);
25
+ }),
26
+ };
27
+ }
28
+
29
+ const pending: Promise<unknown>[] = [];
30
+ function run<T>(storage: CacheStorage | null, scope: string, fn: () => T): T {
31
+ return RequestContext.run(new Request("https://site.test/"), () => {
32
+ bindCacheStorage({ storage, scope, waitUntil: (work) => pending.push(work) });
33
+ return fn();
34
+ });
35
+ }
36
+ async function flush() {
37
+ await Promise.all(pending.splice(0));
38
+ }
39
+ afterEach(async () => {
40
+ await flush();
41
+ clearLoaderCache();
42
+ vi.useRealTimers();
43
+ });
44
+
45
+ function fakeWebCache(): Cache {
46
+ const values = new Map<string, Response>();
47
+ return {
48
+ async match(req: Request) {
49
+ return values.get(req.url)?.clone();
50
+ },
51
+ async put(req: Request, response: Response) {
52
+ values.set(req.url, response.clone());
53
+ },
54
+ async delete(req: Request) {
55
+ return values.delete(req.url);
56
+ },
57
+ } as Cache;
58
+ }
59
+
60
+ for (const [name, make] of [
61
+ ["memory", () => createMemoryCacheStorage()],
62
+ ["KV", () => createKVCacheStorage(fakeKV())],
63
+ ["Web Cache API", () => createWebCacheStorage(fakeWebCache(), "https://site.test")],
64
+ ] as const) {
65
+ describe(`${name} storage contract`, () => {
66
+ it("round-trips, overwrites and deletes values", async () => {
67
+ const storage = make();
68
+ expect(await storage.get("missing")).toBeNull();
69
+ await storage.set("key", "first", Date.now() + 60_000);
70
+ expect(await storage.get("key")).toBe("first");
71
+ await storage.set("key", "second", Date.now() + 60_000);
72
+ expect(await storage.get("key")).toBe("second");
73
+ await storage.delete("key");
74
+ expect(await storage.get("key")).toBeNull();
75
+ });
76
+ it("enforces logical expiry even if the backend retains the record", async () => {
77
+ vi.useFakeTimers();
78
+ const storage = make();
79
+ await storage.set("short", "body", Date.now() + 500);
80
+ expect(await storage.get("short")).toBe("body");
81
+ vi.advanceTimersByTime(500);
82
+ expect(await storage.get("short")).toBeNull();
83
+ });
84
+ });
85
+ }
86
+
87
+ it("uses KV's minimum physical TTL while enforcing shorter logical expiry", async () => {
88
+ const kv = fakeKV();
89
+ await createKVCacheStorage(kv).set("a".repeat(1000), "value", Date.now() + 2000);
90
+ expect(kv.put.mock.calls[0][2]).toEqual({ expirationTtl: 60 });
91
+ expect(kv.put.mock.calls[0][0].length).toBeLessThan(512);
92
+ });
93
+
94
+ it("hydrates another store and isolates namespace, site and deployment", async () => {
95
+ const storage = createKVCacheStorage(fakeKV());
96
+ const first = createCacheStore<{ n: number }>("loaders");
97
+ await run(storage, "site:build-A", async () => {
98
+ first.set(first.key("item"), { n: 1 }, Date.now() + 60_000);
99
+ await flush();
100
+ });
101
+ const second = createCacheStore<{ n: number }>("loaders");
102
+ expect(await run(storage, "site:build-A", () => second.read(second.key("item")))).toEqual({
103
+ n: 1,
104
+ });
105
+ for (const scope of ["other-site:build-A", "site:build-B"]) {
106
+ expect(await run(storage, scope, () => second.read(second.key("item")))).toBeUndefined();
107
+ }
108
+ const sections = createCacheStore("sections");
109
+ expect(
110
+ await run(storage, "site:build-A", () => sections.read(sections.key("item"))),
111
+ ).toBeUndefined();
112
+ });
113
+
114
+ it("does not switch bindings between concurrent requests", async () => {
115
+ const a = createMemoryCacheStorage();
116
+ const b = createMemoryCacheStorage();
117
+ const cache = createCacheStore<string>("concurrent");
118
+ await Promise.all(
119
+ [a, b].map((storage, i) =>
120
+ run(storage, `site-${i}`, async () => {
121
+ await Promise.resolve();
122
+ cache.set(cache.key("key"), String(i), Date.now() + 60_000);
123
+ }),
124
+ ),
125
+ );
126
+ await flush();
127
+ cache.clear();
128
+ expect(await run(a, "site-0", () => cache.read(cache.key("key")))).toBe("0");
129
+ expect(await run(b, "site-1", () => cache.read(cache.key("key")))).toBe("1");
130
+ });
131
+
132
+ it("retains non-JSON values locally without silently corrupting them in KV", async () => {
133
+ const kv = fakeKV();
134
+ const cache = createCacheStore<{ date: Date }>("dates");
135
+ await run(createKVCacheStorage(kv), "site", async () => {
136
+ const value = { date: new Date() };
137
+ cache.set(cache.key("key"), value, Date.now() + 60_000);
138
+ expect(cache.get(cache.key("key"))).toBe(value);
139
+ await flush();
140
+ expect(kv.put).not.toHaveBeenCalled();
141
+ });
142
+ });
143
+
144
+ it("deduplicates a cold loader through slow storage and survives a memory wipe", async () => {
145
+ const storage = createKVCacheStorage(fakeKV());
146
+ const fn = vi.fn(async () => ({ product: 42 }));
147
+ const loader = createCachedLoader("test", fn, {
148
+ policy: "stale-while-revalidate",
149
+ maxAge: 60_000,
150
+ });
151
+ await run(storage, "site", async () => {
152
+ const values = await Promise.all([loader({}), loader({}), loader({})]);
153
+ expect(values).toEqual([{ product: 42 }, { product: 42 }, { product: 42 }]);
154
+ expect(fn).toHaveBeenCalledTimes(1);
155
+ await flush();
156
+ clearLoaderCache();
157
+ expect(await loader({})).toEqual({ product: 42 });
158
+ expect(fn).toHaveBeenCalledTimes(1);
159
+ });
160
+ });
161
+
162
+ it("falls back to the loader when storage reads and writes fail", async () => {
163
+ const failure = async () => {
164
+ throw new Error("KV unavailable");
165
+ };
166
+ const fn = vi.fn(async () => "origin");
167
+ const loader = createCachedLoader("failure", fn, { policy: "no-cache", maxAge: 1000 });
168
+ expect(await run({ get: failure, set: failure, delete: failure }, "site", () => loader({}))).toBe(
169
+ "origin",
170
+ );
171
+ await flush();
172
+ });
173
+
174
+ it("round-trips binary responses, headers and status through KV", async () => {
175
+ const cache = createResponseCache(createKVCacheStorage(fakeKV()), "site:build");
176
+ const req = new Request("https://site.test/image");
177
+ const bytes = new Uint8Array([0, 255, 128, 1]);
178
+ await cache.put(
179
+ req,
180
+ new Response(bytes, {
181
+ status: 203,
182
+ headers: {
183
+ "cache-control": "public, max-age=60",
184
+ "content-type": "application/octet-stream",
185
+ "x-deco-stored-at": "123",
186
+ },
187
+ }),
188
+ );
189
+ const response = await cache.match(req);
190
+ expect(response?.status).toBe(203);
191
+ expect(response?.headers.get("x-deco-stored-at")).toBe("123");
192
+ expect(new Uint8Array(await response!.arrayBuffer())).toEqual(bytes);
193
+ await cache.delete(req);
194
+ expect(await cache.match(req)).toBeUndefined();
195
+ });
196
+
197
+ it("refuses to serialize private HTTP responses or cookies", async () => {
198
+ const storage = createMemoryCacheStorage();
199
+ const cache = createResponseCache(storage, "site");
200
+ const req = new Request("https://site.test/");
201
+ await cache.put(
202
+ req,
203
+ new Response("private", { headers: { "cache-control": "private, max-age=60" } }),
204
+ );
205
+ expect(await cache.match(req)).toBeUndefined();
206
+ await cache.put(
207
+ req,
208
+ new Response("cookie", {
209
+ headers: { "cache-control": "public, max-age=60", "set-cookie": "session=secret" },
210
+ }),
211
+ );
212
+ expect(await cache.match(req)).toBeUndefined();
213
+ });
214
+
215
+ it("bounds SWR separately from stale-if-error and stops serving at the final TTL", async () => {
216
+ vi.useFakeTimers();
217
+ const storage = createKVCacheStorage(fakeKV());
218
+ let fail = false;
219
+ const origin = vi.fn(async () => {
220
+ if (fail) throw new Error("origin down");
221
+ return "last good";
222
+ });
223
+ const loader = createCachedLoader("bounded-stale", origin, {
224
+ policy: "stale-while-revalidate",
225
+ maxAge: 100,
226
+ staleWhileRevalidate: 100,
227
+ staleIfError: 300,
228
+ });
229
+ await run(storage, "bounded", async () => {
230
+ expect(await loader({})).toBe("last good");
231
+ await flush();
232
+ fail = true;
233
+ vi.advanceTimersByTime(150);
234
+ expect(await loader({})).toBe("last good");
235
+ await flush();
236
+ vi.advanceTimersByTime(100);
237
+ expect(await loader({})).toBe("last good");
238
+ expect(origin).toHaveBeenCalledTimes(3); // foreground retry once SWR has elapsed
239
+ vi.advanceTimersByTime(150);
240
+ await expect(loader({})).rejects.toThrow("origin down");
241
+ });
242
+ });