کلاینت WebSocket؛ پیام امن، اتصال دوباره و آزمون دستی
در این درس ۵ بخش
اکنون قطعههای قبلی را در یک صفحهٔ واقعی کنار هم میگذاریم: نام آزمایشی مینویسید، وارد اتاق میشوید و در دو تب پیام ردوبدل میکنید. مرورگر از WebSocket داخلی خودش استفاده میکند؛ نصب کتابخانهٔ سرور در مرورگر لازم نیست. پروژهٔ «گفتوگوی کوچک» یک اتاق آموزشی بدون حساب و ذخیرهٔ دائمی است. این درس سمت کلاینت را توضیح میدهد؛ کد سرور در دورهٔ مکمل Node.js آمده و همهٔ فایلها یکجا همراه پروژه هستند. هدف فقط دیدن حباب پیام نیست؛ اتصال، انتظار تأیید، قطع ارتباط و متنِ باقیمانده باید هرکدام رفتار قابل توضیحی داشته باشند.
پروژهٔ کامل را آماده و اجرا کنید
از صفحهٔ زیر، فایل simple-websocket-chat-project.zip را دریافت و استخراج کنید. وارد پوشهای شوید که package.json، package-lock.json و پوشهٔ public در آن دیده میشوند؛ گاهی ابزار استخراج یک پوشهٔ بیرونی هم میسازد. نام مسیر رایانهٔ شما لازم نیست مانند مسیر نویسنده باشد. این بسته شامل HTML، CSS، آزمونها و تمام فایلهای سرور است؛ آنها را از روی حدس نسازید. کد کامل public/app.js در همین درس نیز آمده است. صفحهٔ پروژه و راهنمای دریافت: https://www.darsavand.com/resources/simple-websocket-chat-project
در ترمینال همان پوشه، ابتدا node --version را اجرا کنید؛ نسخه باید ۲۴ یا بالاتر باشد. npm ci وابستگی قفلشده را از package-lock.json نصب میکند و اینترنت لازم دارد؛ اگر قفل با package.json ناسازگار باشد، توقف آن مفید است و نباید بیدلیل فایل قفل را حذف کنید. برای اجرای محلی، npm start از مقدار پیشفرض 127.0.0.1:3001 استفاده میکند. اگر متغیرهای محیطی قبلی در ترمینال دارید، آنها را با راهنمای README بررسی کنید؛ تغییر نشانی مرورگر بدون تغییر PUBLIC_ORIGIN میتواند اتصال را رد کند. دستورهای زیر برای Bash هستند:
node --version
npm ci
npm run check
npm test
npm startدر Windows PowerShell از نسخهٔ cmd دستور npm استفاده کنید تا محدودیت اجرای اسکریپت PowerShell بیجهت تغییر نکند. فرمانهای زیر را در همان پوشه اجرا کنید. اگر npm.cmd پیدا نمیشود، نصب و PATH محیط Node.js را از دورهٔ پیشنیاز بررسی و ترمینال را دوباره باز کنید. check باید بدون خطای نحوی تمام شود و test خلاصهٔ آزمونهای موفق را نشان دهد؛ عدد آزمونها به نسخهٔ بسته وابسته است. پس از start، ترمینال را باز نگه دارید و برای پایان سرور Ctrl+C بزنید. خطای EADDRINUSE یعنی درگاه اشغال است؛ اجرای قبلی خودتان را پیدا و متوقف کنید.
node --version
npm.cmd ci
npm.cmd run check
npm.cmd test
npm.cmd startنشانی http://127.0.0.1:3001 را در مرورگر باز کنید. فایل index.html را با دوبارکلیک و file:// اجرا نکنید؛ آن روش سرور و مبدأ HTTP لازم را ندارد. برای آزمایش، دقیقاً همین نشانی را در دو تب باز کنید. جابهجایی بیحساب بین localhost و 127.0.0.1 میتواند Origin را عوض کند. پروژه نشانی اتصال را از همان صفحه میسازد و مسیر /chat را به کار میبرد. در انتشار HTTPS، اتصال باید wss باشد و پراکسی درست تنظیم شود؛ مرورگر را با خاموشکردن محدودیت امنیتی مجبور به اتصال ناامن نکنید.
فایل کامل کلاینت و نقشهٔ خواندن آن
اگر میخواهید فایل را با نمونهٔ درس مقایسه کنید، public/app.js را باز کنید؛ محتوای کامل نسخهٔ همراه در باکس بعد است. این کد برای مرورگر و HTML همراه نوشته شده، نه اجرای مستقیم با node app.js. شناسههای join-form، nickname، messages و message-form در public/index.html وجود دارند و style.css ظاهر همان اجزا را میسازد. برای درک کد، اول مسیر ورود تا history، سپس ارسال تا ack و در پایان close و پاکسازی را دنبال کنید. همهٔ تابعها را همزمان تغییر ندهید؛ هر تغییر باید یک رفتار قابل مشاهده داشته باشد.
const joinForm = document.querySelector('#join-form');
const nickname = document.querySelector('#nickname');
const connectButton = document.querySelector('#connect-button');
const disconnectButton = document.querySelector('#disconnect-button');
const connectionStatus = document.querySelector('#connection-status');
const connectionDot = document.querySelector('#connection-dot');
const onlineCount = document.querySelector('#online-count');
const list = document.querySelector('#messages');
const messageForm = document.querySelector('#message-form');
const textInput = document.querySelector('#message-text');
const textCount = document.querySelector('#text-count');
const sendButton = document.querySelector('#send-button');
const sendStatus = document.querySelector('#send-status');
let socket = null;
let ready = false;
let manualStop = true;
let displayName = '';
let retries = 0;
let reconnectTimer = null;
let historyTimer = null;
let serverId = '';
let latestSeq = 0;
let pending = null;
const seen = new Set();
const unknown = new Set();
const encoder = new TextEncoder();
const timeFormat = new Intl.DateTimeFormat('fa-IR', { hour: '2-digit', minute: '2-digit' });
const numberFormat = new Intl.NumberFormat('fa-IR');
function createRequestId() {
const source = globalThis.crypto;
if (typeof source?.randomUUID === 'function') return source.randomUUID();
if (typeof source?.getRandomValues !== 'function') throw new Error('Secure random unavailable');
const bytes = new Uint8Array(16);
source.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = [...bytes].map(value => value.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
function setStatus(text, state = '') {
connectionStatus.textContent = text;
connectionDot.className = 'dot' + (state ? ' ' + state : '');
}
function notice(text, error = false) {
sendStatus.textContent = text;
sendStatus.classList.toggle('error', error);
}
function updateControls() {
nickname.disabled = !manualStop;
connectButton.disabled = !manualStop;
disconnectButton.disabled = manualStop;
textInput.disabled = !ready;
sendButton.disabled = !ready || pending !== null;
}
function unknownPending() {
if (!pending) return;
clearTimeout(pending.timer);
unknown.add(pending.id);
if (unknown.size > 10) unknown.delete(unknown.values().next().value);
pending = null;
notice('نتیجهٔ ارسال نامعلوم است؛ متن حفظ شد و خودکار دوباره فرستاده نمیشود. ارسال دستی ممکن است تکراری شود.', true);
}
function validMessage(value) {
return value && typeof value.id === 'string' && typeof value.at === 'string' &&
Number.isSafeInteger(value.seq) && value.seq > 0 &&
typeof value.name === 'string' && [...value.name].length <= 24 &&
typeof value.text === 'string' && [...value.text].length <= 500 &&
encoder.encode(value.text).length <= 2000 && Number.isFinite(Date.parse(value.at));
}
function appendMessage(message) {
if (seen.has(message.id) || message.seq <= latestSeq) return;
document.querySelector('#empty-state')?.remove();
const article = document.createElement('article');
article.className = 'message';
article.dataset.messageId = message.id;
const meta = document.createElement('div');
meta.className = 'message-meta';
const name = document.createElement('span');
name.className = 'message-name';
name.textContent = message.name;
const time = document.createElement('time');
time.dateTime = message.at;
time.textContent = timeFormat.format(new Date(message.at));
const text = document.createElement('p');
text.className = 'message-text';
text.textContent = message.text;
meta.append(name, time); article.append(meta, text); list.append(article);
seen.add(message.id); latestSeq = message.seq;
while (list.children.length > 50) {
seen.delete(list.firstElementChild.dataset.messageId);
list.firstElementChild.remove();
}
list.scrollTop = list.scrollHeight;
}
function snapshot(packet) {
if (typeof packet.serverId !== 'string' || typeof packet.clientId !== 'string' ||
!Number.isSafeInteger(packet.latestSeq) || packet.latestSeq < 0 ||
!Array.isArray(packet.messages) || packet.messages.length > 50 ||
!packet.messages.every(validMessage)) throw new Error('Invalid history');
let seq = 0;
const ids = new Set();
for (const message of packet.messages) {
if (message.seq <= seq || message.seq > packet.latestSeq || ids.has(message.id)) throw new Error('Invalid history order');
seq = message.seq; ids.add(message.id);
}
if (packet.messages.length && seq !== packet.latestSeq) throw new Error('Invalid history boundary');
const restarted = serverId && serverId !== packet.serverId;
serverId = packet.serverId; latestSeq = 0; seen.clear(); list.replaceChildren();
for (const message of packet.messages) appendMessage(message);
latestSeq = packet.latestSeq;
if (!packet.messages.length) {
const p = document.createElement('p'); p.id = 'empty-state'; p.className = 'empty-state';
p.textContent = 'هنوز پیامی نیست؛ اولین پیام آزمایشی را شما بنویسید.';
list.append(p);
}
ready = true; retries = 0; clearTimeout(historyTimer);
setStatus('وصل هستید؛ آمادهٔ گفتوگو', 'online');
if (!pending) {
if (unknown.size) notice('ارسال قبلی هنوز نتیجهٔ قطعی ندارد؛ متن حفظ شده و خودکار دوباره فرستاده نمیشود.', true);
else notice(restarted ? 'سرور دوباره شروع شده و تاریخچهٔ قبلی در حافظه نیست.' : 'آمادهاید؛ پیام تازه بنویسید.');
}
updateControls();
}
function scheduleReconnect() {
if (manualStop) return;
if (retries >= 5) {
manualStop = true; setStatus('اتصال برقرار نشد؛ دستی دوباره وارد شوید', 'offline');
updateControls(); return;
}
const delay = Math.min(10000, 500 * 2 ** retries + Math.floor(Math.random() * 251));
retries += 1;
setStatus(`اتصال دوباره؛ تلاش ${numberFormat.format(retries)} از ۵`);
reconnectTimer = setTimeout(connect, delay);
}
function connect() {
if (manualStop) return;
ready = false; clearTimeout(reconnectTimer); clearTimeout(historyTimer);
setStatus('در حال اتصال…'); updateControls();
const url = new URL('/chat', location.href);
url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const current = new WebSocket(url);
socket = current;
historyTimer = setTimeout(() => {
if (socket === current && !ready) current.close(1000, 'History timeout');
}, 8000);
current.addEventListener('open', () => {
if (socket !== current || manualStop) return current.close(1000);
current.send(JSON.stringify({ type: 'join', name: displayName }));
});
current.addEventListener('message', event => {
if (socket !== current || manualStop) return;
try {
const packet = JSON.parse(event.data);
if (packet.type === 'history') { snapshot(packet); return; }
if (packet.type === 'message') {
if (!ready || !validMessage(packet.message)) throw new Error('Invalid message');
appendMessage(packet.message); return;
}
if (packet.type === 'presence') {
if (!Number.isSafeInteger(packet.online) || packet.online < 0 || packet.online > 20) throw new Error('Invalid presence');
onlineCount.textContent = numberFormat.format(packet.online); return;
}
if (packet.type === 'ack') {
if (typeof packet.clientMessageId !== 'string' || typeof packet.messageId !== 'string' || !Number.isSafeInteger(packet.seq)) throw new Error('Invalid ack');
if (pending?.id === packet.clientMessageId) {
clearTimeout(pending.timer);
if (textInput.value === pending.draft) textInput.value = '';
pending = null; notice('سرور پیام را پذیرفت.'); updateControls();
textCount.textContent = numberFormat.format([...textInput.value].length);
} else if (unknown.delete(packet.clientMessageId) && !pending) notice('تأیید دیررس رسید؛ سرور پیام قبلی را پذیرفته بود.');
return;
}
if (packet.type === 'error') {
if (typeof packet.code !== 'string' || typeof packet.message !== 'string') throw new Error('Invalid error');
if (pending && (!packet.clientMessageId || packet.clientMessageId === pending.id)) {
clearTimeout(pending.timer); pending = null; updateControls();
}
notice(packet.message, true); return;
}
throw new Error('Unknown packet');
} catch {
notice('پاسخ سرور با قرارداد هماهنگ نیست؛ اتصال بسته شد.', true);
manualStop = true; current.close(1000, 'Invalid server packet');
}
});
current.addEventListener('error', () => {
if (socket === current) setStatus('ارتباط برقرار نشد؛ نشانی، Origin و پراکسی را بررسی کنید', 'offline');
});
current.addEventListener('close', event => {
if (socket !== current) return;
clearTimeout(historyTimer); ready = false; onlineCount.textContent = '۰'; unknownPending();
if ([1003, 1008, 1009].includes(event.code)) manualStop = true;
if (manualStop) setStatus('قطع شده؛ برای ورود دوباره دکمه را بزنید', 'offline');
else scheduleReconnect();
updateControls();
});
}
joinForm.addEventListener('submit', event => {
event.preventDefault();
if (!['http:', 'https:'].includes(location.protocol)) {
notice('صفحه را از نشانی HTTP برنامه باز کنید؛ بازکردن مستقیم فایل HTML کافی نیست.', true); return;
}
const name = nickname.value.normalize('NFC').trim();
if (!name.isWellFormed() || !name || [...name].length > 24 || encoder.encode(name).length > 96) {
notice('نام آزمایشی را کوتاهتر، از ۱ تا ۲۴ نویسه، انتخاب کنید.', true); nickname.focus(); return;
}
manualStop = false; displayName = name; retries = 0; connect();
});
disconnectButton.addEventListener('click', () => {
manualStop = true; ready = false;
clearTimeout(reconnectTimer); clearTimeout(historyTimer); unknownPending();
socket?.close(1000, 'User left'); setStatus('از گفتوگو خارج شدید'); updateControls();
});
textInput.addEventListener('input', () => {
textCount.textContent = numberFormat.format([...textInput.value.normalize('NFC')].length);
});
messageForm.addEventListener('submit', event => {
event.preventDefault();
if (!ready || pending || socket?.readyState !== WebSocket.OPEN) return;
const text = textInput.value.normalize('NFC').trim();
if (!text.isWellFormed() || !text || [...text].length > 500 || encoder.encode(text).length > 2000) {
notice('یک پیام کوتاه از ۱ تا ۵۰۰ نویسه و حداکثر ۲۰۰۰ بایت بنویسید.', true); return;
}
let id;
try { id = createRequestId(); }
catch { notice('مرورگر شناسهٔ تصادفی امن نمیسازد؛ از مرورگر تازه یا HTTPS استفاده کنید.', true); return; }
const packet = JSON.stringify({ type: 'chat', clientMessageId: id, text });
if (socket.bufferedAmount + encoder.encode(packet).length > 262144) {
notice('صف ارسال شلوغ است؛ کمی صبر کنید. پیام فرستاده نشد.', true); return;
}
try { socket.send(packet); }
catch { notice('ارسال آغاز نشد؛ متن شما حفظ شد.', true); return; }
pending = { id, draft: textInput.value, timer: setTimeout(() => { unknownPending(); updateControls(); }, 8000) };
notice('در صف ارسال است؛ منتظر تأیید سرور…'); updateControls();
});
textInput.addEventListener('keydown', event => {
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault(); messageForm.requestSubmit();
}
});
window.addEventListener('pagehide', () => {
manualStop = true; ready = false; clearTimeout(reconnectTimer); clearTimeout(historyTimer);
if (pending) { clearTimeout(pending.timer); pending = null; }
socket?.close(1000, 'Page hidden');
});
window.addEventListener('pageshow', event => {
if (event.persisted) { setStatus('صفحه برگشت؛ برای اتصال دوباره وارد شوید'); updateControls(); }
});
updateControls();
از دکمهٔ ورود تا نمایش امن پیام
هنگام ارسال فرم ورود، preventDefault مانع بارگذاری دوبارهٔ صفحه میشود. نام trim و NFC میشود و طول و اندازهٔ بایتی آن بررسی میشوند؛ این بررسی رابط، جای اعتبارسنجی مستقل سرور را نمیگیرد. connect نشانی /chat را از location میسازد و بسته به http یا https، ws یا wss برمیگزیند. رویداد open فقط join را میفرستد؛ snapshot پس از بررسی history، ready را روشن و ورودی پیام را فعال میکند. هویت current در handlerها بررسی میشود تا رخداد یک اتصال قدیمی وضعیت نشست تازه را خراب نکند. این همان مالکیت اتصال درس ششم است.
appendMessage هر پیام را با createElement میسازد و متن نام و پیام را با textContent قرار میدهد. بنابراین متنی مانند <b>سلام</b> بهعنوان متن دیده میشود، نه دستور قالببندی HTML. رشتهٔ کاربر را به innerHTML منتقل نکنید. #dom در این درس ابزار ساخت نمای امن است، نه محل اعتماد به دادهٔ شبکه. زمان ISO سرور با Intl برای نمایش محلی قالببندی میشود، ولی ترتیب از seq میآید. مجموعهٔ seen و محدودکردن فهرست به ۵۰ پیام، نمایش تکراری و رشد بیپایان DOM را مهار میکنند؛ این کار آرشیو دائمی ایجاد نمیکند.
فرم ارسال، هم ready و هم OPEN بودن socket را میسنجد. در هر لحظه فقط یک تلاش منتظر ack داریم، ولی کاربر میتواند متن بعدی را در ورودی ویرایش کند. هنگام ack، کد فقط وقتی ورودی را پاک میکند که هنوز همان پیشنویس زمان ارسال باشد؛ متن تازهٔ کاربر نباید از بین برود. بسته پس از JSON.stringify یک رشته فرستاده میشود؛ TextEncoder برای اندازهگیری صف است و نوع پیام را باینری نمیکند. #json و قرارداد شناسه به سرور و رابط کمک میکنند نتیجه را به تلاش درست وصل کنند، نه به جملهای که اتفاقاً شبیه است.
تابع createRequestId ابتدا randomUUID را به کار میبرد؛ این API به بستر امن وابسته است و روی HTTP یک دامنهٔ معمولی ممکن است موجود نباشد. جایگزین پروژه از getRandomValues و بیتهای قالب UUID نسخهٔ چهار استفاده میکند؛ Math.random برای شناسه استفاده نشده است. اگر مولد امن هم در دسترس نباشد، پیام راهنما نمایش مییابد و ارسال آغاز نمیشود. این شناسه برای تطبیق تلاش است، نه ورود کاربر یا اثبات هویت. انتشار عمومی همچنان باید HTTPS و WSS داشته باشد.
قطع ارتباط را هم جزئی از محصول آزمایش کنید
اگر ack در هشت ثانیه نرسد، unknownPending متن را نگه میدارد و ارسال خودکار دوباره ندارد. تأیید دیررس ممکن است پذیرفتن تلاش قبلی را روشن کند. هنگام اتصال دوباره، snapshot جای فهرست قبلی مینشیند؛ باقیماندن متن در ورودی به معنی نرسیدن پیام نیست. دکمهٔ «خروج از گفتوگو» خواست اتصال را خاموش، تایمرها را پاک و socket را میبندد. بستهشدن ناشی از خروج نباید retry بسازد. pagehide نیز اتصال را جمع میکند؛ در بازگشت از حافظهٔ رفتوبرگشت مرورگر، ورود دستی لازم است. #event-listener در اینجا باید عمر روشن و پایان مشخص داشته باشد.
برای آزمون عادی، در دو تب با نامهای «سارا» و «نوید» وارد شوید؛ عدد افراد حاضر باید دو اتصال را نشان دهد. یک پیام فارسی و سپس متن <b>سلام</b> بفرستید؛ پیام باید در هر دو تب، با متن خام و بدون اجرای HTML دیده شود. Enter ارسال میکند و Shift+Enter خط تازه میسازد؛ هنگام نوشتن با ورودی ترکیبی، کد isComposing مانع ارسال زودهنگام است. تب سوم را باز کنید؛ تاریخچهٔ محدود باید فقط یک بار ظاهر شود. سپس از یک تب خارج شوید و کاهش تعداد اتصالها را در تب دیگر ببینید.
برای آزمون شکست، یک متن را تایپ ولی ارسال نکنید؛ خروج و ورود نباید آن را پاک کند. سرور محلی را با Ctrl+C متوقف کنید؛ رابط باید قطع و تلاش محدود را نشان دهد، نه اتصال موفق خیالی. با اجرای دوباره، تاریخچهٔ حافظهٔ اجرای قبلی از بین میرود. برای وضعیت نامعلوم، با ابزار شبکهٔ مرورگر و سرور آزمایشی، قطع را نزدیک ارسال ایجاد کنید؛ این مسابقه قطعی و قابل تکرار در هر بار نیست. اگر موفق به ایجاد آن نشدید، ادعای آزمون نکنید. با هر نتیجه، متنِ ارسالنشده و پیام تأییدشده را جدا ثبت کنید.
تمرین با پاسخ و راه عیبیابی
تمرین: یک جدول شامل اقدام، وضعیت مورد انتظار و نتیجهٔ واقعی برای ورود دو تب، متن خالی، خروج، بازگشت، توقف سرور و حفظ پیشنویس بنویسید. سپس آگاهانه فقط متن یکی از پیامهای راهنما را بهتر کنید و دوباره check و آزمون دستی را انجام دهید. پاسخ راهنما برای پیام خالی، باقیماندن در رابط و نرفتن بستهٔ chat است؛ پاسخ توقف سرور، اعلام قطع و نبود تضمین حفظ تاریخچه است. معیار تحویل، ظاهر مرتب همراه رفتار درست است. تغییر پیام راهنما نباید قرارداد فیلدها یا پاکسازی منابع را عوض کند.
اگر صفحه باز میشود ولی اتصال برقرار نیست، Console و بخش Network مرورگر را ببینید: درخواست /chat، نشانی مبدأ و پاسخ پراکسی را بررسی کنید. خطای 403 میتواند اختلاف Origin باشد؛ با افزودن مجوز همگانی مشکل را پنهان نکنید. برای ارجاع به مبانی فرم و رویداد، این درس را مرور کنید: https://www.darsavand.com/courses/javascript-from-zero-to-project?lesson=a97cd3de-7c55-4276-9869-9126279718ad
برای نمایش امن ورودی و مرز اعتماد نیز این درس مرتبط است: https://www.darsavand.com/courses/javascript-project-lab?lesson=430e2d53-26a6-41f6-9718-d14244f3ff1d
مدخل #debugging-console برای ثبت علت خطا مفید است. نام یا پیام واقعی را بیدلیل در گزارش عیبیابی منتشر نکنید.
منابع رسمی انگلیسی:
MDN Writing WebSocket client applications: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications
MDN textContent: https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent
MDN crypto randomUUID: https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
MDN crypto getRandomValues: https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues
npm ci: https://docs.npmjs.com/cli/v11/commands/npm-ci/
MDN pagehide: https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event
تألیف، مثالها و تمرینها: تحریریه درسآوند. منابع رسمی انگلیسی برای راستیآزمایی رفتار ابزارها و مطالعهٔ بیشتر معرفی شدهاند.
تألیف و مثالهای اختصاصی: تحریریه درسآوند. فایلهای کامل پروژه و راهنمای اجرا و دیباگ:
https://www.darsavand.com/resources/simple-websocket-chat-project
متن درسهای عمومی را بدون ورود بخوانید.