solver-sdk
Version:
SDK для интеграции с Code Solver Backend API (совместимо с браузером и Node.js), с поддержкой функциональности мышления (Thinking Mode)
1,112 lines • 88.9 kB
JavaScript
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