/** * Offline Request Queue Service * FE-API-015: Queue requests when offline and replay when back online * * Stores failed requests due to network issues and replays them when connection is restored */ import { AxiosRequestConfig } from 'axios'; import { apiClient } from './api/client'; import { logger } from '@/utils/logger'; /** * Queued request with metadata */ export interface QueuedRequest { id: string; config: AxiosRequestConfig; timestamp: number; retryCount: number; priority: 'high' | 'normal' | 'low'; } /** * Options for queueing requests */ export interface QueueOptions { priority?: 'high' | 'normal' | 'low'; maxRetries?: number; retryDelay?: number; } /** * Offline Queue Service * Manages a queue of failed requests to be retried when connection is restored */ class OfflineQueueService { private queue: QueuedRequest[] = []; private isProcessing = false; private maxQueueSize = 100; // Maximum number of queued requests private defaultMaxRetries = 3; private defaultRetryDelay = 1000; // 1 second /** * Initialize the service */ constructor() { // Load queue from storage on initialization this.loadQueue(); // Listen for online events to process queue if (typeof window !== 'undefined') { window.addEventListener('online', () => { logger.info('[OfflineQueue] Connection restored, processing queue'); this.processQueue(); }); // FIX: Also process queue if we're already online when the service initializes // This handles the case where requests were queued while offline but we're now online if (navigator.onLine && this.queue.length > 0) { // Small delay to ensure everything is initialized setTimeout(() => { // #region agent log fetch('http://127.0.0.1:7242/ingest/09c5ea5e-2380-4cc3-92aa-d26f3b3d26f6',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'offlineQueue.ts:constructor',message:'Auto-processing queue on init (already online)',data:{queueLength:this.queue.length},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{}); // #endregion this.processQueue(); }, 1000); } } } /** * Check if we're currently offline */ private isOffline(): boolean { if (typeof navigator === 'undefined') { return false; } return !navigator.onLine; } /** * Generate a unique ID for a request */ private generateRequestId(): string { return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } /** * Add a request to the queue */ async queueRequest( config: AxiosRequestConfig, options: QueueOptions = {}, ): Promise { const { priority = 'normal' } = options; // Check queue size limit if (this.queue.length >= this.maxQueueSize) { // Remove oldest low-priority request const lowPriorityIndex = this.queue.findIndex( (req) => req.priority === 'low', ); if (lowPriorityIndex !== -1) { this.queue.splice(lowPriorityIndex, 1); } else { // Remove oldest request if no low-priority found this.queue.shift(); } } const queuedRequest: QueuedRequest = { id: this.generateRequestId(), config, timestamp: Date.now(), retryCount: 0, priority, }; // Insert based on priority (high first, then normal, then low) const priorityOrder = { high: 0, normal: 1, low: 2 }; const insertIndex = this.queue.findIndex( (req) => priorityOrder[req.priority] > priorityOrder[priority], ); if (insertIndex === -1) { this.queue.push(queuedRequest); } else { this.queue.splice(insertIndex, 0, queuedRequest); } // Save to storage await this.saveQueue(); logger.info( `[OfflineQueue] Request queued: ${config.method?.toUpperCase()} ${config.url}`, { requestId: queuedRequest.id, priority, queueSize: this.queue.length, }, ); return queuedRequest.id; } /** * Process the queue when back online */ async processQueue(): Promise { // #region agent log fetch('http://127.0.0.1:7242/ingest/09c5ea5e-2380-4cc3-92aa-d26f3b3d26f6',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'offlineQueue.ts:137',message:'processQueue called',data:{isProcessing:this.isProcessing,isOffline:this.isOffline(),queueLength:this.queue.length},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{}); // #endregion if (this.isProcessing || this.isOffline() || this.queue.length === 0) { // #region agent log fetch('http://127.0.0.1:7242/ingest/09c5ea5e-2380-4cc3-92aa-d26f3b3d26f6',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'offlineQueue.ts:139',message:'processQueue early return',data:{reason:this.isProcessing?'processing':this.isOffline()?'offline':'empty'},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{}); // #endregion return; } this.isProcessing = true; logger.info( `[OfflineQueue] Processing ${this.queue.length} queued requests`, ); // #region agent log fetch('http://127.0.0.1:7242/ingest/09c5ea5e-2380-4cc3-92aa-d26f3b3d26f6',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'offlineQueue.ts:142',message:'Starting queue processing',data:{queueLength:this.queue.length},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{}); // #endregion // Process requests in order (high priority first) while (this.queue.length > 0 && !this.isOffline()) { const request = this.queue[0]; // #region agent log fetch('http://127.0.0.1:7242/ingest/09c5ea5e-2380-4cc3-92aa-d26f3b3d26f6',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'offlineQueue.ts:149',message:'Processing request',data:{requestId:request.id,method:request.config.method,url:request.config.url,retryCount:request.retryCount},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{}); // #endregion try { // Retry the request await apiClient.request(request.config); // Success - remove from queue this.queue.shift(); await this.saveQueue(); logger.info( `[OfflineQueue] Request succeeded: ${request.config.method?.toUpperCase()} ${request.config.url}`, { requestId: request.id, }, ); // Small delay between requests to avoid overwhelming the server await new Promise((resolve) => setTimeout(resolve, 100)); } catch (error) { // Check if we should retry request.retryCount++; const maxRetries = this.defaultMaxRetries; if (request.retryCount >= maxRetries) { // Max retries reached - remove from queue logger.error( `[OfflineQueue] Request failed after ${maxRetries} retries: ${request.config.method?.toUpperCase()} ${request.config.url}`, { requestId: request.id, error, }, ); this.queue.shift(); await this.saveQueue(); } else { // Move to end of queue for retry this.queue.shift(); this.queue.push(request); await this.saveQueue(); // Wait before retrying await new Promise((resolve) => setTimeout(resolve, this.defaultRetryDelay * request.retryCount), ); } // If we went offline again, stop processing if (this.isOffline()) { logger.warn( '[OfflineQueue] Connection lost, stopping queue processing', ); break; } } } this.isProcessing = false; if (this.queue.length > 0) { logger.info( `[OfflineQueue] Queue processing complete, ${this.queue.length} requests remaining`, ); } else { logger.info('[OfflineQueue] All queued requests processed successfully'); } } /** * Get the current queue size */ getQueueSize(): number { const size = this.queue.length; // #region agent log fetch('http://127.0.0.1:7242/ingest/09c5ea5e-2380-4cc3-92aa-d26f3b3d26f6',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'offlineQueue.ts:220',message:'getQueueSize called',data:{size,isProcessing:this.isProcessing,isOffline:this.isOffline()},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{}); // #endregion return size; } /** * Get all queued requests (for debugging/monitoring) */ getQueue(): QueuedRequest[] { return [...this.queue]; } /** * Clear the queue */ async clearQueue(): Promise { // #region agent log fetch('http://127.0.0.1:7242/ingest/09c5ea5e-2380-4cc3-92aa-d26f3b3d26f6',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'offlineQueue.ts:234',message:'clearQueue called',data:{queueLength:this.queue.length},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{}); // #endregion this.queue = []; await this.saveQueue(); logger.info('[OfflineQueue] Queue cleared'); } /** * Remove a specific request from the queue */ async removeRequest(requestId: string): Promise { const index = this.queue.findIndex((req) => req.id === requestId); if (index !== -1) { this.queue.splice(index, 1); await this.saveQueue(); logger.info(`[OfflineQueue] Request removed from queue: ${requestId}`); return true; } return false; } /** * Save queue to localStorage */ private async saveQueue(): Promise { try { if (typeof window !== 'undefined' && window.localStorage) { const serialized = JSON.stringify(this.queue); localStorage.setItem('veza_offline_queue', serialized); } } catch (error) { logger.error('[OfflineQueue] Failed to save queue to localStorage', { error, }); } } /** * Load queue from localStorage */ private async loadQueue(): Promise { try { if (typeof window !== 'undefined' && window.localStorage) { const serialized = localStorage.getItem('veza_offline_queue'); if (serialized) { const parsed = JSON.parse(serialized); // Validate and filter out old requests (older than 24 hours) const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000; this.queue = parsed.filter( (req: QueuedRequest) => req.timestamp > oneDayAgo, ); if (this.queue.length !== parsed.length) { await this.saveQueue(); } logger.info( `[OfflineQueue] Loaded ${this.queue.length} requests from storage`, ); } } } catch (error) { logger.error('[OfflineQueue] Failed to load queue from localStorage', { error, }); this.queue = []; } } /** * Check if a request should be queued * Some requests (like GET) might not need to be queued */ shouldQueueRequest(config: AxiosRequestConfig): boolean { const method = config.method?.toUpperCase(); // Don't queue GET requests (they can be retried fresh) if (method === 'GET') { return false; } // Queue mutation requests (POST, PUT, DELETE, PATCH) return ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method || ''); } } // Singleton instance export const offlineQueue = new OfflineQueueService();