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

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 (78) hide show
  1. package/README.md +79 -10
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +79 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +125 -19
  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 +143 -31
  9. package/dist/nodefony/src/backplane/controlOrder.js +57 -0
  10. package/dist/nodefony/src/backplane/envelope.js +113 -12
  11. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  12. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  13. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  14. package/dist/nodefony/src/server/RealtimeConnection.js +110 -0
  15. package/dist/nodefony/src/server/RealtimeController.js +344 -85
  16. package/dist/nodefony/src/server/RealtimeHub.js +604 -61
  17. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  18. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  19. package/dist/nodefony/src/server/channelPolicyRegistry.js +73 -0
  20. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  21. package/dist/nodefony/src/server/contractRoutes.js +85 -0
  22. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  23. package/dist/nodefony/src/server/frameAdmission.js +168 -0
  24. package/dist/nodefony/src/server/subscribedAck.js +10 -1
  25. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  26. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  27. package/dist/nodefony/src/service/RealtimeService.js +42 -1
  28. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  30. package/dist/nodefony/testing/backplaneConformance.js +271 -0
  31. package/dist/nodefony/testing/index.js +21 -4
  32. package/dist/types/index.d.ts +34 -7
  33. package/dist/types/nodefony/config/config.d.ts +9 -0
  34. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +86 -6
  35. package/dist/types/nodefony/interfaces/IBackplane.d.ts +34 -50
  36. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  37. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  38. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  39. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  40. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  41. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  42. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  43. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  44. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +79 -16
  45. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +52 -0
  46. package/dist/types/nodefony/src/backplane/envelope.d.ts +34 -0
  47. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  48. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  49. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +17 -0
  50. package/dist/types/nodefony/src/server/RealtimeController.d.ts +93 -13
  51. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +321 -24
  52. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  53. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  54. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +34 -0
  55. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  56. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  57. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  58. package/dist/types/nodefony/src/server/frameAdmission.d.ts +70 -0
  59. package/dist/types/nodefony/src/server/subscribedAck.d.ts +7 -0
  60. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  61. package/dist/types/nodefony/src/service/RealtimeService.d.ts +10 -2
  62. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  63. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  64. package/dist/types/nodefony/testing/backplaneConformance.d.ts +66 -0
  65. package/dist/types/nodefony/testing/index.d.ts +17 -4
  66. package/docs/actions.md +442 -19
  67. package/docs/architecture.md +83 -8
  68. package/docs/cadence-et-contre-pression.md +87 -11
  69. package/docs/canaux-a-membres.md +267 -0
  70. package/docs/cas-d-usage.md +1702 -0
  71. package/docs/configuration.md +137 -17
  72. package/docs/cookbook-chat.md +333 -85
  73. package/docs/index.md +11 -1
  74. package/docs/observabilite.md +71 -14
  75. package/docs/protocole.md +133 -24
  76. package/docs/securite.md +294 -33
  77. package/docs/vocabulaire.md +13 -12
  78. package/package.json +14 -11
