background blurbackground mobile blur

1/1/1970

কীভাবে Import Maps দিয়ে ক্যাসকেডিং হ্যাশ পরিবর্তনের সমস্যা সমাধান করলাম

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

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

সমস্যা: ক্যাসকেডিং হ্যাশ পরিবর্তন

Vite প্রোডাকশন বিল্ডের জন্য কন্টেন্ট-ভিত্তিক হ্যাশিং ব্যবহার করে। আপনি যখন আপনার অ্যাপ বিল্ড করেন, প্রতিটা JavaScript ফাইল তার কন্টেন্টের ভিত্তিতে ফাইলনেমে একটা হ্যাশ পায়। যদি button.tsx কম্পাইল হয়ে button-abc12345.js হয়, এবং কন্টেন্ট পরিবর্তন হয়, তাহলে এটা হয়ে যায় button-def45678.js। ক্যাশ বাস্টিং-এর জন্য এটা দারুণ. পরিবর্তন হলে ব্যবহারকারীরা নতুন ফাইল পান।

সমস্যা শুরু হয় যখন ফাইল A ফাইল B-কে ইম্পোর্ট করে। ধরুন আপনার কাছে আছে:

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

button.tsx পরিবর্তন হলে, Vite button-def45678.js জেনারেট করে। কিন্তু এখন main.js-ও পরিবর্তন হয়ে যায় কারণ এতে "./button-abc12345.js" স্ট্রিংটা আছে, যেটা এখন ভুল। তাই main.js-ও নতুন হ্যাশ পায়, যদিও main.js-এর আসল লজিকে কোনো পরিবর্তনই হয়নি।

এটা আপনার পুরো ডিপেন্ডেন্সি গ্রাফে ছড়িয়ে পড়ে। একটা ইউটিলিটি ফাংশন পরিবর্তন করুন, আর হঠাৎ করে আপনার অর্ধেক js ফাইল নতুন হ্যাশ পেয়ে যাবে। আমার ক্ষেত্রে, useBackgroundMusic.ts-এ একটা মাত্র অক্ষর পরিবর্তন করার ফলে ৫০০-এরও বেশি ফাইল রি-হ্যাশ হয়ে গেল।

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

ক্যাসকেডিং হ্যাশের সমস্যা সমাধান করার ফলে আমরা এই লিমিটে পৌঁছানো ছাড়াই অনেক বেশি অতীত বিল্ড সংরক্ষণ করতে পারি, কারণ এখন বেশিরভাগ ফাইল আর পরিবর্তন করার দরকার নেই। এতে পুরোনো বিল্ডে থাকা কোনো ব্যবহারকারীর এরর হওয়ার সম্ভাবনাও কমে যায়, যেহেতু এখন বরং বেশি সম্ভাবনা যে তারা একটা অপরিবর্তিত ফাইল রিকোয়েস্ট করবেন যেটা আমাদের কাছে আছে।

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

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

পোস্ট-বিল্ড স্ক্রিপ্ট

আমার প্রথম চিন্তা ছিল একটা পোস্ট-বিল্ড স্ক্রিপ্ট লেখা, যেটা সমস্ত ইম্পোর্ট পাথ নরমালাইজ করবে, ফাইলগুলো রি-হ্যাশ করবে, এবং রেফারেন্সগুলো আপডেট করবে। এটা সরল মনে হয়েছিল. শুধু regex দিয়ে হ্যাশড ফাইলনেমগুলোকে স্থিতিশীল নামে রিপ্লেস করুন, তারপর হ্যাশ আবার গণনা করুন।

আমি এই পদ্ধতিটা বাতিল করেছিলাম "Heisenbugs" এবং ক্যাশ পয়জনিং-এর উদ্বেগের কারণে। যদিও আমরা Cloudflare Pages-এ অতীত বিল্ড সংরক্ষণ করি, ক্যাশ অসঙ্গতির ঝুঁকি নেওয়ার মতো ছিল না। বিল্ডের পরে ফাইল পরিবর্তন করে এমন একটা স্ক্রিপ্ট সূক্ষ্ম বাগ তৈরি করতে পারে যা শুধু প্রোডাকশনে দেখা যাবে, আর সেগুলো ডিবাগ করা একটা দুঃস্বপ্ন হবে।

Vite manualChunks

আরেকটা বিকল্প ছিল Vite-এর manualChunks কনফিগারেশন ব্যবহার করে স্থিতিশীল কোড (যেমন node_modules) থেকে অস্থিতিশীল কোড (বিজনেস লজিক) আলাদা করা। ধারণাটা ছিল ভেন্ডর কোড কম পরিবর্তন হবে, তাই কম ফাইল ক্যাসকেড হবে।

