@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.
Files changed (81) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +207 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +218 -0
  6. package/dist/nodefony/config/config.js +63 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +60 -0
  8. package/dist/nodefony/decorators/realtimeDecorators.js +240 -0
  9. package/dist/nodefony/interfaces/IBackplane.js +1 -0
  10. package/dist/nodefony/interfaces/IChannelPolicy.js +1 -0
  11. package/dist/nodefony/interfaces/IClientLogsLimits.js +1 -0
  12. package/dist/nodefony/interfaces/IRealtimeAuthenticator.js +1 -0
  13. package/dist/nodefony/interfaces/IRealtimeAuthenticatorMatcher.js +1 -0
  14. package/dist/nodefony/interfaces/IRealtimeController.js +1 -0
  15. package/dist/nodefony/interfaces/IRealtimeHandshake.js +1 -0
  16. package/dist/nodefony/interfaces/IRealtimeProbe.js +1 -0
  17. package/dist/nodefony/interfaces/IRealtimeToken.js +1 -0
  18. package/dist/nodefony/interfaces/index.js +1 -0
  19. package/dist/nodefony/src/backplane/ClusterBackplane.js +105 -0
  20. package/dist/nodefony/src/backplane/LoopbackBackplane.js +40 -0
  21. package/dist/nodefony/src/backplane/RedisBackplane.js +176 -0
  22. package/dist/nodefony/src/backplane/backplaneRegistry.js +20 -0
  23. package/dist/nodefony/src/backplane/envelope.js +109 -0
  24. package/dist/nodefony/src/backplane/originId.js +35 -0
  25. package/dist/nodefony/src/backplane/publishQueue.js +96 -0
  26. package/dist/nodefony/src/cluster/ClusterProbeClient.js +284 -0
  27. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +35 -0
  28. package/dist/nodefony/src/server/RealtimeAdminApi.js +87 -0
  29. package/dist/nodefony/src/server/RealtimeController.js +601 -0
  30. package/dist/nodefony/src/server/RealtimeHub.js +926 -0
  31. package/dist/nodefony/src/server/ServerRealtimeSocket.js +168 -0
  32. package/dist/nodefony/src/server/deniedDetail.js +29 -0
  33. package/dist/nodefony/src/server/syslogUplink.js +105 -0
  34. package/dist/nodefony/src/server/welcomeEnv.js +25 -0
  35. package/dist/nodefony/src/service/RealtimeService.js +240 -0
  36. package/dist/nodefony/src/transport/WsConnectionTransport.js +94 -0
  37. package/dist/nodefony/testing/index.js +163 -0
  38. package/dist/types/index.d.ts +99 -0
  39. package/dist/types/nodefony/config/config.d.ts +43 -0
  40. package/dist/types/nodefony/config/defineModuleConfig.d.ts +49 -0
  41. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +190 -0
  42. package/dist/types/nodefony/interfaces/IBackplane.d.ts +128 -0
  43. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +28 -0
  44. package/dist/types/nodefony/interfaces/IClientLogsLimits.d.ts +22 -0
  45. package/dist/types/nodefony/interfaces/IRealtimeAuthenticator.d.ts +53 -0
  46. package/dist/types/nodefony/interfaces/IRealtimeAuthenticatorMatcher.d.ts +46 -0
  47. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +36 -0
  48. package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +27 -0
  49. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +206 -0
  50. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +48 -0
  51. package/dist/types/nodefony/interfaces/index.d.ts +18 -0
  52. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +76 -0
  53. package/dist/types/nodefony/src/backplane/LoopbackBackplane.d.ts +29 -0
  54. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +164 -0
  55. package/dist/types/nodefony/src/backplane/backplaneRegistry.d.ts +51 -0
  56. package/dist/types/nodefony/src/backplane/envelope.d.ts +27 -0
  57. package/dist/types/nodefony/src/backplane/originId.d.ts +22 -0
  58. package/dist/types/nodefony/src/backplane/publishQueue.d.ts +71 -0
  59. package/dist/types/nodefony/src/cluster/ClusterProbeClient.d.ts +90 -0
  60. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +3 -0
  61. package/dist/types/nodefony/src/server/RealtimeAdminApi.d.ts +18 -0
  62. package/dist/types/nodefony/src/server/RealtimeController.d.ts +165 -0
  63. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +516 -0
  64. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +63 -0
  65. package/dist/types/nodefony/src/server/deniedDetail.d.ts +25 -0
  66. package/dist/types/nodefony/src/server/syslogUplink.d.ts +58 -0
  67. package/dist/types/nodefony/src/server/welcomeEnv.d.ts +21 -0
  68. package/dist/types/nodefony/src/service/RealtimeService.d.ts +167 -0
  69. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +78 -0
  70. package/dist/types/nodefony/testing/index.d.ts +169 -0
  71. package/docs/actions.md +573 -0
  72. package/docs/architecture.md +889 -0
  73. package/docs/configuration.md +803 -0
  74. package/docs/cookbook-chat.md +1619 -0
  75. package/docs/couverture-bancs.md +139 -0
  76. package/docs/index.md +372 -0
  77. package/docs/observabilite.md +592 -0
  78. package/docs/protocole.md +518 -0
  79. package/docs/securite.md +845 -0
  80. package/docs/vocabulaire.md +845 -0
  81. 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`) |