@nodefony/devkit 10.0.0-beta.5 → 10.0.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
  3. package/dist/index.js +3 -3
  4. package/dist/nodefony/controllers/DevkitController.js +1 -1
  5. package/dist/nodefony/controllers/McpController.js +2 -2
  6. package/dist/nodefony/controllers/ProfileSessionController.js +1 -1
  7. package/dist/nodefony/mcp/profileRoute.js +28 -9
  8. package/dist/nodefony/service/DevkitService.js +6 -6
  9. package/docs/index.md +1 -1
  10. package/package.json +13 -13
  11. package/skills/nodefony-add-crud/SKILL.md +38 -23
  12. package/skills/nodefony-add-grpc-service/SKILL.md +179 -0
  13. package/skills/nodefony-add-grpc-service/scripts/call.mjs +218 -0
  14. package/skills/nodefony-add-realtime-binary/SKILL.md +301 -0
  15. package/skills/nodefony-add-realtime-binary/references/messages.md +20 -0
  16. package/skills/nodefony-add-realtime-binary/references/mesures.md +59 -0
  17. package/skills/nodefony-add-realtime-channel/SKILL.md +452 -105
  18. package/skills/nodefony-add-realtime-channel/references/contrat.md +22 -0
  19. package/skills/nodefony-add-realtime-channel/references/production.md +140 -0
  20. package/skills/nodefony-add-realtime-presence/SKILL.md +328 -0
  21. package/skills/nodefony-authentication/SKILL.md +2 -2
  22. package/skills/nodefony-browser/SKILL.md +37 -2
  23. package/skills/nodefony-browser/references/sondes.md +19 -0
  24. package/skills/nodefony-browser/scripts/audit.mjs +17 -2
  25. package/skills/nodefony-browser/scripts/inspect.mjs +108 -87
  26. package/skills/nodefony-browser/scripts/lib/actions.mjs +164 -0
  27. package/skills/nodefony-browser/scripts/lib/browser.mjs +67 -11
  28. package/skills/nodefony-browser/scripts/lib/probes.mjs +108 -3
  29. package/skills/nodefony-browser/scripts/watch.mjs +27 -11
  30. package/skills/nodefony-dev/SKILL.md +31 -15
  31. package/skills/nodefony-dev/references/rapport-de-build.md +19 -0
  32. package/skills/nodefony-devops/SKILL.md +28 -21
  33. package/skills/nodefony-devops/references/dimensionnement.md +1 -1
  34. package/skills/nodefony-devops/references/frontal.md +5 -2
  35. package/skills/nodefony-devops/references/image.md +23 -15
  36. package/skills/nodefony-migrate-schema/SKILL.md +110 -20
  37. package/skills/nodefony-migrate-schema/references/verdicts.md +23 -20
  38. package/skills/nodefony-protect-route/SKILL.md +27 -32
@@ -1,161 +1,508 @@
1
1
  ---
2
2
  name: nodefony-add-realtime-channel
3
+ metadata:
4
+ version: 3.1.0
3
5
  description: >
4
- Ajoute un flux temps réel à une application Nodefony par la bonne couche — un
5
- `RealtimeController` et ses décorateurs de canal — au lieu de recomposer un WebSocket à la main.
6
- Porte la façon de fermer un canal à certains rôles, le piège du canal public par défaut, celui
7
- du canal dont le nom est calculé, et la façade cliente à employer côté navigateur. À charger
8
- AVANT d'écrire du code WebSocket, un canal, ou un abonnement client.
9
- Dit aussi quand un flux SSE suffit, et sa façade.
10
- Déclencheurs : "flux temps réel", "websocket", "canal", "push", "notifications en direct",
11
- "abonnement", "RealtimeController", "@RealtimeChannel", "socket client", "temps réel privé",
12
- "réserver un canal à un rôle", "mon canal est public", "diffuser à plusieurs onglets",
13
- "SSE", "server-sent events", "EventSource", "barre de progression", "réponse en streaming".
6
+ Branche le temps réel d'une application Nodefony par ses briques — canal, route appelée par la
7
+ socket, entrée client, action, canal personnel, flux SSE, contrat typé — au lieu d'un WebSocket
8
+ ou d'un contrôle d'accès écrits à la main. Part du besoin (chat, salon privé, jeu, sondes,
9
+ notification, tâche longue), mène à la ligne, à l'exemple exécuté et à son test, dit qui a le
10
+ droit de LIRE et d'ÉCRIRE, et porte la production : plusieurs serveurs, reconnexion,
11
+ redéploiement, révocation. À charger AVANT d'écrire un canal, un abonnement ou un flux. Qui est
12
+ en ligne → `nodefony-add-realtime-presence` ; des octets → `nodefony-add-realtime-binary`.
13
+ Déclencheurs : "temps réel", "websocket", "canal", "push", "chat", "salon privé", "exclure un
14
+ membre", "l'exclu revient", "réserver un canal à un rôle", "mon canal est public", "prévenir un
15
+ utilisateur", "progression d'une tâche", "SSE", "tableau de bord en direct", "-32601", "ça marche en
16
+ local, plus en cluster", "le canal ne répond jamais".
14
17
  ---
15
18
 
16
- # add-realtime-channel — le temps réel par sa couche
19
+ # add-realtime-channel — le temps réel par sa brique
17
20
 
