Як інтегрувати WebSocket з Express.js?
Інтеграція WebSocket з Express.js означає загорнути Express-додаток у http.createServer(app), а потім приєднати до цього сервера Socket.IO або бібліотеку ws. Express обробляє HTTP-маршрути на тому ж порту; WebSocket-бібліотека тримає постійні з'єднання.
Теорія
TL;DR
- HTTP - це рація: надіслав, почекав відповідь, з'єднання закрилось. WebSocket - це телефонний дзвінок: обидві сторони говорять коли хочуть, лінія залишається відкритою.
- Express
app.listen()сам по собі блокує WebSocket. Завжди використовуйhttp.createServer(app). - Socket.IO додає кімнати (rooms), резервне перемикання на polling і авто-перепідключення. Нативна бібліотека
wsважить 3KB і більше нічого не робить. - Використовуй WebSocket, коли сервер сам ініціює відправку даних (чат, live-дані, курсори). HTTP polling підходить для дашборду з оновленням кожні 30 секунд.
- Для 10k+ одночасних користувачів потрібен Redis adapter. Без нього два Node-процеси не можуть ділити стан сокетів.
Швидке налаштування
const express = require('express');
const http = require('http'); // Потрібен для WebSocket handshake
const { Server } = require('socket.io');
const app = express();
const server = http.createServer(app); // Загортаємо Express, не app.listen()
const io = new Server(server, { cors: { origin: '*' } });
io.on('connection', (socket) => {
console.log('підключився:', socket.id); // спрацьовує для кожного клієнта
socket.emit('welcome', 'Привіт!'); // надіслати тільки цьому клієнту
socket.on('message', (msg) => {
io.emit('message', msg); // транслювати всім клієнтам
});
socket.on('disconnect', () => {
console.log('вийшов:', socket.id); // спрацьовує при закритті вкладки
});
});
app.get('/api/health', (req, res) => res.json({ ok: true }));
server.listen(3000);Після server.listen(3000) HTTP-маршрути і WebSocket-з'єднання працюють на одному порту. Express обробляє /api/health, Socket.IO - ws://localhost:3000.
Чому Express не може обробляти WebSocket самостійно
Express - це ланцюжок middleware поверх Node http.IncomingMessage. Він приймає запит, пропускає через middleware, відправляє відповідь. Це повний цикл.
WebSocket починається як HTTP-запит із заголовком Upgrade: websocket і очікує відповідь 101 Switching Protocols. Після цього з'єднання виходить за межі HTTP і стає сирими TCP-фреймами. Подія upgrade живе на рівні сирого Node http.Server, а не на об'єкті Express-додатку. У Express немає шляху її перехопити.
http.createServer(app) дає доступ до справжнього сервера. Передай його в Socket.IO - і він перехоплює upgrade-запит до того, як Express торкнеться з'єднання.
Коли використовувати WebSocket
- Чат, спільне редагування, live-курсори: двостороннє з'єднання в реальному часі. Socket.IO на Express.
- Біржові тікери, push-сповіщення, прогрес-бари: сервер лише відправляє дані. WebSocket або Server-Sent Events (SSE) - обидва варіанти підходять.
- Дашборд з оновленням кожні 30 секунд: HTTP polling простіший і дешевший.
- Ігрові лобі або 10k+ одночасних підключень: WebSocket з Redis adapter.
- Звичайний REST API без live-оновлень: WebSocket тут не потрібен.
Socket.IO проти нативного ws
| Функція | Socket.IO | ws |
|---|---|---|
| Розмір | ~50KB | ~3KB |
| Резерв на HTTP polling | Автоматичний | Відсутній |
| Кімнати (rooms) / простори (namespaces) | Вбудовані | Вручну |
| Авто-перепідключення | Вбудоване | Вручну |
| Підтримка браузерів | Universal + legacy | 95%+ сучасних |
| Масштабування | Redis adapter | Ручне sticky sessions |
| Найкраще для | Production чат, collab-додатки | Кастомні протоколи, мінімум залежностей |
Socket.IO має 2M+ завантажень npm на тиждень. ws обирають, коли потрібно зменшити розмір або реалізувати кастомний бінарний протокол.
Як працює handshake
Коли клієнт надсилає Upgrade: websocket, event loop libuv в Node.js виявляє це на рівні TCP. Node відповідає 101 Switching Protocols, потім переключає сокет з HTTP-фреймів на WebSocket-фрейми: opcode 0x1 для тексту, 0x2 для бінарних даних. V8 обробляє парсинг повідомлень. Socket.IO додає свій шар фреймування для перепідключення, маршрутизації по кімнатах і підтвердження пакетів.
Після handshake між клієнтом і сервером не передаються HTTP-заголовки. Саме тому накладні витрати мінімальні порівняно зі звичайними HTTP-запитами.
Типові помилки
1. Передача Express-додатку напряму в Socket.IO
// НЕПРАВИЛЬНО: TypeError під час виконання
const io = new Server(app);express() повертає функцію-обробник запитів, а не HTTP-сервер. Виправлення: const server = http.createServer(app); new Server(server).
2. Відсутність CORS-конфігурації в Socket.IO
// Браузер заблокує - немає CORS-конфігурації
const io = new Server(server);Handshake Socket.IO - це cross-origin HTTP-запит до WebSocket upgrade. Без { cors: { origin: 'http://localhost:3000' } } браузер його блокує. Це найпоширеніша причина "двох годин дебагу CORS" при роботі з Socket.IO, і я стикався з цим особисто не раз.
3. Неправильна ціль emit у кімнатах
socket.emit('chat', msg); // тільки відправник отримає
io.to(roomId).emit('chat', msg); // всі в кімнаті, включно з відправником
socket.to(roomId).emit('chat', msg); // всі в кімнаті, крім відправника4. Відсутність cleanup при disconnect
// Витік пам'яті при hot-reload: слухачі накопичуються
io.on('connection', () => { /* без cleanup */ });Додай socket.on('disconnect', handler) на сервері. В React поверни cleanup з useEffect: return () => socket.off('chat').
5. Кілька Node-процесів без Redis adapter
Якщо балансувальник навантаження направляє клієнта A до процесу 1, а клієнта B до процесу 2 - io.emit() з процесу 1 досягне лише клієнтів процесу 1. Додай Redis adapter (приклад нижче) і emit пройде через Redis pub/sub до всіх процесів.
Де використовується в реальних проектах
- Discord: шардований WebSocket-шлюз для 15M одночасних користувачів.
- Trello: Socket.IO на Express для live-оновлення дошок.
- Slack: власна реалізація
wsдля повідомлень у реальному часі. - ShareDB: WebSocket + Express для collaborative editing у стилі Google Docs (CRDT).
- Pusher / Ably: керовані WebSocket-сервіси, які проксуються через Express-бекенд.
Питання на співбесіді
Q: Чому не можна приєднати WebSocket напряму до Express-додатку?
A: Express - це middleware-ланцюжок для HTTP request/response. WebSocket потребує події upgrade на рівні сирого Node http.Server. http.createServer(app) відкриває доступ до цієї події.
Q: Як працює fallback у Socket.IO?
A: При збої WebSocket (корпоративні файрволи, NAT) Socket.IO автоматично переходить на HTTP long-polling. Щойно стабільне WebSocket-з'єднання стає можливим, він повертається до нього.
Q: У чому різниця між кімнатами (rooms) і просторами (namespaces)?
A: Rooms - динамічні групи всередині namespace: socket.join('room1'). Namespaces - ізольовані канали з власним scope подій: io.of('/admin'). Rooms для чат-груп, namespaces для окремих функцій на кшталт адмін-панелі.
Q: У чому різниця між socket.emit() і io.emit()?
A: socket.emit() надсилає одному клієнту. io.emit() транслює всім підключеним. socket.to(roomId).emit() надсилає всім у кімнаті, крім відправника.
Q: У multi-region setup як обробляти sticky sessions і уникнути втрати повідомлень при failover?
A: Redis Streams замість звичайного pub/sub дають стійку історію повідомлень - клієнти після перепідключення можуть отримати пропущені події. Клієнти перепідключаються з токенами сесії, щоб новий сервер відновив стан. Для cross-region при 10k+ CCU Kafka надійніша за Redis. Sticky sessions самі по собі дають збій приблизно в 30% випадків при рестарті балансувальника. Повна відповідь включає TTL-heartbeats і reconnection з exponential backoff на клієнті.
Приклади
Базовий чат-сервер з кімнатами
// server.js
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: { origin: 'http://localhost:3000' }
});
io.on('connection', (socket) => {
console.log('user joined:', socket.id);
socket.on('join', (room) => {
socket.join(room);
socket.to(room).emit('user-joined', socket.id); // сповістити інших, не відправника
});
socket.on('chat', ({ room, msg }) => {
io.to(room).emit('chat', msg); // всім у кімнаті, включно з відправником
});
socket.on('disconnect', () => {
console.log('user left:', socket.id);
});
});
server.listen(3000);socket.to(room) виключає відправника. io.to(room) включає всіх. Для повідомлень користувача зазвичай використовують socket.to() - відправник вже бачить своє повідомлення локально. Для системних подій на кшталт "користувач приєднався" - io.to().
JWT-автентифікація при підключенні
const jwt = require('jsonwebtoken');
// Middleware спрацьовує один раз перед подією connection
io.use((socket, next) => {
const token = socket.handshake.auth.token;
try {
const user = jwt.verify(token, process.env.JWT_SECRET);
socket.user = user; // прикріпити до сокета для подальших обробників
next();
} catch {
next(new Error('Unauthorized')); // клієнт отримає connect_error
}
});
io.on('connection', (socket) => {
console.log('автентифіковано:', socket.user.id);
});// Клієнт
const socket = io('http://localhost:3000', {
auth: { token: localStorage.getItem('jwt') }
});
socket.on('connect_error', (err) => {
console.log('помилка автентифікації:', err.message); // "Unauthorized"
});Якщо викликано next(new Error(...)), клієнт отримує connect_error і сокет ніколи не потрапляє в обробник connection. Зручне місце для rate limiting і валідації токена.
Redis adapter для масштабування
// npm install @socket.io/redis-adapter ioredis
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');
const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
io.on('connection', (socket) => {
socket.on('message', (data) => {
io.emit('message', data); // тепер досягає клієнтів на ВСІХ Node-процесах
});
socket.on('disconnect', () => {
// спрацьовує після ~20s таймауту, якщо ping не отримано
console.log('user left:', socket.id);
});
});Без adapter io.emit() досягає лише клієнтів поточного процесу. З Redis pub/sub кожен emit стає повідомленням, яке отримують усі процеси-підписники і пересилають своїм клієнтам. PM2 cluster mode з цим налаштуванням тримає десятки тисяч одночасних підключень.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.