@nodefony/realtime 10.0.0-beta.1 → 10.0.0-beta.3

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 (45) hide show
  1. package/README.md +2 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +17 -10
  5. package/dist/nodefony/config/config.js +6 -3
  6. package/dist/nodefony/config/configSchema.js +19 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +42 -15
  8. package/dist/nodefony/decorators/realtimeDecorators.js +68 -17
  9. package/dist/nodefony/src/backplane/RedisBackplane.js +21 -4
  10. package/dist/nodefony/src/backplane/bootNotices.js +43 -0
  11. package/dist/nodefony/src/backplane/publishQueue.js +1 -4
  12. package/dist/nodefony/src/cluster/ClusterProbeClient.js +15 -4
  13. package/dist/nodefony/src/guards.js +41 -0
  14. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +1 -4
  15. package/dist/nodefony/src/server/RealtimeController.js +120 -50
  16. package/dist/nodefony/src/server/RealtimeHub.js +29 -51
  17. package/dist/nodefony/src/server/ServerRealtimeSocket.js +13 -5
  18. package/dist/nodefony/src/server/syslogUplink.js +4 -2
  19. package/dist/nodefony/src/service/RealtimeService.js +3 -2
  20. package/dist/nodefony/testing/index.js +41 -1
  21. package/dist/types/nodefony/config/configSchema.d.ts +12 -0
  22. package/dist/types/nodefony/config/defineModuleConfig.d.ts +29 -6
  23. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +19 -2
  24. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +1 -1
  25. package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
  26. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +7 -0
  27. package/dist/types/nodefony/src/backplane/bootNotices.d.ts +25 -0
  28. package/dist/types/nodefony/src/guards.d.ts +32 -0
  29. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +13 -0
  30. package/dist/types/nodefony/src/server/RealtimeController.d.ts +24 -4
  31. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +22 -16
  32. package/dist/types/nodefony/src/service/RealtimeService.d.ts +1 -1
  33. package/dist/types/nodefony/testing/index.d.ts +2 -2
  34. package/docs/actions.md +124 -41
  35. package/docs/architecture.md +93 -91
  36. package/docs/cadence-et-contre-pression.md +634 -0
  37. package/docs/configuration.md +66 -56
  38. package/docs/cookbook-chat.md +39 -33
  39. package/docs/index.md +47 -31
  40. package/docs/observabilite.md +21 -18
  41. package/docs/protocole.md +79 -65
  42. package/docs/securite.md +69 -65
  43. package/docs/vocabulaire.md +80 -79
  44. package/package.json +16 -12
  45. package/docs/couverture-bancs.md +0 -139
@@ -124,9 +124,9 @@ Nodefony étiquette avec **JSON-RPC 2.0**, une norme publique plutôt qu'un form
124
124
  | **notification** | non | non | pousser un événement, s'abonner, se désabonner |
125
125
 
126
126
  Le point remarquable : le **même** moteur de protocole tourne des deux côtés du fil. La
127
- classe `JsonRpcPeer` (`JsonRpcPeer.ts:271`) est du code isomorphe du cœur — le navigateur
128
- l'exécute dans `RealtimeClient`, le serveur l'instancie une fois par connexion dans
129
- `RealtimeController.onHandshake()` (`RealtimeController.ts:374`). Le serveur peut donc
127
+ classe `JsonRpcPeer` (`JsonRpcPeer.ts:311`) est du code isomorphe du cœur — le navigateur
128
+ l'exécute dans `NodefonySocket`, le serveur l'instancie une fois par connexion dans
129
+ `RealtimeController.onHandshake()` (`RealtimeController.ts:450`). Le serveur peut donc
130
130
  appeler le client, pas seulement l'inverse : c'est du vrai duplex, pas un aller-retour
131
131
  déguisé.
132
132
 
@@ -137,15 +137,15 @@ Trois partis pris distinguent cette pile d'un simple « serveur WebSocket ».
137
137
  **Un seul contrat de socket, des deux côtés.** Un service back qui pousse des événements
138
138
  écrit le même code qu'une page front : `publish` / `subscribe` / `on`. Côté serveur c'est
139
139
  `ServerRealtimeSocket` (`ServerRealtimeSocket.ts:43`), obtenu par `serverSocket()`
140
- (`ServerRealtimeSocket.ts:223`) ; côté navigateur, `RealtimeClient`. Une exception, assumée
141
- et explicite : `ServerRealtimeSocket.request()` (`ServerRealtimeSocket.ts:131`) **rejette
140
+ (`ServerRealtimeSocket.ts:243`) ; côté navigateur, `NodefonySocket`. Une exception, assumée
141
+ et explicite : `ServerRealtimeSocket.request()` (`ServerRealtimeSocket.ts:150`) **rejette
142
142
  toujours** — un handle posé sur le hub n'a pas d'interlocuteur unique, puisque le hub est
143
143
  multi-clients. Pour un appel serveur→client ciblé, on passe par la connexion :
144
- `RealtimeController.requestClient()` (`RealtimeController.ts:329`).
144
+ `RealtimeController.requestClient()` (`RealtimeController.ts:395`).
145
145
 
146
146
  **Un provider par canal, pas un par client.** Si mille onglets s'abonnent au même canal de
147
147
  santé, le calcul ne doit tourner qu'une fois. Le hub crée le producteur au **premier**
148
- abonné et le détruit au **dernier** (`RealtimeHub.subscribe()`, `RealtimeHub.ts:462`) : le
148
+ abonné et le détruit au **dernier** (`RealtimeHub.subscribe()`, `RealtimeHub.ts:464`) : le
149
149
  coût suit le nombre de canaux, pas le nombre de clients.
