UNPKG

solver-sdk

Version:

SDK для интеграции с Code Solver Backend API

634 lines 32.4 kB
// Импортируем Socket.IO клиент import { io } from 'socket.io-client'; /** * Базовый класс для WebSocket клиентов, реализованный на базе Socket.IO */ export class WebSocketClient { /** * Создает новый WebSocket клиент * @param {string} url URL для подключения * @param {WebSocketClientOptions} [options] Опции клиента */ constructor(url, options = {}) { /** Экземпляр Socket.IO */ this.socket = null; /** Счетчик попыток переподключения */ this.retryCount = 0; /** Флаг, указывающий, что соединение было закрыто намеренно */ this.intentionallyClosed = false; /** Таймер переподключения */ this.reconnectTimer = null; /** Таймер таймаута соединения */ this.connectionTimeoutTimer = null; /** Обработчики событий */ this.eventHandlers = {}; /** Очередь сообщений для отправки после подключения */ this.messageQueue = []; /** Состояние соединения */ this.connected = false; /** Аутентифицировано ли соединение */ this.authenticated = false; /** ID сокета */ this.socketId = null; /** Хранилище ожидающих callback-функций */ this._pendingCallbacks = new Map(); /** Таймер проверки состояния соединения */ this.healthCheckTimer = null; /** Время последнего полученного pong */ this.lastPongTimestamp = 0; /** Интервал проверки здоровья соединения */ this.healthCheckInterval = 10000; /** * Тип для хранения отложенных обработчиков событий * @private */ this._pendingCallbackHandlers = new Map(); this.url = url; this.options = { headers: options.headers || {}, connectionTimeout: options.connectionTimeout || 30000, protocols: options.protocols || [], maxRetries: options.maxRetries || 5, retryDelay: options.retryDelay || 1000, maxRetryDelay: options.maxRetryDelay || 30000, autoReconnect: options.autoReconnect !== undefined ? options.autoReconnect : true, rejectUnauthorized: options.rejectUnauthorized !== undefined ? options.rejectUnauthorized : true, apiKey: options.apiKey || '', namespace: options.namespace || '', logger: options.logger, pingInterval: options.pingInterval || 25000, pingTimeout: options.pingTimeout || 60000, debug: options.debug || false }; // Определяем среду выполнения this.isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined'; // Инициализируем пространство имен this.namespace = this.options.namespace || ''; // Если namespace не начинается с /, добавляем его if (this.namespace && !this.namespace.startsWith('/')) { this.namespace = '/' + this.namespace; } // Инициализируем логгер this.logger = this.options.logger || ((level, message, data) => { if (level === 'error') { console.error(`[WebSocketClient] ${message}`, data); } else if (level === 'warn') { console.warn(`[WebSocketClient] ${message}`, data); } else if (level === 'info') { console.info(`[WebSocketClient] ${message}`, data); } else if (level === 'debug' && process.env.NODE_ENV === 'development') { console.debug(`[WebSocketClient] ${message}`, data); } }); // Логирование конфигурации при создании клиента this.logger('info', 'Создан WebSocket клиент', { url: this.url, namespace: this.namespace, hasApiKey: !!this.options.apiKey, apiKeyLength: this.options.apiKey ? this.options.apiKey.length : 0, connectionTimeout: this.options.connectionTimeout, autoReconnect: this.options.autoReconnect, maxRetries: this.options.maxRetries }); } /** * Получает WebSocket URL из HTTP URL * @returns {string} WebSocket URL * @private */ getWebSocketURL() { let wsUrl = this.url; // Замена протокола if (wsUrl.startsWith('http://')) { wsUrl = wsUrl.replace('http://', 'ws://'); } else if (wsUrl.startsWith('https://')) { wsUrl = wsUrl.replace('https://', 'wss://'); } else if (!wsUrl.startsWith('ws://') && !wsUrl.startsWith('wss://')) { // Если URL не содержит протокол, добавляем ws:// wsUrl = 'ws://' + wsUrl; } // Удаляем слеш в конце URL если он есть if (wsUrl.endsWith('/')) { wsUrl = wsUrl.slice(0, -1); } return wsUrl; } /** * Подключается к WebSocket серверу используя Socket.IO клиент * @returns {Promise<void>} */ connect() { // Если соединение уже установлено, возвращаем Promise.resolve if (this.isConnected()) { this.logger('debug', 'Соединение уже установлено, пропускаем повторное подключение'); return Promise.resolve(); } // Сбрасываем таймер reconnect if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } return new Promise((resolve, reject) => { try { // Получаем WebSocket URL из http/https URL const wsUrl = this.getWebSocketURL(); const namespaceStr = this.options.namespace || ''; this.logger('debug', `Подключение к ${wsUrl}${namespaceStr}`); // Настройки для Socket.IO клиента const socketOptions = { path: '/socket.io', // Устанавливаем путь без слеша в конце transports: ['websocket', 'polling'], // Поддерживаем оба транспорта reconnection: this.options.autoReconnect, // Автоматическое переподключение reconnectionAttempts: this.options.maxRetries, // Количество попыток reconnectionDelay: this.options.retryDelay, // Задержка между попытками timeout: this.options.connectionTimeout, // Таймаут соединения forceNew: true, // Создавать новое соединение extraHeaders: this.options.headers, // HTTP заголовки rejectUnauthorized: this.options.rejectUnauthorized, // Проверка сертификатов pingTimeout: this.options.pingTimeout, // Явно устанавливаем таймаут для ping pingInterval: this.options.pingInterval, // Явно устанавливаем интервал ping debug: this.options.debug }; // Если указан API ключ, добавляем его в query и auth if (this.options.apiKey) { socketOptions.auth = { token: this.options.apiKey }; socketOptions.query = { token: this.options.apiKey }; } // Создаем Socket.IO клиент с namespace this.socket = io(wsUrl + namespaceStr, socketOptions); // Устанавливаем таймаут соединения this.connectionTimeoutTimer = setTimeout(() => { if (this.socket && !this.socket.connected) { const error = new Error('Таймаут подключения WebSocket'); this.logger('error', 'Превышен таймаут подключения', { timeout: this.options.connectionTimeout }); reject(error); this.close(); } }, this.options.connectionTimeout); // Обработчик успешного подключения this.socket.on('connect', () => { clearTimeout(this.connectionTimeoutTimer); this.retryCount = 0; this.connected = true; this.socketId = this.socket?.id || null; this.logger('info', 'WebSocket соединение установлено', { socketId: this.socketId }); // Отладка Socket.IO handshake const engine = this.socket.io?.engine; if (engine) { this.logger('debug', 'Socket.IO engine настройки:', { pingInterval: engine.pingInterval, pingTimeout: engine.pingTimeout, transport: engine.transport?.name }); // Отслеживаем ping-pong обмен engine.on('ping', () => { this.logger('debug', 'Socket.IO engine отправил ping'); // Явно пытаемся стимулировать ответ pong от сервера if (this.socket && this.socket.connected) { // Отправляем простое эхо-событие, чтобы сервер отреагировал this.socket.emit('_ping_check', { timestamp: Date.now() }); } }); engine.on('pong', () => { this.logger('debug', 'Socket.IO engine получил pong'); // Сбрасываем флаг неактивности this.lastPongTimestamp = Date.now(); }); // Ловим ошибки транспорта if (engine.transport) { engine.transport.on('error', (err) => { this.logger('error', `Socket.IO transport ошибка: ${err.message}`, err); }); // При смене транспорта engine.on('upgrade', (transport) => { this.logger('info', `Socket.IO transport обновлен до ${transport.name}`); }); // При отключении по таймауту engine.on('close', (reason) => { this.logger('warn', `Socket.IO engine закрыт по причине: ${reason}`); if (reason === 'ping timeout') { this.logger('error', 'Соединение потеряно из-за таймаута ping-pong обмена!'); // Запускаем процесс переподключения, если это необходимо if (this.options.autoReconnect && !this.intentionallyClosed) { this.reconnect(); } } }); } } // Отслеживаем серверные события ping/pong if (this.socket) { this.socket.on('_ping', (data, callback) => { this.logger('debug', 'Получен _ping от сервера'); if (typeof callback === 'function') { callback({ pong: true, clientTime: Date.now() }); } }); } // Устанавливаем поллинг для проверки состояния соединения this.setupConnectionHealthCheck(); // Отправляем сообщения из очереди while (this.messageQueue.length > 0) { const message = this.messageQueue.shift(); if (message && this.socket && this.socket.connected) { if (typeof message === 'object' && message.event) { this.socket.emit(message.event, message.data); } else { // Поддержка старого формата сообщений this.socket.send(message); } } } resolve(); this.dispatchEvent('open', {}); }); // Обработчик ошибок соединения this.socket.on('connect_error', (error) => { clearTimeout(this.connectionTimeoutTimer); this.logger('error', 'Ошибка соединения WebSocket', { message: error.message, name: error.name, stack: error.stack }); this.dispatchEvent('error', error); if (!this.connected) { reject(new Error('Ошибка подключения WebSocket')); } }); // Обработчик закрытия соединения this.socket.on('disconnect', (reason) => { clearTimeout(this.connectionTimeoutTimer); this.connected = false; this.logger('info', `WebSocket соединение закрыто: ${reason}`); // Формируем объект события для совместимости с WebSocket API const closeEvent = { code: this.getCloseCodeFromReason(reason), reason: reason }; this.dispatchEvent('close', closeEvent); // Если соединение было закрыто намеренно, не пытаемся переподключиться if (this.intentionallyClosed) { return; } }); // Обработчик всех сообщений, используем 'message' для совместимости this.socket.onAny((eventName, ...args) => { // Отправляем в обработчик события по имени события this.dispatchEvent(eventName, args.length === 1 ? args[0] : args); // Также отправляем событие message для совместимости this.dispatchEvent('message', { event: eventName, data: args.length === 1 ? args[0] : args }); }); } catch (error) { clearTimeout(this.connectionTimeoutTimer); this.logger('error', 'Ошибка при создании Socket.IO клиента', error); reject(error); } }); } /** * Получает код закрытия WebSocket из строки причины Socket.IO * @param {string} reason Причина закрытия Socket.IO * @returns {number} Код закрытия WebSocket * @private */ getCloseCodeFromReason(reason) { switch (reason) { case 'io server disconnect': return 1000; // Нормальное закрытие соединения сервером case 'io client disconnect': return 1000; // Нормальное закрытие соединения клиентом case 'ping timeout': return 1001; // Выход из соединения по таймауту case 'transport close': return 1006; // Аномальное закрытие соединения case 'transport error': return 1002; // Протокольная ошибка default: return 1000; // По умолчанию - нормальное закрытие } } /** * Настраивает проверку состояния соединения для обнаружения "зависших" подключений * @private */ setupConnectionHealthCheck() { // Очищаем предыдущий таймер, если он был if (this.healthCheckTimer) { clearInterval(this.healthCheckTimer); this.healthCheckTimer = null; } // Устанавливаем начальное время pong this.lastPongTimestamp = Date.now(); // Создаем новый таймер для регулярной проверки this.healthCheckTimer = setInterval(() => { if (!this.socket || !this.connected) return; const now = Date.now(); const lastPongAge = now - this.lastPongTimestamp; // Если прошло больше 2*pingInterval мс с последнего pong, возможно соединение зависло if (lastPongAge > 2 * (this.options.pingInterval || 25000)) { this.logger('warn', `Возможно зависшее соединение: ${lastPongAge}ms с последнего pong`); // Отправляем тестовое сообщение для проверки соединения const socket = this.socket; // Сохраняем ссылку на сокет в переменную if (socket && socket.connected) { socket.emit('_health_check', { timestamp: now }, (response) => { if (response && response.success) { this.logger('debug', 'Соединение активно, получен ответ на _health_check'); this.lastPongTimestamp = Date.now(); } }); } } }, this.healthCheckInterval); } /** * Останавливает проверку состояния соединения * @private */ stopConnectionHealthCheck() { if (this.healthCheckTimer) { clearInterval(this.healthCheckTimer); this.healthCheckTimer = null; } } /** * Закрывает соединение WebSocket * @param {number} code Код закрытия * @param {string} reason Причина закрытия */ close(code = 1000, reason = 'Closed by client') { this.intentionallyClosed = true; // Останавливаем проверку здоровья соединения this.stopConnectionHealthCheck(); // Очищаем таймеры if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.connectionTimeoutTimer) { clearTimeout(this.connectionTimeoutTimer); this.connectionTimeoutTimer = null; } // Закрываем соединение if (this.socket) { this.logger('info', 'Закрытие WebSocket соединения', { code, reason }); if (this.socket.connected) { this.socket.disconnect(); } this.connected = false; this.socket = null; } } /** * Отправляет сообщение в WebSocket * @param {any} data Данные для отправки * @returns {boolean} Успешно ли отправлено сообщение */ send(data) { try { // Если соединение еще не установлено, добавляем сообщение в очередь if (!this.socket || !this.socket.connected) { this.messageQueue.push(data); return true; } // Обработка разных типов сообщений для совместимости if (typeof data === 'object') { if (data.event) { // Формат { event: 'event_name', data: {} } this.socket.emit(data.event, data.data); } else if (data.type && data.type === '2' && data.data && Array.isArray(data.data)) { // Socket.IO тип пакета '2' - событие с данными // Формат { type: '2', nsp: '/namespace', data: ['event_name', {}] } const eventName = data.data[0]; const eventData = data.data.length > 1 ? data.data[1] : null; this.socket.emit(eventName, eventData); } else { // Обычные объекты отправляем через 'message' this.socket.emit('message', data); } } else { // Строки, бинарные данные и т.д. this.socket.send(data); } return true; } catch (error) { this.logger('error', 'Ошибка при отправке сообщения', error); return false; } } /** * Отправляет событие с данными и ожидает ответа с помощью Promise * @param {string} event Название события * @param {any} data Данные события * @param {number} [timeout=5000] Таймаут ожидания ответа в мс * @returns {Promise<any>} Promise с ответом */ emitWithAck(event, data, timeout = 5000) { return new Promise((resolve, reject) => { if (!this.socket || !this.socket.connected) { reject(new Error('WebSocket не подключен')); return; } try { // Используем встроенный механизм acknowledgements в Socket.IO this.socket.timeout(timeout).emit(event, data, (err, response) => { if (err) { reject(err); } else { resolve(response); } }); } catch (error) { reject(error); } }); } /** * Добавляет обработчик события * @param {string} eventType Тип события * @param {WebSocketEventHandler} handler Обработчик события */ on(eventType, handler) { if (!this.eventHandlers[eventType]) { this.eventHandlers[eventType] = []; } this.eventHandlers[eventType].push(handler); // Если соединение уже установлено, добавляем обработчик для Socket.IO if (this.socket && this.socket.connected && eventType !== 'open' && eventType !== 'close') { // Не добавляем обработчики для 'open' и 'close', так как они обрабатываются // через 'connect' и 'disconnect' в методе connect() this.socket.on(eventType, handler); } } /** * Удаляет обработчик события * @param {string} eventType Тип события * @param {WebSocketEventHandler} [handler] Обработчик события (если не указан, удаляются все обработчики) */ off(eventType, handler) { if (!this.eventHandlers[eventType]) { return; } if (handler) { // Удаляем конкретный обработчик const index = this.eventHandlers[eventType].indexOf(handler); if (index !== -1) { this.eventHandlers[eventType].splice(index, 1); } // Также удаляем обработчик из Socket.IO, если соединение установлено if (this.socket && this.socket.connected) { this.socket.off(eventType, handler); } } else { // Удаляем все обработчики для данного типа события delete this.eventHandlers[eventType]; // Также удаляем все обработчики из Socket.IO, если соединение установлено if (this.socket && this.socket.connected) { this.socket.off(eventType); } } } /** * Отправляет событие в обработчики * @param {string} eventType Тип события * @param {any} data Данные события * @private */ dispatchEvent(eventType, data) { if (!this.eventHandlers[eventType]) { return; } for (const handler of this.eventHandlers[eventType]) { try { handler(data); } catch (error) { this.logger('error', `Ошибка в обработчике события '${eventType}'`, error); } } } /** * Возвращает текущий статус соединения * @returns {boolean} Подключен ли клиент */ isConnected() { return this.socket !== null && this.socket.connected; } /** * Выполняет принудительное переподключение * @returns {Promise<void>} Promise без результата */ async reconnect() { // Если соединение уже установлено, сначала закрываем его if (this.socket && this.socket.connected) { this.close(); } // Сбрасываем флаг намеренного закрытия для возможности переподключения this.intentionallyClosed = false; // Устанавливаем новое соединение return this.connect(); } /** * Отправляет событие (алиас для более удобного использования) * @param {string} eventName Название события * @param {any} data Данные события * @returns {boolean} Успешно ли отправлено событие */ emit(eventName, data) { return this.send({ event: eventName, data }); } /** * Возвращает ID сокета, если соединение установлено * @returns {string|null} ID сокета или null, если соединение не установлено */ getSocketId() { return this.socket?.id || null; } /** * Устанавливает функцию логирования * @param {Function} loggerFn Функция для логирования */ setLogger(loggerFn) { this.logger = loggerFn; } /** * Регистрирует обработчик события, который будет вызван один раз и удален * @param {string} event Название события * @param {WebSocketEventHandler} handler Обработчик события * @returns {void} */ once(event, handler) { // Если есть нативная реализация в Socket.IO, используем её if (this.socket && this.socket.connected) { this.socket.once(event, handler); return; } // Создаем обертку, которая удалит обработчик после первого вызова const wrapperHandler = (data) => { // Удаляем обработчик this.off(event, wrapperHandler); // Вызываем оригинальный обработчик handler(data); }; // Регистрируем обертку this.on(event, wrapperHandler); } /** * Отправляет событие Socket.IO через WebSocket соединение * @param {string} event Имя события * @param {any} data Данные события * @param {(response: any) => void} [callback] Функция обратного вызова для получения ответа * @param {string} [namespace=''] Namespace для Socket.IO * @returns {boolean} Успешно ли отправлено сообщение */ sendSocketIOEvent(event, data, callback, namespace = '') { // Если нет соединения, сразу возвращаем false if (!this.socket || !this.socket.connected) { this.logger('error', 'Нельзя отправить событие: WebSocket не подключен'); return false; } try { // Проверяем, нужно ли использовать другой namespace let targetSocket = this.socket; // Если указан другой namespace, используем его if (namespace && namespace !== this.namespace) { const nsSocket = io(this.url + namespace, { forceNew: false, auth: { token: this.options.apiKey } }); targetSocket = nsSocket; } // Отправляем событие с callback, если он указан if (callback) { targetSocket.emit(event, data, callback); } else { targetSocket.emit(event, data); } this.logger('debug', `Отправлено Socket.IO событие ${event}`, { hasData: !!data, namespace }); return true; } catch (error) { this.logger('error', `Ошибка при отправке Socket.IO события ${event}`, error); return false; } } } //# sourceMappingURL=websocket-client.js.map