Архитектура
Socket.io Real-time Integration с Nuxt Server
Контекст
Платформе требуются real-time коммуникации для:
- Уведомлений о новых заказах и изменениях статусов
- Мгновенных сообщений между пользователями
- Синхронизации данных между вкладками браузера
- Live-обновлений дашбордов и отчётов
Необходимо выбрать решение, которое:
- Интегрируется с Nuxt 4 Server (Nitro)
- Поддерживает комнаты (rooms) и пространства имён (namespaces)
- Обеспечивает автоматический реконнект
- Работает в self-hosted инфраструктуре
Решение
Использовать Socket.io с интеграцией через Nitro plugin и engine.io binding.
Архитектура интеграции
┌─────────────────────────────────────────────────────────────────┐
│ Nuxt Server (Nitro) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ REST API │ │ Socket.io │ │ Server Plugins │ │
│ │ /api/* │ │ Server │ │ socket.io.ts │ │
│ └─────────────┘ └──────┬──────┘ └─────────────────────┘ │
│ │ │
│ ┌───────┴───────┐ │
│ │ engine.io │ │
│ │ binding │ │
│ └───────┬───────┘ │
│ │ │
│ ┌─────────────────────────┴───────────────────────────────┐ │
│ │ Nitro Router /socket.io/ │ │
│ │ (HTTP upgrade → WebSocket) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Клиенты │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Browser │ │ Mobile │ │ Browser │ │
│ │ socket.io │ │ socket.io │ │ socket.io │ │
│ │ client │ │ client │ │ client │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Конфигурация Nuxt
Включение WebSocket в Nitro
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
experimental: {
// ОБЯЗАТЕЛЬНО для Socket.io
websocket: true
}
},
runtimeConfig: {
public: {
// URL для клиентского подключения
socketUrl: process.env.NUXT_PUBLIC_SOCKET_URL || ''
}
},
compatibilityDate: '2025-01-21'
})
Server Plugin
Интеграция через engine.io binding
Официальная документация: https://socket.io/how-to/use-with-nuxt
// server/plugins/socket.io.ts
import { Server as SocketIOServer } from 'socket.io'
import { Server as Engine } from 'engine.io'
// Singleton для предотвращения множественных подключений при hot reload
let io: SocketIOServer | null = null
export default defineNitroPlugin((nitroApp) => {
// Предотвращаем повторную инициализацию при hot reload
if (io) {
console.log('[Socket.io] Already initialized, skipping...')
return
}
// Создаём engine.io сервер
const engine = new Engine()
// Создаём Socket.io сервер и привязываем к engine
io = new SocketIOServer()
io.bind(engine)
// Регистрация обработчиков событий
io.on('connection', (socket) => {
console.log(`[Socket.io] Client connected: ${socket.id}`)
// Аутентификация через auth payload
const userId = socket.handshake.auth.userId
if (userId) {
// Присоединяем к персональной комнате
socket.join(`user:${userId}`)
}
// Пример: подписка на комнату
socket.on('room:join', (roomId: string) => {
socket.join(roomId)
socket.to(roomId).emit('room:user_joined', {
userId,
socketId: socket.id
})
})
// Пример: отправка сообщения в комнату
socket.on('room:message', (data: { roomId: string; message: string }) => {
io!.to(data.roomId).emit('room:message', {
userId,
message: data.message,
timestamp: new Date().toISOString()
})
})
socket.on('disconnect', (reason) => {
console.log(`[Socket.io] Client disconnected: ${socket.id}, reason: ${reason}`)
})
})
// Регистрируем маршрут /socket.io/ в Nitro
nitroApp.router.use('/socket.io/', defineEventHandler({
handler(event) {
// Обрабатываем HTTP запросы (polling)
engine.handleRequest(event.node.req, event.node.res)
event._handled = true
},
websocket: {
open(peer) {
// @ts-expect-error private method
engine.onWebSocket(peer.request, peer.request.socket, peer.websocket)
}
}
}))
console.log('[Socket.io] Server initialized on /socket.io/')
})
Утилита для доступа к io из API-эндпоинтов
// server/utils/socket.ts
import type { Server as SocketIOServer } from 'socket.io'
// Глобальная ссылка на Socket.io сервер
declare global {
// eslint-disable-next-line no-var
var __socketIO: SocketIOServer | undefined
}
export function getIO(): SocketIOServer | null {
return global.__socketIO || null
}
export function setIO(io: SocketIOServer): void {
global.__socketIO = io
}
// Хелпер для отправки события конкретному пользователю
export function emitToUser(userId: string, event: string, data: unknown): void {
const io = getIO()
if (io) {
io.to(`user:${userId}`).emit(event, data)
}
}
// Хелпер для broadcast в комнату
export function emitToRoom(roomId: string, event: string, data: unknown): void {
const io = getIO()
if (io) {
io.to(roomId).emit(event, data)
}
}
Client Integration
Composable для Vue компонентов
// app/composables/useSocketIO.ts
import { io, type Socket } from 'socket.io-client'
// Singleton для предотвращения множественных подключений
let socket: Socket | null = null
export function useSocketIO() {
const config = useRuntimeConfig()
const userStore = useUserStore()
function connect(): Socket {
if (socket?.connected) {
return socket
}
// Определяем URL для подключения
const socketUrl = config.public.socketUrl || window.location.origin
socket = io(socketUrl, {
// Передаём данные аутентификации
auth: {
userId: userStore.user?.id,
token: userStore.accessToken
},
// Транспорты: WebSocket предпочтительнее, polling как fallback
transports: ['websocket', 'polling'],
// Автоматический реконнект
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000
})
socket.on('connect', () => {
console.log('[Socket.io] Connected:', socket?.id)
})
socket.on('connect_error', (error) => {
console.error('[Socket.io] Connection error:', error.message)
})
socket.on('disconnect', (reason) => {
console.log('[Socket.io] Disconnected:', reason)
})
return socket
}
function disconnect(): void {
if (socket) {
socket.disconnect()
socket = null
}
}
function subscribe<T = unknown>(
event: string,
callback: (data: T) => void
): () => void {
const s = connect()
s.on(event, callback)
// Возвращаем функцию отписки
return () => {
s.off(event, callback)
}
}
function emit<T = unknown>(event: string, data: T): void {
const s = connect()
s.emit(event, data)
}
function joinRoom(roomId: string): void {
emit('room:join', roomId)
}
function leaveRoom(roomId: string): void {
emit('room:leave', roomId)
}
return {
connect,
disconnect,
subscribe,
emit,
joinRoom,
leaveRoom,
// Expose socket для продвинутых сценариев
get socket() {
return socket
}
}
}
Использование в компонентах
<script setup lang="ts">
interface OrderNotification {
orderId: string
status: string
message: string
}
const { subscribe, disconnect } = useSocketIO()
// Подписка на события при монтировании
onMounted(() => {
const unsubscribeOrders = subscribe<OrderNotification>(
'orders:status_changed',
(data) => {
toast.info(`Заказ ${data.orderId}: ${data.message}`)
}
)
const unsubscribeNotifications = subscribe(
'notifications:new',
(notification) => {
notificationsStore.add(notification)
}
)
// Отписка при размонтировании
onUnmounted(() => {
unsubscribeOrders()
unsubscribeNotifications()
})
})
// Отключение при закрытии страницы
onBeforeUnmount(() => {
disconnect()
})
</script>
Типизация событий
Определение типов для событий
// shared/types/socket-events.ts
// События от сервера к клиенту
export interface ServerToClientEvents {
'orders:new': (order: Order) => void
'orders:status_changed': (data: OrderStatusChange) => void
'notifications:new': (notification: Notification) => void
'room:user_joined': (data: { userId: string; socketId: string }) => void
'room:user_left': (data: { userId: string; socketId: string }) => void
'room:message': (data: RoomMessage) => void
}
// События от клиента к серверу
export interface ClientToServerEvents {
'room:join': (roomId: string) => void
'room:leave': (roomId: string) => void
'room:message': (data: { roomId: string; message: string }) => void
}
// Типы данных
export interface Order {
id: string
status: string
total: number
createdAt: string
}
export interface OrderStatusChange {
orderId: string
status: string
previousStatus: string
message: string
}
export interface Notification {
id: string
type: 'info' | 'warning' | 'error'
title: string
message: string
createdAt: string
}
export interface RoomMessage {
userId: string
message: string
timestamp: string
}
Типизированный сервер
// server/plugins/socket.io.ts
import { Server as SocketIOServer } from 'socket.io'
import type {
ServerToClientEvents,
ClientToServerEvents
} from '~/shared/types/socket-events'
// Типизированный сервер
const io = new SocketIOServer<ClientToServerEvents, ServerToClientEvents>()
Конвенции именования событий
| Паттерн | Пример | Описание |
|---|---|---|
entity:action | orders:new | Событие сущности |
entity:id:action | orders:123:status | Событие конкретного объекта |
room:action | room:join | Управление комнатами |
notifications:userId | notifications:user_123 | Персональные уведомления |
Ограничения и требования к инфраструктуре
Socket.io НЕ совместим с Serverless
| Платформа | Совместимость | Причина |
|---|---|---|
| Vercel | Нет | Функции не держат постоянные соединения |
| Netlify Functions | Нет | Нет поддержки WebSocket |
| Cloudflare Workers | Нет | Ограничения runtime |
| AWS Lambda | Нет | Timeout и cold starts |
Рекомендуемые платформы
| Платформа | Совместимость | Примечание |
|---|---|---|
| VPS (PM2) | Полная | Рекомендуется |
| Docker | Полная | С node:lts образом |
| Railway | Полная | Автоопределение |
| Digital Ocean App | Полная | Node.js preset |
| Kubernetes | Полная | Со sticky sessions |
Multi-instance deployment
При горизонтальном масштабировании требуется:
- Redis Adapter — для синхронизации событий между инстансами
- Sticky Sessions — для маршрутизации клиента к одному инстансу
// server/plugins/socket.io.ts (multi-instance)
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))
Примечание: Redis adapter откладывается до необходимости масштабирования. На начальном этапе достаточно single-instance.
Альтернативы
| Решение | Плюсы | Минусы |
|---|---|---|
| Socket.io (выбрано) | Rooms, namespaces, auto-reconnect, fallback транспорты | Требует persistent server |
| Native WebSocket | Минимальный overhead, стандарт | Нет rooms, нет auto-reconnect |
| Ably | Managed service, масштабируемость | Платный, зависимость от провайдера |
| Pusher | Простая интеграция | Платный, ограничения |
| Server-Sent Events | Простота, HTTP/2 | Только server→client |
Последствия
Положительные
- Self-hosted решение без зависимости от внешних сервисов
- Встроенная поддержка комнат и namespaces
- Автоматический реконнект и fallback на polling
- Полная type-safety с TypeScript
- Бесплатно (open source)
Отрицательные
- Требуется persistent server (не serverless)
- Необходима собственная инфраструктура
- При масштабировании нужен Redis adapter
- Более сложная настройка по сравнению с managed services
Связанные решения
- ADR-012: Nuxt 4 Server Backend — серверная архитектура
- Standard: Socket.io Patterns — стандарты разработки