/** * 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, NetworkOnly, 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 has sw-bypass flag (skip SW caching) const hasBypassFlag = (request: Request): boolean => { const url = new URL(request.url); return url.searchParams.get('sw-bypass') === '1'; }; // 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), ); }; // Check if request is audio const isAudioRequest = (request: Request): boolean => { const url = new URL(request.url); return ['.mp3', '.wav', '.ogg', '.m4a', '.aac'].some((ext) => url.pathname.toLowerCase().endsWith(ext), ); }; /** * Extract storage path from various URL formats. * Handles: * - Presigned S3 URLs: https://s3.../bucket/assets/project/file.mp4?X-Amz-Signature=... * - Backend proxy URLs: http://localhost:8080/api/file/download?privateUrl=assets%2F... * - Relative paths: assets/project/file.mp4 */ const extractStoragePathFromUrl = (url: string): string | null => { try { // Backend proxy URL format if (url.includes('/file/download?privateUrl=')) { const match = url.match(/privateUrl=([^&]+)/); if (match) { return decodeURIComponent(match[1]).replace(/^\/+/, ''); } } // Presigned S3 URL if (url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=')) { const urlObj = new URL(url); const pathParts = urlObj.pathname.split('/').filter(Boolean); const assetsIndex = pathParts.findIndex((part) => part === 'assets'); if (assetsIndex !== -1) { return pathParts.slice(assetsIndex).join('/'); } if (pathParts.length > 1) { return pathParts.slice(1).join('/'); } } // Already a relative storage path (starts with 'assets/') if (url.startsWith('assets/')) { return url; } // Full S3 URL (non-presigned) const s3Match = url.match( /^https?:\/\/[^/]+\.s3\.[^/]+\.amazonaws\.com\/[^/]+\/(assets\/.+)$/, ); if (s3Match) { return s3Match[1].split('?')[0]; // Remove query params } return null; } catch { return null; } }; // Storage path → storage key mapping (for caching browser video/audio requests) // When main thread does partial preload, it registers {storagePath → storageKey} // SW extracts storage path from any URL format and uses it for cache lookups const storagePathToKeyMap = new Map(); // Clean up old mappings every hour (session cleanup) setInterval( () => { storagePathToKeyMap.clear(); console.log('[SW] Cleared storage path mappings'); }, 60 * 60 * 1000, ); // Initialize Serwist const serwist = new Serwist({ precacheEntries: self.__SW_MANIFEST, skipWaiting: true, clientsClaim: true, navigationPreload: true, runtimeCaching: [ // BYPASS HANDLER - Must be FIRST to intercept sw-bypass=1 requests // These requests go directly to network without any caching or URL transformation { matcher: ({ request }) => hasBypassFlag(request), handler: new NetworkOnly(), }, // 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: [ { // Transform URL to storage key for consistent caching // Matches preload behavior (DownloadManager uses storage keys) cacheKeyWillBeUsed: async ({ request, mode }) => { const storagePath = extractStoragePathFromUrl(request.url); if (storagePath) { console.log( `[SW] Using storagePath for static asset ${mode}:`, storagePath.slice(-40), ); return new Request(storagePath); } return request; }, cacheWillUpdate: async ({ response }) => { if (response && response.status === 200) { return response; } return null; }, }, ], }), }, // Videos - Cache First with Range Request support and storage key mapping // Note: Bypass requests are already handled by the NetworkOnly handler above { matcher: ({ request }) => isVideoRequest(request), handler: new CacheFirst({ cacheName: OFFLINE_CONFIG.cacheNames.assets, plugins: [ { // Transform URL to storage key for BOTH cache reads and writes // Per Serwist: mode='read' for lookups, mode='write' for storing cacheKeyWillBeUsed: async ({ request, mode }) => { // Extract storage path from any URL format const storagePath = extractStoragePathFromUrl(request.url); if (storagePath) { // Check if we have a mapping for this storage path const storageKey = storagePathToKeyMap.get(storagePath); if (storageKey) { console.log( `[SW] Using storageKey for video ${mode}:`, storageKey.slice(-40), ); return new Request(storageKey); } // No explicit mapping, use extracted storage path as cache key console.log( `[SW] Using storagePath for video ${mode}:`, storagePath.slice(-40), ); return new Request(storagePath); } // No storage path extracted - use original URL (fallback) return request; }, // 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; }, }, ], }), }, // Audio - Cache First with Range Request support and storage key mapping // Note: Bypass requests are already handled by the NetworkOnly handler above { matcher: ({ request }) => isAudioRequest(request), handler: new CacheFirst({ cacheName: OFFLINE_CONFIG.cacheNames.assets, plugins: [ { // Transform URL to storage key for BOTH cache reads and writes cacheKeyWillBeUsed: async ({ request, mode }) => { // Extract storage path from any URL format const storagePath = extractStoragePathFromUrl(request.url); if (storagePath) { const storageKey = storagePathToKeyMap.get(storagePath); if (storageKey) { console.log( `[SW] Using storageKey for audio ${mode}:`, storageKey.slice(-40), ); return new Request(storageKey); } // No explicit mapping, use extracted storage path as cache key console.log( `[SW] Using storagePath for audio ${mode}:`, storagePath.slice(-40), ); return new Request(storagePath); } return request; }, // Handle range requests for audio seeking cachedResponseWillBeUsed: async ({ cachedResponse, request }) => { if (!cachedResponse) return null; const rangeHeader = request.headers.get('range'); if (!rangeHeader) return cachedResponse; 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; const blob = await cachedResponse.blob(); const slicedBlob = end !== undefined ? blob.slice(start, end + 1) : blob.slice(start); return new Response(slicedBlob, { status: 206, statusText: 'Partial Content', headers: { 'Content-Type': cachedResponse.headers.get('Content-Type') || 'audio/mpeg', '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 (other cacheable, excluding video and audio) - Cache First // Uses 'assets' cache to match preloading (usePreloadOrchestrator stores here) // Note: Bypass requests are already handled by the NetworkOnly handler above { matcher: ({ request }) => isCacheableRequest(request) && !isVideoRequest(request) && !isAudioRequest(request), handler: new CacheFirst({ cacheName: OFFLINE_CONFIG.cacheNames.assets, plugins: [ { // Transform URL to storage key for consistent caching // Matches preload behavior (DownloadManager uses storage keys) cacheKeyWillBeUsed: async ({ request, mode }) => { const storagePath = extractStoragePathFromUrl(request.url); if (storagePath) { console.log( `[SW] Using storagePath for dynamic asset ${mode}:`, storagePath.slice(-40), ); return new Request(storagePath); } return request; }, 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 'REGISTER_CACHE_URL': // Register storage path → storage key mapping for media caching // Main thread sends this after partial preload; when browser fetches // the full media during playback, we cache using the storage key if (payload?.storageKey) { // Extract storage path from presigned URL (or use storageKey directly) const storagePath = payload.presignedUrl ? extractStoragePathFromUrl(payload.presignedUrl) || payload.storageKey : payload.storageKey; storagePathToKeyMap.set(storagePath, payload.storageKey); console.log('[SW] Registered storage path for caching', { storagePath: storagePath.slice(-50), storageKey: payload.storageKey.slice(-50), }); } break; case 'CLEAR_URL_MAPPINGS': // Clear storage path mappings (called on cleanup/unmount) storagePathToKeyMap.clear(); console.log('[SW] Storage path mappings cleared'); break; 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 and storage path mappings storagePathToKeyMap.clear(); event.waitUntil( Promise.all([ caches.delete(OFFLINE_CONFIG.cacheNames.dynamic), caches.delete(OFFLINE_CONFIG.cacheNames.assets), ]).then(() => { console.log('[SW] Caches and storage path mappings 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');