18
- > 🧭 **Tu es arrivé ici directement ? Charge aussi `nodefony-dev`** — il porte la conduite
19
- > commune (par où commencer, comment prouver que c'est fait) et les pièges qui coûtent une heure,
20
- > serveur comme front. Cette page-ci ne couvre QUE son geste.
21
+ > 🧭 **Arrivé ici directement ? Charge aussi `nodefony-dev`** : il porte la conduite commune (par
22
+ > où commencer, comment prouver, les pièges du serveur et du front). Cette page ne couvre QUE le
23
+ > temps réel.
21
24
  >
22
- > Et si une réponse te manque, elle est probablement INSTALLÉE : `rg` ne descend pas dans
23
- > `node_modules`, donc 70 pages de documentation y paraissent absentes. Une commande les lit, avec
24
- > la ligne exacte :
25
- > `node node_modules/@nodefony/devkit/skills/nodefony-dev/scripts/docs.mjs <termes>`.
26
-
27
- > ⚖️ **La confiance n'exclut pas le contrôle.** Un canal sans politique est **public par
28
- > construction** — c'est le comportement voulu du framework, pas un oubli. Ce qui n'est pas
29
- > déclaré fermé est ouvert.
30
-
31
- ## Le geste
25
+ > Les pages de référence sont INSTALLÉES (`rg` ne descend pas dans `node_modules`). Une commande
26
+ > les trouve et rend leur chemin — **exécute-la, puis lis la page** :
27
+ >
28
+ > ```bash
29
+ > node node_modules/@nodefony/devkit/skills/nodefony-dev/scripts/docs.mjs --open cookbook-chat
30
+ > node node_modules/@nodefony/devkit/skills/nodefony-dev/scripts/docs.mjs salon allow evict
31
+ > ```
32
+
33
+ > ⚖️ **La confiance n'exclut pas le contrôle.** Un **canal** sans politique est public par
34
+ > construction ; une **action** sans politique est fermée (connexion authentifiée exigée). Ce qui
35
+ > n'est pas déclaré n'est pas gardé — et ce que le framework porte déjà ne se réécrit pas.
36
+
37
+ > **Maintenance** : vérité courante, jamais un journal. Éditer en place ; historique = `git log`.
38
+ > Chaque bloc de code est un EXTRAIT d'un fichier exécuté par la suite du framework (marqueur
39
+ > `excerpt`) : un test refuse qu'il en diverge. Une page installée en montre le fichier entier.
40
+ >
41
+ > **La démo** (première implémentation complète, chat compris) : dans ton application si elle a été
42
+ > créée avec la démo, sinon ses gabarits sous `node_modules/nodefony/templates/demo/` (fichiers
43
+ > `.tpl`, lisibles tels quels) — le chat : `server/nodefony/controllers/ChatController.ts.tpl`.
44
+
45
+ ## 1. Quel besoin → quelle brique
46
+
47
+ | Je veux… | La brique (et rien d'autre) | Où le voir |
48
+ | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
49
+ | un **chat** : des messages gardés, des salons, des droits | lire par un **canal**, écrire par une **route** appelée par la socket (§3) | `cookbook-chat.md`, le chat de la démo |
50
+ | pousser un flux que des écrans lisent | `@RealtimeChannel("nom")` ; un producteur par canal et par serveur, créé au premier abonné | `cas-d-usage.md` (tableau de bord) |
51
+ | le réserver à un rôle | `@RealtimeChannel("nom", { roles: ["ROLE_X"] })` | `securite.md` |
52
+ | une famille de canaux (`room:<id>`, `task:<id>`) | motif `"room:*"` — il sert chaque nom ; `createRealtimeChannel()` seulement pour FILTRER (`null` = refus) | §4 |
53
+ | **ce compte-là et lui seul** (salon privé, invitation) | `allow` sur le motif, écrit en **classe d'admission** qui lit la base (§5) | `cas-d-usage.md` (salon privé : son ADMISSION ; l'écriture suit §3, pas son `room:send`) |
54
+ | exclure quelqu'un, pour de bon | retirer l'appartenance (`allow` dira non), PUIS `getRealtimeHub().evictUser(canal, id)` — jamais de liste noire | `canaux-a-membres.md` |
55
+ | placer quelqu'un dans un canal sans qu'il s'abonne | `getRealtimeHub().subscribeConnection(canal, connection)` depuis une entrée client, après avoir écrit l'appartenance | `cas-d-usage.md` (recherche de partie) |
56
+ | **qui est en ligne**, combien, « en train d'écrire » | l'option `presence` du canal | skill `nodefony-add-realtime-presence` |
57
+ | prévenir UNE personne, tous ses onglets, tous les serveurs | `getRealtimeHub().publishToUser(id, charge)` / `socket.onUser(h)` — rien à déclarer | `cas-d-usage.md` (notification) |
58
+ | suivre une tâche longue (agent, export) | le serveur nomme `task:<uuid>`, impose l'abonnement, publie, puis `closeChannel()` → `ended` | `cas-d-usage.md` (tâche d'agent) |
59
+ | recevoir un flux FRÉQUENT du client (coups de jeu, mesures) | `@RealtimeInbound("game:steer", politique)` — sans réponse ; seule la dernière valeur compte (§4) | `cas-d-usage.md` (jeu, sondes) |
60
+ | un appel avec réponse qui ne change rien (un devis) | `@RealtimeAction("x:y")` (fermée par défaut) — ou une route `GET` | `actions.md` |
61
+ | le serveur pousse, le client écoute, sur UNE requête | `this.renderSse()` dans une action ordinaire ; `new NodefonySse(url)` côté client (§8) | `http/docs/sse.md` |
62
+ | typer les deux bouts une fois | `interface C extends IRealtimeContract` + `NodefonySocket.fromContract<C>()` + `ContractRealtimeController<C>` (§9) | `actions.md` (contrat) |
63
+ | des **octets** (image, audio, mesures binaires, document) | le codec CBOR et un `Uint8Array` dans la charge | skill `nodefony-add-realtime-binary` |
64
+ | ça marche avec deux onglets, plus en cluster | `@RealtimeBroadcast("prefix:")` sur la classe (défaut : un canal reste sur son serveur) | `references/production.md` |
65
+
66
+ Chemins complets : `node_modules/@nodefony/realtime/docs/<page>.md`,
67
+ `node_modules/@nodefony/http/docs/sse.md`, `node_modules/nodefony/docs/client.md`.
68
+
69
+ ## 2. Le geste
32
70
 
33
71
  ```bash
34
- npx nodefony create controller Ops --kind realtime
72
+ npx nodefony create controller Room --kind realtime
35
73
  ```
