Пошук уроків, статей та іншого контенту
Створюйте двонапрямну взаємодію в реальному часі та керуйте підключеннями й підписками клієнтів.
WebSocket — це протокол для постійного двонапрямного з’єднання між клієнтом і сервером.
На відміну від звичайного HTTP-запиту:
клієнт може надсилати повідомлення серверу в будь-який момент;
сервер може самостійно надсилати повідомлення клієнту;
не потрібно створювати новий HTTP-запит для кожної події;
з’єднання залишається відкритим, доки одна зі сторін його не закриє.
Типовий життєвий цикл WebSocket-з’єднання:
Клієнт надсилає HTTP-запит на встановлення з’єднання.
Сервер погоджується на перехід до протоколу WebSocket.
Клієнт і сервер обмінюються повідомленнями.
Одна зі сторін закриває з’єднання або воно переривається через помилку.
WebSocket добре підходить для:
чатів;
сповіщень;
відстеження статусу замовлення;
онлайн-ігор;
біржових котирувань;
спільного редагування даних;
панелей моніторингу.
wsУ Node.js для роботи з WebSocket часто використовують пакет ws.
Створіть новий проєкт і встановіть залежність:
mkdir websocket-demo
cd websocket-demo
npm init -y
npm install wsПакет ws надає:
WebSocket-сервер для Node.js;
WebSocket-клієнт для Node.js;
події для підключення, повідомлень і завершення з’єднання;
методи надсилання та закриття з’єднань.
У браузері окрема бібліотека не потрібна: WebSocket підтримується вбудованим об’єктом WebSocket.
Мінімальний WebSocket-сервер може приймати підключення та відповідати на повідомлення:
const { WebSocketServer } = require('ws');
const webSocketServer = new WebSocketServer({
port: 8080,
});
webSocketServer.on('connection', (socket) => {
console.log('Клієнт підключився');
socket.send('Вітаємо на сервері');
socket.on('message', (message) => {
const text = message.toString();
console.log('Отримано:', text);
socket.send(`Сервер отримав: ${text}`);
});
socket.on('close', () => {
console.log('Клієнт відключився');
});
socket.on('error', (error) => {
console.error('Помилка WebSocket:', error.message);
});
});
console.log('WebSocket-сервер запущено на ws://localhost:8080');Запустіть сервер:
node server.jsПовідомлення від клієнта надходять у події message, а надіслати дані можна методом socket.send().
У браузері підключення створюється через глобальний конструктор WebSocket:
const socket = new WebSocket('ws://localhost:8080');
socket.addEventListener('open', () => {
console.log('З’єднання встановлено');
socket.send('Привіт із браузера');
});
socket.addEventListener('message', (event) => {
console.log('Повідомлення від сервера:', event.data);
});
socket.addEventListener('close', () => {
console.log('З’єднання закрито');
});
socket.addEventListener('error', (error) => {
console.error('Помилка WebSocket:', error);
});Основні стани з’єднання:
WebSocket.CONNECTING — з’єднання встановлюється;
WebSocket.OPEN — з’єднання відкрите;
WebSocket.CLOSING — з’єднання закривається;
WebSocket.CLOSED — з’єднання закрито.
Перед надсиланням повідомлення варто перевіряти стан:
if (socket.readyState === WebSocket.OPEN) {
socket.send('Повідомлення');
}WebSocket передає текстові або бінарні дані. Для прикладних повідомлень зручно використовувати JSON.
Наприклад, клієнт може надсилати команди:
{
"type": "subscribe",
"channel": "news"
}А сервер — події:
{
"type": "news",
"payload": {
"title": "Нова подія"
}
}Під час роботи з JSON потрібно обробляти помилки парсингу. Клієнт або сторонній користувач може надіслати некоректний текст.
Сервер може зберігати для кожного клієнта набір каналів, на які він підписаний.
Наприклад:
клієнт A підписаний на news;
клієнт B підписаний на prices;
клієнт C підписаний на обидва канали.
Коли з’являється подія для певного каналу, сервер надсилає її лише відповідним клієнтам.
Для цього кожне підключення можна представити об’єктом із додатковим станом:
const client = {
socket,
channels: new Set(),
};Метод Set зручний для підписок, оскільки він автоматично не допускає дублікати.
Нижче наведено повний приклад. Сервер:
обслуговує просту HTML-сторінку;
приймає WebSocket-підключення за адресою /ws;
підтримує підписки на канали;
надсилає події лише підписаним клієнтам;
видаляє клієнта після відключення;
перевіряє формат вхідних повідомлень;
періодично генерує події для каналу prices.
Створіть файл server.js:
const http = require('node:http');
const { WebSocketServer, WebSocket } = require('ws');
const port = 3000;
const clients = new Set();
const html = `
<!doctype html>
<html lang="uk">
<head>
<meta charset="UTF-8">
<title>WebSocket demo</title>
</head>
<body>
<h1>Події в реальному часі</h1>
<button id="subscribeButton">Підписатися на prices</button>
<button id="unsubscribeButton">Скасувати підписку</button>
<pre id="output"></pre>
<script>
const output = document.querySelector('#output');
const subscribeButton = document.querySelector('#subscribeButton');
const unsubscribeButton = document.querySelector('#unsubscribeButton');
const socket = new WebSocket('ws://' + location.host + '/ws');
function print(value) {
output.textContent += value + '\\\\n';
}
function send(message) {
if (socket.readyState !== WebSocket.OPEN) {
print('З’єднання ще не готове');
return;
}
socket.send(JSON.stringify(message));
}
socket.addEventListener('open', () => {
print('З’єднання встановлено');
});
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data);
print(JSON.stringify(message));
});
socket.addEventListener('close', () => {
print('З’єднання закрито');
});
socket.addEventListener('error', () => {
print('Помилка WebSocket');
});
subscribeButton.addEventListener('click', () => {
send({
type: 'subscribe',
channel: 'prices'
});
});
unsubscribeButton.addEventListener('click', () => {
send({
type: 'unsubscribe',
channel: 'prices'
});
});
</script>
</body>
</html>
`;
const httpServer = http.createServer((request, response) => {
if (request.url === '/') {
response.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
});
response.end(html);
return;
}
response.writeHead(404);
response.end('Not found');
});
const webSocketServer = new WebSocketServer({
server: httpServer,
path: '/ws',
});
function sendJson(socket, message) {
if (socket.readyState !== WebSocket.OPEN) {
return;
}
socket.send(JSON.stringify(message));
}
function broadcastToChannel(channel, message) {
for (const client of clients) {
if (!client.channels.has(channel)) {
continue;
}
sendJson(client.socket, message);
}
}
function isValidChannel(channel) {
return channel === 'prices' || channel === 'news';
}
webSocketServer.on('connection', (socket) => {
const client = {
socket,
channels: new Set(),
};
clients.add(client);
sendJson(socket, {
type: 'connected',
payload: {
message: 'WebSocket-з’єднання встановлено',
},
});
socket.on('message', (rawMessage) => {
let message;
try {
message = JSON.parse(rawMessage.toString());
} catch {
sendJson(socket, {
type: 'error',
payload: {
message: 'Повідомлення має бути коректним JSON',
},
});
return;
}
if (
!message ||
typeof message.type !== 'string' ||
typeof message.channel !== 'string'
) {
sendJson(socket, {
type: 'error',
payload: {
message: 'Повідомлення має містити type і channel',
},
});
return;
}
if (!isValidChannel(message.channel)) {
sendJson(socket, {
type: 'error',
payload: {
message: 'Невідомий канал',
},
});
return;
}
if (message.type === 'subscribe') {
client.channels.add(message.channel);
sendJson(socket, {
type: 'subscribed',
payload: {
channel: message.channel,
},
});
return;
}
if (message.type === 'unsubscribe') {
client.channels.delete(message.channel);
sendJson(socket, {
type: 'unsubscribed',
payload: {
channel: message.channel,
},
});
return;
}
sendJson(socket, {
type: 'error',
payload: {
message: 'Невідомий тип повідомлення',
},
});
});
socket.on('close', () => {
clients.delete(client);
console.log('Клієнт відключився');
});
socket.on('error', (error) => {
console.error('Помилка клієнта:', error.message);
});
});
setInterval(() => {
const price = Number((Math.random() * 100).toFixed(2));
broadcastToChannel('prices', {
type: 'price_updated',
payload: {
symbol: 'NODE',
price,
timestamp: new Date().toISOString(),
},
});
}, 3000);
httpServer.listen(port, () => {
console.log(`Відкрийте http://localhost:${port}`);
});Запустіть програму:
node server.jsВідкрийте http://localhost:3000 у браузері та натисніть кнопку підписки. Після цього браузер почне отримувати події з новими значеннями ціни.
Після підключення сервер створює об’єкт клієнта:
const client = {
socket,
channels: new Set(),
};Цей об’єкт додається до глобальної множини clients.
Коли клієнт надсилає команду:
{
"type": "subscribe",
"channel": "prices"
}сервер виконує:
client.channels.add('prices');Функція broadcastToChannel перебирає всіх підключених клієнтів і надсилає повідомлення лише тим, у кого є потрібний канал:
function broadcastToChannel(channel, message) {
for (const client of clients) {
if (!client.channels.has(channel)) {
continue;
}
sendJson(client.socket, message);
}
}Після команди unsubscribe канал видаляється з Set, і клієнт більше не отримує події цього типу.
Підключення може завершитися з різних причин:
користувач закрив вкладку;
втрачено мережу;
сервер завершив процес;
клієнт або сервер викликав close();
виникла помилка протоколу.
Тому потрібно обробляти подію close і видаляти клієнта зі структур даних:
socket.on('close', () => {
clients.delete(client);
});Якщо цього не зробити, сервер продовжить зберігати непотрібні об’єкти. У довготривалій програмі це може призвести до витоку пам’яті.
Помилки потрібно обробляти окремо:
socket.on('error', (error) => {
console.error(error.message);
});Подія error не замінює подію close. Після помилки з’єднання також може бути закритим, тому обробник очищення має бути зареєстрований незалежно.
Дані від клієнта не можна вважати надійними. Навіть якщо клієнт використовує ваш інтерфейс, з’єднатися із сервером може будь-яка інша програма.
Сервер повинен перевіряти:
чи є повідомлення коректним JSON;
чи має повідомлення очікувану структуру;
чи має type дозволене значення;
чи існує вказаний канал;
чи має користувач право підписуватися на канал.
Небезпечний варіант:
const message = JSON.parse(rawMessage.toString());
client.channels.add(message.channel);У ньому немає обробки помилки JSON і перевірки значення channel.
Безпечніший підхід:
let message;
try {
message = JSON.parse(rawMessage.toString());
} catch {
sendJson(socket, {
type: 'error',
payload: {
message: 'Некоректний JSON',
},
});
return;
}
if (typeof message.channel !== 'string') {
return;
}Перелік дозволених каналів також краще контролювати на сервері, а не приймати довільні назви.
Надсилати дані можна лише через відкрите з’єднання:
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
}У ws також є властивість bufferedAmount, яка показує, скільки байтів ще очікує надсилання.
Якщо сервер генерує події швидше, ніж клієнт може їх отримувати, буфер може зростати. Для високочастотних подій варто:
зменшувати частоту оновлень;
об’єднувати кілька оновлень в одне повідомлення;
пропускати застарілі значення;
обмежувати розмір повідомлень;
контролювати кількість підключених клієнтів.
Мережеве з’єднання може фізично перерватися, але сервер не завжди одразу отримає подію close. Для виявлення таких клієнтів використовують heartbeat — періодичну перевірку доступності.
Бібліотека ws підтримує WebSocket ping/pong. Сервер може періодично надсилати ping, а клієнт автоматично відповідатиме pong.
Приклад heartbeat для серверних клієнтів:
function markAlive() {
this.isAlive = true;
}
webSocketServer.on('connection', (socket) => {
socket.isAlive = true;
socket.on('pong', markAlive);
socket.on('close', () => {
clearInterval(socket.heartbeatTimer);
});
socket.heartbeatTimer = setInterval(() => {
if (socket.isAlive === false) {
socket.terminate();
return;
}
socket.isAlive = false;
socket.ping();
}, 30000);
});Такий підхід потрібен для довготривалих з’єднань, особливо коли клієнти можуть залишатися підключеними годинами.
close() коректно закриває WebSocket-з’єднання, а terminate() негайно завершує його. Для неактивного або недоступного клієнта під час heartbeat зазвичай використовують terminate().
Клієнт може коректно закрити з’єднання:
socket.close(1000, 'Користувач завершив роботу');На сервері можна вказати код і причину:
socket.close(1000, 'Сервер завершує роботу');Код 1000 означає нормальне завершення з’єднання. Не слід використовувати довільні коди без розуміння їх призначення.
Перед завершенням процесу сервер може повідомити клієнтів і закрити активні з’єднання:
for (const client of clients) {
client.socket.close(1000, 'Сервер зупиняється');
}WebSocket не підключається повторно автоматично. Якщо з’єднання закрилося, клієнт може створити нове через затримку:
function connect() {
const socket = new WebSocket('ws://localhost:3000/ws');
socket.addEventListener('open', () => {
console.log('Підключено');
});
socket.addEventListener('message', (event) => {
console.log(event.data);
});
socket.addEventListener('close', () => {
console.log('З’єднання закрито. Нова спроба через 2 секунди');
setTimeout(connect, 2000);
});
socket.addEventListener('error', () => {
// Після помилки з’єднання зазвичай також буде закрито.
});
}
connect();У реальному застосунку повторні спроби краще обмежувати або збільшувати інтервал між ними. Інакше після недоступності сервера багато клієнтів можуть одночасно створювати нові підключення.
Після повторного підключення підписки потрібно відновити, оскільки сервер зберігає стан конкретного WebSocket-з’єднання, а не браузерної вкладки назавжди.
readyStateВиклик send() для вже закритого з’єднання може призвести до помилки.
if (socket.readyState === WebSocket.OPEN) {
socket.send(message);
}Якщо додавати клієнтів у Set, але не видаляти їх після close, структура поступово накопичуватиме непотрібні об’єкти.
socket.on('close', () => {
clients.delete(client);
});JSON.parse() може викинути виняток. Обробляйте його через try...catch, інакше некоректне повідомлення може порушити обробку події.
Не кожен клієнт повинен отримувати кожну подію. Якщо клієнти мають підписки, перевіряйте канал перед надсиланням.
Назви каналів, типи команд і додаткові поля потрібно перевіряти на сервері. Клієнтський код не є механізмом захисту.
Неактивне мережеве з’єднання може залишатися у списку клієнтів. Heartbeat допомагає виявляти такі підключення та звільняти ресурси.
Якщо повідомлення генеруються швидше, ніж обробляються, збільшується буфер надсилання. Потрібно контролювати частоту подій і обсяг повідомлень.
WebSocket створює постійне двонапрямне з’єднання між клієнтом і сервером.
У Node.js для WebSocket-з’єднань можна використовувати пакет ws.
Сервер реагує на події connection, message, close та error.
Браузер працює з WebSocket через вбудований конструктор WebSocket.
JSON зручний для команд і подій прикладного рівня.
Підписки можна зберігати для кожного клієнта в Set.
Перед надсиланням потрібно перевіряти стан з’єднання.
Після завершення з’єднання клієнта потрібно видаляти зі структур даних.
Вхідні повідомлення потрібно перевіряти та безпечно обробляти.
Heartbeat допомагає знаходити недоступні клієнти.
Після повторного підключення клієнту потрібно відновити свої підписки.