WebSockets

WebSocket — протокол двунаправленной связи между клиентом и сервером поверх одного длительно существующего соединения.

После установки соединения обе стороны могут отправлять сообщения в любой момент:

Клиент ⇄ WebSocket-соединение ⇄ Сервер

В обычном HTTP взаимодействие чаще строится по модели:

Клиент → Запрос → Сервер
Клиент ← Ответ  ← Сервер

При WebSocket серверу не нужно ждать нового HTTP-запроса, чтобы передать клиенту событие.

Это удобно для систем, где данные должны обновляться почти сразу:

Пример потока сообщений в чате:

Пользователь A → Сервер: новое сообщение
Сервер → Пользователь B: новое сообщение
Сервер → Пользователь C: новое сообщение

WebSocket не заменяет HTTP полностью. Обычно приложение использует оба протокола:

HTTP:
регистрация, вход, загрузка истории, CRUD-операции

WebSocket:
новые сообщения, статусы, уведомления, обновления в реальном времени

Содержание


Протокол WebSocket

WebSocket — отдельный прикладной протокол, стандартизированный для постоянного двунаправленного соединения.

Адрес WebSocket использует схему:

ws://

Для защищённого соединения:

wss://

Примеры:

ws://localhost:8080
wss://api.example.com/ws

Соответствие протоколов:

HTTP WebSocket
http:// ws://
https:// wss://

В production следует использовать wss://, потому что он защищает трафик через TLS.


Установка соединения

WebSocket-соединение начинается с обычного HTTP-запроса, который просит сервер переключить протокол.

Клиент отправляет handshake-запрос:

GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://app.example.com

Основные заголовки:

Upgrade: websocket
Connection: Upgrade

Они сообщают, что клиент хочет перейти с HTTP на WebSocket.

Если сервер принимает запрос, он отвечает:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: ...

Статус:

101 Switching Protocols

означает успешное переключение протокола.

После этого HTTP-запросы и ответы в данном соединении больше не используются. Клиент и сервер начинают передавать WebSocket-фреймы.

Упрощённая схема:

HTTP handshake
      ↓
101 Switching Protocols
      ↓
WebSocket messages
      ↓
Закрытие соединения

WebSocket-фреймы

После handshake данные передаются небольшими блоками — фреймами.

Основные типы фреймов:

Тип Назначение
Text Текстовое сообщение
Binary Бинарные данные
Ping Проверка доступности другой стороны
Pong Ответ на Ping
Close Закрытие соединения

Браузерный API обычно предоставляет приложению уже собранные сообщения, поэтому вручную работать с отдельными фреймами не требуется.

Текстовое сообщение часто содержит JSON:

{
  "type": "chat.message",
  "payload": {
    "messageId": "message-815",
    "text": "Привет!"
  }
}

WebSocket не требует использовать JSON. Можно передавать:

Однако формат сообщений и правила взаимодействия приложение должно определить самостоятельно.


Отличие WebSocket от HTTP

Модель взаимодействия

HTTP:

Клиент инициирует запрос
Сервер отвечает

WebSocket:

Клиент может отправить сообщение
Сервер может отправить сообщение
Обе стороны используют одно соединение

Время жизни соединения

Обычный HTTP-запрос является отдельной операцией:

Запрос → Ответ

WebSocket-соединение остаётся открытым:

Соединение
├── сообщение 1
├── сообщение 2
├── сообщение 3
├── сообщение 4
└── закрытие

HTTP также может переиспользовать TCP-соединение, но модель взаимодействия всё равно остаётся запросно-ответной.


Направление передачи данных

HTTP обычно инициируется клиентом.

WebSocket является полнодуплексным:

Клиент → Сервер
Клиент ← Сервер

Обе стороны могут отправлять сообщения независимо.


Накладные расходы

Каждый HTTP-запрос содержит метод, путь и заголовки:

POST /api/messages HTTP/1.1
Content-Type: application/json
Authorization: Bearer token

После установки WebSocket-соединения сообщения передаются компактными фреймами без полного набора HTTP-заголовков для каждого события.

Это удобно при большом количестве небольших сообщений.


Кэширование

HTTP поддерживает стандартные механизмы кэширования:

Cache-Control
ETag
Last-Modified

WebSocket-сообщения не используют HTTP-кэширование. Если приложению требуется хранить или повторно получать данные, оно должно реализовать это отдельно.


Статус-коды

HTTP использует статусы:

200 OK
201 Created
400 Bad Request
401 Unauthorized
500 Internal Server Error

После перехода на WebSocket обычных HTTP-статусов для каждого сообщения нет.

Приложение самостоятельно проектирует ответы:

{
  "type": "error",
  "payload": {
    "code": "message_validation_failed",
    "message": "Текст сообщения обязателен"
  }
}

Ошибку установки соединения ещё можно вернуть как HTTP-ответ:

HTTP/1.1 401 Unauthorized

После успешного handshake ошибки передаются сообщениями или через закрытие соединения.


Состояние

HTTP API часто проектируется как stateless:

Каждый запрос содержит данные, необходимые для обработки

WebSocket-сервер обычно хранит состояние активного соединения:

{
  userId: "user-42",
  connectedAt: 1789660800000,
  subscribedRooms: ["room-1", "room-2"]
}

Это усложняет:


WebSocket, polling и Server-Sent Events

WebSocket — не единственный способ доставлять обновления в реальном времени.

Polling

Клиент периодически отправляет запрос:

setInterval(async () => {
  const response = await fetch(
    "/api/notifications",
  );

  const notifications =
    await response.json();

  renderNotifications(notifications);
}, 5000);

Схема:

Клиент → Есть новые данные?
Сервер → Нет

Через 5 секунд:

Клиент → Есть новые данные?
Сервер → Да

Polling прост, но создаёт лишние запросы и задержку между появлением данных и получением.


Long Polling

Сервер удерживает HTTP-запрос до появления данных или тайм-аута:

Клиент → Запрос
Сервер ждёт событие
Сервер → Ответ с событием
Клиент → Сразу создаёт новый запрос

Long polling может работать через инфраструктуру, в которой WebSocket недоступен, но создаёт больше HTTP-операций.


Server-Sent Events

Server-Sent Events (SSE) позволяют серверу передавать текстовые события клиенту через длительное HTTP-соединение.

Клиент ← Сервер

SSE подходит, когда данные передаются преимущественно от сервера к браузеру:

WebSocket подходит, когда нужен активный двусторонний обмен:

Клиент ⇄ Сервер

Сравнение:

Свойство Polling SSE WebSocket
Направление Клиент запрашивает Сервер → клиент Оба направления
Соединение Много запросов Длительное HTTP Длительный WebSocket
Автопереподключение Реализуется вручную Есть в браузерном API Реализуется вручную или библиотекой
Бинарные данные Через HTTP Обычно текст Поддерживаются
Простота Высокая Высокая Ниже
Чаты Возможны, но неудобны Только часть обмена Подходит

Проектирование сообщений

WebSocket определяет транспорт, но не определяет бизнес-формат сообщений.

Удобно использовать единый envelope:

{
  "type": "chat.message.send",
  "id": "event-6c21b86d",
  "timestamp": "2026-09-23T10:30:00.000Z",
  "payload": {
    "roomId": "room-42",
    "text": "Привет!"
  }
}

