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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +55 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +91 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
  6. package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
  7. package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
  8. package/dist/nodefony/src/backplane/RedisBackplane.js +146 -41
  9. package/dist/nodefony/src/backplane/controlOrder.js +86 -0
  10. package/dist/nodefony/src/backplane/envelope.js +505 -27
  11. package/dist/nodefony/src/backplane/ingress.js +93 -0
  12. package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
  13. package/dist/nodefony/src/backplane/publishQueue.js +97 -3
  14. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  15. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  16. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  17. package/dist/nodefony/src/server/RealtimeConnection.js +135 -0
  18. package/dist/nodefony/src/server/RealtimeController.js +409 -107
  19. package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
  20. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  21. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  22. package/dist/nodefony/src/server/channelPolicyRegistry.js +95 -0
  23. package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
  24. package/dist/nodefony/src/server/channelPresence.js +488 -0
  25. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  26. package/dist/nodefony/src/server/contractRoutes.js +75 -0
  27. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  28. package/dist/nodefony/src/server/frameAdmission.js +160 -0
  29. package/dist/nodefony/src/server/presenceBus.js +486 -0
  30. package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
  31. package/dist/nodefony/src/server/remotePresence.js +541 -0
  32. package/dist/nodefony/src/server/subscribedAck.js +13 -1
  33. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  34. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  35. package/dist/nodefony/src/service/RealtimeService.js +51 -1
  36. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  37. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  38. package/dist/nodefony/testing/backplaneConformance.js +347 -0
  39. package/dist/nodefony/testing/index.js +43 -12
  40. package/dist/nodefony/testing/presenceClient.js +54 -0
  41. package/dist/types/index.d.ts +39 -7
  42. package/dist/types/nodefony/config/config.d.ts +9 -0
  43. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
  44. package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
  45. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  46. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  47. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  48. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  49. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  50. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  52. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  53. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +89 -18
  54. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
  55. package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
  56. package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
  57. package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
  58. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
  59. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  60. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  61. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +20 -0
  62. package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
  63. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
  64. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  65. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  66. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +41 -0
  67. package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
  68. package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -0
  69. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  70. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  71. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  72. package/dist/types/nodefony/src/server/frameAdmission.d.ts +66 -0
  73. package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
  74. package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
  75. package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
  76. package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
  77. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  78. package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -2
  79. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  80. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  81. package/dist/types/nodefony/testing/backplaneConformance.d.ts +78 -0
  82. package/dist/types/nodefony/testing/index.d.ts +13 -2
  83. package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
  84. package/docs/actions.md +420 -18
  85. package/docs/architecture.md +101 -11
  86. package/docs/cadence-et-contre-pression.md +87 -11
  87. package/docs/canaux-a-membres.md +277 -0
  88. package/docs/cas-d-usage.md +1703 -0
  89. package/docs/configuration.md +175 -19
  90. package/docs/cookbook-chat.md +701 -224
  91. package/docs/index.md +11 -1
  92. package/docs/observabilite.md +97 -21
  93. package/docs/protocole.md +142 -33
  94. package/docs/securite.md +347 -41
  95. package/docs/vocabulaire.md +13 -12
  96. package/package.json +16 -13
@@ -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
 
@@ -531,7 +531,7 @@ requirements: {
531
531
  Cette méthode est alors joignable de **deux** façons, et c'est **le même code** qui s'exécute :
532
532
 
533
533
  - `GET /chat/history` en HTTP ;
534
- - `socket.request("/chat/history")` par la socket déjà ouverte.
534
+ - `socket.get("/chat/history")` par la socket déjà ouverte — la méthode porte le **verbe** de la route.
535
535
 
536
536
  Le passage par la socket emprunte un pont interne qui rejoue le routage sur le chemin demandé, puis
537
537
  exécute l'action. Ce pont ne peut atteindre **que** les routes ayant explicitement déclaré le
@@ -594,14 +594,15 @@ import { useEffect } from "react";
594
594
  // aucune poignée de main TLS de plus.
595
595
  useEffect(() => {
596
596
  void socket
597
- .request<ChatMessage[]>("/chat/history?limit=50")
597
+ .get<ChatMessage[]>("/chat/history", { query: { limit: 50 } })
598
598
  .then((past) => setMessages(past));
599
599
  }, [socket]);
600
600
  ```
601
601
 
602
- Le chemin commence par `/` : le client le reconnaît comme un appel d'API et emprunte le pont. Une
603
- méthode RPC ordinaire, elle, ne commence jamais par `/` — il n'y a pas de collision possible entre
604
- les deux formes.
602
+ `get` emprunte le pont : la même action que `GET /chat/history`, par la socket. La query se passe
603
+ en objet et voyage dans le chemin (`?limit=50`), comme en HTTP. Le `<ChatMessage[]>` est une
604
+ promesse faite au compilateur, pas une vérification : l'[étape 5](#quatre--un-contrat-pour-les-deux-côtés--lenvoi-et-les-messages-privés-typés)
605
+ la remplace par un contrat qui DÉDUIT le type de la route.
605
606
 
606
607
  ### Ce qu'on observe
607
608
 
@@ -614,7 +615,7 @@ curl -k "https://127.0.0.1:5152/chat/history?limit=5"
614
615
 
615
616
  ```ts ignore
616
617
  // Transport 2 — la socket déjà ouverte, depuis la console du navigateur
617
- await chatSocket.request("/chat/history?limit=5");
618
+ await chatSocket.get("/chat/history", { query: { limit: 5 } });
618
619
  ```
619
620
 
620
621
  Les deux renvoient le même tableau JSON. Si tu ajoutes une garde d'autorisation sur la méthode, elle
@@ -629,102 +630,324 @@ s'applique aux **deux** — c'est le même pipeline, pas deux implémentations
629
630
 
630
631
  ## 🧩 Étape 3 : présence et frappe en cours
631
632
 
632
- On ajoute deux flux : qui est là, et qui est en train d'écrire. Le point de cette étape n'est pas
633
- 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.
634
636
 
635
- ### Trois canaux, une seule connexion
637
+ ### La présence est une option du canal, pas un canal de plus
636
638
 
637
- Un salon complet a besoin d'au moins trois flux distincts. Dans beaucoup d'architectures, cela se
638
- traduit par plusieurs connexions, ou par un canal fourre-tout où chacun filtre ce qui le concerne.
639
- Ici, les trois canaux voyagent **dans la même socket**, chacun avec son nom, chacun avec son
640
- 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.
641
643
 
642
644
  ```mermaid
643
645
  flowchart LR
644
646
  N["Onglet du navigateur<br/>UNE connexion WebSocket"]
645
- N -.->|"chat:room"| R[Messages]
646
- N -.->|"chat:presence"| P[Qui est connecté]
647
- 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)"]
648
651
  ```
649
652
 
650
- S'abonner à un quatrième canal n'ouvre pas de quatrième connexion : cela envoie une frame
651
- d'abonnement sur celle qui existe déjà. C'est ce que « multiplexer » veut dire ici, et c'est ce qui
652
- 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)).
653
672
 
654
673
  ### Le contrôleur — fichier **modifié**
655
674
 
656
- ```ts ignore
657
- // 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é.
658
677
 