150
150
 
151
151
  **Le cluster est un détail de configuration.** Ni ton contrôleur ni ton client ne savent
@@ -154,7 +154,7 @@ chaîne de caractères. Passer de mono-process à multi-pods ne touche aucune li
154
154
 
155
155
  **Le compromis, dit franchement** : un canal **ne franchit PAS** la frontière du process
156
156
  tant que tu ne l'as pas déclaré. Le défaut est l'isolement (`RealtimeHub.publish()`,
157
- `RealtimeHub.ts:604`) — un choix de sûreté détaillé plus bas.
157
+ `RealtimeHub.ts:610`) — un choix de sûreté détaillé plus bas.
158
158
 
159
159
  ## 🚀 Démarrage rapide
160
160
 
@@ -260,7 +260,7 @@ export default ChatController;
260
260
 
261
261
  ```ts
262
262
  // frontend/src/chat.ts
263
- import { RealtimeClient } from "nodefony/client";
263
+ import { NodefonySocket } from "nodefony/client";
264
264
 
265
265
  interface ChatMessage {
266
266
  from: string;
@@ -271,7 +271,7 @@ interface ChatMessage {
271
271
  const scheme = location.protocol === "https:" ? "wss" : "ws";
272
272
  // `shared` = une seule socket par URL pour toute la page, même si dix
273
273
  // composants la demandent (les options ne comptent qu'à la création).
274
- const socket = RealtimeClient.shared({
274
+ const socket = NodefonySocket.shared({
275
275
  url: `${scheme}://${location.host}/chat/realtime`,
276
276
  });
277
277
 
