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илиpreValidationhook. - Браузеры не поддерживают кастомные заголовки при WebSocket handshake — передавайте токен через query string или первое сообщение, а не
Authorizationheader. - Память: каждое 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()вonClosehook. 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-риски: безопасность, отказоустойчивость, логирование и тесты.