veza/apps/web/src/services/offlineQueue.ts
senke 09bb663659 chore: enable noUncheckedIndexedAccess, isolate ghost MSW handlers, document go-clamd tech debt
- Enable TypeScript noUncheckedIndexedAccess and fix 133 resulting errors
  across 46 files with proper null guards, optional chaining, and fallbacks
- Extract education/gamification ghost feature MSW handlers into handlers-ghost.ts
- Add Storybook test plugin documentation in vitest.config.ts
- Document abandoned go-clamd dependency (2017) as tech debt in upload_validator.go

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 23:12:35 +01:00

352 lines
12 KiB
TypeScript

/**
* 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<string> {
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<void> {
// #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];
if (!request) break;
// #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<void> {
// #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<boolean> {
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<void> {
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<void> {
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();