Стандарты

Socket.io Event Patterns

Стандарты проектирования Socket.io событий, namespaces и rooms.

Стандарты проектирования Socket.io событий, namespaces и rooms.

Документация:

Подход

  • Типизированные события — интерфейсы для server-to-client и client-to-server
  • Конвенции именования — единый формат entity:action
  • Namespaces — разделение по доменам (orders, chat, notifications)
  • Rooms — группировка клиентов по контексту
  • Централизованная обработка ошибок — единый формат ошибок

Именование событий

Формат именования

ПаттернПримерИспользование
entity:actionorders:newСобытие сущности
entity:action_detailorders:status_changedСобытие с деталями
entity:id:actionorder:123:updatedСобытие конкретного объекта
room:actionroom:joinУправление комнатами
error:typeerror:validationОшибки

Правила именования

// Хорошо — snake_case для составных действий
'orders:status_changed'
'chat:message_sent'
'user:profile_updated'

// Хорошо — простые действия
'orders:new'
'orders:deleted'
'notifications:read'

// Плохо — camelCase
'orders:statusChanged'  // Не используем

// Плохо — слишком длинные
'orders:order_status_has_been_changed'  // Слишком длинно

Категории событий

// Server → Client (уведомления)
'orders:new'              // Новый заказ
'orders:status_changed'   // Изменение статуса
'notifications:new'       // Новое уведомление
'chat:message'            // Новое сообщение

// Client → Server (действия)
'room:join'               // Подписка на комнату
'room:leave'              // Отписка от комнаты
'chat:send_message'       // Отправка сообщения
'typing:start'            // Начало набора
'typing:stop'             // Конец набора

// Bidirectional (синхронизация)
'presence:update'         // Обновление присутствия
'cursor:move'             // Движение курсора (коллаборация)

Namespaces

Когда использовать

СценарийNamespaceОбоснование
Разные домены/orders, /chatИзоляция логики
Разные права/admin, /userРазделение доступа
Разные версии/v1, /v2Обратная совместимость

Структура namespaces

// server/plugins/socket.io.ts
import { Server as SocketIOServer } from 'socket.io'

export default defineNitroPlugin((nitroApp) => {
  const io = new SocketIOServer()

  // Основной namespace для общих событий
  io.on('connection', (socket) => {
    // Общие обработчики
  })

  // Namespace для заказов
  const ordersNsp = io.of('/orders')
  ordersNsp.on('connection', (socket) => {
    // Обработчики заказов
    socket.on('subscribe', (orderId: string) => {
      socket.join(`order:${orderId}`)
    })
  })

  // Namespace для чата
  const chatNsp = io.of('/chat')
  chatNsp.on('connection', (socket) => {
    // Обработчики чата
    socket.on('room:join', (roomId: string) => {
      socket.join(roomId)
    })
  })

  // Namespace для админов
  const adminNsp = io.of('/admin')
  adminNsp.use((socket, next) => {
    // Middleware проверки прав администратора
    const isAdmin = socket.handshake.auth.role === 'admin'
    if (!isAdmin) {
      return next(new Error('Unauthorized'))
    }
    next()
  })
})

Клиентское подключение

// app/composables/useOrdersSocket.ts
import { io, type Socket } from 'socket.io-client'

export function useOrdersSocket() {
  const config = useRuntimeConfig()
  let socket: Socket | null = null

  function connect() {
    if (socket?.connected) return socket

    // Подключение к namespace /orders
    socket = io(`${config.public.socketUrl}/orders`, {
      auth: { token: useUserStore().accessToken }
    })

    return socket
  }

  function subscribeToOrder(orderId: string) {
    connect()
    socket?.emit('subscribe', orderId)
  }

  return { connect, subscribeToOrder }
}

Rooms

Стратегии именования rooms

ПаттернПримерИспользование
entity:idorder:abc123Подписка на конкретный объект
user:iduser:usr_456Персональный канал пользователя
tenant:idtenant:org_789Multi-tenant изоляция
role:namerole:adminГруппировка по роли

Server-side управление

// server/plugins/socket.io.ts
io.on('connection', (socket) => {
  const userId = socket.handshake.auth.userId
  const tenantId = socket.handshake.auth.tenantId

  // Автоматическое присоединение к персональной комнате
  if (userId) {
    socket.join(`user:${userId}`)
  }

  // Multi-tenant изоляция
  if (tenantId) {
    socket.join(`tenant:${tenantId}`)
  }

  // Подписка на сущность
  socket.on('entity:subscribe', (data: { type: string; id: string }) => {
    const roomName = `${data.type}:${data.id}`
    socket.join(roomName)
    socket.emit('entity:subscribed', { room: roomName })
  })

  // Отписка от сущности
  socket.on('entity:unsubscribe', (data: { type: string; id: string }) => {
    const roomName = `${data.type}:${data.id}`
    socket.leave(roomName)
    socket.emit('entity:unsubscribed', { room: roomName })
  })
})