Поля:

Поле Назначение
type Тип события
id Идентификатор сообщения
timestamp Время создания
payload Полезные данные
requestId Идентификатор операции, если нужен ответ
version Версия формата сообщения

Ответ на команду:

{
  "type": "chat.message.created",
  "requestId": "event-6c21b86d",
  "payload": {
    "messageId": "message-815",
    "roomId": "room-42",
    "text": "Привет!"
  }
}

Ошибка:

{
  "type": "error",
  "requestId": "event-6c21b86d",
  "payload": {
    "code": "validation_failed",
    "message": "Текст сообщения обязателен"
  }
}

Типы событий желательно называть последовательно:

chat.message.send
chat.message.created
chat.message.deleted
notification.created
notification.read
user.presence.changed

Команды и события

Полезно различать:

Команда — просьба выполнить действие
Событие  — факт, который уже произошёл

Команда:

chat.message.send

Событие:

chat.message.created

Не следует сообщать другим клиентам, что сообщение создано, пока сервер:


Версионирование сообщений

Формат может изменяться:

{
  "version": 1,
  "type": "notification.created",
  "payload": {}
}

Или версия включается в URL:

wss://api.example.com/ws/v1

Изменения должны учитывать, что часть клиентов может оставаться подключённой со старой версией приложения.


Реализация WebSocket-сервера через ws

Пакет ws предоставляет реализацию низкоуровневого WebSocket для Node.js.

Установка:

npm install ws

Простой сервер

import {
  WebSocketServer,
  WebSocket,
} from "ws";

const webSocketServer =
  new WebSocketServer({
    port: 8080,
  });

webSocketServer.on(
  "connection",
  (socket, request) => {
    console.log(
      "Client connected",
      request.socket.remoteAddress,
    );

    socket.send(
      JSON.stringify({
        type: "connection.ready",
        payload: {
          message:
            "WebSocket connection established",
        },
      }),
    );

    socket.on("message", (rawData) => {
      const text = rawData.toString();

      console.log(
        "Message received:",
        text,
      );

      socket.send(
        JSON.stringify({
          type: "message.received",
          payload: {
            originalMessage: text,
          },
        }),
      );
    });

    socket.on("close", (
      code,
      reason,
    ) => {
      console.log(
        "Client disconnected",
        {
          code,
          reason: reason.toString(),
        },
      );
    });

    socket.on("error", (error) => {
      console.error(
        "WebSocket error",
        error,
      );
    });
  },
);

Запуск:

node server.js

Клиент подключается к:

ws://localhost:8080

Сервер вместе с HTTP

WebSocket можно подключить к существующему HTTP-серверу:

import http from "node:http";
import express from "express";
import {
  WebSocketServer,
} from "ws";

const app = express();

app.get("/health/live", (
  request,
  response,
) => {
  response.json({
    status: "ok",
  });
});

const httpServer =
  http.createServer(app);

const webSocketServer =
  new WebSocketServer({
    server: httpServer,
    path: "/ws",
  });

webSocketServer.on(
  "connection",
  (socket) => {
    socket.send(
      JSON.stringify({
        type: "connection.ready",
      }),
    );
  },
);

httpServer.listen(8080, () => {
  console.log(
    "Server started on port 8080",
  );
});

Теперь приложение поддерживает:

http://localhost:8080/health/live
ws://localhost:8080/ws

В production:

https://api.example.com/health/live
wss://api.example.com/ws

Разбор входящих сообщений

Клиент может отправить некорректный JSON или данные неожиданной структуры.

function parseMessage(rawData) {
  let message;

  try {
    message = JSON.parse(
      rawData.toString(),
    );
  } catch {
    throw new Error(
      "Некорректный JSON",
    );
  }

  if (
    !message ||
    typeof message !== "object" ||
    Array.isArray(message)
  ) {
    throw new Error(
      "Сообщение должно быть объектом",
    );
  }

  if (
    typeof message.type !== "string"
  ) {
    throw new Error(
      "Тип сообщения обязателен",
    );
  }

  return message;
}

Использование:

socket.on("message", async (rawData) => {
  try {
    const message =
      parseMessage(rawData);

    await handleMessage(
      socket,
      message,
    );
  } catch (error) {
    socket.send(
      JSON.stringify({
        type: "error",
        payload: {
          code:
            "invalid_message",
          message: error.message,
        },
      }),
    );
  }
});

Маршрутизация событий

const messageHandlers = new Map();

messageHandlers.set(
  "chat.message.send",
  handleChatMessage,
);

messageHandlers.set(
  "notification.read",
  handleNotificationRead,
);

async function handleMessage(
  socket,
  message,
) {
  const handler =
    messageHandlers.get(
      message.type,
    );

  if (!handler) {
    socket.send(
      JSON.stringify({
        type: "error",
        requestId: message.id,
        payload: {
          code:
            "unsupported_message_type",
          message:
            "Неизвестный тип сообщения",
        },
      }),
    );

    return;
  }

  await handler({
    socket,
    message,
  });
}

Такой подход лучше большого блока условий:

if (message.type === "...") {
  // ...
} else if (message.type === "...") {
  // ...
}

Функция отправки JSON

import { WebSocket } from "ws";

function sendJson(socket, message) {
  if (
    socket.readyState !==
    WebSocket.OPEN
  ) {
    return false;
  }

  socket.send(
    JSON.stringify(message),
  );

  return true;
}

Использование:

sendJson(socket, {
  type: "notification.created",
  payload: {
    id: "notification-42",
    text: "Заказ отправлен",
  },
});

Перед отправкой следует проверять readyState, потому что соединение уже могло закрыться.


Рассылка сообщений

Рассылка всем клиентам

function broadcast(
  webSocketServer,
  message,
) {
  const serializedMessage =
    JSON.stringify(message);

  for (
    const client
    of webSocketServer.clients
  ) {
    if (
      client.readyState ===
      WebSocket.OPEN
    ) {
      client.send(
        serializedMessage,
      );
    }
  }
}

Использование:

broadcast(
  webSocketServer,
  {
    type: "system.message",
    payload: {
      text:
        "Сервис будет обновлён",
    },
  },
);

Рассылать все события всем клиентам обычно не следует. Клиенты должны получать только данные, на которые у них есть право.


Подписка на комнаты

Комната объединяет клиентов, которым нужны одинаковые события:

room:chat-42
room:user-815
room:project-17

Простое хранилище комнат:

const rooms = new Map();

function joinRoom(
  socket,
  roomId,
) {
  let room = rooms.get(roomId);

  if (!room) {
    room = new Set();
    rooms.set(roomId, room);
  }

  room.add(socket);

  if (!socket.rooms) {
    socket.rooms = new Set();
  }

  socket.rooms.add(roomId);
}

Выход:

function leaveRoom(
  socket,
  roomId,
) {
  const room = rooms.get(roomId);

  if (!room) {
    return;
  }

  room.delete(socket);
  socket.rooms?.delete(roomId);

  if (room.size === 0) {
    rooms.delete(roomId);
  }
}

Рассылка комнате:

function broadcastToRoom(
  roomId,
  message,
) {
  const room = rooms.get(roomId);

  if (!room) {
    return;
  }

  const serializedMessage =
    JSON.stringify(message);

  for (const socket of room) {
    if (
      socket.readyState ===
      WebSocket.OPEN
    ) {
      socket.send(
        serializedMessage,
      );
    }
  }
}

