@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,592 @@
1
+ ---
2
+ title: "Observabilité de la socket — la sonde, les canaux de santé, les écrans"
3
+ navTitle: Observabilité de la socket
4
+ lang: fr
5
+ module: "@nodefony/realtime"
6
+ topic: realtime
7
+ section: "Temps réel"
8
+ audience: [developer, devops, supervisor]
9
+ tags:
10
+ [realtime, observabilite, sonde, probe, backpressure, sante, studio, cluster]
11
+ version: "doc"
12
+ status: stable
13
+ updated: 2026-07-20
14
+ source: "src/packages/@nodefony/realtime/docs/observabilite.md"
15
+ coverageModule: realtime
16
+ ---
17
+
18
+ # Observabilité de la socket — la sonde, les canaux de santé, les écrans
19
+
20
+ > Comment savoir que ta socket va bien. La socket Nodefony s'observe **à travers elle-même** : le hub
21
+ > tient ses propres constantes vitales, les sert en HTTP pour le premier affichage, et les rediffuse
22
+ > sur un canal temps réel — sur la socket qu'elles décrivent. Cette page dit ce que la sonde expose
23
+ > exactement, à quelle cadence, ce que Studio en montre, et comment publier ta propre sonde métier
24
+ > sans faire fuir la mémoire ni empêcher l'arrêt du processus.
25
+
26
+ 📍 [Documentation](../../../../../docs/index.md) › [@nodefony/realtime](index.md) › **Observabilité**
27
+
28
+ ```nodefony-livegraph
29
+ {
30
+ "graph": "sondes",
31
+ "height": 480,
32
+ "title": "La sonde, en direct",
33
+ "hint": "Sonde, agrégat, endpoint, minuteur, canal, écran. Le compteur de sondes vivantes est réel."
34
+ }
35
+ ```
36
+
37
+ ## Schéma général
38
+
39
+ Le patron tient en cinq pièces. Un **producteur** de mesures, un **agrégateur**, puis deux chemins de
40
+ lecture — un instantané et un flux — qui aboutissent au même écran.
41
+
42
+ ```mermaid
43
+ flowchart LR
44
+ HUB["RealtimeHub<br/>compteurs vivants"] -->|"probe()"| PROBE["IRealtimeProbe<br/>snapshot pur"]
45
+ PROBE --> BUILD["buildRealtimeHealth()<br/>+ identité, process, ORM, erreurs"]
46
+ BUILD --> HTTP["GET /nodefony/realtime/api/health<br/>instantané, 1er affichage"]
47
+ BUILD --> TICK["createBrokerTicker<br/>minuteur détaché"]
48
+ TICK -->|"publish"| CH["canal nodefony:socket<br/>cadence 2 s par défaut"]
49
+ CH --> UI["Studio<br/>Temps réel · Cluster · Carte du serveur"]
50
+ HTTP --> UI
51
+ ```
52
+
53
+ Le détail qui rend le schéma vrai : le canal `nodefony:socket` **circule sur la socket elle-même**.
54
+ La mesure emprunte le transport qu'elle mesure — c'est ce qui rend l'observation gratuite en
55
+ infrastructure, et c'est aussi ce dont il faut se méfier quand le transport souffre.
56
+
57
+ ## 📖 Lexique
58
+
59
+ | Terme | Développé | En une ligne |
60
+ | --------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------- |
61
+ | **Sonde** (_probe_) | — | Un thermomètre branché sur un sous-système : il **lit** des compteurs, il n'en produit aucun. |
62
+ | **Snapshot** | instantané | Une photo des compteurs à un instant `ts`. Deux photos donnent un débit. |
63
+ | **Compteur monotone** | — | Un cumul qui ne redescend jamais depuis le démarrage. Le lecteur dérive le débit, le serveur non. |
64
+ | **Fan-out** | diffusion en éventail | Une publication livrée à N abonnés. `fanoutTotal / publishTotal` = taille moyenne d'un salon. |
65
+ | **Back-pressure** | contre-pression | La file d'envoi d'un client qui n'absorbe pas le flux. Le risque mémoire numéro un. |
66
+ | **`bufferedAmount`** | — | Octets déjà remis à la socket mais **pas encore partis** sur le réseau. Le signal de la souffrance. |
67
+ | **Slow consumer** | consommateur lent | Une connexion dont le `bufferedAmount` dépasse le seuil d'alerte. |
68
+ | **ELU** | _Event Loop Utilization_ | Fraction du temps où la boucle d'événements est active. ~1,0 = processeur saturé. |
69
+ | **Cadence** | granularité | L'intervalle de publication d'un canal d'état, inscrit dans son **nom** (`base:<ms>`). |
70
+ | **Per-instance** | par instance | Mesuré sur **ce** processus. En cluster, ce n'est pas la vérité du pod. |
71
+ | **Pod** | — | L'unité déployée : un maître et ses workers, vus comme un seul serveur. |
72
+ | **Drill-down** | forage | Demander à un worker précis d'enrichir sa remontée, le temps qu'on le regarde. |
73
+ | **IPC** | _Inter-Process Communication_ | Le canal de messages entre le maître d'un cluster et ses workers. |
74
+
75
+ ## Qu'est-ce qu'observer une socket ?
76
+
77
+ Un serveur HTTP se surveille facilement : chaque requête a un début, une fin, un code. Une socket,
78
+ non — **elle n'a qu'un début**. Une connexion ouverte pendant six heures ne produit aucune ligne de
79
+ journal, et pourtant elle consomme, elle accumule, et parfois elle se dégrade.
80
+
81
+ L'analogie utile est celle d'un **standard téléphonique**. Ce qui compte n'est pas le nombre d'appels
82
+ passés, c'est : combien de lignes sont décrochées, combien de personnes écoutent chaque conférence, et
83
+ surtout **si l'une d'elles n'arrive plus à suivre**. Ce dernier point est le seul vrai danger.
84
+
85
+ **Le risque numéro un d'une socket n'est pas le processeur, c'est la mémoire.** Quand un client
86
+ n'absorbe pas ce qu'on lui envoie, les messages s'empilent dans une file d'envoi côté serveur. Cette
87
+ file n'a pas de borne naturelle : elle grossit jusqu'à ce que le client réagisse — ou jusqu'à ce que le
88
+ processus tombe. Et le multiplexage **concentre** ce risque : une seule connexion lente retient
89
+ l'ensemble des canaux qu'elle a demandés.
90
+
91
+ C'est pour cela que la sonde de la socket ne ressemble à aucune autre. Elle ne mesure pas d'abord un
92
+ temps de réponse — elle mesure **ce qui n'est pas encore parti**.
93
+
94
+ ## La vision Nodefony — la socket s'observe à travers elle-même
95
+
96
+ Trois partis pris, tous lisibles dans le code.
97
+
98
+ **1. La sonde est une lecture pure, jamais un collecteur.** `RealtimeHub.probe()`
99
+ (`RealtimeHub.ts:775`) ne fait qu'additionner des primitives déjà tenues à jour par le chemin chaud.
100
+ Aucune entrée-sortie, aucune exception possible, aucun état de lecture conservé. Appeler la sonde
101
+ mille fois par seconde ne changerait rien à ce que le hub fait par ailleurs.
102
+
103
+ **2. Les cumuls sont monotones, le débit se dérive côté lecteur.** Le serveur ne calcule aucune
104
+ moyenne mobile, ne garde aucune fenêtre glissante. Il expose `publishTotal`, `bytesSentTotal`, `ts` —
105
+ et le lecteur soustrait deux photos. Conséquence : **zéro état de lecture côté serveur**, donc zéro
106
+ divergence entre deux consommateurs, et un rafraîchissement qui se choisit librement.
107
+
108
+ **3. La mesure voyage sur la socket qu'elle décrit.** Le même producteur alimente l'endpoint HTTP
109
+ (premier affichage, sans attendre un tick) et le canal temps réel (le suivi). Un seul code, deux
110
+ portes — `buildRealtimeHealth()` (`RealtimeAdminApi.ts:74`).
111
+
112
+ Le compromis assumé : cette sonde est **per-instance par nature**. Un pod à quatre workers rend quatre
113
+ vérités partielles. Nodefony ne les moyenne pas au hasard de la requête — il pousse une vue agrégée
114
+ explicite (voir plus bas), et le mode cluster reste **désactivable en bloc**.
115
+
116
+ ## 🚀 Démarrage rapide
117
+
118
+ **Le besoin vécu** : ta boutique publie des commandes, et tu veux voir en direct — dans Studio, sans
119
+ écrire une ligne de frontend — combien de commandes passent et combien échouent. Tu ne veux ni agent
120
+ externe, ni base de séries temporelles, ni redémarrage.
121
+
122
+ Le principe : ton service tient déjà des compteurs ; la sonde ne fait que les **lire** et les
123
+ publier sur un canal.
124
+
125
+ ### Le contrôleur — fichier créé
126
+
127
+ ```ts
128
+ // modules/shop/nodefony/controllers/ShopProbeController.ts
129
+ import { controller, route } from "@nodefony/framework";
130
+ import { RealtimeController, RealtimeChannel } from "@nodefony/realtime";
131
+ import type { RealtimePublish } from "@nodefony/realtime";
132
+ import type { ContextType } from "@nodefony/http";
133
+
134
+ /** Les constantes vitales de la boutique : des primitives, rien d'autre. */
135
+ export interface ShopHealth {
136
+ ts: number;
137
+ ordersTotal: number;
138
+ failuresTotal: number;
139
+ pendingNow: number;
140
+ }
141
+
142
+ /**
143
+ * Compteurs tenus par le MÉTIER (incrémentés là où les commandes se traitent).
144
+ * La sonde les lit ; elle ne les produit pas, et surtout elle ne les mute pas.
145
+ */
146
+ export const shopCounters = {
147
+ ordersTotal: 0,
148
+ failuresTotal: 0,
149
+ pendingNow: 0,
150
+ };
151
+
152
+ /** Le nom du canal : une constante, citée côté serveur ET côté client. */
153
+ export const SHOP_HEALTH = "shop:health";
154
+
155
+ @controller("/shop")
156
+ class ShopProbeController extends RealtimeController {
157
+ constructor(context: ContextType) {
158
+ super("shop", context);
159
+ }
160
+
161
+ /** La porte WebSocket. Tout le protocole est porté par la classe de base. */
162
+ @route("shop-realtime", {
163
+ path: "/realtime",
164
+ requirements: { methods: ["WEBSOCKET"] },
165
+ })
166
+ async realtime(message: string | Buffer | null): Promise<void> {
167
+ this.handleRealtime(message);
168
+ }
169
+
170
+ /**
171
+ * LE CANAL DE SANTÉ. Le hub appelle cette méthode au PREMIER abonné et attend
172
+ * en retour la fonction de nettoyage, qu'il appellera au DERNIER désabonné.
173
+ *
174
+ * Trois règles tiennent dans ces dix lignes :
175
+ * - `unref()` : le minuteur ne retient pas l'arrêt du processus ;
176
+ * - le nettoyage annule TOUT ce que le provider a démarré ;
177
+ * - le tick ne fait que LIRE des primitives — aucune collecte, aucun parcours.
178
+ */
179
+ @RealtimeChannel(SHOP_HEALTH)
180
+ shopHealth(channel: string, publish: RealtimePublish): () => void {
181
+ const timer = setInterval(() => {
182
+ const payload: ShopHealth = {
183
+ ts: Date.now(),
184
+ ordersTotal: shopCounters.ordersTotal,
185
+ failuresTotal: shopCounters.failuresTotal,
186
+ pendingNow: shopCounters.pendingNow,
187
+ };
188
+ publish(channel, payload);
189
+ }, 2000);
190
+ timer.unref();
191
+ return () => clearInterval(timer);
192
+ }
193
+ }
194
+
195
+ export default ShopProbeController;
196
+ ```
197
+
198
+ ### La configuration — fichier modifié
199
+
200
+ ```ts
201
+ // nodefony.config.ts — le manifeste des modules (extrait)
202
+ import { defineConfig, use } from "nodefony";
203
+
204
+ export default defineConfig(() => ({
205
+ modules: [
206
+ use("@nodefony/http", {}),
207
+ "@nodefony/framework",
208
+ // La socket. `loopback` = un seul processus, aucune infrastructure externe.
209
+ use("@nodefony/realtime", { backplane: { driver: "loopback" } }),
210
+ "@mon-shop/shop",
211
+ ],
212
+ }));
213
+ ```
214
+
215
+ ### Ce qu'on observe
216
+
217
+ ```bash
218
+ # La santé de la socket elle-même — un instantané, sans WebSocket.
219
+ curl -k https://127.0.0.1:5152/nodefony/realtime/api/health
220
+ ```
221
+
222
+ Tant que personne n'est abonné à `shop:health`, il **n'apparaît pas** dans `channels[]` et **aucun
223
+ minuteur ne tourne** : un canal sans abonné n'existe pas. Dès qu'un client s'abonne, une ligne
224
+ apparaît, `subscribers` passe à 1, `messages` grimpe de 1 toutes les deux secondes, et
225
+ `fanoutTotal` suit le nombre de livraisons réelles.
226
+
227
+ Côté navigateur, deux lignes suffisent à consommer le canal :
228
+
229
+ ```ts
230
+ // frontend/src/shopHealth.ts
231
+ import { RealtimeClient } from "nodefony/client";
232
+
233
+ const scheme = window.location.protocol === "https:" ? "wss" : "ws";
234
+ const socket = new RealtimeClient({
235
+ url: `${scheme}://${window.location.host}/shop/realtime`,
236
+ });
237
+
238
+ socket.on("shop:health", (payload: unknown) => {
239
+ console.log("santé boutique", payload);
240
+ });
241
+ socket.subscribe("shop:health");
242
+ ```
243
+
244
+ > [!TIP]
245
+ > Ferme l'onglet, et le minuteur s'arrête côté serveur. C'est la propriété la plus utile du patron :
246
+ > **on paie ce qu'on regarde**. Un tableau de bord fermé ne coûte rien.
247
+
248
+ ## 🧰 Ce que la sonde expose, champ par champ
249
+
250
+ Le contrat est `IRealtimeProbe` (`IRealtimeProbe.ts:61`). Quatre familles, un discriminant.
251
+
252
+ ### Canaux — qui vit, qui écoute
253
+
254
+ | Champ | Ce qu'il dit |
255
+ | ------------------------ | -------------------------------------------------------------------------------------------------------- |
256
+ | `channels[]` | Une entrée par canal **actif** (au moins un abonné), cf `IRealtimeChannelStat` (`IRealtimeProbe.ts:47`). |
257
+ | `channels[].channel` | Le nom exact souscrit, **suffixe de cadence compris** (`nodefony:orm:health:2000`). |
258
+ | `channels[].subscribers` | Abonnés locaux vivants. Instantané, pas un cumul. |
259
+ | `channels[].messages` | Publications cumulées sur ce canal. |
260
+ | `channelCount` | Nombre de canaux actifs sur ce processus. |
261
+
262
+ ### Fan-out — l'amplification réelle du broker
263
+
264
+ | Champ | Ce qu'il dit |
265
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------ |
266
+ | `publishTotal` | Appels à `publish`. **Ce que le serveur a voulu envoyer.** |
267
+ | `fanoutTotal` | Livraisons effectives (`publish` × abonnés). **Ce que ça a réellement coûté.** |
268
+ | `inboundTotal` | Frames poussées par les clients sur les canaux entrants, via `RealtimeHub.recordInbound()` (`RealtimeHub.ts:764`). |
269
+ | `ingressRejectedTotal` | Messages venus du **backplane** et jetés parce que leur canal n'est pas déclaré diffusable. |
270
+
271
+ Le rapport `fanoutTotal / publishTotal` est la mesure la plus parlante de la page : c'est la **taille
272
+ moyenne d'un salon**. S'il vaut 1, chaque publication ne touche qu'une personne — un canal par client,
273
+ autrement dit le fan-out ne sert à rien. S'il vaut 400, une publication coûte 400 sérialisations.
274
+
275
+ > [!IMPORTANT]
276
+ > **Un processus qui ne fait que publier paraît inactif.** `publishTotal` et `fanoutTotal` ne
277
+ > s'incrémentent que si le canal a un abonné **local** : un pod qui publie vers ses pairs, sans
278
+ > personne d'abonné chez lui — un travail planifié, un webhook entrant — affiche des compteurs à zéro
279
+ > alors qu'il pousse en continu. Pour juger de son activité, lire l'autre bout de la chaîne : les
280
+ > compteurs du pod qui **reçoit**.
281
+
282
+ ### Messages du bus refusés — le signal qui ne devrait jamais bouger
283
+
284
+ `ingressRejectedTotal` compte les messages arrivés **par le bus** et refusés à l'entrée : leur canal
285
+ n'est pas déclaré diffusable, donc il n'a rien à faire en circulation entre processus. Les canaux
286
+ internes — journaux, audit, santé — sont dans ce cas par construction.
287
+
288
+ En fonctionnement normal il vaut **zéro** : un pair légitime n'émet que des canaux diffusables. Deux
289
+ lectures possibles quand il grimpe :
290
+
291
+ - **bénigne** — une autre application partage la même cloison de transport, et ses canaux sont
292
+ refusés ; chacun chez soi, mais le namespace mérite d'être séparé ;
293
+ - **sérieuse** — quelqu'un écrit sur votre bus. Une infrastructure de diffusion n'authentifie pas
294
+ l'émetteur : c'est le rôle du sceau (cf [Sécurité](./securite.md)).
295
+
296
+ Le canal visé n'est délibérément pas exposé : un compteur d'observabilité ne doit pas devenir un
297
+ moyen de sonder ce que le système accepte.
298
+
299
+ ⚠️ Ce compteur ne voit pas tout : un message **mal scellé** est écarté par le transport, avant
300
+ d'atteindre le hub, donc sans être compté ici. Un bus scellé peut ainsi rester à zéro alors qu'il est
301
+ attaqué — le signe est alors l'absence de trafic légitime, pas ce compteur.
302
+
303
+ ### Connexions et volume
304
+
305
+ `connectionCount`, `bytesSentTotal`, `messagesSentTotal` — vivants pour la première, cumulés pour les
306
+ deux autres. Le débit en octets par seconde se dérive de deux photos.
307
+
308
+ ### Back-pressure — le risque numéro un
309
+
310
+ | Champ | Ce qu'il dit |
311
+ | ---------------------------------- | ----------------------------------------------------------------------------------- |
312
+ | `backpressure.maxBufferedAmount` | La **pire** file d'envoi de l'instant. C'est l'indicateur d'alerte, pas la moyenne. |
313
+ | `backpressure.totalBufferedAmount` | Somme des octets en attente sur tout le processus. |
314
+ | `backpressure.slowConsumers` | Connexions au-dessus du seuil de comptage. |
315
+ | `backpressure.drops` | Cumul des frames **jetées** pour protéger la mémoire. |
316
+
317
+ La source par connexion est `IRealtimeConnProbe` (`IRealtimeProbe.ts:25`), implémentée par le
318
+ transport. Les seuils d'ACTION sont distincts du seuil de comptage, et ils sont
319
+ **configurables** (`@nodefony/http`, `http/nodefony/config/config.ts:625`) : au-delà de
320
+ `websocket.maxBackpressure` (4 MiB par défaut) la politique `websocket.backpressurePolicy`
321
+ s'applique — `drop` par défaut, la frame est **jetée** puisque les canaux d'état sont « le
322
+ dernier gagne » et que le prochain instantané la remplacera. La connexion est **fermée** en
323
+ `1013` (« réessaie plus tard ») après `websocket.backpressureCloseAfterDrops` frames jetées
324
+ CONSÉCUTIVES (1000 par défaut) — une seule frame qui repart remet le compteur à zéro — ou
325
+ dès le premier dépassement si la politique est `close`.
326
+
327
+ > [!IMPORTANT]
328
+ > `drops` qui croît n'est **pas** une panne : c'est la protection qui fonctionne. Ce qui serait une
329
+ > panne, c'est une file d'envoi non bornée multipliée par le nombre de clients.
330
+
331
+ ### Fond de panier
332
+
333
+ `backplane` porte la carte d'identité du relais réellement branché — driver, nature, origine,
334
+ franchissement de machine. Jamais `undefined` en pratique : sans backplane, la sonde rend un
335
+ descripteur `local`. C'est le champ à lire quand le temps réel ne traverse pas les répliques.
336
+
337
+ Trois champs n'apparaissent que lorsqu'ils ont un sens :
338
+
339
+ - `channel` — le canal de transport **effectif**. Il répond à la seule question qu'on ne peut pas
340
+ poser autrement : _suis-je branché sur le bus que je crois ?_ Le nom affiché est celui utilisé,
341
+ pas celui écrit dans la configuration (la cloison peut venir de l'environnement).
342
+ - `sealed` — les messages sont-ils signés ? Renseigné par les transports **partagés**, où un tiers
343
+ peut écrire. `false` y annonce un bus ouvert. Absent quand la question ne se pose pas : en
344
+ mono-processus, ou entre un maître et ses propres workers, personne d'autre ne peut publier.
345
+ - `queue` — la **file d'envoi** vers le bus, présente sur les transports réseau (leurs publications
346
+ sont acquittées plus tard). Publier n'attend pas : le message est confié au client réseau et le
347
+ code continue. Si le bus ralentit, ces messages s'accumulent — sur une file **interne au client**,
348
+ donc invisible. Quatre compteurs la rendent lisible : `bytes` (en attente d'accusé de réception),
349
+ `maxBytes` (le plafond, `0` = aucun), `droppedTotal` (publications abandonnées faute de place) et
350
+ `failedTotal` (publications refusées par le bus). `droppedTotal` est le seul de la sonde qui
351
+ signale une perte **volontaire** : la mémoire du processus a été préservée au prix de messages non
352
+ relayés aux autres répliques. Il doit rester à zéro ; s'il bouge, regarder l'état du bus avant de
353
+ relever le plafond (`backplane.maxQueueBytes`) — un plafond plus haut ne rattrape pas un bus en
354
+ retard, il repousse l'échéance.
355
+
356
+ ### La couche d'identité, au-dessus
357
+
358
+ `buildOwnHealth()` (`RealtimeAdminApi.ts:52`) enrichit ce snapshot pur pour produire
359
+ `IRealtimeHealth` (`IRealtimeProbe.ts:106`) : `instanceId`, la santé **process** du worker
360
+ (CPU, mémoire, boucle d'événements), et — si elles ont été branchées — la santé ORM et les compteurs
361
+ d'erreurs du journal. Ces trois derniers champs sont **additifs et optionnels** : un consommateur qui
362
+ ne les connaît pas les ignore.
363
+
364
+ ## 🔌 Deux chemins de lecture — l'instantané et le flux
365
+
366
+ Un seul producteur, deux portes. C'est volontaire : un tableau de bord qui n'aurait que le flux
367
+ resterait vide jusqu'au premier tick.
368
+
369
+ ### L'instantané HTTP
370
+
371
+ `GET /nodefony/realtime/api/health`, monté par `createRealtimeAdminApi()` (`RealtimeAdminApi.ts:91`)
372
+ sous le namespace `realtime` du data plane admin. Sert le premier affichage, les sondes de liveness,
373
+ et tout script qui ne veut pas ouvrir une socket.
374
+
375
+ ### Le canal temps réel
376
+
377
+ `nodefony:socket`. Le producteur est un minuteur qui rappelle **le même endpoint** à travers le
378
+ courtier admin (`StudioRealtimeController.createRealtimeChannel()`, `StudioRealtimeController.ts:182`,
379
+ via `createBrokerTicker()` (`providers.ts:465`)). Rien n'est dupliqué : le canal est un endpoint
380
+ rejoué.
381
+
382
+ ### La cadence vit dans le nom du canal
383
+
384
+ Convention isomorphe, partagée par les deux bords : `base` nu = cadence par défaut du serveur ;
385
+ `base:<ms>` = cadence explicite. Le client fabrique le nom avec `rateChannel()` (`channelRate.ts:44`),
386
+ le serveur le résout et le **borne** avec `parseRate()` (`channelRate.ts:63`).
387
+
388
+ Conséquence voulue : **un canal = une cadence = un compteur de références**. `nodefony:socket:500` et
389
+ `nodefony:socket` sont deux minuteurs distincts, jamais réconciliés — et deux consommateurs qui
390
+ veulent tous deux le défaut partagent un seul minuteur, parce que la cadence par défaut ne produit
391
+ **aucun** suffixe.
392
+
393
+ ### Les canaux de santé qui circulent sur la socket
394
+
395
+ Les bornes sont déclarées dans `RATE_BOUNDS` (`StudioRealtimeController.ts:59`), les noms dans
396
+ `CHANNELS` (`providers.ts:98`). Une cadence hors bornes est **ramenée dans l'intervalle**, jamais
397
+ refusée.
398
+
399
+ | Canal | Qui le produit | Cadence par défaut | Bornes |
400
+ | ---------------------- | ------------------------------------------------------------------ | ------------------ | ------------- |
401
+ | `nodefony:socket` | La sonde du hub, rejouée depuis l'endpoint admin `realtime` | 2 s | 500 ms – 60 s |
402
+ | `nodefony:supervision` | `createStatsTicker()` (`providers.ts:222`) — CPU, mémoire, GC, ELU | 1 s | 250 ms – 60 s |
403
+ | `nodefony:debugbar` | Le même producteur, canal séparé pour la barre de débogage | 1 s | 250 ms – 60 s |
404
+ | `nodefony:orm:health` | Endpoint admin `orm/connection/health`, rejoué | 5 s | 1 s – 60 s |
405
+ | `nodefony:orm:flow` | Endpoint admin `orm/flow` (débit de requêtes) | 2 s | 500 ms – 60 s |
406
+ | `nodefony:syslog` | `createSyslogBridge()` (`providers.ts:145`) | **événementiel** | — (coalescé) |
407
+
408
+ > [!WARNING]
409
+ > **`nodefony:dashboard` n'existe pas.** Le canal de supervision s'appelle `nodefony:supervision` ; ce
410
+ > nom n'apparaît plus que comme donnée de test. S'abonner à un canal inconnu ne lève **aucune
411
+ > erreur** — le provider rend `null` et rien n'arrive jamais. C'est le piège numéro un de la page.
412
+
413
+ `nodefony:syslog` est le seul de la liste à ne pas être cadencé : il **relaie** au lieu de sonder. Comme
414
+ un flot de journaux peut noyer un frontend, le pont accumule dans un tampon circulaire borné et n'émet
415
+ qu'une frame agrégée toutes les 200 ms, en comptant les entrées omises — le débit de la source est
416
+ découplé de celui de l'interface.
417
+
418
+ Deux autres formes existent, réservées au forage : `nodefony:supervision@<pid>` et `nodefony:orm:rich@<pid>`
419
+ ciblent **un worker précis**, et non le premier venu.
420
+
421
+ ## 📡 Observabilité — Studio
422
+
423
+ Trois écrans consomment `nodefony:socket`, chacun avec une question différente.
424
+
425
+ - **Temps réel** (`/nodefony/hub`) — la console de la socket : canaux vivants et leurs abonnés,
426
+ volume diffusé, connexions en retard. C'est l'écran du développeur qui se demande « mon canal
427
+ est-il monté ? ».
428
+ - **Cluster** (`/nodefony/cluster`) — la vue pod : tous les workers, avec le détail par worker et le
429
+ forage vers un `pid`. L'écran de l'exploitation.
430
+ - **Carte du serveur** (`/nodefony/twin`) — le jumeau vivant : la même sonde projetée sur le graphe
431
+ d'architecture, chaque nœud coloré par son état.
432
+
433
+ Les trois sont des **abonnements comptés par référence** : monter la page active le minuteur côté
434
+ serveur, la démonter le coupe. Aucun d'eux ne tourne en tâche de fond.
435
+
436
+ La page module `/nodefony/modules/realtime` complète le tableau avec la **configuration effective**
437
+ après fusion et validation — la seule vue qui dit ce qui s'applique, plutôt que ce que tu as écrit.
438
+
439
+ ## ⚙️ Configuration
440
+
441
+ | Clé | Défaut | Effet |
442
+ | ------------------------ | ------ | ------------------------------------------------------------------------- |
443
+ | `slowConsumer.bytes` | 1 Mio | Seuil de **comptage** de `slowConsumers` dans la sonde (`config.ts:121`). |
444
+ | `cluster.probe.enabled` | `true` | Branche la sonde agrégée pod en worker de cluster (`config.ts:30`). |
445
+ | `NF_CLUSTER_PROBE` (env) | — | Mis à `0`, coupe la sonde pod quelle que soit la configuration. |
446
+
447
+ > [!CAUTION]
448
+ > `slowConsumer.bytes` ne pilote **que la métrique**. Les seuils qui jettent une frame ou ferment une
449
+ > connexion sont des constantes du transport. Baisser cette clé rend le signalement plus précoce ; ça
450
+ > ne change rien au comportement de la socket.
451
+
452
+ Couper la sonde cluster est un **contournement total**, pas une mise en sourdine : sans elle, il n'y a
453
+ ni client, ni minuteur, ni écouteur, ni message IPC (`Realtime.wireClusterProbe()`, `index.ts:351`), et
454
+ l'endpoint retombe sur la vue per-instance. Aucun coût « au cas où ».
455
+
456
+ ## 🏗️ En cluster — par pod ou par worker ?
457
+
458
+ **Le problème**, d'abord. En cluster, une requête de santé atterrit sur **un** worker au hasard. Son
459
+ instantané est exact — et ne dit rien des trois autres. Pire : deux appels consécutifs peuvent tomber
460
+ sur deux workers et sembler se contredire.
461
+
462
+ **La réponse de Nodefony est un modèle _push_.** Chaque worker remonte périodiquement sa santé au
463
+ maître via `ClusterProbeClient.start()` (`ClusterProbeClient.ts:170`) ; le maître fusionne et
464
+ rediffuse ; chaque worker met le résultat en cache. **N'importe lequel** sert alors la vue pod en temps
465
+ constant, sans latence de requête (`ClusterProbeClient.getClusterHealth()`, `ClusterProbeClient.ts:285`).
466
+
467
+ La fusion, `mergeClusterHealth()` (`ClusterProbeClient.ts:46`), est une fonction pure — et sa règle
468
+ mérite d'être connue :
469
+
470
+ | Grandeur | Agrégation | Pourquoi |
471
+ | ---------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- |
472
+ | `publishTotal`, `fanoutTotal`, `connectionCount`, octets, frames | **somme** | Le pod fait la somme de ce que font ses workers. |
473
+ | `ingressRejectedTotal` | somme | Un refus reste un refus, quel que soit le worker qui l'a opposé. |
474
+ | `backpressure.maxBufferedAmount` | **maximum** | La santé d'une flotte se juge sur son **pire** membre, pas sur sa moyenne. |
475
+ | `slowConsumers`, `drops` | somme | Ce sont des dénombrements. |
476
+
477
+ Le discriminant est le champ `cluster: true` de `IRealtimeClusterHealth` (`IRealtimeProbe.ts:155`) :
478
+ un consommateur sait immédiatement s'il lit une vue pod ou une vue per-instance, et `instances[]`
479
+ garde le détail par worker pour le forage.
480
+
481
+ ### On paie ce qu'on regarde
482
+
483
+ Les sondes coûteuses ne tournent pas en permanence. Le maître peut demander à un worker précis
484
+ d'**enrichir** sa remontée — `ClusterProbeClient.requestEnrich()` (`ClusterProbeClient.ts:239`) — quand
485
+ un humain ouvre le forage de ce `pid`. La sonde riche (espaces mémoire, cycles de ramasse-miettes,
486
+ descripteurs actifs) est alors allouée ; à la fermeture de l'écran, elle est libérée et son observateur
487
+ détaché.
488
+
489
+ Ce qui est **par pod** : la vue agrégée, `instances[]`, les totaux. Ce qui reste **par worker** :
490
+ absolument tout le reste — un `channelCount` per-instance ne dit rien du pod, et une connexion
491
+ appartient à un seul processus.
492
+
493
+ ## ⚡ Performance & mémoire — écrire une sonde qui ne coûte rien
494
+
495
+ Deux règles. Les deux sont des blocages, pas des conseils.
496
+
497
+ ### Règle 1 — `unref()` sur tout minuteur, sans exception
498
+
499
+ Un minuteur ordinaire **retient la boucle d'événements**. Un processus qui en garde un actif ne sort
500
+ jamais tout seul : les tests d'intégration restent suspendus, un conteneur ignore `SIGTERM`, et
501
+ l'orchestrateur finit par le tuer de force à l'expiration du délai de grâce.
502
+
503
+ La règle est tenue partout dans le code, y compris là où on ne l'attendrait pas — le tick de
504
+ revalidation d'identité du hub, qui ne démarre qu'au premier inscrit, est lui aussi détaché
505
+ (`RealtimeHub.ts:437`), tout comme le report de la sonde cluster (`ClusterProbeClient.ts:170`) et le
506
+ minuteur générique des canaux (`providers.ts:465`).
507
+
508
+ ```ts ignore
509
+ const timer = setInterval(tick, 2000);
510
+ timer.unref(); // ⬅ non négociable : le processus doit pouvoir sortir
511
+ return () => clearInterval(timer); // ⬅ et le nettoyage doit annuler ce qu'on a créé
512
+ ```
513
+
514
+ Le corollaire vaut autant : **un provider rend toujours une fonction de nettoyage qui annule tout ce
515
+ qu'il a démarré**. Minuteur, écouteur, observateur. Le hub l'appelle au dernier désabonné et à la
516
+ fermeture de la connexion ; s'il ne trouve rien à appeler, le minuteur survit à l'écran qui l'avait
517
+ demandé.
518
+
519
+ ### Règle 2 — une sonde LIT, elle ne collecte pas
520
+
521
+ > [!CAUTION]
522
+ > **L'anti-patron : la sonde qui alloue à chaque tick.** Un instantané d'environ 1 Ko, publié à 1 Hz
523
+ > pendant 24 h, ce sont **~84 Mo alloués pour rien** en une journée — et autant de pression sur le
524
+ > ramasse-miettes, donc de la latence en fin de distribution. Multiplie par le nombre de canaux
525
+ > observés.
526
+
527
+ Concrètement, dans le corps d'un tick :
528
+
529
+ - **À faire** : lire des primitives déjà tenues à jour ailleurs, et composer un objet plat.
530
+ - **À éviter** : `.map()`, `.filter()`, `.push()` sur des tableaux vivants ; sérialiser ; interroger
531
+ une base ; parcourir une structure pour produire la mesure.
532
+ - **Jamais** : muter l'état observé. Une sonde qui incrémente ce qu'elle mesure ne mesure plus rien.
533
+
534
+ C'est exactement ce que fait `RealtimeHub.probe()` : une boucle sur les canaux actifs, une boucle sur
535
+ les connexions, des additions d'entiers. Le seul objet alloué est le résultat lui-même, et il ne l'est
536
+ qu'à la demande.
537
+
538
+ **Coût toujours actif de l'observabilité de la socket** : deux incréments d'entiers par frame
539
+ envoyée dans le transport (octets et frames), plus la lecture de `bufferedAmount` déjà nécessaire au
540
+ seuil de contre-pression. Pas de chronométrage, pas de sérialisation supplémentaire. La
541
+ back-pressure étant le risque principal, elle est visible **sans drapeau à activer** — contrairement
542
+ aux sondes qui instrumentent chaque opération et se paient donc à l'usage.
543
+
544
+ ## ⚠️ Pièges
545
+
546
+ | Symptôme | Cause | Correction |
547
+ | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
548
+ | Le processus ne s'arrête plus, `Ctrl+C` reste sans effet | Un minuteur de sonde sans `unref()` retient la boucle d'événements | `timer.unref()` sur **tout** minuteur de provider, et une fonction de nettoyage qui l'annule |
549
+ | Le minuteur tourne encore après le départ du dernier abonné | Le provider ne rend pas de fonction de nettoyage, ou elle n'annule pas ce qu'il a démarré | Toujours rendre une fonction qui annule minuteurs **et** écouteurs |
550
+ | Abonnement à un canal de santé, **rien n'arrive**, aucune erreur | Canal inconnu du serveur : le provider rend `null`, l'abonnement est refusé en silence (`nodefony:dashboard` typique) | Vérifier le nom exact ; partager une constante entre les deux bords |
551
+ | `publishTotal` grimpe, `fanoutTotal` stagne | Tu publies dans le vide : personne n'est abonné au canal visé | Comparer le nom publié et le nom souscrit — presque toujours une faute de frappe |
552
+ | Deux appels consécutifs à `/api/health` donnent des chiffres contradictoires | En cluster, la requête tombe sur un worker au hasard et rend une vue per-instance | Lire le champ `cluster` ; si absent, la sonde pod est coupée ou en démarrage à froid |
553
+ | La mémoire du processus grimpe sans fuite apparente | Une sonde alloue à chaque tick, ou un client lent fait grossir la file d'envoi | Lire `backpressure.totalBufferedAmount` ; simplifier le corps du tick |
554
+ | Baisser `slowConsumer.bytes` ne ferme pas les clients lents | Cette clé pilote le **comptage** de la sonde ; les seuils d'action sont des constantes du transport | Rien à régler : ce comportement n'est pas configurable |
555
+ | La cadence demandée n'est pas respectée | Elle a été **ramenée dans les bornes** du canal, silencieusement | Lire les bornes du canal ; vérifier le nom réellement souscrit dans `channels[]` |
556
+ | Deux abonnés au même canal, deux minuteurs | Ils ont demandé des cadences différentes — un canal cadencé est distinct par cadence | Laisser la cadence par défaut (aucun suffixe) quand elle convient : les abonnés partagent un minuteur |
557
+ | Rien n'est visible alors que le trafic est réel | La sonde n'observe **jamais** ce à quoi personne n'est abonné : un canal sans abonné n'existe pas | Ouvrir l'écran ou s'abonner ; c'est le comportement voulu, pas une panne |
558
+
559
+ ## 🧪 Tests
560
+
561
+ L'auto-observabilité est couverte par des tests **unitaires déterministes** — elle n'a besoin d'aucune
562
+ infrastructure, puisque la sonde est une lecture pure.
563
+
564
+ <!-- prettier-ignore -->
565
+ | Suite | Ce qui est prouvé |
566
+ | --- | --- |
567
+ | `RealtimeHub.test.ts` | Hub vide → instantané à zéro ; abonnés et publications par canal ; `publishTotal` vs `fanoutTotal` ; comptage des consommateurs lents et `maxBufferedAmount` ; sortie du registre à la déconnexion ; frames entrantes ; descripteur `local` du backplane |
568
+ | `WsConnectionTransport.test.ts` | Les deux seuils de contre-pression : abandon « le dernier gagne », puis fermeture `1013` |
569
+ | `ClusterProbeClient.test.ts` | Fusion pod (sommes vs maximum), report périodique et cache du snapshot, forage `enrich`/`rich`, repli per-instance quand la sonde est absente |
570
+
571
+ **Ce qui manque, et qu'il faut savoir** : il n'existe pas de banc de charge dédié à la sonde
572
+ elle-même. Le coût du chemin chaud (les incréments du transport) est couvert indirectement par les
573
+ suites de charge du module, jamais isolé. Pour mesurer l'effet d'une sonde métier sur ta propre
574
+ application, passe par les outils dédiés — skill `nodefony-load-test` pour la charge et le
575
+ dimensionnement, `nodefony-check-memory-health` pour la mémoire.
576
+
577
+ ```bash
578
+ cd src/packages/@nodefony/realtime && npm test
579
+ npm run coverage # rapport vitest — jamais de pourcentage figé dans cette page
580
+ ```
581
+
582
+ ## 🔗 Pour aller plus loin
583
+
584
+ - ⬆️ **Retour au hub** : [Hub](index.md) · [Toute la documentation](../../../../../docs/index.md)
585
+ - [Architecture](architecture.md) — d'où viennent les compteurs : hub, transport, peer, backplane.
586
+ - [Configuration](configuration.md) — le schéma complet, les surcharges d'environnement, la vue de la configuration effective.
587
+ - [Sécurité](securite.md) — qui a le droit de s'abonner à un canal de santé, et pourquoi ça compte.
588
+ - [Cookbook — un chat](cookbook-chat.md) — le patron appliqué de bout en bout sur une vraie fonctionnalité.
589
+ - [Vocabulaire](vocabulaire.md) — les douze mots de la socket.
590
+ </content>
591
+
592
+ </invoke>