@nodefony/realtime 10.0.0-beta.2 → 10.0.0-beta.4

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 (63) hide show
  1. package/README.md +2 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/index.js +18 -13
  4. package/dist/nodefony/config/config.js +7 -5
  5. package/dist/nodefony/config/configSchema.js +18 -0
  6. package/dist/nodefony/config/defineModuleConfig.js +43 -17
  7. package/dist/nodefony/decorators/realtimeDecorators.js +69 -19
  8. package/dist/nodefony/interfaces/IRealtimeConnectionState.js +1 -0
  9. package/dist/nodefony/src/backplane/ClusterBackplane.js +1 -2
  10. package/dist/nodefony/src/backplane/LoopbackBackplane.js +1 -2
  11. package/dist/nodefony/src/backplane/RedisBackplane.js +22 -6
  12. package/dist/nodefony/src/backplane/backplaneRegistry.js +1 -2
  13. package/dist/nodefony/src/backplane/bootNotices.js +42 -0
  14. package/dist/nodefony/src/backplane/envelope.js +1 -2
  15. package/dist/nodefony/src/backplane/originId.js +1 -2
  16. package/dist/nodefony/src/backplane/publishQueue.js +1 -5
  17. package/dist/nodefony/src/cluster/ClusterProbeClient.js +16 -6
  18. package/dist/nodefony/src/guards.js +40 -0
  19. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +2 -6
  20. package/dist/nodefony/src/server/RealtimeAdminApi.js +1 -2
  21. package/dist/nodefony/src/server/RealtimeController.js +226 -82
  22. package/dist/nodefony/src/server/RealtimeHub.js +32 -54
  23. package/dist/nodefony/src/server/ServerRealtimeSocket.js +30 -13
  24. package/dist/nodefony/src/server/deniedDetail.js +1 -2
  25. package/dist/nodefony/src/server/subscribedAck.js +29 -0
  26. package/dist/nodefony/src/server/syslogUplink.js +5 -4
  27. package/dist/nodefony/src/server/welcomeEnv.js +1 -2
  28. package/dist/nodefony/src/service/RealtimeService.js +4 -4
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +42 -2
  30. package/dist/nodefony/testing/index.js +118 -5
  31. package/dist/types/index.d.ts +1 -0
  32. package/dist/types/nodefony/config/configSchema.d.ts +12 -0
  33. package/dist/types/nodefony/config/defineModuleConfig.d.ts +29 -6
  34. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +19 -2
  35. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +40 -0
  36. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +1 -1
  37. package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
  38. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +13 -0
  39. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  40. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +7 -0
  41. package/dist/types/nodefony/src/backplane/bootNotices.d.ts +25 -0
  42. package/dist/types/nodefony/src/guards.d.ts +32 -0
  43. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +13 -0
  44. package/dist/types/nodefony/src/server/RealtimeController.d.ts +82 -4
  45. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +22 -16
  46. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +18 -8
  47. package/dist/types/nodefony/src/server/subscribedAck.d.ts +23 -0
  48. package/dist/types/nodefony/src/service/RealtimeService.d.ts +1 -1
  49. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +26 -1
  50. package/dist/types/nodefony/testing/index.d.ts +8 -3
  51. package/docs/actions.md +129 -45
  52. package/docs/architecture.md +143 -135
  53. package/docs/cadence-et-contre-pression.md +643 -0
  54. package/docs/configuration.md +126 -97
  55. package/docs/cookbook-chat.md +50 -43
  56. package/docs/index.md +63 -38
  57. package/docs/observabilite.md +58 -56
  58. package/docs/protocole.md +110 -76
  59. package/docs/securite.md +128 -117
  60. package/docs/vocabulaire.md +133 -129
  61. package/package.json +17 -13
  62. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
  63. package/docs/couverture-bancs.md +0 -139
