@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
@@ -0,0 +1,22 @@
1
+ # Un contrat, déclaré UNE fois — typer les deux bouts
2
+
3
+ > Chargé depuis `SKILL.md` §9 dès que navigateur et serveur échangent plus qu'un canal. Règle
4
+ > complète : `node_modules/@nodefony/realtime/docs/actions.md` (« Un contrat pour les deux bouts »).
5
+
6
+ Dès que navigateur et serveur échangent plus qu'un canal : sans contrat, une méthode oubliée
7
+ compile puis rend `-32601`. Le contrat est une **interface de types seuls** (0 octet compilé),
8
+ dans un fichier partagé que les deux côtés importent :
9
+
10
+ | Clé de `IRealtimeContract` | Ce qu'elle type | Servie côté serveur par |
11
+ | -------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------- |
12
+ | `serverToClient` | ce que les canaux publient (gabarit `user:${string}`) | `getRealtimeHub<C>().publish`, `publishToUser` |
13
+ | `clientToServer` | ce que le client pousse (`emit`) | les entrées |
14
+ | `serverMethods` | les actions `{ in, out }` | la table `realtimeActions()` (une oubliée ne compile pas) |
15
+ | `serverRoutes` | les routes du pont `"VERBE /chemin"` | `static readonly serverRoutes = {…} satisfies ContractRouteSchemas<C>` |
16
+
17
+ - Navigateur : `NodefonySocket.fromContract<C>({ url })` ; serveur :
18
+ `extends ContractRealtimeController<C>` ; politiques par `@RealtimePolicy` sur la classe (un nom
19
+ hors contrat ne compile pas) — n'ajoute pas `@RealtimeAction` à une méthode de la table.
20
+ - Une `Date` arrive en `string` : le contrat lit le type à travers JSON.
21
+ - **Le type n'est pas une validation** : la politique et le schéma de route restent les gardes.
22
+ - Le contrat complet du chat : `chatContract.ts` de la démo ; la règle : `actions.md` (contrat).
@@ -1,6 +1,6 @@
1
1
  # Ce qui ne se voit qu'en production — plusieurs serveurs, réseau, révocation, charge
2
2
 
3
- > Chargé depuis `SKILL.md` §8. Tout se vérifie dans la documentation installée :
3
+ > Chargé depuis `SKILL.md` §7. Tout se vérifie dans la documentation installée :
4
4
  > `node_modules/@nodefony/realtime/docs/configuration.md` (drivers, clés, variables),
5
5
  > `node_modules/@nodefony/realtime/docs/securite.md` (codes de fermeture, `allow` lent),
6
6
  > `node_modules/@nodefony/realtime/docs/cadence-et-contre-pression.md`,
@@ -22,6 +22,12 @@ décrit SON serveur, il ne doit pas traverser. Le framework diffuse `user:` lui-
22
22
 
23
23
  - L'oubli se voit : `localOnlyTotal` monte sur la sonde dès qu'un bus est branché et qu'un canal
24
24
  non déclaré est publié ; les préfixes diffusés sont listés au démarrage.
25
+ - **La présence d'un canal suit la même déclaration** : sans `@RealtimeBroadcast`, chaque serveur ne
26
+ voit que SES présents (skill `nodefony-add-realtime-presence`).
27
+ - **Les noms de canaux sont un espace PLAT, partagé par tous les modules du processus** : préfixe
28
+ les tiens par ton module ou ton domaine (`chat:`, `billing:`). `nodefony:` est réservé au
29
+ framework, et les suffixes `:health` / `:stats` sont réservés à l'administration dans TOUT espace
30
+ de noms (refusés sans `ROLE_NODEFONY_ADMIN`).
25
31
  - Le bus est un transport, pas un journal : un serveur absent 30 s ne rattrape rien. Redis
26
32
  injoignable au démarrage : repli sur ce seul processus, WARNING, l'application démarre.
27
33
  - **Secret du bus** : `NF_REALTIME_BACKPLANE_SECRET` (≥ 32 caractères), le même partout ; un
@@ -73,6 +79,11 @@ registre, exporté par `nodefony` : **compare aux constantes, jamais aux nombres
73
79
  | `4006` | `TOKEN_EXPIRED_CLOSE_CODE` | jeton PORTEUR expiré sur une socket ouverte | s'arrête : recrée la socket avec un jeton neuf AVANT `exp` |
74
80
  | `4007` | `BACKPRESSURE_CLOSE_CODE` | émis par le client : sa file d'envoi ne se vide plus | se reconnecte |
75
81
  | `1013` | `TRY_AGAIN_LATER_CLOSE_CODE` | serveur : client qui ne lit plus, ou authentification trop lente (10 s) | revient, avec recul |
82
+ | `1001` | — (code WebSocket standard) | le serveur s'arrête proprement (redéploiement, `SIGTERM`) | se reconnecte seul, sans alerte, et rejoue ses abonnements |
83
+
84
+ - **Redéploiement** : l'arrêt se fait en deux temps — le trafic s'écoule, puis le temps réel se
85
+ libère (la présence annonce le départ du serveur) et les sockets ferment en `1001`. Rien à écrire
86
+ côté client ; un écran qui veut afficher « reconnexion… » lit `socket.onState`.
76
87
 
77
88
  - Un refus à la poignée de main est un **statut HTTP** (401/403) que le navigateur cache : la
78
89
  socket sonde l'adresse en HTTP et traite 401/403/404 comme définitifs. Jamais de `4006` avant
@@ -107,7 +118,15 @@ registre, exporté par `nodefony` : **compare aux constantes, jamais aux nombres
107
118
  - Un `allow` qui lève = `forbidden` pour tous, sans un mot en production : une règle en classe
108
119
  fait échouer le DÉMARRAGE si sa dépendance manque. Préfère-la dès qu'elle lit la base.
