Use Cases (Actualizado: 3/6/2026)

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.

Crear un chat WebSocket con Claude Code

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.

CasoPor qué encaja WebSocketDecisión inicial
Chat pequeñoMensajes bidireccionales con baja latenciaAutenticación, salas, historial
Panel en vivoEl servidor empuja cambiosDescarte, reconexión
Soporte educativoLa conversación sigue al artículoRegistros, 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

ErrorImpactoSolución
No autenticar en upgradeEntran sockets no confiablesRechazar antes de handleUpgrade
No validar OriginRiesgo de conexión cruzada maliciosaLista de orígenes permitidos
No limitar mensajesUn cliente puede saturar el procesoLimitar por conexión, usuario e IP
Historial ilimitado en memoriaEl proceso crece sin controlLímite por cantidad, edad y almacenamiento
Reconexión inmediataPico de tráfico durante recuperaciónBackoff exponencial con variación
Difusión solo en un procesoUsuarios en otro servidor no reciben mensajesRedis 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.

#Claude Code #WebSocket #chat #tiempo real #Node.js
Gratis

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.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.