Referência v3
Voltar à plataforma

Tempo real com WebSocket e Redis

Atualizações instantâneas sem manter o PHP-FPM ocupado e sem transformar o frontend em uma sequência de consultas repetitivas.

Estado esperado em produção

O endpoint https://realtime.vupi.us/health responde {"status":"ok"}, Redis e vupi-realtime estão ativos e REALTIME_ENABLED=true. O instalador só habilita o recurso depois de validar toda essa cadeia.

O que esta estrutura resolve

Em vez de cada navegador perguntar continuamente “há pedido novo?”, “o status mudou?” ou “chegou uma mensagem?”, a API publica um pequeno sinal quando algo muda. O gateway WebSocket recebe esse sinal pelo Redis e o entrega apenas às conexões autorizadas. Ao receber o evento, o frontend consulta a rota HTTP normal para buscar os dados atuais.

O evento é um aviso, não o conteúdo

Pedidos, mensagens, dados pessoais e valores não trafegam pelo Redis. O evento contém somente tópico, identificador da entidade, data e público autorizado. Isso reduz exposição, memória e acoplamento.

Fluxo completo

text
1. Uma operação HTTP altera um dado e conclui a transação
2. RealtimePublisher publica um sinal de invalidação no Redis
3. O gateway Node.js recebe o sinal pelo Redis Pub/Sub
4. O gateway filtra as conexões pelo público, papel ou UUID
5. O navegador recebe o evento WebSocket
6. O frontend invalida o cache e consulta o endpoint HTTP autorizado

PHP-FPM ──publica──> Redis ──avisa──> Gateway WebSocket ──entrega──> Frontend
ComponenteResponsabilidade
RealtimePublisherPublicar sinais pequenos depois de uma alteração concluída.
Redis Pub/SubDistribuir os eventos entre processos e futuras instâncias.
realtime-gatewayManter conexões WebSocket fora do PHP-FPM, autenticar tickets e filtrar audiências.
RealtimeTicketServiceEmitir tickets HMAC de uso curto para autenticar o socket.
Nginx ou CaddyEncerrar TLS e encaminhar /realtime ao gateway local.
FrontendReconectar quando necessário e atualizar somente o recurso afetado.

Instalação automática em produção

A opção recomendada é o instalador idempotente. Ele pode ser executado novamente para verificar e reparar a estrutura sem reiniciar o servidor inteiro.

bash
cd /var/www/vupi.us
php vupi setup

# Selecione:
# 32) [TEMPO REAL] Instalar Redis + WebSocket automaticamente

Também é possível executar sem o menu:

bash
cd /var/www/vupi.us
php vupi setup --realtime \
  --realtime-domain=realtime.vupi.us

O que a opção 32 configura

  • instala ou atualiza Node.js, npm, Redis e a extensão Redis do PHP;
  • gera uma chave forte para os tickets, caso ainda não exista;
  • protege e dimensiona Redis e PHP-FPM de forma conservadora;
  • instala as dependências do gateway com o usuário de execução;
  • cria e habilita o serviço vupi-realtime no systemd;
  • detecta automaticamente Nginx ou Caddy e configura o proxy ativo;
  • quando não há proxy ativo, escolhe Nginx como padrão de produção;
  • configura HTTPS com Certbot no Nginx sem derrubar os outros sites;
  • testa Redis, gateway local, proxy público, PHP-FPM e WebSocket;
  • mantém REALTIME_ENABLED=false se alguma verificação essencial falhar;
  • não reinicia o servidor operacional.
DNS antes do teste público

Crie o registro DNS do domínio de tempo real apontando para o servidor. O gateway escuta apenas em 127.0.0.1; somente o proxy HTTPS deve ficar exposto à internet.

Variáveis de ambiente

VariávelFunçãoExemplo
REALTIME_ENABLEDAtiva a publicação de eventos. O instalador só muda para true após os testes.true
REALTIME_TICKET_SECRETAssina os tickets HMAC. Use no mínimo 32 caracteres e nunca envie ao navegador.gerada pelo instalador
REALTIME_WEBSOCKET_URLURL pública segura devolvida junto com o ticket.wss://realtime.vupi.us/realtime
REALTIME_REDIS_CHANNELCanal interno usado pelo publisher e pelo subscriber.vupi:realtime
REALTIME_PORTPorta local do gateway.8090
REDIS_HOSTServidor Redis. Em instalação única, permanece local.127.0.0.1
REDIS_PORTPorta do Redis.6379
REDIS_PASSWORDSenha interna gerada ou preservada pelo instalador.valor secreto
REDIS_DBBanco lógico selecionado.0

