---
title: "Actions RPC — appeler le serveur et attendre sa réponse"
source: src/packages/@nodefony/realtime/docs/actions.md
url: https://nodefony.github.io/nodefony-core/docs/modules/realtime/actions/
---


> Une **action RPC** est la direction « contrôle » de la socket : le client demande, le serveur
> exécute, le serveur répond — une `Promise` résolue avec le résultat. C'est l'exact opposé d'une
> publication, qui part sans accusé. Cette page dit **quand** choisir l'une plutôt que l'autre,
> comment déclarer une action, et ce qui arrive quand le client abandonne, quand le réseau coupe ou
> quand l'appel est rejoué.

📍 [Documentation](../../../) › [Realtime](../) › **Actions RPC**

```nodefony-livegraph
{
  "graph": "actions",
  "height": 520,
  "title": "Le trajet d'une action, en direct",
  "hint": "Résolution de la méthode, verrou d'autorisation, handler. Les sorties d'erreur sont des branches à part."
}
```

## 🧠 Le modèle mental — deux directions sur un même fil

Une seule connexion porte deux régimes de trafic. Ils ne se distinguent pas par leur contenu mais
par **la présence d'un `id`** dans la frame : un `id` réclame une réponse, son absence non.

```mermaid
sequenceDiagram
  participant A as Ton code (client)
  participant P as JsonRpcPeer
  participant S as Serveur
  A->>P: request("orders:quote", params)
  P->>S: frame method + id
  Note over P: minuterie armee (30 s par defaut)
  S->>S: le handler s'execute
  S-->>P: frame id + result
  P-->>A: Promise resolue
  Note over A,S: publication : frame method SANS id,<br/>aucune Promise, aucune minuterie
```

### Publication ou action RPC ? Le critère de tri

| Aspect        | Publication (sans `id`)           | Action RPC (avec `id`)                          |
| ------------- | --------------------------------- | ----------------------------------------------- |
| Direction     | flux, 1 → N abonnés               | requête → réponse, 1 → 1                        |
| Sémantique    | « ceci s'est passé »              | « fais ceci, et dis-moi »                       |
| Latence       | la livraison, sans attente        | l'exécution complète du handler                 |
| Ordre         | par canal                         | par appel, chacun corrélé par son `id`          |
| Rejeu         | inutile — une perte est rattrapée | possible, **si l'action est idempotente**       |
| Coût au repos | un sink dans la table du hub      | une entrée en attente + une minuterie par appel |
| Exemples      | `nodefony:socket`, `chat:room-42` | `nodefony:kernel:ping`, `nodefony:scaffold:run` |

> [!TIP]
> **Le critère qui tranche en une seconde : le client a-t-il besoin de savoir si ça a marché ?**
> Si oui, c'est une action RPC. Si non, c'est une publication. Une « action » dont personne ne lit
> le retour est une publication déguisée ; un « événement » dont l'émetteur attend confirmation est
> une action qui s'ignore.

## 📖 Lexique

| Terme              | Sens                                                                                                    |
| ------------------ | ------------------------------------------------------------------------------------------------------- |
| Action RPC         | Une méthode nommée, exposée par le serveur, appelable par le client et qui rend une valeur.             |
| RPC                | _Remote Procedure Call_ : appeler une fonction qui vit ailleurs comme si elle était locale.             |
| Frame              | L'unité qui passe sur le fil, au format JSON-RPC 2.0.                                                   |
| `id`               | Le numéro qui corrèle une requête et sa réponse. Sa présence **définit** une requête.                   |
| Corrélation        | Retrouver, à l'arrivée d'une réponse, l'appel en attente qu'elle résout.                                |
| Accueil (welcome)  | La première frame du serveur ; son champ `methods` liste les actions découvrables.                      |
| Découverte         | Lire ce que l'endpoint expose au lieu de l'écrire en dur dans l'interface.                              |
| Délai d'expiration | Le temps au bout duquel le client cesse d'attendre et rejette sa `Promise`.                             |
| Idempotence        | Propriété d'une action dont deux exécutions font le même effet qu'une seule.                            |
| Clé d'idempotence  | L'identifiant fourni par l'appelant qui permet au serveur de reconnaître un rejeu.                      |
| Verrou de frame    | La décision synchrone « cette frame passe-t-elle ? », posée par la couche sécurité.                     |
| Namespace réservé  | Le préfixe `nodefony:`, qui marque les surfaces de la plateforme et porte un plancher non contournable. |
| Pont API           | La méthode `api.request` qui rejoue une route HTTP sur la socket.                                       |
| Job                | Un travail long identifié, lancé par une action et suivi sur un canal dédié.                            |

Les mots communs à tout le module (socket, canal, hub, pair, backplane) sont définis une seule
fois, dans le [vocabulaire](../vocabulaire/).

## Qu'est-ce qu'une action RPC ?

Imagine un guichet et un panneau d'affichage dans le même hall.

Le **panneau d'affichage**, c'est la publication : il change, tout le monde le voit, personne ne
signe. Si tu regardais ailleurs, tu as raté l'information — et ce n'est pas grave, la suivante
arrive.

Le **guichet**, c'est l'action RPC : tu tends un formulaire portant un numéro, tu attends, on te
rend une réponse portant **le même numéro**. Tu sais si ça a marché. Tu peux échouer. Tu peux
attendre trop longtemps et repartir.

Techniquement, ce numéro est le champ `id` de la frame JSON-RPC 2.0. Le pair l'attribue, garde
l'appel en attente dans une table, arme une minuterie, et résout la `Promise` quand la réponse
portant cet `id` revient (`JsonRpcPeer.handleResponse()`, `JsonRpcPeer.ts#JsonRpcPeer.handleResponse`). Une frame sans
`id` ne crée aucune de ces trois choses — c'est pourquoi une publication ne coûte rien au repos.

## La vision Nodefony — un nom, un handler, une découverte

Trois partis pris distinguent les actions Nodefony d'une couche RPC classique.

**Il n'y a pas de couche RPC séparée.** Une action est une **méthode de contrôleur**, dans la même
classe que tes canaux et — si tu le veux — que tes routes HTTP. Pas de service dédié, pas de
schéma à compiler : `@RealtimeAction("orders:quote")` (`realtimeDecorators.ts#RealtimeAction`) suffit, et le
retour de la méthode devient le `result` de la réponse.

**Le contrat du handler est minuscule, volontairement.** Une action reçoit **un seul argument**,
les paramètres bruts du client, et rend une valeur — `RpcActionHandler`
(`JsonRpcPeer.ts#RpcActionHandler`). Pas de contexte injecté dans la signature : le `this` est lié à
l'instance du contrôleur au handshake, ce qui donne accès au noyau, aux services et à la
connexion sans élargir le contrat.

**L'endpoint s'annonce lui-même.** La liste des actions exposées voyage dans la frame d'accueil —
`IRealtimeWelcome` (`RealtimeController.ts`) — et se lit côté client par
`NodefonySocket.serverMethods` (`NodefonySocket.ts#NodefonySocket.serverMethods`). Une interface n'écrit donc jamais un nom d'action en dur : elle
n'active un bouton que si le serveur a déclaré savoir le servir.

