@decocms/apps-vtex 7.31.7 → 7.32.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps-vtex",
3
- "version": "7.31.7",
3
+ "version": "7.32.1",
4
4
  "type": "module",
5
5
  "description": "Deco commerce app: VTEX integration",
6
6
  "repository": {
@@ -51,10 +51,10 @@
51
51
  "lint:unused": "knip"
52
52
  },
53
53
  "dependencies": {
54
- "@decocms/blocks": "7.31.7",
55
- "@decocms/apps-commerce": "7.31.7",
56
- "@decocms/apps-website": "7.31.7",
57
- "@decocms/tanstack": "7.31.7"
54
+ "@decocms/blocks": "7.32.1",
55
+ "@decocms/apps-commerce": "7.32.1",
56
+ "@decocms/apps-website": "7.32.1",
57
+ "@decocms/tanstack": "7.32.1"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "@tanstack/react-query": ">=5.0.0",
@@ -1,12 +1,20 @@
1
1
  /**
2
- * SWR in-memory fetch cache for VTEX API responses.
2
+ * VTEX SWR fetch cache — a thin binding over the shared, instrumented
3
+ * `createFetchCache` in `@decocms/blocks/sdk/fetchCache`.
3
4
  *
4
- * Inspired by deco-cx/deco runtime/fetch/fetchCache.ts.
5
- * Provides in-flight deduplication + stale-while-revalidate for GET requests.
6
- *
7
- * Only caches on the server side. Keyed by full URL string.
5
+ * The implementation (in-flight dedup, stale-while-revalidate, stale-if-error,
6
+ * inflight backstop) now lives ONCE in the framework so every commerce app
7
+ * shares it and emits `deco.cache.requests{layer="swr"}` automatically. This
8
+ * module just wires VTEX's tuning constants (`./constants`) and the
9
+ * `provider: "vtex"` label into a single module-level instance, preserving the
10
+ * public surface (`fetchWithCache` / `clearFetchCache` / `getFetchCacheStats` /
11
+ * `FetchCacheOptions`) that `client.ts` and existing tests depend on.
8
12
  */
9
13
 
14
+ import {
15
+ createFetchCache,
16
+ type FetchCacheOptions as SharedFetchCacheOptions,
17
+ } from "@decocms/blocks/sdk/fetchCache";
10
18
  import {
11
19
  FETCH_CACHE_FRESH_TTL_MS,
12
20
  FETCH_CACHE_INFLIGHT_BACKSTOP_MS,
@@ -14,186 +22,34 @@ import {
14
22
  FETCH_CACHE_STALE_IF_ERROR_MS,
15
23
  } from "./constants";
16
24
 
17
- interface CacheEntry {
18
- body: unknown;
19
- status: number;
20
- createdAt: number;
21
- refreshing: boolean;
22
- }
23
-
24
- function freshTtlForStatus(status: number): number {
25
- if (status >= 200 && status < 300) return FETCH_CACHE_FRESH_TTL_MS.success;
26
- if (status === 404) return FETCH_CACHE_FRESH_TTL_MS.notFound;
27
- if (status >= 500) return FETCH_CACHE_FRESH_TTL_MS.serverError;
28
- return 0;
29
- }
30
-
31
- const store = new Map<string, CacheEntry>();
32
- const inflight = new Map<string, Promise<CacheEntry>>();
33
-
34
- function evictIfNeeded() {
35
- if (store.size <= FETCH_CACHE_MAX_ENTRIES) return;
36
- const sorted = [...store.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
37
- const toRemove = sorted.slice(0, store.size - FETCH_CACHE_MAX_ENTRIES);
38
- for (const [key] of toRemove) store.delete(key);
39
- }
40
-
41
- /**
42
- * Race a Promise against a timeout so callers' `.finally()` always runs.
43
- * Critical for evicting the inflight Map entry when a `fetch()` hangs —
44
- * without this, a never-settling Promise leaks the Map slot forever and
45
- * every subsequent request for the same key joins the zombie Promise.
46
- */
47
- function withTimeout<T>(work: Promise<T>, ms: number, label: string): Promise<T> {
48
- let timer: ReturnType<typeof setTimeout> | undefined;
49
- const timeout = new Promise<never>((_, reject) => {
50
- timer = setTimeout(() => {
51
- reject(new Error(`${label} timed out after ${ms}ms`));
52
- }, ms);
53
- });
54
- return Promise.race([work, timeout]).finally(() => {
55
- clearTimeout(timer);
56
- });
57
- }
58
-
59
- async function executeFetch(url: string, doFetch: () => Promise<Response>): Promise<CacheEntry> {
60
- // Single attempt on purpose. The resilience layer (`createResilientFetch`,
61
- // wired as the VTEX fetch's baseFetch) owns retries, backoff+jitter, the
62
- // per-host retry budget, and the circuit breaker. Retrying here would:
63
- // - double-retry network errors (resilience 3× × fetchCache 3× = up to 9
64
- // upstream calls per logical request — the retry storm the budget
65
- // prevents), and
66
- // - re-enter the breaker on every 5xx retry, opening it ~3× too fast.
67
- const response = await doFetch();
25
+ export type FetchCacheOptions = SharedFetchCacheOptions;
68
26
 
69
- if (response.status >= 500) {
70
- throw new Error(`fetchWithCache: ${response.status} ${response.statusText} — ${url}`);
71
- }
72
-
73
- const body = response.ok ? await response.json() : null;
74
- return {
75
- body,
76
- status: response.status,
77
- createdAt: Date.now(),
78
- refreshing: false,
79
- };
80
- }
81
-
82
- export interface FetchCacheOptions {
83
- /**
84
- * Custom TTL in ms. If provided, overrides status-based TTL.
85
- */
86
- ttl?: number;
87
- /**
88
- * Stale-if-error window in ms. How long past the freshness TTL a last-good
89
- * entry may still be served while the origin is failing. Defaults to
90
- * {@link FETCH_CACHE_STALE_IF_ERROR_MS} (24h). Set to 0 to disable stale
91
- * serving.
92
- */
93
- sieMs?: number;
94
- }
27
+ const cache = createFetchCache({
28
+ provider: "vtex",
29
+ maxEntries: FETCH_CACHE_MAX_ENTRIES,
30
+ freshTtlMs: FETCH_CACHE_FRESH_TTL_MS,
31
+ staleIfErrorMs: FETCH_CACHE_STALE_IF_ERROR_MS,
32
+ inflightBackstopMs: FETCH_CACHE_INFLIGHT_BACKSTOP_MS,
33
+ });
95
34
 
96
35
  /**
97
36
  * Wrap a GET fetch call with SWR caching and in-flight dedup.
98
37
  *
99
38
  * Returns `null` for non-2xx responses that are cached (e.g. 404).
100
39
  * 5xx responses throw so the caller can handle them explicitly.
101
- *
102
- * @param cacheKey - Unique key (typically the full URL)
103
- * @param doFetch - The actual fetch call to execute
104
- * @param opts - Optional overrides
105
- * @returns Parsed JSON body, or null for cacheable error responses (e.g. 404)
106
40
  */
107
41
  export function fetchWithCache<T>(
108
42
  cacheKey: string,
109
43
  doFetch: () => Promise<Response>,
110
44
  opts?: FetchCacheOptions,
111
45
  ): Promise<T | null> {
112
- const now = Date.now();
113
- const entry = store.get(cacheKey);
114
-
115
- if (entry) {
116
- const maxAge = opts?.ttl ?? freshTtlForStatus(entry.status);
117
- const age = now - entry.createdAt;
118
- const isStale = age > maxAge;
119
-
120
- if (!isStale) return Promise.resolve(entry.body as T | null);
121
-
122
- // Beyond the stale-if-error window the last-good entry is too old to keep
123
- // serving during an outage: drop it and fall through to a foreground
124
- // refetch (cold path). On a healthy origin this is never reached because
125
- // the background refresh below keeps resetting `createdAt`.
126
- const sieMs = opts?.sieMs ?? FETCH_CACHE_STALE_IF_ERROR_MS;
127
- const tooStale = age > maxAge + sieMs;
128
-
129
- if (!tooStale) {
130
- if (!entry.refreshing) {
131
- entry.refreshing = true;
132
- // Background refresh: no retry — stale data is already being served.
133
- // Timeout guards against a hung VTEX response leaving `refreshing`
134
- // stuck true forever (which would silently disable revalidation).
135
- withTimeout(
136
- executeFetch(cacheKey, doFetch),
137
- FETCH_CACHE_INFLIGHT_BACKSTOP_MS,
138
- `fetchCache stale-refresh ${cacheKey}`,
139
- )
140
- .then((fresh) => {
141
- const ttl = opts?.ttl ?? freshTtlForStatus(fresh.status);
142
- const existingWasSuccess = entry.status >= 200 && entry.status < 300;
143
- const freshIsError = fresh.status >= 400;
144
- const wouldDowngrade = existingWasSuccess && freshIsError;
145
- if (ttl > 0 && !wouldDowngrade) {
146
- store.set(cacheKey, fresh);
147
- } else {
148
- entry.refreshing = false;
149
- }
150
- })
151
- .catch(() => {
152
- entry.refreshing = false;
153
- });
154
- }
155
- // Serve last-good while it is within the SIE window (stale-if-error).
156
- return Promise.resolve(entry.body as T | null);
157
- }
158
-
159
- store.delete(cacheKey);
160
- // fall through to the cold path below with the dead entry removed
161
- }
162
-
163
- const existing = inflight.get(cacheKey);
164
- if (existing) return existing.then((e) => e.body as T | null);
165
-
166
- // Wrap with a timeout so the `.finally()` below always runs and evicts
167
- // the inflight slot — even if `executeFetch` never settles. See the
168
- // FETCH_CACHE_INFLIGHT_BACKSTOP_MS doc comment (constants.ts) for the leak
169
- // this guards against.
170
- const promise = withTimeout(
171
- executeFetch(cacheKey, doFetch),
172
- FETCH_CACHE_INFLIGHT_BACKSTOP_MS,
173
- `fetchCache ${cacheKey}`,
174
- )
175
- .then((fresh) => {
176
- const ttl = opts?.ttl ?? freshTtlForStatus(fresh.status);
177
- if (ttl > 0) {
178
- store.set(cacheKey, fresh);
179
- evictIfNeeded();
180
- }
181
- return fresh;
182
- })
183
- .finally(() => inflight.delete(cacheKey));
184
-
185
- inflight.set(cacheKey, promise);
186
- return promise.then((e) => e.body as T | null);
46
+ return cache.fetchWithCache<T>(cacheKey, doFetch, opts);
187
47
  }
188
48
 
189
49
  export function clearFetchCache() {
190
- store.clear();
191
- inflight.clear();
50
+ cache.clear();
192
51
  }
193
52
 
194
53
  export function getFetchCacheStats() {
195
- return {
196
- entries: store.size,
197
- inflight: inflight.size,
198
- };
54
+ return cache.getStats();
199
55
  }
@@ -118,16 +118,10 @@ const MATCHERS: ReadonlyArray<Matcher> = [
118
118
  * });
119
119
  * ```
120
120
  */
121
+ import { extractPathname } from "@decocms/blocks/sdk/urlUtils";
122
+
121
123
  export function vtexOperationRouter(url: string, method: string): string | undefined {
122
- let pathname: string;
123
- try {
124
- pathname = new URL(url).pathname;
125
- } catch {
126
- const qs = url.indexOf("?");
127
- const hash = url.indexOf("#");
128
- const end = [qs, hash].filter((i) => i >= 0).sort((a, b) => a - b)[0];
129
- pathname = end === undefined ? url : url.slice(0, end);
130
- }
124
+ const pathname = extractPathname(url);
131
125
 
132
126
  const upperMethod = method.toUpperCase();
133
127
  for (const { pattern, operation } of MATCHERS) {