background blurbackground mobile blur

1/1/1970

Bagaimana Aku Mengatasi Perubahan Hash Berantai dengan Import Maps

Halo! Masalah ini sudah menggangguku lebih dari 5 tahun, tapi baru sekarang aku memutuskan untuk benar-benar mengatasinya karena sudah sampai di titik yang tidak bisa lagi aku abaikan. Setiap kali aku mengubah satu karakter saja di satu file, setengah dari file JavaScript di build-ku tiba-tiba dapat nama file hash baru, padahal isi sebenarnya tidak berubah. Ini bikin cache jadi ter-invalidasi tanpa alasan, hampir mustahil melacak apa yang benar-benar berubah di antara satu build dan build berikutnya, dan yang paling parah: build Cloudflare Pages-ku sering gagal karena kena batas jumlah file.

Di bawah ini aku bakal jelasin rincian masalahnya, kenapa solusi-solusi yang sudah ada tidak cocok buatku, dan bagaimana aku membangun plugin Vite kustom yang memakai Import Maps untuk menyelesaikannya sekali dan untuk selamanya.

Masalahnya: Perubahan Hash yang Menjalar

Vite memakai hashing berbasis konten untuk build produksi. Saat kamu membuild aplikasi, setiap file JavaScript akan dapat hash di nama filenya yang dihitung dari isinya. Kalau button.tsx dikompilasi menjadi button-abc12345.js, lalu isi file berubah, namanya akan jadi button-def45678.js. Ini sangat bagus untuk cache busting, karena pengguna akan otomatis mendapatkan file baru ketika ada perubahan.

Masalahnya muncul ketika File A meng-import File B. Misalnya kamu punya:

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

Ketika button.tsx berubah, Vite akan membuat button-def45678.js. Tapi sekarang main.js juga ikut berubah karena di dalamnya ada string "./button-abc12345.js" yang sudah tidak benar lagi. Jadi main.js juga akan dapat hash baru, padahal logika di main.js sama sekali tidak berubah.

Efek ini menjalar ke seluruh graf dependensi kamu. Ubah satu fungsi utilitas saja, dan tiba-tiba setengah dari file JS kamu punya hash baru. Di kasusku, mengubah satu karakter di useBackgroundMusic.ts bisa membuat lebih dari 500 file di-hash ulang.

Dampaknya di dunia nyata lumayan besar. Kami membundel 8 versi aset dari build sebelumnya supaya pengguna yang masih memakai versi client yang sedikit ketinggalan tetap bisa menjalankan versi mereka ketika kami merilis versi baru ke Cloudflare Pages. Tapi Cloudflare Pages punya batas 20.000 file, dan kami mulai sering menyentuh batas itu sejak perubahan i18n kami sebelumnya yang membuat jumlah file yang kami hasilkan meledak.

Mengatasi hash berantai ini memungkinkan kami menyimpan jauh lebih banyak build lama tanpa kena batasan tadi, karena sekarang sebagian besar file tidak perlu berubah lagi. Ini juga mengurangi kemungkinan pengguna di build lama akan ketemu error, karena jauh lebih besar peluang mereka meminta file yang sekarang sudah stabil dan masih kami simpan.

Kenapa Bukan [Solusi Alternatif]?

Waktu pertama kali aku mencoba mencari solusi, aku mempertimbangkan beberapa pendekatan berbeda. Tapi tidak ada yang benar-benar terasa pas.

Script Pasca-build

Pemikiran awalku adalah menulis script pasca-build yang menormalkan semua path import, menghitung ulang hash file, lalu memperbarui semua referensi. Kedengarannya cukup lurus aja, tinggal pakai regex untuk mengganti nama file yang sudah di-hash dengan nama yang stabil, lalu hitung ulang hash-nya.

Aku mengurungkan pendekatan ini karena khawatir muncul "Heisenbugs" dan cache poisoning. Walaupun kami menyimpan build lama di Cloudflare Pages, risiko inkonsistensi cache terasa tidak sepadan. Script yang memodifikasi file setelah proses build bisa menambah bug halus yang cuma muncul di produksi, dan proses debugging-nya akan jadi mimpi buruk.

Vite manualChunks

Pilihan lain adalah memakai konfigurasi manualChunks di Vite untuk memisahkan kode yang relatif stabil (seperti node_modules) dari kode yang sering berubah (business logic). Idenya, kode vendor akan lebih jarang berubah, jadi lebih sedikit file yang ikut terkena efek berantai.

Sayangnya ini tidak benar-benar menyelesaikan akar masalahnya, hanya mengurangi efeknya sedikit. Kamu tetap akan dapat hash berantai di dalam chunk business logic. Aku maunya solusi yang menyentuh inti masalah, bukan cuma membuatnya sedikit kurang buruk.

