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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
  3. package/dist/index.js +3 -3
  4. package/dist/nodefony/controllers/DevkitController.js +1 -1
  5. package/dist/nodefony/controllers/McpController.js +2 -2
  6. package/dist/nodefony/controllers/ProfileSessionController.js +1 -1
  7. package/dist/nodefony/mcp/profileRoute.js +28 -9
  8. package/dist/nodefony/service/DevkitService.js +6 -6
  9. package/docs/index.md +1 -1
  10. package/package.json +13 -13
  11. package/skills/nodefony-add-crud/SKILL.md +38 -23
  12. package/skills/nodefony-add-grpc-service/SKILL.md +179 -0
  13. package/skills/nodefony-add-grpc-service/scripts/call.mjs +218 -0
  14. package/skills/nodefony-add-realtime-binary/SKILL.md +301 -0
  15. package/skills/nodefony-add-realtime-binary/references/messages.md +20 -0
  16. package/skills/nodefony-add-realtime-binary/references/mesures.md +59 -0
  17. package/skills/nodefony-add-realtime-channel/SKILL.md +452 -105
  18. package/skills/nodefony-add-realtime-channel/references/contrat.md +22 -0
  19. package/skills/nodefony-add-realtime-channel/references/production.md +140 -0
  20. package/skills/nodefony-add-realtime-presence/SKILL.md +328 -0
  21. package/skills/nodefony-authentication/SKILL.md +2 -2
  22. package/skills/nodefony-browser/SKILL.md +37 -2
  23. package/skills/nodefony-browser/references/sondes.md +19 -0
  24. package/skills/nodefony-browser/scripts/audit.mjs +17 -2
  25. package/skills/nodefony-browser/scripts/inspect.mjs +108 -87
  26. package/skills/nodefony-browser/scripts/lib/actions.mjs +164 -0
  27. package/skills/nodefony-browser/scripts/lib/browser.mjs +67 -11
  28. package/skills/nodefony-browser/scripts/lib/probes.mjs +108 -3
  29. package/skills/nodefony-browser/scripts/watch.mjs +27 -11
  30. package/skills/nodefony-dev/SKILL.md +31 -15
  31. package/skills/nodefony-dev/references/rapport-de-build.md +19 -0
  32. package/skills/nodefony-devops/SKILL.md +28 -21
  33. package/skills/nodefony-devops/references/dimensionnement.md +1 -1
  34. package/skills/nodefony-devops/references/frontal.md +5 -2
  35. package/skills/nodefony-devops/references/image.md +23 -15
  36. package/skills/nodefony-migrate-schema/SKILL.md +110 -20
  37. package/skills/nodefony-migrate-schema/references/verdicts.md +23 -20
  38. package/skills/nodefony-protect-route/SKILL.md +27 -32
