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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +79 -10
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +79 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +125 -19
  6. package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
  7. package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
  8. package/dist/nodefony/src/backplane/RedisBackplane.js +143 -31
  9. package/dist/nodefony/src/backplane/controlOrder.js +57 -0
  10. package/dist/nodefony/src/backplane/envelope.js +113 -12
  11. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  12. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  13. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  14. package/dist/nodefony/src/server/RealtimeConnection.js +110 -0
  15. package/dist/nodefony/src/server/RealtimeController.js +344 -85
  16. package/dist/nodefony/src/server/RealtimeHub.js +604 -61
  17. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  18. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  19. package/dist/nodefony/src/server/channelPolicyRegistry.js +73 -0
  20. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  21. package/dist/nodefony/src/server/contractRoutes.js +85 -0
  22. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  23. package/dist/nodefony/src/server/frameAdmission.js +168 -0
  24. package/dist/nodefony/src/server/subscribedAck.js +10 -1
  25. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  26. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  27. package/dist/nodefony/src/service/RealtimeService.js +42 -1
  28. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  30. package/dist/nodefony/testing/backplaneConformance.js +271 -0
  31. package/dist/nodefony/testing/index.js +21 -4
  32. package/dist/types/index.d.ts +34 -7
  33. package/dist/types/nodefony/config/config.d.ts +9 -0
  34. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +86 -6
  35. package/dist/types/nodefony/interfaces/IBackplane.d.ts +34 -50
  36. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  37. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  38. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  39. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  40. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  41. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  42. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  43. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  44. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +79 -16
  45. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +52 -0
  46. package/dist/types/nodefony/src/backplane/envelope.d.ts +34 -0
  47. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  48. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  49. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +17 -0
  50. package/dist/types/nodefony/src/server/RealtimeController.d.ts +93 -13
  51. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +321 -24
  52. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  53. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  54. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +34 -0
  55. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  56. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  57. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  58. package/dist/types/nodefony/src/server/frameAdmission.d.ts +70 -0
  59. package/dist/types/nodefony/src/server/subscribedAck.d.ts +7 -0
  60. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  61. package/dist/types/nodefony/src/service/RealtimeService.d.ts +10 -2
  62. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  63. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  64. package/dist/types/nodefony/testing/backplaneConformance.d.ts +66 -0
  65. package/dist/types/nodefony/testing/index.d.ts +17 -4
  66. package/docs/actions.md +442 -19
  67. package/docs/architecture.md +83 -8
  68. package/docs/cadence-et-contre-pression.md +87 -11
  69. package/docs/canaux-a-membres.md +267 -0
  70. package/docs/cas-d-usage.md +1702 -0
  71. package/docs/configuration.md +137 -17
  72. package/docs/cookbook-chat.md +333 -85
  73. package/docs/index.md +11 -1
  74. package/docs/observabilite.md +71 -14
  75. package/docs/protocole.md +133 -24
  76. package/docs/securite.md +294 -33
  77. package/docs/vocabulaire.md +13 -12
  78. package/package.json +14 -11
@@ -35,6 +35,25 @@ coverageModule: realtime
35
35
 
36
36
  📍 [Documentation](../../../../../docs/index.md) › [@nodefony/realtime](index.md) › **Cookbook — un chat**
37
37
 
38
+ ## ⚡ Le geste — deux commandes avant d'écrire une ligne
39
+
40
+ Une ressource REST et un canal temps réel ne s'écrivent pas à la main : les générateurs de
41
+ l'application les posent, câblés et testés. Depuis la racine de l'application :
42
+
43
+ ```bash
44
+ npx nodefony create entity Message body:text room:string:index --route /api/messages
45
+ npx nodefony create controller room --kind realtime
46
+ ```
47
+
48
+ | Commande | Ce qu'elle pose |
49
+ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
50
+ | `create entity` | la table (`nodefony/entity/Message.ts`), ses schémas de validation, un service CRUD, `MessageController` — REST sur `/api/messages`, et ses lectures répondent aussi par la socket —, un test unitaire et un test e2e, le tout inscrit dans `index.ts` — puis, si l'application versionne ses migrations (toute application complète), la migration de la table, écrite et appliquée à la base de développement ; sinon la table naît au prochain démarrage |
51
+ | `create controller --kind realtime` | `RoomController` : la socket `/api/room/realtime`, le canal `room:events`, ses actions, et un test qui la pilote sans serveur ni navigateur |
52
+
53
+ La suite de ce cookbook construit le même salon À LA MAIN, dans un seul contrôleur : c'est ce qui
54
+ permet de comprendre ce que les générateurs écrivent, et de le retoucher. Pour avancer, partir des
55
+ générateurs ; pour comprendre, lire la suite.
56
+
38
57
  ## 🎯 Ce qu'on construit
39
58
 
40
59
  **En une phrase** : un salon de discussion où plusieurs personnes échangent des messages et des