109
120
 
110
- ## 7. Lire la sonde avant d'accuser le code
121
+ ## 7. Une socket sur une autre origine que la page
122
+
123
+ La politique de sécurité de contenu par défaut n'autorise que la même origine (`connect-src 'self'`,
124
+ qui couvre `wss:` sur la même machine). Socket servie ailleurs (sous-domaine, passerelle) : déclare
125
+ `publicOrigin` dans la config de `@nodefony/realtime` — l'origine s'ajoute SEULE à `connect-src`.
126
+ N'élargis jamais la politique à la main (`connect-src *`). Détail : `configuration.md`, clé
127
+ `publicOrigin`.
128
+
129
+ ## 8. Lire la sonde avant d'accuser le code
111
130
 
112
131
  `GET /nodefony/realtime/api/health` (rôle d'administration) ou le canal `nodefony:socket`.
113
132
 
@@ -0,0 +1,328 @@
1
+ ---
2
+ name: nodefony-add-realtime-presence
3
+ metadata:
4
+ version: 1.2.0
5
+ description: >
6
+ Affiche qui est là sur un canal temps réel Nodefony — qui est en ligne dans un salon, combien de
7
+ spectateurs, dans combien d'onglets — par l'option de présence que le framework tient lui-même,
8
+ au lieu d'une liste de connectés écrite à la main qui ment dès deux serveurs. Mène de la ligne
9
+ à déclarer à l'écran (React, Vue, Angular, Svelte) et à son test, et porte ce qui ne se voit
10
+ qu'en production : plusieurs serveurs, rechargement de page, redéploiement, salon de mille
11
+ personnes. À charger AVANT d'écrire une liste de présents, un compteur de spectateurs ou un
12
+ indicateur « en ligne ». Le canal lui-même (le créer, le protéger) relève de
13
+ `nodefony-add-realtime-channel`.
14
+ Déclencheurs : "qui est en ligne ?", "afficher les connectés", "liste des présents",
15
+ "combien de spectateurs", "indicateur en ligne", "statut en ligne", "Untel est en train
16
+ d'écrire", "la liste clignote quand je recharge", "les présents ne sont pas les mêmes sur deux
17
+ serveurs", "présence", "presence", "onPresence", "useNodefonyPresence".
18
+ ---
19
+
20
+ # add-realtime-presence — qui est là, sans rien tenir soi-même
21
+
22
+ > 🧭 **Arrivé ici directement ? Charge aussi `nodefony-dev`** (la conduite commune) et
23
+ > `nodefony-add-realtime-channel` (le canal qui porte la présence). Cette page ne couvre QUE la
24
+ > présence.
25
+ >
26
+ > Les pages de référence sont INSTALLÉES. Une commande les trouve — **exécute-la, puis lis la page** :
27
+ >
28
+ > ```bash
29
+ > node node_modules/@nodefony/devkit/skills/nodefony-dev/scripts/docs.mjs --open cookbook-chat
30
+ > ```
31
+ >
32
+ > La présence y est l'**étape 3** (« présence et frappe en cours »), le passage à plusieurs serveurs
33
+ > l'**étape 7**. Exemple exécuté : `cas-d-usage.md`, cas `chatPresence`.
34
+
35
+ > **Maintenance** : vérité courante, jamais un journal. Éditer en place ; historique = `git log`.
36
+ > Chaque bloc de code ci-dessous est un EXTRAIT d'un fichier exécuté par la suite du framework
37
+ > (marqueur `excerpt`) : un test refuse qu'il en diverge.
38
+
39
+ ## 1. Quand m'utiliser — et quand ce n'est pas de la présence
40
+
41
+ | Tu veux… | C'est… | Où |
42
+ | --------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------- |
43
+ | savoir qui est dans le salon, combien, dans quels onglets | **la présence** — une option du canal | ici |
44
+ | « Untel est en train d'écrire » | un **message éphémère** du canal, PAS la présence (§5) | ici, §5 |
45
+ | savoir si un message a été lu | une donnée de l'application (base + route), pas la présence | `nodefony-add-crud` |
46
+ | qui a le droit d'entrer | la **politique** du canal (`allow`, `roles`) — la présence ne garde rien | `nodefony-add-realtime-channel` |
47
+ | les inscrits d'un salon (même absents) | une table de l'application ; la présence ne connaît que les connectés | `nodefony-add-crud` |
48
+ | prévenir une personne précise | `publishToUser` (canal personnel) | `nodefony-add-realtime-channel` |
49
+
50
+ **N'écris jamais de liste de connectés à la main** (un `Set` rempli à l'abonnement, publié toutes
51
+ les N secondes) : chaque serveur ne connaît que SES connexions — derrière `nodefony cluster -w 2`
52
+ ou deux pods, chacun publie sa liste et écrase celle de l'autre, et les départs arrivent avec N
53
+ secondes de retard. Celle du framework est tenue entre les serveurs, groupée par fenêtre de 250 ms,
54
+ sans minuteur par présent.
55
+
56
+ 🔴 **Le geste complet a TROIS morceaux — « afficher qui est en ligne » n'est pas fini sans le
57
+ troisième :**
58
+
59
+ 1. **le canal déclare la présence** (§2) — côté serveur, une option ;
60
+ 2. **le canal est diffusé** (`@RealtimeBroadcast`, §7) dès qu'il y a plus d'un serveur ;
61
+ 3. **l'ÉCRAN lit la présence** (§3) — c'est lui que l'utilisateur voit. Un serveur qui tient la
62
+ liste sans page qui l'affiche ne montre rien à personne. L'application n'a pas encore de front ?
63
+ `npx nodefony create front <nom> --frontend react` (ou `vue`, `angular`, `svelte`) livre une page
64
+ déjà branchée au temps réel ; ajoute-y la liaison du §3. Le test (§10) lit ce que cet écran
65
+ afficherait.
66
+
67
+ ## 2. La ligne — une option de la politique du canal
68
+
69
+ Être abonné, c'est être présent. La présence se déclare **sur le canal lui-même** :
70
+
71
+ <!-- excerpt: chatPresence.ts -->
72
+
73
+ ```ts
74
+ // La présence est portée PAR le canal : être abonné, c'est être présent. Ni
75
+ // minuteur, ni liste à tenir, ni second canal — et elle reste juste quand
76
+ // l'application tourne sur plusieurs pods (étape 7).
77
+ @RealtimeChannel(ROOM_CHANNEL, { presence: true })
78
+ room(): () => void {
79
+ return () => {};
80
+ }
81
+ ```
82
+
83
+ Le framework tient la liste, l'envoie ENTIÈRE à chaque abonné qui arrive, puis n'envoie que les
84
+ changements. Une connexion fermée sort de la liste aussitôt (au plus une fenêtre de 250 ms).
85
+
86
+ Avec une **famille de salons privés** et une **grâce** — la forme du chat de la démo :
87
+
88
+ <!-- excerpt: demo/server/nodefony/controllers/ChatController.ts.tpl -->
89
+
90
+ ```ts
91
+ @RealtimeChannel(ROOM_CHANNEL_PATTERN, {
92
+ allow: RoomAdmission,
93
+ anonymous: true,
94
+ presence: { grace: PRESENCE_GRACE_MS },
95
+ })
96
+ room(): () => void {
97
+ return () => {};
98
+ }
99
+ ```
100
+
101
+ La présence suit l'**admission** : seul un abonné admis par `allow` figure dans la liste. Elle ne
102
+ remplace donc jamais la politique — un canal à présence sans politique montre ses présents à tout
103
+ le monde.
104
+
105
+ | Option | Défaut | Effet |
106
+ | ---------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
107
+ | `true` | — | présence exacte : un départ est annoncé tout de suite |
108
+ | `grace` | `0` | ms d'attente avant d'annoncer un départ ; annulé si la même identité revient (rechargement de page, redéploiement). Un chat : quelques secondes. Un jeu : `0` |
109
+ | `maxNamed` | `1000` | au-delà, la trame vers le navigateur ne porte plus que les NOMBRES ; la liste revient sous `maxNamed − maxNamed/10` (pas de bascule en boucle à 999 ↔ 1000) |
110
+
111
+ Toute autre clé refuse le démarrage. `presence` est refusée sur un canal de la plateforme
112
+ (`nodefony:`) et sur la politique d'une **écriture** (`@RealtimeInbound`) : elle n'y a pas de sens.
113
+
114
+ ## 3. Lire côté navigateur
115
+
116
+ `onPresence` rappelle l'écran avec **la vue entière** à chaque changement : rien à fusionner soi-même.
117
+
118
+ <!-- excerpt: chatPresence.client.ts -->
119
+
120
+ ```ts
121
+ /** Qui est là : rappelé à chaque changement, la vue entière à chaque fois. */
122
+ export function watchPresence(render: (line: string) => void): () => void {
123
+ const off = socket.onPresence(ROOM, (p) => {
124
+ if (!p.ready) return; // « pas encore reçu » n'est pas « vide »
125
+ const names = [...p.members.keys()].join(", ");
126
+ const total = p.count + p.unnamed; // nommés (comptes) + connexions anonymes
127
+ render(`${total} présent(s)${names === "" ? "" : ` : ${names}`}`);
128
+ });
129
+ socket.subscribe(ROOM);
130
+ return () => {
131
+ off();
132
+ socket.unsubscribe(ROOM);
133
+ };
134
+ }
135
+ ```
136
+
137
+ | Champ | Ce qu'il dit |
138
+ | --------- | ----------------------------------------------------------------------------------------------------------- |
139
+ | `ready` | `false` tant que la première liste n'est pas arrivée — n'affiche pas « personne » avant |
140
+ | `count` | présents **nommés** : un par compte, quel que soit son nombre d'onglets |
141
+ | `unnamed` | connexions comptées **sans nom** : anonymes, appelants machine sans compte |
142
+ | `members` | `Map` identité → `{ tabs }` : qui, et dans combien d'onglets |
143
+ | `partial` | `true` quand la vue est figée ou incomplète (socket coupée, serveur injoignable) : grise-la, ne la vide pas |
144
+
145
+ La présence s'abonne avec le canal : sans `subscribe`, aucune trame n'arrive. Avant la première,
146
+ l'instantané vaut `EMPTY_PRESENCE` (`nodefony/client`), `ready: false`.
147
+
148
+ | Vue | Liaison | Import |
149
+ | ---------- | --------------------------------------------------------------------------------------------------- | ------------------ |
150
+ | React | `useNodefonyPresence(socket, "canal")`, ou `useNodefonyPresence("canal")` sous `<NodefonyProvider>` | `nodefony/react` |
151
+ | Vue | `useNodefonyPresence(socket, "canal")` | `nodefony/vue` |
152
+ | Angular | `injectNodefonyPresence(socket, "canal")` | `nodefony/angular` |
153
+ | Svelte | `nodefonyPresence(socket, "canal")` | `nodefony/svelte` |
154
+ | sans cadre | `socket.onPresence(canal, h)`, `socket.presence(canal)`, socle `observePresence` | `nodefony/client` |
155
+
156
+ Chaque liaison rend le même instantané et se désabonne au démontage du composant.
157
+
158
+ **La présence donne des IDENTITÉS, jamais des noms affichables.** L'écran résout identité → nom par
159
+ une route de l'application. Le chat de la démo appelle son annuaire à chaque changement :
160
+
161
+ <!-- excerpt: demo/front/frontend/src/chat/useChat.ts.tpl -->
162
+
163
+ ```ts
164
+ // Qui est là : la vue du salon, puis les noms demandés à l'annuaire.
165
+ socket.onPresence(channel, (snapshot) => {
166
+ setPresence(snapshot);
167
+ roster.observe(snapshot.members, snapshot.partial, Date.now());
168
+ draw();
169
+ askNames();
170
+ scheduleMembers();
171
+ }),
172
+ ```
173
+
174
+ Se retirer soi-même de la liste : filtrer sur l'identité de l'onglet (`socket.onIdentity`).
175
+
176
+ ## 4. Qui est nommé — ce que le serveur a vérifié, jamais ce que le client affirme
177
+
178
+ - **Nommé = un compte** (identité `account === true`). Un anonyme et un appelant machine sans
179
+ compte sont comptés dans `unnamed`, jamais nommés : la présence ne publie ni fournisseur
180
+ d'identité, ni sujet externe.
181
+ - Un pseudo envoyé par le client (« je suis Alice ») ne nomme personne. Sur une application sans
182
+ connexion, tout le monde est `unnamed` — c'est attendu, pas un défaut.
183
+ - Un compte dans trois onglets compte **une** fois, avec `tabs: 3`.
184
+ - **La présence PUBLIE les identifiants de compte à tout abonné admis**, anonymes compris sur un
185
+ salon public : l'identifiant d'un compte ne doit donc jamais être une donnée personnelle (adresse
186
+ électronique, login réattribuable). Reconnais « soi » par cet identifiant (`socket.onIdentity`),
187
+ pas par un nom affiché.
188
+ - **Savoir qui est en ligne SANS le publier** : `presence: { maxNamed: 0 }`. Les abonnés ne
189
+ reçoivent que des NOMBRES ; le serveur lit toujours les identités
190
+ (`getRealtimeHub().presence(canal)?.members`) et n'en rend que ce qu'une route autorise. La démo
191
+ s'en sert pour son canal `chat:online` : seuls les membres d'un salon privé apprennent si les
192
+ autres sont dans le salon, en ligne ailleurs ou absents (`RoomService.members`).
193
+
194
+ Le test qui le prouve lit la présence côté serveur :
195
+
196
+ <!-- excerpt: chatPresence.test.ts -->
197
+
198
+ ```ts
199
+ it("se déclarer « bob » ne nomme personne : seul un compte est nommé", async () => {
200
+ const guest = await connect();
201
+ await guest.subscribe(ROOM_CHANNEL);
202
+ await guest.notify("chat:typing", { author: "bob" });
203
+ await flush();
204
+ expect(getRealtimeHub().presence(ROOM_CHANNEL)).toMatchObject({
205
+ count: 0,
206
+ unnamed: 1,
207
+ });
208
+ });
209
+ ```
210
+
211
+ ## 5. « En train d'écrire » n'est PAS de la présence
212
+
213
+ Un état de présence DURE ; la frappe est un **message éphémère du canal**, que chacun reçoit parce
214
+ qu'il y est abonné, et que l'écran efface seul. La faire passer par la présence renverrait la liste
215
+ entière à tout le salon à chaque frappe. Le service de la démo publie la frappe sur le canal du
216
+ salon — elle suit donc ses droits, et un salon privé ne fuit pas sa frappe :
217
+
218
+ <!-- excerpt: demo/server/nodefony/service/ChatService.ts.tpl -->
219
+
220
+ ```ts
221
+ /**
222
+ * « Untel écrit… » dans un salon : un message ÉPHÉMÈRE du canal, ni gardé ni
223
+ * compté — il suit donc les droits du salon (un salon privé ne fuit pas sa
224
+ * frappe), ce qu'un canal de frappe commun à tous ne ferait pas.
225
+ */
226
+ typing(roomId: string, author: string): void {
227
+ publishFrame(roomId, { kind: "typing", author });
228
+ }
229
+ ```
230
+
231
+ Côté navigateur, envoie le signal au plus toutes les deux secondes, et efface l'indication ~3 s
232
+ après la dernière réception. Le geste qui le PORTE jusqu'au serveur — une route (contrôle
233
+ d'admission par salon, comme la démo) ou une entrée de canal (`@RealtimeInbound`, comme le
234
+ cookbook) — se choisit par la règle « route ou entrée ? » de `nodefony-add-realtime-channel`.
235
+
236
+ ## 6. Lire côté serveur — l'observateur qui ne figure pas
237
+
238
+ `getRealtimeHub().presence(canal)` rend `{ count, unnamed, members, partial }` telle que CE serveur
239
+ la connaît (présents des autres serveurs compris), ou `null` si la politique du canal ne déclare pas
240
+ la présence. Une copie à chaque appel : chemin froid, pas une boucle de jeu. C'est la voie d'un
241
+ modérateur ou d'un tableau de bord qui lit sans apparaître dans la liste, et celle d'un test (§4 en montre un).
242
+
243
+ ## 7. Plusieurs serveurs — UNE condition, puis rien à écrire
244
+
245
+ 🔴 **La présence ne traverse les serveurs que si le canal est DIFFUSÉ** : `@RealtimeBroadcast("chat:")`
246
+ sur la classe du contrôleur, et un bus (`backplane.driver: "cluster"` sur une machine, `"redis"`
247
+ entre pods). Sans la déclaration, chaque serveur ne voit QUE ses propres présents — la liste diffère
248
+ selon le serveur où tombe l'onglet, sans une erreur. Le préfixe diffusé couvre aussi les messages
249
+ et la frappe du canal.
250
+
251
+ Ensuite, rien à écrire : la liste entière part aux nouveaux venus, les changements entre serveurs
252
+ sont groupés, un serveur gelé ou coupé du bus est oublié puis réparé, et une vue incomplète arrive
253
+ avec `partial: true`.
254
+
255
+ | Situation | Ce qui se passe |
256
+ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
257
+ | page rechargée | départ puis retour : invisible avec `grace`, un clignotement sans |
258
+ | seul utilisateur d'un serveur qui recharge | la présence reste servie aux autres pendant `grace` : pas de clignotement chez eux |
259
+ | redéploiement (arrêt propre du serveur) | le serveur annonce son départ : ses présents disparaissent chez les autres dans la fenêtre, sans attendre l'oubli ; les clients reconnectés ailleurs reviennent, `grace` lisse le passage |
260
+ | bus fourni par l'application (`realtimeBackplane`) | la fin est émise, mais arrêter CE bus reste à l'application : `shutdownRealtime` (`@nodefony/realtime`) |
261
+ | deux déploiements de la même application sur un Redis partagé | `backplane.namespace` distinct, sinon les présences se mélangent |
262
+
263
+ ## 8. Ce que la présence coûte — et ne coûte pas
264
+
265
+ - **Un canal sans `presence` ne coûte rien** : ni message, ni minuteur, ni structure.
266
+ - Au plus **5 trames de présence par seconde** par client et par canal (fenêtre de 250 ms).
267
+ - Le bus au repos est indépendant du nombre de canaux (une preuve de vie par serveur, ~260 octets).
268
+ - Mesuré au banc du framework : ~1,8 Ko/s par client pour un salon de 1 000 membres et 30
269
+ mouvements par seconde ; ~75 octets de mémoire par présent d'un autre serveur.
270
+ - Un salon de spectateurs (des milliers) : garde le défaut `maxNamed`, affiche `count + unnamed`.
271
+
272
+ ## 9. Ce que la présence n'est PAS — ne lui fais pas porter ça
273
+
274
+ - **Pas une autorisation** : elle montre qui a été admis, c'est `allow` / `roles` qui admettent.
275
+ - **Pas instantanée** : cohérente à une fenêtre près, réparée par resynchronisation. N'en déduis
276
+ jamais un droit (« il est présent, donc je lui envoie le secret »).
277
+ - **Pas un état** : en 10.0, aucun état (micro, caméra, absent) ne se pose sur un présent ; le
278
+ champ `states` est réservé à un ajout futur. Un état applicatif passe par un message du canal.
279
+ - **Pas la raison d'un départ** : quitté, exclu, panne — non publié. L'exclusion se lit côté
280
+ exclu (`realtime:closed { reason: "evicted" }`).
281
+ - **Pas les membres d'un salon** : `members` désigne les connectés. Les inscrits vivent en base.
282
+
283
+ ## 10. Prouver
284
+
285
+ Le harnais de `@nodefony/realtime/testing` joue des connexions réelles contre le contrôleur ; le
286
+ test lit ce que **l'écran** de chacune afficherait, avec la même vue que le navigateur
287
+ (`PresenceView`, `isPresenceFrame` de `nodefony`) :
288
+
289
+ <!-- excerpt: chatPresence.test.ts -->
290
+
291
+ ```ts
292
+ /** Ce que l'écran de cette connexion affiche : ses trames de présence, appliquées comme le fait le navigateur. */
293
+ function screenOf(h: IRealtimeHarness<ChatController>): IPresenceSnapshot {
294
+ const view = new PresenceView(ROOM_CHANNEL);
295
+ for (const frame of h.received) {
296
+ if (
297
+ frame.method === REALTIME_PRESENCE_METHOD &&
298
+ isPresenceFrame(frame.params)
299
+ )
300
+ view.apply(frame.params);
301
+ }
302
+ return view.snapshot();
303
+ }
304
+
305
+ /** Attendre un FAIT, borné : les changements partent groupés, par fenêtre de 250 ms. */
306
+ async function until(fact: () => boolean): Promise<void> {
307
+ for (let i = 0; i < 100 && !fact(); i++)
308
+ await new Promise((r) => setTimeout(r, 25));
309
+ expect(fact()).toBe(true);
310
+ }
311
+ ```
312
+
313
+ - **Attends un fait, jamais une durée** : `until(() => screenOf(alice).members.has("bob"))`, pas un
314
+ `setTimeout(300)` — les changements partent par fenêtre, une durée fixe rend le test instable.
315
+ - Prouve le **départ** (connexion fermée → absent chez le serveur PUIS sur l'écran des autres) et
316
+ l'**anonyme compté sans nom** — le test complet est dans `cas-d-usage.md`, cas `chatPresence`.
317
+ - **Vois-le rouge une fois** : retire `presence` de la politique, `screenOf(...).ready` reste `false`.
318
+ - Plusieurs serveurs ne se prouvent pas avec un seul processus : deux onglets sur un serveur ne
319
+ disent rien de la diffusion. `cookbook-chat.md` étape 7 donne le montage.
320
+
321
+ ## 11. Voisins
322
+
323
+ | Besoin | Skill |
324
+ | ------------------------------------------------------------- | ------------------------------- |
325
+ | Créer, protéger, diffuser le canal ; route ou entrée de canal | `nodefony-add-realtime-channel` |
326
+ | La table des inscrits, l'annuaire des noms | `nodefony-add-crud` |
327
+ | Voir l'écran, lire les trames depuis la page | `nodefony-browser` |
328
+ | La liste ne bouge plus, un serveur ne voit pas les autres | `nodefony-troubleshoot` |
@@ -215,8 +215,8 @@ preuve à chaque appel, `Authorization: Bearer …`.
215
215
 
216
216
  ❌ Un `POST` vers la route de login depuis un script, ou un cookie de session recopié dans sa
217
217
  configuration : le premier fait stocker un mot de passe au programme, le second expire et casse
218
- en silence. La zone qui reçoit ces preuves (`machine`, `partner`, `stateless: true`) et la
219
- commande qui l'éprouve (`app:partner:call`) → skill `nodefony-protect-route`. Détail :
218
+ en silence. La zone qui reçoit ces preuves (`stateless: true`) → skill `nodefony-protect-route` ;
219
+ une application née avec `--demo` en porte une qui marche (`demo:partner:call`). Détail :
220
220
  `node_modules/@nodefony/security/docs/api-keys.md`, `obtenir-un-jeton.md`.
221
221
 
222
222
  ## 12. Prouver que c'est fait
@@ -208,13 +208,19 @@ Grammaire : `verbe:cible[=valeur]`, séquence séparée par `|`, verbe facultati
208
208
  cible cherchée d'abord comme **texte visible** puis comme sélecteur CSS. Verbes : `clic` · `double`
209
209
  · `droit` (clic droit) · `survol` · `saisir:Nom=valeur` · `choisir:Liste=valeur` (une option d'un
210
210
  `<select>`, par sa valeur ou son libellé ; la cible peut être le libellé qui l'enveloppe) ·
211
- `touche:Enter` · `voir` · `defiler:600` · `attendre` · `connecter:` (se connecte dans un
211
+ `deposer:input[type=file]=chemin` (joint un fichier, comme la boîte du système ; le champ peut être
212
+ caché derrière un bouton) · `touche:Enter` · `voir` · `defiler:600` · `molette:600` · `attendre` · `connecter:` (se connecte dans un
212
213
  AUTRE onglet, sans recharger la page mesurée — qui s'ouvre alors anonyme : une page déjà
213
214
  ouverte voit-elle la session apparaître ?).
214
215
 
215
216
  - 🔴 **`voir` plutôt qu'un clic pour amener dans la vue** : sur un formulaire, le texte d'une
216
217
  question est un `label` — cliquer dessus coche la case qu'il décrit, et tu observes alors un écran
217
218
  que l'observation a modifié.
219
+ - **`molette` n'est pas `defiler`** : `defiler` appelle `scrollBy` sur un conteneur
220
+ choisi ; `molette:N` envoie une VRAIE molette là où est le pointeur (`survol:` avant).
221
+ C'est le navigateur qui décide alors quoi faire défiler — seule façon de constater
222
+ qu'une zone qui défile rend la main à la page une fois en bout. Un geste reste
223
+ « accroché » au conteneur où il a commencé : enchaîner plusieurs `survol|molette`.
218
224
  - ⚠️ **`defiler` n'est pas `NF_BROWSER_FULLPAGE=1`** : une application dont le contenu défile dans
219
225
  un conteneur interne (toute interface à barre latérale fixe) ne grandit pas — la capture « page
220
226
  entière » y rend exactement la fenêtre, et l'on croit que ce qui est plus bas n'existe pas.
@@ -250,6 +256,10 @@ que soit ta trousse d'interface. Valeurs : `light`, `dark`, `no-preference` ; to
250
256
  Si ton application **mémorise** le choix de l'utilisateur, elle n'obéit plus à cette média query :
251
257
  donne alors la clé de stockage, qui t'appartient — le code ne la devine pas.
252
258
 
259
+ `NF_BROWSER_REDUCED_MOTION=reduce` émule `prefers-reduced-motion` : une page dont les sections
260
+ apparaissent en fondu au défilement se mesure alors au repos, au lieu de rendre des contrastes
261
+ de textes à demi transparents (détail : `references/sondes.md`, famille `axe`).
262
+
253
263
  ```bash
254
264
  NF_BROWSER_STORAGE="ma-cle-de-theme=light" node .../scripts/inspect.mjs /
255
265
  ```
@@ -79,6 +79,12 @@ le moteur qu'embarque Lighthouse pour son volet accessibilité.
79
79
  **41 faux positifs** masquant **7 défauts réels**, dont celui qu'on cherchait — à cause de trois
80
80
  cas particuliers (échelle des couleurs modernes, alpha non composé, emoji peints par une police en
81
81
  couleurs) qu'on ne devine pas avant de les avoir vus.
82
+ - **Une page qui ANIME son contenu au défilement trompe le contraste.** Un fondu lié à
83
+ `animation-timeline: view()` laisse les sections hors de la vue à demi transparentes : `axe` y
84
+ mesure un texte que personne ne lit dans cet état, et rend des rouges identiques sur des éléments
85
+ sans rapport (même rapport partout, 2,67:1 par exemple). Rejouer avec
86
+ `NF_BROWSER_REDUCED_MOTION=reduce` : si la page respecte la préférence, la mesure porte sur le
87
+ texte au repos — et un rouge qui survit est un vrai défaut.
82
88
  - **Quand elle est indisponible** : `axe-core` vit dans les dépendances du projet. En conteneur, il
83
89
  faut le copier à part. La famille rend alors `verdict: "INDISPONIBLE"` et la commande à taper —
84
90
  jamais un `OK` qui n'a rien mesuré.
@@ -166,6 +172,19 @@ jeton de session imprimé dans une sortie de sonde finit dans un terminal, un lo
166
172
  l'empreinte mémoire, pas la taille « à l'écran » ; et un cookie `httpOnly: false` n'est pas signalé
167
173
  comme alerte alors qu'il mérite un regard si sa valeur est sensible.
168
174
 
175
+ ## `notifications` — ce que la page a levé
176
+
177
+ Le navigateur piloté n'affiche aucune notification : une notification réussie et une notification
178
+ jamais levée s'y ressemblent. La famille accorde la permission `notifications` d'avance (aucune
179
+ boîte de dialogue) et relève chaque `new Notification(titre, options)` — titre, corps, étiquette —
180
+ puis la crée pour de vrai. `LEVÉES` / `AUCUNE` est une **donnée** : aucune notification peut être
181
+ le bon résultat, si la personne était là.
182
+
183
+ Une page qui ne notifie qu'en l'**absence** de la personne (onglet caché, autre application au
184
+ premier plan) se mesure avec `NF_BROWSER_AWAY=1` : la fenêtre pilotée, elle, est toujours au
185
+ premier plan. Le message déclencheur part d'un AUTRE client pendant que la sonde attend
186
+ (`attendre:<texte>`).
187
+
169
188
  ## `responsive` — la même page à plusieurs largeurs
170
189
 
171
190
  Rejoue la mesure de débordement horizontal à chaque largeur de `NF_BROWSER_WIDTHS` (défaut
@@ -45,6 +45,7 @@ import {
45
45
  browserOrder,
46
46
  captureSlug,
47
47
  colorSchemeLaunchArgs,
48
+ stampedName,
48
49
  summarizeLighthouse,
49
50
  } from "./lib/probes.mjs";
50
51
 
@@ -206,9 +207,11 @@ try {
206
207
  // Le rapport COMPLET est conservé : le résumé sert à décider, l'original à
207
208
  // vérifier — et à comparer dans le temps.
208
209
  mkdirSync(OUTPUT, { recursive: true });
209
- const stamp = new Date().toISOString().replace(/[:.]/gu, "-").slice(0, 19);
210
210
  const slug = captureSlug(PAGE);
211
- const fullPath = path.join(OUTPUT, `lighthouse-${slug}-${stamp}.json`);
211
+ const fullPath = path.join(
212
+ OUTPUT,
213
+ stampedName(`lighthouse-${slug}`, new Date(), process.pid, "json"),
214
+ );
212
215
  writeFileSync(fullPath, JSON.stringify(lhr), "utf8");
213
216
 
214
217
  console.log(
@@ -15,16 +15,18 @@
15
15
  * `@env` NF_BROWSER_OUT dossier des captures et de l'état d'authentification (défaut constaté de la même façon)
16
16
  * `@env` NF_BROWSER_PAGE chemin de la page à ouvrir (défaut /)
17
17
  * `@env` NF_BROWSER_EXPECT texte DISCRIMINANT attendu avant de mesurer (défaut : aucun, on mesure après domcontentloaded)
18
- * `@env` NF_BROWSER_FAMILIES familles de sondes à activer, séparées par des virgules (a11y, axe, rendu, reseau, perf, stockage, responsive — ou « toutes ») ; défaut : aucune, le socle seul
18
+ * `@env` NF_BROWSER_FAMILIES familles de sondes à activer, séparées par des virgules (a11y, axe, rendu, reseau, perf, stockage, responsive, pli, notifications — ou « toutes ») ; défaut : aucune, le socle seul
19
+ * `@env` NF_BROWSER_AWAY `1` : la page se croit AILLEURS (onglet caché, autre application au premier plan) — pour mesurer ce qu'elle ne fait qu'en l'absence de la personne
19
20
  * `@env` NF_BROWSER_ENGINE navigateur imposé (chromium, chrome, msedge, firefox, webkit) ; sans lui, le premier qui répond
20
21
  * `@env` NF_BROWSER_COLOR_SCHEME schéma de couleurs émulé (light, dark, no-preference) — un défaut peut n'exister que dans UN thème
22
+ * `@env` NF_BROWSER_REDUCED_MOTION préférence de mouvement émulée (reduce, no-preference) — `reduce` mesure au repos une page à fondus au défilement
21
23
  * `@env` NF_BROWSER_STORAGE entrées de stockage local posées AVANT chargement (`clé=valeur`) — pour une application qui MÉMORISE son thème
22
24
  * `@env` NF_BROWSER_LOGIN chemin du formulaire de connexion de TON application — requis dès qu'un identifiant est donné, aucun défaut n'est deviné
23
25
  * `@env` NF_BROWSER_USER identifiant de connexion ; si absent, aucune authentification n'est tentée
24
26
  * `@env` NF_BROWSER_PASSWORD mot de passe associé
25
27
  * `@env` NF_BROWSER_ACTIONS séquence d'interactions AVANT mesure, séparées par `|` —
26
28
  * `verbe:cible[=valeur]` : clic (défaut), double, droit, survol,
27
- * saisir, choisir, touche, voir, defiler, attendre. Un écran qui se déplie
29
+ * saisir, choisir, deposer, touche, voir, defiler, molette, attendre. Un écran qui se déplie
28
30
  * au clic n'existe pas tant qu'on ne l'a pas ouvert.
29
31
  * `@env` NF_BROWSER_FULLPAGE 1 = capture la page ENTIÈRE (défaut : la fenêtre)
30
32
  * `@env` NF_BROWSER_PROBES sélecteurs CSS à sonder, séparés par des virgules (`libellé=sélecteur`) ;
@@ -53,6 +55,7 @@ import {
53
55
  parseFamilies,
54
56
  signsInMidway,
55
57
  parseProbes,
58
+ stampedName,
56
59
  parseWidths,
57
60
  pageExpression,
58
61
  summarizeAxe,
@@ -83,9 +86,11 @@ const EVAL = pageExpression(process.env.NF_BROWSER_EVAL);
83
86
  * | `survol` | survol : révèle une infobulle, un menu déroulant |
84
87
  * | `saisir` | remplit un champ (`saisir:Nom=mon-app`) |
85
88
  * | `choisir` | choisit une option d'une liste (`choisir:Habillage=dots`) |
86
- * | `touche` | frappe une touche (`touche:Enter`, `touche:Escape`) |
89
+ * | `deposer` | joint un fichier à un champ, même caché (`deposer:input[type=file]=tmp/a.png`) |
90
+ * | `touche` | frappe une touche sur le focus (`touche:Escape`), ou sur une cible (`touche:#onglet=End`) |
87
91
  * | `voir` | amène dans la vue SANS cliquer |
88
92
  * | `defiler` | fait défiler de N pixels (`defiler:600`), page ou conteneur |
93
+ * | `molette` | une vraie molette de N pixels sous le pointeur (`survol:` avant) |
89
94
  * | `attendre` | attend qu'un texte APPARAISSE (après une action lente) |
90
95
  * | `connecter` | se connecte dans un AUTRE onglet (`connecter:`), sans recharger |
91
96
  *
@@ -114,6 +119,8 @@ if (unknown.length > 0) {
114
119
  process.exit(64); // EX_USAGE
115
120
  }
116
121
  const active = new Set(kept);
122
+ /** `NF_BROWSER_AWAY=1` : la page se croit cachée et sans le focus. */
123
+ const AWAY = process.env.NF_BROWSER_AWAY === "1";
117
124
 
118
125
  /**
119
126
  * Sondes de style par défaut — surchargées par NF_BROWSER_PROBES.
@@ -153,11 +160,12 @@ async function axeSource() {
153
160
  return axe.source;
154
161
  }
155
162
 
156
- const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
157
-
158
163
  // `connecter:` dans la séquence ⇒ la page s'ouvre ANONYME (cf signsInMidway).
159
164
  const ANONYMOUS = signsInMidway(ACTIONS);
160
- const { browser, ctx, page, reuse } = await open({ anonymous: ANONYMOUS });
165
+ const { browser, ctx, page, reuse } = await open({
166
+ anonymous: ANONYMOUS,
167
+ permissions: active.has("notifications") ? ["notifications"] : [],
168
+ });
161
169
 
162
170
  // ── Collecteurs — posés AVANT toute navigation ──────────────────────────────
163
171
  const consoleErrors = [];
@@ -175,6 +183,39 @@ page.on("pageerror", (e) => {
175
183
  // Les violations CSP ne sont visibles QUE depuis la page : le réseau montre la
176
184
  // requête absente, jamais la raison. L'init script est réinjecté à chaque
177
185
  // navigation — c'est l'état de la DERNIÈRE page qu'on lit, celle qu'on mesure.
186
+ // Famille notifications : chaque `new Notification()` est RELEVÉE, puis
187
+ // créée pour de vrai — la page garde son comportement, on lit ce qu'elle a
188
+ // levé. Le pilote n'affiche aucune notification : sans ce relevé, une
189
+ // notification réussie et une notification jamais levée se ressemblent.
190
+ if (active.has("notifications")) {
191
+ await page.addInitScript(() => {
192
+ window.__nfNotifications = [];
193
+ const Native = window.Notification;
194
+ if (typeof Native !== "function") return;
195
+ class Recorded extends Native {
196
+ constructor(title, options) {
197
+ super(title, options);
198
+ if (window.__nfNotifications.length < 20)
199
+ window.__nfNotifications.push({
200
+ title: String(title).slice(0, 140),
201
+ body: String(options?.body ?? "").slice(0, 140),
202
+ tag: options?.tag ?? null,
203
+ });
204
+ }
205
+ }
206
+ window.Notification = Recorded;
207
+ });
208
+ }
209
+ // `NF_BROWSER_AWAY=1` : la personne est AILLEURS — onglet caché, autre
210
+ // application au premier plan. Une page qui ne notifie qu'en son absence ne
211
+ // se mesure pas autrement : la fenêtre pilotée est toujours au premier plan.
212
+ if (AWAY) {
213
+ await page.addInitScript(() => {
214
+ Object.defineProperty(document, "visibilityState", { get: () => "hidden" });
215
+ Object.defineProperty(document, "hidden", { get: () => true });
216
+ document.hasFocus = () => false;
217
+ });
218
+ }
178
219
  await page.addInitScript(() => {
179
220
  window.__nfCsp = [];
180
221
  document.addEventListener("securitypolicyviolation", (e) => {
@@ -827,6 +868,24 @@ if (active.has("reseau")) {
827
868
  };
828
869
  }
829
870
 
871
+ // ── Famille notifications — ce que la page a levé ──────────────────────────
872
+ if (active.has("notifications")) {
873
+ const raised = await page.evaluate(() => ({
874
+ permission:
875
+ typeof Notification === "function" ? Notification.permission : "absente",
876
+ list: window.__nfNotifications ?? [],
877
+ }));
878
+ measured.notifications = {
879
+ // Une DONNÉE, pas un jugement : aucune notification peut être le bon
880
+ // résultat (la personne était là).
881
+ verdict: raised.list.length > 0 ? "LEVÉES" : "AUCUNE",
882
+ permission: raised.permission,
883
+ away: AWAY,
884
+ count: raised.list.length,
885
+ list: raised.list.slice(0, 3),
886
+ };
887
+ }
888
+
830
889
  // ── Famille stockage — volet cookies, lu hors page ──────────────────────────
831
890
  if (active.has("stockage")) {
832
891
  const cookies = await ctx.cookies();
@@ -951,7 +1010,10 @@ if (EVAL) {
951
1010
 
952
1011
  // ── Capture — AVANT la famille responsive, qui déforme le viewport ──────────
953
1012
  const slug = captureSlug(PAGE);
954
- const shot = path.join(OUTPUT, `${slug}-${stamp}.png`);
1013
+ const shot = path.join(
1014
+ OUTPUT,
1015
+ stampedName(slug, new Date(), process.pid, "png"),
1016
+ );
955
1017
  // `NF_BROWSER_FULLPAGE=1` : la page ENTIÈRE, pas la fenêtre. Un formulaire long
956
1018
  // (ou une page qu'on vient de déplier) a l'essentiel SOUS la ligne de flottaison
957
1019
  // — la capture par défaut laisse alors conclure « ce n'est pas là ».