# fwtozza

Le framework fwtozza se base sur l'échange de messages entre objets. Les objets reçoivent
et émettent des messages sans forcément connaître les autres objets autour d'eux ni où
vont les messages. Les objets conversent avec un **broker** dont le rôle est d'aiguiller
les messages et de **valider chaque émission contre son contrat** avant de la livrer. 
La validation utilise un catalogue où sont définis les schéma des messages, leurs versions et révisions.


Trois briques, toutes sans dépendance runtime (modules ES natifs, pas de build) :

- **`broker.js`** — l'aiguilleur. Les objets s'y inscrivent (`register`) en déclarant ce
  qu'ils émettent et ce qu'ils reçoivent ; ils émettent par `publish` ; ils reçoivent
  dans leur méthode `handleMessage`. Il y a un broker par environnement : un dans le
  navigateur, et un sur le serveur quand il y en a un (voir « Cas client-serveur »).
- **`catalog.js`** — le livre des contrats : la description, en **pure donnée**, de
  chaque message (champs typés, versions, révisions, exemples). C'est la source de
  vérité unique ; il est injecté au broker à la construction.
- **`engine.js`** — l'interpréteur du catalogue, partagé par tous les projets, jamais
  régénéré : `validate` (validation d'un payload contre le schéma de sa révision),
  `validateCatalog` (lint structurel du catalogue entier, joué à la construction du
  broker), `upcastToLatestRevision` et `completeToLatestRevision` (complétion vers la
  forme canonique), `upcast` (conversion entre versions cassantes), `canonicalStringify`
  (sérialisation à clés triées — le canal s'en sert pour comparer deux schémas de
  révision au handshake) et `fingerprintOf` (hash de contenu d'un contrat de fil,
  utilitaire de comparaison rapide).

> **Note ES module** : chaque fichier de classe se termine par
> `export default <NomDeClasse>;`, les pages HTML utilisent `<script type="module">`
> avec des `import`. Côté serveur Node.js, `"type": "module"` dans le `package.json`.

> **Terminologie** : le vocabulaire du framework est en anglais (glossaire en §7 de
> `FEUILLE_DE_ROUTE_FWTOZZA.md`) ; les règles complètes de rédaction du catalogue sont
> en §6 du même document.

## Le message et son contrat

Un message circule sous la forme d'une enveloppe à clé unique :

```javascript
{ nom_du_message: { /* payload */ } }
```

Chaque champ du payload est déclaré au catalogue — aucun champ non déclaré, aucune clé
implicite. Deux clés sont réservées au transport en environnement client-serveur et
vivent au niveau de l'enveloppe, à côté du nom du message — jamais dans un payload ni
au catalogue : **`id`** (l'identifiant de session, pour cibler des clients) et
**`session`** (l'état de session, joint par référence aux livraisons côté serveur) —
voir « Cas client-serveur ».

La clé unique est la forme nominale, celle qu'on écrit. Le broker, lui, **tolère une
enveloppe à plusieurs noms** : chaque clé non réservée y est traitée comme une
publication indépendante — validée pour elle-même, livrée dans l'ordre de déclaration
(les clés réservées `id` et `session` de l'enveloppe s'appliquant alors à chacune),
et jetée seule si elle est fautive, sans entraîner les autres (cohérent avec la règle
générale : une erreur est bruyante mais n'arrête rien). Il n'y a en revanche **aucune
atomicité** entre les clés d'une même enveloppe — qui veut un tout-ou-rien en fait un
seul message, avec un payload composite déclaré au catalogue.

Une entrée du catalogue (un **contrat**) décrit le message par versions et révisions :

```javascript
text_entered: {
	versions: {
		1: {
			revisions: {
				0: { fields: {
					text: { type: "string", required: true },
				} },
			},
		},
	},
	examples:        { 1: { 0: [ { text: "bonjour" } ] } },
	counterexamples: { 1: { 0: [ { reason: "text manquant", payload: {} } ] } },
}
```

- Le vocabulaire des champs : `type` (`"string"`, `"number"`, `"boolean"`, `"object"`,
  `"array"`), `required`, `pattern`, `enum`, `default`, `fields` (sous-schéma d'un
  objet), `items` (sous-schéma des éléments d'une liste), plus `oneOf` au niveau d'une
  révision (exactement un champ présent parmi N : `oneOf: ["text", "image"]` — le
  payload porte `text` ou `image`, jamais les deux ni aucun ; ses membres, mutuellement
  exclusifs, doivent rester **optionnels et sans `default`** — sinon l'upcast en
  comblerait un et casserait l'invariant). Aucune règle conditionnelle par valeur.
- Une **révision** est une évolution compatible (ajout de champs), gravée en snapshot
  complet — jamais un diff. La révision courante est la plus haute ; elle n'est pas
  stockée, le moteur la dérive.
- Un **émetteur** déclare la révision qu'il produit et est validé contre elle ; un
  **abonné** ne déclare que la version et reçoit toujours la forme canonique (le broker
  upcast systématiquement, en complétant les champs nouveaux par leur `default`).
- Une **version** est un changement cassant, rare, accompagné d'un upcaster déclaratif
  (`upcastFrom: { rename, defaults, remove }`) que le broker applique pour servir un
  abonné d'une version plus récente que l'émetteur. Si l'écart est de plusieurs versions,
  le broker **chaîne de proche en proche** (v→v+1→…→cible, chaque étape complétée à sa
  forme canonique, chaque palier mémoïsé) : un émetteur v1 sert un abonné v3 via
  `upcastFrom` de v2 puis de v3. Toujours vers l'avant — pas de downcast.
- `examples` et `counterexamples` (entrées `{ reason, payload }`) prouvent le contrat :
  `node test_engine.js` vérifie que chaque exemple valide sa révision, que son upcast
  valide la révision courante, et que chaque contre-exemple est rejeté.

Un payload est de la **donnée JSON pure** (les cinq types du catalogue, rien d'autre) :
jamais de `Date`, d'instance de classe ou de fonction — un message doit se comporter à
l'identique livré localement (copie `structuredClone`) ou à travers un canal
(`JSON.stringify`). Pour une date : un timestamp `number` ou un string ISO.

Le broker est **strict** : l'inscription est validée en bloc et **atomique** — une
seule déclaration fautive (message, version ou révision inconnus du catalogue,
collision d'`instanceName`, objet sans `instanceName`) rejette l'objet **entier**, rien
n'est inscrit, les erreurs sont listées en console et `register` renvoie `false` ;
l'application, elle, continue sans l'objet fautif (pas d'exception qui l'arrêterait).
Un payload invalide est jeté à l'émission, avec la liste des violations en console. Un
contrat validé ne se modifie jamais — on révise ou on versionne.

Le catalogue lui-même est **linté à la construction du broker** (`validateCatalog` du
moteur) : type inconnu, `object` sans `fields`, `array` sans `items`, `pattern` hors
string ou incompilable, `enum` hors primitives ou incohérent avec le type, `default`
invalide contre sa propre règle, `oneOf` visant un champ non déclaré ou dont un membre
est `required` ou porte un `default` (il doit rester optionnel, sinon l'upcast le
comblerait et casserait « exactement un »), un **nom de champ contenant un `.`** (réservé
comme séparateur de chemin d'`upcastFrom`), `upcastFrom` malformé — et la garantie de
forme canonique : un champ que la révision courante exige mais qu'une révision antérieure
pouvait omettre **doit** porter un `default`, sans quoi l'upcast ne pourrait jamais le
compléter. Chaque défaut est une erreur en console à la
composition, au moment où on regarde ; l'application, elle, continue — une publication
qui touche un contrat défectueux sera jetée par la validation.

## La livraison

Le broker livre en **file FIFO à épuisement** (run-to-completion) :

- la **validation est immédiate**, sur la pile de l'émetteur : un payload invalide est
  signalé là où il est produit ; le message validé est mis en file ;
- le `publish` le plus externe **vide la file avant de rendre la main** — vu de
  l'extérieur tout est synchrone : après `publish`, toutes les livraisons ont eu lieu
  (les tests peuvent asserter immédiatement) ;
- un `publish` émis depuis un `handleMessage` est mis en file et livré **après** le
  message courant, jamais imbriqué — ordre FIFO déterministe, pile bornée ;
- chaque `handleMessage` est appelé sous try/catch : un abonné qui lève une exception
  est signalé en console mais **ne prive ni les abonnés suivants ni les messages
  suivants** ;
- garde-fou anti-cycle : au-delà de `Broker.DELIVERY_BUDGET` (1000) messages livrés en
  un seul épuisement, le broker jette la file avec une erreur en console — un cycle
  A → B → A devient un diagnostic, pas un gel.

Une émission valide sans abonné est livrée à personne, sans erreur.

## L'audit du graphe

Sans abonné à l'exécution est légitime ; sans abonné **à la composition** ne l'est
presque jamais. Un message que personne n'écoute, un abonné que personne n'alimente, un
contrat gravé que plus rien ne cite : ce sont les trois formes de dérive entre ce que le
catalogue décrit et ce que le code fait. `broker.audit(transits)` les rend visibles.

Il s'appelle **en fin de composition**, quand tout est monté, et ne fait que lire : il ne
publie rien, ne modifie rien, et **rend** la liste des anomalies au lieu de décider à la
place de l'application. C'est un diagnostic, pas un garde-fou — pendant la construction,
un orphelin est normal : on grave le contrat avant d'écrire le consommateur.

L'argument `transits` est la liste des trios `CHANNEL` des canaux montés, et il est
indispensable : sans lui, tout message qui traverse un canal ressemble à un orphelin,
puisque son seul abonné local *est* le canal. Avec lui la règle devient exacte — un
message émis localement, sans abonné local, **et absent de toute autorisation de
transit**, est un orphelin réel.

| code | anomalie |
|---|---|
| `orphan_emit` | émis localement, aucun abonné local, ne traverse aucun canal |
| `dead_receive` | reçu localement, aucun émetteur local, ne traverse aucun canal — plus grave qu'un orphelin : l'abonné a l'air vivant |
| `unused_contract` | au catalogue, ni émis ni reçu par personne |
| `version_gap` | émis en version X, attendu en version Y ≠ X : émetteur et abonné ne se rencontreront jamais |

```javascript
// fin de index.html ou de server.js, une fois tout monté
for (const a of broker.audit([CHANNEL]))
	console.warn("AUDIT (" + a.code + "): " + a.message);
```

**Ce que l'audit ne voit pas** — et qu'il ne faut pas lui demander :

- **L'autre côté du canal.** `server.js` ne connaît que les composants serveur,
  `index.html` que les composants client. « Émis, traverse le canal, personne n'écoute en
  face » n'est décidable qu'avec les deux arbres sous les yeux : c'est un contrôle
  d'outillage, pas d'exécution.
- **Les payloads.** Un payload naît dans un corps de méthode, à l'exécution ; l'audit ne
  lit que des déclarations. Ce n'est pas un manque : le broker valide déjà **chaque**
  payload à l'émission et à la réception, et `validateCatalog` vérifie au chargement que
  les `examples` sont conformes à leur propre contrat. La dérive porte sur le **graphe**,
  pas sur le **contenu**.
- **Les composants non montés.** Un fichier de `components/` que personne n'instancie est
  invisible ici — correctement, puisque l'audit décrit l'application qui tourne. Repérer
  le fichier mort est un parcours de répertoire, pas un audit de broker.

## L'objet standard

Client et serveur partagent **une seule et même signature** — `(broker, instanceName,
deps)`. Le broker et le nom d'instance sont les deux choses que le framework connaît ;
tout le reste arrive par `deps`, un sac que le point de composition remplit et que le
framework ne regarde pas, à une clé près. Un objet serveur reçoit simplement un `deps`
sans `support` :

```javascript
class StandardObject {

	instanceName;      // string qui différencie une instance d'une autre
	broker;            // le broker auprès duquel l'objet s'inscrit et par lequel il émet
	deps;              // tout le reste : supports DOM, pools, sockets, réglages
	supports;          // les nœuds HTML qui portent le rendu, normalisés en carte
	support;           // raccourci vers supports.main — le cas courant, à un seul support
	verbose = false;   // à basculer à true pour la phase de test

	// Contrats consommés et produits, tels que déclarés au catalogue.
	// Un émetteur peut préciser la révision qu'il produit ({ name, version, revision }) ;
	// omise, c'est la révision courante. Un abonné ne déclare JAMAIS de révision.
	emits    = [{ name: "some_message", version: 1 }];
	receives = [{ name: "other_message", version: 1 }];

	constructor(broker, instanceName, deps = {}) {
		this.broker = broker;
		this.instanceName = instanceName;
		this.deps = deps;
		// un nœud, une carte de nœuds, ou rien : normalisé ici, une fois pour toutes,
		// pour que destroy() et les squelettes générés n'aient jamais à brancher.
		this.supports = deps.support?.nodeType ? { main: deps.support } : (deps.support ?? {});
		this.support  = this.supports.main;
		this.render(); // initialisation du rendu dans this.support (objets client)
		if (this.broker.verbose || this.verbose) console.log("INFO (" + this.instanceName + "): register");
		this.broker.register(this, this.emits, this.receives);
	}

	handleMessage(message) {
		if (this.broker.verbose || this.verbose) console.log("INFO (" + this.instanceName + "): reçoit " + JSON.stringify(message));
		// En fonction des clés présentes dans message, on effectue des traitements :
		// en général un rafraîchissement d'affichage et l'émission de messages via
		// this.broker.publish(this, { some_message: { ... } });
		// Le payload reçu est déjà validé, upcasté en forme canonique, et privé
		// (chaque abonné reçoit sa propre copie).
	}

	// Symétrique du constructeur : à appeler quand l'instance est détruite
	// (vue démontée, composant remplacé...). On nettoie ce qu'on a peint ; on ne
	// détruit jamais ce qu'on a reçu.
	destroy() {
		this.broker.unregister(this, this.emits, this.receives);
		for (const node of Object.values(this.supports)) node.replaceChildren();
	}

}
export default StandardObject;
```

### Les cinq règles de `deps`

1. **Tout ce qui n'est ni le broker ni le nom d'instance arrive par `deps`, à la
   construction.** Un composant ne lit jamais `process.env`, n'importe jamais un module
   de configuration, ne va jamais chercher un singleton : ce qu'il ne reçoit pas, il ne
   l'a pas. C'est la même discipline que pour les messages — un objet qui va chercher
   quelque chose connaît quelque chose, et n'est plus testable seul.
2. **`deps` est opaque au framework — à une clé près, `support`**, que le constructeur
   normalise et que `destroy()` nettoie. Le reste, le broker ne le lit pas, ne le valide
   pas, ne le transporte pas, ne le journalise pas : `deps` n'est pas un second catalogue.
3. **Un handle ne voyage jamais en message.** La règle du payload JSON pur l'interdit
   déjà : pas de `store_ready` qui porterait un pool, pas de socket dans une enveloppe.
   Une ressource se passe au constructeur, jamais par le broker.
4. **La propriété reste au point de composition.** `destroy()` ne ferme pas ce qu'il n'a
   pas ouvert — un composant qui fermerait le pool qu'on lui prête casserait ses voisins.
   Seule exception, bornée et explicite : un **canal** ferme son transport sur une
   *violation de protocole* (handshake refusé, timeout), parce qu'un socket qui ne peut
   pas s'accorder n'est plus utilisable par personne ; jamais au titre du cycle de vie.
5. **On passe la ressource ouverte, pas son URL.** Un pool, pas une chaîne de connexion ;
   un socket, pas une adresse. Sinon N composants ouvrent N connexions vers la même
   chose, et aucun test ne peut leur substituer un double.

Un fichier de configuration (`config.js`, `.env`) peut parfaitement exister ; la
contrainte ne porte pas sur son existence mais sur **qui a le droit de l'importer** :
`server.js` et `index.html`, personne d'autre. Côté serveur ce n'est de toute façon
qu'un `process.env` avec des valeurs par défaut ; côté client il n'y a pas
d'environnement, et `index.html` *est* déjà le fichier de configuration.

### Plusieurs supports

Un composant dont l'interface vit légitimement à deux endroits — une liste dans la
colonne principale, sa saisie ancrée en bas de page — reçoit une **carte** de nœuds :

```javascript
new ChatBox(broker, "chat", { support: {
	liste:  document.getElementById("liste"),
	saisie: document.getElementById("saisie"),
} });
```

Il peint alors dans `this.supports.liste` et `this.supports.saisie` ; `this.support` vaut
`undefined`, ce qui est correct — il n'y a pas de support principal. Sans cette
possibilité, un tel composant n'aurait le choix qu'entre réclamer un conteneur qu'il ne
possède pas et se scinder en deux, c'est-à-dire découper par la géométrie de l'écran là
où le framework demande de découper par fonction.

**Cycle de vie** : un objet non désinscrit reste abonné et continue de recevoir des
messages — toute destruction d'instance passe donc par `destroy()`. Abandonner la
référence (`a = null`) ne détruit rien : le broker retient l'objet, qui reste abonné et
vivant — sans `destroy()` explicite, c'est un abonné fantôme et une fuite mémoire. La
désinscription est **immédiate** : dès `unregister`, plus aucune livraison n'atteint
l'objet, y compris les messages déjà en file et la livraison en cours. Comme à
l'émission, l'**identité de l'objet** est contrôlée — l'inscription doit appartenir à
l'objet qui se désinscrit, pas seulement porter le même `instanceName` : un homonyme ne
peut ni émettre ni désinscrire à la place d'un autre.

# Cas d'une application côté client sans serveur

C'est le cas le plus simple, et le meilleur pour saisir la logique broker / engine /
catalog : tout vit dans le navigateur, un seul broker, aucun réseau. Les fichiers sont
servis par n'importe quel serveur web statique, et l'arborescence sépare ce qui est
**générique** (le framework : moteur, broker — jamais régénéré, dans `fw/`) de ce qui
est **applicatif** (catalogue, composants) :

```
projet/                           racine web, servie par n'importe quel serveur statique
  index.html                      point d'entrée : composition (broker, composants, deps)
  fw/                             framework — générique, jamais régénéré
    engine.js                     le moteur : interpréteur du catalogue
    broker.js                     le broker client
  catalogs/
    catalog.js                    le catalogue du broker : tous les contrats de l'application
  components/
    <composants client>.js        un objet standard = un fichier
```

## Exemple

Imaginons une petite application de chat : une zone de saisie et la liste des messages
déjà envoyés. À chaque message envoyé, il apparaît dans la liste et la saisie se
réinitialise.

**Un seul composant, `ChatBox`.** La liste et la saisie ne sont **pas** deux composants
fwtozza. Le découpage en composants se fait par **fonction et/ou déploiement** : or ces
deux morceaux sont indissociables — ils partagent le même état, se déploient toujours
ensemble, et l'un n'a aucun sens sans l'autre. Les scinder n'ajouterait qu'un échange de
messages artificiel. À l'intérieur d'un composant, on programme normalement (POO
classique, React…) : fwtozza ne régit que les **frontières entre composants**, jamais
leur mécanique interne. C'est là son échelle — quelques composants dissociés par
fonction, pas une poussière de micro-objets d'interface reliés par le broker.

**Une seule réserve, et elle ne porte pas sur les bibliothèques : pas de build.** fwtozza
est en modules ES natifs — `<script type="module">`, `export default`, aucun bundler —, et
un composant doit l'être aussi : **le fichier qui s'exécute est le fichier qui a été
écrit**. Une bibliothèque livrée en ESM, importée depuis `vendor/`, ne pose aucun
problème ; une syntaxe qui demande à être compilée (JSX, TypeScript) en pose un, parce
qu'elle interpose un artefact entre la source et l'exécution. Les dépendances de
développement, elles, sont libres — `jsdom` sert les tests et ne construit rien. La règle
n'est pas esthétique : sans elle, on ne peut plus dire de rien qu'on l'a lu.

**Un second composant, `MessageNotifier`** — et lui a une vraie raison d'exister à part.
C'est un petit calque qui affiche deux compteurs : « N messages envoyés » et « N messages
dans la liste ». Il est **affichable indépendamment** de la zone de chat (on veut pouvoir
le montrer alors que le chat est masqué) et **réutilisable ailleurs** : voilà une
frontière fonctionnelle légitime, et c'est elle — pas un découpage visuel — qui justifie
qu'un message traverse le broker. `ChatBox` émet `text_entered` ; `MessageNotifier` s'y
abonne et met ses compteurs à jour.

Sans serveur, les deux compteurs suivent le même événement (chaque envoi est aussi une
ligne dans la liste) : ils coïncident. Ils divergeront dès le cas client-serveur, où
« dans le salon » comptera les messages de **tous** et « envoyés » seulement les vôtres —
c'est là que le second composant prend tout son sens.

**Le catalogue** (`catalogs/catalog.js`) — application sans serveur : le catalogue du
broker porte directement tous les contrats de l'application (le cas client-serveur
raffinera cette organisation). Un seul suffit : le seul message qui traverse le broker
est `text_entered`, de `ChatBox` vers `MessageNotifier`. Pas de champ `instance` — les
composants sont **uniques**, personne n'a besoin de se reconnaître par un nom :

```javascript
export const CATALOG = {

	text_entered: {
		versions: { 1: { revisions: { 0: { fields: {
			text: { type: "string", required: true },
		} } } } },
		examples:        { 1: { 0: [ { text: "bonjour" } ] } },
		counterexamples: { 1: { 0: [ { reason: "text manquant", payload: {} } ] } },
	},

};
export default CATALOG;
```

**index.html**

```html
<!doctype html>
<html lang="fr">
  <head>
    <meta charset="utf-8">
    <style>
      .notifier { position: fixed; top: 8px; right: 8px; font: 13px sans-serif; }
    </style>
  </head>

  <body>

    <div id="chat"></div>
    <div id="notifier"></div>

    <script type="module">
      import Broker from "./fw/broker.js";
      import CATALOG from "./catalogs/catalog.js";
      import ChatBox from "./components/chat_box.js";
      import MessageNotifier from "./components/message_notifier.js";

      const broker = new Broker(CATALOG);

      const chat     = new ChatBox(broker, "chat", { support: document.getElementById("chat") });
      const notifier = new MessageNotifier(broker, "notifier", { support: document.getElementById("notifier") });
    </script>
  </body>
</html>
```

**ChatBox** — saisie **et** liste dans le même composant. Sans serveur, l'affichage et la
réinitialisation sont purement internes ; le broker n'entre en jeu que pour prévenir le
notifier :

```javascript
class ChatBox {

	instanceName;
	broker;
	deps;
	supports;
	support;
	verbose = false;

	emits    = [{ name: "text_entered", version: 1 }];
	receives = []; // sans serveur, ChatBox affiche ce qu'il saisit : rien à recevoir

	inputField;
	sendButton;
	list;

	constructor(broker, instanceName, deps = {}) {
		this.broker = broker;
		this.instanceName = instanceName;
		this.deps = deps;
		this.supports = deps.support?.nodeType ? { main: deps.support } : (deps.support ?? {});
		this.support  = this.supports.main;
		this.render();
		this.broker.register(this, this.emits, this.receives);
	}

	render() {
		this.list = document.createElement("ul");

		this.inputField = document.createElement("input");
		this.inputField.type = "text";
		this.inputField.placeholder = "Saisissez votre texte ici...";

		this.sendButton = document.createElement("button");
		this.sendButton.innerText = "Envoyer";
		this.sendButton.onclick = () => {
			const text = this.inputField.value.trim();
			if (text === "") return;

			// liste et saisie sont internes au composant : on affiche et on
			// réinitialise directement, sans passer par le broker.
			const li = document.createElement("li");
			li.textContent = text;
			this.list.appendChild(li);
			this.inputField.value = "";
			this.inputField.focus();

			// le seul message qui traverse le broker, à destination du notifier :
			this.broker.publish(this, { text_entered: { text } });
		};

		this.support.appendChild(this.list);
		this.support.appendChild(this.inputField);
		this.support.appendChild(this.sendButton);
	}

}
export default ChatBox;
```

**MessageNotifier** — un calque autonome qui ne connaît pas `ChatBox` : il écoute
`text_entered` et compte. Sans serveur, envoyer c'est aussi ajouter à la liste, donc les
deux compteurs avancent ensemble :

```javascript
class MessageNotifier {

	instanceName;
	broker;
	deps;
	supports;
	support;
	verbose = false;

	emits    = [];
	receives = [{ name: "text_entered", version: 1 }];

	sent   = 0; // messages envoyés
	listed = 0; // messages dans la liste

	constructor(broker, instanceName, deps = {}) {
		this.broker = broker;
		this.instanceName = instanceName;
		this.deps = deps;
		this.supports = deps.support?.nodeType ? { main: deps.support } : (deps.support ?? {});
		this.support  = this.supports.main;
		this.render();
		this.broker.register(this, this.emits, this.receives);
	}

	render() {
		this.support.className = "notifier";
		this.paint();
	}

	paint() {
		this.support.textContent = this.sent + " envoyés · " + this.listed + " dans la liste";
	}

	handleMessage(message) {
		if (message.text_entered) {
			// sans serveur, envoyer = ajouter à la liste : les deux comptent ensemble
			this.sent++;
			this.listed++;
			this.paint();
		}
	}

}
export default MessageNotifier;
```

Si `ChatBox` émettait un payload hors contrat (`text` manquant, champ non déclaré…), le
broker le jetterait avec le détail des violations en console — `MessageNotifier` ne peut
jamais recevoir un message mal formé.

`ChatBox` et `MessageNotifier` ne se connaissent pas : on peut supprimer le notifier sans
toucher au chat, ou monter le notifier seul. C'est cette dissociation — par fonction, pas
par découpage visuel — que le cas client-serveur va exploiter pleinement.

# Cas client-serveur

fwtozza fonctionne aussi en environnement client-serveur, et son intérêt y est même plus
grand : les objets coexistent sans se connaître et **sans savoir sur quelle machine
s'exécutent les autres**. Il faut pour cela un broker sur le serveur, où les objets
s'inscrivent exactement comme au navigateur, et un **canal** (channel) de chaque côté
pour faire passer les messages d'un broker à l'autre. Le canal est un objet fwtozza
comme un autre : il s'inscrit auprès de son broker ; sa seule particularité est de
convertir entre messages locaux et trames réseau.

Broker client et broker serveur sont **deux fichiers volontairement distincts** : le
broker serveur route les clés d'enveloppe `id` (ciblage de session) et `session` (état
de session, propagé par référence — voir plus bas), le broker client les ignore.

```
aller  : ChatBox ──publish──▶ broker client ──▶ canal client
                                                     ┆ (réseau)
												canal serveur ──publish──▶ broker serveur ──▶ TextBoard

retour : TextBoard ──publish──▶ broker serveur ──▶ canal serveur
                                                       ┆ (réseau)
												canal client ──publish──▶ broker client ──▶ ChatBox, MessageNotifier
```

Rien ne parle jamais directement au canal : dans les deux sens, chaque objet — canal
compris — publie auprès de **son** broker, et c'est le broker qui livre au canal parce
que celui-ci s'y est abonné (côté serveur, pour les seuls messages de son autorisation
de transit).

Quatre mécanismes de transport, tels que le canal WebSocket de référence les met en
œuvre — en voici le résumé. La version normative, et surtout le **découpage** entre ce
que tout canal doit respecter, ce qui n'incombe qu'aux canaux réseau et ce que le canal
de référence offre en plus, sont en « Les exigences d'un canal ».

- **L'autorisation de transit** : un serveur peut porter plusieurs canaux, connectés à
  des populations de clients différentes. Chaque canal est donc construit avec
  l'autorisation issue de **son** catalogue partagé (`CHANNEL` : le millésime + la liste
  des messages que ses clients peuvent envoyer + la liste de ceux qu'ils reçoivent,
  avec leurs versions). Le canal ne s'inscrit auprès du broker que pour ces contrats-là,
  et le broker strict fait respecter la frontière : un message serveur-only, ou qui
  concerne un autre canal, ne peut **pas** transiter — le canal n'y est simplement pas
  inscrit, ni en émission ni en abonnement.
- **Le handshake bloquant** : à la connexion, rien ne transite avant que le canal
  client ait envoyé `handshake_request` (son **contrat de canal** — les listes de
  transit et les **schémas complets** des messages qui transitent —, le millésime
  consultatif, et le token d'une session à reprendre s'il en détient un) et reçu
  `handshake_response`. Le serveur vérifie la **compatibilité**, pas l'identité : il ne
  refuse (`error` explicite puis fermeture) que sur une vraie incompatibilité — un
  message de transit manquant ou en trop, une version divergente, un schéma qui diffère
  sur une révision partagée, ou une révision qui viole la règle directionnelle (un
  récepteur en retard sur son émetteur, qu'aucun upcast ne peut combler). Un millésime
  différent n'est plus qu'un **warning** ; le hash de contenu a quitté le protocole. En
  cas de succès, la réponse renvoie au client les **bases d'upcast** à appliquer aux
  trames serveur→client. Les messages de handshake sont de la plomberie du canal, hors
  catalogue — comme les clés `id` et `session`.
- **La session et ses clés d'enveloppe** : le handshake ouvre — ou reprend, via le
  token, à la manière d'une session PHP — une session qui survit aux coupures pendant
  un TTL. Le canal serveur joint à tout message entrant `id` (l'identifiant de session,
  stable à travers les reconnexions) et `session` (l'objet `{ data }` de la session,
  que le broker serveur propage **par référence** : un composant d'authentification y
  écrit l'identité une fois, tous les abonnés la voient). Un objet serveur peut joindre
  `id` (une session ou un array) à ses émissions : le canal ne les enverra qu'à ces
  clients ; sans `id`, tous les reçoivent. Les deux clés sont retirées de la trame
  avant l'envoi au client.
- **Les événements de connexion** : le canal serveur publie `connection_event`
  (contrat fourni par le framework, `fw/channel.catalog.js`) à chaque transition de
  session — `connected`, `resumed`, `disconnected`, `expired` : les objets serveur
  savent qu'un client est arrivé, revenu, coupé, ou parti pour de bon, et peuvent
  libérer ce qu'ils tenaient pour lui.

## Disposition des fichiers

On prendra l'exemple d'un serveur node et d'une partie client dont les interfaces 
sont servies par ce même serveur. On pourra imaginer avoir un serveur d'un côté et la 
partie client servie par un serveur web générique. Ici pour l'exemple le client est 
dans le dossier "public".
Dans cette exemple tout ce qui est commun aux deux côtés vit donc sous `public/`, et le
serveur vient l'y chercher (**une seule source, aucune duplication**). Si on sépare 
 serveur et client, il faudra évidement faire une duplication. Tout le reste
est libre, et l'arborescence de référence sépare ce qui est **générique** (le
framework : moteur, brokers, canaux — jamais régénéré) de ce qui est **applicatif**
(catalogues, composants), en miroir des deux côtés, et range les catalogues **par
canal** : c'est ce qui permet de multiplier objets et canaux (un client peut parler au
serveur qui sert ses interfaces à travers un canal *et* à un autre serveur via un autre canal) sans jamais perdre de vue où
vivent les canaux et leurs catalogues.

```
projet/
  server.js                       point d'entrée : composition (brokers, canaux, composants)
  config.js                       facultatif : process.env + valeurs par défaut.
                                  SEUL server.js a le droit de l'importer — jamais un composant
  package.json                    express, ws ; jsdom en dépendance de dev (tests client)

  fw/                             framework côté serveur — générique, jamais régénéré
    engine.js                     shim : export * from "../public/fw/engine.js"
    broker.js                     broker serveur (route les clés id et session)
    channel.catalog.js            contrats fournis par le framework (connection_event)
    channels/
      websocket_channel.js        un fichier par transport (ws, socket.io, …)

  catalogs/
    catalog.js                    catalogue du broker serveur = union des SHARED + contrats du framework + serveur-only

  components/
    <composants serveur>.js       un objet standard = un fichier

  tests/                          TOUS les tests, y compris ceux des composants client :
    test_<composant serveur>.js     Node importe n'importe où, même sous public/
    test_<composant client>.js      (vrai broker client + sonde-canal + DOM jsdom)

  public/                         racine web — miroir de la structure serveur
    index.html
    fw/                           framework côté client — générique, jamais régénéré
      engine.js                   l'implémentation du moteur (source unique)
      broker.js                   broker client (ignore les clés id et session)
      channels/
        websocket_channel.js
    catalogs/                     UN FICHIER PAR CANAL : SHARED + FINGERPRINT + CHANNEL
      game.catalog.js               canal vers ce serveur
      stats.catalog.js              ex. second canal, vers un autre serveur
      catalog.js                    catalogue du broker client = union + contrats client-only
    components/
      <composants client>.js
```

Trois idées directrices :

- **`fw/` isole ce qu'on ne touche jamais.** Moteur, brokers et canaux sont les pièces
  génériques du framework ; les regrouper, avec la même structure en miroir des deux
  côtés, fait qu'on sait toujours où chercher. Un nouveau transport = un fichier de
  plus dans `channels/`, rien d'autre ne bouge. Le miroir porte sur la *structure*,
  pas sur le contenu — `engine.js` (shim contre source), `broker.js` (le serveur route
  les clés `id` et `session` issues du canal par lequel le client converse, pour
  l'identifier parmi d'autres et porter son état de session)
  et les deux moitiés de chaque canal diffèrent légitimement.

- **Un catalogue par canal, et le `CHANNEL` vit dedans.** Chaque `x.catalog.js` exporte
  le trio indissociable de sa population de clients : `SHARED` (les contrats
  client↔serveur, définis une seule fois), `FINGERPRINT` (le **millésime manuel** du
  canal — une étiquette humaine, bumpée à la main à chaque jalon) et `CHANNEL`
  (l'autorisation de transit : les listes `clientEmits`/`clientReceives`). Au handshake,
  le client annonce son contrat de canal — ces listes **et les schémas complets** des
  messages qui transitent — que le serveur confronte aux siens pour décider la
  compatibilité (§4) : concordance des messages/versions, égalité des schémas des
  révisions partagées, règle directionnelle. Le millésime n'y est qu'un **signal
  consultatif** (divergent, un warning) ; le hash de contenu ne fait plus partie du
  protocole. Le fichier porte le nom du canal : retrouver l'un, c'est
  retrouver l'autre — et les deux moitiés du canal (`server.js` comme `index.html`)
  importent littéralement le même fichier. Pour un canal vers un autre serveur, ce
  fichier est aujourd'hui copié à l'identique dans l'autre dépôt — le contrat annoncé au
  handshake (schémas de transit compris) vérifie la compatibilité et signale toute
  dérive, et c'est précisément ce que le registre (§2.5 de la feuille de route)
  automatisera en générant ces fichiers par côté (le millésime manuel restant sous sa
  gestion), sans
  rien changer à l'arborescence.

- **Les unions sont explicites et gardées.** Chaque broker reçoit un seul catalogue :
  chaque côté a donc son fichier d'union, seul endroit où les catalogues de canaux se
  rencontrent. C'est aussi là que vivent les contrats qui ne traversent jamais un
  canal — client-only dans l'union client (une notification d'interface, par exemple),
  serveur-only dans l'union serveur : les deux catalogues peuvent légitimement
  différer. Attention, le spread ne fusionne pas : à noms de contrats égaux, le
  dernier étalé écrase silencieusement le premier. Tant que le registre ne garantit pas
  l'unicité par construction, un garde-fou au chargement s'impose — et il compare en
  forme **canonique** (`canonicalStringify`, le même tri de clés que l'empreinte de
  contenu), pas via `JSON.stringify` qui, sensible à l'ordre des clés, lèverait un faux
  positif sur deux définitions identiques rangées différemment :

  ```javascript
  // public/catalogs/catalog.js — catalogue du broker client
  import { SHARED as GAME } from "./game.catalog.js";
  import { SHARED as STATS } from "./stats.catalog.js";
  import { canonicalStringify } from "../fw/engine.js";

  for (const name of Object.keys(GAME)) {
  	if (name in STATS && canonicalStringify(GAME[name]) !== canonicalStringify(STATS[name])) {
  		throw new Error(`contrat "${name}" défini différemment dans deux catalogues de canal`);
  	}
  }

  export const CATALOG = { ...GAME, ...STATS /* + contrats client-only */ };
  export default CATALOG;
  ```

Les tests, eux, n'ont **pas** besoin d'être sous la racine web : ils tournent tous sous
Node, qui importe n'importe où — y compris les composants client sous `public/`. Un seul
dossier `tests/` regroupe donc les deux côtés, et `npm test` prouve toute l'application
d'une commande (voir « Tester » pour le motif client). Si les composants se multiplient
encore, `components/` se subdivise par domaine (`components/game/`, `components/stats/`)
sans rien casser : seuls les chemins d'import de `index.html` et `server.js` bougent,
jamais les objets eux-mêmes, qui ne connaissent que le broker.

## Exemple

Reprenons le chat, mais avec un serveur : un serveur Node connecte les utilisateurs, et
chacun voit dans sa liste les messages de **tous**. Les deux mêmes composants —
`ChatBox` et `MessageNotifier` — restent au navigateur ; c'est la **couture qui devient
réelle** : `ChatBox` parle désormais à un `TextBoard` côté serveur, à travers le canal.

Le message `text_entered` **ne change pas** dans son principe — il passe du catalogue
local au catalogue partagé — mais il gagne un champ `ref` : un identifiant **de message**
(pas de composant), un UUID que le client tire à chaque envoi. Il permettra à `ChatBox`
de reconnaître son propre message quand le serveur le lui renverra. Le serveur diffuse
`text_posted` à tous ; il n'y a **pas** d'accusé de réception séparé : le retour de
`text_posted` portant *mon* `ref` **est** la confirmation.

**Le catalogue du canal** (`public/catalogs/text.catalog.js`) — le trio de sa
population de clients : contrats partagés, millésime, autorisation de transit :

```javascript
// millésime manuel : étiquette consultative, bumpée à la main à chaque jalon
export const FINGERPRINT = "manual-text-2";

export const SHARED = {

	text_entered: {   // client → serveur
		versions: { 1: { revisions: { 0: { fields: {
			text: { type: "string", required: true },
			ref:  { type: "string", required: true }, // corrèle UN message (UUID), pas un composant
		} } } } },
		examples:        { 1: { 0: [ { text: "bonjour", ref: "b1f0c3a2-…" } ] } },
		counterexamples: { 1: { 0: [ { reason: "ref manquant", payload: { text: "bonjour" } } ] } },
	},

	text_posted: {    // serveur → tous ; le ref laisse l'émetteur reconnaître son écho
		versions: { 1: { revisions: { 0: { fields: {
			text: { type: "string", required: true },
			ref:  { type: "string", required: true },
		} } } } },
		examples: { 1: { 0: [ { text: "bonjour", ref: "b1f0c3a2-…" } ] } },
	},

};

// Autorisation de transit du canal de cette population de clients : définie AVEC les
// contrats partagés, c'est elle qui borne ce qui a le droit de traverser le réseau.
// Au handshake, le client annonce ces listes ET les schémas des messages qui
// transitent ; le serveur en décide la compatibilité. Le millésime manuel n'est qu'une
// étiquette consultative ; aucun hash n'entre plus dans le protocole.
const clientEmits    = [{ name: "text_entered", version: 1 }];
const clientReceives = [{ name: "text_posted", version: 1 }];

export const CHANNEL = {
	fingerprint: FINGERPRINT, // millésime manuel (étiquette consultative)
	clientEmits,
	clientReceives,
};
```

Chaque broker reçoit son fichier d'union (voir « Disposition des fichiers ») :
`public/catalogs/catalog.js` étale ce `SHARED` plus les contrats client-only,
`catalogs/catalog.js` l'étale plus les contrats fournis par le framework
(`...CHANNEL_CONTRACTS` de `fw/channel.catalog.js` — dont `connection_event`) et les
contrats serveur-only.

**Le cœur de l'exemple : deux composants, deux réactions indépendantes au même cycle de
vie.** À l'envoi, le client ne sait pas encore si le serveur acceptera le message. On
l'affiche donc en **optimiste**, et chacun décide seul de ce qu'il en fait :

- `ChatBox` peint aussitôt la ligne avec un picto **« en cours »** (un roller), la repère
  par son `ref`, et arme un minuteur de 5 s. Quand `text_posted` revient avec *ce* `ref`,
  il **réconcilie** la ligne (retire le roller) ; s'il ne revient pas dans les 5 s, il la
  passe en **rouge**.
- `MessageNotifier` incrémente « envoyés » de +1 dès l'émission — **on croit le client,
  sans attendre le serveur** — arme son **propre** minuteur de 5 s, et redescend de −1 si
  le retour n'arrive pas. Le compteur « dans le salon », lui, ne bouge que sur
  `text_posted` (les messages de tous, échos compris) : autoritaire, il ne roule jamais en
  arrière.

Les deux réagissent au même cycle **sans se connaître** : deux minuteurs, deux politiques,
zéro état partagé. Pendant l'attente, le chat dit « en cours » alors que le notifier a
déjà compté +1 — c'est précisément cette divergence transitoire qui prouve qu'ils sont
dissociés. Le broker les relie, il ne les couple pas. Noter au passage que
`MessageNotifier` s'abonne **localement** à `text_entered`, un message pourtant destiné au
serveur : émettre, c'est **publier au broker**, pas « envoyer au serveur » — le canal
n'est qu'un abonné parmi d'autres, et un frère local peut réagir à la même émission.

Deux points assumés, tous deux **illustratifs** (gérer proprement les acquittements n'est
pas l'objet de cet exemple) :

- `text_posted` a **deux rôles pour l'émetteur** (réconcilier sa ligne provisoire) et **un
  seul pour les autres** (afficher une ligne neuve) ; c'est le `ref` qui les distingue.
  Comme il est diffusé à *tous*, le `ref` doit être **globalement unique** —
  `crypto.randomUUID()` — sinon deux clients pourraient confondre leurs messages.
- On considère qu'**aucun retour n'arrive après 5 s** : un `text_posted` tardif n'est pas
  géré. Suffisant pour l'exemple, insuffisant en production.

**ChatBox** — même composant qu'en solo, mais l'affichage devient optimiste puis se
réconcilie sur le retour serveur :

```javascript
class ChatBox {

	instanceName;
	broker;
	deps;
	supports;
	support;
	verbose = false;

	emits    = [{ name: "text_entered", version: 1 }];
	receives = [{ name: "text_posted", version: 1 }];

	inputField;
	sendButton;
	list;
	pending = new Map(); // ref → { li, timer } : mes messages en attente du retour serveur

	constructor(broker, instanceName, deps = {}) {
		this.broker = broker;
		this.instanceName = instanceName;
		this.deps = deps;
		this.supports = deps.support?.nodeType ? { main: deps.support } : (deps.support ?? {});
		this.support  = this.supports.main;
		this.render();
		this.broker.register(this, this.emits, this.receives);
	}

	render() {
		this.list = document.createElement("ul");

		this.inputField = document.createElement("input");
		this.inputField.type = "text";
		this.inputField.placeholder = "Saisissez votre texte ici...";

		this.sendButton = document.createElement("button");
		this.sendButton.innerText = "Envoyer";
		this.sendButton.onclick = () => {
			const text = this.inputField.value.trim();
			if (text === "") return;

			const ref = crypto.randomUUID(); // unique globalement : mon message parmi tous

			// affichage optimiste : ligne « en cours », repérée par ref
			const li = document.createElement("li");
			li.textContent = text;
			li.className = "pending";
			this.list.appendChild(li);

			const timer = setTimeout(() => {
				li.className = "failed"; // aucun retour en 5 s → rouge
				this.pending.delete(ref);
			}, 5000);
			this.pending.set(ref, { li, timer });

			this.inputField.value = "";
			this.inputField.focus();
			this.broker.publish(this, { text_entered: { text, ref } });
		};

		this.support.appendChild(this.list);
		this.support.appendChild(this.inputField);
		this.support.appendChild(this.sendButton);
	}

	handleMessage(message) {
		if (!message.text_posted) return;
		const { text, ref } = message.text_posted;

		const mine = this.pending.get(ref);
		if (mine) {
			// mon propre écho : je réconcilie ma ligne provisoire (pas de doublon)
			clearTimeout(mine.timer);
			mine.li.className = ""; // confirmé : on retire le roller
			this.pending.delete(ref);
		} else {
			// message d'un autre client : nouvelle ligne
			const li = document.createElement("li");
			li.textContent = text;
			this.list.appendChild(li);
		}
	}

}
export default ChatBox;
```

**MessageNotifier** — il gagne le compteur « dans le salon » (tous les `text_posted`) et
son propre minuteur pour annuler un envoi non confirmé :

```javascript
class MessageNotifier {

	instanceName;
	broker;
	deps;
	supports;
	support;
	verbose = false;

	emits    = [];
	receives = [{ name: "text_entered", version: 1 }, { name: "text_posted", version: 1 }];

	sent    = 0; // vos messages envoyés (optimiste)
	room    = 0; // messages dans le salon (autoritaire)
	pending = new Map(); // ref → timer : vos envois en attente de confirmation

	constructor(broker, instanceName, deps = {}) {
		this.broker = broker;
		this.instanceName = instanceName;
		this.deps = deps;
		this.supports = deps.support?.nodeType ? { main: deps.support } : (deps.support ?? {});
		this.support  = this.supports.main;
		this.render();
		this.broker.register(this, this.emits, this.receives);
	}

	render() {
		this.support.className = "notifier";
		this.paint();
	}

	paint() {
		this.support.textContent = this.sent + " envoyés · " + this.room + " dans le salon";
	}

	handleMessage(message) {
		if (message.text_entered) {
			// on croit le client : +1 tout de suite, sans attendre le serveur
			const { ref } = message.text_entered;
			this.sent++;
			this.pending.set(ref, setTimeout(() => {
				this.sent--; // aucun retour en 5 s → on annule
				this.pending.delete(ref);
				this.paint();
			}, 5000));
			this.paint();
		}

		if (message.text_posted) {
			const { ref } = message.text_posted;
			const timer = this.pending.get(ref); // était-ce un de mes envois ?
			if (timer) {
				clearTimeout(timer); // confirmé : on garde le +1
				this.pending.delete(ref);
			}
			this.room++; // tout message posté compte dans le salon
			this.paint();
		}
	}

}
export default MessageNotifier;
```

**index.html** — on attend que le canal ait terminé son handshake avant de créer les
objets qui parlent au serveur :

```html
<!doctype html>
<html lang="fr">
  <head>
    <meta charset="utf-8">
    <style>
      .notifier { position: fixed; top: 8px; right: 8px; font: 13px sans-serif; }
      li.pending::after { content: " ⏳"; } /* en attente du retour serveur */
      li.failed { color: red; }             /* aucun retour en 5 s */
    </style>
  </head>

  <body>

    <div id="chat"></div>
    <div id="notifier"></div>

    <script type="module">
      import Broker from "./fw/broker.js";
      import WebSocketChannel from "./fw/channels/websocket_channel.js";
      import CATALOG from "./catalogs/catalog.js";
      import { CHANNEL } from "./catalogs/text.catalog.js";
      import ChatBox from "./components/chat_box.js";
      import MessageNotifier from "./components/message_notifier.js";

      const broker = new Broker(CATALOG);
      // la ressource ouverte, pas son URL : index.html est le point de composition,
      // c'est donc lui qui connaît l'adresse du serveur — jamais un composant.
      const channel = new WebSocketChannel(broker, "channel_text", {
        socket:  new WebSocket("ws://localhost:3000"),
        channel: CHANNEL,
      });
      try {
        await channel.ready(); // rejette : contrat incompatible, connexion fermée, timeout (5 s)
        const chat     = new ChatBox(broker, "chat", { support: document.getElementById("chat") });
        const notifier = new MessageNotifier(broker, "notifier", { support: document.getElementById("notifier") });
      } catch (error) {
        console.error("canal indisponible — " + error.message + " : composants non montés");
      }
    </script>
  </body>
</html>
```

**Côté serveur, `server.js`** — le canal reçoit son autorisation de transit (`CHANNEL`),
importée du catalogue de canal : les deux moitiés lisent littéralement le même fichier :

```javascript
import express from "express";
import path from "path";
import { createServer } from "http";
import { fileURLToPath } from "url";
import { WebSocketServer } from "ws";

import Broker from "./fw/broker.js";
import WebSocketChannel from "./fw/channels/websocket_channel.js";
import CATALOG from "./catalogs/catalog.js";
import { CHANNEL as TEXT_CHANNEL } from "./public/catalogs/text.catalog.js";
import TextBoard from "./components/text_board.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const server = createServer(app);
app.use(express.static(path.join(__dirname, "public")));

const broker = new Broker(CATALOG);
const board = new TextBoard(broker, "board");
const channel = new WebSocketChannel(broker, "channel_text", {
	wss: new WebSocketServer({ server }),
	channel: TEXT_CHANNEL,
});

server.listen(3000, () => console.log("http://localhost:3000"));
```

**Le composant serveur `TextBoard`** — ici une simple diffusion à tous ; il répercute le
`ref` pour que l'émetteur reconnaisse son propre message. Le ciblage par `id` (n'envoyer
qu'à certaines sessions) est présenté plus bas, en « Les exigences d'un canal » :

```javascript
class TextBoard {

	instanceName;
	broker;
	deps;
	verbose = false;

	emits    = [{ name: "text_posted", version: 1 }];
	receives = [{ name: "text_entered", version: 1 }];

	// Même signature que côté client ; un objet serveur reçoit simplement un deps
	// sans support. Un composant qui persisterait lirait ici this.deps.store —
	// le pool ouvert par server.js, jamais une URL, jamais un process.env.
	constructor(broker, instanceName, deps = {}) {
		this.broker = broker;
		this.instanceName = instanceName;
		this.deps = deps;
		this.broker.register(this, this.emits, this.receives);
	}

	handleMessage(message) {
		if (this.broker.verbose || this.verbose) console.log("INFO (" + this.instanceName + "): reçoit " + JSON.stringify(message));

		if (message.text_entered) {
			// diffusion à tous les clients (aucune clé id) ; on répercute le ref pour
			// que le client émetteur reconnaisse — et réconcilie — son propre message.
			this.broker.publish(this, {
				text_posted: { text: message.text_entered.text, ref: message.text_entered.ref },
			});
		}
	}

}
export default TextBoard;
```

## Les exigences d'un canal

Le canal est un **concept du framework à implémentation libre**. L'implémentation de
référence ci-dessous parle WebSocket entre un navigateur et Node, mais un canal
socket.io, un client Electron ou Unity face à un serveur Node, un back PHP — n'importe
quel transport, n'importe quel langage — est tout aussi légitime. Ce qui fait partie du
framework, ce sont **les règles ci-dessous**, pas leur implémentation.

Et toutes ne pèsent pas sur tous les canaux. Trois strates : **(A)** ce que **tout**
canal doit respecter, même local ou in-process ; **(B)** ce qu'impose un canal qui
**ponte deux catalogues déployés séparément** (réseau, dépôts ou langages distincts) ;
**(C)** ce que le canal de référence offre **en plus** — un modèle de session,
optionnel, sauf la grille d'admission `allowMessage` qui devient **requise dès qu'un
canal porte des sessions**. Ce découpage garde les canaux interchangeables sans
sur-contraindre un canal local, à qui B et C ne s'appliquent pas.

### A. Règles universelles — tout canal, y compris local ou in-process

1. **Un canal est un objet standard.** Chaque moitié s'inscrit auprès de son broker et
   converse par `publish` / `handleMessage` ; rien ne parle jamais directement au canal.
   Elle en a aussi la signature — `(broker, instanceName, deps)` — et **reçoit son
   transport déjà ouvert par `deps`** : un canal ne connaît pas d'adresse, ne lit pas de
   configuration, n'ouvre pas de connexion de son propre chef (§ « L'objet standard »).
   Il n'a en revanche **pas de `destroy()`** : sa durée de vie est celle du point de
   composition qui l'a monté — `index.html` ou `server.js` — et il ne s'éteint qu'avec
   lui. C'est ce qui rend la règle 4 sans ambiguïté de son côté : il ne referme jamais
   un transport au titre du cycle de vie, puisqu'il n'a pas de cycle de vie propre ;
   la seule fermeture qu'il provoque est celle d'une violation de protocole.
2. **L'autorisation de transit borne le trafic, et la validation a lieu aux deux bouts.**
   Chaque moitié s'inscrit comme le miroir croisé de `CHANNEL` (elle émet localement ce
   que l'autre reçoit, s'abonne à ce que l'autre émet). Tout passant par un broker
   strict, un message hors autorisation ne transite pas, et chaque payload est validé
   contre son contrat à l'émission **comme** à la réception — défense en profondeur :
   une moitié modifiée n'injecte rien hors contrat. (Un canal peut aussi publier ses
   propres contrats de cycle de vie — voir C.)
3. **Le broker local ne voit que la forme canonique ; jamais de downcast.** Ce qu'un
   canal publie dans son broker est à la révision `latest` de ce broker ; il n'existe
   pas de downcast (§2.2.b). Combinée aux champs stricts, cette règle est ce qui force
   l'alignement de B et l'upcast vers l'avant.
4. **La plomberie du canal reste hors catalogue ; les clés d'enveloppe `id`/`session`
   sont du framework.** Les enveloppes propres au protocole d'un canal — sa poignée de
   main, son cadrage — ne sont ni des contrats ni publiées telles quelles dans un
   broker. Mais les clés d'enveloppe **`id` et `session` appartiennent au framework**,
   pas au canal : c'est le **broker** qui les définit et les route (§6.2). Le broker
   serveur les écarte des noms de message et joint à chaque livraison `id` **par
   valeur** et `session` **par référence** — jamais clonée, contrairement au payload qui
   reste une copie privée par abonné ; c'est cette propagation par référence qui fait
   vivre l'état de session partagé et le pattern d'authentification. Le broker client,
   qui n'a pas de notion de session, les ignore défensivement. Un canal ne fait que
   **peupler** ces clés (voir C) ; leur existence et leur sémantique sont du noyau, pas
   une invention de canal.

### B. Canaux qui pontent deux catalogues indépendants — réseau, multi-dépôt, multi-langage

Dès que les deux moitiés peuvent tenir des catalogues **différents** (déploiements ou
dépôts séparés, langages distincts), le canal doit les aligner au raccordement. Un canal
local dont les deux côtés chargent le **même** catalogue en mémoire n'a rien à faire ici.

5. **Rien ne transite avant que la compatibilité soit établie.** Le raccordement est
   bloquant : aucune trame applicative ne passe avant un accord. (Dans le canal
   WebSocket, c'est le handshake ; une trame prématurée ou un pair muet trop longtemps
   est une violation, connexion fermée.)
6. **La décision d'alignement.** Les deux côtés doivent s'accorder sur : le même
   ensemble de messages de transit, la même version par message, l'**égalité des schémas
   des révisions partagées** (`0..min(latest)`, comparés en forme **canonique**), et la
   **règle directionnelle** — pas de downcast, donc le récepteur d'un message doit être à
   une révision **≥** celle de l'émetteur (client→serveur : `rev_serveur ≥ rev_client` ;
   serveur→client : `rev_client ≥ rev_serveur`). Toute violation refuse le raccordement,
   avec la raison. **C'est la décision qui est normative, pas la mécanique** : échanger
   les schémas complets (ce que fait le canal de référence), des empreintes par révision,
   ou s'appuyer sur un fichier partagé — libre, tant que le verdict accepter/refuser est
   le même. Le catalogue reçu du pair ne sert **qu'**à cette décision : on valide et on
   upcaste toujours contre **son propre** catalogue, jamais celui du pair (qui peut
   mentir). Un millésime ou une empreinte peuvent servir de **signal** consultatif ou de
   clé de cache, jamais de verrou.
7. **Upcast symétrique vers le `latest` local.** Le récepteur étant toujours ≥ l'émetteur
   (règle 6), chaque côté upcaste la trame reçue depuis la révision du pair — fixée au
   raccordement — jusqu'à son propre `latest`, avant de la publier (règle 3). Toujours un
   fill-forward, jamais un downcast ; aucune révision ne voyage sur la trame. Un côté à
   un seul pair mémorise une base ; un côté à plusieurs pairs, une base par pair.

### C. Modèle de session du canal de référence — optionnel, sauf sa grille d'admission

Le canal WebSocket offre en plus un modèle de session dont le jeu se sert. Un autre canal
(local Electron, diffusion sans état, requête/réponse…) peut n'en offrir aucun. En
revanche, **dès qu'un canal *porte* des sessions**, la grille d'admission `allowMessage`
(règle 11) n'est plus optionnelle : c'est le point où l'application branche son
autorisation, et le framework en fixe le contrat.

8. **Sessions à token.** Le raccordement ouvre — ou reprend, via un `session_token` non
   devinable représenté à la connexion suivante — une session qui survit aux coupures
   pendant `sessionTtlMs`, à la manière d'une session PHP. Le token est une capacité :
   qui le détient reprend la session (même modèle de menace que le cookie de session PHP
   — le transport doit être protégé en conséquence, wss/https en production).
9. **Peupler `id`/`session` et cibler les sockets.** Les clés d'enveloppe elles-mêmes
   sont du framework (règle 4) ; ce qui revient au canal, c'est de les **renseigner** et
   de les **traduire** en transport. Sur l'entrant, le canal serveur pose `id`
   (l'identifiant de session, stable à travers les reconnexions) et `session` (l'objet
   `{ data }` tiré de son magasin de sessions, règle 8). Sur le sortant, il traduit un
   `id` d'émission (une session ou un array) en sockets concrets ; sans `id`, il diffuse
   à tous. Ni l'une ni l'autre clé ne part jamais sur le réseau. Le *quoi* (les clés,
   leur propagation par référence) est du noyau ; le *comment on les remplit et à qui on
   livre physiquement* est le travail de ce canal.
10. **Transitions de session publiées.** Le canal serveur émet `connection_event` —
    contrat serveur-only fourni par le framework (`fw/channel.catalog.js`), étalé dans
    l'union serveur — à quatre moments : `connected`, `resumed`, `disconnected`
    (la session survit), `expired` (TTL écoulé — dernière livraison où la session est
    encore lisible, détruite juste après). L'enveloppe porte `id` et `session` ; le
    payload porte `event` et `channel` (nom d'instance du canal, un serveur pouvant en
    porter plusieurs).

11. **Grille d'admission des entrants : `allowMessage` — requise dès qu'un canal porte
    des sessions.** Avant de publier une trame **entrante** (client→serveur, sous sa
    forme canonique), le canal appelle `session.allowMessage?.(name, payload)` ; si le
    retour est `false`, il **jette la trame et émet un warning serveur** (nom d'instance
    du canal, `id` de session, nom du message). Un client bien construit n'émet jamais un
    message non autorisé : un refus est un bug ou une malveillance, jamais un cas
    nominal. `allowMessage` absent ⇒ **autorisé** — il faut bien que le message de login
    passe ; c'est le seul défaut permissif du canal, à l'opposé d'une autorisation
    d'action qui, elle, se refuse par défaut.
    - **Le hook est du framework, la politique est libre.** Le canal ne fait qu'appeler
      un hook générique ; qui le pose et ce qu'il décide appartient à l'application.
      L'appel préserve `this === session`, et l'identité vit dans `session.data` (posée
      au login) : `allowMessage` connaît donc l'utilisateur **sans qu'on le lui passe** —
      inutile de faire transiter un id, la session *est* le contexte.
    - **Le composant d'auth pose le hook au `connected` (règle 10), de façon synchrone.**
      Il s'abonne à `connection_event`, **filtre sur `payload.channel`** — d'où un
      composant d'auth *par canal* : chaque canal invoque le hook posé sur *ses* sessions,
      l'aiguillage est structurel — et installe la politique dès `connected`, de manière
      **synchrone** (au minimum un *deny-sauf-login*), quitte à la remplacer une fois les
      droits chargés en asynchrone. Jamais de fenêtre où `allowMessage` est `undefined`
      sous une trame pipelinée. Il **re-valide au `resumed`** (l'utilisateur a pu être
      banni entre-temps).
    - Cette grille ne décide que de l'**admission** du message dans le broker.
      L'autorisation **fine** d'une action — « ce user peut-il faire *ça* avec *ces*
      paramètres » — reste au composant qui l'exécute, par une convention **libre**
      (p. ex. `session.allowAction(...)`), hors framework.

**Le pattern d'authentification** découle des règles 8, 9 et 11 : un composant serveur
abonné au message de login écrit l'identité dans `session.data` —

```javascript
handleMessage(message) {
	if (message.login) {
		const user = this.#authenticate(message.login); // vérification applicative
		if (user !== null) message.session.data.user = user; // visible de tous, dès maintenant
		this.broker.publish(this, { login_result: { granted: user !== null }, id: message.id });
	}
}
```

`session` étant propagée par référence (jamais clonée, contrairement au payload qui
reste une copie privée par abonné), tout abonné servi ensuite pour cette session lit
`message.session.data.user` — sans annuaire global ni couplage entre composants. Et
comme la session expirée passe une dernière fois dans `connection_event` (`expired`),
c'est là qu'un composant libère ce qu'il tenait pour elle.

Le **même composant** pose la grille d'admission (règle 11) : sur `connected`, il installe
`session.allowMessage` — une closure qui lit `session.data.user` et rend `true`/`false`
selon la politique de l'application — puis la resserre ou l'élargit au fil des logins.
C'est aussi lui, s'il le souhaite, qui suspend un `session.allowAction(...)` à la session
pour l'autorisation fine ; mais `allowAction` est une convention applicative, pas une
règle du framework — seul `allowMessage`, appelé par le canal, l'est.

### Porter un canal vers un autre langage ou transport

Le canal WebSocket est **une** implémentation, pas la norme. socket.io troquerait la
poignée de main et les trames contre des événements et des *rooms* ; un back PHP tiendrait
la moitié serveur ; un canal Electron ferait passer les messages entre le processus
principal et le *renderer* sans réseau du tout. Tous restent des canaux fwtozza tant
qu'ils respectent A (et B si les deux moitiés peuvent diverger).

Le piège du multi-langage : dès qu'une moitié n'est pas en JS, certaines choses qui
étaient « du code » deviennent **normatives**, parce que les deux bouts doivent aboutir
au même résultat. Doivent **coïncider** entre implémentations, quel que soit le langage :

- **La décision d'alignement** (règle 6) — messages/versions, égalité des schémas des
  révisions partagées, règle directionnelle.
- **La forme canonique** utilisée pour comparer deux schémas (l'algorithme de
  `canonicalStringify` : tri des clés, encodage des nombres et des chaînes). Si le PHP
  canonicalise autrement, il rendra un verdict accepter/refuser différent du client JS
  pour des schémas pourtant identiques. C'est le prix réel de l'interop : la forme
  canonique fait partie du contrat, pas la fonction JS qui l'incarne. Cet encodage doit
  être **fixé explicitement par le projet** : le déclarer normatif sans l'écrire ne suffit
  pas.
- **Les règles de validation** — le vocabulaire (`fields` / `required` / `pattern` /
  `enum` / `default` / `items` / `oneOf`) et sa sémantique, pour qu'une trame acceptée
  d'un côté le soit de l'autre.
- **La sémantique d'upcast** (règle 7) — complétion des champs par leur `default` vers la
  révision courante, et conversion entre versions cassantes : les deux côtés doivent
  produire le **même** payload comblé.
- **Les clés d'enveloppe `id`/`session`** (règle 4) — si l'autre langage héberge aussi un
  broker (un back PHP, par exemple), ce broker doit les router de la même façon : `id`
  par valeur, `session` **par référence**, écartées des noms de message. Ce sont des
  clés du framework, pas du canal.
- **La grille d'admission `allowMessage`** (règle 11) — dès que le canal porte des
  sessions, il doit appeler `session.allowMessage?.(name, payload)` sur chaque entrant et
  jeter + logguer sur `false`. C'est le **hook** qui est du contrat ; la politique
  derrière reste libre à l'application.

Tout le reste — transport, cadrage des trames, **magasin** de sessions (token, TTL,
reprise — C), la **politique** d'autorisation elle-même, concurrence — est libre. Le
magasin et la politique sont libres ; la sémantique des clés d'enveloppe et l'appel du
hook, non. C'est cette liste, et elle seule, que le portage doit reproduire.

## L'implémentation de référence : le canal WebSocket

**Côté client** (`public/fw/channels/websocket_channel.js`) — il reçoit par `deps` le
socket **déjà construit** et le trio `CHANNEL` de son fichier de canal, et s'inscrit au
broker **après** le handshake, depuis ses listes locales croisées : il émet localement ce
que les clients reçoivent, s'abonne
localement à ce qu'ils émettent. À la connexion il envoie son contrat de canal (les
listes de transit et les **schémas complets** des messages qui transitent, lus dans
`broker.catalog`) et reçoit du serveur les **bases d'upcast** du serveur→client. Il
s'inscrit en émission à son propre `latest` et, à la réception, upcaste chaque trame de
la base reçue jusqu'à ce `latest` avant de publier — l'upcast du broker est alors un
no-op. Le token de session vit dans `sessionStorage` : un rechargement de page dans le
TTL revient comme la même session.

```javascript
import { upcastToLatestRevision } from "../engine.js";

class WebSocketChannel {

	instanceName;
	broker;
	deps;
	socket;  // emprunté au point de composition : ni ouvert ni possédé par le canal
	channel;
	catalogFingerprint; // millésime manuel (étiquette consultative)

	// crossed local lists from the transit authorization: [{ name, version }]
	emits = [];
	receives = [];
	verbose = false;

	// bases d'upcast serveur→client reçues au handshake : name → { version, revision }
	#baselines = new Map();

	#storageKey;
	#resolveReady;
	#rejectReady;
	#isReady = false;
	#ready = new Promise((resolve, reject) => { this.#resolveReady = resolve; this.#rejectReady = reject; });

	constructor(broker, instanceName, deps = {}) {
		const { socket, channel } = deps;
		this.broker = broker;
		this.instanceName = instanceName;
		this.deps = deps;
		this.channel = channel;
		this.catalogFingerprint = channel.fingerprint;
		this.emits = channel.clientReceives;
		this.receives = channel.clientEmits;
		this.#storageKey = "fwtozza:session:" + instanceName;

		// une app qui n'attend pas ready() ne doit pas générer d'unhandled rejection
		this.#ready.catch(() => {});

		// la ressource ouverte, pas son URL : le socket est construit par le point de
		// composition (index.html) et seulement emprunté ici. Un test lui substitue un
		// double sans réseau ; le canal ne le ferme jamais au titre du cycle de vie —
		// uniquement sur violation de protocole (handshake refusé, timeout).
		this.socket = socket;

		this.socket.onmessage = (event) => {
			let message;
			try {
				message = JSON.parse(event.data);
			} catch (e) {
				console.error("ERROR (" + this.instanceName + "): trame illisible", e);
				return;
			}
			if (message.handshake_response) {
				const handshake = message.handshake_response;
				// le gate est la compatibilité, décidée par le serveur : seule une
				// `error` (contrat incompatible) termine le canal ; millésime/hash
				// divergents ne sont que des warnings, portés par la réponse.
				if (handshake.error !== undefined) {
					console.error("ERROR (" + this.instanceName + "): handshake refusé — " + handshake.error + " — canal terminé");
					this.socket.close();
					this.#rejectReady(new Error(handshake.error));
					return;
				}
				for (const w of handshake.warnings ?? []) console.warn("WARN (" + this.instanceName + "): " + w);
				this.#baselines.clear();
				for (const b of handshake.baselines ?? []) this.#baselines.set(b.name, { version: b.version, revision: b.revision });
				this.#storeToken(handshake.session_token);
				if (this.#isReady) return; // déjà inscrit
				if (this.broker.verbose || this.verbose) console.log("INFO (" + this.instanceName + "): register après handshake");
				if (!this.broker.register(this, this.emits, this.receives)) {
					// rejet atomique par le broker (catalogue client incomplet ?) :
					// le canal se termine avec la raison, comme un handshake refusé
					this.socket.close();
					this.#rejectReady(new Error("inscription rejetée par le broker"));
					return;
				}
				this.#isReady = true;
				this.#resolveReady();
				return;
			}
			if (!this.#isReady) return; // rien ne transite avant le handshake
			this.#upcastInbound(message); // de la base reçue (révision serveur) jusqu'au latest client
			this.broker.publish(this, message);
		};

		this.socket.onerror = (error) => console.error("ERROR (" + this.instanceName + "): " + error);
		this.socket.onclose = () => {
			console.warn("WARN (" + this.instanceName + "): connexion fermée");
			// serveur injoignable ou refus : l'attente de ready() échoue au lieu de pendre
			if (!this.#isReady) this.#rejectReady(new Error("connexion fermée avant le handshake"));
		};

		// Un socket confié peut être déjà ouvert — il n'émettra alors jamais `open`,
		// et le handshake ne partirait pas. On teste l'état plutôt que de supposer.
		if (this.socket.readyState === WebSocket.OPEN) this.#sendHandshakeRequest();
		else this.socket.onopen = () => this.#sendHandshakeRequest();
	}

	#sendHandshakeRequest() {
		const request = {
			fingerprint: this.catalogFingerprint,
			contract: this.#contractDescriptor(), // listes de transit + schémas complets de chaque message
		};
		const token = this.#storedToken();
		if (token !== null) request.session_token = token; // reprise de session (à la PHP)
		this.socket.send(JSON.stringify({ handshake_request: request }));
	}

	// Le contrat de canal envoyé au serveur : les deux listes de transit et, pour
	// chaque message qui transite, ses schémas complets (l'objet `versions`, lu dans
	// le catalogue du broker — les exemples n'en font pas partie). Le serveur y
	// vérifie l'égalité des schémas des révisions partagées, la concordance des
	// versions et la règle directionnelle, et en déduit les bases d'upcast des deux
	// côtés. clientEmits = ce que le client émet (= this.receives), clientReceives =
	// ce qu'il reçoit (= this.emits).
	#contractDescriptor() {
		const list = (l) => l.map(({ name, version }) => ({ name, version }));
		const schemas = {};
		for (const { name } of [...this.receives, ...this.emits]) schemas[name] = { versions: this.broker.catalog[name].versions };
		return { clientEmits: list(this.receives), clientReceives: list(this.emits), schemas };
	}

	// Upcaste chaque payload de contrat d'une trame entrante depuis la révision
	// émise par le serveur (baseline du handshake) jusqu'au latest client. Une
	// trame que le broker rejetterait de toute façon est laissée telle quelle.
	#upcastInbound(message) {
		for (const name of Object.keys(message)) {
			const baseline = this.#baselines.get(name);
			if (baseline === undefined) continue;
			try {
				message[name] = upcastToLatestRevision(this.broker.catalog[name].versions[baseline.version], baseline.revision, message[name]);
			} catch (exception) {
				console.error("ERROR (" + this.instanceName + "): upcast entrant de " + name + " échoué — laissé tel quel: " + (exception?.message ?? exception));
			}
		}
	}

	// sessionStorage : le token survit au rechargement de page, pas à l'onglet —
	// même durée de vie qu'une session PHP à cookie de session. Hôtes sans
	// sessionStorage (tests Node) : le token vit le temps du socket.
	#storedToken() {
		try { return sessionStorage.getItem(this.#storageKey); } catch { return null; }
	}

	#storeToken(token) {
		try { sessionStorage.setItem(this.#storageKey, token); } catch { /* pas de stockage : session non reprise */ }
	}

	// Résolue après le handshake. Rejetée avec la raison si le serveur refuse
	// (contrat incompatible), si la connexion se ferme avant le handshake, ou
	// après timeoutMs sans réponse — le timeout ferme alors le socket : pas de
	// canal zombie qui s'inscrirait après coup dans une app non montée. C'est la
	// seule exception à « on ne ferme pas ce qu'on n'a pas ouvert » : un socket
	// qui ne peut pas s'accorder n'est plus utilisable par personne.
	ready(timeoutMs = 5000) {
		return Promise.race([
			this.#ready,
			new Promise((resolve, reject) => setTimeout(() => {
				if (!this.#isReady) this.socket.close();
				reject(new Error("timeout après " + timeoutMs + " ms"));
			}, timeoutMs)),
		]);
	}

	handleMessage(message) {
		if (this.socket.readyState === WebSocket.OPEN) {
			this.socket.send(JSON.stringify(message));
		} else {
			console.warn("WARN (" + this.instanceName + "): message avant connexion, jeté — " + JSON.stringify(message));
		}
	}

}

export default WebSocketChannel;
```

**Côté serveur** (`fw/channels/websocket_channel.js`) — même signature que côté client, au
`deps` près : il reçoit le `WebSocketServer` **déjà construit** par `server.js`. Ses
listes sont **l'autorisation
de transit du canal**, reçue à la construction (`CHANNEL`, définie avec le catalogue
partagé de sa population de clients), plus `connection_event` ; il tient le magasin de
sessions et fait respecter le handshake bloquant. L'autorisation est volontairement une
donnée du catalogue, jamais une déduction depuis les objets inscrits au broker : un
serveur peut porter plusieurs canaux vers des populations de clients différentes, et un
canal ne doit pas pouvoir laisser transiter un message serveur-only ni un message qui
concerne un autre canal. Le broker strict fait respecter la frontière dans les deux
sens : un message entrant hors autorisation est bloqué (le canal n'en est pas émetteur
inscrit), un message local hors autorisation ne part jamais (le canal n'y est pas
abonné).

```javascript
import { randomUUID } from "crypto";
import { WebSocket } from "ws";
import { latestRevision, upcastToLatestRevision, canonicalStringify } from "../engine.js";

class WebSocketChannel {

	instanceName;
	broker;
	deps;
	wss; // borrowed from the composition point (server.js), not owned by the channel
	catalogFingerprint; // manual millésime (human label, advisory only)
	sessionTtlMs;
	handshakeTimeoutMs;
	maxFrameBytes;

	// mirror of the transit authorization + the framework's connection_event
	emits = [];
	receives = [];
	verbose = false;

	// the transit authorization, per direction, for handshake alignment + upcast
	#clientEmits = [];    // client → server (the server receives → it upcasts inbound)
	#clientReceives = []; // server → client (the client receives → it upcasts)
	#emitVersion = new Map(); // name → version, for the inbound (client→server) messages
	#analysisCache = new Map(); // canonical(contract) → contract analysis (dedup across identical clients)

	// PHP-like session store: session id (= token) →
	// { session: { data }, socket|null, expiryTimer, emitFrom: { name → client revision } }
	#sessions = new Map();

	// Same signature as every standard object — and as the client half. The transport
	// (`wss`) arrives already built, from server.js: the channel never opens it, never
	// reads a port, never reads process.env.
	constructor(broker, instanceName, deps = {}) {
		const { wss, channel, sessionTtlMs = 300000, handshakeTimeoutMs = 10000, maxFrameBytes = 262144 } = deps;
		this.broker = broker;
		this.instanceName = instanceName;
		this.deps = deps;
		this.wss = wss;
		this.catalogFingerprint = channel.fingerprint;
		this.sessionTtlMs = sessionTtlMs;
		this.handshakeTimeoutMs = handshakeTimeoutMs;
		this.maxFrameBytes = maxFrameBytes;

		this.#clientEmits = channel.clientEmits;
		this.#clientReceives = channel.clientReceives;
		for (const { name, version } of channel.clientEmits) this.#emitVersion.set(name, version);

		this.emits = [...channel.clientEmits, { name: "connection_event", version: 1 }];
		this.receives = channel.clientReceives;
		if (!this.broker.register(this, this.emits, this.receives)) {
			// atomic rejection by the broker (incomplete server catalog?): the
			// channel is inert but the application keeps running
			console.error("ERROR (" + this.instanceName + "): inscription rejetée par le broker — canal inactif");
		}

		this.wss.on("connection", (socket) => {

			socket.sessionId = null; // null = handshake not done: nothing transits
			if (this.broker.verbose || this.verbose) console.log("INFO (" + this.instanceName + "): nouvelle connexion");

			// a socket that never handshakes is a protocol violation: closed
			const guard = setTimeout(() => {
				if (socket.sessionId === null) {
					console.error("ERROR (" + this.instanceName + "): pas de handshake après " + this.handshakeTimeoutMs + " ms — socket fermé");
					socket.close();
				}
			}, this.handshakeTimeoutMs);

			socket.on("message", (buffer) => {
				// bound the first hostile input: an oversized frame is parsed before
				// any validation, so it is rejected on length alone
				if (buffer.length > this.maxFrameBytes) {
					console.error("ERROR (" + this.instanceName + "): frame de " + buffer.length + " octets > " + this.maxFrameBytes + " — socket fermé");
					socket.close();
					return;
				}

				let message;
				try {
					message = JSON.parse(buffer.toString());
				} catch {
					console.error("ERROR (" + this.instanceName + "): frame non-JSON, jetée — socket fermé");
					socket.close();
					return;
				}

				if (message.handshake_request) {
					this.#handshake(socket, message.handshake_request);
					return;
				}

				// the handshake gates the socket: no frame transits before it succeeded
				if (socket.sessionId === null) {
					console.error("ERROR (" + this.instanceName + "): frame avant handshake, jetée — socket fermé");
					socket.close();
					return;
				}

				// stamp the session: id targets the answers, session carries the
				// shared per-session state (joined by reference by the broker)
				const entry = this.#sessions.get(socket.sessionId);
				message.id = socket.sessionId;
				message.session = entry.session;
				// this session may run an older catalog: upcast every inbound contract
				// payload FROM the revision it emits (learned at the handshake) UP TO
				// the server's latest, so the broker only ever sees canonical frames
				this.#upcastInbound(message, entry.emitFrom);
				// admission gate (rule 11): an app auth component may hang
				// session.allowMessage on the session; a denied message is dropped here
				if (this.#admitInbound(message, entry.session)) this.broker.publish(this, message);
			});

			socket.on("close", () => {
				clearTimeout(guard);
				this.#disconnected(socket);
			});
			socket.on("error", (error) => console.error("ERROR (" + this.instanceName + "): socket — " + error));

		});
	}

	// Gate of the channel: COMPATIBILITY, not identity. The client sends its
	// channel contract; the server refuses (explicit error then close) only on a
	// real incompatibility — a missing/extra transiting message, a version
	// divergence, or a revision breaking the direction rule. A different
	// millésime or hash is a warning carried back, not a refusal. Success: the
	// session is created (or resumed), its inbound upcast baselines (emitFrom) are
	// stored, and the response returns the client's server→client baselines. The
	// token IS the session id: unguessable (randomUUID), a capability like a PHP
	// session id.
	#handshake(socket, request) {
		const verdict = this.#verifyContract(request);
		if (!verdict.ok) {
			console.error("ERROR (" + this.instanceName + "): contrat incompatible — " + verdict.error + " — socket fermé");
			socket.send(JSON.stringify({ handshake_response: {
				fingerprint: this.catalogFingerprint,
				error: verdict.error,
				warnings: verdict.warnings,
			} }));
			socket.close();
			return;
		}

		const resumed = request.session_token !== undefined && this.#sessions.has(request.session_token);
		let sessionId;
		let entry;
		if (resumed) {
			sessionId = request.session_token;
			entry = this.#sessions.get(sessionId);
			clearTimeout(entry.expiryTimer);
			entry.expiryTimer = null;
			entry.emitFrom = verdict.emitFrom; // a reconnecting client may run a newer catalog
			// same token presented twice: the newcomer takes over, the old socket
			// is closed — after the swap, so its close event reads as stale
			const previous = entry.socket;
			entry.socket = socket;
			if (previous !== null && previous !== socket && previous.readyState === WebSocket.OPEN) previous.close();
		} else {
			sessionId = randomUUID();
			entry = { session: { data: {} }, socket, expiryTimer: null, emitFrom: verdict.emitFrom };
			this.#sessions.set(sessionId, entry);
		}
		socket.sessionId = sessionId;

		socket.send(JSON.stringify({ handshake_response: {
			fingerprint: this.catalogFingerprint,
			session_token: sessionId,
			baselines: verdict.baselines,
			warnings: verdict.warnings,
		} }));
		if (this.broker.verbose || this.verbose) console.log("INFO (" + this.instanceName + "): session " + sessionId + (resumed ? " reprise" : " créée"));
		this.#publishEvent(resumed ? "resumed" : "connected", sessionId, entry.session);
	}

	// Aligns the client's channel contract with the server's. The contract-only
	// analysis is memoized (identical clients share it); the millésime warning,
	// which depends on request.fingerprint (outside the contract), is added here.
	#verifyContract(request) {
		const contract = request.contract;
		if (contract === undefined) return { ok: false, error: "handshake sans contrat de canal", warnings: [] };

		const cacheKey = canonicalStringify(contract);
		let analysis = this.#analysisCache.get(cacheKey);
		if (analysis === undefined) { analysis = this.#analyzeContract(contract); this.#analysisCache.set(cacheKey, analysis); }

		const warnings = [...analysis.warnings];
		// the millésime is a human label: divergent, it is a warning, never a refusal
		if (request.fingerprint !== this.catalogFingerprint)
			warnings.push("millésime différent (client " + request.fingerprint + ", serveur " + this.catalogFingerprint + ")");
		return { ...analysis, warnings };
	}

	// Pure function of the client contract (schemas + transit lists). Refuses on:
	// a missing/extra transiting message, a version divergence, a SHARED revision
	// whose schema differs (append-only broken — compared canonically), or a
	// revision breaking the direction rule (no downcast: the receiver's revision
	// must be >= the emitter's). emitFrom (client→server) is the baseline this
	// server upcasts inbound FROM; baselines (server→client) are the server's
	// latest revisions = the baseline the client upcasts FROM.
	#analyzeContract(contract) {
		const warnings = [];
		const problems = [];
		const key = (d) => d.name + "@v" + d.version;
		const clientEmits = new Map((contract.clientEmits ?? []).map((d) => [key(d), d]));
		const clientReceives = new Map((contract.clientReceives ?? []).map((d) => [key(d), d]));
		const clientVersions = (name, version) => contract.schemas?.[name]?.versions?.[version];
		const serverVersion = (name, version) => this.broker.catalog[name].versions[version];

		const compareSet = (serverList, clientMap, dir) => {
			const serverKeys = new Set(serverList.map(key));
			for (const d of serverList) if (!clientMap.has(key(d))) problems.push(dir + " : " + d.name + " v" + d.version + " manquant côté client");
			for (const [, d] of clientMap) if (!serverKeys.has(key(d))) problems.push(dir + " : " + d.name + " v" + d.version + " en trop côté client");
		};
		compareSet(this.#clientEmits, clientEmits, "client→serveur");
		compareSet(this.#clientReceives, clientReceives, "serveur→client");

		// Schemas of every SHARED revision (0..min(latest)) must be identical, and the
		// direction rule must hold on the latest. Returns the two latest revisions.
		const align = (d, dir, receiverIsServer) => {
			const cVer = clientVersions(d.name, d.version);
			if (cVer?.revisions === undefined) { problems.push(dir + " : " + d.name + " v" + d.version + " sans schéma dans le contrat client"); return null; }
			const sVer = serverVersion(d.name, d.version);
			const cLatest = latestRevision(cVer);
			const sLatest = latestRevision(sVer);
			for (let r = 0; r <= Math.min(cLatest, sLatest); r++) {
				if (canonicalStringify(cVer.revisions[r]) !== canonicalStringify(sVer.revisions[r]))
					problems.push(dir + " : " + d.name + " v" + d.version + " r" + r + " — schéma divergent (discipline append-only rompue ?)");
			}
			const receiver = receiverIsServer ? sLatest : cLatest;
			const emitter = receiverIsServer ? cLatest : sLatest;
			if (receiver < emitter) { problems.push(dir + " " + d.name + " v" + d.version + " : révision récepteur (r" + receiver + ") < émetteur (r" + emitter + ")"); return null; }
			if (cLatest !== sLatest) warnings.push(dir + " " + d.name + " : client r" + cLatest + ", serveur r" + sLatest + " (upcast récepteur)");
			return { cLatest, sLatest };
		};

		const emitFrom = {};
		const baselines = [];
		for (const d of this.#clientEmits) {          // client→server: server is receiver
			if (!clientEmits.has(key(d))) continue;
			const a = align(d, "client→serveur", true);
			if (a !== null) emitFrom[d.name] = a.cLatest;
		}
		for (const d of this.#clientReceives) {       // server→client: client is receiver
			if (!clientReceives.has(key(d))) continue;
			const a = align(d, "serveur→client", false);
			if (a !== null) baselines.push({ name: d.name, version: d.version, revision: a.sLatest });
		}

		if (problems.length > 0) return { ok: false, error: problems.join(" ; "), warnings };
		return { ok: true, warnings, baselines, emitFrom };
	}

	// Upcasts each contract payload of an inbound frame from the revision the
	// session emits (emitFrom) up to the server's latest. The reserved envelope
	// keys and non-contract keys are left untouched.
	#upcastInbound(message, emitFrom) {
		for (const name of Object.keys(message)) {
			if (name === "id" || name === "session") continue;
			const version = this.#emitVersion.get(name);
			const baseline = emitFrom?.[name];
			if (version === undefined || baseline === undefined) continue;
			try {
				message[name] = upcastToLatestRevision(this.broker.catalog[name].versions[version], baseline, message[name]);
			} catch (exception) {
				console.error("ERROR (" + this.instanceName + "): upcast entrant de " + name + " échoué — laissé tel quel: " + (exception?.message ?? exception));
			}
		}
	}

	// Admission gate (rule 11): an app auth component may hang session.allowMessage
	// on the session (set at connection_event, reading session.data). Called on the
	// canonical inbound form with `this` = session, so the hook knows the user
	// without an extra argument. A denied message is dropped and warned — a
	// well-built client never sends one, so a refusal is a bug or an attack.
	// No hook = allowed (bootstrap, e.g. login). Returns whether anything remains.
	#admitInbound(message, session) {
		if (typeof session?.allowMessage !== "function") return true;
		let admitted = false;
		for (const name of Object.keys(message)) {
			if (name === "id" || name === "session") continue;
			if (session.allowMessage(name, message[name])) { admitted = true; continue; }
			console.warn("WARN (" + this.instanceName + "): message '" + name + "' refusé pour la session " + message.id + " (allowMessage) — trame jetée");
			delete message[name];
		}
		return admitted;
	}

	// Socket closed: the session survives for sessionTtlMs (a reload or a
	// network glitch resumes it), then expires — components get one last
	// delivery with the session before it is destroyed.
	#disconnected(socket) {
		const sessionId = socket.sessionId;
		if (sessionId === null || sessionId === undefined) return; // never handshaked
		const entry = this.#sessions.get(sessionId);
		if (entry === undefined || entry.socket !== socket) return; // session already resumed on another socket
		entry.socket = null;
		if (this.broker.verbose || this.verbose) console.log("INFO (" + this.instanceName + "): session " + sessionId + " déconnectée");
		this.#publishEvent("disconnected", sessionId, entry.session);
		entry.expiryTimer = setTimeout(() => {
			this.#publishEvent("expired", sessionId, entry.session);
			this.#sessions.delete(sessionId);
		}, this.sessionTtlMs);
	}

	#publishEvent(event, sessionId, session) {
		this.broker.publish(this, {
			connection_event: { event, channel: this.instanceName },
			id: sessionId,
			session,
		});
	}

	// Local delivery → network frame. Targeting is by SESSION id (stable),
	// resolved here to the live socket; sessions currently disconnected are
	// skipped. The reserved keys never reach the wire.
	handleMessage(message) {
		const outgoing = { ...message };
		let targets = null; // null = broadcast
		if (outgoing.id !== undefined) {
			targets = Array.isArray(outgoing.id) ? outgoing.id : [outgoing.id];
			delete outgoing.id;
		}
		delete outgoing.session;
		const frame = JSON.stringify(outgoing);
		for (const [sessionId, entry] of this.#sessions) {
			if (targets !== null && !targets.includes(sessionId)) continue;
			if (entry.socket === null || entry.socket.readyState !== WebSocket.OPEN) continue;
			if (this.broker.verbose || this.verbose) console.log("INFO (" + this.instanceName + "): " + frame + " vers " + sessionId);
			entry.socket.send(frame);
		}
	}

}

export default WebSocketChannel;
```

Cheminement complet : le client A saisit un message → `ChatBox` publie
`text_entered {text, ref}` → le broker client le valide et le livre **localement** à
`MessageNotifier` (qui compte) **et** au canal client → trame réseau → le canal serveur y
joint `id` et `session` et le publie → le broker serveur revalide et livre à `TextBoard`
→ `TextBoard` publie `text_posted {text, ref}` **sans `id`** (diffusé à tous) → les
brokers clients le livrent à tous les `ChatBox` et `MessageNotifier` ; chez A, `ChatBox`
y **reconnaît son `ref`** et réconcilie sa ligne provisoire, chez les autres il l'affiche
comme un message neuf.

C'est ici qu'intervient le **ciblage par `id`**, la clé d'enveloppe évoquée plus haut :
`TextBoard` diffuse à tous parce qu'il n'attache pas d'`id`. S'il fallait n'atteindre
qu'un seul client — un accusé destiné au seul émetteur, un message privé —, il joindrait
`id: message.id` (une session, ou un array de sessions) à son émission : le canal ne
l'enverrait **qu'**à ces sessions. Sans `id`, tous reçoivent ; l'`id` est retiré de la
trame avant l'envoi au client.

La validation aux deux bouts est une défense en profondeur : un client modifié ne peut
pas injecter un payload hors contrat (le broker serveur le jette), ni une trame avant
handshake (le canal ferme la connexion).

**Plusieurs canaux sur le même serveur** : chaque population de clients a son catalogue
partagé (`SHARED`, `FINGERPRINT`, `CHANNEL`), le catalogue serveur les agrège
(`...SHARED_A, ...SHARED_B` + contrats serveur-only), et chaque canal est construit avec
la seule autorisation de sa population. Un message de A publié par un objet serveur ne
part que par le canal A (le canal B n'y est pas abonné) ; un client de B qui enverrait
un message de A est bloqué par le broker (le canal B n'en est pas émetteur inscrit) ;
un message serveur-only ne figure dans aucune autorisation et ne quitte jamais le
serveur. Un client malveillant qui forgerait une trame hors autorisation se heurte donc
au broker, pas à une convention.

**Garanties du canal** : tant que la connexion vit, le transport (TCP) garantit la
livraison et l'ordre — il n'y a rien à ajouter. Les messages émis pendant une coupure
sont **perdus** (warning en console), sans tampon ni rejeu ; la **session**, elle,
survit à la coupure pendant `sessionTtlMs` — un client qui revient avec son token
reprend son identité et son `session.data`, et c'est aux composants de lui rejouer
l'état applicatif s'il y a lieu (répondre au message d'entrée, comme le `join` du jeu
du dépôt de référence). La **reconnexion automatique** reste au bon vouloir de chaque
implémentation de canal : celle de référence ne la fait pas — un rechargement de page
suffit à reprendre la session, et les objets serveur sont prévenus des allers-retours
par `connection_event`. En cas d'échec du handshake (contrat incompatible — la raison
arrive dans la réponse —, connexion fermée, timeout, qui ferme alors le socket : pas
de canal zombie), le canal client émet une erreur en console et **se termine** sans
jamais s'inscrire — `ready()` est rejetée avec la raison, et l'application décide
(l'index ci-dessus ne monte pas les composants).

# Écrire son propre canal

Le canal est le seul objet qui connaisse le transport — en changer ne touche ni les
composants, ni les contrats, ni les brokers. Un canal socket.io (reconnexion automatique
native), un canal SSE + POST, un back PHP face à des navigateurs, un client Unity ou
Electron face à un serveur Node : chacun tient dans deux moitiés (une par côté, dans
`channels/` et son miroir), et n'importe quel langage convient du moment que les huit
exigences ci-dessus sont respectées — même protocole d'enveloppes
(`handshake_request` / `handshake_response`, un événement `message` unique), même
autorisation de transit, mêmes sessions à token, mêmes `connection_event`. Le catalogue
étant de la pure donnée JSON, un côté non-JavaScript le consomme tel quel ; seul le
moteur (validation, upcast) est à transposer dans le langage hôte.

Le banc d'essai du canal de référence (`tests/test_websocket_channel.js`) rejoue les
exigences une à une contre un faux transport : handshake bloquant, alignement de
catalogue (millésime/hash divergents en warning, message/version/révision incompatibles
refusés, upcast entrant d'un client en retard), stampage `id`/`session`, reprise par
token, expiration, ciblage sortant. Un nouveau canal qui passe la même batterie — en
remplaçant le faux transport par le sien — est un canal conforme.

# Faire évoluer un contrat

Le régime de croisière est la **révision** : un nouvel abonné a besoin d'un champ de
plus → on grave une révision qui rejoue le schéma complet et ajoute le champ (avec
`default` s'il est `required`). Seul l'émetteur qui doit produire la nouvelle donnée
est touché ; les émetteurs en retard continuent d'émettre leur révision, le broker
complète. Exemple : ajouter la couleur aux textes diffusés —

```javascript
text_posted: {
	versions: { 1: { revisions: {
		0: { fields: {
			text: { type: "string", required: true },
			ref:  { type: "string", required: true },
		} },
		1: { fields: {                                   // snapshot complet, jamais un diff
			text:  { type: "string", required: true },
			ref:   { type: "string", required: true },
			color: { type: "string", required: true, default: "black" },
		} },
	} } },
	...
}
```

Un `TextBoard` resté en révision 0 émet sans `color` ; tous les `ChatBox` reçoivent
quand même `color: "black"`.

Le `default` ne sert **qu'à l'upcast**, jamais à la validation : un émetteur déclaré en
révision 1 qui omettrait `color` est rejeté — un champ `required` de *sa* révision doit
être fourni, le broker ne comble jamais une émission fautive. Le défaut ne comble que
les émetteurs d'une révision *antérieure*, qui ne pouvaient pas connaître le champ.
C'est aussi pourquoi les champs d'une révision 0 n'en portent pas : il n'existe aucun
émetteur antérieur à combler.

## Changer de version

Le changement **cassant** (renommer, supprimer, changer le sens d'un champ) est
l'événement rare : on grave une nouvelle **version**, et avec elle un upcaster
déclaratif (`upcastFrom`) qui dit comment convertir un payload de la version
précédente. Exemple : regrouper la présentation des textes dans un champ `style` —
`color` y déménage (déplacer un champ existant casse le contrat, d'où la v2) et deux
réglages l'y rejoignent :

```javascript
text_posted: {
	versions: {
		1: { revisions: {
			0: { fields: {
				text: { type: "string", required: true },
				ref:  { type: "string", required: true },
			} },
			1: { fields: {
				text:  { type: "string", required: true },
				ref:   { type: "string", required: true },
				color: { type: "string", required: true, default: "black" },
			} },
		} },
		2: {
			// gravé avec la version : comment un payload v1 devient un payload v2
			upcastFrom: {
				rename:   { "color": "style.color" },
				defaults: { "style.font-size": "16px", "style.font-weight": "normal" },
			},
			revisions: {
				0: { fields: {
					text:  { type: "string", required: true },
					ref:   { type: "string", required: true },
					style: { type: "object", required: true, fields: {
						"font-size":   { type: "string", required: true },
						color:         { type: "string", required: true },
						"font-weight": { type: "string", required: true },
					} },
				} },
			},
		},
	},
	...
}
```

L'upcaster est un vocabulaire fermé — `rename` (chemins pointés pour entrer dans les
blocs), `defaults` (complète les champs que la v1 ne connaissait pas), `remove` — pas
du code : il est interprété par le moteur, et prouvé à l'export comme les révisions
(R6) : chaque exemple de la v1, passé par l'upcaster, doit valider la révision
courante de la v2. Le `.` y étant le **séparateur de chemin** (`"style.color"` = entrer
dans `style`), un nom de champ ne peut pas contenir de `.` — `validateCatalog` le refuse,
sans quoi le chemin serait ambigu.

Un `ChatBox` migré s'abonne en v2 et applique le style — voici la branche qui peint une
ligne postée (la réconciliation par `ref`, inchangée, est omise ici) :

```javascript
receives = [{ name: "text_posted", version: 2 }];

handleMessage(message) {
	if (message.text_posted) {
		const li = document.createElement("li");
		li.textContent = message.text_posted.text;
		Object.assign(li.style, {
			fontSize:   message.text_posted.style["font-size"],
			color:      message.text_posted.style.color,
			fontWeight: message.text_posted.style["font-weight"],
		});
		this.list.appendChild(li);
	}
}
```

Tant que le `TextBoard` émet en v1, le broker sert chacun dans sa version : il met
d'abord l'émission en forme canonique de la v1 (révision la plus haute, `color`
complété à `"black"` si absent), puis applique l'upcaster pour les abonnés v2 —
`{ text: "bonjour", ref: "…", color: "red" }` est livré à un `ChatBox` v2 sous la forme
`{ text: "bonjour", ref: "…", style: { "font-size": "16px", color: "red", "font-weight":
"normal" } }`. Il n'y a pas de downcast (§2.2.b) : la gouvernance de migration est
donc « les abonnés d'abord, l'émetteur en dernier ». Un abonné migré en v2 est servi
par l'upcaster sans attendre personne ; le jour où le `TextBoard` passe en v2, un
abonné resté en v1 n'est plus servi — le broker le signale et le saute, la casse est
visible dans les logs, jamais silencieuse.

## À travers un canal

Tout ce qui précède vaut à l'intérieur d'un broker. À travers un canal, client et
serveur peuvent tourner sur des **révisions différentes** d'un même message sans
déploiement synchrone : le handshake les aligne (§4). La seule contrainte est la règle
directionnelle — pas de downcast, le **récepteur** doit être à une révision **≥** celle
de l'**émetteur**. Elle se projette différemment selon le sens du message :

- **Client→serveur** (le serveur reçoit) : un client **en retard** passe. Le serveur
  upcaste chaque trame entrante de la révision du client jusqu'à la sienne. Un
  déploiement serveur qui grave une nouvelle révision d'un message client→serveur
  **n'éjecte aucun client** ; les clients rattrapent à leur rythme.
- **Serveur→client** (le client reçoit) : le client doit être **au moins aussi à jour**
  que le serveur. Faire avancer un message serveur→client exige donc que les clients
  soient déjà à jour — c'est le « abonnés d'abord, l'émetteur en dernier » du broker,
  reporté sur le fil. Un client en retard sur un tel message est refusé au handshake,
  proprement, plutôt que de recevoir une forme qu'il ne sait pas lire.

Ce sont les **schémas annoncés** au handshake qui décident de la compatibilité et des
bases d'upcast : concordance des messages/versions, **égalité des schémas des révisions
partagées** (une révision publiée éditée en place — violation de l'append-only — est
alors refusée, pas seulement signalée), et règle directionnelle sur le `latest`. Le
millésime manuel n'est plus qu'une étiquette consultative ; le hash de contenu a quitté
le protocole.

# Tester

Chaque composant a son fichier de test : un vrai broker, une **sonde** (probe) abonnée
aux émissions du composant, et l'injection des `examples` du catalogue — aucun DOM ni
réseau nécessaires pour les composants serveur, aucun serveur pour les composants
client (le canal est le seul objet à tester avec un transport factice). C'est ici que
`deps` se paie : un composant qui persiste reçoit un faux store, un canal reçoit un faux
socket, et le test n'a **rien** à monkey-patcher — il construit l'objet comme
`server.js` ou `index.html` le construisent, avec d'autres dépendances.

```javascript
// tests/test_plates_memory.js
import Broker from "../fw/broker.js";
import CATALOG from "../catalogs/catalog.js";
import PlatesMemory from "../components/plates_memory.js";

class Probe {
	instanceName;
	received = [];
	constructor(instanceName) { this.instanceName = instanceName; }
	handleMessage(message) { this.received.push(message); }
}

const broker = new Broker(CATALOG);
const memory = new PlatesMemory(broker, "memory");
const probe = new Probe("probe");
broker.register(probe, [{ name: "click", version: 1 }], [{ name: "plates_count", version: 1 }]);

broker.publish(probe, { click: {} });
console.assert(probe.received[0]?.plates_count?.count === 1, "un clic = une assiette");
```

## Tester les composants client

Le motif est **littéralement le même** — signature identique des deux côtés, un
composant client se construit dans un test exactement comme un composant serveur. Seules
deux choses changent. D'abord, la
**sonde joue le rôle du canal** après handshake : elle s'inscrit comme émettrice de ce
que le serveur enverrait (les `clientReceives` du `CHANNEL`) et abonnée à ce que le
client émet (les `clientEmits`). Comme elle publie à travers le vrai broker client, tout
ce qu'elle injecte est validé contre le catalogue au passage : un test qui enverrait un
message hors contrat échoue de lui-même. Ensuite, les composants client touchent au
DOM : `jsdom` (dépendance de dev uniquement — pas de build, le test reste un
`node test_x.js`) fournit un `document` réel, si bien que le test prouve aussi le
**rendu**, pas seulement les messages.

```javascript
import { JSDOM } from "jsdom";
import Broker from "../public/fw/broker.js";        // Node importe sous public/ sans peine
import CATALOG from "../public/catalogs/catalog.js";
import PlatesButton from "../public/components/plates_button.js";
import PlatesStack from "../public/components/plates_stack.js";

const dom = new JSDOM(`<!DOCTYPE html><div id="button"></div><div id="stack"></div>`);
globalThis.document = dom.window.document;

const broker = new Broker(CATALOG);
const probe = new Probe("probe"); // même classe Probe que côté serveur
broker.register(probe,
	[{ name: "plates_count", version: 1 }],  // la sonde émet ce que le serveur émettrait
	[{ name: "click", version: 1 }]);        // et reçoit ce que le client émet

// le support est un deps comme un autre : le test le fournit, le composant n'en
// cherche aucun — c'est ce qui rend le montage identique au montage réel.
new PlatesButton(broker, "button", { support: document.getElementById("button") });
const stack = document.getElementById("stack");
new PlatesStack(broker, "stack", { support: stack });

// sens client → serveur : un clic sort le bon message
document.querySelector("button").click();
console.assert(probe.received[0]?.click !== undefined, "un clic émet le message click");

// sens serveur → client : le rendu suit ce que « le serveur » publie
broker.publish(probe, { plates_count: { count: 3 } });
console.assert(stack.textContent.includes("3 assiette"), "la pile affiche le compte reçu");
```

Deux règles pour que ces tests vieillissent bien : un scénario = un monde (chaque
scénario construit son broker, sa sonde et son support, rien ne fuit de l'un à l'autre),
et les assertions portent sur ce que **voit l'utilisateur** (textes, comptes, marquages
via `textContent`), jamais sur la structure HTML fine — on peut restyler `render()` sans
casser les tests, mais pas changer ce que le joueur perçoit.

Les fichiers `test_engine.js`, `test_broker.js` et `test_broker_client.js` du dépôt de
référence prouvent le moteur, les deux brokers et les catalogues (exemples validés,
contre-exemples rejetés, upcasts) ; `test_broker_delivery.js` prouve la sémantique de
livraison sur les deux brokers (FIFO, isolation des exceptions, budget anti-cycle,
désinscription immédiate, copie privée, clés d'enveloppe `id`/`session` — dont la
propagation de `session` par référence) ; `test_websocket_channel.js` rejoue les
exigences du canal contre un faux transport (handshake bloquant, alignement de
catalogue — warnings, refus d'incompatibilité, upcast entrant —, sessions à token,
expiration, ciblage) : `node test_engine.js`, etc.