**Le compromis, dit franchement** : une action est **un aller-retour**, point. Elle ne diffuse pas,
elle ne progresse pas, elle ne s'annule pas côté serveur. Tout ce qui dure ou se raconte en
plusieurs temps se fait sur un canal — voir plus bas.

## 🚀 Démarrage rapide

Une action qui calcule un devis, vue d'une application créée par `nodefony create app`.

### 1. Le contrôleur — la seule chose à écrire côté serveur

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

interface IQuote {
  orderId: string;
  total: number;
  currency: string;
}

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

  // UNE route WebSocket : la classe de base porte tout le protocole.
  @route("orders-realtime", {
    path: "/realtime",
    requirements: { methods: ["WEBSOCKET"] },
  })
  async realtime(message: string | Buffer | null): Promise<void> {
    this.handleRealtime(message);
  }

  // L'ACTION : un seul argument (les params du client, JAMAIS fiables),
  // et la valeur rendue devient le `result` de la réponse.
  @RealtimeAction("orders:quote")
  quote(params: unknown): IQuote {
    const orderId = (params as { orderId?: unknown } | undefined)?.orderId;
    // Une RpcError est la SEULE erreur dont le message atteint le client.
    // Tout autre throw devient un `-32603 internal error` opaque.
    if (typeof orderId !== "string") {
      throw new RpcError("params.orderId manquant", -32602);
    }
    return { orderId, total: 4200, currency: "EUR" };
  }
}

export default OrdersRealtimeController;
```

### 2. L'appel — côté navigateur

```ts
// frontend/src/orders.ts
import { NodefonySocket } from "nodefony/client";

interface IQuote {
  orderId: string;
  total: number;
  currency: string;
}

const socket = NodefonySocket.shared({
  url: "wss://127.0.0.1:5152/orders/realtime",
});

export async function askQuote(orderId: string): Promise<IQuote | null> {
  await socket.connect();
  // DÉCOUVERTE : le serveur a-t-il annoncé cette action ? Sinon, on n'appelle
  // pas — c'est ainsi qu'un bouton s'active au lieu d'être écrit en dur.
  if (!socket.serverMethods?.includes("orders:quote")) return null;
  // Signature POSITIONNELLE : (méthode, params, délai en ms). Défaut 30 000.
  return socket.request<"orders:quote", IQuote>(
    "orders:quote",
    { orderId },
    5000,
  );
}
```

### Ce qu'on observe

À la connexion, la frame d'accueil annonce l'action — c'est elle qui pilote l'interface :

```jsonc
{
  "jsonrpc": "2.0",
  "method": "realtime:welcome",
  "params": {
    "ts": 1770000000000,
    "protocol": "jsonrpc-2.0",
    "channels": [],
    "methods": ["orders:quote"],
    "identity": {
      "type": "anonymous",
      "authenticated": false,
      "userIdentifier": "anonymous",
      "roles": ["ROLE_ANONYMOUS"],
      "scopes": [],
    },
  },
}
```

Puis l'aller-retour, deux frames, corrélées par leur `id` :

```jsonc
// → client vers serveur
{ "jsonrpc": "2.0", "id": 1, "method": "orders:quote", "params": { "orderId": "A-42" } }
// ← serveur vers client
{ "jsonrpc": "2.0", "id": 1, "result": { "orderId": "A-42", "total": 4200, "currency": "EUR" } }
```

Un `orderId` absent donne l'autre forme de réponse, l'échec explicite :

```jsonc
{
  "jsonrpc": "2.0",
  "id": 2,
  "error": { "code": -32602, "message": "params.orderId manquant" },
}
```

Le trajet complet d'une requête, des étapes qu'elle traverse aux branches par lesquelles elle

## 🧰 Déclarer une action côté serveur

### Deux voies, un même registre

| Voie                                                                             | Quand la choisir                                                                                                       |
| -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `@RealtimeAction("nom")` (`realtimeDecorators.ts#RealtimeAction`)                | le cas courant — un nom fixe, une méthode, déclaratif                                                                  |
| `realtimeActions()` (`RealtimeController.ts#RealtimeController.realtimeActions`) | la table est **calculée** (noms dynamiques, boucle, config)                                                            |
| `realtimeActions()` **complète** d'un `ContractRealtimeController<C>`            | le contrôleur implémente un **contrat** : une méthode oubliée ne compile pas (voir « Un contrat pour les deux bouts ») |

Les deux sont fusionnées au handshake, et **l'override gagne** en cas de conflit de nom : une
classe peut ainsi remplacer une action héritée sans toucher au parent
(`RealtimeController.ts#RealtimeController.onHandshake`). Un endpoint sans aucune action ne paie rien — la table du pair est
allouée au premier enregistrement seulement.

### Ce que ton `return` et ton `throw` deviennent sur le fil

Le retour du handler est envoyé tel quel en `result`. Les erreurs, elles, suivent une règle Zero
Trust stricte, appliquée dans `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts#JsonRpcPeer.handleRequest`) :

| Côté serveur                    | Ce que reçoit le client                  | Pourquoi                                        |
| ------------------------------- | ---------------------------------------- | ----------------------------------------------- |
| `return valeur`                 | `result: valeur`                         | le contrat nominal                              |
| `throw new RpcError(msg, code)` | `error` avec **ton** code et ton message | tu as choisi d'exposer ce refus                 |
| `throw` de toute autre erreur   | `-32603 "internal error"`, **opaque**    | un message d'exception fuite chemins et schémas |
| action non enregistrée          | `-32601 "method not found: <nom>"`       | découverte honnête d'un nom inconnu             |
| frame refusée par le verrou     | `-32001 "unauthorized"`                  | motif générique : pas d'oracle d'autorisation   |

> [!WARNING]
> **Le temps réel n'a qu'UNE erreur, et elle vient du cœur.** Pour choisir ce que voit le client,
> c'est `RpcError` (`JsonRpcPeer.ts#RpcError`), importée depuis `nodefony` — son `code` est un entier
> JSON-RPC et son `data` traverse le fil. Toute autre exception donne un `-32603` opaque. Ne pas
> chercher d'erreur maison dans `@nodefony/realtime` : il n'y en a pas, et
> `tests/unit/errorSurface.test.ts` refuse d'en exporter une que personne ne lève.

## 🤝 Un contrat pour les deux bouts

Sans contrat, chaque côté type ce qu'il **envoie**, jamais ce que l'autre **reçoit** ou
**implémente**. Le navigateur appelle `request("orders:quote", …)` ; le serveur peut n'avoir
aucune action de ce nom, ou lui donner une autre forme — tout compile, et l'erreur sort en
production (`-32601`, ou une réponse de forme fausse). Un **contrat** se déclare UNE fois, et le
compilateur vérifie les deux bouts.

### Le contrat, les deux portes, le flux SSE

