@nodefony/realtime 10.0.0-beta.5 → 10.0.0-beta.7

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 (96) hide show
  1. package/README.md +55 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +91 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
  6. package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
  7. package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
  8. package/dist/nodefony/src/backplane/RedisBackplane.js +146 -41
  9. package/dist/nodefony/src/backplane/controlOrder.js +86 -0
  10. package/dist/nodefony/src/backplane/envelope.js +505 -27
  11. package/dist/nodefony/src/backplane/ingress.js +93 -0
  12. package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
  13. package/dist/nodefony/src/backplane/publishQueue.js +97 -3
  14. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  15. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  16. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  17. package/dist/nodefony/src/server/RealtimeConnection.js +135 -0
  18. package/dist/nodefony/src/server/RealtimeController.js +409 -107
  19. package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
  20. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  21. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  22. package/dist/nodefony/src/server/channelPolicyRegistry.js +95 -0
  23. package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
  24. package/dist/nodefony/src/server/channelPresence.js +488 -0
  25. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  26. package/dist/nodefony/src/server/contractRoutes.js +75 -0
  27. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  28. package/dist/nodefony/src/server/frameAdmission.js +160 -0
  29. package/dist/nodefony/src/server/presenceBus.js +486 -0
  30. package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
  31. package/dist/nodefony/src/server/remotePresence.js +541 -0
  32. package/dist/nodefony/src/server/subscribedAck.js +13 -1
  33. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  34. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  35. package/dist/nodefony/src/service/RealtimeService.js +51 -1
  36. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  37. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  38. package/dist/nodefony/testing/backplaneConformance.js +347 -0
  39. package/dist/nodefony/testing/index.js +43 -12
  40. package/dist/nodefony/testing/presenceClient.js +54 -0
  41. package/dist/types/index.d.ts +39 -7
  42. package/dist/types/nodefony/config/config.d.ts +9 -0
  43. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
  44. package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
  45. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  46. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  47. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  48. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  49. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  50. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  52. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  53. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +89 -18
  54. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
  55. package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
  56. package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
  57. package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
  58. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
  59. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  60. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  61. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +20 -0
  62. package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
  63. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
  64. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  65. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  66. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +41 -0
  67. package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
  68. package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
  69. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  70. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  71. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  72. package/dist/types/nodefony/src/server/frameAdmission.d.ts +66 -0
  73. package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
  74. package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
  75. package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
  76. package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
  77. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  78. package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -2
  79. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  80. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  81. package/dist/types/nodefony/testing/backplaneConformance.d.ts +78 -0
  82. package/dist/types/nodefony/testing/index.d.ts +13 -2
  83. package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
  84. package/docs/actions.md +420 -18
  85. package/docs/architecture.md +101 -11
  86. package/docs/cadence-et-contre-pression.md +87 -11
  87. package/docs/canaux-a-membres.md +277 -0
  88. package/docs/cas-d-usage.md +1703 -0
  89. package/docs/configuration.md +175 -19
  90. package/docs/cookbook-chat.md +701 -224
  91. package/docs/index.md +11 -1
  92. package/docs/observabilite.md +97 -21
  93. package/docs/protocole.md +142 -33
  94. package/docs/securite.md +347 -41
  95. package/docs/vocabulaire.md +13 -12
  96. package/package.json +16 -13
@@ -21,7 +21,7 @@ tags:
21
21
  ]
22
22
  version: "doc"
23
23
  status: stable
24
- updated: 2026-07-19
24
+ updated: 2026-11-10
25
25
  source: "src/packages/@nodefony/realtime/docs/architecture.md"
26
26
  ---
27
27
 
@@ -344,11 +344,11 @@ La pile se lit de haut en bas ; chaque étage ne connaît que son voisin du dess
344
344
  dispatch des notifications, gestion des erreurs, `dispose()` propre. Deux points
345
345
  d'interception y sont posés :
346
346
 
347
- - `beforeDispatch` (`JsonRpcPeer.ts#JsonRpcPeerOptions.beforeDispatch`) — un verrou **synchrone** appelé avant tout
347
+ - `beforeDispatch` (`JsonRpcPeer.ts#IJsonRpcPeerHooks.beforeDispatch`) — un verrou **synchrone** appelé avant tout
348
348
  traitement de frame. Il rend `true`/`false`. Un refus sur une requête produit
349
349
  `-32001 unauthorized` ; sur une notification, la frame est jetée
350
350
  (`JsonRpcPeer.ts#JsonRpcPeer.receive`).
351
- - `onFrameAudit` (`JsonRpcPeer.ts#JsonRpcPeerOptions.onFrameAudit`) — la trace des événements protocolaires notables
351
+ - `onFrameAudit` (`JsonRpcPeer.ts#IJsonRpcPeerHooks.onFrameAudit`) — la trace des événements protocolaires notables
352
352
  (frame invalide, refusée, méthode inconnue, erreur interne).
353
353
 
354
354
  La contrainte de synchronisme n'est pas un oubli : un `await` par frame coûterait une
@@ -445,7 +445,8 @@ Les étapes, dans l'ordre exact du code :
445
445
  est révocable, préfixes de canaux broadcast, canaux entrants.
446
446
  6. **Message de bienvenue** `realtime:welcome` : canaux et méthodes découvrables, plus
447
447
  l'identité résolue (type, authentifié ou non, rôles, portées). Le client sait **qui il
448
- est** sans appeler la moindre route.
448
+ est** sans appeler la moindre route. Le champ `codec`, quand il est posé, dit le codec de
449
+ trame des frames suivantes ; absent, c'est JSON-RPC texte (ADR-0022 §2).
449
450
 
450
451
  À la fermeture, un unique `onFinish` (`RealtimeController.ts#RealtimeController.onHandshake`) fait le ménage complet :
451
452
  désabonnement de chaque canal tenu, retrait des deux registres, `fireClose()` du transport,
@@ -627,8 +628,11 @@ même process). Sans filtre, le fan-out local serait fait deux fois. D'où **deu
627
628
  et non une :
