2026-04-07 16:42:56 +04:00

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');