36
74
 
37
- Produit une sous-classe de `RealtimeController` avec son canal décoré et une action, plus le
38
- câblage. **N'écris pas de `new WebSocket(...)` ni de `ws.on(...)` côté serveur** : le bas niveau
39
- existe, il est employé par le framework, et le reprendre à la main te prive du routage par canal,
40
- de l'authentification partagée avec HTTP, et de la contre-pression.
75
+ Produit une sous-classe de `RealtimeController` avec un canal de démonstration, une action, une
76
+ entrée client, et **son test** (`createRealtimeHarness`). Nomme le contrôleur par son USAGE
77
+ (`RoomChat`, `Arena`), pas par une entité : `create entity Room` après `create controller Room`
78
+ est refusé (nom déjà référencé). Retires-tu le canal de démonstration ? Adapte son test dans la
79
+ foulée — ne laisse pas un banc rouge que tu finirais par ignorer.
80
+
81
+ 🔴 **`/api` est FERMÉ dans une application générée** (zone `main` sans `anonymous`) : sans
82
+ session, l'ouverture de `/api/room/realtime` reçoit 401 — et un WebSocket refusé ne LÈVE rien côté
83
+ client, il attend puis expire. Un canal qui « ne répond jamais » se cherche dans la zone avant le
84
+ réseau. Canal public voulu : une zone au pattern ÉNUMÉRÉ terminé par `(/|$)` (skill
85
+ `nodefony-protect-route`), jamais `anonymous` remis sur `main`.
41
86
 
42
- ## WebSocket, ou un simple flux SSE ?
87
+ 🔴 **Une socket ne sert QUE les canaux du contrôleur qui porte sa route WebSocket** : ouverte sur
88
+ `/api/arena/realtime`, `room:42` est refusé (`unknown`). Une socket par page : regroupe les canaux
89
+ dans un même contrôleur. Seul `user:<id>` est fourni par toutes.
43
90
 
44
- | Besoin | Choisir |
45
- | -------------------------------------------------------------- | ------------------------------------------- |
46
- | Le serveur pousse, le client écoute (progression, journal) | **SSE** — une réponse HTTP qui ne finit pas |
47
- | La réponse d'UNE requête arrive par morceaux (modèle, export) | **SSE** sur cette requête |
48
- | Échange dans les deux sens, canaux, état partagé entre onglets | **WebSocket** — le reste de cette page |
91
+ **N'écris ni `new WebSocket(...)` côté serveur, ni `ws.on(...)`, ni un `WebSocketServer`** : tu
92
+ perdrais le routage par canal, l'identité partagée avec HTTP, la reconnexion et la contre-pression.
49
93
 
50
- SSE n'est pas une route d'un genre particulier : l'action d'une route ordinaire choisit de
51
- répondre en flux.
94
+ ## 3. Le patron d'un chat : LIRE par le canal, ÉCRIRE par une route
95
+
96
+ C'est la forme du chat de la démo, première implémentation complète du temps réel Nodefony. Le
97
+ canal porte ce qui se DIFFUSE ; chaque GESTE qui change un état (envoyer, modifier, inviter) est
98
+ une **route**, que le navigateur appelle **sur la même socket** — `socket.post()` envoie une trame,
99
+ pas une requête HTTP de plus.
100
+
101
+ **1 — ouvrir le pont** : la route WebSocket du contrôleur, et `realtimeApiRequest()` qui rend
102
+ joignables par la socket les routes qui déclarent `WEBSOCKET` :
103
+
104
+ <!-- excerpt: demo/server/nodefony/controllers/ChatController.ts.tpl -->
52
105
 
53
106
  ```ts
54
- @route("progress-run", { path: "/run" })
55
- async run() {
56
- const sse = await this.renderSse(); // en-têtes, battement, contre-pression
57
- await sse.send({ step: "calcul" }, { event: "step" });
58
- return sse.close();
59
- }
107
+ /** Ouvre le pont : les actions ci-dessous deviennent joignables par la socket. */
108
+ protected override realtimeApiRequest(): boolean {
109
+ return true;
110
+ }
111
+
112
+ /** La porte WebSocket : `handleRealtime` porte tout le protocole. */
113
+ @route("demo-chat-realtime", {
114
+ path: "/realtime",
115
+ requirements: { methods: ["WEBSOCKET"] },
116
+ })
117
+ async realtime(message: string | Uint8Array | null): Promise<void> {
118
+ this.handleRealtime(message);
119
+ }
60
120
  ```
61
121
 
62
- `this.acceptsSse()` dit si le client a demandé `text/event-stream` (une même route peut répondre
63
- en JSON ou en flux). Côté navigateur, `new NodefonySse(url)` (`nodefony/client`), qui se lit comme
64
- `EventSource` ; les liaisons de front existent comme pour la socket. **N'écris jamais
65
- `Content-Type: text/event-stream` à la main** : tu perdrais le battement qui empêche un proxy de
66
- couper la connexion, et la contre-pression (`await sse.send()` freine si le client lit lentement).
67
- Détail : `node_modules/@nodefony/http/docs/sse.md`.
122
+ **2 — le geste d'écriture est une route** : schéma validé, droit vérifié, rejeu sans doublon,
123
+ réponse à l'expéditeur :
68
124
 
69
- ## Fermer un canal
125
+ <!-- excerpt: demo/server/nodefony/controllers/ChatController.ts.tpl -->
70
126
 
71
127
  ```ts
72
- @RealtimeChannel("ops:alerts", { roles: ["ROLE_ADMIN"] })
73
- export class OpsController extends RealtimeController {
74
- @RealtimeAction("ops:snapshot", { roles: ["ROLE_ADMIN"] })
75
- async snapshot() { … }
76
- }
128
+ @route("demo-chat-send", {
129
+ path: "/rooms/{room}/send",
130
+ requirements: { methods: ["POST", "WEBSOCKET"] },
131
+ schema: { params: RoomParams, body: SendBody },
132
+ })
133
+ @IsGranted(["ROLE_USER"])
134
+ @Idempotent()
135
+ async send(
136
+ input: RoomInput<{ body: typeof SendBody }>,
137
+ ): Promise<{ id: string; sentAt: number }> {
138
+ const me = this.#writer();
139
+ await this.#admitted(input.params.room);
77
140
  ```
