From 830e4b93a4e8e377ad7c277b0f010247f3418f38 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 26 Feb 2026 16:14:49 -0300 Subject: [PATCH 1/8] chore: enable CDN caching for anonymous VTEX pages Remove server-side Set-Cookie for IS cookies (vtex_is_anonymous, vtex_is_session) and move persistence to client-side via document.cookie. Skip vtex_segment Set-Cookie when user is anonymous (deterministic segment). Clear dirty flag in middleware for anonymous users so deco runtime can set Cache-Control: public headers, enabling CDN caching. --- vtex/middleware.ts | 12 +++++++++- vtex/sections/Analytics/Vtex.tsx | 23 +++++++++++++++++++ vtex/utils/intelligentSearch.ts | 19 ---------------- vtex/utils/segment.ts | 38 +++++++++++++++++--------------- 4 files changed, 54 insertions(+), 38 deletions(-) diff --git a/vtex/middleware.ts b/vtex/middleware.ts index f4e33f34b..f5fcac942 100644 --- a/vtex/middleware.ts +++ b/vtex/middleware.ts @@ -4,7 +4,11 @@ import { getISCookiesFromBag, setISCookiesBag, } from "./utils/intelligentSearch.ts"; -import { getSegmentFromBag, setSegmentBag } from "./utils/segment.ts"; +import { + getSegmentFromBag, + isAnonymous, + setSegmentBag, +} from "./utils/segment.ts"; export const middleware = ( _props: unknown, @@ -26,5 +30,11 @@ export const middleware = ( } } + // For anonymous users the page content is deterministic — safe to cache. + if (isAnonymous(ctx)) { + ctx.dirty = false; + ctx.dirtyTraces = []; + } + return ctx.next!(); }; diff --git a/vtex/sections/Analytics/Vtex.tsx b/vtex/sections/Analytics/Vtex.tsx index ba91361bd..72bfa1cd8 100644 --- a/vtex/sections/Analytics/Vtex.tsx +++ b/vtex/sections/Analytics/Vtex.tsx @@ -14,7 +14,30 @@ interface ISCookies { // deno-lint-ignore no-explicit-any session: any; } +const ANONYMOUS_COOKIE = "vtex_is_anonymous"; +const SESSION_COOKIE = "vtex_is_session"; +const ONE_YEAR_SECS = 365 * 24 * 3600; +const THIRTY_MIN_SECS = 30 * 60; + +const persistISCookies = (cookies: ISCookies | null) => { + if (!cookies) return; + + const setCookieIfMissing = (name: string, value: string, maxAge: number) => { + document.cookie = + `${name}=${value};path=/;max-age=${maxAge};secure;SameSite=Lax`; + }; + + if (!document.cookie.includes(`${ANONYMOUS_COOKIE}=`)) { + setCookieIfMissing(ANONYMOUS_COOKIE, cookies.anonymous, ONE_YEAR_SECS); + } + + // Always re-set session cookie to simulate sliding expiration + setCookieIfMissing(SESSION_COOKIE, cookies.session, THIRTY_MIN_SECS); +}; + const snippet = (account: string, agent: string, cookies: ISCookies | null) => { + persistISCookies(cookies); + const url = new URL(globalThis.location.href); const isSearch = url.searchParams.get("q"); const apiUrl = `https://sp.vtex.com/event-api/v1/${account}/event`; diff --git a/vtex/utils/intelligentSearch.ts b/vtex/utils/intelligentSearch.ts index 778e5c1c4..482b33f7a 100644 --- a/vtex/utils/intelligentSearch.ts +++ b/vtex/utils/intelligentSearch.ts @@ -1,4 +1,3 @@ -import { setCookie } from "std/http/mod.ts"; import { AppContext } from "../mod.ts"; import { STALE } from "../../utils/fetch.ts"; import type { @@ -100,28 +99,10 @@ export const setISCookiesBag = ( if (!anonymous) { anonymous = crypto.randomUUID(); - - setCookie(ctx.response.headers, { - value: anonymous, - name: ANONYMOUS_COOKIE, - path: "/", - secure: true, - httpOnly: true, - maxAge: 365 * 24 * 3600, - }); } if (!session) { session = crypto.randomUUID(); - - setCookie(ctx.response.headers, { - value: session, - name: SESSION_COOKIE, - path: "/", - secure: true, - httpOnly: true, - maxAge: 30 * 60, - }); } ctx?.bag.set(IS_ANONYMOUS, anonymous); diff --git a/vtex/utils/segment.ts b/vtex/utils/segment.ts index 44bdf0591..f0c45be6d 100644 --- a/vtex/utils/segment.ts +++ b/vtex/utils/segment.ts @@ -223,24 +223,26 @@ export const setSegmentBag = ( const token = serialize(segment); setSegmentInBag(ctx, { payload: segment, token }); - // If the user came from a sales channel in the URL, we set the cookie - if (segmentFromRequest.channel) { - setCookie(ctx.response.headers, { - value: `sc=${segmentFromRequest.channel}`, - name: SALES_CHANNEL_COOKIE, - path: "/", - secure: true, - }); - } + // Skip Set-Cookie for anonymous users to allow CDN caching. + // Anonymous segment is deterministic (same for all users). + if (!isAnonymous(ctx)) { + if (segmentFromRequest.channel) { + setCookie(ctx.response.headers, { + value: `sc=${segmentFromRequest.channel}`, + name: SALES_CHANNEL_COOKIE, + path: "/", + secure: true, + }); + } - // Avoid setting cookie when segment from request matches the one generated - if (vtex_segment !== token) { - setCookie(ctx.response.headers, { - value: token, - name: SEGMENT_COOKIE_NAME, - path: "/", - secure: true, - httpOnly: true, - }); + if (vtex_segment !== token) { + setCookie(ctx.response.headers, { + value: token, + name: SEGMENT_COOKIE_NAME, + path: "/", + secure: true, + httpOnly: true, + }); + } } }; From 3f3d90b53ef8d6913958d60fde054a7aaec52888 Mon Sep 17 00:00:00 2001 From: igoramf Date: Fri, 27 Feb 2026 07:46:42 -0300 Subject: [PATCH 2/8] isCacheableSegment and dirty bag --- vtex/middleware.ts | 12 +++++++----- vtex/utils/segment.ts | 27 ++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/vtex/middleware.ts b/vtex/middleware.ts index f5fcac942..d1c02c9da 100644 --- a/vtex/middleware.ts +++ b/vtex/middleware.ts @@ -1,4 +1,5 @@ import { getCookies } from "std/http/cookie.ts"; +import { PAGE_DIRTY_KEY } from "@deco/deco/blocks"; import { AppMiddlewareContext } from "./mod.ts"; import { getISCookiesFromBag, @@ -6,7 +7,7 @@ import { } from "./utils/intelligentSearch.ts"; import { getSegmentFromBag, - isAnonymous, + isCacheableSegment, setSegmentBag, } from "./utils/segment.ts"; @@ -30,10 +31,11 @@ export const middleware = ( } } - // For anonymous users the page content is deterministic — safe to cache. - if (isAnonymous(ctx)) { - ctx.dirty = false; - ctx.dirtyTraces = []; + // Mark as dirty when the segment has fields that affect page content + // (campaigns, non-default sales channel, price tables, region). + // UTMs are excluded — they're marketing tracking and don't change content. + if (!isCacheableSegment(ctx)) { + ctx.bag.set(PAGE_DIRTY_KEY, true); } return ctx.next!(); diff --git a/vtex/utils/segment.ts b/vtex/utils/segment.ts index f0c45be6d..c1816faea 100644 --- a/vtex/utils/segment.ts +++ b/vtex/utils/segment.ts @@ -61,6 +61,25 @@ export const isAnonymous = ( !regionId; }; +/** + * Checks if the segment only contains fields that don't affect page content. + * UTMs are excluded because they're marketing tracking only — they don't + * change prices, products, or layout. This is used for CDN cache decisions. + */ +export const isCacheableSegment = ( + ctx: AppContext, +) => { + const payload = getSegmentFromBag(ctx)?.payload; + if (!payload) { + return true; + } + const { campaigns, channel, priceTables, regionId } = payload; + return !campaigns && + (!channel || isDefautSalesChannel(ctx, channel)) && + !priceTables && + !regionId; +}; + const setSegmentInBag = (ctx: AppContext, data: WrappedSegment) => ctx?.bag?.set(SEGMENT, data); @@ -223,9 +242,11 @@ export const setSegmentBag = ( const token = serialize(segment); setSegmentInBag(ctx, { payload: segment, token }); - // Skip Set-Cookie for anonymous users to allow CDN caching. - // Anonymous segment is deterministic (same for all users). - if (!isAnonymous(ctx)) { + // Skip Set-Cookie when the segment only differs by UTMs. + // UTMs don't affect page content, so the response can still be cached. + // Only set cookies when content-affecting fields differ (campaigns, + // non-default sales channel, price tables, region). + if (!isCacheableSegment(ctx)) { if (segmentFromRequest.channel) { setCookie(ctx.response.headers, { value: `sc=${segmentFromRequest.channel}`, From 09b721293f8e0e06ae3dbad14f2597fb8dfa09e3 Mon Sep 17 00:00:00 2001 From: igoramf Date: Fri, 27 Feb 2026 08:32:00 -0300 Subject: [PATCH 3/8] fix: include channelPrivacy in isCacheableSegment check Private channels (B2B) require login to view prices/products. Without this check, private-channel segments could be cached publicly, risking exposure of restricted pricing or content. --- vtex/utils/segment.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/vtex/utils/segment.ts b/vtex/utils/segment.ts index c1816faea..656e2f3b0 100644 --- a/vtex/utils/segment.ts +++ b/vtex/utils/segment.ts @@ -73,11 +73,12 @@ export const isCacheableSegment = ( if (!payload) { return true; } - const { campaigns, channel, priceTables, regionId } = payload; + const { campaigns, channel, priceTables, regionId, channelPrivacy } = payload; return !campaigns && (!channel || isDefautSalesChannel(ctx, channel)) && !priceTables && - !regionId; + !regionId && + channelPrivacy !== "private"; }; const setSegmentInBag = (ctx: AppContext, data: WrappedSegment) => From 041837ffc6acae40cf0c0b4b81ff5a4edd314f5f Mon Sep 17 00:00:00 2001 From: igoramf Date: Fri, 27 Feb 2026 09:44:34 -0300 Subject: [PATCH 4/8] fix: respect removeUTMFromCacheKey in isCacheableSegment UTMs can affect pricing in VTEX, so by default they make pages non-cacheable (delegates to isAnonymous). Only with the opt-in removeUTMFromCacheKey flag are UTMs ignored for caching decisions. --- vtex/utils/segment.ts | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/vtex/utils/segment.ts b/vtex/utils/segment.ts index 656e2f3b0..577b25ffe 100644 --- a/vtex/utils/segment.ts +++ b/vtex/utils/segment.ts @@ -62,23 +62,21 @@ export const isAnonymous = ( }; /** - * Checks if the segment only contains fields that don't affect page content. - * UTMs are excluded because they're marketing tracking only — they don't - * change prices, products, or layout. This is used for CDN cache decisions. + * Checks if the segment is cacheable for CDN purposes. + * By default, uses isAnonymous (UTMs affect cacheability because prices + * can vary by utm_source). With removeUTMFromCacheKey, UTMs are ignored + * (opt-in for stores that don't vary prices by UTM). */ -export const isCacheableSegment = ( - ctx: AppContext, -) => { - const payload = getSegmentFromBag(ctx)?.payload; - if (!payload) { - return true; +export const isCacheableSegment = (ctx: AppContext) => { + if (ctx.advancedConfigs?.removeUTMFromCacheKey) { + const payload = getSegmentFromBag(ctx)?.payload; + if (!payload) return true; + const { campaigns, channel, priceTables, regionId } = payload; + return !campaigns && + (!channel || isDefautSalesChannel(ctx, channel)) && + !priceTables && !regionId; } - const { campaigns, channel, priceTables, regionId, channelPrivacy } = payload; - return !campaigns && - (!channel || isDefautSalesChannel(ctx, channel)) && - !priceTables && - !regionId && - channelPrivacy !== "private"; + return isAnonymous(ctx); }; const setSegmentInBag = (ctx: AppContext, data: WrappedSegment) => From fd07e26f7674eaf559fbad1dbe83dd38861461bc Mon Sep 17 00:00:00 2001 From: igoramf Date: Fri, 27 Feb 2026 10:04:56 -0300 Subject: [PATCH 5/8] fix: reintroduce channelPrivacy check in isCacheableSegment Private channels may not always generate session cookies, so explicitly reject caching when channelPrivacy is "private" to prevent leaking restricted content through CDN. --- vtex/utils/segment.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/vtex/utils/segment.ts b/vtex/utils/segment.ts index 577b25ffe..bbd316fde 100644 --- a/vtex/utils/segment.ts +++ b/vtex/utils/segment.ts @@ -68,8 +68,10 @@ export const isAnonymous = ( * (opt-in for stores that don't vary prices by UTM). */ export const isCacheableSegment = (ctx: AppContext) => { + const payload = getSegmentFromBag(ctx)?.payload; + if (payload?.channelPrivacy === "private") return false; + if (ctx.advancedConfigs?.removeUTMFromCacheKey) { - const payload = getSegmentFromBag(ctx)?.payload; if (!payload) return true; const { campaigns, channel, priceTables, regionId } = payload; return !campaigns && From be29dbc46e3714217e6d87dd0f48c024df4eb641 Mon Sep 17 00:00:00 2001 From: igoramf Date: Fri, 27 Feb 2026 17:10:01 -0300 Subject: [PATCH 6/8] feat: VTEX CDN cache optimization - VTEX middleware opts in to page caching via PAGE_CACHE_ALLOWED_KEY - Check VtexIdclientAutCookie to detect logged-in users - Move IS cookie generation to client-side for cache-safe HTML - Mark deterministic matchers as cacheable (queryString, pathname, host, device, site, environment) --- vtex/middleware.ts | 36 ++++++++++++-------- vtex/sections/Analytics/Vtex.tsx | 58 +++++++++++++++++--------------- website/matchers/device.ts | 2 ++ website/matchers/environment.ts | 2 ++ website/matchers/host.ts | 2 ++ website/matchers/pathname.ts | 2 ++ website/matchers/queryString.ts | 2 ++ website/matchers/site.ts | 2 ++ 8 files changed, 64 insertions(+), 42 deletions(-) diff --git a/vtex/middleware.ts b/vtex/middleware.ts index d1c02c9da..cb8ed48f2 100644 --- a/vtex/middleware.ts +++ b/vtex/middleware.ts @@ -1,5 +1,5 @@ import { getCookies } from "std/http/cookie.ts"; -import { PAGE_DIRTY_KEY } from "@deco/deco/blocks"; +import { PAGE_CACHE_ALLOWED_KEY, PAGE_DIRTY_KEY } from "@deco/deco/blocks"; import { AppMiddlewareContext } from "./mod.ts"; import { getISCookiesFromBag, @@ -10,6 +10,7 @@ import { isCacheableSegment, setSegmentBag, } from "./utils/segment.ts"; +import { VTEX_ID_CLIENT_COOKIE } from "./utils/vtexId.ts"; export const middleware = ( _props: unknown, @@ -18,25 +19,32 @@ export const middleware = ( ) => { const segment = getSegmentFromBag(ctx); const isCookies = getISCookiesFromBag(ctx); + const cookies = getCookies(req.headers); - if (!isCookies || !segment) { - const cookies = getCookies(req.headers); - - if (!isCookies) { - setISCookiesBag(cookies, ctx); - } + if (!isCookies) { + setISCookiesBag(cookies, ctx); + } - if (!segment) { - setSegmentBag(cookies, req, ctx); - } + if (!segment) { + setSegmentBag(cookies, req, ctx); } - // Mark as dirty when the segment has fields that affect page content - // (campaigns, non-default sales channel, price tables, region). - // UTMs are excluded — they're marketing tracking and don't change content. - if (!isCacheableSegment(ctx)) { + const isLoggedIn = Boolean( + cookies[VTEX_ID_CLIENT_COOKIE] || + cookies[`${VTEX_ID_CLIENT_COOKIE}_${ctx.account}`] + ); + + const cacheable = isCacheableSegment(ctx) && !isLoggedIn; + + // PAGE_DIRTY_KEY: marks page dirty for section-level caching and other consumers + if (!cacheable) { ctx.bag.set(PAGE_DIRTY_KEY, true); } + // PAGE_CACHE_ALLOWED_KEY: opts in to CDN page caching (VTEX-only) + if (cacheable) { + ctx.bag.set(PAGE_CACHE_ALLOWED_KEY, true); + } + return ctx.next!(); }; diff --git a/vtex/sections/Analytics/Vtex.tsx b/vtex/sections/Analytics/Vtex.tsx index 72bfa1cd8..8611909fc 100644 --- a/vtex/sections/Analytics/Vtex.tsx +++ b/vtex/sections/Analytics/Vtex.tsx @@ -4,43 +4,52 @@ import { SelectItemEvent, } from "../../../commerce/types.ts"; import { AppContext } from "../../mod.ts"; -import { getISCookiesFromBag } from "../../utils/intelligentSearch.ts"; import { SPEvent } from "../../utils/types.ts"; import { type SectionProps } from "@deco/deco"; import { useScriptAsDataURI } from "@deco/deco/hooks"; -interface ISCookies { - // deno-lint-ignore no-explicit-any - anonymous: any; - // deno-lint-ignore no-explicit-any - session: any; -} + const ANONYMOUS_COOKIE = "vtex_is_anonymous"; const SESSION_COOKIE = "vtex_is_session"; const ONE_YEAR_SECS = 365 * 24 * 3600; const THIRTY_MIN_SECS = 30 * 60; -const persistISCookies = (cookies: ISCookies | null) => { - if (!cookies) return; +const getCookie = (name: string): string | null => { + const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); + return match ? match[1] : null; +}; - const setCookieIfMissing = (name: string, value: string, maxAge: number) => { - document.cookie = - `${name}=${value};path=/;max-age=${maxAge};secure;SameSite=Lax`; - }; +const setCookie = (name: string, value: string, maxAge: number) => { + document.cookie = + `${name}=${value};path=/;max-age=${maxAge};secure;SameSite=Lax`; +}; - if (!document.cookie.includes(`${ANONYMOUS_COOKIE}=`)) { - setCookieIfMissing(ANONYMOUS_COOKIE, cookies.anonymous, ONE_YEAR_SECS); +const getOrCreateISCookies = () => { + let anonymous = getCookie(ANONYMOUS_COOKIE); + if (!anonymous) { + anonymous = crypto.randomUUID(); + setCookie(ANONYMOUS_COOKIE, anonymous, ONE_YEAR_SECS); } + let session = getCookie(SESSION_COOKIE); + if (!session) { + session = crypto.randomUUID(); + } // Always re-set session cookie to simulate sliding expiration - setCookieIfMissing(SESSION_COOKIE, cookies.session, THIRTY_MIN_SECS); + setCookie(SESSION_COOKIE, session, THIRTY_MIN_SECS); + + return { anonymous, session }; }; -const snippet = (account: string, agent: string, cookies: ISCookies | null) => { - persistISCookies(cookies); +const snippet = (account: string) => { + const cookies = getOrCreateISCookies(); const url = new URL(globalThis.location.href); const isSearch = url.searchParams.get("q"); const apiUrl = `https://sp.vtex.com/event-api/v1/${account}/event`; + const baseProps = { + agent: navigator.userAgent, + ...cookies, + }; const eventFetch = (props: SPEvent) => { fetch(apiUrl, { method: "POST", @@ -53,10 +62,6 @@ const snippet = (account: string, agent: string, cookies: ISCookies | null) => { }, }); }; - const baseProps = { - agent, - ...cookies, - }; // deno-lint-ignore no-explicit-any function isSelectItemEvent(event: any): event is SelectItemEvent { return event.name === "select_item"; @@ -115,21 +120,18 @@ const snippet = (account: string, agent: string, cookies: ISCookies | null) => { }); }; export default function VtexAnalytics( - { account, agent, cookies }: SectionProps, + { account }: SectionProps, ) { return (