Очистка после отключения:

function leaveAllRooms(socket) {
  if (!socket.rooms) {
    return;
  }

  for (const roomId of socket.rooms) {
    leaveRoom(socket, roomId);
  }
}

socket.on("close", () => {
  leaveAllRooms(socket);
});

Перед добавлением пользователя в комнату сервер обязан проверить право доступа:

const canJoin =
  await chatAuthorization.canJoinRoom({
    userId: socket.user.id,
    roomId,
  });

if (!canJoin) {
  throw new Error(
    "Недостаточно прав",
  );
}

joinRoom(socket, roomId);

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

Соединение нужно связать с пользователем.

Возможные варианты:

Браузерный конструктор WebSocket не позволяет произвольно установить заголовок Authorization.

Нельзя написать:

new WebSocket(
  "wss://api.example.com/ws",
  {
    headers: {
      Authorization:
        "Bearer token",
    },
  },
);

Такой API не поддерживается стандартным браузерным WebSocket.


Браузер автоматически отправляет подходящие cookie во время handshake.

const socket = new WebSocket(
  "wss://api.example.com/ws",
);

Сервер получает cookie в HTTP-запросе обновления протокола.

Концептуальная проверка:

webSocketServer.on(
  "connection",
  async (socket, request) => {
    const sessionId =
      extractSessionId(
        request.headers.cookie,
      );

    const session =
      await sessionRepository.findById(
        sessionId,
      );

    if (!session) {
      socket.close(
        1008,
        "Authentication required",
      );

      return;
    }

    socket.user = {
      id: session.userId,
    };
  },
);

Если используются cookie, необходимо учитывать:


Проверка Origin

WebSocket не использует обычную CORS-проверку так же, как fetch(). Сервер должен самостоятельно проверять Origin.

const allowedOrigins = new Set([
  "https://app.example.com",
]);

function isAllowedOrigin(request) {
  const origin =
    request.headers.origin;

  return (
    typeof origin === "string" &&
    allowedOrigins.has(origin)
  );
}

Если origin не разрешён:

if (!isAllowedOrigin(request)) {
  socket.close(
    1008,
    "Origin is not allowed",
  );

  return;
}

Для более раннего отказа проверку можно выполнить на стадии HTTP upgrade.

Небраузерные клиенты могут не отправлять Origin, поэтому политика должна соответствовать типам разрешённых клиентов.

Проверка Origin не заменяет аутентификацию и авторизацию.


Токен в URL

Технически можно передать токен:

wss://api.example.com/ws?token=secret

Но долгоживущий токен в URL нежелателен. URL может попасть:

Безопаснее использовать короткоживущий одноразовый ticket:

1. Клиент получает ticket через защищённый HTTP endpoint.
2. Ticket действует несколько секунд.
3. Клиент открывает WebSocket с ticket.
4. Сервер использует ticket только один раз.

Получение ticket:

const response = await fetch(
  "/api/ws-ticket",
  {
    method: "POST",
    credentials: "include",
  },
);

const { ticket } =
  await response.json();

const socket = new WebSocket(
  `wss://api.example.com/ws?ticket=${encodeURIComponent(
    ticket,
  )}`,
);

Сервер должен:


Аутентификационное сообщение

Клиент подключается и сразу отправляет токен:

socket.addEventListener(
  "open",
  () => {
    socket.send(
      JSON.stringify({
        type: "auth.authenticate",
        payload: {
          accessToken,
        },
      }),
    );
  },
);

Пока аутентификация не завершена, сервер не должен обрабатывать остальные команды:

if (
  !socket.user &&
  message.type !==
    "auth.authenticate"
) {
  socket.close(
    1008,
    "Authentication required",
  );

  return;
}

Недостаток такого подхода — соединение сначала устанавливается без аутентификации. Нужно ограничивать время ожидания и объём данных.

const authenticationTimeout =
  setTimeout(() => {
    if (!socket.user) {
      socket.close(
        1008,
        "Authentication timeout",
      );
    }
  }, 5000);

authenticationTimeout.unref?.();

После успешной проверки:

socket.user = verifiedUser;
clearTimeout(
  authenticationTimeout,
);

Авторизация каждого действия

Аутентифицированное соединение не означает, что пользователь может выполнять любые операции.

Для каждого сообщения проверяются:

async function handleChatMessage({
  socket,
  message,
}) {
  const { roomId, text } =
    validateChatMessage(
      message.payload,
    );

  const canWrite =
    await chatAuthorization
      .canSendMessage({
        userId: socket.user.id,
        roomId,
      });

  if (!canWrite) {
    sendJson(socket, {
      type: "error",
      requestId: message.id,
      payload: {
        code: "forbidden",
        message:
          "Недостаточно прав",
      },
    });

    return;
  }

  // Сохранение сообщения
}

Нельзя доверять userId, присланному клиентом:

{
  "userId": "admin",
  "text": "Сообщение"
}

Автор сообщения определяется по аутентифицированному соединению:

const authorId =
  socket.user.id;

Heartbeat: Ping и Pong

Соединение может перестать работать без корректного события close:

Поэтому сервер периодически проверяет соединения.

function heartbeat() {
  this.isAlive = true;
}

При подключении:

webSocketServer.on(
  "connection",
  (socket) => {
    socket.isAlive = true;

    socket.on(
      "pong",
      heartbeat,
    );
  },
);

Периодическая проверка:

const heartbeatInterval =
  setInterval(() => {
    for (
      const socket
      of webSocketServer.clients
    ) {
      if (!socket.isAlive) {
        socket.terminate();
        continue;
      }

      socket.isAlive = false;
      socket.ping();
    }
  }, 30_000);

heartbeatInterval.unref?.();

При остановке сервера:

clearInterval(
  heartbeatInterval,
);

Браузерный API не предоставляет JavaScript-коду низкоуровневые методы ping() и событие pong. Браузер обрабатывает протокольные ping/pong на своём уровне.

Если приложению нужен собственный heartbeat, можно использовать сообщения:

{
  "type": "heartbeat"
}

Ответ:

{
  "type": "heartbeat.ack"
}

Закрытие соединения

Клиент может закрыть соединение:

socket.close(
  1000,
  "Page closed",
);

Код 1000 означает нормальное завершение.

Распространённые коды:

Код Значение
1000 Нормальное закрытие
1001 Сторона покидает соединение
1002 Ошибка протокола
1003 Неподдерживаемый тип данных
1008 Нарушение политики
1009 Сообщение слишком большое
1011 Внутренняя ошибка сервера

Некоторые коды зарезервированы и не могут передаваться приложением как обычный close code.

Сервер может закрыть соединение при:

socket.close(
  1008,
  "Session expired",
);

Причина закрытия должна быть короткой и не содержать секреты или внутренние детали.


Ограничение размера сообщений

Сервер должен ограничивать размер входящих сообщений.

const webSocketServer =
  new WebSocketServer({
    server: httpServer,
    path: "/ws",
    maxPayload: 64 * 1024,
  });

В этом примере максимальный размер равен 64 KiB.

Без ограничения клиент может отправлять большие сообщения и расходовать:

Для файлов обычно лучше использовать отдельный HTTP upload, а через WebSocket передавать статус и метаданные.