659
- /** Les pseudos présents, par connexion. Statique : partagé par tout le processus. */
660
- static present = new Set<string>();
678
+ <!-- example: chatPresence.ts -->
661
679
 
662
- /**
663
- * PRÉSENCE. Ce provider-là a quelque chose à démarrer : un rappel régulier de
664
- * l'état. La fonction de nettoyage rendue au hub arrête le minuteur — au dernier
665
- * désabonné, plus personne ne paie ce coût.
666
- */
667
- @RealtimeChannel("chat:presence")
668
- presence(channel: string, publish: RealtimePublish): () => void {
669
- const timer = setInterval(() => {
670
- publish(channel, { present: [...ChatController.present] });
671
- }, 5000);
672
- // `unref` : ce minuteur ne doit jamais retenir le processus à l'arrêt.
673
- timer.unref();
674
- 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;
675
696
  }
676
697
 
677
- /** FRAPPE EN COURS. Volatile par nature : rien à persister, rien à rejouer. */
678
- @RealtimeChannel("chat:typing")
679
- typing(_channel: string, _publish: RealtimePublish): () => void {
680
- 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
+ }
681
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).
682
747
 
683
- /** Le client signale qu'il écrit ; on le relaie tel quel au salon. */
684
- @RealtimeInbound("chat:typing")
685
- onTyping(params: unknown): void {
686
- const author = (params as { author?: unknown } | null)?.author;
687
- if (typeof author !== "string" || author.length === 0) return;
688
- 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
+ });
689
796
  }
690
797
  ```
691
798
 
692
- > [!NOTE]
693
- > **Un canal peut porter les deux sens.** `chat:typing` est déclaré à la fois comme canal sortant
694
- > (`@RealtimeChannel`) et comme canal entrant (`@RealtimeInbound`). C'est ce qu'on appelle un canal
695
- > **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 |
696
806
 
697
- ### Côté client
807
+ ### À cette étape, personne n'est nommé
698
808
 
699
- ```tsx ignore
700
- // frontend/src/ChatRoom.tsx (ajouts de l'étape 3)
701
- import { useNodefonyChannelData } from "nodefony/react";
702
-
703
- // La DERNIÈRE valeur reçue suffit pour un état : on ne veut pas l'historique
704
- // des présences, seulement l'état courant.
705
- const presence = useNodefonyChannelData<{ present: string[] }>("chat:presence");
706
-
707
- const [typingBy, setTypingBy] = useState<string | null>(null);
708
- useNodefonyChannel("chat:typing", (payload) => {
709
- const author = (payload as { author: string }).author;
710
- if (author === pseudo) return; // ne pas s'afficher soi-même
711
- setTypingBy(author);
712
- // L'indicateur s'efface tout seul : le serveur n'envoie jamais de « stop ».
713
- window.setTimeout(() => setTypingBy(null), 2500);
714
- });
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
+ }
715
865
 
716
- // Dans le rendu :
717
- // <p>{presence?.present.length ?? 0} connecté(s)</p>
718
- // {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
+ });
719
933
  ```
720
934
 
721
935
  ### Ce qu'on observe
722
936
 
723
- | Tu fais | Tu vois |
724
- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
725
- | Tu tapes dans l'onglet gauche | « invité-412 est en train d'écrire… » apparaît dans l'onglet droit, puis disparaît seul. |
726
- | 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. |
727
- | 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).
728
951
 
729
952
  ## 💾 Étape 4 : persister l'historique
730
953
 
@@ -829,26 +1052,25 @@ export const securityConfig = () =>
829
1052
 
830
1053
  Le décorateur de canal accepte un second argument : les exigences à satisfaire pour s'y abonner.
831
1054
 
832
- Les deux méthodes concernées, complètes, avec leur politique :
1055
+ La déclaration du salon, complète, avec sa politique :
833
1056
 
834
1057
  ```ts ignore
835
1058
  // modules/chat/nodefony/controllers/ChatController.ts
836
- // → remplace les déclarations de canal des étapes 1 et 3
837
- @RealtimeChannel(ROOM_CHANNEL, { authenticated: true, roles: ["ROLE_USER"] })
838
- 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 {
839
1066
  return () => {};
840
1067
  }
