সরাসরি পর্বে যান
পর্ব ৫১ · ৫৯-এর মধ্যে · মডিউল ৭

real-time অ্যাপ — WebSocket আর Server-Sent Events

Real-time JavaScript — Polling, Server-Sent Events & EventSource, the WebSocket Handshake, Rooms & Presence, Heartbeats, Reconnection, Security and Scaling with Pub/Sub

Taught in Bangla, this lesson explains how servers push live updates: the limits of request–response, polling and long polling, Server-Sent Events with EventSource and an Express SSE server, the WebSocket handshake and browser API, a message protocol with rooms and presence, heartbeats, reconnection and backpressure, authentication and Origin checks, and scaling with pub/sub.

এই পর্বে যা শিখবেন

  • কেন request–response মডেল real-time কাজে আটকে যায়, আর polling ও long polling-এর আসল খরচ
  • SSE-র text/event-stream ফরম্যাট, EventSource, named event, retry আর Last-Event-ID — Node.js-এ নিজে সার্ভার ও client বানিয়ে
  • WebSocket handshake, frame, ws:// বনাম wss://, ব্রাউজার API, close code আর ws প্যাকেজের সার্ভার
  • বার্তার প্রোটোকল নকশা, room, broadcast, presence, heartbeat, backoff-সহ reconnection আর backpressure
  • Authentication, Origin যাচাই, rate limit, Redis pub/sub দিয়ে scaling, Socket.IO, আর কখন কোনটি বাছবেন

১ · সার্ভার কেন নিজে থেকে খবর দিতে পারে না — request–response-এর সীমা

পর্ব ৩৫-এ আপনি HTTP-র মূল নিয়ম দেখেছেন: client (ব্রাউজার) একটি অনুরোধ পাঠায়, server একটি উত্তর দেয়, কথা শেষ। পর্ব ৫০-এ Express দিয়ে যে REST API বানানো হয়েছে, সেটিও এই ছাঁচে চলে। নিয়মটির একটি গোপন শর্ত আছে — কথা শুরু করে সবসময় client. সার্ভারের কাছে নতুন খবর থাকলেও সে ব্রাউজারকে ডাকতে পারে না; অপেক্ষা করতে হয়, কবে ব্রাউজার আবার জিজ্ঞেস করবে।

বেশিরভাগ পাতায় এতে সমস্যা নেই। কিন্তু কিছু কাজে খবরের মূল্য তার সময়ে:

🛵 লাইভ অর্ডার ট্র্যাকিং

“রান্না হচ্ছে → রাইডার রওনা দিয়েছেন → পৌঁছে গেছে” — প্রতিটি ধাপ রেস্তোরাঁ বা রাইডারের অ্যাপে ঘটে, আর গ্রাহকের পর্দায় সাথে সাথে দেখাতে হয়।

💬 কাস্টমার-সাপোর্ট চ্যাট

গ্রাহক আর এজেন্ট দুজনেই যেকোনো সময় লেখেন — খবর দুই দিকে।

🏏 লাইভ ক্রিকেট স্কোর

বিপিএলের ফাইনালে লাখো দর্শক একই স্কোর দেখছেন। খবর শুধু সার্ভার থেকে আসে, দর্শকেরা কিছু পাঠান না।

🔔 অ্যাডমিন ড্যাশবোর্ড আর “কে এখন অনলাইন”

নতুন অর্ডার এলেই নোটিফিকেশন, আর একটি যৌথ ডকুমেন্টে কে কে এখন আছেন তার তালিকা (presence).

এই কাজগুলোকে বলা হয় real-time — খবর তৈরি হওয়া আর পর্দায় পৌঁছানোর মাঝের দেরি (latency) যতটা সম্ভব কম। ব্যবহারিক ভাষায়, সার্ভারকে খবর push করতে হয়, client-কে বারবার pull করতে নয়। ওয়েবে এর তিনটি প্রধান পথ আছে, আর সেগুলো একটি সিঁড়ির মতো — সরল থেকে শক্তিশালী।

ধরুন আপনি দর্জির দোকানে ঈদের পাঞ্জাবি বানাতে দিয়েছেন। Polling: প্রতি ঘণ্টায় দোকানে গিয়ে জিজ্ঞেস করা “হয়েছে?” — বেশিরভাগ দিন উত্তর “না”, যাতায়াতের খরচ বৃথা। Long polling: দোকানে গিয়ে বসে থাকা, দর্জি বলেছেন “হলেই বলব” — হলে নিয়ে বাড়ি ফেরেন, পরের জিনিসের জন্য আবার গিয়ে বসেন। Server-Sent Events: দর্জির নম্বরে একবার মিসড কল দিয়ে রেখেছেন, আর তিনি এসএমএসে খবর পাঠাতে থাকেন — “কাপড় কাটা হলো”, “সেলাই চলছে”, “তৈরি”। খবর একদিকে আসে। WebSocket: একটি চলমান ফোন কল — দুজনেই যেকোনো সময় কথা বলেন, “বোতামটা কালো দেবেন?” “ঠিক আছে”।

পুরো পর্বের মূল প্রশ্ন তাই একটি: আপনার কাজে খবর কোন দিকে যায়, আর কত ঘন ঘন? সিঁড়ির প্রতিটি ধাপে ক্ষমতার সাথে জটিলতাও বাড়ে।

২ · Polling আর long polling — পুরোনো পথ আর তার খরচ

পর্ব ৩৬-এ polling আর long polling-এর পরিচয় পেয়েছেন। এবার খরচের হিসাবটা করা যাক, কারণ সিদ্ধান্ত নিতে হিসাব লাগে।

একটি খাবার-ডেলিভারি অ্যাপ ধরুন। সন্ধ্যার ভিড়ে ১০,০০০ জন গ্রাহক অর্ডারের পাতা খুলে রেখেছেন, আর পাতাটি প্রতি ৫ সেকেন্ডে জিজ্ঞেস করে “অবস্থা বদলেছে?” তাহলে সার্ভারে আসে সেকেন্ডে ১০,০০০ ÷ ৫ = ২,০০০টি request. একটি অর্ডারের অবস্থা গোটা ৪০ মিনিটে হয়তো ৪–৫ বার বদলায়; অর্থাৎ ৪৮০টি প্রশ্নের মধ্যে ৪৭৫টির উত্তর “না, কিছু বদলায়নি”। প্রতিটির সাথে যায় cookie আর header, প্রতিটিতে লগইন যাচাই আর ডেটাবেস খোঁজা — তবু খবর আসে গড়ে আড়াই সেকেন্ড দেরিতে। বিরতি কমালে দেরি কমে, খরচ বাড়ে; এই টানাপোড়েনই polling-এর মূল সমস্যা।

বিষয়Polling (প্রতি ৫ সেকেন্ডে)Long polling
খবর পৌঁছানোর দেরিগড়ে বিরতির অর্ধেক (≈ ২.৫ সেকেন্ড)প্রায় সাথে সাথে, তারপর নতুন request-এর একটি round-trip
খালি উত্তরবেশিরভাগশুধু সময়সীমা পেরোলে (যেমন ২৫–৩০ সেকেন্ড পরে)
সার্ভারের ভারপ্রচুর ছোট requestঅনেক খোলা, অপেক্ষমাণ request
সুবিধাসবচেয়ে সরল; যেকোনো সার্ভার, cache, CDN-এ চলেসাধারণ HTTP; পুরোনো proxy-র পেছনেও চলে
ফাঁদsetInterval ধীর সার্ভারে request জমায়পরপর দুটি খবরের মাঝে একটি হারাতে পারে; ক্রম রাখা কঠিন

Long polling-এ ব্রাউজার জিজ্ঞেস করে, আর সার্ভার উত্তর আটকে রাখে যতক্ষণ না খবর আসে বা একটি সময়সীমা পেরোয়। খবর এলে উত্তর যায়, ব্রাউজার সাথে সাথে নতুন request পাঠায়। দেরি কম, কিন্তু একটি সূক্ষ্ম ফাঁক আছে: উত্তর পাঠানো আর নতুন request আসার মাঝের মুহূর্তে আরেকটি খবর তৈরি হলে সেটি কোথায় থাকবে? সমাধান হলো প্রতিটি খবরের একটি ক্রমিক id রাখা, আর client প্রতিবার বলে দেবে “আমি ১৭ পর্যন্ত পেয়েছি”। এই ধারণাটি মনে রাখুন — SSE-র Last-Event-ID ঠিক এটাই, তবে তৈরি অবস্থায়।

Polling খারাপ নয়। খবর কদাচিৎ বদলালে, ব্যবহারকারী কম হলে, বা দীর্ঘ সংযোগ রাখা যায় না এমন serverless প্ল্যাটফর্মে ৩০ সেকেন্ডের polling-ই সঠিক প্রকৌশল — পর্ব ৩৬-এর নিয়ম মেনে।

৩ · Server-Sent Events — একটি খোলা HTTP সংযোগে একমুখী ধারা

Server-Sent Events (SSE)-এর বুদ্ধিটি খুব সাদামাটা: ব্রাউজার একটি সাধারণ GET request পাঠায়, আর সার্ভার উত্তর দেয় — কিন্তু উত্তর শেষ করে না. সংযোগ খোলা রেখে সে মাঝে মাঝে কয়েক লাইন লেখা পাঠাতে থাকে। প্রতিটি লেখার টুকরো একটি event. নতুন কোনো প্রোটোকল নেই, নতুন port নেই — শুধু একটি লম্বা HTTP উত্তর, যার header-এ লেখা Content-Type: text/event-stream.

text/event-stream ফরম্যাট — চারটি field আর একটি ফাঁকা লাইন

এই event-stream ফরম্যাট UTF-8 লেখার কয়েকটি লাইন। প্রতিটি লাইন field: মান, আর একটি ফাঁকা লাইন মানে “এই event শেষ, এবার পাঠাও”। Field মোট চারটি:

retry: 3000

id: 1
event: status
data: {"step":"cooking","text":"রান্না হচ্ছে"}

: এটি একটি comment — ব্রাউজার উপেক্ষা করে, সংযোগ সজাগ রাখতে কাজে লাগে

id: 2
event: status
data: {"step":"on-the-way",
data: "text":"রাইডার রওনা দিয়েছেন"}

data: — event-এর আসল তথ্য; একাধিক data: লাইন থাকলে ব্রাউজার সেগুলো \n দিয়ে জুড়ে একটি লেখা বানায় (দ্বিতীয় event-এ তাই পুরো JSON ঠিকই জোড়া লাগে)। event: — event-এর নাম; না দিলে নাম হয় "message". id: — এই event-এর পরিচয়; ব্রাউজার শেষ পাওয়া id মনে রাখে। retry: — সংযোগ কাটলে কত মিলিসেকেন্ড পরে আবার চেষ্টা করবে। : দিয়ে শুরু হওয়া লাইন comment. Field-এর নামের পরে :, তারপর একটি ঐচ্ছিক space — সেটি বাদ দিয়ে বাকিটুকু মান। লেখা ছাড়া (binary) কিছু পাঠানো যায় না; ছবি বা ফাইল পাঠাতে হলে তার URL পাঠান।

ব্রাউজারে EventSource

ব্রাউজারের EventSource API এই ফরম্যাট পড়ে প্রতিটি event-কে একটি DOM event বানিয়ে দেয়। নামহীন event আসে onmessage-এ, আর নাম থাকলে addEventListener("নাম", …) দিয়ে ধরতে হয় — onmessage সেগুলো পায় না, নতুনদের একটি সাধারণ বিভ্রান্তি:

const es = new EventSource("/orders/42/events");   // একই origin — cookie নিজে থেকেই যায়

