@nodefony/realtime 10.0.0-beta.4 → 10.0.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +79 -10
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +79 -12
  4. package/dist/nodefony/config/config.js +7 -0
  5. package/dist/nodefony/decorators/realtimeDecorators.js +125 -19
  6. package/dist/nodefony/interfaces/IRealtimeConnection.js +1 -0
  7. package/dist/nodefony/src/backplane/ClusterBackplane.js +32 -4
  8. package/dist/nodefony/src/backplane/RedisBackplane.js +143 -31
  9. package/dist/nodefony/src/backplane/controlOrder.js +57 -0
  10. package/dist/nodefony/src/backplane/envelope.js +113 -12
  11. package/dist/nodefony/src/cluster/ClusterProbeClient.js +2 -0
  12. package/dist/nodefony/src/server/AnonymousRealtimeToken.js +23 -2
  13. package/dist/nodefony/src/server/ContractRealtimeController.js +75 -0
  14. package/dist/nodefony/src/server/RealtimeConnection.js +110 -0
  15. package/dist/nodefony/src/server/RealtimeController.js +344 -85
  16. package/dist/nodefony/src/server/RealtimeHub.js +604 -61
  17. package/dist/nodefony/src/server/ServerRealtimeSocket.js +2 -0
  18. package/dist/nodefony/src/server/admissionBudget.js +176 -0
  19. package/dist/nodefony/src/server/channelPolicyRegistry.js +73 -0
  20. package/dist/nodefony/src/server/connectionAdmission.js +242 -0
  21. package/dist/nodefony/src/server/contractRoutes.js +85 -0
  22. package/dist/nodefony/src/server/deniedDetail.js +10 -3
  23. package/dist/nodefony/src/server/frameAdmission.js +168 -0
  24. package/dist/nodefony/src/server/subscribedAck.js +10 -1
  25. package/dist/nodefony/src/server/syslogUplink.js +3 -4
  26. package/dist/nodefony/src/server/welcomeIdentity.js +37 -0
  27. package/dist/nodefony/src/service/RealtimeService.js +42 -1
  28. package/dist/nodefony/src/service/publicOrigin.js +40 -0
  29. package/dist/nodefony/src/transport/WsConnectionTransport.js +27 -4
  30. package/dist/nodefony/testing/backplaneConformance.js +271 -0
  31. package/dist/nodefony/testing/index.js +21 -4
  32. package/dist/types/index.d.ts +34 -7
  33. package/dist/types/nodefony/config/config.d.ts +9 -0
  34. package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +86 -6
  35. package/dist/types/nodefony/interfaces/IBackplane.d.ts +34 -50
  36. package/dist/types/nodefony/interfaces/IChannelPolicy.d.ts +5 -25
  37. package/dist/types/nodefony/interfaces/IRealtimeConnection.d.ts +22 -0
  38. package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +41 -2
  39. package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +64 -3
  40. package/dist/types/nodefony/interfaces/IRealtimeProbe.d.ts +7 -166
  41. package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +21 -0
  42. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  43. package/dist/types/nodefony/src/backplane/ClusterBackplane.d.ts +22 -4
  44. package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +79 -16
  45. package/dist/types/nodefony/src/backplane/controlOrder.d.ts +52 -0
  46. package/dist/types/nodefony/src/backplane/envelope.d.ts +34 -0
  47. package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +10 -0
  48. package/dist/types/nodefony/src/server/ContractRealtimeController.d.ts +138 -0
  49. package/dist/types/nodefony/src/server/RealtimeConnection.d.ts +17 -0
  50. package/dist/types/nodefony/src/server/RealtimeController.d.ts +93 -13
  51. package/dist/types/nodefony/src/server/RealtimeHub.d.ts +321 -24
  52. package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +1 -1
  53. package/dist/types/nodefony/src/server/admissionBudget.d.ts +71 -0
  54. package/dist/types/nodefony/src/server/channelPolicyRegistry.d.ts +34 -0
  55. package/dist/types/nodefony/src/server/connectionAdmission.d.ts +68 -0
  56. package/dist/types/nodefony/src/server/contractRoutes.d.ts +42 -0
  57. package/dist/types/nodefony/src/server/deniedDetail.d.ts +6 -0
  58. package/dist/types/nodefony/src/server/frameAdmission.d.ts +70 -0
  59. package/dist/types/nodefony/src/server/subscribedAck.d.ts +7 -0
  60. package/dist/types/nodefony/src/server/welcomeIdentity.d.ts +19 -0
  61. package/dist/types/nodefony/src/service/RealtimeService.d.ts +10 -2
  62. package/dist/types/nodefony/src/service/publicOrigin.d.ts +19 -0
  63. package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +16 -30
  64. package/dist/types/nodefony/testing/backplaneConformance.d.ts +66 -0
  65. package/dist/types/nodefony/testing/index.d.ts +17 -4
  66. package/docs/actions.md +442 -19
  67. package/docs/architecture.md +83 -8
  68. package/docs/cadence-et-contre-pression.md +87 -11
  69. package/docs/canaux-a-membres.md +267 -0
  70. package/docs/cas-d-usage.md +1702 -0
  71. package/docs/configuration.md +137 -17
  72. package/docs/cookbook-chat.md +333 -85
  73. package/docs/index.md +11 -1
  74. package/docs/observabilite.md +71 -14
  75. package/docs/protocole.md +133 -24
  76. package/docs/securite.md +294 -33
  77. package/docs/vocabulaire.md +13 -12
  78. package/package.json +14 -11
