background blurbackground mobile blur

1/1/1970

কীভাবে আমি 2 দিনে SSG ইমপ্লিমেন্ট করলাম

হাউডি! এক বছর আগেও মনে করতাম, এই কাজটা করা একদম অসম্ভব। কিন্তু ঠিক এখনই আমি Foony‑র জন্য Static Site Generation (SSG) 2 দিনে ইমপ্লিমেন্ট করে ফেললাম, আর এটা নিয়ে আমি বেশই এক্সাইটেড। Foony‑র জন্য SSG করার চেষ্টা এটাই প্রথম না। আগে NextJS, Vike, Astro, Gatsby আর কয়েকটা সমাধান ঘেঁটেছি। NextJS দিয়ে তো একবার শুরুও করেছিলাম, কিন্তু Foony‑র SPA‑র জটিলতা আর হাজার হাজার ফাইল সামলাতে গিয়ে আটকে গিয়েছিল সবকিছু। ওই মাইগ্রেশনটা একদম দুঃস্বপ্ন হয়েই দাঁড়াত, আর মাসের পর মাস সময় লাগত। তার ওপর সাইটে কাজ করা বাকিদেরও NextJS আর তার সব খুঁতখুঁতে ব্যাপার শিখতে হতো।

আমি এমন কিছু চাইছিলাম যা হালকা আর ইমপ্লিমেন্ট করা সহজ। এমন কিছু, যেটা আমাদের আগের মতই কোড লিখতে দেবে, SSG নিয়ে বাড়তি ভাবার দরকার পড়বে না (শুধু useMediaQuery বাদ, ওটার আর তেমন উপায় নেই)। নিচে আমি ব্যাখ্যা করব কেন শেষমেশ নিজের বানানো সলিউশন নিলাম, কী কী সমস্যায় পড়েছি (বিশেষ করে React‑এর Suspense boundary নিয়ে), আর সেগুলো কীভাবে সমাধান করেছি।

স্ট্যান্ডার্ড সলিউশনগুলো ব্যবহার করলাম না কেন?

Foony‑তে SSG যোগ করার কথা প্রথম ভাবতেই স্বাভাবিকভাবেই NextJS (industry standard), Vike আর Astro‑র কথা মাথায় এসেছিল।

NextJS: মাইগ্রেশনটা অনেক বেশি

NextJS বেশ শক্তিশালী, কিন্তু এটা ব্যবহার করতে গেলেই Foony‑র বর্তমান React SPA‑টা প্রায় পুরোটাই মাইগ্রেট করতে হতো। আমাদের আছে হাজার হাজার ফাইল, জটিল রাউটিং লজিক আর অনেক কাস্টম ইনফ্রা। NextJS‑এ যাওয়া মানে হতো:

  • পুরো রাউটিং সিস্টেম আবার লিখতে হতো
  • গেম আর কম্পোনেন্টগুলো কীভাবে লোড করি, সেটাও পাল্টাতে হতো
  • শুধু আগের মতো ফিচার সমতা পেতে গেলেই মাসের পর মাস কাজ করতে হতো
  • ইউজারদের জন্য ভাঙা পরিবর্তনের ঝুঁকি থাকত
  • ইমেজ হ্যান্ডলিং করার ধরনটাই বদলাতে হতো
  • বিল্ড টাইম অনেক ধীর হয়ে যেত (সম্ভবত 5–30 মিনিট। আসলে হাতে ধরা কোনো নিজের মাপা সংখ্যা নেই, শুধু এই 5 বছর আগের GitHub ডিসকাশনটা বাদে)
  • পুরো টিমকে নতুন কিছু (NextJS) শিখতে হতো, আর ডেভেলপমেন্ট স্পিড সারাজীবনের জন্যই একটু ধীর হয়ে যেত
  • আর যখনই NextJS বড় ধরনের breaking change আনত, তখনই আবার কোড মাইগ্রেট করতে হতো

আমি NextJS দিয়ে একবার শুরুও করেছিলাম, কিন্তু দ্রুতই বুঝলাম, মাইগ্রেশন কস্ট অনেক বেশি। এত জটিলতার মানে আসলে হয় না।

Vike: প্রায় একই জটিলতা

Vike (আগের নাম vite-plugin-ssr)‑এর সমস্যাও প্রায় একই ধরনের। NextJS‑এর চেয়ে বেশ ফ্লেক্সিবল হলেও, আমাদের পুরো কোডবেসই বেশ বড় আকারে রি-স্ট্রাকচার করতে হতো। শেখার কষ্ট আর মাইগ্রেশন কস্ট, দুই মিলে লাভের চেয়ে লসই বেশি মনে হচ্ছিল।

