Skip to content

🎯 Meta: convertir tu web en una app instalable que funciona sin conexión: service workers, estrategias de caché, cola offline y notificaciones push. Una PWA bien hecha sustituye a la app nativa en la mayoría de casos de negocio (y sin pasar por las stores).

Requisitos: cap. 15 (fetch, Vite), ap. L (caché HTTP — no la confundas con esta). Herramienta: vite-plugin-pwa (Workbox por debajo). Requiere HTTPS (en local, localhost cuenta como seguro).


N.1 · Qué es una PWA (y qué no)

Una Progressive Web App = tu web + tres piezas:

1. HTTPS                    → requisito de todo lo demás
2. Web App Manifest         → nombre, iconos, color: la hace INSTALABLE
3. Service Worker           → proxy programable: la hace OFFLINE-CAPAZ
MitoRealidad
"PWA = web metida en un wrapper"Es tu misma web; el navegador la promociona a app
"Offline = toda la app sin red"Offline = degradarse con elegancia: shell + datos cacheados + cola de acciones
"Es solo para móvil"Se instalan en escritorio también (Chrome/Edge)

Cuándo hacer PWA: apps de uso recurrente (el panel de cocina del cap. 22, un TPV, un fichaje), campo con mala cobertura, quioscos. Cuándo no complicarse: webs de consulta esporádica — el 100% del valor con el 0% de la complejidad de sincronización.


N.2 · El manifest: instalable en 20 líneas

json
// public/manifest.webmanifest
{
  "name": "Cantina — Pedidos",
  "short_name": "Cantina",
  "description": "Pide y sigue tu pedido en tiempo real",
  "start_url": "/",
  "display": "standalone",          // sin barra del navegador: parece app nativa
  "background_color": "#0d1117",
  "theme_color": "#2563eb",
  "icons": [
    { "src": "/iconos/192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/iconos/512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/iconos/512-maskable.png", "sizes": "512x512", "type": "image/png",
      "purpose": "maskable" }       // Android lo recorta en círculo sin romperlo
  ]
}
html
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#2563eb">

Con manifest + service worker + HTTPS, el navegador ofrece "Instalar app". Auditalo en Lighthouse (categoría PWA) y DevTools → Application → Manifest.

Manifest avanzado: atajos, compartir y protocolos

Cuatro campos que convierten una PWA instalada en algo indistinguible de una app nativa en el menú del sistema:

