301 lines
8.2 KiB
TypeScript
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');
|