521 lines
17 KiB
TypeScript
521 lines
17 KiB
TypeScript
/**
|
|
* 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
|
|
*/
|
|
|
|
/// <reference lib="webworker" />
|
|
|
|
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),
|
|
);
|
|
};
|
|
|
|
// 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<string, string>();
|
|
|
|
// 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: [
|
|
// 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
|
|
{
|
|
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
|
|
{
|
|
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)
|
|
{
|
|
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');
|