Comunicación bidireccional en tiempo real

1. Concepto clave

  • Full‑dúplex: ambos extremos pueden emitir y recibir simultáneamente.
  • Canal persistente: se envía un flujo de frames (texto o binario) mientras la conexión esté abierta.
  • Mensajería por eventos: conviene estandarizar los mensajes (por ejemplo, {type, payload}) para saber qué hacer en cada caso.

Ejemplo de contrato de mensajes:

{ "type": "join",    "payload": { "room": "general", "user": "ana" } }
{ "type": "message", "payload": { "room": "general", "text": "hola!" } }
{ "type": "typing",  "payload": { "room": "general", "user": "ana" } }
{ "type": "ack",     "payload": { "id": "m-123", "ok": true } }

En aplicaciones de chat (u otras en tiempo real), se suele definir un protocolo propio sobre WebSockets: cada mensaje lleva un campo type (qué tipo de acción es) y un campo payload (los datos específicos).

Ejemplo de lo que significa cada uno:

{ "type": "join", "payload": { "room": "general", "user": "ana" } }

👉 Indica que la usuaria Ana se unió a la sala "general".

  • type: "join" — acción de unirse.
  • payload — datos: a qué sala y qué usuario.
{ "type": "message", "payload": { "room": "general", "text": "hola!" } }

👉 Es un mensaje enviado al chat.

  • type: "message" — acción de enviar mensaje.
  • payload — el texto "hola!" dentro de la sala "general".
{ "type": "typing", "payload": { "room": "general", "user": "ana" } }

👉 Indica que Ana está escribiendo en la sala "general". Sirve para mostrar en la interfaz algo como Ana está escribiendo….

{ "type": "ack", "payload": { "id": "m-123", "ok": true } }

👉 Es un acuse de recibo (acknowledgment).

Confirma que el mensaje con ID "m-123" fue recibido correctamente (ok: true). Sirve para que el cliente sepa que el servidor procesó el mensaje.

2. Servidor Node.js con ws: broadcast, rooms y acks

  • broadcast: enviar un mensaje a varios clientes a la vez. Puede ser a todos o a todos los de una sala (excepto, opcionalmente, al emisor).
  • rooms: agrupaciones lógicas de clientes (por ejemplo, "general", "soporte"). Un cliente se une a una sala y recibe solo lo que se emite allí.
  • acks (acknowledgments): acuses de recibo. El receptor (normalmente el servidor) responde algo como { type: "ack", payload: { id, ok: true } } para confirmar que recibió/procesó un mensaje con cierto id.

Instalación:

npm i ws nanoid

nanoid es un generador de IDs:

  • Crea cadenas aleatorias y únicas que podés usar como identificadores de mensajes, usuarios, recursos, etc.

Ventajas:

  • Más corto que UUID (por ejemplo, 123e4567-e89b-12d3-a456-426614174000).
  • Más rápido y sin dependencias externas.
  • Seguro criptográficamente (usa crypto en Node.js o la Web Crypto API en el navegador).

server.js:

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

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

// Mapa de clientes y rooms
const clients = new Map();            // ws -> {id, user, room}
const rooms = new Map();              // roomName -> Set<ws>

function send(ws, msgObj) {
  if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msgObj));
}

function broadcast(room, msgObj, excludeWs = null) {
  const set = rooms.get(room);
  if (!set) return;
  const data = JSON.stringify(msgObj);
  set.forEach(ws => {
    if (ws !== excludeWs && ws.readyState === WebSocket.OPEN) ws.send(data);
  });
}

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 || !info.room) return;
  const set = rooms.get(info.room);
  if (set) {
    set.delete(ws);
    if (set.size === 0) rooms.delete(info.room);
  }
}

