Comunicación bidireccional en tiempo real (Socket.IO)

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 define un protocolo de eventos. Con Socket.IO puedes:

  • Seguir este esquema type/payload para uniformidad de mensajes.
  • O usar nombres de eventos (por ejemplo, "join", "message", "typing") con objetos de datos como payload.

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).

Con Socket.IO, estos acks se manejan cómodamente con callbacks en socket.emit(event, data, cb), manteniendo la semántica del protocolo.

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

  • broadcast: enviar un mensaje a varios clientes a la vez, típicamente a todos los de una sala.
  • rooms: agrupaciones lógicas de clientes (por ejemplo, "general", "soporte").
  • acks: confirmaciones de entrega usando callbacks de Socket.IO.

Instalación:

npm i socket.io nanoid

server.js:

const { Server } = require("socket.io");
const { nanoid } = require("nanoid");

const io = new Server(3000, { cors: { origin: "*" } });
console.log("Socket.IO en http://localhost:3000");

io.on("connection", (socket) => {
  // Identificador lógico del cliente
  const id = nanoid();
  socket.data = { id, user: null, room: null };
  socket.emit("welcome", { id });

  socket.on("join", ({ room, user }, cb) => {
    if (!room || !user) return cb?.({ op: "join", ok: false, code: "BAD_JOIN" });
    // Salir de la sala anterior si la hubiera
    if (socket.data.room) socket.leave(socket.data.room);

    socket.join(room);
    socket.data.room = room;
    socket.data.user = user;
    cb?.({ op: "join", ok: true });
    socket.to(room).emit("system", { text: `${user} se unió` });
  });

  socket.on("message", ({ text }, cb) => {
    if (!socket.data.room) return cb?.({ op: "message", ok: false, code: "NO_ROOM" });
    const messageId = nanoid();
    io.to(socket.data.room).emit("message", {
      id: messageId,
      user: socket.data.user,
      text,
      ts: Date.now(),
    });
    cb?.({ op: "message", ok: true, id: messageId });
  });

  socket.on("typing", () => {
    if (!socket.data.room) return;
    socket.to(socket.data.room).emit("typing", { user: socket.data.user });
  });

  socket.on("disconnect", () => {
    const { room, user } = socket.data;
    if (room && user) socket.to(room).emit("system", { text: `${user} salió` });
  });
});

Dependencias: nanoid para IDs. Socket.IO simplifica reconexión, salas, broadcast y acks mediante callbacks.

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

cliente.html (abrir en el navegador):

<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8" />
  <title>Chat Socket.IO Bonito</title>
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
  <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 Socket.IO</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 Socket.IO</div>
          <input class="input" id="wsurl" value="http://localhost:3000" />
        </div>
      </div>
      <div class="sep"></div>
      <div class="row-end">
        <button class="btn sec" id="btnJoin">Unirme</button>
      </div>
    </div>
  </div>

  <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
  <script>
    // ---------- Estado ----------
    let socket, myId=null, myUser=null, myRoom=null;
    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 = $("#messages");
    const textEl = $("#text");
    const btnSend = $("#btnSend");
    const dot = $("#dot");
    const typingEl = $("#typing");
    const typingNames = $("#typingNames");
    const roomLabel = $("#roomLabel");
    const userLabel = $("#userLabel");
    const modal = $("#modal");
    const roomInput = $("#room");
    const userInput = $("#user");
    const wsurlInput = $("#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);
      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){ return new Date(ts).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);
      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 = ""; }

    // ---------- Socket.IO ----------
    function connect(){
      try{ socket = io(wsurlInput.value, { transports:["websocket"] }); }
      catch(e){ renderSystem("URL de Socket.IO inválida."); return; }

      socket.on('connect', () => {
        setOnline(true);
        textEl.disabled = !myUser || !myRoom;
        btnSend.disabled = !myUser || !myRoom;
        if(myUser && myRoom){
          socket.emit('join', { room: myRoom, user: myUser }, (ack)=>{ /* opcional */ });
        }
      });

      socket.on('welcome', (p)=>{ myId = p?.id; });
      socket.on('message', (m)=>{ renderMessage(m); });
      socket.on('typing',  (p)=>{ if(p?.user) showTyping(p.user); });
      socket.on('system',  (p)=>{ if(p?.text) renderSystem(p.text); });
      socket.on('connect_error', ()=>{ setOnline(false); });
      socket.on('disconnect', ()=>{
        setOnline(false);
        textEl.disabled = true; btnSend.disabled = true;
        renderSystem("Conexión perdida. Reintentando …");
        // Socket.IO reintenta automáticamente
      });
    }

    // ---------- 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 ----------
    $("#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; messagesEl.innerHTML = "";
      if(!socket || socket.disconnected){ connect(); setTimeout(()=>{ try{ socket.emit('join', { room:r, user:u }, ()=>{}); }catch{} }, 200); }
      else { socket.emit('join', { 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(); } });

    let lastTyping = 0; textEl.addEventListener("input", () => { const now = Date.now(); if(now - lastTyping > 350){ try{ socket?.emit('typing'); }catch{} lastTyping = now; } });

    function sendMsg(){
      const text = textEl.value.trim(); if(!text || !socket || socket.disconnected) return;
      socket.emit('message', { text }, (ack)=>{ if(ack?.id) markAck(ack.id); });
      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

El ejemplo usa Socket.IO en el cliente y servidor. El cliente obtiene reconexión automática, compatibilidad de transporte y acks por callback.

4. Ventajas y desventajas de Socket.IO

  • Ventajas: - Reconexión automática, heartbeats y manejo de caídas integrado. - Rooms y broadcast sencillos con API declarativa. - Acks por callback sin tener que definir mensajes ack manuales. - Manejo de CORS, versiones del protocolo y fallback de transporte. - Namespaces y middleware para auth y métricas.
  • Desventajas: - Capa adicional sobre WebSocket: más tamaño y complejidad. - Menos control “al metal” que con la API nativa o ws. - Requiere servidor compatible Socket.IO (no funciona con servidores WS genéricos).