@nodefony/realtime 10.0.0-alpha.1
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/LICENSE +544 -0
- package/README.md +207 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +218 -0
- package/dist/nodefony/config/config.js +63 -0
- package/dist/nodefony/config/defineModuleConfig.js +60 -0
- package/dist/nodefony/decorators/realtimeDecorators.js +240 -0
- package/dist/nodefony/interfaces/IBackplane.js +1 -0
- package/dist/nodefony/interfaces/IChannelPolicy.js +1 -0
- package/dist/nodefony/interfaces/IClientLogsLimits.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeAuthenticator.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeAuthenticatorMatcher.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeController.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeHandshake.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeProbe.js +1 -0
- package/dist/nodefony/interfaces/IRealtimeToken.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +105 -0
- package/dist/nodefony/src/backplane/LoopbackBackplane.js +40 -0
- package/dist/nodefony/src/backplane/RedisBackplane.js +176 -0
- package/dist/nodefony/src/backplane/backplaneRegistry.js +20 -0
- package/dist/nodefony/src/backplane/envelope.js +109 -0
- package/dist/nodefony/src/backplane/originId.js +35 -0
- package/dist/nodefony/src/backplane/publishQueue.js +96 -0
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +284 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +35 -0
- package/dist/nodefony/src/server/RealtimeAdminApi.js +87 -0
- package/dist/nodefony/src/server/RealtimeController.js +601 -0
- package/dist/nodefony/src/server/RealtimeHub.js +926 -0
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +168 -0
- package/dist/nodefony/src/server/deniedDetail.js +29 -0
- package/dist/nodefony/src/server/syslogUplink.js +105 -0
- package/dist/nodefony/src/server/welcomeEnv.js +25 -0
- package/dist/nodefony/src/service/RealtimeService.js +240 -0
- package/dist/nodefony/src/transport/WsConnectionTransport.js +94 -0
- package/dist/nodefony/testing/index.js +163 -0
- package/dist/types/index.d.ts +99 -0
- package/dist/types/nodefony/config/config.d.ts +43 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +49 -0
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +190 -0
- package/dist/types/nodefony/interfaces/IBackplane.d.ts +128 -0
- package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +28 -0
- package/dist/types/nodefony/interfaces/IClientLogsLimits.d.ts +22 -0
- package/dist/types/nodefony/interfaces/IRealtimeAuthenticator.d.ts +53 -0
- package/dist/types/nodefony/interfaces/IRealtimeAuthenticatorMatcher.d.ts +46 -0
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +36 -0
- package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +27 -0
- package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +206 -0
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +48 -0
- package/dist/types/nodefony/interfaces/index.d.ts +18 -0
- package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +76 -0
- package/dist/types/nodefony/src/backplane/LoopbackBackplane.d.ts +29 -0
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +164 -0
- package/dist/types/nodefony/src/backplane/backplaneRegistry.d.ts +51 -0
- package/dist/types/nodefony/src/backplane/envelope.d.ts +27 -0
- package/dist/types/nodefony/src/backplane/originId.d.ts +22 -0
- package/dist/types/nodefony/src/backplane/publishQueue.d.ts +71 -0
- package/dist/types/nodefony/src/cluster/ClusterProbeClient.d.ts +90 -0
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +3 -0
- package/dist/types/nodefony/src/server/RealtimeAdminApi.d.ts +18 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +165 -0
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +516 -0
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +63 -0
- package/dist/types/nodefony/src/server/deniedDetail.d.ts +25 -0
- package/dist/types/nodefony/src/server/syslogUplink.d.ts +58 -0
- package/dist/types/nodefony/src/server/welcomeEnv.d.ts +21 -0
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +167 -0
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +78 -0
- package/dist/types/nodefony/testing/index.d.ts +169 -0
- package/docs/actions.md +573 -0
- package/docs/architecture.md +889 -0
- package/docs/configuration.md +803 -0
- package/docs/cookbook-chat.md +1619 -0
- package/docs/couverture-bancs.md +139 -0
- package/docs/index.md +372 -0
- package/docs/observabilite.md +592 -0
- package/docs/protocole.md +518 -0
- package/docs/securite.md +845 -0
- package/docs/vocabulaire.md +845 -0
- package/package.json +83 -0
|
@@ -0,0 +1,1619 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Cookbook — un chat temps réel de bout en bout"
|
|
3
|
+
navTitle: Cookbook
|
|
4
|
+
lang: fr
|
|
5
|
+
module: "@nodefony/realtime"
|
|
6
|
+
topic: realtime
|
|
7
|
+
section: "Temps réel"
|
|
8
|
+
audience: [developer, devops]
|
|
9
|
+
tags:
|
|
10
|
+
[
|
|
11
|
+
cookbook,
|
|
12
|
+
chat,
|
|
13
|
+
tutoriel,
|
|
14
|
+
websocket,
|
|
15
|
+
canaux,
|
|
16
|
+
duplex,
|
|
17
|
+
upload,
|
|
18
|
+
backplane,
|
|
19
|
+
cluster,
|
|
20
|
+
]
|
|
21
|
+
version: "doc"
|
|
22
|
+
status: stable
|
|
23
|
+
updated: 2026-07-19
|
|
24
|
+
source: "src/packages/@nodefony/realtime/docs/cookbook-chat.md"
|
|
25
|
+
coverageModule: realtime
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
# Cookbook — un chat temps réel de bout en bout
|
|
29
|
+
|
|
30
|
+
> Sept étapes, du salon vide au salon qui tient plusieurs répliques. On part d'une application
|
|
31
|
+
> vierge, on écrit **un seul contrôleur**, et ce contrôleur répond à la fois en HTTP et en
|
|
32
|
+
> WebSocket — l'historique par `GET`, le direct par le canal, la pièce jointe par `POST` et sa
|
|
33
|
+
> diffusion par le canal. Chaque bloc de code est un fichier complet, avec son chemin. Rien n'est
|
|
34
|
+
> supposé connu du framework.
|
|
35
|
+
|
|
36
|
+
📍 [Documentation](../../../../../docs/index.md) › [@nodefony/realtime](index.md) › **Cookbook — un chat**
|
|
37
|
+
|
|
38
|
+
## 🎯 Ce qu'on construit
|
|
39
|
+
|
|
40
|
+
**En une phrase** : un salon de discussion où plusieurs personnes échangent des messages et des
|
|
41
|
+
fichiers en direct, avec présence, indicateur de frappe, historique persistant, contrôle d'accès, et
|
|
42
|
+
qui continue de fonctionner quand l'application passe de un à plusieurs processus.
|
|
43
|
+
|
|
44
|
+
**Ce que tu verras à l'écran** : deux onglets de navigateur côte à côte, ouverts sur la même page.
|
|
45
|
+
Tu tapes un message dans l'onglet de gauche, tu valides — il apparaît dans l'onglet de droite
|
|
46
|
+
immédiatement, sans rechargement, sans bouton « rafraîchir ». Sous la zone de saisie, une ligne
|
|
47
|
+
« Alice est en train d'écrire… » apparaît et disparaît. En haut, la liste des personnes connectées
|
|
48
|
+
se met à jour quand tu fermes un onglet.
|
|
49
|
+
|
|
50
|
+
**Ce que tu écriras** : un contrôleur d'environ 120 lignes, un composant React, et trois lignes de
|
|
51
|
+
configuration. Le protocole, la reconnexion, le multiplexage des canaux, le nettoyage des
|
|
52
|
+
abonnements et la diffusion entre répliques ne sont pas de ton ressort — c'est ce que le module
|
|
53
|
+
apporte.
|
|
54
|
+
|
|
55
|
+
### Le plan
|
|
56
|
+
|
|
57
|
+
| Étape | Ce qu'elle ajoute | Ce qui devient possible |
|
|
58
|
+
| --------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------- |
|
|
59
|
+
| [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 |
|
|
60
|
+
| [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 |
|
|
61
|
+
| [3 — présence et frappe](#-étape-3--présence-et-frappe-en-cours) | Deux canaux de plus sur la **même** connexion | Multiplexage sans socket supplémentaire |
|
|
62
|
+
| [4 — persister](#-étape-4--persister-lhistorique) | Un repository derrière un service | L'historique survit au redémarrage |
|
|
63
|
+
| [5 — protéger](#-étape-5--protéger-le-salon) | Zone firewall + politique de canal | L'intrus est refusé, et sait qu'il l'est |
|
|
64
|
+
| [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** |
|
|
65
|
+
| [7 — plusieurs pods](#-étape-7--passer-à-plusieurs-pods) | Un changement de **driver**, zéro changement de logique métier | Le salon traverse les répliques |
|
|
66
|
+
|
|
67
|
+
> [!TIP]
|
|
68
|
+
> Les étapes s'enchaînent : chacune reprend le fichier de la précédente et dit précisément ce
|
|
69
|
+
> qu'elle **ajoute**. Tu peux t'arrêter à l'étape 3 et avoir déjà quelque chose d'utilisable.
|
|
70
|
+
|
|
71
|
+
## 📖 Lexique
|
|
72
|
+
|
|
73
|
+
Tous les termes employés dans cette page. Le fond de chacun est dans
|
|
74
|
+
[vocabulaire.md](./vocabulaire.md) — ici, la définition strictement suffisante pour suivre.
|
|
75
|
+
|
|
76
|
+
| Terme | Ce que c'est |
|
|
77
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
78
|
+
| **Socket** | La connexion WebSocket unique entre un navigateur et le serveur. Une seule par onglet, quel que soit le nombre de canaux. |
|
|
79
|
+
| **Canal** | Un flux nommé qui circule **dans** la socket (`chat:room`, `chat:presence`). Analogie : la socket est le câble, les canaux sont les fréquences qui y passent. |
|
|
80
|
+
| **Frame** | Un message du protocole sur le fil. Toutes les frames sont du JSON-RPC 2.0. |
|
|
81
|
+
| **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. |
|
|
82
|
+
| **Backplane** | Le « fond de panier » : ce qui relaie les publications d'un processus à l'autre. Sans lui, chaque réplique est une île. |
|
|
83
|
+
| **Pod / réplique** | Un processus Node qui sert l'application. Un seul en développement, plusieurs en production. |
|
|
84
|
+
| **Fan-out** | La recopie d'une publication vers N abonnés. Une publication, N livraisons. |
|
|
85
|
+
| **Inbound** | Un canal où le **client** pousse vers le serveur. Sens inverse du pub/sub habituel ; il faut le déclarer explicitement. |
|
|
86
|
+
| **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. |
|
|
87
|
+
| **Provider** | La fonction qui « alimente » un canal côté serveur. Créée au premier abonné, détruite au dernier. |
|
|
88
|
+
| **Sink** | Le point de sortie d'une connexion sur un canal. Un abonné = un sink. |
|
|
89
|
+
| **BFF** | _Backend For Frontend_ : le serveur porte la session dans un cookie, le navigateur ne manipule aucun jeton. |
|
|
90
|
+
| **Zone / area** | Un motif d'URL que le firewall protège, avec la liste des façons de prouver son identité. |
|
|
91
|
+
| **Politique** | Les exigences attachées à un canal : authentifié, tel rôle, tel scope. |
|
|
92
|
+
| **Multipart** | L'encodage d'un formulaire HTTP qui transporte des fichiers (`multipart/form-data`). |
|
|
93
|
+
|
|
94
|
+
## 🧠 Le modèle mental en trois phrases
|
|
95
|
+
|
|
96
|
+
**Un.** Le navigateur ouvre **une seule** connexion WebSocket vers ton contrôleur. Tout ce qui suit
|
|
97
|
+
— messages, présence, frappe, annonces de fichiers — voyage dedans, en parallèle, sans jamais
|
|
98
|
+
ouvrir de seconde connexion.
|
|
99
|
+
|
|
100
|
+
**Deux.** Sur cette connexion circulent des **canaux nommés**. Le client dit « je m'abonne à
|
|
101
|
+
`chat:room` », le serveur publie sur `chat:room`, et tous les abonnés reçoivent. Le serveur ne
|
|
102
|
+
s'occupe jamais de « qui envoyer à qui » : il publie sur un nom, le hub distribue.
|
|
103
|
+
|
|
104
|
+
**Trois.** Un contrôleur temps réel est un **contrôleur Nodefony ordinaire**. Il a les mêmes
|
|
105
|
+
décorateurs de route, le même contexte, les mêmes services injectés, les mêmes gardes de sécurité
|
|
106
|
+
que ton contrôleur web. C'est pour ça que la même classe pourra servir un `GET` et un canal
|
|
107
|
+
WebSocket sans acrobatie — et c'est le cœur de cette page.
|
|
108
|
+
|
|
109
|
+
```mermaid
|
|
110
|
+
flowchart LR
|
|
111
|
+
subgraph Navigateur
|
|
112
|
+
A[Onglet Alice]
|
|
113
|
+
B[Onglet Bob]
|
|
114
|
+
end
|
|
115
|
+
subgraph Serveur["Un processus Node"]
|
|
116
|
+
C["ChatController<br/>(une classe)"]
|
|
117
|
+
H["RealtimeHub<br/>(le distributeur)"]
|
|
118
|
+
end
|
|
119
|
+
A -- "1 socket, N canaux" --> C
|
|
120
|
+
B -- "1 socket, N canaux" --> C
|
|
121
|
+
C -- "publish('chat:room')" --> H
|
|
122
|
+
H -- "fan-out" --> A
|
|
123
|
+
H -- "fan-out" --> B
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Le détail du trajet d'une frame, du câble jusqu'à ta méthode, est dans
|
|
127
|
+
[architecture.md](./architecture.md). Pour suivre ce cookbook, les trois phrases ci-dessus suffisent.
|
|
128
|
+
|
|
129
|
+
## 🧰 Ce dont tu pars
|
|
130
|
+
|
|
131
|
+
Une application Nodefony neuve, et rien d'autre. **Pas de Redis, pas de Docker, pas de base de
|
|
132
|
+
données** à cette étape — on n'en aura besoin qu'aux étapes 4 et 7, et la page le dira explicitement
|
|
133
|
+
le moment venu.
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
# 1. Créer l'application (profil complet : http, framework, realtime, sécurité, frontend)
|
|
137
|
+
npx nodefony create app mon-chat --complete
|
|
138
|
+
cd mon-chat
|
|
139
|
+
|
|
140
|
+
# 2. Créer le module qui portera le salon
|
|
141
|
+
npx nodefony create module chat
|
|
142
|
+
|
|
143
|
+
# 3. Créer le contrôleur temps réel dans ce module
|
|
144
|
+
npx nodefony create controller chat --module chat --kind realtime
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
> [!NOTE]
|
|
148
|
+
> **Un module, c'est quoi ?** Un dossier autonome qui regroupe les contrôleurs, services et
|
|
149
|
+
> configuration d'une fonctionnalité — l'équivalent d'un « bundle » Symfony ou d'un « module »
|
|
150
|
+
> NestJS. Il vit dans `modules/<nom>/` et c'est un vrai paquet npm : `nodefony create module chat`
|
|
151
|
+
> dans une app nommée `mon-chat` produit le paquet `@mon-chat/chat`. Tu n'as rien à publier ; ce
|
|
152
|
+
> nommage existe pour que le jour où tu veux partager le module, il n'y ait rien à refaire.
|
|
153
|
+
|
|
154
|
+
### L'arborescence, une fois pour toutes
|
|
155
|
+
|
|
156
|
+
Voici l'ensemble des fichiers que cette page touche. Les trois commandes ci-dessus les créent tous ;
|
|
157
|
+
tu n'écriras du contenu que dans ceux marqués **✍️**.
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
mon-chat/
|
|
161
|
+
├── nodefony.config.ts ✍️ le manifeste : quels modules charger, et comment
|
|
162
|
+
├── env.ts catalogue des variables d'environnement
|
|
163
|
+
├── index.ts point d'entrée de l'app
|
|
164
|
+
├── package.json
|
|
165
|
+
├── modules/
|
|
166
|
+
│ └── chat/ le module du salon (paquet @mon-chat/chat)
|
|
167
|
+
│ ├── index.ts la classe Module : déclare les contrôleurs
|
|
168
|
+
│ ├── package.json
|
|
169
|
+
│ └── nodefony/
|
|
170
|
+
│ ├── config/
|
|
171
|
+
│ │ ├── config.ts schéma Zod de la config du module
|
|
172
|
+
│ │ └── defineModuleConfig.ts
|
|
173
|
+
│ ├── service/
|
|
174
|
+
│ │ └── ChatService.ts ✍️ (étape 4) l'accès aux données
|
|
175
|
+
│ └── controllers/
|
|
176
|
+
│ └── ChatController.ts ✍️ LE fichier central de cette page
|
|
177
|
+
└── frontend/
|
|
178
|
+
└── src/
|
|
179
|
+
├── main.tsx ✍️ monte React et fournit la socket
|
|
180
|
+
├── realtime.ts ✍️ crée la connexion, une seule fois
|
|
181
|
+
└── ChatRoom.tsx ✍️ l'écran du salon
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Le fichier que le scaffold a écrit pour toi
|
|
185
|
+
|
|
186
|
+
Tu n'as pas à le modifier avant l'étape 4, mais il faut savoir ce qu'il contient — c'est lui qui
|
|
187
|
+
relie ton contrôleur au reste de l'application :
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// modules/chat/index.ts — généré par `nodefony create module chat`
|
|
191
|
+
import { Kernel, Module } from "nodefony";
|
|
192
|
+
import { controllers } from "@nodefony/framework";
|
|
193
|
+
import config from "./nodefony/config/config";
|
|
194
|
+
import ChatController from "./nodefony/controllers/ChatController";
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Le décorateur `@controllers([...])` déclare au Kernel les contrôleurs de ce
|
|
198
|
+
* module. C'est la SEULE inscription nécessaire : les routes, elles, sont lues
|
|
199
|
+
* sur les décorateurs des méthodes du contrôleur, pas listées ici.
|
|
200
|
+
*/
|
|
201
|
+
@controllers([ChatController])
|
|
202
|
+
class ChatModule extends Module {
|
|
203
|
+
constructor(kernel: Kernel) {
|
|
204
|
+
// 4ᵉ argument : les défauts de configuration du module, validés au boot.
|
|
205
|
+
super("chat", kernel, import.meta.url, config);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export default ChatModule;
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
> [!IMPORTANT]
|
|
213
|
+
> **Pourquoi un décorateur suffit à monter une route.** Au démarrage, le Kernel parcourt les
|
|
214
|
+
> contrôleurs déclarés, lit les métadonnées posées par `@controller` et `@route` sur la classe et ses
|
|
215
|
+
> méthodes, et construit la table de routage. Tu ne tiens aucun fichier de routes à la main : la
|
|
216
|
+
> route vit à côté du code qui l'implémente. Le même mécanisme vaut pour les routes WebSocket — une
|
|
217
|
+
> route WebSocket est une route comme une autre, qui déclare simplement un transport différent.
|
|
218
|
+
|
|
219
|
+
## 🚀 Démarrage rapide : étape 1, le chat minimal qui marche
|
|
220
|
+
|
|
221
|
+
L'objectif de cette étape est **la plus petite chose qui fonctionne** : deux onglets, un message qui
|
|
222
|
+
traverse. Trois fichiers à écrire, un à ajuster.
|
|
223
|
+
|
|
224
|
+
### Le contrôleur — fichier **créé** (le scaffold l'a rempli d'un exemple, on le remplace)
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
// modules/chat/nodefony/controllers/ChatController.ts
|
|
228
|
+
import { controller, route } from "@nodefony/framework";
|
|
229
|
+
import {
|
|
230
|
+
RealtimeController,
|
|
231
|
+
RealtimeChannel,
|
|
232
|
+
RealtimeInbound,
|
|
233
|
+
getRealtimeHub,
|
|
234
|
+
} from "@nodefony/realtime";
|
|
235
|
+
import type { RealtimePublish } from "@nodefony/realtime";
|
|
236
|
+
import type { ContextType } from "@nodefony/http";
|
|
237
|
+
|
|
238
|
+
/** Un message tel qu'il circule sur le canal du salon. */
|
|
239
|
+
export interface ChatMessage {
|
|
240
|
+
author: string;
|
|
241
|
+
text: string;
|
|
242
|
+
sentAt: number;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Le nom du canal du salon. Une constante : il est cité côté serveur ET côté client. */
|
|
246
|
+
export const ROOM_CHANNEL = "chat:room";
|
|
247
|
+
|
|
248
|
+
@controller("/chat")
|
|
249
|
+
class ChatController extends RealtimeController {
|
|
250
|
+
constructor(context: ContextType) {
|
|
251
|
+
super("chat", context);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* LA PORTE. Cette route ne répond qu'au transport WEBSOCKET : c'est l'URL que
|
|
256
|
+
* le navigateur ouvre (`/chat/realtime`). `handleRealtime` prend en charge tout
|
|
257
|
+
* le protocole — poignée de main, abonnements, découpage des frames, nettoyage
|
|
258
|
+
* à la fermeture. Tu n'écris jamais une ligne de protocole.
|
|
259
|
+
*/
|
|
260
|
+
@route("chat-realtime", {
|
|
261
|
+
path: "/realtime",
|
|
262
|
+
requirements: { methods: ["WEBSOCKET"] },
|
|
263
|
+
})
|
|
264
|
+
async realtime(message: string | Buffer | null): Promise<void> {
|
|
265
|
+
this.handleRealtime(message);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* LE CANAL DU SALON. Cette méthode est le « provider » : le hub l'appelle au
|
|
270
|
+
* PREMIER abonné et attend en retour une fonction de nettoyage, qu'il appellera
|
|
271
|
+
* au DERNIER désabonnement.
|
|
272
|
+
*
|
|
273
|
+
* Ici, il n'y a rien à démarrer : le salon n'émet pas tout seul, il relaie ce
|
|
274
|
+
* que les gens envoient. On rend donc une fonction de nettoyage vide. Elle
|
|
275
|
+
* n'est pas facultative : un canal dont le provider renvoie `null` est
|
|
276
|
+
* considéré INCONNU, et l'abonnement est refusé en silence.
|
|
277
|
+
*/
|
|
278
|
+
@RealtimeChannel(ROOM_CHANNEL)
|
|
279
|
+
room(_channel: string, _publish: RealtimePublish): () => void {
|
|
280
|
+
return () => {};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* L'ENVOI. `@RealtimeInbound` déclare un canal où le CLIENT pousse vers le
|
|
285
|
+
* serveur — le sens inverse du pub/sub. Rien n'est ouvert par défaut : tant
|
|
286
|
+
* qu'une méthode ne le déclare pas, un client ne peut rien pousser.
|
|
287
|
+
*
|
|
288
|
+
* `params` vient du réseau : il n'est PAS digne de confiance. On le valide
|
|
289
|
+
* avant toute chose. `reply` répond à CETTE connexion seulement ; pour parler
|
|
290
|
+
* à tout le salon, on publie sur le canal.
|
|
291
|
+
*/
|
|
292
|
+
@RealtimeInbound("chat:send")
|
|
293
|
+
onSend(params: unknown, reply: (payload: unknown) => void): void {
|
|
294
|
+
const input = params as { author?: unknown; text?: unknown } | null;
|
|
295
|
+
const text = typeof input?.text === "string" ? input.text.trim() : "";
|
|
296
|
+
const author =
|
|
297
|
+
typeof input?.author === "string" && input.author.length > 0
|
|
298
|
+
? input.author.slice(0, 40)
|
|
299
|
+
: "anonyme";
|
|
300
|
+
|
|
301
|
+
if (text.length === 0 || text.length > 2000) {
|
|
302
|
+
reply({ ok: false, error: "message vide ou trop long" });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const message: ChatMessage = { author, text, sentAt: Date.now() };
|
|
307
|
+
|
|
308
|
+
// Diffusion à TOUS les abonnés du canal. Une publication, N livraisons.
|
|
309
|
+
getRealtimeHub().publish(ROOM_CHANNEL, message);
|
|
310
|
+
|
|
311
|
+
// Accusé de réception, à l'expéditeur uniquement.
|
|
312
|
+
reply({ ok: true, sentAt: message.sentAt });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export default ChatController;
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
> [!WARNING]
|
|
320
|
+
> **`author` vient du client — il ment.** À cette étape, n'importe qui peut se déclarer « Alice ».
|
|
321
|
+
> C'est assumé : on construit d'abord ce qui marche. L'[étape 5](#-étape-5--protéger-le-salon)
|
|
322
|
+
> remplace ce champ par l'identité **vérifiée par le serveur**, et le client n'aura plus son mot à
|
|
323
|
+
> dire.
|
|
324
|
+
|
|
325
|
+
### La configuration — fichier **modifié**
|
|
326
|
+
|
|
327
|
+
Le manifeste `modules` dit au Kernel quoi charger, et dans quel ordre. `use(nom, config)` charge un
|
|
328
|
+
module **et** lui passe sa configuration au même endroit — c'est tout ce que fait cette fonction.
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
// nodefony.config.ts — le manifeste des modules (extrait)
|
|
332
|
+
export default defineConfig(() => ({
|
|
333
|
+
modules: [
|
|
334
|
+
// Serveurs HTTP/HTTPS/WebSocket natifs.
|
|
335
|
+
use("@nodefony/http", {}),
|
|
336
|
+
// Routeur, contrôleurs, décorateurs.
|
|
337
|
+
"@nodefony/framework",
|
|
338
|
+
// La socket Nodefony. `loopback` = un seul processus, aucune infrastructure
|
|
339
|
+
// externe. C'est le défaut, et c'est tout ce qu'il faut jusqu'à l'étape 7.
|
|
340
|
+
use("@nodefony/realtime", { backplane: { driver: "loopback" } }),
|
|
341
|
+
// Sert le frontend (Vite en développement, fichiers compilés en production).
|
|
342
|
+
"@nodefony/frontend",
|
|
343
|
+
// Ton module.
|
|
344
|
+
"@mon-chat/chat",
|
|
345
|
+
],
|
|
346
|
+
}));
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### La connexion côté navigateur — fichier **créé**
|
|
350
|
+
|
|
351
|
+
Une seule instance pour toute l'application. C'est important : ouvrir une connexion par composant
|
|
352
|
+
gaspillerait une socket par écran.
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
// frontend/src/realtime.ts
|
|
356
|
+
import { RealtimeClient } from "nodefony/client";
|
|
357
|
+
|
|
358
|
+
// `wss` si la page est en HTTPS, `ws` sinon. Une application Nodefony sert en
|
|
359
|
+
// HTTPS par défaut, y compris en développement : ce sera donc `wss` chez toi.
|
|
360
|
+
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* L'unique connexion du navigateur. L'URL est celle de la route WEBSOCKET du
|
|
364
|
+
* contrôleur : préfixe de classe `/chat` + chemin de route `/realtime`.
|
|
365
|
+
*
|
|
366
|
+
* La reconnexion automatique, le renvoi des abonnements après une coupure et le
|
|
367
|
+
* heartbeat sont inclus — rien à écrire pour ça.
|
|
368
|
+
*/
|
|
369
|
+
export const chatSocket = new RealtimeClient({
|
|
370
|
+
url: `${scheme}://${window.location.host}/chat/realtime`,
|
|
371
|
+
});
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### L'écran — fichier **créé**
|
|
375
|
+
|
|
376
|
+
```tsx ignore
|
|
377
|
+
// frontend/src/ChatRoom.tsx
|
|
378
|
+
import { useState } from "react";
|
|
379
|
+
import {
|
|
380
|
+
useNodefony,
|
|
381
|
+
useNodefonyChannel,
|
|
382
|
+
useNodefonyState,
|
|
383
|
+
} from "nodefony/react";
|
|
384
|
+
|
|
385
|
+
interface ChatMessage {
|
|
386
|
+
author: string;
|
|
387
|
+
text: string;
|
|
388
|
+
sentAt: number;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function ChatRoom({ pseudo }: { pseudo: string }) {
|
|
392
|
+
const socket = useNodefony();
|
|
393
|
+
const state = useNodefonyState();
|
|
394
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
395
|
+
const [draft, setDraft] = useState("");
|
|
396
|
+
|
|
397
|
+
// S'abonne au canal du salon et empile chaque message reçu.
|
|
398
|
+
// Le hook gère l'abonnement, le désabonnement au démontage, et le
|
|
399
|
+
// ré-abonnement après une reconnexion.
|
|
400
|
+
useNodefonyChannel("chat:room", (payload) => {
|
|
401
|
+
setMessages((previous) => [...previous, payload as ChatMessage]);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const send = () => {
|
|
405
|
+
const text = draft.trim();
|
|
406
|
+
if (text.length === 0) return;
|
|
407
|
+
// `channel(...).send(...)` pousse sur un canal inbound : c'est le pendant
|
|
408
|
+
// client de `@RealtimeInbound("chat:send")` côté serveur.
|
|
409
|
+
socket.channel("chat:send").send({ author: pseudo, text });
|
|
410
|
+
setDraft("");
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
return (
|
|
414
|
+
<section>
|
|
415
|
+
<p>Connexion : {state}</p>
|
|
416
|
+
<ul>
|
|
417
|
+
{messages.map((message) => (
|
|
418
|
+
<li key={`${message.author}-${message.sentAt}`}>
|
|
419
|
+
<strong>{message.author}</strong> : {message.text}
|
|
420
|
+
</li>
|
|
421
|
+
))}
|
|
422
|
+
</ul>
|
|
423
|
+
<input
|
|
424
|
+
value={draft}
|
|
425
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
426
|
+
onKeyDown={(event) => {
|
|
427
|
+
if (event.key === "Enter") send();
|
|
428
|
+
}}
|
|
429
|
+
placeholder="Ton message…"
|
|
430
|
+
/>
|
|
431
|
+
<button type="button" onClick={send}>
|
|
432
|
+
Envoyer
|
|
433
|
+
</button>
|
|
434
|
+
</section>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
```tsx ignore
|
|
440
|
+
// frontend/src/main.tsx
|
|
441
|
+
import { createRoot } from "react-dom/client";
|
|
442
|
+
import { NodefonyProvider } from "nodefony/react";
|
|
443
|
+
import { chatSocket } from "./realtime";
|
|
444
|
+
import { ChatRoom } from "./ChatRoom";
|
|
445
|
+
|
|
446
|
+
// La connexion s'ouvre UNE fois, au démarrage de l'application. Les hooks ne
|
|
447
|
+
// gèrent que les abonnements : le cycle de vie de la socket reste à toi.
|
|
448
|
+
void chatSocket.connect();
|
|
449
|
+
|
|
450
|
+
createRoot(document.getElementById("app")!).render(
|
|
451
|
+
<NodefonyProvider client={chatSocket}>
|
|
452
|
+
<ChatRoom pseudo={`invité-${Math.floor(Math.random() * 1000)}`} />
|
|
453
|
+
</NodefonyProvider>,
|
|
454
|
+
);
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
### Ce qu'on observe
|
|
458
|
+
|
|
459
|
+
```bash
|
|
460
|
+
npx nodefony development
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
Au démarrage, la ligne d'identité du temps réel apparaît dans les logs. Elle a cette forme —
|
|
464
|
+
`driver` est celui que tu as déclaré, `kind=local` signifie « aucun relais entre processus », ce qui
|
|
465
|
+
est exactement ce qu'on veut ici :
|
|
466
|
+
|
|
467
|
+
```
|
|
468
|
+
realtime backplane driver=loopback kind=local cross-pod=no (hub local)
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
Ouvre maintenant `https://127.0.0.1:5152/` dans **deux onglets**, côte à côte.
|
|
472
|
+
|
|
473
|
+
| Tu fais | Tu vois |
|
|
474
|
+
| ------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
475
|
+
| Tu ouvres le premier onglet | `Connexion : connected`. Côté serveur, une ligne `WS realtime client connected`. |
|
|
476
|
+
| Tu tapes « bonjour » et tu valides | Le message apparaît dans l'onglet **gauche ET l'onglet droit**, sans rechargement. |
|
|
477
|
+
| Tu fermes un onglet | Côté serveur, `WS realtime client disconnected — cleanup done`. L'abonnement et le provider sont libérés. |
|
|
478
|
+
| Tu coupes le serveur puis le relances | `Connexion : reconnecting` puis `connected`. L'abonnement au canal est renvoyé tout seul. |
|
|
479
|
+
|
|
480
|
+
C'est un chat fonctionnel. Les six étapes suivantes le rendent utilisable pour de vrai.
|
|
481
|
+
|
|
482
|
+
## 🔌 Étape 2 : le même contrôleur sert HTTP et WebSocket
|
|
483
|
+
|
|
484
|
+
**C'est la section qui compte.** Tout ce qui précède, d'autres outils le font. Ce qui suit, non.
|
|
485
|
+
|
|
486
|
+
### Le problème que ça résout
|
|
487
|
+
|
|
488
|
+
Un salon a besoin de deux choses de nature opposée :
|
|
489
|
+
|
|
490
|
+
- **L'historique** — une lecture ponctuelle, qu'on veut pouvoir appeler en `curl`, mettre en cache,
|
|
491
|
+
indexer, tester avec un client HTTP ordinaire.
|
|
492
|
+
- **Le direct** — un flux continu poussé par le serveur, qui n'a aucun sens en requête/réponse.
|
|
493
|
+
|
|
494
|
+
L'architecture habituelle sépare : un serveur HTTP d'un côté, un serveur WebSocket de l'autre, deux
|
|
495
|
+
processus, deux façons de s'authentifier, deux endroits où recopier la logique métier — et le jour
|
|
496
|
+
où la règle « seuls les membres du salon voient l'historique » change, il faut la changer deux fois,
|
|
497
|
+
en espérant ne pas en oublier une.
|
|
498
|
+
|
|
499
|
+
### Ce que fait Nodefony
|
|
500
|
+
|
|
501
|
+
**Une classe. Un contexte. Les mêmes services injectés. Les mêmes gardes.** Une méthode déclare
|
|
502
|
+
simplement à quels transports elle répond :
|
|
503
|
+
|
|
504
|
+
```ts ignore
|
|
505
|
+
requirements: {
|
|
506
|
+
methods: ["GET", "WEBSOCKET"];
|
|
507
|
+
}
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
Cette méthode est alors joignable de **deux** façons, et c'est **le même code** qui s'exécute :
|
|
511
|
+
|
|
512
|
+
- `GET /chat/history` en HTTP ;
|
|
513
|
+
- `socket.request("/chat/history")` par la socket déjà ouverte.
|
|
514
|
+
|
|
515
|
+
Le passage par la socket emprunte un pont interne qui rejoue le routage sur le chemin demandé, puis
|
|
516
|
+
exécute l'action. Ce pont ne peut atteindre **que** les routes ayant explicitement déclaré le
|
|
517
|
+
transport `WEBSOCKET` — une action décide à quelles portes elle répond, il n'y a pas de contournement
|
|
518
|
+
possible. Il est de plus désactivé par défaut : il faut l'ouvrir, contrôleur par contrôleur.
|
|
519
|
+
|
|
520
|
+
### Le contrôleur — fichier **modifié**
|
|
521
|
+
|
|
522
|
+
Trois ajouts au fichier de l'étape 1 : un petit stock en mémoire, l'ouverture du pont, et la route à
|
|
523
|
+
double transport.
|
|
524
|
+
|
|
525
|
+
```ts ignore
|
|
526
|
+
// modules/chat/nodefony/controllers/ChatController.ts (ajouts de l'étape 2)
|
|
527
|
+
import { Query } from "@nodefony/framework";
|
|
528
|
+
|
|
529
|
+
@controller("/chat")
|
|
530
|
+
class ChatController extends RealtimeController {
|
|
531
|
+
/** Stock temporaire — l'étape 4 le remplace par une vraie base. */
|
|
532
|
+
static history: ChatMessage[] = [];
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* OUVRE LE PONT. Sans ce `true`, la connexion socket n'expose aucune surface
|
|
536
|
+
* d'invocation d'API : c'est un choix par défaut, une porte ne s'ouvre jamais
|
|
537
|
+
* toute seule.
|
|
538
|
+
*/
|
|
539
|
+
protected override realtimeApiRequest(): boolean {
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* UNE méthode, DEUX transports. Le corps ne sait pas — et n'a pas à savoir —
|
|
545
|
+
* par où l'appel est arrivé.
|
|
546
|
+
*/
|
|
547
|
+
@route("chat-history", {
|
|
548
|
+
path: "/history",
|
|
549
|
+
requirements: { methods: ["GET", "WEBSOCKET"] },
|
|
550
|
+
})
|
|
551
|
+
historyAction(@Query("limit") limit?: string) {
|
|
552
|
+
const max = Math.min(Number(limit) || 50, 200);
|
|
553
|
+
return ChatController.history.slice(-max);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
Et dans `onSend`, une ligne pour alimenter le stock avant de publier :
|
|
559
|
+
|
|
560
|
+
```ts ignore
|
|
561
|
+
ChatController.history.push(message);
|
|
562
|
+
getRealtimeHub().publish(ROOM_CHANNEL, message);
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
### Côté client — l'historique au montage
|
|
566
|
+
|
|
567
|
+
```tsx ignore
|
|
568
|
+
// frontend/src/ChatRoom.tsx (ajout de l'étape 2)
|
|
569
|
+
import { useEffect } from "react";
|
|
570
|
+
|
|
571
|
+
// Au montage : on récupère l'historique par la socket DÉJÀ ouverte.
|
|
572
|
+
// Aucune requête HTTP supplémentaire, aucun en-tête à reconstruire,
|
|
573
|
+
// aucune poignée de main TLS de plus.
|
|
574
|
+
useEffect(() => {
|
|
575
|
+
void socket
|
|
576
|
+
.request<ChatMessage[]>("/chat/history?limit=50")
|
|
577
|
+
.then((past) => setMessages(past));
|
|
578
|
+
}, [socket]);
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
Le chemin commence par `/` : le client le reconnaît comme un appel d'API et emprunte le pont. Une
|
|
582
|
+
méthode RPC ordinaire, elle, ne commence jamais par `/` — il n'y a pas de collision possible entre
|
|
583
|
+
les deux formes.
|
|
584
|
+
|
|
585
|
+
### Ce qu'on observe
|
|
586
|
+
|
|
587
|
+
La même donnée, obtenue de deux façons, par le même code serveur :
|
|
588
|
+
|
|
589
|
+
```bash
|
|
590
|
+
# Transport 1 — HTTP, un client universel
|
|
591
|
+
curl -k "https://127.0.0.1:5152/chat/history?limit=5"
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
```ts ignore
|
|
595
|
+
// Transport 2 — la socket déjà ouverte, depuis la console du navigateur
|
|
596
|
+
await chatSocket.request("/chat/history?limit=5");
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
Les deux renvoient le même tableau JSON. Si tu ajoutes une garde d'autorisation sur la méthode, elle
|
|
600
|
+
s'applique aux **deux** — c'est le même pipeline, pas deux implémentations à synchroniser.
|
|
601
|
+
|
|
602
|
+
> [!IMPORTANT]
|
|
603
|
+
> **Ce qu'il faut retenir.** Il n'y a pas « le serveur HTTP » et « le serveur WebSocket ». Il y a
|
|
604
|
+
> **ton contrôleur**, et deux façons de l'atteindre. La règle métier s'écrit une fois, la garde de
|
|
605
|
+
> sécurité s'écrit une fois, le test s'écrit une fois. L'[étape 6](#-étape-6--la-pièce-jointe-http-et-websocket-dans-la-même-classe)
|
|
606
|
+
> pousse le raisonnement jusqu'au bout, avec un `POST` de fichier dont le résultat ressort par le
|
|
607
|
+
> canal.
|
|
608
|
+
|
|
609
|
+
## 🧩 Étape 3 : présence et frappe en cours
|
|
610
|
+
|
|
611
|
+
On ajoute deux flux : qui est là, et qui est en train d'écrire. Le point de cette étape n'est pas
|
|
612
|
+
la fonctionnalité — c'est **ce qu'elle ne coûte pas**.
|
|
613
|
+
|
|
614
|
+
### Trois canaux, une seule connexion
|
|
615
|
+
|
|
616
|
+
Un salon complet a besoin d'au moins trois flux distincts. Dans beaucoup d'architectures, cela se
|
|
617
|
+
traduit par plusieurs connexions, ou par un canal fourre-tout où chacun filtre ce qui le concerne.
|
|
618
|
+
Ici, les trois canaux voyagent **dans la même socket**, chacun avec son nom, chacun avec son
|
|
619
|
+
abonnement propre :
|
|
620
|
+
|
|
621
|
+
```mermaid
|
|
622
|
+
flowchart LR
|
|
623
|
+
N["Onglet du navigateur<br/>UNE connexion WebSocket"]
|
|
624
|
+
N -.->|"chat:room"| R[Messages]
|
|
625
|
+
N -.->|"chat:presence"| P[Qui est connecté]
|
|
626
|
+
N -.->|"chat:typing"| T[Qui écrit]
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
S'abonner à un quatrième canal n'ouvre pas de quatrième connexion : cela envoie une frame
|
|
630
|
+
d'abonnement sur celle qui existe déjà. C'est ce que « multiplexer » veut dire ici, et c'est ce qui
|
|
631
|
+
rend viable le fait d'avoir un canal par préoccupation plutôt qu'un canal fourre-tout.
|
|
632
|
+
|
|
633
|
+
### Le contrôleur — fichier **modifié**
|
|
634
|
+
|
|
635
|
+
```ts ignore
|
|
636
|
+
// modules/chat/nodefony/controllers/ChatController.ts (ajouts de l'étape 3)
|
|
637
|
+
|
|
638
|
+
/** Les pseudos présents, par connexion. Statique : partagé par tout le processus. */
|
|
639
|
+
static present = new Set<string>();
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* PRÉSENCE. Ce provider-là a quelque chose à démarrer : un rappel régulier de
|
|
643
|
+
* l'état. La fonction de nettoyage rendue au hub arrête le minuteur — au dernier
|
|
644
|
+
* désabonné, plus personne ne paie ce coût.
|
|
645
|
+
*/
|
|
646
|
+
@RealtimeChannel("chat:presence")
|
|
647
|
+
presence(channel: string, publish: RealtimePublish): () => void {
|
|
648
|
+
const timer = setInterval(() => {
|
|
649
|
+
publish(channel, { present: [...ChatController.present] });
|
|
650
|
+
}, 5000);
|
|
651
|
+
// `unref` : ce minuteur ne doit jamais retenir le processus à l'arrêt.
|
|
652
|
+
timer.unref();
|
|
653
|
+
return () => clearInterval(timer);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** FRAPPE EN COURS. Volatile par nature : rien à persister, rien à rejouer. */
|
|
657
|
+
@RealtimeChannel("chat:typing")
|
|
658
|
+
typing(_channel: string, _publish: RealtimePublish): () => void {
|
|
659
|
+
return () => {};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Le client signale qu'il écrit ; on le relaie tel quel au salon. */
|
|
663
|
+
@RealtimeInbound("chat:typing")
|
|
664
|
+
onTyping(params: unknown): void {
|
|
665
|
+
const author = (params as { author?: unknown } | null)?.author;
|
|
666
|
+
if (typeof author !== "string" || author.length === 0) return;
|
|
667
|
+
getRealtimeHub().publish("chat:typing", { author: author.slice(0, 40) });
|
|
668
|
+
}
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
> [!NOTE]
|
|
672
|
+
> **Un canal peut porter les deux sens.** `chat:typing` est déclaré à la fois comme canal sortant
|
|
673
|
+
> (`@RealtimeChannel`) et comme canal entrant (`@RealtimeInbound`). C'est ce qu'on appelle un canal
|
|
674
|
+
> **duplex** : le client y pousse, et le reçoit en retour parce qu'il y est abonné.
|
|
675
|
+
|
|
676
|
+
### Côté client
|
|
677
|
+
|
|
678
|
+
```tsx ignore
|
|
679
|
+
// frontend/src/ChatRoom.tsx (ajouts de l'étape 3)
|
|
680
|
+
import { useNodefonyChannelData } from "nodefony/react";
|
|
681
|
+
|
|
682
|
+
// La DERNIÈRE valeur reçue suffit pour un état : on ne veut pas l'historique
|
|
683
|
+
// des présences, seulement l'état courant.
|
|
684
|
+
const presence = useNodefonyChannelData<{ present: string[] }>("chat:presence");
|
|
685
|
+
|
|
686
|
+
const [typingBy, setTypingBy] = useState<string | null>(null);
|
|
687
|
+
useNodefonyChannel("chat:typing", (payload) => {
|
|
688
|
+
const author = (payload as { author: string }).author;
|
|
689
|
+
if (author === pseudo) return; // ne pas s'afficher soi-même
|
|
690
|
+
setTypingBy(author);
|
|
691
|
+
// L'indicateur s'efface tout seul : le serveur n'envoie jamais de « stop ».
|
|
692
|
+
window.setTimeout(() => setTypingBy(null), 2500);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// Dans le rendu :
|
|
696
|
+
// <p>{presence?.present.length ?? 0} connecté(s)</p>
|
|
697
|
+
// {typingBy && <p>{typingBy} est en train d'écrire…</p>}
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
### Ce qu'on observe
|
|
701
|
+
|
|
702
|
+
| Tu fais | Tu vois |
|
|
703
|
+
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
704
|
+
| Tu tapes dans l'onglet gauche | « invité-412 est en train d'écrire… » apparaît dans l'onglet droit, puis disparaît seul. |
|
|
705
|
+
| Tu ouvres l'onglet réseau du navigateur | **Une seule** entrée WebSocket, même avec trois canaux actifs. C'est le point de cette étape. |
|
|
706
|
+
| Tu quittes l'écran du salon | Les trois abonnements sont relâchés. Au dernier abonné de `chat:presence`, le minuteur serveur est arrêté. |
|
|
707
|
+
|
|
708
|
+
## 💾 Étape 4 : persister l'historique
|
|
709
|
+
|
|
710
|
+
Le stock statique de l'étape 2 disparaît au redémarrage. On le remplace par un service qui parle à
|
|
711
|
+
la base. **Cette section reste volontairement courte** : ce n'est pas une page ORM, et le temps réel
|
|
712
|
+
n'a aucune opinion sur ta façon de stocker.
|
|
713
|
+
|
|
714
|
+
### Le principe
|
|
715
|
+
|
|
716
|
+
Le contrôleur ne doit pas parler à la base directement. On interpose un service, injecté par le
|
|
717
|
+
conteneur, et le contrôleur se contente de l'appeler :
|
|
718
|
+
|
|
719
|
+
```mermaid
|
|
720
|
+
flowchart LR
|
|
721
|
+
C[ChatController] --> S[ChatService]
|
|
722
|
+
S --> R["Repository (orm-core)"]
|
|
723
|
+
R --> DB[(Base)]
|
|
724
|
+
C -->|"publish"| H[RealtimeHub]
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
Le service ne sait rien du temps réel, et c'est délibéré : il est testable sans socket, réutilisable
|
|
728
|
+
depuis une commande CLI ou une tâche planifiée. Le contrôleur est le seul à connaître les deux
|
|
729
|
+
mondes — il enregistre, **puis** il diffuse.
|
|
730
|
+
|
|
731
|
+
### Le contrôleur — fichier **modifié**
|
|
732
|
+
|
|
733
|
+
La méthode `onSend` de l'étape 1 est remplacée **en entier** par celle-ci. Seules les deux dernières
|
|
734
|
+
lignes changent, mais la voici complète pour qu'il n'y ait rien à reconstituer :
|
|
735
|
+
|
|
736
|
+
```ts ignore
|
|
737
|
+
// modules/chat/nodefony/controllers/ChatController.ts
|
|
738
|
+
// → remplace intégralement la méthode `onSend` de l'étape 1
|
|
739
|
+
@RealtimeInbound("chat:send")
|
|
740
|
+
async onSend(params: unknown, reply: (payload: unknown) => void): Promise<void> {
|
|
741
|
+
const input = params as { author?: unknown; text?: unknown } | null;
|
|
742
|
+
const text = typeof input?.text === "string" ? input.text.trim() : "";
|
|
743
|
+
const author =
|
|
744
|
+
typeof input?.author === "string" && input.author.length > 0
|
|
745
|
+
? input.author.slice(0, 40)
|
|
746
|
+
: "anonyme";
|
|
747
|
+
|
|
748
|
+
if (text.length === 0 || text.length > 2000) {
|
|
749
|
+
reply({ ok: false, error: "message vide ou trop long" });
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// 1. On persiste D'ABORD. Si l'écriture échoue, rien n'est diffusé : on ne
|
|
754
|
+
// veut pas d'un message visible par tous mais absent de l'historique.
|
|
755
|
+
const saved = await this.chatService.save({ author, text, sentAt: Date.now() });
|
|
756
|
+
|
|
757
|
+
// 2. Puis on diffuse le message tel qu'il a été enregistré (avec son id).
|
|
758
|
+
getRealtimeHub().publish(ROOM_CHANNEL, saved);
|
|
759
|
+
|
|
760
|
+
reply({ ok: true, id: saved.id });
|
|
761
|
+
}
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
L'ordre compte, et c'est la seule règle de cette étape : **persister, puis publier**. L'inverse
|
|
765
|
+
produit des messages que certains ont vus et que l'historique ignore.
|
|
766
|
+
|
|
767
|
+
Pour écrire `ChatService` — entité, repository, migrations, choix du dialecte — tout est dans la
|
|
768
|
+
documentation dédiée : **[`@nodefony/orm-core`](../../orm-core/docs/index.md)**.
|
|
769
|
+
|
|
770
|
+
## 🔐 Étape 5 : protéger le salon
|
|
771
|
+
|
|
772
|
+
Jusqu'ici, n'importe qui peut se connecter et se déclarer « Alice ». On corrige les deux problèmes
|
|
773
|
+
d'un coup : savoir **qui** parle, et refuser ceux qui n'ont rien à faire là.
|
|
774
|
+
|
|
775
|
+
### Un : déclarer la zone
|
|
776
|
+
|
|
777
|
+
Une zone firewall dit « ces URL sont protégées, voici comment on y prouve son identité ». Elle
|
|
778
|
+
couvre le HTTP **et** le WebSocket qu'elle recouvre — c'est le défaut, et c'est volontaire : une
|
|
779
|
+
zone protégée qui laisserait passer le WebSocket serait une porte dérobée.
|
|
780
|
+
|
|
781
|
+
```ts ignore
|
|
782
|
+
// nodefony.config.ts (ajout de l'étape 5)
|
|
783
|
+
use("@nodefony/security", {
|
|
784
|
+
areas: {
|
|
785
|
+
chat: {
|
|
786
|
+
pattern: "^/chat",
|
|
787
|
+
// Cookie de session (BFF) : le navigateur n'a aucun jeton à manipuler,
|
|
788
|
+
// et le cookie voyage naturellement avec la poignée de main WebSocket.
|
|
789
|
+
authenticators: ["session"],
|
|
790
|
+
},
|
|
791
|
+
},
|
|
792
|
+
roleHierarchy: {
|
|
793
|
+
ROLE_ADMIN: ["ROLE_USER"],
|
|
794
|
+
},
|
|
795
|
+
});
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
> [!NOTE]
|
|
799
|
+
> **Tu n'écris aucun code de branchement.** Au démarrage, le firewall repère le module temps réel,
|
|
800
|
+
> installe l'authentificateur de chaque zone concernée sur la poignée de main, et pose le verrou qui
|
|
801
|
+
> filtrera les frames. La configuration ci-dessus suffit ; il n'y a pas de service à instancier ni
|
|
802
|
+
> de crochet à enregistrer.
|
|
803
|
+
|
|
804
|
+
### Deux : déclarer la politique du canal
|
|
805
|
+
|
|
806
|
+
Le décorateur de canal accepte un second argument : les exigences à satisfaire pour s'y abonner.
|
|
807
|
+
|
|
808
|
+
Les deux méthodes concernées, complètes, avec leur politique :
|
|
809
|
+
|
|
810
|
+
```ts ignore
|
|
811
|
+
// modules/chat/nodefony/controllers/ChatController.ts
|
|
812
|
+
// → remplace les déclarations de canal des étapes 1 et 3
|
|
813
|
+
@RealtimeChannel(ROOM_CHANNEL, { authenticated: true, roles: ["ROLE_USER"] })
|
|
814
|
+
room(_channel: string, _publish: RealtimePublish): () => void {
|
|
815
|
+
return () => {};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
@RealtimeChannel("chat:presence", { authenticated: true, roles: ["ROLE_USER"] })
|
|
819
|
+
presence(channel: string, publish: RealtimePublish): () => void {
|
|
820
|
+
const timer = setInterval(() => {
|
|
821
|
+
publish(channel, { present: [...ChatController.present] });
|
|
822
|
+
}, 5000);
|
|
823
|
+
timer.unref();
|
|
824
|
+
return () => clearInterval(timer);
|
|
825
|
+
}
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
Le module temps réel ne décide de rien : il **transporte la déclaration** jusqu'au firewall, seul à
|
|
829
|
+
connaître la hiérarchie des rôles et l'identité réelle.
|
|
830
|
+
|
|
831
|
+
### Trois : l'auteur n'est plus déclaré par le client
|
|
832
|
+
|
|
833
|
+
C'est ici qu'il faut être précis, car **tous les chemins ne se valent pas**.
|
|
834
|
+
|
|
835
|
+
Un handler `@RealtimeInbound` reçoit `(params, reply)` — et **rien d'autre**. Il ne reçoit ni la
|
|
836
|
+
connexion, ni le jeton d'identité résolu à la poignée de main. Autrement dit : depuis un canal
|
|
837
|
+
inbound brut, le serveur **ne peut pas savoir qui parle**. Le canal peut être _gardé_ par une
|
|
838
|
+
politique (personne d'anonyme ne s'y abonne), mais le nom de l'auteur reste, lui, déclaré par le
|
|
839
|
+
client.
|
|
840
|
+
|
|
841
|
+
La voie qui donne l'identité est celle de l'[étape 2](#-étape-2--le-même-contrôleur-sert-http-et-websocket) :
|
|
842
|
+
une **action de contrôleur**, atteinte par la socket. Sur ce chemin, le framework établit le contexte
|
|
843
|
+
de requête avec le jeton de la connexion — exactement comme en HTTP — et les décorateurs habituels
|
|
844
|
+
fonctionnent :
|
|
845
|
+
|
|
846
|
+
```ts ignore
|
|
847
|
+
// modules/chat/nodefony/controllers/ChatController.ts
|
|
848
|
+
// → remplace `@RealtimeInbound("chat:send")` par une ACTION
|
|
849
|
+
import { Body, CurrentUser, IsGranted } from "@nodefony/framework";
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* L'envoi devient une action, joignable en POST HTTP comme par la socket.
|
|
853
|
+
* On y gagne ce qu'un canal inbound ne peut pas offrir : l'identité vérifiée
|
|
854
|
+
* (`@CurrentUser`), la garde d'autorisation, et le pipeline complet.
|
|
855
|
+
*/
|
|
856
|
+
@route("chat-send", {
|
|
857
|
+
path: "/send",
|
|
858
|
+
requirements: { methods: ["POST", "WEBSOCKET"] },
|
|
859
|
+
})
|
|
860
|
+
@IsGranted(["ROLE_USER"])
|
|
861
|
+
async send(
|
|
862
|
+
@Body() payload: { text?: string },
|
|
863
|
+
@CurrentUser() user?: { identifier?: string },
|
|
864
|
+
) {
|
|
865
|
+
const text = typeof payload?.text === "string" ? payload.text.trim() : "";
|
|
866
|
+
if (text.length === 0 || text.length > 2000) {
|
|
867
|
+
throw new HttpError("message vide ou trop long", 400);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// L'auteur vient du SERVEUR. Le client ne peut plus mentir dessus.
|
|
871
|
+
const message: ChatMessage = {
|
|
872
|
+
author: user?.identifier ?? "inconnu",
|
|
873
|
+
text,
|
|
874
|
+
sentAt: Date.now(),
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
getRealtimeHub().publish(ROOM_CHANNEL, message);
|
|
878
|
+
return { ok: true, sentAt: message.sentAt };
|
|
879
|
+
}
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
Côté navigateur, l'envoi passe alors par `mutate`, qui porte la méthode HTTP logique et une clé
|
|
883
|
+
d'idempotence — une socket qui se reconnecte peut rejouer une frame, la clé évite le doublon :
|
|
884
|
+
|
|
885
|
+
```tsx ignore
|
|
886
|
+
// frontend/src/ChatRoom.tsx (étape 5)
|
|
887
|
+
await socket.mutate("/chat/send", {
|
|
888
|
+
method: "POST",
|
|
889
|
+
body: { text },
|
|
890
|
+
idempotencyKey: crypto.randomUUID(),
|
|
891
|
+
});
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
> [!TIP]
|
|
895
|
+
> **La règle à retenir.** Canal inbound = flux volatil, à haute fréquence, dont l'expéditeur importe
|
|
896
|
+
> peu (« untel écrit »). Action de contrôleur = tout ce qui doit être **attribué, autorisé ou
|
|
897
|
+
> conservé**. Le chat de l'étape 1 utilisait un canal inbound par simplicité ; dès qu'il y a des
|
|
898
|
+
> comptes, l'envoi devient une action. La frappe en cours, elle, reste un canal inbound — son auteur
|
|
899
|
+
> n'a pas besoin d'être prouvé.
|
|
900
|
+
|
|
901
|
+
Côté navigateur, l'identité annoncée par le serveur se lit sans appeler la moindre route :
|
|
902
|
+
|
|
903
|
+
```tsx ignore
|
|
904
|
+
// frontend/src/ChatRoom.tsx (étape 5)
|
|
905
|
+
import { useNodefonyIdentity } from "nodefony/react";
|
|
906
|
+
|
|
907
|
+
const identity = useNodefonyIdentity();
|
|
908
|
+
// `null` tant que la connexion n'a pas abouti ; ensuite :
|
|
909
|
+
// identity.authenticated === false → afficher l'écran de connexion
|
|
910
|
+
// identity.userIdentifier → le pseudo, garanti par le serveur
|
|
911
|
+
// identity.roles → pour masquer ce qui n'est pas permis
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
### Ce que voit précisément un intrus
|
|
915
|
+
|
|
916
|
+
Rien n'échoue en silence, et rien ne renseigne l'attaquant sur **pourquoi** il échoue.
|
|
917
|
+
|
|
918
|
+
| Situation | Ce que reçoit le client | Détail |
|
|
919
|
+
| ---------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------- |
|
|
920
|
+
| Pas de session, la zone en exige une | Fermeture WebSocket, code **4001**, motif `unauthorized` | La connexion n'est jamais établie. |
|
|
921
|
+
| Origine du navigateur non autorisée | Fermeture WebSocket, code **4003**, motif `origin not allowed` | Défense contre les connexions déclenchées par un tiers. |
|
|
922
|
+
| Session révoquée **pendant** la connexion (déconnexion ailleurs) | Fermeture WebSocket, code **4001**, motif `session revoked` | Vérification périodique ; une session morte ferme la socket. |
|
|
923
|
+
| Abonnement à un canal interdit | Notification `realtime:denied` = `{ channel, reason: "forbidden" }` | Le canal n'est pas ouvert. Le motif reste générique. |
|
|
924
|
+
| Trop de canaux ouverts sur une connexion | Notification `realtime:denied` = `{ channel, reason: "limit" }` | Garde anti-saturation, pas une décision de sécurité. |
|
|
925
|
+
| Nom de canal qu'aucun producteur ne sert | Notification `realtime:denied` = `{ channel, reason: "unknown" }` | Faute de frappe ou module absent — jamais un silence. |
|
|
926
|
+
| Appel d'API refusé par le pont | Erreur JSON-RPC, message `unauthorized` | Une requête reçoit une erreur ; une notification, un `denied`. |
|
|
927
|
+
|
|
928
|
+
> [!IMPORTANT]
|
|
929
|
+
> **Pourquoi `realtime:denied` existe.** Un abonnement est une notification : le protocole ne prévoit
|
|
930
|
+
> aucune réponse. Sans cette annonce, un client refusé se croirait abonné et attendrait indéfiniment
|
|
931
|
+
> des messages qui n'arriveraient jamais. Le refus est donc **rendu observable** — mais le motif
|
|
932
|
+
> reste volontairement vague : dire « il te manque `ROLE_ADMIN` » renseignerait l'attaquant sur la
|
|
933
|
+
> structure des droits.
|
|
934
|
+
|
|
935
|
+
Côté client, ces refus se branchent en un endroit :
|
|
936
|
+
|
|
937
|
+
```tsx ignore
|
|
938
|
+
// frontend/src/main.tsx — un seul point d'écoute pour toute l'application
|
|
939
|
+
chatSocket.onDenied((denied) => {
|
|
940
|
+
console.warn(`accès refusé au canal ${denied.channel} (${denied.reason})`);
|
|
941
|
+
});
|
|
942
|
+
```
|
|
943
|
+
|
|
944
|
+
### Le piège à connaître : une politique sans gardien
|
|
945
|
+
|
|
946
|
+
Si tu déclares une politique de canal **sans** charger le module de sécurité (ou avec toutes les
|
|
947
|
+
zones exclues du temps réel), la politique est enregistrée mais **inerte** — le canal est servi à
|
|
948
|
+
tout le monde. Le framework ne se tait pas : il émet un avertissement explicite au démarrage.
|
|
949
|
+
|
|
950
|
+
```
|
|
951
|
+
Realtime channel policies declared but NO frame authorizer is wired —
|
|
952
|
+
these policies are NOT enforced (a protected channel is currently open).
|
|
953
|
+
Load @nodefony/security with a realtime zone to enforce them.
|
|
954
|
+
```
|
|
955
|
+
|
|
956
|
+
Cet avertissement mérite d'être traité comme une erreur en production. Le fond du sujet —
|
|
957
|
+
authentificateurs disponibles, révocation, audit — est dans [securite.md](./securite.md).
|
|
958
|
+
|
|
959
|
+
## 📎 Étape 6 : la pièce jointe, HTTP et WebSocket dans la même classe
|
|
960
|
+
|
|
961
|
+
**C'est l'étape la plus importante de cette page**, et celle qui rendra l'étape suivante évidente au
|
|
962
|
+
lieu de théorique.
|
|
963
|
+
|
|
964
|
+
### Pourquoi un chat ne prouve rien tout seul
|
|
965
|
+
|
|
966
|
+
Un chat est trop indulgent. Deux secondes de latence ? On ne le voit pas. Un message qui arrive dans
|
|
967
|
+
le désordre ? On ne le remarque pas. Un message perdu de temps en temps ? On croit que l'autre n'a
|
|
968
|
+
pas répondu. Surtout : **tout y passe par la WebSocket**, donc rien n'exerce jamais ce qui distingue
|
|
969
|
+
ce framework — et en développement mono-processus, il marche parfaitement **sans backplane**, si
|
|
970
|
+
bien qu'on ne comprend jamais à quoi sert la brique la plus structurante du module.
|
|
971
|
+
|
|
972
|
+
La pièce jointe change tout, sans changer l'histoire :
|
|
973
|
+
|
|
974
|
+
- Le fichier part en **`POST` HTTP** (multipart) — le seul transport raisonnable pour des octets en
|
|
975
|
+
volume.
|
|
976
|
+
- Le résultat — « Alice a partagé `rapport.pdf` » — arrive à tout le salon **par le canal
|
|
977
|
+
WebSocket**.
|
|
978
|
+
- Les deux sont servis par **la même classe**, avec le même contexte et les mêmes services.
|
|
979
|
+
|
|
980
|
+
Le différenciateur cesse d'être une affirmation : il devient la seule façon simple d'écrire cette
|
|
981
|
+
fonctionnalité.
|
|
982
|
+
|
|
983
|
+
### Le contrôleur — fichier **modifié**
|
|
984
|
+
|
|
985
|
+
```ts ignore
|
|
986
|
+
// modules/chat/nodefony/controllers/ChatController.ts (ajouts de l'étape 6)
|
|
987
|
+
import { UploadedFile } from "@nodefony/framework";
|
|
988
|
+
import type { IUploadedFile } from "@nodefony/http";
|
|
989
|
+
|
|
990
|
+
/** Ce qu'on annonce au salon quand un fichier a fini d'arriver. */
|
|
991
|
+
export interface ChatAttachment {
|
|
992
|
+
author: string;
|
|
993
|
+
filename: string;
|
|
994
|
+
size: number;
|
|
995
|
+
mimeType: string | null | undefined;
|
|
996
|
+
sentAt: number;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* LE FICHIER ARRIVE EN HTTP, LA NOUVELLE REPART EN WEBSOCKET.
|
|
1001
|
+
*
|
|
1002
|
+
* Cette route ne déclare QUE `POST` : un envoi de fichier n'a rien à faire sur
|
|
1003
|
+
* le pont socket. Le fichier est écrit au fil de l'eau par le sous-système
|
|
1004
|
+
* d'upload — il n'est jamais entièrement chargé en mémoire.
|
|
1005
|
+
*/
|
|
1006
|
+
@route("chat-upload", {
|
|
1007
|
+
path: "/upload",
|
|
1008
|
+
requirements: { methods: ["POST"] },
|
|
1009
|
+
})
|
|
1010
|
+
async upload(@UploadedFile() file?: IUploadedFile) {
|
|
1011
|
+
if (!file) {
|
|
1012
|
+
throw new HttpError("aucun fichier reçu", 400);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Même service, même contexte, même identité que la route WebSocket :
|
|
1016
|
+
// c'est la même classe.
|
|
1017
|
+
const saved = await this.chatService.saveAttachment(file);
|
|
1018
|
+
|
|
1019
|
+
const announcement: ChatAttachment = {
|
|
1020
|
+
author: saved.author,
|
|
1021
|
+
filename: file.filename,
|
|
1022
|
+
size: file.size,
|
|
1023
|
+
mimeType: file.mimeType,
|
|
1024
|
+
sentAt: Date.now(),
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
// ── LE POINT DE BASCULE ──
|
|
1028
|
+
// La requête HTTP répondra à SON auteur. Cette publication, elle, atteint
|
|
1029
|
+
// TOUTES les personnes abonnées au canal — y compris celles qui n'ont jamais
|
|
1030
|
+
// émis cette requête. Un transport entre, un autre sort.
|
|
1031
|
+
getRealtimeHub().publish(ROOM_CHANNEL, announcement);
|
|
1032
|
+
|
|
1033
|
+
// La réponse HTTP, pour l'expéditeur uniquement.
|
|
1034
|
+
return { ok: true, filename: file.filename, size: file.size };
|
|
1035
|
+
}
|
|
1036
|
+
```
|
|
1037
|
+
|
|
1038
|
+
### Côté client
|
|
1039
|
+
|
|
1040
|
+
L'envoi est un `POST` ordinaire. La mise à jour de l'écran, elle, ne vient **pas** de la réponse :
|
|
1041
|
+
elle vient du canal, auquel on est déjà abonné depuis l'étape 1.
|
|
1042
|
+
|
|
1043
|
+
```tsx ignore
|
|
1044
|
+
// frontend/src/ChatRoom.tsx (ajout de l'étape 6)
|
|
1045
|
+
const sendFile = async (file: File) => {
|
|
1046
|
+
const form = new FormData();
|
|
1047
|
+
form.append("file", file);
|
|
1048
|
+
|
|
1049
|
+
// Un POST classique. Aucune API temps réel ici.
|
|
1050
|
+
await fetch("/chat/upload", { method: "POST", body: form });
|
|
1051
|
+
|
|
1052
|
+
// Et… c'est tout. On ne touche PAS à `setMessages` : l'annonce arrivera par
|
|
1053
|
+
// `chat:room`, exactement comme pour les autres participants. L'expéditeur
|
|
1054
|
+
// est traité comme tout le monde — un seul chemin de mise à jour de l'écran,
|
|
1055
|
+
// donc un seul comportement à tester.
|
|
1056
|
+
};
|
|
1057
|
+
```
|
|
1058
|
+
|
|
1059
|
+
> [!NOTE]
|
|
1060
|
+
> **Ce que le framework ne fournit pas : la progression d'envoi côté serveur.** Il n'existe pas de
|
|
1061
|
+
> canal qui pousserait « 43 % transféré » pendant la montée du fichier. Ce qui existe, et qui est
|
|
1062
|
+
> montré ici, c'est l'**annonce de la pièce jointe terminée**, diffusée à tout le salon. Une barre de
|
|
1063
|
+
> progression reste possible côté navigateur, avec les API du navigateur, sans participation du
|
|
1064
|
+
> serveur. Cette page ne montre que ce qui existe.
|
|
1065
|
+
|
|
1066
|
+
### Ce qu'on observe — et ce qui casse
|
|
1067
|
+
|
|
1068
|
+
En développement, un seul processus : tout fonctionne. Tu déposes un fichier dans l'onglet gauche,
|
|
1069
|
+
la ligne « invité-412 a partagé rapport.pdf » apparaît dans l'onglet droit.
|
|
1070
|
+
|
|
1071
|
+
**Maintenant, passe à deux processus** — c'est là que ça devient intéressant :
|
|
1072
|
+
|
|
1073
|
+
```bash
|
|
1074
|
+
# Deux workers, chacun un processus Node, sans changer une ligne de code
|
|
1075
|
+
npx nodefony cluster -w 2
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
Ouvre deux onglets. Chaque connexion WebSocket atterrit sur **un worker au hasard**. Avec un peu de
|
|
1079
|
+
chance — ou en insistant — Alice est sur le worker A et Bob sur le worker B.
|
|
1080
|
+
|
|
1081
|
+
| Tu fais | Ce que tu observes |
|
|
1082
|
+
| ------------------------------ | ----------------------------------------------------------------------------------- |
|
|
1083
|
+
| Alice envoie un fichier | Sa propre requête `POST` réussit : `{ ok: true }`. Elle voit son annonce. |
|
|
1084
|
+
| Bob, connecté à l'autre worker | **Rien.** Aucune annonce. Aucune erreur. Aucun log. Le salon est cassé, en silence. |
|
|
1085
|
+
| Tu recharges la page de Bob | Le fichier est bien là, dans l'historique — la base, elle, est partagée. |
|
|
1086
|
+
|
|
1087
|
+
**C'est le symptôme le plus déroutant du temps réel distribué** : la donnée est correcte, la requête
|
|
1088
|
+
a réussi, et pourtant l'écran de l'autre ne bouge pas. La cause est simple une fois nommée : le
|
|
1089
|
+
`POST` a atterri sur le processus A, la socket de Bob est tenue par le processus B, et **aucun
|
|
1090
|
+
chemin ne relie les deux**. Le hub du processus A a fait exactement son travail — distribuer à
|
|
1091
|
+
**ses** abonnés. Bob n'en fait pas partie.
|
|
1092
|
+
|
|
1093
|
+
Le chat seul ne révélait pas ce problème : les deux onglets tombaient souvent sur le même worker, et
|
|
1094
|
+
quand ils tombaient sur des workers différents, on mettait l'absence de message sur le compte d'un
|
|
1095
|
+
bug d'affichage. La pièce jointe, elle, ne laisse aucun doute.
|
|
1096
|
+
|
|
1097
|
+
C'est ce que l'étape suivante répare.
|
|
1098
|
+
|
|
1099
|
+
## 🌐 Étape 7 : passer à plusieurs pods
|
|
1100
|
+
|
|
1101
|
+
### Deux choses, et deux seulement
|
|
1102
|
+
|
|
1103
|
+
**Un — déclarer quels canaux sont diffusables.** Par défaut, un canal reste **local au processus**.
|
|
1104
|
+
Ce n'est pas un oubli mais une protection : la plupart des canaux d'une application (état du
|
|
1105
|
+
processus, métriques d'instance, sondes) n'auraient aucun sens recopiés partout, et les diffuser
|
|
1106
|
+
aveuglément inonderait le réseau. Tu déclares donc, une fois, ce qui doit traverser :
|
|
1107
|
+
|
|
1108
|
+
```ts ignore
|
|
1109
|
+
// modules/chat/nodefony/controllers/ChatController.ts (l'unique ajout de l'étape 7)
|
|
1110
|
+
/**
|
|
1111
|
+
* Les canaux (par préfixe) qui doivent franchir les frontières de processus.
|
|
1112
|
+
* Tout ce qui commence par `chat:` traverse : messages, présence, frappe,
|
|
1113
|
+
* annonces de pièces jointes. Le reste — s'il y en a — demeure local.
|
|
1114
|
+
*/
|
|
1115
|
+
@RealtimeBroadcast("chat:") // sur la classe du contrôleur
|
|
1116
|
+
```
|
|
1117
|
+
|
|
1118
|
+
**Deux — choisir le driver.** Il n'y a pas de troisième chose à faire.
|
|
1119
|
+
|
|
1120
|
+
```ts ignore
|
|
1121
|
+
// nodefony.config.ts — le SEUL changement entre développement et production
|
|
1122
|
+
use("@nodefony/realtime", {
|
|
1123
|
+
backplane: {
|
|
1124
|
+
// "loopback" : un processus, aucun relais. → poste de développement
|
|
1125
|
+
// "cluster" : plusieurs workers d'une machine. → `nodefony cluster -w N`
|
|
1126
|
+
// "redis" : plusieurs machines. → Kubernetes, plusieurs pods
|
|
1127
|
+
driver: "redis",
|
|
1128
|
+
// Cloison logique : deux déploiements de la même application sur un Redis
|
|
1129
|
+
// mutualisé ne doivent pas se parler. À poser explicitement dès que le
|
|
1130
|
+
// Redis est partagé entre environnements.
|
|
1131
|
+
namespace: "mon-chat-prod",
|
|
1132
|
+
},
|
|
1133
|
+
});
|
|
1134
|
+
```
|
|
1135
|
+
|
|
1136
|
+
### Ce qui n'a pas bougé
|
|
1137
|
+
|
|
1138
|
+
C'est l'argument, et il se vérifie fichier par fichier :
|
|
1139
|
+
|
|
1140
|
+
| Fichier | Modifié pour passer à plusieurs pods ? |
|
|
1141
|
+
| --------------------------------- | ---------------------------------------------------------------------------------- |
|
|
1142
|
+
| `ChatController.ts` — méthodes | **Non.** `onSend`, `upload`, `historyAction`, les providers de canaux : inchangés. |
|
|
1143
|
+
| `ChatController.ts` — déclaration | Une méthode ajoutée, qui **énumère des noms de canaux**. Aucune logique métier. |
|
|
1144
|
+
| `ChatService.ts` | **Non.** |
|
|
1145
|
+
| `ChatRoom.tsx`, `realtime.ts` | **Non.** Le navigateur ne sait pas, et n'a pas à savoir, combien de pods existent. |
|
|
1146
|
+
| `nodefony.config.ts` | Une chaîne de caractères. |
|
|
1147
|
+
|
|
1148
|
+
Aucun appel de publication ne change, aucun abonnement ne change, aucune règle métier ne change. Ce
|
|
1149
|
+
qui change, c'est **par où** la publication repart après le fan-out local — et cela se décide dans la
|
|
1150
|
+
configuration, pas dans le code.
|
|
1151
|
+
|
|
1152
|
+
```mermaid
|
|
1153
|
+
flowchart LR
|
|
1154
|
+
subgraph PodA["Processus A"]
|
|
1155
|
+
UA["POST /chat/upload<br/>(Alice)"] --> HA[Hub A]
|
|
1156
|
+
end
|
|
1157
|
+
subgraph PodB["Processus B"]
|
|
1158
|
+
HB[Hub B] --> SB["Socket de Bob"]
|
|
1159
|
+
end
|
|
1160
|
+
HA -->|"publish"| BP{{"Backplane<br/>driver = redis"}}
|
|
1161
|
+
BP -->|"entrée du bus"| HB
|
|
1162
|
+
HA -.->|"sans backplane :<br/>le message meurt ici"| X["✖"]
|
|
1163
|
+
```
|
|
1164
|
+
|
|
1165
|
+
### Ce qu'on observe
|
|
1166
|
+
|
|
1167
|
+
Reprends exactement la manipulation de l'étape 6, driver changé :
|
|
1168
|
+
|
|
1169
|
+
```bash
|
|
1170
|
+
npx nodefony cluster -w 2
|
|
1171
|
+
```
|
|
1172
|
+
|
|
1173
|
+
Au démarrage de chaque worker, la ligne d'identité du temps réel change de forme. Là où on lisait
|
|
1174
|
+
`kind=local cross-pod=no`, on lit maintenant un `kind` de transport et `cross-pod=yes` : c'est la
|
|
1175
|
+
confirmation, au boot, que le relais est en place.
|
|
1176
|
+
|
|
1177
|
+
| Tu fais | Avant (étape 6) | Maintenant |
|
|
1178
|
+
| --------------------------- | ---------------- | ------------------------------------------------------ |
|
|
1179
|
+
| Alice envoie un fichier | Bob ne voit rien | L'annonce apparaît chez Bob, sur l'autre processus |
|
|
1180
|
+
| Alice tape un message | Aléatoire | Reçu par tous, quel que soit le worker |
|
|
1181
|
+
| Tu arrêtes le service Redis | — | Le salon continue **dans chaque processus**, isolément |
|
|
1182
|
+
|
|
1183
|
+
Cette dernière ligne mérite une explication : si le backplane est injoignable au démarrage, le
|
|
1184
|
+
framework **ne bloque pas le boot**. Il journalise un avertissement, garde un hub local et sert le
|
|
1185
|
+
trafic. Tu perds la diffusion entre pods, tu ne perds pas l'application. Une dégradation, jamais
|
|
1186
|
+
silencieuse.
|
|
1187
|
+
|
|
1188
|
+
Le détail des drivers, de leurs options et de leur dimensionnement est dans
|
|
1189
|
+
[configuration.md](./configuration.md) ; le module qui fournit les connexions est
|
|
1190
|
+
[`@nodefony/redis`](../../redis/docs/index.md).
|
|
1191
|
+
|
|
1192
|
+
## 📄 Le contrôleur complet
|
|
1193
|
+
|
|
1194
|
+
Les étapes ont montré les ajouts un par un. Voici le fichier **entier**, tel qu'il est à l'arrivée —
|
|
1195
|
+
rien à reconstituer, rien à deviner. C'est ce que tu copies si tu veux le résultat sans dérouler.
|
|
1196
|
+
|
|
1197
|
+
La seule chose qui n'y figure pas est la persistance : `ChatController.history` est un stock en
|
|
1198
|
+
mémoire, que l'[étape 4](#-étape-4--persister-lhistorique) remplace par un appel à ton service.
|
|
1199
|
+
C'est délibéré — ce fichier doit compiler sans supposer ta base.
|
|
1200
|
+
|
|
1201
|
+
```ts ignore
|
|
1202
|
+
// modules/chat/nodefony/controllers/ChatController.ts — version finale
|
|
1203
|
+
import {
|
|
1204
|
+
controller,
|
|
1205
|
+
route,
|
|
1206
|
+
Body,
|
|
1207
|
+
Query,
|
|
1208
|
+
CurrentUser,
|
|
1209
|
+
IsGranted,
|
|
1210
|
+
UploadedFile,
|
|
1211
|
+
} from "@nodefony/framework";
|
|
1212
|
+
import {
|
|
1213
|
+
RealtimeController,
|
|
1214
|
+
RealtimeChannel,
|
|
1215
|
+
RealtimeInbound,
|
|
1216
|
+
getRealtimeHub,
|
|
1217
|
+
} from "@nodefony/realtime";
|
|
1218
|
+
import type { RealtimePublish } from "@nodefony/realtime";
|
|
1219
|
+
import { HttpError } from "@nodefony/http";
|
|
1220
|
+
import type { ContextType, IUploadedFile } from "@nodefony/http";
|
|
1221
|
+
|
|
1222
|
+
/** Un message tel qu'il circule sur le canal du salon. */
|
|
1223
|
+
export interface ChatMessage {
|
|
1224
|
+
author: string;
|
|
1225
|
+
text: string;
|
|
1226
|
+
sentAt: number;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/** Ce qu'on annonce au salon quand un fichier a fini d'arriver. */
|
|
1230
|
+
export interface ChatAttachment {
|
|
1231
|
+
author: string;
|
|
1232
|
+
filename: string;
|
|
1233
|
+
size: number;
|
|
1234
|
+
mimeType: string | null | undefined;
|
|
1235
|
+
sentAt: number;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** Le nom du canal du salon. Cité côté serveur ET côté client → une constante. */
|
|
1239
|
+
export const ROOM_CHANNEL = "chat:room";
|
|
1240
|
+
|
|
1241
|
+
@controller("/chat")
|
|
1242
|
+
class ChatController extends RealtimeController {
|
|
1243
|
+
/** Stock en mémoire — remplacé par un repository à l'étape 4. */
|
|
1244
|
+
static history: ChatMessage[] = [];
|
|
1245
|
+
|
|
1246
|
+
/** Les pseudos présents dans le salon, sur CE processus. */
|
|
1247
|
+
static present = new Set<string>();
|
|
1248
|
+
|
|
1249
|
+
constructor(context: ContextType) {
|
|
1250
|
+
super("chat", context);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/** Ouvre le pont : les actions ci-dessous deviennent joignables par la socket. */
|
|
1254
|
+
protected override realtimeApiRequest(): boolean {
|
|
1255
|
+
return true;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** Ce qui doit franchir les frontières de processus (étape 7). */
|
|
1259
|
+
// (préfixe diffusable déclaré par `@RealtimeBroadcast` sur la classe)
|
|
1260
|
+
|
|
1261
|
+
// ── La porte WebSocket ────────────────────────────────────────────────────
|
|
1262
|
+
|
|
1263
|
+
@route("chat-realtime", {
|
|
1264
|
+
path: "/realtime",
|
|
1265
|
+
requirements: { methods: ["WEBSOCKET"] },
|
|
1266
|
+
})
|
|
1267
|
+
async realtime(message: string | Buffer | null): Promise<void> {
|
|
1268
|
+
this.handleRealtime(message);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// ── Les actions : HTTP et WebSocket, même code ────────────────────────────
|
|
1272
|
+
|
|
1273
|
+
/** Historique — `GET /chat/history` ou `socket.request("/chat/history")`. */
|
|
1274
|
+
@route("chat-history", {
|
|
1275
|
+
path: "/history",
|
|
1276
|
+
requirements: { methods: ["GET", "WEBSOCKET"] },
|
|
1277
|
+
})
|
|
1278
|
+
historyAction(@Query("limit") limit?: string): ChatMessage[] {
|
|
1279
|
+
const max = Math.min(Number(limit) || 50, 200);
|
|
1280
|
+
return ChatController.history.slice(-max);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/** Envoi — l'auteur vient du serveur, jamais du client. */
|
|
1284
|
+
@route("chat-send", {
|
|
1285
|
+
path: "/send",
|
|
1286
|
+
requirements: { methods: ["POST", "WEBSOCKET"] },
|
|
1287
|
+
})
|
|
1288
|
+
@IsGranted(["ROLE_USER"])
|
|
1289
|
+
async send(
|
|
1290
|
+
@Body() payload: { text?: string },
|
|
1291
|
+
@CurrentUser() user?: { identifier?: string },
|
|
1292
|
+
): Promise<{ ok: true; sentAt: number }> {
|
|
1293
|
+
const text = typeof payload?.text === "string" ? payload.text.trim() : "";
|
|
1294
|
+
if (text.length === 0 || text.length > 2000) {
|
|
1295
|
+
throw new HttpError("message vide ou trop long", 400);
|
|
1296
|
+
}
|
|
1297
|
+
const message: ChatMessage = {
|
|
1298
|
+
author: user?.identifier ?? "inconnu",
|
|
1299
|
+
text,
|
|
1300
|
+
sentAt: Date.now(),
|
|
1301
|
+
};
|
|
1302
|
+
ChatController.history.push(message);
|
|
1303
|
+
getRealtimeHub().publish(ROOM_CHANNEL, message);
|
|
1304
|
+
return { ok: true, sentAt: message.sentAt };
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/** Pièce jointe — entre en POST multipart, ressort par le canal. */
|
|
1308
|
+
@route("chat-upload", {
|
|
1309
|
+
path: "/upload",
|
|
1310
|
+
requirements: { methods: ["POST"] },
|
|
1311
|
+
})
|
|
1312
|
+
@IsGranted(["ROLE_USER"])
|
|
1313
|
+
async upload(
|
|
1314
|
+
@UploadedFile() file?: IUploadedFile,
|
|
1315
|
+
@CurrentUser() user?: { identifier?: string },
|
|
1316
|
+
): Promise<{ ok: true; filename: string; size: number }> {
|
|
1317
|
+
if (!file) {
|
|
1318
|
+
throw new HttpError("aucun fichier reçu", 400);
|
|
1319
|
+
}
|
|
1320
|
+
const announcement: ChatAttachment = {
|
|
1321
|
+
author: user?.identifier ?? "inconnu",
|
|
1322
|
+
filename: file.filename,
|
|
1323
|
+
size: file.size,
|
|
1324
|
+
mimeType: file.mimeType,
|
|
1325
|
+
sentAt: Date.now(),
|
|
1326
|
+
};
|
|
1327
|
+
// La requête HTTP répondra à SON auteur ; cette publication atteint tout le
|
|
1328
|
+
// salon. Un transport entre, un autre sort — dans la même méthode.
|
|
1329
|
+
getRealtimeHub().publish(ROOM_CHANNEL, announcement);
|
|
1330
|
+
return { ok: true, filename: file.filename, size: file.size };
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// ── Les canaux ────────────────────────────────────────────────────────────
|
|
1334
|
+
|
|
1335
|
+
/** Le salon. Rien à démarrer : il relaie, il n'émet pas de lui-même. */
|
|
1336
|
+
@RealtimeChannel(ROOM_CHANNEL, { authenticated: true, roles: ["ROLE_USER"] })
|
|
1337
|
+
room(_channel: string, _publish: RealtimePublish): () => void {
|
|
1338
|
+
return () => {};
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/** Présence — un rappel régulier, arrêté au dernier désabonné. */
|
|
1342
|
+
@RealtimeChannel("chat:presence", {
|
|
1343
|
+
authenticated: true,
|
|
1344
|
+
roles: ["ROLE_USER"],
|
|
1345
|
+
})
|
|
1346
|
+
presence(channel: string, publish: RealtimePublish): () => void {
|
|
1347
|
+
const timer = setInterval(() => {
|
|
1348
|
+
publish(channel, { present: [...ChatController.present] });
|
|
1349
|
+
}, 5000);
|
|
1350
|
+
timer.unref();
|
|
1351
|
+
return () => clearInterval(timer);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/** Frappe en cours — volatile : ni attribué, ni conservé. */
|
|
1355
|
+
@RealtimeChannel("chat:typing")
|
|
1356
|
+
typing(_channel: string, _publish: RealtimePublish): () => void {
|
|
1357
|
+
return () => {};
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/** Le seul canal entrant restant : haute fréquence, sans enjeu d'identité. */
|
|
1361
|
+
@RealtimeInbound("chat:typing")
|
|
1362
|
+
onTyping(params: unknown): void {
|
|
1363
|
+
const author = (params as { author?: unknown } | null)?.author;
|
|
1364
|
+
if (typeof author !== "string" || author.length === 0) return;
|
|
1365
|
+
getRealtimeHub().publish("chat:typing", { author: author.slice(0, 40) });
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
export default ChatController;
|
|
1370
|
+
```
|
|
1371
|
+
|
|
1372
|
+
## 💥 Ce qui casse pour de vrai
|
|
1373
|
+
|
|
1374
|
+
Un cookbook qui ne montre que le chemin heureux ne sert à rien en production. Voici les cinq
|
|
1375
|
+
situations que **tout** salon rencontre, avec pour chacune ce que le framework prend en charge et ce
|
|
1376
|
+
qui reste à ta charge.
|
|
1377
|
+
|
|
1378
|
+
### La reconnexion
|
|
1379
|
+
|
|
1380
|
+
**Symptôme** — le portable passe en veille, le train entre dans un tunnel, le navigateur bascule sur
|
|
1381
|
+
un autre réseau. La socket meurt.
|
|
1382
|
+
|
|
1383
|
+
**Ce que le framework fait déjà** : le client détecte la coupure, passe en `reconnecting`, réessaie
|
|
1384
|
+
avec un délai croissant, et **renvoie tous les abonnements** une fois la connexion rétablie. Les
|
|
1385
|
+
hooks React ne se démontent pas ; tes composants ne voient rien passer. Côté serveur, la fermeture
|
|
1386
|
+
libère les abonnements et détruit les providers devenus inutiles.
|
|
1387
|
+
|
|
1388
|
+
**Ce qui reste à ta charge** : **le trou**. Les messages publiés pendant la coupure ne sont pas
|
|
1389
|
+
rejoués — le hub distribue à qui est là, il ne conserve rien. À la reconnexion, redemande
|
|
1390
|
+
l'historique depuis le dernier message connu :
|
|
1391
|
+
|
|
1392
|
+
```tsx ignore
|
|
1393
|
+
// Au retour de `reconnecting` vers `connected`, on comble le trou.
|
|
1394
|
+
useEffect(() => {
|
|
1395
|
+
if (state !== "connected") return;
|
|
1396
|
+
const lastSeen = messages.at(-1)?.sentAt ?? 0;
|
|
1397
|
+
void socket
|
|
1398
|
+
.request<ChatMessage[]>(`/chat/history?since=${lastSeen}`)
|
|
1399
|
+
.then((missed) => setMessages((previous) => [...previous, ...missed]));
|
|
1400
|
+
}, [state]);
|
|
1401
|
+
```
|
|
1402
|
+
|
|
1403
|
+
### Les messages en double
|
|
1404
|
+
|
|
1405
|
+
**Symptôme** — le même message apparaît deux fois.
|
|
1406
|
+
|
|
1407
|
+
**Les deux causes, à ne pas confondre** :
|
|
1408
|
+
|
|
1409
|
+
1. **Le rattrapage recouvre le direct.** Tu redemandes l'historique après une reconnexion, et
|
|
1410
|
+
certains messages sont arrivés par les deux chemins. C'est le cas courant.
|
|
1411
|
+
2. **L'expéditeur s'affiche deux fois** : une fois localement à l'envoi (« affichage optimiste »),
|
|
1412
|
+
une fois quand son propre message revient par le canal.
|
|
1413
|
+
|
|
1414
|
+
**Ce que le framework fait déjà** : il ne duplique pas de son côté. Un message reçu du backplane est
|
|
1415
|
+
réinjecté **localement** sans repartir vers les autres processus — la barrière anti-boucle est dans
|
|
1416
|
+
le hub, tu n'as pas à t'en soucier.
|
|
1417
|
+
|
|
1418
|
+
**Ce qui reste à ta charge** : dédoublonner à l'affichage sur l'identifiant persistant du message
|
|
1419
|
+
(celui que l'étape 4 fait remonter de la base). Et choisir : soit l'affichage optimiste **sans**
|
|
1420
|
+
réafficher le retour, soit pas d'affichage optimiste — comme à l'étape 6, où l'expéditeur attend son
|
|
1421
|
+
propre message par le canal, ce qui garantit un seul chemin de mise à jour.
|
|
1422
|
+
|
|
1423
|
+
### L'ordre
|
|
1424
|
+
|
|
1425
|
+
**Symptôme** — deux messages s'affichent dans le désordre.
|
|
1426
|
+
|
|
1427
|
+
**Ce que le framework garantit** : sur **une** connexion, l'ordre des frames est celui de la
|
|
1428
|
+
WebSocket, donc préservé. Ce qui n'est **pas** garanti, c'est l'ordre entre des messages publiés
|
|
1429
|
+
depuis des **processus différents** : deux pods n'ont pas d'horloge commune, et rien ne dit lequel
|
|
1430
|
+
atteindra le backplane en premier.
|
|
1431
|
+
|
|
1432
|
+
**Ce qui reste à ta charge** : ne pas trier sur un horodatage produit par le client (il ment, et les
|
|
1433
|
+
horloges dérivent). Trier sur l'identifiant croissant attribué par la base au moment de
|
|
1434
|
+
l'enregistrement — c'est le seul point de l'application qui voit tous les messages.
|
|
1435
|
+
|
|
1436
|
+
### Le consommateur lent
|
|
1437
|
+
|
|
1438
|
+
**Symptôme** — un onglet arrière-plan, un réseau saturé, un client qui ne lit plus assez vite. Les
|
|
1439
|
+
octets s'accumulent en file d'envoi côté serveur. Sans garde, cette file grandit jusqu'à faire tomber
|
|
1440
|
+
le processus — pour **tous** les utilisateurs, à cause d'un seul.
|
|
1441
|
+
|
|
1442
|
+
**Ce que le framework fait déjà** : la file d'envoi de chaque connexion est surveillée. Au-delà d'un
|
|
1443
|
+
premier seuil, les messages destinés à ce client sont abandonnés plutôt qu'empilés. Au-delà d'un
|
|
1444
|
+
second, irrécupérable, la connexion est **fermée** avec le code `1013` (« réessaie plus tard ») — le
|
|
1445
|
+
client se reconnecte alors proprement et resynchronise. Le nombre de consommateurs lents et les
|
|
1446
|
+
abandons sont exposés par la sonde.
|
|
1447
|
+
|
|
1448
|
+
**Ce qui reste à ta charge** : ne pas confondre un canal d'**état** et un canal d'**événements**. Un
|
|
1449
|
+
état (« qui est présent ») n'a besoin que de sa dernière valeur : s'il est en retard, on saute les
|
|
1450
|
+
valeurs intermédiaires sans rien perdre. Un événement (« Alice a écrit ceci ») ne se saute pas.
|
|
1451
|
+
Pour les canaux d'état, les hooks à cadence adaptative ralentissent automatiquement le flux vers un
|
|
1452
|
+
client en difficulté, puis le réaccélèrent quand il va mieux.
|
|
1453
|
+
|
|
1454
|
+
### Le salon saturé
|
|
1455
|
+
|
|
1456
|
+
**Symptôme** — un client s'abonne à des centaines de canaux, ou un salon très actif inonde tout le
|
|
1457
|
+
monde.
|
|
1458
|
+
|
|
1459
|
+
**Ce que le framework fait déjà** : le nombre de canaux qu'**une** connexion peut ouvrir est plafonné
|
|
1460
|
+
(256 par défaut). Au-delà, l'abonnement est refusé et le client reçoit un `realtime:denied` avec le
|
|
1461
|
+
motif `limit` — un refus visible, pas un silence. C'est une garde contre l'épuisement mémoire, pas
|
|
1462
|
+
une bride : en dessous du seuil, le multiplexage reste libre.
|
|
1463
|
+
|
|
1464
|
+
**Ce qui reste à ta charge** : limiter le **débit d'écriture**. Rien n'empêche un client d'appeler
|
|
1465
|
+
`chat:send` mille fois par seconde. Un compteur par connexion dans ton handler inbound, et un refus
|
|
1466
|
+
au-delà d'un seuil, suffisent — c'est de la logique métier, le framework ne peut pas deviner ce qui
|
|
1467
|
+
est raisonnable pour ton salon.
|
|
1468
|
+
|
|
1469
|
+
## 📡 Observer
|
|
1470
|
+
|
|
1471
|
+
### La sonde de santé
|
|
1472
|
+
|
|
1473
|
+
Le module expose un point de mesure d'administration, monté automatiquement dès que le module est
|
|
1474
|
+
chargé :
|
|
1475
|
+
|
|
1476
|
+
```bash
|
|
1477
|
+
curl -k https://127.0.0.1:5152/nodefony/realtime/api/health
|
|
1478
|
+
```
|
|
1479
|
+
|
|
1480
|
+
**Aucune valeur n'est reproduite ici** : elles dépendent entièrement de ton trafic au moment de
|
|
1481
|
+
l'appel. Voici en revanche ce que chaque champ signifie, ce qui est exactement l'information utile
|
|
1482
|
+
pour lire ta propre sortie :
|
|
1483
|
+
|
|
1484
|
+
<!-- prettier-ignore -->
|
|
1485
|
+
| Champ | Ce qu'il dit |
|
|
1486
|
+
| --- | --- |
|
|
1487
|
+
| `ts` | Horodatage de la mesure. |
|
|
1488
|
+
| `channels[]` | Un objet par canal actif : son nom, son nombre d'abonnés, son nombre de messages. |
|
|
1489
|
+
| `channelCount` | Combien de canaux ont au moins un abonné **sur ce processus**. |
|
|
1490
|
+
| `publishTotal` | Nombre de publications depuis le démarrage (cumul, jamais remis à zéro). |
|
|
1491
|
+
| `fanoutTotal` | Nombre de **livraisons**. Rapporté à `publishTotal`, il donne la taille moyenne d'un salon. |
|
|
1492
|
+
| `inboundTotal` | Nombre de frames poussées par les clients (canaux inbound). |
|
|
1493
|
+
| `connectionCount` | Connexions WebSocket vivantes sur ce processus. |
|
|
1494
|
+
| `bytesSentTotal` | Octets émis, cumulés. |
|
|
1495
|
+
| `messagesSentTotal` | Messages émis, cumulés. |
|
|
1496
|
+
| `backpressure.maxBufferedAmount` | La **pire** file d'envoi observée à l'instant de la mesure. C'est l'indicateur d'alerte. |
|
|
1497
|
+
| `backpressure.slowConsumers` | Combien de connexions dépassent le seuil de consommateur lent. |
|
|
1498
|
+
| `backpressure.drops` | Messages abandonnés faute de pouvoir être livrés. |
|
|
1499
|
+
| `backplane` | Présent uniquement si un relais est branché : son driver, son type, s'il traverse les processus. |
|
|
1500
|
+
|
|
1501
|
+
Deux lectures suffisent à diagnostiquer :
|
|
1502
|
+
|
|
1503
|
+
- **`fanoutTotal` qui stagne alors que `publishTotal` grimpe** → tu publies dans le vide : personne
|
|
1504
|
+
n'est abonné au canal visé. Presque toujours une faute de frappe entre le nom côté serveur et le
|
|
1505
|
+
nom côté client. C'est le piège n°1 de cette page.
|
|
1506
|
+
- **`backpressure.slowConsumers` non nul** → au moins un client ne suit pas. S'il grimpe, tes canaux
|
|
1507
|
+
d'état publient trop vite.
|
|
1508
|
+
|
|
1509
|
+
En cluster, la réponse peut être une vue **agrégée du pod** plutôt que celle de l'instance
|
|
1510
|
+
interrogée : la sonde de chaque worker remonte au maître, qui les fusionne. Un champ `cluster` et une
|
|
1511
|
+
liste `instances` distinguent alors les deux formes.
|
|
1512
|
+
|
|
1513
|
+
### L'écran Studio
|
|
1514
|
+
|
|
1515
|
+
En développement, la console d'administration expose le module et sa documentation à
|
|
1516
|
+
`https://127.0.0.1:5152/nodefony`. La page du module `realtime` réunit ses documents, ses routes, ses
|
|
1517
|
+
symboles et sa configuration effective — utile pour vérifier d'un coup d'œil que ta route WebSocket
|
|
1518
|
+
est bien montée, et quel driver de backplane a réellement été retenu au démarrage.
|
|
1519
|
+
|
|
1520
|
+
## ⚠️ Pièges
|
|
1521
|
+
|
|
1522
|
+
<!-- prettier-ignore -->
|
|
1523
|
+
| Symptôme | Cause | Correction |
|
|
1524
|
+
| --- | --- | --- |
|
|
1525
|
+
| Le client s'abonne, **rien n'arrive jamais**, aucune erreur | Le canal est **inconnu du serveur** : aucun `@RealtimeChannel` ne le déclare, ou le provider renvoie `null`. L'abonnement est refusé sans bruit. | Vérifier que le nom du canal est identique des deux côtés (une constante partagée évite la faute de frappe) et que le provider renvoie une fonction de nettoyage. |
|
|
1526
|
+
| Ça marche à deux onglets, ça casse en cluster | Le canal n'est pas déclaré diffusable : par défaut, il reste **local au processus**. | Déclarer le préfixe avec `@RealtimeBroadcast` **et** choisir un driver de backplane. |
|
|
1527
|
+
| Le client pousse sur un canal, le serveur ignore tout | Un canal n'accepte une entrée que s'il est **explicitement** déclaré `@RealtimeInbound`. C'est un défaut sûr. | Déclarer la méthode inbound. Vérifier que le nom poussé correspond exactement. |
|
|
1528
|
+
| Fermeture immédiate, code `4001` | La zone firewall exige une identité que la poignée de main n'a pas fournie (pas de cookie de session). | Se connecter avant d'ouvrir la socket, ou revoir les authentificateurs de la zone. |
|
|
1529
|
+
| Fermeture immédiate, code `4003` | L'origine du navigateur n'est pas autorisée pour l'établissement de la connexion. | Déclarer l'origine dans la configuration du module (voir [configuration.md](./configuration.md)). |
|
|
1530
|
+
| Une politique de canal ne bloque personne | Aucun décideur n'est branché : le module de sécurité est absent, ou la zone exclut le temps réel. | Charger `@nodefony/security` avec une zone qui couvre la route. L'avertissement au démarrage le signale explicitement. |
|
|
1531
|
+
| Deux applications distinctes reçoivent les messages l'une de l'autre | Redis partagé **sans** cloison logique : le numéro de base ne sépare pas le pub/sub. | Poser `backplane.namespace` explicitement dans chaque déploiement. |
|
|
1532
|
+
| Le minuteur d'un canal tourne encore après le départ du dernier abonné | Le provider ne renvoie pas de fonction de nettoyage, ou elle n'arrête pas ce qu'il a démarré. | Toujours renvoyer une fonction qui annule **tout** ce que le provider a créé. |
|
|
1533
|
+
| Le fichier monte, l'annonce n'arrive qu'à l'expéditeur | La publication a bien eu lieu, mais sur un processus dont les autres ne dépendent pas. | C'est exactement l'[étape 7](#-étape-7--passer-à-plusieurs-pods). |
|
|
1534
|
+
|
|
1535
|
+
## 🧪 Tests
|
|
1536
|
+
|
|
1537
|
+
Le module est couvert par une suite exécutable localement, **sans infrastructure externe** pour la
|
|
1538
|
+
majeure partie :
|
|
1539
|
+
|
|
1540
|
+
```bash
|
|
1541
|
+
cd src/packages/@nodefony/realtime && npm test
|
|
1542
|
+
```
|
|
1543
|
+
|
|
1544
|
+
Ce qui est couvert, par nature de test :
|
|
1545
|
+
|
|
1546
|
+
- **Unitaires** — le hub (abonnement, fan-out, cycle de vie des providers), les décorateurs et leurs
|
|
1547
|
+
métadonnées, le registre de drivers de backplane, le contrôleur, le service, le transport
|
|
1548
|
+
WebSocket, la validation de configuration, la dérivation de l'identifiant d'origine.
|
|
1549
|
+
- **Tests d'attaque** (`*.attack.test.ts`) — trois scénarios verrouillés en non-régression : le
|
|
1550
|
+
plafond de canaux par connexion, la révocation d'une session en cours de connexion, et le cas
|
|
1551
|
+
d'une politique de canal déclarée **sans** gardien branché (la dégradation doit rester bruyante).
|
|
1552
|
+
- **Intégration de bout en bout** (`*.e2e.test.ts`) — le cheminement complet sur boucle locale, les
|
|
1553
|
+
chemins de contrôleur, l'autorisation de canal, le branchement du firewall, et le cluster IPC avec
|
|
1554
|
+
de vrais processus enfants.
|
|
1555
|
+
- **Cluster Redis** — exercé de bout en bout, mais **conditionné à la présence d'un Redis**. Sans
|
|
1556
|
+
lui, ces cas sont ignorés, et un test ignoré compte comme réussi : ne conclus pas d'un « tout est
|
|
1557
|
+
vert » que le chemin Redis a été vérifié chez toi.
|
|
1558
|
+
|
|
1559
|
+
Le décompte exact des cas et des fichiers est régénéré depuis le dépôt — il n'est pas recopié ici,
|
|
1560
|
+
un chiffre figé dans une page devenant faux à la première suite ajoutée. Couverture :
|
|
1561
|
+
`npm run coverage` dans le module.
|
|
1562
|
+
|
|
1563
|
+
## 🧭 Ce n'est pas qu'un chat
|
|
1564
|
+
|
|
1565
|
+
Le salon que tu viens de construire est un **patron**, pas une fonctionnalité. Sa forme — un canal
|
|
1566
|
+
par préoccupation, une entrée validée, une publication après enregistrement, une politique par canal
|
|
1567
|
+
— se transpose telle quelle :
|
|
1568
|
+
|
|
1569
|
+
| Ta prochaine fonctionnalité | Ce qui change par rapport au chat |
|
|
1570
|
+
| ------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
1571
|
+
| **Notifications** | Un canal par utilisateur au lieu d'un canal par salon. Le reste est identique. |
|
|
1572
|
+
| **Tableau de bord qui se met à jour** | Canal d'**état** (seule la dernière valeur compte) → cadence adaptative, et le lent ralentit seul. |
|
|
1573
|
+
| **Présence / « qui regarde quoi »** | Exactement l'étape 3, avec un autre nom de canal. |
|
|
1574
|
+
| **Suivi de tâche longue** | Exactement l'étape 6 : on déclenche en `POST`, l'avancement ressort par le canal. |
|
|
1575
|
+
| **Édition collaborative** | Canal duplex (étape 3) + ordre garanti par la base (section « ordre »). |
|
|
1576
|
+
|
|
1577
|
+
Dans les cinq cas, le passage à plusieurs répliques reste ce qu'il est ici : un préfixe déclaré
|
|
1578
|
+
diffusable, un driver dans la configuration.
|
|
1579
|
+
|
|
1580
|
+
## 🔗 Pour aller plus loin
|
|
1581
|
+
|
|
1582
|
+
- ⬆️ **Retour au hub** : [@nodefony/realtime — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
|
|
1583
|
+
- [vocabulaire.md](./vocabulaire.md) — socket, hub, canal, frame, backplane : les mots employés ici, définis pour de bon.
|
|
1584
|
+
- [architecture.md](./architecture.md) — le trajet complet d'une frame, du câble jusqu'à ta méthode.
|
|
1585
|
+
- [configuration.md](./configuration.md) — toutes les options du module : drivers, cloisonnement, bornes, origines autorisées.
|
|
1586
|
+
- [securite.md](./securite.md) — authentification de la poignée de main, politiques de canal, révocation, audit.
|
|
1587
|
+
- [`@nodefony/orm-core`](../../orm-core/docs/index.md) — persister l'historique (étape 4).
|
|
1588
|
+
- [`@nodefony/security`](../../security/docs/index.md) et [le firewall](../../security/docs/firewall.md) — zones, authentificateurs, hiérarchie de rôles (étape 5).
|
|
1589
|
+
- [`@nodefony/redis`](../../redis/docs/index.md) — les connexions pub/sub du backplane multi-machines (étape 7).
|
|
1590
|
+
- [`@nodefony/http`](../../http/docs/index.md) — le sous-système d'upload utilisé à l'étape 6.
|
|
1591
|
+
- [`@nodefony/framework`](../../framework/docs/index.md) — contrôleurs, routes et décorateurs de paramètres.
|
|
1592
|
+
|
|
1593
|
+
### Les points d'ancrage dans le code
|
|
1594
|
+
|
|
1595
|
+
Pour qui veut lire l'implémentation derrière chaque étape :
|
|
1596
|
+
|
|
1597
|
+
<!-- prettier-ignore -->
|
|
1598
|
+
| Ce que tu as utilisé | Où c'est écrit |
|
|
1599
|
+
| --- | --- |
|
|
1600
|
+
| Les trois décorateurs | `RealtimeAction()` (`realtimeDecorators.ts:101`), `RealtimeChannel()` (`realtimeDecorators.ts:142`), `RealtimeInbound()` (`realtimeDecorators.ts:182`) |
|
|
1601
|
+
| La porte WebSocket | `RealtimeController.handleRealtime()` (`RealtimeController.ts:233`) |
|
|
1602
|
+
| Le pont HTTP ⇄ WebSocket | `RealtimeController.realtimeApiRequest()` (`RealtimeController.ts:219`) |
|
|
1603
|
+
| L'abonnement et son cycle de vie | `RealtimeHub.subscribe()` (`RealtimeHub.ts:388`), `RealtimeController.startChannel()` (`RealtimeController.ts:618`) |
|
|
1604
|
+
| La publication et le fan-out | `RealtimeHub.publish()` (`RealtimeHub.ts:530`), réinjection locale `RealtimeHub.publishLocal()` (`RealtimeHub.ts:549`) |
|
|
1605
|
+
| Les canaux diffusables | `RealtimeBroadcast` (`realtimeDecorators.ts:342`), `RealtimeHub.markBroadcastChannel()` (`RealtimeHub.ts:594`) |
|
|
1606
|
+
| Le branchement du backplane | `RealtimeHub.setBackplane()` (`RealtimeHub.ts:640`), registre `registerBackplaneDriver()` (`backplaneRegistry.ts:55`), cloison `resolveBackplaneOriginId()` (`originId.ts:24`) |
|
|
1607
|
+
| Les politiques de canal | `IChannelPolicy` (`IChannelPolicy.ts:20`), `RealtimeHub.registerChannelPolicy()` (`RealtimeHub.ts:1037`), garde-fou `RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:1037`) |
|
|
1608
|
+
| Le branchement automatique du firewall | `Firewall.#wireRealtime()` (`firewall.ts:253`) |
|
|
1609
|
+
| Les refus | `IRealtimeDenied` (`RealtimeEventMap.ts:228`), erreur `unauthorized` (`JsonRpcPeer.ts:400`), fermetures `origin not allowed` (`RealtimeController.ts:327`) et `unauthorized` (`RealtimeController.ts:349`) |
|
|
1610
|
+
| La révocation en cours de session | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts:736`) |
|
|
1611
|
+
| Le consommateur lent | seuil `SLOW_CONSUMER_BYTES` (`RealtimeHub.ts:56`), fermeture `slow consumer` (`RealtimeHub.ts:63`) |
|
|
1612
|
+
| Le plafond de canaux | `RealtimeHub.maxChannelsPerConnection` (`RealtimeHub.ts:910`) |
|
|
1613
|
+
| L'accueil et l'identité | `IRealtimeWelcome` (`RealtimeEventMap.ts:204`) |
|
|
1614
|
+
| La sonde | `RealtimeHub.probe()` (`RealtimeHub.ts:775`), `IRealtimeProbe` (`IRealtimeProbe.ts:61`), `IRealtimeChannelStat` (`IRealtimeProbe.ts:47`) |
|
|
1615
|
+
| Le point de mesure d'administration | `createRealtimeAdminApi()` (`RealtimeAdminApi.ts:91`), `buildRealtimeHealth()` (`RealtimeAdminApi.ts:74`), `buildOwnHealth()` (`RealtimeAdminApi.ts:52`) |
|
|
1616
|
+
| Le service injectable | `RealtimeService.publish()` (`RealtimeService.ts:161`), `RealtimeService.probe()` (`RealtimeService.ts:200`), `RealtimeService.markBroadcastChannel()` (`RealtimeService.ts:222`) |
|
|
1617
|
+
| Les hooks React | `useNodefony()` (`client/react/index.ts:67`), `useNodefonyState()` (`client/react/index.ts:87`), `useNodefonyIdentity()` (`client/react/index.ts:104`), `useNodefonyChannel()` (`client/react/index.ts:198`), `useNodefonyChannelData()` (`client/react/index.ts:148`), `useNodefonyAdaptiveChannel()` (`client/react/index.ts:182`) |
|
|
1618
|
+
| Le client navigateur | `RealtimeClient.connect()` (`RealtimeClient.ts:311`), `RealtimeClient.subscribe()` (`RealtimeClient.ts:530`), `RealtimeClient.request()` (`RealtimeClient.ts:594`), `RealtimeClient.onDenied()` (`RealtimeClient.ts:386`) |
|
|
1619
|
+
| L'upload | `UploadedFile` (`routerDecorators.ts:1239`), `IUploadedFile` (`IUpload.ts:49`), `queryFile` (`Request.ts:185`) |
|