@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,803 @@
1
+ ---
2
+ title: "Configuration — le fond de panier, les bornes, la porte d'entrée"
3
+ navTitle: Configuration
4
+ lang: fr
5
+ module: "@nodefony/realtime"
6
+ topic: realtime
7
+ coverageModule: realtime
8
+ section: "Realtime"
9
+ audience: [developer, devops]
10
+ tags:
11
+ [
12
+ configuration,
13
+ zod,
14
+ backplane,
15
+ driver,
16
+ loopback,
17
+ cluster,
18
+ redis,
19
+ namespace,
20
+ origin,
21
+ limites,
22
+ environnement,
23
+ ]
24
+ version: "doc"
25
+ status: stable
26
+ updated: 2026-07-19
27
+ source: "src/packages/@nodefony/realtime/docs/configuration.md"
28
+ ---
29
+
30
+ # Configuration — le fond de panier, les bornes, la porte d'entrée
31
+
32
+ > Le module temps réel expose **six réglages**, pas un de plus. Un seul décide vraiment de quelque
33
+ > chose : le **driver de backplane**, c'est-à-dire la façon dont deux processus de ton application se
34
+ > transmettent une publication. Les autres bornent la connexion et gardent la porte. Cette page les
35
+ > donne tous, avec leur valeur d'usine lue dans le schéma, l'effet observable de chacun, et les trois
36
+ > situations de déploiement qui décident du choix.
37
+
38
+ 📍 [Documentation](../../../../../docs/index.md) › [@nodefony/realtime](index.md) › **Configuration**
39
+
40
+ ```nodefony-livegraph
41
+ {
42
+ "graph": "backplane",
43
+ "height": 500,
44
+ "title": "Le fond de panier, en direct",
45
+ "hint": "Le driver actif est lu dans la sonde, pas figé dans le schéma — de quoi vérifier qu'un déploiement tourne sur celui qu'on croit."
46
+ }
47
+ ```
48
+
49
+ ## 🧠 Le modèle mental — un schéma, trois couches de surcharge
50
+
51
+ La configuration du module n'est pas un objet que tu construis : c'est un **schéma Zod** qui porte
52
+ déjà toutes ses valeurs d'usine. Ton application n'écrit que ses **écarts**, et le déploiement peut
53
+ encore corriger par variables d'environnement. Chaque couche recouvre la précédente, et le résultat
54
+ est validé puis **gelé** avant que le moindre canal existe.
55
+
56
+ ```mermaid
57
+ flowchart TD
58
+ SCHEMA["Schéma Zod du module<br/>realtimeConfigSchema — les valeurs d'usine"]
59
+ APP["Ton application<br/>use('@nodefony/realtime', { … }) dans nodefony.config.ts"]
60
+ ENVGEN["Déploiement<br/>NF__REALTIME__&lt;CHEMIN&gt;=valeur"]
61
+ BUILD["Builder<br/>defineRealtimeConfig() : parse Zod → NF_REALTIME_DRIVER → freeze"]
62
+ HUB["RealtimeHub<br/>garde d'origine · plafond de canaux · seuil de sonde"]
63
+ BP["Driver de backplane<br/>résolu par nom dans le registre"]
64
+ SCHEMA --> APP --> ENVGEN --> BUILD
65
+ BUILD --> HUB
66
+ BUILD --> BP
67
+ ```
68
+
69
+ Trois conséquences pratiques, et ce sont elles qui font la différence à l'usage :
70
+
71
+ 1. **Une configuration vide est une configuration valide.** Ne rien écrire donne un module qui
72
+ fonctionne en mono-processus, avec des bornes anti-abus déjà actives.
73
+ 2. **Une clé fautive arrête le démarrage**, avec le chemin exact et le message Zod — pas un
74
+ `undefined` qui explose trois minutes plus tard sur la première frame.
75
+ 3. **La configuration appliquée est immuable.** Rien ne se règle à chaud : elle est gelée à la
76
+ validation, et le hub reçoit ses valeurs une seule fois, au démarrage.
77
+
78
+ ## 📖 Lexique
79
+
80
+ | Terme | Développé / traduction | En une ligne |
81
+ | ------------------ | -------------------------------- | ------------------------------------------------------------------------------------------------ |
82
+ | **Backplane** | fond de panier | le transport qui relaie une publication d'un processus vers les autres. Jamais un stockage |
83
+ | **Driver** | pilote | l'implémentation concrète du backplane, désignée par un **nom** (`loopback`, `cluster`, `redis`) |
84
+ | **Hub** | broker du processus | tient un producteur par canal et diffuse aux abonnés locaux |
85
+ | **Canal** | channel | un sous-flux nommé qui circule dans la connexion WebSocket |
86
+ | **Fan-out** | diffusion | une publication, N livraisons aux abonnés |
87
+ | **Namespace** | espace de nommage, cloison | suffixe du canal de transport, qui empêche deux applications de se parler sur un bus mutualisé |
88
+ | **`originId`** | identifiant d'origine | l'étiquette du processus émetteur, comparée pour ne pas se rejouer son propre message |
89
+ | **Sonde** | probe | l'instantané de santé du hub, servi en JSON |
90
+ | **CSRF** | _Cross-Site Request Forgery_ | un site tiers déclenche une requête authentifiée à l'insu de la victime, avec ses cookies |
91
+ | **CSWSH** | _Cross-Site WebSocket Hijacking_ | la même attaque, sur une WebSocket : les navigateurs n'appliquent pas CORS à l'ouverture |
92
+ | **Back-pressure** | contre-pression | ce qui attend dans la file d'envoi quand le client ne lit pas assez vite |
93
+ | **IPC** | _Inter-Process Communication_ | le canal de messages entre le maître et ses workers, fourni par Node |
94
+ | **Pod / réplique** | instance déployée | un processus de l'application ; plusieurs pods servent le même trafic |
95
+
96
+ ## Qu'est-ce qu'on configure ici ?
97
+
98
+ Trois choses seulement, et elles répondent à trois questions distinctes.
99
+
100
+ **Comment mes processus se parlent-ils ?** En développement, un seul processus : une publication
101
+ atteint tous les abonnés parce qu'ils sont tous là. En production à trois répliques, un message
102
+ publié sur la réplique A n'atteindra jamais un abonné branché sur la réplique B — sauf si un
103
+ transport les relie. C'est le rôle du **backplane**, et c'est le seul réglage qui change entre un
104
+ poste de développement et un cluster.
105
+
106
+ **Jusqu'où une connexion peut-elle consommer ?** Une socket ouverte est une ressource. Un client
107
+ peut demander mille canaux, ou cesser de lire ce qu'on lui envoie. Deux clés bornent ces deux dérives.
108
+
109
+ **Qui a le droit d'ouvrir la socket ?** Un navigateur envoie les cookies de ta victime même quand la
110
+ page qui ouvre la connexion appartient à un tiers. Le contrôle d'`Origin` ferme cette porte.
111
+
112
+ > [!NOTE]
113
+ > Ce que tu ne configures **pas** ici : le port et le chemin de la WebSocket (ils viennent de
114
+ > `@nodefony/http` et de ta route), les rôles et les zones protégées (ils viennent de
115
+ > `@nodefony/security`, cf [Sécurité](./securite.md)), et la taille maximale d'un message entrant
116
+ > (`websocket.maxPayload`, porté par `@nodefony/http`).
117
+
118
+ ## La vision Nodefony — le schéma EST la configuration
119
+
120
+ Le module suit la convention figée du framework, dont `@nodefony/drizzle` est la référence : **deux
121
+ fichiers, jamais trois**.
122
+
123
+ | Fichier | Rôle | Ce qu'il contient |
124
+ | --------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------- |
125
+ | `nodefony/config/config.ts` | **le quoi** | le schéma Zod commenté, source **unique** des valeurs d'usine, et les défauts matérialisés |
126
+ | `nodefony/config/defineModuleConfig.ts` | **le comment** | le builder pur : analyse, surcharge d'environnement, gel — il ne retape jamais une valeur d'usine |
127
+
128
+ Le schéma `realtimeConfigSchema` (`config.ts:205`) porte chaque valeur en `.default()` et chaque
129
+ explication en `.describe()`. Les défauts effectifs sont **dérivés** du schéma lui-même
130
+ (`realtimeConfigSchema.parse({})`, `config.ts:231`) : il n'existe pas de second endroit où une valeur
131
+ d'usine serait écrite, donc pas de dérive silencieuse possible entre la doc du champ et son
132
+ comportement.
133
+
134
+ Le builder `defineRealtimeConfig()` (`defineModuleConfig.ts:35`) reste **pur** : il analyse l'entrée,
135
+ applique les variables d'environnement dédiées (`NF_REALTIME_DRIVER`,
136
+ `NF_REALTIME_BACKPLANE_NAMESPACE`, `NF_REALTIME_BACKPLANE_SECRET` — toutes prioritaires sur le
137
+ fichier), et gèle le résultat. Il ne décide rien.
138
+
139
+ Le module **augmente le registre de types** (`index.ts:167`) : `use("@nodefony/realtime", { … })`
140
+ propose ses clés en autocomplétion et **refuse** une clé inconnue à la compilation.
141
+
142
+ > [!IMPORTANT]
143
+ > C'est ce filet qui compte, bien plus que le confort d'écriture. Sans lui, une faute de frappe ne se
144
+ > verrait ni à l'écriture ni au démarrage : le schéma **retire** les clés inconnues sans se plaindre.
145
+ > `slowConsummer: { bytes: 4096 }` donnerait le défaut, en silence. Avec l'augmentation, la même
146
+ > faute ne compile pas.
147
+
148
+ ## 🚀 Démarrage rapide
149
+
150
+ Vu depuis une application créée par `nodefony create app`. Deux fichiers, et une seule ligne qui
151
+ diffère entre les deux situations.
152
+
153
+ ### 1. Un processus — le minimum utile
154
+
155
+ C'est la configuration d'un poste de développement, et celle d'un déploiement à une seule réplique.
156
+ On garde le fond de panier local, on ferme la porte d'entrée, on abaisse le plafond de canaux à ce
157
+ dont l'application a réellement besoin.
158
+
159
+ ```ts
160
+ // nodefony.config.ts — le manifeste de l'application
161
+ export default defineConfig(() => ({
162
+ modules: [
163
+ "@nodefony/http",
164
+ "@nodefony/framework",
165
+ use("@nodefony/realtime", {
166
+ // Un seul processus : le hub diffuse localement, aucun transport n'est ouvert.
167
+ backplane: { driver: "loopback" },
168
+
169
+ // Défense CSWSH (RFC 6455 §10.2). Sans elle, une page tierce ouvre une socket
170
+ // avec les cookies de ton utilisateur : le navigateur n'applique pas CORS ici.
171
+ csrf: {
172
+ checkOrigin: {
173
+ enabled: true,
174
+ allowList: ["https://127.0.0.1:5152"],
175
+ // Un client non-navigateur n'envoie pas d'en-tête Origin. Le laisser à
176
+ // false n'a de sens que si tout ton trafic vient d'un navigateur.
177
+ allowMissingOrigin: false,
178
+ },
179
+ },
180
+
181
+ // Garde anti-saturation : le défaut de 256 est généreux. Un écran qui suit
182
+ // dix canaux n'a aucune raison d'en ouvrir plus de quelques dizaines.
183
+ limits: { maxChannelsPerConnection: 32 },
184
+ }),
185
+ ],
186
+ }));
187
+ ```
188
+
189
+ ### 2. Plusieurs répliques — la seule ligne qui change
190
+
191
+ Passer à N pods sur N machines ne change **ni le contrôleur, ni le client, ni un seul canal** : on
192
+ remplace le nom du driver, et on déclare le module qui fournit les connexions pub/sub.
193
+
194
+ ```ts
195
+ // nodefony.config.ts — la même application, déployée en plusieurs répliques
196
+ export default defineConfig((ctx) => ({
197
+ modules: [
198
+ "@nodefony/http",
199
+ "@nodefony/framework",
200
+
201
+ // Le driver `redis` consomme les connexions `publish` et `subscribe` de ce
202
+ // module. Sans lui déclaré, le hub reste local — et le dit dans les journaux.
203
+ "@nodefony/redis",
204
+
205
+ use("@nodefony/realtime", {
206
+ backplane: {
207
+ // En production seulement : en développement, le fond de panier local évite
208
+ // d'exiger une infrastructure pour lancer l'application.
209
+ driver: ctx.isProd ? "redis" : "loopback",
210
+
211
+ // La cloison. Le numéro de base Redis ne cloisonne PAS le pub/sub : sans ce
212
+ // nom, préproduction et production sur un même Redis échangeraient leurs
213
+ // diffusions. À poser explicitement, et différemment par déploiement.
214
+ namespace: ctx.isProd ? "boutique.prod" : "boutique.dev",
215
+ },
216
+ csrf: {
217
+ checkOrigin: {
218
+ enabled: true,
219
+ allowList: ["https://boutique.example.com"],
220
+ },
221
+ },
222
+ limits: { maxChannelsPerConnection: 32 },
223
+ }),
224
+ ],
225
+ }));
226
+ ```
227
+
228
+ Il reste à **déclarer diffusables** les canaux qui doivent franchir la frontière du processus — c'est
229
+ une propriété du contrôleur, pas de la configuration, et le défaut est l'isolement :
230
+
231
+ ```ts
232
+ // nodefony/controllers/chat.ts — extrait
233
+ import { RealtimeController } from "@nodefony/realtime";
234
+
235
+ export class ChatController extends RealtimeController {
236
+ // Sans cette déclaration, "chat:*" resterait confiné au processus qui l'a publié.
237
+ // (le préfixe diffusable est déclaré par `@RealtimeBroadcast` sur la classe)
238
+ }
239
+ ```
240
+
241
+ ### Ce qu'on lit dans les journaux
242
+
243
+ Le module annonce sa topologie effective au démarrage, en une ligne. C'est le premier endroit à
244
+ regarder quand un message ne traverse pas.
245
+
246
+ | Ligne observée | Ce qu'elle dit |
247
+ | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
248
+ | `realtime backplane driver=loopback kind=local cross-pod=no (hub local)` | mono-processus assumé : rien ne franchit la frontière |
249
+ | `realtime backplane driver=redis kind=redis-pubsub … cross-pod=yes channel=` | le fan-out traverse ; le canal effectif est affiché, cloison comprise |
250
+ | `driver "redis" : module @nodefony/redis absent … RealtimeHub reste local` | le module pub/sub n'est pas déclaré — repli annoncé, démarrage poursuivi |
251
+ | `backplane driver "…" inconnu du registre (disponibles : …)` | nom de driver fautif ; la liste réelle est affichée dans le même message |
252
+
253
+ > [!TIP]
254
+ > Ces quatre lignes viennent du même endroit du code (`Realtime.#wireBackplane()`,
255
+ > `src/packages/@nodefony/realtime/index.ts:253`) et de la carte d'identité que chaque driver publie.
256
+ > Aucun repli n'est silencieux : si le fan-out est dégradé, c'est écrit.
257
+
258
+ ## ⚙️ Le schéma, clé par clé
259
+
260
+ Six blocs, douze clés au total. Le tableau donne la vérité complète ; les sections qui suivent
261
+ expliquent quand et pourquoi en changer.
262
+
263
+ <!-- prettier-ignore -->
264
+ | Clé | Type | Défaut | Effet |
265
+ | --- | --- | --- | --- |
266
+ | `enabled` | `boolean` | `true` | `false` = module chargé mais inerte : ni API d'administration, ni backplane, ni sonde, ni garde |
267
+ | `backplane.driver` | `string` | `"loopback"` | nom résolu dans le registre de drivers ; inconnu → avertissement, le hub reste local |
268
+ | `backplane.namespace` | `string?` | _absent_ → nom d'app | cloison du transport partagé ; suffixe le canal pub/sub. Motif `^[\w.-]+$` |
269
+ | `backplane.secret` | `string?` (≥ 32) | _absent_ | scelle les messages du transport partagé (HMAC) ; identique sur tous les pods. Absent = bus ouvert + avertissement au boot |
270
+ | `backplane.maxQueueBytes` | `number` entier ≥ 0 | `8388608` (8 MiB) | plafond des octets publiés en attente d'accusé de réception du bus ; au-delà, les publications sont abandonnées et comptées (`backplane.queue`). `0` = aucune limite |
271
+ | `cluster.probe.enabled` | `boolean` | `true` | branche la sonde agrégée du pod en worker de cluster. `false` = aucun minuteur, aucun IPC |
272
+ | `slowConsumer.bytes` | `number` entier > 0 | `1048576` (1 MiB) | seuil de **comptage** des consommateurs lents dans la sonde. Observe, n'agit pas |
273
+ | `limits.maxChannelsPerConnection` | `number > 0` \| `null` | `256` | plafond de canaux par connexion ; au-delà le `subscribe` est refusé. `null` = illimité |
274
+ | `csrf.checkOrigin.enabled` | `boolean` | `false` | active le contrôle d'`Origin` à l'ouverture de la socket |
275
+ | `csrf.checkOrigin.allowList` | `string[]` | `[]` | origines acceptées, **comparaison exacte**. Vide + activé = tout est refusé |
276
+ | `csrf.checkOrigin.allowMissingOrigin` | `boolean` | `false` | accepter une ouverture sans en-tête `Origin` (clients non-navigateur) |
277
+
278
+ C'est **tout**. Il n'existe ni réglage de ping, ni de cadence, ni de fréquence d'échantillonnage de
279
+ la sonde, ni de seuil de contre-pression configurable : ces comportements existent, mais leurs
280
+ valeurs sont des constantes du code, pas des clés.
281
+
282
+ ### `enabled` — le module au repos
283
+
284
+ Poser `false` charge le module sans rien activer. Concrètement : pas d'API d'administration
285
+ enregistrée, pas de backplane câblé, pas de sonde de pod
286
+ (`Realtime.onKernelBoot()`, `src/packages/@nodefony/realtime/index.ts:213`), et le service devient
287
+ inerte (`RealtimeService.init()`, `RealtimeService.ts:77`).
288
+
289
+ > [!WARNING]
290
+ > **`enabled: false` désarme aussi tes gardes.** Le hub garde son plafond interne de 256 canaux,
291
+ > mais l'allowlist d'origines que tu as configurée **n'est jamais posée** : c'est le service qui la
292
+ > transmet au hub, et il ne fait plus rien. Éteindre le module pour « désactiver le temps réel » sur
293
+ > une application dont la route WebSocket existe encore laisse la porte plus ouverte qu'avant.
294
+
295
+ Le bon usage : un environnement où le temps réel n'a pas de sens (une commande CLI, un banc). Pour
296
+ désactiver vraiment, retire le module du manifeste.
297
+
298
+ ### `backplane.driver` — qui relaie les publications
299
+
300
+ C'est **le** réglage du module. Le champ est une chaîne libre et non une énumération, volontairement :
301
+ la liste réelle est celle du **registre** de drivers (`listBackplaneDrivers()`,
302
+ `backplaneRegistry.ts:68`), ouverte aux drivers que tu écris toi-même. Une énumération figée dans le
303
+ schéma fermerait cette porte.
304
+
305
+ Trois noms sont enregistrés par le module au chargement :
306
+
307
+ | Nom | Classe | Ce qu'il relie | Traverse les machines |
308
+ | ------------ | ------------------- | ---------------------------------------- | :-------------------: |
309
+ | `"loopback"` | `LoopbackBackplane` | rien — le hub diffuse localement | non |
310
+ | `"cluster"` | `ClusterBackplane` | les workers d'un même processus maître | non |
311
+ | `"redis"` | `RedisBackplane` | tous les processus abonnés au même canal | **oui** |
312
+
313
+ Un nom inconnu ne fait pas échouer le démarrage : il produit un avertissement qui **affiche la liste
314
+ des drivers réellement disponibles**, et le hub reste local. C'est un choix de résilience — une faute
315
+ de frappe dans une variable de déploiement ne doit pas empêcher l'application de servir du HTTP.
316
+
317
+ > [!NOTE]
318
+ > Il n'existe **pas** de driver Kafka. Le registre n'en connaît que trois. Un bus de messages
319
+ > persistant se branche par la voie normale d'extension : tu écris ton driver et tu l'inscris.
320
+
321
+ ### `backplane.namespace` — la cloison sur un bus partagé
322
+
323
+ Ne concerne que les drivers qui traversent les machines. Le canal de transport devient
324
+ `nodefony:realtime:<namespace>` (`resolveRedisChannel()`, `RedisBackplane.ts:31`).
325
+
326
+ Le défaut est **dérivé du nom de l'application**
327
+ (`src/packages/@nodefony/realtime/index.ts:132`) — ce qui suffit à séparer deux applications
328
+ distinctes sur un Redis mutualisé, et **ne suffit pas** à séparer deux déploiements de la **même**
329
+ application. Préproduction et production portent le même nom d'application, donc le même canal
330
+ dérivé, donc le même flux.
331
+
332
+ > [!CAUTION]
333
+ > C'est une clé de **sécurité**, pas de confort. Le numéro de base Redis (`database`) ne cloisonne
334
+ > pas le pub/sub, qui est global au serveur. Deux déploiements sans namespace explicite se
335
+ > transmettent mutuellement leurs diffusions : un message de préproduction arrive chez tes
336
+ > utilisateurs. Pose-la dès que deux environnements partagent un Redis.
337
+
338
+ Caractères acceptés : lettres, chiffres, `_`, `.`, `-` — le motif est validé par le schéma
339
+ (`backplaneSchema`, `config.ts:45`), donc un nom fautif arrête le démarrage plutôt que de produire un
340
+ canal exotique.
341
+
342
+ ### `backplane.secret` — sceller ce qui circule sur le bus
343
+
344
+ Ne concerne, là aussi, que les transports **partagés**. Un bus pub/sub ne dit pas _qui_ a publié :
345
+ sans secret, tout ce qui atterrit sur le canal — venu d'un pod ou d'un tiers qui sait écrire dans ce
346
+ Redis — est traité comme un message légitime. Poser un secret fait porter à chaque message un sceau
347
+ HMAC-SHA256, vérifié à l'arrivée (`openBackplaneEnvelope()`, `envelope.ts:96`) : un message non
348
+ scellé, altéré ou repointé vers un autre canal est **ignoré**, sans exception possible.
349
+
350
+ Le secret doit être **identique sur tous les pods** — c'est lui qui les reconnaît entre eux. En
351
+ déploiement, passe-le par `NF_REALTIME_BACKPLANE_SECRET` (secret k8s, variable Docker) : la variable
352
+ a la précédence sur la configuration, et un secret n'a rien à faire dans un fichier versionné.
353
+
354
+ Sans secret, rien ne casse : le transport reste ouvert et le démarrage l'annonce en avertissement.
355
+ La bonne façon de vérifier que le scellement fonctionne est le compteur `ingressRejectedTotal` de la
356
+ sonde de santé — il reste à zéro en régime normal.
357
+
358
+ > [!NOTE]
359
+ > Le driver `cluster` (IPC entre les workers d'un même pod) n'a pas besoin de secret : ce canal ne
360
+ > relie qu'un maître à **ses** propres processus enfants, aucun tiers ne peut y écrire.
361
+
362
+ ### `cluster.probe.enabled` — la sonde agrégée du pod
363
+
364
+ Sans effet hors d'un worker lancé par `nodefony cluster`. Dans ce contexte, la sonde remonte
365
+ périodiquement la santé de **son** processus au maître, qui la consolide, et redistribue
366
+ l'instantané : n'importe quel worker peut alors servir la vue « pod entier » sans interroger les
367
+ autres (`ClusterProbeClient`, `ClusterProbeClient.ts:138`).
368
+
369
+ Le coût est faible mais réel : un minuteur (déréférencé, il n'empêche pas le processus de sortir) et
370
+ un écouteur IPC par worker. Poser `false` produit un **contournement complet** — le client n'est
371
+ jamais instancié, donc zéro minuteur, zéro message IPC — et l'endpoint de santé retombe sur la vue
372
+ de l'instance courante.
373
+
374
+ Deux leviers coupent la sonde, et **l'un ou l'autre suffit** : cette clé, ou la variable
375
+ `NF_CLUSTER_PROBE=0` (`src/packages/@nodefony/realtime/index.ts:391`). C'est ce qui permet
376
+ d'éteindre une sonde sur un pod en incident sans redéployer une configuration.
377
+
378
+ ### `slowConsumer.bytes` — un compteur, pas un frein
379
+
380
+ Seuil de `bufferedAmount` — les octets en attente d'envoi sur une socket — au-delà duquel la sonde
381
+ compte la connexion comme « lente » (`RealtimeHub.probe()`, `RealtimeHub.ts:775`).
382
+
383
+ > [!IMPORTANT]
384
+ > **Cette clé observe, elle n'agit pas.** Elle ne change que le compteur `slowConsumers` de la
385
+ > sonde. Ce qui **agit** — jeter une frame, puis fermer la connexion — se règle à côté, sous
386
+ > le serveur WebSocket, côté `@nodefony/http` (section suivante). Baisser `slowConsumer.bytes` à 64 KiB pour fermer plus tôt les
387
+ > clients lents ne changerait que le nombre affiché.
388
+
389
+ ### Ce qui AGIT se règle sur le serveur WebSocket, pas ici
390
+
391
+ La contre-pression — jeter une frame, puis fermer la connexion — appartient au **transport**, donc à
392
+ `@nodefony/http`. Une seule implémentation la porte, partagée par `send`/`broadcast` HTTP et par
393
+ chaque connexion realtime ; deux copies avaient déjà divergé en silence (4 Mio d'un côté, 1 Mio de
394
+ l'autre).
395
+
396
+ | Réglage (`@nodefony/http`) | Défaut | Effet |
397
+ | ----------------------------- | ------ | ---------------------------------------------------------------- |
398
+ | `maxBackpressure` | 4 Mio | file d'envoi au-delà de laquelle la frame est **jetée** |
399
+ | `backpressurePolicy` | `drop` | `close` = fermer dès le premier dépassement |
400
+ | `backpressureCloseAfterDrops` | 1000 | **solde** de refus au-delà duquel on ferme (`1013`) ; 0 = jamais |
401
+
402
+ > [!IMPORTANT]
403
+ > **Deux serveurs, deux sections** : `websocket` (ws://) et `websocketSecure` (wss://). Régler l'un
404
+ > ne règle pas l'autre — une application servie en `wss` qui ne configure que `websocket` garde les
405
+ > défauts, sans que rien ne le signale.
406
+
407
+ ```ts
408
+ use("@nodefony/http", {
409
+ websocketSecure: { maxBackpressure: 262144, backpressureCloseAfterDrops: 50 },
410
+ });
411
+ ```
412
+
413
+ Pourquoi un **solde** de refus plutôt qu'un second seuil d'octets : une fois qu'on jette, plus rien
414
+ n'alimente la file — elle plafonne au seuil de drop et n'atteindrait **jamais** un seuil supérieur.
415
+ Le solde monte de 1 par refus et descend de 1 par envoi réussi : un pic passager redescend, un
416
+ client qui n'absorbe plus finit par être coupé. Mesuré sur socket réelle (banc
417
+ `ws-backpressure-e2e.mjs`) : 400 charges poussées à un client qui ne lit pas → 3 servies, 20
418
+ refusées, fermeture `1013`.
419
+
420
+ > [!NOTE]
421
+ > Ces seuils sont **par pod** et ne sont jamais annoncés au client : il reçoit le seul signal utile
422
+ > — la fermeture `1013`, qu'il classe comme transitoire et fait suivre d'une reconnexion temporisée.
423
+ > Sa cadence, elle, s'ajuste sur le comportement observé (AIMD), ce qui reste juste même si ces
424
+ > valeurs changent à chaud.
425
+
426
+ L'usage correct est celui d'un **seuil d'alerte** : le baisser pour être averti plus tôt qu'une
427
+ population de clients décroche (mobile, réseau contraint), le monter pour cesser de compter comme
428
+ anormale une application qui pousse des charges volumineuses. Le défaut de 1 MiB est aligné sur la
429
+ taille maximale d'un message entrant : une file qui dépasse une frame pleine est déjà suspecte pour
430
+ des canaux d'état, où seul le dernier instantané compte.
431
+
432
+ ### `limits.maxChannelsPerConnection` — le plafond par connexion
433
+
434
+ Chaque canal ouvert coûte un producteur côté hub, un minuteur le plus souvent, et une entrée de table
435
+ côté connexion. Sans borne, **un seul** client peut s'abonner jusqu'à épuiser la mémoire du
436
+ processus. Le plafond est vérifié à chaque abonnement (`RealtimeController.startChannel()`,
437
+ `RealtimeController.ts:706`).
438
+
439
+ Quatre propriétés qui décident du bon réglage :
440
+
441
+ - Le refus est **observable** : le client reçoit une notification `realtime:denied` avec le motif
442
+ `limit`. Il ne se croit jamais abonné à tort.
443
+ - Le canal **n'est pas ouvert** : au-delà du plafond, le hub n'est même pas appelé, donc aucun
444
+ producteur ne démarre.
445
+ - Un réabonnement à un canal **déjà tenu** ne consomme pas de place — l'idempotence est vérifiée
446
+ avant le plafond. Un client qui redemande son canal n'est pas puni.
447
+ - La garde existe **même sans configuration** : le hub porte le même défaut de 256
448
+ (`RealtimeHub.ts:217`), il n'y a pas de fenêtre où elle serait absente.
449
+
450
+ `null` retire la borne. C'est un retrait explicite, à réserver aux déploiements dont tu maîtrises
451
+ les clients — un tableau de bord interne qui compose des dizaines de flux, par exemple.
452
+
453
+ > [!NOTE]
454
+ > Le plafond est **par connexion**, pas global. Cent connexions à 256 canaux restent possibles :
455
+ > c'est une garde contre un client abusif, pas contre une charge légitime mal dimensionnée.
456
+
457
+ ### `csrf.checkOrigin` — qui a le droit d'ouvrir la socket
458
+
459
+ Trois clés qui forment une seule politique, appliquée à l'ouverture de la connexion
460
+ (`RealtimeHub.checkOrigin()`, `RealtimeHub.ts:896`). Une origine refusée ferme la socket avec le code
461
+ `4003`.
462
+
463
+ Le défaut est **désactivé**. C'est le seul défaut du module qui n'est pas le réglage recommandé :
464
+ active-le dès que ta socket est joignable depuis un navigateur.
465
+
466
+ Le comportement se lit en trois lignes (`buildOriginGuard()`, `RealtimeService.ts:291`) :
467
+
468
+ | Situation | Résultat |
469
+ | ------------------------------------------------ | ------------------------------------------------------------------------ |
470
+ | `enabled: false` | toutes les origines passent, aucune garde n'est posée |
471
+ | `enabled: true`, en-tête `Origin` présent | accepté **uniquement** si la chaîne figure telle quelle dans `allowList` |
472
+ | `enabled: true`, en-tête `Origin` absent ou vide | accepté si `allowMissingOrigin: true`, refusé sinon |
473
+
474
+ La comparaison est **exacte** : schéma, hôte et port, sans joker. `https://app.example.com` ne couvre
475
+ ni `http://app.example.com`, ni `https://app.example.com:8443`, ni un sous-domaine. C'est un
476
+ durcissement volontaire par rapport au `*` que tolère CORS côté HTTP.
477
+
478
+ Le raisonnement complet sur ce que cette défense bloque — et sur ce qu'elle ne bloque pas, un client
479
+ non-navigateur pouvant toujours forger l'en-tête — appartient à [Sécurité](./securite.md).
480
+
481
+ > [!CAUTION]
482
+ > `enabled: true` avec une `allowList` vide **refuse tout le monde**. C'est un échec fermé
483
+ > volontaire : mieux vaut une socket qui ne s'ouvre pas qu'une garde qu'on croit active. Si tes
484
+ > connexions tombent en `4003` juste après avoir activé le contrôle, c'est la première chose à
485
+ > vérifier. Et `allowMissingOrigin: true` sans authentificateur fort rouvre exactement la brèche que
486
+ > le contrôle ferme.
487
+
488
+ ## 🔌 Choisir le driver de backplane — trois situations
489
+
490
+ Le choix ne se déduit pas d'un tableau de performances : il se déduit de **ta topologie de
491
+ déploiement**. Trois situations couvrent l'essentiel.
492
+
493
+ Le schéma ci-dessous met en évidence le driver **réellement actif** sur l'instance qui sert cette
494
+
495
+ ### Un seul processus — `loopback`
496
+
497
+ **Le besoin.** Tu développes, tu lances des tests, ou tu déploies une application à une seule
498
+ réplique. Tous les abonnés sont dans le même processus que le producteur.
499
+
500
+ ```ts ignore
501
+ use("@nodefony/realtime", { backplane: { driver: "loopback" } });
502
+ ```
503
+
504
+ **Ce que tu observes.** Le journal annonce `kind=local cross-pod=no`. Le hub garde en réalité son
505
+ backplane à `null` : le coût par publication est un test de nullité, pas un appel de fonction. La
506
+ classe `LoopbackBackplane` existe pour matérialiser le contrat et prouver en test que brancher un
507
+ backplane sans pair ne change rien.
508
+
509
+ **Le piège.** Rien ne casse tant que tu restes à une réplique. Le jour où l'orchestrateur en démarre
510
+ une seconde, la moitié de tes utilisateurs cesse de recevoir les messages de l'autre moitié — sans la
511
+ moindre erreur dans les journaux. Ce n'est pas une panne détectable par une sonde de vivacité.
512
+
513
+ ### Plusieurs workers sur une machine — `cluster`
514
+
515
+ **Le besoin.** Tu veux exploiter les cœurs d'une machine avec `nodefony cluster -w 4`, ou valider ton
516
+ application en multi-processus **sans installer la moindre infrastructure**.
517
+
518
+ ```ts ignore
519
+ use("@nodefony/realtime", { backplane: { driver: "cluster" } });
520
+ ```
521
+
522
+ **Ce qui se passe.** Un worker Node ne peut parler qu'au maître. Il lui envoie ses publications, et le
523
+ maître les redistribue aux autres workers. Le driver ne s'active qu'en **rôle worker** et avec la
524
+ variable `NF_CLUSTER=1`, posée par la commande de cluster
525
+ (`src/packages/@nodefony/realtime/index.ts:99`) ; ailleurs, il rend `null` et le hub reste local.
526
+
527
+ **Pourquoi c'est précieux.** C'est le banc d'essai qui stabilise l'architecture multi-processus avant
528
+ d'ajouter du réseau. Ton contrôleur, tes canaux diffusables et ton client voient un vrai fan-out
529
+ entre processus. Il ne manque que le franchissement de machine.
530
+
531
+ > [!TIP]
532
+ > Configurer `driver: "cluster"` et lancer un processus unique n'est pas une erreur : la fabrique
533
+ > rend `null`, le hub reste local, la ligne de journal le dit. Tu peux donc laisser ce driver dans une
534
+ > configuration qui sert aux deux modes de lancement.
535
+
536
+ ### Plusieurs machines — `redis`
537
+
538
+ **Le besoin.** Plusieurs répliques, potentiellement sur plusieurs nœuds : Kubernetes, Swarm, Nomad,
539
+ Cloud Run. C'est la situation de production standard d'une application web temps réel.
540
+
541
+ ```ts ignore
542
+ // Déclarer "@nodefony/redis" dans le manifeste, puis :
543
+ use("@nodefony/realtime", {
544
+ backplane: { driver: "redis", namespace: "boutique.prod" },
545
+ });
546
+ ```
547
+
548
+ **Comment il se branche.** Le driver ne dépend pas de la bibliothèque `redis` : il consomme deux
549
+ connexions du module `@nodefony/redis` par un adaptateur purement structurel
550
+ (`createRedisServiceTransport()`, `RedisBackplane.ts:100`). Deux et non une, parce qu'un client Redis
551
+ abonné ne peut plus émettre de commandes ordinaires — le module fournit précisément des connexions
552
+ nommées `publish` et `subscribe` dans ses défauts.
553
+
554
+ **Ce qui est garanti, et ce qui ne l'est pas.** Le pub/sub est un transport, pas un journal : aucune
555
+ persistance, aucune reprise. Un pod indisponible trente secondes rate ce qui a été publié pendant sa
556
+ coupure. C'est acceptable pour un salon de discussion — le message est en base — et inacceptable pour
557
+ un bus d'événements critiques. Ne construis pas au-dessus une fiabilité que le support n'offre pas.
558
+
559
+ **Si Redis est injoignable.** Le démarrage du backplane est attendu **explicitement**, et **borné à
560
+ cinq secondes** (`src/packages/@nodefony/realtime/index.ts:83`). Au-delà, ou en cas d'échec, le
561
+ module renonce, ferme proprement ce qu'il avait ouvert, et laisse le hub local — le démarrage
562
+ continue. Une base de messages en panne ne doit pas empêcher tes serveurs HTTP de monter.
563
+
564
+ ### En un coup d'œil
565
+
566
+ | Ta topologie | Driver | Infrastructure requise | Ce que tu obtiens |
567
+ | -------------------------------------- | ------------ | ---------------------- | ------------------------------- |
568
+ | un processus | `"loopback"` | aucune | diffusion locale, coût nul |
569
+ | N workers, une machine | `"cluster"` | aucune | fan-out entre processus par IPC |
570
+ | N répliques, plusieurs machines | `"redis"` | Redis + module dédié | fan-out entre machines |
571
+ | un bus maison (NATS, Pulsar, RabbitMQ) | le tien | la tienne | ce que ton driver implémente |
572
+
573
+ ## 🧩 Brancher son propre driver
574
+
575
+ Le contrat `IBackplane` est public et minuscule : six membres, aucune notion de canal logique ni
576
+ d'abonné — tout cet état vit dans le hub. Écrire un driver revient à transporter une enveloppe.
577
+
578
+ **La voie recommandée : inscrire un driver dans le registre.** Ton driver devient sélectionnable par
579
+ son nom, exactement comme les natifs, et ta configuration reste une simple chaîne — donc pilotable
580
+ par variable d'environnement.
581
+
582
+ ```ts ignore
583
+ // src/backplane/NatsBackplane.ts — dans TON application
584
+ import {
585
+ registerBackplaneDriver,
586
+ type IBackplane,
587
+ type IBackplaneInfo,
588
+ type BackplaneHandler,
589
+ } from "@nodefony/realtime";
590
+
591
+ class NatsBackplane implements IBackplane {
592
+ static readonly driver = "nats";
593
+ #handler: BackplaneHandler | null = null;
594
+
595
+ constructor(readonly originId: string) {}
596
+
597
+ async start(): Promise<void> {
598
+ /* ouvrir la connexion, s'abonner au sujet, appeler #handler à la réception */
599
+ }
600
+ publish(channel: string, payload: unknown): void {
601
+ /* émettre vers les AUTRES pairs, en joignant this.originId */
602
+ }
603
+ onMessage(handler: BackplaneHandler): void {
604
+ this.#handler = handler;
605
+ }
606
+ async stop(): Promise<void> {
607
+ /* fermer, libérer les écouteurs — idempotent */
608
+ }
609
+ describe(): IBackplaneInfo {
610
+ return {
611
+ driver: NatsBackplane.driver,
612
+ kind: "nats",
613
+ originId: this.originId,
614
+ crossPod: true,
615
+ };
616
+ }
617
+ }
618
+
619
+ // À l'import du module qui porte ce fichier — donc avant le démarrage du noyau.
620
+ registerBackplaneDriver(
621
+ NatsBackplane.driver,
622
+ (ctx) => new NatsBackplane(ctx.originId),
623
+ );
624
+ ```
625
+
626
+ Puis, en configuration : `backplane: { driver: "nats" }`.
627
+
628
+ Quatre points que la fabrique doit respecter :
629
+
630
+ 1. Elle **construit**, elle ne démarre pas. Le câblage appelle `start()` lui-même, sous garde de
631
+ délai.
632
+ 2. Elle peut rendre **`null`** pour dire « inactif dans ce contexte » — mauvais rôle, infrastructure
633
+ absente. Le hub reste alors local, sans erreur.
634
+ 3. Elle reçoit un contexte complet (`IBackplaneFactoryContext`, `backplaneRegistry.ts:27`) : le
635
+ module, l'`originId` déjà résolu, le rôle dans la topologie et la configuration validée.
636
+ 4. `describe()` alimente **trois** sorties depuis une source unique : la ligne de journal, la sonde
637
+ de santé et l'écran Studio. Remplis-la honnêtement, en particulier `crossPod`.
638
+
639
+ **L'autre voie : fournir une instance déjà construite.** Déclare un service nommé
640
+ `realtimeBackplane` dans le conteneur d'injection de ton module. `RealtimeService.init()`
641
+ (`RealtimeService.ts:77`) le lit et le branche **avant** que le registre soit consulté : une instance
642
+ présente court-circuite la sélection par nom.
643
+
644
+ > [!WARNING]
645
+ > Le builder accepte aussi une instance en second argument (`defineRealtimeConfig(config, {
646
+ backplane })`), et la documentation d'architecture présente cette voie. En pratique elle n'est pas
647
+ > atteignable depuis `nodefony.config.ts` : le module appelle le builder avec la seule configuration
648
+ > fusionnée (`src/packages/@nodefony/realtime/index.ts:216`), et le schéma Zod **retire** toute clé
649
+ > qu'il ne connaît pas — dont une instance de classe. Pour brancher un objet déjà construit, utilise
650
+ > le service `realtimeBackplane`.
651
+
652
+ Le détail du contrat, de l'anti-écho et du cycle de vie d'un driver est dans
653
+ [Architecture](./architecture.md).
654
+
655
+ ## ⚙️ Variables d'environnement et précédence
656
+
657
+ Six variables influent sur le module. Trois lui sont propres, les autres viennent du cœur ou du
658
+ mode cluster. Les trois premières décrivent un **déploiement** — le fond de panier auquel ce
659
+ processus se raccorde — et c'est pour cela qu'elles ne vivent pas dans un fichier versionné.
660
+
661
+ | Variable | Portée | Effet |
662
+ | --------------------------------- | ---------------------- | -------------------------------------------------------------------------- |
663
+ | `NF_REALTIME_DRIVER` | ce module | remplace `backplane.driver`. **Précédence maximale** |
664
+ | `NF_REALTIME_BACKPLANE_NAMESPACE` | ce module | remplace `backplane.namespace` — sépare deux déploiements de la même app |
665
+ | `NF_REALTIME_BACKPLANE_SECRET` | ce module | remplace `backplane.secret` — scelle le transport partagé (≥ 32 car.) |
666
+ | `NF__REALTIME__<CHEMIN>` | mécanisme du cœur | remplace n'importe quelle clé, par son chemin. Ex. `NF__REALTIME__ENABLED` |
667
+ | `NF_CLUSTER` | posée par le lancement | à `1`, le driver `cluster` s'active en worker. Ne la pose pas à la main |
668
+ | `NF_CLUSTER_PROBE` | ce module | à `0`, coupe la sonde de pod même si `cluster.probe.enabled` est vrai |
669
+ | `NF_POD_NAME` | déploiement | étiquette d'origine du processus ; sinon dérivée du nom d'hôte et du PID |
670
+
671
+ L'ordre de recouvrement, du plus faible au plus fort :
672
+
673
+ 1. **Les valeurs d'usine du schéma** — ce que tu obtiens sans rien écrire.
674
+ 2. **La configuration de l'application** — ton `use("@nodefony/realtime", { … })`.
675
+ 3. **`NF__REALTIME__<CHEMIN>`** — appliqué après la fusion et **avant** la validation Zod, donc la
676
+ valeur venue de l'environnement est validée comme les autres. Un chemin introuvable produit un
677
+ avertissement avec une suggestion, jamais une clé fantôme.
678
+ 4. **`NF_REALTIME_DRIVER`**, **`NF_REALTIME_BACKPLANE_NAMESPACE`** et
679
+ **`NF_REALTIME_BACKPLANE_SECRET`** — appliqués **après** l'analyse, dans le builder
680
+ (`defineModuleConfig.ts:41`). Ils gagnent sur tout le reste, y compris sur le mécanisme
681
+ générique `NF__REALTIME__BACKPLANE__…`.
682
+
683
+ Le mécanisme générique mérite d'être connu : le double tiret bas sépare les niveaux, les segments
684
+ sont insensibles à la casse, et les valeurs sont converties (booléens, nombres, listes séparées par
685
+ des virgules, JSON).
686
+
687
+ ```bash
688
+ # Le même déploiement, piloté sans reconstruire l'image.
689
+ NF_REALTIME_DRIVER=redis
690
+ NF__REALTIME__BACKPLANE__NAMESPACE=boutique.prod
691
+ NF__REALTIME__LIMITS__MAXCHANNELSPERCONNECTION=64
692
+ NF__REALTIME__CSRF__CHECKORIGIN__ALLOWLIST=https://a.example.com,https://b.example.com
693
+ NF_CLUSTER_PROBE=0 # couper la sonde de pod sur une réplique en incident
694
+ ```
695
+
696
+ > [!TIP]
697
+ > `NF_REALTIME_DRIVER` existe **en plus** du mécanisme générique parce qu'il est la variable qu'on
698
+ > pose en premier sur un déploiement, et qu'elle doit rester fiable quel que soit le moment où la
699
+ > configuration de l'application fusionne. C'est le levier d'urgence : ramener un cluster à
700
+ > `loopback` pour isoler une panne de bus se fait par une variable, sans redéployer de code.
701
+
702
+ ## 🧰 Le builder et le schéma exportable
703
+
704
+ Deux fonctions publiques, utiles surtout aux tests et aux outils.
705
+
706
+ `defineRealtimeConfig(config?, options?)` (`defineModuleConfig.ts:35`) analyse, applique la variable
707
+ de driver et **gèle**. Le module l'appelle lui-même à l'enregistrement
708
+ (`Realtime.onKernelRegister()`, `src/packages/@nodefony/realtime/index.ts:219`) : tu n'as pas à
709
+ l'invoquer dans une application. En revanche, c'est l'outil qui permet de vérifier une configuration
710
+ sans démarrer un serveur.
711
+
712
+ ```ts ignore
713
+ import { defineRealtimeConfig } from "@nodefony/realtime";
714
+
715
+ const cfg = defineRealtimeConfig({ backplane: { driver: "redis" } });
716
+ // cfg.limits.maxChannelsPerConnection === 256 — les sections omises gardent leurs défauts.
717
+ // Une valeur invalide lève une ZodError, avec le chemin exact du champ fautif.
718
+ ```
719
+
720
+ `realtimeConfigJsonSchema()` (`defineModuleConfig.ts:69`) produit le schéma JSON du module, chaque
721
+ champ portant sa description. C'est ce que consomme la page module de Studio pour afficher la
722
+ configuration attendue. L'instance de backplane éventuelle en est absente : une classe n'a rien à
723
+ faire dans un schéma sérialisable.
724
+
725
+ Un message d'erreur de validation est reformaté par le module avant d'être levé : chaque problème
726
+ apparaît sous la forme `chemin: message`, séparés par des points médians
727
+ (`src/packages/@nodefony/realtime/index.ts:219`). Tu lis quel champ est fautif, pas une trace Zod
728
+ brute.
729
+
730
+ ## 📡 Observabilité — relire ce qui s'applique vraiment
731
+
732
+ Trois endroits, par ordre de fiabilité décroissante.
733
+
734
+ - **La page module de Studio**, `/nodefony/modules/realtime`, onglet Config : la configuration
735
+ **effective** après fusion, surcharges d'environnement et validation. C'est la seule vue qui dit ce
736
+ qui s'applique — pas ce que tu as écrit.
737
+ - **La sonde de santé**, `/nodefony/realtime/api/health` : la carte d'identité du backplane
738
+ réellement branché (driver, nature, origine, franchissement de machine, canal effectif) et les
739
+ compteurs de contre-pression, dont `slowConsumers` qui dépend de ton seuil.
740
+ - **Les journaux de démarrage** : la ligne de topologie, et les avertissements de repli.
741
+
742
+ > [!TIP]
743
+ > Le triplet à comparer quand le temps réel ne traverse pas : le `driver` que tu as configuré, le
744
+ > `driver` qu'annonce la sonde, et le `channel` effectif. Un écart entre les deux premiers signifie
745
+ > qu'une fabrique a rendu `null` ; un `channel` inattendu signifie un namespace dérivé là où tu
746
+ > croyais l'avoir posé.
747
+
748
+ ## ⚠️ Pièges
749
+
750
+ | Symptôme | Cause | Correction |
751
+ | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
752
+ | Un message n'arrive qu'à la moitié des clients, sans aucune erreur | plusieurs répliques avec `driver: "loopback"` | passer à `redis` et déclarer `@nodefony/redis` dans le manifeste |
753
+ | Driver `redis` configuré, journal `RealtimeHub reste local` | module `@nodefony/redis` absent du manifeste, ou connexions `publish`/`subscribe` indisponibles | déclarer le module ; vérifier que Redis répond |
754
+ | Le fan-out traverse, mais des messages d'un autre environnement arrivent | `backplane.namespace` non posé : deux déploiements de la même application dérivent le même canal | poser un namespace explicite et distinct par environnement |
755
+ | Une clé écrite dans `use()` n'a aucun effet | nom de clé fautif : le registre de types n'étant pas augmenté, l'éditeur ne corrige pas, et Zod **retire** l'inconnu | relire la configuration effective dans Studio, onglet Config |
756
+ | Baisser `slowConsumer.bytes` ne ferme pas les clients lents | cette clé ne pilote que le **comptage** de la sonde | régler `websocketSecure.maxBackpressure` / `.backpressureCloseAfterDrops` (@nodefony/http) |
757
+ | Toutes les connexions tombent en `4003` après activation du contrôle d'origine | `allowList` vide, ou origine non identique au caractère près (port, schéma, sous-domaine) | inscrire l'origine exacte ; un client non-navigateur relève de `allowMissingOrigin` |
758
+ | `enabled: false` posé « pour désactiver », et la garde d'origine ne s'applique plus | le service devient inerte et ne pose plus les politiques sur le hub | retirer le module du manifeste plutôt que l'éteindre |
759
+ | Le plafond de canaux semble ignoré en test | il ne l'est pas : le hub porte le même défaut de 256 sans configuration (`RealtimeHub.ts:217`) | vérifier le motif de refus `realtime:denied` côté client |
760
+
761
+ ## 🧪 Tests & couverture
762
+
763
+ Les compteurs de cette page sont régénérés depuis vitest ; aucun chiffre n'est figé dans le texte. Ce
764
+ qui compte ici, c'est **ce que les suites prouvent sur la configuration**.
765
+
766
+ | Ce qui est prouvé | Où |
767
+ | --------------------------------------------------------------------- | ----------------------------------- |
768
+ | Défauts exacts, fusion partielle, gel, rejet des valeurs hors domaine | `defineRealtimeConfig.test.ts` |
769
+ | Le service pose bien les trois politiques sur le hub depuis la config | `RealtimeService.test.ts` |
770
+ | Le registre résout un nom, refuse l'inconnu, accepte un driver ajouté | `backplaneRegistry.test.ts` |
771
+ | Le plafond de canaux tient, y compris sans service | `realtimeChannelCap.attack.test.ts` |
772
+ | Les seuils de contre-pression appliqués par le transport | `WsConnectionTransport.test.ts` |
773
+ | Le fan-out entre processus, sans aucune infrastructure | `clusterIpc.e2e.test.ts` |
774
+ | Le fan-out entre machines par pub/sub | `redisCluster.e2e.test.ts` |
775
+
776
+ > [!WARNING]
777
+ > **Un run vert ne prouve pas le fan-out entre machines.** Le banc Redis est doublement conditionnel :
778
+ > il n'est lancé que sur demande, et il se **saute** de lui-même si aucun Redis ne répond. Un test
779
+ > sauté compte comme un succès — on peut donc lire « tout est vert » sur une suite qui n'a jamais
780
+ > ouvert une connexion. Le fan-out entre workers, lui, ne demande rien et tourne toujours.
781
+ >
782
+ > ```bash
783
+ > cd src/packages/@nodefony/realtime
784
+ > npm test # unitaires + IPC entre workers
785
+ > NF_RUN_CLUSTER_E2E=1 NF_REDIS_PASSWORD=nodefony-dev npm test # + le fan-out entre machines
786
+ > npm run coverage # couverture (vitest, v8)
787
+ > ```
788
+
789
+ ## 🔗 Pour aller plus loin
790
+
791
+ - ⬆️ **Retour au hub** : [@nodefony/realtime — vue d'ensemble](index.md) ·
792
+ [Toute la documentation](../../../../../docs/index.md)
793
+ - 📄 **Pages sœurs** : [Architecture](./architecture.md) (le contrat de backplane, l'anti-écho, le
794
+ cycle de vie d'un driver) · [Sécurité](./securite.md) (ce que le contrôle d'origine bloque
795
+ vraiment, l'identité, l'autorisation par canal) · [Vocabulaire](./vocabulaire.md) (les mots employés
796
+ ici) · [Cookbook — un chat](./cookbook-chat.md) (l'exemple complet)
797
+ - 🧭 **Modules voisins** : [`@nodefony/redis`](../../redis/docs/index.md) (les connexions consommées
798
+ par le driver `redis`) · [`@nodefony/http`](../../http/docs/index.md) (la taille maximale d'un
799
+ message, les serveurs WebSocket) · [`@nodefony/security`](../../security/docs/index.md) (zones,
800
+ identité, droits)
801
+ - 🏛️ **Transverse** : [la configuration d'une application](../../../../../docs/guides/configuration.md)
802
+ (`defineConfig`, `use()`, catalogue d'environnement)
803
+ - 📖 [Lexique général](../../../../../docs/lexique.md) du framework.