@nodefony/devkit 10.0.0-beta.3 → 10.0.0-beta.4
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 +5 -0
- 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/scripts/inspect.mjs +1 -1
- 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`
|
|
@@ -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
|
|
|
@@ -789,7 +789,7 @@ if (active.has("reseau")) {
|
|
|
789
789
|
const slowThreshold = Number(process.env.NF_BROWSER_SEUIL_LENT ?? 1000);
|
|
790
790
|
const items = await Promise.all(
|
|
791
791
|
finishedRequests.map(async (rq) => {
|
|
792
|
-
let bytes
|
|
792
|
+
let bytes;
|
|
793
793
|
try {
|
|
794
794
|
// `sizes()` rend la taille RÉELLEMENT transférée ; l'en-tête
|
|
795
795
|
// 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) =>
|
|
@@ -128,6 +128,11 @@ Elle répond **toujours** — application pas encore construite, terminal sans a
|
|
|
128
128
|
d'environnement : elle ne lit que des fichiers. Elle le DIT alors (« modules installés », pas
|
|
129
129
|
« chargés ») et renvoie à `inspect modules` pour ce qui est vraiment monté.
|
|
130
130
|
|
|
131
|
+
**`rg` ou outil MCP ?** Question étroite, format connu (un motif, un fichier) → `rg`, bien moins
|
|
132
|
+
cher. Question ouverte sur l'application qui TOURNE (démarrage sain ? quelle route casse ?) →
|
|
133
|
+
`nodefony_check` (= `nodefony doctor`), `nodefony_observe` : un appel au lieu de plusieurs `rg`,
|
|
134
|
+
et des faits qu'aucun fichier ne porte.
|
|
135
|
+
|
|
131
136
|
```bash
|
|
132
137
|
npx nodefony inspect routes --json # routes réelles (chemin MONTÉ, méthodes, controller)
|
|
133
138
|
npx nodefony inspect services --json # services enregistrés, et le module qui les porte
|
|
@@ -170,9 +175,26 @@ tube, puis remets ton filtre.
|
|
|
170
175
|
(`npx nodefony ai:mcp` écrit `.mcp.json`) — mêmes réponses, et tes propres modules peuvent publier
|
|
171
176
|
les leurs. La porte est une ROUTE : elle n'existe que serveur démarré, et un client qui la trouve
|
|
172
177
|
éteinte la marque en échec pour toute sa session — démarre l'application D'ABORD, ta session
|
|
173
|
-
ENSUITE.
|
|
178
|
+
ENSUITE. Aucun serveur ne peut tourner (CI, agent dans un runner) ? Ton client lance lui-même
|
|
179
|
+
`npx nodefony ai:stdio` et lui parle sur l'entrée et la sortie standard : mêmes outils, mais le
|
|
180
|
+
code du démarrage, sans rechargement à chaud ni trafic réel à mesurer. Tout le reste (déclarer un outil métier, le réserver à des scopes, l'autorisation OAuth) :
|
|
174
181
|
`node_modules/@nodefony/devkit/docs/index.md`.
|
|
175
182
|
|
|
183
|
+
**Ta tâche est d'OFFRIR les outils de l'application à un agent** (« expose ceci par MCP ») ? Ce
|
|
184
|
+
n'est pas la porte ci-dessus, qui sert l'outillage en développement : c'est une porte de
|
|
185
|
+
l'APPLICATION, et elle se génère — n'écris pas le protocole à la main :
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
npx nodefony create controller mcp --kind mcp # porte locale : la machine seulement
|
|
189
|
+
npx nodefony create controller mcp --kind mcp --expose oauth # production : jeton exigé (401 sans)
|
|
190
|
+
npx nodefony create controller mcp --kind mcp --expose mixte # outils publics sans jeton, les autres réservés à un scope
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Elle naît avec deux outils d'exemple (`nodefony/mcp/mcp-tools.ts`), sa définition
|
|
194
|
+
(`defineMcpGate`, `nodefony/mcp/mcp-gate.ts`) et son test. Ajoute tes outils à la LISTE de la
|
|
195
|
+
porte : elle publie ce qu'elle choisit, rien d'autre. Mode d'emploi :
|
|
196
|
+
`node_modules/@nodefony/ai/docs/mcp.md` § « Monter une porte ».
|
|
197
|
+
|
|
176
198
|
### L'environnement : ne devine JAMAIS, demande
|
|
177
199
|
|
|
178
200
|
```bash
|
|
@@ -344,6 +366,14 @@ ont leur skill, qui part du symptôme : **`nodefony-troubleshoot`**.
|
|
|
344
366
|
huit, `redis` quatre (`session`, `tokens`, `passkeys`, `idempotency`) : un cache ne garde ni
|
|
345
367
|
comptes ni journal d'audit. Ne promets jamais une parité qui n'existe pas — lis ce champ, et
|
|
346
368
|
`npx nodefony inspect stores` dit où atterrit chaque donnée.
|
|
369
|
+
- **L'entrée d'une route se DÉCLARE — ne la valide pas à la main.** L'option `schema` d'un
|
|
370
|
+
décorateur de route (`@Post("/posts", { schema: { params, query, body, headers } })`, un schéma Zod
|
|
371
|
+
par partie) fait valider l'entrée AVANT l'action — **422** qui nomme chaque champ et sa partie, sur
|
|
372
|
+
HTTP comme par la socket (`api.request`) — et l'action la reçoit typée en UN argument,
|
|
373
|
+
`input: RouteInput<…>`. Une action à schéma ne prend **aucun** décorateur de paramètre (`@Body`,
|
|
374
|
+
`@Query`…) : le mélange est refusé au démarrage, qui nomme la route. L'identité se lit par
|
|
375
|
+
`this.user`. Un corps binaire arrive en octets (`Uint8Array`), un `text/*` en chaîne : un même
|
|
376
|
+
`z.instanceof(Uint8Array)` vaut pour les deux portes. Doc : `node_modules/@nodefony/framework/docs/decorateurs.md`.
|
|
347
377
|
- **Les violations de contrainte sont DÉJÀ traduites en HTTP — ne les rattrape pas.** Un doublon
|
|
348
378
|
sur une colonne unique ressort en **409**, une donnée qui viole le schéma Zod en **422**, chacun
|
|
349
379
|
avec son corps JSON : le rendu d'erreur lit le code du pilote (`23505`, `ER_DUP_ENTRY`,
|
|
@@ -136,7 +136,7 @@ export function docDirectories(root) {
|
|
|
136
136
|
};
|
|
137
137
|
push(path.join(modules, "nodefony"));
|
|
138
138
|
const scope = path.join(modules, "@nodefony");
|
|
139
|
-
let entries
|
|
139
|
+
let entries;
|
|
140
140
|
try {
|
|
141
141
|
entries = readdirSync(scope);
|
|
142
142
|
} catch {
|
|
@@ -155,7 +155,7 @@ export function docDirectories(root) {
|
|
|
155
155
|
function markdownFiles(dir) {
|
|
156
156
|
const out = [];
|
|
157
157
|
const walk = (current) => {
|
|
158
|
-
let entries
|
|
158
|
+
let entries;
|
|
159
159
|
try {
|
|
160
160
|
entries = readdirSync(current, { withFileTypes: true });
|
|
161
161
|
} catch {
|
|
@@ -300,7 +300,7 @@ export function index(root) {
|
|
|
300
300
|
const pages = [];
|
|
301
301
|
for (const dir of docDirectories(root)) {
|
|
302
302
|
for (const file of markdownFiles(dir)) {
|
|
303
|
-
let source
|
|
303
|
+
let source;
|
|
304
304
|
try {
|
|
305
305
|
source = readFileSync(file, "utf8");
|
|
306
306
|
} catch {
|