Стандарты
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:action | orders:new | Событие сущности |
entity:action_detail | orders:status_changed | Событие с деталями |
entity:id:action | order:123:updated | Событие конкретного объекта |
room:action | room:join | Управление комнатами |
error:type | error: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:id | order:abc123 | Подписка на конкретный объект |
user:id | user:usr_456 | Персональный канал пользователя |
tenant:id | tenant:org_789 | Multi-tenant изоляция |
role:name | role: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
Связанные стандарты
- ADR-013: Socket.io Real-time Integration — архитектура интеграции
- standard-backend-api — REST API стандарты
- standard-type-generation — генерация типов
- standard-typescript — TypeScript конвенции