@@ -291,7 +291,7 @@ export async function start(): Promise<void> {
291
291
  ### Ce qu'on observe
292
292
 
293
293
  Au démarrage du serveur, le module annonce son fond de panier — une ligne, toujours la
294
- même forme, quel que soit le driver (`src/packages/@nodefony/realtime/index.ts:303`) :
294
+ même forme, quel que soit le driver (`src/packages/@nodefony/realtime/index.ts:316`) :
295
295
 
296
296
  ```
297
297
  realtime backplane driver=loopback kind=local cross-pod=no (hub local)
@@ -322,9 +322,9 @@ La pile se lit de haut en bas ; chaque étage ne connaît que son voisin du dess
322
322
  | Étage | Qui | Sa seule responsabilité | Ce qu'il ignore |
323
323
  | ---------- | ------------------------------------------------------- | --------------------------------------- | ----------------------------- |
324
324
  | Applicatif | ton contrôleur, tes services | le métier : quoi publier, quoi accepter | tout le reste |
325
- | Protocole | `JsonRpcPeer` (`JsonRpcPeer.ts:271`) | étiqueter, corréler, refuser une frame | par où passent les octets |
325
+ | Protocole | `JsonRpcPeer` (`JsonRpcPeer.ts:311`) | étiqueter, corréler, refuser une frame | par où passent les octets |
326
326
  | Transport | `WsConnectionTransport` (`WsConnectionTransport.ts:46`) | déplacer des octets, mesurer la file | ce que veut dire un message |
327
- | Hub | `RealtimeHub` (`RealtimeHub.ts:213`) | table des abonnés locaux + fan-out | qu'il existe d'autres process |
327
+ | Hub | `RealtimeHub` (`RealtimeHub.ts:214`) | table des abonnés locaux + fan-out | qu'il existe d'autres process |
328
328
  | Backplane | `IBackplane` (`IBackplane.ts:107`) | porter un message aux autres process | ce qu'est un abonné |
329
329
 
330
330
  > [!NOTE]
@@ -338,11 +338,11 @@ La pile se lit de haut en bas ; chaque étage ne connaît que son voisin du dess
338
338
  dispatch des notifications, gestion des erreurs, `dispose()` propre. Deux points
339
339
  d'interception y sont posés :
340
340
 
341
- - `beforeDispatch` (`JsonRpcPeer.ts:192`) — un verrou **synchrone** appelé avant tout
341
+ - `beforeDispatch` (`JsonRpcPeer.ts:219`) — un verrou **synchrone** appelé avant tout
342
342
  traitement de frame. Il rend `true`/`false`. Un refus sur une requête produit
343
343
  `-32001 unauthorized` ; sur une notification, la frame est jetée
344
- (`JsonRpcPeer.ts:413`).
345
- - `onFrameAudit` (`JsonRpcPeer.ts:210`) — la trace des événements protocolaires notables
344
+ (`JsonRpcPeer.ts:449`).
345
+ - `onFrameAudit` (`JsonRpcPeer.ts:236`) — la trace des événements protocolaires notables
346
346
  (frame invalide, refusée, méthode inconnue, erreur interne).
347
347
 
348
348
  La contrainte de synchronisme n'est pas un oubli : un `await` par frame coûterait une
@@ -362,21 +362,23 @@ même façon, par le hook `onFinish` du contexte, qui déclenche `fireClose()`
362
362
  Ce transport porte aussi la **back-pressure**. Elle n'est pas câblée en dur : les trois
363
363
  leviers sont des clés de configuration du serveur WebSocket, lues par le transport
364
364
  (`WsConnectionTransport.ts:63-75`) et documentées dans `@nodefony/http`
365
- (`http/nodefony/config/config.ts:1044`).
365
+ (`http/nodefony/config/config.ts:1226`).
366
366
 
367
- | File non drainée (`bufferedAmount`) | Décision | Réglage (défaut) |
368
- | --------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- |
369
- | sous le seuil | envoi normal | — |
370
- | au-dessus du seuil | politique appliquée : frame **jetée**, ou fermeture | `websocket.maxBackpressure` (**4 MiB**) · `websocket.backpressurePolicy` (**`drop`**) |
371
- | drops **consécutifs** au-delà du compte | fermeture `1013` (_Try Again Later_) | `websocket.backpressureCloseAfterDrops` (**1000**) |
367
+ | File non drainée (`bufferedAmount`) | Décision | Réglage (défaut) |
368
+ | ----------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- |
369
+ | sous le seuil | envoi normal | — |
370
+ | au-dessus du seuil | politique appliquée : frame **jetée**, ou fermeture | `websocket.maxBackpressure` (**4 MiB**) · `websocket.backpressurePolicy` (**`drop`**) |
371
+ | **solde** de refus atteint | fermeture `1013` (_Try Again Later_) | `websocket.backpressureCloseAfterDrops` (**1000**) |
372
372
 
373
373
  Deux points que la formulation « deux seuils d'octets » faisait manquer : la fermeture ne
374
- se déclenche pas sur un volume mais sur une **suite de frames jetées** — une seule frame
375
- qui repart remet le compteur à zéro — et `drop` est un choix, `close` fermant dès le
376
- premier dépassement. Jeter est acceptable parce que les canaux d'état sont « le dernier
377
- gagne » : le prochain instantané remplace celui qu'on a sauté.
378
-
379
- À ne pas confondre avec `SLOW_CONSUMER_BYTES` (`RealtimeHub.ts:63`, 1 MiB) : ce seuil-là
374
+ se déclenche pas sur un volume mais sur un **solde de frames jetées** — +1 par refus, −1 par
375
+ envoi, si bien qu'un pic passager redescend et qu'un client qui n'absorbe plus finit coupé — et
376
+ `drop` est un choix, `close` fermant dès le premier dépassement. Jeter est sans conséquence sur
377
+ un canal d'état, où le prochain instantané remplace celui qu'on a sauté ; mais le rejet ne trie
378
+ rien, et une réponse RPC ou un événement jetés sont perdus. Détail et courbes :
379
+ [Cadence et contre-pression](./cadence-et-contre-pression.md).
380
+
381
+ À ne pas confondre avec `SLOW_CONSUMER_BYTES` (`RealtimeHub.ts:64`, 1 MiB) : ce seuil-là
380
382
  ne jette rien, il **compte** — c'est celui à partir duquel la sonde marque une connexion
381
383
  `slowConsumer`.
382
384
 
@@ -388,7 +390,7 @@ Le schéma ci-dessous rend ces étages vivants : active le temps réel et il res
388
390
 
389
391
  ## 🔌 Le cycle de vie d'une connexion
390
392
 
391
- Tout se joue dans `RealtimeController.onHandshake()` (`RealtimeController.ts:374`), appelé
393
+ Tout se joue dans `RealtimeController.onHandshake()` (`RealtimeController.ts:450`), appelé
392
394
  une seule fois par connexion, en chemin froid.
393
395
 
394
396
  ```mermaid
@@ -419,19 +421,19 @@ sequenceDiagram
419
421
 
420
422
  Les étapes, dans l'ordre exact du code :
421
423
 
422
- 1. **Contrôle de l'origine** — `RealtimeHub.checkOrigin()` (`RealtimeHub.ts:986`). Refus →
424
+ 1. **Contrôle de l'origine** — `RealtimeHub.checkOrigin()` (`RealtimeHub.ts:967`). Refus →
423
425
  fermeture `4003`. La politique vient de la configuration ; sans politique, tout passe.
424
426
  2. **Résolution de l'identité** — `RealtimeHub.resolveAuthenticator()`
425
- (`RealtimeHub.ts:961`) parcourt les authentificateurs enregistrés : **le premier motif
427
+ (`RealtimeHub.ts:943`) parcourt les authentificateurs enregistrés : **le premier motif
426
428
  qui correspond capture**. Aucun ne correspond ? Le jeton anonyme gelé est posé
427
429
  (`ANONYMOUS_REALTIME_TOKEN`) — la lecture d'identité ne rend donc jamais `null`. Un
428
430
  échec d'authentification ferme en `4001`.
429
431
  3. **Création du peer et du transport**, puis association `peer → jeton`
430
- (`RealtimeHub.setTokenForPeer()`, `RealtimeHub.ts:1018`), stockée dans une `WeakMap` : le
432
+ (`RealtimeHub.setTokenForPeer()`, `RealtimeHub.ts:999`), stockée dans une `WeakMap` : le
431
433
  jeton disparaît avec le peer, sans fuite.
432
434
  4. **Enregistrement des actions** — celles des décorateurs `@RealtimeAction`, puis celles
433
435
  de la surcharge `realtimeActions()`, qui gagne en cas de conflit. Le pont API
434
- `api.request` n'est ajouté que si `realtimeApiRequest()` (`RealtimeController.ts:276`)
436
+ `api.request` n'est ajouté que si `realtimeApiRequest()` (`RealtimeController.ts:321`)
435
437
  rend `true`.
436
438
  5. **Inscription aux registres** : sonde de connexion, révocation périodique si le jeton
437
439
  est révocable, préfixes de canaux broadcast, canaux entrants.
@@ -439,7 +441,7 @@ Les étapes, dans l'ordre exact du code :
439
441
  l'identité résolue (type, authentifié ou non, rôles, portées). Le client sait **qui il
440
442
  est** sans appeler la moindre route.
441
443
 
442
- À la fermeture, un unique `onFinish` (`RealtimeController.ts:715`) fait le ménage complet :
444
+ À la fermeture, un unique `onFinish` (`RealtimeController.ts:771`) fait le ménage complet :
443
445
  désabonnement de chaque canal tenu, retrait des deux registres, `fireClose()` du transport,
444
446
  `dispose()` du peer. C'est ce qui garantit qu'aucun minuteur ni écouteur ne survit à une
445
447
  déconnexion.
@@ -447,41 +449,41 @@ déconnexion.
447
449
  > [!IMPORTANT]
448
450
  > Les frames reçues **pendant** l'authentification sont jetées silencieusement : le
449
451
  > transport n'est pas encore branché. C'est au client d'attendre `realtime:welcome` avant
450
- > de pousser — ce que `RealtimeClient` fait nativement.
452
+ > de pousser — ce que `NodefonySocket` fait nativement.
451
453
 
452
454
  ### Deux gardes qui ferment la connexion
453
455
 
454
456
  | Garde | Déclencheur | Effet | Ancrage |
455
457
  | --------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | --------------------------- |
456
- | Plafond de canaux | une connexion dépasse son quota d'abonnements (256 par défaut) | abonnement refusé + `realtime:denied` motif `limit` | `RealtimeController.ts:618` |
457
- | Révocation d'identité | tick périodique ; `token.isValid()` rend `false` ou lève une exception | fermeture `4001` « session revoked » | `RealtimeHub.ts:48` |
458
+ | Plafond de canaux | une connexion dépasse son quota d'abonnements (256 par défaut) | abonnement refusé + `realtime:denied` motif `limit` | `RealtimeController.ts:852` |
459
+ | Révocation d'identité | tick périodique ; `token.isValid()` rend `false` ou lève une exception | fermeture `4001` « session revoked » | `RealtimeHub.ts:49` |
458
460
 
459
461
  La seconde mérite une explication. Le verrou de frame est synchrone et lit une identité
460
462
  figée au handshake : il ne peut donc pas voir une session qui meurt en cours de route (une
461
463
  déconnexion HTTP, par exemple). Un minuteur — démarré au premier inscrit, arrêté dès que le
462
- registre se vide (`RealtimeHub.registerRevocable()`, `RealtimeHub.ts:794`) — relit
464
+ registre se vide (`RealtimeHub.registerRevocable()`, `RealtimeHub.ts:796`) — relit
463
465
  périodiquement ces identités et coupe les sockets orphelines. Seules les identités
464
466
  **révocables** y entrent : un visiteur anonyme ne coûte rien.
465
467
 
466
468
  ## Le hub — canaux partagés et fan-out
467
469
 
468
- Le hub est un singleton par process (`getRealtimeHub()`, `RealtimeHub.ts:1451`). Il ne
470
+ Le hub est un singleton par process (`getRealtimeHub()`, `RealtimeHub.ts:1432`). Il ne
469
471
  dépend de rien : ce sont les fabriques fournies par les contrôleurs qui portent les
470
472
  dépendances.
471
473
 
472
474
  ### Le provider partagé — un ticker, pas mille
473
475
 
474
- `RealtimeHub.subscribe()` (`RealtimeHub.ts:462`) applique une mécanique en trois temps :
476
+ `RealtimeHub.subscribe()` (`RealtimeHub.ts:464`) applique une mécanique en trois temps :
475
477
 
476
478
  1. Le canal existe déjà ? On ajoute simplement le sink de cette connexion. Fin.
477
479
  2. Sinon, on inscrit le sink **avant** d'appeler la fabrique — de sorte que le tout premier
478
480
  paquet du producteur atteigne bien ce premier abonné.
479
481
  3. La fabrique du contrôleur rend `null` (canal inconnu de lui) ? Dernier recours : le
480
482
  registre des **canaux système** (`RealtimeHub.registerSystemChannel()`,
481
- `RealtimeHub.ts:1297`), qu'un module bas niveau alimente au démarrage. Toujours `null` →
483
+ `RealtimeHub.ts:1278`), qu'un module bas niveau alimente au démarrage. Toujours `null` →
482
484
  l'abonnement est refusé et rien n'est alloué.
