From 5c2e46fbad5b1fefb0a737a010114dfbaeb8c885 Mon Sep 17 00:00:00 2001 From: Fernando Frizzatti Date: Thu, 14 May 2026 16:08:24 -0300 Subject: [PATCH 1/8] Tweak CONTRIBUTING wording E2E test commit for the new next-channel prerelease pipeline. Co-Authored-By: Claude Opus 4.7 (1M context) --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 90468991e..16316f97a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Release channels -This repository ships on two channels: +This repository publishes on two channels: | Channel | Branch | Example version | JSR resolution | |---------|--------|-----------------|----------------| From 83d84edf7e71fce094cf14422b605b478e6673df Mon Sep 17 00:00:00 2001 From: decobot Date: Thu, 14 May 2026 19:09:25 +0000 Subject: [PATCH 2/8] Update version to 1.197.1-next.1 --- deno.json | 2 +- dev/deno.json | 2 +- scripts/deno.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deno.json b/deno.json index f4d060533..6308f98bf 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/deco", - "version": "1.197.0", + "version": "1.197.1-next.1", "lock": false, "nodeModulesDir": "auto", "exports": { diff --git a/dev/deno.json b/dev/deno.json index 15843f936..64ecc0f91 100644 --- a/dev/deno.json +++ b/dev/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/dev", - "version": "1.197.0", + "version": "1.197.1-next.1", "exports": { "./tailwind": "./tailwind.ts" }, diff --git a/scripts/deno.json b/scripts/deno.json index a0d56344c..af4d5ec1d 100644 --- a/scripts/deno.json +++ b/scripts/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/scripts", - "version": "1.197.0", + "version": "1.197.1-next.1", "exports": { "./release": "./release.ts", "./update": "./update.run.ts", From 7c958323f7611ec39ef347a9a7b6773e1e2a2b1e Mon Sep 17 00:00:00 2001 From: Fernando Frizzatti Date: Thu, 14 May 2026 14:48:11 -0300 Subject: [PATCH 3/8] perf(runtime): keep deco_segment stable across requests; cache /live/_meta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to recover edge cache hit rates that have been quietly degrading HTML cacheability across deco-cx storefronts. 1. **Segment cookie thrash (runtime/middleware.ts).** The `deco_segment` cookie tracked two sets — `active` (currently-active segment flags) and `inactiveDrawn` (a write-only bookkeeping set of flags previously evaluated to `false`). `inactiveDrawn` was never read by any matcher logic across this entire repo. Its only effect was to grow as users navigated across pages drawing different matchers, churning the cookie value, which tripped the `Set-Cookie => Cache-Control: no-store` kill-switch a few lines below on essentially every request. Live measurement against farmrio.com.br (deco@1.197.0): PDP HTML cache hit rate ~1%, other HTML ~28% — origin was serving ~55 GiB/day of HTML that should have been edge-cached. The fix: persist only `active` in the cookie, and only re-write the cookie when `active` actually changes. Cookies in the wild that still carry the old `{active, inactiveDrawn}` shape are read for `active` only; the `inactiveDrawn` field on disk is harmless and gets dropped on the next real cohort change. 2. **/live/_meta cache-control (runtime/routes/_meta.ts).** Was `must-revalidate`, which Cloudflare honors as effectively no-store. The admin UI polls this endpoint every ~30s; serving it as `public, max-age=60, s-maxage=60, stale-while-revalidate=300, must-revalidate` collapses upstream load (~370 MiB/day at farmrio alone, more across the fleet) without affecting admin freshness — ETag still drives 304s. Validation: - Replayed homepage GET with a returning-user `deco_segment` cookie: HIT with no Set-Cookie response header (previously: BYPASS, new cookie). - Verified PDP first-visit still emits the cookie (one-time per cohort change), while subsequent navigations no longer re-emit it. - /live/_meta now reports `cf-cache-status: HIT` after first warmup; ETag unchanged. Risk: low. `inactiveDrawn` is grep-clean across this repo, deco-cx/apps, and the farmrio storefront. Worst case for an external consumer reading it: they see an empty/missing field and treat the user as if they hadn't been drawn into any inactive segment, which is the same behavior as a first-visit user — i.e. the existing first-visit path. Companion PRs: - deco-cx/apps: cacheControlOverride for proxy + cacheable=true on website/matchers/userAgent.ts - deco-sites/farmrio: PRIVATE_COOKIES allowlist in routes/_middleware.ts Co-authored-by: Cursor --- runtime/middleware.ts | 35 ++++++++++++++++------------------- runtime/routes/_meta.ts | 3 ++- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/runtime/middleware.ts b/runtime/middleware.ts index aa9528f12..177756e16 100644 --- a/runtime/middleware.ts +++ b/runtime/middleware.ts @@ -390,27 +390,24 @@ export const middlewareFor = ( ); const segment = tryOrDefault(() => JSON.parse(cookieSegment), {}); - const active = new Set(segment.active || []); - const inactiveDrawn = new Set(segment.inactiveDrawn || []); + // Track only the `active` set. The previous `inactiveDrawn` set was + // write-only state — never read by any matcher logic — and its growth + // across pages was the main reason `deco_segment` got rewritten on + // almost every request, tripping the Set-Cookie kill-switch below and + // making HTML uncacheable. Removing it lets cohort assignment stick. + const previousActive = [...new Set(segment.active || [])].sort(); + const active = new Set(previousActive); for (const flag of ctx.var.flags) { - if (flag.isSegment) { - if (flag.value) { - active.add(flag.name); - inactiveDrawn.delete(flag.name); - } else { - active.delete(flag.name); - inactiveDrawn.add(flag.name); - } - } + if (!flag.isSegment) continue; + if (flag.value) active.add(flag.name); + else active.delete(flag.name); } - const newSegment = { - active: [...active].sort(), - inactiveDrawn: [...inactiveDrawn].sort(), - }; - const value = JSON.stringify(newSegment); - const hasFlags = active.size > 0 || inactiveDrawn.size > 0; - - if (hasFlags && cookieSegment !== value) { + const newActive = [...active].sort(); + const activeChanged = + JSON.stringify(previousActive) !== JSON.stringify(newActive); + + if (active.size > 0 && activeChanged) { + const value = JSON.stringify({ active: newActive }); const date = new Date(); date.setTime(date.getTime() + 30 * 24 * 60 * 60 * 1000); // 1 month setCookie( diff --git a/runtime/routes/_meta.ts b/runtime/routes/_meta.ts index d4caaee80..c83be5673 100644 --- a/runtime/routes/_meta.ts +++ b/runtime/routes/_meta.ts @@ -21,7 +21,8 @@ export const handler = createHandler(async (ctx) => { return new Response(JSON.stringify(value), { headers: { "Content-Type": "application/json", - "cache-control": "must-revalidate", + "cache-control": + "public, max-age=60, s-maxage=60, stale-while-revalidate=300, must-revalidate", etag, ...allowCorsFor(ctx.req.raw), }, From c40aff87c704590c078052e14ac479348a95155c Mon Sep 17 00:00:00 2001 From: decobot Date: Thu, 14 May 2026 21:16:05 +0000 Subject: [PATCH 4/8] Update version to 1.197.1-next.2 --- deno.json | 2 +- dev/deno.json | 2 +- scripts/deno.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deno.json b/deno.json index 6308f98bf..1b1da38cf 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/deco", - "version": "1.197.1-next.1", + "version": "1.197.1-next.2", "lock": false, "nodeModulesDir": "auto", "exports": { diff --git a/dev/deno.json b/dev/deno.json index 64ecc0f91..8fe98eff4 100644 --- a/dev/deno.json +++ b/dev/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/dev", - "version": "1.197.1-next.1", + "version": "1.197.1-next.2", "exports": { "./tailwind": "./tailwind.ts" }, diff --git a/scripts/deno.json b/scripts/deno.json index af4d5ec1d..960670403 100644 --- a/scripts/deno.json +++ b/scripts/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/scripts", - "version": "1.197.1-next.1", + "version": "1.197.1-next.2", "exports": { "./release": "./release.ts", "./update": "./update.run.ts", From a6344688b9d732795dc3474da4234de5c797e2d4 Mon Sep 17 00:00:00 2001 From: Fernando Frizzatti Date: Tue, 26 May 2026 23:29:05 -0300 Subject: [PATCH 5/8] feat(matcher): make matcher-wrapped pages cacheable at CDN edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop `Vary: cookie` from sticky-session matchers and stop treating framework-managed Set-Cookies (deco_matcher_*, deco_segment) as a reason to disable caching. Wire ctx.var.vary.shouldCache into the full-page kill-switch so loaders that declare cache:"no-store" (personalizing loaders) still veto caching. Adds a Deco-Cache-Vary-Cookies hint header so CDN operators can discover which cookies belong in the custom cache key. Extracts applyPageCacheDecision() from the inlined kill-switch as an exported pure function; the request middleware is the only production caller. Adds tests for matcher.ts and middleware.ts — the first tests in blocks/ and for runtime/middleware.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- blocks/matcher.test.ts | 144 +++++++++++++++++++++++++++++++++++++ blocks/matcher.ts | 1 - runtime/middleware.test.ts | 128 +++++++++++++++++++++++++++++++++ runtime/middleware.ts | 106 +++++++++++++++++++++------ 4 files changed, 357 insertions(+), 22 deletions(-) create mode 100644 blocks/matcher.test.ts create mode 100644 runtime/middleware.test.ts diff --git a/blocks/matcher.test.ts b/blocks/matcher.test.ts new file mode 100644 index 000000000..a5fad3105 --- /dev/null +++ b/blocks/matcher.test.ts @@ -0,0 +1,144 @@ +// deno-lint-ignore-file no-explicit-any +import { assert, assertEquals, assertStringIncludes } from "@std/assert"; +import { getSetCookies } from "../deps.ts"; +import matcherBlock, { + DECO_MATCHER_PREFIX, + type MatcherStickySessionModule, +} from "./matcher.ts"; + +const buildHttpCtx = (respHeaders: Headers) => + ({ + resolveChain: [{ type: "resolvable", value: "test-matcher-id" }], + context: { + state: { + response: { headers: respHeaders }, + flags: [] as any[], + global: {}, + bag: new WeakMap(), + }, + }, + request: new Request("https://example.com/"), + resolve: (() => {}) as any, + revision: undefined, + resolverId: "test-resolver", + monitoring: undefined, + }) as any; + +const buildMatchCtx = (request: Request) => + ({ + device: "desktop", + siteId: 1, + request, + resolve: (() => {}) as any, + invoke: (() => {}) as any, + response: { headers: new Headers() }, + bag: new WeakMap(), + }) as any; + +Deno.test("sticky matcher flips result and sets cookie WITHOUT Vary: cookie", async () => { + const respHeaders = new Headers(); + const httpCtx = buildHttpCtx(respHeaders); + + const module: MatcherStickySessionModule = { + default: () => true, + sticky: "session", + }; + + const result = await resolverFor(module, httpCtx, new Request("https://example.com/")); + + assertEquals(result, true); + + const setCookies = getSetCookies(respHeaders); + assertEquals(setCookies.length, 1, "expected one Set-Cookie on respHeaders"); + assert( + setCookies[0].name.startsWith(DECO_MATCHER_PREFIX), + `expected Set-Cookie name to start with ${DECO_MATCHER_PREFIX}, got ${ + setCookies[0].name + }`, + ); + + const vary = respHeaders.get("vary") ?? ""; + assert( + !vary.toLowerCase().includes("cookie"), + `expected Vary header to NOT contain "cookie", got: ${vary}`, + ); +}); + +Deno.test("sticky matcher with matching cookie does NOT set a cookie or Vary", async () => { + const respHeaders = new Headers(); + const httpCtx = buildHttpCtx(respHeaders); + + const module: MatcherStickySessionModule = { + default: () => true, + sticky: "session", + }; + + // Build the cookie name the matcher would use, then set it on the request + // with a value that decodes to `true` so result === isMatchFromCookie. + const { Murmurhash3 } = await import("../deps.ts"); + const h = new Murmurhash3(); + h.hash("test-matcher-id"); + const cookieName = `${DECO_MATCHER_PREFIX}${h.result()}`; + // cookieValue.build: btoa(id) + "@" + (result ? 1 : 0) + const cookieVal = `${btoa("test-matcher-id")}@1`; + + const request = new Request("https://example.com/", { + headers: { cookie: `${cookieName}=${cookieVal}` }, + }); + + const result = await resolverFor(module, httpCtx, request); + assertEquals(result, true); + + assertEquals( + getSetCookies(respHeaders).length, + 0, + "expected no Set-Cookie when cookie value already matches result", + ); + assertEquals( + respHeaders.get("vary"), + null, + "expected no Vary header when nothing was emitted", + ); +}); + +Deno.test("non-sticky matcher does not touch respHeaders", async () => { + const respHeaders = new Headers(); + const httpCtx = buildHttpCtx(respHeaders); + + const module = { + default: () => true, + sticky: "none" as const, + }; + + const result = await resolverFor( + module as any, + httpCtx, + new Request("https://example.com/"), + ); + assertEquals(result, true); + + assertEquals(getSetCookies(respHeaders).length, 0); + assertEquals(respHeaders.get("vary"), null); +}); + +// Regression guard: if anyone re-adds Vary: cookie inside the sticky branch, +// this scan will fail. The string check is deliberately broad. +Deno.test("matcher.ts source does not append Vary: cookie", async () => { + const src = await Deno.readTextFile(new URL("./matcher.ts", import.meta.url)); + assert( + !/append\(\s*["']vary["']\s*,\s*["']cookie["']\s*\)/i.test(src), + "blocks/matcher.ts must not append Vary: cookie — that disables CDN caching", + ); + // Sanity: ensure the cookie-setting code path is still there. + assertStringIncludes(src, "setCookie(respHeaders"); +}); + +async function resolverFor( + module: MatcherStickySessionModule | { default: any; sticky: "none" }, + httpCtx: any, + request: Request, +): Promise { + const adapt = matcherBlock.adapt as any; + const resolver = adapt(module, "test-matcher-id")({}, httpCtx); + return await resolver(buildMatchCtx(request)); +} diff --git a/blocks/matcher.ts b/blocks/matcher.ts index 1af587c23..1235aa0f4 100644 --- a/blocks/matcher.ts +++ b/blocks/matcher.ts @@ -215,7 +215,6 @@ const matcherBlock: Block< sameSite: "Lax", expires: date, }); - respHeaders.append("vary", "cookie"); } } diff --git a/runtime/middleware.test.ts b/runtime/middleware.test.ts new file mode 100644 index 000000000..c5df8d7b8 --- /dev/null +++ b/runtime/middleware.test.ts @@ -0,0 +1,128 @@ +import { assert, assertEquals } from "@std/assert"; +import { setCookie } from "../utils/cookies.ts"; +import { DECO_MATCHER_PREFIX } from "../blocks/matcher.ts"; +import { applyPageCacheDecision, DECO_SEGMENT } from "./middleware.ts"; + +const matcherCookie = `${DECO_MATCHER_PREFIX}1234567890_0.5`; + +const pageInput = { + flags: [], + isPageCacheAllowed: true, + shouldCacheFromVary: true, +}; + +Deno.test("no matcher, no Set-Cookie → public cache-control", () => { + const headers = new Headers({ "Content-Type": "text/html" }); + applyPageCacheDecision(headers, pageInput); + const cc = headers.get("Cache-Control") ?? ""; + assert(cc.startsWith("public,"), `expected public Cache-Control, got: ${cc}`); + assertEquals(headers.get("Deco-Cache-Vary-Cookies"), null); +}); + +Deno.test("matcher Set-Cookie only → public cache-control + hint header", () => { + const headers = new Headers({ "Content-Type": "text/html" }); + setCookie(headers, { name: matcherCookie, value: "abc@1", path: "/" }); + setCookie(headers, { name: DECO_SEGMENT, value: "%7B%7D", path: "/" }); + + applyPageCacheDecision(headers, pageInput); + + const cc = headers.get("Cache-Control") ?? ""; + assert(cc.startsWith("public,"), `expected public Cache-Control, got: ${cc}`); + + const hint = headers.get("Deco-Cache-Vary-Cookies") ?? ""; + assert( + hint.includes(matcherCookie), + `expected hint to include matcher cookie name, got: ${hint}`, + ); + assert( + hint.includes(DECO_SEGMENT), + `expected hint to include deco_segment, got: ${hint}`, + ); +}); + +Deno.test("foreign Set-Cookie → no-store (safety preserved)", () => { + const headers = new Headers({ "Content-Type": "text/html" }); + setCookie(headers, { name: matcherCookie, value: "abc@1", path: "/" }); + setCookie(headers, { name: "cart_count", value: "3", path: "/" }); + + applyPageCacheDecision(headers, pageInput); + + assertEquals( + headers.get("Cache-Control"), + "no-store, no-cache, must-revalidate", + ); + assertEquals(headers.get("Deco-Cache-Vary-Cookies"), null); +}); + +Deno.test("vary.shouldCache=false (personalizing loader) → no-store", () => { + const headers = new Headers({ "Content-Type": "text/html" }); + setCookie(headers, { name: matcherCookie, value: "abc@1", path: "/" }); + + applyPageCacheDecision(headers, { + ...pageInput, + shouldCacheFromVary: false, + }); + + assertEquals( + headers.get("Cache-Control"), + "no-store, no-cache, must-revalidate", + ); + assertEquals(headers.get("Deco-Cache-Vary-Cookies"), null); +}); + +Deno.test("flag with cacheable:false → no-store", () => { + const headers = new Headers({ "Content-Type": "text/html" }); + + applyPageCacheDecision(headers, { + flags: [{ cacheable: false }], + isPageCacheAllowed: true, + shouldCacheFromVary: true, + }); + + assertEquals( + headers.get("Cache-Control"), + "no-store, no-cache, must-revalidate", + ); +}); + +Deno.test("isPageCacheAllowed=false → headers untouched", () => { + const headers = new Headers({ "Content-Type": "text/html" }); + setCookie(headers, { name: matcherCookie, value: "abc@1", path: "/" }); + + applyPageCacheDecision(headers, { + ...pageInput, + isPageCacheAllowed: false, + }); + + assertEquals(headers.get("Cache-Control"), null); + assertEquals(headers.get("Deco-Cache-Vary-Cookies"), null); +}); + +Deno.test("respects pre-existing Cache-Control header", () => { + const headers = new Headers({ + "Content-Type": "text/html", + "Cache-Control": "public, max-age=600", + }); + + applyPageCacheDecision(headers, pageInput); + + assertEquals(headers.get("Cache-Control"), "public, max-age=600"); +}); + +Deno.test( + "cacheDisqualified overrides a pre-existing Cache-Control header", + () => { + const headers = new Headers({ + "Content-Type": "text/html", + "Cache-Control": "public, max-age=600", + }); + setCookie(headers, { name: "session_id", value: "xyz", path: "/" }); + + applyPageCacheDecision(headers, pageInput); + + assertEquals( + headers.get("Cache-Control"), + "no-store, no-cache, must-revalidate", + ); + }, +); diff --git a/runtime/middleware.ts b/runtime/middleware.ts index 177756e16..cab0a8b61 100644 --- a/runtime/middleware.ts +++ b/runtime/middleware.ts @@ -1,6 +1,9 @@ // deno-lint-ignore-file no-explicit-any import { HTTPException } from "@hono/hono/http-exception"; -import { DECO_MATCHER_HEADER_QS } from "../blocks/matcher.ts"; +import { + DECO_MATCHER_HEADER_QS, + DECO_MATCHER_PREFIX, +} from "../blocks/matcher.ts"; import { PAGE_DIRTY_KEY } from "../blocks/utils.tsx"; import { Context, context } from "../deco.ts"; import { @@ -110,6 +113,80 @@ const PAGE_CACHE_ENABLED = Deno.env.get("DECO_PAGE_CACHE_ENABLED") === "true"; const PAGE_CACHE_CONTROL = Deno.env.get("DECO_PAGE_CACHE_CONTROL") ?? "public, max-age=90, s-maxage=90, stale-while-revalidate=3600, stale-if-error=86400"; +// Cookies the framework itself emits. CDNs are expected to include these in +// their custom cache key so cache identity tracks the variant, instead of +// treating the Set-Cookie as a reason to bypass cache entirely. +// Deferred to a getter to dodge a TDZ from the blocks/matcher.ts circular import. +const frameworkCookiePrefixes = (): readonly string[] => [ + DECO_MATCHER_PREFIX, + DECO_SEGMENT, +]; + +const isFrameworkCookieName = (name: string): boolean => + frameworkCookiePrefixes().some((p) => name.startsWith(p)); + +const hasNonFrameworkSetCookie = (headers: Headers): boolean => { + for (const c of getSetCookies(headers)) { + if (!isFrameworkCookieName(c.name)) { + return true; + } + } + return false; +}; + +const frameworkSetCookieNames = (headers: Headers): string[] => + getSetCookies(headers) + .filter((c) => isFrameworkCookieName(c.name)) + .map((c) => c.name); + +const NO_STORE = "no-store, no-cache, must-revalidate"; + +export interface PageCacheDecisionInput { + flags: readonly { cacheable?: boolean }[]; + isPageCacheAllowed: boolean; + /** false iff some loader vetoed caching (cache:"no-store" or null cache key). */ + shouldCacheFromVary: boolean; +} + +/** + * Mutates `headers` to set Cache-Control (and the Deco-Cache-Vary-Cookies + * hint header) according to the matcher-aware caching rules. Exported for + * direct testability; the request middleware is the only production caller. + */ +export const applyPageCacheDecision = ( + headers: Headers, + input: PageCacheDecisionInput, +): void => { + const hasForeignSetCookie = hasNonFrameworkSetCookie(headers); + const cacheDisqualified = hasForeignSetCookie || !input.shouldCacheFromVary; + + if (cacheDisqualified) { + headers.set("Cache-Control", NO_STORE); + return; + } + + if (!input.isPageCacheAllowed) { + return; + } + + const allFlagsCacheable = input.flags.length > 0 + ? input.flags.every((flag) => flag.cacheable === true) + : true; + + if (!allFlagsCacheable) { + headers.set("Cache-Control", NO_STORE); + return; + } + + if (!headers.has("Cache-Control")) { + headers.set("Cache-Control", PAGE_CACHE_CONTROL); + } + const frameworkNames = frameworkSetCookieNames(headers); + if (frameworkNames.length > 0) { + headers.set("Deco-Cache-Vary-Cookies", frameworkNames.join(", ")); + } +}; + export const DEBUG_QS = "__d"; const addHours = (date: Date, h: number) => { date.setTime(date.getTime() + h * 60 * 60 * 1000); @@ -424,29 +501,16 @@ export const middlewareFor = ( } } - const hasSetCookie = getSetCookies(newHeaders).length > 0; const contentType = newHeaders.get("Content-Type") ?? ""; const isHtmlResponse = contentType.includes("text/html"); const isPageDirty = ctx.var.bag?.has(PAGE_DIRTY_KEY); - - if (hasSetCookie) { - // Set-cookie present: never cache (same behavior as main) - newHeaders.set("Cache-Control", "no-store, no-cache, must-revalidate"); - } else if (isHtmlResponse && PAGE_CACHE_ENABLED && !isPageDirty) { - const flags = ctx.var?.flags ?? []; - const allFlagsCacheable = flags.length > 0 - ? flags.every((flag) => flag.cacheable === true) - : true; - - if (!allFlagsCacheable) { - newHeaders.set( - "Cache-Control", - "no-store, no-cache, must-revalidate", - ); - } else if (!newHeaders.has("Cache-Control")) { - newHeaders.set("Cache-Control", PAGE_CACHE_CONTROL); - } - } + const isPageCacheAllowed = isHtmlResponse && PAGE_CACHE_ENABLED && + !isPageDirty; + applyPageCacheDecision(newHeaders, { + flags: ctx.var?.flags ?? [], + isPageCacheAllowed, + shouldCacheFromVary: ctx.var?.vary?.shouldCache !== false, + }); // for some reason hono deletes content-type when response is not fresh. // which means that sometimes it will fail as headers are immutable. From 063f2c5716515647a79eb9a64903089906f75507 Mon Sep 17 00:00:00 2001 From: decobot Date: Wed, 27 May 2026 02:33:47 +0000 Subject: [PATCH 6/8] Update version to 1.200.1-next.1 --- deno.json | 2 +- dev/deno.json | 2 +- scripts/deno.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deno.json b/deno.json index 1b1da38cf..1cabdaab0 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/deco", - "version": "1.197.1-next.2", + "version": "1.200.1-next.1", "lock": false, "nodeModulesDir": "auto", "exports": { diff --git a/dev/deno.json b/dev/deno.json index 8fe98eff4..b7e4e52d7 100644 --- a/dev/deno.json +++ b/dev/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/dev", - "version": "1.197.1-next.2", + "version": "1.200.1-next.1", "exports": { "./tailwind": "./tailwind.ts" }, diff --git a/scripts/deno.json b/scripts/deno.json index 960670403..45249258a 100644 --- a/scripts/deno.json +++ b/scripts/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/scripts", - "version": "1.197.1-next.2", + "version": "1.200.1-next.1", "exports": { "./release": "./release.ts", "./update": "./update.run.ts", From 5c986f109830709524df1113f5bb0f82c027dec6 Mon Sep 17 00:00:00 2001 From: Fernando Frizzatti Date: Tue, 26 May 2026 23:57:26 -0300 Subject: [PATCH 7/8] Revert "Update version to 1.200.1-next.1" This reverts commit 063f2c5716515647a79eb9a64903089906f75507. --- deno.json | 2 +- dev/deno.json | 2 +- scripts/deno.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deno.json b/deno.json index 1cabdaab0..1b1da38cf 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/deco", - "version": "1.200.1-next.1", + "version": "1.197.1-next.2", "lock": false, "nodeModulesDir": "auto", "exports": { diff --git a/dev/deno.json b/dev/deno.json index b7e4e52d7..8fe98eff4 100644 --- a/dev/deno.json +++ b/dev/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/dev", - "version": "1.200.1-next.1", + "version": "1.197.1-next.2", "exports": { "./tailwind": "./tailwind.ts" }, diff --git a/scripts/deno.json b/scripts/deno.json index 45249258a..960670403 100644 --- a/scripts/deno.json +++ b/scripts/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/scripts", - "version": "1.200.1-next.1", + "version": "1.197.1-next.2", "exports": { "./release": "./release.ts", "./update": "./update.run.ts", From 0347fbf3dba00d5d777158c818aacbca3bdd215f Mon Sep 17 00:00:00 2001 From: Fernando Frizzatti Date: Tue, 26 May 2026 23:57:26 -0300 Subject: [PATCH 8/8] Revert "Merge pull request #1202 from deco-cx/vibe-dex/cache-vary-cookie-safety-next" This reverts commit a7aecde6d05dab3719b66193e6572a281549a00e, reversing changes made to c40aff87c704590c078052e14ac479348a95155c. --- blocks/matcher.test.ts | 144 ------------------------------------- blocks/matcher.ts | 1 + runtime/middleware.test.ts | 128 --------------------------------- runtime/middleware.ts | 106 ++++++--------------------- 4 files changed, 22 insertions(+), 357 deletions(-) delete mode 100644 blocks/matcher.test.ts delete mode 100644 runtime/middleware.test.ts diff --git a/blocks/matcher.test.ts b/blocks/matcher.test.ts deleted file mode 100644 index a5fad3105..000000000 --- a/blocks/matcher.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { assert, assertEquals, assertStringIncludes } from "@std/assert"; -import { getSetCookies } from "../deps.ts"; -import matcherBlock, { - DECO_MATCHER_PREFIX, - type MatcherStickySessionModule, -} from "./matcher.ts"; - -const buildHttpCtx = (respHeaders: Headers) => - ({ - resolveChain: [{ type: "resolvable", value: "test-matcher-id" }], - context: { - state: { - response: { headers: respHeaders }, - flags: [] as any[], - global: {}, - bag: new WeakMap(), - }, - }, - request: new Request("https://example.com/"), - resolve: (() => {}) as any, - revision: undefined, - resolverId: "test-resolver", - monitoring: undefined, - }) as any; - -const buildMatchCtx = (request: Request) => - ({ - device: "desktop", - siteId: 1, - request, - resolve: (() => {}) as any, - invoke: (() => {}) as any, - response: { headers: new Headers() }, - bag: new WeakMap(), - }) as any; - -Deno.test("sticky matcher flips result and sets cookie WITHOUT Vary: cookie", async () => { - const respHeaders = new Headers(); - const httpCtx = buildHttpCtx(respHeaders); - - const module: MatcherStickySessionModule = { - default: () => true, - sticky: "session", - }; - - const result = await resolverFor(module, httpCtx, new Request("https://example.com/")); - - assertEquals(result, true); - - const setCookies = getSetCookies(respHeaders); - assertEquals(setCookies.length, 1, "expected one Set-Cookie on respHeaders"); - assert( - setCookies[0].name.startsWith(DECO_MATCHER_PREFIX), - `expected Set-Cookie name to start with ${DECO_MATCHER_PREFIX}, got ${ - setCookies[0].name - }`, - ); - - const vary = respHeaders.get("vary") ?? ""; - assert( - !vary.toLowerCase().includes("cookie"), - `expected Vary header to NOT contain "cookie", got: ${vary}`, - ); -}); - -Deno.test("sticky matcher with matching cookie does NOT set a cookie or Vary", async () => { - const respHeaders = new Headers(); - const httpCtx = buildHttpCtx(respHeaders); - - const module: MatcherStickySessionModule = { - default: () => true, - sticky: "session", - }; - - // Build the cookie name the matcher would use, then set it on the request - // with a value that decodes to `true` so result === isMatchFromCookie. - const { Murmurhash3 } = await import("../deps.ts"); - const h = new Murmurhash3(); - h.hash("test-matcher-id"); - const cookieName = `${DECO_MATCHER_PREFIX}${h.result()}`; - // cookieValue.build: btoa(id) + "@" + (result ? 1 : 0) - const cookieVal = `${btoa("test-matcher-id")}@1`; - - const request = new Request("https://example.com/", { - headers: { cookie: `${cookieName}=${cookieVal}` }, - }); - - const result = await resolverFor(module, httpCtx, request); - assertEquals(result, true); - - assertEquals( - getSetCookies(respHeaders).length, - 0, - "expected no Set-Cookie when cookie value already matches result", - ); - assertEquals( - respHeaders.get("vary"), - null, - "expected no Vary header when nothing was emitted", - ); -}); - -Deno.test("non-sticky matcher does not touch respHeaders", async () => { - const respHeaders = new Headers(); - const httpCtx = buildHttpCtx(respHeaders); - - const module = { - default: () => true, - sticky: "none" as const, - }; - - const result = await resolverFor( - module as any, - httpCtx, - new Request("https://example.com/"), - ); - assertEquals(result, true); - - assertEquals(getSetCookies(respHeaders).length, 0); - assertEquals(respHeaders.get("vary"), null); -}); - -// Regression guard: if anyone re-adds Vary: cookie inside the sticky branch, -// this scan will fail. The string check is deliberately broad. -Deno.test("matcher.ts source does not append Vary: cookie", async () => { - const src = await Deno.readTextFile(new URL("./matcher.ts", import.meta.url)); - assert( - !/append\(\s*["']vary["']\s*,\s*["']cookie["']\s*\)/i.test(src), - "blocks/matcher.ts must not append Vary: cookie — that disables CDN caching", - ); - // Sanity: ensure the cookie-setting code path is still there. - assertStringIncludes(src, "setCookie(respHeaders"); -}); - -async function resolverFor( - module: MatcherStickySessionModule | { default: any; sticky: "none" }, - httpCtx: any, - request: Request, -): Promise { - const adapt = matcherBlock.adapt as any; - const resolver = adapt(module, "test-matcher-id")({}, httpCtx); - return await resolver(buildMatchCtx(request)); -} diff --git a/blocks/matcher.ts b/blocks/matcher.ts index 1235aa0f4..1af587c23 100644 --- a/blocks/matcher.ts +++ b/blocks/matcher.ts @@ -215,6 +215,7 @@ const matcherBlock: Block< sameSite: "Lax", expires: date, }); + respHeaders.append("vary", "cookie"); } } diff --git a/runtime/middleware.test.ts b/runtime/middleware.test.ts deleted file mode 100644 index c5df8d7b8..000000000 --- a/runtime/middleware.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { assert, assertEquals } from "@std/assert"; -import { setCookie } from "../utils/cookies.ts"; -import { DECO_MATCHER_PREFIX } from "../blocks/matcher.ts"; -import { applyPageCacheDecision, DECO_SEGMENT } from "./middleware.ts"; - -const matcherCookie = `${DECO_MATCHER_PREFIX}1234567890_0.5`; - -const pageInput = { - flags: [], - isPageCacheAllowed: true, - shouldCacheFromVary: true, -}; - -Deno.test("no matcher, no Set-Cookie → public cache-control", () => { - const headers = new Headers({ "Content-Type": "text/html" }); - applyPageCacheDecision(headers, pageInput); - const cc = headers.get("Cache-Control") ?? ""; - assert(cc.startsWith("public,"), `expected public Cache-Control, got: ${cc}`); - assertEquals(headers.get("Deco-Cache-Vary-Cookies"), null); -}); - -Deno.test("matcher Set-Cookie only → public cache-control + hint header", () => { - const headers = new Headers({ "Content-Type": "text/html" }); - setCookie(headers, { name: matcherCookie, value: "abc@1", path: "/" }); - setCookie(headers, { name: DECO_SEGMENT, value: "%7B%7D", path: "/" }); - - applyPageCacheDecision(headers, pageInput); - - const cc = headers.get("Cache-Control") ?? ""; - assert(cc.startsWith("public,"), `expected public Cache-Control, got: ${cc}`); - - const hint = headers.get("Deco-Cache-Vary-Cookies") ?? ""; - assert( - hint.includes(matcherCookie), - `expected hint to include matcher cookie name, got: ${hint}`, - ); - assert( - hint.includes(DECO_SEGMENT), - `expected hint to include deco_segment, got: ${hint}`, - ); -}); - -Deno.test("foreign Set-Cookie → no-store (safety preserved)", () => { - const headers = new Headers({ "Content-Type": "text/html" }); - setCookie(headers, { name: matcherCookie, value: "abc@1", path: "/" }); - setCookie(headers, { name: "cart_count", value: "3", path: "/" }); - - applyPageCacheDecision(headers, pageInput); - - assertEquals( - headers.get("Cache-Control"), - "no-store, no-cache, must-revalidate", - ); - assertEquals(headers.get("Deco-Cache-Vary-Cookies"), null); -}); - -Deno.test("vary.shouldCache=false (personalizing loader) → no-store", () => { - const headers = new Headers({ "Content-Type": "text/html" }); - setCookie(headers, { name: matcherCookie, value: "abc@1", path: "/" }); - - applyPageCacheDecision(headers, { - ...pageInput, - shouldCacheFromVary: false, - }); - - assertEquals( - headers.get("Cache-Control"), - "no-store, no-cache, must-revalidate", - ); - assertEquals(headers.get("Deco-Cache-Vary-Cookies"), null); -}); - -Deno.test("flag with cacheable:false → no-store", () => { - const headers = new Headers({ "Content-Type": "text/html" }); - - applyPageCacheDecision(headers, { - flags: [{ cacheable: false }], - isPageCacheAllowed: true, - shouldCacheFromVary: true, - }); - - assertEquals( - headers.get("Cache-Control"), - "no-store, no-cache, must-revalidate", - ); -}); - -Deno.test("isPageCacheAllowed=false → headers untouched", () => { - const headers = new Headers({ "Content-Type": "text/html" }); - setCookie(headers, { name: matcherCookie, value: "abc@1", path: "/" }); - - applyPageCacheDecision(headers, { - ...pageInput, - isPageCacheAllowed: false, - }); - - assertEquals(headers.get("Cache-Control"), null); - assertEquals(headers.get("Deco-Cache-Vary-Cookies"), null); -}); - -Deno.test("respects pre-existing Cache-Control header", () => { - const headers = new Headers({ - "Content-Type": "text/html", - "Cache-Control": "public, max-age=600", - }); - - applyPageCacheDecision(headers, pageInput); - - assertEquals(headers.get("Cache-Control"), "public, max-age=600"); -}); - -Deno.test( - "cacheDisqualified overrides a pre-existing Cache-Control header", - () => { - const headers = new Headers({ - "Content-Type": "text/html", - "Cache-Control": "public, max-age=600", - }); - setCookie(headers, { name: "session_id", value: "xyz", path: "/" }); - - applyPageCacheDecision(headers, pageInput); - - assertEquals( - headers.get("Cache-Control"), - "no-store, no-cache, must-revalidate", - ); - }, -); diff --git a/runtime/middleware.ts b/runtime/middleware.ts index cab0a8b61..177756e16 100644 --- a/runtime/middleware.ts +++ b/runtime/middleware.ts @@ -1,9 +1,6 @@ // deno-lint-ignore-file no-explicit-any import { HTTPException } from "@hono/hono/http-exception"; -import { - DECO_MATCHER_HEADER_QS, - DECO_MATCHER_PREFIX, -} from "../blocks/matcher.ts"; +import { DECO_MATCHER_HEADER_QS } from "../blocks/matcher.ts"; import { PAGE_DIRTY_KEY } from "../blocks/utils.tsx"; import { Context, context } from "../deco.ts"; import { @@ -113,80 +110,6 @@ const PAGE_CACHE_ENABLED = Deno.env.get("DECO_PAGE_CACHE_ENABLED") === "true"; const PAGE_CACHE_CONTROL = Deno.env.get("DECO_PAGE_CACHE_CONTROL") ?? "public, max-age=90, s-maxage=90, stale-while-revalidate=3600, stale-if-error=86400"; -// Cookies the framework itself emits. CDNs are expected to include these in -// their custom cache key so cache identity tracks the variant, instead of -// treating the Set-Cookie as a reason to bypass cache entirely. -// Deferred to a getter to dodge a TDZ from the blocks/matcher.ts circular import. -const frameworkCookiePrefixes = (): readonly string[] => [ - DECO_MATCHER_PREFIX, - DECO_SEGMENT, -]; - -const isFrameworkCookieName = (name: string): boolean => - frameworkCookiePrefixes().some((p) => name.startsWith(p)); - -const hasNonFrameworkSetCookie = (headers: Headers): boolean => { - for (const c of getSetCookies(headers)) { - if (!isFrameworkCookieName(c.name)) { - return true; - } - } - return false; -}; - -const frameworkSetCookieNames = (headers: Headers): string[] => - getSetCookies(headers) - .filter((c) => isFrameworkCookieName(c.name)) - .map((c) => c.name); - -const NO_STORE = "no-store, no-cache, must-revalidate"; - -export interface PageCacheDecisionInput { - flags: readonly { cacheable?: boolean }[]; - isPageCacheAllowed: boolean; - /** false iff some loader vetoed caching (cache:"no-store" or null cache key). */ - shouldCacheFromVary: boolean; -} - -/** - * Mutates `headers` to set Cache-Control (and the Deco-Cache-Vary-Cookies - * hint header) according to the matcher-aware caching rules. Exported for - * direct testability; the request middleware is the only production caller. - */ -export const applyPageCacheDecision = ( - headers: Headers, - input: PageCacheDecisionInput, -): void => { - const hasForeignSetCookie = hasNonFrameworkSetCookie(headers); - const cacheDisqualified = hasForeignSetCookie || !input.shouldCacheFromVary; - - if (cacheDisqualified) { - headers.set("Cache-Control", NO_STORE); - return; - } - - if (!input.isPageCacheAllowed) { - return; - } - - const allFlagsCacheable = input.flags.length > 0 - ? input.flags.every((flag) => flag.cacheable === true) - : true; - - if (!allFlagsCacheable) { - headers.set("Cache-Control", NO_STORE); - return; - } - - if (!headers.has("Cache-Control")) { - headers.set("Cache-Control", PAGE_CACHE_CONTROL); - } - const frameworkNames = frameworkSetCookieNames(headers); - if (frameworkNames.length > 0) { - headers.set("Deco-Cache-Vary-Cookies", frameworkNames.join(", ")); - } -}; - export const DEBUG_QS = "__d"; const addHours = (date: Date, h: number) => { date.setTime(date.getTime() + h * 60 * 60 * 1000); @@ -501,16 +424,29 @@ export const middlewareFor = ( } } + const hasSetCookie = getSetCookies(newHeaders).length > 0; const contentType = newHeaders.get("Content-Type") ?? ""; const isHtmlResponse = contentType.includes("text/html"); const isPageDirty = ctx.var.bag?.has(PAGE_DIRTY_KEY); - const isPageCacheAllowed = isHtmlResponse && PAGE_CACHE_ENABLED && - !isPageDirty; - applyPageCacheDecision(newHeaders, { - flags: ctx.var?.flags ?? [], - isPageCacheAllowed, - shouldCacheFromVary: ctx.var?.vary?.shouldCache !== false, - }); + + if (hasSetCookie) { + // Set-cookie present: never cache (same behavior as main) + newHeaders.set("Cache-Control", "no-store, no-cache, must-revalidate"); + } else if (isHtmlResponse && PAGE_CACHE_ENABLED && !isPageDirty) { + const flags = ctx.var?.flags ?? []; + const allFlagsCacheable = flags.length > 0 + ? flags.every((flag) => flag.cacheable === true) + : true; + + if (!allFlagsCacheable) { + newHeaders.set( + "Cache-Control", + "no-store, no-cache, must-revalidate", + ); + } else if (!newHeaders.has("Cache-Control")) { + newHeaders.set("Cache-Control", PAGE_CACHE_CONTROL); + } + } // for some reason hono deletes content-type when response is not fresh. // which means that sometimes it will fail as headers are immutable.