Skip to content

feat(matcher): make matcher-wrapped pages cacheable at CDN edge - #1202

Merged
vibe-dex merged 1 commit into
nextfrom
vibe-dex/cache-vary-cookie-safety-next
May 27, 2026
Merged

feat(matcher): make matcher-wrapped pages cacheable at CDN edge#1202
vibe-dex merged 1 commit into
nextfrom
vibe-dex/cache-vary-cookie-safety-next

Conversation

@vibe-dex

@vibe-dex vibe-dex commented May 27, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Drops Vary: cookie from sticky-session matchers (blocks/matcher.ts:218) — the cookie value is what determines the variant; CDN cache keys target the specific cookie name, so Vary: cookie is overbroad and forces every CDN to bypass cache for matcher pages.
  • Filters framework-managed Set-Cookies (deco_matcher_*, deco_segment) out of the kill-switch in runtime/middleware.ts. Only foreign Set-Cookies (cart, profile, etc.) downgrade Cache-Control to no-store.
  • Wires ctx.var.vary.shouldCache into the full-page kill-switch (Change 2.5). Loaders that declare cache: "no-store" (the documented contract for personalizing loaders) now veto full-page caching the same way they already veto partial-section caching (see runtime/routes/render.tsx:118-134 for the pre-existing reference pattern).
  • Emits a Deco-Cache-Vary-Cookies hint header listing the framework cookies present, so CDN operators can discover which cookies belong in the custom cache key.
  • Extracts the kill-switch logic into an exported pure applyPageCacheDecision(headers, input) for testability.
  • Adds first-ever tests for blocks/matcher.ts and runtime/middleware.ts.

Why

Customer Lojas Torra (~9% of fleet egress, ~5 TB/month) is 100% origin-bound on PDP/PLP/search/home despite a 70-83% overall hit rate. Every matcher-wrapped page emits Vary: cookie + Cache-Control: no-store, making the response uncacheable at any CDN regardless of cache rules. This is a fleet-wide issue — any deco-cx/deco site with active matchers (A/B tests, banners, sticky variants) has uncacheable pages.

After this PR, matcher-wrapped pages emit:

Vary: Accept-Encoding
Set-Cookie: deco_matcher_<hash>_<split>=...
Set-Cookie: deco_segment=...
Cache-Control: public, max-age=90, swr=3600, sie=86400
Deco-Cache-Vary-Cookies: deco_matcher_<hash>_<split>, deco_segment

The CDN can then cache them using a custom cache key that includes the matcher cookie values.

Decision table (new)

Condition Cache-Control
Any foreign Set-Cookie (cart, profile, etc.) no-store, no-cache, must-revalidate
Any loader marked cache: "no-store" (vary.shouldCache===false) no-store, no-cache, must-revalidate
Any flag with cacheable: false no-store, no-cache, must-revalidate
Matcher-only page, all loaders cacheable, HTML, PAGE_CACHE_ENABLED, not PAGE_DIRTY PAGE_CACHE_CONTROL + Deco-Cache-Vary-Cookies hint
Anything else headers untouched

Safety — landmines in @deco/apps

The old Vary: cookie + no-store sledgehammer also incidentally protected pages whose loaders read auth cookies and bake personalization into SSR HTML without emitting any Set-Cookie back. Change 2.5 replaces that protection with the loader-cache: "no-store" contract, but the contract is only as good as the loaders that respect it.

Pre-merge audit of deco-cx/apps found ~24 personalizing loaders that read auth cookies but do NOT declare cache: "no-store". Each is a silent data-leak vector after this PR. They need a companion PR in deco-cx/apps:

VTEX (~14): user.ts, profile/getCurrentProfile.ts, profile/getProfileByEmail.ts, wishlist.ts, address/getUserAddresses.ts, payment/userPayments.ts, payment/paymentSystems.ts, orders/list.ts, orders/getById.ts, orders/orderplaced.ts, session/getSession.ts, session/getUserSessions.ts, masterdata/searchDocuments.ts, promotion/getPromotionById.ts

