

1/1/1970
मैंने Import Maps की मदद से Cascading Hash Changes कैसे सुलझाए
हेलो! ये प्रॉब्लम मेरे साथ 5+ साल से थी, लेकिन अभी हाल में मैंने फैसला किया कि अब इसे सच में ठीक करना ही पड़ेगा, क्योंकि ये इतनी बढ़ गई थी कि अब इसे इग्नोर करना पॉसिबल ही नहीं था। किसी एक फ़ाइल में बस एक कैरेक्टर बदलने पर भी, मेरे बिल्ड में आधी JavaScript फ़ाइलों के hashed filenames बदल जाते थे, जबकि उनकी असली कंटेन्ट में कोई बदलाव नहीं हुआ होता था। इससे बेवजह cache invalidate हो रहा था, यह ट्रैक करना लगभग नामुमकिन हो जाता था कि आखिर दो बिल्ड्स के बीच क्या बदला, और सबसे बुरा: Cloudflare Pages पर फ़ाइल लिमिट की वजह से मेरे बिल्ड्स टूटने लगे थे।
नीचे मैं ये समझाऊँगा कि प्रॉब्लम क्या थी, दूसरे सॉल्यूशन मेरे लिए क्यों काम नहीं आए, और मैंने Import Maps यूज़ करके कस्टम Vite प्लगिन कैसे बनाया जिसने इसे हमेशा के लिए सुलझा दिया।
समस्या: Cascading Hash Changes
Vite प्रोडक्शन बिल्ड्स के लिए content-based hashing यूज़ करता है। जब तुम अपना ऐप बिल्ड करते हो, हर JavaScript फ़ाइल के filename में उसके content के बेस पर एक hash लग जाता है। अगर button.tsx कम्पाइल होकर button-abc12345.js बनती है, और फिर कंटेन्ट बदलता है, तो वो button-def45678.js बन जाएगा। कैश bust करने के लिए ये काफ़ी बढ़िया है, यूज़र्स को फ़ाइल बदलने पर नई फ़ाइल मिल जाती है।
समस्या तब आती है जब File A, File B को import करती है। मान लो तुम्हारे पास ये है:
// main.js
import { Button } from "./button-abc12345.js";
जब button.tsx बदलती है, Vite button-def45678.js जनरेट करता है। लेकिन अब main.js भी बदल जाता है क्योंकि उसके अंदर "./button-abc12345.js" वाली string है, जो अब गलत हो गई। तो main.js को भी नया hash मिल जाता है, जबकि main.js की असली logic में तो कुछ भी नहीं बदला।
ये पूरा तुम्हारे dependency graph में कैस्केड हो जाता है। एक utility function बदलो, और अचानक आधी js फ़ाइलों के hashes बदल जाते हैं। मेरे केस में, useBackgroundMusic.ts में सिर्फ़ एक कैरेक्टर बदलने से 500 से ज़्यादा फ़ाइलें फिर से re-hash हो गईं।
रियल वर्ल्ड में इसका असर काफ़ी बड़ा था। हम अपने पुराने बिल्ड्स के assets की 8 वर्ज़न bundle करके रखते हैं ताकि जो यूज़र थोड़ा पुराने वर्ज़न पर हों, वो भी नए वर्ज़न को Cloudflare Pages पर deploy करने के बाद अपना पुराना वर्ज़न चला पाएं। लेकिन Cloudflare Pages में 20,000 फ़ाइलों की लिमिट है, और हम ये लिमिट हिट करने लगे थे क्योंकि हमारा पहले वाला i18n बदलाव फ़ाइलों की गिनती को धड़ाधड़ बढ़ा चुका था।
Cascading hashes सॉल्व करने से हम बहुत ज़्यादा पुराने बिल्ड्स स्टोर कर सकते हैं बिना इन लिमिट्स से टकराए, क्योंकि अब ज़्यादातर फ़ाइलों को बदलने की ज़रूरत ही नहीं पड़ती। इससे ये चांस भी बहुत कम हो जाता है कि कोई यूज़र stale बिल्ड पर error देखे, क्योंकि अब ज़्यादा संभावना है कि वो ऐसी फ़ाइल माँगेगा जो अब नहीं बदली और जो हमारे पास मौजूद है।
क्यों नहीं [वैकल्पिक सॉल्यूशन्स]?
जब मैंने पहली बार ये सॉल्व करने के बारे में सोचा, तो मेरे दिमाग में कुछ तरीके आए। लेकिन कोई भी पूरी तरह फिट नहीं बैठा।
Post-build Scripts
सबसे पहले ख्याल आया कि एक post-build स्क्रिप्ट लिखूँ जो सारे import paths को normalize कर दे, फ़ाइलों को फिर से hash करे और references अपडेट कर दे। सुनने में सीधा सा लगता है, बस regex से hashed filenames को स्टेबल नामों से replace करो, फिर hash दोबारा निकाल लो।
मैंने ये तरीका "Heisenbugs" और cache poisoning के डर से रिजेक्ट कर दिया। भले ही हम Cloudflare Pages पर पुराने बिल्ड्स स्टोर करते हैं, लेकिन cache inconsistency का रिस्क मेरे लिए वर्थ नहीं था। बिल्ड के बाद फ़ाइलों को मॉडिफाई करने वाली स्क्रिप्ट बहुत सूक्ष्म बग्स ला सकती है जो सिर्फ़ production में दिखें, और ऐसे बग्स को debug करना एक बुरे सपने जैसा होता।
Vite manualChunks
दूसरा ऑप्शन Vite की manualChunks कॉन्फ़िगरेशन यूज़ करने का था, ताकि स्टेबल कोड (जैसे node_modules) को unstable कोड (business logic) से अलग किया जा सके। आइडिया ये था कि vendor कोड कम बदलेगा, तो कम फ़ाइलों में cascading होगा।
ये असली समस्या को सॉल्व ही नहीं करता, बस उसे थोड़ा कम दर्दनाक बनाता है। तुम्हें अभी भी अपने business logic chunks के अंदर cascading hashes मिलते हैं। मैं ऐसा सॉल्यूशन चाहता था जो कोर इश्यू को ही पकड़ कर सुलझाए, न कि उसे थोड़ा कम खराब कर दे।
Import Maps: मॉडर्न सॉल्यूशन
Import Maps एक browser-native फीचर हैं (पुराने ब्राउज़र्स के लिए polyfill सपोर्ट के साथ) जो module specifiers को file paths से अलग कर देता है। File A "./button-abc123.js" import करने के बजाय "button" import करती है। ब्राउज़र import map देखकर "button" को असली hashed filename से resolve करता है।
मुझे बिल्कुल यही चीज़ चाहिए थी। File A का कंटेन्ट बिलकुल सेम रहता है (वो हमेशा "button" ही import करती है), तो उसका hash भी सेम रहता है। सिर्फ़ import map और जो बदली हुई फ़ाइल है, वही नए hashes पाते हैं। मुझे सच में थोड़ा अजीब लगा कि पहले से किसी ने इसके लिए एक अच्छा प्लगिन नहीं बनाया था!
Implementation की जर्नी
मैंने सोचा कि मैं एक Vite प्लगिन बनाऊँ जो ये काम करे:
- सारी relative imports को स्टेबल module specifiers में बदले
- एक import map जनरेट करे जो उन specifiers को असली hashed filenames से मैप करे
- उस import map को HTML में inject करे
प्लगिन अब GitHub पर उपलब्ध है: @foony/vite-plugin-import-map
पहला तरीका
मैंने generateBundle hook यूज़ करके Vite प्लगिन से शुरुआत की। पहली कोशिश में मैंने regex से import paths ढूँढने और replace करने की ट्रिक यूज़ की। कोड लिखने में आसान था और हमारी छोटी टीम Foony के लिए तो काम कर भी रहा था, लेकिन ये काफ़ी fragile था और प्लगिन की दुनिया में ये पक्का गड़बड़ करता, जहाँ false-positives भी mutate हो सकते हैं।
Regex वाला तरीका कई साफ़ समस्याएँ लेकर आता है: अगर कोड के अंदर कोई string वैसे ही दिखे जैसे filename दिखता है तो? dynamic imports का क्या? export statements का क्या? अगर मुझे दूसरों के लिए प्लगिन बनाना था, तो मुझे ज़्यादा भरोसेमंद तरीका चाहिए था।
AST Parsing
मुझे JavaScript कोड को ठीक से parse करना था ताकि मैं सारी import statements पकड़ सकूँ। पहली कोशिश थी es-module-lexer, जो खास तौर पर ES modules को parse करने के लिए बनी है। लेकिन Vite के module analysis phase के दौरान ये native panics करने लगी। asm.js build ट्राय करने पर भी panics रुक नहीं रहे थे।
आखिर में मैंने Acorn चुना, जो तेज़, हल्का और pure JavaScript parser है। acorn-walk के साथ मिलकर AST traversal के लिए इसने मुझे वो सब दे दिया जो मुझे चाहिए था, और native dependency वाली टेंशन भी नहीं रही।
कौन-कौन सी चुनौतियाँ सॉल्व करनी पड़ीं
सारे Import Types संभालना
Imports कई तरह के होते हैं, और AST में सबको अलग-अलग तरह से ट्रीट किया जाता है। मुझे ये सब संभालने थे:
- Static imports:
import x from "./file.js" - Dynamic imports:
import("./file.js") - Named re-exports:
export { x } from "./file.js"(ये मैं शुरू में मिस कर गया!) - Re-export all:
export * from "./file.js"
Re-export वाला केस ख़ास तौर पर tricky था, क्योंकि मैं इसे तब तक मिस करता रहा जब तक मुझे एक फ़ाइल नहीं दिखी जो ट्रांसफॉर्म हो ही नहीं रही थी। उस कोड में ये था:export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js"
और मेरा प्लगिन इसे बिलकुल इग्नोर कर रहा था, क्योंकि मैं सिर्फ़ ImportDeclaration और ImportExpression नोड्स देख रहा था।
अब मैं इन सबको ऐसे हैंडल करता हूँ:
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
},
});
Deterministic Conflict Resolution
जब कई फ़ाइलों का base name सेम हो (जैसे अलग-अलग directories में कई index.tsx फाइलें), तो मुझे उन्हें अलग-अलग पहचानना होता है। मैं सबके लिए बस "index" यूज़ नहीं कर सकता।
मेरा सॉल्यूशन: अगर कॉन्फ़्लिक्ट हो, तो मैं original source path + base name को hash कर देता हूँ। जैसे src/client/games/chess/index.tsx:index को hash करके index-abc123 जैसा नाम मिलता है। इससे ये पक्का हो जाता है कि हर बिल्ड में वही फ़ाइल हमेशा वही module specifier पाएगी, चाहे बाद में सेम नाम वाली और फाइलें आ जाएँ या हट जाएँ।
मैं primary identifier के तौर पर chunk.facadeModuleId (entry point) यूज़ करता हूँ, और अगर वो न हो तो chunk.moduleIds[0] पर fallback कर लेता हूँ। इससे मुझे deterministic hashing के लिए स्टेबल source path मिल जाता है।
Source Map Chaining
जब मैं कोड ट्रांसफॉर्म करता हूँ, तो मैं source map की chain तोड़ देता हूँ। existing source map, original TypeScript source से होते हुए Babel और minification के जरिए अभी वाले कोड तक मैप करता है। मेरी transformation एक और लेयर जोड़ देती है, तो मुझे वो chain बचानी पड़ती है।
इसके लिए मैं MagicString यूज़ करता हूँ ताकि अपनी transformations को track कर सकूँ और नया source map बना सकूँ। फिर मैं उसे existing map से merge करता हूँ, existing map की sources और sourcesContent arrays को प्रिज़र्व करके। इससे पूरी chain बची रहती है: Original Source → (existing map) → Transformed 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,
};
Transformed Content को दोबारा hash करना
मुझे फ़ाइल का कंटेन्ट स्टेबल चाहिए था। इसके लिए मैं imports को ट्रांसफॉर्म करता हूँ (Vite के hashed imports की जगह अपने स्टेबल imports लगा देता हूँ), और फिर hash निकालने से पहले source map comments हटा देता हूँ (क्योंकि उनमें पुराने filenames का रेफरेंस होता है)।
उसके बाद मैं नया hash निकालता हूँ, और filename के साथ-साथ import map entry भी अपडेट कर देता हूँ।
Final Implementation
प्लगिन चार-पास वाली strategy यूज़ करता है:
- Count pass: ये देखना कि कितनी फ़ाइलों का base name सेम है, ताकि name collisions पकड़ सकूँ
- Map pass: chunk mapping बनाना (hashed filename → module specifier) और initial import map तैयार करना
- Transform pass: कोड के अंदर import paths को rewrite करना, hashes दोबारा निकालना, source maps अपडेट करना
- Rename pass: bundle filenames अपडेट करना और import map को final रूप देना
Core transformation logic कुछ ऐसा दिखता है:
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);
}
HTML में import map inject करने के लिए मैं regex से छेड़छाड़ करने के बजाय Vite का tag injection API यूज़ करता हूँ:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
HTML को regex से मैच करने की कोशिश करने से ये तरीका कहीं ज़्यादा भरोसेमंद है।
By the Numbers
ज़रा नंबरों से समझते हैं कि ये प्लगिन क्या कर रहा है:
- ~1,000+ JavaScript फाइलें हर बिल्ड में प्रोसेस होती हैं
- ~2–3 सेकंड बिल्ड टाइम में extra लगते हैं (मेरे लिए ये trade-off ठीक है)
- ~99% कमी बेवजह hash changes में (अब ज़्यादातर फ़ाइलें तभी बदलती हैं जब उनका असली कंटेन्ट बदलता है)
- ~340 लाइन्स प्लगिन कोड (comments और error handling समेत)
अब तक मिले जितने भी edge cases थे, ये प्लगिन उन्हें हैंडल कर रहा है, और बिल्ड प्रोसेस अब काफी प्रेडिक्टेबल लगने लगा है।
क्या सीखा मैंने
क्यों AST parsing ज़रूरी है
Bundled कोड पर regex चलाना ख़तरनाक है। अगर कोड के अंदर कोई string गलती से filename जैसी दिख गई, तो regex उसे भी rewrite कर देगा। AST parsing ये पक्का करता है कि तुम सिर्फ़ असली import/export statements ही ट्रांसफॉर्म करो।
क्यों Acorn, es-module-lexer से बेहतर निकला
es-module-lexer तेज़ है और इसी काम के लिए बना है, लेकिन native panics की वजह से Vite प्लगिन के कॉन्टेक्स्ट में ये practically unusable था। Acorn pure JavaScript है, मतलब कोई native dependency वाली टेंशन नहीं। आगे चलकर मैं perf के लिए es-module-lexer फिर से देखना चाहूँगा, लेकिन अभी के लिए Acorn बहुत बढ़िया काम कर रहा है।
क्यों Import Maps, दूसरे तरीकों से बेहतर हैं
Import Maps वेब स्टैंडर्ड हैं और मॉडर्न ब्राउज़र्स में native सपोर्ट है। ये इस प्रॉब्लम के लिए "सही" तरीका है। जो पुराने ब्राउज़र्स हैं (जैसे Safari < 16.4), उनके लिए polyfill (es-module-shims) अच्छी तरह काम कर लेता है, और पूरा सॉल्यूशन साफ़ और maintainable रहता है।
नतीजा
Import Maps वाला ये प्लगिन मेरे Vite बिल्ड्स में होने वाले cascading hash changes को रोकने में सफल रहा। अब फ़ाइलों को नया hash सिर्फ़ तब मिलता है जब उनका असली कंटेन्ट बदलता है, न कि जब उनकी dependency बदल जाए। इससे बिल्ड्स ज़्यादा प्रेडिक्टेबल हो गए, बेकार वाली cache invalidation कम हो गई, और Cloudflare Pages की file limit के अंदर रहना आसान हो गया।
सॉल्यूशन सिंपल है, maintainable है, और मॉडर्न वेब स्टैंडर्ड्स पर टिका है। ये एक अच्छी मिसाल है कि कभी-कभी "सही" सॉल्यूशन वही होता है जो सबसे सिंपल भी निकले, बस तुम्हें प्रॉब्लम को इतना अच्छे से समझना पड़ता है कि वो साफ़ दिखाई देने लगे।
प्लगिन ओपन सोर्स है और GitHub पर उपलब्ध है: @foony/vite-plugin-import-map. तुम इसे npm install @foony/vite-plugin-import-map से इंस्टॉल कर सकते हो और अपने Vite प्रोजेक्ट्स में यूज़ कर सकते हो।
आगे चलकर मैं शायद es-module-lexer के साथ optimization ट्राय करूँगा, जब native panics वाला इश्यू सॉल्व हो जाए, या फिर और complex import scenarios के लिए सपोर्ट जोड़ूँगा। लेकिन फिलहाल, प्लगिन वही कर रहा है जिसकी मुझे ज़रूरत थी।
और कौन जाने, शायद कभी Vite खुद ही ऐसा कुछ native तरीक़े से सपोर्ट करने लगे।