es.addEventListener("status", (e) => {
  const { step, text } = JSON.parse(e.data);
  showStep(text);                                   // "রাইডার রওনা দিয়েছেন"
  if (step === "delivered") es.close();             // কাজ শেষ — আর reconnect নয়
});
es.onmessage = (e) => console.log("নামহীন event:", e.data, e.lastEventId);
es.onerror = () => console.log("সংযোগে সমস্যা, readyState =", es.readyState);
// readyState: 0 = CONNECTING (আবার চেষ্টা চলছে), 1 = OPEN, 2 = CLOSED

এখানে সবচেয়ে মূল্যবান অংশটি কোডে দেখাই যায় না: স্বয়ংক্রিয় reconnection. নেটওয়ার্ক কাটলে বা সার্ভার উত্তর শেষ করে দিলে EventSource নিজেই retry-এর সময় (না দিলে ব্রাউজারের ঠিক করা কয়েক সেকেন্ড) অপেক্ষা করে আবার সংযোগ করে, আর নতুন request-এ একটি header যোগ করে: Last-Event-ID, যার মান শেষ পাওয়া id. সার্ভার সেটি দেখে শুধু বাকি event-গুলো পাঠায়। সার্ভার যদি চায় ব্রাউজার আর চেষ্টা না করুক, সে HTTP 204 No Content দেয়; ভুল status বা ভুল Content-Type পেলেও ব্রাউজার চেষ্টা থামিয়ে দেয়।

  1. সংযোগ খোলাGET /events200, event-stream
  2. event আসছেid: 1, id: 2ব্রাউজার মনে রাখে
  3. সংযোগ কাটলonerrorreadyState 0
  4. আবার requestLast-Event-ID: 2retry ms পরে
  5. বাকিটাid: 3কিছু হারায়নি
ব্রাউজার → সার্ভারএকটি সাধারণ GET
GET /orders/42/events HTTP/1.1
Accept: text/event-stream
Cookie: session=…

← HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
সার্ভার → ব্রাউজারউত্তর চলছেই, শেষ হয়নি
retry: 3000

id: 1
event: status
data: রান্না হচ্ছে

id: 2
event: status
data: রাইডার রওনা দিয়েছেন
… (সংযোগ খোলা)
ব্রাউজারের ভেতরেকিছু করতে হয় না
es.readyState      → 0 (CONNECTING)
es.onerror         → একবার ডাকা হলো
মনে রাখা lastEventId = "2"
অপেক্ষা: retry = 3000 ms
ব্রাউজার → সার্ভারনতুন request, বাড়তি header
GET /orders/42/events HTTP/1.1
Accept: text/event-stream
Last-Event-ID: 2
সার্ভার → ব্রাউজার২-এর পর থেকে
id: 3
event: status
data: পৌঁছে গেছে
ব্রাউজার: step === "delivered" → es.close()
  1. সংযোগ খোলা। new EventSource(url) একটি সাধারণ GET পাঠায়। সার্ভার 200 আর text/event-stream দিয়ে উত্তর শুরু করে, কিন্তু শেষ করে না। Cache-Control: no-cache দরকার, যাতে মাঝের কোনো cache এই চলমান উত্তর জমিয়ে না রাখে।
  2. Event আসছে। রেস্তোরাঁ রান্না শুরু করলে সার্ভার id: 1, রাইডার রওনা দিলে id: 2 লেখে। প্রতিটি ফাঁকা লাইনে ব্রাউজার একটি status event ছোড়ে আর id-টি নিজে মনে রাখে।
  3. সংযোগ কাটল। মোবাইল টাওয়ার বদলাল, বা সার্ভার deploy-এর জন্য restart হলো। onerror একবার ডাকা হয়, readyState হয় ০ — অর্থাৎ “আবার চেষ্টা করছি”, হাল ছাড়েনি। এখানে নিজে নতুন EventSource বানাবেন না; তাতে দুটি সংযোগ হয়ে যাবে।
  4. Last-Event-ID নিয়ে ফেরা। retry-এর ৩০০০ ms পরে ব্রাউজার একই URL-এ নতুন GET পাঠায়, সাথে Last-Event-ID: 2. সার্ভারকে তাই পুরোনো event কোথাও জমা রাখতে হয়।
  5. যেখানে থেমেছিল, সেখান থেকে। সার্ভার শুধু id: 3 পাঠায়। গ্রাহক কোনো ধাপ হারাননি, আর একটিও বাড়তি কোড লেখেননি। কাজ শেষে es.close() — নইলে সার্ভার উত্তর শেষ করলেও ব্রাউজার বারবার ফিরে আসত।

নিজে চালিয়ে দেখুন — Node.js-এ SSE সার্ভার আর client একসাথে

নিচের প্রোগ্রামে একটি আসল SSE সার্ভার (node:http) আর একই প্রোগ্রামে একটি client আছে। Node 20-এ EventSource নেই, তাই client fetch দিয়ে res.body-কে ReadableStream হিসেবে পড়ে আর নিজে লাইন ধরে ফরম্যাটটি parse করে — EventSource ভেতরে ঠিক এই কাজই করে। সার্ভার প্রথম সংযোগে দুটি event পাঠিয়ে উত্তর শেষ করে দেয় (সংযোগ কাটার নকল), তারপর client Last-Event-ID নিয়ে ফিরে আসে:

const http = require("node:http");
const updates = ["রান্না হচ্ছে", "রাইডার রওনা দিয়েছেন", "পৌঁছে গেছে"];
let visits = 0;
const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
  const from = Number(req.headers["last-event-id"] ?? 0) + 1;   // কোথা থেকে আবার শুরু
  const to = ++visits === 1 ? 2 : updates.length;               // প্রথম সংযোগ ২টির পরে কাটে
  for (let id = from; id <= to; id++) res.write(`id: ${id}\nevent: status\ndata: ${updates[id - 1]}\n\n`);
  res.end();
});
let lastId;                                                      // EventSource যা মনে রাখে
async function listen(url) {
  const res = await fetch(url, { headers: lastId ? { "Last-Event-ID": lastId } : {} });
  let buf = "", ev = {};
  for await (const text of res.body.pipeThrough(new TextDecoderStream())) {
    const lines = (buf + text).split("\n"); buf = lines.pop();  // অসম্পূর্ণ শেষ লাইন রেখে দিন
    for (const line of lines) {
      if (line) { const i = line.indexOf(":"); ev[line.slice(0, i)] = line.slice(i + 2); continue; }
      console.log(`#${ev.id} ${ev.event}: ${ev.data}`); lastId = ev.id; ev = {};  // ফাঁকা লাইন
    }
  }
}
server.listen(0, "127.0.0.1", async () => {
  const url = `http://127.0.0.1:${server.address().port}/orders/42/events`;
  await listen(url);
  console.log(`সংযোগ কেটে গেল — Last-Event-ID: ${lastId} দিয়ে আবার`);
  await listen(url);
  server.close();
});

আউটপুট চার লাইন: #1 status: রান্না হচ্ছে, #2 status: রাইডার রওনা দিয়েছেন, সংযোগ কেটে গেল — Last-Event-ID: 2 দিয়ে আবার, আর #3 status: পৌঁছে গেছে. listen(0) মানে যেকোনো খালি port. নেটওয়ার্ক লেখা টুকরো টুকরো আনে — একটি টুকরো একটি লাইনের মাঝখানে শেষ হতে পারে, এমনকি একটি বাংলা অক্ষরের byte-এর মাঝখানেও। TextDecoderStream অক্ষর ভাঙা ঠেকায়, আর buf অসম্পূর্ণ শেষ লাইনটি পরের টুকরোর জন্য রেখে দেয়। এই parser ইচ্ছা করে সরল (i + 2 ধরে নিচ্ছে :-এর পরে ঠিক একটি space আছে; একাধিক data: লাইন বা comment সামলায় না)। Production-এ ব্রাউজারে EventSource, আর Node-এ একটি পরীক্ষিত লাইব্রেরি ব্যবহার করুন।

৪ · বাস্তবের SSE সার্ভার — Express, proxy আর সীমাবদ্ধতা

পর্ব ৫০-এর Express-এ একটি SSE route দেখতে প্রায় সাধারণ route-এর মতোই; পার্থক্য হলো res.json()-এর বদলে বারবার res.write(), আর ব্রাউজার চলে গেলে পরিষ্কার করা:

app.get("/orders/:id/events", requireLogin, async (req, res) => {
  if (!(await canSeeOrder(req.user, req.params.id))) return res.sendStatus(403);
  res.set({
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    "X-Accel-Buffering": "no",                 // nginx-কে বলুন: জমিয়ে রাখবেন না
  });
  res.flushHeaders();                          // header এখনই পাঠান
  const send = (e) => res.write(`id: ${e.seq}\nevent: status\ndata: ${JSON.stringify(e)}\n\n`);
  const lastId = Number(req.get("Last-Event-ID") ?? 0);
  (await orderEvents.since(req.params.id, lastId)).forEach(send);   // হারানোগুলো আগে
  const unsubscribe = orderEvents.subscribe(req.params.id, send);   // তারপর নতুনগুলো
  const ping = setInterval(() => res.write(": ping\n\n"), 25_000);  // comment — সংযোগ সজাগ
  req.on("close", () => { clearInterval(ping); unsubscribe(); });   // না করলে memory leak
});

অনুমতি যাচাই — URL-এ অন্যের অর্ডার নম্বর বসানো খুব সহজ, তাই সংযোগের শুরুতেই দেখুন অর্ডারটি এই ব্যবহারকারীর কি না। Comment ping — অনেক proxy আর load balancer কিছুক্ষণ (প্রায়ই ৬০ সেকেন্ডের কাছাকাছি) কোনো byte না গেলে সংযোগ কেটে দেয়; ২৫ সেকেন্ড পরপর একটি comment লাইন সেটি ঠেকায়, আর ব্রাউজার comment উপেক্ষা করে। req.on("close") — ব্রাউজার tab বন্ধ করলে listener আর timer সরাতে হবে, নইলে প্রতিটি পুরোনো সংযোগ চিরকাল মেমরিতে থাকবে (পর্ব ৫৩-এ memory leak বিস্তারিত)।

Proxy আর buffering — “লোকালে চলে, সার্ভারে চলে না”

SSE-র সবচেয়ে প্রচলিত অভিযোগ: লোকালে সব ঠিক, কিন্তু deploy করার পরে event আসে একসাথে, অনেক দেরিতে, বা উত্তর শেষ হলে একবারে। কারণ প্রায় সবসময় মাঝখানের কেউ উত্তরটি জমিয়ে রাখছে। nginx reverse proxy ডিফল্টভাবে backend-এর উত্তর buffer করে — এই proxy buffering সাধারণ পাতার জন্য ভালো, stream-এর জন্য মারাত্মক। সমাধান: সেই location-এ proxy_buffering off;, অথবা উপরের মতো X-Accel-Buffering: no header; সাথে proxy_read_timeout বাড়ানো। Express-এর compression middleware-ও লেখা জমায় — stream route-এ বন্ধ রাখুন।

SSE-র সীমা

  • একমুখী। সার্ভার থেকে ব্রাউজারে শুধু। ব্রাউজারের কিছু পাঠাতে হলে সাধারণ fetch POST — যা আসলে বেশিরভাগ অ্যাপে যথেষ্ট (চ্যাটেও বার্তা POST করে, নতুন বার্তা SSE-তে পাওয়া যায়)।
  • শুধু লেখা (UTF-8), শুধু GET, নিজের header নয়। EventSource-এ Authorization header বসানো যায় না। তাই authentication চলে cookie দিয়ে (ভিন্ন origin হলে { withCredentials: true } আর CORS), বা একটি স্বল্পমেয়াদি token URL-এ। POST বা header দরকার হলে fetch দিয়ে stream পড়ে নিজে parse করুন — AI চ্যাট-অ্যাপগুলো এভাবেই করে।
  • HTTP/1.1-এ সংযোগের সীমা। HTTP/1.1-এ ব্রাউজার একটি domain-এ একসাথে সাধারণত ৬টির বেশি সংযোগ খোলে না — সব tab মিলিয়ে। ৭টি tab-এ একই সাইট খোলা থাকলে সপ্তমটি আটকে থাকতে পারে। HTTP/2-তে একটি TCP সংযোগের ভেতরে অনেকগুলো stream চলে (সাধারণত ১০০ বা তার বেশি), তাই সমস্যাটি প্রায় মুছে যায় — production-এ HTTP/2 চালু রাখুন।

