Beta / public /sw.js
Rox-Turbo's picture
Upload 21 files
dcd5a0a verified
// Rox AI Service Worker - PWA Support v33 (Production Ready - Optimized)
'use strict';
// ==================== CONFIGURATION ====================
/** @constant {number} Cache version - increment to force update */
const CACHE_VERSION = 33;
/** @constant {string} Static cache name */
const STATIC_CACHE = `rox-ai-static-v${CACHE_VERSION}`;
/** @constant {string} Dynamic cache name */
const DYNAMIC_CACHE = `rox-ai-dynamic-v${CACHE_VERSION}`;
/** @constant {string[]} Core assets that must be cached for offline use */
const STATIC_ASSETS = Object.freeze([
'/',
'/index.html',
'/app.js',
'/styles.css',
'/manifest.json',
'/icon-192.svg',
'/icon-512.svg'
]);
/** @constant {string[]} Assets that should ALWAYS be fetched fresh (never serve stale) */
const ALWAYS_FRESH = Object.freeze([
'/app.js',
'/styles.css',
'/index.html',
'/'
]);
/** @constant {number} Maximum entries in dynamic cache */
const MAX_DYNAMIC_CACHE_SIZE = 50;
/** @constant {number} Network timeout for fetch requests (ms) - optimized for weak connections */
const NETWORK_TIMEOUT = 10000;
/** @constant {number} Fast network timeout for quick fallback to cache (ms) */
const FAST_NETWORK_TIMEOUT = 3000;
/** @constant {Set<string>} Valid cache names for quick lookup */
const VALID_CACHES = new Set([STATIC_CACHE, DYNAMIC_CACHE]);
// ==================== LOGGING ====================
/** @constant {boolean} Enable debug logging */
const DEBUG = false;
/**
* Debug logger - only logs when DEBUG is true
* @param {...any} args - Arguments to log
*/
const log = (...args) => DEBUG && console.log('[SW]', ...args);
// ==================== NETWORK UTILITIES ====================
/**
* Fetch with timeout - prevents hanging on slow connections
* @param {Request} request - The request to fetch
* @param {number} timeout - Timeout in milliseconds
* @returns {Promise<Response>}
*/
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;
}
}
// ==================== CACHE MANAGEMENT ====================
/**
* Clear ALL caches completely - nuclear option
* @returns {Promise<number>} Number of caches cleared
*/
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;
}
}
/**
* Clear specific cache entries for core assets
*/
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);
}
}
/**
* Limit cache size by removing oldest entries (recursive)
* @param {string} cacheName - Name of the cache
* @param {number} maxSize - Maximum number of entries
*/
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);
}
}
// ==================== SERVICE WORKER LIFECYCLE ====================
// Install - cache static assets
self.addEventListener('install', (event) => {
log(`Installing v${CACHE_VERSION}...`);
event.waitUntil(
(async () => {
try {
// Clear old caches first before installing new ones
await clearAllCaches();
const cache = await caches.open(STATIC_CACHE);
log('Caching static assets');
// Cache assets individually to handle failures gracefully
const cachePromises = STATIC_ASSETS.map(async (asset) => {
try {
// Fetch with cache-busting to ensure fresh content
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`);
// Notify all clients that an update is available
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`);
// Skip waiting to activate immediately
await self.skipWaiting();
} catch (err) {
console.error('[SW] Install failed:', err);
}
})()
);
});
// Activate - clean ALL old caches, take control immediately
self.addEventListener('activate', (event) => {
log(`Activating v${CACHE_VERSION}...`);
event.waitUntil(
(async () => {
// Clean ALL old caches - keep only current versions
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);
// Enable navigation preload if supported
if ('navigationPreload' in self.registration) {
await self.registration.navigationPreload.enable();
log('Navigation preload enabled');
}
log('Claiming clients');
await self.clients.claim();
// Notify all clients that update is now active
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`);
})()
);
});
// ==================== FETCH HANDLER ====================
// Fetch - network first for core assets, stale-while-revalidate for others
self.addEventListener('fetch', (event) => {
const { request } = event;
// Skip non-GET requests
if (request.method !== 'GET') return;
let url;
try {
url = new URL(request.url);
} catch (e) {
// Invalid URL, skip
return;
}
// Skip API calls - always go to network (important for streaming)
if (url.pathname.startsWith('/api/')) return;
// Skip download routes - always go to network for file downloads
if (url.pathname.startsWith('/download/')) return;
// Skip chrome-extension and other non-http(s) requests
if (!url.protocol.startsWith('http')) return;
// Skip cross-origin requests
if (url.origin !== self.location.origin) return;
// If URL has update/cache-bust parameters, ALWAYS fetch fresh
const hasUpdateParam = url.searchParams.has('_v') ||
url.searchParams.has('_update') ||
url.searchParams.has('_nocache') ||
url.searchParams.has('_emergency');
if (hasUpdateParam) {
// Force network fetch, bypass all caches
event.respondWith(
fetch(request, { cache: 'no-store' })
.catch(() => caches.match('/index.html'))
);
return;
}
// Handle app shortcuts (from manifest)
if (url.searchParams.get('action') === 'new') {
event.respondWith(
caches.match('/index.html').then((response) => {
return response || fetch('/index.html');
})
);
return;
}
// Check if this is a core asset that should always be fresh
const isCoreAsset = ALWAYS_FRESH.some(asset => url.pathname === asset || url.pathname.endsWith(asset));
if (isCoreAsset) {
// Network-first strategy for core assets with timeout for weak connections
event.respondWith(
(async () => {
try {
// Try network with timeout - falls back to cache quickly on slow connections
const networkResponse = await fetchWithTimeout(request, FAST_NETWORK_TIMEOUT);
if (networkResponse.ok) {
// Update cache with fresh response
const cache = await caches.open(STATIC_CACHE);
cache.put(request, networkResponse.clone()).catch(() => {});
return networkResponse;
}
} catch (e) {
// Network failed or timed out, fall back to cache
log('Network failed/timeout for core asset, using cache:', url.pathname);
}
// Fallback to cache
const cachedResponse = await caches.match(request);
if (cachedResponse) return cachedResponse;
// Last resort for navigation - return index.html
if (request.mode === 'navigate') {
return caches.match('/index.html');
}
return new Response('Offline', { status: 503 });
})()
);
return;
}
// Stale-while-revalidate strategy for other assets with timeout
event.respondWith(
(async () => {
// Try to get from cache first
const cachedResponse = await caches.match(request);
// Fetch from network in background with timeout
const fetchPromise = fetchWithTimeout(request, NETWORK_TIMEOUT)
.then(async (response) => {
// Don't cache non-successful responses
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Clone and cache successful responses in dynamic cache
const responseToCache = response.clone();
const cache = await caches.open(DYNAMIC_CACHE);
await cache.put(request, responseToCache);
// Limit dynamic cache size
await limitCacheSize(DYNAMIC_CACHE, MAX_DYNAMIC_CACHE_SIZE);
return response;
})
.catch(() => null);
// Return cached response immediately if available, otherwise wait for network
if (cachedResponse) {
// Update cache in background (stale-while-revalidate)
fetchPromise.catch(() => {});
return cachedResponse;
}
// No cache, wait for network
const networkResponse = await fetchPromise;
if (networkResponse) {
return networkResponse;
}
// Network failed and no cache - return offline response
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'
})
});
})()
);
});
// ==================== MESSAGE HANDLER ====================
// Handle messages from main thread
self.addEventListener('message', (event) => {
log('Received message:', event.data);
if (event.data === 'skipWaiting') {
self.skipWaiting();
}
if (event.data === 'clearCache' || event.data === 'clearAllCaches') {
// Clear ALL caches completely
event.waitUntil(
(async () => {
const count = await clearAllCaches();
log(`Cleared ${count} caches`);
// Notify all clients that caches are cleared
const clients = await self.clients.matchAll();
clients.forEach((client) => {
client.postMessage({ type: 'CACHES_CLEARED', count });
});
})()
);
}
if (event.data === 'clearCoreAssets') {
// Clear only core assets from cache
event.waitUntil(clearCoreAssets());
}
if (event.data === 'forceUpdate' || event.data?.type === 'FORCE_UPDATE') {
// Force update - clear ALL caches, unregister, and notify clients to reload
event.waitUntil(
(async () => {
// Step 1: Clear all caches
await clearAllCaches();
log('All caches cleared for force update');
// Step 2: Unregister this service worker
try {
await self.registration.unregister();
log('Service worker unregistered');
} catch (err) {
console.error('[SW] Failed to unregister:', err);
}
// Step 3: Notify all clients to reload
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') {
// Return current cache version
event.source?.postMessage({ type: 'VERSION', version: CACHE_VERSION });
}
if (event.data?.type === 'CHECK_UPDATE') {
// Check if there's a newer service worker waiting
event.waitUntil(
(async () => {
const reg = self.registration;
if (reg.waiting) {
// There's a new version waiting - activate it
reg.waiting.postMessage('skipWaiting');
}
})()
);
}
});
// ==================== BACKGROUND FEATURES ====================
// Background sync for offline messages (future feature)
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-messages') {
log('Syncing messages...');
}
});
// ==================== PUSH NOTIFICATIONS ====================
// Push notifications (future feature)
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)
);
}
});
// Notification click handler
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Focus existing window if available
for (const client of clientList) {
if (client.url === event.notification.data.url && 'focus' in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(event.notification.data.url);
}
})
);
});