background blurbackground mobile blur

1/1/1970

Como Resolvi Mudanças em Cascata de Hashes Usando Import Maps

E aí! Eu tinha esse problema há mais de 5 anos, mas só agora resolvi encarar porque chegou num ponto que não dava mais para ignorar. Quando eu mudava um único caractere em um arquivo, metade dos arquivos JavaScript do meu build ganhava novos nomes com hash, mesmo que o conteúdo real deles não tivesse mudado. Isso causava invalidação de cache desnecessária, tornava quase impossível rastrear o que realmente mudou entre builds, e o pior: quebrava meus builds no Cloudflare Pages por causa de um limite de arquivos.

Abaixo vou detalhar o problema, por que as soluções existentes não funcionaram para mim, e como criei um plugin Vite personalizado usando Import Maps para resolver isso de uma vez por todas.

O Problema: Mudanças em Cascata de Hashes

O Vite usa hashing baseado em conteúdo para builds de produção. Quando você faz o build do seu app, cada arquivo JavaScript recebe um hash no nome com base no seu conteúdo. Se button.tsx compila para button-abc12345.js, e o conteúdo muda, ele vira button-def45678.js. Isso é ótimo para invalidar o cache: os usuários recebem o arquivo novo quando ele muda.

O problema aparece quando o Arquivo A importa o Arquivo B. Digamos que você tenha:

// main.js
import { Button } from "./button-abc12345.js";

Quando button.tsx muda, o Vite gera button-def45678.js. Mas agora main.js também muda, porque ele contém a string "./button-abc12345.js", que agora está errada. Então main.js ganha um novo hash também, mesmo que a lógica real dele não tenha mudado nada.

Isso cascateia por todo o seu grafo de dependências. Mude uma única função utilitária e, de repente, metade dos seus arquivos js ganha novos hashes. No meu caso, mudar um único caractere em useBackgroundMusic.ts fez com que mais de 500 arquivos fossem re-hashados.

O impacto no mundo real foi significativo. A gente empacota 8 versões dos assets dos builds anteriores para que usuários em versões um pouco desatualizadas do nosso cliente ainda consigam rodar a versão deles quando fazemos deploy de uma nova no Cloudflare Pages. Só que o Cloudflare Pages tem um limite de 20.000 arquivos, que começamos a estourar por causa da nossa mudança de i18n recente, que explodiu a quantidade de arquivos que estávamos criando.

Resolver as cascatas de hashes nos permite armazenar muito mais builds antigos sem bater nesses limites, porque agora a maioria dos arquivos não precisa mais mudar. Isso também reduz a chance de um usuário em um build desatualizado dar erro, já que é muito mais provável que ele esteja pedindo um arquivo que agora não mudou e que por acaso ainda temos.

Por Que Não [Soluções Alternativas]?

Quando comecei a pensar em resolver isso, considerei algumas abordagens. Nenhuma delas se encaixou direito.

Scripts Pós-Build

Meu primeiro pensamento foi escrever um script pós-build que normalizaria todos os caminhos de import, re-hasharia os arquivos e atualizaria as referências. Parecia simples: usar regex para substituir os nomes com hash por nomes estáveis e depois recalcular os hashes.

Rejeitei essa abordagem por causa de "Heisenbugs" e preocupações com envenenamento de cache. Mesmo armazenando builds antigos no Cloudflare Pages, o risco de inconsistências de cache não valia a pena. Um script que modifica arquivos depois do build pode introduzir bugs sutis que só aparecem em produção, e debugar isso seria um pesadelo.

manualChunks do Vite

Outra opção era usar a configuração manualChunks do Vite para separar código estável (como node_modules) de código instável (lógica de negócio). A ideia era que o código de vendor mudaria com menos frequência, então menos arquivos cascateariam.

Isso na verdade não resolve o problema raiz, só o ameniza. Você ainda tem cascatas de hashes dentro dos chunks de lógica de negócio. Eu queria uma solução que atacasse o problema de fato, não que apenas o tornasse um pouco menos ruim.

Import Maps: A Solução Moderna

Import Maps são uma funcionalidade nativa do navegador (com suporte a polyfill para navegadores mais antigos) que desacopla os especificadores de módulos dos caminhos de arquivo. Em vez do Arquivo A importar "./button-abc123.js", ele importa "button". O navegador usa o import map para resolver "button" para o nome real do arquivo com hash.

Era exatamente o que eu precisava. O conteúdo do Arquivo A continua idêntico (ele sempre importa "button"), então o hash dele continua o mesmo. Só o import map e o arquivo que mudou ganham novos hashes. Fiquei meio chocado de ninguém já ter feito um plugin bom para isso!

Construindo o Plugin Vite

Decidi criar um plugin Vite que iria:

  1. Transformar todos os imports relativos para usar especificadores de módulos estáveis
  2. Gerar um import map que mapeia esses especificadores para os nomes reais dos arquivos com hash
  3. Injetar o import map no HTML

O plugin já está disponível no GitHub: @foony/vite-plugin-import-map

Abordagem Inicial

Comecei com um plugin Vite usando o hook generateBundle. Minha primeira tentativa usou regex para encontrar e substituir os caminhos de import. Foi fácil de codar e funcionou para nosso pequeno time da Foony, mas era frágil e definitivamente não funcionaria em um plugin onde poderiam haver falsos positivos sendo modificados.

A abordagem com regex tinha problemas óbvios: e se uma string no código por acaso parecesse um nome de arquivo? E os imports dinâmicos? E as declarações de export? Eu precisava de uma solução mais robusta se fosse criar um plugin para outras pessoas.

Parsing de AST

Eu precisava parsear o código JavaScript direito para encontrar todas as declarações de import. Minha primeira tentativa foi com es-module-lexer, que é especificamente projetado para parsear módulos ES. Infelizmente, ele causava panics nativos durante a fase de análise de módulos do Vite. Nem tentar o build asm.js ajudou a parar os panics.

Acabei optando pelo Acorn, um parser JavaScript puro, rápido e leve. Combinado com acorn-walk para travessia de AST, ele me deu tudo que eu precisava sem os problemas de dependência nativa.

Principais Desafios Resolvidos

Lidando com Todos os Tipos de Import

Imports vêm em várias formas, e são tratados de maneira diferente na AST. Eu precisava lidar com:

  • Imports estáticos: import x from "./file.js"
  • Imports dinâmicos: import("./file.js")
  • Re-exports nomeados: export { x } from "./file.js" (esse eu esqueci no começo!)
  • Re-export geral: export * from "./file.js"

O caso de re-export foi particularmente complicado, porque eu tinha esquecido dele até ver um arquivo que não estava sendo transformado. O código tinha export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" e meu plugin estava ignorando completamente porque eu só estava olhando os nós ImportDeclaration e ImportExpression.

Veja como lido com todos eles agora:

walk(ast, {
  ImportDeclaration(node: any) {
    // Static imports: import x from "spec"
    const specifier = node.source.value;
    // ... transform logic
  },
  ExportNamedDeclaration(node: any) {
    // Named exports with source: export { x, y } from "spec"
    if (!node.source?.value) return;
    // ... transform logic
  },
  ExportAllDeclaration(node: any) {
    // Export all: export * from "spec"
    if (!node.source?.value) return;
    // ... transform logic
  },
  ImportExpression(node: any) {
    // Dynamic imports: import("spec")
    // ... transform logic
  },
});

Resolução Determinística de Conflitos

Quando vários arquivos têm o mesmo nome base (como vários arquivos index.tsx em diretórios diferentes), preciso desambiguá-los. Não dá para usar "index" para todos.

Minha solução: se houver conflito, eu hasho o caminho original do arquivo junto com o nome base. Por exemplo, src/client/games/chess/index.tsx:index é hashado para criar index-abc123. Isso garante que o mesmo arquivo sempre receba o mesmo especificador de módulo entre builds, mesmo se outros arquivos com o mesmo nome forem adicionados ou removidos.

Uso chunk.facadeModuleId (o ponto de entrada) como identificador principal, voltando para chunk.moduleIds[0] se ele não estiver disponível. Isso me dá um caminho de origem estável para o hashing determinístico.

Encadeamento de Source Maps

Quando transformo o código, estou quebrando a cadeia do source map. O source map existente mapeia o código TypeScript original passando pelo Babel e pela minificação até chegar no código atual. Minhas transformações adicionam mais uma camada, então preciso preservar essa cadeia.

Uso MagicString para rastrear minhas transformações e gerar um novo source map. Depois faço o merge com o map existente preservando os arrays originais sources e sourcesContent. Isso mantém a cadeia completa: Código Original → (map existente) → Código Transformado.

const existingMap = typeof chunk.map === 'string' ? JSON.parse(chunk.map) : chunk.map;
const newMap = magicString.generateMap({
  source: fileName,
  file: newFileName,
  includeContent: true,
  hires: true,
});

// Merge: use new map's mappings but preserve original sources
chunk.map = {
  ...newMap,
  sources: existingMap.sources || newMap.sources,
  sourcesContent: existingMap.sourcesContent || newMap.sourcesContent,
  file: newFileName,
};

Re-hashing do Conteúdo Transformado

Preciso de conteúdo de arquivo estável. Para isso, transformo os imports (substituindo os imports com hash do Vite pelos meus imports estáveis) e depois removo os comentários de source map do cálculo do hash (eles referenciam nomes antigos de arquivo).

Depois disso, computo um novo hash e atualizo tanto o nome do arquivo quanto a entrada no import map.

A Implementação Final