৫ · WebSocket — handshake, frame আর ব্রাউজার API

SSE-তে ব্রাউজার শুধু শোনে। সাপোর্ট চ্যাটে দুজনেই লেখেন, “টাইপ করছেন…” সেকেন্ডে কয়েকবার বদলায় — প্রতিটির জন্য আলাদা POST মানে প্রতিবার পুরো header আর লগইন যাচাই। WebSocket একটি TCP সংযোগকে খোলা রেখে দুই পক্ষকে যেকোনো সময়, একই সাথে কথা বলতে দেয় — একে বলে full-duplex. প্রতিটি বার্তার সাথে বাড়তি মাত্র কয়েক byte যায়, header-এর কয়েকশো byte নয়।

Handshake — HTTP দিয়ে শুরু, তারপর অন্য প্রোটোকল

WebSocket আলাদা port বা নতুন ধরনের সংযোগ দিয়ে শুরু হয় না। শুরু হয় একটি সাধারণ HTTP GET দিয়ে, যাতে লেখা থাকে “চলুন প্রোটোকল বদলাই” — এই protocol upgrade-এর ফলে একই port (80 বা 443), একই firewall নিয়ম আর একই সার্ভার ব্যবহার করা যায়। সার্ভার রাজি হলে উত্তর দেয় 101 Switching Protocols, আর সেই মুহূর্ত থেকে ওই TCP সংযোগে HTTP আর চলে না — চলে WebSocket-এর frame.

  1. Upgrade চাওয়াGET + Upgradeসাধারণ HTTP
  2. সার্ভার রাজি101Accept হিসাব
  3. frame দুই দিকেtext / binaryযেকোনো সময়
  4. ping / pong0x9 / 0xAবেঁচে আছ?
  5. close1000ভদ্র বিদায়
ব্রাউজার → সার্ভারnew WebSocket("wss://chat.example.com/support")
GET /support HTTP/1.1
Host: chat.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://shop.example.com
Cookie: session=…
সার্ভার → ব্রাউজারHTTP-র শেষ উত্তর
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
← এখান থেকে আর HTTP নয়
একই TCP সংযোগেframe (প্রতিটিতে কয়েক byte-এর মাথা)
→ [text, masked]  {"type":"chat","text":"আমার অর্ডার কোথায়?"}
← [text]          {"type":"typing","from":"করিম"}
← [text]          {"type":"chat","from":"করিম","text":"দেখছি…"}
client → server frame সবসময় masked; উল্টো দিকে নয়
control frameব্রাউজার নিজেই pong দেয়
← [ping]   (সার্ভার, প্রতি ৩০ সেকেন্ডে)
→ [pong]   (ব্রাউজার স্বয়ংক্রিয়ভাবে)
pong না এলে সার্ভার সংযোগটিকে মৃত ধরে বন্ধ করে
close handshakeদুই পক্ষই close frame পাঠায়
→ [close] code 1000, reason "চ্যাট শেষ"
← [close] code 1000
তারপর TCP সংযোগ বন্ধ; ws.onclose → e.code 1000, e.wasClean true
  1. Upgrade চাওয়া। ব্রাউজার একটি GET পাঠায়, সাথে Upgrade: websocket আর Connection: Upgrade. Sec-WebSocket-Key প্রতিবার নতুন একটি এলোমেলো মান (base64-এ ১৬ byte). লক্ষ করুন Origin আর Cookie — ব্রাউজার নিজে থেকে এগুলো পাঠায়, আর নিরাপত্তার জন্য এদের গুরুত্ব অংশ ৮-এ দেখবেন।
  2. সার্ভার রাজি। সার্ভার key-এর সাথে RFC-তে লেখা একটি স্থির GUID জুড়ে SHA-1 hash করে base64 বানায় — এটিই Sec-WebSocket-Accept. এটি পাসওয়ার্ড নয়, শুধু প্রমাণ যে উত্তরদাতা সত্যিই WebSocket বোঝে। Status 101 মানে “প্রোটোকল বদলাচ্ছি”। এই মুহূর্তে ব্রাউজারে onopen চলে।
  3. Frame দুই দিকে। এখন দুই পক্ষ যেকোনো সময় লেখা (text) বা byte (binary) frame পাঠায়, কেউ কারও অপেক্ষায় থাকে না। Client থেকে যাওয়া প্রতিটি frame একটি এলোমেলো ৪-byte key দিয়ে masked থাকে — এটি গোপনীয়তার জন্য নয় (key frame-এই লেখা থাকে), বরং মাঝের পুরোনো proxy যাতে আক্রমণকারীর বানানো byte-কে HTTP ভেবে ভুল না করে (ভাবনার প্রশ্ন ২)।
  4. Ping আর pong. প্রোটোকলের নিজস্ব “control frame”: একজন ping পাঠালে অন্যজন pong দিতে বাধ্য। ব্রাউজার pong নিজেই দেয়, কিন্তু JavaScript থেকে ping পাঠানোর কোনো API নেই — তাই ব্রাউজারের দিক থেকে সজাগ থাকতে হলে নিজের {"type":"ping"} বার্তা লাগে (অংশ ৭)।
  5. Close. এক পক্ষ একটি close frame (code আর ঐচ্ছিক কারণ) পাঠায়, অন্যজন close frame ফেরত দেয়, তারপর TCP বন্ধ। নেটওয়ার্ক হঠাৎ কাটলে কোনো close frame আসে না — ব্রাউজার তখন code 1006 দেখায়, যা কখনো তারে পাঠানো হয় না, শুধু “অস্বাভাবিকভাবে কাটা” বোঝায়।

অভিজ্ঞ পাঠকদের জন্য — Sec-WebSocket-Accept-এর হিসাব Node-এ তিন লাইনের। RFC 6455-এর নিজের উদাহরণের key দিলে নিচের মানটিই আসে (চালানো যায় না এমন উদাহরণ; লোকাল Node-এ যাচাই করা):

const { createHash } = require("node:crypto");
const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";     // RFC 6455-এ লেখা স্থির মান
const key = "dGhlIHNhbXBsZSBub25jZQ==";                  // ব্রাউজারের Sec-WebSocket-Key
console.log(createHash("sha1").update(key + GUID).digest("base64"));
// প্রত্যাশিত আউটপুট: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Frame-এর ধরন আর ws:// বনাম wss://

Opcodeধরনকাজ
0x1TextUTF-8 লেখা — সাধারণত JSON
0x2Binarybyte — ছবি, অডিও, MessagePack-এর মতো ঘন ফরম্যাট
0x0Continuationবড় বার্তা কয়েকটি frame-এ ভাগ হলে পরের টুকরো
0x8Closeবিদায়, সাথে code আর কারণ
0x9 / 0xAPing / Pongসংযোগ বেঁচে আছে কি না যাচাই

ws:// হলো খোলা লেখায় WebSocket (HTTP-র মতো), আর wss:// হলো TLS-এ মোড়া (HTTPS-এর মতো, port 443). Production-এ সবসময় wss://: চ্যাটের লেখা আর token পথে কেউ পড়তে পারে না, HTTPS পাতা থেকে ws:// খোলা ব্রাউজার আটকে দেয় (mixed content), আর এনক্রিপ্ট করা সংযোগ মাঝের proxy খুলে দেখতে বা ভাঙতে পারে না।

ব্রাউজারের WebSocket API

const ws = new WebSocket("wss://chat.example.com/support");
ws.binaryType = "arraybuffer";                    // binary এলে Blob-এর বদলে ArrayBuffer

ws.onopen = () => ws.send(JSON.stringify({ type: "hello", v: 1, token: getShortToken() }));
ws.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  if (msg.type === "chat") addBubble(msg.from, msg.text);   // ভেতরে textContent — innerHTML নয়
};
ws.onclose = (e) => console.log("বন্ধ:", e.code, e.reason, e.wasClean);
ws.onerror = () => console.log("ভুল — কারণ ইচ্ছা করেই জানানো হয় না; onclose-এর code দেখুন");

function sendChat(text) {
  if (ws.readyState !== WebSocket.OPEN) return queueForLater(text);  // CONNECTING হলে send ভুল ছুড়ে
  ws.send(JSON.stringify({ type: "chat", v: 1, text }));
}

WebSocket একটি EventTarget, তাই addEventListener("message", …)-ও চলে। readyState-এর চারটি মান: 0 CONNECTING, 1 OPEN, 2 CLOSING, 3 CLOSED — সংযোগ খোলার আগে send() ডাকলে ব্রাউজার InvalidStateError ছোড়ে। onerror-এ কোনো বিস্তারিত থাকে না; এটি ইচ্ছাকৃত, যাতে কোনো পাতা WebSocket দিয়ে অন্যের নেটওয়ার্কের port খুঁজে বেড়াতে না পারে। আর ব্রাউজারের WebSocket constructor-এ নিজের header (যেমন Authorization) দেওয়ার কোনো উপায় নেই — শুধু URL আর ঐচ্ছিক subprotocol. তাই token যায় cookie-তে, URL-এ, বা প্রথম বার্তায়।

Close codeঅর্থClient-এর করণীয়
1000স্বাভাবিক বন্ধআবার জোড়া নয়
1001চলে যাচ্ছি (সার্ভার restart, পাতা বন্ধ)কিছুক্ষণ পরে আবার জোড়া
1006অস্বাভাবিকভাবে কাটা, close frame আসেনি (তারে কখনো পাঠানো হয় না)backoff-সহ আবার জোড়া
1008 / 1009নীতি ভঙ্গ / বার্তা খুব বড়আবার একই কাজ নয় — ভুলটি ঠিক করুন
1011সার্ভারের ভেতরের ভুলbackoff-সহ আবার
4000–4999আপনার অ্যাপের নিজের code (যেমন 4001 = লগইন লাগবে)আপনার প্রোটোকল যা বলে

Node.js-এর দিক থেকে একটি খবর: Node 22 থেকে ব্রাউজারের মতো global WebSocket client আছে (Node 21-এ একটি flag-এর পেছনে ছিল), কিন্তু WebSocket সার্ভার Node-এ আজও built-in নয় — তার জন্য সাধারণত ws প্যাকেজ।

৬ · WebSocket সার্ভার, বার্তার প্রোটোকল, room আর presence

WebSocket আপনাকে শুধু একটি পাইপ দেয় — “বার্তা পাঠাও, বার্তা পাও”। কোন বার্তার অর্থ কী, কে কোন room-এ আছে, কে কাকে পাঠাতে পারে — এসব আপনার নকশা। প্রথমে ws প্যাকেজে একটি সার্ভারের কাঠামো (npm প্যাকেজ লাগে, তাই এখানে চালানো যায় না):

import { createServer } from "node:http";
import { WebSocketServer } from "ws";                 // npm install ws

const server = createServer(app);                     // পর্ব ৫০-এর Express app-ও হতে পারে
const wss = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 });  // বার্তা ≤ 16 KB
const ALLOWED_ORIGINS = new Set(["https://shop.example.com"]);

server.on("upgrade", async (req, socket, head) => {
  const user = await userFromCookie(req);              // সংযোগের আগেই পরিচয়
  if (!ALLOWED_ORIGINS.has(req.headers.origin) || !user) {
    socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
    return socket.destroy();
  }
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, user));
});

