@nodefony/devkit 10.0.0-beta.6 → 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 (32) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
  3. package/dist/index.js +1 -1
  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/service/DevkitService.js +3 -3
  8. package/package.json +11 -11
  9. package/skills/nodefony-add-grpc-service/SKILL.md +179 -0
  10. package/skills/nodefony-add-grpc-service/scripts/call.mjs +218 -0
  11. package/skills/nodefony-add-realtime-binary/SKILL.md +301 -0
  12. package/skills/nodefony-add-realtime-binary/references/messages.md +20 -0
  13. package/skills/nodefony-add-realtime-binary/references/mesures.md +59 -0
  14. package/skills/nodefony-add-realtime-channel/SKILL.md +408 -326
  15. package/skills/nodefony-add-realtime-channel/references/contrat.md +22 -0
  16. package/skills/nodefony-add-realtime-channel/references/production.md +21 -2
  17. package/skills/nodefony-add-realtime-presence/SKILL.md +328 -0
  18. package/skills/nodefony-authentication/SKILL.md +2 -2
  19. package/skills/nodefony-browser/SKILL.md +11 -1
  20. package/skills/nodefony-browser/references/sondes.md +19 -0
  21. package/skills/nodefony-browser/scripts/audit.mjs +5 -2
  22. package/skills/nodefony-browser/scripts/inspect.mjs +69 -7
  23. package/skills/nodefony-browser/scripts/lib/actions.mjs +54 -3
  24. package/skills/nodefony-browser/scripts/lib/browser.mjs +18 -1
  25. package/skills/nodefony-browser/scripts/lib/probes.mjs +52 -3
  26. package/skills/nodefony-dev/SKILL.md +20 -15
  27. package/skills/nodefony-devops/references/dimensionnement.md +1 -1
  28. package/skills/nodefony-devops/references/frontal.md +5 -2
  29. package/skills/nodefony-devops/references/image.md +23 -15
  30. package/skills/nodefony-migrate-schema/references/verdicts.md +1 -0
  31. package/skills/nodefony-protect-route/SKILL.md +27 -32
  32. package/skills/nodefony-add-realtime-channel/references/binaire.md +0 -191
@@ -1,20 +1,19 @@
1
1
  ---
2
2
  name: nodefony-add-realtime-channel
3
3
  metadata:
4
- version: 2.1.1
4
+ version: 3.1.0
5
5
  description: >
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".
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".
18
17
  ---
19
18
 
20
19
  # add-realtime-channel — le temps réel par sa brique
@@ -27,7 +26,7 @@ description: >
27
26
  > les trouve et rend leur chemin — **exécute-la, puis lis la page** :
28
27
  >
29
28
  > ```bash
30
- > node node_modules/@nodefony/devkit/skills/nodefony-dev/scripts/docs.mjs --open cas-d-usage
29
+ > node node_modules/@nodefony/devkit/skills/nodefony-dev/scripts/docs.mjs --open cookbook-chat
31
30
  > node node_modules/@nodefony/devkit/skills/nodefony-dev/scripts/docs.mjs salon allow evict
32
31
  > ```
33
32
 
@@ -35,217 +34,370 @@ description: >
35
34
  > construction ; une **action** sans politique est fermée (connexion authentifiée exigée). Ce qui
36
35
  > n'est pas déclaré n'est pas gardé — et ce que le framework porte déjà ne se réécrit pas.
37
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
+
38
45
  ## 1. Quel besoin → quelle brique
39
46
 
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`,
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`,
62
67
  `node_modules/@nodefony/http/docs/sse.md`, `node_modules/nodefony/docs/client.md`.
63
68
 
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
69
  ## 2. Le geste
71
70
 
72
71
  ```bash
73
72
  npx nodefony create controller Room --kind realtime
74
73
  ```
75
74
 
76
- Produit une sous-classe de `RealtimeController`, avec un canal de démonstration, une action, une
75
+ Produit une sous-classe de `RealtimeController` avec un canal de démonstration, une action, une
77
76
  entrée client, et **son test** (`createRealtimeHarness`). Nomme le contrôleur par son USAGE
