Nodefony 10.0.0-beta.5
Framework Node.js fullstack — HTTP & WebSocket, même contexte

Configurer une application Nodefony (defineConfig)

stablemis à jour 2026-09-26

Modèle defineConfig (depuis 2026-06-05). La config d’une app tient dans un fichier racine auto-documenté, et grandit par composition — sans jamais subir le découpage.

📍 Documentation › Guides › Configuration

Le modèle — un fichier racine, qui grandit par composition#

Commencer minuscule comme Vite. Pouvoir grandir structuré. Sans jamais subir le découpage.

Tout ce que vous n’écrivez pas prend le défaut du framework (defaultAppConfig, dans le core). Votre nodefony.config.ts ne contient donc QUE vos écarts.

Vue d’ensemble — comprendre TOUTE la config en 1 minute#

La config se lit sur quatre couches, classées par une échelle de précédence unique (le plus bas perd, le plus haut gagne) :

# Couche Qui décide Où
1 Défaut (framework) Nodefony schéma Zod du core (defaultAppConfig) + de chaque module
2 Config du projet vous nodefony.config.ts (vos écarts, par-env via ctx ; inclut ctx.env = env.ts)
3 Déploiement le devops variables d’env — catalogue NF_X + override générique NF__* + secrets *_FILE
4 Invocation la CLI flags (--workers, …)

La seule règle à retenir : ce que vous n’écrivez pas prend le défaut du framework. Vous n’écrivez que vos écarts dans nodefony.config.ts. Le déploiement (Docker/k8s) surcharge par variable d’environnement, sans toucher au code. Studio affiche la provenance de chaque valeur.

Décision d’architecture complète : ADR-0006.

Les deux fichiers racine#

Fichier Rôle
nodefony.config.ts La config : defineConfig((ctx) => …) + manifeste modules
env.ts Le catalogue des variables d’environnement (defineEnv) — SEUL lecteur de process.env

index.ts (racine) importe le descripteur et ré-exporte env :

import config from "./nodefony.config";
export { env } from "./env";
// … le Module App reçoit `config` via super(...)

nodefony.config.ts — l’orchestrateur#

import { defineConfig, use } from "nodefony";
import type { env } from "./env";

export default defineConfig<typeof env>((ctx) => ({
  domain: ctx.isProd ? "0.0.0.0" : "127.0.0.1", // par-env via ctx
  log: { debug: ctx.isProd ? [] : "*", driver: ctx.env.NF_LOG_DRIVER },
  modules: [
    use(
      "@nodefony/http",
      { trustedHosts: ["localhost"] },
      { policy: "mandatory" },
    ),
    "@nodefony/framework",
    { name: "@nodefony/test", policy: "dev" },
  ],
}));

ctx est passé au boot : { env, appEnv, runtimeEnv, isProd, isDev, isTest }.

env.ts — le catalogue d’environnement#

import { defineEnv, envEnum, envBoolean, envString } from "nodefony";

export const env = defineEnv({
  NF_LOG_DRIVER: envEnum(["stdout", "file", "null"] as const, {
    default: "stdout",
  }),
  NF_LOG_FILE_SYNC: envBoolean({ default: false }),
  NF_LOKI_URL: envString({ optional: true }),
});

Les 6 recettes (faire grandir la config)#

  1. Ajouter un module → ajouter son nom dans modules.
  2. Configurer un module → use("@nodefony/security", { firewalls: { … } }).
  3. Module dev/conditionnel → { name, policy: "dev" } ou use(name, config, { when: (c) => … }).
  4. Réglage par-env → tester ctx.isProd / ctx.isDev dans la fonction.
  5. Lire une variable d’env → la déclarer dans env.ts, lire ctx.env.X (jamais process.env).
  6. Configurer un module → un fichier nodefony/config/<module>.ts que le manifeste importe (voir juste en dessous). C’est la forme NORMALE, pas un recours quand un bloc grossit.

Où vit la config d’un module — l’emplacement, et la garde qui vient avec#

Le manifeste reste l’index ordonné : quels modules, dans quel ordre, sous quelle politique. C’est la seule chose sur laquelle le Kernel décide, et la seule qu’un outil du produit y écrit. La configuration d’un module, elle, part dans son fichier.

Deux choses à ne pas confondre — l’une est gardée, l’autre est une convention.

Aucun automate ne peut dire « tu aurais dû extraire ce bloc » : le seuil serait arbitraire et se rejugerait à chaque édition. Prétendre le contraire donnerait une règle que rien ne fait respecter, c’est-à-dire pas une règle.

L’emplacement est nodefony/config/<module>.ts, jamais un dossier config/ à la racine : tout le code d’une application vit déjà sous nodefony/ (controllers/, entity/, security/), ce dossier est dans l’include des deux tsconfig et dans le glob du bundler. Un fichier oublié y reste au moins typechecké, quand un fichier racine serait invisible tant que personne ne l’importe.

Le nom est réservé, et la réserve ne crie pas. config.ts et *.config.ts appartiennent au chargement d’un module (<module>/nodefony/config/config.ts, produit par nodefony create module et importé par l’index.ts du module) : dans <app>/nodefony/config/, les lecteurs du manifeste les écartent — sinon on ne saurait plus dire, à la lecture, si un fichier est chargé seul par le framework ou parce que le manifeste l’importe. Un fragment se nomme donc <module>.ts (security.ts, jamais security.config.ts) : mal nommé, il n’est lu par aucun contrôle et chargé par personne — nodefony doctor le signale (reserved-fragment-name). Seul le sous-dossier cluster/ fait exception : il est lu par chemin par le process maître.

⚠️ Extraire SANS satisfies désarme le typage, et le désarme en silence. Le contrôle des propriétés en excès de TypeScript ne porte que sur un littéral écrit au point où le type est attendu : dès que le bloc part dans une fonction, une clé mal orthographiée compile — et la faute n’apparaît qu’au démarrage, quand le schéma strict du module la refuse : tard, et sans la ligne de code fautive. Aucun helper ne peut rattraper ça : le typage contextuel du retour ne déclenche jamais le contrôle. Seul satisfies sur le littéral le fait.

C’est pourquoi nodefony doctor refuse un fragment qui rend une configuration sans satisfies (constat fragment-without-satisfies) : extraire un bloc ne rend pas l’application plus sûre, cela lui RETIRE une garde — et satisfies est ce qui la rend.

// nodefony/config/http.ts
// Le type d'ENTRÉE vient du MODULE, jamais du cœur : `nodefony` n'exporte pas
// `IHttpConfigInput`.
import type { ConfigContext } from "nodefony";
import type { IHttpConfigInput } from "@nodefony/http";
import type { env } from "../../env";

export const httpConfig = (ctx: ConfigContext<typeof env>) =>
  ({
    headerServer: ctx.isProd ? null : "nodefony",
    upload: { maxFileSize: 1_048_576 },
  }) satisfies IHttpConfigInput;
// nodefony.config.ts — garde les use(), qui restent l'index ordonné des modules
import { defineConfig, use } from "nodefony";
import { httpConfig } from "./nodefony/config/http";

export default defineConfig((ctx) => ({
  modules: [use("@nodefony/http", httpConfig(ctx))],
}));

Ce que le satisfies attrape, vérifié sur ce dépôt :

// ❌ TS2561 — « trustProxi n'existe pas… vouliez-vous écrire trustProxy ? »
//    Sans `satisfies`, ce MÊME fichier compile ; la faute n'apparaît qu'au boot (BootConfigurationError).
trustProxi: true;

// ❌ TS2353 — `servers` appartient à la config de l'APPLICATION, pas au module.
//    La confusion des deux niveaux est la faute la plus facile à faire ici.
servers: {
  http: {
    port: 5151;
  }
}

Rien ne charge nodefony/config/ tout seul : c’est un import ordinaire, et le fichier ne vaut que parce que nodefony.config.ts le nomme.

La fonction ne prend ctx que si elle s’en sert : une configuration qui ne change pas avec l’environnement s’écrit () => ({ … }) satisfies …, et le manifeste l’appelle sans argument. Ce n’est pas un détail de style — noUnusedParameters refuse un paramètre inutilisé, et la signature dit ainsi, à la lecture, si ce bloc dépend ou non de l’environnement.

Ce dépôt s’applique la forme à lui-même : il est une application Nodefony, et ses sept modules configurés portent chacun leur fragment sous nodefony/config/ — http.ts, framework.ts, realtime.ts, security.ts, studio.ts, devkit.ts, drizzle.ts. Son manifeste est passé de 570 à 215 lignes sans qu’aucune valeur effective ne bouge : nodefony inspect config --json rend le même objet, au caractère près, avant et après.

Une application générée reçoit la même forme, sur les blocs qui la méritent : nodefony/config/devkit.ts partout, et avec le preset complet nodefony/config/security.ts et nodefony/config/drizzle.ts (ou mongoose.ts sur MongoDB).

L’écoute : ports et TLS#

servers ne porte que des écarts ; laissé vide, le framework écoute en 5151 (HTTP) et 5152 (HTTPS, en HTTP/2).

Un port appartient au DÉPLOIEMENT, pas au code. En PaaS (Cloud Run, Heroku, Railway) la plateforme IMPOSE le sien via PORT : un port écrit en dur donne un service qui écoute là où personne n’appelle. D’où la lecture par l’environnement (NF_PORT, NF_PORT_HTTPS, PORT). En développement, deux applications Nodefony peuvent tourner côte à côte : un port déjà pris glisse au suivant et le décalage est ANNONCÉ (portPolicy: "auto", défaut hors production). En production et en test, portPolicy: "strict" → échec franc plutôt que port surprise.

HTTPS est actif par défaut, même en développement. Ce n’est pas du zèle : les API navigateur modernes exigent un contexte sécurisé — WebRTC/getUserMedia, presse-papiers, service workers, notifications. Un projet démarré en clair découvre le problème le jour où il ajoute la première de ces fonctionnalités.

Au premier boot, un certificat de développement est généré tout seul : via mkcert s’il est installé (autorité locale de confiance, zéro avertissement navigateur), sinon auto-signé. Inspection et regénération : npx nodefony http:certificates.

En production, deux voies : fournir un vrai certificat, ou terminer le TLS à l’ingress / au load balancer et n’exposer qu’un port en clair — https: false dans servers (HTTPS et WSS en héritent tous deux). C’est le cas nominal en cloud.

La console Studio en production#

Studio est déclaré policy: "mandatory" par le scaffold : la console d’administration (introspection de la config, des sessions, des logs) existe aussi en production, là où l’application tourne. Un policy: "dev" la ferait disparaître de la production sans un mot.

Ce qui est clos : le data plane (/nodefony/<module>/api/…) exige une session, par la zone nodefony-admin que pose @nodefony/framework. Ce qui ne l’est pas encore : la page elle-même, qu’un anonyme peut afficher (tous ses appels reçoivent 401). Pour la fermer, ajoutez une zone dans nodefony/config/security.ts : nodefonyUi: { pattern: "^/nodefony(/|$)", authenticators: ["session"] } — le pattern le plus long gagne, le data plane garde la sienne.

La molette ui décide de la livraison de l’interface : "static" (épinglé par le scaffold) sert les assets pré-buildés du paquet npm — Studio marche sans rien recompiler. "auto" / "vite" feraient passer l’UI Studio (React) par ton serveur Vite : utile uniquement pour développer Studio lui-même, et cela exigerait ses plugins dans tes devDependencies — une application Vue ou Angular n’a pas @vitejs/plugin-react.

Le manifeste modules#

L’ordre du tableau = ordre (priorité) de chargement. Trois formes :

