@decocms/apps-vtex 8.1.0-next.0 → 8.1.0-next.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.
Files changed (139) hide show
  1. package/package.json +7 -52
  2. package/src/index.ts +10 -28
  3. package/src/utils/constants.ts +2 -56
  4. package/src/utils/cookieSanitizer.ts +59 -59
  5. package/src/utils/operationRouter.ts +75 -65
  6. package/src/utils/types.ts +709 -1557
  7. package/src/vtexClient.ts +354 -343
  8. package/src/README.md +0 -6
  9. package/src/__conformance__/upstream.test.ts +0 -158
  10. package/src/__tests__/client-segment-cookie.test.ts +0 -255
  11. package/src/__tests__/client-set-cookie-forward.test.ts +0 -257
  12. package/src/__tests__/schemas.test.ts +0 -71
  13. package/src/actions/address.ts +0 -250
  14. package/src/actions/analytics/sendEvent.ts +0 -86
  15. package/src/actions/auth.ts +0 -333
  16. package/src/actions/checkout.ts +0 -659
  17. package/src/actions/index.ts +0 -11
  18. package/src/actions/masterData.ts +0 -168
  19. package/src/actions/misc.ts +0 -188
  20. package/src/actions/newsletter.ts +0 -105
  21. package/src/actions/orders.ts +0 -34
  22. package/src/actions/profile.ts +0 -201
  23. package/src/actions/session.ts +0 -85
  24. package/src/actions/trigger.ts +0 -43
  25. package/src/actions/wishlist.ts +0 -114
  26. package/src/client.ts +0 -724
  27. package/src/commerceLoaders.ts +0 -267
  28. package/src/hooks/__tests__/createCart.render.test.ts +0 -114
  29. package/src/hooks/__tests__/createCart.test.ts +0 -92
  30. package/src/hooks/__tests__/createUseCart.test.ts +0 -184
  31. package/src/hooks/__tests__/createUseUser.test.ts +0 -48
  32. package/src/hooks/__tests__/createUseWishlist.test.ts +0 -87
  33. package/src/hooks/cartQuery.ts +0 -204
  34. package/src/hooks/createCart.ts +0 -427
  35. package/src/hooks/createUseCart.ts +0 -360
  36. package/src/hooks/createUseUser.ts +0 -153
  37. package/src/hooks/createUseWishlist.ts +0 -242
  38. package/src/hooks/index.ts +0 -25
  39. package/src/hooks/useAutocomplete.ts +0 -83
  40. package/src/hooks/useCart.ts +0 -243
  41. package/src/hooks/useUser.ts +0 -78
  42. package/src/hooks/useWishlist.ts +0 -119
  43. package/src/invoke.ts +0 -235
  44. package/src/loaders/ProductDetailsPage.ts +0 -1
  45. package/src/loaders/ProductList.ts +0 -1
  46. package/src/loaders/ProductListingPage.ts +0 -1
  47. package/src/loaders/__tests__/legacyProductList.test.ts +0 -157
  48. package/src/loaders/address.ts +0 -115
  49. package/src/loaders/autocomplete.ts +0 -56
  50. package/src/loaders/brands.ts +0 -44
  51. package/src/loaders/cart/attachments.ts +0 -53
  52. package/src/loaders/cart/full.ts +0 -67
  53. package/src/loaders/cart/gifts.ts +0 -43
  54. package/src/loaders/cart/orderFormId.ts +0 -31
  55. package/src/loaders/cart/shipping.test.ts +0 -125
  56. package/src/loaders/cart/shipping.ts +0 -136
  57. package/src/loaders/cart/summary.ts +0 -36
  58. package/src/loaders/cart.ts +0 -52
  59. package/src/loaders/catalog.ts +0 -163
  60. package/src/loaders/collections.ts +0 -55
  61. package/src/loaders/index.ts +0 -20
  62. package/src/loaders/intelligentSearch/__tests__/productListingPage.test.ts +0 -51
  63. package/src/loaders/intelligentSearch/productDetailsPage.ts +0 -111
  64. package/src/loaders/intelligentSearch/productList.ts +0 -170
  65. package/src/loaders/intelligentSearch/productListingPage.ts +0 -525
  66. package/src/loaders/intelligentSearch/suggestions.ts +0 -44
  67. package/src/loaders/legacy/productDetailsPage.ts +0 -1
  68. package/src/loaders/legacy/productList.ts +0 -1
  69. package/src/loaders/legacy/relatedProductsLoader.ts +0 -79
  70. package/src/loaders/legacy.ts +0 -710
  71. package/src/loaders/logistics.ts +0 -111
  72. package/src/loaders/minicart.ts +0 -78
  73. package/src/loaders/navbar.ts +0 -26
  74. package/src/loaders/orders.ts +0 -92
  75. package/src/loaders/pageType.ts +0 -68
  76. package/src/loaders/paths/PDPDefaultPath.ts +0 -51
  77. package/src/loaders/payment.ts +0 -97
  78. package/src/loaders/productListFull.ts +0 -157
  79. package/src/loaders/profile.ts +0 -138
  80. package/src/loaders/promotion.ts +0 -30
  81. package/src/loaders/search.ts +0 -124
  82. package/src/loaders/session.ts +0 -83
  83. package/src/loaders/user.ts +0 -87
  84. package/src/loaders/wishlist.ts +0 -89
  85. package/src/loaders/wishlistProducts.ts +0 -69
  86. package/src/loaders/workflow/products.ts +0 -62
  87. package/src/loaders/workflow.ts +0 -316
  88. package/src/logo.png +0 -0
  89. package/src/manifest.gen.ts +0 -89
  90. package/src/middleware.cacheHeaders.test.ts +0 -44
  91. package/src/middleware.ts +0 -240
  92. package/src/mod.ts +0 -173
  93. package/src/registry.ts +0 -9
  94. package/src/schemas.gen.ts +0 -3191
  95. package/src/schemas.ts +0 -30
  96. package/src/types.ts +0 -248
  97. package/src/utils/__tests__/cartProjection.test.ts +0 -118
  98. package/src/utils/__tests__/cookieSanitizer.test.ts +0 -162
  99. package/src/utils/__tests__/fetch.test.ts +0 -80
  100. package/src/utils/__tests__/fetchCache.test.ts +0 -155
  101. package/src/utils/__tests__/instrumentedFetch.test.ts +0 -158
  102. package/src/utils/__tests__/intelligentSearch.test.ts +0 -88
  103. package/src/utils/__tests__/minicart.test.ts +0 -184
  104. package/src/utils/__tests__/operationRouter.test.ts +0 -227
  105. package/src/utils/__tests__/resilience.test.ts +0 -205
  106. package/src/utils/__tests__/resourceRange.test.ts +0 -32
  107. package/src/utils/__tests__/simulationCache.test.ts +0 -40
  108. package/src/utils/__tests__/sitemap.test.ts +0 -185
  109. package/src/utils/__tests__/slugify.test.ts +0 -31
  110. package/src/utils/__tests__/storefrontBaseUrl.test.ts +0 -61
  111. package/src/utils/__tests__/transform.test.ts +0 -1111
  112. package/src/utils/accountLoaders.ts +0 -203
  113. package/src/utils/authHelpers.ts +0 -85
  114. package/src/utils/batch.ts +0 -18
  115. package/src/utils/buildOfferShelf.test.ts +0 -59
  116. package/src/utils/cartProjection.ts +0 -96
  117. package/src/utils/cookies.ts +0 -167
  118. package/src/utils/enrichment.ts +0 -556
  119. package/src/utils/fetch.ts +0 -111
  120. package/src/utils/fetchCache.ts +0 -55
  121. package/src/utils/index.ts +0 -19
  122. package/src/utils/instrumentedFetch.ts +0 -98
  123. package/src/utils/intelligentSearch.ts +0 -96
  124. package/src/utils/legacy.ts +0 -139
  125. package/src/utils/minicart.ts +0 -139
  126. package/src/utils/pickAndOmit.ts +0 -25
  127. package/src/utils/proxy.ts +0 -495
  128. package/src/utils/resilience.ts +0 -352
  129. package/src/utils/resourceRange.ts +0 -10
  130. package/src/utils/segment.ts +0 -152
  131. package/src/utils/similars.ts +0 -35
  132. package/src/utils/simulationCache.ts +0 -73
  133. package/src/utils/sitemap.ts +0 -283
  134. package/src/utils/slugCache.ts +0 -28
  135. package/src/utils/slugify.ts +0 -13
  136. package/src/utils/transform.ts +0 -1691
  137. package/src/utils/vtexId.ts +0 -151
  138. package/src/vtexClient.test.ts +0 -204
  139. package/tsconfig.json +0 -7
