@nodefony/realtime 10.0.0-beta.5 → 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 +50 -0
  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 +340 -81
  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 +91 -11
  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 +12 -1
  66. package/docs/actions.md +419 -18
  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 +314 -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 +284 -32
  77. package/docs/vocabulaire.md +13 -12
  78. package/package.json +14 -11
@@ -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
@@ -856,13 +857,13 @@ connaître la hiérarchie des rôles et l'identité réelle.
856
857
 
857
858
  C'est ici qu'il faut être précis, car **tous les chemins ne se valent pas**.
858
859
 
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.
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.
864
865
 
865
- 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) :
866
867
  une **action de contrôleur**, atteinte par la socket. Sur ce chemin, le framework établit le contexte
867
868
  de requête avec le jeton de la connexion — exactement comme en HTTP — et les décorateurs habituels
868
869
  fonctionnent :
@@ -870,52 +871,57 @@ fonctionnent :
870
871
  ```ts ignore
871
872
  // modules/chat/nodefony/controllers/ChatController.ts
872
873
  // → remplace `@RealtimeInbound("chat:send")` par une ACTION
873
- import { Body, CurrentUser, IsGranted } from "@nodefony/framework";
874
- 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) });
875
880
 
876
881
  /**
877
882
  * L'envoi devient une action, joignable en POST HTTP comme par la socket.
878
883
  * 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.
884
+ * (`this.user`), la garde d'autorisation, la validation du corps et le
885
+ * pipeline complet.
880
886
  */
881
887
  @route("chat-send", {
882
888
  path: "/send",
883
889
  requirements: { methods: ["POST", "WEBSOCKET"] },
890
+ schema: { body: SendBody },
884
891
  })
885
892
  @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.
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.
896
899
  const message: ChatMessage = {
897
- author: user.identifier,
898
- text,
900
+ author: this.user?.identifier ?? "anon.",
901
+ text: input.body.text,
899
902
  sentAt: Date.now(),
900
903
  };
901
904
 
902
905
  getRealtimeHub().publish(ROOM_CHANNEL, message);
903
- return { ok: true, sentAt: message.sentAt };
906
+ return { sentAt: message.sentAt };
904
907
  }
905
908
  ```
906
909
 
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 :
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.
909
914
 
910
915
  ```tsx ignore
911
916
  // frontend/src/ChatRoom.tsx (étape 5)
912
- await socket.mutate("/chat/send", {
913
- method: "POST",
914
- body: { text },
915
- idempotencyKey: crypto.randomUUID(),
916
- });
917
+ await socket.post("/chat/send", { text });
917
918
  ```
918
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
+
919
925
  > [!TIP]
920
926
  > **La règle à retenir.** Canal inbound = flux volatil, à haute fréquence, dont l'expéditeur importe
921
927
  > peu (« untel écrit »). Action de contrôleur = tout ce qui doit être **attribué, autorisé ou
@@ -936,6 +942,212 @@ const identity = useNodefonyIdentity();
936
942
  // identity.roles → pour masquer ce qui n'est pas permis
937
943
  ```
938
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
+
939
1151
  ### Ce que voit précisément un intrus
940
1152
 
941
1153
  Rien n'échoue en silence, et rien ne renseigne l'attaquant sur **pourquoi** il échoue.
@@ -1223,19 +1435,23 @@ La seule chose qui n'y figure pas est la persistance : `ChatController.history`
1223
1435
  mémoire, que l'[étape 4](#-étape-4--persister-lhistorique) remplace par un appel à ton service.
1224
1436
  C'est délibéré — ce fichier doit compiler sans supposer ta base.
1225
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
+
1226
1442
  ```ts ignore
1227
1443
  // modules/chat/nodefony/controllers/ChatController.ts — version finale
1228
1444
  import {
1229
1445
  controller,
1230
1446
  route,
1231
- Body,
1232
- Query,
1233
- CurrentUser,
1447
+ Idempotent,
1234
1448
  IsGranted,
1235
1449
  UploadedFile,
1236
1450
  } from "@nodefony/framework";
