La lliçó anterior va deixar Nómada Tasques connectada i robusta, però amb un límit que cap millora de fetch no resol: el navegador només s'assabenta de les coses quan pregunta. Si l'Iván arrossega una tasca a «en curs» des del taller, el tauler de la Marta continuarà mostrant l'estat antic fins que ella refresqui. Preguntar cada cinc segons és malgastar bateria i amplada de banda per sentir «res de nou» el 95 % de les vegades. El que cal és invertir la iniciativa: que el servidor pugui parlar primer. Això és un WebSocket, una connexió permanent i bidireccional per la qual els missatges viatgen en tots dos sentits així que passen. En aquesta lliçó compararàs les quatre tècniques de temps real, entendràs el handshake que converteix una petició HTTP en un canal persistent, dominaràs l'API WebSocket del navegador, dissenyaràs un petit protocol de missatges, resoldràs la reconnexió i els conflictes d'edició simultània, i escriuràs js/dades/temps-real.js amb la classe CanalTauler.

Contingut

  1. Quan petició-resposta no basta
  2. Les quatre tècniques de temps real
  3. El protocol: del handshake al canal persistent
  4. L'API WebSocket del navegador
  5. readyState i el cicle de vida
  6. Enviar i rebre: send, message i JSON
  7. Un protocol de missatges per a Nómada Tasques
  8. Tancar bé: close(codi, motiu)
  9. Reconnexió amb retrocés exponencial
  10. Heartbeat: detectar connexions zombis
  11. Encuar missatges mentre no hi ha connexió
  12. Integrar amb els CustomEvent de la vista
  13. Conflictes: dues persones, una mateixa tasca
  14. Seguretat: wss, autenticació i validació
  15. Nómada Tasques: js/dades/temps-real.js
  16. Un servidor de proves mínim
  17. Errors Habituals i Consells
  18. Exercicis
  19. Conclusió

  1. Quan petició-resposta no basta

HTTP és un protocol de petició i resposta: el client pregunta, el servidor contesta, la connexió acaba. Aquell model és perfecte per carregar una pàgina o desar un formulari, i és estructuralment incapaç de fer una cosa: que el servidor avisi per iniciativa pròpia.

Els casos on això fa mal són recognoscibles:

  • Un tauler compartit on tres persones mouen targetes alhora.
  • Un xat, on esperar cinc segons que aparegui un missatge és inacceptable.
  • Un panell de mètriques en directe, notificacions, cotitzacions, una partida multijugador.
  • Un editor col·laboratiu, on veus el cursor de l'altra persona.

A Nómada Tasques l'escenari és concret: la Marta té el tauler obert a la pantalla gran de la sala. L'Iván marca des del taller la tasca «Pressupost de la fusteria» com a en curs. El correcte és que la targeta es mogui a la pantalla de la Marta, sola, en menys d'un segon, sense que ningú premi res.

  1. Les quatre tècniques de temps real

Abans de triar WebSockets convé saber quines alternatives hi ha, perquè sovint n'hi ha prou amb una de més simple.

Tècnica Com funciona Direcció Cost Quan triar-la
Polling El client pregunta cada N segons Client → servidor Alt: moltes peticions buides Dades que canvien poc i la latència no importa (mètriques cada minut)
Long polling El client pregunta i el servidor reté la resposta fins que hi hagi alguna cosa Client → servidor Mitjà Compatibilitat màxima, sense infraestructura especial
SSE (EventSource) Una connexió HTTP que el servidor manté oberta i per la qual envia esdeveniments Servidor → client (unidireccional) Baix Notificacions, avisos, un panell que només rep
WebSockets Connexió persistent full-duplex sobre TCP Tots dos sentits Baix per missatge, requereix servidor amb estat Xat, col·laboració, jocs, taulers compartits

Dues comparacions que convé tenir clares:

Polling davant de WebSocket, en números. Un polling cada 5 segons són 720 peticions per hora i usuari. Cadascuna arrossega capçaleres HTTP completes (cookies, User-Agent, Accept…): fàcilment 1 KB d'anada i un altre de tornada encara que no hi hagi novetats. Això és més d'1 MB per hora per persona per no dir res. Un WebSocket paga el cost del handshake una vegada i després cada missatge són uns pocs bytes de capçalera de trama.

SSE davant de WebSocket. SSE s'infravalora. És HTTP normal —travessa proxies sense problemes—, es reconnecta sol i és trivial d'implementar al servidor. La seva limitació és que el client no pot enviar pel mateix canal: per a això fes servir fetch a part.

// Server-Sent Events: sorprenentment simple per rebre
const font = new EventSource('https://api.tallernomada.example/v1/esdeveniments');

font.addEventListener('tasca:actualitzada', (esdeveniment) => {
  const tasca = JSON.parse(esdeveniment.data);
  vista.actualitzarTargeta(tasca);
});

font.onerror = () => console.warn('SSE reconnectant sol…');   // ← ho fa el navegador

La regla de decisió: si només reps, SSE. Si a més envies sovint i necessites baixa latència en tots dos sentits, WebSockets. Nómada Tasques és al segon cas: la Marta i l'Iván no només miren, també mouen targetes.

  1. El protocol: del handshake al canal persistent

Un WebSocket comença sent una petició HTTP. Aquesta és la clau del seu disseny: reutilitza el port 443 i travessa la infraestructura existent.

sequenceDiagram
    participant C as Navegador
    participant S as Servidor

    Note over C,S: Fase 1 · Handshake (HTTP)
    C->>S: GET /tauler HTTP/1.1<br/>Upgrade: websocket<br/>Connection: Upgrade<br/>Sec-WebSocket-Key: dGhlIHNhbXBsZQ==<br/>Sec-WebSocket-Version: 13
    S-->>C: HTTP/1.1 101 Switching Protocols<br/>Upgrade: websocket<br/>Connection: Upgrade<br/>Sec-WebSocket-Accept: s3pPLMBiTxaQ…

    Note over C,S: Fase 2 · Canal persistent (ja no és HTTP)
    C->>S: {"tipus":"subscriure","carrega":{"tauler":"taller-nomada"}}
    S-->>C: {"tipus":"estat:complet","carrega":{…6 tasques…}}
    S-->>C: {"tipus":"tasca:actualitzada","carrega":{"id":6,"estat":"en-curs"}}
    C->>S: {"tipus":"tasca:actualitzada","carrega":{"id":2,"estat":"feta"}}
    S-->>C: {"tipus":"pong"}

    Note over C,S: Fase 3 · Tancament
    C->>S: close(1000, "Vista tancada")
    S-->>C: close ack

Els punts que importen:

  • 101 Switching Protocols és el codi que autoritza el canvi. És l'únic 1xx que veuràs a la vida com a desenvolupador de frontend, i ja va aparèixer a la taula de 07-02.
  • Després del 101 ja no hi ha HTTP. No hi ha verbs, ni rutes, ni codis d'estat, ni capçaleres per missatge. Només trames de dades en tots dos sentits.
  • Els esquemes són ws:// i wss://, anàlegs a http:// i https://. wss és WebSocket sobre TLS.
  • És full-duplex: tots dos extrems poden enviar en qualsevol moment, sense torns i sense que un missatge sigui la «resposta» d'un altre. Aquella absència de correlació petició-resposta és el canvi mental més gran respecte de fetch.
  • La connexió té estat. El servidor manté un objecte per client connectat, cosa que complica l'escalat horitzontal: dos servidors darrere d'un balancejador no comparteixen connexions sense ajuda (Redis, un bus de missatges…). És un cost real que cal conèixer abans de triar.

  1. L'API WebSocket del navegador

