background blurbackground mobile blur

1/1/1970

কীভাবে আমি ২ দিনে SSG বানিয়ে ফেললাম

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

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

স্ট্যান্ডার্ড সমাধানগুলো কেন না?

প্রথমে যখন Foony-তে SSG যোগ করার কথা ভাবলাম, স্বাভাবিকভাবেই NextJS (ইন্ডাস্ট্রি স্ট্যান্ডার্ড), Vike এবং Astro-র কথা ভাবলাম।

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

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

  • আমাদের পুরো রাউটিং সিস্টেম আবার লেখা
  • কীভাবে গেম এবং কম্পোনেন্ট লোড হয় তার পুনর্গঠন
  • আগের ফিচার পেতেই কয়েক মাসের কাজ
  • ব্যবহারকারীদের জন্য সম্ভাব্য ব্রেকিং পরিবর্তন
  • ছবি হ্যান্ডেল করার পদ্ধতি বদলানো
  • উল্লেখযোগ্যভাবে ধীর বিল্ড টাইম (সম্ভবত ৫-৩০ মিনিট। এর পক্ষে কোনো নির্দিষ্ট সংখ্যা নেই, শুধু GitHub-এ এই ৫ বছরের পুরনো আলোচনা ছাড়া)
  • পুরো টিমের নতুন কিছু (NextJS) শেখা, এবং চিরকালের জন্য ধীর ডেভেলপার গতি
  • যখনই NextJS ব্রেকিং পরিবর্তন আনবে, কোড মাইগ্রেট করতে হবে

NextJS দিয়ে একটা ভুয়া শুরু পর্যন্ত করেছিলাম, কিন্তু দ্রুত বুঝতে পারলাম মাইগ্রেশনের খরচ অনেক বেশি। জটিলতা ফায়দার যোগ্য না।

Vike: একই রকম জটিলতা

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

Astro: ভুল আর্কিটেকচার

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

সমাধান: কাস্টম SSG

কয়েকদিন আগে i18n-এর পরে বানানো আমার "ফেক SSG" পদ্ধতির অনুপ্রেরণায়, Foony-এর SSG-এর জন্য একটা ছোট, হালকা, কাস্টম সমাধান বেছে নিলাম।

আমার "ফেক SSG" পদ্ধতিতে ব্লগ পোস্টযুক্ত পেজগুলো (/posts রুট এবং গেম পেজ) থেকে ব্লগ কন্টেন্ট টেনে আনতাম, এবং সার্চ ইঞ্জিন ও 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 স্ট্যাটিক ফাইলে লেখে। সহজ, আমার পছন্দমতো!

এটা বেশ দ্রুতও হলো। প্রায় ২,৮০০ রুট ১০ সেকেন্ডে রেন্ডার হলো। চমৎকার। এটা 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 বাউন্ডারির সমস্যা

তো এখন 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 বাউন্ডারি থাকে, এমনকি আপনি await stream.allReady করলেও। আমার ধারণা এটা একটা "স্ট্রিম" বলে, এবং বাইট আসার সাথে সাথে ক্লায়েন্টে পাঠানোর জন্য ডিজাইন করা হয়েছে।

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 সংখ্যা দিয়ে মিলে যায় (০-ভিত্তিক ইনডেক্স)।

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

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

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

আমার কাছে যা ছিল (JavaScript বন্ধ) এবং যা চাইছিলাম (JavaScript চালু) তা থেকে DOM-এর কিছু উদাহরণ নিয়ে ট্রান্সফরমেশনের টেস্ট লেখা শুরু করলাম। এগুলো একটা LLM-কে দিলাম এবং তাকে টেস্ট জেনারেশন করতে বললাম, এই কাজে এটা বেশ ভালো। এই টেস্টগুলো আছে client/src/generators/ssr/renderRoute.test.ts-এ এবং নিশ্চিত করে যে ট্রান্সফরমেশন সঠিকভাবে কাজ করে। টেস্টগুলো কভার করে:

  • সরল বাউন্ডারি প্রতিস্থাপন (ব্লগ লিস্টিং)
  • টেমপ্লেট এবং বন্ধনী মন্তব্যের মধ্যে কন্টেন্ট সহ জটিল বাউন্ডারি
  • একাধিক বাউন্ডারি
  • মন্তব্য মার্কার ছাড়া বাউন্ডারি
  • এজ কেস

