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.
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.
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
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
| Componente | Responsabilidade |
|---|---|
RealtimePublisher | Publicar sinais pequenos depois de uma alteração concluída. |
Redis Pub/Sub | Distribuir os eventos entre processos e futuras instâncias. |
realtime-gateway | Manter conexões WebSocket fora do PHP-FPM, autenticar tickets e filtrar audiências. |
RealtimeTicketService | Emitir tickets HMAC de uso curto para autenticar o socket. |
Nginx ou Caddy | Encerrar TLS e encaminhar /realtime ao gateway local. |
Frontend | Reconectar 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.
cd /var/www/vupi.us
php vupi setup
# Selecione:
# 32) [TEMPO REAL] Instalar Redis + WebSocket automaticamente
Também é possível executar sem o menu:
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-realtimeno 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=falsese alguma verificação essencial falhar; - não reinicia o servidor operacional.
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ável | Função | Exemplo |
|---|---|---|
REALTIME_ENABLED | Ativa a publicação de eventos. O instalador só muda para true após os testes. | true |
REALTIME_TICKET_SECRET | Assina os tickets HMAC. Use no mínimo 32 caracteres e nunca envie ao navegador. | gerada pelo instalador |
REALTIME_WEBSOCKET_URL | URL pública segura devolvida junto com o ticket. | wss://realtime.vupi.us/realtime |
REALTIME_REDIS_CHANNEL | Canal interno usado pelo publisher e pelo subscriber. | vupi:realtime |
REALTIME_PORT | Porta local do gateway. | 8090 |
REDIS_HOST | Servidor Redis. Em instalação única, permanece local. | 127.0.0.1 |
REDIS_PORT | Porta do Redis. | 6379 |
REDIS_PASSWORD | Senha interna gerada ou preservada pelo instalador. | valor secreto |
REDIS_DB | Banco 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
localhoste127.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
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,
);
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
{
"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
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:
{
"ticket": "payload.assinatura",
"expires_at": 1785187454,
"websocket_url": "wss://realtime.vupi.us/realtime"
}
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.
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.
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ção | Como funciona |
|---|---|
| Ticket curto | Expira em 60 segundos, possui identificador aleatório e assinatura HMAC SHA-256. |
| Ticket fora da URL | É enviado pela primeira mensagem WebSocket. |
| Filtro de audiência | Eventos privados são entregues somente ao UUID ou papel autorizado. |
| Allowlist de origem | O handshake valida a origem contra o CORS atualizado. |
| Gateway local | Node.js escuta em 127.0.0.1:8090; o acesso público ocorre por WSS no proxy. |
| Redis local protegido | Não deve ser exposto à internet e recebe senha forte. |
| Limite por IP | O gateway limita conexões simultâneas para reduzir abuso. |
| Payload pequeno | Mensagens do cliente têm limite reduzido e compressão desabilitada. |
| Heartbeat | Conexões mortas são removidas periodicamente. |
| Falha isolada | Redis ou WebSocket indisponível não derruba a API nem desfaz operações concluídas. |
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
# 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:
[Realtime] CORS sincronizado (6 origens).
[Realtime] Redis conectado.
[Realtime] Subscriber conectado.
[Realtime] Gateway ouvindo em 127.0.0.1:8090
Solução de problemas
| Sintoma | Verificação | Ação |
|---|---|---|
/health não responde | systemctl status vupi-realtime e proxy | Execute novamente a opção 32 e confira DNS/TLS. |
ECONNREFUSED 127.0.0.1:6379 | systemctl status redis-server | Inicie ou repare Redis; o gateway reconecta automaticamente quando o serviço volta. |
| Origem rejeitada | Confira CORS_ALLOWED_ORIGINS | Cadastre a origem exata, sem caminho. Aguarde até cinco segundos. |
| Socket abre e fecha | Confira emissão, prazo e assinatura do ticket | Emita um ticket novo e envie imediatamente na mensagem authenticate. |
| Evento não chega | Confira REALTIME_ENABLED, tópico e audiência | Valide se o papel/UUID conectado pertence ao público informado. |
| API funciona, mas realtime não | Comportamento esperado de degradação | Mantenha 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.
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.