628
629
 
629
630
  1. **Côté hub** : l'arrivée passe par `publishLocal`, jamais par `publish`. Rien ne repart.
630
- 2. **Côté backplane** : à la réception, on compare l'`originId` de l'enveloppe au sien et
631
- on jette si c'est le même (`RedisBackplane.ts#RedisBackplane.originId`, `ClusterBackplane.ts#ClusterBackplane.#ingress`).
631
+ 2. **Côté backplane** : à la réception, l'écho se reconnaît à son `originId` et se jette
632
+ (`ingress.ts#BackplaneIngress`, `ClusterBackplane.ts#ClusterBackplane.#ingress`). Sur un bus
633
+ partagé, il se reconnaît à la **fin** du message, sans le décoder : sous un secret, un seul
634
+ calcul de sceau le vérifie (`envelope.ts#BackplaneEcho`). Chaque écho est compté
635
+ (`echoTotal` de la carte d'identité du backplane) : tant qu'il revient, le bus est vivant.
632
636
 
633
637
  L'étiquette elle-même est calculée par `resolveBackplaneOriginId()` (`originId.ts#resolveBackplaneOriginId`), et
634
638
  sa recette mérite qu'on s'y arrête :
@@ -647,6 +651,38 @@ sa recette mérite qu'on s'y arrête :
647
651
  > celle qui ne lève aucune erreur. L'`originId` est résolu à la construction : coût nul par
648
652
  > message.
649
653
 
654
+ ### Des octets sur le bus
655
+
656
+ JSON ne sait pas porter des octets : un `Uint8Array` y devient un objet ordinaire
657
+ (`{"0":1,"1":2}`), sans erreur. Les octets traversent donc le bus par une voie à eux, ouverte
658
+ quand le serveur a un codec binaire (`codec: "cbor"`, [configuration](configuration.md)) :
659
+
660
+ - **`redis`** : un **second canal**, nommé d'après le codec
661
+ (`nodefony:realtime:<cloison>:nodefony.jsonrpc.cbor.v1`,
662
+ `RedisBackplane.ts#resolveRedisBytesChannel`). Il porte la même enveloppe
663
+ `{ channel, payload, originId }`, encodée en CBOR, octets natifs dans `payload`, scellée
664
+ comme le texte. Une charge sans octets garde le canal texte : un serveur plus ancien, qui
665
+ n'écoute pas le second canal, continue de recevoir tout le reste.
666
+ - **`cluster`** : le master forke ses workers en sérialisation `advanced` (clonage
667
+ structuré) — un `Uint8Array` traverse le relais tel quel, sans codec ni sceau : l'IPC ne
668
+ quitte pas la machine.
669
+
670
+ Le hub ne confie des octets au bus que si le driver les accepte
671
+ (`IBackplane.enableBytes`, `RealtimeHub.ts#RealtimeHub.publish`). Sinon il les garde sur le
672
+ serveur qui les publie, et un avertissement de plateforme, émis une seule fois, nomme le canal.
673
+
674
+ | Charge publiée sur un canal diffusé | Abonnés de ce serveur | Autres serveurs, codec binaire + driver capable | Autres serveurs, sinon |
675
+ | -------------------------------------- | --------------------- | ----------------------------------------------- | ------------------------------ |
676
+ | valeur JSON (`{ text: "bonjour" }`) | reçue | reçue | reçue |
677
+ | `Uint8Array` | reçue, intacte | reçue, intacte | **non envoyée**, avertissement |
678
+ | objet qui **contient** un `Uint8Array` | reçue, intacte | reçue, intacte | **non envoyée**, avertissement |
679
+ | autre vue typée, `ArrayBuffer` | reçue, intacte | **non envoyée**, avertissement | **non envoyée**, avertissement |
680
+
681
+ Sans codec binaire, seule la charge de premier niveau est examinée : chercher des octets au
682
+ fond d'un objet coûterait sur chaque publication d'une application qui n'en publie pas. Un
683
+ objet qui contient des octets sans codec binaire reste donc déformé sur le bus — comme il
684
+ l'est déjà pour les navigateurs, qui le reçoivent en JSON.
685
+
650
686
  ### Le cloisonnement du canal Redis
651
687
 
652
688
  Le canal pub/sub de base est `nodefony:realtime` (`RedisBackplane.ts#REDIS_RT_CHANNEL`). Problème : le
@@ -693,6 +729,60 @@ Le module se déclare non critique (`src/packages/@nodefony/realtime/index.ts`)
693
729
  tout repli est annoncé. Enfin, `enabled: false` rend le module totalement inerte : ni API
694
730
  d'administration, ni backplane, ni sonde.
695
731
 
732
+ ### Écrire un driver, et le prouver
733
+
734
+ Un driver mal réglé compile, démarre et perd des messages sans un mot. Les exigences qui
735
+ l'en gardent sont écrites sur le contrat (`IBackplane.ts#IBackplane`) ; trois d'entre elles
736
+ piègent tout journal partitionné :
737
+
738
+ - **chaque serveur lit TOUT** — un groupe de lecteurs par serveur ; un groupe partagé
739
+ répartirait les messages, et un serveur sur deux ne verrait qu'une partie du trafic ;
740
+ - **l'ordre par émetteur** — la clé de partition est l'`originId`, jamais le canal ;
741
+ - **aucun rejeu au démarrage** — on lit à partir du dernier message : relue depuis le
742
+ début, une fin de partie fermerait la partie rouverte depuis.
743
+
744
+ Dès que le bus quitte la machine, le driver **scelle** ses messages avec le sceau des
745
+ drivers livrés, exporté pour lui : `sealBackplaneEnvelope` à l'émission,
746
+ `openBackplaneEnvelope` à la réception (`envelope.ts#sealBackplaneEnvelope`).
747
+
748
+ La preuve est la même pour tous : `runBackplaneConformance`, exportée par
749
+ `@nodefony/realtime/testing` (`backplaneConformance.ts#runBackplaneConformance`). Elle
750
+ relie plusieurs pairs du driver sur un même bus et vérifie la diffusion à tous, l'ordre,
751
+ l'absence de rejeu, le sceau, le transport des ordres de contrôle et de la preuve de vie même
752
+ file saturée, le refus de l'espace réservé à l'entrée du hub, la présence entre deux pods, le
753
+ remplacement du handler et l'arrêt. Les drivers `cluster` et `redis` la passent dans le
754
+ dépôt ; `loopback`, qui n'a aucun pair, n'a rien à y prouver.
755
+
756
+ Le cas « présence entre deux pods » branche deux hubs réels sur deux pairs du driver.
757
+ Il vérifie trois choses :
758
+
759
+ - une identité ouverte sur les deux compte pour un seul présent ;
760
+ - l'arrêt propre d'un hub retire ses présents chez l'autre bien avant le délai d'oubli ;
761
+ - aucun message de présence n'arrive en double ni altéré.
762
+
763
+ Un bus « au moins une fois », qui relivre un message, y tombe : la présence compte les doublons
764
+ dans `presenceStaleDiscardedTotal`. Le driver n'a rien à écrire pour la présence, qui ne parle
765
+ qu'à `IBackplane`. Ce cas prouve que son transport la porte.
766
+
767
+ ```typescript
768
+ import { describe, it } from "vitest";
769
+ import { runBackplaneConformance } from "@nodefony/realtime/testing";
770
+
771
+ // Chaque appel rend un pair NEUF, relié au même bus, non démarré.
772
+ runBackplaneConformance(
773
+ "mon-bus",
774
+ (secret) => new MonBackplane({ url, secret }),
775
+ {
776
+ describe,
777
+ it,
778
+ },
779
+ );
780
+ ```
781
+
782
+ Le lanceur de tests est passé, pas importé : la suite tourne sous n'importe quel lanceur
783
+ qui fournit `describe` et `it`. Un bus lent relève `quietMs`, le silence attendu avant de
784
+ conclure qu'un message refusé n'arrivera pas.
785
+
696
786
  ## 🧩 Extension — brancher son propre fond de panier
697
787
 
698
788
  Trois voies, de la plus déclarative à la plus directe.
@@ -741,10 +831,10 @@ refonte. Ici, seulement **où** ils sont ; le **quoi** et le **comment** sont da
741
831
 
742
832
  | Point de greffe | Étage | Moment | Ancrage |
743
833
  | ---------------------------- | --------- | -------------- | ------------------------------------------------------ |
744
- | Contrôle d'origine | hub | à l'upgrade | `RealtimeHub.ts#RealtimeHub.publishLocal` |
745
- | Authentificateur réseau | hub | au handshake | `RealtimeHub.ts#RealtimeHub.unsubscribe` |
746
- | Verrou de frame | protocole | à chaque frame | `RealtimeHub.ts#RealtimeHub.markBroadcastChannel` |
747
- | Politique déclarée par canal | hub | au handshake | `RealtimeHub.ts#RealtimeHub.setBackplane` |
834
+ | Contrôle d'origine | hub | à l'upgrade | `RealtimeHub.ts#RealtimeHub.checkOrigin` |
835
+ | Authentificateur réseau | hub | au handshake | `RealtimeHub.ts#RealtimeHub.useAuthenticator` |
836
+ | Verrou de frame | protocole | à chaque frame | `RealtimeHub.ts#RealtimeHub.setFrameAuthorizer` |
837
+ | Politique déclarée par canal | hub | au handshake | `RealtimeHub.ts#RealtimeHub.registerChannelPolicy` |
748
838
  | Audit de frame | protocole | sur refus | `RealtimeController.ts#RealtimeController.onHandshake` |
749
839
 
750
840
  Deux propriétés architecturales méritent d'être notées ici, parce qu'elles expliquent des
@@ -776,7 +866,7 @@ un débit.
776
866
  | Back-pressure | `maxBufferedAmount`, `totalBufferedAmount`, `slowConsumers`, `drops` | le risque mémoire numéro un |
777
867
  | Fond de panier | `backplane` (la carte d'identité de `describe()`) | ce qui est réellement branché |
778
868
 
779
- Le contrat complet est `IRealtimeProbe` (`IRealtimeProbe.ts#IRealtimeProbe`). La version enrichie
869
+ Le contrat complet est `IRealtimeProbe` (`nodefony/src/realtime/platformHealth.ts#IRealtimeProbe`). La version enrichie
780
870
  `buildOwnHealth()` (`RealtimeAdminApi.ts#buildOwnHealth`) y ajoute l'identifiant d'instance et la sonde
781
871
  process, plus — si elles sont disponibles — les sondes ORM et les compteurs d'erreurs. Deux
782
872
  chemins de lecture, un seul producteur :
@@ -16,10 +16,11 @@ tags:
16
16
  contre-pression,
17
17
  sonde,
18
18
  1013,
19
+ 4007,
19
20
  ]
20
21
  version: "doc"
21
22
  status: stable
22
- updated: 2026-10-07
23
+ updated: 2026-10-30
23
24
  source: "src/packages/@nodefony/realtime/docs/cadence-et-contre-pression.md"
24
25
  coverageModule: realtime
25
26
  ---
@@ -70,6 +71,8 @@ flowchart LR
70
71
  | **File d'envoi** | `bufferedAmount` | Les octets acceptés par la socket mais pas encore partis sur le réseau. |
71
72
  | **Solde de refus** | `_nfDropStreak` | Compteur par connexion : +1 par trame refusée, −1 par trame envoyée. |
72
73
  | **1013** | _Try Again Later_ | Code de fermeture WebSocket : « reviens plus tard ». Le client le traite comme transitoire et se reconnecte. |
74
+ | **4007** | `BACKPRESSURE_CLOSE_CODE` | Code privé Nodefony : le CLIENT ferme parce que SA file d'envoi ne se vide plus. Il se reconnecte ensuite. |
75
+ | **Borne de débit** | `maxFramesPerSecond` | Nombre de trames qu'une connexion peut envoyer au serveur par seconde. Au-delà, la trame est écartée. |
73
76
  | **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
77
  | **Sonde** | _probe_ | Lecture instantanée des compteurs du hub, exposée par HTTP et par un canal de santé. |
75
78
  | **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. |
@@ -500,6 +503,65 @@ Le client **n'apprend jamais** les seuils : il ne reçoit que leur effet.
500
503
  définitifs (`FATAL_CLOSE_CODES`, `notice.ts#FATAL_CLOSE_CODES`), donc `isReconnectableCloseCode()`
501
504
  (`notice.ts#isReconnectableCloseCode`) répond oui, et il se reconnecte avec une attente croissante.
502
505
 
506
+ ### Côté navigateur — la même règle, fermée en `4007`
507
+
508
+ Un client peut lui aussi produire plus vite que le réseau n'écoule : un jeu qui émet trente
509
+ positions par seconde sur une connexion mobile accumule des positions déjà périmées dans la file
510
+ du navigateur. `NodefonySocket` applique alors **la même règle que le serveur** — elle vit au
511
+ cœur, `backpressure.ts#decideBackpressure`, et les deux côtés l'appellent :
512
+
513
+ ```ts
514
+ import { NodefonySocket } from "nodefony/client";
515
+
516
+ const socket = new NodefonySocket({
517
+ url: "/game/realtime",
518
+ // Au-delà de 64 Kio en attente, la position est jetée au lieu d'être mise en file.
519
+ maxBufferedAmount: 64 * 1024,
520
+ // Solde de 200 positions jetées : la connexion est jugée morte, fermée puis rouverte.
521
+ backpressureCloseAfterDrops: 200,
522
+ });
523
+
524
+ socket.emit("game:move", { x: 12, y: 4 }); // part, ou est jetée et comptée
525
+ console.log(socket.framesDropped); // positions jetées depuis la création
526
+ ```
527
+
528
+ - Le client lit `bufferedAmount` **avant** de sérialiser : une frame jetée ne coûte pas son
529
+ `JSON.stringify`. `emit`, `request` et `sendBinary` passent tous par là ; une requête jetée est
530
+ rejetée aussitôt, sans attendre son délai.
531
+ - Quand le solde atteint `backpressureCloseAfterDrops` (1 000 par défaut), le client ferme en
532
+ **`4007`** et se reconnecte comme après n'importe quelle coupure. Pas en `1013` : un navigateur
533
+ n'accepte que `1000` et `3000`-`4999` dans `WebSocket.close()`, et lèverait une erreur.
534
+ - **Désactivé par défaut** dans toute la série 10 : sans `maxBufferedAmount`, rien n'est jamais jeté.
535
+
536
+ ### Dans l'autre sens — borner les trames reçues
537
+
538
+ Le serveur borne aussi ce qu'il **reçoit**. Un client hostile qui envoie des milliers de trames par
539
+ seconde ferait décoder chacune : la borne de débit les écarte **avant** tout décodage.
540
+
541
+ ```ts
542
+ // nodefony.config.ts — section websocket ET websocketSecure
543
+ use("@nodefony/http", {
544
+ websocket: { maxFramesPerSecond: 200, frameRateCloseAfterRejects: 1000 },
545
+ websocketSecure: {
546
+ maxFramesPerSecond: 200,
547
+ frameRateCloseAfterRejects: 1000,
548
+ },
549
+ });
550
+ ```
551
+
552
+ - On compte des **trames**, pas des octets : la taille est déjà bornée par `maxPayload`. Texte et
553
+ binaire suivent la même règle.
554
+ - Le compte se fait à l'arrivée de la trame brute, dans `@nodefony/http`
555
+ (`wsFrameRate.ts#admitFrame`) : une trame écartée ne paie ni la conversion en texte, ni le
556
+ routage, ni `JSON.parse`, ni le décodage binaire.
557
+ - Une trame écartée est **comptée** (`frameRateRejectedTotal`, dans la sonde du hub) ; la connexion
558
+ reste ouverte. Elle n'est fermée en **`1008`** (violation de politique) que lorsque le solde de
559
+ refus atteint `frameRateCloseAfterRejects` — jamais au premier dépassement. Le solde descend
560
+ d'une unité par trame admise : seul un flux qui dépasse **le double** de la borne le fait monter.
561
+ - **Désactivée par défaut** (`maxFramesPerSecond: 0`) dans toute la série 10 : un seul test par
562
+ trame, aucune lecture d'horloge, aucune allocation. Repère : 50 trames/s d'audio passent avec
563
+ marge sous une borne de 200.
564
+
503
565
  ## 🔌 Comment les deux étages se rencontrent
504
566
 
505
567
  Aucune ligne de code ne relie la contre-pression du serveur à la cadence du client. Ils se
@@ -533,6 +595,10 @@ sans activer de trace.
533
595
  | `slowConsumers` | les connexions dont la file atteint `slowConsumer.bytes` (1 Mio par défaut) | non nul = au moins un client prend du retard |
534
596
  | `drops` | les trames refusées, cumulées | qui **augmente** = la contre-pression agit en ce moment |
535
597
 
598
+ Les refus ont leurs propres compteurs, à côté du bloc `backpressure`. **`frameRateRejectedTotal`**
599
+ compte les trames reçues écartées par la borne de débit — cumul du **processus**, relevé dans
600
+ `@nodefony/http`, car une trame écartée n'atteint aucun hub. `0` tant que la borne est désactivée.
601
+
536
602
  > [!IMPORTANT]
537
603
  > **`slowConsumer.bytes` compte, il ne freine pas.** C'est le réglage de la **sonde**
538
604
  > (`@nodefony/realtime`), pas de la contre-pression (`@nodefony/http`). Le baisser fait apparaître
@@ -557,6 +623,10 @@ Le détail champ par champ et le flux de santé : [Observabilité de la socket](
557
623
  | `maxBackpressure` | `@nodefony/http` | 4 Mio | file d'envoi au-delà de laquelle la politique s'applique ; 0 = coupé |
558
624
  | `backpressurePolicy` | `@nodefony/http` | `drop` | `drop` jette la trame ; `close` ferme au premier dépassement |
559
625
  | `backpressureCloseAfterDrops` | `@nodefony/http` | 1000 | solde de refus qui déclenche la fermeture `1013` ; 0 = jamais |
626
+ | `maxFramesPerSecond` | `@nodefony/http` | 0 | trames reçues admises par seconde et par connexion ; 0 = coupé |
627
+ | `frameRateCloseAfterRejects` | `@nodefony/http` | 1000 | solde de trames écartées qui ferme en `1008` ; 0 = jamais |
628
+ | `maxBufferedAmount` (`NodefonySocket`) | `nodefony` | 0 | file d'envoi du navigateur au-delà de laquelle on jette ; 0 = coupé |
629
+ | `backpressureCloseAfterDrops` (`NodefonySocket`) | `nodefony` | 1000 | solde de frames jetées qui ferme en `4007` ; 0 = jamais |
560
630
  | `slowConsumer.bytes` | `@nodefony/realtime` | 1 Mio | seuil de **comptage** de la sonde ; n'agit sur rien |
561
631
  | `intervalMs` (hook / client) | `nodefony` | — | cadence **désirée** ; l'AIMD ne descend jamais en dessous |
562
632
  | `ladder`, `maxMs` | `nodefony` | ×2, 60 s | l'échelle des cadences possibles |
@@ -576,6 +646,8 @@ La table complète des réglages du module et leurs schémas : [Configuration](c
576
646
  | ---------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
577
647
  | 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
648
  | Registre IANA des codes de fermeture WebSocket | `1013` = _Try Again Later_ : condition temporaire du serveur | le client le traite comme transitoire et se reconnecte |
649
+ | RFC 6455 §7.4.1 — `1008` | _Policy Violation_ : le pair a enfreint une politique du serveur | ferme la connexion qui dépasse durablement la borne de débit entrante |
650
+ | WHATWG WebSockets — `close(code)` | seuls `1000` et `3000`-`4999` sont admis, sinon `InvalidAccessError` | le navigateur ferme sa contre-pression en `4007`, jamais en `1013` |
579
651
  | 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
652
  | JSON-RPC 2.0 | requêtes, réponses, notifications | aucune notion de cadence ni de file : tout ce mécanisme est propre à Nodefony |
581
653
 
@@ -596,15 +668,18 @@ La table complète des réglages du module et leurs schémas : [Configuration](c
596
668
 
597
669
  ## 🧪 Tests
598
670
 
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 |
671
+ | Suite | Ce qui est prouvé |
672
+ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
673
+ | `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 |
674
+ | `channelRate.test.ts` (`nodefony`) | le nom cadencé : canal nu pour le défaut, suffixe sinon, bornage côté serveur |
675
+ | `RealtimeNotice.test.ts` (`nodefony`) | `1013` fait partie des fermetures transitoires qui déclenchent la reconnexion |
676
+ | `realtimeBackpressure.test.ts` (`nodefony`) | la règle pure du cœur : un verdict par cas (`send`, `drop`, `close`), le solde qui monte au refus et redescend à l'envoi |
677
+ | `clientBackpressure.test.ts` (`nodefony`) | le navigateur : rien de jeté sans borne ; au-delà, `emit`/`request`/`sendBinary` jettent et comptent ; solde atteint → `4007`, reconnexion |
678
+ | `wsBackpressure.test.ts` (`@nodefony/http`) | `decideSend()` : désactivé à 0, nominal sous le seuil, `drop` sans fermer, `close` en `1013` |
679
+ | `wsFrameRate.test.ts` (`@nodefony/http`) | la borne entrante : aucune fenêtre à 0, rafale écartée **sans décodage**, flux soutenu fermé en `1008` |
680
+ | `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 |
681
+ | `WsConnectionTransport.test.ts` (`@nodefony/realtime`) | le transport : envoi seulement sur socket ouverte, fermeture déléguée |
682
+ | `cadencePressureFigures.test.ts` (`@nodefony/realtime`) | **les courbes de cette page** : rejoue les trois scénarios avec le vrai code et compare chaque valeur |
608
683
 
609
684
  Deux bancs complètent, hors des suites (skill `nodefony-load-test`) :
610
685
 
@@ -619,7 +694,8 @@ pas éprouvée de bout en bout.
619
694
  ```bash
620
695
  cd src/nodefony && npx vitest run src/tests/AdaptiveRate.test.ts src/tests/channelRate.test.ts
621
696
  cd src/packages/@nodefony/realtime && npm test
622
- cd src/packages/@nodefony/http && npx vitest run nodefony/tests/unit/wsBackpressure.test.ts
697
+ cd src/packages/@nodefony/http && npx vitest run nodefony/tests/unit/wsBackpressure.test.ts nodefony/tests/unit/wsFrameRate.test.ts
698
+ cd src/nodefony && npx vitest run src/tests/realtimeBackpressure.test.ts src/tests/clientBackpressure.test.ts
623
699
  ```
624
700
 
625
701
  ## Ce qu'il faut retenir
@@ -0,0 +1,277 @@
1
+ ---
2
+ title: "Canal à membres — abonner d'office, éjecter, terminer"
3
+ navTitle: Canal à membres
4
+ lang: fr
5
+ module: "@nodefony/realtime"
6
+ topic: realtime
7
+ section: "Temps réel"
8
+ audience: [developer]
9
+ tags: [canaux, membres, salon, partie, éjection, fermeture, allow]
10
+ version: "doc"
11
+ status: stable
12
+ updated: 2026-11-02
13
+ source: "src/packages/@nodefony/realtime/docs/canaux-a-membres.md"
14
+ coverageModule: realtime
15
+ ---
16
+
17
+ # Canal à membres — abonner d'office, éjecter, terminer
18
+
19
+ 📍 [Documentation](../../../../../docs/index.md) › [Realtime](index.md) › **Canal à membres**
20
+
21
+ > Un salon fermé, une partie à quatre, un appel, le flux d'une tâche qu'un agent rend à son
22
+ > demandeur : des canaux dont le **serveur** décide qui est dedans. Le canal reste le modèle — il
23
+ > porte son producteur — et le hub sait désormais **qui** y est abonné : il liste ses membres,
24
+ > abonne une connexion d'office, en éjecte une, et termine le canal pour tous.
25
+
26
+ ## 🧠 Le modèle mental
27
+
28
+ Un canal ordinaire se remplit par la demande des clients : chaque navigateur envoie `subscribe`, la
29
+ politique du canal (rôles, scopes, `allow`) tranche, et le serveur diffuse. Un canal **à membres**
30
+ ajoute cinq gestes au serveur, et une règle qui les tient ensemble :
31
+
32
+ | Geste | Méthode du hub | Portée |
33
+ | --------------------------------------- | --------------------------------------- | ------------------------ |
34
+ | Lister les membres | `channelMembers(canal)` | **ce processus** |
35
+ | Abonner une connexion d'office | `subscribeConnection(canal, connexion)` | **ce processus** |
36
+ | Éjecter une connexion | `evictConnection(canal, connexion)` | **ce processus** |
37
+ | Éjecter un compte (tous ses onglets) | `evictUser(canal, compte)` | **tous les exemplaires** |
38
+ | Terminer le canal pour tous ses membres | `closeChannel(canal)` | **tous les exemplaires** |
39
+
40
+ Ce qui désigne une **connexion** reste local : elle n'existe que dans le processus qui tient sa
41
+ socket. Ce qui désigne un **nom** — un canal, un compte — traverse les exemplaires par le bus.
42
+
43
+ **La règle : `allow` est seul juge de qui ENTRE.** Abonner d'office passe par la même politique
44
+ qu'un `subscribe` du client, `allow` compris ; éjecter **coupe**, n'exclut pas. Le navigateur
45
+ rejoue ses abonnements à chaque reconnexion, et ce rejeu passe par `allow` : un membre imposé que
46
+ `allow` ne connaît pas serait éjecté à la première coupure réseau, et un éjecté qu'`allow` connaît
47
+ encore reviendrait au rechargement de sa page. L'application écrit donc l'appartenance **là où
48
+ `allow` la lit** — une table, un registre partagé — et les gestes du hub ne font que la suivre.
49
+
50
+ ## 📖 Lexique
51
+
52
+ - **Membre** — une connexion abonnée à un canal par un CLIENT (sa demande, ou une imposition du
53
+ serveur). Les écouteurs et services du serveur ne sont pas des membres.
54
+ - **Poignée** (`IRealtimeConnection`) — ce qui désigne une connexion côté serveur : `userId` et
55
+ `open`. Elle n'existe que dans le processus qui tient la socket.
56
+ - **Abonnement imposé** — le serveur abonne une connexion qui n'a rien demandé ; le client en est
57
+ averti par `realtime:subscribed { imposed: true }`.
58
+ - **Éjection** — retrait d'UNE connexion d'un canal, motif `evicted`. **Fin** — retrait de tous
59
+ les membres, motif `ended`.
60
+ - **Instance** — l'occurrence d'un canal à durée de vie, portée par son nom (`game:42:a1b2`).
61
+
62
+ ## 🚀 Démarrage rapide — une partie à quatre
63
+
64
+ ```typescript
65
+ // nodefony/controllers/GameRealtimeController.ts
66
+ import { controller, route } from "@nodefony/framework";
67
+ import { Context } from "@nodefony/http";
68
+ import {
69
+ RealtimeChannel,
70
+ RealtimeController,
71
+ RealtimeInbound,
72
+ getRealtimeHub,
73
+ } from "@nodefony/realtime";
74
+ import type { IRealtimeConnection } from "@nodefony/realtime";
75
+
76
+ /** Qui joue à quelle partie — en vrai, une table ou un Redis partagé. */
77
+ const players = new Map<string, Set<string>>();
78
+ /** Parties en cours : une partie finie n'a plus de producteur. */
79
+ const running = new Set<string>();
80
+
81
+ @controller("/rt")
82
+ class GameRealtimeController extends RealtimeController {
83
+ constructor(context: Context) {
84
+ super("GameRealtimeController", context);
85
+ }
86
+
87
+ @route("game-ws", { path: "/game", requirements: { methods: ["WEBSOCKET"] } })
88
+ async realtime(message: string | Buffer | null): Promise<void> {
89
+ this.handleRealtime(message);
90
+ }
91
+
92
+ // `allow` lit l'appartenance : c'est elle qui fait entrer, au premier
93
+ // abonnement comme à chaque reconnexion.
94
+ @RealtimeChannel("game:*", {
95
+ allow: (channel, who) =>
96
+ who.id !== null && (players.get(channel)?.has(who.id) ?? false),
97
+ })
98
+ game(): () => void {
99
+ return () => {};
100
+ }
101
+
102
+ // La fabrique d'un canal à durée de vie rend `null` pour une partie finie :
103
+ // c'est elle, et elle seule, qui clôt le canal.
104
+ override createRealtimeChannel(channel: string): (() => void) | null {
105
+ return running.has(channel) ? () => {} : null;
106
+ }
107
+
108
+ // Le geste part PAR LA SOCKET : il atteint le processus qui la tient. Le
109
+ // gestionnaire reçoit la connexion en dernier argument.
110
+ @RealtimeInbound("game:join")
111
+ async join(
112
+ _params: unknown,
113
+ _reply: (payload: unknown) => void,
114
+ connection: IRealtimeConnection,
115
+ ): Promise<void> {
116
+ const game = "game:42:a1b2"; // la partie trouvée par la recherche
117
+ if (connection.userId === null) return;
118
+ running.add(game);
119
+ const members = players.get(game) ?? new Set<string>();
120
+ members.add(connection.userId);
121
+ players.set(game, members);
122
+ const joined = await getRealtimeHub().subscribeConnection(game, connection);
123
+ if (!joined) this.log(`partie ${game} refusée`, "INFO");
124
+ }
125
+ }
126
+
127
+ export default GameRealtimeController;
128
+
129
+ // Fin de partie, depuis n'importe quel service — la fin atteint tous les exemplaires :
130
+ export function endGame(game: string): void {
131
+ running.delete(game);
132
+ players.delete(game);
133
+ getRealtimeHub().closeChannel(game);
134
+ }
135
+ ```
136
+
137
+ Côté navigateur, rien à écrire pour suivre un canal imposé : l'acquittement
138
+ `realtime:subscribed { channel, imposed: true }` l'inscrit dans les abonnements du client, qui le
139
+ rejoue à la reconnexion comme s'il l'avait demandé. Pour dire la fin à l'écran :
140
+
141
+ ```typescript
142
+ import { NodefonySocket } from "nodefony/client";
143
+
144
+ const socket = new NodefonySocket({ url: "/rt/game" });
145
+ socket.on("game:42:a1b2", (state) => console.log("état de la partie", state));
146
+ socket.onClosed(({ channel, reason }) => {
147
+ // `ended` : la partie est finie pour tous ; `evicted` : on en a été retiré.
148
+ console.log(
149
+ channel,
150
+ reason === "ended" ? "partie terminée" : "partie quittée",
151
+ );
152
+ });
153
+ socket.connect().catch(() => {});
154
+ ```
155
+
156
+ ## 📋 Ce que fait chaque geste
157
+
158
+ **`subscribeConnection(canal, connexion)`** rend une promesse : `true` une fois l'abonnement acquitté,
159
+ `false` sinon — politique refusée, `allow` qui dit non ou ne répond pas dans `allowTimeoutMs`, canal
160
+ sans producteur, connexion fermée pendant l'attente, client qui se désabonne avant le verdict. Un
161
+ refus n'est rendu **qu'à l'appelant** : le client, qui n'a rien demandé, ne reçoit aucun
162
+ `realtime:denied`. Une connexion déjà membre rend `true` sans second acquittement.
163
+
164
+ **`evictConnection(canal, connexion)`** retire la connexion du canal par le même chemin qu'un
165
+ `unsubscribe` (le hub ET l'état de la connexion — jamais un abonné fantôme), lui retire l'admission
166
+ de ses trames entrantes sur ce canal, et lui envoie `realtime:closed { reason: "evicted" }`. Les
167
+ autres membres restent servis. Le client ne rejoue pas le canal ; une page rechargée, si — et c'est
168
+ `allow` qui tranche alors. Sur un canal **sans** `allow`, la politique fixe le laisse rentrer :
169
+ **exclure d'un canal sans `allow`, c'est lui en donner un**.
170
+
171
+ **`evictUser(canal, compte)`** éjecte de la même façon **toutes** les connexions de ce compte membres
172
+ du canal — ses onglets, quel que soit l'exemplaire qui les sert. Il rend le nombre de connexions
173
+ éjectées **ici** ; les autres exemplaires l'appliquent sans compte rendu. Un compte vide n'éjecte
174
+ rien. La même règle vaut : retirer l'appartenance là où `allow` la lit, **puis** éjecter.
175
+
176
+ **Un accès borné dans le temps** (l'invité d'un événement, un abonnement qui expire) ne se périme
177
+ pas seul : le verdict d'`allow` est retenu sur la connexion, et le hub ne relit rien. À l'échéance,
178
+ l'application rejoue sa règle et éjecte qui n'y a plus droit. Un seul minuteur suffit, visé sur
179
+ la prochaine échéance — ni un par accès, ni un balayage périodique qui lirait la base pour rien. La
180
+ démo le fait pour ses salons (`RoomService.closeEnded`, gabarit `create app --demo`).
181
+
182
+ **`closeChannel(canal)`** envoie `realtime:closed { reason: "ended" }` à chaque membre, sur tous les
183
+ exemplaires, puis libère le producteur. Les écouteurs du serveur (`listen`) restent inscrits, sur un canal redevenu passif. Le
184
+ hub ne garde **aucune marque « fermé »** : le `subscribe` suivant rejoue la fabrique, qui rend `null`
185
+ pour une instance terminée — le client reçoit `realtime:denied { reason: "unknown" }`. D'où la règle
186
+ d'écriture : **le nom d'un canal à durée de vie porte l'identifiant de son instance**
187
+ (`game:42:a1b2`, pas `game:42`), pour qu'une partie rouverte ne soit jamais confondue avec celle
188
+ qu'on vient de finir.
189
+
190
+ **`channelMembers(canal)`** rend les connexions abonnées par un client — pas les écouteurs ni les
191
+ services du serveur. Chaque poignée porte `userId` (le compte, `null` pour l'anonyme) et `open`.
192
+
193
+ Un geste qui ne trouve rien rend `false` (ou `0`) : connexion non membre, fermée, ou poignée qui ne
194
+ vient pas de ce processus. Jamais un geste muet.
195
+
196
+ ## 🔩 Où c'est fait
197
+
198
+ - Le registre des membres vit dans l'état du canal, rempli à l'abonnement par
199
+ `RealtimeHub.ts#RealtimeHub.subscribeClient` et vidé par `RealtimeHub.ts#RealtimeHub.unsubscribe` ;
200
+ le fan-out (`RealtimeHub.ts#RealtimeHub.publish`) ne le lit jamais.
201
+ - Les cinq gestes : `RealtimeHub.ts#RealtimeHub.channelMembers`,
202
+ `RealtimeHub.ts#RealtimeHub.subscribeConnection`, `RealtimeHub.ts#RealtimeHub.evictConnection`,
203
+ `RealtimeHub.ts#RealtimeHub.evictUser`, `RealtimeHub.ts#RealtimeHub.closeChannel`.
204
+ - Les ordres entre exemplaires, en liste fermée : `controlOrder.ts#readControlOrder` (la même garde
205
+ à l'émission et à la réception).
206
+ - La poignée : `RealtimeConnection.ts#RealtimeConnection`, créée au premier besoin par le contrôleur.
207
+ - Le chemin unique d'une sortie, demandée ou décidée :
208
+ `RealtimeController.ts#RealtimeController.leaveChannel` ; la notification de fin :
209
+ `RealtimeController.ts#RealtimeController.endChannel`.
210
+ - Côté navigateur : `NodefonySocket.ts#NodefonySocket.onClosed`.
211
+
212
+ ## ⚠️ Pièges
213
+
214
+ - **Imposer sans écrire l'appartenance là où `allow` la lit** : l'abonnement tient jusqu'à la
215
+ première coupure réseau, puis le rejeu est refusé. Sur mobile, c'est dans la minute.
216
+ - **Éjecter pour exclure** : une page rechargée refait `subscribe`. Retirer l'appartenance d'abord,
217
+ éjecter ensuite.
218
+ - **Un nom de canal sans identifiant d'instance** (`game:42`) : la partie suivante portera le même
219
+ nom, et un client resté sur l'ancienne y entrera.
220
+ - **Appeler un geste PAR CONNEXION depuis une requête HTTP** en cluster : la connexion est peut-être
221
+ tenue par un autre exemplaire. Passer par la socket, par `allow` (section suivante), ou par un
222
+ geste par NOM (`evictUser`, `closeChannel`), qui atteint tous les exemplaires.
223
+ - **Un bus partagé** (Redis mutualisé) : un tiers qui peut y écrire, même sans le secret, peut
224
+ rejouer un ordre d'éjection capté. Sur un canal durable (`chat:general`), il recouperait le compte
225
+ à chaque retour. L'effet reste une coupure, jamais une entrée ; changer le secret du bus rend
226
+ caducs les ordres déjà captés. **Le bus doit être privé.**
227
+
228
+ ## 🌐 Plusieurs exemplaires (pods, `nodefony cluster`)
229
+
230
+ Une connexion n'existe que dans la mémoire du processus qui a accepté sa socket. Le backplane
231
+ transporte les **publications** et deux **ordres** — terminer un canal, éjecter un compte — que
232
+ chaque exemplaire applique à SES connexions ; ni membres, ni politiques, ni acquittements. Ces deux
233
+ ordres ne font qu'enlever : seul `allow` fait entrer. Livrés « au plus une fois », comme toute
234
+ publication : un exemplaire qui en manque un, ou qui démarre après, est rattrapé par `allow` (pour
235
+ une éjection) ou par la fabrique qui rend `null` à une instance terminée (pour une fin). Un bus
236
+ chargé ne les sacrifie pas : quand la file d'envoi vers le bus est pleine, les publications en
237
+ trop sont abandonnées, les ordres passent quand même. Seul un bus injoignable en perd un, et la
238
+ perte se compte (`controlLostTotal`) et s'annonce.
239
+
240
+ Les gestes **par connexion** agissent sur les connexions de ce processus. Deux voies marchent en
241
+ cluster pour eux :
242
+
243
+ 1. **Le geste part par la socket** — un gestionnaire `@RealtimeInbound` reçoit la connexion et
244
+ s'exécute, par construction, sur le processus qui la tient.
245
+ 2. **Le serveur autorise, le navigateur s'abonne** — une requête HTTP (routée sur n'importe quel
246
+ exemplaire) écrit l'appartenance dans un registre partagé ; le navigateur fait `subscribe`, et
247
+ `allow` lit ce registre **directement**, sans cache : il n'est appelé qu'une fois par abonnement.
248
+
249
+ Une requête HTTP qui appellerait `subscribeConnection` sur une connexion tenue par un autre
250
+ exemplaire n'a pas de poignée à lui passer : elle ne peut rien faire, et c'est voulu — un geste qui
251
+ marcherait en développement et une fois sur N en production serait le pire des défauts.
252
+
253
+ ## 🧪 Tests & couverture
254
+
255
+ - **Serveur** (`channelMembers.test.ts`) : membres listés et retirés à la fermeture de la socket ;
256
+ éjection (l'éjecté n'est plus servi, les autres si ; il rentre sans `allow`) ; imposition depuis un
257
+ gestionnaire entrant, refus de `allow` rendu au seul appelant, connexion fermée ou désabonnée
258
+ pendant l'attente ; fin du canal, producteur libéré, écouteur conservé, fabrique rejouée.
259
+ - **Entre exemplaires** (`memberOrdersAcrossPods.test.ts`) : fin et éjection d'un compte données sur
260
+ un hub atteignent les membres de l'autre ; canal de contrôle jamais diffusé ; ordres mal formés
261
+ ou visant un canal de la plateforme rejetés et comptés (`ingressRejectedTotal`), ordre d'un type
262
+ inconnu de cette version jeté et compté à part (`ingressUnknownDiscardedTotal`). Sur un
263
+ **Redis réel** (`memberOrdersRedis.test.ts`, gate Redis) : ordre scellé appliqué, ordre non scellé
264
+ ignoré, et le rejeu d'une éjection captée — le risque accepté — décrit par un test. Sur le **bus du
265
+ cluster** (`clusterIpc.e2e.test.ts`, `npm run test:cluster`) : deux vrais processus.
266
+ - **Client** (`NodefonySocketClosed.test.ts`) : canal imposé inscrit et rejoué, canal terminé retiré
267
+ pour tous ses consommateurs et jamais rejoué, attente d'acquittement rejetée, trame mal formée
268
+ ignorée.
269
+ - **Trames** (`subscribedAck.test.ts`) : l'acquittement imposé est octet pour octet celui du pair
270
+ JSON-RPC. **Surface figée** (`frozenSurface.test.ts`) : les motifs `ended` et `evicted`.
271
+
272
+ ## 🔗 Pour aller plus loin
273
+
274
+ - ⬆️ **Retour au hub** : [Realtime — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
275
+ - [Sécurité](./securite.md) — `allow`, sa forme classe, son délai et son budget.
276
+ - [Protocole](./protocole.md) — les notifications système de la socket.
277
+ - Décisions : ADR-0022 §6 (canaux privés, décisions 2 à 4), ADR-0021 S4 (fin d'un flux).