@nodefony/realtime 10.0.0-beta.5 → 10.0.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +91 -12
- package/dist/nodefony/config/config.js +7 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +341 -43
- package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
- package/dist/nodefony/src/backplane/RedisBackplane.js +146 -41
- package/dist/nodefony/src/backplane/controlOrder.js +86 -0
- package/dist/nodefony/src/backplane/envelope.js +505 -27
- package/dist/nodefony/src/backplane/ingress.js +93 -0
- package/dist/nodefony/src/backplane/presenceMessage.js +205 -0
- package/dist/nodefony/src/backplane/publishQueue.js +97 -3
- 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 +135 -0
- package/dist/nodefony/src/server/RealtimeController.js +409 -107
- package/dist/nodefony/src/server/RealtimeHub.js +1158 -111
- 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 +95 -0
- package/dist/nodefony/src/server/channelPolicyResolution.js +191 -0
- package/dist/nodefony/src/server/channelPresence.js +488 -0
- package/dist/nodefony/src/server/connectionAdmission.js +242 -0
- package/dist/nodefony/src/server/contractRoutes.js +75 -0
- package/dist/nodefony/src/server/deniedDetail.js +10 -3
- package/dist/nodefony/src/server/frameAdmission.js +160 -0
- package/dist/nodefony/src/server/presenceBus.js +486 -0
- package/dist/nodefony/src/server/realtimeShutdown.js +31 -0
- package/dist/nodefony/src/server/remotePresence.js +541 -0
- package/dist/nodefony/src/server/subscribedAck.js +13 -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 +51 -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 +347 -0
- package/dist/nodefony/testing/index.js +43 -12
- package/dist/nodefony/testing/presenceClient.js +54 -0
- package/dist/types/index.d.ts +39 -7
- package/dist/types/nodefony/config/config.d.ts +9 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +198 -17
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +46 -51
- 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 +89 -18
- package/dist/types/nodefony/src/backplane/controlOrder.d.ts +75 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +77 -0
- package/dist/types/nodefony/src/backplane/ingress.d.ts +48 -0
- package/dist/types/nodefony/src/backplane/presenceMessage.d.ts +150 -0
- package/dist/types/nodefony/src/backplane/publishQueue.d.ts +25 -1
- 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 +20 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +110 -12
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +448 -42
- 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 +41 -0
- package/dist/types/nodefony/src/server/channelPolicyResolution.d.ts +112 -0
- package/dist/types/nodefony/src/server/channelPresence.d.ts +155 -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 +66 -0
- package/dist/types/nodefony/src/server/presenceBus.d.ts +112 -0
- package/dist/types/nodefony/src/server/realtimeShutdown.d.ts +21 -0
- package/dist/types/nodefony/src/server/remotePresence.d.ts +151 -0
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +10 -0
- package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +17 -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 +78 -0
- package/dist/types/nodefony/testing/index.d.ts +13 -2
- package/dist/types/nodefony/testing/presenceClient.d.ts +20 -0
- package/docs/actions.md +420 -18
- package/docs/architecture.md +101 -11
- package/docs/cadence-et-contre-pression.md +87 -11
- package/docs/canaux-a-membres.md +277 -0
- package/docs/cas-d-usage.md +1703 -0
- package/docs/configuration.md +175 -19
- package/docs/cookbook-chat.md +701 -224
- package/docs/index.md +11 -1
- package/docs/observabilite.md +97 -21
- package/docs/protocole.md +142 -33
- package/docs/securite.md +347 -41
- package/docs/vocabulaire.md +13 -12
- package/package.json +16 -13
package/docs/cookbook-chat.md
CHANGED
|
@@ -77,7 +77,7 @@ apporte.
|
|
|
77
77
|
| --------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------- |
|
|
78
78
|
| [1 — le chat minimal](#-démarrage-rapide--étape-1-le-chat-minimal-qui-marche) | Un canal, un message qui traverse | Deux onglets qui se parlent |
|
|
79
79
|
| [2 — HTTP et WebSocket](#-étape-2--le-même-contrôleur-sert-http-et-websocket) | L'historique en `GET` dans la **même classe** | Une action, deux transports |
|
|
80
|
-
| [3 — présence et frappe](#-étape-3--présence-et-frappe-en-cours) |
|
|
80
|
+
| [3 — présence et frappe](#-étape-3--présence-et-frappe-en-cours) | La présence en option du canal, la frappe en message | Ni canal ni minuteur de plus |
|
|
81
81
|
| [4 — persister](#-étape-4--persister-lhistorique) | Un repository derrière un service | L'historique survit au redémarrage |
|
|
82
82
|
| [5 — protéger](#-étape-5--protéger-le-salon) | Zone firewall + politique de canal | L'intrus est refusé, et sait qu'il l'est |
|
|
83
83
|
| [6 — la pièce jointe](#-étape-6--la-pièce-jointe-http-et-websocket-dans-la-même-classe) | `POST` multipart + annonce sur le canal | Le différenciateur devient **visible** |
|
|
@@ -92,23 +92,23 @@ apporte.
|
|
|
92
92
|
Tous les termes employés dans cette page. Le fond de chacun est dans
|
|
93
93
|
[vocabulaire.md](./vocabulaire.md) — ici, la définition strictement suffisante pour suivre.
|
|
94
94
|
|
|
95
|
-
| Terme | Ce que c'est
|
|
96
|
-
| ------------------ |
|
|
97
|
-
| **Socket** | La connexion WebSocket unique entre un navigateur et le serveur. Une seule par onglet, quel que soit le nombre de canaux.
|
|
98
|
-
| **Canal** | Un flux nommé qui circule **dans** la socket (`chat:room`, `
|
|
99
|
-
| **Frame** | Un message du protocole sur le fil. Toutes les frames sont du JSON-RPC 2.0.
|
|
100
|
-
| **Hub** | Le distributeur côté serveur : il tient la liste « qui écoute quel canal » et recopie chaque publication vers les abonnés du processus courant.
|
|
101
|
-
| **Backplane** | Le « fond de panier » : ce qui relaie les publications d'un processus à l'autre. Sans lui, chaque réplique est une île.
|
|
102
|
-
| **Pod / réplique** | Un processus Node qui sert l'application. Un seul en développement, plusieurs en production.
|
|
103
|
-
| **Fan-out** | La recopie d'une publication vers N abonnés. Une publication, N livraisons.
|
|
104
|
-
| **Inbound** | Un canal où le **client** pousse vers le serveur. Sens inverse du pub/sub habituel ; il faut le déclarer explicitement.
|
|
105
|
-
| **RPC** | _Remote Procedure Call_ : le client appelle une méthode serveur et attend une réponse (une `Promise`). Par opposition à une notification, qui ne répond pas.
|
|
106
|
-
| **Provider** | La fonction qui « alimente » un canal côté serveur. Créée au premier abonné, détruite au dernier.
|
|
107
|
-
| **Sink** | Le point de sortie d'une connexion sur un canal. Un abonné = un sink.
|
|
108
|
-
| **BFF** | _Backend For Frontend_ : le serveur porte la session dans un cookie, le navigateur ne manipule aucun jeton.
|
|
109
|
-
| **Zone / area** | Un motif d'URL que le firewall protège, avec la liste des façons de prouver son identité.
|
|
110
|
-
| **Politique** | Les exigences attachées à un canal : authentifié, tel rôle, tel scope.
|
|
111
|
-
| **Multipart** | L'encodage d'un formulaire HTTP qui transporte des fichiers (`multipart/form-data`).
|
|
95
|
+
| Terme | Ce que c'est |
|
|
96
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
97
|
+
| **Socket** | La connexion WebSocket unique entre un navigateur et le serveur. Une seule par onglet, quel que soit le nombre de canaux. |
|
|
98
|
+
| **Canal** | Un flux nommé qui circule **dans** la socket (`chat:room`, `room:42`). Analogie : la socket est le câble, les canaux sont les fréquences qui y passent. |
|
|
99
|
+
| **Frame** | Un message du protocole sur le fil. Toutes les frames sont du JSON-RPC 2.0. |
|
|
100
|
+
| **Hub** | Le distributeur côté serveur : il tient la liste « qui écoute quel canal » et recopie chaque publication vers les abonnés du processus courant. |
|
|
101
|
+
| **Backplane** | Le « fond de panier » : ce qui relaie les publications d'un processus à l'autre. Sans lui, chaque réplique est une île. |
|
|
102
|
+
| **Pod / réplique** | Un processus Node qui sert l'application. Un seul en développement, plusieurs en production. |
|
|
103
|
+
| **Fan-out** | La recopie d'une publication vers N abonnés. Une publication, N livraisons. |
|
|
104
|
+
| **Inbound** | Un canal où le **client** pousse vers le serveur. Sens inverse du pub/sub habituel ; il faut le déclarer explicitement. |
|
|
105
|
+
| **RPC** | _Remote Procedure Call_ : le client appelle une méthode serveur et attend une réponse (une `Promise`). Par opposition à une notification, qui ne répond pas. |
|
|
106
|
+
| **Provider** | La fonction qui « alimente » un canal côté serveur. Créée au premier abonné, détruite au dernier. |
|
|
107
|
+
| **Sink** | Le point de sortie d'une connexion sur un canal. Un abonné = un sink. |
|
|
108
|
+
| **BFF** | _Backend For Frontend_ : le serveur porte la session dans un cookie, le navigateur ne manipule aucun jeton. |
|
|
109
|
+
| **Zone / area** | Un motif d'URL que le firewall protège, avec la liste des façons de prouver son identité. |
|
|
110
|
+
| **Politique** | Les exigences attachées à un canal : authentifié, tel rôle, tel scope. |
|
|
111
|
+
| **Multipart** | L'encodage d'un formulaire HTTP qui transporte des fichiers (`multipart/form-data`). |
|
|
112
112
|
|
|
113
113
|
## 🧠 Le modèle mental en trois phrases
|
|
114
114
|
|
|
@@ -531,7 +531,7 @@ requirements: {
|
|
|
531
531
|
Cette méthode est alors joignable de **deux** façons, et c'est **le même code** qui s'exécute :
|
|
532
532
|
|
|
533
533
|
- `GET /chat/history` en HTTP ;
|
|
534
|
-
- `socket.
|
|
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
|
|
@@ -629,102 +630,324 @@ s'applique aux **deux** — c'est le même pipeline, pas deux implémentations
|
|
|
629
630
|
|
|
630
631
|
## 🧩 Étape 3 : présence et frappe en cours
|
|
631
632
|
|
|
632
|
-
On ajoute deux
|
|
633
|
-
la fonctionnalité — c'est **ce qu'elle ne coûte pas
|
|
633
|
+
On ajoute deux choses : qui est là, et qui est en train d'écrire. Le point de cette étape n'est pas
|
|
634
|
+
la fonctionnalité — c'est **ce qu'elle ne coûte pas** : pas un canal de plus, pas un minuteur, pas
|
|
635
|
+
une ligne de code par pod.
|
|
634
636
|
|
|
635
|
-
###
|
|
637
|
+
### La présence est une option du canal, pas un canal de plus
|
|
636
638
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
639
|
+
Être abonné au salon, c'est y être présent. La présence se déclare donc **sur le canal lui-même**,
|
|
640
|
+
par une option de sa politique — `presence: true` — et le framework fait le reste : il tient la liste
|
|
641
|
+
des présents, l'envoie entière à chaque abonné qui arrive, puis n'envoie que les changements,
|
|
642
|
+
regroupés par fenêtre de 250 ms. Une connexion qui se ferme s'en va aussitôt de la liste.
|
|
641
643
|
|
|
642
644
|
```mermaid
|
|
643
645
|
flowchart LR
|
|
644
646
|
N["Onglet du navigateur<br/>UNE connexion WebSocket"]
|
|
645
|
-
N -.->|"chat:room"|
|
|
646
|
-
|
|
647
|
-
|
|
647
|
+
N -.->|"chat:room"| C["Le canal du salon"]
|
|
648
|
+
C --> M[Messages]
|
|
649
|
+
C --> T["« Untel écrit… »"]
|
|
650
|
+
C --> P["Qui est là<br/>(option presence)"]
|
|
648
651
|
```
|
|
649
652
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
+
> [!WARNING]
|
|
654
|
+
> **La recette « un `Set` des connectés publié toutes les cinq secondes » est fausse dès deux
|
|
655
|
+
> processus.** Chaque processus ne connaît que les connexions qu'il tient : derrière
|
|
656
|
+
> `nodefony cluster -w 2` ou deux pods, chacun publie SA liste et écrase celle de l'autre. Elle coûte
|
|
657
|
+
> en plus un envoi permanent, et annonce les départs jusqu'à cinq secondes en retard. La présence du
|
|
658
|
+
> framework est tenue **entre les pods** (étape 7) : rien à écrire de plus.
|
|
659
|
+
|
|
660
|
+
### La frappe est un message, pas un état
|
|
661
|
+
|
|
662
|
+
« Untel écrit » ne dit pas qui est là : c'est un message éphémère, publié sur le canal du salon,
|
|
663
|
+
que chacun reçoit parce qu'il y est abonné et que l'écran efface seul. Il ne passe jamais par la
|
|
664
|
+
présence — sinon chaque frappe ferait renvoyer la liste à tout le salon.
|
|
665
|
+
|
|
666
|
+
> [!IMPORTANT]
|
|
667
|
+
> **À cette étape, l'auteur de la frappe vient du client** — comme le pseudo du message de l'étape 1,
|
|
668
|
+
> et pour la même raison : il n'y a encore ni comptes ni salons, un seul salon ouvert à tous. Ce
|
|
669
|
+
> n'est pas la forme finale. Dès l'[étape 5](#-étape-5--protéger-le-salon), l'auteur vient de la
|
|
670
|
+
> **connexion** (`this.user`), jamais d'un champ : un « Untel écrit » forgé usurpe aussi bien qu'un
|
|
671
|
+
> message (voir le [contrôleur complet](#-le-contrôleur-complet)).
|
|
653
672
|
|
|
654
673
|
### Le contrôleur — fichier **modifié**
|
|
655
674
|
|
|
656
|
-
|
|
657
|
-
|
|
675
|
+
Le contrôleur entier à cette étape ; l'envoi de l'étape 1 (`chat:send`) ne change pas et n'est pas
|
|
676
|
+
répété.
|
|
658
677
|
|
|
659
|
-
|
|
660
|
-
static present = new Set<string>();
|
|
678
|
+
<!-- example: chatPresence.ts -->
|
|
661
679
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
680
|
+
```ts
|
|
681
|
+
import { controller, route } from "@nodefony/framework";
|
|
682
|
+
import type { ContextType } from "@nodefony/http";
|
|
683
|
+
import {
|
|
684
|
+
RealtimeChannel,
|
|
685
|
+
RealtimeController,
|
|
686
|
+
RealtimeInbound,
|
|
687
|
+
getRealtimeHub,
|
|
688
|
+
} from "@nodefony/realtime";
|
|
689
|
+
|
|
690
|
+
/** Le canal du salon : il porte les messages, la frappe — et la présence. */
|
|
691
|
+
export const ROOM_CHANNEL = "chat:room";
|
|
692
|
+
|
|
693
|
+
/** « Untel écrit » : un message ÉPHÉMÈRE du canal, jamais gardé, jamais rejoué. */
|
|
694
|
+
export interface ChatTyping {
|
|
695
|
+
typing: string;
|
|
675
696
|
}
|
|
676
697
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
698
|
+
@controller("/chat")
|
|
699
|
+
export class ChatController extends RealtimeController {
|
|
700
|
+
constructor(context: ContextType) {
|
|
701
|
+
super("chat", context);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
@route("chat-realtime", {
|
|
705
|
+
path: "/realtime",
|
|
706
|
+
requirements: { methods: ["WEBSOCKET"] },
|
|
707
|
+
})
|
|
708
|
+
async realtime(message: string | Uint8Array | null): Promise<void> {
|
|
709
|
+
this.handleRealtime(message);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// La présence est portée PAR le canal : être abonné, c'est être présent. Ni
|
|
713
|
+
// minuteur, ni liste à tenir, ni second canal — et elle reste juste quand
|
|
714
|
+
// l'application tourne sur plusieurs pods (étape 7).
|
|
715
|
+
@RealtimeChannel(ROOM_CHANNEL, { presence: true })
|
|
716
|
+
room(): () => void {
|
|
717
|
+
return () => {};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// La frappe n'est PAS un état de présence : c'est un message du canal, que
|
|
721
|
+
// chacun reçoit parce qu'il y est abonné, et que l'écran efface seul.
|
|
722
|
+
@RealtimeInbound("chat:typing")
|
|
723
|
+
onTyping(params: unknown): void {
|
|
724
|
+
if (!ChatController.isTyping(params)) return;
|
|
725
|
+
const typing: ChatTyping = { typing: params.author.slice(0, 40) };
|
|
726
|
+
getRealtimeHub().publish(ROOM_CHANNEL, typing);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Ce qui arrive du réseau n'est pas fiable : un auteur non vide, ou rien. */
|
|
730
|
+
private static isTyping(value: unknown): value is { author: string } {
|
|
731
|
+
return (
|
|
732
|
+
typeof value === "object" &&
|
|
733
|
+
value !== null &&
|
|
734
|
+
"author" in value &&
|
|
735
|
+
typeof value.author === "string" &&
|
|
736
|
+
value.author.length > 0
|
|
737
|
+
);
|
|
738
|
+
}
|
|
681
739
|
}
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
### Côté client
|
|
743
|
+
|
|
744
|
+
`onPresence` rappelle l'écran avec **la vue entière** à chaque changement : il n'y a rien à
|
|
745
|
+
fusionner soi-même. En React, `useNodefonyPresence(chatSocket, "chat:room")` rend le même
|
|
746
|
+
instantané (Vue, Angular et Svelte ont leur équivalent).
|
|
682
747
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
748
|
+
<!-- example: chatPresence.client.ts -->
|
|
749
|
+
|
|
750
|
+
```ts
|
|
751
|
+
import { NodefonySocket } from "nodefony/client";
|
|
752
|
+
|
|
753
|
+
const ROOM = "chat:room";
|
|
754
|
+
const socket = NodefonySocket.shared({ url: "/chat/realtime" });
|
|
755
|
+
|
|
756
|
+
/** Qui est là : rappelé à chaque changement, la vue entière à chaque fois. */
|
|
757
|
+
export function watchPresence(render: (line: string) => void): () => void {
|
|
758
|
+
const off = socket.onPresence(ROOM, (p) => {
|
|
759
|
+
if (!p.ready) return; // « pas encore reçu » n'est pas « vide »
|
|
760
|
+
const names = [...p.members.keys()].join(", ");
|
|
761
|
+
const total = p.count + p.unnamed; // nommés (comptes) + connexions anonymes
|
|
762
|
+
render(`${total} présent(s)${names === "" ? "" : ` : ${names}`}`);
|
|
763
|
+
});
|
|
764
|
+
socket.subscribe(ROOM);
|
|
765
|
+
return () => {
|
|
766
|
+
off();
|
|
767
|
+
socket.unsubscribe(ROOM);
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** Signaler qu'on écrit — au plus une fois toutes les deux secondes. */
|
|
772
|
+
export function typingNotifier(author: string): () => void {
|
|
773
|
+
let last = 0;
|
|
774
|
+
return () => {
|
|
775
|
+
const now = Date.now();
|
|
776
|
+
if (now - last < 2000) return;
|
|
777
|
+
last = now;
|
|
778
|
+
socket.emit("chat:typing", { author });
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** « Untel écrit… » : affiché à réception, effacé seul trois secondes plus tard. */
|
|
783
|
+
export function watchTyping(
|
|
784
|
+
self: string,
|
|
785
|
+
show: (author: string | null) => void,
|
|
786
|
+
): () => void {
|
|
787
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
788
|
+
return socket.on(ROOM, (frame) => {
|
|
789
|
+
if (typeof frame !== "object" || frame === null || !("typing" in frame))
|
|
790
|
+
return;
|
|
791
|
+
if (typeof frame.typing !== "string" || frame.typing === self) return;
|
|
792
|
+
show(frame.typing);
|
|
793
|
+
clearTimeout(timer);
|
|
794
|
+
timer = setTimeout(() => show(null), 3000);
|
|
795
|
+
});
|
|
689
796
|
}
|
|
690
797
|
```
|
|
691
798
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
799
|
+
| Champ de l'instantané | Ce qu'il dit |
|
|
800
|
+
| --------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
801
|
+
| `ready` | `false` tant que la première liste n'est pas arrivée — « pas encore reçu » n'est pas « vide » |
|
|
802
|
+
| `count` | les présents **nommés** : un par compte, quel que soit son nombre d'onglets |
|
|
803
|
+
| `unnamed` | les connexions comptées **sans nom** : anonymes, appelants machine sans compte |
|
|
804
|
+
| `members` | `Map` identité → `{ tabs }` : qui, et dans combien d'onglets |
|
|
805
|
+
| `partial` | `true` quand la vue est incomplète ou figée (connexion coupée, pod injoignable) — la liste suivante la répare |
|
|
696
806
|
|
|
697
|
-
###
|
|
807
|
+
### À cette étape, personne n'est nommé
|
|
698
808
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
809
|
+
Tout le monde est encore anonyme : la présence **compte** les connexions (`unnamed`), elle ne nomme
|
|
810
|
+
personne. Et le pseudo que le client envoie avec son message n'y change rien — se déclarer « Alice »
|
|
811
|
+
ne fait pas de toi une présente nommée. Les noms arrivent à l'[étape 5](#-étape-5--protéger-le-salon),
|
|
812
|
+
avec les comptes : la présence nomme ce que le serveur a vérifié, jamais ce que le client affirme.
|
|
813
|
+
|
|
814
|
+
### Le test — fichier **créé**
|
|
815
|
+
|
|
816
|
+
Le test joue des connexions réelles contre le contrôleur, et lit ce que **l'écran** de chacune
|
|
817
|
+
afficherait : les trames de présence reçues, appliquées par la même vue que le navigateur.
|
|
818
|
+
|
|
819
|
+
<!-- example: chatPresence.test.ts -->
|
|
820
|
+
|
|
821
|
+
```ts
|
|
822
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
823
|
+
import {
|
|
824
|
+
PresenceView,
|
|
825
|
+
REALTIME_PRESENCE_METHOD,
|
|
826
|
+
isPresenceFrame,
|
|
827
|
+
} from "nodefony";
|
|
828
|
+
import type { IPresenceSnapshot } from "nodefony";
|
|
829
|
+
import { getRealtimeHub } from "@nodefony/realtime";
|
|
830
|
+
import { createRealtimeHarness } from "@nodefony/realtime/testing";
|
|
831
|
+
import type { IRealtimeHarness } from "@nodefony/realtime/testing";
|
|
832
|
+
import { createTestIdentity } from "@nodefony/security/testing";
|
|
833
|
+
import { ChatController, ROOM_CHANNEL } from "./chatPresence";
|
|
834
|
+
|
|
835
|
+
const open: IRealtimeHarness<ChatController>[] = [];
|
|
836
|
+
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
|
|
837
|
+
|
|
838
|
+
/** Une connexion : un compte (`name`), ou un anonyme. */
|
|
839
|
+
async function connect(
|
|
840
|
+
name?: string,
|
|
841
|
+
): Promise<IRealtimeHarness<ChatController>> {
|
|
842
|
+
const h = createRealtimeHarness((ctx) => new ChatController(ctx), {
|
|
843
|
+
...(name === undefined
|
|
844
|
+
? {}
|
|
845
|
+
: { identity: createTestIdentity({ identifier: name }) }),
|
|
846
|
+
resetHub: open.length === 0,
|
|
847
|
+
});
|
|
848
|
+
await h.connect();
|
|
849
|
+
open.push(h);
|
|
850
|
+
return h;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Ce que l'écran de cette connexion affiche : ses trames de présence, appliquées comme le fait le navigateur. */
|
|
854
|
+
function screenOf(h: IRealtimeHarness<ChatController>): IPresenceSnapshot {
|
|
855
|
+
const view = new PresenceView(ROOM_CHANNEL);
|
|
856
|
+
for (const frame of h.received) {
|
|
857
|
+
if (
|
|
858
|
+
frame.method === REALTIME_PRESENCE_METHOD &&
|
|
859
|
+
isPresenceFrame(frame.params)
|
|
860
|
+
)
|
|
861
|
+
view.apply(frame.params);
|
|
862
|
+
}
|
|
863
|
+
return view.snapshot();
|
|
864
|
+
}
|
|
715
865
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
866
|
+
/** Attendre un FAIT, borné : les changements partent groupés, par fenêtre de 250 ms. */
|
|
867
|
+
async function until(fact: () => boolean): Promise<void> {
|
|
868
|
+
for (let i = 0; i < 100 && !fact(); i++)
|
|
869
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
870
|
+
expect(fact()).toBe(true);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
describe("salon — la présence portée par le canal, la frappe en message éphémère", () => {
|
|
874
|
+
afterEach(() => {
|
|
875
|
+
for (const h of open.splice(0)) h.close();
|
|
876
|
+
getRealtimeHub().clear();
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it("être abonné, c'est être présent : un compte est nommé, un anonyme compté sans nom", async () => {
|
|
880
|
+
const alice = await connect("alice");
|
|
881
|
+
const guest = await connect();
|
|
882
|
+
await alice.subscribe(ROOM_CHANNEL);
|
|
883
|
+
await guest.subscribe(ROOM_CHANNEL);
|
|
884
|
+
await flush();
|
|
885
|
+
// Le dernier arrivé reçoit la liste complète dès son abonnement.
|
|
886
|
+
const seen = screenOf(guest);
|
|
887
|
+
expect(seen.ready).toBe(true);
|
|
888
|
+
expect([...seen.members.keys()]).toEqual(["alice"]);
|
|
889
|
+
expect([seen.count, seen.unnamed]).toEqual([1, 1]);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it("se déclarer « bob » ne nomme personne : seul un compte est nommé", async () => {
|
|
893
|
+
const guest = await connect();
|
|
894
|
+
await guest.subscribe(ROOM_CHANNEL);
|
|
895
|
+
await guest.notify("chat:typing", { author: "bob" });
|
|
896
|
+
await flush();
|
|
897
|
+
expect(getRealtimeHub().presence(ROOM_CHANNEL)).toMatchObject({
|
|
898
|
+
count: 0,
|
|
899
|
+
unnamed: 1,
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it("la frappe arrive comme un message du canal, et ne touche pas la présence", async () => {
|
|
904
|
+
const alice = await connect("alice");
|
|
905
|
+
const bob = await connect("bob");
|
|
906
|
+
await alice.subscribe(ROOM_CHANNEL);
|
|
907
|
+
await bob.subscribe(ROOM_CHANNEL);
|
|
908
|
+
await bob.notify("chat:typing", { author: "bob" });
|
|
909
|
+
await flush();
|
|
910
|
+
expect(alice.messages(ROOM_CHANNEL)).toEqual([{ typing: "bob" }]);
|
|
911
|
+
expect(getRealtimeHub().presence(ROOM_CHANNEL)?.members.get("bob")).toEqual(
|
|
912
|
+
{
|
|
913
|
+
tabs: 1,
|
|
914
|
+
},
|
|
915
|
+
);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("connexion fermée : le présent s'en va, chez le serveur PUIS sur l'écran des autres", async () => {
|
|
919
|
+
const alice = await connect("alice");
|
|
920
|
+
const bob = await connect("bob");
|
|
921
|
+
await alice.subscribe(ROOM_CHANNEL);
|
|
922
|
+
await bob.subscribe(ROOM_CHANNEL);
|
|
923
|
+
await until(() => screenOf(alice).members.has("bob"));
|
|
924
|
+
bob.close();
|
|
925
|
+
await flush();
|
|
926
|
+
expect(getRealtimeHub().presence(ROOM_CHANNEL)?.members.has("bob")).toBe(
|
|
927
|
+
false,
|
|
928
|
+
);
|
|
929
|
+
await until(() => !screenOf(alice).members.has("bob"));
|
|
930
|
+
expect(screenOf(alice).count).toBe(1);
|
|
931
|
+
});
|
|
932
|
+
});
|
|
719
933
|
```
|
|
720
934
|
|
|
721
935
|
### Ce qu'on observe
|
|
722
936
|
|
|
723
|
-
| Tu fais | Tu vois
|
|
724
|
-
| --------------------------------------- |
|
|
725
|
-
| Tu
|
|
726
|
-
| Tu
|
|
727
|
-
| Tu
|
|
937
|
+
| Tu fais | Tu vois |
|
|
938
|
+
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
939
|
+
| Tu ouvres deux onglets | « 2 présent(s) » dans chacun, sans nom : ce sont deux anonymes. |
|
|
940
|
+
| Tu tapes dans l'onglet gauche | « invité-412 est en train d'écrire… » apparaît dans l'onglet droit, puis disparaît seul. La liste des présents ne bouge pas. |
|
|
941
|
+
| Tu fermes l'onglet gauche | L'onglet droit passe à « 1 présent(s) » dans le quart de seconde. |
|
|
942
|
+
| Tu ouvres l'onglet réseau du navigateur | **Une seule** entrée WebSocket et **un seul** canal : messages, frappe et présence voyagent ensemble. |
|
|
943
|
+
|
|
944
|
+
### Le même modèle que les normes
|
|
945
|
+
|
|
946
|
+
La présence suit le modèle des protocoles de présence normalisés, pour qu'une passerelle reste
|
|
947
|
+
possible : un **présent** est l'_occupant_ d'un salon de XEP-0045 ; la liste entière puis les
|
|
948
|
+
changements sont la _full_ puis la _partial presence_ de RFC 5262 ; un abonnement répété redemande
|
|
949
|
+
la liste entière, comme XEP-0045 §17.3 ; et « en train d'écrire » reste hors de la présence, comme
|
|
950
|
+
le veut RFC 6121 §4.4.1. Le détail des choix est dans l'ADR-0025 (présence répartie d'un canal).
|
|
728
951
|
|
|
729
952
|
## 💾 Étape 4 : persister l'historique
|
|
730
953
|
|
|
@@ -829,26 +1052,25 @@ export const securityConfig = () =>
|
|
|
829
1052
|
|
|
830
1053
|
Le décorateur de canal accepte un second argument : les exigences à satisfaire pour s'y abonner.
|
|
831
1054
|
|
|
832
|
-
|
|
1055
|
+
La déclaration du salon, complète, avec sa politique :
|
|
833
1056
|
|
|
834
1057
|
```ts ignore
|
|
835
1058
|
// modules/chat/nodefony/controllers/ChatController.ts
|
|
836
|
-
// → remplace
|
|
837
|
-
@RealtimeChannel(ROOM_CHANNEL, {
|
|
838
|
-
|
|
1059
|
+
// → remplace la déclaration de canal de l'étape 3
|
|
1060
|
+
@RealtimeChannel(ROOM_CHANNEL, {
|
|
1061
|
+
authenticated: true,
|
|
1062
|
+
roles: ["ROLE_USER"],
|
|
1063
|
+
presence: true,
|
|
1064
|
+
})
|
|
1065
|
+
room(): () => void {
|
|
839
1066
|
return () => {};
|
|
840
1067
|
}
|
|
841
|
-
|
|
842
|
-
@RealtimeChannel("chat:presence", { authenticated: true, roles: ["ROLE_USER"] })
|
|
843
|
-
presence(channel: string, publish: RealtimePublish): () => void {
|
|
844
|
-
const timer = setInterval(() => {
|
|
845
|
-
publish(channel, { present: [...ChatController.present] });
|
|
846
|
-
}, 5000);
|
|
847
|
-
timer.unref();
|
|
848
|
-
return () => clearInterval(timer);
|
|
849
|
-
}
|
|
850
1068
|
```
|
|
851
1069
|
|
|
1070
|
+
La présence suit la politique : n'est présent que celui qu'elle admet. Et puisque chacun est
|
|
1071
|
+
désormais connecté à son compte, la liste **nomme** les présents — par l'identité vérifiée à la
|
|
1072
|
+
poignée de main, jamais par un champ du client.
|
|
1073
|
+
|
|
852
1074
|
Le module temps réel ne décide de rien : il **transporte la déclaration** jusqu'au firewall, seul à
|
|
853
1075
|
connaître la hiérarchie des rôles et l'identité réelle.
|
|
854
1076
|
|
|
@@ -856,13 +1078,13 @@ connaître la hiérarchie des rôles et l'identité réelle.
|
|
|
856
1078
|
|
|
857
1079
|
C'est ici qu'il faut être précis, car **tous les chemins ne se valent pas**.
|
|
858
1080
|
|
|
859
|
-
Un handler `@RealtimeInbound` reçoit `(params, reply)
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
1081
|
+
Un handler `@RealtimeInbound` reçoit `(params, reply, connection)`. Le troisième argument porte
|
|
1082
|
+
l'identité résolue à la poignée de main : `connection.userId` est l'auteur, et c'est lui qu'on
|
|
1083
|
+
écrit dans le message — jamais un champ de la charge, où le client peut mentir. Ce qu'un canal
|
|
1084
|
+
inbound n'offre pas, en revanche : la validation du corps, la garde `@IsGranted`, le rejeu sans
|
|
1085
|
+
doublon (`@Idempotent`), et la même porte en HTTP.
|
|
864
1086
|
|
|
865
|
-
|
|
1087
|
+
Ces garanties-là viennent par la voie de l'[étape 2](#-étape-2--le-même-contrôleur-sert-http-et-websocket) :
|
|
866
1088
|
une **action de contrôleur**, atteinte par la socket. Sur ce chemin, le framework établit le contexte
|
|
867
1089
|
de requête avec le jeton de la connexion — exactement comme en HTTP — et les décorateurs habituels
|
|
868
1090
|
fonctionnent :
|
|
@@ -870,58 +1092,66 @@ fonctionnent :
|
|
|
870
1092
|
```ts ignore
|
|
871
1093
|
// modules/chat/nodefony/controllers/ChatController.ts
|
|
872
1094
|
// → remplace `@RealtimeInbound("chat:send")` par une ACTION
|
|
873
|
-
import {
|
|
874
|
-
import
|
|
1095
|
+
import { z } from "zod";
|
|
1096
|
+
import { Idempotent, IsGranted } from "@nodefony/framework";
|
|
1097
|
+
import type { RouteInput } from "@nodefony/framework";
|
|
1098
|
+
|
|
1099
|
+
/** Ce que l'envoi accepte — VALIDÉ par le framework avant l'action, par les deux portes. */
|
|
1100
|
+
const SendBody = z.object({ text: z.string().trim().min(1).max(2000) });
|
|
875
1101
|
|
|
876
1102
|
/**
|
|
877
1103
|
* L'envoi devient une action, joignable en POST HTTP comme par la socket.
|
|
878
1104
|
* On y gagne ce qu'un canal inbound ne peut pas offrir : l'identité vérifiée
|
|
879
|
-
* (
|
|
1105
|
+
* (`this.user`), la garde d'autorisation, la validation du corps et le
|
|
1106
|
+
* pipeline complet.
|
|
880
1107
|
*/
|
|
881
1108
|
@route("chat-send", {
|
|
882
1109
|
path: "/send",
|
|
883
1110
|
requirements: { methods: ["POST", "WEBSOCKET"] },
|
|
1111
|
+
schema: { body: SendBody },
|
|
884
1112
|
})
|
|
885
1113
|
@IsGranted(["ROLE_USER"])
|
|
886
|
-
|
|
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.
|
|
1114
|
+
@Idempotent()
|
|
1115
|
+
send(input: RouteInput<{ body: typeof SendBody }>) {
|
|
1116
|
+
// Corps vide, trop long, champ manquant : refusé AVANT d'arriver ici, par un
|
|
1117
|
+
// 422 qui nomme le champ — en HTTP comme par la socket. Ici, `input.body.text`
|
|
1118
|
+
// est une chaîne valide, rognée.
|
|
1119
|
+
// L'auteur vient du SERVEUR (la zone l'a posé). Le client ne peut plus mentir dessus.
|
|
896
1120
|
const message: ChatMessage = {
|
|
897
|
-
author: user.
|
|
898
|
-
text,
|
|
1121
|
+
author: this.user?.identifier ?? "anon.",
|
|
1122
|
+
text: input.body.text,
|
|
899
1123
|
sentAt: Date.now(),
|
|
900
1124
|
};
|
|
901
1125
|
|
|
902
1126
|
getRealtimeHub().publish(ROOM_CHANNEL, message);
|
|
903
|
-
return {
|
|
1127
|
+
return { sentAt: message.sentAt };
|
|
904
1128
|
}
|
|
905
1129
|
```
|
|
906
1130
|
|
|
907
|
-
Côté navigateur, l'envoi passe
|
|
908
|
-
|
|
1131
|
+
Côté navigateur, l'envoi passe par `post` — le **verbe de la route** : une route `POST` s'appelle
|
|
1132
|
+
`socket.post(…)`, une route `GET` `socket.get(…)`, rien d'autre à apprendre. La socket pose seule une
|
|
1133
|
+
**clé d'idempotence** sur chaque écriture : une socket qui se reconnecte peut rejouer une trame, et
|
|
1134
|
+
`@Idempotent()` reconnaît la clé et rend la réponse déjà calculée au lieu de publier deux fois.
|
|
909
1135
|
|
|
910
1136
|
```tsx ignore
|
|
911
1137
|
// frontend/src/ChatRoom.tsx (étape 5)
|
|
912
|
-
await socket.
|
|
913
|
-
method: "POST",
|
|
914
|
-
body: { text },
|
|
915
|
-
idempotencyKey: crypto.randomUUID(),
|
|
916
|
-
});
|
|
1138
|
+
await socket.post("/chat/send", { text });
|
|
917
1139
|
```
|
|
918
1140
|
|
|
1141
|
+
> [!NOTE]
|
|
1142
|
+
> Une clé par **appel**, posée par la socket. Pour dédoublonner une **intention** — un double clic
|
|
1143
|
+
> sur « Envoyer » qui part deux fois —, l'écran fournit la sienne : `socket.post("/chat/send", { text
|
|
1144
|
+
}, { idempotencyKey: brouillon.id })`. Deux appels, une seule clé, un seul message.
|
|
1145
|
+
|
|
919
1146
|
> [!TIP]
|
|
920
1147
|
> **La règle à retenir.** Canal inbound = flux volatil, à haute fréquence, dont l'expéditeur importe
|
|
921
1148
|
> peu (« untel écrit »). Action de contrôleur = tout ce qui doit être **attribué, autorisé ou
|
|
922
1149
|
> conservé**. Le chat de l'étape 1 utilisait un canal inbound par simplicité ; dès qu'il y a des
|
|
923
|
-
> comptes, l'envoi devient une action. La frappe en cours, elle, reste un canal inbound
|
|
924
|
-
>
|
|
1150
|
+
> comptes, l'envoi devient une action. La frappe en cours, elle, reste un canal inbound : rien n'y
|
|
1151
|
+
> est conservé. Mais son auteur se lit sur la **connexion** (`this.user`), plus dans les paramètres
|
|
1152
|
+
> — sinon n'importe qui ferait écrire « Alice ». Avec **plusieurs salons à admission**, un canal
|
|
1153
|
+
> inbound ne sait pas dans quel salon la connexion est admise : la frappe devient alors une route,
|
|
1154
|
+
> qui relit l'admission du salon comme l'envoi (c'est ce que fait la démo de `create app`).
|
|
925
1155
|
|
|
926
1156
|
Côté navigateur, l'identité annoncée par le serveur se lit sans appeler la moindre route :
|
|
927
1157
|
|
|
@@ -936,6 +1166,213 @@ const identity = useNodefonyIdentity();
|
|
|
936
1166
|
// identity.roles → pour masquer ce qui n'est pas permis
|
|
937
1167
|
```
|
|
938
1168
|
|
|
1169
|
+
### Quatre : un contrat pour les deux côtés — l'envoi et les messages privés typés
|
|
1170
|
+
|
|
1171
|
+
À ce stade, tout marche, mais rien ne relie les deux côtés. `socket.post("/chat/send", { texte:
|
|
1172
|
+
"salut" })` — avec une faute de frappe — compile, part sur le réseau, et revient en 422. Le résultat
|
|
1173
|
+
de `post` est `unknown`. Le message privé n'existe pas encore. Les trois se règlent par **un seul
|
|
1174
|
+
fichier** que le serveur et le navigateur partagent : le **contrat**.
|
|
1175
|
+
|
|
1176
|
+
**Le principe : une seule source.** Le corps de `/chat/send`, c'est le schéma `SendBody` que la route
|
|
1177
|
+
valide. Le contrat ne le **recopie** pas (`body: { text: string }`) : il le **référence**
|
|
1178
|
+
(`typeof SendBody`). Recopier, c'est écrire deux formes qui divergeront un jour sans que rien ne le
|
|
1179
|
+
dise — le schéma accepte `to`, la copie l'ignore, et le navigateur ne peut plus l'envoyer.
|
|
1180
|
+
|
|
1181
|
+
#### Le schéma partagé — fichier **créé**
|
|
1182
|
+
|
|
1183
|
+
```ts ignore
|
|
1184
|
+
// shared/chatSchemas.ts — la SEULE définition du corps de l'envoi
|
|
1185
|
+
import { z } from "zod";
|
|
1186
|
+
|
|
1187
|
+
export const SendBody = z.object({
|
|
1188
|
+
text: z.string().trim().min(1).max(2000),
|
|
1189
|
+
/** Destinataire d'un message privé : l'`id` STABLE de son compte. Absent = le salon. */
|
|
1190
|
+
to: z.string().optional(),
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* La query de l'historique — une query arrive en texte, `coerce` la convertit.
|
|
1195
|
+
* Pas de `since` sur un instant : voir « La reconnexion », plus bas.
|
|
1196
|
+
*/
|
|
1197
|
+
export const HistoryQuery = z.object({
|
|
1198
|
+
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
1199
|
+
});
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
#### Le contrat — fichier **créé**
|
|
1203
|
+
|
|
1204
|
+
```ts ignore
|
|
1205
|
+
// shared/chatContract.ts — des TYPES seulement
|
|
1206
|
+
import type { IRealtimeContract } from "nodefony/client";
|
|
1207
|
+
import type { HistoryQuery, SendBody } from "./chatSchemas";
|
|
1208
|
+
|
|
1209
|
+
export interface ChatMessage {
|
|
1210
|
+
author: string;
|
|
1211
|
+
text: string;
|
|
1212
|
+
sentAt: number;
|
|
1213
|
+
/** Présent sur un message privé. */
|
|
1214
|
+
to?: string;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/** Ce qu'on annonce au salon quand un fichier a fini d'arriver (étape 6). */
|
|
1218
|
+
export interface ChatAttachment {
|
|
1219
|
+
author: string;
|
|
1220
|
+
filename: string;
|
|
1221
|
+
size: number;
|
|
1222
|
+
mimeType: string | null | undefined;
|
|
1223
|
+
sentAt: number;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
export interface ChatContract extends IRealtimeContract {
|
|
1227
|
+
/** Ce que le serveur pousse : le salon, et le canal personnel de chacun. */
|
|
1228
|
+
serverToClient: {
|
|
1229
|
+
"chat:room": ChatMessage | ChatAttachment;
|
|
1230
|
+
[channel: `user:${string}`]: ChatMessage;
|
|
1231
|
+
};
|
|
1232
|
+
/** Les routes que la socket appelle : "<VERBE> <chemin>" → corps, query, résultat. */
|
|
1233
|
+
serverRoutes: {
|
|
1234
|
+
"POST /chat/send": { body: typeof SendBody; result: { sentAt: number } };
|
|
1235
|
+
"GET /chat/history": { query: typeof HistoryQuery; result: ChatMessage[] };
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
```
|
|
1239
|
+
|
|
1240
|
+
`import type` est le point qui compte : le navigateur lit `typeof SendBody` **à la compilation**, et
|
|
1241
|
+
n'embarque **aucun octet** de zod. Le fichier du contrat, fait de types seuls, disparaît à la
|
|
1242
|
+
construction.
|
|
1243
|
+
|
|
1244
|
+
#### Le serveur — fichier **modifié**
|
|
1245
|
+
|
|
1246
|
+
```ts ignore
|
|
1247
|
+
// modules/chat/nodefony/controllers/ChatController.ts
|
|
1248
|
+
import { ContractRealtimeController, getRealtimeHub } from "@nodefony/realtime";
|
|
1249
|
+
import type { ContractRouteSchemas } from "nodefony";
|
|
1250
|
+
import type { RouteInput } from "@nodefony/framework";
|
|
1251
|
+
import { HistoryQuery, SendBody } from "../../../../shared/chatSchemas";
|
|
1252
|
+
import type {
|
|
1253
|
+
ChatContract,
|
|
1254
|
+
ChatMessage,
|
|
1255
|
+
} from "../../../../shared/chatContract";
|
|
1256
|
+
|
|
1257
|
+
@controller("/chat")
|
|
1258
|
+
class ChatController extends ContractRealtimeController<ChatContract> {
|
|
1259
|
+
/**
|
|
1260
|
+
* Les VALEURS des routes du contrat — le schéma même que chaque route valide.
|
|
1261
|
+
* Vérifiées au démarrage (voir plus bas) ; jamais envoyées au navigateur.
|
|
1262
|
+
*/
|
|
1263
|
+
static readonly serverRoutes = {
|
|
1264
|
+
"POST /chat/send": { body: SendBody },
|
|
1265
|
+
"GET /chat/history": { query: HistoryQuery },
|
|
1266
|
+
} satisfies ContractRouteSchemas<ChatContract>;
|
|
1267
|
+
|
|
1268
|
+
// La route de l'historique valide désormais `schema: { query: HistoryQuery }`
|
|
1269
|
+
// (version complète plus bas) ; l'envoi, son corps :
|
|
1270
|
+
@route("chat-send", {
|
|
1271
|
+
path: "/send",
|
|
1272
|
+
requirements: { methods: ["POST", "WEBSOCKET"] },
|
|
1273
|
+
schema: { body: SendBody },
|
|
1274
|
+
})
|
|
1275
|
+
@IsGranted(["ROLE_USER"])
|
|
1276
|
+
@Idempotent()
|
|
1277
|
+
send(input: RouteInput<{ body: typeof SendBody }>) {
|
|
1278
|
+
const me = this.user;
|
|
1279
|
+
if (me === null) throw new HttpError("connexion requise", 401);
|
|
1280
|
+
const { text, to } = input.body;
|
|
1281
|
+
const message: ChatMessage = {
|
|
1282
|
+
author: me.identifier,
|
|
1283
|
+
text,
|
|
1284
|
+
sentAt: Date.now(),
|
|
1285
|
+
};
|
|
1286
|
+
const hub = getRealtimeHub<ChatContract>();
|
|
1287
|
+
if (to === undefined) {
|
|
1288
|
+
hub.publish("chat:room", message);
|
|
1289
|
+
} else {
|
|
1290
|
+
// Message privé : le destinataire, sur TOUS ses onglets et TOUS les
|
|
1291
|
+
// serveurs — et une copie à l'expéditeur, pour ses autres onglets.
|
|
1292
|
+
hub.publishToUser(to, { ...message, to });
|
|
1293
|
+
hub.publishToUser(me.id, { ...message, to });
|
|
1294
|
+
}
|
|
1295
|
+
return { sentAt: message.sentAt };
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
```
|
|
1299
|
+
|
|
1300
|
+
`satisfies ContractRouteSchemas<ChatContract>` fait vérifier la table par le compilateur : une route
|
|
1301
|
+
du contrat **oubliée**, une route **en trop**, un **autre** schéma que `typeof SendBody` — et le
|
|
1302
|
+
fichier ne compile pas. `getRealtimeHub<ChatContract>()` type la diffusion : `hub.publish("chat:room",
|
|
1303
|
+
{ txt: "…" })` ne compile pas davantage.
|
|
1304
|
+
|
|
1305
|
+
Le message privé ne demande **rien d'autre**. Le canal personnel `user:<id>` est une brique du
|
|
1306
|
+
framework : seule l'identité `<id>` peut s'y abonner, il traverse le bus entre serveurs, et
|
|
1307
|
+
personne ne paie rien tant qu'aucun écran ne l'écoute (détail : [securite.md](./securite.md)).
|
|
1308
|
+
|
|
1309
|
+
#### Le navigateur — fichier **modifié**
|
|
1310
|
+
|
|
1311
|
+
```ts ignore
|
|
1312
|
+
// frontend/src/realtime.ts — la socket, typée par le contrat
|
|
1313
|
+
import { NodefonySocket } from "nodefony/client";
|
|
1314
|
+
import type { ChatContract } from "../../shared/chatContract";
|
|
1315
|
+
|
|
1316
|
+
export const chatSocket = NodefonySocket.fromContract<ChatContract>({
|
|
1317
|
+
url: `${scheme}://${window.location.host}/chat/realtime`,
|
|
1318
|
+
});
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
```tsx ignore
|
|
1322
|
+
// frontend/src/ChatRoom.tsx (étape 5, typée)
|
|
1323
|
+
// Les messages privés de CET utilisateur : `m` est un ChatMessage, sans annotation.
|
|
1324
|
+
useEffect(() => chatSocket.onUser((m) => addPrivate(m)), []);
|
|
1325
|
+
|
|
1326
|
+
// L'historique : ChatMessage[] — déduit de la route, query vérifiée.
|
|
1327
|
+
const past = await chatSocket.get("/chat/history", { query: { limit: 50 } });
|
|
1328
|
+
|
|
1329
|
+
// L'envoi : le corps est l'ENTRÉE du schéma, le résultat est { sentAt: number }.
|
|
1330
|
+
const { sentAt } = await chatSocket.post("/chat/send", { text });
|
|
1331
|
+
await chatSocket.post("/chat/send", { text, to: bob.id }); // message privé
|
|
1332
|
+
|
|
1333
|
+
// Et ce qui ne compile plus :
|
|
1334
|
+
await chatSocket.post("/chat/send", { texte: "x" }); // ✗ `texte` n'existe pas dans SendBody
|
|
1335
|
+
await chatSocket.post("/chat/send"); // ✗ le schéma exige un corps
|
|
1336
|
+
```
|
|
1337
|
+
|
|
1338
|
+
Un chemin **hors contrat** reste libre — `chatSocket.get("/stats")` rend `unknown`,
|
|
1339
|
+
`chatSocket.get<Stats>("/stats")` le type choisi — : le contrat type ce qu'il déclare, il n'interdit
|
|
1340
|
+
rien d'autre.
|
|
1341
|
+
|
|
1342
|
+
Une route à **paramètre de chemin** se déclare par son gabarit — la clé est le chemin de la route,
|
|
1343
|
+
`{id}` compris — et s'appelle en NOMMANT ce gabarit ; la socket le remplit :
|
|
1344
|
+
|
|
1345
|
+
```ts ignore
|
|
1346
|
+
// contrat — la route valide `schema: { params: IdParams }`
|
|
1347
|
+
"GET /chat/messages/{id}": { params: typeof IdParams; result: ChatMessage };
|
|
1348
|
+
|
|
1349
|
+
// navigateur
|
|
1350
|
+
const m = await chatSocket.get("/chat/messages/{id}", { params: { id } });
|
|
1351
|
+
await chatSocket.get("/chat/messages/{id}"); // ✗ `params.id` manquant
|
|
1352
|
+
```
|
|
1353
|
+
|
|
1354
|
+
Chaque valeur est encodée pour tenir dans **un** segment : un identifiant qui contient `/` ou `?`
|
|
1355
|
+
ne peut pas détourner l'appel vers une autre route, et une valeur vide, `.` ou `..` — que la
|
|
1356
|
+
normalisation du chemin résoudrait en remontant d'un cran — est refusée avant l'envoi. Sans
|
|
1357
|
+
schéma `params`, les valeurs sont typées par les `{noms}` du gabarit. Seuls des segments
|
|
1358
|
+
`{nom}` entiers se déclarent : une contrainte s'écrit dans `requirements` de la route, pas en
|
|
1359
|
+
ligne (`{id}(\d+)`) — le démarrage le refuse en nommant l'entrée.
|
|
1360
|
+
|
|
1361
|
+
#### Ce que le démarrage refuse
|
|
1362
|
+
|
|
1363
|
+
Le contrat est un **type** : il fait compiler `chatSocket.post("/chat/send", …)` même si la route
|
|
1364
|
+
n'existe pas. C'est la table `serverRoutes` qui porte les **valeurs**, et le démarrage les confronte
|
|
1365
|
+
aux routes montées — une fois, coût nul par requête. Trois fautes, trois refus qui nomment l'entrée :
|
|
1366
|
+
|
|
1367
|
+
| Faute | Le démarrage s'arrête sur |
|
|
1368
|
+
| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
1369
|
+
| La route n'existe pas (chemin mal recopié, module non chargé) | `ChatController.serverRoutes « POST /chat/send » : aucune route montée au chemin …` |
|
|
1370
|
+
| La route sert HTTP seulement (`methods: ["POST"]`) | `… ne sert pas POST par la socket — ses méthodes doivent inclure "POST" et "WEBSOCKET".` |
|
|
1371
|
+
| La route valide un autre schéma (copie, ou schéma redéclaré) | `… le schéma « body » n'est pas celui que la route valide (même objet attendu).` |
|
|
1372
|
+
|
|
1373
|
+
Sans ce contrôle, chacune de ces fautes compilerait, démarrerait, et n'apparaîtrait qu'au premier
|
|
1374
|
+
message envoyé — chez l'utilisateur.
|
|
1375
|
+
|
|
939
1376
|
### Ce que voit précisément un intrus
|
|
940
1377
|
|
|
941
1378
|
Rien n'échoue en silence, et rien ne renseigne l'attaquant sur **pourquoi** il échoue.
|
|
@@ -1158,6 +1595,21 @@ use("@nodefony/realtime", {
|
|
|
1158
1595
|
});
|
|
1159
1596
|
```
|
|
1160
1597
|
|
|
1598
|
+
### Ce que le relais ne partage pas : l'état
|
|
1599
|
+
|
|
1600
|
+
Le backplane transporte des **trames**, rien d'autre. Ce que l'application **garde** — l'historique,
|
|
1601
|
+
les pièces jointes, la liste des salons — ne traverse pas avec lui : chaque pod lit et écrit le sien.
|
|
1602
|
+
Plusieurs pods exigent donc un état **partagé** :
|
|
1603
|
+
|
|
1604
|
+
- **la base** : PostgreSQL ou MySQL, que tous les pods joignent. Un fichier SQLite vit sur le disque
|
|
1605
|
+
d'UNE machine : c'est le mode d'un poste de développement ou d'une démo, pas d'une application
|
|
1606
|
+
répartie ;
|
|
1607
|
+
- **les fichiers** : un stockage partagé (volume réseau, stockage objet), jamais le disque local
|
|
1608
|
+
d'un pod — une pièce reçue par l'un serait introuvable depuis l'autre.
|
|
1609
|
+
|
|
1610
|
+
La démo de `create app --demo` tient sur un seul pod, pour cette raison : sa base est un fichier
|
|
1611
|
+
SQLite et ses pièces restent sur le disque du serveur.
|
|
1612
|
+
|
|
1161
1613
|
### Ce qui n'a pas bougé
|
|
1162
1614
|
|
|
1163
1615
|
C'est l'argument, et il se vérifie fichier par fichier :
|
|
@@ -1170,6 +1622,10 @@ C'est l'argument, et il se vérifie fichier par fichier :
|
|
|
1170
1622
|
| `ChatRoom.tsx`, `realtime.ts` | **Non.** Le navigateur ne sait pas, et n'a pas à savoir, combien de pods existent. |
|
|
1171
1623
|
| `nodefony.config.ts` | Une chaîne de caractères. |
|
|
1172
1624
|
|
|
1625
|
+
La présence suit le même chemin : un canal diffusé partage ses présents entre les pods, sans une
|
|
1626
|
+
ligne de plus. Un canal à présence **non** diffusé reste juste, mais local à son processus — le
|
|
1627
|
+
journal le signale une fois, au premier abonné du canal, dès qu'un bus est configuré.
|
|
1628
|
+
|
|
1173
1629
|
Aucun appel de publication ne change, aucun abonnement ne change, aucune règle métier ne change. Ce
|
|
1174
1630
|
qui change, c'est **par où** la publication repart après le fan-out local — et cela se décide dans la
|
|
1175
1631
|
configuration, pas dans le code.
|
|
@@ -1199,11 +1655,14 @@ Au démarrage de chaque worker, la ligne d'identité du temps réel change de fo
|
|
|
1199
1655
|
`kind=local cross-pod=no`, on lit maintenant un `kind` de transport et `cross-pod=yes` : c'est la
|
|
1200
1656
|
confirmation, au boot, que le relais est en place.
|
|
1201
1657
|
|
|
1202
|
-
| Tu fais
|
|
1203
|
-
|
|
|
1204
|
-
| Alice envoie un fichier
|
|
1205
|
-
| Alice tape un message
|
|
1206
|
-
|
|
|
1658
|
+
| Tu fais | Avant (étape 6) | Maintenant |
|
|
1659
|
+
| ------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
|
|
1660
|
+
| Alice envoie un fichier | Bob ne voit rien | L'annonce apparaît chez Bob, sur l'autre processus |
|
|
1661
|
+
| Alice tape un message | Aléatoire | Reçu par tous, quel que soit le worker |
|
|
1662
|
+
| Alice et Bob, un worker chacun | Chacun ne voit que lui présent | Les deux se voient présents ; un onglet sur chaque worker compte pour un seul présent |
|
|
1663
|
+
| Tu arrêtes un worker proprement | — | Ses présents disparaissent chez les autres aussitôt : il l'annonce en partant |
|
|
1664
|
+
| Tu tues un worker (`kill -9`) | — | Ses présents disparaissent chez les autres au délai d'oubli : trois preuves de vie manquées, 15 s par défaut |
|
|
1665
|
+
| Tu arrêtes le service Redis | — | Le salon continue **dans chaque processus**, isolément ; la présence venue des autres pods se fige, marquée `partial` |
|
|
1207
1666
|
|
|
1208
1667
|
Cette dernière ligne mérite une explication : si le backplane est injoignable au démarrage, le
|
|
1209
1668
|
framework **ne bloque pas le boot**. Il journalise un avertissement, garde un hub local et sert le
|
|
@@ -1223,55 +1682,56 @@ La seule chose qui n'y figure pas est la persistance : `ChatController.history`
|
|
|
1223
1682
|
mémoire, que l'[étape 4](#-étape-4--persister-lhistorique) remplace par un appel à ton service.
|
|
1224
1683
|
C'est délibéré — ce fichier doit compiler sans supposer ta base.
|
|
1225
1684
|
|
|
1685
|
+
Il importe les deux fichiers partagés de l'[étape 5](#quatre--un-contrat-pour-les-deux-côtés--lenvoi-et-les-messages-privés-typés) :
|
|
1686
|
+
`shared/chatSchemas.ts` (les schémas que les routes valident) et `shared/chatContract.ts` (le
|
|
1687
|
+
contrat, types seuls).
|
|
1688
|
+
|
|
1226
1689
|
```ts ignore
|
|
1227
1690
|
// modules/chat/nodefony/controllers/ChatController.ts — version finale
|
|
1228
1691
|
import {
|
|
1229
1692
|
controller,
|
|
1230
1693
|
route,
|
|
1231
|
-
|
|
1232
|
-
Query,
|
|
1233
|
-
CurrentUser,
|
|
1694
|
+
Idempotent,
|
|
1234
1695
|
IsGranted,
|
|
1235
1696
|
UploadedFile,
|
|
1236
1697
|
} from "@nodefony/framework";
|
|
1698
|
+
import type { RouteInput } from "@nodefony/framework";
|
|
1237
1699
|
import {
|
|
1238
|
-
|
|
1700
|
+
ContractRealtimeController,
|
|
1701
|
+
RealtimeBroadcast,
|
|
1239
1702
|
RealtimeChannel,
|
|
1240
1703
|
RealtimeInbound,
|
|
1241
1704
|
getRealtimeHub,
|
|
1242
1705
|
} from "@nodefony/realtime";
|
|
1243
|
-
import type { RealtimePublish } from "@nodefony/realtime";
|
|
1244
1706
|
import { HttpError } from "@nodefony/http";
|
|
1245
1707
|
import type { ContextType, IUploadedFile } from "@nodefony/http";
|
|
1246
|
-
import type {
|
|
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
|
-
}
|
|
1708
|
+
import type { ContractRouteSchemas } from "nodefony";
|
|
1709
|
+
import { HistoryQuery, SendBody } from "../../../../shared/chatSchemas";
|
|
1710
|
+
import type {
|
|
1711
|
+
ChatAttachment,
|
|
1712
|
+
ChatContract,
|
|
1713
|
+
ChatMessage,
|
|
1714
|
+
} from "../../../../shared/chatContract";
|
|
1263
1715
|
|
|
1264
1716
|
/** Le nom du canal du salon. Cité côté serveur ET côté client → une constante. */
|
|
1265
1717
|
export const ROOM_CHANNEL = "chat:room";
|
|
1266
1718
|
|
|
1719
|
+
// Le salon traverse les serveurs (étape 7) : sans cette ligne, il reste sur le sien.
|
|
1720
|
+
@RealtimeBroadcast("chat:")
|
|
1267
1721
|
@controller("/chat")
|
|
1268
|
-
class ChatController extends
|
|
1722
|
+
class ChatController extends ContractRealtimeController<ChatContract> {
|
|
1723
|
+
/**
|
|
1724
|
+
* Les VALEURS des routes du contrat — le schéma même que chaque route valide.
|
|
1725
|
+
* Vérifiées au démarrage ; jamais envoyées au navigateur.
|
|
1726
|
+
*/
|
|
1727
|
+
static readonly serverRoutes = {
|
|
1728
|
+
"POST /chat/send": { body: SendBody },
|
|
1729
|
+
"GET /chat/history": { query: HistoryQuery },
|
|
1730
|
+
} satisfies ContractRouteSchemas<ChatContract>;
|
|
1731
|
+
|
|
1269
1732
|
/** Stock en mémoire — remplacé par un repository à l'étape 4. */
|
|
1270
1733
|
static history: ChatMessage[] = [];
|
|
1271
1734
|
|
|
1272
|
-
/** Les pseudos présents dans le salon, sur CE processus. */
|
|
1273
|
-
static present = new Set<string>();
|
|
1274
|
-
|
|
1275
1735
|
constructor(context: ContextType) {
|
|
1276
1736
|
super("chat", context);
|
|
1277
1737
|
}
|
|
@@ -1281,9 +1741,6 @@ class ChatController extends RealtimeController {
|
|
|
1281
1741
|
return true;
|
|
1282
1742
|
}
|
|
1283
1743
|
|
|
1284
|
-
/** Ce qui doit franchir les frontières de processus (étape 7). */
|
|
1285
|
-
// (préfixe diffusable déclaré par `@RealtimeBroadcast` sur la classe)
|
|
1286
|
-
|
|
1287
1744
|
// ── La porte WebSocket ────────────────────────────────────────────────────
|
|
1288
1745
|
|
|
1289
1746
|
@route("chat-realtime", {
|
|
@@ -1296,38 +1753,47 @@ class ChatController extends RealtimeController {
|
|
|
1296
1753
|
|
|
1297
1754
|
// ── Les actions : HTTP et WebSocket, même code ────────────────────────────
|
|
1298
1755
|
|
|
1299
|
-
/** Historique — `GET /chat/history` ou `socket.
|
|
1756
|
+
/** Historique — `GET /chat/history` ou `socket.get("/chat/history")`. */
|
|
1300
1757
|
@route("chat-history", {
|
|
1301
1758
|
path: "/history",
|
|
1302
1759
|
requirements: { methods: ["GET", "WEBSOCKET"] },
|
|
1760
|
+
schema: { query: HistoryQuery },
|
|
1303
1761
|
})
|
|
1304
|
-
historyAction(
|
|
1305
|
-
|
|
1306
|
-
|
|
1762
|
+
historyAction(
|
|
1763
|
+
input: RouteInput<{ query: typeof HistoryQuery }>,
|
|
1764
|
+
): ChatMessage[] {
|
|
1765
|
+
return ChatController.history.slice(-input.query.limit);
|
|
1307
1766
|
}
|
|
1308
1767
|
|
|
1309
|
-
/** Envoi — l'auteur vient du serveur
|
|
1768
|
+
/** Envoi — au salon, ou en privé à une personne ; l'auteur vient du serveur. */
|
|
1310
1769
|
@route("chat-send", {
|
|
1311
1770
|
path: "/send",
|
|
1312
1771
|
requirements: { methods: ["POST", "WEBSOCKET"] },
|
|
1772
|
+
schema: { body: SendBody },
|
|
1313
1773
|
})
|
|
1314
1774
|
@IsGranted(["ROLE_USER"])
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
const
|
|
1320
|
-
if (text.length === 0 || text.length > 2000) {
|
|
1321
|
-
throw new HttpError("message vide ou trop long", 400);
|
|
1322
|
-
}
|
|
1775
|
+
@Idempotent()
|
|
1776
|
+
send(input: RouteInput<{ body: typeof SendBody }>): { sentAt: number } {
|
|
1777
|
+
const me = this.user;
|
|
1778
|
+
if (me === null) throw new HttpError("connexion requise", 401);
|
|
1779
|
+
const { text, to } = input.body;
|
|
1323
1780
|
const message: ChatMessage = {
|
|
1324
|
-
author:
|
|
1781
|
+
author: me.identifier,
|
|
1325
1782
|
text,
|
|
1326
1783
|
sentAt: Date.now(),
|
|
1327
1784
|
};
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1785
|
+
const hub = getRealtimeHub<ChatContract>();
|
|
1786
|
+
if (to === undefined) {
|
|
1787
|
+
ChatController.history.push(message);
|
|
1788
|
+
hub.publish(ROOM_CHANNEL, message);
|
|
1789
|
+
} else {
|
|
1790
|
+
// Le destinataire sur tous ses onglets et tous les serveurs, et une copie
|
|
1791
|
+
// à l'expéditeur pour ses autres onglets. Rien à déclarer : `user:<id>`
|
|
1792
|
+
// est un canal du framework.
|
|
1793
|
+
hub.publishToUser(to, { ...message, to });
|
|
1794
|
+
hub.publishToUser(me.id, { ...message, to });
|
|
1795
|
+
}
|
|
1796
|
+
return { sentAt: message.sentAt };
|
|
1331
1797
|
}
|
|
1332
1798
|
|
|
1333
1799
|
/** Pièce jointe — entre en POST multipart, ressort par le canal. */
|
|
@@ -1338,13 +1804,12 @@ class ChatController extends RealtimeController {
|
|
|
1338
1804
|
@IsGranted(["ROLE_USER"])
|
|
1339
1805
|
async upload(
|
|
1340
1806
|
@UploadedFile() file?: IUploadedFile,
|
|
1341
|
-
|
|
1342
|
-
): Promise<{ ok: true; filename: string; size: number }> {
|
|
1807
|
+
): Promise<{ filename: string; size: number }> {
|
|
1343
1808
|
if (!file) {
|
|
1344
1809
|
throw new HttpError("aucun fichier reçu", 400);
|
|
1345
1810
|
}
|
|
1346
1811
|
const announcement: ChatAttachment = {
|
|
1347
|
-
author: user.
|
|
1812
|
+
author: this.user?.identifier ?? "anon.",
|
|
1348
1813
|
filename: file.filename,
|
|
1349
1814
|
size: file.size,
|
|
1350
1815
|
mimeType: file.mimeType,
|
|
@@ -1353,42 +1818,34 @@ class ChatController extends RealtimeController {
|
|
|
1353
1818
|
// La requête HTTP répondra à SON auteur ; cette publication atteint tout le
|
|
1354
1819
|
// salon. Un transport entre, un autre sort — dans la même méthode.
|
|
1355
1820
|
getRealtimeHub().publish(ROOM_CHANNEL, announcement);
|
|
1356
|
-
return {
|
|
1821
|
+
return { filename: file.filename, size: file.size };
|
|
1357
1822
|
}
|
|
1358
1823
|
|
|
1359
1824
|
// ── Les canaux ────────────────────────────────────────────────────────────
|
|
1360
1825
|
|
|
1361
|
-
/**
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
/** Présence — un rappel régulier, arrêté au dernier désabonné. */
|
|
1368
|
-
@RealtimeChannel("chat:presence", {
|
|
1826
|
+
/**
|
|
1827
|
+
* Le salon. Rien à démarrer : il relaie, il n'émet pas de lui-même. Sa
|
|
1828
|
+
* présence est tenue par le framework, entre les pods compris.
|
|
1829
|
+
*/
|
|
1830
|
+
@RealtimeChannel(ROOM_CHANNEL, {
|
|
1369
1831
|
authenticated: true,
|
|
1370
1832
|
roles: ["ROLE_USER"],
|
|
1833
|
+
presence: true,
|
|
1371
1834
|
})
|
|
1372
|
-
|
|
1373
|
-
const timer = setInterval(() => {
|
|
1374
|
-
publish(channel, { present: [...ChatController.present] });
|
|
1375
|
-
}, 5000);
|
|
1376
|
-
timer.unref();
|
|
1377
|
-
return () => clearInterval(timer);
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
/** Frappe en cours — volatile : ni attribué, ni conservé. */
|
|
1381
|
-
@RealtimeChannel("chat:typing")
|
|
1382
|
-
typing(_channel: string, _publish: RealtimePublish): () => void {
|
|
1835
|
+
room(): () => void {
|
|
1383
1836
|
return () => {};
|
|
1384
1837
|
}
|
|
1385
1838
|
|
|
1386
|
-
/**
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1839
|
+
/**
|
|
1840
|
+
* Frappe en cours : un message ÉPHÉMÈRE du salon, ni conservé ni rejoué. Le
|
|
1841
|
+
* seul canal entrant restant — l'auteur est celui de la connexion, pas un
|
|
1842
|
+
* champ du client.
|
|
1843
|
+
*/
|
|
1844
|
+
@RealtimeInbound("chat:typing", { authenticated: true })
|
|
1845
|
+
onTyping(): void {
|
|
1846
|
+
const author = this.user?.identifier;
|
|
1847
|
+
if (author === undefined) return;
|
|
1848
|
+
getRealtimeHub().publish(ROOM_CHANNEL, { typing: author });
|
|
1392
1849
|
}
|
|
1393
1850
|
}
|
|
1394
1851
|
|
|
@@ -1412,20 +1869,40 @@ hooks React ne se démontent pas ; tes composants ne voient rien passer. Côté
|
|
|
1412
1869
|
libère les abonnements et détruit les providers devenus inutiles.
|
|
1413
1870
|
|
|
1414
1871
|
**Ce qui reste à ta charge** : **le trou**. Les messages publiés pendant la coupure ne sont pas
|
|
1415
|
-
rejoués — le hub distribue à qui est là, il ne conserve rien. À la reconnexion,
|
|
1416
|
-
l'
|
|
1872
|
+
rejoués — le hub distribue à qui est là, il ne conserve rien. À la reconnexion, attends que
|
|
1873
|
+
l'abonnement soit **effectif**, relis les derniers messages, et **recoupe-les par identifiant** :
|
|
1417
1874
|
|
|
1418
1875
|
```tsx ignore
|
|
1419
1876
|
// Au retour de `reconnecting` vers `connected`, on comble le trou.
|
|
1420
1877
|
useEffect(() => {
|
|
1421
1878
|
if (state !== "connected") return;
|
|
1422
|
-
const lastSeen = messages.at(-1)?.sentAt ?? 0;
|
|
1423
1879
|
void socket
|
|
1424
|
-
.
|
|
1425
|
-
.
|
|
1880
|
+
// Lire AVANT l'abonnement effectif perdrait ce qui se publie entre les deux.
|
|
1881
|
+
.whenSubscribed("chat:room")
|
|
1882
|
+
.then(() =>
|
|
1883
|
+
socket.get<ChatMessage[]>("/chat/history", { query: { limit: 50 } }),
|
|
1884
|
+
)
|
|
1885
|
+
.then((page) =>
|
|
1886
|
+
setMessages((previous) => {
|
|
1887
|
+
// Par `id` : un message arrivé par les DEUX chemins n'apparaît qu'une fois.
|
|
1888
|
+
const byId = new Map(previous.map((m) => [m.id, m]));
|
|
1889
|
+
for (const m of page) byId.set(m.id, m);
|
|
1890
|
+
return [...byId.values()];
|
|
1891
|
+
}),
|
|
1892
|
+
);
|
|
1426
1893
|
}, [state]);
|
|
1427
1894
|
```
|
|
1428
1895
|
|
|
1896
|
+
Deux pièges, que ce code évite :
|
|
1897
|
+
|
|
1898
|
+
- **Un rattrapage borné par l'heure** (`since: dernierMessage.sentAt`) double ou perd des messages :
|
|
1899
|
+
chaque pod a son horloge, et deux messages de la même milliseconde se départagent mal. L'heure
|
|
1900
|
+
sert à afficher, jamais à rattraper.
|
|
1901
|
+
- **Une page qui ne recoupe rien** de ce que l'écran montrait signale un trou plus long qu'elle :
|
|
1902
|
+
les messages plus anciens ne sont plus contigus. La démo de `create app --demo` les retire
|
|
1903
|
+
plutôt que de les afficher de part et d'autre d'un silence qui n'a pas eu lieu
|
|
1904
|
+
(`catchUpMessages`).
|
|
1905
|
+
|
|
1429
1906
|
### Les messages en double
|
|
1430
1907
|
|
|
1431
1908
|
**Symptôme** — le même message apparaît deux fois.
|
|
@@ -1632,14 +2109,14 @@ Pour qui veut lire l'implémentation derrière chaque étape :
|
|
|
1632
2109
|
| La publication et le fan-out | `RealtimeHub.publish()` (`RealtimeHub.ts#RealtimeHub.publish`), réinjection locale `RealtimeHub.publishLocal()` (`RealtimeHub.ts#RealtimeHub.publishLocal`) |
|
|
1633
2110
|
| Les canaux diffusables | `RealtimeBroadcast` (`realtimeDecorators.ts#RealtimeBroadcast`), `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts#RealtimeHub.markBroadcastChannel`) |
|
|
1634
2111
|
| Le branchement du backplane | `RealtimeHub.setBackplane()` (`RealtimeHub.ts#RealtimeHub.setBackplane`), registre `registerBackplaneDriver()` (`backplaneRegistry.ts#registerBackplaneDriver`), cloison `resolveBackplaneOriginId()` (`originId.ts#resolveBackplaneOriginId`) |
|
|
1635
|
-
| Les politiques de canal | `IChannelPolicy` (`
|
|
2112
|
+
| Les politiques de canal | `IChannelPolicy` (`nodefony/src/realtime/channelAdmission.ts#IChannelPolicy`), `RealtimeHub.registerChannelPolicy()` (`RealtimeHub.ts#RealtimeHub.registerChannelPolicy`), garde-fou `RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts#RealtimeHub.hasUnenforcedChannelPolicies`) |
|
|
1636
2113
|
| Le branchement automatique du firewall | `Firewall.#wireRealtime()` (`firewall.ts#Firewall.#wireRealtime`) |
|
|
1637
2114
|
| Les refus | `IRealtimeDenied` (`RealtimeEventMap.ts#IRealtimeDenied`), erreur `unauthorized` (`JsonRpcPeer.ts#JsonRpcPeer.receive`), fermetures `origin not allowed` (`RealtimeController.ts#RealtimeController.notifyClient`) et `unauthorized` (`RealtimeController.ts#RealtimeController.onHandshake`) |
|
|
1638
2115
|
| La révocation en cours de session | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts#RealtimeHub.revalidateRevocable`) |
|
|
1639
2116
|
| Le consommateur lent | seuil `SLOW_CONSUMER_BYTES` (`RealtimeHub.ts#SLOW_CONSUMER_BYTES`), fermeture `slow consumer` (`RealtimeHub.ts#SLOW_CONSUMER_BYTES`) |
|
|
1640
2117
|
| Le plafond de canaux | `RealtimeHub.maxChannelsPerConnection` (`RealtimeHub.ts#RealtimeHub.maxChannelsPerConnection`) |
|
|
1641
2118
|
| L'accueil et l'identité | `IRealtimeWelcome` (`RealtimeEventMap.ts#IRealtimeWelcome`) |
|
|
1642
|
-
| La sonde | `RealtimeHub.probe()` (`RealtimeHub.ts#RealtimeHub.probe`), `IRealtimeProbe` (`
|
|
2119
|
+
| La sonde | `RealtimeHub.probe()` (`RealtimeHub.ts#RealtimeHub.probe`), `IRealtimeProbe` (`nodefony/src/realtime/platformHealth.ts#IRealtimeProbe`), `IRealtimeChannelStat` (`nodefony/src/realtime/platformHealth.ts#IRealtimeChannelStat`) |
|
|
1643
2120
|
| Le point de mesure d'administration | `createRealtimeAdminApi()` (`RealtimeAdminApi.ts#createRealtimeAdminApi`), `buildRealtimeHealth()` (`RealtimeAdminApi.ts#buildRealtimeHealth`), `buildOwnHealth()` (`RealtimeAdminApi.ts#buildOwnHealth`) |
|
|
1644
2121
|
| Le service injectable | `RealtimeService.publish()` (`RealtimeService.ts#RealtimeService.publish`), `RealtimeService.probe()` (`RealtimeService.ts#RealtimeService.probe`), `RealtimeService.markBroadcastChannel()` (`RealtimeService.ts#RealtimeService.markBroadcastChannel`) |
|
|
1645
2122
|
| Les hooks React | `useNodefony()` (`client/react/index.ts#useNodefony`), `useNodefonyState()` (`client/react/index.ts#useNodefonyState`), `useNodefonyIdentity()` (`client/react/index.ts#useNodefonyIdentity`), `useNodefonyChannel()` (`client/react/index.ts#useNodefonyChannel`), `useNodefonyChannelData()` (`client/react/index.ts#useNodefonyChannelData`), `useNodefonyAdaptiveChannel()` (`client/react/index.ts#useNodefonyAdaptiveChannel`) |
|