const CACHE_NAME = 'erp-shell-v3'; const API_CACHE_NAME = 'erp-api-v1'; const WRITE_QUEUE_DB_NAME = 'erp-write-queue-db'; const WRITE_QUEUE_STORE = 'requests'; const WRITE_QUEUE_SYNC_TAG = 'erp-write-queue-sync'; const OFFLINE_URL = '/offline.html'; const PRECACHE_URLS = ['/', '/manifest.webmanifest', '/pwa-icon.svg', OFFLINE_URL]; const API_CACHE_BLOCKLIST = [ '/api/token/', '/api/token/refresh/', '/api/fleet/pusher-auth/', ]; function isCacheableApiGet(request, url) { if (request.method !== 'GET') return false; if (url.origin !== self.location.origin) return false; if (!url.pathname.startsWith('/api/')) return false; return !API_CACHE_BLOCKLIST.some((blocked) => url.pathname.startsWith(blocked)); } function isQueueableApiWrite(request, url) { if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) return false; if (url.origin !== self.location.origin) return false; if (!url.pathname.startsWith('/api/')) return false; if (API_CACHE_BLOCKLIST.some((blocked) => url.pathname.startsWith(blocked))) return false; const contentType = (request.headers.get('content-type') || '').toLowerCase(); if (contentType.includes('multipart/form-data')) return false; return true; } function openWriteQueueDb() { return new Promise((resolve, reject) => { const request = indexedDB.open(WRITE_QUEUE_DB_NAME, 1); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(WRITE_QUEUE_STORE)) { db.createObjectStore(WRITE_QUEUE_STORE, { keyPath: 'id', autoIncrement: true }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } function serializeHeaders(headers) { const serialized = {}; headers.forEach((value, key) => { serialized[key] = value; }); return serialized; } function getAllQueuedRequests(db) { return new Promise((resolve, reject) => { const tx = db.transaction(WRITE_QUEUE_STORE, 'readonly'); const store = tx.objectStore(WRITE_QUEUE_STORE); const request = store.getAll(); request.onsuccess = () => resolve(request.result || []); request.onerror = () => reject(request.error); }); } function addQueuedRequest(db, queuedRequest) { return new Promise((resolve, reject) => { const tx = db.transaction(WRITE_QUEUE_STORE, 'readwrite'); const store = tx.objectStore(WRITE_QUEUE_STORE); const request = store.add(queuedRequest); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } function deleteQueuedRequest(db, id) { return new Promise((resolve, reject) => { const tx = db.transaction(WRITE_QUEUE_STORE, 'readwrite'); const store = tx.objectStore(WRITE_QUEUE_STORE); const request = store.delete(id); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } async function queueWriteRequest(request) { const body = request.method === 'DELETE' ? null : await request.clone().text(); const db = await openWriteQueueDb(); const payload = { url: request.url, method: request.method, headers: serializeHeaders(request.headers), body, createdAt: Date.now(), }; await addQueuedRequest(db, payload); } function createQueuedResponse() { return new Response( JSON.stringify({ queued: true, offline: true, detail: 'Zahtjev je spremljen i bit će sinkroniziran kad veza bude dostupna.', }), { status: 202, headers: { 'Content-Type': 'application/json' }, } ); } async function scheduleWriteQueueSync() { if (!self.registration || !self.registration.sync) return; await self.registration.sync.register(WRITE_QUEUE_SYNC_TAG); } async function replayQueuedRequests() { const db = await openWriteQueueDb(); const queuedRequests = await getAllQueuedRequests(db); for (const queued of queuedRequests) { const headers = new Headers(queued.headers || {}); const init = { method: queued.method, headers, credentials: 'same-origin', }; if (queued.body != null && queued.method !== 'DELETE') { init.body = queued.body; } try { const response = await fetch(queued.url, init); if (response.ok) { await deleteQueuedRequest(db, queued.id); continue; } if (response.status >= 500) { throw new Error(`Replay failed with status ${response.status}`); } await deleteQueuedRequest(db, queued.id); } catch (error) { throw error; } } } self.addEventListener('install', (event) => { self.addEventListener('install', (event) => { async function precacheShell() { const cache = await caches.open(CACHE_NAME); await Promise.all( PRECACHE_URLS.map(async (url) => { try { const response = await fetch(url, { cache: 'reload' }); if (!response || !response.ok) { console.warn('Skipping precache for non-OK response:', url, response && response.status); return; } await cache.put(url, response); } catch (error) { console.warn('Skipping precache for failed request:', url, error); } }) ); } self.addEventListener('install', (event) => { event.waitUntil(precacheShell()); self.skipWaiting(); }); self.addEventListener('activate', (event) => { event.waitUntil( Promise.all([ caches.keys().then((cacheNames) => Promise.all( cacheNames .filter((name) => name !== CACHE_NAME && name !== API_CACHE_NAME) .map((name) => caches.delete(name)) ) ), replayQueuedRequests().catch((error) => { console.error('Initial write-queue replay failed.', error); }), ]) ); self.clients.claim(); }); self.addEventListener('fetch', (event) => { const { request } = event; const url = new URL(request.url); if (isQueueableApiWrite(request, url)) { event.respondWith( fetch(request.clone()).catch(async () => { try { await queueWriteRequest(request); } catch (error) { console.error('Failed to queue offline write request.', error); return new Response( JSON.stringify({ queued: false, offline: true, detail: 'Zahtjev nije moguće spremiti offline.', }), { status: 503, headers: { 'Content-Type': 'application/json' }, } ); } try { await scheduleWriteQueueSync(); } catch (error) { console.warn('Background sync registration failed.', error); } return createQueuedResponse(); }) ); return; } if (request.method !== 'GET') return; if (isCacheableApiGet(request, url)) { event.respondWith( caches.open(API_CACHE_NAME).then(async (cache) => { const cached = await cache.match(request); const networkFetch = fetch(request) .then((response) => { if (response && response.ok) { cache.put(request, response.clone()); } return response; }) .catch(() => cached); return cached || networkFetch; }) ); return; } if (url.origin !== self.location.origin) return; if (request.mode === 'navigate') { event.respondWith( fetch(request) .then((response) => { const copy = response.clone(); caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); return response; }) .catch(async () => { const cached = await caches.match(request); return cached || caches.match(OFFLINE_URL); }) ); return; } if (['style', 'script', 'worker', 'font', 'image'].includes(request.destination)) { event.respondWith( caches.match(request).then((cached) => { const networkFetch = fetch(request) .then((response) => { const copy = response.clone(); caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); return response; }) .catch(() => cached); return cached || networkFetch; }) ); } }); self.addEventListener('sync', (event) => { if (event.tag !== WRITE_QUEUE_SYNC_TAG) return; event.waitUntil(replayQueuedRequests()); }); self.addEventListener('message', (event) => { if (event.data !== 'FLUSH_WRITE_QUEUE') return; event.waitUntil(replayQueuedRequests()); });