PageSourceSearch

https://comunidad.openautoacademy.com/sw.js

js openautoacademy.com collected 2026-10-02 01:38:50 UTC 9,947 bytes, 224 lines download raw bytes

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.