@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.
- package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
- package/dist/index.js +1 -1
- 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/service/DevkitService.js +3 -3
- package/package.json +11 -11
- 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 +408 -326
- package/skills/nodefony-add-realtime-channel/references/contrat.md +22 -0
- package/skills/nodefony-add-realtime-channel/references/production.md +21 -2
- package/skills/nodefony-add-realtime-presence/SKILL.md +328 -0
- package/skills/nodefony-authentication/SKILL.md +2 -2
- package/skills/nodefony-browser/SKILL.md +11 -1
- package/skills/nodefony-browser/references/sondes.md +19 -0
- package/skills/nodefony-browser/scripts/audit.mjs +5 -2
- package/skills/nodefony-browser/scripts/inspect.mjs +69 -7
- package/skills/nodefony-browser/scripts/lib/actions.mjs +54 -3
- package/skills/nodefony-browser/scripts/lib/browser.mjs +18 -1
- package/skills/nodefony-browser/scripts/lib/probes.mjs +52 -3
- package/skills/nodefony-dev/SKILL.md +20 -15
- 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/references/verdicts.md +1 -0
- package/skills/nodefony-protect-route/SKILL.md +27 -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` §
|
|
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.
|
|
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 (`
|
|
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
|
|
@@ -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
|
-
`
|
|
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(
|
|
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
|
-
* | `
|
|
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({
|
|
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(
|
|
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à ».
|