এটা আসলে মূল সমস্যার সমাধান করে না. শুধু কমায়। আপনার বিজনেস লজিক চাঙ্কগুলোর মধ্যেই ক্যাসকেডিং হ্যাশ থেকে যায়। আমি এমন একটা সমাধান চেয়েছিলাম যা মূল সমস্যাটা সমাধান করবে, শুধু সেটাকে কিছুটা কম খারাপ করে নয়।

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

Import Maps হলো একটা ব্রাউজার-নেটিভ ফিচার (পুরোনো ব্রাউজারের জন্য পলিফিল সাপোর্ট সহ) যা মডিউল স্পেসিফায়ারকে ফাইল পাথ থেকে ডিকাপল করে। ফাইল A "./button-abc123.js" ইম্পোর্ট করার পরিবর্তে, এটা "button" ইম্পোর্ট করে। ব্রাউজার import map ব্যবহার করে "button"-কে আসল হ্যাশড ফাইলনেমে রিজলভ করে।

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

Vite প্লাগইন তৈরি করা

আমি সিদ্ধান্ত নিলাম একটা Vite প্লাগইন বানাবো যা:

  1. সমস্ত রিলেটিভ ইম্পোর্টকে স্থিতিশীল মডিউল স্পেসিফায়ার ব্যবহার করার জন্য ট্রান্সফর্ম করবে
  2. একটা import map জেনারেট করবে যা সেই স্পেসিফায়ারগুলোকে আসল হ্যাশড ফাইলনেমে ম্যাপ করে
  3. HTML-এ import map ইনজেক্ট করবে

প্লাগইনটা এখন GitHub-এ পাওয়া যাচ্ছে: @foony/vite-plugin-import-map

প্রাথমিক পদ্ধতি

আমি generateBundle হুক ব্যবহার করে একটা Vite প্লাগইন দিয়ে শুরু করেছিলাম। আমার প্রথম চেষ্টায় ইম্পোর্ট পাথ খুঁজে রিপ্লেস করতে regex ব্যবহার করেছিলাম। এটা কোড করা সহজ ছিল আর আমাদের ছোট দল Foony-এর জন্য কাজ করেছিল, কিন্তু এটা ভঙ্গুর ছিল এবং নিশ্চিতভাবে এমন একটা প্লাগইনে কাজ করবে না যেখানে false-positive হয়ে যেতে পারে এবং সেগুলো পরিবর্তন হয়ে যেতে পারে।

regex পদ্ধতির সুস্পষ্ট সমস্যা ছিল: যদি কোডের কোনো স্ট্রিং হঠাৎ ফাইলনেমের মতো দেখায়? ডায়নামিক ইম্পোর্টের ক্ষেত্রে কী হবে? এক্সপোর্ট স্টেটমেন্টের ক্ষেত্রে কী হবে? অন্যদের জন্য একটা প্লাগইন বানাতে হলে আমার আরও শক্তিশালী একটা সমাধান দরকার ছিল।

AST পার্সিং

সব ইম্পোর্ট স্টেটমেন্ট খুঁজে পেতে আমাকে JavaScript কোড সঠিকভাবে পার্স করতে হয়েছিল। আমার প্রথম চেষ্টা ছিল es-module-lexer, যা বিশেষভাবে ES মডিউল পার্স করার জন্য ডিজাইন করা। দুর্ভাগ্যবশত, এটা Vite-এর মডিউল অ্যানালাইসিস ফেজে নেটিভ প্যানিক ঘটাচ্ছিল। এমনকি asm.js বিল্ড চেষ্টা করেও প্যানিক বন্ধ করা যায়নি।

আমি Acorn-এ স্থির হলাম, একটা দ্রুত, হালকা, বিশুদ্ধ JavaScript পার্সার। AST ট্রাভার্সালের জন্য acorn-walk-এর সাথে মিলিয়ে, এটা নেটিভ ডিপেন্ডেন্সি সমস্যা ছাড়াই আমাকে যা যা দরকার তার সব দিয়েছে।

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

সব ধরনের ইম্পোর্ট হ্যান্ডেল করা

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

  • স্ট্যাটিক ইম্পোর্ট: import x from "./file.js"
  • ডায়নামিক ইম্পোর্ট: import("./file.js")
  • নেমড রি-এক্সপোর্ট: export { x } from "./file.js" (আমি প্রথমে এটা মিস করেছিলাম!)
  • রি-এক্সপোর্ট অল: export * from "./file.js"

রি-এক্সপোর্ট কেসটা বিশেষভাবে কঠিন ছিল কারণ আমি এটা মিস করেছিলাম যতক্ষণ না একটা ফাইল দেখলাম যেটা ট্রান্সফর্ম হচ্ছে না। কোডে ছিল 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
  },
});