841
-
842
- @RealtimeChannel("chat:presence", { authenticated: true, roles: ["ROLE_USER"] })
843
- presence(channel: string, publish: RealtimePublish): () => void {
844
- const timer = setInterval(() => {
845
- publish(channel, { present: [...ChatController.present] });
846
- }, 5000);
847
- timer.unref();
848
- return () => clearInterval(timer);
849
- }
850
1068
  ```
851
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
+
852
1074
  Le module temps réel ne décide de rien : il **transporte la déclaration** jusqu'au firewall, seul à
853
1075
  connaître la hiérarchie des rôles et l'identité réelle.
854
1076
 
@@ -856,13 +1078,13 @@ connaître la hiérarchie des rôles et l'identité réelle.
856
1078
 
857
1079
  C'est ici qu'il faut être précis, car **tous les chemins ne se valent pas**.
858
1080
 
859
- Un handler `@RealtimeInbound` reçoit `(params, reply)` — et **rien d'autre**. Il ne reçoit ni la
860
- connexion, ni le jeton d'identité résolu à la poignée de main. Autrement dit : depuis un canal
861
- inbound brut, le serveur **ne peut pas savoir qui parle**. Le canal peut être _gardé_ par une
862
- politique (personne d'anonyme ne s'y abonne), mais le nom de l'auteur reste, lui, déclaré par le
863
- client.
1081
+ Un handler `@RealtimeInbound` reçoit `(params, reply, connection)`. Le troisième argument porte
1082
+ l'identité résolue à la poignée de main : `connection.userId` est l'auteur, et c'est lui qu'on
1083
+ écrit dans le message — jamais un champ de la charge, où le client peut mentir. Ce qu'un canal
1084
+ inbound n'offre pas, en revanche : la validation du corps, la garde `@IsGranted`, le rejeu sans
1085
+ doublon (`@Idempotent`), et la même porte en HTTP.
864
1086
 
865
- La voie qui donne l'identité est celle de l'[étape 2](#-étape-2--le-même-contrôleur-sert-http-et-websocket) :
1087
+ Ces garanties-là viennent par la voie de l'[étape 2](#-étape-2--le-même-contrôleur-sert-http-et-websocket) :
866
1088
  une **action de contrôleur**, atteinte par la socket. Sur ce chemin, le framework établit le contexte
867
1089
  de requête avec le jeton de la connexion — exactement comme en HTTP — et les décorateurs habituels
868
1090
  fonctionnent :
@@ -870,58 +1092,66 @@ fonctionnent :
870
1092
  ```ts ignore
871
1093
  // modules/chat/nodefony/controllers/ChatController.ts
872
1094
  // → remplace `@RealtimeInbound("chat:send")` par une ACTION
873
- import { Body, CurrentUser, IsGranted } from "@nodefony/framework";
874
- import type { IUser } from "@nodefony/user";
1095
+ import { z } from "zod";
1096
+ import { Idempotent, IsGranted } from "@nodefony/framework";
1097
+ import type { RouteInput } from "@nodefony/framework";
1098
+
1099
+ /** Ce que l'envoi accepte — VALIDÉ par le framework avant l'action, par les deux portes. */
1100
+ const SendBody = z.object({ text: z.string().trim().min(1).max(2000) });
875
1101
 
876
1102
  /**
877
1103
  * L'envoi devient une action, joignable en POST HTTP comme par la socket.
878
1104
  * On y gagne ce qu'un canal inbound ne peut pas offrir : l'identité vérifiée
879
- * (`@CurrentUser`), la garde d'autorisation, et le pipeline complet.
1105
+ * (`this.user`), la garde d'autorisation, la validation du corps et le
1106
+ * pipeline complet.
880
1107
  */
881
1108
  @route("chat-send", {
882
1109
  path: "/send",
883
1110
  requirements: { methods: ["POST", "WEBSOCKET"] },
1111
+ schema: { body: SendBody },
884
1112
  })
885
1113
  @IsGranted(["ROLE_USER"])
886
- async send(
887
- @Body() payload: { text?: string },
888
- @CurrentUser() user: IUser,
889
- ) {
890
- const text = typeof payload?.text === "string" ? payload.text.trim() : "";
891
- if (text.length === 0 || text.length > 2000) {
892
- throw new HttpError("message vide ou trop long", 400);
893
- }
894
-
895
- // L'auteur vient du SERVEUR. Le client ne peut plus mentir dessus.
1114
+ @Idempotent()
1115
+ send(input: RouteInput<{ body: typeof SendBody }>) {
1116
+ // Corps vide, trop long, champ manquant : refusé AVANT d'arriver ici, par un
1117
+ // 422 qui nomme le champ — en HTTP comme par la socket. Ici, `input.body.text`
1118
+ // est une chaîne valide, rognée.
1119
+ // L'auteur vient du SERVEUR (la zone l'a posé). Le client ne peut plus mentir dessus.
896
1120
  const message: ChatMessage = {
897
- author: user.identifier,
898
- text,
1121
+ author: this.user?.identifier ?? "anon.",
1122
+ text: input.body.text,
899
1123
  sentAt: Date.now(),
900
1124
  };
901
1125
 
902
1126
  getRealtimeHub().publish(ROOM_CHANNEL, message);
903
- return { ok: true, sentAt: message.sentAt };
1127
+ return { sentAt: message.sentAt };
904
1128
  }
905
1129
  ```
906
1130
 
907
- Côté navigateur, l'envoi passe alors par `mutate`, qui porte la méthode HTTP logique et une clé
908
- d'idempotence — une socket qui se reconnecte peut rejouer une frame, la clé évite le doublon :
1131
+ Côté navigateur, l'envoi passe par `post` — le **verbe de la route** : une route `POST` s'appelle
1132
+ `socket.post(…)`, une route `GET` `socket.get(…)`, rien d'autre à apprendre. La socket pose seule une
1133
+ **clé d'idempotence** sur chaque écriture : une socket qui se reconnecte peut rejouer une trame, et
1134
+ `@Idempotent()` reconnaît la clé et rend la réponse déjà calculée au lieu de publier deux fois.
909
1135
 
910
1136
  ```tsx ignore
911
1137
  // frontend/src/ChatRoom.tsx (étape 5)
912
- await socket.mutate("/chat/send", {
913
- method: "POST",
914
- body: { text },
915
- idempotencyKey: crypto.randomUUID(),
916
- });
1138
+ await socket.post("/chat/send", { text });
917
1139
  ```
918
1140
 
1141
+ > [!NOTE]
1142
+ > Une clé par **appel**, posée par la socket. Pour dédoublonner une **intention** — un double clic
1143
+ > sur « Envoyer » qui part deux fois —, l'écran fournit la sienne : `socket.post("/chat/send", { text
1144
+ }, { idempotencyKey: brouillon.id })`. Deux appels, une seule clé, un seul message.
1145
+
919
1146
  > [!TIP]
920
1147
  > **La règle à retenir.** Canal inbound = flux volatil, à haute fréquence, dont l'expéditeur importe
921
1148
  > peu (« untel écrit »). Action de contrôleur = tout ce qui doit être **attribué, autorisé ou
922
1149
  > conservé**. Le chat de l'étape 1 utilisait un canal inbound par simplicité ; dès qu'il y a des
923
- > comptes, l'envoi devient une action. La frappe en cours, elle, reste un canal inbound — son auteur
924
- > 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`).
925
1155
 