1451
+ import type { RouteInput } from "@nodefony/framework";
1237
1452
  import {
1238
- RealtimeController,
1453
+ ContractRealtimeController,
1454
+ RealtimeBroadcast,
1239
1455
  RealtimeChannel,
1240
1456
  RealtimeInbound,
1241
1457
  getRealtimeHub,
@@ -1243,29 +1459,30 @@ import {
1243
1459
  import type { RealtimePublish } from "@nodefony/realtime";
1244
1460
  import { HttpError } from "@nodefony/http";
1245
1461
  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
- }
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";
1263
1469
 
1264
1470
  /** Le nom du canal du salon. Cité côté serveur ET côté client → une constante. */
1265
1471
  export const ROOM_CHANNEL = "chat:room";
1266
1472
 
1473
+ // Le salon traverse les serveurs (étape 7) : sans cette ligne, il reste sur le sien.
1474
+ @RealtimeBroadcast("chat:")
1267
1475
  @controller("/chat")
1268
- 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
+
1269
1486
  /** Stock en mémoire — remplacé par un repository à l'étape 4. */
1270
1487
  static history: ChatMessage[] = [];
1271
1488
 
@@ -1281,9 +1498,6 @@ class ChatController extends RealtimeController {
1281
1498
  return true;
1282
1499
  }
1283
1500
 
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
1501
  // ── La porte WebSocket ────────────────────────────────────────────────────
1288
1502
 
1289
1503
  @route("chat-realtime", {
@@ -1296,38 +1510,50 @@ class ChatController extends RealtimeController {
1296
1510
 
1297
1511
  // ── Les actions : HTTP et WebSocket, même code ────────────────────────────
1298
1512
 
1299
- /** Historique — `GET /chat/history` ou `socket.request("/chat/history")`. */
1513
+ /** Historique — `GET /chat/history` ou `socket.get("/chat/history")`. */
1300
1514
  @route("chat-history", {
1301
1515
  path: "/history",
1302
1516
  requirements: { methods: ["GET", "WEBSOCKET"] },
1517
+ schema: { query: HistoryQuery },
1303
1518
  })
1304
- historyAction(@Query("limit") limit?: string): ChatMessage[] {
1305
- const max = Math.min(Number(limit) || 50, 200);
1306
- 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);
1307
1526
  }
1308
1527
 
1309
- /** Envoi — l'auteur vient du serveur, jamais du client. */
1528
+ /** Envoi — au salon, ou en privé à une personne ; l'auteur vient du serveur. */
1310
1529
  @route("chat-send", {
1311
1530
  path: "/send",
1312
1531
  requirements: { methods: ["POST", "WEBSOCKET"] },
1532
+ schema: { body: SendBody },
1313
1533
  })
1314
1534
  @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
- }
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;
1323
1540
  const message: ChatMessage = {
1324
- author: user.identifier,
1541
+ author: me.identifier,
1325
1542
  text,
1326
1543
  sentAt: Date.now(),
1327
1544
  };
1328
- ChatController.history.push(message);
1329
- getRealtimeHub().publish(ROOM_CHANNEL, message);
1330
- 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 };
1331
1557
  }
1332
1558
 
1333
1559
  /** Pièce jointe — entre en POST multipart, ressort par le canal. */
