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= le catalogue typé deenv.ts(ctx.env.NF_LOG_DRIVERest auto-complété + documenté en hover).runtimeEnv=NODE_ENVcanonisé ;appEnv= axe de déploiement libre (APP_ENV/NF_ENV).
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 }),
});
- Une variable présente mais invalide (enum hors liste, nombre malformé, requise manquante) fait échouer le boot avec un message clair nommant la variable — pas de fallback silencieux.
- Une variable absente prend le défaut déclaré.
- ⚠️
as constsur les valeurs d’envEnum([...]): sinon l’union littérale ("stdout" | "file" | "null") est élargie enstring, et un champ qui attend l’union (ex.log.driver) ne typecheck plus.
Les 6 recettes (faire grandir la config)#
- Ajouter un module → ajouter son nom dans
modules. - Configurer un module →
use("@nodefony/security", { firewalls: { … } }). - Module dev/conditionnel →
{ name, policy: "dev" }ouuse(name, config, { when: (c) => … }). - Réglage par-env → tester
ctx.isProd/ctx.isDevdans la fonction. - Lire une variable d’env → la déclarer dans
env.ts, lirectx.env.X(jamaisprocess.env). - Configurer un module → un fichier
nodefony/config/<module>.tsque 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.
- Ce qu’un automate garde : si tu extrais, tu écris
satisfies.nodefony doctorrefuse un fragment qui s’en passe (fragment-without-satisfies). C’est vérifiable sans jugement, donc c’est une règle. - Ce qui relève de la convention : quand extraire. Une application qui naît reçoit ses deux gros blocs déjà extraits — c’est l’exemple sous les yeux —, et laisse en ligne un réglage d’une clé. Ce dépôt, lui, applique la forme à tous ses modules configurés : il est mature, et la prévisibilité y vaut plus que la concision.
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
satisfiesdé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. Seulsatisfiessur le littéral le fait.C’est pourquoi
nodefony doctorrefuse un fragment qui rend une configuration sanssatisfies(constatfragment-without-satisfies) : extraire un bloc ne rend pas l’application plus sûre, cela lui RETIRE une garde — etsatisfiesest 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ésmodule-<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).
hot— applicable à chaud. Aujourd’hui trois chemins exactement :log.active,log.debug,log.requestFormat(le cadre de la « fenêtre d’audit » : élever la verbosité en production, sans redémarrer).boot— figé au boot, un changement exige un redémarrage : tout le reste, ports,protocol, listemodules,domaincompris.
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 :
- Seules les clés de la LISTE BLANCHE du module se surchargent. Un module qui n’en déclare pas
n’accepte aucun calque ; une clé hors liste est refusée en la nommant. Aujourd’hui, seul
@nodefony/httpen déclare une :maxBodySizeet les limitesupload.*(horsuploadDir). Secrets, pare-feu, en-têtes de sécurité, sessions, limites de débit : jamais. Le tableau complet, module par module et avec ses raisons, est dans l’ADR-0012. onRequestScopeest le bon moment : le premier point où la requête a son contexte, AVANT la lecture du corps. Un calque posé plus tard — au pare-feu, dans un contrôleur — arrive après l’application des quotas de corps.- En WebSocket, le calque suit la connexion : il vaut pour tous les messages de la socket.
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’estinspectqui 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ériqueNF__*et secrets*_FILEsont 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 :
- Catalogue (
env.ts, formeNF_X) — secrets, choix structurants, défauts à logique, exposés typés dansctx.env. Expérience développeur. - Override générique (forme
NF__<MODULE>__<CHEMIN>) — surcharge de n’importe quel champ d’un module, sans code, coercée + validée par le schéma Zod du module. Expérience devops. - Override de la config APP (forme
NF__APP__<CHEMIN>) — même mécanique, segment réservéapp(jamais un nom de module → 0 collision). Surchargedomain,servers.*,log.*… validés par le Zod app au resolve. Plus de « comment je surcharge le port / le domaine / le driver de log ? ».
__ (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é dansnodefony.config.ts(ou câblé au cataloguectx.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#
- Une clé qu’un module n’a pas déclarée fait échouer le démarrage. Les schémas des modules sont
stricts (
z.strictObject) : une faute de frappe lève uneBootConfigurationErrorqui la nomme.use()avance l’erreur à la compilation. Seule la racine de la configuration d’application écarte encore une clé inconnue au lieu de la refuser. - Ne jamais déréférencer le kernel à l’évaluation d’un fichier de configuration. Il n’existe pas
encore au moment de l’import : le module devient non importable et non testable. Utilisez un
accesseur (
get filename() { … }), résolu à la lecture. - Modifier une clé
bootsans redémarrer ne change rien, et rien ne le signale. Vérifiez le régime de la clé avant de conclure que la configuration est ignorée. - En développement, une modification de configuration demande une reconstruction — la section dédiée ci-dessus dit laquelle. Un réglage qui « ne prend pas » vient souvent de là.
- Les variables d’environnement du framework se préfixent
NF_. Les noms génériques appartiennent à d’autres outils, et une collision ne se manifeste jamais par une erreur : juste par un comportement inexplicable. Le préfixe dit à qui appartient la valeur, pas qui la lit : ce que votre application se donne à elle-même le porte aussi. Trois choses en sont dispensées — ce que l’on ne possède pas (NODE_ENV,CI), les alias qu’un hébergeur pose lui-même (DATABASE_URL,REDIS_URL, acceptés en second rang derrière la formeNF_), et les identifiants émis par un service tiers dont l’écosystème fixe déjà le nom (GOOGLE_CLIENT_ID,GITHUB_CLIENT_SECRET) : ceux-là, vous les avez déjà, Nodefony les lit tels quels plutôt que de vous les faire recopier.
🧪 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#
- ⬆️ Retour au hub : Guides · Toute la documentation
- 🏛️ Le concept, et non la recette : architecture — configuration
- 🗄️ Déclarer son infrastructure plutôt que huit backends :
persistence.md - 🐳 Surcharger en conteneur :
docker-cloud-native.md - 🔄 Le moment où la configuration est lue : cycle de boot du Kernel
- 📖 Lexique général du framework.