@@ -512,7 +531,7 @@ requirements: {
512
531
  Cette méthode est alors joignable de **deux** façons, et c'est **le même code** qui s'exécute :
513
532
 
514
533
  - `GET /chat/history` en HTTP ;
515
- - `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.
516
535
 
517
536
  Le passage par la socket emprunte un pont interne qui rejoue le routage sur le chemin demandé, puis
518
537
  exécute l'action. Ce pont ne peut atteindre **que** les routes ayant explicitement déclaré le
@@ -575,14 +594,15 @@ import { useEffect } from "react";
575
594
  // aucune poignée de main TLS de plus.
576
595
  useEffect(() => {
577
596
  void socket
578
- .request<ChatMessage[]>("/chat/history?limit=50")
597
+ .get<ChatMessage[]>("/chat/history", { query: { limit: 50 } })
579
598
  .then((past) => setMessages(past));
580
599
  }, [socket]);
581
600
  ```
582
601
 
583
- Le chemin commence par `/` : le client le reconnaît comme un appel d'API et emprunte le pont. Une
584
- méthode RPC ordinaire, elle, ne commence jamais par `/` — il n'y a pas de collision possible entre
585
- 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.
586
606
 
587
607
  ### Ce qu'on observe
588
608
 
@@ -595,7 +615,7 @@ curl -k "https://127.0.0.1:5152/chat/history?limit=5"
595
615
 
596
616
  ```ts ignore
597
617
  // Transport 2 — la socket déjà ouverte, depuis la console du navigateur
598
- await chatSocket.request("/chat/history?limit=5");
618
+ await chatSocket.get("/chat/history", { query: { limit: 5 } });
599
619
  ```
600
620
 
601
621
  Les deux renvoient le même tableau JSON. Si tu ajoutes une garde d'autorisation sur la méthode, elle
@@ -837,13 +857,13 @@ connaître la hiérarchie des rôles et l'identité réelle.
837
857
 
838
858
  C'est ici qu'il faut être précis, car **tous les chemins ne se valent pas**.
839
859
 
840
- Un handler `@RealtimeInbound` reçoit `(params, reply)` — et **rien d'autre**. Il ne reçoit ni la
841
- connexion, ni le jeton d'identité résolu à la poignée de main. Autrement dit : depuis un canal
842
- inbound brut, le serveur **ne peut pas savoir qui parle**. Le canal peut être _gardé_ par une
843
- politique (personne d'anonyme ne s'y abonne), mais le nom de l'auteur reste, lui, déclaré par le
844
- client.
860
+ Un handler `@RealtimeInbound` reçoit `(params, reply, connection)`. Le troisième argument porte
861
+ l'identité résolue à la poignée de main : `connection.userId` est l'auteur, et c'est lui qu'on
862
+ écrit dans le message — jamais un champ de la charge, où le client peut mentir. Ce qu'un canal
863
+ inbound n'offre pas, en revanche : la validation du corps, la garde `@IsGranted`, le rejeu sans
864
+ doublon (`@Idempotent`), et la même porte en HTTP.
845
865
 
846
- La voie qui donne l'identité est celle de l'[étape 2](#-étape-2--le-même-contrôleur-sert-http-et-websocket) :
866
+ Ces garanties-là viennent par la voie de l'[étape 2](#-étape-2--le-même-contrôleur-sert-http-et-websocket) :
847
867
  une **action de contrôleur**, atteinte par la socket. Sur ce chemin, le framework établit le contexte
848
868
  de requête avec le jeton de la connexion — exactement comme en HTTP — et les décorateurs habituels
849
869
  fonctionnent :
@@ -851,52 +871,57 @@ fonctionnent :
851
871
  ```ts ignore
852
872
  // modules/chat/nodefony/controllers/ChatController.ts
853
873
  // → remplace `@RealtimeInbound("chat:send")` par une ACTION
854
- import { Body, CurrentUser, IsGranted } from "@nodefony/framework";
855
- import type { IUser } from "@nodefony/user";
874
+ import { z } from "zod";
875
+ import { Idempotent, IsGranted } from "@nodefony/framework";
876
+ import type { RouteInput } from "@nodefony/framework";
877
+
878
+ /** Ce que l'envoi accepte — VALIDÉ par le framework avant l'action, par les deux portes. */
879
+ const SendBody = z.object({ text: z.string().trim().min(1).max(2000) });
856
880
 
857
881
  /**
858
882
  * L'envoi devient une action, joignable en POST HTTP comme par la socket.
859
883
  * On y gagne ce qu'un canal inbound ne peut pas offrir : l'identité vérifiée
860
- * (`@CurrentUser`), la garde d'autorisation, et le pipeline complet.
884
+ * (`this.user`), la garde d'autorisation, la validation du corps et le
885
+ * pipeline complet.
861
886
  */
862
887
  @route("chat-send", {
863
888
  path: "/send",
864
889
  requirements: { methods: ["POST", "WEBSOCKET"] },
890
+ schema: { body: SendBody },
865
891
  })
866
892
  @IsGranted(["ROLE_USER"])
867
- async send(
868
- @Body() payload: { text?: string },
869
- @CurrentUser() user: IUser,
870
- ) {
871
- const text = typeof payload?.text === "string" ? payload.text.trim() : "";
872
- if (text.length === 0 || text.length > 2000) {
873
- throw new HttpError("message vide ou trop long", 400);
874
- }
875
-
876
- // L'auteur vient du SERVEUR. Le client ne peut plus mentir dessus.
893
+ @Idempotent()
894
+ send(input: RouteInput<{ body: typeof SendBody }>) {
895
+ // Corps vide, trop long, champ manquant : refusé AVANT d'arriver ici, par un
896
+ // 422 qui nomme le champ — en HTTP comme par la socket. Ici, `input.body.text`
897
+ // est une chaîne valide, rognée.
898
+ // L'auteur vient du SERVEUR (la zone l'a posé). Le client ne peut plus mentir dessus.
877
899
  const message: ChatMessage = {
878
- author: user.identifier,
879
- text,
900
+ author: this.user?.identifier ?? "anon.",
901
+ text: input.body.text,
880
902
  sentAt: Date.now(),
881
903
  };
882
904
 
883
905
  getRealtimeHub().publish(ROOM_CHANNEL, message);
884
- return { ok: true, sentAt: message.sentAt };
906
+ return { sentAt: message.sentAt };
885
907
  }
886
908
  ```
887
909
 
888
- Côté navigateur, l'envoi passe alors par `mutate`, qui porte la méthode HTTP logique et une clé
889
- d'idempotence — une socket qui se reconnecte peut rejouer une frame, la clé évite le doublon :
910
+ Côté navigateur, l'envoi passe par `post` — le **verbe de la route** : une route `POST` s'appelle
911
+ `socket.post(…)`, une route `GET` `socket.get(…)`, rien d'autre à apprendre. La socket pose seule une
912
+ **clé d'idempotence** sur chaque écriture : une socket qui se reconnecte peut rejouer une trame, et
913
+ `@Idempotent()` reconnaît la clé et rend la réponse déjà calculée au lieu de publier deux fois.
890
914
 
891
915
  ```tsx ignore
892
916
  // frontend/src/ChatRoom.tsx (étape 5)
893
- await socket.mutate("/chat/send", {
894
- method: "POST",
895
- body: { text },
896
- idempotencyKey: crypto.randomUUID(),
897
- });
917
+ await socket.post("/chat/send", { text });
898
918
  ```
899
919
 
920
+ > [!NOTE]
921
+ > Une clé par **appel**, posée par la socket. Pour dédoublonner une **intention** — un double clic
922
+ > sur « Envoyer » qui part deux fois —, l'écran fournit la sienne : `socket.post("/chat/send", { text
923
+ }, { idempotencyKey: brouillon.id })`. Deux appels, une seule clé, un seul message.
924
+
900
925
  > [!TIP]
901
926
  > **La règle à retenir.** Canal inbound = flux volatil, à haute fréquence, dont l'expéditeur importe
902
927
  > peu (« untel écrit »). Action de contrôleur = tout ce qui doit être **attribué, autorisé ou
@@ -917,6 +942,212 @@ const identity = useNodefonyIdentity();
917
942
  // identity.roles → pour masquer ce qui n'est pas permis
918
943
  ```
