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.
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.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.