UNPKG

solver-sdk

Version:

SDK для интеграции с Code Solver Backend API (совместимо с браузером и Node.js), с поддержкой функциональности мышления (Thinking Mode)

1,112 lines 88.9 kB
import { WebSocketClient } from './websocket-client.js'; import { WebSocketEvents as WsEvents } from '../constants/websocket-events.constants.js'; /** * Пространства имен для WebSocket */ export var WebSocketNamespace; (function (WebSocketNamespace) { /** Основное пространство имен */ WebSocketNamespace["DEFAULT"] = "/"; /** Пространство для рассуждений */ WebSocketNamespace["REASONING"] = "/reasoning"; /** Пространство для индексации */ WebSocketNamespace["INDEXING"] = "/indexing"; /** Пространство для зависимостей */ WebSocketNamespace["DEPENDENCIES"] = "/dependencies"; })(WebSocketNamespace || (WebSocketNamespace = {})); /** * WebSocket клиент для работы с Code Solver API */ export class CodeSolverWebSocketClient { /** * Создает новый WebSocket клиент для Code Solver API * @param {string} baseURL Базовый URL API * @param {CodeSolverWebSocketOptions} [options] Опции клиента */ constructor(baseURL, options = {}) { /** Пространство имен для Socket.IO */ this.namespace = ''; /** WebSocket клиенты для разных пространств имен */ this.clients = new Map(); /** Активная сессия рассуждения */ this.activeReasoningId = null; /** Активная сессия индексации */ this.activeProjectId = null; /** Обработчики событий мышления */ this.thinkingEventHandlers = new Map(); /** Таймеры для ping/pong */ this.pingIntervals = new Map(); /** Статистика ping/pong */ this.pingStats = new Map(); /** Количество последовательных таймаутов */ this.pingTimeouts = new Map(); /** Задержка по умолчанию между ping-сообщениями (30 секунд) */ this.defaultPingInterval = 30000; /** Порог таймаута (количество пропущенных pong) */ this.timeoutThreshold = 3; /** Хранилище обработчиков ping/pong */ this.pingPongEventHandlers = new Map(); /** Токены сессий для разных пространств имен */ this.sessionTokens = new Map(); /** Состояние подключения для разных пространств имен */ this.connectionState = new Map(); /** Таймер для проверки здоровья соединений */ this.healthCheckTimer = null; this.baseURL = baseURL.replace(/^http/, 'ws'); this.options = { ...options, headers: { ...(options.headers || {}), ...(options.apiKey ? { 'Authorization': `Bearer ${options.apiKey}` } : {}) } }; // Инициализируем пространство имен this.namespace = String(WebSocketNamespace.REASONING); } /** * Логирование сообщений * @param {string} level Уровень логирования ('info', 'debug', 'error') * @param {string} message Сообщение для логирования * @param {any} [data] Дополнительные данные для логирования */ logger(level, message, data) { if (level === 'error') { console.error(`[CodeSolverWebSocket] ${message}`, data); } else if (level === 'info') { console.info(`[CodeSolverWebSocket] ${message}`, data); } else { console.debug(`[CodeSolverWebSocket] ${message}`, data); } } /** * Подключается к пространству имен WebSocket * @param {WebSocketNamespace} namespace Пространство имен * @param {Record<string, any>} [params] Параметры подключения * @returns {Promise<WebSocketClient>} WebSocket клиент */ async connect(namespace, params = {}) { // Если клиент уже существует, возвращаем его if (this.clients.has(namespace)) { const client = this.clients.get(namespace); // Если клиент уже подключен, возвращаем его if (client.isConnected()) { console.debug(`[WS] Уже подключен к ${namespace}`); return client; } } // Формируем URL для подключения let baseUrl; let namespaceStr = ''; // Добавляем namespace в URL путь (стандартный подход Socket.IO) if (namespace !== WebSocketNamespace.DEFAULT) { namespaceStr = String(namespace); if (!namespaceStr.startsWith('/')) { namespaceStr = '/' + namespaceStr; } } // Формируем правильный URL для Socket.IO if (this.baseURL.endsWith('/socket.io') || this.baseURL.endsWith('/socket.io/')) { // Для случая когда URL заканчивается на /socket.io, убираем слеш const cleanBaseUrl = this.baseURL.endsWith('/') ? this.baseURL.slice(0, -1) : this.baseURL; // Добавляем namespace к URL baseUrl = cleanBaseUrl + namespaceStr; } else { // Для случая когда URL не содержит /socket.io baseUrl = this.baseURL + namespaceStr; } // Создаем URL объект с параметрами const url = new URL(baseUrl); // Добавляем обязательные параметры для Socket.IO url.searchParams.append('EIO', '4'); url.searchParams.append('transport', 'websocket'); // Добавляем параметры к URL Object.entries(params).forEach(([key, value]) => { if (value !== undefined) { url.searchParams.append(key, String(value)); } }); const urlString = url.toString(); console.debug(`[WS] Подключение к ${urlString}`, { namespace: String(namespace), hasApiKey: !!this.options.apiKey, apiKeyLength: this.options.apiKey ? this.options.apiKey.length : 0, params: Object.keys(params) }); // Создаем новый WebSocket клиент const client = new WebSocketClient(urlString, { ...this.options, namespace: namespaceStr }); // Добавляем обработчик для успешного подключения и отправки аутентификации client.on('open', () => { console.debug(`[WS] Подключение к ${String(namespace)} установлено`, { socketId: client.webSocket?.id, readyState: client.webSocket?.readyState }); // Отправляем сообщение аутентификации, если задан API ключ if (this.options.apiKey && namespace !== WebSocketNamespace.DEFAULT) { try { const apiKeySafe = this.options.apiKey.length > 8 ? `${this.options.apiKey.substring(0, 4)}...${this.options.apiKey.substring(this.options.apiKey.length - 4)}` : '[короткий ключ]'; console.debug(`[WS] Отправка аутентификации для ${String(namespace)}`, { namespace: String(namespace), apiKey: apiKeySafe }); // Отправляем сообщение аутентификации в формате Socket.IO const authMessage = { type: '2', // Socket.IO packet type: EVENT nsp: String(namespace), data: ['authenticate', { token: this.options.apiKey }] }; client.send(authMessage); } catch (error) { console.error(`[WS] Ошибка при отправке аутентификации: ${error instanceof Error ? error.message : String(error)}`); } } }); // Логирование ошибок client.on('error', (error) => { console.error(`[WS] Ошибка соединения с ${namespace}: ${error instanceof Error ? error.message : String(error)}`); }); // Логирование разъединений client.on('close', (event) => { console.debug(`[WS] Соединение с ${namespace} закрыто: ${event.code || 'нет кода'}, ${event.reason || 'Причина не указана'}`); }); // Логирование сообщений для отладки client.on('message', (data) => { try { console.debug(`[WS] Получено сообщение от ${namespace}: ${JSON.stringify(data).substring(0, 100)}...`); } catch (error) { console.debug(`[WS] Получено сообщение от ${namespace} (не может быть сериализовано в JSON)`); } }); // Подключаемся к серверу await client.connect(); // Сохраняем клиент this.clients.set(namespace, client); return client; } /** * Подключается к пространству имен индексации * @param projectId ID проекта (опционально) * @returns Promise с результатом подключения */ async connectToIndexing(projectId) { try { this.logger('info', 'Подключение к пространству имен индексации', { projectId }); // Если указан ID проекта, сохраняем его if (projectId) { this.activeProjectId = projectId; } // Подключаемся к пространству имен индексации const client = await this.connect(WebSocketNamespace.INDEXING); // Аутентифицируемся с увеличенным таймаутом try { const authResult = await client.emitWithAck(WsEvents.AUTHENTICATE, { token: this.options.apiKey, projectId: this.activeProjectId }, 10000); this.logger('debug', 'Результат аутентификации в namespace индексации', authResult); } catch (error) { this.logger('error', 'Ошибка аутентификации в namespace индексации', error); return false; } // Если у нас есть ID проекта, присоединяемся к нему if (this.activeProjectId) { try { const joinResult = await client.emitWithAck(WsEvents.JOIN_PROJECT, { projectId: this.activeProjectId, token: this.options.apiKey }, 10000); this.logger('debug', 'Результат присоединения к проекту', joinResult); return true; } catch (error) { this.logger('error', 'Ошибка присоединения к проекту', error); return false; } } return true; } catch (error) { this.logger('error', 'Ошибка подключения к пространству имен индексации', error); return false; } } /** * Подключается к уведомлениям * @returns {Promise<boolean>} Результат подключения */ async connectToNotifications() { // Подключаемся к пространству имен try { await this.connect(WebSocketNamespace.DEFAULT); return true; } catch (error) { return false; } } /** * Подключается к пространству имен dependencies * @param projectId ID проекта (опционально) * @returns Promise с результатом подключения */ async connectToDependencies(projectId) { try { this.logger('info', 'Подключение к пространству имен dependencies', { projectId }); // Если указан ID проекта, сохраняем его if (projectId) { this.activeProjectId = projectId; } // Подключаемся к пространству имен dependencies const client = await this.connect(WebSocketNamespace.DEPENDENCIES); // Аутентифицируемся с увеличенным таймаутом try { const authResult = await client.emitWithAck(WsEvents.AUTHENTICATE, { token: this.options.apiKey, projectId: this.activeProjectId }, 10000); this.logger('debug', 'Результат аутентификации в namespace dependencies', authResult); } catch (error) { this.logger('error', 'Ошибка аутентификации в namespace dependencies', error); return false; } // Если у нас есть ID проекта, присоединяемся к нему if (this.activeProjectId) { try { const joinResult = await client.emitWithAck(WsEvents.JOIN_DEPENDENCIES, { projectId: this.activeProjectId, token: this.options.apiKey }, 10000); this.logger('debug', 'Результат присоединения к проекту', joinResult); return true; } catch (error) { this.logger('error', 'Ошибка присоединения к проекту', error); return false; } } return true; } catch (error) { this.logger('error', 'Ошибка подключения к пространству имен dependencies', error); return false; } } /** * Отключается от пространства имен * @param {WebSocketNamespace} namespace Пространство имен */ disconnect(namespace) { const client = this.clients.get(namespace); if (client) { client.close(); this.clients.delete(namespace); } // Сбрасываем активные сессии if (namespace === WebSocketNamespace.REASONING) { this.activeReasoningId = null; } else if (namespace === WebSocketNamespace.INDEXING) { this.activeProjectId = null; } } /** * Отключается от всех пространств имен * Отключает автоматический механизм ping/pong */ disconnectAll() { // Отключаем ping/pong для всех соединений this.disablePingPong(); // Отключаемся от всех namespace for (const [namespace, client] of this.clients.entries()) { if (client) { client.close(); this.clients.delete(namespace); } } // Сбрасываем активные сессии this.activeReasoningId = null; this.activeProjectId = null; } /** * Добавляет обработчик события для пространства имен * @param {string} eventType Тип события * @param {Function} handler Обработчик события * @param {WebSocketNamespace} [namespace] Пространство имен (если не указано, добавляется ко всем активным) */ on(eventType, handler, namespace) { if (namespace) { // Если указано пространство имен, добавляем обработчик только к нему const client = this.clients.get(namespace); if (!client) { throw new Error(`Не подключен к пространству имен ${namespace}`); } client.on(eventType, handler); } else { // Если пространство имен не указано, добавляем обработчик ко всем активным пространствам for (const client of this.clients.values()) { client.on(eventType, handler); } } } /** * Удаляет обработчик события для пространства имен * @param {string} eventType Тип события * @param {Function} [handler] Обработчик события (если не указан, удаляются все обработчики) * @param {WebSocketNamespace} [namespace] Пространство имен (если не указано, удаляется из всех активных) */ off(eventType, handler, namespace) { if (namespace) { // Если указано пространство имен, удаляем обработчик только из него const client = this.clients.get(namespace); if (!client) { return; } client.off(eventType, handler); } else { // Если пространство имен не указано, удаляем обработчик из всех активных пространств for (const client of this.clients.values()) { client.off(eventType, handler); } } } /** * Отправляет сообщение в пространство имен * @param {WebSocketNamespace} namespace Пространство имен * @param {string} eventType Тип события * @param {any} [data] Данные сообщения * @returns {boolean} Успешно ли отправлено сообщение */ send(namespace, eventType, data) { const client = this.clients.get(namespace); if (!client) { throw new Error(`Не подключен к пространству имен ${namespace}`); } return client.send({ event: eventType, data }); } /** * Отправляет сообщение в активную сессию рассуждения * @param {string} eventType Тип события * @param {any} [data] Данные сообщения * @returns {boolean} Успешно ли отправлено сообщение */ sendToReasoning(eventType, data) { if (!this.activeReasoningId) { throw new Error('Не подключен к сессии рассуждения'); } return this.send(WebSocketNamespace.REASONING, eventType, data); } /** * Отправляет сообщение в активную сессию индексации * @param {string} eventType Тип события * @param {any} [data] Данные сообщения * @returns {boolean} Успешно ли отправлено сообщение */ sendToIndexing(eventType, data) { if (!this.activeProjectId) { throw new Error('Не подключен к сессии индексации'); } return this.send(WebSocketNamespace.INDEXING, eventType, data); } /** * Отправляет сообщение в уведомления * @param {string} eventType Тип события * @param {any} [data] Данные сообщения * @returns {boolean} Успешно ли отправлено сообщение */ sendToNotifications(eventType, data) { return this.send(WebSocketNamespace.DEFAULT, eventType, data); } /** * Проверяет, подключен ли клиент к указанному пространству имен * @param {WebSocketNamespace} namespace Пространство имен * @returns {boolean} Статус подключения */ isConnected(namespace) { const client = this.clients.get(namespace); return client ? client.isConnected() : false; } /** * Проверяет, подключен ли клиент к пространству имен рассуждений * @returns {boolean} Статус подключения */ isConnectedToReasoning() { return this.isConnected(WebSocketNamespace.REASONING); } /** * Проверяет, подключен ли клиент к пространству имен индексации * @returns {boolean} Статус подключения */ isConnectedToIndexing() { return this.isConnected(WebSocketNamespace.INDEXING); } /** * Проверяет, подключен ли клиент к пространству имен уведомлений * @returns {boolean} Статус подключения */ isConnectedToNotifications() { return this.isConnected(WebSocketNamespace.DEFAULT); } /** * Получает ID сокета для указанного пространства имен * @param {WebSocketNamespace} [namespace=WebSocketNamespace.REASONING] Пространство имен * @returns {string|null} ID сокета или null, если соединение не установлено */ getSocketId(namespace = WebSocketNamespace.REASONING) { const client = this.clients.get(namespace); if (!client || !client.isConnected()) { console.warn(`[WsClientWrapper] getSocketId: Нет активных соединений с Socket.IO сервером для ${namespace}`); return null; } return client.getSocketId(); } /** * Подписывается на события мышления * @param {string} reasoningId Идентификатор рассуждения * @param {Function} handler Обработчик событий мышления * @returns {void} */ subscribeToThinking(reasoningId, handler) { // Сохраняем обработчик this.thinkingEventHandlers.set(reasoningId, handler); // Получаем клиент рассуждений const client = this.clients.get(WebSocketNamespace.REASONING); if (!client) { throw new Error('Не подключен к пространству имен рассуждения'); } // Подписываемся на события мышления client.on(`thinking:${reasoningId}`, (data) => { handler(data); }); // Дублируем подписку для полной совместимости client.on(`reasoning:thinking:${reasoningId}`, (data) => { handler(data); }); } /** * Отписывается от событий мышления * @param {string} reasoningId Идентификатор рассуждения * @returns {void} */ unsubscribeFromThinking(reasoningId) { // Удаляем обработчик this.thinkingEventHandlers.delete(reasoningId); // Получаем клиент рассуждений const client = this.clients.get(WebSocketNamespace.REASONING); if (!client) return; // Отписываемся от событий client.off(`thinking:${reasoningId}`); client.off(`reasoning:thinking:${reasoningId}`); } /** * Подключается к сессии рассуждения с thinking * @param {string} [reasoningId="system"] Идентификатор рассуждения * @param {Function} [thinkingHandler] Обработчик событий мышления * @returns {Promise<string>} Идентификатор сессии рассуждения */ async connectToThinkingSession(reasoningId = "system", thinkingHandler) { try { // Подключаемся к пространству имен await this.connect(WebSocketNamespace.REASONING); // Получаем клиент const client = this.clients.get(WebSocketNamespace.REASONING); if (!client) { throw new Error(`Не удалось получить WebSocket клиент для ${WebSocketNamespace.REASONING}`); } // Если reasoningId == "system", сервер заменит его на новый // с префиксом "system-". Для получения нового ID нужно подписаться // на событие создания рассуждения. if (reasoningId === "system") { // Будем ждать ответа о создании рассуждения return new Promise((resolve, reject) => { // Устанавливаем таймаут const timeout = setTimeout(() => { reject(new Error('Таймаут ожидания ответа о создании рассуждения')); }, 10000); // Подписываемся на событие создания рассуждения client.once(`${WsEvents.CREATE_REASONING}_response`, (data) => { clearTimeout(timeout); if (data.error) { reject(new Error(`Ошибка создания рассуждения: ${data.error}`)); return; } const newReasoningId = data.reasoningId; this.activeReasoningId = newReasoningId; // Отправляем запрос на присоединение к сессии рассуждения client.sendSocketIOEvent(WsEvents.JOIN_REASONING, { reasoningId: newReasoningId }, (joinResponse) => { if (joinResponse.success === false) { reject(new Error(`Ошибка при присоединении к сессии рассуждения: ${joinResponse.error || 'Неизвестная ошибка'}`)); return; } // Отправляем запрос на запуск рассуждения client.sendSocketIOEvent(WsEvents.START_REASONING, { reasoningId: newReasoningId }, (startResponse) => { if (startResponse.success === false) { reject(new Error(`Ошибка при запуске рассуждения: ${startResponse.error || 'Неизвестная ошибка'}`)); return; } // Если передан обработчик событий мышления, подписываемся if (thinkingHandler) { this.subscribeToThinking(newReasoningId, thinkingHandler); } resolve(newReasoningId); }, this.namespace); }, this.namespace); }); }); } else { this.activeReasoningId = reasoningId; // Отправляем запрос на присоединение к сессии рассуждения return new Promise((resolve, reject) => { client.sendSocketIOEvent(WsEvents.JOIN_REASONING, { reasoningId }, (joinResponse) => { if (joinResponse.success === false) { reject(new Error(`Ошибка при присоединении к сессии рассуждения: ${joinResponse.error || 'Неизвестная ошибка'}`)); return; } // Отправляем запрос на запуск рассуждения client.sendSocketIOEvent(WsEvents.START_REASONING, { reasoningId }, (startResponse) => { if (startResponse.success === false) { reject(new Error(`Ошибка при запуске рассуждения: ${startResponse.error || 'Неизвестная ошибка'}`)); return; } // Если передан обработчик событий мышления, подписываемся if (thinkingHandler) { this.subscribeToThinking(reasoningId, thinkingHandler); } resolve(reasoningId); }, this.namespace); }, this.namespace); }); } } catch (error) { throw new Error(`Ошибка при подключении к сессии thinking: ${error instanceof Error ? error.message : String(error)}`); } } /** * Настраивает отладочное логирование для WebSocket клиента * @param namespace Пространство имен */ setupDebugLogging(namespace) { const client = this.clients.get(namespace); if (!client) return; // Добавляем детальное логирование всех событий client.on('socket.io_event', (data) => { this.logger('debug', `[WS:${namespace}] Получено Socket.IO событие: ${data.event}`, data.data); }); // Добавляем обработчик для всех событий (onAny) client.on('message', (data) => { if (typeof data === 'string') { try { this.logger('debug', `[WS:${namespace}] Получено сырое сообщение`, data); } catch (e) { this.logger('error', `[WS:${namespace}] Ошибка при обработке сырого сообщения`, data); } } }); // Отслеживаем состояние соединения client.on('connect', () => { this.logger('info', `[WS:${namespace}] Соединение установлено`); }); client.on('close', (data) => { this.logger('info', `[WS:${namespace}] Соединение закрыто: ${data.code}, Причина: ${data.reason}`); }); client.on('error', (error) => { this.logger('error', `[WS:${namespace}] Ошибка соединения`, error); }); // Отслеживаем ping/pong для проверки состояния соединения client.on('ping', () => this.logger('debug', `[WS:${namespace}] Отправлен ping`)); client.on('pong', () => this.logger('debug', `[WS:${namespace}] Получен pong`)); // Добавляем обработчик для отслеживания ответов на события client.on('socket.io_raw', (data) => { this.logger('debug', `[WS:${namespace}] Socket.IO raw пакет`, { type: data.type, data: data.data }); }); } /** * Проверяет наличие callback-функции в данных и правильно вызывает ее * @param eventName Имя события * @param data Данные события * @private */ extractAndCallCallback(eventName, data) { if (data && typeof data === 'object' && typeof data.callback === 'function') { try { // Копируем данные без callback const dataCopy = { ...data }; delete dataCopy.callback; // Получаем callback-функцию const callback = data.callback; // Вызываем callback this.logger('debug', `Вызов callback для события ${eventName}`); // Создаем таймаут для предотвращения зависания const timeoutId = setTimeout(() => { this.logger('warn', `Таймаут выполнения callback для события ${eventName}`); }, 5000); // Вызываем callback и очищаем таймаут callback(); clearTimeout(timeoutId); } catch (error) { this.logger('error', `Ошибка при вызове callback для события ${eventName}: ${error instanceof Error ? error.message : String(error)}`); } } } /** * Включить автоматическую отправку ping-сообщений и сбор статистики * @param {number} interval - Интервал между ping-сообщениями в миллисекундах * @param {number} timeoutThreshold - Количество пропущенных pong-сообщений, после которого соединение считается потерянным * @returns {boolean} - Успешность включения ping/pong */ enablePingPong(interval = this.defaultPingInterval, timeoutThreshold = 3) { // Сохраняем порог таймаута this.timeoutThreshold = timeoutThreshold; // Для каждого активного соединения for (const [namespace, client] of this.clients.entries()) { try { // Проверяем, активно ли соединение if (!client || !this.isConnected(namespace)) { this.logger('warn', `Невозможно включить ping/pong для неактивного соединения в ${namespace}`); continue; } // Останавливаем существующий таймер, если есть this.disablePingPong(namespace); // Инициализируем статистику, если не была создана if (!this.pingStats.has(namespace)) { this.pingStats.set(namespace, { namespace, socketId: client.getSocketId(), pingSent: 0, pongReceived: 0, averageRtt: 0, minRtt: Number.MAX_SAFE_INTEGER, maxRtt: 0, lastRtt: 0, lastPongTimestamp: Date.now(), isConnected: true }); } // Сбрасываем счетчик таймаутов this.pingTimeouts.set(namespace, 0); // Устанавливаем обработчик для события connection_pong client.on(WsEvents.CONNECTION_PONG, (data) => { // Обновляем статистику const stats = this.pingStats.get(namespace); if (stats) { stats.pongReceived++; stats.lastPongTimestamp = Date.now(); stats.isConnected = true; // Рассчитываем RTT, если есть метка времени эхо if (data && data.echo) { const rtt = Date.now() - data.echo; stats.lastRtt = rtt; // Обновляем min и max stats.minRtt = Math.min(stats.minRtt, rtt); stats.maxRtt = Math.max(stats.maxRtt, rtt); // Обновляем среднее значение stats.averageRtt = (stats.averageRtt * (stats.pongReceived - 1) + rtt) / stats.pongReceived; } // Сбрасываем счетчик таймаутов this.pingTimeouts.set(namespace, 0); } // Логируем получение pong this.logger('debug', `Получен pong для ${namespace}`, { rtt: stats?.lastRtt, socketId: client.getSocketId() }); }); // Устанавливаем интервал отправки ping const pingInterval = setInterval(() => { if (this.isConnected(namespace)) { // Формируем данные ping const pingData = { timestamp: Date.now() }; // Отправляем ping const sent = this.send(namespace, WsEvents.CONNECTION_PING, pingData); // Если успешно отправлено, обновляем статистику if (sent) { const stats = this.pingStats.get(namespace); if (stats) { stats.pingSent++; } this.logger('debug', `Отправлен ping для ${namespace}`, pingData); } else { this.logger('warn', `Не удалось отправить ping для ${namespace}`); } // Проверяем таймаут const timeouts = this.pingTimeouts.get(namespace) || 0; const stats = this.pingStats.get(namespace); // Если разница между отправленными и полученными превышает порог, // или последний pong был получен слишком давно if ((stats && stats.pingSent - stats.pongReceived > this.timeoutThreshold) || (stats && Date.now() - stats.lastPongTimestamp > interval * this.timeoutThreshold)) { // Увеличиваем счетчик таймаутов this.pingTimeouts.set(namespace, timeouts + 1); if (timeouts + 1 >= this.timeoutThreshold) { // Соединение потеряно this.logger('error', `Соединение потеряно (таймаут ping/pong) для ${namespace}`); // Установка флага неактивного соединения if (stats) { stats.isConnected = false; } // На прямую отправку события через socket this.send(namespace, 'connection_timeout', { namespace, socketId: client.getSocketId(), timeouts: timeouts + 1, threshold: this.timeoutThreshold }); // Также вызываем обработчики событий const timeoutHandlers = this.pingPongEventHandlers.get('connection_timeout') || []; timeoutHandlers.forEach(handler => { try { handler({ namespace, socketId: client.getSocketId(), timeouts: timeouts + 1, threshold: this.timeoutThreshold }); } catch (error) { this.logger('error', `Ошибка при обработке события connection_timeout`, error); } }); } } } }, interval); // Сохраняем интервал this.pingIntervals.set(namespace, pingInterval); this.logger('info', `Включен механизм ping/pong для ${namespace} с интервалом ${interval}ms`); } catch (error) { this.logger('error', `Ошибка при включении ping/pong для ${namespace}`, error); return false; } } return true; } /** * Отключить автоматическую отправку ping-сообщений * @param {WebSocketNamespace} [namespace] - Пространство имен для отключения (если не указано - отключается везде) */ disablePingPong(namespace) { if (namespace) { // Отключаем для указанного namespace const interval = this.pingIntervals.get(namespace); if (interval) { clearInterval(interval); this.pingIntervals.delete(namespace); this.logger('info', `Отключен механизм ping/pong для ${namespace}`); } } else { // Отключаем для всех namespace for (const [ns, interval] of this.pingIntervals.entries()) { clearInterval(interval); this.pingIntervals.delete(ns); this.logger('info', `Отключен механизм ping/pong для ${ns}`); } } } /** * Получить статистику ping/pong * @param {WebSocketNamespace} [namespace] - Пространство имен для получения статистики * @returns {PingPongStats | PingPongStats[] | null} - Статистика ping/pong */ getPingStats(namespace) { if (namespace) { // Возвращаем статистику для указанного namespace return this.pingStats.get(namespace) || null; } else { // Возвращаем статистику для всех namespace return Array.from(this.pingStats.values()); } } /** * Добавляет обработчик для событий ping/pong * @param {string} eventType - Тип события (connection_timeout) * @param {(data: any) => void} handler - Обработчик события */ onPingPongEvent(eventType, handler) { if (!this.pingPongEventHandlers.has(eventType)) { this.pingPongEventHandlers.set(eventType, []); } const handlers = this.pingPongEventHandlers.get(eventType); if (handlers) { handlers.push(handler); } } /** * Удаляет обработчик для событий ping/pong * @param {string} eventType - Тип события * @param {(data: any) => void} [handler] - Обработчик события (если не указан, удаляются все обработчики) */ offPingPongEvent(eventType, handler) { if (!handler) { // Если обработчик не указан, удаляем все обработчики для этого типа события this.pingPongEventHandlers.delete(eventType); } else { // Если обработчик указан, удаляем только его const handlers = this.pingPongEventHandlers.get(eventType); if (handlers) { const index = handlers.findIndex(h => h === handler); if (index !== -1) { handlers.splice(index, 1); } } } } /** * Возвращает функцию-обработчик для pong-ответов, которая рассчитывает RTT * @returns {(data: any) => void} Функция-обработчик */ getPongHandler() { return (data) => { if (data && data.echo) { const rtt = Date.now() - data.echo; console.log(`[PONG] RTT: ${rtt}ms, namespace: ${data.namespace || 'unknown'}`); return rtt; } return -1; }; } /** * Выполняет диагностику соединения и возвращает подробный отчет * @param {WebSocketNamespace} namespace Пространство имен * @returns {ConnectionDiagnostics} Объект с диагностической информацией */ diagnoseConnection(namespace) { const client = this.clients.get(namespace); const stats = this.pingStats.get(namespace); const connectionState = this.getConnectionState(namespace); const sessionToken = this.getSessionToken(namespace); return { namespace, isConnected: client?.isConnected() || false, socketId: client?.getSocketId() || null, lastActivity: stats?.lastPongTimestamp || 0, rtt: { current: stats?.lastRtt || -1, min: stats?.minRtt === Number.MAX_SAFE_INTEGER ? -1 : (stats?.minRtt || -1), max: stats?.maxRtt || -1, avg: stats?.averageRtt || -1 }, pingSent: stats?.pingSent || 0, pongReceived: stats?.pongReceived || 0, missedPongs: (stats?.pingSent || 0) - (stats?.pongReceived || 0), timeoutCount: this.pingTimeouts.get(namespace) || 0, reconnectAttempts: connectionState.reconnectAttempts, lastConnectTime: connectionState.lastConnectTime, sessionRecovery: { hasSessionToken: !!sessionToken, tokenLength: sessionToken?.length || 0, wasRecovered: !!sessionToken && (stats?.pongReceived || 0) > 0 } }; } /** * Выполняет диагностику всех активных соединений * @returns {Record<string, ConnectionDiagnostics>} Объект с диагностической информацией по всем соединениям */ diagnoseAllConnections() { const result = {}; // Проверяем каждое возможное пространство имен for (const namespace of Object.values(WebSocketNamespace)) { if (this.clients.has(namespace)) { result[String(namespace)] = this.diagnoseConnection(namespace); } } return result; } /** * Рассчитывает задержку для переподключения на основе количества попыток и стратегии * @param {WebSocketNamespace} namespace Пространство имен * @returns {number} Задержка в миллисекундах */ calculateReconnectDelay(namespace) { const state = this.getConnectionState(namespace); const attempts = state.reconnectAttempts; const strategy = this.options.reconnectStrategy || 'exponential'; const baseDelay = this.options.retryDelay || 1000; const maxDelay = this.options.maxRetryDelay || 30000; if (strategy === 'exponential') { // Экспоненциальный рост с фактором 1.5 const calculatedDelay = Math.min(baseDelay * Math.pow(1.5, attempts), maxDelay); // Добавляем случайный фактор (jitter) для предотвращения штормов переподключений return calculatedDelay * (0.8 + Math.random() * 0.4); } else { // Линейный рост return Math.min(baseDelay * (attempts + 1), maxDelay); } } /** * Принудительно переподключает соединение для указанного пространства имен * @param {WebSocketNamespace} namespace Пространство имен * @param {boolean} immediate Выполнить переподключение немедленно, без задержки * @returns {Promise<boolean>} Успешность операции */ async reconnectNamespace(namespace, immediate = false) { const client = this.clients.get(namespace); try { // Если клиент уже существует, закрываем его if (client) { this.logger('info', `Принудительное переподключение для ${namespace}`); try { // Отключаем ping/pong для этого namespace this.disablePingPong(namespace); // Закрываем соединение client.close(); } catch (e) { this.logger('warn', `Ошибка при закрытии соединения с ${namespace}: ${e instanceof Error ? e.message : String(e)}`); } // Удаляем клиент из кэша this.clients.delete(namespace); } else { this.logger('info', `Инициируем новое подключение для ${namespace}`); } // Устанавливаем состояние переподключения this.setConnectionState(namespace, false, true); // Инкрементируем счетчик попыток this.incrementReconnectAttempts(namespace); // Рассчитываем задержку, если не требуется немедленное переподключение if (!immediate) { const delay = this.calculateReconnectDelay(namespace); this.logger('info', `Переподключение для ${namespace} через ${delay}ms (попытка ${this.getConnectionState(namespace).reconnectAttempts})`); // Ждем рассчитанное время await new Promise(resolve => setTimeout(resolve, delay)); } // Выполняем подключение с соответствующими параметрами let params = {}; // Проверяем тип пространства имен и добавляем соответствующие параметры if (namespace === WebSocketNamespace.REASONING && this.activeReasoningId) { params.reasoningId = this.activeReasoningId; } else if ((namespace === WebSocketNamespace.INDEXING || namespace === WebSocketNamespace.DEPENDENCIES) && this.activeProjectId) { params.projectId = this.activeProjectId; } // Подключаемся await this.connect(namespace, params); // Если это пространство имен рассуждений и есть активное рассуждение, // пытаемся присоединиться к нему if (namespace === WebSocketNamespace.REASONING && this.activeReasoningId) { await this.connectToReasoning(this.activeReasoningId); } else if (namespace === WebSocketNamespace.INDEXING && this.activeProjectId) { await this.connectToIndexing(this.activeProjectId); } else if (namespace === WebSocketNamespace.DEPENDENCIES && this.activeProjectId) { await this.connectToDependencies(this.activeProjectId); } return true; } catch (error) { this.logger('error', `Ошибка при переподключении к ${namespace}: ${error instanceof Error ? error.message : String(error)}`); return false; } } /** * Настраивает периодическую проверку здоровья соединения * @param {number} [interval=30000] Интервал проверки в миллисекундах */ setupConnectionHealthCheck(interval = 30000) { // Останавливаем существующую проверку, если она есть if (this.healthCheckTimer) { clearInterval(this.healthCheckTimer); } this.healthCheckTimer = setInterval(() => { for (const namespace of Object.values(WebSocketNamespace)) { const typedNamespace = namespace; const client = this.clients.get(typedNamespace); if (!client) continue; // Проверяем соединение через WebSocket клиент if (!client.isConnected()) { this.logger('warn', `Соединение с ${namespace} не активно, инициируем переподключение`); this.reconnectNamespace(typedNamespace, false).catch(() => { }); continue; } // Проверяем статистику ping/pong const stats = this.pingStats.get(typedNamespace); if (stats) { const now = Date.now(); // Если последний pong был получен слишком давно if (now - stats.lastPongTimestamp > interval * 2) { this.logger('warn', `Долгое отсутствие активности для ${namespace}, проверка соединения...`); // Отправляем проверочный ping this.send(typedNamespace, 'connection_health_check', { timestamp: now, echo: now }); // Устанавливаем таймаут для проверки ответа setTimeout(() => { const currentStats = this.pingStats.get(typedNamespace); if (currentStats && now - currentStats.lastPongTimestamp > interval * 2) { this.logger('error', `Соединение не отвечает для ${namespace}, инициируем переподключение`); this.reconnectNamespace(typedNamespace, false).catch(() => { }); } }, 5000); // Ждем 5 секунд на ответ } } } }, interval); this.logger('info', 'Настроена периодическая проверка здоровья соединения с интервалом ' + interval + 'ms'); } /** * Сохраняет токен сессии для пространства имен * @param {WebSocketNamespace} namespace Пространство имен * @param {string} token Токен сессии */ saveSessionToken(namespace, token) { if (this.options.enableSessionPersistence !== false) { this.sessionTokens.set(namespace, token); this.logger('info', `Сохранен токен сессии для ${namespace}`, { tokenLength: token.length }); } } /** * Получает сохраненный токен сессии для пространства имен * @param {WebSocketNamespace} namespace Пространство имен * @returns {string | null} Токен сессии или null, если не найден */ getSessionToken(namespace) { if (this.options.enableSessionPersistence === false) { return null; } return this.sessionTokens.get(namespace) || null; } /** * Удаляет сохраненный токен сессии для пространства имен * @param {WebSocketNamespace} namespace Пространство имен */ clearSessionToken(namespace) { this.sessionTokens.delete(namespace); this.logger('info', `Удален токен сессии для ${namespace}`); } /** * Устанавливает состояние подключения для пространства имен * @param {WebSocketNamespace} namespace Пространство имен * @param {boolean} connected Состояние подключения * @param {boolean} reconnecting Состояние переподключения */ setConnecti