wss.on("connection", (ws, user) => {
  ws.on("message", (data, isBinary) => {
    if (isBinary) return ws.close(1003, "text only");
    hub.receive(user, data.toString());                // parse, যাচাই, তারপর broadcast
  });
  ws.on("close", () => hub.leaveAll(user, ws));
});
server.listen(8080);

noServer: true আর "upgrade" event দিয়ে 101 পাঠানোর আগেই Origin আর লগইন যাচাই করা যায়। maxPayload না দিলে ws-এর ডিফল্ট সীমা অনেক বড় (১০০ MiB).

বার্তার প্রোটোকল — প্রতিটি বার্তায় type আর version

HTTP-তে URL আর method বলে দেয় কোন কাজ। WebSocket-এ সব বার্তা একই পাইপে আসে, তাই প্রতিটি বার্তাকে নিজেই বলতে হয় সে কী। প্রচলিত নকশা হলো একটি JSON “খাম” (envelope) — এটিই আপনার message protocol:

{ "type": "chat",     "v": 1, "room": "order-42", "text": "আমার অর্ডার কোথায়?", "clientId": "c-17" }
{ "type": "chat",     "v": 1, "room": "order-42", "id": 981, "from": "রিনা", "text": "…", "clientId": "c-17" }
{ "type": "typing",   "v": 1, "room": "order-42", "from": "করিম" }
{ "type": "presence", "v": 1, "room": "order-42", "online": ["রিনা", "করিম"] }
{ "type": "error",    "v": 1, "code": "TOO_LONG", "reason": "বার্তা ২০০ অক্ষরের বেশি" }

type দিয়ে সার্ভার আর client একটি switch-এ বার্তা ভাগ করে। v (version) রাখুন শুরু থেকেই — মোবাইল অ্যাপের পুরোনো সংস্করণ মাসের পর মাস চলে, নতুন সার্ভারকে পুরোনো বার্তাও বুঝতে হবে। id দেয় সার্ভার — ক্রম আর “কোথা পর্যন্ত পেয়েছি”-র জন্য। clientId দেয় client — নিজের বার্তা ফেরত এলে চিনতে (optimistic UI, পর্ব ৩৬) আর দুবার এলে বাদ দিতে।

Room, broadcast আর presence

সাপোর্ট চ্যাটে প্রতিটি অর্ডারের কথোপকথন একটি room: গ্রাহক আর সেই অর্ডারের এজেন্ট। একটি বার্তা room-এর সবার কাছে পাঠানো — এক থেকে অনেকে ছড়িয়ে দেওয়া — একে বলে fan-out বা broadcast. আর room-এ এখন কে কে আছেন, সেই তালিকা হলো presence. ধাপে ধাপে একটি বার্তার যাত্রা দেখুন:

👩 রিনাroom: order-42
🧑‍💼 করিম (এজেন্ট)room: order-42
👨 তানভীর (সুপারভাইজার)room: order-42
🧑 সাকিবroom: order-77
① JSON.parse + আকার যাচাইভাঙা বা বড় হলে বাতিল
② রিনা কি order-42-এ আছেন?অনুমতি + হারের সীমা
③ id: 981 বসানো, সংরক্ষণসার্ভারই সত্যের উৎস
→ রিনা ✓ (নিজের নিশ্চয়তা)
→ করিম ✓
→ তানভীর ✓
✗ সাকিব — অন্য room, কিছুই পান না
  1. রিনা পাঠান। তাঁর ব্রাউজার {"type":"chat","room":"order-42","text":"আমার অর্ডার কোথায়?"} পাঠায়। যে কেউ একটি script থেকে যা খুশি পাঠাতে পারে।
  2. Parse আর আকার যাচাই। JSON.parse একটি try/catch-এ; type চেনা কি না, text string কি না, ২০০ অক্ষরের মধ্যে কি না। প্রতিটি বার্তা আলাদা একটি API কলের মতো যাচাই হয় (পর্ব ১৯-এর schema validation).
  3. অনুমতি আর সার্ভারের id. বার্তায় লেখা room বিশ্বাস করা যায় না — রিনা সত্যিই order-42-এর সদস্য কি না, সার্ভারের নিজের খাতায় দেখতে হবে। প্রেরকের নামও বার্তা থেকে নয়, সংযোগের লগইন থেকে নিন। তারপর সার্ভার ক্রমিক id বসায় আর ডেটাবেসে রাখে (পর্ব ৫২)।
  4. Fan-out. Room-এর প্রতিটি সংযোগে একই বার্তা যায় — রিনার নিজের কাছেও, যাতে তাঁর পর্দার “পাঠানো হচ্ছে…” নিশ্চিত হয়। সাকিব অন্য room-এ, তাই কিছুই পান না। Room ভুলে “সবাইকে” পাঠালে এক গ্রাহকের তথ্য অন্যজন দেখবেন।
  5. ভুল বার্তা হলে। রিনা যদি JSON-এর বদলে <img src=x onerror=…> পাঠাতেন, সার্ভার শুধু তাঁকেই একটি error বার্তা দিত — কারও কাছে ছড়াত না। আর বৈধ বার্তার লেখাতেও HTML থাকতে পারে, তাই গ্রহণকারী সবসময় textContent দিয়ে দেখায় (পর্ব ৪৩)।

এবার এই হাবের মূল যুক্তি নিজে চালিয়ে দেখুন। আসল নেটওয়ার্কের বদলে প্রতিটি “সংযোগ” এখানে একটি সাধারণ function, যা পাওয়া বার্তা ছাপে — কিন্তু room, presence, যাচাই আর fan-out-এর যুক্তি হুবহু একটি আসল সার্ভারের মতো:

class ChatHub {
  #rooms = new Map(); #seq = 0;                           // room → Map(user → send); message id
  #send(room, msg, onlyTo) {                              // onlyTo না থাকলে রুমের সবাইকে
    for (const [user, send] of this.#rooms.get(room) ?? [])
      if (!onlyTo || user === onlyTo) send(JSON.stringify(msg));
  }
  join(room, user, send) {
    this.#rooms.set(room, (this.#rooms.get(room) ?? new Map()).set(user, send));
    this.#send(room, { type: "presence", online: [...this.#rooms.get(room).keys()] });
  }
  receive(room, user, raw) {                              // client-এর বার্তা কখনো বিশ্বাস নয়
    let msg = null;
    try { msg = JSON.parse(raw); } catch {}
    if (msg?.type !== "chat" || typeof msg.text !== "string" || msg.text.length > 200)
      return this.#send(room, { type: "error", reason: "অবৈধ বার্তা" }, user);
    this.#send(room, { type: "chat", id: ++this.#seq, from: user, text: msg.text });
  }
}
const hub = new ChatHub();
const inbox = (name) => (data) => console.log(`${name} ← ${data}`);
hub.join("order-42", "রিনা", inbox("রিনা"));
hub.join("order-42", "করিম", inbox("করিম"));
hub.join("order-77", "সাকিব", inbox("সাকিব"));
hub.receive("order-42", "রিনা", '{"type":"chat","text":"আমার অর্ডার কোথায়?"}');
hub.receive("order-42", "রিনা", "<img src=x onerror=alert(1)>");

আউটপুট সাত লাইন। প্রথম চার লাইন presence: রিনা যোগ দিলে তিনি পান {"type":"presence","online":["রিনা"]}; করিম যোগ দিলে দুজনেই পান ["রিনা","করিম"]; সাকিব অন্য room-এ, তাই শুধু নিজেকে দেখেন ["সাকিব"]. তারপর রিনার বার্তা রিনা আর করিম দুজনের কাছে যায় — {"type":"chat","id":1,"from":"রিনা","text":"আমার অর্ডার কোথায়?"} — সাকিবের কাছে নয়। শেষ লাইন রিনা ← {"type":"error","reason":"অবৈধ বার্তা"}: HTML-ভরা লেখাটি JSON নয়, তাই msg থাকে null, আর ভুলের খবর যায় শুধু প্রেরকের কাছে। লক্ষ করুন from এসেছে সংযোগের পরিচয় (user) থেকে, বার্তা থেকে নয়; আর #rooms একটি #private field (পর্ব ২৬)। আসল সার্ভারে send হবে ws.send, আর একজন ব্যবহারকারীর কয়েকটি tab মানে কয়েকটি সংযোগ — তাই Map(user → Set(ws)).

৭ · নির্ভরযোগ্যতা — heartbeat, reconnection আর backpressure

বাস্তবে মোবাইল ঘুমায়, নেটওয়ার্ক বদলায়, load balancer অলস সংযোগ কাটে, সার্ভার deploy হয়। SSE-র EventSource এসব নিজে সামলায়; WebSocket কিছুই সামলায় না। তিনটি জিনিস আপনাকে বানাতে হবে।

Heartbeat — আধমরা সংযোগ চেনা

সবচেয়ে বিপজ্জনক অবস্থা হলো “half-open” সংযোগ: ফোনের ব্যাটারি শেষ বা নেটওয়ার্ক হঠাৎ উধাও, কোনো close frame যায়নি। সার্ভারের চোখে সংযোগটি তখনো খোলা — সে বার্তা পাঠিয়েই যায়, presence-এ ব্যবহারকারীকে “অনলাইন” দেখায়, মেমরি ধরে রাখে। TCP নিজে থেকে এটি ধরতে অনেক সময় নেয়। সমাধান heartbeat: সার্ভার নির্দিষ্ট সময় পরপর ping পাঠায়; পরের ping-এর সময় পর্যন্ত pong না এলে সংযোগটি মৃত ধরে বন্ধ করে। ws প্যাকেজের প্রচলিত রূপ:

wss.on("connection", (ws) => {
  ws.isAlive = true;
  ws.on("pong", () => { ws.isAlive = true; });        // উত্তর এল — বেঁচে আছে
});
setInterval(() => {                                    // প্রতি ৩০ সেকেন্ডে
  for (const ws of wss.clients) {
    if (!ws.isAlive) { ws.terminate(); continue; }      // আগের ping-এর উত্তর নেই — মৃত
    ws.isAlive = false;
    ws.ping();
  }
}, 30_000);

নিয়মিত ping-এর ফলে proxy-র idle timeout-ও সংযোগ কাটে না। ব্রাউজারের দিক থেকে সার্ভার মৃত কি না জানতে একটি অ্যাপ-স্তরের {"type":"ping"} পাঠান, উত্তর না এলে close() করে আবার জুড়ুন।

Reconnection — exponential backoff আর jitter

সংযোগ কাটলে সাথে সাথে আবার চেষ্টা করা স্বাভাবিক মনে হয়। কিন্তু সার্ভার deploy-এর সময় এক লাখ সংযোগ একসাথে কাটে — সবাই একই মুহূর্তে ফিরলে নতুন সার্ভার জন্মেই ভেঙে পড়ে। এটিই পর্ব ৩৬-এর thundering herd, আর সমাধানও সেখানেই: exponential backoff আর jitter.

function connect(url, onMessage) {
  let attempt = 0, lastId = null, ws;
  function open() {
    ws = new WebSocket(url);
    ws.onopen = () => {
      attempt = 0;                                       // সফল — অপেক্ষা আবার শুরু থেকে
      ws.send(JSON.stringify({ type: "hello", v: 1, token: getShortToken(), lastId }));
    };
    ws.onmessage = (e) => {
      const msg = JSON.parse(e.data);
      if (msg.id) lastId = msg.id;                       // কোথা পর্যন্ত পেয়েছি
      onMessage(msg);
    };
    ws.onclose = (e) => {
      if (e.code === 1000 || e.code === 4001) return;    // ইচ্ছাকৃত বন্ধ বা লগইন ব্যর্থ — আর নয়
      const cap = Math.min(30_000, 500 * 2 ** attempt++);
      setTimeout(open, Math.random() * cap);             // full jitter: ০ থেকে cap-এর মধ্যে
    };
  }
  open();
  return { close: () => ws.close(1000) };
}

