learning-front

Nivel 9 · El ciclo completo: del commit al deploy

PWA, TWA y el salto a nativo

Convertir tu web en una app instalable que funciona sin conexión: el manifest, el service worker como proxy entre la app y la red, las estrategias de caché, y cómo empaquetar tu PWA como una APK de Android (TWA). Una mención a React Native.

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:

texto
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ítulo

Cuando 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>:

html
<!-- 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:

json
{
  "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 campo purpose: "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:

texto
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:

typescript
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:

javascript
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:

texto
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 / CDN

La 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):

javascript
// 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):

javascript
// 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:

texto
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-revalidate

Funcionar 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:

texto
  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:

javascript
// 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.

texto
  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óvil

La 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:

texto
  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 Native

React 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.

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.