এই ধরনের "TDD" আসলে এই কাজে বেশ উপকারী যেখানে আপনার প্রত্যাশিত ইনপুট এবং আউটপুট আছে।

এটাকে "Robert C. Martin বলেছেন তাই সবকিছুতে TDD" (যা আপনার টিমের ডেভেলপমেন্ট গতি কমিয়ে দেবে)-এর সাথে গুলিয়ে ফেলবেন না। UI বা কোডের যেসব এলাকা সারাক্ষণ পরিবর্তিত হচ্ছে সেখানে TDD ব্যবহার করা উচিত নয়!

সমাধান: resolveSuspenseBoundaries

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

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="Foony-র ব্লগের জন্য JavaScript ছাড়া 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 ফরম্যাট পরিবর্তন করতে পারে। যখন lazy-load করা পেজগুলোর (যা Suspense বাউন্ডারি দরকার) জন্য আমার একটা ভালো সমাধান হবে, তখন হয়তো এই Suspense রেজোলিউশন কোডটা সরিয়ে ফেলব।

হাইড্রেশন স্ট্র্যাটেজি (আপডেট: এতে ৩ দিন + ১ অতিরিক্ত দিন লেগেছে)

হাইড্রেশন চ্যালেঞ্জিং। এটা আমি জানতাম। কিন্তু, কিছু কাজের পরে, এটা চালু করতে পারলাম!

হাইড্রেশনের জন্য মোট সময়: ৩ দিন, এবং ডিহাইড্রেশন পদ্ধতি প্রতিস্থাপনের জন্য আরও ১ দিন।

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

<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" }} />

সেই প্রথম ছোট, কর্মক্ষম হাইড্রেটের জন্য আমার একটা অনন্য চ্যালেঞ্জ ছিল: আমি হাইড্রেশন চাইছিলাম, কিন্তু পাশাপাশি ডেভেলপারদের Suspense বাউন্ডারি নিয়ে চিন্তা করতে না হয়ে সার্চ ইঞ্জিন এবং LLM-এর জন্য ভালো SEO চাইছিলাম।

চ্যালেঞ্জ

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

আমাদের ক্ষেত্রে, SSG এটাকে কয়েকভাবে আরও খারাপ করেছিল:

  1. আমরা React 18 স্ট্রিমিং Suspense আর্টিফ্যাক্ট সরাতে/সমাধান করতে HTML পোস্ট-প্রসেস করেছিলাম (যা বটদের জন্য চমৎকার)।
  2. ক্লায়েন্টের কাছে (t = 0) সময়ে সার্ভার রেন্ডার যা পেয়েছিল ঠিক একই ডেটা সবসময় ছিল না (SSG ডেটা, ব্লগ মেটাডেটা, ইত্যাদি)।
  3. আমাদের i18n ডিফল্টরূপে "lazy", যার মানে প্রথম রেন্ডারের জন্য অনুবাদ হারিয়ে যেতে পারে যদি না SSG-র জন্য কোন অনুবাদগুলো ব্যবহৃত হয়েছিল তা রেকর্ড করেন এবং React রেন্ডার করার আগে সেগুলো ইনজেক্ট করেন।

যা কাজ করল (প্রাথমিক পদ্ধতি: ডিহাইড্রেশন)

