@decocms/apps-vtex 7.2.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 +67 -0
- package/src/README.md +6 -0
- package/src/__tests__/client-segment-cookie.test.ts +255 -0
- package/src/__tests__/client-set-cookie-forward.test.ts +257 -0
- package/src/actions/address.ts +250 -0
- package/src/actions/analytics/sendEvent.ts +86 -0
- package/src/actions/auth.ts +333 -0
- package/src/actions/checkout.ts +522 -0
- package/src/actions/index.ts +11 -0
- package/src/actions/masterData.ts +168 -0
- package/src/actions/misc.ts +188 -0
- package/src/actions/newsletter.ts +105 -0
- package/src/actions/orders.ts +34 -0
- package/src/actions/profile.ts +201 -0
- package/src/actions/session.ts +85 -0
- package/src/actions/trigger.ts +43 -0
- package/src/actions/wishlist.ts +114 -0
- package/src/client.ts +667 -0
- package/src/commerceLoaders.ts +261 -0
- package/src/hooks/__tests__/createUseCart.test.ts +184 -0
- package/src/hooks/__tests__/createUseUser.test.ts +48 -0
- package/src/hooks/__tests__/createUseWishlist.test.ts +87 -0
- package/src/hooks/createUseCart.ts +360 -0
- package/src/hooks/createUseUser.ts +153 -0
- package/src/hooks/createUseWishlist.ts +242 -0
- package/src/hooks/index.ts +19 -0
- package/src/hooks/useAutocomplete.ts +83 -0
- package/src/hooks/useCart.ts +243 -0
- package/src/hooks/useUser.ts +78 -0
- package/src/hooks/useWishlist.ts +119 -0
- package/src/index.ts +17 -0
- package/src/invoke.ts +181 -0
- package/src/loaders/ProductDetailsPage.ts +1 -0
- package/src/loaders/ProductList.ts +1 -0
- package/src/loaders/ProductListingPage.ts +1 -0
- package/src/loaders/address.ts +115 -0
- package/src/loaders/autocomplete.ts +58 -0
- package/src/loaders/brands.ts +44 -0
- package/src/loaders/cart.ts +52 -0
- package/src/loaders/catalog.ts +163 -0
- package/src/loaders/collections.ts +55 -0
- package/src/loaders/index.ts +19 -0
- package/src/loaders/intelligentSearch/__tests__/productListingPage.test.ts +20 -0
- package/src/loaders/intelligentSearch/productDetailsPage.ts +95 -0
- package/src/loaders/intelligentSearch/productList.ts +154 -0
- package/src/loaders/intelligentSearch/productListingPage.ts +506 -0
- package/src/loaders/intelligentSearch/suggestions.ts +46 -0
- package/src/loaders/legacy/productDetailsPage.ts +1 -0
- package/src/loaders/legacy/productList.ts +1 -0
- package/src/loaders/legacy/relatedProductsLoader.ts +81 -0
- package/src/loaders/legacy.ts +635 -0
- package/src/loaders/logistics.ts +111 -0
- package/src/loaders/minicart.ts +78 -0
- package/src/loaders/navbar.ts +26 -0
- package/src/loaders/orders.ts +92 -0
- package/src/loaders/pageType.ts +68 -0
- package/src/loaders/payment.ts +97 -0
- package/src/loaders/productListFull.ts +159 -0
- package/src/loaders/profile.ts +138 -0
- package/src/loaders/promotion.ts +30 -0
- package/src/loaders/search.ts +124 -0
- package/src/loaders/session.ts +83 -0
- package/src/loaders/user.ts +87 -0
- package/src/loaders/wishlist.ts +89 -0
- package/src/loaders/wishlistProducts.ts +69 -0
- package/src/loaders/workflow/products.ts +64 -0
- package/src/loaders/workflow.ts +316 -0
- package/src/logo.png +0 -0
- package/src/middleware.ts +240 -0
- package/src/mod.ts +152 -0
- package/src/registry.ts +9 -0
- package/src/types.ts +248 -0
- package/src/utils/__tests__/cookieSanitizer.test.ts +162 -0
- package/src/utils/__tests__/fetch.test.ts +80 -0
- package/src/utils/__tests__/fetchCache.test.ts +112 -0
- package/src/utils/__tests__/instrumentedFetch.test.ts +158 -0
- package/src/utils/__tests__/intelligentSearch.test.ts +88 -0
- package/src/utils/__tests__/minicart.test.ts +184 -0
- package/src/utils/__tests__/operationRouter.test.ts +227 -0
- package/src/utils/__tests__/resourceRange.test.ts +32 -0
- package/src/utils/__tests__/sitemap.test.ts +185 -0
- package/src/utils/__tests__/slugify.test.ts +31 -0
- package/src/utils/__tests__/transform.test.ts +698 -0
- package/src/utils/accountLoaders.ts +203 -0
- package/src/utils/authHelpers.ts +85 -0
- package/src/utils/batch.ts +18 -0
- package/src/utils/cookieSanitizer.ts +173 -0
- package/src/utils/cookies.ts +167 -0
- package/src/utils/enrichment.ts +560 -0
- package/src/utils/fetch.ts +107 -0
- package/src/utils/fetchCache.ts +222 -0
- package/src/utils/index.ts +19 -0
- package/src/utils/instrumentedFetch.ts +78 -0
- package/src/utils/intelligentSearch.ts +96 -0
- package/src/utils/legacy.ts +139 -0
- package/src/utils/minicart.ts +139 -0
- package/src/utils/operationRouter.ts +139 -0
- package/src/utils/pickAndOmit.ts +25 -0
- package/src/utils/proxy.ts +435 -0
- package/src/utils/resourceRange.ts +10 -0
- package/src/utils/segment.ts +152 -0
- package/src/utils/similars.ts +37 -0
- package/src/utils/sitemap.ts +282 -0
- package/src/utils/slugCache.ts +28 -0
- package/src/utils/slugify.ts +13 -0
- package/src/utils/transform.ts +1509 -0
- package/src/utils/types.ts +1884 -0
- package/src/utils/vtexId.ts +138 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL-derived operation name router for VTEX API calls.
|
|
3
|
+
*
|
|
4
|
+
* Plugged into `@decocms/start`'s `createInstrumentedFetch` via the
|
|
5
|
+
* `resolveOperation(url, method)` option. The resolved string becomes the
|
|
6
|
+
* span suffix (`vtex.<operation>`) and the `fetch.operation` span +
|
|
7
|
+
* histogram label, so it must be:
|
|
8
|
+
*
|
|
9
|
+
* - low-cardinality (no IDs, slugs, search terms, account names);
|
|
10
|
+
* - stable across deploys (used for alerting + dashboards);
|
|
11
|
+
* - human-debuggable in a trace view.
|
|
12
|
+
*
|
|
13
|
+
* The router is intentionally a flat ordered list of regex matchers,
|
|
14
|
+
* not a tree. Adding/auditing routes is a one-line patch and routes
|
|
15
|
+
* are evaluated in priority order (most specific first). Unknown URLs
|
|
16
|
+
* return `undefined` so the framework falls back to the generic
|
|
17
|
+
* `vtex.fetch` span name — observable, just less specific.
|
|
18
|
+
*
|
|
19
|
+
* Callers that need finer granularity than the URL can express (e.g.
|
|
20
|
+
* `POST /orderForm/{id}/items` is one URL but covers add / update /
|
|
21
|
+
* remove flows) should set `init.operation` explicitly per call; that
|
|
22
|
+
* always wins over the router.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
type OperationResolver = string | ((match: RegExpMatchArray, method: string) => string);
|
|
26
|
+
|
|
27
|
+
interface Matcher {
|
|
28
|
+
pattern: RegExp;
|
|
29
|
+
operation: OperationResolver;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const m = (pattern: RegExp, operation: OperationResolver): Matcher => ({ pattern, operation });
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Ordered list of `(regex, operation)` matchers. The first match wins.
|
|
36
|
+
*
|
|
37
|
+
* Patterns match against the URL pathname (the host is ignored — VTEX
|
|
38
|
+
* spreads the same API surface across `*.vtexcommercestable.*`,
|
|
39
|
+
* `*.myvtex.com`, and storefront origins, all on identical paths).
|
|
40
|
+
*
|
|
41
|
+
* Operation strings are bare (no `vtex.` prefix) — the framework
|
|
42
|
+
* prefixes them with the integration name at span time.
|
|
43
|
+
*/
|
|
44
|
+
const MATCHERS: ReadonlyArray<Matcher> = [
|
|
45
|
+
m(/^\/api\/io\/_v\/api\/intelligent-search\/([a-z_-]+)/, (mm) => `intelligent-search.${mm[1]}`),
|
|
46
|
+
m(/^\/_v\/private\/graphql\/v1/, "io.graphql"),
|
|
47
|
+
m(/^\/_v\/segment\//, "io.segment"),
|
|
48
|
+
|
|
49
|
+
m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/items\/update/, (_mm, method) =>
|
|
50
|
+
method === "POST" ? "checkout.orderform.items.update" : "checkout.orderform.items",
|
|
51
|
+
),
|
|
52
|
+
m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/items/, (_mm, method) => {
|
|
53
|
+
if (method === "DELETE") return "checkout.orderform.items.remove";
|
|
54
|
+
if (method === "PATCH" || method === "PUT") return "checkout.orderform.items.update";
|
|
55
|
+
return "checkout.orderform.items.add";
|
|
56
|
+
}),
|
|
57
|
+
m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/coupons/, "checkout.orderform.coupons"),
|
|
58
|
+
m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/profile/, "checkout.orderform.profile"),
|
|
59
|
+
m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/shippingData/, "checkout.orderform.shipping"),
|
|
60
|
+
m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/paymentData/, "checkout.orderform.payment"),
|
|
61
|
+
m(/^\/api\/checkout\/pub\/orderForm\/[^/]+/, (_mm, method) =>
|
|
62
|
+
method === "GET" ? "checkout.orderform.get" : "checkout.orderform.update",
|
|
63
|
+
),
|
|
64
|
+
m(/^\/api\/checkout\/pub\/orderForm(?:\/?$)/, (_mm, method) =>
|
|
65
|
+
method === "POST" ? "checkout.orderform.create" : "checkout.orderform.get",
|
|
66
|
+
),
|
|
67
|
+
m(/^\/api\/checkout\/pub\/orderForms\/simulation/, "checkout.simulation"),
|
|
68
|
+
m(/^\/api\/checkout\/pub\/regions/, "checkout.regions"),
|
|
69
|
+
m(/^\/api\/checkout\/pub\/postal-code/, "checkout.postal-code"),
|
|
70
|
+
|
|
71
|
+
m(/^\/api\/sessions/, (_mm, method) => (method === "POST" ? "sessions.update" : "sessions.get")),
|
|
72
|
+
m(/^\/api\/segments\//, "segments.get"),
|
|
73
|
+
|
|
74
|
+
m(/^\/api\/catalog_system\/pub\/portal\/pagetype\//, "catalog.pagetype"),
|
|
75
|
+
m(
|
|
76
|
+
/^\/api\/catalog_system\/pub\/products\/crossselling\/([^/]+)/,
|
|
77
|
+
(mm) => `catalog.crossselling.${mm[1]}`,
|
|
78
|
+
),
|
|
79
|
+
m(/^\/api\/catalog_system\/pub\/products\/variations\//, "catalog.products.variations"),
|
|
80
|
+
m(/^\/api\/catalog_system\/pub\/products\/search/, "catalog.products.search"),
|
|
81
|
+
m(/^\/api\/catalog_system\/pub\/facets\/search/, "catalog.facets.search"),
|
|
82
|
+
m(/^\/api\/catalog_system\/pub\/category\/tree/, "catalog.category.tree"),
|
|
83
|
+
m(/^\/api\/catalog_system\/(?:pub|pvt)\/specification/, "catalog.specification"),
|
|
84
|
+
m(/^\/api\/catalog_system\/pub\/brand/, "catalog.brand"),
|
|
85
|
+
m(/^\/api\/catalog_system\/pvt\/sku\//, "catalog.sku"),
|
|
86
|
+
m(/^\/api\/catalog_system\//, "catalog.other"),
|
|
87
|
+
|
|
88
|
+
m(/^\/api\/wishlist\//, "wishlist"),
|
|
89
|
+
m(/^\/api\/profile-system\/profile\//, "profile"),
|
|
90
|
+
m(/^\/api\/dataentities\/([^/]+)/, (mm) => `masterdata.${mm[1]}`),
|
|
91
|
+
|
|
92
|
+
m(/^\/api\/oms\/user\/orders\/[^/]+\/cancel/, "oms.orders.cancel"),
|
|
93
|
+
m(/^\/api\/oms\/user\/orders/, "oms.orders"),
|
|
94
|
+
m(/^\/api\/oms\/pvt\/orders/, "oms.orders.pvt"),
|
|
95
|
+
|
|
96
|
+
m(/^\/api\/vtexid\/pub\/logout/, "vtexid.logout"),
|
|
97
|
+
m(/^\/api\/vtexid\/pub\/authentication\/start/, "vtexid.authentication.start"),
|
|
98
|
+
m(/^\/api\/vtexid\/pub\/authentication\/[a-z]+\/validate/, "vtexid.authentication.validate"),
|
|
99
|
+
m(/^\/api\/vtexid\/pub\/authenticated\/user/, "vtexid.user"),
|
|
100
|
+
m(/^\/api\/vtexid\//, "vtexid.other"),
|
|
101
|
+
|
|
102
|
+
m(/^\/api\/events\/v1\//, "events.send"),
|
|
103
|
+
m(/^\/sitemap.*\.xml$/, "sitemap"),
|
|
104
|
+
m(/^\/api\/license-manager/, "license-manager"),
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve an operation name for a VTEX URL. Returns `undefined` if no
|
|
109
|
+
* matcher fires, which causes the framework to fall back to
|
|
110
|
+
* `vtex.fetch`.
|
|
111
|
+
*
|
|
112
|
+
* Designed to be passed directly to `createInstrumentedFetch`:
|
|
113
|
+
*
|
|
114
|
+
* ```ts
|
|
115
|
+
* createInstrumentedFetch({
|
|
116
|
+
* name: "vtex",
|
|
117
|
+
* resolveOperation: vtexOperationRouter,
|
|
118
|
+
* });
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
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
|
+
}
|
|
131
|
+
|
|
132
|
+
const upperMethod = method.toUpperCase();
|
|
133
|
+
for (const { pattern, operation } of MATCHERS) {
|
|
134
|
+
const match = pathname.match(pattern);
|
|
135
|
+
if (!match) continue;
|
|
136
|
+
return typeof operation === "function" ? operation(match, upperMethod) : operation;
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function pick<T extends object, K extends keyof T = keyof T>(
|
|
2
|
+
keys: K[],
|
|
3
|
+
obj: T | null | undefined,
|
|
4
|
+
): Pick<T, K> | null {
|
|
5
|
+
if (!keys.length || !obj) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const entries = keys.map((key) => [key, obj[key]]);
|
|
10
|
+
|
|
11
|
+
return Object.fromEntries(entries);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function omit<T extends object, K extends keyof T>(
|
|
15
|
+
keys: K[],
|
|
16
|
+
obj: T | null | undefined,
|
|
17
|
+
): Omit<T, K> | null {
|
|
18
|
+
if (!keys.length || !obj) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const pickedKeys = (Object.keys(obj) as K[]).filter((key) => !keys.includes(key));
|
|
23
|
+
|
|
24
|
+
return pick(pickedKeys, obj) as unknown as Omit<T, K>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VTEX Proxy Utility.
|
|
3
|
+
*
|
|
4
|
+
* Proxies storefront requests for /checkout, /account, /api, /files, /arquivos
|
|
5
|
+
* to the VTEX origin. Essential for checkout and My Account pages to work.
|
|
6
|
+
*
|
|
7
|
+
* Two flavors:
|
|
8
|
+
* - `proxyToVtex()` — simple single-origin proxy (vtexcommercestable)
|
|
9
|
+
* - `createVtexCheckoutProxy()` — production-grade dual-origin proxy with
|
|
10
|
+
* proper cookie attribute preservation, non-ASCII sanitization, and
|
|
11
|
+
* configurable origin routing (checkout UI vs API paths)
|
|
12
|
+
*
|
|
13
|
+
* Designed to be used with TanStack Start API routes or Cloudflare Worker
|
|
14
|
+
* fetch handlers.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { getVtexConfig, getVtexFetch, type VtexConfig, vtexHost } from "../client";
|
|
18
|
+
import { proxySetCookie } from "./cookies";
|
|
19
|
+
|
|
20
|
+
export interface VtexProxyOptions {
|
|
21
|
+
/**
|
|
22
|
+
* VTEX environment suffix.
|
|
23
|
+
* @default "vtexcommercestable"
|
|
24
|
+
*/
|
|
25
|
+
environment?: "vtexcommercestable" | "vtexcommercebeta";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Additional path prefixes to proxy beyond the defaults.
|
|
29
|
+
* Example: ["/custom-api/"]
|
|
30
|
+
*/
|
|
31
|
+
extraPaths?: string[];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Paths that should NOT be proxied even if they match a prefix.
|
|
35
|
+
*/
|
|
36
|
+
excludePaths?: string[];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether to rewrite Set-Cookie domains to the storefront's domain.
|
|
40
|
+
* @default true
|
|
41
|
+
*/
|
|
42
|
+
rewriteCookieDomain?: boolean;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Custom headers to inject into every proxied request.
|
|
46
|
+
*/
|
|
47
|
+
extraHeaders?: Record<string, string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DEFAULT_PROXY_PATHS = [
|
|
51
|
+
"/checkout",
|
|
52
|
+
"/checkout/",
|
|
53
|
+
"/account",
|
|
54
|
+
"/account/",
|
|
55
|
+
"/api/",
|
|
56
|
+
"/files/",
|
|
57
|
+
"/arquivos/",
|
|
58
|
+
"/checkout/changeToAnonymousUser/",
|
|
59
|
+
"/_v/",
|
|
60
|
+
"/no-cache/",
|
|
61
|
+
"/graphql/",
|
|
62
|
+
"/login",
|
|
63
|
+
"/login/",
|
|
64
|
+
"/logout",
|
|
65
|
+
"/logout/",
|
|
66
|
+
"/assets/vtex",
|
|
67
|
+
"/_secure/account",
|
|
68
|
+
"/XMLData/",
|
|
69
|
+
] as const;
|
|
70
|
+
|
|
71
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
72
|
+
"connection",
|
|
73
|
+
"keep-alive",
|
|
74
|
+
"proxy-authenticate",
|
|
75
|
+
"proxy-authorization",
|
|
76
|
+
"te",
|
|
77
|
+
"trailers",
|
|
78
|
+
"transfer-encoding",
|
|
79
|
+
"upgrade",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Returns all path prefixes that should be proxied to VTEX.
|
|
84
|
+
*/
|
|
85
|
+
export function getVtexProxyPaths(options?: VtexProxyOptions): string[] {
|
|
86
|
+
return [...DEFAULT_PROXY_PATHS, ...(options?.extraPaths ?? [])];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check if a request path should be proxied to VTEX.
|
|
91
|
+
*/
|
|
92
|
+
export function shouldProxyToVtex(pathname: string, options?: VtexProxyOptions): boolean {
|
|
93
|
+
const paths = getVtexProxyPaths(options);
|
|
94
|
+
const excluded = options?.excludePaths ?? [];
|
|
95
|
+
|
|
96
|
+
if (excluded.some((ex) => pathname.startsWith(ex))) return false;
|
|
97
|
+
return paths.some((prefix) => pathname.startsWith(prefix));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildOriginUrl(request: Request, config: VtexConfig, environment: string): URL {
|
|
101
|
+
const url = new URL(request.url);
|
|
102
|
+
const originHost = vtexHost(environment, config);
|
|
103
|
+
return new URL(`https://${originHost}${url.pathname}${url.search}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Copy headers excluding hop-by-hop and Set-Cookie.
|
|
108
|
+
*
|
|
109
|
+
* Set-Cookie is excluded intentionally: Headers.forEach / .set() joins
|
|
110
|
+
* multiple Set-Cookie values with ", " which corrupts cookies containing
|
|
111
|
+
* commas (e.g. Expires dates). proxySetCookie handles Set-Cookie
|
|
112
|
+
* separately using Headers.getSetCookie() for correct multi-cookie support.
|
|
113
|
+
*/
|
|
114
|
+
function filterHeaders(headers: Headers): Headers {
|
|
115
|
+
const filtered = new Headers();
|
|
116
|
+
headers.forEach((value, key) => {
|
|
117
|
+
const lower = key.toLowerCase();
|
|
118
|
+
if (lower === "set-cookie") return;
|
|
119
|
+
if (!HOP_BY_HOP_HEADERS.has(lower)) {
|
|
120
|
+
filtered.set(key, value);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
return filtered;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Proxy a request to VTEX origin.
|
|
128
|
+
*
|
|
129
|
+
* Forwards the request with all cookies and headers, rewrites
|
|
130
|
+
* Set-Cookie domains on the response, and strips hop-by-hop headers.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* // In a TanStack Start API route or catch-all handler
|
|
135
|
+
* if (shouldProxyToVtex(url.pathname)) {
|
|
136
|
+
* return proxyToVtex(request);
|
|
137
|
+
* }
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
export async function proxyToVtex(request: Request, options?: VtexProxyOptions): Promise<Response> {
|
|
141
|
+
const config = getVtexConfig();
|
|
142
|
+
const environment = options?.environment ?? "vtexcommercestable";
|
|
143
|
+
|
|
144
|
+
const originUrl = buildOriginUrl(request, config, environment);
|
|
145
|
+
const forwardHeaders = filterHeaders(new Headers(request.headers));
|
|
146
|
+
|
|
147
|
+
const requestUrl = new URL(request.url);
|
|
148
|
+
forwardHeaders.set("origin", request.headers.get("origin") ?? requestUrl.origin);
|
|
149
|
+
forwardHeaders.set("Host", originUrl.hostname);
|
|
150
|
+
forwardHeaders.set("X-Forwarded-Host", requestUrl.host);
|
|
151
|
+
forwardHeaders.set("X-Forwarded-Proto", "https");
|
|
152
|
+
|
|
153
|
+
if (options?.extraHeaders) {
|
|
154
|
+
for (const [k, v] of Object.entries(options.extraHeaders)) {
|
|
155
|
+
forwardHeaders.set(k, v);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (typeof config.appKey === "string" && typeof config.appToken === "string") {
|
|
160
|
+
forwardHeaders.set("X-VTEX-API-AppKey", config.appKey);
|
|
161
|
+
forwardHeaders.set("X-VTEX-API-AppToken", config.appToken);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const init: RequestInit = {
|
|
165
|
+
method: request.method,
|
|
166
|
+
headers: forwardHeaders,
|
|
167
|
+
redirect: "manual",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
171
|
+
init.body = request.body;
|
|
172
|
+
// @ts-expect-error -- needed for streaming body in Workers
|
|
173
|
+
init.duplex = "half";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Route through the configured VTEX fetch so traces / metrics / logs
|
|
177
|
+
// see the proxied origin call. The URL router classifies the call
|
|
178
|
+
// into the right `vtex.<area>.<op>` bucket (e.g. `vtex.checkout.*`,
|
|
179
|
+
// `vtex.vtexid.logout`, `vtex.io.segment`) — no per-callsite hint
|
|
180
|
+
// needed because we're a generic forwarder.
|
|
181
|
+
const originResponse = await getVtexFetch()(originUrl.toString(), init);
|
|
182
|
+
|
|
183
|
+
const responseHeaders = filterHeaders(new Headers(originResponse.headers));
|
|
184
|
+
|
|
185
|
+
proxySetCookie(
|
|
186
|
+
originResponse.headers,
|
|
187
|
+
responseHeaders,
|
|
188
|
+
options?.rewriteCookieDomain !== false ? requestUrl.origin : undefined,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
if (originResponse.status >= 300 && originResponse.status < 400) {
|
|
192
|
+
const location = originResponse.headers.get("location");
|
|
193
|
+
if (location) {
|
|
194
|
+
const originVtexHost = vtexHost(environment, config);
|
|
195
|
+
const storefrontOrigin = requestUrl.origin;
|
|
196
|
+
const vtexOrigin = `https://${originVtexHost}`;
|
|
197
|
+
const rewritten = location.replace(vtexOrigin, storefrontOrigin);
|
|
198
|
+
responseHeaders.set("location", rewritten);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return new Response(originResponse.body, {
|
|
203
|
+
status: originResponse.status,
|
|
204
|
+
statusText: originResponse.statusText,
|
|
205
|
+
headers: responseHeaders,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Production-grade checkout proxy factory
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
export interface VtexCheckoutProxyConfig {
|
|
214
|
+
/** VTEX account name (e.g. "casaevideonewio"). */
|
|
215
|
+
account: string;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Store's public checkout domain (e.g. "secure.casaevideo.com.br").
|
|
219
|
+
* Checkout UI, /files/, and /_v/private/graphql are routed here.
|
|
220
|
+
*/
|
|
221
|
+
checkoutOrigin: string;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* VTEX commerce-stable origin for API calls.
|
|
225
|
+
* @default `https://{account}.vtexcommercestable.com.br`
|
|
226
|
+
*/
|
|
227
|
+
apiOrigin?: string;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* myvtex origin — used for redirect rewriting.
|
|
231
|
+
* @default `https://{account}.myvtex.com`
|
|
232
|
+
*/
|
|
233
|
+
myvtexOrigin?: string;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* VTEX TLD — most accounts use `.com.br`, but some use `.com`.
|
|
237
|
+
* @default "com.br"
|
|
238
|
+
*/
|
|
239
|
+
domain?: string;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Extra paths on which to force-expire cookies.
|
|
243
|
+
* Useful for logout: VTEX sends Max-Age=0 for auth cookies, but the
|
|
244
|
+
* checkout orderForm cookie sometimes survives. This appends explicit
|
|
245
|
+
* Set-Cookie: name=; Max-Age=0 entries.
|
|
246
|
+
*/
|
|
247
|
+
expireCookiesOnPaths?: Array<{
|
|
248
|
+
pathPrefix: string;
|
|
249
|
+
cookies: string[];
|
|
250
|
+
}>;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Optional HTML transform for checkout pages.
|
|
254
|
+
* Receives the full HTML string and should return the modified version.
|
|
255
|
+
*/
|
|
256
|
+
htmlTransform?: (html: string) => string;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const CF_INTERNAL_HEADERS = new Set([
|
|
260
|
+
"cf-connecting-ip",
|
|
261
|
+
"cf-ipcountry",
|
|
262
|
+
"cf-ray",
|
|
263
|
+
"cf-visitor",
|
|
264
|
+
"cf-ew-via",
|
|
265
|
+
"cdn-loop",
|
|
266
|
+
]);
|
|
267
|
+
|
|
268
|
+
const CHECKOUT_SKIP_HEADERS = new Set([
|
|
269
|
+
...HOP_BY_HOP_HEADERS,
|
|
270
|
+
"set-cookie",
|
|
271
|
+
...CF_INTERNAL_HEADERS,
|
|
272
|
+
]);
|
|
273
|
+
|
|
274
|
+
const toAscii = (v: string) => v.replace(/[^\x20-\x7E]/g, "");
|
|
275
|
+
|
|
276
|
+
function filterHeadersStrict(headers: Headers): Headers {
|
|
277
|
+
const filtered = new Headers();
|
|
278
|
+
headers.forEach((value, key) => {
|
|
279
|
+
if (CHECKOUT_SKIP_HEADERS.has(key.toLowerCase())) return;
|
|
280
|
+
try {
|
|
281
|
+
filtered.set(key, toAscii(value));
|
|
282
|
+
} catch {
|
|
283
|
+
// skip headers that still fail after sanitization
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
return filtered;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Rewrite Set-Cookie headers: only change the Domain attribute.
|
|
291
|
+
* Unlike `proxySetCookie`, this preserves ALL attributes (Max-Age,
|
|
292
|
+
* Expires, SameSite, etc.) which is critical for logout.
|
|
293
|
+
*/
|
|
294
|
+
function rewriteSetCookieDomain(from: Headers, to: Headers, toHostname: string) {
|
|
295
|
+
const raw: string[] =
|
|
296
|
+
typeof from.getSetCookie === "function"
|
|
297
|
+
? from.getSetCookie()
|
|
298
|
+
: (from.get("set-cookie") ?? "").split(/,(?=[^ ]+=)/).filter(Boolean);
|
|
299
|
+
|
|
300
|
+
for (const cookie of raw) {
|
|
301
|
+
const rewritten = cookie.replace(/Domain=[^;]*/i, `Domain=${toHostname}`);
|
|
302
|
+
to.append("Set-Cookie", rewritten);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Creates a production-grade VTEX checkout proxy handler.
|
|
308
|
+
*
|
|
309
|
+
* Routes checkout UI pages to the store's public domain and API calls
|
|
310
|
+
* to vtexcommercestable. Properly rewrites Set-Cookie domains (preserving
|
|
311
|
+
* Max-Age/Expires), sanitizes non-ASCII headers, filters hop-by-hop and
|
|
312
|
+
* CF-internal headers, and rewrites Location redirects.
|
|
313
|
+
*
|
|
314
|
+
* Returns a function compatible with `createDecoWorkerEntry`'s `proxyHandler`.
|
|
315
|
+
*
|
|
316
|
+
* @example
|
|
317
|
+
* ```ts
|
|
318
|
+
* const vtexProxy = createVtexCheckoutProxy({
|
|
319
|
+
* account: "casaevideonewio",
|
|
320
|
+
* checkoutOrigin: "secure.casaevideo.com.br",
|
|
321
|
+
* expireCookiesOnPaths: [
|
|
322
|
+
* { pathPrefix: "/api/vtexid/pub/logout", cookies: ["checkout.vtex.com"] },
|
|
323
|
+
* ],
|
|
324
|
+
* htmlTransform: (html) =>
|
|
325
|
+
* html.replace("</head>", "<style>.body{min-height:100vh}</style></head>"),
|
|
326
|
+
* });
|
|
327
|
+
*
|
|
328
|
+
* createDecoWorkerEntry(serverEntry, {
|
|
329
|
+
* proxyHandler: async (request, url) => {
|
|
330
|
+
* if (url.pathname === "/login") return null;
|
|
331
|
+
* if (!shouldProxyToVtex(url.pathname)) return null;
|
|
332
|
+
* return vtexProxy(request, url);
|
|
333
|
+
* },
|
|
334
|
+
* });
|
|
335
|
+
* ```
|
|
336
|
+
*/
|
|
337
|
+
export function createVtexCheckoutProxy(
|
|
338
|
+
config: VtexCheckoutProxyConfig,
|
|
339
|
+
): (request: Request, url: URL) => Promise<Response> {
|
|
340
|
+
const domain = config.domain ?? "com.br";
|
|
341
|
+
const checkoutOrigin = config.checkoutOrigin.startsWith("https://")
|
|
342
|
+
? config.checkoutOrigin
|
|
343
|
+
: `https://${config.checkoutOrigin}`;
|
|
344
|
+
const apiOrigin = config.apiOrigin ?? `https://${config.account}.vtexcommercestable.${domain}`;
|
|
345
|
+
const myvtexOrigin = config.myvtexOrigin ?? `https://${config.account}.myvtex.com`;
|
|
346
|
+
|
|
347
|
+
function getOrigin(pathname: string, method: string): string {
|
|
348
|
+
if (
|
|
349
|
+
pathname.startsWith("/checkout") ||
|
|
350
|
+
pathname.startsWith("/account") ||
|
|
351
|
+
pathname.startsWith("/_secure/account") ||
|
|
352
|
+
pathname.startsWith("/files/") ||
|
|
353
|
+
pathname.startsWith("/_v/private/graphql")
|
|
354
|
+
) {
|
|
355
|
+
return checkoutOrigin;
|
|
356
|
+
}
|
|
357
|
+
if (method !== "GET" && method !== "HEAD" && pathname.startsWith("/_v/")) {
|
|
358
|
+
return checkoutOrigin;
|
|
359
|
+
}
|
|
360
|
+
return apiOrigin;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return async (request: Request, url: URL): Promise<Response> => {
|
|
364
|
+
const origin = getOrigin(url.pathname, request.method);
|
|
365
|
+
const originUrl = new URL(`${origin}${url.pathname}${url.search}`);
|
|
366
|
+
const fwd = filterHeadersStrict(new Headers(request.headers));
|
|
367
|
+
|
|
368
|
+
fwd.set("Host", originUrl.hostname);
|
|
369
|
+
fwd.set("X-Forwarded-Host", url.host);
|
|
370
|
+
fwd.set("X-Forwarded-Proto", "https");
|
|
371
|
+
fwd.set("origin", request.headers.get("origin") ?? url.origin);
|
|
372
|
+
|
|
373
|
+
const isCheckoutUI =
|
|
374
|
+
url.pathname.startsWith("/checkout") || url.pathname.startsWith("/account");
|
|
375
|
+
const isLogout = url.pathname.startsWith("/api/vtexid/pub/logout");
|
|
376
|
+
|
|
377
|
+
const init: RequestInit = {
|
|
378
|
+
method: request.method,
|
|
379
|
+
headers: fwd,
|
|
380
|
+
redirect: isCheckoutUI || isLogout ? "manual" : "follow",
|
|
381
|
+
};
|
|
382
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
383
|
+
init.body = request.body;
|
|
384
|
+
// @ts-expect-error -- needed for streaming body in Workers
|
|
385
|
+
init.duplex = "half";
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const originRes = await getVtexFetch()(originUrl.toString(), init);
|
|
389
|
+
const resHeaders = filterHeadersStrict(new Headers(originRes.headers));
|
|
390
|
+
rewriteSetCookieDomain(originRes.headers, resHeaders, url.hostname);
|
|
391
|
+
|
|
392
|
+
// Force-expire cookies on configured paths
|
|
393
|
+
if (config.expireCookiesOnPaths) {
|
|
394
|
+
for (const rule of config.expireCookiesOnPaths) {
|
|
395
|
+
if (url.pathname.startsWith(rule.pathPrefix)) {
|
|
396
|
+
for (const name of rule.cookies) {
|
|
397
|
+
resHeaders.append("Set-Cookie", `${name}=; Path=/; Max-Age=0; Domain=${url.hostname}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Rewrite redirect Location headers from VTEX domains to storefront
|
|
404
|
+
if (originRes.status >= 300 && originRes.status < 400) {
|
|
405
|
+
const loc = originRes.headers.get("location");
|
|
406
|
+
if (loc) {
|
|
407
|
+
resHeaders.set(
|
|
408
|
+
"location",
|
|
409
|
+
loc
|
|
410
|
+
.replace(checkoutOrigin, url.origin)
|
|
411
|
+
.replace(apiOrigin, url.origin)
|
|
412
|
+
.replace(myvtexOrigin, url.origin),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// HTML transform for checkout pages
|
|
418
|
+
const ct = originRes.headers.get("content-type") ?? "";
|
|
419
|
+
if (config.htmlTransform && ct.includes("text/html")) {
|
|
420
|
+
const html = await originRes.text();
|
|
421
|
+
const patched = config.htmlTransform(html);
|
|
422
|
+
return new Response(patched, {
|
|
423
|
+
status: originRes.status,
|
|
424
|
+
statusText: originRes.statusText,
|
|
425
|
+
headers: resHeaders,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return new Response(originRes.body, {
|
|
430
|
+
status: originRes.status,
|
|
431
|
+
statusText: originRes.statusText,
|
|
432
|
+
headers: resHeaders,
|
|
433
|
+
});
|
|
434
|
+
};
|
|
435
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build REST-Range header values for VTEX paginated APIs.
|
|
3
|
+
* Ported from deco-cx/apps vtex/utils/resourceRange.ts
|
|
4
|
+
*/
|
|
5
|
+
export function resourceRange(skip: number, take: number) {
|
|
6
|
+
const from = Math.max(skip, 0);
|
|
7
|
+
const to = from + Math.min(100, take);
|
|
8
|
+
|
|
9
|
+
return { from, to };
|
|
10
|
+
}
|