Nodefony 10.0.0-beta.6
Framework Node.js fullstack — HTTP & WebSocket, même contexte

Protocole — la grammaire des frames JSON-RPC 2.0

stable@nodefony/realtimemis à jour 2026-07-20

Une connexion temps réel Nodefony ne transporte pas « des messages » : elle transporte des frames dont la forme est normée. Trois formes existent, et une seule règle les distingue — la nature d’une frame se lit sur method, jamais sur id. Cette page donne la grammaire champ par champ, les méthodes que le serveur comprend nativement, et ce qui revient exactement quand ça rate. Le moteur est JsonRpcPeer (JsonRpcPeer.ts#JsonRpcPeer), écrit une fois et branché des deux côtés du fil.

📍 Documentation › Realtime › Protocole

🧠 Schéma général — trois formes, un seul moteur#

Le pair classe chaque frame entrante avant toute chose. Ce classement — le dispatch — est le cœur du protocole : il décide s’il faut répondre, à qui, et si une réponse est même possible.

Deux conséquences que le reste de la page décline :

  1. Une notification ne peut pas échouer visiblement — sans id, il n’existe aucun canal de réponse. C’est pourquoi Nodefony ajoute une notification de refus (voir plus bas).
  2. Le protocole est symétrique : le serveur appelle le client exactement comme le client appelle le serveur. Ce n’est pas un aller-retour déguisé, c’est du duplex.

📖 Lexique#

Terme Sens
JSON-RPC 2.0 Norme publique d’appel de procédure à distance en JSON, publiée en 2010, symétrique.
Frame L’unité atomique qui passe sur le fil : un objet JSON conforme à la norme.
Pair (peer) Le moteur de protocole d’une connexion — JsonRpcPeer, identique client et serveur.
Requête Frame avec method et id — appelle exactement une réponse.
Notification Frame avec method seul — aucune réponse, jamais.
Réponse Frame avec id sans method, portant result ou error bien formé.
Dispatch Classer une frame entrante puis la router vers son handler.
Enveloppe Réponse qui joint des métadonnées serveur dans un champ frère du result.
Accueil, welcome La 1ʳᵉ notification poussée par le serveur : protocole, canaux, actions, identité.
Canal entrant Canal où le client a le droit de pousser ; le nom du canal est le nom de méthode.
Zero Trust Ici : un échec non voulu ne révèle rien au pair (message générique, détail au log).
RTT Round-Trip Time — le temps d’un aller-retour complet sur la socket.

Qu’est-ce qu’un protocole de frames — et pourquoi JSON-RPC 2.0 ?#

Multiplexer N flux dans une connexion oblige à étiqueter chaque message : sans étiquette, le récepteur ne sait pas si l’octet qui arrive est un message de chat, une réponse attendue, ou un battement de cœur. Cette étiquette, c’est le protocole.

Nodefony ne l’a pas inventée. Le calcul, mis à plat :

Critère JSON-RPC 2.0 Format maison
Spécifié, stable oui, depuis 2010 à inventer et à maintenir
Bidirectionnel natif — la norme ne distingue pas client à coder, puis à défendre en revue
Batch (plusieurs ops/frame) prévu par la norme (tableau de frames) à coder
Outillage inspecteurs et bibliothèques existants zéro
Coût mental quasi nul, une frame se lit à l’œil toujours plus, et jamais documenté
Erreurs codes réservés, plage applicative dédiée chacun réinvente sa table de statuts

ℹ️ Note

Le batch est une propriété de la norme, pas du moteur Nodefony. JsonRpcPeer.receive() (JsonRpcPeer.ts#JsonRpcPeer.receive) attend un objet portant jsonrpc: "2.0" ; un tableau de frames n’en porte pas et tombe donc en invalid. Une frame = un objet. Le multiplexage rend le batch peu utile ici : les canaux voyagent déjà dans la même connexion.

Le multiplexage, lui, n’est PAS dans la norme. C’est Nodefony qui le construit par-dessus, en se servant de method comme aiguillage : subscribe, unsubscribe, le nom d’un canal, le nom d’une action. La norme fournit la grammaire ; le vocabulaire est nodefonien.

La vision Nodefony — un moteur écrit une fois, branché des deux côtés#

Trois partis pris distinguent cette implémentation d’un simple « serveur WebSocket qui parle JSON ».

Le même fichier tourne dans le navigateur et dans Node. JsonRpcPeer (JsonRpcPeer.ts#JsonRpcPeer) n’a aucune dépendance Node — seulement setTimeout. Classer, router, corréler les id : ce travail est identique des deux côtés, il est donc écrit une seule fois. Chaque côté l’entoure de son transport et de ses handlers. Historiquement, cette discrimination vivait à deux endroits qui divergeaient ; c’est précisément la classe de bug que l’isomorphisme supprime. Le classement lui-même est classifyJsonRpcFrame (jsonrpc/index.ts#classifyJsonRpcFrame), la brique que partage aussi le serveur MCP du framework : les deux portes ne peuvent plus diverger sur ce qu’est une frame valide.

Le rôle se lit sur method, pas sur id. La lecture naïve (« id présent = requête ») casse sur les réponses, qui portent un id sans être des appels. JsonRpcPeer.receive() (JsonRpcPeer.ts#JsonRpcPeer.receive) teste donc method d’abord, puis la présence d’un id — chaîne ou nombre : un id null, objet ou booléen rend la frame invalid, il n’en fait pas une notification. Une frame id sans method, portant result ou une error bien formée, est une réponse et ne déclenche jamais -32601 à tort ; sans l’un ni l’autre, elle est invalid.

Un échec ne raconte rien par défaut. Un handler qui lève une exception ordinaire produit un -32603 "internal error" générique (JsonRpcPeer.ts#JsonRpcPeer.handleRequest) : ni message d’origine, ni pile d’appels. Pour exposer volontairement un refus au pair — un 404, un droit manquant — il existe une porte explicite, RpcError (JsonRpcPeer.ts#RpcError), et elle seule.

🚀 Démarrage rapide#

Un endpoint qui expose une action RPC et un client qui l’appelle, vus d’une application créée par nodefony create app. C’est le plus court chemin pour voir les trois formes de frame.

Le contrôleur — une action, deux façons d’échouer#

// nodefony/controllers/ChatController.ts
import { controller, route } from "@nodefony/framework";
import {
  RealtimeController,
  RealtimeAction,
  RealtimeChannel,
} from "@nodefony/realtime";
import type { RealtimePublish } from "@nodefony/realtime";
import type { ContextType } from "@nodefony/http";
import { RpcError } from "nodefony";

interface ChatHistory {
  messages: string[];
}

@controller("/chat")
class ChatController extends RealtimeController {
  constructor(context: ContextType) {
    super("chat", context);
  }

  @route("chat-realtime", {
    path: "/realtime",
    requirements: { methods: ["WEBSOCKET"] },
  })
  async realtime(message: string | Buffer | null): Promise<void> {
    this.handleRealtime(message);
  }

  // Un CANAL : son nom sera annoncé dans l'accueil, et servira de `method`
  // aux notifications que le serveur poussera vers les abonnés.
  @RealtimeChannel("chat:room-42")
  room(_channel: string, _publish: RealtimePublish): () => void {
    return () => {};
  }

  // Une ACTION : appelée par une REQUÊTE (method + id), la valeur rendue
  // devient le `result` de la réponse corrélée.
  @RealtimeAction("chat:history")
  history(params: unknown): ChatHistory {
    const room = (params as { room?: unknown } | undefined)?.room;
    // Refus ASSUMÉ : `RpcError` choisit ce que le pair voit (code + data).
    if (typeof room !== "string") {
      throw new RpcError("params.room manquant", -32602);
    }
    if (room !== "room-42") {
      throw new RpcError("salon inconnu", -32000, { status: 404 });
    }
    // Un throw ORDINAIRE ici deviendrait `-32603 internal error`, opaque.
    return { messages: ["salut"] };
  }
}

export default ChatController;

Le client — l’accueil, l’abonnement, l’appel corrélé#

// frontend/src/chat.ts
import { NodefonySocket, RpcError } from "nodefony/client";

interface ChatHistory {
  messages: string[];
}

export async function joinChat(): Promise<void> {
  const socket = new NodefonySocket({
    url: "wss://127.0.0.1:5152/chat/realtime",
  });
  await socket.connect();

  // L'ACCUEIL est déjà ingéré : la carte du territoire, sans taper une route.
  console.log(socket.serverMethods); // actions exposées par l'endpoint
  console.log(socket.serverChannels); // canaux annoncés
  console.log(socket.identity); // qui je suis, tel que le serveur l'a résolu

  // `on` REÇOIT (handler local), `subscribe` DEMANDE le flux au serveur.
  socket.on("chat:room-42", (payload: unknown) => console.log(payload));
  socket.subscribe("chat:room-42"); // UN seul argument — pas de callback ici

  try {
    // REQUÊTE corrélée. Signature POSITIONNELLE : (méthode, params, délai en ms).
    // Un objet `{ timeoutMs }` en 3ᵉ position ne serait PAS lu comme un délai.
    const page = await socket.request<"chat:history", ChatHistory>(
      "chat:history",
      { room: "room-42" },
      5000,
    );
    console.log(page.messages);
  } catch (err) {
    // Le refus assumé du serveur arrive typé : code ET data traversent.
    if (err instanceof RpcError) console.warn(err.code, err.message, err.data);
  }
}

Ce qu’on observe#

Sur le fil, dans l’inspecteur réseau du navigateur, exactement cinq frames :

// 1. serveur → client : l'accueil, aussitôt après le handshake
{"jsonrpc":"2.0","method":"realtime:welcome","params":{
  "ts":1770000000000,"protocol":"jsonrpc-2.0","env":"development",
  "channels":["chat:room-42"],"methods":["chat:history"],
  "identity":{"type":"anonymous","authenticated":false,
              "userIdentifier":"anonymous","roles":["ROLE_ANONYMOUS"],"scopes":[]}}}

// 2. client → serveur : notification, aucun `id`
{"jsonrpc":"2.0","method":"subscribe","params":{"channel":"chat:room-42"}}

// 3. client → serveur : requête, `id` attribué par le pair
{"jsonrpc":"2.0","id":1,"method":"chat:history","params":{"room":"room-42"}}

// 4. serveur → client : réponse corrélée par le même `id`
{"jsonrpc":"2.0","id":1,"result":{"messages":["salut"]}}

// 5. serveur → client : push sur le canal — le nom du canal EST la `method`
{"jsonrpc":"2.0","method":"chat:room-42","params":{"text":"salut"}}

L’accueil porte cinq champs — ts, protocol, channels, methods, identity (IRealtimeWelcome, RealtimeEventMap.ts#IRealtimeWelcome) — plus un sixième, env, uniquement hors production (welcomeEnv(), welcomeEnv.ts#welcomeEnv) : c’est lui que porte l’exemple ci-dessus, capturé en développement. Émis par le contrôleur au handshake (peer.notify("realtime:welcome"), RealtimeController.ts#RealtimeController.onHandshake). Il n’y a pas de champ version.

Un septième champ, codec, est réservé au codec de trame retenu pour les frames qui suivent (RealtimeEventMap.ts#IRealtimeWelcome.codec, ADR-0022 §2) : un des sous-protocoles que le client a offerts au handshake. Absent, il vaut nodefony.jsonrpc.v1 — JSON-RPC texte —, et le codec ne se déduit jamais de l’écho Sec-WebSocket-Protocol, que ws renvoie qu’il parle le sous-protocole ou non. Le client sait l’offrir et l’adopter (frameCodecNegotiation.ts#adoptFrameCodec) ; le serveur le choisit quand l’application active codec: "cbor", et renvoie ce choix dans le handshake.

Un huitième, bytes, dit qu’une connexion restée en texte reçoit les octets sous la convention {"$bytes":"<base64url>"} (RealtimeEventMap.ts#IRealtimeWelcome.bytes) : le serveur l’émet quand il a activé un codec binaire que le client n’a pas pris, et le client relit alors des Uint8Array sans rien importer. Absent : JSON natif. La valeur nomme la convention ; une valeur inconnue laisse le JSON natif.

🔌 Anatomie d’une frame, champ par champ#

Le tronc commun#

{
  "jsonrpc": "2.0", // signature de protocole — OBLIGATOIRE, sinon la frame est `invalid`
  "method": "subscribe", // ce qu'on demande — sa PRÉSENCE fait de la frame un appel
  "params": { "channel": "nodefony:orm:health" }, // la charge : objet ou tableau, jamais fiable côté serveur
  "id": 42, // PRÉSENT = requête (réponse due) · ABSENT = notification
}
Champ Obligatoire Type accepté Rôle exact
jsonrpc oui, toujours "2.0" littéral Sans lui, JsonRpcPeer.receive() classe invalid (JsonRpcPeer.ts#JsonRpcPeer.receive)
method sur un appel chaîne Décide de la nature ET de l’aiguillage (canal, action, verbe pub/sub)
params non objet ou tableau Charge applicative. Vient du réseau : jamais digne de confiance
id sur une requête / réponse nombre ou chaîne Corrèle l’aller et le retour

❗ Important

La charge d’un canal est un objet ou un tableau, ou absente. JSON-RPC 2.0 (§4.2) exige un params structuré : une bibliothèque JSON-RPC stricte, dans un autre langage, refuserait la trame d’un scalaire. publish lève donc une TypeError qui nomme le canal et le type reçu, à la ligne fautive, des deux côtés de la socket (payload.ts#isRealtimePayload) :

const temperature = 42;
hub.publish("capteur:temp", temperature); // TypeError — un scalaire
hub.publish("capteur:temp", { value: temperature }); // conforme
hub.publish("build:done"); // conforme : part sans `params`, arrive `undefined`

Des octets restent admis. Une charge absente arrive undefined chez l’abonné, quel que soit le bus entre les serveurs, scellé ou non.

❗ Important

Le pair accepte un id chaîne en entrée (conforme à la norme), mais il n’attribue jamais que des id numériques à ses propres appels sortants. Une réponse portant un id chaîne est donc ignorée sans erreur (JsonRpcPeer.handleResponse(), JsonRpcPeer.ts#JsonRpcPeer.handleResponse) : elle ne peut, par construction, correspondre à aucune requête émise par ce pair.

La réponse — result ou error, jamais les deux#

// succès — la valeur rendue par le handler, nue
{ "jsonrpc": "2.0", "id": 42, "result": { "messages": ["salut"] } }

// échec — un objet error normalisé ; `data` est optionnel et libre
{ "jsonrpc": "2.0", "id": 42, "error": { "code": -32601, "message": "method not found: chat:history" } }

Côté client, les deux formes sont converties en une seule chose : une Promise résolue avec le result, ou rejetée avec une RpcError qui préserve code et data. C’est ce qui permet de distinguer un 404 d’un refus d’autorisation sans analyser un message de texte.

L’enveloppe — joindre une méta sans polluer le result#

Un handler peut rendre une RpcEnvelope (JsonRpcPeer.ts#RpcEnvelope) au lieu d’une valeur nue. Le pair la déballe : le result reste exactement la valeur, et la méta voyage dans un champ frère meta (RpcMeta, JsonRpcPeer.ts#RpcMeta).

{
  "jsonrpc": "2.0",
  "id": 42,
  "result": { "…": "…" },
  "meta": { "requestId": "c7.12" },
}

Pourquoi ce détour plutôt qu’un champ de plus dans le result : le pont API garantit qu’une réponse obtenue par la socket est identique à ce que rendrait la même route en REST. Glisser une méta dans le result casserait cette égalité. Un pair qui ne connaît pas meta l’ignore — le champ est rétro-compatible par construction.

Des octets dans la charge — une frame comme les autres#

Une trame binaire routée est une notification JSON-RPC dont la charge est, ou contient, un Uint8Array ; le codec dit seulement comment il la porte sur le fil (ADR-0022 §3). Il n’existe pas de seconde grammaire, ni d’en-tête d’octets maison du type [n° de canal][charge] : un tel en-tête aurait demandé un second verrou et une seconde sonde, qui finissent toujours par diverger du premier.

// côté serveur — la même publication, quel que soit le codec de chaque abonné
const frame = new Uint8Array([0x01, 0x02, 0x03]);
hub.publish("capteur:raw", { seq: 42, frame });
Connexion de l’abonné Ce qui passe sur le fil Ce que reçoit on("capteur:raw")
CBOR trame binaire : frame en chaîne d’octets native (type majeur 2) { seq: 42, frame: Uint8Array }
texte, serveur en codec: "cbor" trame texte : "frame":{"$bytes":"AQID"} (base64url) { seq: 42, frame: Uint8Array }
texte, serveur en codec: "json" trame texte, sans convention d’octets : "frame":{"0":1,"1":2,"2":3} un objet déformé, plus un Uint8Array (jsonFrameCodec.ts#JSON_FRAME_CODEC) — d’où codec: "cbor" dès que des octets circulent

Parce que c’est la même frame, elle passe par les mêmes portes que le texte : verrou de frame, politique de canal, audit des refus, sonde du hub (sécurité). Le codec se négocie au handshake et s’annonce dans l’accueil (champs codec et bytes, voir plus haut) ; il se règle côté serveur par la clé codec, côté navigateur par l’option codec de la socket.

La règle du $bytes forgé. Sur une connexion texte, le récepteur relit en octets tout objet dont la seule clé est $bytes et dont la valeur est une chaîne. Un objet applicatif qui a exactement cette forme sans venir d’octets changerait donc de nature selon le codec de l’abonné : objet pour un client CBOR, octets pour un client texte. L’encodeur texte le refuse plutôt que d’envoyer une donnée qui serait mal relue (jsonFrameCodec.ts#JSON_BYTES_FRAME_CODEC) :

socket.publish("prix", { montant: { $bytes: "12 euros" } });
// TypeError : codec JSON : objet {"$bytes": …} non issu d'octets sous la clé "montant"
//   — le récepteur le relirait en octets ; envoyer un Uint8Array, ou ajouter une autre clé à l'objet

socket.publish("prix", { montant: { $bytes: "12 euros", devise: "EUR" } }); // conforme : deux clés

Le refus est préféré à un échappement (envelopper l’objet dans une seconde clé réservée) : échapper coûterait un parcours de chaque trame, et imposerait une seconde convention aux deux côtés du fil. Le refus ne coûte qu’une recherche de $bytes dans le texte déjà produit. Il ne vaut que sous la convention d’octets : avec codec: "json", $bytes est une clé comme une autre.

🧰 Les méthodes nominales#

Ce que le serveur comprend sans qu’aucun contrôleur ne l’ait déclaré, plus les deux formes ouvertes à l’application. Colonne id : présent = requête (réponse due), absent = notification.

Méthode Direction id ? Rôle Ancrage
subscribe client→server non « pousse-moi ce canal » — params.channel RealtimeController.ts#RealtimeController.onHandshake
unsubscribe client→server non « arrête » — dernier abonné, le producteur est libéré RealtimeController.ts#RealtimeController.onRealtimeNotification
ping client→server non Battement de cœur — no-op serveur, aucun pong NodefonySocket.ts#NodefonySocket.ping
<canal> server→client non Push d’un message : le nom du canal est la method du notify RealtimeController.ts#RealtimeController.startChannel
<canal entrant> client→server non Le client pousse sur un canal déclaré entrant RealtimeController.ts#RealtimeController.onRealtimeNotification
realtime:welcome server→client non L’accueil : 5 champs, dont l’identité résolue RealtimeController.ts#RealtimeController.onHandshake
realtime:denied server→client non Rend OBSERVABLE le refus d’une notification RealtimeController.ts#RealtimeController.onHandshake
realtime:subscribed server→client non Acquitte un subscribe ACCEPTÉ — le flux est actif RealtimeController.ts#RealtimeController.acknowledgeSubscription
api.request client→server oui Pont API — rejoue une route HTTP sur la socket (désactivé par défaut) RealtimeController.ts#RealtimeController.onHandshake
<action> client→server oui Toute action déclarée par @RealtimeAction realtimeDecorators.ts#RealtimeAction

💡 Astuce

L’accueil est ta carte du territoire. channels ne liste que les canaux que ce visiteur pourrait obtenir (filtrés par le même verrou que subscribe), methods les actions de l’endpoint (RealtimeController.ts#RealtimeController.onHandshake) : un client peut activer ou griser ses commandes sans rien coder en dur. Côté navigateur, ils se lisent en socket.serverMethods et socket.serverChannels.

Les quatre formes de frame circulent en permanence sous tes yeux — ce schéma les montre sur la

⚠️ Attention

nodefony:kernel:ping et nodefony:kernel:gc (StudioRealtimeController.ts#StudioRealtimeController.realtimeActions) sont des exemples d’actions, pas des méthodes du cœur temps réel : elles sont déclarées par le contrôleur d’administration de @nodefony/studio. Un endpoint applicatif ne les expose pas. Le helper NodefonySocket.ping() (NodefonySocket.ts#NodefonySocket.ping) mesure le RTT en les appelant — il suppose donc un endpoint qui les déclare, contrairement à la notification ping du battement de cœur, qui n’attend jamais de réponse.

subscribe et unsubscribe ne sont pas des actions enregistrées : elles sont traitées dans onRealtimeNotification() (RealtimeController.ts#RealtimeController.onRealtimeNotification). Envoyées avec un id, elles seraient classées « requête », ne trouveraient aucun handler et récolteraient un -32601.

Une conversation type, de bout en bout#

Les deux pushs intermédiaires n’ont ni id ni accusé de réception : ce sont des notifications, et c’est ce qui rend le pub/sub bon marché. Seule la frame id: 7 immobilise une entrée dans la table des appels en attente du client, jusqu’à sa réponse ou son expiration.

Quand ça rate — les codes d’erreur réellement émis#

Voici tout ce que le moteur produit. Les codes de la plage -32000…-32099 sont réservés par la norme aux erreurs applicatives serveur ; Nodefony y place son refus d’autorisation et le défaut de RpcError.

Code Émis par Déclencheur Ce que voit le pair
-32601 JsonRpcPeer.handleRequest() (JsonRpcPeer.ts#JsonRpcPeer.handleRequest) requête vers une action non enregistrée method not found: <nom>
-32603 même méthode (JsonRpcPeer.ts#JsonRpcPeer.handleRequest) le handler a levé une exception ordinaire internal error — générique, rien d’autre
-32001 le refus du verrou de frame (JsonRpcPeer.ts#JsonRpcPeer.receive) beforeDispatch a dit non sur une requête unauthorized, sans jamais dire pourquoi
-32000 défaut du constructeur de RpcError (JsonRpcPeer.ts#RpcError) le handler expose volontairement son refus le message ET le data choisis par le handler
-32602 le pont API, via RpcError (RealtimeController.ts#RealtimeController.actionParamsWrapper) api.request appelé avec un params.path invalide message explicite (l’appel est malformé)
-32000 le pont API (RealtimeController.ts#RealtimeController.invokeApiRequest) la route invoquée REFUSE : 401, 403, 404, 405, 422 le message, data.status — et data.fields sur un 422

Un refus de la route invoquée par le pont garde la forme d’un fetch dont ok est faux : le statut HTTP voyage dans data.status. Une erreur de validation — ZodError levée par l’action, ou entrée refusée par le schema de la route — est un 422 dont data.fields nomme chaque champ et sa partie ({ field, message, in }), exactement le error.fields du même appel en HTTP. Tout autre throw reste opaque (-32603).

Et un échec qui n’est pas une frame : l’expiration. startCall() ne reçoit rien dans le délai imparti, supprime l’entrée en attente et rejette localement avec RPC timeout: <méthode> (JsonRpcPeer.ts#JsonRpcPeer.startCall). Défaut de 30 000 ms, réglable par appel. Aucun octet ne part sur le fil : c’est une décision du client, le serveur peut très bien répondre après, sa réponse sera ignorée.

L’inverse n’attend pas : une frame qui porte l’id d’une requête en attente sans être une réponse valide — ni result ni error bien formé — fait échouer l’appel aussitôt, en RpcError -32603 « réponse JSON-RPC invalide » (JsonRpcPeer.ts#JsonRpcPeer.settleUnreadableResponse). Si elle porte result ET une error bien formée (la norme l’interdit), l’erreur annoncée est rendue telle quelle. Rien ne part sur le fil : c’est un échec local, et la frame est auditée invalid.

Les deux codes de la norme que Nodefony n’émet jamais#

-32700 Parse error et -32600 Invalid Request figurent dans la norme, mais aucune frame ne les porte ici :

Le choix est délibéré : une frame cassée n’a pas d’id digne de confiance, donc pas de corrélation possible ; et répondre systématiquement à du bruit offre à un attaquant un amplificateur gratuit. Le refus reste traçable — c’est le motif invalid de FrameAuditReason (JsonRpcPeer.ts#FrameAuditReason), qui alimente le journal d’audit avec le pair concerné.

Le refus d’une notification n’est pas une erreur#

C’est la subtilité la plus importante de la page. Le verrou de frame s’applique aux requêtes et aux notifications (beforeDispatch, JsonRpcPeer.ts#IJsonRpcPeerHooks.beforeDispatch), mais leurs conséquences diffèrent radicalement :

La frame refusée est… Ce qui part Pourquoi
une requête -32001 "unauthorized" (JsonRpcPeer.ts#JsonRpcPeer.receive) Un id existe : il y a un canal de réponse
une notification la notification realtime:denied (RealtimeController.ts#RealtimeController.onHandshake) Aucun id : sans elle, le client se croirait abonné

IRealtimeDenied (RealtimeEventMap.ts#IRealtimeDenied) porte channel et reason — plus un detail optionnel, posé hors production seulement — et le motif est générique. Jamais « il te manque ROLE_ADMIN » : ce serait un oracle d’autorisation, un attaquant y lirait la carte des droits. Quatre motifs circulent, et l’union est FERMÉE (RealtimeEventMap.ts#RealtimeDeniedReason) — un motif inconnu reçu d’un serveur plus récent est replié sur forbidden :

Motif Sens Le client réessaie ?
forbidden le verrou ou la règle d’admission allow a dit non jamais — le droit ne changera pas en réessayant
limit le plafond de canaux de la connexion est atteint (RealtimeController.ts#RealtimeController.onRealtimeNotification) jamais
unknown aucun producteur ne sert ce nom — jamais un oracle : un canal gardé rend forbidden, qu’il existe ou non jamais
unavailable la règle allow n’a pas répondu à temps, ou le serveur protège sa base (plafond, coupe-circuit) oui, le seul : trois fois au plus

unavailable peut porter retryAfterMs : le délai, en millisecondes, pendant lequel le serveur n’appellera plus la règle (coupe-circuit ouvert). Le client attend ce délai, puis ajoute un recul exponentiel à gigue complète — un tirage uniforme entre zéro et 1 s, 2 s, puis 4 s — pour que mille clients refusés au même instant ne reviennent pas au même instant. Il ne rejoue que le subscribe concerné, jamais la connexion. Côté client, onDenied() (NodefonySocket.ts#NodefonySocket.onDenied) branche un handler dessus ; il ne voit que le refus DÉFINITIF, après le dernier réessai.

L’abonnement accepté s’acquitte#

Le pendant du refus : un subscribe ACCEPTÉ reçoit realtime:subscribed { channel } (IRealtimeSubscribed), y compris pour un canal que la connexion suivait déjà — sans quoi un subscribe réémis attendrait une réponse qui ne vient jamais. Un abonnement reçoit l’un OU l’autre, jamais les deux.

Garantie : tout événement publié sur le canal APRÈS l’émission de l’acquittement est livré à la connexion. Le producteur s’attache AVANT : une première charge (un tick initial) peut arriver avant l’acquittement — « rien n’arrive avant » n’est pas promis. L’abonnement d’une connexion est local au pod, le backplane n’y prend aucune part : l’acquittement local suffit.

Le welcome annonce la capacité (subscriptionAcks: true). Un client qui ne la voit pas — serveur antérieur — répute l’abonnement actif dès le rejeu, au lieu d’attendre pour toujours. Côté client, whenSubscribed(canal) se résout à l’acquittement et se rejette sur un refus du même canal ; onSubscribed() rappelle un écouteur à chaque acquittement.

🛑 Prudence

Un -32403 Forbidden circule dans d’anciennes notes. Ce code n’existe pas dans Nodefony, et il n’est réservé par aucune norme. Le refus d’autorisation est -32001 sur une requête, et une notification realtime:denied sur une notification.

La fermeture qui dit « reviens plus tard »#

Une frame refusée laisse la connexion ouverte ; certaines causes, elles, la ferment. Le code de fermeture dit alors ce que le client doit faire (closeCodes.ts, le registre complet est dans securite.md). Un seul est une invitation à revenir : 1013 « try again later » (RFC 6455 §7.4.1). Le serveur l’émet quand l’authentification n’a pas répondu à temps à la poignée de main (authenticateTimeoutMs), ou quand le client ne vide plus sa file d’envoi. Le client le range parmi les fermetures reconnectables (notice.ts#isReconnectableCloseCode) : il recule, puis rouvre une connexion neuve, rejoue son welcome et ses abonnements. Un navigateur ne peut pas émettre 1013 lui-même (WebSocket.close() n’accepte que 1000 et 3000-4999) : c’est toujours le serveur qui ferme.

🔐 Ce que le protocole ne décide pas#

Le protocole transporte et classe. Il ne décide ni de l’identité, ni des droits — trois règles qui en découlent :

  1. Aucune authentification ne circule dans les frames. L’identité est résolue une fois au handshake, puis figée pour la connexion et relue en O(1) par frame. Une frame ne porte jamais de credential : la rejouer ne rejoue aucune authentification.
  2. subscribe est une demande, jamais un droit. Le serveur peut refuser un abonnement selon la politique du canal ; la demande a exactement la même forme qu’un abonnement autorisé, seule la réponse diffère.
  3. Le client ne pousse que là où c’est déclaré. Un canal n’accepte d’entrée que si un handler entrant existe pour ce nom (RealtimeController.ts#RealtimeController.onHandshake) ; sinon la notification est ignorée en silence. Le défaut est fermé.

Les mécanismes eux-mêmes — authenticator, verrou de frame, politique de canal, révocation — sont décrits dans la page sécurité.

⚠️ Pièges#

Symptôme Cause Correction
subscribe répond -32601 method not found Envoyé avec un id : classé requête, or c’est une notification (RealtimeController.ts#RealtimeController.onHandshake) L’émettre sans id — socket.subscribe(canal)
Le handler passé à subscribe n’est jamais appelé NodefonySocket.subscribe() prend un seul argument (NodefonySocket.ts#NodefonySocket.subscribe) subscribe(canal) et on(canal, handler), deux gestes distincts
request() expire immédiatement, ou ignore le délai Signature positionnelle (méthode, params, ms) (NodefonySocket.ts#NodefonySocket.request) — un objet d’options n’est pas lu request(m, p, 5000) ; le défaut est 30 000 ms
Un tableau de frames n’obtient aucune réponse Le batch n’est pas implémenté : un tableau n’a pas de jsonrpc → invalid (jsonrpc/index.ts#classifyJsonRpcFrame) Une frame = un objet ; le multiplexage remplace le batch
Une frame malformée ne renvoie aucune erreur Ni -32700 ni -32600 ne sont émis — silence + audit (JsonRpcPeer.ts#JsonRpcPeer.receive) Lire le motif invalid côté serveur, pas la réponse
Une trame binaire envoyée au client n’arrive à aucun handler Sur une connexion JSON-RPC texte, une trame binaire n’a pas de canal : le client l’écarte, la compte et émet une notice -32700 (NodefonySocket.ts#NodefonySocket.binaryFramesDiscarded) Un flux binaire se lit par onBinary() (NodefonySocket.ts#NodefonySocket.onBinary), hors JSON-RPC ; des octets routés par canal passent par un codec binaire (codec)
Une trame binaire envoyée au serveur n’arrive à aucune action Même règle côté serveur : écartée, comptée (WsConnectionTransport.ts#WsConnectionTransport.binaryDiscarded), journalisée une fois en WARNING Des octets par canal : codec: "cbor" et publish. Un protocole binaire propre à l’application : surcharger onRealtimeBinary() (RealtimeController.ts#RealtimeController.onRealtimeBinary), émettre par sendBinaryToClient() — ⚠️ hors du verrou de frame et des gardes d’action et de canal : toute décision d’accès sur ces octets appartient à l’application
TypeError : codec JSON : objet {"$bytes": …} non issu d'octets Un objet applicatif a la forme exacte du marqueur d’octets ; un client texte le relirait en octets (jsonFrameCodec.ts#JSON_BYTES_FRAME_CODEC) Envoyer un vrai Uint8Array, renommer la clé, ou en ajouter une seconde — la règle
L’exception du serveur n’arrive jamais au client Zero Trust : tout throw ordinaire devient -32603 générique (JsonRpcPeer.ts#JsonRpcPeer.handleRequest) Lever une RpcError pour exposer volontairement code et data
Une notification refusée disparaît sans trace côté client Sans id, aucune réponse possible (beforeDispatch, JsonRpcPeer.ts#IJsonRpcPeerHooks.beforeDispatch) Écouter realtime:denied via onDenied() (NodefonySocket.ts#NodefonySocket.onDenied)
nodefony:kernel:ping répond -32601 sur mon endpoint L’action nodefony:kernel:ping est déclarée par @nodefony/studio (StudioRealtimeController.ts#StudioRealtimeController.realtimeActions) Déclarer la sienne, ou lire serverMethods avant d’appeler
Le battement de cœur ne renvoie aucun pong La notification ping est un no-op serveur (RealtimeController.ts#RealtimeController.onHandshake) Pour mesurer un RTT, utiliser une action RPC — ping() (NodefonySocket.ts#NodefonySocket.ping)
Une réponse reçue est ignorée sans message Corrélation sur id numériques seulement (JsonRpcPeer.ts#JsonRpcPeer.handleResponse) Ne pas fabriquer soi-même de réponse à id chaîne
Les premières frames envoyées après connect() semblent perdues Le transport n’est branché qu’une fois le handshake terminé Attendre realtime:welcome — le client le fait déjà

🧪 Tests & couverture#

Le protocole est couvert à deux endroits, et c’est une conséquence directe de l’isomorphisme : le moteur vit dans le cœur, son câblage serveur dans ce module. Les chiffres exacts vivent dans la carte de l’aperçu, régénérée depuis les résultats réels — jamais figés ici.

Couverture : npm run coverage dans @nodefony/realtime et dans le workspace nodefony.

🛑 Prudence

Plusieurs suites bout en bout exigent une infrastructure. Un test sauté compte comme vert : avant de conclure « tout passe », vérifier les variables d’environnement — la source unique est scripts/test/vitest/gates.ts à la racine, dont le rapport s’affiche en fin d’exécution.

🔗 Pour aller plus loin#