@decocms/apps-vtex 8.0.0 → 8.1.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +5 -5
  2. package/src/__conformance__/upstream.test.ts +158 -0
  3. package/src/__tests__/client-set-cookie-forward.test.ts +4 -4
  4. package/src/client.ts +61 -4
  5. package/src/index.ts +17 -2
  6. package/src/loaders/__tests__/legacyProductList.test.ts +157 -0
  7. package/src/loaders/autocomplete.ts +2 -4
  8. package/src/loaders/intelligentSearch/productDetailsPage.ts +2 -4
  9. package/src/loaders/intelligentSearch/productList.ts +21 -5
  10. package/src/loaders/intelligentSearch/productListingPage.ts +2 -3
  11. package/src/loaders/intelligentSearch/suggestions.ts +2 -4
  12. package/src/loaders/legacy/relatedProductsLoader.ts +2 -4
  13. package/src/loaders/legacy.ts +77 -2
  14. package/src/loaders/productListFull.ts +2 -4
  15. package/src/loaders/workflow/products.ts +2 -4
  16. package/src/middleware.cacheHeaders.test.ts +44 -0
  17. package/src/mod.ts +18 -2
  18. package/src/utils/__tests__/cookieSanitizer.test.ts +1 -1
  19. package/src/utils/__tests__/storefrontBaseUrl.test.ts +61 -0
  20. package/src/utils/__tests__/transform.test.ts +349 -7
  21. package/src/utils/buildOfferShelf.test.ts +59 -0
  22. package/src/utils/enrichment.ts +3 -7
  23. package/src/utils/fetch.ts +5 -1
  24. package/src/utils/fetchCache.ts +24 -168
  25. package/src/utils/instrumentedFetch.ts +2 -1
  26. package/src/utils/minicart.ts +1 -1
  27. package/src/utils/operationRouter.ts +3 -9
  28. package/src/utils/proxy.ts +64 -4
  29. package/src/utils/similars.ts +2 -4
  30. package/src/utils/sitemap.ts +3 -2
  31. package/src/utils/transform.ts +222 -53
  32. package/src/utils/vtexId.ts +15 -2
  33. package/src/vtexClient.test.ts +204 -0
  34. package/src/vtexClient.ts +433 -0
@@ -33,6 +33,7 @@ import {
33
33
  toProduct,
34
34
  toProductPage,
35
35
  } from "../utils/transform";
36
+ import type { ProductOptions } from "../utils/transform";
36
37
  import type { LegacyFacet, LegacyItem, LegacyProduct, LegacySort, PageType } from "../utils/types";
37
38
 
38
39
  // ---------------------------------------------------------------------------
@@ -158,6 +159,23 @@ export interface LegacyProductListOptions {
158
159
  query: LegacyProductListQuery;
159
160
  baseUrl: string;
160
161
  priceCurrency?: string;
162
+ /**
163
+ * Payload-shaping options, forwarded verbatim to `toProduct`. Same set the
164
+ * PLP loader already accepts, and for the same reason: a shelf renders a
165
+ * CARD, but this loader built the full product for every entry. Measured on
166
+ * a real home shelf (`count: 28`), 1095 KB — 39.1 KB per product, of which
167
+ * `isVariantOf` 19.1 KB and the 48-rung `offers.priceSpecification` 12.8 KB.
168
+ *
169
+ * All default to undefined, which keeps the previous output byte for byte.
170
+ * See the matching fields on {@link ProductOptions}.
171
+ */
172
+ leanVariants?: ProductOptions["leanVariants"];
173
+ displayedVariantId?: ProductOptions["displayedVariantId"];
174
+ variantPropertyNames?: ProductOptions["variantPropertyNames"];
175
+ variantIncludeImage?: ProductOptions["variantIncludeImage"];
176
+ variantIncludeInventory?: ProductOptions["variantIncludeInventory"];
177
+ maxImages?: ProductOptions["maxImages"];
178
+ priceSpecifications?: ProductOptions["priceSpecifications"];
161
179
  }
162
180
 
