@nodefony/devkit 10.0.0-beta.5 → 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 (26) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.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 +4 -4
  9. package/docs/index.md +1 -1
  10. package/package.json +11 -11
  11. package/skills/nodefony-add-crud/SKILL.md +38 -23
  12. package/skills/nodefony-add-realtime-channel/SKILL.md +376 -111
  13. package/skills/nodefony-add-realtime-channel/references/binaire.md +191 -0
  14. package/skills/nodefony-add-realtime-channel/references/production.md +121 -0
  15. package/skills/nodefony-browser/SKILL.md +27 -2
  16. package/skills/nodefony-browser/scripts/audit.mjs +12 -0
  17. package/skills/nodefony-browser/scripts/inspect.mjs +40 -81
  18. package/skills/nodefony-browser/scripts/lib/actions.mjs +113 -0
  19. package/skills/nodefony-browser/scripts/lib/browser.mjs +50 -11
  20. package/skills/nodefony-browser/scripts/lib/probes.mjs +56 -0
  21. package/skills/nodefony-browser/scripts/watch.mjs +27 -11
  22. package/skills/nodefony-dev/SKILL.md +11 -0
  23. package/skills/nodefony-dev/references/rapport-de-build.md +19 -0
  24. package/skills/nodefony-devops/SKILL.md +28 -21
  25. package/skills/nodefony-migrate-schema/SKILL.md +110 -20
  26. package/skills/nodefony-migrate-schema/references/verdicts.md +22 -20
@@ -1,161 +1,426 @@
1
1
  ---
2
2
  name: nodefony-add-realtime-channel
3
+ metadata:
4
+ version: 2.1.1
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
+ Choisit et branche la brique temps réel que Nodefony fournit déjà — canal, entrée client,
7
+ action, canal personnel, canal à membres, flux SSE, contrat typé, octets — au lieu d'un
8
+ WebSocket, d'une liste noire ou d'un contrôle d'accès écrits à la main. Part du besoin (salon
9
+ privé avec exclusion, jeu, sondes, tableau de bord, notification à une personne, tâche longue)
10
+ et mène à la ligne à écrire, à l'exemple installé et à son test. Porte ce qui ne se voit qu'en
11
+ production : plusieurs serveurs, reconnexion, révocation, contre-pression. À charger AVANT
12
+ d'écrire un canal, un abonnement client ou un flux.
13
+ Déclencheurs : "temps réel", "websocket", "canal", "push", "notifications en direct",
14
+ "abonnement", "salon privé", "exclure un membre", "réserver un canal à un rôle", "mon canal
15
+ est public", "prévenir un utilisateur", "progression d'une tâche", "SSE", "EventSource",
16
+ "réponse en streaming", "contrat temps réel", "-32601", "CBOR",
17
+ "Uint8Array", "ça marche en local, plus en cluster", "l'exclu revient".
14
18
  ---
15
19
 
