Files
schulos/public/sw.js

347 lines
11 KiB
JavaScript
Executable File

// SchulOS — Service Worker for PWA Offline Support
const CACHE_NAME = 'schulos-v3';
const STATIC_CACHE = 'schulos-static-v3';
const DATA_CACHE = 'schulos-data-v3';
const OFFLINE_CACHE = 'schulos-offline-v3';
const SYNC_TAG = 'schulos-sync';
const LEGACY_SYNC_TAG = 'competencetrack-sync';
const SYNC_DB = 'schulos-sync-db';
const LEGACY_SYNC_DB = 'competencetrack-sync-db';
// Static assets to cache on install
const STATIC_ASSETS = [
'/',
'/manifest.json',
'/logo.svg',
'/robots.txt',
];
// Install event — cache static assets and offline fallback
self.addEventListener('install', (event) => {
event.waitUntil(
Promise.all([
caches.open(STATIC_CACHE).then((cache) => {
return cache.addAll(STATIC_ASSETS).catch((err) => {
console.warn('[SW] Some static assets failed to cache:', err);
});
}),
caches.open(OFFLINE_CACHE).then((cache) => {
return cache.add('/').catch(() => {
// Fallback: just create the cache
});
}),
]).then(() => {
console.log('[SW] Installed and caches populated');
return self.skipWaiting();
})
);
});
// Activate event — migrate queued writes before clearing legacy caches.
self.addEventListener('activate', (event) => {
event.waitUntil(
migrateLegacyPendingRequests()
.then(() => caches.keys())
.then((keys) => Promise.all(
keys
.filter((key) => key !== CACHE_NAME && key !== STATIC_CACHE && key !== DATA_CACHE && key !== OFFLINE_CACHE)
.map((key) => caches.delete(key))
))
.then(() => self.clients.claim())
);
});
// Fetch event — routing strategy
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests (POST, PUT, DELETE) — handle with background sync
if (request.method !== 'GET') {
// Queue write operations for background sync
if (navigator.onLine === false) {
event.respondWith(
new Response(JSON.stringify({ queued: true, offline: true }), {
status: 202,
headers: { 'Content-Type': 'application/json' },
})
);
// Store the request for later sync
storeRequestForSync(request);
return;
}
return; // Let non-GET requests go through normally when online
}
// Skip chrome-extension and other non-http requests
if (!url.protocol.startsWith('http')) return;
// API data requests — NetworkFirst strategy
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirstStrategy(request, DATA_CACHE));
return;
}
// Never cache /_next/ paths — Turbopack recompiles frequently and cached chunks
// cause "module factory is not available" errors. Always fetch from network.
if (url.pathname.startsWith('/_next/')) {
event.respondWith(
fetch(request).catch(() => new Response('Offline', { status: 503 }))
);
return;
}
// Static assets (CSS, JS, images) from other origins — CacheFirst strategy
if (
url.pathname.endsWith('.css') ||
url.pathname.endsWith('.js')
) {
event.respondWith(networkFirstStrategy(request, STATIC_CACHE));
return;
}
if (
url.pathname.endsWith('.png') ||
url.pathname.endsWith('.jpg') ||
url.pathname.endsWith('.svg') ||
url.pathname.endsWith('.ico') ||
url.pathname.endsWith('.woff2') ||
url.pathname.endsWith('.woff')
) {
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE));
return;
}
// Navigation requests — NetworkFirst with offline fallback
if (request.mode === 'navigate') {
event.respondWith(networkFirstWithOfflineFallback(request));
return;
}
// Default: NetworkFirst
event.respondWith(networkFirstStrategy(request, CACHE_NAME));
});
// NetworkFirst strategy — try network, fall back to cache
async function networkFirstStrategy(request, cacheName) {
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(cacheName);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// Return offline response for API calls
if (new URL(request.url).pathname.startsWith('/api/')) {
return new Response(JSON.stringify({ error: 'offline', message: 'No cached data available' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response('Offline', { status: 503 });
}
}
// CacheFirst strategy — try cache, fall back to network
async function cacheFirstStrategy(request, cacheName) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(cacheName);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// No cache and no network
return new Response('Offline', { status: 503 });
}
}
// NetworkFirst with offline fallback page
async function networkFirstWithOfflineFallback(request) {
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// Fall back to cached root page
const fallbackResponse = await caches.match('/');
if (fallbackResponse) {
return fallbackResponse;
}
return new Response('Offline — Please check your connection', {
status: 503,
headers: { 'Content-Type': 'text/html' },
});
}
}
// Store failed requests for background sync
async function storeRequestForSync(request) {
const db = await openIndexedDB(SYNC_DB);
const tx = db.transaction('pending-requests', 'readwrite');
const store = tx.objectStore('pending-requests');
const requestData = {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
body: await request.text().catch(() => ''),
timestamp: Date.now(),
};
store.add(requestData);
await transactionDone(tx);
self.registration.sync.register(SYNC_TAG);
}
function openIndexedDB(name) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name, 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('pending-requests')) {
db.createObjectStore('pending-requests', { keyPath: 'timestamp' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function transactionDone(tx) {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
async function migrateLegacyPendingRequests() {
const legacyDb = await openIndexedDB(LEGACY_SYNC_DB);
const legacyTx = legacyDb.transaction('pending-requests', 'readonly');
const legacyStore = legacyTx.objectStore('pending-requests');
const requests = await new Promise((resolve, reject) => {
const request = legacyStore.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
await transactionDone(legacyTx);
if (!requests.length) return;
const currentDb = await openIndexedDB(SYNC_DB);
const currentTx = currentDb.transaction('pending-requests', 'readwrite');
const currentStore = currentTx.objectStore('pending-requests');
for (const request of requests) currentStore.put(request);
await transactionDone(currentTx);
const deleteTx = legacyDb.transaction('pending-requests', 'readwrite');
deleteTx.objectStore('pending-requests').clear();
await transactionDone(deleteTx);
}
// Background sync event — replay queues from both brand generations.
self.addEventListener('sync', (event) => {
if (event.tag === SYNC_TAG || event.tag === LEGACY_SYNC_TAG) {
event.waitUntil(migrateLegacyPendingRequests().then(replayPendingRequests));
}
});
async function replayPendingRequests() {
const db = await openIndexedDB(SYNC_DB);
const tx = db.transaction('pending-requests', 'readwrite');
const store = tx.objectStore('pending-requests');
const allRequests = await new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
for (const reqData of allRequests) {
try {
const headers = new Headers(reqData.headers);
const response = await fetch(reqData.url, {
method: reqData.method,
headers,
body: reqData.method !== 'GET' ? reqData.body : undefined,
});
if (response.ok) store.delete(reqData.timestamp);
} catch (error) {
console.warn('[SW] Failed to replay request:', error);
}
}
await transactionDone(tx);
}
// Push event — show notification (if push notifications are configured)
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
const options = {
body: data.message || '',
icon: '/icon-192.png',
badge: '/icon-192.png',
vibrate: [100, 50, 100],
data: {
url: data.url || '/',
},
};
event.waitUntil(
self.registration.showNotification(data.title || 'SchulOS', options)
);
});
// Notification click — open the app
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url || '/';
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clients) => {
// Focus existing window if available
for (const client of clients) {
if (client.url.includes(url) && 'focus' in client) {
return client.focus();
}
}
// Open new window
return self.clients.openWindow(url);
})
);
});
// Message event — handle messages from the app
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
if (event.data && event.data.type === 'CLEAR_CACHES') {
caches.keys().then((keys) => {
Promise.all(keys.map((key) => caches.delete(key))).then(() => {
console.log('[SW] All caches cleared');
});
});
}
if (event.data && event.data.type === 'CACHE_URLS') {
const urls = event.data.urls || [];
caches.open(STATIC_CACHE).then((cache) => {
cache.addAll(urls).catch((err) => {
console.warn('[SW] Failed to cache URLs:', err);
});
});
}
});