O plugin usa uma estratégia de quatro passes:

  1. Pass de contagem: Detecta colisões de nomes contando quantos arquivos compartilham cada nome base
  2. Pass de mapeamento: Cria o mapeamento de chunks (nome com hash → especificador de módulo) e o import map inicial
  3. Pass de transformação: Reescreve os caminhos de import no código, recomputa hashes, atualiza source maps
  4. Pass de renomeação: Atualiza os nomes de arquivo do bundle e finaliza o import map

Aqui está a lógica central da transformação:

import {simple as walk} from 'acorn-walk';

// Parse the code to get an AST
const ast = Parser.parse(chunk.code, {
  ecmaVersion: 'latest',
  sourceType: 'module',
  locations: true,
});

const importsToTransform: Array<{start: number; end: number; replacement: string}> = [];

// Traverse the AST to find all imports/exports
walk(ast, {
  ImportDeclaration(node: any) {
    const specifier = node.source.value;
    const filename = specifier.split('/').pop()!;
    const moduleSpec = chunkMapping.get(filename);
    
    if (moduleSpec) {
      importsToTransform.push({
        start: node.source.start + 1, // +1 to skip opening quote
        end: node.source.end - 1,     // -1 to skip closing quote
        replacement: moduleSpec,
      });
    }
  },
  // ... handle other node types
});

// Apply transformations in reverse order to preserve positions
importsToTransform.sort((a, b) => b.start - a.start);
for (const transform of importsToTransform) {
  magicString.overwrite(transform.start, transform.end, transform.replacement);
}

Para injetar o import map no HTML, uso a API de injeção de tags do Vite em vez de manipulação por regex:

transformIndexHtml() {
  return {
    tags: [
      {
        tag: 'script',
        attrs: {type: 'importmap'},
        children: JSON.stringify(importMap, null, 2),
        injectTo: 'head-prepend',
      },
    ],
  };
}

Isso é muito mais confiável do que tentar dar match em tags HTML com regex.

Em Números

Para te dar uma ideia do que esse plugin faz:

  • ~1.000+ arquivos JavaScript processados por build
  • ~2-3 segundos adicionados ao tempo de build (troca aceitável)
  • ~99% de redução em mudanças desnecessárias de hash (a maioria dos arquivos agora só muda quando o conteúdo real deles muda)
  • ~340 linhas de código de plugin (incluindo comentários e tratamento de erros)

O plugin lida com todos os casos extremos que encontrei até agora, e o processo de build agora está bem mais previsível.

Lições Aprendidas

Por que parsing de AST é essencial

Regex em código bundlado é perigoso. Se uma string no seu código por acaso parecer um nome de arquivo, o regex vai reescrevê-la. O parsing de AST garante que você só transforma declarações reais de import/export.

Por que Acorn em vez de es-module-lexer

O es-module-lexer é mais rápido e mais focado, mas os problemas de panic nativo o tornaram inutilizável no contexto do meu plugin Vite. O Acorn é JavaScript puro, o que significa que não há dependências nativas com que se preocupar. Quero olhar para o es-module-lexer no futuro como otimização de velocidade, mas por enquanto o Acorn funciona perfeitamente.

Por que Import Maps em vez de alternativas

Import Maps são um padrão web com suporte nativo nos navegadores. Eles são a maneira "certa" de resolver esse problema. O polyfill (es-module-shims) lida com navegadores mais antigos (ex.: Safari < 16.4) de forma elegante, e a solução é limpa e fácil de manter.

Conclusão

O plugin de Import Maps consegue evitar mudanças em cascata de hashes nos meus builds Vite. Os arquivos agora só ganham novos hashes quando o conteúdo real deles muda, não quando suas dependências mudam. Isso torna os builds mais previsíveis, reduz a invalidação desnecessária de cache e nos ajuda a ficar abaixo dos limites de arquivos do Cloudflare Pages.

A solução é simples, fácil de manter e usa padrões web modernos. É um bom exemplo de como, às vezes, a solução "certa" também é a mais simples, uma vez que você entende o problema fundo o suficiente para enxergá-la.

O plugin é open source e está disponível no GitHub: @foony/vite-plugin-import-map. Você pode instalar com npm install @foony/vite-plugin-import-map e começar a usar nos seus próprios projetos Vite.

Melhorias futuras podem incluir otimizar com es-module-lexer quando os problemas de panic nativo forem resolvidos, ou adicionar suporte a cenários de import mais complexos. Mas por enquanto, o plugin faz exatamente o que eu preciso.

E quem sabe? Talvez um dia o Vite suporte algo assim de forma nativa.

(Atualização: Depois de testar o plugin no build da Foony, alguns usuários estavam tendo problemas inesperados, então desativei por enquanto. Vou revisitar isso depois. Provavelmente. Ainda acho que essa é uma solução bem legal.)

8 Ball Pool online multiplayer billiards icon