/** * Tour Builder Platform - Service Worker (Serwist) * * Provides offline caching for PWA functionality with: * - Range requests for video seeking * - IndexedDB fallback for large files * - Background sync for resumable downloads */ /// import { defaultCache } from '@serwist/next/worker'; import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'; import { CacheFirst, NetworkFirst, Serwist, StaleWhileRevalidate, } from 'serwist'; import { OFFLINE_CONFIG } from './config/offline.config'; // Service Worker type declarations declare const self: ServiceWorkerGlobalScope & typeof globalThis; // Serwist global configuration declare global { interface ServiceWorkerGlobalScope extends SerwistGlobalConfig { __SW_MANIFEST: (PrecacheEntry | string)[] | undefined; } } // Cacheable asset extensions const CACHEABLE_EXTENSIONS = [ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.mp4', '.webm', '.mov', '.mp3', '.wav', '.ogg', '.m4a', '.woff', '.woff2', '.ttf', '.eot', '.css', '.js', ]; // Check if request should be cached const isCacheableRequest = (request: Request): boolean => { const url = new URL(request.url); // Don't cache API requests (except static assets from /file/download) if ( url.pathname.startsWith('/api/') && !url.pathname.includes('/file/download') ) { return false; } // Cache known asset extensions const hasExtension = CACHEABLE_EXTENSIONS.some((ext) => url.pathname.toLowerCase().endsWith(ext), ); if (hasExtension) { return true; } // Cache file downloads (S3 presigned URLs, CDN assets) if ( url.pathname.includes('/file/download') || url.hostname.includes('amazonaws.com') || url.hostname.includes('cloudfront.net') ) { return true; } return false; }; // Check if request is a video const isVideoRequest = (request: Request): boolean => { const url = new URL(request.url); return ['.mp4', '.webm', '.mov'].some((ext) => url.pathname.toLowerCase().endsWith(ext), ); }; // Initialize Serwist const serwist = new Serwist({ precacheEntries: self.__SW_MANIFEST, skipWaiting: true, clientsClaim: true, navigationPreload: true, runtimeCaching: [ // Static assets (images, fonts, css, js) - Cache First // Uses 'assets' cache to match preloading (usePreloadOrchestrator stores here) { matcher: ({ request }) => { const url = new URL(request.url); return ( request.destination === 'image' || request.destination === 'font' || ['.css', '.js', '.woff', '.woff2'].some((ext) => url.pathname.endsWith(ext), ) ); }, handler: new CacheFirst({ cacheName: OFFLINE_CONFIG.cacheNames.assets, plugins: [ { cacheWillUpdate: async ({ response }) => { if (response && response.status === 200) { return response; } return null; }, }, ], }), }, // Videos - Cache First with Range Request support { matcher: ({ request }) => isVideoRequest(request), handler: new CacheFirst({ cacheName: OFFLINE_CONFIG.cacheNames.assets, plugins: [ { // Handle range requests for video seeking cachedResponseWillBeUsed: async ({ cachedResponse, request }) => { if (!cachedResponse) return null; const rangeHeader = request.headers.get('range'); if (!rangeHeader) return cachedResponse; // Parse range header const match = rangeHeader.match(/bytes=(\d+)-(\d*)/); if (!match) return cachedResponse; const start = parseInt(match[1], 10); const end = match[2] ? parseInt(match[2], 10) : undefined; // Get the full response body const blob = await cachedResponse.blob(); const slicedBlob = end !== undefined ? blob.slice(start, end + 1) : blob.slice(start); // Return partial content return new Response(slicedBlob, { status: 206, statusText: 'Partial Content', headers: { 'Content-Type': cachedResponse.headers.get('Content-Type') || 'video/mp4', 'Content-Length': String(slicedBlob.size), 'Content-Range': `bytes ${start}-${end !== undefined ? end : blob.size - 1}/${blob.size}`, 'Accept-Ranges': 'bytes', }, }); }, cacheWillUpdate: async ({ response }) => { if (response && response.status === 200) { return response; } return null; }, }, ], }), }, // API requests - Network First { matcher: ({ url }) => url.pathname.startsWith('/api/'), handler: new NetworkFirst({ cacheName: 'api-cache', networkTimeoutSeconds: 10, }), }, // Dynamic assets (audio, other cacheable) - Cache First // Uses 'assets' cache to match preloading (usePreloadOrchestrator stores here) { matcher: ({ request }) => isCacheableRequest(request) && !isVideoRequest(request), handler: new CacheFirst({ cacheName: OFFLINE_CONFIG.cacheNames.assets, plugins: [ { cacheWillUpdate: async ({ response }) => { if (response && response.status === 200) { return response; } return null; }, }, ], }), }, // Default caching strategies ...defaultCache, ], }); // Message event - handle commands from main thread self.addEventListener('message', (event) => { const { type, payload } = event.data || {}; switch (type) { case 'CACHE_ASSETS': // Cache specific assets for a project/page if (Array.isArray(payload?.urls)) { event.waitUntil( caches.open(OFFLINE_CONFIG.cacheNames.assets).then((cache) => { return Promise.all( payload.urls.map((url: string) => fetch(url) .then((response) => { if (response.status === 200) { return cache.put(url, response); } }) .catch((error) => { console.warn('[SW] Failed to cache asset:', url, error); }), ), ); }), ); } break; case 'CACHE_VIDEO_CHUNK': // Cache a video chunk with range support if (payload?.url && payload?.chunk) { event.waitUntil( caches.open(OFFLINE_CONFIG.cacheNames.assets).then((cache) => { const response = new Response(payload.chunk, { headers: { 'Content-Type': payload.contentType || 'video/mp4', 'Content-Length': String(payload.chunk.byteLength), }, }); return cache.put(payload.url, response); }), ); } break; case 'CLEAR_CACHE': // Clear all dynamic caches event.waitUntil( Promise.all([ caches.delete(OFFLINE_CONFIG.cacheNames.dynamic), caches.delete(OFFLINE_CONFIG.cacheNames.assets), ]).then(() => { console.log('[SW] Caches cleared'); }), ); break; case 'GET_CACHE_STATUS': // Return current cache status event.waitUntil( caches.open(OFFLINE_CONFIG.cacheNames.assets).then((cache) => { return cache.keys().then((keys) => { const source = event.source as Client | null; source?.postMessage({ type: 'CACHE_STATUS', payload: { cachedCount: keys.length, urls: keys.map((request) => request.url), }, }); }); }), ); break; case 'SKIP_WAITING': self.skipWaiting(); break; default: break; } }); // Add event listeners for Serwist serwist.addEventListeners(); console.log('[SW] Serwist service worker loaded');