feat(tanstack): cache de CDN para /_serverFn via marcador de segmento na URL - #513
Open
JonasJesus42 wants to merge 4 commits into
Open
feat(tanstack): cache de CDN para /_serverFn via marcador de segmento na URL#513JonasJesus42 wants to merge 4 commits into
JonasJesus42 wants to merge 4 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 queobriga o
CDN-Cache-Control: no-storeem toda resposta, e por isso 100% do tráfego invoca o Worker.A solução, para
/_serverFnInverte 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.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-storequando confere exatamente. Mantêm ono-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 geoO pior caso é não cachear, nunca uma resposta errada.
isBote o cookie de A/B são checados com os mesmos helpers que obuildCacheKeyusa — chavear e liberarpor 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-profilevirou armadilha silenciosaO JSDoc prometia segurança "sem
buildSegmentedeviceSpecificKeys: false", masdeviceSpecificKeysétruepor 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.tsde 9.Validação
Testado end-to-end contra um site real buildado, não só unit: pacotes linkados,
bun run build, worker nowrangler dev, navegação SPA dirigida por Chrome.Cdn-Cache-Control/_serverFncom marcador correto (desktop e mobile)public, max-age=1800✅no-storeno-storeno-storeno-storeno-storeno-storeno-storeO valor liberado bate exatamente com o que a implementação de site produz em produção.
2573testes passando. Typecheck limpo.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 }nowrangler), 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
/_serverFnrequests 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/_serverFnURLs; the Worker recomputes the segment from the request and only relaxesCDN-Cache-Controlon an exact match. Absent, diverging, forged, or stale-build markers keep today'sno-store— the worst case is not caching, never a wrong response.New Features
cdnCacheControl: "serverfn-segment"oncreateDecoWorkerEntry, paired with thedecoServerFnFetchclient hook wired insrc/start.ts.no-storesince the initial navigation has no client hook to attach a marker.buildCacheKeyuses.Bug Fixes
cdnCacheControl: "match-profile"is now refused (with a warning) while the cache key is segmented — its JSDoc promised safety that never held sincedeviceSpecificKeysdefaults to true.listingdefault), is case-insensitive, and tolerates locale prefixes.setCacheProfilerefuses to flipprivate/cart/noneprofiles public; custom cache patterns can no longer make a private path public, and the newregisterPrivatePaths()is the safe way to add private routes.Cache-Controlon every response — it only personalizes for logged-in or custom-pricing requests, and clearsCDN-Cache-Controlwhen it does.set-cookievalues are never cached,bypassPathsalways extends the framework defaults, andCDN-Cache-Controlis decided at the single response exit (bypass or absent header →no-store).Written for commit b079c94. Summary will update on new commits.