{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:
type/payload
para uniformidad de mensajes."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": "message", "payload": { "room": "general", "text": "hola!" } }
👉 Es un mensaje enviado al chat.
{ "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.
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.
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:
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:
Una vez conectados, todos los usuarios de la misma sala pueden enviar 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.
ack
manuales.
- Manejo de CORS, versiones del protocolo y fallback de transporte.
- Namespaces y middleware para auth y métricas.
ws
.
- Requiere servidor compatible Socket.IO (no funciona con servidores WS genéricos).