সফল হলে attempt = 0 — এটি না করলে একবার অনেকবার ব্যর্থ হওয়ার পরে প্রতিটি নতুন বিচ্ছেদেও ৩০ সেকেন্ড পর্যন্ত অপেক্ষা করতে হতো। কিছু code-এ আর চেষ্টা নয় — লগইন ব্যর্থ হলে লাখবার চেষ্টাও ব্যর্থ। lastId পাঠানো — SSE-র Last-Event-ID-এর মতো, যাতে কাটা থাকার সময়ের বার্তা হারায় না। বাস্তবে online event এলে সাথে সাথে চেষ্টাও যোগ করুন।

Backpressure — পাঠানোর গতি গ্রহণের গতির চেয়ে বেশি হলে

ws.send() সাথে সাথে ফেরে, কিন্তু তার মানে বার্তা পৌঁছেছে নয় — বার্তা একটি queue-তে বসেছে মাত্র। দুর্বল 3G-র ব্যবহারকারীর কাছে সেকেন্ডে ১০০টি আপডেট গেলে queue শুধু বাড়ে। এই সমস্যার নাম backpressure. ব্রাউজারে bufferedAmount জানায় কত byte এখনো পাঠানো বাকি; সার্ভারে ws-এর সংযোগেও একই নামের property আছে। নিয়ম: সীমা পেরোলে নতুন বার্তা জমাবেন না — পুরোনো আপডেট ফেলে শুধু সর্বশেষ অবস্থা পাঠান (স্কোরের ক্ষেত্রে পুরোনো স্কোরের কোনো মূল্য নেই), বা খুব পিছিয়ে পড়া client-এর সংযোগ বন্ধ করে দিন, যাতে সে নতুন করে সর্বশেষ অবস্থা নিয়ে শুরু করে।

function sendScore(ws, score) {
  if (ws.bufferedAmount > 64 * 1024) { pending.set(ws, score); return; }  // শুধু সর্বশেষটি রাখুন
  ws.send(JSON.stringify({ type: "score", v: 1, ...score }));
}

৮ · নিরাপত্তা — লগইন, Origin আর প্রতিটি বার্তা যাচাই

HTTP API-তে প্রতিটি request middleware-এর মধ্য দিয়ে যায়; WebSocket-এ একবার handshake-এর পরে হাজার বার্তা আসে middleware ছাড়াই।

Authentication — কোথায় token রাখবেন

  • Cookie: একই site-এ ব্রাউজার handshake-এ নিজে থেকেই cookie পাঠায় — সবচেয়ে সরল, HttpOnly cookie JavaScript চুরি করতে পারে না। কিন্তু ঠিক এই কারণেই নিচের CSWSH-এর ঝুঁকি।
  • স্বল্পমেয়াদি ticket URL-এ: আগে একটি সাধারণ HTTPS API থেকে ৩০ সেকেন্ডের এককালীন ticket নিন, তারপর wss://…?ticket=…. URL সার্ভার আর proxy-র log-এ লেখা থাকতে পারে, তাই দীর্ঘমেয়াদি JWT কখনো URL-এ নয়।
  • প্রথম বার্তায় token: সংযোগ খোলার পরে প্রথম বার্তা {"type":"hello","token":…}; কয়েক সেকেন্ডের মধ্যে বৈধ hello না এলে সার্ভার 4001 দিয়ে বন্ধ করে। সংযোগের মাঝে token-এর মেয়াদ শেষ হলে কী হবে, তাও ঠিক করুন।

Origin যাচাই আর CSWSH

পর্ব ৪৩-এ দেখেছেন, same-origin policy আর CORS অন্য সাইটের JavaScript-কে আপনার API-র উত্তর পড়তে দেয় না। কিন্তু WebSocket-এ CORS প্রযোজ্য নয় — evil.example-এর পাতা new WebSocket("wss://chat.yoursite.com") খুলতে পারে, আর ব্রাউজার তাতে আপনার সাইটের cookie-ও জুড়ে দিতে পারে। সার্ভার শুধু cookie দেখে রাজি হলে দুষ্ট পাতা ব্যবহারকারীর নামে চ্যাট করতে পারে। এর নাম Cross-Site WebSocket Hijacking (CSWSH) — WebSocket-এর CSRF. প্রতিরক্ষা: handshake-এ Origin header নিজের অনুমোদিত তালিকার সাথে মেলান (অংশ ৬-এর কোডের মতো), আর cookie-তে SameSite রাখুন। মনে রাখবেন, Origin যাচাই ব্রাউজারের ব্যবহারকারীকে রক্ষা করে; ব্রাউজারের বাইরের একটি script যেকোনো Origin লিখে পাঠাতে পারে, তাই এটি লগইনের বিকল্প নয়।

প্রতিটি বার্তা একটি অবিশ্বস্ত request

নিয়মকেন
আকারের সীমা (maxPayload, লেখার দৈর্ঘ্য)বিশাল বার্তায় মেমরি শেষ আর সবার জন্য সার্ভার ধীর
প্রতি সংযোগে বার্তার rate limit (যেমন সেকেন্ডে ৫টি চ্যাট)একজন স্প্যামার পুরো room ভাসিয়ে দিতে পারে; সীমা পেরোলে সতর্কতা, তারপর 1008
Schema দিয়ে যাচাই — type, ধরন, দৈর্ঘ্যJSON.parse-এর ভুল বা অচেনা field যেন সার্ভার ফেলে না দেয়; প্রোটোটাইপ দূষণের মতো ফাঁদও এড়ায়
প্রতিটি বার্তায় অনুমতি — room-এর সদস্য কি?বার্তায় লেখা room বা from বিশ্বাস করলে যে কেউ যেকোনো room-এ লিখতে পারে
দেখানোর সময় textContent, HTML নয়একজনের পাঠানো <img onerror> সবার ব্রাউজারে চললে XSS — broadcast-এ একটি আক্রমণ শত জনে ছড়ায়

Broadcast XSS-কে বহুগুণ করে। একটি সাধারণ মন্তব্যের পাতায় stored XSS যারা পাতাটি খোলেন তাঁদের আঘাত করে; একটি চ্যাট room-এ একটি দুষ্ট বার্তা সেই মুহূর্তে অনলাইন সবার ব্রাউজারে চলে — এজেন্ট আর অ্যাডমিনসহ, যাঁদের অনুমতি সবচেয়ে বেশি। সার্ভারে যাচাই আর client-এ output encoding দুটোই লাগবে; একটির ওপর ভরসা নয়।

৯ · Scaling, Socket.IO আর কখন কোনটি বাছবেন

একটি সার্ভারে সব সহজ: সব সংযোগ একই process-এর মেমরিতে, ChatHub সবাইকে চেনে। ব্যবহারকারী বাড়লে দুটি বা দশটি সার্ভার লাগে, সামনে একটি load balancer. তখন দুটি নতুন সমস্যা দেখা দেয়।

প্রথম — sticky session. Socket.IO-র মতো ব্যবস্থা শুরুতে long polling-এর কয়েকটি আলাদা request পাঠায়, যেগুলো একই সার্ভারে পৌঁছাতে হয় — load balancer-এর এই নিয়মের নাম sticky session. খাঁটি WebSocket একটিই TCP সংযোগ, তাই নিজে থেকেই এক সার্ভারে থাকে; শুধু load balancer-কে upgrade সমর্থন আর লম্বা idle timeout দিতে হয়।

দ্বিতীয় — ভিন্ন সার্ভারের মানুষ। রিনা সার্ভার ১-এ, করিম সার্ভার ২-এ। রিনার বার্তা সার্ভার ১-এর ChatHub পেল, কিন্তু করিমের সংযোগ তো সার্ভার ২-এর মেমরিতে! সমাধান একটি ভাগ করা বার্তা-পথ — পর্ব ৩৮-এ দেখা pub/sub নকশা, এবার সার্ভারগুলোর মধ্যে। সবচেয়ে প্রচলিত হাতিয়ার Redis-এর pub/sub:

  1. রিনা → সার্ভার ১chatযাচাই হলো
  2. Redis-এ publishroom:order-42নিজে পাঠায় না
  3. Redis বিলি করেসার্ভার ১ ও ২যারা subscribe করেছে
  4. সার্ভার ২ → করিমws.sendনিজের সংযোগে
সার্ভার ১সাধারণ যাচাই, যেমন অংশ ৬-এ
receive("order-42", "রিনা", raw)
  → parse ✓  আকার ✓  সদস্য ✓
  → msg = { type: "chat", id: 981, from: "রিনা", … }
সার্ভার ১সরাসরি fan-out নয়
await pub.publish("room:order-42", JSON.stringify(msg));
Redischannel-এর সব subscriber-কে
room:order-42 → সার্ভার ১ (রিনার সংযোগ আছে)
room:order-42 → সার্ভার ২ (করিমের সংযোগ আছে)
সার্ভার ৩-এ এই room-এর কেউ নেই — সে subscribe করেনি
প্রতিটি সার্ভারশুধু নিজের সংযোগে পাঠায়
await sub.subscribe("room:order-42", (raw) =>
  localHub.deliver("order-42", raw));   // সার্ভার ১ → রিনা, সার্ভার ২ → করিম
  1. যাচাই যেখানে বার্তা আসে। রিনা যে সার্ভারে সংযুক্ত, সেটিই parse, অনুমতি আর id বসানোর কাজ করে — নিরাপত্তার নিয়ম বদলায় না।
  2. নিজে fan-out নয়, publish. সার্ভার ১ জানে না room-এর বাকিরা কোথায়। তাই সে বার্তাটি Redis-এর room:order-42 channel-এ প্রকাশ করে — রিনাকে ফেরত পাঠানোও এই পথেই হবে, যাতে সবার ক্রম এক থাকে।
  3. Redis বিলি করে। যে সার্ভারগুলোতে ওই room-এর কেউ আছেন, তারা আগেই channel-টিতে subscribe করে রেখেছে। Redis pub/sub “পাঠাও আর ভুলে যাও” — কোনো subscriber সেই মুহূর্তে না থাকলে বার্তাটি তার জন্য জমা থাকে না। তাই আসল ইতিহাস ডেটাবেসে, আর পুনরায় পড়ার দরকার হলে Redis Streams-এর মতো টেকসই ব্যবস্থা।
  4. প্রতিটি সার্ভার নিজের সংযোগে। সার্ভার ২ তার মেমরির ChatHub থেকে করিমের সংযোগ খুঁজে পাঠায়। সার্ভার যত খুশি বাড়ান — প্রত্যেকে শুধু নিজের ব্যবহারকারীদের চেনে, আর Redis তাদের জুড়ে রাখে।

Socket.IO — সুবিধা অনেক, কিন্তু এটি WebSocket নয়

Socket.IO Node জগতের খুব জনপ্রিয় লাইব্রেরি। এটি দেয় স্বয়ংক্রিয় reconnection, room (socket.join("order-42"), io.to("order-42").emit(…)), acknowledgement (পাঠানো বার্তার উত্তর-callback), heartbeat, আর WebSocket না চললে HTTP long polling-এ ফিরে যাওয়া; ডিফল্টভাবে long polling দিয়ে শুরু করে পরে WebSocket-এ ওঠে। একাধিক সার্ভারের জন্য আছে Redis adapter. কিন্তু মনে রাখুন: Socket.IO নিজস্ব প্রোটোকল (Engine.IO-র ওপর), যা WebSocket-কে পরিবহন হিসেবে ব্যবহার করে মাত্র। একটি সাধারণ new WebSocket() client Socket.IO সার্ভারে যুক্ত হতে পারে না, আর Socket.IO client একটি সাধারণ ws সার্ভারে পারে না — দুই পক্ষেই একই লাইব্রেরি লাগে। নিজে সার্ভার চালাতে না চাইলে আছে managed সেবা (Pusher, Ably, AWS API Gateway-এর WebSocket API, Firebase বা Supabase-এর realtime) — সংযোগ রাখার ঝামেলা তারা নেয়, বিনিময়ে মাসিক খরচ।

