@nodefony/devkit 10.0.0-alpha.1

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 (67) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +318 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateParam.js +8 -0
  6. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js +9 -0
  7. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateMetadata.js +6 -0
  8. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateParam.js +8 -0
  9. package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorate.js +9 -0
  10. package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateMetadata.js +6 -0
  11. package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateParam.js +8 -0
  12. package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorate.js +9 -0
  13. package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateMetadata.js +6 -0
  14. package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateParam.js +8 -0
  15. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  16. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  17. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
  18. package/dist/index.js +47 -0
  19. package/dist/nodefony/command/CardCommand.js +70 -0
  20. package/dist/nodefony/config/config.js +200 -0
  21. package/dist/nodefony/config/defineModuleConfig.js +36 -0
  22. package/dist/nodefony/controllers/DevkitController.js +60 -0
  23. package/dist/nodefony/controllers/McpController.js +223 -0
  24. package/dist/nodefony/controllers/OAuthMetadataController.js +89 -0
  25. package/dist/nodefony/interfaces/IDevkitService.js +1 -0
  26. package/dist/nodefony/interfaces/index.js +1 -0
  27. package/dist/nodefony/service/DevkitService.js +198 -0
  28. package/dist/nodefony/src/card.js +2 -0
  29. package/dist/nodefony/src/errors/DevkitError.js +21 -0
  30. package/dist/nodefony/src/mcp/guard.js +51 -0
  31. package/dist/nodefony/src/mcp/protocol.js +127 -0
  32. package/dist/nodefony/src/mcp/server.js +133 -0
  33. package/dist/nodefony/src/mcp/tools.js +163 -0
  34. package/dist/types/index.d.ts +52 -0
  35. package/dist/types/nodefony/command/CardCommand.d.ts +33 -0
  36. package/dist/types/nodefony/config/config.d.ts +25 -0
  37. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  38. package/dist/types/nodefony/controllers/DevkitController.d.ts +37 -0
  39. package/dist/types/nodefony/controllers/McpController.d.ts +70 -0
  40. package/dist/types/nodefony/controllers/OAuthMetadataController.d.ts +39 -0
  41. package/dist/types/nodefony/interfaces/IDevkitService.d.ts +69 -0
  42. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  43. package/dist/types/nodefony/service/DevkitService.d.ts +143 -0
  44. package/dist/types/nodefony/src/card.d.ts +18 -0
  45. package/dist/types/nodefony/src/errors/DevkitError.d.ts +14 -0
  46. package/dist/types/nodefony/src/mcp/guard.d.ts +66 -0
  47. package/dist/types/nodefony/src/mcp/protocol.d.ts +139 -0
  48. package/dist/types/nodefony/src/mcp/server.d.ts +48 -0
  49. package/dist/types/nodefony/src/mcp/tools.d.ts +81 -0
  50. package/docs/index.md +358 -0
  51. package/package.json +77 -0
  52. package/skills/nodefony-add-crud/SKILL.md +199 -0
  53. package/skills/nodefony-add-realtime-channel/SKILL.md +95 -0
  54. package/skills/nodefony-add-service/SKILL.md +90 -0
  55. package/skills/nodefony-browser/SKILL.md +416 -0
  56. package/skills/nodefony-browser/references/socket.md +115 -0
  57. package/skills/nodefony-browser/references/sondes.md +175 -0
  58. package/skills/nodefony-browser/scripts/audit.mjs +169 -0
  59. package/skills/nodefony-browser/scripts/inspect.mjs +903 -0
  60. package/skills/nodefony-browser/scripts/lib/browser.mjs +357 -0
  61. package/skills/nodefony-browser/scripts/lib/probes.mjs +501 -0
  62. package/skills/nodefony-browser/scripts/lib/wcag.mjs +153 -0
  63. package/skills/nodefony-browser/scripts/socket.mjs +354 -0
  64. package/skills/nodefony-browser/scripts/watch.mjs +125 -0
  65. package/skills/nodefony-migrate-schema/SKILL.md +359 -0
  66. package/skills/nodefony-migrate-schema/references/verdicts.md +139 -0
  67. package/skills/nodefony-protect-route/SKILL.md +195 -0
