@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.
- package/README.md +55 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +91 -12
- package/dist/nodefony/config/config.js +7 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
- package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
- package/dist/nodefony/src/backplane/RedisBackplane.js +146 -41
- package/dist/nodefony/src/backplane/controlOrder.js +86 -0
- package/dist/nodefony/src/backplane/envelope.js +505 -27
- package/dist/nodefony/src/backplane/ingress.js +93 -0
- package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
- package/dist/nodefony/src/backplane/publishQueue.js +97 -3
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
- package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
- package/dist/nodefony/src/server/RealtimeConnection.js +135 -0
- package/dist/nodefony/src/server/RealtimeController.js +409 -107
- package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
- package/dist/nodefony/src/server/admissionBudget.js +176 -0
- package/dist/nodefony/src/server/channelPolicyRegistry.js +95 -0
- package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
- package/dist/nodefony/src/server/channelPresence.js +488 -0
- package/dist/nodefony/src/server/connectionAdmission.js +242 -0
- package/dist/nodefony/src/server/contractRoutes.js +75 -0
- package/dist/nodefony/src/server/deniedDetail.js +10 -3
- package/dist/nodefony/src/server/frameAdmission.js +160 -0
- package/dist/nodefony/src/server/presenceBus.js +486 -0
- package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
- package/dist/nodefony/src/server/remotePresence.js +541 -0
- package/dist/nodefony/src/server/subscribedAck.js +13 -1
- package/dist/nodefony/src/server/syslogUplink.js +3 -4
- package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
- package/dist/nodefony/src/service/RealtimeService.js +51 -1
- package/dist/nodefony/src/service/publicOrigin.js +40 -0
- package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
- package/dist/nodefony/testing/backplaneConformance.js +347 -0
- package/dist/nodefony/testing/index.js +43 -12
- package/dist/nodefony/testing/presenceClient.js +54 -0
- package/dist/types/index.d.ts +39 -7
- package/dist/types/nodefony/config/config.d.ts +9 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
- package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
- package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
- package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
- package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
- package/dist/types/nodefony/interfaces/index.d.ts +2 -1
- package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +89 -18
- package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
- package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
- package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
- package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
- package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
- package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +20 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
- package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
- package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +41 -0
- package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
- package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
- package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
- package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
- package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
- package/dist/types/nodefony/src/server/frameAdmission.d.ts +66 -0
- package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
- package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
- package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
- package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -2
- package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
- package/dist/types/nodefony/testing/backplaneConformance.d.ts +78 -0
- package/dist/types/nodefony/testing/index.d.ts +13 -2
- package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
- package/docs/actions.md +420 -18
- package/docs/architecture.md +101 -11
- package/docs/cadence-et-contre-pression.md +87 -11
- package/docs/canaux-a-membres.md +277 -0
- package/docs/cas-d-usage.md +1703 -0
- package/docs/configuration.md +175 -19
- package/docs/cookbook-chat.md +701 -224
- package/docs/index.md +11 -1
- package/docs/observabilite.md +97 -21
- package/docs/protocole.md +142 -33
- package/docs/securite.md +347 -41
- package/docs/vocabulaire.md +13 -12
- package/package.json +16 -13
package/docs/architecture.md
CHANGED
|
@@ -21,7 +21,7 @@ tags:
|
|
|
21
21
|
]
|
|
22
22
|
version: "doc"
|
|
23
23
|
status: stable
|
|
24
|
-
updated: 2026-
|
|
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#
|
|
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#
|
|
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,
|
|
631
|
-
|
|
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.
|
|
745
|
-
| Authentificateur réseau | hub | au handshake | `RealtimeHub.ts#RealtimeHub.
|
|
746
|
-
| Verrou de frame | protocole | à chaque frame | `RealtimeHub.ts#RealtimeHub.
|
|
747
|
-
| Politique déclarée par canal | hub | au handshake | `RealtimeHub.ts#RealtimeHub.
|
|
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` (`
|
|
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-
|
|
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
|
-
| `
|
|
605
|
-
| `
|
|
606
|
-
| `
|
|
607
|
-
| `
|
|
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).
|