274 lines
7.4 KiB
TypeScript
274 lines
7.4 KiB
TypeScript
// Disables access to DOM typings like `HTMLElement` which are not available
|
|
// inside a service worker and instantiates the correct globals
|
|
/// <reference no-default-lib="true"/>
|
|
/// <reference lib="esnext" />
|
|
/// <reference lib="webworker" />
|
|
|
|
// Ensures that the `$service-worker` import has proper type definitions
|
|
/// <reference types="@sveltejs/kit" />
|
|
|
|
// Only necessary if you have an import from `$env/static/public`
|
|
/// <reference types="../.svelte-kit/ambient.d.ts" />
|
|
|
|
import { build, files, version } from '$service-worker';
|
|
|
|
// This gives `self` the correct types
|
|
const self = globalThis.self as unknown as ServiceWorkerGlobalScope;
|
|
|
|
// Create cache names for this deployment
|
|
const CACHE = `cache-${version}`;
|
|
const RUNTIME_CACHE = `runtime-${version}`;
|
|
const IMAGE_CACHE = `images-${version}`;
|
|
const R2_CACHE = `r2-${version}`;
|
|
|
|
const ASSETS = [
|
|
...build, // the app itself
|
|
...files // everything in `static`
|
|
];
|
|
|
|
// Assets to precache for better performance
|
|
const CRITICAL_ASSETS = ['/cafe-bg-compressed.jpg', '/fonts/Washington.ttf', '/logo.svg'];
|
|
|
|
self.addEventListener('install', (event) => {
|
|
// Create a new cache and add all files to it
|
|
async function addFilesToCache() {
|
|
const cache = await caches.open(CACHE);
|
|
const imageCache = await caches.open(IMAGE_CACHE);
|
|
|
|
// Cache core assets
|
|
await cache.addAll(ASSETS);
|
|
|
|
// Precache critical assets with error handling
|
|
await Promise.allSettled(
|
|
CRITICAL_ASSETS.map(async (asset) => {
|
|
try {
|
|
const response = await fetch(asset);
|
|
if (response.ok) {
|
|
if (
|
|
asset.includes('jpg') ||
|
|
asset.includes('jpeg') ||
|
|
asset.includes('png') ||
|
|
asset.includes('webp')
|
|
) {
|
|
await imageCache.put(asset, response);
|
|
} else {
|
|
await cache.put(asset, response);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Failed to cache ${asset}:`, error);
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
event.waitUntil(addFilesToCache());
|
|
// Skip waiting to activate immediately
|
|
self.skipWaiting();
|
|
});
|
|
|
|
self.addEventListener('activate', (event) => {
|
|
// Remove previous cached data from disk
|
|
async function deleteOldCaches() {
|
|
const currentCaches = [CACHE, RUNTIME_CACHE, IMAGE_CACHE, R2_CACHE];
|
|
for (const key of await caches.keys()) {
|
|
if (!currentCaches.includes(key)) {
|
|
await caches.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
event.waitUntil(deleteOldCaches());
|
|
// Claim clients immediately
|
|
self.clients.claim();
|
|
});
|
|
|
|
self.addEventListener('fetch', (event) => {
|
|
// ignore POST requests etc
|
|
if (event.request.method !== 'GET') return;
|
|
|
|
async function respond() {
|
|
const url = new URL(event.request.url);
|
|
const cache = await caches.open(CACHE);
|
|
const runtimeCache = await caches.open(RUNTIME_CACHE);
|
|
const imageCache = await caches.open(IMAGE_CACHE);
|
|
const r2Cache = await caches.open(R2_CACHE);
|
|
|
|
// `build`/`files` can always be served from the cache
|
|
if (ASSETS.includes(url.pathname)) {
|
|
const response = await cache.match(url.pathname);
|
|
|
|
if (response) {
|
|
return response;
|
|
}
|
|
}
|
|
|
|
// Handle images with cache-first strategy
|
|
if (url.pathname.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)) {
|
|
const cachedResponse = await imageCache.match(event.request);
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(event.request);
|
|
if (response.ok) {
|
|
imageCache.put(event.request, response.clone());
|
|
}
|
|
return response;
|
|
} catch {
|
|
// Return a fallback image or the cached version
|
|
return cachedResponse || new Response('Image not available', { status: 404 });
|
|
}
|
|
}
|
|
|
|
// Handle fonts with cache-first strategy
|
|
if (url.pathname.match(/\.(woff|woff2|ttf|otf)$/i)) {
|
|
const cachedResponse = await cache.match(event.request);
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
}
|
|
|
|
// Handle R2 resources with cache-first strategy
|
|
if (url.hostname.includes('.r2.dev') || url.hostname.includes('.r2.cloudflarestorage.com')) {
|
|
const cachedResponse = await r2Cache.match(event.request);
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(event.request);
|
|
if (response.ok) {
|
|
// Cache R2 resources for a long time as they're typically immutable
|
|
r2Cache.put(event.request, response.clone());
|
|
}
|
|
return response;
|
|
} catch {
|
|
// Return cached version if available, or fall through to other cache checks
|
|
return cachedResponse || new Response('R2 resource not available', { status: 404 });
|
|
}
|
|
}
|
|
|
|
// Handle OpenStreetMap tiles with cache-first strategy
|
|
if (url.hostname === 'tile.openstreetmap.org') {
|
|
const cachedResponse = await r2Cache.match(event.request);
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(event.request);
|
|
if (response.ok) {
|
|
// Cache map tiles for a long time
|
|
r2Cache.put(event.request, response.clone());
|
|
}
|
|
return response;
|
|
} catch {
|
|
return cachedResponse || new Response('Map tile not available', { status: 404 });
|
|
}
|
|
}
|
|
|
|
// Handle API and dynamic content with network-first strategy
|
|
if (url.pathname.startsWith('/api/') || url.searchParams.has('_data')) {
|
|
try {
|
|
const response = await fetch(event.request);
|
|
if (response.ok) {
|
|
runtimeCache.put(event.request, response.clone());
|
|
}
|
|
return response;
|
|
} catch (err) {
|
|
const cachedResponse = await runtimeCache.match(event.request);
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// for everything else, try the network first, but
|
|
// fall back to the cache if we're offline
|
|
try {
|
|
const response = await fetch(event.request);
|
|
|
|
// if we're offline, fetch can return a value that is not a Response
|
|
// instead of throwing - and we can't pass this non-Response to respondWith
|
|
if (!(response instanceof Response)) {
|
|
throw new Error('invalid response from fetch');
|
|
}
|
|
|
|
if (response.status === 200) {
|
|
runtimeCache.put(event.request, response.clone());
|
|
}
|
|
|
|
return response;
|
|
} catch (err) {
|
|
// Try all caches for fallback
|
|
const cachedResponse =
|
|
(await cache.match(event.request)) ||
|
|
(await runtimeCache.match(event.request)) ||
|
|
(await imageCache.match(event.request)) ||
|
|
(await r2Cache.match(event.request));
|
|
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
|
|
// if there's no cache, then just error out
|
|
// as there is nothing we can do to respond to this request
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
event.respondWith(respond());
|
|
});
|
|
|
|
// Handle push notifications
|
|
self.addEventListener('push', (event) => {
|
|
if (!event.data) {
|
|
return;
|
|
}
|
|
|
|
const data = event.data.json();
|
|
const title = data.title || 'Serengo';
|
|
const options = {
|
|
body: data.body || '',
|
|
icon: data.icon || '/logo.svg',
|
|
badge: data.badge || '/logo.svg',
|
|
tag: data.tag || 'default',
|
|
data: data.data || {},
|
|
requireInteraction: false,
|
|
vibrate: [200, 100, 200]
|
|
};
|
|
|
|
event.waitUntil(self.registration.showNotification(title, options));
|
|
});
|
|
|
|
// Handle notification clicks
|
|
self.addEventListener('notificationclick', (event) => {
|
|
event.notification.close();
|
|
|
|
const urlToOpen = event.notification.data?.url || '/';
|
|
|
|
event.waitUntil(
|
|
self.clients
|
|
.matchAll({
|
|
type: 'window',
|
|
includeUncontrolled: true
|
|
})
|
|
.then((clientList) => {
|
|
// Check if there's already a window open
|
|
for (const client of clientList) {
|
|
if (client.url === urlToOpen && 'focus' in client) {
|
|
return client.focus();
|
|
}
|
|
}
|
|
|
|
// Otherwise, open a new window
|
|
if (self.clients.openWindow) {
|
|
return self.clients.openWindow(urlToOpen);
|
|
}
|
|
})
|
|
);
|
|
});
|