Guia de Implementação PWA - Experiência Nativa com Progressive Web Apps

Intermediário | 2025.12.02

O que é PWA

PWA (Progressive Web App) é uma tecnologia que oferece experiência similar a apps nativos para aplicações web. Permite funcionamento offline, notificações push e instalação na tela inicial.

CaracterísticaFuncionalidade
InstalávelAdicionar ícone na tela inicial, sem app store, exibição em tela cheia
Suporte OfflineCache via Service Worker, funciona sem rede, sincronização em background
Notificações PushRecebe notificações mesmo com navegador fechado, melhora engajamento, re-engajamento

Web App Manifest

// public/manifest.json
{
  "name": "My Progressive Web App",
  "short_name": "MyPWA",
  "description": "An example Progressive Web App",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#3b82f6",
  "orientation": "portrait-primary",
  "scope": "/",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "shortcuts": [
    {
      "name": "Criar Novo",
      "short_name": "Criar",
      "description": "Criar um novo item",
      "url": "/new",
      "icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide"
    },
    {
      "src": "/screenshots/mobile.png",
      "sizes": "750x1334",
      "type": "image/png",
      "form_factor": "narrow"
    }
  ]
}
<!-- index.html -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My PWA</title>

  <!-- Tags obrigatórias para PWA -->
  <link rel="manifest" href="/manifest.json">
  <meta name="theme-color" content="#3b82f6">

  <!-- Suporte iOS Safari -->
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="default">
  <meta name="apple-mobile-web-app-title" content="MyPWA">
  <link rel="apple-touch-icon" href="/icons/icon-192x192.png">

  <!-- Suporte Windows -->
  <meta name="msapplication-TileColor" content="#3b82f6">
  <meta name="msapplication-TileImage" content="/icons/icon-144x144.png">
</head>
<body>
  <div id="app"></div>
  <script src="/app.js" type="module"></script>
</body>
</html>

Service Worker

Service Worker Básico

// public/sw.js
const CACHE_NAME = 'my-pwa-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/app.js',
  '/styles.css',
  '/icons/icon-192x192.png',
  '/offline.html',
];

// Cachear na instalação
self.addEventListener('install', (event: ExtendableEvent) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      console.log('Caching static assets');
      return cache.addAll(STATIC_ASSETS);
    })
  );
  // Ativar imediatamente
  self.skipWaiting();
});

// Deletar caches antigos na ativação
self.addEventListener('activate', (event: ExtendableEvent) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      );
    })
  );
  // Controlar todos os clientes imediatamente
  self.clients.claim();
});

// Estratégia de fetch
self.addEventListener('fetch', (event: FetchEvent) => {
  const { request } = event;

  // Requisições de API são network first
  if (request.url.includes('/api/')) {
    event.respondWith(networkFirst(request));
    return;
  }

  // Assets estáticos são cache first
  event.respondWith(cacheFirst(request));
});

// Estratégia Cache First
async function cacheFirst(request: Request): Promise<Response> {
  const cached = await caches.match(request);
  if (cached) {
    return cached;
  }

  try {
    const response = await fetch(request);
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
    return response;
  } catch (error) {
    // Fallback offline
    const offlinePage = await caches.match('/offline.html');
    return offlinePage || new Response('Offline', { status: 503 });
  }
}

// Estratégia Network First
async function networkFirst(request: Request): Promise<Response> {
  try {
    const response = await fetch(request);
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
    return response;
  } catch (error) {
    const cached = await caches.match(request);
    if (cached) {
      return cached;
    }
    return new Response(JSON.stringify({ error: 'Offline' }), {
      status: 503,
      headers: { 'Content-Type': 'application/json' },
    });
  }
}

Registro do Service Worker

