@nodefony/realtime 10.0.0-beta.6 → 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 (62) hide show
  1. package/README.md +5 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +15 -3
  4. package/dist/nodefony/decorators/realtimeDecorators.js +231 -39
  5. package/dist/nodefony/src/backplane/RedisBackplane.js +33 -40
  6. package/dist/nodefony/src/backplane/controlOrder.js +34 -5
  7. package/dist/nodefony/src/backplane/envelope.js +422 -45
  8. package/dist/nodefony/src/backplane/ingress.js +93 -0
  9. package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
  10. package/dist/nodefony/src/backplane/publishQueue.js +97 -3
  11. package/dist/nodefony/src/server/RealtimeConnection.js +31 -6
  12. package/dist/nodefony/src/server/RealtimeController.js +87 -44
  13. package/dist/nodefony/src/server/RealtimeHub.js +631 -127
  14. package/dist/nodefony/src/server/admissionBudget.js +8 -8
  15. package/dist/nodefony/src/server/channelPolicyRegistry.js +35 -13
  16. package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
  17. package/dist/nodefony/src/server/channelPresence.js +488 -0
  18. package/dist/nodefony/src/server/contractRoutes.js +3 -13
  19. package/dist/nodefony/src/server/frameAdmission.js +27 -35
  20. package/dist/nodefony/src/server/presenceBus.js +486 -0
  21. package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
  22. package/dist/nodefony/src/server/remotePresence.js +541 -0
  23. package/dist/nodefony/src/server/subscribedAck.js +3 -0
  24. package/dist/nodefony/src/service/RealtimeService.js +9 -0
  25. package/dist/nodefony/testing/backplaneConformance.js +79 -3
  26. package/dist/nodefony/testing/index.js +22 -8
  27. package/dist/nodefony/testing/presenceClient.js +54 -0
  28. package/dist/types/index.d.ts +6 -1
  29. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +117 -16
  30. package/dist/types/nodefony/interfaces/IBackplane.d.ts +12 -1
  31. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +10 -2
  32. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +23 -0
  33. package/dist/types/nodefony/src/backplane/envelope.d.ts +50 -7
  34. package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
  35. package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
  36. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
  37. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +3 -0
  38. package/dist/types/nodefony/src/server/RealtimeController.d.ts +19 -1
  39. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +139 -30
  40. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +9 -2
  41. package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
  42. package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
  43. package/dist/types/nodefony/src/server/contractRoutes.d.ts +1 -1
  44. package/dist/types/nodefony/src/server/frameAdmission.d.ts +14 -18
  45. package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
  46. package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
  47. package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
  48. package/dist/types/nodefony/src/server/subscribedAck.d.ts +3 -0
  49. package/dist/types/nodefony/src/service/RealtimeService.d.ts +8 -1
  50. package/dist/types/nodefony/testing/backplaneConformance.d.ts +14 -2
  51. package/dist/types/nodefony/testing/index.d.ts +1 -1
  52. package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
  53. package/docs/actions.md +1 -0
  54. package/docs/architecture.md +21 -6
  55. package/docs/canaux-a-membres.md +13 -3
  56. package/docs/cas-d-usage.md +4 -3
  57. package/docs/configuration.md +38 -2
  58. package/docs/cookbook-chat.md +400 -152
  59. package/docs/observabilite.md +28 -9
  60. package/docs/protocole.md +10 -10
  61. package/docs/securite.md +64 -10
  62. package/package.json +12 -12
@@ -77,7 +77,7 @@ apporte.
77
77
  | --------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------- |
