D
P
0
← Semua artikel Read in English

Next.js & React di Produksi

Astro Menyimpan Gambar Asli 19 MB di `_astro` Tanpa Jejak di Log? Pemicunya Satu Pembacaan `.width` dari Modul Glob

· · 8 menit baca
Astro Menyimpan Gambar Asli 19 MB di `_astro` Tanpa Jejak di Log? Pemicunya Satu Pembacaan `.width` dari Modul Glob

Sampul di blog ini dulu dikirim 1600px ke semua tempat, dari kartu di halaman indeks sampai sampul di halaman artikel. Audit tanggal 8 September 2026, sesudah domain dialihkan, mencatatnya di antara pekerjaan yang belum dikerjakan: 238 sampul berukuran 1600x894 dikirim ke kotak selebar sekitar 230px tanpa srcset sama sekali, sehingga satu halaman /blog memegang 20,5 MB gambar. Catatan yang sama menyebut sampul itu pos byte terbesar di seluruh situs, jauh melampaui JavaScript.

Sebabnya bukan misteri. Sampul-sampul itu duduk di public/, jadi astro:assets tidak pernah memprosesnya, padahal di repo yang sama jalur astro:assets sudah terbukti jalan untuk gambar projek. Perbaikannya tinggal memakai ulang pola itu, dan akhirnya memang itu yang dipakai. Masalahnya, percobaan pertama meninggalkan satu berkas lebih banyak per sampul daripada yang diminta, dan berkas itu nyaris lolos tanpa ketahuan.

Pola yang sudah bersih di halaman projek

Keputusan soal gambar sudah diambil lebih dulu: astro:assets cukup, tanpa skrip dan tanpa dependensi baru, karena sharp sudah ada di pohon dependensi Astro. Halaman daftar projek sudah memakainya sebelum sampul. Skrip terpisah untuk membuat thumbnail kecil tidak jadi dibuat, dan thumbnail-nya diturunkan lewat astro:assets dalam dua lebar. Bentuknya glob eager ke berkas di public/, lalu getImage per lebar dengan modul.default sebagai sumber:

// src/pages/projects/index.astro, potongan
const sumberThumb = import.meta.glob<{ default: ImageMetadata }>(
  '../../../public/images/thumbnail/*.webp',
  { eager: true },
);
// ...
  const modul = sumberThumb[`../../../public${proyek.image}`];
  if (!modul) continue;
  const kecil = await getImage({ src: modul.default, width: 320, format: 'webp' });
  const sedang = await getImage({ src: modul.default, width: 960, format: 'webp' });

Hasilnya bersih. Baris daftar /projects turun dari 4,15 MB menjadi 0,17 MB, 96 persen, tanpa menambah satu berkas pun ke public/.

Sampul memakai pola yang sama, glob eager ke berkas sampul lalu getImage per lebar dengan src: modul.default, hanya saja lebarnya empat. Lebar itu diambil dari kotak yang benar-benar ada di CSS dan diperiksa ulang di peramban, lalu dikali dua untuk layar rapat, sehingga jatuh di 320, 640, 960, dan 1440.

Empat lebar diminta, lima berkas keluar

Masalahnya terukur pada 9 September 2026, saat srcset untuk 238 sampul itu dipasang. Empat lebar diminta per sampul, tetapi keluarannya lima berkas per sampul. Berkas kelima adalah berkas asli 1600px yang ikut tersalin ke /_astro/ dengan nama ber-hash, padahal nol halaman menunjuknya. Ongkosnya 19 MB per deployment, dan log build tidak memuat satu pun tanda, karena berkas itu tidak pernah digenerate, hanya disalin. Pesan commit perbaikannya menyebut ini jebakan yang hampir lolos.

Pemicunya satu baris yang kelihatan tidak berbahaya. Percobaan pertama mengambil dimensi asli sampul langsung dari modul gambarnya:

width: modul.default.width,     // <- ini pemicunya
height: modul.default.height,

Catatan saya menaruh halaman projek sebagai pembanding. Pola yang sama sudah berjalan di sana sejak thumbnail-nya ditangani astro:assets, dan halaman itu tidak pernah menyimpan berkas aslinya, karena dia hanya meneruskan modul.default tanpa membaca apa pun darinya.

Proxy yang mencatat setiap pembacaan