Rate limiting сообщений

HTTP rate limiter не контролирует сообщения после WebSocket handshake. Для соединений нужен отдельный лимит.

Простой token bucket:

function createMessageRateLimiter({
  capacity,
  refillPerSecond,
}) {
  let tokens = capacity;
  let lastRefillAt = Date.now();

  return function consume() {
    const now = Date.now();

    const elapsedSeconds =
      (now - lastRefillAt) / 1000;

    tokens = Math.min(
      capacity,
      tokens +
        elapsedSeconds *
          refillPerSecond,
    );

    lastRefillAt = now;

    if (tokens < 1) {
      return false;
    }

    tokens -= 1;

    return true;
  };
}

Для каждого соединения:

socket.consumeMessageToken =
  createMessageRateLimiter({
    capacity: 20,
    refillPerSecond: 5,
  });

Проверка:

socket.on("message", (rawData) => {
  if (
    !socket.consumeMessageToken()
  ) {
    sendJson(socket, {
      type: "error",
      payload: {
        code:
          "rate_limit_exceeded",
        message:
          "Слишком много сообщений",
      },
    });

    return;
  }

  // Обработка сообщения
});

В распределённой системе лимиты на пользователя могут храниться централизованно, например в Redis.

Следует ограничивать:


Backpressure

Если сервер отправляет данные быстрее, чем клиент успевает принимать, внутренний буфер начинает расти.

Это называется backpressure.

В ws можно проверять:

socket.bufferedAmount

Пример:

const MAX_BUFFERED_BYTES =
  1024 * 1024;

function safeSend(
  socket,
  message,
) {
  if (
    socket.readyState !==
    WebSocket.OPEN
  ) {
    return false;
  }

  if (
    socket.bufferedAmount >
    MAX_BUFFERED_BYTES
  ) {
    socket.close(
      1008,
      "Client is too slow",
    );

    return false;
  }

  socket.send(
    JSON.stringify(message),
  );

  return true;
}

Вместо немедленного закрытия можно:

Для уведомлений о текущем состоянии часто нет смысла отправлять каждое промежуточное изменение:

status = 10%
status = 11%
status = 12%
...

Можно отправлять обновления реже или заменять старое неотправленное состояние новым.


Клиентская работа с WebSocket

Браузер предоставляет встроенный класс WebSocket.

const socket = new WebSocket(
  "wss://api.example.com/ws",
);

Событие open

socket.addEventListener(
  "open",
  () => {
    console.log(
      "WebSocket connected",
    );

    socket.send(
      JSON.stringify({
        type: "chat.room.join",
        payload: {
          roomId: "room-42",
        },
      }),
    );
  },
);

Отправлять сообщения следует после открытия соединения.


Событие message

socket.addEventListener(
  "message",
  (event) => {
    try {
      const message =
        JSON.parse(event.data);

      handleServerMessage(
        message,
      );
    } catch (error) {
      console.error(
        "Invalid WebSocket message",
        error,
      );
    }
  },
);

Маршрутизация:

function handleServerMessage(message) {
  switch (message.type) {
    case "chat.message.created":
      renderChatMessage(
        message.payload,
      );
      break;

    case "notification.created":
      showNotification(
        message.payload,
      );
      break;

    case "user.presence.changed":
      updatePresence(
        message.payload,
      );
      break;

    case "error":
      showError(
        message.payload.message,
      );
      break;

    default:
      console.warn(
        "Unknown message type",
        message.type,
      );
  }
}

Событие error

socket.addEventListener(
  "error",
  (event) => {
    console.error(
      "WebSocket error",
      event,
    );
  },
);

Браузер намеренно предоставляет ограниченные подробности сетевой ошибки. Основную диагностику следует выполнять через:


Событие close

socket.addEventListener(
  "close",
  (event) => {
    console.log(
      "WebSocket closed",
      {
        code: event.code,
        reason: event.reason,
        wasClean: event.wasClean,
      },
    );
  },
);

Поля:

Поле Назначение
code Код закрытия
reason Краткая причина
wasClean Было ли соединение закрыто корректно

Состояния соединения

WebSocket.CONNECTING
WebSocket.OPEN
WebSocket.CLOSING
WebSocket.CLOSED

Проверка:

if (
  socket.readyState ===
  WebSocket.OPEN
) {
  socket.send(message);
}

Переподключение

Стандартный браузерный WebSocket не переподключается автоматически.

Причины разрыва:

Простой клиент с повторным подключением:

class ReconnectingWebSocket {
  constructor(url) {
    this.url = url;
    this.socket = null;
    this.retryAttempt = 0;
    this.closedManually = false;
  }

  connect() {
    this.socket =
      new WebSocket(this.url);

    this.socket.addEventListener(
      "open",
      () => {
        this.retryAttempt = 0;

        console.log(
          "WebSocket connected",
        );
      },
    );

    this.socket.addEventListener(
      "message",
      (event) => {
        this.handleMessage(event);
      },
    );

    this.socket.addEventListener(
      "close",
      () => {
        if (!this.closedManually) {
          this.scheduleReconnect();
        }
      },
    );
  }

  scheduleReconnect() {
    const baseDelay =
      Math.min(
        1000 *
          2 ** this.retryAttempt,
        30_000,
      );

    const jitter =
      Math.random() * 1000;

    const delay =
      baseDelay + jitter;

    this.retryAttempt += 1;

    setTimeout(() => {
      this.connect();
    }, delay);
  }

  handleMessage(event) {
    try {
      const message =
        JSON.parse(event.data);

      console.log(message);
    } catch (error) {
      console.error(
        "Invalid message",
        error,
      );
    }
  }

  send(message) {
    if (
      this.socket?.readyState !==
      WebSocket.OPEN
    ) {
      return false;
    }

    this.socket.send(
      JSON.stringify(message),
    );

    return true;
  }

  close() {
    this.closedManually = true;

    this.socket?.close(
      1000,
      "Client shutdown",
    );
  }
}

Использование:

const realtime =
  new ReconnectingWebSocket(
    "wss://api.example.com/ws",
  );

realtime.connect();

Exponential backoff

Нельзя переподключаться без задержки:

socket.onclose = () => {
  connect();
};

При падении сервера тысячи клиентов начнут непрерывно подключаться и увеличат нагрузку.

Лучше применять:

1 секунда
2 секунды
4 секунды
8 секунд
...
до максимального интервала

И добавлять случайное отклонение — jitter.

Это распределяет подключения клиентов по времени.


Восстановление состояния после переподключения

Новое соединение не знает состояние старого.

Клиенту может потребоваться повторно:

socket.addEventListener(
  "open",
  () => {
    for (
      const roomId
      of subscribedRooms
    ) {
      socket.send(
        JSON.stringify({
          type:
            "chat.room.join",
          payload: {
            roomId,
          },
        }),
      );
    }
  },
);

WebSocket сам по себе не гарантирует доставку событий, произошедших во время отключения.

Для восстановления можно использовать:


Идентификатор последнего события

Сервер отправляет последовательность:

{
  "type": "chat.message.created",
  "eventId": 1542,
  "payload": {}
}

Клиент сохраняет:

let lastEventId = 1542;

После переподключения:

{
  "type": "connection.resume",
  "payload": {
    "lastEventId": 1542
  }
}