926
1156
  Côté navigateur, l'identité annoncée par le serveur se lit sans appeler la moindre route :
927
1157
 
@@ -936,6 +1166,213 @@ const identity = useNodefonyIdentity();
936
1166
  // identity.roles → pour masquer ce qui n'est pas permis
937
1167
  ```
938
1168
 
1169
+ ### Quatre : un contrat pour les deux côtés — l'envoi et les messages privés typés
1170
+
1171
+ À ce stade, tout marche, mais rien ne relie les deux côtés. `socket.post("/chat/send", { texte:
1172
+ "salut" })` — avec une faute de frappe — compile, part sur le réseau, et revient en 422. Le résultat
1173
+ de `post` est `unknown`. Le message privé n'existe pas encore. Les trois se règlent par **un seul
1174
+ fichier** que le serveur et le navigateur partagent : le **contrat**.
1175
+
1176
+ **Le principe : une seule source.** Le corps de `/chat/send`, c'est le schéma `SendBody` que la route
1177
+ valide. Le contrat ne le **recopie** pas (`body: { text: string }`) : il le **référence**
1178
+ (`typeof SendBody`). Recopier, c'est écrire deux formes qui divergeront un jour sans que rien ne le
1179
+ dise — le schéma accepte `to`, la copie l'ignore, et le navigateur ne peut plus l'envoyer.
1180
+
1181
+ #### Le schéma partagé — fichier **créé**
1182
+
1183
+ ```ts ignore
1184
+ // shared/chatSchemas.ts — la SEULE définition du corps de l'envoi
1185
+ import { z } from "zod";
1186
+
1187
+ export const SendBody = z.object({
1188
+ text: z.string().trim().min(1).max(2000),
1189
+ /** Destinataire d'un message privé : l'`id` STABLE de son compte. Absent = le salon. */
1190
+ to: z.string().optional(),
1191
+ });
1192
+
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
+ */
1197
+ export const HistoryQuery = z.object({
1198
+ limit: z.coerce.number().int().min(1).max(200).default(50),
1199
+ });
1200
+ ```
1201
+
1202
+ #### Le contrat — fichier **créé**
1203
+
1204
+ ```ts ignore
1205
+ // shared/chatContract.ts — des TYPES seulement
1206
+ import type { IRealtimeContract } from "nodefony/client";
1207
+ import type { HistoryQuery, SendBody } from "./chatSchemas";
1208
+
1209
+ export interface ChatMessage {
1210
+ author: string;
1211
+ text: string;
1212
+ sentAt: number;
1213
+ /** Présent sur un message privé. */
1214
+ to?: string;
1215
+ }
1216
+
1217
+ /** Ce qu'on annonce au salon quand un fichier a fini d'arriver (étape 6). */
1218
+ export interface ChatAttachment {
1219
+ author: string;
1220
+ filename: string;
1221
+ size: number;
1222
+ mimeType: string | null | undefined;
1223
+ sentAt: number;
1224
+ }
1225
+
1226
+ export interface ChatContract extends IRealtimeContract {
1227
+ /** Ce que le serveur pousse : le salon, et le canal personnel de chacun. */
1228
+ serverToClient: {
1229
+ "chat:room": ChatMessage | ChatAttachment;
1230
+ [channel: `user:${string}`]: ChatMessage;
1231
+ };
1232
+ /** Les routes que la socket appelle : "<VERBE> <chemin>" → corps, query, résultat. */
1233
+ serverRoutes: {
1234
+ "POST /chat/send": { body: typeof SendBody; result: { sentAt: number } };
1235
+ "GET /chat/history": { query: typeof HistoryQuery; result: ChatMessage[] };
1236
+ };
1237
+ }
1238
+ ```
1239
+
1240
+ `import type` est le point qui compte : le navigateur lit `typeof SendBody` **à la compilation**, et
1241
+ n'embarque **aucun octet** de zod. Le fichier du contrat, fait de types seuls, disparaît à la
1242
+ construction.
1243
+
1244
+ #### Le serveur — fichier **modifié**
1245
+
1246
+ ```ts ignore
1247
+ // modules/chat/nodefony/controllers/ChatController.ts
1248
+ import { ContractRealtimeController, getRealtimeHub } from "@nodefony/realtime";
1249
+ import type { ContractRouteSchemas } from "nodefony";
1250
+ import type { RouteInput } from "@nodefony/framework";
1251
+ import { HistoryQuery, SendBody } from "../../../../shared/chatSchemas";
1252
+ import type {
1253
+ ChatContract,
1254
+ ChatMessage,
1255
+ } from "../../../../shared/chatContract";
1256
+
1257
+ @controller("/chat")
1258
+ class ChatController extends ContractRealtimeController<ChatContract> {
1259
+ /**
1260
+ * Les VALEURS des routes du contrat — le schéma même que chaque route valide.
1261
+ * Vérifiées au démarrage (voir plus bas) ; jamais envoyées au navigateur.
1262
+ */
1263
+ static readonly serverRoutes = {
1264
+ "POST /chat/send": { body: SendBody },
1265
+ "GET /chat/history": { query: HistoryQuery },
1266
+ } satisfies ContractRouteSchemas<ChatContract>;
1267
+
1268
+ // La route de l'historique valide désormais `schema: { query: HistoryQuery }`
1269
+ // (version complète plus bas) ; l'envoi, son corps :
1270
+ @route("chat-send", {
1271
+ path: "/send",
1272
+ requirements: { methods: ["POST", "WEBSOCKET"] },
1273
+ schema: { body: SendBody },
1274
+ })
1275
+ @IsGranted(["ROLE_USER"])
1276
+ @Idempotent()
1277
+ send(input: RouteInput<{ body: typeof SendBody }>) {
1278
+ const me = this.user;
1279
+ if (me === null) throw new HttpError("connexion requise", 401);
1280
+ const { text, to } = input.body;
1281
+ const message: ChatMessage = {
1282
+ author: me.identifier,
1283
+ text,
1284
+ sentAt: Date.now(),
1285
+ };
1286
+ const hub = getRealtimeHub<ChatContract>();
1287
+ if (to === undefined) {
1288
+ hub.publish("chat:room", message);
1289
+ } else {
1290
+ // Message privé : le destinataire, sur TOUS ses onglets et TOUS les
1291
+ // serveurs — et une copie à l'expéditeur, pour ses autres onglets.
1292
+ hub.publishToUser(to, { ...message, to });
1293
+ hub.publishToUser(me.id, { ...message, to });
1294
+ }
1295
+ return { sentAt: message.sentAt };
1296
+ }
1297
+ }
1298
+ ```
1299
+
1300
+ `satisfies ContractRouteSchemas<ChatContract>` fait vérifier la table par le compilateur : une route
1301
+ du contrat **oubliée**, une route **en trop**, un **autre** schéma que `typeof SendBody` — et le
1302
+ fichier ne compile pas. `getRealtimeHub<ChatContract>()` type la diffusion : `hub.publish("chat:room",
1303
+ { txt: "…" })` ne compile pas davantage.
1304
+
1305
+ Le message privé ne demande **rien d'autre**. Le canal personnel `user:<id>` est une brique du
1306
+ framework : seule l'identité `<id>` peut s'y abonner, il traverse le bus entre serveurs, et
1307
+ personne ne paie rien tant qu'aucun écran ne l'écoute (détail : [securite.md](./securite.md)).
1308
+
1309
+ #### Le navigateur — fichier **modifié**
1310
+
1311
+ ```ts ignore
1312
+ // frontend/src/realtime.ts — la socket, typée par le contrat
1313
+ import { NodefonySocket } from "nodefony/client";
1314
+ import type { ChatContract } from "../../shared/chatContract";
1315
+
1316
+ export const chatSocket = NodefonySocket.fromContract<ChatContract>({
1317
+ url: `${scheme}://${window.location.host}/chat/realtime`,
1318
+ });
1319
+ ```
1320
+
1321
+ ```tsx ignore
1322
+ // frontend/src/ChatRoom.tsx (étape 5, typée)
1323
+ // Les messages privés de CET utilisateur : `m` est un ChatMessage, sans annotation.
1324
+ useEffect(() => chatSocket.onUser((m) => addPrivate(m)), []);
1325
+
1326
+ // L'historique : ChatMessage[] — déduit de la route, query vérifiée.
1327
+ const past = await chatSocket.get("/chat/history", { query: { limit: 50 } });
1328
+
1329
+ // L'envoi : le corps est l'ENTRÉE du schéma, le résultat est { sentAt: number }.
1330
+ const { sentAt } = await chatSocket.post("/chat/send", { text });
1331
+ await chatSocket.post("/chat/send", { text, to: bob.id }); // message privé
1332
+
1333
+ // Et ce qui ne compile plus :
1334
+ await chatSocket.post("/chat/send", { texte: "x" }); // ✗ `texte` n'existe pas dans SendBody
1335
+ await chatSocket.post("/chat/send"); // ✗ le schéma exige un corps
1336
+ ```
1337
+
1338
+ Un chemin **hors contrat** reste libre — `chatSocket.get("/stats")` rend `unknown`,
1339
+ `chatSocket.get<Stats>("/stats")` le type choisi — : le contrat type ce qu'il déclare, il n'interdit
1340
+ rien d'autre.
1341
+
1342
+ Une route à **paramètre de chemin** se déclare par son gabarit — la clé est le chemin de la route,
1343
+ `{id}` compris — et s'appelle en NOMMANT ce gabarit ; la socket le remplit :
1344
+
1345
+ ```ts ignore
1346
+ // contrat — la route valide `schema: { params: IdParams }`
1347
+ "GET /chat/messages/{id}": { params: typeof IdParams; result: ChatMessage };
1348
+
1349
+ // navigateur
1350
+ const m = await chatSocket.get("/chat/messages/{id}", { params: { id } });
1351
+ await chatSocket.get("/chat/messages/{id}"); // ✗ `params.id` manquant
1352
+ ```
1353
+
1354
+ Chaque valeur est encodée pour tenir dans **un** segment : un identifiant qui contient `/` ou `?`
1355
+ ne peut pas détourner l'appel vers une autre route, et une valeur vide, `.` ou `..` — que la
1356
+ normalisation du chemin résoudrait en remontant d'un cran — est refusée avant l'envoi. Sans
1357
+ schéma `params`, les valeurs sont typées par les `{noms}` du gabarit. Seuls des segments
1358
+ `{nom}` entiers se déclarent : une contrainte s'écrit dans `requirements` de la route, pas en
1359
+ ligne (`{id}(\d+)`) — le démarrage le refuse en nommant l'entrée.
1360
+
1361
+ #### Ce que le démarrage refuse
1362
+
1363
+ Le contrat est un **type** : il fait compiler `chatSocket.post("/chat/send", …)` même si la route
1364
+ n'existe pas. C'est la table `serverRoutes` qui porte les **valeurs**, et le démarrage les confronte
1365
+ aux routes montées — une fois, coût nul par requête. Trois fautes, trois refus qui nomment l'entrée :
1366
+
1367
+ | Faute | Le démarrage s'arrête sur |
1368
+ | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
1369
+ | La route n'existe pas (chemin mal recopié, module non chargé) | `ChatController.serverRoutes « POST /chat/send » : aucune route montée au chemin …` |
1370
+ | La route sert HTTP seulement (`methods: ["POST"]`) | `… ne sert pas POST par la socket — ses méthodes doivent inclure "POST" et "WEBSOCKET".` |
1371
+ | La route valide un autre schéma (copie, ou schéma redéclaré) | `… le schéma « body » n'est pas celui que la route valide (même objet attendu).` |
1372
+
1373
+ Sans ce contrôle, chacune de ces fautes compilerait, démarrerait, et n'apparaîtrait qu'au premier
1374
+ message envoyé — chez l'utilisateur.
1375
+
939
1376
  ### Ce que voit précisément un intrus
