@decocms/apps-vtex 8.0.0 → 8.1.0-next.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.
Files changed (34) hide show
  1. package/package.json +5 -5
  2. package/src/__conformance__/upstream.test.ts +158 -0
  3. package/src/__tests__/client-set-cookie-forward.test.ts +4 -4
  4. package/src/client.ts +61 -4
  5. package/src/index.ts +17 -2
  6. package/src/loaders/__tests__/legacyProductList.test.ts +157 -0
  7. package/src/loaders/autocomplete.ts +2 -4
  8. package/src/loaders/intelligentSearch/productDetailsPage.ts +2 -4
  9. package/src/loaders/intelligentSearch/productList.ts +21 -5
  10. package/src/loaders/intelligentSearch/productListingPage.ts +2 -3
  11. package/src/loaders/intelligentSearch/suggestions.ts +2 -4
  12. package/src/loaders/legacy/relatedProductsLoader.ts +2 -4
  13. package/src/loaders/legacy.ts +77 -2
  14. package/src/loaders/productListFull.ts +2 -4
  15. package/src/loaders/workflow/products.ts +2 -4
  16. package/src/middleware.cacheHeaders.test.ts +44 -0
  17. package/src/mod.ts +18 -2
  18. package/src/utils/__tests__/cookieSanitizer.test.ts +1 -1
  19. package/src/utils/__tests__/storefrontBaseUrl.test.ts +61 -0
  20. package/src/utils/__tests__/transform.test.ts +349 -7
  21. package/src/utils/buildOfferShelf.test.ts +59 -0
  22. package/src/utils/enrichment.ts +3 -7
  23. package/src/utils/fetch.ts +5 -1
  24. package/src/utils/fetchCache.ts +24 -168
  25. package/src/utils/instrumentedFetch.ts +2 -1
  26. package/src/utils/minicart.ts +1 -1
  27. package/src/utils/operationRouter.ts +3 -9
  28. package/src/utils/proxy.ts +64 -4
  29. package/src/utils/similars.ts +2 -4
  30. package/src/utils/sitemap.ts +3 -2
  31. package/src/utils/transform.ts +222 -53
  32. package/src/utils/vtexId.ts +15 -2
  33. package/src/vtexClient.test.ts +204 -0
  34. package/src/vtexClient.ts +433 -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
  }
@@ -30,6 +30,7 @@
30
30
  * behavior.
31
31
  */
32
32
 
33
+ import type { FetchFn } from "@decocms/blocks/sdk/fetchTimeout";
33
34
  import {
34
35
  createInstrumentedFetch,
35
36
  type InstrumentedFetch,
@@ -45,7 +46,7 @@ export interface CreateVtexFetchOptions {
45
46
  * or routes through a proxy) to preserve its behavior while adding
46
47
  * the VTEX instrumentation layer on top.
47
48
  */
48
- baseFetch?: typeof fetch;
49
+ baseFetch?: FetchFn;
49
50
  /**
50
51
  * Disable the `http.client.request.duration` histogram emission for
51
52
  * VTEX calls. The framework's span and structured logs still emit.
@@ -71,7 +71,7 @@ function vtexItemToMinicartItem(item: OrderFormItem, index: number, coupon?: str
71
71
 
72
72
  return {
73
73
  // AnalyticsItem identifier — VTEX uses productId; sites map to numeric SKU
74
- // when needed via `Number(item.item_id)` (see bagaggio Minicart).
74
+ // when needed via `Number(item.item_id)` (see a production Minicart).
75
75
  item_id: item.id,
76
76
  item_group_id: item.productId,
77
77
  item_name: item.name ?? item.skuName ?? "",
@@ -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) {
@@ -45,6 +45,14 @@ export interface VtexProxyOptions {
45
45
  * Custom headers to inject into every proxied request.
46
46
  */
47
47
  extraHeaders?: Record<string, string>;
48
+
49
+ /**
50
+ * Force `Cache-Control: private, no-store` + `CDN-Cache-Control: no-store`
51
+ * on authenticated proxy responses (everything except static assets), so a
52
+ * personalized VTEX response can never be cached and served across users.
53
+ * @default true
54
+ */
55
+ hardenAuthenticatedCache?: boolean;
48
56
  }
