---
title: "Protocole — la grammaire des frames JSON-RPC 2.0"
source: src/packages/@nodefony/realtime/docs/protocole.md
url: https://nodefony.github.io/nodefony-core/docs/modules/realtime/protocole/
---


> 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**

```nodefony-livegraph
{
  "graph": "protocole",
  "height": 480,
  "title": "Le protocole, en direct",
  "hint": "Les formes de frame qui circulent sur la socket de Studio — celle qui sert cette page."
}
```

## 🧠 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.

```mermaid
flowchart TD
  IN["Frame entrante (JSON déjà parsé)"] --> V{"frame JSON-RPC 2.0 valide ?"}
  V -->|non| INV["invalid — auditée, AUCUNE réponse"]
  V -->|oui| M{"method présent ?"}
  M -->|"method + id"| REQ["requête — un handler doit rendre result ou error"]
  M -->|"method seul"| NOT["notification — pub/sub, aucune réponse"]
  M -->|"id + result ou error"| RES["réponse — résout une requête sortante en attente"]
  REQ --> OUT["result · error"]
  NOT --> HUB["subscribe · unsubscribe · canal entrant"]
  RES --> PROM["la Promise de l'appelant"]
```

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

```ts
// 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é

```ts
// 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 :

```jsonc
// 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

```jsonc
{
  "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`) :
>
> ```ts
> 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

```jsonc
// 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`).

```jsonc
{
  "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.

```ts
// 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é](../securite/)). 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`](../configuration/#codec--la-forme-des-frames-sur-le-fil), 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`) :

```ts ignore
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
```

- **Côté navigateur**, l'émission lève à la ligne fautive.
- **Côté serveur**, une publication diffusée qui contient un tel objet n'atteint pas les abonnés
  texte ; une seule ligne `ERROR` est écrite par publication, pas une par abonné
  (`RealtimeHub.ts#ChannelFrame.claimFailure`). La clé citée est tronquée à 64 caractères et
  échappée, pour qu'un client ne puisse pas forger de ligne de journal (CWE-117).
- **Limite assumée** : un tel objet **conservé** par l'application, par exemple l'état initial
  qu'un canal renvoie à chaque nouvel abonné, sera refusé aux abonnés texte **à chaque envoi**.
  Renommer la clé ou en ajouter une seconde règle le cas une fois pour toutes.

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`                             |

> [!TIP]
> **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

> [!WARNING]
> `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

```mermaid
sequenceDiagram
  participant C as Client
  participant P as JsonRpcPeer (serveur)
  participant H as RealtimeHub
  C->>P: handshake WebSocket
  P-->>C: realtime:welcome — ts, protocol, channels, methods, identity, codec?, bytes?
  C->>P: subscribe — params.channel = "nodefony:orm:health"
  Note over P,H: le hub crée le producteur au 1er abonné
  P-->>C: nodefony:orm:health — params { queries: 42 }
  P-->>C: nodefony:orm:health — params { queries: 43 }
  C->>P: chat:history — id 7
  P-->>C: id 7 — result { messages }
  C->>P: unsubscribe — params.channel = "nodefony:orm:health"
  Note over P,H: dernier abonné parti : le producteur est libéré
```

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 :

- un JSON illisible n'atteint même pas le pair — le contrôleur l'abandonne à la lecture
  (`RealtimeController.ts#RealtimeController.onHandshake`) ;
- une frame lisible mais non conforme — pas d'objet, un lot, `jsonrpc` absent ou faux, `id` `null`,
  objet ou booléen, `method` non chaîne, réponse sans `result` ni `error` bien formé, ou avec les
  deux — est classée `invalid`, signalée à l'audit, et **rien n'est renvoyé** (`JsonRpcPeer.ts#JsonRpcPeer.receive`).

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.

> [!CAUTION]
> 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`](../securite/)). 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é](../securite/).

## ⚠️ 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](#des-octets-dans-la-charge--une-frame-comme-les-autres)                                                                                                                                                                                                                               |
| 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.

- **Le moteur, dans le cœur** (`src/nodefony/src/tests/`) : `JsonRpcPeer.test.ts` (classement des
  frames, corrélation, timeout, erreurs), `JsonRpcPeer.types.test.ts` (le contrat générique tient à
  la compilation), et la famille `NodefonySocket*.test.ts` — dispatch, identité annoncée par
  l'accueil, transport, `ping`. Ces suites **ne sont pas comptées** dans la carte du module
  `@nodefony/realtime` : elles appartiennent au workspace `nodefony`.
- **Le câblage serveur, dans ce module** : `RealtimeController.test.ts` couvre les réponses
  d'erreur exactes (`-32601` sur méthode inconnue, `-32603` générique sur throw, `-32001` sur frame
  refusée) et le fait qu'une **réponse entrante** ne déclenche pas `-32601` à tort.
- **Bout en bout, sur un vrai serveur** : `realtimeLoopback.e2e.test.ts` exerce le duplex complet —
  requête client→serveur, requête **serveur→client**, `-32601` quand le client n'a pas enregistré
  l'action, `-32603` quand son handler lève, et le passage fidèle du `data` d'une `RpcError`.
- **Sous attaque** : les bancs `*.attack.test.ts` du module vérifient que le refus est observable
  (plafond de canaux) et qu'une politique déclarée sans décideur est détectée.

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

> [!CAUTION]
> 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

- ⬆️ **Retour au hub** : [Realtime — vue d'ensemble](../) · [Toute la documentation](../../../)
- 🧭 **Pages sœurs** : [Vocabulaire](../vocabulaire/) · [Architecture](../architecture/) · [Sécurité](../securite/) · [Configuration](../configuration/) · [Cookbook — un chat complet](../cookbook-chat/)

- Les mots employés ici, avec leurs analogies → [vocabulaire](../vocabulaire/)
- Où le pair se situe dans la pile, et le cycle de vie d'une connexion → [architecture](../architecture/)
- Qui pose le verrou de frame, et ce qu'il lit → [sécurité](../securite/)
- Le protocole mis en œuvre d'un bout à l'autre → [cookbook](../cookbook-chat/)
- Les signatures exactes ne sont jamais recopiées ici : elles vivent dans le graphe symbolique
  `.ai/symbols.json`, régénéré depuis les TSDoc du code.