L'API és petita: un constructor, quatre esdeveniments, dos mètodes i unes quantes propietats.

const socket = new WebSocket('wss://api.tallernomada.example/v1/tauler');

socket.addEventListener('open', () => {
  console.log('Connectat');
  socket.send(JSON.stringify({ tipus: 'subscriure', carrega: { tauler: 'taller-nomada' } }));
});

socket.addEventListener('message', (esdeveniment) => {
  const missatge = JSON.parse(esdeveniment.data);   // esdeveniment.data SEMPRE és text o binari
  console.log('Rebut:', missatge.tipus);
});

socket.addEventListener('error', () => {
  console.error('Error al socket');                 // ← no diu quin, per seguretat
});

socket.addEventListener('close', (esdeveniment) => {
  console.log('Tancat:', esdeveniment.code, esdeveniment.reason, 'net:', esdeveniment.wasClean);
});
Membre Tipus Què és
new WebSocket(url, protocols?) constructor Obre la connexió immediatament
readyState número 0 connectant, 1 obert, 2 tancant, 3 tancat
bufferedAmount número Bytes en cua pendents d'enviar
protocol string El subprotocol negociat
url string La URL final
binaryType string 'blob' (per defecte) o 'arraybuffer'
send(dades) mètode Envia string, Blob, ArrayBuffer o TypedArray
close(codi?, motiu?) mètode Tanca ordenadament
esdeveniments open, message, error, close

Tres avisos des del principi:

  • El constructor connecta ja. No hi ha cap .connect(). Si registres els oients massa tard, pots perdre't l'open. Registra'ls a la línia següent del constructor, sempre.
  • L'esdeveniment error no diu què ha passat. És una restricció de seguretat, igual que el TypeError genèric de CORS a 07-02. El diagnòstic útil ve al close que el segueix, amb el seu code.
  • Després d'un error sempre arriba un close. No posis la lògica de reconnexió a l'error: es duplicaria. Posa-la al close.

  1. readyState i el cicle de vida

stateDiagram-v2
    [*] --> CONNECTING: new WebSocket(url)
    CONNECTING --> OPEN: esdeveniment open (handshake OK)
    CONNECTING --> CLOSED: esdeveniment error + close (fallada)
    OPEN --> CLOSING: close() o tancament remot
    CLOSING --> CLOSED: esdeveniment close
    CLOSED --> [*]
    CLOSED --> CONNECTING: reconnectar() crea un socket NOU
