@decocms/apps-vtex 7.71.6 → 8.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps-vtex",
3
- "version": "7.71.6",
3
+ "version": "8.0.0-next.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.71.6",
55
- "@decocms/apps-commerce": "7.71.6",
56
- "@decocms/apps-website": "7.71.6",
57
- "@decocms/tanstack": "7.71.6"
54
+ "@decocms/blocks": "8.0.0-next.0",
55
+ "@decocms/apps-commerce": "8.0.0-next.0",
56
+ "@decocms/apps-website": "8.0.0-next.0",
57
+ "@decocms/tanstack": "8.0.0-next.0"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "@tanstack/react-query": ">=5.0.0",
@@ -0,0 +1,158 @@
1
+ // @vitest-environment node
2
+ /**
3
+ * Conformance: upstream-clients.mdx (Calling APIs) against the next-major
4
+ * client packages. Only the v8 client modules are checked; the v7 app
5
+ * surfaces that still ship beside them are out of scope until v7 is removed.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { createAlgoliaClient } from "@decocms/apps-algolia";
11
+ import { createVtexClient } from "@decocms/apps-vtex";
12
+ import { describe, expect, it, vi } from "vitest";
13
+
14
+ const PACKAGES = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
15
+
16
+ /** The next-major client module of each platform package. */
17
+ const V8_CLIENTS: Record<string, string[]> = {
18
+ "apps-vtex": ["src/vtexClient.ts"],
19
+ "apps-shopify": ["src/v8/client.ts", "src/v8/graphqlOperationName.ts"],
20
+ "apps-wake": ["src/wakeClient.ts"],
21
+ "apps-magento": ["src/magentoClient.ts"],
22
+ "apps-algolia": ["src/index.ts"],
23
+ "apps-resend": ["src/emails.ts"],
24
+ "apps-sfmc-personalization": ["src/index.ts"],
25
+ };
26
+
27
+ /** The source without comments, so doc examples (`process.env.X!` in a JSDoc) don't count. */
28
+ function code(pkg: string, file: string): string {
29
+ return fs
30
+ .readFileSync(path.join(PACKAGES, pkg, file), "utf8")
31
+ .replace(/\/\*[\s\S]*?\*\//g, "")
32
+ .replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
33
+ }
34
+
35
+ describe("what a client is (upstream-clients.mdx)", () => {
36
+ it("up-01: VTEX, Shopify, Wake, Magento, Algolia, Resend (and others) ship a client built on createInstrumentedFetch", () => {
37
+ for (const [pkg, files] of Object.entries(V8_CLIENTS)) {
38
+ expect(fs.existsSync(path.join(PACKAGES, pkg, "package.json"))).toBe(true);
39
+ expect([pkg, code(pkg, files[0]!)]).toEqual([
40
+ pkg,
41
+ expect.stringMatching(/import \{[^}]*createInstrumentedFetch[^}]*\} from "@decocms\/blocks\/fetch"/),
42
+ ]);
43
+ }
44
+ });
45
+
46
+ it("up-02: clients take settings as arguments: no environment reads inside a client", () => {
47
+ for (const [pkg, files] of Object.entries(V8_CLIENTS)) {
48
+ for (const file of files) {
49
+ expect([pkg, file, /process\.env|import\.meta\.env|Deno\.env/.test(code(pkg, file))]).toEqual(
50
+ [pkg, file, false],
51
+ );
52
+ }
53
+ }
54
+ });
55
+
56
+ it("up-03: no React hooks, client components or shared-commerce converters in a client", () => {
57
+ for (const [pkg, files] of Object.entries(V8_CLIENTS)) {
58
+ for (const file of files) {
59
+ const text = code(pkg, file);
60
+ const found = [
61
+ /["']use client["']/,
62
+ /from ["']react["']/,
63
+ /\buse(Cart|User|Wishlist)\b/,
64
+ /@decocms\/apps-commerce/,
65
+ /from ["']\.\/(loaders|actions|hooks)\//,
66
+ ].filter((pattern) => pattern.test(text));
67
+ expect([pkg, file, found]).toEqual([pkg, file, []]);
68
+ }
69
+ }
70
+ });
71
+
72
+ it("up-05: the Salesforce client is @decocms/apps-sfmc-personalization; @decocms/apps-salesforce is gone", () => {
73
+ const pkg = JSON.parse(
74
+ fs.readFileSync(path.join(PACKAGES, "apps-sfmc-personalization/package.json"), "utf8"),
75
+ );
76
+ expect(pkg.name).toBe("@decocms/apps-sfmc-personalization");
77
+ const names = fs
78
+ .readdirSync(PACKAGES)
79
+ .filter((dir) => fs.existsSync(path.join(PACKAGES, dir, "package.json")))
80
+ .map((dir) => JSON.parse(fs.readFileSync(path.join(PACKAGES, dir, "package.json"), "utf8")).name);
81
+ expect(names).not.toContain("@decocms/apps-salesforce");
82
+ });
83
+
84
+ it("up-07: clients don't cache upstream responses", () => {
85
+ for (const [pkg, files] of Object.entries(V8_CLIENTS)) {
86
+ for (const file of files) {
87
+ expect([
88
+ pkg,
89
+ file,
90
+ /createFetchCache|fetchWithCache|fetchCache|caches\.default|caches\.open|cachedLoader/.test(
91
+ code(pkg, file),
92
+ ),
93
+ ]).toEqual([pkg, file, false]);
94
+ }
95
+ }
96
+ });
97
+ });
98
+
99
+ describe("call a client (upstream-clients.mdx)", () => {
100
+ it("up-06: createVtexClient({ account, appKey, appToken }) and vtex.search.products({ query, count })", async () => {
101
+ const fetch = vi.fn(
102
+ async (_input: string | URL | Request, _init?: RequestInit) =>
103
+ new Response(JSON.stringify({ products: [] }), { status: 200 }),
104
+ );
105
+ const vtex = createVtexClient({
106
+ account: "mystore",
107
+ appKey: "key",
108
+ appToken: "token",
109
+ fetch: fetch as unknown as typeof globalThis.fetch,
110
+ });
111
+ const products = await vtex.search.products({ query: "linen shirt", count: 12 });
112
+ expect(products).toEqual({ products: [] });
113
+ const url = new URL(String(fetch.mock.calls[0]![0]));
114
+ expect(url.host).toBe("mystore.vtexcommercestable.com.br");
115
+ expect(url.pathname).toContain("product_search");
116
+ expect(url.searchParams.get("query")).toBe("linen shirt");
117
+ expect(url.searchParams.get("count")).toBe("12");
118
+ });
119
+ });
120
+
121
+ describe("retries and failures (upstream-clients.mdx)", () => {
122
+ it("up-08: the VTEX client retries by default", async () => {
123
+ const statuses = [503, 200];
124
+ const fetch = vi.fn(
125
+ async () => new Response("{}", { status: statuses.shift() ?? 200 }),
126
+ );
127
+ const vtex = createVtexClient({
128
+ account: "mystore",
129
+ fetch: fetch as unknown as typeof globalThis.fetch,
130
+ });
131
+ await vtex.search.products({ query: "shirt" });
132
+ expect(fetch).toHaveBeenCalledTimes(2);
133
+ });
134
+
135
+ it("up-08: the VTEX client's circuit breaker fails fast after repeated failures", async () => {
136
+ const fetch = vi.fn(async () => new Response("{}", { status: 503 }));
137
+ const vtex = createVtexClient({
138
+ account: "mystore",
139
+ fetch: fetch as unknown as typeof globalThis.fetch,
140
+ });
141
+ for (let i = 0; i < 5; i++) await expect(vtex.search.products({ query: "shirt" })).rejects.toThrow();
142
+ const before = fetch.mock.calls.length;
143
+ await expect(vtex.search.products({ query: "shirt" })).rejects.toThrow();
144
+ expect(fetch.mock.calls.length).toBe(before);
145
+ }, 20_000);
146
+
147
+ it("up-08: other clients leave retries off (one attempt on a 503)", async () => {
148
+ const fetch = vi.fn(async () => new Response("{}", { status: 503 }));
149
+ const algolia = createAlgoliaClient(
150
+ { applicationId: "APP123", apiKey: "k" },
151
+ { fetch: fetch as unknown as typeof globalThis.fetch },
152
+ );
153
+ await expect(algolia.search([{ indexName: "products", query: "shirt" }] as never)).rejects.toThrow(
154
+ "algolia search failed with HTTP 503",
155
+ );
156
+ expect(fetch).toHaveBeenCalledTimes(1);
157
+ });
158
+ });
package/src/index.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  /**
2
- * VTEX app entry point for @decocms/apps.
3
- * Re-exports client config + initializer + app contract.
2
+ * VTEX app entry point.
3
+ *
4
+ * Next major: `createVtexClient` is the thin, instrumented VTEX client
5
+ * (see ./vtexClient.ts and /next/upstream-clients). Everything else below is
6
+ * the v7 app surface, kept exported for v7 consumers until v7 is dropped.
7
+ *
8
+ * v7: re-exports client config + initializer + app contract.
4
9
  *
5
10
  * For actions/loaders/utils, use sub-path imports:
6
11
  * import { addItemsToCart } from "@decocms/apps/vtex/actions/checkout"
@@ -15,3 +20,13 @@ export * from "./client";
15
20
  export { configure, type VtexState } from "./mod";
16
21
  export { type CreateVtexFetchOptions, createVtexFetch } from "./utils/instrumentedFetch";
17
22
  export { vtexOperationRouter } from "./utils/operationRouter";
23
+ export {
24
+ createVtexClient,
25
+ type VtexCatalogSearchArgs,
26
+ type VtexClient,
27
+ type VtexClientConfig,
28
+ VtexError,
29
+ type VtexRequestOptions,
30
+ type VtexResponse,
31
+ type VtexSearchArgs,
32
+ } from "./vtexClient";
@@ -0,0 +1,204 @@
1
+ // @vitest-environment node
2
+ /**
3
+ * The VTEX upstream client (/next/upstream-clients): typed calls over the
4
+ * framework's instrumented fetch, labeled provider "vtex" with a named
5
+ * operation, retries and a circuit breaker on by default, per-shopper inputs
6
+ * as arguments, and errors that carry no body, URL, token or cookie.
7
+ */
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+
10
+ const instrumented = vi.hoisted(() => ({
11
+ options: [] as Record<string, unknown>[],
12
+ operations: [] as (string | undefined)[],
13
+ }));
14
+
15
+ vi.mock("@decocms/blocks/fetch", async (importActual) => {
16
+ const actual = await importActual<typeof import("@decocms/blocks/fetch")>();
17
+ return {
18
+ ...actual,
19
+ createInstrumentedFetch: (options: Parameters<typeof actual.createInstrumentedFetch>[0]) => {
20
+ instrumented.options.push(options as unknown as Record<string, unknown>);
21
+ const request = actual.createInstrumentedFetch(options);
22
+ return (input: string | URL | Request, init?: RequestInit & { operation?: string }) => {
23
+ instrumented.operations.push(init?.operation);
24
+ return request(input, init);
25
+ };
26
+ },
27
+ };
28
+ });
29
+
30
+ import { createVtexClient, VtexError } from ".";
31
+
32
+ type Call = { url: string; init: RequestInit };
33
+
34
+ /** An upstream answering with `statuses` in order, recording each request. */
35
+ function upstream(statuses: number[] = [], headers: Record<string, string | string[]> = {}) {
36
+ const calls: Call[] = [];
37
+ const fetch = vi.fn(async (input: string | URL | Request, init: RequestInit = {}) => {
38
+ calls.push({ url: String(input), init });
39
+ const response = new Response('{"secret-body":"token-123"}', { status: statuses.shift() ?? 200 });
40
+ for (const [name, value] of Object.entries(headers)) {
41
+ for (const v of Array.isArray(value) ? value : [value]) response.headers.append(name, v);
42
+ }
43
+ return response;
44
+ });
45
+ return { calls, fetch: fetch as unknown as typeof globalThis.fetch };
46
+ }
47
+
48
+ const header = (call: Call | undefined, name: string) => new Headers(call?.init.headers).get(name);
49
+
50
+ afterEach(() => {
51
+ instrumented.options.length = 0;
52
+ instrumented.operations.length = 0;
53
+ });
54
+
55
+ describe("createVtexClient", () => {
56
+ it("uses the instrumented fetch as provider vtex, with retries and a circuit breaker on", () => {
57
+ createVtexClient({ account: "store" });
58
+ expect(instrumented.options[0]).toMatchObject({
59
+ provider: "vtex",
60
+ retry: { attempts: 2, backoffMs: 150 },
61
+ circuitBreaker: { failures: 5, cooldownMs: 5000 },
62
+ });
63
+ });
64
+
65
+ it("turns retries and the circuit breaker off when asked", () => {
66
+ createVtexClient({ account: "store", retry: false, circuitBreaker: false });
67
+ expect(instrumented.options[0]?.retry).toBeUndefined();
68
+ expect(instrumented.options[0]?.circuitBreaker).toBeUndefined();
69
+ });
70
+
71
+ it("searches Intelligent Search with facets, region, locale and sales channel as arguments", async () => {
72
+ const { calls, fetch } = upstream();
73
+ const vtex = createVtexClient({
74
+ account: "store",
75
+ appKey: "key",
76
+ appToken: "token",
77
+ salesChannel: "2",
78
+ locale: "pt-BR",
79
+ fetch,
80
+ });
81
+ await vtex.search.products({
82
+ query: "linen shirt",
83
+ count: 12,
84
+ facets: [{ key: "category-1", value: "shirts" }],
85
+ regionId: "v2.ABC",
86
+ });
87
+ const url = new URL(calls[0]!.url);
88
+ expect(url.origin).toBe("https://store.vtexcommercestable.com.br");
89
+ expect(url.pathname).toBe("/api/io/_v/api/intelligent-search/product_search/category-1/shirts");
90
+ expect(Object.fromEntries(url.searchParams)).toEqual({
91
+ query: "linen shirt",
92
+ count: "12",
93
+ locale: "pt-BR",
94
+ regionId: "v2.ABC",
95
+ sc: "2",
96
+ });
97
+ expect(header(calls[0], "x-vtex-api-appkey")).toBe("key");
98
+ expect(header(calls[0], "x-vtex-api-apptoken")).toBe("token");
99
+ expect(header(calls[0], "cookie")).toBeNull();
100
+ expect(instrumented.operations).toEqual(["intelligent-search.product_search"]);
101
+ });
102
+
103
+ it("names every operation from the VTEX API, never a URL", async () => {
104
+ const { fetch } = upstream();
105
+ const vtex = createVtexClient({ account: "store", fetch });
106
+ await vtex.catalog.pageType("/shirts/linen");
107
+ await vtex.catalog.products({ fq: ["productId:1", "productId:2"], from: 0, to: 9 });
108
+ await vtex.checkout.simulation({ items: [{ id: "1", quantity: 1, seller: "1" }] });
109
+ await vtex.checkout.regions({ postalCode: "01000-000", country: "BRA" });
110
+ await vtex.sessions.get();
111
+ expect(instrumented.operations).toEqual([
112
+ "catalog.pagetype",
113
+ "catalog.products.search",
114
+ "checkout.simulation",
115
+ "checkout.regions",
116
+ "sessions.get",
117
+ ]);
118
+ });
119
+
120
+ it("retries an idempotent call and fails fast once the breaker opens", async () => {
121
+ const { calls, fetch } = upstream([503, 200]);
122
+ const vtex = createVtexClient({ account: "store", fetch, retry: { attempts: 1, backoffMs: 0 } });
123
+ await expect(vtex.catalog.categoryTree()).resolves.toBeDefined();
124
+ expect(calls).toHaveLength(2);
125
+
126
+ const down = upstream([500, 500, 500]);
127
+ const failing = createVtexClient({
128
+ account: "store",
129
+ fetch: down.fetch,
130
+ retry: false,
131
+ circuitBreaker: { failures: 2, cooldownMs: 60_000 },
132
+ });
133
+ await expect(failing.catalog.categoryTree()).rejects.toBeInstanceOf(VtexError);
134
+ await expect(failing.catalog.categoryTree()).rejects.toBeInstanceOf(VtexError);
135
+ await expect(failing.catalog.categoryTree()).rejects.toThrow(/circuit open/);
136
+ expect(down.calls).toHaveLength(2);
137
+ });
138
+
139
+ it("never retries a cart write", async () => {
140
+ const { calls, fetch } = upstream([503, 200]);
141
+ const vtex = createVtexClient({ account: "store", fetch, retry: { attempts: 3, backoffMs: 0 } });
142
+ await expect(vtex.checkout.addItems("of1", [{ id: 1, quantity: 1, seller: "1" }])).rejects.toThrow(
143
+ VtexError,
144
+ );
145
+ expect(calls).toHaveLength(1);
146
+ });
147
+
148
+ it("forwards the shopper's cookie, sanitized, and hands back VTEX's Set-Cookie values", async () => {
149
+ const { calls, fetch } = upstream([], {
150
+ "set-cookie": ["checkout.vtex.com=__ofid=of1; Domain=store.vtexcommercestable.com.br; Path=/"],
151
+ });
152
+ const vtex = createVtexClient({ account: "store", appKey: "key", appToken: "token", fetch });
153
+ const result = await vtex.checkout.orderForm({
154
+ cookie: "checkout.vtex.com=__ofid=of1; tag=cateçoria",
155
+ });
156
+ expect(header(calls[0], "cookie")).toBe("checkout.vtex.com=__ofid=of1");
157
+ expect(calls[0]?.init.method).toBe("POST");
158
+ // Every orderForm section, and no app credentials next to a shopper's cookie.
159
+ expect(calls[0]?.init.body).toBe("{}");
160
+ expect(header(calls[0], "x-vtex-api-appkey")).toBeNull();
161
+ expect(header(calls[0], "x-vtex-api-apptoken")).toBeNull();
162
+ expect(result.setCookies).toEqual([
163
+ "checkout.vtex.com=__ofid=of1; Domain=store.vtexcommercestable.com.br; Path=/",
164
+ ]);
165
+ expect(result.data).toEqual({ "secret-body": "token-123" });
166
+ });
167
+
168
+ it("throws errors that carry the operation and status, never the body, URL or credentials", async () => {
169
+ const { fetch } = upstream([400]);
170
+ const vtex = createVtexClient({ account: "store", appKey: "key", appToken: "token-123", fetch });
171
+ const error = await vtex.search.products({ query: "q" }).catch((e: unknown) => e);
172
+ expect(error).toBeInstanceOf(VtexError);
173
+ expect(error).toMatchObject({ operation: "intelligent-search.product_search", status: 400 });
174
+ expect((error as Error).message).toBe("vtex intelligent-search.product_search failed with HTTP 400");
175
+ });
176
+
177
+ it("narrows the orderForm sections only when asked", async () => {
178
+ const { calls, fetch } = upstream();
179
+ const vtex = createVtexClient({ account: "store", fetch });
180
+ await vtex.checkout.orderForm({ sections: ["items", "totalizers"] });
181
+ expect(calls[0]?.init.body).toBe('{"expectedOrderFormSections":["items","totalizers"]}');
182
+ });
183
+
184
+ it("keeps caller paths inside their endpoint", async () => {
185
+ const { calls, fetch } = upstream();
186
+ const vtex = createVtexClient({ account: "store", appKey: "key", appToken: "token", fetch });
187
+ for (const path of [
188
+ "../../api/dataentities/CL/search",
189
+ "/shirts/%2e%2e/%2E%2E/api/dataentities/CL/search",
190
+ "shirts/./linen",
191
+ ]) {
192
+ await expect(vtex.catalog.pageType(path)).rejects.toThrow(/invalid path segment/);
193
+ }
194
+ await expect(vtex.catalog.products({ term: "a/../../../api/x" })).rejects.toThrow(
195
+ /invalid path segment/,
196
+ );
197
+ expect(calls).toHaveLength(0);
198
+
199
+ await vtex.catalog.pageType("/shirts/linen?_where=x#y");
200
+ const url = new URL(calls[0]!.url);
201
+ expect(url.pathname).toBe("/api/catalog_system/pub/portal/pagetype/shirts/linen%3F_where%3Dx%23y");
202
+ expect(url.search).toBe("");
203
+ });
204
+ });
@@ -0,0 +1,433 @@
1
+ /**
2
+ * The VTEX upstream client: typed request functions over the framework's
3
+ * instrumented fetch (`@decocms/blocks/fetch`). See /next/upstream-clients.
4
+ *
5
+ * ```ts
6
+ * import { createVtexClient } from "@decocms/apps-vtex";
7
+ *
8
+ * export const vtex = createVtexClient({
9
+ * account: process.env.VTEX_ACCOUNT!,
10
+ * appKey: process.env.VTEX_APP_KEY!,
11
+ * appToken: process.env.VTEX_APP_TOKEN!,
12
+ * });
13
+ *
14
+ * const result = await vtex.search.products({ query: "linen shirt", count: 12 });
15
+ * ```
16
+ *
17
+ * Thin by design: it returns VTEX's own types, takes every setting and every
18
+ * per-shopper input (cookies, region) as arguments, and never reads the
19
+ * incoming request, caches, or converts. Converters, hooks, cart/session/sign-in
20
+ * flows and loaders live in the VTEX platform template and site code; upstream
21
+ * caching lives in the framework binding (/next/caching#upstream-data), keyed
22
+ * by everything a response depends on, such as `regionId`.
23
+ *
24
+ * Retries and a circuit breaker are ON by default for VTEX (and only for
25
+ * VTEX). Retries apply to idempotent requests only, so a checkout POST is
26
+ * never repeated. Pass `retry: false` / `circuitBreaker: false` to turn them off.
27
+ */
28
+ import { createInstrumentedFetch } from "@decocms/blocks/fetch";
29
+ import { DEFAULT_RESILIENCE_CONFIG } from "./utils/constants";
30
+ import { sanitizeOutboundCookieHeader } from "./utils/cookieSanitizer";
31
+ import { vtexOperationRouter } from "./utils/operationRouter";
32
+ import type {
33
+ Category,
34
+ FacetSearchResult,
35
+ Fuzzy,
36
+ LegacyProduct,
37
+ LegacySort,
38
+ CrossSellingType,
39
+ OrderForm,
40
+ OrderFormItemInput,
41
+ PageType,
42
+ ProductSearchResult,
43
+ SelectedFacet,
44
+ Session,
45
+ SimulationBehavior,
46
+ SimulationOrderForm,
47
+ Sort,
48
+ Suggestion,
49
+ } from "./utils/types";
50
+
51
+ export interface VtexClientConfig {
52
+ /** The VTEX account name, e.g. `mystore`. */
53
+ account: string;
54
+ /** Sent as `X-VTEX-API-AppKey` when set together with `appToken`. */
55
+ appKey?: string;
56
+ /** Sent as `X-VTEX-API-AppToken` when set together with `appKey`. */
57
+ appToken?: string;
58
+ /** @default "vtexcommercestable" */
59
+ environment?: string;
60
+ /** @default "com.br" */
61
+ domain?: string;
62
+ /** Default sales channel (`sc`), overridable per call where VTEX accepts one. */
63
+ salesChannel?: string;
64
+ /** Default Intelligent Search locale, e.g. `pt-BR`. */
65
+ locale?: string;
66
+ /** The fetch underneath; for tests. Defaults to `globalThis.fetch`. */
67
+ fetch?: typeof fetch;
68
+ /** On by default (2 retries, 150ms backoff); idempotent requests only. `false` turns it off. */
69
+ retry?: { attempts: number; backoffMs?: number } | false;
70
+ /** On by default (opens after 5 failures, for 5s). `false` turns it off. */
71
+ circuitBreaker?: { failures: number; cooldownMs: number } | false;
72
+ }
73
+
74
+ const VTEX_DEFAULT_RETRY = {
75
+ attempts: DEFAULT_RESILIENCE_CONFIG.maxRetries,
76
+ backoffMs: DEFAULT_RESILIENCE_CONFIG.backoffBaseMs,
77
+ } as const;
78
+
79
+ const VTEX_DEFAULT_CIRCUIT_BREAKER = {
80
+ failures: DEFAULT_RESILIENCE_CONFIG.breakerConsecutiveFailures,
81
+ cooldownMs: DEFAULT_RESILIENCE_CONFIG.breakerOpenCooldownMs,
82
+ } as const;
83
+
84
+ /** Per-call inputs. Nothing is read from the incoming request. */
85
+ export interface VtexRequestOptions {
86
+ /** The shopper's `Cookie` header to forward (non-ASCII and malformed pairs are dropped). */
87
+ cookie?: string;
88
+ signal?: AbortSignal;
89
+ }
90
+
91
+ /** A shopper-scoped response: the body plus VTEX's `Set-Cookie` values, unmodified, for the caller to forward. */
92
+ export interface VtexResponse<T> {
93
+ data: T;
94
+ setCookies: string[];
95
+ }
96
+
97
+ /** A failed VTEX call. Carries the operation and the status, never a body, URL, token or cookie. */
98
+ export class VtexError extends Error {
99
+ constructor(
100
+ readonly operation: string,
101
+ readonly status: number,
102
+ ) {
103
+ super(`vtex ${operation} failed with HTTP ${status}`);
104
+ this.name = "VtexError";
105
+ }
106
+ }
107
+
108
+ /** Intelligent Search arguments, mirroring its query parameters. */
109
+ export interface VtexSearchArgs extends VtexRequestOptions {
110
+ query?: string;
111
+ /** Selected facets, sent as the path (`category-1/shirts/brand/acme`). */
112
+ facets?: SelectedFacet[];
113
+ /** Intelligent Search's 1-based page. */
114
+ page?: number;
115
+ count?: number;
116
+ sort?: Sort;
117
+ fuzzy?: Fuzzy;
118
+ locale?: string;
119
+ hideUnavailableItems?: boolean;
120
+ simulationBehavior?: SimulationBehavior;
121
+ /** The shopper's region (from the `vtex_segment` cookie); part of the cache key. */
122
+ regionId?: string;
123
+ salesChannel?: string;
124
+ }
125
+
126
+ /** Legacy Catalog search arguments (`/api/catalog_system/pub/products/search`). */
127
+ export interface VtexCatalogSearchArgs extends VtexRequestOptions {
128
+ /** Path term, e.g. a category path. */
129
+ term?: string;
130
+ /** Full-text query (`ft`). */
131
+ ft?: string;
132
+ /** Filter queries (`fq`), e.g. `productId:123`. */
133
+ fq?: string[];
134
+ /** `_from`, 0-based inclusive. */
135
+ from?: number;
136
+ /** `_to`, 0-based inclusive. */
137
+ to?: number;
138
+ /** `O`. */
139
+ sort?: LegacySort;
140
+ salesChannel?: string;
141
+ }
142
+
143
+ interface CallInit extends VtexRequestOptions {
144
+ method?: string;
145
+ body?: unknown;
146
+ headers?: Record<string, string>;
147
+ }
148
+
149
+ /**
150
+ * Encodes a caller-supplied path (e.g. the shopper's URL path) segment by
151
+ * segment, so it can't climb out of its endpoint (`..`, `%2e%2e`) or inject a
152
+ * query (`?`, `#`) while the app credentials are attached.
153
+ */
154
+ function pathSegments(path: string): string {
155
+ return path
156
+ .split("/")
157
+ .filter((segment) => segment !== "")
158
+ .map((segment) => {
159
+ if (/^(\.|%2e){1,2}$/i.test(segment)) throw new Error("vtex: invalid path segment");
160
+ return encodeURIComponent(segment);
161
+ })
162
+ .join("/");
163
+ }
164
+
165
+ export function createVtexClient(config: VtexClientConfig) {
166
+ const host = `${config.account}.${config.environment ?? "vtexcommercestable"}.${config.domain ?? "com.br"}`;
167
+ const base = `https://${host}`;
168
+ const request = createInstrumentedFetch({
169
+ provider: "vtex",
170
+ fetch: config.fetch,
171
+ retry: config.retry === false ? undefined : (config.retry ?? VTEX_DEFAULT_RETRY),
172
+ circuitBreaker:
173
+ config.circuitBreaker === false
174
+ ? undefined
175
+ : (config.circuitBreaker ?? VTEX_DEFAULT_CIRCUIT_BREAKER),
176
+ });
177
+
178
+ async function call(url: string | URL, init: CallInit = {}): Promise<Response> {
179
+ const method = (init.method ?? "GET").toUpperCase();
180
+ const operation = vtexOperationRouter(String(url), method) ?? "unknown";
181
+ const headers = new Headers({ accept: "application/json", ...init.headers });
182
+ if (init.body !== undefined) headers.set("content-type", "application/json");
183
+ // Shopper-scoped calls go out as the shopper only: with app credentials
184
+ // VTEX would answer an orderForm with the profile data unmasked.
185
+ if (config.appKey && config.appToken && !init.cookie) {
186
+ headers.set("x-vtex-api-appkey", config.appKey);
187
+ headers.set("x-vtex-api-apptoken", config.appToken);
188
+ }
189
+ if (init.cookie) {
190
+ const { cookies } = sanitizeOutboundCookieHeader(init.cookie);
191
+ if (cookies) headers.set("cookie", cookies);
192
+ }
193
+ const response = await request(url, {
194
+ method,
195
+ headers,
196
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
197
+ signal: init.signal,
198
+ operation,
199
+ });
200
+ if (!response.ok) {
201
+ void response.body?.cancel().catch(() => {});
202
+ throw new VtexError(operation, response.status);
203
+ }
204
+ return response;
205
+ }
206
+
207
+ /** Only the per-call request options, never the operation's other arguments. */
208
+ const pick = (options: VtexRequestOptions = {}): VtexRequestOptions => ({
209
+ cookie: options.cookie,
210
+ signal: options.signal,
211
+ });
212
+
213
+ const json = async <T>(url: string | URL, init?: CallInit): Promise<T> =>
214
+ (await call(url, init)).json() as Promise<T>;
215
+
216
+ const withCookies = async <T>(url: string | URL, init?: CallInit): Promise<VtexResponse<T>> => {
217
+ const response = await call(url, init);
218
+ const setCookies =
219
+ typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [];
220
+ return { data: (await response.json()) as T, setCookies };
221
+ };
222
+
223
+ const url = (path: string, params: Record<string, string | number | boolean | undefined> = {}) => {
224
+ const result = new URL(path, base);
225
+ for (const [key, value] of Object.entries(params)) {
226
+ if (value !== undefined && value !== "") result.searchParams.set(key, String(value));
227
+ }
228
+ return result;
229
+ };
230
+
231
+ const isUrl = (endpoint: string, args: VtexSearchArgs) => {
232
+ const facetPath = (args.facets ?? [])
233
+ .map(({ key, value }) =>
234
+ key ? `${encodeURIComponent(key)}/${encodeURIComponent(value)}` : encodeURIComponent(value),
235
+ )
236
+ .join("/");
237
+ return url(`/api/io/_v/api/intelligent-search/${endpoint}/${facetPath}`, {
238
+ query: args.query,
239
+ page: args.page,
240
+ count: args.count,
241
+ sort: args.sort,
242
+ fuzzy: args.fuzzy,
243
+ locale: args.locale ?? config.locale,
244
+ hideUnavailableItems: args.hideUnavailableItems,
245
+ simulationBehavior: args.simulationBehavior,
246
+ regionId: args.regionId,
247
+ sc: args.salesChannel ?? config.salesChannel,
248
+ });
249
+ };
250
+
251
+ const orderFormPath = (orderFormId: string, rest = "") =>
252
+ `/api/checkout/pub/orderForm/${encodeURIComponent(orderFormId)}${rest}`;
253
+
254
+ return {
255
+ /** Intelligent Search. */
256
+ search: {
257
+ products: (args: VtexSearchArgs = {}) =>
258
+ json<ProductSearchResult>(isUrl("product_search", args), pick(args)),
259
+ facets: (args: VtexSearchArgs = {}) => json<FacetSearchResult>(isUrl("facets", args), pick(args)),
260
+ suggestions: (args: VtexSearchArgs) =>
261
+ json<Suggestion>(isUrl("search_suggestions", { ...args, facets: [] }), pick(args)),
262
+ autocomplete: (args: VtexSearchArgs) =>
263
+ json<Suggestion>(isUrl("autocomplete_suggestions", { ...args, facets: [] }), pick(args)),
264
+ topSearches: (args: VtexSearchArgs = {}) =>
265
+ json<Suggestion>(isUrl("top_searches", { ...args, facets: [] }), pick(args)),
266
+ },
267
+
268
+ /** Legacy Catalog API. */
269
+ catalog: {
270
+ pageType: async (path: string, options?: VtexRequestOptions) =>
271
+ json<PageType>(url(`/api/catalog_system/pub/portal/pagetype/${pathSegments(path)}`), options),
272
+ products: async (args: VtexCatalogSearchArgs = {}) => {
273
+ const target = url(
274
+ `/api/catalog_system/pub/products/search/${pathSegments(args.term ?? "")}`,
275
+ {
276
+ ft: args.ft,
277
+ _from: args.from,
278
+ _to: args.to,
279
+ O: args.sort,
280
+ sc: args.salesChannel ?? config.salesChannel,
281
+ },
282
+ );
283
+ for (const fq of args.fq ?? []) target.searchParams.append("fq", fq);
284
+ return json<LegacyProduct[]>(target, pick(args));
285
+ },
286
+ crossSelling: (type: CrossSellingType, productId: string, options?: VtexRequestOptions) =>
287
+ json<LegacyProduct[]>(
288
+ url(
289
+ `/api/catalog_system/pub/products/crossselling/${type}/${encodeURIComponent(productId)}`,
290
+ ),
291
+ options,
292
+ ),
293
+ categoryTree: (levels = 3, options?: VtexRequestOptions) =>
294
+ json<Category[]>(url(`/api/catalog_system/pub/category/tree/${levels}`), options),
295
+ },
296
+
297
+ /** Checkout API. Cart calls are shopper-scoped and return VTEX's `Set-Cookie` values. */
298
+ checkout: {
299
+ /**
300
+ * Gets the shopper's cart from their `checkout.vtex.com` cookie, or creates one.
301
+ * Every section by default; `sections` narrows it (`expectedOrderFormSections`).
302
+ */
303
+ orderForm: (options: VtexRequestOptions & { sections?: string[] } = {}) =>
304
+ withCookies<OrderForm>(url("/api/checkout/pub/orderForm"), {
305
+ ...pick(options),
306
+ method: "POST",
307
+ body: options.sections ? { expectedOrderFormSections: options.sections } : {},
308
+ }),
309
+ addItems: (orderFormId: string, orderItems: OrderFormItemInput[], options?: VtexRequestOptions) =>
310
+ withCookies<OrderForm>(url(orderFormPath(orderFormId, "/items")), {
311
+ ...options,
312
+ method: "POST",
313
+ body: { orderItems },
314
+ }),
315
+ updateItems: (
316
+ orderFormId: string,
317
+ orderItems: { index: number; quantity: number }[],
318
+ options?: VtexRequestOptions,
319
+ ) =>
320
+ withCookies<OrderForm>(url(orderFormPath(orderFormId, "/items/update")), {
321
+ ...options,
322
+ method: "POST",
323
+ body: { orderItems },
324
+ }),
325
+ addCoupon: (orderFormId: string, text: string, options?: VtexRequestOptions) =>
326
+ withCookies<OrderForm>(url(orderFormPath(orderFormId, "/coupons")), {
327
+ ...options,
328
+ method: "POST",
329
+ body: { text },
330
+ }),
331
+ attachment: (
332
+ orderFormId: string,
333
+ name: string,
334
+ body: Record<string, unknown>,
335
+ options?: VtexRequestOptions,
336
+ ) =>
337
+ withCookies<OrderForm>(
338
+ url(orderFormPath(orderFormId, `/attachments/${encodeURIComponent(name)}`)),
339
+ { ...options, method: "POST", body },
340
+ ),
341
+ simulation: (
342
+ body: {
343
+ items: { id: string; quantity: number; seller: string }[];
344
+ postalCode?: string;
345
+ country?: string;
346
+ },
347
+ options: VtexRequestOptions & { salesChannel?: string } = {},
348
+ ) =>
349
+ json<SimulationOrderForm>(
350
+ url("/api/checkout/pub/orderForms/simulation", {
351
+ sc: options.salesChannel ?? config.salesChannel,
352
+ }),
353
+ { ...pick(options), method: "POST", body },
354
+ ),
355
+ /** The region (and its sellers) serving a postal code; its `id` is the `regionId`. */
356
+ regions: (
357
+ args: { postalCode: string; country: string; salesChannel?: string },
358
+ options?: VtexRequestOptions,
359
+ ) =>
360
+ json<{ id: string; sellers: { id: string; name: string }[] }[]>(
361
+ url("/api/checkout/pub/regions", {
362
+ postalCode: args.postalCode,
363
+ country: args.country,
364
+ sc: args.salesChannel ?? config.salesChannel,
365
+ }),
366
+ options,
367
+ ),
368
+ },
369
+
370
+ /** Session API. Shopper-scoped; returns VTEX's `Set-Cookie` values. */
371
+ sessions: {
372
+ get: (options: VtexRequestOptions & { items?: string[] } = {}) =>
373
+ withCookies<Session>(
374
+ url("/api/sessions", { items: options.items?.join(",") }),
375
+ pick(options),
376
+ ),
377
+ update: (publicProps: Record<string, { value: string }>, options?: VtexRequestOptions) =>
378
+ withCookies<{ id: string; sessionToken?: string }>(url("/api/sessions"), {
379
+ ...options,
380
+ method: "POST",
381
+ body: { public: publicProps },
382
+ }),
383
+ },
384
+
385
+ /** VTEX IO's private GraphQL endpoint (`{account}.myvtex.com`). */
386
+ io: {
387
+ graphql: async <T>(
388
+ body: { query: string; variables?: Record<string, unknown>; operationName?: string },
389
+ options?: VtexRequestOptions,
390
+ ): Promise<T> => {
391
+ const result = await json<{ data: T; errors?: unknown[] }>(
392
+ `https://${config.account}.myvtex.com/_v/private/graphql/v1`,
393
+ { ...options, method: "POST", body },
394
+ );
395
+ // GraphQL errors arrive with HTTP 200; their messages can echo inputs, so they stay out.
396
+ if (result.errors?.length) throw new VtexError("io.graphql", 200);
397
+ return result.data;
398
+ },
399
+ },
400
+
401
+ /** Master Data v1. */
402
+ masterdata: {
403
+ search: <T>(
404
+ entity: string,
405
+ args: VtexRequestOptions & {
406
+ where?: string;
407
+ fields?: string[];
408
+ sort?: string;
409
+ from?: number;
410
+ to?: number;
411
+ } = {},
412
+ ) =>
413
+ json<T[]>(
414
+ url(`/api/dataentities/${encodeURIComponent(entity)}/search`, {
415
+ _where: args.where,
416
+ _fields: args.fields?.join(","),
417
+ _sort: args.sort,
418
+ }),
419
+ {
420
+ ...pick(args),
421
+ headers: { "rest-range": `resources=${args.from ?? 0}-${args.to ?? 10}` },
422
+ },
423
+ ),
424
+ create: (entity: string, document: Record<string, unknown>, options?: VtexRequestOptions) =>
425
+ json<{ Id: string; Href: string; DocumentId: string }>(
426
+ url(`/api/dataentities/${encodeURIComponent(entity)}/documents`),
427
+ { ...options, method: "POST", body: document },
428
+ ),
429
+ },
430
+ };
431
+ }
432
+
433
+ export type VtexClient = ReturnType<typeof createVtexClient>;