Implementación básica en el lado del servidor con Node.js

2) Servidor

En este tema implementamos servidores WebSocket en Node.js usando la librería ws, junto con nanoid para IDs .

.env (ejemplo)

PORT=8080
MAX_PAYLOAD=1048576      # 1 MB
PING_INTERVAL_MS=30000
PING_TIMEOUT_MS=10000

2) Servidor base con ws: eco mínimo

Útil para probar que todo funciona; luego pasamos al servidor completo.

eco.js

const WebSocket = require("ws");

const wss = new WebSocket.Server({ port: 8080 }, () => {
  console.log("WS eco en ws://localhost:8080");
});

wss.on("connection", (ws) => {
  console.log("Cliente conectado");
  ws.send("¡Bienvenido!");

  ws.on("message", (msg) => {
    ws.send("Eco: " + msg.toString());
  });

  ws.on("close", () => console.log("Cliente desconectado"));
  ws.on("error", (err) => console.error("WS error:", err.message));
});

Ejecutar:

node eco.js

3) Servidor completo: JSON + rooms + broadcast + ACKs + heartbeat

server.js

require("dotenv").config();
const WebSocket = require("ws");
const { nanoid } = require("nanoid");

const PORT = Number(process.env.PORT || 8080);
const MAX_PAYLOAD = Number(process.env.MAX_PAYLOAD || 1024 * 1024); // 1 MB
const PING_INTERVAL_MS = Number(process.env.PING_INTERVAL_MS || 30_000);
const PING_TIMEOUT_MS  = Number(process.env.PING_TIMEOUT_MS  || 10_000);

/** Helpers */
function json(obj) { return JSON.stringify(obj); }
function send(ws, obj) {
  if (ws.readyState === WebSocket.OPEN) ws.send(json(obj));
}
function safeParse(buf) {
  try { return JSON.parse(buf.toString()); } catch { return null; }
}

/** Estado del servidor */
const clients = new Map();  // ws -> { id, user, room, lastPongAt }
const rooms   = new Map();  // room -> Set<ws>

/** Rooms helpers */
function joinRoom(ws, room) {
  if (!rooms.has(room)) rooms.set(room, new Set());
  rooms.get(room).add(ws);
}
function leaveRoom(ws) {
  const info = clients.get(ws);
  if (!info?.room) return;
  const set = rooms.get(info.room);
  if (set) { set.delete(ws); if (set.size === 0) rooms.delete(info.room); }
}
function broadcast(room, obj, except = null) {
  const set = rooms.get(room);
  if (!set) return;
  const data = json(obj);
  for (const c of set) {
    if (c !== except && c.readyState === WebSocket.OPEN) c.send(data);
  }
}

/** Crear servidor WS con límites */
const wss = new WebSocket.Server({
  port: PORT,
  maxPayload: MAX_PAYLOAD,         // protege de mensajes enormes
  perMessageDeflate: false,        // opcional: desactiva compresión
}, () => console.log(`WS en ws://localhost:${PORT}`));

/** Conexión entrante */
wss.on("connection", (ws, req) => {
  // (Opcional) Verificar Origin para CORS de WebSocket
  // const origin = req.headers.origin; // validar contra lista blanca si querés

  const id = nanoid();
  clients.set(ws, { id, user: null, room: null, lastPongAt: Date.now() });
  send(ws, { type: "welcome", payload: { id } });

  ws.on("message", (buf, isBinary) => {
    if (isBinary) {
      // Si querés soportar binario, manejalo aquí
      return send(ws, { type: "error", payload: { code: "BINARY_NOT_SUPPORTED" } });
    }

    const msg = safeParse(buf);
    if (!msg || typeof msg !== "object") {
      return send(ws, { type: "error", payload: { code: "BAD_JSON" } });
    }

    const info = clients.get(ws);

    switch (msg.type) {
      case "join": {
        const { room, user } = msg.payload || {};
        if (!room || !user) return send(ws, { type: "error", payload: { code: "BAD_JOIN" } });

        leaveRoom(ws);
        info.user = String(user).slice(0, 32);
        info.room = String(room).slice(0, 64);
        joinRoom(ws, info.room);

        send(ws, { type: "ack", payload: { op: "join", ok: true } });
        broadcast(info.room, { type: "system", payload: { text: `${info.user} se unió` } }, ws);
        break;
      }

      case "message": {
        if (!info.room) return send(ws, { type: "error", payload: { code: "NO_ROOM" } });

        const text = String((msg.payload && msg.payload.text) || "").trim();
        if (!text) return send(ws, { type: "error", payload: { code: "EMPTY_TEXT" } });

        const mid = nanoid();
        const payload = { id: mid, user: info.user, text, ts: Date.now() };
        broadcast(info.room, { type: "message", payload });
        send(ws, { type: "ack", payload: { op: "message", id: mid, ok: true } });
        break;
      }

      case "typing": {
        if (!info.room) return;
        broadcast(info.room, { type: "typing", payload: { user: info.user } }, ws);
        break;
      }

      // keep-alive de aplicación (no confundir con frame Ping de protocolo)
      case "app_ping": {
        send(ws, { type: "app_pong", payload: { ts: Date.now() } });
        break;
      }

      default:
        send(ws, { type: "error", payload: { code: "UNKNOWN_TYPE" } });
    }
  });

  // Heartbeat de protocolo: registrar PONG
  ws.on("pong", () => {
    const info = clients.get(ws);
    if (info) info.lastPongAt = Date.now();
  });

  ws.on("close", () => {
    const info = clients.get(ws);
    if (info?.room && info?.user) {
      broadcast(info.room, { type: "system", payload: { text: `${info.user} salió` } }, ws);
    }
    leaveRoom(ws);
    clients.delete(ws);
  });

  ws.on("error", (e) => {
    console.error("WS error:", e.message);
  });
});