Import Maps: Solusi Kekinian

Import Maps adalah fitur native di browser (dengan polyfill untuk browser yang lebih tua) yang memisahkan nama modul dari path file sebenarnya. Alih-alih File A meng-import "./button-abc123.js", sekarang ia meng-import "button". Browser memakai import map untuk menerjemahkan "button" ke nama file ber-hash yang sesungguhnya.

Ini persis yang aku butuhkan. Isi File A tetap sama persis (dia selalu meng-import "button"), jadi hash-nya juga tetap. Hanya import map dan file yang memang berubah yang akan dapat hash baru. Aku cukup kaget tidak ada yang sudah membuat plugin yang bagus untuk ini!

Perjalanan Implementasi

Aku akhirnya memutuskan untuk membuat plugin Vite yang akan:

  1. Mengubah semua import relatif supaya memakai nama modul yang stabil
  2. Membuat import map yang memetakan nama-nama modul itu ke nama file ber-hash yang sebenarnya
  3. Menyuntikkan import map tersebut ke dalam HTML

Plugin-nya sekarang tersedia di GitHub: @foony/vite-plugin-import-map

Pendekatan Awal

Aku mulai dengan plugin Vite yang memakai hook generateBundle. Coba pertama aku memakai regex untuk mencari dan mengganti path import. Ini gampang ditulis dan cukup bekerja untuk tim kecil kami di Foony, tapi rapuh banget dan jelas tidak layak dipakai di plugin umum, karena bisa saja ada false-positive yang ikut terubah.

Pendekatan regex punya masalah yang cukup jelas: bagaimana kalau ada string di dalam kode yang kebetulan mirip nama file? Bagaimana dengan dynamic import? Bagaimana dengan pernyataan export? Aku butuh solusi yang jauh lebih kokoh kalau benar-benar mau membuat plugin yang dipakai orang lain.

Parsing AST

Aku perlu mem-parse kode JavaScript dengan benar untuk menemukan semua pernyataan import. Coba pertama aku adalah memakai es-module-lexer, yang memang didesain khusus untuk mem-parse modul ES. Sayangnya, library ini menimbulkan native panic saat fase analisis modul di Vite. Bahkan ketika aku mencoba build asm.js-nya pun, panic-nya tetap muncul.

Akhirnya aku memilih Acorn, parser JavaScript murni yang cepat dan ringan. Dipadukan dengan acorn-walk untuk menelusuri AST, kombinasi ini memberikan semua yang aku butuhkan tanpa masalah dependency native.

Tantangan Utama yang Berhasil Dipecahkan

Menangani Semua Jenis Import

Import muncul dalam banyak bentuk, dan masing-masing direpresentasikan berbeda di AST. Aku perlu menangani:

  • Static import: import x from "./file.js"
  • Dynamic import: import("./file.js")
  • Named re-export: export { x } from "./file.js" (yang satu ini awalnya sempat kelewatan!)
  • Re-export all: export * from "./file.js"

Kasus re-export ini lumayan tricky, karena awalnya aku sama sekali melewatkannya sampai suatu saat aku melihat ada satu file yang tidak ikut ter-transform. Di dalamnya ada kode export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" dan plugin-ku benar-benar mengabaikannya, karena waktu itu aku hanya mencari node ImportDeclaration dan ImportExpression.

Begini caranya aku menangani semuanya sekarang:

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

Resolusi Konflik yang Deterministik

Kalau ada banyak file dengan nama dasar yang sama (misalnya beberapa file index.tsx di direktori yang berbeda), aku harus bisa membedakannya. Aku tidak bisa begitu saja memakai "index" untuk semuanya.

Solusiku: kalau ada konflik, aku membuat hash dari path sumber aslinya ditambah nama dasarnya. Contohnya, src/client/games/chess/index.tsx:index di-hash menjadi index-abc123. Ini memastikan file yang sama akan selalu mendapat nama modul yang sama di semua build, bahkan kalau nanti ada file lain dengan nama yang sama yang ditambah atau dihapus.

Aku memakai chunk.facadeModuleId (entry point-nya) sebagai pengenal utama, dan kalau itu tidak ada aku jatuh ke chunk.moduleIds[0]. Dengan begitu aku punya path sumber yang stabil untuk hashing yang deterministik.

Rantai Source Map

Saat aku mentransformasi kode, aku sebenarnya memutus rantai source map. Source map yang sudah ada menghubungkan dari source TypeScript asli, lewat Babel dan proses minifikasi, sampai ke kode saat ini. Transformasi tambahan dariku menambah satu lapisan lagi, jadi aku harus menjaga rantai itu tetap utuh.

