FastifySeniorSystem design

Как реализовать поддержку WebSocket в Fastify с помощью @fastify/websocket?

WebSocket в Fastify реализуется через @fastify/websocket: регистрация плагина, объявление роута с { websocket: true }, работа с ws объектом в handler. Поддерживается смешанный режим HTTP+WS на одном роуте через dual handler.

Установка и базовая настройка

Плагин @fastify/websocket основан на библиотеке ws и интегрируется в lifecycle Fastify, включая hooks и plugin encapsulation.

// Установка (в контейнере):
// npm install @fastify/websocket

import Fastify from 'fastify';
import fastifyWebsocket from '@fastify/websocket';

const app = Fastify({ logger: true });

await app.register(fastifyWebsocket, {
  options: {
    maxPayload: 1_048_576, // 1 MB, защита от flood-атак
    verifyClient: (info, cb) => {
      // Проверка origin или auth до upgrade
      const allowed = info.origin === process.env.ALLOWED_ORIGIN;
      cb(allowed, 403, 'Forbidden');
    },
  },
});

Базовый WebSocket роут

app.register(async function wsRoutes(instance) {
  instance.get('/ws', { websocket: true }, (socket, req) => {
    // socket — это ws.WebSocket
    req.log.info('Client connected');

    socket.on('message', (raw) => {
      const message = raw.toString();
      req.log.debug({ message }, 'Received');

      // Echo с обработкой ошибок
      try {
        const parsed = JSON.parse(message);
        socket.send(JSON.stringify({ echo: parsed, ts: Date.now() }));
      } catch {
        socket.send(JSON.stringify({ error: 'Invalid JSON' }));
      }
    });

    socket.on('close', (code, reason) => {
      req.log.info({ code, reason: reason.toString() }, 'Client disconnected');
    });

    socket.on('error', (err) => {
      req.log.error(err, 'WebSocket error');
    });
  });
});

Dual handler: HTTP + WebSocket на одном роуте

app.route({
  method: 'GET',
  url: '/chat',
  handler: async (req, reply) => {
    // Обычный HTTP запрос (например, получение истории)
    return { messages: await getChatHistory() };
  },
  wsHandler: (socket, req) => {
    // WebSocket соединение
    const userId = req.user?.sub ?? 'anonymous';
    chatRoom.join(userId, socket);

    socket.on('message', (raw) => {
      chatRoom.broadcast(userId, raw.toString());
    });

    socket.on('close', () => {
      chatRoom.leave(userId);
    });
  },
});

Аутентификация WebSocket соединений

// JWT из query string (браузеры не поддерживают WS headers)
app.register(async function wsRoutes(instance) {
  // preValidation срабатывает до upgrade
  instance.get(
    '/ws/secure',
    {
      websocket: true,
      preValidation: async (req, reply) => {
        try {
          // Токен передаётся как ?token=...
          const token = (req.query as { token?: string }).token;
          if (!token) throw new Error('Missing token');
          req.user = await instance.jwt.verify(token);
        } catch {
          reply.code(401).send({ error: 'Unauthorized' });
        }
      },
    },
    (socket, req) => {
      socket.send(JSON.stringify({
        type: 'connected',
        userId: req.user.sub,
      }));
    }
  );
});

Broadcast: рассылка всем клиентам

// Доступ к серверу ws для broadcast
import type { WebSocket } from 'ws';

const connectedClients = new Set<WebSocket>();

app.get('/ws/broadcast', { websocket: true }, (socket, req) => {
  connectedClients.add(socket);

  socket.on('close', () => {
    connectedClients.delete(socket);
  });

  socket.on('message', (raw) => {
    const message = raw.toString();
    // Broadcast всем подключённым
    for (const client of connectedClients) {
      if (client.readyState === 1 /* OPEN */) {
        client.send(message);
      }
    }
  });
});

// Broadcast из HTTP endpoint
app.post('/broadcast', async (req, reply) => {
  const { message } = req.body as { message: string };
  for (const client of connectedClients) {
    if (client.readyState === 1) {
      client.send(JSON.stringify({ type: 'broadcast', message }));
    }
  }
  return { sent: connectedClients.size };
});

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

  • WebSocket upgrade не проходит через JSON Schema валидацию body — защищайте через verifyClient или preValidation hook.
  • Браузеры не поддерживают кастомные заголовки при WebSocket handshake — передавайте токен через query string или первое сообщение, а не Authorization header.
  • Память: каждое WebSocket соединение удерживает буферы в ws — при тысячах соединений без maxPayload ограничения возможен OOM.
  • Set<WebSocket> для broadcast не масштабируется горизонтально — используйте Redis Pub/Sub (@fastify/redis + subscribe/publish) для multi-instance.
  • Нет heartbeat по умолчанию — клиенты с нестабильным соединением зависают без ping/pong. Добавьте setInterval с socket.ping() каждые 30s.
  • При graceful shutdown app.close() не дожидается закрытия WebSocket соединений — явно итерируйте и вызывайте socket.terminate() в onClose hook.
  • reply.hijack() в WebSocket handler приводит к ошибке — не вызывайте HTTP reply методы внутри WS handler.
  • nginx как reverse proxy требует конфигурации proxy_http_version 1.1 и proxy_set_header Upgrade $http_upgrade — без этого WebSocket соединение не устанавливается.

Common mistakes

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

What the interviewer is testing

  • Может объяснить WebSocket через @fastify/websocket на примере кода.
  • Называет ключевые API: @fastify/websocket.
  • Использует точные API Fastify, а не вымышленные hooks/decorators/methods.
  • Видит production-риски: безопасность, отказоустойчивость, логирование и тесты.

Sources

Related topics