Skip to content

feat(tanstack): cache de CDN para /_serverFn via marcador de segmento na URL - #513

Open
JonasJesus42 wants to merge 4 commits into
mainfrom
feat/cdn-cache-serverfn
Open

feat(tanstack): cache de CDN para /_serverFn via marcador de segmento na URL#513
JonasJesus42 wants to merge 4 commits into
mainfrom
feat/cdn-cache-serverfn

Conversation

@JonasJesus42

@JonasJesus42 JonasJesus42 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Separado do #510 a pedido do review. Depende de #512 (hotfix de rotas privadas), que é a base desta branch —
mergear aquele primeiro.

O problema

Qualquer camada na frente do Worker chaveia pela URL crua. O Worker chaveia por uma Request sintética que
carrega __seg/__cf_device/__cf_geo/__bot/__fetch/__abf (buildCacheKey). É essa divergência que
obriga o CDN-Cache-Control: no-store em toda resposta, e por isso 100% do tráfego invoca o Worker.

A solução, para /_serverFn

Inverte o problema: em vez de esperar que a camada da frente reproduza a chave, o cliente põe o segmento na
própria URL
(?__cseg=<device>.<buildHash>), o que torna as duas chaves equivalentes.

// worker-entry.ts
createDecoWorkerEntry(serverEntry, {
  cdnCacheControl: "serverfn-segment",
  buildSegment: (request) => ({ /* ... */ }),   // obrigatório
});
// src/start.ts
import { decoServerFnFetch } from "@decocms/tanstack/sdk/serverFnFetch";
export const startInstance = createStart(() => ({ serverFns: { fetch: decoServerFnFetch } }));

Cobre navegação SPA e prefetch — o volume que o Speculation Rules gera. Default inalterado.

Por que é seguro

O marcador é dica, não fonte de verdade. O Worker recalcula o segmento do próprio request e só relaxa o
no-store quando confere exatamente. Mantêm o no-store:

marcador ausente · divergente · forjado · de build antigo · logado · região · canal ≠ 1 · campo custom
desconhecido no SegmentKey · UA de bot · cookie de A/B · chave que varia por geo

O pior caso é não cachear, nunca uma resposta errada.

isBot e o cookie de A/B são checados com os mesmos helpers que o buildCacheKey usa — chavear e liberar
por predicados diferentes é exatamente como os dois divergem em silêncio.

O build hash entra no token porque deploy não purga quem estiver cacheando a resposta; a URL precisa mudar
sozinha quando o bundle muda.

HTML continua no-store: a navegação inicial é request do browser, sem hook de JS para anexar marcador.

Também aqui: match-profile virou armadilha silenciosa

O JSDoc prometia segurança "sem buildSegment e deviceSpecificKeys: false", mas deviceSpecificKeys é
true por default — a condição é falsa em todo site. Quem seguisse a doc cross-serviria segmentos sem aviso.
Agora é recusado com warning.

Procedência

Portado de uma implementação que já roda em produção num site, com dois gaps fechados: aquela versão
verificava só o device, deixando bots (que executam JS — Lighthouse, PageSpeed) e cohorts de A/B envenenarem
uma entrada compartilhada. No site, isso substituiu ~230 linhas de shim por 6 linhas + um start.ts de 9.

Validação

Testado end-to-end contra um site real buildado, não só unit: pacotes linkados, bun run build, worker no
wrangler dev, navegação SPA dirigida por Chrome.

caso Cdn-Cache-Control
GET /_serverFn com marcador correto (desktop e mobile) public, max-age=1800
sem marcador no-store
marcador de outro device (forjado) no-store
build antigo no-store
UA de bot no-store
cookie de login no-store
cookie de A/B no-store
documento HTML no-store

O valor liberado bate exatamente com o que a implementação de site produz em produção.

2573 testes passando. Typecheck limpo.

As 4 falhas em draft preview são pré-existentes no main.

O que ficou de fora

O gerador de Cache Rules que estava no #510 saiu. O review apontou — corretamente — que em sites servidos
por Workers não existe cache na frente, e que o caminho é o Workers Cache (cache: { enabled: true } no
wrangler), não Cache Rules na zona. Isso muda o desenho: é por worker em vez da zona compartilhada, é
config-as-code, a versão do worker já entra na chave (deploy invalida sozinho) e o Vary é respeitado.

Vou refazer a parte de HTML em cima disso, em PR próprio. Não fazia sentido mergear um gerador que já sei que
é a abordagem errada.

🤖 Generated with Claude Code


Summary by cubic

Enables opt-in CDN caching for /_serverFn requests by making the CDN's cache key (the raw URL) match the Worker's, and hardens the cache boundaries that change depends on.

The client now attaches a segment marker (?__cseg=<device>.<buildHash>) to /_serverFn URLs; the Worker recomputes the segment from the request and only relaxes CDN-Cache-Control on an exact match. Absent, diverging, forged, or stale-build markers keep today's no-store — the worst case is not caching, never a wrong response.

New Features

  • cdnCacheControl: "serverfn-segment" on createDecoWorkerEntry, paired with the decoServerFnFetch client hook wired in src/start.ts.
  • Covers SPA navigation and prefetch; HTML documents stay no-store since the initial navigation has no client hook to attach a marker.
  • Build hash is part of the token so stale CDN entries invalidate when the bundle changes.
  • Bot UAs and A/B cohort cookies are rejected using the same helpers buildCacheKey uses.

Bug Fixes

  • cdnCacheControl: "match-profile" is now refused (with a warning) while the cache key is segmented — its JSDoc promised safety that never held since deviceSpecificKeys defaults to true.
  • Private path detection now covers wishlist, profile, signup, and returns routes (previously fell through to the cacheable listing default), is case-insensitive, and tolerates locale prefixes.
  • setCacheProfile refuses to flip private/cart/none profiles public; custom cache patterns can no longer make a private path public, and the new registerPrivatePaths() is the safe way to add private routes.
  • VTEX middleware no longer overwrites the cache layer's Cache-Control on every response — it only personalizes for logged-in or custom-pricing requests, and clears CDN-Cache-Control when it does.
  • Cache bypasses fail closed: unparseable set-cookie values are never cached, bypassPaths always extends the framework defaults, and CDN-Cache-Control is decided at the single response exit (bypass or absent header → no-store).

Written for commit b079c94. Summary will update on new commits.

Review in cubic

JonasJesus42 and others added 4 commits August 27, 2026 20:14
Every response currently ships `CDN-Cache-Control: no-store`, which hides a
few real gaps in how routes are classified. They are harmless only while
nothing is cached at the CDN; enabling that turns each one into a leak.

- `PRIVATE_PREFIX_RE` only matched a short list, was case-sensitive and
  anchored at the root, so `/listadedesejos`, `/wishlist`, `/favoritos`,
  `/orders`, `/profile`, `/logout`, `/cadastro` and returns routes all fell
  through to the cacheable `listing` default (public, 120s edge) — as did
  `/Checkout` and `/pt/checkout`. Rebuilt from a `PRIVATE_SEGMENTS` list,
  case-insensitive, tolerating a locale prefix.

- `setCacheProfile` would happily flip `private`/`cart`/`none` to public via a
  props bag. Now refused with a warning unless `allowPublicPrivateProfile()`
  is called first — the escape hatch has a name you have to type.

- `registerCachePattern` is evaluated before the built-ins "so they can
  override defaults", which let a broad site pattern capture `/checkout` and
  make it public. Custom patterns can still tighten anything; they can no
  longer make a private path public.

- Adds `registerPrivatePaths()`, the safe half of cache configuration: it can
  only restrict, and (because the Worker is the source of truth for
  cacheability) it propagates to the CDN with no rule change.

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

The VTEX app middleware wraps `handleRequest`, so it runs after the entire
edge-cache layer and is the last writer of `Cache-Control` — including on a
cache HIT. It overwrote unconditionally, which downgraded a home page the
cache layer had resolved as `s-maxage=900` to `vtexCacheControl`'s generic
`s-maxage=60`, throwing away the per-profile TTL.