// src/registerSW.ts
export async function registerServiceWorker(): Promise<void> {
  if (!('serviceWorker' in navigator)) {
    console.log('Service Worker not supported');
    return;
  }

  try {
    const registration = await navigator.serviceWorker.register('/sw.js', {
      scope: '/',
    });

    console.log('Service Worker registered:', registration.scope);

    // Verificar atualizações
    registration.addEventListener('updatefound', () => {
      const newWorker = registration.installing;
      if (!newWorker) return;

      newWorker.addEventListener('statechange', () => {
        if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
          // Nova versão disponível
          showUpdateNotification();
        }
      });
    });

    // Verificar atualizações periodicamente
    setInterval(() => {
      registration.update();
    }, 60 * 60 * 1000); // A cada 1 hora

  } catch (error) {
    console.error('Service Worker registration failed:', error);
  }
}

function showUpdateNotification(): void {
  if (confirm('Nova versão disponível. Deseja atualizar?')) {
    window.location.reload();
  }
}

Estratégias de Cache

EstratégiaFluxoUso
Cache FirstRequest -> Cache -> (miss) -> Network -> CacheAssets estáticos, conteúdo com baixa frequência de mudança
Network FirstRequest -> Network -> (fail) -> CacheRequisições de API, quando dados atuais são necessários
Stale While RevalidateRequest -> Cache (retorno imediato) + Atualização via NetworkAvatares de usuário, dados de configuração
Cache OnlyRequest -> Cache apenasConteúdo exclusivo offline
Network OnlyRequest -> Network apenasAutenticação, pagamentos, dados em tempo real

Implementação Stale While Revalidate

// sw.js
async function staleWhileRevalidate(request: Request): Promise<Response> {
  const cache = await caches.open(CACHE_NAME);
  const cached = await cache.match(request);

  // Atualizar em background
  const fetchPromise = fetch(request).then((response) => {
    cache.put(request, response.clone());
    return response;
  });

  // Se há cache, retorna imediatamente; senão, aguarda network
  return cached || fetchPromise;
}

Notificações Push

Lado do Servidor (Node.js)

// server/push.ts
import webpush from 'web-push';

// Geração de chaves VAPID (apenas primeira vez)
// const vapidKeys = webpush.generateVAPIDKeys();

const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY!;
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY!;

webpush.setVapidDetails(
  'mailto:admin@example.com',
  VAPID_PUBLIC_KEY,
  VAPID_PRIVATE_KEY
);

interface PushSubscription {
  endpoint: string;
  keys: {
    p256dh: string;
    auth: string;
  };
}

export async function sendPushNotification(
  subscription: PushSubscription,
  payload: {
    title: string;
    body: string;
    icon?: string;
    badge?: string;
    url?: string;
  }
): Promise<void> {
  try {
    await webpush.sendNotification(
      subscription,
      JSON.stringify(payload)
    );
    console.log('Push notification sent');
  } catch (error: any) {
    if (error.statusCode === 410) {
      // Assinatura inválida
      console.log('Subscription expired');
      // Deletar do DB
    }
    throw error;
  }
}

Lado do Cliente

// src/pushNotifications.ts
const VAPID_PUBLIC_KEY = 'YOUR_VAPID_PUBLIC_KEY';

export async function subscribeToPush(): Promise<PushSubscription | null> {
  if (!('PushManager' in window)) {
    console.log('Push notifications not supported');
    return null;
  }

  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    console.log('Notification permission denied');
    return null;
  }

  const registration = await navigator.serviceWorker.ready;

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
  });

  // Enviar informação de assinatura para o servidor
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });

  return subscription;
}

function urlBase64ToUint8Array(base64String: string): Uint8Array {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  const rawData = window.atob(base64);
  return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}

Recebimento no Service Worker

// sw.js
self.addEventListener('push', (event: PushEvent) => {
  const data = event.data?.json() ?? {
    title: 'Nova notificação',
    body: 'Você tem uma nova mensagem',
  };

  const options: NotificationOptions = {
    body: data.body,
    icon: data.icon || '/icons/icon-192x192.png',
    badge: data.badge || '/icons/badge-72x72.png',
    vibrate: [100, 50, 100],
    data: {
      url: data.url || '/',
    },
    actions: [
      { action: 'open', title: 'Abrir' },
      { action: 'close', title: 'Fechar' },
    ],
  };

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

self.addEventListener('notificationclick', (event: NotificationEvent) => {
  event.notification.close();

  if (event.action === 'close') {
    return;
  }

  const url = event.notification.data?.url || '/';

  event.waitUntil(
    clients.matchAll({ type: 'window' }).then((windowClients) => {
      // Se há janela existente, dar foco
      for (const client of windowClients) {
        if (client.url === url && 'focus' in client) {
          return client.focus();
        }
      }
      // Se não, abrir nova janela
      return clients.openWindow(url);
    })
  );
});

Prompt de Instalação

// src/installPrompt.ts
let deferredPrompt: BeforeInstallPromptEvent | null = null;

interface BeforeInstallPromptEvent extends Event {
  prompt(): Promise<void>;
  userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}

window.addEventListener('beforeinstallprompt', (e: Event) => {
  // Prevenir prompt padrão
  e.preventDefault();
  deferredPrompt = e as BeforeInstallPromptEvent;

  // Exibir botão de instalação customizado
  showInstallButton();
});

function showInstallButton(): void {
  const installButton = document.getElementById('install-button');
  if (installButton) {
    installButton.style.display = 'block';
    installButton.addEventListener('click', handleInstallClick);
  }
}

async function handleInstallClick(): Promise<void> {
  if (!deferredPrompt) return;

  // Exibir prompt de instalação
  deferredPrompt.prompt();

  const { outcome } = await deferredPrompt.userChoice;
  console.log(`User ${outcome} the install prompt`);

  deferredPrompt = null;
  hideInstallButton();
}

function hideInstallButton(): void {
  const installButton = document.getElementById('install-button');
  if (installButton) {
    installButton.style.display = 'none';
  }
}

// Detectar conclusão da instalação
window.addEventListener('appinstalled', () => {
  console.log('PWA was installed');
  hideInstallButton();
});

Sincronização em Background

// sw.js
self.addEventListener('sync', (event: SyncEvent) => {
  if (event.tag === 'sync-messages') {
    event.waitUntil(syncMessages());
  }
});

async function syncMessages(): Promise<void> {
  // Obter dados salvos offline do IndexedDB
  const pendingMessages = await getPendingMessages();

  for (const message of pendingMessages) {
    try {
      await fetch('/api/messages', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(message),
      });
      await removePendingMessage(message.id);
    } catch (error) {
      console.error('Sync failed:', error);
      throw error; // Tentar novamente
    }
  }
}

// Registro de sincronização no lado do cliente
async function registerBackgroundSync(): Promise<void> {
  const registration = await navigator.serviceWorker.ready;

  if ('sync' in registration) {
    await registration.sync.register('sync-messages');
    console.log('Background sync registered');
  }
}

Workbox (Biblioteca de Simplificação do Service Worker)

// sw.ts (usando Workbox)
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// Manifest de precache gerado no build
precacheAndRoute(self.__WB_MANIFEST);

// Cache de imagens
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 dias
      }),
    ],
  })
);

// Requisições de API
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api',
    networkTimeoutSeconds: 10,
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 24 * 60 * 60, // 1 dia
      }),
    ],
  })
);

// Cache do Google Fonts
registerRoute(
  ({ url }) => url.origin === 'https://fonts.googleapis.com',
  new StaleWhileRevalidate({
    cacheName: 'google-fonts-stylesheets',
  })
);

Checklist PWA

CategoriaItem de Verificação
ObrigatórioSuporte HTTPS
ObrigatórioRegistro de Service Worker
ObrigatórioWeb App Manifest
RecomendadoFallback offline
RecomendadoÍcone 192x192 ou maior
RecomendadoConfiguração de theme color
OpcionalNotificações push
OpcionalSincronização em background
← Voltar para a lista