প্রথমে চটকদার এবং সুন্দর কিছু চেষ্টা করলাম: HTML-এর Suspense বাউন্ডারি সমাধান করতে ব্যবহৃত কমান্ডগুলো রেকর্ড করতে আমি একটা কমান্ড প্যাটার্ন ব্যবহার করলাম, এবং বিপরীত ট্রান্সফরমেশন কমান্ড ফেরত দিলাম যাতে হাইড্রেশনের জন্য React-এর প্রয়োজনীয় HTML-এ পুনরুদ্ধার করতে পারি। আমার আশা ছিল এই কমান্ড পদ্ধতিতে index.html-এ অনেক কম বাইট পাঠাতে পারব। কিন্তু, বেশিরভাগ চটকদার সমাধানের মতো, এটা ব্যর্থ হলো কারণ ব্রাউজার HTML-কে সূক্ষ্মভাবে পরিবর্তন করে, যেমন ; বা / সরিয়ে বা যোগ করে, যা প্রতিস্থাপন ইনডেক্সগুলোকে ভুল করে দিত। টেকনিক্যালি আপনি হয়তো এই সূক্ষ্ম ব্রাউজার পরিবর্তনগুলোর হিসাব রাখতে পারতেন, কিন্তু আমি এত ভঙ্গুর কিছু পাঠাতে রাজি ছিলাম না। Suspense-বাউন্ডারি ট্রান্সফরমেশনকে React-এর স্ট্রিমিং মার্কআপে "বিপরীত" করার চেষ্টার বদলে, আমি অসাধারণ সরল কিছু করলাম:

মূল, অসমাধানকৃত HTML-কে একটা <script type="text">-এ বান্ডেল করো।

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

আরও ভালো পদ্ধতি: ক্রিটিকাল পাথ Suspense বাউন্ডারি প্রতিস্থাপন

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

  • হাইড্রেশনের আগে ক্রিটিকাল পাথ লোড করে: SSR-এর সময় যেসব কম্পোনেন্ট প্রিলোড হয়েছিল সেগুলো শনাক্ত করে এবং hydrateRoot কল করার আগে ক্লায়েন্টে প্রিলোড করে
  • রক্ষণাবেক্ষণ সহজ: কোনো React অভ্যন্তরীণ বা AST পার্সিং দরকার নেই (ডিহাইড্রেশন পদ্ধতিতে HTML পার্স ও পুনরুদ্ধার দরকার ছিল)
  • কম বাইট পাঠায়: আমরা আর React থেকে আসা মূল SSR রেসপন্সকে স্ক্রিপ্ট ট্যাগে বান্ডেল করি না
  • সম্ভাব্য ফ্ল্যাশ প্রতিরোধ করে: HTML ডিহাইড্রেট/রিহাইড্রেট করার দরকার নেই, একটা সম্ভাব্য ভিজ্যুয়াল ফ্ল্যাশ এড়ানো যায়

ইমপ্লিমেন্টেশন ট্র্যাক করে SSR-এর সময় কোন lazy কম্পোনেন্ট প্রিলোড হয়েছিল (SSRLazyComponentTracker-এর মাধ্যমে), হাইড্রেশন ডেটায় তাদের ইম্পোর্ট পাথ অন্তর্ভুক্ত করে, এবং হাইড্রেশনের আগে সিঙ্ক্রোনাসভাবে প্রিলোড করে। ক্রিটিকাল পাথ কম্পোনেন্টগুলো Suspense বাউন্ডারি ছাড়াই সরাসরি রেন্ডার হয়, ঠিক SSR আউটপুটের সাথে মিলিয়ে।

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

