2026-03-24 08:20:27 +04:00

301 lines
8.2 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),
);
};
// 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');