background blurbackground mobile blur

1/1/1970

Import Maps দিয়ে Cascading Hash পরিবর্তনের ঝামেলা কীভাবে মিটিয়েছি

হ্যালো! এই ঝামেলাটা আমার ৫ বছরেরও বেশি সময় ধরে ছিল, কিন্তু একেবারে সাম্প্রতিককালে এসে এমন জায়গায় পৌঁছাল যে আর এড়িয়ে যাওয়া যাচ্ছিল না। একটা ফাইলের একটিমাত্র অক্ষর বদলালেই, আমার build-এর প্রায় অর্ধেক JavaScript ফাইলের hashed filename বদলে যেত, যদিও ফাইলগুলোর আসল কনটেন্ট একটুও পাল্টায়নি। এতে অকারণে cache invalidation হচ্ছিল, কোন build-এর মধ্যে আসলে কী বদলাচ্ছে সেটা ট্র্যাক করা প্রায় অসম্ভব হয়ে যাচ্ছিল, আর সবচেয়ে খারাপ ব্যাপারটা হল: file limit-এর জন্য আমার Cloudflare Pages build সরাসরি ভেঙে যাচ্ছিল।

নিচে আমি সমস্যাটা খুলে বলব, কেন আগের সমাধানগুলো আমার কাজে দেয়নি, আর Import Maps ব্যবহার করে কীভাবে একটা কাস্টম Vite plugin বানিয়ে একেবারে মূল থেকে সমস্যাটা মিটিয়েছি।

সমস্যা: Cascading Hash পরিবর্তন

Vite production build-এর জন্য content-based hashing ব্যবহার করে। তুমি যখন app build করো, প্রতিটা JavaScript ফাইলের filename-এ তার কনটেন্টের ওপর ভিত্তি করে একটা hash যোগ হয়। যদি button.tsx compile হয়ে button-abc12345.js হয়, আর ভেতরের কনটেন্ট বদলালে সেটা button-def45678.js হয়ে যায়। cache busting-এর জন্য এটা দারুণ, কারণ ফাইল বদলালেই ব্যবহারকারীরা নতুন ফাইলটা পেয়ে যায়।

সমস্যাটা আসে যখন 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 ফাইলের hash বদলে যায়। আমার ক্ষেত্রে, useBackgroundMusic.ts-এ একটামাত্র ক্যারেক্টার বদলানোতেই ৫০০-এর বেশি ফাইল re-hash হয়ে গিয়েছিল।

রিয়েল-ওয়ার্ল্ড ইমপ্যাক্ট বেশ বড় ছিল। আমরা আমাদের পুরনো build-এর asset-এর ৮টা করে ভার্সন bundle করে রাখি, যেন একটু পুরনো client ভার্সনে থাকা ইউজাররাও নতুন ভার্সন Cloudflare Pages-এ deploy করার পরে তাদের পুরনোটা চালাতে পারে। কিন্তু Cloudflare Pages-এ ২০,০০০ ফাইলের লিমিট আছে, আর আগের i18n পরিবর্তনের জন্য আমরা যে পরিমাণ ফাইল তৈরি করছিলাম তা বিস্ফোরিত হয়ে গিয়েছিল, ফলে লিমিটে ধাক্কা খেতে শুরু করলাম।

Cascading hash থামাতে পারায় এখন আমরা অনেক বেশি পুরনো build স্টোর করতে পারি, কারণ প্রায় সব ফাইলই আর বদলাতে হয় না। এতে stale build-এ থাকা কোনো ইউজারের error পাওয়ার সম্ভাবনাও কমে যায়, কারণ সে এখন অনেক বেশি সম্ভাবনায় এমন কোনো ফাইল চাইবে যেটা আসলে আর বদলায়নি আর আমাদের cache-এ আছে।

কেন [বিকল্প সমাধান] নয়?

প্রথমে যখন এটা সমাধান করার কথা ভাবি, কিছু আলাদা আলাদা পদ্ধতি মাথায় এসেছিল। কোনোটা নিয়েই পুরোপুরি খুশি হতে পারিনি।

Post-build script

সবচেয়ে আগে ভেবেছিলাম একটা post-build script লিখব, যেটা সব import path normalize করে, ফাইলগুলো আবার hash করবে আর রেফারেন্স আপডেট করবে। শুনতে বেশ সোজা লাগে, তাই না? regex দিয়ে hashed filename গুলো stable নামে পাল্টে দাও, তারপর নতুন করে hash বের করো।

