@nodefony/realtime 10.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +207 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +218 -0
  6. package/dist/nodefony/config/config.js +63 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +60 -0
  8. package/dist/nodefony/decorators/realtimeDecorators.js +240 -0
  9. package/dist/nodefony/interfaces/IBackplane.js +1 -0
  10. package/dist/nodefony/interfaces/IChannelPolicy.js +1 -0
  11. package/dist/nodefony/interfaces/IClientLogsLimits.js +1 -0
  12. package/dist/nodefony/interfaces/IRealtimeAuthenticator.js +1 -0
  13. package/dist/nodefony/interfaces/IRealtimeAuthenticatorMatcher.js +1 -0
  14. package/dist/nodefony/interfaces/IRealtimeController.js +1 -0
  15. package/dist/nodefony/interfaces/IRealtimeHandshake.js +1 -0
  16. package/dist/nodefony/interfaces/IRealtimeProbe.js +1 -0
  17. package/dist/nodefony/interfaces/IRealtimeToken.js +1 -0
  18. package/dist/nodefony/interfaces/index.js +1 -0
  19. package/dist/nodefony/src/backplane/ClusterBackplane.js +105 -0
  20. package/dist/nodefony/src/backplane/LoopbackBackplane.js +40 -0
  21. package/dist/nodefony/src/backplane/RedisBackplane.js +176 -0
  22. package/dist/nodefony/src/backplane/backplaneRegistry.js +20 -0
  23. package/dist/nodefony/src/backplane/envelope.js +109 -0
  24. package/dist/nodefony/src/backplane/originId.js +35 -0
  25. package/dist/nodefony/src/backplane/publishQueue.js +96 -0
  26. package/dist/nodefony/src/cluster/ClusterProbeClient.js +284 -0
  27. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +35 -0
  28. package/dist/nodefony/src/server/RealtimeAdminApi.js +87 -0
  29. package/dist/nodefony/src/server/RealtimeController.js +601 -0
  30. package/dist/nodefony/src/server/RealtimeHub.js +926 -0
  31. package/dist/nodefony/src/server/ServerRealtimeSocket.js +168 -0
  32. package/dist/nodefony/src/server/deniedDetail.js +29 -0
  33. package/dist/nodefony/src/server/syslogUplink.js +105 -0
  34. package/dist/nodefony/src/server/welcomeEnv.js +25 -0
  35. package/dist/nodefony/src/service/RealtimeService.js +240 -0
  36. package/dist/nodefony/src/transport/WsConnectionTransport.js +94 -0
  37. package/dist/nodefony/testing/index.js +163 -0
  38. package/dist/types/index.d.ts +99 -0
  39. package/dist/types/nodefony/config/config.d.ts +43 -0
  40. package/dist/types/nodefony/config/defineModuleConfig.d.ts +49 -0
  41. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +190 -0
  42. package/dist/types/nodefony/interfaces/IBackplane.d.ts +128 -0
  43. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +28 -0
  44. package/dist/types/nodefony/interfaces/IClientLogsLimits.d.ts +22 -0
  45. package/dist/types/nodefony/interfaces/IRealtimeAuthenticator.d.ts +53 -0
  46. package/dist/types/nodefony/interfaces/IRealtimeAuthenticatorMatcher.d.ts +46 -0
  47. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +36 -0
  48. package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +27 -0
  49. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +206 -0
  50. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +48 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +18 -0
  52. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +76 -0
  53. package/dist/types/nodefony/src/backplane/LoopbackBackplane.d.ts +29 -0
  54. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +164 -0
  55. package/dist/types/nodefony/src/backplane/backplaneRegistry.d.ts +51 -0
  56. package/dist/types/nodefony/src/backplane/envelope.d.ts +27 -0
  57. package/dist/types/nodefony/src/backplane/originId.d.ts +22 -0
  58. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +71 -0
  59. package/dist/types/nodefony/src/cluster/ClusterProbeClient.d.ts +90 -0
  60. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +3 -0
  61. package/dist/types/nodefony/src/server/RealtimeAdminApi.d.ts +18 -0
  62. package/dist/types/nodefony/src/server/RealtimeController.d.ts +165 -0
  63. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +516 -0
  64. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +63 -0
  65. package/dist/types/nodefony/src/server/deniedDetail.d.ts +25 -0
  66. package/dist/types/nodefony/src/server/syslogUplink.d.ts +58 -0
  67. package/dist/types/nodefony/src/server/welcomeEnv.d.ts +21 -0
  68. package/dist/types/nodefony/src/service/RealtimeService.d.ts +167 -0
  69. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +78 -0
  70. package/dist/types/nodefony/testing/index.d.ts +169 -0
  71. package/docs/actions.md +573 -0
  72. package/docs/architecture.md +889 -0
  73. package/docs/configuration.md +803 -0
  74. package/docs/cookbook-chat.md +1619 -0
  75. package/docs/couverture-bancs.md +139 -0
  76. package/docs/index.md +372 -0
  77. package/docs/observabilite.md +592 -0
  78. package/docs/protocole.md +518 -0
  79. package/docs/securite.md +845 -0
  80. package/docs/vocabulaire.md +845 -0
  81. package/package.json +83 -0