Отправка в rooms из API

// server/utils/socket.ts
export function emitToUser(userId: string, event: string, data: unknown): void {
  const io = getIO()
  io?.to(`user:${userId}`).emit(event, data)
}

export function emitToTenant(tenantId: string, event: string, data: unknown): void {
  const io = getIO()
  io?.to(`tenant:${tenantId}`).emit(event, data)
}

export function emitToEntity(type: string, id: string, event: string, data: unknown): void {
  const io = getIO()
  io?.to(`${type}:${id}`).emit(event, data)
}
// server/api/orders/[id]/status.patch.ts
export default defineEventHandler(async (event) => {
  const orderId = getRouterParam(event, 'id')
  const { status } = await readBody(event)

  const order = await prisma.order.update({
    where: { id: orderId },
    data: { status }
  })

  // Уведомляем подписчиков заказа
  emitToEntity('order', orderId, 'orders:status_changed', {
    orderId,
    status,
    previousStatus: order.status,
    updatedAt: new Date().toISOString()
  })

  // Уведомляем владельца заказа
  emitToUser(order.userId, 'notifications:new', {
    type: 'order_status',
    title: 'Статус заказа изменён',
    message: `Заказ #${orderId} теперь в статусе "${status}"`
  })

  return order
})

Типизация

Определение типов событий

// shared/types/socket-events.ts

// === Server → Client ===
export interface ServerToClientEvents {
  // Заказы
  'orders:new': (order: Order) => void
  'orders:status_changed': (data: OrderStatusChange) => void
  'orders:deleted': (data: { orderId: string }) => void

  // Уведомления
  'notifications:new': (notification: Notification) => void
  'notifications:read': (data: { notificationIds: string[] }) => void

  // Комнаты
  'room:user_joined': (data: RoomUserEvent) => void
  'room:user_left': (data: RoomUserEvent) => void

  // Чат
  'chat:message': (message: ChatMessage) => void
  'chat:typing': (data: TypingEvent) => void

  // Системные
  'entity:subscribed': (data: { room: string }) => void
  'entity:unsubscribed': (data: { room: string }) => void
  'error': (error: SocketError) => void
}

// === Client → Server ===
export interface ClientToServerEvents {
  // Подписки
  'entity:subscribe': (data: { type: string; id: string }) => void
  'entity:unsubscribe': (data: { type: string; id: string }) => void

  // Комнаты
  'room:join': (roomId: string) => void
  'room:leave': (roomId: string) => void

  // Чат
  'chat:send_message': (data: { roomId: string; content: string }) => void
  'chat:typing_start': (roomId: string) => void
  'chat:typing_stop': (roomId: string) => void
}

// === Data Types ===
export interface Order {
  id: string
  status: OrderStatus
  total: number
  userId: string
  createdAt: string
}

export type OrderStatus = 'pending' | 'confirmed' | 'processing' | 'shipped' | 'delivered' | 'cancelled'

export interface OrderStatusChange {
  orderId: string
  status: OrderStatus
  previousStatus: OrderStatus
  updatedAt: string
}

export interface Notification {
  id: string
  type: 'info' | 'warning' | 'error' | 'success'
  title: string
  message: string
  read: boolean
  createdAt: string
}

export interface RoomUserEvent {
  userId: string
  socketId: string
  username?: string
}

export interface ChatMessage {
  id: string
  roomId: string
  userId: string
  content: string
  createdAt: string
}

export interface TypingEvent {
  userId: string
  username: string
  roomId: string
}

export interface SocketError {
  code: string
  message: string
  details?: unknown
}

Типизированный сервер

// 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>()

io.on('connection', (socket) => {
  // socket.emit() теперь типизирован
  socket.emit('notifications:new', {
    id: '1',
    type: 'info',
    title: 'Добро пожаловать',
    message: 'Вы подключены',
    read: false,
    createdAt: new Date().toISOString()
  })

  // socket.on() теперь типизирован
  socket.on('chat:send_message', (data) => {
    // data: { roomId: string; content: string }
    io.to(data.roomId).emit('chat:message', {
      id: generateId(),
      roomId: data.roomId,
      userId: socket.handshake.auth.userId,
      content: data.content,
      createdAt: new Date().toISOString()
    })
  })
})

Типизированный клиент

