UNPKG

solver-sdk

Version:

SDK for WorkAI API - AI-powered code analysis with WorkCoins billing system

438 lines 20.7 kB
/** * 🔌 Project Sync WebSocket Client * * Клиент для работы с real-time уведомлениями о статусе синхронизации проектов. * Интегрируется с backend ProjectSyncGateway через Socket.io. * * @features * - Real-time push notifications о статусе индексации * - Auto-reconnection при разрыве соединения * - Graceful fallback к REST polling * - TypeScript поддержка для всех событий */ import { io } from 'socket.io-client'; /** * 🔌 WebSocket клиент для project sync уведомлений */ export class ProjectSyncClient { constructor(options) { this.socket = null; this.connected = false; this.retryCount = 0; this.reconnectTimer = null; this.subscribedProjects = new Set(); this.idleWarningTimer = null; this.IDLE_WARNING_DELAY = 25000; // Предупреждение через 25 секунд без подписок // 🔄 Error debounce для снижения спама логов this.errorDebounce = new Map(); this.ERROR_DEBOUNCE_MS = 5000; // 5 секунд debounce для одинаковых ошибок // 📝 Последняя ошибка подключения (для reconnect_exhausted) this.lastConnectionError = ''; // Event handlers this.eventHandlers = new Map(); this.baseURL = options.baseURL; this.options = { connectionTimeout: 10000, maxRetries: 5, retryDelay: 2000, debug: false, disableAutoReconnect: false, logLevel: options.debug ? 'debug' : 'error', // Совместимость с legacy debug флагом ...options }; // Инициализируем logger с поддержкой logLevel и debounce this.logger = { log: (msg) => this.shouldLog('info') && console.log(`[ProjectSyncClient] ${msg}`), warn: (msg) => this.shouldLog('warn') && console.warn(`[ProjectSyncClient] ${msg}`), error: (msg) => this.logErrorWithDebounce(msg), debug: (msg) => this.shouldLog('debug') && console.debug(`[ProjectSyncClient] ${msg}`) }; // Инициализируем event handlers map (синхронизировано с backend) const eventTypes = [ 'sync-status-update', 'sync-progress', 'sync-completed', 'error', 'connected', 'disconnected', 'project-sync-joined', 'project-sync-left', 'disconnect-idle', 'reconnect_exhausted' ]; eventTypes.forEach(type => { this.eventHandlers.set(type, new Set()); }); } /** * 📝 Проверка уровня логирования */ shouldLog(level) { const logLevel = this.options.logLevel || 'error'; if (logLevel === 'silent') return false; const levels = ['error', 'warn', 'info', 'debug']; const currentLevelIndex = levels.indexOf(logLevel); const requiredLevelIndex = levels.indexOf(level); return requiredLevelIndex <= currentLevelIndex; } /** * 🔄 Логирование ошибок с debounce для снижения спама */ logErrorWithDebounce(msg) { if (!this.shouldLog('error')) return; const now = Date.now(); const lastTime = this.errorDebounce.get(msg) || 0; // Пропускаем если такая же ошибка была менее 5 секунд назад if (now - lastTime < this.ERROR_DEBOUNCE_MS) { return; } this.errorDebounce.set(msg, now); console.error(`[ProjectSyncClient] ${msg}`); // Очищаем старые записи debounce (память) if (this.errorDebounce.size > 100) { const cutoff = now - this.ERROR_DEBOUNCE_MS * 2; for (const [key, time] of this.errorDebounce.entries()) { if (time < cutoff) { this.errorDebounce.delete(key); } } } } /** * 🔌 Подключение к WebSocket */ async connect() { return new Promise(async (resolve, reject) => { try { this.logger.debug(`Подключение к ${this.baseURL}/project-sync`); // ✅ Получаем актуальный токен (ТОЛЬКО через getAuthToken - fail-fast) if (!this.options.getAuthToken) { const error = '[ProjectSyncClient] getAuthToken callback is required. Static headers are not supported.'; this.logger.error(error); throw new Error(error); } this.logger.debug('[ProjectSyncClient] Calling getAuthToken()...'); const token = await this.options.getAuthToken(); if (!token) { const error = '[ProjectSyncClient] getAuthToken returned null/empty token. Cannot authenticate.'; this.logger.error(error); throw new Error(error); } this.logger.debug(`[ProjectSyncClient] Token received: ${token.substring(0, 10)}...`); const queryParams = { token }; this.logger.debug(`[ProjectSyncClient] Creating Socket.io: url=${this.baseURL}/project-sync, hasToken=${!!token}, tokenPreview=${token ? token.substring(0, 15) + '...' : 'NONE'}`); // Создаем Socket.io соединение this.socket = io(`${this.baseURL}/project-sync`, { query: queryParams, // ✅ Передаем токен в query для авторизации transports: ['websocket'], // ✅ Только WebSocket, без polling (предотвращает множественные подключения) upgrade: false, // ✅ Отключаем upgrade process (уже используем только websocket) rememberUpgrade: true, // ✅ Запоминает успешный WebSocket для последующих подключений timeout: this.options.connectionTimeout, reconnectionAttempts: 0, // Отключаем автоматический reconnect Socket.io (управляем вручную) reconnection: false, // ✅ Полностью отключаем auto-reconnect autoConnect: true, // Подключаемся сразу при создании extraHeaders: this.options.headers || {}, }); // Обработчик успешного подключения this.socket.on('connect', () => { this.connected = true; this.retryCount = 0; this.logger.log('✅ WebSocket подключение установлено'); this.emit('connected', {}); // ⚠️ Запускаем таймер предупреждения если нет подписок this.scheduleIdleWarning(); resolve(); }); // Обработчик отключения this.socket.on('disconnect', (reason) => { this.connected = false; this.logger.warn(`🔌 WebSocket отключен: ${reason}`); this.emit('disconnected', { reason }); // 🔄 Автоматическое переподключение (если не отключено) // Расширенные триггеры: io server disconnect, transport close, ping timeout if (!this.options.disableAutoReconnect) { const reconnectReasons = ['io server disconnect', 'transport close', 'ping timeout']; if (reconnectReasons.includes(reason)) { this.scheduleReconnect(); } } }); // Обработчик ошибок подключения this.socket.on('connect_error', (error) => { this.lastConnectionError = error.message; this.logger.error(`❌ Ошибка подключения: ${error.message}`); reject(new Error(`WebSocket connection failed: ${error.message}`)); }); // 📊 Обработчики project sync событий this.setupEventHandlers(); // Таймаут подключения setTimeout(() => { if (!this.connected) { reject(new Error('WebSocket connection timeout')); } }, this.options.connectionTimeout); } catch (error) { reject(error); } }); } /** * ❌ Отключение от WebSocket */ disconnect() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.idleWarningTimer) { clearTimeout(this.idleWarningTimer); this.idleWarningTimer = null; } if (this.socket) { this.socket.disconnect(); this.socket = null; } this.connected = false; this.subscribedProjects.clear(); this.logger.log('🔌 WebSocket отключен вручную'); } /** * 📡 Подписка на проект для получения уведомлений */ subscribeToProject(projectId, userId) { if (!this.connected || !this.socket) { this.logger.warn(`⚠️ Попытка подписки на проект ${projectId} без соединения`); return; } const joinData = { projectId, userId }; this.socket.emit('join-project-sync', joinData); this.subscribedProjects.add(projectId); this.logger.debug(`📡 Подписка на проект: ${projectId}`); // ✅ Отменяем idle warning - есть активная подписка if (this.idleWarningTimer) { clearTimeout(this.idleWarningTimer); this.idleWarningTimer = null; } } /** * 📡 Отписка от проекта */ unsubscribeFromProject(projectId) { if (!this.connected || !this.socket) { return; } this.socket.emit('leave-project-sync', { projectId }); this.subscribedProjects.delete(projectId); this.logger.debug(`📡 Отписка от проекта: ${projectId}`); // ⚠️ Если больше нет подписок, запускаем idle warning if (this.subscribedProjects.size === 0) { this.scheduleIdleWarning(); } } /** * 👂 Подписка на события */ on(event, handler) { const handlers = this.eventHandlers.get(event); if (handlers) { handlers.add(handler); } } /** * 👂 Отписка от событий */ off(event, handler) { const handlers = this.eventHandlers.get(event); if (handlers) { handlers.delete(handler); } } /** * 📢 Эмиссия событий */ emit(event, data) { const handlers = this.eventHandlers.get(event); if (handlers) { handlers.forEach(handler => { try { handler(data); } catch (error) { this.logger.error(`Error in event handler for ${event}: ${error}`); } }); } } /** * 🔧 Настройка обработчиков событий от backend */ setupEventHandlers() { if (!this.socket) return; // 📊 Обновление статуса синхронизации this.socket.on('sync-status-update', (data) => { const safeData = { projectId: data?.projectId || 'unknown', sessionId: data?.sessionId || 'unknown', status: data?.status || 'unknown', progress: data?.progress || 0, message: data?.message || '', timestamp: data?.timestamp || new Date(), chunksProcessed: data?.chunksProcessed || 0, totalChunks: data?.totalChunks || 0, embeddingsCreated: data?.embeddingsCreated || 0 }; this.logger.debug(`📊 Статус обновлен: ${safeData.status} для проекта ${safeData.projectId}`); this.emit('sync-status-update', safeData); }); // 📈 Прогресс синхронизации this.socket.on('sync-progress', (data) => { const safeData = { projectId: data?.projectId || 'unknown', sessionId: data?.sessionId || 'unknown', stage: data?.stage || 'unknown', progress: data?.progress || 0, currentChunk: data?.currentChunk || 0, totalChunks: data?.totalChunks || 0, estimatedTimeRemaining: data?.estimatedTimeRemaining || 0, details: data?.details || '' }; this.logger.debug(`📈 Прогресс: ${safeData.progress}% (${safeData.stage}) для проекта ${safeData.projectId}`); this.emit('sync-progress', safeData); }); // ✅ Синхронизация завершена this.socket.on('sync-completed', (data) => { const safeData = { projectId: data?.projectId || 'unknown', sessionId: data?.sessionId || 'unknown', success: data?.success !== false, // По умолчанию true message: data?.message || '', totalProcessed: data?.totalProcessed || 0, duration: data?.duration || 0, error: data?.error || '' }; this.logger.log(`✅ Синхронизация завершена: ${safeData.success ? 'SUCCESS' : 'FAILED'} для проекта ${safeData.projectId}`); this.emit('sync-completed', safeData); }); // ❌ Ошибки this.socket.on('error', (data) => { // Безопасная обработка undefined/неполных данных const safeData = { projectId: data?.projectId || 'unknown', sessionId: data?.sessionId || 'unknown', error: data?.error || data?.message || (typeof data === 'object' ? JSON.stringify(data) : String(data)) || 'Unknown error', code: data?.code || 'UNKNOWN_ERROR', timestamp: data?.timestamp || new Date(), recoverable: data?.recoverable !== false // По умолчанию true }; this.logger.error(`❌ Ошибка для проекта ${safeData.projectId}: ${safeData.error}`); this.emit('error', safeData); }); // ✅ Успешная подписка на проект (от backend) this.socket.on('project-sync-joined', (data) => { const safeData = { projectId: data?.projectId || 'unknown', message: data?.message || '', connectedClients: data?.connectedClients || 1, timestamp: data?.timestamp || new Date(), }; this.logger.debug(`✅ Подписка подтверждена: ${safeData.projectId} (${safeData.connectedClients} клиентов)`); this.emit('project-sync-joined', safeData); }); // 👋 Успешная отписка от проекта (от backend) this.socket.on('project-sync-left', (data) => { const safeData = { projectId: data?.projectId || 'unknown', message: data?.message || '', timestamp: data?.timestamp || new Date(), }; this.logger.debug(`👋 Отписка подтверждена: ${safeData.projectId}`); this.emit('project-sync-left', safeData); }); // ⏰ Принудительное отключение по idle timeout (от backend) this.socket.on('disconnect-idle', (data) => { const safeData = { reason: data?.reason || 'Idle timeout', idleTime: data?.idleTime || 0, maxIdleTime: data?.maxIdleTime || 30, timestamp: data?.timestamp || new Date(), }; this.logger.warn(`⏰ Отключение по idle: ${safeData.reason} (${safeData.idleTime}s)`); this.emit('disconnect-idle', safeData); }); } /** * ⚠️ Планирование idle предупреждения */ scheduleIdleWarning() { // Отменяем предыдущий таймер если есть if (this.idleWarningTimer) { clearTimeout(this.idleWarningTimer); } this.idleWarningTimer = setTimeout(() => { if (this.connected && this.subscribedProjects.size === 0) { this.logger.warn(`⚠️ WebSocket подключен ${this.IDLE_WARNING_DELAY / 1000}s, но нет подписок на проекты. ` + `Вызовите subscribeToProject(projectId) или disconnect() для освобождения ресурсов.`); } }, this.IDLE_WARNING_DELAY); } /** * 🔄 Планирование переподключения */ scheduleReconnect() { // Проверяем флаг disableAutoReconnect if (this.options.disableAutoReconnect) { this.logger.debug('🔄 Auto-reconnect отключен (disableAutoReconnect=true)'); return; } if (this.retryCount >= this.options.maxRetries) { this.logger.error(`❌ Превышено максимальное количество попыток переподключения (${this.options.maxRetries})`); // 🔔 Отправляем событие reconnect_exhausted const exhaustedEvent = { attempts: this.retryCount, maxAttempts: this.options.maxRetries, lastError: this.lastConnectionError || undefined, timestamp: new Date(), }; this.emit('reconnect_exhausted', exhaustedEvent); return; } const delay = Math.min(this.options.retryDelay * Math.pow(2, this.retryCount), 30000); this.retryCount++; this.logger.warn(`🔄 Переподключение через ${delay}ms (попытка ${this.retryCount}/${this.options.maxRetries})`); this.reconnectTimer = setTimeout(async () => { try { await this.connect(); // Восстанавливаем подписки на проекты this.subscribedProjects.forEach(projectId => { this.subscribeToProject(projectId); }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.lastConnectionError = errorMessage; this.logger.error(`❌ Ошибка переподключения: ${errorMessage}`); this.scheduleReconnect(); // Планируем следующую попытку } }, delay); } /** * 🔍 Проверка статуса подключения */ get isConnected() { return this.connected; } /** * 📋 Список подписанных проектов */ get subscribedProjectIds() { return Array.from(this.subscribedProjects); } } //# sourceMappingURL=project-sync-client.js.map