Сервер может отправить пропущенные события, если они сохраняются.

Если история событий недоступна, клиент должен повторно загрузить актуальное состояние через HTTP.


Подтверждение доставки

Вызов:

socket.send(message);

означает только передачу данных WebSocket-реализации. Он не подтверждает, что бизнес-операция успешно выполнена сервером.

Для команды нужен ответ:

Клиент:

{
  "type": "chat.message.send",
  "id": "request-42",
  "payload": {
    "roomId": "room-7",
    "text": "Привет!"
  }
}

Сервер:

{
  "type": "chat.message.created",
  "requestId": "request-42",
  "payload": {
    "messageId": "message-815"
  }
}

Клиент может хранить ожидающие операции:

const pendingRequests =
  new Map();

function sendRequest(
  socket,
  type,
  payload,
  timeoutMs = 5000,
) {
  const id = crypto.randomUUID();

  return new Promise(
    (resolve, reject) => {
      const timeout = setTimeout(
        () => {
          pendingRequests.delete(id);

          reject(
            new Error(
              "WebSocket request timed out",
            ),
          );
        },
        timeoutMs,
      );

      pendingRequests.set(id, {
        resolve,
        reject,
        timeout,
      });

      socket.send(
        JSON.stringify({
          id,
          type,
          payload,
        }),
      );
    },
  );
}

Обработка ответа:

function handleMessage(message) {
  if (
    message.requestId &&
    pendingRequests.has(
      message.requestId,
    )
  ) {
    const pending =
      pendingRequests.get(
        message.requestId,
      );

    clearTimeout(
      pending.timeout,
    );

    pendingRequests.delete(
      message.requestId,
    );

    if (message.type === "error") {
      pending.reject(
        new Error(
          message.payload.message,
        ),
      );
    } else {
      pending.resolve(
        message.payload,
      );
    }

    return;
  }

  handleEvent(message);
}

Идемпотентность сообщений

При переподключении клиент может не знать, обработал ли сервер последнюю команду.

Пример:

Клиент отправил сообщение
Сервер сохранил сообщение
Соединение оборвалось до подтверждения
Клиент отправил сообщение повторно

Без защиты в чате появятся дубликаты.

Клиент может передавать уникальный clientMessageId:

{
  "type": "chat.message.send",
  "id": "request-42",
  "payload": {
    "clientMessageId": "client-message-99",
    "roomId": "room-7",
    "text": "Привет!"
  }
}

Сервер сохраняет уникальный идентификатор и не создаёт второе сообщение при повторе:

const existingMessage =
  await messageRepository
    .findByClientMessageId({
      userId: socket.user.id,
      clientMessageId:
        payload.clientMessageId,
    });

if (existingMessage) {
  sendMessageCreated(
    socket,
    message.id,
    existingMessage,
  );

  return;
}

Уникальность желательно обеспечить и на уровне базы данных.


Реализация через Socket.IO

Socket.IO — библиотека для событийного обмена в реальном времени.

Socket.IO не является просто удобной оболочкой над стандартным WebSocket. У него собственный протокол поверх транспортов.

Поэтому стандартный WebSocket-клиент не может напрямую подключиться к Socket.IO-серверу:

// Не является Socket.IO-клиентом
new WebSocket(
  "wss://example.com/socket.io/",
);

Для Socket.IO используется специальный клиент:

import { io } from "socket.io-client";

Возможности Socket.IO

Socket.IO предоставляет:

ws предоставляет более низкоуровневый WebSocket и требует самостоятельно реализовать большую часть этих возможностей.


Установка

Сервер:

npm install socket.io

Клиент:

npm install socket.io-client

Socket.IO-сервер

import http from "node:http";
import express from "express";
import {
  Server,
} from "socket.io";

const app = express();
const httpServer =
  http.createServer(app);

const io = new Server(
  httpServer,
  {
    cors: {
      origin:
        "https://app.example.com",
      credentials: true,
    },
  },
);

io.on(
  "connection",
  (socket) => {
    console.log(
      "Client connected",
      socket.id,
    );

    socket.emit(
      "connection:ready",
      {
        socketId: socket.id,
      },
    );

    socket.on(
      "chat:message",
      async (
        payload,
        acknowledge,
      ) => {
        try {
          const message =
            await saveMessage(
              payload,
            );

          io
            .to(
              `room:${message.roomId}`,
            )
            .emit(
              "chat:message-created",
              message,
            );

          acknowledge?.({
            ok: true,
            messageId: message.id,
          });
        } catch {
          acknowledge?.({
            ok: false,
            error:
              "message_creation_failed",
          });
        }
      },
    );

    socket.on(
      "disconnect",
      (reason) => {
        console.log(
          "Client disconnected",
          {
            socketId: socket.id,
            reason,
          },
        );
      },
    );
  },
);

httpServer.listen(8080);

Socket.IO-клиент

import { io } from "socket.io-client";

const socket = io(
  "https://api.example.com",
  {
    withCredentials: true,
  },
);

socket.on("connect", () => {
  console.log(
    "Connected",
    socket.id,
  );
});

socket.on(
  "chat:message-created",
  (message) => {
    renderChatMessage(message);
  },
);

socket.on(
  "disconnect",
  (reason) => {
    console.log(
      "Disconnected",
      reason,
    );
  },
);

Отправка с acknowledgement:

socket.emit(
  "chat:message",
  {
    roomId: "room-42",
    text: "Привет!",
  },
  (result) => {
    if (!result.ok) {
      showError(
        "Не удалось отправить сообщение",
      );

      return;
    }

    console.log(
      "Message created",
      result.messageId,
    );
  },
);

Аутентификация Socket.IO

Клиент:

const socket = io(
  "https://api.example.com",
  {
    auth: {
      token: accessToken,
    },
  },
);

Сервер:

io.use(
  async (socket, next) => {
    try {
      const token =
        socket.handshake.auth.token;

      const user =
        await tokenService.verify(
          token,
        );

      socket.data.user = user;

      next();
    } catch {
      next(
        new Error(
          "Authentication failed",
        ),
      );
    }
  },
);

Токен не следует журналировать.

Если токен истекает, необходимо определить:


Rooms в Socket.IO

Подключение к комнате:

socket.on(
  "chat:join",
  async (
    { roomId },
    acknowledge,
  ) => {
    const canJoin =
      await chatAuthorization
        .canJoinRoom({
          userId:
            socket.data.user.id,
          roomId,
        });

    if (!canJoin) {
      acknowledge?.({
        ok: false,
        error: "forbidden",
      });

      return;
    }

    await socket.join(
      `room:${roomId}`,
    );

    acknowledge?.({
      ok: true,
    });
  },
);

Отправка комнате:

io
  .to(`room:${roomId}`)
  .emit(
    "chat:message-created",
    message,
  );

Всем в комнате, кроме отправителя:

socket
  .to(`room:${roomId}`)
  .emit(
    "chat:user-typing",
    {
      userId:
        socket.data.user.id,
    },
  );

Namespace

Namespace создаёт отдельный логический канал:

const adminNamespace =
  io.of("/admin");

adminNamespace.use(
  authenticateAdmin,
);

adminNamespace.on(
  "connection",
  (socket) => {
    // Только административные соединения
  },
);

Клиент:

