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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorateParam.js +1 -1
  3. package/dist/index.js +3 -3
  4. package/dist/nodefony/controllers/DevkitController.js +1 -1
  5. package/dist/nodefony/controllers/McpController.js +2 -2
  6. package/dist/nodefony/controllers/ProfileSessionController.js +1 -1
  7. package/dist/nodefony/mcp/profileRoute.js +28 -9
  8. package/dist/nodefony/service/DevkitService.js +4 -4
  9. package/docs/index.md +1 -1
  10. package/package.json +11 -11
  11. package/skills/nodefony-add-crud/SKILL.md +38 -23
  12. package/skills/nodefony-add-realtime-channel/SKILL.md +376 -111
  13. package/skills/nodefony-add-realtime-channel/references/binaire.md +191 -0
  14. package/skills/nodefony-add-realtime-channel/references/production.md +121 -0
  15. package/skills/nodefony-browser/SKILL.md +27 -2
  16. package/skills/nodefony-browser/scripts/audit.mjs +12 -0
  17. package/skills/nodefony-browser/scripts/inspect.mjs +40 -81
  18. package/skills/nodefony-browser/scripts/lib/actions.mjs +113 -0
  19. package/skills/nodefony-browser/scripts/lib/browser.mjs +50 -11
  20. package/skills/nodefony-browser/scripts/lib/probes.mjs +56 -0
  21. package/skills/nodefony-browser/scripts/watch.mjs +27 -11
  22. package/skills/nodefony-dev/SKILL.md +11 -0
  23. package/skills/nodefony-dev/references/rapport-de-build.md +19 -0
  24. package/skills/nodefony-devops/SKILL.md +28 -21
  25. package/skills/nodefony-migrate-schema/SKILL.md +110 -20
  26. package/skills/nodefony-migrate-schema/references/verdicts.md +22 -20