@@ -0,0 +1,643 @@
1
+ ---
2
+ title: "Cadence adaptative et contre-pression — quand un client ne suit plus"
3
+ navTitle: "Cadence et contre-pression"
4
+ lang: fr
5
+ module: "@nodefony/realtime"
6
+ topic: realtime
7
+ section: "Temps réel"
8
+ audience: [developer, devops]
9
+ tags:
10
+ [
11
+ realtime,
12
+ websocket,
13
+ aimd,
14
+ cadence,
15
+ backpressure,
16
+ contre-pression,
17
+ sonde,
18
+ 1013,
19
+ ]
20
+ version: "doc"
21
+ status: stable
22
+ updated: 2026-10-07
23
+ source: "src/packages/@nodefony/realtime/docs/cadence-et-contre-pression.md"
24
+ coverageModule: realtime
25
+ ---
26
+
27
+ # Cadence adaptative et contre-pression — quand un client ne suit plus
28
+
29
+ > Un serveur temps réel produit souvent plus vite qu'un client ne consomme. Nodefony y répond par
30
+ > **deux étages indépendants** : le client **ralentit lui-même la cadence** des canaux d'état qu'il
31
+ > écoute (AIMD), et le transport **refuse d'empiler** au-delà d'un seuil, puis coupe un client qui
32
+ > n'absorbe plus rien (contre-pression, fermeture `1013`). Aucun code ne relie les deux : c'est la
33
+ > **mesure** qui les fait se rencontrer. Cette page explique chaque étage, montre leurs courbes
34
+ > calculées par le vrai code, et dit où les observer en production.
35
+
36
+ 📍 [Documentation](../../../../../docs/index.md) › [@nodefony/realtime](index.md) › **Cadence et contre-pression**
37
+
38
+ ## 🧠 Schéma général
39
+
40
+ Deux étages, deux endroits du code, deux signaux. Les sondes, qui les observent, viennent plus bas.
41
+
42
+ ```mermaid
43
+ flowchart LR
44
+ T["Ticker<br/>1 trame / s"] --> H["Hub"]
45
+ H --> D{"File d'envoi<br/>> 4 Mio ?"}
46
+ D -->|"non"| C["Navigateur<br/>mesure l'écart"]
47
+ D -->|"oui"| J["Trame jetée<br/>solde + 1"]
48
+ J -->|"solde = 1000"| X["Fermeture<br/>1013"]
49
+ C -->|"trop lent"| L["Cadence × 2"]
50
+ C -->|"4 fois sain"| F["Cadence ÷ 2"]
51
+ ```
52
+
53
+ - **Étage 1 — la cadence adaptative**, côté **client** : il mesure l'écart entre deux trames et se
54
+ réabonne à une cadence plus lente ou plus rapide. Ce réabonnement retourne au hub, qui démarre
55
+ (ou rejoint) le ticker de la nouvelle cadence : le serveur ne fait qu'honorer le nom du canal.
56
+ - **Étage 2 — la contre-pression**, côté **serveur**, trame par trame : au-delà de la file permise,
57
+ la trame est jetée ; un client qui refuse plus qu'il n'accepte finit fermé.
58
+ - **Les sondes** comptent les clients lents et les refus, sans rien décider.
59
+
60
+ ## 📖 Lexique
61
+
62
+ | Terme | Développé / traduction | Ce que c'est, en une ligne |
63
+ | ------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
64
+ | **AIMD** | _Additive Increase / Multiplicative Decrease_ | Règle de régulation de TCP : on accélère prudemment par petits pas, on ralentit franchement dès que ça coince. |
65
+ | **Cadence** | intervalle de publication d'un canal | Le temps entre deux valeurs publiées (1000 ms = une valeur par seconde). |
66
+ | **Canal cadencé** | `base:<ms>` | Un canal dont le **nom** porte la cadence : `nodefony:orm:health:2000`. |
67
+ | **Famine** | _starvation_ | Le client attend une trame bien plus longtemps que la cadence promise. |
68
+ | **Bande morte** | _dead band_ / hystérésis | Zone entre « sain » et « famine » où l'on ne change rien, pour éviter d'osciller. |
69
+ | **Contre-pression** | _backpressure_ | Refuser d'accumuler pour un destinataire qui ne suit pas, au lieu de stocker sans limite. |
70
+ | **File d'envoi** | `bufferedAmount` | Les octets acceptés par la socket mais pas encore partis sur le réseau. |
71
+ | **Solde de refus** | `_nfDropStreak` | Compteur par connexion : +1 par trame refusée, −1 par trame envoyée. |
72
+ | **1013** | _Try Again Later_ | Code de fermeture WebSocket : « reviens plus tard ». Le client le traite comme transitoire et se reconnecte. |
73
+ | **Canal d'état / canal d'événements** | _latest-wins_ / _every-message-counts_ | État : seule la dernière valeur compte. Événements : chaque message compte. |
74
+ | **Sonde** | _probe_ | Lecture instantanée des compteurs du hub, exposée par HTTP et par un canal de santé. |
75
+ | **Abonner avant de couper** | _make-before-break_ | Changer de canal en s'abonnant au nouveau avant de quitter l'ancien : aucun trou dans le flux. |
76
+
77
+ ## Qu'est-ce que le problème, concrètement ?
78
+
79
+ **L'image** : un robinet qui remplit un verre. Si le robinet coule plus vite que l'on boit, le verre
80
+ déborde. On peut **fermer un peu le robinet** (c'est le rôle de la cadence adaptative), et il faut
81
+ de toute façon **un trop-plein** qui empêche l'inondation (c'est la contre-pression).
82
+
83
+ En temps réel, le « verre » est la **file d'envoi** de la socket côté serveur. Un client lent —
84
+ réseau mobile, onglet en arrière-plan, machine saturée — la laisse grossir. Sans garde :
85
+
86
+ - la **mémoire** du serveur gonfle, un client à la fois ;
87
+ - la **latence** explose : le client lit des valeurs vieilles de plusieurs secondes ;
88
+ - un `broadcast()` **amplifie** le problème : un seul client lent retient une diffusion entière ;
89
+ - au bout, le processus tombe à court de mémoire, et tous les clients le paient.
90
+
91
+ > [!IMPORTANT]
92
+ > Ce n'est pas une question de débit réseau mesuré. Nodefony **ne mesure jamais** la bande passante
93
+ > d'un client : le client observe les **intervalles d'arrivée** des trames, le serveur observe sa
94
+ > **file d'envoi**. Ce sont deux mesures locales, simples et sans coût, qui suffisent.
95
+
96
+ ## La vision Nodefony — deux étages qui ne se parlent pas
97
+
98
+ Nodefony empile deux mécanismes qui **ne partagent aucun code et aucun message**. Chacun agit sur
99
+ ce qu'il voit lui-même :
100
+
101
+ | | Étage 1 — cadence adaptative | Étage 2 — contre-pression |
102
+ | --------------------- | -------------------------------------------------------------- | ---------------------------------------------------- |
103
+ | **Où il vit** | dans le **navigateur** (`AdaptiveRate`, `bindAdaptiveChannel`) | dans le **serveur**, au transport (`decideSend`) |
104
+ | **Ce qu'il mesure** | l'écart entre deux trames reçues | la file d'envoi `bufferedAmount` de la socket |
105
+ | **Ce qu'il fait** | se réabonne à un canal plus lent ou plus rapide | envoie, jette la trame, ou ferme en `1013` |
106
+ | **Sa portée** | **un canal d'état** chez un client | **toutes les trames** d'une connexion |
107
+ | **Quand il agit** | avant la saturation : il prévient | à la saturation : il protège |
108
+ | **Activé par défaut** | non : chaque écran l'active (hook ou option) | oui : 4 Mio, refus, fermeture après un solde de 1000 |
109
+ | **Ce qu'il garantit** | une cadence soutenable pour **ce** client | une mémoire bornée pour **le serveur** |
110
+
111
+ **Le point qui fait comprendre tout le reste** : la contre-pression **jette** des trames, donc elle
112
+ **allonge** les écarts que le client mesure ; le client y voit une famine et **ralentit** ; le
113
+ serveur produit moins pour lui ; la file se vide. Personne n'a programmé ce dialogue : il découle de
114
+ la mesure. Voir [Comment les deux étages se rencontrent](#-comment-les-deux-étages-se-rencontrent).
115
+
116
+ > [!NOTE]
117
+ > Ni l'un ni l'autre n'appartient à JSON-RPC. Le protocole transporte des requêtes, des réponses et
118
+ > des notifications ; la notion de **canal**, de **cadence** et de **file** est propre à Nodefony.
119
+ > Le standard JSON-RPC 2.0 n'en dit rien, et Nodefony n'y ajoute aucun champ.
120
+
121
+ ## 🚀 Démarrage rapide
122
+
123
+ **Le besoin vécu** : ton tableau de bord affiche la charge du serveur, publiée chaque seconde. Sur
124
+ le réseau d'un client en déplacement, l'écran prend du retard et la mémoire du serveur grossit.
125
+ Tu veux que **chaque écran** tienne la cadence qu'il peut, et que **le serveur** ne retienne jamais
126
+ un client mort.
127
+
128
+ ### Le contrôleur — fichier créé
129
+
130
+ Le serveur publie **à la cadence que le nom du canal demande**, bornée pour se protéger. Le
131
+ décorateur `@RealtimeChannel` ne reconnaît que des noms **exacts** : une famille cadencée
132
+ (`atelier:load`, `atelier:load:2000`, …) passe donc par la surcharge de `createRealtimeChannel()`,
133
+ la voie que le framework prévoit pour elle.
134
+
135
+ ```ts
136
+ // modules/atelier/nodefony/controllers/AtelierController.ts
137
+ import { performance } from "node:perf_hooks";
138
+ import { controller, route } from "@nodefony/framework";
139
+ import { RealtimeController } from "@nodefony/realtime";
140
+ import type { RealtimePublish } from "@nodefony/realtime";
141
+ import type { ContextType } from "@nodefony/http";
142
+ import { isRateChannel, parseRate } from "nodefony";
143
+
144
+ /** Le canal de base, partagé avec le client. */
145
+ export const ATELIER_LOAD = "atelier:load";
146
+
147
+ /** Le serveur garde la main : jamais plus vite que 250 ms, jamais plus lent que 60 s. */
148
+ const BOUNDS = { default: 1000, min: 250, max: 60000 };
149
+
150
+ @controller("/atelier")
151
+ class AtelierController extends RealtimeController {
152
+ constructor(context: ContextType) {
153
+ super("atelier", context);
154
+ }
155
+
156
+ @route("atelier-realtime", {
157
+ path: "/realtime",
158
+ requirements: { methods: ["WEBSOCKET"] },
159
+ })
160
+ async realtime(message: string | Buffer | null): Promise<void> {
161
+ this.handleRealtime(message);
162
+ }
163
+
164
+ /**
165
+ * Sert `atelier:load` ET ses variantes `atelier:load:<ms>`.
166
+ *
167
+ * Le hub appelle cette fabrique au PREMIER abonné d'un canal et garde sa
168
+ * fonction de nettoyage pour le DERNIER : un seul ticker par cadence, partagé
169
+ * par tous ses abonnés. Il survit à la connexion qui l'a créé — il ne doit
170
+ * donc rien capturer de `this.context`.
171
+ */
172
+ override createRealtimeChannel(
173
+ channel: string,
174
+ publish: RealtimePublish,
175
+ ): (() => void) | null {
176
+ if (!isRateChannel(channel, ATELIER_LOAD)) return null;
177
+ const ms = parseRate(channel, ATELIER_LOAD, BOUNDS);
178
+ // Utilisation de la boucle d'événements depuis le tick précédent, de 0 à 1 :
179
+ // une mesure que Node tient déjà, identique sur Linux, macOS et Windows.
180
+ let since = performance.eventLoopUtilization();
181
+ const timer = setInterval(() => {
182
+ const delta = performance.eventLoopUtilization(since);
183
+ since = performance.eventLoopUtilization();
184
+ publish(channel, { ts: Date.now(), load: delta.utilization });
185
+ }, ms);
186
+ timer.unref(); // le ticker ne retient pas l'arrêt du processus
187
+ return () => clearInterval(timer);
188
+ }
189
+ }
190
+
191
+ export default AtelierController;
192
+ ```
193
+
194
+ ### Le client — fichier créé
195
+
196
+ Le hook s'abonne à 1 s et **laisse l'AIMD** reculer ou remonter. Il rend la cadence effective, à
197
+ afficher en badge.
198
+
199
+ ```ts
200
+ // frontend/src/useAtelierLoad.ts
201
+ import { useNodefonyAdaptiveChannelData } from "nodefony/react";
202
+
203
+ export interface AtelierLoad {
204
+ ts: number;
205
+ load: number;
206
+ }
207
+
208
+ /** La charge de l'atelier, à la cadence que CE client peut tenir. */
209
+ export function useAtelierLoad(): { load: number | null; everyMs: number } {
210
+ const { data, intervalMs } = useNodefonyAdaptiveChannelData<AtelierLoad>(
211
+ "atelier:load",
212
+ 1000,
213
+ );
214
+ return { load: data?.load ?? null, everyMs: intervalMs };
215
+ }
216
+ ```
217
+
218
+ ### La configuration — fichier modifié
219
+
220
+ Deux réglages, et le second est celui qu'on oublie :
221
+
222
+ - la **contre-pression** est déjà active avec ses défauts ; on ne la règle que pour la serrer, et
223
+ sur **les deux** serveurs WebSocket ;
224
+ - la **garde du canal** se pose par **préfixe** : une règle sur `atelier:` couvre `atelier:load` et
225
+ toutes ses cadences. Une politique posée sur le nom exact `atelier:load` laisserait
226
+ `atelier:load:2000` ouvert à n'importe qui.
227
+
228
+ ```ts
229
+ // nodefony/config/http.ts — le fragment du module, importé par le manifeste
230
+ import type { IHttpConfigInput } from "@nodefony/http";
231
+
232
+ export const httpConfig = () =>
233
+ ({
234
+ websocket: { maxBackpressure: 1048576, backpressureCloseAfterDrops: 200 },
235
+ websocketSecure: {
236
+ maxBackpressure: 1048576,
237
+ backpressureCloseAfterDrops: 200,
238
+ },
239
+ }) satisfies IHttpConfigInput;
240
+ ```
241
+
242
+ ```ts ignore
243
+ // nodefony.config.ts — extrait
244
+ import { defineConfig, use } from "nodefony";
245
+ import { httpConfig } from "./nodefony/config/http";
246
+
247
+ export default defineConfig(() => ({
248
+ modules: [
249
+ use("@nodefony/http", httpConfig()),
250
+ "@nodefony/framework",
251
+ use("@nodefony/security", {
252
+ // Préfixe : garde atelier:load ET atelier:load:2000, atelier:load:4000…
253
+ realtimeChannels: [{ pattern: "atelier:", authenticated: true }],
254
+ }),
255
+ use("@nodefony/realtime", { backplane: { driver: "loopback" } }),
256
+ "@mon-app/atelier",
257
+ ],
258
+ }));
259
+ ```
260
+
261
+ ### Ce qu'on observe
262
+
263
+ | Situation | Ce qui se passe |
264
+ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
265
+ | Réseau sain | `everyMs` reste à `1000` ; le client est abonné à `atelier:load` (le défaut ne porte pas de suffixe). |
266
+ | Réseau dégradé (trames toutes les 4–5 s) | `everyMs` passe à `2000` puis `4000` ; le client est abonné à `atelier:load:4000`. |
267
+ | Retour à la normale | `everyMs` redescend d'un cran tous les 4 échantillons sains : `2000`, puis `1000`. |
268
+ | Client qui ne lit plus du tout | file à 1 Mio, trames refusées, puis fermeture `1013` après un solde de 200 ; reconnexion automatique. |
269
+ | Deux écrans à la même cadence | **un seul** ticker serveur : le canal `atelier:load:2000` est partagé et compté par référence. |
270
+ | Un client anonyme demande `atelier:load:250` | refusé : la règle de préfixe exige une identité, quelle que soit la cadence demandée. |
271
+
272
+ ```bash
273
+ # La sonde du hub, sans WebSocket : clients lents et refus cumulés.
274
+ curl -sk https://127.0.0.1:5152/nodefony/realtime/api/health | jq '.backpressure'
275
+ ```
276
+
277
+ ## 🏗️ Étage 1 — la cadence adaptative (AIMD)
278
+
279
+ ### La cadence vit dans le nom du canal
280
+
281
+ Un canal d'état cadencé s'appelle `base` (cadence par défaut) ou `base:<ms>` (cadence explicite).
282
+ Le client fabrique le nom avec `rateChannel()` (`channelRate.ts#rateChannel`), le serveur le relit et le
283
+ **borne** avec `parseRate()` (`channelRate.ts#parseRate`). Une même fonction pour les deux bords : la
284
+ convention ne peut pas diverger.
285
+
286
+ Conséquences voulues :
287
+
288
+ - **1 canal = 1 cadence = 1 ticker.** Deux clients à 2 s partagent `base:2000` ; un troisième à 4 s
289
+ ouvre `base:4000`. Le hub compte les abonnés et arrête le ticker au dernier.
290
+ - **Le serveur garde la main.** Un client qui demande `base:10` obtient la borne basse : Studio
291
+ borne ses statistiques à 250 ms minimum (`RATE_BOUNDS`, `StudioRealtimeController.ts#RATE_BOUNDS`).
292
+ - **Aucun message de contrôle** : changer de cadence, c'est changer d'abonnement. Le protocole
293
+ reste celui des canaux, rien de plus.
294
+
295
+ > [!WARNING]
296
+ > **Une politique de canal posée sur le nom exact ne garde pas ses cadences.** Le registre des
297
+ > politiques s'indexe par nom : protéger `atelier:load` laisse `atelier:load:2000` ouvert, et le hub
298
+ > le signale par un avertissement. Garder une famille cadencée, c'est poser une règle par
299
+ > **préfixe** (`realtimeChannels`, configuration de `@nodefony/security`) ou par motif
300
+ > (`atelier:load:*`).
301
+
302
+ ### Ce que le client mesure
303
+
304
+ À chaque trame reçue, `AdaptiveRate.noteFrame()` (`AdaptiveRate.ts#AdaptiveRate.noteFrame`) compare l'écart depuis
305
+ la trame précédente à la cadence courante. Trois zones, et une seule décision par trame :
306
+
307
+ ```mermaid
308
+ flowchart LR
309
+ E{"Écart entre<br/>deux trames ?"} -->|"> 1,8 × cadence"| FAM["Famine<br/>cadence × 2"]
310
+ E -->|"1,25 à 1,8 ×"| BM["Bande morte<br/>rien ne bouge"]
311
+ E -->|"≤ 1,25 ×"| S["Sain"]
312
+ S -->|"4 fois de suite"| UP["Cadence ÷ 2"]
313
+ ```
314
+
315
+ | Zone | Condition (défauts) | Décision |
316
+ | --------------- | ------------------------ | -------------------------------------------------------------------- |
317
+ | **Famine** | écart > `1,8` × cadence | **un cran plus lent, tout de suite** (_Multiplicative Decrease_) |
318
+ | **Sain** | écart ≤ `1,25` × cadence | compte un bon échantillon ; au **4ᵉ d'affilée**, un cran plus rapide |
319
+ | **Bande morte** | entre les deux | ne change rien, et **remet à zéro** le compte des bons échantillons |
320
+
321
+ Les seuils et la fenêtre sont des options (`starvationFactor`, `healthyFactor`, `recoveryWindow`),
322
+ avec ces défauts (`AdaptiveRate.ts#AdaptiveRate.constructor`).
323
+
324
+ > [!TIP]
325
+ > La **bande morte** est ce qui empêche l'oscillation : un réseau « moyen » ne fait ni monter ni
326
+ > descendre la cadence. Sans elle, un écart de 1,5 fois la cadence ferait basculer l'écran toutes les
327
+ > quelques secondes.
328
+
329
+ ### Ralentir vite, accélérer prudemment
330
+
331
+ - **L'échelle** est géométrique : de la cadence désirée, doublée jusqu'à 60 s par défaut — `1 s,
332
+ 2 s, 4 s, 8 s, …, 60 s` (`deriveLadder()`, `AdaptiveRate.ts#deriveLadder`). Une échelle sur mesure se passe
333
+ par l'option `ladder`.
334
+ - **Ralentir** = monter d'un cran (×2) **dès la première famine**. Plusieurs famines successives
335
+ doublent plusieurs fois : c'est ce qui rend la décroissance multiplicative.
336
+ - **Accélérer** = descendre d'**un seul** cran après **quatre** écarts sains consécutifs. La reprise
337
+ est lente par construction.
338
+ - **Après chaque changement**, la mesure repart de zéro : la première trame du nouveau canal ne sert
339
+ que de référence.
340
+ - **Famine totale** : si plus aucune trame n'arrive, `noteFrame()` ne serait jamais appelé. Un chien
341
+ de garde, réarmé à chaque cadence, appelle `checkStarvation()` (`AdaptiveRate.ts#AdaptiveRate.checkStarvation`) et ralentit
342
+ quand même.
343
+
344
+ ### Changer de cadence sans trou
345
+
346
+ **L'image** : on change de voie sur l'autoroute en s'insérant dans la nouvelle **avant** de quitter
347
+ l'ancienne, jamais l'inverse. `bindAdaptiveChannel()` (`AdaptiveRate.ts#bindAdaptiveChannel`) fait pareil : il
348
+ **s'abonne au nouveau canal, puis quitte l'ancien**, dans le même geste (`applyDecision()`,
349
+ `AdaptiveRate.ts#bindAdaptiveChannel`).
350
+
351
+ ```mermaid
352
+ flowchart LR
353
+ A["Écoute<br/>atelier:load<br/>1 trame / s"] -->|"famine"| B["1. s'abonne à<br/>atelier:load:2000<br/>2. quitte atelier:load"]
354
+ B --> C["Écoute<br/>atelier:load:2000<br/>1 trame / 2 s"]
355
+ ```
356
+
357
+ Le gestionnaire de l'écran reçoit les trames des deux canaux sans interruption : la page ne voit
358
+ qu'un flux dont la cadence change.
359
+
360
+ ### La courbe — le vrai code, sur un scénario
361
+
362
+ Le scénario : cadence désirée 1 s ; **de 20 s à 70 s**, le client met 4,5 s à absorber chaque trame
363
+ (réseau saturé), puis redevient rapide. La courbe est **calculée** en faisant tourner
364
+ `AdaptiveRate` lui-même ; un test la rejoue et échoue si le code ne la produit plus (voir
365
+ [Tests](#-tests)).
366
+
367
+ ```mermaid
368
+ %%{init: {"xyChart": {"width": 800, "height": 260}, "themeVariables": {"xyChart": {"plotColorPalette": "#0072B2, #D55E00"}}}}%%
369
+ xychart-beta
370
+ title "Cadence AIMD — dégradation de 20 s à 70 s"
371
+ x-axis "temps (s)" 0 --> 125
372
+ y-axis "cadence (ms)" 0 --> 5000
373
+ line [1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000]
374
+ ```
375
+
376
+ Ce que la courbe raconte, décision par décision :
377
+
378
+ | Instant | Décision | Pourquoi |
379
+ | ------- | ------------- | ------------------------------------------------------------------------------------------------- |
380
+ | 24 s | 1 s → **2 s** | premier écart mesuré de 4,5 s, au-delà de 1,8 s : famine |
381
+ | 33 s | 2 s → **4 s** | 4,5 s dépasse encore 3,6 s (1,8 × 2 s) : famine |
382
+ | 55 s | 4 s → **2 s** | à 4 s, l'écart (4,5 s) est sain (≤ 5 s) : après 4 bons échantillons, l'AIMD **tente** de remonter |
383
+ | 64 s | 2 s → **4 s** | la tentative échoue : famine, retour au cran sûr |
384
+ | 84 s | 4 s → **2 s** | la dégradation est finie depuis 70 s : 4 écarts sains |
385
+ | 94 s | 2 s → **1 s** | 4 écarts sains de plus : retour à la cadence désirée |
386
+
387
+ La tentative de 55 s n'est pas un défaut : c'est **ainsi** qu'AIMD découvre que le réseau s'est
388
+ rétabli. Il sonde prudemment, recule s'il s'est trompé, et ne le paie que de quelques secondes.
389
+
390
+ ### Réservé aux canaux d'état
391
+
392
+ > [!WARNING]
393
+ > **N'active jamais la cadence adaptative sur un canal d'événements.** Ralentir un canal d'état
394
+ > perd des valeurs **intermédiaires** sans conséquence : la température de 10 h 00 min 03 s est
395
+ > remplacée par celle de 10 h 00 min 04 s. Sur un flux d'événements (`ORDER_CREATED`,
396
+ > `PAYMENT_RECEIVED`, lignes de journal), chaque message compte : ces canaux se **regroupent**
397
+ > (le pont syslog de Studio coalesce), ils ne se **déciment** pas.
398
+
399
+ ## 🏗️ Étage 2 — la contre-pression du transport
400
+
401
+ ### La file d'envoi et le seuil
402
+
403
+ Quand le serveur envoie une trame, la socket l'accepte et la place dans sa **file d'envoi** :
404
+ `bufferedAmount` compte les octets qui attendent le réseau. Un client qui ne lit plus fait grossir
405
+ cette file sans fin. Nodefony la plafonne à **`maxBackpressure`**, 4 Mio par défaut
406
+ (`@nodefony/http/nodefony/config/config.ts#websocketSchema`).
407
+
408
+ La décision est prise **trame par trame**, par une seule fonction : `decideSend()`
409
+ (`wsBackpressure.ts#decideSend`). Elle sert à la fois le `send()`/`broadcast()` d'un contexte WebSocket de
410
+ `@nodefony/http` et chaque connexion temps réel (`WsConnectionTransport.send()`,
411
+ `WsConnectionTransport.ts#WsConnectionTransport.send`). Une seule règle, deux usages.
412
+
413
+ ```mermaid
414
+ flowchart LR
415
+ F["Trame<br/>à envoyer"] --> Q{"File d'envoi<br/>> 4 Mio ?"}
416
+ Q -->|"non"| S["Envoyée<br/>solde − 1"]
417
+ Q -->|"oui · close"| C1["Fermeture<br/>1013"]
418
+ Q -->|"oui · drop"| D["Jetée<br/>solde + 1"]
419
+ D -->|"solde = 1000"| C2["Fermeture<br/>1013"]
420
+ ```
421
+
422
+ Sous le seuil, le chemin est **nominal** : une lecture de `bufferedAmount`, aucune allocation. Le
423
+ coût n'apparaît que pour un client déjà en difficulté.
424
+
425
+ ### Le solde de refus — pourquoi pas un second seuil, pourquoi pas une série
426
+
427
+ Deux idées naturelles échouent, et le code dit pourquoi :
428
+
429
+ - **Un second seuil d'octets** (« fermer à 8 Mio ») serait **inatteignable** : une fois qu'on jette,
430
+ plus rien n'alimente la file, qui plafonne au premier seuil. Mesuré : 4000 trames poussées à un
431
+ client qui ne lit pas, 3 servies, aucune fermeture.
432
+ - **Une série de refus consécutifs**, remise à zéro au premier envoi, ne fermerait **jamais** un
433
+ client bloqué : sa file **oscille** autour du seuil (refus, un peu de vidage, un envoi, refus…),
434
+ et chaque envoi effacerait la série.
435
+
436
+ D'où le **solde** : **+1 par refus, −1 par envoi**. Un pic passager redescend tout seul ; un client
437
+ qui refuse plus qu'il n'accepte monte jusqu'au seuil de fermeture. C'est `_nfDropStreak`, tenu sur
438
+ la socket elle-même.
439
+
440
+ ### Les courbes — le vrai code, sur deux clients
441
+
442
+ Le scénario : le serveur pousse **100 trames de 32 Kio par seconde** ; le client vide sa file à
443
+ 8 Mo/s, puis **tombe à 1 Mo/s** à partir de 2 s. Défauts de production : seuil 4 Mio, refus,
444
+ fermeture à un solde de 1000. Les valeurs sont calculées par `decideSend()` lui-même.
445
+
446
+ **La file d'envoi plafonne au seuil** — la contre-pression tient la mémoire, quelle que soit la
447
+ durée :
448
+
449
+ ```mermaid
450
+ %%{init: {"xyChart": {"width": 800, "height": 260}, "themeVariables": {"xyChart": {"plotColorPalette": "#0072B2, #D55E00"}}}}%%
451
+ xychart-beta
452
+ title "File d'envoi d'un client qui ne suit plus (seuil 4 Mio)"
453
+ x-axis "temps (s)" [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29]
454
+ y-axis "file d'envoi (Mio)" 0 --> 5
455
+ line [0.03, 0.03, 0.05, 2.22, 4.02, 4, 4.02, 4, 4.02, 4, 4.02, 4, 4.02, 4, 4.02, 4.03, 4.01, 4.03, 4.01, 4.03, 4.01, 4.03, 4.01, 4.03, 4.01, 4.02, 4.01, 4.02, 4.01, 4.02]
456
+ ```
457
+
458
+ **Le solde distingue un client mort d'un pic passager.** Deux clients, même début : ils ralentissent
459
+ à 2 s. Le premier ne se rétablit jamais, le second se rétablit à 9 s.
460
+
461
+ - **Client mort (bleu)** : le solde monte d'environ 39 par seconde, l'écart entre les refus et les
462
+ envois. Il atteint 1000 à **29,5 s** : fermeture `1013`, après 1781 trames refusées et 1166
463
+ envoyées.
464
+ - **Pic passager (orange)** : le solde monte jusqu'à 201, puis redescend d'une unité par envoi,
465
+ donc très vite : à 12 s il est revenu à 0. Aucune fermeture.
466
+
467
+ ```mermaid
468
+ %%{init: {"xyChart": {"width": 800, "height": 260}, "themeVariables": {"xyChart": {"plotColorPalette": "#0072B2, #D55E00"}}}}%%
469
+ xychart-beta
470
+ title "Solde de refus — client mort (bleu) et pic passager (orange)"
471
+ x-axis "temps (s)" [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29]
472
+ y-axis "solde de refus" 0 --> 1000
473
+ %% series: client mort | pic passager
474
+ line [0, 0, 0, 0, 7, 47, 85, 125, 163, 203, 241, 281, 319, 359, 397, 435, 475, 513, 553, 591, 631, 669, 709, 747, 787, 825, 865, 903, 943, 981]
475
+ line [0, 0, 0, 0, 7, 47, 85, 125, 163, 201, 101, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
476
+ ```
477
+
478
+ ### `drop` ou `close` — choisir en situation
479
+
480
+ | Le besoin vécu | La politique | Le comportement |
481
+ | --------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------ |
482
+ | Tableaux de bord, télémétrie, positions : un client lent doit **rester connecté** | `drop` (défaut) + `backpressureCloseAfterDrops` | il perd des trames pendant le pic, il est coupé seulement s'il ne récupère pas |
483
+ | Flux où **une trame perdue rend la suite fausse** (rejeu, synchronisation incrémentale) | `close` | coupé **au premier dépassement** ; il se reconnecte et resynchronise de zéro |
484
+ | Ne **jamais** couper (environnement maîtrisé, débogage) | `drop` + `backpressureCloseAfterDrops: 0` | la file reste bornée, la connexion ne ferme jamais |
485
+
486
+ > [!WARNING]
487
+ > **`drop` ne trie pas les trames.** Au-delà du seuil, `decideSend()` jette **la trame qui se
488
+ > présente**, quelle qu'elle soit : valeur d'un canal d'état, événement métier, ou **réponse à une
489
+ > requête RPC**. Une requête dont la réponse a été jetée **expire côté client**. La contre-pression
490
+ > protège le serveur ; elle ne garantit la livraison de rien. Un flux où chaque message compte a
491
+ > besoin d'un accusé de réception et d'une reprise applicatifs, ou de la politique `close`.
492
+
493
+ ### Ce que voit le client
494
+
495
+ Le client **n'apprend jamais** les seuils : il ne reçoit que leur effet.
496
+
497
+ - Une trame jetée **n'existe pas** pour lui : il voit seulement un écart plus long entre deux
498
+ trames.
499
+ - Une fermeture arrive avec le code `1013`. Il le classe **transitoire** : ce n'est pas un des codes
500
+ définitifs (`FATAL_CLOSE_CODES`, `notice.ts#FATAL_CLOSE_CODES`), donc `isReconnectableCloseCode()`
501
+ (`notice.ts#isReconnectableCloseCode`) répond oui, et il se reconnecte avec une attente croissante.
502
+
503
+ ## 🔌 Comment les deux étages se rencontrent
504
+
505
+ Aucune ligne de code ne relie la contre-pression du serveur à la cadence du client. Ils se
506
+ rencontrent par la **mesure** :
507
+
508
+ ```mermaid
509
+ flowchart LR
510
+ A["Serveur<br/>trame jetée"] --> B["Client<br/>attend 2 s<br/>au lieu d'1"]
511
+ B --> C["AIMD<br/>famine"]
512
+ C --> D["Réabonné<br/>à 2 s"]
513
+ D --> E["Moitié moins<br/>de trames :<br/>la file se vide"]
514
+ ```
515
+
516
+ Une seule trame jetée sur un canal cadencé suffit à doubler l'écart mesuré, donc à faire ralentir le
517
+ client. **La contre-pression envoie un signal au client sans lui envoyer un seul octet.**
518
+
519
+ ## 📡 Les sondes — voir le mécanisme en production
520
+
521
+ Les sondes **observent**, elles ne décident rien. Elles répondent à « mes clients suivent-ils ? »
522
+ sans activer de trace.
523
+
524
+ ### Ce que la sonde du hub expose
525
+
526
+ `RealtimeHub.probe()` (`RealtimeHub.ts#RealtimeHub.probe`) parcourt les connexions et rend un bloc
527
+ `backpressure` :
528
+
529
+ | Champ | Ce qu'il dit | Lecture |
530
+ | --------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------- |
531
+ | `maxBufferedAmount` | la **pire** file d'envoi, en octets | proche de `maxBackpressure` : un client est au plafond |
532
+ | `totalBufferedAmount` | la somme des files du processus | la mémoire d'envoi réellement retenue |
533
+ | `slowConsumers` | les connexions dont la file atteint `slowConsumer.bytes` (1 Mio par défaut) | non nul = au moins un client prend du retard |
534
+ | `drops` | les trames refusées, cumulées | qui **augmente** = la contre-pression agit en ce moment |
535
+
536
+ > [!IMPORTANT]
537
+ > **`slowConsumer.bytes` compte, il ne freine pas.** C'est le réglage de la **sonde**
538
+ > (`@nodefony/realtime`), pas de la contre-pression (`@nodefony/http`). Le baisser fait apparaître
539
+ > plus de clients lents dans les écrans, sans en couper aucun.
540
+
541
+ ### Où la lire
542
+
543
+ - **Par HTTP**, sans WebSocket : `GET /nodefony/realtime/api/health`, rôle d'administration requis.
544
+ - **En flux**, sur le canal de santé `nodefony:socket` ; il est lui-même cadencé
545
+ (`nodefony:socket:<ms>`).
546
+ - **Dans Studio** : l'écran **Cluster** affiche les clients lents et la pire file, par instance.
547
+ - **Côté client** : la cadence effective est rendue par le hook (`intervalMs`), et Studio permet de
548
+ basculer la cadence adaptative de ses tableaux de bord depuis le panneau temps réel (désactivée par
549
+ défaut).
550
+
551
+ Le détail champ par champ et le flux de santé : [Observabilité de la socket](observabilite.md#back-pressure--le-risque-numéro-un).
552
+
553
+ ## ⚙️ Configuration
554
+
555
+ | Réglage | Paquet | Défaut | Effet |
556
+ | ----------------------------------------------------- | -------------------- | -------------- | -------------------------------------------------------------------- |
557
+ | `maxBackpressure` | `@nodefony/http` | 4 Mio | file d'envoi au-delà de laquelle la politique s'applique ; 0 = coupé |
558
+ | `backpressurePolicy` | `@nodefony/http` | `drop` | `drop` jette la trame ; `close` ferme au premier dépassement |
559
+ | `backpressureCloseAfterDrops` | `@nodefony/http` | 1000 | solde de refus qui déclenche la fermeture `1013` ; 0 = jamais |
560
+ | `slowConsumer.bytes` | `@nodefony/realtime` | 1 Mio | seuil de **comptage** de la sonde ; n'agit sur rien |
561
+ | `intervalMs` (hook / client) | `nodefony` | — | cadence **désirée** ; l'AIMD ne descend jamais en dessous |
562
+ | `ladder`, `maxMs` | `nodefony` | ×2, 60 s | l'échelle des cadences possibles |
563
+ | `starvationFactor`, `healthyFactor`, `recoveryWindow` | `nodefony` | 1,8 · 1,25 · 4 | les seuils de famine, de santé et la fenêtre de reprise |
564
+ | `enabled` | `nodefony` | `true` | `false` = abonnement fixe, sans mesure ni chien de garde |
565
+
566
+ > [!IMPORTANT]
567
+ > **Deux serveurs, deux sections** : `websocket` (`ws://`) et `websocketSecure` (`wss://`). Régler
568
+ > l'un ne règle pas l'autre. Une application servie en `wss` qui ne règle que `websocket` garde les
569
+ > défauts, sans que rien ne le signale.
570
+
571
+ La table complète des réglages du module et leurs schémas : [Configuration](configuration.md).
572
+
573
+ ## 📜 Normes appliquées
574
+
575
+ | Norme | Ce qu'elle dit | Ce que fait Nodefony |
576
+ | ---------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
577
+ | RFC 6455 §7.4 — codes de fermeture | une fermeture porte un code ; la plage 1000-2999 est réservée et enregistrée à l'IANA | ferme en `1013` (`backpressure`) |
578
+ | Registre IANA des codes de fermeture WebSocket | `1013` = _Try Again Later_ : condition temporaire du serveur | le client le traite comme transitoire et se reconnecte |
579
+ | RFC 5681 — contrôle de congestion TCP | augmentation additive, diminution multiplicative | **inspiration** de l'AIMD de cadence, appliquée à des abonnements et non à des octets |
580
+ | JSON-RPC 2.0 | requêtes, réponses, notifications | aucune notion de cadence ni de file : tout ce mécanisme est propre à Nodefony |
581
+
582
+ ## ⚠️ Pièges
583
+
584
+ | Symptôme | Cause | Correction |
585
+ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
586
+ | Des événements métier manquent chez un client lent | la politique `drop` jette toute trame au-delà du seuil, événements compris | accusé de réception et reprise applicatifs, ou politique `close` pour ce flux |
587
+ | Une requête RPC expire sans erreur serveur | sa réponse a été jetée par la contre-pression | surveiller `drops` dans la sonde ; serrer la cadence des canaux d'état de ce client |
588
+ | `atelier:load` est gardé, `atelier:load:2000` répond à un anonyme | la politique vise le nom exact ; les variantes cadencées n'en héritent pas | règle par préfixe dans `realtimeChannels` (`{ pattern: "atelier:", authenticated: true }`) |
589
+ | La cadence adaptative ne bouge jamais | le serveur ne sert que le nom exact (`@RealtimeChannel`), pas `base:<ms>` | surcharger `createRealtimeChannel()` avec `isRateChannel()` et `parseRate()` |
590
+ | Un client demande 100 ms et reçoit autre chose | `parseRate()` borne la cadence demandée | c'est voulu (anti-déni de service) : ajuster les bornes du contrôleur |
591
+ | Le réglage de contre-pression semble ignoré | seule la section `websocket` est réglée, l'application est servie en `wss` | régler aussi `websocketSecure` |
592
+ | `slowConsumers` est non nul mais personne n'est coupé | `slowConsumer.bytes` (1 Mio) est un seuil de comptage, sous `maxBackpressure` (4 Mio) | normal : la sonde prévient avant que la contre-pression agisse |
593
+ | Un client est coupé au premier ralentissement | `backpressurePolicy: "close"` ferme dès le premier dépassement | revenir à `drop` avec un solde de fermeture |
594
+ | L'écran oscille entre deux cadences | réseau à la limite : l'AIMD sonde, recule, sonde | normal et borné ; élargir `recoveryWindow` pour sonder moins souvent |
595
+ | La cadence adaptative est activée sur un journal | un canal d'événements a été déclaré adaptatif | revenir à un abonnement fixe ; regrouper les messages côté serveur |
596
+
597
+ ## 🧪 Tests
598
+
599
+ | Suite | Ce qui est prouvé |
600
+ | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
601
+ | `AdaptiveRate.test.ts` (`nodefony`) | la machine pure : échelle ×2, première trame sans décision, famine immédiate, reprise après N bons échantillons, chien de garde |
602
+ | `channelRate.test.ts` (`nodefony`) | le nom cadencé : canal nu pour le défaut, suffixe sinon, bornage côté serveur |
603
+ | `RealtimeNotice.test.ts` (`nodefony`) | `1013` fait partie des fermetures transitoires qui déclenchent la reconnexion |
604
+ | `wsBackpressure.test.ts` (`@nodefony/http`) | `decideSend()` : désactivé à 0, nominal sous le seuil, `drop` sans fermer, `close` en `1013` |
605
+ | `backpressureConfig.test.ts` (`@nodefony/realtime`) | le transport temps réel applique la règle de `@nodefony/http` : refus compté, fermeture après N, **le solde décroît** sur un pic passager |
606
+ | `WsConnectionTransport.test.ts` (`@nodefony/realtime`) | le transport : envoi seulement sur socket ouverte, fermeture déléguée |
607
+ | `cadencePressureFigures.test.ts` (`@nodefony/realtime`) | **les courbes de cette page** : rejoue les trois scénarios avec le vrai code et compare chaque valeur |
608
+
609
+ Deux bancs complètent, hors des suites (skill `nodefony-load-test`) :
610
+
611
+ - `ws-backpressure-e2e.mjs` — la contre-pression sur une **vraie socket**, file remplie par le
612
+ noyau, mesure côté serveur ;
613
+ - `aimd-demo.mjs` — la cadence adaptative, lisible pas à pas, sur une horloge contrôlée.
614
+
615
+ **Ce qui manque** : aucun test ne fait jouer **ensemble** un vrai serveur qui jette et un vrai client
616
+ qui ralentit. La rencontre des deux étages décrite plus haut découle de leur conception, elle n'est
617
+ pas éprouvée de bout en bout.
618
+
619
+ ```bash
620
+ cd src/nodefony && npx vitest run src/tests/AdaptiveRate.test.ts src/tests/channelRate.test.ts
621
+ cd src/packages/@nodefony/realtime && npm test
622
+ cd src/packages/@nodefony/http && npx vitest run nodefony/tests/unit/wsBackpressure.test.ts
623
+ ```
624
+
625
+ ## Ce qu'il faut retenir
626
+
627
+ 1. **La cadence adaptative est décidée par le client**, canal d'état par canal d'état, en mesurant
628
+ l'écart entre trames ; elle se traduit par un **réabonnement** à `base:<ms>`.
629
+ 2. **La contre-pression est décidée par le serveur**, trame par trame, sur la file d'envoi ; elle
630
+ jette au-delà de 4 Mio et ferme en `1013` quand le **solde** de refus atteint 1000.
631
+ 3. **Les deux se rencontrent par la mesure** : une trame jetée allonge un écart, et le client
632
+ ralentit.
633
+ 4. **Les sondes comptent** (clients lents, refus) **sans rien décider**.
634
+ 5. **Rien de tout cela ne garantit la livraison** : un flux où chaque message compte a besoin de sa
635
+ propre reprise.
636
+
637
+ ## 🔗 Pour aller plus loin
638
+
639
+ - ⬆️ **Retour au hub** : [@nodefony/realtime — la socket Nodefony](index.md) · [Toute la documentation](../../../../../docs/index.md)
640
+ - [Observabilité de la socket](observabilite.md) — la sonde champ par champ, les canaux de santé, les écrans
641
+ - [Configuration](configuration.md) — la table complète des réglages, dont la contre-pression
642
+ - [Protocole](protocole.md) — le format des trames et les codes d'erreur
643
+ - [Vocabulaire](vocabulaire.md) — la socket, le hub, le canal, le fan-out