78
141
 
79
- Deux niveaux, et ils sont indépendants : la **politique du canal** décide qui peut s'abonner, la
80
- politique d'une **action** décide qui peut la déclencher. `{ authenticated: true }` suffit quand
81
- aucun rôle particulier n'est requis.
142
+ **3 — le service persiste PUIS publie** sur le canal du salon (seule voie d'écriture : la route,
143
+ l'envoi de pièces jointes et un outil MCP l'appellent) :
144
+
145
+ <!-- excerpt: demo/server/nodefony/service/ChatService.ts.tpl -->
82
146
 
83
- ## 🔴 Le piège du canal dont le nom est calculé
147
+ ```ts
148
+ async publish(roomId: string, draft: IMessageDraft): Promise<ChatMessage> {
149
+ const message = await this.#messages().archive(roomId, draft);
150
+ publishFrame(roomId, { kind: "message", message });
151
+ ```
84
152
 
85
- La politique d'un `@RealtimeChannel` est indexée par le nom **EXACT** du canal. Un canal dont le
86
- nom se construit à l'exécution (`` `room:${id}` ``) **n'est couvert par aucun décorateur** — il
87
- naît public, et rien ne le signale.
153
+ **4 — le navigateur écrit par la socket** ; l'auteur, c'est le serveur qui le pose :
154
+
155
+ <!-- excerpt: demo/front/frontend/src/chat/useChat.ts.tpl -->
88
156
 
89
157
  ```ts
90
- @RealtimeChannel("room:lobby", { authenticated: true }) // ✅ couvert
91
- // `room:42` construit à la volée // 🔴 PAS couvert par le décorateur
158
+ await socket.post(
159
+ "/demo/chat/rooms/{room}/send",
160
+ replyTo === null ? { text } : { text, replyTo },
161
+ { params: { room: roomId } },
162
+ );
92
163
  ```
93
164
 
94
- La parade est une règle **par préfixe** dans la configuration de sécurité, qui garde à la fois
95
- les canaux ET les actions dont le nom commence ainsi :
165
+ Ce que la route donne et qu'une entrée de canal n'a pas : identité **revérifiée** à chaque appel
166
+ (une entrée se fie au verdict retenu sur la connexion), corps validé par schéma (422), rejeu sans
167
+ doublon (`@Idempotent`, la socket pose la clé), une **réponse** (le message enregistré, ou
168
+ 403/409/429 — une entrée ne répond rien), et la même route sert en HTTP simple (réseau qui bloque
169
+ les WebSocket, test, agent). La table `serverRoutes` du contrôleur déclare ces routes dans le
170
+ contrat, et le démarrage refuse une route du contrat sans action.
171
+
172
+ **Route ou entrée de canal ?** Le geste a-t-il besoin d'une réponse, d'un schéma, d'une identité
173
+ revérifiée, d'un rejeu sûr ou d'un contrôle métier (appartenance au salon) ? → **route**. Un flux
174
+ fréquent sans réponse, où seule la dernière valeur compte (direction d'un joueur, mesure d'une
175
+ sonde, curseur) ? → **entrée** (§4). La frappe « en train d'écrire » d'un salon PRIVÉ passe par une
176
+ route dans la démo, parce qu'elle doit vérifier l'admission au salon.
177
+
178
+ - **Un seul chemin d'affichage** : la route ne rend que l'identifiant (`{ id, sentAt }`) ; l'écran
179
+ n'ajoute le message qu'à sa DIFFUSION sur le canal, dédoublonnée par cet identifiant. Afficher la
180
+ réponse ET la diffusion ferait voir le message deux fois.
181
+ - **L'historique se lit APRÈS l'abonnement effectif** : `await socket.whenSubscribed(canal)` puis
182
+ la route `GET …/history` (`GET` + `WEBSOCKET`). Lu avant, un message publié pendant qu'`allow`
183
+ lit la base n'arrive par aucun des deux chemins. Même geste à chaque reconnexion : le client
184
+ rejoue ses abonnements, pas les messages manqués.
185
+ - **Rattrape sur l'identifiant croissant de la base, jamais sur un horodatage** : deux serveurs ont
186
+ deux horloges, et un curseur `sentAt > x` perd les messages de la même milliseconde. Une page
187
+ relue qui ne recoupe aucun identifiant connu REMPLACE la liste (trou) au lieu de la fusionner.
188
+ - **Borne le débit d'écriture par compte** : le transport ne le fait pas par défaut, et un compte
189
+ connecté peut sinon boucler l'envoi. `#writer()` ci-dessus compte le geste AVANT toute lecture
190
+ en base ; au-delà, l'action pose `this.response?.setHeader("Retry-After", n)` puis lève un
191
+ `HttpError` 429. Le MÊME code dit l'attente sur les deux transports : l'en-tête en HTTP,
192
+ `error.data.retryAfter` (secondes, RFC 9110 §10.2.3) sur la socket, qui n'a pas d'en-tête — et
193
+ seul cet en-tête-là passe sur une trame. La démo tient une fenêtre fixe par compte
194
+ (`nodefony/kit/rateLimit.ts`, `take(clé)` rend les secondes à attendre), un quota séparé pour la
195
+ frappe (épuiser l'un ne coupe pas l'autre).
196
+ - **Les fichiers ne passent pas par la socket** : la démo envoie ses pièces jointes en `POST`
197
+ multipart (avec `Idempotency-Key`), puis publie le message qui les cite. La route qui SERT une
198
+ pièce d'un salon privé vérifie l'admission au salon comme les autres — un identifiant
199
+ imprévisible n'est pas une autorisation.
200
+
201
+ ## 4. Les mots du serveur — canal, fabrique, entrée, action
202
+
203
+ <!-- excerpt: gameMoves.ts -->
96
204
 