কখন কোনটি

প্রযুক্তিদিকযা পানমানানসই কাজ
Pollingclient জিজ্ঞেস করেসবচেয়ে সরল, cache-বান্ধব; দেরি = বিরতিপ্রতি মিনিটে বদলানো তথ্য, ছোট অ্যাপ
Long pollingclient জিজ্ঞেস করে, সার্ভার অপেক্ষা করায়প্রায় তাৎক্ষণিক, সাধারণ HTTPপুরোনো পরিবেশে fallback
SSEসার্ভার → clientস্বয়ংক্রিয় reconnection আর Last-Event-ID, HTTP/2-বান্ধব; শুধু লেখাঅর্ডার ট্র্যাকিং, লাইভ স্কোর, নোটিফিকেশন, AI-এর stream করা উত্তর
WebSocketদুই দিকেকম বাড়তি খরচ, text আর binary; reconnection, heartbeat নিজেচ্যাট, “টাইপ করছেন”, মাল্টিপ্লেয়ার গেম, যৌথ সম্পাদনা
WebTransportদুই দিকে, অনেক streamHTTP/3 (QUIC)-এর ওপর; নির্ভরযোগ্য stream আর অনির্ভরযোগ্য datagramলাইভ ভিডিও, দ্রুতগতির গেম — নতুন; সব ব্রাউজার আর সার্ভারে সমান সমর্থন নেই, ব্যবহারের আগে যাচাই করুন

সহজ নিয়ম: খবর শুধু সার্ভার থেকে এলে আগে SSE ভাবুন; দুই দিকে ঘন ঘন কথা লাগলে তবে WebSocket; খবর কদাচিৎ বদলালে polling.

C/C++/Python/Java জানলে

Java-তে Jakarta WebSocket API (@ServerEndpoint, @OnMessage) আর Spring-এর WebSocket ও STOMP সমর্থন একই ধারণা দেয়, তবে সেখানে shared state-এ thread-এর synchronization লাগে (Java Networking). Python-এর websockets লাইব্রেরি asyncio-র ওপর চলে, আর async for message in websocket দেখতে প্রায় JavaScript-এর for await-এর মতো (Python-এর asyncio). C++-এ Boost.Beast-এর মতো লাইব্রেরি নিচের TCP socket-এর ওপর WebSocket বানায়, যেখানে handshake আর frame প্রায় হাতে ধরতে হয় (C++ Networking).

পরীক্ষা ও ইন্টারভিউতে
  • WebSocket আর HTTP-র পার্থক্য কী? — HTTP-তে প্রতিটি request-এর একটি উত্তর, আর কথা শুরু করে client; WebSocket একটি HTTP upgrade (101) দিয়ে শুরু হয়ে একটি স্থায়ী full-duplex সংযোগ খোলে, যেখানে দুই পক্ষই যেকোনো সময় পাঠায়।
  • SSE আর WebSocket — কখন কোনটি? — শুধু সার্ভার থেকে খবর (নোটিফিকেশন, স্কোর, AI stream) হলে SSE: সরল, স্বয়ংক্রিয় reconnection; দুই দিকে ঘন ঘন বার্তা (চ্যাট, গেম) হলে WebSocket.
  • Polling আর long polling-এর পার্থক্য? — Polling নির্দিষ্ট বিরতিতে জিজ্ঞেস করে, বেশিরভাগ উত্তর খালি; long polling-এ সার্ভার খবর না হওয়া পর্যন্ত উত্তর আটকে রাখে, তারপর client আবার জিজ্ঞেস করে।
  • একাধিক সার্ভারে WebSocket কীভাবে scale করবেন? — প্রতিটি সার্ভার নিজের সংযোগ রাখে; বার্তা Redis pub/sub-এর মতো ভাগ করা channel-এ publish হয়, আর প্রতিটি সার্ভার নিজের client-দের পাঠায়; দরকার হলে sticky session.

১০ · AI-এর যুগে — AI-এর লেখা real-time কোড যাচাই করা

“একটি real-time চ্যাট বানিয়ে দাও” বললে AI মিনিটে একটি চলমান ডেমো দেয়। সমস্যা ধরা পড়ে প্রথম deploy, দুর্বল নেটওয়ার্ক আর দুষ্ট ব্যবহারকারীর সাথে। এই ভুলগুলো খুঁজুন:

AI-এর সাধারণ ভুলফলঠিক রূপ
একমুখী নোটিফিকেশনেও WebSocket + Socket.IOঅকারণ জটিলতা, sticky session, বাড়তি সার্ভারSSE বা এমনকি polling যথেষ্ট কি না আগে ভাবা
Reconnection বা heartbeat নেইপ্রথম নেটওয়ার্ক-কাটায় চ্যাট চিরতরে চুপ; “অনলাইন” তালিকায় ভূতBackoff + jitter-সহ reconnect, lastId দিয়ে হারানো বার্তা, সার্ভারে ping/pong
Client-এর বার্তা বিশ্বাস — from, room, isAdmin বার্তা থেকেযে কেউ অন্যের নামে, অন্যের room-এ লেখেপরিচয় সংযোগের লগইন থেকে; প্রতিটি বার্তায় অনুমতি আর schema যাচাই
পাওয়া লেখা innerHTML-এ বসানোBroadcast XSS — এক বার্তায় সবার ব্রাউজার আক্রান্তtextContent; সার্ভারে দৈর্ঘ্য ও ধরন যাচাই
Socket.IO client দিয়ে খাঁটি ws সার্ভারে যুক্ত হওয়া (বা উল্টো)সংযোগই হয় না, বিভ্রান্তিকর ভুলদুই পক্ষে একই প্রোটোকল
Origin যাচাই নেই, maxPayload বা rate limit নেইCSWSH, মেমরি শেষ, স্প্যামHandshake-এ Origin + লগইন; আকার ও হারের সীমা

AI-কে কাজ দেওয়ার আগে প্রয়োজন আর নিয়মগুলো স্পষ্ট বলুন — বিশেষ করে “প্রোটোকল কেন” প্রশ্নটি:

আমি একটি খাবার-ডেলিভারি অ্যাপে দুটি real-time ফিচার বানাব: (ক) গ্রাহকের পর্দায় অর্ডারের অবস্থা (রান্না হচ্ছে → রাইডার রওনা → পৌঁছেছে), (খ) গ্রাহক আর সাপোর্ট এজেন্টের চ্যাট। Node.js 20, Express. প্রথমে কোড নয় — প্রতিটি ফিচারের জন্য polling, SSE আর WebSocket-এর মধ্যে কোনটি নেবে আর কেন, তিন লাইনে বলো। তারপর কোড দাও এই নিয়মে: ১. SSE-তে id আর Last-Event-ID দিয়ে হারানো event আবার পাঠাবে, ২৫ সেকেন্ডে comment ping, ব্রাউজার চলে গেলে listener পরিষ্কার। ২. WebSocket (ws প্যাকেজ, Socket.IO নয়): handshake-এ Origin তালিকা আর cookie দিয়ে লগইন যাচাই, maxPayload 16 KB, ৩০ সেকেন্ডের ping/pong heartbeat. ৩. প্রতিটি বার্তা JSON, type আর v field সহ; schema দিয়ে যাচাই; from আর room কখনো বার্তা থেকে নেবে না, সংযোগের পরিচয় আর সার্ভারের সদস্য-তালিকা থেকে নেবে। ৪. প্রতি সংযোগে সেকেন্ডে ৫টির বেশি chat বার্তা নয়; সীমা পেরোলে close code 1008. ৫. ব্রাউজারের client: exponential backoff + full jitter দিয়ে reconnect, 1000 আর 4001-এ আর চেষ্টা নয়, lastId পাঠিয়ে হারানো বার্তা চাইবে; লেখা দেখাবে textContent দিয়ে। ৬. দুই সার্ভারে চালাতে হলে কী বদলাবে (Redis pub/sub), শুধু ব্যাখ্যা করো। শেষে ৪টি পরীক্ষার পরিস্থিতি দাও: ভাঙা JSON, অন্যের room-এ লেখার চেষ্টা, নেটওয়ার্ক কেটে ফেরা, আর ভুল Origin.

AI-এর লেখা real-time কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১WebSocket আসার আগে Gmail-এর চ্যাট বা লাইভ স্কোরের সাইটগুলো কীভাবে “push” করত? ২০০৬ সালে “Comet” নামটি কেন জন্মাল?

২০০৫ সালে Jesse James Garrett “Ajax” নামটি দেন — পাতা পুরো না বদলে পেছন থেকে XMLHttpRequest-এ ডেটা আনা। Gmail আর Google Maps দেখাল ব্রাউজারে আসল অ্যাপ বানানো যায়। কিন্তু Ajax তখনো request–response: ব্রাউজার না চাইলে সার্ভার কিছু বলতে পারে না। চ্যাট আর লাইভ ডেটার জন্য প্রকৌশলীরা HTTP-কে নানা কৌশলে বাঁকাতে শুরু করলেন। ২০০৬ সালের মার্চে Dojo Toolkit-এর Alex Russell একটি ব্লগ লেখায় এই কৌশলগুলোর একটি সাধারণ নাম দেন — “Comet”. নামটি আসলে একটি রসিকতা: Ajax আর Comet দুটোই আমেরিকার পরিচিত ঘর-পরিষ্কারের পাউডারের ব্র্যান্ড।

Comet-এর কৌশলগুলো ছিল চতুর আর ভঙ্গুর। Long polling: request পাঠাও, সার্ভার খবর না হওয়া পর্যন্ত ধরে রাখে। “Forever frame”: পাতায় একটি লুকানো iframe, যার HTML উত্তর কখনো শেষ হয় না; সার্ভার মাঝে মাঝে একটি <script> ট্যাগ লিখে পাঠায়, আর ব্রাউজার আসা মাত্র সেটি চালায়। XHR streaming: একটি request-এর উত্তর ধীরে ধীরে পড়া — কিন্তু তখনকার কিছু ব্রাউজার পুরো উত্তর আসার আগে কিছুই দেখাত না। প্রতিটি ব্রাউজারের জন্য আলাদা কৌশল, proxy-র buffering আর প্রতি-domain সংযোগের সীমা মিলিয়ে Comet লাইব্রেরি ছিল ঝুঁকিপূর্ণ জাদু; পরে Socket.IO-র fallback ব্যবস্থাও এই উত্তরাধিকার বহন করে।

সার্ভারের দিকেও সমস্যা ছিল: প্রতি সংযোগে একটি thread রাখা সার্ভারে হাজার জন long polling-এ বসে থাকলে হাজার thread শুধু অপেক্ষা করে (ভাবনার প্রশ্ন ৪)। Comet-এর অভিজ্ঞতা দুটি মানদণ্ডের জন্ম দেয়: একমুখী ধারার জন্য Server-Sent Events (forever frame-এর পরিষ্কার উত্তরসূরি, সাথে reconnection), আর দুইমুখী কথার জন্য WebSocket.

মূল উপলব্ধি: মানদণ্ড প্রায়ই আসে হ্যাকের পরে। ডেভেলপাররা যখন একটি প্রয়োজন মেটাতে বছরের পর বছর পুরোনো প্রযুক্তিকে বাঁকান, সেই কষ্টই দেখিয়ে দেয় নতুন প্রযুক্তির কী থাকা দরকার — আর পুরোনো কৌশলগুলো (long polling) fallback হিসেবে টিকে থাকে।

প্র ০২WebSocket-এর মানদণ্ড হতে এত বছর লাগল কেন, আর client-এর প্রতিটি frame “mask” করার অদ্ভুত নিয়মটি কোন নিরাপত্তা-শিক্ষা থেকে এল?