// app/composables/useTypedSocket.ts
import { io, type Socket } from 'socket.io-client'
import type {
  ServerToClientEvents,
  ClientToServerEvents
} from '~/shared/types/socket-events'

type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>

let socket: TypedSocket | null = null

export function useTypedSocket() {
  const config = useRuntimeConfig()

  function connect(): TypedSocket {
    if (socket?.connected) return socket

    socket = io(config.public.socketUrl, {
      auth: { userId: useUserStore().user?.id }
    }) as TypedSocket

    return socket
  }

  function subscribe<K extends keyof ServerToClientEvents>(
    event: K,
    callback: ServerToClientEvents[K]
  ): () => void {
    const s = connect()
    s.on(event, callback as any)
    return () => s.off(event, callback as any)
  }

  function emit<K extends keyof ClientToServerEvents>(
    event: K,
    ...args: Parameters<ClientToServerEvents[K]>
  ): void {
    connect().emit(event, ...args)
  }

  return { connect, subscribe, emit }
}

Обработка ошибок

Server-side ошибки

// server/plugins/socket.io.ts
io.on('connection', (socket) => {
  // Обработчик с валидацией и ошибками
  socket.on('chat:send_message', (data) => {
    try {
      // Валидация
      if (!data.content?.trim()) {
        socket.emit('error', {
          code: 'VALIDATION_ERROR',
          message: 'Message content is required'
        })
        return
      }

      if (data.content.length > 5000) {
        socket.emit('error', {
          code: 'VALIDATION_ERROR',
          message: 'Message too long (max 5000 characters)'
        })
        return
      }

      // Бизнес-логика
      const message = await saveMessage(data)
      io.to(data.roomId).emit('chat:message', message)

    } catch (error) {
      console.error('[Socket.io] Error in chat:send_message:', error)
      socket.emit('error', {
        code: 'INTERNAL_ERROR',
        message: 'Failed to send message'
      })
    }
  })
})

Client-side обработка

// app/composables/useSocket.ts
export function useSocket() {
  const { subscribe, emit } = useTypedSocket()
  const toast = useToast()

  // Глобальный обработчик ошибок
  onMounted(() => {
    const unsubscribe = subscribe('error', (error) => {
      switch (error.code) {
        case 'VALIDATION_ERROR':
          toast.error(error.message)
          break
        case 'UNAUTHORIZED':
          navigateTo('/login')
          break
        case 'RATE_LIMITED':
          toast.warning('Слишком много запросов. Подождите.')
          break
        default:
          toast.error('Произошла ошибка')
          console.error('[Socket] Error:', error)
      }
    })

    onUnmounted(unsubscribe)
  })

  return { subscribe, emit }
}

Коды ошибок

КодОписаниеДействие клиента
VALIDATION_ERRORОшибка валидации данныхПоказать сообщение
UNAUTHORIZEDНе авторизованРедирект на логин
FORBIDDENНет доступаПоказать сообщение
NOT_FOUNDРесурс не найденПоказать сообщение
RATE_LIMITEDПревышен лимит запросовПоказать предупреждение
INTERNAL_ERRORСерверная ошибкаПоказать общую ошибку

Reconnection и состояние

Обработка переподключения

// app/composables/useSocketConnection.ts
export function useSocketConnection() {
  const socket = useTypedSocket()
  const isConnected = ref(false)
  const reconnectAttempts = ref(0)

  onMounted(() => {
    const s = socket.connect()

    s.on('connect', () => {
      isConnected.value = true
      reconnectAttempts.value = 0

      // Переподписка на комнаты после реконнекта
      resubscribeToRooms()
    })

    s.on('disconnect', (reason) => {
      isConnected.value = false

      if (reason === 'io server disconnect') {
        // Сервер отключил — нужен ручной реконнект
        s.connect()
      }
    })

    s.on('connect_error', () => {
      reconnectAttempts.value++
    })
  })

  function resubscribeToRooms() {
    // Восстанавливаем подписки из store
    const subscriptions = useSubscriptionsStore()
    for (const sub of subscriptions.active) {
      socket.emit('entity:subscribe', sub)
    }
  }

  return { isConnected, reconnectAttempts }
}

Чек-лист

  • События названы в формате entity:action (snake_case)
  • Типы событий определены в shared/types/socket-events.ts
  • Server и client используют типизированные сокеты
  • Rooms именованы как entity:id или user:id
  • Автоматическое присоединение к персональной комнате при подключении
  • Обработка ошибок с кодами и сообщениями
  • Обработка reconnect и восстановление подписок
  • Namespaces используются для разделения доменов (если нужно)
  • Валидация данных на server-side перед broadcast

Связанные стандарты