import { describeUnknown } from '@/lib/http'; import type { Channel, Role, UserStatus } from './types'; /** * Клиент шлюза (WebSocket) Фазы 1. * * Контракт: сервер присылает HELLO (op 10) с интервалом heartbeat, клиент * отвечает IDENTIFY (op 2, пустой токен — браузер аутентифицируется cookie * на рукопожатии), затем получает READY/RESUMED. Разрыв соединения лечится * экспоненциальной задержкой и RESUME (op 3) с последним полученным `s`. * * Модуль намеренно не зависит от React и Zustand: он принимает колбэки, * поэтому его можно проверить юнит-тестом с подставным WebSocket. */ export const GatewayOp = { DISPATCH: 0, HEARTBEAT: 1, IDENTIFY: 2, RESUME: 3, INVALID_SESSION: 4, HELLO: 10, HEARTBEAT_ACK: 11, RECONNECT: 12, } as const; export const GATEWAY_CLOSE_INVALID_SESSION = 4000; export interface GatewayHello { heartbeat_interval_ms: number; session_id: string; } /** Сервер в снапшоте READY: каналы/роли/права приходят сразу. */ export interface GatewayGuild { id: string; name: string; owner_id: string; is_main: boolean; icon_file_id?: string; channels: Channel[]; roles: Role[]; member_ids: string[]; my_role_ids: string[]; my_nickname?: string; my_permissions?: string[]; } export interface GatewayUser { id: string; username: string; display_name: string; avatar_file_id?: string; is_instance_admin: boolean; badges: string[]; status?: UserStatus; custom_status?: string; custom_status_emoji?: string; } export interface GatewaySnapshot { user: GatewayUser; guilds: GatewayGuild[]; dm_channels: Channel[]; read_states: unknown[]; session_id: string; heartbeat_interval_ms: number; } export interface GatewayDispatch { op: 0; t: string; s: number; d: unknown; } export type GatewayStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected'; export interface GatewayHandlers { /** Любое событие DISPATCH (READY, RESUMED и события гильдий/каналов). */ onDispatch?: (event: GatewayDispatch) => void; onStatus?: (status: GatewayStatus) => void; /** Сессия недействительна — нужно разлогинить пользователя. */ onInvalidSession?: () => void; /** RESUMED без снапшота: данные нужно перечитать по REST. */ onResumeIncomplete?: () => void; } export interface GatewayLogger { warn: (message: string) => void; error: (message: string) => void; } export interface GatewayConnectionOptions { url: string; handlers?: GatewayHandlers; /** Инъекция для тестов; по умолчанию берётся глобальный WebSocket. */ socketFactory?: (url: string) => WebSocket; logger?: GatewayLogger; /** Базовая задержка переподключения; в тестах её уменьшают. */ baseBackoffMs?: number; /** Сколько ждать HELLO после открытия сокета. */ helloTimeoutMs?: number; } const DEFAULT_HEARTBEAT_MS = 45_000; const MAX_BACKOFF_MS = 30_000; const BASE_BACKOFF_MS = 1_000; const HELLO_TIMEOUT_MS = 15_000; interface GatewayPacket { op: number; t?: string; s?: number; d?: unknown; } function isGatewayPacket(value: unknown): value is GatewayPacket { return ( typeof value === 'object' && value !== null && typeof (value as { op?: unknown }).op === 'number' ); } function asRecord(value: unknown): Record | null { return typeof value === 'object' && value !== null ? (value as Record) : null; } function asString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } function asNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } function asStringArray(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; } function parseChannel(value: unknown): Channel | null { const record = asRecord(value); if (record === null) { return null; } const id = asString(record['id']); const name = asString(record['name']); const type = asString(record['type']); if (id === undefined || name === undefined) { return null; } if (type !== 'text' && type !== 'voice' && type !== 'category') { return null; } const channel: Channel = { id, name, type, position: asNumber(record['position']) ?? 0, }; const guildId = asString(record['guild_id']); const parentId = asString(record['parent_id']); const userLimit = asNumber(record['user_limit']); const slowmode = asNumber(record['slowmode_seconds']); if (guildId !== undefined) { channel.guild_id = guildId; } if (parentId !== undefined) { channel.parent_id = parentId; } if (userLimit !== undefined) { channel.user_limit = userLimit; } if (slowmode !== undefined) { channel.slowmode_seconds = slowmode; } if (typeof record['can_send'] === 'boolean') { channel.can_send = record['can_send']; } if (typeof record['can_connect'] === 'boolean') { channel.can_connect = record['can_connect']; } if (typeof record['can_view'] === 'boolean') { channel.can_view = record['can_view']; } return channel; } function parseRole(value: unknown): Role | null { const record = asRecord(value); if (record === null) { return null; } const id = asString(record['id']); const name = asString(record['name']); if (id === undefined || name === undefined) { return null; } const permissions = record['permissions']; return { id, name, color: asNumber(record['color']) ?? 0, position: asNumber(record['position']) ?? 0, permissions: typeof permissions === 'string' ? permissions : typeof permissions === 'number' ? String(permissions) : '0', is_default: record['is_default'] === true, hoist: record['hoist'] === true, mentionable: record['mentionable'] === true, }; } function parseGatewayUser(value: unknown): GatewayUser | null { const record = asRecord(value); if (record === null) { return null; } const id = asString(record['id']); const username = asString(record['username']); if (id === undefined || username === undefined) { return null; } const user: GatewayUser = { id, username, display_name: asString(record['display_name']) ?? username, is_instance_admin: record['is_instance_admin'] === true, badges: asStringArray(record['badges']), }; const avatar = asString(record['avatar_file_id']); const status = asString(record['status']); const customStatus = asString(record['custom_status']); const customEmoji = asString(record['custom_status_emoji']); if (avatar !== undefined) { user.avatar_file_id = avatar; } if (status !== undefined) { user.status = status as UserStatus; } if (customStatus !== undefined) { user.custom_status = customStatus; } if (customEmoji !== undefined) { user.custom_status_emoji = customEmoji; } return user; } function parseGuild(value: unknown): GatewayGuild | null { const record = asRecord(value); if (record === null) { return null; } const id = asString(record['id']); const name = asString(record['name']); if (id === undefined || name === undefined) { return null; } const channels = Array.isArray(record['channels']) ? record['channels'].map(parseChannel).filter((item): item is Channel => item !== null) : []; const roles = Array.isArray(record['roles']) ? record['roles'].map(parseRole).filter((item): item is Role => item !== null) : []; const guild: GatewayGuild = { id, name, owner_id: asString(record['owner_id']) ?? '', is_main: record['is_main'] === true, channels, roles, member_ids: asStringArray(record['member_ids']), my_role_ids: asStringArray(record['my_role_ids']), my_permissions: asStringArray(record['my_permissions']), }; const icon = asString(record['icon_file_id']); const nickname = asString(record['my_nickname']); if (icon !== undefined) { guild.icon_file_id = icon; } if (nickname !== undefined) { guild.my_nickname = nickname; } return guild; } /** Разбирает снапшот READY, отбрасывая всё, что не совпало с контрактом. */ export function parseGatewaySnapshot(value: unknown): GatewaySnapshot | null { const record = asRecord(value); if (record === null) { return null; } const user = parseGatewayUser(record['user']); if (user === null) { return null; } const guilds = Array.isArray(record['guilds']) ? record['guilds'].map(parseGuild).filter((item): item is GatewayGuild => item !== null) : []; const dmChannels = Array.isArray(record['dm_channels']) ? record['dm_channels'].map(parseChannel).filter((item): item is Channel => item !== null) : []; return { user, guilds, dm_channels: dmChannels, read_states: Array.isArray(record['read_states']) ? record['read_states'] : [], session_id: asString(record['session_id']) ?? '', heartbeat_interval_ms: asNumber(record['heartbeat_interval_ms']) ?? DEFAULT_HEARTBEAT_MS, }; } export function gatewayUrl(): string { if (typeof window === 'undefined') { return '/gateway'; } const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${window.location.host}/gateway`; } export type GatewaySend = (packet: { op: number; d: unknown }) => void; export class GatewayConnection { private readonly url: string; private readonly handlers: GatewayHandlers; private readonly socketFactory: (url: string) => WebSocket; private readonly logger: GatewayLogger; private readonly baseBackoffMs: number; private readonly helloTimeoutMs: number; private socket: WebSocket | null = null; private status: GatewayStatus = 'idle'; private heartbeatTimer: ReturnType | null = null; private helloTimeout: ReturnType | null = null; private reconnectTimer: ReturnType | null = null; private heartbeatIntervalMs = DEFAULT_HEARTBEAT_MS; private awaitingAck = false; private attempt = 0; private sessionId: string | null = null; private lastSeq = 0; private closedByUser = false; constructor(options: GatewayConnectionOptions) { this.url = options.url; this.handlers = options.handlers ?? {}; this.logger = options.logger ?? { warn: () => undefined, error: () => undefined }; this.baseBackoffMs = options.baseBackoffMs ?? BASE_BACKOFF_MS; this.helloTimeoutMs = options.helloTimeoutMs ?? HELLO_TIMEOUT_MS; this.socketFactory = options.socketFactory ?? ((url) => { if (typeof WebSocket === 'undefined') { throw new Error('WebSocket is not available in this environment'); } return new WebSocket(url); }); } getStatus(): GatewayStatus { return this.status; } getSessionId(): string | null { return this.sessionId; } getLastSeq(): number { return this.lastSeq; } connect(): void { this.closedByUser = false; if (this.socket !== null) { return; } this.open(undefined); } /** Плановое закрытие: без переподключения. */ close(): void { this.closedByUser = true; if (this.reconnectTimer !== null) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } this.stopHeartbeat(); const socket = this.socket; this.socket = null; if (socket !== null) { socket.onopen = null; socket.onmessage = null; socket.onclose = null; socket.onerror = null; try { socket.close(); } catch { // Сокет мог уже закрыться — это не ошибка. } } this.setStatus('disconnected'); } private open(reason: unknown): void { this.setStatus(this.attempt === 0 ? 'connecting' : 'reconnecting'); let socket: WebSocket; try { socket = this.socketFactory(this.url); } catch (error) { this.logger.warn(`gateway: cannot open socket (${describeUnknown(error)})`); this.scheduleReconnect(); return; } this.socket = socket; if (reason !== undefined) { this.logger.warn(`gateway: reconnecting (${describeUnknown(reason)})`); } socket.onopen = () => { // Ждём HELLO; если его нет — соединение считаем нерабочим. this.helloTimeout = setTimeout(() => { this.logger.warn('gateway: HELLO timeout'); this.dropSocket(); }, this.helloTimeoutMs); }; socket.onmessage = (event: MessageEvent) => { this.handleMessage(event.data); }; socket.onerror = () => { this.logger.warn('gateway: socket error'); }; socket.onclose = (event: CloseEvent) => { const wasCurrent = this.socket === socket; this.socket = null; this.stopHeartbeat(); if (!wasCurrent || this.closedByUser) { return; } if (event.code === GATEWAY_CLOSE_INVALID_SESSION) { this.setStatus('disconnected'); this.handlers.onInvalidSession?.(); return; } this.scheduleReconnect(); }; } private dropSocket(): void { const socket = this.socket; this.socket = null; this.stopHeartbeat(); if (socket !== null) { socket.onclose = null; try { socket.close(); } catch { // Игнорируем: сокет уже не нужен. } } this.scheduleReconnect(); } private scheduleReconnect(): void { if (this.closedByUser || this.reconnectTimer !== null) { return; } this.attempt += 1; const backoff = Math.min(this.baseBackoffMs * 2 ** (this.attempt - 1), MAX_BACKOFF_MS); const jitter = Math.floor(Math.random() * 250); this.setStatus('reconnecting'); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.open('backoff'); }, backoff + jitter); } private setStatus(status: GatewayStatus): void { if (this.status === status) { return; } this.status = status; this.handlers.onStatus?.(status); } private send(op: number, d: unknown): void { const socket = this.socket; if (socket === null || socket.readyState !== 1) { return; } socket.send(JSON.stringify({ op, d })); } private handleMessage(raw: unknown): void { if (typeof raw !== 'string') { return; } let parsed: unknown; try { parsed = JSON.parse(raw) as unknown; } catch { this.logger.warn('gateway: cannot parse frame'); return; } if (!isGatewayPacket(parsed)) { return; } switch (parsed.op) { case GatewayOp.HELLO: { const data = asRecord(parsed.d); this.heartbeatIntervalMs = asNumber(data?.['heartbeat_interval_ms']) ?? DEFAULT_HEARTBEAT_MS; this.sessionId = asString(data?.['session_id']) ?? this.sessionId; if (this.helloTimeout !== null) { clearTimeout(this.helloTimeout); this.helloTimeout = null; } this.identify(); this.startHeartbeat(); return; } case GatewayOp.HEARTBEAT_ACK: { this.awaitingAck = false; return; } case GatewayOp.DISPATCH: { const seq = asNumber(parsed.s) ?? 0; if (seq > this.lastSeq) { this.lastSeq = seq; } const type = asString(parsed.t) ?? ''; if (type === 'READY') { this.attempt = 0; } if (type === 'RESUMED' && parsed.d === null) { this.handlers.onResumeIncomplete?.(); } this.setStatus('connected'); this.handlers.onDispatch?.({ op: 0, t: type, s: seq, d: parsed.d, }); return; } case GatewayOp.INVALID_SESSION: { const resumable = parsed.d === true; if (resumable) { this.logger.warn('gateway: invalid session, retrying resume'); this.dropSocket(); return; } this.sessionId = null; this.lastSeq = 0; this.closedByUser = true; this.setStatus('disconnected'); this.handlers.onInvalidSession?.(); return; } case GatewayOp.RECONNECT: { this.dropSocket(); return; } default: // Неизвестные opcode игнорируем: контракт может расширяться. return; } } private identify(): void { // Пустой токен допустим: браузер аутентифицируется cookie на рукопожатии. const token = ''; if (this.sessionId !== null && this.lastSeq > 0) { this.send(GatewayOp.RESUME, { token, resume_seq: this.lastSeq }); return; } this.send(GatewayOp.IDENTIFY, { token, resume_seq: 0 }); } private startHeartbeat(): void { this.stopHeartbeat(); this.awaitingAck = false; this.heartbeatTimer = setInterval(() => { if (this.awaitingAck) { this.logger.warn('gateway: heartbeat was not acknowledged'); this.dropSocket(); return; } this.awaitingAck = true; this.send(GatewayOp.HEARTBEAT, null); }, this.heartbeatIntervalMs); } private stopHeartbeat(): void { if (this.heartbeatTimer !== null) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } if (this.helloTimeout !== null) { clearTimeout(this.helloTimeout); this.helloTimeout = null; } } } let sharedConnection: GatewayConnection | null = null; export function getGatewayConnection(): GatewayConnection | null { return sharedConnection; } /** Создаёт (или переиспользует) единственное соединение с шлюзом. */ export function connectGateway(options: { url?: string; handlers: GatewayHandlers; socketFactory?: (url: string) => WebSocket; }): GatewayConnection { if (sharedConnection !== null) { sharedConnection.close(); } const socketFactory = options.socketFactory; sharedConnection = new GatewayConnection({ url: options.url ?? gatewayUrl(), handlers: options.handlers, ...(socketFactory === undefined ? {} : { socketFactory }), }); sharedConnection.connect(); return sharedConnection; } export function disconnectGateway(): void { sharedConnection?.close(); sharedConnection = null; }