Pular para o conteúdo
upgbp

Web Push do zero: Service Worker, VAPID e o pedido de permissão

A implementação inteira, do registro do worker ao envio. E o erro de UX que queima a permissão do usuário para sempre, sem possibilidade de desfazer.

8 min de leitura

O erro tratado aqui

DOMException: Registration failed - permission denied

Ambiente testado

  • Next.js 15.3
  • web-push 3.6
  • Chrome 140
  • Firefox 142
Neste artigo (8)

Contexto: o que estava rodando

Um aplicativo de acompanhamento de entregas. A ideia era avisar o usuário quando o status do pedido mudasse, sem depender de ele manter a aba aberta.

Primeira versão: pedir permissão assim que a página carrega, e assinar.

O erro

console do navegadorexit 1
DOMException: Registration failed - permission denied
    at PushManager.subscribe

Notification.permission
> "denied"

E, na tentativa de pedir de novo:

console do navegadorexit 1
> await Notification.requestPermission()
< "denied"          ← devolvido na hora, sem mostrar nada ao usuário

O navegador não perguntou nada. Ele devolveu denied imediatamente.

Diagnóstico

Permissão negada é definitiva

Este é o ponto que muda todo o desenho da funcionalidade. Uma vez que o usuário clica em “Bloquear”, Notification.requestPermission() para de mostrar diálogo. Ele passa a devolver denied sem perguntar nada.

Não existe API para reverter isso. O usuário precisa ir nas configurações do site, no navegador, e mudar na mão — algo que praticamente ninguém faz.

E os navegadores endureceram ainda mais: pedido disparado no carregamento, sem interação, pode ser negado automaticamente. O Chrome tem um modo que bloqueia sozinho quando detecta esse padrão, e o Firefox exige gesto do usuário.

As três peças e onde cada uma falha

Peça O que faz Falha típica
Service Worker Recebe o push com a aba fechada Escopo errado; não registra fora de HTTPS
Chaves VAPID Identificam o seu servidor Pública no cliente diferente da privada no servidor
Subscription Endereço para onde enviar Não persistida, ou não removida quando expira

localhost é exceção à regra de HTTPS. Em qualquer outro host, sem TLS o navigator.serviceWorker nem existe — e o erro que aparece é undefined is not an object, que não sugere em nada o motivo real.