Penjelasannya ada di kode astro 7.3.1 yang terpasang, versi yang juga di-pin di package.json pada commit perbaikannya. Ceritanya mulai saat build. Kalau perintahnya build, plugin astro:assets:esm memancarkan isi berkas gambar yang diimpor lewat ESM sebagai aset lewat emitClientAsset, dan glob eager termasuk impor ESM. Jadi berkas asli memang ikut ditulis ke keluaran, dan nasibnya baru diputuskan belakangan.

Modul gambarnya sendiri dibungkus Proxy. Getter-nya ditulis sebagai template kode, jadi bagian ${...} diisi ketika plugin menghasilkan kode modul gambarnya:

// node_modules/astro/dist/assets/utils/proxy.js (astro 7.3.1), potongan
get(target, name, receiver) {
	if (name === 'clone') {
		return structuredClone(target);
	}
	if (name === 'fsPath') {
		return ${stringifiedFSPath};
	}
	${!isSSR ? `if (target[name] !== undefined && globalThis.astroAsset) globalThis.astroAsset?.referencedImages?.add(${stringifiedFSPath});` : ""}
	return target[name];
}

Untuk clone, getter mengembalikan structuredClone(target). Untuk fsPath, dia mengembalikan path berkas. Untuk properti lain yang nilainya tidak undefined, path berkas dicatat ke globalThis.astroAsset.referencedImages. Baris pencatatan itu hanya disisipkan kalau isSSR bernilai false, dan di vite-plugin-assets.js nilai yang diteruskan sebagai isSSR baru true kalau buildOutput adalah "server" dan lingkungan Vite-nya bernama ssr.

Daftar referencedImages itu dibaca di generate.js. Berkas asli di-unlink kalau originalSrcPath-nya tidak ada di referencedImages, dan pesannya dicatat lewat env.logger.debug, bukan info:

