Web Push + Service Worker
Notificações push no navegador têm duas metades que conversam: a main thread (sua UI pede permissão e cria a inscrição) e a worker thread (o service worker recebe o push e desenha a notificação, mesmo com a aba fechada). O módulo push do tempest-react-sdk cobre as duas metades — a parte browser (permissão, pushManager.subscribe, notificationclick) é do SDK; os endpoints de persistência e o envio do push são do seu app, via callbacks.
Diagrama editável: push-flow.drawio (abra no draw.io).
Como o Web Push funciona (visão de 30 segundos)
- O app pede permissão ao usuário (
Notification.requestPermission()). - Concedida a permissão, o navegador cria uma
PushSubscriptionassinada com a sua chave VAPID pública. - O app envia o JSON da inscrição ao seu backend, que o guarda.
- Mais tarde, o backend usa a chave VAPID privada para mandar um push pro endpoint da inscrição.
- O service worker acorda no evento
push, lê o payload e chamashowNotification.
O SDK te dá os passos 1–3 (e os handlers do passo 5); os passos 3 (storage) e 4 (envio) são responsabilidade do backend.
O que é VAPID, em uma frase
VAPID (Voluntary Application Server Identification) é um par de chaves
(pública + privada) que identifica o seu servidor para o push service do
navegador. A pública vai no front (VITE_VAPID_PUBLIC_KEY); a
privada fica só no backend e nunca é exposta. Gere o par uma vez com
npx web-push generate-vapid-keys.
Pré-requisitos
- Backend que armazena
PushSubscriptionJSONe envia notificações via web-push (VAPID). - Service worker registrado (
vite-plugin-pwa,registerServiceWorker, ounavigator.serviceWorker.register). - Variável
VITE_VAPID_PUBLIC_KEYno front (chave pública VAPID URL-safe base64). - HTTPS, ou
localhost. Service worker e Push API não existem em origem insegura — nem em IP de rede local (http://192.168.0.10:5173) enquanto você testa no celular. Pra isso, use um túnel HTTPS (cloudflared,ngrok).
O hook NÃO registra o service worker
usePushSubscription assume que o SW já está registrado e usa
navigator.serviceWorker.ready por padrão. Registre o SW você mesmo (passo
abaixo) — ou passe getRegistration para reusar uma registration própria.
Sem SW registrado, o subscribe() nunca resolve.
Adotando num app que já existe
Esta seção é pro caso mais comum: o app já está no ar e você vai ligar push
agora. Nada aqui exige adotar o scaffold, o createViteConfig ou virar PWA.
Checklist, na ordem:
- Gerar o par VAPID e colocar a pública no front.
- Ter um service worker servido na raiz (três cenários abaixo).
- Instalar os handlers de push dentro desse SW.
- Combinar o contrato dos dois endpoints com o backend.
- Ligar o
usePushSubscriptionnum botão. - Amarrar
subscribe/unsubscribeao login e logout — o passo que quase todo mundo esquece.
1. Chaves VAPID
npx web-push generate-vapid-keys
# .env — só a pública vai pro front
VITE_VAPID_PUBLIC_KEY=BOxx…
A privada fica no backend. Trocar esse par depois invalida todas as inscrições existentes — veja rotação de chave.
2. O service worker: três cenários
O arquivo do SW tem que ser servido na raiz do escopo
Um service worker só controla páginas dentro do próprio caminho: um
/assets/sw-abc123.js controla /assets/… e mais nada — então
navigator.serviceWorker.ready nunca resolve na sua home e o subscribe()
fica pendurado pra sempre, sem erro no console.
É o modo de falhar mais comum ao ligar push num app com bundler: o SW não
pode passar pelo pipeline de assets com hash. Ele precisa sair em /sw.js
(ou receber Service-Worker-Allowed: / no header). Confira em
DevTools → Application → Service workers: o campo Scope tem que ser /.
Crie src/sw.ts e bunde ele separado do app, porque o entry do app
passa pelo pipeline de assets e o SW não pode:
/// <reference lib="webworker" />
import {
installNotificationClickHandler,
installPushHandler,
installSkipWaitingListener,
} from "tempest-react-sdk/sw";
declare const self: ServiceWorkerGlobalScope;
installSkipWaitingListener();
installPushHandler({ defaultTitle: "Minha App", defaultIcon: "/icons/logo.png" });
installNotificationClickHandler();
// vite.sw.config.ts — build só do worker, em dist/sw.js
import { resolve } from "node:path";
import { defineConfig } from "vite";
export default defineConfig({
build: {
emptyOutDir: false, // não apaga o dist/ do app
lib: {
entry: resolve(__dirname, "src/sw.ts"),
formats: ["iife"], // worker clássico, sem import/export
name: "sw",
fileName: () => "sw.js",
},
rollupOptions: { output: { entryFileNames: "sw.js", inlineDynamicImports: true } },
},
});
{
"scripts": {
"build": "vite build && npm run build:sw",
"build:sw": "vite build --config vite.sw.config.ts"
}
}
Registre no entry do app:
// src/main.tsx
import { registerServiceWorker } from "tempest-react-sdk";
registerServiceWorker({ url: "/sw.js" });
Sem passo de build, se você preferir
Um public/sw.js escrito na mão também funciona — arquivos de public/
são copiados crus pra raiz do dist/. O custo é não poder import os
helpers do SDK ali dentro: você escreve os listeners de push e
notificationclick à mão. Vale pro caso simples; passando disso, bunde.
Só o modo injectManifest deixa você escrever o SW. Se o seu está em
generateSW (o default), troque:
// vite.config.ts
VitePWA({
strategies: "injectManifest",
srcDir: "src",
filename: "sw.ts",
injectRegister: "auto",
});
E no src/sw.ts, junte os handlers do SDK aos seus do Workbox — eles não
competem, são eventos diferentes (push/notificationclick de um lado,
fetch do outro):
/// <reference lib="webworker" />
import { precacheAndRoute } from "workbox-precaching";
import { installNotificationClickHandler, installPushHandler } from "tempest-react-sdk/sw";
declare const self: ServiceWorkerGlobalScope & { __WB_MANIFEST: unknown[] };
precacheAndRoute(self.__WB_MANIFEST); // o que o plugin já fazia
installPushHandler({ defaultTitle: "Minha App" });
installNotificationClickHandler();
Mantenha o registro do plugin (virtual:pwa-register) — não chame
registerServiceWorker também: dois registros do mesmo arquivo brigam pelo
ciclo de update.
Duas linhas dentro do que já existe, e nada do seu código muda:
import { installNotificationClickHandler, installPushHandler } from "tempest-react-sdk/sw";
installPushHandler({ defaultTitle: "Minha App" });
installNotificationClickHandler();
Já tem um addEventListener("push", …) seu? Escolha um dos dois: os dois
handlers rodando mostram duas notificações para o mesmo push, porque cada
listener chama showNotification.
Se você registra o SW por conta própria e quer reusar aquela registration em
vez do navigator.serviceWorker.ready, passe getRegistration:
const registration = await navigator.serviceWorker.register("/sw.js");
usePushSubscription({
vapidPublicKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
getRegistration: async () => registration,
onSubscribe: (sub) => api.post("/webpush/subscribe", { body: sub }),
});
3. O contrato com o backend
O SDK não escolhe rota, verbo nem formato — ele te entrega o
PushSubscriptionJSON e você decide. É exatamente isto que chega no
onSubscribe:
{
"endpoint": "https://fcm.googleapis.com/fcm/send/dQw4w9Wg...",
"expirationTime": null,
"keys": {
"p256dh": "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM=",
"auth": "tBHItJI5svbpez7KI4CCXg=="
}
}
Duas rotas resolvem o ciclo — e a chave natural é o endpoint, não o usuário:
| Rota | Quando | Corpo |
|---|---|---|
POST /webpush/subscribe |
inscreveu (ou re-sincronizou) | o JSON acima + o usuário logado, vindo do token |
DELETE /webpush/subscribe |
desinscreveu | { "endpoint": "…" } |
Um usuário tem N inscrições, uma por navegador
Celular, notebook do trabalho, Chrome e Firefox na mesma máquina: cada um é
uma inscrição com endpoint próprio. Se o backend guarda uma inscrição por
usuário (UPDATE … WHERE user_id = ?), cada novo dispositivo desliga o
anterior em silêncio — o usuário instala no celular e para de receber no
desktop, sem nada explicando por quê.
Guarde uma linha por endpoint, com UNIQUE(endpoint) e user_id indexado.
O POST é upsert por endpoint: o mesmo navegador reenviando a mesma
inscrição (o que acontece a cada subscribe() — veja
re-sincronização) não pode
criar linha duplicada.
Main-thread
Registrar o SW
import { registerServiceWorker, skipWaiting } from "tempest-react-sdk";
registerServiceWorker({
url: "/sw.js",
onUpdate: (waiting) => {
if (confirm("Nova versão disponível. Recarregar?")) {
skipWaiting(waiting);
window.location.reload();
}
},
});
Atualização automática (sem vite-plugin-pwa)
Se você prefere que cada deploy chegue sozinho ao usuário — sem prompt de "recarregar?" — ligue autoUpdate. O helper passa a chamar registration.update() num intervalo (updateIntervalMs, padrão 1h) e recarrega a página assim que um novo worker assume o controle (controllerchange), com guarda contra loop de reload. É o comportamento auto-update do vite-plugin-pwa, mas implementado direto sobre navigator.serviceWorker, sem depender dele:
import { registerServiceWorker } from "tempest-react-sdk";
registerServiceWorker({
url: "/sw.js",
autoUpdate: true, // poll + reload no controllerchange
updateIntervalMs: 60 * 60 * 1000, // 1h (padrão)
reloadOnActivate: true, // padrão; use `false` para só fazer o poll
});
Deixe reloadOnActivate: false quando quiser continuar o poll mas controlar o reload você mesmo (ex.: exibir um toast antes).
Inscrever o usuário (com o hook)
import { usePushSubscription, Button } from "tempest-react-sdk";
const push = usePushSubscription({
vapidPublicKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
onSubscribe: (sub) => api.post("/webpush/subscribe", { body: sub }),
onUnsubscribe: (sub) => api.delete("/webpush/subscribe", { body: { endpoint: sub.endpoint } }),
});
<Button loading={push.loading} onClick={() => push.subscribe()}>
{push.subscribed ? "Desinscrever" : "Receber notificações"}
</Button>;
O hook expõe supported, permission, subscribed, loading, error, subscribe(), unsubscribe() e refresh(). Versão imperativa: WebPushClient. Erros tipados: WebPushUnsupportedError, WebPushPermissionDeniedError.
O que subscribe() faz, passo a passo
Notification.requestPermission()— o prompt do navegador. Recusa lançaWebPushPermissionDeniedError.- Pega a registration (
navigator.serviceWorker.ready, ou o seugetRegistration). - Se já existe inscrição neste navegador, reusa ela e chama
onSubscribede novo. - Se não existe, cria com
pushManager.subscribe({ userVisibleOnly: true, applicationServerKey })e chamaonSubscribe.
O que acontece quando já existe inscrição
O passo 3 é de propósito, e é o que faz o botão servir de re-sincronização:
chamar subscribe() num navegador que já está inscrito não cria inscrição nova —
reenvia a mesma pro seu backend. Serve pra recuperar o caso em que o banco perdeu
a linha (restore de backup, migration, troca de ambiente) e o navegador continua
inscrito: sem o reenvio, esse dispositivo ficaria inscrito no navegador e
desconhecido no servidor, ou seja, mudo pra sempre.
O preço é do lado do backend: o POST precisa ser upsert por endpoint. Se
for insert cego, o mesmo dispositivo vira duas linhas e o usuário recebe a mesma
notificação duas vezes.
subscribed começa false, sempre
Saber se existe inscrição exige await pushManager.getSubscription(), então o
primeiro render não pode saber a resposta: o hook devolve false e corrige
logo depois. Um botão que lê só subscribed pisca "Receber notificações" antes
de virar "Desinscrever".
Espere o estado assentar antes de decidir o rótulo — loading é false nesse
intervalo, então use uma flag própria de "já checou":
const push = usePushSubscription({ /* … */ });
const [checked, setChecked] = useState(false);
useEffect(() => {
void push.refresh().finally(() => setChecked(true));
}, [push.refresh]);
if (!checked) return <Skeleton height={40} />;
refresh() é pra quando o estado muda fora do seu app
O usuário libera ou bloqueia notificação nas configurações do navegador, ou
remove a inscrição em chrome://settings/content/notifications, e o seu React
não fica sabendo — não existe evento pra isso. Chame refresh() quando a aba
volta a ficar visível:
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === "visible") void push.refresh();
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
}, [push.refresh]);
Fluxo de permissão e inscrição (exemplo completo)
Este componente mostra o estado completo do ciclo de vida — não suportado, permissão negada, inscrito, alternar — e trata o erro de permissão negada:
import { usePushSubscription, WebPushPermissionDeniedError, Button } from "tempest-react-sdk";
import { api } from "./api";
export function PushToggle() {
const push = usePushSubscription({
vapidPublicKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
onSubscribe: (sub) => api.post("/webpush/subscribe", { body: sub }),
onUnsubscribe: (sub) =>
api.delete("/webpush/subscribe", { body: { endpoint: sub.endpoint } }),
});
// 1. Browser sem suporte (iOS Safari fora de PWA, navegadores antigos)
if (!push.supported) {
return <p>Notificações não são suportadas neste navegador.</p>;
}
// 2. Usuário bloqueou nas configurações do navegador
if (push.permission === "denied") {
return <p>Permissão de notificação bloqueada. Libere nas configurações do navegador.</p>;
}
async function handleSubscribe() {
try {
await push.subscribe();
} catch (err) {
if (err instanceof WebPushPermissionDeniedError) {
alert("Você precisa permitir notificações para recebê-las.");
}
}
}
// 3. Alternar inscrição
return (
<Button
loading={push.loading}
onClick={() => (push.subscribed ? push.unsubscribe() : handleSubscribe())}
>
{push.subscribed ? "Desinscrever" : "Receber notificações"}
</Button>
);
}
Desinscrever — o que quase todo mundo erra
unsubscribe() faz duas coisas, nessa ordem:
- chama
onUnsubscribe(subscription)— a sua rota de exclusão; - só então chama
subscription.unsubscribe()no navegador.
A ordem é escolhida: se o backend falhar, a inscrição continua no navegador e
o unsubscribe() lança. É o lado seguro do erro — o oposto (apagar no navegador
primeiro) perderia o endpoint, e sem ele o backend nunca saberia qual linha
apagar. Ficaria mandando push pra um endpoint morto até o push service devolver
410.
Apague pelo endpoint, não pela sessão
const push = usePushSubscription({
vapidPublicKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
onSubscribe: (sub) => api.post("/webpush/subscribe", { body: sub }),
// ✅ o argumento é a inscrição que está saindo — use o endpoint dela
onUnsubscribe: (sub) => api.delete("/webpush/subscribe", { body: { endpoint: sub.endpoint } }),
});
// ❌ ignora o argumento: o backend só sabe "algum dispositivo deste usuário saiu"
onUnsubscribe: () => api.delete("/webpush/my"),
O segundo padrão só funciona se o usuário tiver um dispositivo. Com dois, ou
o backend apaga todos (o celular para de receber porque você desligou no desktop)
ou apaga um qualquer (e o desktop volta a receber sozinho depois). O endpoint
é o que identifica o dispositivo — ele está no argumento justamente pra isso.
Desinscrever não revoga a permissão
Notification.permission continua "granted" depois do unsubscribe(), e não
existe API pra revogar — só o usuário, nas configurações do navegador.
Consequência prática: permission === "granted" não quer dizer inscrito. Quem
decide o rótulo do botão é subscribed; a permission só serve pra saber se
ainda dá pra pedir ("default") ou se está bloqueado de fora ("denied"). Um
segundo subscribe() depois de desinscrever não mostra prompt nenhum — a
permissão já está lá — e volta na hora.
Logout e troca de usuário
Este é o furo que aparece em produção, não em teste. A inscrição pertence ao navegador, não ao usuário: se a Ana faz logout e o Bruno entra no mesmo Chrome, o endpoint continua o mesmo — e continua amarrado à Ana no seu banco. O Bruno passa a receber as notificações da Ana, no aparelho dele, com o app mostrando a conta dele.
Desinscreva no logout, antes de jogar o token fora:
// src/stores/auth.ts — ou onde seu logout mora
import { WebPushClient } from "tempest-react-sdk";
async function logout() {
const push = new WebPushClient({
vapidPublicKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
onSubscribe: () => {},
onUnsubscribe: (sub) =>
api.delete("/webpush/subscribe", { body: { endpoint: sub.endpoint } }),
});
// Sem token, o DELETE volta 401 e a inscrição fica órfã no banco.
await push.unsubscribe().catch(() => {
// Rede caiu: o backend limpa quando o push service devolver 404/410.
});
auth.clear();
}
Se você não quer perder a permissão conquistada
unsubscribe() no logout mantém a permissão (veja acima), então o próximo
login só precisa de um subscribe() — sem prompt, instantâneo. Chame no
sucesso do login, não numa tela de onboarding:
async function onLoginSuccess() {
if (Notification.permission === "granted") await push.subscribe();
}
Isso também cobre o dispositivo que ficou meses fora: um subscribe() por
login re-sincroniza o endpoint com o backend sem incomodar ninguém.
Nunca chame unsubscribe() em beforeunload
Fechar a aba não é sair do app — a inscrição existe pra receber push com o
app fechado. Desinscrever ali desliga o push de todo mundo que fecha a
aba, e o beforeunload não espera promise: o DELETE provavelmente nem sai.
Manter a inscrição viva
Uma inscrição não é eterna, e as três formas de ela morrer são silenciosas.
pushsubscriptionchange: o navegador troca por conta própria
O navegador pode invalidar e recriar a inscrição sozinho — troca de chave interna do push service, reinstalação do app no Android, tempo. O endpoint muda, o seu banco continua com o antigo, e o push simplesmente para de chegar semanas depois de tudo funcionar.
O SDK não tem helper pra isso (o evento existe só dentro do worker e ainda tem
suporte irregular). São 15 linhas no seu sw.ts:
/// <reference lib="webworker" />
import { urlBase64ToUint8Array } from "tempest-react-sdk";
declare const self: ServiceWorkerGlobalScope;
const VAPID_PUBLIC_KEY = "BOxx…"; // a mesma do front
self.addEventListener("pushsubscriptionchange", (event: Event) => {
const change = event as Event & { oldSubscription?: PushSubscription };
event.waitUntil(
(async () => {
const fresh = await self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
await fetch("/webpush/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
subscription: fresh.toJSON(),
replaces: change.oldSubscription?.endpoint ?? null,
}),
});
})(),
);
});
Sem cookie de sessão, o worker precisa de outra forma de autenticar
O fetch do worker não tem o seu token em memória. Se a sua autenticação é
por cookie HttpOnly, ele vai junto e resolve. Se é Authorization: Bearer,
aceite essa rota pelo replaces (o endpoint antigo já identifica o dono) ou
guarde o token em IndexedDB pra ler aqui.
404/410 do push service: apague no backend
Quando o usuário revoga a permissão, limpa os dados do site ou desinstala o PWA,
você não é avisado — o próximo envio devolve 404 Not Found ou 410 Gone. Trate
os dois como "apague esta linha", ou a sua tabela cresce de endpoint morto e cada
envio fica mais lento:
# backend (exemplo com pywebpush)
from pywebpush import WebPushException, webpush
try:
webpush(subscription_info=sub, data=payload, vapid_private_key=KEY, vapid_claims=CLAIMS)
except WebPushException as exc:
if exc.response is not None and exc.response.status_code in (404, 410):
subscriptions.delete(endpoint=sub["endpoint"])
else:
raise
Rotação da chave VAPID
Uma inscrição é assinada com a chave pública que a criou. Trocar o par VAPID
invalida todas: os envios passam a falhar com 403.
E tem uma armadilha aqui: como subscribe() reusa a inscrição existente, chamar
ele depois de trocar a chave não conserta nada — ele reenvia a inscrição
antiga, assinada com a chave velha. O caminho é desinscrever e inscrever de novo:
await push.unsubscribe(); // limpa navegador + backend
await push.subscribe(); // cria com a chave nova (sem prompt: permissão já é granted)
Se você precisa disso pra base inteira, versione a chave no cliente e compare com a que criou a inscrição:
const sub = await client.getSubscription();
const current = sub?.options.applicationServerKey; // ArrayBuffer | null
const stale =
!!current &&
new Uint8Array(current).toString() !==
new Uint8Array(urlBase64ToUint8Array(VAPID_PUBLIC_KEY)).toString();
if (stale) {
await client.unsubscribe();
await client.subscribe();
}
Melhor ainda: não rotacione. Guarde o par VAPID como segredo de longo prazo — ele não identifica usuário nenhum, só o seu servidor.
Versão imperativa — WebPushClient
Quando você precisa do fluxo fora do React (um botão vanilla, um setup script), use a classe direto:
import { WebPushClient } from "tempest-react-sdk";
const client = new WebPushClient({
vapidPublicKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
onSubscribe: async (sub) => {
await fetch("/webpush/subscribe", { method: "POST", body: JSON.stringify(sub) });
},
});
if (WebPushClient.isSupported()) {
await client.subscribe(); // pede permissão + cria inscrição + chama onSubscribe
}
Worker-thread (sw.ts)
Dentro do seu service worker, importe os handlers do subpath tempest-react-sdk/sw:
/// <reference lib="webworker" />
import {
installPushHandler,
installNotificationClickHandler,
installSkipWaitingListener,
} from "tempest-react-sdk/sw";
installSkipWaitingListener();
installPushHandler({
defaultTitle: "Tempest",
defaultIcon: "/icons/Logo.png",
transform: (payload) => (payload.tag === "silent-ping" ? null : payload),
});
installNotificationClickHandler();
Importe de tempest-react-sdk/sw, não do barrel raiz
Os helpers de worker têm um subpath dedicado: tempest-react-sdk/sw. Ele é
puro e sem React — importar daí mantém o bundle do seu sw.ts minúsculo
(~1 KB) e impede que o grafo de componentes do SDK vaze pro escopo do worker.
Importar do barrel raiz (tempest-react-sdk) também funciona graças ao
tree-shaking, mas o subpath é a forma à prova de bala. É exatamente o que o
create-tempest-app --pwa gera.
installPushHandler tenta event.data.json() e cai pra event.data.text(). Use transform pra suprimir (null) ou enriquecer notificações.
installNotificationClickHandler foca o client existente quando a URL bate, ou abre nova janela.
Cache offline mora no mesmo módulo
tempest-react-sdk/sw também exporta installPrecache (app shell offline) e
installRuntimeCache (caching por rota: cache-first / network-first /
stale-while-revalidate). Junto com o plugin tempestPwaManifest() de
tempest-react-sdk/vite, dão paridade com o vite-plugin-pwa no caso comum
— sem dependência nova. É o que o
create-tempest-app --pwa já cabeia.
urlBase64ToUint8Array e isPushSupported são exportados
Você raramente os chama na mão — WebPushClient já usa os dois internamente
(applicationServerKey exige Uint8Array, não a string base64). Eles estão
no barrel para quem precisa de uma checagem de suporte fora do hook
(isPushSupported()) ou de um fluxo de inscrição 100% customizado — inclusive
dentro do worker, que é o caso do handler de
pushsubscriptionchange.
A conversão funciona nos dois escopos a partir da 0.28.1; antes disso ela
chamava window.atob e estourava ReferenceError no worker.
isPushSupported() continua sendo main-thread por definição: ele checa
window, então dentro do worker o resultado é sempre false — lá o suporte
já está provado pelo simples fato de o worker estar rodando.
Inbox na aplicação (NotificationCenter)
Um push mostra uma notificação do sistema e depois desaparece — no que depende da sua UI, ele nunca existiu. O usuário que fechou o toast não tem onde reencontrar aquilo. É a metade que falta do web push: um inbox dentro do app.
O service worker roda fora da página e não pode tocar em estado React. A ponte é uma mensagem:
// src/sw.ts — dentro do seu handler de push
self.addEventListener("push", (event) => {
const payload = event.data?.json() ?? {};
event.waitUntil(
(async () => {
await self.registration.showNotification(payload.title, payload);
const clients = await self.clients.matchAll({ includeUncontrolled: true });
for (const client of clients) {
client.postMessage({ type: "tempest:notification", notification: payload });
}
})(),
);
});
E, no app, useNotificationInbox escuta essa mensagem por default:
import { NotificationCenter, useNotificationInbox, Popover, Button } from "tempest-react-sdk";
export function NotificationsButton() {
const inbox = useNotificationInbox();
return (
<Popover
trigger={
<Button variant="ghost" aria-label={`Notificações (${inbox.unreadCount} não lidas)`}>
🔔 {inbox.unreadCount > 0 && inbox.unreadCount}
</Button>
}
>
<NotificationCenter
items={inbox.items}
onMarkRead={inbox.markRead}
onMarkAllRead={inbox.markAllRead}
onDismiss={inbox.remove}
onSelect={(item) => item.url && navigate(item.url)}
/>
</Popover>
);
}
useNotificationInbox
| Opção | Tipo | Default |
|---|---|---|
initialItems |
NotificationItem[] |
[] |
listenToServiceWorker |
boolean |
true |
messageType |
string |
"tempest:notification" |
limit |
number |
100 |
onChange |
(items: NotificationItem[]) => void |
— |
Retorna { items, unreadCount, add, markRead, markUnread, markAllRead, remove, clear }.
Entrada: { id, title, body?, receivedAt, read?, url?, data? }.
Filtra por type, e isso não é detalhe
O canal de mensagens do service worker é compartilhado — um ping de
progresso de sync ou um aviso de cache atualizado passam pelo mesmo lugar.
Sem filtrar por type, tudo isso apareceria no inbox do usuário.
Persistência é decisão sua
O hook guarda a lista em memória e nada mais: onde um inbox mora (servidor,
Dexie, localStorage) muda por app, e um default errado seria pior que
nenhum. Use onChange pra escrever e initialItems pra ler de volta.
const inbox = useNotificationInbox({
initialItems: restored,
onChange: (items) => storage.set("inbox", items),
});
limit existe porque um inbox alimentado por push cresce sem fim
Default de 100, os mais antigos caem fora. Aumente se você persiste e pagina.
NotificationCenter
| Prop | Tipo | Default |
|---|---|---|
items |
NotificationItem[] |
— |
title |
ReactNode (null remove o cabeçalho) |
"Notificações" |
onSelect |
(item: NotificationItem) => void |
— |
onMarkRead |
(id: string) => void |
— |
onMarkAllRead |
() => void |
— |
onDismiss |
(id: string) => void |
— |
renderIcon |
(item: NotificationItem) => ReactNode |
— |
locale |
"pt-BR" \| "en" |
"pt-BR" |
emptyState |
ReactNode |
<EmptyState …/> |
now |
number (referência dos timestamps) |
agora, no render |
É só o painel, não um popover
Monte dentro do seu Popover, Drawer ou de uma rota própria. Um componente
que fosse dono do inbox e de uma estratégia de posicionamento serviria pra
menos casos, não mais.
Abrir é ler
Ativar uma notificação chama onMarkRead junto com onSelect — senão todo app
teria que lembrar de chamar os dois, e o contador de não lidas continuaria
contando algo que o usuário já viu.
Não lida não é só cor
A linha ganha barra à esquerda e fundo tingido, mais aria-current="true".
Cor sozinha não sobrevive a monocromia nem a daltonismo.
renderIcon casa direto com o subpath de ícones:
import { Icon } from "tempest-react-sdk/icons";
<NotificationCenter
items={inbox.items}
renderIcon={(item) => <Icon name={(item.data?.icon as string) ?? "bell"} size={16} />}
/>
Compatibilidade
- iOS Safari só funciona quando o app é instalado como PWA (Add to Home Screen) — e o app precisa de
manifest.jsoncomdisplay: "standalone"pra ser instalável. Fora disso,isPushSupported()dáfalseno iOS mesmo no Safari atual: não é bug seu. usePushSubscriptionexpõesupported— esconda o toggle quandofalse.- Origem insegura (
http://que não sejalocalhost) não tem service worker nem Push API.
Quando não chega notificação: por onde olhar
| Sintoma | Causa provável | Onde confirmar |
|---|---|---|
subscribe() nunca resolve |
SW não controla a página (escopo errado, ou nunca registrado) | DevTools → Application → Service workers: Scope tem que ser / e o status activated |
supported === false no iOS |
app não está instalado como PWA | Add to Home Screen e abrir pelo ícone |
| Inscreveu, backend não recebeu | onSubscribe falhou em silêncio |
o error do hook; o subscribe() re-lança, então trate o catch |
| Push some semanas depois | inscrição rotacionada pelo navegador | pushsubscriptionchange |
Envio devolve 403 |
par VAPID trocado, ou a pública do front não é o par da privada do backend | rotação de chave |
Envio devolve 404/410 |
usuário revogou/limpou dados; endpoint morto | apague a linha (acima) |
| Notificação duplicada | dois handlers de push no SW, ou POST não é upsert por endpoint |
o seu sw.ts; a tabela de inscrições |
| Usuário recebe notificação de outra conta | não desinscreveu no logout | logout e troca de usuário |
ReferenceError: window is not defined no SW |
SDK anterior a 0.28.1 usando urlBase64ToUint8Array no worker |
atualize o SDK |
Resumo
- VAPID: pública no front, privada só no backend. Gere uma vez com
web-pushe não rotacione — trocar invalida toda inscrição existente. - Você registra o SW; o hook só assina/desassina sobre uma registration pronta. O arquivo tem que ser servido em
/sw.js— SW com hash dentro de/assets/não controla a sua home, e aí osubscribe()fica pendurado sem erro. - Num app que já existe: três cenários de SW (nenhum,
vite-plugin-pwaeminjectManifest, SW próprio) — em nenhum deles você precisa adotar o resto do SDK. - A inscrição é do navegador, não do usuário: guarde uma linha por
endpoint(UNIQUE), faça upsert noPOSTe apague peloendpointnoDELETE. Um usuário tem quantos dispositivos quiser. - Desinscreva no logout, antes de descartar o token — senão o próximo usuário do mesmo navegador recebe as notificações do anterior. A permissão sobrevive, então o próximo login re-inscreve sem prompt.
unsubscribe()não revoga permissão:permission === "granted"não significa inscrito. Quem manda no rótulo do botão ésubscribed.subscribedcomeçafalse(a checagem é assíncrona) erefresh()existe pro estado que muda fora do app (configurações do navegador).- Inscrição morre em silêncio: trate
pushsubscriptionchangeno worker e apague no backend em404/410. usePushSubscriptiondá todo o estado (supported/permission/subscribed/loading/error) + ações;WebPushClienté a versão imperativa.- Handlers do worker (
installPushHandler/installNotificationClickHandler/installSkipWaitingListener) vão dentro do seusw.ts. - iOS só recebe push em PWA instalado — esconda o toggle quando
!supported. useNotificationInbox+NotificationCenterfecham o ciclo: o worker fazpostMessage, o hook guarda a lista (filtrando portype, comlimit) e o painel mostra lida/não lida com ação por item. Persistência fica com o app, viaonChange/initialItems.
Veja também
- HTTP — transporte das inscrições para o backend
- Diagrama: push-flow.drawio