Tuto 3 : passer au client/serveur

La pile déménage sur le serveur. Un canal WebSocket relie les deux brokers, après un handshake. Le bouton ne change pas d'une ligne.

Fondé sur : client/serveur et canal. Projet : tutos/projets/assiettes_serveur (téléchargement). Il faut Node 20 et npm install pour ws.

  1. Réorganiser l'arborescence

    Le serveur a son fw/, le client le sien sous public/. Le framework est fourni en miroir, canal compris : chaque côté a sa moitié du canal dans fw/channels/. fw/engine.js côté serveur n'est qu'un shim vers la source unique sous public/.

    assiettes_serveur/
      server.js                            composition serveur : broker, canal, composants
      fw/                                  framework serveur, jamais modifié
        engine.js                          shim : export * from "../public/fw/engine.js"
        broker.js                          broker serveur (route id et session)
        channel.catalog.js                 contrat connection_event
        channels/websocket_channel.js      LE CANAL, moitié serveur
      catalogs/catalog.js                  union serveur
      components/plates_memory.js          la pile, côté serveur
      tests/
      public/
        index.html                         composition client : broker, canal, composants
        fw/                                framework client, jamais modifié
          engine.js                        source unique du moteur
          broker.js                        broker client (ignore id et session)
          channel.catalog.js               connection_event, version client
          channels/websocket_channel.js    LE CANAL, moitié client
        catalogs/plates.catalog.js         UN FICHIER PAR CANAL : SHARED + FINGERPRINT + CHANNEL
        catalogs/catalog.js                union client
        components/plates_button.js        inchangé
        components/plates_stack.js         n'émet plus : elle affiche ce que le serveur dit
  2. Le canal : un composant comme les autres, en deux moitiés

    Le canal est ce qui relie les deux brokers. Ce n'est pas une couche à part du framework : c'est un objet standard, avec la même signature (broker, instanceName, deps), des emits, des receives, un handleMessage. Sa seule particularité : il est le seul objet qui connaisse le transport. Les autres composants ne voient que leur broker, et ne savent pas si un abonné est à côté d'eux ou de l'autre côté du réseau.

    Il a deux moitiés, une par broker, inscrites en miroir croisé du CHANNEL :

    moitié client (public/fw/channels/)moitié serveur (fw/channels/)
    reçoit de son broker (receives)clientEmits : ce que les écrans émettent, pour l'envoyerclientReceives : ce que les composants serveur émettent, pour l'envoyer
    publie auprès de son broker (emits)clientReceives : ce qui arrive du serveurclientEmits : ce qui arrive des clients, plus connection_event
    ressource reçue par depsun WebSocket déjà créé par la pageun WebSocketServer déjà créé par server.js

    Le squelette ci-dessous est le vrai code de la moitié client, où tout ce qui tient du protocole (handshake, reconnexion, jeton) est remplacé par une ligne de commentaire. Il reste ce qui compte : un composant qui s'inscrit, qui envoie ce qu'il reçoit de son broker, et qui publie ce qu'il reçoit du réseau.

    import { upcastToLatestRevision } from "../engine.js";
    
    class WebSocketChannel {
    
    	instanceName;
    	broker;
    	deps;
    	socket;
    	emits;
    	receives;
    
    	constructor(broker, instanceName, deps = {}) {
    		this.broker = broker;
    		this.instanceName = instanceName;
    		this.deps = deps;
    		this.socket = deps.socket;                 // la ressource ouverte, pas son URL
    
    		// miroir croisé de CHANNEL : ce que le client reçoit, le canal l'émet ici ;
    		// ce que le client émet, le canal le reçoit ici pour l'envoyer
    		this.emits    = [...deps.channel.clientReceives, { name: "connection_event", version: 1 }];
    		this.receives = [...deps.channel.clientEmits];
    
    		this.socket.addEventListener("open", () => { /* envoie handshake_request : listes de transit + schémas */ });
    		this.socket.addEventListener("message", (event) => {
    			const message = JSON.parse(event.data);
    			if (message.handshake_response) {
    				/* vérifie l'accord, mémorise le jeton de session ; au premier accord : */
    				this.broker.register(this, this.emits, this.receives);   // il n'existe pour le broker qu'à partir d'ici
    				this.broker.publish(this, { connection_event: { event: "connected", channel: this.instanceName } });
    				return;
    			}
    			/* upcaste chaque payload depuis la révision du serveur vers la révision locale */
    			this.broker.publish(this, message);     // ENTRANT : le broker valide et livre aux écrans
    		});
    		this.socket.addEventListener("close", () => { /* publie connection_event disconnected, programme une reconnexion */ });
    	}
    
    	// SORTANT : un écran a publié un message de clientEmits ; le broker l'a validé et le livre ici
    	handleMessage(message) {
    		if (this.socket.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message));
    		else console.warn("WARN (" + this.instanceName + "): " + Object.keys(message).join(", ") + " perdu, lien coupé");
    	}
    
    	ready() { /* promesse résolue après le premier handshake accepté, rejetée sinon */ }
    
    	// pas de destroy() : un canal ne se démonte pas
    }
    export default WebSocketChannel;

    La moitié serveur a la même forme, avec trois différences : elle s'inscrit dès le constructeur (c'est le client qui se présente à elle, pas l'inverse) ; elle tient un magasin de sessions, et pose sur chaque entrant les clés d'enveloppe id et session avant de publier ; et dans handleMessage, elle lit la clé id d'un sortant pour n'écrire que sur les sockets visés (sans id, sur tous).

    class WebSocketChannel {                           // moitié serveur, fw/channels/websocket_channel.js
    
    	constructor(broker, instanceName, deps = {}) {
    		/* … mêmes champs … */
    		this.emits    = [...deps.channel.clientEmits, { name: "connection_event", version: 1 }];
    		this.receives = [...deps.channel.clientReceives];
    		this.broker.register(this, this.emits, this.receives);
    		deps.wss.on("connection", (socket) => {         // le WebSocketServer vient de deps, déjà ouvert
    			/* attend handshake_request, vérifie la compatibilité des catalogues, crée ou reprend la session, répond */
    			/* publie connection_event { connected | resumed } avec id et session */
    			socket.on("message", (trame) => {
    				const message = JSON.parse(trame);
    				message.id = socket.sessionId;              // identifiant de session, par valeur
    				message.session = this.#sessions.get(socket.sessionId).session;   // { data }, par référence
    				/* upcaste depuis la révision de ce client ; consulte session.allowMessage */
    				this.broker.publish(this, message);         // ENTRANT : le broker serveur valide et livre
    			});
    			socket.on("close", () => { /* connection_event disconnected ; expired après sessionTtlMs */ });
    		});
    	}
    
    	// SORTANT : un composant serveur a publié un message de clientReceives
    	handleMessage(message) {
    		const sortant = { ...message };
    		const cibles = sortant.id === undefined ? null : [sortant.id].flat();
    		delete sortant.id;                                  // les clés d'enveloppe ne partent jamais sur le réseau
    		delete sortant.session;
    		for (const [sessionId, entree] of this.#sessions) {
    			if (cibles !== null && !cibles.includes(sessionId)) continue;
    			entree.socket?.send(JSON.stringify(sortant));
    		}
    	}
    
    }

    Rien dans ce code ne nomme un composant : le canal ne connaît que le broker et le transport. On pourrait le remplacer par un canal socket.io, SSE ou en mémoire (comme celui de la démo) sans toucher ni aux écrans, ni aux composants serveur, ni aux catalogues. Le vrai fichier, avec le protocole complet, est lisible en source annotée côté client et côté serveur ; la référence en liste les exigences.

  3. Le catalogue du canal

    Un fichier exporte le trio de sa population de clients : les contrats partagés, le millésime, et l'autorisation de transit CHANNEL (ce qui a le droit de traverser, dans chaque sens). Les deux moitiés du canal importent ce même fichier.

    Puis chaque broker reçoit son union : le client étale les contrats du framework côté client et le SHARED ; le serveur étale ses contrats du framework (connection_event) et le même SHARED.

  4. La pile côté serveur

    Même signature, un deps sans support, pas de render(). Elle diffuse plates_count à tous (pas de clé id) à chaque clic, et l'envoie au seul arrivant (clé id) sur connection_event.

  5. La pile côté client

    Elle n'émet plus rien : elle affiche ce qu'elle reçoit. Son code ne dit nulle part « serveur ».

  6. Composer côté serveur

    server.js ouvre le WebSocketServer et le passe au canal par deps : la ressource ouverte, pas son port. Seul server.js lit l'environnement.

  7. Composer côté client : handshake, puis canal.ready()

    Le canal client ne s'inscrit au broker qu'après le handshake. On attend ready() avant de monter ce qui parle au serveur. Rejet si le contrat est incompatible, si la connexion se ferme, ou après le délai.

  8. Lancer

    $ cd tutos/projets/assiettes_serveur && npm install && node server.js INFO (assiettes): http://localhost:3000

    Ouvrez deux onglets : un clic dans l'un met à jour la pile dans les deux. Cochez le mode verbeux dans un onglet pour voir le handshake puis les messages traverser.

    À ce stade le bouton fonctionne sans se connecter : le tuto 4 ferme la porte.

Ce que fait le handshake

Le client annonce ses listes de transit et les schémas complets des messages qui traversent. Le serveur vérifie la compatibilité, pas l'identité : mêmes messages et versions, schémas égaux sur les révisions partagées (en forme canonique), et règle directionnelle « le récepteur est au moins aussi à jour que l'émetteur ». Il répond un jeton de session et les bases d'upcast. Rien ne transite avant. Référence.