

1/1/1970
कैसे मैंने Import Maps के ज़रिए कैस्केडिंग हैश बदलावों की समस्या हल की
नमस्ते! यह समस्या मुझे 5 साल से ज़्यादा समय से परेशान कर रही थी, लेकिन अब जाकर इसे हल करने का फैसला किया क्योंकि अब इसे नज़रअंदाज़ करना मुमकिन नहीं था. जब मैं किसी एक फ़ाइल में सिर्फ़ एक अक्षर बदलता था, तो मेरे build में आधी JavaScript फ़ाइलों के नए हैश वाले फ़ाइलनाम बन जाते थे, जबकि उनका असली content बिल्कुल नहीं बदला होता था. इससे बिना वजह cache invalidation हो रहा था, यह जानना लगभग नामुमकिन हो गया था कि builds के बीच असल में क्या बदला, और सबसे बुरा यह था कि file limit की वजह से मेरे Cloudflare Pages के builds ही टूट रहे थे.
नीचे मैं समस्या को विस्तार से समझाऊँगा, बताऊँगा कि मौजूदा solutions मेरे लिए क्यों काम नहीं किए, और कैसे मैंने Import Maps का इस्तेमाल करके एक custom Vite plugin बनाया जिससे यह समस्या हमेशा के लिए हल हो गई.
समस्या: कैस्केडिंग हैश बदलाव
Vite production builds के लिए content-based hashing का इस्तेमाल करता है. जब आप अपना app build करते हैं, तो हर JavaScript फ़ाइल को उसके content के आधार पर एक हैश मिलता है जो उसके फ़ाइलनाम में जुड़ जाता है. अगर button.tsx कंपाइल होकर button-abc12345.js बनती है, और content बदलता है, तो वह button-def45678.js बन जाती है. यह cache busting के लिए बहुत बढ़िया है, content बदलने पर users को नई फ़ाइल मिलती है.
समस्या तब आती है जब फ़ाइल A, फ़ाइल B को import करती है. मान लीजिए आपके पास है:
// main.js
import { Button } from "./button-abc12345.js";
जब button.tsx बदलती है, तो Vite button-def45678.js बनाता है. लेकिन अब main.js भी बदल जाती है क्योंकि उसमें "./button-abc12345.js" string है, जो अब गलत हो गया है. तो main.js को भी नया हैश मिल जाता है, भले ही main.js का असली logic बिल्कुल न बदला हो.
यह आपके पूरे dependency graph में फैलता चला जाता है. एक utility function बदलिए, और अचानक आपकी आधी js फ़ाइलों को नए हैश मिल जाते हैं. मेरे केस में, useBackgroundMusic.ts में सिर्फ़ एक अक्षर बदलने से 500 से ज़्यादा फ़ाइलों को re-hash होना पड़ा.
असली दुनिया में इसका असर बहुत बड़ा था. हम अपने पिछले build के assets के 8 versions bundle करते हैं ताकि जो users हमारे client के थोड़े पुराने version पर हैं, वे अपने version को चला सकें जब हम Cloudflare Pages पर नया version deploy करते हैं. लेकिन Cloudflare Pages की 20,000 फ़ाइलों की एक limit है जिसे हम छूने लगे थे, और इसकी वजह थी हमारा हालिया i18n बदलाव, जिसने हमारी फ़ाइलों की संख्या बहुत बढ़ा दी थी.
कैस्केडिंग हैश की समस्या हल करने से हम बहुत ज़्यादा पुराने builds स्टोर कर सकते हैं बिना limits पार किए, क्योंकि अब ज़्यादातर फ़ाइलों को बदलने की ज़रूरत ही नहीं पड़ेगी. इससे यह भी कम हो जाता है कि किसी पुराने build पर मौजूद user को error मिले, क्योंकि अब बहुत ज़्यादा संभावना है कि वे ऐसी फ़ाइल माँगेंगे जो अब भी अनबदली है और हमारे पास मौजूद है.
[दूसरे Solutions] क्यों नहीं?
जब मैंने इसे हल करने के बारे में सोचा, तो कुछ तरीके दिमाग में आए. लेकिन कोई भी सही नहीं बैठा.
Post-build Scripts
मेरा पहला ख़याल था कि एक post-build script लिखूँ जो सभी import paths को normalize करे, फ़ाइलों को re-hash करे और references को update करे. यह आसान लगा था, बस regex से हैश वाले फ़ाइलनामों को stable नामों से बदल दो, फिर हैश दोबारा निकालो.
मैंने यह तरीका इसलिए छोड़ दिया क्योंकि "Heisenbugs" और cache poisoning का डर था. भले ही हम पुराने builds Cloudflare Pages में स्टोर करते हैं, cache inconsistencies का जोखिम उठाने लायक नहीं था. एक script जो build के बाद फ़ाइलें बदलती है, ऐसे subtle bugs ला सकती है जो सिर्फ़ production में दिखें, और उन्हें debug करना एक दुःस्वप्न होता.
Vite manualChunks
एक और option था Vite की manualChunks configuration का इस्तेमाल करना ताकि stable code (जैसे node_modules) को unstable code (business logic) से अलग किया जा सके. आइडिया यह था कि vendor code कम बदलता है, तो कम फ़ाइलें cascade होंगी.
यह असल में मूल समस्या हल नहीं करता, बस उसका असर थोड़ा कम करता है. आपके business logic chunks के अंदर अब भी कैस्केडिंग हैश मिलेंगे. मुझे ऐसा solution चाहिए था जो जड़ से समस्या ख़त्म करे, बस उसे थोड़ा कम बुरा न बनाए.
Import Maps: आधुनिक Solution
Import Maps एक browser-native feature है (पुराने browsers के लिए polyfill support के साथ) जो module specifiers को file paths से अलग कर देता है. फ़ाइल A "./button-abc123.js" import करने के बजाय, बस "button" import करती है. Browser import map का इस्तेमाल करके "button" को असली हैश वाले फ़ाइलनाम पर resolve करता है.
यही तो मुझे चाहिए था. फ़ाइल A का content वही रहता है (वह हमेशा "button" import करती है), इसलिए उसका हैश भी वही रहता है. सिर्फ़ import map और बदली हुई फ़ाइल को नए हैश मिलते हैं. मुझे थोड़ी हैरानी हुई कि इसके लिए किसी ने पहले से एक अच्छा plugin क्यों नहीं बनाया!
Vite Plugin बनाना
मैंने एक Vite plugin बनाने का फ़ैसला किया जो:
- सभी relative imports को stable module specifiers में बदलेगा
- एक import map generate करेगा जो उन specifiers को असली हैश वाले फ़ाइलनामों से जोड़ेगा
- import map को HTML में inject करेगा
Plugin अब GitHub पर उपलब्ध है: @foony/vite-plugin-import-map
शुरुआती तरीका
मैंने generateBundle hook के साथ Vite plugin से शुरुआत की. मेरी पहली कोशिश में regex से import paths ढूँढ़कर बदले जा रहे थे. इसे code करना आसान था और हमारी छोटी टीम Foony के लिए काम कर गया, लेकिन यह नाज़ुक था और एक plugin में तो बिल्कुल काम नहीं करता जहाँ false-positives आ सकते हैं और गलत बदल सकते हैं.
Regex वाले तरीके में साफ़ समस्याएँ थीं: अगर code में कोई string फ़ाइलनाम जैसी दिखे तो? Dynamic imports का क्या? Export statements का क्या? अगर मुझे दूसरों के लिए plugin बनाना था तो मुझे ज़्यादा मज़बूत solution चाहिए था.
AST Parsing
मुझे JavaScript code को सही तरह से parse करना था ताकि सभी import statements मिल जाएँ. मेरी पहली कोशिश थी es-module-lexer, जो ख़ास ES modules को parse करने के लिए बना है. दुर्भाग्य से, इसने Vite के module analysis phase के दौरान native panics दिए. asm.js build try करने पर भी panics नहीं रुके.
मैं Acorn पर टिक गया, जो एक तेज़, हल्का, pure JavaScript parser है. AST traversal के लिए acorn-walk के साथ मिलकर इसने मुझे वह सब दिया जो चाहिए था, बिना native dependency की समस्याओं के.
हल की गई मुख्य चुनौतियाँ
सभी प्रकार के Imports को संभालना
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 वाला case ख़ास तौर पर मुश्किल था क्योंकि यह मुझसे छूट गया था, फिर एक ऐसी फ़ाइल दिखी जो transform नहीं हो रही थी. Code में था export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js" और मेरा plugin उसे पूरी तरह नज़रअंदाज़ कर रहा था क्योंकि मैं सिर्फ़ ImportDeclaration और ImportExpression nodes ढूँढ़ रहा था.
अब मैं इन सभी को इस तरह संभालता हूँ:
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" इस्तेमाल नहीं कर सकता.
मेरा solution: अगर conflict हो, तो मैं original source path और base name को मिलाकर हैश करता हूँ. उदाहरण के लिए, src/client/games/chess/index.tsx:index को हैश करके index-abc123 बनाया जाता है. इससे यह तय होता है कि एक ही फ़ाइल को builds के बीच हमेशा वही module specifier मिले, चाहे उसी नाम की दूसरी फ़ाइलें जोड़ी या हटाई जाएँ.
मैं primary identifier के रूप में chunk.facadeModuleId (entry point) का इस्तेमाल करता हूँ, और अगर वह उपलब्ध न हो तो chunk.moduleIds[0] पर fallback कर देता हूँ. इससे deterministic hashing के लिए मुझे एक stable source path मिल जाता है.
Source Map Chaining
जब मैं code को transform करता हूँ, तो source map की chain टूट जाती है. मौजूदा source map original TypeScript source से Babel और minification होते हुए मौजूदा code तक map करता है. मेरे transformations एक और परत जोड़ते हैं, इसलिए मुझे उस chain को बचाना होता है.
मैं अपने transformations को track करने और नया source map बनाने के लिए MagicString इस्तेमाल करता हूँ. फिर मैं उसे मौजूदा map के साथ merge करता हूँ, original 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 को Re-hash करना
मुझे stable file content चाहिए था. इसके लिए मैं imports को transform करता हूँ (Vite के हैश वाले imports को अपने stable imports से बदल देता हूँ), और फिर हैश calculation से source map comments हटा देता हूँ (वे पुराने फ़ाइलनामों को reference करते हैं).
उसके बाद, मैं नया हैश निकालता हूँ, और फ़ाइलनाम और import map entry दोनों update करता हूँ.
अंतिम Implementation
Plugin एक four-pass strategy इस्तेमाल करता है:
- Count pass: यह गिनकर name collisions का पता लगाता है कि कितनी फ़ाइलें एक ही base name share करती हैं
- Map pass: chunk mapping (हैश वाला फ़ाइलनाम → module specifier) और शुरुआती import map बनाता है
- Transform pass: code में import paths दोबारा लिखता है, हैश दोबारा निकालता है, source maps update करता है
- Rename pass: bundle फ़ाइलनाम update करता है और import map को finalize करता है
यह रहा 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 manipulation की जगह Vite की tag injection API इस्तेमाल करता हूँ:
transformIndexHtml() {
return {
tags: [
{
tag: 'script',
attrs: {type: 'importmap'},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
},
],
};
}
यह HTML tags को regex से match करने की कोशिश से कहीं ज़्यादा भरोसेमंद है.
आँकड़ों में
आपको अंदाज़ा देने के लिए कि यह plugin क्या करता है:
- ~1,000+ JavaScript फ़ाइलें per build process होती हैं
- ~2-3 सेकंड build time में जुड़ते हैं (मंज़ूर करने लायक trade-off)
- ~99% कमी बिना ज़रूरत के हैश बदलावों में (अब ज़्यादातर फ़ाइलें सिर्फ़ तब बदलती हैं जब उनका असली content बदलता है)
- ~340 lines plugin code की (comments और error handling समेत)
Plugin अब तक मिले सभी edge cases को संभाल लेता है, और build process अब बहुत ज़्यादा predictable है.
सीखी हुई बातें
AST parsing क्यों ज़रूरी है
Bundled code पर regex ख़तरनाक है. अगर आपके code में कोई string फ़ाइलनाम जैसी दिखे, तो regex उसे बदल देगा. AST parsing यह तय करती है कि आप सिर्फ़ असली import/export statements ही transform करें.
Acorn ही क्यों, es-module-lexer क्यों नहीं
es-module-lexer ज़्यादा तेज़ है और ख़ास इसी काम के लिए बना है, लेकिन native panic की समस्याओं ने इसे मेरे Vite plugin के context में बेकार बना दिया. Acorn pure JavaScript है, यानी native dependencies की कोई फ़िक्र नहीं. भविष्य में मैं es-module-lexer को speed optimization के लिए देखना चाहूँगा, लेकिन फ़िलहाल Acorn बढ़िया काम करता है.
Import Maps ही क्यों
Import Maps एक web standard हैं जिनका native browser support है. यह इस समस्या को हल करने का "सही" तरीका है. Polyfill (es-module-shims) पुराने browsers (जैसे Safari < 16.4) को आराम से संभाल लेता है, और solution साफ़-सुथरा और maintainable है.
निष्कर्ष
Import Maps plugin मेरे Vite builds में कैस्केडिंग हैश बदलावों को कामयाबी से रोकता है. फ़ाइलों को अब नए हैश सिर्फ़ तब मिलते हैं जब उनका असली content बदलता है, dependencies बदलने पर नहीं. इससे builds ज़्यादा predictable हो जाते हैं, बिना वजह cache invalidation कम होता है, और हम Cloudflare Pages की file limits के नीचे बने रहते हैं.
Solution सरल है, maintainable है, और आधुनिक web standards इस्तेमाल करता है. यह एक अच्छा उदाहरण है कि कैसे कभी-कभी "सही" solution सबसे आसान भी होता है, बस आपको समस्या को इतनी गहराई से समझना होता है कि वह दिख जाए.
Plugin open source है और GitHub पर उपलब्ध है: @foony/vite-plugin-import-map. आप इसे npm install @foony/vite-plugin-import-map से install कर सकते हैं और अपने Vite projects में इस्तेमाल शुरू कर सकते हैं.
भविष्य में सुधारों में es-module-lexer के साथ optimization शामिल हो सकता है (जब native panic की समस्याएँ हल हो जाएँ), या ज़्यादा complex import scenarios के लिए support जोड़ना. लेकिन फ़िलहाल, plugin वही करता है जो मुझे चाहिए था.
और कौन जानता है? शायद किसी दिन Vite इस तरह की चीज़ natively support करने लगे.
(Update: Foony के build पर plugin try करने के बाद, कुछ users को अनपेक्षित समस्याएँ हो रही थीं, इसलिए मैंने इसे फ़िलहाल disable कर दिया है. बाद में दोबारा देखूँगा. शायद. मुझे अब भी लगता है कि यह एक बढ़िया solution है.)