Astro: আমাদের আর্কিটেকচারের জন্য ঠিক না

Astro কনটেন্ট‑হেভি সাইটের জন্য দারুণ, কিন্তু Foony একটা জটিল মাল্টিপ্লেয়ার গেম প্ল্যাটফর্ম। আমাদের দরকার রিয়েল‑টাইম আপডেট, WebSocket কানেকশন আর ডায়নামিক React কম্পোনেন্ট। Astro‑র আর্কিটেকচার আমাদের বানাতে চাওয়া জিনিসটার সাথে একদম যায় না।

সমাধান: নিজস্ব বানানো SSG

কয়েক দিন আগে i18n করার পর যে "fake SSG" পদ্ধতিটা করেছিলাম, সেটাতে ভরসা পেয়েই Foony‑র SSG‑র জন্য ছোট, হালকা, নিজের বানানো একটা সলিউশনেই থিতু হলাম।

আমার ওই "fake SSG" পদ্ধতিতে করেছি কী, যেসব পেজে ব্লগ পোস্ট আছে (/posts রুট আর গেম পেজগুলো), সেখান থেকে ব্লগ কনটেন্ট তুলে এনে একদম ক্লায়েন্ট যেমন করে রেন্ডার করত, ঠিক সেভাবেই HTML‑এ বসিয়ে দিয়েছিলাম, শুধু সার্চ ইঞ্জিন আর LLM‑এর জন্য, যাতে তারা Foony‑কে ভালোভাবে বুঝতে পারে। সাথে কিছু ld+json স্কিমা আর ছোটখাটো SEO জিনিসও ছিল।

অ্যাপ্রোচটা খুব সোজা:

  1. আগের React SPA‑র ওপরেই বিল্ড: কোনো মাইগ্রেশন না, শুধু বিল্ড টাইমে SSG জেনারেশন যোগ করা
  2. renderToReadableStream ব্যবহার: React 18‑এর স্ট্রিমিং SSR API নিজে থেকেই Suspense হ্যান্ডল করে
  3. স্ট্যাটিক HTML ফাইল বানানো: বিল্ড টাইমে রুটগুলো আগেই রেন্ডার করে স্ট্যাটিক ফাইল হিসেবে সার্ভ করা, আর রুটের তালিকা নেওয়া আমাদের SitemapGenerator থেকে
  4. বিদ্যমান কোডবেসে প্রায় নেই বললেই চলে পরিবর্তন: বেশিরভাগ কম্পোনেন্ট আগের মতোই কাজ করেছে

মূল ইমপ্লিমেন্টেশনটা আছে client/src/generators/GenerateShellSsgFromSitemap.ts ফাইলে। ওটা সাইটম্যাপ পড়ে, React‑এর renderToReadableStream দিয়ে প্রতিটি রুট রেন্ডার করে, আর তৈরি হওয়া HTML স্ট্যাটিক ফাইলে লিখে দেয়। ঝামেলা কম, ঠিক যেমনটা আমার পছন্দ।

স্পিডও বেশ ভালো পেলাম। খুব মোটামুটি 2,800টা রুট 10 সেকেন্ডের মতো সময় নিয়েছে। খারাপ না। NextJS, Gatsby আর Astro‑র তুলনায় অনেক দ্রুত। <img alt="SSG কনসোল লগ, যেখানে সময় দেখাচ্ছে" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />

সরলতা নিয়ে আমি অনেক কথা বলতে পারি। বড় বড় কোম্পানিতে "কম জটিল" কোড দেখে হয়তো কেউ আপনাকে প্রোমোশন দেবে না, কিন্তু সিম্পল কোডই বেশি সুন্দর, মেইন্টেইন করা সহজ আর ডেভেলপার ভেলোসিটির জন্যও অনেক ভালো। Zen principles‑এর এই দিকটা আমি খুব পছন্দ করি।

Suspense Boundary সমস্যা

তাহলে এখন SSG আছে, কনটেন্টও HTML‑এ দেখা যাচ্ছে... কিন্তু পেজগুলো একদম ফাঁকা! এ আবার কীভাবে?! <img alt="SSG রেন্ডার হলেও পেজ ফাঁকা" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

পরে বুঝলাম, renderToReadableStream ব্যবহার করলেও Suspense boundary থেকে যায়, এমনকি তুমি await stream.allReady করলেও। আমার ধারণা, যেহেতু এটা একটা "stream", তাই এটা এমনভাবে বানানো যে বাইট আসতে আসতেই সেটা ক্লায়েন্টের দিকে পাঠানো যায়।