Le contrat est une interface de **types seuls** : son fichier, partagé par les deux côtés,
compile en zéro octet, et le navigateur qui l'importe ne tire aucun code serveur. Chaque côté
entre ensuite par **sa** porte, et la direction (ce que j'envoie, ce que je reçois) est
**calculée** — jamais réécrite à la main.

```ts
// ── shared/orders.ts — le contrat, déclaré UNE fois (types seuls) ──────────
import type { IRealtimeContract } from "nodefony/client";

export interface OrdersContract extends IRealtimeContract {
  // ce que le serveur pousse (socket, flux SSE, webhook)
  serverToClient: {
    "orders:status": { orderId: string; at: Date };
    [channel: `orders:user:${string}`]: { unread: number };
  };
  // ce que le client envoie sur un canal
  clientToServer: { "orders:typing": { on: boolean } };
  // les méthodes du serveur : le client appelle, le serveur répond
  serverMethods: {
    "orders:quote": { in: { orderId: string }; out: { total: number } };
  };
  // les méthodes du client : le serveur appelle, le client répond
  clientMethods: {
    "ui:confirm": { in: { text: string }; out: { ok: boolean } };
  };
}

// ── frontend/src/orders.ts — la porte du CLIENT ─────────────────────────────
import { NodefonySocket } from "nodefony/client";

const socket = NodefonySocket.fromContract<OrdersContract>({
  url: "/orders/realtime",
});
socket.on("orders:status", (m) => console.log(m.orderId, m.at)); // m.at : string (JSON)
const quote = await socket.request("orders:quote", { orderId: "A-1" }); // { total: number }
socket.register("ui:confirm", (p) => ({ ok: p.text.length > 0 }));
void quote;

// ── nodefony/controllers/OrdersRealtime.ts — la porte du SERVEUR ────────────
import type { ContextType } from "@nodefony/http";
import type { ContractServerEmit } from "nodefony";
import { ContractRealtimeController, RealtimePolicy } from "@nodefony/realtime";

@RealtimePolicy({ "orders:quote": { roles: ["ROLE_USER"] } })
export class OrdersRealtime extends ContractRealtimeController<OrdersContract> {
  constructor(context: ContextType) {
    super("orders", context);
  }

  // TOUTES les méthodes du contrat, et cette table SEULE les sert.
  protected realtimeActions() {
    return {
      "orders:quote": (p: { orderId: string }) => ({
        total: p.orderId.length * 100,
      }),
    };
  }

  // Facultatif, canal par canal : accepter une entrée est une permission.
  protected override realtimeInbound() {
    return {
      "orders:typing": (p: { on: boolean }) => {
        void p.on; // NON FIABLE : le type n'est pas une validation
      },
    };
  }

  // Le flux SSE lit la MÊME carte que la socket.
  async statusStream(): Promise<void> {
    const sse = await this.renderSse<ContractServerEmit<OrdersContract>>();
    await sse.send(
      { orderId: "A-1", at: new Date() },
      { event: "orders:status" },
    );
    sse.close();
  }
}
```

| Porte                                             | Ce qu'elle dérive du contrat                                                                                             |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `NodefonySocket.fromContract<C>()` (client)       | émet `clientToServer` · reçoit `serverToClient` · appelle `serverMethods` · implémente `clientMethods`                   |
| `extends ContractRealtimeController<C>` (serveur) | émet `serverToClient` · reçoit `clientToServer` · implémente `serverMethods` · appelle `clientMethods` (`requestClient`) |
| `getRealtimeHub<C>()` (service, tâche, jeu)       | `publish` n'accepte que les canaux de `serverToClient`, avec leur charge                                                 |
| `ContractRealtimePublish<C>` (fabrique de canal)  | le `publish` reçu par `@RealtimeChannel` / `createRealtimeChannel`, même règle                                           |
| `renderSse<ContractServerEmit<C>>()` (serveur)    | `event:` = un canal de `serverToClient`, la charge est celle du canal                                                    |
| `@RealtimePolicy({ … })` (serveur)                | chaque nom doit être une méthode ou un canal du contrat                                                                  |

Les quatre clés sont **facultatives** : un tableau de bord ne déclare que `serverToClient`, et son
contrôleur n'écrit rien de plus que `extends ContractRealtimeController<C> {}` — la table des
méthodes n'est due que si le contrat déclare des `serverMethods`. Une direction non déclarée reste
libre.

### Diffuser depuis un service — le même contrat

Le cas le plus courant ne passe par aucun contrôleur : un service, une tâche, une boucle de jeu
publient. Le hub se lit à travers le contrat, et une faute de frappe ne compile plus :

```ts
import { getRealtimeHub } from "@nodefony/realtime";
import type { ChatContract } from "../shared/chat";

const chat = getRealtimeHub<ChatContract>(); // même objet hub : 0 octet, 0 ns de plus
chat.publish("chat:room", message); // charge vérifiée par le contrat
getRealtimeHub().publish("n'importe:quoi", { x: 1 }); // sans paramètre : libre, comme avant
```

Une fabrique de canal déclare son `publish` de la même façon :
`presence(_channel: string, publish: ContractRealtimePublish<ChatContract>)`. Le contrat est choisi
par l'appelant : c'est le fichier partagé qui fait foi.

> [!NOTE]
> **Lire les noms.** `Contract…<C>` est la même chose que sa forme libre, lue à travers le contrat :
> `RealtimeController` → `ContractRealtimeController<C>`, `RealtimePublish` →
> `ContractRealtimePublish<C>`, `RealtimeHub` → `IContractRealtimeHub<C>`. `Typed…` type un handler
> par ses types directs ; `…Of<X>` lit ce que porte une classe (`RealtimeContractOf`).

### Deux cas réels — l'atelier et la partie

**L'atelier : une mesure typée une fois, trois lecteurs.** Chaque machine pousse sa télémétrie
sur son propre canal (`machine:<id>`). Le tableau de bord la lit par la socket, l'écran d'atelier
— un simple navigateur, sans socket — par un flux SSE, et l'opérateur peut arrêter une machine.
Un seul contrat, et la charge est la MÊME aux trois endroits.

```ts
// ── shared/plant.ts ─────────────────────────────────────────────────────────
import type { IRealtimeContract } from "nodefony/client";

export interface PlantContract extends IRealtimeContract {
  serverToClient: {
    [machine: `machine:${string}`]: {
      temperature: number;
      rpm: number;
      at: Date;
    };
  };
  serverMethods: {
    "machine:stop": {
      in: { machineId: string; reason: string };
      out: { stopped: boolean };
    };
  };
}

// ── frontend/src/dashboard.ts — le tableau de bord ──────────────────────────
import { NodefonySocket } from "nodefony/client";

const plant = NodefonySocket.fromContract<PlantContract>({
  url: "/plant/realtime",
});
plant.on("machine:press-07", (m) => {
  if (m.temperature > 90) {
    void plant.request("machine:stop", {
      machineId: "press-07",
      reason: "surchauffe",
    });
  }
});

// ── nodefony/controllers/PlantRealtime.ts — le serveur ──────────────────────
import type { ContextType } from "@nodefony/http";
import type { ContractServerEmit } from "nodefony";
import { ContractRealtimeController, RealtimePolicy } from "@nodefony/realtime";

@RealtimePolicy({
  "machine:stop": { roles: ["ROLE_OPERATOR"] }, // arrêter une machine : un rôle
  "machine:*": { authenticated: true }, // lire la télémétrie : être connecté
})
export class PlantRealtime extends ContractRealtimeController<PlantContract> {
  constructor(context: ContextType) {
    super("plant", context);
  }

  protected realtimeActions() {
    return {
      "machine:stop": (p: { machineId: string; reason: string }) => ({
        stopped: p.machineId.length > 0 && p.reason.length > 0,
      }),
    };
  }

  // L'écran d'atelier : UN flux = UN canal, sans champ `event:`.
  async machineStream(machineId: string): Promise<void> {
    const sse =
      await this.renderSse<
        Pick<ContractServerEmit<PlantContract>, `machine:${string}`>
      >();
    await sse.send({ temperature: 71, rpm: 1450, at: new Date() });
    void machineId;
    sse.close();
  }
}
```

**La partie : les entrées du joueur, typées jusque dans le serveur.** Le client envoie ses
déplacements soixante fois par seconde ; le serveur les reçoit typés, sans jamais les croire, et
pousse l'état de la partie. Aucune méthode n'est demandée au serveur : la table est vide.

```ts
// ── shared/arena.ts ─────────────────────────────────────────────────────────
import type { IRealtimeContract } from "nodefony/client";

export interface ArenaContract extends IRealtimeContract {
  clientToServer: { "arena:input": { dx: number; dy: number; seq: number } };
  serverToClient: {
    "arena:state": {
      tick: number;
      players: Array<{ id: string; x: number; y: number }>;
    };
  };
}

// ── frontend/src/arena.ts — le joueur ───────────────────────────────────────
import { NodefonySocket } from "nodefony/client";

const arena = NodefonySocket.fromContract<ArenaContract>({
  url: "/arena/realtime",
});
let seq = 0;
arena.publish("arena:input", { dx: 1, dy: 0, seq: ++seq });
arena.on("arena:state", (s) => console.log(s.tick, s.players.length));

// ── nodefony/controllers/ArenaRealtime.ts — le serveur ──────────────────────
import type { ContextType } from "@nodefony/http";
import { ContractRealtimeController } from "@nodefony/realtime";

const MAX_STEP = 1;

export class ArenaRealtime extends ContractRealtimeController<ArenaContract> {
  constructor(context: ContextType) {
    super("arena", context);
  }

  // Aucune méthode serveur dans ce contrat : aucune table à écrire.

  // L'entrée est TYPÉE, mais vient du réseau : bornée avant d'être appliquée.
  protected override realtimeInbound() {
    return {
      "arena:input": (input: { dx: number; dy: number; seq: number }) => {
        const dx = Math.max(-MAX_STEP, Math.min(MAX_STEP, input.dx));
        const dy = Math.max(-MAX_STEP, Math.min(MAX_STEP, input.dy));
        this.notifyClient("arena:state", {
          tick: input.seq,
          players: [{ id: "p1", x: dx, y: dy }],
        });
      },
    };
  }
}
```

Le serveur d'une vraie partie ne pousse évidemment pas l'état à chaque entrée : il les accumule et
diffuse l'état à cadence fixe, par un canal partagé — voir
[`cadence-et-contre-pression.md`](../cadence-et-contre-pression/).

### Ce que chaque bout reçoit — ce que JSON en fait

Ce que j'**envoie** est le type déclaré ; ce que je **reçois** est `Jsonify<T>` : ce que JSON rend
de ce type, quel que soit le codec. Une `Date` arrive en `string`, un membre fonction ou
`undefined` disparaît, `bigint` n'a pas de forme JSON. Seul `Uint8Array` reste `Uint8Array` — en
chaîne d'octets native sous le codec CBOR, sous la convention `{"$bytes":"<base64url>"}` sur une
connexion texte dont l'application a activé le binaire, annoncée par le champ `bytes` de l'accueil :
un client sans codec les relit aussi en `Uint8Array` (ADR-0022 §2).

| Qui reçoit                                               | Quoi             | Type reçu    |
| -------------------------------------------------------- | ---------------- | ------------ |
| le client, `on(…)`                                       | `serverToClient` | `Jsonify<T>` |
| le serveur, entrée d'un canal                            | `clientToServer` | `Jsonify<T>` |
| qui **implémente** une méthode                           | son `in`         | `Jsonify<T>` |
| qui **appelle** une méthode (`request`, `requestClient`) | son `out`        | `Jsonify<T>` |
| un composant, hook de canal (socket en tête)             | `serverToClient` | `Jsonify<T>` |

`serverToClient: { meeting: { start: Date } }` : sans cette règle, `m.start.getTime()` compilait
et plantait au navigateur ; avec elle, `m.start` est une `string` et l'éditeur le souligne.

### Lire dans une vue — le hook prend la socket du contrat

Dans React, Vue, Svelte ou Angular, la lecture passe par la **même** socket typée, donnée en
premier argument du hook de canal. Le nom est contraint aux canaux de `serverToClient`, la charge
est déduite — aucun générique écrit à la main, aucune carte recopiée.

```ts
export const chat = NodefonySocket.fromContract<ChatContract>({
  url: "/chat/realtime",
});

const presence = useNodefonyChannelData(chat, "chat:presence", { present: [] }); // React, Vue
useNodefonyChannel(chat, "chat:room", (m) => add(m)); // m : Jsonify<ChatMessage>
nodefonyChannelData(chat, "chat:presence"); // Svelte
injectNodefonyChannelData(chat, "chat:presence"); // Angular
```

Les hooks lisent les cartes de `NodefonySocket.on` (`client/realtime/observe.ts#ChannelPayload`) :
une seule règle, quatre liaisons. La socket reste celle de l'application — le hook ne l'ouvre ni
ne la ferme. La forme historique `useNodefonyChannelData<T>("canal")` reste valide, mais son `<T>`
n'est vérifié par rien : une charge qui change côté serveur n'y casse aucune compilation.

### Ce que le compilateur refuse

| Faute                                                                | Où elle est refusée                                                  |
| -------------------------------------------------------------------- | -------------------------------------------------------------------- |
| une méthode de `serverMethods` **oubliée**                           | `realtimeActions()` du contrôleur                                    |
| un handler de **forme fausse** (paramètres ou résultat)              | `realtimeActions()` / `realtimeInbound()`                            |
| un nom de **politique** hors contrat (`"orders:qoute"`)              | `@RealtimePolicy` — l'erreur affiche `notInContract: "orders:qoute"` |
| un canal émis **hors** `serverToClient`                              | `notifyClient`, `realtimeChannels()`, `renderSse`                    |
| une entrée acceptée sur un canal que le **serveur** émet (inversion) | `realtimeInbound()`                                                  |
| une charge fausse sur un canal ou une méthode connus                 | client et serveur                                                    |
| une diffusion de service hors contrat (nom ou charge)                | `getRealtimeHub<C>().publish`, `ContractRealtimePublish<C>`          |
| une charge **scalaire** (`3`, `"ok"`) — refusée aussi à l'exécution  | `getRealtimeHub<C>().publish`, `ContractRealtimePublish<C>`          |
| un canal lu **hors** `serverToClient` dans une vue                   | hooks de canal React, Vue, Svelte, Angular (socket en tête)          |

> [!IMPORTANT]
> **La table sert SEULE les méthodes d'un contrat — c'est une règle de performance.** N'ajoute pas
> `@RealtimeAction` à une méthode déjà servie par la table : au handshake, le décorateur fabrique
> un handler que la table écrase aussitôt, à **chaque connexion**, avec un appel de plus par
> requête, et l'injection des paramètres décorés (`@Body`…) saute sans un mot. La politique d'une
> méthode se pose sur la classe, par `@RealtimePolicy`, écrite une seule fois. Des handlers qui
> n'ont pas besoin de `this` se rangent dans une table **constante** du module, rendue telle
> quelle : aucune allocation par connexion.

À savoir encore :

- **Le type n'est pas une validation.** Les paramètres reçus restent NON FIABLES ; la garde de
  canal (politique, `allow`) s'applique à toutes les livraisons.
- **Côté client, les noms de canaux restent ouverts** (canaux dynamiques, de plateforme) :
  `publish` et `on` acceptent tout nom, et typent la charge d'un nom déclaré.
- **`realtimeChannels()` surchargée** : TypeScript ne donne pas son type de retour à une méthode
  qui surcharge ; écrire `["orders:status" as const]` ou annoter le retour.
- **Un `renderSse()` sans paramètre** reste un flux libre (la porte MCP en est un).
- **Le webhook** lira la même carte quand l'émission applicative existera (en 10.1, #70).

## 🔐 Autorisation — qui peut appeler quoi

> [!IMPORTANT]
> **La découverte n'est pas une protection.** Le champ `methods` de l'accueil sert à construire une
> interface honnête, pas à garder une porte. Un client peut forger une frame pour n'importe quel
> nom, annoncé ou non : la seule défense est côté serveur.

Cette défense est le **verrou de frame**, posé par `@nodefony/security`. Il examine chaque frame
entrante et, pour une action, résout une politique **par son nom** — exactement le mécanisme des
canaux (`buildFrameAuthorizer()`, `frameAuthorizer.ts#buildFrameAuthorizer` ; branche des méthodes,
`frameAuthorizer.ts#buildFrameAuthorizer`). Trois situations, à connaître dans cet ordre :

### Situation 1 — une action est FERMÉE par défaut

`@RealtimeAction("orders:quote")` exige une connexion **authentifiée**, sans que tu aies rien à
écrire (`realtimeDecorators.ts`). Le décorateur pose pour toi la politique `{ authenticated: true }`.
Le raisonnement : une action est une méthode que le pair **appelle** et qui **agit** — le défaut sûr
est de la fermer. Un visiteur anonyme se voit refuser la frame, et reçoit
`realtime:denied { channel, reason: "forbidden" }`.

Le décorateur accepte une politique en second argument, comme `@RealtimeChannel` et
`@RealtimeInbound` :

```ts ignore
@RealtimeAction("orders:quote")                          // authentifié (défaut)
quote() { … }

@RealtimeAction("orders:purge", { roles: ["ROLE_ADMIN"] })   // rôle exigé
purge() { … }

@RealtimeAction("catalog:browse", { authenticated: false })  // PUBLIQUE, assumée
browse() { … }
```

Ouvrir une action reste donc possible — mais c'est désormais un acte **délibéré et lisible à la
déclaration**, au lieu d'un silence.

Une action servie par la **table** de `realtimeActions()` n'a pas de décorateur où écrire sa
politique : elle prend le même défaut fermé, et sa politique se pose **sur la classe**, par
`@RealtimePolicy` (`realtimeDecorators.ts#RealtimePolicy`) — écrite une fois, à la définition de
la classe, dans le même registre :

```ts ignore
@RealtimePolicy({
  "orders:quote": { roles: ["ROLE_SALES"] },
  "orders:user:*": { authenticated: true }, // un motif garde une famille de canaux
})
class OrdersRealtime extends ContractRealtimeController<OrdersContract> { … }
```

Sur un contrôleur de contrat, `@RealtimePolicy` lit le contrat sur la classe : un nom mal
orthographié ne compile pas, au lieu de garder un canal fantôme pendant que le vrai reste au
défaut. Si une politique est déjà déclarée sous le même nom (canal
homonyme), elle est conservée : le défaut ne rétrograde jamais une règle plus stricte.

### Situation 2 — les namespaces réservés portent un plancher

Un nom qui commence par `nodefony:` hérite d'un plancher non contournable : authentifié **et**
`ROLE_NODEFONY_ADMIN` (`SYSTEM_CHANNEL_POLICY`, `frameAuthorizer.ts#SYSTEM_CHANNEL_POLICY`) — un rôle de la plateforme,
pas le `ROLE_ADMIN` d'une organisation. Un seul préfixe couvre donc tout
ce que la plateforme expose (`DEFAULT_SYSTEM_PREFIXES`, `frameAuthorizer.ts#DEFAULT_SYSTEM_PREFIXES`). C'est ce qui
protège `nodefony:kernel:gc` : le nom **est** la garde, et la comparaison est insensible à la casse
pour qu'un `NODEFONY:kernel:gc` ne passe pas à côté.

Ce plancher **prime sur ta déclaration** — y compris sur `{ authenticated: false }`. Et il ne
s'arrête pas aux préfixes : tout nom **contenant** `:health` ou `:stats` est traité comme un canal
d'observabilité (`matchSystemPolicy`, `frameAuthorizer.ts#matchSystemPolicy`). Une action `public:health` reste
donc réservée, quoi qu'en dise son décorateur. À savoir avant de nommer une action « ouverte ».

### Situation 3 — couvrir SES actions par la configuration

Pour exiger un rôle sur tes propres actions, on déclare une règle de préfixe dans la
configuration de sécurité — la même liste que pour les canaux
(`realtimeChannels`, `security/nodefony/config/config.ts#securityConfigSchema`) :

```ts ignore
use("@nodefony/security", {
  realtimeChannels: [
    // Couvre AUSSI bien le canal `orders:feed` que l'action `orders:quote` :
    // le verrou résout une politique par NOM, sans savoir ce qu'il garde.
    { pattern: "orders:", authenticated: true, roles: ["ROLE_USER"] },
  ],
});
```

### Savoir QUI appelle — `@CurrentUser` et les paramètres décorés

Une action authentifiée sait que l'appelant est identifié ; elle peut aussi savoir **qui** il est.
Les décorateurs de paramètres d'une route y prennent le même sens
(`actionParamsWrapper`, `RealtimeController.ts#RealtimeController.actionParamsWrapper`) :

```ts ignore
import { Body, CurrentUser } from "@nodefony/framework";
import type { IUser } from "@nodefony/user";

// Le client : socket.request("chat:send", { text: "salut" })
@RealtimeAction("chat:send")
send(@Body("text") text: string, @CurrentUser() user: IUser) {
  // L'auteur vient du SERVEUR : le client ne peut pas mentir dessus.
  return { author: user.identifier, text };
}
```

| Décorateur                                | Ce qu'il reçoit dans une action                             |
| ----------------------------------------- | ----------------------------------------------------------- |
| `@Body()` / `@Body("clé")`                | la charge de l'appel (`params`), ou l'une de ses clés       |
| `@CurrentUser()`                          | l'utilisateur de la connexion, `undefined` pour un anonyme  |
| `@Query()` / `@Param()`                   | un objet vide — une action n'a ni URL ni variables de route |
| `@Headers()` / `@Cookie()` / `@Session()` | ceux de la requête d'ouverture de la socket                 |

Une action **sans** paramètre décoré reçoit la charge brute en premier argument, comme avant —
rien ne change pour elle, ni comportement ni coût.

> [!IMPORTANT]
> **L'identité est celle de l'ouverture de la socket.** Elle n'est pas relue à chaque appel : le
> hub la revalide toutes les 30 secondes (`REVOCATION_REVALIDATE_MS`, `RealtimeHub.ts#channelFamily`) et
> ferme la socket en `4001` si la session est morte (`RealtimeHub.ts#RealtimeHub.probe`). Après une
> déconnexion, une action peut donc encore voir l'ancien utilisateur pendant cette fenêtre. Pour
> une écriture sensible — paiement, droits, suppression — passe par une route
> `POST + WEBSOCKET` appelée par `socket.post()` : ce pont revalide l'identité à **chaque**
> appel et porte la clé d'idempotence.

### La règle DEV-only

Une action réservée au développement se contrôle **dans le handler, côté serveur** — jamais par un
drapeau d'interface. Cacher un bouton n'empêche personne de forger la frame. Le modèle est
`nodefony:scaffold:run` dans Studio, qui refuse net hors développement avant même de lire ses paramètres
(`StudioRealtimeController.ts#StudioRealtimeController.actionScaffoldRun`).

> [!CAUTION]
> Le défaut fermé protège de l'oubli, pas de la sur-ouverture. Une action mutable reste la surface
> la plus facile à sous-estimer : elle n'apparaît dans aucune table de routes HTTP et aucun test
> d'API REST ne la couvre. « Authentifié » ne veut pas dire « autorisé à faire _ça_ » — pour une
> action qui écrit, exige un rôle. Avant de livrer, relis tes `@RealtimeAction` et demande-toi,
> pour chacune : « qu'arrive-t-il si le premier compte venu l'appelle en boucle ? »

## ⏱️ Délai, abandon et rejeu

### Le délai d'expiration est la seule libération automatique

`request()` prend le délai en **troisième argument positionnel**, en millisecondes — il n'y a pas
d'objet d'options (`NodefonySocket.request()`, `NodefonySocket.ts#NodefonySocket.request`) :

```ts ignore
await socket.request("orders:quote", { orderId }); // 30 000 ms par défaut
await socket.request("orders:export", { scope }, 120_000); // action longue
```

| Défaut    | Valeur    | Ancrage                              |
| --------- | --------- | ------------------------------------ |
| `request` | 30 000 ms | `JsonRpcPeer.ts#JsonRpcPeer.request` |

À l'expiration, l'entrée en attente est **retirée** et la `Promise` rejetée avec
`RPC timeout: <méthode>` (`JsonRpcPeer.ts#JsonRpcPeer.startCall`). Conséquence à connaître : une réponse qui
arriverait après coup ne trouve plus personne et est ignorée en silence
(`JsonRpcPeer.ts#JsonRpcPeer.handleResponse`) — pas de résolution tardive, pas de fuite.

### Abandonner : ce qui existe vraiment

> [!CAUTION]
> **Il n'y a pas d'annulation d'un appel en cours.** La socket n'expose ni `AbortController` ni
> `signal` : cherche-les, tu ne les trouveras pas. Et même s'ils existaient, ils ne changeraient
> rien au fait capital — **abandonner l'attente n'arrête pas l'exécution serveur**. Si le handler
> a commencé à modifier l'état, il finira son travail, seul.

Trois leviers existent, et ils ne font pas la même chose :

1. **Le délai d'expiration** — libère le client, laisse le serveur travailler.
2. **La fermeture de la connexion** — `dispose()` rejette **tous** les appels en attente d'un
   coup (`JsonRpcPeer.ts#JsonRpcPeer.dispose`), appelé au nettoyage de la socket
   (`RealtimeController.ts#RealtimeController.onHandshake`). Là encore : côté client seulement.
3. **Une action compagnon** — la seule vraie annulation. On expose une seconde action qui prend
   l'identifiant du travail et l'interrompt côté serveur. Le modèle du dépôt est
   `nodefony:scaffold:cancel` (`StudioRealtimeController.ts#StudioRealtimeController.actionScaffoldRun`), pendant de `nodefony:scaffold:run`.

### Rejouer : idempotence par action

Un rejeu n'est pas un cas rare : une socket se reconnecte, une frame peut repartir, un utilisateur
reclique. La question à se poser pour chaque action est donc **« deux fois font-elles comme une
fois ? »**.

| Action                         | Idempotente ?                  | Rejeu sûr ?                          |
| ------------------------------ | ------------------------------ | ------------------------------------ |
| `nodefony:kernel:ping`         | oui — lecture pure             | oui                                  |
| `nodefony:kernel:gc`           | oui — l'effet est un cycle GC  | oui, mais coûteux (pause du process) |
| `nodefony:realtime:throughput` | oui — charge déterministe      | oui, mais jusqu'à 1 Mo par appel     |
| `api.request` en lecture       | oui — c'est un `GET`           | oui                                  |
| `nodefony:scaffold:cancel`     | oui — annuler deux fois annule | oui                                  |
| `nodefony:scaffold:run`        | **non** — crée un travail      | **non** : deux appels, deux jobs     |
| une écriture (`socket.post`)   | **non** par nature             | oui, **avec une clé d'idempotence**  |

Pour les écritures passant par le pont API, la clé n'est pas une convention : la socket la **pose
seule** sur chaque `post`/`put`/`patch`/`delete` (`NodefonySocket.ts#NodefonySocket.post`), la
trame rejouée après une reconnexion porte la même, et c'est la garde `@Idempotent`
(`routerDecorators.ts#Idempotent`) qui, côté serveur, reconnaît le rejeu et rend la réponse déjà calculée
au lieu de refaire l'effet.

```ts ignore
// rejouer cette trame ne révoque qu'une fois — la clé est posée par la socket
await socket.post("/nodefony/security/api/apikeys/42/revoke");
// dédoublonner une INTENTION (double clic) : l'écran fournit sa clé
await socket.post("/orders", panier, { idempotencyKey: panier.id });
```

> [!IMPORTANT]
> Pour une action **maison** non idempotente, la même discipline s'applique mais **rien ne
> l'impose** : c'est à ton handler d'accepter un identifiant fourni par l'appelant et de mémoriser
> le résultat déjà rendu. Sans cela, une reconnexion malheureuse double l'effet — deuxième
> commande, deuxième courriel, deuxième débit.

## 🧩 Un flux ne tient pas dans une réponse

C'est l'erreur de conception la plus fréquente : vouloir faire progresser un travail long à
l'intérieur d'un appel. **Une action, c'est un aller-retour** — une seule réponse, envoyée à la
fin. Une génération de code, un import de fichier, une réponse de modèle de langage produisent au
contraire des résultats **au fil de l'eau**, pendant des minutes.

Le remède tient en trois gestes, et il est déjà en production dans Studio :

1. **L'action lance et rend un identifiant**, sans attendre — `nodefony:scaffold:run`
   (`StudioRealtimeController.ts#StudioRealtimeController.actionScaffoldRun`) démarre le travail et rend son état, dont son `id`.
2. **Le flux se diffuse sur un canal dédié**, nommé d'après ce travail — `nodefony:scaffold:job@<id>`,
   servi par le contrôleur (`StudioRealtimeController.ts#StudioRealtimeController.actionScaffoldCancel`).
3. **Le client s'abonne à ce canal** et regarde le travail se faire, ligne après ligne
   (`Create.tsx#Create`).

### La course, et comment le dépôt la neutralise

Le danger classique de ce découpage est une **course** : entre le retour de l'action et
l'abonnement du client, les premières lignes sont émises et personne ne les écoute. La règle
prudente est donc de **s'abonner avant** de lancer l'appel.

Nodefony a choisi la garantie côté serveur plutôt que la discipline côté client : le producteur du
canal **rejoue son historique** au nouvel abonné, de sorte qu'un arrivant tardif voit tout depuis
le début (`ScaffoldService.subscribe()`, `ScaffoldService.ts#ScaffoldService.subscribe`). C'est ce qui autorise le front
à faire l'appel d'abord et à s'abonner ensuite, sans rien perdre.

> [!TIP]
> Retiens la règle sous cette forme : **s'abonner avant de lancer**, sauf si le producteur du canal
> rejoue explicitement son historique. Un canal sans historique ne rattrape rien — c'est une
> propriété du module : un abonnement ne donne accès qu'à l'avenir.

### Et le streaming du protocole ?

**Il n'y en a pas.** Une action rend **une** valeur (`RpcActionHandler`, `JsonRpcPeer.ts#RpcActionHandler`), que
le pair emballe en une réponse unique. Pour tout ce qui progresse — une réponse mot à mot d'un
modèle de langage, un export qui avance, un traitement long — la voie est le motif
**« travail + canal »** : l'action accuse réception, et la progression arrive sur un canal.

Le protocole a porté un temps une réception en morceaux dont **aucun serveur n'émettait la
contrepartie** : elle a été retirée plutôt que publiée à moitié. Un vrai streaming demande de
pouvoir **annuler** un flux en cours, **signaler une erreur au milieu** et **réguler le débit** —
il sera conçu avec son premier consommateur réel.

## 🔌 Le pont API — la même action, servie sur la socket

Un cas particulier mérite d'être connu avant d'écrire une action : **elle existe peut-être déjà en
HTTP**. Le pont API expose la méthode `api.request`, qui rejoue une route de contrôleur sur la
socket, avec la même garde et le même résultat qu'en REST — `invokeApiRequest()`
(`RealtimeController.ts#RealtimeController.invokeApiRequest`). Il est **désactivé par défaut** et s'active en surchargeant
`realtimeApiRequest()` (`RealtimeController.ts#RealtimeController.realtimeApiRequest`).

```ts ignore
const modules = await socket.get("/nodefony/kernel/api/modules");
const { sentAt } = await socket.post("/chat/send", { text: "salut" });
```

Chaque méthode porte le **verbe HTTP** que la route déclare — `get`, `post`, `put`, `patch`,
`delete` (`NodefonySocket.ts#NodefonySocket.get`) : une route `methods: ["POST", "WEBSOCKET"]` s'appelle
`socket.post(…)`, rien d'autre à apprendre. `request()` reste l'appel d'une **action RPC** par son nom.
Écris une action RPC pour ce qui n'a de sens **que** sur la socket ; passe par le pont pour tout ce
qui est déjà une route. Le détail du pont vit dans le [vocabulaire](../vocabulaire/) et
l'[architecture](../architecture/).

### Le contrat type les routes

Sans contrat, `get` et `post` rendent `unknown` et acceptent n'importe quel corps. Le contrat de
l'application (`IRealtimeContract`, `RealtimeEventMap.ts#IRealtimeContract`) les type par la clé
`serverRoutes` : `"<VERBE> <chemin>"` → le schéma que la route **valide**, référencé par `typeof`
(une seule source — jamais une copie), et ce qu'elle rend.

```ts ignore
// shared/chatContract.ts — types seuls, aucun octet dans le navigateur
import type { SendBody } from "./chatSchemas";
export interface ChatContract extends IRealtimeContract {
  serverRoutes: {
    "POST /chat/send": { body: typeof SendBody; result: { sentAt: number } };
  };
}

// navigateur
const chat = NodefonySocket.fromContract<ChatContract>({
  url: "/chat/realtime",
});
await chat.post("/chat/send", { text: "salut" }); // { sentAt: number }
await chat.post("/chat/send", { texte: "x" }); // ✗ ne compile pas
```

Le contrat est un **type** : il ferait compiler un appel vers une route absente. Le contrôleur qui
l'implémente déclare donc la **valeur** — la table statique `serverRoutes`, `satisfies
ContractRouteSchemas<C>` — et le démarrage la confronte aux routes montées
(`contractRoutes.ts#assertContractRoutes`) : route absente, route sans transport `WEBSOCKET` pour ce
verbe, ou autre schéma que celui que la route valide → démarrage refusé, l'entrée nommée. Un
chemin hors contrat reste libre. Une route à **paramètre de chemin** se déclare par son gabarit
(`"GET /chat/messages/{id}": { params: typeof IdParams; result }`) et s'appelle en le nommant :
`chat.get("/chat/messages/{id}", { params: { id } })` — la socket encode chaque valeur pour son
segment (`runtime/routePath.ts#fillRoutePath`) et refuse une valeur manquante, vide, `.` ou `..`.
Seuls des segments `{nom}` entiers se déclarent (`routePath.ts#isSimpleRouteTemplate`) : une
contrainte passe par `requirements`. Le chat complet, messages privés compris :
[cookbook](../cookbook-chat/#quatre--un-contrat-pour-les-deux-côtés--lenvoi-et-les-messages-privés-typés).

## ⚠️ Pièges

| Symptôme                                                            | Cause                                                                              | Correction                                                                                   |
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `RPC timeout: <méthode>` alors que le serveur a bien répondu        | le handler dure plus que 30 s, le défaut                                           | passer le délai en **3ᵉ argument** : `request(m, p, 120_000)`                                |
| Le client attend indéfiniment, aucune erreur                        | le handler ne rend jamais (il publie au lieu de retourner) — pas de frame `result` | toujours `return` une valeur ; publier **en plus**, jamais **à la place**                    |
| `-32601 method not found`                                           | nom mal orthographié, ou action déclarée sur un **autre** endpoint                 | vérifier `socket.serverMethods` — c'est la liste réelle de CETTE connexion                   |
| `-32603 internal error` sans détail                                 | un `throw` ordinaire est rendu opaque au client (Zero Trust)                       | lever une `RpcError` avec un code et un message publiables                                   |
| `api.request` rejeté avec `data.status: 422`                        | la route invoquée valide son entrée (`schema`, ou une `ZodError` de l'action)      | lire `data.fields` : chaque champ fautif, sa partie (`in`) et son message                    |
| `-32001 unauthorized` sur une action légitime                       | le nom commence par `nodefony:`, le namespace réservé à la plateforme              | renommer hors de `nodefony:`, ou obtenir `ROLE_NODEFONY_ADMIN`                               |
| Une action est refusée à un anonyme alors qu'on la croyait publique | une action est fermée par défaut (`{ authenticated: true }`)                       | déclarer `{ authenticated: false }` pour l'ouvrir ; un rôle se pose par une règle de préfixe |
| Un travail relancé crée deux jobs                                   | action non idempotente rejouée après une reconnexion                               | action compagnon d'annulation, ou identifiant fourni par l'appelant + mémorisation           |
| `abort()` introuvable sur la socket                                 | il n'existe pas — et n'arrêterait pas le serveur de toute façon                    | exposer une action d'annulation qui prend l'identifiant du travail                           |
| Les premières lignes d'un job manquent                              | abonnement au canal après le début de la production, sans historique rejoué        | s'abonner **avant** de lancer, ou faire rejouer l'historique par le producteur               |
| Toutes les `Promise` rejettent d'un coup                            | la connexion s'est fermée : `dispose()` vide la table des appels en attente        | attendu ; relancer après reconnexion, l'appel n'a pas survécu                                |

## 🧪 Tests & couverture

Les actions sont couvertes à trois étages — protocole, contrôleur, sécurité. 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.

- **Unitaires, protocole** (`JsonRpcPeer.test.ts`) : corrélation requête/réponse, méthode inconnue
  `-32601`, `throw` opaque `-32603`, propagation fidèle d'une `RpcError`, expiration du délai,
  audit des frames.
- **Unitaires, déclaration** (`realtimeDecorators.test.ts`) : `@RealtimeAction` enregistre bien le
  nom et lie le `this` ; (`RealtimeController.test.ts`) : fusion décorateurs + override et
  annonce dans l'accueil.
- **Unitaires, client** (`NodefonySocketCoverage.test.ts`) : formes de `request`, contrat de
  streaming, rejet des appels en attente au `dispose`.
- **Attaque, autorisation** (`realtimeFrameLock.test.ts`) : `nodefony:kernel:ping` et `nodefony:kernel:gc` refusés à
  l'anonyme **et** à l'utilisateur authentifié, acceptés à l'administrateur — la preuve que le
  plancher de namespace garde bien les actions, pas seulement les canaux.
- **Bout en bout** (`realtimeControllerPaths.e2e.test.ts`, `realtimeChannelAuth.e2e.test.ts`) :
  serveur réel, résolution d'endpoint et pont API.
- **Ce qui manque** : aucun banc de **charge** dédié aux actions (le débit mesuré est celui du
  fan-out, pas celui des appels corrélés). Pour en monter un, voir le skill
  `nodefony-load-test` ; pour la mémoire, `nodefony-check-memory-health`.

### Éprouver SON action — le harnais, identité comprise

Une application teste ses actions sans serveur ni navigateur avec `createRealtimeHarness`
(`@nodefony/realtime/testing`, `nodefony/testing/index.ts#createRealtimeHarness`) : il monte le contrôleur sur une
fausse connexion, envoie les frames qu'un client enverrait et rend celles qui sortent. Sans option,
la connexion est **anonyme** — le repli Zero Trust. Une action qui lit `@CurrentUser()` s'éprouve
donc avec l'identité qu'aurait posée le pare-feu au handshake, passée en `identity`
(`nodefony/testing/index.ts#IRealtimeHarnessOptions.identity`) :

```ts ignore
import { describe, it, expect, afterEach } from "vitest";
import { getRealtimeHub } from "@nodefony/realtime";
import type { IRealtimeToken } from "@nodefony/realtime";
import { createRealtimeHarness } from "@nodefony/realtime/testing";
import ChatController from "../nodefony/controllers/ChatController";

describe("ChatController — actions", () => {
  afterEach(() => getRealtimeHub().clear());

  it("l'action reçoit son appelant par @CurrentUser", async () => {
    const alice = { identifier: "alice" };
    const attributes = new Map<string, unknown>([["user", alice]]);
    const identity: IRealtimeToken = {
      type: "session",
      getUserIdentifier: () => alice.identifier,
      isAuthenticated: () => true,
      getRoles: () => ["ROLE_USER"],
      getScopes: () => [],
      getAttribute: <T>(key: string) => attributes.get(key) as T | undefined,
    };
    const h = createRealtimeHarness((ctx) => new ChatController(ctx), {
      identity,
    });
    await h.connect();
    const me = await h.call<{ identifier: string }>("chat:whoami");
    expect(me.identifier).toBe("alice");
    h.dispose();
  });
});
```

C'est l'attribut `user` du jeton que `@CurrentUser()` rend. Pour éprouver un **refus** (`@IsGranted`,
`policy` de canal), il faut en plus le verrou de frame de `@nodefony/security`, passé en
`frameAuthorizer` : `@nodefony/realtime` ne connaît pas la sécurité, et sans ce verrou une règle
déclarée n'est appliquée par personne — en test comme en production.

`@nodefony/security/testing` fournit les deux pièces, et c'est la forme à préférer au jeton écrit à
la main ci-dessus : `createTestIdentity({ identifier, roles })`
(`security/nodefony/testing/index.ts#createTestIdentity`) rend l'identité authentifiée, et
`createTestFrameAuthorizer(getRealtimeHub())` (`security/nodefony/testing/index.ts#createTestFrameAuthorizer`) le **vrai**
verrou du pare-feu — même fabrique, même hiérarchie de rôles, mêmes canaux système qu'au runtime.
Un verrou factice qui ne regarde que `isAuthenticated()` laisse passer au vert un canal mal protégé.

````ts ignore
import { getRealtimeHub } from "@nodefony/realtime";
import { createRealtimeHarness } from "@nodefony/realtime/testing";
import {
  createTestFrameAuthorizer,
  createTestIdentity,
} from "@nodefony/security/testing";

const h = createRealtimeHarness((ctx) => new ChatController(ctx), {
  identity: createTestIdentity({ identifier: "bob", roles: ["ROLE_USER"] }),
  frameAuthorizer: createTestFrameAuthorizer(getRealtimeHub()),
});
``` La table des membres du
harnais et l'exemple de refus sont dans le `README.md` du paquet, section « Tester un controller ».
`nodefony create controller --kind realtime` génère ce test, identité comprise, quand l'application
porte la sécurité.

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

## 🔗 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/)
- Le trajet d'une frame, du transport au hub → [architecture](../architecture/)
- Le verrou de frame, le handshake, les politiques de canal → [sécurité](../securite/)
- La garde qui décide, côté HTTP comme WebSocket → [firewall](../../security/firewall/)
- Les signatures exactes ne sont pas recopiées ici : elles vivent dans le graphe symbolique
  `.ai/symbols.json`, régénéré depuis les TSDoc du code.
````