@@ -0,0 +1,115 @@
1
+ # Le socket, de bout en bout — `socket.mjs` et le protocole qu'il parle
2
+
3
+ > **Maintenance** : vérité courante, jamais un journal. Éditer en place ; l'historique vit dans git.
4
+
5
+ `socket.mjs` pilote un endpoint temps réel Nodefony COMPLET, depuis une vraie page : accueil,
6
+ abonnement, action, latence, pont API, reconnexion. Le scénario s'exécute **dans la page** — la
7
+ connexion porte donc les cookies de session et l'`Origin` réels. Un client « à côté » n'aurait ni
8
+ l'un ni l'autre, et l'on croirait à un refus d'authentification là où il n'y a qu'un décor faux.
9
+
10
+ ```bash
11
+ docker exec -e NF_BROWSER_API=/api/sante mon-app-browser node /app/see-screen/socket.mjs /chat/realtime
12
+ ```
13
+
14
+ ## Le protocole du fil — quatre formes de frame, à l'œil
15
+
16
+ Tout ce qui passe est du **JSON-RPC 2.0**. La nature d'une frame se lit sur `method`, jamais sur
17
+ `id` :
18
+
19
+ | Forme | Contenu | Exemple |
20
+ | ----------------- | ------------------------------------------------- | -------------------------------------------------------------------------------- |
21
+ | **Notification** | `method` seul — aucune réponse, jamais | `{"jsonrpc":"2.0","method":"subscribe","params":{"channel":"orders:new"}}` |
22
+ | **Requête** | `method` + `id` — une réponse est due | `{"jsonrpc":"2.0","id":1,"method":"api.request","params":{"path":"/api/sante"}}` |
23
+ | **Réponse** | `id` seul, `result` OU `error` | `{"jsonrpc":"2.0","id":1,"result":{…},"meta":{"requestId":"…"}}` |
24
+ | **Push de canal** | notification dont le NOM du canal est la `method` | `{"jsonrpc":"2.0","method":"orders:new","params":{…}}` |
25
+
26
+ Deux frames servent de repères :
27
+
28
+ - **`realtime:welcome`** — la première notification poussée par le serveur : protocole, **canaux**
29
+ annoncés, **actions** exposées, **identité résolue au handshake**. C'est la carte du territoire ;
30
+ tant qu'elle n'est pas là, rien d'autre n'a de sens.
31
+ - **`realtime:denied`** — le refus d'une notification. Une notification n'a pas de canal de
32
+ réponse : sans cette frame dédiée, un abonnement refusé serait indiscernable d'un canal
33
+ silencieux. La sonde l'écoute, et son verdict `REFUSÉ` vient de là.
34
+
35
+ ## Les étapes du scénario, et comment lire chaque verdict
36
+
37
+ ### `welcome`
38
+
39
+ `receivedAfterMs`, canaux, méthodes, identité. **S'il ne vient jamais** (code de retour 65), les trois
40
+ suspects, dans l'ordre : le chemin du endpoint est faux ; la page n'est pas authentifiée (donner
41
+ `NF_BROWSER_USER` + `NF_BROWSER_LOGIN`) ; l'`Origin` de la page est refusé par le serveur. Le
42
+ réseau qui « passe » n'innocente aucun des trois.
43
+
44
+ ### `subscription`
45
+
46
+ S'abonne au canal de `NF_BROWSER_CHANNEL` — à défaut, au **premier canal annoncé par l'accueil** —
47
+ puis écoute pendant `NF_BROWSER_SOCKET_WAIT` ms (défaut 4 000).
48
+
49
+ - `OK` : au moins une poussée reçue (horodatées, tronquées, plafonnées).
50
+ - `REFUSÉ` : le serveur a poussé `realtime:denied` pour ce canal — droits insuffisants ou plafond
51
+ de canaux. Le motif reste générique par conception : le serveur ne dit jamais QUEL droit manquait.
52
+ - `SILENCIEUX` : **pas forcément une panne.** Un canal d'événements ne pousse que quand il se passe
53
+ quelque chose ; seul un canal d'état cadencé garantit du trafic dans la fenêtre. Avant de
54
+ conclure, provoquer un événement, allonger la fenêtre, ou choisir un canal cadencé.
55
+
56
+ L'abonnement part **sans `id`** : c'est une notification. Envoyé avec un `id`, il serait classé
57
+ requête, ne trouverait aucun handler, et récolterait un `-32601` — piège classique du protocole.
58
+
59
+ ### `latency`
60
+
61
+ Mesure `NF_BROWSER_PINGS` allers-retours (défaut 5) sur une méthode **corrélée** : l'action de
62
+ `NF_BROWSER_ACTION` si elle est donnée, sinon le pont API si `NF_BROWSER_API` l'est. Rend chaque
63
+ mesure, les expirations, et la **médiane** — jamais la moyenne, qu'un seul aller-retour aberrant
64
+ (ramasse-miettes, réveil de connexion) suffit à déplacer.
65
+
66
+ - **Sans méthode corrélée, pas de latence** : la notification `ping` du battement de cœur est un
67
+ no-op serveur, aucun pong n'en revient. Verdict `NON MESURÉE`, jamais un zéro inventé.
68
+ - **`RÉPOND EN ERREUR -32601`** : l'aller-retour est COMPLET — la latence mesure le fil, mais ne
69
+ valide pas l'action, qui n'existe pas sur cet endpoint. Lire `methods` dans l'accueil avant
70
+ d'appeler.
71
+
72
+ ### `api`
73
+
74
+ Rejoue une route HTTP de l'application **sur le socket** (`api.request`, `params.path`). La réponse
75
+ porte le `result` de la route et, souvent, un champ frère `meta` (identifiant de requête serveur) —
76
+ la preuve que le plan de données passe bien par le WebSocket. Une erreur corrélée (`-32601` si le
77
+ pont n'est pas exposé sur cet endpoint, erreur applicative sinon) est rendue telle quelle.
78
+
79
+ ### `reconnection`
80
+
81
+ Ferme le socket (code 1000), en rouvre un, attend le nouvel accueil, et compare l'identité.
82
+ `memeIdentite: true` prouve que l'identité est portée par la **session** (résolue au handshake,
83
+ jamais dans les frames) : c'est la propriété qui compte pour une application qui reconnecte en
84
+ production. Un `ÉCHEC` ici avec un premier accueil réussi désigne un serveur qui refuse la
85
+ DEUXIÈME connexion — plafond de connexions, ou état serveur consommé par la première.
86
+
87
+ ## Variables d'environnement
88
+
89
+ <!-- prettier-ignore -->
90
+ | Variable | Rôle | Défaut |
91
+ | --- | --- | --- |
92
+ | `NF_BROWSER_SOCKET` | chemin du endpoint (ou 1er argument) — **requis**, rien n'est deviné | — |
93
+ | `NF_BROWSER_PAGE` | page ouverte AVANT le socket — elle porte cookies et `Origin` | `/` |
94
+ | `NF_BROWSER_CHANNEL` | canal à écouter | 1er canal annoncé |
95
+ | `NF_BROWSER_ACTION` | action RPC à appeler (la latence la réutilise) | aucune |
96
+ | `NF_BROWSER_ACTION_PARAMS` | paramètres JSON de l'action | aucun |
97
+ | `NF_BROWSER_API` | chemin rejoué par le pont `api.request` | aucun |
98
+ | `NF_BROWSER_SOCKET_WAIT` | fenêtre d'écoute du canal (ms) | 4000 |
99
+ | `NF_BROWSER_PINGS` | nombre de mesures de latence | 5 |
100
+
101
+ Plus les variables communes à toutes les sondes : `NF_BROWSER_BASE`, `NF_BROWSER_LOGIN`,
102
+ `NF_BROWSER_USER`, `NF_BROWSER_PASSWORD`.
103
+
104
+ ## Quand cette sonde se trompe
105
+
106
+ - **Elle parle le protocole à la main, sans la bibliothèque cliente.** C'est voulu — on observe le
107
+ FIL, pas une surcouche — mais ce que la bibliothèque ferait en plus (reconnexion automatique,
108
+ ré-abonnements, cadence adaptative) n'est PAS exercé ici : la « reconnexion » du scénario prouve
109
+ que le serveur accepte une nouvelle connexion authentifiée, pas que le client de l'application
110
+ reconnecte bien.
111
+ - **La fenêtre d'écoute échantillonne.** Trois poussées en 4 s ne disent rien du débit de pointe ni
112
+ d'une fuite lente — c'est `watch.mjs`, plus longtemps, qui observe une dérive.
113
+ - **Une latence médiane de quelques millisecondes est celle du conteneur vers l'hôte** — un
114
+ aller-retour local. Elle borne le coût du protocole, elle ne prédit pas la latence d'un
115
+ utilisateur réel derrière un réseau.
@@ -0,0 +1,175 @@
1
+ # Les sondes d'`inspect.mjs` — ce que chaque famille mesure, et quand elle se trompe
2
+
3
+ > **Maintenance** : vérité courante, jamais un journal. Éditer en place ; l'historique vit dans git.
4
+
5
+ `inspect.mjs` rend toujours un **socle**, et n'ajoute une **famille** de sondes que si on la
6
+ demande (`NF_BROWSER_FAMILIES=a11y,perf`, ou `toutes`). Un nom de famille inconnu est **refusé**
7
+ (code 64), jamais ignoré : une famille fautée en silence ferait croire qu'on a mesuré ce qu'on n'a
8
+ pas mesuré.
9
+
10
+ Chaque famille rend un **`verdict`** (`OK` / `ALERTE`) et des données bornées (comptes + 3
11
+ exemples, jamais l'inventaire). Le champ `verdict` de fin de sortie agrège les familles actives —
12
+ `OK` seulement si tout est OK. **Le code de retour reste 0** : le verdict est une donnée, pas une
13
+ panne de la sonde. Les codes non nuls disent autre chose — 64 : usage (famille inconnue,
14
+ identifiant sans chemin de connexion) ; 65 : le texte attendu n'est jamais apparu.
15
+
16
+ ## Le socle — toujours rendu
17
+
18
+ <!-- prettier-ignore -->
19
+ | Champ | Ce que c'est |
20
+ | --- | --- |
21
+ | `url` | La page RÉELLEMENT ouverte — à comparer à celle demandée (redirection de connexion, 404 SPA). |
22
+ | `theme` / `lang` | `color-scheme` **calculé** (ce que le moteur applique) et attribut `lang` de la racine. |
23
+ | `title` | `document.title`. |
24
+ | `scripts` | Les scripts RÉELLEMENT servis — pour vérifier qu'on observe bien le bundle qu'on vient de bâtir. |
25
+ | `probes` | Les sondes de style (voir ci-dessous). |
26
+ | `violationsCSP` | Les violations de Content-Security-Policy vues PAR la page — le réseau montre l'absence, jamais la raison. |
27
+ | `consoleErrors` | Les `console.error` émis pendant la mesure. |
28
+ | `uncaughtErrors` | Les exceptions non capturées (`pageerror`) — elles ne passent pas toutes par la console. |
29
+ | `capture` | Le PNG horodaté déposé dans le volume monté. |
30
+
31
+ Les erreurs de console et les violations CSP **ne pèsent pas** dans le verdict global : un parcours
32
+ de connexion produit des `401` légitimes, et les trancher ici les ferait passer pour des pannes.
33
+ C'est au lecteur de juger — la sonde fournit, elle ne condamne pas ce qu'elle ne peut pas qualifier.
34
+
35
+ ## Les sondes de style (`probes`) — le contraste CALCULÉ
36
+
37
+ Un sélecteur par élément (`NF_BROWSER_PROBES=libellé=sélecteur,…`) ; pour chacun : texte, couleur,
38
+ fond effectif, rapport de contraste, police, verdict WCAG, taille rendue.
39
+
40
+ - **Le fond effectif empile TOUTES les couches** jusqu'au premier ancêtre opaque, puis les compose.
41
+ Deux erreurs à ne pas refaire : lire `backgroundColor` sur l'élément rend `rgba(0,0,0,0)` et un
42
+ contraste faux ; s'arrêter à la première couche non transparente traite un voile à 13 % comme un
43
+ aplat plein, c'est-à-dire comme une couleur que personne ne voit.
44
+ - **Les couleurs modernes ne comptent pas dans la même échelle.** `rgb(0, 87, 156)` est en 0–255,
45
+ `color(srgb 0 0.34 0.61 / 0.13)` en 0–1. Les lire avec la même expression régulière rend un bleu
46
+ presque noir — et fabrique des échecs qui noient les vrais.
47
+ - **Le verdict WCAG dépend de la POLICE** : 3:1 suffit à un texte « large » (≥ 24 px, ou 18,66 px
48
+ en gras), 4,5:1 sinon. Un contraste rendu sans sa police ne conclut rien.
49
+ - **Quand elle se trompe** : un fond en dégradé ou une image de fond ne sont pas vus — la sonde lit
50
+ des COULEURS, pas le pixel composité. Sur ces cas, juger sur la capture.
51
+
52
+ > Ces sondes visent **un** élément qu'on désigne. Pour balayer la page entière sans rien désigner,
53
+ > prendre la famille `axe` : elle voit ce à quoi on ne pensait pas.
54
+
55
+ ## `axe` — l'audit WCAG par un moteur dont c'est le métier
56
+
57
+ Une centaine de règles jouées par `axe-core`, dont le contraste de **tout** le texte visible. C'est
58
+ le moteur qu'embarque Lighthouse pour son volet accessibilité.
59
+
60
+ <!-- prettier-ignore -->
61
+ | Champ | Ce qu'il dit |
62
+ | --- | --- |
63
+ | `failures` | Les défauts AVÉRÉS, comptés par gravité (critique, sérieux, modéré, mineur) |
64
+ | `worst` | Jusqu'à 8 règles, du plus grave au moins grave, avec **5 cibles** chacune et le `reason` calculé (contraste mesuré, rôle attendu) |
65
+ | `otherTargets` | Ce qui dépasse les 5 — annoncé, jamais tronqué en silence |
66
+ | `toReview` | Ce que le moteur REFUSE de trancher (fond en image…) — **pas** des défauts |
67
+ | `passed` | Les règles passées, pour situer le reste |
68
+
69
+ - **`toReview` n'est pas un manquement** et ne déclenche pas l'alerte. Le confondre ferait crier la
70
+ sonde sur des pages saines, et on cesserait de la lire.
71
+ - **Cinq cibles par règle, pas une.** Une même règle couvre des défauts à des endroits différents,
72
+ qui ne se corrigent pas d'un seul geste ; n'en montrer qu'un fait croire le travail fini.
73
+ - **N'écris jamais ce calcul toi-même.** Mesuré en conditions réelles : une sonde maison a rendu
74
+ **41 faux positifs** masquant **7 défauts réels**, dont celui qu'on cherchait — à cause de trois
75
+ cas particuliers (échelle des couleurs modernes, alpha non composé, emoji peints par une police en
76
+ couleurs) qu'on ne devine pas avant de les avoir vus.
77
+ - **Quand elle est indisponible** : `axe-core` vit dans les dépendances du projet. En conteneur, il
78
+ faut le copier à part. La famille rend alors `verdict: "INDISPONIBLE"` et la commande à taper —
79
+ jamais un `OK` qui n'a rien mesuré.
80
+
81
+ ## `a11y` — ce qu'un lecteur d'écran ou un clavier rencontrent
82
+
83
+ | Champ | Question à laquelle il répond |
84
+ | ------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
85
+ | `lang` | La racine annonce-t-elle sa langue (sans elle, la synthèse vocale lit avec le mauvais accent) ? |
86
+ | `headings` | Un seul `h1` ? Des sauts de niveau (`h2→h4`) qui cassent la table des matières ? |
87
+ | `imagesWithoutAlt` | Des images sans attribut `alt` — muettes pour un lecteur d'écran. |
88
+ | `fieldsWithoutLabel` | Des champs sans étiquette (label, `aria-label`, `aria-labelledby`, `title`). |
89
+ | `controlsWithoutName` | Boutons et liens sans nom accessible — le bouton-icône muet, le cas réel. |
90
+ | `targetsTooSmall` | Cibles interactives < 24×24 px ; les liens DANS le texte sont exemptés, comme dans le critère. |
91
+ | `positiveTabIndexValues` | Un `tabindex` positif impose un ordre de focus manuel qui diverge du DOM — l'anti-pattern du parcours clavier. |
92
+ | `visibleFocusables` | L'ampleur du parcours clavier de la page. |
93
+ | `tree` | L'arbre d'accessibilité (rôles + noms), tel que le calcule le navigateur — tronqué : il dit la STRUCTURE, pas l'inventaire. |
94
+
95
+ **Quand elle se trompe** : le nom accessible est calculé de façon SIMPLIFIÉE (l'algorithme complet
96
+ de la norme fait plus) — un composant qui pose son nom par un mécanisme exotique peut être compté
97
+ « sans nom » à tort ; vérifier dans `tree`, qui lui applique le calcul complet du navigateur.
98
+ Et une sonde automatique ne couvre qu'une fraction de l'accessibilité : elle attrape le mesurable
99
+ (étiquettes, tailles, structure), jamais le sens — l'ordre logique d'un formulaire ou la pertinence
100
+ d'un `alt` restent un jugement humain.
101
+
102
+ ## `rendu` — la page tient-elle dans son viewport, ses polices sont-elles là
103
+
104
+ - `horizontalOverflow` : la page dépasse-t-elle la largeur de la fenêtre (le défilement
105
+ horizontal accidentel). C'est LUI qui porte le verdict.
106
+ - `elementsOutsideViewport` : une **information**, pas un verdict — carrousels, tiroirs et textes
107
+ destinés aux lecteurs d'écran sortent du viewport légitimement.
108
+ - `fonts` : ce que `document.fonts` a RÉELLEMENT chargé (statut par famille + graisse). Une
109
+ police en échec bascule le verdict — le texte s'affiche alors dans une police de repli, et toutes
110
+ les mesures de taille en héritent.
111
+
112
+ **Quand elle se trompe** : la mesure attend `document.fonts.ready` au plus 2 s — une police servie
113
+ très lentement peut encore être `loading` au moment de la lecture, sans être en échec.
114
+
115
+ ## `reseau` — requêtes, échecs, poids, temps
116
+
117
+ Compte par type, octets réellement transférés, échecs (statut ≥ 400 et requêtes avortées),
118
+ ressources **lourdes** (> `NF_BROWSER_SEUIL_LOURD`, défaut 512 000 octets) et **lentes**
119
+ (> `NF_BROWSER_SEUIL_LENT`, défaut 1 000 ms). Verdict : ALERTE dès un échec ou une ressource lourde.
120
+
121
+ **Quand elle se trompe** :
122
+
123
+ - En développement, un serveur d'assets qui livre les modules UN PAR UN rend des centaines de
124
+ requêtes et des mégaoctets non minifiés : c'est le DÉCOR du mode dev, pas une régression. Les
125
+ seuils jugent une application SERVIE — comparer dev et prod n'a pas de sens.
126
+ - Les tailles viennent du transfert réel quand le navigateur les donne, de `content-length` sinon ;
127
+ `unknownBytes` compte ce qui n'a pu être pesé — un total avec beaucoup d'inconnus minore.
128
+ - La collecte s'arrête à la mesure : ce que la page télécharge APRÈS (interaction, différé) n'est
129
+ pas vu — c'est le travail de `watch.mjs`.
130
+
131
+ ## `perf` — temps de rendu et stabilité visuelle
132
+
133
+ `ttfbMs`, `domContentLoadedMs`, `loadCompleteMs`, `fcpMs`, `lcpMs`, `cls`, `longTasks` —
134
+ verdict sur les seuils « bons » des Web Vitals : LCP ≤ 2 500 ms, CLS ≤ 0,1.
135
+
136
+ **Quand elle se trompe** :
137
+
138
+ - **Une seule visite n'est pas une statistique.** Cache froid ou chaud, machine chargée, premier
139
+ boot d'un serveur de dev : la même page varie du simple au double. Un verdict ALERTE isolé se
140
+ vérifie en relançant ; une tendance se mesure en médiane de plusieurs passes.
141
+ - LCP et CLS sont observés PENDANT le chargement (observateurs injectés avant la navigation) : la
142
+ sonde ne voit pas les décalages provoqués ENSUITE par une interaction.
143
+ - Le CLS d'une application en mode développement (styles injectés à la volée) est structurellement
144
+ plus mauvais qu'en production.
145
+
146
+ ## `stockage` — cookies et Web Storage
147
+
148
+ Attributs des cookies (`secure`, `httpOnly`, `sameSite`, expiration) et inventaire du
149
+ `localStorage`/`sessionStorage` (clés, octets, les 5 plus grosses). **Jamais les valeurs** : un
150
+ jeton de session imprimé dans une sortie de sonde finit dans un terminal, un log de CI, un rapport
151
+ — il a fuité. Verdict : ALERTE si un cookie sans `secure` circule sur une origine https.
152
+
153
+ **Quand elle se trompe** : les octets du Web Storage comptent en unités UTF-16 (×2) — c'est
154
+ l'empreinte mémoire, pas la taille « à l'écran » ; et un cookie `httpOnly: false` n'est pas signalé
155
+ comme alerte alors qu'il mérite un regard si sa valeur est sensible.
156
+
157
+ ## `responsive` — la même page à plusieurs largeurs
158
+
159
+ Rejoue la mesure de débordement horizontal à chaque largeur de `NF_BROWSER_WIDTHS` (défaut
160
+ `360,768,1280`). Par largeur : dépassement en pixels, nombre d'éléments débordants, 3 exemples.
161
+
162
+ **Quand elle se trompe** : redimensionner un viewport n'est pas changer d'appareil — ni densité de
163
+ pixels, ni tactile, ni `user-agent`. Une media query sur `pointer` ou `hover` ne réagira pas. Et la
164
+ capture PNG est prise AVANT cette famille, à la largeur d'origine : les débordements constatés ici
165
+ ne s'y voient pas.
166
+
167
+ ## Lire un verdict sans se faire piéger
168
+
169
+ 1. **`ALERTE` n'est pas « cassé »** — c'est « mérite un regard ». Le détail dit lequel.
170
+ 2. **`OK` n'est pas « accessible / rapide / propre »** — c'est « rien de mesurable à signaler dans
171
+ cette famille, sur cette page, à cet instant ».
172
+ 3. **Une mesure en mode développement juge le mode développement.** Poids, nombre de requêtes et
173
+ CLS ne se comparent qu'à décor égal.
174
+ 4. **Le verdict global agrège, il n'explique pas.** Toujours descendre dans la famille qui l'a fait
175
+ basculer.
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Audit Lighthouse d'une page, y compris DERRIÈRE une authentification.
3
+ *
4
+ * Pourquoi un script à part plutôt qu'une famille d'`inspect.mjs` : Lighthouse
5
+ * ne mesure pas une page ouverte, il en PILOTE le chargement — il recharge, vide
6
+ * le cache, bride le réseau et le processeur, et chronomètre. C'est l'inverse de
7
+ * la photographie d'un instant, et cela prend des dizaines de secondes.
8
+ *
9
+ * Comment l'authentification survit, alors que Lighthouse ouvre son propre
10
+ * onglet : le navigateur est lancé avec un PROFIL PERSISTANT et un port de
11
+ * débogage. On s'y connecte normalement — les témoins de session vivent alors
12
+ * dans le profil, pas dans un contexte isolé — puis Lighthouse se branche sur ce
13
+ * même navigateur et hérite du profil. Sans cela, il mesurerait l'écran de
14
+ * connexion en croyant tenir la page demandée.
15
+ *
16
+ * `@usage` node audit.mjs /tableau-de-bord
17
+ * `@env` NF_BROWSER_BASE origine à joindre (défaut constaté : local ou conteneur)
18
+ * `@env` NF_BROWSER_OUT dossier de sortie (le rapport complet y est déposé)
19
+ * `@env` NF_BROWSER_LOGIN chemin du formulaire de connexion — aucun défaut deviné
20
+ * `@env` NF_BROWSER_USER identifiant ; sans lui, aucune authentification n'est tentée
21
+ * `@env` NF_BROWSER_PASSWORD mot de passe associé
22
+ * `@env` NF_BROWSER_CATEGORIES catégories à jouer, séparées par des virgules (défaut : toutes celles que ce Lighthouse connaît)
23
+ * `@env` NF_BROWSER_FORMFACTOR `desktop` (défaut) ou `mobile` — un score de performance ne veut RIEN dire sans son appareil
24
+ * `@env` NF_BROWSER_SEUIL_AUDIT score en deçà duquel un audit est retenu, en pourcentage (défaut 90)
25
+ * `@requires` `lighthouse` et `playwright` installés (pairs optionnels)
26
+ * `@output` un résumé JSON sur stdout + le rapport COMPLET dans le dossier de sortie
27
+ * `@exit` 0 mesure rendue (le verdict est une DONNÉE) · 64 usage · 69 outil indisponible
28
+ */
29
+ import { createServer } from "node:net";
30
+ import { mkdirSync, writeFileSync } from "node:fs";
31
+ import path from "node:path";
32
+ import { mkdtempSync } from "node:fs";
33
+ import { tmpdir } from "node:os";
34
+ import { BASE, LOGIN, PASSWORD, OUTPUT, USER } from "./lib/browser.mjs";
35
+ import { summarizeLighthouse } from "./lib/probes.mjs";
36
+
37
+ const PAGE = process.argv[2] ?? process.env.NF_BROWSER_PAGE ?? "/";
38
+ const THRESHOLD = Number(process.env.NF_BROWSER_SEUIL_AUDIT ?? 90) / 100;
39
+ const FORMFACTOR = (process.env.NF_BROWSER_FORMFACTOR ?? "desktop").trim();
40
+ if (FORMFACTOR !== "desktop" && FORMFACTOR !== "mobile") {
41
+ console.error(
42
+ `NF_BROWSER_FORMFACTOR inconnu : « ${FORMFACTOR} ». Valeurs : desktop, mobile.`,
43
+ );
44
+ process.exit(64); // EX_USAGE
45
+ }
46
+
47
+ const tools = {};
48
+ for (const name of ["lighthouse", "playwright"]) {
49
+ try {
50
+ tools[name] = await import(name);
51
+ } catch {
52
+ console.error(
53
+ `${name} est absent — cet audit ne peut pas avoir lieu sans lui.\n\n` +
54
+ ` npm i -D lighthouse playwright && npx playwright install chromium\n\n` +
55
+ "Les deux sont des pairs OPTIONNELS : seuls ceux qui auditent une page\n" +
56
+ "les installent, personne ne les paie sans les vouloir.",
57
+ );
58
+ process.exit(69); // EX_UNAVAILABLE
59
+ }
60
+ }
61
+ const lighthouse = tools.lighthouse.default;
62
+ const { chromium } = tools.playwright;
63
+
64
+ /**
65
+ * Un port libre, demandé au système plutôt que choisi au hasard.
66
+ *
67
+ * Un port fixe entrerait en collision avec un autre navigateur de débogage —
68
+ * et l'audit se brancherait alors sur le mauvais, ce qui ne produit pas une
69
+ * erreur mais une mesure d'une AUTRE page.
70
+ *
71
+ * @returns {Promise<number>} un port que rien n'écoute au moment du rendu.
72
+ */
73
+ function freePort() {
74
+ return new Promise((resolve, reject) => {
75
+ const srv = createServer();
76
+ srv.on("error", reject);
77
+ srv.listen(0, "127.0.0.1", () => {
78
+ const { port } = srv.address();
79
+ srv.close(() => resolve(port));
80
+ });
81
+ });
82
+ }
83
+
84
+ const port = await freePort();
85
+ // Profil PERSISTANT : c'est lui qui porte la session entre notre connexion et
86
+ // l'onglet que Lighthouse ouvrira. Jetable — il vit le temps de l'audit.
87
+ const profile = mkdtempSync(path.join(tmpdir(), "nf-audit-"));
88
+ const ctx = await chromium.launchPersistentContext(profile, {
89
+ channel: "chromium",
90
+ ignoreHTTPSErrors: true,
91
+ args: [`--remote-debugging-port=${port}`, "--no-sandbox"],
92
+ });
93
+
94
+ try {
95
+ const page = ctx.pages()[0] ?? (await ctx.newPage());
96
+ if (USER) {
97
+ if (!LOGIN) {
98
+ console.error(
99
+ "NF_BROWSER_USER est posé mais pas NF_BROWSER_LOGIN : donne le chemin de\n" +
100
+ "ton formulaire de connexion, sinon l'audit mesurerait l'écran de connexion.",
101
+ );
102
+ process.exit(64); // EX_USAGE
103
+ }
104
+ await page.goto(`${BASE}${LOGIN}`, { waitUntil: "domcontentloaded" });
105
+ const id = page.getByRole("textbox", {
106
+ name: /identifiant|utilisateur|username|e-?mail/iu,
107
+ });
108
+ const pw = page.getByRole("textbox", { name: /mot de passe|password/iu });
109
+ await id.or(pw).first().waitFor({ timeout: 15000 });
110
+ if ((await id.count()) > 0) {
111
+ await id.fill(USER);
112
+ await id.press("Enter");
113
+ }
114
+ await pw.fill(PASSWORD, { timeout: 15000 });
115
+ await pw.press("Enter");
116
+ await page.waitForURL((u) => !u.pathname.endsWith(LOGIN), {
117
+ timeout: 20000,
118
+ });
119
+ }
120
+
121
+ const target = `${BASE}${PAGE}`;
122
+ const categories = (process.env.NF_BROWSER_CATEGORIES ?? "")
123
+ .split(",")
124
+ .map((c) => c.trim())
125
+ .filter(Boolean);
126
+
127
+ const runnerResult = await lighthouse(target, {
128
+ port,
129
+ output: "json",
130
+ logLevel: "error",
131
+ formFactor: FORMFACTOR,
132
+ // Le bridage d'écran par défaut simule un téléphone : le laisser en place
133
+ // pendant qu'on demande `desktop` produirait un décor incohérent, et des
134
+ // chiffres qu'on ne saurait rattacher à aucun appareil réel.
135
+ screenEmulation:
136
+ FORMFACTOR === "desktop"
137
+ ? { mobile: false, width: 1440, height: 900, deviceScaleFactor: 1 }
138
+ : undefined,
139
+ // 🔴 Sans ceci, Lighthouse VIDE le stockage avant de mesurer — donc les
140
+ // témoins de session — et audite l'écran de connexion en silence. C'est le
141
+ // piège central d'un audit derrière authentification.
142
+ disableStorageReset: true,
143
+ ...(categories.length > 0 ? { onlyCategories: categories } : {}),
144
+ });
145
+
146
+ const lhr = runnerResult?.lhr;
147
+ if (!lhr) {
148
+ console.error("Lighthouse n'a rendu aucun rapport.");
149
+ process.exit(69); // EX_UNAVAILABLE
150
+ }
151
+
152
+ // Le rapport COMPLET est conservé : le résumé sert à décider, l'original à
153
+ // vérifier — et à comparer dans le temps.
154
+ mkdirSync(OUTPUT, { recursive: true });
155
+ const stamp = new Date().toISOString().replace(/[:.]/gu, "-").slice(0, 19);
156
+ const slug = PAGE.replace(/\//gu, "-").replace(/^-/u, "") || "racine";
157
+ const fullPath = path.join(OUTPUT, `lighthouse-${slug}-${stamp}.json`);
158
+ writeFileSync(fullPath, JSON.stringify(lhr), "utf8");
159
+
160
+ console.log(
161
+ JSON.stringify(
162
+ { ...summarizeLighthouse(lhr, THRESHOLD), fullReport: fullPath },
163
+ null,
164
+ 2,
165
+ ),
166
+ );
167
+ } finally {
168
+ await ctx.close();
169
+ }