@@ -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).
@@ -0,0 +1,140 @@
1
+ # Ce qui ne se voit qu'en production — plusieurs serveurs, réseau, révocation, charge
2
+
3
+ > Chargé depuis `SKILL.md` §7. Tout se vérifie dans la documentation installée :
4
+ > `node_modules/@nodefony/realtime/docs/configuration.md` (drivers, clés, variables),
5
+ > `node_modules/@nodefony/realtime/docs/securite.md` (codes de fermeture, `allow` lent),
6
+ > `node_modules/@nodefony/realtime/docs/cadence-et-contre-pression.md`,
7
+ > `node_modules/@nodefony/realtime/docs/canaux-a-membres.md` (gestes qui traversent les serveurs),
8
+ > `node_modules/@nodefony/realtime/docs/observabilite.md` (les compteurs qui accusent un oubli).
9
+
10
+ ## 1. Plusieurs serveurs — rien ne traverse sans intention
11
+
12
+ | Déploiement | `backplane.driver` | Ce qui se passe sinon |
13
+ | ------------------------------------ | ----------------------------------- | ---------------------------------------------------------------------------------------------- |
14
+ | un seul processus | `loopback` | — |
15
+ | `nodefony cluster -w N`, une machine | `cluster` (défaut de l'app générée) | sans bus, un abonné sur le worker 2 ne reçoit pas ce que publie le worker 1 |
16
+ | plusieurs machines ou pods | `redis` (+ `@nodefony/redis`) | `loopback` à plusieurs répliques : la moitié des utilisateurs ne reçoit plus rien, sans erreur |
17
+
18
+ Un bus branché ne diffuse **que les canaux déclarés** : pose `@RealtimeBroadcast("chat:", "room:")`
19
+ sur la classe du contrôleur, **sur tous les serveurs** (le receveur aussi). Un préfixe couvre
20
+ `chat:`, `chat:room-42`, `chat:room-42:1000`. Le défaut est l'isolement : un canal d'observabilité
21
+ décrit SON serveur, il ne doit pas traverser. Le framework diffuse `user:` lui-même.
22
+
23
+ - L'oubli se voit : `localOnlyTotal` monte sur la sonde dès qu'un bus est branché et qu'un canal
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`).
31
+ - Le bus est un transport, pas un journal : un serveur absent 30 s ne rattrape rien. Redis
32
+ injoignable au démarrage : repli sur ce seul processus, WARNING, l'application démarre.
33
+ - **Secret du bus** : `NF_REALTIME_BACKPLANE_SECRET` (≥ 32 caractères), le même partout ; un
34
+ message non scellé est refusé et compté (`sealRejectedTotal`). Deux applications sur un même
35
+ Redis : `backplane.namespace` distinct — c'est une clé de sécurité, pas de confort.
36
+ - **Ce qui traverse, et ce qui ne traverse pas** : les publications des canaux diffusés, et deux
37
+ ordres qui ne font qu'enlever — `closeChannel(canal)` et `evictUser(canal, compte)`. Les gestes
38
+ par CONNEXION (`subscribeConnection`, `evictConnection`, `channelMembers`) sont locaux au
39
+ serveur qui tient la socket : appelle-les depuis une entrée client (`@RealtimeInbound`, qui
40
+ s'exécute par construction sur ce serveur), jamais depuis une requête HTTP routée au hasard.
41
+ - **Bus partagé = bus privé.** Un ordre d'éjection capté sur le bus peut être rejoué par qui y
42
+ écrit : l'effet reste une coupure (jamais une admission), et changer le secret rend caducs les
43
+ ordres captés. Risque écrit et accepté (ADR-0022 §6).
44
+
45
+ ## 2. Reconnexion — ce qui est rejoué, ce qui ne l'est pas
46
+
47
+ - Le client rejoue **ses abonnements** au `realtime:welcome` (imposés compris), et chacun repasse
48
+ par la politique et par `allow`. D'où la règle : l'appartenance est écrite là où `allow` la lit
49
+ AVANT d'abonner, sinon le membre est refusé à la première coupure (sur mobile : dans la minute).
50
+ - Il ne rejoue **ni les messages publiés pendant la coupure, ni les requêtes en vol**. Une
51
+ discussion se reprend par une route (`GET /chat/history?since=<dernier id>`), dédoublonnée sur
52
+ l'identifiant persistant, triée sur l'identifiant croissant de la base — jamais une horloge.
53
+ - Un canal fermé par le serveur (`realtime:closed`, `ended` ou `evicted`) n'est pas rejoué. Un
54
+ rechargement de page, si : c'est `allow` qui tranche.
55
+ - `connected` ne veut pas dire abonné : `await socket.whenSubscribed(canal)` avant de charger
56
+ l'état initial, sinon un événement entre les deux est perdu.
57
+
58
+ ## 3. Garanties — ce que le framework promet, et rien de plus
59
+
60
+ | Livraison | Ordre | Livraison | En cas de perte |
61
+ | --------- | ------------------------------------------------ | ---------------- | --------------------------------------------------------------------------- |
62
+ | socket | par flux et par émetteur ; jamais entre serveurs | au plus une fois | un trou, jamais un réordonnancement ; client qui ne lit plus fermé (`1013`) |
63
+ | flux SSE | par flux | au plus une fois | un trou ; la reprise appartient à l'action |
64
+ | bus | par émetteur | au plus une fois | un trou |
65
+
66
+ Une alarme machine qui ne doit pas se perdre se bâtit **au-dessus** : identifiant dans la charge,
67
+ accusé par une entrée client, réémission par l'application. Le socle ne stocke rien.
68
+
69
+ ## 4. Révocation — une socket survit à sa session
70
+
71
+ Toutes les 30 s, chaque connexion authentifiée est revalidée. Les codes vivent dans un seul
72
+ registre, exporté par `nodefony` : **compare aux constantes, jamais aux nombres**.
73
+
74
+ | Code | Constante | Cause | Le client |
75
+ | ------ | ----------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------- |
76
+ | `4001` | `IDENTITY_DEAD_CLOSE_CODE` | session détruite ou expirée, jeton révoqué | s'arrête |
77
+ | `4003` | `ORIGIN_REFUSED_CLOSE_CODE` | origine refusée à la poignée de main | s'arrête |
78
+ | `4005` | `IDENTITY_REFRESH_CLOSE_CODE` | droits changés, identité vivante | revient aussitôt — lis `onIdentity()` pour rafraîchir l'écran |
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` |
80
+ | `4007` | `BACKPRESSURE_CLOSE_CODE` | émis par le client : sa file d'envoi ne se vide plus | se reconnecte |
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`.
87
+
88
+ - Un refus à la poignée de main est un **statut HTTP** (401/403) que le navigateur cache : la
89
+ socket sonde l'adresse en HTTP et traite 401/403/404 comme définitifs. Jamais de `4006` avant
90
+ authentification.
91
+ - Un membre retiré d'une salle reste abonné tant que sa connexion vit : l'exclusion immédiate, c'est
92
+ `evictUser` ; la révocation ferme la connexion entière.
93
+
94
+ ## 5. Contre-pression et bornes — une règle, trois endroits
95
+
96
+ - **Serveur** (`@nodefony/http`, clés `websocket` ET `websocketSecure` — régler l'un ne règle pas
97
+ l'autre) : `maxBackpressure` 4 MiB, `backpressurePolicy: "drop"` (défaut) ou `"close"`,
98
+ `backpressureCloseAfterDrops` 1000 → fermeture `1013`. `drop` ne trie pas : une réponse RPC
99
+ peut partir aussi. Un flux où chaque message compte choisit `close`, ou un accusé applicatif.
100
+ - **Débit entrant** : `maxFramesPerSecond` (défaut `0` = désactivé), fermeture `1008`
101
+ sur un solde de refus — jamais au premier dépassement (une reconnexion qui rejoue ses
102
+ abonnements est une rafale légitime). Pour un jeu : `120` et `frameRateCloseAfterRejects: 1000`.
103
+ - **Navigateur** : `maxBufferedAmount` (désactivé par défaut) jette un coup périmé au lieu de
104
+ l'empiler, compté dans `framesDropped` ; `backpressureCloseAfterDrops` ferme en `4007`.
105
+ - **Cadence adaptative** : un canal d'ÉTAT se demande `base:<ms>` ; le serveur le sert par
106
+ `createRealtimeChannel` + `parseRate` (`nodefony`), le client par
107
+ `useNodefonyAdaptiveChannelData`. Jamais sur un canal d'événements.
108
+ - `slowConsumer.bytes` (`@nodefony/realtime`) compte, ne freine pas.
109
+
110
+ ## 6. `allow` sous charge
111
+
112
+ - Rejeu au redémarrage : N clients × M canaux appels d'`allow` en rafale. Parade : une règle en
113
+ classe qui lit un registre partagé, ou un cache court côté application.
114
+ - `allowTimeoutMs` 5 s → `unavailable` (le client réessaie seul, 3 fois, gigue complète) ;
115
+ `maxPendingAllowsPerConnection` 8 (la demande attend son tour) ; `maxPendingAllowsTotal` 512
116
+ → `unavailable` ; coupe-circuit 50 dépassements en 5 s. Compteurs : `allowTimedOutTotal`,
117
+ `allowSaturatedTotal`.
118
+ - Un `allow` qui lève = `forbidden` pour tous, sans un mot en production : une règle en classe
119
+ fait échouer le DÉMARRAGE si sa dépendance manque. Préfère-la dès qu'elle lit la base.
120
+
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
130
+
131
+ `GET /nodefony/realtime/api/health` (rôle d'administration) ou le canal `nodefony:socket`.
132
+
133
+ | Signal | Lecture |
134
+ | ------------------------------------------------- | ------------------------------------------------------------------------ |
135
+ | `publishTotal` monte, `fanoutTotal` stagne | tu publies dans le vide : nom de canal différent entre serveur et client |
136
+ | `localOnlyTotal` > 0 sur un canal applicatif | `@RealtimeBroadcast` oublié |
137
+ | `ingressRejectedTotal`, `sealRejectedTotal` | quelqu'un d'autre écrit sur le bus, ou rotation du secret en cours |
138
+ | `backpressure.slowConsumers`, `drops` | un client ne suit pas ; la protection agit — ce n'est pas une panne |
139
+ | `allowTimedOutTotal`, `authenticateTimedOutTotal` | la base ne répond plus |
140
+ | `systemFloorDeniedTotal` | un client demande un canal de plateforme sans module de sécurité |
@@ -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
@@ -70,7 +70,10 @@ npx playwright install chromium
70
70
 
71
71
  `NF_BROWSER_ENGINE=chrome` (ou `msedge`, `chromium`) impose un navigateur précis. Un choix explicite
72
72
  n'est **jamais** complété par un repli : se rabattre en silence attribuerait la mesure au mauvais
73
- moteur. ⚠️ À ne pas confondre avec `NF_BROWSER_CHANNEL`, qui désigne le canal d'un socket
73
+ moteur. `NF_BROWSER_ENGINE=firefox` (ou `webkit`) change de MOTEUR — après
74
+ `npx playwright install firefox` : ce que dicte une norme (sécurité du contenu, rapports de
75
+ violation, cookies) diverge d'un moteur à l'autre, et Chromium seul n'en dit rien. L'audit
76
+ Lighthouse, lui, reste réservé à Chromium. ⚠️ À ne pas confondre avec `NF_BROWSER_CHANNEL`, qui désigne le canal d'un socket
74
77
  applicatif — le mot « canal » sert aux deux dans des mondes différents.
75
78
 
76
79
  ## L'autre voie — en conteneur, et QUAND s'en servir
@@ -182,6 +185,15 @@ Réglages par variables d'environnement : `NF_BROWSER_BASE`, `NF_BROWSER_PAGE`,
182
185
  (`libellé=sélecteur`, séparés par des virgules), `NF_BROWSER_FAMILIES`, `NF_BROWSER_WIDTHS`,
183
186
  `NF_BROWSER_SEUIL_LOURD`, `NF_BROWSER_SEUIL_LENT`, `NF_BROWSER_ACTIONS`, `NF_BROWSER_FULLPAGE`. Le détail vit dans l'en-tête de chaque script.
184
187
 
188
+ **Lire un état que seule ton application connaît** (un store, l'instantané d'une socket) :
189
+ `NF_BROWSER_EVAL` prend une expression évaluée DANS la page après les actions et les mesures, et
190
+ rend son résultat dans le champ `evaluated` — ou `{ error }` si elle lève. Une forme fonction
191
+ (`async () => …`) est appelée, jamais seulement définie.
192
+
193
+ ```bash
194
+ NF_BROWSER_EVAL='() => document.title' node scripts/inspect.mjs /tableau-de-bord
195
+ ```
196
+
185
197
  ### Un écran qui n'existe qu'après un GESTE — `NF_BROWSER_ACTIONS`
186
198
 
187
199
  Certaines pages ne sont pas un état mais un **parcours** : un formulaire qui ne déplie ses questions
@@ -196,11 +208,19 @@ Grammaire : `verbe:cible[=valeur]`, séquence séparée par `|`, verbe facultati
196
208
  cible cherchée d'abord comme **texte visible** puis comme sélecteur CSS. Verbes : `clic` · `double`
197
209
  · `droit` (clic droit) · `survol` · `saisir:Nom=valeur` · `choisir:Liste=valeur` (une option d'un
198
210
  `<select>`, par sa valeur ou son libellé ; la cible peut être le libellé qui l'enveloppe) ·
199
- `touche:Enter` · `voir` · `defiler:600` · `attendre`.
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
213
+ AUTRE onglet, sans recharger la page mesurée — qui s'ouvre alors anonyme : une page déjà
214
+ ouverte voit-elle la session apparaître ?).
200
215
 
201
216
  - 🔴 **`voir` plutôt qu'un clic pour amener dans la vue** : sur un formulaire, le texte d'une
202
217
  question est un `label` — cliquer dessus coche la case qu'il décrit, et tu observes alors un écran
203
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`.
204
224
  - ⚠️ **`defiler` n'est pas `NF_BROWSER_FULLPAGE=1`** : une application dont le contenu défile dans
205
225
  un conteneur interne (toute interface à barre latérale fixe) ne grandit pas — la capture « page
206
226
  entière » y rend exactement la fenêtre, et l'on croit que ce qui est plus bas n'existe pas.
@@ -236,6 +256,10 @@ que soit ta trousse d'interface. Valeurs : `light`, `dark`, `no-preference` ; to
236
256
  Si ton application **mémorise** le choix de l'utilisateur, elle n'obéit plus à cette média query :
237
257
  donne alors la clé de stockage, qui t'appartient — le code ne la devine pas.
238
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
+
239
263
  ```bash
240
264
  NF_BROWSER_STORAGE="ma-cle-de-theme=light" node .../scripts/inspect.mjs /
241
265
  ```
@@ -381,6 +405,17 @@ en bout : le message part-il, revient-il, et l'écran le reçoit-il ?
381
405
  > lui, vaut pour toute attente que tu écriras : tant qu'elle n'a pas échoué une fois, elle ne
382
406
  > discrimine rien.
383
407
 
408
+ ### Voir la trame qu'un GESTE envoie — `NF_BROWSER_ACTIONS`
409
+
410
+ ```bash
411
+ docker exec -e "NF_BROWSER_ACTIONS=Supprimer|Confirmer" mon-app-browser node /app/see-screen/watch.mjs /articles 4000
412
+ ```
413
+
414
+ La même grammaire que pour `inspect.mjs`, jouée une fois la page ouverte, **pendant** l'observation :
415
+ la trame qu'un bouton envoie par la socket est enregistrée comme les autres. C'est la seule façon de
416
+ prouver qu'une écriture passe par la socket. Sans la trame, un client qui se replie sur HTTP en cas
417
+ d'échec affiche le même écran. Une cible introuvable arrête la sonde (code 65).
418
+
384
419
  ### Éprouver la REPRISE après une coupure réseau — `NF_BROWSER_CUT`
385
420
 
386
421
  ```bash
@@ -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