UNPKG

@bugspotter/sdk

Version:

Professional bug reporting SDK with screenshots, session replay, and automatic error capture for web applications

302 lines (301 loc) 10.8 kB
"use strict"; /** * Offline queue for storing failed requests with localStorage persistence */ Object.defineProperty(exports, "__esModule", { value: true }); exports.OfflineQueue = exports.LocalStorageAdapter = void 0; exports.clearOfflineQueue = clearOfflineQueue; const logger_1 = require("../utils/logger"); /** * LocalStorage implementation of StorageAdapter */ class LocalStorageAdapter { getItem(key) { try { if (typeof localStorage === 'undefined') { return null; } return localStorage.getItem(key); } catch (_a) { return null; } } setItem(key, value) { if (typeof localStorage === 'undefined') { return; } localStorage.setItem(key, value); } removeItem(key) { try { if (typeof localStorage === 'undefined') { return; } localStorage.removeItem(key); } catch (_a) { // Ignore errors } } } exports.LocalStorageAdapter = LocalStorageAdapter; // ============================================================================ // CONSTANTS // ============================================================================ const QUEUE_STORAGE_KEY = 'bugspotter_offline_queue'; const QUEUE_EXPIRY_DAYS = 7; const MAX_RETRY_ATTEMPTS = 5; const MAX_ITEM_SIZE_BYTES = 100 * 1024; // 100KB per item const DEFAULT_OFFLINE_CONFIG = { enabled: false, maxQueueSize: 10, }; // ============================================================================ // OFFLINE QUEUE CLASS // ============================================================================ class OfflineQueue { constructor(config, logger, storage) { this.requestCounter = 0; this.config = Object.assign(Object.assign({}, DEFAULT_OFFLINE_CONFIG), config); this.logger = logger || (0, logger_1.getLogger)(); this.storage = storage || new LocalStorageAdapter(); } /** * Queue a request for offline retry */ enqueue(endpoint, body, headers) { try { const serializedBody = this.serializeBody(body); if (!serializedBody || !this.validateItemSize(serializedBody)) { return; } const queue = this.getQueue(); // Ensure space in queue if (queue.length >= this.config.maxQueueSize) { this.logger.warn(`Offline queue is full (${this.config.maxQueueSize}), removing oldest request`); queue.shift(); } queue.push(this.createQueuedRequest(endpoint, serializedBody, headers)); this.saveQueue(queue); this.logger.log(`Request queued for offline retry (queue size: ${queue.length})`); } catch (error) { this.logger.error('Failed to queue request for offline retry:', error); } } /** * Process offline queue */ async process(retryableStatusCodes) { const queue = this.getQueue(); if (queue.length === 0) { return; } this.logger.log(`Processing offline queue (${queue.length} requests)`); const successfulIds = []; const failedRequests = []; for (const request of queue) { // Check if request has exceeded max retry attempts if (request.attempts >= MAX_RETRY_ATTEMPTS) { this.logger.warn(`Max retry attempts (${MAX_RETRY_ATTEMPTS}) reached for request (id: ${request.id}), removing`); continue; } // Check if request has expired const age = Date.now() - request.timestamp; const maxAge = QUEUE_EXPIRY_DAYS * 24 * 60 * 60 * 1000; if (age > maxAge) { this.logger.warn(`Removing expired queued request (id: ${request.id})`); continue; } try { // Attempt to send const response = await fetch(request.endpoint, { method: 'POST', headers: request.headers, body: request.body, }); if (response.ok) { this.logger.log(`Successfully sent queued request (id: ${request.id})`); successfulIds.push(request.id); } else if (retryableStatusCodes.includes(response.status)) { // Keep in queue for next attempt request.attempts++; failedRequests.push(request); this.logger.warn(`Queued request failed with status ${response.status}, will retry later (id: ${request.id})`); } else { // Non-retryable error, remove from queue this.logger.warn(`Queued request failed with non-retryable status ${response.status}, removing (id: ${request.id})`); } } catch (error) { // Network error, keep in queue request.attempts++; failedRequests.push(request); this.logger.warn(`Queued request failed with network error, will retry later (id: ${request.id}):`, error); } } // Update queue (remove successful and expired, keep failed) this.saveQueue(failedRequests); if (successfulIds.length > 0 || failedRequests.length < queue.length) { this.logger.log(`Offline queue processed: ${successfulIds.length} successful, ${failedRequests.length} remaining`); } } /** * Clear offline queue */ clear() { try { this.storage.removeItem(QUEUE_STORAGE_KEY); } catch (_a) { // Ignore storage errors } } /** * Get queue size */ size() { return this.getQueue().length; } // ============================================================================ // PRIVATE METHODS // ============================================================================ /** * Serialize body to string format */ serializeBody(body) { if (typeof body === 'string') { return body; } if (body instanceof Blob) { this.logger.warn('Cannot queue Blob for offline retry, skipping'); return null; } return JSON.stringify(body); } /** * Create a queued request object */ createQueuedRequest(endpoint, body, headers) { return { id: this.generateRequestId(), endpoint, body, headers, timestamp: Date.now(), attempts: 0, }; } /** * Validate that item size doesn't exceed localStorage limits */ validateItemSize(body) { const sizeInBytes = new Blob([body]).size; if (sizeInBytes > MAX_ITEM_SIZE_BYTES) { this.logger.warn(`Request body too large (${sizeInBytes} bytes), skipping queue`); return false; } return true; } getQueue() { try { const stored = this.storage.getItem(QUEUE_STORAGE_KEY); if (!stored) { return []; } return JSON.parse(stored); } catch (error) { // Log corrupted data and clear it to prevent repeated errors this.logger.warn('Failed to parse offline queue data, clearing corrupted queue:', error); this.clear(); return []; } } saveQueue(queue) { try { this.storage.setItem(QUEUE_STORAGE_KEY, JSON.stringify(queue)); } catch (error) { // Handle quota exceeded error (check multiple properties for cross-browser compatibility) if (this.isQuotaExceededError(error)) { this.logger.error('localStorage quota exceeded, clearing oldest items'); this.clearOldestItems(queue); } else { this.logger.error('Failed to save offline queue:', error); } } } /** * Check if error is a quota exceeded error (cross-browser compatible) */ isQuotaExceededError(error) { if (!(error instanceof Error)) { return false; } // Check error name (standard) if (error.name === 'QuotaExceededError') { return true; } // Check DOMException code (Safari, older browsers) if ('code' in error && error.code === 22) { return true; } // Check error message as fallback (Firefox, Chrome variants) const message = error.message.toLowerCase(); return message.includes('quota') || message.includes('storage') || message.includes('exceeded'); } /** * Clear oldest 50% of items and retry save */ clearOldestItems(queue) { try { const trimmedQueue = queue.slice(Math.floor(queue.length / 2)); this.storage.setItem(QUEUE_STORAGE_KEY, JSON.stringify(trimmedQueue)); this.logger.log(`Trimmed offline queue to ${trimmedQueue.length} items due to quota`); } catch (_a) { // If still failing, clear everything this.logger.error('Failed to save even after trimming, clearing queue'); this.clear(); } } generateRequestId() { // Increment counter for additional entropy this.requestCounter = (this.requestCounter + 1) % 10000; // Use crypto.getRandomValues for better randomness (browser-safe fallback) let randomPart; if (typeof crypto !== 'undefined' && crypto.getRandomValues) { const array = new Uint32Array(2); crypto.getRandomValues(array); randomPart = Array.from(array, (num) => { return num.toString(36); }).join(''); } else { // Fallback to Math.random for environments without crypto randomPart = Math.random().toString(36).substring(2, 9) + Math.random().toString(36).substring(2, 9); } return `req_${Date.now()}_${this.requestCounter}_${randomPart}`; } } exports.OfflineQueue = OfflineQueue; /** * Global function to clear offline queue */ function clearOfflineQueue() { try { const storage = new LocalStorageAdapter(); storage.removeItem(QUEUE_STORAGE_KEY); } catch (error) { // Log error for debugging purposes const logger = (0, logger_1.getLogger)(); logger.warn('Failed to clear offline queue:', error); } }