@decocms/blocks 7.63.1 → 7.64.1
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/encoding.ts +2 -5
- package/src/sdk/fetchCache.ts +35 -32
- package/src/sdk/responseCache.ts +82 -0
package/src/sdk/fetchCache.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared SWR
|
|
2
|
+
* Shared SWR fetch cache for commerce API responses.
|
|
3
3
|
*
|
|
4
4
|
* Provides in-flight deduplication + stale-while-revalidate + stale-if-error
|
|
5
5
|
* for server-side GET requests, keyed by full URL string. Ported from the
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
* tests), so there is zero cost off the instrumented path.
|
|
18
18
|
*
|
|
19
19
|
* **Provider isolation:** every {@link createFetchCache} call owns its own
|
|
20
|
-
*
|
|
21
|
-
*
|
|
20
|
+
* bounded memory tier and in-flight Map. Shared storage keys include the
|
|
21
|
+
* provider name, so VTEX / Magento / Shopify entries never collide. The `provider` string rides on the `provider` metric label so
|
|
22
22
|
* dashboards can slice hit ratio per backend.
|
|
23
23
|
*
|
|
24
24
|
* Note on why the SWR cache — not `createInstrumentedFetch` — must emit the
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { type CacheDecision, recordCacheMetric } from "../middleware/observability";
|
|
31
|
+
import { cacheBackground, createCacheStore, getCacheStorageContext } from "./cacheStorage";
|
|
31
32
|
|
|
32
33
|
/** Fresh-TTL (ms) by HTTP status class. See {@link FetchCacheConfig}. */
|
|
33
34
|
export interface FreshTtlByStatus {
|
|
@@ -129,7 +130,7 @@ function withTimeout<T>(work: Promise<T>, ms: number, label: string): Promise<T>
|
|
|
129
130
|
export function createFetchCache(config: FetchCacheConfig): FetchCache {
|
|
130
131
|
const { provider, maxEntries, freshTtlMs, staleIfErrorMs, inflightBackstopMs } = config;
|
|
131
132
|
|
|
132
|
-
const store =
|
|
133
|
+
const store = createCacheStore<CacheEntry>(`fetch:${provider}`, maxEntries);
|
|
133
134
|
const inflight = new Map<string, Promise<CacheEntry>>();
|
|
134
135
|
|
|
135
136
|
function freshTtlForStatus(status: number): number {
|
|
@@ -139,12 +140,6 @@ export function createFetchCache(config: FetchCacheConfig): FetchCache {
|
|
|
139
140
|
return 0;
|
|
140
141
|
}
|
|
141
142
|
|
|
142
|
-
function evictIfNeeded() {
|
|
143
|
-
// ponytail: Map preserves insertion order ≈ createdAt order — holds as long
|
|
144
|
-
// as updates always re-insert via store.set(key, fresh), never mutate in-place.
|
|
145
|
-
while (store.size > maxEntries) store.delete(store.keys().next().value!);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
143
|
// Single attempt on purpose — the underlying fetch (resilience layer) owns
|
|
149
144
|
// retries/backoff/breaker. Retrying here would multiply upstream calls and
|
|
150
145
|
// re-enter the breaker too fast.
|
|
@@ -165,13 +160,16 @@ export function createFetchCache(config: FetchCacheConfig): FetchCache {
|
|
|
165
160
|
recordCacheMetric(decision !== "MISS", undefined, decision, "swr", provider);
|
|
166
161
|
}
|
|
167
162
|
|
|
168
|
-
function fetchWithCache<T>(
|
|
163
|
+
async function fetchWithCache<T>(
|
|
169
164
|
cacheKey: string,
|
|
170
165
|
doFetch: () => Promise<Response>,
|
|
171
166
|
opts?: FetchCacheOptions,
|
|
172
167
|
): Promise<T | null> {
|
|
168
|
+
cacheKey = store.key(cacheKey);
|
|
169
|
+
const entry = getCacheStorageContext()?.storage
|
|
170
|
+
? await store.read(cacheKey)
|
|
171
|
+
: store.get(cacheKey);
|
|
173
172
|
const now = Date.now();
|
|
174
|
-
const entry = store.get(cacheKey);
|
|
175
173
|
|
|
176
174
|
if (entry) {
|
|
177
175
|
const maxAge = opts?.ttl ?? freshTtlForStatus(entry.status);
|
|
@@ -194,25 +192,31 @@ export function createFetchCache(config: FetchCacheConfig): FetchCache {
|
|
|
194
192
|
if (!entry.refreshing) {
|
|
195
193
|
entry.refreshing = true;
|
|
196
194
|
// Background refresh: no retry — stale data is already being served.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
195
|
+
cacheBackground(
|
|
196
|
+
withTimeout(
|
|
197
|
+
executeFetch(cacheKey, doFetch),
|
|
198
|
+
inflightBackstopMs,
|
|
199
|
+
`fetchCache stale-refresh ${cacheKey}`,
|
|
200
|
+
)
|
|
201
|
+
.then((fresh) => {
|
|
202
|
+
const ttl = opts?.ttl ?? freshTtlForStatus(fresh.status);
|
|
203
|
+
const existingWasSuccess = entry.status >= 200 && entry.status < 300;
|
|
204
|
+
const freshIsError = fresh.status >= 400;
|
|
205
|
+
const wouldDowngrade = existingWasSuccess && freshIsError;
|
|
206
|
+
if (ttl > 0 && !wouldDowngrade) {
|
|
207
|
+
store.set(
|
|
208
|
+
cacheKey,
|
|
209
|
+
fresh,
|
|
210
|
+
fresh.createdAt + ttl + (opts?.sieMs ?? staleIfErrorMs),
|
|
211
|
+
);
|
|
212
|
+
} else {
|
|
213
|
+
entry.refreshing = false;
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
.catch(() => {
|
|
210
217
|
entry.refreshing = false;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
.catch(() => {
|
|
214
|
-
entry.refreshing = false;
|
|
215
|
-
});
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
216
220
|
}
|
|
217
221
|
// Serve last-good within the SIE window: this is the stale-while-
|
|
218
222
|
// revalidate serve.
|
|
@@ -242,8 +246,7 @@ export function createFetchCache(config: FetchCacheConfig): FetchCache {
|
|
|
242
246
|
.then((fresh) => {
|
|
243
247
|
const ttl = opts?.ttl ?? freshTtlForStatus(fresh.status);
|
|
244
248
|
if (ttl > 0) {
|
|
245
|
-
store.set(cacheKey, fresh);
|
|
246
|
-
evictIfNeeded();
|
|
249
|
+
store.set(cacheKey, fresh, fresh.createdAt + ttl + (opts?.sieMs ?? staleIfErrorMs));
|
|
247
250
|
}
|
|
248
251
|
return fresh;
|
|
249
252
|
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { CacheStorage } from "./cacheStorage";
|
|
2
|
+
import { decodeBase64 } from "./encoding";
|
|
3
|
+
|
|
4
|
+
interface StoredResponse {
|
|
5
|
+
status: number;
|
|
6
|
+
statusText: string;
|
|
7
|
+
headers: [string, string][];
|
|
8
|
+
body: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** HTTP serialization belongs to the cache consumer, independent of its storage adapter. */
|
|
12
|
+
export function createResponseCache(storage: CacheStorage, scope: string) {
|
|
13
|
+
const keyFor = (request: Request) => JSON.stringify([scope, "responses", request.url]);
|
|
14
|
+
return {
|
|
15
|
+
async match(request: Request): Promise<Response | undefined> {
|
|
16
|
+
const raw = await storage.get(keyFor(request));
|
|
17
|
+
if (raw === null) return undefined;
|
|
18
|
+
try {
|
|
19
|
+
const stored = JSON.parse(raw) as StoredResponse;
|
|
20
|
+
// Uint8Array.from(string, mapper) creates a large temporary iterable array.
|
|
21
|
+
// Decode straight into the byte buffer to keep cache hits proportional to body size.
|
|
22
|
+
const body = decodeBase64(stored.body);
|
|
23
|
+
return new Response([204, 205, 304].includes(stored.status) ? null : body, {
|
|
24
|
+
status: stored.status,
|
|
25
|
+
statusText: stored.statusText,
|
|
26
|
+
headers: stored.headers,
|
|
27
|
+
});
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
async put(request: Request, response: Response): Promise<void> {
|
|
33
|
+
const cc = response.headers.get("Cache-Control") ?? "";
|
|
34
|
+
const ttl = Number(/(?:^|[,\s])max-age=(\d+)/i.exec(cc)?.[1] ?? 0);
|
|
35
|
+
if (!ttl || /(?:private|no-store|no-cache)/i.test(cc) || response.headers.has("set-cookie"))
|
|
36
|
+
return;
|
|
37
|
+
const expiresAt = Date.now() + ttl * 1000;
|
|
38
|
+
// Bound buffering before serialization; an oversized page can still stream to its caller.
|
|
39
|
+
const chunks: Uint8Array[] = [];
|
|
40
|
+
let size = 0;
|
|
41
|
+
const reader = response.body?.getReader();
|
|
42
|
+
if (reader) {
|
|
43
|
+
while (true) {
|
|
44
|
+
const { done, value } = await reader.read();
|
|
45
|
+
if (done) break;
|
|
46
|
+
size += value.byteLength;
|
|
47
|
+
if (size > 8 * 1024 * 1024) {
|
|
48
|
+
void reader.cancel().catch(() => {});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
chunks.push(value);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const bytes = new Uint8Array(size);
|
|
55
|
+
let offset = 0;
|
|
56
|
+
for (const chunk of chunks) {
|
|
57
|
+
bytes.set(chunk, offset);
|
|
58
|
+
offset += chunk.byteLength;
|
|
59
|
+
}
|
|
60
|
+
let binary = "";
|
|
61
|
+
for (let i = 0; i < bytes.length; i += 8192) {
|
|
62
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
|
63
|
+
}
|
|
64
|
+
const value: StoredResponse = {
|
|
65
|
+
status: response.status,
|
|
66
|
+
statusText: response.statusText,
|
|
67
|
+
headers: [...response.headers.entries()],
|
|
68
|
+
body: btoa(binary),
|
|
69
|
+
};
|
|
70
|
+
try {
|
|
71
|
+
await storage.set(keyFor(request), JSON.stringify(value), expiresAt);
|
|
72
|
+
} catch {
|
|
73
|
+
/* Cache writes must not fail the response or its waitUntil task. */
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
async delete(request: Request): Promise<boolean> {
|
|
77
|
+
// KV deletion has no "existed" result. Report an accepted deletion, avoiding an extra read.
|
|
78
|
+
await storage.delete(keyFor(request));
|
|
79
|
+
return true;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|