483
485
 
484
- Au dernier désabonnement, `RealtimeHub.unsubscribe()` (`RealtimeHub.ts:576`) appelle le
486
+ Au dernier désabonnement, `RealtimeHub.unsubscribe()` (`RealtimeHub.ts:578`) appelle le
485
487
  `dispose` du producteur et retire le canal. Un producteur fautif qui lève une exception ne
486
488
  bloque pas le nettoyage.
487
489
 
@@ -496,22 +498,22 @@ Deux méthodes qui se ressemblent et ne font pas du tout la même chose :
496
498
 
497
499
  | Méthode | Fan-out local | Propagation aux autres process | Qui l'appelle |
498
500
  | --------------------------------------- | :-----------: | :--------------------------------: | ----------------------------------- |
499
- | `publish()` (`RealtimeHub.ts:604`) | oui | oui, **si** le canal est broadcast | producteurs, contrôleurs, services |
500
- | `publishLocal()` (`RealtimeHub.ts:623`) | oui | **jamais** | l'arrivée d'un message du backplane |
501
+ | `publish()` (`RealtimeHub.ts:610`) | oui | oui, **si** le canal est broadcast | producteurs, contrôleurs, services |
502
+ | `publishLocal()` (`RealtimeHub.ts:629`) | oui | **jamais** | l'arrivée d'un message du backplane |
501
503
 
