WebSocket на TypeScript: продвинутая разработка real-time приложений

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

WebSocket на TypeScript: Строим продвинутые real-time приложения

Real-time приложения стали неотъемлемой частью современного веба. От чатов и уведомлений до онлайн-игр и торговых платформ — технология WebSocket обеспечивает постоянное двустороннее соединение между клиентом и сервером. На продвинутом уровне разработка таких систем требует не только понимания протокола, но и умения строить отказоустойчивые, масштабируемые архитектуры. В этой статье мы разберем, как создавать production-grade WebSocket приложения на TypeScript, используя лучшие практики и паттерны.

1. Архитектура и кластеризация WebSocket сервера

Одиночный сервер WebSocket не выдержит нагрузки реального проекта. Ключевая задача — организовать кластеризацию и обмен сообщениями между инстансами. Рассмотрим архитектуру с использованием ws библиотеки и шины событий Redis.

Установим зависимости:

npm install ws redis ioredis @types/ws @types/ioredis

Создадим базовый кластеризованный сервер:

import { WebSocketServer, WebSocket } from 'ws';
import Redis from 'ioredis';

const redisPub = new Redis();
const redisSub = new Redis();

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

// Канал для межсерверной коммуникации
const CHANNEL = 'ws:messages';

redisSub.subscribe(CHANNEL, (err, count) => {
  if (err) console.error('Ошибка подписки:', err);
  console.log(`Подписан на ${count} каналов`);
});

// Получение сообщений из других инстансов
redisSub.on('message', (channel, message) => {
  const parsed = JSON.parse(message);
  wss.clients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(parsed.data);
    }
  });
});

wss.on('connection', (ws) => {
  console.log('Новое соединение');

  ws.on('message', (data) => {
    const message = data.toString();
    // Публикуем в Redis для всех инстансов
    redisPub.publish(CHANNEL, JSON.stringify({ data: message }));
  });
});

Преимущества:

  • Горизонтальное масштабирование — добавляйте сколько угодно серверов
  • Отказоустойчивость — при падении одного инстанса другие продолжают работу
  • Единая шина сообщений через Redis Pub/Sub

2. Аутентификация и управление сессиями

В production-приложениях нельзя доверять неаутентифицированным соединениям. Реализуем проверку токенов через JWT при установке соединения.

Установим jsonwebtoken:

npm install jsonwebtoken @types/jsonwebtoken

Создадим middleware для аутентификации WebSocket:

import { IncomingMessage } from 'http';
import jwt from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';

interface AuthenticatedSocket extends WebSocket {
  userId?: string;
}

function authenticate(ws: AuthenticatedSocket, req: IncomingMessage): boolean {
  const url = new URL(req.url || '', 'http://localhost');
  const token = url.searchParams.get('token');

  if (!token) {
    ws.close(4001, 'Токен не предоставлен');
    return false;
  }

  try {
    const decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
    ws.userId = decoded.userId;
    console.log(`Пользователь ${decoded.userId} аутентифицирован`);
    return true;
  } catch (err) {
    ws.close(4002, 'Невалидный токен');
    return false;
  }
}

// Использование
wss.on('connection', (ws: AuthenticatedSocket, req) => {
  if (!authenticate(ws, req)) return;

  ws.on('message', (data) => {
    // Теперь мы знаем userId
    console.log(`Сообщение от ${ws.userId}: ${data}`);
  });
});

Рекомендации:

  • Передавайте токен через query-параметр или заголовки при установке соединения
  • Используйте короткоживущие access-токены и refresh-токены
  • Храните сессии в Redis для быстрого доступа

3. Продвинутая обработка сообщений и комнаты

Для чатов, уведомлений и коллаборативных инструментов необходима поддержка комнат (каналов). Реализуем систему подписки на каналы.

import { WebSocket } from 'ws';

interface RoomSocket extends WebSocket {
  rooms: Set<string>;
  userId: string;
}

class RoomManager {
  private rooms: Map<string, Set<RoomSocket>> = new Map();
  private userRooms: Map<string, Set<string>> = new Map();

  joinRoom(ws: RoomSocket, roomName: string): void {
    ws.rooms.add(roomName);

    if (!this.rooms.has(roomName)) {
      this.rooms.set(roomName, new Set());
    }
    this.rooms.get(roomName)!.add(ws);

    if (!this.userRooms.has(ws.userId)) {
      this.userRooms.set(ws.userId, new Set());
    }
    this.userRooms.get(ws.userId)!.add(roomName);

    ws.send(JSON.stringify({ type: 'joined', room: roomName }));
  }

  leaveRoom(ws: RoomSocket, roomName: string): void {
    ws.rooms.delete(roomName);
    this.rooms.get(roomName)?.delete(ws);
    this.userRooms.get(ws.userId)?.delete(roomName);
  }

  broadcast(roomName: string, message: string, exclude?: RoomSocket): void {
    const room = this.rooms.get(roomName);
    if (!room) return;

    room.forEach((client) => {
      if (client !== exclude && client.readyState === WebSocket.OPEN) {
        client.send(message);
      }
    });
  }

  removeUser(ws: RoomSocket): void {
    ws.rooms.forEach((room) => this.leaveRoom(ws, room));
  }
}

const roomManager = new RoomManager();

wss.on('connection', (ws: RoomSocket) => {
  ws.rooms = new Set();

  ws.on('message', (data) => {
    const parsed = JSON.parse(data.toString());

    switch (parsed.action) {
      case 'join':
        roomManager.joinRoom(ws, parsed.room);
        break;
      case 'leave':
        roomManager.leaveRoom(ws, parsed.room);
        break;
      case 'message':
        roomManager.broadcast(parsed.room, data, ws);
        break;
    }
  });

  ws.on('close', () => {
    roomManager.removeUser(ws);
  });
});

Ключевые возможности: