@nodefony/devkit 10.0.0-alpha.9 → 10.0.0-beta.1

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/index.js CHANGED
@@ -33,7 +33,8 @@ let DevkitModule = class DevkitModule extends Module {
33
33
  * fautif nommé, plutôt qu'en `undefined.x` au premier appel en production.
34
34
  */
35
35
  async onKernelRegister() {
36
- this.options = defineDevkitConfig(this.options ?? {});
36
+ const input = this.options;
37
+ this.options = defineDevkitConfig(input ?? {});
37
38
  return this;
38
39
  }
39
40
  };
@@ -57,7 +57,8 @@ let DevkitService = class DevkitService extends Service {
57
57
  module;
58
58
  cfg;
59
59
  constructor(module) {
60
- const merged = extend(true, {}, defaults, module.options ?? {});
60
+ const options = module.options;
61
+ const merged = extend(true, {}, defaults, options ?? {});
61
62
  super("devkit", module.container, module.notificationsCenter, merged);
62
63
  this.module = module;
63
64
  this.cfg = merged;
package/docs/index.md CHANGED
@@ -4,7 +4,7 @@ navTitle: devkit
4
4
  lang: fr
5
5
  module: "@nodefony/devkit"
6
6
  topic: overview
7
- audience: [human, ai]
7
+ audience: [developer]
8
8
  tags: [module, developpement, agent]
9
9
  status: stable
10
10
  updated: 2026-09-01
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/devkit",
3
- "version": "10.0.0-alpha.9",
3
+ "version": "10.0.0-beta.1",
4
4
  "type": "module",
5
5
  "description": "Outillage de développement d'une application Nodefony : carte de visite du projet et portes de découverte pour un agent de développement",
6
6
  "author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
@@ -25,17 +25,17 @@
25
25
  "coverage": "vitest run --coverage"
26
26
  },
27
27
  "peerDependencies": {
28
- "@nodefony/framework": "^10.0.0-alpha.9",
29
- "@nodefony/http": "^10.0.0-alpha.9",
30
- "nodefony": "^10.0.0-alpha.9",
28
+ "@nodefony/framework": "^10.0.0-beta.1",
29
+ "@nodefony/http": "^10.0.0-beta.1",
30
+ "nodefony": "^10.0.0-beta.1",
31
31
  "zod": "^4.6.5",
32
32
  "playwright": "^1.50.0",
33
33
  "lighthouse": "^13.0.0"
34
34
  },
35
35
  "devDependencies": {
36
- "@nodefony/framework": "^10.0.0-alpha.9",
37
- "@nodefony/http": "^10.0.0-alpha.9",
38
- "nodefony": "^10.0.0-alpha.9"
36
+ "@nodefony/framework": "^10.0.0-beta.1",
37
+ "@nodefony/http": "^10.0.0-beta.1",
38
+ "nodefony": "^10.0.0-beta.1"
39
39
  },
40
40
  "dependencies": {
41
41
  "axe-core": "4.13.0",
@@ -4,12 +4,14 @@ description: >
4
4
  Crée un service injectable dans une application Nodefony par `nodefony create service`, et le
5
5
  fait entrer dans le conteneur — la moitié qu'on oublie. Porte la distinction entre le nom de la
6
6
  CLASSE et le nom de l'INSTANCE, les deux façons d'obtenir un service depuis un autre
7
- (`@inject` au constructeur ou `container.get` à l'usage), et le défaut mesuré qu'un service
7
+ (`@inject` au constructeur ou `container.get` à l'usage), le choix de sa durée de vie
8
+ (un exemplaire pour l'application, ou un par requête), et le défaut mesuré qu'un service
8
9
  écrit à la main produit : une classe qui compile, dont les tests passent, et que le conteneur
9
10
  ignore. À charger AVANT d'écrire une classe de service ou d'appeler un service depuis un autre.
10
11
  Déclencheurs : "crée un service", "un service métier", "logique métier partagée", "injecter une
11
12
  dépendance", "container.get", "@injectable", "@services", "appeler un service depuis un autre",
12
- "mon service est undefined", "le conteneur ne trouve pas mon service".
13
+ "mon service est undefined", "le conteneur ne trouve pas mon service", "un service par
14
+ requête", "un état propre à chaque requête", "scope request".
13
15
  ---
14
16
 
15
17
  # add-service — un service que le conteneur connaît
@@ -58,7 +60,7 @@ et à l'introspection, et n'est construit qu'à la première requête qui le ré
58
60
 
59
61
  ## Deux noms, et ils ne servent pas à la même chose
60
62
 
61
- C'est le piège n°1, et il ne produit aucune erreur — juste un `undefined` :
63
+ C'est le piège n°1, et il ne produit aucune erreur — juste un `null` :
62
64
 
63
65
  ```ts
64
66
  @injectable() // ← nomme la CLASSE : @inject("BillingService")
@@ -69,6 +71,23 @@ export class BillingService extends Service {
69
71
  }
70
72
  ```
71
73
 
74
+ ## Choisir sa durée de vie — un seul exemplaire, ou un par requête
75
+
76
+ Chaque requête a son propre conteneur (un scope, calque posé sur celui de l'application et jeté à
77
+ la fin ; en WebSocket, à la fin de la connexion). La portée décide où vit l'exemplaire :
78
+
79
+ | `@injectable(…)` | Exemplaires | Pour |
80
+ | ---------------------------------------- | --------------------------------------- | --------------------------------------------------------- |
81
+ | `()` ou `("nom")` — défaut `singleton` | un pour toute l'application | cache, pool, client : ce qui doit être unique |
82
+ | `({ scope: "transient" })` | un par résolution | un objet jetable sans état partagé |
83
+ | `({ name: "tenant", scope: "request" })` | un par requête, créé si elle le demande | ce qui naît et meurt avec la requête (`clean()` à la fin) |
84
+
85
+ Un service `request` reçoit le scope au constructeur (`super("tenant", scope, false)`, même nom
86
+ qu'au décorateur) ; `create service` n'en génère pas, il s'écrit à la main. Un singleton qui en
87
+ dépend est refusé au démarrage. Pour **lire** une donnée de la requête depuis un singleton, pas
88
+ besoin de portée : `RequestContext.getScope()` ou `RequestContext.get()` au moment de l'appel.
89
+ Détail : `node_modules/nodefony/docs/service.md`.
90
+
72
91
  ## Obtenir un service depuis un autre — deux voies, un choix
73
92
 
74
93
  | Voie | Quand |
@@ -167,8 +167,8 @@ await page.addInitScript(() => {
167
167
  if (window.__nfCsp.length < 20)
168
168
  window.__nfCsp.push({
169
169
  directive: e.violatedDirective,
170
- blocked: String(e.blockedURI ?? "").slice(0, 140),
171
- source: String(e.sourceFile ?? "").slice(0, 140),
170
+ blocked: (e.blockedURI ?? "").slice(0, 140),
171
+ source: (e.sourceFile ?? "").slice(0, 140),
172
172
  line: e.lineNumber,
173
173
  });
174
174
  });
@@ -355,7 +355,7 @@ function effectiveBackground(el) {
355
355
  let opaqueLayer = null;
356
356
  for (let n = el; n; n = n.parentElement) {
357
357
  const bg = getComputedStyle(n).backgroundColor;
358
- if (!bg || /transparent/.test(bg)) continue;
358
+ if (!bg || bg.includes("transparent")) continue;
359
359
  const { a } = parseColor(bg);
360
360
  if (a === 0) continue;
361
361
  if (a >= 1) {
@@ -397,7 +397,7 @@ function probeA11y() {
397
397
  // simplifié suffit à attraper un bouton-icône muet — le cas réel.
398
398
  const accessibleName = (el) => {
399
399
  const aria = el.getAttribute("aria-label");
400
- if (aria && aria.trim()) return aria.trim();
400
+ if (aria?.trim()) return aria.trim();
401
401
  const refs = el.getAttribute("aria-labelledby");
402
402
  if (refs) {
403
403
  const t = refs
@@ -682,14 +682,14 @@ async function measurePage(args) {
682
682
  // CSP de l'application refuserait à bon droit) : c'est le pilote qui évalue.
683
683
  const expression = `((args) => {
684
684
  ${sourceWcag()}
685
- ${describeElement}
686
- ${isVisible}
687
- ${effectiveBackground}
688
- ${probeA11y}
689
- ${probeRendering}
690
- ${probeWebStorage}
691
- ${readPerf}
692
- return (${measurePage})(args);
685
+ ${describeElement.toString()}
686
+ ${isVisible.toString()}
687
+ ${effectiveBackground.toString()}
688
+ ${probeA11y.toString()}
689
+ ${probeRendering.toString()}
690
+ ${probeWebStorage.toString()}
691
+ ${readPerf.toString()}
692
+ return (${measurePage.toString()})(args);
693
693
  })(${JSON.stringify({ probes: PROBES, families: [...active] })})`;
694
694
 
695
695
  const measured = await page.evaluate(expression);
@@ -711,7 +711,9 @@ if (active.has("axe")) {
711
711
  try {
712
712
  const axeCode = await axeSource();
713
713
  const report = await page.evaluate(async (source) => {
714
- // eslint-disable-next-line no-new-func -- évalué par le pilote, hors CSP
714
+ // Le code d'axe-core est une CHAÎNE à exécuter dans la page : c'est le seul
715
+ // moyen de l'y charger sans `<script>` injecté, que la CSP refuserait.
716
+ // oxlint-disable-next-line no-new-func, typescript/no-implied-eval -- évalué par le pilote, hors CSP
715
717
  new Function(source)();
716
718
  return await window.axe.run(document, {
717
719
  resultTypes: ["violations", "incomplete"],
@@ -39,7 +39,7 @@ export const FAMILIES = Object.freeze({
39
39
  * et celles qui n'existent pas — à refuser, jamais à ignorer.
40
40
  */
41
41
  export function parseFamilies(raw, fallback = []) {
42
- const requested = String(raw ?? "").trim();
42
+ const requested = (raw ?? "").trim();
43
43
  if (!requested) return { kept: [...fallback], unknown: [] };
44
44
  if (requested === "toutes") {
45
45
  return { kept: Object.keys(FAMILIES), unknown: [] };
@@ -71,7 +71,7 @@ export function parseFamilies(raw, fallback = []) {
71
71
  export function parseProbes(raw) {
72
72
  const probes = [];
73
73
  const rejected = [];
74
- for (const chunk of String(raw ?? "")
74
+ for (const chunk of (raw ?? "")
75
75
  .split(",")
76
76
  .map((p) => p.trim())
77
77
  .filter(Boolean)) {
@@ -171,9 +171,7 @@ export function parseActions(raw) {
171
171
  * `schema` à null quand rien n'est demandé (on ne force rien).
172
172
  */
173
173
  export function parseColorScheme(raw) {
174
- const v = String(raw ?? "")
175
- .trim()
176
- .toLowerCase();
174
+ const v = (raw ?? "").trim().toLowerCase();
177
175
  if (!v) return { schema: null, invalid: null };
178
176
  if (v === "light" || v === "dark" || v === "no-preference")
179
177
  return { schema: v, invalid: null };
@@ -200,7 +198,7 @@ export function parseColorScheme(raw) {
200
198
  export function parseStorage(raw) {
201
199
  const entries = [];
202
200
  const rejected = [];
203
- for (const chunk of String(raw ?? "")
201
+ for (const chunk of (raw ?? "")
204
202
  .split(",")
205
203
  .map((p) => p.trim())
206
204
  .filter(Boolean)) {
@@ -261,7 +259,7 @@ export function environmentDefaults({ inContainer, base, out } = {}) {
261
259
  * @returns {string} le nom de fichier, sans dossier.
262
260
  */
263
261
  export function authStateName(login) {
264
- const raw = String(login ?? "");
262
+ const raw = login ?? "";
265
263
  const readable =
266
264
  raw.replace(/[^A-Za-z0-9._-]/gu, "_").slice(0, 40) || "anonyme";
267
265
  const fingerprint = createHash("sha256")
@@ -284,7 +282,7 @@ export function authStateName(login) {
284
282
  export function parseWidths(raw) {
285
283
  const widths = [];
286
284
  const invalidWidths = [];
287
- for (const chunk of String(raw ?? "")
285
+ for (const chunk of (raw ?? "")
288
286
  .split(",")
289
287
  .map((p) => p.trim())
290
288
  .filter(Boolean)) {
@@ -427,7 +425,7 @@ export function summarizeLighthouse(lhr, threshold = 0.9) {
427
425
  for (const c of categories) {
428
426
  for (const ref of c.auditRefs ?? []) {
429
427
  const a = audits[ref.id];
430
- if (!a || a.score === null || a.score === undefined) continue;
428
+ if (a?.score == null) continue;
431
429
  if (a.score >= threshold) continue;
432
430
  failed.push({
433
431
  category: c.id,
@@ -495,7 +493,7 @@ export function summarizeLighthouse(lhr, threshold = 0.9) {
495
493
  * @returns {string[]} les navigateurs à essayer, dans l'ordre.
496
494
  */
497
495
  export function browserOrder(explicit) {
498
- const v = String(explicit ?? "").trim();
496
+ const v = (explicit ?? "").trim();
499
497
  if (v) return [v];
500
498
  return ["chromium", "chrome", "msedge"];
501
499
  }
@@ -39,7 +39,7 @@
39
39
  * @throws {Error} si la notation n'est pas reconnue.
40
40
  */
41
41
  export function parseColor(color) {
42
- const s = String(color).trim();
42
+ const s = color.trim();
43
43
  // `transparent` est le SEUL mot-clé que les navigateurs rendent parfois tel
44
44
  // quel, et il a une valeur définie : `rgba(0, 0, 0, 0)`. Le refuser serait
45
45
  // faux — un fond non peint est un cas courant, pas une erreur de mesure.
@@ -51,7 +51,7 @@ export function parseColor(color) {
51
51
  if (hex) {
52
52
  const d = hex[1];
53
53
  if (d.length === 3 || d.length === 4) {
54
- const q = [...d].map((c) => parseInt(c + c, 16));
54
+ const q = Array.from(d, (c) => parseInt(c + c, 16));
55
55
  return {
56
56
  r: q[0],
57
57
  g: q[1],
@@ -73,7 +73,7 @@ export function parseColor(color) {
73
73
  const numbers = s.match(/-?\d*\.?\d+(?:e-?\d+)?%?/gi);
74
74
  if (!numbers || numbers.length < 3)
75
75
  throw new Error(
76
- `notation de couleur non reconnue : ${JSON.stringify(String(color))} — ` +
76
+ `notation de couleur non reconnue : ${JSON.stringify(color)} — ` +
77
77
  "attendu rgb()/rgba(), color(srgb …) ou #rrggbb",
78
78
  );
79
79
  // `color(srgb …)` et `color(display-p3 …)` : canaux en 0–1. Le pourcentage
@@ -164,7 +164,7 @@ export function contrastRatio(a, b) {
164
164
  * @returns {boolean} vrai si les seuils « texte large » s'appliquent.
165
165
  */
166
166
  export function isLargeText(px, bold) {
167
- return px >= 24 || (bold === true && px >= 18.66);
167
+ return px >= 24 || (bold && px >= 18.66);
168
168
  }
169
169
 
170
170
  /**
@@ -320,7 +320,7 @@ async function scenarioSocket(conf) {
320
320
  } catch (e) {
321
321
  result.reconnection = {
322
322
  closedAt,
323
- verdict: `ÉCHEC — ${String(e && e.message ? e.message : e).slice(0, 140)}`,
323
+ verdict: `ÉCHEC — ${String(e?.message ? e.message : e).slice(0, 140)}`,
324
324
  };
325
325
  }
326
326
  result.closes = closes;
@@ -337,7 +337,7 @@ try {
337
337
  // L'accueil qui ne vient jamais est LE symptôme à diagnostiquer en premier :
338
338
  // endpoint faux, page non authentifiée, ou Origin refusé par le serveur.
339
339
  console.error(
340
- `Scénario interrompu : ${String(e && e.message ? e.message : e).slice(0, 300)}\n` +
340
+ `Scénario interrompu : ${String(e?.message ? e.message : e).slice(0, 300)}\n` +
341
341
  `→ vérifier le chemin du endpoint, l'authentification (NF_BROWSER_USER + NF_BROWSER_LOGIN), et que la page ${process.env.NF_BROWSER_PAGE ?? "/"} appartient bien à la même origine.`,
342
342
  );
343
343
  await browser.close();
@@ -306,6 +306,8 @@ Chacun a déjà coûté au moins une heure à quelqu'un. Les quatre premiers son
306
306
  - **Une clé de configuration mal orthographiée est retirée en silence.** `satisfies` sur le
307
307
  fragment n'est pas décoratif : sans lui, la faute compile, puis la validation écarte la clé et
308
308
  le module démarre sur son défaut. Personne ne le voit.
309
+ - **La config d'un module est FIGÉE après le démarrage** : l'écrire lève `TypeError`. Une variante
310
+ par requête = `overlayConfig()` (clés de la liste blanche du module), lue par `useConfig()`.
309
311
  - **Tu lis une liste sans la BORNER.** Un `find` sans limite matérialise la table entière —
310
312
  indolore sur les quelques lignes du poste de développement, fatal sur les dizaines de milliers
311
313
  de la production. Le service d'une entité hérite `findPage({ limit: 25 })`.
@@ -370,12 +372,11 @@ Chacun a déjà coûté au moins une heure à quelqu'un. Les quatre premiers son
370
372
 
371
373
  ## 8. Données et fichiers — les façades qu'on ne recompose pas
372
374
 
373
- - **Un adaptateur de données ne remplace pas l'autre : ils se COMPLÈTENT.** Chacun déclare les
374
- _stores_ qu'il sait tenir (`nodefony.stores` de son `package.json`) — `drizzle` les huit,
375
- `mongoose` cinq (ni `totp`, ni `audit`, ni `idempotency`), `redis` quatre. Ce n'est pas un retard
376
- de développement mais un CHOIX : un journal d'audit n'a rien à faire dans un moteur documentaire.
377
- Ne promets jamais une parité qui n'existe pas — `npx nodefony inspect stores` dit où atterrit
378
- chaque donnée.
375
+ - **Un cache ne remplace pas une base : ils se COMPLÈTENT.** Chaque adaptateur déclare les
376
+ _stores_ qu'il sait tenir (`nodefony.stores` de son `package.json`) — `drizzle` et `mongoose` les
377
+ huit, `redis` quatre (`session`, `tokens`, `passkeys`, `idempotency`) : un cache ne garde ni
378
+ comptes ni journal d'audit. Ne promets jamais une parité qui n'existe pas — lis ce champ, et
379
+ `npx nodefony inspect stores` dit où atterrit chaque donnée.
379
380
  - **Les violations de contrainte sont DÉJÀ traduites en HTTP — ne les rattrape pas.** Un doublon
380
381
  sur une colonne unique ressort en **409**, une donnée qui viole le schéma Zod en **422**, chacun
381
382
  avec son corps JSON : le rendu d'erreur lit le code du pilote (`23505`, `ER_DUP_ENTRY`,
@@ -59,7 +59,7 @@ export function parseArgs(argv) {
59
59
  const opts = {
60
60
  terms: [],
61
61
  list: false,
62
- open: null,
62
+ open: /** @type {string | null} */ (null),
63
63
  json: false,
64
64
  limit: DEFAULT_LIMIT,
65
65
  root: process.cwd(),