json
{
  "shortcuts": [
    { "name": "Nuevo pedido", "url": "/pedidos/nuevo",
      "icons": [{ "src": "/iconos/nuevo-96.png", "sizes": "96x96" }] }
  ],
  "share_target": {
    "action": "/compartir", "method": "GET",
    "params": { "title": "titulo", "text": "texto", "url": "enlace" }
  },
  "protocol_handlers": [
    { "protocol": "web+cantina", "url": "/pedido?id=%s" }
  ],
  "display_override": ["window-controls-overlay", "standalone"]
}
  • shortcuts: mantén pulsado el icono de la app (móvil) o clic derecho (escritorio) → accesos directos a acciones concretas, como los shortcuts de una app nativa.
  • share_target: tu PWA aparece en el menú "Compartir" del sistema operativo — otra app puede compartirte una URL/texto y tu PWA la recibe como una petición normal.
  • protocol_handlers: tu PWA se registra como manejadora de un esquema de URL propio (web+cantina://...), útil para enlaces profundos entre sistemas (equivalente al deep linking nativo del apéndice U).

💡 Ninguno de los tres es imprescindible para que la PWA funcione — son mejora progresiva pura: el navegador que no los soporta simplemente los ignora. Añádelos cuando el manifest básico (N.2) y el offline (N.3-N.5) ya estén sólidos.


N.3 · El service worker: un proxy dentro del navegador

Un service worker es un script que se instala entre tu app y la red. Intercepta cada fetch y decide: ¿red, caché, o una mezcla?

App ──fetch──▶ Service Worker ──▶ ¿estrategia?
                    │                ├─ Cache First  → caché; si no está, red
                    │                ├─ Network First → red; si falla, caché
                    │                └─ Stale-While-Revalidate → caché YA + refresca detrás

              Cache Storage (independiente de la caché HTTP del ap. L)

La decisión clave es qué estrategia para qué recurso:

RecursoEstrategiaPor qué
App shell (JS/CSS con hash)Precache (en la instalación)Inmutables: la app arranca sin red
Imágenes de productosCache First (+ límite)Cambiar tarde no duele; ahorran datos
GET /api/cartaStale-While-RevalidateRápido siempre, fresco casi siempre
GET /api/pedidos/:idNetwork First (+ timeout)Mejor fresco; cacheado si no hay red
POST/PUT/DELETENUNCA se cacheanSe encolan (N.5)

N.4 · Todo junto con vite-plugin-pwa

bash
npm install -D vite-plugin-pwa
typescript
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: 'prompt',            // tú controlas cuándo se actualiza (ver abajo)
      manifest: { /* el manifest de N.2 aquí */ },
      workbox: {
        globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],   // precache del shell
        navigateFallback: '/index.html',                      // SPA offline (el try_files local)
        runtimeCaching: [
          {
            urlPattern: /\/api\/carta/,
            handler: 'StaleWhileRevalidate',
            options: { cacheName: 'api-carta', expiration: { maxAgeSeconds: 3600 } },
          },
          {
            urlPattern: /\/api\/pedidos/,
            handler: 'NetworkFirst',
            options: { cacheName: 'api-pedidos', networkTimeoutSeconds: 3 },
          },
          {
            urlPattern: /\.(png|webp|avif)$/,
            handler: 'CacheFirst',
            options: { cacheName: 'imagenes',
                       expiration: { maxEntries: 100, maxAgeSeconds: 30 * 86400 } },
          },
        ],
      },
    }),
  ],
});

El ciclo de vida y el banner "hay nueva versión"

Un SW nuevo queda en espera hasta que se cierran todas las pestañas — por eso "deployé y los usuarios siguen viendo lo viejo". Con registerType: 'prompt' lo gestionas tú:

tsx
// React — banner de actualización
import { useRegisterSW } from 'virtual:pwa-register/react';

function BannerActualizacion() {
  const { needRefresh: [hayNueva], updateServiceWorker } = useRegisterSW();
  if (!hayNueva) return null;
  return (
    <div role="alert" className="banner">
      Hay una versión nueva.
      <button onClick={() => updateServiceWorker(true)}>Actualizar</button>
    </div>
  );
}

⚠️ El SW puede servir contenido zombi si lo configuras mal. Reglas de supervivencia: nunca hagas precache de index.html con caché infinita manual, deja que Workbox gestione las revisiones, y en DevTools → Application ten a mano "Unregister" + "Clear storage" mientras desarrollas. El modo "Update on reload" es tu amigo.


N.5 · Escrituras offline: la cola de acciones

Leer offline es caché; escribir offline es una cola. El patrón (el mismo de las colas del apéndice B, pero en el cliente):

Usuario pulsa "marcar pedido listo" sin red
   1. UI optimista: se marca YA (cap. 17.5)
   2. La acción se guarda en IndexedDB: { id, url, method, body, creadaEn }
   3. Al volver la red (evento 'online' / Background Sync) → se reenvía EN ORDEN
   4. Conflicto (409/422) → se resuelve o se avisa al usuario
typescript
// cola-offline.ts (esquema mínimo con idb)
import { openDB } from 'idb';

const db = await openDB('cola', 1, {
  upgrade: (d) => { d.createObjectStore('acciones', { keyPath: 'id' }); },
});

