@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 +3 -1
- package/src/cms/index.ts +3 -2
- package/src/cms/resolve.ts +33 -23
- package/src/cms/sectionLoaders.ts +56 -43
- package/src/sdk/cacheStorage.test.ts +242 -0
- package/src/sdk/cacheStorage.ts +287 -0
- package/src/sdk/cachedLoader.ts +82 -63
- package/src/sdk/fetchCache.ts +35 -32
- package/src/sdk/responseCache.ts +79 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks",
|
|
3
|
-
"version": "7.
|
|
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,
|
package/src/cms/resolve.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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 =
|
|
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.
|
|
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(
|
|
1193
|
-
|
|
1194
|
-
|
|
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
|
-
|
|
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 =
|
|
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}::${
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
167
|
-
.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
section:
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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(
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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 =
|
|
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.
|
|
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(
|
|
351
|
-
|
|
352
|
-
|
|
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
|
+
});
|