940
1377
 
941
1378
  Rien n'échoue en silence, et rien ne renseigne l'attaquant sur **pourquoi** il échoue.
@@ -1158,6 +1595,21 @@ use("@nodefony/realtime", {
1158
1595
  });
1159
1596
  ```
1160
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
+
1161
1613
  ### Ce qui n'a pas bougé
1162
1614
 
1163
1615
  C'est l'argument, et il se vérifie fichier par fichier :
@@ -1170,6 +1622,10 @@ C'est l'argument, et il se vérifie fichier par fichier :
1170
1622
  | `ChatRoom.tsx`, `realtime.ts` | **Non.** Le navigateur ne sait pas, et n'a pas à savoir, combien de pods existent. |
1171
1623
  | `nodefony.config.ts` | Une chaîne de caractères. |
1172
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
+
1173
1629
  Aucun appel de publication ne change, aucun abonnement ne change, aucune règle métier ne change. Ce
1174
1630
  qui change, c'est **par où** la publication repart après le fan-out local — et cela se décide dans la
1175
1631
  configuration, pas dans le code.
@@ -1199,11 +1655,14 @@ Au démarrage de chaque worker, la ligne d'identité du temps réel change de fo
1199
1655
  `kind=local cross-pod=no`, on lit maintenant un `kind` de transport et `cross-pod=yes` : c'est la
1200
1656
  confirmation, au boot, que le relais est en place.
1201
1657
 
1202
- | Tu fais | Avant (étape 6) | Maintenant |
1203
- | --------------------------- | ---------------- | ------------------------------------------------------ |
1204
- | Alice envoie un fichier | Bob ne voit rien | L'annonce apparaît chez Bob, sur l'autre processus |
1205
- | Alice tape un message | Aléatoire | Reçu par tous, quel que soit le worker |
1206
- | 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` |
1207
1666
 
