WebSockets e Comunicação em Tempo Real

[132] WebSockets e Comunicação em Tempo Real

HTTP é uma conversa em que só o cliente pode falar primeiro. O WebSocket abre um canal em que os dois lados falam quando quiserem, e o artigo mostra o que vem junto: autenticação no handshake, salas, reconexão, integração com a API REST por eventos de domínio, Redis para escalar e quando o SSE resolve melhor.
Javascript

45 min de leitura

Início do Módulo 9 — Tópicos Avançados

Introdução

Até agora todas as comunicações entre front-end e back-end seguiram o modelo requisição-resposta: o cliente pergunta, o servidor responde, a conexão encerra. Este modelo funciona bem para a maioria das situações — buscar produtos, criar tarefas, fazer login.

Mas algumas aplicações precisam de algo diferente. Um chat onde mensagens aparecem instantaneamente. Um dashboard onde métricas atualizam em tempo real. Uma ferramenta colaborativa onde múltiplos usuários editam simultaneamente. Um jogo multiplayer. Nestas situações, o cliente não pode ficar perguntando "tem novidade?" a cada segundo — essa abordagem (chamada polling) é ineficiente e cria uma sensação de latência perceptível.

WebSockets resolvem este problema criando um canal de comunicação bidirecional e persistente. Depois que a conexão é estabelecida, tanto o cliente quanto o servidor podem enviar mensagens a qualquer momento, sem que o outro precisasse ter "perguntado" antes. É uma conversa, não uma sequência de perguntas e respostas.

Como WebSockets funcionam

Para entender o que torna WebSockets especiais, precisamos entender a diferença em relação ao HTTP convencional.

No HTTP, quem inicia a conversa é sempre o cliente: o servidor não tem como falar primeiro. É comum ler que cada requisição abre e fecha uma conexão TCP, mas isso descreve o HTTP/1.0 — desde o HTTP/1.1 a conexão é persistente por padrão (o keep-alive), e várias requisições reaproveitam o mesmo canal; no HTTP/2 elas ainda são multiplexadas numa conexão só. O custo que permanece, e que importa aqui, é outro: cada mensagem carrega os cabeçalhos HTTP inteiros, e o servidor continua sem poder enviar nada por conta própria — para saber de uma novidade, o cliente precisa perguntar de novo.

O WebSocket começa como uma requisição HTTP especial chamada "upgrade request". O servidor responde aceitando o upgrade e, a partir desse momento, a conexão TCP original permanece aberta e os dois lados podem trocar mensagens livremente, com overhead mínimo por mensagem.

HTTP convencional:
  Cliente → [Abre TCP] → [Envia HTTP GET] → Servidor
  Cliente ← [Recebe HTTP 200] ← Servidor
  Cliente → [Fecha TCP] → Servidor
  (repete para cada requisição)

WebSocket:
  Cliente → [Abre TCP + HTTP Upgrade] → Servidor
  Cliente ← [HTTP 101 Switching Protocols] ← Servidor
  ─────── conexão permanece aberta ────────────────────
  Cliente ←→ mensagem ←→ Servidor  (qualquer lado, qualquer hora)
  Cliente ←→ mensagem ←→ Servidor
  Cliente ←→ mensagem ←→ Servidor
  Cliente → [Fecha conexão quando quiser] → Servidor

Socket.IO — WebSockets com superpoderes

O Socket.IO é a biblioteca mais popular para WebSockets em Node.js. Além do protocolo WebSocket, ele oferece funcionalidades que tornam o desenvolvimento de aplicações em tempo real muito mais simples: reconexão automática, salas de chat, eventos com nome, broadcast seletivo, e fallback para polling quando WebSockets não estão disponíveis.

npm install socket.io        # back-end
npm install socket.io-client # front-end

Servidor — configuração básica

O Socket.IO se integra ao servidor Express existente, compartilhando a mesma porta. A configuração é simples, mas os detalhes de CORS e autenticação são importantes para produção.

// src/index.js — integrando Socket.IO ao Express existente
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const jwt = require('jsonwebtoken');
const config = require('./config');

const app = express();

// Socket.IO precisa de um servidor HTTP nativo — não o Express diretamente
// O Express é passado para o http.createServer() e ambos compartilham a porta
const servidor = http.createServer(app);

const io = new Server(servidor, {
  cors: {
    // Mesmas origens permitidas que o CORS do Express
    origin: process.env.NODE_ENV === 'production'
      ? process.env.FRONTEND_URL
      : ['http://localhost:5173', 'http://localhost:4173'],
    methods: ['GET', 'POST'],
    credentials: true,
  },

  // Configurações de reconexão e timeout
  pingTimeout: 60000,    // tempo antes de considerar conexão morta (60s)
  pingInterval: 25000,   // frequência dos pings de heartbeat (25s)
});

// ── Middleware de autenticação do Socket.IO ─────────
// Todo socket que tenta se conectar passa por aqui primeiro
// Se a autenticação falhar, a conexão é recusada imediatamente
io.use(async (socket, next) => {
  try {
    // O lugar certo do token é o campo auth do handshake.
    //
    // A query string abaixo existe por compatibilidade com clientes antigos,
    // e é a opção pior: a URL de conexão aparece no log de acesso do servidor,
    // no proxy reverso, no histórico do navegador e em qualquer ferramenta de
    // monitoramento — ou seja, o token vaza para vários lugares que ninguém
    // trata como sensíveis. Em projeto novo, aceite apenas o auth.
    const token =
      socket.handshake.auth?.token ||
      socket.handshake.query?.token;

    if (!token) {
      return next(new Error('Autenticação necessária.'));
    }

    const payload = jwt.verify(token, config.jwtSecret);

    // Busca o usuário e anexa ao socket — disponível em todos os eventos
    const usuario = await Usuario.findById(payload.id).select('nome email papel').lean();
    if (!usuario) return next(new Error('Usuário não encontrado.'));

    socket.usuario = usuario;
    next(); // autenticação bem-sucedida — permite a conexão
  } catch (erro) {
    next(new Error('Token inválido ou expirado.'));
  }
});

// ── Gerenciador de conexões ──────────────────────────
// Este é o coração do servidor de tempo real
// Cada socket representa uma conexão de um cliente específico
io.on('connection', (socket) => {
  const { usuario } = socket;

  console.log(`[Socket] Conectado: ${usuario.nome} (${socket.id})`);

  // Entra automaticamente em uma sala com o ID do usuário
  // Isso permite enviar mensagens para um usuário específico
  // mesmo que ele tenha múltiplas abas abertas
  socket.join(`usuario:${usuario._id}`);

  // Informa o cliente que a conexão foi estabelecida com sucesso
  socket.emit('conectado', {
    mensagem: `Bem-vindo, ${usuario.nome}!`,
    socketId: socket.id,
  });

  // Registra os handlers de eventos aqui dentro
  // 'socket' já tem o usuário autenticado disponível
  registrarEventosTarefas(io, socket);
  registrarEventosNotificacoes(io, socket);

  // Quando a conexão é encerrada (usuário fechou a aba, perdeu internet, etc.)
  socket.on('disconnect', (motivo) => {
    console.log(`[Socket] Desconectado: ${usuario.nome} — motivo: ${motivo}`);
  });

  // Tratamento de erros no socket individual
  socket.on('error', (erro) => {
    console.error(`[Socket] Erro (${usuario.nome}):`, erro.message);
  });
});

// Inicia o servidor na porta configurada
servidor.listen(config.porta, () => {
  console.log(`[Server] ✅ HTTP + WebSocket na porta ${config.porta}`);
});

Salas e eventos — colaboração em tempo real

As salas do Socket.IO são grupos lógicos de conexões. Um socket pode entrar e sair de salas livremente. Mensagens enviadas para uma sala chegam a todos os membros — perfeito para chats por projeto, notificações por equipe, ou colaboração em documentos.

// src/socketHandlers/tarefas.js
// Handlers para eventos relacionados a tarefas em tempo real