78
78
  | [1 — le chat minimal](#-démarrage-rapide--étape-1-le-chat-minimal-qui-marche) | Un canal, un message qui traverse | Deux onglets qui se parlent |
79
79
  | [2 — HTTP et WebSocket](#-étape-2--le-même-contrôleur-sert-http-et-websocket) | L'historique en `GET` dans la **même classe** | Une action, deux transports |
80
- | [3 — présence et frappe](#-étape-3--présence-et-frappe-en-cours) | Deux canaux de plus sur la **même** connexion | Multiplexage sans socket supplémentaire |
80
+ | [3 — présence et frappe](#-étape-3--présence-et-frappe-en-cours) | La présence en option du canal, la frappe en message | Ni canal ni minuteur de plus |
81
81
  | [4 — persister](#-étape-4--persister-lhistorique) | Un repository derrière un service | L'historique survit au redémarrage |
82
82
  | [5 — protéger](#-étape-5--protéger-le-salon) | Zone firewall + politique de canal | L'intrus est refusé, et sait qu'il l'est |
83
83
  | [6 — la pièce jointe](#-étape-6--la-pièce-jointe-http-et-websocket-dans-la-même-classe) | `POST` multipart + annonce sur le canal | Le différenciateur devient **visible** |
@@ -92,23 +92,23 @@ apporte.
92
92
  Tous les termes employés dans cette page. Le fond de chacun est dans
93
93
  [vocabulaire.md](./vocabulaire.md) — ici, la définition strictement suffisante pour suivre.
94
94
 
95
- | Terme | Ce que c'est |
96
- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
97
- | **Socket** | La connexion WebSocket unique entre un navigateur et le serveur. Une seule par onglet, quel que soit le nombre de canaux. |
98
- | **Canal** | Un flux nommé qui circule **dans** la socket (`chat:room`, `chat:presence`). Analogie : la socket est le câble, les canaux sont les fréquences qui y passent. |
99
- | **Frame** | Un message du protocole sur le fil. Toutes les frames sont du JSON-RPC 2.0. |
100
- | **Hub** | Le distributeur côté serveur : il tient la liste « qui écoute quel canal » et recopie chaque publication vers les abonnés du processus courant. |
101
- | **Backplane** | Le « fond de panier » : ce qui relaie les publications d'un processus à l'autre. Sans lui, chaque réplique est une île. |
102
- | **Pod / réplique** | Un processus Node qui sert l'application. Un seul en développement, plusieurs en production. |
103
- | **Fan-out** | La recopie d'une publication vers N abonnés. Une publication, N livraisons. |
104
- | **Inbound** | Un canal où le **client** pousse vers le serveur. Sens inverse du pub/sub habituel ; il faut le déclarer explicitement. |
105
- | **RPC** | _Remote Procedure Call_ : le client appelle une méthode serveur et attend une réponse (une `Promise`). Par opposition à une notification, qui ne répond pas. |
106
- | **Provider** | La fonction qui « alimente » un canal côté serveur. Créée au premier abonné, détruite au dernier. |
107
- | **Sink** | Le point de sortie d'une connexion sur un canal. Un abonné = un sink. |
108
- | **BFF** | _Backend For Frontend_ : le serveur porte la session dans un cookie, le navigateur ne manipule aucun jeton. |
109
- | **Zone / area** | Un motif d'URL que le firewall protège, avec la liste des façons de prouver son identité. |
110
- | **Politique** | Les exigences attachées à un canal : authentifié, tel rôle, tel scope. |
111
- | **Multipart** | L'encodage d'un formulaire HTTP qui transporte des fichiers (`multipart/form-data`). |
95
+ | Terme | Ce que c'est |
96
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
97
+ | **Socket** | La connexion WebSocket unique entre un navigateur et le serveur. Une seule par onglet, quel que soit le nombre de canaux. |
98
+ | **Canal** | Un flux nommé qui circule **dans** la socket (`chat:room`, `room:42`). Analogie : la socket est le câble, les canaux sont les fréquences qui y passent. |
99
+ | **Frame** | Un message du protocole sur le fil. Toutes les frames sont du JSON-RPC 2.0. |
100
+ | **Hub** | Le distributeur côté serveur : il tient la liste « qui écoute quel canal » et recopie chaque publication vers les abonnés du processus courant. |
101
+ | **Backplane** | Le « fond de panier » : ce qui relaie les publications d'un processus à l'autre. Sans lui, chaque réplique est une île. |
102
+ | **Pod / réplique** | Un processus Node qui sert l'application. Un seul en développement, plusieurs en production. |
103
+ | **Fan-out** | La recopie d'une publication vers N abonnés. Une publication, N livraisons. |
104
+ | **Inbound** | Un canal où le **client** pousse vers le serveur. Sens inverse du pub/sub habituel ; il faut le déclarer explicitement. |
105
+ | **RPC** | _Remote Procedure Call_ : le client appelle une méthode serveur et attend une réponse (une `Promise`). Par opposition à une notification, qui ne répond pas. |
106
+ | **Provider** | La fonction qui « alimente » un canal côté serveur. Créée au premier abonné, détruite au dernier. |
107
+ | **Sink** | Le point de sortie d'une connexion sur un canal. Un abonné = un sink. |
108
+ | **BFF** | _Backend For Frontend_ : le serveur porte la session dans un cookie, le navigateur ne manipule aucun jeton. |
109
+ | **Zone / area** | Un motif d'URL que le firewall protège, avec la liste des façons de prouver son identité. |
110
+ | **Politique** | Les exigences attachées à un canal : authentifié, tel rôle, tel scope. |
111
+ | **Multipart** | L'encodage d'un formulaire HTTP qui transporte des fichiers (`multipart/form-data`). |
112
112
 
113
113
  ## 🧠 Le modèle mental en trois phrases
114
114
 
@@ -630,102 +630,324 @@ s'applique aux **deux** — c'est le même pipeline, pas deux implémentations
630
630
 
631
631
  ## 🧩 Étape 3 : présence et frappe en cours
632
632
 
633
- On ajoute deux flux : qui est là, et qui est en train d'écrire. Le point de cette étape n'est pas
634
- la fonctionnalité — c'est **ce qu'elle ne coûte pas**.
633
+ On ajoute deux choses : qui est là, et qui est en train d'écrire. Le point de cette étape n'est pas
634
+ la fonctionnalité — c'est **ce qu'elle ne coûte pas** : pas un canal de plus, pas un minuteur, pas
635
+ une ligne de code par pod.
635
636
 
636
- ### Trois canaux, une seule connexion
637
+ ### La présence est une option du canal, pas un canal de plus
637
638
 
638
- Un salon complet a besoin d'au moins trois flux distincts. Dans beaucoup d'architectures, cela se
639
- traduit par plusieurs connexions, ou par un canal fourre-tout où chacun filtre ce qui le concerne.
640
- Ici, les trois canaux voyagent **dans la même socket**, chacun avec son nom, chacun avec son
641
- abonnement propre :
639
+ Être abonné au salon, c'est y être présent. La présence se déclare donc **sur le canal lui-même**,
640
+ par une option de sa politique — `presence: true` — et le framework fait le reste : il tient la liste
641
+ des présents, l'envoie entière à chaque abonné qui arrive, puis n'envoie que les changements,
642
+ regroupés par fenêtre de 250 ms. Une connexion qui se ferme s'en va aussitôt de la liste.
642
643
 
643
644
  ```mermaid
644
645
  flowchart LR
645
646
  N["Onglet du navigateur<br/>UNE connexion WebSocket"]
646
- N -.->|"chat:room"| R[Messages]
647
- N -.->|"chat:presence"| P[Qui est connecté]
648
- N -.->|"chat:typing"| T[Qui écrit]
647
+ N -.->|"chat:room"| C["Le canal du salon"]
648
+ C --> M[Messages]
649
+ C --> T["« Untel écrit… »"]
650
+ C --> P["Qui est là<br/>(option presence)"]
649
651
  ```
650
652
 
651
- S'abonner à un quatrième canal n'ouvre pas de quatrième connexion : cela envoie une frame
652
- d'abonnement sur celle qui existe déjà. C'est ce que « multiplexer » veut dire ici, et c'est ce qui
653
- rend viable le fait d'avoir un canal par préoccupation plutôt qu'un canal fourre-tout.
653
+ > [!WARNING]
654
+ > **La recette « un `Set` des connectés publié toutes les cinq secondes » est fausse dès deux
655
+ > processus.** Chaque processus ne connaît que les connexions qu'il tient : derrière
656
+ > `nodefony cluster -w 2` ou deux pods, chacun publie SA liste et écrase celle de l'autre. Elle coûte
657
+ > en plus un envoi permanent, et annonce les départs jusqu'à cinq secondes en retard. La présence du
658
+ > framework est tenue **entre les pods** (étape 7) : rien à écrire de plus.
659
+
660
+ ### La frappe est un message, pas un état
661
+
662
+ « Untel écrit » ne dit pas qui est là : c'est un message éphémère, publié sur le canal du salon,
663
+ que chacun reçoit parce qu'il y est abonné et que l'écran efface seul. Il ne passe jamais par la
664
+ présence — sinon chaque frappe ferait renvoyer la liste à tout le salon.
665
+
666
+ > [!IMPORTANT]
667
+ > **À cette étape, l'auteur de la frappe vient du client** — comme le pseudo du message de l'étape 1,
668
+ > et pour la même raison : il n'y a encore ni comptes ni salons, un seul salon ouvert à tous. Ce
669
+ > n'est pas la forme finale. Dès l'[étape 5](#-étape-5--protéger-le-salon), l'auteur vient de la
670
+ > **connexion** (`this.user`), jamais d'un champ : un « Untel écrit » forgé usurpe aussi bien qu'un
671
+ > message (voir le [contrôleur complet](#-le-contrôleur-complet)).
654
672
 
655
673
  ### Le contrôleur — fichier **modifié**
656
674
 
657
- ```ts ignore
658
- // modules/chat/nodefony/controllers/ChatController.ts (ajouts de l'étape 3)
675
+ Le contrôleur entier à cette étape ; l'envoi de l'étape 1 (`chat:send`) ne change pas et n'est pas
676
+ répété.
659
677
 
660
- /** Les pseudos présents, par connexion. Statique : partagé par tout le processus. */
661
- static present = new Set<string>();
678
+ <!-- example: chatPresence.ts -->
662
679
 
663
- /**
664
- * PRÉSENCE. Ce provider-là a quelque chose à démarrer : un rappel régulier de
665
- * l'état. La fonction de nettoyage rendue au hub arrête le minuteur — au dernier
666
- * désabonné, plus personne ne paie ce coût.
667
- */
668
- @RealtimeChannel("chat:presence")
669
- presence(channel: string, publish: RealtimePublish): () => void {
670
- const timer = setInterval(() => {
671
- publish(channel, { present: [...ChatController.present] });
672
- }, 5000);
673
- // `unref` : ce minuteur ne doit jamais retenir le processus à l'arrêt.
674
- timer.unref();
675
- return () => clearInterval(timer);
680
+ ```ts
681
+ import { controller, route } from "@nodefony/framework";
682
+ import type { ContextType } from "@nodefony/http";
683
+ import {
684
+ RealtimeChannel,
685
+ RealtimeController,
686
+ RealtimeInbound,
687
+ getRealtimeHub,
688
+ } from "@nodefony/realtime";
689
+
690
+ /** Le canal du salon : il porte les messages, la frappe — et la présence. */
691
+ export const ROOM_CHANNEL = "chat:room";
692
+
693
+ /** « Untel écrit » : un message ÉPHÉMÈRE du canal, jamais gardé, jamais rejoué. */
694
+ export interface ChatTyping {
695
+ typing: string;
676
696
  }
677
697
 
678
- /** FRAPPE EN COURS. Volatile par nature : rien à persister, rien à rejouer. */
679
- @RealtimeChannel("chat:typing")
680
- typing(_channel: string, _publish: RealtimePublish): () => void {
681
- return () => {};
698
+ @controller("/chat")
699
+ export class ChatController extends RealtimeController {
700
+ constructor(context: ContextType) {
701
+ super("chat", context);
702
+ }
703
+
704
+ @route("chat-realtime", {
705
+ path: "/realtime",
706
+ requirements: { methods: ["WEBSOCKET"] },
707
+ })
708
+ async realtime(message: string | Uint8Array | null): Promise<void> {
709
+ this.handleRealtime(message);
710
+ }
711
+
712
+ // La présence est portée PAR le canal : être abonné, c'est être présent. Ni
713
+ // minuteur, ni liste à tenir, ni second canal — et elle reste juste quand
714
+ // l'application tourne sur plusieurs pods (étape 7).
715
+ @RealtimeChannel(ROOM_CHANNEL, { presence: true })
716
+ room(): () => void {
717
+ return () => {};
718
+ }
719
+
720
+ // La frappe n'est PAS un état de présence : c'est un message du canal, que
721
+ // chacun reçoit parce qu'il y est abonné, et que l'écran efface seul.
722
+ @RealtimeInbound("chat:typing")
723
+ onTyping(params: unknown): void {
724
+ if (!ChatController.isTyping(params)) return;
725
+ const typing: ChatTyping = { typing: params.author.slice(0, 40) };
726
+ getRealtimeHub().publish(ROOM_CHANNEL, typing);
727
+ }
728
+
729
+ /** Ce qui arrive du réseau n'est pas fiable : un auteur non vide, ou rien. */
730
+ private static isTyping(value: unknown): value is { author: string } {
731
+ return (
732
+ typeof value === "object" &&
733
+ value !== null &&
734
+ "author" in value &&
735
+ typeof value.author === "string" &&
736
+ value.author.length > 0
737
+ );
738
+ }
682
739
  }
740
+ ```
741
+
742
+ ### Côté client
743
+
744
+ `onPresence` rappelle l'écran avec **la vue entière** à chaque changement : il n'y a rien à
745
+ fusionner soi-même. En React, `useNodefonyPresence(chatSocket, "chat:room")` rend le même
746
+ instantané (Vue, Angular et Svelte ont leur équivalent).
683
747
 
684
- /** Le client signale qu'il écrit ; on le relaie tel quel au salon. */
685
- @RealtimeInbound("chat:typing")
686
- onTyping(params: unknown): void {
687
- const author = (params as { author?: unknown } | null)?.author;
688
- if (typeof author !== "string" || author.length === 0) return;
689
- getRealtimeHub().publish("chat:typing", { author: author.slice(0, 40) });
748
+ <!-- example: chatPresence.client.ts -->
749
+
750
+ ```ts
751
+ import { NodefonySocket } from "nodefony/client";
752
+
753
+ const ROOM = "chat:room";
754
+ const socket = NodefonySocket.shared({ url: "/chat/realtime" });
755
+
756
+ /** Qui est là : rappelé à chaque changement, la vue entière à chaque fois. */
757
+ export function watchPresence(render: (line: string) => void): () => void {
758
+ const off = socket.onPresence(ROOM, (p) => {
759
+ if (!p.ready) return; // « pas encore reçu » n'est pas « vide »
760
+ const names = [...p.members.keys()].join(", ");
761
+ const total = p.count + p.unnamed; // nommés (comptes) + connexions anonymes
762
+ render(`${total} présent(s)${names === "" ? "" : ` : ${names}`}`);
763
+ });
764
+ socket.subscribe(ROOM);
765
+ return () => {
766
+ off();
767
+ socket.unsubscribe(ROOM);
768
+ };
769
+ }
770
+
771
+ /** Signaler qu'on écrit — au plus une fois toutes les deux secondes. */
772
+ export function typingNotifier(author: string): () => void {
773
+ let last = 0;
774
+ return () => {
775
+ const now = Date.now();
776
+ if (now - last < 2000) return;
777
+ last = now;
778
+ socket.emit("chat:typing", { author });
779
+ };
780
+ }
781
+
782
+ /** « Untel écrit… » : affiché à réception, effacé seul trois secondes plus tard. */
783
+ export function watchTyping(
784
+ self: string,
785
+ show: (author: string | null) => void,
786
+ ): () => void {
787
+ let timer: ReturnType<typeof setTimeout> | undefined;
788
+ return socket.on(ROOM, (frame) => {
789
+ if (typeof frame !== "object" || frame === null || !("typing" in frame))
790
+ return;
791
+ if (typeof frame.typing !== "string" || frame.typing === self) return;
792
+ show(frame.typing);
793
+ clearTimeout(timer);
794
+ timer = setTimeout(() => show(null), 3000);
795
+ });
690
796
  }
691
797
  ```
692
798
 
693
- > [!NOTE]
694
- > **Un canal peut porter les deux sens.** `chat:typing` est déclaré à la fois comme canal sortant
695
- > (`@RealtimeChannel`) et comme canal entrant (`@RealtimeInbound`). C'est ce qu'on appelle un canal
696
- > **duplex** : le client y pousse, et le reçoit en retour parce qu'il y est abonné.
799
+ | Champ de l'instantané | Ce qu'il dit |
800
+ | --------------------- | ------------------------------------------------------------------------------------------------------------- |
801
+ | `ready` | `false` tant que la première liste n'est pas arrivée — « pas encore reçu » n'est pas « vide » |
802
+ | `count` | les présents **nommés** : un par compte, quel que soit son nombre d'onglets |
803
+ | `unnamed` | les connexions comptées **sans nom** : anonymes, appelants machine sans compte |
804
+ | `members` | `Map` identité → `{ tabs }` : qui, et dans combien d'onglets |
805
+ | `partial` | `true` quand la vue est incomplète ou figée (connexion coupée, pod injoignable) — la liste suivante la répare |
697
806
 
698
- ### Côté client
807
+ ### À cette étape, personne n'est nommé
699
808
 
700
- ```tsx ignore
701
- // frontend/src/ChatRoom.tsx (ajouts de l'étape 3)
702
- import { useNodefonyChannelData } from "nodefony/react";
703
-
704
- // La DERNIÈRE valeur reçue suffit pour un état : on ne veut pas l'historique
705
- // des présences, seulement l'état courant.
706
- const presence = useNodefonyChannelData<{ present: string[] }>("chat:presence");
707
-
708
- const [typingBy, setTypingBy] = useState<string | null>(null);
709
- useNodefonyChannel("chat:typing", (payload) => {
710
- const author = (payload as { author: string }).author;
711
- if (author === pseudo) return; // ne pas s'afficher soi-même
712
- setTypingBy(author);
713
- // L'indicateur s'efface tout seul : le serveur n'envoie jamais de « stop ».
714
- window.setTimeout(() => setTypingBy(null), 2500);
715
- });
809
+ Tout le monde est encore anonyme : la présence **compte** les connexions (`unnamed`), elle ne nomme
810
+ personne. Et le pseudo que le client envoie avec son message n'y change rien — se déclarer « Alice »
811
+ ne fait pas de toi une présente nommée. Les noms arrivent à l'[étape 5](#-étape-5--protéger-le-salon),
812
+ avec les comptes : la présence nomme ce que le serveur a vérifié, jamais ce que le client affirme.
813
+
814
+ ### Le test — fichier **créé**
815
+
816
+ Le test joue des connexions réelles contre le contrôleur, et lit ce que **l'écran** de chacune
817
+ afficherait : les trames de présence reçues, appliquées par la même vue que le navigateur.
818
+
819
+ <!-- example: chatPresence.test.ts -->
820
+
821
+ ```ts
822
+ import { afterEach, describe, expect, it } from "vitest";
823
+ import {
824
+ PresenceView,
825
+ REALTIME_PRESENCE_METHOD,
826
+ isPresenceFrame,
827
+ } from "nodefony";
828
+ import type { IPresenceSnapshot } from "nodefony";
829
+ import { getRealtimeHub } from "@nodefony/realtime";
830
+ import { createRealtimeHarness } from "@nodefony/realtime/testing";
831
+ import type { IRealtimeHarness } from "@nodefony/realtime/testing";
832
+ import { createTestIdentity } from "@nodefony/security/testing";
833
+ import { ChatController, ROOM_CHANNEL } from "./chatPresence";
834
+
835
+ const open: IRealtimeHarness<ChatController>[] = [];
836
+ const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
837
+
838
+ /** Une connexion : un compte (`name`), ou un anonyme. */
839
+ async function connect(
840
+ name?: string,
841
+ ): Promise<IRealtimeHarness<ChatController>> {
842
+ const h = createRealtimeHarness((ctx) => new ChatController(ctx), {
843
+ ...(name === undefined
844
+ ? {}
845
+ : { identity: createTestIdentity({ identifier: name }) }),
846
+ resetHub: open.length === 0,
847
+ });
848
+ await h.connect();
849
+ open.push(h);
850
+ return h;
851
+ }
852
+
853
+ /** Ce que l'écran de cette connexion affiche : ses trames de présence, appliquées comme le fait le navigateur. */
854
+ function screenOf(h: IRealtimeHarness<ChatController>): IPresenceSnapshot {
855
+ const view = new PresenceView(ROOM_CHANNEL);
856
+ for (const frame of h.received) {
857
+ if (
858
+ frame.method === REALTIME_PRESENCE_METHOD &&
859
+ isPresenceFrame(frame.params)
860
+ )
861
+ view.apply(frame.params);
862
+ }
863
+ return view.snapshot();
864
+ }
716
865
 
717
- // Dans le rendu :
718
- // <p>{presence?.present.length ?? 0} connecté(s)</p>
719
- // {typingBy && <p>{typingBy} est en train d'écrire…</p>}
866
+ /** Attendre un FAIT, borné : les changements partent groupés, par fenêtre de 250 ms. */
867
+ async function until(fact: () => boolean): Promise<void> {
868
+ for (let i = 0; i < 100 && !fact(); i++)
869
+ await new Promise((r) => setTimeout(r, 25));
870
+ expect(fact()).toBe(true);
871
+ }
872
+
873
+ describe("salon — la présence portée par le canal, la frappe en message éphémère", () => {
874
+ afterEach(() => {
875
+ for (const h of open.splice(0)) h.close();
876
+ getRealtimeHub().clear();
877
+ });
878
+
879
+ it("être abonné, c'est être présent : un compte est nommé, un anonyme compté sans nom", async () => {
880
+ const alice = await connect("alice");
881
+ const guest = await connect();
882
+ await alice.subscribe(ROOM_CHANNEL);
883
+ await guest.subscribe(ROOM_CHANNEL);
884
+ await flush();
885
+ // Le dernier arrivé reçoit la liste complète dès son abonnement.
886
+ const seen = screenOf(guest);
887
+ expect(seen.ready).toBe(true);
888
+ expect([...seen.members.keys()]).toEqual(["alice"]);
889
+ expect([seen.count, seen.unnamed]).toEqual([1, 1]);
890
+ });
891
+
892
+ it("se déclarer « bob » ne nomme personne : seul un compte est nommé", async () => {
893
+ const guest = await connect();
894
+ await guest.subscribe(ROOM_CHANNEL);
895
+ await guest.notify("chat:typing", { author: "bob" });
896
+ await flush();
897
+ expect(getRealtimeHub().presence(ROOM_CHANNEL)).toMatchObject({
898
+ count: 0,
899
+ unnamed: 1,
900
+ });
901
+ });
902
+
903
+ it("la frappe arrive comme un message du canal, et ne touche pas la présence", async () => {
904
+ const alice = await connect("alice");
905
+ const bob = await connect("bob");
906
+ await alice.subscribe(ROOM_CHANNEL);
907
+ await bob.subscribe(ROOM_CHANNEL);
908
+ await bob.notify("chat:typing", { author: "bob" });
909
+ await flush();
910
+ expect(alice.messages(ROOM_CHANNEL)).toEqual([{ typing: "bob" }]);
911
+ expect(getRealtimeHub().presence(ROOM_CHANNEL)?.members.get("bob")).toEqual(
912
+ {
913
+ tabs: 1,
914
+ },
915
+ );
916
+ });
917
+
918
+ it("connexion fermée : le présent s'en va, chez le serveur PUIS sur l'écran des autres", async () => {
919
+ const alice = await connect("alice");
920
+ const bob = await connect("bob");
921
+ await alice.subscribe(ROOM_CHANNEL);
922
+ await bob.subscribe(ROOM_CHANNEL);
923
+ await until(() => screenOf(alice).members.has("bob"));
924
+ bob.close();
925
+ await flush();
926
+ expect(getRealtimeHub().presence(ROOM_CHANNEL)?.members.has("bob")).toBe(
927
+ false,
928
+ );
929
+ await until(() => !screenOf(alice).members.has("bob"));
930
+ expect(screenOf(alice).count).toBe(1);
931
+ });
932
+ });
720
933
  ```
721
934
 
722
935
  ### Ce qu'on observe
723
936
 
724
- | Tu fais | Tu vois |
725
- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
726
- | Tu tapes dans l'onglet gauche | « invité-412 est en train d'écrire… » apparaît dans l'onglet droit, puis disparaît seul. |
727
- | Tu ouvres l'onglet réseau du navigateur | **Une seule** entrée WebSocket, même avec trois canaux actifs. C'est le point de cette étape. |
728
- | Tu quittes l'écran du salon | Les trois abonnements sont relâchés. Au dernier abonné de `chat:presence`, le minuteur serveur est arrêté. |
937
+ | Tu fais | Tu vois |
938
+ | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
939
+ | Tu ouvres deux onglets | « 2 présent(s) » dans chacun, sans nom : ce sont deux anonymes. |
940
+ | Tu tapes dans l'onglet gauche | « invité-412 est en train d'écrire… » apparaît dans l'onglet droit, puis disparaît seul. La liste des présents ne bouge pas. |
941
+ | Tu fermes l'onglet gauche | L'onglet droit passe à « 1 présent(s) » dans le quart de seconde. |
942
+ | Tu ouvres l'onglet réseau du navigateur | **Une seule** entrée WebSocket et **un seul** canal : messages, frappe et présence voyagent ensemble. |
943
+
944
+ ### Le même modèle que les normes
945
+
946
+ La présence suit le modèle des protocoles de présence normalisés, pour qu'une passerelle reste
947
+ possible : un **présent** est l'_occupant_ d'un salon de XEP-0045 ; la liste entière puis les
948
+ changements sont la _full_ puis la _partial presence_ de RFC 5262 ; un abonnement répété redemande
949
+ la liste entière, comme XEP-0045 §17.3 ; et « en train d'écrire » reste hors de la présence, comme
950
+ le veut RFC 6121 §4.4.1. Le détail des choix est dans l'ADR-0025 (présence répartie d'un canal).
729
951
 
730
952
  ## 💾 Étape 4 : persister l'historique
731
953
 
@@ -830,26 +1052,25 @@ export const securityConfig = () =>
830
1052
 
831
1053
  Le décorateur de canal accepte un second argument : les exigences à satisfaire pour s'y abonner.
832
1054
 
833
- Les deux méthodes concernées, complètes, avec leur politique :
1055
+ La déclaration du salon, complète, avec sa politique :
834
1056
 
835
1057
  ```ts ignore
836
1058
  // modules/chat/nodefony/controllers/ChatController.ts
837
- // → remplace les déclarations de canal des étapes 1 et 3
838
- @RealtimeChannel(ROOM_CHANNEL, { authenticated: true, roles: ["ROLE_USER"] })
839
- room(_channel: string, _publish: RealtimePublish): () => void {
1059
+ // → remplace la déclaration de canal de l'étape 3
1060
+ @RealtimeChannel(ROOM_CHANNEL, {
1061
+ authenticated: true,
1062
+ roles: ["ROLE_USER"],
1063
+ presence: true,
1064
+ })
1065
+ room(): () => void {
840
1066
  return () => {};
841
1067
  }
842
-
843
- @RealtimeChannel("chat:presence", { authenticated: true, roles: ["ROLE_USER"] })
844
- presence(channel: string, publish: RealtimePublish): () => void {
845
- const timer = setInterval(() => {
846
- publish(channel, { present: [...ChatController.present] });
847
- }, 5000);
848
- timer.unref();
849
- return () => clearInterval(timer);
850
- }
851
1068
  ```
852
1069
 
1070
+ La présence suit la politique : n'est présent que celui qu'elle admet. Et puisque chacun est
1071
+ désormais connecté à son compte, la liste **nomme** les présents — par l'identité vérifiée à la
1072
+ poignée de main, jamais par un champ du client.
1073
+
853
1074
  Le module temps réel ne décide de rien : il **transporte la déclaration** jusqu'au firewall, seul à
854
1075
  connaître la hiérarchie des rôles et l'identité réelle.
855
1076
 
@@ -926,8 +1147,11 @@ await socket.post("/chat/send", { text });
926
1147
  > **La règle à retenir.** Canal inbound = flux volatil, à haute fréquence, dont l'expéditeur importe
927
1148
  > peu (« untel écrit »). Action de contrôleur = tout ce qui doit être **attribué, autorisé ou
928
1149
  > conservé**. Le chat de l'étape 1 utilisait un canal inbound par simplicité ; dès qu'il y a des
929
- > comptes, l'envoi devient une action. La frappe en cours, elle, reste un canal inbound — son auteur
930
- > n'a pas besoin d'être prouvé.
1150
+ > comptes, l'envoi devient une action. La frappe en cours, elle, reste un canal inbound : rien n'y
1151
+ > est conservé. Mais son auteur se lit sur la **connexion** (`this.user`), plus dans les paramètres
1152
+ > — sinon n'importe qui ferait écrire « Alice ». Avec **plusieurs salons à admission**, un canal
1153
+ > inbound ne sait pas dans quel salon la connexion est admise : la frappe devient alors une route,
1154
+ > qui relit l'admission du salon comme l'envoi (c'est ce que fait la démo de `create app`).
931
1155
 
932
1156
  Côté navigateur, l'identité annoncée par le serveur se lit sans appeler la moindre route :
933
1157
 
@@ -966,11 +1190,12 @@ export const SendBody = z.object({
966
1190
  to: z.string().optional(),
967
1191
  });
968
1192
 
969
- /** La query de l'historique — une query arrive en texte, `coerce` la convertit. */
1193
+ /**
1194
+ * La query de l'historique — une query arrive en texte, `coerce` la convertit.
1195
+ * Pas de `since` sur un instant : voir « La reconnexion », plus bas.
1196
+ */
970
1197
  export const HistoryQuery = z.object({
971
1198
  limit: z.coerce.number().int().min(1).max(200).default(50),
972
- /** Rattrapage après une coupure : les messages postérieurs à cet instant. */
973
- since: z.coerce.number().optional(),
974
1199
  });
975
1200
  ```
976
1201
 
@@ -1370,6 +1595,21 @@ use("@nodefony/realtime", {
1370
1595
  });
1371
1596
  ```
1372
1597
 
1598
+ ### Ce que le relais ne partage pas : l'état
1599
+
1600
+ Le backplane transporte des **trames**, rien d'autre. Ce que l'application **garde** — l'historique,
1601
+ les pièces jointes, la liste des salons — ne traverse pas avec lui : chaque pod lit et écrit le sien.
1602
+ Plusieurs pods exigent donc un état **partagé** :
1603
+
1604
+ - **la base** : PostgreSQL ou MySQL, que tous les pods joignent. Un fichier SQLite vit sur le disque
1605
+ d'UNE machine : c'est le mode d'un poste de développement ou d'une démo, pas d'une application
1606
+ répartie ;
1607
+ - **les fichiers** : un stockage partagé (volume réseau, stockage objet), jamais le disque local
1608
+ d'un pod — une pièce reçue par l'un serait introuvable depuis l'autre.
1609
+
1610
+ La démo de `create app --demo` tient sur un seul pod, pour cette raison : sa base est un fichier
1611
+ SQLite et ses pièces restent sur le disque du serveur.
1612
+
1373
1613
  ### Ce qui n'a pas bougé
1374
1614
 
1375
1615
  C'est l'argument, et il se vérifie fichier par fichier :
@@ -1382,6 +1622,10 @@ C'est l'argument, et il se vérifie fichier par fichier :
1382
1622
  | `ChatRoom.tsx`, `realtime.ts` | **Non.** Le navigateur ne sait pas, et n'a pas à savoir, combien de pods existent. |
1383
1623
  | `nodefony.config.ts` | Une chaîne de caractères. |
1384
1624
 
1625
+ La présence suit le même chemin : un canal diffusé partage ses présents entre les pods, sans une
1626
+ ligne de plus. Un canal à présence **non** diffusé reste juste, mais local à son processus — le
1627
+ journal le signale une fois, au premier abonné du canal, dès qu'un bus est configuré.
1628
+
1385
1629
  Aucun appel de publication ne change, aucun abonnement ne change, aucune règle métier ne change. Ce
1386
1630
  qui change, c'est **par où** la publication repart après le fan-out local — et cela se décide dans la
1387
1631
  configuration, pas dans le code.
@@ -1411,11 +1655,14 @@ Au démarrage de chaque worker, la ligne d'identité du temps réel change de fo
1411
1655
  `kind=local cross-pod=no`, on lit maintenant un `kind` de transport et `cross-pod=yes` : c'est la
1412
1656
  confirmation, au boot, que le relais est en place.
1413
1657
 
1414
- | Tu fais | Avant (étape 6) | Maintenant |
1415
- | --------------------------- | ---------------- | ------------------------------------------------------ |
1416
- | Alice envoie un fichier | Bob ne voit rien | L'annonce apparaît chez Bob, sur l'autre processus |
1417
- | Alice tape un message | Aléatoire | Reçu par tous, quel que soit le worker |
1418
- | Tu arrêtes le service Redis | — | Le salon continue **dans chaque processus**, isolément |
1658
+ | Tu fais | Avant (étape 6) | Maintenant |
1659
+ | ------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
1660
+ | Alice envoie un fichier | Bob ne voit rien | L'annonce apparaît chez Bob, sur l'autre processus |
1661
+ | Alice tape un message | Aléatoire | Reçu par tous, quel que soit le worker |
1662
+ | Alice et Bob, un worker chacun | Chacun ne voit que lui présent | Les deux se voient présents ; un onglet sur chaque worker compte pour un seul présent |
1663
+ | Tu arrêtes un worker proprement | — | Ses présents disparaissent chez les autres aussitôt : il l'annonce en partant |
1664
+ | Tu tues un worker (`kill -9`) | — | Ses présents disparaissent chez les autres au délai d'oubli : trois preuves de vie manquées, 15 s par défaut |
1665
+ | Tu arrêtes le service Redis | — | Le salon continue **dans chaque processus**, isolément ; la présence venue des autres pods se fige, marquée `partial` |
1419
1666
 
1420
1667
  Cette dernière ligne mérite une explication : si le backplane est injoignable au démarrage, le
1421
1668
  framework **ne bloque pas le boot**. Il journalise un avertissement, garde un hub local et sert le
@@ -1456,7 +1703,6 @@ import {
1456
1703
  RealtimeInbound,
1457
1704
  getRealtimeHub,
1458
1705
  } from "@nodefony/realtime";
1459
- import type { RealtimePublish } from "@nodefony/realtime";
1460
1706
  import { HttpError } from "@nodefony/http";
1461
1707
  import type { ContextType, IUploadedFile } from "@nodefony/http";
1462
1708
  import type { ContractRouteSchemas } from "nodefony";
@@ -1486,9 +1732,6 @@ class ChatController extends ContractRealtimeController<ChatContract> {
1486
1732
  /** Stock en mémoire — remplacé par un repository à l'étape 4. */
1487
1733
  static history: ChatMessage[] = [];
1488
1734
 
1489
- /** Les pseudos présents dans le salon, sur CE processus. */
1490
- static present = new Set<string>();
1491
-
1492
1735
  constructor(context: ContextType) {
1493
1736
  super("chat", context);
1494
1737
  }
@@ -1519,10 +1762,7 @@ class ChatController extends ContractRealtimeController<ChatContract> {
1519
1762
  historyAction(
1520
1763
  input: RouteInput<{ query: typeof HistoryQuery }>,
1521
1764
  ): ChatMessage[] {
1522
- const { limit, since } = input.query;
1523
- return ChatController.history
1524
- .filter((m) => since === undefined || m.sentAt > since)
1525
- .slice(-limit);
1765
+ return ChatController.history.slice(-input.query.limit);
1526
1766
  }
1527
1767
 
1528
1768
  /** Envoi — au salon, ou en privé à une personne ; l'auteur vient du serveur. */
@@ -1583,41 +1823,29 @@ class ChatController extends ContractRealtimeController<ChatContract> {
1583
1823
 
1584
1824
  // ── Les canaux ────────────────────────────────────────────────────────────
1585
1825
 
1586
- /** Le salon. Rien à démarrer : il relaie, il n'émet pas de lui-même. */
1587
- @RealtimeChannel(ROOM_CHANNEL, { authenticated: true, roles: ["ROLE_USER"] })
1588
- room(_channel: string, _publish: RealtimePublish): () => void {
1589
- return () => {};
1590
- }
1591
-
1592
- /** Présence — un rappel régulier, arrêté au dernier désabonné. */
1593
- @RealtimeChannel("chat:presence", {
1826
+ /**
1827
+ * Le salon. Rien à démarrer : il relaie, il n'émet pas de lui-même. Sa
1828
+ * présence est tenue par le framework, entre les pods compris.
1829
+ */
1830
+ @RealtimeChannel(ROOM_CHANNEL, {
1594
1831
  authenticated: true,
1595
1832
  roles: ["ROLE_USER"],
1833
+ presence: true,
1596
1834
  })
1597
- presence(channel: string, publish: RealtimePublish): () => void {
1598
- const timer = setInterval(() => {
1599
- publish(channel, { present: [...ChatController.present] });
1600
- }, 5000);
1601
- timer.unref();
1602
- return () => clearInterval(timer);
1603
- }
1604
-
1605
- /** Frappe en cours — volatile : ni attribué, ni conservé. */
1606
- @RealtimeChannel("chat:typing")
1607
- typing(_channel: string, _publish: RealtimePublish): () => void {
1835
+ room(): () => void {
1608
1836
  return () => {};
1609
1837
  }
1610
1838
 
1611
- /** Le seul canal entrant restant : haute fréquence, sans enjeu d'identité. */
1612
- @RealtimeInbound("chat:typing")
1613
- onTyping(params: unknown): void {
1614
- // Une entrée réseau n'est pas fiable : on la RÉDUIT, on ne la convertit pas.
1615
- const author =
1616
- typeof params === "object" && params !== null && "author" in params
1617
- ? params.author
1618
- : undefined;
1619
- if (typeof author !== "string" || author.length === 0) return;
1620
- getRealtimeHub().publish("chat:typing", { author: author.slice(0, 40) });
1839
+ /**
1840
+ * Frappe en cours : un message ÉPHÉMÈRE du salon, ni conservé ni rejoué. Le
1841
+ * seul canal entrant restant — l'auteur est celui de la connexion, pas un
1842
+ * champ du client.
1843
+ */
1844
+ @RealtimeInbound("chat:typing", { authenticated: true })
1845
+ onTyping(): void {
1846
+ const author = this.user?.identifier;
1847
+ if (author === undefined) return;
1848
+ getRealtimeHub().publish(ROOM_CHANNEL, { typing: author });
1621
1849
  }
1622
1850
  }
1623
1851
 
@@ -1641,20 +1869,40 @@ hooks React ne se démontent pas ; tes composants ne voient rien passer. Côté
1641
1869
  libère les abonnements et détruit les providers devenus inutiles.
1642
1870
 
1643
1871
  **Ce qui reste à ta charge** : **le trou**. Les messages publiés pendant la coupure ne sont pas
1644
- rejoués — le hub distribue à qui est là, il ne conserve rien. À la reconnexion, redemande
1645
- l'historique depuis le dernier message connu :
1872
+ rejoués — le hub distribue à qui est là, il ne conserve rien. À la reconnexion, attends que
1873
+ l'abonnement soit **effectif**, relis les derniers messages, et **recoupe-les par identifiant** :
1646
1874
 
1647
1875
  ```tsx ignore
1648
1876
  // Au retour de `reconnecting` vers `connected`, on comble le trou.
1649
1877
  useEffect(() => {
1650
1878
  if (state !== "connected") return;
1651
- const lastSeen = messages.at(-1)?.sentAt ?? 0;
1652
1879
  void socket
1653
- .get<ChatMessage[]>("/chat/history", { query: { since: lastSeen } })
1654
- .then((missed) => setMessages((previous) => [...previous, ...missed]));
1880
+ // Lire AVANT l'abonnement effectif perdrait ce qui se publie entre les deux.
1881
+ .whenSubscribed("chat:room")
1882
+ .then(() =>
1883
+ socket.get<ChatMessage[]>("/chat/history", { query: { limit: 50 } }),
1884
+ )
1885
+ .then((page) =>
1886
+ setMessages((previous) => {
1887
+ // Par `id` : un message arrivé par les DEUX chemins n'apparaît qu'une fois.
1888
+ const byId = new Map(previous.map((m) => [m.id, m]));
1889
+ for (const m of page) byId.set(m.id, m);
1890
+ return [...byId.values()];
1891
+ }),
1892
+ );
1655
1893
  }, [state]);
1656
1894
  ```
1657
1895
 
1896
+ Deux pièges, que ce code évite :
1897
+
1898
+ - **Un rattrapage borné par l'heure** (`since: dernierMessage.sentAt`) double ou perd des messages :
1899
+ chaque pod a son horloge, et deux messages de la même milliseconde se départagent mal. L'heure
1900
+ sert à afficher, jamais à rattraper.
1901
+ - **Une page qui ne recoupe rien** de ce que l'écran montrait signale un trou plus long qu'elle :
1902
+ les messages plus anciens ne sont plus contigus. La démo de `create app --demo` les retire
1903
+ plutôt que de les afficher de part et d'autre d'un silence qui n'a pas eu lieu
1904
+ (`catchUpMessages`).
1905
+
1658
1906
  ### Les messages en double
1659
1907
 
1660
1908
  **Symptôme** — le même message apparaît deux fois.