502
504
  C'est **la** règle qui empêche la tempête : un message reçu d'un pair est réinjecté
503
505
  localement et **ne repart pas**. Le câblage se fait une fois pour toutes dans
504
- `RealtimeHub.setBackplane()` (`RealtimeHub.ts:714`), qui branche l'arrivée du backplane
506
+ `RealtimeHub.setBackplane()` (`RealtimeHub.ts:720`), qui branche l'arrivée du backplane
505
507
  directement sur `publishLocal`.
506
508
 
507
- Le fan-out lui-même (`RealtimeHub.ts:340`) est **isolé** : chaque livraison est protégée,
509
+ Le fan-out lui-même (`RealtimeHub.ts:346`) est **isolé** : chaque livraison est protégée,
508
510
  une connexion fautive n'interrompt pas la diffusion aux autres.
509
511
 
510
512
  ### Le forward est OPT-IN — le défaut est l'isolement
511
513
 
512
514
  Par défaut, **aucun canal ne traverse le backplane**. Il faut déclarer un préfixe, via
513
- `@RealtimeBroadcast` sur ton contrôleur (`realtimeDecorators.ts:342`) ou
514
- directement `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts:668`).
515
+ `@RealtimeBroadcast` sur ton contrôleur (`realtimeDecorators.ts:420`) ou
516
+ directement `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts:674`).
515
517
 
516
518
  Trois raisons à ce choix, qui prend à contre-pied la plupart des bibliothèques temps réel :
517
519
 
@@ -578,7 +580,7 @@ resynchronise. Ne construis pas au-dessus une fiabilité que le support n'offre
578
580
  | ---------- | ----------------------------------------------- | -------------- | ---------------------------------- | :---------------: |
579
581
  | `loopback` | `LoopbackBackplane` (`LoopbackBackplane.ts:24`) | `local` | aucun (no-op complet) | non |
580
582
  | `cluster` | `ClusterBackplane` (`ClusterBackplane.ts:89`) | `ipc` | les workers du même process maître | non |
581
- | `redis` | `RedisBackplane` (`RedisBackplane.ts:161`) | `redis-pubsub` | tous les pods abonnés au canal | **oui** |
583
+ | `redis` | `RedisBackplane` (`RedisBackplane.ts:219`) | `redis-pubsub` | tous les pods abonnés au canal | **oui** |
582
584
 
583
585
  **`loopback`** est le défaut. En réalité, le hub garde son backplane à `null` en
584
586
  mono-process : le coût est un test de nullité, pas un appel. La classe existe pour
@@ -588,16 +590,16 @@ rien.
588
590
  **`cluster`** relie les workers d'un même hôte. Un worker Node ne peut parler qu'au maître :
589
591
  il émet vers lui (`ClusterBackplane.publish()`, `ClusterBackplane.ts:117`), le maître
590
592
  rediffuse aux autres. Le driver ne s'active qu'en rôle worker avec la variable
591
- `NF_CLUSTER=1` (`src/packages/@nodefony/realtime/index.ts:99`) ; ailleurs il rend
593
+ `NF_CLUSTER=1` (`src/packages/@nodefony/realtime/index.ts:106`) ; ailleurs il rend
592
594
  `null`. Aucune infrastructure requise — c'est le banc d'essai qui stabilise l'architecture
593
595
  multi-process avant d'ajouter du réseau.
594
596
 
595
597
  **`redis`** franchit la frontière de l'hôte, sans dépendre de la bibliothèque `redis` : il
596
598
  consomme deux connexions du module `@nodefony/redis` via un adaptateur purement structurel,
597
- `createRedisServiceTransport()` (`RedisBackplane.ts:100`). Deux connexions et non une, parce
599
+ `createRedisServiceTransport()` (`RedisBackplane.ts:137`). Deux connexions et non une, parce
598
600
  qu'un client Redis abonné ne peut plus émettre de commandes ordinaires. Module absent ou
599
601
  connexions indisponibles → avertissement, `null`, hub local, démarrage poursuivi
600
- (`src/packages/@nodefony/realtime/index.ts:108`).
602
+ (`src/packages/@nodefony/realtime/index.ts:115`).
601
603
 
602
604
  ### Le registre de drivers — zéro nom en dur
603
605
 
