/* * Service worker of the portal web app. Same address as the old one, so every installed app moves to it. * * Safe by design: * - only page navigations of the portal itself are handled: network first; the offline page (or a page saved for reading) * answers only when there is no network; pages are never served from a cache while the network works * - POST, Livewire, panels, API, files, CDN: never touched (no respondWith at all) * - the app turned off (or the platform kill switch): the worker unregisters itself and drops its caches * Served with Cache-Control: no-cache (nginx), so browsers pick up a new version on the next visit. */ const VERSION = '2026.10.18-1'; const OFFLINE_CACHE = 'qc-offline-' + VERSION; const READ_CACHE = 'qc-read-v1'; const READ_LIMIT = 30; const READ_FRESH = 6 * 3600 * 1000; const OFFLINE_URL = '/offline'; const SESSION_KEY = '/__qc-session'; const SKIP = /^\/(admin|dashboard|livewire|filament|api|pwa|studio|horizon|telescope|_debugbar|storage|sanctum|broadcasting|u\/sso|authentication|logout|financial|payment|cart|checkout)(\/|$)/; self.addEventListener('install', event => { self.skipWaiting(); event.waitUntil( caches.open(OFFLINE_CACHE) .then(cache => cache.add(new Request(OFFLINE_URL, { cache: 'reload', credentials: 'same-origin' }))) .catch(() => null) ); }); self.addEventListener('activate', event => { event.waitUntil((async () => { const keys = await caches.keys(); await Promise.all(keys.filter(key => ours(key) && key !== OFFLINE_CACHE && key !== READ_CACHE).map(key => caches.delete(key))); await self.clients.claim(); try { const response = await fetch('/pwa/version', { cache: 'no-store', credentials: 'same-origin' }); const state = response.ok ? await response.json() : null; if (state && state.active === false) return retire(); } catch (error) { /* offline: keep working */ } fetch('/pwa/event?e=sw&v=' + encodeURIComponent(VERSION), { cache: 'no-store', credentials: 'omit' }).catch(() => null); })()); }); self.addEventListener('fetch', event => { const request = event.request; if (request.mode !== 'navigate' || request.method !== 'GET') return; const url = new URL(request.url); if (url.origin !== self.location.origin || SKIP.test(url.pathname)) return; event.respondWith((async () => { try { return await fetch(request); } catch (error) { const saved = await caches.open(READ_CACHE).then(cache => cache.match(url.href, { ignoreSearch: true })).catch(() => null); if (saved) return saved; const offline = await caches.match(OFFLINE_URL); return offline || new Response('
Offline
', { status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8' } }); } })()); }); self.addEventListener('message', event => { const data = event.data || {}; if (data.type === 'SKIP_WAITING') self.skipWaiting(); if (data.type === 'SESSION') event.waitUntil(session(String(data.user || 0))); if (data.type === 'READ_LATER' && data.url) event.waitUntil(readLater(data.url)); if (data.type === 'CLEAR_BADGE' && self.navigator.clearAppBadge) self.navigator.clearAppBadge().catch(() => null); if (data.type === 'RETIRE') event.waitUntil(retire()); }); /* Web push (phase 2): title, text, link, icon and the app badge count come from the portal */ self.addEventListener('push', event => { let data = {}; try { data = event.data ? event.data.json() : {}; } catch (error) { data = { body: event.data ? event.data.text() : '' }; } const work = [self.registration.showNotification(data.title || '•', { body: data.body || '', icon: data.icon, badge: data.icon, tag: data.tag, renotify: !!data.tag, dir: 'auto', data: { url: data.url || '/' }, })]; if (typeof data.badge === 'number' && self.navigator.setAppBadge) work.push(self.navigator.setAppBadge(data.badge).catch(() => null)); event.waitUntil(Promise.all(work)); }); self.addEventListener('notificationclick', event => { event.notification.close(); const target = new URL((event.notification.data && event.notification.data.url) || '/', self.location.origin).href; event.waitUntil((async () => { fetch('/pwa/event?e=push_clicked', { cache: 'no-store', credentials: 'omit' }).catch(() => null); const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); const same = windows.find(client => client.url === target); if (same) return same.focus(); const any = windows.find(client => new URL(client.url).origin === self.location.origin && 'navigate' in client); if (any) { await any.focus(); return any.navigate(target); } return self.clients.openWindow(target); })()); }); /* Offline reading (phase 3): pages the visitor read are saved (at most READ_LIMIT); another user on the device clears them */ async function readLater(href) { const url = new URL(href, self.location.origin); if (url.origin !== self.location.origin || SKIP.test(url.pathname)) return; const cache = await caches.open(READ_CACHE); const old = await cache.match(url.href, { ignoreSearch: true }); if (old && Date.now() - Number(old.headers.get('x-qc-saved') || 0) < READ_FRESH) return; const response = await fetch(url.origin + url.pathname, { credentials: 'same-origin', cache: 'no-store' }).catch(() => null); if (!response || !response.ok || response.type !== 'basic' || !(response.headers.get('content-type') || '').includes('text/html')) return; // The body is stored decoded: the transfer headers no longer apply to it const headers = new Headers(response.headers); ['content-encoding', 'content-length', 'set-cookie'].forEach(name => headers.delete(name)); headers.set('x-qc-saved', String(Date.now())); await cache.put(url.origin + url.pathname, new Response(await response.blob(), { status: 200, headers })); const keys = (await cache.keys()).filter(request => new URL(request.url).pathname !== SESSION_KEY); for (let i = 0; i < keys.length - READ_LIMIT; i++) await cache.delete(keys[i]); } async function session(user) { const cache = await caches.open(READ_CACHE); const stored = await cache.match(SESSION_KEY); const previous = stored ? await stored.text() : null; if (previous === user) return; if (previous !== null) await caches.delete(READ_CACHE); await (await caches.open(READ_CACHE)).put(SESSION_KEY, new Response(user)); } /* Caches of this worker and of the old one (pwa-v...); other workers' caches on the portal are never touched */ function ours(key) { return key.indexOf('qc-') === 0 || key.indexOf('pwa-v') === 0; } async function retire() { const keys = await caches.keys(); await Promise.all(keys.filter(ours).map(key => caches.delete(key))); await self.registration.unregister(); }