React কী আউটপুট দেয়

তুমি যখন Suspense সহ renderToReadableStream ব্যবহার করো, React প্রায় এমন HTML আউটপুট দেয়:

<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
  <!-- Actual content here -->
</div>
...
<script>/*Script that replaces the suspense boundaries*/</script>

<template id="B:0"> হচ্ছে যেখানে আসল কনটেন্টটা থাকার কথা, সেই জায়গার প্লেসহোল্ডার। <div hidden id="S:0">‑এর ভেতরে থাকে আসল রেন্ডার করা কনটেন্ট। B:0 আর S:0‑এর নম্বরটা মিলেই বাউন্ডারি ম্যাচ করানো হয় (0‑based ইনডেক্স)।

JavaScript ছাড়া সার্চ ইঞ্জিনগুলো (তোমার দিকেই তাকিয়ে আছি, Bing) আর LLM‑গুলো প্রায় ফাঁকা একটা পেজই দেখতে পেত, যেখানে শুধু টেমপ্লেট প্লেসহোল্ডার আছে। এতে SSG করার পুরো উদ্দেশ্যটাই নষ্ট হয়ে যায়!

Suspense boundary গুলোকে সরানোর কোনো পরিষ্কার, সুন্দর উপায় চোখে পড়ছিল না। তাই আমি টেস্ট লিখে, resolveSuspenseBoundaries নামে একটা ফাংশন বানানোর পথটাই নিলাম। এটা JSDOM‑এর মতো কিছু ব্যবহার করে HTML পার্স করে স্ক্রিপ্ট চালানোর চেয়ে দ্রুত হয়েছে। আর তার থেকেও গুরুত্বপূর্ণ, আমার প্ল্যানের জন্য এটা একদম দরকার ছিল: সার্চ ইঞ্জিন আর LLM‑এর জন্য JavaScript ছাড়াই পড়ার মতো সুন্দর পেজ, কিন্তু আবার ক্লায়েন্ট‑সাইডে Suspense boundary আর hydration‑ও থাকবে।

ট্রান্সফরমেশনটা টেস্ট করা

শুরুতে আমি ট্রান্সফরমেশনটার জন্য টেস্ট লিখেছি। JavaScript অফ করে আমি DOM থেকে কিছু উদাহরণ কপি করেছি, একবার যেখানে আছি (JS অফ), আরেকবার যেখানে থাকতে চাই (JS অন)। এগুলো LLM‑এর মধ্যে ঢুকিয়ে দিয়েছি, আর ওকে দিয়ে টেস্ট জেনারেশন করিয়েছি, এই কাজটা ও বেশ ভালোই পারে।

এই টেস্টগুলো রাখা আছে client/src/generators/ssr/renderRoute.test.ts ফাইলে, আর এগুলোই দেখে যে ট্রান্সফরমেশন ঠিকঠাক কাজ করছে কিনা। টেস্টগুলো কভার করে:

  • একদম সিম্পল boundary রিপ্লেসমেন্ট (ব্লগ লিস্টিং)
  • টেমপ্লেট আর ক্লোজিং কমেন্টের মাঝখানে কনটেন্ট থাকলে
  • একাধিক boundary থাকলে
  • কমেন্ট মার্কার ছাড়া boundary
  • সব রকম edge case

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

এটা কিন্তু "রবার্ট সি. মার্টিন তাই বলেছেন" টাইপ সব জায়গায় TDD চাপিয়ে দেওয়ার কথা না, যা তোমার টিমের গতি কমিয়ে দেয়। UI বা যেখানে কোড ঘন ঘন বদলাবে, সেখানে TDD গুঁজে দেওয়ার কোনো দরকারই নেই!

সমাধান: resolveSuspenseBoundaries

এখন যেহেতু টেস্টগুলো তৈরি, LLM‑কে দিয়ে resolveSuspenseBoundaries ফাংশনটা লিখিয়ে নিলাম। এখানে আমি cheerio ব্যবহার করেছি, যাতে RegEx‑এর ব্রিটলনেস এড়ানো যায়, যদিও RegEx ব্যবহার করলে SSG‑র টাইম প্রায় 40% কমে যেত।