49
57
 
50
58
  const DEFAULT_PROXY_PATHS = [
@@ -79,6 +87,42 @@ const HOP_BY_HOP_HEADERS = new Set([
79
87
  "upgrade",
80
88
  ]);
81
89
 
90
+ /**
91
+ * Path prefixes whose proxied responses are safe to cache publicly — genuinely
92
+ * public static assets (media files, VTEX store-theme bundles). Everything else
93
+ * routed through the VTEX proxy is authenticated/personalized by nature
94
+ * (account, checkout, /api/sessions, orders, graphql, VTEX ID) and MUST NOT be
95
+ * cached by shared/edge layers.
96
+ */
97
+ const PUBLIC_PROXY_ASSET_PREFIXES = [
98
+ "/files/",
99
+ "/arquivos/",
100
+ "/assets/vtex",
101
+ "/XMLData/",
102
+ ];
103
+
104
+ function isPublicProxyAsset(pathname: string): boolean {
105
+ return PUBLIC_PROXY_ASSET_PREFIXES.some((p) => pathname.startsWith(p));
106
+ }
107
+
108
+ /**
109
+ * Force authenticated proxy responses to be non-cacheable on every shared layer.
110
+ *
111
+ * VTEX marks some endpoints `no-store` (e.g. `/api/sessions`) but not all
112
+ * (`/account` comes back `public`), and the raw origin `Cache-Control` must
113
+ * never let a personalized response be cached across users. Without this, a
114
+ * logged-in user's response can be stored at the edge and served to another
115
+ * user — the root cause of a cross-customer data leak (see decocms/blocks#412).
116
+ * Static assets are exempt so store-theme/media caching is preserved.
117
+ */
118
+ function hardenProxyCacheHeaders(headers: Headers, pathname: string): void {
119
+ if (isPublicProxyAsset(pathname)) return;
120
+ headers.set("Cache-Control", "private, no-store, no-cache, must-revalidate");
121
+ // CDN-Cache-Control takes precedence for Cloudflare's CDN layer.
122
+ headers.set("CDN-Cache-Control", "no-store");
123
+ headers.delete("Surrogate-Control");
124
+ }
125
+
82
126
  /**
83
127
  * Returns all path prefixes that should be proxied to VTEX.
84
128
  */
@@ -199,6 +243,10 @@ export async function proxyToVtex(request: Request, options?: VtexProxyOptions):
199
243
  }
200
244
  }
201
245
 
