সরাসরি পর্বে যান
পর্ব ৬০ · ৯৩-এর মধ্যে · মডিউল ৯

WebSocket — Socket.IO, room আর real-time chat

WebSockets — Socket.IO, Rooms and a Real-Time Chat in Node.js and React

Taught in Bangla, this lesson starts with when real-time is needed and when polling is enough, captures the upgrade handshake and frames, uses the browser WebSocket API and the ws package for Node, explains Socket.IO events, acks, rooms and namespaces, builds the Bazarghar chat with session auth, room checks and messages saved in PostgreSQL, heartbeats and reconnects, scaling with the Redis adapter, a React useSocket hook, and security limits.

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

  • কখন real-time সংযোগ দরকার আর কখন নয়, তা সংখ্যা দিয়ে ঠিক করতে
  • WebSocket handshake, Sec-WebSocket-Accept আর masked frame নিজের হাতে খুলতে
  • ws আর Socket.IO দিয়ে event, ack, room আর namespace-ভিত্তিক server বানাতে
  • Cookie দিয়ে auth, room-এর অনুমতি, Zod, rate limit আর PostgreSQL-এ বার্তা রাখতে
  • Heartbeat, reconnect, backpressure, Redis adapter, sticky session আর React-এর useSocket সামলাতে

১ · কখন real-time দরকার, আর কখন polling-ই যথেষ্ট

বাজারঘরে এখন দুটি নতুন চাহিদা। প্রথমটি অর্ডারের পাতায় লাইভ অবস্থা: “প্যাক হচ্ছে”, “পাঠানো হয়েছে”। দ্বিতীয়টি ক্রেতা আর বিক্রেতার chat, যেমন “ডেলিভারি কখন হবে?”। এতদিন আমাদের সব কথা ছিল এক ধাঁচের: browser জিজ্ঞেস করে, server উত্তর দেয়। Server নিজে থেকে কিছু বলতে পারে না। তত্ত্বটি আছে সিস্টেম ডিজাইন কোর্সের Real-time যোগাযোগ পর্বে, সেখানে polling, long polling, SSE আর WebSocket-এর তুলনা পাবেন। এখানে আমরা application developer হিসেবে এটি বানাব, চালাব আর ভাঙব।

পাড়ার মুদি দোকানে চাল এসেছে কি না জানতে আপনি দুইভাবে খোঁজ নিতে পারেন। এক, প্রতি দশ মিনিটে দোকানে গিয়ে জিজ্ঞেস করা। এটি polling: বেশিরভাগ যাত্রা বৃথা, আর চাল এলেও আপনি জানবেন পরের যাত্রায়। দুই, দোকানির সাথে একটি ফোন কল খোলা রাখা, চাল এলেই তিনি বলবেন, আর আপনিও যেকোনো সময় প্রশ্ন করতে পারবেন। এটি WebSocket. খোলা ফোনের একটি দাম আছে: লাইন ধরে রাখতে হয়, আর লাইন কেটে গেলে বুঝতে হয়।

তাই প্রথম প্রশ্ন প্রযুক্তি নয়, চাহিদা। তিনটি প্রশ্ন করুন।

প্রশ্নউত্তর “না” হলেউত্তর “হ্যাঁ” হলে
খবর কি কয়েক সেকেন্ডের মধ্যে পৌঁছাতে হবে?পাতা খুললে বা প্রতি এক মিনিটে আনা যথেষ্টPush দরকার
Client-ও কি ঘন ঘন পাঠাবে?শুধু server থেকে আসবে: SSE (পর্ব ৬১)দুই দিকের কথা: WebSocket
খবর কি খুব ঘন, বা খুব বেশি মানুষের কাছে?Polling-এর খরচ কম, চলবেPolling-এর অনুরোধ জমে যায়

একটি হিসাব করুন। ধরুন একসাথে ২০,০০০ ক্রেতা অর্ডারের পাতা খোলা রেখেছেন, আর পাতা প্রতি ৫ সেকেন্ডে poll করে। তাহলে server পায় ২০,০০০ ÷ ৫ = ৪,০০০ অনুরোধ প্রতি সেকেন্ডে, ঘণ্টায় ১ কোটি ৪৪ লাখ। অথচ একটি অর্ডারের অবস্থা দিনে হয়তো চার-পাঁচবার বদলায়। প্রায় সব উত্তরই “কিছু বদলায়নি”। আবার একই ২০,০০০ জনের জন্য WebSocket খোলা রাখলে প্রতিটি সংযোগ memory নেয়, যা আমরা অংশ ৭-এ মাপব, আর heartbeat-এর জন্য প্রতি ২৫ সেকেন্ডে একটি ছোট ping যায়, মানে ২০,০০০ ÷ ২৫ = ৮০০টি ping প্রতি সেকেন্ডে, প্রতিটি কয়েক byte.

সিদ্ধান্তের নিয়ম

অর্ডারের অবস্থা দেখানোর জন্য শুধু এক দিকের push লাগে, তাই SSE প্রায়ই যথেষ্ট। Chat-এ দুই দিকেই কথা, তাই WebSocket মানানসই। বাজারঘর দুটিই এক সংযোগে করবে, কারণ chat-এর সংযোগ তো খোলাই থাকছে। Polling, long polling আর SSE-এর পুরো কোড আসবে পরের পর্ব ৬১-এ।

২ · Handshake আর frame — HTTP থেকে WebSocket-এ বদল

WebSocket নতুন কোনো port বা নতুন সংযোগ খোলে না। এটি শুরু হয় একটি সাধারণ HTTP GET দিয়ে, যাতে বলা থাকে “এই সংযোগটি অন্য protocol-এ বদলাতে চাই”। একে বলে HTTP Upgrade. Server রাজি হলে 101 Switching Protocols পাঠায়। তারপর একই TCP সংযোগে HTTP আর থাকে না, চলে ছোট ছোট frame. নিয়মটি লেখা আছে RFC 6455-এ, ২০১১ সালের ডিসেম্বরে প্রকাশিত।

নিচের capture কোনো ছবি থেকে নেওয়া নয়। আমরা node:net দিয়ে কাঁচা TCP খুলে ws 8.22.1 server-এ হাতে বানানো byte পাঠিয়েছি (সত্যিকারের run, Node 24.15.0). Key হিসেবে দিয়েছি RFC-র নিজের উদাহরণের key.

>>> REQUEST
GET /chat HTTP/1.1
Host: 127.0.0.1:38661
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: http://localhost:5173

<<< RESPONSE
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

>>> FRAME (client, masked) 81 85 37 fa 21 3d 7f 9f 4d 51 58
<<< FRAME (server, unmasked) 81 09 6f 6b 3a 20 48 65 6c 6c 6f
    text = "ok: Hello"
<<< CLOSE 88 02 03 e8

লক্ষ করুন, s3pPLMBiTxaQ9kYGzzhZRbK+xOo= আর client-এর frame-এর ১১টি byte দুটিই RFC 6455-এর উদাহরণের সাথে হুবহু মেলে। এখন ধাপে ধাপে দেখুন প্রতিটি byte কী বলছে।

  1. BrowserGET + UpgradeSec-WebSocket-Key
  2. ServerSHA-1 + base64Origin যাচাই
  3. 101SwitchingHTTP শেষ
  4. Frame81 85 …দুই দিকে
অনুরোধসাধারণ HTTP/1.1 GET
GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: http://localhost:5173
Server-এর হিসাবRFC 6455-এর স্থির GUID
key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
  → SHA-1 (20 byte)
  → base64 → s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Origin তালিকায় না থাকলে: 403, upgrade নেই
উত্তরসত্যিকারের run
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
এরপর এই TCP সংযোগে আর HTTP নেই
Client-এর frame১১ byte
81 = FIN 1, opcode 1 (text)
85 = MASK 1, length 5
37 fa 21 3d = masking key
7f 9f 4d 51 58 = masked "Hello"
Mask খোলাপ্রতিটি byte XOR key[i mod 4]
7f ^ 37 = 48  'H'
9f ^ fa = 65  'e'
4d ^ 21 = 6c  'l'
51 ^ 3d = 6c  'l'
58 ^ 37 = 6f  'o'
Server-এর উত্তর আর বন্ধserver-এর frame-এ mask নেই
81 09 6f 6b 3a 20 48 65 6c 6c 6f
   FIN+text, length 9, "ok: Hello"
88 02 03 e8
   opcode 8 (close), code 0x03e8 = 1000
  1. অনুরোধ। Browser একটি সাধারণ GET পাঠায়, সাথে ১৬টি এলোমেলো byte-এর base64 রূপ Sec-WebSocket-Key. এটি গোপন কিছু নয়, শুধু প্রমাণ চাওয়া যে server সত্যিই WebSocket বোঝে।
  2. হিসাব। Server key-এর পরে RFC-র একটি স্থির GUID জুড়ে SHA-1 করে, তারপর base64. এখানে server Origin-ও দেখে নিতে পারে, অচেনা সাইট হলে 403.
  3. 101. উত্তর মিললে browser নিশ্চিত হয়। এখন থেকে এই TCP সংযোগে HTTP-এর header বা status আর নেই।
  4. প্রথম frame. 0x81 মানে শেষ টুকরো আর text. 0x85-এর ওপরের bit বলে mask আছে, বাকি ৭ bit বলে দৈর্ঘ্য ৫. তারপর ৪ byte-এর masking key.
  5. Mask খোলা। প্রতিটি byte-কে key-এর পালাক্রমের byte দিয়ে XOR করলেই “Hello”. Client থেকে server-এ যাওয়া প্রতিটি frame-এ mask বাধ্যতামূলক।
  6. উত্তর আর বন্ধ। Server-এর frame-এ mask নেই, দৈর্ঘ্য ৯. শেষে close frame, code 1000 মানে স্বাভাবিক বন্ধ। একটি পুরো বার্তার খরচ মাত্র কয়েক byte header.

এবার একই হিসাব নিজে চালান। ব্লকটি RFC-র key থেকে accept বানায়, তারপর RFC-র masked frame আর একটি server frame খোলে।

// শেখার মডেল: handshake-এর চাবি আর একটি masked frame খোলা (RFC 6455-এর উদাহরণ)
const crypto = require('node:crypto');
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; // RFC-তে স্থির করা
function accept(key) {
  return crypto.createHash('sha1').update(key + GUID).digest('base64');
}
console.log('accept:', accept('dGhlIHNhbXBsZSBub25jZQ=='));

function decode(buf) {
  const fin = (buf[0] & 0x80) !== 0, opcode = buf[0] & 0x0f;
  const masked = (buf[1] & 0x80) !== 0;
  let len = buf[1] & 0x7f, off = 2;
  if (len === 126) { len = buf.readUInt16BE(2); off = 4; }
  if (len === 127) throw new Error('এই মডেলে বড় frame নেই');
  const mask = masked ? buf.subarray(off, off + 4) : null;
  if (masked) off += 4;
  const data = Buffer.from(buf.subarray(off, off + len));
  if (masked) for (let i = 0; i < len; i++) data[i] ^= mask[i % 4];
  return { fin, opcode, masked, len, text: data.toString('utf8') };
}
const fromClient = Buffer.from('818537fa213d7f9f4d5158', 'hex');
console.log('client frame:', decode(fromClient));
const fromServer = Buffer.from('810548656c6c6f', 'hex');
console.log('server frame:', decode(fromServer));
const bn = Buffer.from('চাল', 'utf8');
console.log('Bangla word:', bn.length, 'bytes, len field =', decode(Buffer.concat([Buffer.from([0x81, bn.length]), bn])).len);

