Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як інтегрувати WebSocket з Express.js?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Інтеграція WebSocket з Express.js** потребує `http.createServer(app)` замість `app.listen()`, а потім підключення Socket.IO або `ws` до цього сервера. ```js const server = http.createServer(app); const io = new Server(server, { cors: { origin: '*' } }); io.on('connection', (socket) => socket.emit('welcome', 'Привіт!')); server.listen(3000); ``` **Ключове:** Express обробляє HTTP; сирий `http.Server` відкриває подію `upgrade` для WebSocket, яка робить постійні з'єднання можливими.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Інтеграція 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.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** ```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 з цим налаштуванням тримає десятки тисяч одночасних підключень.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.