78
77
  (`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.
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`.
86
+
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.
82
90
 
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.
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.
87
93
 
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.
94
+ ## 3. Le patron d'un chat : LIRE par le canal, ÉCRIRE par une route
91
95
 
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.
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.
94
100
 
95
- ## 3. Les quatre mots du serveur
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 -->
96
105
 
97
106
  ```ts
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);
107
+ /** Ouvre le pont : les actions ci-dessous deviennent joignables par la socket. */
108
+ protected override realtimeApiRequest(): boolean {
109
+ return true;
113
110
  }
114
111
 
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 () => {};
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);
120
119
  }
120
+ ```
121
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
- }
122
+ **2 — le geste d'écriture est une route** : schéma validé, droit vérifié, rejeu sans doublon,
123
+ réponse à l'expéditeur :
124
+
125
+ <!-- excerpt: demo/server/nodefony/controllers/ChatController.ts.tpl -->
126
+
127
+ ```ts
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);
140
+ ```
141
+
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 -->
146
+
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
+ ```
132
152
 
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 };
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 -->
156
+
157
+ ```ts
158
+ await socket.post(
159
+ "/demo/chat/rooms/{room}/send",
160
+ replyTo === null ? { text } : { text, replyTo },
161
+ { params: { room: roomId } },
162
+ );
163
+ ```
164
+
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 -->
204
+
205
+ ```ts
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);
137
218
  }
138
- }
139
219
 
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
- }
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
+ }
145
226
  ```
146
227
 
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)`.
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)``).
152
237
  - **É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.
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 :
160
245
 
161
- ## 4. Qui a le droit — la politique, et ce qu'elle ne couvre pas
246
+ <!-- excerpt: privateRoom.ts -->
162
247
 
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**.
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
+ ```
261
+
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 -->
166
293
 
167
294
  ```ts
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 () => {};
178
- }
295
+ @injectable()
296
+ export class RoomAdmission extends Service implements IChannelAdmission {
297
+ constructor(@Inject("RoomService") private readonly rooms: RoomService) {
298
+ super("roomAdmission");
299
+ }
179
300
 
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;
301
+ async allow(channel: string, who: ISubscriberIdentity): Promise<boolean> {
302
+ return who.id !== null && (await this.rooms.isMember(channel, who.id));
303
+ }
184
304
  }
185
305
  ```
186
306
 
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
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 -->
222
342
 
223
343
  ```ts
224
344
  import { NodefonySocket } from "nodefony/client";
225
345
 
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
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
+ }
236
360
  ```
237
361
 
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
+
238
375
  - **Importe de `nodefony/client`**, jamais de la racine `nodefony` dans un bundle navigateur ;
239
376
  `nodefony/realtime` n'existe pas.
240
- - `shared()` sans `url` lève ; les options ne comptent qu'à la PREMIÈRE création (codec compris).
241
377
  - 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 ?
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 ?
249
401
 
250
402
  | Besoin | Choisir |
251
403
  | -------------------------------------------------------------------- | ------------------------------------------- |
@@ -253,174 +405,104 @@ socket.onUser((note) => {}); // les messages adressés à CE compte
253
405
  | Un réseau d'entreprise bloque les WebSocket | **SSE** — c'est une réponse HTTP ordinaire |
254
406
  | Deux sens, canaux, plusieurs onglets, identité par connexion | **socket** — le reste de cette page |
255
407
 
256
- ```ts
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
- }
263
- ```
264
-
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
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
267
411
  `Content-Type: text/event-stream` à la main** : tu perdrais le battement qui empêche un proxy de
268
412
  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`.
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`.
271
415
 
272
- ## 7. Un contrat, déclaré UNE fois
416
+ ## 9. Un contrat, déclaré UNE fois
273
417
 
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`.
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.**
276
423
 
277
- ```ts
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
- };
287
- }
288
- ```
424
+ ## 10. Prouver
289
425
 
290
- ```ts
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
426
+ ```bash
427
+ npm test
428
+ npx nodefony inspect routes --json
296
429
  ```
297
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
+
298
437
  ```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
- }
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;
305
449
  }
306
450
  ```
307
451
 
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.
452
+ <!-- excerpt: privateRoom.test.ts -->
347
453
 
348
- ## 10. Prouver
349
-
350
- ```bash
351
- npm test
352
- npx nodefony inspect routes --json
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
+ });
353
466
  ```
354
467
 
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 :
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 -->
357
479
 
358
480
  ```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();
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();
376
486
  ```
377
487
 
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.
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
+
380
491
  - **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é.
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`).
415
495
  - La santé en marche : `GET /nodefony/realtime/api/health` (rôle d'administration) —
416
496
  `publishTotal` qui monte sans `fanoutTotal` = tu publies dans le vide (nom mal orthographié).
417
497
 
418
498
  ## 11. Voisins
419
499
 
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` |
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` |