/* ===================================================================== Service Worker del Panel Clínico (SKEEN) --------------------------------------------------------------------- Sin dependencias ni Workbox. Estrategia: - App shell (HTML, manifest, iconos, JS/CSS con hash): precache en install, versionado por build. - Navegación (HTML): network-first. Un deploy nuevo se ve al primer refresh (nginx sirve index.html con Cache-Control: no-cache) y, si no hay red, se devuelve la última copia del shell o /offline.html. - Assets con hash (/assets/*): cache-first (son inmutables). - Otras GET del mismo origen (logos de marca, svg): stale-while-revalidate. - API (/api/*), peticiones con Authorization y cualquier método distinto de GET: NUNCA se cachean. Se dejan pasar tal cual al navegador. La versión de caché y la lista de assets se inyectan en el build (vite.config.ts); si no se inyectan, el SW sigue siendo funcional. ===================================================================== */ var VERSION = '__BUILD_VERSION__'; // Lista de assets con hash generados por el build (JSON inyectado). var PRECACHE_ASSETS = []; try { PRECACHE_ASSETS = JSON.parse('__PRECACHE_ASSETS__'); } catch (e) { PRECACHE_ASSETS = []; } var PRECACHE = 'skeen-precache-' + VERSION; var RUNTIME = 'skeen-runtime-' + VERSION; var CACHE_PREFIX = 'skeen-'; var PRECACHE_URLS = [ '/', '/index.html', '/offline.html', '/manifest.webmanifest', '/favicon.svg', '/icons/icon.svg', '/icons/icon-32.png', '/icons/icon-192.png', '/icons/icon-512.png', '/icons/icon-192-maskable.png', '/icons/icon-512-maskable.png', '/icons/apple-touch-icon.png', // Brand neutro por defecto: permite pintar el login sin red. '/brand/favicon.svg', '/brand/logo-blanco.svg', '/brand/logo-negro.svg', ].concat(PRECACHE_ASSETS); // -------------------------------------------------------------------- // install / activate // -------------------------------------------------------------------- self.addEventListener('install', function (event) { event.waitUntil( caches.open(PRECACHE).then(function (cache) { // add() individual: un recurso que falle no aborta toda la instalación. return Promise.all( PRECACHE_URLS.map(function (url) { return cache.add(new Request(url, { cache: 'reload' })).catch(function () { /* ignore */ }); }) ); }).then(function () { return self.skipWaiting(); }) ); }); self.addEventListener('activate', function (event) { event.waitUntil( caches.keys().then(function (keys) { return Promise.all( keys.map(function (key) { // Borra cachés de builds anteriores (mismo prefijo, otra versión). if (key.indexOf(CACHE_PREFIX) === 0 && key !== PRECACHE && key !== RUNTIME) { return caches.delete(key); } return undefined; }) ); }).then(function () { return self.clients.claim(); }) ); }); // Permite forzar la activación desde la página si se desea. self.addEventListener('message', function (event) { if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting(); }); // -------------------------------------------------------------------- // Estrategias // -------------------------------------------------------------------- function isSameOrigin(url) { return url.origin === self.location.origin; } function isApiRequest(url) { return url.pathname.indexOf('/api/') === 0; } function isHashedAsset(url) { return isSameOrigin(url) && url.pathname.indexOf('/assets/') === 0; } async function networkFirstNavigation(request) { try { var fresh = await fetch(request); var type = fresh.headers.get('content-type') || ''; if (fresh && fresh.ok && type.indexOf('text/html') !== -1) { var cache = await caches.open(PRECACHE); // Se guarda una única copia del shell bajo /index.html. cache.put('/index.html', fresh.clone()).catch(function () { /* ignore */ }); } return fresh; } catch (e) { var shell = await caches.open(PRECACHE); var cached = (await shell.match('/index.html')) || (await shell.match('/')); if (cached) return cached; var offline = await shell.match('/offline.html'); if (offline) return offline; return new Response('Sin conexión', { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }); } } async function cacheFirst(request, cacheName) { var cache = await caches.open(cacheName); var hit = await cache.match(request); if (hit) return hit; var res = await fetch(request); if (res && res.ok && res.type === 'basic') { cache.put(request, res.clone()).catch(function () { /* ignore */ }); } return res; } async function staleWhileRevalidate(request, cacheName) { var cache = await caches.open(cacheName); var hit = await cache.match(request); var network = fetch(request).then(function (res) { if (res && res.ok && res.type === 'basic') { cache.put(request, res.clone()).catch(function () { /* ignore */ }); } return res; }).catch(function () { return undefined; }); if (hit) return hit; var res = await network; if (res) return res; return new Response('', { status: 504, statusText: 'Sin conexión' }); } // -------------------------------------------------------------------- // fetch // -------------------------------------------------------------------- self.addEventListener('fetch', function (event) { var request = event.request; var url; try { url = new URL(request.url); } catch (e) { return; } // 1) Nunca interceptar lo que no debe cachearse: // - métodos distintos de GET // - peticiones con Authorization (JWT / datos clínicos) // - API del backend (/api/odoo/... y /api/v1/...) // - backend Odoo directo (/web/) if (request.method !== 'GET') return; if (request.headers.get('authorization')) return; if (isApiRequest(url)) return; if (url.pathname.indexOf('/web/') === 0) return; // 2) Navegación HTML: network-first con fallback al shell cacheado. if (request.mode === 'navigate') { event.respondWith(networkFirstNavigation(request)); return; } // 3) Assets con hash: cache-first (inmutables). if (isHashedAsset(url)) { event.respondWith(cacheFirst(request, RUNTIME)); return; } // 4) Otras GET del mismo origen (logos, svg): SWR. if (isSameOrigin(url)) { event.respondWith(staleWhileRevalidate(request, RUNTIME)); return; } // 5) Cross-origin (p. ej. Google Fonts): sin interceptar. });