Forme Signification
"@nodefony/http" string nue = { name, policy: "optional" }
{ name, policy, when } entrée détaillée (gating)
use(name, config, { policy, when }) entrée + config colocalisée du module

Policies : mandatory (socle, jamais gaté) · optional (défaut, gaté par when) · dev (chargé hors production). when(config) reçoit la config résolue ; false → module non chargé (0 coût — en ESM un module non importé n’existe pas).

Le manifeste remplace le décorateur @modules (retiré 2026-06-03) et les clés module-<name> à la racine.

Quand un MODULE configure un autre module — la précédence#

Un module peut porter, dans sa propre configuration, une clé module-<nom> adressée à un autre module (@nodefony/framework règle ainsi @nodefony/security). Elle est appliquée après la configuration de l’application, donc c’est le module qui gagne — y compris un module de banc (policy: "dev"). La règle : un module doit compléter, jamais contredire. Compléter est silencieux ; écraser une feuille que l’application a posée par use() est signalé en WARNING, avec le module, le chemin, la valeur remplacée et la nouvelle (un secret est rédigé). Le WARNING entre au bilan de boot : une décision explicite de l’application écrasée par une fixture ne se manifesterait autrement que par un comportement inexplicable.

⚠️ Une liste ne se remplace pas : elle se fusionne case par case. La fusion profonde (extend(true, …), la même pour la configuration de l’application et pour ces surcharges) traite un tableau comme un objet indexé :

// application                       // module (module-security)
grantableScopes: {                   grantableScopes: {
  "x:write": ["ROLE_A", "ROLE_B"],     "x:write": ["ROLE_C"],
}                                    }
// résultat : "x:write": ["ROLE_C", "ROLE_B"] — ROLE_B survit sans que personne ne l'ait écrit

Pour une liste de rôles, d’origines ou d’émetteurs, c’est un droit hérité en silence. La règle qui l’évite : une liste n’est déclarée qu’à un endroit — un module complète par des clés qui sont les siennes (son préfixe de scopes, sa zone), il ne redéclare pas celles de l’application. Ce comportement est figé par un test (src/nodefony/src/tests/Tools.test.ts).

Typage par module — use() propose les bonnes clés#

Pour que use("@nodefony/x", …) auto-complète les clés du module x, le module augmente le registre du core (declaration merging, pattern Nuxt/Pinia) :

// dans @nodefony/x
declare module "nodefony" {
  interface NodefonyModuleConfig {
    "@nodefony/x": IXConfigInput; // ⚠️ le type d'ENTRÉE, pas celui de sortie
  }
}

⚠️ Le type d’ENTRÉE (z.input du schéma — tout optionnel), jamais celui de sortie (z.infer) : après application des défauts, les champs sont requis. Surcharger une seule clé obligerait alors l’app à réécrire toute la configuration du module.

Ce que l’augmentation évite vraiment. Sans elle, use() accepte Record<string, unknown> : une clé mal orthographiée compile, puis le schéma strict du module la refuse au démarrage. La faute est donc attrapée, mais tard, sur la machine qui démarre, et sans la ligne de code fautive. L’augmentation l’avance à la compilation :

use("@nodefony/redis", { enabledd: false });
//                       ^^^^^^^^ erreur de compilation, au lieu d'un silence au boot

Un module tiers qui ne l’applique pas reste fonctionnel (jamais bloquant) — il perd simplement ce filet. Convention : tout module qui expose une config publie son type d’entrée et augmente ce registre. nodefony create module le génère déjà : un module neuf naît conforme.

Réactivité : hot vs boot#

