@decocms/apps 0.28.1 → 1.0.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",
3
- "version": "0.28.1",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "description": "Deco commerce apps for TanStack Start - Shopify, VTEX, commerce types, analytics utils",
6
6
  "exports": {
@@ -22,6 +22,7 @@
22
22
  "./shopify/actions/user/*": "./shopify/actions/user/*.ts",
23
23
  "./shopify/utils/*": "./shopify/utils/*.ts",
24
24
  "./vtex": "./vtex/index.ts",
25
+ "./vtex/commerceLoaders": "./vtex/commerceLoaders.ts",
25
26
  "./vtex/mod": "./vtex/mod.ts",
26
27
  "./vtex/client": "./vtex/client.ts",
27
28
  "./vtex/types": "./vtex/types.ts",
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Standard VTEX commerce loader map factory for CMS block resolution.
3
+ *
4
+ * Wraps all VTEX inline loaders with createCachedLoader, applies universal
5
+ * workarounds (slug fallback, IS sort sanitization, map=productClusterIds),
6
+ * and registers both `.ts` and `.ts`-less aliases.
7
+ *
8
+ * Sites call `createVtexCommerceLoaders()` instead of manually wiring ~30
9
+ * individual loader entries. Site-specific loaders are merged via `extra`.
10
+ */
11
+
12
+ import { createCachedLoader } from "@decocms/start/sdk/cachedLoader";
13
+ import type { CacheProfileName } from "@decocms/start/sdk/cacheHeaders";
14
+ import vtexProductList from "./inline-loaders/productList";
15
+ import vtexProductListShelf from "./inline-loaders/productListShelf";
16
+ import vtexProductDetailsPage from "./inline-loaders/productDetailsPage";
17
+ import vtexProductListingPage from "./inline-loaders/productListingPage";
18
+ import vtexSuggestions from "./inline-loaders/suggestions";
19
+ import vtexRelatedProducts from "./inline-loaders/relatedProducts";
20
+ import vtexWorkflowProducts from "./inline-loaders/workflowProducts";
21
+ import { getCategoryTree } from "./loaders/catalog";
22
+ import { VALID_IS_SORTS } from "./utils/intelligentSearch";
23
+
24
+ export type CommerceLoaderFn = (props: any) => Promise<any>;
25
+
26
+ export interface VtexCommerceLoadersOptions {
27
+ /** Override cache profiles per loader type. */
28
+ cacheProfiles?: {
29
+ listing?: CacheProfileName;
30
+ product?: CacheProfileName;
31
+ search?: CacheProfileName;
32
+ static?: CacheProfileName;
33
+ };
34
+ /** Additional loaders to merge into the map (site-specific). */
35
+ extra?: Record<string, CommerceLoaderFn>;
36
+ }
37
+
38
+ /**
39
+ * Bridge __pagePath → slug when CMS doesn't set slug explicitly.
40
+ * VTEX PDP pages receive __pagePath (e.g. "/produto-slug/p") but the
41
+ * inline loader only reads the `slug` field.
42
+ */
43
+ function pdpWithSlugFallback(props: any): Promise<any> {
44
+ if ((!props.slug || props.slug.length === 0) && props.__pagePath) {
45
+ props = { ...props, slug: props.__pagePath };
46
+ }
47
+ return vtexProductDetailsPage(props);
48
+ }
49
+
50
+ /**
51
+ * Extract collection name from PLP product data.
52
+ * Products carry cluster info in additionalProperty with name="cluster".
53
+ */
54
+ function extractCollectionName(
55
+ result: any,
56
+ collectionId: string,
57
+ ): string | null {
58
+ if (!result?.products?.length) return null;
59
+ for (const product of result.products) {
60
+ const props =
61
+ product.additionalProperty ||
62
+ product.isVariantOf?.additionalProperty ||
63
+ [];
64
+ for (const prop of props) {
65
+ if (prop.name === "cluster" && prop.propertyID === collectionId) {
66
+ return prop.value || null;
67
+ }
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Returns the standard VTEX commerce loader map for CMS resolution.
75
+ *
76
+ * Includes all Intelligent Search, legacy, category tree, and navbar loaders
77
+ * with SWR caching. Also registers `.ts`-less aliases for invoke compatibility.
78
+ *
79
+ * @example
80
+ * ```ts
81
+ * import { createVtexCommerceLoaders } from "@decocms/apps/vtex/commerceLoaders";
82
+ *
83
+ * const COMMERCE_LOADERS = {
84
+ * ...createVtexCommerceLoaders(),
85
+ * // site-specific only:
86
+ * "site/loaders/myCustomLoader": async (props) => { ... },
87
+ * };
88
+ * registerCommerceLoaders(COMMERCE_LOADERS);
89
+ * ```
90
+ */
91
+ export function createVtexCommerceLoaders(
92
+ options?: VtexCommerceLoadersOptions,
93
+ ): Record<string, CommerceLoaderFn> {
94
+ const profiles = {
95
+ listing: options?.cacheProfiles?.listing ?? "listing",
96
+ product: options?.cacheProfiles?.product ?? "product",
97
+ search: options?.cacheProfiles?.search ?? "search",
98
+ static: options?.cacheProfiles?.static ?? "static",
99
+ };
100
+
101
+ const cachedProductList = createCachedLoader(
102
+ "vtex/productList",
103
+ vtexProductList,
104
+ profiles.listing,
105
+ );
106
+ const cachedProductListShelf = createCachedLoader(
107
+ "vtex/productListShelf",
108
+ vtexProductListShelf,
109
+ profiles.listing,
110
+ );
111
+ const cachedPDP = createCachedLoader(
112
+ "vtex/productDetailsPage",
113
+ pdpWithSlugFallback,
114
+ profiles.product,
115
+ );
116
+ const _cachedPLP = createCachedLoader(
117
+ "vtex/productListingPage",
118
+ vtexProductListingPage,
119
+ profiles.listing,
120
+ );
121
+ const cachedSuggestions = createCachedLoader(
122
+ "vtex/suggestions",
123
+ vtexSuggestions,
124
+ profiles.search,
125
+ );
126
+ const cachedRelatedProducts = createCachedLoader(
127
+ "vtex/relatedProducts",
128
+ vtexRelatedProducts,
129
+ profiles.product,
130
+ );
131
+ const cachedWorkflowProducts = createCachedLoader(
132
+ "vtex/workflowProducts",
133
+ vtexWorkflowProducts,
134
+ profiles.listing,
135
+ );
136
+
137
+ /**
138
+ * PLP wrapper: handles `map=productClusterIds` legacy URLs and sanitizes
139
+ * IS sort parameters that would cause VTEX API 400 errors.
140
+ */
141
+ const cachedPLP: CommerceLoaderFn = async (props) => {
142
+ if (props.__pageUrl && !props.selectedFacets?.length) {
143
+ try {
144
+ const pageUrl = new URL(props.__pageUrl, "https://localhost");
145
+ const mapParam = pageUrl.searchParams.get("map");
146
+ if (mapParam && props.__pagePath) {
147
+ const segments = props.__pagePath.split("/").filter(Boolean);
148
+ const mapValues = mapParam.split(",");
149
+ const facets: Array<{ key: string; value: string }> = [];
150
+ for (
151
+ let i = 0;
152
+ i < Math.min(segments.length, mapValues.length);
153
+ i++
154
+ ) {
155
+ const key = mapValues[i].trim();
156
+ const value = decodeURIComponent(segments[i]);
157
+ if (key && value) facets.push({ key, value });
158
+ }
159
+ if (facets.length) {
160
+ const rawSort = pageUrl.searchParams.get("sort") ?? "";
161
+ const cleanSort = VALID_IS_SORTS.has(rawSort) ? rawSort : "";
162
+
163
+ if (rawSort !== cleanSort) {
164
+ if (cleanSort) {
165
+ pageUrl.searchParams.set("sort", cleanSort);
166
+ } else {
167
+ pageUrl.searchParams.delete("sort");
168
+ }
169
+ }
170
+
171
+ const result = await _cachedPLP({
172
+ ...props,
173
+ selectedFacets: facets,
174
+ sort: cleanSort || undefined,
175
+ __pageUrl: pageUrl.toString(),
176
+ });
177
+
178
+ const clusterFacet = facets.find(
179
+ (f) => f.key === "productClusterIds",
180
+ );
181
+ if (result && clusterFacet) {
182
+ const collectionName = extractCollectionName(
183
+ result,
184
+ clusterFacet.value,
185
+ );
186
+ if (collectionName) {
187
+ result.breadcrumb = {
188
+ "@type": "BreadcrumbList",
189
+ itemListElement: [
190
+ {
191
+ "@type": "ListItem",
192
+ name: collectionName,
193
+ item: props.__pagePath || "/",
194
+ position: 1,
195
+ },
196
+ ],
197
+ numberOfItems: 1,
198
+ };
199
+ result.seo = { ...result.seo, title: collectionName };
200
+ }
201
+ }
202
+ return result;
203
+ }
204
+ }
205
+ } catch (e) {
206
+ console.error("[PLP] Error parsing map param:", e);
207
+ }
208
+ }
209
+ return _cachedPLP(props);
210
+ };
211
+
212
+ /**
213
+ * Related products wrapper: extracts slug from __pagePath when the CMS
214
+ * requestToParam stub returns null (standard in TanStack sites).
215
+ */
216
+ const relatedWithSlugFallback: CommerceLoaderFn = (props) => {
217
+ if (!props.slug && props.__pagePath) {
218
+ const path = props.__pagePath.replace(/\/p$/, "").replace(/^\//, "");
219
+ props = { ...props, slug: path };
220
+ }
221
+ return cachedRelatedProducts(props);
222
+ };
223
+
224
+ const loaders: Record<string, CommerceLoaderFn> = {
225
+ // Intelligent Search loaders
226
+ "vtex/loaders/intelligentSearch/productListingPage.ts": cachedPLP,
227
+ "vtex/loaders/intelligentSearch/productList.ts": cachedProductListShelf,
228
+ "vtex/loaders/intelligentSearch/productDetailsPage.ts": cachedPDP,
229
+ "vtex/loaders/intelligentSearch/suggestions.ts": cachedSuggestions,
230
+ // Legacy loaders (map to same cached functions)
231
+ "vtex/loaders/legacy/productDetailsPage.ts": cachedPDP,
232
+ "vtex/loaders/legacy/productList.ts": cachedProductListShelf,
233
+ "vtex/loaders/legacy/relatedProductsLoader.ts": relatedWithSlugFallback,
234
+ // Workflow
235
+ "vtex/loaders/workflow/products.ts": cachedWorkflowProducts,
236
+ // Top-level aliases (used by some CMS block configs)
237
+ "vtex/loaders/ProductList.ts": cachedProductListShelf,
238
+ "vtex/loaders/ProductDetailsPage.ts": cachedPDP,
239
+ "vtex/loaders/ProductListingPage.ts": cachedPLP,
240
+ // Category tree
241
+ "vtex/loaders/categories/tree": (props: any) =>
242
+ getCategoryTree(props?.categoryLevels ?? 3),
243
+ // Commerce passthrough loaders
244
+ "commerce/loaders/navbar.ts": async (props: any) => props.items ?? [],
245
+ "commerce/loaders/product/extensions/detailsPage.ts": async (
246
+ props: any,
247
+ ) => {
248
+ const data = props.data;
249
+ if (data?.product) return data;
250
+ return cachedPDP({ __pagePath: props.__pagePath });
251
+ },
252
+ // requestToParam stub — unresolvable in TanStack, pdpWithSlugFallback bridges it
253
+ "website/functions/requestToParam.ts": async () => null,
254
+ };
255
+
256
+ // Register .ts-less aliases for invoke compatibility
257
+ const withAliases: Record<string, CommerceLoaderFn> = { ...loaders };
258
+ for (const key of Object.keys(loaders)) {
259
+ if (key.endsWith(".ts")) {
260
+ withAliases[key.slice(0, -3)] = loaders[key];
261
+ }
262
+ }
263
+
264
+ if (options?.extra) {
265
+ Object.assign(withAliases, options.extra);
266
+ }
267
+
268
+ return withAliases;
269
+ }
270
+
271
+ /**
272
+ * Exposes the cached PDP loader for site-specific section loaders that need
273
+ * to call it directly (e.g. ProductDescription fallback).
274
+ *
275
+ * Returns a new instance each call — sites should cache the reference.
276
+ */
277
+ export function createCachedPDPLoader(
278
+ profile: CacheProfileName = "product",
279
+ ): CommerceLoaderFn {
280
+ return createCachedLoader(
281
+ "vtex/productDetailsPage",
282
+ pdpWithSlugFallback,
283
+ profile,
284
+ );
285
+ }
@@ -622,3 +622,14 @@ export async function legacySuggestions(
622
622
 
623
623
  return { searches, products };
624
624
  }
625
+
626
+ // ---------------------------------------------------------------------------
627
+ // Short aliases — sites use invoke.vtex.loaders.legacy.productDetailsPage()
628
+ // ---------------------------------------------------------------------------
629
+
630
+ export {
631
+ legacyProductDetailsPage as productDetailsPage,
632
+ legacyProductList as productList,
633
+ legacyProductListingPage as productListingPage,
634
+ legacySuggestions as suggestions,
635
+ };
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Pre-built section loaders for VTEX account pages.
3
+ *
4
+ * Every VTEX site with account pages repeats the same pattern:
5
+ * 1. Extract VTEX cookies from request
6
+ * 2. Call the VTEX user/profile/address/payment API
7
+ * 3. Return enriched props with { device, logged, ...data }
8
+ * 4. Catch errors gracefully (return logged: false)
9
+ *
10
+ * These factories encapsulate that boilerplate.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { vtexAccountLoaders } from "@decocms/apps/vtex/utils/accountLoaders";
15
+ *
16
+ * registerSectionLoaders({
17
+ * "site/sections/Account/PersonalData.tsx": vtexAccountLoaders.personalData(),
18
+ * "site/sections/Account/MyOrders.tsx": vtexAccountLoaders.orders(),
19
+ * "site/sections/Account/Cards.tsx": vtexAccountLoaders.cards(),
20
+ * "site/sections/Account/Addresses.tsx": vtexAccountLoaders.addresses(),
21
+ * "site/sections/Account/Auth.tsx": vtexAccountLoaders.authentication(),
22
+ * "site/sections/Account/Other.tsx": vtexAccountLoaders.loggedIn(),
23
+ * });
24
+ * ```
25
+ */
26
+ import { getVtexCookies } from "./cookies";
27
+ import { getUser } from "../loaders/user";
28
+ import { getCurrentProfile, type Profile } from "../loaders/profile";
29
+ import { getUserAddresses, type VtexAddress } from "../loaders/address";
30
+ import { getUserPayments, type Payment } from "../loaders/payment";
31
+ import { detectDevice } from "@decocms/start/sdk/useDevice";
32
+
33
+ type Device = "mobile" | "tablet" | "desktop";
34
+
35
+ type SectionLoaderFn = (
36
+ props: Record<string, unknown>,
37
+ req: Request,
38
+ ) => Promise<Record<string, unknown>> | Record<string, unknown>;
39
+
40
+ function getDevice(req: Request): Device {
41
+ return detectDevice(req.headers.get("user-agent") ?? "");
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // personalData — fetches full VTEX profile for personal data sections
46
+ // ---------------------------------------------------------------------------
47
+
48
+ export interface PersonalDataOptions {
49
+ /** Extra custom profile fields to request beyond the standard set. */
50
+ extraProfileFields?: string[];
51
+
52
+ /**
53
+ * Transform the raw VTEX Profile into the shape your component expects.
54
+ * When omitted, the raw Profile object is returned as `profile`.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * vtexAccountLoaders.personalData({
59
+ * mapProfile: (p) => ({
60
+ * "@id": p.userId ?? p.id,
61
+ * email: p.email,
62
+ * givenName: p.firstName ?? null,
63
+ * familyName: p.lastName ?? null,
64
+ * taxID: p.document,
65
+ * }),
66
+ * })
67
+ * ```
68
+ */
69
+ mapProfile?: (profile: Profile) => Record<string, unknown>;
70
+ }
71
+
72
+ function personalData(options?: PersonalDataOptions): SectionLoaderFn {
73
+ const { extraProfileFields, mapProfile } = options ?? {};
74
+ return async (props, req) => {
75
+ const cookie = getVtexCookies(req);
76
+ try {
77
+ const profile = await getCurrentProfile(cookie, extraProfileFields);
78
+ const data = mapProfile ? mapProfile(profile) : profile;
79
+ return {
80
+ ...props,
81
+ device: getDevice(req),
82
+ logged: !!profile,
83
+ loading: false,
84
+ userData: data,
85
+ };
86
+ } catch (error) {
87
+ console.error("[accountLoaders.personalData]", error);
88
+ return { ...props, device: getDevice(req), logged: false, loading: false, userData: null };
89
+ }
90
+ };
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // orders — checks login status for order listing sections
95
+ // ---------------------------------------------------------------------------
96
+
97
+ function orders(): SectionLoaderFn {
98
+ return async (props, req) => {
99
+ const cookie = getVtexCookies(req);
100
+ try {
101
+ const userData = await getUser(cookie);
102
+ return { ...props, device: getDevice(req), logged: !!userData };
103
+ } catch {
104
+ return { ...props, device: getDevice(req), logged: false };
105
+ }
106
+ };
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // cards — fetches saved payment tokens for card management sections
111
+ // ---------------------------------------------------------------------------
112
+
113
+ function cards(): SectionLoaderFn {
114
+ return async (props, req) => {
115
+ const cookie = getVtexCookies(req);
116
+ try {
117
+ const user = await getUser(cookie);
118
+ const logged = !!user;
119
+ let payments: Payment[] = [];
120
+ if (logged) {
121
+ try {
122
+ payments = (await getUserPayments(cookie)) ?? [];
123
+ } catch {
124
+ payments = [];
125
+ }
126
+ }
127
+ return { ...props, logged, payments };
128
+ } catch {
129
+ return { ...props, logged: false, payments: [] };
130
+ }
131
+ };
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // addresses — fetches address list for address management sections
136
+ // ---------------------------------------------------------------------------
137
+
138
+ function addresses(): SectionLoaderFn {
139
+ return async (props, req) => {
140
+ const cookie = getVtexCookies(req);
141
+ try {
142
+ const userData = await getUser(cookie);
143
+ const logged = !!userData;
144
+ let userAddressData: VtexAddress[] | null = null;
145
+ if (logged) {
146
+ try {
147
+ userAddressData = await getUserAddresses(cookie);
148
+ } catch {
149
+ userAddressData = null;
150
+ }
151
+ }
152
+ return { ...props, device: getDevice(req), logged, userAddressData };
153
+ } catch {
154
+ return { ...props, device: getDevice(req), logged: false, userAddressData: null };
155
+ }
156
+ };
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // authentication — checks login + returns user data for auth pages
161
+ // ---------------------------------------------------------------------------
162
+
163
+ function authentication(): SectionLoaderFn {
164
+ return async (props, req) => {
165
+ const cookie = getVtexCookies(req);
166
+ try {
167
+ const userData = await getUser(cookie);
168
+ return { ...props, device: getDevice(req), logged: !!userData, userData };
169
+ } catch {
170
+ return { ...props, device: getDevice(req), logged: false, userData: null };
171
+ }
172
+ };
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // loggedIn — generic "is the user logged in?" loader
177
+ // ---------------------------------------------------------------------------
178
+
179
+ function loggedIn(): SectionLoaderFn {
180
+ return async (props, req) => {
181
+ const cookie = getVtexCookies(req);
182
+ try {
183
+ const userData = await getUser(cookie);
184
+ return { ...props, device: getDevice(req), logged: !!userData };
185
+ } catch {
186
+ return { ...props, device: getDevice(req), logged: false };
187
+ }
188
+ };
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Public API
193
+ // ---------------------------------------------------------------------------
194
+
195
+ export const vtexAccountLoaders = {
196
+ personalData,
197
+ orders,
198
+ cards,
199
+ addresses,
200
+ authentication,
201
+ loggedIn,
202
+ } as const;
@@ -20,13 +20,22 @@ function parseSingleSetCookie(raw: string): Cookie | null {
20
20
  value: nameValue.slice(eqIdx + 1),
21
21
  };
22
22
  for (const attr of attrs) {
23
- const [k, v] = attr.split("=").map((s) => s.trim());
23
+ const eqi = attr.indexOf("=");
24
+ const k = (eqi >= 0 ? attr.slice(0, eqi) : attr).trim();
25
+ const v = eqi >= 0 ? attr.slice(eqi + 1).trim() : "";
24
26
  const lower = k.toLowerCase();
25
27
  if (lower === "domain") cookie.domain = v;
26
28
  else if (lower === "path") cookie.path = v;
27
29
  else if (lower === "secure") cookie.secure = true;
28
30
  else if (lower === "httponly") cookie.httpOnly = true;
29
31
  else if (lower === "samesite") cookie.sameSite = v as Cookie["sameSite"];
32
+ else if (lower === "max-age") {
33
+ const n = Number(v);
34
+ if (!Number.isNaN(n)) cookie.maxAge = n;
35
+ } else if (lower === "expires") {
36
+ const d = new Date(v);
37
+ if (!Number.isNaN(d.getTime())) cookie.expires = d;
38
+ }
30
39
  }
31
40
  return cookie;
32
41
  }
@@ -111,3 +120,55 @@ export const proxySetCookie = (from: Headers, to: Headers, toDomain?: URL | stri
111
120
 
112
121
  export const CHECKOUT_DATA_ACCESS_COOKIE = "CheckoutDataAccess";
113
122
  export const VTEX_CHKO_AUTH = "Vtex_CHKO_Auth";
123
+
124
+ /**
125
+ * Cookie name prefixes that are VTEX-relevant.
126
+ * Used by getVtexCookies() to filter request cookies before forwarding
127
+ * to VTEX APIs — avoids undici non-ASCII warnings from other cookies.
128
+ */
129
+ export const VTEX_COOKIE_PREFIXES = [
130
+ "VtexIdclientAutCookie",
131
+ "checkout.vtex",
132
+ "CheckoutOrderFormOwnership",
133
+ "vtex_is_",
134
+ ];
135
+
136
+ /**
137
+ * Filter a request's cookies to only VTEX-relevant ones.
138
+ * Prevents undici non-ASCII header warnings when forwarding cookies to VTEX APIs.
139
+ */
140
+ export function getVtexCookies(request: Request): string {
141
+ const raw = request.headers.get("cookie") ?? "";
142
+ return raw
143
+ .split(";")
144
+ .map((c) => c.trim())
145
+ .filter((c) => VTEX_COOKIE_PREFIXES.some((p) => c.startsWith(p)))
146
+ .join("; ");
147
+ }
148
+
149
+ /**
150
+ * Ensure the unsuffixed VtexIdclientAutCookie is present alongside the
151
+ * account-suffixed variant (e.g. VtexIdclientAutCookie_myaccount).
152
+ *
153
+ * VTEX GraphQL requires both the suffixed AND unsuffixed cookie for
154
+ * authenticated mutations. The browser only stores the suffixed variant,
155
+ * so server-side code must synthesize the unsuffixed one.
156
+ */
157
+ export function ensureUnsuffixedAuthCookie(cookieStr: string): string {
158
+ if (!cookieStr) return cookieStr;
159
+ const cookies = cookieStr.split(";").map((c) => c.trim());
160
+ let hasUnsuffixed = false;
161
+ let suffixedToken: string | null = null;
162
+ for (const c of cookies) {
163
+ const [name, ...rest] = c.split("=");
164
+ if (name === "VtexIdclientAutCookie") {
165
+ hasUnsuffixed = true;
166
+ } else if (name?.startsWith("VtexIdclientAutCookie_") && !suffixedToken) {
167
+ suffixedToken = rest.join("=");
168
+ }
169
+ }
170
+ if (!hasUnsuffixed && suffixedToken) {
171
+ return `VtexIdclientAutCookie=${suffixedToken}; ${cookieStr}`;
172
+ }
173
+ return cookieStr;
174
+ }
@@ -1,3 +1,5 @@
1
+ export { vtexAccountLoaders } from "./accountLoaders";
2
+ export type { PersonalDataOptions } from "./accountLoaders";
1
3
  export * from "./batch";
2
4
  export * from "./cookies";
3
5
  export * from "./enrichment";
@@ -44,6 +44,27 @@ export const withDefaultParams = ({
44
44
 
45
45
  export const isFilterParam = (keyFilter: string): boolean => keyFilter.startsWith("filter.");
46
46
 
47
+ /**
48
+ * Valid VTEX Intelligent Search sort values.
49
+ * Anything else (e.g. "orders:desc)" with a trailing paren from legacy URLs)
50
+ * causes IS API to return 400.
51
+ */
52
+ export const VALID_IS_SORTS = new Set([
53
+ "",
54
+ "orders:desc",
55
+ "price:asc",
56
+ "price:desc",
57
+ "name:asc",
58
+ "name:desc",
59
+ "release:desc",
60
+ "discount:desc",
61
+ ]);
62
+
63
+ /** Sanitize an IS sort parameter — returns empty string for invalid values. */
64
+ export function sanitizeISSort(sort: string): string {
65
+ return VALID_IS_SORTS.has(sort) ? sort : "";
66
+ }
67
+
47
68
  const segmentsFromTerm = (term: string) => term.split("/").filter(Boolean);
48
69
 
49
70
  const segmentsFromSearchParams = (url: string) => {
@@ -4,6 +4,12 @@
4
4
  * Proxies storefront requests for /checkout, /account, /api, /files, /arquivos
5
5
  * to the VTEX origin. Essential for checkout and My Account pages to work.
6
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
+ *
7
13
  * Designed to be used with TanStack Start API routes or Cloudflare Worker
8
14
  * fetch handlers.
9
15
  */
@@ -194,3 +200,244 @@ export async function proxyToVtex(request: Request, options?: VtexProxyOptions):
194
200
  headers: responseHeaders,
195
201
  });
196
202
  }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Production-grade checkout proxy factory
206
+ // ---------------------------------------------------------------------------
207
+
208
+ export interface VtexCheckoutProxyConfig {
209
+ /** VTEX account name (e.g. "casaevideonewio"). */
210
+ account: string;
211
+
212
+ /**
213
+ * Store's public checkout domain (e.g. "secure.casaevideo.com.br").
214
+ * Checkout UI, /files/, and /_v/private/graphql are routed here.
215
+ */
216
+ checkoutOrigin: string;
217
+
218
+ /**
219
+ * VTEX commerce-stable origin for API calls.
220
+ * @default `https://{account}.vtexcommercestable.com.br`
221
+ */
222
+ apiOrigin?: string;
223
+
224
+ /**
225
+ * myvtex origin — used for redirect rewriting.
226
+ * @default `https://{account}.myvtex.com`
227
+ */
228
+ myvtexOrigin?: string;
229
+
230
+ /**
231
+ * VTEX TLD — most accounts use `.com.br`, but some use `.com`.
232
+ * @default "com.br"
233
+ */
234
+ domain?: string;
235
+
236
+ /**
237
+ * Extra paths on which to force-expire cookies.
238
+ * Useful for logout: VTEX sends Max-Age=0 for auth cookies, but the
239
+ * checkout orderForm cookie sometimes survives. This appends explicit
240
+ * Set-Cookie: name=; Max-Age=0 entries.
241
+ */
242
+ expireCookiesOnPaths?: Array<{
243
+ pathPrefix: string;
244
+ cookies: string[];
245
+ }>;
246
+
247
+ /**
248
+ * Optional HTML transform for checkout pages.
249
+ * Receives the full HTML string and should return the modified version.
250
+ */
251
+ htmlTransform?: (html: string) => string;
252
+ }
253
+
254
+ const CF_INTERNAL_HEADERS = new Set([
255
+ "cf-connecting-ip",
256
+ "cf-ipcountry",
257
+ "cf-ray",
258
+ "cf-visitor",
259
+ "cf-ew-via",
260
+ "cdn-loop",
261
+ ]);
262
+
263
+ const CHECKOUT_SKIP_HEADERS = new Set([
264
+ ...HOP_BY_HOP_HEADERS,
265
+ "set-cookie",
266
+ ...CF_INTERNAL_HEADERS,
267
+ ]);
268
+
269
+ const toAscii = (v: string) => v.replace(/[^\x20-\x7E]/g, "");
270
+
271
+ function filterHeadersStrict(headers: Headers): Headers {
272
+ const filtered = new Headers();
273
+ headers.forEach((value, key) => {
274
+ if (CHECKOUT_SKIP_HEADERS.has(key.toLowerCase())) return;
275
+ try {
276
+ filtered.set(key, toAscii(value));
277
+ } catch {
278
+ // skip headers that still fail after sanitization
279
+ }
280
+ });
281
+ return filtered;
282
+ }
283
+
284
+ /**
285
+ * Rewrite Set-Cookie headers: only change the Domain attribute.
286
+ * Unlike `proxySetCookie`, this preserves ALL attributes (Max-Age,
287
+ * Expires, SameSite, etc.) which is critical for logout.
288
+ */
289
+ function rewriteSetCookieDomain(
290
+ from: Headers,
291
+ to: Headers,
292
+ toHostname: string,
293
+ ) {
294
+ const raw: string[] =
295
+ typeof from.getSetCookie === "function"
296
+ ? from.getSetCookie()
297
+ : (from.get("set-cookie") ?? "")
298
+ .split(/,(?=[^ ]+=)/)
299
+ .filter(Boolean);
300
+
301
+ for (const cookie of raw) {
302
+ const rewritten = cookie.replace(/Domain=[^;]*/i, `Domain=${toHostname}`);
303
+ to.append("Set-Cookie", rewritten);
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Creates a production-grade VTEX checkout proxy handler.
309
+ *
310
+ * Routes checkout UI pages to the store's public domain and API calls
311
+ * to vtexcommercestable. Properly rewrites Set-Cookie domains (preserving
312
+ * Max-Age/Expires), sanitizes non-ASCII headers, filters hop-by-hop and
313
+ * CF-internal headers, and rewrites Location redirects.
314
+ *
315
+ * Returns a function compatible with `createDecoWorkerEntry`'s `proxyHandler`.
316
+ *
317
+ * @example
318
+ * ```ts
319
+ * const vtexProxy = createVtexCheckoutProxy({
320
+ * account: "casaevideonewio",
321
+ * checkoutOrigin: "secure.casaevideo.com.br",
322
+ * expireCookiesOnPaths: [
323
+ * { pathPrefix: "/api/vtexid/pub/logout", cookies: ["checkout.vtex.com"] },
324
+ * ],
325
+ * htmlTransform: (html) =>
326
+ * html.replace("</head>", "<style>.body{min-height:100vh}</style></head>"),
327
+ * });
328
+ *
329
+ * createDecoWorkerEntry(serverEntry, {
330
+ * proxyHandler: async (request, url) => {
331
+ * if (url.pathname === "/login") return null;
332
+ * if (!shouldProxyToVtex(url.pathname)) return null;
333
+ * return vtexProxy(request, url);
334
+ * },
335
+ * });
336
+ * ```
337
+ */
338
+ export function createVtexCheckoutProxy(
339
+ config: VtexCheckoutProxyConfig,
340
+ ): (request: Request, url: URL) => Promise<Response> {
341
+ const domain = config.domain ?? "com.br";
342
+ const checkoutOrigin = config.checkoutOrigin.startsWith("https://")
343
+ ? config.checkoutOrigin
344
+ : `https://${config.checkoutOrigin}`;
345
+ const apiOrigin =
346
+ config.apiOrigin ??
347
+ `https://${config.account}.vtexcommercestable.${domain}`;
348
+ const myvtexOrigin =
349
+ config.myvtexOrigin ?? `https://${config.account}.myvtex.com`;
350
+
351
+ function getOrigin(pathname: string, method: string): string {
352
+ if (
353
+ pathname.startsWith("/checkout") ||
354
+ pathname.startsWith("/account") ||
355
+ pathname.startsWith("/_secure/account") ||
356
+ pathname.startsWith("/files/") ||
357
+ pathname.startsWith("/_v/private/graphql")
358
+ ) {
359
+ return checkoutOrigin;
360
+ }
361
+ if (method !== "GET" && method !== "HEAD" && pathname.startsWith("/_v/")) {
362
+ return checkoutOrigin;
363
+ }
364
+ return apiOrigin;
365
+ }
366
+
367
+ return async (request: Request, url: URL): Promise<Response> => {
368
+ const origin = getOrigin(url.pathname, request.method);
369
+ const originUrl = new URL(`${origin}${url.pathname}${url.search}`);
370
+ const fwd = filterHeadersStrict(new Headers(request.headers));
371
+
372
+ fwd.set("Host", originUrl.hostname);
373
+ fwd.set("X-Forwarded-Host", url.host);
374
+ fwd.set("X-Forwarded-Proto", "https");
375
+ fwd.set("origin", request.headers.get("origin") ?? url.origin);
376
+
377
+ const isCheckoutUI =
378
+ url.pathname.startsWith("/checkout") ||
379
+ url.pathname.startsWith("/account");
380
+ const isLogout = url.pathname.startsWith("/api/vtexid/pub/logout");
381
+
382
+ const init: RequestInit = {
383
+ method: request.method,
384
+ headers: fwd,
385
+ redirect: isCheckoutUI || isLogout ? "manual" : "follow",
386
+ };
387
+ if (request.method !== "GET" && request.method !== "HEAD") {
388
+ init.body = request.body;
389
+ // @ts-expect-error -- needed for streaming body in Workers
390
+ init.duplex = "half";
391
+ }
392
+
393
+ const originRes = await fetch(originUrl.toString(), init);
394
+ const resHeaders = filterHeadersStrict(new Headers(originRes.headers));
395
+ rewriteSetCookieDomain(originRes.headers, resHeaders, url.hostname);
396
+
397
+ // Force-expire cookies on configured paths
398
+ if (config.expireCookiesOnPaths) {
399
+ for (const rule of config.expireCookiesOnPaths) {
400
+ if (url.pathname.startsWith(rule.pathPrefix)) {
401
+ for (const name of rule.cookies) {
402
+ resHeaders.append(
403
+ "Set-Cookie",
404
+ `${name}=; Path=/; Max-Age=0; Domain=${url.hostname}`,
405
+ );
406
+ }
407
+ }
408
+ }
409
+ }
410
+
411
+ // Rewrite redirect Location headers from VTEX domains to storefront
412
+ if (originRes.status >= 300 && originRes.status < 400) {
413
+ const loc = originRes.headers.get("location");
414
+ if (loc) {
415
+ resHeaders.set(
416
+ "location",
417
+ loc
418
+ .replace(checkoutOrigin, url.origin)
419
+ .replace(apiOrigin, url.origin)
420
+ .replace(myvtexOrigin, url.origin),
421
+ );
422
+ }
423
+ }
424
+
425
+ // HTML transform for checkout pages
426
+ const ct = originRes.headers.get("content-type") ?? "";
427
+ if (config.htmlTransform && ct.includes("text/html")) {
428
+ const html = await originRes.text();
429
+ const patched = config.htmlTransform(html);
430
+ return new Response(patched, {
431
+ status: originRes.status,
432
+ statusText: originRes.statusText,
433
+ headers: resHeaders,
434
+ });
435
+ }
436
+
437
+ return new Response(originRes.body, {
438
+ status: originRes.status,
439
+ statusText: originRes.statusText,
440
+ headers: resHeaders,
441
+ });
442
+ };
443
+ }
@@ -97,4 +97,42 @@ export function buildAuthCookieHeader(authCookie: string, account: string): stri
97
97
  return `${VTEX_AUTH_COOKIE}=${authCookie}; ${VTEX_AUTH_COOKIE}_${account}=${authCookie}`;
98
98
  }
99
99
 
100
+ export interface CookiePayload {
101
+ sub?: string;
102
+ account?: string;
103
+ audience?: string;
104
+ sess?: string;
105
+ exp?: number;
106
+ userId?: string;
107
+ }
108
+
109
+ /**
110
+ * Parse VTEX auth cookies from request headers.
111
+ *
112
+ * Returns the serialized cookie string (for forwarding) and the decoded
113
+ * JWT payload. Compatible with the legacy deco-cx/apps parseCookie API.
114
+ */
115
+ export function parseCookie(
116
+ headers: Headers,
117
+ account: string,
118
+ ): { cookie: string; payload: CookiePayload | undefined } {
119
+ const cookieHeader = headers.get("cookie") ?? "";
120
+
121
+ const base = extractVtexAuthCookie(cookieHeader);
122
+ const suffixedRe = new RegExp(`(?:^|;\\s*)${VTEX_AUTH_COOKIE}_${account}=([^;]+)`);
123
+ const suffixedMatch = cookieHeader.match(suffixedRe);
124
+ const suffixed = suffixedMatch?.[1] ?? null;
125
+
126
+ const token = base ?? suffixed;
127
+ const payload = token
128
+ ? ((decodeJwtPayload(token) as CookiePayload | null) ?? undefined)
129
+ : undefined;
130
+
131
+ const parts: string[] = [];
132
+ if (base) parts.push(`${VTEX_AUTH_COOKIE}=${base}`);
133
+ if (suffixed) parts.push(`${VTEX_AUTH_COOKIE}_${account}=${suffixed}`);
134
+
135
+ return { cookie: parts.join("; "), payload };
136
+ }
137
+
100
138
  export { VTEX_AUTH_COOKIE };