163
181
  function isCollectionQuery(
@@ -233,7 +251,18 @@ function queryToSearchParams(
233
251
  * Ported from: vtex/loaders/legacy/productList.ts
234
252
  */
235
253
  export async function legacyProductList(opts: LegacyProductListOptions): Promise<Product[] | null> {
236
- const { query, baseUrl, priceCurrency = "BRL" } = opts;
254
+ const {
255
+ query,
256
+ baseUrl,
257
+ priceCurrency = "BRL",
258
+ leanVariants,
259
+ displayedVariantId,
260
+ variantPropertyNames,
261
+ variantIncludeImage,
262
+ variantIncludeInventory,
263
+ maxImages,
264
+ priceSpecifications,
265
+ } = opts;
237
266
  const searchArgs = queryToSearchParams(query);
238
267
  const qs = buildSearchParams(searchArgs);
239
268
 
@@ -254,7 +283,17 @@ export async function legacyProductList(opts: LegacyProductListOptions): Promise
254
283
  };
255
284
 
256
285
  let products = vtexProducts.map((p) =>
257
- toProduct(p, preferredSKU(p.items), 0, { baseUrl, priceCurrency }),
286
+ toProduct(p, preferredSKU(p.items), 0, {
287
+ baseUrl,
288
+ priceCurrency,
289
+ leanVariants,
290
+ displayedVariantId,
291
+ variantPropertyNames,
292
+ variantIncludeImage,
293
+ variantIncludeInventory,
294
+ maxImages,
295
+ priceSpecifications,
296
+ }),
258
297
  );
259
298
 
260
299
  if (isSkuIdsQuery(query)) {
@@ -342,6 +381,28 @@ export interface LegacyPLPOptions {
342
381
  /** Ignore case when checking if a facet is selected */
343
382
  ignoreCaseSelected?: boolean;
344
383
  includeOriginalAttributes?: string[];
384
+ /**
385
+ * Build `isVariantOf.hasVariant[]` with the lean variant transform instead of
386
+ * a full nested `toProduct` per SKU. Off by default (unchanged behaviour).
387
+ *
388
+ * `productDetailsPage` (intelligent search) already exposes this, but the
389
+ * legacy PLP never plumbed it — so a listing always paid the full variant
390
+ * tree. Measured on a real store (36 products, 26 variants each): ~9.6 MB
391
+ * per page, 96% of each product in `isVariantOf.hasVariant`, because every
392
+ * variant carries the whole 48-entry payment ladder plus a copy of the
393
+ * parent `description` — none of which a listing card reads.
394
+ */
395
+ leanVariants?: boolean;
396
+ /** Keep the ladder on the ONE variant the card renders — see ProductOptions.displayedVariantId. */
397
+ displayedVariantId?: ProductOptions["displayedVariantId"];
398
+ /** Forwarded to the lean variant transform. */
399
+ variantPropertyNames?: Set<string>;
400
+ variantIncludeImage?: boolean;
401
+ variantIncludeInventory?: boolean;
402
+ /** Cap image[] to the first N entries — see ProductOptions.maxImages. */
403
+ maxImages?: number;
404
+ /** Rewrite priceSpecification on emitted offers — see ProductOptions.priceSpecifications. */
405
+ priceSpecifications?: ProductOptions["priceSpecifications"];
345
406
  }
346
407
 
347
408
  /**
@@ -362,6 +423,13 @@ export async function legacyProductListingPage(
362
423
  ignoreCaseSelected,
363
424
  useCollectionName,
364
425
  includeOriginalAttributes,
426
+ leanVariants,
427
+ displayedVariantId,
428
+ variantPropertyNames,
429
+ variantIncludeImage,
430
+ variantIncludeInventory,
431
+ maxImages,
432
+ priceSpecifications,
365
433
  } = opts;
366
434
 
367
435
  const currentPageOffset = opts.pageOffset ?? 1;
@@ -447,6 +515,13 @@ export async function legacyProductListingPage(
447
515
  baseUrl,
448
516
  priceCurrency,
449
517
  includeOriginalAttributes,
518
+ leanVariants,
519
+ displayedVariantId,
520
+ variantPropertyNames,
521
+ variantIncludeImage,
522
+ variantIncludeInventory,
523
+ maxImages,
524
+ priceSpecifications,
450
525
  }),
451
526
  );
452
527
 
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { Product } from "@decocms/apps-commerce/types";
10
- import { getVtexConfig, intelligentSearch, toFacetPath } from "../client";
10
+ import { getVtexConfig, intelligentSearch, storefrontBaseUrl, toFacetPath } from "../client";
11
11
  import { pickSku, sortProducts, toProduct } from "../utils/transform";
12
12
  import type { Product as ProductVTEX } from "../utils/types";
13
13
 
@@ -135,9 +135,7 @@ export default async function vtexProductList(props: ProductListProps): Promise<
135
135
  const data = await intelligentSearch<{ products: ProductVTEX[] }>(endpoint, params);
136
136
 
137
137
  const vtexProducts = data.products ?? [];
138
- const baseUrl = config.publicUrl
139
- ? `https://${config.publicUrl}`
140
- : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
138
+ const baseUrl = storefrontBaseUrl(config);
141
139
 
142
140
  let products = vtexProducts.map((p) => {
143
141
  const fetchedSkus = ids ? new Set(ids) : null;
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { Product } from "@decocms/apps-commerce/types";
7
- import { getVtexConfig, intelligentSearch, toFacetPath } from "../../client";
7
+ import { getVtexConfig, intelligentSearch, storefrontBaseUrl, toFacetPath } from "../../client";
8
8
  import { pickSku, toProduct } from "../../utils/transform";
9
9
  import type { Product as ProductVTEX } from "../../utils/types";
10
10
 
@@ -49,9 +49,7 @@ export default async function vtexWorkflowProducts(
49
49
  const data = await intelligentSearch<{ products: ProductVTEX[] }>(endpoint, params);
50
50
 
51
51
  const products = data.products ?? [];
52
- const baseUrl = config.publicUrl
53
- ? `https://${config.publicUrl}`
54
- : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
52
+ const baseUrl = storefrontBaseUrl(config);
55
53
 
56
54
  return products.map((p) => {
57
55
  const sku = pickSku(p);
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { vtexMiddleware } from "./mod";
3
+
4
+ /**
5
+ * The VTEX app middleware wraps the framework's entire edge-cache layer, so it
6
+ * is the last writer of Cache-Control on every response — including cache HITs.
7
+ * These tests pin the two things that made that dangerous.
8
+ */
9
+
10
+ // A response as the framework's cache layer would hand it over: public headers
11
+ // resolved from the page's cache profile, plus the CDN header.
12
+ function cachedResponse(): Response {
13
+ return new Response("page", {
14
+ headers: {
15
+ "Cache-Control": "public, max-age=120, s-maxage=900, stale-while-revalidate=1800",
16
+ "CDN-Cache-Control": "public, max-age=900",
17
+ },
18
+ });
19
+ }
20
+
21
+ const next = async () => cachedResponse();
22
+
23
+ describe("vtexMiddleware cache headers", () => {
24
+ it("leaves the cache layer's headers alone for an anonymous request", async () => {
25
+ const res = await vtexMiddleware(new Request("https://store.com/"), next);
26
+ // Used to be downgraded to vtexCacheControl's generic `s-maxage=60`,
27
+ // throwing away the profile the cache layer had resolved.
28
+ expect(res.headers.get("Cache-Control")).toContain("s-maxage=900");
29
+ });
30
+
31
+ it("forces private headers and clears the CDN header when logged in", async () => {
32
+ const req = new Request("https://store.com/", {
33
+ // extractVtexContext treats any VtexIdclientAutCookie* as authenticated.
34
+ headers: { cookie: "VtexIdclientAutCookie_store=abc123" },
35
+ });
36
+ const res = await vtexMiddleware(req, next);
37
+
38
+ expect(res.headers.get("Cache-Control")).toContain("no-store");
39
+ // Cloudflare gives CDN-Cache-Control precedence, so leaving the public
40
+ // value behind would cache a personalized page at the CDN regardless of
41
+ // the private Cache-Control next to it.
42
+ expect(res.headers.get("CDN-Cache-Control")).toBeNull();
43
+ });
44
+ });
package/src/mod.ts CHANGED
@@ -96,10 +96,26 @@ export interface VtexState {
96
96
  // Middleware
97
97
  // -------------------------------------------------------------------------
98
98
 
99
- const vtexMiddleware: AppMiddleware = async (request, next) => {
99
+ export const vtexMiddleware: AppMiddleware = async (request, next) => {
100
100
  const ctx = extractVtexContext(request);
101
101
  const response = await next();
102
- response.headers.set("Cache-Control", vtexCacheControl(ctx));
102
+
103
+ // This middleware wraps the framework's whole edge-cache layer, so it is the
104
+ // LAST writer of Cache-Control — including on a cache HIT. It used to
105
+ // overwrite unconditionally, which downgraded a home page the cache layer had
106
+ // resolved as `s-maxage=900` to vtexCacheControl's generic `s-maxage=60`.
107
+ //
108
+ // Now it only speaks up for the case it actually knows better about: a
109
+ // personalized request, which must not be cached anywhere. And when it does,
110
+ // it clears CDN-Cache-Control too — otherwise a response can go out as
111
+ // `Cache-Control: private, no-store` alongside `CDN-Cache-Control: public,
112
+ // max-age=300`, and Cloudflare gives the CDN header precedence. Same pairing
113
+ // the worker's own bypasses use, and utils/proxy.ts's hardenProxyCacheHeaders.
114
+ if (ctx.isLoggedIn || ctx.hasCustomPricing) {
115
+ response.headers.set("Cache-Control", vtexCacheControl(ctx));
116
+ response.headers.delete("CDN-Cache-Control");
117
+ }
118
+
103
119
  propagateISCookies(ctx, response);
104
120
  return response;
105
121
  };
@@ -19,7 +19,7 @@ describe("sanitizeOutboundCookieHeader", () => {
19
19
  expect(cookies).toBe("checkout.vtex.com=__ofid=abc; vtex_segment=eyJ0b2tlbiI6IjEyMyJ9");
20
20
  });
21
21
 
22
- it("drops a cookie whose value contains non-ASCII bytes — the casaevideo repro", () => {
22
+ it("drops a cookie whose value contains non-ASCII bytes — a production VTEX storefront repro", () => {
23
23
  // `á` is the UTF-8 encoding of `á` interpreted as Latin-1 — bytes 0xC3 0xA1.
24
24
  // VTEX's janus gateway returns 503 deterministically when this reaches it.
25
25
  const raw = "checkout.vtex.com=__ofid=abc; category_click=Eletroportáteis; vtex_segment=ok";
@@ -0,0 +1,61 @@
1
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+
4
+ import { configureVtex, storefrontBaseUrl } from "../../client";
5
+
6
+ const ACCOUNT = { account: "examplestore" } as const;
7
+
8
+ describe("storefrontBaseUrl", () => {
9
+ beforeEach(() => {
10
+ configureVtex({ ...ACCOUNT });
11
+ });
12
+
13
+ it("uses the origin of the request being served", () => {
14
+ configureVtex({ ...ACCOUNT, publicUrl: "https://secure.example.com/" });
15
+
16
+ const origin = RequestContext.run(new Request("https://www.example.com/camisas?page=2"), () =>
17
+ storefrontBaseUrl(),
18
+ );
19
+
20
+ expect(origin).toBe("https://www.example.com");
21
+ });
22
+
23
+ it("never returns the double-scheme origin a full publicUrl used to produce", () => {
24
+ // `https://${publicUrl}` on "https://secure.example.com/" parsed with
25
+ // host "https", so every product URL came out as https://https/<slug>/p.
26
+ configureVtex({ ...ACCOUNT, publicUrl: "https://secure.example.com/" });
27
+
28
+ const origin = storefrontBaseUrl();
29
+
30
+ expect(origin).toBe("https://secure.example.com");
31
+ expect(new URL("/camisa-paris/p", origin).href).toBe("https://secure.example.com/camisa-paris/p");
32
+ });
33
+
34
+ it("accepts a publicUrl stored without a scheme", () => {
35
+ configureVtex({ ...ACCOUNT, publicUrl: "secure.example.com" });
36
+
37
+ expect(storefrontBaseUrl()).toBe("https://secure.example.com");
38
+ });
39
+
40
+ it("falls back to the account host when publicUrl is absent", () => {
41
+ expect(storefrontBaseUrl()).toBe("https://examplestore.vtexcommercestable.com.br");
42
+ });
43
+
44
+ it("honours a configured domain in the account-host fallback", () => {
45
+ configureVtex({ ...ACCOUNT, domain: "com" });
46
+
47
+ expect(storefrontBaseUrl()).toBe("https://examplestore.vtexcommercestable.com");
48
+ });
49
+
50
+ it("falls back to the account host rather than publish an unparseable publicUrl", () => {
51
+ configureVtex({ ...ACCOUNT, publicUrl: "://" });
52
+
53
+ expect(storefrontBaseUrl()).toBe("https://examplestore.vtexcommercestable.com.br");
54
+ });
55
+
56
+ it("returns an origin with no trailing slash", () => {
57
+ configureVtex({ ...ACCOUNT, publicUrl: "https://secure.example.com/" });
58
+
59
+ expect(storefrontBaseUrl()).not.toMatch(/\/$/);
60
+ });
61
+ });