@@ -0,0 +1,191 @@
1
+ # Des octets dans le temps réel — mode d'emploi complet
2
+
3
+ > Chargé depuis `SKILL.md` dès qu'une charge contient des octets (`Uint8Array`, `Buffer`, CBOR,
4
+ > `$bytes`). Tout ce qui suit se vérifie dans la documentation installée :
5
+ > `node_modules/@nodefony/realtime/docs/protocole.md` (section « Des octets dans la charge »),
6
+ > `node_modules/@nodefony/realtime/docs/configuration.md` (clé `codec`),
7
+ > `node_modules/nodefony/docs/client.md` (« Choisir le codec de trame »),
8
+ > `node_modules/nodefony/docs/cbor.md` (le codec lui-même).
9
+
10
+ ## Sommaire
11
+
12
+ 1. [Le modèle](#1-le-modèle--une-frame-comme-les-autres)
13
+ 2. [Mise en place](#2-mise-en-place--deux-lignes-une-par-côté)
14
+ 3. [Publier et recevoir](#3-publier-et-recevoir)
15
+ 4. [Canal ou flux brut ?](#4-canal-ou-flux-brut-)
16
+ 5. [Lire des octets sans se tromper](#5-lire-des-octets-sans-se-tromper)
17
+ 6. [Plusieurs serveurs](#6-plusieurs-serveurs)
18
+ 7. [Chaque message, sa cause, ton geste](#7-chaque-message-sa-cause-ton-geste)
19
+ 8. [Ce qui arrive SANS message](#8-ce-qui-arrive-sans-message)
20
+ 9. [Prouver que ça marche](#9-prouver-que-ça-marche)
21
+
22
+ ## 1. Le modèle — une frame comme les autres
23
+
24
+ - Une trame qui porte des octets est une **notification JSON-RPC ordinaire** dont la charge est, ou
25
+ contient, un `Uint8Array`. Il n'y a pas de seconde grammaire.
26
+ - Le **codec** ne dit que la forme sur le fil : en CBOR, les octets voyagent tels quels ; sur une
27
+ connexion restée en texte, ils voyagent sous la convention `{"$bytes":"<base64url>"}`, que le
28
+ client relit en `Uint8Array` sans rien importer.
29
+ - Le codec se **négocie** par connexion : le navigateur l'offre, le serveur choisit et l'annonce
30
+ dans `realtime:welcome`. Un client qui ne l'offre pas reste en texte — rien ne casse.
31
+ - Parce que c'est la même frame, les octets d'un canal passent par **les mêmes gardes** que le
32
+ texte : politique du canal, rôles, audit, sonde.
33
+
34
+ **N'invente jamais d'en-tête binaire maison** (`[n° de canal][charge]`) : il contournerait toutes
35
+ les gardes et serait à maintenir des deux côtés.
36
+
37
+ ## 2. Mise en place — deux lignes, une par côté
38
+
39
+ Serveur (`nodefony.config.ts`) :
40
+
41
+ ```ts
42
+ use("@nodefony/realtime", { codec: "cbor" });
43
+ ```
44
+
45
+ Navigateur — à la **PREMIÈRE** création de la socket partagée (souvent le noyau client) :
46
+
47
+ ```ts
48
+ import { NodefonySocket } from "nodefony/client";
49
+ import { CBOR_FRAME_CODEC } from "nodefony/cbor";
50
+
51
+ const socket = NodefonySocket.shared({
52
+ url: "/api/live/realtime",
53
+ codec: CBOR_FRAME_CODEC,
54
+ });
55
+ ```
56
+
57
+ | Serveur | Navigateur offre CBOR | Fil retenu | Octets reçus en |
58
+ | ------------------------ | --------------------- | ---------- | ---------------------------- |
59
+ | `codec: "cbor"` | oui | CBOR | `Uint8Array` |
60
+ | `codec: "cbor"` | non | texte | `Uint8Array` (via `$bytes`) |
61
+ | `codec: "json"` (défaut) | oui ou non | texte | 🔴 objet déformé `{"0":1,…}` |
62
+
63
+ D'où la règle : **dès que des octets circulent, le serveur passe en `codec: "cbor"`**. Côté
64
+ navigateur, offrir CBOR est un gain (≈ 30× plus rapide à recevoir 4 Ko d'octets, ~25 % d'octets en
65
+ moins), pas une obligation. Ce gain ne vaut QUE pour des octets : sur des objets ordinaires, CBOR
66
+ encode plus lentement que JSON, natif au moteur, et ne décode pas plus vite. N'offre le codec que si
67
+ des octets circulent.
68
+
69
+ ## 3. Publier et recevoir
70
+
71
+ ```ts
72
+ // serveur — depuis un service, une tâche, un minuteur
73
+ import { getRealtimeHub } from "@nodefony/realtime";
74
+ getRealtimeHub().publish("capteur:raw", {
75
+ seq: 42,
76
+ frame: new Uint8Array([1, 2, 3]),
77
+ });
78
+ getRealtimeHub().publish("capteur:raw", new Uint8Array([1, 2, 3])); // charge = les octets seuls
79
+
80
+ // serveur — dans un RealtimeController, la fabrique du canal reçoit `publish`
81
+ // navigateur
82
+ socket.subscribe("capteur:raw");
83
+ socket.on("capteur:raw", (charge) => {
84
+ /* charge.frame est un Uint8Array */
85
+ });
86
+ socket.publish("doc:42", { maj: new Uint8Array(update) }); // canal entrant déclaré côté serveur
87
+ ```
88
+
89
+ - `subscribe(canal)` prend **un seul argument** ; le handler se pose par `on(canal, handler)`.
90
+ - Une charge est un objet, un tableau, des octets, ou absente — jamais un scalaire
91
+ (`publish("c", 42)` lève une `TypeError` qui nomme le canal).
92
+ - Un `Buffer` de Node est un `Uint8Array` : il part en octets, jamais en `{type:"Buffer",data:[…]}`.
93
+ - Une `Date` revient en chaîne et un `bigint` est refusé — exactement comme en JSON.
94
+
95
+ ## 4. Canal ou flux brut ?
96
+
97
+ | Besoin | Voie |
98
+ | ------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
99
+ | Des octets qui appartiennent à un canal (cas normal) | `codec: "cbor"` + `publish` / `on(canal)` |
100
+ | Un protocole binaire entièrement propre à l'application | `onRealtimeBinary()` (serveur) / `onBinary()` (navigateur), connexion **texte** seulement |
101
+
102
+ 🔴 **Le flux brut est HORS des gardes.** Une trame reçue par `onRealtimeBinary(data)` ne passe ni
103
+ par le verrou de frame ni par les politiques d'action et de canal. Seule la connexion a franchi
104
+ l'Origin, l'authentificateur et le pare-feu. Si tu l'emploies, **toute décision d'accès est à
105
+ toi** : lis l'identité par `RequestContext.getAuthenticatedUser()` et refuse explicitement.
106
+
107
+ 🔴 **Le flux brut n'existe pas sur une connexion CBOR** : toute trame binaire y est une frame du
108
+ codec. `onRealtimeBinary()` n'y est jamais appelée, `sendBinaryToClient()` et `sendBinary()` rendent
109
+ `false`. Ne mélange pas les deux voies sur une même application sans le savoir.
110
+
111
+ Dans le doute, **préfère un canal + `Uint8Array`** (§3) : même politique, mêmes gardes, même bus
112
+ entre serveurs — le flux brut ne se justifie que pour un protocole binaire qui n'a pas de canal.
113
+
114
+ ## 5. Lire des octets sans se tromper
115
+
116
+ - **`DataView` : passe la tranche entière.** `new DataView(data.buffer, data.byteOffset,
117
+ data.byteLength)`. Un `Buffer` de Node ne commence pas à 0 dans son tampon : `new
118
+ DataView(data.buffer)` lit des octets qui ne sont pas les tiens.
119
+ - Côté serveur, `onRealtimeBinary` reçoit un `Buffer` de `ws` typé `Uint8Array` : écris ton
120
+ décodeur sur `Uint8Array`, il servira tel quel dans le navigateur.
121
+ - Si tu appelles toi-même `decodeCbor()` sur un tampon que tu réutilises ensuite, **copie** les
122
+ octets décodés à garder (`slice()`) : ce sont des vues sur ce tampon.
123
+
124
+ ### 🔴 Le piège du `$bytes` forgé
125
+
126
+ Quand le serveur a `codec: "cbor"` et qu'une connexion est restée en texte (la convention d'octets
127
+ y est armée), un objet de la forme **exacte** `{ $bytes: "<chaîne>" }` (seule clé, valeur chaîne)
128
+ est réservé aux octets. L'encodeur **refuse** d'en envoyer un qui ne vient pas
129
+ d'octets : un client texte le relirait en octets, un client CBOR en objet.
130
+
131
+ ```ts
132
+ socket.publish("prix", { montant: { $bytes: "12 euros" } }); // 🔴 TypeError
133
+ socket.publish("prix", { montant: { $bytes: "12 euros", devise: "EUR" } }); // ✅ deux clés
134
+ socket.publish("prix", { montant: { raw: "12 euros" } }); // ✅ autre clé
135
+ ```
136
+
137
+ - Navigateur : `emit` / `publish` **lèvent** à la ligne fautive.
138
+ - Serveur : la publication n'atteint pas les abonnés texte, une ligne `ERROR` par publication.
139
+ - Un tel objet **conservé** (état initial renvoyé à chaque nouvel abonné) est refusé **à chaque
140
+ envoi**. Renomme la clé une fois pour toutes. Ne contourne pas en encodant toi-même en base64.
141
+ - Avec `codec: "json"`, `$bytes` est une clé comme une autre : la règle ne s'applique pas.
142
+
143
+ ## 6. Plusieurs serveurs
144
+
145
+ Les octets traversent le bus entre exemplaires (`redis`, `cluster`) **intacts**, scellés comme le
146
+ texte si `backplane.secret` est posé : rien à configurer de plus. Un canal suivi à la fois par des
147
+ connexions CBOR et texte paie un encodage par codec, pas un par abonné.
148
+
149
+ ## 7. Chaque message, sa cause, ton geste
150
+
151
+ Les textes entre guillemets sont recopiés du code ; `<…>` marque une partie variable.
152
+
153
+ | Où | Message | Cause | Geste |
154
+ | -------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
155
+ | exception (navigateur) / journal `ERROR` (serveur) | `codec JSON : objet {"$bytes": …} non issu d'octets` | objet applicatif de la forme exacte du marqueur, vers une connexion texte | envoyer un vrai `Uint8Array`, ou renommer / ajouter une clé (§5) |
156
+ | journal `ERROR` serveur | `rpc <action> : réponse non encodable` | une `@RealtimeAction` a rendu une valeur que le codec refuse (`$bytes` forgé, cycle, `bigint`) ; le client reçoit `-32603` générique | corriger la valeur rendue |
157
+ | exception | `Impossible d'encoder un BigInt — JSON.stringify le refuse aussi` | `bigint` dans la charge, sous CBOR | convertir en chaîne ou en nombre |
158
+ | exception | `Impossible d'encoder une structure circulaire — JSON.stringify la refuse aussi` | cycle dans la charge | aplatir la donnée |
159
+ | notice navigateur (`-32700`) | `Trame binaire écartée : en JSON-RPC texte elle n'a pas de canal` | le serveur écrit du binaire brut sur une connexion texte sans `onBinary()` | lire par `onBinary()`, ou passer par un canal + CBOR |
160
+ | notice navigateur (`-32700`) | `Trame illisible écartée : elle ne se décode pas en <protocole>` | trame que le codec ne décode pas | côté serveur : chercher qui écrit hors du codec |
161
+ | notice navigateur | `Codec « <protocole> » annoncé sans avoir été offert : la connexion reste en JSON-RPC texte` | serveur qui annonce un codec que ce client n'a pas offert | mettre client et serveur à la même version |
162
+ | notice navigateur | `Flux binaire brut indisponible sous <protocole>` | `onBinary()` / `sendBinary()` sur une connexion CBOR | publier les octets sur un canal (§4) |
163
+ | journal `WARNING` serveur, une fois par connexion | `WS realtime : trame binaire reçue et écartée` | binaire brut reçu sur une connexion texte, sans `onRealtimeBinary()` | passer par un canal + CBOR, ou surcharger `onRealtimeBinary()` (§4, hors gardes) |
164
+ | journal `WARNING` serveur, une fois par connexion | `WS realtime : sendBinaryToClient refusé` | `sendBinaryToClient()` sur une connexion CBOR | publier les octets sur un canal |
165
+ | journal `WARNING` serveur, une fois par nature | `WS realtime : trame binaire illisible pour <protocole> reçue et écartée` | trame CBOR malformée ou tronquée ; **aucune réponse** n'est renvoyée (pas d'oracle pour un fuzzer), connexion conservée | côté client : chercher qui écrit hors du codec |
166
+ | exception (`CborError`) | `CBOR refusé à l'octet <n> : <raison>` | `decodeCbor()` appelé par l'application sur des octets invalides ; `err.offset` dit où | valider la source ; ne jamais faire confiance à des octets reçus |
167
+
168
+ ## 8. Ce qui arrive SANS message
169
+
170
+ - **Serveur resté en `codec: "json"`** : un `Uint8Array` part en `{"0":1,"1":2,…}` — aucune erreur.
171
+ C'est le piège n°1 : vérifier la clé `codec` avant tout autre diagnostic.
172
+ - **`codec` passé à un `shared()` qui n'est pas le premier** : l'instance existante revient,
173
+ réglages compris ; la connexion reste en texte (un avertissement hors production seulement).
174
+ - **`sendBinary()` sur un transport fermé** : rend `false`, compté dans `framesUnsent`, sans notice.
175
+ - **Codec non annoncé par l'accueil** : le client reste en texte, sans notice — c'est le cas
176
+ normal d'un serveur en `codec: "json"`.
177
+ - **Ne déduis jamais le codec de l'en-tête `Sec-WebSocket-Protocol`** renvoyé au handshake : la
178
+ bibliothèque WebSocket le renvoie qu'elle parle le sous-protocole ou non. Seul `socket.codec`
179
+ fait foi.
180
+
181
+ ## 9. Prouver que ça marche
182
+
183
+ - `socket.codec` vaut `"nodefony.jsonrpc.cbor.v1"` une fois l'accueil reçu ; `"nodefony.jsonrpc.v1"`
184
+ = texte.
185
+ - Compteurs côté navigateur : `socket.binaryFramesDiscarded`, `socket.malformedFramesDiscarded`
186
+ doivent rester à 0.
187
+ - Un test qui le prouve publie un `Uint8Array` sur un canal et vérifie que l'abonné reçoit un
188
+ `Uint8Array` **identique** (`instanceof Uint8Array` et mêmes octets) — sous CBOR ET sous texte.
189
+ - Pour lire le fil à l'œil dans l'inspecteur réseau : `NF_REALTIME_CODEC=json` remet le serveur en
190
+ texte. Exception de débogage : la taille des trames et la contre-pression changent, aucune mesure
191
+ prise ainsi ne vaut pour la production.
@@ -0,0 +1,121 @@
1
+ # Ce qui ne se voit qu'en production — plusieurs serveurs, réseau, révocation, charge
2
+
3
+ > Chargé depuis `SKILL.md` §8. 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
+ - Le bus est un transport, pas un journal : un serveur absent 30 s ne rattrape rien. Redis
26
+ injoignable au démarrage : repli sur ce seul processus, WARNING, l'application démarre.
27
+ - **Secret du bus** : `NF_REALTIME_BACKPLANE_SECRET` (≥ 32 caractères), le même partout ; un
28
+ message non scellé est refusé et compté (`sealRejectedTotal`). Deux applications sur un même
29
+ Redis : `backplane.namespace` distinct — c'est une clé de sécurité, pas de confort.
30
+ - **Ce qui traverse, et ce qui ne traverse pas** : les publications des canaux diffusés, et deux
31
+ ordres qui ne font qu'enlever — `closeChannel(canal)` et `evictUser(canal, compte)`. Les gestes
32
+ par CONNEXION (`subscribeConnection`, `evictConnection`, `channelMembers`) sont locaux au
33
+ serveur qui tient la socket : appelle-les depuis une entrée client (`@RealtimeInbound`, qui
34
+ s'exécute par construction sur ce serveur), jamais depuis une requête HTTP routée au hasard.
35
+ - **Bus partagé = bus privé.** Un ordre d'éjection capté sur le bus peut être rejoué par qui y
36
+ écrit : l'effet reste une coupure (jamais une admission), et changer le secret rend caducs les
37
+ ordres captés. Risque écrit et accepté (ADR-0022 §6).
38
+
39
+ ## 2. Reconnexion — ce qui est rejoué, ce qui ne l'est pas
40
+
41
+ - Le client rejoue **ses abonnements** au `realtime:welcome` (imposés compris), et chacun repasse
42
+ par la politique et par `allow`. D'où la règle : l'appartenance est écrite là où `allow` la lit
43
+ AVANT d'abonner, sinon le membre est refusé à la première coupure (sur mobile : dans la minute).
44
+ - Il ne rejoue **ni les messages publiés pendant la coupure, ni les requêtes en vol**. Une
45
+ discussion se reprend par une route (`GET /chat/history?since=<dernier id>`), dédoublonnée sur
46
+ l'identifiant persistant, triée sur l'identifiant croissant de la base — jamais une horloge.
47
+ - Un canal fermé par le serveur (`realtime:closed`, `ended` ou `evicted`) n'est pas rejoué. Un
48
+ rechargement de page, si : c'est `allow` qui tranche.
49
+ - `connected` ne veut pas dire abonné : `await socket.whenSubscribed(canal)` avant de charger
50
+ l'état initial, sinon un événement entre les deux est perdu.
51
+
52
+ ## 3. Garanties — ce que le framework promet, et rien de plus
53
+
54
+ | Livraison | Ordre | Livraison | En cas de perte |
55
+ | --------- | ------------------------------------------------ | ---------------- | --------------------------------------------------------------------------- |
56
+ | 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`) |
57
+ | flux SSE | par flux | au plus une fois | un trou ; la reprise appartient à l'action |
58
+ | bus | par émetteur | au plus une fois | un trou |
59
+
60
+ Une alarme machine qui ne doit pas se perdre se bâtit **au-dessus** : identifiant dans la charge,
61
+ accusé par une entrée client, réémission par l'application. Le socle ne stocke rien.
62
+
63
+ ## 4. Révocation — une socket survit à sa session
64
+
65
+ Toutes les 30 s, chaque connexion authentifiée est revalidée. Les codes vivent dans un seul
66
+ registre, exporté par `nodefony` : **compare aux constantes, jamais aux nombres**.
67
+
68
+ | Code | Constante | Cause | Le client |
69
+ | ------ | ----------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------- |
70
+ | `4001` | `IDENTITY_DEAD_CLOSE_CODE` | session détruite ou expirée, jeton révoqué | s'arrête |
71
+ | `4003` | `ORIGIN_REFUSED_CLOSE_CODE` | origine refusée à la poignée de main | s'arrête |
72
+ | `4005` | `IDENTITY_REFRESH_CLOSE_CODE` | droits changés, identité vivante | revient aussitôt — lis `onIdentity()` pour rafraîchir l'écran |
73
+ | `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
+ | `4007` | `BACKPRESSURE_CLOSE_CODE` | émis par le client : sa file d'envoi ne se vide plus | se reconnecte |
75
+ | `1013` | `TRY_AGAIN_LATER_CLOSE_CODE` | serveur : client qui ne lit plus, ou authentification trop lente (10 s) | revient, avec recul |
76
+
77
+ - Un refus à la poignée de main est un **statut HTTP** (401/403) que le navigateur cache : la
78
+ socket sonde l'adresse en HTTP et traite 401/403/404 comme définitifs. Jamais de `4006` avant
79
+ authentification.
80
+ - Un membre retiré d'une salle reste abonné tant que sa connexion vit : l'exclusion immédiate, c'est
81
+ `evictUser` ; la révocation ferme la connexion entière.
82
+
83
+ ## 5. Contre-pression et bornes — une règle, trois endroits
84
+
85
+ - **Serveur** (`@nodefony/http`, clés `websocket` ET `websocketSecure` — régler l'un ne règle pas
86
+ l'autre) : `maxBackpressure` 4 MiB, `backpressurePolicy: "drop"` (défaut) ou `"close"`,
87
+ `backpressureCloseAfterDrops` 1000 → fermeture `1013`. `drop` ne trie pas : une réponse RPC
88
+ peut partir aussi. Un flux où chaque message compte choisit `close`, ou un accusé applicatif.
89
+ - **Débit entrant** : `maxFramesPerSecond` (défaut `0` = désactivé), fermeture `1008`
90
+ sur un solde de refus — jamais au premier dépassement (une reconnexion qui rejoue ses
91
+ abonnements est une rafale légitime). Pour un jeu : `120` et `frameRateCloseAfterRejects: 1000`.
92
+ - **Navigateur** : `maxBufferedAmount` (désactivé par défaut) jette un coup périmé au lieu de
93
+ l'empiler, compté dans `framesDropped` ; `backpressureCloseAfterDrops` ferme en `4007`.
94
+ - **Cadence adaptative** : un canal d'ÉTAT se demande `base:<ms>` ; le serveur le sert par
95
+ `createRealtimeChannel` + `parseRate` (`nodefony`), le client par
96
+ `useNodefonyAdaptiveChannelData`. Jamais sur un canal d'événements.
97
+ - `slowConsumer.bytes` (`@nodefony/realtime`) compte, ne freine pas.
98
+
99
+ ## 6. `allow` sous charge
100
+
101
+ - Rejeu au redémarrage : N clients × M canaux appels d'`allow` en rafale. Parade : une règle en
102
+ classe qui lit un registre partagé, ou un cache court côté application.
103
+ - `allowTimeoutMs` 5 s → `unavailable` (le client réessaie seul, 3 fois, gigue complète) ;
104
+ `maxPendingAllowsPerConnection` 8 (la demande attend son tour) ; `maxPendingAllowsTotal` 512
105
+ → `unavailable` ; coupe-circuit 50 dépassements en 5 s. Compteurs : `allowTimedOutTotal`,
106
+ `allowSaturatedTotal`.
107
+ - Un `allow` qui lève = `forbidden` pour tous, sans un mot en production : une règle en classe
108
+ fait échouer le DÉMARRAGE si sa dépendance manque. Préfère-la dès qu'elle lit la base.
109
+
110
+ ## 7. Lire la sonde avant d'accuser le code
111
+
112
+ `GET /nodefony/realtime/api/health` (rôle d'administration) ou le canal `nodefony:socket`.
113
+
114
+ | Signal | Lecture |
115
+ | ------------------------------------------------- | ------------------------------------------------------------------------ |
116
+ | `publishTotal` monte, `fanoutTotal` stagne | tu publies dans le vide : nom de canal différent entre serveur et client |
117
+ | `localOnlyTotal` > 0 sur un canal applicatif | `@RealtimeBroadcast` oublié |
118
+ | `ingressRejectedTotal`, `sealRejectedTotal` | quelqu'un d'autre écrit sur le bus, ou rotation du secret en cours |
119
+ | `backpressure.slowConsumers`, `drops` | un client ne suit pas ; la protection agit — ce n'est pas une panne |
120
+ | `allowTimedOutTotal`, `authenticateTimedOutTotal` | la base ne répond plus |
121
+ | `systemFloorDeniedTotal` | un client demande un canal de plateforme sans module de sécurité |
@@ -70,7 +70,10 @@ npx playwright install chromium
70
70
 
71
71
  `NF_BROWSER_ENGINE=chrome` (ou `msedge`, `chromium`) impose un navigateur précis. Un choix explicite
72
72
  n'est **jamais** complété par un repli : se rabattre en silence attribuerait la mesure au mauvais
73
- moteur. ⚠️ À ne pas confondre avec `NF_BROWSER_CHANNEL`, qui désigne le canal d'un socket
73
+ moteur. `NF_BROWSER_ENGINE=firefox` (ou `webkit`) change de MOTEUR — après
74
+ `npx playwright install firefox` : ce que dicte une norme (sécurité du contenu, rapports de
75
+ violation, cookies) diverge d'un moteur à l'autre, et Chromium seul n'en dit rien. L'audit
76
+ Lighthouse, lui, reste réservé à Chromium. ⚠️ À ne pas confondre avec `NF_BROWSER_CHANNEL`, qui désigne le canal d'un socket
74
77
  applicatif — le mot « canal » sert aux deux dans des mondes différents.
75
78
 
76
79
  ## L'autre voie — en conteneur, et QUAND s'en servir
@@ -182,6 +185,15 @@ Réglages par variables d'environnement : `NF_BROWSER_BASE`, `NF_BROWSER_PAGE`,
182
185
  (`libellé=sélecteur`, séparés par des virgules), `NF_BROWSER_FAMILIES`, `NF_BROWSER_WIDTHS`,
183
186
  `NF_BROWSER_SEUIL_LOURD`, `NF_BROWSER_SEUIL_LENT`, `NF_BROWSER_ACTIONS`, `NF_BROWSER_FULLPAGE`. Le détail vit dans l'en-tête de chaque script.
184
187
 
188
+ **Lire un état que seule ton application connaît** (un store, l'instantané d'une socket) :
189
+ `NF_BROWSER_EVAL` prend une expression évaluée DANS la page après les actions et les mesures, et
190
+ rend son résultat dans le champ `evaluated` — ou `{ error }` si elle lève. Une forme fonction
191
+ (`async () => …`) est appelée, jamais seulement définie.
192
+
193
+ ```bash
194
+ NF_BROWSER_EVAL='() => document.title' node scripts/inspect.mjs /tableau-de-bord
195
+ ```
196
+
185
197
  ### Un écran qui n'existe qu'après un GESTE — `NF_BROWSER_ACTIONS`
186
198
 
187
199
  Certaines pages ne sont pas un état mais un **parcours** : un formulaire qui ne déplie ses questions
@@ -196,7 +208,9 @@ Grammaire : `verbe:cible[=valeur]`, séquence séparée par `|`, verbe facultati
196
208
  cible cherchée d'abord comme **texte visible** puis comme sélecteur CSS. Verbes : `clic` · `double`
197
209
  · `droit` (clic droit) · `survol` · `saisir:Nom=valeur` · `choisir:Liste=valeur` (une option d'un
198
210
  `<select>`, par sa valeur ou son libellé ; la cible peut être le libellé qui l'enveloppe) ·
199
- `touche:Enter` · `voir` · `defiler:600` · `attendre`.
211
+ `touche:Enter` · `voir` · `defiler:600` · `attendre` · `connecter:` (se connecte dans un
212
+ AUTRE onglet, sans recharger la page mesurée — qui s'ouvre alors anonyme : une page déjà
213
+ ouverte voit-elle la session apparaître ?).
200
214
 
201
215
  - 🔴 **`voir` plutôt qu'un clic pour amener dans la vue** : sur un formulaire, le texte d'une
202
216
  question est un `label` — cliquer dessus coche la case qu'il décrit, et tu observes alors un écran
@@ -381,6 +395,17 @@ en bout : le message part-il, revient-il, et l'écran le reçoit-il ?
381
395
  > lui, vaut pour toute attente que tu écriras : tant qu'elle n'a pas échoué une fois, elle ne
382
396
  > discrimine rien.
383
397
 
398
+ ### Voir la trame qu'un GESTE envoie — `NF_BROWSER_ACTIONS`
399
+
400
+ ```bash
401
+ docker exec -e "NF_BROWSER_ACTIONS=Supprimer|Confirmer" mon-app-browser node /app/see-screen/watch.mjs /articles 4000
402
+ ```
403
+
404
+ La même grammaire que pour `inspect.mjs`, jouée une fois la page ouverte, **pendant** l'observation :
405
+ la trame qu'un bouton envoie par la socket est enregistrée comme les autres. C'est la seule façon de
406
+ prouver qu'une écriture passe par la socket. Sans la trame, un client qui se replie sur HTTP en cas
407
+ d'échec affiche le même écran. Une cible introuvable arrête la sonde (code 65).
408
+
384
409
  ### Éprouver la REPRISE après une coupure réseau — `NF_BROWSER_CUT`
385
410
 
386
411
  ```bash
@@ -41,6 +41,7 @@ import {
41
41
  USER,
42
42
  } from "./lib/browser.mjs";
43
43
  import {
44
+ browserEngine,
44
45
  browserOrder,
45
46
  captureSlug,
46
47
  colorSchemeLaunchArgs,
@@ -104,6 +105,17 @@ const profile = mkdtempSync(path.join(tmpdir(), "nf-audit-"));
104
105
  // seul Chrome est installé — là même où `inspect.mjs` passait.
105
106
  let ctx = null;
106
107
  const failures = [];
108
+ // Lighthouse parle au navigateur par le protocole de débogage de Chromium :
109
+ // Firefox et WebKit ne l'exposent pas. Refuser en le disant, plutôt que de se
110
+ // rabattre sur Chromium et d'attribuer l'audit au moteur qu'on n'a pas lancé.
111
+ const wanted = (process.env.NF_BROWSER_ENGINE ?? "").trim();
112
+ if (wanted && browserEngine(wanted).engine !== "chromium") {
113
+ console.error(
114
+ `L'audit exige un navigateur de la famille Chromium (protocole de débogage) — « ${wanted} » n'en est pas un.\n` +
115
+ "Pour regarder la page sous ce moteur : inspect.mjs avec le même NF_BROWSER_ENGINE.",
116
+ );
117
+ process.exit(64); // EX_USAGE
118
+ }
107
119
  for (const channel of browserOrder(process.env.NF_BROWSER_ENGINE)) {
108
120
  try {
109
121
  ctx = await chromium.launchPersistentContext(profile, {
@@ -16,7 +16,7 @@
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
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
19
- * `@env` NF_BROWSER_ENGINE navigateur imposé (chromium, chrome, msedge) ; sans lui, le premier qui répond
19
+ * `@env` NF_BROWSER_ENGINE navigateur imposé (chromium, chrome, msedge, firefox, webkit) ; sans lui, le premier qui répond
20
20
  * `@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
21
21
  * `@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
22
  * `@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é
@@ -34,11 +34,14 @@
34
34
  * `@env` NF_BROWSER_PRIMARY sélecteur de l'action principale pour la famille pli (défaut : le plus grand bouton de validation visible)
35
35
  * `@env` NF_BROWSER_SEUIL_LOURD bytes au-delà desquels une ressource est « lourde » (défaut 512000)
36
36
  * `@env` NF_BROWSER_SEUIL_LENT millisecondes au-delà desquelles une réponse est « lente » (défaut 1000)
37
+ * `@env` NF_BROWSER_EVAL expression JS évaluée DANS la page après les actions et les mesures (une forme fonction est appelée) ;
38
+ * son résultat sérialisé sort dans `evaluated` — l'état propre à l'application (store, socket)
37
39
  * `@requires` conteneur du profil `browser` démarré · serveur joignable depuis le conteneur
38
40
  * `@output` un objet JSON sur stdout + une capture PNG horodatée dans /output
39
41
  * `@exit` 0 mesure rendue (le verdict est une DONNÉE, pas un code de retour) · 64 usage (famille inconnue, identifiant sans chemin de connexion) · 65 texte attendu jamais apparu
40
42
  */
41
43
  import { existsSync, readFileSync } from "node:fs";
44
+ import { playActions } from "./lib/actions.mjs";
42
45
  import path from "node:path";
43
46
  import * as decor from "./lib/browser.mjs";
44
47
  import { open, goTo, LOGIN, OUTPUT } from "./lib/browser.mjs";
@@ -48,14 +51,17 @@ import {
48
51
  captureSlug,
49
52
  parseActions,
50
53
  parseFamilies,
54
+ signsInMidway,
51
55
  parseProbes,
52
56
  parseWidths,
57
+ pageExpression,
53
58
  summarizeAxe,
54
59
  verdictGlobal,
55
60
  } from "./lib/probes.mjs";
56
61
 
57
62
  const PAGE = process.argv[2] ?? process.env.NF_BROWSER_PAGE ?? "/";
58
63
  const EXPECT = process.argv[3] ?? process.env.NF_BROWSER_EXPECT ?? "";
64
+ const EVAL = pageExpression(process.env.NF_BROWSER_EVAL);
59
65
  /**
60
66
  * Ce qu'il faut FAIRE avant de mesurer — une séquence, pas un geste.
61
67
  *
@@ -81,6 +87,7 @@ const EXPECT = process.argv[3] ?? process.env.NF_BROWSER_EXPECT ?? "";
81
87
  * | `voir` | amène dans la vue SANS cliquer |
82
88
  * | `defiler` | fait défiler de N pixels (`defiler:600`), page ou conteneur |
83
89
  * | `attendre` | attend qu'un texte APPARAISSE (après une action lente) |
90
+ * | `connecter` | se connecte dans un AUTRE onglet (`connecter:`), sans recharger |
84
91
  *
85
92
  * 🔴 `voir` existe parce qu'un clic n'est pas neutre : sur un formulaire, le
86
93
  * texte d'une question est un `label` — cliquer dessus COCHE la case qu'il
@@ -148,7 +155,9 @@ async function axeSource() {
148
155
 
149
156
  const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
150
157
 
151
- const { browser, ctx, page, reuse } = await open();
158
+ // `connecter:` dans la séquence ⇒ la page s'ouvre ANONYME (cf signsInMidway).
159
+ const ANONYMOUS = signsInMidway(ACTIONS);
160
+ const { browser, ctx, page, reuse } = await open({ anonymous: ANONYMOUS });
152
161
 
153
162
  // ── Collecteurs — posés AVANT toute navigation ──────────────────────────────
154
163
  const consoleErrors = [];
@@ -230,7 +239,7 @@ if (active.has("reseau")) {
230
239
  });
231
240
  }
232
241
 
233
- await goTo(page, ctx, PAGE, reuse);
242
+ await goTo(page, ctx, PAGE, reuse, { anonymous: ANONYMOUS });
234
243
 
235
244
  // Attendre un texte DISCRIMINANT, jamais `networkidle` : une application qui se
236
245
  // monte puis demande ses données passe par un état « réseau calme » où l'écran
@@ -259,84 +268,12 @@ if (EXPECT) {
259
268
  // toute mesure. Une action qui ne trouve pas sa cible ARRÊTE la sonde : la
260
269
  // mesure qui suivrait porterait sur un écran qu'on n'a pas ouvert, et rien ne
261
270
  // le dirait.
262
- for (const { verb, target, value } of ACTIONS) {
263
- if (verb === "defiler") {
264
- const pixels = Number.parseInt(target, 10);
265
- if (!Number.isFinite(pixels)) {
266
- console.error(`defiler attend un nombre de pixels, reçu « ${target} »`);
267
- await browser.close();
268
- process.exit(64); // EX_USAGE
269
- }
270
- // Le conteneur qui défile RÉELLEMENT, pas la fenêtre : sur une console à
271
- // barre latérale fixe, `window.scrollBy` ne bouge rien du tout.
272
- await page.evaluate((dy) => {
273
- const scrollable = [...document.querySelectorAll("*")].find(
274
- (el) => el.scrollHeight > el.clientHeight + 40 && el.clientHeight > 200,
275
- );
276
- (scrollable ?? window).scrollBy(0, dy);
277
- }, pixels);
278
- continue;
279
- }
280
- // Le premier candidat VISIBLE, pas le premier du DOM : un libellé apparaît
281
- // souvent d'abord dans un menu replié ou un gabarit caché, et agir là ne fait
282
- // rien tout en passant pour un succès.
283
- const candidates = page.getByText(target, { exact: false });
284
- let locator = null;
285
- const total = await candidates.count();
286
- for (let i = 0; i < total; i += 1) {
287
- const c = candidates.nth(i);
288
- if (await c.isVisible().catch(() => false)) {
289
- locator = c;
290
- break;
291
- }
292
- }
293
- // Aucun candidat visible À CET INSTANT ne dit pas que la cible est un
294
- // sélecteur : juste après un clic qui navigue, la page suivante n'est pas
295
- // encore rendue. On attend donc le texte OU le sélecteur — et le texte seul
296
- // quand la cible n'est pas du CSS valide. Trancher sur l'instant faisait
297
- // chercher une balise `<FreeOTP>` une fois sur trois.
298
- if (locator === null) {
299
- // Une navigation en vol détruirait le contexte de l'évaluation ci-dessous.
300
- await page.waitForLoadState("domcontentloaded").catch(() => {});
301
- const isCss = await page
302
- .evaluate((s) => {
303
- try {
304
- document.querySelector(s);
305
- return true;
306
- } catch {
307
- return false;
308
- }
309
- }, target)
310
- .catch(() => false);
311
- locator = isCss
312
- ? candidates.or(page.locator(target)).first()
313
- : candidates.first();
314
- }
315
- try {
316
- await locator.waitFor({ timeout: 15000 });
317
- if (verb === "clic") await locator.click();
318
- else if (verb === "double") await locator.dblclick();
319
- else if (verb === "droit") await locator.click({ button: "right" });
320
- else if (verb === "survol") await locator.hover();
321
- else if (verb === "saisir") await locator.fill(value);
322
- // `choisir` : la cible peut être le `<label>` qui ENVELOPPE la liste —
323
- // Playwright reporte alors le choix sur son contrôle. La valeur est celle
324
- // de l'option (`value`), sinon son libellé.
325
- else if (verb === "choisir") {
326
- const chosen = await locator.selectOption(value);
327
- if (chosen.length === 0) throw new Error(`option « ${value} » absente`);
328
- } else if (verb === "touche") await locator.press(value || "Enter");
329
- else if (verb === "voir") await locator.scrollIntoViewIfNeeded();
330
- // `attendre` : le `waitFor` ci-dessus EST l'action.
331
- } catch {
332
- console.error(
333
- `Action « ${verb}:${target} » impossible\n` +
334
- `Page réellement ouverte : ${page.url()}\n` +
335
- "→ le libellé a changé, l'élément n'est pas encore rendu, ou il faut " +
336
- "agir sur autre chose avant lui (NF_BROWSER_ACTIONS accepte une séquence, séparée par « | »).",
337
- );
271
+ {
272
+ const failed = await playActions(page, ACTIONS);
273
+ if (failed !== null) {
274
+ console.error(failed.message);
338
275
  await browser.close();
339
- process.exit(65); // EX_DATAERR
276
+ process.exit(failed.code);
340
277
  }
341
278
  }
342
279
 
@@ -998,12 +935,34 @@ if (active.has("pli")) {
998
935
  }, primarySelector);
999
936
  }
1000
937
 
938
+ // ── Lire un ÉTAT de l'application — `NF_BROWSER_EVAL` ───────────────────────
939
+ // Ce qu'aucune famille ne mesure parce que c'est propre à l'application : l'état
940
+ // d'un store, l'instantané d'une socket, une valeur calculée par la page. Évalué
941
+ // APRÈS les actions et les mesures, sur l'écran qu'on décrit. Une expression qui
942
+ // lève rend son erreur, jamais un champ absent : un « rien » se lirait comme
943
+ // « la page n'a pas cet état ».
944
+ if (EVAL) {
945
+ try {
946
+ measured.evaluated = await page.evaluate(EVAL);
947
+ } catch (e) {
948
+ measured.evaluated = { error: String(e).slice(0, 300) };
949
+ }
950
+ }
951
+
1001
952
  // ── Capture — AVANT la famille responsive, qui déforme le viewport ──────────
1002
953
  const slug = captureSlug(PAGE);
1003
954
  const shot = path.join(OUTPUT, `${slug}-${stamp}.png`);
1004
955
  // `NF_BROWSER_FULLPAGE=1` : la page ENTIÈRE, pas la fenêtre. Un formulaire long
1005
956
  // (ou une page qu'on vient de déplier) a l'essentiel SOUS la ligne de flottaison
1006
957
  // — la capture par défaut laisse alors conclure « ce n'est pas là ».
958
+ //
959
+ // La console se FIGE avant la capture : sous WebKit, le pilote injecte un
960
+ // `<style>` pour capturer, et une politique de sécurité du contenu stricte le
961
+ // refuse. Ce refus appartient à l'instrument, pas à la page — compté, il ferait
962
+ // accuser l'application d'une violation qu'elle ne commet pas. (Le rapport, lui,
963
+ // part quand même vers le serveur : une collecte de rapports sous WebKit doit
964
+ // écarter les `style-src-elem` `inline` émis à l'instant de la capture.)
965
+ const consoleFrozen = consoleErrors.slice();
1007
966
  await page.screenshot({
1008
967
  path: shot,
1009
968
  fullPage: process.env.NF_BROWSER_FULLPAGE === "1",
@@ -1071,7 +1030,7 @@ console.log(
1071
1030
  // L'écran mesuré : une proportion sans son écran ne conclut rien.
1072
1031
  viewport: `${decor.VIEWPORT.name}${decor.VIEWPORT.mobile ? " (téléphone)" : ""}`,
1073
1032
  ...measured,
1074
- consoleErrors,
1033
+ consoleErrors: consoleFrozen,
1075
1034
  uncaughtErrors,
1076
1035
  // Le chemin RENDU est celui où l'appelant trouvera l'image. Dans un
1077
1036
  // conteneur, `/output` est un volume monté et ne veut rien dire au