297 lines
8.2 KiB
TypeScript
297 lines
8.2 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, AxiosResponse } 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();
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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',
|
|
maxRetries = this.defaultMaxRetries,
|
|
} = 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> {
|
|
if (this.isProcessing || this.isOffline() || this.queue.length === 0) {
|
|
return;
|
|
}
|
|
|
|
this.isProcessing = true;
|
|
logger.info(`[OfflineQueue] Processing ${this.queue.length} queued requests`);
|
|
|
|
// Process requests in order (high priority first)
|
|
while (this.queue.length > 0 && !this.isOffline()) {
|
|
const request = this.queue[0];
|
|
|
|
try {
|
|
// Retry the request
|
|
const response = 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 {
|
|
return this.queue.length;
|
|
}
|
|
|
|
/**
|
|
* Get all queued requests (for debugging/monitoring)
|
|
*/
|
|
getQueue(): QueuedRequest[] {
|
|
return [...this.queue];
|
|
}
|
|
|
|
/**
|
|
* Clear the queue
|
|
*/
|
|
async clearQueue(): Promise<void> {
|
|
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();
|
|
|