@decocms/apps-vtex 7.31.6 → 7.32.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 +5 -5
- package/src/utils/fetchCache.ts +24 -168
- package/src/utils/operationRouter.ts +3 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps-vtex",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.32.0",
|
|
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.
|
|
55
|
-
"@decocms/apps-commerce": "7.
|
|
56
|
-
"@decocms/apps-website": "7.
|
|
57
|
-
"@decocms/tanstack": "7.
|
|
54
|
+
"@decocms/blocks": "7.32.0",
|
|
55
|
+
"@decocms/apps-commerce": "7.32.0",
|
|
56
|
+
"@decocms/apps-website": "7.32.0",
|
|
57
|
+
"@decocms/tanstack": "7.32.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"@tanstack/react-query": ">=5.0.0",
|
package/src/utils/fetchCache.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SWR
|
|
2
|
+
* VTEX SWR fetch cache — a thin binding over the shared, instrumented
|
|
3
|
+
* `createFetchCache` in `@decocms/blocks/sdk/fetchCache`.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|