wss.on("connection", (ws, req) => {
  const id = nanoid();
  clients.set(ws, { id, user: null, room: null });
  send(ws, { type: "welcome", payload: { id } });

  ws.on("message", raw => {
    let msg;
    try { msg = JSON.parse(raw.toString()); }
    catch { 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);               // salir de room anterior si había
        info.user = user; info.room = room;
        joinRoom(ws, room);
        send(ws, { type: "ack", payload: { op: "join", ok: true } });
        broadcast(room, { type: "system", payload: { text: `${user} se unió` } }, ws);
        break;
      }
      case "message": {
        const { text } = msg.payload || {};
        if (!info.room) return send(ws, { type: "error", payload: { code: "NO_ROOM" } });
        const messageId = nanoid();
        broadcast(info.room, {
          type: "message",
          payload: { id: messageId, user: info.user, text, ts: Date.now() }
        });
        send(ws, { type: "ack", payload: { op: "message", id: messageId, ok: true } });
        break;
      }
      case "typing": {
        if (!info.room) return;
        broadcast(info.room, { type: "typing", payload: { user: info.user } }, ws);
        break;
      }
      default:
        send(ws, { type: "error", payload: { code: "UNKNOWN_TYPE" } });
    }
  });

  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);
  });
});

// Heartbeat: mantener vivos y detectar desconexiones colgadas
const interval = setInterval(() => {
  wss.clients.forEach(ws => {
    if (ws.isAlive === false) return ws.terminate();
    ws.isAlive = false;
    ws.ping(); // el cliente contestará con pong y marcaremos alive en 'pong'
  });
}, 30000);

wss.on("close", () => clearInterval(interval));
wss.on("connection", ws => {
  ws.isAlive = true;
  ws.on("pong", () => (ws.isAlive = true));
});

Dependencias: nanoid para IDs. API de WebSocket para las constantes/estados.

3. Cliente HTML: envío/recepción, reconexión y ACKs

cliente.html (abrir en el navegador, por ejemplo con el Live Server de Visual Studio Code, iniciar tambien el servidor con Node):

