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
Ú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
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
ws://localhost:8080
.{ "type": "join", "payload": { "room": "general", "user": "ana" } }
{ "type": "message", "payload": { "text": "hola a todos!" } }
{ "type": "typing", "payload": {} }
req.headers.origin
y compáralo contra una lista blanca si expones públicamente.maxPayload
(ya configurado) para evitar abusos.user
, room
, text
(ya incluido).mkcert -install
mkcert localhost
# genera: localhost.pem y localhost-key.pem
Proyecto mkcert para certificados locales de desarrollo.
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://
.
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.
{
"scripts": {
"dev": "node server.js",
"eco": "node eco.js",
"wss": "node secure-ws.js"
}
}
send
; puedes pausar o cortar.1000
(normal) y especifica reason
cuando tenga sentido.type
, payload
, errores y ACKs esperados.