1/* Openauto IA â Service Worker (mÃnimo y CONSERVADOR). 2 * 3 * Para qué: que la PWA sea "instalable" (Chrome/Android y PWABuilder lo exigen 4 * para empaquetar la app TWA) y dar una pantalla de "sin conexión" decente. 5 * 6 * Qué NO hace (a propósito): no cachea la raÃz ni ninguna navegación que no 7 * esté en RUTAS_OFFLINE, ni /api/* fuera de las dos de API_OFFLINE. Asà jamás 8 * sirve un HTML con la sesión de otro. 9 * 10 * Reglas de fetch: 11 * - método != GET (login, mensajesâ¦) â red directa (no intercepta) 12 * - /api/auth/me, /api/vehiculo/mis-mapas â RED PRIMERO, copia de respaldo 13 * - resto de /api/* y /_next/data/* â red directa (la app viva) 14 * - navegación a /escaner â RED PRIMERO, copia de respaldo 15 * - resto de navegaciones â RED PRIMERO; si offline â /offline.html 16 * - /_next/static/* e iconos â CACHE-FIRST (inmutables) 17 * - resto â red normal 18 * 19 * Subir el número de CACHE invalida el caché viejo en 'activate'. 20 */ 21const CACHE = 'openauto-v2'; 22const OFFLINE_URL = '/offline.html'; 23const PRECACHE = [OFFLINE_URL, '/openauto-icon-192.png', '/openauto-icon-512.png']; 24 25// ââ EL TALLER NO TIENE SEÃAL ââââââââââââââââââââââââââââââââââââââââââââââââ 26// El multimapa cambia el mapa hablándole al ELM327 del auto, NO a nosotros, y 27// sus eventos ya se encolan en localStorage cuando no hay red 28// (CambioMapas.jsx, 'oa.mapas.pendientes'). O sea que la función YA tolera 29// estar sin internet: lo único que la mataba es que la PÃGINA no podÃa cargar, 30// porque la app carga el sitio en vivo y acá abajo las navegaciones nunca se 31// guardaban. Pedido de Andrés desde un taller (1-sep-2026). 32// 33// Se guarda copia SOLO de estas rutas. La raÃz NO entra a propósito: su HTML sà 34// depende de quién sos (a dónde te manda /inicio), y ese era el motivo original 35// para no cachear navegaciones. El documento de /escaner, en cambio, es una 36// cáscara: `app/escaner/page.jsx` solo renderiza <EscanerOBD/> y el gate de 37// sesión vive DENTRO del componente cliente. No lleva estado de nadie. 38const RUTAS_OFFLINE = ['/escaner']; 39 40// Y estas dos respuestas, porque sin ellas la pantalla carga VACÃA: al fallar 41// /api/auth/me el componente cae en su catch y pone puedeMapas = false, asà que 42// el multimapa no se dibuja aunque el documento haya cargado. 43// 44// â ï¸ ESTO GUARDA PERMISOS EN EL DISPOSITIVO, y hay que decirlo: si a alguien le 45// quitan el permiso, su copia sigue diciendo que lo tiene hasta la próxima vez 46// que entre CON red. Se acota con TOPE_EDAD_API. No es un agujero de 47// autorización del servidor âoffline no hay servidor al que pedirle nada, y el 48// cambio de mapa viaja al auto por BLE/WiFiâ pero es una decisión, no un 49// descuido. 50const API_OFFLINE = ['/api/auth/me', '/api/vehiculo/mis-mapas']; 51const TOPE_EDAD_API = 7 * 24 * 60 * 60 * 1000; // 7 dÃas 52const CABECERA_FECHA = 'x-oa-cacheado'; 53 54// Guarda una copia con la fecha puesta por nosotros. `Date` no sirve: puede 55// venir del servidor o faltar, y necesitamos saber cuándo lo guardó ESTE 56// teléfono. 57async function guardarCopia(request, response) { 58 try { 59 const cuerpo = await response.clone().blob(); 60 const headers = new Headers(response.headers); 61 headers.set(CABECERA_FECHA, String(Date.now())); 62 const copia = new Response(cuerpo, { status: response.status, statusText: response.statusText, headers }); 63 const c = await caches.open(CACHE); 64 await c.put(request, copia); 65 } catch { /* sin espacio o respuesta no clonable: seguimos sin copia */ } 66} 67 68self.addEventListener('install', (event) => { 69 event.waitUntil( 70 caches 71 .open(CACHE) 72 .then((c) => c.addAll(PRECACHE)) 73 .then(() => self.skipWaiting()), 74 ); 75}); 76 77self.addEventListener('activate', (event) => { 78 event.waitUntil( 79 caches 80 .keys() 81 .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) 82 .then(() => self.clients.claim()), 83 ); 84}); 85 86self.addEventListener('fetch', (event) => { 87 const { request } = event; 88 // Solo GET: POST/PUT (login, envÃo de mensajes, subidas) van directo a la red. 89 if (request.method !== 'GET') return; 90 91 const url = new URL(request.url); 92 // Terceros (Google Fonts, Vercel Blob, etc.) â red, sin tocar. 93 if (url.origin !== self.location.origin) return; 94 // Las dos que el multimapa necesita para dibujarse: red primero y, si no hay, 95 // la última copia buena. Van ANTES del corte general de /api/* de abajo. 96 if (API_OFFLINE.includes(url.pathname)) { 97 event.respondWith( 98 fetch(request) 99 .then((res) => { 100 // Solo se guarda una respuesta BUENA. Un 401 cacheado dejarÃa al 101 // alumno fuera de su propio multimapa hasta que expire. 102 if (res.ok) guardarCopia(request, res); 103 return res; 104 }) 105 .catch(async () => { 106 const hit = await caches.match(request); 107 if (!hit) throw new Error('sin copia'); 108 const t = Number(hit.headers.get(CABECERA_FECHA) || 0); 109 if (t && Date.now() - t > TOPE_EDAD_API) throw new Error('copia vencida'); 110 return hit; 111 }), 112 ); 113 return; 114 } 115 116 // El resto de la app viva nunca se cachea. 117 if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/_next/data/')) return; 118 119 // Navegaciones: RED PRIMERO siempre. De las rutas de RUTAS_OFFLINE se guarda 120 // además una copia, y es esa copia la que se sirve cuando no hay red. Del 121 // resto se sigue sin guardar nada âincluida la raÃzâ asà que nunca se sirve un 122 // shell con la sesión de otro. 123 if (request.mode === 'navigate') { 124 const guardable = RUTAS_OFFLINE.includes(url.pathname); 125 event.respondWith( 126 fetch(request) 127 .then((res) => { 128 if (guardable && res.ok) guardarCopia(request, res); 129 return res; 130 }) 131 .catch(async () => { 132 // `ignoreSearch`: se entra con ?ir=mapas, ?ir=dyno⦠y la copia se 133 // guardó bajo la URL que tocó ese dÃa. Sin esto, entrar por una 134 // variante distinta no encontrarÃa nada y caerÃa en la pantalla de 135 // sin conexión teniendo la página guardada. 136 if (guardable) { 137 const hit = await caches.match(request, { ignoreSearch: true }); 138 if (hit) return hit; 139 } 140 return caches.match(OFFLINE_URL); 141 }), 142 ); 143 return; 144 } 145 146 // Estáticos inmutables de Next + iconos precacheados: cache-first. 147 if (url.pathname.startsWith('/_next/static/') || PRECACHE.includes(url.pathname)) { 148 event.respondWith( 149 caches.match(request).then( 150 (hit) => 151 hit || 152 fetch(request).then((res) => { 153 const copy = res.clone(); 154 caches.open(CACHE).then((c) => c.put(request, copy)); 155 return res; 156 }), 157 ), 158 ); 159 } 160 // Resto: sin respondWith â la red maneja la petición normalmente. 161}); 162 163/* ââ Web Push (notificaciones de ventas al admin) âââââââââââââââââââââââââ 164 * El backend (lib/push.js) manda un payload JSON {title, body, url} cuando se 165 * confirma una venta (o al apretar "Probar" en /admin). Solo los admins pueden 166 * suscribirse â el guard vive en /api/admin/push/*; el SW solo pinta lo que 167 * llega. Si el payload no parsea, mostramos un aviso genérico igual (en iOS, 168 * NO mostrar notificación tras un push puede costar la suscripción). */ 169self.addEventListener('push', (event) => { 170 let data = {}; 171 try { 172 data = event.data ? event.data.json() : {}; 173 } catch { 174 /* payload no-JSON â aviso genérico */ 175 } 176 const title = data.title || 'Openauto Academy'; 177 event.waitUntil( 178 Promise.all([ 179 self.registration.showNotification(title, { 180 body: data.body || '', 181 icon: '/openauto-icon-192.png', 182 // El badge se pinta como MÃSCARA ALFA monocroma en la barra de estado: 183 // exige un PNG con transparencia. Con el maskable (opaco de borde a 184 // borde) salÃa un cuadrado sólido. Este ya va en PRECACHE. 185 badge: '/openauto-icon-192.png', 186 // Default '/aula' y no '/admin': desde que los ALUMNOS reciben push, 187 // un aviso sin url los mandarÃa a una pantalla donde no tienen acceso. 188 // Los emisores siempre mandan url; esto es solo la red de seguridad. 189 data: { url: data.url || '/aula' }, 190 }), 191 // Si es una VENTA y hay alguna ventana/PWA abierta, le avisamos para que 192 // suene el "cha-ching" de caja registradora (Web Audio en el cliente). En 193 // segundo plano no hay cliente que reciba esto â suena el tono del sistema. 194 data.kind === 'sale' 195 ? self.clients 196 .matchAll({ type: 'window', includeUncontrolled: true }) 197 .then((wins) => wins.forEach((w) => w.postMessage({ type: 'openauto-sale' }))) 198 .catch(() => {}) 199 : Promise.resolve(), 200 ]), 201 ); 202}); 203 204/* Tap en la notificación â abrir la URL del aviso (default /admin). Si ya hay 205 * una ventana/PWA abierta de este origen, la enfocamos y navegamos ahà en vez 206 * de abrir otra. */ 207self.addEventListener('notificationclick', (event) => { 208 event.notification.close(); 209 const url = event.notification.data?.url || '/admin'; 210 event.waitUntil( 211 self.clients 212 .matchAll({ type: 'window', includeUncontrolled: true }) 213 .then((wins) => { 214 const win = wins[0]; 215 if (win) { 216 // focus primero (navigate puede fallar en clients no controlados y 217 // aun asà queremos traer la app al frente). 218 return win.focus().then(() => (win.navigate ? win.navigate(url).catch(() => {}) : null)); 219 } 220 return self.clients.openWindow(url); 221 }) 222 .catch(() => self.clients.openWindow(url)), 223 ); 224});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.