@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.
- package/README.md +79 -10
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +79 -12
- package/dist/nodefony/config/config.js +7 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +125 -19
- package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
- package/dist/nodefony/src/backplane/RedisBackplane.js +143 -31
- package/dist/nodefony/src/backplane/controlOrder.js +57 -0
- package/dist/nodefony/src/backplane/envelope.js +113 -12
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
- package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
- package/dist/nodefony/src/server/RealtimeConnection.js +110 -0
- package/dist/nodefony/src/server/RealtimeController.js +344 -85
- package/dist/nodefony/src/server/RealtimeHub.js +604 -61
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
- package/dist/nodefony/src/server/admissionBudget.js +176 -0
- package/dist/nodefony/src/server/channelPolicyRegistry.js +73 -0
- package/dist/nodefony/src/server/connectionAdmission.js +242 -0
- package/dist/nodefony/src/server/contractRoutes.js +85 -0
- package/dist/nodefony/src/server/deniedDetail.js +10 -3
- package/dist/nodefony/src/server/frameAdmission.js +168 -0
- package/dist/nodefony/src/server/subscribedAck.js +10 -1
- package/dist/nodefony/src/server/syslogUplink.js +3 -4
- package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
- package/dist/nodefony/src/service/RealtimeService.js +42 -1
- package/dist/nodefony/src/service/publicOrigin.js +40 -0
- package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
- package/dist/nodefony/testing/backplaneConformance.js +271 -0
- package/dist/nodefony/testing/index.js +21 -4
- package/dist/types/index.d.ts +34 -7
- package/dist/types/nodefony/config/config.d.ts +9 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +86 -6
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +34 -50
- package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
- package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
- package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
- package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
- package/dist/types/nodefony/interfaces/index.d.ts +2 -1
- package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +79 -16
- package/dist/types/nodefony/src/backplane/controlOrder.d.ts +52 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +34 -0
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
- package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
- package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +17 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +93 -13
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +321 -24
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
- package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
- package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +34 -0
- package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
- package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
- package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
- package/dist/types/nodefony/src/server/frameAdmission.d.ts +70 -0
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +7 -0
- package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +10 -2
- package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
- package/dist/types/nodefony/testing/backplaneConformance.d.ts +66 -0
- package/dist/types/nodefony/testing/index.d.ts +17 -4
- package/docs/actions.md +442 -19
- package/docs/architecture.md +83 -8
- package/docs/cadence-et-contre-pression.md +87 -11
- package/docs/canaux-a-membres.md +267 -0
- package/docs/cas-d-usage.md +1702 -0
- package/docs/configuration.md +137 -17
- package/docs/cookbook-chat.md +333 -85
- package/docs/index.md +11 -1
- package/docs/observabilite.md +71 -14
- package/docs/protocole.md +133 -24
- package/docs/securite.md +294 -33
- package/docs/vocabulaire.md +13 -12
- package/package.json +14 -11
package/docs/cookbook-chat.md
CHANGED
|
@@ -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.
|
|
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
|
-
.
|
|
597
|
+
.get<ChatMessage[]>("/chat/history", { query: { limit: 50 } })
|
|
579
598
|
.then((past) => setMessages(past));
|
|
580
599
|
}, [socket]);
|
|
581
600
|
```
|
|
582
601
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
les
|
|
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.
|
|
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)
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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 {
|
|
855
|
-
import
|
|
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
|
-
* (
|
|
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
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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.
|
|
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 {
|
|
906
|
+
return { sentAt: message.sentAt };
|
|
885
907
|
}
|
|
886
908
|
```
|
|
887
909
|
|
|
888
|
-
Côté navigateur, l'envoi passe
|
|
889
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
|
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.
|
|
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(
|
|
1286
|
-
|
|
1287
|
-
|
|
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
|
|
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
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
const
|
|
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:
|
|
1541
|
+
author: me.identifier,
|
|
1306
1542
|
text,
|
|
1307
1543
|
sentAt: Date.now(),
|
|
1308
1544
|
};
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
.
|
|
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` (`
|
|
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` (`
|
|
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. [
|
|
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" }
|