HTML5-এর প্রথম দিকের খসড়ায় WHATWG-এর Ian Hickson “TCPConnection” নামে একটি ধারণা রাখেন — ব্রাউজার থেকে সরাসরি socket. ২০০৮ সালের দিকে আলোচনার মধ্য দিয়ে এর নাম হয় WebSocket, আর ২০০৯ সালের শেষে Google Chrome প্রথম প্রধান ব্রাউজার হিসেবে এটি ডিফল্টভাবে চালু করে। তারের ওপরের প্রোটোকলের দায়িত্ব যায় IETF-এর HyBi working group-এ, আর একের পর এক অসামঞ্জস্যপূর্ণ খসড়া আসতে থাকে।

২০১০ সালের শেষ দিকে নিরাপত্তা গবেষকদের একটি দল (Lin-Shung Huang, Eric Chen, Adam Barth, Eric Rescorla ও Collin Jackson) দেখান যে তখনকার খসড়া একটি বাস্তব বিপদ তৈরি করে। ইন্টারনেটে অনেক “transparent proxy” আছে — কর্পোরেট বা ISP-র যন্ত্র, যারা চুপচাপ HTTP পড়ে আর cache করে। এদের অনেকে WebSocket বোঝে না। গবেষকেরা দেখান, একটি দুষ্ট পাতা WebSocket সংযোগে এমন byte পাঠাতে পারে, যা এমন proxy-র চোখে একটি সাধারণ HTTP request-এর মতো দেখায়, আর proxy ভুল উত্তরকে একটি জনপ্রিয় ফাইলের নামে cache করে ফেলতে পারে — তারপর ওই proxy-র পেছনের সবাই সেই বিষাক্ত ফাইল পায়। একে বলে cache poisoning. প্রতিক্রিয়া ছিল দ্রুত: Firefox আর Opera তাদের আসন্ন সংস্করণে WebSocket সাময়িকভাবে বন্ধ করে দেয়।

সমাধান হলো masking. Client থেকে যাওয়া প্রতিটি frame-এর payload ব্রাউজারের বাছাই করা একটি এলোমেলো ৪-byte key দিয়ে XOR করা হয়। key frame-এই লেখা থাকে, তাই এটি গোপনীয়তা দেয় না; কিন্তু পাতার JavaScript আর জানে না শেষ পর্যন্ত তারে কোন byte যাবে — অর্থাৎ proxy-কে বোকা বানানোর মতো byte সে সাজাতে পারে না। সাথে Sec-WebSocket-Accept-এর হিসাব নিশ্চিত করে যে উত্তরদাতা সত্যিই WebSocket সার্ভার। এই নকশা নিয়ে ২০১১ সালের ডিসেম্বরে RFC 6455 প্রকাশিত হয়, আর ব্রাউজারগুলো নতুন সংস্করণে আবার WebSocket চালু করে।

মূল উপলব্ধি: ইন্টারনেট শুধু আপনার client আর সার্ভার নয় — মাঝে অসংখ্য পুরোনো, অজানা যন্ত্র আছে, যারা নিজেদের মতো করে byte পড়ে। নতুন প্রোটোকলকে শুধু নিজে কাজ করলেই চলে না, ওই যন্ত্রগুলোকে বিভ্রান্ত না করাও তার দায়িত্ব। এই একই কারণে আজ wss:// শুধু গোপনীয়তার জন্য নয়, নির্ভরযোগ্যতার জন্যও ভালো।

প্র ০৩বহু বছর ধরে প্রায় উপেক্ষিত Server-Sent Events হঠাৎ AI চ্যাটের যুগে এত গুরুত্বপূর্ণ হয়ে উঠল কেন?

SSE-র মানদণ্ডও Ian Hickson-এর হাতে, WHATWG-এর ওয়েব অ্যাপ্লিকেশন খসড়ার অংশ হিসেবে, ২০০০-এর দশকের মাঝামাঝি। Opera ২০০৬ সালে পরীক্ষামূলক একটি রূপ চালু করে, আর ২০১০–২০১১-এর মধ্যে Chrome, Safari ও Firefox EventSource সমর্থন করে। কিন্তু Internet Explorer কখনো করেনি — Microsoft-এর ব্রাউজারে এটি এল শুধু ২০২০ সালে Chromium-ভিত্তিক Edge-এর সাথে। এই এক অভাবেই বহু বছর অনেক দল SSE এড়িয়ে গেছে; ততদিনে WebSocket “real-time মানেই WebSocket” ধারণা তৈরি করে ফেলেছে, আর Socket.IO-র মতো লাইব্রেরি দুটোর পার্থক্য আড়াল করেছে।

তবু SSE-র শান্ত শক্তি ছিল: এটি সাধারণ HTTP (একই port, cookie, load balancer), HTTP/2-তে সংযোগ-সীমার দুর্বলতা নেই, আর reconnection বিনা কোডে।

তারপর এল বড় ভাষা মডেল (LLM). একটি মডেল উত্তর তৈরি করে token ধরে ধরে, আর পুরো উত্তর আসতে অনেক সেকেন্ড লাগতে পারে। ব্যবহারকারীকে ততক্ষণ ফাঁকা পর্দা দেখানো খারাপ অভিজ্ঞতা; শব্দে শব্দে দেখালে মনে হয় উত্তর সাথে সাথে আসছে। এই প্রবাহ ঠিক একমুখী — প্রশ্ন একবার যায়, উত্তর ধারায় আসে। তাই OpenAI ও Anthropic-এর মতো বড় AI API-গুলো streaming উত্তর দেয় text/event-stream ফরম্যাটে: প্রতিটি টুকরো একটি data: লাইন (কেউ কেউ event: নামও দেয়, যেমন বার্তা শুরু, লেখার টুকরো, বার্তা শেষ)। একটি মজার খুঁটিনাটি: এই API-গুলোতে প্রশ্ন পাঠাতে POST আর Authorization header লাগে, যা EventSource পারে না — তাই client-গুলো সাধারণত fetch দিয়ে stream পড়ে নিজেই ফরম্যাট parse করে, ঠিক এই পর্বের Node উদাহরণের মতো। অর্থাৎ API-টি নয়, ফরম্যাটটি আজ সবচেয়ে বেশি টিকে আছে।

মূল উপলব্ধি: প্রযুক্তির জনপ্রিয়তা আর তার উপযোগিতা এক জিনিস নয়। একটি সরল, “বিরক্তিকর” মানদণ্ড সঠিক সমস্যার দেখা পেলে দশ বছর পরেও কেন্দ্রে চলে আসতে পারে — তাই কাজের ধরন (একমুখী না দুইমুখী) দেখে বাছুন, প্রচলিত ধারণা দেখে নয়।

প্র ০৪C10K সমস্যা থেকে একটি সার্ভারে লাখো সংযোগ — WhatsApp কীভাবে একটি যন্ত্রে ২০ লাখের বেশি সংযোগ ধরে রেখেছিল, আর এর সাথে Node.js-এর সম্পর্ক কী?

১৯৯৯ সালে Dan Kegel “The C10K problem” নামে একটি লেখা প্রকাশ করেন: একটি সার্ভার কীভাবে একসাথে দশ হাজার client সামলাবে? তখনকার প্রচলিত নকশা — প্রতি সংযোগে একটি thread বা process — এখানে ভেঙে পড়ে: প্রতিটি thread-এর নিজের stack (প্রায়ই অনেক KB থেকে MB), আর হাজার thread-এর মাঝে অপারেটিং সিস্টেমের পালাবদল (context switch) নিজেই বড় খরচ। আর real-time সংযোগের বেশিরভাগ সময় কাটে কিছু না করে, শুধু অপেক্ষায়।

সমাধান এল অপারেটিং সিস্টেম থেকে: “এই হাজার socket-এর মধ্যে কোনগুলো প্রস্তুত, আমাকে জানাও” — FreeBSD-তে kqueue, Linux-এ epoll (২০০০-এর দশকের শুরুতে)। একটি thread একটি event loop-এ হাজার হাজার সংযোগ সামলাতে পারে, কারণ সে শুধু যেগুলোতে কিছু ঘটেছে সেগুলোতেই কাজ করে। nginx এই নকশায় জনপ্রিয় হয়, আর Node.js (২০০৯) পুরো ভাষার runtime-কে এর ওপর দাঁড় করায় — পর্ব ৩১-এর event loop আসলে নিচে এই epoll/kqueue ব্যবস্থাকেই ব্যবহার করে (libuv-এর মাধ্যমে)। এই কারণেই Node real-time সার্ভারের জন্য স্বাভাবিক পছন্দ হয়ে ওঠে।

২০১২ সালের শুরুতে WhatsApp তাদের প্রকৌশল ব্লগে জানায়, তারা একটি সার্ভারে একসাথে ২০ লাখের বেশি TCP সংযোগ ধরে রাখতে পেরেছে — FreeBSD আর Erlang-এ। Erlang-এর virtual machine খুব হালকা “process” দেয় (অপারেটিং সিস্টেমের thread নয়; শুরুতে মাত্র কয়েক KB মেমরি নেয়), তাই প্রতি সংযোগে একটি process রাখাও সস্তা, আর একটি ভেঙে পড়লে বাকিরা অক্ষত থাকে। পরে Elixir-এর Phoenix framework-ও একটি যন্ত্রে ২০ লাখ WebSocket সংযোগের পরীক্ষা প্রকাশ করে। আর একসময় আলোচনা শুরু হয় “C10M” — এক কোটি সংযোগ — নিয়ে, যেখানে অপারেটিং সিস্টেমের নেটওয়ার্ক স্তরও বাধা।

মূল উপলব্ধি: real-time-এর প্রকৌশল আসলে “অপেক্ষা সস্তা করার” প্রকৌশল। যে নকশায় প্রতিটি অপেক্ষমাণ সংযোগের খরচ সবচেয়ে কম, সেটিই বড় পরিসরে জেতে — thread থেকে event loop আর হালকা process পর্যন্ত যাত্রাটি সেই গল্প।

প্র ০৫WebSocket যদি এত ভালো হয়, তাহলে WebTransport আর HTTP/3 (QUIC) কেন এল? TCP-র কোন সীমাবদ্ধতা তারা সারাতে চায়?

WebSocket চলে একটি TCP সংযোগের ওপর, আর TCP একটি প্রতিশ্রুতি দেয়: সব byte ঠিক ক্রমে, একটিও না হারিয়ে পৌঁছাবে। বেশিরভাগ কাজে এটি আশীর্বাদ। কিন্তু একটি packet হারালে TCP তার পরের সব ডেটা আটকে রাখে, যতক্ষণ না হারানোটি আবার পাঠিয়ে পৌঁছায় — একে বলে head-of-line blocking. একটি মাল্টিপ্লেয়ার গেমে খেলোয়াড়ের ২০০ ms আগের অবস্থান হারালে তার জন্য অপেক্ষার কোনো মানে নেই; সর্বশেষ অবস্থানটিই দরকার। লাইভ ভিডিওতেও তাই। আর একটি সংযোগে চ্যাট, ফাইল আর অবস্থান একসাথে চললে একটির হারানো packet সবগুলোকে থামিয়ে দেয়।

Google ২০১০-এর দশকের শুরুতে QUIC নিয়ে পরীক্ষা শুরু করে — UDP-র ওপর একটি নতুন পরিবহন, যেখানে একটি সংযোগে অনেকগুলো স্বাধীন stream থাকে, একটির ক্ষতি অন্যটিকে আটকায় না; TLS 1.3 ভেতরেই বসানো, আর সংযোগ স্থাপনে কম round-trip লাগে। সংযোগের পরিচয় IP ঠিকানার সাথে বাঁধা নয়, তাই Wi-Fi থেকে মোবাইল ডেটায় গেলেও সংযোগ টিকে থাকতে পারে — IETF ২০২১ সালে QUIC (RFC 9000) আর ২০২২ সালে এর ওপরের HTTP/3 (RFC 9114) মানদণ্ড প্রকাশ করে।