@@ -0,0 +1,518 @@
1
+ ---
2
+ title: "Protocole — la grammaire des frames JSON-RPC 2.0"
3
+ navTitle: Protocole
4
+ lang: fr
5
+ module: "@nodefony/realtime"
6
+ topic: realtime
7
+ coverageModule: realtime
8
+ section: "Realtime"
9
+ audience: [developer]
10
+ tags:
11
+ [
12
+ protocole,
13
+ json-rpc,
14
+ frame,
15
+ pair,
16
+ rpc,
17
+ notification,
18
+ welcome,
19
+ erreurs,
20
+ duplex,
21
+ ]
22
+ version: "doc"
23
+ status: stable
24
+ updated: 2026-07-20
25
+ source: "src/packages/@nodefony/realtime/docs/protocole.md"
26
+ ---
27
+
28
+ # Protocole — la grammaire des frames JSON-RPC 2.0
29
+
30
+ > Une connexion temps réel Nodefony ne transporte pas « des messages » : elle transporte des
31
+ > **frames** dont la forme est normée. Trois formes existent, et une seule règle les distingue —
32
+ > **la nature d'une frame se lit sur `method`, jamais sur `id`**. Cette page donne la grammaire
33
+ > champ par champ, les méthodes que le serveur comprend nativement, et ce qui revient exactement
34
+ > quand ça rate. Le moteur est `JsonRpcPeer` (`JsonRpcPeer.ts:271`), écrit une fois et branché des
35
+ > deux côtés du fil.
36
+
37
+ 📍 [Documentation](../../../../../docs/index.md) › [Realtime](index.md) › **Protocole**
38
+
39
+ ```nodefony-livegraph
40
+ {
41
+ "graph": "protocole",
42
+ "height": 480,
43
+ "title": "Le protocole, en direct",
44
+ "hint": "Les formes de frame qui circulent sur la socket de Studio — celle qui sert cette page."
45
+ }
46
+ ```
47
+
48
+ ## 🧠 Schéma général — trois formes, un seul moteur
49
+
50
+ Le pair classe chaque frame entrante avant toute chose. Ce classement — le **dispatch** — est le
51
+ cœur du protocole : il décide s'il faut répondre, à qui, et si une réponse est même possible.
52
+
53
+ ```mermaid
54
+ flowchart TD
55
+ IN["Frame entrante (JSON déjà parsé)"] --> V{"jsonrpc = 2.0 ?"}
56
+ V -->|non| INV["invalid — auditée, AUCUNE réponse"]
57
+ V -->|oui| M{"method présent ?"}
58
+ M -->|"method + id"| REQ["requête — un handler doit rendre result ou error"]
59
+ M -->|"method seul"| NOT["notification — pub/sub, aucune réponse"]
60
+ M -->|"id seul"| RES["réponse — résout une requête sortante en attente"]
61
+ REQ --> OUT["result · error"]
62
+ NOT --> HUB["subscribe · unsubscribe · canal entrant"]
63
+ RES --> PROM["la Promise de l'appelant"]
64
+ ```
65
+
66
+ Deux conséquences que le reste de la page décline :
67
+
68
+ 1. **Une notification ne peut pas échouer visiblement** — sans `id`, il n'existe aucun canal de
69
+ réponse. C'est pourquoi Nodefony ajoute une notification de refus (voir plus bas).
70
+ 2. **Le protocole est symétrique** : le serveur appelle le client exactement comme le client appelle
71
+ le serveur. Ce n'est pas un aller-retour déguisé, c'est du duplex.
72
+
73
+ ## 📖 Lexique
74
+
75
+ | Terme | Sens |
76
+ | ---------------- | ------------------------------------------------------------------------------------- |
77
+ | JSON-RPC 2.0 | Norme publique d'appel de procédure à distance en JSON, publiée en 2010, symétrique. |
78
+ | Frame | L'unité atomique qui passe sur le fil : un objet JSON conforme à la norme. |
79
+ | Pair (peer) | Le moteur de protocole d'une connexion — `JsonRpcPeer`, identique client et serveur. |
80
+ | Requête | Frame avec `method` **et** `id` — appelle exactement une réponse. |
81
+ | Notification | Frame avec `method` seul — aucune réponse, jamais. |
82
+ | Réponse | Frame avec `id` seul, portant `result` **ou** `error`. |
83
+ | Dispatch | Classer une frame entrante puis la router vers son handler. |
84
+ | Enveloppe | Réponse qui joint des métadonnées serveur dans un champ **frère** du `result`. |
85
+ | Accueil, welcome | La 1ʳᵉ notification poussée par le serveur : protocole, canaux, actions, identité. |
86
+ | Canal entrant | Canal où le client a le droit de pousser ; le nom du canal **est** le nom de méthode. |
87
+ | Zero Trust | Ici : un échec non voulu ne révèle rien au pair (message générique, détail au log). |
88
+ | RTT | _Round-Trip Time_ — le temps d'un aller-retour complet sur la socket. |
89
+
90
+ ## Qu'est-ce qu'un protocole de frames — et pourquoi JSON-RPC 2.0 ?
91
+
92
+ Multiplexer N flux dans **une** connexion oblige à étiqueter chaque message : sans étiquette, le
93
+ récepteur ne sait pas si l'octet qui arrive est un message de chat, une réponse attendue, ou un
94
+ battement de cœur. Cette étiquette, c'est le protocole.
95
+
96
+ Nodefony ne l'a pas inventée. Le calcul, mis à plat :
97
+
98
+ | Critère | JSON-RPC 2.0 | Format maison |
99
+ | --------------------------- | ---------------------------------------- | ------------------------------------ |
100
+ | Spécifié, stable | oui, depuis 2010 | à inventer **et** à maintenir |
101
+ | Bidirectionnel | natif — la norme ne distingue pas client | à coder, puis à défendre en revue |
102
+ | Batch (plusieurs ops/frame) | prévu par la norme (tableau de frames) | à coder |
103
+ | Outillage | inspecteurs et bibliothèques existants | zéro |
104
+ | Coût mental | quasi nul, une frame se lit à l'œil | toujours plus, et jamais documenté |
105
+ | Erreurs | codes réservés, plage applicative dédiée | chacun réinvente sa table de statuts |
106
+
107
+ > [!NOTE]
108
+ > **Le batch est une propriété de la norme, pas du moteur Nodefony.** `JsonRpcPeer.receive()`
109
+ > (`JsonRpcPeer.ts:371`) attend un **objet** portant `jsonrpc: "2.0"` ; un tableau de frames n'en
110
+ > porte pas et tombe donc en `invalid`. Une frame = un objet. Le multiplexage rend le batch peu
111
+ > utile ici : les canaux voyagent déjà dans la même connexion.
112
+
113
+ **Le multiplexage, lui, n'est PAS dans la norme.** C'est Nodefony qui le construit par-dessus, en
114
+ se servant de `method` comme aiguillage : `subscribe`, `unsubscribe`, le nom d'un canal, le nom
115
+ d'une action. La norme fournit la grammaire ; le vocabulaire est nodefonien.
116
+
117
+ ## La vision Nodefony — un moteur écrit une fois, branché des deux côtés
118
+
119
+ Trois partis pris distinguent cette implémentation d'un simple « serveur WebSocket qui parle JSON ».
120
+
121
+ **Le même fichier tourne dans le navigateur et dans Node.** `JsonRpcPeer` (`JsonRpcPeer.ts:271`)
122
+ n'a aucune dépendance Node — seulement `setTimeout`. Classer, router, corréler les `id` : ce travail
123
+ est identique des deux côtés, il est donc écrit **une seule fois**. Chaque côté l'entoure de son
124
+ transport et de ses handlers. Historiquement, cette discrimination vivait à deux endroits qui
125
+ divergeaient ; c'est précisément la classe de bug que l'isomorphisme supprime.
126
+
127
+ **Le rôle se lit sur `method`, pas sur `id`.** La lecture naïve (« `id` présent = requête ») casse
128
+ sur les réponses, qui portent un `id` sans être des appels. `JsonRpcPeer.receive()`
129
+ (`JsonRpcPeer.ts:371`) teste donc `method` d'abord, puis la présence d'un `id`. Une frame `id` seul
130
+ est une **réponse** et ne déclenche jamais `-32601` à tort.
131
+
132
+ **Un échec ne raconte rien par défaut.** Un handler qui lève une exception ordinaire produit un
133
+ `-32603 "internal error"` **générique** (`JsonRpcPeer.ts:528`) : ni message d'origine, ni pile
134
+ d'appels. Pour exposer volontairement un refus au pair — un 404, un droit manquant — il existe une
135
+ porte explicite, `RpcError` (`JsonRpcPeer.ts:70`), et elle seule.
136
+
137
+ ## 🚀 Démarrage rapide
138
+
139
+ Un endpoint qui expose **une action RPC** et un client qui l'appelle, vus d'une application créée
140
+ par `nodefony create app`. C'est le plus court chemin pour voir les trois formes de frame.
141
+
142
+ ### Le contrôleur — une action, deux façons d'échouer
143
+
144
+ ```ts
145
+ // nodefony/controllers/ChatController.ts
146
+ import { controller, route } from "@nodefony/framework";
147
+ import {
148
+ RealtimeController,
149
+ RealtimeAction,
150
+ RealtimeChannel,
151
+ } from "@nodefony/realtime";
152
+ import type { RealtimePublish } from "@nodefony/realtime";
153
+ import type { ContextType } from "@nodefony/http";
154
+ import { RpcError } from "nodefony";
155
+
156
+ interface ChatHistory {
157
+ messages: string[];
158
+ }
159
+
160
+ @controller("/chat")
161
+ class ChatController extends RealtimeController {
162
+ constructor(context: ContextType) {
163
+ super("chat", context);
164
+ }
165
+
166
+ @route("chat-realtime", {
167
+ path: "/realtime",
168
+ requirements: { methods: ["WEBSOCKET"] },
169
+ })
170
+ async realtime(message: string | Buffer | null): Promise<void> {
171
+ this.handleRealtime(message);
172
+ }
173
+
174
+ // Un CANAL : son nom sera annoncé dans l'accueil, et servira de `method`
175
+ // aux notifications que le serveur poussera vers les abonnés.
176
+ @RealtimeChannel("chat:room-42")
177
+ room(_channel: string, _publish: RealtimePublish): () => void {
178
+ return () => {};
179
+ }
180
+
181
+ // Une ACTION : appelée par une REQUÊTE (method + id), la valeur rendue
182
+ // devient le `result` de la réponse corrélée.
183
+ @RealtimeAction("chat:history")
184
+ history(params: unknown): ChatHistory {
185
+ const room = (params as { room?: unknown } | undefined)?.room;
186
+ // Refus ASSUMÉ : `RpcError` choisit ce que le pair voit (code + data).
187
+ if (typeof room !== "string") {
188
+ throw new RpcError("params.room manquant", -32602);
189
+ }
190
+ if (room !== "room-42") {
191
+ throw new RpcError("salon inconnu", -32000, { status: 404 });
192
+ }
193
+ // Un throw ORDINAIRE ici deviendrait `-32603 internal error`, opaque.
194
+ return { messages: ["salut"] };
195
+ }
196
+ }
197
+
198
+ export default ChatController;
199
+ ```
200
+
201
+ ### Le client — l'accueil, l'abonnement, l'appel corrélé
202
+
203
+ ```ts
204
+ // frontend/src/chat.ts
205
+ import { RealtimeClient, RpcError } from "nodefony/client";
206
+
207
+ interface ChatHistory {
208
+ messages: string[];
209
+ }
210
+
211
+ export async function joinChat(): Promise<void> {
212
+ const socket = new RealtimeClient({
213
+ url: "wss://127.0.0.1:5152/chat/realtime",
214
+ });
215
+ await socket.connect();
216
+
217
+ // L'ACCUEIL est déjà ingéré : la carte du territoire, sans taper une route.
218
+ console.log(socket.serverMethods); // actions exposées par l'endpoint
219
+ console.log(socket.serverChannels); // canaux annoncés
220
+ console.log(socket.identity); // qui je suis, tel que le serveur l'a résolu
221
+
222
+ // `on` REÇOIT (handler local), `subscribe` DEMANDE le flux au serveur.
223
+ socket.on("chat:room-42", (payload: unknown) => console.log(payload));
224
+ socket.subscribe("chat:room-42"); // UN seul argument — pas de callback ici
225
+
226
+ try {
227
+ // REQUÊTE corrélée. Signature POSITIONNELLE : (méthode, params, délai en ms).
228
+ // Un objet `{ timeoutMs }` en 3ᵉ position ne serait PAS lu comme un délai.
229
+ const page = await socket.request<"chat:history", ChatHistory>(
230
+ "chat:history",
231
+ { room: "room-42" },
232
+ 5000,
233
+ );
234
+ console.log(page.messages);
235
+ } catch (err) {
236
+ // Le refus assumé du serveur arrive typé : code ET data traversent.
237
+ if (err instanceof RpcError) console.warn(err.code, err.message, err.data);
238
+ }
239
+ }
240
+ ```
241
+
242
+ ### Ce qu'on observe
243
+
244
+ Sur le fil, dans l'inspecteur réseau du navigateur, exactement cinq frames :
245
+
246
+ ```jsonc
247
+ // 1. serveur → client : l'accueil, aussitôt après le handshake
248
+ {"jsonrpc":"2.0","method":"realtime:welcome","params":{
249
+ "ts":1770000000000,"protocol":"jsonrpc-2.0",
250
+ "channels":["chat:room-42"],"methods":["chat:history"],
251
+ "identity":{"type":"anonymous","authenticated":false,
252
+ "userIdentifier":"anonymous","roles":["ROLE_ANONYMOUS"],"scopes":[]}}}
253
+
254
+ // 2. client → serveur : notification, aucun `id`
255
+ {"jsonrpc":"2.0","method":"subscribe","params":{"channel":"chat:room-42"}}
256
+
257
+ // 3. client → serveur : requête, `id` attribué par le pair
258
+ {"jsonrpc":"2.0","id":1,"method":"chat:history","params":{"room":"room-42"}}
259
+
260
+ // 4. serveur → client : réponse corrélée par le même `id`
261
+ {"jsonrpc":"2.0","id":1,"result":{"messages":["salut"]}}
262
+
263
+ // 5. serveur → client : push sur le canal — le nom du canal EST la `method`
264
+ {"jsonrpc":"2.0","method":"chat:room-42","params":{"text":"salut"}}
265
+ ```
266
+
267
+ L'accueil porte **cinq** champs : `ts`, `protocol`, `channels`, `methods`, `identity`
268
+ (`IRealtimeWelcome`, `RealtimeEventMap.ts:204`), émis par le contrôleur au handshake
269
+ (`RealtimeController.ts:8`). Il n'y a pas de champ `version`.
270
+
271
+ ## 🔌 Anatomie d'une frame, champ par champ
272
+
273
+ ### Le tronc commun
274
+
275
+ ```jsonc
276
+ {
277
+ "jsonrpc": "2.0", // signature de protocole — OBLIGATOIRE, sinon la frame est `invalid`
278
+ "method": "subscribe", // ce qu'on demande — sa PRÉSENCE fait de la frame un appel
279
+ "params": { "channel": "nodefony:orm:health" }, // la charge ; forme libre, jamais fiable côté serveur
280
+ "id": 42, // PRÉSENT = requête (réponse due) · ABSENT = notification
281
+ }
282
+ ```
283
+
284
+ | Champ | Obligatoire | Type accepté | Rôle exact |
285
+ | --------- | ------------------------- | -------------------- | ------------------------------------------------------------------------- |
286
+ | `jsonrpc` | oui, toujours | `"2.0"` littéral | Sans lui, `JsonRpcPeer.receive()` classe `invalid` (`JsonRpcPeer.ts:371`) |
287
+ | `method` | sur un appel | chaîne | Décide de la nature ET de l'aiguillage (canal, action, verbe pub/sub) |
288
+ | `params` | non | tout JSON | Charge applicative. Vient du réseau : **jamais** digne de confiance |
289
+ | `id` | sur une requête / réponse | nombre **ou** chaîne | Corrèle l'aller et le retour |
290
+
291
+ > [!IMPORTANT]
292
+ > Le pair accepte un `id` **chaîne** en entrée (conforme à la norme), mais il n'attribue jamais que
293
+ > des `id` **numériques** à ses propres appels sortants. Une réponse portant un `id` chaîne est donc
294
+ > ignorée sans erreur (`JsonRpcPeer.handleResponse()`, `JsonRpcPeer.ts:556`) : elle ne peut, par
295
+ > construction, correspondre à aucune requête émise par ce pair.
296
+
297
+ ### La réponse — `result` ou `error`, jamais les deux
298
+
299
+ ```jsonc
300
+ // succès — la valeur rendue par le handler, nue
301
+ { "jsonrpc": "2.0", "id": 42, "result": { "messages": ["salut"] } }
302
+
303
+ // échec — un objet error normalisé ; `data` est optionnel et libre
304
+ { "jsonrpc": "2.0", "id": 42, "error": { "code": -32601, "message": "method not found: chat:history" } }
305
+ ```
306
+
307
+ Côté client, les deux formes sont converties en une seule chose : une `Promise` résolue avec le
308
+ `result`, ou rejetée avec une `RpcError` qui **préserve `code` et `data`**. C'est ce qui permet de
309
+ distinguer un 404 d'un refus d'autorisation sans analyser un message de texte.
310
+
311
+ ### L'enveloppe — joindre une méta sans polluer le `result`
312
+
313
+ Un handler peut rendre une `RpcEnvelope` (`JsonRpcPeer.ts:104`) au lieu d'une valeur nue. Le pair la
314
+ déballe : le `result` reste **exactement** la valeur, et la méta voyage dans un champ frère
315
+ `meta` (`RpcMeta`, `JsonRpcPeer.ts:90`).
316
+
317
+ ```jsonc
318
+ {
319
+ "jsonrpc": "2.0",
320
+ "id": 42,
321
+ "result": { "…": "…" },
322
+ "meta": { "requestId": "c7.12" },
323
+ }
324
+ ```
325
+
326
+ Pourquoi ce détour plutôt qu'un champ de plus dans le `result` : le pont API garantit qu'une réponse
327
+ obtenue par la socket est **identique** à ce que rendrait la même route en REST. Glisser une méta
328
+ dans le `result` casserait cette égalité. Un pair qui ne connaît pas `meta` l'ignore — le champ est
329
+ rétro-compatible par construction.
330
+
331
+ ## 🧰 Les méthodes nominales
332
+
333
+ Ce que le serveur comprend **sans qu'aucun contrôleur ne l'ait déclaré**, plus les deux formes
334
+ ouvertes à l'application. Colonne `id` : présent = requête (réponse due), absent = notification.
335
+
336
+ | Méthode | Direction | `id` ? | Rôle | Ancrage |
337
+ | ------------------ | ------------- | :-----: | --------------------------------------------------------------------- | --------------------------- |
338
+ | `subscribe` | client→server | non | « pousse-moi ce canal » — `params.channel` | `RealtimeController.ts:459` |
339
+ | `unsubscribe` | client→server | non | « arrête » — dernier abonné, le producteur est libéré | `RealtimeController.ts:687` |
340
+ | `ping` | client→server | non | Battement de cœur — **no-op serveur**, aucun pong | `RealtimeClient.ts:740` |
341
+ | `<canal>` | server→client | non | Push d'un message : le **nom du canal est la `method`** | `RealtimeController.ts:612` |
342
+ | `<canal entrant>` | client→server | non | Le client pousse sur un canal déclaré entrant | `RealtimeController.ts:694` |
343
+ | `realtime:welcome` | server→client | non | L'accueil : 5 champs, dont l'identité résolue | `RealtimeController.ts:579` |
344
+ | `realtime:denied` | server→client | non | Rend OBSERVABLE le refus d'une notification | `RealtimeController.ts:433` |
345
+ | `api.request` | client→server | **oui** | Pont API — rejoue une route HTTP sur la socket (désactivé par défaut) | `RealtimeController.ts:498` |
346
+ | `<action>` | client→server | **oui** | Toute action déclarée par `@RealtimeAction` | `realtimeDecorators.ts:101` |
347
+
348
+ > [!TIP]
349
+ > **L'accueil est ta carte du territoire.** `methods` et `channels` sont construits à partir de ce
350
+ > que l'endpoint expose réellement (`RealtimeController.ts:566`) : un client peut activer ou griser
351
+ > ses commandes sans rien coder en dur. Côté navigateur, ils se lisent en `socket.serverMethods` et
352
+ > `socket.serverChannels`.
353
+
354
+ Les quatre formes de frame circulent en permanence sous tes yeux — ce schéma les montre sur la
355
+
356
+ > [!WARNING]
357
+ > `nodefony:kernel:ping` et `nodefony:kernel:gc` (`StudioRealtimeController.ts:114`) sont des exemples d'actions,
358
+ > **pas des méthodes du cœur temps réel** : elles sont déclarées par le contrôleur
359
+ > d'administration de `@nodefony/studio`. Un endpoint applicatif ne les expose pas. Le helper
360
+ > `RealtimeClient.ping()` (`RealtimeClient.ts:740`) mesure le RTT en les appelant — il suppose donc
361
+ > un endpoint qui les déclare, contrairement à la notification `ping` du battement de cœur, qui
362
+ > n'attend jamais de réponse.
363
+
364
+ `subscribe` et `unsubscribe` ne sont **pas** des actions enregistrées : elles sont traitées dans
365
+ `onRealtimeNotification()` (`RealtimeController.ts:675`). Envoyées avec un `id`, elles seraient
366
+ classées « requête », ne trouveraient aucun handler et récolteraient un `-32601`.
367
+
368
+ ## Une conversation type, de bout en bout
369
+
370
+ ```mermaid
371
+ sequenceDiagram
372
+ participant C as Client
373
+ participant P as JsonRpcPeer (serveur)
374
+ participant H as RealtimeHub
375
+ C->>P: handshake WebSocket
376
+ P-->>C: realtime:welcome — ts, protocol, channels, methods, identity
377
+ C->>P: subscribe — params.channel = "nodefony:orm:health"
378
+ Note over P,H: le hub crée le producteur au 1er abonné
379
+ P-->>C: nodefony:orm:health — params { queries: 42 }
380
+ P-->>C: nodefony:orm:health — params { queries: 43 }
381
+ C->>P: chat:history — id 7
382
+ P-->>C: id 7 — result { messages }
383
+ C->>P: unsubscribe — params.channel = "nodefony:orm:health"
384
+ Note over P,H: dernier abonné parti : le producteur est libéré
385
+ ```
386
+
387
+ Les deux pushs intermédiaires n'ont ni `id` ni accusé de réception : ce sont des notifications, et
388
+ c'est ce qui rend le pub/sub bon marché. Seule la frame `id: 7` immobilise une entrée dans la table
389
+ des appels en attente du client, jusqu'à sa réponse ou son expiration.
390
+
391
+ ## Quand ça rate — les codes d'erreur réellement émis
392
+
393
+ Voici **tout** ce que le moteur produit. Les codes de la plage `-32000`…`-32099` sont réservés par
394
+ la norme aux erreurs applicatives serveur ; Nodefony y place son refus d'autorisation et le défaut
395
+ de `RpcError`.
396
+
397
+ | Code | Émis par | Déclencheur | Ce que voit le pair |
398
+ | -------- | ---------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------- |
399
+ | `-32601` | `JsonRpcPeer.handleRequest()` (`JsonRpcPeer.ts:489`) | requête vers une action non enregistrée | `method not found: <nom>` |
400
+ | `-32603` | même méthode (`JsonRpcPeer.ts:528`) | le handler a levé une exception **ordinaire** | `internal error` — générique, rien d'autre |
401
+ | `-32001` | le refus du verrou de frame (`JsonRpcPeer.ts:400`) | `beforeDispatch` a dit non **sur une requête** | `unauthorized`, sans jamais dire pourquoi |
402
+ | `-32000` | défaut du constructeur de `RpcError` (`JsonRpcPeer.ts:74`) | le handler expose volontairement son refus | le message ET le `data` choisis par le handler |
403
+ | `-32602` | le pont API, via `RpcError` (`RealtimeController.ts:678`) | `api.request` appelé avec un `params.path` invalide | message explicite (l'appel est malformé) |
404
+
405
+ Et un échec qui n'est **pas** une frame : l'expiration. `startCall()` ne reçoit rien dans le délai
406
+ imparti, supprime l'entrée en attente et rejette localement avec `RPC timeout: <méthode>`
407
+ (`JsonRpcPeer.ts:428`). Défaut de 30 000 ms, réglable par appel. Aucun octet ne part sur le fil : c'est une décision du
408
+ client, le serveur peut très bien répondre après, sa réponse sera ignorée.
409
+
410
+ ### Les deux codes de la norme que Nodefony n'émet jamais
411
+
412
+ `-32700 Parse error` et `-32600 Invalid Request` figurent dans la norme, mais **aucune frame ne les
413
+ porte** ici :
414
+
415
+ - un JSON illisible n'atteint même pas le pair — le contrôleur l'abandonne à la lecture
416
+ (`RealtimeController.ts:446`) ;
417
+ - une frame lisible mais non conforme (pas d'objet, `jsonrpc` absent ou faux) est classée `invalid`,
418
+ signalée à l'audit, et **rien n'est renvoyé** (`JsonRpcPeer.ts:429`).
419
+
420
+ Le choix est délibéré : une frame cassée n'a pas d'`id` digne de confiance, donc pas de corrélation
421
+ possible ; et répondre systématiquement à du bruit offre à un attaquant un amplificateur gratuit.
422
+ Le refus reste **traçable** — c'est le motif `invalid` de `FrameAuditReason` (`JsonRpcPeer.ts:143`),
423
+ qui alimente le journal d'audit avec le pair concerné.
424
+
425
+ ### Le refus d'une notification n'est pas une erreur
426
+
427
+ C'est la subtilité la plus importante de la page. Le verrou de frame s'applique aux requêtes **et**
428
+ aux notifications (`beforeDispatch`, `JsonRpcPeer.ts:192`), mais leurs conséquences diffèrent
429
+ radicalement :
430
+
431
+ | La frame refusée est… | Ce qui part | Pourquoi |
432
+ | --------------------- | --------------------------------------------------------------- | ---------------------------------------------------- |
433
+ | une **requête** | `-32001 "unauthorized"` (`JsonRpcPeer.ts:413`) | Un `id` existe : il y a un canal de réponse |
434
+ | une **notification** | la notification `realtime:denied` (`RealtimeController.ts:433`) | Aucun `id` : sans elle, le client se croirait abonné |
435
+
436
+ `IRealtimeDenied` (`RealtimeEventMap.ts:228`) porte deux champs, `channel` et `reason` — et le motif
437
+ est **générique**. Jamais « il te manque `ROLE_ADMIN` » : ce serait un oracle d'autorisation, un
438
+ attaquant y lirait la carte des droits. Deux motifs circulent : `forbidden` (le verrou a dit non) et
439
+ `limit` (le plafond de canaux de la connexion est atteint, `RealtimeController.ts:718`). Côté client,
440
+ `onDenied()` (`RealtimeClient.ts:469`) branche un handler dessus.
441
+
442
+ > [!CAUTION]
443
+ > Un `-32403 Forbidden` circule dans d'anciennes notes. **Ce code n'existe pas** dans Nodefony, et il
444
+ > n'est réservé par aucune norme. Le refus d'autorisation est `-32001` sur une requête, et une
445
+ > notification `realtime:denied` sur une notification.
446
+
447
+ ## 🔐 Ce que le protocole ne décide pas
448
+
449
+ Le protocole transporte et classe. Il ne décide ni de l'identité, ni des droits — trois règles qui
450
+ en découlent :
451
+
452
+ 1. **Aucune authentification ne circule dans les frames.** L'identité est résolue **une fois** au
453
+ handshake, puis figée pour la connexion et relue en O(1) par frame. Une frame ne porte jamais de
454
+ credential : la rejouer ne rejoue aucune authentification.
455
+ 2. **`subscribe` est une demande, jamais un droit.** Le serveur peut refuser un abonnement selon la
456
+ politique du canal ; la demande a exactement la même forme qu'un abonnement autorisé, seule la
457
+ réponse diffère.
458
+ 3. **Le client ne pousse que là où c'est déclaré.** Un canal n'accepte d'entrée que si un handler
459
+ entrant existe pour ce nom (`RealtimeController.ts:608`) ; sinon la notification est ignorée en
460
+ silence. Le défaut est fermé.
461
+
462
+ Les mécanismes eux-mêmes — authenticator, verrou de frame, politique de canal, révocation — sont
463
+ décrits dans [la page sécurité](./securite.md).
464
+
465
+ ## ⚠️ Pièges
466
+
467
+ | Symptôme | Cause | Correction |
468
+ | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
469
+ | `subscribe` répond `-32601 method not found` | Envoyé **avec un `id`** : classé requête, or c'est une notification (`RealtimeController.ts:592`) | L'émettre sans `id` — `socket.subscribe(canal)` |
470
+ | Le handler passé à `subscribe` n'est jamais appelé | `RealtimeClient.subscribe()` prend **un seul** argument (`RealtimeClient.ts:430`) | `subscribe(canal)` **et** `on(canal, handler)`, deux gestes distincts |
471
+ | `request()` expire immédiatement, ou ignore le délai | Signature **positionnelle** `(méthode, params, ms)` (`RealtimeClient.ts:727`) — un objet d'options n'est pas lu | `request(m, p, 5000)` ; le défaut est 30 000 ms |
472
+ | Un tableau de frames n'obtient aucune réponse | Le batch n'est pas implémenté : un tableau n'a pas de `jsonrpc` → `invalid` (`JsonRpcPeer.ts:371`) | Une frame = un objet ; le multiplexage remplace le batch |
473
+ | Une frame malformée ne renvoie **aucune** erreur | Ni `-32700` ni `-32600` ne sont émis — silence + audit (`JsonRpcPeer.ts:396`) | Lire le motif `invalid` côté serveur, pas la réponse |
474
+ | L'exception du serveur n'arrive jamais au client | Zero Trust : tout throw ordinaire devient `-32603` générique (`JsonRpcPeer.ts:528`) | Lever une `RpcError` pour exposer volontairement code et `data` |
475
+ | Une notification refusée disparaît sans trace côté client | Sans `id`, aucune réponse possible (`beforeDispatch`, `JsonRpcPeer.ts:151`) | Écouter `realtime:denied` via `onDenied()` (`RealtimeClient.ts:386`) |
476
+ | `nodefony:kernel:ping` répond `-32601` sur mon endpoint | L'action `nodefony:kernel:ping` est déclarée par `@nodefony/studio` (`StudioRealtimeController.ts:114`) | Déclarer la sienne, ou lire `serverMethods` avant d'appeler |
477
+ | Le battement de cœur ne renvoie aucun pong | La notification `ping` est un no-op serveur (`RealtimeController.ts:661`) | Pour mesurer un RTT, utiliser une action RPC — `ping()` (`RealtimeClient.ts:740`) |
478
+ | Une réponse reçue est ignorée sans message | Corrélation sur `id` **numériques** seulement (`JsonRpcPeer.ts:537`) | Ne pas fabriquer soi-même de réponse à `id` chaîne |
479
+ | 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à |
480
+
481
+ ## 🧪 Tests & couverture
482
+
483
+ Le protocole est couvert à **deux endroits**, et c'est une conséquence directe de l'isomorphisme :
484
+ le moteur vit dans le cœur, son câblage serveur dans ce module. Les chiffres exacts vivent dans la
485
+ carte de l'aperçu, régénérée depuis les résultats réels — jamais figés ici.
486
+
487
+ - **Le moteur, dans le cœur** (`src/nodefony/src/tests/`) : `JsonRpcPeer.test.ts` (classement des
488
+ frames, corrélation, timeout, erreurs), `JsonRpcPeer.types.test.ts` (le contrat générique tient à
489
+ la compilation), et la famille `RealtimeClient*.test.ts` — dispatch, identité annoncée par
490
+ l'accueil, transport, `ping`. Ces suites **ne sont pas comptées** dans la carte du module
491
+ `@nodefony/realtime` : elles appartiennent au workspace `nodefony`.
492
+ - **Le câblage serveur, dans ce module** : `RealtimeController.test.ts` couvre les réponses
493
+ d'erreur exactes (`-32601` sur méthode inconnue, `-32603` générique sur throw, `-32001` sur frame
494
+ refusée) et le fait qu'une **réponse entrante** ne déclenche pas `-32601` à tort.
495
+ - **Bout en bout, sur un vrai serveur** : `realtimeLoopback.e2e.test.ts` exerce le duplex complet —
496
+ requête client→serveur, requête **serveur→client**, `-32601` quand le client n'a pas enregistré
497
+ l'action, `-32603` quand son handler lève, et le passage fidèle du `data` d'une `RpcError`.
498
+ - **Sous attaque** : les bancs `*.attack.test.ts` du module vérifient que le refus est observable
499
+ (plafond de canaux) et qu'une politique déclarée sans décideur est détectée.
500
+
501
+ Couverture : `npm run coverage` dans `@nodefony/realtime` et dans le workspace `nodefony`.
502
+
503
+ > [!CAUTION]
504
+ > Plusieurs suites bout en bout exigent une infrastructure. **Un test sauté compte comme vert** :
505
+ > avant de conclure « tout passe », vérifier les variables d'environnement — la source unique est
506
+ > `vitest.gates.ts` à la racine, dont le rapport s'affiche en fin d'exécution.
507
+
508
+ ## 🔗 Pour aller plus loin
509
+
510
+ - ⬆️ **Retour au hub** : [Realtime — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
511
+ - 🧭 **Pages sœurs** : [Vocabulaire](vocabulaire.md) · [Architecture](architecture.md) · [Sécurité](securite.md) · [Configuration](configuration.md) · [Cookbook — un chat complet](cookbook-chat.md)
512
+
513
+ - Les mots employés ici, avec leurs analogies → [vocabulaire](./vocabulaire.md)
514
+ - Où le pair se situe dans la pile, et le cycle de vie d'une connexion → [architecture](./architecture.md)
515
+ - Qui pose le verrou de frame, et ce qu'il lit → [sécurité](./securite.md)
516
+ - Le protocole mis en œuvre d'un bout à l'autre → [cookbook](./cookbook-chat.md)
517
+ - Les signatures exactes ne sont jamais recopiées ici : elles vivent dans le graphe symbolique
518
+ `.ai/symbols.json`, régénéré depuis les TSDoc du code.