@@ -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,267 @@
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
+ **`closeChannel(canal)`** envoie `realtime:closed { reason: "ended" }` à chaque membre, sur tous les
177
+ exemplaires, puis libère le producteur. Les écouteurs du serveur (`listen`) restent inscrits, sur un canal redevenu passif. Le
178
+ hub ne garde **aucune marque « fermé »** : le `subscribe` suivant rejoue la fabrique, qui rend `null`
179
+ pour une instance terminée — le client reçoit `realtime:denied { reason: "unknown" }`. D'où la règle
180
+ d'écriture : **le nom d'un canal à durée de vie porte l'identifiant de son instance**
181
+ (`game:42:a1b2`, pas `game:42`), pour qu'une partie rouverte ne soit jamais confondue avec celle
182
+ qu'on vient de finir.
183
+
184
+ **`channelMembers(canal)`** rend les connexions abonnées par un client — pas les écouteurs ni les
185
+ services du serveur. Chaque poignée porte `userId` (le compte, `null` pour l'anonyme) et `open`.
186
+
187
+ Un geste qui ne trouve rien rend `false` (ou `0`) : connexion non membre, fermée, ou poignée qui ne
188
+ vient pas de ce processus. Jamais un geste muet.
189
+
190
+ ## 🔩 Où c'est fait
191
+
192
+ - Le registre des membres vit dans l'état du canal, rempli à l'abonnement par
193
+ `RealtimeHub.ts#RealtimeHub.subscribeClient` et vidé par `RealtimeHub.ts#RealtimeHub.unsubscribe` ;
194
+ le fan-out (`RealtimeHub.ts#RealtimeHub.publish`) ne le lit jamais.
195
+ - Les cinq gestes : `RealtimeHub.ts#RealtimeHub.channelMembers`,
196
+ `RealtimeHub.ts#RealtimeHub.subscribeConnection`, `RealtimeHub.ts#RealtimeHub.evictConnection`,
197
+ `RealtimeHub.ts#RealtimeHub.evictUser`, `RealtimeHub.ts#RealtimeHub.closeChannel`.
198
+ - Les ordres entre exemplaires, en liste fermée : `controlOrder.ts#readControlOrder` (la même garde
199
+ à l'émission et à la réception).
200
+ - La poignée : `RealtimeConnection.ts#RealtimeConnection`, créée au premier besoin par le contrôleur.
201
+ - Le chemin unique d'une sortie, demandée ou décidée :
202
+ `RealtimeController.ts#RealtimeController.leaveChannel` ; la notification de fin :
203
+ `RealtimeController.ts#RealtimeController.endChannel`.
204
+ - Côté navigateur : `NodefonySocket.ts#NodefonySocket.onClosed`.
205
+
206
+ ## ⚠️ Pièges
207
+
208
+ - **Imposer sans écrire l'appartenance là où `allow` la lit** : l'abonnement tient jusqu'à la
209
+ première coupure réseau, puis le rejeu est refusé. Sur mobile, c'est dans la minute.
210
+ - **Éjecter pour exclure** : une page rechargée refait `subscribe`. Retirer l'appartenance d'abord,
211
+ éjecter ensuite.
212
+ - **Un nom de canal sans identifiant d'instance** (`game:42`) : la partie suivante portera le même
213
+ nom, et un client resté sur l'ancienne y entrera.
214
+ - **Appeler un geste PAR CONNEXION depuis une requête HTTP** en cluster : la connexion est peut-être
215
+ tenue par un autre exemplaire. Passer par la socket, par `allow` (section suivante), ou par un
216
+ geste par NOM (`evictUser`, `closeChannel`), qui atteint tous les exemplaires.
217
+ - **Un bus partagé** (Redis mutualisé) : un tiers qui peut y écrire, même sans le secret, peut
218
+ rejouer un ordre d'éjection capté. Sur un canal durable (`chat:general`), il recouperait le compte
219
+ à chaque retour. L'effet reste une coupure, jamais une entrée ; changer le secret du bus rend
220
+ caducs les ordres déjà captés. **Le bus doit être privé.**
221
+
222
+ ## 🌐 Plusieurs exemplaires (pods, `nodefony cluster`)
223
+
224
+ Une connexion n'existe que dans la mémoire du processus qui a accepté sa socket. Le backplane
225
+ transporte les **publications** et deux **ordres** — terminer un canal, éjecter un compte — que
226
+ chaque exemplaire applique à SES connexions ; ni membres, ni politiques, ni acquittements. Ces deux
227
+ ordres ne font qu'enlever : seul `allow` fait entrer. Livrés « au plus une fois », comme toute
228
+ publication : un exemplaire qui en manque un, ou qui démarre après, est rattrapé par `allow` (pour
229
+ une éjection) ou par la fabrique qui rend `null` à une instance terminée (pour une fin).
230
+
231
+ Les gestes **par connexion** agissent sur les connexions de ce processus. Deux voies marchent en
232
+ cluster pour eux :
233
+
234
+ 1. **Le geste part par la socket** — un gestionnaire `@RealtimeInbound` reçoit la connexion et
235
+ s'exécute, par construction, sur le processus qui la tient.
236
+ 2. **Le serveur autorise, le navigateur s'abonne** — une requête HTTP (routée sur n'importe quel
237
+ exemplaire) écrit l'appartenance dans un registre partagé ; le navigateur fait `subscribe`, et
238
+ `allow` lit ce registre **directement**, sans cache : il n'est appelé qu'une fois par abonnement.
239
+
240
+ Une requête HTTP qui appellerait `subscribeConnection` sur une connexion tenue par un autre
241
+ exemplaire n'a pas de poignée à lui passer : elle ne peut rien faire, et c'est voulu — un geste qui
242
+ marcherait en développement et une fois sur N en production serait le pire des défauts.
243
+
244
+ ## 🧪 Tests & couverture
245
+
246
+ - **Serveur** (`channelMembers.test.ts`) : membres listés et retirés à la fermeture de la socket ;
247
+ éjection (l'éjecté n'est plus servi, les autres si ; il rentre sans `allow`) ; imposition depuis un
248
+ gestionnaire entrant, refus de `allow` rendu au seul appelant, connexion fermée ou désabonnée
249
+ pendant l'attente ; fin du canal, producteur libéré, écouteur conservé, fabrique rejouée.
250
+ - **Entre exemplaires** (`memberOrdersAcrossPods.test.ts`) : fin et éjection d'un compte données sur
251
+ un hub atteignent les membres de l'autre ; canal de contrôle jamais diffusé ; ordres mal formés,
252
+ inventés ou visant un canal de la plateforme rejetés et comptés (`ingressRejectedTotal`). Sur un
253
+ **Redis réel** (`memberOrdersRedis.test.ts`, gate Redis) : ordre scellé appliqué, ordre non scellé
254
+ ignoré, et le rejeu d'une éjection captée — le risque accepté — décrit par un test. Sur le **bus du
255
+ cluster** (`clusterIpc.e2e.test.ts`, `npm run test:cluster`) : deux vrais processus.
256
+ - **Client** (`NodefonySocketClosed.test.ts`) : canal imposé inscrit et rejoué, canal terminé retiré
257
+ pour tous ses consommateurs et jamais rejoué, attente d'acquittement rejetée, trame mal formée
258
+ ignorée.
259
+ - **Trames** (`subscribedAck.test.ts`) : l'acquittement imposé est octet pour octet celui du pair
260
+ JSON-RPC. **Surface figée** (`frozenSurface.test.ts`) : les motifs `ended` et `evicted`.
261
+
262
+ ## 🔗 Pour aller plus loin
263
+
264
+ - ⬆️ **Retour au hub** : [Realtime — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
265
+ - [Sécurité](./securite.md) — `allow`, sa forme classe, son délai et son budget.
266
+ - [Protocole](./protocole.md) — les notifications système de la socket.
267
+ - Décisions : ADR-0022 §6 (canaux privés, décisions 2 à 4), ADR-0021 S4 (fin d'un flux).