@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.63.1",
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);
@@ -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
+ });