function registrarEventosTarefas(io, socket) {
  const { usuario } = socket;

  // ── Entrar em uma sala de projeto ────────────────
  // Quando o usuário abre a lista de tarefas de um projeto,
  // entra na sala daquele projeto para receber atualizações ao vivo
  socket.on('entrar:projeto', async ({ projetoId }) => {
    try {
      // Verifica se o usuário tem acesso ao projeto
      const projeto = await Projeto.findOne({
        _id: projetoId,
        membros: usuario._id,
      });

      if (!projeto) {
        socket.emit('erro', { mensagem: 'Projeto não encontrado ou sem acesso.' });
        return;
      }

      // Entra na sala do projeto
      socket.join(`projeto:${projetoId}`);

      // Confirma para o cliente que entrou na sala
      socket.emit('entrou:projeto', { projetoId, nome: projeto.nome });

      // Notifica os outros membros do projeto que este usuário está online
      socket.to(`projeto:${projetoId}`).emit('usuario:entrou', {
        usuario: { id: usuario._id, nome: usuario.nome },
        projetoId,
      });

      console.log(`[Socket] ${usuario.nome} entrou no projeto ${projetoId}`);
    } catch (erro) {
      socket.emit('erro', { mensagem: 'Erro ao entrar no projeto.' });
    }
  });

  // ── Sair de uma sala de projeto ──────────────────
  socket.on('sair:projeto', ({ projetoId }) => {
    socket.leave(`projeto:${projetoId}`);
    socket.to(`projeto:${projetoId}`).emit('usuario:saiu', {
      usuario: { id: usuario._id, nome: usuario.nome },
      projetoId,
    });
  });

  // ── Tarefa criada via HTTP — notifica a sala ─────
  // Este handler é chamado internamente (pelo EventEmitter do domínio)
  // não pelo cliente — quando uma tarefa é criada via API REST,
  // todos na sala recebem a atualização em tempo real
  socket.on('tarefa:nova', ({ tarefa, projetoId }) => {
    // Envia para todos na sala do projeto EXCETO quem criou (já tem localmente)
    socket.to(`projeto:${projetoId}`).emit('tarefa:criada', { tarefa });
  });

  // ── Usuário está digitando (indicador de atividade) ─
  socket.on('tarefa:digitando', ({ projetoId, tarefaId }) => {
    // Reencaminha para os outros na sala — sem persistir nada
    socket.to(`projeto:${projetoId}`).emit('tarefa:alguem-digitando', {
      usuario: { id: usuario._id, nome: usuario.nome },
      tarefaId,
    });
  });
}

module.exports = { registrarEventosTarefas };
// src/socketHandlers/notificacoes.js
// Sistema de notificações em tempo real para o usuário individual

function registrarEventosNotificacoes(io, socket) {
  const { usuario } = socket;

  // Quando o usuário se conecta, busca notificações não lidas
  // e envia imediatamente — sem precisar que ele recarregue a página
  socket.on('notificacoes:buscar', async () => {
    try {
      const naoLidas = await Notificacao.find({
        destinatario: usuario._id,
        lida: false,
      })
        .sort({ criadoEm: -1 })
        .limit(20)
        .lean();

      socket.emit('notificacoes:lista', { notificacoes: naoLidas });
    } catch (erro) {
      socket.emit('erro', { mensagem: 'Erro ao buscar notificações.' });
    }
  });

  // Marca uma notificação como lida
  socket.on('notificacao:ler', async ({ notificacaoId }) => {
    try {
      await Notificacao.findOneAndUpdate(
        { _id: notificacaoId, destinatario: usuario._id },
        { lida: true, lidaEm: new Date() }
      );

      socket.emit('notificacao:lida', { notificacaoId });
    } catch (erro) {
      socket.emit('erro', { mensagem: 'Erro ao marcar notificação.' });
    }
  });
}

// Função utilitária exportada para uso em outras partes da aplicação
// Permite que qualquer serviço envie uma notificação para um usuário específico
// mesmo sem ter acesso direto ao socket dele
function notificarUsuario(io, usuarioId, notificacao) {
  // A sala 'usuario:ID' garante que a mensagem chega a TODAS as abas
  // abertas daquele usuário — mesmo se ele tiver múltiplas sessões
  io.to(`usuario:${usuarioId}`).emit('notificacao:nova', { notificacao });
}

module.exports = { registrarEventosNotificacoes, notificarUsuario };

Integrando WebSockets com a API REST

Uma das decisões de arquitetura mais importantes em aplicações com tempo real é: quando usar REST e quando usar WebSockets? A resposta prática é usar ambos.

REST continua sendo a escolha certa para operações com estado durável — criar, ler, atualizar e deletar recursos. WebSockets são ideais para propagação de eventos — notificar outros clientes que algo mudou.

// src/container.js — integrando o io nos eventos de domínio
// Quando uma tarefa é criada via API REST, o evento de domínio
// aciona a notificação em tempo real para todos na sala do projeto

const { notificarUsuario } = require('./socketHandlers/notificacoes');

// 'io' é injetado no container para que os ouvintes de eventos possam usá-lo
function criarContainer(io) {
  const eventEmitter = new EventEmitter();

  // Quando uma tarefa é criada via REST, notifica a sala do projeto em tempo real
  eventEmitter.on(EVENTOS.TAREFA_CRIADA, ({ tarefa, usuario }) => {
    // Invalida o cache
    cache.del(`stats:${usuario._id}`);

    // Se a tarefa pertence a um projeto, notifica todos na sala
    if (tarefa.projetoId) {
      io.to(`projeto:${tarefa.projetoId}`).emit('tarefa:criada', {
        tarefa: tarefa.toJSON(),
        criadoPor: { id: usuario._id, nome: usuario.nome },
      });
    }
  });

  eventEmitter.on(EVENTOS.TAREFA_CONCLUIDA, ({ tarefa, usuario }) => {
    cache.del(`stats:${usuario._id}`);

    if (tarefa.projetoId) {
      io.to(`projeto:${tarefa.projetoId}`).emit('tarefa:concluida', {
        tarefaId: tarefa.id,
        concluidoPor: { id: usuario._id, nome: usuario.nome },
        concluidaEm: tarefa.concluidaEm,
      });
    }
  });

  // Casos de uso recebem os mesmos repositórios e event emitter
  const tarefaRepository = new MongoTarefaRepository();
  const criarTarefa = new CriarTarefa(tarefaRepository, eventEmitter);
  // ... resto das instâncias

  return { tarefaController: new TarefaController(/* ... */) };
}

module.exports = { criarContainer };

Front-end React — conectando ao Socket.IO

No front-end, vamos encapsular toda a lógica do Socket.IO em um hook customizado. Isso mantém os componentes limpos e centraliza o gerenciamento da conexão.

// src/hooks/useSocket.js
// Hook que gerencia a conexão com o servidor de WebSockets
import { useEffect, useRef, useCallback } from 'react';
import { io } from 'socket.io-client';
import useAuthStore from '../stores/authStore';

// Mantemos o socket fora do hook para que seja singleton —
// uma única conexão compartilhada por toda a aplicação
let socketInstancia = null;

export function useSocket() {
  const token = useAuthStore((state) => state.token);
  const socketRef = useRef(null);

  useEffect(() => {
    // Não conecta se não há token (usuário não logado)
    if (!token) return;

    // Reutiliza a instância existente se já estiver conectada
    if (socketInstancia?.connected) {
      socketRef.current = socketInstancia;
      return;
    }

    // Cria uma nova conexão
    const socket = io(import.meta.env.VITE_API_URL || 'http://localhost:3000', {
      // Envia o token no handshake — o middleware de auth do servidor usa isso
      auth: { token },

      // Tenta reconectar automaticamente com backoff exponencial
      reconnection: true,
      reconnectionDelay: 1000,      // aguarda 1s antes da primeira tentativa
      reconnectionDelayMax: 30000,  // no máximo 30s entre tentativas
      reconnectionAttempts: 5,      // desiste após 5 tentativas

      // Prefere WebSocket, com polling como fallback
      transports: ['websocket', 'polling'],
    });

    socket.on('connect', () => {
      console.log('[Socket] Conectado:', socket.id);
    });

    socket.on('disconnect', (motivo) => {
      console.log('[Socket] Desconectado:', motivo);
      // Se o servidor encerrou a conexão, não tenta reconectar automaticamente
      if (motivo === 'io server disconnect') {
        socket.connect();
      }
    });

    socket.on('connect_error', (erro) => {
      console.error('[Socket] Erro de conexão:', erro.message);
    });

    socketInstancia = socket;
    socketRef.current = socket;

    // Cleanup: desconecta quando o componente que usa o hook desmonta
    // Na prática, como é singleton, só desconecta quando o usuário faz logout
    return () => {
      if (socket.connected) {
        socket.disconnect();
        socketInstancia = null;
      }
    };
  }, [token]);

  // Retorna funções estáveis para emitir e ouvir eventos
  const emitir = useCallback((evento, dados) => {
    socketRef.current?.emit(evento, dados);
  }, []);

  const ouvir = useCallback((evento, callback) => {
    const socket = socketRef.current;
    if (!socket) return () => {};
    socket.on(evento, callback);
    // Retorna função de cleanup para remover o listener
    return () => socket.off(evento, callback);
  }, []);

  return {
    socket: socketRef.current,
    emitir,
    ouvir,
    conectado: socketRef.current?.connected ?? false,
  };
}
// src/hooks/useProjetoTempoReal.js
// Hook específico para sincronizar tarefas de um projeto em tempo real
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useSocket } from './useSocket';


