@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,889 @@
1
+ ---
2
+ title: "Architecture — la socket, le hub, le backplane"
3
+ navTitle: Architecture
4
+ lang: fr
5
+ module: "@nodefony/realtime"
6
+ topic: realtime
7
+ coverageModule: realtime
8
+ section: "Realtime"
9
+ audience: [developer]
10
+ tags:
11
+ [
12
+ realtime,
13
+ architecture,
14
+ hub,
15
+ backplane,
16
+ fan-out,
17
+ websocket,
18
+ json-rpc,
19
+ cluster,
20
+ sonde,
21
+ ]
22
+ version: "doc"
23
+ status: stable
24
+ updated: 2026-07-19
25
+ source: "src/packages/@nodefony/realtime/docs/architecture.md"
26
+ ---
27
+
28
+ # Architecture — la socket, le hub, le backplane
29
+
30
+ > Une application temps réel Nodefony ouvre **une seule** connexion WebSocket par client
31
+ > et fait voyager **N canaux** dedans. Cette page explique la machinerie qui rend ça
32
+ > possible : le protocole qui étiquette les messages, le **hub** qui aiguille les abonnés
33
+ > d'un process, et le **backplane** qui franchit la frontière du process quand tu passes à
34
+ > plusieurs workers ou plusieurs pods. Tout est ancré sur
35
+ > `src/packages/@nodefony/realtime/nodefony/src/`.
36
+
37
+ 📍 [Documentation](../../../../../docs/index.md) › [Realtime](index.md) › **Architecture**
38
+
39
+ ```nodefony-livegraph
40
+ {
41
+ "graph": "architecture",
42
+ "height": 520,
43
+ "title": "L'architecture, en direct",
44
+ "hint": "Les quatre étages, avec leurs compteurs réels. OFF = aucun abonnement côté serveur ; ON = la page s'abonne au canal de santé."
45
+ }
46
+ ```
47
+
48
+ ## 🧠 Le modèle mental — une prise, un standard, un fond de panier
49
+
50
+ Trois objets suffisent à comprendre l'ensemble, et ils se racontent avec du matériel bien
51
+ réel :
52
+
53
+ - La **socket**, c'est la **prise murale** que tient ton code. Elle est unique, tu y
54
+ branches ce que tu veux, tu ignores ce qu'il y a derrière le mur.
55
+ - Le **hub**, c'est le **standard téléphonique** d'un immeuble : il tient la liste
56
+ « qui écoute quelle conférence » et recopie chaque message aux bons abonnés.
57
+ - Le **backplane**, c'est le **fond de panier** d'un rack : la carte passive au dos qui
58
+ relie les serveurs entre eux. Il ne fait qu'une chose — porter un message d'un standard
59
+ à l'autre.
60
+
61
+ ```mermaid
62
+ flowchart TD
63
+ subgraph POD_A["Process A — 1 hub"]
64
+ C1["Client 1<br/>1 WebSocket"] --> T1["WsConnectionTransport"]
65
+ C2["Client 2<br/>1 WebSocket"] --> T2["WsConnectionTransport"]
66
+ T1 --> P1["JsonRpcPeer<br/>frames JSON-RPC 2.0"]
67
+ T2 --> P2["JsonRpcPeer"]
68
+ P1 --> HUB_A["RealtimeHub<br/>canal → abonnés locaux"]
69
+ P2 --> HUB_A
70
+ SVC["Un service métier<br/>serverSocket().publish()"] --> HUB_A
71
+ end
72
+ HUB_A -->|"canal déclaré broadcast"| BP["IBackplane<br/>loopback · cluster IPC · redis"]
73
+ BP --> HUB_B["RealtimeHub<br/>Process B"]
74
+ HUB_B --> C3["Client 3"]
75
+ ```
76
+
77
+ Trois règles tiennent tout l'édifice :
78
+
79
+ 1. **Chaque étage ignore les étages du dessous.** Ton contrôleur appelle `publish()` ; il
80
+ ne sait pas s'il tourne seul ou dans un cluster de douze pods.
81
+ 2. **Le fan-out local est fait AVANT la propagation.** Le hub sert d'abord ses abonnés,
82
+ puis passe le relais au backplane.
83
+ 3. **Ce qui arrive du backplane n'y retourne jamais.** C'est la barrière anti-boucle, sans
84
+ laquelle un message tournerait à l'infini entre les pods.
85
+
86
+ ## 📖 Lexique
87
+
88
+ | Terme | Sens |
89
+ | ------------- | ------------------------------------------------------------------------------------------------------ |
90
+ | Socket | La prise que tient le métier : un handle, N canaux multiplexés. Contrat `IRealtimeSocket` (isomorphe). |
91
+ | Hub | Le broker d'un process : table « canal → abonnés locaux » + fan-out. Classe `RealtimeHub`. |
92
+ | Peer | Le moteur de protocole d'**une** connexion. Classe `JsonRpcPeer`, identique serveur et navigateur. |
93
+ | Transport | Le câble : ce qui déplace des octets. Côté serveur `WsConnectionTransport`. |
94
+ | Frame | Un message JSON-RPC 2.0 sur le fil (requête, réponse ou notification). |
95
+ | Canal | Un nom logique auquel on s'abonne (`chat:room-42`). Plusieurs canaux partagent une connexion. |
96
+ | Fan-out | Recopier une charge à tous les abonnés d'un canal. |
97
+ | Backplane | Le fond de panier : porte les publications d'un process à l'autre. Contrat `IBackplane`. |
98
+ | Provider | Le producteur d'un canal (ticker, écouteur). **Un seul par canal et par process**, partagé. |
99
+ | Sink | Le point de livraison d'**une** connexion sur un canal. 1 sink = 1 client abonné. |
100
+ | `originId` | L'étiquette du process émetteur, comparée pour jeter les messages qui reviennent à leur source. |
101
+ | Anti-écho | Le filtre qui jette ces retours. Sans lui, un message se dédoublerait à chaque aller-retour. |
102
+ | Backpressure | La file d'envoi non drainée d'un client lent (`bufferedAmount`). Le risque mémoire numéro un. |
103
+ | RPC | _Remote Procedure Call_ : une frame avec `id`, qui attend une réponse corrélée. |
104
+ | Sonde (probe) | Lecture pure de l'état du hub (canaux, fan-out, connexions, backpressure). |
105
+ | IPC | _Inter-Process Communication_ : le canal de messages entre le master d'un cluster Node et ses workers. |
106
+
107
+ ## Qu'est-ce qu'une socket multiplexée ?
108
+
109
+ Le réflexe naïf, quand une page a besoin de trois flux temps réel (un chat, un compteur de
110
+ présence, une barre de progression), c'est d'ouvrir trois WebSockets : trois handshakes,
111
+ trois authentifications, trois reconnexions à orchestrer.
112
+
113
+ **Multiplexer**, c'est ouvrir **une** connexion et étiqueter chaque message avec le nom du
114
+ flux auquel il appartient — une multiprise : un seul câble au mur, N appareils derrière. Le
115
+ prix à payer est un protocole d'étiquetage ; le gain, une authentification et un seul état
116
+ de connexion à afficher.
117
+
118
+ Nodefony étiquette avec **JSON-RPC 2.0**, une norme publique plutôt qu'un format maison :
119
+
120
+ | Forme de frame | A un `id` ? | Attend une réponse ? | À quoi ça sert |
121
+ | ---------------- | ------------ | ------------------------------- | ---------------------------------------------- |
122
+ | **requête** | oui | oui (une Promise côté émetteur) | appeler une action et récupérer son résultat |
123
+ | **réponse** | oui (miroir) | non | le résultat, corrélé par son `id` |
124
+ | **notification** | non | non | pousser un événement, s'abonner, se désabonner |
125
+
126
+ Le point remarquable : le **même** moteur de protocole tourne des deux côtés du fil. La
127
+ classe `JsonRpcPeer` (`JsonRpcPeer.ts:271`) est du code isomorphe du cœur — le navigateur
128
+ l'exécute dans `RealtimeClient`, le serveur l'instancie une fois par connexion dans
129
+ `RealtimeController.onHandshake()` (`RealtimeController.ts:312`). Le serveur peut donc
130
+ appeler le client, pas seulement l'inverse : c'est du vrai duplex, pas un aller-retour
131
+ déguisé.
132
+
133
+ ## La vision Nodefony — un handle, N canaux, zéro couplage au cluster
134
+
135
+ Trois partis pris distinguent cette pile d'un simple « serveur WebSocket ».
136
+
137
+ **Un seul contrat de socket, des deux côtés.** Un service back qui pousse des événements
138
+ écrit le même code qu'une page front : `publish` / `subscribe` / `on`. Côté serveur c'est
139
+ `ServerRealtimeSocket` (`ServerRealtimeSocket.ts:43`), obtenu par `serverSocket()`
140
+ (`ServerRealtimeSocket.ts:223`) ; côté navigateur, `RealtimeClient`. Une exception, assumée
141
+ et explicite : `ServerRealtimeSocket.request()` (`ServerRealtimeSocket.ts:131`) **rejette
142
+ toujours** — un handle posé sur le hub n'a pas d'interlocuteur unique, puisque le hub est
143
+ multi-clients. Pour un appel serveur→client ciblé, on passe par la connexion :
144
+ `RealtimeController.requestClient()` (`RealtimeController.ts:263`).
145
+
146
+ **Un provider par canal, pas un par client.** Si mille onglets s'abonnent au même canal de
147
+ santé, le calcul ne doit tourner qu'une fois. Le hub crée le producteur au **premier**
148
+ abonné et le détruit au **dernier** (`RealtimeHub.subscribe()`, `RealtimeHub.ts:388`) : le
149
+ coût suit le nombre de canaux, pas le nombre de clients.
150
+
151
+ **Le cluster est un détail de configuration.** Ni ton contrôleur ni ton client ne savent
152
+ combien de process tournent ; le seul objet au courant est le backplane, choisi par une
153
+ chaîne de caractères. Passer de mono-process à multi-pods ne touche aucune ligne applicative.
154
+
155
+ **Le compromis, dit franchement** : un canal **ne franchit PAS** la frontière du process
156
+ tant que tu ne l'as pas déclaré. Le défaut est l'isolement (`RealtimeHub.publish()`,
157
+ `RealtimeHub.ts:530`) — un choix de sûreté détaillé plus bas.
158
+
159
+ ## 🚀 Démarrage rapide
160
+
161
+ Un salon de discussion complet, vu d'une application créée par `nodefony create app` :
162
+ trois fichiers, et il fonctionne en mono-process comme en cluster.
163
+
164
+ ### 1. Charger le module et choisir le fond de panier
165
+
166
+ ```ts
167
+ // nodefony.config.ts
168
+ import { defineConfig, use } from "nodefony";
169
+
170
+ export default defineConfig(() => ({
171
+ modules: [
172
+ "@nodefony/framework",
173
+ use("@nodefony/realtime", {
174
+ // "loopback" = un seul process : le hub suffit, aucun transport réseau.
175
+ // "cluster" (workers IPC) ou "redis" (multi-pods) sans changer une ligne de code.
176
+ backplane: { driver: "loopback" },
177
+ // Défense CSRF de l'upgrade WebSocket : SameSite ne protège pas un handshake.
178
+ csrf: {
179
+ checkOrigin: {
180
+ enabled: true,
181
+ allowList: ["http://localhost:5151"],
182
+ },
183
+ },
184
+ }),
185
+ ],
186
+ }));
187
+ ```
188
+
189
+ ### 2. Le contrôleur temps réel — ton seul code serveur
190
+
191
+ ```ts
192
+ /// <reference lib="es2025" />
193
+ // ↑ utile uniquement si ton tsconfig fixe `lib` sous ES2025 : les paquets Nodefony
194
+ // exposent leurs types en SOURCE. Le tsconfig généré par `nodefony create app`
195
+ // porte déjà `"lib": ["ESNext", …]` — tu n'as alors rien à ajouter.
196
+ // nodefony/controllers/ChatController.ts
197
+ import { controller, route } from "@nodefony/framework";
198
+ import {
199
+ RealtimeController,
200
+ RealtimeChannel,
201
+ RealtimeInbound,
202
+ serverSocket,
203
+ } from "@nodefony/realtime";
204
+ import type { RealtimePublish } from "@nodefony/realtime";
205
+ import type { ContextType } from "@nodefony/http";
206
+
207
+ interface ChatMessage {
208
+ from: string;
209
+ text: string;
210
+ ts: number;
211
+ }
212
+
213
+ @controller("/chat")
214
+ class ChatController extends RealtimeController {
215
+ constructor(context: ContextType) {
216
+ super("chat", context);
217
+ }
218
+
219
+ // UNE route WebSocket : la base porte tout le protocole, tu délègues.
220
+ @route("chat-realtime", {
221
+ path: "/realtime",
222
+ requirements: { methods: ["WEBSOCKET"] },
223
+ })
224
+ async realtime(message: string | Buffer | null): Promise<void> {
225
+ this.handleRealtime(message);
226
+ }
227
+
228
+ // Canaux annoncés au client dans le message de bienvenue (découverte).
229
+ protected override realtimeChannels(): string[] {
230
+ return ["chat:room-42"];
231
+ }
232
+
233
+ // OPT-IN cross-process : sans cette ligne, un message publié sur le pod A
234
+ // ne sortirait jamais du pod A. Le préfixe couvre `chat:*`.
235
+ // (le préfixe diffusable est déclaré par `@RealtimeBroadcast` sur la classe)
236
+
237
+ // Le provider du canal : créé au 1ᵉʳ abonné, son `dispose` appelé au dernier.
238
+ // Ici rien à produire — le contenu vient des clients, pas d'un ticker.
239
+ @RealtimeChannel("chat:room-42")
240
+ room(_channel: string, _publish: RealtimePublish): () => void {
241
+ return () => {};
242
+ }
243
+
244
+ // Canal FULL-DUPLEX : le client a le droit de pousser ici (défaut : interdit).
245
+ @RealtimeInbound("chat:send")
246
+ onSend(params: unknown, reply: (payload: unknown) => void): void {
247
+ // `params` vient du réseau : jamais de confiance, on valide d'abord.
248
+ const text = (params as { text?: unknown } | undefined)?.text;
249
+ if (typeof text !== "string" || text.length === 0) return;
250
+ const msg: ChatMessage = { from: "anonyme", text, ts: Date.now() };
251
+ serverSocket().publish("chat:room-42", msg); // fan-out à tous les abonnés
252
+ reply({ ok: true }); // accusé, à CETTE connexion seulement
253
+ }
254
+ }
255
+
256
+ export default ChatController;
257
+ ```
258
+
259
+ ### 3. Le client — navigateur, mobile ou autre serveur
260
+
261
+ ```ts
262
+ // frontend/src/chat.ts
263
+ import { RealtimeClient } from "nodefony/client";
264
+
265
+ interface ChatMessage {
266
+ from: string;
267
+ text: string;
268
+ ts: number;
269
+ }
270
+
271
+ const scheme = location.protocol === "https:" ? "wss" : "ws";
272
+ // `shared` = une seule socket par URL pour toute la page, même si dix
273
+ // composants la demandent (les options ne comptent qu'à la création).
274
+ const socket = RealtimeClient.shared({
275
+ url: `${scheme}://${location.host}/chat/realtime`,
276
+ });
277
+
278
+ // `on` REÇOIT, `subscribe` DEMANDE au serveur de pousser. Les deux sont nécessaires.
279
+ socket.on("chat:room-42", (msg: unknown) => {
280
+ const m = msg as ChatMessage;
281
+ console.log(`[${m.ts}] ${m.from}: ${m.text}`);
282
+ });
283
+
284
+ export async function start(): Promise<void> {
285
+ await socket.connect();
286
+ socket.subscribe("chat:room-42");
287
+ socket.publish("chat:send", { text: "Salut !" });
288
+ }
289
+ ```
290
+
291
+ ### Ce qu'on observe
292
+
293
+ Au démarrage du serveur, le module annonce son fond de panier — une ligne, toujours la
294
+ même forme, quel que soit le driver (`src/packages/@nodefony/realtime/index.ts:303`) :
295
+
296
+ ```
297
+ realtime backplane driver=loopback kind=local cross-pod=no (hub local)
298
+ ```
299
+
300
+ Puis, en ouvrant deux onglets, la sonde montre le canal vivant et ses deux abonnés :
301
+
302
+ ```bash
303
+ curl -s http://localhost:5151/nodefony/realtime/api/health | head -20
304
+ # {
305
+ # "instanceId": "51234",
306
+ # "channels": [{ "channel": "chat:room-42", "subscribers": 2, "messages": 7 }],
307
+ # "channelCount": 1,
308
+ # "publishTotal": 7, "fanoutTotal": 14, "inboundTotal": 7,
309
+ # "connectionCount": 2,
310
+ # "backpressure": { "maxBufferedAmount": 0, "slowConsumers": 0, "drops": 0 },
311
+ # "backplane": { "driver": "loopback", "kind": "local", "crossPod": false }
312
+ # }
313
+ ```
314
+
315
+ `publishTotal` compte les publications, `fanoutTotal` les livraisons réelles : l'écart
316
+ entre les deux **est** le facteur d'amplification du canal.
317
+
318
+ ## 🏗️ Architecture interne — les quatre étages
319
+
320
+ La pile se lit de haut en bas ; chaque étage ne connaît que son voisin du dessous.
321
+
322
+ | Étage | Qui | Sa seule responsabilité | Ce qu'il ignore |
323
+ | ---------- | ------------------------------------------------------- | --------------------------------------- | ----------------------------- |
324
+ | Applicatif | ton contrôleur, tes services | le métier : quoi publier, quoi accepter | tout le reste |
325
+ | Protocole | `JsonRpcPeer` (`JsonRpcPeer.ts:271`) | étiqueter, corréler, refuser une frame | par où passent les octets |
326
+ | Transport | `WsConnectionTransport` (`WsConnectionTransport.ts:46`) | déplacer des octets, mesurer la file | ce que veut dire un message |
327
+ | Hub | `RealtimeHub` (`RealtimeHub.ts:213`) | table des abonnés locaux + fan-out | qu'il existe d'autres process |
328
+ | Backplane | `IBackplane` (`IBackplane.ts:75`) | porter un message aux autres process | ce qu'est un abonné |
329
+
330
+ > [!NOTE]
331
+ > Une part de la documentation historique décrit cette pile en « 5 étages » en comptant
332
+ > ton code applicatif comme un étage à part. C'est le même découpage : **quatre briques du
333
+ > framework**, plus ce que tu écris.
334
+
335
+ ### L'étage protocole — le même moteur des deux côtés
336
+
337
+ `JsonRpcPeer` porte tout le plan de contrôle d'une connexion : corrélation des `id`,
338
+ dispatch des notifications, gestion des erreurs, `dispose()` propre. Deux points
339
+ d'interception y sont posés :
340
+
341
+ - `beforeDispatch` (`JsonRpcPeer.ts:172`) — un verrou **synchrone** appelé avant tout
342
+ traitement de frame. Il rend `true`/`false`. Un refus sur une requête produit
343
+ `-32001 unauthorized` ; sur une notification, la frame est jetée
344
+ (`JsonRpcPeer.ts:413`).
345
+ - `onFrameAudit` (`JsonRpcPeer.ts:210`) — la trace des événements protocolaires notables
346
+ (frame invalide, refusée, méthode inconnue, erreur interne).
347
+
348
+ La contrainte de synchronisme n'est pas un oubli : un `await` par frame coûterait une
349
+ microtâche et sérialiserait le pipeline du peer. L'identité, elle, est résolue **une fois**
350
+ au handshake, puis lue en O(1). C'est le compromis assumé, documenté dans
351
+ [la page sécurité](./securite.md).
352
+
353
+ ### L'étage transport — le serveur ne lit pas la socket
354
+
355
+ Particularité contre-intuitive : côté serveur, le transport **n'écoute pas** d'événement
356
+ socket. C'est le pipeline HTTP de `@nodefony/http` qui appelle la route WebSocket du
357
+ contrôleur à chaque message reçu, laquelle pousse la charge dans
358
+ `WsConnectionTransport.feed()` (`WsConnectionTransport.ts:146`). La fermeture arrive de la
359
+ même façon, par le hook `onFinish` du contexte, qui déclenche `fireClose()`
360
+ (`WsConnectionTransport.ts:151`).
361
+
362
+ Ce transport porte aussi la **back-pressure**. Elle n'est pas câblée en dur : les trois
363
+ leviers sont des clés de configuration du serveur WebSocket, lues par le transport
364
+ (`WsConnectionTransport.ts:63-75`) et documentées dans `@nodefony/http`
365
+ (`http/nodefony/config/config.ts:625`).
366
+
367
+ | File non drainée (`bufferedAmount`) | Décision | Réglage (défaut) |
368
+ | --------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- |
369
+ | sous le seuil | envoi normal | — |
370
+ | au-dessus du seuil | politique appliquée : frame **jetée**, ou fermeture | `websocket.maxBackpressure` (**4 MiB**) · `websocket.backpressurePolicy` (**`drop`**) |
371
+ | drops **consécutifs** au-delà du compte | fermeture `1013` (_Try Again Later_) | `websocket.backpressureCloseAfterDrops` (**1000**) |
372
+
373
+ Deux points que la formulation « deux seuils d'octets » faisait manquer : la fermeture ne
374
+ se déclenche pas sur un volume mais sur une **suite de frames jetées** — une seule frame
375
+ qui repart remet le compteur à zéro — et `drop` est un choix, `close` fermant dès le
376
+ premier dépassement. Jeter est acceptable parce que les canaux d'état sont « le dernier
377
+ gagne » : le prochain instantané remplace celui qu'on a sauté.
378
+
379
+ À ne pas confondre avec `SLOW_CONSUMER_BYTES` (`RealtimeHub.ts:63`, 1 MiB) : ce seuil-là
380
+ ne jette rien, il **compte** — c'est celui à partir duquel la sonde marque une connexion
381
+ `slowConsumer`.
382
+
383
+ Sans ces seuils, un client lent — onglet en arrière-plan, mobile sur réseau dégradé —
384
+ accumule sans borne côté serveur, et le multiplexage aggrave le phénomène : une connexion
385
+ lente retient **tous** ses canaux.
386
+
387
+ Le schéma ci-dessous rend ces étages vivants : active le temps réel et il respire au rythme
388
+
389
+ ## 🔌 Le cycle de vie d'une connexion
390
+
391
+ Tout se joue dans `RealtimeController.onHandshake()` (`RealtimeController.ts:312`), appelé
392
+ une seule fois par connexion, en chemin froid.
393
+
394
+ ```mermaid
395
+ sequenceDiagram
396
+ participant C as Client
397
+ participant HTTP as Pipeline HTTP
398
+ participant RC as RealtimeController
399
+ participant H as RealtimeHub
400
+ participant P as JsonRpcPeer
401
+
402
+ C->>HTTP: upgrade HTTP → WebSocket
403
+ HTTP->>RC: handleRealtime(null)
404
+ RC->>H: checkOrigin(origin)
405
+ alt origine refusée
406
+ RC-->>C: close 4003 « origin not allowed »
407
+ end
408
+ RC->>H: resolveAuthenticator(handshake)
409
+ alt authentification en échec
410
+ RC-->>C: close 4001 « unauthorized »
411
+ end
412
+ RC->>H: setTokenForPeer(peer, token)
413
+ RC->>P: register(actions décorées + surchargées)
414
+ RC->>H: registerConnection · registerRevocable · markBroadcastChannel
415
+ RC-->>C: notification « realtime:welcome »
416
+ C->>P: subscribe / notifications / requêtes
417
+ Note over RC,H: à la fermeture (hook onFinish) :<br/>désabonnement de tous les canaux,<br/>sortie des registres, dispose du peer
418
+ ```
419
+
420
+ Les étapes, dans l'ordre exact du code :
421
+
422
+ 1. **Contrôle de l'origine** — `RealtimeHub.checkOrigin()` (`RealtimeHub.ts:896`). Refus →
423
+ fermeture `4003`. La politique vient de la configuration ; sans politique, tout passe.
424
+ 2. **Résolution de l'identité** — `RealtimeHub.resolveAuthenticator()`
425
+ (`RealtimeHub.ts:871`) parcourt les authentificateurs enregistrés : **le premier motif
426
+ qui correspond capture**. Aucun ne correspond ? Le jeton anonyme gelé est posé
427
+ (`ANONYMOUS_REALTIME_TOKEN`) — la lecture d'identité ne rend donc jamais `null`. Un
428
+ échec d'authentification ferme en `4001`.
429
+ 3. **Création du peer et du transport**, puis association `peer → jeton`
430
+ (`RealtimeHub.setTokenForPeer()`, `RealtimeHub.ts:928`), stockée dans une `WeakMap` : le
431
+ jeton disparaît avec le peer, sans fuite.
432
+ 4. **Enregistrement des actions** — celles des décorateurs `@RealtimeAction`, puis celles
433
+ de la surcharge `realtimeActions()`, qui gagne en cas de conflit. Le pont API
434
+ `api.request` n'est ajouté que si `realtimeApiRequest()` (`RealtimeController.ts:219`)
435
+ rend `true`.
436
+ 5. **Inscription aux registres** : sonde de connexion, révocation périodique si le jeton
437
+ est révocable, préfixes de canaux broadcast, canaux entrants.
438
+ 6. **Message de bienvenue** `realtime:welcome` : canaux et méthodes découvrables, plus
439
+ l'identité résolue (type, authentifié ou non, rôles, portées). Le client sait **qui il
440
+ est** sans appeler la moindre route.
441
+
442
+ À la fermeture, un unique `onFinish` (`RealtimeController.ts:542`) fait le ménage complet :
443
+ désabonnement de chaque canal tenu, retrait des deux registres, `fireClose()` du transport,
444
+ `dispose()` du peer. C'est ce qui garantit qu'aucun minuteur ni écouteur ne survit à une
445
+ déconnexion.
446
+
447
+ > [!IMPORTANT]
448
+ > Les frames reçues **pendant** l'authentification sont jetées silencieusement : le
449
+ > transport n'est pas encore branché. C'est au client d'attendre `realtime:welcome` avant
450
+ > de pousser — ce que `RealtimeClient` fait nativement.
451
+
452
+ ### Deux gardes qui ferment la connexion
453
+
454
+ | Garde | Déclencheur | Effet | Ancrage |
455
+ | --------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | --------------------------- |
456
+ | Plafond de canaux | une connexion dépasse son quota d'abonnements (256 par défaut) | abonnement refusé + `realtime:denied` motif `limit` | `RealtimeController.ts:618` |
457
+ | Révocation d'identité | tick périodique ; `token.isValid()` rend `false` ou lève une exception | fermeture `4001` « session revoked » | `RealtimeHub.ts:48` |
458
+
459
+ La seconde mérite une explication. Le verrou de frame est synchrone et lit une identité
460
+ figée au handshake : il ne peut donc pas voir une session qui meurt en cours de route (une
461
+ déconnexion HTTP, par exemple). Un minuteur — démarré au premier inscrit, arrêté dès que le
462
+ registre se vide (`RealtimeHub.registerRevocable()`, `RealtimeHub.ts:704`) — relit
463
+ périodiquement ces identités et coupe les sockets orphelines. Seules les identités
464
+ **révocables** y entrent : un visiteur anonyme ne coûte rien.
465
+
466
+ ## Le hub — canaux partagés et fan-out
467
+
468
+ Le hub est un singleton par process (`getRealtimeHub()`, `RealtimeHub.ts:1255`). Il ne
469
+ dépend de rien : ce sont les fabriques fournies par les contrôleurs qui portent les
470
+ dépendances.
471
+
472
+ ### Le provider partagé — un ticker, pas mille
473
+
474
+ `RealtimeHub.subscribe()` (`RealtimeHub.ts:388`) applique une mécanique en trois temps :
475
+
476
+ 1. Le canal existe déjà ? On ajoute simplement le sink de cette connexion. Fin.
477
+ 2. Sinon, on inscrit le sink **avant** d'appeler la fabrique — de sorte que le tout premier
478
+ paquet du producteur atteigne bien ce premier abonné.
479
+ 3. La fabrique du contrôleur rend `null` (canal inconnu de lui) ? Dernier recours : le
480
+ registre des **canaux système** (`RealtimeHub.registerSystemChannel()`,
481
+ `RealtimeHub.ts:1117`), qu'un module bas niveau alimente au démarrage. Toujours `null` →
482
+ l'abonnement est refusé et rien n'est alloué.
483
+
484
+ Au dernier désabonnement, `RealtimeHub.unsubscribe()` (`RealtimeHub.ts:502`) appelle le
485
+ `dispose` du producteur et retire le canal. Un producteur fautif qui lève une exception ne
486
+ bloque pas le nettoyage.
487
+
488
+ > [!WARNING]
489
+ > Un producteur est **partagé** et survit à la connexion qui l'a créé. Il doit capturer des
490
+ > valeurs à longue durée de vie (le noyau, un service, un journal) — **jamais** le contexte
491
+ > de la connexion créatrice, qui peut fermer alors que le producteur tourne toujours.
492
+
493
+ ### `publish` et `publishLocal` — la barrière anti-boucle
494
+
495
+ Deux méthodes qui se ressemblent et ne font pas du tout la même chose :
496
+
497
+ | Méthode | Fan-out local | Propagation aux autres process | Qui l'appelle |
498
+ | --------------------------------------- | :-----------: | :--------------------------------: | ----------------------------------- |
499
+ | `publish()` (`RealtimeHub.ts:530`) | oui | oui, **si** le canal est broadcast | producteurs, contrôleurs, services |
500
+ | `publishLocal()` (`RealtimeHub.ts:549`) | oui | **jamais** | l'arrivée d'un message du backplane |
501
+
502
+ C'est **la** règle qui empêche la tempête : un message reçu d'un pair est réinjecté
503
+ localement et **ne repart pas**. Le câblage se fait une fois pour toutes dans
504
+ `RealtimeHub.setBackplane()` (`RealtimeHub.ts:640`), qui branche l'arrivée du backplane
505
+ directement sur `publishLocal`.
506
+
507
+ Le fan-out lui-même (`RealtimeHub.ts:340`) est **isolé** : chaque livraison est protégée,
508
+ une connexion fautive n'interrompt pas la diffusion aux autres.
509
+
510
+ ### Le forward est OPT-IN — le défaut est l'isolement
511
+
512
+ Par défaut, **aucun canal ne traverse le backplane**. Il faut déclarer un préfixe, via
513
+ `@RealtimeBroadcast` sur ton contrôleur (`realtimeDecorators.ts:342`) ou
514
+ directement `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts:594`).
515
+
516
+ Trois raisons à ce choix, qui prend à contre-pied la plupart des bibliothèques temps réel :
517
+
518
+ 1. **Sûreté.** Aucune donnée propre à une instance — journaux, sondes, état interne — ne
519
+ fuit vers un autre process sans intention explicite.
520
+ 2. **Justesse par défaut.** Tous les canaux d'observabilité sont per-instance : ils restent
521
+ corrects en cluster sans la moindre déclaration.
522
+ 3. **Le franchissement devient une capacité qu'un canal demande**, donc une décision qu'on
523
+ lit dans le code du contrôleur.
524
+
525
+ Le drapeau est résolu **une fois**, au premier abonné, et mis en cache dans l'état du canal
526
+ — le chemin chaud ne lit qu'un booléen. Et en mono-process, la politique n'est **jamais**
527
+ évaluée : `publish` sort sur un simple test de nullité du backplane.
528
+
529
+ > [!WARNING]
530
+ > Le symptôme classique : « mon chat marche en local, mais en cluster les onglets ne se
531
+ > voient plus ». Cause : le préfixe `chat:` n'est pas déclaré broadcast. Le préfixe couvre
532
+ > la granularité, donc `chat:` attrape aussi `chat:room-42` et `chat:room-42:1000`.
533
+
534
+ Une publication part vers N destinations : le schéma suivant montre ce trajet, et ce que
535
+ coûte chaque abonné supplémentaire.
536
+
537
+ ```nodefony-livegraph
538
+ {
539
+ "graph": "fan-out",
540
+ "height": 500,
541
+ "title": "Le fan-out, en direct",
542
+ "hint": "Le nombre d'abonnés et le volume diffusé viennent de la sonde ; la branche cross-worker ne s'allume que si un canal est déclaré diffusable."
543
+ }
544
+ ```
545
+
546
+ ## Le backplane — franchir la frontière du process
547
+
548
+ ### Le contrat, en cinq méthodes
549
+
550
+ `IBackplane` (`IBackplane.ts:75`) est délibérément minuscule : le backplane ne connaît ni
551
+ les abonnés ni les canaux logiques. Tout l'état vit dans le hub.
552
+
553
+ | Membre | Rôle |
554
+ | ------------------------------------------------ | --------------------------------------------------------------- |
555
+ | `originId` (`IBackplane.ts:77`) | l'étiquette de CE process, lue par l'anti-écho |
556
+ | `start()` (`IBackplane.ts:115`) | ouvrir le transport. Idempotent, synchrone ou asynchrone |
557
+ | `publish(channel, payload)` (`IBackplane.ts:89`) | propager aux **autres** pairs. Ne refait pas le fan-out local |
558
+ | `onMessage(handler)` (`IBackplane.ts:127`) | recevoir des pairs. Un seul gestionnaire à la fois |
559
+ | `stop()` (`IBackplane.ts:130`) | libérer connexions et écouteurs. Idempotent |
560
+ | `describe()` (`IBackplane.ts:136`) | la carte d'identité : driver, nature, origine, cross-pod, canal |
561
+
562
+ Deux absences volontaires. Il n'y a **pas** de `subscribe`/`unsubscribe` par canal : le
563
+ transport porte **un seul** canal physique, et les canaux logiques voyagent **dans**
564
+ l'enveloppe (`IBackplaneMessage`, `IBackplane.ts:51`). Un seul abonnement au démarrage,
565
+ zéro (dés)abonnement dynamique — et le hub reste seul maître de la table des canaux.
566
+
567
+ `describe()`, lui, alimente **trois sorties cohérentes** depuis une source unique : la ligne
568
+ de journal au démarrage, le champ `backplane` de la sonde, et la vue Studio. Personne ne
569
+ redit « c'est quoi ce backplane ».
570
+
571
+ **Garantie de livraison : au plus une fois, au mieux.** Un pub/sub ne persiste ni ne rejoue.
572
+ Un process déconnecté rate les messages émis pendant sa coupure ; c'est le client qui se
573
+ resynchronise. Ne construis pas au-dessus une fiabilité que le support n'offre pas.
574
+
575
+ ### Les drivers natifs
576
+
577
+ | Driver | Classe | Nature | Pairs | Traverse les pods |
578
+ | ---------- | ----------------------------------------------- | -------------- | ---------------------------------- | :---------------: |
579
+ | `loopback` | `LoopbackBackplane` (`LoopbackBackplane.ts:24`) | `local` | aucun (no-op complet) | non |
580
+ | `cluster` | `ClusterBackplane` (`ClusterBackplane.ts:89`) | `ipc` | les workers du même process maître | non |
581
+ | `redis` | `RedisBackplane` (`RedisBackplane.ts:161`) | `redis-pubsub` | tous les pods abonnés au canal | **oui** |
582
+
583
+ **`loopback`** est le défaut. En réalité, le hub garde son backplane à `null` en
584
+ mono-process : le coût est un test de nullité, pas un appel. La classe existe pour
585
+ matérialiser le contrat et prouver, en test, que brancher un backplane sans pair ne change
586
+ rien.
587
+
588
+ **`cluster`** relie les workers d'un même hôte. Un worker Node ne peut parler qu'au maître :
589
+ il émet vers lui (`ClusterBackplane.publish()`, `ClusterBackplane.ts:117`), le maître
590
+ rediffuse aux autres. Le driver ne s'active qu'en rôle worker avec la variable
591
+ `NF_CLUSTER=1` (`src/packages/@nodefony/realtime/index.ts:99`) ; ailleurs il rend
592
+ `null`. Aucune infrastructure requise — c'est le banc d'essai qui stabilise l'architecture
593
+ multi-process avant d'ajouter du réseau.
594
+
595
+ **`redis`** franchit la frontière de l'hôte, sans dépendre de la bibliothèque `redis` : il
596
+ consomme deux connexions du module `@nodefony/redis` via un adaptateur purement structurel,
597
+ `createRedisServiceTransport()` (`RedisBackplane.ts:100`). Deux connexions et non une, parce
598
+ qu'un client Redis abonné ne peut plus émettre de commandes ordinaires. Module absent ou
599
+ connexions indisponibles → avertissement, `null`, hub local, démarrage poursuivi
600
+ (`src/packages/@nodefony/realtime/index.ts:108`).
601
+
602
+ ### Le registre de drivers — zéro nom en dur
603
+
604
+ Choisir le driver par une cascade de `if (driver === "redis")` trahirait la promesse de
605
+ brancherie. La sélection passe donc par un registre — `registerBackplaneDriver()`
606
+ (`backplaneRegistry.ts:55`) et `getBackplaneDriver()` (`backplaneRegistry.ts:63`) : chaque
607
+ driver porte son nom en membre statique, les natifs s'inscrivent au chargement du module, et
608
+ le câblage résout une chaîne vers une fabrique sans connaître aucun littéral
609
+ (`src/packages/@nodefony/realtime/index.ts:253`).
610
+
611
+ Une fabrique reçoit module, `originId`, rôle dans la topologie et configuration validée
612
+ (`IBackplaneFactoryContext`, `backplaneRegistry.ts:27`), puis rend une instance ou `null`
613
+ (« inactif ici »). Elle ne démarre rien : c'est le câblage qui appelle `start()`.
614
+
615
+ ### L'anti-écho et l'`originId`
616
+
617
+ Redis renvoie à l'émetteur ce qu'il publie (le publieur et l'abonné sont deux connexions du
618
+ même process). Sans filtre, le fan-out local serait fait deux fois. D'où **deux barrières**,
619
+ et non une :
620
+
621
+ 1. **Côté hub** : l'arrivée passe par `publishLocal`, jamais par `publish`. Rien ne repart.
622
+ 2. **Côté backplane** : à la réception, on compare l'`originId` de l'enveloppe au sien et
623
+ on jette si c'est le même (`RedisBackplane.ts:220`, `ClusterBackplane.ts:134`).
624
+
625
+ L'étiquette elle-même est calculée par `resolveBackplaneOriginId()` (`originId.ts:24`), et
626
+ sa recette mérite qu'on s'y arrête :
627
+
628
+ | Étape | Valeur | Pourquoi |
629
+ | ------------------ | ---------------------- | ----------------------------------------------------------------------------------------------------- |
630
+ | 1. explicite | variable `NF_POD_NAME` | l'opérateur sait mieux que nous (API descendante Kubernetes) |
631
+ | 2. filet | `os.hostname()` | nom de pod en Kubernetes, identifiant de conteneur en Docker, nom de machine ailleurs |
632
+ | 3. toujours ajouté | suffixe `:pid` | distingue les workers d'un même hôte (cluster + driver `redis`) |
633
+ | 4. dernier recours | `randomUUID()` | hôte indisponible ; l'unicité à l'instant t suffit, la stabilité entre redémarrages n'est pas requise |
634
+
635
+ > [!CAUTION]
636
+ > Prendre le PID seul serait un piège sournois : en conteneur, l'espace de noms des PID est
637
+ > **par conteneur**, donc deux pods peuvent tous deux être PID 1. L'anti-écho les
638
+ > confondrait et jetterait **silencieusement** du fan-out légitime — la pire des pannes,
639
+ > celle qui ne lève aucune erreur. L'`originId` est résolu à la construction : coût nul par
640
+ > message.
641
+
642
+ ### Le cloisonnement du canal Redis
643
+
644
+ Le canal pub/sub de base est `nodefony:realtime` (`RedisBackplane.ts:20`). Problème : le
645
+ numéro de base Redis **ne cloisonne pas** le pub/sub, qui est global au serveur. Deux
646
+ applications sur un Redis mutualisé se parleraient.
647
+
648
+ D'où le suffixe de cloison, calculé par `resolveRedisChannel()` (`RedisBackplane.ts:31`) :
649
+ le canal effectif devient `nodefony:realtime:<namespace>`. Le namespace vient de la
650
+ configuration ; à défaut, il est dérivé du nom de l'application
651
+ (`src/packages/@nodefony/realtime/index.ts:132`).
652
+
653
+ > [!TIP]
654
+ > Deux déploiements de la **même** application sur un Redis partagé (préproduction et
655
+ > production, par exemple) obtiennent le même nom dérivé — donc le même canal. Pose un
656
+ > namespace **explicite et distinct** dans ces cas-là.
657
+
658
+ L'entrée est par ailleurs blindée : un JSON malformé publié par un tiers sur le même canal
659
+ est ignoré, pas propagé (`RedisBackplane.ts:212`).
660
+
661
+ ### Le démarrage, et ce qui se passe quand ça rate
662
+
663
+ Le module valide sa configuration par Zod à l'enregistrement
664
+ (`src/packages/@nodefony/realtime/index.ts:181`), puis câble tout au démarrage du noyau
665
+ (`src/packages/@nodefony/realtime/index.ts:213`) — après l'initialisation des services (le
666
+ service Redis a donc ouvert ses connexions) et avant le trafic.
667
+
668
+ Le `start()` du backplane est **attendu explicitement avant** d'être branché : un driver
669
+ asynchrone brancherait sinon un abonnement incomplet et perdrait ses premiers messages. Et
670
+ il est **borné par un délai** de cinq secondes
671
+ (`src/packages/@nodefony/realtime/index.ts:83`), pour une raison très concrète : un
672
+ transport réseau qui reste pendu gèlerait la phase de démarrage, et donc la montée des
673
+ serveurs HTTP eux-mêmes.
674
+
675
+ Trois issues, toutes annoncées, jamais silencieuses :
676
+
677
+ | Situation | Comportement | Journal |
678
+ | -------------------------------------------- | ------------------------------------ | --------- |
679
+ | driver actif, démarrage réussi | branché ; carte d'identité complète | `INFO` |
680
+ | driver inactif ici (loopback, mauvais rôle) | hub local | `INFO` |
681
+ | driver inconnu, ou démarrage en échec/expiré | hub local, **le démarrage continue** | `WARNING` |
682
+
683
+ Le module se déclare non critique (`src/packages/@nodefony/realtime/index.ts:144`) : son
684
+ échec ne tue jamais le process. C'est de la résilience **sans dégradation silencieuse** —
685
+ tout repli est annoncé. Enfin, `enabled: false` rend le module totalement inerte : ni API
686
+ d'administration, ni backplane, ni sonde.
687
+
688
+ ## 🧩 Extension — brancher son propre fond de panier
689
+
690
+ Trois voies, de la plus déclarative à la plus directe.
691
+
692
+ **1. Enregistrer un driver** — la voie recommandée : ton driver devient sélectionnable par
693
+ son nom, comme les natifs.
694
+
695
+ ```typescript
696
+ import { registerBackplaneDriver, type IBackplane } from "@nodefony/realtime";
697
+
698
+ class NatsBackplane implements IBackplane {
699
+ static readonly driver = "nats";
700
+ readonly originId: string;
701
+ /* start / publish / onMessage / stop / describe */
702
+ }
703
+
704
+ registerBackplaneDriver(
705
+ NatsBackplane.driver,
706
+ (ctx) => new NatsBackplane(/* … */ ctx.originId),
707
+ );
708
+ // puis, en configuration : backplane: { driver: "nats" }
709
+ ```
710
+
711
+ **2. Déclarer un service** nommé `realtimeBackplane` dans le conteneur d'injection —
712
+ **la voie « instance » pour une application**. `RealtimeService.init()`
713
+ (`RealtimeService.ts:79`) le résout et le branche **avant** le registre : une instance
714
+ déjà fournie court-circuite la sélection par nom.
715
+
716
+ **3. Passer une instance** en second argument de `defineRealtimeConfig()` — réservé au
717
+ code qui **appelle le builder lui-même** (un `Module` sur mesure, une intégration
718
+ programmatique). Une application qui configure via `use("@nodefony/realtime", …)` **ne
719
+ l'atteint pas** : elle ne construit pas la config (le module appelle
720
+ `defineRealtimeConfig(this.options)` sans second argument, `index.ts:216`), et une
721
+ instance posée dans l'objet de config est retirée par la validation Zod (elle n'est pas
722
+ au schéma). Pour une app, la voie « instance » est l'option 2 ci-dessus.
723
+
724
+ Le registre s'introspecte : `listBackplaneDrivers()` (`backplaneRegistry.ts:68`) rend les
725
+ noms disponibles — c'est ce que le message d'erreur affiche quand un driver déclaré est
726
+ introuvable.
727
+
728
+ ## 🔐 Où la sécurité se greffe
729
+
730
+ L'architecture prévoit des points de greffe pour que `@nodefony/security` s'y branche sans
731
+ refonte. Ici, seulement **où** ils sont ; le **quoi** et le **comment** sont dans
732
+ [la page sécurité](./securite.md).
733
+
734
+ | Point de greffe | Étage | Moment | Ancrage |
735
+ | ---------------------------- | --------- | -------------- | --------------------------- |
736
+ | Contrôle d'origine | hub | à l'upgrade | `RealtimeHub.ts:614` |
737
+ | Authentificateur réseau | hub | au handshake | `RealtimeHub.ts:579` |
738
+ | Verrou de frame | protocole | à chaque frame | `RealtimeHub.ts:675` |
739
+ | Politique déclarée par canal | hub | au handshake | `RealtimeHub.ts:713` |
740
+ | Audit de frame | protocole | sur refus | `RealtimeController.ts:404` |
741
+
742
+ Deux propriétés architecturales méritent d'être notées ici, parce qu'elles expliquent des
743
+ choix de conception visibles partout dans le module :
744
+
745
+ **Coût nul quand la sécurité est absente.** Le verrou de frame n'est branché sur le peer que
746
+ si une politique existe (`RealtimeHub.hasFrameAuthorizer()`, `RealtimeHub.ts:961`, testé une
747
+ fois au handshake). Sans module de sécurité, `beforeDispatch` reste indéfini et le chemin
748
+ chaud ne paie **rien** du tout.
749
+
750
+ **Échec bruyant plutôt que faux sentiment de sécurité.** Si des canaux déclarent une
751
+ politique sans qu'aucun décideur ne soit câblé, `hasUnenforcedChannelPolicies()`
752
+ (`RealtimeHub.ts:1020`) le détecte et un avertissement est émis une fois par process
753
+ (`RealtimeController.ts:487`). Un canal qui **se croit** gardé alors qu'il est ouvert est
754
+ bien plus dangereux qu'un canal ouvertement public.
755
+
756
+ ## 📡 Observabilité — la sonde et Studio
757
+
758
+ `RealtimeHub.probe()` (`RealtimeHub.ts:775`) rend un instantané en **lecture pure** : aucune
759
+ allocation sur le chemin chaud, jamais d'exception. Les compteurs sont des primitives
760
+ incrémentées en O(1) ; ils sont **monotones**, ce qui laisse au lecteur le soin de dériver
761
+ un débit.
762
+
763
+ | Famille | Champs | Ce que ça dit |
764
+ | -------------- | -------------------------------------------------------------------- | -------------------------------- |
765
+ | Canaux | `channels[]` (`channel`, `subscribers`, `messages`), `channelCount` | qui vit, qui écoute |
766
+ | Fan-out | `publishTotal`, `fanoutTotal`, `inboundTotal` | l'amplification réelle du broker |
767
+ | Connexions | `connectionCount`, `bytesSentTotal`, `messagesSentTotal` | le volume |
768
+ | Back-pressure | `maxBufferedAmount`, `totalBufferedAmount`, `slowConsumers`, `drops` | le risque mémoire numéro un |
769
+ | Fond de panier | `backplane` (la carte d'identité de `describe()`) | ce qui est réellement branché |
770
+
771
+ Le contrat complet est `IRealtimeProbe` (`IRealtimeProbe.ts:61`). La version enrichie
772
+ `buildOwnHealth()` (`RealtimeAdminApi.ts:52`) y ajoute l'identifiant d'instance et la sonde
773
+ process, plus — si elles sont disponibles — les sondes ORM et les compteurs d'erreurs. Deux
774
+ chemins de lecture, un seul producteur :
775
+
776
+ - **HTTP** : `GET /nodefony/realtime/api/health`, exposé par `createRealtimeAdminApi()`
777
+ (`RealtimeAdminApi.ts:91`) ;
778
+ - **Canal temps réel** : `nodefony:socket`, poussé par un ticker.
779
+
780
+ ### En cluster : la vue « pod » plutôt que « worker »
781
+
782
+ Interroger la santé en cluster pose un problème sournois : la requête atterrit sur **un**
783
+ worker au hasard, dont l'instantané ne dit rien des autres. D'où `ClusterProbeClient`
784
+ (`ClusterProbeClient.ts:138`), qui applique un modèle **push** — chaque worker remonte
785
+ périodiquement sa santé au maître (`ClusterProbeClient.ts:179`), le maître agrège et
786
+ rediffuse, chaque worker met en cache. **N'importe lequel** sert alors la vue pod en O(1),
787
+ sans latence de requête.
788
+
789
+ L'agrégation, `mergeClusterHealth()` (`ClusterProbeClient.ts:46`), est une fonction pure :
790
+ elle somme les scalaires, mais prend le **maximum** de `maxBufferedAmount` — la santé d'une
791
+ flotte se juge sur son pire membre, pas sur sa moyenne. `buildRealtimeHealth()`
792
+ (`RealtimeAdminApi.ts:74`) choisit ensuite la vue agrégée si elle existe, la per-instance
793
+ sinon.
794
+
795
+ Sonde désactivée ? Elle n'est pas muette : **elle n'existe pas**. Aucun client, donc aucun
796
+ minuteur, aucun écouteur, aucun message IPC
797
+ (`src/packages/@nodefony/realtime/index.ts:351`) — coupable par la configuration ou par une
798
+ variable d'environnement. Enfin, le maître peut demander à un worker précis d'enrichir sa
799
+ remontée (`requestEnrich()`, `ClusterProbeClient.ts:239`) : la sonde riche n'est allouée que
800
+ pendant l'inspection, puis libérée — on paie ce qu'on regarde.
801
+
802
+ ## ⚡ Performance & mémoire
803
+
804
+ Le module suit une discipline stricte, lisible dans presque chaque champ privé du hub.
805
+
806
+ **Allocation paresseuse partout.** Table des canaux, registre des connexions, registre des
807
+ révocables, authentificateurs, politiques de canal, association peer→jeton : tout démarre à
808
+ `null`. Un process sans abonné n'alloue **rien** (`RealtimeHub.ts:141` et suivants).
809
+
810
+ **Aucun minuteur au repos.** Le tick de révocation démarre au premier inscrit et s'arrête
811
+ dès que le registre se vide — `RealtimeHub.unregisterRevocable()` (`RealtimeHub.ts:719`).
812
+ Les minuteurs sont détachés de la boucle d'événements : ils ne retiennent jamais l'arrêt du
813
+ process.
814
+
815
+ **Les chemins chauds sont maigres.** En mono-process, `publish` ne paie qu'un test de
816
+ nullité. Le drapeau de forward est calculé une fois puis lu en O(1). La lecture d'identité
817
+ par frame est un accès `WeakMap`. Le contrôle d'origine, la résolution
818
+ d'authentificateur, la lecture des métadonnées de décorateurs : tout est en chemin froid,
819
+ une fois par connexion.
820
+
821
+ **Le nettoyage est symétrique par construction.** Chaque inscription à un registre a son
822
+ retrait dans le même `onFinish` ; chaque producteur créé a son `dispose` appelé au dernier
823
+ désabonné.
824
+
825
+ **Le vrai point de vigilance est la back-pressure**, pas le processeur : une file d'envoi
826
+ non bornée multipliée par le nombre de clients, c'est la panne mémoire — et le multiplexage
827
+ concentre le risque. D'où les deux seuils du transport, le comptage des consommateurs lents
828
+ dans la sonde, et le plafond de canaux par connexion.
829
+
830
+ ## ⚠️ Pièges
831
+
832
+ | Symptôme | Cause | Correction |
833
+ | -------------------------------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
834
+ | Le chat marche en local, plus rien en cluster | le canal n'est pas déclaré broadcast (défaut : instance-local) | déclarer le préfixe avec `@RealtimeBroadcast` |
835
+ | Le client ne reçoit rien alors qu'il a un handler `on(...)` | `on` reçoit, `subscribe` demande — il faut les **deux** | appeler `socket.subscribe(canal)` |
836
+ | Chaque message arrive **en double** en cluster | `publishLocal` court-circuité, ou `originId` non unique entre pods | ne jamais republier une arrivée backplane ; vérifier `NF_POD_NAME`/hostname |
837
+ | Deux applications se parlent sur un Redis mutualisé | même canal dérivé (le numéro de base Redis ne cloisonne pas le pub/sub) | poser un `backplane.namespace` explicite et distinct |
838
+ | Producteur planté après le départ du premier abonné | la fabrique a capturé le contexte de la connexion créatrice | ne capturer que des valeurs à longue durée de vie |
839
+ | Abonnement refusé avec `realtime:denied` motif `limit` | plafond de canaux par connexion atteint (256 par défaut) | regrouper les canaux, ou relever le plafond en connaissance de cause |
840
+ | Fermeture `1013` sur un client lent | file d'envoi ≥ 8 MiB, jugée irrécupérable | attendu ; le client se reconnecte et se resynchronise |
841
+ | Fermeture `4001` en cours de session | identité révoquée, détectée par le tick de re-validation | se réauthentifier ; le comportement est voulu |
842
+ | Frames envoyées juste après `connect()` perdues | le transport n'est pas branché tant que le handshake n'est pas fini | attendre `realtime:welcome` (`RealtimeClient` le fait déjà) |
843
+ | Avertissement « channel policies … NOT enforced » au démarrage | des canaux déclarent une politique sans décideur câblé | charger `@nodefony/security` avec une zone realtime |
844
+ | `ServerRealtimeSocket.request()` rejette systématiquement | un handle posé sur le hub n'a pas d'interlocuteur unique | utiliser `RealtimeController.requestClient()` pour un appel ciblé |
845
+ | `TS2550: RegExp.escape does not exist` à la compilation | `lib` du tsconfig sous ES2025 (les paquets exposent leurs types en source) | `"lib": ["ESNext", …]`, comme le tsconfig généré par `nodefony create app` |
846
+
847
+ ## 🧪 Tests & couverture
848
+
849
+ Cinq familles couvrent cette architecture. Les chiffres exacts vivent dans la carte de
850
+ l'aperçu, régénérée depuis les résultats réels — jamais figés ici.
851
+
852
+ - **Unitaires** — le cœur du fan-out (`RealtimeHub`), le cycle de connexion
853
+ (`RealtimeController`), la façade d'injection (`RealtimeService`), les trois backplanes,
854
+ le registre de drivers, la résolution d'`originId`, les décorateurs, le transport et son
855
+ contrôle de back-pressure, la validation Zod de la configuration.
856
+ - **Contrat de types** — `realtimeSharedContract.types.test.ts` vérifie que le contrat
857
+ partagé serveur/client tient à la compilation.
858
+ - **Intégration** — le backplane Redis face à un vrai serveur.
859
+ - **Bout en bout** — un serveur réel : cluster IPC par `fork`, boucle loopback complète,
860
+ chemins de résolution du contrôleur, câblage du pare-feu, autorisation par canal, et
861
+ cluster Redis multi-workers.
862
+ - **Attaque** — c'est là qu'on lit ce que le module défend vraiment :
863
+ `realtimeUnenforcedPolicy` (une politique déclarée sans décideur est-elle détectée ?),
864
+ `realtimeRevocation` (une session révoquée ferme-t-elle la socket, y compris quand la
865
+ re-validation lève une exception ?), `realtimeChannelCap` (le plafond tient-il, et le
866
+ refus est-il observable ?).
867
+
868
+ > [!CAUTION]
869
+ > Plusieurs de ces suites exigent une infrastructure (Redis, cluster). **Un test qui se
870
+ > saute compte comme vert.** Avant de conclure « tout passe », vérifie que les variables
871
+ > d'environnement d'infrastructure sont bien posées — la source unique est `vitest.gates.ts`
872
+ > à la racine, dont le rapport s'affiche en fin d'exécution.
873
+
874
+ Couverture : `npm run coverage` dans `@nodefony/realtime`. Pour la charge et la mémoire, les
875
+ outils dédiés sont les skills `nodefony-load-test` et `nodefony-check-memory-health`.
876
+
877
+ ## 🔗 Pour aller plus loin
878
+
879
+ - ⬆️ **Retour au hub** : [Realtime — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
880
+ - 🧭 **Pages sœurs** : [Vocabulaire](vocabulaire.md) · [Configuration](configuration.md) · [Sécurité](securite.md) · [Cookbook — un chat complet](cookbook-chat.md)
881
+
882
+ - Les termes employés ici, avec leurs analogies → [vocabulaire](./vocabulaire.md)
883
+ - Toutes les clés de configuration, driver par driver → [configuration](./configuration.md)
884
+ - Qui parle, qui a le droit, comment le pare-feu se branche → [sécurité](./securite.md)
885
+ - Le même chat, déroulé de bout en bout jusqu'au déploiement → [cookbook](./cookbook-chat.md)
886
+ - Où la couche temps réel s'insère dans le traitement d'une requête →
887
+ [pipeline de requête](../../../../../docs/architecture/pipeline-requete.md)
888
+ - Les signatures exactes ne sont jamais recopiées ici : elles vivent dans le graphe
889
+ symbolique `.ai/symbols.json`, régénéré depuis les TSDoc du code.