Saltar para o conteúdo
WebSockets com Socket.io: Chat em Tempo Real do Zero
Node.js

WebSockets com Socket.io: Chat em Tempo Real do Zero

10 de outubro de 2024·Paulo de Paula

WebSocket é o protocolo certo para comunicação bidirecional em tempo real. HTTP polling funciona para dados que atualizam a cada minuto; para mensagens instantâneas, você precisa de WebSocket.

Arquitetura do sistema

Cliente A ──┐
Cliente B ──┼── Socket.io Server ── Redis Adapter ── Socket.io Server
Cliente C ──┘                                         (segunda instância)

O Redis Adapter permite escalar horizontalmente — mensagens emitidas em qualquer instância chegam a todos os clientes.

Servidor: setup completo

// server.js
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';

const app = express();
const httpServer = createServer(app);

const io = new Server(httpServer, {
  cors: { origin: process.env.FRONTEND_URL, credentials: true },
  pingTimeout: 60000,
  pingInterval: 25000,
});

// Redis para múltiplas instâncias
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));

Autenticação no handshake

// Middleware executado antes da conexão
io.use(async (socket, next) => {
  const token = socket.handshake.auth.token;
  
  if (!token) return next(new Error('Token obrigatório'));
  
  try {
    const user = await verifyJWT(token);
    socket.user = user; // disponível em todos os handlers
    next();
  } catch {
    next(new Error('Token inválido'));
  }
});

Gerenciamento de salas e presença

io.on('connection', (socket) => {
  console.log(`${socket.user.name} conectou — ${socket.id}`);

  // Entrar em uma sala
  socket.on('join:room', async ({ roomId }) => {
    await socket.join(roomId);
    
    // Notifica os outros na sala
    socket.to(roomId).emit('user:joined', {
      userId: socket.user.id,
      name: socket.user.name,
    });
    
    // Envia histórico das últimas 50 mensagens
    const history = await Message.findAll({
      where: { roomId },
      order: [['createdAt', 'DESC']],
      limit: 50,
    });
    socket.emit('room:history', history.reverse());
  });

  // Enviar mensagem
  socket.on('message:send', async ({ roomId, content }) => {
    // Valida que o usuário está na sala
    const rooms = socket.rooms;
    if (!rooms.has(roomId)) return;
    
    const message = await Message.create({
      roomId,
      userId: socket.user.id,
      content: sanitize(content),
    });
    
    // Broadcast para todos na sala (inclusive quem enviou)
    io.to(roomId).emit('message:new', {
      id: message.id,
      content: message.content,
      author: socket.user.name,
      createdAt: message.createdAt,
    });
  });

  // Indicador "digitando..."
  socket.on('typing:start', ({ roomId }) => {
    socket.to(roomId).emit('typing:update', {
      userId: socket.user.id,
      name: socket.user.name,
      isTyping: true,
    });
  });

  socket.on('typing:stop', ({ roomId }) => {
    socket.to(roomId).emit('typing:update', {
      userId: socket.user.id,
      name: socket.user.name,
      isTyping: false,
    });
  });

  // Desconexão
  socket.on('disconnect', (reason) => {
    console.log(`${socket.user.name} desconectou — ${reason}`);
    // Notifica todas as salas que o usuário estava
    socket.rooms.forEach((roomId) => {
      socket.to(roomId).emit('user:left', { userId: socket.user.id });
    });
  });
});

Cliente React

// hooks/useChat.ts
import { useEffect, useRef, useState, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';

interface Message {
  id: string;
  content: string;
  author: string;
  createdAt: string;
}

export function useChat(roomId: string) {
  const socketRef = useRef<Socket | null>(null);
  const [messages, setMessages] = useState<Message[]>([]);
  const [typingUsers, setTypingUsers] = useState<string[]>([]);
  const [connected, setConnected] = useState(false);
  const typingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    const token = localStorage.getItem('token');
    
    socketRef.current = io(process.env.NEXT_PUBLIC_API_URL!, {
      auth: { token },
      reconnection: true,
      reconnectionDelay: 1000,
      reconnectionAttempts: 5,
    });

    const socket = socketRef.current;

    socket.on('connect', () => {
      setConnected(true);
      socket.emit('join:room', { roomId });
    });

    socket.on('disconnect', () => setConnected(false));

    socket.on('room:history', (history: Message[]) => {
      setMessages(history);
    });

    socket.on('message:new', (msg: Message) => {
      setMessages((prev) => [...prev, msg]);
    });

    socket.on('typing:update', ({ name, isTyping }: { name: string; isTyping: boolean }) => {
      setTypingUsers((prev) =>
        isTyping ? [...new Set([...prev, name])] : prev.filter((n) => n !== name)
      );
    });

    return () => { socket.disconnect(); };
  }, [roomId]);

  const sendMessage = useCallback((content: string) => {
    socketRef.current?.emit('message:send', { roomId, content });
  }, [roomId]);

  const sendTyping = useCallback(() => {
    socketRef.current?.emit('typing:start', { roomId });
    if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
    typingTimerRef.current = setTimeout(() => {
      socketRef.current?.emit('typing:stop', { roomId });
    }, 2000);
  }, [roomId]);

  return { messages, typingUsers, connected, sendMessage, sendTyping };
}

Lidando com reconexão

O Socket.io reconecta automaticamente, mas você precisa re-entrar nas salas:

socket.on('connect', () => {
  // Se já estava em salas antes da desconexão, rejoin
  if (currentRoomId) {
    socket.emit('join:room', { roomId: currentRoomId });
  }
});

Escalando com Redis em produção

Com um único servidor você aguenta ~10k conexões simultâneas. Para mais, use clustering com o Redis Adapter — cada instância publica no Redis e todas recebem, transparente para o código da aplicação.

O ponto crítico é monitorar o lag do Redis: se o pub/sub adicionar mais de 10ms de latência, o “tempo real” começa a parecer atrasado. Use LATENCY HISTORY event no Redis para monitorar isso.