Relatório preliminar. O universo pode ter mudado desde a coleta dos dados.
De static export ao OpenNext adapter: a decisão de runtime do tneemo.com
Este site começou sem runtime: um static export para Cloudflare Pages, sem backend e sem rota dinâmica. Cinco commits depois, ele roda como um Worker completo — com API de contato, estado compartilhado em Cloudflare KV e um rate limiter que a auditoria de segurança apontou como frágil, reescrito sobre estado durável. Este texto registra a decisão de runtime no meio do caminho: por que o adapter não-estático foi a escolha definitiva, e como ela destravou tudo o que veio depois.
O ponto de partida: static export, sem backend
O commit c894f81 transformou o site em export estático (output: export, imagens sem otimização, rotas dinâmicas forçadas a estáticas — og/twitter/apple-icon/manifest/robots/sitemap). O custo foi a API: POST /api/contact (206 linhas) e seu teste saíram do repositório. A página de contato ganhou um fallback honesto — endpoint null, sem webhook, sem entrega falsa.
Estático é o deploy mais barato que existe: não há runtime, não há o que escalar, não há o que vazar. Mas ele cobra um teto: o site só pode fazer o que foi pré-renderizado. A decisão parecia final — e durou oito minutos.
A mudança de fundação: OpenNext Cloudflare adapter
O commit ad49041, oito minutos depois, desfez o static export com uma troca de fundação: @opennextjs/cloudflare (que suporta Next >= 16.2.11), opennext.config.ts, wrangler.jsonc — o site passou a ser um Worker com Next 16 completo e não-estático. A API de contato voltou (as mesmas 206 linhas restauradas), mas com a honestidade preservada: sem webhook configurado, a rota responde 503 com mensagem clara — nunca um "enviado" mentiroso. A verificação foi local, com wrangler dev: home 200, projetos 200, api 503 honesto.
Essa é a escolha que permite o resto. Estático não tem runtime; Worker tem. E com runtime vieram as duas consequências que este texto quer registrar: estado compartilhado e um rate limiter que funciona de verdade.
Consequência 1: estado compartilhado em Cloudflare KV
57b9294 configurou o namespace KV CONTACT_RATE_LIMIT no wrangler.jsonc. Em serverless não existe "a memória do site": cada isolate tem a sua, e requests se distribuem entre muitos. KV é o primeiro estado que atravessa isolates — lento (consistência eventual), mas durável e compartilhado. Para um contador anti-spam de 5 requests por 10 minutos, é exatamente a ferramenta certa.
Consequência 2: o rate limiter virou KV — e por quê
O motivo real não foi performance. Foi a auditoria de segurança (tneemosite#1). O limitador original, MemoryRateLimiter, guardava os buckets num Map no heap da instância — um limite por-isolate. Num único processo, funciona. Em serverless, é um convite: o atacante distribui requests entre isolates, o cold start zera o contador, e o "5 req / 10 min" vira sugestão, não enforcement.
A correção (da2bc20 + 9dc4a9a) reescreveu o limitador sobre o KV: KvRateLimiter grava count|resetAt no namespace com expirationTtl — a janela expira sozinha — e é fail-closed: qualquer falha de leitura ou escrita em KV responde 429, jamais 200, para que o bypass não aconteça nem quando o armazenamento falha. A resposta de negação carrega Retry-After e X-RateLimit-*, e a cobertura de teste subiu junto: 11 testes focados no módulo (janela, rollover, fail-closed) mais testes de rota provando o 429 a partir da 6ª requisição e a persistência entre instâncias.
O que a sequência ensina
O adapter não-estático não foi uma melhoria de performance — foi a decisão que tornou o resto expressável. Sem runtime, o site não tinha onde guardar estado; sem KV, o rate limiter não tinha onde ser durável; sem o limiter durável, o achado da auditoria ficaria sem correção. Quando a fundação tem o formato errado, até o fix de segurança deixa de ser escrevível. A ordem dos commits conta a história: c894f81 → ad49041 → 57b9294 → 9dc4a9a → da2bc20.