app/src/sw.ts
2025-07-11 13:21:49 +00:00

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
})