1208
1667
  Cette dernière ligne mérite une explication : si le backplane est injoignable au démarrage, le
1209
1668
  framework **ne bloque pas le boot**. Il journalise un avertissement, garde un hub local et sert le
@@ -1223,55 +1682,56 @@ La seule chose qui n'y figure pas est la persistance : `ChatController.history`
1223
1682
  mémoire, que l'[étape 4](#-étape-4--persister-lhistorique) remplace par un appel à ton service.
1224
1683
  C'est délibéré — ce fichier doit compiler sans supposer ta base.
1225
1684
 
1685
+ Il importe les deux fichiers partagés de l'[étape 5](#quatre--un-contrat-pour-les-deux-côtés--lenvoi-et-les-messages-privés-typés) :
1686
+ `shared/chatSchemas.ts` (les schémas que les routes valident) et `shared/chatContract.ts` (le
1687
+ contrat, types seuls).
1688
+
1226
1689
  ```ts ignore
1227
1690
  // modules/chat/nodefony/controllers/ChatController.ts — version finale
1228
1691
  import {
1229
1692
  controller,
1230
1693
  route,
1231
- Body,
1232
- Query,
1233
- CurrentUser,
1694
+ Idempotent,
1234
1695
  IsGranted,
1235
1696
  UploadedFile,
1236
1697
  } from "@nodefony/framework";
1698
+ import type { RouteInput } from "@nodefony/framework";
1237
1699
  import {
1238
- RealtimeController,
1700
+ ContractRealtimeController,
1701
+ RealtimeBroadcast,
1239
1702
  RealtimeChannel,
1240
1703
  RealtimeInbound,
1241
1704
  getRealtimeHub,
1242
1705
  } from "@nodefony/realtime";
1243
- import type { RealtimePublish } from "@nodefony/realtime";
1244
1706
  import { HttpError } from "@nodefony/http";
1245
1707
  import type { ContextType, IUploadedFile } from "@nodefony/http";
1246
- import type { IUser } from "@nodefony/user";
1247
-
1248
- /** Un message tel qu'il circule sur le canal du salon. */
1249
- export interface ChatMessage {
1250
- author: string;
1251
- text: string;
1252
- sentAt: number;
1253
- }
1254
-
1255
- /** Ce qu'on annonce au salon quand un fichier a fini d'arriver. */
1256
- export interface ChatAttachment {
1257
- author: string;
1258
- filename: string;
1259
- size: number;
1260
- mimeType: string | null | undefined;
1261
- sentAt: number;
1262
- }
1708
+ import type { ContractRouteSchemas } from "nodefony";
1709
+ import { HistoryQuery, SendBody } from "../../../../shared/chatSchemas";
1710
+ import type {
1711
+ ChatAttachment,
1712
+ ChatContract,
1713
+ ChatMessage,
1714
+ } from "../../../../shared/chatContract";
1263
1715
 
1264
1716
  /** Le nom du canal du salon. Cité côté serveur ET côté client → une constante. */
1265
1717
  export const ROOM_CHANNEL = "chat:room";
1266
1718
 
1719
+ // Le salon traverse les serveurs (étape 7) : sans cette ligne, il reste sur le sien.
1720
+ @RealtimeBroadcast("chat:")
1267
1721
  @controller("/chat")
1268
- class ChatController extends RealtimeController {
1722
+ class ChatController extends ContractRealtimeController<ChatContract> {
1723
+ /**
1724
+ * Les VALEURS des routes du contrat — le schéma même que chaque route valide.
1725
+ * Vérifiées au démarrage ; jamais envoyées au navigateur.
1726
+ */
1727
+ static readonly serverRoutes = {
1728
+ "POST /chat/send": { body: SendBody },
1729
+ "GET /chat/history": { query: HistoryQuery },
1730
+ } satisfies ContractRouteSchemas<ChatContract>;
1731
+
1269
1732
  /** Stock en mémoire — remplacé par un repository à l'étape 4. */
1270
1733
  static history: ChatMessage[] = [];
1271
1734
 
1272
- /** Les pseudos présents dans le salon, sur CE processus. */
1273
- static present = new Set<string>();
1274
-
1275
1735
  constructor(context: ContextType) {
1276
1736
  super("chat", context);
1277
1737
  }
@@ -1281,9 +1741,6 @@ class ChatController extends RealtimeController {
1281
1741
  return true;
1282
1742
  }
1283
1743
 
1284
- /** Ce qui doit franchir les frontières de processus (étape 7). */
1285
- // (préfixe diffusable déclaré par `@RealtimeBroadcast` sur la classe)
1286
-
1287
1744
  // ── La porte WebSocket ────────────────────────────────────────────────────
1288
1745
 
1289
1746
  @route("chat-realtime", {
@@ -1296,38 +1753,47 @@ class ChatController extends RealtimeController {
1296
1753
 
1297
1754
  // ── Les actions : HTTP et WebSocket, même code ────────────────────────────
1298
1755
 
1299
- /** Historique — `GET /chat/history` ou `socket.request("/chat/history")`. */
1756
+ /** Historique — `GET /chat/history` ou `socket.get("/chat/history")`. */
1300
1757
  @route("chat-history", {
1301
1758
  path: "/history",
1302
1759
  requirements: { methods: ["GET", "WEBSOCKET"] },
1760
+ schema: { query: HistoryQuery },
1303
1761
  })
1304
- historyAction(@Query("limit") limit?: string): ChatMessage[] {
1305
- const max = Math.min(Number(limit) || 50, 200);
1306
- return ChatController.history.slice(-max);
1762
+ historyAction(
1763
+ input: RouteInput<{ query: typeof HistoryQuery }>,
1764
+ ): ChatMessage[] {
1765
+ return ChatController.history.slice(-input.query.limit);
1307
1766
  }
1308
1767
 
1309
- /** Envoi — l'auteur vient du serveur, jamais du client. */
1768
+ /** Envoi — au salon, ou en privé à une personne ; l'auteur vient du serveur. */
1310
1769
  @route("chat-send", {
1311
1770
  path: "/send",
1312
1771
  requirements: { methods: ["POST", "WEBSOCKET"] },
1772
+ schema: { body: SendBody },
1313
1773
  })
1314
1774
  @IsGranted(["ROLE_USER"])
1315
- async send(
1316
- @Body() payload: { text?: string },
1317
- @CurrentUser() user: IUser,
1318
- ): Promise<{ ok: true; sentAt: number }> {
1319
- const text = typeof payload?.text === "string" ? payload.text.trim() : "";
1320
- if (text.length === 0 || text.length > 2000) {
1321
- throw new HttpError("message vide ou trop long", 400);
1322
- }
1775
+ @Idempotent()
1776
+ send(input: RouteInput<{ body: typeof SendBody }>): { sentAt: number } {
1777
+ const me = this.user;
1778
+ if (me === null) throw new HttpError("connexion requise", 401);
1779
+ const { text, to } = input.body;
1323
1780
  const message: ChatMessage = {
1324
- author: user.identifier,
1781
+ author: me.identifier,
1325
1782
  text,
1326
1783
  sentAt: Date.now(),
1327
1784
  };
1328
- ChatController.history.push(message);
1329
- getRealtimeHub().publish(ROOM_CHANNEL, message);
1330
- return { ok: true, sentAt: message.sentAt };
1785
+ const hub = getRealtimeHub<ChatContract>();
1786
+ if (to === undefined) {
1787
+ ChatController.history.push(message);
1788
+ hub.publish(ROOM_CHANNEL, message);
1789
+ } else {
1790
+ // Le destinataire sur tous ses onglets et tous les serveurs, et une copie
1791
+ // à l'expéditeur pour ses autres onglets. Rien à déclarer : `user:<id>`
1792
+ // est un canal du framework.
1793
+ hub.publishToUser(to, { ...message, to });
1794
+ hub.publishToUser(me.id, { ...message, to });
1795
+ }
1796
+ return { sentAt: message.sentAt };
1331
1797
  }
1332
1798
 
1333
1799
  /** Pièce jointe — entre en POST multipart, ressort par le canal. */
@@ -1338,13 +1804,12 @@ class ChatController extends RealtimeController {
1338
1804
  @IsGranted(["ROLE_USER"])
1339
1805
  async upload(
1340
1806
  @UploadedFile() file?: IUploadedFile,
1341
- @CurrentUser() user: IUser,
1342
- ): Promise<{ ok: true; filename: string; size: number }> {
1807
+ ): Promise<{ filename: string; size: number }> {
1343
1808
  if (!file) {
1344
1809
  throw new HttpError("aucun fichier reçu", 400);
1345
1810
  }
1346
1811
  const announcement: ChatAttachment = {
1347
- author: user.identifier,
1812
+ author: this.user?.identifier ?? "anon.",
1348
1813
  filename: file.filename,
1349
1814
  size: file.size,
1350
1815
  mimeType: file.mimeType,
@@ -1353,42 +1818,34 @@ class ChatController extends RealtimeController {
1353
1818
  // La requête HTTP répondra à SON auteur ; cette publication atteint tout le
1354
1819
  // salon. Un transport entre, un autre sort — dans la même méthode.
1355
1820
  getRealtimeHub().publish(ROOM_CHANNEL, announcement);
1356
- return { ok: true, filename: file.filename, size: file.size };
1821
+ return { filename: file.filename, size: file.size };
1357
1822
  }
1358
1823
 
1359
1824
  // ── Les canaux ────────────────────────────────────────────────────────────
1360
1825
 
1361
- /** Le salon. Rien à démarrer : il relaie, il n'émet pas de lui-même. */
1362
- @RealtimeChannel(ROOM_CHANNEL, { authenticated: true, roles: ["ROLE_USER"] })
1363
- room(_channel: string, _publish: RealtimePublish): () => void {
1364
- return () => {};
1365
- }
1366
-
1367
- /** Présence — un rappel régulier, arrêté au dernier désabonné. */
1368
- @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, {
1369
1831
  authenticated: true,
1370
1832
  roles: ["ROLE_USER"],
1833
+ presence: true,
1371
1834
  })
1372
- presence(channel: string, publish: RealtimePublish): () => void {
1373
- const timer = setInterval(() => {
1374
- publish(channel, { present: [...ChatController.present] });
1375
- }, 5000);
1376
- timer.unref();
1377
- return () => clearInterval(timer);
1378
- }
1379
-
1380
- /** Frappe en cours — volatile : ni attribué, ni conservé. */
1381
- @RealtimeChannel("chat:typing")
1382
- typing(_channel: string, _publish: RealtimePublish): () => void {
1835
+ room(): () => void {
1383
1836
  return () => {};
1384
1837
  }
1385
1838
 
1386
- /** Le seul canal entrant restant : haute fréquence, sans enjeu d'identité. */
1387
- @RealtimeInbound("chat:typing")
1388
- onTyping(params: unknown): void {
1389
- const author = (params as { author?: unknown } | null)?.author;
1390
- if (typeof author !== "string" || author.length === 0) return;
1391
- 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 });
1392
1849
  }
1393
1850
  }
1394
1851
 
@@ -1412,20 +1869,40 @@ hooks React ne se démontent pas ; tes composants ne voient rien passer. Côté
1412
1869
  libère les abonnements et détruit les providers devenus inutiles.
1413
1870
 
1414
1871
  **Ce qui reste à ta charge** : **le trou**. Les messages publiés pendant la coupure ne sont pas
1415
- rejoués — le hub distribue à qui est là, il ne conserve rien. À la reconnexion, redemande
1416
- 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** :
1417
1874
 
1418
1875
  ```tsx ignore