97
205
  ```ts
98
- use("@nodefony/security", {
99
- realtimeChannels: [
100
- { pattern: "room:", authenticated: true, roles: ["ROLE_USER"] },
101
- ],
102
- });
206
+ // Le joueur envoie ce qu'il VEUT faire (une direction), jamais où il EST.
207
+ // Le rôle est contrôlé à la première commande, puis retenu sur la connexion.
208
+ @RealtimeInbound("game:steer", { roles: ["ROLE_PLAYER"] })
209
+ steer(
210
+ params: unknown,
211
+ _reply: unknown,
212
+ connection: IRealtimeConnection,
213
+ ): void {
214
+ if (connection.userId === null || !ArenaRealtime.isDirection(params)) {
215
+ return;
216
+ }
217
+ this.arena.steer(connection.userId, params);
218
+ }
219
+
220
+ // Le moteur démarre au PREMIER joueur abonné et s'arrête au DERNIER : la
221
+ // fonction rendue est l'arrêt. Personne d'autre ne l'appelle.
222
+ @RealtimeChannel("game:state", { roles: ["ROLE_PLAYER"] })
223
+ state(channel: string, publish: RealtimePublish): () => void {
224
+ return this.arena.start((state) => publish(channel, state));
225
+ }
103
226
  ```
104
227
 
105
- Elle exprime une classe d'identité (connecté, rôles), jamais une liste de membres : « seuls les
106
- participants de la salle 42 » ne s'écrit pas encore dans le framework. Ne le simule pas avec un
107
- nom de canal difficile à deviner.
228
+ - **Le contrôleur est instancié PAR CONNEXION** : l'état partagé (le jeu, les salons) vit dans un
229
+ service `@injectable`, injecté par son nom (`@Inject("ArenaService")`). La fabrique d'un canal
230
+ est partagée entre connexions : elle ne capture jamais `this.context`.
231
+ - **`params` vient du réseau** : valide-le. L'auteur est `connection.userId`, jamais un champ de
232
+ la charge.
233
+ - **Publier depuis n'importe où** (service, tâche, minuteur) : `getRealtimeHub().publish(canal,
234
+ charge)`. La charge est un objet, un tableau, des octets ou rien — **un scalaire lève**
235
+ (`publish("c", 42)` → `TypeError`) : écris `{ value: 42 }`. Un canal au nom calculé se publie par
236
+ son nom (``getRealtimeHub().publish(`room:${id}`, charge)``).
237
+ - **État ou événement ?** Une position, une jauge : seule la dernière valeur compte — garde-la et
238
+ diffuse au rythme du serveur (`setInterval`, `unref()`) — un relais direct à chaque réception ne se
239
+ tolère qu'à faible cadence (quelques trames par seconde). Un message de
240
+ chat : chacun compte — persiste-le AVANT de le publier (§3).
241
+ - **Une famille de canaux** (`room:*`) : le motif pose la POLITIQUE de toute la famille ET sert
242
+ ses noms — `room:42` est servi, la méthode reçoit le nom réel. Une surcharge de
243
+ `createRealtimeChannel` ne sert qu'à FILTRER : dès qu'elle existe, elle seule décide, et son
244
+ `null` est un refus `unknown` que le motif ne contourne pas :
245
+
246
+ <!-- excerpt: privateRoom.ts -->
108
247
 
109
- ## Savoir QUI appelle une action
248
+ ```ts
249
+ // « CETTE personne a-t-elle droit à CE salon ? » : la classe d'admission tranche.
250
+ @RealtimeChannel("room:*", { allow: RoomAdmission })
251
+ room(): () => void {
252
+ return () => {};
253
+ }
254
+
255
+ // La surcharge FILTRE la famille : un nom qui n'est pas un salon est refusé
256
+ // (`null`). Sans elle, le motif servirait tout `room:*`, sous `allow`.
257
+ override createRealtimeChannel(channel: string): (() => void) | null {
258
+ return RoomService.roomOf(channel) === null ? null : () => {};
259
+ }
260
+ ```
110
261
 
111
- Les paramètres décorés d'une route valent aussi dans une `@RealtimeAction` : `@Body("clé")` lit la
112
- charge de l'appel, `@CurrentUser()` l'utilisateur de la connexion (`undefined` pour un anonyme).
113
- L'auteur d'un message se prend là, côté SERVEUR — jamais dans la charge, où le client peut mentir.
262
+ Un canal à durée de vie porte l'identifiant de son instance dans son nom (`game:42:a1b2`) ; la
263
+ fabrique rend `null` une fois l'instance finie.
264
+
265
+ ## 5. Qui a le droit — LIRE et ÉCRIRE sont deux politiques
266
+
267
+ Une politique = `{ authenticated?, roles?, scopes?, allow?, anonymous?, presence? }` — toute autre
268
+ clé refuse le démarrage en nommant la classe et le canal. Les axes se cumulent (ET). Un rôle dit
269
+ **qui est connecté** ; `allow` dit **ce compte sur ce canal**.
270
+
271
+ | Déclaration | Elle garde | Jugée |
272
+ | ---------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------- |
273
+ | `@RealtimeChannel(nom, politique)` | la **LECTURE** du nom | au `subscribe` |
274
+ | `@RealtimeInbound(nom, politique)` | l'**ÉCRITURE** du nom | à chaque trame entrante : lecture ET écriture |
275
+ | `@RealtimeAction(nom, politique)` | l'écriture du nom ; sans politique : la lecture du nom si déclarée, sinon **fermée** | à chaque appel |
276
+ | `@RealtimePolicy({ nom: politique })` (classe) | lecture ET écriture, pour un contrôleur à contrat | idem |
277
+ | route + `@IsGranted` | le geste, comme toute route HTTP | à chaque appel, identité revérifiée |
278
+
279
+ - **Ce qui n'est pas déclaré s'hérite** : une lecture absente reprend l'écriture du même nom ; un
280
+ canal que tout le monde lit et que seuls certains écrivent se déclare donc deux fois, lecture
281
+ ouverte et écriture gardée. Deux déclarations contradictoires d'une même sorte sur un nom
282
+ refusent le démarrage.
283
+ - **Une entrée sous un AUTRE nom n'hérite PAS de la politique du canal qu'elle vise** :
284
+ `@RealtimeInbound("room:send")` ne reçoit pas l'`allow` de `room:*` — elle vérifie l'appartenance
285
+ elle-même, par le MÊME service que l'admission (ou devient une route, §3).
286
+ - **`allow` est une règle de LECTURE** : posé sur une écriture distincte, il refuse le démarrage.
287
+ Il est demandé UNE fois au `subscribe` (et à chaque reconnexion) ; seul `=== true` admet ; lever
288
+ = `forbidden` ; trop lent = `unavailable` (le client réessaie seul).
289
+ - **Dès que la règle lit la base, écris-la en classe d'admission** — une dépendance absente refuse
290
+ alors le démarrage en nommant la classe, au lieu de refuser tous les abonnés sans un mot :
291
+
292
+ <!-- excerpt: privateRoom.ts -->
114
293
 
115
294
  ```ts
116
- @RealtimeAction("chat:send")
117
- send(@Body("text") text: string, @CurrentUser() user: IUser) {
118
- return { author: user.identifier, text };
295
+ @injectable()
296
+ export class RoomAdmission extends Service implements IChannelAdmission {
297
+ constructor(@Inject("RoomService") private readonly rooms: RoomService) {
298
+ super("roomAdmission");
299
+ }
300
+
301
+ async allow(channel: string, who: ISubscriberIdentity): Promise<boolean> {
302
+ return who.id !== null && (await this.rooms.isMember(channel, who.id));
303
+ }
119
304
  }
120
305
  ```
121
306
 
122
- ## Côté client — la façade, pas le socket
307
+ - **Déclarée dans `@services([...])` du module** : `nodefony create service RoomAdmission` le fait ;
308
+ écrite à la main et oubliée, `nodefony doctor` la signale orpheline (`wiring/orphan-service`).
309
+ - `who` est plat (`id`, `authenticated`, `roles`, `scopes`, `issuer`, `account`) : la règle ne lit
310
+ que ses arguments — ni contexte de requête, ni session. Un salon PUBLIC lisible sans compte :
311
+ `anonymous: true` à côté d'`allow` (la démo), la règle refusant les salons privés.
312
+ - **Le verdict d'`allow` est RETENU sur la connexion** : une admission qui expire (invitation à un
313
+ événement terminé, salon verrouillé) ne se retire pas seule — éjecte (`evictUser`) au moment où
314
+ la condition tombe, sinon le canal continue de livrer ce que la route refuse déjà. Une même
315
+ règle pure, appelée par la classe d'admission ET par les routes, évite qu'elles divergent.
316
+ - **Exclure** = retirer l'appartenance, PUIS `getRealtimeHub().evictUser(canal, id)` (tous ses
317
+ onglets, tous les serveurs ; `realtime:closed { reason: "evicted" }`). L'éjection seule ne suffit
318
+ pas : son navigateur rejoue `subscribe` au rechargement, et `allow` tranche alors. Une liste
319
+ noire à côté d'`allow` diverge en silence : interdit. **Exclusion DÉFINITIVE** (pas de
320
+ réinvitation) = un ÉTAT sur la ligne d'appartenance (`banned`), lu par la MÊME règle qu'`allow` et
321
+ par la route d'invitation — jamais une seconde table. Le modérateur est un attribut
322
+ d'appartenance, pas un rôle global ; l'entité d'appartenance se génère **sans ressource REST**
323
+ (`--no-controller`) — un CRUD ouvert laisserait chacun s'inviter.
324
+ - **Inviter** = une route (`@IsGranted` + contrôle « est modérateur ») qui écrit l'appartenance PUIS
325
+ prévient l'invité : `getRealtimeHub().publishToUser(invité, { kind: "invited", room })`. Son
326
+ écran s'abonne au salon, `allow` dit oui.
327
+ - **Nom exact contre motif** : le nom exact l'emporte et REMPLACE le motif en entier ; entre
328
+ motifs, le littéral le plus long gagne. Un canal servi par la fabrique sans aucune politique qui
329
+ le couvre est signalé une fois au journal (`… n'a aucune règle d'accès …`) : pose le motif.
330
+ - **Noms réservés** : `nodefony:` (plateforme) et les suffixes `:health` / `:stats` dans TOUT
331
+ espace de noms (refusés sans `ROLE_NODEFONY_ADMIN`). `user:<id>` est fourni (son propre id
332
+ seulement) : n'écris jamais de politique sur `user:…` sans le vouloir — elle remplacerait la
333
+ garantie « toi seul ».
334
+ - **Condition d'activation** : rôles, portées et `authenticated` ne sont jugés que si
335
+ `@nodefony/security` est chargé avec une zone qui couvre la socket ; sinon un WARNING au premier
336
+ handshake. `allow` s'applique toujours. **Ne ferme pas la zone pour fermer un canal** : la
337
+ politique se pose sur le canal.
338
+
339
+ ## 6. Côté client — la façade, jamais le socket nu
340
+
341
+ <!-- excerpt: privateRoom.client.ts -->
123
342
 
124
343
  ```ts
125
344
  import { NodefonySocket } from "nodefony/client";
126
345
 
127
- const client = NodefonySocket.shared({ url: "/api/live/realtime" });
128
- client.subscribe("ops:alerts", (payload) => { … });
346
+ const socket = NodefonySocket.shared({ url: "/api/room/realtime" });
347
+
348
+ /** Entrer dans un salon : une DEMANDE, que `allow` tranche côté serveur. */
349
+ export function joinRoom(
350
+ room: string,
351
+ onMessage: (message: unknown) => void,
352
+ ): () => void {
353
+ const off = socket.on(room, onMessage);
354
+ socket.subscribe(room);
355
+ return () => {
356
+ off();
357
+ socket.unsubscribe(room);
358
+ };
359
+ }
129
360
  ```
130
361
 
131
- L'adresse est **obligatoire** : `shared()` sans `url` lève une erreur, parce que la route dépend de
132
- l'application. Une application générée monte `/api/live/realtime`.
133
-
134
- En React, les hooks du paquet client font la même chose avec le cycle de vie du composant.
135
- **Importe depuis `nodefony/client`**, jamais depuis la racine `nodefony` : côté navigateur, elle
136
- n'expose pas ces symboles et le compilateur refuse.
362
+ | Méthode | Rôle |
363
+ | ---------------------------------------------------- | -------------------------------------------------------------------------- |
364
+ | `NodefonySocket.shared({ url })` | UNE socket par adresse ; les options ne comptent qu'à la première création |
365
+ | `subscribe(canal)` / `on(canal, h)` | DEMANDER le flux (rejoué à chaque reconnexion) / le RECEVOIR — deux gestes |
366
+ | `whenSubscribed(canal)` | attendre l'acquittement avant de charger l'état initial |
367
+ | `get` / `post` / `put` / `patch` / `delete(chemin…)` | appeler une route par la socket (§3) |
368
+ | `emit(nom, charge)` | pousser vers une entrée (`@RealtimeInbound`), sans réponse |
369
+ | `request(nom, params)` | appeler une action, avec réponse |
370
+ | `onDenied(h)` | refus : `forbidden`, `limit`, `unknown`, `unavailable` |
371
+ | `onClosed(h)` | fin imposée : `ended`, `evicted` — le client ne rejoue pas |
372
+ | `onUser(h)` | les messages adressés à CE compte |
373
+ | `onState(h)` / `onIdentity(h)` | connexion (`connected`…) / compte de la connexion |
374
+
375
+ - **Importe de `nodefony/client`**, jamais de la racine `nodefony` dans un bundle navigateur ;
376
+ `nodefony/realtime` n'existe pas.
377
+ - Vues : `nodefony/react` (`<NodefonyProvider url="…">`, `useNodefony()`,
378
+ `useNodefonyChannelData`, `useNodefonyChannel`), mêmes noms en `nodefony/vue`. Un composant monte
379
+ et démonte son abonnement ; la socket appartient à la page.
380
+ - Un refus de handshake (401/403) n'est vu par le navigateur que comme une coupure : la socket
381
+ sonde l'adresse en HTTP et s'arrête d'elle-même. Rien à écrire.
382
+
383
+ ## 7. Plusieurs serveurs, redéploiement, révocation — résumé
384
+
385
+ Lis [`references/production.md`](references/production.md) avant de dire « fait ».
386
+
387
+ - Un canal reste sur son serveur tant que sa classe ne porte pas `@RealtimeBroadcast("prefix:")`
388
+ — sur TOUS les serveurs. L'application générée tourne en `backplane.driver: "cluster"` : deux
389
+ onglets sur un processus ne prouvent rien. L'oubli se compte (`localOnlyTotal`).
390
+ - **Le temps réel traverse les serveurs, l'ÉTAT non** : plusieurs serveurs exigent une base
391
+ partagée (SQLite local = des salons et des appartenances différents par serveur, donc des `allow`
392
+ différents) et un stockage de fichiers partagé.
393
+ - Noms de canaux = espace PLAT entre modules : préfixe-les par domaine. Livraison : au plus une
394
+ fois — la reconnexion rejoue les abonnements, pas les messages.
395
+ - Révocation : `4001` session morte, `4005` droits changés (revient aussitôt), `4006` jeton porteur
396
+ expiré ; `1013` client qui ne lit plus ; `1001` redéploiement (reconnexion silencieuse). Compare
397
+ aux constantes de `nodefony`, jamais aux nombres.
398
+ - Socket sur une autre origine que la page : `publicOrigin` (CSP `connect-src`).
399
+
400
+ ## 8. SSE ou socket ?
401
+
402
+ | Besoin | Choisir |
403
+ | -------------------------------------------------------------------- | ------------------------------------------- |
404
+ | Le serveur pousse sur UNE requête (progression, réponse d'un modèle) | **SSE** — une réponse HTTP qui ne finit pas |
405
+ | Un réseau d'entreprise bloque les WebSocket | **SSE** — c'est une réponse HTTP ordinaire |
406
+ | Deux sens, canaux, plusieurs onglets, identité par connexion | **socket** — le reste de cette page |
407
+
408
+ `const sse = await this.renderSse()` dans une action ordinaire, puis `await sse.send(données, {
409
+ event })` et `sse.close()` ; `this.acceptsSse()` laisse la même route répondre en JSON ou en flux.
410
+ Client : `new NodefonySse(url)` (`nodefony/client`), liaison `useNodefonySse`. **N'écris jamais
411
+ `Content-Type: text/event-stream` à la main** : tu perdrais le battement qui empêche un proxy de
412
+ couper, et la contre-pression (`await sse.send()` freine si le client lit lentement). Un flux SSE
413
+ est gardé par la zone et `@IsGranted` de sa route, pas par une politique de canal. Exemple complet :
414
+ `node_modules/@nodefony/http/docs/sse.md`.
137
415
 
138
- ## Ne pas ouvrir plus que le canal demandé
416
+ ## 9. Un contrat, déclaré UNE fois
139
417
 
140
- Fermer « toute la zone `^/api` » pour protéger un canal emporte le reste de l'application avec
141
- lui — y compris les démonstrations publiques posées par le scaffold. La politique se pose **sur le
142
- canal**, pas sur l'espace HTTP qui l'entoure.
418
+ Dès que navigateur et serveur échangent plus qu'un canal : sans contrat, une méthode oubliée
419
+ compile puis rend `-32601`. Une interface de types seuls (`extends IRealtimeContract`), partagée
420
+ par les deux côtés : `NodefonySocket.fromContract<C>()` au navigateur, `ContractRealtimeController<C>`
421
+ au serveur, `@RealtimePolicy` sur la classe. Les quatre clés, leurs pièges et le contrat du chat :
422
+ [`references/contrat.md`](references/contrat.md). **Le type n'est pas une validation.**
143
423
 
144
- ## Prouver
424
+ ## 10. Prouver
145
425
 
146
426
  ```bash
147
427
  npm test
148
- npx nodefony inspect routes --json # les canaux montés, tels que l'application les voit
428
+ npx nodefony inspect routes --json
429
+ ```
430
+
431
+ Le harnais `createRealtimeHarness` (`@nodefony/realtime/testing`) parle le protocole sans serveur
432
+ ni navigateur ; l'identité et le VRAI verrou de trames viennent de `@nodefony/security/testing`.
433
+ Le patron (salon privé, cas exécuté complet dans `cas-d-usage.md`) :
434
+
435
+ <!-- excerpt: privateRoom.test.ts -->
436
+
437
+ ```ts
438
+ /** Une connexion par compte, identifiée par son identifiant EN BASE. */
439
+ async function connect(name: string): Promise<IRealtimeHarness<RoomRealtime>> {
440
+ // Le service et l'admission passés à la main, comme l'injecteur en production.
441
+ const h = createRealtimeHarness((ctx) => new RoomRealtime(ctx, rooms), {
442
+ identity: createTestIdentity({ identifier: name, id: idOf(name) }),
443
+ admissions: [new RoomAdmission(rooms)],
444
+ resetHub: open.length === 0,
445
+ });
446
+ await h.connect();
447
+ open.push(h);
448
+ return h;
449
+ }
149
450
  ```
150
451
 
151
- Puis, avec trois identités comme pour une route : un anonyme ne reçoit pas le flux réservé, un
152
- compte sans le rôle non plus, l'administrateur oui — **et le canal public du scaffold répond
153
- toujours** (s'il s'est fermé, une politique a débordé).
452
+ <!-- excerpt: privateRoom.test.ts -->
154
453
 
155
- ## Voisins
454
+ ```ts
455
+ it("exclu : `evicted` tout de suite, puis refusé à la page rechargée", async () => {
456
+ const carol = await connect("carol");
457
+ await carol.subscribe(channel);
458
+ await flush();
459
+ await rooms.exclude(channel, idOf("carol"));
460
+ expect(closedOf(carol)).toEqual([{ channel, reason: "evicted" }]);
461
+ const reloaded = await connect("carol"); // une page rechargée refait `subscribe`
462
+ await reloaded.subscribe(channel);
463
+ await flush();
464
+ expect(reloaded.denials()).toHaveLength(1);
465
+ });
466
+ ```
467
+
468
+ - Sans `identity` : **anonyme**. `resetHub` au premier harnais seulement ; `subscriptions()`,
469
+ `denials()`, `messages(canal)` : acquittés, refus, reçus.
470
+ - **`roles` / `authenticated` ne sont jugés qu'avec un verrou** : passe
471
+ `frameAuthorizer: createTestFrameAuthorizer(getRealtimeHub())` au harnais, sinon la politique
472
+ n'est appliquée par personne — et le test est vert pour la mauvaise raison.
473
+ - 🔴 **`allow` lit la base ? Le test MONTE la base**, sur le connecteur que lit le service
474
+ (`const CONNECTOR = "default"`) — sinon `aucun connecteur « default »`, et le harnais rend
475
+ `socket fermée (code 1011)` : ni l'Origin ni l'authentification, le service est sans base.
476
+ Dans le `beforeAll` :
477
+
478
+ <!-- excerpt: privateRoom.test.ts -->
479
+
480
+ ```ts
481
+ for (const entity of ENTITIES) {
482
+ entityRegistry.register({ ...entity, connector: CONNECTOR });
483
+ }
484
+ orm = new DrizzleOrm(CONNECTOR, { filename: ":memory:" }); // en mémoire, rien à installer
485
+ await orm.connect();
486
+ ```
156
487
 
157
- | Besoin | Skill |
158
- | ------------------------------ | ------------------------ |
159
- | Réserver une route HTTP | `nodefony-protect-route` |
160
- | Une ressource complète stockée | `nodefony-add-crud` |
161
- | Un service métier injectable | `nodefony-add-service` |
488
+ Puis sème les PARENTS (`seedEntityRow`) : `FOREIGN KEY constraint failed` = un parent non
489
+ semé, jamais une raison de retirer le test. Fichier complet : salon privé de `cas-d-usage.md`.
490
+
491
+ - **Vois le test rouge une fois** : retire `allow` (ou `roles`), l'intrus doit entrer ; remets-le.
492
+ L'exclusion se prouve par `evicted` PUIS un `subscribe` refusé — un refus opposé à qui n'a jamais
493
+ été membre ne prouve rien.
494
+ - Une route du pont se prouve aussi en HTTP (même route, `POST`).
495
+ - La santé en marche : `GET /nodefony/realtime/api/health` (rôle d'administration) —
496
+ `publishTotal` qui monte sans `fanoutTotal` = tu publies dans le vide (nom mal orthographié).
497
+
498
+ ## 11. Voisins
499
+
500
+ | Besoin | Skill |
501
+ | --------------------------------------------------------- | -------------------------------- |
502
+ | Qui est en ligne, combien, « en train d'écrire » | `nodefony-add-realtime-presence` |
503
+ | Des octets : image, audio, mesures binaires, CBOR | `nodefony-add-realtime-binary` |
504
+ | Réserver une route HTTP, une page | `nodefony-protect-route` |
505
+ | Une ressource stockée (l'entité d'appartenance, messages) | `nodefony-add-crud` |
506
+ | Un service métier injectable (la classe d'admission) | `nodefony-add-service` |
507
+ | Voir l'écran, piloter une socket depuis la page | `nodefony-browser` |
508
+ | Un canal qui répondait ne répond plus | `nodefony-troubleshoot` |