// Connection management with retry logic export interface ConnectionConfig { maxRetries: number; initialDelay: number; maxDelay: number; backoffMultiplier: number; timeout: number; onlineCheckInterval: number; } export interface RetryOptions { retries?: number; delay?: number; onRetry?: (attempt: number, error: Error) => void; } export type ConnectionStatus = 'online' | 'offline' | 'connecting' | 'error'; export interface ConnectionState { status: ConnectionStatus; lastError?: Error; retryCount: number; lastOnlineTime?: Date; } export class ConnectionManager { private static instance: ConnectionManager; private config: ConnectionConfig; private state: ConnectionState; private listeners: Map void> = new Map(); private onlineCheckTimer?: number; private reconnectTimer?: number; private constructor() { this.config = { maxRetries: 3, initialDelay: 1000, // 1 second maxDelay: 30000, // 30 seconds backoffMultiplier: 2, timeout: 10000, // 10 seconds onlineCheckInterval: 5000 // 5 seconds }; this.state = { status: navigator.onLine ? 'online' : 'offline', retryCount: 0 }; this.setupEventListeners(); this.startOnlineCheck(); } static getInstance(): ConnectionManager { if (!ConnectionManager.instance) { ConnectionManager.instance = new ConnectionManager(); } return ConnectionManager.instance; } // Configure connection settings configure(config: Partial): void { this.config = { ...this.config, ...config }; } // Setup browser online/offline event listeners private setupEventListeners(): void { window.addEventListener('online', () => { console.log('Browser online event detected'); this.updateState({ status: 'online', retryCount: 0 }); this.checkServerConnection(); }); window.addEventListener('offline', () => { console.log('Browser offline event detected'); this.updateState({ status: 'offline' }); }); // Listen for visibility changes to check connection when tab becomes active document.addEventListener('visibilitychange', () => { if (!document.hidden && this.state.status === 'offline') { this.checkServerConnection(); } }); } // Start periodic online checking private startOnlineCheck(): void { this.onlineCheckTimer = window.setInterval(() => { if (this.state.status === 'offline' || this.state.status === 'error') { this.checkServerConnection(); } }, this.config.onlineCheckInterval); } // Check actual server connection async checkServerConnection(): Promise { if (!navigator.onLine) { this.updateState({ status: 'offline' }); return false; } this.updateState({ status: 'connecting' }); try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); const response = await fetch('/health', { method: 'GET', signal: controller.signal, cache: 'no-cache' }); clearTimeout(timeoutId); if (response.ok) { this.updateState({ status: 'online', retryCount: 0, lastOnlineTime: new Date() }); return true; } else { throw new Error(`Server returned status ${response.status}`); } } catch (error) { this.updateState({ status: 'error', lastError: error as Error }); return false; } } // Retry a failed request with exponential backoff async retryRequest( request: () => Promise, options: RetryOptions = {} ): Promise { const { retries = this.config.maxRetries, delay = this.config.initialDelay, onRetry } = options; let lastError: Error; for (let attempt = 0; attempt <= retries; attempt++) { try { // Check if we're online before attempting if (!navigator.onLine) { throw new Error('No internet connection'); } // Add timeout to request const result = await this.withTimeout(request(), this.config.timeout); // Success - reset retry count if (this.state.retryCount > 0) { this.updateState({ retryCount: 0 }); } return result; } catch (error) { lastError = error as Error; // Don't retry if offline if (!navigator.onLine) { this.updateState({ status: 'offline' }); throw new Error('Request failed: No internet connection'); } // Don't retry on client errors (4xx) if (this.isClientError(error)) { throw error; } // Call retry callback if provided if (onRetry && attempt < retries) { onRetry(attempt + 1, lastError); } // If we have retries left, wait and try again if (attempt < retries) { const backoffDelay = Math.min( delay * Math.pow(this.config.backoffMultiplier, attempt), this.config.maxDelay ); console.log(`Retry attempt ${attempt + 1}/${retries} after ${backoffDelay}ms`); // Update retry count in state this.updateState({ retryCount: attempt + 1 }); await this.delay(backoffDelay); } } } // All retries exhausted this.updateState({ status: 'error', lastError: lastError! }); throw new Error(`Request failed after ${retries} retries: ${lastError!.message}`); } // Add timeout to a promise private withTimeout(promise: Promise, timeout: number): Promise { return Promise.race([ promise, new Promise((_, reject) => { setTimeout(() => reject(new Error('Request timeout')), timeout); }) ]); } // Check if error is a client error (4xx) private isClientError(error: any): boolean { if (error.response && error.response.status >= 400 && error.response.status < 500) { return true; } // Check for specific error messages that shouldn't be retried const message = error.message?.toLowerCase() || ''; const noRetryErrors = ['unauthorized', 'forbidden', 'bad request', 'not found']; return noRetryErrors.some(e => message.includes(e)); } // Delay helper private delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } // Update connection state private updateState(updates: Partial): void { this.state = { ...this.state, ...updates }; this.notifyListeners(); } // Subscribe to connection state changes subscribe(id: string, callback: (state: ConnectionState) => void): void { this.listeners.set(id, callback); // Immediately call with current state callback(this.state); } // Unsubscribe from connection state changes unsubscribe(id: string): void { this.listeners.delete(id); } // Notify all listeners of state change private notifyListeners(): void { this.listeners.forEach(callback => callback(this.state)); } // Get current connection state getState(): ConnectionState { return { ...this.state }; } // Check if currently online isOnline(): boolean { return this.state.status === 'online'; } // Manual reconnect attempt async reconnect(): Promise { console.log('Manual reconnect requested'); return this.checkServerConnection(); } // Cleanup destroy(): void { if (this.onlineCheckTimer) { clearInterval(this.onlineCheckTimer); } if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); } this.listeners.clear(); } } // Helper function for retrying fetch requests export async function fetchWithRetry( url: string, options: RequestInit = {}, retryOptions: RetryOptions = {} ): Promise { const connectionManager = ConnectionManager.getInstance(); return connectionManager.retryRequest(async () => { const response = await fetch(url, options); if (!response.ok && response.status >= 500) { // Server error - throw to trigger retry throw new Error(`Server error: ${response.status}`); } return response; }, retryOptions); } // Helper function for retrying JSON requests export async function fetchJSONWithRetry( url: string, options: RequestInit = {}, retryOptions: RetryOptions = {} ): Promise { const response = await fetchWithRetry(url, options, retryOptions); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); }