Construir o ~/tools/paper-revision foi a parte fácil. Fazer com que ele se comportasse em produção do mesmo jeito que no localhost levou muito mais tempo, e a maioria dos bugs não estava na parte de IA — estava no encanamento ao redor dela. Aqui está o que realmente quebrou.
O 502 silencioso: conexões SSE precisam de um heartbeat
Reviews longas usando uma chave BYOK da Anthropic às vezes falhavam depois de ~100 segundos — sem nenhuma mensagem de erro, só uma conexão morta. Os logs da Vercel mostravam um 502 limpo, sem nada que apontasse para a causa real.
O problema real: um modelo pode ficar em silêncio por um tempo no meio da geração (um trecho longo de "pensamento" interno antes do primeiro token visível), e se o stream de resposta passa tempo demais sem enviar nenhum byte, algum intermediário entre o cliente e o modelo decide que a conexão está ociosa e a derruba. A solução foi um heartbeat — um caractere invisível escrito no stream em um intervalo fixo, para que os bytes nunca parem de fluir:
const HEARTBEAT_MS = 15_000
const HEARTBEAT_CHAR = '' // espaço de largura zero, invisível no markdown renderizado
const result = await Promise.race([pending, heartbeat])
if (result === 'heartbeat') {
controller.enqueue(encoder.encode(HEARTBEAT_CHAR))
continue
}Investigar isso a fundo também revelou um bug pior escondido por trás: o handler de abort estava capturando qualquer exceção chamada AbortError e tratando isso como um cancelamento deliberado do usuário — incluindo casos em que a rede simplesmente caía sozinha. Falhas reais estavam sendo engolidas silenciosamente. Corrigir isso significou rastrear se o usuário tinha mesmo clicado em "abort" antes de confiar no nome do erro.
Bring-your-own-key, sem as armadilhas
O plano gratuito roda em um modelo hospedado com rate limit; usuários BYOK pagam com a própria chave e pulam essa limitação por completo — exceto que a primeira versão não pulava, e as requisições BYOK também estavam consumindo a cota do plano gratuito. Foi um bug report bem honesto ("mas eu usei minha própria chave e mesmo assim fui limitado por rate limit") que revelou isso.
A outra lição de BYOK foi proativa, não reativa: limitar a allowlist de modelos a faixas de preço razoáveis, para que uma requisição não caia acidentalmente no modelo mais caro disponível, e limitar os tokens de saída independentemente do provider. Nada disso impede um usuário de gastar mal o próprio dinheiro, mas impede que o nosso código seja o motivo disso acontecer.
Um favicon que o Google Search se recusava a mostrar
O favicon do site renderizava perfeitamente em todas as abas do navegador, mas simplesmente nunca aparecia ao lado da URL nos resultados do Google Search. A causa era quase embaraçosamente indireta: a convenção genérica de ícones do Next.js adiciona um hash de conteúdo à URL do arquivo — /icon.png?d72260d... — e esse hash muda toda vez que os bytes do arquivo mudam. As próprias diretrizes do Google são explícitas sobre esse exato modo de falha: "a URL do favicon precisa ser estável — não mude a URL com frequência." Cada edição naquele ícone estava silenciosamente zerando toda a confiança que o Google tinha acumulado na URL antiga.
O Next.js tem um caso especial para exatamente um nome de arquivo — favicon.ico pula o hash de conteúdo por completo e recebe uma URL permanentemente estável. Envolver a mesma arte em um contêiner ICO mínimo e trocar para esse nome de arquivo foi toda a correção. Valeu a pena revisar a configuração de domínio ao mesmo tempo: o Google trata hostnames diferentes como "sites" totalmente separados para fins de favicon, então tanto o www. quanto o domínio padrão *.vercel.app receberam redirecionamentos permanentes para o único hostname canônico.
O Cloudflare Turnstile só renderiza uma vez
O modo de renderização implícita do Turnstile varre a página em busca de elementos .cf-turnstile exatamente uma vez, quando o script carrega pela primeira vez. Isso é imperceptível em um site tradicional de múltiplas páginas, onde cada navegação recarrega o script do zero — mas isso aqui é uma SPA, e o estado do formulário de review sobrevive deliberadamente à navegação no client-side, para que uma review em andamento não seja abandonada. Se você navega para outra página e volta, o ponto de montagem do widget é um nó DOM totalmente novo que o script já carregado nunca vai escanear de novo. O token silenciosamente vira undefined, o que — depois de adicionar verificação no server-side ao endpoint de compartilhamento — significava que toda tentativa de compartilhar depois de sair e voltar falhava sem nenhuma explicação visível.
A correção é a API de renderização explícita do Turnstile: carregar o script uma vez e, a partir daí, chamar turnstile.render() manualmente em cada mount e turnstile.remove() em cada unmount, em vez de depender daquela varredura automática única.
Dev no Windows, prod no Linux
Dois bugs separados, a mesma causa raiz: desenvolver no Windows e fazer deploy no runtime Linux da Vercel não são o mesmo ambiente, e bibliotecas de PDF/imagem são exatamente onde essa diferença aparece. O pdf-parse quebrava com um erro de DOMMatrix em produção que nunca reproduzia localmente — trocar para uma biblioteca de PDF segura para serverless (unpdf) resolveu de vez. Separadamente, a geração de imagens do next/og quebrava ao carregar fontes, o que foi rastreado até um caminho de arquivo do Windows contendo espaços, que travava o parsing de URL da biblioteca. Nenhum dos dois bugs existia do ponto de vista do npm run dev local; os dois eram 100% reproduzíveis em todo único deploy.
O que uma revisão de segurança realmente pega
Uma revisão completa da codebase revelou um punhado de problemas reais, ainda que discretos: um endpoint de compartilhamento de snapshot sem nenhuma verificação de bot — tecnicamente aberto para qualquer pessoa publicar texto arbitrário em uma URL pública; um rate limit cobrado antes de verificar se o arquivo enviado sequer podia ser parseado, de modo que um upload malformado queimava a cota do usuário por uma review que nunca ia acontecer; um handler de zip que descomprimia uma entrada do arquivo antes de checar seu tamanho declarado, que é exatamente a cara de uma vulnerabilidade de zip bomb; e um botão de compartilhar que criava silenciosamente um snapshot duplicado se você navegasse para outra página e clicasse nele de novo, porque o estado de "já temos um link ou não" vivia em um componente que tinha acabado de ser destruído e recriado.
Nenhum desses era exótico. Eram o tipo de bug que só aparece quando alguém realmente lê o código procurando o que pode dar errado, em vez de apenas checar se o caminho feliz funciona.
Reflexão final
Todo bug dessa lista foi lançado, funcionou e parecia pronto — até que um input específico, uma navegação específica ou um ambiente de hospedagem específico provasse o contrário. Infraestrutura chata, verificada com cuidado, vence infraestrutura esperta que nunca foi testada contra produção. exit 0.