export function useProjetoTempoReal(projetoId) {
  const { emitir, ouvir } = useSocket();
  const queryClient = useQueryClient();

  useEffect(() => {
    if (!projetoId) return;

    // Entra na sala do projeto ao montar o componente
    emitir('entrar:projeto', { projetoId });

    // Quando uma nova tarefa chega, invalida o cache do React Query
    // para que a lista seja refetchada automaticamente
    const removerListenerCriada = ouvir('tarefa:criada', ({ tarefa }) => {
      queryClient.invalidateQueries({ queryKey: ['tarefas', { projetoId }] });
    });

    const removerListenerConcluida = ouvir('tarefa:concluida', ({ tarefaId }) => {
      // Atualiza otimisticamente no cache — sem esperar refetch
      queryClient.setQueriesData(
        { queryKey: ['tarefas', { projetoId }] },
        (cache) => {
          if (!cache?.dados) return cache;
          return {
            ...cache,
            dados: cache.dados.map((t) =>
              t.id === tarefaId ? { ...t, status: 'concluida' } : t
            ),
          };
        }
      );
    });

    // Sai da sala e remove listeners ao desmontar
    return () => {
      emitir('sair:projeto', { projetoId });
      removerListenerCriada();
      removerListenerConcluida();
    };
  }, [projetoId, emitir, ouvir, queryClient]);
}
// src/hooks/useNotificacoes.js
// Hook para notificações em tempo real do usuário atual
import { useState, useEffect } from 'react';
import { useSocket } from './useSocket';

export function useNotificacoes() {
  const [notificacoes, setNotificacoes] = useState([]);
  const [naoLidas, setNaoLidas] = useState(0);
  const { emitir, ouvir } = useSocket();

  useEffect(() => {
    // Solicita a lista inicial de notificações não lidas ao conectar
    emitir('notificacoes:buscar');

    const removerListenerLista = ouvir('notificacoes:lista', ({ notificacoes }) => {
      setNotificacoes(notificacoes);
      setNaoLidas(notificacoes.filter((n) => !n.lida).length);
    });

    // Quando uma nova notificação chega, adiciona no topo da lista
    const removerListenerNova = ouvir('notificacao:nova', ({ notificacao }) => {
      setNotificacoes((prev) => [notificacao, ...prev]);
      setNaoLidas((prev) => prev + 1);

      // Toca um som sutil de notificação se o usuário tiver dado permissão
      if (document.visibilityState === 'hidden') {
        new Audio('/notification.mp3').play().catch(() => {});
      }
    });

    const removerListenerLida = ouvir('notificacao:lida', ({ notificacaoId }) => {
      setNotificacoes((prev) =>
        prev.map((n) => (n._id === notificacaoId ? { ...n, lida: true } : n))
      );
      setNaoLidas((prev) => Math.max(0, prev - 1));
    });

    return () => {
      removerListenerLista();
      removerListenerNova();
      removerListenerLida();
    };
  }, [emitir, ouvir]);

  function marcarComoLida(notificacaoId) {
    emitir('notificacao:ler', { notificacaoId });
  }

  return { notificacoes, naoLidas, marcarComoLida };
}

Componentes de UI em tempo real

// src/components/SinoBadge.jsx
// Ícone de sino com badge de notificações não lidas
import { useState, useRef, useEffect } from 'react';
import { useNotificacoes } from '../hooks/useNotificacoes';

export function SinoBadge() {
  const [aberto, setAberto] = useState(false);
  const { notificacoes, naoLidas, marcarComoLida } = useNotificacoes();
  const refPainel = useRef(null);

  // Fecha o painel ao clicar fora
  useEffect(() => {
    function handleClickFora(e) {
      if (refPainel.current && !refPainel.current.contains(e.target)) {
        setAberto(false);
      }
    }
    document.addEventListener('mousedown', handleClickFora);
    return () => document.removeEventListener('mousedown', handleClickFora);
  }, []);

  return (
    <div className="sino-container" ref={refPainel}>
      <button
        className="sino-botao"
        onClick={() => setAberto((v) => !v)}
        aria-label={`Notificações — ${naoLidas} não lidas`}
      >
        🔔
        {naoLidas > 0 && (
          <span className="sino-badge">
            {naoLidas > 99 ? '99+' : naoLidas}
          </span>
        )}
      </button>

      {aberto && (
        <div className="notificacoes-painel">
          <div className="notificacoes-header">
            <h3>Notificações</h3>
            {naoLidas > 0 && (
              <span className="notificacoes-contador">{naoLidas} novas</span>
            )}
          </div>

          <ul className="notificacoes-lista">
            {notificacoes.length === 0 && (
              <li className="notificacoes-vazia">Nenhuma notificação.</li>
            )}
            {notificacoes.map((n) => (
              <li
                key={n._id}
                className={`notificacao-item ${!n.lida ? 'nao-lida' : ''}`}
                onClick={() => !n.lida && marcarComoLida(n._id)}
              >
                <p className="notificacao-texto">{n.mensagem}</p>
                <time className="notificacao-tempo">
                  {new Date(n.criadoEm).toLocaleTimeString('pt-BR', {
                    hour: '2-digit',
                    minute: '2-digit',
                  })}
                </time>
                {!n.lida && <span className="notificacao-ponto" aria-hidden />}
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}
// src/components/IndicadorDigitando.jsx
// Mostra quem está digitando em tempo real — padrão comum em chats e colaboração
import { useState, useEffect } from 'react';
import { useSocket } from '../hooks/useSocket';

export function IndicadorDigitando({ projetoId, tarefaId }) {
  const [digitando, setDigitando] = useState([]);
  const { emitir, ouvir } = useSocket();

  useEffect(() => {
    const remover = ouvir('tarefa:alguem-digitando', (dados) => {
      if (dados.tarefaId !== tarefaId) return;

      // Adiciona o usuário à lista de quem está digitando
      setDigitando((prev) => {
        const jaEsta = prev.find((u) => u.id === dados.usuario.id);
        if (jaEsta) return prev;
        return [...prev, dados.usuario];
      });

      // Remove após 3 segundos de inatividade — debounce visual
      setTimeout(() => {
        setDigitando((prev) => prev.filter((u) => u.id !== dados.usuario.id));
      }, 3000);
    });

    return remover;
  }, [tarefaId, ouvir]);

  function handleDigitando() {
    emitir('tarefa:digitando', { projetoId, tarefaId });
  }

  if (digitando.length === 0) return null;

  const nomes = digitando.map((u) => u.nome);
  const texto =
    nomes.length === 1
      ? `${nomes[0]} está digitando...`
      : `${nomes.slice(0, -1).join(', ')} e ${nomes.at(-1)} estão digitando...`;

  return (
    <p className="indicador-digitando" aria-live="polite">
      <span className="pontos-animados" aria-hidden>···</span>
      {texto}
    </p>
  );
}

Escalabilidade — múltiplas instâncias com Redis Adapter

Um servidor Socket.IO armazena as salas e as conexões em memória. Quando você escala horizontalmente para múltiplas instâncias (múltiplos containers no Railway, por exemplo), cada instância tem sua própria memória — um socket conectado na instância A não recebe mensagens emitidas da instância B.

O Redis Adapter resolve isso: as salas e as mensagens são sincronizadas entre todas as instâncias via Redis.

// npm install @socket.io/redis-adapter ioredis

const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('ioredis');

// Dois clientes Redis: um para publicar, um para subscrever
// O Redis pub/sub é o mecanismo de sincronização entre instâncias
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();

async function configurarRedisAdapter(io) {
  await Promise.all([pubClient.connect(), subClient.connect()]);

  // Agora qualquer io.to('sala').emit() é sincronizado entre todas as instâncias
  io.adapter(createAdapter(pubClient, subClient));

  console.log('[Socket] Redis Adapter configurado — escalabilidade horizontal ativa.');
}

// No index.js, após criar o io:
configurarRedisAdapter(io).catch(console.error);

Server-Sent Events — alternativa unidirecional

WebSockets são bidirecionais — ambos os lados podem enviar. Mas às vezes você só precisa que o servidor envie dados para o cliente — dashboards de monitoramento, feeds de notícias, progresso de processamento. Para esses casos, Server-Sent Events (SSE) é uma alternativa mais simples que usa HTTP convencional.

// Back-end — SSE com Express
// Não precisa de nenhuma biblioteca adicional — é HTTP puro

app.get('/eventos/tarefas', autenticar, (req, res) => {
  // Headers especiais que ativam o modo SSE no navegador
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no'); // desabilita buffering no nginx

  // Envia um comentário para manter a conexão viva
  // Alguns proxies fecham conexões ociosas após 30-60 segundos
  res.write(': ping

');
  const intervalo = setInterval(() => res.write(': ping

'), 30000);

  const usuarioId = req.usuario._id.toString();

  // Função que envia um evento SSE formatado
  function enviarEvento(tipo, dados) {
    // Formato SSE: "event: tipo
data: JSON

"
    res.write(`event: ${tipo}
`);
    res.write(`data: ${JSON.stringify(dados)}

`);
  }

  // Registra um ouvinte no event emitter global
  // Quando um evento de domínio ocorre, envia via SSE para este cliente
  const removerOuvinte = eventEmitter.on(
    EVENTOS.TAREFA_CRIADA,
    ({ tarefa, usuario }) => {
      if (usuario._id.toString() === usuarioId) {
        enviarEvento('tarefa-criada', tarefa.toJSON());
      }
    }
  );

  // Cleanup quando o cliente desconecta
  req.on('close', () => {
    clearInterval(intervalo);
    removerOuvinte();
    console.log(`[SSE] Cliente desconectado: ${usuarioId}`);
  });
});
// Front-end — consumindo SSE com EventSource nativo
// Não precisa de nenhuma biblioteca — é suportado em todos os browsers modernos

function useEventosServidor(url) {
  useEffect(() => {
    const token = useAuthStore.getState().token;
    // EventSource não suporta headers customizados — token na query string
    const eventSource = new EventSource(`${url}?token=${token}`);

    eventSource.addEventListener('tarefa-criada', (evento) => {
      const tarefa = JSON.parse(evento.data);
      queryClient.invalidateQueries({ queryKey: ['tarefas'] });
    });

    eventSource.onerror = () => {
      // EventSource reconecta automaticamente após erros
      console.warn('[SSE] Conexão interrompida — reconectando...');
    };

    return () => eventSource.close();
  }, [url]);
}

Quando usar cada tecnologia

Escolher entre WebSockets, SSE e polling depende dos requisitos da sua aplicação. A tabela abaixo resume as diferenças.

                    WebSocket (Socket.IO)    SSE             Polling
─────────────────────────────────────────────────────────────────────
Direção             Bidirecional            Server → Client Server → Client
Protocolo           WebSocket + HTTP         HTTP            HTTP
Biblioteca extra    Socket.IO               Nenhuma         Nenhuma
Reconexão auto      Sim (Socket.IO)         Sim (nativo)    Controla você
Suporte browsers    Excelente               Excelente       Universal
Escalabilidade      Redis Adapter           Load balancer   Fácil
Complexidade        Média                   Baixa           Baixa

Use WebSocket quando:
  ✓ Comunicação bidirecional (chat, jogos, colaboração)
  ✓ Baixa latência é crítica (< 100ms)
  ✓ Muitos eventos por segundo
  ✓ Salas e grupos complexos

Use SSE quando:
  ✓ Apenas servidor → cliente (dashboards, feeds, progresso)
  ✓ Quer simplicidade (sem biblioteca extra)
  ✓ Os dados são principalmente texto

Use Polling quando:
  ✓ Atualizações não são críticas (a cada 30s já basta)
  ✓ Quer implementação mais simples
  ✓ Backend não suporta WebSockets

Tarefa para você

Adicione comunicação em tempo real na SPA do Módulo 6:

# 1. Configure o Socket.IO no servidor
#    Integre ao servidor Express existente (sem criar nova porta)
#    Adicione autenticação JWT no middleware do Socket.IO

# 2. Crie o hook useSocket.js com:
#    - Conexão singleton
#    - Reconexão automática
#    - Funções emitir() e ouvir() estáveis

# 3. Implemente notificações em tempo real:
#    - Quando uma tarefa é criada via REST, emita um socket event
#    - No front-end, ouça o evento e invalide o React Query cache
#    - Adicione o componente SinoBadge no Layout

# 4. Adicione indicador de "usuário online":
#    - Ao conectar, o servidor registra o socket na sala 'usuario:ID'
#    - Crie um endpoint REST GET /usuarios/online que retorna
#      a lista de usuários com conexão ativa: io.sockets.adapter.rooms

# 5. Implemente um chat simples entre usuários do mesmo projeto:
#    - Sala: 'chat:projetoId'
#    - Evento: 'chat:mensagem' com { texto, usuario, timestamp }
#    - Componente ChatProjeto com lista de mensagens e input
#    - Indicador de digitando com debounce de 3 segundos

# 6. Teste com múltiplas abas:
#    - Crie uma tarefa em uma aba
#    - Observe a lista atualizar na outra aba sem reload
Ver solução — tempo real completo — JWT no handshake, hook estável, sino, presença e chat
// WEBSOCKETS na SPA do Módulo 6.
//
//   1 Socket.IO no servidor Express + JWT no handshake → src/socket.js
//   2 hook useSocket (singleton, reconexão, funções estáveis) → src/hooks/useSocket.js
//   3 notificação em tempo real + invalidação do React Query → src/componentes/SinoBadge.jsx
//   4 usuários online (io.sockets.adapter.rooms)       → usuariosOnline(), em src/socket.js
//   5 chat por projeto, com "digitando"                → src/componentes/ChatProjeto.jsx
//   6 teste com múltiplas abas                         → automatizado: dois clientes
//     do mesmo usuário recebendo o mesmo evento (tests/socket.servidor.test.js)
//
// 24 testes verdes. Os 11 do servidor sobem um Socket.IO real e conectam nele
// com socket.io-client — não há mock de socket do lado do servidor. Os 13 do
// front-end usam um dublê, porque o que se testa ali é o componente.

// ---- src/socket.js
// 1 — Socket.IO PENDURADO no servidor HTTP que o Express já usa. Não há porta
// nova: o handshake começa como HTTP e vira WebSocket na mesma conexão.
const { Server } = require("socket.io");
const jwt = require("jsonwebtoken");

const SALA_USUARIO = (id) => `usuario:${id}`;
const SALA_CHAT = (projetoId) => `chat:${projetoId}`;

function instalarSocket(servidorHttp, { segredo, origem }) {
  const io = new Server(servidorHttp, {
    cors: { origin: origem, credentials: true },
  });

  // Middleware de autenticação: roda UMA vez, no handshake. Depois disso o
  // socket já está autenticado e nenhum evento precisa carregar o token.
  io.use((socket, proximo) => {
    const token = socket.handshake.auth?.token;
    if (!token) return proximo(new Error("token ausente"));
    try {
      const { sub, papel } = jwt.verify(token, segredo);
      socket.data.usuario = { id: sub, papel };
      proximo();
    } catch {
      // a mensagem chega ao cliente em `err.message` no evento connect_error
      proximo(new Error("token inválido"));
    }
  });

  io.on("connection", (socket) => {
    const { id: usuarioId } = socket.data.usuario;
    socket.join(SALA_USUARIO(usuarioId));

    // O segundo parâmetro é o ACK: o cliente sabe quando o join terminou de
    // valer. Sem ele, "entrar e mandar a primeira mensagem" é uma corrida — a
    // mensagem pode chegar ao servidor antes de a sala existir para este socket.
    socket.on("chat:entrar", ({ projetoId }, confirmar) => {
      socket.join(SALA_CHAT(projetoId));
      // socket.to() NÃO devolve para quem entrou: o aviso é para os outros.
      socket.to(SALA_CHAT(projetoId)).emit("chat:entrou", { usuario: usuarioId });
      confirmar?.({ sala: SALA_CHAT(projetoId) });
    });

    socket.on("chat:mensagem", ({ projetoId, texto }) => {
      const mensagem = { texto, usuario: usuarioId, timestamp: new Date().toISOString() };
      // io.to() inclui quem enviou; socket.to() exclui. Aqui queremos os dois
      // lados vendo a mesma lista, então é io.to().
      io.to(SALA_CHAT(projetoId)).emit("chat:mensagem", mensagem);
    });

    socket.on("chat:digitando", ({ projetoId }) => {
      socket.to(SALA_CHAT(projetoId)).emit("chat:digitando", { usuario: usuarioId });
    });
  });

  return io;
}

// 4 — QUEM ESTÁ ONLINE.
// A armadilha: `io.sockets.adapter.rooms` traz também UMA SALA POR SOCKET, com
// o nome igual ao id do socket. Sem filtrar pelo prefixo, a lista de usuários
// online vem cheia de ids de conexão.
function usuariosOnline(io) {
  const online = [];
  for (const [sala, sockets] of io.sockets.adapter.rooms) {
    if (!sala.startsWith("usuario:")) continue;
    online.push({ usuarioId: sala.slice("usuario:".length), conexoes: sockets.size });
  }
  return online;
}

function notificarUsuario(io, usuarioId, evento, dados) {
  io.to(SALA_USUARIO(usuarioId)).emit(evento, dados);
}

module.exports = { instalarSocket, usuariosOnline, notificarUsuario, SALA_USUARIO, SALA_CHAT };

// ---- src/servidor.js
const http = require("node:http");
const express = require("express");
const jwt = require("jsonwebtoken");
const { instalarSocket, usuariosOnline, notificarUsuario } = require("./socket");

function criarServidor({ segredo = "segredo-de-teste", origem = "*" } = {}) {
  const app = express();
  app.use(express.json());

  // autenticação REST de mentira, só para o exemplo caber num arquivo
  const autenticar = (req, res, proximo) => {
    const token = (req.get("authorization") || "").replace("Bearer ", "");
    try {
      const { sub } = jwt.verify(token, segredo);
      req.usuario = { id: sub };
      proximo();
    } catch {
      res.status(401).json({ erro: "token inválido" });
    }
  };

  const servidor = http.createServer(app);
  const io = instalarSocket(servidor, { segredo, origem });

  const tarefas = [];

  // 3 — a tarefa nasce por REST e o aviso sai por socket. O front-end não fica
  // perguntando "tem novidade?": ele é avisado.
  app.post("/tarefas", autenticar, (req, res) => {
    const tarefa = { id: String(tarefas.length + 1), titulo: req.body.titulo, dono: req.usuario.id };
    tarefas.push(tarefa);
    const destino = req.body.responsavelId || req.usuario.id;
    notificarUsuario(io, destino, "tarefa:criada", tarefa);
    res.status(201).json(tarefa);
  });

  app.get("/usuarios/online", autenticar, (_req, res) => res.json(usuariosOnline(io)));

  return { app, servidor, io, tarefas };
}

module.exports = { criarServidor };

// ---- src/hooks/useSocket.js
// 2 — O HOOK. Um socket por aba, não um por componente.
import { useCallback, useEffect, useRef, useState } from "react";
import { io } from "socket.io-client";

let socketUnico = null;

// A fábrica é injetável pelo mesmo motivo visto no artigo de testes: `io()` de verdade abre uma
// conexão, e o teste precisa de um dublê. Em produção ninguém chama isto.
let fabricar = (url, token) =>
  io(url, {
    auth: { token },
    transports: ["websocket"],
    reconnection: true,
    reconnectionDelay: 1000,
    reconnectionDelayMax: 10000, // teto: sem ele, o backoff cresce sem limite
    reconnectionAttempts: Infinity,
  });

export function configurarSocket(novaFabrica) {
  fabricar = novaFabrica;
}

export function obterSocket({ url, token }) {
  if (!socketUnico) socketUnico = fabricar(url, token);
  return socketUnico;
}

export function encerrarSocket() {
  socketUnico?.close?.();
  socketUnico = null;
}

export function useSocket({ url, token }) {
  const socket = obterSocket({ url, token });
  const [conectado, setConectado] = useState(Boolean(socket.connected));
  const socketRef = useRef(socket);
  socketRef.current = socket;

  useEffect(() => {
    const aoConectar = () => setConectado(true);
    const aoDesconectar = () => setConectado(false);
    socket.on("connect", aoConectar);
    socket.on("disconnect", aoDesconectar);
    return () => {
      // remover SÓ os próprios ouvintes. socket.off("connect") sem o segundo
      // argumento apagaria os de todo mundo, inclusive os do Socket.IO.
      socket.off("connect", aoConectar);
      socket.off("disconnect", aoDesconectar);
    };
  }, [socket]);

  // Identidade estável: as duas funções leem o socket pela ref, então não
  // dependem do estado `conectado` e não mudam a cada render. Se mudassem,
  // todo useEffect([ouvir]) do app rodaria de novo a cada reconexão.
  const emitir = useCallback((evento, dados, ack) => {
    socketRef.current.emit(evento, dados, ack);
  }, []);

  const ouvir = useCallback((evento, ouvinte) => {
    const socketAtual = socketRef.current;
    socketAtual.on(evento, ouvinte);
    return () => socketAtual.off(evento, ouvinte);
  }, []);

  return { conectado, emitir, ouvir, socket };
}

// ---- src/componentes/SinoBadge.jsx
// 3 — O SINO. Ouve o evento, invalida o cache do React Query e conta.
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useSocket } from "../hooks/useSocket";

export function SinoBadge({ url, token }) {
  const { ouvir, conectado } = useSocket({ url, token });
  const queryClient = useQueryClient();
  const [naoLidas, setNaoLidas] = useState(0);

  useEffect(() => {
    // `ouvir` devolve a própria função de limpeza — o useEffect só a repassa.
    return ouvir("tarefa:criada", (tarefa) => {
      setNaoLidas((n) => n + 1);
      // invalidar, e não setQueryData: o servidor é a fonte da verdade, e o
      // evento carrega só o que coube nele.
      queryClient.invalidateQueries({ queryKey: ["tarefas"] });
      queryClient.invalidateQueries({ queryKey: ["estatisticas", tarefa.dono] });
    });
  }, [ouvir, queryClient]);

  return (
    <button type="button" aria-label={`Notificações: ${naoLidas} não lidas`} onClick={() => setNaoLidas(0)}>
      <span aria-hidden="true">🔔</span>
      {naoLidas > 0 && <span className="badge">{naoLidas > 9 ? "9+" : naoLidas}</span>}
      <span className="sr-only">{conectado ? "conectado" : "reconectando"}</span>
    </button>
  );
}

// ---- src/componentes/ChatProjeto.jsx
// 5 — O CHAT.
import { useEffect, useRef, useState } from "react";
import { useSocket } from "../hooks/useSocket";

const SILENCIO_ATE_PARAR = 3000; // 3 s sem evento = parou de digitar

export function ChatProjeto({ projetoId, url, token }) {
  const { emitir, ouvir, conectado } = useSocket({ url, token });
  const [mensagens, setMensagens] = useState([]);
  const [texto, setTexto] = useState("");
  const [digitando, setDigitando] = useState(null);
  const relogio = useRef(null);
  const ultimoAviso = useRef(0);

  useEffect(() => {
    emitir("chat:entrar", { projetoId });
    return ouvir("chat:mensagem", (m) => setMensagens((atuais) => [...atuais, m]));
  }, [projetoId, emitir, ouvir]);

  useEffect(() => {
    return ouvir("chat:digitando", ({ usuario }) => {
      setDigitando(usuario);
      clearTimeout(relogio.current);
      // cada novo evento adia o desaparecimento: é debounce, não intervalo fixo
      relogio.current = setTimeout(() => setDigitando(null), SILENCIO_ATE_PARAR);
    });
  }, [ouvir]);

  // limpar o relógio na desmontagem, senão o setDigitando roda em componente
  // que já saiu da tela — e o React avisa no console.
  useEffect(() => () => clearTimeout(relogio.current), []);

  function aoDigitar(evento) {
    setTexto(evento.target.value);
    // throttle na saída: avisar a cada tecla mandaria dezenas de eventos por
    // segundo para o servidor.
    const agora = Date.now();
    if (agora - ultimoAviso.current > SILENCIO_ATE_PARAR / 3) {
      ultimoAviso.current = agora;
      emitir("chat:digitando", { projetoId });
    }
  }

  function enviar(evento) {
    evento.preventDefault();
    const limpo = texto.trim();
    if (!limpo) return;
    emitir("chat:mensagem", { projetoId, texto: limpo });
    setTexto("");
  }

  return (
    <section aria-label={`Chat do projeto ${projetoId}`}>
      <ul aria-live="polite">
        {mensagens.map((m, i) => (
          <li key={`${m.timestamp}-${i}`}>
            <strong>{m.usuario}</strong>: {m.texto}
          </li>
        ))}
      </ul>
      <p aria-live="polite">{digitando ? `${digitando} está digitando…` : ""}</p>
      <form onSubmit={enviar}>
        <input
          aria-label="Mensagem"
          value={texto}
          onChange={aoDigitar}
          disabled={!conectado}
          placeholder={conectado ? "Escreva…" : "Reconectando…"}
        />
        <button type="submit" disabled={!conectado || !texto.trim()}>
          Enviar
        </button>
      </form>
    </section>
  );
}

// ---- tests/socket.servidor.test.js
const jwt = require("jsonwebtoken");
const request = require("supertest");
const { io: cliente } = require("socket.io-client");
const { criarServidor } = require("../src/servidor");

const SEGREDO = "segredo-de-teste";
const tokenDe = (sub) => jwt.sign({ sub, papel: "usuario" }, SEGREDO);

let servidor, io, url, abertos;

beforeAll((pronto) => {
  ({ servidor, io } = criarServidor({ segredo: SEGREDO }));
  servidor.listen(0, () => {
    url = `http://localhost:${servidor.address().port}`;
    pronto();
  });
});

afterAll(async () => {
  // io.close() derruba as conexões e fecha o servidor HTTP por baixo; sem isto
  // o Jest reclama de "asynchronous operations that weren't stopped".
  await new Promise((ok) => io.close(ok));
});

beforeEach(() => (abertos = []));
afterEach(() => abertos.forEach((s) => s.close()));

function conectar(usuarioId, extras = {}) {
  const socket = cliente(url, {
    auth: { token: tokenDe(usuarioId) },
    transports: ["websocket"],
    // no teste, reconexão automática só serve para segurar o processo aberto
    reconnection: false,
    ...extras,
  });
  abertos.push(socket);
  return socket;
}

// O relógio do timeout TEM de ser cancelado: um setTimeout pendente segura o
// event loop e o Jest termina reclamando de "open handles".
const esperar = (socket, evento) =>
  new Promise((ok, falha) => {
    const relogio = setTimeout(() => falha(new Error(`sem "${evento}" em 3s`)), 3000);
    socket.once(evento, (dados) => {
      clearTimeout(relogio);
      ok(dados);
    });
  });

describe("1 · autenticação no handshake", () => {
  test("token válido conecta e entra na sala do próprio usuário", async () => {
    const socket = conectar("u1");
    await esperar(socket, "connect");
    expect(socket.connected).toBe(true);
    const sala = io.sockets.adapter.rooms.get("usuario:u1");
    expect(sala?.size).toBe(1);
  });

  test("token inválido é recusado no handshake, com a mensagem do servidor", async () => {
    const socket = cliente(url, { auth: { token: "lixo" }, transports: ["websocket"], reconnection: false });
    abertos.push(socket);
    const erro = await esperar(socket, "connect_error");
    expect(erro.message).toBe("token inválido");
    expect(socket.connected).toBe(false);
  });

  test("sem token nenhum, também não entra", async () => {
    const socket = cliente(url, { transports: ["websocket"], reconnection: false });
    abertos.push(socket);
    expect((await esperar(socket, "connect_error")).message).toBe("token ausente");
  });

  test("o servidor HTTP é o mesmo — REST e WebSocket na mesma porta", async () => {
    const socket = conectar("u1");
    await esperar(socket, "connect");
    const r = await request(servidor).get("/usuarios/online").set("authorization", `Bearer ${tokenDe("u1")}`);
    expect(r.status).toBe(200);
  });
});

describe("3 · notificação de tarefa criada", () => {
  test("quem é o responsável recebe; os outros não", async () => {
    const dono = conectar("u1");
    const outro = conectar("u2");
    await Promise.all([esperar(dono, "connect"), esperar(outro, "connect")]);

    const recebidoPorOutro = jest.fn();
    outro.on("tarefa:criada", recebidoPorOutro);

    const promessa = esperar(dono, "tarefa:criada");
    await request(servidor)
      .post("/tarefas")
      .set("authorization", `Bearer ${tokenDe("u2")}`)
      .send({ titulo: "revisar o lote 18", responsavelId: "u1" })
      .expect(201);

    expect(await promessa).toMatchObject({ titulo: "revisar o lote 18" });
    expect(recebidoPorOutro).not.toHaveBeenCalled();
  });

  test("duas abas do MESMO usuário recebem as duas — é sala, não conexão", async () => {
    const aba1 = conectar("u1");
    const aba2 = conectar("u1");
    await Promise.all([esperar(aba1, "connect"), esperar(aba2, "connect")]);
    const ambas = Promise.all([esperar(aba1, "tarefa:criada"), esperar(aba2, "tarefa:criada")]);
    await request(servidor)
      .post("/tarefas")
      .set("authorization", `Bearer ${tokenDe("u1")}`)
      .send({ titulo: "duas abas" })
      .expect(201);
    const [a, b] = await ambas;
    expect(a.id).toBe(b.id);
    expect(io.sockets.adapter.rooms.get("usuario:u1").size).toBe(2);
  });
});

describe("4 · GET /usuarios/online", () => {
  test("a lista traz usuários, e não ids de socket", async () => {
    const a = conectar("u1");
    const b = conectar("u1");
    const c = conectar("u2");
    await Promise.all([esperar(a, "connect"), esperar(b, "connect"), esperar(c, "connect")]);

    // a armadilha, medida: o adapter tem uma sala por socket, com o id dele
    const todas = [...io.sockets.adapter.rooms.keys()];
    expect(todas).toHaveLength(5); // 3 salas de socket + usuario:u1 + usuario:u2
    expect(todas.filter((s) => s.startsWith("usuario:"))).toHaveLength(2);

    const r = await request(servidor)
      .get("/usuarios/online")
      .set("authorization", `Bearer ${tokenDe("u1")}`)
      .expect(200);
    expect(r.body).toEqual(
      expect.arrayContaining([
        { usuarioId: "u1", conexoes: 2 },
        { usuarioId: "u2", conexoes: 1 },
      ])
    );
    expect(r.body).toHaveLength(2);
  });

  test("quem desconecta some da lista sozinho — o Socket.IO limpa a sala", async () => {
    const a = conectar("u9");
    await esperar(a, "connect");
    expect(io.sockets.adapter.rooms.has("usuario:u9")).toBe(true);
    a.disconnect();
    await new Promise((ok) => setTimeout(ok, 120));
    expect(io.sockets.adapter.rooms.has("usuario:u9")).toBe(false);
  });
});

describe("5 · chat por projeto", () => {
  test("a mensagem chega a todos do projeto, inclusive a quem enviou", async () => {
    const a = conectar("u1");
    const b = conectar("u2");
    await Promise.all([esperar(a, "connect"), esperar(b, "connect")]);
    // emitWithAck espera o servidor confirmar o join — nada de setTimeout torto
    expect(await a.emitWithAck("chat:entrar", { projetoId: "p1" })).toEqual({ sala: "chat:p1" });
    const avisoParaA = esperar(a, "chat:entrou");
    await b.emitWithAck("chat:entrar", { projetoId: "p1" });
    expect(await avisoParaA).toEqual({ usuario: "u2" }); // e b NÃO recebe o próprio aviso

    const ambos = Promise.all([esperar(a, "chat:mensagem"), esperar(b, "chat:mensagem")]);
    a.emit("chat:mensagem", { projetoId: "p1", texto: "bom dia" });
    const [paraA, paraB] = await ambos;
    expect(paraA).toMatchObject({ texto: "bom dia", usuario: "u1" });
    expect(paraB).toEqual(paraA);
    expect(Date.parse(paraA.timestamp)).not.toBeNaN();
  });

  test("projeto diferente não escuta a conversa do vizinho", async () => {
    const a = conectar("u1");
    const intruso = conectar("u3");
    await Promise.all([esperar(a, "connect"), esperar(intruso, "connect")]);
    await a.emitWithAck("chat:entrar", { projetoId: "p1" });
    await intruso.emitWithAck("chat:entrar", { projetoId: "p2" });

    const espiao = jest.fn();
    intruso.on("chat:mensagem", espiao);
    const chegou = esperar(a, "chat:mensagem");
    a.emit("chat:mensagem", { projetoId: "p1", texto: "segredo" });
    await chegou;
    await new Promise((ok) => setTimeout(ok, 120));
    expect(espiao).not.toHaveBeenCalled();
  });

  test("digitando vai para os OUTROS, nunca de volta para quem digita", async () => {
    const a = conectar("u1");
    const b = conectar("u2");
    await Promise.all([esperar(a, "connect"), esperar(b, "connect")]);
    await a.emitWithAck("chat:entrar", { projetoId: "p1" });
    await b.emitWithAck("chat:entrar", { projetoId: "p1" });

    const voltou = jest.fn();
    a.on("chat:digitando", voltou);
    const promessa = esperar(b, "chat:digitando");
    a.emit("chat:digitando", { projetoId: "p1" });
    expect(await promessa).toEqual({ usuario: "u1" });
    expect(voltou).not.toHaveBeenCalled();
  });
});

// ---- tests/socket.cliente.test.jsx
/**
 * @jest-environment jsdom
 */
import { EventEmitter } from "node:events";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { configurarSocket, encerrarSocket, obterSocket, useSocket } from "../src/hooks/useSocket";
import { SinoBadge } from "../src/componentes/SinoBadge.jsx";
import { ChatProjeto } from "../src/componentes/ChatProjeto.jsx";

// Dublê com a superfície que o hook usa: on/off/emit/close/connected.
// Não é mock de biblioteca — é um socket de mentira que se comporta como um.
function socketFalso() {
  const barramento = new EventEmitter();
  const s = {
    connected: false,
    enviados: [],
    criacoes: 0,
    on: (e, f) => (barramento.on(e, f), s),
    off: (e, f) => (barramento.off(e, f), s),
    emit: (e, dados, ack) => {
      s.enviados.push({ evento: e, dados });
      ack?.({ ok: true });
      return s;
    },
    close: () => barramento.removeAllListeners(),
    // o que o SERVIDOR manda para este cliente
    servidorEmite: (e, dados) => barramento.emit(e, dados),
    ouvintes: (e) => barramento.listenerCount(e),
    conectar: () => {
      s.connected = true;
      barramento.emit("connect");
    },
    cair: () => {
      s.connected = false;
      barramento.emit("disconnect", "transport close");
    },
  };
  return s;
}

let socket;
let criacoes;

beforeEach(() => {
  socket = socketFalso();
  criacoes = 0;
  configurarSocket(() => {
    criacoes++;
    return socket;
  });
});
afterEach(() => encerrarSocket());

const opcoes = { url: "http://localhost:3000", token: "jwt" };

describe("2 · o hook", () => {
  test("conexão singleton: dois componentes, um socket só", () => {
    const a = renderHook(() => useSocket(opcoes));
    const b = renderHook(() => useSocket(opcoes));
    expect(criacoes).toBe(1);
    expect(a.result.current.socket).toBe(b.result.current.socket);
    expect(obterSocket(opcoes)).toBe(socket);
  });

  test("emitir e ouvir mantêm a IDENTIDADE entre renders e através da reconexão", () => {
    const { result, rerender } = renderHook(() => useSocket(opcoes));
    const { emitir, ouvir } = result.current;
    rerender();
    act(() => socket.conectar());
    act(() => socket.cair());
    act(() => socket.conectar());
    expect(result.current.emitir).toBe(emitir);
    expect(result.current.ouvir).toBe(ouvir);
    expect(result.current.conectado).toBe(true); // o ESTADO muda; a função, não
  });

  test("conectado acompanha connect e disconnect", () => {
    const { result } = renderHook(() => useSocket(opcoes));
    expect(result.current.conectado).toBe(false);
    act(() => socket.conectar());
    expect(result.current.conectado).toBe(true);
    act(() => socket.cair());
    expect(result.current.conectado).toBe(false);
  });

  test("desmontar remove os ouvintes — e não deixa vazamento a cada montagem", () => {
    const { unmount } = renderHook(() => useSocket(opcoes));
    expect(socket.ouvintes("connect")).toBe(1);
    const segundo = renderHook(() => useSocket(opcoes));
    expect(socket.ouvintes("connect")).toBe(2);
    segundo.unmount();
    unmount();
    expect(socket.ouvintes("connect")).toBe(0);
    expect(socket.ouvintes("disconnect")).toBe(0);
  });

  test("a função devolvida por ouvir() cancela só o próprio ouvinte", () => {
    const { result } = renderHook(() => useSocket(opcoes));
    const a = jest.fn();
    const b = jest.fn();
    let cancelarA;
    act(() => {
      cancelarA = result.current.ouvir("x", a);
      result.current.ouvir("x", b);
    });
    act(() => socket.servidorEmite("x", 1));
    act(() => cancelarA());
    act(() => socket.servidorEmite("x", 2));
    expect(a).toHaveBeenCalledTimes(1);
    expect(b).toHaveBeenCalledTimes(2);
  });
});

describe("3 · SinoBadge", () => {
  const comQuery = (ui) => {
    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
    const invalidar = jest.spyOn(queryClient, "invalidateQueries");
    return { invalidar, ...render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>) };
  };

  test("conta as notificações e invalida o cache do React Query", () => {
    const { invalidar } = comQuery(<SinoBadge {...opcoes} />);
    expect(screen.getByRole("button", { name: "Notificações: 0 não lidas" })).toBeInTheDocument();

    act(() => socket.servidorEmite("tarefa:criada", { id: "1", dono: "u1" }));
    act(() => socket.servidorEmite("tarefa:criada", { id: "2", dono: "u1" }));

    expect(screen.getByRole("button", { name: "Notificações: 2 não lidas" })).toBeInTheDocument();
    expect(screen.getByText("2")).toBeInTheDocument();
    expect(invalidar).toHaveBeenCalledWith({ queryKey: ["tarefas"] });
    expect(invalidar).toHaveBeenCalledWith({ queryKey: ["estatisticas", "u1"] });
  });

  test("acima de 9 o contador vira 9+, e o clique zera", async () => {
    const usuario = userEvent.setup();
    comQuery(<SinoBadge {...opcoes} />);
    act(() => {
      for (let i = 0; i < 12; i++) socket.servidorEmite("tarefa:criada", { id: String(i), dono: "u1" });
    });
    expect(screen.getByText("9+")).toBeInTheDocument();
    await usuario.click(screen.getByRole("button"));
    expect(screen.queryByText("9+")).not.toBeInTheDocument();
  });
});

describe("5 · ChatProjeto", () => {
  test("entra na sala do projeto ao montar", () => {
    render(<ChatProjeto projetoId="p1" {...opcoes} />);
    expect(socket.enviados[0]).toEqual({ evento: "chat:entrar", dados: { projetoId: "p1" } });
  });

  test("mensagem recebida aparece na lista, com autor", () => {
    render(<ChatProjeto projetoId="p1" {...opcoes} />);
    act(() =>
      socket.servidorEmite("chat:mensagem", { texto: "bom dia", usuario: "u2", timestamp: "t1" })
    );
    expect(screen.getByRole("listitem")).toHaveTextContent("u2: bom dia");
  });

  test("o campo fica desabilitado enquanto o socket está caído", () => {
    render(<ChatProjeto projetoId="p1" {...opcoes} />);
    expect(screen.getByLabelText("Mensagem")).toBeDisabled();
    expect(screen.getByPlaceholderText("Reconectando…")).toBeInTheDocument();
    act(() => socket.conectar());
    expect(screen.getByLabelText("Mensagem")).toBeEnabled();
  });

  test("enviar emite chat:mensagem e limpa o campo; espaço em branco não envia", async () => {
    const usuario = userEvent.setup();
    render(<ChatProjeto projetoId="p1" {...opcoes} />);
    act(() => socket.conectar());
    const campo = screen.getByLabelText("Mensagem");

    await usuario.type(campo, "   ");
    expect(screen.getByRole("button", { name: "Enviar" })).toBeDisabled();

    await usuario.clear(campo);
    await usuario.type(campo, "olá{Enter}");
    expect(socket.enviados.at(-1)).toEqual({
      evento: "chat:mensagem",
      dados: { projetoId: "p1", texto: "olá" },
    });
    expect(campo).toHaveValue("");
  });

  test("o indicador some 3 s depois do ÚLTIMO evento — e cada evento adia o prazo", () => {
    jest.useFakeTimers();
    try {
      render(<ChatProjeto projetoId="p1" {...opcoes} />);
      act(() => socket.servidorEmite("chat:digitando", { usuario: "u2" }));
      expect(screen.getByText("u2 está digitando…")).toBeInTheDocument();

      act(() => jest.advanceTimersByTime(2500));
      expect(screen.getByText("u2 está digitando…")).toBeInTheDocument();

      act(() => socket.servidorEmite("chat:digitando", { usuario: "u2" })); // adia
      act(() => jest.advanceTimersByTime(2500));
      expect(screen.getByText("u2 está digitando…")).toBeInTheDocument();

      act(() => jest.advanceTimersByTime(600)); // 3,1 s desde o último
      expect(screen.queryByText(/está digitando/)).not.toBeInTheDocument();
    } finally {
      jest.useRealTimers();
    }
  });

  test("digitar não manda um evento por tecla — há throttle na saída", () => {
    jest.useFakeTimers();
    try {
      render(<ChatProjeto projetoId="p1" {...opcoes} />);
      act(() => socket.conectar());
      const campo = screen.getByLabelText("Mensagem");
      const frase = "escrevendo uma frase inteira";
      // fireEvent.change, e não dispatchEvent(new Event("input")): o React só
      // dispara onChange quando o VALOR muda de fato — um evento "input" cru,
      // sem mexer no value, não faz o componente reagir a nada.
      for (let i = 1; i <= frase.length; i++) {
        fireEvent.change(campo, { target: { value: frase.slice(0, i) } });
      }
      const avisos = socket.enviados.filter((e) => e.evento === "chat:digitando");
      expect(avisos).toHaveLength(1); // 27 teclas, 1 aviso

      // os fake timers do Jest também congelam o Date.now() que o throttle lê
      act(() => jest.advanceTimersByTime(1100));
      fireEvent.change(campo, { target: { value: frase + "!" } });
      expect(socket.enviados.filter((e) => e.evento === "chat:digitando")).toHaveLength(2);
    } finally {
      jest.useRealTimers();
    }
  });
});

Duas coisas que o enunciado pede quase de passagem e que decidem se isto funciona. A primeira: io.sockets.adapter.rooms traz uma sala por socket, com o nome igual ao id da conexão — a lista de "usuários online" sai cheia de ids de socket se você não filtrar pelo prefixo usuario:. O teste mede: três conexões de dois usuários dão cinco salas. A segunda: as funções emitir e ouvir precisam de identidade estável. Se elas dependerem do estado conectado, mudam a cada reconexão, e todo useEffect([ouvir]) do aplicativo se reinscreve junto — os ouvintes se multiplicam e a mesma notificação aparece duas, quatro, oito vezes. Por isso o socket é lido por ref dentro de useCallback([]). Fora isso: socket.to() exclui quem enviou e io.to() inclui — trocar um pelo outro é o motivo de a própria mensagem não aparecer no chat.

HTTP é uma conversa em que só o cliente pode falar primeiro; o WebSocket abre um canal em que os dois lados falam quando quiserem. Essa diferença explica tudo o que vem junto: a conexão precisa ser autenticada no handshake, precisa se reconectar sozinha quando cair, e precisa de um lugar compartilhado — o Redis, aqui — assim que existir mais de uma instância do servidor. E quando só o servidor tem o que dizer, o SSE entrega o mesmo resultado por uma fração da complexidade.

Fontes e Referências

Exercícios

Exercício 1

O chat funciona perfeitamente com uma instância. Depois de escalar para três, usuários no mesmo projeto param de ver as mensagens uns dos outros. O que aconteceu?

io.to(`projeto:${projetoId}`).emit('mensagem:nova', mensagem);
Ver resposta

✓ Resposta: As salas do Socket.IO vivem na memória de cada processo. Com três instâncias atrás de um balanceador, cada uma conhece apenas os sockets conectados a ela: o emit para a sala alcança quem está naquele processo e mais ninguém. Se Ana caiu na instância 1 e Bruno na 2, eles estão na "mesma sala" apenas nominalmente — são duas salas homônimas em processos diferentes. A solução é o Redis Adapter, que faz os processos publicarem e assinarem os eventos entre si por um canal comum; com ele, um emit numa instância chega às demais e a sala volta a ser única do ponto de vista da aplicação. É o mesmo princípio já visto com cache e com rate limit: estado compartilhado precisa morar fora do processo. E há um segundo problema de escala que aparece junto e costuma vir antes: sem sticky session configurada no balanceador, a própria conexão falha de forma intermitente — o Socket.IO começa por long polling e faz várias requisições HTTP antes de migrar para WebSocket, e se elas caírem em instâncias diferentes o handshake nunca se completa. O sintoma clássico é o console repetindo erros de conexão logo no início, antes mesmo de qualquer mensagem.

Exercício 2

O middleware de autenticação valida o token no handshake. O token expira em 15 minutos. Um usuário fica com a aba aberta por 3 horas. O que acontece?

io.use(async (socket, next) => {
  const token = socket.handshake.auth?.token;
  const payload = jwt.verify(token, config.jwtSecret);
  socket.usuario = await Usuario.findById(payload.id);
  next();
});
Ver resposta

✓ Resposta: Ele continua conectado e autorizado indefinidamente. O middleware roda uma vez, no handshake; depois disso a conexão é persistente e ninguém revalida nada. Passadas as três horas, o token está expirado há muito tempo, mas o socket continua aberto, recebendo e enviando eventos com a identidade guardada em socket.usuario. O mesmo vale para revogação: se o usuário for desativado ou banido, ele permanece ativo no tempo real até que a conexão caia por outro motivo. É uma diferença de fundo em relação ao HTTP, em que cada requisição carrega o token e passa pelo middleware de novo. As defesas usuais são três, e costumam ser combinadas: verificar a expiração também em cada evento sensível, e não só no handshake; manter um temporizador no servidor que desconecte o socket quando o token expirar, forçando o cliente a reconectar com um token novo; e, no cliente, atualizar o token antes do vencimento e reconectar. Vale também a advertência de que reconexão não revalida sozinha se o cliente reenviar o token antigo guardado em memória — o Socket.IO reconecta com os mesmos dados de auth, então é preciso atualizá-los explicitamente antes.

Exercício 3

O evento de "usuário digitando" é emitido a cada tecla. Com 40 pessoas numa sala, o servidor fica sobrecarregado. Qual a correção — e por que o debounce comum não basta?

<input onChange={(e) => {
  setTexto(e.target.value);
  socket.emit('digitando', { projetoId });
}} />
Ver resposta

✓ Resposta: Cada tecla vira uma mensagem, e cada mensagem é retransmitida a todos os outros na sala — com 40 pessoas digitando, a conta cresce pelo produto, não pela soma. A correção certa aqui é throttle, não debounce, e a diferença importa: o debounce espera a pessoa parar de digitar para então emitir, o que produz exatamente o contrário do desejado — o aviso "está digitando" só apareceria depois que ela parou. O throttle deixa passar no máximo um evento a cada intervalo, digamos dois segundos, mantendo o indicador vivo enquanto a digitação acontece. O par natural dele é um evento de "parou de digitar", esse sim com debounce, ou um tempo limite no receptor que apaga o indicador sozinho após alguns segundos sem novidade — o que é mais robusto, porque cobre também o caso de a pessoa fechar a aba no meio da frase. Vale reter a distinção, que aparece sempre que há evento de alta frequência: throttle limita a taxa, debounce espera o silêncio. E, para eventos assim, considere não usar confirmação nem garantia de entrega: perder um "está digitando" não custa nada, e tratá-lo como mensagem importante é desperdiçar recurso com o que é puramente cosmético.

Exercício 4

Quando usar WebSocket, quando usar SSE e quando o polling ainda é a escolha certa? Classifique os três casos.

// A — notificações do sistema para o usuário (só o servidor fala)
// B — editor colaborativo com cursor de cada participante
// C — status de um relatório que leva de 30s a 5min para ficar pronto
Ver resposta

✓ Resposta: A pede Server-Sent Events: o fluxo é de mão única, e o SSE resolve isso com uma fração da complexidade — é HTTP comum, atravessa proxy e firewall sem configuração especial, e reconecta sozinho, inclusive retomando do ponto em que parou por meio do Last-Event-ID. B pede WebSocket de verdade: os dois lados falam o tempo todo, a frequência é alta e a latência importa. C é o caso em que o polling continua sendo a resposta certa — perguntar a cada cinco segundos custa quase nada, o código é trivial, não há conexão para manter viva e o comportamento é previsível; abrir um canal persistente para transmitir meia dúzia de atualizações ao longo de minutos é complexidade sem retorno. Vale o critério geral: a pergunta não é "o que é mais moderno", é "quem precisa falar, com que frequência e por quanto tempo". E vale a ressalva operacional que costuma decidir a escolha na prática: conexão persistente tem custo real de infraestrutura — ocupa um descritor de arquivo por cliente, atravessa balanceador e proxy com configuração própria, e muitas plataformas serverless simplesmente não a suportam. Uma limitação específica do SSE que também pesa: sobre HTTP/1.1 o navegador limita a seis conexões por domínio, e cada aba aberta consome uma.

Exercício 5

A API REST cria a tarefa e o front atualiza sozinho pelo WebSocket. Mas quem criou a tarefa vê o item duplicado por um instante. Por quê?

// no cliente, ao criar
const nova = await api.post('/tarefas', dados);
setTarefas((prev) => [...prev, nova]);

// e o socket também escuta
socket.on('tarefa:criada', (t) => {
  setTarefas((prev) => [...prev, t]);
});
Ver resposta

✓ Resposta: O item entra duas vezes: uma pela resposta da requisição e outra pelo evento que o servidor transmitiu para a sala — e quem criou também está na sala. É a duplicação clássica de quem mistura resposta HTTP com notificação em tempo real. Há três correções possíveis, e a escolha muda o desenho. A primeira é não retransmitir para quem originou: no Socket.IO, emitir com socket.broadcast.to(sala) em vez de io.to(sala) exclui o próprio remetente — desde que o evento saia do contexto daquele socket, o que exige passar o id da conexão junto da requisição REST quando a emissão nasce no controlador. A segunda é ignorar no cliente o evento cujo autor é você mesmo, comparando com o id do usuário. A terceira, e a mais robusta, é deduplicar por id ao inserir — verificar se o item já está na lista antes de adicionar —, o que protege também contra o evento chegando duas vezes por reconexão. Vale notar que a terceira resolve um problema maior: em tempo real, entrega duplicada é normal, não excepcional. Reconexão, retransmissão e múltiplas abas produzem eventos repetidos, e um cliente que assume "cada evento chega exatamente uma vez" vai falhar em produção. Tratar a atualização como idempotente — aplicar o mesmo evento duas vezes ter o mesmo efeito de aplicá-lo uma — é o que torna a tela confiável.

Comentários

Mais em Javascript

Performance em aplicações web
Performance em aplicações web

É fácil otimizar a coisa errada, e por isso a ordem importa: medir antes. O…

Callbacks: o começo de tudo
Callbacks: o começo de tudo

Antes das Promises, esperar era passar uma função para ser chamada depois. O…

Mini Projeto: Calculadora no Console
Mini Projeto: Calculadora no Console

Chegamos ao fim do primeiro módulo, e o jeito de fechá-lo é construindo: uma…