/** Heartbeat para detectar clientes colgados y evitar fugas */
const interval = setInterval(() => {
  for (const ws of wss.clients) {
    if (ws.readyState !== WebSocket.OPEN) continue;

    const info = clients.get(ws);
    if (!info) continue;

    // si no responde pongs hace tiempo, terminar
    if (Date.now() - info.lastPongAt > PING_INTERVAL_MS + PING_TIMEOUT_MS) {
      try { ws.terminate(); } catch {}
      continue;
    }

    try { ws.ping(); } catch {}
  }
}, PING_INTERVAL_MS);

wss.on("close", () => clearInterval(interval));

/** Cierre elegante del proceso */
function shutdown() {
  console.log("Cerrando servidor WS");
  clearInterval(interval);
  wss.close(() => {
    console.log("WS cerrado");
    process.exit(0);
  });
  // Forzar cierre en 5s si algo queda colgado
  setTimeout(() => process.exit(0), 5000).unref();
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

Ejecutar

node server.js

4) Prueba rápida (navegador o Postman)

  • Navegador: conecta tu cliente a ws://localhost:8080.
  • DevTools: pestaña Network → WS para ver frames.
  • Postman / WebSocket King Client: conectar y enviar:
{ "type": "join",    "payload": { "room": "general", "user": "ana" } }
{ "type": "message", "payload": { "text": "hola a todos!" } }
{ "type": "typing",  "payload": {} }

5) Seguridad y límites

  • Origen: verifica req.headers.origin y compáralo contra una lista blanca si expones públicamente.
  • Tamaño: maxPayload (ya configurado) para evitar abusos.
  • Rate limiting (sugerido): cuenta mensajes/minuto por socket y aplica límites.
  • Saneamiento: recortar longitudes de user, room, text (ya incluido).

6) WebSocket seguro (WSS) con TLS (local/producción)

6.1 Certificado local (mkcert)

mkcert -install
mkcert localhost
# genera: localhost.pem y localhost-key.pem

Proyecto mkcert para certificados locales de desarrollo.

6.2 Servidor WSS

secure-ws.js

const fs = require("fs");
const https = require("https");
const WebSocket = require("ws");

const server = https.createServer({
  cert: fs.readFileSync("./localhost.pem"),
  key:  fs.readFileSync("./localhost-key.pem"),
});

const wss = new WebSocket.Server({ server });

wss.on("connection", (ws) => {
  ws.send("Conexión segura WSS lista");
  ws.on("message", (m) => ws.send("Eco (WSS): " + m));
});

server.listen(8443, () => {
  console.log("WSS en wss://localhost:8443");
});

En producción usa certificados válidos (Let’s Encrypt, etc.) y siempre wss://.

7) Proxy inverso con NGINX (producción)

Ejemplo mínimo (con HTTPS ya configurado en NGINX):

server {
  listen 443 ssl http2;
  server_name tu-dominio.com;

  # ssl_certificate ...; ssl_certificate_key ...;  # tus certificados

  location /ws/ {
    proxy_pass http://127.0.0.1:8080/ws/;  # o sin /ws si no usas path
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "Upgrade";
    proxy_set_header Host $host;
  }
}

Si tu servidor no usa prefijo /ws, ajusta location y proxy_pass en consecuencia.

8) Scripts en package.json

{
  "scripts": {
    "dev": "node server.js",
    "eco": "node eco.js",
    "wss": "node secure-ws.js"
  }
}

9) Buenas prácticas para producción

  • Supervisión: logs estructurados (JSON) y health checks (por HTTP aparte).
  • Escala horizontal: si hay múltiples instancias detrás de un balanceador, usa sticky sessions o un bus (Redis Pub/Sub, Kafka, RabbitMQ) para sincronizar eventos entre nodos.
  • Backpressure: si un cliente se atrasa, evita saturar send; puedes pausar o cortar.
  • Códigos de cierre: prefiere 1000 (normal) y especifica reason cuando tenga sentido.
  • Documentar eventos: lista de type, payload, errores y ACKs esperados.