Criar um chat WebSocket com Claude Code
Guia para chat WebSocket com Claude Code: autenticação, reconexão, limites e código Node.js executável.
Um chat WebSocket é pequeno o bastante para construir rápido, mas realista o bastante para mostrar problemas de produção: autenticação no momento da conexão, reconexão depois de queda, envio para a sala correta, limite contra spam, validação de JSON e um teste simples para garantir que a função ainda funciona.
Neste guia usamos a API WebSocket do navegador e o pacote ws do Node.js. WebSocket é uma conexão persistente e bidirecional: depois do upgrade HTTP, navegador e servidor podem enviar mensagens pela mesma conexão. Use fontes oficiais enquanto adapta o exemplo: MDN WebSocket API, Writing WebSocket client applications, RFC 6455, evento upgrade do Node.js HTTP, README do ws e guia OWASP para teste de WebSockets. Para o fluxo da ferramenta, consulte a documentação oficial do Claude Code.
Para continuar, veja também desenvolvimento de API com Claude Code, code review com Claude Code e boas práticas de segurança.
Três usos práticos
O primeiro uso é um chat pequeno para comunidade ou suporte. Sala, nome, texto e histórico recente bastam para começar. Sem autenticação e limite, porém, um único script pode enviar mensagens sem parar.
O segundo uso é um painel em tempo real. Eventos de deploy, pagamentos, estoque e filas podem aparecer no navegador assim que acontecem. O ponto crítico é decidir quais eventos antigos podem ser descartados, porque a API WebSocket do navegador não oferece contrapressão automática forte.
O terceiro uso é uma sala de perguntas ligada a artigos, cursos ou materiais pagos. O leitor passa do conteúdo para a conversa, e a equipe pode indicar templates, produtos ou consultoria. Isso ajuda a conversão, mas exige regras claras de log, moderação e dados pessoais.
| Uso | Por que WebSocket ajuda | Decisão inicial |
|---|---|---|
| Chat pequeno | Mensagens bidirecionais rápidas | Autenticação, salas, histórico |
| Painel ao vivo | Servidor envia eventos | Descarte e reconexão |
| Suporte educacional | Conversa segue o artigo | Logs, moderação, chamada para ação |
Arquitetura
Fan-out é distribuir uma mensagem recebida para todos os clientes da mesma sala. Heartbeat é verificar periodicamente se a conexão longa ainda está viva.
flowchart LR
BrowserA["Aba A"] -->|ws:// /chat| Node["Node.js server"]
BrowserB["Aba B"] -->|ws:// /chat| Node
Smoke["smoke-client.mjs"] -->|ws:// /chat| Node
Node --> Auth["token and Origin check"]
Node --> Rooms["room registry"]
Rooms --> Fanout["fan-out to room peers"]
Node --> History["last 50 messages"]
Node --> Limit["rate limit and max payload"]
Prompt para Claude Code
Crie um chat WebSocket mínimo com Node.js 20+ e o pacote ws.
Requisitos:
- Usar apenas server.js, index.html, smoke-client.mjs e package.json
- Servir a interface em http://localhost:8080
- Validar token e Origin durante o upgrade WebSocket de /chat
- Fazer fan-out por sala
- Manter só as últimas 50 mensagens em memória
- Limitar cada conexão a 20 mensagens por 10 segundos
- Limitar texto a 500 caracteres
- Reconnect no cliente com backoff exponencial após close
- Verificar readyState e bufferedAmount antes de enviar
- smoke-client.mjs deve conectar, enviar uma mensagem e sair com sucesso
Não faça:
- Trocar por Socket.IO
- Aceitar conexão sem autenticação
- Usar JSON sem validar
- Descrever histórico em memória como persistência de produção
Executar localmente
mkdir ws-chat-demo
cd ws-chat-demo
npm init -y
npm install ws
npm run start
Troque o package.json:
{
"type": "module",
"scripts": {
"start": "node server.js",
"smoke": "node smoke-client.mjs"
},
"dependencies": {
"ws": "^8.18.3"
},
"engines": {
"node": ">=20"
}
}
server.js
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { WebSocket, WebSocketServer } from "ws";
const PORT = Number(process.env.PORT ?? 8080);
const AUTH_TOKEN = process.env.CHAT_TOKEN ?? "dev-token";
const MAX_MESSAGE_LENGTH = 500;
const HISTORY_LIMIT = 50;
const RATE_WINDOW_MS = 10_000;
const RATE_LIMIT = 20;
const CLIENT_FILE = join(fileURLToPath(new URL(".", import.meta.url)), "index.html");
const ALLOWED_ORIGINS = new Set(
(process.env.ALLOWED_ORIGINS ?? `http://localhost:${PORT},http://127.0.0.1:${PORT}`)
.split(",")
.map((value) => value.trim())
.filter(Boolean)
);
const rooms = new Map();
const histories = new Map();
const clients = new Map();
const server = createServer(async (request, response) => {
if (request.url === "/" || request.url === "/index.html") {
try {
const html = await readFile(CLIENT_FILE, "utf8");
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(html);
} catch {
response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
response.end("index.html was not found");
}
return;
}
if (request.url === "/healthz") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true, clients: clients.size }));
return;
}
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
response.end("not found");
});
const wss = new WebSocketServer({ noServer: true, maxPayload: 2048 });
server.on("upgrade", (request, socket, head) => {
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
if (url.pathname !== "/chat") {
rejectUpgrade(socket, 404, "Not Found");
return;
}
const origin = request.headers.origin ?? "";
if (!ALLOWED_ORIGINS.has(origin)) {
rejectUpgrade(socket, 403, "Forbidden");
return;
}
const token = url.searchParams.get("token");
if (token !== AUTH_TOKEN) {
rejectUpgrade(socket, 401, "Unauthorized");
return;
}
const context = {
name: cleanName(url.searchParams.get("name")),
room: cleanRoom(url.searchParams.get("room"))
};
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit("connection", ws, request, context);
});
});
wss.on("connection", (ws, request, context) => {
const client = {
id: randomUUID(),
name: context.name,
room: context.room,
isAlive: true,
rateResetAt: Date.now() + RATE_WINDOW_MS,
messagesInWindow: 0
};
clients.set(ws, client);
joinRoom(ws, client.room);
send(ws, { type: "system", message: `connected as ${client.name}`, clientId: client.id });
send(ws, { type: "history", messages: histories.get(client.room) ?? [] });
broadcast(client.room, { type: "presence", message: `${client.name} joined`, online: roomSize(client.room) });
ws.on("pong", () => {
client.isAlive = true;
});
ws.on("message", (raw, isBinary) => {
if (isBinary) {
ws.close(1003, "text messages only");
return;
}
if (!consumeQuota(client)) {
send(ws, { type: "error", code: "rate_limited", message: "Too many messages. Wait a moment." });
return;
}
const textBody = raw.toString("utf8");
if (textBody.length > MAX_MESSAGE_LENGTH) {
send(ws, { type: "error", code: "too_large", message: `Message must be ${MAX_MESSAGE_LENGTH} characters or less.` });
return;
}
let event;
try {
event = JSON.parse(textBody);
} catch {
send(ws, { type: "error", code: "bad_json", message: "Send JSON such as {\"type\":\"message\",\"text\":\"hi\"}." });
return;
}
if (event.type !== "message") {
send(ws, { type: "error", code: "bad_type", message: "Only message events are accepted." });
return;
}
const text = String(event.text ?? "").trim();
if (!text) {
send(ws, { type: "error", code: "empty", message: "Message text is required." });
return;
}
const message = {
id: randomUUID(),
room: client.room,
from: client.name,
text,
sentAt: new Date().toISOString()
};
remember(client.room, message);
broadcast(client.room, { type: "message", message });
});
ws.on("close", () => {
leaveRoom(ws);
clients.delete(ws);
broadcast(client.room, { type: "presence", message: `${client.name} left`, online: roomSize(client.room) });
});
ws.on("error", (error) => {
console.error("websocket error", error);
});
});
const heartbeat = setInterval(() => {
for (const ws of wss.clients) {
const client = clients.get(ws);
if (!client) continue;
if (!client.isAlive) {
ws.terminate();
continue;
}
client.isAlive = false;
ws.ping();
}
}, 30_000);
server.listen(PORT, () => {
console.log(`Chat demo: http://localhost:${PORT}`);
console.log(`Token: ${AUTH_TOKEN}`);
});
process.on("SIGINT", () => {
clearInterval(heartbeat);
wss.close();
server.close(() => process.exit(0));
});
function rejectUpgrade(socket, statusCode, message) {
socket.write(`HTTP/1.1 ${statusCode} ${message}\r\nConnection: close\r\n\r\n`);
socket.destroy();
}
function cleanName(value) {
const name = String(value ?? "guest").trim().slice(0, 24);
return /^[\w -]+$/.test(name) ? name : "guest";
}
function cleanRoom(value) {
const room = String(value ?? "lobby").trim().slice(0, 32);
return /^[a-zA-Z0-9_-]+$/.test(room) ? room : "lobby";
}
function joinRoom(ws, room) {
if (!rooms.has(room)) rooms.set(room, new Set());
rooms.get(room).add(ws);
}
function leaveRoom(ws) {
const client = clients.get(ws);
if (!client) return;
const peers = rooms.get(client.room);
if (!peers) return;
peers.delete(ws);
if (peers.size === 0) rooms.delete(client.room);
}
function roomSize(room) {
return rooms.get(room)?.size ?? 0;
}
function send(ws, payload) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
function broadcast(room, payload) {
const peers = rooms.get(room);
if (!peers) return;
const body = JSON.stringify(payload);
for (const peer of peers) {
if (peer.readyState === WebSocket.OPEN) {
peer.send(body);
}
}
}
function remember(room, message) {
const history = histories.get(room) ?? [];
history.push(message);
while (history.length > HISTORY_LIMIT) history.shift();
histories.set(room, history);
}
function consumeQuota(client) {
const now = Date.now();
if (now > client.rateResetAt) {
client.rateResetAt = now + RATE_WINDOW_MS;
client.messagesInWindow = 0;
}
client.messagesInWindow += 1;
return client.messagesInWindow <= RATE_LIMIT;
}
index.html
<!doctype html>
<html lang="pt">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Demonstração de chat WebSocket</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 760px; margin: 32px auto; padding: 0 16px; }
label { display: block; margin-top: 12px; }
input, button { font: inherit; padding: 8px; }
#log { border: 1px solid #ccc; min-height: 240px; padding: 12px; overflow: auto; }
.message { margin: 0 0 8px; }
.muted { color: #666; }
.error { color: #b00020; }
</style>
</head>
<body>
<h1>Demonstração de chat WebSocket</h1>
<p id="status" class="muted">offline</p>
<label>Sala <input id="room" value="lobby" /></label>
<label>Nome <input id="name" value="masa" /></label>
<label>Token <input id="token" value="dev-token" /></label>
<button id="connect">Conectar</button>
<button id="disconnect">Desconectar</button>
<div id="log" aria-live="polite"></div>
<form id="form">
<label>Mensagem <input id="message" autocomplete="off" maxlength="500" /></label>
<button type="submit">Enviar</button>
</form>
<script>
const statusEl = document.querySelector("#status");
const logEl = document.querySelector("#log");
const formEl = document.querySelector("#form");
const roomEl = document.querySelector("#room");
const nameEl = document.querySelector("#name");
const tokenEl = document.querySelector("#token");
const messageEl = document.querySelector("#message");
const connectEl = document.querySelector("#connect");
const disconnectEl = document.querySelector("#disconnect");
let socket;
let reconnectTimer;
let reconnectAttempt = 0;
let manuallyClosed = false;
const pendingMessages = [];
connectEl.addEventListener("click", connect);
disconnectEl.addEventListener("click", () => {
manuallyClosed = true;
clearTimeout(reconnectTimer);
if (socket) socket.close(1000, "user disconnected");
});
formEl.addEventListener("submit", (event) => {
event.preventDefault();
const text = messageEl.value.trim();
if (!text) return;
const payload = JSON.stringify({ type: "message", text });
if (socket?.readyState === WebSocket.OPEN && socket.bufferedAmount < 64 * 1024) {
socket.send(payload);
} else {
pendingMessages.push(payload);
writeLog("Socket ainda não está pronto; mensagem em fila.", "muted");
}
messageEl.value = "";
});
function connect() {
manuallyClosed = false;
clearTimeout(reconnectTimer);
if (socket && socket.readyState === WebSocket.OPEN) return;
const params = new URLSearchParams({
token: tokenEl.value,
name: nameEl.value,
room: roomEl.value
});
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
socket = new WebSocket(`${protocol}//${location.host}/chat?${params.toString()}`);
setStatus("conectando");
socket.addEventListener("open", () => {
reconnectAttempt = 0;
setStatus("online");
flushPending();
});
socket.addEventListener("message", (event) => {
const data = JSON.parse(event.data);
renderEvent(data);
});
socket.addEventListener("close", (event) => {
setStatus(`fechado ${event.code}`);
if (!manuallyClosed) scheduleReconnect();
});
socket.addEventListener("error", () => {
writeLog("Erro WebSocket. Verifique o log do servidor.", "error");
});
}
function scheduleReconnect() {
const delay = Math.min(1000 * 2 ** reconnectAttempt, 10000) + Math.floor(Math.random() * 250);
reconnectAttempt += 1;
setStatus(`reconectando em ${delay}ms`);
reconnectTimer = setTimeout(connect, delay);
}
function flushPending() {
while (pendingMessages.length > 0 && socket.readyState === WebSocket.OPEN && socket.bufferedAmount < 64 * 1024) {
socket.send(pendingMessages.shift());
}
}
function renderEvent(data) {
if (data.type === "message") {
writeLog(`${data.message.from}: ${data.message.text}`);
} else if (data.type === "history") {
writeLog(`${data.messages.length} mensagens anteriores carregadas.`, "muted");
data.messages.forEach((message) => writeLog(`${message.from}: ${message.text}`));
} else if (data.type === "error") {
writeLog(`${data.code}: ${data.message}`, "error");
} else {
writeLog(data.message ?? JSON.stringify(data), "muted");
}
}
function setStatus(value) {
statusEl.textContent = value;
}
function writeLog(value, className = "message") {
const line = document.createElement("p");
line.className = className;
line.textContent = value;
logEl.append(line);
logEl.scrollTop = logEl.scrollHeight;
}
connect();
</script>
</body>
</html>
smoke-client.mjs
import { WebSocket } from "ws";
const url = new URL("ws://localhost:8080/chat");
url.searchParams.set("token", process.env.CHAT_TOKEN ?? "dev-token");
url.searchParams.set("name", "smoke");
url.searchParams.set("room", "lobby");
const ws = new WebSocket(url, {
headers: {
Origin: "http://localhost:8080"
}
});
const timeout = setTimeout(() => {
console.error("smoke test timed out");
ws.terminate();
process.exit(1);
}, 5000);
ws.on("open", () => {
ws.send(JSON.stringify({ type: "message", text: "hello from smoke test" }));
});
ws.on("message", (raw) => {
const data = JSON.parse(raw.toString("utf8"));
console.log(data);
if (data.type === "message" && data.message.text === "hello from smoke test") {
clearTimeout(timeout);
ws.close(1000, "done");
}
});
ws.on("close", (code, reason) => {
console.log(`closed ${code} ${reason.toString()}`);
process.exit(code === 1000 ? 0 : 1);
});
ws.on("error", (error) => {
clearTimeout(timeout);
console.error(error);
process.exit(1);
});
npm run smoke
Armadilhas comuns
| Falha | Impacto | Correção |
|---|---|---|
| Não autenticar no upgrade | Socket não confiável fica ativo | Rejeitar antes de handleUpgrade |
| Não validar Origin | Risco de conexão por outro site | Lista de origens permitidas |
| Sem limite de envio | Um cliente consome CPU e memória | Limitar por conexão, usuário e IP |
| Histórico infinito em memória | Processo cresce sem parar | Limitar quantidade, idade e armazenamento |
| Reconexão imediata | Pico de tráfego na recuperação | Backoff exponencial com variação |
| Fan-out só em um processo | Outros servidores não recebem | Redis Pub/Sub, NATS ou fila |
Produção e chamada para ação
Use wss:// sempre que houver conta, pagamento, suporte ou dados pessoais. A autorização precisa existir ao conectar, entrar em sala, enviar, apagar e moderar. O histórico deve ir para banco de dados, e múltiplos processos Node precisam de Pub/Sub compartilhado.
Se você está estudando sozinho, comece pelo cheatsheet gratuito de Claude Code. Para prompts e materiais reutilizáveis, veja os produtos ClaudeCodeLab. Equipes que precisam adaptar autenticação, auditoria, Pub/Sub, CI e revisão ao repositório real podem usar treinamento e consultoria Claude Code.
Resultado testado: Masa iniciou server.js, abriu http://localhost:8080 em duas abas e viu as mensagens da sala lobby aparecerem imediatamente nas duas. npm run smoke enviou e recebeu hello from smoke test. Alterar Origin retornou 403, e alterar token retornou 401. Colocar reconexão, limite, validação de Origin e histórico de 50 mensagens no pedido inicial ao Claude Code deixou a revisão muito mais simples.
Artigos relacionados
WebSocket em tempo real com Claude Code: auth, reconexao, heartbeat e rooms
Implemente WebSocket confiavel com Claude Code: autenticacao, reconexao, heartbeat, rooms, validacao e revisao.
Automação de e-mail com Claude Code: de leads à monetização
Implemente automação de e-mail com Claude Code: lead magnet, consentimento, retries, analytics e receita.
Desenvolver ferramentas CLI com Claude Code: Node.js, TypeScript, testes e releases seguros
Crie uma CLI Node.js/TypeScript com Claude Code: argumentos, config, stdin/stdout, exit codes, testes, CI e segredos.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.