const adminSocket = io(
  "https://api.example.com/admin",
);

Namespaces полезны для логического разделения, но не заменяют проверку прав.


ws или Socket.IO

Критерий ws Socket.IO
Стандартный WebSocket Да Использует собственный протокол
Низкоуровневый контроль Высокий Ниже
Автопереподключение Реализуется вручную Встроено
Rooms Реализуются вручную Встроены
Acknowledgements Реализуются вручную Встроены
Размер протокола Меньше Больше
Совместимость со стандартным клиентом Да Нужен Socket.IO-клиент
Скорость начала разработки Ниже Обычно выше
Гибкость протокола Высокая Событийная модель библиотеки

ws подходит, если:

Socket.IO подходит, если:


Практическое применение: чат

Типичная архитектура чата:

HTTP:
GET /api/rooms/:roomId/messages
POST /api/attachments

WebSocket:
chat.room.join
chat.message.send
chat.message.created
chat.message.updated
chat.message.deleted
chat.user.typing
user.presence.changed

Историю сообщений удобно загружать через HTTP:

const response = await fetch(
  `/api/rooms/${roomId}/messages?cursor=${cursor}`,
);

const history =
  await response.json();

Новые сообщения доставляются через WebSocket.


Отправка сообщения

Клиент:

function sendChatMessage({
  roomId,
  text,
}) {
  const message = {
    id: crypto.randomUUID(),
    type: "chat.message.send",
    payload: {
      clientMessageId:
        crypto.randomUUID(),
      roomId,
      text,
    },
  };

  socket.send(
    JSON.stringify(message),
  );
}

Сервер:

async function handleChatMessage({
  socket,
  message,
}) {
  const input =
    validateChatMessage(
      message.payload,
    );

  const canWrite =
    await chatAuthorization
      .canSendMessage({
        userId: socket.user.id,
        roomId: input.roomId,
      });

  if (!canWrite) {
    sendJson(socket, {
      type: "error",
      requestId: message.id,
      payload: {
        code: "forbidden",
        message:
          "Отправка сообщения запрещена",
      },
    });

    return;
  }

  const savedMessage =
    await messageRepository.create({
      clientMessageId:
        input.clientMessageId,
      roomId: input.roomId,
      authorId: socket.user.id,
      text: input.text,
    });

  broadcastToRoom(
    input.roomId,
    {
      type:
        "chat.message.created",
      requestId: message.id,
      payload: {
        id: savedMessage.id,
        clientMessageId:
          savedMessage
            .clientMessageId,
        roomId:
          savedMessage.roomId,
        authorId:
          savedMessage.authorId,
        text: savedMessage.text,
        createdAt:
          savedMessage.createdAt,
      },
    },
  );
}

Валидация сообщения

function validateChatMessage(
  payload,
) {
  if (
    !payload ||
    typeof payload !== "object"
  ) {
    throw new Error(
      "Некорректные данные",
    );
  }

  if (
    typeof payload.roomId !==
      "string" ||
    payload.roomId.length > 100
  ) {
    throw new Error(
      "Некорректная комната",
    );
  }

  if (
    typeof payload
      .clientMessageId !== "string" ||
    payload
      .clientMessageId.length > 100
  ) {
    throw new Error(
      "Некорректный идентификатор сообщения",
    );
  }

  if (
    typeof payload.text !==
      "string"
  ) {
    throw new Error(
      "Текст обязателен",
    );
  }

  const text =
    payload.text.trim();

  if (
    text.length < 1 ||
    text.length > 4000
  ) {
    throw new Error(
      "Текст должен содержать от 1 до 4000 символов",
    );
  }

  return {
    roomId: payload.roomId,
    clientMessageId:
      payload.clientMessageId,
    text,
  };
}

На клиенте сообщение следует выводить через textContent:

const messageElement =
  document.createElement("p");

messageElement.textContent =
  message.text;

Нежелательно:

messageElement.innerHTML =
  message.text;

Иначе пользовательское сообщение может стать источником XSS.


Статус набора текста

Клиент:

let typingTimeout;

messageInput.addEventListener(
  "input",
  () => {
    socket.send(
      JSON.stringify({
        type: "chat.typing.start",
        payload: {
          roomId,
        },
      }),
    );

    clearTimeout(
      typingTimeout,
    );

    typingTimeout = setTimeout(
      () => {
        socket.send(
          JSON.stringify({
            type:
              "chat.typing.stop",
            payload: {
              roomId,
            },
          }),
        );
      },
      1500,
    );
  },
);

Событие набора текста обычно:

Сервер:

socket
  .to(`room:${roomId}`)
  .emit(
    "chat:typing",
    {
      userId:
        socket.data.user.id,
      isTyping: true,
    },
  );

Presence

Presence показывает состояние пользователя:

online
offline
away

Нельзя считать пользователя offline сразу после закрытия одного соединения. Один пользователь может открыть:

Полезно учитывать число активных соединений пользователя:

const userConnections =
  new Map();

function addUserConnection(
  userId,
  socket,
) {
  let connections =
    userConnections.get(userId);

  if (!connections) {
    connections = new Set();
    userConnections.set(
      userId,
      connections,
    );
  }

  connections.add(socket);

  return connections.size;
}

function removeUserConnection(
  userId,
  socket,
) {
  const connections =
    userConnections.get(userId);

  if (!connections) {
    return 0;
  }

  connections.delete(socket);

  if (connections.size === 0) {
    userConnections.delete(userId);
    return 0;
  }

  return connections.size;
}

Пользователь становится offline, когда закрыто последнее соединение.

В распределённой системе локальной Map недостаточно. Состояние присутствия нужно координировать между экземплярами, например через Redis с TTL и событиями.


Практическое применение: real-time уведомления

Уведомление обычно возникает в результате операции:

Заказ отправлен
Платёж подтверждён
Пользователь получил сообщение
Отчёт сформирован
Администратор изменил статус

Схема:

Бизнес-операция
      ↓
Сохранение уведомления
      ↓
Публикация события
      ↓
WebSocket Gateway
      ↓
Активные соединения пользователя

Персональная комната пользователя

После аутентификации соединение добавляется в комнату:

const userRoom =
  `user:${socket.data.user.id}`;

await socket.join(userRoom);

Отправка уведомления:

io
  .to(`user:${userId}`)
  .emit(
    "notification:created",
    {
      id: notification.id,
      type: notification.type,
      text: notification.text,
      createdAt:
        notification.createdAt,
    },
  );

Пользователь получит уведомление во всех своих активных вкладках и устройствах.


Сначала сохранить, потом отправить

Надёжнее сначала записать уведомление:

const notification =
  await notificationRepository.create({
    userId,
    type: "order_shipped",
    text: "Заказ отправлен",
  });

И только затем отправить его подключённому клиенту:

io
  .to(`user:${userId}`)
  .emit(
    "notification:created",
    notification,
  );

Если пользователь offline, уведомление останется в базе и будет доступно через HTTP:

GET /api/notifications?unread=true

WebSocket ускоряет доставку, но не должен быть единственным хранилищем уведомления.


Прочтение уведомления

Клиент отправляет:

{
  "type": "notification.read",
  "id": "request-42",
  "payload": {
    "notificationId": "notification-815"
  }
}

Сервер проверяет владельца:

