Crear un chat WebSocket con Claude Code
Guía para crear chat WebSocket con Claude Code: autenticación, reconexión, límites y código Node.js ejecutable.
Un chat WebSocket parece pequeño, pero obliga a resolver problemas reales: autenticación al conectar, reconexión cuando se cae la red, difusión por sala, límites contra spam, validación de mensajes y una prueba mínima para no romperlo después. Claude Code puede generar los archivos rápido, pero la calidad depende de las condiciones que le entregues desde el inicio.
En esta guía vamos a crear un ejemplo ejecutable con la API WebSocket del navegador y el paquete ws de Node.js. WebSocket es una conexión persistente y bidireccional: después del upgrade HTTP, el navegador y el servidor pueden enviarse mensajes sin abrir una nueva petición por cada evento. Revisa las fuentes oficiales: MDN WebSocket API, Writing WebSocket client applications, RFC 6455, evento upgrade de Node.js HTTP, README de ws y la guía de OWASP para probar WebSockets. Para el flujo de trabajo de la herramienta, usa la documentación oficial de Claude Code.
Para contexto interno, continúa con desarrollo de API con Claude Code, revisión de código con Claude Code y buenas prácticas de seguridad.
Tres casos de uso
El primer caso es un chat pequeño de comunidad o soporte. Solo necesitas sala, usuario, texto y un historial corto. El riesgo aparece cuando cualquiera puede conectarse y enviar mensajes sin límite.
El segundo caso es un panel en tiempo real. Estados de despliegue, pagos, inventario o colas de trabajo pueden llegar al navegador al instante. Aquí importa decidir qué eventos antiguos se pueden descartar, porque la API WebSocket del navegador no trae control de contrapresión automático.
El tercer caso es una sala de preguntas para artículos, cursos o material de pago. Puede mejorar la conversión porque el lector pasa de leer a conversar, pero exige reglas de registro, moderación y tratamiento de datos personales.
| Caso | Por qué encaja WebSocket | Decisión inicial |
|---|---|---|
| Chat pequeño | Mensajes bidireccionales con baja latencia | Autenticación, salas, historial |
| Panel en vivo | El servidor empuja cambios | Descarte, reconexión |
| Soporte educativo | La conversación sigue al artículo | Registros, moderación, llamada a la acción |
Arquitectura
Difusión significa repartir un mensaje entrante entre todos los clientes de la misma sala. Latido significa comprobar periódicamente que una conexión larga sigue viva.
flowchart LR
BrowserA["Pestaña A"] -->|ws:// /chat| Node["Node.js server"]
BrowserB["Pestaña 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
Crea un chat WebSocket mínimo con Node.js 20+ y el paquete ws.
Requisitos:
- Usar solo server.js, index.html, smoke-client.mjs y package.json
- Servir la interfaz en http://localhost:8080
- Validar token y Origin durante el upgrade de /chat
- Difundir mensajes por sala
- Guardar en memoria solo los últimos 50 mensajes
- Limitar cada conexión a 20 mensajes cada 10 segundos
- Limitar cada mensaje a 500 caracteres
- Reconectar el cliente con backoff exponencial tras close
- Revisar readyState y bufferedAmount antes de enviar
- smoke-client.mjs debe conectar, enviar un mensaje y salir correctamente
No hagas:
- Cambiarlo a Socket.IO
- Aceptar conexiones sin autenticación
- Usar JSON sin validarlo
- Presentar el historial en memoria como persistencia de producción
Ejecución local
mkdir ws-chat-demo
cd ws-chat-demo
npm init -y
npm install ws
npm run start
Sustituye package.json por esto:
{
"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="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Demo 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>Demo de chat WebSocket</h1>
<p id="status" class="muted">sin conexión</p>
<label>Sala <input id="room" value="lobby" /></label>
<label>Nombre <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>Mensaje <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("El socket no está listo; el mensaje queda en cola.", "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("en línea");
flushPending();
});
socket.addEventListener("message", (event) => {
const data = JSON.parse(event.data);
renderEvent(data);
});
socket.addEventListener("close", (event) => {
setStatus(`cerrado ${event.code}`);
if (!manuallyClosed) scheduleReconnect();
});
socket.addEventListener("error", () => {
writeLog("Error WebSocket. Revisa el registro del servidor.", "error");
});
}
function scheduleReconnect() {
const delay = Math.min(1000 * 2 ** reconnectAttempt, 10000) + Math.floor(Math.random() * 250);
reconnectAttempt += 1;
setStatus(`reconexión en ${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(`Se cargaron ${data.messages.length} mensajes previos.`, "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
Errores frecuentes
| Error | Impacto | Solución |
|---|---|---|
| No autenticar en upgrade | Entran sockets no confiables | Rechazar antes de handleUpgrade |
| No validar Origin | Riesgo de conexión cruzada maliciosa | Lista de orígenes permitidos |
| No limitar mensajes | Un cliente puede saturar el proceso | Limitar por conexión, usuario e IP |
| Historial ilimitado en memoria | El proceso crece sin control | Límite por cantidad, edad y almacenamiento |
| Reconexión inmediata | Pico de tráfico durante recuperación | Backoff exponencial con variación |
| Difusión solo en un proceso | Usuarios en otro servidor no reciben mensajes | Redis Pub/Sub, NATS o cola |
Producción y monetización
Usa wss:// cuando haya cuentas, pagos, soporte o datos personales. La autorización no debe quedarse solo en la conexión: entrar a una sala, enviar, borrar y moderar también requieren verificación. Para escalar, mueve el historial a una base de datos y la difusión entre procesos a Redis Pub/Sub, NATS o un broker equivalente.
Si estás aprendiendo solo, empieza con la hoja gratuita de Claude Code. Para plantillas y material reutilizable, revisa productos ClaudeCodeLab. Si tu equipo necesita adaptar autenticación, auditoría, Pub/Sub, CI y revisión a un repositorio real, la ruta práctica es formación y consultoría de Claude Code.
Resultado probado: Masa abrió http://localhost:8080 en dos pestañas y los mensajes del mismo lobby aparecieron de inmediato en ambas. npm run smoke envió y recibió hello from smoke test. Cambiar Origin devolvió 403 y cambiar el token devolvió 401. Pedir a Claude Code reconexión, límites, validación de Origin e historial de 50 mensajes desde el inicio dejó un cambio mucho más fácil de revisar.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.