919
944
 
945
+ ### Quatre : un contrat pour les deux côtés — l'envoi et les messages privés typés
946
+
947
+ À ce stade, tout marche, mais rien ne relie les deux côtés. `socket.post("/chat/send", { texte:
948
+ "salut" })` — avec une faute de frappe — compile, part sur le réseau, et revient en 422. Le résultat
949
+ de `post` est `unknown`. Le message privé n'existe pas encore. Les trois se règlent par **un seul
950
+ fichier** que le serveur et le navigateur partagent : le **contrat**.
951
+
952
+ **Le principe : une seule source.** Le corps de `/chat/send`, c'est le schéma `SendBody` que la route
953
+ valide. Le contrat ne le **recopie** pas (`body: { text: string }`) : il le **référence**
954
+ (`typeof SendBody`). Recopier, c'est écrire deux formes qui divergeront un jour sans que rien ne le
955
+ dise — le schéma accepte `to`, la copie l'ignore, et le navigateur ne peut plus l'envoyer.
956
+
957
+ #### Le schéma partagé — fichier **créé**
958
+
959
+ ```ts ignore
960
+ // shared/chatSchemas.ts — la SEULE définition du corps de l'envoi
961
+ import { z } from "zod";
962
+
963
+ export const SendBody = z.object({
964
+ text: z.string().trim().min(1).max(2000),
965
+ /** Destinataire d'un message privé : l'`id` STABLE de son compte. Absent = le salon. */
966
+ to: z.string().optional(),
967
+ });
968
+
969
+ /** La query de l'historique — une query arrive en texte, `coerce` la convertit. */
970
+ export const HistoryQuery = z.object({
971
+ 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
+ });
975
+ ```
976
+
977
+ #### Le contrat — fichier **créé**
978
+
979
+ ```ts ignore
980
+ // shared/chatContract.ts — des TYPES seulement
981
+ import type { IRealtimeContract } from "nodefony/client";
982
+ import type { HistoryQuery, SendBody } from "./chatSchemas";
983
+
984
+ export interface ChatMessage {
985
+ author: string;
986
+ text: string;
987
+ sentAt: number;
988
+ /** Présent sur un message privé. */
989
+ to?: string;
990
+ }
991
+
992
+ /** Ce qu'on annonce au salon quand un fichier a fini d'arriver (étape 6). */
993
+ export interface ChatAttachment {
994
+ author: string;
995
+ filename: string;
996
+ size: number;
997
+ mimeType: string | null | undefined;
998
+ sentAt: number;
999
+ }
1000
+
1001
+ export interface ChatContract extends IRealtimeContract {
1002
+ /** Ce que le serveur pousse : le salon, et le canal personnel de chacun. */
1003
+ serverToClient: {
1004
+ "chat:room": ChatMessage | ChatAttachment;
1005
+ [channel: `user:${string}`]: ChatMessage;
1006
+ };
1007
+ /** Les routes que la socket appelle : "<VERBE> <chemin>" → corps, query, résultat. */
1008
+ serverRoutes: {
1009
+ "POST /chat/send": { body: typeof SendBody; result: { sentAt: number } };
1010
+ "GET /chat/history": { query: typeof HistoryQuery; result: ChatMessage[] };
1011
+ };
1012
+ }
1013
+ ```
1014
+
1015
+ `import type` est le point qui compte : le navigateur lit `typeof SendBody` **à la compilation**, et
1016
+ n'embarque **aucun octet** de zod. Le fichier du contrat, fait de types seuls, disparaît à la
1017
+ construction.
1018
+
1019
+ #### Le serveur — fichier **modifié**
1020
+
1021
+ ```ts ignore
1022
+ // modules/chat/nodefony/controllers/ChatController.ts
1023
+ import { ContractRealtimeController, getRealtimeHub } from "@nodefony/realtime";
1024
+ import type { ContractRouteSchemas } from "nodefony";
1025
+ import type { RouteInput } from "@nodefony/framework";
1026
+ import { HistoryQuery, SendBody } from "../../../../shared/chatSchemas";
1027
+ import type {
1028
+ ChatContract,
1029
+ ChatMessage,
1030
+ } from "../../../../shared/chatContract";
1031
+
1032
+ @controller("/chat")
1033
+ class ChatController extends ContractRealtimeController<ChatContract> {
1034
+ /**
1035
+ * Les VALEURS des routes du contrat — le schéma même que chaque route valide.
1036
+ * Vérifiées au démarrage (voir plus bas) ; jamais envoyées au navigateur.
1037
+ */
1038
+ static readonly serverRoutes = {
1039
+ "POST /chat/send": { body: SendBody },
1040
+ "GET /chat/history": { query: HistoryQuery },
1041
+ } satisfies ContractRouteSchemas<ChatContract>;
1042
+
1043
+ // La route de l'historique valide désormais `schema: { query: HistoryQuery }`
1044
+ // (version complète plus bas) ; l'envoi, son corps :
1045
+ @route("chat-send", {
1046
+ path: "/send",
1047
+ requirements: { methods: ["POST", "WEBSOCKET"] },
1048
+ schema: { body: SendBody },
1049
+ })
1050
+ @IsGranted(["ROLE_USER"])
1051
+ @Idempotent()
1052
+ send(input: RouteInput<{ body: typeof SendBody }>) {
1053
+ const me = this.user;
1054
+ if (me === null) throw new HttpError("connexion requise", 401);
1055
+ const { text, to } = input.body;
1056
+ const message: ChatMessage = {
1057
+ author: me.identifier,
1058
+ text,
1059
+ sentAt: Date.now(),
1060
+ };
1061
+ const hub = getRealtimeHub<ChatContract>();
1062
+ if (to === undefined) {
1063
+ hub.publish("chat:room", message);
1064
+ } else {
1065
+ // Message privé : le destinataire, sur TOUS ses onglets et TOUS les
1066
+ // serveurs — et une copie à l'expéditeur, pour ses autres onglets.
1067
+ hub.publishToUser(to, { ...message, to });
1068
+ hub.publishToUser(me.id, { ...message, to });
1069
+ }
1070
+ return { sentAt: message.sentAt };
1071
+ }
1072
+ }
1073
+ ```
1074
+
1075
+ `satisfies ContractRouteSchemas<ChatContract>` fait vérifier la table par le compilateur : une route
1076
+ du contrat **oubliée**, une route **en trop**, un **autre** schéma que `typeof SendBody` — et le
1077
+ fichier ne compile pas. `getRealtimeHub<ChatContract>()` type la diffusion : `hub.publish("chat:room",
1078
+ { txt: "…" })` ne compile pas davantage.
1079
+
1080
+ Le message privé ne demande **rien d'autre**. Le canal personnel `user:<id>` est une brique du
1081
+ framework : seule l'identité `<id>` peut s'y abonner, il traverse le bus entre serveurs, et
1082
+ personne ne paie rien tant qu'aucun écran ne l'écoute (détail : [securite.md](./securite.md)).
1083
+
1084
+ #### Le navigateur — fichier **modifié**
1085
+
1086
+ ```ts ignore
1087
+ // frontend/src/realtime.ts — la socket, typée par le contrat
1088
+ import { NodefonySocket } from "nodefony/client";
1089
+ import type { ChatContract } from "../../shared/chatContract";
1090
+
1091
+ export const chatSocket = NodefonySocket.fromContract<ChatContract>({
1092
+ url: `${scheme}://${window.location.host}/chat/realtime`,
1093
+ });
1094
+ ```
1095
+
1096
+ ```tsx ignore
1097
+ // frontend/src/ChatRoom.tsx (étape 5, typée)
1098
+ // Les messages privés de CET utilisateur : `m` est un ChatMessage, sans annotation.
1099
+ useEffect(() => chatSocket.onUser((m) => addPrivate(m)), []);
1100
+
1101
+ // L'historique : ChatMessage[] — déduit de la route, query vérifiée.
1102
+ const past = await chatSocket.get("/chat/history", { query: { limit: 50 } });
1103
+
1104
+ // L'envoi : le corps est l'ENTRÉE du schéma, le résultat est { sentAt: number }.
1105
+ const { sentAt } = await chatSocket.post("/chat/send", { text });
1106
+ await chatSocket.post("/chat/send", { text, to: bob.id }); // message privé
1107
+
1108
+ // Et ce qui ne compile plus :
1109
+ await chatSocket.post("/chat/send", { texte: "x" }); // ✗ `texte` n'existe pas dans SendBody
1110
+ await chatSocket.post("/chat/send"); // ✗ le schéma exige un corps
1111
+ ```
1112
+
1113
+ Un chemin **hors contrat** reste libre — `chatSocket.get("/stats")` rend `unknown`,
1114
+ `chatSocket.get<Stats>("/stats")` le type choisi — : le contrat type ce qu'il déclare, il n'interdit
1115
+ rien d'autre.
1116
+
1117
+ Une route à **paramètre de chemin** se déclare par son gabarit — la clé est le chemin de la route,
1118
+ `{id}` compris — et s'appelle en NOMMANT ce gabarit ; la socket le remplit :
1119
+
1120
+ ```ts ignore
1121
+ // contrat — la route valide `schema: { params: IdParams }`
1122
+ "GET /chat/messages/{id}": { params: typeof IdParams; result: ChatMessage };
1123
+
1124
+ // navigateur
1125
+ const m = await chatSocket.get("/chat/messages/{id}", { params: { id } });
1126
+ await chatSocket.get("/chat/messages/{id}"); // ✗ `params.id` manquant
1127
+ ```
1128
+
1129
+ Chaque valeur est encodée pour tenir dans **un** segment : un identifiant qui contient `/` ou `?`
1130
+ ne peut pas détourner l'appel vers une autre route, et une valeur vide, `.` ou `..` — que la
1131
+ normalisation du chemin résoudrait en remontant d'un cran — est refusée avant l'envoi. Sans
1132
+ schéma `params`, les valeurs sont typées par les `{noms}` du gabarit. Seuls des segments
1133
+ `{nom}` entiers se déclarent : une contrainte s'écrit dans `requirements` de la route, pas en
1134
+ ligne (`{id}(\d+)`) — le démarrage le refuse en nommant l'entrée.
1135
+
1136
+ #### Ce que le démarrage refuse
1137
+
1138
+ Le contrat est un **type** : il fait compiler `chatSocket.post("/chat/send", …)` même si la route
1139
+ n'existe pas. C'est la table `serverRoutes` qui porte les **valeurs**, et le démarrage les confronte
1140
+ aux routes montées — une fois, coût nul par requête. Trois fautes, trois refus qui nomment l'entrée :
1141
+
1142
+ | Faute | Le démarrage s'arrête sur |
1143
+ | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
1144
+ | La route n'existe pas (chemin mal recopié, module non chargé) | `ChatController.serverRoutes « POST /chat/send » : aucune route montée au chemin …` |
1145
+ | La route sert HTTP seulement (`methods: ["POST"]`) | `… ne sert pas POST par la socket — ses méthodes doivent inclure "POST" et "WEBSOCKET".` |
1146
+ | 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).` |
1147
+
1148
+ Sans ce contrôle, chacune de ces fautes compilerait, démarrerait, et n'apparaîtrait qu'au premier
1149
+ message envoyé — chez l'utilisateur.
1150
+
920
1151
  ### Ce que voit précisément un intrus