export function resolveSuspenseBoundaries(html: string): {html: string; didResolveSuspense: boolean} {
  const originalHtml = html;
  const $ = cheerio.load(originalHtml, {xml: false, isDocument: false, sourceCodeLocationInfo: true});
  const operations: Array<{index: number; removeLength: number; insertText?: string}> = [];

  // Collect hidden divs with their content and positions.
  const hiddenDivs = new Map<string, {content: string; divStartIndex: number; divEndIndex: number}>();
  $('div[hidden][id^="S:"]').each((_, el) => {
    const id = $(el).attr('id');
    if (!id) {
      return;
    }
    const boundaryId = id.substring(2);
    const content = $(el).html() || '';
    const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
    if (typeof startOffset === 'number' && typeof endOffset === 'number') {
      hiddenDivs.set(boundaryId, {content, divStartIndex: startOffset, divEndIndex: endOffset});
    }
  });

  if (hiddenDivs.size === 0) {
    return {html: originalHtml, didResolveSuspense: false};
  }

  // Find templates (B:0) and replace them with the matching hidden content (S:0),
  // following React’s internal $RV behavior.
  $('template[id^="B:"]').each((_, el) => {
    const id = $(el).attr('id');
    if (!id) {
      return;
    }
    const boundaryId = id.substring(2);
    const divInfo = hiddenDivs.get(boundaryId);
    if (!divInfo) {
      return;
    }
    const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
    if (typeof startOffset !== 'number' || typeof endOffset !== 'number') {
      return;
    }

    const templateIndex = startOffset;
    const templateLength = endOffset - startOffset;
    const afterTemplate = originalHtml.substring(templateIndex + templateLength);
    const closingCommentMatch = afterTemplate.match(/<!--\/[
amp;]-->/); const removeEndIndex = closingCommentMatch ? templateIndex + templateLength + closingCommentMatch.index! : templateIndex + templateLength; const divContentStartIndex = originalHtml.indexOf('>', divInfo.divStartIndex) + 1; const divContentEndIndex = originalHtml.lastIndexOf('</', divInfo.divEndIndex); const divContent = originalHtml.substring(divContentStartIndex, divContentEndIndex); operations.push({index: templateIndex, removeLength: removeEndIndex - templateIndex}); operations.push({index: templateIndex, removeLength: 0, insertText: divContent}); operations.push({index: divContentStartIndex, removeLength: divContentEndIndex - divContentStartIndex}); operations.push({index: divInfo.divStartIndex, removeLength: divContentStartIndex - divInfo.divStartIndex}); operations.push({index: divContentEndIndex, removeLength: divInfo.divEndIndex - divContentEndIndex}); }); operations.sort((a, b) => (a.index !== b.index ? b.index - a.index : b.removeLength - a.removeLength)); let resultHtml = originalHtml; for (const operation of operations) { resultHtml = resultHtml.slice(0, operation.index) + (operation.insertText ?? '') + resultHtml.slice(operation.index + operation.removeLength); } return {html: resultHtml, didResolveSuspense: true}; }

এতে যা হচ্ছে, প্রায় ফাঁকা পেজ দেখার বদলে সার্চ ইঞ্জিন আর LLM‑গুলো একটা পুরোপুরি রেন্ডার করা পেজ দেখতে পাচ্ছে।

এখন JavaScript ছাড়াই SSG বেশ ভালোভাবে কাজ করছে! <img alt="JavaScript ছাড়াই Foony‑র ব্লগের SSG ভিউ" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />

লং‑টার্মে React‑এর Suspense‑এর ফরম্যাট পাল্টে যেতে পারে। তখন হয়তো এই Suspense রেজোলিউশনের কোডটাই সরিয়ে দেব, যদি lazy‑loaded পেজগুলোর জন্য আরও ভালো কোনো সমাধান পাই (যেগুলোর জন্য Suspense boundary দরকার হয়)।

Hydration স্ট্র্যাটেজি (আপডেট: এটা নিতে 3 দিন + বাড়তি 1 দিন)

Hydration কঠিন হবে, এটা আগে থেকেই জানতাম। তবুও একটু ঘাঁটাঘাঁটি করার পর শেষমেশ সেটাও দাঁড় করাতে পেরেছি!

Hydration করতে মোট সময় লেগেছে: 3 দিন, আর ডিহাইড্রেশন অ্যাপ্রোচ বদলাতে বাড়তি 1 দিন।

সবচেয়ে কঠিন অংশটাই ছিল প্রথম সেই মিনিমাল, কিন্তু চালু থাকা hydrate পেয়ে বসা। একবার যেদিন "Hello World" আর সাথে navbar ঠিকঠাক ভাবে হাইড্রেট হতে দেখলাম, তখনই আত্মবিশ্বাস পেলাম, হ্যাঁ, এটা গোটা মাস জুড়ে টানতে হবে না!