ডিটারমিনিস্টিক কনফ্লিক্ট রেজোলিউশন

যখন একাধিক ফাইলের একই বেস নাম থাকে (যেমন আলাদা আলাদা ডিরেক্টরিতে একাধিক index.tsx ফাইল), তখন আমাকে সেগুলোকে আলাদা করতে হবে। সবগুলোর জন্য "index" ব্যবহার করা যাবে না।

আমার সমাধান: যদি কনফ্লিক্ট থাকে, আমি মূল সোর্স পাথ এবং বেস নাম একসাথে হ্যাশ করি। উদাহরণস্বরূপ, src/client/games/chess/index.tsx:index হ্যাশ হয়ে index-abc123 তৈরি হয়। এটা নিশ্চিত করে যে একই ফাইল বিল্ডের মাঝে সর্বদা একই মডিউল স্পেসিফায়ার পায়, এমনকি যদি একই নামের অন্য ফাইল যোগ বা সরানো হয়।

আমি chunk.facadeModuleId (এন্ট্রি পয়েন্ট) প্রাথমিক আইডেন্টিফায়ার হিসেবে ব্যবহার করি, সেটা না থাকলে chunk.moduleIds[0]-এ ফিরে যাই। এটা আমাকে ডিটারমিনিস্টিক হ্যাশিং-এর জন্য একটা স্থিতিশীল সোর্স পাথ দেয়।

সোর্স ম্যাপ চেইনিং

যখন আমি কোড ট্রান্সফর্ম করি, আমি সোর্স ম্যাপ চেইন ভেঙে দিচ্ছি। বিদ্যমান সোর্স ম্যাপ মূল TypeScript সোর্স থেকে Babel এবং মিনিফিকেশন হয়ে বর্তমান কোডে ম্যাপ করে। আমার ট্রান্সফর্মেশন আরেকটা লেয়ার যোগ করে, তাই আমাকে সেই চেইন সংরক্ষণ করতে হবে।

আমি MagicString ব্যবহার করি আমার ট্রান্সফর্মেশনগুলো ট্র্যাক করতে এবং একটা নতুন সোর্স ম্যাপ জেনারেট করতে। তারপর আমি সেটাকে বিদ্যমান ম্যাপের সাথে মার্জ করি মূল sources এবং sourcesContent অ্যারে সংরক্ষণ করে। এটা পুরো চেইন বজায় রাখে: মূল সোর্স → (বিদ্যমান ম্যাপ) → ট্রান্সফর্মড কোড।

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

ট্রান্সফর্মড কন্টেন্ট রি-হ্যাশ করা

আমার স্থিতিশীল ফাইল কন্টেন্ট দরকার। এটা করতে, আমি ইম্পোর্ট ট্রান্সফর্ম করি (Vite-এর হ্যাশড ইম্পোর্টকে আমার স্থিতিশীল ইম্পোর্ট দিয়ে রিপ্লেস করি), এবং তারপর হ্যাশ গণনা থেকে সোর্স ম্যাপ কমেন্ট সরিয়ে ফেলি (সেগুলো পুরোনো ফাইলনেম রেফার করে)।

এরপর, আমি একটা নতুন হ্যাশ গণনা করি, এবং ফাইলনেম এবং import map এন্ট্রি দুটোই আপডেট করি।

চূড়ান্ত ইমপ্লিমেন্টেশন

প্লাগইনটা একটা চার-পাস স্ট্র্যাটেজি ব্যবহার করে:

  1. কাউন্ট পাস: কতগুলো ফাইল প্রতিটা বেস নাম শেয়ার করে তা গণনা করে নাম সংঘাত শনাক্ত করা
  2. ম্যাপ পাস: চাঙ্ক ম্যাপিং তৈরি করা (হ্যাশড ফাইলনেম → মডিউল স্পেসিফায়ার) এবং প্রাথমিক import map
  3. ট্রান্সফর্ম পাস: কোডে ইম্পোর্ট পাথ পুনর্লিখন করা, হ্যাশ পুনরায় গণনা করা, সোর্স ম্যাপ আপডেট করা
  4. রিনেম পাস: বান্ডল ফাইলনেম আপডেট করা এবং import map ফাইনালাইজ করা

এখানে কোর ট্রান্সফর্মেশন লজিকটা:

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 ইনজেক্ট করার জন্য, regex ম্যানিপুলেশনের পরিবর্তে আমি Vite-এর ট্যাগ ইনজেকশন API ব্যবহার করি:

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

HTML ট্যাগ regex দিয়ে ম্যাচ করার চেষ্টার চেয়ে এটা অনেক বেশি নির্ভরযোগ্য।

সংখ্যায় হিসাব

