@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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Shared SWR in-memory fetch cache for commerce API responses.
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
- * `store`/`inflight` Maps, so VTEX / Magento / Shopify caches never collide in
21
- * a shared isolate. The `provider` string rides on the `provider` metric label so
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 = new Map<string, CacheEntry>();
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
- withTimeout(
198
- executeFetch(cacheKey, doFetch),
199
- inflightBackstopMs,
200
- `fetchCache stale-refresh ${cacheKey}`,
201
- )
202
- .then((fresh) => {
203
- const ttl = opts?.ttl ?? freshTtlForStatus(fresh.status);
204
- const existingWasSuccess = entry.status >= 200 && entry.status < 300;
205
- const freshIsError = fresh.status >= 400;
206
- const wouldDowngrade = existingWasSuccess && freshIsError;
207
- if (ttl > 0 && !wouldDowngrade) {
208
- store.set(cacheKey, fresh);
209
- } else {
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
+ }