CORS e origens do WebSocket

As origens não precisam ser mantidas em duas listas. O gateway herda CORS_ALLOWED_ORIGINS, APP_URL_FRONTEND e APP_URL. Alterações salvas nas Configurações do ambiente são percebidas automaticamente:

  • novas origens autorizadas podem abrir conexões imediatamente;
  • origens removidas deixam de abrir novas conexões;
  • conexões existentes de origens removidas são encerradas em até cinco segundos;
  • * não é aceito pelo WebSocket;
  • produção aceita HTTPS; HTTP é reservado a localhost e 127.0.0.1.

Publicando uma atualização no backend

Publique somente depois de confirmar a transação. O método é fail-open: uma falha no Redis é registrada, mas não desfaz uma operação de negócio já concluída.

php
<?php

use Src\Kernel\Realtime\RealtimePublisher;

// Somente gerentes e funcionários conectados recebem o aviso.
RealtimePublisher::publish(
    topic: 'pedidos.created',
    roles: ['gerente', 'funcionario'],
    entityId: $pedidoUuid,
);

// Atualização exclusiva de um usuário.
RealtimePublisher::publish(
    topic: 'pedidos.status.updated',
    users: [$clienteUuid],
    entityId: $pedidoUuid,
);

// Evento público, adequado apenas a dados realmente públicos.
RealtimePublisher::publish(
    topic: 'catalogo.updated',
    public: true,
);
Não publique antes do commit

Se o evento sair antes de a transação terminar, o frontend pode consultar dados antigos. Também não coloque payloads sensíveis no tópico ou no identificador da entidade.

Contrato do evento entregue

json
{
  "type": "event",
  "id": "identificador-aleatorio",
  "topic": "pedidos.status.updated",
  "entity_id": "uuid-do-pedido",
  "occurred_at": "2026-07-27T21:23:14+00:00"
}

Emitindo um ticket autenticado

Cada aplicação deve expor uma rota HTTP protegida para emitir o ticket. O gateway não aceita JWT de sessão diretamente. O ticket expira em 60 segundos e deve ser usado imediatamente.

php
<?php

use Src\Kernel\Realtime\RealtimeTicketService;

final class RealtimeController
{
    public function ticket(Request $request): Response
    {
        // Obtenha a identidade validada pelo middleware; nunca use valores
        // livres enviados no body para definir o usuário ou o papel.
        $usuario = $request->attribute('auth_user');
        if ($usuario === null) {
            return Response::json(['error' => 'Não autenticado.'], 401);
        }

        $dados = (new RealtimeTicketService())->issue(
            (string) $usuario->getAuthId(),
            (string) $usuario->getAuthRole(),
        );

        return Response::json($dados);
    }
}

Resposta esperada:

json
{
  "ticket": "payload.assinatura",
  "expires_at": 1785187454,
  "websocket_url": "wss://realtime.vupi.us/realtime"
}
Proteja a rota de ticket

Use o mesmo middleware de autenticação da aplicação. Nunca aceite UUID ou papel enviados livremente pelo cliente; derive ambos da sessão ou do token já validado no servidor.

Integração no frontend

O ticket é enviado na primeira mensagem do socket, não na URL. Assim ele não aparece em histórico, logs de proxy ou ferramentas de análise de URL.

javascript
async function conectarRealtime() {
  const resposta = await fetch('/v1/realtime/ticket', {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: '{}',
  });

  if (!resposta.ok) throw new Error('Não foi possível emitir o ticket.');

  const { ticket, websocket_url: url } = await resposta.json();
  const socket = new WebSocket(url);

  socket.addEventListener('open', () => {
    socket.send(JSON.stringify({ type: 'authenticate', ticket }));
  });

  socket.addEventListener('message', async event => {
    const mensagem = JSON.parse(event.data);

    if (mensagem.type === 'ready') {
      console.info('Tempo real autenticado.');
      return;
    }

    if (mensagem.type === 'event') {
      // Invalide apenas o recurso indicado pelo tópico.
      await atualizarRecurso(mensagem.topic, mensagem.entity_id);
    }
  });

  return socket;
}

Reconexão correta

Redes móveis mudam, notebooks dormem e proxies encerram conexões ociosas. O frontend deve reconectar com atraso progressivo e alguma aleatoriedade, evitando que milhares de clientes retornem no mesmo milissegundo.

javascript
let tentativa = 0;

async function iniciarComReconexao() {
  try {
    const socket = await conectarRealtime();
    socket.addEventListener('open', () => { tentativa = 0; });
    socket.addEventListener('close', reagendar);
    socket.addEventListener('error', () => socket.close());
  } catch {
    reagendar();
  }
}

function reagendar() {
  const base = Math.min(30000, 1000 * (2 ** tentativa++));
  const atraso = base + Math.floor(Math.random() * 750);
  window.setTimeout(iniciarComReconexao, atraso);
}

Enquanto o WebSocket estiver indisponível, use uma atualização HTTP adaptativa e de baixa frequência. Pare o fallback assim que a conexão voltar. Não mantenha polling por segundo em paralelo com o socket.

Proteções aplicadas

ProteçãoComo funciona
Ticket curtoExpira em 60 segundos, possui identificador aleatório e assinatura HMAC SHA-256.
Ticket fora da URLÉ enviado pela primeira mensagem WebSocket.
Filtro de audiênciaEventos privados são entregues somente ao UUID ou papel autorizado.
Allowlist de origemO handshake valida a origem contra o CORS atualizado.
Gateway localNode.js escuta em 127.0.0.1:8090; o acesso público ocorre por WSS no proxy.
Redis local protegidoNão deve ser exposto à internet e recebe senha forte.
Limite por IPO gateway limita conexões simultâneas para reduzir abuso.
Payload pequenoMensagens do cliente têm limite reduzido e compressão desabilitada.
HeartbeatConexões mortas são removidas periodicamente.
Falha isoladaRedis ou WebSocket indisponível não derruba a API nem desfaz operações concluídas.
Tempo real não substitui autorização HTTP

O evento apenas avisa que algo mudou. A rota usada para buscar os dados continua responsável por autenticar, autorizar e filtrar o conteúdo. Nunca confie no tópico recebido como permissão de leitura.

Verificação e operação

bash
# Saúde pública
curl -fsS https://realtime.vupi.us/health

# Serviços essenciais
sudo systemctl is-active nginx redis-server vupi-realtime

# Configuração habilitada
sudo grep '^REALTIME_ENABLED=' /var/www/vupi.us/.env

# Logs recentes
sudo journalctl -u vupi-realtime -n 100 --no-pager

# Logs acompanhados ao vivo
sudo journalctl -u vupi-realtime -f

Uma inicialização saudável mostra mensagens equivalentes a:

log
[Realtime] CORS sincronizado (6 origens).
[Realtime] Redis conectado.
[Realtime] Subscriber conectado.
[Realtime] Gateway ouvindo em 127.0.0.1:8090

Solução de problemas

SintomaVerificaçãoAção
/health não respondesystemctl status vupi-realtime e proxyExecute novamente a opção 32 e confira DNS/TLS.
ECONNREFUSED 127.0.0.1:6379systemctl status redis-serverInicie ou repare Redis; o gateway reconecta automaticamente quando o serviço volta.
Origem rejeitadaConfira CORS_ALLOWED_ORIGINSCadastre a origem exata, sem caminho. Aguarde até cinco segundos.
Socket abre e fechaConfira emissão, prazo e assinatura do ticketEmita um ticket novo e envie imediatamente na mensagem authenticate.
Evento não chegaConfira REALTIME_ENABLED, tópico e audiênciaValide se o papel/UUID conectado pertence ao público informado.
API funciona, mas realtime nãoComportamento esperado de degradaçãoMantenha fallback HTTP adaptativo até a reconexão.

Eficiência e capacidade

Separar as conexões persistentes do PHP-FPM evita manter um worker PHP ocupado para cada navegador. Redis permite distribuir os sinais entre processos e futuras instâncias. Ainda assim, capacidade real depende de CPU, memória, rede, proxy, quantidade de conexões e frequência de eventos.

  • publique um evento por mudança relevante, não por renderização de tela;
  • agrupe atualizações repetidas quando possível;
  • busque somente o recurso invalidado;
  • use paginação nas consultas HTTP acionadas pelo evento;
  • acompanhe memória, conexões, latência, reconexões e erros do Redis;
  • faça teste de carga antes de aumentar limites ou adicionar instâncias.
Mais eficiente, não ilimitado

A arquitetura reduz consultas desnecessárias e isola conexões persistentes, mas não substitui observabilidade, testes de carga e dimensionamento conforme o uso real.