{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": "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
). Sirve para que el cliente sepa que el servidor procesó el mensaje.
{ 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:
Ventajas:
123e4567-e89b-12d3-a456-426614174000
).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.
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:
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:
id
por mensaje y responde con ack
para confirmar entrega/acción.ack
en X ms, reenvía (con contador para evitar duplicados).readyState !== OPEN
), bufferiza o descarta según tu política.type/payload
y los errores (error.code
).type/payload
), usa ACKs, heartbeat y reconexión.