আউটপুট: accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=, তারপর client frame { fin: true, opcode: 1, masked: true, len: 5, text: 'Hello' }, server frame একই, শুধু masked: false. শেষ লাইন Bangla word: 9 bytes, len field = 9. “চাল” তিনটি অক্ষর, কিন্তু UTF-8-এ প্রতিটি ৩ byte, তাই দৈর্ঘ্যের ঘরে ৯. Frame-এর দৈর্ঘ্য সবসময় byte-এ, অক্ষরে নয়। বাংলা chat-এ ৫০০ অক্ষরের সীমা মানে প্রায় ১,৫০০ byte, এটি অংশ ৯-এর আকারের সীমায় কাজে লাগবে। ১২৫-এর বেশি হলে দৈর্ঘ্য পরের ২ byte-এ যায় (126), আরও বড় হলে ৮ byte-এ (127).

এখন পুরো handshake নিজে লিখুন, কোনো package ছাড়া। node:http server-এর upgrade event কাঁচা TCP socket হাতে দেয়। একই ব্লকে একটি অচেনা Origin-এর অনুরোধও আছে।

// শেখার মডেল: node:http-এ হাতে লেখা WebSocket handshake আর একটি frame
const http = require('node:http'), crypto = require('node:crypto');
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const accept = (k) => crypto.createHash('sha1').update(k + GUID).digest('base64');
const frame = (text, mask) => {            // ছোট text frame (১২৫ byte পর্যন্ত)
  const p = Buffer.from(text);
  if (!mask) return Buffer.concat([Buffer.from([0x81, p.length]), p]);
  const m = p.map((b, i) => b ^ mask[i % 4]);
  return Buffer.concat([Buffer.from([0x81, 0x80 | p.length]), mask, m]);
};
const unmask = (b) => Buffer.from(b.subarray(6, 6 + (b[1] & 0x7f)).map((x, i) => x ^ b[2 + (i % 4)]));

const server = http.createServer((req, res) => res.writeHead(426).end());
server.on('upgrade', (req, socket) => {
  if (req.headers.origin !== 'http://bazarghar.test') return socket.end('HTTP/1.1 403 Forbidden\r\n\r\n');
  socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
    `Sec-WebSocket-Accept: ${accept(req.headers['sec-websocket-key'])}\r\n\r\n`);
  socket.once('data', (b) => socket.end(frame('server got: ' + unmask(b))));
});
server.listen(0, '127.0.0.1', () => {
  const key = crypto.randomBytes(16).toString('base64');
  const open = (origin) => http.request({ port: server.address().port, host: '127.0.0.1', headers: {
    Connection: 'Upgrade', Upgrade: 'websocket', Origin: origin,
    'Sec-WebSocket-Key': key, 'Sec-WebSocket-Version': '13' } });
  const bad = open('http://evil.example');
  bad.on('response', (r) => { console.log('evil origin ->', r.statusCode); good(); }); bad.end();
  const good = () => { const req = open('http://bazarghar.test');
  req.on('upgrade', (res, socket) => {
    console.log('status', res.statusCode, '| accept ok:', res.headers['sec-websocket-accept'] === accept(key));
    socket.write(frame('order 101?', crypto.randomBytes(4)));
    socket.on('data', (b) => console.log('frame bytes', b.length, '| text:', b.subarray(2).toString()));
    socket.on('end', () => server.close());
  }); req.end(); };
});

আউটপুট তিন লাইন: evil origin -> 403, status 101 | accept ok: true আর frame bytes 24 | text: server got: order 101?. ২৪ byte = ২ byte header + ২২ byte লেখা। Client প্রতিবার নতুন এলোমেলো key আর নতুন mask বানায়, তবু ফল একই। এই মডেলে fragment, ping, বড় frame বা UTF-8 যাচাই নেই। আসল কাজে নিজে frame parser লিখবেন না। ws-এর মতো পরীক্ষিত library ব্যবহার করুন, যা এসব সীমার ক্ষেত্রও সামলায়।

৩ · Browser-এর WebSocket API আর Node-এর ws

Browser-এ WebSocket তৈরি করা খুব ছোট কাজ। চারটি event আর দুটি method-ই প্রায় সব।

const ws = new WebSocket('wss://bazarghar.example/ws');   // production-এ সবসময় wss://
ws.addEventListener('open', () => ws.send(JSON.stringify({ type: 'join', orderId: 101 })));
ws.addEventListener('message', (e) => render(JSON.parse(e.data)));
ws.addEventListener('close', (e) => console.log('closed', e.code, e.reason));
ws.addEventListener('error', () => { /* বিস্তারিত দেয় না, পরে close আসবে */ });
// readyState: 0 CONNECTING, 1 OPEN, 2 CLOSING, 3 CLOSED

লক্ষ করুন, এখানে cookie বা header দেওয়ার কোনো উপায় নেই। Browser handshake-এ নিজে থেকে সেই সাইটের cookie আর Origin জুড়ে দেয়, কিন্তু Authorization header বসানো যায় না। এটি অংশ ৫-এর auth নকশায় গুরুত্বপূর্ণ। আর browser-এর API-তে নিজে reconnect, room বা ack নেই, এগুলো আপনাকে লিখতে হয়।

Server-এর দিকে Node.js-এ সবচেয়ে প্রচলিত package হলো ws. লেখার সময়ে এর version 8.22.1. Node 22 থেকে browser-এর মতো একটি global WebSocket client Node-এ এসেছে, তবে server বানানোর কোনো built-in API এখনো নেই, তাই server-এ ws লাগে। (এই পাতার চালানো যায় এমন Node ব্লকগুলো Node 20-এ চলে, যেখানে global client নেই, তাই ওপরে আমরা node:http দিয়ে হাতে লিখেছি।)

import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ host: '127.0.0.1', port: 38675, maxPayload: 16 * 1024 });
wss.on('connection', (ws, req) => {
  console.log('server: connection from origin', req.headers.origin ?? '(none: not a browser)');
  ws.on('message', (data) => {
    for (const c of wss.clients) if (c.readyState === 1) c.send(`echo: ${data}`);
  });
  ws.on('error', (err) => console.log('server: error', err.code));   // এই লাইন না থাকলে কী হয়, নিচে দেখুন
  ws.on('close', (code) => console.log('server: close', code));
});

Client হিসেবে Node 24-এর global WebSocket দিয়ে একটি JSON পাঠিয়ে, তারপর ২০,০০০ byte-এর একটি বার্তা পাঠালাম (সত্যিকারের run, ws 8.22.1):

client: readyState 0 (CONNECTING)
server: connection from origin (none: not a browser)
client: open, readyState 1
client: message echo: {"type":"hello","orderId":101}
server: error WS_ERR_UNSUPPORTED_MESSAGE_LENGTH
client: close 1009 "" wasClean true
server: close 1006

দুটি শিক্ষা আছে। এক, maxPayload ১৬ KB, তাই ২০ KB-এর বার্তায় সংযোগ বন্ধ হলো, client পেল close code 1009 (message too big). দুই, প্রথমবার আমরা ws.on('error') লিখিনি। তখন একই বার্তায় পুরো server process পড়ে গিয়েছিল:

node:events:487
      throw er; // Unhandled 'error' event
      ^
RangeError: Max payload size exceeded
    at Receiver.haveLength (…/ws/lib/receiver.js:442:28)
  …
  code: 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH',
  Symbol(status-code): 1009

একজন client-এর একটি বড় বার্তায় সবার chat বন্ধ, কারণ Node-এ কোনো EventEmitter-এর error event কেউ না শুনলে সেটি throw হয় (পর্ব ২৪)। প্রতিটি socket-এ error listener রাখুন। আরও লক্ষ করুন, Node-এর client Origin পাঠায়নি। Origin যাচাই শুধু browser থেকে আসা অন্য সাইটের চেষ্টা আটকায়, script থেকে নয়। তাই Origin যাচাই কখনো auth-এর বদলি নয়।

৪ · Socket.IO — event, ack, room আর namespace

কাঁচা WebSocket শুধু byte পাঠানো জানে। বাস্তব অ্যাপে লাগে আরও অনেক কিছু: কোন বার্তা কোন ধরনের, উত্তর এল কি না, কে কোন দলে, সংযোগ কাটলে আবার জোড়া, আর সংযোগ না থাকলে বার্তা জমিয়ে রাখা। Socket.IO এগুলো একসাথে দেয়। লেখার সময়ে npm-এ socket.io আর socket.io-client দুটিরই latest 4.8.4.

Event

নাম দেওয়া বার্তা: socket.emit('chat:send', data), অন্য দিকে socket.on('chat:send', fn). JSON-এ নিজে type লিখতে হয় না।

emit, on, off

Acknowledgement

বার্তার সাথে একটি callback, যা অন্য দিক ডেকে উত্তর ফেরায়। HTTP-এর request-response-এর মতো, কিন্তু খোলা সংযোগের ওপর।

emitWithAck, timeout

Room

Server-এর ভেতরের একটি দল। socket.join('order:101'), তারপর io.to('order:101').emit(…). Client room-এর কথা জানেই না।

join, leave, to

Namespace

একই সংযোগের ভেতরে আলাদা পথ, যেমন / ক্রেতার জন্য আর /admin admin-এর জন্য, প্রতিটির নিজের middleware.

io.of('/admin')

কিন্তু একটি বিষয় প্রায়ই ভুল বোঝা হয়: Socket.IO কোনো WebSocket library নয়, এটি নিজস্ব protocol. ভেতরে দুটি স্তর: নিচে Engine.IO, যা সংযোগ, heartbeat আর transport সামলায় (প্রথমে HTTP long-polling, পরে WebSocket-এ উন্নীত), আর ওপরে Socket.IO, যা event, namespace আর ack দেয়। তাই একটি সাধারণ WebSocket client Socket.IO server-এর সাথে কথা বলতে পারে না। নিচের run সেটি দেখায় (সত্যিকারের run, Socket.IO 4.8.4, client হিসেবে Node 24-এর global WebSocket):