<img alt="Foony‑র Hello World, navbar সহ সফলভাবে হাইড্রেট হচ্ছে" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

ওই প্রথম মিনিমাল, কাজ করা hydrate‑এর জন্য একটা আলাদা চ্যালেঞ্জ ছিল: আমার দরকার ছিল hydration, আবার চাইছিলাম সার্চ ইঞ্জিন আর LLM‑এর জন্য ভালো SEO, আর চাইনি ডেভেলপারদের Suspense boundary নিয়ে বিশেষ মাথা ঘামাতে হোক।

চ্যালেঞ্জটা কী ছিল

React‑এর hydration ভীষণ লিটারাল: প্রথম রেন্ডারে DOM ঠিক যেমন থাকার কথা, ঠিক তেমন না দেখলেই কনসোলে একখানা সুন্দর, প্রায় ব্যবহার‑অযোগ্য এরর মেসেজ দেয়, তারপর পুরো DOM ফেলে দিয়ে আবার নতুন করে রেন্ডার করা শুরু করে। কোনো ডিফই দেয় না, কী ভুল হলো সেটাও জানায় না!

আমাদের ক্ষেত্রে SSG সবকিছুকে আরও খারাপ করছিল কয়েকটা কারণে:

  1. আমরা HTML‑টা পোস্ট‑প্রসেস করে React 18‑এর স্ট্রিমিং Suspense আর্টিফ্যাক্টগুলো সরাচ্ছিলাম (বটদের জন্য এটা দারুণ)
  2. ক্লায়েন্ট সাইডে সব সময় সার্ভার রেন্ডারের মতো একই ডেটা t = 0 সময়ে থাকত না (SSG ডেটা, ব্লগ মেটাডেটা ইত্যাদি)
  3. আমাদের i18n ডিফল্টভাবে “lazy”, মানে SSG করার সময় কোন কোন ট্রান্সলেশন ব্যবহার হয়েছে সেটা না জানলে, প্রথম রেন্ডারে ট্রান্সলেশন মিস হতে পারত

কী কাজ করল (প্রথম পদ্ধতি: Dehydration)

শুরুতে আমি একটু চালাকি করতে গেলাম। যে কমান্ডগুলো দিয়ে HTML‑এর Suspense boundary রেজোল্ভ করছিলাম, সেগুলোকে command pattern‑এর মতো করে রেকর্ড করতাম, আর তার উল্টা কমান্ডগুলো রিটার্ন করতাম, যাতে চাইলে আবার HTML‑টাকে ঠিক React‑এর প্রয়োজনমতো অবস্থায় ফিরিয়ে নেওয়া যায় hydration‑এর আগে।

ভাবনাটা ছিল, এতে করে index.html‑এ অনেক কম বাইট শিপ করতে পারব। কিন্তু বেশিরভাগ চালাক সমাধানের মতোই, ব্রাউজাররা যখন নিজে থেকে HTML‑এ ছোটখাটো বদল করতে লাগল, যেমন কোথাও ; বা / ঢুকিয়ে দেওয়া বা সরিয়ে দেওয়া, তখন ওই কমান্ডগুলোর ইনডেক্স সব গুলিয়ে গেল।

ইচ্ছে করলে এসব ছোটখাটো ব্রাউজার‑ভেদে পার্থক্য সামলানো যেত, কিন্তু এমন ব্রিটল সমাধান প্রডাকশনে পাঠানোর কোনো ইচ্ছে ছিল না।

Suspense boundary ট্রান্সফরমেশনের উল্টা দিকের কাজ করতে গিয়ে ডুবে থাকার বদলে আমি একদম সিম্পল কিছু করলাম:

আসল, অপরিবর্তিত HTML‑টাই <script type="text"> এর ভেতরে বুন্ডল করে দিলাম।

এই "dehydration" পদ্ধতিটা কাজ করেছিল, কিন্তু পরে আমি আরও ভালো সমাধান পাওয়ার জন্য বাড়তি এক দিন নিয়ে এটা বদলে ফেলেছি।

আরও ভালো পদ্ধতি: Critical Path Suspense Boundary Replacement