@@ -0,0 +1,1702 @@
1
+ ---
2
+ title: "Cas d'usage du temps réel — l'exemple qui correspond à votre besoin"
3
+ navTitle: Cas d'usage
4
+ lang: fr
5
+ module: "@nodefony/realtime"
6
+ topic: realtime
7
+ section: "Temps réel"
8
+ audience: [developer]
9
+ tags:
10
+ [cas d'usage, jeu, salon, sonde, notifications, tâche, allow, contre-pression]
11
+ version: "doc"
12
+ status: stable
13
+ updated: 2026-11-02
14
+ source: "src/packages/@nodefony/realtime/docs/cas-d-usage.md"
15
+ coverageModule: realtime
16
+ ---
17
+
18
+ # Cas d'usage du temps réel — l'exemple qui correspond à votre besoin
19
+
20
+ 📍 [Documentation](../../../../../docs/index.md) › [Realtime](index.md) › **Cas d'usage**
21
+
22
+ > Un jeu, un salon privé, une sonde industrielle, une notification personnelle, le suivi d'une
23
+ > tâche longue : chaque besoin a son exemple, court, et le choix qui le justifie. Les décisions du
24
+ > temps réel (admission, abonnement imposé, éjection, contre-pression) ne se comprennent
25
+ > qu'ensemble ; ici, on part du besoin et on les retrouve en situation.
26
+
27
+ ## 🧠 Le modèle mental
28
+
29
+ Partez de la ligne qui ressemble à votre besoin, copiez son exemple, puis lisez « le piège » : c'est
30
+ l'erreur qui compile, passe les tests, et ne se voit qu'en production.
31
+
32
+ | Je veux… | Le cas | Ce qui décide |
33
+ | ------------------------------------------------------------------ | ------------------------------------------------------ | --------------------------------------- |
34
+ | un jeu que le serveur fait avancer et montre à tous | [Jeu — déplacements](#jeu--déplacements) | le serveur calcule, le tick diffuse |
35
+ | placer un joueur dans une partie sans qu'il s'abonne | [Jeu — recherche de partie](#jeu--recherche-de-partie) | l'abonnement imposé, lu par `allow` |
36
+ | un salon sur invitation, dont on peut exclure quelqu'un | [Salon privé](#salon-privé-sur-invitation) | `allow` exclut, l'éjection coupe |
37
+ | des capteurs qui écrivent, des écrans qui lisent | [Sonde et supervision](#sonde-et-supervision) | la portée du jeton, le canal en lecture |
38
+ | une mesure périodique affichée sur N écrans | [Tableau de bord partagé](#tableau-de-bord-partagé) | un producteur par canal, pas par écran |
39
+ | prévenir une personne, sur tous ses onglets | [Notification privée](#notification-privée-userid) | le canal `user:<id>`, fourni |
40
+ | montrer la progression d'une tâche longue à celui qui l'a demandée | [Tâche d'agent](#tâche-dagent) | le serveur nomme le canal et le termine |
41
+ | un appel, une conversation à la voix | [Appel et voix](#appel-et-voix) | en conception |
42
+
43
+ Chaque exemple serveur est un **contrôleur temps réel complet, point d'entrée compris**.
44
+ `@controller("/api/arena")` donne le préfixe ; la route `WEBSOCKET` (`path: "/realtime"`) est la
45
+ porte de la socket, et `handleRealtime` y fait tout le protocole — accueil, identité, abonnements,
46
+ entrées. C'est la forme que produit la commande :
47
+
48
+ ```bash
49
+ npx nodefony create controller Arena --kind realtime # sert /api/arena/realtime
50
+ ```
51
+
52
+ > ⚠️ **Une socket ne sert que les canaux du contrôleur qui porte sa route.** Pour `room:*`, le
53
+ > navigateur ouvre `/api/room/realtime` : ouverte sur un autre contrôleur, la socket refuse
54
+ > `room:42` (`unknown`). Pour qu'une page n'ouvre qu'une socket, regroupez ses canaux dans un même
55
+ > contrôleur. Seul `user:<id>` est fourni par toutes.
56
+
57
+ Chaque exemple range ses responsabilités de la même façon, parce qu'un contrôleur temps réel est
58
+ instancié **par connexion** : rien de partagé ne peut vivre dans `this`.
59
+
60
+ - **Le métier dans un service** (`nodefony create service`) : l'état partagé, les règles, le travail.
61
+ Un exemplaire par serveur, que l'injecteur donne au contrôleur (`@Inject("ArenaService")`).
62
+ - **Le contrôleur traduit** : il valide ce qui arrive du réseau et appelle le service ; un canal
63
+ démarre son producteur au premier abonné et l'arrête au dernier.
64
+ - **L'admission lit le service** : une règle qui ne lit que ses arguments s'écrit en flèche
65
+ (`allow: (channel, who) => …`) ; une règle qui lit un service est une classe `@injectable`
66
+ (`allow: RoomAdmission`), qui le reçoit par son constructeur. Le test la construit avec le MÊME
67
+ service et la passe au harnais (`admissions`).
68
+
69
+ Chaque cas montre trois fichiers : le **serveur**, le **navigateur**, et le **test** qui éprouve
70
+ le serveur avec l'identité d'essai de `@nodefony/security/testing`. Le serveur et le test sont
71
+ exécutés tels quels par la suite du module, le navigateur y est vérifié par le compilateur : ce
72
+ que vous copiez a tourné. Seuls les imports changent — la page montre ceux d'une application qui
73
+ pose le test à côté de son contrôleur.
74
+
75
+ ## 📖 Lexique
76
+
77
+ - **État** — une valeur dont seule la dernière compte (une position, une jauge). **Événement** — un
78
+ message dont chacun compte (un message de chat). Les deux ne se traitent pas pareil sous charge.
79
+ - **Tick** — le rythme propre du serveur : il diffuse l'état à intervalle fixe, quel que soit le
80
+ nombre de messages reçus entre deux.
81
+ - **Abonnement imposé** — le serveur abonne une connexion qui n'a rien demandé ; le navigateur
82
+ l'apprend par `realtime:subscribed { imposed: true }` et le rejoue à la reconnexion.
83
+ - **Éjection** — retrait d'une connexion d'un canal (`realtime:closed { reason: "evicted" }`) ;
84
+ elle coupe, elle n'interdit pas de revenir. **Fin** — le canal est terminé pour tous (`ended`).
85
+ - **Portée** (`scope`) — un droit porté par le jeton d'une machine ou d'une application, là où une
86
+ personne porte un rôle.
87
+
88
+ ## 🚀 Démarrage rapide — un exemple par besoin
89
+
90
+ ### Jeu — déplacements
91
+
92
+ Le joueur envoie sa direction soixante fois par seconde ; le serveur calcule les positions et les
93
+ montre à tous, vingt fois par seconde.
94
+
95
+ <!-- example: gameMoves.ts -->
96
+
97
+ ```ts
98
+ import { Inject, Module, Service, injectable } from "nodefony";
99
+ import { controller, route } from "@nodefony/framework";
100
+ import type { ContextType } from "@nodefony/http";
101
+ import {
102
+ RealtimeChannel,
103
+ RealtimeController,
104
+ RealtimeInbound,
105
+ } from "@nodefony/realtime";
106
+ import type { IRealtimeConnection, RealtimePublish } from "@nodefony/realtime";
107
+
108
+ type Point = { x: number; y: number };
109
+ type Direction = { dx: number; dy: number };
110
+
111
+ /**
112
+ * Le jeu : l'état partagé, les règles, le moteur. UN exemplaire par serveur —
113
+ * le service singleton que `nodefony create service Arena` génère, déclaré
114
+ * dans le module (`@services([ArenaService])`). Il ne connaît pas la socket :
115
+ * il reçoit des directions, et rend des états.
116
+ */
117
+ @injectable()
118
+ export class ArenaService extends Service {
119
+ /** Rythme du serveur : au plus vingt états par seconde, quel que soit le nombre de joueurs. */
120
+ static readonly TICK_MS = 50;
121
+ /** Vitesse d'un joueur, en unités par seconde : c'est le SERVEUR qui l'applique. */
122
+ static readonly SPEED = 200;
123
+ /** L'arène va de 0 à `SIZE` sur chaque axe ; personne n'en sort. */
124
+ static readonly SIZE = 1000;
125
+
126
+ readonly #directions = new Map<string, Direction>();
127
+ readonly #positions = new Map<string, Point>();
128
+
129
+ constructor(module: Module) {
130
+ super("arena", module.container ?? undefined, module.notificationsCenter);
131
+ }
132
+
133
+ /** Retient la DERNIÈRE direction d'un joueur, bornée à [-1, 1] : elle remplace la précédente. */
134
+ steer(player: string, { dx, dy }: Direction): void {
135
+ this.#directions.set(player, {
136
+ dx: ArenaService.clamp(dx, -1, 1),
137
+ dy: ArenaService.clamp(dy, -1, 1),
138
+ });
139
+ }
140
+
141
+ /** Un pas de simulation, à la vitesse du serveur. `null` quand personne n'a bougé. */
142
+ step(): Record<string, Point> | null {
143
+ const stride = (ArenaService.SPEED * ArenaService.TICK_MS) / 1000;
144
+ const center = ArenaService.SIZE / 2;
145
+ let moved = false;
146
+ for (const [player, { dx, dy }] of this.#directions) {
147
+ const at = this.#positions.get(player);
148
+ const from = at ?? { x: center, y: center };
149
+ const to = {
150
+ x: ArenaService.clamp(from.x + dx * stride, 0, ArenaService.SIZE),
151
+ y: ArenaService.clamp(from.y + dy * stride, 0, ArenaService.SIZE),
152
+ };
153
+ if (at === undefined || to.x !== at.x || to.y !== at.y) {
154
+ this.#positions.set(player, to);
155
+ moved = true;
156
+ }
157
+ }
158
+ return moved ? Object.fromEntries(this.#positions) : null;
159
+ }
160
+
161
+ /** Démarre le moteur : un pas tous les `TICK_MS`, publié s'il a changé le monde. Rend l'arrêt. */
162
+ start(publish: (state: Record<string, Point>) => void): () => void {
163
+ const timer = setInterval(() => {
164
+ const state = this.step();
165
+ if (state !== null) publish(state);
166
+ }, ArenaService.TICK_MS);
167
+ timer.unref(); // un serveur qui s'arrête n'attend pas le prochain pas
168
+ return () => clearInterval(timer);
169
+ }
170
+
171
+ /** Où est ce joueur, selon le serveur. */
172
+ position(player: string): Point | undefined {
173
+ return this.#positions.get(player);
174
+ }
175
+
176
+ private static clamp(value: number, min: number, max: number): number {
177
+ return Math.min(max, Math.max(min, value));
178
+ }
179
+ }
180
+
181
+ // Sa socket : `/api/arena/realtime` (préfixe + route). Elle ne sert que les canaux déclarés ici.
182
+ @controller("/api/arena")
183
+ export class ArenaRealtime extends RealtimeController {
184
+ // Instancié PAR CONNEXION : l'état du jeu ne peut pas vivre dans `this`. Il
185
+ // vit dans `ArenaService`, le même pour tous, que l'injecteur fournit.
186
+ constructor(
187
+ context: ContextType,
188
+ @Inject("ArenaService") private readonly arena: ArenaService,
189
+ ) {
190
+ super("ArenaRealtime", context);
191
+ }
192
+
193
+ // Le point d'entrée : `handleRealtime` fait tout le protocole et route vers les décorateurs.
194
+ @route("arena-realtime", {
195
+ path: "/realtime",
196
+ requirements: { methods: ["WEBSOCKET"] },
197
+ })
198
+ async realtime(message: string | Uint8Array | null): Promise<void> {
199
+ this.handleRealtime(message);
200
+ }
201
+
202
+ // Le joueur envoie ce qu'il VEUT faire (une direction), jamais où il EST.
203
+ // Le rôle est contrôlé à la première commande, puis retenu sur la connexion.
204
+ @RealtimeInbound("game:steer", { roles: ["ROLE_PLAYER"] })
205
+ steer(
206
+ params: unknown,
207
+ _reply: unknown,
208
+ connection: IRealtimeConnection,
209
+ ): void {
210
+ if (connection.userId === null || !ArenaRealtime.isDirection(params)) {
211
+ return;
212
+ }
213
+ this.arena.steer(connection.userId, params);
214
+ }
215
+
216
+ // Le moteur démarre au PREMIER joueur abonné et s'arrête au DERNIER : la
217
+ // fonction rendue est l'arrêt. Personne d'autre ne l'appelle.
218
+ @RealtimeChannel("game:state", { roles: ["ROLE_PLAYER"] })
219
+ state(channel: string, publish: RealtimePublish): () => void {
220
+ return this.arena.start((state) => publish(channel, state));
221
+ }
222
+
223
+ /** Ce qui arrive du réseau n'est pas fiable : deux nombres finis, ou rien. */
224
+ private static isDirection(value: unknown): value is Direction {
225
+ return (
226
+ typeof value === "object" &&
227
+ value !== null &&
228
+ "dx" in value &&
229
+ "dy" in value &&
230
+ typeof value.dx === "number" &&
231
+ typeof value.dy === "number" &&
232
+ Number.isFinite(value.dx) &&
233
+ Number.isFinite(value.dy)
234
+ );
235
+ }
236
+ }
237
+ ```
238
+
239
+ Côté navigateur, la socket **jette** une commande quand le réseau cale, au lieu de l'empiler :
240
+
241
+ <!-- example: gameMoves.client.ts -->
242
+
243
+ ```ts
244
+ import { NodefonySocket } from "nodefony/client";
245
+
246
+ const socket = NodefonySocket.shared({
247
+ url: "/api/arena/realtime",
248
+ maxBufferedAmount: 64 * 1024, // au-delà, la commande est JETÉE et comptée, pas mise en file
249
+ });
250
+
251
+ /** Chaque état reçu REMPLACE le précédent : rien n'est empilé. */
252
+ export function watchArena(draw: (players: unknown) => void): () => void {
253
+ const off = socket.on("game:state", draw); // les positions viennent du SERVEUR
254
+ socket.subscribe("game:state"); // le premier abonné démarre le moteur du jeu
255
+ return () => {
256
+ off();
257
+ socket.unsubscribe("game:state");
258
+ };
259
+ }
260
+
261
+ /** La manette, lue soixante fois par seconde : une direction, aucune réponse attendue. */
262
+ export function steer(dx: number, dy: number): void {
263
+ socket.emit("game:steer", { dx, dy });
264
+ }
265
+ ```
266
+
267
+ Le test envoie soixante commandes, avance l'horloge d'un pas, et prouve qu'une direction hors
268
+ bornes ne téléporte personne ni qu'un monde immobile ne fait parler le serveur ; il branche le
269
+ **vrai** verrou du pare-feu, sans lequel le rôle exigé ne serait jugé par personne :
270
+
271
+ <!-- example: gameMoves.test.ts -->
272
+
273
+ ```ts
274
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
275
+ import { getRealtimeHub } from "@nodefony/realtime";
276
+ import { createRealtimeHarness } from "@nodefony/realtime/testing";
277
+ import type { IRealtimeHarness } from "@nodefony/realtime/testing";
278
+ import {
279
+ createTestFrameAuthorizer,
280
+ createTestIdentity,
281
+ } from "@nodefony/security/testing";
282
+ import { createTestModule } from "nodefony/testing";
283
+ import { ArenaRealtime, ArenaService } from "./gameMoves";
284
+
285
+ const open: IRealtimeHarness<ArenaRealtime>[] = [];
286
+ const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
287
+ let arena: ArenaService;
288
+
289
+ async function connect(
290
+ user: string,
291
+ roles: string[],
292
+ ): Promise<IRealtimeHarness<ArenaRealtime>> {
293
+ // Le service passé à la main, comme l'injecteur le fait en production.
294
+ const h = createRealtimeHarness((ctx) => new ArenaRealtime(ctx, arena), {
295
+ identity: createTestIdentity({ identifier: user, roles }),
296
+ // Le VRAI verrou du pare-feu : sans lui, `roles` n'est jugé par personne.
297
+ frameAuthorizer: createTestFrameAuthorizer(getRealtimeHub()),
298
+ resetHub: open.length === 0,
299
+ });
300
+ await h.connect();
301
+ open.push(h);
302
+ return h;
303
+ }
304
+
305
+ describe("jeu — le serveur calcule les positions et les diffuse à son rythme", () => {
306
+ // Le moteur ne tourne que quand le test avance l'horloge.
307
+ beforeEach(() => {
308
+ vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
309
+ arena = new ArenaService(createTestModule());
310
+ });
311
+ afterEach(() => {
312
+ for (const h of open.splice(0)) h.close();
313
+ getRealtimeHub().clear();
314
+ vi.useRealTimers();
315
+ });
316
+
317
+ it("soixante commandes, un seul état diffusé : celui que le serveur a calculé", async () => {
318
+ const alice = await connect("alice", ["ROLE_PLAYER"]);
319
+ await alice.subscribe("game:state");
320
+ for (let i = 0; i < 59; i++) {
321
+ await alice.notify("game:steer", { dx: 0, dy: 1 });
322
+ }
323
+ await alice.notify("game:steer", { dx: 1, dy: 0 }); // la dernière gagne
324
+ await alice.notify("game:steer", { dx: "pas un nombre", dy: 0 });
325
+ expect(alice.messages("game:state")).toHaveLength(0); // rien avant le pas
326
+ vi.advanceTimersByTime(ArenaService.TICK_MS);
327
+ expect(alice.messages("game:state")).toEqual([
328
+ { alice: { x: 510, y: 500 } },
329
+ ]);
330
+ });
331
+
332
+ it("une direction hors bornes ne téléporte personne", async () => {
333
+ const mallory = await connect("mallory", ["ROLE_PLAYER"]);
334
+ await mallory.subscribe("game:state");
335
+ await mallory.notify("game:steer", { dx: 9999, dy: -9999 });
336
+ vi.advanceTimersByTime(ArenaService.TICK_MS);
337
+ // Un pas, à la vitesse du serveur : la triche ne passe pas par la socket.
338
+ expect(arena.position("mallory")).toEqual({ x: 510, y: 490 });
339
+ });
340
+
341
+ it("le premier abonné démarre le moteur, le dernier l'arrête", async () => {
342
+ const alice = await connect("alice", ["ROLE_PLAYER"]);
343
+ await alice.notify("game:steer", { dx: 1, dy: 0 });
344
+ vi.advanceTimersByTime(ArenaService.TICK_MS * 3);
345
+ expect(arena.position("alice")).toBeUndefined(); // personne n'écoute : aucun pas
346
+
347
+ await alice.subscribe("game:state");
348
+ vi.advanceTimersByTime(ArenaService.TICK_MS * 3);
349
+ expect(alice.messages("game:state")).toHaveLength(3);
350
+ expect(arena.position("alice")).toEqual({ x: 530, y: 500 });
351
+
352
+ await alice.unsubscribe("game:state");
353
+ await flush();
354
+ vi.advanceTimersByTime(ArenaService.TICK_MS * 3);
355
+ expect(arena.position("alice")).toEqual({ x: 530, y: 500 }); // moteur arrêté
356
+ });
357
+
358
+ it("personne ne bouge : le serveur se tait", async () => {
359
+ const alice = await connect("alice", ["ROLE_PLAYER"]);
360
+ await alice.subscribe("game:state");
361
+ await alice.notify("game:steer", { dx: 0, dy: 0 });
362
+ vi.advanceTimersByTime(ArenaService.TICK_MS * 5);
363
+ // Une arrivée au centre, puis rien : aucun état répété pour ne rien dire.
364
+ expect(alice.messages("game:state")).toEqual([
365
+ { alice: { x: 500, y: 500 } },
366
+ ]);
367
+ });
368
+
369
+ it("sans ROLE_PLAYER, ni l'état ni les commandes", async () => {
370
+ const watcher = await connect("watcher", ["ROLE_USER"]);
371
+ await watcher.subscribe("game:state");
372
+ await watcher.notify("game:steer", { dx: 1, dy: 1 });
373
+ await flush();
374
+ vi.advanceTimersByTime(ArenaService.TICK_MS);
375
+ expect(watcher.denials().map((d) => d.channel)).toContain("game:state");
376
+ expect(arena.position("watcher")).toBeUndefined();
377
+ });
378
+ });
379
+ ```
380
+
381
+ Et côté serveur, une borne du nombre de trames qu'un client peut envoyer :
382
+
383
+ ```ts ignore
384
+ // nodefony.config.ts
385
+ use("@nodefony/http", {
386
+ websocket: { maxFramesPerSecond: 120, frameRateCloseAfterRejects: 1000 },
387
+ websocketSecure: {
388
+ maxFramesPerSecond: 120,
389
+ frameRateCloseAfterRejects: 1000,
390
+ },
391
+ });
392
+ ```
393
+
394
+ **Pourquoi le serveur calcule, et diffuse au tick.** Un jeu a besoin d'UNE horloge et d'UNE vérité.
395
+ Le monde avance avec le temps, pas avec les messages : un joueur qui garde la flèche enfoncée
396
+ envoie une direction, c'est l'horloge du serveur qui le fait avancer (vitesse × durée du pas).
397
+ Chaque joueur a sa latence : si chacun diffusait sa position, chaque écran verrait un monde
398
+ différent, et personne ne trancherait « qui a touché qui ». Le serveur calcule à un instant
399
+ donné, puis envoie le MÊME état à tous — et le client, qui ne demande qu'une direction bornée, ne
400
+ peut pas se téléporter. Le coût suit : dix joueurs à soixante commandes par seconde, rediffusées
401
+ à chacun, font 6 000 messages par seconde ; un état par pas en fait 200. Le pas fixe laisse le
402
+ navigateur interpoler entre deux états, et le serveur se tait quand rien ne bouge. Le rôle est
403
+ vérifié à la première commande, puis retenu sur la connexion.
404
+
405
+ **Le piège.** Recevoir la POSITION du client : elle se falsifie, un `{ x: 9999 }` et le joueur se
406
+ téléporte. Et mettre chaque commande dans une file : après dix secondes de tunnel, six cents
407
+ directions périmées partent en rafale.
408
+
409
+ Les positions portent des octets (un `Uint8Array` compact) ? Activez le codec binaire, et la charge
410
+ passe par le même canal, sous la même politique — voir [Protocole](protocole.md).
411
+
412
+ ### Jeu — recherche de partie
413
+
414
+ Le joueur demande une partie ; le serveur la trouve et l'y place, sans qu'il ait à s'abonner.
415
+
416
+ <!-- example: matchmaking.ts -->
417
+
418
+ ```ts
419
+ import { Inject, Module, Service, injectable } from "nodefony";
420
+ import type { IChannelAdmission, ISubscriberIdentity } from "nodefony";
421
+ import { controller, route } from "@nodefony/framework";
422
+ import type { ContextType } from "@nodefony/http";
423
+ import {
424
+ RealtimeChannel,
425
+ RealtimeController,
426
+ RealtimeInbound,
427
+ getRealtimeHub,
428
+ } from "@nodefony/realtime";
429
+ import type { IRealtimeConnection } from "@nodefony/realtime";
430
+
431
+ /**
432
+ * Les parties : qui joue où. Une partie se remplit jusqu'à `SEATS` joueurs, la
433
+ * suivante s'ouvre ensuite. UN exemplaire par serveur (en vrai, une table ou
434
+ * un Redis partagé derrière ces mêmes méthodes).
435
+ */
436
+ @injectable()
437
+ export class MatchmakingService extends Service {
438
+ /** Joueurs par partie. */
439
+ static readonly SEATS = 2;
440
+
441
+ readonly #members = new Map<string, Set<string>>();
442
+ #open: string | null = null;
443
+ #count = 0;
444
+
445
+ constructor(module: Module) {
446
+ super(
447
+ "matchmaking",
448
+ module.container ?? undefined,
449
+ module.notificationsCenter,
450
+ );
451
+ }
452
+
453
+ /** Place un joueur : sa partie s'il en a une, sinon la partie ouverte, sinon une neuve. */
454
+ join(player: string): string {
455
+ for (const [match, members] of this.#members) {
456
+ if (members.has(player)) return match;
457
+ }
458
+ const match = this.#open ?? `match:${++this.#count}`;
459
+ const members = this.#members.get(match) ?? new Set<string>();
460
+ members.add(player);
461
+ this.#members.set(match, members);
462
+ this.#open = members.size < MatchmakingService.SEATS ? match : null;
463
+ return match;
464
+ }
465
+
466
+ /** Ce joueur appartient-il à cette partie ? */
467
+ isMember(match: string, player: string): boolean {
468
+ return this.#members.get(match)?.has(player) ?? false;
469
+ }
470
+
471
+ /** Cette partie existe-t-elle ? */
472
+ exists(match: string): boolean {
473
+ return this.#members.has(match);
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Qui entre sur `match:*` : les joueurs de la partie, et eux seuls. Demandé au
479
+ * premier abonnement ET à chaque reconnexion — l'appartenance vit dans le
480
+ * service, pas sur la connexion qui vient de tomber.
481
+ */
482
+ @injectable()
483
+ export class MatchAdmission extends Service implements IChannelAdmission {
484
+ constructor(
485
+ @Inject("MatchmakingService") private readonly matches: MatchmakingService,
486
+ ) {
487
+ super("matchAdmission");
488
+ }
489
+
490
+ allow(channel: string, who: ISubscriberIdentity): boolean {
491
+ return who.id !== null && this.matches.isMember(channel, who.id);
492
+ }
493
+ }
494
+
495
+ // Sa socket : `/api/match/realtime` (préfixe + route). Elle ne sert que les canaux déclarés ici.
496
+ @controller("/api/match")
497
+ export class MatchRealtime extends RealtimeController {
498
+ // Instancié PAR CONNEXION : les parties vivent dans le service, le même pour tous.
499
+ constructor(
500
+ context: ContextType,
501
+ @Inject("MatchmakingService") private readonly matches: MatchmakingService,
502
+ ) {
503
+ super("MatchRealtime", context);
504
+ }
505
+
506
+ // Le point d'entrée : `handleRealtime` fait tout le protocole et route vers les décorateurs.
507
+ @route("match-realtime", {
508
+ path: "/realtime",
509
+ requirements: { methods: ["WEBSOCKET"] },
510
+ })
511
+ async realtime(message: string | Uint8Array | null): Promise<void> {
512
+ this.handleRealtime(message);
513
+ }
514
+
515
+ @RealtimeChannel("match:*", { allow: MatchAdmission })
516
+ match(): () => void {
517
+ return () => {};
518
+ }
519
+
520
+ // Un canal décoré n'est servi que sous son nom exact : la famille l'est ici,
521
+ // et seulement pour une partie qui existe.
522
+ override createRealtimeChannel(channel: string): (() => void) | null {
523
+ return this.matches.exists(channel) ? () => {} : null;
524
+ }
525
+
526
+ @RealtimeInbound("match:find", { authenticated: true })
527
+ async find(
528
+ _params: unknown,
529
+ _reply: unknown,
530
+ connection: IRealtimeConnection,
531
+ ): Promise<void> {
532
+ if (connection.userId === null) return;
533
+ // 1. écrire l'appartenance là où `allow` la lit, 2. abonner d'office.
534
+ const match = this.matches.join(connection.userId);
535
+ await getRealtimeHub().subscribeConnection(match, connection);
536
+ }
537
+ }
538
+ ```
539
+
540
+ Côté navigateur, rien à demander : l'abonnement arrive avec `imposed: true`.
541
+
542
+ <!-- example: matchmaking.client.ts -->
543
+
544
+ ```ts
545
+ import { NodefonySocket } from "nodefony/client";
546
+
547
+ const socket = NodefonySocket.shared({ url: "/api/match/realtime" });
548
+
549
+ /** Rien à écrire pour suivre la partie : le serveur IMPOSE l'abonnement. */
550
+ export function findMatch(onState: (state: unknown) => void): void {
551
+ socket.onSubscribed(({ channel, imposed }) => {
552
+ if (imposed === true && channel.startsWith("match:"))
553
+ socket.on(channel, onState);
554
+ });
555
+ socket.emit("match:find");
556
+ }
557
+
558
+ socket.onClosed(({ channel, reason }) => {
559
+ if (reason === "ended") console.log(`partie ${channel} terminée`);
560
+ });
561
+ ```
562
+
563
+ Le test coupe la connexion, puis la rouvre — ce qu'un tunnel fait au téléphone :
564
+
565
+ <!-- example: matchmaking.test.ts -->
566
+
567
+ ```ts
568
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
569
+ import { getRealtimeHub } from "@nodefony/realtime";
570
+ import { createRealtimeHarness } from "@nodefony/realtime/testing";
571
+ import type { IRealtimeHarness } from "@nodefony/realtime/testing";
572
+ import { createTestIdentity } from "@nodefony/security/testing";
573
+ import { createTestModule } from "nodefony/testing";
574
+ import {
575
+ MatchAdmission,
576
+ MatchRealtime,
577
+ MatchmakingService,
578
+ } from "./matchmaking";
579
+
580
+ const open: IRealtimeHarness<MatchRealtime>[] = [];
581
+ const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
582
+ let matches: MatchmakingService;
583
+
584
+ async function connect(user: string): Promise<IRealtimeHarness<MatchRealtime>> {
585
+ // Le service et l'admission passés à la main, comme l'injecteur en production.
586
+ const h = createRealtimeHarness((ctx) => new MatchRealtime(ctx, matches), {
587
+ identity: createTestIdentity({ identifier: user }),
588
+ admissions: [new MatchAdmission(matches)],
589
+ resetHub: open.length === 0,
590
+ });
591
+ await h.connect();
592
+ open.push(h);
593
+ return h;
594
+ }
595
+
596
+ describe("recherche de partie — abonné d'office, et l'appartenance survit à la reconnexion", () => {
597
+ beforeEach(() => {
598
+ matches = new MatchmakingService(createTestModule());
599
+ });
600
+ afterEach(() => {
601
+ for (const h of open.splice(0)) h.close();
602
+ getRealtimeHub().clear();
603
+ });
604
+
605
+ it("deux joueurs par partie, la troisième en ouvre une autre", async () => {
606
+ const players = await Promise.all(
607
+ ["alice", "bob", "carol"].map((name) => connect(name)),
608
+ );
609
+ for (const player of players) await player.notify("match:find");
610
+ await flush();
611
+ expect(players.map((p) => p.subscriptions())).toEqual([
612
+ ["match:1"],
613
+ ["match:1"],
614
+ ["match:2"],
615
+ ]);
616
+ });
617
+
618
+ it("placé sans s'abonner, le joueur retrouve sa partie après une coupure", async () => {
619
+ const alice = await connect("alice");
620
+ await alice.notify("match:find");
621
+ await flush();
622
+ expect(alice.subscriptions()).toEqual(["match:1"]);
623
+ alice.close(); // un tunnel
624
+ const again = await connect("alice"); // le navigateur rejoue `subscribe`
625
+ await again.subscribe("match:1");
626
+ await flush();
627
+ expect(again.denials()).toHaveLength(0);
628
+ });
629
+
630
+ it("un joueur d'une autre partie n'entre pas", async () => {
631
+ const alice = await connect("alice");
632
+ await alice.notify("match:find");
633
+ await flush();
634
+ const mallory = await connect("mallory");
635
+ await mallory.subscribe("match:1");
636
+ await flush();
637
+ expect(mallory.denials()).toMatchObject([
638
+ { channel: "match:1", reason: "forbidden" },
639
+ ]);
640
+ });
641
+ });
642
+ ```
643
+
644
+ **Pourquoi ce choix.** Le navigateur rejoue ses abonnements à chaque reconnexion, et ce rejeu passe
645
+ par `allow`. L'appartenance est donc écrite **là où `allow` la lit**, avant d'abonner : le joueur
646
+ retrouve sa partie après une coupure, sans code côté navigateur.
647
+
648
+ **Le piège.** Abonner d'office sans écrire l'appartenance : tout marche, jusqu'à la première
649
+ coupure réseau — sur mobile, dans la minute — où le joueur est refusé de sa propre partie.
650
+
651
+ Le détail des gestes (lister, éjecter, terminer) : [Canal à membres](canaux-a-membres.md).
652
+
653
+ ### Salon privé sur invitation
654
+
655
+ Seuls les membres entrent ; un modérateur peut exclure quelqu'un. L'appartenance vit en base,
656
+ dans les entités que le générateur écrit :
657
+
658
+ ```bash
659
+ npx nodefony create entity Room "name:string"
660
+ npx nodefony create entity RoomMember "room:ref:Room" "user:ref:User" "role:enum(member,moderator)" --no-controller
661
+ ```
662
+
663
+ `--no-controller` : un CRUD ouvert sur `RoomMember` laisserait chacun s'inviter lui-même.
664
+
665
+ <!-- example: privateRoom.ts -->
666
+
667
+ ```ts
668
+ import { Inject, Module, Service, injectable } from "nodefony";
669
+ import type { IChannelAdmission, ISubscriberIdentity } from "nodefony";
670
+ import { controller, route } from "@nodefony/framework";
671
+ import type { ContextType } from "@nodefony/http";
672
+ import {
673
+ RealtimeChannel,
674
+ RealtimeController,
675
+ RealtimeInbound,
676
+ getRealtimeHub,
677
+ } from "@nodefony/realtime";
678
+ import type { IRealtimeConnection } from "@nodefony/realtime";
679
+ import { getRoomMemberService } from "../service/RoomMemberService";
680
+
681
+ /**
682
+ * Les salons : qui en est membre, et comment on en exclut. L'appartenance vit
683
+ * en base — une ligne `RoomMember` (salon, compte) — derrière le service
684
+ * d'entité généré par `create entity RoomMember`.
685
+ */
686
+ @injectable()
687
+ export class RoomService extends Service {
688
+ constructor(module: Module) {
689
+ super("rooms", module.container ?? undefined, module.notificationsCenter);
690
+ }
691
+
692
+ /** Ce compte est-il membre du salon de ce canal (`room:<id>`) ? */
693
+ async isMember(channel: string, user: string): Promise<boolean> {
694
+ const room = RoomService.roomOf(channel);
695
+ if (room === null) return false;
696
+ return (await getRoomMemberService().findOne({ room, user })) !== null;
697
+ }
698
+
699
+ /** Exclure : retirer l'appartenance (l'admission dira non), PUIS couper ce qu'il reçoit déjà. */
700
+ async exclude(channel: string, user: string): Promise<void> {
701
+ const room = RoomService.roomOf(channel);
702
+ if (room === null) return;
703
+ await getRoomMemberService().delete({ room, user });
704
+ getRealtimeHub().evictUser(channel, user); // ses onglets reçoivent `realtime:closed { reason: "evicted" }`
705
+ }
706
+
707
+ /** L'identifiant du salon d'un canal `room:<id>`, `null` hors de la famille. */
708
+ static roomOf(channel: string): string | null {
709
+ return channel.startsWith("room:") ? channel.slice("room:".length) : null;
710
+ }
711
+ }
712
+
713
+ /**
714
+ * Qui entre sur `room:*` : les membres du salon. Demandé UNE fois, au
715
+ * `subscribe` (et à chaque page rechargée) : une requête en base y est normale.
716
+ * `who.id` est l'identité posée par le serveur à l'ouverture, jamais un champ
717
+ * du client.
718
+ */
719
+ @injectable()
720
+ export class RoomAdmission extends Service implements IChannelAdmission {
721
+ constructor(@Inject("RoomService") private readonly rooms: RoomService) {
722
+ super("roomAdmission");
723
+ }
724
+
725
+ async allow(channel: string, who: ISubscriberIdentity): Promise<boolean> {
726
+ return who.id !== null && (await this.rooms.isMember(channel, who.id));
727
+ }
728
+ }
729
+
730
+ // La socket de CE contrôleur s'ouvre sur `/api/room/realtime` : le préfixe de la
731
+ // classe, puis le chemin de sa route WebSocket. Seuls les canaux déclarés ICI y
732
+ // sont servis — une socket ouverte ailleurs ne connaît pas `room:*`.
733
+ @controller("/api/room")
734
+ export class RoomRealtime extends RealtimeController {
735
+ // Instancié PAR CONNEXION : ce qui est partagé entre membres vit dans le
736
+ // service, que l'injecteur fournit.
737
+ constructor(
738
+ context: ContextType,
739
+ @Inject("RoomService") private readonly rooms: RoomService,
740
+ ) {
741
+ super("RoomRealtime", context);
742
+ }
743
+
744
+ // Le point d'entrée : chaque trame de la socket arrive ici. `handleRealtime`
745
+ // fait tout le protocole — accueil, identité de la connexion, abonnements,
746
+ // entrées — et route vers les décorateurs ci-dessous. Rien d'autre à écrire.
747
+ @route("room-realtime", {
748
+ path: "/realtime",
749
+ requirements: { methods: ["WEBSOCKET"] },
750
+ })
751
+ async realtime(message: string | Uint8Array | null): Promise<void> {
752
+ this.handleRealtime(message);
753
+ }
754
+
755
+ // « CETTE personne a-t-elle droit à CE salon ? » : la classe d'admission tranche.
756
+ @RealtimeChannel("room:*", { allow: RoomAdmission })
757
+ room(): () => void {
758
+ return () => {};
759
+ }
760
+
761
+ // Un canal décoré n'est servi que sous son nom exact : la famille l'est ici.
762
+ override createRealtimeChannel(channel: string): (() => void) | null {
763
+ return RoomService.roomOf(channel) === null ? null : () => {};
764
+ }
765
+
766
+ // Une entrée n'hérite PAS de la politique du canal : elle vérifie l'appartenance
767
+ // elle-même, par le MÊME service que l'admission, et l'auteur vient de la connexion.
768
+ @RealtimeInbound("room:send", { authenticated: true })
769
+ async send(
770
+ params: unknown,
771
+ _reply: unknown,
772
+ connection: IRealtimeConnection,
773
+ ): Promise<void> {
774
+ if (connection.userId === null || !RoomRealtime.isMessage(params)) return;
775
+ if (!(await this.rooms.isMember(params.room, connection.userId))) return;
776
+ getRealtimeHub().publish(params.room, {
777
+ author: connection.userId,
778
+ text: params.text.slice(0, 500),
779
+ });
780
+ }
781
+
782
+ /** Ce qui arrive du réseau n'est pas fiable : un salon et un texte, ou rien. */
783
+ private static isMessage(
784
+ value: unknown,
785
+ ): value is { room: string; text: string } {
786
+ return (
787
+ typeof value === "object" &&
788
+ value !== null &&
789
+ "room" in value &&
790
+ "text" in value &&
791
+ typeof value.room === "string" &&
792
+ typeof value.text === "string"
793
+ );
794
+ }
795
+ }
796
+ ```
797
+
798
+ Côté navigateur, entrer est une demande ; un refus ou une exclusion s'affiche, sans réessai :
799
+
800
+ <!-- example: privateRoom.client.ts -->
801
+
802
+ ```ts
803
+ import { NodefonySocket } from "nodefony/client";
804
+
805
+ const socket = NodefonySocket.shared({ url: "/api/room/realtime" });
806
+
807
+ /** Entrer dans un salon : une DEMANDE, que `allow` tranche côté serveur. */
808
+ export function joinRoom(
809
+ room: string,
810
+ onMessage: (message: unknown) => void,
811
+ ): () => void {
812
+ const off = socket.on(room, onMessage);
813
+ socket.subscribe(room);
814
+ return () => {
815
+ off();
816
+ socket.unsubscribe(room);
817
+ };
818
+ }
819
+
820
+ export function say(room: string, text: string): void {
821
+ socket.emit("room:send", { room, text });
822
+ }
823
+
824
+ // Refusé (pas membre) ou exclu : l'écran le dit, et ne réessaie pas.
825
+ socket.onDenied(({ channel, reason }) =>
826
+ console.warn(`${channel} : ${reason}`),
827
+ );
828
+ socket.onClosed(({ channel, reason }) => {
829
+ if (reason === "evicted") console.warn(`exclu de ${channel}`);
830
+ });
831
+ ```
832
+
833
+ Le test admet, refuse, puis exclut — et recharge la page de l'exclu. Comme `allow` lit la
834
+ base, le test en ouvre une en mémoire et l'**amorce**, avec la recette du test que `create entity`
835
+ génère pour chaque entité :
836
+
837
+ - les entités sont enregistrées sous le connecteur de l'APPLICATION (`default`) : c'est là que
838
+ `getRoomMemberService()` les cherche ;
839
+ - les lignes PARENTES d'abord : `RoomMember` porte deux clés étrangères, et la base refuse un
840
+ compte ou un salon inventé (« FOREIGN KEY constraint failed »). `seedEntityRow` pose une ligne
841
+ valide quelle que soit la table, et rend son identifiant ;
842
+ - l'identité de test porte cet identifiant (`createTestIdentity({ identifier, id })`) : c'est
843
+ lui que `who.id` donne à `allow`.
844
+
845
+ <!-- example: privateRoom.test.ts -->
846
+
847
+ ```ts
848
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
849
+ import { DrizzleOrm, seedEntityRow } from "@nodefony/drizzle";
850
+ import { entityRegistry, ormRegistry } from "@nodefony/orm-core";
851
+ import { getRealtimeHub } from "@nodefony/realtime";
852
+ import { createRealtimeHarness } from "@nodefony/realtime/testing";
853
+ import type { IRealtimeHarness } from "@nodefony/realtime/testing";
854
+ import { createTestIdentity } from "@nodefony/security/testing";
855
+ import { UserEntity } from "../entity/User";
856
+ import { RoomEntity } from "../entity/Room";
857
+ import { RoomMemberEntity } from "../entity/RoomMember";
858
+ import { getRoomMemberService } from "../service/RoomMemberService";
859
+ import { createTestModule } from "nodefony/testing";
860
+ import { RoomAdmission, RoomRealtime, RoomService } from "./privateRoom";
861
+
862
+ // Le connecteur de l'APPLICATION : `getRoomMemberService()` le lit sous ce nom.
863
+ // Un autre nom (`test-room-member`, celui du test d'entité généré) laisserait
864
+ // le service sans base.
865
+ const CONNECTOR = "default";
866
+ const ENTITIES = [UserEntity, RoomEntity, RoomMemberEntity];
867
+
868
+ let orm: DrizzleOrm;
869
+ const rooms = new RoomService(createTestModule());
870
+ let channel = "";
871
+ const accounts = new Map<string, string>();
872
+
873
+ /** L'identifiant en base d'un compte semé. */
874
+ function idOf(name: string): string {
875
+ const id = accounts.get(name);
876
+ if (id === undefined) throw new Error(`compte non semé : ${name}`);
877
+ return id;
878
+ }
879
+
880
+ beforeAll(async () => {
881
+ for (const entity of ENTITIES) {
882
+ entityRegistry.register({ ...entity, connector: CONNECTOR });
883
+ }
884
+ orm = new DrizzleOrm(CONNECTOR, { filename: ":memory:" }); // en mémoire, rien à installer
885
+ await orm.connect();
886
+ // Les lignes PARENTES d'abord : `RoomMember` porte deux clés étrangères, et la
887
+ // base refuse un compte ou un salon inventé (« FOREIGN KEY constraint failed »).
888
+ for (const name of ["alice", "bob", "carol", "mallory"]) {
889
+ const id = await seedEntityRow(orm, {
890
+ ...UserEntity,
891
+ connector: CONNECTOR,
892
+ });
893
+ accounts.set(name, String(id));
894
+ }
895
+ const room = String(
896
+ await seedEntityRow(orm, { ...RoomEntity, connector: CONNECTOR }),
897
+ );
898
+ channel = `room:${room}`;
899
+ for (const name of ["alice", "bob", "carol"]) {
900
+ await getRoomMemberService().create({
901
+ room,
902
+ user: idOf(name),
903
+ role: "member",
904
+ });
905
+ }
906
+ });
907
+
908
+ afterAll(async () => {
909
+ await orm.disconnect();
910
+ for (const entity of ENTITIES)
911
+ entityRegistry.unregister(entity.name, CONNECTOR);
912
+ ormRegistry.unregister(CONNECTOR);
913
+ });
914
+
915
+ const open: IRealtimeHarness<RoomRealtime>[] = [];
916
+ const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
917
+
918
+ /** Une connexion par compte, identifiée par son identifiant EN BASE. */
919
+ async function connect(name: string): Promise<IRealtimeHarness<RoomRealtime>> {
920
+ // Le service et l'admission passés à la main, comme l'injecteur en production.
921
+ const h = createRealtimeHarness((ctx) => new RoomRealtime(ctx, rooms), {
922
+ identity: createTestIdentity({ identifier: name, id: idOf(name) }),
923
+ admissions: [new RoomAdmission(rooms)],
924
+ resetHub: open.length === 0,
925
+ });
926
+ await h.connect();
927
+ open.push(h);
928
+ return h;
929
+ }
930
+
931
+ const closedOf = (h: IRealtimeHarness<RoomRealtime>): unknown[] =>
932
+ h.received.filter((f) => f.method === "realtime:closed").map((f) => f.params);
933
+
934
+ describe("salon privé — `allow` lit la base, l'exclusion coupe ET empêche de revenir", () => {
935
+ afterEach(() => {
936
+ for (const h of open.splice(0)) h.close();
937
+ getRealtimeHub().clear();
938
+ });
939
+
940
+ it("le membre entre, l'intrus est refusé par le MÊME motif", async () => {
941
+ const bob = await connect("bob");
942
+ const mallory = await connect("mallory");
943
+ await bob.subscribe(channel);
944
+ await mallory.subscribe(channel);
945
+ await flush();
946
+ expect(bob.subscriptions()).toEqual([channel]);
947
+ expect(mallory.denials()).toMatchObject([{ channel, reason: "forbidden" }]);
948
+ });
949
+
950
+ it("seul un membre écrit ; l'auteur vient de la connexion, pas du message", async () => {
951
+ const alice = await connect("alice");
952
+ const bob = await connect("bob");
953
+ const mallory = await connect("mallory");
954
+ await alice.subscribe(channel);
955
+ await bob.notify("room:send", {
956
+ room: channel,
957
+ text: "salut",
958
+ author: "x",
959
+ });
960
+ await mallory.notify("room:send", { room: channel, text: "intrus" });
961
+ await flush();
962
+ expect(alice.messages(channel)).toEqual([
963
+ { author: idOf("bob"), text: "salut" },
964
+ ]);
965
+ });
966
+
967
+ it("exclu : `evicted` tout de suite, puis refusé à la page rechargée", async () => {
968
+ const carol = await connect("carol");
969
+ await carol.subscribe(channel);
970
+ await flush();
971
+ await rooms.exclude(channel, idOf("carol"));
972
+ expect(closedOf(carol)).toEqual([{ channel, reason: "evicted" }]);
973
+ const reloaded = await connect("carol"); // une page rechargée refait `subscribe`
974
+ await reloaded.subscribe(channel);
975
+ await flush();
976
+ expect(reloaded.denials()).toHaveLength(1);
977
+ });
978
+ });
979
+ ```
980
+
981
+ **Pourquoi ce choix.** `allow` est appelé une seule fois par abonnement, jamais par message : une
982
+ requête en base y est normale. Exclure, c'est retirer l'appartenance **puis** éjecter. L'entrée
983
+ `room:send` n'hérite pas de la politique du canal : elle relit l'appartenance elle-même, et prend
984
+ l'auteur sur la connexion, jamais dans le message.
985
+
986
+ **Le piège.** Éjecter sans retirer l'appartenance, ou tenir une liste noire à côté : l'exclu
987
+ recharge sa page, son navigateur refait `subscribe`, et `allow` le laisse rentrer. Côté test :
988
+ écrire une appartenance pour un compte qui n'existe pas en base — refusée par la clé étrangère, ou,
989
+ si on renonce, un test qui ne prouve plus l'exclusion.
990
+
991
+ Le salon complet — historique, messages privés, présence : [Recette — un chat](cookbook-chat.md).
992
+
993
+ ### Sonde et supervision
994
+
995
+ Des capteurs envoient des mesures ; les écrans de supervision les reçoivent.
996
+
997
+ <!-- example: sensors.ts -->
998
+
999
+ ```ts
1000
+ import { controller, route } from "@nodefony/framework";
1001
+ import type { ContextType } from "@nodefony/http";
1002
+ import {
1003
+ RealtimeChannel,
1004
+ RealtimeController,
1005
+ RealtimeInbound,
1006
+ getRealtimeHub,
1007
+ } from "@nodefony/realtime";
1008
+
1009
+ // Sa socket : `/api/sensor/realtime` (préfixe + route). Elle ne sert que les canaux déclarés ici.
1010
+ @controller("/api/sensor")
1011
+ export class SensorRealtime extends RealtimeController {
1012
+ constructor(context: ContextType) {
1013
+ super("SensorRealtime", context);
1014
+ }
1015
+
1016
+ // Le point d'entrée : `handleRealtime` fait tout le protocole et route vers les décorateurs.
1017
+ @route("sensor-realtime", {
1018
+ path: "/realtime",
1019
+ requirements: { methods: ["WEBSOCKET"] },
1020
+ })
1021
+ async realtime(message: string | Uint8Array | null): Promise<void> {
1022
+ this.handleRealtime(message);
1023
+ }
1024
+
1025
+ // Écrire : réservé aux sondes, par la portée de leur jeton.
1026
+ @RealtimeInbound("sensor:reading", { scopes: ["telemetry:write"] })
1027
+ reading(params: unknown): void {
1028
+ if (!SensorRealtime.isReading(params)) return;
1029
+ getRealtimeHub().publish("sensor:dashboard", params);
1030
+ }
1031
+
1032
+ // Lire : les écrans de supervision. Aucun gestionnaire entrant : le canal est en lecture seule.
1033
+ @RealtimeChannel("sensor:dashboard", { roles: ["ROLE_SUPERVISOR"] })
1034
+ dashboard(): () => void {
1035
+ return () => {};
1036
+ }
1037
+
1038
+ /** Ce qui arrive du réseau n'est pas fiable : une sonde et une valeur, ou rien. */
1039
+ private static isReading(
1040
+ value: unknown,
1041
+ ): value is { probe: string; value: number } {
1042
+ return (
1043
+ typeof value === "object" &&
1044
+ value !== null &&
1045
+ "probe" in value &&
1046
+ "value" in value &&
1047
+ typeof value.probe === "string" &&
1048
+ typeof value.value === "number"
1049
+ );
1050
+ }
1051
+ }
1052
+ ```
1053
+
1054
+ Côté navigateur, l'écran lit ; côté atelier, une passerelle écrit avec son propre jeton :
1055
+
1056
+ <!-- example: sensors.client.ts -->
1057
+
1058
+ ```ts
1059
+ import { NodefonySocket } from "nodefony/client";
1060
+
1061
+ /** L'écran de supervision LIT (ROLE_SUPERVISOR) ; il ne peut rien pousser. */
1062
+ export function watchDashboard(show: (reading: unknown) => void): () => void {
1063
+ const screen = NodefonySocket.shared({ url: "/api/sensor/realtime" });
1064
+ const off = screen.on("sensor:dashboard", show);
1065
+ screen.subscribe("sensor:dashboard");
1066
+ return off;
1067
+ }
1068
+
1069
+ /** Une passerelle d'atelier (script Node) : jeton de portée `telemetry:write`, rien d'autre. */
1070
+ export function openGateway(token: string): NodefonySocket {
1071
+ const gateway = new NodefonySocket({
1072
+ url: "https://supervision.example/api/sensor/realtime",
1073
+ token,
1074
+ maxBufferedAmount: 64 * 1024, // une mesure périmée se jette, elle ne s'empile pas
1075
+ });
1076
+ gateway.connect().catch(() => {}); // la reconnexion automatique prend le relais
1077
+ return gateway;
1078
+ }
1079
+
1080
+ export function report(
1081
+ gateway: NodefonySocket,
1082
+ probe: string,
1083
+ value: number,
1084
+ ): void {
1085
+ gateway.emit("sensor:reading", { probe, value });
1086
+ }
1087
+ ```
1088
+
1089
+ Le test donne un rôle à l'écran, une portée à la sonde, et vérifie que chacun reste à sa place :
1090
+
1091
+ <!-- example: sensors.test.ts -->
1092
+
1093
+ ```ts
1094
+ import { afterEach, describe, expect, it } from "vitest";
1095
+ import { getRealtimeHub } from "@nodefony/realtime";
1096
+ import { createRealtimeHarness } from "@nodefony/realtime/testing";
1097
+ import type { IRealtimeHarness } from "@nodefony/realtime/testing";
1098
+ import {
1099
+ createTestFrameAuthorizer,
1100
+ createTestIdentity,
1101
+ } from "@nodefony/security/testing";
1102
+ import { SensorRealtime } from "./sensors";
1103
+
1104
+ const open: IRealtimeHarness<SensorRealtime>[] = [];
1105
+ const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
1106
+
1107
+ async function connect(
1108
+ user: string,
1109
+ rights: { roles?: string[]; scopes?: string[] },
1110
+ ): Promise<IRealtimeHarness<SensorRealtime>> {
1111
+ const h = createRealtimeHarness((ctx) => new SensorRealtime(ctx), {
1112
+ identity: createTestIdentity({ identifier: user, ...rights }),
1113
+ frameAuthorizer: createTestFrameAuthorizer(getRealtimeHub()),
1114
+ resetHub: open.length === 0,
1115
+ });
1116
+ await h.connect();
1117
+ open.push(h);
1118
+ return h;
1119
+ }
1120
+
1121
+ describe("sondes — la portée du jeton écrit, le rôle lit", () => {
1122
+ afterEach(() => {
1123
+ for (const h of open.splice(0)) h.close();
1124
+ getRealtimeHub().clear();
1125
+ });
1126
+
1127
+ it("une mesure de sonde atteint l'écran ; une mesure malformée, nulle part", async () => {
1128
+ const screen = await connect("screen", { roles: ["ROLE_SUPERVISOR"] });
1129
+ const probe = await connect("probe-1", {
1130
+ roles: [],
1131
+ scopes: ["telemetry:write"],
1132
+ });
1133
+ await screen.subscribe("sensor:dashboard");
1134
+ await probe.notify("sensor:reading", { probe: "four-3", value: 812 });
1135
+ await probe.notify("sensor:reading", { probe: "four-3", value: "chaud" });
1136
+ await flush();
1137
+ expect(screen.messages("sensor:dashboard")).toEqual([
1138
+ { probe: "four-3", value: 812 },
1139
+ ]);
1140
+ });
1141
+
1142
+ it("un écran ne pousse rien, un compte sans le rôle ne lit rien", async () => {
1143
+ const screen = await connect("screen", { roles: ["ROLE_SUPERVISOR"] });
1144
+ const visitor = await connect("visitor", { roles: ["ROLE_USER"] });
1145
+ await screen.subscribe("sensor:dashboard");
1146
+ await screen.notify("sensor:reading", { probe: "forged", value: 0 });
1147
+ await visitor.subscribe("sensor:dashboard");
1148
+ await flush();
1149
+ expect(screen.messages("sensor:dashboard")).toHaveLength(0);
1150
+ expect(visitor.denials().map((d) => d.channel)).toContain(
1151
+ "sensor:dashboard",
1152
+ );
1153
+ });
1154
+ });
1155
+ ```
1156
+
1157
+ **Pourquoi ce choix.** Écrire et lire sont deux droits distincts : la sonde s'identifie par la
1158
+ portée de son jeton (`telemetry:write`), l'écran par un rôle. Le canal du tableau n'a aucun
1159
+ gestionnaire entrant : un écran ne peut rien y pousser.
1160
+
1161
+ **Le piège.** Un écran lent — un onglet en arrière-plan, un réseau faible — ne ralentit personne :
1162
+ au-delà de sa file d'envoi, le serveur jette ce qui lui est destiné, puis le déconnecte. Ne
1163
+ compensez pas par une file maison : ce serait garder en mémoire ce que l'écran ne lira jamais.
1164
+ Réglages et mesures : [Cadence et contre-pression](cadence-et-contre-pression.md).
1165
+
1166
+ ### Tableau de bord partagé
1167
+
1168
+ Une mesure par seconde, affichée sur autant d'écrans qu'on veut, pour le prix d'un seul calcul.
1169
+
1170
+ <!-- example: boardTicker.ts -->
1171
+
1172
+ ```ts
1173
+ import { controller, route } from "@nodefony/framework";
1174
+ import type { ContextType } from "@nodefony/http";
1175
+ import { RealtimeChannel, RealtimeController } from "@nodefony/realtime";
1176
+ import type { RealtimePublish } from "@nodefony/realtime";
1177
+
1178
+ // Sa socket : `/api/board/realtime` (préfixe + route). Elle ne sert que les canaux déclarés ici.
1179
+ @controller("/api/board")
1180
+ export class BoardRealtime extends RealtimeController {
1181
+ /** Cadence du tableau : une publication par seconde, quel que soit le nombre d'écrans. */
1182
+ static readonly TICK_MS = 1000;
1183
+
1184
+ constructor(context: ContextType) {
1185
+ super("BoardRealtime", context);
1186
+ }
1187
+
1188
+ // Le point d'entrée : `handleRealtime` fait tout le protocole et route vers les décorateurs.
1189
+ @route("board-realtime", {
1190
+ path: "/realtime",
1191
+ requirements: { methods: ["WEBSOCKET"] },
1192
+ })
1193
+ async realtime(message: string | Uint8Array | null): Promise<void> {
1194
+ this.handleRealtime(message);
1195
+ }
1196
+
1197
+ // UN minuteur par serveur : créé au PREMIER écran, arrêté au DERNIER (la
1198
+ // fonction rendue est l'arrêt). Il publie par `publish`, que le hub lui
1199
+ // donne : rien de `this.context` ici, le producteur survit à la connexion
1200
+ // qui l'a créé.
1201
+ @RealtimeChannel("board:load", { roles: ["ROLE_SUPERVISOR"] })
1202
+ load(channel: string, publish: RealtimePublish): () => void {
1203
+ const timer = setInterval(
1204
+ () => publish(channel, BoardRealtime.measure()),
1205
+ BoardRealtime.TICK_MS,
1206
+ );
1207
+ timer.unref(); // un serveur qui s'arrête n'attend pas le prochain tick
1208
+ return () => clearInterval(timer);
1209
+ }
1210
+
1211
+ /** Ce que publie chaque tick — ici la mémoire du processus, en vrai un service. */
1212
+ private static measure(): { rss: number; heap: number } {
1213
+ const { rss, heapUsed } = process.memoryUsage();
1214
+ return { rss, heap: heapUsed };
1215
+ }
1216
+ }
1217
+ ```
1218
+
1219
+ Côté navigateur, chaque écran s'abonne, sans rien savoir des autres :
1220
+
1221
+ <!-- example: boardTicker.client.ts -->
1222
+
1223
+ ```ts
1224
+ import { NodefonySocket } from "nodefony/client";
1225
+
1226
+ const socket = NodefonySocket.shared({ url: "/api/board/realtime" });
1227
+
1228
+ /** Chaque écran s'abonne ; le serveur ne paie qu'un producteur pour tous. */
1229
+ export function watchLoad(render: (load: unknown) => void): () => void {
1230
+ const off = socket.on("board:load", render); // charge réseau : la vérifier avant d'afficher
1231
+ socket.subscribe("board:load");
1232
+ return () => {
1233
+ off();
1234
+ socket.unsubscribe("board:load");
1235
+ };
1236
+ }
1237
+ ```
1238
+
1239
+ Le test simule le seul minuteur du producteur, et compte ce que reçoit chaque écran :
1240
+
1241
+ <!-- example: boardTicker.test.ts -->
1242
+
1243
+ ```ts
1244
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1245
+ import { getRealtimeHub } from "@nodefony/realtime";
1246
+ import { createRealtimeHarness } from "@nodefony/realtime/testing";
1247
+ import type { IRealtimeHarness } from "@nodefony/realtime/testing";
1248
+ import {
1249
+ createTestFrameAuthorizer,
1250
+ createTestIdentity,
1251
+ } from "@nodefony/security/testing";
1252
+ import { BoardRealtime } from "./boardTicker";
1253
+
1254
+ const open: IRealtimeHarness<BoardRealtime>[] = [];
1255
+
1256
+ async function connect(
1257
+ user: string,
1258
+ roles: string[],
1259
+ ): Promise<IRealtimeHarness<BoardRealtime>> {
1260
+ const h = createRealtimeHarness((ctx) => new BoardRealtime(ctx), {
1261
+ identity: createTestIdentity({ identifier: user, roles }),
1262
+ frameAuthorizer: createTestFrameAuthorizer(getRealtimeHub()),
1263
+ resetHub: open.length === 0,
1264
+ });
1265
+ await h.connect();
1266
+ open.push(h);
1267
+ return h;
1268
+ }
1269
+
1270
+ describe("tableau de bord — un producteur pour N écrans", () => {
1271
+ // Seul le minuteur du producteur est simulé : le reste du hub garde le vrai temps.
1272
+ beforeEach(() =>
1273
+ vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }),
1274
+ );
1275
+ afterEach(() => {
1276
+ for (const h of open.splice(0)) h.close();
1277
+ getRealtimeHub().clear();
1278
+ vi.useRealTimers();
1279
+ });
1280
+
1281
+ it("deux écrans, un tick : UNE publication chacun — jamais un minuteur par écran", async () => {
1282
+ const a = await connect("ops-a", ["ROLE_SUPERVISOR"]);
1283
+ const b = await connect("ops-b", ["ROLE_SUPERVISOR"]);
1284
+ await a.subscribe("board:load");
1285
+ await b.subscribe("board:load");
1286
+ vi.advanceTimersByTime(BoardRealtime.TICK_MS);
1287
+ expect(a.messages("board:load")).toHaveLength(1);
1288
+ expect(b.messages("board:load")).toHaveLength(1);
1289
+ });
1290
+
1291
+ it("le dernier écran parti, le minuteur s'arrête", async () => {
1292
+ const a = await connect("ops-a", ["ROLE_SUPERVISOR"]);
1293
+ await a.subscribe("board:load");
1294
+ expect(vi.getTimerCount()).toBe(1);
1295
+ a.close();
1296
+ expect(vi.getTimerCount()).toBe(0);
1297
+ });
1298
+
1299
+ it("un compte sans le rôle est refusé", async () => {
1300
+ const visitor = await connect("visitor", ["ROLE_USER"]);
1301
+ await visitor.subscribe("board:load");
1302
+ expect(visitor.denials()).toMatchObject([
1303
+ { channel: "board:load", reason: "forbidden" },
1304
+ ]);
1305
+ });
1306
+ });
1307
+ ```
1308
+
1309
+ **Pourquoi ce choix.** La fabrique du canal tourne au **premier** abonné et s'arrête au
1310
+ **dernier** : le minuteur appartient au canal, pas à la connexion. Dix écrans coûtent une mesure par
1311
+ seconde, pas dix.
1312
+
1313
+ **Le piège.** Lancer le minuteur dans le gestionnaire d'une connexion, ou y lire `this.context` :
1314
+ chaque écran ajoute son minuteur, et le serveur publie N fois la même mesure à N écrans — N² messages
1315
+ par seconde, sans une erreur.
1316
+
1317
+ ### Notification privée `user:<id>`
1318
+
1319
+ Prévenir une personne — sur tous ses onglets, tous les serveurs, et elle seule.
1320
+
1321
+ <!-- example: userNotifications.ts -->
1322
+
1323
+ ```ts
1324
+ import { Module, Service, injectable } from "nodefony";
1325
+ import { getRealtimeHub } from "@nodefony/realtime";
1326
+
1327
+ /**
1328
+ * La facturation. Prévenir quelqu'un est un geste MÉTIER : il part d'un
1329
+ * service, d'un job ou d'un contrôleur — rien à déclarer, aucun canal à écrire.
1330
+ */
1331
+ @injectable()
1332
+ export class BillingService extends Service {
1333
+ constructor(module: Module) {
1334
+ super("billing", module.container ?? undefined, module.notificationsCenter);
1335
+ }
1336
+
1337
+ /** Une facture payée : sa propriétaire l'apprend sur tous ses onglets, elle seule. */
1338
+ invoicePaid(userId: string, invoice: string): void {
1339
+ getRealtimeHub().publishToUser(userId, { kind: "invoice:paid", invoice });
1340
+ }
1341
+ }
1342
+ ```
1343
+
1344
+ Côté navigateur, un seul geste ; la forme du message se déclare dans le contrat :
1345
+
1346
+ <!-- example: userNotifications.client.ts -->
1347
+
1348
+ ```ts
1349
+ import { NodefonySocket } from "nodefony/client";
1350
+ import type { IRealtimeContract } from "nodefony/client";
1351
+
1352
+ // La forme du message se déclare dans le contrat : `note` est typé à l'arrivée.
1353
+ interface BillingContract extends IRealtimeContract {
1354
+ serverToClient: {
1355
+ [channel: `user:${string}`]: { kind: "invoice:paid"; invoice: string };
1356
+ };
1357
+ }
1358
+
1359
+ // N'importe quelle socket temps réel de l'application : `user:<id>` est fourni
1360
+ // par chacune, sans rien déclarer. Ici celle des sondes (`SensorRealtime`).
1361
+ const socket = NodefonySocket.fromContract<BillingContract>({
1362
+ url: "/api/sensor/realtime",
1363
+ });
1364
+
1365
+ /** Rien à connaître ni à construire : l'identifiant vient de l'accueil de la socket. */
1366
+ export const stopListening = socket.onUser((note) =>
1367
+ console.log(`facture ${note.invoice} payée`),
1368
+ );
1369
+ ```
1370
+
1371
+ Le test vérifie la destinataire, et qu'un autre compte n'entre pas sur son canal :
1372
+
1373
+ <!-- example: userNotifications.test.ts -->
1374
+
1375
+ ```ts
1376
+ import { afterEach, describe, expect, it } from "vitest";
1377
+ import { getRealtimeHub } from "@nodefony/realtime";
1378
+ import { createRealtimeHarness } from "@nodefony/realtime/testing";
1379
+ import type { IRealtimeHarness } from "@nodefony/realtime/testing";
1380
+ import { createTestIdentity } from "@nodefony/security/testing";
1381
+ import { SensorRealtime } from "./sensors";
1382
+ import { createTestModule } from "nodefony/testing";
1383
+ import { BillingService } from "./userNotifications";
1384
+
1385
+ const open: IRealtimeHarness<SensorRealtime>[] = [];
1386
+ const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
1387
+
1388
+ /** N'importe quel contrôleur temps réel : le canal personnel est fourni par le framework. */
1389
+ async function connect(
1390
+ user: string,
1391
+ ): Promise<IRealtimeHarness<SensorRealtime>> {
1392
+ const h = createRealtimeHarness((ctx) => new SensorRealtime(ctx), {
1393
+ identity: createTestIdentity({ identifier: user }),
1394
+ resetHub: open.length === 0,
1395
+ });
1396
+ await h.connect();
1397
+ open.push(h);
1398
+ return h;
1399
+ }
1400
+
1401
+ describe("notification privée — n'atteint que la personne visée", () => {
1402
+ afterEach(() => {
1403
+ for (const h of open.splice(0)) h.close();
1404
+ getRealtimeHub().clear();
1405
+ });
1406
+
1407
+ it("alice reçoit, bob non, et personne n'entre sur le canal d'un autre", async () => {
1408
+ const alice = await connect("alice");
1409
+ const bob = await connect("bob");
1410
+ await alice.subscribe("user:alice"); // ce que fait `socket.onUser()`
1411
+ await bob.subscribe("user:bob");
1412
+ await bob.subscribe("user:alice");
1413
+ await flush();
1414
+ new BillingService(createTestModule()).invoicePaid("alice", "F-2026-001");
1415
+ expect(alice.messages("user:alice")).toEqual([
1416
+ { kind: "invoice:paid", invoice: "F-2026-001" },
1417
+ ]);
1418
+ expect(bob.messages("user:alice")).toHaveLength(0);
1419
+ expect(bob.denials()).toMatchObject([
1420
+ { channel: "user:alice", reason: "forbidden" },
1421
+ ]);
1422
+ });
1423
+ });
1424
+ ```
1425
+
1426
+ **Pourquoi ce choix.** Le canal personnel est une brique du framework : connexion authentifiée,
1427
+ admission par égalité exacte avec `user:<ton id>`, diffusion sur tous les serveurs. Rien à
1428
+ déclarer, et une application qui n'appelle jamais `onUser` ne paie rien.
1429
+
1430
+ **Le piège.** Déclarer soi-même une politique sur `user:…` — par exemple pour ouvrir `user:public`
1431
+ à tous. Elle l'emporte sur la règle intégrée, et retire la garantie « toi seul » au canal qu'elle
1432
+ couvre. À n'écrire qu'en connaissance de cause : [Sécurité](securite.md).
1433
+
1434
+ ### Tâche d'agent
1435
+
1436
+ Une tâche longue — une analyse, une génération — dont le demandeur suit la progression.
1437
+
1438
+ <!-- example: agentTask.ts -->
1439
+
1440
+ ```ts
1441
+ import { randomUUID } from "node:crypto";
1442
+ import { Inject, Module, Service, injectable } from "nodefony";
1443
+ import type { IChannelAdmission, ISubscriberIdentity } from "nodefony";
1444
+ import { controller, route } from "@nodefony/framework";
1445
+ import type { ContextType } from "@nodefony/http";
1446
+ import {
1447
+ RealtimeChannel,
1448
+ RealtimeController,
1449
+ RealtimeInbound,
1450
+ getRealtimeHub,
1451
+ } from "@nodefony/realtime";
1452
+ import type { IRealtimeConnection } from "@nodefony/realtime";
1453
+
1454
+ /**
1455
+ * Les tâches longues : qui a demandé quoi, et le travail lui-même. Chaque
1456
+ * tâche a son canal, nommé par le SERVEUR ; seul son demandeur le suit.
1457
+ */
1458
+ @injectable()
1459
+ export class TaskService extends Service {
1460
+ /** Tâche en cours → son demandeur. */
1461
+ readonly #owners = new Map<string, string>();
1462
+
1463
+ constructor(module: Module) {
1464
+ super("tasks", module.container ?? undefined, module.notificationsCenter);
1465
+ }
1466
+
1467
+ /** Ouvre une tâche pour ce compte ; rend le nom de son canal, jamais choisi par le client. */
1468
+ open(owner: string): string {
1469
+ const task = `task:${randomUUID()}`;
1470
+ this.#owners.set(task, owner);
1471
+ return task;
1472
+ }
1473
+
1474
+ /** Ce compte a-t-il demandé cette tâche ? */
1475
+ isOwner(task: string, user: string | null): boolean {
1476
+ return user !== null && this.#owners.get(task) === user;
1477
+ }
1478
+
1479
+ /** La tâche tourne-t-elle encore ? Finie, elle n'a plus de canal. */
1480
+ isRunning(task: string): boolean {
1481
+ return this.#owners.has(task);
1482
+ }
1483
+
1484
+ /** Le travail long : il publie sa progression, puis termine le canal pour tous. */
1485
+ async run(task: string): Promise<void> {
1486
+ const hub = getRealtimeHub();
1487
+ for (const step of ["lecture", "analyse", "rédaction"]) {
1488
+ hub.publish(task, { step });
1489
+ await new Promise((resolve) => setTimeout(resolve, 0)); // le vrai travail
1490
+ }
1491
+ this.#owners.delete(task);
1492
+ hub.closeChannel(task); // le client reçoit `realtime:closed { reason: "ended" }`
1493
+ }
1494
+ }
1495
+
1496
+ /** Qui suit `task:*` : le demandeur de la tâche, et lui seul. */
1497
+ @injectable()
1498
+ export class TaskAdmission extends Service implements IChannelAdmission {
1499
+ constructor(@Inject("TaskService") private readonly tasks: TaskService) {
1500
+ super("taskAdmission");
1501
+ }
1502
+
1503
+ allow(channel: string, who: ISubscriberIdentity): boolean {
1504
+ return this.tasks.isOwner(channel, who.id);
1505
+ }
1506
+ }
1507
+
1508
+ // Sa socket : `/api/task/realtime` (préfixe + route). Elle ne sert que les canaux déclarés ici.
1509
+ @controller("/api/task")
1510
+ export class TaskRealtime extends RealtimeController {
1511
+ // Instancié PAR CONNEXION : les tâches vivent dans le service, le même pour tous.
1512
+ constructor(
1513
+ context: ContextType,
1514
+ @Inject("TaskService") private readonly tasks: TaskService,
1515
+ ) {
1516
+ super("TaskRealtime", context);
1517
+ }
1518
+
1519
+ // Le point d'entrée : `handleRealtime` fait tout le protocole et route vers les décorateurs.
1520
+ @route("task-realtime", {
1521
+ path: "/realtime",
1522
+ requirements: { methods: ["WEBSOCKET"] },
1523
+ })
1524
+ async realtime(message: string | Uint8Array | null): Promise<void> {
1525
+ this.handleRealtime(message);
1526
+ }
1527
+
1528
+ @RealtimeChannel("task:*", { allow: TaskAdmission })
1529
+ task(): () => void {
1530
+ return () => {};
1531
+ }
1532
+
1533
+ // Une tâche finie n'a plus de canal.
1534
+ override createRealtimeChannel(channel: string): (() => void) | null {
1535
+ return this.tasks.isRunning(channel) ? () => {} : null;
1536
+ }
1537
+
1538
+ @RealtimeInbound("task:start", { authenticated: true })
1539
+ async start(
1540
+ _params: unknown,
1541
+ reply: (payload: object) => void,
1542
+ connection: IRealtimeConnection,
1543
+ ): Promise<void> {
1544
+ if (connection.userId === null) return;
1545
+ const task = this.tasks.open(connection.userId);
1546
+ if (await getRealtimeHub().subscribeConnection(task, connection)) {
1547
+ reply({ task }); // le navigateur apprend le nom du canal qu'il suit déjà
1548
+ void this.tasks.run(task);
1549
+ }
1550
+ }
1551
+ }
1552
+ ```
1553
+
1554
+ Côté navigateur, la réponse donne le nom du canal, que la socket suit déjà :
1555
+
1556
+ <!-- example: agentTask.client.ts -->
1557
+
1558
+ ```ts
1559
+ import { NodefonySocket } from "nodefony/client";
1560
+
1561
+ const socket = NodefonySocket.shared({ url: "/api/task/realtime" });
1562
+
1563
+ /** Démarre une tâche : le serveur nomme le canal, y abonne, puis le termine. */
1564
+ export function startTask(
1565
+ onStep: (step: unknown) => void,
1566
+ onDone: () => void,
1567
+ ): void {
1568
+ socket.on("task:start", (reply) => {
1569
+ // Une charge reçue du réseau se vérifie avant usage.
1570
+ if (typeof reply !== "object" || reply === null || !("task" in reply))
1571
+ return;
1572
+ if (typeof reply.task === "string") socket.on(reply.task, onStep);
1573
+ });
1574
+ socket.onClosed(({ channel, reason }) => {
1575
+ if (channel.startsWith("task:") && reason === "ended") onDone();
1576
+ });
1577
+ socket.emit("task:start");
1578
+ }
1579
+ ```
1580
+
1581
+ Le test suit la progression jusqu'à la fin, puis tente de deviner le canal d'un autre :
1582
+
1583
+ <!-- example: agentTask.test.ts -->
1584
+
1585
+ ```ts
1586
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
1587
+ import { getRealtimeHub } from "@nodefony/realtime";
1588
+ import { createRealtimeHarness } from "@nodefony/realtime/testing";
1589
+ import type { IRealtimeHarness } from "@nodefony/realtime/testing";
1590
+ import { createTestIdentity } from "@nodefony/security/testing";
1591
+ import { createTestModule } from "nodefony/testing";
1592
+ import { TaskAdmission, TaskRealtime, TaskService } from "./agentTask";
1593
+
1594
+ const open: IRealtimeHarness<TaskRealtime>[] = [];
1595
+ const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
1596
+ let tasks: TaskService;
1597
+
1598
+ async function connect(user: string): Promise<IRealtimeHarness<TaskRealtime>> {
1599
+ // Le service et l'admission passés à la main, comme l'injecteur en production.
1600
+ const h = createRealtimeHarness((ctx) => new TaskRealtime(ctx, tasks), {
1601
+ identity: createTestIdentity({ identifier: user }),
1602
+ admissions: [new TaskAdmission(tasks)],
1603
+ resetHub: open.length === 0,
1604
+ });
1605
+ await h.connect();
1606
+ open.push(h);
1607
+ return h;
1608
+ }
1609
+
1610
+ const closedOf = (h: IRealtimeHarness<TaskRealtime>): unknown[] =>
1611
+ h.received.filter((f) => f.method === "realtime:closed").map((f) => f.params);
1612
+
1613
+ describe("tâche d'agent — le demandeur suit SA progression, puis reçoit `ended`", () => {
1614
+ beforeEach(() => {
1615
+ tasks = new TaskService(createTestModule());
1616
+ });
1617
+ afterEach(() => {
1618
+ for (const h of open.splice(0)) h.close();
1619
+ getRealtimeHub().clear();
1620
+ });
1621
+
1622
+ it("progression puis fin, sur un canal que le serveur a nommé", async () => {
1623
+ const alice = await connect("alice");
1624
+ await alice.notify("task:start");
1625
+ for (let i = 0; i < 10; i++) await flush();
1626
+ const [task] = alice.subscriptions();
1627
+ expect(task).toMatch(/^task:/);
1628
+ expect(alice.messages("task:start")).toEqual([{ task }]);
1629
+ expect(alice.messages(task ?? "")).toEqual([
1630
+ { step: "lecture" },
1631
+ { step: "analyse" },
1632
+ { step: "rédaction" },
1633
+ ]);
1634
+ expect(closedOf(alice)).toEqual([{ channel: task, reason: "ended" }]);
1635
+ });
1636
+
1637
+ it("un autre compte ne suit pas la tâche d'alice, même en devinant son nom", async () => {
1638
+ const alice = await connect("alice");
1639
+ await alice.notify("task:start");
1640
+ const [task] = alice.subscriptions(); // la tâche tourne encore
1641
+ const mallory = await connect("mallory");
1642
+ await mallory.subscribe(task ?? "task:devine");
1643
+ expect(mallory.denials()).toMatchObject([{ reason: "forbidden" }]);
1644
+ });
1645
+ });
1646
+ ```
1647
+
1648
+ **Pourquoi ce choix.** Le serveur choisit le nom du canal et y abonne le demandeur : personne ne
1649
+ peut deviner, ni imposer, le nom de la tâche d'un autre. La fin se dit par `closeChannel`, que le
1650
+ navigateur reçoit en `realtime:closed { reason: "ended" }` — pas par un message « fini » que le
1651
+ client devrait reconnaître.
1652
+
1653
+ **Le piège.** Réutiliser un nom de canal d'une tâche à l'autre (`task:alice`) : un onglet resté
1654
+ ouvert sur la précédente recevrait la progression de la suivante.
1655
+
1656
+ ### Appel et voix
1657
+
1658
+ En conception : le transport du son sur la socket, et la conversation à la voix avec un agent,
1659
+ font l'objet de travaux en cours. Cette section renverra à leurs pages quand elles existeront ; d'ici
1660
+ là, n'en anticipez pas l'interface.
1661
+
1662
+ ## ⚠️ Pièges
1663
+
1664
+ - **Recomposer ce que le framework porte** : une liste noire à côté d'`allow`, un contrôle de rôle
1665
+ recopié dans chaque gestionnaire, un canal `user:` écrit à la main. Chacun compile ; chacun se
1666
+ désynchronise.
1667
+ - **Mettre en file ce qui est un état** (une position, une jauge) : seule la dernière valeur compte.
1668
+ Les événements où chaque message compte (un message de chat) suivent l'autre règle.
1669
+ - **Laisser le client nommer un canal privé** : le serveur le nomme, `allow` le garde.
1670
+ - **Écrire un exemple sans politique** : un canal sans politique est public. Chaque exemple
1671
+ ci-dessus en déclare une.
1672
+
1673
+ ## 🔩 Où c'est fait
1674
+
1675
+ - Entrée client par canal : `realtimeDecorators.ts#RealtimeInbound` ; canal servi :
1676
+ `realtimeDecorators.ts#RealtimeChannel`.
1677
+ - Abonner d'office : `RealtimeHub.ts#RealtimeHub.subscribeConnection` ; éjecter un compte :
1678
+ `RealtimeHub.ts#RealtimeHub.evictUser` ; terminer : `RealtimeHub.ts#RealtimeHub.closeChannel`.
1679
+ - Canal personnel : `RealtimeHub.ts#RealtimeHub.publishToUser`, et côté navigateur
1680
+ `NodefonySocket.ts#NodefonySocket.onUser`.
1681
+ - Contre-pression du navigateur : `NodefonySocket.ts#NodefonySocket.admitOutbound` (jette au-delà de
1682
+ `maxBufferedAmount`).
1683
+
1684
+ ## 🧪 Tests & couverture
1685
+
1686
+ - **Exemples exécutés** : chaque bloc serveur et navigateur de cette page est un fichier de
1687
+ `nodefony/tests/fixtures/use-cases/` (le navigateur, typé), chaque bloc de test un fichier de
1688
+ `nodefony/tests/unit/use-cases/`, exécuté — dernière position seule et diffusion au tick,
1689
+ appartenance qui survit à la reconnexion, exclu qui ne revient pas, auteur pris sur la connexion,
1690
+ mesure malformée écartée, un minuteur pour N écrans, notification qui n'atteint que sa
1691
+ destinataire, progression puis `ended`.
1692
+ - **Page et fichiers identiques** (`useCases.test.ts`) : chaque bloc marqué de cette page est
1693
+ comparé au fichier du dépôt, et chaque cas doit montrer ses trois fichiers ; un exemple corrigé
1694
+ d'un seul côté fait échouer la suite.
1695
+
1696
+ ## 🔗 Pour aller plus loin
1697
+
1698
+ - ⬆️ **Retour au hub** : [Realtime — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
1699
+ - [Canal à membres](canaux-a-membres.md) — abonner d'office, éjecter, terminer.
1700
+ - [Sécurité](securite.md) — `allow`, ses formes, son délai ; le canal `user:<id>`.
1701
+ - [Cadence et contre-pression](cadence-et-contre-pression.md) — bornes d'envoi et de réception.
1702
+ - Décisions : ADR-0022 §5 (contre-pression, bornes) et §6 (canaux privés).