En el capítulo 4 desplegaste tu Team Builder con HTTPS. En el capítulo anterior le añadiste
las meta tags de SEO. Con dos ficheros más —un manifest.json y un service worker— esa
misma web se puede instalar como una app y funcionar sin conexión. No reescribes nada: son
capacidades que añades encima de la web que ya tienes. Eso es una PWA.
Qué es una PWA#
PWA son las siglas de Progressive Web App, aplicación web progresiva. Es una web normal que el navegador puede instalar como si fuera una app nativa: aparece con su icono en la pantalla de inicio del móvil, arranca sin la barra de dirección del navegador, y funciona aunque no haya conexión a internet.
Lo que la hace especial no es un nuevo framework ni una reescritura: es la combinación de tres requisitos que tu Team Builder ya cumple parcialmente:
Los tres requisitos de una PWA
HTTPS activo → ya lo tienes del capítulo 4
manifest.json → lo vas a añadir en este capítulo
service worker activo → lo vas a añadir en este capítuloCuando el navegador detecta los tres, habilita el banner de instalación. En Android, Chrome lo muestra como un botón en la barra de dirección o como un popup. En iOS, Safari lo ofrece a través del menú “Compartir” → “Añadir a la pantalla de inicio”. Una vez instalada, la app aparece en el menú de apps del sistema operativo como cualquier otra.
El “¿y qué?” de no tener HTTPS: un service worker sin HTTPS no se instala. El navegador
lo rechaza porque un service worker intercepta todo el tráfico de tu origen, y sin cifrado
eso sería un vector de ataque enorme: cualquiera en la misma red podría modificar el worker
y envenenarte la caché. localhost es la única excepción permitida, para desarrollo.
El manifest: que el navegador ofrezca instalar#
El manifest.json es el carné de identidad de tu PWA. Es un fichero JSON (ya conoces JSON del Nivel 3) que describe la app: su nombre, sus iconos, cómo debe mostrarse la ventana, y qué URL abrir cuando el usuario la lanza desde el icono. El navegador lo lee cuando el usuario visita la web y decide si puede ofrecer instalarla.
Se enlaza desde el index.html con una etiqueta en el <head>:
<!-- Le dice al navegador dónde encontrar el carné de identidad de la PWA -->
<link rel="manifest" href="/manifest.json">Este es el manifest que vas a crear para el Team Builder:
{
"name": "Overwatch Team Builder",
"short_name": "Team Builder",
"description": "Construye y gestiona tu equipo de Overwatch. Filtra héroes por rol y compara estadísticas.",
"start_url": "/",
"display": "standalone",
"theme_color": "#b8336a",
"background_color": "#f7f6fb",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}Qué hace cada campo:
name: el nombre completo que aparece en la pantalla de instalación y en la lista de apps del sistema. Puede ser largo.short_name: el nombre que aparece debajo del icono en el escritorio del móvil. Menos de 12 caracteres para que no se corte.start_url: la URL que abre la app al lanzarla desde el icono."/"para la raíz.display: cómo se presenta la ventana."standalone"elimina la barra de dirección del navegador y hace que parezca una app nativa. Sin este campo (o con"browser"), la app abre en una ventana de navegador normal.theme_color: el color de la barra de estado del sistema operativo en Android. Usamos el acento del Team Builder.background_color: el color de la pantalla de inicio (splash screen) mientras la app carga tras lanzarse. Usamos el fondo claro del Team Builder.icons: el array de iconos que el navegador usa en distintos contextos (icono en el escritorio, splash screen, notificaciones). Los mínimos recomendados son 192×192 y 512×512. El campopurpose: "any maskable"indica que el icono puede recortarse en cualquier forma que use el sistema operativo (círculo en Android, cuadrado redondeado en iOS).
El manifest va en la carpeta public/ de tu proyecto Vite. Vite la copia tal cual en el
dist/ sin procesarla, así que el fichero queda disponible en /manifest.json directamente.
Con solo este fichero, Chrome ya ofrece instalar la app. Su límite: sin service worker, si el usuario abre la app sin conexión, ve la pantalla de error de Chrome. La instalación funciona, pero la app no es resiliente.
El service worker: un proxy entre tu app y la red#
Un service worker es un script de JavaScript que el navegador registra y ejecuta en un hilo separado de la página, en segundo plano. Su función es interceptar las peticiones de red que hace tu app y decidir qué respuesta dar: puede ir a la red, responder desde una caché propia, o combinar las dos cosas.
Si recuerdas el Nivel 8, MSW (Mock Service Worker) usaba exactamente este mecanismo para interceptar las peticiones de tus tests. Aquí lo escribes tú, con un propósito distinto: la caché offline.
El ciclo de vida del service worker tiene tres eventos principales:
CICLO DE VIDA DEL SERVICE WORKER
(primera visita o cambio en sw.js)
│
▼
install
Pre-cachea el app-shell.
Si algo falla, el SW no se instala.
│
▼
activate
Limpia cachés de versiones anteriores.
Toma el control de las pestañas abiertas.
│
▼
fetch
Se dispara en cada petición de red.
Aquí decides: caché, red, o los dos.Para registrar el service worker, añades este bloque al final de tu main.tsx:
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}El if comprueba que el navegador soporte service workers (todos los modernos lo hacen,
pero evita errores en entornos muy antiguos). El listener de 'load' espera a que la página
haya terminado de cargar para registrar el worker, así no compite con la carga inicial.
El fichero sw.js va también en public/ por la misma razón que el manifest.
En el service worker, cacheas el app-shell en el evento install y respondes desde la
caché en el evento fetch:
const CACHE_NAME = 'team-builder-shell';
const APP_SHELL = [
'/',
'/index.html'
];
// El evento 'install' se dispara la primera vez que el navegador registra el service worker
// y cada vez que cambia el fichero sw.js.
self.addEventListener('install', function(event) {
// event.waitUntil le dice al navegador que espere a que la promesa se resuelva
// antes de considerar la instalación completa.
event.waitUntil(
// caches.open devuelve (o crea) una caché con ese nombre.
caches.open(CACHE_NAME).then(function(cache) {
// cache.addAll hace una petición por cada URL y guarda la respuesta.
// Si una petición falla, addAll falla entera y el SW no se instala.
return cache.addAll(APP_SHELL);
})
);
});
// El evento 'fetch' se dispara en cada petición que hace la app.
self.addEventListener('fetch', function(event) {
// event.respondWith le dice al navegador que use esta respuesta
// en vez de ir a la red directamente.
event.respondWith(
// Buscamos el recurso en la caché primero.
caches.match(event.request).then(function(respuestaEnCache) {
// Si está en la caché, lo devolvemos sin tocar la red.
if (respuestaEnCache) {
return respuestaEnCache;
}
// Si no está en la caché, hacemos la petición normal a la red.
return fetch(event.request);
})
);
});La clave está en event.respondWith: en cuanto el service worker llama a esa función,
el navegador sabe que hay una respuesta alternativa y no hace la petición de red por su
cuenta. Tú tienes el control total sobre qué se devuelve.
El “¿y qué?” de no tener service worker: sin él, la app instalada muestra la pantalla
de error de Chrome cuando no hay red. Con él, el navegador encuentra el index.html en
la caché y la app arranca, aunque no haya datos frescos de la API.
Dos capas de caché que no debes confundir#
Antes de hablar de estrategias de caché, hay una distinción que confunde a casi todo el mundo la primera vez que trabaja con PWAs. Hay DOS capas de caché completamente distintas:
UNA PETICIÓN A "/index.html"
tu app
│
│ petición de red
▼
SERVICE WORKER (Cache API: caches.open, cache.put, caches.match)
│ ← esta caché la controla tu código JavaScript
│ si no está en la caché del SW, deja pasar la petición
▼
RED (navegador → servidor)
│
│ antes de llegar al servidor, el navegador comprueba:
▼
CACHÉ HTTP (Cache-Control: max-age, ETag, Last-Modified)
│ ← esta caché la decide el servidor o CDN
│ si no está en la caché HTTP, la petición llega al servidor
▼
SERVIDOR / CDNLa caché HTTP (Cache-Control: max-age) la configura el servidor y controla durante
cuánto tiempo el navegador puede reutilizar una respuesta sin volver a pedirla al servidor.
Esto lo viste en los capítulos 2 y 4: los assets de Vite tienen un hash en el nombre
(index-a1b2c3.js) y el servidor les pone max-age de un año porque el nombre cambia con
cada build, garantizando que el navegador nunca sirva un asset obsoleto.
La caché del service worker (Cache API: caches.open, cache.put, caches.match) la
controlas tú con código JavaScript en el fichero sw.js. El service worker intercepta la
petición antes de que salga al servidor o a la caché HTTP, y puede responder desde su propia
caché interna.
El “¿y qué?” de confundirlas: si la app carga un recurso obsoleto y abres DevTools para depurar, puedes pasar media hora borrando la caché HTTP (el checkbox “Disable cache” de Network) sin efecto porque el problema está en la caché del service worker, que es otra capa distinta. La pestaña “Application” → “Cache Storage” de DevTools es donde ves (y borras) la caché del service worker.
Estrategias de caché#
No existe una sola forma de usar la caché del service worker. La estrategia que eliges depende del tipo de recurso y de si prefieres velocidad, frescura de datos, o resiliencia offline:
Cache-first (caché primero):
// Estrategia cache-first: responde de la caché y solo va a la red si no hay nada.
// Ideal para el app-shell (index.html, fuentes, assets base) que cambian poco.
caches.match(event.request).then(function(respuestaEnCache) {
// Si está en la caché, lo devolvemos sin ir a la red.
if (respuestaEnCache) {
return respuestaEnCache;
}
// Si no está, vamos a la red.
return fetch(event.request);
});La app responde al instante desde la caché y solo hace una petición de red cuando el recurso no está cacheado. El límite: si usas esta estrategia para datos de una API, el usuario siempre verá la respuesta vieja que haya en caché, aunque haya una más reciente en el servidor.
Network-first (red primero):
// Estrategia network-first: intenta la red y, si falla, responde de la caché.
// Ideal para datos de API que deben estar actualizados cuando hay conexión.
fetch(event.request).then(function(respuestaRed) {
// Guardamos la respuesta fresca en la caché para usarla si la red falla.
const copia = respuestaRed.clone();
caches.open(CACHE_NAME).then(function(cache) {
cache.put(event.request, copia);
});
// Devolvemos la respuesta fresca de la red.
return respuestaRed;
}).catch(function() {
// Sin conexión: respondemos con la última respuesta guardada en la caché.
return caches.match(event.request);
});El usuario ve datos actualizados cuando hay conexión, y datos algo obsoletos cuando no la hay. La caché es el plan B. Es la estrategia adecuada para las peticiones a una API de datos.
Stale-while-revalidate:
Responde de la caché inmediatamente (sin esperar a la red) y en paralelo hace una petición de red para actualizar la caché para la próxima vez. El usuario siempre ve una respuesta rápida (puede estar algo desactualizada), y la siguiente visita ya tiene la versión nueva. Útil para recursos como imágenes o fuentes que pueden mostrarse ligeramente desactualizados sin coste para el usuario.
Cuándo cada estrategia:
TIPO DE RECURSO ESTRATEGIA RECOMENDADA
app-shell (index.html,
CSS base, fuentes) → cache-first
Assets con hash de Vite → cache-first (el hash garantiza frescura)
Datos de API → network-first
Imágenes estáticas → cache-first o stale-while-revalidate
Contenido que cambia
con frecuencia moderada → stale-while-revalidateFuncionar sin conexión#
El objetivo real de cachear el app-shell es que la app arranque aunque no haya red. El flujo cuando el usuario abre la app sin conexión es este:
usuario abre la app (sin conexión)
│
▼
service worker intercepta la petición de "/index.html"
│
▼
caches.match encuentra el index.html en la caché del SW
│
▼
el service worker devuelve el index.html cacheado
│
▼
el navegador carga React y pinta la interfaz
│
▼
las peticiones de datos (API) fallan o responden de la cachéEl resultado: el usuario ve la interfaz de la app en vez de la pantalla de error de Chrome. Puede que los datos de los héroes no estén, o que salgan obsoletos si estaban en la caché. Eso es esperado y correcto: la red no existe, solo podemos mostrar lo que tenemos guardado.
Para los casos en que una petición de navegación (el usuario escribe una URL directamente)
falla sin conexión, puedes añadir un fallback explícito en el handler de fetch:
// Si la red falla y el usuario pedía una página (navegación), devolvemos
// el index.html cacheado como fallback offline en vez del error del navegador.
.catch(function() {
if (event.request.mode === 'navigate') {
return caches.match('/index.html');
}
});El request.mode === 'navigate' distingue las peticiones de navegación (el usuario pide
una URL) de las peticiones de assets (CSS, imágenes). Para las de navegación, devolver el
index.html es siempre mejor que el error del navegador.
Para verificar la instalabilidad, la pestaña Application → Manifest de DevTools te
muestra cómo el navegador interpreta tu manifest.json y te avisa de lo que falte para
que la app sea instalable —igual que la pestaña Application → Cache Storage te enseña
lo que el service worker ha cacheado—. Y Lighthouse (la pestaña “Analyze page load” de
DevTools) audita tu web en rendimiento, accesibilidad, buenas prácticas y SEO, con una
lista de problemas concretos y cómo resolverlos: es la herramienta profesional para medir
la calidad de una web en producción, más allá de comprobar las cosas a mano.
En producción: vite-plugin-pwa#
En proyectos reales nadie escribe el service worker a mano. Hay un motivo sólido: un error en el service worker puede hacer que la app sirva indefinidamente una versión vieja de sí misma a todos los usuarios, porque la caché del service worker no expira sola. Depurar eso en producción es costoso.
La librería vite-plugin-pwa (basada en Workbox de Google) genera el service worker
automáticamente durante el pnpm build:
- Escanea el
dist/tras el build y calcula los hashes de cada fichero. - Genera la lista de pre-caché con los recursos exactos del deploy actual.
- Aplica estrategias por patrón de URL (un patrón para
/api/*, otro para assets, otro para el shell). - Gestiona el versionado de la caché y la limpieza de versiones antiguas en cada deploy.
Con unas pocas líneas en vite.config.ts, tienes todo lo que has implementado aquí, más
control de actualizaciones y revisiones automáticas.
Lo has escrito a mano una vez porque es la única forma de entender qué genera ese plugin por ti. Cuando lo veas en un proyecto real, o cuando tengas que depurar un fallo de caché, sabrás exactamente qué está pasando dentro.
El salto a nativo: TWA y React Native#
Una PWA bien construida funciona como app instalable en Android e iOS. Pero a veces hace falta ir un paso más allá: distribuir la app en la tienda de aplicaciones (Play Store o App Store) o acceder a APIs nativas que la web no expone. Hay dos caminos:
TWA: tu PWA empaquetada como APK#
TWA (Trusted Web Activity) es un componente de Android que muestra tu PWA a pantalla completa dentro de una APK instalable desde la Play Store. El código que corre es exactamente el mismo HTML, CSS y JavaScript de tu web, sin cambiar ni una línea.
tu web desplegada con HTTPS
│
│ Bubblewrap (herramienta CLI de Google)
▼
APK de Android
(envuelve tu PWA
en una webview de confianza
a pantalla completa)
│
▼
Play Store → instalación en el móvilLa herramienta que genera la APK es Bubblewrap, una CLI de Google. Le das la URL de tu PWA y los datos de la app (nombre, icono, colores), y genera el proyecto Android listo para compilar y subir a la Play Store.
La relación de “confianza” entre la APK y la web se establece mediante el fichero
.well-known/assetlinks.json que publicas en tu servidor: le dice a Android que la APK
con esa firma es la propietaria oficial de esa URL, y así Android elimina la barra de
dirección y la muestra como una app nativa.
La ventaja es obvia: el mismo código en la Play Store sin reescribir nada. El límite: todo sigue siendo web. El acceso a APIs nativas profundas del sistema operativo (Bluetooth, NFC, sensores de hardware específicos) está limitado a lo que la Web API expone, que cada vez es más, pero aún no llega al nivel de una app nativa compilada.
React Native: apps nativas de verdad con React#
Cuando una PWA o TWA no es suficiente —necesitas APIs nativas profundas, presencia en la App Store de iOS, o rendimiento a nivel nativo— la alternativa es React Native.
React Native no es una webview. Construye apps que se renderizan a componentes nativos
reales del sistema operativo: UIView en iOS, View en Android. El resultado es una app
que el sistema operativo trata exactamente igual que cualquier otra app nativa.
Reutilizas el modelo mental de React (componentes, estado, hooks, props) y puedes compartir
parte de la lógica de negocio con tu web. Pero la capa de UI es completamente distinta: en
vez de <div> y <button>, usas <View> y <TouchableOpacity>; en vez de CSS, usas un
subset de estilos parecido a CSS pero no idéntico. Y para cada API nativa (cámara, GPS,
notificaciones del sistema) necesitas o bien una librería de la comunidad o bien código
específico de plataforma.
La elección entre PWA, TWA y React Native depende del caso:
Tu web ya funciona bien en móvil
y quieres instalación sin mucho coste → PWA
Quieres la app en la Play Store
sin reescribir nada → TWA (Bubblewrap + tu PWA)
Necesitas APIs nativas profundas,
App Store de iOS, o UX indistinguible
de una app nativa → React NativeReact Native queda fuera de este curso: es una mención para que sepas que existe y cuándo lo necesitarías, no algo que vayas a aprender aquí. Si algún día tu proyecto lo pide, su documentación oficial es el punto de partida. Por ahora, lo importante es saber qué problema resuelve y cuándo la PWA ya no es suficiente.
Comprueba lo que sabes#
Pregunta 1 de 7
¿Qué es una PWA?
Tu turno#
El ejercicio es en local, sobre tu Team Builder de React. Necesitas la app desplegada con
HTTPS (GitHub Pages, Vercel o cualquier otro hosting del capítulo 4) o corriendo en
localhost para probar el service worker. No hay playground: los service workers requieren
HTTPS real (o localhost) y el resultado —el banner de instalación, el modo offline— solo
es visible en el navegador con la app cargada. Las soluciones están para leer y comparar
después de hacer el tuyo.
Ejercicio · hazlo en local
Añadir manifest y service worker al Team Builder
El ejercicio es en local, sobre tu Team Builder de React desplegado con HTTPS (o en localhost si aún no lo has desplegado). Añade el manifest.json y el service worker para convertirlo en una PWA instalable que funciona sin conexión. Las soluciones tienen tres niveles de ambición: desde el manifest mínimo que activa el banner de instalación hasta un service worker con estrategias diferenciadas y limpieza de versiones antiguas.
Paso 1: El manifest.json: la app ya es instalable
- Creas el fichero manifest.json en la carpeta public/ de tu proyecto con los campos name, short_name, description, start_url, display ("standalone"), theme_color (#b8336a), background_color (#f7f6fb) e icons (al menos 192x192 y 512x512).
- Enlazas el manifest desde el head del index.html con <link rel="manifest" href="/manifest.json">.
- Tienes los iconos en public/icons/ (puede ser cualquier imagen PNG de 192px y 512px mientras el manifest los referencie).
- Al abrir la app en Chrome en el móvil (o en Chrome de escritorio con DevTools → Application → Manifest), el navegador la reconoce como instalable y ofrece "Instalar app". Sin conexión, la app muestra el error de Chrome porque aún no hay service worker.
Paso 2: Service worker básico: la app funciona offline
- Sobre el tier "ok", creas el fichero sw.js en public/ con los eventos install (pre-caché del app-shell: "/" e "/index.html") y fetch (estrategia cache-first: caches.match primero, fetch como fallback).
- Registras el service worker desde main.tsx con navigator.serviceWorker.register("/sw.js") dentro de un if ("serviceWorker" in navigator) y un listener de "load".
- Con el service worker activo, la app arranca en modo avión: el navegador encuentra el index.html en la caché y muestra la interfaz en vez del error de Chrome.
- Entiendes el límite: la estrategia cache-first aplicada a todo devolvería siempre datos de API obsoletos; y si despliegas una nueva versión, la caché antigua persiste hasta que el usuario la borre.
Paso 3: Service worker con estrategias diferenciadas y control de versiones
- Sobre el tier "mejor", usas un nombre de caché versionado (p. ej. "team-builder-v1") para poder limpiar versiones antiguas.
- El evento activate borra todas las cachés que no pertenezcan a la versión actual (caches.keys + filter + caches.delete), e invoca self.clients.claim() para tomar el control sin recargar.
- El evento fetch diferencia por tipo de recurso: cache-first para el app-shell y assets, network-first para rutas de /api/ (datos frescos con fallback a caché offline).
- Hay un fallback offline explícito: si una petición de navegación (request.mode === "navigate") falla sin conexión, el service worker devuelve el index.html cacheado.
- Puedes explicar qué genera vite-plugin-pwa automáticamente y por qué escribirlo a mano una vez tiene valor pedagógico.
- Verificas la instalabilidad en DevTools → Application → Manifest (el navegador muestra el manifest interpretado y avisa de lo que falte) y la app ofrece "Instalar"; una auditoría de Lighthouse confirma rendimiento, accesibilidad y buenas prácticas.
Cómo hacerlo en local
Clona el repositorio del curso, entra en la carpeta del ejercicio y abre el
index.html en tu navegador. Toda tu solución va en
solucion.js.
git clone <repo>
cd exercises/nivel-9/pwa-twa-y-nativo
# abre index.html en el navegador y edita solucion.js Ver soluciones
{
"name": "Overwatch Team Builder",
"short_name": "Team Builder",
"description": "Construye y gestiona tu equipo de Overwatch. Filtra héroes por rol, compara estadísticas y guarda tu selección.",
"start_url": "/",
"display": "standalone",
"theme_color": "#b8336a",
"background_color": "#f7f6fb",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
} Por qué este nivel
- El manifest.json es todo lo que necesita el navegador para ofrecer "Instalar app": con él, el icono aparece en la pantalla de inicio del móvil y la app abre sin la barra de dirección del navegador (display: "standalone"). Con el tier "ok", la PWA ya es instalable y tiene una identidad visual propia (colores del Team Builder en el splash screen y en la barra de estado de Android).
- Su límite es claro: sin service worker, abrir la app sin conexión muestra la pantalla de error de Chrome. La app es instalable pero no es resiliente. El tier "mejor" añade exactamente eso: el service worker que intercepta las peticiones y responde desde la caché cuando no hay red.
// Tier: mejor
// Qué demuestra: service worker básico con estrategia cache-first para el app-shell.
// El alumno ve cómo interceptar peticiones de red y responder desde la caché,
// consiguiendo que la app arranque sin conexión.
// Nombre de la caché donde se guardan los recursos del app-shell.
// "app-shell" es solo una convención; puedes llamarla como quieras.
const CACHE_NAME = 'team-builder-shell';
// Lista de recursos que forman el app-shell: los ficheros mínimos para que
// la app arranque y muestre algo, aunque no haya conexión.
// "/" e "/index.html" son lo mismo en Vite, pero los añadimos los dos
// porque el navegador puede pedir cualquiera de los dos según cómo llega.
const APP_SHELL = [
'/',
'/index.html'
];
// El evento 'install' se dispara la primera vez que el navegador registra
// el service worker, y también cada vez que cambias el fichero sw.js.
// Es el momento de pre-cachear el app-shell.
self.addEventListener('install', function(event) {
// event.waitUntil le dice al navegador que espere a que esta promesa
// se resuelva antes de considerar la instalación completa.
// Si la promesa falla, el service worker no se instala.
event.waitUntil(
// caches.open devuelve (o crea) una caché con ese nombre.
caches.open(CACHE_NAME).then(function(cache) {
// cache.addAll hace una petición de red por cada URL y guarda
// la respuesta en la caché. Si una falla, addAll falla entera.
return cache.addAll(APP_SHELL);
})
);
});
// El evento 'fetch' se dispara cada vez que la página pide cualquier recurso:
// HTML, CSS, JS, imágenes, peticiones a APIs. Aquí decidimos qué devolver.
self.addEventListener('fetch', function(event) {
// event.respondWith le dice al navegador que usemos nuestra respuesta
// en vez de ir a la red directamente.
event.respondWith(
// Estrategia cache-first: primero buscamos en la caché.
caches.match(event.request).then(function(respuestaEnCache) {
// Si encontramos la respuesta en la caché, la devolvemos.
// No hay petición de red: el alumno ve la app aunque esté sin conexión.
if (respuestaEnCache) {
return respuestaEnCache;
}
// Si no está en la caché, hacemos la petición normal a la red.
// Para el app-shell esto solo ocurre la primera vez (antes del install).
// Para otros recursos (datos de API, imágenes dinámicas) siempre irá aquí.
return fetch(event.request);
})
);
});
// ---------------------------------------------------------------------------
// Por qué supera al tier ok:
// El tier ok solo tiene el manifest.json, así que el navegador ofrece
// "Instalar app", pero sin conexión la app muestra la pantalla de error
// de Chrome. Con este service worker, al arrancar sin conexión el navegador
// encuentra el index.html en la caché y la app se carga.
//
// Límite de este tier:
// La estrategia cache-first va bien para el app-shell (que rara vez cambia),
// pero para las peticiones de datos devuelve siempre la respuesta vieja aunque
// haya una más nueva en el servidor. Además, si despliegas una nueva versión
// de la app, la caché antigua sigue activa hasta que el usuario la borre
// manualmente: el service worker nuevo no limpia el anterior.
// --------------------------------------------------------------------------- Por qué es mejor que el anterior
- El service worker del tier "mejor" implementa el ciclo de vida mínimo: install pre-cachea el app-shell y fetch aplica cache-first. Combina bien con el manifest del tier "ok": ahora la app no solo es instalable sino que también arranca en modo avión. El mecanismo clave es event.respondWith: en cuanto el service worker llama a esa función, el navegador sabe que hay una respuesta alternativa y no hace la petición de red por su cuenta.
- El límite está en la uniformidad: cache-first para todo significa que los datos de API nunca se actualizan mientras haya algo en caché. Si el Team Builder tuviera una API real con estadísticas en vivo, el usuario siempre vería los datos de la primera visita. Además, al desplegar una versión nueva de la app, la caché antigua con los assets viejos sigue activa indefinidamente. El tier "excelente" resuelve los dos problemas.
// Tier: excelente
// Qué demuestra: service worker con estrategias diferenciadas por tipo de recurso,
// nombre de caché versionado para controlar actualizaciones, limpieza de cachés
// antiguas en 'activate', y un fallback offline explícito para navegación.
// Este es el esquema conceptual que genera vite-plugin-pwa (Workbox) por ti
// en proyectos reales.
// Nombre de la caché con versión. Cuando despliegas una nueva versión de la app,
// cambia el número (p. ej. 'team-builder-v2') para que el 'activate' borre la vieja.
// Sin versión, la caché anterior persiste indefinidamente aunque el código haya cambiado.
const CACHE_SHELL = 'team-builder-v1';
// Caché separada para datos de API. La separamos del app-shell para poder
// aplicar una estrategia distinta (network-first) sin mezclar recursos estáticos
// con respuestas de datos que cambian con frecuencia.
const CACHE_DATA = 'team-builder-data-v1';
// Lista de cachés que ESTE service worker gestiona.
// En 'activate' borrará cualquier caché que no esté en esta lista.
const CACHES_PROPIAS = [CACHE_SHELL, CACHE_DATA];
// Recursos del app-shell: los ficheros mínimos para arrancar la app offline.
const APP_SHELL = [
'/',
'/index.html'
];
// El evento 'install' pre-cachea el app-shell.
// skipWaiting() hace que el nuevo service worker tome el control inmediatamente
// sin esperar a que se cierren todas las pestañas con la versión anterior.
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(CACHE_SHELL).then(function(cache) {
return cache.addAll(APP_SHELL);
}).then(function() {
// Con skipWaiting el service worker nuevo no espera: en cuanto se instala,
// pasa a 'activate'. Útil en desarrollo; en producción evalúa si prefieres
// que el usuario vea la versión nueva solo al recargar.
return self.skipWaiting();
})
);
});
// El evento 'activate' se dispara después de 'install', cuando el service worker
// toma el control. Es el momento de limpiar cachés de versiones anteriores.
self.addEventListener('activate', function(event) {
event.waitUntil(
// caches.keys devuelve los nombres de todas las cachés existentes en el navegador.
caches.keys().then(function(nombresCaches) {
return Promise.all(
nombresCaches
// Filtramos las cachés que NO son de esta versión del service worker.
// Una caché 'team-builder-v0' o 'team-builder-shell' del tier anterior
// no está en CACHES_PROPIAS, así que se borra.
.filter(function(nombre) {
return !CACHES_PROPIAS.includes(nombre);
})
.map(function(nombre) {
// caches.delete borra la caché con ese nombre y todas sus entradas.
return caches.delete(nombre);
})
);
}).then(function() {
// clients.claim hace que el service worker tome el control de todas las
// pestañas abiertas sin necesidad de recargar. Combina bien con skipWaiting.
return self.clients.claim();
})
);
});
// El evento 'fetch' intercepta todas las peticiones de red.
// Usamos estrategias distintas según el tipo de recurso.
self.addEventListener('fetch', function(event) {
const url = new URL(event.request.url);
// Solo interceptamos peticiones al mismo origen (no a CDNs externas, etc.).
if (url.origin !== self.location.origin) {
return;
}
// Estrategia para peticiones de datos (URLs que empiecen por /api/).
// Usamos network-first: intentamos la red y, si falla, respondemos con la caché.
// Así el alumno siempre ve datos actualizados cuando hay conexión,
// y datos guardados cuando no la hay.
if (url.pathname.startsWith('/api/')) {
event.respondWith(manejarDatos(event.request));
return;
}
// Estrategia para el resto (app-shell, assets): cache-first.
// Los assets de Vite tienen hash en el nombre, así que nunca quedan obsoletos.
// Si no están en caché (nuevo deploy), los pedimos a la red.
event.respondWith(manejarShell(event.request));
});
// Estrategia cache-first para el app-shell y assets estáticos.
function manejarShell(request) {
return caches.match(request).then(function(respuestaEnCache) {
if (respuestaEnCache) {
// Recurso encontrado en la caché: respondemos sin ir a la red.
return respuestaEnCache;
}
// No está en caché: pedimos a la red y guardamos la respuesta para la próxima vez.
return fetch(request).then(function(respuestaRed) {
// Clonamos la respuesta porque los streams solo se pueden leer una vez:
// una copia va a la caché y la original al navegador.
const copia = respuestaRed.clone();
caches.open(CACHE_SHELL).then(function(cache) {
cache.put(request, copia);
});
return respuestaRed;
}).catch(function() {
// Si la red falla y el recurso es una navegación (el usuario pide una URL),
// devolvemos el index.html de la caché como fallback offline.
// Así el alumno ve la app en vez de la pantalla de error de Chrome.
if (request.mode === 'navigate') {
return caches.match('/index.html');
}
// Para otros recursos (imágenes, etc.) sin conexión, no podemos hacer más.
// El navegador mostrará el recurso roto, que es el comportamiento esperado.
});
});
}
// Estrategia network-first para peticiones de datos.
function manejarDatos(request) {
return fetch(request).then(function(respuestaRed) {
// Petición exitosa: guardamos la respuesta en la caché de datos.
const copia = respuestaRed.clone();
caches.open(CACHE_DATA).then(function(cache) {
cache.put(request, copia);
});
// Devolvemos la respuesta fresca de la red.
return respuestaRed;
}).catch(function() {
// Sin conexión: intentamos responder con la última respuesta guardada.
// El alumno ve datos algo obsoletos en vez de un error vacío.
return caches.match(request);
});
}
// ---------------------------------------------------------------------------
// Por qué supera al tier mejor:
// El tier mejor aplica cache-first a todo por igual, lo que significa que
// los datos de API nunca se actualizan mientras haya algo en caché. Este tier
// diferencia: el app-shell va cache-first (cambia poco y los assets tienen hash),
// los datos van network-first (el alumno siempre ve la información más reciente
// cuando hay conexión). Además, la versión en el nombre de la caché permite
// limpiar versiones viejas en 'activate', algo que el tier mejor no hacía
// y que acumula espacio y puede servir recursos obsoletos indefinidamente.
//
// Nota sobre vite-plugin-pwa:
// Este fichero implementa manualmente lo que Workbox (usado por vite-plugin-pwa)
// genera automáticamente durante el build. Workbox escanea el dist/, calcula los
// hashes, genera la lista de pre-caché y aplica estrategias por patrón de URL.
// Ahora que has escrito esto a mano una vez, cuando leas la config de
// vite-plugin-pwa en un proyecto real entenderás exactamente qué hace cada opción
// y podrás depurar problemas de caché con criterio.
// --------------------------------------------------------------------------- Por qué es mejor que el anterior
- El tier "excelente" diferencia estrategias según el tipo de recurso: cache-first para el app-shell y assets estáticos (que tienen hash en el nombre y rara vez cambian), network-first para las rutas de /api/ (donde la frescura de los datos importa). El versionado en el nombre de la caché ("team-builder-v1") hace que el evento activate de una nueva versión del service worker pueda borrar la caché anterior: una limpieza automática en cada deploy.
- El fallback offline explícito (si request.mode === "navigate" y la red falla, devolver index.html de la caché) es la pieza que cierra el círculo: el usuario que abre una URL concreta de la app sin conexión no ve el error de Chrome sino la interfaz, aunque los datos no estén disponibles. Esto es exactamente el esquema que genera vite-plugin-pwa con Workbox: ahora que lo has escrito a mano, la config del plugin te resultará transparente.