A solução

  1. Gerar o par de chaves VAPID.

    npx web-push generate-vapid-keys
    .env
    NEXT_PUBLIC_VAPID_PUBLIC_KEY="BEl6...Wc4"
    VAPID_PRIVATE_KEY="hK9...b2Q"
    VAPID_SUBJECT="mailto:contato@seudominio.com"

    A pública vai para o navegador; a privada nunca. Se elas não forem do mesmo par, o servidor de push devolve 403 no envio — e a assinatura parecia perfeita.

  2. Registrar o Service Worker no escopo certo.

    O arquivo precisa estar na raiz pública. Um worker servido de /js/sw.js só controla páginas dentro de /js/.

    public/sw.js
    self.addEventListener('push', (event) => {
      const dados = event.data?.json() ?? {};
    
      // waitUntil segura o worker vivo até a notificação aparecer. Sem ele, o
      // navegador pode encerrar o worker antes e o push some sem erro.
      event.waitUntil(
        self.registration.showNotification(dados.titulo ?? 'Atualização', {
          body: dados.corpo,
          icon: '/icons/192.png',
          badge: '/icons/badge.png',
          // Mesma tag substitui a notificação anterior em vez de empilhar.
          tag: dados.tag ?? 'geral',
          data: { url: dados.url ?? '/' },
        }),
      );
    });
    
    self.addEventListener('notificationclick', (event) => {
      event.notification.close();
      const destino = event.notification.data?.url ?? '/';
    
      event.waitUntil(
        (async () => {
          const janelas = await self.clients.matchAll({
            type: 'window',
            includeUncontrolled: true,
          });
          // Reaproveita uma aba aberta em vez de abrir a quarta cópia do app.
          for (const janela of janelas) {
            if (janela.url.includes(destino) && 'focus' in janela) {
              return janela.focus();
            }
          }
          return self.clients.openWindow(destino);
        })(),
      );
    });
  3. Pedir permissão só depois de o usuário demonstrar interesse.

    O padrão que funciona tem duas etapas: uma explicação sua, que pode ser recusada sem custo, e só então o diálogo do navegador.

    src/components/AtivarAvisos.tsx
    'use client';
    import { useState, useEffect } from 'react';
    
    type Estado = 'indisponivel' | 'pode-pedir' | 'ativo' | 'bloqueado';
    
    export function AtivarAvisos({ pedidoId }: { pedidoId: string }) {
      const [estado, setEstado] = useState<Estado>('indisponivel');
    
      useEffect(() => {
        if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
        setEstado(
          Notification.permission === 'granted' ? 'ativo'
          : Notification.permission === 'denied' ? 'bloqueado'
          : 'pode-pedir',
        );
      }, []);
    
      if (estado === 'indisponivel') return null;
    
      if (estado === 'bloqueado') {
        // Não adianta oferecer o botão: requestPermission devolveria denied
        // na hora. A única saída é instruir sobre as configurações do navegador.
        return (
          <p className="text-sm text-neutral-500">
            Avisos bloqueados. Para reativar, abra as configurações deste site no
            seu navegador e permita notificações.
          </p>
        );
      }
    
      if (estado === 'ativo') return <p className="text-sm">Avisos ativados.</p>;
    
      return (
        <button
          type="button"
          // Sempre dentro de um clique: sem gesto do usuário, parte dos
          // navegadores nem mostra o diálogo.
          onClick={async () => {
            const permissao = await Notification.requestPermission();
            if (permissao !== 'granted') {
              setEstado(permissao === 'denied' ? 'bloqueado' : 'pode-pedir');
              return;
            }
            await assinar(pedidoId);
            setEstado('ativo');
          }}
          className="rounded-md bg-neutral-900 px-4 py-2 text-sm text-white"
        >
          Avisar quando meu pedido sair para entrega
        </button>
      );
    }

    Repare no texto do botão. Ele diz o que o usuário ganha, não “ativar notificações” — a diferença aparece direto na taxa de aceitação.

  4. Assinar e guardar no servidor.

    src/lib/push-cliente.ts
    /** A chave VAPID vem em base64url; o PushManager exige bytes. */
    function base64UrlParaBytes(base64: string) {
      const preenchido = (base64 + '='.repeat((4 - (base64.length % 4)) % 4))
        .replace(/-/g, '+')
        .replace(/_/g, '/');
      const cru = atob(preenchido);
      return Uint8Array.from(cru, (c) => c.charCodeAt(0));
    }
    
    export async function assinar(pedidoId: string) {
      const registro = await navigator.serviceWorker.register('/sw.js');
      await navigator.serviceWorker.ready;
    
      const assinatura =
        (await registro.pushManager.getSubscription()) ??
        (await registro.pushManager.subscribe({
          // Obrigatório: sem isso o navegador recusa a assinatura.
          userVisibleOnly: true,
          applicationServerKey: base64UrlParaBytes(
            process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
          ),
        }));
    
      await fetch('/api/push/assinar', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ assinatura: assinatura.toJSON(), pedidoId }),
      });
    }
    prisma/schema.prisma
    model PushSubscription {
      id        String   @id @default(cuid())
      endpoint  String   @unique   // o endpoint identifica o dispositivo
      p256dh    String
      auth      String
      usuarioId String
      criadoEm  DateTime @default(now())
    
      @@index([usuarioId])
    }
  5. Enviar e limpar assinaturas mortas.

    src/lib/push-servidor.ts
    import webpush from 'web-push';
    
    webpush.setVapidDetails(
      process.env.VAPID_SUBJECT!,
      process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
      process.env.VAPID_PRIVATE_KEY!,
    );
    
    export async function enviarPara(usuarioId: string, carga: object) {
      const assinaturas = await prisma.pushSubscription.findMany({ where: { usuarioId } });
    
      await Promise.allSettled(
        assinaturas.map(async (s) => {
          try {
            await webpush.sendNotification(
              { endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } },
              JSON.stringify(carga),
              { TTL: 3600 },
            );
          } catch (err) {
            const status = (err as { statusCode?: number }).statusCode;
            // 404 e 410 significam assinatura morta: o usuário desinstalou,
            // limpou os dados ou revogou. Guardar não serve para nada.
            if (status === 404 || status === 410) {
              await prisma.pushSubscription.delete({ where: { id: s.id } });
              return;
            }
            throw err;
          }
        }),
      );
    }

    Sem essa limpeza, a tabela acumula endpoints mortos e cada envio gasta tempo batendo em portas fechadas.

Como confirmar que resolveu

Estado inicial limpo. Em janela anônima, Notification.permission deve ser "default" e nenhum diálogo pode aparecer sozinho.

O worker está no ar e no escopo certo:

await navigator.serviceWorker.getRegistrations();
// scope precisa ser a raiz: 'https://seu-site.com/'

Dispare um push de teste sem passar pelo seu backend. No DevTools, aba Application → Service Workers, o campo Push envia uma carga direto para o worker. Se a notificação aparecer, o worker está correto e o problema seria no envio.

Teste com a aba fechada. É o único teste que prova a funcionalidade — com a aba aberta, muita coisa funciona por acidente.

Revogue e confirme a limpeza. Remova a permissão nas configurações do site e dispare um envio: o servidor deve receber 410 e apagar a linha.

Armadilhas que sobram depois disso

userVisibleOnly: true é obrigatório. A especificação prevê push silencioso; os navegadores não permitem. Todo push precisa gerar notificação visível, e o worker que recebe um push sem mostrar nada pode perder a permissão.

Service Worker antigo em cache atrapalha. O navegador segura a versão anterior até todas as abas fecharem. Durante o desenvolvimento, use Update on reload no DevTools — sem isso você depura código que não está mais rodando.

Assinatura expira sozinha. Navegadores rotacionam endpoints periodicamente. Trate pushsubscriptionchange no worker e reassine, ou o usuário simplesmente para de receber sem nunca ter desativado nada.

Cada navegador tem seu servidor de push. Chrome usa a infraestrutura do Google, Firefox a da Mozilla. Uma delas bloqueada na rede corporativa do usuário faz o push falhar só para ele.

iOS tem regras próprias, e bem mais restritivas. Nada do que está aqui funciona no Safari móvel enquanto o site não for instalado na tela de início — o que muda o desenho da funcionalidade inteira.

Continue por aqui