@nodefony/devkit 10.0.0-beta.3 → 10.0.0-beta.5
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/README.md +146 -4
- package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
- package/dist/index.js +24 -11
- package/dist/nodefony/config/config.js +16 -137
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/controllers/DevkitController.js +7 -14
- package/dist/nodefony/controllers/McpController.js +25 -226
- package/dist/nodefony/controllers/ProfileSessionController.js +47 -0
- package/dist/nodefony/mcp/observe.js +235 -0
- package/dist/nodefony/mcp/profileReport.js +242 -0
- package/dist/nodefony/mcp/profileRoute.js +521 -0
- package/dist/nodefony/mcp/shotClient.js +84 -0
- package/dist/nodefony/mcp/shutdown.js +44 -0
- package/dist/nodefony/mcp/tools.js +696 -0
- package/dist/nodefony/service/DevkitService.js +93 -29
- package/dist/nodefony/src/errors/DevkitError.js +1 -2
- package/dist/nodefony/src/profileSession.js +14 -0
- package/dist/types/index.d.ts +15 -1
- package/dist/types/nodefony/config/config.d.ts +1 -1
- package/dist/types/nodefony/controllers/McpController.d.ts +11 -26
- package/dist/types/nodefony/controllers/ProfileSessionController.d.ts +31 -0
- package/dist/types/nodefony/interfaces/IDevkitService.d.ts +22 -1
- package/dist/types/nodefony/mcp/observe.d.ts +83 -0
- package/dist/types/nodefony/mcp/profileReport.d.ts +138 -0
- package/dist/types/nodefony/mcp/profileRoute.d.ts +87 -0
- package/dist/types/nodefony/mcp/shotClient.d.ts +53 -0
- package/dist/types/nodefony/mcp/shutdown.d.ts +25 -0
- package/dist/types/nodefony/mcp/tools.d.ts +109 -0
- package/dist/types/nodefony/service/DevkitService.d.ts +41 -4
- package/dist/types/nodefony/src/profileSession.d.ts +12 -0
- package/docs/index.md +160 -11
- package/package.json +16 -11
- package/skills/nodefony-add-crud/SKILL.md +10 -1
- package/skills/nodefony-add-service/SKILL.md +3 -3
- package/skills/nodefony-authentication/SKILL.md +5 -5
- package/skills/nodefony-browser/SKILL.md +20 -0
- package/skills/nodefony-browser/references/socket.md +13 -4
- package/skills/nodefony-browser/references/sondes.md +13 -1
- package/skills/nodefony-browser/scripts/inspect.mjs +55 -8
- package/skills/nodefony-browser/scripts/lib/probes.mjs +53 -0
- package/skills/nodefony-browser/scripts/socket.mjs +70 -4
- package/skills/nodefony-browser/scripts/watch.mjs +101 -0
- package/skills/nodefony-dev/SKILL.md +31 -1
- package/skills/nodefony-dev/scripts/docs.mjs +3 -3
- package/skills/nodefony-devops/SKILL.md +85 -64
- package/skills/nodefony-devops/references/dimensionnement.md +122 -0
- package/skills/nodefony-devops/references/frontal.md +277 -47
- package/skills/nodefony-devops/references/kubernetes.md +41 -14
- package/skills/nodefony-devops/references/secrets.md +13 -2
- package/skills/nodefony-devops/references/variables.md +14 -12
- package/skills/nodefony-protect-route/SKILL.md +6 -4
- package/skills/nodefony-security-review/scripts/review-diff.mjs +4 -4
- package/skills/nodefony-troubleshoot/SKILL.md +86 -12
- package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
|
@@ -42,6 +42,11 @@ gabarit porte des détails qui ne se devinent pas : le repository résolu au pre
|
|
|
42
42
|
se connecte qu'au démarrage), la pagination bornée **et son tri déclaré**, les codes
|
|
43
43
|
201/204/404/409/422, et l'en-tête `Location`.
|
|
44
44
|
|
|
45
|
+
Le controller généré **déclare** ses entrées : chaque route porte un `schema` (`idParams`, le schéma
|
|
46
|
+
de création ou de retouche de l'entité), et l'action reçoit `input` — validé à la porte (422) avant
|
|
47
|
+
tout appel au service, qui revalide pour ses autres portes (CLI, socket). Pour ajouter une action,
|
|
48
|
+
**garde cette forme** : un `@Body` ou un `@Param` sur une action à schéma est refusé au démarrage.
|
|
49
|
+
|
|
45
50
|
## La grammaire de champs
|
|
46
51
|
|
|
47
52
|
`nom:type[?][=défaut][:index|:unique]` — **non-null par défaut**, `?` rend facultatif, `=valeur`
|
|
@@ -190,7 +195,11 @@ refus tombe tout seul.
|
|
|
190
195
|
|
|
191
196
|
## Les trois vérités à savoir avant de livrer (SQL)
|
|
192
197
|
|
|
193
|
-
1. **La table
|
|
198
|
+
1. **La table existe dès la commande quand l'application versionne ses migrations** (toute
|
|
199
|
+
application `complete`) : `create entity` écrit ET applique la migration de la table neuve —
|
|
200
|
+
`--no-migrate` s'en dispense ; si des entités modifiées attendaient, la migration est écrite mais
|
|
201
|
+
PAS appliquée, et la sortie nomme les tables en trop. Sinon, la table naît au prochain démarrage
|
|
202
|
+
en développement (`CREATE TABLE IF NOT EXISTS`).
|
|
194
203
|
2. **La modifier n'altère rien** — aucun `ALTER` n'est émis. Une colonne ajoutée à une entité déjà
|
|
195
204
|
créée n'apparaîtra pas dans une base existante.
|
|
196
205
|
3. **La production ne fabrique JAMAIS le schéma.** Elle l'applique par des migrations, écrites par
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Crée un service injectable dans une application Nodefony par `nodefony create service`, et le
|
|
5
5
|
fait entrer dans le conteneur — la moitié qu'on oublie. Porte la distinction entre le nom de la
|
|
6
6
|
CLASSE et le nom de l'INSTANCE, les deux façons d'obtenir un service depuis un autre
|
|
7
|
-
(`@
|
|
7
|
+
(`@Inject` au constructeur ou `container.get` à l'usage), le choix de sa durée de vie
|
|
8
8
|
(un exemplaire pour l'application, ou un par requête), et le défaut mesuré qu'un service
|
|
9
9
|
écrit à la main produit : une classe qui compile, dont les tests passent, et que le conteneur
|
|
10
10
|
ignore. À charger AVANT d'écrire une classe de service ou d'appeler un service depuis un autre.
|
|
@@ -63,7 +63,7 @@ et à l'introspection, et n'est construit qu'à la première requête qui le ré
|
|
|
63
63
|
C'est le piège n°1, et il ne produit aucune erreur — juste un `null` :
|
|
64
64
|
|
|
65
65
|
```ts
|
|
66
|
-
@injectable() // ← nomme la CLASSE : @
|
|
66
|
+
@injectable() // ← nomme la CLASSE : @Inject("BillingService")
|
|
67
67
|
export class BillingService extends Service {
|
|
68
68
|
constructor() {
|
|
69
69
|
super("billing", ...); // ← nomme l'INSTANCE : container.get("billing")
|
|
@@ -103,7 +103,7 @@ Détail : `node_modules/nodefony/docs/request-context.md`.
|
|
|
103
103
|
|
|
104
104
|
| Voie | Quand |
|
|
105
105
|
| ------------------------------ | ----------------------------------------------------------------------------- |
|
|
106
|
-
| `@
|
|
106
|
+
| `@Inject("X")` au constructeur | **par défaut** — la dépendance est déclarée, l'ordre de démarrage la respecte |
|
|
107
107
|
| `container.get("x")` à l'usage | quand la dépendance est facultative, tardive, ou choisie à l'exécution |
|
|
108
108
|
|
|
109
109
|
`create service --inject` pose la première. Le second est visible partout dans les exemples, et
|
|
@@ -207,11 +207,11 @@ l'audience absente : `--write`, puis réimport. Détail :
|
|
|
207
207
|
Un script, une tâche planifiée, un agent n'ont ni navigateur ni cookie : ils présentent une
|
|
208
208
|
preuve à chaque appel, `Authorization: Bearer …`.
|
|
209
209
|
|
|
210
|
-
| Preuve | L'obtenir
|
|
211
|
-
| ------------------------------- |
|
|
212
|
-
| clé d'API opaque (`nf_…`) | `POST /nodefony/security/api/keys` (session d'administration)
|
|
213
|
-
| jeton signé par l'application | `npx nodefony security:token [compte] --scope … --ttl … --json` |
|
|
214
|
-
| jeton Keycloak (client machine) | `requestClientCredentialsToken` de `@nodefony/security`
|
|
210
|
+
| Preuve | L'obtenir |
|
|
211
|
+
| ------------------------------- | ------------------------------------------------------------------- |
|
|
212
|
+
| clé d'API opaque (`nf_…`) | `POST /nodefony/security/api/keys` (session d'administration) |
|
|
213
|
+
| jeton signé par l'application | `npx nodefony security:mcp:token [compte] --scope … --ttl … --json` |
|
|
214
|
+
| jeton Keycloak (client machine) | `requestClientCredentialsToken` de `@nodefony/security` |
|
|
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
|
|
@@ -381,6 +381,26 @@ en bout : le message part-il, revient-il, et l'écran le reçoit-il ?
|
|
|
381
381
|
> lui, vaut pour toute attente que tu écriras : tant qu'elle n'a pas échoué une fois, elle ne
|
|
382
382
|
> discrimine rien.
|
|
383
383
|
|
|
384
|
+
### Éprouver la REPRISE après une coupure réseau — `NF_BROWSER_CUT`
|
|
385
|
+
|
|
386
|
+
```bash
|
|
387
|
+
docker exec -e NF_BROWSER_CUT=5000:4000 mon-app-browser node /app/see-screen/watch.mjs /tableau-de-bord 25000
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
`<début>:<durée>` en millisecondes : le réseau tombe 5 s après l'ouverture de la page, revient 4 s
|
|
391
|
+
plus tard. Chaque socket coupé sort avec `closedAt` et `closeCode` (1006, ce que voit un client
|
|
392
|
+
qui perd le réseau), le bloc `cut` compte les réouvertures refusées pendant la coupure, et le
|
|
393
|
+
socket rouvert montre ses frames : l'accueil, les abonnements rejoués, leur acquittement, puis le
|
|
394
|
+
flux. C'est la seule preuve qu'une page qui perd le réseau **retrouve** ses données.
|
|
395
|
+
|
|
396
|
+
- **Le serveur est prévenu** (fermeture 1000) : la connexion coupée ne reste pas à demi ouverte
|
|
397
|
+
chez lui. Une vraie perte de réseau, elle, ne le prévient pas. La sonde éprouve la reprise du
|
|
398
|
+
client, pas la détection d'un pair mort.
|
|
399
|
+
- **Le relais Vite (`/_vite/`) n'est pas coupé** : son client de rechargement à chaud recharge la
|
|
400
|
+
page quand il retrouve le serveur. Vous observeriez une page neuve, pas sa reprise.
|
|
401
|
+
- **Les `ERR_INTERNET_DISCONNECTED` de la console pendant la coupure sont attendus.**
|
|
402
|
+
- Une valeur malformée est refusée (code 64), jamais ignorée.
|
|
403
|
+
|
|
384
404
|
## Piloter le socket de bout en bout — `socket.mjs`
|
|
385
405
|
|
|
386
406
|
`watch.mjs` regarde le trafic d'une page ; celui-ci **conduit** : il ouvre un socket temps réel
|
|
@@ -36,10 +36,19 @@ Deux frames servent de repères :
|
|
|
36
36
|
|
|
37
37
|
### `welcome`
|
|
38
38
|
|
|
39
|
-
`receivedAfterMs`, canaux, méthodes, identité. **S'il ne vient jamais** (code de retour
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
`verdict`, `receivedAfterMs`, canaux, méthodes, identité. **S'il ne vient jamais** (code de retour
|
|
40
|
+
65), la sortie dit lequel des deux cas :
|
|
41
|
+
|
|
42
|
+
- **`REFUSÉ AU HANDSHAKE`** — le serveur a répondu autre chose que `101` : la socket ne s'est jamais
|
|
43
|
+
ouverte, et la sonde le rend tout de suite avec `status`, `afterMs` et la `browserReason` du
|
|
44
|
+
navigateur (la page ne voit qu'une fermeture `1006` ; le statut, seul le navigateur le connaît).
|
|
45
|
+
`401` : la page n'est pas authentifiée (donner `NF_BROWSER_USER` + `NF_BROWSER_LOGIN`) ; `403` :
|
|
46
|
+
le compte n'a pas le rôle, ou l'`Origin` de la page est refusé ; `404` : le chemin du endpoint
|
|
47
|
+
est faux. Chromium ne nomme pas le code d'un `401` porteur de `WWW-Authenticate` (« HTTP
|
|
48
|
+
Authentication failed ») : la sonde le traduit.
|
|
49
|
+
- **Rien sur la sortie standard, message « en 8000 ms »** — la socket s'est ouverte mais l'accueil
|
|
50
|
+
n'est jamais venu : l'endpoint n'est pas un endpoint temps réel Nodefony, ou le serveur ferme
|
|
51
|
+
après l'ouverture. Le réseau qui « passe » n'innocente rien.
|
|
43
52
|
|
|
44
53
|
### `subscription`
|
|
45
54
|
|
|
@@ -37,6 +37,11 @@ C'est au lecteur de juger — la sonde fournit, elle ne condamne pas ce qu'elle
|
|
|
37
37
|
Un sélecteur par élément (`NF_BROWSER_PROBES=libellé=sélecteur,…`) ; pour chacun : texte, couleur,
|
|
38
38
|
fond effectif, rapport de contraste, police, verdict WCAG, taille rendue.
|
|
39
39
|
|
|
40
|
+
- **Un widget en Shadow DOM est invisible à un sélecteur nu** — la sonde rend `absent`, et l'on
|
|
41
|
+
conclut à tort que l'élément n'existe pas. `hôte >>> sélecteur` entre dans la racine fantôme
|
|
42
|
+
OUVERTE de l'hôte (`#ma-barre >>> .bouton`), segment après segment. Le fond effectif traverse la
|
|
43
|
+
frontière de la même façon : au-dessus de la racine fantôme, c'est l'hôte qui fait fond.
|
|
44
|
+
|
|
40
45
|
- **Le fond effectif empile TOUTES les couches** jusqu'au premier ancêtre opaque, puis les compose.
|
|
41
46
|
Deux erreurs à ne pas refaire : lire `backgroundColor` sur l'élément rend `rgba(0,0,0,0)` et un
|
|
42
47
|
contraste faux ; s'arrêter à la première couche non transparente traite un voile à 13 % comme un
|
|
@@ -102,9 +107,16 @@ d'un `alt` restent un jugement humain.
|
|
|
102
107
|
## `rendu` — la page tient-elle dans son viewport, ses polices sont-elles là
|
|
103
108
|
|
|
104
109
|
- `horizontalOverflow` : la page dépasse-t-elle la largeur de la fenêtre (le défilement
|
|
105
|
-
horizontal accidentel).
|
|
110
|
+
horizontal accidentel). Il porte le verdict.
|
|
111
|
+
- `horizontalScrollers` : les conteneurs défilants (`overflow-x: auto|scroll`) dont le contenu
|
|
112
|
+
déborde — une barre de défilement horizontale DANS la page, que `horizontalOverflow` ne voit
|
|
113
|
+
pas. Il porte AUSSI le verdict. Vécu : une barre de débogage en Shadow DOM débordait de 232 px
|
|
114
|
+
pendant que la page entière se déclarait saine. Cause fréquente : une infobulle invisible
|
|
115
|
+
(`visibility: hidden`) ancrée à gauche sur un bouton de droite — invisible, elle élargit quand
|
|
116
|
+
même la zone défilable.
|
|
106
117
|
- `elementsOutsideViewport` : une **information**, pas un verdict — carrousels, tiroirs et textes
|
|
107
118
|
destinés aux lecteurs d'écran sortent du viewport légitimement.
|
|
119
|
+
- Les trois mesures descendent dans les **Shadow DOM ouverts**.
|
|
108
120
|
- `fonts` : ce que `document.fonts` a RÉELLEMENT chargé (statut par famille + graisse). Une
|
|
109
121
|
police en échec bascule le verdict — le texte s'affiche alors dans une police de repli, et toutes
|
|
110
122
|
les mesures de taille en héritent.
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
* saisir, choisir, touche, voir, defiler, attendre. Un écran qui se déplie
|
|
28
28
|
* au clic n'existe pas tant qu'on ne l'a pas ouvert.
|
|
29
29
|
* `@env` NF_BROWSER_FULLPAGE 1 = capture la page ENTIÈRE (défaut : la fenêtre)
|
|
30
|
-
* `@env` NF_BROWSER_PROBES sélecteurs CSS à sonder, séparés par des virgules (`libellé=sélecteur`)
|
|
30
|
+
* `@env` NF_BROWSER_PROBES sélecteurs CSS à sonder, séparés par des virgules (`libellé=sélecteur`) ;
|
|
31
|
+
* `hôte >>> sélecteur` entre dans le Shadow DOM ouvert de l'hôte
|
|
31
32
|
* `@env` NF_BROWSER_WIDTHS largeurs de la famille responsive (défaut 360,768,1280)
|
|
32
33
|
* `@env` NF_BROWSER_VIEWPORT écran mesuré : mobile, mobile-court, tablette, bureau (défaut), large, tres-large, ou LARGEURxHAUTEUR — sous 600 px, émulé en téléphone (tactile, densité 2)
|
|
33
34
|
* `@env` NF_BROWSER_PRIMARY sélecteur de l'action principale pour la famille pli (défaut : le plus grand bouton de validation visible)
|
|
@@ -384,7 +385,14 @@ function isVisible(el) {
|
|
|
384
385
|
function effectiveBackground(el) {
|
|
385
386
|
const layers = [];
|
|
386
387
|
let opaqueLayer = null;
|
|
387
|
-
|
|
388
|
+
// Un élément posé à la racine d'un Shadow DOM n'a pas de parent : son fond
|
|
389
|
+
// est celui de l'HÔTE, et la remontée continue de l'autre côté.
|
|
390
|
+
const up = (n) => {
|
|
391
|
+
if (n.parentElement) return n.parentElement;
|
|
392
|
+
const root = n.getRootNode();
|
|
393
|
+
return root instanceof ShadowRoot ? root.host : null;
|
|
394
|
+
};
|
|
395
|
+
for (let n = el; n; n = up(n)) {
|
|
388
396
|
const bg = getComputedStyle(n).backgroundColor;
|
|
389
397
|
if (!bg || bg.includes("transparent")) continue;
|
|
390
398
|
const { a } = parseColor(bg);
|
|
@@ -550,18 +558,44 @@ function probeA11y() {
|
|
|
550
558
|
* Famille rendu — la page tient-elle dans son viewport, ses polices sont-elles
|
|
551
559
|
* VRAIMENT arrivées. Les éléments hors viewport sont une INFO, pas le verdict :
|
|
552
560
|
* carrousels et textes pour lecteurs d'écran en produisent légitimement.
|
|
561
|
+
*
|
|
562
|
+
* Le débordement se cherche AUSSI dans les Shadow DOM ouverts, et dans chaque
|
|
563
|
+
* conteneur défilant : un widget isolé (une barre de débogage) peut porter sa
|
|
564
|
+
* propre barre de défilement horizontale sans que la page, elle, déborde d'un
|
|
565
|
+
* pixel — la mesure du document seul la déclarait saine.
|
|
553
566
|
*/
|
|
554
567
|
function probeRendering() {
|
|
555
568
|
const doc = document.scrollingElement ?? document.documentElement;
|
|
556
569
|
const overflowPx = Math.max(0, doc.scrollWidth - window.innerWidth);
|
|
557
570
|
const outsideViewport = [];
|
|
558
571
|
let totalOutside = 0;
|
|
559
|
-
|
|
572
|
+
const scrollersX = [];
|
|
573
|
+
let totalScrollersX = 0;
|
|
574
|
+
const all = (root, out) => {
|
|
575
|
+
for (const el of root.querySelectorAll("*")) {
|
|
576
|
+
out.push(el);
|
|
577
|
+
if (el.shadowRoot) all(el.shadowRoot, out);
|
|
578
|
+
}
|
|
579
|
+
return out;
|
|
580
|
+
};
|
|
581
|
+
for (const el of all(document.body, [])) {
|
|
560
582
|
const r = el.getBoundingClientRect();
|
|
561
|
-
if (r.width
|
|
583
|
+
if (r.width === 0) continue;
|
|
584
|
+
if (r.right > window.innerWidth + 1 && totalOutside < 200) {
|
|
562
585
|
totalOutside += 1;
|
|
563
586
|
if (outsideViewport.length < 3) outsideViewport.push(describeElement(el));
|
|
564
|
-
|
|
587
|
+
}
|
|
588
|
+
const ox = getComputedStyle(el).overflowX;
|
|
589
|
+
if (
|
|
590
|
+
(ox === "auto" || ox === "scroll") &&
|
|
591
|
+
el.scrollWidth > el.clientWidth + 1
|
|
592
|
+
) {
|
|
593
|
+
totalScrollersX += 1;
|
|
594
|
+
if (scrollersX.length < 3)
|
|
595
|
+
scrollersX.push({
|
|
596
|
+
element: describeElement(el),
|
|
597
|
+
overflowPx: el.scrollWidth - el.clientWidth,
|
|
598
|
+
});
|
|
565
599
|
}
|
|
566
600
|
}
|
|
567
601
|
const families = {};
|
|
@@ -571,8 +605,12 @@ function probeRendering() {
|
|
|
571
605
|
if (f.status === "error") failedFonts += 1;
|
|
572
606
|
}
|
|
573
607
|
return {
|
|
574
|
-
verdict:
|
|
608
|
+
verdict:
|
|
609
|
+
overflowPx > 0 || totalScrollersX > 0 || failedFonts > 0
|
|
610
|
+
? "ALERTE"
|
|
611
|
+
: "OK",
|
|
575
612
|
horizontalOverflow: { present: overflowPx > 0, overflowPx },
|
|
613
|
+
horizontalScrollers: { total: totalScrollersX, examples: scrollersX },
|
|
576
614
|
elementsOutsideViewport: { total: totalOutside, examples: outsideViewport },
|
|
577
615
|
fonts: {
|
|
578
616
|
status: document.fonts.status,
|
|
@@ -659,7 +697,16 @@ async function measurePage(args) {
|
|
|
659
697
|
s.getAttribute("src"),
|
|
660
698
|
),
|
|
661
699
|
probes: args.probes.map(({ label, sel }) => {
|
|
662
|
-
|
|
700
|
+
// `hôte >>> sélecteur` traverse un Shadow DOM ouvert (un widget isolé,
|
|
701
|
+
// une barre de débogage) : chaque segment se cherche dans la racine
|
|
702
|
+
// fantôme de l'élément trouvé par le précédent.
|
|
703
|
+
let el = null;
|
|
704
|
+
let scope = document;
|
|
705
|
+
for (const part of sel.split(">>>")) {
|
|
706
|
+
el = scope?.querySelector(part.trim()) ?? null;
|
|
707
|
+
scope = el?.shadowRoot ?? null;
|
|
708
|
+
if (el === null) break;
|
|
709
|
+
}
|
|
663
710
|
if (!el) return { label, absent: true, selector: sel };
|
|
664
711
|
const cs = getComputedStyle(el);
|
|
665
712
|
const r = el.getBoundingClientRect();
|
|
@@ -789,7 +836,7 @@ if (active.has("reseau")) {
|
|
|
789
836
|
const slowThreshold = Number(process.env.NF_BROWSER_SEUIL_LENT ?? 1000);
|
|
790
837
|
const items = await Promise.all(
|
|
791
838
|
finishedRequests.map(async (rq) => {
|
|
792
|
-
let bytes
|
|
839
|
+
let bytes;
|
|
793
840
|
try {
|
|
794
841
|
// `sizes()` rend la taille RÉELLEMENT transférée ; l'en-tête
|
|
795
842
|
// content-length manque sur les réponses en flux.
|
|
@@ -240,6 +240,44 @@ export function parseColorScheme(raw) {
|
|
|
240
240
|
return { schema: null, invalid: v };
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Analyse une coupure réseau demandée (`NF_BROWSER_CUT`), au format
|
|
245
|
+
* `<début>:<durée>` en millisecondes — `4000:3000` coupe le réseau 4 s après
|
|
246
|
+
* l'ouverture de la page et le rétablit 3 s plus tard.
|
|
247
|
+
*
|
|
248
|
+
* La coupure existe pour éprouver une REPRISE : un flux temps réel qui revient
|
|
249
|
+
* après une perte de réseau ne se constate sur aucune capture. Une valeur
|
|
250
|
+
* malformée est REFUSÉE : ignorée, elle ferait observer une page jamais coupée
|
|
251
|
+
* en croyant avoir éprouvé sa reprise.
|
|
252
|
+
*
|
|
253
|
+
* @param {string|undefined} raw - valeur brute (`"4000:3000"`, vide).
|
|
254
|
+
* @returns {{ cut: { at: number, duration: number }|null, invalid: string|null }}
|
|
255
|
+
* `cut` à null quand rien n'est demandé.
|
|
256
|
+
*/
|
|
257
|
+
export function parseCut(raw) {
|
|
258
|
+
const v = (raw ?? "").trim();
|
|
259
|
+
if (!v) return { cut: null, invalid: null };
|
|
260
|
+
const m = /^(\d+):(\d+)$/.exec(v);
|
|
261
|
+
if (!m || Number(m[2]) === 0) return { cut: null, invalid: v };
|
|
262
|
+
return { cut: { at: Number(m[1]), duration: Number(m[2]) }, invalid: null };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Ramène un code de fermeture WebSocket à un code qu'un NAVIGATEUR peut
|
|
267
|
+
* émettre : 1000, ou 3000 à 4999 — tout autre lève `InvalidAccessError`.
|
|
268
|
+
*
|
|
269
|
+
* 🔴 Le côté serveur d'une route Playwright est un vrai WebSocket de page.
|
|
270
|
+
* Fermé avec 1001 ou 1006, il refuse, Playwright avale l'erreur et rend « ok » :
|
|
271
|
+
* la connexion réelle reste ouverte et le serveur continue de pousser vers un
|
|
272
|
+
* socket que la sonde dit fermé. Mesuré : 47 s sans fermeture côté serveur.
|
|
273
|
+
*
|
|
274
|
+
* @param {number} code - le code voulu.
|
|
275
|
+
* @returns {number} ce code s'il est émissible, 1000 sinon.
|
|
276
|
+
*/
|
|
277
|
+
export function sendableCloseCode(code) {
|
|
278
|
+
return code === 1000 || (code >= 3000 && code <= 4999) ? code : 1000;
|
|
279
|
+
}
|
|
280
|
+
|
|
243
281
|
/**
|
|
244
282
|
* Arguments de lancement qui imposent le schéma de couleurs AU MOTEUR.
|
|
245
283
|
*
|
|
@@ -483,6 +521,21 @@ export function median(values) {
|
|
|
483
521
|
const m = Math.floor(sorted.length / 2);
|
|
484
522
|
return sorted.length % 2 === 1 ? sorted[m] : (sorted[m - 1] + sorted[m]) / 2;
|
|
485
523
|
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Arrondit une durée au centième de milliseconde — la précision d'un RTT local.
|
|
527
|
+
*
|
|
528
|
+
* Un arrondi à la milliseconde entière rend 0 pour tout aller-retour sous
|
|
529
|
+
* 0,5 ms : sur une même machine, la sonde annonçait une latence nulle. Le
|
|
530
|
+
* centième reste sous la résolution de `performance.now()` côté navigateur
|
|
531
|
+
* (~0,1 ms hors isolation), donc ne fabrique aucune précision qui n'existe pas.
|
|
532
|
+
*
|
|
533
|
+
* @param {number} ms - durée brute en millisecondes.
|
|
534
|
+
* @returns {number} la durée arrondie à 0,01 ms.
|
|
535
|
+
*/
|
|
536
|
+
export function roundMs(ms) {
|
|
537
|
+
return Math.round(ms * 100) / 100;
|
|
538
|
+
}
|
|
486
539
|
/**
|
|
487
540
|
* Résume un rapport axe-core en un bloc lisible — le tri éditorial, pas la mesure.
|
|
488
541
|
*
|
|
@@ -24,10 +24,10 @@
|
|
|
24
24
|
* `@env` NF_BROWSER_PINGS nombre de mesures de latence (défaut 5)
|
|
25
25
|
* `@requires` conteneur du profil `browser` démarré · serveur joignable · un endpoint temps réel exposé
|
|
26
26
|
* `@output` un objet JSON sur stdout : accueil, abonnement, latence, api, reconnexion — un verdict par étape
|
|
27
|
-
* `@exit` 0 scénario joué (les verdicts sont des DONNÉES) · 64 usage (endpoint manquant, params illisibles) · 65 accueil jamais reçu
|
|
27
|
+
* `@exit` 0 scénario joué (les verdicts sont des DONNÉES) · 64 usage (endpoint manquant, params illisibles) · 65 accueil jamais reçu (refus au handshake : JSON sur stdout, statut nommé)
|
|
28
28
|
*/
|
|
29
29
|
import { open, goTo } from "./lib/browser.mjs";
|
|
30
|
-
import { median } from "./lib/probes.mjs";
|
|
30
|
+
import { median, roundMs } from "./lib/probes.mjs";
|
|
31
31
|
|
|
32
32
|
const ENDPOINT = process.argv[2] ?? process.env.NF_BROWSER_SOCKET ?? "";
|
|
33
33
|
if (!ENDPOINT.startsWith("/")) {
|
|
@@ -95,6 +95,13 @@ async function scenarioSocket(conf) {
|
|
|
95
95
|
const openSocket = () =>
|
|
96
96
|
new Promise((resolve, reject) => {
|
|
97
97
|
const ws = new WebSocket(url);
|
|
98
|
+
// Une fermeture AVANT l'ouverture est un refus du HANDSHAKE (réponse
|
|
99
|
+
// non-101 : 401, 403, 404…) — rien à attendre, et surtout pas les huit
|
|
100
|
+
// secondes du garde, qui feraient lire un refus net comme un silence.
|
|
101
|
+
let opened = false;
|
|
102
|
+
ws.addEventListener("open", () => {
|
|
103
|
+
opened = true;
|
|
104
|
+
});
|
|
98
105
|
const guard = setTimeout(() => {
|
|
99
106
|
try {
|
|
100
107
|
ws.close();
|
|
@@ -105,6 +112,14 @@ async function scenarioSocket(conf) {
|
|
|
105
112
|
}, conf.timeoutMs);
|
|
106
113
|
ws.addEventListener("close", (ev) => {
|
|
107
114
|
closes.push({ a: a(), code: ev.code, reason: ev.reason });
|
|
115
|
+
if (!opened) {
|
|
116
|
+
clearTimeout(guard);
|
|
117
|
+
const refusal = new Error(
|
|
118
|
+
`accueil jamais reçu : connexion refusée au handshake sur ${url}`,
|
|
119
|
+
);
|
|
120
|
+
refusal.handshake = { afterMs: a(), closeCode: ev.code };
|
|
121
|
+
reject(refusal);
|
|
122
|
+
}
|
|
108
123
|
});
|
|
109
124
|
ws.addEventListener("message", (ev) => {
|
|
110
125
|
let frame;
|
|
@@ -166,7 +181,8 @@ async function scenarioSocket(conf) {
|
|
|
166
181
|
const settle = (value) => {
|
|
167
182
|
if (settled) return;
|
|
168
183
|
settled = true;
|
|
169
|
-
|
|
184
|
+
// Durée BRUTE : l'arrondi se fait côté Node (`roundMs`), où il s'éprouve.
|
|
185
|
+
resolve({ ...value, ms: performance.now() - start });
|
|
170
186
|
};
|
|
171
187
|
const guard = setTimeout(() => {
|
|
172
188
|
pending.delete(id);
|
|
@@ -181,8 +197,21 @@ async function scenarioSocket(conf) {
|
|
|
181
197
|
ws.send(JSON.stringify(frame));
|
|
182
198
|
});
|
|
183
199
|
|
|
184
|
-
|
|
200
|
+
let first;
|
|
201
|
+
try {
|
|
202
|
+
first = await openSocket();
|
|
203
|
+
} catch (e) {
|
|
204
|
+
// Le refus au handshake est une RÉPONSE du serveur, pas une panne de la
|
|
205
|
+
// sonde : il se rend en données (le pilote y joint le statut HTTP que seul
|
|
206
|
+
// le navigateur connaît — la page ne voit qu'un code de fermeture 1006).
|
|
207
|
+
if (!e?.handshake) throw e;
|
|
208
|
+
result.welcome = { verdict: "REFUSÉ AU HANDSHAKE", ...e.handshake };
|
|
209
|
+
result.closes = closes;
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
const { ws, welcome } = first;
|
|
185
213
|
result.welcome = {
|
|
214
|
+
verdict: "OK",
|
|
186
215
|
receivedAfterMs: a(),
|
|
187
216
|
protocol: welcome.protocol ?? null,
|
|
188
217
|
channels: welcome.channels ?? [],
|
|
@@ -330,6 +359,19 @@ async function scenarioSocket(conf) {
|
|
|
330
359
|
const { browser, ctx, page, reuse } = await open();
|
|
331
360
|
await goTo(page, ctx, process.env.NF_BROWSER_PAGE ?? "/", reuse);
|
|
332
361
|
|
|
362
|
+
// Le statut HTTP d'un handshake refusé n'existe que pour le NAVIGATEUR : la
|
|
363
|
+
// page n'en reçoit qu'un code de fermeture 1006. Le pilote l'écoute là où le
|
|
364
|
+
// navigateur le dit — l'erreur du socket et la console.
|
|
365
|
+
const handshakeErrors = [];
|
|
366
|
+
page.on("websocket", (socket) => {
|
|
367
|
+
socket.on("socketerror", (error) => handshakeErrors.push(String(error)));
|
|
368
|
+
});
|
|
369
|
+
page.on("console", (message) => {
|
|
370
|
+
if (message.type() === "error" && message.text().includes("WebSocket")) {
|
|
371
|
+
handshakeErrors.push(message.text());
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
333
375
|
let result;
|
|
334
376
|
try {
|
|
335
377
|
result = await page.evaluate(scenarioSocket, cfg);
|
|
@@ -344,11 +386,35 @@ try {
|
|
|
344
386
|
process.exit(65); // EX_DATAERR
|
|
345
387
|
}
|
|
346
388
|
|
|
389
|
+
if (result.welcome?.verdict === "REFUSÉ AU HANDSHAKE") {
|
|
390
|
+
// Les messages du navigateur arrivent par le protocole de débogage, parfois
|
|
391
|
+
// juste APRÈS la fermeture vue par la page : une courte attente bornée.
|
|
392
|
+
if (handshakeErrors.length === 0) await page.waitForTimeout(500);
|
|
393
|
+
const reason = handshakeErrors[0]?.slice(0, 300) ?? null;
|
|
394
|
+
// Chromium nomme le code (« Unexpected response code: 404 ») SAUF pour un
|
|
395
|
+
// 401 porteur de `WWW-Authenticate` : il le traite en défi d'authentification
|
|
396
|
+
// qu'il ne sait pas satisfaire, et ne dit que cela.
|
|
397
|
+
const status = /HTTP Authentication failed/i.test(reason ?? "")
|
|
398
|
+
? "401"
|
|
399
|
+
: /\b([1-5]\d\d)\b/.exec(reason ?? "")?.[1];
|
|
400
|
+
result.welcome.status = status ? Number(status) : null;
|
|
401
|
+
result.welcome.browserReason = reason;
|
|
402
|
+
console.log(JSON.stringify(result, null, 2));
|
|
403
|
+
console.error(
|
|
404
|
+
`Accueil jamais reçu : le serveur a REFUSÉ le handshake (statut ${result.welcome.status ?? "non communiqué par le navigateur"}).\n` +
|
|
405
|
+
`→ 401/403 : la page ${process.env.NF_BROWSER_PAGE ?? "/"} n'est pas authentifiée, ou le compte n'a pas le droit (NF_BROWSER_USER + NF_BROWSER_LOGIN) ; 404 : chemin du endpoint faux ; 403 sur une page authentifiée : Origin refusé.`,
|
|
406
|
+
);
|
|
407
|
+
await browser.close();
|
|
408
|
+
process.exit(65); // EX_DATAERR — l'accueil n'est pas venu, la cause est nommée
|
|
409
|
+
}
|
|
410
|
+
|
|
347
411
|
// La médiane se calcule ici, avec la fonction que les tests éprouvent — la
|
|
348
412
|
// moyenne serait déplacée par un seul aller-retour aberrant.
|
|
349
413
|
if (Array.isArray(result.latency?.measuresMs)) {
|
|
414
|
+
result.latency.measuresMs = result.latency.measuresMs.map(roundMs);
|
|
350
415
|
result.latency.medianMs = median(result.latency.measuresMs);
|
|
351
416
|
}
|
|
417
|
+
if (typeof result.api?.ms === "number") result.api.ms = roundMs(result.api.ms);
|
|
352
418
|
|
|
353
419
|
console.log(JSON.stringify(result, null, 2));
|
|
354
420
|
await browser.close();
|
|
@@ -13,16 +13,27 @@
|
|
|
13
13
|
* `@env` NF_BROWSER_USER identifiant ; sans lui, aucune authentification n'est tentée
|
|
14
14
|
* `@env` NF_BROWSER_PASSWORD mot de passe associé
|
|
15
15
|
* `@env` NF_BROWSER_UNTIL expression JS évaluée DANS la page ; l'observation s'arrête dès qu'elle est vraie (point d'arrêt applicatif)
|
|
16
|
+
* `@env` NF_BROWSER_CUT coupure réseau `<début>:<durée>` en ms (`4000:3000`), comptée depuis l'ouverture de la page — éprouve la REPRISE d'un flux ; chaque socket dit alors quand il s'est fermé et rouvert
|
|
16
17
|
* `@env` NF_BROWSER_MAXFRAMES nombre de frames WebSocket conservées par sens (défaut 12)
|
|
17
18
|
* `@requires` conteneur du profil `browser` démarré · serveur Nodefony joignable
|
|
18
19
|
* `@output` JSON : sockets et leurs frames (horodatées), requêtes non-2xx, erreurs console, verdict de la condition
|
|
19
20
|
*/
|
|
20
21
|
import { open, goTo } from "./lib/browser.mjs";
|
|
22
|
+
import { parseCut, sendableCloseCode } from "./lib/probes.mjs";
|
|
21
23
|
|
|
22
24
|
const PAGE = process.argv[2] ?? process.env.NF_BROWSER_PAGE ?? "/";
|
|
23
25
|
const DURATION = Number(process.argv[3] ?? 8000);
|
|
24
26
|
const UNTIL = process.env.NF_BROWSER_UNTIL ?? "";
|
|
25
27
|
const MAX = Number(process.env.NF_BROWSER_MAXFRAMES ?? 12);
|
|
28
|
+
const { cut: CUT, invalid: invalidCut } = parseCut(process.env.NF_BROWSER_CUT);
|
|
29
|
+
if (invalidCut) {
|
|
30
|
+
console.error(
|
|
31
|
+
`NF_BROWSER_CUT attend « <début>:<durée> » en ms (ex. 4000:3000), reçu « ${invalidCut} »`,
|
|
32
|
+
);
|
|
33
|
+
process.exit(64);
|
|
34
|
+
}
|
|
35
|
+
// Sockets que la coupure FERME (cf « La coupure réseau » plus bas).
|
|
36
|
+
const isRouted = (url) => !new URL(url).pathname.startsWith("/_vite/");
|
|
26
37
|
|
|
27
38
|
const { browser, ctx, page, reuse } = await open();
|
|
28
39
|
|
|
@@ -39,6 +50,9 @@ const uncaughtErrors = [];
|
|
|
39
50
|
// qu'on ne lit, et une sortie de plusieurs mégaoctets serait illisible — donc
|
|
40
51
|
// inexploitable pour décider. On garde les premières de chaque sens.
|
|
41
52
|
page.on("websocket", (ws) => {
|
|
53
|
+
// Un socket routé pour la coupure est enregistré par la route : l'écouter
|
|
54
|
+
// ici aussi le compterait deux fois.
|
|
55
|
+
if (CUT && isRouted(ws.url())) return;
|
|
42
56
|
const rec = { url: ws.url(), openedAt: at(), sent: [], received: [] };
|
|
43
57
|
sockets.push(rec);
|
|
44
58
|
ws.on("framesent", (f) => {
|
|
@@ -72,8 +86,88 @@ page.on("pageerror", (e) => {
|
|
|
72
86
|
uncaughtErrors.push({ a: at(), text: String(e).slice(0, 200) });
|
|
73
87
|
});
|
|
74
88
|
|
|
89
|
+
// ── La coupure réseau : éprouver une REPRISE ────────────────────────────────
|
|
90
|
+
// `setOffline` seul ne suffit PAS : Chromium y coupe le HTTP mais laisse les
|
|
91
|
+
// WebSockets déjà ouverts vivre — mesuré, aucun `close` n'est émis, et l'on
|
|
92
|
+
// « éprouverait » une reprise sans qu'aucune connexion ait été perdue. Les
|
|
93
|
+
// sockets passent donc par `routeWebSocket` : à la coupure, la sonde ferme
|
|
94
|
+
// chaque connexion des deux côtés — 1006 vers la page, ce que voit un client
|
|
95
|
+
// qui perd le réseau ; 1000 vers le serveur, qui libère la connexion au lieu de
|
|
96
|
+
// la garder à demi ouverte — et refuse toute réouverture jusqu'au
|
|
97
|
+
// rétablissement.
|
|
98
|
+
// Le relais Vite (`/_vite/`) est épargné : son client de rechargement à chaud
|
|
99
|
+
// RECHARGE la page quand il retrouve le serveur, et la reprise observée serait
|
|
100
|
+
// celle d'une page neuve, pas celle de l'application.
|
|
101
|
+
let cut = null;
|
|
102
|
+
let cutActive = false;
|
|
103
|
+
const cutTimers = [];
|
|
104
|
+
const routed = new Set();
|
|
105
|
+
// Le côté SERVEUR d'une route est un vrai WebSocket de navigateur : son code
|
|
106
|
+
// de fermeture passe par `sendableCloseCode` (cf son TSDoc).
|
|
107
|
+
if (CUT) {
|
|
108
|
+
cut = { requested: CUT, refused: 0 };
|
|
109
|
+
await ctx.routeWebSocket(isRouted, (route) => {
|
|
110
|
+
if (cutActive) {
|
|
111
|
+
cut.refused += 1;
|
|
112
|
+
route.close({ code: 1006, reason: "coupure réseau (sonde)" });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const server = route.connectToServer();
|
|
116
|
+
const rec = { url: route.url(), openedAt: at(), sent: [], received: [] };
|
|
117
|
+
sockets.push(rec);
|
|
118
|
+
const pair = { route, server, rec };
|
|
119
|
+
routed.add(pair);
|
|
120
|
+
// Poser `onMessage` coupe le relais automatique : on relaie à la main.
|
|
121
|
+
route.onMessage((m) => {
|
|
122
|
+
if (rec.sent.length < MAX)
|
|
123
|
+
rec.sent.push({ a: at(), payload: String(m).slice(0, 160) });
|
|
124
|
+
server.send(m);
|
|
125
|
+
});
|
|
126
|
+
server.onMessage((m) => {
|
|
127
|
+
if (rec.received.length < MAX)
|
|
128
|
+
rec.received.push({ a: at(), payload: String(m).slice(0, 160) });
|
|
129
|
+
route.send(m);
|
|
130
|
+
});
|
|
131
|
+
const closed = (code) => {
|
|
132
|
+
rec.closedAt ??= at();
|
|
133
|
+
rec.closeCode ??= code;
|
|
134
|
+
routed.delete(pair);
|
|
135
|
+
};
|
|
136
|
+
route.onClose((code, reason) => {
|
|
137
|
+
closed(code);
|
|
138
|
+
server.close({ code: sendableCloseCode(code), reason });
|
|
139
|
+
});
|
|
140
|
+
server.onClose((code, reason) => {
|
|
141
|
+
closed(code);
|
|
142
|
+
route.close({ code, reason });
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
75
147
|
await goTo(page, ctx, PAGE, reuse);
|
|
76
148
|
|
|
149
|
+
if (CUT) {
|
|
150
|
+
cutTimers.push(
|
|
151
|
+
setTimeout(() => {
|
|
152
|
+
cut.offlineAt = at();
|
|
153
|
+
cutActive = true;
|
|
154
|
+
ctx.setOffline(true).catch((e) => (cut.error = String(e)));
|
|
155
|
+
for (const { route, server, rec } of routed) {
|
|
156
|
+
rec.closedAt ??= at();
|
|
157
|
+
rec.closeCode ??= 1006;
|
|
158
|
+
server.close({ code: 1000, reason: "coupure réseau (sonde)" });
|
|
159
|
+
route.close({ code: 1006, reason: "coupure réseau (sonde)" });
|
|
160
|
+
}
|
|
161
|
+
routed.clear();
|
|
162
|
+
}, CUT.at),
|
|
163
|
+
setTimeout(() => {
|
|
164
|
+
cut.onlineAt = at();
|
|
165
|
+
cutActive = false;
|
|
166
|
+
ctx.setOffline(false).catch((e) => (cut.error = String(e)));
|
|
167
|
+
}, CUT.at + CUT.duration),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
77
171
|
// ── Le « point d'arrêt » : une CONDITION, pas une ligne de code ──────────────
|
|
78
172
|
// `waitForFunction` réévalue l'expression dans la page à chaque animation frame.
|
|
79
173
|
// C'est ce qui remplace utilement un breakpoint dans un pilotage automatisé :
|
|
@@ -100,12 +194,19 @@ if (UNTIL) {
|
|
|
100
194
|
await page.waitForTimeout(DURATION);
|
|
101
195
|
}
|
|
102
196
|
|
|
197
|
+
for (const t of cutTimers) clearTimeout(t);
|
|
198
|
+
if (cut && cut.onlineAt === undefined) {
|
|
199
|
+
cutActive = false;
|
|
200
|
+
await ctx.setOffline(false);
|
|
201
|
+
}
|
|
202
|
+
|
|
103
203
|
console.log(
|
|
104
204
|
JSON.stringify(
|
|
105
205
|
{
|
|
106
206
|
url: page.url(),
|
|
107
207
|
observeForMs: at(),
|
|
108
208
|
verdict,
|
|
209
|
+
cut,
|
|
109
210
|
// Les totaux sont posés sur l'objet accumulé plutôt que recomposés par
|
|
110
211
|
// étalement dans un `map` (règle `no-map-spread` : allocation inutile).
|
|
111
212
|
sockets: sockets.map((s) =>
|