Aku memakai MagicString untuk melacak transformasi dan menghasilkan source map baru. Lalu aku menggabungkannya dengan map yang sudah ada dengan mempertahankan array sources dan sourcesContent aslinya. Ini menjaga rantai lengkap: Source Asli → (map yang sudah ada) → Kode Hasil Transformasi.

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

Meng-hash Ulang Konten yang Sudah Ditransformasi

Aku butuh konten file yang stabil. Untuk itu, aku mentransformasi bagian import (mengganti import ber-hash milik Vite dengan import yang stabil versiku), lalu menghapus komentar source map dari perhitungan hash, karena komentar itu masih merujuk ke nama file lama.

Setelah itu, aku menghitung hash baru, lalu memperbarui nama file dan entri di import map.

Implementasi Akhir

Plugin-nya memakai strategi empat kali lintasan:

  1. Count pass: Mendeteksi bentrok nama dengan menghitung berapa banyak file yang berbagi tiap nama dasar
  2. Map pass: Membuat pemetaan chunk (nama file ber-hash → nama modul) dan import map awal
  3. Transform pass: Menulis ulang path import di dalam kode, menghitung ulang hash, memperbarui source map
  4. Rename pass: Memperbarui nama file bundle dan memfinalisasi import map

Berikut adalah logika transformasi intinya:

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

Untuk menyuntikkan import map ke dalam HTML, aku memakai API penyuntikan tag milik Vite, bukan mengutak-atik HTML dengan regex:

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

Ini jauh lebih bisa diandalkan dibanding mencoba mencocokkan tag HTML dengan regex.

Dalam Angka

Supaya kamu punya gambaran tentang apa yang dilakukan plugin ini:

  • ~1.000+ file JavaScript diproses setiap build
  • ~2–3 detik tambahan waktu build (menurutku trade-off yang sangat layak)
  • ~99% pengurangan perubahan hash yang tidak perlu (sekarang sebagian besar file hanya berubah ketika isi sebenarnya berubah)
  • ~340 baris kode plugin (termasuk komentar dan penanganan error)

Plugin ini sejauh ini sudah menangani semua edge case yang kutemui, dan proses build sekarang jadi jauh lebih mudah diprediksi.

Pelajaran yang Aku Dapat

Kenapa AST Parsing Itu Penting

Memakai regex di kode yang sudah dibundle itu berbahaya. Kalau ada string di kode yang kebetulan mirip nama file, regex bisa saja ikut mengubahnya. Dengan AST parsing, kamu bisa memastikan hanya pernyataan import/export yang benar-benar diubah.

Kenapa Memilih Acorn dibanding es-module-lexer

es-module-lexer sebenarnya lebih cepat dan lebih khusus dibuat untuk tugas ini, tapi masalah native panic membuatnya tidak bisa kupakai di konteks plugin Vite-ku. Acorn murni JavaScript, artinya tidak ada dependency native yang perlu dikhawatirkan. Mungkin nanti aku akan melirik lagi es-module-lexer sebagai optimasi kecepatan, tapi untuk sekarang Acorn sudah bekerja dengan sangat baik.

Kenapa Import Maps Dibanding Alternatif Lain

Import Maps adalah standar web dengan dukungan native di browser modern. Ini cara yang "benar" untuk menyelesaikan masalah ini. Polyfill-nya (es-module-shims) menangani browser lama (misalnya Safari < 16.4) dengan cukup mulus, dan solusi keseluruhannya tetap bersih serta mudah dirawat.

Kesimpulan

Plugin Import Maps ini berhasil mencegah perubahan hash berantai di build Vite-ku. Sekarang file hanya akan mendapat hash baru ketika isi sebenarnya berubah, bukan ketika dependensinya berubah. Ini membuat build lebih mudah diprediksi, mengurangi invalidasi cache yang tidak perlu, dan membantu kami tetap berada di bawah batas jumlah file Cloudflare Pages.

Solusinya sederhana, mudah dirawat, dan memakai standar web modern. Ini contoh bagus bahwa kadang solusi yang "benar" juga adalah yang paling sederhana, asalkan kamu cukup memahami masalahnya sampai ke akar.

Plugin ini open source dan tersedia di GitHub: @foony/vite-plugin-import-map. Kamu bisa menginstalnya dengan npm install @foony/vite-plugin-import-map dan langsung memakainya di proyek Vite-mu sendiri.

Pengembangan ke depan mungkin akan mencakup optimasi dengan es-module-lexer begitu masalah native panic terselesaikan, atau menambah dukungan untuk skenario import yang lebih kompleks. Tapi untuk saat ini, plugin ini sudah melakukan tepat seperti yang aku butuhkan.

Dan siapa tahu, suatu hari nanti Vite akan mendukung hal seperti ini secara native.

8 Ball Pool online multiplayer billiards icon