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.
Réorganiser l'arborescence
Le serveur a son
fw/, le client le sien souspublic/. Le framework est fourni en miroir, canal compris : chaque côté a sa moitié du canal dansfw/channels/.fw/engine.jscôté serveur n'est qu'un shim vers la source unique souspublic/.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 ditLe 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), desemits, desreceives, unhandleMessage. 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'envoyerpublie auprès de son broker ( emits)clientReceives: ce qui arrive du serveurclientEmits: ce qui arrive des clients, plusconnection_eventressource reçue par depsun WebSocketdéjà créé par la pageun WebSocketServerdéjà créé parserver.jsLe 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
idetsessionavant de publier ; et danshandleMessage, elle lit la cléidd'un sortant pour n'écrire que sur les sockets visés (sansid, 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.
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êmeSHARED.La pile côté serveur
Même signature, un
depssans support, pas derender(). Elle diffuseplates_countà tous (pas de cléid) à chaque clic, et l'envoie au seul arrivant (cléid) surconnection_event.La pile côté client
Elle n'émet plus rien : elle affiche ce qu'elle reçoit. Son code ne dit nulle part « serveur ».
Composer côté serveur
server.jsouvre leWebSocketServeret le passe au canal pardeps: la ressource ouverte, pas son port. Seulserver.jslit l'environnement.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.Lancer
$ cd tutos/projets/assiettes_serveur && npm install && node server.js INFO (assiettes): http://localhost:3000Ouvrez 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.
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.