প্রথম ইমপ্লিমেন্টেশনের পরও আমি Suspense boundary নিয়ে ঝামেলায় পড়ছিলাম। তখনই বুঝলাম, এর চেয়ে অনেক পরিষ্কার আর সিম্পল একটা রাস্তা আছে। তাই dehydration পদ্ধতিটা সরিয়ে দিয়ে critical path Suspense boundary replacement নামে নতুন একটা অ্যাপ্রোচ নিলাম, যেখানে:

  • Hydration শুরু হওয়ার আগে critical path লোড করা হয়: SSR‑এর সময় যেসব lazy কম্পোনেন্ট প্রিলোড হয়েছিল, সেগুলোকে ক্লায়েন্ট সাইডেও hydrate করার আগে প্রিলোড করিয়ে নিই
  • মেইন্টেইন করা সহজ: কোনো React internal বা AST পার্সিং দরকার নেই (dehydration পদ্ধতিতে HTML পার্স করে আবার ফিরিয়ে আনতে হতো)
  • কম বাইট শিপ হয়: React‑এর আসল SSR রেসপন্স আর <script> ট্যাগে বুন্ডল করে পাঠাতে হয় না
  • সম্ভাব্য flash এড়ানো যায়: HTML ডিহাইড্রেট/রিহাইড্রেট করার দরকার পড়ে না, তাই চোখে পড়ার মতো ঝলক কমে যায়

ইমপ্লিমেন্টেশনটা SSR‑এর সময় কোন কোন lazy কম্পোনেন্ট প্রিলোড হয়েছে, সেটা SSRLazyComponentTracker দিয়ে ট্র্যাক করে, তারপর তাদের import path গুলো hydration ডেটার সাথে পাঠায়, আর ক্লায়েন্ট‑সাইড hydration শুরু হওয়ার আগে সেগুলো সিঙ্ক্রোনাসভাবে প্রিলোড করে ফেলে। Critical path‑এর এসব কম্পোনেন্ট Suspense boundary ছাড়াই সরাসরি রেন্ডার করে, আর HTML ঠিক SSR আউটপুটের সাথেই ম্যাচ করে।

বাকি সব কিছুর জন্য, প্রথম ক্লায়েন্ট রেন্ডারটাই SSR/SSG‑এর মতো আচরণ করে। মানে একই ইনপুট ব্যবহার করে, আর hydrateRoot কল হওয়ার আগেই ওই ইনপুটগুলো সিঙ্ক্রোনাসভাবে পাওয়া যায়। এটা করি আমাদের "ssg-data" দিয়ে বুন্ডল করে।

কনক্রিটভাবে, যা যা করেছি:

  1. SSR ইনপুটগুলোকে একটাই টেক্সট স্ক্রিপ্টে বুন্ডল করা

    • SSG চলার সময়, আমরা Vite module entrypoint‑এর ঠিক আগে একটা <script type="text/foony-ssg" id="foony-ssg-data">...</script> ইনজেক্ট করি
    • ওই স্ক্রিপ্টের ভেতরে থাকে:
      • html: আমরা যে resolved HTML স্ট্যাটিক ফাইলে লিখেছি
      • ssgData: SSR wrapper‑এ ব্যবহৃত সিরিয়ালাইজ করা SSGData। ভবিষ্যতে plan আছে যেন Proxy বা কিছু ব্যবহার করে কেবল সত্যি যেগুলো অ্যাক্সেস হয়েছে, শুধু সেগুলোই রাখা যায়
      • translationData: SSR‑এর সময় যেসব ট্রান্সলেশন কিজ‑ভ্যালু জোড়া ব্যবহার করেছি, সেগুলোর ব্লব
  2. Hydration শুরু হওয়ার ঠিক আগে ওই ইনপুটগুলো ইনজেক্ট করা

    • main.tsx ফাইলে আমরা সিঙ্ক্রোনাসভাবে:
      • #root.innerHTML‑কে ওই সিরিয়ালাইজড resolved HTML দিয়ে সেট করি (যাতে DOM ঠিক যেমনটা SSR‑এর পর ছিল, তেমনই থাকে)
      • অ্যাপটাকে SSGDataProvider দিয়ে wrap করি, যাতে কম্পোনেন্টগুলো প্রথম রেন্ডারেই একই SSGData পায়
  3. i18n‑কে ইনস্ট্যান্ট করার জন্য ট্রান্সলেশন ভ্যালু ইনজেক্ট করা

    • SSR‑এর সময় আমরা কোন কোন ট্রান্সলেশন অবজেক্টে অ্যাক্সেস করেছি, সেগুলো রেকর্ড করি আর SSG স্ক্রিপ্টে পাঠাই
    • ক্লায়েন্ট সাইডে LocaleQueryer.inject() মেথড দিয়ে সেগুলো সরাসরি LocaleQueryer‑এর cache‑এ ঢুকিয়ে দিই, যাতে প্রথম রেন্ডার থেকেই ট্রান্সলেশন পাওয়া যায়