@@ -1338,13 +1564,12 @@ class ChatController extends RealtimeController {
1338
1564
  @IsGranted(["ROLE_USER"])
1339
1565
  async upload(
1340
1566
  @UploadedFile() file?: IUploadedFile,
1341
- @CurrentUser() user: IUser,
1342
- ): Promise<{ ok: true; filename: string; size: number }> {
1567
+ ): Promise<{ filename: string; size: number }> {
1343
1568
  if (!file) {
1344
1569
  throw new HttpError("aucun fichier reçu", 400);
1345
1570
  }
1346
1571
  const announcement: ChatAttachment = {
1347
- author: user.identifier,
1572
+ author: this.user?.identifier ?? "anon.",
1348
1573
  filename: file.filename,
1349
1574
  size: file.size,
1350
1575
  mimeType: file.mimeType,
@@ -1353,7 +1578,7 @@ class ChatController extends RealtimeController {
1353
1578
  // La requête HTTP répondra à SON auteur ; cette publication atteint tout le
1354
1579
  // salon. Un transport entre, un autre sort — dans la même méthode.
1355
1580
  getRealtimeHub().publish(ROOM_CHANNEL, announcement);
1356
- return { ok: true, filename: file.filename, size: file.size };
1581
+ return { filename: file.filename, size: file.size };
1357
1582
  }
1358
1583
 
1359
1584
  // ── Les canaux ────────────────────────────────────────────────────────────
@@ -1386,7 +1611,11 @@ class ChatController extends RealtimeController {
1386
1611
  /** Le seul canal entrant restant : haute fréquence, sans enjeu d'identité. */
1387
1612
  @RealtimeInbound("chat:typing")
1388
1613
  onTyping(params: unknown): void {
1389
- 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;
1390
1619
  if (typeof author !== "string" || author.length === 0) return;
1391
1620
  getRealtimeHub().publish("chat:typing", { author: author.slice(0, 40) });
1392
1621
  }
@@ -1421,7 +1650,7 @@ useEffect(() => {
1421
1650
  if (state !== "connected") return;
1422
1651
  const lastSeen = messages.at(-1)?.sentAt ?? 0;
1423
1652
  void socket
1424
- .request<ChatMessage[]>(`/chat/history?since=${lastSeen}`)
1653
+ .get<ChatMessage[]>("/chat/history", { query: { since: lastSeen } })
1425
1654
  .then((missed) => setMessages((previous) => [...previous, ...missed]));
1426
1655
  }, [state]);
1427
1656
  ```
@@ -1632,14 +1861,14 @@ Pour qui veut lire l'implémentation derrière chaque étape :
1632
1861
  | La publication et le fan-out | `RealtimeHub.publish()` (`RealtimeHub.ts#RealtimeHub.publish`), réinjection locale `RealtimeHub.publishLocal()` (`RealtimeHub.ts#RealtimeHub.publishLocal`) |
1633
1862
  | Les canaux diffusables | `RealtimeBroadcast` (`realtimeDecorators.ts#RealtimeBroadcast`), `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts#RealtimeHub.markBroadcastChannel`) |
1634
1863
  | 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`) |
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`) |
1636
1865
  | Le branchement automatique du firewall | `Firewall.#wireRealtime()` (`firewall.ts#Firewall.#wireRealtime`) |
1637
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`) |
1638
1867
  | La révocation en cours de session | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts#RealtimeHub.revalidateRevocable`) |
1639
1868
  | Le consommateur lent | seuil `SLOW_CONSUMER_BYTES` (`RealtimeHub.ts#SLOW_CONSUMER_BYTES`), fermeture `slow consumer` (`RealtimeHub.ts#SLOW_CONSUMER_BYTES`) |
1640
1869
  | Le plafond de canaux | `RealtimeHub.maxChannelsPerConnection` (`RealtimeHub.ts#RealtimeHub.maxChannelsPerConnection`) |
1641
1870
  | 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`) |
1871
+ | La sonde | `RealtimeHub.probe()` (`RealtimeHub.ts#RealtimeHub.probe`), `IRealtimeProbe` (`nodefony/src/realtime/platformHealth.ts#IRealtimeProbe`), `IRealtimeChannelStat` (`nodefony/src/realtime/platformHealth.ts#IRealtimeChannelStat`) |
1643
1872
  | Le point de mesure d'administration | `createRealtimeAdminApi()` (`RealtimeAdminApi.ts#createRealtimeAdminApi`), `buildRealtimeHealth()` (`RealtimeAdminApi.ts#buildRealtimeHealth`), `buildOwnHealth()` (`RealtimeAdminApi.ts#buildOwnHealth`) |
1644
1873
  | Le service injectable | `RealtimeService.publish()` (`RealtimeService.ts#RealtimeService.publish`), `RealtimeService.probe()` (`RealtimeService.ts#RealtimeService.probe`), `RealtimeService.markBroadcastChannel()` (`RealtimeService.ts#RealtimeService.markBroadcastChannel`) |
1645
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" }