/**
* CanadaGPT Service Worker
*
* Provides offline support and performance optimization through caching.
*
* Caching Strategies:
* - Static assets (CSS, JS, fonts): Cache-first
* - Images: Cache-first with network fallback
* - API requests: Network-first with cache fallback
* - HTML pages: Network-first with offline fallback
*/
const CACHE_VERSION = 'v1';
const STATIC_CACHE = `canadagpt-static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `canadagpt-dynamic-${CACHE_VERSION}`;
const IMAGE_CACHE = `canadagpt-images-${CACHE_VERSION}`;
// Assets to pre-cache on install
const PRECACHE_ASSETS = [
'/offline',
'/manifest.json',
'/icon-192.png',
'/icon-512.png',
];
// Cache duration in milliseconds
const CACHE_DURATION = {
static: 7 * 24 * 60 * 60 * 1000, // 7 days
dynamic: 24 * 60 * 60 * 1000, // 1 day
image: 30 * 24 * 60 * 60 * 1000, // 30 days
};
/**
* Install event - precache essential assets
*/
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker...');
event.waitUntil(
caches.open(STATIC_CACHE)
.then((cache) => {
console.log('[SW] Precaching assets');
return cache.addAll(PRECACHE_ASSETS);
})
.then(() => {
console.log('[SW] Installation complete');
return self.skipWaiting();
})
.catch((error) => {
console.error('[SW] Installation failed:', error);
})
);
});
/**
* Activate event - clean up old caches
*/
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker...');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((cacheName) => {
return cacheName.startsWith('canadagpt-') &&
cacheName !== STATIC_CACHE &&
cacheName !== DYNAMIC_CACHE &&
cacheName !== IMAGE_CACHE;
})
.map((cacheName) => {
console.log('[SW] Deleting old cache:', cacheName);
return caches.delete(cacheName);
})
);
})
.then(() => {
console.log('[SW] Activation complete');
return self.clients.claim();
})
);
});
/**
* Fetch event - handle requests with appropriate caching strategy
*/
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// Skip chrome-extension and other non-http(s) requests
if (!url.protocol.startsWith('http')) {
return;
}
// Skip API routes that shouldn't be cached (auth, mutations)
if (url.pathname.startsWith('/api/auth') ||
url.pathname.startsWith('/api/chat') ||
url.pathname.startsWith('/api/user')) {
return;
}
// Determine caching strategy based on request type
if (isStaticAsset(url)) {
event.respondWith(cacheFirst(request, STATIC_CACHE));
} else if (isImageRequest(url, request)) {
event.respondWith(cacheFirst(request, IMAGE_CACHE));
} else if (isAPIRequest(url)) {
event.respondWith(networkFirst(request, DYNAMIC_CACHE));
} else if (isNavigationRequest(request)) {
event.respondWith(networkFirstWithOffline(request));
}
});
/**
* Check if request is for a static asset
*/
function isStaticAsset(url) {
return url.pathname.match(/\.(js|css|woff2?|ttf|eot)$/) ||
url.pathname.startsWith('/_next/static/');
}
/**
* Check if request is for an image
*/
function isImageRequest(url, request) {
return url.pathname.match(/\.(png|jpg|jpeg|gif|webp|svg|ico)$/) ||
request.destination === 'image' ||
url.hostname === 'storage.googleapis.com';
}
/**
* Check if request is an API request
*/
function isAPIRequest(url) {
return url.pathname.startsWith('/api/') ||
url.pathname.includes('/graphql');
}
/**
* Check if request is a navigation request (HTML page)
*/
function isNavigationRequest(request) {
return request.mode === 'navigate' ||
request.destination === 'document';
}
/**
* Cache-first strategy
* Try cache, fall back to network, then cache the response
*/
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
// Refresh cache in background for static assets
refreshCache(request, cache);
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error('[SW] Cache-first fetch failed:', error);
return new Response('Offline', { status: 503 });
}
}
/**
* Network-first strategy
* Try network, fall back to cache
*/
async function networkFirst(request, cacheName) {
const cache = await caches.open(cacheName);
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
console.error('[SW] Network-first fetch failed:', error);
return new Response(JSON.stringify({ error: 'Offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
/**
* Network-first with offline fallback for navigation
*/
async function networkFirstWithOffline(request) {
try {
const networkResponse = await fetch(request);
return networkResponse;
} catch (error) {
// Try to return cached page
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// Return offline page
const offlinePage = await caches.match('/offline');
if (offlinePage) {
return offlinePage;
}
// Last resort fallback
return new Response(
'<!DOCTYPE html><html><head><title>Offline</title></head><body><h1>You are offline</h1><p>Please check your internet connection.</p></body></html>',
{ headers: { 'Content-Type': 'text/html' } }
);
}
}
/**
* Refresh cache in background
*/
async function refreshCache(request, cache) {
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
} catch (error) {
// Silently fail - we have cached version
}
}
/**
* Handle push notifications (future feature)
*/
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
const options = {
body: data.body,
icon: '/icon-192.png',
badge: '/icon-72.png',
vibrate: [100, 50, 100],
data: {
url: data.url || '/',
},
actions: data.actions || [],
};
event.waitUntil(
self.registration.showNotification(data.title || 'CanadaGPT', options)
);
});
/**
* Handle notification clicks
*/
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Focus existing window if available
for (const client of clientList) {
if (client.url.includes(url) && 'focus' in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
});
/**
* Handle background sync (for offline form submissions)
*/
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-bookmarks') {
event.waitUntil(syncBookmarks());
}
});
async function syncBookmarks() {
// Future: Sync offline bookmarks when back online
console.log('[SW] Syncing bookmarks...');
}
console.log('[SW] Service worker loaded');