246
+ if (options?.hardenAuthenticatedCache !== false) {
247
+ hardenProxyCacheHeaders(responseHeaders, requestUrl.pathname);
248
+ }
249
+
202
250
  return new Response(originResponse.body, {
203
251
  status: originResponse.status,
204
252
  statusText: originResponse.statusText,
@@ -211,11 +259,11 @@ export async function proxyToVtex(request: Request, options?: VtexProxyOptions):
211
259
  // ---------------------------------------------------------------------------
212
260
 
213
261
  export interface VtexCheckoutProxyConfig {
214
- /** VTEX account name (e.g. "casaevideonewio"). */
262
+ /** VTEX account name (e.g. "acme"). */
215
263
  account: string;
216
264
 
217
265
  /**
218
- * Store's public checkout domain (e.g. "secure.casaevideo.com.br").
266
+ * Store's public checkout domain (e.g. "secure.acme.com.br").
219
267
  * Checkout UI, /files/, and /_v/private/graphql are routed here.
220
268
  */
221
269
  checkoutOrigin: string;
@@ -254,6 +302,14 @@ export interface VtexCheckoutProxyConfig {
254
302
  * Receives the full HTML string and should return the modified version.
255
303
  */
256
304
  htmlTransform?: (html: string) => string;
305
+
306
+ /**
307
+ * Force `Cache-Control: private, no-store` + `CDN-Cache-Control: no-store`
308
+ * on authenticated proxy responses (everything except static assets), so a
309
+ * personalized VTEX response can never be cached and served across users.
310
+ * @default true
311
+ */
312
+ hardenAuthenticatedCache?: boolean;
257
313
  }
258
314
 
259
315
  const CF_INTERNAL_HEADERS = new Set([
@@ -316,8 +372,8 @@ function rewriteSetCookieDomain(from: Headers, to: Headers, toHostname: string)
316
372
  * @example
317
373
  * ```ts
318
374
  * const vtexProxy = createVtexCheckoutProxy({
319
- * account: "casaevideonewio",
320
- * checkoutOrigin: "secure.casaevideo.com.br",
375
+ * account: "acme",
376
+ * checkoutOrigin: "secure.acme.com.br",
321
377
  * expireCookiesOnPaths: [
322
378
  * { pathPrefix: "/api/vtexid/pub/logout", cookies: ["checkout.vtex.com"] },
323
379
  * ],
@@ -414,6 +470,10 @@ export function createVtexCheckoutProxy(
414
470
  }
415
471
  }
416
472
 
473
+ if (config.hardenAuthenticatedCache !== false) {
474
+ hardenProxyCacheHeaders(resHeaders, url.pathname);
475
+ }
476
+
417
477
  // HTML transform for checkout pages
418
478
  const ct = originRes.headers.get("content-type") ?? "";
419
479
  if (config.htmlTransform && ct.includes("text/html")) {
@@ -1,5 +1,5 @@
1
1
  import type { Product } from "@decocms/apps-commerce/types";
2
- import { getVtexConfig, vtexFetch } from "../client";
2
+ import { getVtexConfig, storefrontBaseUrl, vtexFetch } from "../client";
3
3
  import { pickSku, toProduct } from "./transform";
4
4
  import type { LegacyProduct } from "./types";
5
5
 
@@ -18,9 +18,7 @@ export const withIsSimilarTo = async (product: Product): Promise<Product> => {
18
18
  if (!rawSimilars?.length) return product;
19
19
 
20
20
  const config = getVtexConfig();
21
- const baseUrl = config.publicUrl
22
- ? `https://${config.publicUrl}`
23
- : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
21
+ const baseUrl = storefrontBaseUrl(config);
24
22
 
25
23
  const similars = rawSimilars.map((p) => {
26
24
  const sku = pickSku(p);
@@ -11,6 +11,7 @@
11
11
  * needs to expose VTEX's existing crawl tree to the public hostname.
12
12
  */
13
13
 
14
+ import { type FetchFn, withFetchTimeout } from "@decocms/blocks/sdk/fetchTimeout";
14
15
  import { getVtexConfig, vtexFetchResponse, vtexHost } from "../client";
15
16
 
16
17
  export interface SitemapEntry {
@@ -185,7 +186,7 @@ export interface VtexSitemapProxyConfig {
185
186
  * Optional fetch override — primarily for tests. Defaults to the
186
187
  * platform `fetch`.
187
188
  */
188
- fetchImpl?: typeof fetch;
189
+ fetchImpl?: FetchFn;
189
190
  }
190
191
 
191
192
  const DEFAULT_SITEMAP_CACHE_CONTROL = "public, s-maxage=3600, stale-while-revalidate=86400";
@@ -238,7 +239,7 @@ export function createVtexSitemapProxy(
238
239
  const environment = config.environment ?? "vtexcommercestable";
239
240
  const cacheControl = config.cacheControl ?? DEFAULT_SITEMAP_CACHE_CONTROL;
240
241
  const extraSitemaps = config.extraSitemaps ?? [];
241
- const fetchImpl = config.fetchImpl ?? fetch;
242
+ const fetchImpl = config.fetchImpl ?? withFetchTimeout();
242
243
 
243
244
  return async (_request: Request, url: URL): Promise<Response | null> => {
244
245
  if (!isVtexSitemapPath(url.pathname)) return null;