export async function ejecutar(url: string, init: RequestInit) {
  const accion = { id: crypto.randomUUID(), url, init, creadaEn: Date.now() };
  try {
    return await fetch(url, { ...init, headers: { ...init.headers, 'Idempotency-Key': accion.id } });
  } catch {                                     // sin red → encolar
    await db.add('acciones', accion);
    return new Response(null, { status: 202 }); // "aceptado, pendiente"
  }
}

window.addEventListener('online', async () => {
  for (const a of await db.getAll('acciones')) {
    try { await fetch(a.url, a.init); await db.delete('acciones', a.id); }
    catch { break; }                            // sigue sin red: reintenta luego
  }
});

🧠 La Idempotency-Key es la mitad de la solución: el reenvío puede duplicarse (se envió pero la respuesta se perdió). El backend guarda las keys procesadas y responde lo mismo sin repetir el efecto (pagos del mundo real funcionan así). Sin idempotencia, tu cola offline duplica pedidos.

💡 Workbox trae esto hecho: workbox-background-sync (BackgroundSyncPlugin) encola los POST fallidos y reintenta incluso con la pestaña cerrada (donde Background Sync existe; el evento online es el fallback universal).

IndexedDB también sirve para los datos: la carta cacheada como datos estructurados (consultables) en vez de respuestas HTTP. Para apps muy offline (inventarios, rutas de reparto), mira RxDB/PouchDB/PowerSync — sincronización bidireccional seria.


N.6 · Notificaciones push (con cabeza)

Push = el servidor despierta al SW aunque la web esté cerrada. Piezas: Push API (recibir)

  • Notifications API (mostrar) + protocolo Web Push/VAPID (tu backend firma los envíos).
typescript
// Frontend: pedir permiso SOLO tras una acción con contexto (jamás al aterrizar)
const sub = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: VAPID_PUBLIC_KEY,
});
await fetch('/api/push/suscribir', { method: 'POST', body: JSON.stringify(sub) });
typescript
// Backend (Node): npm i web-push
webpush.setVapidDetails('mailto:admin@cantina.com', VAPID_PUBLIC, VAPID_PRIVATE);
await webpush.sendNotification(suscripcion, JSON.stringify({
  titulo: 'Pedido listo 🛎', cuerpo: 'Tu pedido #17 te espera', url: '/pedido/17',
}));
javascript
// En el service worker
self.addEventListener('push', (e) => {
  const d = e.data.json();
  e.waitUntil(self.registration.showNotification(d.titulo, { body: d.cuerpo, data: d }));
});
self.addEventListener('notificationclick', (e) => {
  e.notification.close();
  e.waitUntil(clients.openWindow(e.notification.data.url));
});

⚠️ Funciona en iOS ≥ 16.4 solo si la PWA está instalada en la pantalla de inicio. Y el permiso denegado es casi irreversible: pídelo en el momento con valor ("¿Te avisamos cuando tu pedido esté listo?"), nunca en el primer segundo.

Periodic Background Sync y Badging API

Dos APIs más pequeñas que rematan la sensación de "app instalada de verdad", con soporte todavía parcial (Chromium; no Safari) — trátalas como mejora progresiva, nunca como base:

typescript
// Periodic Background Sync: refresca datos en segundo plano aunque la PWA esté cerrada
// (requiere que el navegador considere la PWA "de uso frecuente" — no lo decides tú)
const registro = await navigator.serviceWorker.ready;
await registro.periodicSync.register('refrescar-carta', { minInterval: 24 * 60 * 60 * 1000 });

// En el service worker
self.addEventListener('periodicsync', (e) => {
  if (e.tag === 'refrescar-carta') e.waitUntil(refrescarCacheDeCarta());
});
typescript
// Badging API: el numerito rojo sobre el icono de la app (pedidos pendientes de cocina)
if ('setAppBadge' in navigator) await navigator.setAppBadge(pedidosPendientes);
if (pedidosPendientes === 0) await navigator.clearAppBadge();