16
- # add-realtime-channel — le temps réel par sa couche
20
+ # add-realtime-channel — le temps réel par sa brique
17
21
 
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.
22
+ > 🧭 **Arrivé ici directement ? Charge aussi `nodefony-dev`** : il porte la conduite commune (par
23
+ > où commencer, comment prouver, les pièges du serveur et du front). Cette page ne couvre QUE le
24
+ > temps réel.
21
25
  >
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
26
+ > Les pages de référence sont INSTALLÉES (`rg` ne descend pas dans `node_modules`). Une commande
27
+ > les trouve et rend leur chemin — **exécute-la, puis lis la page** :
28
+ >
29
+ > ```bash
30
+ > node node_modules/@nodefony/devkit/skills/nodefony-dev/scripts/docs.mjs --open cas-d-usage
31
+ > node node_modules/@nodefony/devkit/skills/nodefony-dev/scripts/docs.mjs salon allow evict
32
+ > ```
33
+
34
+ > ⚖️ **La confiance n'exclut pas le contrôle.** Un **canal** sans politique est public par
35
+ > construction ; une **action** sans politique est fermée (connexion authentifiée exigée). Ce qui
36
+ > n'est pas déclaré n'est pas gardé — et ce que le framework porte déjà ne se réécrit pas.
37
+
38
+ ## 1. Quel besoin → quelle brique
39
+
40
+ Lis la ligne qui ressemble à ton besoin, écris ce qu'elle dit, puis ouvre la page citée. Pour
41
+ chaque cas, `cas-d-usage.md` montre trois fichiers — le serveur, le navigateur, le test —
42
+ exécutés par la suite du framework : **copie le test avec le serveur**.
43
+
44
+ | Je veux… | La brique (et rien d'autre) | Page installée |
45
+ | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
46
+ | pousser un flux que des écrans lisent | `@RealtimeChannel("nom")` ; un seul producteur par canal et par serveur, créé au premier abonné | `cas-d-usage.md` (tableau de bord) |
47
+ | le réserver à un rôle | `@RealtimeChannel("nom", { roles: ["ROLE_X"] })` | `securite.md` |
48
+ | une famille de canaux (`room:<id>`, `task:<id>`) | motif `"room:*"` + `createRealtimeChannel()` qui sert (ou refuse, `null`) chaque nom | `securite.md` (nom exact vs dérivés) |
49
+ | **ce compte-là et lui seul** (salon privé, invitation) | `allow: (channel, who) => …` sur le motif — l'appartenance vit en base, `allow` la lit | `cas-d-usage.md` (salon privé) |
50
+ | 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` |
51
+ | 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) |
52
+ | 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) |
53
+ | 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) |
54
+ | recevoir ce que le client pousse (coups de jeu, mesures) | `@RealtimeInbound("game:move", politique)` — `params` non fiable, `connection.userId` = l'auteur | `cas-d-usage.md` (jeu, sondes) |
55
+ | un appel avec réponse (un devis, un historique) | `@RealtimeAction("x:y")` (fermée par défaut) — ou une ROUTE appelée par `socket.post()` si ça change un état | `actions.md` |
56
+ | le serveur pousse, le client écoute, sur UNE requête | `this.renderSse()` dans une action ordinaire ; `new NodefonySse(url)` côté client | `http/docs/sse.md` |
57
+ | typer les deux bouts une fois | `interface C extends IRealtimeContract` + `NodefonySocket.fromContract<C>()` + `ContractRealtimeController<C>` | `actions.md` (contrat) |
58
+ | des octets (document partagé, audio, image, mesures binaires) | `use("@nodefony/realtime", { codec: "cbor" })` et `Uint8Array` dans la charge — même canal, mêmes gardes | `references/binaire.md` |
59
+ | ça marche avec deux onglets, plus en cluster | `@RealtimeBroadcast("prefix:")` sur la classe (défaut : un canal reste sur son serveur) | `references/production.md` |
60
+
61
+ Les chemins complets : `node_modules/@nodefony/realtime/docs/<page>.md`,
62
+ `node_modules/@nodefony/http/docs/sse.md`, `node_modules/nodefony/docs/client.md`.
63
+
64
+ **Canal ou route ?** Le canal porte les FLUX (diffusés, éphémères, attribués par l'identité de la
65
+ connexion). Un GESTE qui change un état (envoyer un message conservé, payer, inviter) est une
66
+ **route** `POST` avec `@IsGranted` et `@Idempotent`, que le navigateur appelle par
67
+ `socket.post("/chemin", corps)` : identité revérifiée à chaque appel, validation, rejeu sans
68
+ doublon. Détail : `actions.md` (« Le pont API »).
69
+
70
+ ## 2. Le geste
32
71
 
33
72
  ```bash
34
- npx nodefony create controller Ops --kind realtime
73
+ npx nodefony create controller Room --kind realtime
35
74
  ```
36
75
 
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.
76
+ Produit une sous-classe de `RealtimeController`, avec un canal de démonstration, une action, une
77
+ entrée client, et **son test** (`createRealtimeHarness`). Nomme le contrôleur par son USAGE
78
+ (`RoomChat`, `Arena`), pas par une entité : `create entity Room` après `create controller Room`
79
+ est refusé (nom déjà référencé). Une application générée sert déjà `/api/live/realtime`
80
+ (`LiveController`, canal `live:events`) : c'est le canal public témoin, il doit continuer de
81
+ répondre après ta modification.
41
82
 
42
- ## WebSocket, ou un simple flux SSE ?
83
+ **Le client ouvre la socket du contrôleur qui DÉCLARE ses canaux** — `/api/room/realtime` pour
84
+ `create controller Room`. Une socket ne sert que les canaux de SON contrôleur : ouvert sur
85
+ `/api/live/realtime`, `room:42` est refusé (`unknown`). Pour une seule socket par page, regroupe
86
+ les canaux dans un même contrôleur ; seul `user:<id>` est fourni par toutes.
43
87
 
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 |
88
+ **N'écris ni `new WebSocket(...)` côté serveur, ni `ws.on(...)`, ni un `WebSocketServer`** : le
89
+ bas niveau existe, le framework l'emploie ; le reprendre te prive du routage par canal, de
90
+ l'identité partagée avec HTTP, de la reconnexion et de la contre-pression.
49
91
 
50
- SSE n'est pas une route d'un genre particulier : l'action d'une route ordinaire choisit de
51
- répondre en flux.
92
+ **Si tu retires ou renommes le canal de démonstration du contrôleur généré, adapte son test dans
93
+ la foulée** — ne laisse pas un banc rouge que tu finirais par ignorer.
94
+
95
+ ## 3. Les quatre mots du serveur
52
96
 
53
97
  ```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();
98
+ import type { ContextType } from "@nodefony/http";
99
+ import {
100
+ RealtimeAction,
101
+ RealtimeChannel,
102
+ RealtimeController,
103
+ RealtimeInbound,
104
+ getRealtimeHub,
105
+ } from "@nodefony/realtime";
106
+ import type { IRealtimeConnection } from "@nodefony/realtime";
107
+ import { CurrentUser } from "@nodefony/framework";
108
+ import type { IUser } from "@nodefony/user";
109
+
110
+ export class OpsRealtime extends RealtimeController {
111
+ constructor(context: ContextType) {
112
+ super("OpsRealtime", context);
113
+ }
114
+
115
+ // CANAL (flux serveur → abonnés) : la fabrique rend le nettoyage. Un seul producteur
116
+ // par canal et par serveur, partagé entre connexions : ne capture jamais `this.context`.
117
+ @RealtimeChannel("ops:alerts", { roles: ["ROLE_ADMIN"] })
118
+ alerts(): () => void {
119
+ return () => {};
120
+ }
121
+
122
+ // ENTRÉE (le client pousse, sans réponse) : `params` vient du réseau, valide-le ;
123
+ // l'auteur est `connection.userId`, jamais un champ de la charge.
124
+ @RealtimeInbound("ops:ack", { authenticated: true })
125
+ ack(params: unknown, _reply: unknown, connection: IRealtimeConnection): void {
126
+ if (connection.userId === null || !isAck(params)) return;
127
+ getRealtimeHub().publish("ops:alerts", {
128
+ acked: params.id,
129
+ by: connection.userId,
130
+ });
131
+ }
132
+
133
+ // ACTION (appel avec réponse) : FERMÉE par défaut ; `@CurrentUser()` comme sur une route.
134
+ @RealtimeAction("ops:whoami")
135
+ whoami(@CurrentUser() user: IUser): { identifier: string } {
136
+ return { identifier: user.identifier };
137
+ }
59
138
  }
60
- ```
61
139
 
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`.
140
+ function isAck(v: unknown): v is { id: string } {
141
+ return (
142
+ typeof v === "object" && v !== null && "id" in v && typeof v.id === "string"
143
+ );
144
+ }
145
+ ```
68
146
 
69
- ## Fermer un canal
147
+ - **Publier depuis n'importe où** (service, tâche, minuteur, boucle de jeu) :
148
+ `getRealtimeHub().publish(canal, charge)`. La charge est un objet, un tableau, des octets ou
149
+ rien — **un scalaire lève** (`publish("c", 42)` → `TypeError`) : écris `{ value: 42 }`.
150
+ - **Un canal au nom calculé ne se publie pas par la variable `publishToChannel` du contrôleur
151
+ généré** (elle ne sert qu'un nom exact) : `getRealtimeHub().publish(\`room:${id}\`, charge)`.
152
+ - **État ou événement ?** Une position, une jauge : seule la dernière valeur compte — garde-la et
153
+ diffuse au rythme du serveur (`setInterval`, `unref()`), jamais à chaque réception. Un message
154
+ de chat : chacun compte — persiste-le AVANT de le publier.
155
+ - **Une famille de canaux** (`room:*`) : le décorateur ne sert que le nom EXACT. Ajoute
156
+ `override createRealtimeChannel(channel) { return exists(channel) ? () => {} : null; }` ;
157
+ `null` = canal inconnu, le client reçoit `realtime:denied { reason: "unknown" }`. Un canal à
158
+ durée de vie porte l'identifiant de son instance dans son nom (`game:42:a1b2`), et la fabrique
159
+ rend `null` une fois l'instance finie.
160
+
161
+ ## 4. Qui a le droit — la politique, et ce qu'elle ne couvre pas
162
+
163
+ Politique = `{ authenticated?, roles?, scopes?, allow?, anonymous? }` — toute autre clé est
164
+ refusée au démarrage. Les clés se cumulent. Un rôle dit **qui est connecté** ; `allow` dit **ce
165
+ compte sur ce canal**.
70
166
 
71
167
  ```ts
72
- @RealtimeChannel("ops:alerts", { roles: ["ROLE_ADMIN"] })
73
- export class OpsController extends RealtimeController {
74
- @RealtimeAction("ops:snapshot", { roles: ["ROLE_ADMIN"] })
75
- async snapshot() { … }
168
+ // L'appartenance vit en base (entité générée) ; `allow` la lit UNE fois, au `subscribe` —
169
+ // et à chaque reconnexion, puisque le navigateur rejoue ses abonnements.
170
+ @RealtimeChannel("room:*", {
171
+ allow: async (channel, who) =>
172
+ who.id !== null &&
173
+ (await getRoomMemberService().findOne({ room: channel.slice("room:".length), user: who.id })) !==
174
+ null,
175
+ })
176
+ room(): () => void {
177
+ return () => {};
76
178
  }
77
- ```
78
179
 
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.
82
-
83
- ## 🔴 Le piège du canal dont le nom est calculé
180
+ // 🔴 Le motif pose la POLITIQUE de toute la famille, pas la FABRIQUE : sans celle-ci,
181
+ // `room:42` est refusé (`realtime:denied { reason: "unknown" }`).
182
+ override createRealtimeChannel(channel: string): (() => void) | null {
183
+ return channel.startsWith("room:") ? () => {} : null;
184
+ }
185
+ ```
84
186
 
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.
187
+ - `who` est une donnée plate (`id`, `authenticated`, `roles`, `scopes`, `issuer`, `account`) :
188
+ `allow` ne lit **que ses arguments** — ni contexte de requête, ni session. Seul `=== true`
189
+ admet ; lever ou rejeter = `forbidden` ; trop lent = `unavailable` (le client réessaie seul).
190
+ Dès que la règle lit la base par des services, écris-la en **classe `@injectable`** résolue au
191
+ démarrage (`securite.md`, « forme longue ») : une dépendance absente y refuse le démarrage en
192
+ nommant la classe, au lieu de refuser tous les abonnés sans un mot.
193
+ - **Lire l'appartenance** : `getXxxService()` est l'accesseur que `create entity Xxx` a généré ;
194
+ une colonne de référence se filtre par l'identifiant de la cible
195
+ (`findOne({ room: roomId, user: who.id })`). N'invente ni service, ni injection, ni `as never`.
196
+ - **Exclure** = retirer l'appartenance, PUIS `getRealtimeHub().evictUser(canal, who.id)` (tous ses
197
+ onglets, tous les serveurs ; `realtime:closed { reason: "evicted" }`). L'éjection seule ne
198
+ suffit pas : son navigateur rejoue `subscribe` au rechargement, et `allow` tranche alors. Une
199
+ liste noire à côté d'`allow` diverge en silence : interdit.
200
+ - **Le modérateur est un attribut d'appartenance** (`RoomMember.role`), pas un rôle global de
201
+ l'application. Et l'entité d'appartenance se génère **sans ressource REST** (`--no-controller`) :
202
+ un CRUD ouvert laisserait chacun s'inviter lui-même.
203
+ - **Une entrée ou une action n'hérite PAS de la politique du canal.**
204
+ `@RealtimeInbound("room:send")` porte la sienne, et vérifie l'appartenance avec
205
+ `connection.userId` par le MÊME registre qu'`allow` — ou devient une route `POST` (§1).
206
+ - **Le nom exact l'emporte sur le motif, et le remplace EN ENTIER** : une politique
207
+ `@RealtimeChannel("room:lobby", { authenticated: true })` ouvre `room:lobby` à tout compte
208
+ connecté malgré l'`allow` de `room:*`.
209
+ - **`user:<id>` est fourni** : politique intégrée (compte connecté, son propre id seulement),
210
+ diffusé entre serveurs, rien à déclarer. N'écris jamais de politique sur `user:…`.
211
+ - **Ne ferme pas la zone pour fermer un canal.** La politique se pose sur le canal ; une zone
212
+ `^/api` privée de `"anonymous"` emporte le reste de l'application, démonstrations et tests
213
+ fournis compris. Réserver une route : skill `nodefony-protect-route`.
214
+ - **Condition d'activation** : rôles, portées et `authenticated` ne sont appliqués que si
215
+ `@nodefony/security` est chargé avec une zone qui couvre la socket (`realtime: true`, défaut).
216
+ Sinon le serveur l'écrit en WARNING au premier handshake. `allow` s'applique toujours.
217
+ - La configuration de sécurité `realtimeChannels: [{ pattern: "room:", roles: […] }]` durcit une
218
+ famille sans toucher au code ; elle n'exprime jamais l'appartenance. Ne simule jamais un salon
219
+ privé par un nom de canal difficile à deviner.
220
+
221
+ ## 5. Côté client — la façade, jamais le socket nu
88
222
 
89
223
  ```ts
90
- @RealtimeChannel("room:lobby", { authenticated: true }) // ✅ couvert
91
- // `room:42` construit à la volée // 🔴 PAS couvert par le décorateur
224
+ import { NodefonySocket } from "nodefony/client";
225
+
226
+ // La socket du contrôleur qui DÉCLARE ces canaux (`create controller Ops` → `/api/ops/realtime`).
227
+ const socket = NodefonySocket.shared({ url: "/api/ops/realtime" }); // UNE socket par URL
228
+ socket.subscribe("ops:alerts"); // DEMANDE le flux (rejoué à chaque reconnexion)
229
+ socket.on("ops:alerts", (alert) => render(alert)); // REÇOIT ; deux gestes distincts
230
+ socket.emit("ops:ack", { id: "a1" }); // entrée client, sans réponse
231
+ const me = await socket.request("ops:whoami"); // action, avec réponse (30 s)
232
+ socket.onDenied(({ channel, reason }) => {}); // forbidden | limit | unknown | unavailable
233
+ socket.onClosed(({ channel, reason }) => {}); // ended | evicted — le client ne rejoue pas
234
+ await socket.whenSubscribed("ops:alerts"); // avant de charger l'état initial
235
+ socket.onUser((note) => {}); // les messages adressés à CE compte
92
236
  ```
93
237
 
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 :
238
+ - **Importe de `nodefony/client`**, jamais de la racine `nodefony` dans un bundle navigateur ;
239
+ `nodefony/realtime` n'existe pas.
240
+ - `shared()` sans `url` lève ; les options ne comptent qu'à la PREMIÈRE création (codec compris).
241
+ - Vues : `nodefony/react` (`<NodefonyProvider url="…">`, `useNodefony()`,
242
+ `useNodefonyChannelData(socket, "canal", initial)`, `useNodefonyChannel(socket, "canal", h)`),
243
+ mêmes noms en `nodefony/vue`. Un composant monte et démonte son abonnement ; la socket
244
+ appartient à la page. Détail : `node_modules/nodefony/docs/client.md`.
245
+ - Le navigateur ne voit un refus de handshake (401/403) que comme une coupure : la socket sonde
246
+ l'adresse en HTTP et s'arrête d'elle-même ; tu n'as rien à écrire.
247
+
248
+ ## 6. SSE ou socket ?
249
+
250
+ | Besoin | Choisir |
251
+ | -------------------------------------------------------------------- | ------------------------------------------- |
252
+ | Le serveur pousse sur UNE requête (progression, réponse d'un modèle) | **SSE** — une réponse HTTP qui ne finit pas |
253
+ | Un réseau d'entreprise bloque les WebSocket | **SSE** — c'est une réponse HTTP ordinaire |
254
+ | Deux sens, canaux, plusieurs onglets, identité par connexion | **socket** — le reste de cette page |
96
255
 
97
256
  ```ts
98
- use("@nodefony/security", {
99
- realtimeChannels: [
100
- { pattern: "room:", authenticated: true, roles: ["ROLE_USER"] },
101
- ],
102
- });
257
+ @route("progress-run", { path: "/run" })
258
+ async run() {
259
+ const sse = await this.renderSse(); // en-têtes, battement, contre-pression
260
+ await sse.send({ step: "calcul" }, { event: "step" });
261
+ return sse.close();
262
+ }
103
263
  ```
104
264
 
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.
265
+ `this.acceptsSse()` laisse la même route répondre en JSON ou en flux. Client :
266
+ `new NodefonySse(url)` (`nodefony/client`), liaison `useNodefonySse`. **N'écris jamais
267
+ `Content-Type: text/event-stream` à la main** : tu perdrais le battement qui empêche un proxy de
268
+ couper, et la contre-pression (`await sse.send()` freine si le client lit lentement). Un flux SSE
269
+ n'est pas un canal : il n'est pas gardé par une politique de canal, mais par la zone et
270
+ `@IsGranted` de sa route. Détail : `node_modules/@nodefony/http/docs/sse.md`.
108
271
 
109
- ## Savoir QUI appelle une action
272
+ ## 7. Un contrat, déclaré UNE fois
110
273
 
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.
274
+ Dès que navigateur et serveur échangent plus qu'un canal : sans contrat, chaque côté type ce qu'il
275
+ envoie, jamais ce que l'autre implémente — une méthode oubliée compile puis rend `-32601`.
114
276
 
115
277
  ```ts
116
- @RealtimeAction("chat:send")
117
- send(@Body("text") text: string, @CurrentUser() user: IUser) {
118
- return { author: user.identifier, text };
278
+ // shared/ops.ts — types SEULS : 0 octet compilé, aucun import de code serveur
279
+ import type { IRealtimeContract } from "nodefony/client";
280
+
281
+ export interface OpsContract extends IRealtimeContract {
282
+ serverToClient: { "ops:alerts": { level: string; at: Date } };
283
+ clientToServer: { "ops:ack": { id: string } };
284
+ serverMethods: {
285
+ "ops:mute": { in: { id: string }; out: { muted: boolean } };
286
+ };
119
287
  }
120
288
  ```
121
289
 
122
- ## Côté client — la façade, pas le socket
123
-
124
290
  ```ts
125
- import { NodefonySocket } from "nodefony/client";
126
-
127
- const client = NodefonySocket.shared({ url: "/api/live/realtime" });
128
- client.subscribe("ops:alerts", (payload) => { … });
291
+ // navigateur
292
+ const ops = NodefonySocket.fromContract<OpsContract>({
293
+ url: "/api/ops/realtime", // la socket d'OpsRealtime, qui sert ces canaux
294
+ });
295
+ ops.on("ops:alerts", (a) => a.at); // `string` : ce que JSON fait d'une Date
129
296
  ```
130
297
 
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.
137
-
138
- ## Ne pas ouvrir plus que le canal demandé
139
-
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.
298
+ ```ts
299
+ // serveur — le contrat s'écrit UNE fois, sur `extends` ; la table sert SEULE les méthodes
300
+ @RealtimePolicy({ "ops:mute": { roles: ["ROLE_OPS"] } })
301
+ class OpsRealtime extends ContractRealtimeController<OpsContract> {
302
+ protected override realtimeActions() {
303
+ return { "ops:mute": (p: { id: string }) => ({ muted: p.id.length > 0 }) }; // une oubliée ne compile pas
304
+ }
305
+ }
306
+ ```
143
307
 
144
- ## Prouver
308
+ - N'ajoute pas `@RealtimeAction` à une méthode de la table ; la politique va sur la classe par
309
+ `@RealtimePolicy` (un nom hors contrat ne compile pas). Sans `serverMethods`, rien à écrire.
310
+ - Diffuser : `getRealtimeHub<OpsContract>().publish(canal, charge)` — nom et charge vérifiés ; une
311
+ fabrique de canal déclare `publish: ContractRealtimePublish<OpsContract>` ; le flux SSE lit la
312
+ même carte (`this.renderSse<ContractServerEmit<OpsContract>>()`).
313
+ - Routes du pont : clé `serverRoutes` → le client appelle `ops.post("/ops/mute", corps)`.
314
+ - **Le type n'est pas une validation** : la politique reste la seule garde.
315
+
316
+ ## 8. Ce qui ne se voit qu'en production — lis-le avant de dire « fait »
317
+
318
+ Résumé ; chaque ligne est détaillée dans [`references/production.md`](references/production.md).
319
+
320
+ - **Plusieurs serveurs** : un canal reste sur son serveur tant que sa classe ne porte pas
321
+ `@RealtimeBroadcast("prefix:")` — sur TOUS les serveurs, pas seulement l'émetteur. L'application
322
+ générée tourne en `backplane.driver: "cluster"` : deux onglets sur un seul processus ne prouvent
323
+ rien. La sonde compte l'oubli (`localOnlyTotal`). `user:` est diffusé par le framework.
324
+ - **Reconnexion** : le client rejoue ses abonnements au `welcome` — pas les messages manqués. Une
325
+ discussion se reprend par une route (`GET …/history?since=`) ; déduplique sur l'identifiant
326
+ persistant.
327
+ - **Aucune garantie de livraison** : au plus une fois, un trou jamais réordonné. Une alarme qui ne
328
+ doit pas se perdre porte son identifiant dans la charge, un accusé, une réémission applicative.
329
+ - **Révocation** : une socket survit à sa session jusqu'à la revalidation (30 s). Rôles changés →
330
+ `4005` (revient aussitôt, lis `onIdentity`) ; session morte → `4001` ; jeton porteur expiré →
331
+ `4006` (définitif : recrée la socket avant `exp`). Compare aux constantes de `nodefony`, jamais
332
+ aux nombres.
333
+ - **Contre-pression** : un client lent est jeté puis fermé (`1013`) par le serveur (`websocket` ET
334
+ `websocketSecure` de `@nodefony/http`) ; `drop` ne trie pas. Côté navigateur, `maxBufferedAmount`
335
+ jette un coup périmé au lieu de l'empiler (jeu, capteur).
336
+ - **`allow` lent** : `unavailable`, réessayé 3 fois par le client ; en rafale au redémarrage
337
+ (N clients × M canaux). Un bus partagé doit être **privé** (un ordre d'éjection capté se rejoue).
338
+
339
+ ## 9. Des octets dans un canal
340
+
341
+ Côté serveur, une ligne : `use("@nodefony/realtime", { codec: "cbor" })`. Sans elle, un
342
+ `Uint8Array` part en `{"0":1,…}` **sans erreur**. Côté navigateur, offre le codec à la PREMIÈRE
343
+ création : `NodefonySocket.shared({ url, codec: CBOR_FRAME_CODEC })` (`nodefony/cbor`). Les octets
344
+ passent par `publish` / `on(canal)` sous les mêmes politiques ; `onRealtimeBinary()` est **hors des
345
+ gardes**. Mise en place, messages d'erreur, ce qui rate sans message :
346
+ [`references/binaire.md`](references/binaire.md) — à lire AVANT la première ligne.
347
+
348
+ ## 10. Prouver
145
349
 
146
350
  ```bash
147
351
  npm test
148
- npx nodefony inspect routes --json # les canaux montés, tels que l'application les voit
352
+ npx nodefony inspect routes --json
149
353
  ```
150
354
 
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é).
355
+ Le harnais parle le protocole sans serveur ni navigateur ; chaque cas de `cas-d-usage.md` en
356
+ montre un test complet. Le patron :
154
357
 
155
- ## Voisins
358
+ ```ts
359
+ import { getRealtimeHub } from "@nodefony/realtime";
360
+ import { createRealtimeHarness } from "@nodefony/realtime/testing";
361
+ import {
362
+ createTestFrameAuthorizer,
363
+ createTestIdentity,
364
+ } from "@nodefony/security/testing";
365
+
366
+ const bob = createRealtimeHarness((ctx) => new RoomRealtime(ctx), {
367
+ identity: createTestIdentity({ identifier: "bob" }), // `who.id` = "bob" ; `id:` pour une clé distincte
368
+ frameAuthorizer: createTestFrameAuthorizer(getRealtimeHub()), // le VRAI verrou : sans lui, `roles` n'est pas jugé
369
+ });
370
+ await bob.connect();
371
+ await bob.subscribe("room:7"); // acquitté : `bob.subscriptions()` ; refusé : `bob.denials()`
372
+ await bob.notify("room:send", { room: "room:7", text: "salut" }); // entrée client
373
+ bob.messages("room:7"); // ce qu'il a reçu, dans l'ordre
374
+ bob.received.filter((f) => f.method === "realtime:closed"); // evicted | ended
375
+ bob.dispose();
376
+ ```
156
377
 
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` |
378
+ - Sans `identity` : connexion **anonyme**. Plusieurs harnais sur le même hub : `resetHub` au
379
+ premier seulement — c'est ainsi qu'on éprouve qu'une publication atteint l'un et pas l'autre.
380
+ - **Vois le test rouge une fois** : retire `allow` (ou `roles`), l'intrus doit entrer ; remets-le.
381
+ - L'exclusion se prouve : exclu → `realtime:closed evicted`, puis un nouveau `subscribe` refusé.
382
+ Un refus opposé à quelqu'un qui n'a jamais été membre ne prouve PAS l'exclusion.
383
+ - **`allow` lit la base ? Le test l'amorce** — c'est la recette du test que `create entity` a
384
+ écrit à côté de chaque entité (`tests/<entité>.test.ts`), avec deux écarts :
385
+
386
+ ```ts
387
+ import { DrizzleOrm, seedEntityRow } from "@nodefony/drizzle";
388
+ import { entityRegistry, ormRegistry } from "@nodefony/orm-core";
389
+
390
+ const CONNECTOR = "default"; // celui de l'APPLICATION : `getRoomMemberService()` le lit
391
+ for (const e of [UserEntity, RoomEntity, RoomMemberEntity]) {
392
+ entityRegistry.register({ ...e, connector: CONNECTOR });
393
+ }
394
+ const orm = new DrizzleOrm(CONNECTOR, { filename: ":memory:" });
395
+ await orm.connect();
396
+ // Les PARENTS d'abord : une clé étrangère inventée est refusée par la base.
397
+ const bobId = String(
398
+ await seedEntityRow(orm, { ...UserEntity, connector: CONNECTOR }),
399
+ );
400
+ const room = String(
401
+ await seedEntityRow(orm, { ...RoomEntity, connector: CONNECTOR }),
402
+ );
403
+ await getRoomMemberService().create({ room, user: bobId, role: "member" });
404
+ // `who.id` = l'identifiant EN BASE :
405
+ createTestIdentity({ identifier: "bob", id: bobId });
406
+ // afterAll : orm.disconnect(), entityRegistry.unregister(nom, CONNECTOR), ormRegistry.unregister(CONNECTOR)
407
+ ```
408
+
409
+ Écart 1 : le connecteur s'appelle `default`, pas `test-<entité>` — sinon l'accesseur du
410
+ service ne trouve aucune base. Écart 2 : `FOREIGN KEY constraint failed` = un parent non semé,
411
+ jamais une raison de retirer le test. Exemple exécuté complet : `cas-d-usage.md` (salon privé).
412
+
413
+ - **Et le canal public témoin répond toujours** (`live:events` d'une application générée) : s'il
414
+ s'est fermé, une politique ou une zone a débordé.
415
+ - La santé en marche : `GET /nodefony/realtime/api/health` (rôle d'administration) —
416
+ `publishTotal` qui monte sans `fanoutTotal` = tu publies dans le vide (nom mal orthographié).
417
+
418
+ ## 11. Voisins
419
+
420
+ | Besoin | Skill |
421
+ | -------------------------------------------------------- | ------------------------ |
422
+ | Réserver une route HTTP, une page | `nodefony-protect-route` |
423
+ | Une ressource complète stockée (l'entité d'appartenance) | `nodefony-add-crud` |
424
+ | Un service métier injectable (la classe d'admission) | `nodefony-add-service` |
425
+ | Voir l'écran, piloter une socket depuis la page | `nodefony-browser` |
426
+ | Un canal qui répondait ne répond plus | `nodefony-troubleshoot` |