কংক্রিটভাবে, পরিবর্তনগুলো ছিল:

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

    • SSG-এর সময়, আমরা Vite মডিউল এন্ট্রিপয়েন্টের ঠিক আগে একটা <script type="text/foony-ssg" id="foony-ssg-data">...</script> ইনজেক্ট করি।
    • সেই স্ক্রিপ্টে থাকে:
      • html: যে সমাধানকৃত HTML আমরা স্ট্যাটিক ফাইলে আসলে পাঠিয়েছি
      • ssgData: SSR র‍্যাপারে ব্যবহৃত সিরিয়ালাইজড SSGData। আমি এটাকে Proxy বা এমন কিছুতে আপডেট করার পরিকল্পনা করছি যাতে শুধু অ্যাক্সেস করা ডেটা অন্তর্ভুক্ত হয়।
      • translationData: SSR-এর সময় যে অনুবাদ key-value ব্লব আমরা স্পর্শ করেছি
  2. হাইড্রেশনের ঠিক আগে এই ইনপুটগুলো ইনজেক্ট করো

    • main.tsx-এ, আমরা সিঙ্ক্রোনাসভাবে:
      • #root.innerHTML-কে সিরিয়ালাইজড সমাধানকৃত HTML-এ সেট করি (যাতে DOM ঠিক যা হাইড্রেশন দেখে তেমনি হয়)
      • অ্যাপটাকে SSGDataProvider-এ র‍্যাপ করি যাতে কম্পোনেন্টগুলো প্রথম রেন্ডারে একই SSGData পায়
  3. অনুবাদ মান ইনজেক্ট করে i18n-কে তাৎক্ষণিক করো

    • SSR-এর সময় অ্যাক্সেস করা প্রকৃত অনুবাদ অবজেক্টগুলো রেকর্ড করি এবং SSG স্ক্রিপ্টে পাঠাই।
    • ক্লায়েন্টে, আমরা একটা ডেডিকেটেড LocaleQueryer.inject() মেথডের মাধ্যমে সেগুলোকে সরাসরি LocaleQueryer-এর ক্যাশে ইনজেক্ট করি, যাতে অনুবাদ অবিলম্বে উপলব্ধ থাকে।

এবং এর সাথে, প্রথম রেন্ডারে 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-এর সময় এবং প্রথম ক্লায়েন্ট রেন্ডারে (হাইড্রেশন) true ফেরত দেয়, তারপর মাউন্টের পরে false-এ চলে যায়। UserBanner, Navbar, এবং Dialog-এর মতো কম্পোনেন্টগুলো হাইড্রেশন মিসম্যাচ প্রতিরোধে ইতিমধ্যে এটা ব্যবহার করে।

  1. ভালো ডিফের জন্য React প্যাচ করো

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

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

<img alt="আমাদের নতুন হাইড্রেশন ওভারলে" 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="React হাইড্রেশনের জন্য আমাদের SSG বনাম ক্লায়েন্ট প্রথম-পেজ রেন্ডারের ডিফ" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />

সংখ্যায়

এই ইমপ্লিমেন্টেশনে কী জড়িত ছিল তার একটা ধারণা দিতে:

  • ২ দিন কাজ (শুরু থেকে কর্মক্ষম SSG পর্যন্ত)। এটা ছুটিতে মাত্র ২৪ ঘণ্টার একটু বেশি ছিল।
  • ৪ দিন কাজ অ্যাসিঙ্ক অনুবাদ রেস বা useMediaQuery জিনিসপত্র গোলমেলে না করেই হাইড্রেশনকে সুন্দরভাবে চালু করতে।
  • ১ অতিরিক্ত দিন ক্রিটিকাল পাথ Suspense বাউন্ডারি প্রতিস্থাপন দিয়ে ডিহাইড্রেশন পদ্ধতিকে বদলাতে (সরল, কম বাইট, কোনো সম্ভাব্য ফ্ল্যাশ নেই)।
  • ~২০০ লাইন কোর SSG জেনারেশন কোড (GenerateShellSsgFromSitemap.ts)
  • ~১২০ লাইন Suspense বাউন্ডারি রেজোলিউশন (renderRoute.tsx-এ resolveSuspenseBoundaries) - নোট: এটা পরে ক্রিটিকাল পাথ পদ্ধতি দিয়ে বদলানো হয়েছে
  • ~৫০ লাইন SSR ইউটিলিটি (isSSRMode.ts)
  • ~১০০ লাইন টেস্ট (renderRoute.test.ts)
  • ~১৫০ লাইন SSR-এর জন্য পলিফিল (setupSSREnvironment)
  • ন্যূনতম পরিবর্তন বর্তমান কম্পোনেন্টে (বেশিরভাগ useIsSSRMode() চেক যোগ করা)

