solver-sdk
Version:
SDK for WorkAI API - AI-powered code analysis with WorkCoins billing system
438 lines • 20.7 kB
JavaScript
/**
* 🔌 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