1

Socket.IO do Zero: Chat Completo com Salas, Typing Indicator e Histórico de Mensagens

O problema com tutoriais de chat

A maioria dos tutoriais de Socket.IO para no "Hello World": um emit no cliente, um on no servidor, e pronto. O resultado é um chat que perde mensagens quando o usuário recarrega a página, não separa conversas por sala, e mostra "fulano está digitando" para todos os usuários conectados ao servidor, independente de onde estão.

Este post constrói um chat que resolve esses três problemas de verdade: salas isoladas com controle de entrada e saída, typing indicator com debounce que não inunda o servidor de eventos, e histórico persistido em PostgreSQL via Prisma. O código roda, tem tipos, e você pode copiar e adaptar.

Arquitetura do servidor com Express e Socket.IO

O servidor usa Express como base HTTP e Socket.IO como camada de WebSocket. A separação importa: Express serve o health check e qualquer endpoint REST que você precise, enquanto Socket.IO cuida exclusivamente da comunicação bidirecional.

// src/server.ts
import express from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";
import { PrismaClient } from "@prisma/client";

const app = express();
const httpServer = createServer(app);
const prisma = new PrismaClient();

// cors configurado para o domínio do frontend; "*" só em dev
const io = new Server(httpServer, {
  cors: {
    origin: process.env.CLIENT_ORIGIN ?? "http://localhost:3000",
    methods: ["GET", "POST"],
  },
  // pingTimeout alto evita desconexões falsas em redes móveis
  pingTimeout: 30_000,
  pingInterval: 25_000,
});

app.get("/health", (_req, res) => {
  res.json({ status: "ok", connections: io.engine.clientsCount });
});

const PORT = Number(process.env.PORT) || 4000;
httpServer.listen(PORT, () => {
  console.log(`Server listening on :${PORT}`);
});

export { io, prisma };

Se você precisa escalar para múltiplas instâncias, o Socket.IO sozinho não resolve: cada processo Node.js mantém seu próprio mapa de sockets. A solução padrão é o adapter do Redis, que sincroniza eventos entre processos via Pub/Sub.

Schema do banco: mensagens e salas

O modelo é simples de propósito. Uma tabela de salas, uma de mensagens com foreign key para a sala, e o username como string (sem autenticação neste escopo, mas você pode integrar com NextAuth.js quando precisar).

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Room {
  id        String    @id @default(cuid())
  name      String    @unique
  createdAt DateTime  @default(now())
  messages  Message[]
}

model Message {
  id        String   @id @default(cuid())
  content   String
  username  String
  roomId    String
  room      Room     @relation(fields: [roomId], references: [id])
  createdAt DateTime @default(now())

  @@index([roomId, createdAt]) // índice composto para paginação por sala
}

O índice composto [roomId, createdAt] é o que permite buscar "as últimas 50 mensagens da sala X" sem full scan. Sem ele, a query de histórico degrada linearmente com o volume de mensagens. Se você quer entender mais sobre modelagem de dados com Prisma, o post sobre API REST com Fastify e Prisma cobre o setup completo.

Tipos compartilhados entre cliente e servidor

Definir os tipos dos eventos em um arquivo compartilhado evita o problema clássico de emitir "mesage" (com typo) no cliente e escutar "message" no servidor.

// src/types/events.ts
export interface ServerToClientEvents {
  "message:new": (message: MessagePayload) => void;
  "room:joined": (data: { roomId: string; username: string }) => void;
  "room:left": (data: { roomId: string; username: string }) => void;
  "typing:update": (data: { username: string; isTyping: boolean }) => void;
  "

---

Leia o artigo completo em [https://www.vivodecodigo.com.br/backend/socketio-chat-salas-typing-indicator-historico-mensagens-1786104123738](https://www.vivodecodigo.com.br/backend/socketio-chat-salas-typing-indicator-historico-mensagens-1786104123738)
Carregando publicação patrocinada...