Skip to main content

Як інтегрувати 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-процеси не можуть ділити стан сокетів.

Швидке налаштування

js
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.IOws
Розмір~50KB~3KB
Резерв на HTTP pollingАвтоматичнийВідсутній
Кімнати (rooms) / простори (namespaces)ВбудованіВручну
Авто-перепідключенняВбудованеВручну
Підтримка браузерівUniversal + legacy95%+ сучасних
Масштабування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

js
// НЕПРАВИЛЬНО: TypeError під час виконання const io = new Server(app);

express() повертає функцію-обробник запитів, а не HTTP-сервер. Виправлення: const server = http.createServer(app); new Server(server).

2. Відсутність CORS-конфігурації в Socket.IO

js
// Браузер заблокує - немає CORS-конфігурації const io = new Server(server);

Handshake Socket.IO - це cross-origin HTTP-запит до WebSocket upgrade. Без { cors: { origin: 'http://localhost:3000' } } браузер його блокує. Це найпоширеніша причина "двох годин дебагу CORS" при роботі з Socket.IO, і я стикався з цим особисто не раз.

3. Неправильна ціль emit у кімнатах

js
socket.emit('chat', msg); // тільки відправник отримає io.to(roomId).emit('chat', msg); // всі в кімнаті, включно з відправником socket.to(roomId).emit('chat', msg); // всі в кімнаті, крім відправника

4. Відсутність cleanup при disconnect

js
// Витік пам'яті при 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 на клієнті.

Приклади

Базовий чат-сервер з кімнатами

js
// 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-автентифікація при підключенні

js
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); });
js
// Клієнт 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 для масштабування

js
// 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 з цим налаштуванням тримає десятки тисяч одночасних підключень.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?