@nodefony/devkit 10.0.0-beta.6 → 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.
- package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
- package/dist/index.js +1 -1
- package/dist/nodefony/controllers/DevkitController.js +1 -1
- package/dist/nodefony/controllers/McpController.js +2 -2
- package/dist/nodefony/controllers/ProfileSessionController.js +1 -1
- package/dist/nodefony/service/DevkitService.js +3 -3
- package/package.json +11 -11
- package/skills/nodefony-add-grpc-service/SKILL.md +179 -0
- package/skills/nodefony-add-grpc-service/scripts/call.mjs +218 -0
- package/skills/nodefony-add-realtime-binary/SKILL.md +301 -0
- package/skills/nodefony-add-realtime-binary/references/messages.md +20 -0
- package/skills/nodefony-add-realtime-binary/references/mesures.md +59 -0
- package/skills/nodefony-add-realtime-channel/SKILL.md +408 -326
- package/skills/nodefony-add-realtime-channel/references/contrat.md +22 -0
- package/skills/nodefony-add-realtime-channel/references/production.md +21 -2
- package/skills/nodefony-add-realtime-presence/SKILL.md +328 -0
- package/skills/nodefony-authentication/SKILL.md +2 -2
- package/skills/nodefony-browser/SKILL.md +11 -1
- package/skills/nodefony-browser/references/sondes.md +19 -0
- package/skills/nodefony-browser/scripts/audit.mjs +5 -2
- package/skills/nodefony-browser/scripts/inspect.mjs +69 -7
- package/skills/nodefony-browser/scripts/lib/actions.mjs +54 -3
- package/skills/nodefony-browser/scripts/lib/browser.mjs +18 -1
- package/skills/nodefony-browser/scripts/lib/probes.mjs +52 -3
- package/skills/nodefony-dev/SKILL.md +20 -15
- package/skills/nodefony-devops/references/dimensionnement.md +1 -1
- package/skills/nodefony-devops/references/frontal.md +5 -2
- package/skills/nodefony-devops/references/image.md +23 -15
- package/skills/nodefony-migrate-schema/references/verdicts.md +1 -0
- package/skills/nodefony-protect-route/SKILL.md +27 -32
- package/skills/nodefony-add-realtime-channel/references/binaire.md +0 -191
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
import { USER, signInElsewhere } from "./browser.mjs";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Attend que le défilement se pose : la position de la fenêtre ne bouge plus
|
|
5
|
+
* pendant trois images (une molette peut défiler en douceur). Borné à une
|
|
6
|
+
* seconde environ — jamais une attente aveugle.
|
|
7
|
+
*
|
|
8
|
+
* @param {import("playwright").Page} page - la page pilotée.
|
|
9
|
+
*/
|
|
10
|
+
async function settleScroll(page) {
|
|
11
|
+
await page.evaluate(
|
|
12
|
+
() =>
|
|
13
|
+
new Promise((resolve) => {
|
|
14
|
+
let last = window.scrollY;
|
|
15
|
+
let still = 0;
|
|
16
|
+
let frames = 0;
|
|
17
|
+
const tick = () => {
|
|
18
|
+
frames += 1;
|
|
19
|
+
still = window.scrollY === last ? still + 1 : 0;
|
|
20
|
+
last = window.scrollY;
|
|
21
|
+
if (still >= 3 || frames >= 60) resolve(undefined);
|
|
22
|
+
else requestAnimationFrame(tick);
|
|
23
|
+
};
|
|
24
|
+
requestAnimationFrame(tick);
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
3
29
|
/**
|
|
4
30
|
* Joue une séquence d'interactions (`NF_BROWSER_ACTIONS`, analysée par
|
|
5
31
|
* `parseActions`) sur une page — la seule implémentation, partagée par
|
|
@@ -28,14 +54,23 @@ export async function playActions(page, actions) {
|
|
|
28
54
|
await signInElsewhere(page.context());
|
|
29
55
|
continue;
|
|
30
56
|
}
|
|
31
|
-
if (verb === "defiler") {
|
|
57
|
+
if (verb === "defiler" || verb === "molette") {
|
|
32
58
|
const pixels = Number.parseInt(target, 10);
|
|
33
59
|
if (!Number.isFinite(pixels)) {
|
|
34
60
|
return {
|
|
35
61
|
code: 64, // EX_USAGE
|
|
36
|
-
message:
|
|
62
|
+
message: `${verb} attend un nombre de pixels, reçu « ${target} »`,
|
|
37
63
|
};
|
|
38
64
|
}
|
|
65
|
+
if (verb === "molette") {
|
|
66
|
+
// Une VRAIE molette, là où est le pointeur (`survol:` avant) : c'est
|
|
67
|
+
// le navigateur qui choisit quoi faire défiler, et qui passe à la page
|
|
68
|
+
// quand le conteneur est en bout — ce que `defiler`, qui appelle
|
|
69
|
+
// `scrollBy` sur un élément choisi, ne peut pas montrer.
|
|
70
|
+
await page.mouse.wheel(0, pixels);
|
|
71
|
+
await settleScroll(page);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
39
74
|
// Le conteneur qui défile RÉELLEMENT, pas la fenêtre : sur une console à
|
|
40
75
|
// barre latérale fixe, `window.scrollBy` ne bouge rien du tout.
|
|
41
76
|
await page.evaluate((dy) => {
|
|
@@ -47,6 +82,12 @@ export async function playActions(page, actions) {
|
|
|
47
82
|
}, pixels);
|
|
48
83
|
continue;
|
|
49
84
|
}
|
|
85
|
+
// Une touche sans cible part à l'élément qui a le focus (onglets,
|
|
86
|
+
// listes, menus : c'est lui que le clavier pilote).
|
|
87
|
+
if (verb === "touche" && target === "") {
|
|
88
|
+
await page.keyboard.press(value || "Enter");
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
50
91
|
// Le premier candidat VISIBLE, pas le premier du DOM : un libellé apparaît
|
|
51
92
|
// souvent d'abord dans un menu replié ou un gabarit caché, et agir là ne fait
|
|
52
93
|
// rien tout en passant pour un succès.
|
|
@@ -83,7 +124,13 @@ export async function playActions(page, actions) {
|
|
|
83
124
|
: candidates.first();
|
|
84
125
|
}
|
|
85
126
|
try {
|
|
86
|
-
|
|
127
|
+
// Un champ de fichier est presque toujours CACHÉ derrière un bouton
|
|
128
|
+
// (trombone, zone de dépôt) : l'attendre visible échouerait sur un champ
|
|
129
|
+
// pourtant présent. Il suffit qu'il soit attaché au document.
|
|
130
|
+
await locator.waitFor({
|
|
131
|
+
state: verb === "deposer" ? "attached" : "visible",
|
|
132
|
+
timeout: 15000,
|
|
133
|
+
});
|
|
87
134
|
if (verb === "clic") await locator.click();
|
|
88
135
|
else if (verb === "double") await locator.dblclick();
|
|
89
136
|
else if (verb === "droit") await locator.click({ button: "right" });
|
|
@@ -97,6 +144,10 @@ export async function playActions(page, actions) {
|
|
|
97
144
|
if (chosen.length === 0) throw new Error(`option « ${value} » absente`);
|
|
98
145
|
} else if (verb === "touche") await locator.press(value || "Enter");
|
|
99
146
|
else if (verb === "voir") await locator.scrollIntoViewIfNeeded();
|
|
147
|
+
// `deposer` : le fichier passe par le champ lui-même, comme un choix dans
|
|
148
|
+
// la boîte du système — la page reçoit le même `change` qu'à la main. Un
|
|
149
|
+
// chemin relatif part du répertoire courant.
|
|
150
|
+
else if (verb === "deposer") await locator.setInputFiles(value);
|
|
100
151
|
// `attendre` : le `waitFor` ci-dessus EST l'action.
|
|
101
152
|
} catch {
|
|
102
153
|
return {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* `@env` NF_BROWSER_WHOAMI chemin qui répond 2xx à une session authentifiée, et 401/403 ou un 2xx portant `{ user: null }` sinon — prouve une session reprise (défaut `/nodefony/security/api/auth/me` ; vide = aucune reprise)
|
|
16
16
|
* `@env` NF_BROWSER_ENGINE navigateur imposé (chromium, chrome, msedge, firefox, webkit) ; sans lui, le premier qui répond
|
|
17
17
|
* `@env` NF_BROWSER_COLOR_SCHEME schéma de couleurs émulé (light, dark, no-preference) ; sans lui, celui du navigateur
|
|
18
|
+
* `@env` NF_BROWSER_REDUCED_MOTION préférence de mouvement émulée (reduce, no-preference) — `reduce` mesure une page à fondus au repos
|
|
18
19
|
* `@env` NF_BROWSER_STORAGE entrées de stockage local posées AVANT chargement (`clé=valeur`, séparées par des virgules)
|
|
19
20
|
*/
|
|
20
21
|
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
browserOrder,
|
|
27
28
|
sessionVerdict,
|
|
28
29
|
parseColorScheme,
|
|
30
|
+
parseReducedMotion,
|
|
29
31
|
parseStorage,
|
|
30
32
|
parseViewport,
|
|
31
33
|
} from "./probes.mjs";
|
|
@@ -193,6 +195,16 @@ if (invalidScheme) {
|
|
|
193
195
|
);
|
|
194
196
|
process.exit(64); // EX_USAGE
|
|
195
197
|
}
|
|
198
|
+
const { motion: REDUCED_MOTION, invalid: invalidMotion } = parseReducedMotion(
|
|
199
|
+
process.env.NF_BROWSER_REDUCED_MOTION,
|
|
200
|
+
);
|
|
201
|
+
if (invalidMotion) {
|
|
202
|
+
console.error(
|
|
203
|
+
`NF_BROWSER_REDUCED_MOTION inconnu : « ${invalidMotion} ».\n` +
|
|
204
|
+
"Valeurs acceptées (celles de la média query standard) : reduce, no-preference.",
|
|
205
|
+
);
|
|
206
|
+
process.exit(64); // EX_USAGE
|
|
207
|
+
}
|
|
196
208
|
const { entries: STORAGE, rejected: storageRejected } = parseStorage(
|
|
197
209
|
process.env.NF_BROWSER_STORAGE,
|
|
198
210
|
);
|
|
@@ -209,10 +221,13 @@ if (storageRejected.length > 0) {
|
|
|
209
221
|
*
|
|
210
222
|
* @param options.anonymous - ne reprendre AUCUN état d'authentification : la
|
|
211
223
|
* séquence se connecte elle-même en route (`connecter:`).
|
|
224
|
+
* @param options.permissions - permissions accordées d'avance à toutes les
|
|
225
|
+
* origines (`notifications`…) : une page qui les DEMANDE les reçoit sans
|
|
226
|
+
* boîte de dialogue — un navigateur piloté n'en montre aucune.
|
|
212
227
|
* @returns `{ browser, ctx, page, reuse }` — `reuse` dit si un état
|
|
213
228
|
* d'authentification a été repris, ce qui décide s'il faut se connecter.
|
|
214
229
|
*/
|
|
215
|
-
export async function open({ anonymous = false } = {}) {
|
|
230
|
+
export async function open({ anonymous = false, permissions = [] } = {}) {
|
|
216
231
|
// On CONSTATE quel navigateur répond, au lieu de supposer lequel est là.
|
|
217
232
|
//
|
|
218
233
|
// `channel` demande un navigateur COMPLET plutôt que le
|
|
@@ -261,6 +276,8 @@ export async function open({ anonymous = false } = {}) {
|
|
|
261
276
|
? { isMobile: true, hasTouch: true, deviceScaleFactor: 2 }
|
|
262
277
|
: {}),
|
|
263
278
|
...(COLOR_SCHEME ? { colorScheme: COLOR_SCHEME } : {}),
|
|
279
|
+
...(REDUCED_MOTION ? { reducedMotion: REDUCED_MOTION } : {}),
|
|
280
|
+
...(permissions.length > 0 ? { permissions } : {}),
|
|
264
281
|
};
|
|
265
282
|
let reuse = !anonymous && Boolean(USER) && existsSync(STATE);
|
|
266
283
|
let ctx = null;
|
|
@@ -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,9 +157,11 @@ 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",
|
|
162
166
|
"connecter",
|
|
163
167
|
];
|
|
@@ -190,8 +194,9 @@ export function signsInMidway(actions) {
|
|
|
190
194
|
* s'arrêtait en annonçant un élément introuvable, ce qui envoyait chercher un
|
|
191
195
|
* défaut dans la page. D'où deux règles :
|
|
192
196
|
*
|
|
193
|
-
* 1. seuls les verbes qui PRENNENT une valeur (`saisir`, `choisir`
|
|
194
|
-
*
|
|
197
|
+
* 1. seuls les verbes qui PRENNENT une valeur (`saisir`, `choisir`, `touche`,
|
|
198
|
+
* `deposer`)
|
|
199
|
+
* découpent sur `=` ;
|
|
195
200
|
* pour tous les autres, la cible est le reste entier ;
|
|
196
201
|
* 2. même pour ceux-là, la coupure ignore les `=` situés à l'intérieur de
|
|
197
202
|
* crochets — `saisir:input[name=q]=bonjour` saisit bien « bonjour » dans
|
|
@@ -223,6 +228,11 @@ export function parseActions(raw) {
|
|
|
223
228
|
else if (c === "]") depth = Math.max(0, depth - 1);
|
|
224
229
|
else if (c === "=" && depth === 0) cut = i;
|
|
225
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
|
+
}
|
|
226
236
|
return cut === -1
|
|
227
237
|
? { verb, target: rest.trim(), value: "" }
|
|
228
238
|
: {
|
|
@@ -256,6 +266,28 @@ export function parseColorScheme(raw) {
|
|
|
256
266
|
return { schema: null, invalid: v };
|
|
257
267
|
}
|
|
258
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
|
+
|
|
259
291
|
/**
|
|
260
292
|
* Rend une expression JS fournie par l'utilisateur ÉVALUABLE dans la page —
|
|
261
293
|
* une forme fonction (`() => x`, `function`, `async`) y est APPELÉE.
|
|
@@ -765,3 +797,20 @@ export function browserEngine(name) {
|
|
|
765
797
|
return { engine: v, channel: undefined };
|
|
766
798
|
return { engine: "chromium", channel: name.trim() };
|
|
767
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
|
+
}
|
|
@@ -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
|
|
24
|
-
>
|
|
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
|
|
45
|
-
|
|
|
46
|
-
| Une ressource : table, validation, service, REST et WebSocket
|
|
47
|
-
| Une logique métier réutilisable, hors de tout controller
|
|
48
|
-
|
|
|
49
|
-
|
|
|
50
|
-
|
|
|
51
|
-
|
|
|
52
|
-
|
|
|
53
|
-
|
|
|
54
|
-
|
|
|
55
|
-
|
|
|
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
|
|
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
|
|
|
@@ -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.
|
|
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 —
|
|
210
|
+
### nginx — HTTP/1.1 vers l'application, gRPC vers h2c
|
|
211
211
|
|
|
212
|
-
nginx ne parle
|
|
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
|
|
11
|
-
| ----------- |
|
|
12
|
-
| `build` | `node:24-
|
|
13
|
-
| `proxyconf` | hérite de `build`
|
|
14
|
-
| `edge` | `nginx:1.30-alpine`
|
|
15
|
-
|
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
|
80
|
-
|
|
|
81
|
-
|
|
|
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.
|
|
@@ -130,6 +130,7 @@ n'a jamais les deux — c'est le discriminant à tester.
|
|
|
130
130
|
| `NF_GENERATE_MISSING_ENTITY` | une entité est enregistrée sans fichier qui la fournisse | `nodefony inspect entities --json` pour la situer |
|
|
131
131
|
| `NF_GENERATE_UNREADABLE_ENTITY` | rien d'écrit, et un fichier d'entités ne s'importe pas : sa table n'est vue par personne | corriger la cause nommée (souvent un paquet absent des dépendances), ou sortir le fichier de `nodefony/entity/` |
|
|
132
132
|
| `NF_GENERATE_FRAMEWORK_TABLE` | une entité de l'application usurpe une table du framework | la renommer, ou écrire une migration libre (`--custom`) |
|
|
133
|
+
| `NF_GENERATE_DEV_MODULE_TABLE` | un module `policy: "dev"` lie ses tables au connecteur généré : elles iraient en base de production | lui donner un connecteur à lui, sans migration ; ou retirer sa politique `dev` |
|
|
133
134
|
| `NF_GENERATE_DESTRUCTIVE` | la migration ÉCRITE supprime des données | relire le fichier, puis annuler (`git checkout`) ou appliquer — la mise en service a sa propre garde |
|
|
134
135
|
| `NF_GENERATE_DATABASE_BEHIND` | rien à écrire, RIEN n'attend, et pourtant la base ne porte pas le schéma | `repair --forget <source>/<tag>` : l'historique affirme une migration jamais exécutée |
|
|
135
136
|
| `NF_GENERATE_TOOL_MISSING` | l'outil de génération n'est pas installé | l'installer en dépendance de développement |
|
|
@@ -245,10 +245,10 @@ Un service partenaire, un script, un agent ne stockent aucun cookie. Le
|
|
|
245
245
|
`nodefony/config/security.ts` généré (preset `complete`) pose **deux zones, une par sorte de
|
|
246
246
|
preuve** — choisis selon ce que l'appelant détient, n'en ajoute pas une troisième :
|
|
247
247
|
|
|
248
|
-
| L'appelant présente
|
|
249
|
-
|
|
|
250
|
-
| une **clé d'API** opaque (`nf_…`)
|
|
251
|
-
| un **jeton d'accès**
|
|
248
|
+
| L'appelant présente | Zone | Préfixe | Obtenir la preuve |
|
|
249
|
+
| ------------------------------------ | --------- | -------------- | ---------------------------------- |
|
|
250
|
+
| une **clé d'API** opaque (`nf_…`) | `machine` | `/api/machine` | `POST /nodefony/security/api/keys` |
|
|
251
|
+
| un **jeton d'accès** signé par l'app | `service` | `/api/service` | émis par l'app (voir la démo) |
|
|
252
252
|
|
|
253
253
|
### Clé d'API : la zone `machine`
|
|
254
254
|
|
|
@@ -275,27 +275,30 @@ qui ne stocke pas de cookie ne doit rien recevoir qu'il faille stocker.**
|
|
|
275
275
|
|
|
276
276
|
Les clés s'émettent par `POST /nodefony/security/api/keys`.
|
|
277
277
|
|
|
278
|
-
### Jeton d'accès : la zone `
|
|
278
|
+
### Jeton d'accès : la zone `service`
|
|
279
279
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
npx nodefony app:partner:call # jeton signé par l'app pour `admin` (--as <compte>)
|
|
287
|
-
npx nodefony app:partner:call --issuer keycloak # client <app>-machine, flux client_credentials
|
|
280
|
+
```ts
|
|
281
|
+
service: {
|
|
282
|
+
pattern: "^/api/service",
|
|
283
|
+
authenticators: ["jwt"], // jeton signé par CETTE application
|
|
284
|
+
stateless: true,
|
|
285
|
+
}
|
|
288
286
|
```
|
|
289
287
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
288
|
+
Une route qui tombe sous `/api/service` est protégée sans rien ajouter. L'application générée
|
|
289
|
+
n'y met AUCUN controller : l'exemple complet — émission du jeton, appel, et la variante Keycloak
|
|
290
|
+
(`client_credentials`) — vit dans la démo, qu'on copie dans l'application pour la lire.
|
|
291
|
+
|
|
292
|
+
**Accepter aussi les jetons d'un fournisseur** (Keycloak) : ajouter `"external-jwt"` à la zone ;
|
|
293
|
+
`jwt` et `external-jwt` s'aiguillent sur l'émetteur du jeton, leur ordre ne décide de rien. Un jeton
|
|
294
|
+
Keycloak s'obtient par `requestClientCredentialsToken` de `@nodefony/security` — découverte du point
|
|
295
|
+
de jeton comprise —, jamais par un `fetch` recopié.
|
|
294
296
|
|
|
295
297
|
🔴 **L'audience vit en UN endroit.** Un jeton n'ouvre que la zone dont il porte la ressource
|
|
296
|
-
(RFC 8707).
|
|
297
|
-
|
|
298
|
-
|
|
298
|
+
(RFC 8707). Écris-la UNE fois (une fonction de `nodefony/config/security.ts`, sur le modèle de
|
|
299
|
+
`publicBaseUrl`), lue par la zone (`resource`) et par `jwt.audiences`. Une ressource recopiée à la
|
|
300
|
+
main finit par différer, et le seul symptôme est un refus sans explication. Ouvrir une ressource
|
|
301
|
+
à des jetons, c'est trois gestes, pas un :
|
|
299
302
|
|
|
300
303
|
| Oublié | Symptôme |
|
|
301
304
|
| ----------------------------------------- | ------------------------------------------------------------- |
|
|
@@ -303,19 +306,11 @@ sans explication. Ouvrir une AUTRE ressource à des jetons, c'est trois gestes,
|
|
|
303
306
|
| `security:keycloak:realm --write` relancé | **401** du jeton Keycloak : le realm n'inscrit pas l'audience |
|
|
304
307
|
| la `resource` sur la zone | l'application **refuse de démarrer** en nommant la zone |
|
|
305
308
|
|
|
306
|
-
🔴 **`subjectPolicy: "ephemeral"` s'écrit SUR LA ZONE, jamais dans `resourceServer`.**
|
|
309
|
+
🔴 **`subjectPolicy: "ephemeral"` s'écrit SUR LA ZONE, jamais dans `resourceServer`.** Un client
|
|
307
310
|
machine de Keycloak n'a pas de compte ici : `ephemeral` l'accepte le temps de la requête, avec les
|
|
308
|
-
seuls `ephemeralRoles` de la zone
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
`whoami` n'exige pas `ROLE_PARTNER` — un compte de l'app ne l'a pas. Pour réserver une action aux
|
|
313
|
-
partenaires : `@IsGranted("ROLE_PARTNER")`.
|
|
314
|
-
|
|
315
|
-
**Prouver** : `--issuer nodefony` rend 200 sans Keycloak ; `--issuer keycloak` rend 200 avec le
|
|
316
|
-
profil `keycloak` du compose allumé ; et le MÊME jeton machine est refusé (401) sur une zone qui
|
|
317
|
-
garde `subjectPolicy` par défaut (`require`). Chaque échec de la commande sort en code 1 avec le
|
|
318
|
-
geste qui répare — lis-le avant de chercher ailleurs.
|
|
311
|
+
seuls `ephemeralRoles` de la zone. Posé dans `resourceServer.subjectPolicy` (global), il ferait
|
|
312
|
+
accepter un appelant sans compte par **toutes** les zones `external-jwt`. Et un rôle fort dans
|
|
313
|
+
`ephemeralRoles` est accordé à **quiconque** détient un jeton valide du realm.
|
|
319
314
|
|
|
320
315
|
## Les gestes qui affaiblissent en silence
|
|
321
316
|
|