Linx (3): user.ts, cart.ts, wishlist/search.ts
Shopify (2): user.ts, cart.ts
WAP (3): user.ts, cart.ts, wishlist.ts
Vnda (1): cart.ts
Nuvemshop (1): cart.ts

Per-file fix: add export const cache = "no-store";.

Wake is already correctly protected (user.ts, cart.ts, wishlist.ts declare no-store; partner-token loaders use null cacheKey which already flips vary.shouldCache=false).

Sequencing constraint for stable promotion: the @deco/apps companion PR must merge AND be released to npm AND consumed by canary sites BEFORE this framework PR can promote from next to stable.

Canary gate for stable promotion

next-channel canary must include at least one site exercising all three logged-in patterns: (a) SSR user greeting, (b) SSR mini-cart with items, (c) per-user/B2B pricing baked into product cards. Run 24-48h two-session soak (auth as U1 in browser A, browse same pages in clean B). Stable promotion blocked until zero cross-user leakage observed.

Test plan

  • deno test --unstable-http -A blocks/matcher.test.ts runtime/middleware.test.ts — 12 new tests pass
  • deno test --unstable-http -A runtime/caches/ engine/ clients/ utils/ — all 26 existing tests still pass
  • deno check live.ts clean
  • Deploy to a next-tagged site with active matcher; curl -sI → confirm Vary: Accept-Encoding (no cookie) and Cache-Control: public, max-age=…
  • Companion @deco/apps PR adding cache: "no-store" to ~24 personalizing loaders (separate PR, blocker for stable)
  • 24-48h two-session logged-in soak on canary sites covering SSR header/mini-cart/per-user-pricing (blocker for stable)
  • End-to-end on Lojas Torra after stable: re-enable Cloudflare cache rule b2cb2d5a5a784f0fb8eba5a39b3784c2, confirm cf-cache-status: HIT on PDPs, stats lake shows bypass collapse from ~600k req/day to <50k

🤖 Generated with Claude Code


Summary by cubic

Make matcher-wrapped pages cacheable at the CDN edge. Removes overbroad cookie variance and keeps safety by letting loaders and flags veto caching.

  • New Features

    • Removed Vary: cookie from sticky-session matchers; CDN can cache by specific matcher cookie values.
    • Framework Set-Cookies (deco_matcher_*, deco_segment) no longer force no-store; only foreign cookies do.
    • Full-page cache kill-switch now respects loader veto via ctx.var.vary.shouldCache (e.g., loaders with cache: "no-store").
    • Added Deco-Cache-Vary-Cookies header listing framework cookies to include in CDN cache keys.
    • Extracted applyPageCacheDecision(headers, input) for reuse and testing; added tests for matcher and middleware.
  • Migration

    • CDN: include deco_matcher_* and deco_segment cookie values in the custom cache key for HTML pages.
    • Apps: ensure any personalizing loader exports cache: "no-store"; a companion @deco/apps PR is required before promoting this to stable.

Written for commit a634468. Summary will update on new commits. Review in cubic

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) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 30618677-0453-4ddf-acfe-dc36d515142c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch vibe-dex/cache-vary-cookie-safety-next

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown
Contributor

Prerelease Tagging

Merging this PR will publish a prerelease automatically.
By default this is a Patch prerelease (1.200.1-next.N).
A maintainer can override the bump by reacting:

  • 👍 Patch prerelease 1.200.1-next.N (default)
  • 🎉 Minor prerelease 1.201.0-next.N
  • 🚀 Major prerelease 2.0.0-next.N

.N auto-increments per base. Consumers opt in via the explicit version
(e.g. jsr:@deco/deco@1.200.1-next.1); jsr:@deco/deco continues to resolve to the stable channel.

@vibe-dex
vibe-dex merged commit a7aecde into next May 27, 2026
6 checks passed
@vibe-dex
vibe-dex deleted the vibe-dex/cache-vary-cookie-safety-next branch May 27, 2026 02:33
vibe-dex added a commit that referenced this pull request May 27, 2026
…kie-safety-next"

