@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.
Files changed (54) hide show
  1. package/README.md +146 -4
  2. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
  4. package/dist/index.js +24 -11
  5. package/dist/nodefony/config/config.js +16 -137
  6. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  7. package/dist/nodefony/controllers/DevkitController.js +7 -14
  8. package/dist/nodefony/controllers/McpController.js +25 -226
  9. package/dist/nodefony/controllers/ProfileSessionController.js +47 -0
  10. package/dist/nodefony/mcp/observe.js +235 -0
  11. package/dist/nodefony/mcp/profileReport.js +242 -0
  12. package/dist/nodefony/mcp/profileRoute.js +521 -0
  13. package/dist/nodefony/mcp/shotClient.js +84 -0
  14. package/dist/nodefony/mcp/shutdown.js +44 -0
  15. package/dist/nodefony/mcp/tools.js +696 -0
  16. package/dist/nodefony/service/DevkitService.js +93 -29
  17. package/dist/nodefony/src/errors/DevkitError.js +1 -2
  18. package/dist/nodefony/src/profileSession.js +14 -0
  19. package/dist/types/index.d.ts +15 -1
  20. package/dist/types/nodefony/config/config.d.ts +1 -1
  21. package/dist/types/nodefony/controllers/McpController.d.ts +11 -26
  22. package/dist/types/nodefony/controllers/ProfileSessionController.d.ts +31 -0
  23. package/dist/types/nodefony/interfaces/IDevkitService.d.ts +22 -1
  24. package/dist/types/nodefony/mcp/observe.d.ts +83 -0
  25. package/dist/types/nodefony/mcp/profileReport.d.ts +138 -0
  26. package/dist/types/nodefony/mcp/profileRoute.d.ts +87 -0
  27. package/dist/types/nodefony/mcp/shotClient.d.ts +53 -0
  28. package/dist/types/nodefony/mcp/shutdown.d.ts +25 -0
  29. package/dist/types/nodefony/mcp/tools.d.ts +109 -0
  30. package/dist/types/nodefony/service/DevkitService.d.ts +41 -4
  31. package/dist/types/nodefony/src/profileSession.d.ts +12 -0
  32. package/docs/index.md +160 -11
  33. package/package.json +16 -11
  34. package/skills/nodefony-add-crud/SKILL.md +5 -0
  35. package/skills/nodefony-add-service/SKILL.md +3 -3
  36. package/skills/nodefony-authentication/SKILL.md +5 -5
  37. package/skills/nodefony-browser/SKILL.md +20 -0
  38. package/skills/nodefony-browser/references/socket.md +13 -4
  39. package/skills/nodefony-browser/scripts/inspect.mjs +1 -1
  40. package/skills/nodefony-browser/scripts/lib/probes.mjs +53 -0
  41. package/skills/nodefony-browser/scripts/socket.mjs +70 -4
  42. package/skills/nodefony-browser/scripts/watch.mjs +101 -0
  43. package/skills/nodefony-dev/SKILL.md +31 -1
  44. package/skills/nodefony-dev/scripts/docs.mjs +3 -3
  45. package/skills/nodefony-devops/SKILL.md +85 -64
  46. package/skills/nodefony-devops/references/dimensionnement.md +122 -0
  47. package/skills/nodefony-devops/references/frontal.md +277 -47
  48. package/skills/nodefony-devops/references/kubernetes.md +41 -14
  49. package/skills/nodefony-devops/references/secrets.md +13 -2
  50. package/skills/nodefony-devops/references/variables.md +14 -12
  51. package/skills/nodefony-protect-route/SKILL.md +6 -4
  52. package/skills/nodefony-security-review/scripts/review-diff.mjs +4 -4
  53. package/skills/nodefony-troubleshoot/SKILL.md +86 -12
  54. 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
- (`@inject` au constructeur ou `container.get` à l'usage), le choix de sa durée de vie
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 : @inject("BillingService")
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
- | `@inject("X")` au constructeur | **par défaut** — la dépendance est déclarée, l'ordre de démarrage la respecte |
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 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.
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 = null;
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
- resolve({ ...value, ms: Math.round(performance.now() - start) });
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
- const { ws, welcome } = await openSocket();
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. Tout le reste (déclarer un outil métier, le réserver à des scopes, l'autorisation OAuth) :
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 {