921
1152
 
922
1153
  Rien n'échoue en silence, et rien ne renseigne l'attaquant sur **pourquoi** il échoue.
@@ -1204,19 +1435,23 @@ La seule chose qui n'y figure pas est la persistance : `ChatController.history`
1204
1435
  mémoire, que l'[étape 4](#-étape-4--persister-lhistorique) remplace par un appel à ton service.
1205
1436
  C'est délibéré — ce fichier doit compiler sans supposer ta base.
1206
1437
 
1438
+ 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) :
1439
+ `shared/chatSchemas.ts` (les schémas que les routes valident) et `shared/chatContract.ts` (le
1440
+ contrat, types seuls).
1441
+
1207
1442
  ```ts ignore
1208
1443
  // modules/chat/nodefony/controllers/ChatController.ts — version finale
1209
1444
  import {
1210
1445
  controller,
1211
1446
  route,
1212
- Body,
1213
- Query,
1214
- CurrentUser,
1447
+ Idempotent,
1215
1448
  IsGranted,
1216
1449
  UploadedFile,
1217
1450
  } from "@nodefony/framework";
1451
+ import type { RouteInput } from "@nodefony/framework";
1218
1452
  import {
1219
- RealtimeController,
1453
+ ContractRealtimeController,
1454
+ RealtimeBroadcast,
1220
1455
  RealtimeChannel,
1221
1456
  RealtimeInbound,
1222
1457
  getRealtimeHub,
@@ -1224,29 +1459,30 @@ import {
1224
1459
  import type { RealtimePublish } from "@nodefony/realtime";
1225
1460
  import { HttpError } from "@nodefony/http";
1226
1461
  import type { ContextType, IUploadedFile } from "@nodefony/http";
1227
- import type { IUser } from "@nodefony/user";
1228
-
1229
- /** Un message tel qu'il circule sur le canal du salon. */
1230
- export interface ChatMessage {
1231
- author: string;
1232
- text: string;
1233
- sentAt: number;
1234
- }
1235
-
1236
- /** Ce qu'on annonce au salon quand un fichier a fini d'arriver. */
1237
- export interface ChatAttachment {
1238
- author: string;
1239
- filename: string;
1240
- size: number;
1241
- mimeType: string | null | undefined;
1242
- sentAt: number;
1243
- }
1462
+ import type { ContractRouteSchemas } from "nodefony";
1463
+ import { HistoryQuery, SendBody } from "../../../../shared/chatSchemas";
1464
+ import type {
1465
+ ChatAttachment,
1466
+ ChatContract,
1467
+ ChatMessage,
1468
+ } from "../../../../shared/chatContract";
1244
1469
 
1245
1470
  /** Le nom du canal du salon. Cité côté serveur ET côté client → une constante. */
1246
1471
  export const ROOM_CHANNEL = "chat:room";
1247
1472
 
1473
+ // Le salon traverse les serveurs (étape 7) : sans cette ligne, il reste sur le sien.
1474
+ @RealtimeBroadcast("chat:")
1248
1475
  @controller("/chat")
1249
- class ChatController extends RealtimeController {
1476
+ class ChatController extends ContractRealtimeController<ChatContract> {
1477
+ /**
1478
+ * Les VALEURS des routes du contrat — le schéma même que chaque route valide.
1479
+ * Vérifiées au démarrage ; jamais envoyées au navigateur.
1480
+ */
1481
+ static readonly serverRoutes = {
1482
+ "POST /chat/send": { body: SendBody },
1483
+ "GET /chat/history": { query: HistoryQuery },
1484
+ } satisfies ContractRouteSchemas<ChatContract>;
1485
+
1250
1486
  /** Stock en mémoire — remplacé par un repository à l'étape 4. */
1251
1487
  static history: ChatMessage[] = [];
1252
1488
 
@@ -1262,9 +1498,6 @@ class ChatController extends RealtimeController {
1262
1498
  return true;
1263
1499
  }
1264
1500
 
1265
- /** Ce qui doit franchir les frontières de processus (étape 7). */
1266
- // (préfixe diffusable déclaré par `@RealtimeBroadcast` sur la classe)
1267
-
1268
1501
  // ── La porte WebSocket ────────────────────────────────────────────────────
1269
1502
 
1270
1503
  @route("chat-realtime", {
@@ -1277,38 +1510,50 @@ class ChatController extends RealtimeController {
1277
1510
 
1278
1511
  // ── Les actions : HTTP et WebSocket, même code ────────────────────────────
1279
1512
 
1280
- /** Historique — `GET /chat/history` ou `socket.request("/chat/history")`. */
1513
+ /** Historique — `GET /chat/history` ou `socket.get("/chat/history")`. */
1281
1514
  @route("chat-history", {
1282
1515
  path: "/history",
1283
1516
  requirements: { methods: ["GET", "WEBSOCKET"] },
1517
+ schema: { query: HistoryQuery },
1284
1518
  })
1285
- historyAction(@Query("limit") limit?: string): ChatMessage[] {
1286
- const max = Math.min(Number(limit) || 50, 200);
1287
- return ChatController.history.slice(-max);
1519
+ historyAction(
1520
+ input: RouteInput<{ query: typeof HistoryQuery }>,
1521
+ ): ChatMessage[] {
1522
+ const { limit, since } = input.query;
1523
+ return ChatController.history
1524
+ .filter((m) => since === undefined || m.sentAt > since)
1525
+ .slice(-limit);
1288
1526
  }
1289
1527
 
1290
- /** Envoi — l'auteur vient du serveur, jamais du client. */
1528
+ /** Envoi — au salon, ou en privé à une personne ; l'auteur vient du serveur. */
1291
1529
  @route("chat-send", {
1292
1530
  path: "/send",
1293
1531
  requirements: { methods: ["POST", "WEBSOCKET"] },
1532
+ schema: { body: SendBody },
1294
1533
  })
1295
1534
  @IsGranted(["ROLE_USER"])
1296
- async send(
1297
- @Body() payload: { text?: string },
1298
- @CurrentUser() user: IUser,
1299
- ): Promise<{ ok: true; sentAt: number }> {
1300
- const text = typeof payload?.text === "string" ? payload.text.trim() : "";
1301
- if (text.length === 0 || text.length > 2000) {
1302
- throw new HttpError("message vide ou trop long", 400);
1303
- }
1535
+ @Idempotent()
1536
+ send(input: RouteInput<{ body: typeof SendBody }>): { sentAt: number } {
1537
+ const me = this.user;
1538
+ if (me === null) throw new HttpError("connexion requise", 401);
1539
+ const { text, to } = input.body;
1304
1540
  const message: ChatMessage = {
1305
- author: user.identifier,
1541
+ author: me.identifier,
1306
1542
  text,
1307
1543
  sentAt: Date.now(),
1308
1544
  };
1309
- ChatController.history.push(message);
1310
- getRealtimeHub().publish(ROOM_CHANNEL, message);
1311
- return { ok: true, sentAt: message.sentAt };
1545
+ const hub = getRealtimeHub<ChatContract>();
1546
+ if (to === undefined) {
1547
+ ChatController.history.push(message);
1548
+ hub.publish(ROOM_CHANNEL, message);
1549
+ } else {
1550
+ // Le destinataire sur tous ses onglets et tous les serveurs, et une copie
1551
+ // à l'expéditeur pour ses autres onglets. Rien à déclarer : `user:<id>`
1552
+ // est un canal du framework.
1553
+ hub.publishToUser(to, { ...message, to });
1554
+ hub.publishToUser(me.id, { ...message, to });
1555
+ }
1556
+ return { sentAt: message.sentAt };
1312
1557
  }
1313
1558
 
1314
1559
  /** Pièce jointe — entre en POST multipart, ressort par le canal. */
@@ -1319,13 +1564,12 @@ class ChatController extends RealtimeController {
1319
1564
  @IsGranted(["ROLE_USER"])
1320
1565
  async upload(
1321
1566
  @UploadedFile() file?: IUploadedFile,
1322
- @CurrentUser() user: IUser,
1323
- ): Promise<{ ok: true; filename: string; size: number }> {
1567
+ ): Promise<{ filename: string; size: number }> {
1324
1568
  if (!file) {
1325
1569
  throw new HttpError("aucun fichier reçu", 400);
1326
1570
  }
1327
1571
  const announcement: ChatAttachment = {
1328
- author: user.identifier,
1572
+ author: this.user?.identifier ?? "anon.",
1329
1573
  filename: file.filename,
1330
1574
  size: file.size,
1331
1575
  mimeType: file.mimeType,
@@ -1334,7 +1578,7 @@ class ChatController extends RealtimeController {
1334
1578
  // La requête HTTP répondra à SON auteur ; cette publication atteint tout le
1335
1579
  // salon. Un transport entre, un autre sort — dans la même méthode.
1336
1580
  getRealtimeHub().publish(ROOM_CHANNEL, announcement);
1337
- return { ok: true, filename: file.filename, size: file.size };
1581
+ return { filename: file.filename, size: file.size };
1338
1582
  }
1339
1583
 
1340
1584
  // ── Les canaux ────────────────────────────────────────────────────────────
@@ -1367,7 +1611,11 @@ class ChatController extends RealtimeController {
1367
1611
  /** Le seul canal entrant restant : haute fréquence, sans enjeu d'identité. */
1368
1612
  @RealtimeInbound("chat:typing")
1369
1613
  onTyping(params: unknown): void {
1370
- const author = (params as { author?: unknown } | null)?.author;
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;
1371
1619
  if (typeof author !== "string" || author.length === 0) return;
1372
1620
  getRealtimeHub().publish("chat:typing", { author: author.slice(0, 40) });
1373
1621
  }
@@ -1402,7 +1650,7 @@ useEffect(() => {
1402
1650
  if (state !== "connected") return;
1403
1651
  const lastSeen = messages.at(-1)?.sentAt ?? 0;
1404
1652
  void socket
1405
- .request<ChatMessage[]>(`/chat/history?since=${lastSeen}`)
1653
+ .get<ChatMessage[]>("/chat/history", { query: { since: lastSeen } })
1406
1654
  .then((missed) => setMessages((previous) => [...previous, ...missed]));
1407
1655
  }, [state]);
1408
1656
  ```