Valor Constant Significa send() funciona?
0 WebSocket.CONNECTING Handshake en marxa No: llança InvalidStateError
1 WebSocket.OPEN Llest
2 WebSocket.CLOSING Tancant No (s'ignora)
3 WebSocket.CLOSED Tancat o fallit No

L'error de novell consisteix a enviar just després de crear el socket:

const socket = new WebSocket(URL);
socket.send('hola');        // ✗ InvalidStateError: l'estat és CONNECTING, no OPEN

Cal esperar l'open, o comprovar l'estat:

if (socket.readyState === WebSocket.OPEN) socket.send(missatge);
else cua.push(missatge);    // ← el patró de l'apartat 11

I un detall que s'oblida: un socket tancat no es torna a obrir. CLOSED és terminal. Reconnectar significa construir un WebSocket nou, exactament igual que un AbortController avortat no es reutilitza (07-03).

  1. Enviar i rebre: send, message i JSON

send() accepta text o binari, però no objectes. Com a fetch i a localStorage, la serialització és teva:

// ✗ Es converteix amb String() → '[object Object]'
socket.send({ tipus: 'ping' });

// ✓
socket.send(JSON.stringify({ tipus: 'ping' }));

I en recepció, esdeveniment.data és sempre text (o binari), mai un objecte ja analitzat:

socket.addEventListener('message', (esdeveniment) => {
  let missatge;
  try {
    missatge = JSON.parse(esdeveniment.data);
  } catch {
    console.warn('Missatge no-JSON, ignorat:', esdeveniment.data);
    return;                                    // ← no deixis mai que un missatge dolent tombi el canal
  }
  gestionar(missatge);
});

Aquell try/catch no és paranoia: un canal obert rep el que el servidor enviï, i un error d'anàlisi sense capturar trenca el gestor i pot deixar el canal inservible.

Per a binari, binaryType decideix què reps:

socket.binaryType = 'arraybuffer';             // en lloc del Blob per defecte
socket.addEventListener('message', (esdeveniment) => {
  if (typeof esdeveniment.data === 'string') gestionarText(esdeveniment.data);
  else gestionarBinari(new DataView(esdeveniment.data));
});

A Nómada Tasques tot és JSON: el volum és minúscul i la llegibilitat en depurar val molt més que uns bytes.

  1. Un protocol de missatges per a Nómada Tasques

Aquí hi ha la diferència entre un WebSocket que funciona i un que es torna immantenible. HTTP et donava estructura de franc (verb, ruta, estat). Un WebSocket és un tub de bytes: l'estructura la defineixes tu, i cal definir-la abans d'escriure codi.

El format mínim que funciona bé:

{
  "tipus": "tasca:actualitzada",
  "carrega": { "id": 6, "estat": "en-curs" },
  "meta": { "id": "a3f1", "emissor": "client-marta", "ts": "2026-09-20T10:14:32.120Z", "versio": 4 }
}
Camp Per a què
tipus Discrimina el missatge. Amb domini:acció, igual que els CustomEvent de 06-04
carrega Les dades, amb la mateixa forma que retorna l'API REST
meta.id Identificador únic del missatge: serveix per descartar duplicats i correlacionar respostes
meta.emissor Qui l'ha originat: permet ignorar l'eco dels teus propis canvis
meta.ts Marca de temps ISO, per ordenar i resoldre conflictes
meta.versio Versió de la tasca, la peça clau de l'apartat 13

El catàleg complet del projecte:

Tipus Sentit Càrrega Què provoca
subscriure client → servidor { tauler, desDeVersio } El servidor apunta el client i envia l'estat
estat:complet servidor → client { tasques: [...] } La vista reemplaça el tauler sencer
tasca:creada tots dos La tasca completa Afegir targeta
tasca:actualitzada tots dos { id, ...canvis, versio } Reconciliar aquella targeta
tasca:esborrada tots dos { id } Treure targeta
presencia servidor → client { connectats: ['Marta','Iván'] } Mostrar qui està mirant
ping / pong tots dos Heartbeat (apartat 10)
error servidor → client { codi, missatge } Avisar; possible rebuig d'un canvi

Quatre regles de disseny de protocol que val la pena seguir sempre:

  • Un tipus per missatge, sempre present. El gestor és un switch sobre tipus, o un objecte de funcions indexat per tipus; mai una cadena d'if que endevina per la forma de la càrrega.
  • Versiona el protocol, com vas versionar el format de localStorage a 07-01. Un camp v: 1 al subscriure et permetrà canviar de format sense trencar els clients vells.
  • La càrrega ha de tenir la mateixa forma que l'API REST. Així Tasca.desDeJSON serveix per a les dues vies, i no mantens dos models.
  • Reutilitza els noms dels teus CustomEvent. 'tasca:actualitzada' al socket i 'tasca:canviada' al DOM: la traducció és d'una línia i el vocabulari és un de sol.

  1. Tancar bé: close(codi, motiu)

socket.close(1000, "L'usuari ha tancat el tauler");

Els codis de tancament són un vocabulari estàndard, i saber llegir-los és el que converteix «no funciona» en un diagnòstic:

Codi Nom Significa Reconnectar?
1000 Normal Closure Tancament net i previst No
1001 Going Away La pàgina es tanca o navega No
1005 No Status No s'ha rebut codi (el posa el navegador)
1006 Abnormal Closure Connexió perduda sense tancament net
1008 Policy Violation El servidor rebutja per política (auth) No, arregla la credencial
1009 Message Too Big Missatge massa gran No, trosseja
1011 Internal Error Error intern del servidor
1012 / 1013 Service Restart / Try Again Later Manteniment Sí, amb espera
4000-4999 Codis propis de la teva aplicació Tu decideixes

El 1006 és el que més veuràs: significa «s'ha tallat i ningú no ha dit adeu» —wifi caigut, portàtil suspès, un proxy que ha matat la connexió inactiva—. És el senyal de reconnectar.

El rang 4000-4999 està reservat per a tu, i val la pena fer-lo servir:

// Al servidor
if (!tokenValid) socket.close(4001, 'Token caducat');
if (taulerNoExisteix) socket.close(4004, 'Tauler desconegut');
// Al client
socket.addEventListener('close', (esdeveniment) => {
  if (esdeveniment.code === 4001) { anarAIniciarSessio(); return; }   // no reconnectar
  if (esdeveniment.code === 1000) return;                             // tancament demanat per nosaltres
  reconnectar();
});

Dos detalls finals: esdeveniment.wasClean indica si hi va haver handshake de tancament; i el motiu està limitat a 123 bytes, així que és una etiqueta, no una explicació.

  1. Reconnexió amb retrocés exponencial

Un WebSocket cau. El wifi del taller parpelleja, el mòbil passa de wifi a dades, el portàtil se suspèn, un proxy talla les connexions inactives als 60 segons. Una aplicació seriosa assumeix que la connexió es perd i es reconnecta sola.

La tècnica és la mateixa de 07-03: retrocés exponencial amb jitter. Reconnectar immediatament i en bucle és una manera eficaç de tombar el teu propi servidor quan es reinicia i mil clients tornen alhora.

class Reconnector {
  #intents = 0;
  #maxIntents;
  #baseMs;
  #maxMs;

  constructor({ maxIntents = 10, baseMs = 500, maxMs = 30000 } = {}) {
    this.#maxIntents = maxIntents;
    this.#baseMs = baseMs;
    this.#maxMs = maxMs;
  }

  /** Retorna els ms a esperar, o null si cal rendir-se. */
  seguentEspera() {
    if (this.#intents >= this.#maxIntents) return null;
    const exponencial = Math.min(this.#baseMs * 2 ** this.#intents, this.#maxMs);
    this.#intents += 1;
    return exponencial + Math.random() * exponencial * 0.3;    // jitter del 30 %
  }

  /** Es crida en connectar amb èxit: la caiguda següent torna a començar per baix. */
  reiniciar() {
    this.#intents = 0;
  }
}

La seqüència resultant: 500 ms, 1 s, 2 s, 4 s, 8 s, 16 s, 30 s, 30 s… més el jitter. Ràpid quan és un parpelleig, tranquil quan el servidor està caigut de debò.

Dues millores que marquen la diferència:

// 1 · Si el navegador diu que no hi ha xarxa, no gastis intents: espera l'esdeveniment 'online'
if (!navigator.onLine) {
  window.addEventListener('online', () => this.connectar(), { once: true });
  return;
}

// 2 · Si la pestanya està amagada, no reconnectis amb pressa: estalvia bateria
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible' && this.#estat === 'tancat') this.connectar();
});

I una decisió de producte: quan et rendeixis, digues-ho. Un bàner «Sense connexió amb el tauler — Reintentar» amb un botó és infinitament millor que reintentar en silenci per sempre o que fingir que tot va bé.

  1. Heartbeat: detectar connexions zombis

Existeix una fallada especialment traïdora: la connexió sembla oberta —readyState és 1— però no arriba res. Passa quan un router NAT o un proxy descarta la connexió sense avisar cap dels dos extrems. El navegador no té manera de saber-ho, i el teu tauler es queda congelat sense cap error.

La solució és un batec: enviar un ping periòdic i esperar un pong. Si el pong no arriba a temps, es dona la connexió per morta i es reconnecta.

sequenceDiagram
    participant C as Client
    participant S as Servidor

    loop cada 25 s
        C->>S: {"tipus":"ping"}
        S-->>C: {"tipus":"pong"}
        Note over C: ✅ reinicia el vigilant
    end

    C->>S: {"tipus":"ping"}
    Note over C,S: ❌ la xarxa s'ha tallat sense avisar
    Note over C: passen 10 s sense pong
    C->>C: socket.close(4008, 'Sense pong')
    C->>C: reconnectar()
#batec = { interval: null, vigilant: null, cadaMs: 25000, esperaMs: 10000 };

#iniciarBatec() {
  this.#aturarBatec();
  this.#batec.interval = setInterval(() => {
    if (this.#socket?.readyState !== WebSocket.OPEN) return;

    this.#socket.send(JSON.stringify({ tipus: 'ping' }));

    // Si no arriba el pong a temps, la connexió és un zombi
    this.#batec.vigilant = setTimeout(() => {
      console.warn('[nomada] Sense pong: connexió zombi, es tanca.');
      this.#socket.close(4008, 'Sense resposta al ping');     // provoca 'close' → reconnexió
    }, this.#batec.esperaMs);
  }, this.#batec.cadaMs);
}

#enRebrePong() {
  clearTimeout(this.#batec.vigilant);                         // ha arribat: cancel·la el vigilant
  this.#batec.vigilant = null;
}

