@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.
- package/README.md +50 -0
- 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 +340 -81
- 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 +91 -11
- 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 +12 -1
- package/docs/actions.md +419 -18
- 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 +314 -85
- package/docs/index.md +11 -1
- package/docs/observabilite.md +71 -14
- package/docs/protocole.md +133 -24
- package/docs/securite.md +284 -32
- package/docs/vocabulaire.md +13 -12
- package/package.json +14 -11
package/docs/cookbook-chat.md
CHANGED
|
@@ -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.
|
|
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
|
-
.
|
|
597
|
+
.get<ChatMessage[]>("/chat/history", { query: { limit: 50 } })
|
|
598
598
|
.then((past) => setMessages(past));
|
|
599
599
|
}, [socket]);
|
|
600
600
|
```
|
|
601
601
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
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.
|
|
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.
|
|
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)
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
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 {
|
|
874
|
-
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) });
|
|
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
|
-
* (
|
|
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
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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.
|
|
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 {
|
|
906
|
+
return { sentAt: message.sentAt };
|
|
904
907
|
}
|
|
905
908
|
```
|
|
906
909
|
|
|
907
|
-
Côté navigateur, l'envoi passe
|
|
908
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
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
|
|
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.
|
|
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(
|
|
1305
|
-
|
|
1306
|
-
|
|
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
|
|
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
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
const
|
|
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:
|
|
1541
|
+
author: me.identifier,
|
|
1325
1542
|
text,
|
|
1326
1543
|
sentAt: Date.now(),
|
|
1327
1544
|
};
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
.
|
|
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` (`
|
|
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` (`
|
|
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. [
|
|
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" }
|