This reverts commit a7aecde, reversing
changes made to c40aff8.
vibe-dex added a commit to deco-cx/apps that referenced this pull request May 27, 2026
…1601)

* feat(commerce): declare cache: "no-store" on personalizing loaders

Adds `export const cache = "no-store";` to 24 loaders across VTEX, Linx,
Shopify, WAP, Vnda, and Nuvemshop apps. These loaders read auth
cookies/headers and bake user-specific data (profile, cart, orders,
wishlist, payments) into SSR — they must opt out of full-page CDN
caching.

Companion to deco-cx/deco#1202 which removes the over-broad
`Vary: cookie` blanket from matcher-wrapped pages. After that change
lands on stable, the framework's load-bearing safety signal for "this
page contains personalized SSR" becomes the loader-level
`cache: "no-store"` declaration (already consumed by the partial-render
endpoint, newly consumed by the full-page kill-switch).

Wake's user/cart/wishlist already declare this; its partner-token
loaders use `cacheKey() => null` which already flips
`vary.shouldCache=false`. Not modified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: resolve pre-existing type errors blocking CI

Fixes 11 type errors that block `deno check **/mod.ts` on PR CI. All
pre-existing on main (verified via `git stash`), surfaced now because
the Bundle & Check Apps workflow only runs on PRs.

- blog `ctx.invoke.records.loaders.drizzle()` (7 sites): the records
  app is an optional runtime dependency; the existing pattern already
  handles the unresolved case at runtime. Use the established
  `(ctx.invoke as any).records` cast (matches the cross-app invoke
  pattern in vtex/matchers/userSegment.ts:77 and
  vtex/matchers/birthday.ts:32).
- blog/core/records.ts:13 `Record<string, Resolvable<T>>` assignment:
  ctx.get() returns the literal input type; cast through `unknown`.
- blog Seo sections (`ctx.seo`): apply the typeof-narrowed pattern
  from spire/sections/Seo/SeoBlogPost.tsx.
- vtex/loaders/orders/orderplaced.ts:33 cookies: cast
  Object.fromEntries result to Record<string, string> for stringify().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Update version to 0.153.1-next.1

* fix(matchers): declare random matcher cacheable=true

Sticky-session A/B test matchers persist the assigned variant in a
deco_matcher_* cookie, and CDN cache rules include that cookie in the
custom cache key — so per-variant responses get distinct cache entries
and the page is safely cacheable.

Without this declaration, the framework's applyPageCacheDecision walks
ctx.var.flags and forces Cache-Control: no-store when any flag has
cacheable !== true. random.ts was the last domino blocking PDPs that
use ABTest {{{traffic}}} blocks from being cached at the edge.

Cherry-picks the fix from #1602 onto the 1.200.1 prerelease branch so
canary sites can soak the full chain (deco@1.200.1-next.2 +
apps@0.153.1-next.2) without waiting for #1602 to merge separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Update version to 0.153.1-next.2

* fix(matchers): declare cron matcher cacheable=true

The cron matcher evaluates a server-side schedule and returns the same
answer for every request at any given minute. Within a response's TTL
the cron answer is stable; on cache expiry the matcher re-evaluates
against the schedule and the next snapshot is taken. There's no
per-request variation, no cookie/header dependency outside cache key,
no leak surface.

Same safety profile as the date matcher, which already declares
cacheable=true.

Other matchers flagged in the previous audit but NOT promoted in this
release:
- userAgent.ts: granular UA regex; UA isn't in standard cache key and
  there's no normalization layer (device.ts works only because the
  framework provides a 3-way bucket).
- location.ts: CF-IP* headers per-IP, not in default cache key.
- cookie.ts: arbitrary cookie name=value not in cache key.

Leaving those at cacheable !== true is the correct safety default —
they force no-store, which prevents cross-user leaks until a site
adds the relevant signal to its CDN cache-key rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Update version to 0.153.1-next.3

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant