@nodefony/devkit 10.0.0-beta.5 → 10.0.0-beta.6
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.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorateParam.js +1 -1
- package/dist/index.js +3 -3
- 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/mcp/profileRoute.js +28 -9
- package/dist/nodefony/service/DevkitService.js +4 -4
- package/docs/index.md +1 -1
- package/package.json +11 -11
- package/skills/nodefony-add-crud/SKILL.md +38 -23
- package/skills/nodefony-add-realtime-channel/SKILL.md +376 -111
- package/skills/nodefony-add-realtime-channel/references/binaire.md +191 -0
- package/skills/nodefony-add-realtime-channel/references/production.md +121 -0
- package/skills/nodefony-browser/SKILL.md +27 -2
- package/skills/nodefony-browser/scripts/audit.mjs +12 -0
- package/skills/nodefony-browser/scripts/inspect.mjs +40 -81
- package/skills/nodefony-browser/scripts/lib/actions.mjs +113 -0
- package/skills/nodefony-browser/scripts/lib/browser.mjs +50 -11
- package/skills/nodefony-browser/scripts/lib/probes.mjs +56 -0
- package/skills/nodefony-browser/scripts/watch.mjs +27 -11
- package/skills/nodefony-dev/SKILL.md +11 -0
- package/skills/nodefony-dev/references/rapport-de-build.md +19 -0
- package/skills/nodefony-devops/SKILL.md +28 -21
- package/skills/nodefony-migrate-schema/SKILL.md +110 -20
- package/skills/nodefony-migrate-schema/references/verdicts.md +22 -20
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { USER, signInElsewhere } from "./browser.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Joue une séquence d'interactions (`NF_BROWSER_ACTIONS`, analysée par
|
|
5
|
+
* `parseActions`) sur une page — la seule implémentation, partagée par
|
|
6
|
+
* `inspect.mjs` (agir PUIS photographier) et `watch.mjs` (agir PENDANT qu'on
|
|
7
|
+
* observe le trafic).
|
|
8
|
+
*
|
|
9
|
+
* Une action qui ne trouve pas sa cible ARRÊTE la séquence : une mesure faite
|
|
10
|
+
* sur un écran qu'on n'a pas ouvert est pire qu'aucune mesure.
|
|
11
|
+
*
|
|
12
|
+
* @param {import("playwright").Page} page - la page pilotée.
|
|
13
|
+
* @param {{ verb: string, target: string, value?: string }[]} actions - la séquence.
|
|
14
|
+
* @returns {Promise<{ code: number, message: string } | null>} `null` si tout a
|
|
15
|
+
* été joué ; sinon le code de sortie à rendre (64 usage, 65 cible
|
|
16
|
+
* introuvable) et le message qui nomme l'action.
|
|
17
|
+
*/
|
|
18
|
+
export async function playActions(page, actions) {
|
|
19
|
+
for (const { verb, target, value } of actions) {
|
|
20
|
+
if (verb === "connecter") {
|
|
21
|
+
if (!USER) {
|
|
22
|
+
return {
|
|
23
|
+
code: 64, // EX_USAGE
|
|
24
|
+
message:
|
|
25
|
+
"connecter: exige NF_BROWSER_USER, NF_BROWSER_PASSWORD et NF_BROWSER_LOGIN",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
await signInElsewhere(page.context());
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (verb === "defiler") {
|
|
32
|
+
const pixels = Number.parseInt(target, 10);
|
|
33
|
+
if (!Number.isFinite(pixels)) {
|
|
34
|
+
return {
|
|
35
|
+
code: 64, // EX_USAGE
|
|
36
|
+
message: `defiler attend un nombre de pixels, reçu « ${target} »`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// Le conteneur qui défile RÉELLEMENT, pas la fenêtre : sur une console à
|
|
40
|
+
// barre latérale fixe, `window.scrollBy` ne bouge rien du tout.
|
|
41
|
+
await page.evaluate((dy) => {
|
|
42
|
+
const scrollable = [...document.querySelectorAll("*")].find(
|
|
43
|
+
(el) =>
|
|
44
|
+
el.scrollHeight > el.clientHeight + 40 && el.clientHeight > 200,
|
|
45
|
+
);
|
|
46
|
+
(scrollable ?? window).scrollBy(0, dy);
|
|
47
|
+
}, pixels);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
// Le premier candidat VISIBLE, pas le premier du DOM : un libellé apparaît
|
|
51
|
+
// souvent d'abord dans un menu replié ou un gabarit caché, et agir là ne fait
|
|
52
|
+
// rien tout en passant pour un succès.
|
|
53
|
+
const candidates = page.getByText(target, { exact: false });
|
|
54
|
+
let locator = null;
|
|
55
|
+
const total = await candidates.count();
|
|
56
|
+
for (let i = 0; i < total; i += 1) {
|
|
57
|
+
const c = candidates.nth(i);
|
|
58
|
+
if (await c.isVisible().catch(() => false)) {
|
|
59
|
+
locator = c;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Aucun candidat visible À CET INSTANT ne dit pas que la cible est un
|
|
64
|
+
// sélecteur : juste après un clic qui navigue, la page suivante n'est pas
|
|
65
|
+
// encore rendue. On attend donc le texte OU le sélecteur — et le texte seul
|
|
66
|
+
// quand la cible n'est pas du CSS valide. Trancher sur l'instant faisait
|
|
67
|
+
// chercher une balise `<FreeOTP>` une fois sur trois.
|
|
68
|
+
if (locator === null) {
|
|
69
|
+
// Une navigation en vol détruirait le contexte de l'évaluation ci-dessous.
|
|
70
|
+
await page.waitForLoadState("domcontentloaded").catch(() => {});
|
|
71
|
+
const isCss = await page
|
|
72
|
+
.evaluate((s) => {
|
|
73
|
+
try {
|
|
74
|
+
document.querySelector(s);
|
|
75
|
+
return true;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}, target)
|
|
80
|
+
.catch(() => false);
|
|
81
|
+
locator = isCss
|
|
82
|
+
? candidates.or(page.locator(target)).first()
|
|
83
|
+
: candidates.first();
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
await locator.waitFor({ timeout: 15000 });
|
|
87
|
+
if (verb === "clic") await locator.click();
|
|
88
|
+
else if (verb === "double") await locator.dblclick();
|
|
89
|
+
else if (verb === "droit") await locator.click({ button: "right" });
|
|
90
|
+
else if (verb === "survol") await locator.hover();
|
|
91
|
+
else if (verb === "saisir") await locator.fill(value);
|
|
92
|
+
// `choisir` : la cible peut être le `<label>` qui ENVELOPPE la liste —
|
|
93
|
+
// Playwright reporte alors le choix sur son contrôle. La valeur est celle
|
|
94
|
+
// de l'option (`value`), sinon son libellé.
|
|
95
|
+
else if (verb === "choisir") {
|
|
96
|
+
const chosen = await locator.selectOption(value);
|
|
97
|
+
if (chosen.length === 0) throw new Error(`option « ${value} » absente`);
|
|
98
|
+
} else if (verb === "touche") await locator.press(value || "Enter");
|
|
99
|
+
else if (verb === "voir") await locator.scrollIntoViewIfNeeded();
|
|
100
|
+
// `attendre` : le `waitFor` ci-dessus EST l'action.
|
|
101
|
+
} catch {
|
|
102
|
+
return {
|
|
103
|
+
code: 65, // EX_DATAERR
|
|
104
|
+
message:
|
|
105
|
+
`Action « ${verb}:${target} » impossible\n` +
|
|
106
|
+
`Page réellement ouverte : ${page.url()}\n` +
|
|
107
|
+
"→ le libellé a changé, l'élément n'est pas encore rendu, ou il faut " +
|
|
108
|
+
"agir sur autre chose avant lui (NF_BROWSER_ACTIONS accepte une séquence, séparée par « | »).",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* `@env` NF_BROWSER_USER identifiant ; sans lui, aucune authentification n'est tentée
|
|
14
14
|
* `@env` NF_BROWSER_PASSWORD mot de passe associé
|
|
15
15
|
* `@env` NF_BROWSER_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
|
-
* `@env` NF_BROWSER_ENGINE navigateur imposé (chromium, chrome, msedge) ; sans lui, le premier qui répond
|
|
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
18
|
* `@env` NF_BROWSER_STORAGE entrées de stockage local posées AVANT chargement (`clé=valeur`, séparées par des virgules)
|
|
19
19
|
*/
|
|
@@ -22,6 +22,7 @@ import path from "node:path";
|
|
|
22
22
|
import {
|
|
23
23
|
environmentDefaults,
|
|
24
24
|
authStateName,
|
|
25
|
+
browserEngine,
|
|
25
26
|
browserOrder,
|
|
26
27
|
sessionVerdict,
|
|
27
28
|
parseColorScheme,
|
|
@@ -38,9 +39,10 @@ import {
|
|
|
38
39
|
* en pair optionnel — et un `MODULE_NOT_FOUND` nu, qui ne dit ni quoi
|
|
39
40
|
* installer ni pourquoi, n'est pas une réponse acceptable.
|
|
40
41
|
*/
|
|
41
|
-
let
|
|
42
|
+
let launchers;
|
|
42
43
|
try {
|
|
43
|
-
|
|
44
|
+
const { chromium, firefox, webkit } = await import("playwright");
|
|
45
|
+
launchers = { chromium, firefox, webkit };
|
|
44
46
|
} catch {
|
|
45
47
|
console.error(
|
|
46
48
|
"Playwright est absent — c'est lui qui pilote le navigateur.\n\n" +
|
|
@@ -205,10 +207,12 @@ if (storageRejected.length > 0) {
|
|
|
205
207
|
/**
|
|
206
208
|
* Ouvre un navigateur et un contexte prêts à mesurer.
|
|
207
209
|
*
|
|
210
|
+
* @param options.anonymous - ne reprendre AUCUN état d'authentification : la
|
|
211
|
+
* séquence se connecte elle-même en route (`connecter:`).
|
|
208
212
|
* @returns `{ browser, ctx, page, reuse }` — `reuse` dit si un état
|
|
209
213
|
* d'authentification a été repris, ce qui décide s'il faut se connecter.
|
|
210
214
|
*/
|
|
211
|
-
export async function open() {
|
|
215
|
+
export async function open({ anonymous = false } = {}) {
|
|
212
216
|
// On CONSTATE quel navigateur répond, au lieu de supposer lequel est là.
|
|
213
217
|
//
|
|
214
218
|
// `channel` demande un navigateur COMPLET plutôt que le
|
|
@@ -222,10 +226,14 @@ export async function open() {
|
|
|
222
226
|
const failures = [];
|
|
223
227
|
for (const channel of BROWSERS) {
|
|
224
228
|
try {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
+
// `--no-sandbox` est un drapeau de Chromium : Firefox et WebKit n'en
|
|
230
|
+
// ont pas l'usage, et le pilote ne leur passe aucun canal.
|
|
231
|
+
const { engine, channel: variant } = browserEngine(channel);
|
|
232
|
+
browser = await launchers[engine].launch(
|
|
233
|
+
engine === "chromium"
|
|
234
|
+
? { channel: variant, args: ["--no-sandbox"] }
|
|
235
|
+
: {},
|
|
236
|
+
);
|
|
229
237
|
browserUsed = channel;
|
|
230
238
|
break;
|
|
231
239
|
} catch (e) {
|
|
@@ -238,7 +246,8 @@ export async function open() {
|
|
|
238
246
|
`${failures.join("\n")}\n\n` +
|
|
239
247
|
"Installer celui du pilote (une fois par machine, partagé par tous tes projets) :\n\n" +
|
|
240
248
|
" npx playwright install chromium\n\n" +
|
|
241
|
-
"Ou viser un navigateur déjà présent : NF_BROWSER_ENGINE=chrome (ou msedge)
|
|
249
|
+
"Ou viser un navigateur déjà présent : NF_BROWSER_ENGINE=chrome (ou msedge).\n" +
|
|
250
|
+
"Autre moteur : NF_BROWSER_ENGINE=firefox (ou webkit), après npx playwright install firefox.",
|
|
242
251
|
);
|
|
243
252
|
process.exit(69); // EX_UNAVAILABLE
|
|
244
253
|
}
|
|
@@ -253,7 +262,7 @@ export async function open() {
|
|
|
253
262
|
: {}),
|
|
254
263
|
...(COLOR_SCHEME ? { colorScheme: COLOR_SCHEME } : {}),
|
|
255
264
|
};
|
|
256
|
-
let reuse = Boolean(USER) && existsSync(STATE);
|
|
265
|
+
let reuse = !anonymous && Boolean(USER) && existsSync(STATE);
|
|
257
266
|
let ctx = null;
|
|
258
267
|
if (reuse) {
|
|
259
268
|
try {
|
|
@@ -411,6 +420,24 @@ export async function signIn(page, ctx) {
|
|
|
411
420
|
await ctx.storageState({ path: STATE });
|
|
412
421
|
}
|
|
413
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Se connecte dans un AUTRE onglet du même contexte, puis le referme — la page
|
|
425
|
+
* mesurée n'est ni rechargée ni quittée. C'est le geste d'un utilisateur qui
|
|
426
|
+
* ouvre sa session ailleurs (console, autre onglet) pendant qu'une page déjà
|
|
427
|
+
* ouverte doit le constater d'elle-même : les cookies sont partagés, rien
|
|
428
|
+
* d'autre.
|
|
429
|
+
*
|
|
430
|
+
* @param ctx - le contexte de la page mesurée.
|
|
431
|
+
*/
|
|
432
|
+
export async function signInElsewhere(ctx) {
|
|
433
|
+
const tab = await ctx.newPage();
|
|
434
|
+
try {
|
|
435
|
+
await signIn(tab, ctx);
|
|
436
|
+
} finally {
|
|
437
|
+
await tab.close();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
414
441
|
/**
|
|
415
442
|
* Ouvre la page demandée, en garantissant que c'est bien ELLE qu'on mesure.
|
|
416
443
|
*
|
|
@@ -424,8 +451,20 @@ export async function signIn(page, ctx) {
|
|
|
424
451
|
* @param ctx - son contexte.
|
|
425
452
|
* @param pathname - le chemin à ouvrir, relatif à l'origine.
|
|
426
453
|
* @param reuse - un état d'authentification a-t-il été repris au lancement.
|
|
454
|
+
* @param options.anonymous - ouvrir la page SANS se connecter d'abord : la
|
|
455
|
+
* séquence s'en charge en route (`connecter:`).
|
|
427
456
|
*/
|
|
428
|
-
export async function goTo(
|
|
457
|
+
export async function goTo(
|
|
458
|
+
page,
|
|
459
|
+
ctx,
|
|
460
|
+
pathname,
|
|
461
|
+
reuse,
|
|
462
|
+
{ anonymous = false } = {},
|
|
463
|
+
) {
|
|
464
|
+
if (anonymous) {
|
|
465
|
+
await page.goto(`${BASE}${pathname}`, { waitUntil: "domcontentloaded" });
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
429
468
|
if (USER && !reuse) await signIn(page, ctx);
|
|
430
469
|
await page.goto(`${BASE}${pathname}`, { waitUntil: "domcontentloaded" });
|
|
431
470
|
if (USER && reuse) {
|
|
@@ -159,8 +159,24 @@ export const ACTION_VERBS = [
|
|
|
159
159
|
"voir",
|
|
160
160
|
"defiler",
|
|
161
161
|
"attendre",
|
|
162
|
+
"connecter",
|
|
162
163
|
];
|
|
163
164
|
|
|
165
|
+
/**
|
|
166
|
+
* La séquence se connecte-t-elle EN COURS de route (`connecter:`) ?
|
|
167
|
+
*
|
|
168
|
+
* Alors la page doit s'ouvrir ANONYME — ni état d'authentification repris, ni
|
|
169
|
+
* connexion préalable : c'est précisément le passage « sans session » →
|
|
170
|
+
* « avec session », sans recharger, qu'on veut observer. Se connecter avant
|
|
171
|
+
* aurait rendu l'action sans objet, et la mesure verte pour la mauvaise raison.
|
|
172
|
+
*
|
|
173
|
+
* @param actions - la séquence analysée par `parseActions`.
|
|
174
|
+
* @returns `true` si une action `connecter` y figure.
|
|
175
|
+
*/
|
|
176
|
+
export function signsInMidway(actions) {
|
|
177
|
+
return actions.some((a) => a.verb === "connecter");
|
|
178
|
+
}
|
|
179
|
+
|
|
164
180
|
/**
|
|
165
181
|
* Analyse une séquence d'actions (`NF_BROWSER_ACTIONS`).
|
|
166
182
|
*
|
|
@@ -240,6 +256,25 @@ export function parseColorScheme(raw) {
|
|
|
240
256
|
return { schema: null, invalid: v };
|
|
241
257
|
}
|
|
242
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Rend une expression JS fournie par l'utilisateur ÉVALUABLE dans la page —
|
|
261
|
+
* une forme fonction (`() => x`, `function`, `async`) y est APPELÉE.
|
|
262
|
+
*
|
|
263
|
+
* Le piège est réel : `page.evaluate` et `waitForFunction` évaluent une chaîne
|
|
264
|
+
* comme une EXPRESSION. Donner « () => x » y définit une fonction sans jamais
|
|
265
|
+
* l'appeler : l'objet fonction est truthy (une attente réussit TOUJOURS) et ne
|
|
266
|
+
* se sérialise pas (une lecture rend `undefined`). Faux vert vécu, découvert
|
|
267
|
+
* seulement en éprouvant le sens négatif.
|
|
268
|
+
*
|
|
269
|
+
* @param {string|undefined} raw - expression brute (`NF_BROWSER_UNTIL`, `NF_BROWSER_EVAL`).
|
|
270
|
+
* @returns {string} l'expression à évaluer, vide si rien n'est demandé.
|
|
271
|
+
*/
|
|
272
|
+
export function pageExpression(raw) {
|
|
273
|
+
const v = (raw ?? "").trim();
|
|
274
|
+
if (!v) return "";
|
|
275
|
+
return /^(\(|function\b|async\b)/.test(v) ? `(${v})()` : v;
|
|
276
|
+
}
|
|
277
|
+
|
|
243
278
|
/**
|
|
244
279
|
* Analyse une coupure réseau demandée (`NF_BROWSER_CUT`), au format
|
|
245
280
|
* `<début>:<durée>` en millisecondes — `4000:3000` coupe le réseau 4 s après
|
|
@@ -709,3 +744,24 @@ export function browserOrder(explicit) {
|
|
|
709
744
|
if (v) return [v];
|
|
710
745
|
return ["chromium", "chrome", "msedge"];
|
|
711
746
|
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Traduit un nom de navigateur en MOTEUR du pilote et, pour Chromium, en canal.
|
|
750
|
+
*
|
|
751
|
+
* Un comportement que dicte une norme — la politique de sécurité du contenu, le
|
|
752
|
+
* rapport de violation, un cookie `SameSite` — diverge d'un moteur à l'autre :
|
|
753
|
+
* le constater sous Chromium seul ne dit rien de Firefox ni de Safari. `firefox`
|
|
754
|
+
* et `webkit` désignent donc les moteurs que le pilote installe lui-même
|
|
755
|
+
* (`npx playwright install firefox`) ; tout autre nom reste un canal de
|
|
756
|
+
* Chromium (`chrome`, `msedge`, `chrome-beta`…), comme avant.
|
|
757
|
+
*
|
|
758
|
+
* @param {string} name - un nom rendu par `browserOrder`.
|
|
759
|
+
* @returns {{ engine: "chromium"|"firefox"|"webkit", channel: string|undefined }}
|
|
760
|
+
* le moteur à lancer, et le canal Chromium (absent pour les deux autres).
|
|
761
|
+
*/
|
|
762
|
+
export function browserEngine(name) {
|
|
763
|
+
const v = name.trim().toLowerCase();
|
|
764
|
+
if (v === "firefox" || v === "webkit")
|
|
765
|
+
return { engine: v, channel: undefined };
|
|
766
|
+
return { engine: "chromium", channel: name.trim() };
|
|
767
|
+
}
|
|
@@ -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 {
|
|
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
|
|
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
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
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`;
|
|
@@ -456,6 +456,13 @@ Ne concerne qu'une application qui a un frontend. Sauf mention, **vaut pour les
|
|
|
456
456
|
sur les scripts, et le navigateur refuse un `<script>` en ligne qui n'en porte pas (« Refused to
|
|
457
457
|
execute inline script »). Un `curl` ne le voit JAMAIS, il ne lit que le corps. Signe le script
|
|
458
458
|
(valeur `this.context?.cspNonce`) ou sors-le dans un fichier servi ; ne desserre PAS la politique.
|
|
459
|
+
- **Une image, une police ou une API tierce est bloquée** (« violates the following Content
|
|
460
|
+
Security Policy directive ») : AJOUTE l'origine exacte par `headers.cspSources` de
|
|
461
|
+
`@nodefony/security` (`{ cdn: { "img-src": ["https://cdn.example.com"] } }`), ou sur la seule
|
|
462
|
+
route qui en a besoin par `@Csp` — ne réécris pas `headers.csp`. Une socket servie depuis une
|
|
463
|
+
AUTRE origine se déclare par `realtime.publicOrigin`. Ce que les navigateurs ont bloqué se lit,
|
|
464
|
+
en administrateur, sur `GET /nodefony/security/api/csp-reports` (`headers.cspReport`) — Brave
|
|
465
|
+
n'envoie aucun rapport, éprouve sous Chrome ou Firefox.
|
|
459
466
|
- **En production, la modification front n'apparaît jamais** : hors développement il n'y a PAS de
|
|
460
467
|
rechargement à chaud, et le manifeste est lu AU BOOT. `npm run build`, puis **redémarre le
|
|
461
468
|
serveur**, puis rechargement forcé.
|
|
@@ -474,6 +481,10 @@ npm run verify # types, style, mise en forme, .env.example, tests, build,
|
|
|
474
481
|
npm run test:e2e # boot réel + HTTP/WS — le gate LENT, hors `verify`
|
|
475
482
|
```
|
|
476
483
|
|
|
484
|
+
Ton diff change ce que le build produit (taille, dépendance, build plus lent) ? Chaque build
|
|
485
|
+
écrit un rapport : lis [`references/rapport-de-build.md`](references/rapport-de-build.md) avant de
|
|
486
|
+
recalculer quoi que ce soit à la main.
|
|
487
|
+
|
|
477
488
|
Ton diff ajoute une route, une requête ou touche la sécurité ? Relis-le AVANT de commiter :
|
|
478
489
|
skill `nodefony-security-review` — un script y nomme, ligne par ligne, ce qu'un œil laisse passer.
|
|
479
490
|
|
|
@@ -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.
|
|
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
|
|
120
|
-
|
|
|
121
|
-
| L'exemplaire redémarre en boucle, journaux muets
|
|
122
|
-
|
|
|
123
|
-
|
|
|
124
|
-
| Le
|
|
125
|
-
|
|
|
126
|
-
|
|
|
127
|
-
|
|
|
128
|
-
| Depuis l'activation de h2c,
|
|
129
|
-
|
|
|
130
|
-
|
|
|
131
|
-
|
|
|
132
|
-
|
|
|
133
|
-
|
|
|
134
|
-
|
|
|
135
|
-
|
|
|
136
|
-
| La
|
|
137
|
-
|
|
|
138
|
-
|
|
|
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
|
|