সমাধানটা হালকা এবং রক্ষণাবেক্ষণযোগ্য। এতে ফ্রেমওয়ার্ক মাইগ্রেশন দরকার নেই, এবং এটা আমাদের বর্তমান React SPA-র সাথে কাজ করে।

মূল শিক্ষা

কখনও কখনও কাস্টম সমাধান ভালো

প্রতিটা সমস্যার জন্য ফ্রেমওয়ার্ক দরকার নেই। Foony-র জন্য, একটা ছোট, কাস্টম SSG সমাধানই ছিল সঠিক পছন্দ। এটা:

  • হালকা: কোনো ভারী ডিপেন্ডেন্সি বা ফ্রেমওয়ার্কের ওভারহেড নেই
  • রক্ষণাবেক্ষণযোগ্য: আমরা যে সরল কোড বুঝি
  • নমনীয়: প্রয়োজনমতো সংশোধন এবং প্রসারিত করা সহজ
  • সামঞ্জস্যপূর্ণ: মাইগ্রেশন ছাড়া আমাদের বর্তমান React SPA-র সাথে কাজ করে

React-এর স্ট্রিমিং SSR-এর কিছু বিশেষত্ব আছে

React-এর renderToReadableStream Suspense হ্যান্ডেল করার জন্য চমৎকার, কিন্তু এর কিছু বিশেষত্ব আছে। এমনকি await stream.allReady দিয়েও, আউটপুটে এখনও Suspense বাউন্ডারি থাকে। এটা বাগ না, এটা স্ট্রিমিং-এর জন্য ডিজাইন করা। কিন্তু SSG-এর জন্য, আমাদের পুরোপুরি সমাধানকৃত HTML দরকার। মনে হয় এই পরিস্থিতি পরিচ্ছন্নভাবে হ্যান্ডেল না করে React টিম একটা ব্যর্থতা দেখিয়েছে।

আমার সমাধান ছিল HTML পোস্ট-প্রসেস করা এবং বাউন্ডারি সমাধান করা। সুন্দর না, কিন্তু আমার ব্যবহারের জন্য দ্রুত এবং যথেষ্ট নমনীয়।

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

HTML ট্রান্সফরমেশন ভুলপ্রবণ। একটা ছোট বাগে পুরো SSG আউটপুট ভেঙে যেতে পারে এবং শেষ-ব্যবহারকারীর অভিজ্ঞতা নষ্ট হতে পারে। ট্রান্সফরমেশন সঠিকভাবে কাজ করছে নিশ্চিত করতে আমি একটা LLM-কে দিয়ে ব্যাপক টেস্ট লিখিয়েছি (আমার ইনপুট সহ)।

উপসংহার

Foony-র জন্য SSG এখন কাজ করছে। সার্চ ইঞ্জিন এবং LLM-এর জন্য পেজগুলো সম্পূর্ণরূপে রেন্ডার হয়, এবং সমাধানটা রক্ষণাবেক্ষণযোগ্য ও হালকা। SSG রুটগুলোর জন্য হাইড্রেশন আশার চেয়ে বেশি সময় (৩ দিন) নিয়েছে, এবং প্রাথমিক ডিহাইড্রেশন পদ্ধতিকে ক্রিটিকাল পাথ Suspense বাউন্ডারি প্রতিস্থাপন দিয়ে বদলাতে আমার আরও একদিন লেগেছে। নতুন পদ্ধতি রক্ষণাবেক্ষণে সহজ, কম বাইট পাঠায়, এবং HTML ডিহাইড্রেট/রিহাইড্রেট করা থেকে সম্ভাব্য ভিজ্যুয়াল ফ্ল্যাশ প্রতিরোধ করে।

এখনও অবাক হয়ে আছি যে SSG-এর জন্য একটা কাস্টম সমাধান বানাতে মাত্র ২ দিন লেগেছে। কিন্তু কখনও কখনও সঠিক সমাধানই সবচেয়ে সরল।

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

8 Ball Pool online multiplayer billiards icon