/** * UpdateManager - Comprehensive PWA update management system * * Automatically detects new application versions and forcefully * updates all cache levels: Service Worker, browser cache, localStorage * * @class UpdateManager */ class UpdateManager { constructor(options = {}) { this.options = { // URL for version check (meta.json) versionUrl: options.versionUrl || '/meta.json', // Update check interval (ms) checkInterval: options.checkInterval || 60000, // 1 minute // Local storage key for version versionKey: options.versionKey || 'app_version', // Keys for preserving critical data before cleanup preserveKeys: options.preserveKeys || [ 'auth_token', 'user_settings', 'encryption_keys', 'peer_connections' ], // Callback on update detection onUpdateAvailable: options.onUpdateAvailable || null, // Callback on error onError: options.onError || null, // Logging debug: options.debug || false, // Force check on load checkOnLoad: options.checkOnLoad !== false, // Request timeout requestTimeout: options.requestTimeout || 10000 }; this.currentVersion = null; this.serverVersion = null; this.checkIntervalId = null; this.isUpdating = false; this.updatePromise = null; // Initialization this.init(); } /** * Initialize update manager */ async init() { try { // Load current version from localStorage this.currentVersion = this.getLocalVersion(); if (this.options.debug) { console.log('🔄 UpdateManager initialized', { currentVersion: this.currentVersion, versionUrl: this.options.versionUrl }); } // Check version on load if (this.options.checkOnLoad) { await this.checkForUpdates(); } // Start periodic check this.startPeriodicCheck(); // Listen to Service Worker events this.setupServiceWorkerListeners(); } catch (error) { this.handleError('Init failed', error); } } /** * Get local version from localStorage */ getLocalVersion() { try { return localStorage.getItem(this.options.versionKey) || null; } catch (error) { this.handleError('Failed to get local version', error); return null; } } /** * Save version to localStorage */ setLocalVersion(version) { try { localStorage.setItem(this.options.versionKey, version); this.currentVersion = version; if (this.options.debug) { console.log('✅ Version saved:', version); } } catch (error) { this.handleError('Failed to save version', error); } } /** * Check for updates on server */ async checkForUpdates() { // Prevent parallel checks if (this.updatePromise) { return this.updatePromise; } this.updatePromise = this._performCheck(); const result = await this.updatePromise; this.updatePromise = null; return result; } /** * Perform version check */ async _performCheck() { try { if (this.options.debug) { console.log('🔍 Checking for updates...'); } // Request meta.json with cache-busting const response = await this.fetchWithTimeout( `${this.options.versionUrl}?t=${Date.now()}`, { method: 'GET', cache: 'no-store', headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0' } } ); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const meta = await response.json(); this.serverVersion = meta.version || meta.buildVersion || null; if (!this.serverVersion) { throw new Error('Version not found in meta.json'); } if (this.options.debug) { console.log('đŸ“Ļ Server version:', this.serverVersion, 'Local:', this.currentVersion); } // Compare versions if (this.currentVersion === null) { // First load - save version this.setLocalVersion(this.serverVersion); return { hasUpdate: false, version: this.serverVersion }; } if (this.currentVersion !== this.serverVersion) { // New version detected if (this.options.debug) { console.log('🆕 New version detected!', { current: this.currentVersion, new: this.serverVersion }); } // Call callback if (this.options.onUpdateAvailable) { this.options.onUpdateAvailable({ currentVersion: this.currentVersion, newVersion: this.serverVersion, updateManager: this }); } return { hasUpdate: true, currentVersion: this.currentVersion, newVersion: this.serverVersion }; } return { hasUpdate: false, version: this.serverVersion }; } catch (error) { // Graceful degradation - if meta.json is unavailable, continue working if (this.options.debug) { console.warn('âš ī¸ Update check failed (non-critical):', error.message); } if (this.options.onError) { this.options.onError(error); } return { hasUpdate: false, error: error.message }; } } /** * Fetch with timeout */ async fetchWithTimeout(url, options = {}) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.options.requestTimeout); try { const response = await fetch(url, { ...options, signal: controller.signal }); clearTimeout(timeoutId); return response; } catch (error) { clearTimeout(timeoutId); if (error.name === 'AbortError') { throw new Error('Request timeout'); } throw error; } } /** * Force application update * Clears all cache levels and reloads the page */ async forceUpdate() { if (this.isUpdating) { if (this.options.debug) { console.log('âŗ Update already in progress...'); } return; } this.isUpdating = true; try { if (this.options.debug) { console.log('🚀 Starting force update...'); } // Step 1: Preserve critical data const preservedData = this.preserveCriticalData(); // Step 2: Clear Service Worker caches await this.clearServiceWorkerCaches(); // Step 3: Unregister Service Workers await this.unregisterServiceWorkers(); // Step 4: Clear browser cache (localStorage, sessionStorage) this.clearBrowserCaches(); // Step 5: Update version if (this.serverVersion) { this.setLocalVersion(this.serverVersion); } // Step 6: Restore critical data this.restoreCriticalData(preservedData); // Step 7: Force reload with cache-busting if (this.options.debug) { console.log('🔄 Reloading page with new version...'); } // Small delay to complete operations await new Promise(resolve => setTimeout(resolve, 500)); // Reload with full cache bypass window.location.href = `${window.location.pathname}?v=${Date.now()}&_update=true`; } catch (error) { this.handleError('Force update failed', error); this.isUpdating = false; throw error; } } /** * Preserve critical data before cleanup */ preserveCriticalData() { const data = {}; this.options.preserveKeys.forEach(key => { try { const value = localStorage.getItem(key); if (value !== null) { data[key] = value; } } catch (error) { if (this.options.debug) { console.warn(`âš ī¸ Failed to preserve ${key}:`, error); } } }); if (this.options.debug) { console.log('💾 Preserved critical data:', Object.keys(data)); } return data; } /** * Restore critical data after cleanup */ restoreCriticalData(data) { Object.entries(data).forEach(([key, value]) => { try { localStorage.setItem(key, value); } catch (error) { if (this.options.debug) { console.warn(`âš ī¸ Failed to restore ${key}:`, error); } } }); if (this.options.debug) { console.log('✅ Restored critical data'); } } /** * Clear all Service Worker caches */ async clearServiceWorkerCaches() { try { if ('caches' in window) { const cacheNames = await caches.keys(); if (this.options.debug) { console.log('đŸ—‘ī¸ Clearing Service Worker caches:', cacheNames); } await Promise.all( cacheNames.map(cacheName => caches.delete(cacheName)) ); // Send message to Service Worker for cleanup if (navigator.serviceWorker.controller) { navigator.serviceWorker.controller.postMessage({ type: 'CACHE_CLEAR' }); } if (this.options.debug) { console.log('✅ Service Worker caches cleared'); } } } catch (error) { this.handleError('Failed to clear SW caches', error); } } /** * Unregister all Service Workers */ async unregisterServiceWorkers() { try { if ('serviceWorker' in navigator) { const registrations = await navigator.serviceWorker.getRegistrations(); if (this.options.debug) { console.log('🔌 Unregistering Service Workers:', registrations.length); } await Promise.all( registrations.map(registration => { // Send skipWaiting command before unregistering if (registration.waiting) { registration.waiting.postMessage({ type: 'SKIP_WAITING' }); } if (registration.installing) { registration.installing.postMessage({ type: 'SKIP_WAITING' }); } return registration.unregister(); }) ); if (this.options.debug) { console.log('✅ Service Workers unregistered'); } } } catch (error) { this.handleError('Failed to unregister SW', error); } } /** * Clear browser caches (localStorage, sessionStorage) */ clearBrowserCaches() { try { // Clear sessionStorage sessionStorage.clear(); // Clear localStorage (except critical data that is already preserved) const keysToRemove = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && !this.options.preserveKeys.includes(key) && key !== this.options.versionKey) { keysToRemove.push(key); } } keysToRemove.forEach(key => { try { localStorage.removeItem(key); } catch (error) { if (this.options.debug) { console.warn(`âš ī¸ Failed to remove ${key}:`, error); } } }); if (this.options.debug) { console.log('✅ Browser caches cleared'); } } catch (error) { this.handleError('Failed to clear browser caches', error); } } /** * Start periodic update check */ startPeriodicCheck() { if (this.checkIntervalId) { clearInterval(this.checkIntervalId); } this.checkIntervalId = setInterval(() => { this.checkForUpdates(); }, this.options.checkInterval); if (this.options.debug) { console.log(`⏰ Periodic check started (${this.options.checkInterval}ms)`); } } /** * Stop periodic check */ stopPeriodicCheck() { if (this.checkIntervalId) { clearInterval(this.checkIntervalId); this.checkIntervalId = null; if (this.options.debug) { console.log('âšī¸ Periodic check stopped'); } } } /** * Setup Service Worker event listeners */ setupServiceWorkerListeners() { if ('serviceWorker' in navigator) { // Listen to Service Worker updates navigator.serviceWorker.addEventListener('controllerchange', () => { if (this.options.debug) { console.log('🔄 Service Worker controller changed'); } // Check for updates after controller change setTimeout(() => { this.checkForUpdates(); }, 1000); }); // Listen to messages from Service Worker navigator.serviceWorker.addEventListener('message', (event) => { if (event.data && event.data.type === 'SW_ACTIVATED') { if (this.options.debug) { console.log('✅ Service Worker activated'); } // Check for updates after activation setTimeout(() => { this.checkForUpdates(); }, 1000); } }); } } /** * Handle errors */ handleError(message, error) { const errorMessage = `${message}: ${error.message || error}`; if (this.options.debug) { console.error('❌ UpdateManager error:', errorMessage, error); } if (this.options.onError) { this.options.onError(new Error(errorMessage)); } } /** * Destroy manager (cleanup) */ destroy() { this.stopPeriodicCheck(); this.updatePromise = null; if (this.options.debug) { console.log('đŸ—‘ī¸ UpdateManager destroyed'); } } } // Export for use in modules if (typeof module !== 'undefined' && module.exports) { module.exports = UpdateManager; } else { window.UpdateManager = UpdateManager; }