A plain WebSocket to /            -> error event
A close code 1006
B open; sending "hello"
B raw frame: 0{"sid":"D-vF_TSB6QP5e4iyAAAA","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"max
B closed by server code 1005
C event hello {"shop":"bazarghar"}
C ack {"got":"চাল আছে?","at":"server"}
  • A: ws://…/-এ সাধারণ client. কাঁচা অনুরোধে দেখা গেছে, server এই path-এর upgrade-এ কোনো উত্তর না দিয়েই সংযোগ বন্ধ করে, তাই browser-এর মতো client পায় error আর code 1006.
  • B: ঠিক path /socket.io/?EIO=4&transport=websocket দিলে handshake হয়, কিন্তু প্রথম frame-ই Engine.IO-র: 0 মানে “open”, সাথে session id আর heartbeat-এর সময়। আমরা সাদা hello পাঠাতেই server সংযোগ কেটে দিল, কারণ এটি তার protocol-এর কোনো packet নয়।
  • C: আসল socket.io-client event পেল, আর emitWithAck দিয়ে উত্তরও। বাংলা লেখা ঠিকঠাক গেল।

Ack-এর ব্যবহার দেখুন। এটি আমাদের chat-এর মূল ধাঁচ হবে: server প্রতিটি অনুরোধের ফল জানাবে, ব্যর্থ হলেও।

// server
socket.on('ping:shop', (msg, ack) => ack({ got: msg, at: 'server' }));
// client: ১ সেকেন্ডে উত্তর না এলে promise reject হয়
const reply = await socket.timeout(1000).emitWithAck('ping:shop', 'চাল আছে?');
বিষয়ws (কাঁচা WebSocket)Socket.IO 4.8
Clientযেকোনো WebSocket client, browser-এর API সরাসরিশুধু Socket.IO client (JS, আর অন্য ভাষায় community library)
Reconnect, bufferingনিজে লিখতে হয়আছে, ডিফল্টে চালু
Room, broadcastনিজের Mapআছে, adapter দিয়ে একাধিক process-এ
HeartbeatProtocol-এ ping/pong আছে, টাইমার নিজে লিখতে হয়আছে (২৫ s / ২০ s)
Memory (আমাদের এক run)প্রতি সংযোগে কমবেশি, অংশ ৭ দেখুন

কোনটি নেবেন, তা নিয়ে মতভেদ আছে। ছোট দল আর chat-এর মতো অ্যাপে Socket.IO অনেক সময় বাঁচায়। আবার যে API অন্য ভাষার বা device-এর client-ও ব্যবহার করবে, বা যেখানে প্রতি সংযোগের memory খুব গুরুত্বপূর্ণ, সেখানে অনেকে কাঁচা WebSocket আর নিজের ছোট protocol পছন্দ করেন। বাজারঘরে আমরা Socket.IO নেব, কারণ client-ও আমাদের নিজের React অ্যাপ।

৫ · বাজারঘরের chat — auth, room-এর অনুমতি আর PostgreSQL-এ বার্তা

নকশাটি সহজ। প্রতিটি অর্ডারের জন্য একটি room: order:101. সেই অর্ডারের ক্রেতা আর বিক্রেতা room-এ ঢোকেন। Chat-এর বার্তা আর অবস্থার খবর দুটিই সেই room-এ যায়। কিন্তু তিনটি দরজায় পাহারা লাগে।

  1. সংযোগের দরজা: আপনি কে? পর্ব ৪৫-এর session cookie handshake-এর সাথেই আসে, তাই একই express-session middleware Socket.IO-তেও চালানো যায়। Mobile app বা অন্য domain হলে বিকল্প হলো auth: { token } দিয়ে token পাঠানো (পর্ব ৪৬), যা server socket.handshake.auth-এ পায়।
  2. Room-এর দরজা: আপনি কি এই অর্ডারের? সংযোগ থাকা মানেই সব room-এ ঢোকার অধিকার নয়। এটি পর্ব ৪৮-এর ownership যাচাই, এবার socket-এ।
  3. বার্তার দরজা: বার্তার আকার, ধরন আর হার ঠিক আছে কি? Zod (পর্ব ২৭), rate limit আর আকারের সীমা।

Server-এর মূল অংশ নিচে। ল্যাবে আছে Express 5.3.0, express-session 1.19.0, Socket.IO 4.8.4, Zod 4.6.7, pg 8.23.1 আর PostgreSQL 18.6 (embedded-postgres-এর 18.6.0-beta.19). Login-এ ল্যাব শুধু user id নেয়, password-এর আসল login পর্ব ৪৫-এ।

const ORIGINS = new Set(['http://localhost:5173']);
const sessionMw = session({ name: 'sid', secret: process.env.SESSION_SECRET, resave: false,
  saveUninitialized: false, cookie: { httpOnly: true, sameSite: 'lax', maxAge: 30 * 60 * 1000 } });
app.use(express.json(), sessionMw);

const io = new Server(server, {
  maxHttpBufferSize: 16 * 1024,                                   // প্রতি বার্তা ১৬ KB
  allowRequest: (req, cb) => cb(null, ORIGINS.has(req.headers.origin)),
  cors: { origin: [...ORIGINS], credentials: true },
});
io.engine.use(sessionMw);                       // একই cookie, একই session
io.use((socket, next) => {                      // দরজা ১: সংযোগের সময় পরিচয়
  const uid = socket.request.session?.userId;
  if (!uid) return next(new Error('unauthorized'));
  socket.data.userId = uid;
  next();
});

const OrderId = z.coerce.number().int().positive();
const ChatMsg = z.object({ orderId: OrderId, text: z.string().trim().min(1).max(500) });

socket.on('order:join', async (raw, ack) => {   // দরজা ২: room-এর অনুমতি
  if (typeof ack !== 'function') return;
  const p = OrderId.safeParse(raw);
  if (!p.success) return ack({ ok: false, error: 'invalid_input' });
  const role = await canSee(uid, p.data);       // SELECT customer_id, seller_id FROM orders …
  if (!role) return ack({ ok: false, error: 'forbidden' });
  await socket.join(`order:${p.data}`);
  const { rows } = await pool.query(
    'SELECT id, sender_id, body, created_at FROM messages WHERE order_id = $1 ORDER BY id DESC LIMIT 50',
    [p.data]);
  ack({ ok: true, role, history: rows.reverse() });
});

socket.on('chat:send', async (raw, ack) => {    // দরজা ৩: বার্তা
  if (typeof ack !== 'function') return;
  if (limited()) return ack({ ok: false, error: 'rate_limited' });  // প্রতি socket ২ s-এ ৫টি
  const p = ChatMsg.safeParse(raw);
  if (!p.success) return ack({ ok: false, error: 'invalid_input' });
  if (!socket.rooms.has(`order:${p.data.orderId}`)) return ack({ ok: false, error: 'not_joined' });
  const { rows } = await pool.query(
    'INSERT INTO messages(order_id, sender_id, body) VALUES ($1, $2, $3) RETURNING *',
    [p.data.orderId, uid, p.data.text]);
  io.to(`order:${p.data.orderId}`).emit('chat:new', rows[0]);   // আগে database, তারপর room
  ack({ ok: true, id: rows[0].id });
});

Table তিনটি: users, orders(customer_id, seller_id, status) আর messages(order_id, sender_id, body, created_at), সাথে (order_id, id)-এর ওপর একটি index, যাতে একটি অর্ডারের শেষ ৫০টি বার্তা দ্রুত আসে। তিনজন ব্যবহারকারী: ক্রেতা Rahima (অর্ডার ১০১), বিক্রেতা Karim (দুই অর্ডারেরই), আর অন্য ক্রেতা Sumon (অর্ডার ১০২)। Database-এ আগে থেকে Karim-এর একটি বার্তা আছে। এবার আসল run (সত্যিকারের run, Node 24.15.0):

pg ok bazarghar_ws 38664 18.6
--- auth at connect
anon     connect_error: unauthorized
evil     connect_error: websocket error
rahima/karim/sumon connected: true true true
--- room join
rahima join 101 -> {"ok":true,"role":"customer","history":["অর্ডার পেয়েছি, আজই প্যাক হবে"]}
karim  join 101 -> {"ok":true,"role":"seller"}
sumon  join 101 -> {"ok":false,"error":"forbidden"}
sumon  join "101 OR 1=1" -> {"ok":false,"error":"invalid_input"}
--- message to a room
  rahima got chat:new {"id":"2","from":1,"body":"ডেলিভারি কখন হবে?"}
rahima send -> {"ok":true,"id":"2"}
  karim got chat:new {"id":"2","from":1,"body":"ডেলিভারি কখন হবে?"}
sumon send to 101 -> {"ok":false,"error":"not_joined"}
bad payload -> {"ok":false,"error":"invalid_input","issues":["orderId:invalid_type","text:too_small"]}
too long -> {"ok":false,"error":"invalid_input","issues":["text:too_big"]}
--- order status
rahima sets status -> {"ok":false,"error":"forbidden"}
  rahima got order:status {"orderId":101,"status":"packed"}
karim sets status -> {"ok":true}
--- rate limit (8 fast sends)
ok ok ok ok ok rate_limited rate_limited rate_limited
--- history after reconnect
history count 7 first 3: ["অর্ডার পেয়েছি, আজই প্যাক হবে","ডেলিভারি কখন হবে?","spam 0"]
[ { order_id: 101, n: 7 } ]
--- oversized message (20 KB)
sumon disconnected: transport close after <1 s
done
  • Auth: cookie ছাড়া unauthorized. বৈধ cookie থাকলেও Origin: http://evil.example-এ allowRequest handshake-ই আটকে দিল। Rahima-র cookie সেখানে কাজে আসেনি।
  • অন্যের room: Sumon সংযুক্ত, login করা, তবু order:101-এ forbidden. SQL-এর চালাকি "101 OR 1=1" Zod-এই আটকে গেল, আর query তো parameterized.
  • Room-এ বার্তা: Rahima-র বার্তা Rahima আর Karim পেলেন, Sumon পাননি। Sumon room-এ না ঢুকে সরাসরি পাঠাতে চাইলে not_joined.
  • Id একটি string: "id":"2". bigserial PostgreSQL-এর ৬৪-bit সংখ্যা, আর pg নির্ভুলতা রাখতে একে string হিসেবে দেয়। React-এর দিকে এটি মনে রাখবেন।
  • অবস্থা বদল: ক্রেতা নিজে অবস্থা বদলাতে পারেন না, বিক্রেতা পারেন, আর খবরটি room-এ গেল।
  • ইতিহাস: নতুন সংযোগে room-এ ঢুকতেই database থেকে ৭টি বার্তা এল। বার্তা memory-তে নয়, PostgreSQL-এ, তাই server restart হলেও হারায় না।

Room আসলে কী? Server-এর ভেতরে একটি Map: room-এর নাম থেকে socket id-এর একটি set. নিচের মডেলে সেটি নিজে চালিয়ে দেখুন।

// শেখার মডেল: in-memory room, join-এর অনুমতি আর room-এ broadcast
const orders = { 101: { customer: 'rahima', seller: 'karim' }, 102: { customer: 'sumon', seller: 'karim' } };
const rooms = new Map();                  // room -> Set(socketId)
const sockets = new Map();                // socketId -> { user, inbox, rooms }

function connect(id, user) { sockets.set(id, { user, inbox: [], rooms: new Set() }); }
function join(id, orderId) {
  const s = sockets.get(id), o = orders[orderId];
  if (!o || (o.customer !== s.user && o.seller !== s.user)) return 'forbidden';
  const room = 'order:' + orderId;
  if (!rooms.has(room)) rooms.set(room, new Set());
  rooms.get(room).add(id); s.rooms.add(room);
  return 'ok';
}
function toRoom(room, msg, exceptId) {
  let n = 0;
  for (const id of rooms.get(room) ?? []) if (id !== exceptId) { sockets.get(id).inbox.push(msg); n++; }
  return n;
}
function disconnect(id) {
  for (const room of sockets.get(id).rooms) {
    rooms.get(room).delete(id);
    if (rooms.get(room).size === 0) rooms.delete(room);   // খালি room মুছে ফেলা
  }
  sockets.delete(id);
}
connect('s1', 'rahima'); connect('s2', 'karim'); connect('s3', 'sumon'); connect('s4', 'rahima');
console.log('join', join('s1', 101), join('s2', 101), join('s3', 101), join('s4', 101), join('s3', 102));
console.log('delivered to', toRoom('order:101', 'karim: packed', 's2'), 'sockets');
for (const [id, s] of sockets) console.log(id, s.user, JSON.stringify(s.inbox));
disconnect('s3');
console.log('rooms now:', JSON.stringify([...rooms].map(([r, set]) => [r, [...set]])));

আউটপুট: join ok ok forbidden ok ok, তারপর delivered to 2 sockets. Rahima-র দুটি ট্যাব (s1, s4) দুটিই ["karim: packed"] পেল, Karim নিজে পাননি, কারণ পাঠানো socket বাদ (Socket.IO-তে socket.to(room) এভাবেই কাজ করে, আর io.to(room) সবাইকে পাঠায়)। Sumon কিছু পাননি। শেষে s3 চলে গেলে তার একমাত্র room order:102 খালি হয়ে মুছে গেল, তাই থাকল শুধু [["order:101",["s1","s2","s4"]]]. এই Map একটি process-এর memory-তে থাকে, তাই অংশ ৭-এ দ্বিতীয় process আনলেই সমস্যা দেখা দেবে।

1if (limited()) return ack(…);2const p = ChatMsg.safeParse(raw);3if (!socket.rooms.has(room)) …4INSERT INTO messages … RETURNING *5io.to(room).emit('chat:new', row);6ack({ ok: true, id: row.id });
Rahima-র socket-এ
win.n
p.success
in room
row.id
পেলেন
  1. হার যাচাই। এই socket ২ সেকেন্ডের জানালায় এটি প্রথম বার্তা পাঠাচ্ছে, তাই সীমার ভেতরে। সবচেয়ে সস্তা পরীক্ষা আগে, database ছোঁয়ার আগেই।
  2. আকার যাচাই। Zod দেখল orderId একটি ধনাত্মক পূর্ণসংখ্যা, আর trim করা লেখা ১ থেকে ৫০০ অক্ষর। ভুল হলে এখানেই invalid_input.
  3. Room-এ আছে কি? Rahima আগে order:join করেছিলেন, আর সেখানে database মালিকানা মিলিয়েছিল। তাই এখানে আবার query লাগে না, socket.rooms দেখাই যথেষ্ট।
  4. আগে database. Row লেখা হলো, id "2". এখন server crash করলেও বার্তা হারাবে না, পরের join-এর ইতিহাসে আসবে।
  5. তারপর room. Room-এর সব socket পেল, পাঠানো socket-সহ। Sumon room-এ নেই, তাই তাঁর socket কিছুই পায়নি।
  6. Ack. Rahima-র client জানল বার্তা জমা হয়েছে, আর id পেল। এই id দিয়ে client নিজের দেখানো “পাঠানো হচ্ছে” বার্তাটি নিশ্চিত হিসেবে চিহ্নিত করে, আর একই বার্তা দুবার এলে বাদ দেয়।

ক্রম কেন “আগে database, পরে room”: উল্টো করলে Karim বার্তা দেখলেন, তারপর INSERT ব্যর্থ হলো, আর reload-এ বার্তাটি উধাও। আরও কড়া নিশ্চয়তা চাইলে, যেমন অবস্থা বদলের সাথে email বা অন্য service-কে খবর দেওয়া, পর্ব ৪৩-এর outbox ধাঁচ কাজে লাগে। Client-এর দিকে ack না এলে আবার পাঠানোর সময় একটি client-বানানো id দিন, আর database-এ সেটি unique রাখুন, যাতে একই বার্তা দুবার না বসে (পর্ব ৩৩-এর idempotency-র ধারণা)।

৬ · Heartbeat, মৃত সংযোগ আর reconnect

TCP সংযোগ নিজে থেকে জানায় না যে অন্য প্রান্ত হারিয়ে গেছে। ক্রেতা বাসে উঠলেন, signal হারাল, ফোন কোনো বিদায় বার্তাও পাঠাতে পারল না। Server-এর দিকে socket তখনও “খোলা”, memory ধরে আছে, room-এ আছে। একে বলে half-open সংযোগ. সমাধান heartbeat: নিয়মিত ping পাঠানো, আর নির্দিষ্ট সময়ে pong না এলে সংযোগ বন্ধ করা। Socket.IO 4-এ server প্রতি pingInterval (ডিফল্ট ২৫,০০০ ms) পরে ping পাঠায়, আর pingTimeout (ডিফল্ট ২০,০০০ ms)-এর মধ্যে উত্তর না পেলে সংযোগ বন্ধ করে।

// শেখার মডেল: simulated clock-এ heartbeat আর মৃত সংযোগ ধরা
const PING_INTERVAL = 25000, PING_TIMEOUT = 20000;  // Socket.IO-র ডিফল্ট (ms)
let now = 0;
const clients = [
  { name: 'rahima-wifi', lostAt: Infinity },  // সবসময় pong দেয়
  { name: 'karim-bus', lostAt: 40000 },        // ৪০ s-এ বাসে signal হারাল
  { name: 'sumon-lift', lostAt: 70000 },        // ৭০ s-এ লিফটে ঢুকল
];
for (const c of clients) { c.alive = true; c.waitingSince = null; }

function tick() {
  for (const c of clients) {
    if (!c.alive) continue;
    if (c.waitingSince !== null && now - c.waitingSince >= PING_TIMEOUT) {
      c.alive = false;
      console.log(`t=${now / 1000}s  ${c.name}: no pong for ${PING_TIMEOUT / 1000}s -> closed` +
        ` (${(now - c.lostAt) / 1000}s after it really died)`);
    }
  }
  if (now % PING_INTERVAL === 0) {
    for (const c of clients) {
      if (!c.alive || now < c.lostAt) continue;    // জীবিত: pong সাথে সাথে আসে
      if (c.waitingSince === null) c.waitingSince = now;  // ping গেল, উত্তর নেই
    }
  }
}
for (now = 0; now <= 150000; now += 1000) tick();
console.log('alive at 150s:', clients.filter((c) => c.alive).map((c) => c.name).join(', '));

আউটপুট: t=70s karim-bus: no pong for 20s -> closed (30s after it really died), t=95s sumon-lift: no pong for 20s -> closed (25s after it really died), আর alive at 150s: rahima-wifi. Karim ৪০ s-এ হারালেন, পরের ping গেল ৫০ s-এ, ২০ s অপেক্ষার পরে ৭০ s-এ বন্ধ। মৃত্যু থেকে ধরা পড়া পর্যন্ত সময় = পরের ping পর্যন্ত অপেক্ষা + pingTimeout. সবচেয়ে খারাপ ক্ষেত্রে ২৫ + ২০ = ৪৫ সেকেন্ড. ততক্ষণ বিক্রেতার পাতায় ক্রেতাকে “online” দেখাতে পারে। সময় ছোট করলে মৃত সংযোগ দ্রুত ধরা পড়ে, কিন্তু ping বাড়ে আর ধীর মোবাইল network-এ জীবিত সংযোগও ভুল করে কাটা যায়।

ws-এ heartbeat নিজে লিখতে হয়। প্রচলিত ধাঁচ হলো isAlive চিহ্ন: ping পাঠানোর আগে false, pong এলে true, আর পরের পালায় false পেলে terminate(). ল্যাবে ৩০০ ms-এর ছোট সময় দিয়ে দুটি client রাখলাম। একটির autoPong: false, মানে সে ping পেয়েও উত্তর দেয় না, ঠিক signal-হারানো ফোনের মতো (সত্যিকারের run, ws 8.22.1):

  615 ms server: no pong from zombie -> terminate
  616 ms server: closed zombie 1006
 1024 ms clients left: healthy

দুই পালা পরে zombie বাদ পড়ল, healthy থেকে গেল। 1006 মানে বিদায়ের frame ছাড়াই সংযোগ শেষ।

সংযোগ কাটলে: reconnect আর buffering

Socket.IO-র client নিজে থেকে আবার যুক্ত হয়। ডিফল্টে প্রথম চেষ্টা প্রায় ১ সেকেন্ড পরে, তারপর অপেক্ষা বাড়ে সর্বোচ্চ ৫ সেকেন্ড পর্যন্ত, সাথে কিছু এলোমেলো অংশ, যাতে server ফিরে এলে লাখো client একই মুহূর্তে না ঝাঁপিয়ে পড়ে (পর্ব ১৯-এর exponential backoff-এর ধারণা)। সংযোগ না থাকার সময় client যা emit করে, তা একটি buffer-এ জমা থাকে, ফিরে এলে পাঠায়।

কিন্তু server-এর দিকে সমস্যা আছে। নতুন সংযোগ মানে নতুন socket: room-এর সদস্যপদ নেই, আর অনুপস্থিত সময়ের খবরও নেই। Socket.IO 4.6 থেকে connection state recovery আছে, যা ডিফল্টে বন্ধ। চালু করলে server কিছুক্ষণ socket-এর id, room আর না-পৌঁছানো packet রেখে দেয়। ল্যাবে আমরা client আর server-এর মাঝে একটি TCP relay রেখে network “কেটে” দিলাম, দুইবার, একবার recovery ছাড়া, একবার সহ (সত্যিকারের run, Socket.IO 4.8.4, reconnect-এর দেরি ৩০০ ms করে দেওয়া):

=== connectionStateRecovery: off
  server: connection id=YXE2CI recovered=false rooms=[]
  client: got order:status confirmed
  server: disconnect transport close
  client: disconnect transport close
  client: sendBuffer has 2 packet(s) while offline
  client: reconnect_attempt 1
  server: connection id=Kxw8FD recovered=false rooms=[]
  server: got chat:send {"orderId":101,"text":"ঠিকানা বদলাতে চাই"}
  server: got typing
=== connectionStateRecovery: on (2 min)
  server: connection id=SfgL1Z recovered=false rooms=[]
  client: got order:status confirmed
  server: disconnect transport close
  client: disconnect transport close
  client: sendBuffer has 2 packet(s) while offline
  client: reconnect_attempt 1
  server: connection id=SfgL1Z recovered=true rooms=["order:101"]
  client: got order:status packed
  client: connect recovered=true
  server: got chat:send {"orderId":101,"text":"ঠিকানা বদলাতে চাই"}
  server: got typing
  client: got order:status shipped
  1. ClientsendBufferঅফলাইনে জমা
  2. Networktransport closeসংযোগ কাটা
  3. Serverrecoveredid আর room ফিরল
স্বাভাবিক অবস্থাid=SfgL1Z
client: join order:101 → ack
server → room: order:status confirmed
client: got order:status confirmed
Network কাটাদুই দিকেই transport close
server: disconnect transport close
client: disconnect transport close
server socket-এর অবস্থা ২ মিনিট রেখে দেয়
অফলাইনেদুই দিকেই খবর জমছে
server → room: order:status packed
  (client নেই, recovery-র জন্য রাখা)
client.emit('chat:send', …)
client.volatile.emit('typing')
sendBuffer: 2 packet
আবার চেষ্টাল্যাবে ৩০০ ms পরে
client: reconnect_attempt 1
handshake-এ পাঠায়: আগের id আর
শেষ পাওয়া packet-এর offset
Recoveryএকই id
server: connection id=SfgL1Z recovered=true
        rooms=["order:101"]
client: got order:status packed
client: connect recovered=true
Buffer খালি হলোতারপর আবার লাইভ
server: got chat:send {…"ঠিকানা বদলাতে চাই"}
server: got typing
client: got order:status shipped
  1. যুক্ত। Client অর্ডার ১০১-এর room-এ ঢুকল, আর “confirmed” খবর পেল। Server-এ socket-এর id SfgL1Z.
  2. Network কাটল। Relay ভেঙে দেওয়ায় দুই দিকই transport close পেল। Recovery চালু থাকায় server socket-এর অবস্থা আর বার্তা ২ মিনিটের জন্য রেখে দিল।
  3. অফলাইনের খবর। বিক্রেতা “packed” করলেন, client তখন নেই। Client নিজে একটি chat বার্তা আর একটি volatile “typing” পাঠাল, দুটিই buffer-এ জমা, মোট ২টি।
  4. আবার চেষ্টা। প্রথম চেষ্টাতেই সংযোগ হলো। Handshake-এ client জানাল তার আগের id আর শেষ কোন packet পেয়েছিল।
  5. Recovery. Server একই id SfgL1Z ফিরিয়ে দিল, room-এর সদস্যপদও। না-পৌঁছানো “packed” এখন পৌঁছাল। Recovery বন্ধ থাকলে নতুন id Kxw8FD, room খালি, আর “packed” চিরতরে হারানো।
  6. Buffer খালি। জমা থাকা দুটি packet server পেল, তারপর “shipped” লাইভ এল। কোনো খবর হারায়নি, কোনোটি দুবারও আসেনি।

দুটি বিষয় লক্ষ করুন। প্রথমত, recovery ছাড়া আবার যুক্ত হলে client-কে নিজেই আবার order:join পাঠাতে হবে, তাই connect event-এ join লেখা নিয়ম। দ্বিতীয়ত, volatile মানে পাঠানো না গেলে ফেলে দেওয়া, কিন্তু আমাদের run-এ “typing” ফেলে দেওয়া হয়নি। Client-এর কোড শুধু তখনই ফেলে, যখন পুরোনো transport-কে লেখার অযোগ্য দেখে, আর network কাটার প্রথম মুহূর্তে client তা জানত না। তাই volatile-কে একটি ইঙ্গিত ভাবুন, প্রতিশ্রুতি নয়। আর recovery-ও কোনো নিশ্চয়তা নয়: server restart হলে বা ২ মিনিট পেরোলে এটি কাজ করে না। আরও একটি ফাঁদ: Socket.IO-র docs-এর তালিকা অনুযায়ী অংশ ৭-এর Redis pub/sub adapter-এ recovery চলে না, Redis Streams adapter-এ চলে। একাধিক process-এ যাওয়ার সময় এটি মিলিয়ে নেবেন। আসল সত্য থাকে database-এ, তাই join-এর সময় ইতিহাস আনা বাদ দেবেন না।

৭ · ধীর client, memory আর একাধিক process

Backpressure: যে client পড়ে না

Server যত দ্রুত পাঠায়, একজন client যদি তত দ্রুত পড়তে না পারে, তবে বার্তা কোথাও জমে। প্রথমে জমে operating system-এর TCP buffer-এ, তারপর Node-এর memory-তে। ws-এ এই জমা অংশটি দেখা যায় bufferedAmount-এ। এটিই backpressure-এর সংকেত (পর্ব ২৪-এর stream-এ এটি দেখেছেন)। ল্যাবে server প্রতি ৫ ms-এ ৬৪ KB পাঠাল, আর client TCP থেকে পড়া থামিয়ে দিল (সত্যিকারের run, Windows-এ, ws 8.22.1):

 1433 ms server: sent 20, bufferedAmount 0
 1737 ms server: sent 40, bufferedAmount 0
 2040 ms server: bufferedAmount 1048736 > 1048576, sent 59 msgs -> close 1013
 2647 ms server: closed slow 1006

প্রথম ৪০টি বার্তায় bufferedAmount শূন্য, কারণ OS-এর buffer তখনো ভরেনি। ভরে যাওয়ার পর জমা হঠাৎ বাড়ল, আর ১ MB পেরোতেই আমাদের নিয়ম client-কে বিদায় দিল (1013, “পরে চেষ্টা করুন”)। Client কিছুই পড়ছিল না, তাই বিদায়ের frame-ও পৌঁছায়নি, শেষ পর্যন্ত 1006. এই নিয়ম না থাকলে একটি ধীর ফোন server-এর memory ধীরে ধীরে খেয়ে ফেলতে পারে। Chat-এ বার্তা ছোট, তাই এটি বিরল, কিন্তু লাইভ দাম বা অবস্থান পাঠানোর মতো ঘন খবরে খুব বাস্তব। সমাধানের পথ: সীমা পেরোলে বাদ দেওয়া, পুরোনো খবর ফেলে শুধু সর্বশেষ অবস্থা পাঠানো, বা client-কে বলা আবার যুক্ত হয়ে ইতিহাস আনতে। গভীর তত্ত্ব আছে Overload সামলানো পর্বে।

প্রতি সংযোগে কত memory?

একটি আলাদা server process-এ ২,০০০টি সংযোগ খুলে, garbage collection চালিয়ে, আগে-পরের memory মাপলাম। একই Node 24.15.0, একই Windows machine, এক run:

ws        N=2000  rss 71.0 MB -> 85.3 MB  heap 10.1 MB -> 15.5 MB  per conn: rss 7.3 KB, heap 2.8 KB
socket.io N=2000  rss 71.1 MB -> 119.8 MB  heap 10.1 MB -> 33.1 MB  per conn: rss 24.9 KB, heap 11.8 KB

এটি একটি local run, সাধারণ ফল নয়। OS-এর TCP buffer এখানে ধরা হয়নি, বার্তা চলাচল ছিল না, আর আপনার অ্যাপ socket.data-তে যা রাখবে, তা যোগ হবে। তবু একটি হিসাব করা যায়। ২০,০০০ সংযোগে Socket.IO-র জন্য ২০,০০০ × ২৪.৯ KB ≈ ৪৮৬ MiB, আর ws-এ ২০,০০০ × ৭.৩ KB ≈ ১৪৩ MiB. একটি ছোট VPS-এ এই পার্থক্য গুরুত্বপূর্ণ। সীমা ঠিক করার আগে নিজের অ্যাপে, নিজের বার্তা দিয়ে মাপুন। আর OS-এর খোলা file-এর সীমাও (Linux-এ ulimit -n) দেখুন, কারণ প্রতিটি সংযোগ একটি file descriptor নেয়।

দুটি process: Redis adapter

একটি Node process-এর JavaScript মূলত একটি CPU core-এ চলে। বেশি সংযোগ বা নিরবচ্ছিন্ন deploy-এর জন্য দুই বা তার বেশি process লাগে। তখন অংশ ৫-এর room-এর Map ভেঙে পড়ে: ক্রেতা আছেন node-A-তে, বিক্রেতা node-B-তে। Node-B জানেই না node-A-র room-এ কে আছে। সমাধান একটি adapter: প্রতিটি broadcast Redis-এর pub/sub দিয়ে সব process-এ পৌঁছায়, আর প্রতিটি process নিজের socket-গুলোকে দেয়। Redis চালানোর কথা পর্ব ৪৪-এ।

দুটি Socket.IO process, একটি Redis

Two Socket.IO processes share broadcasts through Redis pub/sub

Load balancer, দুটি Socket.IO process আর Redis adapter ক্রেতা বিক্রেতা Load balancer node-A node-B Redis order:101 দেখছেন "shipped" দিলেন polling থাকলে sticky room-এ ক্রেতা subscribe করে room-এ বিক্রেতা publish করে pub/sub channel ১ publish ২ পৌঁছে দেয়
ছবি ৬০.১ — প্রতিটি process শুধু নিজের socket চেনে। Redis শুধু খবরটি সবার কাছে ছড়িয়ে দেয়।

ল্যাবে দুটি আলাদা Node process (node-A আর node-B), একটি Redis 8.10.3 server, @socket.io/redis-adapter 8.3.0 আর redis client 6.3.1. ক্রেতা node-A-তে, বিক্রেতা node-B-তে, দুজনই order:101-এ। বিক্রেতা অবস্থা বদলালেন (সত্যিকারের run):

=== without adapter (in-memory, each process alone)
  customer joined on node-A
  seller joined on node-B
  sockets in room seen from B: 1
=== with @socket.io/redis-adapter
  customer joined on node-A
  seller joined on node-B
  customer (on A) got {"orderId":101,"status":"shipped","from":"node-B"}
  sockets in room seen from B: 2

Adapter ছাড়া node-B তার room-এ মাত্র ১টি socket দেখে, আর ক্রেতা কিছুই পান না। কোনো error নেই, শুধু নীরবতা, তাই এই ভুল production-এ দেরিতে ধরা পড়ে। Adapter-সহ খবর পৌঁছাল, আর fetchSockets() দুই process মিলিয়ে ২টি socket দেখল। মনে রাখবেন, Redis pub/sub একবার পাঠিয়ে ভুলে যায়: Redis এক মুহূর্তের জন্য না থাকলে সেই সময়ের broadcast হারায়। তাই এখানেও database-ই সত্যের উৎস।

Sticky session: load balancer-এর সমস্যা

Socket.IO ডিফল্টে প্রথমে HTTP long-polling দিয়ে শুরু করে, তারপর WebSocket-এ উন্নীত হয়। Polling মানে একই session-এর অনেকগুলো আলাদা HTTP অনুরোধ। Load balancer যদি পালাক্রমে পাঠায়, প্রথম অনুরোধ যায় node-A-তে, পরেরটি node-B-তে, আর node-B সেই session চেনে না। তাই দরকার sticky session: একই client-এর সব অনুরোধ একই process-এ। আমরা node:http দিয়ে একটি ছোট round-robin balancer বানিয়ে দুটি process-এর সামনে বসালাম (সত্যিকারের run, ছাঁটা):

  LB GET  sid=-      -> A 200
  LB POST sid=u9Bn5Y -> B 400
=== balancer, transports ["polling"]: connect_error: xhr post error (400)
…
  LB UPGRADE -> A
  connect event
=== balancer, transports ["websocket"]: joined on node-A

Polling-এ প্রথম GET node-A-তে session id পেল, পরের POST গেল node-B-তে, উত্তর 400, সংযোগ ব্যর্থ। শুধু WebSocket দিলে একটিই দীর্ঘ সংযোগ, তাই একবার যেখানে গেল সেখানেই থাকল, sticky লাগল না। Socket.IO-র docs-ও এটিই বলে: শুধু WebSocket ব্যবহার করলে sticky session আর লাগে না, তবে তখন যেসব network WebSocket আটকায়, সেখানে polling-এর বিকল্প থাকে না। Nginx-এ sticky করার সাধারণ পথ হলো client-এর IP ধরে hash (ip_hash বা hash $remote_addr consistent). বিস্তারিত Load balancing পর্বে। আর মনে রাখবেন, reverse proxy-কে Upgrade আর Connection header পাঠাতে বলতে হয়, নইলে handshake proxy-তেই আটকে যায়।

৮ · React-এ useSocket, আর Next.js-এর সাথে

React-এ socket একটি বাইরের ব্যবস্থার সাথে সংযোগ, তাই এর জায়গা useEffect, আর cleanup বাধ্যতামূলক (পর্ব ১৩)। একটি custom hook বানিয়ে রাখলে সব component একই নিয়মে চলে (পর্ব ১৪)।

import { useEffect, useState } from 'react';
import { io } from 'socket.io-client';

export function useSocket(orderId) {
  const [status, setStatus] = useState('…');
  const [online, setOnline] = useState(false);
  useEffect(() => {
    const socket = io(API_URL, { withCredentials: true, transports: ['websocket'] });
    const onConnect = () => { setOnline(true); socket.emit('order:join', orderId); };  // প্রতি connect-এ আবার join
    const onDisconnect = () => setOnline(false);
    const onStatus = (s) => setStatus(s.status);
    socket.on('connect', onConnect);
    socket.on('disconnect', onDisconnect);
    socket.on('order:status', onStatus);
    return () => {                                   // cleanup: listener সরানো আর সংযোগ বন্ধ
      socket.off('connect', onConnect);
      socket.off('disconnect', onDisconnect);
      socket.off('order:status', onStatus);
      socket.disconnect();
    };
  }, [orderId]);
  return { status, online };
}

Cleanup না থাকলে কী হয়? Development-এ StrictMode প্রতিটি effect একবার চালিয়ে, cleanup করে, আবার চালায়, যাতে এমন ভুল চোখে পড়ে। আমরা একটি আসল Vite dev server-এ (React 19.3.1, Vite 8.3.5, StrictMode চালু) দুটি রূপ Edge-এ চালালাম, Playwright দিয়ে। Server প্রতি ৪০০ ms-এ অর্ডার ১০১-এ একটি খবর পাঠায় (সত্যিকারের run):

=== page ?mode=bad
  [browser] effect: socket 1 for order 101
  [browser] effect: socket 2 for order 101
DOM: bad 101: packed | status events in 1.5 s: 8
=== page ?mode=good
  [browser] effect: socket 1 for order 101
  [browser] cleanup: socket 1 connected = false
  [browser] WebSocket connection to 'ws://127.0.0.1:38674/socket.io/?EIO=4&transport=websocket' failed: WebSocket is closed before the connection is established.
  [browser] effect: socket 2 for order 101
DOM: good 101: online packed | status events in 1.5 s: 4
  [browser] cleanup: socket 2 connected = true
  [browser] effect: socket 3 for order 102

আর server-এর log:

connect #1 (open now: 1)
  #1 join order:101
connect #2 (open now: 2)
  #2 join order:101
disconnect #1 transport close (open now: 1)
disconnect #2 transport close (open now: 0)
connect #3 (open now: 1)
  #3 join order:101
disconnect #3 client namespace disconnect (open now: 1)
connect #4 (open now: 1)
  #4 join order:102
  • Cleanup ছাড়া: দুটি সংযোগ খোলা রইল (#1, #2), দুটিই একই room-এ, আর একই সময়ে খবর এল দ্বিগুণ: ৮ বনাম ৪. Production-এ StrictMode-এর দ্বিতীয় চালানো নেই, কিন্তু একই ভুল route বদলালে বা orderId বদলালে ফিরে আসে, তখন প্রতিবার একটি করে সংযোগ জমে।
  • Cleanup-সহ: প্রথম socket সংযোগ হওয়ার আগেই বন্ধ হলো। Browser-এর console-এ “closed before the connection is established” বার্তাটি এই কারণেই। এটি dev-এর প্রত্যাশিত আচরণ, ভুল নয়। Server দেখল মাত্র একটি সংযোগ। orderId ১০২ হলে পুরোনো সংযোগ বন্ধ (client namespace disconnect), নতুনটি ১০২-এ।

অনেকে module-এর ওপরের স্তরে একটি মাত্র socket বানিয়ে পুরো অ্যাপে ভাগ করেন, আর component-এ শুধু listener যোগ-বিয়োগ করেন। এটিও ঠিক পথ, বিশেষত একাধিক component একই সংযোগ ব্যবহার করলে। তখনও cleanup-এ off করা বাধ্যতামূলক, নইলে listener জমে। আসা বার্তা TanStack Query-এর cache-এ বসাতে চাইলে queryClient.setQueryData ব্যবহার করুন, যাতে socket আর HTTP দুই উৎসের ডেটা এক জায়গায় থাকে (পর্ব ১৯)।

Next.js: socket কোথায় চলবে?

Next.js 16-এর route handler (পর্ব ৫৭) Web-এর Request আর Response নিয়ে কাজ করে, একটি অনুরোধ, একটি উত্তর। route.js-এর অফিসিয়াল reference-এ WebSocket upgrade-এর কোনো API নেই। Next.js দলের একটি খোলা RFC (GitHub discussion #95514, জুলাই ২০২৬) নিজেই লিখেছে যে App Router-এর route handler-এর এখন WebSocket সংযোগ নেওয়ার কোনো first-class পথ নেই, আর তা যোগ করার প্রস্তাব দিয়েছে। লেখার সময়ে সেটি খসড়া, তাই নিজের version-এ docs দেখে নেবেন। আর serverless বা edge-এ চলা deployment-এ function কিছুক্ষণ পরেই থামে, যা ঘণ্টার পর ঘণ্টা খোলা সংযোগের সাথে মেলে না।

তাই বাজারঘরের নকশা: Next.js যেমন চলছে চলুক, আর পাশে একটি আলাদা Node.js process-এ Socket.IO server, নিজের subdomain বা path-এ, একই session store ব্যবহার করে। আরেক পথ হলো Next.js-এর custom server, যেখানে নিজের http.createServer-এ upgrade ধরা যায়। কিন্তু Next.js-এর docs বলে custom server শুধু তখনই নেবেন, যখন built-in router যথেষ্ট নয়, আর এটি standalone output-এর সাথে একসাথে চলে না। কিছু দল একটি managed real-time service-ও ব্যবহার করে। কোনটি নেবেন, তা আপনার hosting-এর ওপর নির্ভর করে। শুধু server থেকে client-এ খবর দরকার হলে route handler-এ SSE চলে, সেটি পর্ব ৬১-এ।

৯ · নিরাপত্তা — origin, হার, আকার আর যাচাই

WebSocket-এর সংযোগ একবার খোলে, তারপর অনেক বার্তা চলে। HTTP-এর middleware প্রতিটি অনুরোধে চলে, কিন্তু socket-এর বার্তায় সেই middleware আর চলে না। তাই যে নিয়ম HTTP-তে আপনা থেকে পেতেন, socket-এ তা নিজে বসাতে হয়। তালিকাটি এক জায়গায়:

ঝুঁকিবাজারঘরে যা করেছিযাচাইয়ের run
অন্য সাইট থেকে browser-এর cookie নিয়ে সংযোগ (CSWSH)allowRequest-এ Origin-এর তালিকাevil → websocket error
পরিচয় ছাড়া সংযোগio.use-এ session যাচাইanon → unauthorized
অন্যের অর্ডারের roomJoin-এ database-এ মালিকানাSumon → forbidden
ভুল বা বিষাক্ত payloadপ্রতিটি event-এ Zod, parameterized SQLinvalid_input
Spam আর floodপ্রতি socket-এ হার সীমা৮টির মধ্যে ৫ ok
বড় বার্তায় memorymaxHttpBufferSize ১৬ KB২০ KB → সংযোগ বন্ধ
Ack ছাড়া event-এ crashtypeof ack !== 'function' যাচাইকোডে
Logout বা ban-এর পরেও খোলা সংযোগনিচের নোট দেখুন—
  • প্রতি socket-এর হার সীমা যথেষ্ট নয়। একজন দশটি ট্যাব খুললে দশগুণ। তাই user id ধরে Redis-এ হিসাব রাখা ভালো (পর্ব ৪৯-এর rate limit), আর IP ধরে সংযোগের সংখ্যাও সীমিত রাখুন।
  • Session শেষ মানে সংযোগ শেষ। সংযোগ খোলার সময় একবারই auth হয়। Logout হলে, বা ban হলে, সেই user-এর socket নিজে থেকে বন্ধ হয় না। প্রতিটি socket-কে user:<id> নামের room-এ রাখলে logout route থেকে io.in('user:7').disconnectSockets() ডেকে সব বন্ধ করা যায়। Token-এর ক্ষেত্রে মেয়াদ শেষে সংযোগ কাটার টাইমারও রাখুন।
  • বার্তা HTML নয়। Chat-এর লেখা React-এ {text} হিসেবে দেখান, dangerouslySetInnerHTML-এ কখনো নয়। আসা বার্তা অন্য মানুষের লেখা।
  • সবসময় wss://. TLS ছাড়া cookie আর chat দুটিই পথে পড়া যায়।

তত্ত্বের দিক আছে API নিরাপত্তা আর CSRF আর cookie সুরক্ষা পর্বে, আর পুরো অ্যাপের তালিকা পর্ব ৫০-এ।

আরও গভীরে: RFC 6455 শুধু HTTP/1.1-এর upgrade বর্ণনা করে। HTTP/2-এ একটি stream-এ WebSocket চালানোর নিয়ম এসেছে RFC 8441 (২০১৮)-এ, আর HTTP/3-এর জন্য RFC 9220 (২০২২)-এ, যাতে 101-এর বদলে extended CONNECT ব্যবহার হয়। Frame-এ mask কেন লাগে, তার কারণ ২০১০-১১ সালের গবেষণা: পুরোনো transparent proxy-কে ধোঁকা দিয়ে cache বিষিয়ে দেওয়ার একটি আক্রমণ দেখানো হয়েছিল, আর mask client-এর পাঠানো byte অনুমান-অযোগ্য করে সেই পথ বন্ধ করে। Socket.IO-র packet format আলাদা নথিতে আছে: Engine.IO protocol v4 আর Socket.IO protocol v5.

পরীক্ষা ও ইন্টারভিউতে:

  • WebSocket handshake-এর status code কত? 101 Switching Protocols, একটি GET-এ Upgrade: websocket-এর উত্তরে।
  • Sec-WebSocket-Accept কীভাবে হয়? Key + স্থির GUID, তারপর SHA-1, তারপর base64.
  • Socket.IO আর WebSocket কি এক? না। Socket.IO নিজস্ব protocol, যা WebSocket বা HTTP long-polling-এর ওপর চলে, তাই সাধারণ WebSocket client এর সাথে কথা বলতে পারে না।
  • দুই server-এ chat কেন ভাঙে? Room থাকে এক process-এর memory-তে। Redis-এর মতো adapter দিয়ে broadcast সব process-এ ছড়াতে হয়, আর polling থাকলে sticky session লাগে।
  • মৃত সংযোগ কীভাবে ধরবেন? Heartbeat: নিয়মিত ping, নির্দিষ্ট সময়ে pong না এলে বন্ধ। সবচেয়ে খারাপ দেরি ≈ ping interval + timeout.

১০ · AI-এর যুগে real-time কোড

“একটি Socket.IO chat বানাও” বললে AI কয়েক সেকেন্ডে চলতে থাকা কোড দেয়। Demo-তে সব ঠিক দেখায়, কারণ এক process, এক ব্যবহারকারী, একটি ট্যাব। ভুলগুলো লুকিয়ে থাকে সেখানে, যেখানে demo যায় না।

AI-এর সাধারণ ভুলকীভাবে ধরবেন
cors: { origin: '*' }, auth নেইCookie ছাড়া আর অন্য Origin থেকে সংযোগ করে দেখা
Client যে room চায়, server সেখানেই join করায়অন্যের অর্ডারের id দিয়ে join করে দেখা
বার্তা শুধু memory-র array-তেServer restart করে ইতিহাস দেখা
পুরোনো API, যেমন v2-এর io.set(...), বা নেই এমন optionnpm view socket.io version আর v4-এর docs
React-এ cleanup নেই, বা component-এর ভেতরে প্রতি render-এ io()Server-এর log-এ সংযোগ গোনা, StrictMode-এ
Redis adapter ছাড়া “scale” করাদুটি process চালিয়ে দুই দিক থেকে বার্তা পাঠানো
Next.js route handler-এ WebSocket server বানানোর চেষ্টানিজের Next.js version-এর docs পড়া

নিচের prompt AI-কে কোড লিখতে নয়, আপনার কোডের দুর্বলতা খুঁজতে বলে। আসল secret, cookie বা গ্রাহকের বার্তা কখনো পেস্ট করবেন না।

আমার Socket.IO 4.8 server আর React 19-এর useSocket hook নিচে দিলাম। Express 5, express-session, PostgreSQL. [এখানে কোড পেস্ট করুন। secret আর আসল ডেটা বাদ দিন] নতুন কোড লিখো না। প্রতিটি প্রশ্নের উত্তর দাও লাইন নম্বর ধরে: ১. সংযোগের সময় পরিচয় কোথায় যাচাই হয়? অন্য Origin থেকে browser-এর cookie নিয়ে সংযোগ কি সম্ভব? ২. একজন ব্যবহারকারী অন্যের অর্ডারের room-এ ঢুকতে পারে কি? কোন লাইন আটকায়? ৩. প্রতিটি event-এর payload কোথায় যাচাই হয়? আকার আর হারের সীমা কত? ৪. দুটি process চালালে room-এর broadcast কি সবার কাছে যাবে? load balancer-এ কী লাগবে? ৫. React-এ effect-এর cleanup কি সব listener সরায় আর সংযোগ বন্ধ করে? ৬. যা নিশ্চিত নও, সেটি "যাচাই দরকার" লিখে আলাদা করো। কোনো option বা package বানিয়ো না।

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

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

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

প্র ০১সব browser এখন WebSocket বোঝে। তবু ২০২৬ সালে Socket.IO-র মতো একটি স্তর কেন এত জনপ্রিয়?

WebSocket মান হওয়ার আগে, ২০০০-এর দশকের মাঝামাঝি, server থেকে browser-এ খবর পাঠানোর পথগুলো ছিল কৌশল: long polling, লুকানো iframe-এ শেষ না হওয়া উত্তর, বা Flash-এর socket. এগুলোকে একসাথে “Comet” বলা হতো। RFC 6455 আসে ২০১১ সালে, কিন্তু সব browser, proxy আর অফিসের firewall সেটি মেনে নিতে আরও কয়েক বছর লাগে। Socket.IO-র জন্ম সেই সময়ে, ২০১০ সালের দিকে, একটি সহজ প্রতিশ্রুতি নিয়ে: যে পথ চলে, সেটি দিয়েই চলবে।

আজ transport-এর সমস্যা অনেকটা মিটেছে, তবু Socket.IO টিকে আছে, কারণ এর আসল মূল্য transport নয়।

  • যা কাঁচা WebSocket দেয় না: reconnect আর backoff, অফলাইনে buffering, ack আর timeout, room, namespace, একাধিক process-এর adapter আর state recovery. এগুলো নিজে লিখলে কয়েকশো লাইন, আর সেগুলোর ভুল ধরা কঠিন।
  • Polling-এর বিকল্প: কিছু অফিস বা হোটেলের network এখনো WebSocket আটকায়। তখন polling-এ নেমে যাওয়া কাজে লাগে।
  • দাম: নিজস্ব protocol মানে client-ও Socket.IO-র হতে হবে, আর আমাদের এক run-এ প্রতি সংযোগে memory তিনগুণেরও বেশি, rss-এ ৭.৩ বনাম ২৪.৯ KB. Polling রাখলে sticky session-ও লাগে।

অন্যদিকে, browser-এ WebSocketStream-এর মতো নতুন API, আর WebTransport-এর মতো নতুন transport নিয়ে কাজ চলছে, আর অনেক বড় ব্যবস্থা নিজের পাতলা protocol বানায়। দুই পক্ষেরই যুক্তি আছে, আর এটি একটি সত্যিকারের trade-off.

মূল উপলব্ধি: একটি library-র মূল্য তার নামের প্রযুক্তিতে নয়, সে আপনার হয়ে কোন কঠিন সমস্যাগুলো সমাধান করে তাতে। প্রয়োজনের তালিকা আগে লিখুন, তারপর বাছাই করুন।

প্র ০২Client-এর frame-এ mask বাধ্যতামূলক, server-এর frame-এ নিষেধ। এই অদ্ভুত অসমতা কেন?

Mask কোনো গোপনীয়তা দেয় না। Key তো frame-এর ভেতরেই লেখা, যে কেউ খুলতে পারে, যেমন আমরা নিজেই খুললাম। তাহলে এটি কী রক্ষা করে? উত্তর আছে ২০১০-১১ সালে WebSocket মান তৈরির সময়ের নিরাপত্তা গবেষণায়।

তখন অনেক network-এ “transparent proxy” ছিল, যা ব্যবহারকারীর অজান্তে HTTP পড়ত আর cache করত। গবেষকেরা দেখালেন, কিছু proxy upgrade ঠিকমতো বোঝে না। আক্রমণকারীর পাতা যদি browser দিয়ে WebSocket-এ ইচ্ছামতো byte পাঠাতে পারে, তবে সেই byte দেখতে একটি সাধারণ HTTP অনুরোধের মতো বানানো সম্ভব। Proxy ভাবে এটি অন্য একটি সাইটের পাতার অনুরোধ, আর আক্রমণকারীর server-এর উত্তরকে সেই সাইটের নামে cache করে রাখে। তখন সেই network-এর অন্য ব্যবহারকারীরা আসল সাইটের বদলে বিষাক্ত script পান।

  • Mask-এর কাজ: প্রতিটি frame-এর জন্য browser একটি নতুন এলোমেলো key বেছে নেয়, আর পাতার JavaScript সেটি বেছে নিতে পারে না। তাই তারের ওপর কোন byte যাবে, আক্রমণকারী আগে থেকে ঠিক করতে পারে না।
  • Server-এর frame কেন নয়: Server-এর byte আক্রমণকারীর browser-এর script নিয়ন্ত্রণ করে না, তাই সেখানে ঝুঁকিটি নেই। আর mask খোলার খরচও বাঁচে।
  • বাস্তব ফল: ws mask ছাড়া client frame পেলে protocol error দিয়ে সংযোগ বন্ধ করে। নিজের parser লিখলে এই নিয়ম ভুলে যাওয়া সহজ।

এই ঘটনা একটি বড় শিক্ষা দেয়: নতুন protocol শুধু নিজের দুই প্রান্তে নিরাপদ হলেই হয় না, মাঝের পুরোনো, ভুলভাবে লেখা যন্ত্রগুলোর সাথেও নিরাপদ হতে হয়। HTTPS-এর ভেতরে, মানে wss://-এ, মাঝের proxy কিছু দেখতেই পায় না, তাই আজকের প্রায় সব WebSocket TLS-এর ভেতর দিয়ে চলে।

মূল উপলব্ধি: কিছু নিয়ম আপনার নিজের নিরাপত্তার জন্য নয়, পুরো Internet-এর জন্য। অদ্ভুত শোনালে তার ইতিহাস খুঁজুন।

প্র ০৩“আমার chat-এ প্রতিটি বার্তা ঠিক একবার পৌঁছাবে” — এই প্রতিশ্রুতি কি দেওয়া যায়?

Network-এর ওপর সরাসরি নয়। দুটি কম্পিউটারের মাঝে বার্তা হারাতে পারে, আর “পাঠালাম, উত্তর এল না” অবস্থায় পাঠানো প্রান্ত জানতে পারে না বার্তাটি পৌঁছেছিল কি না। তাই আবার পাঠাতে হয়, আর তাতে কখনো কখনো একই বার্তা দুবার যায়। এই দুটি বিকল্প আছে: “সর্বোচ্চ একবার” (হারাতে পারে) আর “অন্তত একবার” (দুবার আসতে পারে)।

বাস্তব ব্যবস্থাগুলো “ঠিক একবার”-এর অনুভূতি তৈরি করে তিনটি অংশ জুড়ে।

  • অন্তত একবার পাঠানো: Client ack-এর অপেক্ষা করে, না এলে timeout-এর পরে আবার পাঠায়। Socket.IO-র client-এ retries option এটি করে, অথবা আপনি নিজে লিখবেন।
  • Duplicate বাদ দেওয়া: প্রতিটি বার্তার সাথে client একটি id বানায়। Database-এ (sender_id, client_msg_id)-এর ওপর unique constraint থাকলে দ্বিতীয় INSERT ব্যর্থ হয়, আর server আগের row-এর id-ই ack-এ ফেরত দেয়। এটি পর্ব ৩৩-এর idempotency-র সেই ধারণা।
  • সত্যের উৎস database: লাইভ broadcast হারালেও join-এর সময় ইতিহাস, বা “শেষ যে id দেখেছি তার পরের সব” চেয়ে নিলে ফাঁক ভরে যায়।

আমাদের run-এ recovery চালু থাকায় network কাটার পরেও কোনো খবর হারায়নি। কিন্তু সেটি ছিল একটি process আর দুই মিনিটের জানালা। Server restart, Redis-এর এক মুহূর্তের বিরতি, বা ফোন সারারাত বন্ধ থাকলে recovery কাজ করে না। আর ক্রম নিয়েও সতর্ক থাকুন: দুটি process থেকে দুটি বার্তা Redis দিয়ে এলে ক্রম সবসময় নিশ্চিত নয়, তাই database-এর id বা একটি sequence ধরে সাজানো ভালো। বিস্তারিত নকশা আছে chat system-এর case study-তে।

মূল উপলব্ধি: “ঠিক একবার” network দেয় না, নকশা দেয়: আবার পাঠানো, id দিয়ে duplicate বাদ, আর database থেকে ফাঁক পূরণ।

প্র ০৪বাজারঘর বড় হলো। Redis adapter রেখে process বাড়াবেন, নাকি real-time-এর জন্য আলাদা একটি gateway বা managed service নেবেন?

দুটির কোনোটিই সবসময় ঠিক নয়। প্রশ্নটি আসলে কোন বোঝা কে বইবে।

  • Redis adapter আর কয়েকটি process: সবচেয়ে ছোট পরিবর্তন। আমাদের run-এ দুই লাইনের কোডে দুই process জুড়ে গেল। কিন্তু প্রতিটি broadcast প্রতিটি process-এ যায়, তাই process অনেক বাড়লে এবং room খুব বেশি হলে Redis-এর ওপর চাপ বাড়ে। Redis 7-এর sharded pub/sub-এর জন্য Socket.IO-র আলাদা adapter আছে, আর Redis Streams-ভিত্তিক adapter-ও আছে, যা Redis-এর ছোট বিরতিতে বার্তা কম হারায়। নিজের ক্ষেত্রে docs পড়ে, মেপে নেবেন।
  • আলাদা gateway: সংযোগ রাখার কাজ একটি আলাদা service করে, আর মূল API শুধু “এই user-কে এটি পাঠাও” বলে, queue বা pub/sub দিয়ে। Deploy আলাদা হয়, তাই API-র প্রতিটি deploy-এ লাখো সংযোগ কাটে না। দাম: আরও একটি service চালানো, monitoring আর auth দুই জায়গায়।
  • Managed service: বাইরের একটি প্রতিষ্ঠান সংযোগ রাখে। কাজ সবচেয়ে কম, কিন্তু খরচ সংযোগ বা বার্তা ধরে বাড়ে, ডেটা তৃতীয় পক্ষের কাছে যায়, আর সেবাটির নিয়মে বাঁধা পড়েন। দামের হিসাব নিজের অনুমানে, সেবাটির সরকারি পাতা দেখে করবেন।

একটি বাস্তব কথা: deploy-এর সময় process থামলে সব সংযোগ একসাথে কাটে, আর সবাই একসাথে আবার যুক্ত হতে চায়। একে reconnect storm বলে। Client-এর backoff আর এলোমেলো দেরি, আর server-এর ধীরে ধীরে process বদলানো এর ক্ষতি কমায়। পর্ব ৩১-এর graceful shutdown এখানেও কাজে লাগে: আগে নতুন সংযোগ নেওয়া বন্ধ, তারপর পুরোনোদের বিদায়।

একটি ছোট দলের জন্য যুক্তিসঙ্গত পথ হলো এক process দিয়ে শুরু, মাপা, তারপর adapter-সহ দুই-তিনটি process, আর সংযোগ সত্যিই বড় হলে gateway নিয়ে ভাবা। Capacity-র হিসাব আছে Real-time যোগাযোগ পর্বে।

মূল উপলব্ধি: Real-time-এর scale মানে সংযোগ ধরে রাখা, খবর ছড়ানো আর deploy-এর সময় ভেঙে না পড়া। কোন অংশ কোথায় যাবে, সেটি আপনার দলের চালানোর ক্ষমতা দিয়ে ঠিক করুন।

প্র ০৫Serverless platform আর Next.js-এর route handler কেন WebSocket-এর সাথে মেলে না, আর SSE কেন তুলনায় সহজে মেলে?

Serverless-এর মূল ধারণা হলো: একটি অনুরোধ এল, একটি function জাগল, উত্তর দিয়ে ঘুমিয়ে গেল। খরচ আর scale দুটিই এই ছোট জীবনের ওপর দাঁড়িয়ে। WebSocket এর উল্টো: একটি সংযোগ ঘণ্টার পর ঘণ্টা খোলা, আর যেকোনো মুহূর্তে যেকোনো দিক থেকে বার্তা আসতে পারে। Function-কে পুরো সময় জাগিয়ে রাখা দামি, আর অনেক platform একটি function-এর সময়ের সীমাও রাখে।

  • Next.js-এর route handler: Web-এর Request নিয়ে একটি Response ফেরায়। এতে upgrade-এর জায়গা নেই। Next.js দলের ২০২৬ সালের একটি খসড়া RFC route handler-এ upgrade যোগ করার প্রস্তাব দিয়েছে, যা দেখায় এখনো এটি built-in নয়। প্রস্তাবটি গৃহীত হলেও serverless deployment-এ দীর্ঘ সংযোগের সমস্যা থাকবে।
  • SSE কেন সহজ: SSE একটি সাধারণ HTTP উত্তর, যা শেষ হয় না, একটি stream. Route handler ReadableStream ফেরাতে পারে, তাই এটি স্বাভাবিকভাবে মেলে। তবে platform-এর সময়সীমা SSE-তেও প্রযোজ্য, তাই client-কে আবার যুক্ত হতে হয়। এটি পর্ব ৬১-এর বিষয়।
  • আলাদা Node server: একটি সাধারণ, সবসময় চলা process, যেমন VPS বা container-এ, WebSocket-এর জন্য সবচেয়ে সরল। Next.js যেখানে খুশি চলুক, socket server পাশে।

বাংলাদেশের অনেক ছোট প্রকল্প shared hosting-এ চলে, যেখানে দীর্ঘ সময় চলা Node process রাখা যায় কি না, তা host-এর নিয়মের ওপর নির্ভর করে। তাই নকশার আগে hosting-এ একটি ছোট পরীক্ষা চালিয়ে দেখুন সংযোগ কতক্ষণ টেকে। কোথায় চালাবেন, তার পুরো তুলনা আসবে পর্ব ৮২-এ।

মূল উপলব্ধি: প্রযুক্তি বাছাইয়ের আগে deployment-এর আকার দেখুন। “প্রতি অনুরোধে একটি function” আর “ঘণ্টাব্যাপী খোলা সংযোগ” দুটি ভিন্ন জগৎ।

অনুশীলন

আগে নিজে করুন, তারপর উত্তর মিলিয়ে নিন।

  1. হিসাব: বাজারঘরে একসাথে ৩০,০০০ জন অর্ডারের পাতা খোলা রাখেন। (ক) প্রতি ১০ সেকেন্ডে poll করলে প্রতি সেকেন্ডে কত অনুরোধ? (খ) Socket.IO-তে ডিফল্ট heartbeat-এ প্রতি সেকেন্ডে কতটি ping? (গ) প্রতি সংযোগে ২৫ KB ধরলে সব সংযোগে কত MiB? (ঘ) একটি সংযোগ মারা গেলে সবচেয়ে বেশি কত সেকেন্ড পরে server জানবে?

    (ক) ৩০,০০০ ÷ ১০ = ৩,০০০ অনুরোধ/সেকেন্ড. (খ) ৩০,০০০ ÷ ২৫ = ১,২০০ ping/সেকেন্ড, প্রতিটি কয়েক byte, তাই network-এর চেয়ে CPU আর টাইমারের খরচ বেশি। (গ) ৩০,০০০ × ২৫ = ৭,৫০,০০০ KB, ÷ ১,০২৪ ≈ ৭৩২ MiB. ২৫ KB আমাদের এক local run-এর কাছাকাছি সংখ্যা, নিজের অ্যাপে মেপে নেবেন। (ঘ) ping interval + ping timeout = ২৫ + ২০ = ৪৫ সেকেন্ড.

  2. ফল অনুমান করুন: অর্ডার ১০১-এর ক্রেতা Rahima, বিক্রেতা Karim. Rahima দুটি ট্যাবে (s1, s2) আর Karim একটিতে (s3) যুক্ত, আর তিনটিই order:101-এ join করেছে। Server একটি process. ক্রমে ঘটে: (১) s3 থেকে socket.to('order:101').emit('chat:new', A). (২) s2 বন্ধ। (৩) io.to('order:101').emit('order:status', 'packed'). (৪) অন্য ক্রেতা Sumon-এর s4 order:join-এ 101 পাঠায়। প্রতিটি socket কী কী পেল, আর শেষে room-এ কারা?

    (১) socket.to পাঠানো socket বাদ দেয়, তাই s1 আর s2 পেল A, s3 পেল না। (২) s2 room থেকে সরে গেল। (৩) io.to সবাইকে পাঠায়, তাই s1 আর s3 পেল “packed”. (৪) Sumon অর্ডারের মালিক বা বিক্রেতা নন, তাই ack { ok: false, error: 'forbidden' }. ফল: s1 = [A, packed], s2 = [A], s3 = [packed], s4 = []. শেষে room-এ s1 আর s3.

  3. কোড সমালোচনা: AI এই server দিল। অন্তত ৬টি সমস্যা বের করুন।
    const io = new Server(server, { cors: { origin: '*' } });
    const messages = [];
    io.on('connection', (socket) => {
      socket.on('join', (room) => socket.join(room));
      socket.on('msg', ({ room, text, user }) => {
        io.to(room).emit('msg', { user, text, html: marked(text) });
        messages.push({ room, user, text });
        socket.emit('saved');
      });
    });
    • origin: '*' আর Origin যাচাই নেই; সংযোগে কোনো auth নেই।
    • Client যে নাম দেয় সেই room-এ join, মালিকানা যাচাই নেই। যে কেউ order:101-এ ঢুকতে পারে।
    • user client থেকে আসে, তাই যে কেউ অন্যের নামে লিখতে পারে। পরিচয় আসবে socket.data থেকে।
    • Payload যাচাই নেই: text-এর ধরন, দৈর্ঘ্য, room-এর রূপ।
    • Markdown থেকে HTML বানিয়ে সবাইকে পাঠানো, sanitize ছাড়া: XSS-এর পথ।
    • বার্তা memory-র array-তে: restart-এ হারায়, আর সীমাহীন বাড়ে। দুই process-এ ভাগও হয় না।
    • আগে broadcast, পরে সংরক্ষণ; ack-এর বদলে আলাদা saved event, তাই কোন বার্তা জমা হলো বোঝা যায় না।
    • Rate limit আর আকারের সীমা নেই (ডিফল্ট maxHttpBufferSize ১ MB).
  4. নকশা: বাজারঘরে বিক্রেতার একটি “লাইভ অর্ডার বোর্ড” চাই: নতুন অর্ডার এলেই বিক্রেতার পাতায় দেখাবে। তিনটি Node process, একটি load balancer, PostgreSQL আর Redis আছে। Room-এর নাম, কে কোথায় join করবে, অর্ডার তৈরির route থেকে খবর কীভাবে যাবে, অফলাইন থাকা বিক্রেতা কী পাবেন, আর load balancer-এর নিয়ম লিখুন।

    একটি যুক্তিসঙ্গত উত্তর, একমাত্র উত্তর নয়:

    • Room: seller:<id>. সংযোগের middleware-এ session থেকে role আর id জেনে server নিজেই join করায়, client কোনো room-এর নাম দেয় না।
    • খবর পাঠানো: অর্ডার তৈরির transaction commit হওয়ার পরে io.to('seller:7').emit('order:new', {...}). HTTP route যে process-এ চলছে, বিক্রেতা সেখানে না-ও থাকতে পারেন, তাই Redis adapter লাগবে। আলাদা process থেকে পাঠাতে @socket.io/redis-emitter-এর মতো ব্যবস্থাও আছে। Commit-এর আগে পাঠালে rollback-এ ভুয়া অর্ডার দেখাবে; নিশ্চয়তা দরকার হলে outbox (পর্ব ৪৩)।
    • অফলাইন: বোর্ড খোলার সময় HTTP বা join-এর ack দিয়ে database থেকে খোলা অর্ডারের তালিকা, তারপর লাইভ খবর। Client শেষ দেখা অর্ডারের id রাখলে reconnect-এ “এর পরের সব” চাওয়া যায়।
    • Load balancer: শুধু WebSocket transport দিলে sticky লাগে না; polling রাখলে IP বা cookie ধরে sticky. Upgrade header পাঠানো, আর idle timeout heartbeat-এর চেয়ে বড় রাখা।

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

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

ক্রমSession cookie দিয়ে একটি Socket.IO chat-এ ক্রেতা নিজের অর্ডারের room-এ ঢোকা পর্যন্ত ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Browser cookie আর Origin-সহ Upgrade: websocket অনুরোধ পাঠায়
  2. allowRequest Origin-কে অনুমোদিত তালিকার সাথে মেলায়
  3. Session middleware cookie থেকে session পড়ে
  4. io.use দেখে session-এ userId আছে কি না
  5. Client order:join পাঠায়, server database-এ মালিকানা যাচাই করে
  6. socket.join, আর ack-এ বার্তার ইতিহাস ফেরত

WebSocket handshake সফল হলে server কোন HTTP status পাঠায়?

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

একটি client frame-এর প্রথম দুই byte 0x81 0x85. এর অর্থ কী?

  • শেষ টুকরো, binary, mask ছাড়া, দৈর্ঘ্য ১৩৩
  • শেষ টুকরো, text, mask আছে, দৈর্ঘ্য ৫
  • একটি close frame, code ৫
  • একটি ping, ৫ byte payload

একটি সাধারণ browser WebSocket client একটি Socket.IO 4 server-এর /socket.io/?EIO=4&transport=websocket-এ যুক্ত হয়ে সাদা লেখা hello পাঠাল। কী ঘটবে?

  • Server-এ hello নামের event চলবে
  • Handshake-ই হবে না, 404 আসবে
  • Server একটি ack ফেরত দেবে
  • একটি open packet আসবে, তারপর server সংযোগ কেটে দেবে

দুটি Socket.IO process, কোনো adapter নেই। ক্রেতা process A-তে, বিক্রেতা process B-তে, দুজনই order:101 room-এ। বিক্রেতার socket-এর handler io.to('order:101').emit(...) চালাল। কে খবর পাবেন?

  • শুধু বিক্রেতা
  • দুজনই
  • কেউই না
  • Server একটি error দেবে

Socket.IO-র pingInterval ২৫ সেকেন্ড আর pingTimeout ২০ সেকেন্ড। একটি ফোন ঠিক একটি pong পাঠানোর পরেই signal হারাল। Server সবচেয়ে দেরিতে কখন সংযোগটি বন্ধ করবে?

  • প্রায় ২০ সেকেন্ড পরে
  • প্রায় ৪৫ সেকেন্ড পরে
  • প্রায় ২৫ সেকেন্ড পরে
  • কখনো না, TCP নিজেই জানাবে

React 19-এ development mode আর StrictMode-এ একটি component useEffect-এর ভেতরে io(URL) ডাকে, কিন্তু কোনো cleanup ফেরত দেয় না। Server-এ কী দেখা যাবে?

  • একটি সংযোগ, স্বাভাবিক
  • দুটি চেষ্টা, কিন্তু Socket.IO দ্বিতীয়টি বাতিল করবে
  • দুটি সংযোগ খোলা থাকবে, আর প্রতিটি খবর দুবার আসবে
  • React একটি error দিয়ে render থামাবে

Socket.IO-র দুটি process-এর সামনে round-robin load balancer, client-এ ডিফল্ট transport (প্রথমে polling). Sticky session নেই। সবচেয়ে সম্ভাব্য ফল কী?

  • সব ঠিক চলবে, কারণ Redis adapter আছে
  • শুধু broadcast-এ দেরি হবে
  • Load balancer নিজে থেকে WebSocket-এ নিয়ে যাবে
  • পরের polling অনুরোধ অন্য process-এ গিয়ে 400 পাবে, সংযোগ ব্যর্থ হবে

শব্দকোষ

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

মনে রাখুন

  1. WebSocket শুরু হয় HTTP GET + Upgrade দিয়ে, উত্তর 101, তারপর একই TCP-তে frame. Client-এর frame-এ mask বাধ্যতামূলক। দুই দিকের ঘন কথা না হলে polling বা SSE সহজ।
  2. Socket.IO নিজস্ব protocol: event, ack, room, namespace, reconnect, buffering দেয়, কিন্তু client-ও Socket.IO হতে হয়।
  3. তিন দরজা: সংযোগে auth (cookie বা token, সাথে Origin), room-এ মালিকানা, আর প্রতিটি বার্তায় Zod, হার আর আকারের সীমা. বার্তা আগে database-এ, তারপর room-এ।
  4. Heartbeat মৃত সংযোগ ধরে (সবচেয়ে খারাপে interval + timeout). Recovery আর buffer সাহায্য করে, কিন্তু সত্যের উৎস database. ধীর client-কে bufferedAmount দেখে সীমা দিন।
  5. একাধিক process-এ Redis adapter, polling থাকলে sticky session. React-এ effect-এর cleanup, আর Next.js-এর পাশে আলাদা Node socket server.

পরের পর্বে: সব real-time-এর জন্য খোলা দুইমুখী সংযোগ লাগে না। পর্ব ৬১-এ polling, long polling আর SSE বানাব, আর দেখব AI-এর উত্তর কীভাবে শব্দে শব্দে stream হয়ে browser-এ আসে।

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

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