286 lines
8.3 KiB
TypeScript
286 lines
8.3 KiB
TypeScript
/* eslint-env serviceworker */
|
|
/// <reference lib="webworker" />
|
|
/// <reference types="vite/client" />
|
|
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
|
import { NavigationRoute, registerRoute } from 'workbox-routing'
|
|
import { NetworkFirst, CacheFirst, StaleWhileRevalidate } from 'workbox-strategies'
|
|
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
|
|
import { ExpirationPlugin } from 'workbox-expiration'
|
|
import { BackgroundSyncPlugin } from 'workbox-background-sync'
|
|
|
|
declare const self: ServiceWorkerGlobalScope
|
|
// Only apply service worker logic if on the app subdomain
|
|
if (!self.location.hostname.startsWith('app.')) {
|
|
console.log('Skipping SW install: not on app domain');
|
|
self.skipWaiting();
|
|
self.registration?.unregister();
|
|
}
|
|
|
|
// Define cache names with domain awareness
|
|
const CACHE_NAMES = {
|
|
static: `static-assets-v1-${self.location.hostname}`,
|
|
dynamic: `dynamic-content-v1-${self.location.hostname}`,
|
|
pages: `pages-v1-${self.location.hostname}`,
|
|
api: `api-v1-${self.location.hostname}`,
|
|
offline: `offline-v1-${self.location.hostname}`
|
|
}
|
|
|
|
// Clean up old caches
|
|
cleanupOutdatedCaches()
|
|
|
|
// Precache production assets only
|
|
// Development resources are excluded by Vite's build configuration
|
|
const manifest = self.__WB_MANIFEST;
|
|
|
|
// Find the index.html entry from the manifest
|
|
const manifestIndexEntry = manifest.find(entry => {
|
|
const url = typeof entry === 'string' ? entry : entry.url;
|
|
return url === 'index.html' || url === '/index.html';
|
|
});
|
|
|
|
// Create index entries using the revision from manifest if available
|
|
const indexEntries = [
|
|
{
|
|
url: '/index.html',
|
|
revision: manifestIndexEntry && typeof manifestIndexEntry !== 'string' ? manifestIndexEntry.revision : null
|
|
},
|
|
{
|
|
url: '/',
|
|
revision: manifestIndexEntry && typeof manifestIndexEntry !== 'string' ? manifestIndexEntry.revision : null
|
|
}
|
|
];
|
|
|
|
// Filter out index.html from manifest and add absolute paths
|
|
const manifestWithoutIndex = manifest.filter(entry => {
|
|
const url = typeof entry === 'string' ? entry : entry.url;
|
|
return url !== 'index.html' && url !== '/index.html';
|
|
});
|
|
|
|
// Combine entries and ensure absolute paths
|
|
const manifestWithIndex = [
|
|
...indexEntries,
|
|
...manifestWithoutIndex.map(entry => {
|
|
if (typeof entry === 'string') {
|
|
return { url: entry.startsWith('/') ? entry : `/${entry}`, revision: null };
|
|
}
|
|
return {
|
|
...entry,
|
|
url: entry.url.startsWith('/') ? entry.url : `/${entry.url}`
|
|
};
|
|
})
|
|
];
|
|
|
|
// Precache all assets
|
|
precacheAndRoute(manifestWithIndex);
|
|
|
|
// Create a background sync queue for failed requests
|
|
const bgSyncPlugin = new BackgroundSyncPlugin('failedRequests', {
|
|
maxRetentionTime: 24 * 60 // Retry for up to 24 hours (specified in minutes)
|
|
})
|
|
|
|
// Navigation handler for offline usage
|
|
const navigationHandler = async ({ request }: { request: Request }): Promise<Response> => {
|
|
try {
|
|
// Try network first
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
return response;
|
|
}
|
|
} catch (error) {
|
|
console.log('Navigation fetch failed:', error);
|
|
}
|
|
|
|
// If network fails, try cache
|
|
const cache = await caches.open(CACHE_NAMES.pages);
|
|
|
|
// Try both root and /index.html paths
|
|
const cachedResponse = await cache.match(request) ||
|
|
await cache.match('/') ||
|
|
await cache.match('/index.html');
|
|
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
|
|
// If cache fails, try to fetch index.html directly
|
|
try {
|
|
const indexResponse = await fetch('/index.html');
|
|
if (indexResponse.ok) {
|
|
await cache.put('/index.html', indexResponse.clone());
|
|
return indexResponse;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch index.html:', error);
|
|
}
|
|
|
|
// If all else fails, return a basic offline page
|
|
return new Response(
|
|
'<html><body><h1>Offline</h1><p>Please check your internet connection.</p></body></html>',
|
|
{
|
|
headers: { 'Content-Type': 'text/html' }
|
|
}
|
|
);
|
|
};
|
|
|
|
// Register navigation route
|
|
const navigationRoute = new NavigationRoute(navigationHandler, {
|
|
denylist: [
|
|
/^\/(auth|rest|api|whisperlive|tldraw|searxng-api)/,
|
|
/^\/@.*/, // Block all /@vite/, /@react-refresh/, etc.
|
|
/^\/src\/.*/ // Block all /src/ paths
|
|
]
|
|
});
|
|
registerRoute(navigationRoute);
|
|
|
|
// Cache page navigations (html) with a Network First strategy
|
|
registerRoute(
|
|
// Check to see if the request is a navigation to a new page
|
|
({ request }) => request.mode === 'navigate',
|
|
new NetworkFirst({
|
|
// Put all cached files in a cache named 'pages'
|
|
cacheName: CACHE_NAMES.pages,
|
|
plugins: [
|
|
// Ensure that only requests that result in a 200 status are cached
|
|
new CacheableResponsePlugin({
|
|
statuses: [200]
|
|
})
|
|
],
|
|
networkTimeoutSeconds: 3
|
|
})
|
|
)
|
|
|
|
// Cache manifest and icons with a Stale While Revalidate strategy
|
|
registerRoute(
|
|
({ request }) =>
|
|
request.destination === 'manifest' ||
|
|
request.url.includes('/icons/'),
|
|
new StaleWhileRevalidate({
|
|
cacheName: 'manifest-and-icons',
|
|
plugins: [
|
|
new CacheableResponsePlugin({
|
|
statuses: [200]
|
|
})
|
|
]
|
|
})
|
|
)
|
|
|
|
// Cache other static assets with a Cache First strategy
|
|
registerRoute(
|
|
({ request }) => {
|
|
const destination = request.destination;
|
|
return destination === 'style' ||
|
|
destination === 'script' ||
|
|
destination === 'image' ||
|
|
destination === 'font'
|
|
},
|
|
new CacheFirst({
|
|
cacheName: CACHE_NAMES.static,
|
|
plugins: [
|
|
new CacheableResponsePlugin({
|
|
statuses: [200]
|
|
}),
|
|
new ExpirationPlugin({
|
|
maxEntries: 60,
|
|
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
|
|
purgeOnQuotaError: true
|
|
})
|
|
]
|
|
})
|
|
)
|
|
|
|
// Cache API responses with a Network First strategy
|
|
// TODO: This is a temporary solution to cache API responses. We should move to a more efficient strategy in the future using our new domain names search.classroomcopilot.ai and api.classroomcopilot.ai
|
|
registerRoute(
|
|
({ url }) => url.pathname.startsWith('/api/'),
|
|
new NetworkFirst({
|
|
cacheName: CACHE_NAMES.api,
|
|
plugins: [
|
|
new CacheableResponsePlugin({
|
|
statuses: [200]
|
|
}),
|
|
new ExpirationPlugin({
|
|
maxEntries: 50,
|
|
maxAgeSeconds: 5 * 60 // 5 minutes
|
|
}),
|
|
bgSyncPlugin
|
|
]
|
|
})
|
|
)
|
|
|
|
// Cache SearXNG API with Network First strategy
|
|
registerRoute(
|
|
({ url }) => url.pathname.startsWith('/searxng-api'),
|
|
new NetworkFirst({
|
|
cacheName: CACHE_NAMES.api,
|
|
plugins: [
|
|
new CacheableResponsePlugin({
|
|
statuses: [200]
|
|
}),
|
|
new ExpirationPlugin({
|
|
maxEntries: 50,
|
|
maxAgeSeconds: 60 * 60 // 1 hour
|
|
}),
|
|
bgSyncPlugin
|
|
]
|
|
})
|
|
)
|
|
|
|
// Cache SearXNG static assets
|
|
registerRoute(
|
|
({ url }) => url.pathname.startsWith('/searxng-api/static'),
|
|
new CacheFirst({
|
|
cacheName: CACHE_NAMES.static,
|
|
plugins: [
|
|
new CacheableResponsePlugin({
|
|
statuses: [200]
|
|
}),
|
|
new ExpirationPlugin({
|
|
maxEntries: 50,
|
|
maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days
|
|
})
|
|
]
|
|
})
|
|
)
|
|
|
|
// This allows the web app to trigger skipWaiting via
|
|
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
|
|
self.addEventListener('message', (event) => {
|
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
|
self.skipWaiting()
|
|
}
|
|
})
|
|
|
|
// Handle offline fallback
|
|
self.addEventListener('install', () => {
|
|
self.skipWaiting()
|
|
})
|
|
|
|
self.addEventListener('activate', (event) => {
|
|
event.waitUntil(
|
|
Promise.all([
|
|
// Enable navigation preload if available
|
|
self.registration.navigationPreload?.enable(),
|
|
// Delete old caches
|
|
caches.keys().then((cacheNames) => {
|
|
return Promise.all(
|
|
cacheNames
|
|
.filter((cacheName) => !Object.values(CACHE_NAMES).includes(cacheName))
|
|
.map((cacheName) => caches.delete(cacheName))
|
|
)
|
|
}),
|
|
// Tell the active service worker to take control of the page immediately
|
|
self.clients.claim()
|
|
])
|
|
)
|
|
})
|
|
|
|
// Handle errors
|
|
self.addEventListener('error', (event) => {
|
|
console.error('[Service Worker] Error:', event.error)
|
|
// You could send this to your error tracking service
|
|
})
|
|
|
|
self.addEventListener('unhandledrejection', (event) => {
|
|
console.error('[Service Worker] Unhandled rejection:', event.reason)
|
|
// You could send this to your error tracking service
|
|
})
|