এই পদ্ধতি আমি বাদ দিলাম মূলত "Heisenbugs" আর cache poisoning-এর ভয়ে। যদিও আমরা আগের build গুলো Cloudflare Pages-এ স্টোর করি, তবু cache গুলোর মধ্যে অসঙ্গতি তৈরি হওয়ার ঝুঁকি নেওয়ার মতো না। build শেষ হওয়ার পর স্ক্রিপ্ট দিয়ে ফাইল এডিট করলে এমন সূক্ষ্ম বাগ ঢুকে যেতে পারে যেগুলো শুধু production-এ দেখা যাবে, আর সেগুলো debug করা মানে দুঃস্বপ্ন।

Vite manualChunks

আরেকটা আইডিয়া ছিল Vite-এর manualChunks কনফিগারেশন ব্যবহার করে stable code (যেমন node_modules) আর unstable code (business logic) আলাদা করা। ভাবনাটা ছিল vendor code তো কম বদলাবে, তাই কম ফাইলের hash cascade হবে।

কিন্তু এতে আসল সমস্যাটা ধরা পড়ে না, শুধু একটু চাপা পড়ে। business logic-এর chunk-এর ভেতরে hash cascade ঠিকই হয়। আমি এমন কিছু চাইছিলাম যেটা সমস্যার গোড়ায় গিয়ে ধরে, অল্প একটু কম খারাপ করে না।

Import Maps: আধুনিক সমাধান

Import Maps হচ্ছে একেবারে browser-native ফিচার (পুরনো ব্রাউজারের জন্য polyfill আছে) যেটা module specifier আর file path-কে আলাদা করে ফেলে। মানে File A "./button-abc123.js" না লিখে "button" import করে। ব্রাউজার Import Map দেখে "button"-কে আসল hashed filename-এর সঙ্গে মেলায়।

আমার যা দরকার ছিল, ঠিক সেটাই। File A-এর কনটেন্ট সব build-এ হুবহু এক হয় (ওই "button" ইম্পোর্টটাই থাকে), তাই তার hash-ও সবসময় এক থাকে। শুধু Import Map আর বদলানো ফাইলটাই নতুন hash পায়। আমি সত্যি একটু অবাকই হয়েছিলাম যে এর জন্য ভালো কোনো প্লাগইন আগে থেকেই কেউ বানায়নি!

ইমপ্লিমেন্টেশন জার্নি

শেষ পর্যন্ত আমি ঠিক করলাম একটা Vite plugin বানাব, যেটা করবে:

  1. সব relative import-কে stable module specifier-এ রূপান্তর করবে
  2. এই specifier গুলোকে আসল hashed filename-এর সঙ্গে মেপে একটা Import Map বানাবে
  3. Import Map-টা HTML-এর ভেতরে inject করবে

প্লাগইন এখন GitHub-এ পাবলিক: @foony/vite-plugin-import-map

শুরুর চেষ্টা

আমি generateBundle hook দিয়ে একটা Vite plugin লিখে শুরু করেছিলাম। প্রথমচেষ্টায় regex দিয়ে import path খুঁজে বের করে replace করছিলাম। কোড লেখা সহজ, আমাদের ছোট্ট টিম Foony-এর জন্য এটা মোটামুটি কাজও করছিল, কিন্তু যথেষ্ট fragile ছিল আর প্লাগইন আকারে ছাড়ার মতো নির্ভরযোগ্য ছিল না, কারণ অন্যদের প্রোজেক্টে false-positive গুলো গিয়ে ভয়াবহ ফল দিতে পারে।

Regex পদ্ধতির সমস্যা একগাদা। ধরো, কোডের কোনো random string হুবহু filename-এর মত দেখাচ্ছে, তখন কী হবে? dynamic import-এর কী হবে? export স্টেটমেন্ট গুলোর কী হবে? অন্যদের ব্যবহারের জন্য প্লাগইন বানাতে হলে এর থেকে অনেক বেশি solid কিছু দরকার।

AST parsing

আমাকে আসলে JavaScript কোডটা parse করে সত্যিকারের import স্টেটমেন্টগুলো খুঁজে বের করতে হচ্ছিল। প্রথমে আমি চেষ্টা করেছিলাম es-module-lexer, যেটা একেবারে ES modules parse করার জন্যই বানানো। কিন্তু ওটা Vite-এর module analysis ফেজে গিয়ে বারবার native panic করাচ্ছিল। এমনকি asm.js build চেষ্টা করেও panics থামানো গেল না।

