background blurbackground mobile blur

1/1/1970

Hoe ik cascaderende hash-wijzigingen heb opgelost met Import Maps

Hoi! Ik loop al meer dan 5 jaar tegen dit probleem aan, maar nu heb ik pas besloten het echt aan te pakken, omdat het punt was bereikt waarop ik het niet meer kon negeren. Als ik één enkel teken in één bestand wijzigde, kregen de helft van de JavaScript-bestanden in mijn build een nieuwe gehashte bestandsnaam, terwijl de inhoud helemaal niet veranderd was. Daardoor werd de cache onnodig ongeldig gemaakt, was het bijna niet meer te volgen wat er nu echt veranderd was tussen builds, en het ergste van alles: mijn Cloudflare Pages-builds gingen kapot door een limiet op het aantal bestanden.

Hieronder leg ik uit wat het probleem precies is, waarom bestaande oplossingen voor mij niet werkten, en hoe ik een eigen Vite-plugin met Import Maps heb gebouwd om het definitief op te lossen.

Het probleem: cascaderende hash-wijzigingen

Vite gebruikt content-based hashing voor productie-builds. Wanneer je je app buildt, krijgt elk JavaScript-bestand een hash in de bestandsnaam op basis van de inhoud. Als button.tsx compileert naar button-abc12345.js en de inhoud verandert, wordt dat button-def45678.js. Dat is ideaal voor cache busting, gebruikers krijgen automatisch het nieuwe bestand zodra het verandert.

Het probleem ontstaat zodra bestand A bestand B importeert. Stel dat je het volgende hebt:

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

Wanneer button.tsx verandert, genereert Vite button-def45678.js. Maar nu verandert main.js ook, omdat daar de string "./button-abc12345.js" in staat, en die klopt niet meer. Daardoor krijgt main.js ook een nieuwe hash, terwijl de eigenlijke logica in main.js helemaal niet is aangepast.

Dat effect loopt door je hele dependency graph heen. Pas één utilityfunctie aan en ineens krijgen de helft van je js-bestanden een nieuwe hash. In mijn geval zorgde het veranderen van één enkel teken in useBackgroundMusic.ts ervoor dat er meer dan 500 bestanden opnieuw werden gehasht.

De impact in de praktijk was best groot. We bundelen 8 versies van de assets van eerdere builds, zodat gebruikers met een iets verouderde versie van onze client hun versie nog kunnen draaien zodra wij een nieuwe versie naar Cloudflare Pages deployen. Maar Cloudflare Pages heeft een limiet van 20.000 bestanden, en daar begonnen we tegenaan te lopen door onze eerdere i18n-wijziging, waardoor het aantal aangemaakte bestanden echt ontplofte.

Door de cascaderende hashes op te lossen kunnen we veel meer oude builds bewaren zonder tegen die limiet aan te lopen, omdat de meeste bestanden nu niet meer hoeven te veranderen. Daardoor is ook de kans kleiner dat een gebruiker op een verouderde build in een error loopt, omdat de kans veel groter is dat hij een bestand opvraagt dat niet meer wijzigt en dat wij dus nog gewoon hebben.

Waarom niet [alternatieve oplossingen]?

Toen ik dit probleem voor het eerst echt wilde oplossen, heb ik een paar verschillende aanpakken overwogen. Geen daarvan paste echt goed.

Post-build-scripts

Mijn eerste gedachte was om een post-build-script te schrijven dat alle importpaden zou normaliseren, de bestanden opnieuw zou hashen en alle referenties zou bijwerken. Dat klonk vrij simpel: met een regex de gehashte bestandsnamen vervangen door stabiele namen en daarna de hashes opnieuw uitrekenen.

Ik heb deze aanpak laten vallen vanwege mogelijke „Heisenbugs” en angst voor cache poisoning. Ook al slaan we oude builds op in Cloudflare Pages, het risico op inconsistente caches vond ik te groot. Een script dat na de build nog bestanden wijzigt kan heel subtiele bugs introduceren die alleen in productie opduiken, en dat soort dingen debuggen is echt een nachtmerrie.

Vite manualChunks

Een andere optie was om Vites manualChunks-configuratie te gebruiken om stabiele code (zoals node_modules) te scheiden van minder stabiele code (businesslogica). Het idee daarachter is dat vendor code minder vaak verandert, zodat er minder bestanden mee zouden cascaderen.

Maar daarmee los je de kern van het probleem niet op, je verzacht het alleen een beetje. Je krijgt nog steeds cascaderende hashes binnen je businesslogica-chunks. Ik wilde iets dat het echte probleem aanpakt in plaats van het alleen iets minder erg te maken.

