Ir para o conteúdo

PWA e offline

O que você vai aprender

Como transformar seu app num PWA instalável e offline — service worker, fila de mutações durável e WebPush ponta a ponta — com o mínimo de código.

A camada PWA / offline-first / WebPush (Trilho P) torna seu app instalável e capaz de rodar sem rede. Ela é mais turnkey no Modo C (transpile): como o bundle é 100% estático, o build --mode transpile já emite a PWA inteira sozinho. 📱

PWA de fábrica no Modo C

Comece uma PWA em um comando:

tempestweb new meuapp --template pwa

Isso gera um projeto Modo C já configurado (mode = "transpile" + bloco [pwa]) com um contador e um botão Install. Um tempestweb build --mode transpile emite o manifest, os ícones, o sw.js (service worker cache-first) e o register.js — sem você escrever uma linha de plumbing. 🚀

As quatro peças

  • Instalável (P0)


    Manifest + ícones + prompt de instalação. O app entra na tela inicial como um nativo.

  • Service worker (P1)


    Precache do app-shell → offline após o 1º load + update lifecycle ("nova versão, recarregar").

  • Offline-first (P2)


    Fila de mutações durável no IndexedDB + replay no reconnect (Background Sync).

  • WebPush (P3)


    Subscribe no browser (native.notifications); envio via webpush_router (VAPID) no servidor.

P0 — App instalável

O prompt de instalação é exposto ao Python pela capacidade native.install. O controlador já suprime o mini-infobar frio do browser e guarda o evento beforeinstallprompt, então você mostra um botão "Instalar" no momento certo:

from tempestweb import native


async def maybe_show_install_button() -> bool:
    """Return whether an Install button should be shown."""
    state = await native.install.state()   # InstallState(can_install, installed)
    return state.can_install and not state.installed


async def on_install_tap() -> None:
    """Fire the native install prompt from a button handler."""
    outcome = await native.install.prompt()   # "accepted" | "dismissed" | "unavailable"

Chame o prompt depois de um gesto do usuário

Os browsers só permitem install.prompt() a partir de um gesto real (clique). Renderize o botão quando can_install for verdadeiro e dispare o prompt no on_click.

Método de instalação + cooldown de recusa (adotado do famachapp)

state.method classifica como o usuário instala aqui: "native" (há prompt — mostre o botão), "ios" (Share → "Adicionar à Tela de Início" — mostre um tutorial) ou "manual" (ex.: Firefox desktop). Assim a UI não mostra um botão que não faz nada no iOS.

Do lado JS, client/pwa/install-prompt.js traz um cooldown de recusa pra não insistir: recordInstallDecline() quando o usuário fecha o banner e canPromptInstall() (default 7 dias) antes de reexibir.

import { recordInstallDecline, canPromptInstall } from "/client/pwa/install-prompt.js";

if (canPromptInstall()) showInstallBanner();
// ... no "agora não":
recordInstallDecline();

Redirect pós-instalação

client/pwa/post-install-redirect.js mostra um overlay em tela cheia quando o appinstalled dispara (a aba que instalou ainda é uma aba comum — o usuário deve ir pro app standalone). Opt-in:

import { mountPostInstallRedirect } from "/client/pwa/post-install-redirect.js";
mountPostInstallRedirect();   // no-op se já estiver rodando standalone

P1 — Service worker: offline após o 1º load

No Modo C, o sw.js gerado pré-cacheia o bundle estático inteiroindex.html, o cliente compartilhado, o seu app.gen.js, os ícones e o manifest. Depois da primeira carga, o app abre e roda sem rede.

Offline de verdade ✅

Com o servidor HTTP desligado, recarregar a página ainda renderiza o app e a navegação continua funcionando — verificado ao vivo no Playwright. Como o Modo C é um bundle estático sem Python, nada depende do servidor depois do primeiro fetch.

Teste offline com build/run, não com dev

O tempestweb dev não registra o service worker de propósito (ele injeta um kill-switch para você nunca ver bundle cacheado velho — veja Usando a CLI). Ou seja, o comportamento offline só existe no artefato de produção: teste-o com tempestweb build --mode transpile (e sirva o dist/) ou com tempestweb run --mode transpile.

Prompt de atualização (automático)

Quando você publica uma versão nova, o service worker antigo continua no ar até a aba fechar. O shell detecta o worker em espera e mostra um banner discreto "nova versão disponível → Atualizar"; ao confirmar, o worker novo assume e a página recarrega uma vez. Nada a escrever no app.

P2 — Offline-first: fila de mutações durável

Escritas feitas offline sobrevivem. A capacidade native.offline grava cada mutação numa fila durável no IndexedDB (com chave de idempotência) e reaplica em ordem FIFO quando a conexão volta — via o evento online, via Background Sync (aba fechada) ou explicitamente:

from tempestweb import native


async def save_note(text: str) -> None:
    """Persist a note, queueing the write if we are offline."""
    await native.offline.enqueue("POST", "/api/notes", {"text": text})


async def flush_when_online() -> None:
    """Replay any pending mutations in FIFO order."""
    await native.offline.replay()

Inspecione a fila com native.offline.size() e native.offline.pending(). Uma mutação que falha de forma permanente vira dead-letter (native.offline.failed()) e um conflito 409 vai pra lane de conflito (native.offline.conflicts()) — nenhum dos dois trava a fila. Veja Offline + sincronização para o ciclo completo.

Replay precisa de idempotência

Ao voltar a rede, a fila reenvia as mutações. O servidor deduplica pela chave de idempotência, então um replay nunca aplica o efeito duas vezes. É a mesma chave da capacidade native.http.

Cachear binários grandes (modelos, wasm)

Para um asset grande que não vai no precache (ex.: um modelo ONNX baixado da API), client/offline/asset-cache.js faz "baixa uma vez, versiona, atualiza na mudança do manifest, serve cache-first offline" — adotado do padrão de model-sync do famachapp.

import { ensureCached, syncAssets } from "/client/offline/asset-cache.js";

// Baixa uma vez; nas próximas cargas vem do cache (fetches concorrentes deduplicam).
const res = await ensureCached("/models/detect.onnx");

// No boot: re-baixa só se o manifest de versão mudou; devolve { refreshed }.
const { refreshed } = await syncAssets({
  version: manifest.version,
  assets: [{ url: "/models/detect.onnx" }, { url: "/models/classify.onnx" }],
});
if (refreshed) resetOnnxSessions();   // invalide os handles em memória

Warmup + reset no refresh

Chame syncAssets() no boot (quando online) e, se ele retornar refreshed: true, descarte as sessões ONNX/Pyodide em memória para a próxima inferência usar os bytes novos — sem exigir reload.

P3 — WebPush ponta a ponta

O browser cria a assinatura; o servidor envia. Os dois lados usam a chave VAPID que prova ao serviço de push do navegador que o envio é legítimo.

No cliente — native.notifications

from tempestweb import native


async def enable_push(vapid_public_key: str) -> None:
    """Ask for permission and subscribe the browser to WebPush."""
    state = await native.notifications.push_state()   # {supported, permission}
    if not state.supported:
        return
    await native.notifications.request_permission()
    sub = await native.notifications.subscribe(vapid_public_key)
    # Envie `sub` (JSON da assinatura) ao seu backend — via native.http
    # ou enfileirado com native.offline. O framework não decide seu schema.
    await native.http.request("POST", "/webpush/subscribe", json=sub)

No servidor — tempestweb vapid + webpush_router

Gere o par de chaves VAPID uma vez com o CLI e monte o roteador pronto:

tempestweb vapid --env   # imprime VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY
from fastapi import FastAPI

from tempestweb.server import WebPushService, webpush_router

app = FastAPI()
service = WebPushService()                       # lê as chaves de VAPID_* no ambiente
app.include_router(webpush_router(service))       # /webpush/subscribe, /send, …

O webpush_router já expõe os endpoints de assinatura e envio; o WebPushService guarda as assinaturas e dispara os envios assinados via tempest-fastapi-sdk[webpush] (pywebpush).

iOS/Safari exige PWA instalada

No iOS (16.4+), o WebPush só funciona com o PWA instalado na tela inicial. Em browsers desktop e Android funciona sem instalar. Teste em device real — veja Verificação manual.

O fluxo completo tem uma página só pra ele

O exemplo WebPush ponta a ponta (servidor) percorre a geração de chaves, o roteador, a assinatura e o envio, passo a passo, com um diagrama de sequência.

Configurando o manifest com [pwa]

Os metadados de instalação vêm de uma seção opcional [pwa] no seu tempestweb.toml. Todos os campos são opcionais — sem a seção, o build usa padrões sensatos derivados do nome do projeto:

[pwa]
name = "Weather Pro"
short_name = "WPro"
theme_color = "#0a84ff"
display = "standalone"

Os campos completos estão documentados na página Modo C — transpile.

Desligando o PWA (enabled, manifest, service_worker)

Nem toda app quer precache offline. O caso claro é o painel de administração atrás de login, servido por um control plane: ele não ganha nada com offline — o usuário sempre tem rede quando usa — e paga por isso duas vezes.

O que o worker custa quando você não precisa dele

  • Asset antigo depois de um deploy. O worker serve a shell do precache até se atualizar, então o primeiro load pós-deploy pode ser da versão anterior.
  • Disputa de conexão no primeiro load. O precache busca ~90 arquivos enquanto o Pyodide (Modo A) ainda está subindo.

As duas metades do layer são eixos separados, porque são úteis apart:

[pwa]
enabled = false

Isso é o atalho: desliga as duas. Nenhum manifest.webmanifest, nenhum register.js, nenhum <link rel="manifest"> e nenhum registro de worker no index.html.

Para desligar só uma:

[pwa]
service_worker = false   # sem precache, mas o app continua instalável
[pwa]
manifest = false         # com precache, sem prompt de instalação

enabled é o default que as duas metades seguem; nomear uma metade explicitamente ganha dele. Então "desligado por padrão, exceto o manifest" se escreve assim:

[pwa]
enabled = false
manifest = true

O campo tem que ser booleano de verdade

service_worker = "false" (com aspas) é recusado no build. String não-vazia é truthy em Python, e um switch cujo trabalho é desligar algo não pode fazer o oposto do que se lê.

Desligar não é o mesmo que nunca ter ligado

Quem já visitou o app já tem o worker registrado, e um worker registrado continua servindo a shell do precache até ser substituído. Simplesmente parar de emitir sw.js deixaria essas pessoas presas ao build antigo, sem nada no deploy capaz de alcançá-las.

Por isso o build continua emitindo sw.js quando você desliga o worker — só que um worker diferente: ele limpa todo cache do origin, se desregistra e recarrega as páginas que controlava. Roda uma vez por browser que ainda tinha o worker antigo, e some.

[pwa]
service_worker = false
Arquivo Com o worker ligado Desligado
sw.js worker cache-first worker de teardown
register.js emitido não emitido
registro no index.html presente ausente
manifest.webmanifest emitido segue manifest
ícones emitidos emitidos (favicon e apple-touch)

O banner de conectividade continua

Ele reporta a rede, não o precache, então uma app sem worker continua avisando o usuário quando cai o sinal.

Verificação manual

O que exige device/browser real

Algumas garantias de PWA não dá para automatizar 100%; confirme à mão:

  • Instalar o app a partir do prompt e abrir da tela inicial.
  • Desligar a rede e confirmar que o 2º load abre o app (offline).
  • Receber uma notificação WebPush — no iOS, com o PWA instalado.

Recap

  • A PWA é mais turnkey no Modo C: build --mode transpile emite manifest, ícones e service worker sozinho.
  • Instalável (P0) via native.install; offline após o 1º load (P1) via o service worker que pré-cacheia o bundle.
  • O runtime offline (P2) usa fila de mutações no IndexedDB com chave de idempotência (native.offline).
  • WebPush (P3): native.notifications.subscribe no cliente; tempestweb vapid
  • webpush_router no servidor.
  • Alguns testes de PWA exigem device real — veja a verificação manual.
  • [pwa] enabled = false desliga o layer inteiro; manifest e service_worker desligam uma metade só. Desligar o worker emite o worker de teardown, para quem já registrou não ficar preso ao precache velho.

Para a saúde em produção, veja Observabilidade. 🚀