Архитектура

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:actionorders:newСобытие сущности
entity:id:actionorders:123:statusСобытие конкретного объекта
room:actionroom:joinУправление комнатами
notifications:userIdnotifications: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

При горизонтальном масштабировании требуется:

  1. Redis Adapter — для синхронизации событий между инстансами
  2. 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
AblyManaged 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

Связанные решения