এই প্লাগইনটা কী করে সেটার একটা ধারণা দিতে:

  • প্রতিটা বিল্ডে ~১,০০০+ JavaScript ফাইল প্রসেস হয়
  • বিল্ড টাইমে ~২-৩ সেকেন্ড যোগ হয় (গ্রহণযোগ্য ট্রেড-অফ)
  • অপ্রয়োজনীয় হ্যাশ পরিবর্তনে ~৯৯% হ্রাস (বেশিরভাগ ফাইল এখন শুধু তাদের আসল কন্টেন্ট পরিবর্তন হলেই পরিবর্তন হয়)
  • ~৩৪০ লাইন প্লাগইন কোড (কমেন্ট এবং এরর হ্যান্ডলিং সহ)

এখন পর্যন্ত যেসব এজ কেস আমি পেয়েছি প্লাগইনটা সেগুলো হ্যান্ডেল করে, এবং বিল্ড প্রসেস এখন অনেক বেশি অনুমানযোগ্য।

যা শিখলাম

কেন AST পার্সিং অপরিহার্য

বান্ডল করা কোডে regex ব্যবহার করা বিপজ্জনক। যদি আপনার কোডের কোনো স্ট্রিং হঠাৎ ফাইলনেমের মতো দেখায়, regex সেটা পুনর্লিখন করবে। AST পার্সিং নিশ্চিত করে যে আপনি শুধু আসল ইম্পোর্ট/এক্সপোর্ট স্টেটমেন্ট ট্রান্সফর্ম করছেন।

কেন es-module-lexer-এর চেয়ে Acorn

es-module-lexer দ্রুততর এবং নির্দিষ্ট উদ্দেশ্যের জন্য তৈরি, কিন্তু নেটিভ প্যানিক সমস্যার কারণে এটা আমার Vite প্লাগইন কনটেক্সটে ব্যবহার করা যাচ্ছিল না। Acorn বিশুদ্ধ JavaScript, যার মানে নেটিভ ডিপেন্ডেন্সি নিয়ে চিন্তা করতে হয় না। ভবিষ্যতে স্পিড অপ্টিমাইজেশনের জন্য es-module-lexer দেখতে চাই, কিন্তু আপাতত Acorn দারুণ কাজ করছে।

কেন বিকল্পের চেয়ে Import Maps

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

উপসংহার

Import Maps প্লাগইনটা সফলভাবে আমার Vite বিল্ডে ক্যাসকেডিং হ্যাশ পরিবর্তন প্রতিরোধ করে। ফাইলগুলো এখন শুধু তখনই নতুন হ্যাশ পায় যখন তাদের আসল কন্টেন্ট পরিবর্তন হয়, যখন তাদের ডিপেন্ডেন্সি পরিবর্তন হয় তখন নয়। এটা বিল্ডকে আরও অনুমানযোগ্য করে, অপ্রয়োজনীয় ক্যাশ ইনভ্যালিডেশন কমায়, এবং Cloudflare Pages-এর ফাইল লিমিটের নিচে থাকতে সাহায্য করে।

সমাধানটা সরল, রক্ষণাবেক্ষণযোগ্য, এবং আধুনিক ওয়েব স্ট্যান্ডার্ড ব্যবহার করে। এটা একটা ভালো উদাহরণ যে কখনো কখনো "সঠিক" সমাধানই সবচেয়ে সরল সমাধান. একবার আপনি সমস্যাটা যথেষ্ট গভীরভাবে বুঝতে পারলে সেটা দেখতে পান।

প্লাগইনটা ওপেন সোর্স এবং GitHub-এ উপলব্ধ: @foony/vite-plugin-import-map। আপনি npm install @foony/vite-plugin-import-map দিয়ে ইনস্টল করতে পারেন এবং নিজের Vite প্রকল্পে ব্যবহার শুরু করতে পারেন।

ভবিষ্যতের উন্নতির মধ্যে থাকতে পারে নেটিভ প্যানিক সমস্যা সমাধান হলে es-module-lexer দিয়ে অপ্টিমাইজ করা, বা আরও জটিল ইম্পোর্ট পরিস্থিতির জন্য সাপোর্ট যোগ করা। কিন্তু আপাতত, প্লাগইনটা আমার যা দরকার ঠিক তাই করে।

আর কে জানে? হয়তো একদিন Vite নেটিভভাবে এমন কিছু সাপোর্ট করবে।

(আপডেট: Foony-র বিল্ডে প্লাগইনটা চেষ্টা করার পর, কিছু ব্যবহারকারী অপ্রত্যাশিত সমস্যা পাচ্ছিলেন, তাই আমি আপাতত এটা ডিজেবল করে রেখেছি। পরে আবার দেখব। হয়তো। আমি এখনো মনে করি এটা একটা চমৎকার সমাধান।)

8 Ball Pool online multiplayer billiards icon