@@ -1613,14 +1861,14 @@ Pour qui veut lire l'implémentation derrière chaque étape :
1613
1861
  | La publication et le fan-out | `RealtimeHub.publish()` (`RealtimeHub.ts#RealtimeHub.publish`), réinjection locale `RealtimeHub.publishLocal()` (`RealtimeHub.ts#RealtimeHub.publishLocal`) |
1614
1862
  | Les canaux diffusables | `RealtimeBroadcast` (`realtimeDecorators.ts#RealtimeBroadcast`), `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts#RealtimeHub.markBroadcastChannel`) |
1615
1863
  | Le branchement du backplane | `RealtimeHub.setBackplane()` (`RealtimeHub.ts#RealtimeHub.setBackplane`), registre `registerBackplaneDriver()` (`backplaneRegistry.ts#registerBackplaneDriver`), cloison `resolveBackplaneOriginId()` (`originId.ts#resolveBackplaneOriginId`) |
1616
- | Les politiques de canal | `IChannelPolicy` (`IChannelPolicy.ts#IChannelPolicy`), `RealtimeHub.registerChannelPolicy()` (`RealtimeHub.ts#RealtimeHub.registerChannelPolicy`), garde-fou `RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts#RealtimeHub.hasUnenforcedChannelPolicies`) |
1864
+ | 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`) |
1617
1865
  | Le branchement automatique du firewall | `Firewall.#wireRealtime()` (`firewall.ts#Firewall.#wireRealtime`) |