1419
1876
  // Au retour de `reconnecting` vers `connected`, on comble le trou.
1420
1877
  useEffect(() => {
1421
1878
  if (state !== "connected") return;
1422
- const lastSeen = messages.at(-1)?.sentAt ?? 0;
1423
1879
  void socket
1424
- .request<ChatMessage[]>(`/chat/history?since=${lastSeen}`)
1425
- .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
+ );
1426
1893
  }, [state]);
1427
1894
  ```
1428
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
+
1429
1906
  ### Les messages en double
1430
1907
 
1431
1908
  **Symptôme** — le même message apparaît deux fois.
@@ -1632,14 +2109,14 @@ Pour qui veut lire l'implémentation derrière chaque étape :
1632
2109
  | La publication et le fan-out | `RealtimeHub.publish()` (`RealtimeHub.ts#RealtimeHub.publish`), réinjection locale `RealtimeHub.publishLocal()` (`RealtimeHub.ts#RealtimeHub.publishLocal`) |
1633
2110
  | Les canaux diffusables | `RealtimeBroadcast` (`realtimeDecorators.ts#RealtimeBroadcast`), `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts#RealtimeHub.markBroadcastChannel`) |
1634
2111
  | Le branchement du backplane | `RealtimeHub.setBackplane()` (`RealtimeHub.ts#RealtimeHub.setBackplane`), registre `registerBackplaneDriver()` (`backplaneRegistry.ts#registerBackplaneDriver`), cloison `resolveBackplaneOriginId()` (`originId.ts#resolveBackplaneOriginId`) |