#aturarBatec() {
  clearInterval(this.#batec.interval);
  clearTimeout(this.#batec.vigilant);
  this.#batec.interval = this.#batec.vigilant = null;
}

Dues notes de context. El protocol WebSocket té trames ping/pong natives, però l'API del navegador no les exposa: no les pots enviar ni escoltar des de JavaScript. Per això el batec s'implementa al teu propi protocol de missatges. I l'interval importa: un valor entre 20 i 30 segons manté viva la connexió davant de proxies que tallen al minut, sense gastar bateria.

  1. Encuar missatges mentre no hi ha connexió

La Marta marca una tasca com a feta justament mentre el socket s'està reconnectant. Sense cua, aquell canvi es perd en silenci, que és el pitjor resultat possible.

#cua = [];
#MAX_CUA = 50;

enviar(tipus, carrega = {}) {
  const missatge = {
    tipus,
    carrega,
    meta: { id: crypto.randomUUID(), emissor: this.#emissor, ts: new Date().toISOString() }
  };

  if (this.#socket?.readyState === WebSocket.OPEN) {
    this.#socket.send(JSON.stringify(missatge));
    return true;
  }

  // Sense connexió: a la cua, amb sostre per no créixer sense límit
  if (this.#cua.length >= this.#MAX_CUA) this.#cua.shift();       // descarta el més vell
  this.#cua.push(missatge);
  return false;                                                    // ← qui crida sap que no ha sortit
}

/** Es crida a l'esdeveniment 'open': buida el que s'ha acumulat. */
#buidarCua() {
  const pendents = this.#cua;
  this.#cua = [];
  for (const missatge of pendents) {
    this.#socket.send(JSON.stringify(missatge));
  }
}

Quatre decisions que cal prendre conscientment:

  • Sostre de la cua. Sense límit, una desconnexió llarga acaba consumint memòria. Cinquanta missatges és un sostre raonable; en superar-lo, descarta els més antics.
  • Deduplicar per recurs. Si la Marta canvia tres vegades l'estat de la tasca 6, només importa l'últim. Una cua que col·lapsi els missatges del mateix tipus + carrega.id evita enviar història irrellevant.
  • Persistir o no. Una cua en memòria mor en recarregar. Si els canvis són valuosos, desa-la a localStorage amb el repositori de 07-01.
  • Retornar si ha sortit o no. El return false permet a la vista marcar la targeta com a «pendent de sincronitzar», que és el que és honest (07-03).

  1. Integrar amb els CustomEvent de la vista

Aquí encaixa netament amb el que ja vas construir. El canal no coneix la vista: tradueix missatges entrants a CustomEvent sobre document, i qui vulgui escoltar, escolta. És exactament el desacoblament de 06-04.

flowchart LR
    S[Servidor] -->|"WebSocket message"| C["CanalTauler"]
    C -->|"analitza i valida"| T{"switch (tipus)"}
    T -->|"tasca:actualitzada"| E1["emetre(document, 'tasca:canviada')"]
    T -->|"estat:complet"| E2["emetre(document, 'tauler:actualitzat')"]
    E1 --> V["TaulerVista<br/>escolta i reconcilia"]
    E2 --> V
    V --> D["DOM"]
// js/dades/temps-real.js (fragment de la gestió d'entrada)
import { ESDEVENIMENTS, emetre } from '../vista/esdeveniments.js';
import { Tasca } from '../model/tasca.js';

#gestionar(missatge) {
  // Ignora l'eco dels teus propis canvis: ja els vas aplicar de manera optimista (07-03)
  if (missatge.meta?.emissor === this.#emissor) return;

  switch (missatge.tipus) {
    case 'pong':
      this.#enRebrePong();
      break;

    case 'estat:complet':
      emetre(document, ESDEVENIMENTS.TAULER_ACTUALITZAT, {
        tasques: missatge.carrega.tasques.map((d) => Tasca.desDeJSON(d)),
        origen: 'servidor'
      });
      break;

    case 'tasca:creada':
    case 'tasca:actualitzada':
      emetre(document, ESDEVENIMENTS.TASCA_CANVIADA, {
        tasca: Tasca.desDeJSON(missatge.carrega),
        versio: missatge.meta?.versio,
        origen: 'servidor'
      });
      break;

    case 'tasca:esborrada':
      emetre(document, ESDEVENIMENTS.TASCA_ESBORRADA, { id: missatge.carrega.id, origen: 'servidor' });
      break;

    case 'presencia':
      emetre(document, ESDEVENIMENTS.PRESENCIA, { connectats: missatge.carrega.connectats });
      break;

    case 'error':
      console.error('[nomada] El servidor ha rebutjat un canvi:', missatge.carrega);
      emetre(document, ESDEVENIMENTS.ERROR_REMOT, missatge.carrega);
      break;

    default:
      console.warn('[nomada] Tipus de missatge desconegut:', missatge.tipus);
  }
}

I a la vista, sense ni una línia que sàpiga que existeixen els WebSockets:

// js/app.js
document.addEventListener(ESDEVENIMENTS.TASCA_CANVIADA, (esdeveniment) => {
  if (esdeveniment.detail.origen !== 'servidor') return;    // els propis ja estan aplicats
  tauler.substituir(esdeveniment.detail.tasca);
  vista.render();                                            // reconcilia per data-id (06-06)
  repositori.desar(tauler);                                  // la còpia local, al dia (07-01)
});

Aquí es veu el valor de les tres lliçons anteriors juntes: la reconciliació per clau de 06-06 fa que la targeta s'actualitzi sense parpellejar ni perdre el focus, i el repositori de 07-01 manté la còpia local sincronitzada.

Aquell if (missatge.meta?.emissor === this.#emissor) return mereix una explicació. El servidor reenvia cada canvi a tots els clients, inclòs qui l'ha originat. Si apliquessis el teu propi eco, la targeta que ja has mogut optimistament parpellejaria o revertiria a un estat intermedi. Filtrar per emissor ho evita.

  1. Conflictes: dues persones, una mateixa tasca

El problema no és tècnic, és de producte, i apareix així que hi ha dues persones. La Marta canvia la prioritat de la tasca 6 a «mitjana» en el mateix segon en què l'Iván la canvia a «baixa». Què queda?

Estratègia Com funciona Avantatge Inconvenient
Última escriptura guanya (LWW) L'últim missatge que arriba al servidor mana Trivial d'implementar Es perden canvis en silenci
Versió / updatedAt Cada canvi porta la versió que el client es creia tenir; el servidor rebutja si no coincideix Detecta el conflicte en lloc d'ignorar-lo Cal decidir què fer en rebutjar
Fusió per camps Es combinen canvis de camps diferents Menys conflictes reals Complex; no val per al mateix camp
CRDT / transformació operacional Estructures que convergeixen soles Edició col·laborativa real (tipus document compartit) Molta complexitat; per a editors de text

Última escriptura guanya és acceptable quan els canvis són atòmics i poc freqüents, i és el que fa la majoria d'aplicacions senzilles. Però perd dades sense avisar, i en un tauler de treball això genera desconfiança: «jo hi vaig posar alta, qui ho ha canviat?».

L'opció sana per a Nómada Tasques és control de versió optimista, l'equivalent en temps real de l'ETag / If-Match d'HTTP:

// El client envia quina versió es creia tenir
canal.enviar('tasca:actualitzada', {
  id: 6,
  prioritat: 'mitjana',
  versioBase: tasca.versio        // ← 4, la que tenia quan la Marta va prémer
});
// El servidor (pseudocodi, perquè s'entengui la regla)
if (tascaEnBd.versio !== missatge.carrega.versioBase) {
  respondre({ tipus: 'error', carrega: {
    codi: 'conflicte',
    id: missatge.carrega.id,
    actual: tascaEnBd            // li retorna l'estat real
  }});
} else {
  tascaEnBd.versio += 1;
  desar(tascaEnBd);
  difondreATots({ tipus: 'tasca:actualitzada', carrega: tascaEnBd, meta: { versio: tascaEnBd.versio } });
}

I al client, un conflicte es gestiona sense perdre la feina de ningú:

document.addEventListener(ESDEVENIMENTS.ERROR_REMOT, (esdeveniment) => {
  const { codi, id, actual } = esdeveniment.detail;
  if (codi !== 'conflicte') return;

  const meva = tauler.cercarPerId(id);
  vista.mostrarConflicte({
    missatge: `«${actual.titol}» ha estat modificada per una altra persona mentre l'editaves.`,
    opcions: [
      { text: 'Veure la versió actual',   enPremer: () => { tauler.substituir(Tasca.desDeJSON(actual)); vista.render(); } },
      { text: 'Sobreescriure amb la meva', enPremer: () => canal.enviar('tasca:actualitzada', { ...meva.toJSON(), versioBase: actual.versio }) }
    ]
  });
});

La regla d'or: un conflicte detectat i mostrat sempre és millor que un canvi perdut en silenci. I un advertiment important: la versió la controla el servidor, mai el client. Si el client pogués decidir la versió, el mecanisme no serviria de res.

  1. Seguretat: wss, autenticació i validació

Els WebSockets tenen un perfil de seguretat propi, i tres regles no negociables.

Fes servir sempre wss://. Sobre ws:// tot viatja en clar: missatges, tokens, dades. A més, una pàgina servida per HTTPS no pot obrir un ws:// —el navegador el bloqueja com a contingut mixt—, així que en producció no és opcional.

Autentica la connexió, i vés amb compte amb com. El constructor WebSocket no accepta capçaleres: no pots posar Authorization: Bearer. Les opcions reals:

Mètode Com Pega
Cookie de sessió El navegador l'envia al handshake Requereix mateix lloc i SameSite ben configurat
Token a la URL wss://…/tauler?token=abc Queda als registres del servidor i del proxy. Fes servir tokens d'un sol ús i vida curta
Missatge d'autenticació Connectar i enviar {tipus:'auth', token} com a primer missatge El servidor ha de tancar si no arriba en N segons
Subprotocol new WebSocket(url, ['bearer', token]) Truc estès, però abusa del camp

L'opció més neta sol ser la tercera: connectar, autenticar com a primer missatge i que el servidor tanqui amb 4001 si no arriba credencial vàlida en uns segons.

Valida SEMPRE al servidor. Aquesta és la més important i la més ignorada. Un WebSocket obert és un canal pel qual el client pot enviar el que vulgui, i qualsevol en pot obrir un amb una línia des de la consola. Tot el que arriba pel canal és entrada no fiable, exactament igual que un formulari:

  • Comprova l'autorització a cada missatge, no només en connectar. Que l'Iván pugui veure el tauler no significa que pugui esborrar tasques de la Lucía.
  • Valida la forma i el contingut de cada càrrega. Les regles R1-R10 del model s'apliquen al servidor, no només al navegador.
  • Comprova l'origen al handshake: el navegador envia la capçalera Origin, i el servidor ha de rebutjar els que no reconegui. Els WebSockets no estan subjectes a la política de mateix origen de CORS, així que sense aquesta comprovació qualsevol web s'hi podria connectar amb les cookies de l'usuari. Aquell atac té nom: Cross-Site WebSocket Hijacking.
  • Limita la mida dels missatges i la freqüència (rate limiting): un client maliciós pot intentar esgotar la memòria del servidor.
  • No pintis mai amb innerHTML un contingut que ha arribat pel canal. És entrada d'un altre usuari, i l'XSS de 06-02 hi sotja igual.

I l'advertiment de compliance que acompanya tot el mòdul: un canal de temps real que transporti dades personals reals (noms, ubicacions, missatges entre persones) entra en l'àmbit de la normativa de protecció de dades, amb obligacions de xifratge, minimització i retenció. La Marta, l'Iván i la Lucía són ficticis; els teus usuaris, no.

  1. Nómada Tasques: js/dades/temps-real.js

Tot junt, en una classe amb la mateixa filosofia que la resta de la capa dades/: no coneix el DOM, no coneix la vista, només tradueix entre el servidor i els esdeveniments de l'aplicació.

// js/dades/temps-real.js
import { ESDEVENIMENTS, emetre } from '../vista/esdeveniments.js';
import { Tasca } from '../model/tasca.js';

const ESTATS = Object.freeze({
  TANCAT: 'tancat', CONNECTANT: 'connectant', OBERT: 'obert', RENDIT: 'rendit'
});

export class CanalTauler extends EventTarget {
  #url;
  #socket = null;
  #estat = ESTATS.TANCAT;
  #emissor = crypto.randomUUID();         // identifica AQUEST client, per ignorar l'eco
  #cua = [];
  #tancatAProposit = false;
  #intents = 0;
  #reintentId = null;
  #batec = { interval: null, vigilant: null };

  static MAX_INTENTS = 10;
  static MAX_CUA = 50;
  static BATEC_MS = 25000;
  static ESPERA_PONG_MS = 10000;

  constructor(url) {
    super();
    this.#url = url;
  }

  get estat()     { return this.#estat; }
  get connectat() { return this.#socket?.readyState === WebSocket.OPEN; }
  get pendents()  { return this.#cua.length; }

  // ─────────────────────────── connexió ───────────────────────────

  connectar() {
    if (this.#estat === ESTATS.CONNECTANT || this.connectat) return;

    if (!navigator.onLine) {                                   // sense xarxa: espera, no gastis intents
      window.addEventListener('online', () => this.connectar(), { once: true });
      return;
    }

    this.#tancatAProposit = false;
    this.#canviarEstat(ESTATS.CONNECTANT);

    this.#socket = new WebSocket(this.#url);
    this.#socket.addEventListener('open', () => this.#enObrir());
    this.#socket.addEventListener('message', (e) => this.#enMissatge(e));
    this.#socket.addEventListener('error', () => console.warn('[nomada] Error al canal.'));
    this.#socket.addEventListener('close', (e) => this.#enTancar(e));
  }

  /** Tancament voluntari: NO reconnecta. */
  desconnectar(motiu = 'Tancament del tauler') {
    this.#tancatAProposit = true;
    clearTimeout(this.#reintentId);
    this.#aturarBatec();
    this.#socket?.close(1000, motiu);
    this.#socket = null;
    this.#canviarEstat(ESTATS.TANCAT);
  }

  #enObrir() {
    this.#intents = 0;
    this.#canviarEstat(ESTATS.OBERT);
    this.#iniciarBatec();

    this.enviar('subscriure', { tauler: 'taller-nomada', v: 1 });
    this.#buidarCua();
  }

  #enTancar(esdeveniment) {
    this.#aturarBatec();
    this.#socket = null;

    if (this.#tancatAProposit || esdeveniment.code === 1000) {
      this.#canviarEstat(ESTATS.TANCAT);
      return;
    }
    if (esdeveniment.code === 4001) {                          // token invàlid: no insisteixis
      this.#canviarEstat(ESTATS.RENDIT);
      emetre(document, ESDEVENIMENTS.ERROR_REMOT, { codi: 'sessio', missatge: 'Sessió caducada.' });
      return;
    }
    this.#programarReconnexio(esdeveniment.code);
  }

  #programarReconnexio(codi) {
    if (this.#intents >= CanalTauler.MAX_INTENTS) {
      this.#canviarEstat(ESTATS.RENDIT);                       // la vista mostrarà «Reintentar»
      return;
    }
    const exponencial = Math.min(500 * 2 ** this.#intents, 30000);
    const espera = exponencial + Math.random() * exponencial * 0.3;   // jitter
    this.#intents += 1;

    console.warn(`[nomada] Canal tancat (${codi}). Reintent ${this.#intents} en ${Math.round(espera)} ms.`);
    this.#canviarEstat(ESTATS.TANCAT);
    this.#reintentId = setTimeout(() => this.connectar(), espera);
  }

  // ─────────────────────────── missatges ───────────────────────────

  enviar(tipus, carrega = {}) {
    const missatge = {
      tipus, carrega,
      meta: { id: crypto.randomUUID(), emissor: this.#emissor, ts: new Date().toISOString() }
    };

    if (this.connectat) {
      this.#socket.send(JSON.stringify(missatge));
      return true;
    }
    if (this.#cua.length >= CanalTauler.MAX_CUA) this.#cua.shift();
    this.#cua.push(missatge);
    return false;                                              // la vista marca «sense sincronitzar»
  }

  #buidarCua() {
    const pendents = this.#cua;
    this.#cua = [];
    for (const missatge of pendents) this.#socket.send(JSON.stringify(missatge));
  }

  #enMissatge(esdeveniment) {
    let missatge;
    try {
      missatge = JSON.parse(esdeveniment.data);
    } catch {
      console.warn('[nomada] Missatge no-JSON descartat.');
      return;                                                  // un missatge dolent no tomba el canal
    }
    if (typeof missatge?.tipus !== 'string') return;
    if (missatge.meta?.emissor === this.#emissor) return;       // eco dels meus propis canvis

    this.#despatxar(missatge);
  }

  #despatxar(missatge) {
    switch (missatge.tipus) {
      case 'pong':
        clearTimeout(this.#batec.vigilant);
        break;
      case 'estat:complet':
        emetre(document, ESDEVENIMENTS.TAULER_ACTUALITZAT, {
          tasques: missatge.carrega.tasques.map((d) => Tasca.desDeJSON(d)), origen: 'servidor'
        });
        break;
      case 'tasca:creada':
      case 'tasca:actualitzada':
        emetre(document, ESDEVENIMENTS.TASCA_CANVIADA, {
          tasca: Tasca.desDeJSON(missatge.carrega), versio: missatge.meta?.versio, origen: 'servidor'
        });
        break;
      case 'tasca:esborrada':
        emetre(document, ESDEVENIMENTS.TASCA_ESBORRADA, { id: missatge.carrega.id, origen: 'servidor' });
        break;
      case 'presencia':
        emetre(document, ESDEVENIMENTS.PRESENCIA, { connectats: missatge.carrega.connectats });
        break;
      case 'error':
        emetre(document, ESDEVENIMENTS.ERROR_REMOT, missatge.carrega);
        break;
      default:
        console.warn('[nomada] Tipus desconegut:', missatge.tipus);
    }
  }

  // ─────────────────────────── batec ───────────────────────────

  #iniciarBatec() {
    this.#aturarBatec();
    this.#batec.interval = setInterval(() => {
      if (!this.connectat) return;
      this.#socket.send(JSON.stringify({ tipus: 'ping', meta: { emissor: this.#emissor } }));
      this.#batec.vigilant = setTimeout(
        () => this.#socket?.close(4008, 'Sense resposta al ping'),
        CanalTauler.ESPERA_PONG_MS
      );
    }, CanalTauler.BATEC_MS);
  }

  #aturarBatec() {
    clearInterval(this.#batec.interval);
    clearTimeout(this.#batec.vigilant);
    this.#batec.interval = this.#batec.vigilant = null;
  }

  #canviarEstat(nou) {
    if (this.#estat === nou) return;
    this.#estat = nou;
    this.dispatchEvent(new CustomEvent('estat', { detail: { estat: nou } }));
  }
}

I l'ús a l'aplicació:

// js/app.js
import { CanalTauler } from './dades/temps-real.js';

const canal = new CanalTauler('wss://api.tallernomada.example/v1/tauler');

canal.addEventListener('estat', (esdeveniment) => {
  const { estat } = esdeveniment.detail;
  $('#indicador-connexio').textContent = {
    obert: 'En directe', connectant: 'Connectant…',
    tancat: 'Sense connexió', rendit: 'Sense connexió amb el tauler'
  }[estat];
  $('#indicador-connexio').dataset.estat = estat;           // el CSS pinta el punt de color
  $('#reconnectar').hidden = estat !== 'rendit';
});

canal.connectar();

// Els canvis locals viatgen pel canal a més de per l'API
document.addEventListener(ESDEVENIMENTS.TASCA_CANVIADA, (esdeveniment) => {
  if (esdeveniment.detail.origen === 'servidor') return;    // no reenviïs el que ha vingut de fora
  canal.enviar('tasca:actualitzada', esdeveniment.detail.tasca.toJSON());
});

// Tancament net en abandonar la pàgina
window.addEventListener('pagehide', () => canal.desconnectar('Pàgina tancada'));

Fixa't que la classe estén EventTarget: això li permet emetre els seus propis esdeveniments ('estat') amb la mateixa API que qualsevol element del DOM, sense dependre de document. És un patró molt útil per a objectes de servei.

  1. Un servidor de proves mínim

Per practicar necessites un servidor. Amb Node i la llibreria ws, just el necessari perquè el tauler funcioni:

mkdir nomada-ws && cd nomada-ws
npm init -y
npm install ws
// servidor.js — servidor de proves MÍNIM. No apte per a producció:
// sense autenticació, sense validació, sense persistència, sense comprovació d'Origin.
import { WebSocketServer } from 'ws';

const servidor = new WebSocketServer({ port: 8080 });

let tasques = [
  { id: 1, titol: 'Redissenyar la sala polivalent', responsable: 'Iván', prioritat: 'alta',
    estat: 'en-curs', horesEstimades: 12, dataLimit: '2026-09-30', versio: 1 },
  { id: 6, titol: 'Pressupost de la fusteria', responsable: 'Iván', prioritat: 'alta',
    estat: 'pendent', horesEstimades: 5, dataLimit: '2026-09-05', versio: 1 }
];

/** Reenvia un missatge a tots els clients connectats. */
function difondre(missatge) {
  const text = JSON.stringify(missatge);
  for (const client of servidor.clients) {
    if (client.readyState === 1) client.send(text);
  }
}

servidor.on('connection', (socket) => {
  console.log('Client connectat. Total:', servidor.clients.size);

  socket.on('message', (cru) => {
    let missatge;
    try { missatge = JSON.parse(cru); } catch { return; }

    switch (missatge.tipus) {
      case 'ping':
        socket.send(JSON.stringify({ tipus: 'pong' }));
        break;

      case 'subscriure':
        socket.send(JSON.stringify({ tipus: 'estat:complet', carrega: { tasques } }));
        difondre({ tipus: 'presencia', carrega: { connectats: servidor.clients.size } });
        break;

      case 'tasca:actualitzada': {
        const actual = tasques.find((t) => t.id === missatge.carrega.id);
        if (!actual) return;

        // Control de versió optimista (apartat 13)
        if (missatge.carrega.versioBase !== undefined && missatge.carrega.versioBase !== actual.versio) {
          socket.send(JSON.stringify({
            tipus: 'error', carrega: { codi: 'conflicte', id: actual.id, actual }
          }));
          return;
        }
        Object.assign(actual, missatge.carrega, { versio: actual.versio + 1 });
        difondre({ tipus: 'tasca:actualitzada', carrega: actual,
                   meta: { emissor: missatge.meta?.emissor, versio: actual.versio } });
        break;
      }
    }
  });

  socket.on('close', () => {
    difondre({ tipus: 'presencia', carrega: { connectats: servidor.clients.size } });
  });
});

console.log('Servidor de proves a ws://localhost:8080');
node servidor.js

I al client, new CanalTauler('ws://localhost:8080'). Obre dues pestanyes amb l'aplicació, mou una targeta en una i mira-la moure's a l'altra: aquest és el moment en què s'entén per a què serveix tot això.

Aquest servidor és una joguina didàctica. Li falta absolutament tot el de l'apartat 14: autenticació, autorització per missatge, validació de les regles R1-R10, comprovació d'Origin, límits de mida i de freqüència, i persistència. No el desplegis.

Per provar el client sense escriure servidor existeixen serveis públics d'eco com wss://echo.websocket.org o wss://ws.postman-echo.com/raw, que retornen el que els envies. Serveixen per verificar la connexió, la reconnexió i el format dels missatges, no la lògica del tauler.

Errors Habituals i Consells

  • Enviar abans de l'open. readyState és CONNECTING i send() llança InvalidStateError. Espera l'esdeveniment o encua.
  • Intentar reobrir el mateix socket. CLOSED és terminal. Reconnectar és construir un WebSocket nou.
  • Posar la reconnexió al gestor d'error. Després d'un error sempre arriba un close; faries dues reconnexions. Va al close.
  • Reconnectar sense retrocés exponencial. Quan el servidor es reiniciï, mil clients en bucle el tombaran una altra vegada.
  • Reconnectar després d'un 1000 o d'un 4001. El primer va ser un tancament demanat; el segon, un rebuig de credencials. Insistir no n'arregla cap.
  • No implementar heartbeat. La connexió zombi és la fallada més difícil de diagnosticar: tot «funciona» i no arriba res.
  • Enviar objectes a send(). Es converteixen en '[object Object]'. JSON.stringify sempre.
  • Analitzar sense try/catch. Un missatge corromput trenca el gestor i pot deixar el canal inservible.
  • Aplicar l'eco dels teus propis missatges. Provoca parpellejos i estats intermedis visibles. Filtra per meta.emissor.
  • Fer servir ws:// en producció. Tot viatja en clar i el navegador el bloqueja des d'una pàgina HTTPS.
  • Confiar en el que arriba pel canal. És entrada d'usuari. Valida al servidor, i al client no ho pintis mai amb innerHTML.
  • Oblidar tancar en abandonar la pàgina. Deixa connexions penjant al servidor. Fes servir pagehide.
  • Consell: mira els frames a DevTools. Network → filtre WS → pestanya Messages: veuràs cada missatge enviat i rebut amb la seva marca de temps. És l'eina de diagnòstic principal.
  • Consell: mostra sempre l'estat de la connexió. Un punt verd/ambre/vermell amb text costa deu línies i evita que l'usuari es cregui que l'aplicació està trencada.
  • Consell: prova desconnectant el wifi. És l'única manera de saber si la teva reconnexió, la teva cua i el teu heartbeat funcionen de debò.
  • Consell: si només reps, planteja't SSE. Es reconnecta sol, travessa proxies i és molt menys codi.

Exercicis

Exercici 1 — Cua amb deduplicació i persistència. Amplia la cua de CanalTauler perquè: (a) si s'encuen dos missatges amb el mateix tipus i el mateix carrega.id, només se'n conservi l'últim; (b) la cua es desi a localStorage sota 'nomada:cua:v1' cada vegada que canviï, i es recuperi en construir el canal; (c) els missatges amb més d'una hora d'antiguitat (segons meta.ts) es descartin en buidar, perquè reenviar un canvi molt vell pot trepitjar feina més recent d'una altra persona.

Exercici 2 — Indicador de connexió accessible. Escriu connectarIndicador(canal, element) que reflecteixi l'estat del canal (connectant, obert, tancat, rendit) en un element del DOM, amb text llegible, un data-estat per al CSS i aria-live="polite" perquè s'anunciï. Ha de mostrar a més quants missatges hi ha pendents a la cua quan no hi hagi connexió, i oferir un botó «Reintentar» només a l'estat rendit que reiniciï els intents i torni a connectar. Fes servir { signal } per poder-ho desconnectar tot de cop.

Exercici 3 — Resolució de conflictes amb versió. Implementa aplicarCanviRemot(tauler, tasca, versio) que apliqui un canvi rebut només si la seva versio és més gran que la que ja es té, i retorni 'aplicat' | 'ignorat' | 'conflicte'. Es considera conflicte quan la versió remota és menor que la local (el nostre canvi és més nou i el servidor encara no ho sap). Afegeix enviarAmbVersio(canal, tasca) que inclogui versioBase i, davant d'un missatge d'error amb codi: 'conflicte', mostri les dues opcions a l'usuari en lloc de decidir sola.

Solucions

Solució 1

const CLAU_CUA = 'nomada:cua:v1';
const MAX_EDAT_MS = 3600000;      // 1 hora

#encuar(missatge) {
  // (a) Deduplicar: fora l'anterior del mateix tipus i mateix recurs
  const clau = `${missatge.tipus}:${missatge.carrega?.id ?? ''}`;
  this.#cua = this.#cua.filter((m) => `${m.tipus}:${m.carrega?.id ?? ''}` !== clau);

  this.#cua.push(missatge);
  if (this.#cua.length > CanalTauler.MAX_CUA) this.#cua.shift();
  this.#persistirCua();                                    // (b)
}

#persistirCua() {
  try {
    localStorage.setItem(CLAU_CUA, JSON.stringify(this.#cua));
  } catch {
    /* sense quota: la cua continua viva en memòria, que és l'important */
  }
}

#recuperarCua() {
  try {
    const desada = JSON.parse(localStorage.getItem(CLAU_CUA) ?? '[]');
    this.#cua = Array.isArray(desada) ? desada : [];
  } catch {
    this.#cua = [];
  }
}

#buidarCua() {
  const ara = Date.now();
  // (c) Descartar el massa vell: reenviar-lo trepitjaria feina més recent
  const vigents = this.#cua.filter((m) => ara - Date.parse(m.meta.ts) < MAX_EDAT_MS);
  const descartats = this.#cua.length - vigents.length;
  if (descartats > 0) console.warn(`[nomada] ${descartats} missatges caducats descartats.`);

  this.#cua = [];
  this.#persistirCua();
  for (const missatge of vigents) this.#socket.send(JSON.stringify(missatge));
}

La deduplicació per tipus:id és el que evita reenviar cinc canvis d'estat de la mateixa targeta quan només importa l'últim. I el descart per edat és una decisió de producte disfressada de codi: un canvi de fa tres hores gairebé mai no ha de sobreescriure l'estat actual, i si ho fes, seria el pitjor tipus de pèrdua de dades.

Solució 2

const TEXTOS = Object.freeze({
  connectant: 'Connectant…',
  obert:      'En directe',
  tancat:     'Sense connexió',
  rendit:     'Sense connexió amb el tauler'
});

export function connectarIndicador(canal, element, { signal } = {}) {
  const text = element.querySelector('.indicador__text');
  const boto = element.querySelector('.indicador__reintentar');

  element.setAttribute('aria-live', 'polite');
  element.setAttribute('role', 'status');

  function pintar(estat) {
    const pendents = canal.pendents;
    const sufix = (estat !== 'obert' && pendents > 0)
      ? ` · ${pendents} ${pendents === 1 ? 'canvi sense enviar' : 'canvis sense enviar'}`
      : '';

    text.textContent = TEXTOS[estat] + sufix;         // textContent, mai innerHTML (06-02)
    element.dataset.estat = estat;                    // el CSS pinta el punt de color
    boto.hidden = estat !== 'rendit';
  }

  canal.addEventListener('estat', (esdeveniment) => pintar(esdeveniment.detail.estat), { signal });

  boto.addEventListener('click', () => {
    canal.reiniciarIntents();                         // mètode nou: posa #intents a 0
    canal.connectar();
  }, { signal });

  pintar(canal.estat);                                 // estat inicial, sense esperar el primer canvi
  return () => pintar(canal.estat);                    // permet refrescar el comptador de pendents
}

El signal permet desmuntar l'indicador sencer amb un sol abort(), tal com vas aprendre a 07-03. I role="status" amb aria-live="polite" fa que el canvi d'estat s'anunciï sense interrompre: perdre la connexió és informació rellevant també per a qui no veu el punt de color.

Solució 3

export function aplicarCanviRemot(tauler, tascaRemota, versioRemota) {
  const local = tauler.cercarPerId(tascaRemota.id);

  if (local === undefined) {                       // no la teníem: és nova per a nosaltres
    tauler.afegir(tascaRemota);
    return 'aplicat';
  }

  const versioLocal = local.versio ?? 0;

  if (versioRemota > versioLocal) {
    tauler.substituir(tascaRemota);
    return 'aplicat';
  }
  if (versioRemota === versioLocal) {
    return 'ignorat';                              // ja la tenim, és l'eco d'un canvi conegut
  }
  return 'conflicte';                              // la nostra és més nova que la del servidor
}
export function enviarAmbVersio(canal, tasca) {
  return canal.enviar('tasca:actualitzada', { ...tasca.toJSON(), versioBase: tasca.versio });
}

document.addEventListener(ESDEVENIMENTS.ERROR_REMOT, (esdeveniment) => {
  const { codi, id, actual } = esdeveniment.detail;
  if (codi !== 'conflicte') return;

  const meva = tauler.cercarPerId(id);
  vista.mostrarConflicte({
    missatge: `«${actual.titol}» ha canviat mentre l'editaves.`,
    opcions: [
      { text: 'Quedar-me amb la del servidor',
        enPremer: () => { tauler.substituir(Tasca.desDeJSON(actual)); vista.render(); } },
      { text: 'Sobreescriure amb la meva',
        enPremer: () => canal.enviar('tasca:actualitzada', { ...meva.toJSON(), versioBase: actual.versio }) }
    ]
  });
});

L'essencial no és l'algorisme, sinó la decisió de disseny: el codi no tria per l'usuari. Quan hi ha un conflicte real, es mostra, amb les dues versions i dos botons. El cas 'ignorat' és igual d'important: sense ell, l'eco d'un canvi que ja vas aplicar provocaria un repintat innecessari i, amb optimistic UI pel mig, un parpelleig visible.

Conclusió

El tauler del Taller Nómada ja és viu. Saps per què el model petició-resposta no basta —el servidor no pot parlar primer— i coneixes les quatre tècniques que ho resolen, amb criteri per triar: polling quan la latència és igual, long polling quan la compatibilitat mana, SSE quan només reps (es reconnecta sol i és molt menys codi) i WebSockets quan necessites els dos sentits amb baixa latència. Entens el protocol: el handshake HTTP amb Upgrade i el 101 Switching Protocols, els esquemes ws:// i wss://, el canal full-duplex on ja no hi ha verbs ni codis d'estat, i el cost real que implica —el servidor manté estat per connexió, i això complica l'escalat—.

Domines l'API: el constructor que connecta immediatament, els quatre esdeveniments, el readyState amb els seus quatre valors i la regla que CLOSED és terminal —reconnectar és construir un socket nou—, send() que només accepta text o binari, esdeveniment.data que mai arriba analitzat, i close(codi, motiu) amb el seu vocabulari de codis, del 1000 net al 1006 que crida «reconnecta» i el rang 4000-4999 que és teu. Has dissenyat un protocol de missatges amb tipus, carrega i meta, perquè un WebSocket és un tub de bytes i l'estructura la poses tu; i l'has alineat amb els noms dels CustomEvent de 06-04 per tenir un sol vocabulari.

Saps que una connexió real cau, i tens les tres defenses: reconnexió amb retrocés exponencial i jitter, que no insisteix davant d'un 1000 ni d'un 4001; heartbeat ping/pong amb vigilant per caçar la connexió zombi que el navegador no detecta; i cua de missatges amb sostre, deduplicació i descart per edat perquè un canvi fet sense connexió no es perdi en silenci. Saps integrar el canal amb la vista sense acoblar-los —el canal emet CustomEvent, la vista escolta i reconcilia per data-id com a 06-06, el repositori de 07-01 manté la còpia local al dia— i filtrar l'eco dels teus propis canvis per meta.emissor perquè l'optimistic UI de 07-03 no parpellegi. I saps que els conflictes són un problema de producte: última escriptura guanya perd dades en silenci, i el control de versió optimista els detecta i deixa decidir la persona, que sempre és millor.

En seguretat queden tres regles gravades: wss obligatori, autenticació real —recordant que el constructor no admet capçaleres i que un token a la URL acaba als registres—, i sobretot validar sempre al servidor, comprovant l'Origin al handshake, perquè els WebSockets no estan subjectes a la política de mateix origen i sense aquella comprovació qualsevol web s'hi podria connectar amb les credencials del teu usuari.

Amb js/dades/temps-real.js i el seu CanalTauler, Nómada Tasques és una aplicació connectada, robusta i col·laborativa. Li queda una debilitat gran, i és la més quotidiana de totes: depèn completament de tenir xarxa. Si l'Iván baixa al magatzem de serigrafia, on no arriba el wifi, l'aplicació no arrenca: l'HTML, el CSS i els mòduls ES viuen al servidor, i sense connexió el navegador no té res per carregar. La còpia de localStorage és allà, però ningú no la pot llegir perquè la pàgina ni s'obre. Per creuar aquella frontera cal una cosa diferent: un procés que visqui fora de la pàgina, que intercepti les peticions de xarxa abans que surtin i sàpiga respondre-les des d'una memòria cau. Això és Service Workers i Aplicacions Web Progressives (PWA), on Nómada Tasques aprendrà a funcionar sense connexió i a instal·lar-se al dispositiu com una aplicació més.

Curs de JavaScript: De Principiant a Avançat

Mòdul 1: Introducció a JavaScript

Mòdul 2: Estructures de Control

Mòdul 3: Funcions

Mòdul 4: Objectes i Arrays

Mòdul 5: Objectes i Funcions Avançades

Mòdul 6: El Model d'Objectes del Document (DOM)

Mòdul 7: APIs del Navegador i Temes Avançats

Mòdul 8: Proves i Depuració

Mòdul 9: Rendiment i Optimització

Mòdul 10: Frameworks i Llibreries de JavaScript

Mòdul 11: Projecte Final

© Copyright 2026. Tots els drets reservats