এর ফলে প্রথম রেন্ডারে SSR‑এর সময় যে ডেটা ছিল, ঠিক সেই একই ডেটাই থাকে!

useIsSSRMode() হুকটা আগে থেকেই ইমপ্লিমেন্ট করা ছিল client/src/generators/ssr/isSSRMode.ts ফাইলে:

export function useIsSSRMode(): boolean {
  const [isSSRMode, setIsSSRMode] = React.useState(true);
  
  React.useEffect(() => {
    // After mount (hydration complete), switch to client mode
    setIsSSRMode(false);
  }, []);
  
  return isSSRMode;
}

এই হুক SSR‑এর সময় আর প্রথম ক্লায়েন্ট রেন্ডারে (hydration চলার সময়) true রিটার্ন করে, তারপর কম্পোনেন্ট মাউন্ট হয়ে গেলে আবার false হয়ে যায়। UserBanner, Navbar, আর Dialog‑এর মতো কম্পোনেন্টগুলো এটা ব্যবহার করছে, যাতে hydration mismatch না হয়।

  1. React‑কে প্যাচ করে ভালো ডিফ পাওয়া

আমি ভেবেছিলাম hydration-overlay সরাসরি ব্যবহার করব। কিন্তু ওটা এখন আর বিশেষ মেইনটেইন হয় না, React 18 পর্যন্তেই সাপোর্ট, আর প্রডাকশন‑রেডি অবস্থায়ও নেই। তাই আমি একটা LLM‑কে দিয়ে ওটার repo কপি করিয়ে, সেখান থেকে অনুপ্রেরণা নিয়ে কয়েক মিনিটের মধ্যেই মিনিমাল এক hydration overlay বানিয়ে ফেললাম। খুব বাড়তি কিছু লাগেনি, শুধু ডেভেলপমেন্টের সময় চোখে পড়ার মতো কিছু, যাতে বোঝা যায় কোথায় কী mismatch হচ্ছে।

নতুন overlay‑টা খুবই বেসিক, তাই ডিফগুলো এখনো একদম নিখুঁত না। React কমেন্ট সরিয়ে দেয়, style attributes‑এর পর ; ঢুকিয়ে দেয়, whitespace পাল্টায়, আর ছোটখাটো আরও কিছু জিনিস করে, যেগুলো আমাদের overlay এখনো সামলায় না। আমাদের overlay‑তে আবার HTML কমেন্টও থাকে, যেগুলো React hydration‑এর সময় তোয়াক্কা করে না।

<img alt="আমাদের নতুন hydration overlay" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />

তবু এতেই বোঝা যায়, কোথায় গিয়ে জিনিসটা ভাঙছে আর কী ফিক্স করা দরকার।

<img alt="SSG আউটপুট আর ক্লায়েন্টের প্রথম রেন্ডারের মধ্যে React hydration ডিফ" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />

সংখ্যার হিসেবে

এই ইমপ্লিমেন্টেশনে কী কী জড়িত ছিল, একটু সংখ্যায় দেখি:

  • 2 দিন কাজ (শুরু থেকে কাজ করা SSG পর্যন্ত)। ছুটির মধ্যে, মোটামুটি 24 ঘণ্টার একটু বেশি
  • 4 দিন লেগেছে hydration‑কে ঠিকঠাক করতে, যাতে async ট্রান্সলেশন race বা useMediaQuery কিছু গন্ডগোল না করে
  • বাড়তি 1 দিন লেগেছে dehydration অ্যাপ্রোচ বাদ দিয়ে critical path Suspense boundary replacement আনতে (সহজ, কম বাইট, আর কোনো সম্ভাব্য flash নেই)
  • ~200 লাইন core SSG জেনারেশন কোড (GenerateShellSsgFromSitemap.ts)
  • ~120 লাইন Suspense boundary রেজোলিউশন (renderRoute.tsx‑এর resolveSuspenseBoundaries) - নোট: পরে এটা critical path অ্যাপ্রোচ দিয়ে বদলে ফেলা হয়েছে
  • ~50 লাইন SSR utility (isSSRMode.ts)
  • ~100 লাইন টেস্ট (renderRoute.test.ts)
  • ~150 লাইন SSR‑এর polyfill (setupSSREnvironment)
  • বিদ্যমান কম্পোনেন্টে খুব কম পরিবর্তন (মোটামুটি useIsSSRMode() চেক যোগ করা)

সমাধানটা হালকা আর মেইন্টেইনেবল। কোনো ফ্রেমওয়ার্ক মাইগ্রেশনের দরকার হয়নি, আমাদের আগের React SPA‑এর সাথেই সুন্দরভাবে কাজ করছে।