WebTransport হলো এই ক্ষমতা ব্রাউজারের JavaScript-এ আনার API: একটি সংযোগে অনেকগুলো নির্ভরযোগ্য stream (ক্রমসহ, প্রতিটি আলাদা), আর অনির্ভরযোগ্য datagram — পাঠাও, পৌঁছালে ভালো, না পৌঁছালে আবার পাঠানোর চেষ্টা নেই। Chrome ২০২২ সালের শুরুতে এটি চালু করে, পরে Firefox; কিন্তু সব ব্রাউজারে সমান সমর্থন, সার্ভার লাইব্রেরি, load balancer আর কর্পোরেট firewall (অনেকে UDP আটকায়) — সবকিছু এখনো WebSocket-এর মতো পরিণত নয়। তাই আজও বেশিরভাগ চ্যাট আর নোটিফিকেশনের জন্য WebSocket বা SSE-ই বাস্তবসম্মত, আর WebTransport-এ গেলেও WebSocket fallback রাখা বুদ্ধিমানের কাজ।

মূল উপলব্ধি: প্রতিটি প্রোটোকল একটি আপস — TCP-র “সব ক্রমে, কিছু হারাবে না” প্রতিশ্রুতি কিছু কাজে বোঝা। প্রয়োজন বুঝে আপস বাছাই করাই প্রকৌশল: চ্যাটে প্রতিটি বার্তা চাই, গেমে চাই সর্বশেষটি।

অনুশীলন

প্রথম তিনটিতে কোড লিখতে হবে না — কাগজে উত্তর লিখে তারপর মিলিয়ে নিন।

  1. প্রযুক্তি বাছাই: প্রতিটির জন্য polling, SSE বা WebSocket বাছুন আর এক লাইনে কারণ বলুন — (ক) বিপিএলের লাইভ স্কোর, (খ) কাস্টমার-সাপোর্ট চ্যাট, “টাইপ করছেন”-সহ, (গ) প্রতি ঘণ্টায় বদলানো ডলারের রেট, (ঘ) একটি AI সহকারীর শব্দে শব্দে উত্তর, (ঙ) দুজনের একসাথে একটি ডকুমেন্ট সম্পাদনা।

    (ক) SSE — খবর শুধু সার্ভার থেকে, দর্শক কিছু পাঠান না; স্বয়ংক্রিয় reconnection আর HTTP/2-বান্ধব। (খ) WebSocket — দুই দিকে ঘন ঘন ছোট বার্তা। (গ) Polling — ঘণ্টায় একবার বদলায়। (ঘ) SSE-র ফরম্যাট, fetch দিয়ে — প্রশ্ন একবার POST, উত্তর একমুখী ধারা; POST আর header লাগে বলে EventSource নয়। (ঙ) WebSocket — দুজনের সম্পাদনা দুই দিকে, কম দেরিতে, আর presence (কার cursor কোথায়)।

  2. স্ট্রিম পড়ুন: একটি SSE সংযোগে নিচের লেখা এল। EventSource কয়টি event ছুড়বে, প্রতিটির নাম, data আর lastEventId কী? onmessage কোনগুলো পাবে?
    retry: 5000 / (ফাঁকা) / id: 7 / data: স্কোর 120/3 / (ফাঁকা) / : ping / (ফাঁকা) / id: 8 / event: wicket / data: সাকিব আউট / data: 120/4 / (ফাঁকা)

    দুটি event. প্রথম ব্লকে শুধু retry — reconnection-এর সময় ৫ সেকেন্ড হলো, কোনো data নেই বলে event নয়। দ্বিতীয়টি নামহীন, তাই নাম "message", data = "স্কোর 120/3", lastEventId = "7" — এটিই onmessage পায়। : ping comment, উপেক্ষিত। শেষটির নাম "wicket", দুটি data লাইন \n দিয়ে জুড়ে "সাকিব আউট\n120/4", lastEventId = "8". এটি onmessage পায় না — es.addEventListener("wicket", …) লাগবে। সংযোগ এখন কাটলে ৫ সেকেন্ড পরে ব্রাউজার Last-Event-ID: 8 পাঠাবে।

  3. বাগ খুঁজুন: একটি AI-এর লেখা চ্যাট সার্ভারের মূল অংশ: wss.on("connection", ws => ws.on("message", d => { const m = JSON.parse(d); wss.clients.forEach(c => c.send(JSON.stringify({ from: m.from, html: m.text }))); })); আর client-এ box.innerHTML += msg.html. অন্তত পাঁচটি সমস্যা বলুন।

    (১) JSON.parse কোনো try/catch-এ নেই — একটি ভাঙা বার্তায় listener-এ exception; আর যাচাই নেই (text string কি না, দৈর্ঘ্য)। (২) from বার্তা থেকে নেওয়া — যে কেউ অন্যের নামে লিখতে পারে; পরিচয় আসা উচিত সংযোগের লগইন থেকে। (৩) wss.clients মানে সব সংযুক্ত মানুষ — room নেই, এক গ্রাহকের কথা সবাই দেখে। (৪) innerHTML-এ পাওয়া লেখা — broadcast XSS; textContent দরকার, আর field-এর নাম html নিজেই বিপদের সংকেত। (৫) handshake-এ Origin বা লগইন যাচাই নেই (CSWSH), maxPayload আর rate limit নেই, heartbeat নেই। বাড়তি: c.readyState === WebSocket.OPEN না দেখে পাঠানো, আর বার্তায় type/v নেই।

  4. কোড লিখুন — SSE-র ছোট buffer: একটি Node.js class EventLog বানান: push(data) প্রতিটি event-কে ক্রমিক id দিয়ে রাখে (সর্বোচ্চ শেষ ১০০টি), আর since(lastId) তার পরের event-গুলো ফেরত দেয়। lastId যদি এত পুরোনো হয় যে buffer থেকে মুছে গেছে, তাহলে কী করবেন?

    একটি সম্ভাব্য সমাধান:

    class EventLog {
      #items = [];
      #seq = 0;
      constructor(max = 100) { this.max = max; }
      push(data) {
        const ev = { id: ++this.#seq, data };
        this.#items.push(ev);
        if (this.#items.length > this.max) this.#items.shift();   // পুরোনোটি ফেলুন
        return ev;
      }
      since(lastId) {
        const oldest = this.#items[0]?.id ?? this.#seq + 1;
        if (lastId < oldest - 1) return null;                      // ফাঁক আছে — পূর্ণ অবস্থা লাগবে
        return this.#items.filter((e) => e.id > lastId);
      }
    }

    since যখন null দেয়, তখন হারানো event আর জোড়া লাগানো যাবে না — সার্ভার একটি বিশেষ event (যেমন event: reset, সাথে অর্ডারের সম্পূর্ণ বর্তমান অবস্থা) পাঠাবে, আর client পুরো পর্দা নতুন করে আঁকবে। বাস্তবে buffer মেমরিতে না রেখে Redis বা ডেটাবেসে রাখুন, যাতে একাধিক সার্ভার আর restart-এর পরেও কাজ করে।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি নিরাপদ WebSocket চ্যাট সংযোগে শুরু থেকে শেষ পর্যন্ত ঘটনাগুলো সাজান (▲▼ চেপে বা টেনে)।

  1. 🙋 ব্রাউজার Upgrade: websocket header-সহ একটি GET পাঠায়
  2. 🛡️ সার্ভার Origin আর লগইন cookie যাচাই করে
  3. 🤝 সার্ভার 101 Switching Protocols উত্তর দেয়, ব্রাউজারে onopen চলে
  4. 💬 দুই দিকে JSON বার্তার frame চলে, মাঝে মাঝে ping/pong
  5. 👋 এক পক্ষ code 1000-সহ close frame পাঠায়
  6. 🔚 অন্য পক্ষ close frame ফেরত দেয়, TCP সংযোগ বন্ধ হয়

একটি লাইভ ক্রিকেট স্কোরের পাতায় খবর শুধু সার্ভার থেকে দর্শকের দিকে যায়, আর সংযোগ কাটলে নিজে থেকেই আবার জোড়া লাগা দরকার। কোন প্রযুক্তি সবচেয়ে সরল আর মানানসই?

  • প্রতি সেকেন্ডে polling
  • Server-Sent Events (EventSource)
  • WebSocket
  • WebTransport datagram

সংযোগ কেটে যাওয়ার পরে ব্রাউজারের EventSource আবার সংযোগ করার সময় শেষ পাওয়া event-এর id কোন HTTP header-এ পাঠায়?

  • If-None-Match
  • Sec-WebSocket-Key
  • Last-Event-ID
  • Retry-After

WebSocket handshake সফল হলে সার্ভার কোন HTTP status code দিয়ে উত্তর দেয়?

  • 101 Switching Protocols
  • 200 OK
  • 204 No Content
  • 426 Upgrade Required

একটি সাধারণ ব্রাউজার new WebSocket("wss://…") client দিয়ে একটি Socket.IO সার্ভারে যুক্ত হওয়ার চেষ্টা করলে কী ঘটে?

  • সব কাজ করে, room-সহ
  • স্বয়ংক্রিয়ভাবে long polling-এ চলে যায়
  • শুধু text বার্তা চলে, binary চলে না
  • ঠিকমতো যুক্ত হয় না, কারণ Socket.IO একটি নিজস্ব প্রোটোকল

Cross-Site WebSocket Hijacking (CSWSH) ঠেকাতে WebSocket সার্ভারের handshake-এ সবচেয়ে জরুরি যাচাই কোনটি?

  • Access-Control-Allow-Origin header পাঠানো
  • Origin header অনুমোদিত তালিকার সাথে মেলানো
  • Sec-WebSocket-Accept ঠিকমতো হিসাব করা
  • শুধু wss:// ব্যবহার করা

ব্রাউজারের WebSocket অবজেক্টের bufferedAmount কী জানায়?

  • এ পর্যন্ত পাওয়া বার্তার সংখ্যা
  • সার্ভারের queue-তে কত বার্তা জমে আছে
  • send() করা কিন্তু এখনো নেটওয়ার্কে না যাওয়া byte-এর পরিমাণ
  • একটি বার্তার সর্বোচ্চ অনুমোদিত আকার

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখতে পাবেন।

মনে রাখুন

  1. HTTP-তে কথা শুরু করে client; real-time মানে সার্ভারের push. সিঁড়ি: polling → long polling → SSE → WebSocket — প্রয়োজনের চেয়ে উঁচুতে উঠবেন না।
  2. SSE = একটি না-ফুরানো HTTP উত্তর, text/event-stream, ফাঁকা লাইনে event শেষ। EventSource নিজে reconnect করে আর Last-Event-ID পাঠায়; proxy buffering বন্ধ, comment ping, HTTP/2.
  3. WebSocket = HTTP upgrade (101), তারপর frame দুই দিকে। Production-এ wss://; close code পড়ে সিদ্ধান্ত; সংযোগের আগে send নয়।
  4. নকশা আপনার: type আর v-সহ JSON বার্তা, সার্ভারের id, room আর presence, heartbeat, backoff + jitter-সহ reconnect, bufferedAmount দেখে backpressure.
  5. প্রতিটি বার্তা অবিশ্বস্ত — Origin আর লগইন handshake-এ, schema, আকার ও হারের সীমা, textContent. একাধিক সার্ভারে Redis pub/sub; Socket.IO আলাদা প্রোটোকল।

পরের পর্বে: এই পর্বের প্রতিটি বার্তা, অর্ডারের অবস্থা আর “Last-Event-ID-এর পরের event” কোথাও স্থায়ীভাবে রাখতে হবে। পরের পর্বে Node.js থেকে ডেটাবেসে যাওয়া — কীভাবে Node একটি ডেটাবেসের সাথে কথা বলে, আর ডেটা নিরাপদে রাখা ও ফেরত আনা যায়।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান