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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) 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 +10 -1
  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/references/sondes.md +13 -1
  40. package/skills/nodefony-browser/scripts/inspect.mjs +55 -8
  41. package/skills/nodefony-browser/scripts/lib/probes.mjs +53 -0
  42. package/skills/nodefony-browser/scripts/socket.mjs +70 -4
  43. package/skills/nodefony-browser/scripts/watch.mjs +101 -0
  44. package/skills/nodefony-dev/SKILL.md +31 -1
  45. package/skills/nodefony-dev/scripts/docs.mjs +3 -3
  46. package/skills/nodefony-devops/SKILL.md +85 -64
  47. package/skills/nodefony-devops/references/dimensionnement.md +122 -0
  48. package/skills/nodefony-devops/references/frontal.md +277 -47
  49. package/skills/nodefony-devops/references/kubernetes.md +41 -14
  50. package/skills/nodefony-devops/references/secrets.md +13 -2
  51. package/skills/nodefony-devops/references/variables.md +14 -12
  52. package/skills/nodefony-protect-route/SKILL.md +6 -4
  53. package/skills/nodefony-security-review/scripts/review-diff.mjs +4 -4
  54. package/skills/nodefony-troubleshoot/SKILL.md +86 -12
  55. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
@@ -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 {
@@ -1,19 +1,19 @@
1
1
  ---
2
2
  name: nodefony-devops
3
3
  metadata:
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  description: >
6
- Exploite CETTE application Nodefony en conteneur : image, compose, frontal, secrets, variables,
7
- Kubernetes, Podman. N'enseigne pas Docker — il énonce le CONTRAT d'exploitation, chiffré :
8
- quelles sondes elle expose, combien de temps elle draine, quel code de sortie elle rend, sous
9
- quel identifiant elle tourne, où elle écrit, ce qui casse quand une variable manque. Sert
10
- d'abord à savoir ce qu'il ne faut PAS réécrire. À charger AVANT d'écrire un manifeste, un
11
- compose, une étape de déploiement, ou de diagnostiquer un conteneur qui redémarre.
6
+ Exploite CETTE application Nodefony en conteneur : image, compose, frontal (nginx, HAProxy, h2c,
7
+ Ingress), secrets, variables, Kubernetes, Podman, nombre de pods. N'enseigne pas Docker — il
8
+ énonce le CONTRAT d'exploitation, chiffré : sondes, durée du drain, code de sortie, identifiant,
9
+ dossiers écrits, ce qui casse quand une variable manque. À charger AVANT d'écrire un manifeste,
10
+ un compose, une configuration de frontal, de dimensionner les pods, ou de diagnostiquer un
11
+ conteneur qui redémarre.
12
12
  Déclencheurs : "déployer mon application en production", "mettre mon app en production",
13
- "construire et vérifier l'image", "docker compose", "derrière nginx", "Kubernetes",
14
- "mon pod redémarre", "mon pod perd des requêtes", "redéploiement sans coupure",
15
- "arrêt gracieux", "readOnlyRootFilesystem", "politique Restricted", "secrets",
16
- "mot de passe en production", "variables d'environnement", "Podman", "GitLab CI".
13
+ "docker compose", "derrière nginx", "derrière HAProxy", "Ingress", "h2c",
14
+ "ma WebSocket se coupe derrière le proxy", "SSE derrière un proxy", "combien de pods ?", "dimensionner un pod",
15
+ "plusieurs pods en temps réel", "Kubernetes", "mon pod redémarre", "mon pod perd des requêtes",
16
+ "arrêt gracieux", "politique Restricted", "secrets", "variables d'environnement", "Podman".
17
17
  ---
18
18
 
19
19
  # nodefony-devops — exploiter cette application
@@ -28,19 +28,21 @@ Tu sais faire du Docker. Ce que tu ne sais pas encore, c'est ce que **cette** ap
28
28
  Ce que l'application tient, sans que tu aies rien à coder. Chiffré, parce qu'un ordonnanceur se
29
29
  règle avec des nombres.
30
30
 
31
- | Ce que tu veux | Ce qu'elle fait |
32
- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
33
- | **Savoir si le process vit** | `GET /livez` → `200`. Reste à `200` **pendant tout l'arrêt** : un exemplaire qui draine est vivant, le tuer ferait exactement ce qu'on veut éviter. |
34
- | **Savoir s'il peut servir** | `GET /readyz` → `503` avant d'être prêt, `200` ensuite, et `503` **dès la première milliseconde du SIGTERM**. C'est lui qui te retire du répartiteur. |
35
- | **Ne pas être cru mort à tort** | Les deux sondes **court-circuitent le pipeline avant la limitation de débit**. Un agent d'ordonnancement limité recevrait `429`, qu'il lirait comme un échec de sonde. |
36
- | **Un arrêt propre** | `SIGTERM` → refus des nouvelles requêtes, on laisse finir celles en vol, borné à **15 s**. Sous les 30 s qu'un kubelet accorde par défaut. |
37
- | **Laisser le répartiteur suivre** | Un délai configurable **avant** le début du drain, le temps que ton frontal retire l'exemplaire de sa table. |
38
- | **Vérifier que l'arrêt a marché** | Code de sortie **`0`**. Un **`137`** signifie qu'il a été tué : les requêtes en vol sont perdues, à chaque déploiement, **sans une ligne de journal**. |
39
- | **Un utilisateur non root** | `USER 1000:1000` — **numérique**, donc utilisable tel quel par un `runAsUser`. Un `USER nom` ne l'est pas, et certains ordonnanceurs le refusent. |
40
- | **Des secrets montés** | **Toute** variable accepte le suffixe `_FILE` : `NF_CSRF_SECRET_FILE=/run/secrets/csrf` lit le contenu du fichier. Secrets Docker **et** Kubernetes. |
41
- | **Identifier l'exemplaire** | `NF_POD_NAME` — à alimenter depuis `metadata.name`. Elle dérive l'identité d'origine du bus temps réel ; sans elle, plusieurs exemplaires se confondent. |
42
- | **Un port** | `5151`, déclaré par `EXPOSE`. Au-dessus de 1024, donc aucune capacité privilégiée n'est requise. |
43
- | **Un contrôle de santé natif** | `HEALTHCHECK` sur `/readyz`, déjà dans l'image. |
31
+ | Ce que tu veux | Ce qu'elle fait |
32
+ | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
33
+ | **Savoir si le process vit** | `GET /livez` → `200`. Reste à `200` **pendant tout l'arrêt** : un exemplaire qui draine est vivant, le tuer ferait exactement ce qu'on veut éviter. |
34
+ | **Savoir s'il peut servir** | `GET /readyz` → `503` avant d'être prêt, `200` ensuite, et `503` **dès la première milliseconde du SIGTERM**. C'est lui qui te retire du répartiteur. |
35
+ | **Ne pas être cru mort à tort** | Les deux sondes **court-circuitent le pipeline avant la limitation de débit**. Un agent d'ordonnancement limité recevrait `429`, qu'il lirait comme un échec de sonde. |
36
+ | **Un arrêt propre** | `SIGTERM` → refus des nouvelles requêtes, on laisse finir celles en vol, borné à **15 s**. Sous les 30 s qu'un kubelet accorde par défaut. |
37
+ | **Laisser le répartiteur suivre** | Un délai configurable **avant** le début du drain, le temps que ton frontal retire l'exemplaire de sa table. |
38
+ | **Vérifier que l'arrêt a marché** | Code de sortie **`0`**. Un **`137`** signifie qu'il a été tué : les requêtes en vol sont perdues, à chaque déploiement, **sans une ligne de journal**. |
39
+ | **Un utilisateur non root** | `USER 1000:1000` — **numérique**, donc utilisable tel quel par un `runAsUser`. Un `USER nom` ne l'est pas, et certains ordonnanceurs le refusent. |
40
+ | **Des secrets montés** | Les variables du catalogue acceptent le suffixe `_FILE` : `NF_CSRF_SECRET_FILE=/run/secrets/csrf` lit le contenu du fichier — URL de base et de cache comprises (`NF_DATABASE_URL_FILE`). |
41
+ | **Identifier l'exemplaire** | `NF_POD_NAME` — à alimenter depuis `metadata.name`. Elle dérive l'identité d'origine du bus temps réel ; sans elle, plusieurs exemplaires se confondent. |
42
+ | **Un port** | `5151`, déclaré par `EXPOSE`. Au-dessus de 1024, donc aucune capacité privilégiée n'est requise. |
43
+ | **HTTP/2 vers un frontal** | En option, un port **h2c** (HTTP/2 en clair, `5153`) : `NF__APP__SERVERS__H2C__ENABLED=true`. Ni sondes ni WebSocket dessus — elles restent sur `5151`. |
44
+ | **Des WebSocket qui survivent** | Un battement de cœur ping/pong réclame les sockets mortes ; à l'arrêt, chaque client reçoit `1001` et se reconnecte ailleurs. Le frontal doit tolérer ≥ 300 s d'inactivité. |
45
+ | **Un contrôle de santé natif** | `HEALTHCHECK` sur `/readyz`, déjà dans l'image. |
44
46
 
45
47
  **Où l'application écrit — la question qui décide de tout le reste.** Deux dossiers, et deux
46
48
  seulement : **`tmp/`** et **`var/`**. Tout le reste du système de fichiers peut être scellé.
@@ -74,9 +76,14 @@ Ce que l'application ne peut pas deviner. Chacun de ces oublis produit une dégr
74
76
  **silencieuse** — pas une erreur, pas une alerte.
75
77
 
76
78
  - **`NF__HTTP__TRUSTPROXY`** dès qu'un frontal termine le TLS. Sans lui, l'application constate un
77
- schéma `http` et **un cookie `__Host-` n'est pas émis** : les sessions ne tiennent plus, sans le
78
- moindre message — et toute connexion par mot de passe répond `403`, le secret étant reçu
79
- « en clair ». Détail et preuve → [`references/frontal.md`](references/frontal.md).
79
+ schéma `http` : **toute connexion par mot de passe répond `403`** (le secret est reçu « en
80
+ clair »), le cookie de session perd son préfixe `__Host-`, et l'adresse cliente devient celle du
81
+ frontal — donc une vague de reconnexions WebSocket épuise **un seul** quota. Sans le moindre
82
+ message. Détail et preuve → [`references/frontal.md`](references/frontal.md).
83
+ - **Une inactivité de frontal ≥ 300 s.** Plus courte que le battement, elle coupe des WebSocket et
84
+ des flux SSE **vivants**. `proxy:generate` la dérive ; un contrôleur d'entrée Kubernetes, non.
85
+ - **Un bus temps réel partagé dès deux exemplaires** : `NF_REALTIME_DRIVER=redis`. Sans lui, un
86
+ message n'atteint que les clients du pod qui l'émet → [`references/dimensionnement.md`](references/dimensionnement.md).
80
87
  - **Deux volumes inscriptibles**, sur `/app/tmp` et `/app/var`, si tu scelles la racine.
81
88
  - **Une fenêtre d'arrêt ≥ 30 s**, strictement supérieure au drain de 15 s.
82
89
  - **Pas de limite processeur.** Une limite mémoire tue proprement et l'exemplaire redémarre ; une
@@ -88,20 +95,22 @@ Ce que l'application ne peut pas deviner. Chacun de ces oublis produit une dégr
88
95
 
89
96
  Chaque ligne a la commande qui la **prouve**. Une case cochée sans commande est une supposition.
90
97
 
91
- | # | Le point | La preuve |
92
- | --- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
93
- | 1 | L'image se construit | `docker build -t <app> .` |
94
- | 2 | Elle n'embarque **aucun secret** | `npx nodefony image:check <app>` — lit les **couches**, pas l'image aplatie (§5) |
95
- | 3 | Elle démarre et se déclare prête | `docker run -d --name t <app>` puis `curl -fsS localhost:5151/readyz` |
96
- | 4 | Elle s'arrête **proprement** | `docker stop t; docker inspect t --format '{{.State.ExitCode}}'` → doit rendre **`0`** |
97
- | 5 | Les variables requises sont posées | `npx nodefony doctor --env production` |
98
- | 6 | Les secrets sont **montés**, pas dans l'environnement | Suffixe `_FILE` → [`references/secrets.md`](references/secrets.md) |
99
- | 7 | Les migrations passent **avant** les nouveaux exemplaires | `kubectl apply -f deploy/migrate-job.yaml`, attendre la fin |
100
- | 8 | Le frontal est **dérivé**, pas écrit à la main | `npx nodefony proxy:generate nginx` |
101
- | 9 | Le schéma constaté derrière le frontal est le bon | Une requête réelle pose un cookie `__Host-` → [`references/frontal.md`](references/frontal.md) |
102
- | 10 | Un redéploiement ne perd **aucune** requête | Charge constante pendant un `rollout restart` ; zéro `5xx` |
103
- | 11 | La racine du conteneur est **scellée** | `--read-only` + volumes sur `/app/tmp` et `/app/var` |
104
- | 12 | Tu sais ce que tu **ne** peux **pas** garantir | Podman sans privilèges perd l'adresse du client (§7) — à énoncer, pas à masquer |
98
+ | # | Le point | La preuve |
99
+ | --- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
100
+ | 1 | L'image se construit | `docker build -t <app> .` |
101
+ | 2 | Elle n'embarque **aucun secret** | `npx nodefony image:check <app>` — lit les **couches**, pas l'image aplatie (§5) |
102
+ | 3 | Elle démarre et se déclare prête | `docker run -d --name t <app>` puis `curl -fsS localhost:5151/readyz` |
103
+ | 4 | Elle s'arrête **proprement** | `docker stop t; docker inspect t --format '{{.State.ExitCode}}'` → doit rendre **`0`** |
104
+ | 5 | Les variables requises sont posées | `npx nodefony doctor --env production` |
105
+ | 6 | Les secrets sont **montés**, pas dans l'environnement | Suffixe `_FILE` → [`references/secrets.md`](references/secrets.md) |
106
+ | 7 | Les migrations passent **avant** les nouveaux exemplaires | `kubectl apply -f deploy/migrate-job.yaml`, attendre la fin |
107
+ | 8 | Le frontal est **dérivé**, pas écrit à la main | `npx nodefony proxy:generate nginx` |
108
+ | 9 | Le schéma constaté derrière le frontal est le bon | Une requête réelle pose un cookie `__Host-` → [`references/frontal.md`](references/frontal.md) |
109
+ | 10 | Un redéploiement ne perd **aucune** requête | Charge constante pendant un `rollout restart` ; zéro `5xx` |
110
+ | 11 | Une WebSocket **traverse** le frontal et y survit | Ouverte à travers lui, muette plus d'une minute : toujours ouverte |
111
+ | 12 | Le nombre de pods et leur mémoire sont **calculés** | Débit mesuré sur **ton** application → [`references/dimensionnement.md`](references/dimensionnement.md) |
112
+ | 13 | La racine du conteneur est **scellée** | `--read-only` + volumes sur `/app/tmp` et `/app/var` |
113
+ | 14 | Tu sais ce que tu **ne** peux **pas** garantir | Podman sans privilèges perd l'adresse du client (§7) — à énoncer, pas à masquer |
105
114
 
106
115
  ## 5. Symptôme → cause → correction
107
116
 
@@ -112,7 +121,13 @@ Le tableau qu'on lit en urgence.
112
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 |
113
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**) |
114
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 |
115
- | Les sessions ne tiennent pas derrière le frontal | Schéma constaté `http` → le cookie `__Host-` n'est pas émis | `NF__HTTP__TRUSTPROXY` |
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) |
116
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` |
117
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) |
118
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) |
@@ -131,21 +146,26 @@ Service et l'Ingress ne le sont pas — tu les écris, et les §1 et §3 te dise
131
146
  kubectl apply -f deploy/migrate-job.yaml # les migrations AVANT les exemplaires
132
147
  ```
133
148
 
134
- **Le manifeste rendu est ton modèle : il est conforme à la politique Restricted.** Recopie-en le
135
- contexte de sécurité dans ton Deployment — c'est le seul endroit du dépôt où il est déjà juste.
149
+ **Le manifeste rendu passe la politique Restricted.** Recopie-en le contexte de sécurité dans ton
150
+ Deployment — sauf son `readOnlyRootFilesystem: false`, juste pour un travail éphémère, pas pour un
151
+ exemplaire qui sert du trafic.
136
152
 
137
- La politique exige six champs. Aucun n'est « fourni par l'image » : ce sont des champs de
138
- **manifeste**, c'est toi qui les poses. Ce que l'image apporte, c'est de les rendre tous
139
- satisfaisables :
153
+ Aucun de ces champs n'est « fourni par l'image » : ce sont des champs de **manifeste**, c'est toi
154
+ qui les poses. Ce que l'image apporte, c'est de les rendre satisfaisables :
140
155
 
141
- | Le champ | Ce qu'il te coûte |
142
- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
143
- | `runAsNonRoot: true` | rien |
144
- | `runAsUser` / `runAsGroup` numériques | rien — l'image déclare `USER 1000:1000`, tu reportes `1000` |
145
- | `allowPrivilegeEscalation: false` | rien |
146
- | `capabilities.drop: ["ALL"]` | rien — l'application n'écoute qu'au-dessus de 1024 |
147
- | `seccompProfile: RuntimeDefault` | rien — mais **à ne pas oublier** : la politique traite l'**absence** de profil comme une violation, pas comme un défaut permissif |
148
- | `readOnlyRootFilesystem: true` | **les deux volumes du §3** — sans eux le conteneur ne démarre pas |
156
+ | Le champ | Exigé par Restricted ? | Ce qu'il te coûte |
157
+ | ------------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------- |
158
+ | `runAsNonRoot: true` | **oui** | rien |
159
+ | `runAsUser` / `runAsGroup` numériques | non nul **s'il est posé** | rien — l'image déclare `USER 1000:1000`, tu reportes `1000` |
160
+ | `allowPrivilegeEscalation: false` | **oui** | rien |
161
+ | `capabilities.drop: ["ALL"]` | **oui** | rien — l'application n'écoute qu'au-dessus de 1024 |
162
+ | `seccompProfile: RuntimeDefault` | **oui** | rien — mais la politique traite l'**absence** de profil comme une violation, pas comme un défaut permissif |
163
+ | `readOnlyRootFilesystem: true` | non — durcissement en plus | **les deux volumes du §3** — sans eux le conteneur ne démarre pas |
164
+
165
+ **Sondes sur le port HTTP (5151)**, même avec h2c actif. Combien d'exemplaires, quelle mémoire, et
166
+ le bus partagé dès qu'il y en a deux → [`references/dimensionnement.md`](references/dimensionnement.md).
167
+ L'Ingress (ingress-nginx est retiré depuis mars 2026), la Gateway API et leurs délais →
168
+ [`references/frontal.md`](references/frontal.md) §10.
149
169
 
150
170
  **La base de données n'est pas dans le cluster**, et ça a trois conséquences dures →
151
171
  [`references/kubernetes.md`](references/kubernetes.md).
@@ -173,15 +193,16 @@ de la politique, ni l'Ingress, ni un déploiement progressif. Ce qu'il ne prouve
173
193
 
174
194
  ## 8. Les références
175
195
 
176
- | Fichier | Quand l'ouvrir |
177
- | ------------------------------------------------------ | --------------------------------------------------------------------------------------- |
178
- | [`references/image.md`](references/image.md) | Modifier le `Dockerfile` : étages, arguments, bases candidates, ce qui ne se touche pas |
179
- | [`references/compose.md`](references/compose.md) | Services, profils, volumes, réseaux, le profil de topologie de production |
180
- | [`references/variables.md`](references/variables.md) | Le catalogue, la grammaire des noms, `_FILE`, **ce qui casse si absent** |
181
- | [`references/secrets.md`](references/secrets.md) | Mots de passe, rotation, couches d'image, ce qui n'entre jamais |
182
- | [`references/frontal.md`](references/frontal.md) | nginx et haproxy dérivés, TLS, en-têtes, `trustProxy`, `__Host-` |
183
- | [`references/kubernetes.md`](references/kubernetes.md) | Ton Deployment, Restricted, la base administrée |
184
- | [`references/podman.md`](references/podman.md) | Les six écarts, et celui qui ne se répare pas |
196
+ | Fichier | Quand l'ouvrir |
197
+ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
198
+ | [`references/image.md`](references/image.md) | Modifier le `Dockerfile` : étages, arguments, bases candidates, ce qui ne se touche pas |
199
+ | [`references/compose.md`](references/compose.md) | Services, profils, volumes, réseaux, le profil de topologie de production |
200
+ | [`references/variables.md`](references/variables.md) | Le catalogue, la grammaire des noms, `_FILE`, **ce qui casse si absent** |
201
+ | [`references/secrets.md`](references/secrets.md) | Mots de passe, rotation, couches d'image, ce qui n'entre jamais |
202
+ | [`references/frontal.md`](references/frontal.md) | nginx et HAProxy dérivés, `trustProxy`, SSE, **h2c**, Envoy, Traefik, Ingress, Gateway API |
203
+ | [`references/kubernetes.md`](references/kubernetes.md) | Ton Deployment, Restricted, sondes, arrêt, la base administrée |
204
+ | [`references/dimensionnement.md`](references/dimensionnement.md) | Combien de pods, quelle mémoire, WebSocket et SSE, plusieurs exemplaires |
205
+ | [`references/podman.md`](references/podman.md) | Les six écarts, et celui qui ne se répare pas |
185
206
 
186
207
  ## 9. Passer la main
187
208
 
@@ -0,0 +1,122 @@
1
+ # Dimensionner un pod — HTTP, WebSocket, SSE
2
+
3
+ > **Maintenance** : vérité courante. Éditer en place. Les constantes du §2 se recalent sur la page
4
+ > de performance publiée, jamais de mémoire.
5
+
6
+ ## 1. Le modèle — un process, une boucle, un cœur
7
+
8
+ Un process Node exécute ton code sur **une seule** boucle d'événements. Ce qui borne un pod n'est
9
+ donc pas le nombre de cœurs de la machine, c'est la part de temps où cette boucle travaille :
10
+ l'**ELU** (_event loop utilization_, de 0 à 1). À ELU 1, le pod ne sert pas plus vite : il met en
11
+ file, et la latence monte.
12
+
13
+ | Conséquence | Le geste |
14
+ | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15
+ | Un pod n'utilise **qu'un cœur** pour ton code | `requests.cpu: "1"`. Davantage ne sert qu'au ramasse-miettes et aux E/S de fond |
16
+ | Une **limite** processeur ralentit sans tuer | pas de `limits.cpu` (voir le §3 de la page principale) |
17
+ | Monter en charge, c'est **ajouter des pods** | horizontal, par l'orchestrateur ; `nodefony cluster -w N` n'est que pour une machine sans orchestrateur |
18
+ | Plusieurs workers dans un pod | à éviter : la diffusion `broadcast()` ne touche que les clients du **même** worker, et le cluster Node répartit des **connexions** — avec les rares connexions h2c d'un proxy, un worker peut tout recevoir |
19
+
20
+ ## 2. Les constantes mesurées — un ordre de grandeur, pas ta capacité
21
+
22
+ Mesurées sur Nodefony **10.0.0**, en **production**, Node 26.10, **un** process, route de banc
23
+ **triviale**, charge de 1 Ko. Chaque débit est ramené à ELU 1 (débit observé ÷ ELU), donc c'est un
24
+ **plafond**. Source et calculateur :
25
+ [peut-on partir en production ?](https://nodefony.github.io/nodefony-core/performance/latest/).
26
+
27
+ | Constante | Valeur | Ce qu'elle dit |
28
+ | ------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------- |
29
+ | Tas par socket WebSocket — TLS terminé au frontal | **≈ 10 Ko** | le coût d'une connexion **ouverte et muette** ; pente linéaire sur 0 → 800 sockets |
30
+ | Tas par socket — TLS terminé par Node (`wss`) | ≈ 12 à 14 Ko | mesure non concordante d'un run à l'autre : retenir le haut de la fourchette |
31
+ | Écho WebSocket 1:1 | ≈ 35 000 à 46 000 msg/s | messages reçus **et** renvoyés par seconde, à ELU 1 |
32
+ | Diffusion à 100 abonnés | ≈ 197 000 livraisons/s | une livraison = un message remis à un abonné ; c'est elle qui coûte, pas l'émission |
33
+ | HTTP/1.1, route triviale | ≈ 15 000 req/s (TLS ≈ 12 000) | p99 ≈ 0,3 ms à vide — **ton** contrôleur, ta base et tes services dominent ce chiffre |
34
+ | Mémoire au repos (RSS) | **à mesurer chez toi** | dépend des modules chargés ; `docker stats` sur un conteneur prêt et sans trafic |
35
+
36
+ 🔴 **Ce que ces constantes ne disent pas.** Une route réelle lit une base, signe un jeton, rend une
37
+ vue : elle coûte dix à cent fois la route de banc. Le coût d'un **flux SSE** ouvert n'est pas
38
+ mesuré. Ces nombres servent à vérifier un ordre de grandeur, puis à **mesurer ton application**
39
+ (§5) — jamais à remplir directement un manifeste.
40
+
41
+ ## 3. La formule
42
+
43
+ Trois plafonds, et le pod est borné par le **premier** atteint :
44
+
45
+ ```text
46
+ pods = max( ⌈ C / C_pod ⌉ , ⌈ M / (D_msg × u) ⌉ , ⌈ R / (D_req × u) ⌉ ) + 1
47
+
48
+ C connexions simultanées (WebSocket + flux SSE) au pic
49
+ M livraisons temps réel par seconde au pic (un message diffusé à N abonnés = N livraisons)
50
+ R requêtes HTTP par seconde au pic
51
+ D_msg livraisons/s d'UN pod à ELU 1 — mesurées sur TON application (§5)
52
+ D_req requêtes/s d'UN pod à ELU 1 — idem
53
+ u ELU visée : 0,6 — la marge absorbe une rafale et la reconnexion d'un déploiement (§4)
54
+ +1 le pod qui draine pendant un déploiement, ou celui qu'on perd
55
+ ```
56
+
57
+ `C_pod` se déduit de la **mémoire** :
58
+
59
+ ```text
60
+ limits.memory ≥ RSS_repos + C_pod × coût_socket + L × maxBackpressure + marge GC (≈ 30 %)
61
+
62
+ coût_socket ≈ 10 Ko de tas (§2) + les tampons noyau et TLS, hors tas
63
+ L clients LENTS simultanés tolérés
64
+ maxBackpressure file d'envoi par socket avant de jeter : 4 Mio par défaut
65
+ ```
66
+
67
+ > 🔴 **Le terme qui fait exploser la mémoire n'est pas le nombre de sockets, c'est la
68
+ > contre-pression.** Dix mille sockets muettes tiennent en ≈ 100 Mo de tas ; cent clients mobiles
69
+ > qui ne lisent plus, à 4 Mio chacun, en réservent 400. Pour des canaux d'**état** (seul le dernier
70
+ > instantané compte), baisser `maxBackpressure` — et le régler sur `websocket` **et**
71
+ > `websocketSecure`, régler l'un ne règle pas l'autre :
72
+ >
73
+ > ```ts
74
+ > use("@nodefony/http", {
75
+ > websocket: { maxBackpressure: 262144, backpressureCloseAfterDrops: 50 },
76
+ > websocketSecure: {
77
+ > maxBackpressure: 262144,
78
+ > backpressureCloseAfterDrops: 50,
79
+ > },
80
+ > });
81
+ > ```
82
+ >
83
+ > Au-delà du solde de refus, le client est fermé en `1013` et se reconnecte de lui-même.
84
+
85
+ **Exemple.** 20 000 connexions, 2 000 messages/s diffusés à 50 abonnés en moyenne (100 000
86
+ livraisons/s), 300 req/s. Ton application mesure `D_msg = 60 000`, `D_req = 1 500`, RSS au repos
87
+ 250 Mo, et tu tolères 200 clients lents avec `maxBackpressure` à 256 Kio :
88
+
89
+ - mémoire par pod pour 10 000 connexions : 250 + 10 000 × 0,01 + 200 × 0,25 = 400 Mo, + 30 % ≈ **520 Mo** ;
90
+ - connexions : ⌈20 000 / 10 000⌉ = 2 ;
91
+ - temps réel : ⌈100 000 / (60 000 × 0,6)⌉ = 3 ;
92
+ - HTTP : ⌈300 / (1 500 × 0,6)⌉ = 1 ;
93
+ - **pods = 3 + 1 = 4**, `limits.memory: 768Mi`. C'est le temps réel qui dimensionne, pas les connexions.
94
+
95
+ ## 4. Ce qui change dès qu'il y a plus d'un pod
96
+
97
+ | Fait | Conséquence, et le geste |
98
+ | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
99
+ | Un canal est **local au pod** par défaut | deux utilisateurs sur deux pods ne se voient pas, **sans erreur**. Bus partagé : `NF_REALTIME_DRIVER=redis` et `NF_REDIS_URL`, plus un `namespace` explicite si le Redis est mutualisé — sinon deux applications se parlent. Le canal doit aussi **demander** à traverser : skill `nodefony-add-realtime-channel` |
100
+ | Chaque exemplaire doit être **identifiable** | `NF_POD_NAME` depuis `metadata.name` — sans elle, l'identité d'origine du bus se confond entre pods |
101
+ | Un déploiement **ferme** les WebSocket du pod qui draine (`1001`) | tous ses clients se reconnectent **en même temps** sur les pods restants : `maxUnavailable: 0`, `maxSurge: 1`, et la marge `u = 0,6` du §3 |
102
+ | La limitation de débit compte **par adresse cliente** | derrière un frontal sans `NF__HTTP__TRUSTPROXY`, tous les clients ont l'adresse du frontal : une vague de reconnexions épuise un seul quota et répond `429` à tout le monde |
103
+ | Une connexion WebSocket **ne migre pas** | après un ajout de pods, les anciens gardent leurs connexions jusqu'à ce qu'elles se rouvrent : l'équilibre ne revient qu'avec les reconnexions. Côté HAProxy, `balance leastconn` répartit mieux des connexions longues que le tourniquet |
104
+ | Le CPU est un mauvais signal de mise à l'échelle pour des connexions longues | un pod chargé de sockets muettes a peu de CPU et beaucoup de mémoire. L'application **n'expose aucune métrique Prometheus** : une mise à l'échelle sur le nombre de connexions demande de la produire toi-même |
105
+ | Chaque connexion est un **descripteur de fichier**, deux à travers un frontal sur la même machine | vérifier `ulimit -n` de l'hôte ou du conteneur avant de viser des dizaines de milliers de connexions par pod |
106
+
107
+ ## 5. Mesurer TON application — la seule source de `D_msg` et `D_req`
108
+
109
+ 1. **Un pod**, en production (`NODE_ENV=production`), à la taille prévue, avec ses vraies
110
+ dépendances (base, Redis). Pas de limite processeur.
111
+ 2. Une charge qui ressemble au trafic réel : tes routes, tes canaux, tes tailles de message.
112
+ `h2load` (HTTP/1.1 et h2c avec le même instrument), `k6` ou `artillery` pour le WebSocket.
113
+ 3. Monter par paliers jusqu'à ce que le CPU du conteneur plafonne autour d'**un** cœur
114
+ (`kubectl top pod`, `docker stats`) ou que le p99 décroche : le débit à ce palier est ton plafond.
115
+ 4. Rejouer **trois fois** et garder la médiane. Un écart de plus de 10 % entre runs ne tranche
116
+ rien : le décor bouge (autre charge sur la machine, base froide).
117
+ 5. Mesurer la mémoire **à connexions ouvertes et muettes**, par paliers (0, 1 000, 2 000…) : la
118
+ pente est ton `coût_socket`. Une mesure à un seul point mélange le repos et le coût.
119
+
120
+ > Le CPU d'un conteneur additionne la boucle, le ramasse-miettes et les fils d'E/S : il surestime
121
+ > un peu l'ELU. C'est sans gravité pour dimensionner — la marge `u = 0,6` le couvre — mais deux
122
+ > mesures ne se comparent qu'avec le même instrument.