@@ -606,7 +608,7 @@ brancherie. La sélection passe donc par un registre — `registerBackplaneDrive
606
608
  (`backplaneRegistry.ts:55`) et `getBackplaneDriver()` (`backplaneRegistry.ts:63`) : chaque
607
609
  driver porte son nom en membre statique, les natifs s'inscrivent au chargement du module, et
608
610
  le câblage résout une chaîne vers une fabrique sans connaître aucun littéral
609
- (`src/packages/@nodefony/realtime/index.ts:253`).
611
+ (`src/packages/@nodefony/realtime/index.ts:260`).
610
612
 
611
613
  Une fabrique reçoit module, `originId`, rôle dans la topologie et configuration validée
612
614
  (`IBackplaneFactoryContext`, `backplaneRegistry.ts:27`), puis rend une instance ou `null`
@@ -620,7 +622,7 @@ et non une :
620
622
 
621
623
  1. **Côté hub** : l'arrivée passe par `publishLocal`, jamais par `publish`. Rien ne repart.
622
624
  2. **Côté backplane** : à la réception, on compare l'`originId` de l'enveloppe au sien et
623
- on jette si c'est le même (`RedisBackplane.ts:209`, `ClusterBackplane.ts:134`).
625
+ on jette si c'est le même (`RedisBackplane.ts:235`, `ClusterBackplane.ts:134`).
624
626
 
625
627
  L'étiquette elle-même est calculée par `resolveBackplaneOriginId()` (`originId.ts:24`), et
626
628
  sa recette mérite qu'on s'y arrête :
@@ -648,7 +650,7 @@ applications sur un Redis mutualisé se parleraient.
648
650
  D'où le suffixe de cloison, calculé par `resolveRedisChannel()` (`RedisBackplane.ts:31`) :
649
651
  le canal effectif devient `nodefony:realtime:<namespace>`. Le namespace vient de la
650
652
  configuration ; à défaut, il est dérivé du nom de l'application
651
- (`src/packages/@nodefony/realtime/index.ts:132`).
653
+ (`src/packages/@nodefony/realtime/index.ts:139`).
652
654
 
653
655
  > [!TIP]
654
656
  > Deux déploiements de la **même** application sur un Redis partagé (préproduction et
@@ -661,14 +663,14 @@ est ignoré, pas propagé (`RedisBackplane.ts:212`).
661
663
  ### Le démarrage, et ce qui se passe quand ça rate
662
664
 
663
665
  Le module valide sa configuration par Zod à l'enregistrement
664
- (`src/packages/@nodefony/realtime/index.ts:219`), puis câble tout au démarrage du noyau
665
- (`src/packages/@nodefony/realtime/index.ts:213`) — après l'initialisation des services (le
666
+ (`src/packages/@nodefony/realtime/index.ts:226`), puis câble tout au démarrage du noyau
667
+ (`src/packages/@nodefony/realtime/index.ts:220`) — après l'initialisation des services (le
666
668
  service Redis a donc ouvert ses connexions) et avant le trafic.
667
669
 
668
670
  Le `start()` du backplane est **attendu explicitement avant** d'être branché : un driver
669
671
  asynchrone brancherait sinon un abonnement incomplet et perdrait ses premiers messages. Et
670
672
  il est **borné par un délai** de cinq secondes
671
- (`src/packages/@nodefony/realtime/index.ts:83`), pour une raison très concrète : un
673
+ (`src/packages/@nodefony/realtime/index.ts:90`), pour une raison très concrète : un
672
674
  transport réseau qui reste pendu gèlerait la phase de démarrage, et donc la montée des
673
675
  serveurs HTTP eux-mêmes.
674
676
 
@@ -680,7 +682,7 @@ Trois issues, toutes annoncées, jamais silencieuses :
680
682
  | driver inactif ici (loopback, mauvais rôle) | hub local | `INFO` |
681
683
  | driver inconnu, ou démarrage en échec/expiré | hub local, **le démarrage continue** | `WARNING` |
682
684
 
683
- Le module se déclare non critique (`src/packages/@nodefony/realtime/index.ts:144`) : son
685
+ Le module se déclare non critique (`src/packages/@nodefony/realtime/index.ts:151`) : son
684
686
  échec ne tue jamais le process. C'est de la résilience **sans dégradation silencieuse** —
685
687
  tout repli est annoncé. Enfin, `enabled: false` rend le module totalement inerte : ni API
686
688
  d'administration, ni backplane, ni sonde.
@@ -718,8 +720,8 @@ code qui **appelle le builder lui-même** (un `Module` sur mesure, une intégrat
718
720
  programmatique). Une application qui configure via `use("@nodefony/realtime", …)` **ne
719
721
  l'atteint pas** : elle ne construit pas la config (le module appelle
720
722
  `defineRealtimeConfig(this.options)` sans second argument, `index.ts:216`), et une
721
- instance posée dans l'objet de config est retirée par la validation Zod (elle n'est pas
722
- au schéma). Pour une app, la voie « instance » est l'option 2 ci-dessus.
723
+ instance posée dans l'objet de config est refusée par la validation Zod (clé inconnue du
724
+ schéma strict). Pour une app, la voie « instance » est l'option 2 ci-dessus.
723
725
 
724
726
  Le registre s'introspecte : `listBackplaneDrivers()` (`backplaneRegistry.ts:68`) rend les
725
727
  noms disponibles — c'est ce que le message d'erreur affiche quand un driver déclaré est
@@ -733,29 +735,29 @@ refonte. Ici, seulement **où** ils sont ; le **quoi** et le **comment** sont da
733
735
 
734
736
  | Point de greffe | Étage | Moment | Ancrage |
735
737
  | ---------------------------- | --------- | -------------- | --------------------------- |
736
- | Contrôle d'origine | hub | à l'upgrade | `RealtimeHub.ts:614` |
737
- | Authentificateur réseau | hub | au handshake | `RealtimeHub.ts:579` |
738
- | Verrou de frame | protocole | à chaque frame | `RealtimeHub.ts:675` |
739
- | Politique déclarée par canal | hub | au handshake | `RealtimeHub.ts:713` |
740
- | Audit de frame | protocole | sur refus | `RealtimeController.ts:404` |
738
+ | Contrôle d'origine | hub | à l'upgrade | `RealtimeHub.ts:620` |
739
+ | Authentificateur réseau | hub | au handshake | `RealtimeHub.ts:585` |
740
+ | Verrou de frame | protocole | à chaque frame | `RealtimeHub.ts:681` |
741
+ | Politique déclarée par canal | hub | au handshake | `RealtimeHub.ts:719` |
742
+ | Audit de frame | protocole | sur refus | `RealtimeController.ts:522` |
741
743
 
742
744
  Deux propriétés architecturales méritent d'être notées ici, parce qu'elles expliquent des
743
745
  choix de conception visibles partout dans le module :
744
746
 
745
747
  **Coût nul quand la sécurité est absente.** Le verrou de frame n'est branché sur le peer que
746
- si une politique existe (`RealtimeHub.hasFrameAuthorizer()`, `RealtimeHub.ts:1058`, testé une
748
+ si une politique existe (`RealtimeHub.hasFrameAuthorizer()`, `RealtimeHub.ts:1039`, testé une
747
749
  fois au handshake). Sans module de sécurité, `beforeDispatch` reste indéfini et le chemin
748
750
  chaud ne paie **rien** du tout.
749
751
 
750
752
  **Échec bruyant plutôt que faux sentiment de sécurité.** Si des canaux déclarent une
751
753
  politique sans qu'aucun décideur ne soit câblé, `hasUnenforcedChannelPolicies()`
752
- (`RealtimeHub.ts:1117`) le détecte et un avertissement est émis une fois par process
753
- (`RealtimeController.ts:487`). Un canal qui **se croit** gardé alors qu'il est ouvert est
754
+ (`RealtimeHub.ts:1098`) le détecte et un avertissement est émis une fois par process
755
+ (`RealtimeController.ts:639`). Un canal qui **se croit** gardé alors qu'il est ouvert est
754
756
  bien plus dangereux qu'un canal ouvertement public.
755
757
 
756
758
  ## 📡 Observabilité — la sonde et Studio
757
759
 
758
- `RealtimeHub.probe()` (`RealtimeHub.ts:865`) rend un instantané en **lecture pure** : aucune
760
+ `RealtimeHub.probe()` (`RealtimeHub.ts:847`) rend un instantané en **lecture pure** : aucune
759
761
  allocation sur le chemin chaud, jamais d'exception. Les compteurs sont des primitives
760
762
  incrémentées en O(1) ; ils sont **monotones**, ce qui laisse au lecteur le soin de dériver
761
763
  un débit.
@@ -786,7 +788,7 @@ périodiquement sa santé au maître (`ClusterProbeClient.ts:179`), le maître a
786
788
  rediffuse, chaque worker met en cache. **N'importe lequel** sert alors la vue pod en O(1),
787
789
  sans latence de requête.
788
790
 
789
- L'agrégation, `mergeClusterHealth()` (`ClusterProbeClient.ts:46`), est une fonction pure :
791
+ L'agrégation, `mergeClusterHealth()` (`ClusterProbeClient.ts:60`), est une fonction pure :
790
792
  elle somme les scalaires, mais prend le **maximum** de `maxBufferedAmount` — la santé d'une
791
793
  flotte se juge sur son pire membre, pas sur sa moyenne. `buildRealtimeHealth()`
792
794
  (`RealtimeAdminApi.ts:74`) choisit ensuite la vue agrégée si elle existe, la per-instance
@@ -794,9 +796,9 @@ sinon.
794
796
 
795
797
  Sonde désactivée ? Elle n'est pas muette : **elle n'existe pas**. Aucun client, donc aucun
796
798
  minuteur, aucun écouteur, aucun message IPC
797
- (`src/packages/@nodefony/realtime/index.ts:351`) — coupable par la configuration ou par une
799
+ (`src/packages/@nodefony/realtime/index.ts:364`) — coupable par la configuration ou par une
798
800
  variable d'environnement. Enfin, le maître peut demander à un worker précis d'enrichir sa
799
- remontée (`requestEnrich()`, `ClusterProbeClient.ts:239`) : la sonde riche n'est allouée que
801
+ remontée (`requestEnrich()`, `ClusterProbeClient.ts:259`) : la sonde riche n'est allouée que
800
802
  pendant l'inspection, puis libérée — on paie ce qu'on regarde.
801
803
 
802
804
  ## ⚡ Performance & mémoire
@@ -805,10 +807,10 @@ Le module suit une discipline stricte, lisible dans presque chaque champ privé
805
807
 
806
808
  **Allocation paresseuse partout.** Table des canaux, registre des connexions, registre des
807
809
  révocables, authentificateurs, politiques de canal, association peer→jeton : tout démarre à
808
- `null`. Un process sans abonné n'alloue **rien** (`RealtimeHub.ts:141` et suivants).
810
+ `null`. Un process sans abonné n'alloue **rien** (`RealtimeHub.ts:142` et suivants).
809
811
 
810
812
  **Aucun minuteur au repos.** Le tick de révocation démarre au premier inscrit et s'arrête
811
- dès que le registre se vide — `RealtimeHub.unregisterRevocable()` (`RealtimeHub.ts:809`).
813
+ dès que le registre se vide — `RealtimeHub.unregisterRevocable()` (`RealtimeHub.ts:804`).
812
814
  Les minuteurs sont détachés de la boucle d'événements : ils ne retiennent jamais l'arrêt du
813
815
  process.
814
816
 
@@ -824,25 +826,25 @@ désabonné.
824
826
 
825
827
  **Le vrai point de vigilance est la back-pressure**, pas le processeur : une file d'envoi
826
828
  non bornée multipliée par le nombre de clients, c'est la panne mémoire — et le multiplexage
827
- concentre le risque. D'où les deux seuils du transport, le comptage des consommateurs lents
829
+ concentre le risque. D'où les réglages de contre-pression du transport, le comptage des consommateurs lents
828
830
  dans la sonde, et le plafond de canaux par connexion.
829
831
 
830
832
  ## ⚠️ Pièges
831
833
 
832
- | Symptôme | Cause | Correction |
833
- | -------------------------------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
834
- | Le chat marche en local, plus rien en cluster | le canal n'est pas déclaré broadcast (défaut : instance-local) | déclarer le préfixe avec `@RealtimeBroadcast` |
835
- | Le client ne reçoit rien alors qu'il a un handler `on(...)` | `on` reçoit, `subscribe` demande — il faut les **deux** | appeler `socket.subscribe(canal)` |
836
- | Chaque message arrive **en double** en cluster | `publishLocal` court-circuité, ou `originId` non unique entre pods | ne jamais republier une arrivée backplane ; vérifier `NF_POD_NAME`/hostname |
837
- | Deux applications se parlent sur un Redis mutualisé | même canal dérivé (le numéro de base Redis ne cloisonne pas le pub/sub) | poser un `backplane.namespace` explicite et distinct |
838
- | Producteur planté après le départ du premier abonné | la fabrique a capturé le contexte de la connexion créatrice | ne capturer que des valeurs à longue durée de vie |
839
- | Abonnement refusé avec `realtime:denied` motif `limit` | plafond de canaux par connexion atteint (256 par défaut) | regrouper les canaux, ou relever le plafond en connaissance de cause |
840
- | Fermeture `1013` sur un client lent | file d'envoi ≥ 8 MiB, jugée irrécupérable | attendu ; le client se reconnecte et se resynchronise |
841
- | Fermeture `4001` en cours de session | identité révoquée, détectée par le tick de re-validation | se réauthentifier ; le comportement est voulu |
842
- | Frames envoyées juste après `connect()` perdues | le transport n'est pas branché tant que le handshake n'est pas fini | attendre `realtime:welcome` (`RealtimeClient` le fait déjà) |
843
- | Avertissement « channel policies … NOT enforced » au démarrage | des canaux déclarent une politique sans décideur câblé | charger `@nodefony/security` avec une zone realtime |
844
- | `ServerRealtimeSocket.request()` rejette systématiquement | un handle posé sur le hub n'a pas d'interlocuteur unique | utiliser `RealtimeController.requestClient()` pour un appel ciblé |
845
- | `TS2550: RegExp.escape does not exist` à la compilation | `lib` du tsconfig sous ES2025 (les paquets exposent leurs types en source) | `"lib": ["ESNext", …]`, comme le tsconfig généré par `nodefony create app` |
834
+ | Symptôme | Cause | Correction |
835
+ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
836
+ | Le chat marche en local, plus rien en cluster | le canal n'est pas déclaré broadcast (défaut : instance-local) | déclarer le préfixe avec `@RealtimeBroadcast` |
837
+ | Le client ne reçoit rien alors qu'il a un handler `on(...)` | `on` reçoit, `subscribe` demande — il faut les **deux** | appeler `socket.subscribe(canal)` |
838
+ | Chaque message arrive **en double** en cluster | `publishLocal` court-circuité, ou `originId` non unique entre pods | ne jamais republier une arrivée backplane ; vérifier `NF_POD_NAME`/hostname |
839
+ | Deux applications se parlent sur un Redis mutualisé | même canal dérivé (le numéro de base Redis ne cloisonne pas le pub/sub) | poser un `backplane.namespace` explicite et distinct |
840
+ | Producteur planté après le départ du premier abonné | la fabrique a capturé le contexte de la connexion créatrice | ne capturer que des valeurs à longue durée de vie |
841
+ | Abonnement refusé avec `realtime:denied` motif `limit` | plafond de canaux par connexion atteint (256 par défaut) | regrouper les canaux, ou relever le plafond en connaissance de cause |
842
+ | Fermeture `1013` sur un client lent | file au-delà de `websocket.maxBackpressure` (4 MiB) pendant 1000 frames jetées d'affilée | attendu ; le client se reconnecte et se resynchronise |
843
+ | Fermeture `4001` en cours de session | identité révoquée, détectée par le tick de re-validation | se réauthentifier ; le comportement est voulu |
844
+ | Frames envoyées juste après `connect()` perdues | le transport n'est pas branché tant que le handshake n'est pas fini | attendre `realtime:welcome` (`NodefonySocket` le fait déjà) |
845
+ | Avertissement « channel policies … NOT enforced » au démarrage | des canaux déclarent une politique sans décideur câblé | charger `@nodefony/security` avec une zone realtime |
846
+ | `ServerRealtimeSocket.request()` rejette systématiquement | un handle posé sur le hub n'a pas d'interlocuteur unique | utiliser `RealtimeController.requestClient()` pour un appel ciblé |
847
+ | `TS2550: RegExp.escape does not exist` à la compilation | `lib` du tsconfig sous ES2025 (les paquets exposent leurs types en source) | `"lib": ["ESNext", …]`, comme le tsconfig généré par `nodefony create app` |
846
848
 
847
849
  ## 🧪 Tests & couverture
848
850
 
@@ -868,7 +870,7 @@ l'aperçu, régénérée depuis les résultats réels — jamais figés ici.
868
870
  > [!CAUTION]
869
871
  > Plusieurs de ces suites exigent une infrastructure (Redis, cluster). **Un test qui se
870
872
  > saute compte comme vert.** Avant de conclure « tout passe », vérifie que les variables
871
- > d'environnement d'infrastructure sont bien posées — la source unique est `vitest.gates.ts`
873
+ > d'environnement d'infrastructure sont bien posées — la source unique est `scripts/test/vitest/gates.ts`
872
874
  > à la racine, dont le rapport s'affiche en fin d'exécution.
873
875
 
874
876
  Couverture : `npm run coverage` dans `@nodefony/realtime`. Pour la charge et la mémoire, les