🧠 Ambas son azúcar sobre lo ya construido: Periodic Sync reutiliza la misma lógica de refrescarCacheDeCarta que ya tienes para el SWR de N.4; el badge solo refleja un número que ya calculas para la UI. Nunca dependas de que se ejecuten — son "si el navegador quiere y puede", no un cron garantizado (para eso, tu backend sigue siendo la fuente de verdad).


N.7 · Buenas prácticas PWA

  1. Empieza por el shell offline + una estrategia por tipo de recurso. Push y Background Sync después.
  2. POST nunca a la caché; siempre a la cola, con Idempotency-Key y backend idempotente.
  3. Banner de actualización controlado por ti (prompt), no recargas sorpresa.
  4. La UI dice la verdad: indicador offline, acciones "pendientes de sincronizar" marcadas.
  5. Presupuesta la caché (maxEntries/maxAge): el almacenamiento del navegador se puede purgar; tu app debe sobrevivir a una caché vacía.
  6. Prueba offline de verdad: DevTools → Network → Offline es el mínimo; modo avión en un móvil real es el examen.
  7. Lighthouse PWA + "Application" panel en cada release.

✅ Ejercicio del apéndice

El panel de cocina del proyecto final (cap. 22), a prueba de wifi de bar:

1. Manifest completo + iconos (uno maskable). Instálala en tu móvil.
2. Precache del shell + SWR para /api/carta + NetworkFirst para /api/pedidos.
   Con el modo avión: la app abre y muestra los últimos pedidos conocidos.
3. Banner de "versión nueva" con registerType: 'prompt'.
4. Cola offline: "marcar como listo" sin red se aplica optimista, se encola en
   IndexedDB y se sincroniza al volver la conexión (verifica el orden).
5. Idempotency-Key end-to-end: implementa la tabla de keys en el backend y
   demuestra con un test que reenviar la misma acción no duplica el efecto.
6. Push: "pedido nuevo" despierta la PWA de cocina instalada (VAPID + web-push).
7. Indicador de estado (online/offline/sincronizando) visible y accesible
   (aria-live, ap. M).
💡 Pistas
  • Si tras cambiar el SW ves código viejo: Application → Service Workers → "Update on reload" + "Clear storage". El 90% del sufrimiento PWA en dev es esto.
  • El test del punto 5: misma key dos veces → segunda respuesta idéntica, y el contador de pedidos en BD sube UNA vez.
  • Para probar push en local: web-push generate-vapid-keys y dispara desde un script Node contra tu suscripción real.

🧠 Autoevaluación

La HTTP la gestiona el navegador según cabeceras y no es programable. La del SW es un proxy con lógica tuya: eliges estrategia por recurso, respondes offline, encolas escrituras. Se complementan: assets inmutables se benefician de ambas.

SWR muestra lo cacheado al instante y refresca detrás: perfecto para datos que toleran minutos de desfase. El estado de un pedido cambia y DECIDE acciones del usuario: mejor NetworkFirst (fresco si hay red, cacheado solo como último recurso, e idealmente marcado como "posiblemente desactualizado").

El reenvío puede ejecutarse dos veces (respuesta perdida, reintento). Con la key, el backend detecta la repetición y devuelve el resultado original sin repetir el efecto — sin ella, la sincronización duplica pedidos/pagos.

El SW nuevo queda "waiting" hasta que se cierren todas las pestañas, y mientras tanto el viejo sirve el precache antiguo. La solución es el flujo de actualización controlado: banner "hay nueva versión" que llama a updateServiceWorker(true) (skipWaiting + reload).

Es el navegador quien decide si y cuándo se ejecuta (según uso frecuente de la app, batería, soporte del navegador — Safari no lo implementa), no un cron garantizado. Sirve como optimización sobre la estrategia de caché que ya tienes (SWR/Network First); la fuente de verdad y el refresco al abrir la app siguen siendo imprescindibles.


Volver al: README.md · Relacionado: L-despliegue-frontend.md, B-redis-cache-colas.md, 21-tiempo-real.md