1635
- | Les politiques de canal | `IChannelPolicy` (`IChannelPolicy.ts#IChannelPolicy`), `RealtimeHub.registerChannelPolicy()` (`RealtimeHub.ts#RealtimeHub.registerChannelPolicy`), garde-fou `RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts#RealtimeHub.hasUnenforcedChannelPolicies`) |
2112
+ | Les politiques de canal | `IChannelPolicy` (`nodefony/src/realtime/channelAdmission.ts#IChannelPolicy`), `RealtimeHub.registerChannelPolicy()` (`RealtimeHub.ts#RealtimeHub.registerChannelPolicy`), garde-fou `RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts#RealtimeHub.hasUnenforcedChannelPolicies`) |
1636
2113
  | Le branchement automatique du firewall | `Firewall.#wireRealtime()` (`firewall.ts#Firewall.#wireRealtime`) |
1637
2114
  | Les refus | `IRealtimeDenied` (`RealtimeEventMap.ts#IRealtimeDenied`), erreur `unauthorized` (`JsonRpcPeer.ts#JsonRpcPeer.receive`), fermetures `origin not allowed` (`RealtimeController.ts#RealtimeController.notifyClient`) et `unauthorized` (`RealtimeController.ts#RealtimeController.onHandshake`) |
1638
2115
  | La révocation en cours de session | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts#RealtimeHub.revalidateRevocable`) |
1639
2116
  | Le consommateur lent | seuil `SLOW_CONSUMER_BYTES` (`RealtimeHub.ts#SLOW_CONSUMER_BYTES`), fermeture `slow consumer` (`RealtimeHub.ts#SLOW_CONSUMER_BYTES`) |
1640
2117
  | Le plafond de canaux | `RealtimeHub.maxChannelsPerConnection` (`RealtimeHub.ts#RealtimeHub.maxChannelsPerConnection`) |
1641
2118
  | L'accueil et l'identité | `IRealtimeWelcome` (`RealtimeEventMap.ts#IRealtimeWelcome`) |
1642
- | La sonde | `RealtimeHub.probe()` (`RealtimeHub.ts#RealtimeHub.probe`), `IRealtimeProbe` (`IRealtimeProbe.ts#IRealtimeProbe`), `IRealtimeChannelStat` (`IRealtimeProbe.ts#IRealtimeChannelStat`) |
2119
+ | La sonde | `RealtimeHub.probe()` (`RealtimeHub.ts#RealtimeHub.probe`), `IRealtimeProbe` (`nodefony/src/realtime/platformHealth.ts#IRealtimeProbe`), `IRealtimeChannelStat` (`nodefony/src/realtime/platformHealth.ts#IRealtimeChannelStat`) |
1643
2120
  | Le point de mesure d'administration | `createRealtimeAdminApi()` (`RealtimeAdminApi.ts#createRealtimeAdminApi`), `buildRealtimeHealth()` (`RealtimeAdminApi.ts#buildRealtimeHealth`), `buildOwnHealth()` (`RealtimeAdminApi.ts#buildOwnHealth`) |
1644
2121
  | Le service injectable | `RealtimeService.publish()` (`RealtimeService.ts#RealtimeService.publish`), `RealtimeService.probe()` (`RealtimeService.ts#RealtimeService.probe`), `RealtimeService.markBroadcastChannel()` (`RealtimeService.ts#RealtimeService.markBroadcastChannel`) |
1645
2122
  | Les hooks React | `useNodefony()` (`client/react/index.ts#useNodefony`), `useNodefonyState()` (`client/react/index.ts#useNodefonyState`), `useNodefonyIdentity()` (`client/react/index.ts#useNodefonyIdentity`), `useNodefonyChannel()` (`client/react/index.ts#useNodefonyChannel`), `useNodefonyChannelData()` (`client/react/index.ts#useNodefonyChannelData`), `useNodefonyAdaptiveChannel()` (`client/react/index.ts#useNodefonyAdaptiveChannel`) |