La lección anterior dejó una debilidad señalada: Iván baja al almacén de serigrafía, donde no llega el wifi, y Nómada Tareas ni siquiera abre. El tablero está guardado en localStorage, pero da igual, porque el index.html, el CSS y los quince módulos ES viven en el servidor y sin red el navegador no tiene nada que cargar. Cruzar esa frontera exige algo que no habías visto: un script que se ejecuta fuera de la página, que sigue vivo cuando la pestaña está cerrada, y que se sitúa entre tu aplicación y la red para responder a las peticiones desde una caché propia. Ese script es el service worker, y es la pieza que convierte una web en una aplicación web progresiva: instalable en el dispositivo, capaz de arrancar sin conexión y de comportarse como una aplicación nativa. En esta lección entenderás su ciclo de vida, dominarás la Cache API y sus estrategias, harás que Nómada Tareas funcione offline con cola de cambios pendientes, escribirás el manifiesto, resolverás el problema de las actualizaciones y aprenderás a depurarlo sin volverte loco.
Contenido
- Qué es una PWA
- Los tres requisitos
- El service worker: un proxy que vive fuera de la página
- Registro y ámbito (scope)
- El ciclo de vida:
install,waiting,activate,fetch - Por qué no tiene DOM ni
localStorage - La Cache API
- Precachear el app shell en
install - Limpiar versiones antiguas en
activate - Las estrategias de red
- Interceptar con
fetch: el enrutador del service worker - Funcionar sin conexión: respaldo y cola de cambios
- El manifiesto y la instalación
- Actualizar sin dejar a nadie atrás
- Depurar en DevTools
- Notificaciones push, brevemente
- Nómada Tareas:
sw.jscompleto - Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Qué es una PWA
Una aplicación web progresiva no es una tecnología: es un conjunto de capacidades que, sumadas, hacen que una web se comporte como una aplicación instalada. Sigue siendo HTML, CSS y JavaScript servidos por una URL.
| Web normal | PWA | App nativa | |
|---|---|---|---|
| Instalación | No | Sí, desde el navegador | Tienda de aplicaciones |
| Icono en el escritorio | No | Sí | Sí |
| Funciona sin conexión | No | Sí, si la programas | Sí |
| Ventana propia, sin barra del navegador | No | Sí (display: standalone) |
Sí |
| Notificaciones push | Limitado | Sí | Sí |
| Actualización | Instantánea, al recargar | Instantánea (controlada por ti) | Revisión de la tienda |
| Distribución | Una URL | Una URL | Tienda, revisión, comisión |
| Acceso al hardware | Limitado | Limitado (mejorando) | Completo |
| Tamaño de descarga | Lo que pesa la web | Lo que pesa la web | Decenas de MB |
El adjetivo progresiva es la clave y explica el enfoque: la aplicación funciona en cualquier navegador, y en los que soportan más capacidades añade funciones. Nadie se queda fuera. Es la misma mejora progresiva que aplicaste en 07-01 al degradar a un almacén en memoria si localStorage fallaba.
Para Taller Nómada la propuesta es concreta: Marta pone Nómada Tareas en la pantalla de la sala como aplicación instalada, e Iván la abre en el móvil en el almacén sin cobertura, ve el tablero, marca una tarea como hecha, y ese cambio se envía solo cuando vuelve a subir.
- Los tres requisitos
| Requisito | Qué es | Por qué |
|---|---|---|
| HTTPS | La página debe servirse por conexión segura | Un service worker intercepta todas las peticiones; sobre HTTP, un atacante en la red podría inyectar uno malicioso y permanente |
| Manifiesto | Un manifest.json enlazado desde el HTML |
Da nombre, iconos y modo de presentación a la aplicación instalada |
| Service worker | Un script registrado con al menos un manejador fetch |
Es lo que permite funcionar sin conexión |
Una excepción práctica: http://localhost está permitido para desarrollo. Es lo que hace posible probar todo esto en tu máquina sin certificados.
- El service worker: un proxy que vive fuera de la página
Esta es la idea central, y hay que entenderla antes de escribir código. Un service worker no es un script de tu página. Es un worker independiente que el navegador ejecuta en su propio hilo, con su propio contexto global, y que se sitúa entre todas las páginas de tu origen y la red.
flowchart LR
subgraph Antes["Sin service worker"]
P1["Página"] -->|fetch| R1["Red"]
end
subgraph Despues["Con service worker"]
P2["Página"] -->|fetch| SW["Service Worker<br/>(hilo propio)"]
SW -->|"¿está en caché?"| C[("Cache API")]
SW -->|"si no, o según estrategia"| R2["Red"]
C -.->|"respuesta"| SW
R2 -.->|"respuesta"| SW
SW -.->|"respuesta"| P2
end
Cinco propiedades que definen lo que es y lo que no:
- Vive fuera de la página y sobrevive a que se cierre la pestaña. El navegador lo arranca cuando lo necesita y lo detiene cuando no.
- Intercepta todas las peticiones de las páginas de su ámbito: HTML, CSS, JS, imágenes, llamadas a la API. Todas pasan por su evento
fetch. - No tiene acceso al DOM. No existe
document, niwindow, ni tus elementos. - Es asíncrono por completo. Nada de APIs síncronas: por eso no puede usar
localStorage. - Puede detenerse en cualquier momento. No guardes estado en variables globales del worker: al arrancar de nuevo, se habrán perdido.
La consecuencia de la segunda propiedad es la que exige respeto: un service worker mal escrito puede romper tu sitio para todos los visitantes, y como se guarda en el dispositivo, sigue roto aunque arregles el servidor. Es una herramienta potente y persistente. De ahí la exigencia de HTTPS.
- Registro y ámbito (scope)
El registro se hace desde la página, con un fetch implícito al fichero del worker:
// js/app.js — al final, cuando lo esencial ya funciona
if ('serviceWorker' in navigator) { // ← mejora progresiva
window.addEventListener('load', async () => { // no compitas con la carga inicial
try {
const registro = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
console.log('[nomada] Service worker registrado. Ámbito:', registro.scope);
} catch (error) {
console.error('[nomada] Fallo al registrar el service worker:', error);
}
});
}El ámbito decide qué páginas controla, y sigue una regla estricta: un service worker solo puede controlar URLs que estén en su carpeta o por debajo.
| Ubicación del fichero | Ámbito por defecto | Controla |
|---|---|---|
/sw.js |
/ |
Todo el sitio |
/js/sw.js |
/js/ |
Solo /js/… — casi nunca lo que quieres |
/app/sw.js |
/app/ |
Solo /app/… |
Por eso sw.js va en la raíz del sitio, no en js/ junto a los demás módulos. Es la excepción a la organización de carpetas del proyecto, y la causa número uno de «lo registré y no intercepta nada».
nomada-tareas/ index.html manifest.json ← nuevo sw.js ← nuevo, EN LA RAÍZ (ámbito '/') offline.html ← nuevo, página de respaldo css/estilos.css js/app.js js/…
Un servidor puede ampliar el ámbito de un worker que esté en un subdirectorio mediante la cabecera
Service-Worker-Allowed, pero es una complicación innecesaria: pon el fichero en la raíz.
Y una advertencia de manual: register() devuelve una promesa que se resuelve cuando el registro se ha aceptado, no cuando el worker está activo y controlando la página. La primera visita de un usuario no está controlada por el service worker salvo que lo fuerces (apartado 14). Eso confunde muchísimo al probar.
- El ciclo de vida:
install, waiting, activate, fetch
install, waiting, activate, fetchEl ciclo de vida es lo que más cuesta de los service workers, y entenderlo evita el 80 % de los problemas.
stateDiagram-v2
[*] --> Descargado: register() · el navegador descarga sw.js
Descargado --> Instalando: evento install
Instalando --> Instalado: waitUntil() resuelto (precaché lista)
Instalando --> Fallido: waitUntil() rechazado
Instalado --> Esperando: ya hay OTRO sw controlando páginas
Instalado --> Activando: no hay ninguno (primera vez)
Esperando --> Activando: se cierran todas las pestañas<br/>o skipWaiting()
Activando --> Activo: evento activate terminado
Activo --> Activo: evento fetch (por cada petición)
Fallido --> [*]
| Fase | Evento | Qué hacer en ella |
|---|---|---|
| Instalación | install |
Precachear el app shell. Se ejecuta una sola vez por versión del worker |
| Espera | — | El worker nuevo espera a que el viejo deje de controlar páginas |
| Activación | activate |
Limpiar cachés de versiones antiguas. Momento seguro: ya no hay otro worker |
| Funcionamiento | fetch |
Interceptar y responder a cada petición |
La fase de espera es la que desconcierta. Si hay un service worker activo controlando pestañas abiertas, el nuevo se queda en waiting y no toma el control, aunque recargues con F5. Solo entra cuando todas las pestañas del sitio se cierran. Es una protección deliberada: evita que a mitad de sesión cambien las reglas bajo los pies del usuario, con una página vieja pidiendo recursos que la caché nueva ya borró.
El navegador decide que un worker es "nuevo" comparando el fichero byte a byte con el que tiene guardado. Un solo carácter distinto —típicamente el número de versión de la caché— basta para desencadenar el ciclo completo.
Y una pieza esencial: event.waitUntil(). Como el navegador puede detener el worker en cuanto el manejador retorna, hay que decirle explícitamente que espere a una promesa.
self.addEventListener('install', (evento) => {
evento.waitUntil( // ← sin esto, el worker puede morir a mitad
caches.open('nomada-v1').then((cache) => cache.addAll(RECURSOS))
);
});Sin waitUntil, la instalación se daría por terminada antes de que la caché se llenara, y tendrías un service worker "instalado" con una caché a medias.
- Por qué no tiene DOM ni
localStorage
localStorageDentro de sw.js, el objeto global no es window, es self (un ServiceWorkerGlobalScope). Lo que hay y lo que no:
| Disponible | No disponible |
|---|---|
fetch, caches, indexedDB |
document, window, DOM |
postMessage, clients |
localStorage y sessionStorage |
setTimeout, Promise, async/await |
alert, confirm, prompt |
importScripts() y módulos ES (con type: 'module') |
Acceso directo a la interfaz |
Las dos ausencias importantes tienen motivos distintos:
- No hay DOM porque el worker no pertenece a ninguna página: puede haber cero, una o cinco pestañas abiertas, o ninguna. Para hablar con las páginas se usa
postMessage, y para cambiar la interfaz, la página escucha y actúa. - No hay
localStorageporque es síncrono, y en un worker que atiende peticiones de red eso sería un desastre de rendimiento. Es la misma advertencia de 07-01 llevada a su conclusión: las alternativas son la Cache API (para respuestas HTTP) e IndexedDB (para datos).
Esto tiene una consecuencia práctica para Nómada Tareas: la copia del tablero que guardaste en localStorage no es accesible desde el service worker. Si quieres que el worker maneje una cola de cambios pendientes, esa cola tiene que vivir en IndexedDB.
Comunicación entre página y worker, en ambos sentidos:
// Desde la página, hacia el worker
navigator.serviceWorker.controller?.postMessage({ tipo: 'limpiar-cache-api' });
// Desde la página, escuchando al worker
navigator.serviceWorker.addEventListener('message', (evento) => {
if (evento.data.tipo === 'sincronizado') {
vista.avisar(`${evento.data.cuantos} cambios enviados al servidor.`);
}
});// Dentro de sw.js
self.addEventListener('message', (evento) => {
if (evento.data?.tipo === 'saltar-espera') self.skipWaiting();
});
/** Avisa a TODAS las pestañas controladas. */
async function avisarAClientes(mensaje) {
const clientes = await self.clients.matchAll({ includeUncontrolled: true });
for (const cliente of clientes) cliente.postMessage(mensaje);
}
- La Cache API
caches es un almacén de pares petición/respuesta HTTP. No guarda datos como localStorage: guarda objetos Response completos, con sus cabeceras y su estado.
// Abrir (o crear) una caché con nombre
const cache = await caches.open('nomada-shell-v1');
// Guardar: descarga y almacena
await cache.add('/css/estilos.css');
await cache.addAll(['/index.html', '/js/app.js', '/css/estilos.css']);
// Guardar una respuesta que ya tienes
await cache.put('/api/tareas', respuesta.clone()); // ← clone(), el cuerpo se lee una vez (07-02)
// Buscar
const guardada = await cache.match('/css/estilos.css');
const enCualquiera = await caches.match('/css/estilos.css'); // busca en TODAS las cachés
// Gestionar
await cache.delete('/js/viejo.js');
const nombres = await caches.keys(); // ['nomada-shell-v1', 'nomada-datos-v1']
await caches.delete('nomada-shell-v0');| Método | Sobre | Qué hace |
|---|---|---|
caches.open(nombre) |
caches |
Abre o crea una caché con nombre |
caches.match(peticion) |
caches |
Busca en todas las cachés |
caches.keys() |
caches |
Lista los nombres |
caches.delete(nombre) |
caches |
Borra una caché entera |
cache.add(url) |
una caché | Descarga y guarda |
cache.addAll([urls]) |
una caché | Igual, en lote. Si una falla, fallan todas |
cache.put(peticion, respuesta) |
una caché | Guarda una respuesta que ya tienes |
cache.match(peticion) |
una caché | Busca en esa caché |
cache.delete(peticion) |
una caché | Borra una entrada |
Cuatro detalles que se aprenden a base de tropezar:
addAlles atómico. Si una sola URL da 404, la promesa rechaza y no se guarda ninguna. Es útil (una precaché a medias es peor que ninguna) y desconcertante: una ruta mal escrita rompe toda la instalación.putno comprueba el estado. Guardará encantada una respuesta 404 o 500. Compruebarespuesta.okantes.- El cuerpo se consume al leerlo, igual que en 07-02. Si vas a devolver la respuesta y guardarla, clona.
- La caché es del origen, con la misma frontera que
localStorage, y comparte cuota con IndexedDB. Cachear vídeos la llena rápido.
- Precachear el app shell en
install
installEl app shell es el conjunto mínimo de recursos que la aplicación necesita para pintar su estructura: el HTML, el CSS, los módulos JavaScript, la fuente, los iconos. Los datos no forman parte del shell; se piden aparte.
// sw.js
const VERSION = 'v3'; // ← súbela en cada despliegue
const CACHE_SHELL = `nomada-shell-${VERSION}`;
const RECURSOS_SHELL = [
'/', // importante: la raíz, además del index.html
'/index.html',
'/offline.html',
'/manifest.json',
'/css/estilos.css',
'/js/app.js',
'/js/modelo/tarea.js',
'/js/modelo/tablero.js',
'/js/modelo/errores.js',
'/js/datos/backlog.js',
'/js/datos/repositorio-local.js',
'/js/datos/api-tareas.js',
'/js/datos/http.js',
'/js/datos/tiempo-real.js',
'/js/util/fechas.js',
'/js/util/formato.js',
'/js/util/tiempo.js',
'/js/vista/dom.js',
'/js/vista/tarjeta.js',
'/js/vista/pintar.js',
'/js/vista/tablero-vista.js',
'/js/vista/eventos.js',
'/js/vista/controlador.js',
'/js/vista/formulario.js',
'/iconos/icono-192.png',
'/iconos/icono-512.png'
];
self.addEventListener('install', (evento) => {
console.log(`[sw] Instalando ${VERSION}`);
evento.waitUntil(
caches.open(CACHE_SHELL)
.then((cache) => cache.addAll(RECURSOS_SHELL))
.then(() => console.log('[sw] App shell precacheado'))
);
});Aquí aparece el precio de los módulos ES de 05-04: cada fichero es una petición, y todos tienen que estar en la lista. Si olvidas uno, la aplicación arrancará sin conexión hasta el import que falta y ahí morirá. Dos consejos: manténla ordenada por carpetas para poder auditarla de un vistazo, y recuerda que en un proyecto con empaquetador esta lista se genera automáticamente (es uno de los motivos por los que existen, y lo verás en 09-05).
Fíjate también en que '/' y '/index.html' son dos entradas distintas para la caché, aunque el servidor devuelva lo mismo. Si solo cacheas una, la otra fallará offline.
- Limpiar versiones antiguas en
activate
activateCada versión crea su propia caché. Sin limpieza, el dispositivo acumularía nomada-shell-v1, v2, v3… hasta agotar la cuota. El activate es el momento seguro para borrar: el worker viejo ya no controla nada.
self.addEventListener('activate', (evento) => {
console.log(`[sw] Activando ${VERSION}`);
evento.waitUntil((async () => {
const nombres = await caches.keys();
await Promise.all(
nombres
.filter((nombre) => nombre.startsWith('nomada-') && !nombre.endsWith(VERSION))
.map((nombre) => {
console.log('[sw] Borrando caché antigua:', nombre);
return caches.delete(nombre);
})
);
await self.clients.claim(); // toma el control de las pestañas ya abiertas
})());
});Dos puntos:
- Filtra por prefijo
nomada-. El origen puede tener otras cachés (de otra aplicación del mismo dominio, o de una librería). Borrar todo lo que haya sería el equivalente allocalStorage.clear()que desaconsejamos en 07-01. clients.claim()hace que el worker recién activado tome el control de las pestañas que ya estaban abiertas sin recargarlas. Sin él, seguirían sin controlador hasta la siguiente navegación.
- Las estrategias de red
Aquí está el verdadero diseño. Interceptar peticiones no sirve de nada si no decides qué hacer con cada una, y no todos los recursos merecen el mismo trato.
| Estrategia | Cómo funciona | Ventaja | Inconveniente | En Nómada Tareas |
|---|---|---|---|---|
| Cache first | Mira la caché; si no está, red | Instantáneo, funciona offline | Puede servir contenido viejo | CSS, JS, fuentes, iconos, imágenes |
| Network first | Prueba la red; si falla, caché | Siempre lo más fresco | Lento si la red va mal | La API de tareas |
| Stale-while-revalidate | Devuelve la caché ya y actualiza en segundo plano | Rápido y se mantiene fresco | La primera vez muestra lo viejo | Avatares, catálogos, listas de etiquetas |
| Network only | Siempre red, sin caché | Nunca datos obsoletos | No funciona offline | POST/PATCH/DELETE, WebSocket |
| Cache only | Solo caché | Predecible | Falla si no está precacheado | Recursos del shell versionados |
Las tres primeras, en código:
/** Cache first: para lo que no cambia dentro de una misma versión. */
async function cacheFirst(peticion) {
const guardada = await caches.match(peticion);
if (guardada) return guardada;
const respuesta = await fetch(peticion);
if (respuesta.ok) {
const cache = await caches.open(CACHE_SHELL);
cache.put(peticion, respuesta.clone()); // clone: el original se devuelve
}
return respuesta;
}
/** Network first: para datos, con la caché como red de seguridad. */
async function networkFirst(peticion, nombreCache = CACHE_DATOS) {
try {
const respuesta = await fetch(peticion);
if (respuesta.ok) {
const cache = await caches.open(nombreCache);
cache.put(peticion, respuesta.clone());
}
return respuesta;
} catch {
const guardada = await caches.match(peticion);
if (guardada) {
// Marcamos la respuesta para que la interfaz pueda avisar de que es antigua
const cabeceras = new Headers(guardada.headers);
cabeceras.set('X-Desde-Cache', 'true');
return new Response(guardada.body, { status: 200, headers: cabeceras });
}
throw new Error('Sin red y sin copia en caché');
}
}
/** Stale-while-revalidate: lo mejor de los dos mundos para datos poco críticos. */
async function staleWhileRevalidate(peticion, nombreCache = CACHE_DATOS) {
const cache = await caches.open(nombreCache);
const guardada = await cache.match(peticion);
const actualizando = fetch(peticion)
.then((respuesta) => {
if (respuesta.ok) cache.put(peticion, respuesta.clone());
return respuesta;
})
.catch(() => null); // sin red: no pasa nada, ya devolvimos la caché
return guardada ?? await actualizando; // ← devuelve YA lo guardado si lo hay
}Ese X-Desde-Cache merece un comentario: es una cabecera inventada por nosotros que permite a la aplicación distinguir un dato fresco de uno recuperado del pasado. Sin ella, el usuario vería el tablero de ayer creyendo que es el de hoy, y eso es exactamente el tipo de mentira que hay que evitar.
- Interceptar con
fetch: el enrutador del service worker
fetch: el enrutador del service workerEl manejador fetch recibe todas las peticiones. Aplicar una sola estrategia a todas sería un error; lo que se escribe es un enrutador.
self.addEventListener('fetch', (evento) => {
const peticion = evento.request;
const url = new URL(peticion.url);
// 1 · Solo GET: nunca interceptes escrituras
if (peticion.method !== 'GET') return; // sin respondWith: va a la red normal
// 2 · Solo nuestro origen (y lo que decidamos permitir)
if (url.origin !== self.location.origin && !esApiPermitida(url)) return;
// 3 · Navegaciones (el usuario abre o recarga la página)
if (peticion.mode === 'navigate') {
evento.respondWith(manejarNavegacion(peticion));
return;
}
// 4 · Llamadas a la API: network first
if (url.pathname.startsWith('/api/') || esApiPermitida(url)) {
evento.respondWith(networkFirst(peticion));
return;
}
// 5 · Todo lo demás (CSS, JS, imágenes): cache first
evento.respondWith(cacheFirst(peticion));
});Tres reglas de oro del manejador fetch:
evento.respondWith()debe llamarse de forma síncrona. No puedes hacer unawaitantes de decidir si respondes; primero llamas arespondWithcon una promesa, y esa promesa hace el trabajo. Si el manejador termina sin llamarlo, la petición sigue su curso normal, que es exactamente lo que quieres para lo que no gestionas.- Nunca interceptes peticiones que no sean
GET. UnPOSTcacheado o duplicado crea datos fantasma. Y en 07-03 ya aprendiste lo caro que sale repetir unPOST. - Devuelve siempre algo. Si la promesa de
respondWithrechaza, el navegador muestra un error de red genérico. Es preferible responder con una página o un JSON de respaldo.
- Funcionar sin conexión: respaldo y cola de cambios
Con lo anterior, la aplicación arranca sin conexión. Falta cerrar dos huecos.
Página de respaldo para las navegaciones a rutas que no están cacheadas:
async function manejarNavegacion(peticion) {
try {
return await fetch(peticion); // network first: siempre el HTML más fresco
} catch {
const guardada = await caches.match('/index.html');
return guardada ?? await caches.match('/offline.html');
}
}<!-- offline.html — autónomo: no puede depender de nada que no esté cacheado -->
<main class="offline">
<h1>Sin conexión</h1>
<p>Nómada Tareas no puede contactar con el servidor ahora mismo.</p>
<p>Tus cambios se están guardando en el dispositivo y se enviarán en cuanto vuelva la conexión.</p>
<button type="button" onclick="location.reload()">Reintentar</button>
</main>Detectar el estado de la conexión desde la página:
// js/app.js
function actualizarConectividad() {
const enLinea = navigator.onLine;
$('#estado-red').textContent = enLinea ? '' : 'Sin conexión — trabajando en local';
$('#estado-red').hidden = enLinea;
document.body.classList.toggle('sin-conexion', !enLinea);
}
window.addEventListener('online', () => { actualizarConectividad(); sincronizarPendientes(); });
window.addEventListener('offline', actualizarConectividad);
actualizarConectividad();Una advertencia importante sobre navigator.onLine: solo dice si hay una interfaz de red activa, no si hay Internet de verdad. Un wifi conectado a un router sin salida da true. Es una pista útil, nunca una garantía; la única prueba real es intentar la petición, con el manejo de errores de 07-03.
Cola de cambios pendientes. Como el service worker no puede usar localStorage, la cola vive en IndexedDB, que sí comparte con la página:
// js/datos/cola-pendientes.js (en la PÁGINA, no en el worker)
const BD = 'nomada-pendientes';
const ALMACEN = 'cambios';
function abrir() {
return new Promise((resolver, rechazar) => {
const solicitud = indexedDB.open(BD, 1);
solicitud.onupgradeneeded = () => {
solicitud.result.createObjectStore(ALMACEN, { keyPath: 'id', autoIncrement: true });
};
solicitud.onsuccess = () => resolver(solicitud.result);
solicitud.onerror = () => rechazar(solicitud.error);
});
}
export async function encolar(cambio) {
const bd = await abrir();
const tx = bd.transaction(ALMACEN, 'readwrite');
tx.objectStore(ALMACEN).add({ ...cambio, ts: Date.now() });
return new Promise((r) => { tx.oncomplete = r; });
}
export async function leerTodos() {
const bd = await abrir();
return new Promise((resolver) => {
const solicitud = bd.transaction(ALMACEN).objectStore(ALMACEN).getAll();
solicitud.onsuccess = () => resolver(solicitud.result);
});
}
export async function eliminar(id) {
const bd = await abrir();
bd.transaction(ALMACEN, 'readwrite').objectStore(ALMACEN).delete(id);
}// js/app.js — envía lo acumulado cuando vuelve la red
export async function sincronizarPendientes() {
const pendientes = await leerTodos();
if (pendientes.length === 0) return;
vista.avisar(`Enviando ${pendientes.length} cambios pendientes…`);
for (const cambio of pendientes) {
try {
await api.actualizarTarea(cambio.id, cambio.datos); // con reintentos de 07-03
await eliminar(cambio.claveCola);
} catch (error) {
if (!error.reintentable) await eliminar(cambio.claveCola); // no insistas con un 400
break; // el resto, en el próximo intento
}
}
vista.avisar('Sincronización completada.');
}Existe además la Background Sync API, que permite registrar una sincronización que el navegador ejecutará cuando haya conexión, aunque la pestaña esté cerrada:
// Desde la página
const registro = await navigator.serviceWorker.ready;
if ('sync' in registro) await registro.sync.register('enviar-cambios');// Dentro de sw.js
self.addEventListener('sync', (evento) => {
if (evento.tag === 'enviar-cambios') evento.waitUntil(enviarCambiosPendientes());
});Es elegante, pero su soporte no es universal, así que trátala como una mejora sobre la sincronización con el evento online, nunca como el mecanismo principal.
- El manifiesto y la instalación
El manifiesto es un JSON que describe la aplicación instalada:
{
"name": "Nómada Tareas — Taller Nómada",
"short_name": "Nómada",
"description": "Gestión de tareas del taller y el coworking",
"start_url": "/?origen=pwa",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#faf7f2",
"theme_color": "#2b6b5b",
"lang": "es",
"dir": "ltr",
"icons": [
{ "src": "/iconos/icono-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/iconos/icono-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/iconos/icono-mascara.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"shortcuts": [
{ "name": "Nueva tarea", "url": "/?accion=nueva", "description": "Crear una tarea" }
]
}<!-- index.html, dentro de <head> -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2b6b5b">
<link rel="apple-touch-icon" href="/iconos/icono-192.png">| Campo | Para qué | Nota |
|---|---|---|
name |
Nombre completo, en la pantalla de instalación | — |
short_name |
Bajo el icono | Máximo ~12 caracteres o se corta |
start_url |
Qué se abre al pulsar el icono | El parámetro permite medir cuánta gente la usa instalada |
scope |
Qué URLs pertenecen a la aplicación | Fuera del scope se abre el navegador |
display |
standalone, fullscreen, minimal-ui, browser |
standalone es lo habitual: sin barra de direcciones |
theme_color |
Color de la barra del sistema | — |
background_color |
Fondo de la pantalla de arranque | Ponlo igual que el fondo real, o se verá un destello |
icons |
Iconos | 192 y 512 px como mínimo; añade uno maskable |
El icono maskable es un detalle que se nota: Android recorta los iconos en formas distintas (círculo, cuadrado redondeado), y sin él tu logotipo puede quedar decapitado. Un maskable deja margen de seguridad alrededor.
El aviso de instalación. Cuando se cumplen los requisitos, el navegador dispara beforeinstallprompt, y puedes controlar el momento:
let promesaInstalacion = null;
window.addEventListener('beforeinstallprompt', (evento) => {
evento.preventDefault(); // ← evita el aviso automático del navegador
promesaInstalacion = evento;
$('#instalar').hidden = false; // muestra TU botón, en TU momento
});
$('#instalar').addEventListener('click', async () => {
if (promesaInstalacion === null) return;
promesaInstalacion.prompt();
const { outcome } = await promesaInstalacion.userChoice;
console.log('[nomada] Instalación:', outcome); // 'accepted' | 'dismissed'
promesaInstalacion = null; // el evento es de un solo uso
$('#instalar').hidden = true;
});
window.addEventListener('appinstalled', () => {
$('#instalar').hidden = true;
console.log('[nomada] Instalada');
});Dos normas de buena educación, hermanas de las que verás en 07-06 con los permisos: no pidas instalar nada más entrar —el usuario no sabe todavía si tu aplicación le interesa—, y ofrécelo cuando haya demostrado interés, por ejemplo tras crear su tercera tarea. Y si lo rechaza, no vuelvas a preguntar en semanas.
- Actualizar sin dejar a nadie atrás
Este es el problema práctico más molesto de las PWAs: el usuario puede quedarse con una versión antigua indefinidamente. Con una web normal, un F5 trae lo último; con una PWA mal configurada, el service worker sirve la caché vieja para siempre.
El navegador comprueba si sw.js cambió al navegar (y como mucho cada 24 h). Si cambió, instala el nuevo… que se queda esperando. Hay tres estrategias:
| Estrategia | Cómo | Ventaja | Inconveniente |
|---|---|---|---|
| Esperar (por defecto) | El nuevo entra cuando se cierran todas las pestañas | Nunca rompe una sesión en curso | El usuario que nunca cierra la pestaña no actualiza jamás |
skipWaiting() inmediato |
El nuevo toma el control ya | Actualización garantizada | Peligroso: la página en curso puede pedir recursos ya borrados |
| Avisar y dejar decidir | «Hay una versión nueva. Actualizar» | Seguro y transparente | Un poco más de código |
La tercera es la correcta, y así se implementa:
// sw.js
self.addEventListener('install', (evento) => {
evento.waitUntil(caches.open(CACHE_SHELL).then((c) => c.addAll(RECURSOS_SHELL)));
// ← NO llamamos a skipWaiting() aquí: esperamos a que el usuario acepte
});
self.addEventListener('message', (evento) => {
if (evento.data?.tipo === 'saltar-espera') self.skipWaiting();
});// js/app.js
const registro = await navigator.serviceWorker.register('/sw.js');
// 1 · Detectar que hay un worker nuevo esperando
registro.addEventListener('updatefound', () => {
const nuevo = registro.installing;
nuevo.addEventListener('statechange', () => {
// 'installed' + hay un controlador = es una ACTUALIZACIÓN, no la primera instalación
if (nuevo.state === 'installed' && navigator.serviceWorker.controller) {
mostrarAvisoDeActualizacion(nuevo);
}
});
});
function mostrarAvisoDeActualizacion(trabajadorNuevo) {
$('#aviso-version').hidden = false;
$('#aviso-version-actualizar').addEventListener('click', () => {
trabajadorNuevo.postMessage({ tipo: 'saltar-espera' }); // 2 · el usuario acepta
}, { once: true });
}
// 3 · Cuando el nuevo toma el control, recargar UNA vez
let recargando = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (recargando) return; // ← guardia contra bucles
recargando = true;
window.location.reload();
});
// Comprobar si hay actualizaciones al volver a la pestaña
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') registro.update();
});Esa bandera recargando no es opcional: sin ella, controllerchange puede dispararse más de una vez y provocar un bucle infinito de recargas, uno de los fallos más desagradables que puede sufrir un usuario.
Y una regla de despliegue que evita el peor escenario: el fichero sw.js nunca debe cachearse en el servidor. Configúralo con Cache-Control: no-cache. Si un CDN sirve un sw.js viejo durante horas, tus usuarios se quedan congelados en una versión antigua y no hay nada que puedas hacer desde el cliente.
- Depurar en DevTools
En DevTools → Application tienes el panel de control completo:
| Sección | Para qué |
|---|---|
| Service Workers | Ver el estado (installing / waiting / activated), forzar skipWaiting, Unregister, ver los registros |
| Manifest | Comprobar que el manifiesto se lee bien y ver los iconos detectados |
| Cache Storage | Inspeccionar cada caché entrada por entrada, y borrarlas |
| Storage → Clear site data | El botón nuclear: borra todo y desregistra el worker |
Tres casillas que te salvarán la vida durante el desarrollo:
Update on reload: fuerza que el service worker nuevo se instale y active en cada recarga, saltándose la espera. Actívala mientras desarrollas. Es la diferencia entre iterar en segundos o pelear con cachés.Bypass for network: ignora el service worker por completo, como si no existiera.Offline(en Network): la única forma de probar de verdad que tu aplicación funciona sin conexión.
La trampa de la caché durante el desarrollo merece un párrafo propio porque le pasa a todo el mundo: cambias el CSS, recargas y no ves el cambio. La causa es tu propia estrategia cache-first sirviendo el fichero viejo. Los remedios, por orden:
- Marca
Update on reloadyDisable cacheen DevTools. - Sube la constante
VERSIONensw.jsen cada cambio de recursos. - Como último recurso, Clear site data y recarga.
Y el consejo más valioso: registra el service worker solo en producción mientras desarrollas la aplicación, o detrás de una bandera. Depurar una aplicación con un proxy de caché entre medias multiplica el tiempo de cada iteración.
const ENTORNO_LOCAL = ['localhost', '127.0.0.1'].includes(location.hostname);
if ('serviceWorker' in navigator && (!ENTORNO_LOCAL || location.search.includes('sw=1'))) {
navigator.serviceWorker.register('/sw.js');
}
- Notificaciones push, brevemente
Un service worker puede recibir mensajes del servidor aunque la aplicación esté cerrada, y mostrar una notificación del sistema. El mecanismo, en tres pasos:
// 1 · La página pide permiso y se suscribe (con la clave pública VAPID del servidor)
const registro = await navigator.serviceWorker.ready;
const suscripcion = await registro.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: CLAVE_PUBLICA_VAPID
});
await api.guardarSuscripcion(suscripcion); // el servidor la necesita para enviarte cosas// 2 · En sw.js: llega el push
self.addEventListener('push', (evento) => {
const datos = evento.data?.json() ?? {};
evento.waitUntil(self.registration.showNotification('Nómada Tareas', {
body: datos.mensaje ?? 'Hay novedades en el tablero',
icon: '/iconos/icono-192.png',
data: { url: datos.url ?? '/' }
}));
});
// 3 · El usuario pulsa la notificación
self.addEventListener('notificationclick', (evento) => {
evento.notification.close();
evento.waitUntil(self.clients.openWindow(evento.notification.data.url));
});Tres cosas que hay que saber antes de plantearlo:
- Requiere un servidor que gestione las suscripciones y firme los envíos con claves VAPID. No es solo cliente.
userVisibleOnly: truees obligatorio en la práctica: no puedes usar push para hacer trabajo silencioso.- Los permisos se estudian en 07-06, junto con la regla de oro: no pidas permiso de notificaciones nada más entrar. Es la forma más rápida de que te lo denieguen para siempre.
Para Nómada Tareas es probablemente innecesario: con el WebSocket de 07-04 el tablero ya se actualiza en vivo mientras está abierto, y las notificaciones aportan cuando la aplicación no lo está.
- Nómada Tareas:
sw.js completo
sw.js completo// sw.js — en la RAÍZ del sitio, para que el ámbito sea '/'
const VERSION = 'v3';
const CACHE_SHELL = `nomada-shell-${VERSION}`;
const CACHE_DATOS = `nomada-datos-${VERSION}`;
const API = 'http://localhost:3000'; // en producción, la URL real de la API
const RECURSOS_SHELL = [
'/', '/index.html', '/offline.html', '/manifest.json',
'/css/estilos.css',
'/js/app.js',
'/js/modelo/tarea.js', '/js/modelo/tablero.js', '/js/modelo/errores.js',
'/js/datos/backlog.js', '/js/datos/repositorio-local.js',
'/js/datos/api-tareas.js', '/js/datos/http.js', '/js/datos/tiempo-real.js',
'/js/util/fechas.js', '/js/util/formato.js', '/js/util/tiempo.js',
'/js/vista/dom.js', '/js/vista/tarjeta.js', '/js/vista/pintar.js',
'/js/vista/tablero-vista.js', '/js/vista/eventos.js',
'/js/vista/controlador.js', '/js/vista/formulario.js',
'/iconos/icono-192.png', '/iconos/icono-512.png'
];
// ══════════════════ INSTALACIÓN ══════════════════
self.addEventListener('install', (evento) => {
console.log(`[sw] install ${VERSION}`);
evento.waitUntil(
caches.open(CACHE_SHELL).then((cache) => cache.addAll(RECURSOS_SHELL))
);
// Sin skipWaiting(): la actualización la decide el usuario (apartado 14)
});
// ══════════════════ ACTIVACIÓN ══════════════════
self.addEventListener('activate', (evento) => {
console.log(`[sw] activate ${VERSION}`);
evento.waitUntil((async () => {
const nombres = await caches.keys();
await Promise.all(
nombres
.filter((n) => n.startsWith('nomada-') && !n.endsWith(VERSION))
.map((n) => caches.delete(n))
);
await self.clients.claim();
})());
});
// ══════════════════ MENSAJES DESDE LA PÁGINA ══════════════════
self.addEventListener('message', (evento) => {
if (evento.data?.tipo === 'saltar-espera') self.skipWaiting();
});
// ══════════════════ ESTRATEGIAS ══════════════════
async function cacheFirst(peticion) {
const guardada = await caches.match(peticion);
if (guardada) return guardada;
try {
const respuesta = await fetch(peticion);
if (respuesta.ok) (await caches.open(CACHE_SHELL)).put(peticion, respuesta.clone());
return respuesta;
} catch (error) {
if (peticion.destination === 'image') return caches.match('/iconos/icono-192.png');
throw error;
}
}
async function networkFirst(peticion) {
try {
const respuesta = await fetch(peticion);
if (respuesta.ok) (await caches.open(CACHE_DATOS)).put(peticion, respuesta.clone());
return respuesta;
} catch {
const guardada = await caches.match(peticion);
if (guardada) {
const cabeceras = new Headers(guardada.headers);
cabeceras.set('X-Desde-Cache', 'true'); // la interfaz avisará de que es antiguo
return new Response(guardada.body, { status: 200, headers: cabeceras });
}
// Respuesta JSON de respaldo: mejor que un error de red opaco
return new Response(
JSON.stringify({ error: 'sin-conexion', mensaje: 'Sin conexión y sin copia local.' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }
);
}
}
async function manejarNavegacion(peticion) {
try {
return await fetch(peticion);
} catch {
return (await caches.match('/index.html')) ?? (await caches.match('/offline.html'));
}
}
// ══════════════════ INTERCEPCIÓN ══════════════════
self.addEventListener('fetch', (evento) => {
const peticion = evento.request;
const url = new URL(peticion.url);
if (peticion.method !== 'GET') return; // nunca POST/PATCH/DELETE
if (url.protocol.startsWith('ws')) return; // el WebSocket de 07-04 va aparte
if (peticion.mode === 'navigate') {
evento.respondWith(manejarNavegacion(peticion));
return;
}
if (url.origin === API) {
evento.respondWith(networkFirst(peticion));
return;
}
if (url.origin === self.location.origin) {
evento.respondWith(cacheFirst(peticion));
}
// Cualquier otro origen: sin respondWith, va a la red tal cual
});Comprueba que funciona con este guion, que es también el que deberías repetir en cada despliegue:
- Sirve el sitio (
npx serveen la carpeta del proyecto) y ábrelo. - DevTools → Application → Service Workers: debe aparecer activated and running.
- Cache Storage: debe estar
nomada-shell-v3con todos los recursos. - Network → marca Offline.
- Pulsa F5. La aplicación arranca, con el tablero de
localStorage(07-01). - Marca una tarea como hecha: se encola.
- Desmarca Offline: el cambio se envía y el WebSocket se reconecta con el retroceso de 07-04.
Ese punto 5 es el momento en el que Nómada Tareas deja de ser una web y pasa a ser una aplicación.
Errores Comunes y Consejos
- Poner
sw.jsenjs/. Su ámbito sería/js/y no controlaría nada. Va en la raíz. - Olvidar
event.waitUntil(). El navegador puede detener el worker antes de que la precaché termine, dejándola a medias. - Un 404 en
RECURSOS_SHELL.addAlles atómico: una URL mal escrita impide que se guarde nada. - Interceptar peticiones que no son
GET. Datos duplicados o perdidos. - Llamar a
skipWaiting()siempre. La página en curso puede quedarse pidiendo recursos que la caché nueva ya borró. Avisa y deja decidir. - No poner la guardia en
controllerchange. Bucle infinito de recargas. - Cachear
sw.jsen el servidor. Tus usuarios se congelan en una versión vieja.Cache-Control: no-cache. - Olvidar borrar cachés viejas. Se acumulan versiones hasta agotar la cuota.
- Guardar respuestas sin comprobar
respuesta.ok. Un 500 cacheado se sirve como si fuera bueno. - Olvidar
clone().TypeError: body stream already read, el mismo de 07-02. - Creer que
navigator.onLine === truesignifica que hay Internet. Solo dice que hay una interfaz de red. - Confundir la primera visita con estar controlado. El worker no controla la página en la que se registró salvo que uses
clients.claim(). - Probar sin activar Offline. Un modo offline que nunca se ha probado no funciona; es una ley.
- Consejo:
Update on reloadmientras desarrollas. Ahorra horas. - Consejo: sube
VERSIONen cada despliegue. Es lo que dispara el ciclo de instalación y la limpieza. - Consejo: registra el worker solo en producción mientras construyes la aplicación.
- Consejo: pasa Lighthouse (DevTools → Lighthouse → Progressive Web App). Te dice exactamente qué falta.
- Consejo: no pidas instalar ni notificar nada más entrar. Espera a que el usuario demuestre interés.
Ejercicios
Ejercicio 1 — Caché de datos con caducidad.
La estrategia networkFirst guarda respuestas de la API sin límite de antigüedad, y un tablero de hace tres días es peor que ninguno. Escribe cacheConCaducidad(peticion, maxEdadMs) que, al guardar, añada una cabecera X-Cacheado-En con Date.now(), y al recuperar de la caché compruebe esa marca: si la respuesta es más vieja que maxEdadMs, la borra y lanza en lugar de devolverla. Añade una función limpiarCaducados(nombreCache, maxEdadMs) que se ejecute en activate.
Ejercicio 2 — Aviso de versión nueva accesible.
Escribe vigilarActualizaciones(registro, { alHaberVersion }) que detecte un service worker nuevo en estado installed (distinguiendo actualización de primera instalación), llame a alHaberVersion con una función aplicar(), compruebe actualizaciones al volver a la pestaña visible y cada 60 minutos, y gestione controllerchange con la guardia contra bucles. El aviso en el HTML debe usar role="status" y aria-live="polite", ofrecer «Actualizar ahora» y «Más tarde», y recordar el rechazo durante la sesión con sessionStorage.
Ejercicio 3 — Auditar la precaché.
La lista RECURSOS_SHELL se desincroniza en cuanto alguien añade un módulo. Escribe un script de Node auditar-shell.js que lea todos los ficheros .js bajo js/, extraiga sus rutas, las compare con la lista declarada en sw.js y avise de los que faltan y de los que sobran, terminando con código de salida 1 si hay diferencias. No uses librerías externas: node:fs y expresiones regulares bastan.
Soluciones
Solución 1
const CABECERA_FECHA = 'X-Cacheado-En';
async function guardarConMarca(nombreCache, peticion, respuesta) {
const cabeceras = new Headers(respuesta.headers);
cabeceras.set(CABECERA_FECHA, String(Date.now()));
// Hay que reconstruir la Response: sus cabeceras son inmutables
const conMarca = new Response(await respuesta.clone().blob(), {
status: respuesta.status,
statusText: respuesta.statusText,
headers: cabeceras
});
const cache = await caches.open(nombreCache);
await cache.put(peticion, conMarca);
}
function estaCaducada(respuesta, maxEdadMs) {
const marca = Number(respuesta.headers.get(CABECERA_FECHA));
if (!Number.isFinite(marca)) return true; // sin marca: la tratamos como caducada
return Date.now() - marca > maxEdadMs;
}
async function cacheConCaducidad(peticion, maxEdadMs = 3600000) {
try {
const respuesta = await fetch(peticion);
if (respuesta.ok) await guardarConMarca(CACHE_DATOS, peticion, respuesta);
return respuesta;
} catch (error) {
const cache = await caches.open(CACHE_DATOS);
const guardada = await cache.match(peticion);
if (!guardada) throw error;
if (estaCaducada(guardada, maxEdadMs)) {
await cache.delete(peticion);
throw new Error('Copia en caché demasiado antigua');
}
return guardada;
}
}
async function limpiarCaducados(nombreCache, maxEdadMs) {
const cache = await caches.open(nombreCache);
const peticiones = await cache.keys();
await Promise.all(peticiones.map(async (peticion) => {
const respuesta = await cache.match(peticion);
if (respuesta && estaCaducada(respuesta, maxEdadMs)) await cache.delete(peticion);
}));
}El detalle que hay que descubrir a la fuerza: las cabeceras de una Response son inmutables. No puedes hacer respuesta.headers.set(...); hay que construir una Response nueva con las cabeceras modificadas, y para eso primero se lee el cuerpo del clon.
Solución 2
const CLAVE_RECHAZO = 'nomada:actualizacion-rechazada';
export function vigilarActualizaciones(registro, { alHaberVersion }) {
let recargando = false;
function evaluar(trabajador) {
if (trabajador.state !== 'installed') return;
if (!navigator.serviceWorker.controller) return; // primera instalación, no actualización
if (sessionStorage.getItem(CLAVE_RECHAZO) === 'si') return;
alHaberVersion(() => trabajador.postMessage({ tipo: 'saltar-espera' }));
}
// Un worker que ya estaba esperando cuando cargamos
if (registro.waiting && navigator.serviceWorker.controller) evaluar(registro.waiting);
registro.addEventListener('updatefound', () => {
const nuevo = registro.installing;
nuevo?.addEventListener('statechange', () => evaluar(nuevo));
});
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (recargando) return; // ← guardia contra el bucle infinito
recargando = true;
window.location.reload();
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') registro.update().catch(() => {});
});
setInterval(() => registro.update().catch(() => {}), 3600000);
}<div id="aviso-version" role="status" aria-live="polite" hidden>
<p>Hay una versión nueva de Nómada Tareas.</p>
<button type="button" id="version-actualizar">Actualizar ahora</button>
<button type="button" id="version-luego">Más tarde</button>
</div>vigilarActualizaciones(registro, {
alHaberVersion(aplicar) {
const aviso = document.querySelector('#aviso-version');
aviso.hidden = false;
document.querySelector('#version-actualizar')
.addEventListener('click', aplicar, { once: true });
document.querySelector('#version-luego').addEventListener('click', () => {
sessionStorage.setItem(CLAVE_RECHAZO, 'si'); // no insistir en esta sesión
aviso.hidden = true;
}, { once: true });
}
});La comprobación registro.waiting inicial es la que se olvida siempre: si el usuario abre la aplicación y ya había un worker esperando de una visita anterior, el evento updatefound no volverá a dispararse y el aviso nunca aparecería.
Solución 3
// auditar-shell.js — ejecutar con: node auditar-shell.js
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
const RAIZ = process.cwd();
/** Recorre un directorio recursivamente devolviendo las rutas de los .js */
function listarJs(directorio) {
const salida = [];
for (const entrada of readdirSync(directorio)) {
const ruta = join(directorio, entrada);
if (statSync(ruta).isDirectory()) salida.push(...listarJs(ruta));
else if (entrada.endsWith('.js')) salida.push('/' + relative(RAIZ, ruta).replaceAll('\\', '/'));
}
return salida;
}
const enDisco = new Set(listarJs(join(RAIZ, 'js')));
// Extrae las cadenas entre comillas de la lista RECURSOS_SHELL
const sw = readFileSync(join(RAIZ, 'sw.js'), 'utf8');
const bloque = sw.match(/const RECURSOS_SHELL\s*=\s*\[([\s\S]*?)\]/)?.[1] ?? '';
const declarados = new Set([...bloque.matchAll(/'([^']+)'/g)].map((m) => m[1]));
const faltan = [...enDisco].filter((r) => !declarados.has(r));
const sobran = [...declarados].filter((r) => r.startsWith('/js/') && !enDisco.has(r));
if (faltan.length) console.error('❌ Faltan en RECURSOS_SHELL:\n ' + faltan.join('\n '));
if (sobran.length) console.error('❌ Declarados pero inexistentes:\n ' + sobran.join('\n '));
if (faltan.length === 0 && sobran.length === 0) {
console.log(`✅ La precaché está al día (${enDisco.size} módulos).`);
process.exit(0);
}
process.exit(1);El código de salida 1 es lo que convierte este script en algo útil de verdad: se puede enganchar a un npm run build o a la integración continua, y el despliegue falla si alguien añadió un módulo y olvidó la precaché. Es también una buena ilustración de por qué existen los empaquetadores: generan esta lista solos, y lo verás en 09-05.
Conclusión
Nómada Tareas ya funciona sin conexión y puede instalarse en el dispositivo. Sabes qué es una PWA —no una tecnología, sino un conjunto de capacidades sobre HTML, CSS y JavaScript— y sus tres requisitos: HTTPS (con localhost como excepción para desarrollo), manifiesto y service worker. Y entiendes la idea que lo sostiene todo: el service worker es un proxy que vive fuera de la página, en su propio hilo, que sobrevive al cierre de la pestaña, intercepta todas las peticiones de su ámbito y puede detenerse en cualquier momento —de ahí que no se guarde estado en sus variables globales—.
Dominas su ciclo de vida: install para precachear el app shell, la fase de espera que protege a las pestañas abiertas y que explica por qué tu worker nuevo «no entra», activate para limpiar versiones antiguas, y fetch para atender cada petición; con event.waitUntil() como pieza obligatoria para que el navegador no detenga el worker a mitad. Sabes que el ámbito lo determina la ubicación del fichero, y que por eso sw.js va en la raíz. Sabes por qué no tiene DOM (no pertenece a ninguna página) ni localStorage (es síncrono), y que las alternativas son la Cache API e IndexedDB, con postMessage y clients para hablar con las pestañas.
Manejas la Cache API con sus trampas —addAll atómico, put que no mira el estado, el clone() obligatorio— y, sobre todo, sabes elegir estrategia por tipo de recurso: cache-first para el shell, network-first para la API con la caché como red de seguridad, stale-while-revalidate para lo poco crítico, network-only para las escrituras. El manejador fetch que has escrito es un enrutador, no una regla única, y respeta las tres normas: respondWith síncrono, nunca interceptar lo que no sea GET, y devolver siempre algo. Con eso, más la página de respaldo, la cola de cambios en IndexedDB y los eventos online/offline —recordando que navigator.onLine solo indica que hay interfaz de red, no Internet—, Nómada Tareas arranca en el almacén de serigrafía y envía sola lo pendiente al volver la cobertura.
Y tienes resueltos los dos problemas operativos que hunden a las PWAs mal hechas: el manifiesto con sus iconos de 192 y 512 píxeles, el maskable, el short_name corto y el beforeinstallprompt controlado para no pedir instalación nada más entrar; y la actualización, con el patrón de avisar y dejar decidir, la comprobación de registro.waiting que casi todo el mundo olvida, la guardia contra el bucle de recargas en controllerchange, y la regla de despliegue que evita el desastre: sw.js nunca se cachea en el servidor. Sumado a Update on reload, Bypass for network y Clear site data, ya sabes salir de la trampa de la caché durante el desarrollo en lugar de sufrirla.
Con esto, la aplicación tiene sus grandes capacidades cubiertas: recuerda (07-01), habla con un servidor (07-02), aguanta los fallos de la red (07-03), se sincroniza en vivo (07-04) y funciona sin conexión e instalada (07-05). Lo que queda son las piezas medianas que separan una aplicación correcta de una que da gusto usar: cargar tarjetas solo cuando aparecen en pantalla, reaccionar a cambios de tamaño, copiar el resumen del tablero al portapapeles con un clic, hacer que los filtros sean enlazables y sobrevivan a la recarga, respetar que Marta prefiera el modo oscuro o que Lucía haya pedido menos animación, animar sin tirones, y formatear fechas y horas en español de verdad en lugar de a mano —esa función fechaLegible con su array de meses escrito a mano pide a gritos una reescritura—. Todo eso son APIs del navegador que ya están ahí, esperando: APIs del Navegador Esenciales.
Curso de JavaScript: De Principiante a Avanzado
Módulo 1: Introducción a JavaScript
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