const notification =
  await notificationRepository.findById(
    notificationId,
  );

if (
  !notification ||
  notification.userId !==
    socket.user.id
) {
  sendJson(socket, {
    type: "error",
    requestId: message.id,
    payload: {
      code: "not_found",
      message:
        "Уведомление не найдено",
    },
  });

  return;
}

После обновления событие можно отправить всем соединениям пользователя:

broadcastToUser(
  socket.user.id,
  {
    type:
      "notification.updated",
    payload: {
      id: notification.id,
      readAt:
        new Date().toISOString(),
    },
  },
);

Так прочитанное уведомление обновится во всех вкладках.


Масштабирование WebSocket

При одном экземпляре сервера соединения можно хранить в памяти:

WebSocket Server
├── Client A
├── Client B
└── Client C

При нескольких экземплярах:

Load Balancer
├── Server 1 → Client A
├── Server 2 → Client B
└── Server 3 → Client C

Если Server 1 создаёт событие для Client B, он не может отправить его через локальный список соединений Server 2.

Нужен общий канал событий:

Server 1
   ↓ publish
Redis / message broker
   ↓
Server 2
   ↓
Client B

Redis Pub/Sub для нескольких WS-серверов

Каждый сервер подписывается на канал:

await subscriber.subscribe(
  "realtime.events",
  (rawMessage) => {
    const event =
      JSON.parse(rawMessage);

    deliverToLocalConnections(
      event,
    );
  },
);

Публикация:

await publisher.publish(
  "realtime.events",
  JSON.stringify({
    userId: "user-42",
    type:
      "notification.created",
    payload: notification,
  }),
);

Каждый экземпляр проверяет, есть ли у него соединения нужного пользователя.

Redis Pub/Sub не гарантирует доставку отключённому серверу. Для real-time-сигнала это может быть допустимо, если исходные данные сохранены и клиент может синхронизироваться после переподключения.


Socket.IO Redis Adapter

Для нескольких Socket.IO-серверов можно использовать адаптер, передающий события через Redis.

Концептуальная схема:

Socket.IO Server 1
        ↓
Redis adapter
        ↑
Socket.IO Server 2

После настройки:

io
  .to(`user:${userId}`)
  .emit(
    "notification:created",
    notification,
  );

может доставить событие соединению на другом экземпляре.

Адаптер обеспечивает межсерверную рассылку, но не превращает временное событие в надёжно сохранённое сообщение. Критичные данные всё равно должны храниться отдельно.


Sticky sessions

Балансировщик может направлять одно соединение к одному серверу на всё время его жизни. Для WebSocket это естественно: установленное соединение остаётся привязано к экземпляру.

При некоторых fallback-механизмах и библиотечных протоколах может дополнительно потребоваться sticky session.

Даже при sticky sessions общий брокер всё равно нужен, если события должны доставляться клиентам на разных экземплярах.


Перезапуск сервера

При развёртывании сервер должен:

  1. перестать принимать новые соединения;
  2. сообщить балансировщику о неготовности;
  3. по возможности закрыть соединения корректно;
  4. дать клиентам переподключиться;
  5. завершить процесс через ограниченное время.
async function shutdown() {
  for (
    const socket
    of webSocketServer.clients
  ) {
    socket.close(
      1001,
      "Server restarting",
    );
  }

  webSocketServer.close(() => {
    httpServer.close(() => {
      process.exit(0);
    });
  });
}

Клиенты должны ожидать временные отключения и корректно восстанавливать состояние.


Proxy и WebSocket

Reverse proxy должен поддерживать protocol upgrade.

Концептуальная конфигурация Nginx:

location /ws {
  proxy_pass http://application;

  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
  proxy_set_header Host $host;

  proxy_read_timeout 60s;
}

Без заголовков Upgrade и Connection handshake может не пройти.

Также необходимо учитывать:

Heartbeat должен происходить чаще, чем idle timeout промежуточной инфраструктуры.


Безопасность WebSocket

WebSocket-соединение является длительным каналом доступа, поэтому требует тех же серверных проверок, что и HTTP API.

Основные меры:


Истечение и отзыв доступа

Если токен был действителен при подключении, это не означает, что соединение должно работать бесконечно.

Возможные подходы:

Пример:

const expiresInMs =
  token.expiresAt - Date.now();

const expirationTimer =
  setTimeout(() => {
    socket.close(
      1008,
      "Session expired",
    );
  }, expiresInMs);

expirationTimer.unref?.();

socket.on("close", () => {
  clearTimeout(
    expirationTimer,
  );
});

Защита от XSS в чате

Небезопасно:

messagesContainer.innerHTML += `
  <p>${message.text}</p>
`;

Безопасно:

const paragraph =
  document.createElement("p");

paragraph.textContent =
  message.text;

messagesContainer.append(
  paragraph,
);

Если приложение разрешает форматированный HTML, нужен специализированный HTML-санитайзер с allowlist.


Защита от повторной отправки

Злоумышленник может повторно отправлять одно сообщение.

Полезны:


Логи и метрики WebSocket

Полезные логи:

Соединение установлено
Соединение закрыто
Аутентификация отклонена
Подписка на комнату запрещена
Получено некорректное сообщение
Превышен лимит
Медленный клиент отключён
Ошибка обработки события

Структурированный лог:

logger.info(
  {
    connectionId:
      socket.connectionId,
    userId: socket.user?.id,
    origin:
      request.headers.origin,
  },
  "WebSocket connected",
);

Не следует записывать:


Метрики

Полезные метрики:

websocket_connections_active
websocket_connections_total
websocket_connection_errors_total
websocket_messages_received_total
websocket_messages_sent_total
websocket_message_errors_total
websocket_message_duration_seconds
websocket_reconnections_total
websocket_disconnections_total
websocket_buffered_bytes

Подходящие labels:

event_type
result
close_code
service

Нежелательные labels:

userId
connectionId
messageId
roomId с неограниченным числом значений

Уникальные идентификаторы создают высокую cardinality. Их следует помещать в логи и трейсы.


Health-check

Обычный HTTP health-check может использоваться и для приложения с WebSocket:

app.get(
  "/health/live",
  (request, response) => {
    response.json({
      status: "ok",
    });
  },
);

Readiness должна учитывать способность принимать новые соединения:

app.get(
  "/health/ready",
  (request, response) => {
    if (shuttingDown) {
      return response
        .status(503)
        .json({
          status: "not_ready",
        });
    }

    return response.json({
      status: "ready",
    });
  },
);

Не следует устанавливать отдельное WebSocket-соединение для каждого health-check, если достаточно проверить HTTP endpoint и внутреннее состояние сервера.


Полный минимальный пример на ws

import http from "node:http";
import express from "express";
import {
  WebSocket,
  WebSocketServer,
} from "ws";
import { randomUUID } from "node:crypto";

const app = express();

app.get(
  "/health/live",
  (request, response) => {
    response.json({
      status: "ok",
    });
  },
);

const httpServer =
  http.createServer(app);

const webSocketServer =
  new WebSocketServer({
    server: httpServer,
    path: "/ws",
    maxPayload: 64 * 1024,
  });

const allowedOrigins = new Set([
  "http://localhost:3000",
  "https://app.example.com",
]);

function sendJson(
  socket,
  message,
) {
  if (
    socket.readyState !==
    WebSocket.OPEN
  ) {
    return false;
  }

  socket.send(
    JSON.stringify(message),
  );

  return true;
}

function parseMessage(rawData) {
  let message;

  try {
    message = JSON.parse(
      rawData.toString(),
    );
  } catch {
    throw new Error(
      "Некорректный JSON",
    );
  }

  if (
    !message ||
    typeof message !== "object" ||
    typeof message.type !== "string"
  ) {
    throw new Error(
      "Некорректная структура сообщения",
    );
  }

  return message;
}

webSocketServer.on(
  "connection",
  async (socket, request) => {
    const origin =
      request.headers.origin;

    if (
      !origin ||
      !allowedOrigins.has(origin)
    ) {
      socket.close(
        1008,
        "Origin is not allowed",
      );

      return;
    }

    const user =
      await authenticateConnection(
        request,
      );

    if (!user) {
      socket.close(
        1008,
        "Authentication required",
      );

      return;
    }

    socket.connectionId =
      randomUUID();

    socket.user = user;
    socket.isAlive = true;

    socket.on("pong", () => {
      socket.isAlive = true;
    });

    sendJson(socket, {
      type: "connection.ready",
      payload: {
        connectionId:
          socket.connectionId,
        userId: user.id,
      },
    });

    socket.on(
      "message",
      async (rawData) => {
        try {
          const message =
            parseMessage(rawData);

          await handleMessage({
            socket,
            message,
          });
        } catch (error) {
          sendJson(socket, {
            type: "error",
            payload: {
              code:
                "invalid_message",
              message:
                error.message,
            },
          });
        }
      },
    );

    socket.on("close", () => {
      leaveAllRooms(socket);
    });

    socket.on("error", (error) => {
      console.error(
        "WebSocket error",
        {
          connectionId:
            socket.connectionId,
          error,
        },
      );
    });
  },
);

const heartbeatInterval =
  setInterval(() => {
    for (
      const socket
      of webSocketServer.clients
    ) {
      if (!socket.isAlive) {
        socket.terminate();
        continue;
      }

      socket.isAlive = false;
      socket.ping();
    }
  }, 30_000);

heartbeatInterval.unref?.();

httpServer.listen(
  8080,
  () => {
    console.log(
      "HTTP and WebSocket server started",
    );
  },
);

Функции authenticateConnection(), handleMessage() и управление комнатами должны быть реализованы в соответствии с системой аутентификации и предметной областью приложения.


Частые ошибки

Использование ws:// в production

Нежелательно:

ws://api.example.com/ws

Нужно:

wss://api.example.com/ws

Отсутствие проверки Origin

Сам факт успешной cookie-аутентификации не означает, что соединение было открыто с доверенной страницы.

Сервер должен проверять Origin по точному allowlist.


Доверие данным клиента

Нельзя доверять:

{
  "userId": "admin",
  "role": "admin"
}

Личность и роли определяются сервером по проверенной сессии или токену.


Отсутствие проверки прав на комнату

Нельзя разрешать присоединение к произвольной комнате:

joinRoom(
  socket,
  message.payload.roomId,
);

Сначала требуется серверная авторизация.


Отсутствие heartbeat

Мёртвые соединения могут долго оставаться в памяти и занимать ресурсы.

Нужны ping/pong и очистка неактивных соединений.


Немедленное бесконечное переподключение

Нежелательно:

socket.onclose = connect;

Нужны exponential backoff, максимальная задержка и jitter.


Отсутствие синхронизации после reconnect

События, отправленные во время отключения, не появятся автоматически.

Клиент должен запросить историю или текущее состояние.


WebSocket как единственное хранилище события

Если уведомление или сообщение существует только в отправленном фрейме, отключённый клиент его потеряет.

Критичные данные нужно сначала сохранить.


Использование Socket.IO-клиента с ws-сервером

Протоколы несовместимы:

Стандартный WebSocket клиент ↔ стандартный WebSocket сервер
Socket.IO client            ↔ Socket.IO server

Отправка всем клиентам без фильтрации

Нежелательно:

for (const client of clients) {
  client.send(
    privateNotification,
  );
}

Сообщение должно отправляться только авторизованным получателям.


Неограниченный входящий JSON

Даже валидный JSON может быть огромным.

Необходимо ограничивать maxPayload, длины строк, размеры массивов и частоту сообщений.


Игнорирование backpressure

Медленный клиент может привести к росту буфера и памяти.

Нужно контролировать bufferedAmount и иметь политику для медленных соединений.


Рекомендуемая архитектура

HTTP API
├── аутентификация
├── история сообщений
├── CRUD
└── загрузка файлов

WebSocket Gateway
├── управление соединениями
├── проверка Origin
├── аутентификация
├── heartbeat
├── подписки
├── доставка событий
└── rate limiting

Application Services
├── создание сообщений
├── изменение уведомлений
└── проверка прав

Database
└── постоянные данные

Redis / Message Broker
├── межсерверная доставка
├── presence
└── временные события

WebSocket Gateway не должен содержать всю бизнес-логику. Он адаптирует сообщения к прикладным сценариям:

class ChatGateway {
  constructor({
    sendMessage,
    joinChatRoom,
  }) {
    this.sendMessageUseCase =
      sendMessage;

    this.joinChatRoomUseCase =
      joinChatRoom;
  }

  async handleSendMessage({
    currentUser,
    payload,
  }) {
    return this.sendMessageUseCase
      .execute({
        currentUser,
        roomId: payload.roomId,
        text: payload.text,
        clientMessageId:
          payload.clientMessageId,
      });
  }
}

Это позволяет использовать одну бизнес-логику из разных интерфейсов и тестировать её без настоящего WebSocket.


Краткая памятка

WebSocket:

HTTP handshake
      ↓
101 Switching Protocols
      ↓
Постоянное двунаправленное соединение
      ↓
Text / Binary / Ping / Pong / Close

Адреса:

ws://  — без TLS
wss:// — с TLS

В production:

Использовать wss://

HTTP и WebSocket:

HTTP      — запрос и ответ
WebSocket — двусторонние сообщения в одном соединении

Типичный формат:

{
  "type": "chat.message.send",
  "id": "request-42",
  "payload": {
    "roomId": "room-7",
    "text": "Привет!"
  }
}

Браузерный клиент:

const socket = new WebSocket(
  "wss://api.example.com/ws",
);

socket.addEventListener(
  "open",
  () => {
    console.log("Connected");
  },
);

socket.addEventListener(
  "message",
  (event) => {
    const message =
      JSON.parse(event.data);

    console.log(message);
  },
);

socket.addEventListener(
  "close",
  () => {
    console.log("Disconnected");
  },
);

Сервер на ws:

const webSocketServer =
  new WebSocketServer({
    server: httpServer,
    path: "/ws",
    maxPayload: 64 * 1024,
  });

Основные различия:

ws:
стандартный WebSocket, низкий уровень, больше ручной логики

Socket.IO:
события, rooms, acknowledgements, reconnect,
но собственный протокол и специальный клиент

Для чата:

HTTP      — история и файлы
WebSocket — новые сообщения, typing, presence
Database  — постоянное хранение сообщений

Для уведомлений:

Сначала сохранить уведомление
Затем отправить активным соединениям
После reconnect загрузить пропущенные данные

Основные правила: