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

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 (38) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
  3. package/dist/index.js +3 -3
  4. package/dist/nodefony/controllers/DevkitController.js +1 -1
  5. package/dist/nodefony/controllers/McpController.js +2 -2
  6. package/dist/nodefony/controllers/ProfileSessionController.js +1 -1
  7. package/dist/nodefony/mcp/profileRoute.js +28 -9
  8. package/dist/nodefony/service/DevkitService.js +6 -6
  9. package/docs/index.md +1 -1
  10. package/package.json +13 -13
  11. package/skills/nodefony-add-crud/SKILL.md +38 -23
  12. package/skills/nodefony-add-grpc-service/SKILL.md +179 -0
  13. package/skills/nodefony-add-grpc-service/scripts/call.mjs +218 -0
  14. package/skills/nodefony-add-realtime-binary/SKILL.md +301 -0
  15. package/skills/nodefony-add-realtime-binary/references/messages.md +20 -0
  16. package/skills/nodefony-add-realtime-binary/references/mesures.md +59 -0
  17. package/skills/nodefony-add-realtime-channel/SKILL.md +452 -105
  18. package/skills/nodefony-add-realtime-channel/references/contrat.md +22 -0
  19. package/skills/nodefony-add-realtime-channel/references/production.md +140 -0
  20. package/skills/nodefony-add-realtime-presence/SKILL.md +328 -0
  21. package/skills/nodefony-authentication/SKILL.md +2 -2
  22. package/skills/nodefony-browser/SKILL.md +37 -2
  23. package/skills/nodefony-browser/references/sondes.md +19 -0
  24. package/skills/nodefony-browser/scripts/audit.mjs +17 -2
  25. package/skills/nodefony-browser/scripts/inspect.mjs +108 -87
  26. package/skills/nodefony-browser/scripts/lib/actions.mjs +164 -0
  27. package/skills/nodefony-browser/scripts/lib/browser.mjs +67 -11
  28. package/skills/nodefony-browser/scripts/lib/probes.mjs +108 -3
  29. package/skills/nodefony-browser/scripts/watch.mjs +27 -11
  30. package/skills/nodefony-dev/SKILL.md +31 -15
  31. package/skills/nodefony-dev/references/rapport-de-build.md +19 -0
  32. package/skills/nodefony-devops/SKILL.md +28 -21
  33. package/skills/nodefony-devops/references/dimensionnement.md +1 -1
  34. package/skills/nodefony-devops/references/frontal.md +5 -2
  35. package/skills/nodefony-devops/references/image.md +23 -15
  36. package/skills/nodefony-migrate-schema/SKILL.md +110 -20
  37. package/skills/nodefony-migrate-schema/references/verdicts.md +23 -20
  38. package/skills/nodefony-protect-route/SKILL.md +27 -32
@@ -27,6 +27,8 @@ export const FAMILIES = Object.freeze({
27
27
  stockage: "cookies (attributs, jamais les valeurs) et Web Storage",
28
28
  responsive: "débordement horizontal à plusieurs largeurs d'écran",
29
29
  pli: "proportions — l'action principale visible sans défiler, la place du bloc principal dans l'écran",
30
+ notifications:
31
+ "notifications du navigateur — permission accordée, chaque `new Notification()` relevée (titre, corps, étiquette)",
30
32
  });
31
33
 
32
34
  /**
@@ -145,7 +147,7 @@ export function parseProbes(raw) {
145
147
  }
146
148
 
147
149
  /** Verbes qui prennent une VALEUR après la cible. Les autres n'en ont aucune. */
148
- const VERBS_WITH_VALUE = new Set(["saisir", "choisir"]);
150
+ const VERBS_WITH_VALUE = new Set(["saisir", "choisir", "touche", "deposer"]);
149
151
 
150
152
  /** Verbes reconnus d'une action, le premier étant celui par défaut. */
151
153
  export const ACTION_VERBS = [
@@ -155,12 +157,30 @@ export const ACTION_VERBS = [
155
157
  "survol",
156
158
  "saisir",
157
159
  "choisir",
160
+ "deposer",
158
161
  "touche",
159
162
  "voir",
160
163
  "defiler",
164
+ "molette",
161
165
  "attendre",
166
+ "connecter",
162
167
  ];
163
168
 
169
+ /**
170
+ * La séquence se connecte-t-elle EN COURS de route (`connecter:`) ?
171
+ *
172
+ * Alors la page doit s'ouvrir ANONYME — ni état d'authentification repris, ni
173
+ * connexion préalable : c'est précisément le passage « sans session » →
174
+ * « avec session », sans recharger, qu'on veut observer. Se connecter avant
175
+ * aurait rendu l'action sans objet, et la mesure verte pour la mauvaise raison.
176
+ *
177
+ * @param actions - la séquence analysée par `parseActions`.
178
+ * @returns `true` si une action `connecter` y figure.
179
+ */
180
+ export function signsInMidway(actions) {
181
+ return actions.some((a) => a.verb === "connecter");
182
+ }
183
+
164
184
  /**
165
185
  * Analyse une séquence d'actions (`NF_BROWSER_ACTIONS`).
166
186
  *
@@ -174,8 +194,9 @@ export const ACTION_VERBS = [
174
194
  * s'arrêtait en annonçant un élément introuvable, ce qui envoyait chercher un
175
195
  * défaut dans la page. D'où deux règles :
176
196
  *
177
- * 1. seuls les verbes qui PRENNENT une valeur (`saisir`, `choisir`) découpent
178
- * sur `=` ;
197
+ * 1. seuls les verbes qui PRENNENT une valeur (`saisir`, `choisir`, `touche`,
198
+ * `deposer`)
199
+ * découpent sur `=` ;
179
200
  * pour tous les autres, la cible est le reste entier ;
180
201
  * 2. même pour ceux-là, la coupure ignore les `=` situés à l'intérieur de
181
202
  * crochets — `saisir:input[name=q]=bonjour` saisit bien « bonjour » dans
@@ -207,6 +228,11 @@ export function parseActions(raw) {
207
228
  else if (c === "]") depth = Math.max(0, depth - 1);
208
229
  else if (c === "=" && depth === 0) cut = i;
209
230
  }
231
+ // `touche:Enter` SANS `=` : le reste est la TOUCHE, frappée sur
232
+ // l'élément focalisé — pas un texte à chercher dans la page.
233
+ if (cut === -1 && verb === "touche") {
234
+ return { verb, target: "", value: rest.trim() };
235
+ }
210
236
  return cut === -1
211
237
  ? { verb, target: rest.trim(), value: "" }
212
238
  : {
@@ -240,6 +266,47 @@ export function parseColorScheme(raw) {
240
266
  return { schema: null, invalid: v };
241
267
  }
242
268
 
269
+ /**
270
+ * Lit la préférence de mouvement à émuler (`NF_BROWSER_REDUCED_MOTION`) — les
271
+ * valeurs de la média query standard `prefers-reduced-motion`.
272
+ *
273
+ * Pourquoi : une page qui fait apparaître ses sections au défilement (fondu lié
274
+ * à `animation-timeline: view()`) laisse hors de la vue des textes à demi
275
+ * transparents. `axe` mesure alors un contraste que personne ne lira jamais à
276
+ * l'écran — des rouges qui envoient chercher un défaut de palette. `reduce`
277
+ * coupe ces animations quand la page respecte la préférence, et la mesure
278
+ * porte alors sur le texte au repos.
279
+ *
280
+ * @param {string|undefined} raw - la valeur brute de la variable.
281
+ * @returns {{ motion: "reduce"|"no-preference"|null, invalid: string|null }}
282
+ */
283
+ export function parseReducedMotion(raw) {
284
+ const v = (raw ?? "").trim().toLowerCase();
285
+ if (!v) return { motion: null, invalid: null };
286
+ if (v === "reduce" || v === "no-preference")
287
+ return { motion: v, invalid: null };
288
+ return { motion: null, invalid: v };
289
+ }
290
+
291
+ /**
292
+ * Rend une expression JS fournie par l'utilisateur ÉVALUABLE dans la page —
293
+ * une forme fonction (`() => x`, `function`, `async`) y est APPELÉE.
294
+ *
295
+ * Le piège est réel : `page.evaluate` et `waitForFunction` évaluent une chaîne
296
+ * comme une EXPRESSION. Donner « () => x » y définit une fonction sans jamais
297
+ * l'appeler : l'objet fonction est truthy (une attente réussit TOUJOURS) et ne
298
+ * se sérialise pas (une lecture rend `undefined`). Faux vert vécu, découvert
299
+ * seulement en éprouvant le sens négatif.
300
+ *
301
+ * @param {string|undefined} raw - expression brute (`NF_BROWSER_UNTIL`, `NF_BROWSER_EVAL`).
302
+ * @returns {string} l'expression à évaluer, vide si rien n'est demandé.
303
+ */
304
+ export function pageExpression(raw) {
305
+ const v = (raw ?? "").trim();
306
+ if (!v) return "";
307
+ return /^(\(|function\b|async\b)/.test(v) ? `(${v})()` : v;
308
+ }
309
+
243
310
  /**
244
311
  * Analyse une coupure réseau demandée (`NF_BROWSER_CUT`), au format
245
312
  * `<début>:<durée>` en millisecondes — `4000:3000` coupe le réseau 4 s après
@@ -709,3 +776,41 @@ export function browserOrder(explicit) {
709
776
  if (v) return [v];
710
777
  return ["chromium", "chrome", "msedge"];
711
778
  }
779
+
780
+ /**
781
+ * Traduit un nom de navigateur en MOTEUR du pilote et, pour Chromium, en canal.
782
+ *
783
+ * Un comportement que dicte une norme — la politique de sécurité du contenu, le
784
+ * rapport de violation, un cookie `SameSite` — diverge d'un moteur à l'autre :
785
+ * le constater sous Chromium seul ne dit rien de Firefox ni de Safari. `firefox`
786
+ * et `webkit` désignent donc les moteurs que le pilote installe lui-même
787
+ * (`npx playwright install firefox`) ; tout autre nom reste un canal de
788
+ * Chromium (`chrome`, `msedge`, `chrome-beta`…), comme avant.
789
+ *
790
+ * @param {string} name - un nom rendu par `browserOrder`.
791
+ * @returns {{ engine: "chromium"|"firefox"|"webkit", channel: string|undefined }}
792
+ * le moteur à lancer, et le canal Chromium (absent pour les deux autres).
793
+ */
794
+ export function browserEngine(name) {
795
+ const v = name.trim().toLowerCase();
796
+ if (v === "firefox" || v === "webkit")
797
+ return { engine: v, channel: undefined };
798
+ return { engine: "chromium", channel: name.trim() };
799
+ }
800
+
801
+ /**
802
+ * Le nom d'un fichier produit par une sonde : préfixe, instant à la seconde,
803
+ * puis le numéro du processus — deux sondes lancées dans la même seconde
804
+ * (mesures en parallèle) écrivaient le MÊME fichier, et la seconde capture
805
+ * effaçait la première sans un mot.
806
+ *
807
+ * @param {string} prefix - ce que montre le fichier (`demo-chat`, `lighthouse-…`)
808
+ * @param {Date} at - l'instant de la mesure
809
+ * @param {number} pid - le processus qui écrit
810
+ * @param {string} ext - l'extension, sans point
811
+ * @returns {string}
812
+ */
813
+ export function stampedName(prefix, at, pid, ext) {
814
+ const stamp = at.toISOString().replace(/[:.]/gu, "-").slice(0, 19);
815
+ return `${prefix}-${stamp}-${pid}.${ext}`;
816
+ }
@@ -15,16 +15,25 @@
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
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
17
17
  * `@env` NF_BROWSER_MAXFRAMES nombre de frames WebSocket conservées par sens (défaut 12)
18
+ * `@env` NF_BROWSER_ACTIONS séquence d'interactions jouée UNE FOIS la page ouverte, PENDANT l'observation — même grammaire que `inspect.mjs` (`clic`, `saisir:Nom=x`, `attendre`…). C'est ce qui fait voir la trame qu'un GESTE envoie : un bouton qui écrit par la socket, un formulaire soumis
18
19
  * `@requires` conteneur du profil `browser` démarré · serveur Nodefony joignable
19
20
  * `@output` JSON : sockets et leurs frames (horodatées), requêtes non-2xx, erreurs console, verdict de la condition
20
21
  */
21
22
  import { open, goTo } from "./lib/browser.mjs";
22
- import { parseCut, sendableCloseCode } from "./lib/probes.mjs";
23
+ import {
24
+ parseActions,
25
+ parseCut,
26
+ pageExpression,
27
+ sendableCloseCode,
28
+ signsInMidway,
29
+ } from "./lib/probes.mjs";
30
+ import { playActions } from "./lib/actions.mjs";
23
31
 
24
32
  const PAGE = process.argv[2] ?? process.env.NF_BROWSER_PAGE ?? "/";
25
33
  const DURATION = Number(process.argv[3] ?? 8000);
26
34
  const UNTIL = process.env.NF_BROWSER_UNTIL ?? "";
27
35
  const MAX = Number(process.env.NF_BROWSER_MAXFRAMES ?? 12);
36
+ const ACTIONS = parseActions(process.env.NF_BROWSER_ACTIONS);
28
37
  const { cut: CUT, invalid: invalidCut } = parseCut(process.env.NF_BROWSER_CUT);
29
38
  if (invalidCut) {
30
39
  console.error(
@@ -35,7 +44,8 @@ if (invalidCut) {
35
44
  // Sockets que la coupure FERME (cf « La coupure réseau » plus bas).
36
45
  const isRouted = (url) => !new URL(url).pathname.startsWith("/_vite/");
37
46
 
38
- const { browser, ctx, page, reuse } = await open();
47
+ const ANONYMOUS = signsInMidway(ACTIONS);
48
+ const { browser, ctx, page, reuse } = await open({ anonymous: ANONYMOUS });
39
49
 
40
50
  const t0 = Date.now();
41
51
  const at = () => Date.now() - t0;
@@ -144,7 +154,7 @@ if (CUT) {
144
154
  });
145
155
  }
146
156
 
147
- await goTo(page, ctx, PAGE, reuse);
157
+ await goTo(page, ctx, PAGE, reuse, { anonymous: ANONYMOUS });
148
158
 
149
159
  if (CUT) {
150
160
  cutTimers.push(
@@ -168,6 +178,17 @@ if (CUT) {
168
178
  );
169
179
  }
170
180
 
181
+ // ── Les gestes : joués PENDANT l'observation ───────────────────────────────
182
+ // Les écouteurs sont déjà posés : la trame qu'un clic envoie est enregistrée
183
+ // comme les autres. Une cible introuvable arrête la sonde (code 65), comme
184
+ // `inspect.mjs` — observer un geste qui n'a pas eu lieu ne prouve rien.
185
+ const failed = await playActions(page, ACTIONS);
186
+ if (failed !== null) {
187
+ console.error(failed.message);
188
+ await browser.close();
189
+ process.exit(failed.code);
190
+ }
191
+
171
192
  // ── Le « point d'arrêt » : une CONDITION, pas une ligne de code ──────────────
172
193
  // `waitForFunction` réévalue l'expression dans la page à chaque animation frame.
173
194
  // C'est ce qui remplace utilement un breakpoint dans un pilotage automatisé :
@@ -176,14 +197,9 @@ if (CUT) {
176
197
  // observe simplement pendant la durée demandée.
177
198
  let verdict = "durée écoulée";
178
199
  if (UNTIL) {
179
- // ⚠️ Une chaîne passée à `waitForFunction` est évaluée comme une EXPRESSION.
180
- // Donner « () => x » y définit une fonction sans jamais l'appeler : l'objet
181
- // fonction est truthy, donc l'attente réussit TOUJOURS — y compris sur une
182
- // condition impossible. Faux vert vécu, découvert seulement en éprouvant le
183
- // sens négatif. On invoque donc explicitement les formes fonction.
184
- const expr = /^\s*(\(|function\b|async\b)/.test(UNTIL)
185
- ? `(${UNTIL})()`
186
- : UNTIL;
200
+ // Une forme fonction est APPELÉE (cf `pageExpression`) : passée telle quelle,
201
+ // l'attente réussirait TOUJOURS, condition impossible comprise.
202
+ const expr = pageExpression(UNTIL);
187
203
  try {
188
204
  await page.waitForFunction(expr, null, { timeout: DURATION });
189
205
  verdict = `condition VRAIE après ${at()} ms`;
@@ -20,8 +20,8 @@ metadata:
20
20
  # nodefony-dev — développer dans cette application sans rien inventer
21
21
 
22
22
  > **Maintenance** : vérité courante, jamais un journal. Éditer en place ; l'historique vit dans
23
- > `git log`. Ce fichier n'a **pas** de `references/` — et c'est une décision mesurée, pas un
24
- > oubli : sur 1020 transcripts d'agents lâchés dans une application générée, une page de
23
+ > `git log`. Ce fichier n'a qu'UNE page en `references/` (`rapport-de-build.md`, un rapport qu'on
24
+ > lit quand le build le désigne) — et c'est une décision mesurée : sur 1020 transcripts d'agents lâchés dans une application générée, une page de
25
25
  > `references/` a été ouverte **0 fois**, quand un script cité comme commande l'a été dans 80 %
26
26
  > des cas. Ce qui doit atteindre un agent s'EXÉCUTE ou vit dans ce corps ; ce qu'on déporterait
27
27
  > « pour alléger » n'atteindrait plus personne.
@@ -41,18 +41,22 @@ yeux. Ils ont simplement continué sans elle.
41
41
  Ces skills sont installés avec le framework et se chargent par leur nom. Chacun porte les pièges
42
42
  de son geste, et ceux-là ne se devinent pas — ils se paient.
43
43
 
44
- | Ce que tu t'apprêtes à faire | Le skill |
45
- | -------------------------------------------------------------------- | ------------------------------- |
46
- | Une ressource : table, validation, service, REST et WebSocket | `nodefony-add-crud` |
47
- | Une logique métier réutilisable, hors de tout controller | `nodefony-add-service` |
48
- | Un flux temps réel (WebSocket ou SSE), un canal, un abonnement | `nodefony-add-realtime-channel` |
49
- | Se connecter : /login, second facteur, retour après connexion, jeton | `nodefony-authentication` |
50
- | Réserver une route ou une page à des rôles, ouvrir à un partenaire | `nodefony-protect-route` |
51
- | Changer une entité déjà en base, ou déployer un schéma changé | `nodefony-migrate-schema` |
52
- | Conclure quoi que ce soit d'un ÉCRAN — affichage, contraste, console | `nodefony-browser` |
53
- | Quelque chose qui marchait ne marche plus : démarrage, 404, journal | `nodefony-troubleshoot` |
54
- | Relire la sécurité de ton diff AVANT de commiter | `nodefony-security-review` |
55
- | Monter Nodefony de version, ou une montée qui a laissé des rouges | `nodefony-upgrade` |
44
+ | Ce que tu t'apprêtes à faire | Le skill |
45
+ | ------------------------------------------------------------------------- | -------------------------------- |
46
+ | Une ressource : table, validation, service, REST et WebSocket | `nodefony-add-crud` |
47
+ | Une logique métier réutilisable, hors de tout controller | `nodefony-add-service` |
48
+ | Du temps réel : chat, canal, notification, jeu, sondes, tableau de bord | `nodefony-add-realtime-channel` |
49
+ | Afficher qui est en ligne, combien de présents, « en train d'écrire » | `nodefony-add-realtime-presence` |
50
+ | Des octets en temps réel : image, audio, mesures binaires, CBOR | `nodefony-add-realtime-binary` |
51
+ | Exposer ou appeler un service gRPC | `nodefony-add-grpc-service` |
52
+ | Se connecter : /login, second facteur, retour après connexion, jeton | `nodefony-authentication` |
53
+ | Réserver une route ou une page à des rôles, ouvrir à un partenaire | `nodefony-protect-route` |
54
+ | Changer une entité déjà en base, ou déployer un schéma changé | `nodefony-migrate-schema` |
55
+ | Mettre en production : image, conteneur, variables, santé, plusieurs pods | `nodefony-devops` |
56
+ | Conclure quoi que ce soit d'un ÉCRAN — affichage, contraste, console | `nodefony-browser` |
57
+ | Quelque chose qui marchait ne marche plus : démarrage, 404, journal | `nodefony-troubleshoot` |
58
+ | Relire la sécurité de ton diff AVANT de commiter | `nodefony-security-review` |
59
+ | Monter Nodefony de version, ou une montée qui a laissé des rouges | `nodefony-upgrade` |
56
60
 
57
61
  Aucune ligne ne correspond ? Alors seulement, continue ici. Et si ta tâche est EXACTEMENT celle
58
62
  d'un spécialiste, va droit à lui : il n'y a pas à passer par cette page d'abord.
@@ -344,7 +348,8 @@ ont leur skill, qui part du symptôme : **`nodefony-troubleshoot`**.
344
348
  par requête = `overlayConfig()` (clés de la liste blanche du module), lue par `useConfig()`.
345
349
  - **Tu lis une liste sans la BORNER.** Un `find` sans limite matérialise la table entière —
346
350
  indolore sur les quelques lignes du poste de développement, fatal sur les dizaines de milliers
347
- de la production. Le service d'une entité hérite `findPage({ limit: 25 })`.
351
+ de la production. Le service d'une entité hérite `findPage({ limit })` — `limit` est OBLIGATOIRE,
352
+ aucun défaut ne le pose pour toi : écris-le (`findPage({ limit: 25 })`).
348
353
 
349
354
  ### Ce qui tue le processus, pas la requête
350
355
 
@@ -456,6 +461,13 @@ Ne concerne qu'une application qui a un frontend. Sauf mention, **vaut pour les
456
461
  sur les scripts, et le navigateur refuse un `<script>` en ligne qui n'en porte pas (« Refused to
457
462
  execute inline script »). Un `curl` ne le voit JAMAIS, il ne lit que le corps. Signe le script
458
463
  (valeur `this.context?.cspNonce`) ou sors-le dans un fichier servi ; ne desserre PAS la politique.
464
+ - **Une image, une police ou une API tierce est bloquée** (« violates the following Content
465
+ Security Policy directive ») : AJOUTE l'origine exacte par `headers.cspSources` de
466
+ `@nodefony/security` (`{ cdn: { "img-src": ["https://cdn.example.com"] } }`), ou sur la seule
467
+ route qui en a besoin par `@Csp` — ne réécris pas `headers.csp`. Une socket servie depuis une
468
+ AUTRE origine se déclare par `realtime.publicOrigin`. Ce que les navigateurs ont bloqué se lit,
469
+ en administrateur, sur `GET /nodefony/security/api/csp-reports` (`headers.cspReport`) — Brave
470
+ n'envoie aucun rapport, éprouve sous Chrome ou Firefox.
459
471
  - **En production, la modification front n'apparaît jamais** : hors développement il n'y a PAS de
460
472
  rechargement à chaud, et le manifeste est lu AU BOOT. `npm run build`, puis **redémarre le
461
473
  serveur**, puis rechargement forcé.
@@ -474,6 +486,10 @@ npm run verify # types, style, mise en forme, .env.example, tests, build,
474
486
  npm run test:e2e # boot réel + HTTP/WS — le gate LENT, hors `verify`
475
487
  ```
476
488
 
489
+ Ton diff change ce que le build produit (taille, dépendance, build plus lent) ? Chaque build
490
+ écrit un rapport : lis [`references/rapport-de-build.md`](references/rapport-de-build.md) avant de
491
+ recalculer quoi que ce soit à la main.
492
+
477
493
  Ton diff ajoute une route, une requête ou touche la sécurité ? Relis-le AVANT de commiter :
478
494
  skill `nodefony-security-review` — un script y nomme, ligne par ligne, ce qu'un œil laisse passer.
479
495
 
@@ -0,0 +1,19 @@
1
+ # Le rapport de build — ce que pèse ce que tu bâtis
2
+
3
+ > Chargé depuis `nodefony-dev` §10, quand un changement touche ce que le build produit.
4
+
5
+ Chaque build (`npm run build` : serveur ET front) écrit, sans rien demander, un rapport sous
6
+ `node_modules/.rolldown/report/<dossier de sortie>/` — le chemin relatif au projet, chaque `..`
7
+ devenu `__` (le front bâti dans `public/dist` → `node_modules/.rolldown/report/public/dist/`).
8
+
9
+ - **Quand le lire** : une taille qui change, une dépendance qui entre, un paquet présent en deux
10
+ versions, un build qui ralentit. Pas pour savoir si le build passe : son code de sortie le dit.
11
+ - **Comment** : `entry.md` d'abord (une vingtaine de lignes, il nomme le fichier à ouvrir
12
+ ensuite). Pour juger TON changement, épingle la base AVANT de modifier
13
+ (`cp .state.json baseline.json` dans ce dossier), rebâtis, lis `delta.md`. Un chiffre :
14
+ `jq '.metrics["output.total_bytes"]' metrics.json` — ne lis pas les `.json` en entier.
15
+ - **Ce qu'il ne dit pas** : ses octets sont comptés AVANT minification et compression, jamais
16
+ un poids réseau ; il ne vérifie aucun type (`npm run typecheck`) ; le mode watch et le serveur
17
+ de développement n'en écrivent pas ; une installation propre l'efface, base comprise.
18
+ - **Le couper** (côté serveur) : `defineNodefonyRolldownConfig({ buildReport: false })` dans
19
+ `rolldown.config.ts`.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: nodefony-devops
3
3
  metadata:
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  description: >
6
6
  Exploite CETTE application Nodefony en conteneur : image, compose, frontal (nginx, HAProxy, h2c,
7
7
  Ingress), secrets, variables, Kubernetes, Podman, nombre de pods. N'enseigne pas Docker — il
@@ -80,6 +80,12 @@ Ce que l'application ne peut pas deviner. Chacun de ces oublis produit une dégr
80
80
  clair »), le cookie de session perd son préfixe `__Host-`, et l'adresse cliente devient celle du
81
81
  frontal — donc une vague de reconnexions WebSocket épuise **un seul** quota. Sans le moindre
82
82
  message. Détail et preuve → [`references/frontal.md`](references/frontal.md).
83
+ - **Une seule CSP, et c'est l'application qui la pose.** Elle porte un nonce neuf par page, que le
84
+ frontal ne peut pas connaître. N'ajoute pas de `Content-Security-Policy` dans nginx, haproxy ou
85
+ une annotation d'ingress : le navigateur appliquerait les deux, et les scripts signés seraient
86
+ bloqués — page blanche. La poser au frontal quand même : `headers: { csp: "" }` dans la config
87
+ de `@nodefony/security`, puis une politique SANS nonce au frontal →
88
+ `node_modules/@nodefony/security/docs/headers.md` § « Poser la CSP au proxy ».
83
89
  - **Une inactivité de frontal ≥ 300 s.** Plus courte que le battement, elle coupe des WebSocket et
84
90
  des flux SSE **vivants**. `proxy:generate` la dérive ; un contrôleur d'entrée Kubernetes, non.
85
91
  - **Un bus temps réel partagé dès deux exemplaires** : `NF_REALTIME_DRIVER=redis`. Sans lui, un
@@ -116,26 +122,27 @@ Chaque ligne a la commande qui la **prouve**. Une case cochée sans commande est
116
122
 
117
123
  Le tableau qu'on lit en urgence.
118
124
 
119
- | Symptôme | Cause | Correction |
120
- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
121
- | L'exemplaire redémarre en boucle, journaux muets | La sonde de vivacité tombe avant la fin du démarrage | Sonde de démarrage sur `/readyz`, dimensionnée sur le **pire** cas |
122
- | Le conteneur sort en **137** à l'arrêt | Il a été tué : la fenêtre d'arrêt est ≤ au drain | Fenêtre ≥ 30 s (Podman : `--stop-timeout 20`, son défaut est **10 s**) |
123
- | Des `5xx` **pendant** chaque déploiement | Le répartiteur envoie encore du trafic quand le drain a commencé | Un délai avant drain, et un court arrêt avant terminaison |
124
- | Le cookie de session n'a pas le préfixe `__Host-` en production | Schéma constaté `http` derrière le frontal | `NF__HTTP__TRUSTPROXY` |
125
- | La WebSocket se ferme seule au bout d'une minute | L'inactivité du frontal est plus courte que le battement | Régénérer le frontal ; sur un contrôleur d'entrée, `proxy-read-timeout` ≥ 300 s → [`references/frontal.md`](references/frontal.md) |
126
- | Un message temps réel n'arrive qu'à une partie des clients | Plusieurs exemplaires, bus local : chaque pod ne diffuse qu'à ses propres clients | `NF_REALTIME_DRIVER=redis` + `NF_REDIS_URL` → [`references/dimensionnement.md`](references/dimensionnement.md) |
127
- | Depuis l'activation de h2c, le pod n'est jamais prêt | La sonde vise le port h2c (5153), qui refuse HTTP/1.1 | Sonder le port HTTP (5151) |
128
- | Depuis l'activation de h2c, les WebSocket ne s'ouvrent plus | Le frontal les envoie au port h2c | Router `Upgrade: websocket` vers le port HTTP → [`references/frontal.md`](references/frontal.md) §8 |
129
- | `DB_SQLITE_FALLBACK` en production alors que la base est posée | Le nom de la variable est faux d'un caractère, ou le Secret n'est pas monté dans CE conteneur | `npx nodefony doctor --env production` nomme la variable lue (`NF_DATABASE_URL` ou `NF_DATABASE_URL_FILE`) |
130
- | Le pod est tué (`OOMKilled`) avec peu de connexions | La contre-pression : des clients lents retiennent jusqu'à 4 Mio chacun | Baisser `maxBackpressure` sur `websocket` **et** `websocketSecure` → [`references/dimensionnement.md`](references/dimensionnement.md) |
131
- | Toutes les connexions répondent `403` « doit passer par HTTPS » | Même cause : le mot de passe arrive sur une requête que l'application croit en clair | `NF__HTTP__TRUSTPROXY` — jamais `allowInsecureCredentials` |
132
- | Sortie **78** au démarrage, une clé ou une variable nommée | Faute de configuration : clé inconnue (ou retirée par une mise à jour), valeur invalide, secret requis absent (`NF_CSRF_SECRET`, `NF_JWT_KEYSET`) | Lire la ligne qui la nomme ; `npx nodefony doctor --env production` annonce AVANT les variables requises (pas `NF_JWT_KEYSET`, exigée seulement si l'app émet des jetons) |
133
- | Les journaux d'audit montrent tous la **même adresse** | L'adresse du client n'est pas propagée, ou le frontal n'est pas de confiance | `trustProxy` ; en Podman sans privilèges, **c'est irréparable** (§7) |
134
- | `EACCES` au premier démarrage sur un volume | Le volume est né `root:root` : monté **par-dessus** un dossier dont l'image avait fixé le porteur | Volume nommé (il hérite du porteur) ou groupe de système de fichiers |
135
- | La base refuse des connexions dès qu'on monte en exemplaires | Une base administrée les compte ; le pool n'est pas borné | Réduire les exemplaires — le plafond n'est pas encore réglable (§6) |
136
- | La sonde répond `429` | Ce n'est **pas** la sonde : c'est la limitation de débit — donc l'appel n'a pas pris le raccourci | Interroger `/livez` et `/readyz` nus, sans en-tête ni préfixe ajouté |
137
- | Un secret retiré du `Dockerfile` reste dans l'image | Une couche antérieure le contient encore ; l'effacer plus loin ne l'enlève pas | `npx nodefony image:check`, et reconstruire sans jamais l'avoir copié |
138
- | La variable posée « ne fait rien » | Le nom est faux d'un caractère — une variable inconnue est **ignorée**, pas refusée | `npx nodefony doctor` → [`references/variables.md`](references/variables.md) |
125
+ | Symptôme | Cause | Correction |
126
+ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
+ | L'exemplaire redémarre en boucle, journaux muets | La sonde de vivacité tombe avant la fin du démarrage | Sonde de démarrage sur `/readyz`, dimensionnée sur le **pire** cas |
128
+ | Page blanche derrière le frontal, console « Refused to execute… blocked by CSP » | Deux CSP : celle de l'application et une ajoutée au frontal | Retirer celle du frontal, ou `headers.csp: ""` dans l'application |
129
+ | Le conteneur sort en **137** à l'arrêt | Il a été tué : la fenêtre d'arrêt est ≤ au drain | Fenêtre ≥ 30 s (Podman : `--stop-timeout 20`, son défaut est **10 s**) |
130
+ | Des `5xx` **pendant** chaque déploiement | Le répartiteur envoie encore du trafic quand le drain a commencé | Un délai avant drain, et un court arrêt avant terminaison |
131
+ | Le cookie de session n'a pas le préfixe `__Host-` en production | Schéma constaté `http` derrière le frontal | `NF__HTTP__TRUSTPROXY` |
132
+ | La WebSocket se ferme seule au bout d'une minute | L'inactivité du frontal est plus courte que le battement | Régénérer le frontal ; sur un contrôleur d'entrée, `proxy-read-timeout` ≥ 300 s → [`references/frontal.md`](references/frontal.md) |
133
+ | Un message temps réel n'arrive qu'à une partie des clients | Plusieurs exemplaires, bus local : chaque pod ne diffuse qu'à ses propres clients | `NF_REALTIME_DRIVER=redis` + `NF_REDIS_URL` → [`references/dimensionnement.md`](references/dimensionnement.md) |
134
+ | Depuis l'activation de h2c, le pod n'est jamais prêt | La sonde vise le port h2c (5153), qui refuse HTTP/1.1 | Sonder le port HTTP (5151) |
135
+ | Depuis l'activation de h2c, les WebSocket ne s'ouvrent plus | Le frontal les envoie au port h2c | Router `Upgrade: websocket` vers le port HTTP → [`references/frontal.md`](references/frontal.md) §8 |
136
+ | `DB_SQLITE_FALLBACK` en production alors que la base est posée | Le nom de la variable est faux d'un caractère, ou le Secret n'est pas monté dans CE conteneur | `npx nodefony doctor --env production` nomme la variable lue (`NF_DATABASE_URL` ou `NF_DATABASE_URL_FILE`) |
137
+ | Le pod est tué (`OOMKilled`) avec peu de connexions | La contre-pression : des clients lents retiennent jusqu'à 4 Mio chacun | Baisser `maxBackpressure` sur `websocket` **et** `websocketSecure` → [`references/dimensionnement.md`](references/dimensionnement.md) |
138
+ | Toutes les connexions répondent `403` « doit passer par HTTPS » | Même cause : le mot de passe arrive sur une requête que l'application croit en clair | `NF__HTTP__TRUSTPROXY` — jamais `allowInsecureCredentials` |
139
+ | Sortie **78** au démarrage, une clé ou une variable nommée | Faute de configuration : clé inconnue (ou retirée par une mise à jour), valeur invalide, secret requis absent (`NF_CSRF_SECRET`, `NF_JWT_KEYSET`) | Lire la ligne qui la nomme ; `npx nodefony doctor --env production` annonce AVANT les variables requises (pas `NF_JWT_KEYSET`, exigée seulement si l'app émet des jetons) |
140
+ | Les journaux d'audit montrent tous la **même adresse** | L'adresse du client n'est pas propagée, ou le frontal n'est pas de confiance | `trustProxy` ; en Podman sans privilèges, **c'est irréparable** (§7) |
141
+ | `EACCES` au premier démarrage sur un volume | Le volume est né `root:root` : monté **par-dessus** un dossier dont l'image avait fixé le porteur | Volume nommé (il hérite du porteur) ou groupe de système de fichiers |
142
+ | La base refuse des connexions dès qu'on monte en exemplaires | Une base administrée les compte ; le pool n'est pas borné | Réduire les exemplaires — le plafond n'est pas encore réglable (§6) |
143
+ | La sonde répond `429` | Ce n'est **pas** la sonde : c'est la limitation de débit — donc l'appel n'a pas pris le raccourci | Interroger `/livez` et `/readyz` nus, sans en-tête ni préfixe ajouté |
144
+ | Un secret retiré du `Dockerfile` reste dans l'image | Une couche antérieure le contient encore ; l'effacer plus loin ne l'enlève pas | `npx nodefony image:check`, et reconstruire sans jamais l'avoir copié |
145
+ | La variable posée « ne fait rien » | Le nom est faux d'un caractère — une variable inconnue est **ignorée**, pas refusée | `npx nodefony doctor` → [`references/variables.md`](references/variables.md) |
139
146
 
140
147
  ## 6. Kubernetes — ce qui est rendu, et ce qui te reste
141
148
 
@@ -22,7 +22,7 @@ file, et la latence monte.
22
22
  Mesurées sur Nodefony **10.0.0**, en **production**, Node 26.10, **un** process, route de banc
23
23
  **triviale**, charge de 1 Ko. Chaque débit est ramené à ELU 1 (débit observé ÷ ELU), donc c'est un
24
24
  **plafond**. Source et calculateur :
25
- [peut-on partir en production ?](https://nodefony.github.io/nodefony-core/performance/latest/).
25
+ [peut-on partir en production ?](https://nodefony.net/performance/latest/).
26
26
 
27
27
  | Constante | Valeur | Ce qu'elle dit |
28
28
  | ------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------- |
@@ -207,9 +207,12 @@ Avec `--reencrypt`, h2c est ignoré : le port TLS négocie déjà HTTP/2. **C'es
207
207
  contre un frontal réel** (HAProxy 3.0, configuration générée) : requête servie en h2c, sonde lue
208
208
  sur le port HTTP, WebSocket ouverte sur le port HTTP.
209
209
 
210
- ### nginx — reste en HTTP/1.1 vers l'application
210
+ ### nginx — HTTP/1.1 vers l'application, gRPC vers h2c
211
211
 
212
- nginx ne parle pas HTTP/2 à ses amonts (hors gRPC) : `proxy:generate nginx` ignore h2c.
212
+ nginx ne parle HTTP/2 à ses amonts que par `grpc_pass`. Avec `servers.h2c` actif,
213
+ `proxy:generate nginx` écoute en HTTP/2 et envoie les seuls appels gRPC (`content-type:
214
+ application/grpc…`) au port h2c ; le reste, gRPC-Web compris, garde le port HTTP en HTTP/1.1.
215
+ Re-chiffrement demandé : pas de lien h2c, la configuration le dit en commentaire.
213
216
 
214
217
  ### Envoy
215
218
 
@@ -7,12 +7,13 @@ du frontal ne sort que si tu le demandes explicitement.
7
7
 
8
8
  ## 1. La carte
9
9
 
10
- | Étage | Base | Ce qu'il fait | Descend dans l'image finale ? |
11
- | ----------- | ------------------- | --------------------------------------------------------------------- | ----------------------------- |
12
- | `build` | `node:24-alpine` | Installe, compile, élague les dépendances de développement | seulement son `/app` |
13
- | `proxyconf` | hérite de `build` | **Dérive** la configuration nginx et rassemble les fichiers statiques | non — étage intermédiaire |
14
- | `edge` | `nginx:1.30-alpine` | Le frontal, avec la configuration dérivée ci-dessus | non — cible séparée |
15
- | _(final)_ | `node:24-alpine` | L'application en production | **oui** |
10
+ | Étage | Base | Ce qu'il fait | Descend dans l'image finale ? |
11
+ | ----------- | ------------------------------------- | ----------------------------------------------------------------------- | ----------------------------- |
12
+ | `build` | `node:24-slim` | Installe, compile, élague les dépendances de développement | seulement son `/app` |
13
+ | `proxyconf` | hérite de `build` | **Dérive** la configuration nginx et rassemble les fichiers statiques | non — étage intermédiaire |
14
+ | `edge` | `nginx:1.30-alpine` | Le frontal, avec la configuration dérivée ci-dessus | non — cible séparée |
15
+ | `nf-dirs` | `node:24-slim` | Crée `tmp/` et `var/` vides — distroless n'a pas de shell pour le faire | seulement ces deux dossiers |
16
+ | _(final)_ | `gcr.io/distroless/nodejs24-debian13` | L'application en production — ni shell, ni npm, `node` hors du `PATH` | **oui** |
16
17
 
17
18
  ```bash
18
19
  docker build -t <app> . # l'application
@@ -27,7 +28,7 @@ docker build -t <app>-edge --target edge . # le frontal, seulement si tu le ve
27
28
  | `USER` | **`1000:1000`** — numérique, donc reportable tel quel dans un `runAsUser` |
28
29
  | `ENV` | `NODE_ENV=production` |
29
30
  | `EXPOSE` | `5151` |
30
- | `CMD` | `["node_modules/.bin/nodefony", "production"]` |
31
+ | `CMD` | `["node_modules/nodefony/bin/nodefony", "production"]` |
31
32
  | `HEALTHCHECK` | `/readyz`, intervalle 10 s, délai 2 s, **période de démarrage 20 s**, 3 essais |
32
33
 
33
34
  **`node` est le process n° 1.** Il n'y a pas d'`ENTRYPOINT` d'init : le signal `SIGTERM` arrive
@@ -71,14 +72,21 @@ Sans eux l'image se construit quand même — avec des étiquettes vides. Une im
71
72
 
72
73
  ## 3. Changer la base — c'est prévu, et c'est écrit dans le fichier
73
74
 
74
- Le gabarit porte deux alternatives en commentaire, avec leur mode d'emploi. La règle commune :
75
- **remplacer les DEUX `FROM`, jamais un seul** — un étage de compilation `alpine` et une exécution
76
- `slim` produisent des binaires natifs qui ne se chargeront pas.
77
-
78
- | Tu veux… | Bascule | Ce qu'il faut aussi changer |
79
- | ----------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------- |
80
- | Un paquet natif qui ne publie **pas** de binaire musl | `node:24-slim` (glibc) sur les deux `FROM` | rien |
81
- | La surface d'attaque minimale | `gcr.io/distroless/nodejs24-debian12` en finale, `slim` en build | **`CMD` et `HEALTHCHECK` en `/nodejs/bin/node`** — pas de shell |
75
+ L'image finale est distroless `debian13` : aucune vulnérabilité critique ni haute, Node à jour. Le
76
+ gabarit porte en commentaire comment passer à une base À SHELL, avec son mode d'emploi. La règle
77
+ commune : **l'étage de construction et l'étage final partagent la même libc** — une compilation
78
+ `alpine` (musl) et une exécution glibc produisent des binaires natifs qui ne se chargeront pas.
79
+
80
+ | Tu veux… | Bascule | Ce qu'il faut aussi changer |
81
+ | ---------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------ |
82
+ | Un `apk add` dans l'image finale | `node:24-alpine` sur les deux `FROM` | retirer l'étage `nf-dirs`, remettre le `RUN mkdir … && chown`, sonde en `node` |
83
+ | Un shell glibc (`bash`, `apt-get`) | `node:24-slim` sur les deux `FROM` | idem |
84
+
85
+ Sous distroless, rien ne lit un shebang : `node` n'est pas sur le `PATH` et il n'y a pas de
86
+ `/usr/bin/env`. L'`ENTRYPOINT` de l'image est `/nodejs/bin/node`, donc une commande se change par ses
87
+ **arguments** — `docker run <image> node_modules/nodefony/bin/nodefony …`, `args:` en Kubernetes (jamais
88
+ `command:`, qui remplacerait l'`ENTRYPOINT`), `command:` dans compose. Un diagnostic :
89
+ `docker exec <conteneur> /nodejs/bin/node …`.
82
90
 
83
91
  Le symptôme d'un mélange musl/glibc est franc : un `.node` introuvable, ou `Error relocating`, **au
84
92
  démarrage** — jamais à la construction. L'image se construit parfaitement et ne démarre pas.