package/src/client.ts DELETED
@@ -1,724 +0,0 @@
1
- /**
2
- * VTEX API Client for TanStack Start.
3
- * Uses VTEX's public REST APIs (Intelligent Search + Catalog + Checkout).
4
- */
5
-
6
- import { type FetchFn, withFetchTimeout } from "@decocms/blocks/sdk/fetchTimeout";
7
- import type {
8
- InstrumentedFetch,
9
- InstrumentedFetchInit,
10
- } from "@decocms/blocks/sdk/instrumentedFetch";
11
- import { RequestContext } from "@decocms/blocks/sdk/requestContext";
12
- import { sanitizeOutboundCookieHeader, warnDroppedCookies } from "./utils/cookieSanitizer";
13
- import { type FetchCacheOptions, fetchWithCache } from "./utils/fetchCache";
14
- import { ANONYMOUS_COOKIE, SESSION_COOKIE } from "./utils/intelligentSearch";
15
- import { parseSegment, SEGMENT_COOKIE_NAME } from "./utils/segment";
16
-
17
- /**
18
- * Outgoing response headers for the active request, or `null` when
19
- * called outside a request scope (which happens during module init).
20
- * `RequestContext.responseHeaders` was added to `@decocms/start` in
21
- * v0.39.0; we now require >=2.5.0 as a devDep so the property is
22
- * always typed/present.
23
- */
24
- function getResponseHeaders(): Headers | null {
25
- const ctx = RequestContext.current;
26
- return ctx ? ctx.responseHeaders : null;
27
- }
28
-
29
- /**
30
- * Hostname of the active storefront request, or `null` outside a request
31
- * scope. Used to rewrite the `Domain` attribute of VTEX `Set-Cookie`
32
- * headers so server-function cookies are scoped identically to the ones
33
- * `createVtexCheckoutProxy` emits (`rewriteSetCookieDomain` → `url.hostname`)
34
- * and to what VTEX itself sets natively (`domain=<host>`).
35
- */
36
- function getRequestHost(): string | null {
37
- const ctx = RequestContext.current;
38
- if (!ctx) return null;
39
- try {
40
- return new URL(ctx.request.url).hostname;
41
- } catch {
42
- return null;
43
- }
44
- }
45
-
46
- /**
47
- * Normalize a VTEX `Set-Cookie` so the browser accepts it on the storefront
48
- * host AND so it lands at the SAME cookie scope as the checkout proxy.
49
- *
50
- * VTEX sets `checkout.vtex.com` / `CheckoutOrderFormOwnership` with
51
- * `domain=<vtex-host>` (e.g. `acme.vtexcommercestable.com.br`),
52
- * which the browser would reject on the storefront host. There are two ways
53
- * to make it acceptable:
54
- *
55
- * - strip the `Domain` attribute → host-only cookie
56
- * - rewrite `Domain` to `<host>` → domain-scoped cookie
57
- *
58
- * The checkout proxy does the latter. If this path does the former, the
59
- * cart's cookie (host-only) and the proxy's cookie (domain-scoped) become
60
- * TWO DISTINCT cookies in the browser: they don't overwrite each other, can
61
- * drift to different orderForm ids, and VTEX reads whichever is sent last
62
- * (RFC 6265 §5.4 orders by creation time) — a nondeterministic empty-cart
63
- * bug. Rewriting (instead of stripping) keeps both writers on the same key
64
- * so the newest write always wins. When the host is unknown (only at module
65
- * init, never inside a real request) we fall back to stripping.
66
- */
67
- function rewriteCookieDomain(cookie: string, host: string | null): string {
68
- // Anchor to an attribute boundary (`; `) so we never touch a `domain=`
69
- // substring that happens to live inside the cookie value (before the
70
- // first `;`).
71
- return host
72
- ? cookie.replace(/(;\s*)domain=[^;]*/i, `$1Domain=${host}`)
73
- : cookie.replace(/;\s*domain=[^;]*/gi, "");
74
- }
75
-
76
- // ---------------------------------------------------------------------------
77
- // URL sanitization (ported from deco-cx/apps vtex/utils/fetchVTEX.ts)
78
- // ---------------------------------------------------------------------------
79
-
80
- const removeNonLatin1Chars = (str: string): string => str.replace(/[^\x00-\x7F]|["']/g, "");
81
-
82
- const removeScriptChars = (str: string): string => {
83
- return str
84
- .replace(/\+/g, "")
85
- .replaceAll(" ", "")
86
- .replace(/[[\]{}()<>]/g, "")
87
- .replace(/[/\\]/g, "")
88
- .replace(/\./g, "")
89
- .normalize("NFD")
90
- .replace(/[\u0300-\u036f]/g, "");
91
- };
92
-
93
- function sanitizeUrl(input: string): string {
94
- let url: URL;
95
- try {
96
- url = new URL(input);
97
- } catch {
98
- return input;
99
- }
100
-
101
- const QS_TO_SANITIZE = ["utm_campaign", "utm_medium", "utm_source", "map"];
102
- for (const qs of QS_TO_SANITIZE) {
103
- if (url.searchParams.has(qs)) {
104
- const values = url.searchParams.getAll(qs);
105
- url.searchParams.delete(qs);
106
- for (const v of values) {
107
- const sanitized = removeScriptChars(removeNonLatin1Chars(v));
108
- if (sanitized) url.searchParams.append(qs, sanitized);
109
- }
110
- }
111
- }
112
-
113
- const QS_TO_ENCODE = ["ft"];
114
- for (const qs of QS_TO_ENCODE) {
115
- if (url.searchParams.has(qs)) {
116
- const values = url.searchParams.getAll(qs);
117
- url.searchParams.delete(qs);
118
- for (const v of values) {
119
- url.searchParams.append(qs, encodeURIComponent(v.trim()));
120
- }
121
- }
122
- }
123
-
124
- return url.toString();
125
- }
126
-
127
- // ---------------------------------------------------------------------------
128
- // Config
129
- // ---------------------------------------------------------------------------
130
-
131
- export interface VtexConfig {
132
- account: string;
133
- publicUrl?: string;
134
- salesChannel?: string;
135
- locale?: string;
136
- appKey?: string;
137
- appToken?: string;
138
- /**
139
- * ISO 3166-1 alpha-3 country code used for simulation/checkout.
140
- * @default "BRA"
141
- */
142
- country?: string;
143
- /**
144
- * VTEX domain suffix. Override for non-standard VTEX environments.
145
- * @default "com.br"
146
- */
147
- domain?: string;
148
- }
149
-
150
- let _config: VtexConfig | null = null;
151
- let _fetch: FetchFn | InstrumentedFetch = withFetchTimeout();
152
-
153
- export function configureVtex(config: VtexConfig) {
154
- _config = config;
155
- console.log(`[VTEX] Configured: ${config.account}.vtexcommercestable.com.br`);
156
- }
157
-
158
- /**
159
- * Override the fetch function used by all VTEX client calls.
160
- * Pass an `InstrumentedFetch` to get spans, traceparent injection,
161
- * URL redaction, and the canonical `http.client.request.duration` histogram —
162
- * use the pre-wired `createVtexFetch()` factory:
163
- *
164
- * ```ts
165
- * import { setVtexFetch, createVtexFetch } from "@decocms/apps/vtex";
166
- * setVtexFetch(createVtexFetch());
167
- * ```
168
- *
169
- * Accepts a plain `typeof fetch` too; in that mode VTEX calls are
170
- * uninstrumented (useful for tests + sites that haven't onboarded
171
- * the observability stack yet).
172
- */
173
- export function setVtexFetch(fetchFn: FetchFn | InstrumentedFetch) {
174
- _fetch = fetchFn;
175
- }
176
-
177
- /**
178
- * Read-only accessor for the configured VTEX fetch. Used by ad-hoc
179
- * callsites that don't fit the `vtexFetch*` helpers (FormData
180
- * uploads, the storefront proxies, .aspx endpoints) but still want
181
- * to participate in the instrumentation set up via `setVtexFetch`.
182
- *
183
- * Callers can stamp a per-call operation through the init:
184
- *
185
- * ```ts
186
- * const fetch = getVtexFetch();
187
- * await fetch(url, { method: "POST", operation: "notifyme" });
188
- * ```
189
- */
190
- export function getVtexFetch(): InstrumentedFetch {
191
- return _fetch as InstrumentedFetch;
192
- }
193
-
194
- export function getVtexConfig(): VtexConfig {
195
- if (!_config) throw new Error("VTEX not configured. Call configureVtex() first.");
196
- return _config;
197
- }
198
-
199
- /**
200
- * Build the VTEX hostname for a given environment.
201
- * Centralizes `{account}.{env}.{domain}` so nothing is hardcoded.
202
- */
203
- export function vtexHost(environment: string = "vtexcommercestable", config?: VtexConfig): string {
204
- const c = config ?? getVtexConfig();
205
- const domain = c.domain ?? "com.br";
206
- return `${c.account}.${environment}.${domain}`;
207
- }
208
-
209
- /**
210
- * Origin every product/SKU/breadcrumb URL in the schema.org payload is built
211
- * from.
212
- *
213
- * Order matters, and the first branch is the whole point: a storefront answers
214
- * on the host the shopper actually typed, and that is the host its canonical
215
- * URLs, JSON-LD and sitemap entries have to name. deco-cx/apps got this from
216
- * the request too (`vtex/loaders/intelligentSearch/productList.ts` passes
217
- * `baseUrl: url`), and dropping it here is what made a migrated storefront
218
- * publish structured data pointing somewhere else.
219
- *
220
- * `publicUrl` is only a fallback. It is the VTEX *checkout* host
221
- * (`secure.<brand>.com`), which is a different site from the storefront, and
222
- * it is stored with whatever shape the admin typed — commonly a full URL
223
- * (`https://secure.example.com/`). Interpolating that into `https://${...}`
224
- * produced `https://https://secure.example.com/`, which `new URL()` parses
225
- * with host `https`, so every product ended up at `https://https/<slug>/p`.
226
- * Normalising through `URL` removes that whole class of bug.
227
- *
228
- * Returns no trailing slash, so `new URL(path, baseUrl)` behaves the same for
229
- * every branch.
230
- */
231
- export function storefrontBaseUrl(config?: VtexConfig): string {
232
- const c = config ?? getVtexConfig();
233
-
234
- // 1. The host serving this request -- the storefront's own origin.
235
- const request = RequestContext.current?.request;
236
- if (request) {
237
- try {
238
- return new URL(request.url).origin;
239
- } catch {
240
- // Fall through: a synthetic Request may carry an unparseable url.
241
- }
242
- }
243
-
244
- // 2. Configured public URL, normalised whether or not it carries a scheme.
245
- if (c.publicUrl) {
246
- const normalised = /^https?:\/\//i.test(c.publicUrl) ? c.publicUrl : `https://${c.publicUrl}`;
247
- try {
248
- return new URL(normalised).origin;
249
- } catch {
250
- // Fall through to the account host rather than publish a broken origin.
251
- }
252
- }
253
-
254
- // 3. The account's VTEX host. Always well-formed; never the public brand.
255
- return `https://${vtexHost("vtexcommercestable", c)}`;
256
- }
257
-
258
- function baseUrl(): string {
259
- return `https://${vtexHost()}`;
260
- }
261
-
262
- function isUrl(): string {
263
- return `https://${vtexHost()}/api/io/_v/api/intelligent-search`;
264
- }
265
-
266
- function authHeaders(): Record<string, string> {
267
- const c = getVtexConfig();
268
- const headers: Record<string, string> = {
269
- "Content-Type": "application/json",
270
- Accept: "application/json",
271
- };
272
- if (c.appKey && c.appToken) {
273
- headers["X-VTEX-API-AppKey"] = c.appKey;
274
- headers["X-VTEX-API-AppToken"] = c.appToken;
275
- }
276
- return headers;
277
- }
278
-
279
- /**
280
- * Read regionId from the current request's vtex_segment cookie.
281
- * Returns null when outside a request context or no regionId is set.
282
- */
283
- function extractRegionIdFromCookies(): string | null {
284
- const ctx = RequestContext.current;
285
- if (!ctx) return null;
286
- const cookies = ctx.request.headers.get("cookie");
287
- if (!cookies) return null;
288
- const match = cookies.match(new RegExp(`(?:^|;\\s*)${SEGMENT_COOKIE_NAME}=([^;]+)`));
289
- if (!match?.[1]) return null;
290
- const segment = parseSegment(match[1]);
291
- return segment?.regionId ?? null;
292
- }
293
-
294
- /**
295
- * Read the raw `vtex_segment=<token>` cookie from the active request.
296
- * Returns null when outside a request context or no segment cookie is set.
297
- *
298
- * Used to forward the segment cookie on outgoing VTEX API calls so
299
- * Legacy Catalog endpoints (which gate on the cookie, not on
300
- * `?regionId=` query params) see the right region for products
301
- * available only through regional sellers.
302
- */
303
- function getSegmentCookieHeader(): string | null {
304
- const ctx = RequestContext.current;
305
- if (!ctx) return null;
306
- const cookies = ctx.request.headers.get("cookie");
307
- if (!cookies) return null;
308
- const match = cookies.match(new RegExp(`(?:^|;\\s*)${SEGMENT_COOKIE_NAME}=([^;]+)`));
309
- if (!match?.[1]) return null;
310
- return `${SEGMENT_COOKIE_NAME}=${match[1]}`;
311
- }
312
-
313
- /** Case-insensitive lookup for `cookie` / `Cookie` in a headers init. */
314
- function hasCookieHeader(headers: HeadersInit | undefined): boolean {
315
- if (!headers) return false;
316
- if (headers instanceof Headers) return headers.has("cookie");
317
- if (Array.isArray(headers)) {
318
- return headers.some(([k]) => k.toLowerCase() === "cookie");
319
- }
320
- return Object.keys(headers).some((k) => k.toLowerCase() === "cookie");
321
- }
322
-
323
- /**
324
- * Read the cookie header value from any HeadersInit shape.
325
- * Returns undefined when no cookie header is set.
326
- */
327
- function readCookieHeader(headers: HeadersInit | undefined): string | undefined {
328
- if (!headers) return undefined;
329
- if (headers instanceof Headers) return headers.get("cookie") ?? undefined;
330
- if (Array.isArray(headers)) {
331
- const found = headers.find(([k]) => k.toLowerCase() === "cookie");
332
- return found?.[1];
333
- }
334
- const rec = headers as Record<string, string>;
335
- const key = Object.keys(rec).find((k) => k.toLowerCase() === "cookie");
336
- return key ? rec[key] : undefined;
337
- }
338
-
339
- /**
340
- * Return a new Headers instance that copies `headers` and replaces the
341
- * `cookie` value with `cookieValue` (or removes it when undefined).
342
- * Centralises the "merge cookie into existing init.headers" operation so
343
- * we never spread a Headers instance as a plain object — that collapses
344
- * to {} because Headers has no own enumerable entries, and silently
345
- * wipes every other header the caller set. See PR #53.
346
- */
347
- function withCookieHeader(
348
- headers: HeadersInit | undefined,
349
- cookieValue: string | undefined,
350
- ): Headers {
351
- const next = new Headers(headers ?? {});
352
- if (cookieValue) next.set("cookie", cookieValue);
353
- else next.delete("cookie");
354
- return next;
355
- }
356
-
357
- export async function vtexFetchResponse(
358
- path: string,
359
- init?: InstrumentedFetchInit,
360
- ): Promise<Response> {
361
- const raw = path.startsWith("http") ? path : `${baseUrl()}${path}`;
362
- const url = sanitizeUrl(raw);
363
-
364
- // Forward the incoming `vtex_segment` cookie on outgoing calls when
365
- // the caller hasn't set a cookie header explicitly. This is what the
366
- // Legacy Catalog API (and a handful of other VTEX endpoints) needs
367
- // to resolve regional sellers correctly. Without it, products only
368
- // available via a region's seller appear as OutOfStock on PDPs even
369
- // for users with the cookie. Sites used to wrap `_fetch` themselves
370
- // to do this — see https://github.com/decocms/apps-start#regional-sellers
371
- const segmentCookie = !hasCookieHeader(init?.headers) ? getSegmentCookieHeader() : null;
372
-
373
- const response = await _fetch(url, {
374
- ...init,
375
- headers: mergeHeaders(authHeaders(), segmentCookie, init?.headers),
376
- });
377
- if (!response.ok) {
378
- // Include the response body: VTEX returns actionable detail here
379
- // (e.g. MasterData "duplicated entry"), and without it callers can't
380
- // distinguish error kinds and are forced to reimplement the fetch to
381
- // read the body themselves. Safe to consume — we throw, not return.
382
- const body = await response.text().catch(() => "");
383
- throw new Error(
384
- `VTEX API error: ${response.status} ${response.statusText} - ${url}${body ? ` - ${body}` : ""}`,
385
- );
386
- }
387
- return response;
388
- }
389
-
390
- /**
391
- * Combine framework headers + optional segment cookie + caller headers,
392
- * preserving the precedence "caller wins" regardless of whether the
393
- * caller passed `Headers`, `string[][]`, or `Record<string, string>`.
394
- *
395
- * Why a helper: the naive `{ ...authHeaders, ...init?.headers }` spread
396
- * silently collapses a `Headers` instance to `{}` (Headers has no own
397
- * enumerable entries), which means any cookies the caller put on a
398
- * Headers object are lost on the wire. The `createVtexCheckoutProxy`
399
- * factory passes init with Headers, which makes this the failure mode
400
- * for every forwarder that relies on browser-supplied cookies reaching
401
- * VTEX. Funneling all merges through the `Headers` constructor (which
402
- * correctly absorbs every HeadersInit shape) keeps the bug from
403
- * sneaking back in.
404
- */
405
- function mergeHeaders(
406
- auth: Record<string, string>,
407
- segmentCookie: string | null,
408
- callerHeaders: HeadersInit | undefined,
409
- ): Headers {
410
- const merged = new Headers(auth);
411
- if (segmentCookie) merged.set("cookie", segmentCookie);
412
- if (callerHeaders) {
413
- const incoming = new Headers(callerHeaders);
414
- incoming.forEach((value, key) => {
415
- merged.set(key, value);
416
- });
417
- }
418
- return merged;
419
- }
420
-
421
- export async function vtexFetch<T>(path: string, init?: InstrumentedFetchInit): Promise<T> {
422
- const response = await vtexFetchResponse(path, init);
423
- return response.json();
424
- }
425
-
426
- export interface VtexCachedFetchOptions {
427
- /** SWR cache TTL override in ms */
428
- cacheTTL?: number;
429
- }
430
-
431
- /**
432
- * Like vtexFetch but routes GET requests through the SWR in-memory cache.
433
- * Uses in-flight dedup + stale-while-revalidate.
434
- * Non-GET requests fall through to regular vtexFetch.
435
- */
436
- export async function vtexCachedFetch<T>(
437
- path: string,
438
- init?: InstrumentedFetchInit,
439
- cacheOpts?: VtexCachedFetchOptions,
440
- ): Promise<T | null> {
441
- const method = (init?.method ?? "GET").toUpperCase();
442
- if (method !== "GET") return vtexFetch<T>(path, init);
443
-
444
- const raw = path.startsWith("http") ? path : `${baseUrl()}${path}`;
445
- const url = sanitizeUrl(raw);
446
- const opts: FetchCacheOptions | undefined = cacheOpts?.cacheTTL
447
- ? { ttl: cacheOpts.cacheTTL }
448
- : undefined;
449
-
450
- // Mirrors vtexFetchResponse: Legacy Catalog and several other GET
451
- // endpoints gate regional seller availability on the `vtex_segment`
452
- // cookie. Cached GETs (PDP / shelf product lookups) must see the same
453
- // regionalization the rest of the stack does — otherwise sites have
454
- // to wrap _fetch themselves to forward the cookie, which is easy to
455
- // get subtly wrong (especially around HeadersInit shapes). Inline
456
- // here keeps the surface small; if a third callsite appears we
457
- // extract a shared helper.
458
- const segmentCookie = !hasCookieHeader(init?.headers) ? getSegmentCookieHeader() : null;
459
-
460
- return fetchWithCache<T>(
461
- url,
462
- () =>
463
- _fetch(url, {
464
- ...init,
465
- headers: mergeHeaders(authHeaders(), segmentCookie, init?.headers),
466
- }),
467
- opts,
468
- );
469
- }
470
-
471
- /**
472
- * Like vtexFetch, but also forwards Set-Cookie headers via RequestContext.
473
- * Use for checkout, session, and auth actions that set cookies.
474
- *
475
- * Cookie propagation happens automatically:
476
- * - Reads the browser's Cookie header from RequestContext.request
477
- * - Writes upstream Set-Cookie headers to RequestContext.responseHeaders
478
- * - The invoke handler copies responseHeaders into the HTTP Response
479
- *
480
- * This mirrors deco-cx/deco's `proxySetCookie(response.headers, ctx.response.headers)`.
481
- */
482
- export async function vtexFetchWithCookies<T>(
483
- path: string,
484
- init?: InstrumentedFetchInit,
485
- ): Promise<T> {
486
- // Auto-inject request cookies from RequestContext.
487
- //
488
- // We sanitize the forwarded Cookie header before sending it to VTEX:
489
- // the janus gateway returns 503 (empty body) on any cookie value that
490
- // isn't strict ASCII per RFC 6265. Third-party analytics tags that write
491
- // raw UTF-8 into document.cookie (e.g. category names with accents) will
492
- // otherwise poison every checkout call for the affected user. The drop
493
- // report is emitted via warnDroppedCookies() so we have observability the
494
- // next time a tag misbehaves.
495
- //
496
- // Headers normalisation: callers pass either Headers, [name,value][],
497
- // or Record<string,string>. We must NEVER spread a Headers instance as
498
- // a plain object — it collapses to {} and silently drops every other
499
- // header the caller set (auth, content-type, etc.). withCookieHeader()
500
- // funnels every shape through the Headers constructor and is the only
501
- // safe way to rewrite the cookie value.
502
- const callerCookie = readCookieHeader(init?.headers);
503
- if (!callerCookie) {
504
- const ctx = RequestContext.current;
505
- const raw = ctx?.request.headers.get("cookie");
506
- if (raw) {
507
- const { cookies, dropped } = sanitizeOutboundCookieHeader(raw);
508
- if (dropped.length) warnDroppedCookies(dropped, vtexHost());
509
- if (cookies) {
510
- init = { ...init, headers: withCookieHeader(init?.headers, cookies) };
511
- }
512
- }
513
- } else {
514
- // Caller passed an explicit cookie — sanitize it too.
515
- const { cookies, dropped } = sanitizeOutboundCookieHeader(callerCookie);
516
- if (dropped.length) warnDroppedCookies(dropped, vtexHost());
517
- init = { ...init, headers: withCookieHeader(init?.headers, cookies) };
518
- }
519
-
520
- const response = await vtexFetchResponse(path, init);
521
- const data = (await response.json()) as T;
522
-
523
- // Forward Set-Cookie headers to RequestContext.responseHeaders,
524
- // but skip VTEX internal IS cookies (managed server-side by the middleware).
525
- const responseHeaders = getResponseHeaders();
526
- if (responseHeaders) {
527
- const host = getRequestHost();
528
- const setCookies =
529
- typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [];
530
- for (const cookie of setCookies) {
531
- if (cookie.startsWith(`${SESSION_COOKIE}=`) || cookie.startsWith(`${ANONYMOUS_COOKIE}=`)) {
532
- continue;
533
- }
534
- responseHeaders.append("set-cookie", rewriteCookieDomain(cookie, host));
535
- }
536
- }
537
-
538
- return data;
539
- }
540
-
541
- export async function intelligentSearch<T>(
542
- path: string,
543
- params?: Record<string, string>,
544
- opts?: { cookieHeader?: string; locale?: string; regionId?: string },
545
- ): Promise<T> {
546
- const url = new URL(`${isUrl()}${path}`);
547
- if (params) {
548
- for (const [k, v] of Object.entries(params)) {
549
- url.searchParams.set(k, v);
550
- }
551
- }
552
- const c = getVtexConfig();
553
- if (c.salesChannel) url.searchParams.set("sc", c.salesChannel);
554
-
555
- const locale = opts?.locale ?? c.locale;
556
- if (locale && !url.searchParams.has("locale")) {
557
- url.searchParams.set("locale", locale);
558
- }
559
-
560
- const regionId = opts?.regionId ?? extractRegionIdFromCookies();
561
- if (regionId) {
562
- url.searchParams.set("regionId", regionId);
563
- }
564
-
565
- const headers: Record<string, string> = { ...authHeaders() };
566
- if (opts?.cookieHeader) {
567
- headers.cookie = opts.cookieHeader;
568
- } else {
569
- // IS already gets regionId on the query string above, but some
570
- // internal IS flows (and downstream services it consults) still
571
- // honor the `vtex_segment` cookie — forward it when the caller
572
- // didn't pass an explicit one. See vtexCachedFetch for the same
573
- // rationale.
574
- const segmentCookie = getSegmentCookieHeader();
575
- if (segmentCookie) headers.cookie = segmentCookie;
576
- }
577
-
578
- const fullUrl = url.toString();
579
-
580
- return fetchWithCache<T>(fullUrl, async () => {
581
- const response = await _fetch(fullUrl, { headers });
582
- if (!response.ok) {
583
- throw new Error(`VTEX IS error: ${response.status} - ${fullUrl}`);
584
- }
585
- return response;
586
- }) as Promise<T>;
587
- }
588
-
589
- /**
590
- * Execute a GraphQL query against the VTEX IO Runtime (myvtex.com).
591
- * Used for private profile/session/wishlist/payment queries that the
592
- * original Deco loaders called via `ctx.io.query(...)`.
593
- */
594
- export async function vtexIOGraphQL<T>(
595
- body: {
596
- query: string;
597
- variables?: Record<string, unknown> | null;
598
- operationName?: string;
599
- },
600
- headers?: Record<string, string>,
601
- ): Promise<T> {
602
- const { account } = getVtexConfig();
603
- const res = await vtexFetch<{ data: T; errors?: Array<{ message: string }> }>(
604
- `https://${account}.myvtex.com/_v/private/graphql/v1`,
605
- {
606
- method: "POST",
607
- headers,
608
- body: JSON.stringify(body),
609
- },
610
- );
611
- if (res.errors?.length) {
612
- throw new Error(`VTEX IO GraphQL error: ${res.errors.map((e) => e.message).join(", ")}`);
613
- }
614
- return res.data;
615
- }
616
-
617
- // -- Page Type API (used by PLP to derive category facets from URL path) --
618
-
619
- export interface PageType {
620
- id: string;
621
- name: string;
622
- url: string;
623
- title: string;
624
- metaTagDescription: string;
625
- pageType:
626
- | "Brand"
627
- | "Category"
628
- | "Department"
629
- | "SubCategory"
630
- | "Collection"
631
- | "Cluster"
632
- | "Search"
633
- | "Product"
634
- | "NotFound"
635
- | "FullText";
636
- }
637
-
638
- const PAGE_TYPE_TO_MAP_PARAM: Record<string, string | null> = {
639
- Brand: "brand",
640
- Collection: "productClusterIds",
641
- Cluster: "productClusterIds",
642
- Search: null,
643
- Product: null,
644
- NotFound: null,
645
- FullText: null,
646
- };
647
-
648
- function pageTypeToMapParam(type: PageType["pageType"], index: number): string | null {
649
- if (type === "Category" || type === "Department" || type === "SubCategory") {
650
- return `category-${index + 1}`;
651
- }
652
- return PAGE_TYPE_TO_MAP_PARAM[type] ?? null;
653
- }
654
-
655
- function cachedPageType(term: string): Promise<PageType | null> {
656
- return vtexCachedFetch<PageType>(`/api/catalog_system/pub/portal/pagetype/${term}`);
657
- }
658
-
659
- /**
660
- * Query VTEX Page Type API for each path segment (cumulative).
661
- * Mirrors deco-cx/apps `pageTypesFromUrl`.
662
- * Uses in-flight deduplication to avoid duplicate calls for the same segment.
663
- */
664
- export async function pageTypesFromPath(pagePath: string): Promise<PageType[]> {
665
- const segments = pagePath.split("/").filter(Boolean);
666
- const results = await Promise.all(
667
- segments.map((_, index) => {
668
- const term = segments.slice(0, index + 1).join("/");
669
- return cachedPageType(term);
670
- }),
671
- );
672
- return results.filter((pt): pt is PageType => pt !== null);
673
- }
674
-
675
- const slugify = (str: string) =>
676
- str
677
- .replace(/,/g, "")
678
- .replace(/[·/_,:]/g, "-")
679
- .replace(/[*+~.()'"!:@&[\]`/ %$#?{}|><=_^]/g, "-")
680
- .normalize("NFD")
681
- .replace(/[\u0300-\u036f]/g, "")
682
- .toLowerCase();
683
-
684
- /**
685
- * Convert page types to selectedFacets with correct IS facet keys.
686
- * Mirrors deco-cx/apps `filtersFromPathname`.
687
- */
688
- export function filtersFromPageTypes(pageTypes: PageType[]): Array<{ key: string; value: string }> {
689
- return pageTypes
690
- .map((page, index) => {
691
- const key = pageTypeToMapParam(page.pageType, index);
692
- if (!key || !page.name) return null;
693
- return { key, value: slugify(page.name) };
694
- })
695
- .filter((f): f is { key: string; value: string } => f !== null);
696
- }
697
-
698
- /**
699
- * Build the IS facet path string from selectedFacets.
700
- * Mirrors deco-cx/apps `toPath`.
701
- */
702
- export function toFacetPath(facets: Array<{ key: string; value: string }>): string {
703
- return facets.map(({ key, value }) => (key ? `${key}/${value}` : value)).join("/");
704
- }
705
-
706
- export function initVtexFromBlocks(blocks: Record<string, any>) {
707
- const vtexBlock = blocks.vtex || blocks["deco-vtex"];
708
- if (!vtexBlock) {
709
- console.warn("[VTEX] No vtex.json block found.");
710
- return;
711
- }
712
- const appKey = typeof vtexBlock.appKey === "string" ? vtexBlock.appKey : undefined;
713
- const appToken = typeof vtexBlock.appToken === "string" ? vtexBlock.appToken : undefined;
714
- configureVtex({
715
- account: vtexBlock.account,
716
- publicUrl: vtexBlock.publicUrl,
717
- salesChannel: vtexBlock.salesChannel || "1",
718
- locale: vtexBlock.locale || vtexBlock.defaultLocale,
719
- appKey,
720
- appToken,
721
- country: vtexBlock.country,
722
- domain: vtexBlock.domain,
723
- });
724
- }