📱 PATRÓN PWA & OFFLINE-FIRST
🎯 ¿Qué es y por qué es crítico?
Una Progressive Web App (PWA) permite a tu web instalarse como una app nativa, enviar notificaciones push y funcionar sin conexión a internet. Una arquitectura Offline-First garantiza que el usuario pueda interactuar con la app en el metro, en un avión o con mala señal, y sincronizar los datos automáticamente cuando recupere la conexión.
[REQUIRED] REGLA: Toda app SaaS en producción DEBE funcionar sin conexión básica (caché). Si permite edición, DEBE encolar las operaciones y sincronizarlas en background cuando vuelva la red.
Por qué: una app que se congela por completo al perder conexión trata la red como si nunca fallara, y en móvil la red falla constantemente — túneles, ascensores, zonas rurales. Encolar las operaciones y sincronizar al volver la conexión es lo que separa una app resiliente de una que castiga al usuario por su cobertura.
⚙️ 1. MANIFEST (El ADN de la App)
El archivo manifest.json va en la raíz de public/ y le dice al móvil/desktop cómo instalarse.
{
"name": "OmniSuite Enterprise",
"short_name": "OmniSuite",
"description": "Tu suite de gestión offline-first.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2563eb",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}🧠 2. SERVICE WORKER CON WORKBOX (Estrategias de Caché)
Usamos Workbox de Google para no lidiar con la API cruda del Service Worker.
service-worker.js
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { BackgroundSyncPlugin } from 'workbox-background-sync';
// 1. CACHE FIRST (Para Assets Estáticos: JS, CSS, Fuentes)
// Si está en caché lo sirve instantáneo. Solo va a red si no existe.
registerRoute(
({ request }) => request.destination === 'script' || request.destination === 'style' || request.destination === 'font',
new CacheFirst({
cacheName: 'static-assets',
plugins: [
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 }), // 30 días
],
})
);
// 2. STALE WHILE REVALIDATE (Para Imágenes y Avatars)
// Sirve caché instantáneo, pero revalida en background para actualizar en el próximo reload
registerRoute(
({ request }) => request.destination === 'image',
new StaleWhileRevalidate({ cacheName: 'images' })
);
// 3. NETWORK FIRST (Para llamadas API /fetch)
// Siempre busca la API fresca. Si estás sin red, devuelve el caché anterior de la DB.
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-responses',
networkTimeoutSeconds: 3, // Si tarda >3s asume offline y sirve caché
})
);
// 4. BACKGROUND SYNC (Cola de mutaciones offline)
// Si intentas hacer un POST/PUT sin red, lo guarda y lo envía al reconectar
const bgSyncPlugin = new BackgroundSyncPlugin('offline-mutations-queue', {
maxRetentionTime: 24 * 60 // Reintentar hasta por 24 horas
});
registerRoute(
({ request }) => request.method === 'POST' || request.method === 'PUT',
new NetworkFirst({ plugins: [bgSyncPlugin] })
);💻 3. LÓGICA EN FRONTEND (React)
En el frontend detectamos el estado de la red y gestionamos el banner de instalación.
import { useState, useEffect } from 'react'
import Dexie from 'dexie'
// 1. Base de datos local (IndexedDB) para guardar borradores complejos offline
export const localDb = new Dexie('OmniSuiteLocalDB')
localDb.version(1).stores({
drafts: 'id, type, payload, updated_at' // Primary key 'id'
})
// `beforeinstallprompt` no está en lib.dom.d.ts — se declara su forma mínima (FE-001: nada de `any`)
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
export function PwaManager() {
const [isOffline, setIsOffline] = useState(!navigator.onLine)
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null)
useEffect(() => {
// 1. Registro del Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
})
}
// 2. Monitoreo de Red (Online / Offline)
const handleOnline = () => setIsOffline(false)
const handleOffline = () => setIsOffline(true)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
// 3. Capturar el prompt de Instalación (Añadir a Pantalla de Inicio)
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
setInstallPrompt(e as BeforeInstallPromptEvent)
})
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
// Disparar la instalación manual
const handleInstallClick = async () => {
if (!installPrompt) return
installPrompt.prompt()
const { outcome } = await installPrompt.userChoice
if (outcome === 'accepted') setInstallPrompt(null)
}
return (
<>
{/* BANNER OFFLINE */}
{isOffline && (
<div className="fixed bottom-0 w-full bg-red-600 text-white text-center py-2 z-50">
⚠️ Estás desconectado. Los cambios se guardarán localmente y se sincronizarán al volver.
</div>
)}
{/* BOTÓN INSTALAR APP */}
{installPrompt && !isOffline && (
<button
onClick={handleInstallClick}
className="fixed top-4 right-4 bg-blue-600 text-white px-4 py-2 rounded-full shadow-lg"
>
⬇️ Instalar App Nativamente
</button>
)}
</>
)
}🔔 4. NOTIFICACIONES PUSH NATIVAS
Un PWA puede recibir notificaciones Push (Web Push API) incluso cuando está cerrado.
// En frontend: Pedir permiso
async function requestPushPermission() {
const permission = await Notification.requestPermission()
if (permission === 'granted') {
const swRegistration = await navigator.serviceWorker.ready
// Suscribirse al Push Service de Google/Mozilla
const subscription = await swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: 'TU_VAPID_PUBLIC_KEY' // Generado en el backend
})
// Enviar el token (subscription) a tu base de datos para guardarlo en el Perfil del usuario
await fetch('/api/user/push-subscription', {
method: 'POST',
body: JSON.stringify(subscription)
})
}
}
// En service-worker.js: Escuchar la llegada del Push y mostrarlo
self.addEventListener('push', (event) => {
const data = event.data.json()
const options = {
body: data.body,
icon: '/icons/icon-192x192.png',
badge: '/icons/badge.png',
data: { url: data.url } // Guardar URL para abrir al clickear
}
event.waitUntil(
self.registration.showNotification(data.title, options)
)
})
// Clic en la notificación: Abrir la PWA en esa ruta
self.addEventListener('notificationclick', (event) => {
event.notification.close()
event.waitUntil(
clients.openWindow(event.notification.data.url)
)
})