Express.jsSeniorSystem design

Как реализовать WebSockets совместно с сервером Express?

WebSocket в Express реализуется через библиотеку ws или socket.io: создать http.Server, передать его в WebSocketServer, навесить обработчик upgrade. Socket.io добавляет комнаты, неймспейсы и fallback на polling.

WebSockets совместно с Express

Почему нужен отдельный подход

WebSocket — это протокол поверх TCP, отличный от HTTP. Express обрабатывает только HTTP-запросы. Для WebSocket нужно перехватить upgrade событие на уровне http.Server, которое Express использует внутри.

Вариант 1: библиотека ws (низкоуровневый)

import express from 'express';
import { createServer } from 'http';
import { WebSocketServer } from 'ws';

const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server }); // разделяем один порт

// HTTP роуты работают как обычно
app.get('/api/status', (req, res) => res.json({ ok: true }));

// WebSocket подключения
wss.on('connection', (ws, req) => {
  const clientIp = req.headers['x-forwarded-for'] ?? req.socket.remoteAddress;
  console.log(`Client connected: ${clientIp}`);

  ws.on('message', (data) => {
    const message = data.toString();
    console.log(`Received: ${message}`);

    // Echo back
    ws.send(JSON.stringify({ echo: message, ts: Date.now() }));
  });

  ws.on('close', (code, reason) => {
    console.log(`Client disconnected: ${code} ${reason}`);
  });

  ws.on('error', (err) => {
    console.error('WebSocket error:', err);
  });

  // Отправить приветствие
  ws.send(JSON.stringify({ type: 'connected' }));
});

server.listen(3000, () => console.log('Server on :3000'));

Broadcast всем клиентам

const broadcast = (data) => {
  const message = JSON.stringify(data);
  wss.clients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(message);
    }
  });
};

// Из HTTP-роута отправить WS всем подключённым
app.post('/api/notify', express.json(), (req, res) => {
  broadcast({ type: 'notification', payload: req.body });
  res.json({ sent: wss.clients.size });
});

Аутентификация WebSocket

WS handshake — обычный HTTP-запрос, можно использовать cookie или query-токен:

import { parse } from 'cookie';
import jwt from 'jsonwebtoken';

wss.on('connection', (ws, req) => {
  // Вариант 1: из cookie
  const cookies = parse(req.headers.cookie ?? '');
  const token = cookies.auth_token;

  // Вариант 2: из query string (?token=...)
  const url = new URL(req.url, 'http://localhost');
  const queryToken = url.searchParams.get('token');

  try {
    const user = jwt.verify(token ?? queryToken, process.env.JWT_SECRET);
    ws.userId = user.id; // прикрепить к ws объекту
  } catch (err) {
    ws.close(4001, 'Unauthorized');
    return;
  }

  ws.on('message', (data) => {
    console.log(`Message from user ${ws.userId}:`, data.toString());
  });
});

Вариант 2: socket.io (высокоуровневый)

import express from 'express';
import { createServer } from 'http';
import { Server as SocketIO } from 'socket.io';

const app = express();
const server = createServer(app);
const io = new SocketIO(server, {
  cors: { origin: process.env.FRONTEND_URL, credentials: true },
});

// Неймспейсы
const chatNs = io.of('/chat');

chatNs.on('connection', (socket) => {
  socket.on('join-room', (roomId) => {
    socket.join(roomId);
    // Отправить всем в комнате
    chatNs.to(roomId).emit('user-joined', { userId: socket.id });
  });

  socket.on('send-message', ({ roomId, text }) => {
    chatNs.to(roomId).emit('new-message', {
      userId: socket.id,
      text,
      ts: Date.now(),
    });
  });
});

server.listen(3000);

Масштабирование: Redis Pub/Sub

При нескольких инстансах сервера нужен брокер сообщений. Socket.io поддерживает Redis adapter:

import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';

const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);

io.adapter(createAdapter(pubClient, subClient));

Подводные камни

  • Не использовать app.listen() — он создаёт внутренний http.Server, недоступный для WebSocketServer. Нужно явно создавать createServer(app).
  • Отсутствие heartbeat — мёртвые соединения не определяются. Использовать ws.ping() каждые 30 сек и закрывать клиентов без pong.
  • Хранить состояние WebSocket в памяти процесса — при нескольких инстансах клиенты на разных серверах не видят сообщения друг друга. Нужен Redis adapter.
  • Не ограничивать размер входящих сообщений — DoS через огромные payload. Параметр maxPayload в конструкторе WebSocketServer.
  • Не обрабатывать error событие на ws объекте — необработанный error event падает весь процесс.
  • Socket.io клиент и чистый ws — несовместимы. Socket.io использует собственный протокол поверх WebSocket.
  • Nginx по умолчанию не проксирует WebSocket — нужны заголовки Upgrade и Connection в конфиге.

Common mistakes

  • Дает общий ответ про Node.js и не называет конкретные API Express.js.
  • Не объясняет, где в lifecycle находится WebSockets совместно с Express.
  • Не разделяет validation, authorization, business logic и persistence.
  • Игнорирует ошибки, лимиты входных данных, observability и тестирование.

What the interviewer is testing

  • Может объяснить WebSockets совместно с Express на примере кода.
  • Называет ключевые API: http.createServer(app), WebSocketServer.
  • Использует точные API Express.js, а не вымышленные hooks/decorators/methods.
  • Видит production-риски: безопасность, отказоустойчивость, логирование и тесты.

Sources

Related topics