Porque o teu CDN não faz cache de nada

Todos os ficheiros estáticos de agentready.md voltavam com a mesma linha:

cf-cache-status: BYPASS

Tipos de letra, folhas de estilo, scripts, imagens. Não MISS, que quereria dizer que a edge ainda não tinha visto o ficheiro e vai guardar a cópia que acabou de ir buscar. BYPASS quer dizer que a edge leu a resposta, decidiu que não a podia guardar, e amanhã decide o mesmo. Cada visitante puxava tudo da origem, e teria continuado a fazê-lo para sempre.

As quatro definições que não eram o problema

  • Development Mode — desligado. Desactiva por completo a cache da edge durante três horas e fica ligado por esquecimento, por isso é a primeira coisa a ver.
  • Caching Level — Standard.
  • Page Rules — nenhuma punha Cache Level em Bypass.
  • Browser Cache TTL — um valor sensato.

Tudo o que o painel tinha a dizer sobre cache dizia guarda isto. A edge não guardava nada.

A causa estava na resposta, não no painel

Aparece assim que se lê o bloco de cabeçalhos inteiro em vez da única linha que se foi procurar:

curl -sI https://agentready.md/fonts/ibm-plex-sans-latin.woff2
HTTP/2 200
content-type: font/woff2
cache-control: public, max-age=31536000, immutable
set-cookie: _csrf=8f3c…; Path=/; SameSite=Strict
cf-cache-status: BYPASS

A terceira linha pede um ano. A quarta explica porque é que ninguém lho dá.

Uma resposta que traz Set-Cookie não pode ser guardada numa cache partilhada. Um cookie é de um visitante; guardar essa resposta e entregá-la ao seguinte é dar-lhe o cookie de outra pessoa. Todos os CDN recusam. A Cloudflare dá um nome à recusa, BYPASS; outros limitam-se a não guardar o objecto, sem dizer nada.

O cookie era nosso. Um middleware gerava um token CSRF em cada pedido e anexava-o, para que qualquer formulário do site encontrasse um à espera. Em «cada pedido» entrava o de um tipo de letra woff2, um ficheiro que não desenha formulários, não lê tokens e não corre código.

A correcção

O CSRF fica como está. O que deixa de acontecer é dar um token a quem não o pode usar:

const ASSET_PREFIXES = ['/css/', '/js/', '/fonts/', '/images/'];
const COOKIELESS_FILES = new Set([
  '/favicon.ico', '/badge.js', '/robots.txt', '/sitemap.xml', '/llms.txt',
]);

function servesWithoutCookie(path) {
  return COOKIELESS_FILES.has(path)
    || ASSET_PREFIXES.some((prefix) => path.startsWith(prefix))
    || path.endsWith('.md');
}

Compara caminhos, não extensões. Algumas das respostas que mais precisam de cache não saem do disco mas de um template, como o gémeo em Markdown de cada página ou os nossos badges em SVG, e também nunca desenham um formulário.

Se o cookie não foi posto por ti, olha para a camada de sessão. Muitos frameworks abrem sessão, e mandam o cookie dela, em qualquer pedido que passe pelo middleware, mesmo que lá dentro nunca se guarde nada.

Diagnosticar isto em qualquer stack

Vê um asset, não a página inicial. Uma página inicial pode não ser cacheável por bons motivos, e não diz nada sobre os trinta ficheiros que arrasta atrás.

for u in / /css/app.css /js/app.js /fonts/ibm-plex-sans-latin.woff2; do
  echo "== $u"
  curl -sI "https://exemplo.com$u" \
    | grep -iE 'cache-control|set-cookie|cf-cache-status|x-cache|^age'
done

Pede o mesmo URL duas vezes. O segundo pedido devia ser um acerto: cf-cache-status: HIT, x-cache: Hit from cloudfront, ou um cabeçalho age: que sobe em Varnish e no nginx. Se o segundo também falha, o objecto não está a ser guardado, e o motivo está nos cabeçalhos que acabaste de imprimir.

Os outros suspeitos do costume

Cache-Control: private: só navegadores, nunca uma cache partilhada. no-store: ninguém, nem o navegador. E no-cache não quer dizer «não guardes», quer dizer revalida antes de cada utilização, o que continua a ser uma ida e volta por ficheiro.

Vary: * deixa a resposta fora da cache, ponto final. Vary: Cookie é quase tão mau, porque a chave de cache passa a incluir um valor diferente por visitante e assim nada é partilhado. Vary: User-Agent parte um ficheiro em milhares de variantes. Accept-Encoding é o que faz sentido ali.

As query strings. A maioria das caches usa o URL inteiro como chave, por isso cada variante com ?utm_source= ou ?fbclid= é um objecto à parte, pedido à origem. Limpa ou normaliza os parâmetros de campanha na edge.

E o valor por omissão do framework, que não chega a ser um bypass mas custa quase o mesmo: max-age=0 em cada ficheiro estático são tantos pedidos de revalidação quantos os assets de cada navegação.

Uma política de cache que se aguenta

A regra é o que o URL promete, não o que o ficheiro é.

Um URL com um hash do conteúdo (app.css?v=9f2ac41b, app.9f2ac41b.css) é um endereço de conteúdo: se o ficheiro muda, o URL muda. Esses levam public, max-age=31536000, immutable e nunca há nada para purgar. O mesmo ficheiro sem o token fica por uma hora: o token é a promessa, e nada se guarda em cache com base numa promessa que ninguém fez. Um logótipo ou uma imagem social, substituídos no lugar, aguentam uma semana.

Fica a excepção, aquela em que quase toda a gente falha porque o ficheiro parece o mais cacheável que se tem: o que quer que esteja embebido em páginas alheias, num URL que não podes versionar. Servimos o badge.js com uma hora só por isso. Corre dentro de sites onde não controlamos nem o HTML nem o URL, por isso um ano de immutable seria um bug fora do nosso alcance durante um ano.

Se serves ficheiros com @fastify/static

Dois detalhes que nos custaram uma tarde. Manda Cache-Control: public, max-age=0 por omissão. E se és tu a pôr o cabeçalho com setHeaders, passa também cacheControl: false, senão a biblioteca constrói o dela a partir do maxAge por omissão e escreve por cima do teu, sem avisar, deixando uma coisa com ar de intencional:

app.register(fastifyStatic, {
  root: join(__dirname, 'public'),
  cacheControl: false,           // senão o setHeaders é substituído
  setHeaders: setStaticCacheHeaders,
});

Antes de mexer numa única definição de cache, lê uma resposta inteira. O cabeçalho que estraga a cache raramente é o que tem «cache» no nome.

Analisa o teu site · Checklist de preparação para IA · Os bots estão autorizados?