Le client isomorphe — Nodefony dans le navigateur
stablenodefony (cœur)mis à jour 2026-07-19
Le paquet
nodefonyne s’arrête pas au serveur : une partie de son code est compilée pour le navigateur et publiée sous plusieurs points d’entrée : le socle (nodefony/client,nodefony/roles,nodefony/debugbar) et une liaison par moteur front —nodefony/react,nodefony/vue,nodefony/svelte,nodefony/angular. Ton front n’importe donc pas une bibliothèque cliente « compagnon » qu’il faudrait tenir à jour en parallèle du back — il importe le même paquet, avec les mêmes types, le même protocole et les mêmes règles de rôles. Ancré sursrc/nodefony/src/client/.
📍 Documentation › Cœur — @nodefony/core › Client isomorphe
🧠 Le modèle mental — un paquet, deux compilations#
Une application temps réel a toujours deux moitiés qui doivent se mettre d’accord : le serveur qui pousse, le navigateur qui reçoit. La méthode habituelle consiste à écrire deux fois le contrat — une fois en TypeScript côté back, une fois en JavaScript côté front — puis à réparer les divergences.
Nodefony supprime la deuxième écriture. Le même dépôt de code produit deux sorties depuis les
mêmes sources : un bundle Node et un bundle navigateur. Le champ exports du paquet aiguille selon
qui demande.
Trois idées portent toute la page :
- Le contrat n’est pas dupliqué, il est partagé. Le moteur de protocole
JsonRpcPeerest le même objet des deux côtés : le navigateur l’exécute dans son client, le serveur en instancie un par connexion. - Le navigateur n’a droit qu’à ce qui a du sens pour lui. Le barrel client ne réexporte pas le Kernel, ni les serveurs, ni la validation de schéma — on n’expédie pas 400 Kio de back au client.
- Ce que le client sait faire, le serveur n’a pas à le faire. La reconnexion, la mémoire des abonnements et la négociation de cadence vivent côté navigateur : le serveur reste bête et sans état par connexion.
📖 Lexique#
| Terme | Sens |
|---|---|
| Isomorphe | Un même code source qui s’exécute des deux côtés (serveur et navigateur), sans réécriture. |
| Subpath | Point d’entrée secondaire d’un paquet npm (nodefony/client), déclaré dans le champ exports. |
| Condition d’export | Clé du champ exports (browser, import, types) qui choisit quel fichier un outil résout. |
| Barrel | Fichier index.ts qui réexporte la surface publique d’un dossier. C’est lui qui décide ce qui est public. |
| JSON-RPC 2.0 | Protocole d’appel de méthode par messages ; Nodefony l’emploie sur WebSocket dans les deux sens. |
| Canal | Sujet logique multiplexé sur l’unique connexion (orders:new). On s’y abonne, on y publie. |
| Notification | Message JSON-RPC sans identifiant — aucune réponse attendue. C’est la forme du pub/sub. |
| Ref-comptage | Compter les consommateurs d’un abonnement pour n’émettre le trafic qu’aux transitions 0↔1. |
| Welcome | Première trame poussée par le serveur à l’ouverture : identité résolue + capacités annoncées. |
| Notice | Message normalisé prêt à afficher (level, title, message) — le format unique des alertes du client. |
| Close code | Code numérique de fermeture d’une WebSocket (RFC 6455 §7.4) : il dit pourquoi ça s’est fermé. |
| AIMD | Additive Increase / Multiplicative Decrease — la loi de contrôle de congestion de TCP, reprise ici. |
| Famine | Les trames arrivent nettement plus lentement que la cadence demandée : le producteur est sous pression. |
| Latest-wins | Canal d’état où seule la dernière valeur compte — on peut décimer sans rien perdre. |
| RBAC | Role-Based Access Control — décider d’un droit à partir de rôles (ROLE_ADMIN). |
| Shim | Petit remplacement d’une API absente du navigateur (node:events, node:util) pour que le code compile. |
| Tree-shaking | Élimination du code non importé au build : un subpath qu’on n’importe pas pèse zéro octet. |
| HMR | Hot Module Replacement — remplacement d’un module à chaud pendant le développement, sans recharger. |
Qu’est-ce qu’un client isomorphe — et ce que ça change concrètement#
Un « client » de framework, d’habitude, c’est un second paquet npm (mon-framework-client) publié à
côté du serveur. Il embarque sa propre copie du protocole, sa propre définition des types échangés, et
son propre calendrier de versions. Tant que les deux avancent ensemble, tout va bien. Le jour où le
serveur renomme un champ, la divergence ne se voit qu’à l’exécution, chez l’utilisateur.
Un client isomorphe supprime la question : il n’y a qu’un paquet, donc qu’une version. Trois conséquences pratiques, dans l’ordre où on les rencontre.
- Les types sont les mêmes objets, pas des copies. Le contrat de socket
IRealtimeSocketdécrit la prise côté navigateur et côté serveur. Une méthode qui change de signature casse la compilation des deux côtés, au build, pas en production. - Les règles de rôles s’évaluent à l’identique.
hasAnyRole()(client/roles/roles.ts#hasAnyRole) est la fonction que le front appelle pour griser un bouton et que le back peut appeler dans un jury d’autorisation. Plus de « le front croyait queROLE_ADMINsuffisait ». - Les erreurs se traduisent une seule fois. Le serveur ferme la connexion avec un code RFC 6455 ;
le client traduit ce code en message lisible via
closeCodeToNotice()(client/realtime/notice.ts#closeCodeToNotice). La table de correspondance vit à un seul endroit.
❗ Important
Isomorphe ne veut pas dire « identique ». Le navigateur n’a ni kernel, ni serveurs, ni accès à la base. Le barrel client publie une sélection —
NodefonySocket,Syslog,Pdu,Service,Container(client/index.ts) : les briques qui ont un sens dans un onglet. Le reste n’est simplement pas là, et n’alourdit donc pas ton bundle.
La vision Nodefony#
Le champ exports du paquet est le pivot de tout le dispositif. Il déclare sept points d’entrée
navigateur — le socle (client, roles, debugbar) et une liaison par moteur front (react, vue,
svelte, angular) —, plus une condition browser sur l’entrée principale.
| Ce que tu importes | Ce que tu obtiens | Quand l’utiliser |
|---|---|---|
nodefony/client |
Le client temps réel, les notices, la cadence adaptative, Syslog/Pdu |
Toujours — c’est le point d’entrée de référence |
nodefony/react |
Les hooks useNodefony* (peer optionnelle react) |
App React qui consomme des canaux |
nodefony/vue |
Le plugin nodefonyVue et les composables useNodefony* |
App Vue 3 qui consomme des canaux |
nodefony/svelte |
configureNodefony et des sources réactives lues .current |
App Svelte 5 qui consomme des canaux |
nodefony/angular |
provideNodefony et les fonctions injectNodefony* (signals) |
App Angular qui consomme des canaux |
nodefony/roles |
hasRole & co, RoleSet, RoleRegistry |
Afficher/masquer selon les rôles |
nodefony/syslog |
Les règles de lecture des journaux (protocole, étape, ligne d’accès, trafic de la console) | Écrire un visualiseur de journaux |
nodefony/debugbar |
La barre de debug de développement + son modèle pur | Dev, ou réutiliser le calcul de cascade |
nodefony (au front) |
La même chose que nodefony/client, via la condition browser |
Code partagé front/back ; sinon préfère l’explicite |
Ces entrées navigateur sont produites par une compilation dédiée — clientConfig
(rolldown.config.ts#clientConfig) déclare exactement ces sept fichiers d’entrée, en conservant la structure
des modules pour que le client temps réel ne soit émis qu’une fois même s’il est tiré par deux
subpaths.
Deux choix méritent d’être explicités, parce qu’ils se voient dans ton bundle.
- Les subpaths ne sont jamais réexportés depuis le barrel principal. Importer
nodefony/clientne tire ni React, ni la barre de debug. C’est ce qui permet à la barre de peser zéro octet en production : personne ne l’importe. - React, Vue, Angular et Svelte sont des dépendances externes, jamais empaquetées.
clientExternal(rolldown.config.ts#clientExternal) marquereact/react-dom,vue,@angular/*etsveltecomme externes : c’est ton runtime qui sera utilisé, donc pas de double instance et pas de règle des hooks violée.
Le compromis assumé : côté navigateur, quelques API Node manquent. Nodefony ne charge pas de
polyfill lourd — il substitue au build deux shims minimaux, browserShim
(rolldown.config.ts#browserShim), qui redirigent node:events et node:util vers des implémentations
navigateur de quelques dizaines de lignes.
🚀 Démarrage rapide#
Le scénario : une application générée par nodefony create app avec un front servi par
@nodefony/frontend. On veut une socket ouverte, un canal écouté, et un appel d’API par cette même
socket.
1. La socket de l’application#
Le client est une classe : on l’instancie, on la connecte.
import { NodefonySocket } from "nodefony/client";
const socket = new NodefonySocket({ url: "/api/live/realtime" });
await socket.connect();
C’est ce que fait shared() — plus une garantie : une application veut une seule connexion
WebSocket pour toute la page, et new en ouvre une par appel. NodefonySocket.shared({ url }) rend
donc la même instance pour la même URL, depuis n’importe quel fichier, sans passer la socket de
main en main.
Retenir :
newpour comprendre l’objet,shared()partout dans une application.
Un seul fichier, monté une fois au démarrage du front.
// frontend/src/realtime.ts — la socket de l'app, importée partout ailleurs.
import { NodefonySocket } from "nodefony/client";
import type { NodefonyNotice } from "nodefony/client";
// Instance PARTAGÉE par URL : deux appels = la même socket, pas deux connexions.
// L'URL relative est normalisée (https → wss) avant l'ouverture.
export const socket = NodefonySocket.shared({
url: "/api/live/realtime",
});
// Les criticités du temps réel arrivent déjà traduites et prêtes à afficher.
socket.onNotice((notice: NodefonyNotice) => {
console.warn(`[${notice.level}] ${notice.message}`);
});
// `on` REÇOIT les messages ; `subscribe` DEMANDE au serveur de les pousser.
// Les deux sont nécessaires — et `subscribe` est ré-émis seul au reconnect.
socket.on("orders:new", (payload) => {
console.log("nouvelle commande", payload);
});
socket.subscribe("orders:new");
// L'identité vient du serveur (trame « welcome ») : aucune route /auth/me.
socket.onIdentity((identity) => {
if (!identity?.authenticated) console.log("visiteur anonyme");
});
await socket.connect();
2. Appeler l’API par la socket, pas par fetch#
La même action de contrôleur répond en HTTP et en WebSocket. Un chemin qui commence par / est
routé vers le pont d’API ; les mutations exigent une clé d’idempotence, parce qu’une socket qui
reconnecte peut rejouer une trame en vol.
// frontend/src/orders.ts
import { NodefonySocket } from "nodefony/client";
// MÊME URL ⇒ MÊME instance que dans `realtime.ts` : `shared()` se relit de
// n'importe quel fichier, sans passer la socket de main en main.
const socket = NodefonySocket.shared({ url: "/api/live/realtime" });
// Lecture — équivalent strict du GET REST sur la même route.
export async function loadModules(): Promise<{ name: string }[]> {
return socket.request<{ name: string }[]>("/nodefony/kernel/api/modules");
}
// Écriture — la clé d'idempotence dédoublonne un éventuel rejeu.
export async function ship(orderId: string): Promise<void> {
await socket.mutate(`/shop/api/orders/${orderId}/ship`, {
method: "POST",
idempotencyKey: crypto.randomUUID(),
});
}
// Mesure du temps d'aller-retour — la convention `nodefony:kernel:ping` vit dans la lib.
export async function latency(): Promise<number> {
const { rtt } = await socket.ping();
return rtt;
}
3. Adapter l’affichage aux rôles#
// frontend/src/nav.ts — filtrer une navigation. ERGONOMIE, pas sécurité.
import { hasAnyRole, RoleSet } from "nodefony/roles";
export function visibleTabs(roles: readonly string[]): string[] {
const tabs = ["Commandes"];
// Contrôle ponctuel : zéro allocation, parcours linéaire.
if (hasAnyRole(roles, ["ROLE_ADMIN", "ROLE_BILLING"]))
tabs.push("Facturation");
// Contrôles RÉPÉTÉS sur le même utilisateur : une allocation, puis O(1).
const set = new RoleSet(roles);
if (set.has("ROLE_ADMIN")) tabs.push("Administration");
return tabs;
}
Ce qu’on observe#
npx nodefony development
Dans la console du navigateur, à l’ouverture de la page :
visiteur anonyme # la trame welcome est arrivée, non authentifié
nouvelle commande { id: 'CMD-42' } # le canal orders:new pousse
[warning] Connexion temps réel perdue # le réseau a sauté (close code 1006)
[success] Connexion temps réel rétablie
La dernière ligne mérite une remarque : elle n’est émise qu’après une vraie perte, jamais à la première connexion. Le client distingue « je me connecte » de « je me reconnecte » — sinon toute page afficherait un message de rétablissement au chargement.
💡 Astuce
Ne câble rien à la main : la liaison de ton moteur front fait l’abonnement, le désabonnement et la mise à jour de la vue pour toi. Voir Hooks React, composables Vue, réactivité Svelte ou services Angular.
🔌 NodefonySocket — la socket vue du navigateur#
C’est la brique centrale du subpath. NodefonySocket (client/realtime/NodefonySocket.ts#NodefonySocket)
implémente le contrat de socket isomorphe et le contrat de pair JSON-RPC : il sait donc à la fois
recevoir des messages poussés et répondre à des requêtes venues du serveur.
Les options du constructeur#
new NodefonySocket(options) et NodefonySocket.shared(options) prennent le même objet,
NodefonySocketOptions (client/realtime/NodefonySocket.ts#NodefonySocketOptions). Toutes les clés sont facultatives ;
seule l’adresse doit être connue au moment de connect().
| Option | Défaut | Effet |
|---|---|---|
url |
— | Point d’entrée WebSocket. Relatif (/api/live/realtime) : résolu contre la page, http(s) devenant ws(s). Absente au connect() et sans argument → requireUrl lève une erreur qui le dit (NodefonySocket.ts#NodefonySocket.requireUrl). |
token |
— | Jeton ajouté à l’URL de connexion en paramètre token (NodefonySocket.ts#NodefonySocketOptions.token). Inutile quand la session passe par le cookie — le cas par défaut, et le seul qui ne fait pas voyager le secret dans une adresse. |
autoReconnect |
true |
false coupe la reconnexion automatique après une fermeture transitoire (NodefonySocket.ts#NodefonySocket.settleClose). Une fermeture définitive ne relance jamais, quelle que soit la valeur. |
reconnectDelay |
1000 |
Premier délai entre deux tentatives (ms) ; il double à chaque échec, avec une gigue, sans jamais descendre sous 100 ms (NodefonySocket.ts#reconnectDelay). |
reconnectDelayMax |
30000 |
Plafond de ce délai (ms) (NodefonySocket.ts#NodefonySocket.scheduleReconnect). |
connectTimeout |
10000 |
Délai d’ouverture (ms) : une poignée de main sans réponse est abandonnée, la promesse d’ouverture rejette et la reconnexion prend le relais. 0 retire la borne (NodefonySocket.ts#NodefonySocket.openSocket). |
heartbeatInterval |
30000 |
Intervalle du ping qui maintient la connexion et en mesure l’aller-retour (ms) (NodefonySocket.ts#NodefonySocket.startHeartbeat). |
banner |
true |
Annonce du framework dans la console et handle nodefony (NodefonySocket.ts#NodefonySocket.constructor). false pour une application publiée qui ne veut rien dans la console de ses utilisateurs. |
⚠️ Avec shared(), les options ne s’appliquent qu’à la première création pour une URL : les
appels suivants rendent l’instance existante, réglages compris.
Ouvrir, perdre, retrouver la connexion#
La subtilité utile est la distinction entre fermeture transitoire et définitive.
isReconnectableCloseCode() (client/realtime/notice.ts#isReconnectableCloseCode) lit le code de fermeture : une perte
réseau (1006) ou un redémarrage serveur (1011) relancent la boucle de reconnexion ; un refus de
politique (1008, c’est-à-dire un 401/403 traduit), une session refusée ou révoquée (4001) et une
origine refusée (4003) ne la relancent pas. Un refus AU HANDSHAKE (le serveur répond 401/403
avant le 101) arrive, lui, comme un 1006 que le navigateur ne distingue pas d’une panne : le
client pose alors une requête HTTP sur la même adresse (option probe) et un 401/403/404 vaut le
même refus définitif. Sans cette règle, un visiteur anonyme martèlerait
indéfiniment un point d’entrée protégé — et une session révoquée rejouerait son identité à chaque
tentative.
Le délai entre tentatives double à chaque échec — scheduleReconnect()
(client/realtime/NodefonySocket.ts#NodefonySocket.scheduleReconnect) — plafonné à 30 secondes par défaut. Trois garde-fous
le tiennent face à un serveur qui coupe tout le monde au même instant :
- une gigue : le délai tombe entre la moitié et la totalité du doublement, pour que mille onglets coupés ensemble ne reviennent pas ensemble ;
- un plancher de 100 ms, que ni la configuration ni le serveur ne franchissent ;
- le compteur ne revient à zéro qu’après 5 secondes de connexion stable : un serveur qui accepte puis coupe aussitôt est ménagé de plus en plus, pas sondé à cadence fixe.
La date de la prochaine tentative est exposée en lecture, ce qui permet d’afficher un compte à rebours exact plutôt qu’un sablier qui ment.
connect() se règle sur sa tentative : il résout à l’ouverture, et rejette si la socket se
ferme avant de s’ouvrir — serveur absent, délai connectTimeout dépassé, disconnect(). La
reconnexion, elle, continue en fond. Un appel qu’on n’attend pas s’écrit donc
socket.connect().catch(() => {}), jamais void socket.connect(), qui laisserait le rejet sans
traitement.
Les canaux — ref-comptés, et remémorés#
C’est le mécanisme qui évite la classe de bugs la plus pénible d’une application temps réel : deux composants écoutent le même canal, l’un se démonte, et coupe le flux de l’autre.
NodefonySocket.subscribe() (client/realtime/NodefonySocket.ts#NodefonySocket.subscribe) compte les consommateurs et
n’envoie la demande au serveur qu’au premier. NodefonySocket.unsubscribe()
(client/realtime/NodefonySocket.ts#NodefonySocket.unsubscribe) ne coupe qu’au dernier. Entre les deux, le trafic réseau
est nul.
Second effet, tout aussi important : la liste des abonnements est rejouée à chaque reconnexion.
Le serveur repart d’un état vide après une coupure ; c’est le client qui se souvient de ce qu’il
écoutait, dans la routine d’ouverture de socket (client/realtime/NodefonySocket.ts#NodefonySocket.mutate).
connected ne veut pas dire « abonné ». L’état passe à connected dès l’ouverture du
transport ; les abonnements ne sont rejoués qu’au realtime:welcome, et ne deviennent effectifs
qu’à l’acquittement du serveur (realtime:subscribed). Ce qui est publié entre les deux est
perdu. Pour le patron « je recharge l’état, puis je suis le flux », attendre l’acquittement :
socket.subscribe("app:orders");
await socket.whenSubscribed("app:orders"); // rejetée si le serveur refuse le canal
const snapshot = await loadOrders(); // tout ce qui est publié à partir d'ici est livré
whenSubscribed() (client/realtime/NodefonySocket.ts#NodefonySocket.whenSubscribed) repart de zéro à chaque reconnexion ;
onSubscribed() rappelle un écouteur à chaque acquittement. Face à un serveur qui n’annonce pas
subscriptionAcks dans son welcome (version antérieure), la promesse se résout dès le rejeu : la
garantie d’avant, sans plus. Un premier message peut arriver AVANT l’acquittement (le producteur
s’attache d’abord) — la garantie porte sur ce qui suit, pas sur « rien n’arrive avant ».
⚠️ Attention
on(canal, handler)etsubscribe(canal)ne font pas la même chose et ne se remplacent pas. Le premier installe un écouteur local ; le second demande au serveur de pousser. Écouter sans s’abonner donne un canal silencieux ; s’abonner sans écouter fait transiter des trames que personne ne lit.
Un abonnement conditionnel se monte et se démonte — il ne se met pas en pause. Aucune liaison de
vue n’accepte un canal « vide » ni un interrupteur : un interrupteur « temps réel » à l’écran rend
ou retire le composant abonné, et c’est son démontage qui libère le canal côté serveur. L’option
enabled de la cadence adaptative n’en est pas un : à false, elle garde l’abonnement à cadence
fixe (client/realtime/AdaptiveRate.ts#bindAdaptiveChannel).
Les quatre façons de parler au serveur#
| Appel | Ancre | Ce que ça fait |
|---|---|---|
NodefonySocket.emit() / publish() |
client/realtime/NodefonySocket.ts#NodefonySocket.publish |
Notification sans réponse — la forme du pub/sub |
NodefonySocket.request() |
client/realtime/NodefonySocket.ts#NodefonySocket.request |
Requête/réponse ; un argument commençant par / cible une route |
NodefonySocket.mutate() |
client/realtime/NodefonySocket.ts#NodefonySocket.mutate |
Écriture par le pont d’API — clé d’idempotence obligatoire |
Deux compléments moins courants. NodefonySocket.call()
(client/realtime/NodefonySocket.ts#NodefonySocket.call) rend l’enveloppe complète — la valeur et
l’identifiant du profil serveur de cette trame, ce qui permet en développement d’aller lire la
radiographie de l’appel. Et NodefonySocket.register()
(client/realtime/NodefonySocket.ts#NodefonySocket.register) fait du navigateur un appelé : le serveur peut lui
adresser une requête et attendre son résultat. C’est le duplex réel, pas seulement du push.
Ce que la socket dit d’elle-même — instantané et description#
socketSnapshot() (client/realtime/snapshot.ts#socketSnapshot) lit l’état de la socket sans émettre une
trame : adresse, état, identité, canaux tenus, canaux et actions annoncés à l’accueil, trames
reçues. observeSnapshot() (client/realtime/observe.ts#observeSnapshot) le pousse à chaque échantillon, à
chaque changement d’état et à chaque accueil — c’est ce dernier qui apporte l’identité, après le
passage à « connecté ».
describeSocket() (client/realtime/snapshot.ts#describeSocket) met cet instantané en mots : une ligne par
fait, avec sa valeur ET une phrase qui dit ce qu’elle garantit. C’est la source UNIQUE du tableau
que la console du navigateur affiche en développement, et de la vignette « Le client vu de
l’intérieur » des quatre vitrines — écrit dans chaque page, le texte des explications divergerait
de celui de la console à la première retouche.
Identité et refus — l’interface sait sans demander#
L’identité de la connexion n’est pas devinée par le front : le serveur l’annonce dans sa première
trame, et le client la retient. NodefonySocket.identity
(client/realtime/NodefonySocket.ts#NodefonySocket.identity) vaut null tant que rien n’est reçu, puis porte un objet
dont authenticated vaut false pour un visiteur anonyme. Un écran de connexion se décide donc
sans appeler aucune route.
Le pendant côté refus : quand le serveur rejette un abonnement, il pousse un message dédié que le
client transforme en deux signaux — une notice générique pour le centre de notifications, et un
événement ciblé consommable par NodefonySocket.onDenied()
(client/realtime/NodefonySocket.ts#NodefonySocket.onDenied) pour une réaction précise (griser le contrôle concerné). Le
motif reste volontairement générique : le serveur ne dit jamais quel rôle manquait, ce qui en
ferait un oracle.
L’identité se lit à chaque accueil, pas une fois au login. Quand les rôles d’un compte changent,
le serveur ferme ses sockets avec le code 4005 (realtime/closeCodes.ts#IDENTITY_REFRESH_CLOSE_CODE) ;
le client se reconnecte seul, et le nouvel accueil apporte les rôles à jour — onIdentity() est
rappelé. Une interface qui a mémorisé les rôles de la réponse de connexion continue d’afficher les
anciens ; une interface branchée sur onIdentity() (ou sur le hook d’identité de son moteur) suit
sans rien faire.
Le transport, et pourquoi il est aussi bête#
BrowserWsTransport (client/realtime/BrowserWsTransport.ts#BrowserWsTransport) enveloppe la WebSocket native et
ne fait rien d’autre : ouvrir, envoyer, fermer, relayer quatre événements. Toute l’orchestration —
état, reconnexion, battement de cœur, statistiques — vit au-dessus.
Cette séparation a une conséquence directe et très utile : le client accepte une fabrique de
transport injectée en second argument de son constructeur
(client/realtime/NodefonySocket.ts#toSocketUrl). Les tests branchent un transport factice et exercent toute
la machine à états sans ouvrir une seule socket. C’est ce qui rend les 177 cas de test de cette
page possibles hors navigateur.
Le transport relaie tout, binaire compris (binaryType = "arraybuffer") : il ne trie pas, parce
qu’un tri à cet étage rendait l’écart invisible pour tout ce qui est au-dessus.
Le binaire — écarté bruyamment, ou lu par toi#
Le protocole temps réel est du JSON-RPC texte : il ne sait pas à quel canal une trame binaire appartient, et ne la route donc pas. Ce qui se passe à la réception, selon le cas :
| Trame reçue | Sans lecteur | Avec onBinary(handler) |
|---|---|---|
| Binaire | écartée, comptée (binaryFramesDiscarded), une notice -32700 |
remise telle quelle (Uint8Array), en ordre |
| Texte qui n’est pas du JSON | écartée, comptée (malformedFramesDiscarded), une notice -32700 |
idem — onBinary ne lit que le binaire |
La notice part à la PREMIÈRE trame de chaque nature, jamais à chaque trame : un flux à 60 Hz ne
devient pas soixante notices par seconde. En sortie, sendBinary(octets) émet sans sérialiser
(un Uint8Array) et suit la règle de send : transport fermé → false, compté dans
framesUnsent (client/realtime/NodefonySocket.ts#NodefonySocket.onBinary,
client/realtime/NodefonySocket.ts#NodefonySocket.sendBinary).
Les octets ont le même type des deux côtés : onBinary livre un Uint8Array, comme
RealtimeController.onRealtimeBinary côté serveur (où c’est un Buffer, qui en est un). Un
décodeur s’écrit donc une fois. Le transport du navigateur pose une vue sur l’ArrayBuffer reçu,
sans copier les octets. Pour un DataView, passer la tranche entière —
new DataView(data.buffer, data.byteOffset, data.byteLength) — car un Buffer de Node ne
commence pas à 0 dans son tampon.
onBinary est un point d’extension, pas un protocole : la corrélation d’une trame (quel canal,
quelle requête) appartient alors à l’application, par exemple par un en-tête qu’elle définit.
Ce que le client alloue — et ce qu’il n’alloue pas#
Un onglet ouvert huit heures ne pardonne pas les allocations gratuites. Les choix visibles au code :
- Le journal de protocole est différé. Chaque trame est poussée dans un anneau borné à 300
entrées sous forme de référence brute ; la mise en forme et le masquage des secrets ne sont
faits qu’à la lecture —
recordFrame()(client/realtime/NodefonySocket.ts#NodefonySocket.recordFrame). Un inspecteur qu’on n’ouvre jamais ne coûte donc presque rien. - Les secrets ne transitent pas en clair dans l’inspecteur.
redactFrame()(client/realtime/NodefonySocket.ts#redactFrame) remplace toute clé ressemblant à un jeton, un mot de passe ou une autorisation, avec une profondeur bornée. - Ce qui n’a pas servi n’existe pas. L’anneau de trames, l’identité et les capacités annoncées
démarrent à
nullet ne sont alloués qu’au premier usage. - L’échantillonnage de débit ne retient pas le processus. Le minuteur de statistiques est
déréférencé (
client/realtime/NodefonySocket.ts#NodefonySocket.request) — utile quand le même code tourne dans un test Node.
⚡ Cadence adaptative — une capacité du CLIENT#
Voici le point le plus contre-intuitif de la lib, et son meilleur argument : c’est le navigateur qui décide à quel rythme il veut être servi, sans que le serveur n’apprenne quoi que ce soit.
Le principe repose sur une convention de nommage : la cadence vit dans le nom du canal
(stats à 1 s, stats:2000 à 2 s…). Un canal = une cadence = un ticker serveur. Changer de cadence
revient donc à changer de canal, opération que le client sait faire tout seul.
AdaptiveRate (client/realtime/AdaptiveRate.ts#AdaptiveRate) est la machine à états pure qui prend la
décision — aucun minuteur, aucune socket, donc parfaitement déterministe et testable. Elle applique la
loi AIMD de TCP :
| Situation observée | Décision | Rythme |
|---|---|---|
| L’écart entre trames dépasse 1,8 × la cadence (famine) | on grossit d’un cran l’échelle | immédiat |
| L’écart reste sous 1,25 × la cadence, 4 fois de suite | on affine d’un cran | lent |
| Entre les deux (bande morte) | on ne change rien | — |
L’asymétrie est délibérée : on recule vite sous pression, on revient doucement. C’est exactement ce qui empêche l’oscillation, et la bande morte empêche le va-et-vient autour du seuil.
Deux détecteurs se complètent. AdaptiveRate.noteFrame()
(client/realtime/AdaptiveRate.ts#AdaptiveRate.noteFrame) mesure l’écart à chaque trame reçue ; mais si le flux
s’arrête complètement, il n’y a plus de trame pour déclencher quoi que ce soit — d’où le chien de
garde AdaptiveRate.checkStarvation() (client/realtime/AdaptiveRate.ts#AdaptiveRate.checkStarvation), appelé
périodiquement, qui voit la famine totale.
Le câblage à une socket réelle est séparé : bindAdaptiveChannel()
(client/realtime/AdaptiveRate.ts#bindAdaptiveChannel) s’abonne au nouveau canal avant de couper l’ancien, ce
qui évite le trou de quelques centaines de millisecondes qu’on obtiendrait dans l’ordre inverse.
// Un tableau de bord d'état : on VEUT 1 s, on ACCEPTE de reculer si ça souffre.
const binding = socket.adaptiveChannel(
"nodefony:dashboard",
(payload) => render(payload),
{
intervalMs: 1000,
onRate: (ms, reason) => setBadge(`${ms} ms (${reason})`),
},
);
// …plus tard
binding.dispose(); // coupe le chien de garde, l'écouteur ET l'abonnement
🛑 Prudence
Réservé aux canaux d’état (latest-wins : supervision, statistiques), où sauter une valeur intermédiaire est sans conséquence. Sur un canal d’événements (journal, messages de chat), chaque élément compte : décimer perdrait des données. Ces flux-là se regroupent, ils ne se décime pas. Le réglage
enabled: false(client/realtime/AdaptiveRate.ts#bindAdaptiveChannel) bascule en abonnement fixe sans changer le code appelant.
📡 NodefonySse — un flux serveur, et ses quatre liaisons de vue#
La socket est un téléphone, le flux SSE une radio : le serveur parle, le navigateur écoute. Côté
serveur, une action ouvre le flux par this.renderSse() — tout est dans la page
Flux SSE. Côté navigateur, NodefonySse
(client/sse/NodefonySse.ts#NodefonySse) parle le vocabulaire d’EventSource et y ajoute method,
headers et body.
Une différence avec la socket, et elle est voulue : un flux n’est pas partagé. C’est la
réponse à UNE requête — ses en-têtes et son Last-Event-ID lui appartiennent. Chaque composant
ouvre donc le sien, et le doit FERMER à son démontage : un flux oublié est une requête HTTP qui
reste ouverte, que personne ne lit.
C’est la seule raison d’être des liaisons. Toutes s’appuient sur observeSse()
(client/sse/observe.ts#observeSse), qui ouvre le flux, rend son état et le ferme à la libération ;
aucune n’écrit d’ouverture de flux elle-même.
| Front | Fonction | Rend | Fermé quand |
|---|---|---|---|
| React | useNodefonySse(url, options) |
SseSnapshot |
le composant est démonté |
| Vue | useNodefonySse(url, options) |
Readonly<Ref<SseSnapshot>> |
la portée meurt (onScopeDispose) |
| Angular | injectNodefonySse(url, options) |
Signal<SseSnapshot> |
le contexte d’injection est détruit |
| Svelte | nodefonySse(url, options) (.current) |
Reactive<SseSnapshot> |
le dernier effet qui la lit est détruit |
L’état, SseSnapshot, porte trois champs : readyState (0 CONNECTING, 1 OPEN, 2 CLOSED),
lastEvent (le dernier événement reçu, null avant le premier) et error (la dernière transition
était une coupure). CONNECTING avec error : le client se reconnecte. CLOSED avec error : le
serveur a refusé le flux (401, 403, autre type de contenu) et le client a abandonné.
Les options sont celles de NodefonySse, plus deux :
events— les types écoutés (le champevent:du serveur). Défaut :["message"]. Un flux qui nomme ses événements n’est entendu que si leur type est listé, comme avecaddEventListener.onEvent— appelé pour CHAQUE événement. L’état ne garde que le dernier : un journal s’accumule ici, sans quoi React, qui regroupe les rendus d’un même tour, en perdrait.
Une adresse null n’ouvre rien : la vue suspend l’écoute sans se démonter. Le flux n’est rouvert
que si l’adresse ou les types écoutés changent (sseRebindKey(), client/sse/observe.ts#sseRebindKey) —
un onEvent recréé à chaque rendu ne coupe rien.
// React
const [lignes, setLignes] = useState<string[]>([]);
const flux = useNodefonySse("/progress/run", {
events: ["step"],
onEvent: (e) => setLignes((l) => [...l, e.data]),
});
// Vue — l'adresse peut être une ref : le flux la suit
const lignes = ref<string[]>([]);
const flux = useNodefonySse("/progress/run", {
events: ["step"],
onEvent: (e) => lignes.value.push(e.data),
});
// Angular — dans un composant ou un service
readonly lignes = signal<string[]>([]);
readonly flux = injectNodefonySse("/progress/run", {
events: ["step"],
onEvent: (e) => this.lignes.update((l) => [...l, e.data]),
});
<!-- Svelte — le flux s'ouvre au premier `.current` lu -->
<script lang="ts">
let lignes = $state<string[]>([]);
const flux = nodefonySse("/progress/run", {
events: ["step"],
onEvent: (e) => lignes.push(e.data),
});
</script>
<p>état : {flux.current.readyState}</p>
Deux écarts entre les fronts, et ce sont ceux de la socket. Angular ouvre le flux hors zone
(client/angular/index.ts#injectNodefonySse) : avec zone.js, chaque morceau lu par fetch relancerait une
détection de changements globale. Svelte est paresseux : une valeur créée mais jamais lue
n’ouvre aucun flux, et onEvent ne court que tant qu’elle est affichée.
🔑 NodefonyLogin — la connexion, et ses quatre liaisons#
Le serveur sert toute l’API de connexion : mot de passe, second facteur (le code TOTP à six
chiffres), passkey, fournisseurs externes (Keycloak, Google, GitHub…). Ce qui manquait, c’est
le déroulé côté navigateur : identifiant, puis mot de passe, puis code si le compte en exige
un ; le blocage après trop d’essais ; le bouton de chaque fournisseur. NodefonyLogin
(client/auth/NodefonyLogin.ts#NodefonyLogin) porte ces règles, sans aucune interface : le balisage et
le design restent entièrement à l’application.
import { NodefonyLogin } from "nodefony/client";
const login = new NodefonyLogin();
login.subscribe((state) => render(state)); // un objet NEUF à chaque changement
await login.login("admin", "secret"); // ou submitIdentifier() puis submitPassword()
if (login.getState().step === "mfa") await login.submitMfaCode("123456");
L’état, NodefonyLoginState, se lit en une ligne :
step—identifier→password→mfa→authenticated. Un compte sans second facteur sautemfa. Aucune session n’est ouverte avantauthenticated: le serveur répond202au mot de passe d’un compte protégé, et ne pose le cookie qu’au bon code. Ce202est un défi à liste ouverte :mfaMethodsn’en garde que ce que le client sait conduire (totp), et un défi sans méthode connue reste àpasswordavec une erreurserver.pending— une requête est en vol ; une seconde action est ignorée (double clic).error— l’échec de la dernière action, classé pour que l’écran sache quoi dire :credentials(refus, 401 ou 403),throttled(trop d’essais, 429),network(aucune réponse),server(5xx),cancelled(invite de passkey refermée),unsupported(le navigateur ne sait pas). Unthrottledporte son échéanceretryAt; tant qu’elle n’est pas passée, aucune requête ne part. Un 403 dontmessagevautINSECURE_TRANSPORT_MESSAGE(exporté parnodefony/client) n’est pas un mauvais mot de passe : en production, le serveur refuse un secret qui arrive en HTTP, avant de le vérifier. L’écran doit le dire (« la connexion doit passer par HTTPS ») — « mot de passe incorrect » ferait recommencer, et renvoyer le secret en clair. La page de connexion du framework le fait.providers— les fournisseurs configurés, chargés parloadProviders();nullavant.passkeyAvailable— le navigateur sait-il signer ? Constaté : il fautPublicKeyCredential.parseRequestOptionsFromJSONetnavigator.credentials.get(client/auth/NodefonyLogin.ts#nativePasskeyAgent), pas seulement l’existence dePublicKeyCredential.
Les actions : submitIdentifier, submitPassword, login (les deux d’un coup, pour un
formulaire d’un seul écran), submitMfaCode, loginWithPasskey, loadProviders,
startProvider(nom, from?, back?) (navigation PLEINE PAGE, jamais un fetch : le navigateur doit
suivre les redirections du fournisseur ; back, l’écran où revenir si la connexion n’aboutit pas,
vaut par défaut la page courante), me() pour reprendre une session, logout(), back().
Trois choses que la classe ne fait pas, et c’est délibéré :
- Elle ne redirige jamais après la connexion. Elle rend
authenticated; c’est l’appelant qui navigue. - Elle ne reformule pas un refus. Le message du 401 est uniforme côté serveur et relayé tel quel : le préciser dans l’écran dirait à un inconnu si le compte existe.
- Elle ne mémorise rien. Le dernier compte utilisé, s’il faut s’en souvenir, se passe en
option (
identifier) ; où le garder est une décision de l’application.
logout() rend l’adresse de déconnexion du fournisseur quand la session venait de lui : il faut
la suivre pour fermer AUSSI la session Keycloak, sans quoi le prochain clic sur « Keycloak »
reconnecte sans rien demander.
Les chemins appelés sont des constantes du cœur (runtime/authRoutes.ts#AUTH_API_BASE) que le module
framework utilise aussi pour MONTER les routes : client et serveur ne peuvent pas diverger.
Chaque liaison ne fait que relayer l’état par observeLogin()
(client/auth/NodefonyLogin.ts#observeLogin) — aucune règle n’y est réécrite. Elles rendent toutes
{ state, flow } : l’état dans la réactivité du front, et l’instance qui porte les actions.
| Front | Fonction | state |
Libéré quand |
|---|---|---|---|
| React | useNodefonyLogin(options) |
NodefonyLoginState |
le composant est démonté |
| Vue | useNodefonyLogin(options) |
Readonly<Ref<NodefonyLoginState>> |
la portée meurt (onScopeDispose) |
| Angular | injectNodefonyLogin(options) |
Signal<NodefonyLoginState> |
le contexte d’injection est détruit |
| Svelte | nodefonyLogin(options) |
Reactive<NodefonyLoginState> |
le dernier effet qui la lit est détruit |
// React — le second facteur n'est qu'une branche de plus du rendu
const { state, flow } = useNodefonyLogin();
if (state.step === "mfa") {
return (
<CodeForm
pending={state.pending}
onCode={(c) => void flow.submitMfaCode(c)}
/>
);
}
Une page qui n’a pas besoin de SON formulaire renvoie à la page de connexion du framework, et
garde la liaison pour dire l’état de la session. C’est ainsi que sont écrites les pages générées
par nodefony create app --preset complete (les quatre fronts) :
import { useNodefonyLogin } from "nodefony/react";
import { loginPagePath } from "nodefony/client";
const { state, flow } = useNodefonyLogin();
useEffect(() => void flow.me(), []); // reprend la session, au retour de /login aussi
return state.user ? (
<button onClick={() => void flow.logout()}>Se déconnecter</button>
) : (
<a href={loginPagePath(location.pathname + location.search)}>Se connecter</a>
);
loginPagePath(from?, page?) compose /login?from=… (/ ne voyage pas) ; page suit une page
de connexion déplacée (security.loginPage.path), que le navigateur ne peut pas deviner.
🧠 NodefonyKernel — le noyau client, et ses quatre liaisons#
La socket parle au serveur ; le noyau tient l’application. NodefonyKernel
(client/NodefonyKernel.ts#NodefonyKernel) compose les services techniques d’une application front — la
socket, le journal, le cycle d’identité — et porte leur cycle de vie dans le navigateur. Il ne
possède jamais le rendu, le routage ni l’état métier : React, Vue, Angular et Svelte restent
maîtres de la vue. Il se construit par new, comme NodefonySocket et NodefonySse — jamais un
singleton de module, qu’un rechargement à chaud dédoublerait.
import { NodefonyKernel } from "nodefony/client";
const kernel = new NodefonyKernel({
connectOnBoot: false, // socket authentifiée : elle s'ouvre au login
realtime: { url: "/api/live/realtime" },
});
await kernel.boot();
kernel.setIdentity({ key: user.id }); // ouvre la socket, ou la renégocie au changement de compte
setIdentity() porte une règle de sécurité : sur un vrai changement de compte, et lui seul, le
noyau coupe la socket et en rouvre une — sans cela le pont api.request rejouerait des requêtes
avec le jeton du compte précédent. Une clé inchangée (profil rafraîchi) ne touche à rien. Le
contrat complet est INodefonyKernel (client/INodefonyKernel.ts#INodefonyKernel) ; le registre de services
NodefonyKernelServices (client/INodefonyKernel.ts#NodefonyKernelServices) s’étend par augmentation de module,
comme le registre de configuration côté serveur. L’ADR-0007 en donne les décisions.
Un composant profond doit pouvoir l’atteindre — déclarer une connexion, lire l’état du noyau,
suivre l’identité — sans que l’application écrive son propre contexte. C’est le rôle de la
troisième source des fournisseurs, kernel, à côté de client et url. La socket se prend alors
dans kernel.get("realtime"), et son cycle reste au noyau : le fournisseur ne l’ouvre ni ne la
ferme. La précédence kernel > client > url s’écrit une fois, dans connectShared()
(client/realtime/observe.ts#connectShared) ; un noyau composé sans socket (realtime: false) cède la place
à client, puis à url.
| Front | Fournir | Lire le noyau | Son état | Son identité |
|---|---|---|---|---|
| React | <NodefonyProvider kernel={kernel}> |
useNodefonyKernel() |
useNodefonyKernelState() |
useNodefonyKernelIdentity() |
| Vue | app.use(nodefonyVue, { kernel }) |
useNodefonyKernel() |
useNodefonyKernelState() (ref) |
useNodefonyKernelIdentity() (ref) |
| Angular | provideNodefony({ kernel }) |
injectNodefonyKernel() |
injectNodefonyKernelState() |
injectNodefonyKernelIdentity() |
| Svelte | configureNodefony({ kernel }) |
nodefonyKernel() |
nodefonyKernelState() |
nodefonyKernelIdentity() |
Sans noyau fourni, les trois rendent null — une application qui n’en compose pas n’a rien à
changer. L’état suit le cycle created → booting → ready → terminated ; l’identité est celle
que l’application a DÉCLARÉE par setIdentity(), et elle suit les changements de compte, pas
de profil. Ne pas la confondre avec useNodefonyIdentity(), l’identité RÉSOLUE par le serveur au
welcome de la socket. Toutes s’appuient sur observeKernelState() et observeKernelIdentity()
(client/realtime/observe.ts#observeKernelIdentity), et libèrent leurs écouteurs au démontage — y compris après un
terminate(), sur lequel on et off sont inertes.
// React — un bouton de déconnexion, n'importe où sous le fournisseur
function Deconnexion() {
const kernel = useNodefonyKernel();
const identite = useNodefonyKernelIdentity();
if (!kernel || !identite) return null;
return (
<button onClick={() => kernel.setIdentity(null)}>
Quitter {identite.key}
</button>
);
}
Les vitrines de démonstration restent sans noyau, par décision (ADR-0007 D7) : un noyau vide n’y
démontrerait rien. La console d’administration est son premier consommateur réel — elle passe
kernel={rootStore.kernel} à son fournisseur.
📤 Les journaux de l’onglet, remontés au pod#
Une erreur JavaScript chez un utilisateur est invisible depuis le serveur : elle vit et meurt dans son onglet. Le client peut la faire remonter dans le journal du pod, à côté de la ligne de la requête qui l’a provoquée — trois appels, posés une fois au démarrage de la page.
import {
NodefonySocket,
Syslog,
installErrorCapture,
installRequestIdProvider,
installSyslogUplink,
withRequestId,
} from "nodefony/client";
const socket = NodefonySocket.shared({ url: "/api/live/realtime" });
const journal = new Syslog({ moduleName: "shop-front" });
installRequestIdProvider(); // sans lui, withRequestId ne pose rien
installErrorCapture({ syslog: journal }); // erreurs et rejets non rattrapés
installSyslogUplink({ syslog: journal, publisher: socket });
// Le requestId n'est SU que dans le traitement d'une réponse :
const res = await fetch("/api/orders");
withRequestId(res.headers.get("x-request-id") ?? undefined, () => {
journal.log("commande refusée", "ERROR", "checkout");
});
Chaque install… rend sa fonction de retrait — à appeler au démontage. Le transport
(client/syslog/uplink.ts#installSyslogUplink) met les entrées en tampon et les pousse par lots,
toutes les deux secondes ou par cinquante ; rien n’est alloué tant qu’aucune entrée n’est retenue.
Ce qu’il faut savoir avant de chercher une ligne qui n’arrive pas :
- Le serveur doit l’ouvrir.
clientLogs.enableddu module@nodefony/realtimevautfalsepar défaut ; fermé, le pod n’a aucun récepteur pour le canal, et une notification sans récepteur est ignorée sans un mot (RealtimeController.ts#RealtimeController.onRealtimeNotification). Voir la configuration du temps réel. - Il faut une connexion authentifiée. Avec
@nodefony/security, le canal montant exige une identité, sans rôle particulier (frameAuthorizer.ts#UPLINK_CHANNEL_POLICY) : les erreurs d’un visiteur anonyme ne remontent pas — celles de la page de connexion, notamment. - Seul le grave part, par défaut. Le client ne retient que jusqu’à WARNING (option
maxSeverity) ; le pod, de son côté, ramène à ERROR tout ce qui se prétend plus grave : un onglet ne déclenche pas une alerte d’exploitation. - Le pod ne croit pas l’onglet. Le
moduleNameenvoyé est remplacé par l’origine « navigateur », le débit et la taille sont bornés par connexion (syslogUplink.ts#createSyslogUplinkHandler). LerequestId, lui, est déclaré par le client — validé en forme, jamais en fond : une piste de diagnostic, pas une preuve. withRequestIdne vaut que le tick synchrone. Après unawait, la portée est refermée (client/syslog/context.ts#withRequestId) — le navigateur n’a pas de stockage de contexte comme le serveur. Hors de la portée, l’entrée porte l’identifiant de la page (getPageId()), qui relie les lignes d’un même onglet.- Un lot perdu reste perdu. Socket fermée ou en reconnexion : le lot en cours est abandonné, jamais remis en file. Un journal n’est pas une file de messages garantie.
🔐 Les rôles isomorphes — de l’ergonomie, jamais une garantie#
Le subpath nodefony/roles fournit des fonctions pures, sans état ni dépendance, utilisables
telles quelles des deux côtés.
| Outil | Ancre | Coût | Quand l’employer |
|---|---|---|---|
hasRole() |
client/roles/roles.ts#hasRole |
zéro allocation, parcours linéaire | Un contrôle ponctuel |
hasAnyRole() |
client/roles/roles.ts#hasAnyRole |
idem | « L’un de ces rôles suffit » |
hasAllRoles() |
client/roles/roles.ts#hasAllRoles |
idem | « Tous ces rôles sont requis » |
RoleSet |
client/roles/roles.ts#RoleSet |
1 allocation, puis O(1) | Contrôles répétés sur le même utilisateur |
RoleRegistry |
client/roles/registry.ts#RoleRegistry |
masques binaires, O(1) sans alloc | Ensemble fixe de rôles, chemin chaud |
Deux conventions à connaître, parce qu’elles surprennent au premier essai :
hasAnyRole()avec une liste d’exigences vide rendfalse— aucune exigence ne peut être satisfaite.hasAllRoles()avec une liste vide rendtrue, commeArray.every. Les deux sont cohérentes avec la logique, pas entre elles.RoleRegistryest plafonné à 31 rôles —ROLE_MASK_CAPACITY(client/roles/registry.ts#ROLE_MASK_CAPACITY) — parce que les opérateurs binaires de JavaScript travaillent sur des entiers 32 bits signés. Au-delà, on reste sur les chaînes. Il est aussi inadapté aux rôles créés dynamiquement en base : ils n’ont pas de bit fixe.
❗ Important
Ces contrôles sont de l’ergonomie, jamais une sécurité. Le navigateur exécute du code que l’utilisateur contrôle : masquer un bouton n’empêche personne d’appeler la route. L’autorité reste le serveur — le pare-feu applicatif et les jurys d’autorisation décident, et refusent, quoi qu’ait affiché le front. Le bon usage : ne pas montrer une action qui sera de toute façon refusée. Voir la sécurité du temps réel.
🧩 La barre de debug, le stockage et les shims#
Trois briques secondaires, qu’on touche rarement — mais qu’il vaut mieux connaître avant de les croiser.
nodefony/debugbarvoir le framework respirer#Une barre de développement type « bandeau de profilage », montée par mountDebugBar()
(client/debugbar/index.ts#mountDebugBar). Elle ouvre la même socket temps réel que le reste de la page,
intercepte les appels réseau et affiche la cascade des phases serveur d’une requête. Rendue en Shadow
DOM, sans aucune dépendance d’interface.
En pratique tu ne l’appelles pas : en développement, @nodefony/frontend l’injecte lui-même dans la
page, en la résolvant depuis le paquet installé (ViteConfigGenerator.ts#ViteConfigGenerator.toMjs). Tu l’appelles à la
main uniquement sur une page hors Vite, où le bundle autonome fait office d’alternative.
Le subpath expose aussi son modèle pur — computeWaterfall()
(client/debugbar/profile.ts#computeWaterfall) et NetworkModel (client/debugbar/profile.ts#NetworkModel) — sans une
ligne de DOM. C’est ce qui permet à Studio d’afficher la même cascade dans son écran de profilage
sans recopier le calcul.
Les shims — pourquoi ton code Node compile dans un onglet#
Deux modules Node sont remplacés au build par des équivalents navigateur de quelques dizaines de
lignes : EventEmitter (client/shim/events.ts#EventEmitter) couvre exactement l’API que le bus d’événements
du cœur appelle réellement, et inspect (client/shim/util.ts#inspect) rend un affichage lisible via
JSON.stringify. La façade de couleur, elle, devient l’identité (client/shim/util.ts#styleText) : il n’y a
pas de terminal dans un navigateur, donc jamais de code d’échappement.
Tu ne les importes jamais — ils sont substitués au moment du build. Leur seul intérêt pour toi est de
savoir où sont les limites : ce sont des shims, pas des implémentations complètes. Le shim
d’événements ne distingue pas un écouteur « une fois » d’un écouteur ordinaire, et ne gère pas
l’événement error spécial de Node.
🏗️ Comment la lib arrive dans ton bundle#
Rien de spécial à configurer : @nodefony/frontend pilote Vite, et Vite résout nodefony/client
comme n’importe quel paquet du dossier node_modules. Trois points valent d’être connus.
- La condition
browserfait le tri. Dans un bundle navigateur,import … from "nodefony"résout le barrel client ; dans un fichier serveur, le barrel Node. Le même import, deux destinations — c’est le mécanisme central, et aussi la source du piège numéro un de cette page. - La barre de debug est autorisée explicitement. Vite refuse par défaut de servir un fichier hors
de la racine du projet. Le générateur de configuration ajoute le dossier de la lib cliente à la
liste autorisée (
ViteConfigGenerator.ts#ViteConfigGenerator.toMjs), sans quoi une application liée en développement verrait la barre répondre en refus d’accès. - En production, seul ce que tu importes part. Les sous-chemins (
client,roles,debugbar,react,vue,svelte,angular) sont des entrées séparées : ce que tu n’importes pas n’est pas dans le bundle.
Le détail du builder, du rechargement à chaud et du rendu de la page côté serveur est dans
la doc de @nodefony/frontend.
⚠️ Pièges (symptôme → cause → correction)#
| Symptôme | Cause (dans le code) | Correction |
|---|---|---|
Cannot find module 'nodefony/realtime' |
Ce subpath n’existe pas — le champ exports n’en déclare aucun sous ce nom |
Importer depuis nodefony/client |
has no exported member 'NodefonySocket' dans un fichier front |
Import depuis "nodefony" typé par la condition Node (outil sans condition browser) |
Importer explicitement depuis nodefony/client |
RealtimeIdentity introuvable à l’import |
Version antérieure : le type n’était réexporté ni par nodefony/client ni par nodefony/react |
Corrigé — import type { RealtimeIdentity } from "nodefony/client" (ou nodefony/react) |
| Le canal est silencieux, aucun message | on() installé sans subscribe() — le serveur ne pousse pas |
Appeler les deux (client/realtime/NodefonySocket.ts#NodefonySocket.subscribe) |
| Notice « Trame binaire reçue et écartée » | Le serveur émet du binaire, que le protocole JSON-RPC ne route pas (client/realtime/NodefonySocket.ts#NodefonySocket.binaryFramesDiscarded) |
Le lire par onBinary(handler), ou ne pas en émettre sur cette socket |
| Un composant démonté coupe le flux d’un autre | Attendu et déjà traité : les abonnements sont ref-comptés | Ne pas contourner l’API en émettant unsubscribe à la main |
| Après une reconnexion, plus rien n’arrive | Le serveur repart d’un état vide ; le client ré-émet ses abonnements | Comportement natif ; vérifier que l’abonnement passe bien par subscribe() |
| La reconnexion ne repart jamais | Fermeture définitive (1008 = 401/403 traduit), reconnexion volontairement coupée | Corriger la cause (se connecter) puis retryNow() (client/realtime/NodefonySocket.ts#NodefonySocket.retryNow) |
| Deux connexions WebSocket pour la même page | Deux new NodefonySocket(…) au lieu de l’instance partagée |
NodefonySocket.shared() (client/realtime/NodefonySocket.ts#NodefonySocket.shared) |
| Les trames envoyées juste après la connexion sont perdues | send() abandonne la trame tant que le transport n’est pas ouvert (client/realtime/NodefonySocket.ts#NodefonySocket.send) |
Émettre après la résolution de connect() |
| La cadence adaptative « perd » des messages | Employée sur un canal d’événements, où décimer supprime des éléments | La réserver aux canaux d’état, ou passer enabled: false |
hasAnyRole(roles, []) rend false et surprend |
Aucune exigence ne peut être satisfaite (client/roles/roles.ts#hasAnyRole) |
Convention assumée ; hasAllRoles avec une liste vide rend true |
RoleRegistry lève au 32ᵉ rôle |
Limite des entiers 32 bits signés (client/roles/registry.ts#RoleRegistry) |
Rester sur les chaînes / RoleSet au-delà de 31 rôles |
| Un bouton masqué au front reste appelable | Le RBAC client est de l’ergonomie, pas un contrôle | Protéger la route côté serveur — c’est lui l’autorité |
request("/x", { id: 3 }) ignore ses paramètres |
En forme chemin, le 2ᵉ argument est le délai d’attente (client/realtime/NodefonySocket.ts#NodefonySocket.request) |
Paramètres dans le chemin ou la requête ; un corps passe par mutate() |
| Une donnée mal formée traverse l’écran sans erreur | request<T>() convertit le type, il ne valide rien |
Valider au bord (Zod, déjà dépendance du framework) toute donnée sensible ou non maîtrisée |
| Les erreurs de la page n’arrivent pas au journal du pod | clientLogs.enabled fermé, visiteur anonyme, ou sévérité au-delà de maxSeverity |
Voir Les journaux de l’onglet |
| Le canal reste ouvert alors que l’écran a « coupé » le direct | Le composant abonné est resté monté ; aucune liaison n’a d’interrupteur | Démonter le composant abonné |
| L’interface affiche des rôles périmés après un changement | Rôles mémorisés à la connexion ; la socket s’est reconnectée (4005) avec les nouveaux | Lire l’identité par onIdentity() ou le hook du moteur |
Deux instances de React après ajout de nodefony/react |
React empaqueté au lieu d’être externe | Déjà traité au build (rolldown.config.ts#browserShim) ; vérifier ses propres alias |
🧪 Tests & couverture#
Le client isomorphe est couvert par 203 cas répartis sur 16 fichiers, tous unitaires et tous exécutés hors navigateur — c’est l’injection de transport qui le permet.
- La machine à états et le transport : ouverture, reconnexion à délai croissant, battement de
cœur, transport factice, distinction transitoire/définitif
(
NodefonySocketTransport.test.ts,NodefonySocketCoverage.test.ts). - Le routage des trames : classification requête/notification/réponse, dispatch vers les
écouteurs, joker, ingestion de la trame de bienvenue (
NodefonySocketDispatch.test.ts,NodefonySocketIdentity.test.ts). - Le protocole lui-même : corrélation d’identifiants, délais, erreurs, flux en morceaux, actions
entrantes, trames limites (
JsonRpcPeer.test.ts— 42 cas, le plus fourni) ; la classification d’une trame et les fabriques, partagées avec le serveur MCP, confrontées à la spécification JSON-RPC 2.0 exigence par exigence (jsonrpc.test.ts). - Les types comme test : deux fichiers vérifient des propriétés de typage, dont l’inférence
de
paramset du résultat derequestdepuis le contratActions(NodefonySocket.types.test.ts,JsonRpcPeer.types.test.ts). - Les notices et les codes de fermeture : la table RFC 6455 cas par cas, le silence sur les
fermetures propres, la reconnectabilité (
RealtimeNotice.test.ts— 19 cas). - La cadence adaptative : décrue immédiate, reprise après fenêtre, bande morte, chien de garde,
échelle dérivée — avec horloge et planificateur injectés (
AdaptiveRate.test.ts— 17 cas). - Les rôles (
roles.test.ts), le shim d’événements (clientShimEvents.test.ts), la barre de debug et son modèle de profilage (DebugBar.test.ts,Profiler.client.test.ts).
Ce qui manque, et qu’il faut savoir :
- Aucun test de bout en bout dans un vrai navigateur. Tout est exercé en Node avec un transport
factice. Les comportements réellement navigateur — normalisation d’URL par
window.location,sessionStorage, montage en Shadow DOM — ne sont donc pas couverts par une exécution réelle. - Aucun banc de charge ni de mémoire côté client. Les choix d’allocation décrits plus haut sont
lisibles au code mais ne sont pas mesurés par une suite : pas d’équivalent client au gate mémoire du
pipeline serveur (skill
nodefony-check-memory-health). - Aucun test d’attaque (
*.attack.test.ts) sur le masquage de l’inspecteur de trames — un candidat naturel pour une campagnenodefony-security-review.
Couverture : npm run coverage dans src/nodefony. Les chiffres exacts vivent dans le rapport
vitest, jamais figés ici.
🔗 Pour aller plus loin#
- ⬆️ Retour au hub : @nodefony/core — vue d’ensemble · Toute la documentation
- 🧭 Pages sœurs : la liaison de ton moteur — Hooks React · composables Vue ·
réactivité Svelte · services Angular — les bindings qui câblent tout
ça dans un composant · Service et événements — la classe de base, isomorphe elle
aussi · Journalisation — le
Syslogqui tourne aussi dans l’onglet - Le serveur en face : la socket Nodefony · son architecture · son vocabulaire
- Un exemple complet de bout en bout → Cookbook — un chat
- Comment le bundle arrive au navigateur →
@nodefony/frontend