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:
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 viawebpush_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 inteiro —
index.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:
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:
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:
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:
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:
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.
| 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 transpileemite 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.subscribeno cliente;tempestweb vapid webpush_routerno servidor.- Alguns testes de PWA exigem device real — veja a verificação manual.
[pwa] enabled = falsedesliga o layer inteiro;manifesteservice_workerdesligam 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. 🚀