@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.
- package/package.json +5 -5
- package/src/__conformance__/upstream.test.ts +158 -0
- package/src/__tests__/client-set-cookie-forward.test.ts +4 -4
- package/src/client.ts +61 -4
- package/src/index.ts +17 -2
- package/src/loaders/__tests__/legacyProductList.test.ts +157 -0
- package/src/loaders/autocomplete.ts +2 -4
- package/src/loaders/intelligentSearch/productDetailsPage.ts +2 -4
- package/src/loaders/intelligentSearch/productList.ts +21 -5
- package/src/loaders/intelligentSearch/productListingPage.ts +2 -3
- package/src/loaders/intelligentSearch/suggestions.ts +2 -4
- package/src/loaders/legacy/relatedProductsLoader.ts +2 -4
- package/src/loaders/legacy.ts +77 -2
- package/src/loaders/productListFull.ts +2 -4
- package/src/loaders/workflow/products.ts +2 -4
- package/src/middleware.cacheHeaders.test.ts +44 -0
- package/src/mod.ts +18 -2
- package/src/utils/__tests__/cookieSanitizer.test.ts +1 -1
- package/src/utils/__tests__/storefrontBaseUrl.test.ts +61 -0
- package/src/utils/__tests__/transform.test.ts +349 -7
- package/src/utils/buildOfferShelf.test.ts +59 -0
- package/src/utils/enrichment.ts +3 -7
- package/src/utils/fetch.ts +5 -1
- package/src/utils/fetchCache.ts +24 -168
- package/src/utils/instrumentedFetch.ts +2 -1
- package/src/utils/minicart.ts +1 -1
- package/src/utils/operationRouter.ts +3 -9
- package/src/utils/proxy.ts +64 -4
- package/src/utils/similars.ts +2 -4
- package/src/utils/sitemap.ts +3 -2
- package/src/utils/transform.ts +222 -53
- package/src/utils/vtexId.ts +15 -2
- package/src/vtexClient.test.ts +204 -0
- package/src/vtexClient.ts +433 -0
package/src/loaders/legacy.ts
CHANGED
|
@@ -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 {
|
|
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, {
|
|
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
|
|
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
|
|
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
|
-
|
|
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 —
|
|
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
|
+
});
|