মূল শিক্ষা

অনেক সময় নিজের বানানো সলিউশনই ভালো হয়

সব সমস্যার জন্য ফ্রেমওয়ার্ক দরকার হয় না। Foony‑র জন্য ছোট, নিজস্ব বানানো SSG সলিউশনটাই সবচেয়ে ভালো ফিট হয়েছে। এটা:

  • হালকা: কোনো ভারী ডিপেন্ডেন্সি বা ফ্রেমওয়ার্ক ওভারহেড নেই
  • মেইন্টেইনেবল: সোজা কোড, যা আমরা নিজেরাই পুরোটা বুঝি
  • ফ্লেক্সিবল: দরকার হলে সহজেই বদলানো আর এক্সটেন্ড করা যায়
  • কম্প্যাটিবল: আমাদের আগের React SPA‑এর সাথেই কাজ করে, মাইগ্রেশন ছাড়াই

React‑এর স্ট্রিমিং SSR‑এর নিজস্ব কিছু ঝামেলা আছে

React‑এর renderToReadableStream Suspense সামলানোর জন্য দারুণ, কিন্তু এর কিছু অদ্ভুত দিকও আছে। তুমি await stream.allReady করলেও আউটপুটে Suspense boundary থেকে যায়। এটা বাগ না, বরং স্ট্রিমিংয়ের ডিজাইনের অংশ। কিন্তু SSG‑এর জন্য আমাদের দরকার সম্পূর্ণ রেজোল্ভ করা HTML। এই সিনারিওটা পরিষ্কারভাবে সামলানোর ব্যবস্থা না থাকা আমার কাছে React টিমের একটা ছোট ব্যর্থতার মতোই লাগে।

আমার সমাধান ছিল HTML‑টা পোস্ট‑প্রসেস করে boundary গুলো নিজে হাতে রেজোল্ভ করা। দেখতে খুব ফ্যান্সি না, কিন্তু আমার প্রয়োজনের জন্য যথেষ্ট ফাস্ট আর ফ্লেক্সিবল।

LLM‑এর জন্য TDD বেশ উপকারী হতে পারে

HTML ট্রান্সফরমেশন খুবই error‑prone কাজ। ছোট একটা বাগেই পুরো SSG আউটপুট ভেঙে যেতে পারে, আর ইউজার একদম ফাঁকা পেজ পেতে পারে। আমি LLM‑কে (নিজের ইনপুটসহ) দিয়ে বেশ বিস্তৃত টেস্ট লিখিয়েছি, যাতে ট্রান্সফরমেশনটা ঠিকমতো কাজ করছে কিনা নিশ্চিত হওয়া যায়।

শেষ কথা

Foony‑র জন্য এখন SSG কাজ করছে। পেজগুলো সার্চ ইঞ্জিন আর LLM‑এর জন্য পুরোপুরি রেন্ডার হচ্ছে, আর সমাধানটা মেইন্টেইন করাও সহজ, হালকাও বটে। SSG রুটগুলোর hydration আমার চিন্তার চেয়ে বেশি সময় নিয়েছে (3 দিন), আর প্রথম dehydration পদ্ধতিটা বাদ দিয়ে critical path Suspense boundary replacement আনতে বাড়তি এক দিন লেগেছে। নতুন অ্যাপ্রোচটা মেইন্টেইন করা সহজ, কম বাইট শিপ করে, আর HTML ডিহাইড্রেট/রিহাইড্রেট করার সম্ভাব্য visual flash এড়িয়ে যায়।

এখনো ভাবলে একটু অবিশ্বাসই লাগে, SSG‑র জন্য এমন একটা নিজস্ব সলিউশন দাঁড় করাতে আসলে মাত্র 2 দিন লেগেছে। অনেক সময় সত্যি সত্যিই সবচেয়ে সঠিক সমাধানটাই হয় সবচেয়ে সিম্পলটা।

আগামীতে কাজ আছে hydration‑এর ম্যাচিং পুরোপুরি ঠিকঠাক করা, আর ডিবাগিং আরও ভালো করতে চাইলে React‑কে সামান্য প্যাচ করা। আপাতত Foony‑তে SSG সুন্দরভাবেই চলছে। পরের কয়েক সপ্তাহে Google Search Console আর Bing Webmaster Tools‑এ নজর রাখব, দেখে নেব এই সবকিছুর SEO‑তে কী প্রভাব পড়ে।

8 Ball Pool online multiplayer billiards icon