<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8" />
  <title>Chat WebSocket  Bonito</title>
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <style>
    :root{
      --bg: #0f172a;          /* slate-900 */
      --panel: #111827;       /* gray-900 */
      --panel-2:#0b1220;
      --text: #e5e7eb;        /* gray-200 */
      --muted:#9ca3af;        /* gray-400 */
      --me:#22c55e;           /* green-500 */
      --sys:#fbbf24;          /* amber-400 */
      --accent:#60a5fa;       /* blue-400 */
      --danger:#f87171;       /* red-400 */
      --ok:#34d399;           /* emerald-400 */
      --shadow: 0 10px 30px rgba(0,0,0,.35);
      --radius: 16px;
    }
    *{box-sizing:border-box}
    html,body{height:100%}
    body{
      margin:0;
      background: radial-gradient(1200px 1200px at 100% -10%, #1f2937 0%, transparent 50%) , var(--bg);
      color:var(--text);
      font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
      letter-spacing:.2px;
    }

    /* Layout */
    .wrap{
      display:grid;
      grid-template-rows: auto 1fr auto;
      height:100%;
      max-width: 980px;
      margin:0 auto;
    }
    header{
      display:flex; align-items:center; gap:12px;
      padding:18px 20px;
    }
    .brand{
      font-weight:700; letter-spacing:.4px;
      background: linear-gradient(90deg,#93c5fd,#a78bfa,#34d399);
      -webkit-background-clip:text;background-clip:text;color:transparent;
    }
    .room-pill{
      margin-left:auto;
      display:flex; align-items:center; gap:8px;
      background: linear-gradient(180deg,var(--panel), var(--panel-2));
      border:1px solid #1f2937; padding:8px 12px; border-radius:999px;
      box-shadow: var(--shadow);
      font-size:.9rem;
    }
    .dot{
      width:10px;height:10px;border-radius:50%;
      background: var(--danger); box-shadow:0 0 10px rgba(248,113,113,.8);
    }
    .dot.ok{ background: var(--ok); box-shadow:0 0 10px rgba(52,211,153,.7); }
    .status{ color: var(--muted);}

    .messages{
      overflow:auto;
      padding: 6px 16px 8px;
      display:flex; flex-direction:column; gap:10px;
    }
    .sys{
      align-self:center;
      background:rgba(251,191,36,.1);
      color:#fde68a;
      border:1px solid rgba(251,191,36,.3);
      padding:6px 10px; border-radius:10px;
      font-size:.85rem;
    }

    /* Bubbles */
    .msg{
      max-width: 72ch;
      display:grid; gap:6px;
      padding:10px 12px;
      border-radius: 14px;
      box-shadow: var(--shadow);
      border:1px solid rgba(255,255,255,.06);
      animation: fadeUp .18s ease-out both;
    }
    .msg .meta{
      display:flex; align-items:center; gap:8px;
      font-size: .8rem; color: var(--muted);
    }
    .msg .avatar{
      width:22px;height:22px;border-radius:50%; flex:0 0 22px;
      background: #111;
      border:1px solid rgba(255,255,255,.1);
    }
    .msg .who{ font-weight:600; color:#e2e8f0 }
    .msg .time{ margin-left:auto; font-variant-numeric: tabular-nums; }
    .msg .text{ white-space:pre-wrap; line-height:1.35 }
    .msg .ticks{
      font-size:.9rem; opacity:.8; margin-left:4px;
    }

    /* Others vs me */
    .row{ display:flex; }
    .row.me{ justify-content:flex-end; }
    .row.me .msg{
      background: linear-gradient(160deg, rgba(34,197,94,.15), rgba(34,197,94,.05) 60%);
      border-color: rgba(34,197,94,.35);
    }
    .row.other .msg{
      background: linear-gradient(160deg, rgba(96,165,250,.14), rgba(99,102,241,.06) 60%);
      border-color: rgba(147,197,253,.35);
    }

    /* Composer */
    .composer{
      display:grid;
      grid-template-columns: 1fr auto;
      gap:10px;
      padding:12px 16px 18px;
      background: linear-gradient(180deg, transparent, rgba(0,0,0,.25));
      position:sticky; bottom:0;
    }
    textarea{
      resize:none; width:100%; min-height:48px; max-height:120px;
      border-radius: var(--radius);
      border:1px solid #1f2937;
      background: linear-gradient(180deg, var(--panel), var(--panel-2));
      color:var(--text);
      padding:12px 14px;
      outline:none;
      box-shadow: var(--shadow);
    }
    textarea:disabled{ opacity:.6; cursor:not-allowed }
    .btn{
      border:none; cursor:pointer;
      padding:12px 16px; border-radius: 12px; font-weight:700;
      color:white;
      background: linear-gradient(180deg,#3b82f6,#1d4ed8);
      box-shadow: var(--shadow);
    }
    .btn:disabled{ opacity:.6; cursor:not-allowed }

    .hint{
      font-size:.82rem; color:var(--muted);
      display:flex; align-items:center; gap:10px; padding:0 18px 12px;
    }
    .typing{
      display:none; align-items:center; gap:8px;
      color:#cbd5e1; font-size:.9rem;
    }
    .typing.show{ display:flex }
    .typing .dots{ display:inline-flex; gap:3px; }
    .typing .dots i{ width:6px;height:6px;border-radius:50%; background:#cbd5e1; opacity:.4; animation: blink 1s infinite }
    .typing .dots i:nth-child(2){ animation-delay:.15s }
    .typing .dots i:nth-child(3){ animation-delay:.3s }

    .sep{ height:1px; background:linear-gradient(90deg,transparent,rgba(255,255,255,.07),transparent); margin: 4px 0 8px; }

    /* Join modal */
    .modal{
      position:fixed; inset:0; display:grid; place-items:center;
      background: rgba(0,0,0,.55); backdrop-filter: blur(4px);
    }
    .card{
      width:min(560px, 92vw);
      background: linear-gradient(180deg, var(--panel), var(--panel-2));
      border:1px solid #1f2937;
      border-radius: 18px; padding:18px;
      box-shadow: var(--shadow);
    }
    .grid{ display:grid; gap:12px; grid-template-columns: 1fr 1fr; }
    .grid .full{ grid-column: 1 / -1 }
    .label{ font-size:.9rem; color:#cbd5e1; margin-bottom:6px }
    .input{
      width:100%; padding:10px 12px; border-radius:12px;
      border:1px solid #1f2937; outline:none; color:var(--text);
      background: #0b1220;
    }
    .row-end{ display:flex; justify-content:flex-end; gap:10px; }
    .btn.sec{
      background: linear-gradient(180deg,#0ea5e9,#0369a1);
    }

    @keyframes fadeUp{ from{opacity:0; transform: translateY(6px)} to{opacity:1; transform:none} }
    @keyframes blink{ 0%,80%,100%{opacity:.3} 40%{opacity:1} }

    /* Scrollbar (nice) */
    .messages::-webkit-scrollbar{ width:10px }
    .messages::-webkit-scrollbar-thumb{ background: rgba(255,255,255,.08); border-radius:999px }
    .messages::-webkit-scrollbar-track{ background: transparent }
  </style>
</head>
<body>
  <div class="wrap">
    <header>
      <div class="brand">Chat WebSocket</div>
      <div class="room-pill">
        <div class="dot" id="dot"></div>
        <div class="status">
          <span id="roomLabel">sin sala</span>  <span id="userLabel">anónimo</span>
        </div>
      </div>
    </header>

    <div class="messages" id="messages" aria-live="polite"></div>

    <div class="hint">
      <div id="typing" class="typing">
        <span id="typingNames">Alguien</span> <span class="dots"><i></i><i></i><i></i></span>
      </div>
      <div style="margin-left:auto">
        Usa <kbd>Enter</kbd> para enviar  <kbd>Shift+Enter</kbd> salta de línea
      </div>
    </div>

    <div class="composer">
      <textarea id="text" placeholder="Escribe un mensaje" disabled></textarea>
      <button class="btn" id="btnSend" disabled>Enviar</button>
    </div>
  </div>

  <!-- Modal de unión -->
  <div class="modal" id="modal">
    <div class="card">
      <h2 style="margin:0 0 12px 0">Unirse al chat</h2>
      <div class="sep"></div>
      <div class="grid">
        <div>
          <div class="label">Sala</div>
          <input class="input" id="room" placeholder="p.ej. general" />
        </div>
        <div>
          <div class="label">Usuario</div>
          <input class="input" id="user" placeholder="tu nombre" />
        </div>
        <div class="full">
          <div class="label">Servidor WS</div>
          <input class="input" id="wsurl" value="ws://localhost:8080" />
        </div>
      </div>
      <div class="sep"></div>
      <div class="row-end">
        <button class="btn sec" id="btnJoin">Unirme</button>
      </div>
    </div>
  </div>

  <script>
    // ---------- Estado ----------
    let ws, myId=null, myUser=null, myRoom=null;
    let backoff = 500;
    const acks = new Set();              // ids de mensajes ya confirmados
    const typingMap = new Map();         // user -> timeoutId
    const colorCache = new Map();        // user -> hsl()

    // ---------- Helpers UI ----------
    const $ = sel => document.querySelector(sel);
    const messagesEl = document.querySelector('#messages');
    const textEl = document.querySelector('#text');
    const btnSend = document.querySelector('#btnSend');
    const dot = document.querySelector('#dot');
    const typingEl = document.querySelector('#typing');
    const typingNames = document.querySelector('#typingNames');
    const roomLabel = document.querySelector('#roomLabel');
    const userLabel = document.querySelector('#userLabel');
    const modal = document.querySelector('#modal');
    const roomInput = document.querySelector('#room');
    const userInput = document.querySelector('#user');
    const wsurlInput = document.querySelector('#wsurl');

    // Prefill desde localStorage
    roomInput.value = localStorage.getItem("room") || "general";
    userInput.value = localStorage.getItem("user") || "";

    function setOnline(on){
      dot.classList.toggle("ok", !!on);
    }
    function scrollBottom(){
      messagesEl.scrollTo({ top: messagesEl.scrollHeight, behavior: "smooth" });
    }
    function userColor(name){
      if(colorCache.has(name)) return colorCache.get(name);
      // Hash simple  tono HSL estable
      let h=0; for(let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i))>>>0 }
      const hue = h % 360;
      const col = `hsl(${hue} 70% 60%)`;
      colorCache.set(name,col); return col;
    }
    function fmtTime(ts){
      const d = new Date(ts);
      return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
    }

    function renderSystem(text){
      const el = document.createElement("div");
      el.className = "sys";
      el.textContent = text;
      messagesEl.appendChild(el);
      scrollBottom();
    }

    function renderMessage({id, user, text, ts}){
      const me = user === myUser;
      const row = document.createElement("div");
      row.className = "row " + (me ? "me":"other");
      const msg = document.createElement("div");
      msg.className = "msg";
      msg.dataset.id = id;

      const meta = document.createElement("div");
      meta.className = "meta";

      const av = document.createElement("div");
      av.className = "avatar";
      av.style.background = user ? userColor(user) : "#111";

      const who = document.createElement("div");
      who.className = "who";
      who.textContent = user || "desconocido";

      const time = document.createElement("div");
      time.className = "time";
      time.textContent = fmtTime(ts || Date.now());

      const ticks = document.createElement("span");
      ticks.className = "ticks";
      ticks.title = "Entregado";
      ticks.textContent = acks.has(id) ? "" : "";

      const textElDiv = document.createElement("div");
      textElDiv.className = "text";
      textElDiv.textContent = text ?? "";

      meta.appendChild(av);
      meta.appendChild(who);
      meta.appendChild(time);
      meta.appendChild(ticks);
      msg.appendChild(meta);
      msg.appendChild(textElDiv);
      row.appendChild(msg);
      messagesEl.appendChild(row);

      // si ya teníamos ack, marcar
      if (acks.has(id)) ticks.textContent = "";
      scrollBottom();
    }

    function markAck(id){
      acks.add(id);
      const el = messagesEl.querySelector(`.msg[data-id="${CSS.escape(id)}"] .ticks`);
      if(el) el.textContent = "";
    }

    // ---------- WS ----------
    function connect(){
      try{
        ws = new WebSocket(wsurlInput.value);
      }catch(e){
        renderSystem("URL de WebSocket inválida.");
        return;
      }

      ws.onopen = () => {
        setOnline(true);
        backoff = 500;
        textEl.disabled = !myUser || !myRoom;
        btnSend.disabled = !myUser || !myRoom;
        if(myUser && myRoom){
          // Re-join en reconexión
          ws.send(JSON.stringify({ type:"join", payload:{ room: myRoom, user: myUser } }));
        }
      };

      ws.onmessage = (e) => {
        let msg; try{ msg = JSON.parse(e.data) }catch{ return }
        switch(msg.type){
          case "welcome":
            myId = msg.payload.id;
            break;
          case "ack":
            if (msg.payload?.op === "message" && msg.payload?.id) {
              markAck(msg.payload.id);
            }
            break;
          case "message":
            renderMessage(msg.payload);
            break;
          case "typing":
            showTyping(msg.payload?.user);
            break;
          case "system":
            renderSystem(msg.payload?.text ?? "");
            break;
          case "error":
            renderSystem(" Error: " + (msg.payload?.code || "desconocido"));
            break;
        }
      };

      ws.onclose = () => {
        setOnline(false);
        textEl.disabled = true;
        btnSend.disabled = true;
        renderSystem("Conexión perdida. Reintentando");
        setTimeout(connect, backoff);
        backoff = Math.min(backoff * 2, 5000);
      };

      ws.onerror = () => {
        setOnline(false);
      };
    }

    // ---------- Typing ----------
    function showTyping(name){
      if(!name || name === myUser) return;
      if(typingMap.has(name)) clearTimeout(typingMap.get(name));
      updateTypingBanner();

      const t = setTimeout(() => {
        typingMap.delete(name);
        updateTypingBanner();
      }, 1500);
      typingMap.set(name, t);
    }

    function updateTypingBanner(){
      const names = [...typingMap.keys()];
      if(names.length === 0){
        typingEl.classList.remove("show");
        return;
      }
      typingEl.classList.add("show");
      typingNames.innerHTML = names
        .slice(0,3)
        .map(n => `<b style="color:${userColor(n)}">${n}</b>`)
        .join(", ") + (names.length>3 ? ` y ${names.length-3} más`:"");
    }

    // ---------- Eventos UI ----------
    document.querySelector('#btnJoin').addEventListener('click', () => {
      const r = roomInput.value.trim();
      const u = userInput.value.trim();
      const url = wsurlInput.value.trim();
      if(!r || !u || !url){
        renderSystem("Completá sala, usuario y servidor.");
        return;
      }
      myRoom = r; myUser = u;
      localStorage.setItem("room", r);
      localStorage.setItem("user", u);

      roomLabel.textContent = r;
      userLabel.textContent = u;

      // Limpiar historial al cambiar de sala
      messagesEl.innerHTML = "";

      // Conectar si aún no hay socket o está cerrada
      if(!ws || ws.readyState !== WebSocket.OPEN){
        connect();
        // pequeño delay para onopen
        setTimeout(() => {
          try{
            ws.send(JSON.stringify({ type:"join", payload:{ room:r, user:u } }));
          }catch{}
        }, 200);
      }else{
        ws.send(JSON.stringify({ type:"join", payload:{ room:r, user:u } }));
      }

      modal.style.display = "none";
      textEl.disabled = false;
      btnSend.disabled = false;
      textEl.focus();
    });

    btnSend.addEventListener("click", sendMsg);

    textEl.addEventListener("keydown", (ev) => {
      if(ev.key === "Enter" && !ev.shiftKey){
        ev.preventDefault();
        sendMsg();
      }
    });

    // typing con throttling muy simple
    let lastTyping = 0;
    textEl.addEventListener("input", () => {
      const now = Date.now();
      if(now - lastTyping > 350){
        try{ ws?.send(JSON.stringify({ type:"typing", payload:{} })); }catch{}
        lastTyping = now;
      }
    });

    function sendMsg(){
      const text = textEl.value.trim();
      if(!text || !ws || ws.readyState !== WebSocket.OPEN) return;
      ws.send(JSON.stringify({ type:"message", payload:{ text } }));
      textEl.value = "";
      textEl.focus();
    }

    // Arranque
    connect();
  </script>
</body>
</html>

Primero iniciamos el servidor con Node:

Iniciamos servidor con Node

Luego abrimos el cliente en el navegador (por ejemplo, con la extensión Live Server de Visual Studio Code), 2 o más instancias del mismo, ingresamos nombres de usuario y salas:

Cliente en el navegador

Una vez conectados, todos los usuarios de la misma sala pueden enviar mensajes:

Mensajes

4. Garantías y patrones útiles

  • Idempotencia y ACKs: agrega id por mensaje y responde con ack para confirmar entrega/acción.
  • Reintentos: si no llega ack en X ms, reenvía (con contador para evitar duplicados).
  • Backpressure: si el socket no está listo (readyState !== OPEN), bufferiza o descarta según tu política.
  • Rate limiting: evita spam de mensajes (por ejemplo, 10 msg/seg por usuario).
  • Rooms y permisos: valida acceso del usuario a la sala antes de unirlo.
  • Formato de eventos: documenta type/payload y los errores (error.code).
  • Heartbeat: mantiene conexiones sanas con ping/pong y timeouts de inactividad.

Resumen

  • La bidireccionalidad permite que servidor y cliente emitan eventos en tiempo real.
  • Estandariza un protocolo de mensajes (type/payload), usa ACKs, heartbeat y reconexión.