1618
1866
  | 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`) |
1619
1867
  | La révocation en cours de session | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts#RealtimeHub.revalidateRevocable`) |
1620
1868
  | Le consommateur lent | seuil `SLOW_CONSUMER_BYTES` (`RealtimeHub.ts#SLOW_CONSUMER_BYTES`), fermeture `slow consumer` (`RealtimeHub.ts#SLOW_CONSUMER_BYTES`) |
1621
1869
  | Le plafond de canaux | `RealtimeHub.maxChannelsPerConnection` (`RealtimeHub.ts#RealtimeHub.maxChannelsPerConnection`) |
1622
1870
  | L'accueil et l'identité | `IRealtimeWelcome` (`RealtimeEventMap.ts#IRealtimeWelcome`) |
1623
- | La sonde | `RealtimeHub.probe()` (`RealtimeHub.ts#RealtimeHub.probe`), `IRealtimeProbe` (`IRealtimeProbe.ts#IRealtimeProbe`), `IRealtimeChannelStat` (`IRealtimeProbe.ts#IRealtimeChannelStat`) |
1871
+ | La sonde | `RealtimeHub.probe()` (`RealtimeHub.ts#RealtimeHub.probe`), `IRealtimeProbe` (`nodefony/src/realtime/platformHealth.ts#IRealtimeProbe`), `IRealtimeChannelStat` (`nodefony/src/realtime/platformHealth.ts#IRealtimeChannelStat`) |
1624
1872
  | Le point de mesure d'administration | `createRealtimeAdminApi()` (`RealtimeAdminApi.ts#createRealtimeAdminApi`), `buildRealtimeHealth()` (`RealtimeAdminApi.ts#buildRealtimeHealth`), `buildOwnHealth()` (`RealtimeAdminApi.ts#buildOwnHealth`) |