It now only speaks up for the case it actually knows better about — a
logged-in or custom-pricing request — and when it does, it clears
`CDN-Cache-Control` too. Otherwise a response could go out as
`Cache-Control: private, no-store` alongside `CDN-Cache-Control: public,
max-age=300`, and Cloudflare gives the CDN header precedence. Same pairing the
Worker's own bypasses and `utils/proxy.ts` already use.

Exports `vtexMiddleware` so the behaviour is testable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three gaps that are currently masked by every response shipping
`CDN-Cache-Control: no-store`. They are harmless only while nothing is cached
in front of the Worker — the moment anything is (Workers Cache, a CDN rule),
each one becomes a cross-user leak.

- `hasOnlySafeCookies` was fail-open: a response that HAS a `set-cookie` whose
  names failed to parse was treated as safe, i.e. cacheable. The parser's
  fallback path is documented as unreliable, and the two outcomes are not
  symmetric — guessing "safe" caches a personalized response into the shared
  entry. Now fail-closed.

- `bypassPaths` REPLACED the framework defaults instead of extending them, so a
  site adding one path silently lost `/deco/`, `/live/` and `/.decofile`. Now
  always merged.

- `CDN-Cache-Control` is decided at the single response exit. Previously a dozen
  bypass call sites each decided for themselves: some deleted the header, some
  didn't, and several still emitted the profile's public `Cache-Control`
  (`public, s-maxage=900`) on the way out. Branches that return before the cache
  layer (`?asJson`, `?renderJson`, proxy, redirects) emitted nothing at all.
  Now: bypass forces `no-store`, an absent header defaults to `no-store`, and a
  value the cache layer already decided is left alone — so an early return can
  only ever be more restrictive, never accidentally public.

Also warns at boot when `buildSegment` is missing, since the logged-in bypass
reads `segment.loggedIn` and is inert without it — authenticated and anonymous
visitors then share one edge entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r in the URL

Anything in front of the Worker — Workers Cache, a CDN rule — keys on the raw
URL. The Worker keys on a synthetic Request carrying `__seg`/`__cf_device`/
`__cf_geo`/`__bot`/`__fetch`/`__abf` (`buildCacheKey`). That mismatch is why
every response ships `CDN-Cache-Control: no-store`, and why 100% of traffic
comes back uncached.

This inverts the problem for `/_serverFn` (SPA navigation and prefetch data):
the client appends the segment to the URL itself, so the two keys become
equivalent and releasing the cache is safe.

    // worker-entry.ts
    createDecoWorkerEntry(serverEntry, {
      cdnCacheControl: "serverfn-segment",
      buildSegment: (request) => ({ /* ... */ }),   // required
    });

    // src/start.ts
    import { decoServerFnFetch } from "@decocms/tanstack/sdk/serverFnFetch";
    export const startInstance = createStart(() => ({
      serverFns: { fetch: decoServerFnFetch },
    }));

The default is unchanged, and the marker is a HINT, not a source of truth: the
Worker recomputes the segment from the request and only relaxes `no-store` on
an exact match. Absent, diverging, forged or stale-build markers, logged-in /
region / sales-channel requests, an unknown custom segment field, a bot UA, an
A/B cohort cookie, or geo-varying keys all keep today's behaviour. The worst
case is not caching, never a wrong response.

The build hash is part of the token because deploying does not purge whatever
caches the response, so the URL has to change on its own when the bundle does.

`isBot` and the A/B cookie are checked with the same helpers `buildCacheKey`
uses — keying and releasing off different predicates is exactly how the two
silently diverge.

HTML documents stay `no-store`: the initial navigation is a browser request
with no client hook to attach a marker to.

Also refuses `cdnCacheControl: "match-profile"` when the cache key is
segmented. Its JSDoc promised safety "without buildSegment and
deviceSpecificKeys: false", but `deviceSpecificKeys` defaults to true, so the
condition is false on every site — following the docs would have silently
cross-served segments.

Ported from a site-level implementation running in production, with two gaps
closed: that version verified only the device, leaving bots (which execute JS —
Lighthouse, PageSpeed) and A/B cohorts able to poison a shared entry.

Co-Authored-By: Claude Opus 5 (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