// node_modules/astro/dist/assets/build/generate.js (astro 7.3.1), potongan
if (transformsAndPath.originalSrcPath && !globalThis.astroAsset.referencedImages?.has(transformsAndPath.originalSrcPath)) {
  try {
    if (transformsAndPath.originalSrcPath) {
      env.logger.debug(
        "assets",
        `Deleting ${originalFilePath} as it's not referenced outside of image processing.`
      );
      await fs.promises.unlink(getFullImagePath(originalFilePath, env));
      // ...

Jadi Astro memang membuang berkas asli yang hanya dipakai sebagai bahan turunan, dan pembacaan .width membatalkan pembuangan itu. Satu pembacaan properti sudah cukup untuk membuat berkas 1600px dianggap dirujuk di luar pemrosesan gambar.

Dari sini pola halaman projek juga masuk akal. getImage di astro 7.3.1 membaca fsPath dan clone dari src gambar ESM, dua properti yang ditangani getter sebelum sampai ke baris pencatatan. Sesudah itu width dan height diambil dari hasil clone, dan hasil clone itulah yang dipakai sebagai src selanjutnya.

Log yang diam pun terjelaskan. Tiap turunan yang digenerate dicatat dengan env.logger.info, sedangkan penghapusan berkas asli hanya tercatat di tingkat debug. Berkas asli yang tertahan tidak pernah digenerate, jadi dia tidak punya baris sendiri. Pada tingkat log bawaan, keadaan benar dan keadaan salah mencetak baris turunan yang sama, dan tidak satu pun menyebut berkas aslinya.

Dimensi dari konstanta, modul diserahkan utuh

Aturannya sekarang satu: modul hasil glob hanya boleh diserahkan utuh ke getImage. Kalau butuh dimensi asli, ambil dari sumber lain, misalnya konstanta yang sudah diverifikasi ke berkasnya. Di src/lib/sampul.ts, dimensinya disimpan sebagai konstanta LEBAR_ASLI dan TINGGI_ASLI, dan fungsi hitung mengembalikan width dan height dari situ. Komentar di atas konstanta itu menulis bahwa dimensinya diukur langsung ke 238 berkas dan melarang menggantinya dengan modul.default.width:

// src/lib/sampul.ts, potongan
const LEBAR = [320, 640, 960, 1440];
 
/**
 * Dimensi asli seluruh sampul, diukur langsung ke 238 berkasnya.
 *
 * JANGAN diganti dengan `modul.default.width`. ...
 */
const LEBAR_ASLI = 1600;
const TINGGI_ASLI = 894;
 
// di dalam hitung()
  const varian = await Promise.all(
    LEBAR.map((lebar) => getImage({ src: modul.default, width: lebar, format: 'webp' })),
  );
  // ...
    width: LEBAR_ASLI,
    height: TINGGI_ASLI,

Komentar di berkas yang sama mencatat hasil ukurnya: 19 MB per deployment itu hilang seluruhnya begitu pembacaan properti dicabut. Riwayat git berkas ini hanya memuat satu commit, aefcf8b tertanggal 9 September 2026, dengan subjek "perf(blog): kirim sampul sesuai lebar kotaknya, bukan 1600px ke semuanya".

Menghitung berkas per gambar

Karena log tidak membantu, pemeriksaannya harus ke keluaran build. Hitung berapa berkas per gambar yang mendarat di dist/client/_astro. Kalau jumlahnya satu lebih banyak dari jumlah lebar yang diminta, yang lebih itu berkas aslinya. Catatan saya menyimpan dua keluaran pemeriksaan ini berdampingan: [(5, 238)] untuk keadaan salah, ketika ada yang membaca properti modul, dan [(4, 238)] untuk keadaan benar. Bacanya lima berkas untuk tiap sampul dari 238 sampul, lalu empat.

Di bawah ini versi shell sederhana untuk pemeriksaan serupa. Ini bukan skrip yang menghasilkan keluaran di atas. Dia mengelompokkan berkas .webp menurut bagian nama sebelum titik pertama, jadi dia hanya berguna kalau turunan dan aslinya berbagi awalan nama seperti itu. Lihat dulu pola nama di folder keluaranmu.

# versi sederhana: kelompokkan .webp menurut nama dasar, lalu hitung berkas per kelompok
ls dist/client/_astro/*.webp | sed -E 's#.*/##; s/\..*//' | sort | uniq -c | awk '{print $1}' | sort -n | uniq -c

Tiap baris keluarannya berisi dua angka: berapa gambar, lalu berapa berkas per gambar. Untuk gambar yang diminta dalam empat lebar, angka kedua seharusnya 4, bukan 5.

Harga turunan, dan berkas yang tidak membeli apa pun

Diukur dengan menjumlahkan byte kandidat yang benar-benar dipilih peramban, bukan lewat alat halaman, berat gambar indeks blog turun dari 19,57 MB menjadi 1,89 MB pada DPR1 dan 5,47 MB pada DPR2. Itu sekitar 90 persen lebih ringan pada DPR1 (1,89 dibagi 19,57 kira-kira 0,097) dan sekitar 72 persen pada DPR2 (5,47 dibagi 19,57 kira-kira 0,28). Hub topik turun dari 0,57 MB ke 0,08 MB, artikel dari 0,24 MB ke 0,03 MB, dan beranda dari 0,37 MB ke 0,05 MB. Angka awal 19,57 MB itu tidak sama dengan 20,5 MB di audit sehari sebelumnya. Keduanya datang dari dua pengukuran yang berbeda, dan saya laporkan dua-duanya apa adanya.

Turunan ini tidak gratis. Keluaran build naik dari 147 MB ke 189 MB, dan angka itu masuk kuota Deployment Storage. Build di Vercel naik dari 55 detik ke 2 menit. Kenaikan itu membeli halaman yang jauh lebih ringan. Berkas kelima tadi tidak membeli apa pun: 19 MB per deployment untuk berkas yang tidak dirujuk satu halaman pun.

Berkas asli di public/ sengaja tidak disentuh, karena og:image masih memakainya dan tautan kartu berbagi yang sudah tersebar tidak boleh mati. Jadi sampul 1600px tetap ada di situs untuk keperluan itu, dan salinan ber-hash di /_astro/ tadi hanyalah salinan tambahan. Turunannya sendiri mendarat di /_astro/, yang sudah dijaga immutable setahun, dan itu terverifikasi di situs hidup.

Satu aturan untuk modul hasil glob

Modul gambar dari import.meta.glob kelihatan seperti objek metadata biasa, tapi di astro 7.3.1 dia Proxy yang mencatat pembacaan properti selain clone dan fsPath. Satu pembacaan cukup untuk membuat berkas aslinya dianggap dipakai, dan Astro berhenti membuangnya dari keluaran tanpa satu baris pun di log. Yang aman dilakukan ke modul itu hanya menyerahkannya utuh ke getImage. Dimensi asli diambil dari konstanta yang sudah dicocokkan ke berkasnya, dan setiap kali pola ini disentuh, hitung berkas per gambar di keluaran build, karena log tidak akan bilang apa-apa.