@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.
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
- package/dist/index.js +3 -3
- package/dist/nodefony/controllers/DevkitController.js +1 -1
- package/dist/nodefony/controllers/McpController.js +2 -2
- package/dist/nodefony/controllers/ProfileSessionController.js +1 -1
- package/dist/nodefony/mcp/profileRoute.js +28 -9
- package/dist/nodefony/service/DevkitService.js +6 -6
- package/docs/index.md +1 -1
- package/package.json +13 -13
- package/skills/nodefony-add-crud/SKILL.md +38 -23
- package/skills/nodefony-add-grpc-service/SKILL.md +179 -0
- package/skills/nodefony-add-grpc-service/scripts/call.mjs +218 -0
- package/skills/nodefony-add-realtime-binary/SKILL.md +301 -0
- package/skills/nodefony-add-realtime-binary/references/messages.md +20 -0
- package/skills/nodefony-add-realtime-binary/references/mesures.md +59 -0
- package/skills/nodefony-add-realtime-channel/SKILL.md +452 -105
- package/skills/nodefony-add-realtime-channel/references/contrat.md +22 -0
- package/skills/nodefony-add-realtime-channel/references/production.md +140 -0
- package/skills/nodefony-add-realtime-presence/SKILL.md +328 -0
- package/skills/nodefony-authentication/SKILL.md +2 -2
- package/skills/nodefony-browser/SKILL.md +37 -2
- package/skills/nodefony-browser/references/sondes.md +19 -0
- package/skills/nodefony-browser/scripts/audit.mjs +17 -2
- package/skills/nodefony-browser/scripts/inspect.mjs +108 -87
- package/skills/nodefony-browser/scripts/lib/actions.mjs +164 -0
- package/skills/nodefony-browser/scripts/lib/browser.mjs +67 -11
- package/skills/nodefony-browser/scripts/lib/probes.mjs +108 -3
- package/skills/nodefony-browser/scripts/watch.mjs +27 -11
- package/skills/nodefony-dev/SKILL.md +31 -15
- package/skills/nodefony-dev/references/rapport-de-build.md +19 -0
- package/skills/nodefony-devops/SKILL.md +28 -21
- package/skills/nodefony-devops/references/dimensionnement.md +1 -1
- package/skills/nodefony-devops/references/frontal.md +5 -2
- package/skills/nodefony-devops/references/image.md +23 -15
- package/skills/nodefony-migrate-schema/SKILL.md +110 -20
- package/skills/nodefony-migrate-schema/references/verdicts.md +23 -20
- 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 (`
|
|
219
|
-
|
|
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.
|
|
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
|
-
`
|
|
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
|