La réactivité n’est pas déduite d’un tag posé sur chaque champ : elle vient d’une liste explicite, configReactivity (src/nodefony/src/config/reactivity.ts#configReactivity), interrogée par getConfigReactivity(path).

Un champ absent de la liste est donc boot — c’est le défaut sûr. L’application à chaud se fait par le data plane PATCH /nodefony/kernel/api/config/{module} (KernelAdminApi.ts#createKernelAdminApi), qu’utilise l’onglet Configuration de Studio, lequel badge chaque champ 🔥 à chaud / 🔒 redémarrage.

Figée au démarrage — et surchargée par requête#

La configuration d’un module est figée à la fin de onReady, avant que le premier serveur n’écoute. Elle est partagée par toutes les requêtes : l’écrire (this.options.upload.maxFiles = 3) lève désormais une TypeError au lieu de changer, en silence, la configuration des requêtes concurrentes. Une configuration se complète au démarrage, jamais après. L’édition à chaud ci-dessus reste possible : elle remplace la configuration au lieu de l’écrire.

Une requête peut pourtant porter SA variante — le cas typique : une application qui sert plusieurs organisations, chacune avec son quota d’envoi. Elle pose un calque : quelques clés surchargées, pour elle seule, sans jamais toucher la configuration du module.

// Dans un module de l'application — reconnaître l'organisation, poser son calque.
import { Module, overlayConfig } from "nodefony";

export class TenantModule extends Module {
  override async onKernelBoot(): Promise<this> {
    this.kernel?.on("onRequestScope", (context: unknown) => {
      const { request } = context as {
        request?: { headers?: Record<string, unknown> };
      };
      const host = String(request?.headers?.host ?? "");
      if (host.startsWith("premium.")) {
        overlayConfig("@nodefony/http", {
          maxBodySize: 50_000_000,
          upload: { maxFileSize: 500_000_000 },
        });
      }
    });
    return this;
  }
}

Le code qui applique la limite lit la configuration par useConfig(), qui voit le calque de la requête courante (et, sans calque, l’objet figé du module, sans rien allouer) :

import { useConfig } from "nodefony";

const { maxBodySize } = useConfig("@nodefony/http");

Trois règles à connaître :

Un module qui ouvre une clé à la surcharge la déclare dans overlaySchema (un schéma Zod strict, bâti avec overlayField() pour ne pas hériter des défauts), l’enregistre dans le registre typé NodefonyModuleOverlay, et la lit par useConfig() au moment de s’en servir.

Cas particulier : la topologie cluster#

nodefony/config/cluster/cluster.config.ts reste un fichier séparé, kernel-free : le process master le lit standalone, AVANT de booter le moindre Kernel, pour décider du nombre de workers. Ne PAS le mettre dans nodefony.config.ts. Override runtime : CLI --workers > NF_WORKERS > ce fichier.

nodefony create app ne génère pas ce fichier : sa valeur serait le défaut (workers: 1, un process par pod), et chaque application porterait un fichier qui ne dit rien. Les deux voies d’exécution suffisent à un déploiement ; le fichier ne sert que si la topologie doit vivre en git — trois lignes, kernel-free, dont le README généré donne la forme. NF_WORKERS est en revanche déclarée dans l’env.ts généré, avec ses trois formes (1, "auto", <n>) : c’est là qu’on cherche une variable, et c’est ce que npx nodefony env cataloguera.

Quand la config est invalide#

Le boot échoue proprement (il ne peut pas deviner vos ports/modules) : un diagnostic clair (titre + cause + champ Zod nommé + valeurs par défaut du framework explicitées), sans stack brute, et un code de sortie EX_CONFIG (78) pour que l’orchestrateur distingue « mauvaise config » d’un crash.

✖ Configuration de l'application invalide
  La résolution de la configuration (`defineConfig`) a échoué.
  Cause : servers.http.port: Expected number, received string
  …
  Configuration PAR DÉFAUT du framework (appliquée à tout champ omis) :
    • servers = {"statics":true,"http":{"port":5151}, …}

En développement : rebuild après une modif de config#

Le boot lit la config depuis le dist (dist/index.js, dist/nodefony.config.js, dist/env.js), pas la source. Après avoir édité nodefony.config.ts / env.ts, rebuilder le root avant de relancer : npm run build à la racine (le start.sh du skill ne rebuilde que le module test en dev). Voir le skill nodefony-start-server.

Voir la config — l’ÉTAT, et le CATALOGUE#

Deux questions différentes, deux réponses :

La question La commande
« Qu’est-ce qui est POSÉ aujourd’hui, et d’où ça vient ? » npx nodefony inspect config
« Qu’est-ce que j’ai le DROIT d’écrire, et que fait cette clé ? » npx nodefony inspect schema <module>
npx nodefony inspect schema http          # les clés de @nodefony/http, une fiche par réglage
npx nodefony inspect schema http --json   # le même catalogue, filtrable au `jq`
npx nodefony inspect schema              # tous les modules montés

Le catalogue rend, par clé : le chemin pointé tel qu’on l’écrit dans use(), le type (ou les valeurs de l’énumération), le défaut du schéma, la valeur effective, sa provenance (default / app / env / runtime) et la description écrite dans le schéma. C’est cette dernière qui compte : les schémas du framework portent des centaines de .describe() — ce que la clé fait, ce qui arrive si on l’omet, quel RFC elle applique — et elles n’étaient lisibles qu’en ouvrant le schéma Zod dans node_modules.

La colonne note dit ce qui rend une clé particulière : réservé (acceptée par le schéma, mais le code n’en fait encore rien), secret (jamais rendue en clair), dérivé du kernel (remplie au boot si on la laisse vide), modifiable à chaud.

Un module inconnu est refusé en nommant les modules acceptés — une faute de frappe ne rend donc jamais un catalogue vide, qui se lirait « ce module n’a pas de configuration ».

Ces commandes bootent l’application sans ouvrir un seul port : elles cohabitent avec un serveur de développement en marche. Même donnée côté web : l’onglet Configuration de Studio (/nodefony/config), alimenté par GET /nodefony/kernel/api/config et GET /nodefony/kernel/api/config/schema — même code, deux portes.

Il n’existe pas de commande nodefony config:show : c’est inspect qui porte les deux lectures.

Surcharger en déploiement — Docker / variables d’env#

Pour commencer — régler un module sans ouvrir son code#

Un module, c’est un appareil livré avec ses réglages d’usine : Redis se connecte à localhost, sur le port 6379. Tu ne modifies jamais les fichiers du module pour changer ça. Tu as trois boutons, de l’extérieur :

Bouton Exemple — l’hôte Redis Pour qui, quand
1. Le code de l’app use("@nodefony/redis", { globalOptions: { socket: { host: "redis.interne" } } }) dans nodefony.config.ts le développeur — ce qui ne change pas d’un déploiement à l’autre
2. Le nom court NF_ NF_REDIS_HOST=redis.interne l’exploitant — le contrat de PRODUCTION
3. La forme NF__ NF__REDIS__GLOBALOPTIONS__SOCKET__HOST=redis.interne les bancs, le diagnostic, le dépannage ponctuel

Le nom court (NF_REDIS_HOST) n’existe que si l’auteur du module l’a prévu. C’est un nom stable et documenté : l’auteur s’engage à ne pas le casser. C’est celui qu’on pose dans un .env, un Secret Kubernetes, les valeurs Helm, l’environment: d’un compose.yaml. Sa valeur est validée comme celle du fichier : fausse, elle arrête le démarrage en nommant la variable (… — valeur posée par NF_REDIS_PORT). npx nodefony env liste ceux des modules de l’application, et signale ceux d’un module chargé seulement sous condition (when) ou en développement : posée, la variable ne règle rien tant que le module n’est pas chargé.

La forme NF__ marche pour n’importe quelle clé de n’importe quel module, sans que personne ne la déclare : chaque __ descend d’un niveau dans la configuration. C’est sa force, et c’est aussi sa limite : son nom suit la structure INTERNE du module. Si l’auteur renomme globalOptions, la variable ne vise plus rien. Le démarrage le signale, mais le réglage est perdu. D’où la règle : en production durable, le nom court ; la forme NF__ pour faire varier un réglage dans un banc, ou pour dépanner tout de suite. Une équipe qui pose une NF__ en production depuis des mois a trouvé un réglage qui mérite un nom court : demande-le au module.

Un secret se monte en fichier. En production, un mot de passe n’est pas écrit dans une variable mais monté par l’orchestrateur (Docker secrets, Kubernetes). Ajoute _FILE au nom court :

NF_REDIS_PASSWORD_FILE=/run/secrets/redis-password   # le contenu du fichier devient la valeur

Poser NF_REDIS_PASSWORD et NF_REDIS_PASSWORD_FILE arrête le démarrage : deux valeurs pour un seul secret, c’est une erreur, pas un choix.

Nom court ET NF__ sur le même réglage (NF_REDIS_HOST et NF__REDIS__GLOBALOPTIONS__SOCKET__HOST) : deux valeurs pour un seul réglage.

Mode Ce qui se passe
production démarrage refusé (code 78), le message nomme les deux variables
développement NF__ l’emporte, et un avertissement le dit en nommant les deux variables

Pourquoi deux règles : en production, on ne devine pas laquelle l’exploitant voulait. En développement, c’est le geste normal d’un banc — le .env pose le nom court, la mesure fait varier le réglage en NF__ le temps d’un essai.

Une valeur fausse arrête le démarrage, en nommant la variable : NF_REDIS_PORT=abc est refusé, jamais ignoré. Une variable vide (NF_REDIS_HOST= dans un .env) vaut absente.

Voir ce qui est réellement appliqué : npx nodefony env. La commande ne démarre pas l’application, elle répond donc même quand celle-ci ne démarre plus. Elle range chaque variable selon d’où elle vient (terminal, .env), montre les noms courts que proposent tes modules (posés ou non, avec leur défaut), et signale en dernier ce que personne ne lit — une faute de frappe, le plus souvent. --json rend le même rapport pour un script.

Côté auteur de module — donner un nom court à un réglage#

Un module a toujours deux fichiers de configuration, créés par nodefony create module :

Fichier Rôle
nodefony/config/config.ts le schéma : quels réglages existent, leurs défauts — LE fichier qu’on édite
nodefony/config/defineModuleConfig.ts la machinerie (valide, gèle) — on n’y touche presque jamais

Donner un nom court à un réglage tient en une déclaration, sur le champ, dans config.ts :

host: z.string().min(1).default("localhost")
  .describe("Hôte du serveur Redis.")
  .meta({ env: "NF_REDIS_HOST" }),   // ← le seul geste

Rien d’autre à écrire : le démarrage applique la variable (applyEnvShortcuts(), src/nodefony/src/config/envShortcuts.ts#applyEnvShortcuts), nodefony env la reconnaît et .env.example la décrit, avec la description et le défaut du schéma. .meta() se place en dernier dans la chaîne. Le nom suit la forme NF_<MODULE>_<RÉGLAGE>.

✅ Livré (ADR-0006). Catalogue env.ts, override générique NF__* et secrets *_FILE sont opérationnels : résolus 1× au boot, validés par le schéma Zod du module (chemin/valeur invalide → boot rejeté, jamais de surcharge silencieuse).

Deux façons d’agir par l’environnement, rôles distincts :

__ (double underscore) = séparateur de niveau (choix .NET/Docker, sans ambiguïté avec le camelCase) ; segments insensibles à la casse, résolus contre les clés réelles du schéma :

NF__SECURITY__JWT__ACCESSTTLS=300
NF__HTTP__SERVERS__HTTPS__PORT=8443
NF__SECURITY__CORS__ORIGINS=https://a.com,https://b.com   # CSV → array
NF__APP__DOMAIN=0.0.0.0                                   # config APP (segment réservé `app`)
NF__APP__SERVERS__HTTP__PORT=8080
NF__APP__LOG__DRIVER=file
NF_WEBHOOK_KEY_FILE=/run/secrets/webhook_key             # secret depuis un fichier monté (*_FILE)

Une valeur invalide fait échouer le boot avec un message nommant la variable (jamais de fallback silencieux). Un segment mal orthographié (module, app, ou chemin) déclenche un « vouliez-vous dire X ? » façon git, avec les clés disponibles. Non-chevauchement : un champ qui a une variable dédiée au catalogue de l’APPLICATION (env.ts) n’est pas aussi piloté par NF__* (la variable nommée fait foi). Le nom court d’un MODULE suit sa propre règle — voir « Pour commencer » plus haut.

Limite assumée pour NF__APP__* : seuls les champs qui ont un défaut framework (domain, servers.*, log.*) sont surchargeables génériquement. Un champ app opt-in sans défaut (domainCheck, domainAlias) doit être déclaré dans nodefony.config.ts (ou câblé au catalogue ctx.env) pour devenir adressable — sinon WARNING + suggestion, jamais de clé fantôme.

Structure d’un module — une source de vérité (règle d’or)#

Chaque module porte sa config dans un schéma Zod commenté = la seule source de : type (z.infer), validation, défaut (.default()), doc (.describe()) et formulaire Studio (z.toJSONSchema). Un défaut n’est jamais re-tapé ailleurs. Forme : config.ts (le schéma = QUOI, lisible) + defineXConfig.ts (builder pur = COMMENT, ~15 lignes). @nodefony/drizzle = module de référence de cette forme à deux fichiers. Voir ADR-0006 (D1/D2).

Héritage quand Nodefony est une dépendance#

Un projet qui fait npm i nodefony a les mêmes nodefony.config.ts + env.ts, hérite automatiquement des défauts du core et de chaque module (deep-merge au boot), n’écrit que ses écarts, et son devops surcharge par NF__*/*_FILE en Docker — sans toucher au code du projet ni du framework. C’est le modèle Spring Boot starter / Symfony bundle, transposé en TS.

📖 Lexique#

Terme Ce que c’est
defineConfig La fonction qui compose la configuration de l’application (defineConfig.ts#defineConfig). Elle ne lit jamais l’environnement — elle assemble.
env.ts Le seul endroit qui lit l’environnement. Tout le reste reçoit des valeurs déjà résolues, ce qui rend la configuration testable sans variables.
use() Déclarer un module et le configurer avec ses types (use.ts#use) : une clé mal orthographiée ne compile pas.
Fusion profonde Les défauts du cœur et de chaque module servent de socle ; votre fichier n’écrit que les écarts.
Provenance D’où vient chaque valeur retenue : défaut, fichier, variable d’environnement. Elle reste consultable après le boot.
hot / boot Une clé est soit relue à chaud, soit figée au démarrage. Confondre les deux fait croire qu’un réglage « ne prend pas ».

⚠️ Pièges#

🧪 Tests & couverture#

Les chiffres exacts vivent dans la carte de l’aperçu, régénérée depuis vitest — jamais figés ici.

Type Où Ce qui est prouvé
Unitaires (composition) nodefony defineConfig.test.ts, configUse.test.ts, configBoot.test.ts l’assemblage, le typage par module, ce qui est figé au démarrage
Unitaires (environnement) nodefony defineEnv.test.ts, loadEnv.test.ts, envOverride.test.ts, envExample.test.ts la lecture unique de l’environnement, les surcharges NF__*, le fichier d’exemple engendré
Unitaires (provenance) nodefony configProvenance.test.ts, infra.test.ts, podEnvironment.test.ts d’où vient chaque valeur retenue, et ce que le pod ajoute
Unitaires (par module) @nodefony/framework config.test.ts, configMutation.test.ts · @nodefony/http httpConfig.test.ts · @nodefony/security defineSecurityConfig.test.ts · @nodefony/realtime defineRealtimeConfig.test.ts que chaque module valide bien la sienne

🔗 Pour aller plus loin#