1625
1873
  | Le service injectable | `RealtimeService.publish()` (`RealtimeService.ts#RealtimeService.publish`), `RealtimeService.probe()` (`RealtimeService.ts#RealtimeService.probe`), `RealtimeService.markBroadcastChannel()` (`RealtimeService.ts#RealtimeService.markBroadcastChannel`) |
1626
1874
  | 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`) |
package/docs/index.md CHANGED
@@ -46,7 +46,9 @@ Quatre parcours selon ce que tu viens faire. L'ordre compte : chaque étape supp
46
46
  2. [Ce que le module apporte](#-ce-que-le-module-apporte) — pourquoi une couche de plus au-dessus de
47
47
  la WebSocket, et ce qu'elle t'évite d'écrire.
48
48
  3. [Architecture](./architecture.md) — le trajet complet d'une frame, du câble jusqu'à ta méthode.
49
- 4. [Cookbook — un chat](./cookbook-chat.md) — l'exemple intégrateur, de bout en bout.
49
+ 4. [Cas d'usage](./cas-d-usage.md) — jeu, salon privé, sonde, notification, tâche : l'exemple
50
+ court qui correspond à ton besoin, et le piège qui l'accompagne.
51
+ 5. [Cookbook — un chat](./cookbook-chat.md) — l'exemple intégrateur, de bout en bout.
50
52
 
51
53
  **Je passe d'un processus à plusieurs** — le moment où le temps réel casse sans prévenir. En
52
54
  mono-processus tout marchait ; à deux répliques, un message publié sur l'une n'atteint jamais un
@@ -93,6 +95,8 @@ Le tableau pour choisir en cinq secondes ; les cards en dessous pour savoir ce q
93
95
  | [Actions RPC](./actions.md) | appeler le serveur et attendre une réponse | tu veux savoir si l'appel a marché |
94
96
  | [Observabilité](./observabilite.md) | la sonde, les canaux de santé, les écrans | tu te demandes si ta socket va bien |
95
97
  | [Cadence et contre-pression](./cadence-et-contre-pression.md) | un client trop lent : ralentir, refuser, couper | un écran prend du retard, ou la mémoire monte |
98
+ | [Canal à membres](./canaux-a-membres.md) | abonner d'office, éjecter, terminer un canal | le serveur décide qui est dans le canal |
99
+ | [Cas d'usage](./cas-d-usage.md) | un exemple court par besoin, et son piège | tu sais ce que tu veux construire |
96
100
  | [Cookbook — un chat](./cookbook-chat.md) | l'exemple complet, client et serveur | tu veux du code qui marche tout de suite |
97
101
 
98
102
  ```nodefony-cards
@@ -121,6 +125,12 @@ Le tableau pour choisir en cinq secondes ; les cards en dessous pour savoir ce q
121
125
  { "icon": "🚦", "title": "cadence-et-contre-pression", "href": "./cadence-et-contre-pression.md",
122
126
  "desc": "Deux étages qui ne se parlent pas et se rencontrent quand même : le client ralentit la cadence de ses canaux d'état (AIMD), le serveur refuse d'empiler au-delà de 4 Mio puis ferme en 1013. Avec les courbes calculées par le vrai code, et les sondes qui le montrent en production.",
123
127
  "meta": "un écran prend du retard, ou la mémoire du serveur monte" },
128
+ { "icon": "👥", "title": "canaux-a-membres", "href": "./canaux-a-membres.md",
129
+ "desc": "Une partie à quatre, un salon fermé, un appel : le serveur abonne une connexion d'office, en éjecte une, termine le canal pour tous — et allow reste seul juge de qui entre.",
130
+ "meta": "le serveur décide qui est dans le canal" },
131
+ { "icon": "🎯", "title": "cas-d-usage", "href": "./cas-d-usage.md",
132
+ "desc": "Un jeu, un salon sur invitation, des capteurs, une notification personnelle, la progression d'une tâche : l'exemple de chaque besoin, exécuté par la suite de tests, avec le choix qui le justifie et l'erreur qui ne se voit qu'en production.",
133
+ "meta": "tu sais ce que tu veux construire" },
124
134
  { "icon": "🍳", "title": "cookbook-chat", "href": "./cookbook-chat.md",
125
135
  "desc": "Un salon de discussion complet : configuration, service métier, contrôleur, client navigateur, puis le passage en cluster.",
126
136
  "meta": "du code qui tourne, à déformer vers ton besoin" }