| |
| 'use strict'; |
|
|
| |
| |
| const CACHE_VERSION = 33; |
| |
| const STATIC_CACHE = `rox-ai-static-v${CACHE_VERSION}`; |
| |
| const DYNAMIC_CACHE = `rox-ai-dynamic-v${CACHE_VERSION}`; |
|
|
| |
| const STATIC_ASSETS = Object.freeze([ |
| '/', |
| '/index.html', |
| '/app.js', |
| '/styles.css', |
| '/manifest.json', |
| '/icon-192.svg', |
| '/icon-512.svg' |
| ]); |
|
|
| |
| const ALWAYS_FRESH = Object.freeze([ |
| '/app.js', |
| '/styles.css', |
| '/index.html', |
| '/' |
| ]); |
|
|
| |
| const MAX_DYNAMIC_CACHE_SIZE = 50; |
|
|
| |
| const NETWORK_TIMEOUT = 10000; |
|
|
| |
| const FAST_NETWORK_TIMEOUT = 3000; |
|
|
| |
| const VALID_CACHES = new Set([STATIC_CACHE, DYNAMIC_CACHE]); |
|
|
| |
| |
| const DEBUG = false; |
| |
| |
| |
| |
| const log = (...args) => DEBUG && console.log('[SW]', ...args); |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| async function fetchWithTimeout(request, timeout = NETWORK_TIMEOUT) { |
| const controller = new AbortController(); |
| const timeoutId = setTimeout(() => controller.abort(), timeout); |
| |
| try { |
| const response = await fetch(request, { signal: controller.signal }); |
| clearTimeout(timeoutId); |
| return response; |
| } catch (error) { |
| clearTimeout(timeoutId); |
| throw error; |
| } |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| async function clearAllCaches() { |
| try { |
| const cacheNames = await caches.keys(); |
| if (cacheNames.length === 0) return 0; |
| log('Clearing all caches:', cacheNames); |
| await Promise.all(cacheNames.map(name => caches.delete(name))); |
| log('All caches cleared:', cacheNames.length); |
| return cacheNames.length; |
| } catch (err) { |
| console.error('[SW] Failed to clear caches:', err); |
| return 0; |
| } |
| } |
|
|
| |
| |
| |
| async function clearCoreAssets() { |
| try { |
| const cacheNames = await caches.keys(); |
| if (cacheNames.length === 0) return; |
| |
| const deletePromises = []; |
| for (const cacheName of cacheNames) { |
| const cache = await caches.open(cacheName); |
| for (const asset of ALWAYS_FRESH) { |
| deletePromises.push( |
| cache.delete(asset), |
| cache.delete(asset + '?v=' + CACHE_VERSION) |
| ); |
| } |
| } |
| await Promise.all(deletePromises); |
| log('Core assets cleared from all caches'); |
| } catch (err) { |
| console.error('[SW] Failed to clear core assets:', err); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| async function limitCacheSize(cacheName, maxSize) { |
| const cache = await caches.open(cacheName); |
| const keys = await cache.keys(); |
| if (keys.length > maxSize) { |
| await cache.delete(keys[0]); |
| await limitCacheSize(cacheName, maxSize); |
| } |
| } |
|
|
| |
|
|
| |
| self.addEventListener('install', (event) => { |
| log(`Installing v${CACHE_VERSION}...`); |
| event.waitUntil( |
| (async () => { |
| try { |
| |
| await clearAllCaches(); |
| |
| const cache = await caches.open(STATIC_CACHE); |
| log('Caching static assets'); |
| |
| |
| const cachePromises = STATIC_ASSETS.map(async (asset) => { |
| try { |
| |
| const response = await fetch(asset + '?v=' + CACHE_VERSION, { cache: 'no-store' }); |
| if (response.ok) { |
| await cache.put(asset, response); |
| return true; |
| } |
| return false; |
| } catch (err) { |
| console.warn('[SW] Failed to cache:', asset, err.message); |
| return false; |
| } |
| }); |
| |
| const results = await Promise.all(cachePromises); |
| const successCount = results.filter(Boolean).length; |
| log(`Install complete: ${successCount}/${STATIC_ASSETS.length} assets cached`); |
| |
| |
| const clients = await self.clients.matchAll({ includeUncontrolled: true }); |
| clients.forEach((client) => { |
| client.postMessage({ type: 'UPDATE_AVAILABLE', version: CACHE_VERSION }); |
| }); |
| log(`Notified ${clients.length} clients about update`); |
| |
| |
| await self.skipWaiting(); |
| } catch (err) { |
| console.error('[SW] Install failed:', err); |
| } |
| })() |
| ); |
| }); |
|
|
| |
| self.addEventListener('activate', (event) => { |
| log(`Activating v${CACHE_VERSION}...`); |
| event.waitUntil( |
| (async () => { |
| |
| const keys = await caches.keys(); |
| const deletePromises = keys |
| .filter((key) => !VALID_CACHES.has(key)) |
| .map((key) => { |
| log('Deleting old cache:', key); |
| return caches.delete(key); |
| }); |
| |
| await Promise.all(deletePromises); |
| |
| |
| if ('navigationPreload' in self.registration) { |
| await self.registration.navigationPreload.enable(); |
| log('Navigation preload enabled'); |
| } |
| |
| log('Claiming clients'); |
| await self.clients.claim(); |
| |
| |
| const clients = await self.clients.matchAll({ includeUncontrolled: true }); |
| clients.forEach((client) => { |
| client.postMessage({ type: 'UPDATE_ACTIVATED', version: CACHE_VERSION }); |
| }); |
| log(`Notified ${clients.length} clients about activation`); |
| })() |
| ); |
| }); |
|
|
| |
|
|
| |
| self.addEventListener('fetch', (event) => { |
| const { request } = event; |
| |
| |
| if (request.method !== 'GET') return; |
| |
| let url; |
| try { |
| url = new URL(request.url); |
| } catch (e) { |
| |
| return; |
| } |
|
|
| |
| if (url.pathname.startsWith('/api/')) return; |
| |
| |
| if (url.pathname.startsWith('/download/')) return; |
|
|
| |
| if (!url.protocol.startsWith('http')) return; |
|
|
| |
| if (url.origin !== self.location.origin) return; |
| |
| |
| const hasUpdateParam = url.searchParams.has('_v') || |
| url.searchParams.has('_update') || |
| url.searchParams.has('_nocache') || |
| url.searchParams.has('_emergency'); |
| |
| if (hasUpdateParam) { |
| |
| event.respondWith( |
| fetch(request, { cache: 'no-store' }) |
| .catch(() => caches.match('/index.html')) |
| ); |
| return; |
| } |
|
|
| |
| if (url.searchParams.get('action') === 'new') { |
| event.respondWith( |
| caches.match('/index.html').then((response) => { |
| return response || fetch('/index.html'); |
| }) |
| ); |
| return; |
| } |
| |
| |
| const isCoreAsset = ALWAYS_FRESH.some(asset => url.pathname === asset || url.pathname.endsWith(asset)); |
| |
| if (isCoreAsset) { |
| |
| event.respondWith( |
| (async () => { |
| try { |
| |
| const networkResponse = await fetchWithTimeout(request, FAST_NETWORK_TIMEOUT); |
| if (networkResponse.ok) { |
| |
| const cache = await caches.open(STATIC_CACHE); |
| cache.put(request, networkResponse.clone()).catch(() => {}); |
| return networkResponse; |
| } |
| } catch (e) { |
| |
| log('Network failed/timeout for core asset, using cache:', url.pathname); |
| } |
| |
| |
| const cachedResponse = await caches.match(request); |
| if (cachedResponse) return cachedResponse; |
| |
| |
| if (request.mode === 'navigate') { |
| return caches.match('/index.html'); |
| } |
| |
| return new Response('Offline', { status: 503 }); |
| })() |
| ); |
| return; |
| } |
|
|
| |
| event.respondWith( |
| (async () => { |
| |
| const cachedResponse = await caches.match(request); |
| |
| |
| const fetchPromise = fetchWithTimeout(request, NETWORK_TIMEOUT) |
| .then(async (response) => { |
| |
| if (!response || response.status !== 200 || response.type !== 'basic') { |
| return response; |
| } |
|
|
| |
| const responseToCache = response.clone(); |
| const cache = await caches.open(DYNAMIC_CACHE); |
| await cache.put(request, responseToCache); |
| |
| |
| await limitCacheSize(DYNAMIC_CACHE, MAX_DYNAMIC_CACHE_SIZE); |
|
|
| return response; |
| }) |
| .catch(() => null); |
| |
| |
| if (cachedResponse) { |
| |
| fetchPromise.catch(() => {}); |
| return cachedResponse; |
| } |
| |
| |
| const networkResponse = await fetchPromise; |
| if (networkResponse) { |
| return networkResponse; |
| } |
| |
| |
| if (request.mode === 'navigate') { |
| const offlineIndex = await caches.match('/index.html'); |
| if (offlineIndex) return offlineIndex; |
| } |
| |
| return new Response('Offline', { |
| status: 503, |
| statusText: 'Service Unavailable', |
| headers: new Headers({ |
| 'Content-Type': 'text/plain' |
| }) |
| }); |
| })() |
| ); |
| }); |
|
|
| |
|
|
| |
| self.addEventListener('message', (event) => { |
| log('Received message:', event.data); |
| |
| if (event.data === 'skipWaiting') { |
| self.skipWaiting(); |
| } |
| |
| if (event.data === 'clearCache' || event.data === 'clearAllCaches') { |
| |
| event.waitUntil( |
| (async () => { |
| const count = await clearAllCaches(); |
| log(`Cleared ${count} caches`); |
| |
| |
| const clients = await self.clients.matchAll(); |
| clients.forEach((client) => { |
| client.postMessage({ type: 'CACHES_CLEARED', count }); |
| }); |
| })() |
| ); |
| } |
| |
| if (event.data === 'clearCoreAssets') { |
| |
| event.waitUntil(clearCoreAssets()); |
| } |
| |
| if (event.data === 'forceUpdate' || event.data?.type === 'FORCE_UPDATE') { |
| |
| event.waitUntil( |
| (async () => { |
| |
| await clearAllCaches(); |
| log('All caches cleared for force update'); |
| |
| |
| try { |
| await self.registration.unregister(); |
| log('Service worker unregistered'); |
| } catch (err) { |
| console.error('[SW] Failed to unregister:', err); |
| } |
| |
| |
| const clients = await self.clients.matchAll({ includeUncontrolled: true }); |
| clients.forEach((client) => { |
| client.postMessage({ type: 'FORCE_RELOAD', timestamp: Date.now() }); |
| }); |
| log(`Notified ${clients.length} clients to reload`); |
| })() |
| ); |
| } |
| |
| if (event.data === 'getVersion') { |
| |
| event.source?.postMessage({ type: 'VERSION', version: CACHE_VERSION }); |
| } |
| |
| if (event.data?.type === 'CHECK_UPDATE') { |
| |
| event.waitUntil( |
| (async () => { |
| const reg = self.registration; |
| if (reg.waiting) { |
| |
| reg.waiting.postMessage('skipWaiting'); |
| } |
| })() |
| ); |
| } |
| }); |
|
|
| |
|
|
| |
| self.addEventListener('sync', (event) => { |
| if (event.tag === 'sync-messages') { |
| log('Syncing messages...'); |
| } |
| }); |
|
|
| |
|
|
| |
| self.addEventListener('push', (event) => { |
| if (event.data) { |
| let data; |
| try { |
| data = event.data.json(); |
| } catch (e) { |
| console.error('[SW] Failed to parse push data:', e); |
| data = { title: 'Rox AI', body: event.data.text() || 'New notification' }; |
| } |
| const options = { |
| body: data.body || 'New message from Rox AI', |
| icon: '/icon-192.svg', |
| badge: '/icon-192.svg', |
| vibrate: [100, 50, 100], |
| data: { |
| url: data.url || '/' |
| } |
| }; |
| |
| event.waitUntil( |
| self.registration.showNotification(data.title || 'Rox AI', options) |
| ); |
| } |
| }); |
|
|
| |
| self.addEventListener('notificationclick', (event) => { |
| event.notification.close(); |
| |
| event.waitUntil( |
| clients.matchAll({ type: 'window', includeUncontrolled: true }) |
| .then((clientList) => { |
| |
| for (const client of clientList) { |
| if (client.url === event.notification.data.url && 'focus' in client) { |
| return client.focus(); |
| } |
| } |
| |
| if (clients.openWindow) { |
| return clients.openWindow(event.notification.data.url); |
| } |
| }) |
| ); |
| }); |
|
|