Import Maps: de moderne oplossing

Import Maps zijn een browser-native feature (met een polyfill voor oudere browsers) die modulespecifiers loskoppelt van bestandspaden. In plaats van dat bestand A "./button-abc123.js" importeert, importeert het gewoon "button". De browser gebruikt de import map om "button" naar de echte gehashte bestandsnaam te vertalen.

Dat is precies wat ik nodig had. De inhoud van bestand A blijft identiek (het importeert altijd "button"), dus de hash blijft ook hetzelfde. Alleen de import map en het gewijzigde bestand krijgen een nieuwe hash. Ik was eerlijk gezegd best verbaasd dat nog niemand hier een goede plugin voor had gemaakt.

De weg naar de implementatie

Ik besloot een Vite-plugin te bouwen die het volgende zou doen:

  1. Alle relatieve imports omzetten naar stabiele modulespecifiers
  2. Een import map genereren die die specifiers koppelt aan de echte gehashte bestandsnamen
  3. De import map in de HTML injecteren

De plugin staat nu op GitHub: @foony/vite-plugin-import-map

Eerste aanpak

Ik begon met een Vite-plugin die de generateBundle-hook gebruikte. In mijn eerste poging gebruikte ik regex om importpaden te vinden en te vervangen. Dat was makkelijk te schrijven en werkte prima voor ons kleine team bij Foony, maar het was erg fragiel en zou zeker niet betrouwbaar zijn in een plugin, waar er eerder vals-positieve matches zijn die dan per ongeluk worden aangepast.

De regex-aanpak had duidelijke problemen: wat als een willekeurige string in je code toevallig op een bestandsnaam leek? Hoe zit het met dynamische imports? En met exportstatements? Ik had een robuustere oplossing nodig als ik hier een plugin voor anderen van wilde maken.

AST-parsing

Ik moest de JavaScript-code echt goed parsen om alle importstatements te kunnen vinden. Mijn eerste poging was met es-module-lexer, dat speciaal gemaakt is om ES-modules te parsen. Helaas veroorzaakte dat native panics tijdens Vites module-analysefase. Zelfs de asm.js-build gebruiken hielp niet om die panics te voorkomen.

Uiteindelijk ben ik bij Acorn uitgekomen, een snelle, lichte parser in pure JavaScript. In combinatie met acorn-walk voor het traverseren van de AST gaf dat me alles wat ik nodig had, zonder gezeur met native dependencies.

Belangrijkste uitdagingen die ik heb opgelost

Alle soorten imports afhandelen

Imports komen in allerlei vormen voor en worden in de AST ook op verschillende manieren weergegeven. Ik moest het volgende ondersteunen:

  • Statische imports: import x from "./file.js"
  • Dynamische imports: import("./file.js")
  • Named re-exports: export { x } from "./file.js" (die was ik in het begin vergeten!)
  • Re-export all: export * from "./file.js"

Het re-exportgeval was vooral verraderlijk, omdat ik het pas in de gaten kreeg toen ik een bestand zag dat helemaal niet werd getransformeerd. In die code stond export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" en mijn plugin negeerde dat volledig, omdat ik alleen naar ImportDeclaration- en ImportExpression-nodes keek.

Zo pak ik ze nu allemaal aan:

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
  },
});

Deterministische conflictafhandeling

Als meerdere bestanden dezelfde bestandsnaam hebben (zoals verschillende index.tsx-bestanden in verschillende mappen), moet ik die uit elkaar kunnen houden. Ik kan niet simpelweg "index" voor allemaal gebruiken.

Mijn oplossing: als er een conflict is, hash ik het originele bronpad plus de bestandsnaam. Bijvoorbeeld, src/client/games/chess/index.tsx:index wordt gehasht en zo ontstaat index-abc123. Daardoor krijgt hetzelfde bestand in alle builds altijd dezelfde modulespecifier, zelfs als er andere bestanden met dezelfde naam bijkomen of verdwijnen.

Ik gebruik chunk.facadeModuleId (de entrypoint) als primaire identifier en val terug op chunk.moduleIds[0] als die er niet is. Zo krijg ik een stabiel bronpad dat ik kan gebruiken voor deterministische hashing.

Source-map-chaining

Als ik de code transformeer, doorbreek ik de sourcemap-keten. De bestaande sourcemap verwijst al van de originele TypeScript-bron via Babel en minificatie naar de huidige code. Mijn transformaties voegen daar weer een laag bovenop, dus ik moet die keten in stand houden.

Ik gebruik MagicString om mijn transformaties bij te houden en een nieuwe sourcemap te genereren. Die merge ik vervolgens met de bestaande map door de originele arrays sources en sourcesContent te behouden. Zo blijft de hele keten intact: originele bron → (bestaande map) → getransformeerde code.

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,
};

Getransformeerde content opnieuw hashen

Ik heb stabiele bestandsinhoud nodig. Om dat te bereiken transformeer ik eerst de imports (ik vervang Vites gehashte imports door mijn stabiele imports) en haal ik daarna de sourcemap-comments weg uit de hash-berekening (die verwijzen nog naar oude bestandsnamen).

Daarna bereken ik een nieuwe hash en werk ik zowel de bestandsnaam als de entry in de import map bij.

De uiteindelijke implementatie

De plugin gebruikt een strategie in vier passes:

  1. Count pass: naamconflicten detecteren door te tellen hoeveel bestanden dezelfde bestandsnaam delen
  2. Map pass: de chunk-mapping maken (gehashte bestandsnaam → modulespecifier) en de eerste versie van de import map opbouwen
  3. Transform pass: importpaden in de code herschrijven, hashes opnieuw berekenen en sourcemaps bijwerken
  4. Rename pass: bundlebestandsnamen updaten en de import map afronden

Dit is de kern van de transformatielogica:

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);
}

Om de import map in de HTML te injecteren gebruik ik Vites tag injection-API in plaats van regex-manipulatie:

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

Dat is een stuk betrouwbaarder dan proberen HTML-tags met een regex te matchen.

In cijfers

Om je een idee te geven wat deze plugin doet:

  • ±1.000+ JavaScript-bestanden verwerkt per build
  • ±2-3 seconden extra buildtijd (prima compromis)
  • ±99% minder onnodige hash-wijzigingen (de meeste bestanden veranderen nu alleen nog als hun inhoud echt wijzigt)
  • ±340 regels plugincode (inclusief comments en errorhandling)

De plugin vangt alle edgecases op die ik tot nu toe ben tegengekomen, en het buildproces is nu veel beter voorspelbaar.

Wat ik hiervan heb geleerd

Waarom AST-parsing essentieel is

Regex loslaten op gebundelde code is gevaarlijk. Als een string in je code toevallig op een bestandsnaam lijkt, gaat je regex die gewoon herschrijven. Met AST-parsing zorg je dat je alleen echte import- en exportstatements aanpast.

Waarom Acorn en niet es-module-lexer

es-module-lexer is sneller en meer to-the-point gebouwd, maar de native panics maakten het onbruikbaar in de context van mijn Vite-plugin. Acorn is pure JavaScript, dus je hoeft je geen zorgen te maken over native dependencies. In de toekomst wil ik es-module-lexer nog wel eens opnieuw bekijken als snelheidsoptimalisatie, maar voor nu werkt Acorn perfect.

Waarom Import Maps in plaats van alternatieven

Import Maps zijn een webstandaard met native browsersupport. Het is de „juiste” manier om dit probleem op te lossen. De polyfill (es-module-shims) vangt oudere browsers (bijvoorbeeld Safari < 16.4) netjes op en de oplossing blijft schoon en goed onderhoudbaar.

Conclusie

De Import Maps-plugin voorkomt nu succesvol cascaderende hash-wijzigingen in mijn Vite-builds. Bestanden krijgen alleen nog een nieuwe hash als hun eigenlijke inhoud verandert, niet wanneer hun dependencies veranderen. Dat maakt builds beter voorspelbaar, vermindert onnodige cache-invalidation en helpt ons onder de bestandslimieten van Cloudflare Pages te blijven.

De oplossing is simpel, goed onderhoudbaar en gebruikt moderne webstandaarden. Het is een mooi voorbeeld van hoe de „juiste” oplossing soms ook de simpelste is, zodra je het probleem maar diep genoeg begrijpt.

De plugin is open source en staat op GitHub: @foony/vite-plugin-import-map. Je kunt hem installeren met npm install @foony/vite-plugin-import-map en direct in je eigen Vite-projecten gebruiken.

Mogelijke verbeteringen voor de toekomst zijn bijvoorbeeld optimaliseren met es-module-lexer zodra de native panics zijn opgelost, of ondersteuning toevoegen voor nog complexere import-scenario’s. Maar voor nu doet de plugin precies wat ik nodig heb.

En wie weet, misschien ondersteunt Vite dit ooit gewoon standaard.

8 Ball Pool online multiplayer billiards icon