- 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>
352 lines
12 KiB
TypeScript
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();
|