শেষে আমি থিতু হলাম Acorn-এ, যেটা দ্রুত, হালকা, আর pure JavaScript parser। acorn-walk দিয়ে AST traverse করার সুবিধা নিয়ে একে সঙ্গে করে নিলাম। এতে আমার প্রয়োজনীয় সবকিছুই পেলাম, আর extra native dependency নিয়েও ভাবতে হল না।

যেসব বড় চ্যালেঞ্জ সমাধান হয়েছে

সব ধরনের import হ্যান্ডল করা

Import অনেক রকমভাবে লেখা হয়, আর AST-এ এগুলো আলাদা আলাদা টাইপের node হিসেবে আসে। আমাকে হ্যান্ডল করতে হচ্ছিল:

  • Static import: import x from "./file.js"
  • Dynamic import: import("./file.js")
  • Named re-export: export { x } from "./file.js" (এটাকে প্রথমে একেবারেই মিস করেছিলাম!)
  • Re-export all: export * from "./file.js"

Re-export কেসটাই সবচেয়ে ঝামেলায় ফেলেছিল, কারণ একদম চোখের সামনে থেকেও সেটা দেখিনি, যতক্ষণ না একটা ফাইল দেখলাম যেটাতে কোনো পরিবর্তনই হচ্ছিল না। ওটাতে ছিল export{PoolBalls,PoolCues,PoolTables}from"./Items-Bd_KmSuk.js", আর আমার প্লাগইন ওটাকে একদমই ছুঁইনি, কারণ আমি শুধু ImportDeclaration আর ImportExpression node গুলোই খুঁজছিলাম।

এখন আমি সবগুলোকে এইভাবে হ্যান্ডল করি:

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 এক হয়, যেমন আলাদা আলাদা ডিরেক্টরিতে অনেকগুলো index.tsx। তখন এগুলোকে আলাদা করে চেনাতে হবে। সবার নামই "index" রাখতে পারি না।

আমার সমাধানটা হচ্ছে: conflict হলে, original source path আর base name একসঙ্গে জুড়ে hash করি। যেমন src/client/games/chess/index.tsx:index থেকে hash করে index-abc123 এর মত একটা নাম বানাই। এতে নির্দিষ্ট কোনো ফাইল সব build-এই একই module specifier পায়, এমনকি একই নামে নতুন ফাইল যোগ হলেও, বা কোনো ফাইল উঠে গেলেও।

আমি এখানে মূলত chunk.facadeModuleId (entry point) ব্যবহার করি, আর ওটা না পেলে chunk.moduleIds[0]-এ fallback করি। এতে একটা stable source path পাই, যেটার ওপর deterministic hashing করা যায়।

Source map chaining

আমি যখন কোড transform করি, তখন আগের source map চেইন কেটে যায়। পুরনো source map Original TypeScript source থেকে Babel, minifier হয়ে বর্তমান কোড পর্যন্ত map করে। আমার transform আরেকটা লেয়ার যোগ করছে, তাই এই নতুন লেয়ারটাকেও পুরনো চেইনের সঙ্গে জুড়ে দিতে হবে।

আমি MagicString দিয়ে সব পরিবর্তন track করি আর নতুন source map জেনারেট করি। তারপর ওই map-টা পুরনোটার সঙ্গে merge করি, যেখানে sources আর sourcesContent পুরনো map থেকেই রেখে দিই। এতে পুরো চেইনটা বেঁচে যায়: 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 কনটেন্ট আবার re-hash করা

আমার দরকার ছিল stable file content। তার জন্য আমি প্রথমে import গুলো transform করি (Vite-এর hashed import-এর জায়গায় নিজের stable import বসাই), তারপর hash বের করার আগে source map comment গুলো strip করে দিই, কারণ ওগুলোতে পুরনো filename থাকে।

এরপর নতুন hash ক্যালকুলেট করি, ফাইলের নাম আপডেট করি, আর Import Map-এর entry টাও আপডেট করি।

ফাইনাল ইমপ্লিমেন্টেশন

পুরো প্লাগইন আসলে চারটা পাসে কাজ করে:

  1. Count pass: কোন কোন base name একাধিক ফাইলে আছে সেটা গুনে name collision ডিটেক্ট করা
  2. Map pass: chunk mapping বানানো (hashed filename → module specifier) আর প্রাথমিক Import Map তৈরি করা
  3. Transform pass: কোডের ভেতরের import path rewrite করা, নতুন করে hash বের করা, source map আপডেট করা
  4. Rename pass: bundle filename গুলো আপডেট করা আর Import Map ফাইনাল করা

এর ভেতরের 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 দিয়ে HTML ঘাঁটাঘাঁটি না করে, সরাসরি Vite-এর tag injection API ব্যবহার করেছি:

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

HTML regex দিয়ে match করার চেয়ে এই পথে যাওয়া অনেক বেশি নির্ভরযোগ্য।

সংখ্যায় কী বদলাল

একটু ধারণা দিতে:

  • প্রতি build-এ ~1,000+ JavaScript ফাইল প্রসেস হয়
  • build time-এ বাড়তি ~2-3 সেকেন্ড যোগ হয় (এই trade-off পুরোপুরি মানিয়ে নেওয়া যায়)
  • অপ্রয়োজনীয় hash পরিবর্তনে ~99% কমতি, এখন প্রায় সব ফাইলই শুধু কনটেন্ট বদলালেই hash বদলায়
  • পুরো প্লাগইন কোড ~340 লাইন (কমেন্ট আর error handling-সহ)

এ পর্যন্ত যত রকম edge case-এর মুখোমুখি হয়েছি, সবই ঠিকঠাক সামলাতে পেরেছে, আর build প্রক্রিয়াটা এখন অনেক বেশি predictable।

কী কী শিখলাম

কেন AST parsing জরুরি

Bundled কোডের ওপর regex চালানো ভীষণ ঝুঁকিপূর্ণ। কোডের ভেতরে যদি কোনো string filename-এর মত দেখায়, regex সেটাকেও rewrite করার চেষ্টা করবে। AST parsing করলে শুধু সত্যিকারের import/export স্টেটমেন্টেই হাত পড়ে।

কেন Acorn, es-module-lexer নয়

es-module-lexer গতি আর কাজের দিক থেকে বেশ এগিয়ে, কিন্তু ওই native panic ইস্যু গুলোর জন্য আমার Vite plugin কনটেক্সটে একদম ব্যবহারযোগ্য ছিল না। Acorn সম্পূর্ণ JavaScript ভিত্তিক, মানে আলাদা native dependency নিয়ে ভাবতে হয় না। ভবিষ্যতে পারফরম্যান্স টিউন করতে চাইলে আবার es-module-lexer নিয়ে খোঁজ নেব, কিন্তু আপাতত Acorn একদমই যথেষ্ট ভালো কাজ করছে।

কেন Import Maps, অন্য কিছু নয়

Import Maps ওয়েব স্ট্যান্ডার্ড, আর বেশিরভাগ ব্রাউজারেই native সাপোর্ট আছে। এই সমস্যার জন্য ওটাই আসলে সবচেয়ে প্রাকৃতিক সমাধান। Polyfill (es-module-shims) পুরনো ব্রাউজার (যেমন Safari < 16.4) সুন্দরভাবে সামলে নেয়, আর পুরো সমাধানটাই পরিষ্কার, পড়তে সহজ আর maintainable।

শেষ কথা

Import Maps প্লাগইন আমার Vite build-এ cascading hash পরিবর্তন একদম থামিয়ে দিয়েছে। এখন কোনো ফাইলের hash বদলায় শুধু তখনই, যখন তার আসল কনটেন্ট বদলায়, dependency বদলালেই না। এতে build অনেক বেশি predictable হয়েছে, অকারণে cache invalidation কমেছে, আর Cloudflare Pages-এর file limit-এর ভেতরেও সহজে থাকা যাচ্ছে।

সমাধানটা একদিকে যেমন সোজা আর maintainable, তেমনি পুরোপুরি আধুনিক ওয়েব স্ট্যান্ডার্ডের ওপর দাঁড়ানো। অনেক সময় যেটা "সঠিক" সমাধান, সেটা আশ্চর্যভাবে সবচেয়ে সহজটাও হয়, যদি সমস্যাটাকে যথেষ্ট গভীরভাবে দেখে বোঝা যায়।

প্লাগইনটা open source আর GitHub-এ পাওয়া যাবে: @foony/vite-plugin-import-mapnpm install @foony/vite-plugin-import-map চালালেই তুমি নিজের Vite প্রোজেক্টে ব্যবহার শুরু করতে পারো।

ভবিষ্যতে es-module-lexer দিয়ে optimize করা, বা আরও জটিল import সিনারিও সাপোর্ট দেওয়ার কথা ভাবছি। কিন্তু এখন যেটা দরকার ছিল, প্লাগইনটা ঠিক সেটাই করছে।

আর কে জানে, ভবিষ্যতে হয়তো Vite নিজেই এমন কিছু ফিচার নেটিভলি সাপোর্ট করা শুরু করবে।

8 Ball Pool online multiplayer billiards icon