راهاندازی سرور چت؛ Upgrade، Origin و قرارداد پیام
در این درس ۸ بخش
در این درس سرور واقعی «گفتوگوی کوچک» را راه میاندازیم. یک فرایند Node.js هم صفحه و فایلهای ثابت را با HTTP ارائه میکند، هم درخواست پذیرفتنی /chat را به WebSocket ارتقا میدهد. این دو مسیر را از هم تشخیص میدهیم: گرفتن صفحه، عضویت در گفتوگو نیست و برقرارشدن اتصال نیز هنوز پذیرفتهشدن نام نمایشی نیست. خروجی مورد انتظار، یک صفحهٔ محلی است که دو تب میتوانند در آن با نام ساختگی پیام ردوبدل کنند؛ تاریخچه پس از توقف سرور باقی نمیماند.
ابتدا بستهٔ کامل و محیط اجرا را آماده کنید
بستهٔ کامل پروژهٔ همراه دوره را در پوشهای تازه استخراج کنید و ترمینال را در پوشهای باز کنید که package.json دارد. اگر فایلهای پروژهٔ قبلی همناماند، روی آنها استخراج نکنید. این پروژه علاوه بر app.mjs و server.mjs به protocol.mjs، room.mjs، connection.mjs، پوشهٔ public و فایل آزمون نیاز دارد. کد کامل سه فایل نخست این درس و ماژولهای دیگر در درسهای بعد آمده؛ همهٔ آنها از ابتدا در بستهٔ همراه موجودند. public/app.js در بخش کلاینت دورهٔ مکمل توضیح داده میشود.
دستورهای زیر را یکییکی در همان ترمینال اجرا کنید. node --version باید نسخهٔ ۲۴ یا بالاتر نشان دهد. npm ci از package-lock.json همراه استفاده میکند؛ فایل قفل را حذف نکنید و بهجای آن نسخهای تصادفی نصب نکنید. نصب وابستگی برای دریافت بسته به شبکه نیاز دارد. در صورت خطای شبکه، اول پیام npm و دسترسی رجیستری را بررسی کنید؛ خاموشکردن بررسی TLS یا نصب بستهٔ ناشناس راه اصلاح این درس نیست. npm run check نحو فایلها را بررسی میکند و npm test آزمونها را اجرا خواهد کرد.
node --version
npm --version
npm ci
npm run check
npm test
npm startخروجی شروع موفق باید Small chat: http://127.0.0.1:3001/ باشد. همین نشانی را باز کنید. اگر EADDRINUSE دیدید، درگاه توسط برنامهٔ دیگری اشغال است؛ همهٔ فرایندهای Node.js را نکشید. برنامهٔ شناختهشدهٔ خودتان را مرتب متوقف کنید یا برای این پروژه PORT دیگری تعیین کنید. پیشفرض Origin از همان درگاه ساخته میشود. در اجرای عادی فقط حلقهٔ محلی 127.0.0.1 گوش میدهد؛ بازدید از موبایل یا اینترنت به مراحل استقرار بعدی نیاز دارد و با تغییر نشانی مرورگر بهتنهایی حل نمیشود.
فایل package.json؛ قرارداد اجرای پروژه
محتوای کامل package.json این نسخه را در ادامه میبینید. type برابر module یعنی import را در ماژولها به کار میبریم. وابستگی ws با نسخهٔ دقیق 8.22.0 ثبت شده و فایل قفل همان دریافت را همراهی میکند. start سرور را اجرا میکند؛ dev در صورت وجود، فایل محلی .env را نیز میخواند. وجود .env برای اجرای پیشفرض لازم نیست. private جلوی انتشار ناخواستهٔ خود این بسته در رجیستری را میگیرد و معنی خصوصیبودن پیامهای چت را ندارد.
{
"name": "darsavand-small-chat",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": { "node": ">=24" },
"scripts": {
"start": "node server.mjs",
"dev": "node --env-file-if-exists=.env server.mjs",
"test": "node --test",
"check": "node --check app.mjs && node --check server.mjs && node --check protocol.mjs && node --check room.mjs && node --check connection.mjs && node --check public/app.js && node --check app.test.mjs"
},
"dependencies": { "ws": "8.22.0" }
}فایل app.mjs؛ کارخانهٔ سرور و مرز ارتقا
کارخانه یا factory در اینجا تابعی است که اجزای سرور را میسازد و به فراخواننده تحویل میدهد. createChatServer هنوز روی درگاه گوش نمیدهد؛ این جداسازی کمک میکند آزمونها نمونهٔ مستقل بسازند و در پایان آن را ببندند. تابع ناهمزمان است چون فایلهای کوچک public را هنگام شروع میخواند. نتیجه شامل server، wss، close و getStats است. #modules-import-export وابستگیهای فایل را روشن میکند؛ هیچ تابعی از فضای نام نامشخص یا فایل پنهان فرض نشده است.
در بخش HTTP فقط سه فایل شناختهشده و /api/status ارائه میشوند. برنامه مسیر دلخواه کاربر را به نام فایل سیستم تبدیل نمیکند. GET و HEAD برای مسیرهای شناختهشده مجازند، روش دیگر 405 میگیرد و مسیر ناشناخته 404. پاسخ وضعیت شامل online برای اعضای واردشده و historySize برای تعداد پیامهای حافظه است. این API فهرست رمزها یا متن تمام درخواستها را در گزارش چاپ نمیکند. سرآیندهای محتوا و سیاست منبع نیز همراه پاسخ تنظیم شدهاند؛ آنها جای بررسی قرارداد پیام را نمیگیرند.
کد کامل app.mjs را بهعنوان نقشهٔ جریان بخوانید. نخست ساخت منابع، سپس HTTP، بعد upgrade و connection و در پایان close آمده است. ماژول protocol شکل ورودی را بررسی میکند، room پیام پذیرفتهشده میسازد و connection کنترل ارسال و heartbeat را انجام میدهد. پیادهسازی کامل آنها در دو درس بعد باز میشود. برای اجرای همین نسخه، از فایلهای همنسخهٔ بسته استفاده کنید و ماژول خالی با نام مشابه نسازید.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { WebSocketServer, WebSocket } from 'ws';
import { LIMITS, ProtocolError, parsePacket, errorPacket } from './protocol.mjs';
import { createRoom } from './room.mjs';
import { sendBounded, heartbeat } from './connection.mjs';
function originValue(value) {
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol) || url.origin !== value) {
throw new TypeError('PUBLIC_ORIGIN must be one exact http(s) origin without a path');
}
return value;
}
export async function createChatServer({
publicOrigin = 'http://127.0.0.1:3001',
heartbeatMs = LIMITS.heartbeatMs, joinTimeoutMs = LIMITS.joinDeadlineMs
} = {}) {
const allowedOrigin = originValue(publicOrigin);
if (!Number.isSafeInteger(heartbeatMs) || heartbeatMs < 20 ||
!Number.isSafeInteger(joinTimeoutMs) || joinTimeoutMs < 20) throw new RangeError('Invalid timing');
const assets = new Map();
for (const [path, file, type] of [
['/', 'index.html', 'text/html; charset=utf-8'],
['/style.css', 'style.css', 'text/css; charset=utf-8'],
['/app.js', 'app.js', 'text/javascript; charset=utf-8']
]) assets.set(path, { body: await readFile(new URL(`./public/${file}`, import.meta.url)), type });
const room = createRoom();
const members = new Map();
let shuttingDown = false;
let closePromise;
const online = () => [...members].filter(([socket, member]) => member.joined && socket.readyState === WebSocket.OPEN).length;
const getStats = () => ({ ok: true, service: 'darsavand-chat', online: online(), historySize: room.size });
const wsOrigin = allowedOrigin.replace(/^http/, 'ws');
const headers = {
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'same-origin',
'Content-Security-Policy': `default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self' ${wsOrigin}; object-src 'none'; base-uri 'none'; frame-ancestors 'none'`
};
const server = createServer({ requestTimeout: 15000, headersTimeout: 10000, maxHeaderSize: 8192 }, (req, res) => {
let path;
try { path = new URL(req.url, 'http://127.0.0.1').pathname; }
catch { res.writeHead(400); return res.end(); }
const known = assets.has(path) || path === '/api/status';
let status = known ? 200 : 404;
let type = 'application/json; charset=utf-8';
let body = Buffer.from(JSON.stringify(known ? getStats() : { error: 'NOT_FOUND' }));
const extra = {};
if (known && !['GET', 'HEAD'].includes(req.method)) {
status = 405; extra.Allow = 'GET, HEAD';
body = Buffer.from(JSON.stringify({ error: 'METHOD_NOT_ALLOWED' }));
} else if (known && assets.has(path)) {
({ body, type } = assets.get(path));
}
req.resume();
res.writeHead(status, { ...headers, ...extra, 'Content-Type': type, 'Content-Length': body.length });
res.end(req.method === 'HEAD' ? undefined : body);
});
const wss = new WebSocketServer({ noServer: true, maxPayload: LIMITS.maxPayloadBytes, perMessageDeflate: false });
function broadcast(packet) {
for (const [socket, member] of members) if (member.joined) sendBounded(socket, packet);
}
function presence() { broadcast({ type: 'presence', online: online() }); }
server.on('upgrade', (req, socket, head) => {
socket.on('error', () => {});
let path;
try { path = new URL(req.url, 'http://127.0.0.1').pathname; }
catch { path = ''; }
const status = shuttingDown || wss.clients.size >= LIMITS.maxClients ? 503
: path !== '/chat' ? 404 : req.headers.origin !== allowedOrigin ? 403 : 0;
if (status) {
socket.end(`HTTP/1.1 ${status} Rejected\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
return;
}
wss.handleUpgrade(req, socket, head, ws => wss.emit('connection', ws));
});
wss.on('connection', socket => {
socket.isAlive = true;
const member = { id: randomUUID(), joined: false, name: '', count: 0, windowStart: performance.now(), accepted: new Map() };
members.set(socket, member);
const joinTimer = setTimeout(() => socket.close(1008, 'Join timeout'), joinTimeoutMs);
joinTimer.unref();
member.joinTimer = joinTimer;
socket.on('error', () => {});
socket.on('pong', () => { socket.isAlive = true; });
socket.on('message', (data, isBinary) => {
if (shuttingDown || socket.readyState !== WebSocket.OPEN) return;
const now = performance.now();
if (now - member.windowStart >= LIMITS.windowMs) { member.count = 0; member.windowStart = now; }
if (++member.count > LIMITS.packetsPerWindow) {
sendBounded(socket, errorPacket('RATE_LIMIT'));
return socket.close(1008, 'Rate limit');
}
if (isBinary) {
sendBounded(socket, errorPacket('TEXT_ONLY'));
return socket.close(1003, 'Text only');
}
let packet;
try {
packet = parsePacket(data);
if (packet.type === 'join') {
if (member.joined) throw new ProtocolError('ALREADY_JOINED');
member.name = packet.name; member.joined = true; clearTimeout(joinTimer);
sendBounded(socket, { type: 'history', clientId: member.id, ...room.snapshot() });
return presence();
}
if (!member.joined) throw new ProtocolError('JOIN_REQUIRED');
const previous = member.accepted.get(packet.clientMessageId);
if (previous) {
if (previous.text !== packet.text) throw new ProtocolError('DUPLICATE_ID');
return sendBounded(socket, { type: 'ack', clientMessageId: packet.clientMessageId, messageId: previous.id, seq: previous.seq });
}
const message = room.accept(member.name, packet.text);
member.accepted.set(packet.clientMessageId, message);
if (member.accepted.size > LIMITS.historyMessages) member.accepted.delete(member.accepted.keys().next().value);
broadcast({ type: 'message', message });
sendBounded(socket, { type: 'ack', clientMessageId: packet.clientMessageId, messageId: message.id, seq: message.seq });
} catch (error) {
if (error instanceof ProtocolError) sendBounded(socket, errorPacket(error.code, packet?.clientMessageId));
else socket.close(1011, 'Internal error');
}
});
socket.on('close', () => { clearTimeout(joinTimer); members.delete(socket); if (!shuttingDown) presence(); });
});
const heartbeatTimer = setInterval(() => heartbeat(wss), heartbeatMs);
heartbeatTimer.unref();
async function close() {
if (closePromise) return closePromise;
shuttingDown = true; clearInterval(heartbeatTimer);
for (const [socket, member] of members) { clearTimeout(member.joinTimer); socket.close(1001, 'Server shutdown'); }
const deadline = setTimeout(() => { for (const socket of wss.clients) socket.terminate(); }, 3000);
deadline.unref();
closePromise = Promise.all([
new Promise(resolve => wss.close(resolve)),
new Promise(resolve => { server.close(() => resolve()); server.closeIdleConnections(); })
]).finally(() => { clearTimeout(deadline); members.clear(); });
return closePromise;
}
return { server, wss, close, getStats };
}Upgrade و Origin را پیش از پذیرفتن اتصال بررسی کنید
noServer:true یعنی ws خودش درگاه جدا باز نمیکند؛ سرور HTTP درخواست ارتقا را به آن تحویل میدهد. در upgrade، مسیر /chat، ظرفیت و Origin پیش از handleUpgrade بررسی میشوند. مسیر نامربوط 404، Origin نامعتبر یا مفقود 403 و ظرفیت پر یا توقف سرور 503 میگیرد. پس از پذیرش، کتابخانه جزئیات پروتکل را مدیریت میکند. head نیز بخشی از دادهٔ دریافتشده است که به handleUpgrade سپرده میشود؛ آن را در پیادهسازی دستی کنار نگذارید.
PUBLIC_ORIGIN باید یک مبدأ کامل HTTP یا HTTPS بدون مسیر، اسلش انتهایی اضافه یا ستاره باشد؛ نمونهٔ محلی دقیق، http://127.0.0.1:3001 است. مقدار wss://... برای این متغیر درست نیست، چون مبدأ صفحه را تنظیم میکنیم. اگر از localhost بهجای 127.0.0.1 استفاده کنید، بررسی دقیق آن را متفاوت میبیند. این مرز برای کنترل صفحهٔ مرورگر است و احراز هویت نیست. #validation-contracts به همین دلیل درون اتصال هم ادامه دارد؛ کلاینت غیرمرورگری میتواند Origin دلخواه بفرستد.
قرارداد پیام؛ ابتدا join، سپس chat
عضو تازه ده ثانیه برای join معتبر فرصت دارد. نام پس از trim و NFC، بین یک تا ۲۴ نقطهکد و حداکثر ۹۶ بایت UTF-8 است. این نام صرفاً برچسب نمایشی است. پاسخ history ابتدا snapshot تاریخچه، serverId، clientId و latestSeq را میفرستد؛ بعد حضور اعلام میشود. پیام chat پیش از عضویت، JOIN_REQUIRED میگیرد. این محدودیتها در سرور اعمال میشوند، حتی اگر یک صفحهٔ دستکاریشده بررسیهای فرم را حذف کرده باشد.
پس از عضویت، chat باید clientMessageId معتبر UUID و متن داشته باشد. متن عادیشده بین یک تا پانصد نقطهکد و حداکثر دو هزار بایت است؛ کل پیام WebSocket نیز حداکثر ۴۰۹۶ بایت، شامل مجموع تکههای پیام چندفریمی است. کلید اضافی پذیرفته نمیشود. #json قالب سریالسازی است و #throw-error مسیر رد قرارداد را در ماژول protocol نشان میدهد. خطاهای قابل پیشبینی به پاسخ کنترلشده تبدیل میشوند؛ جزئیات خطای داخلی را برای کاربر شبکه افشا نمیکنیم.
پس از پذیرش، سرور id، seq و at را میسازد و message را پخش میکند؛ ack درخواست را به همان پیام مرتبط میکند. پنجاه شناسهٔ پذیرفتهشدهٔ اخیر در همان اتصال نگهداری میشوند: تکرار شناسه با متن یکسان، ack قبلی میگیرد؛ متن متفاوت با همان شناسه، DUPLICATE_ID است. این رفتار تضمین حذف تکرار پس از اتصال دوباره یا خروج شناسه از حافظه نیست. نرخ تمام بستهها، حتی join و ورودی خراب، شمرده میشود؛ بستهٔ یازدهم در ده ثانیه محدودیت را فعال میکند.
فایل server.mjs؛ درگاه، خطا و توقف
کد کامل ورودی برنامه در ادامه است. PORT فقط عدد صحیح یک تا ۶۵۵۳۵ پذیرفته میشود. فایل app را میسازد، خطای گوشدادن را گزارش میکند و SIGINT یا SIGTERM را به close وصل میکند. Ctrl+C در ترمینال اجرای محلی همان توقف مرتب را آغاز میکند. در مسیر close، پذیرش تازه متوقف، تایمرها پاک و اتصالها برای پایان بسته میشوند؛ پس از مهلت مشخص، اتصال باقیمانده قطع میشود. پایان مرتب نیز تضمین تحویل آخرین پیام به همهٔ کاربران نیست.
import { createChatServer } from './app.mjs';
const raw = process.env.PORT ?? '3001';
if (!/^\d{1,5}$/.test(raw) || Number(raw) < 1 || Number(raw) > 65535) {
console.error('PORT must be an integer from 1 to 65535');
process.exitCode = 1;
} else {
try {
const port = Number(raw);
const app = await createChatServer({ publicOrigin: process.env.PUBLIC_ORIGIN ?? `http://127.0.0.1:${port}` });
app.server.on('error', error => {
console.error(`Cannot listen: ${error.code ?? 'UNKNOWN'}`);
process.exitCode = 1;
void app.close();
});
app.server.listen(port, '127.0.0.1', () => console.log(`Small chat: http://127.0.0.1:${port}/`));
const stop = () => { void app.close(); };
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
} catch {
console.error('Cannot start: check PUBLIC_ORIGIN, dependencies and public files.');
process.exitCode = 1;
}
}بررسی مشاهدهپذیر و تمرین با پاسخ
پس از npm start، در یک تب /api/status را باز کنید و online صفر را پیش از join ببینید. دو تب صفحه باز کنید، نامهای ساختگی بدهید و پیام فارسی بفرستید؛ online باید تعداد عضوهای واردشده را نشان دهد. تب صرفاً بازشده بدون عضویت عضو آنلاین محسوب نمیشود. اگر فایل public کم باشد، شروع برنامه شکست میخورد و باید بستهٔ کامل را بررسی کنید. در #debugging-console اول پیام همان ترمینالی را بخوانید که فرایند در آن اجرا شده است.
تمرین: تفاوت سه شکست را توضیح دهید: Origin اشتباه، chat پیش از join و قطعشدن ارتباط پیش از ack. پاسخ راهنما به ترتیب خطای HTTP پیش از ارتقا، error کاربردی JOIN_REQUIRED و نتیجهٔ احتمالاً نامعلوم ارسال است. سپس سرور را متوقف و دوباره اجرا کنید؛ تاریخچه باید خالی و serverId تازه باشد. معیار تحویل، اجرای دو تب، فهم مرز اعتماد و توانایی دنبالکردن مسیر درخواست در کد است. دوام داده و هویت واقعی جزو قابلیتهای این نمونه نیستند.
برای مرور پیشنیازها، درس سرور HTTP قبلی و درس متغیر محیطی را ببینید. اینجا روی تغییر پروتکل و قرارداد اتصال تمرکز کردیم؛ نصب مقدماتی Node.js یا صدور گواهی را دوباره انجام نمیدهیم.
سرور و API قبلی: https://www.darsavand.com/courses/nodejs-http-server-page-api-tls?lesson=7a83fd1a-3584-409c-8eb4-dd51e21144ce
متغیرهای محیطی: https://www.darsavand.com/courses/nodejs-windows-backend-start?lesson=e5ebc43b-785b-432e-bd25-b5e825d5364c
منابع رسمی و مطالعهٔ تکمیلی
ws، مستندات API: https://github.com/websockets/ws/blob/master/doc/ws.md
ws، نمونهٔ Upgrade: https://github.com/websockets/ws
Node.js HTTP و رویداد upgrade: https://nodejs.org/api/http.html#event-upgrade_1
npm ci و فایل قفل: https://docs.npmjs.com/cli/v11/commands/npm-ci
RFC 6455، Origin: https://www.rfc-editor.org/rfc/rfc6455
تألیف و مثالهای اختصاصی: تحریریه درسآوند.
تألیف و مثالهای اختصاصی: تحریریه درسآوند. فایلهای کامل پروژه و راهنمای اجرا و دیباگ:
https://www.darsavand.com/resources/simple-websocket-chat-project
متن درسهای عمومی را بدون ورود بخوانید.