solver-sdk
Version:
SDK для интеграции с Code Solver Backend API
634 lines • 32.4 kB
JavaScript
// Импортируем 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