/* eslint-env serviceworker */ /// /// import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching' import { NavigationRoute, registerRoute } from 'workbox-routing' import { NetworkFirst, CacheFirst, StaleWhileRevalidate } from 'workbox-strategies' import { CacheableResponsePlugin } from 'workbox-cacheable-response' import { ExpirationPlugin } from 'workbox-expiration' import { BackgroundSyncPlugin } from 'workbox-background-sync' declare const self: ServiceWorkerGlobalScope // Only apply service worker logic if on the app subdomain if (!self.location.hostname.startsWith('app.')) { console.log('Skipping SW install: not on app domain'); self.skipWaiting(); self.registration?.unregister(); } // Define cache names with domain awareness const CACHE_NAMES = { static: `static-assets-v1-${self.location.hostname}`, dynamic: `dynamic-content-v1-${self.location.hostname}`, pages: `pages-v1-${self.location.hostname}`, api: `api-v1-${self.location.hostname}`, offline: `offline-v1-${self.location.hostname}` } // Clean up old caches cleanupOutdatedCaches() // Precache production assets only // Development resources are excluded by Vite's build configuration const manifest = self.__WB_MANIFEST; // Find the index.html entry from the manifest const manifestIndexEntry = manifest.find(entry => { const url = typeof entry === 'string' ? entry : entry.url; return url === 'index.html' || url === '/index.html'; }); // Create index entries using the revision from manifest if available const indexEntries = [ { url: '/index.html', revision: manifestIndexEntry && typeof manifestIndexEntry !== 'string' ? manifestIndexEntry.revision : null }, { url: '/', revision: manifestIndexEntry && typeof manifestIndexEntry !== 'string' ? manifestIndexEntry.revision : null } ]; // Filter out index.html from manifest and add absolute paths const manifestWithoutIndex = manifest.filter(entry => { const url = typeof entry === 'string' ? entry : entry.url; return url !== 'index.html' && url !== '/index.html'; }); // Combine entries and ensure absolute paths const manifestWithIndex = [ ...indexEntries, ...manifestWithoutIndex.map(entry => { if (typeof entry === 'string') { return { url: entry.startsWith('/') ? entry : `/${entry}`, revision: null }; } return { ...entry, url: entry.url.startsWith('/') ? entry.url : `/${entry.url}` }; }) ]; // Precache all assets precacheAndRoute(manifestWithIndex); // Create a background sync queue for failed requests const bgSyncPlugin = new BackgroundSyncPlugin('failedRequests', { maxRetentionTime: 24 * 60 // Retry for up to 24 hours (specified in minutes) }) // Navigation handler for offline usage const navigationHandler = async ({ request }: { request: Request }): Promise => { try { // Try network first const response = await fetch(request); if (response.ok) { return response; } } catch (error) { console.log('Navigation fetch failed:', error); } // If network fails, try cache const cache = await caches.open(CACHE_NAMES.pages); // Try both root and /index.html paths const cachedResponse = await cache.match(request) || await cache.match('/') || await cache.match('/index.html'); if (cachedResponse) { return cachedResponse; } // If cache fails, try to fetch index.html directly try { const indexResponse = await fetch('/index.html'); if (indexResponse.ok) { await cache.put('/index.html', indexResponse.clone()); return indexResponse; } } catch (error) { console.error('Failed to fetch index.html:', error); } // If all else fails, return a basic offline page return new Response( '

Offline

Please check your internet connection.

', { headers: { 'Content-Type': 'text/html' } } ); }; // Register navigation route const navigationRoute = new NavigationRoute(navigationHandler, { denylist: [ /^\/(auth|rest|api|whisperlive|tldraw|searxng-api)/, /^\/@.*/, // Block all /@vite/, /@react-refresh/, etc. /^\/src\/.*/ // Block all /src/ paths ] }); registerRoute(navigationRoute); // Cache page navigations (html) with a Network First strategy registerRoute( // Check to see if the request is a navigation to a new page ({ request }) => request.mode === 'navigate', new NetworkFirst({ // Put all cached files in a cache named 'pages' cacheName: CACHE_NAMES.pages, plugins: [ // Ensure that only requests that result in a 200 status are cached new CacheableResponsePlugin({ statuses: [200] }) ], networkTimeoutSeconds: 3 }) ) // Cache manifest and icons with a Stale While Revalidate strategy registerRoute( ({ request }) => request.destination === 'manifest' || request.url.includes('/icons/'), new StaleWhileRevalidate({ cacheName: 'manifest-and-icons', plugins: [ new CacheableResponsePlugin({ statuses: [200] }) ] }) ) // Cache other static assets with a Cache First strategy registerRoute( ({ request }) => { const destination = request.destination; return destination === 'style' || destination === 'script' || destination === 'image' || destination === 'font' }, new CacheFirst({ cacheName: CACHE_NAMES.static, plugins: [ new CacheableResponsePlugin({ statuses: [200] }), new ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days purgeOnQuotaError: true }) ] }) ) // Cache API responses with a Network First strategy // TODO: This is a temporary solution to cache API responses. We should move to a more efficient strategy in the future using our new domain names search.classroomcopilot.ai and api.classroomcopilot.ai registerRoute( ({ url }) => url.pathname.startsWith('/api/'), new NetworkFirst({ cacheName: CACHE_NAMES.api, plugins: [ new CacheableResponsePlugin({ statuses: [200] }), new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 5 * 60 // 5 minutes }), bgSyncPlugin ] }) ) // Cache SearXNG API with Network First strategy registerRoute( ({ url }) => url.pathname.startsWith('/searxng-api'), new NetworkFirst({ cacheName: CACHE_NAMES.api, plugins: [ new CacheableResponsePlugin({ statuses: [200] }), new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 60 * 60 // 1 hour }), bgSyncPlugin ] }) ) // Cache SearXNG static assets registerRoute( ({ url }) => url.pathname.startsWith('/searxng-api/static'), new CacheFirst({ cacheName: CACHE_NAMES.static, plugins: [ new CacheableResponsePlugin({ statuses: [200] }), new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days }) ] }) ) // This allows the web app to trigger skipWaiting via // registration.waiting.postMessage({type: 'SKIP_WAITING'}) self.addEventListener('message', (event) => { if (event.data && event.data.type === 'SKIP_WAITING') { self.skipWaiting() } }) // Handle offline fallback self.addEventListener('install', () => { self.skipWaiting() }) self.addEventListener('activate', (event) => { event.waitUntil( Promise.all([ // Enable navigation preload if available self.registration.navigationPreload?.enable(), // Delete old caches caches.keys().then((cacheNames) => { return Promise.all( cacheNames .filter((cacheName) => !Object.values(CACHE_NAMES).includes(cacheName)) .map((cacheName) => caches.delete(cacheName)) ) }), // Tell the active service worker to take control of the page immediately self.clients.claim() ]) ) }) // Handle errors self.addEventListener('error', (event) => { console.error('[Service Worker] Error:', event.error) // You could send this to your error tracking service }) self.addEventListener('unhandledrejection', (event) => { console.error('[Service Worker] Unhandled rejection:', event.reason) // You could send this to your error tracking service })