Système de fichiers en Node.js — fs.promises, watchers, atomic writes
TL;DR —
fs.promisesest la surface API moderne — utilise-la systématiquement sauf pour les scripts de bootstrap oùfsSyncreste légitime.fs.watchest rapide mais bourré de pièges multi-plateformes (renames vs modifications confondus sur macOS, events doublonnés sur Linux) ; pour du watch fiable, prendschokidar. Pour écrire un fichier sans risquer un état corrompu (crash en plein milieu), suis le pattern atomique : écris dans un fichier temporaire dans le même répertoire,fsync(), puisrename()(atomique au niveau du même filesystem). Pour les gros fichiers, n'utilise jamaisreadFile: passe parcreateReadStream+pipeline. Sécurise toujours les chemins fournis par l'utilisateur contre le directory traversal (path.resolve+ vérification du préfixe). Les erreurs typiques (ENOENT,EACCES,EBUSY,EMFILE,ENOSPC,EXDEV) ont chacune un traitement spécifique.
🧠 Mental model — ASCII + analogie
Le filesystem est un magasin d'archives géant avec plusieurs employés : le kernel met en queue tes demandes (read, write, stat), un thread du threadpool libuv (4 threads par défaut) exécute l'I/O bloquant, et te rappelle quand c'est fini. Toutes les API fs (sauf *Sync) traversent ce threadpool. Si tu satures le pool (4 threads, 4 reads bloquantes simultanées sur un disque lent), les autres requêtes attendent — y compris DNS resolutions et compressions zlib qui partagent le même pool.
┌──────────────────────────────────────┐
│ Node.js process │
│ │
await fs.readFile(...) ─┼─▶ Event loop (V8 + libuv) │
│ │ │
│ ▼ enqueue │
│ ┌──────────────────────────────┐ │
│ │ libuv threadpool (4 par │ │
│ │ défaut, UV_THREADPOOL_SIZE)│ │
│ │ ▲ │ │
│ │ │ syscalls bloquantes │ │
│ │ ▼ │ │
│ │ open / read / write / │ │
│ │ stat / fsync / rename │ │
│ └──────────────────────────────┘ │
│ │ │
│ ▼ │
│ kernel + filesystem (ext4, APFS, │
│ NTFS, NFS, S3FS...) │
└──────────────────────────────────────┘
Atomic write :
┌─────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐
│ open() │ ─▶ │ write() │ ─▶ │ fsync() │ ─▶ │rename()│
│ tmpfile │ │ payload │ │ flush OS │ │atomic │
└─────────┘ └──────────┘ └──────────┘ └────────┘
│
target.json ◀───────────────────────────────────────────────────────┘
Watch : chokidar ──▶ délègue à fs.watch (Linux=inotify, macOS=FSEvents,
Windows=ReadDirectoryChangesW)
normalise events, debounce, ré-arme sur renamesL'analogie : fs est un service postal. Tu peux envoyer une lettre normalement (fs.promises.readFile), expédiée par le facteur en arrière-plan, ou tu peux te déplacer toi-même en bloquant tout (fs.readFileSync). Les écritures atomiques sont l'équivalent de signer un contrat sur brouillon, puis remplacer l'original d'un coup sec — personne ne voit jamais un document à moitié écrit.
🛠️ Code minimal (ts/js)
// Lecture / écriture simples (petits fichiers < 10 MiB)
import { readFile, writeFile, stat, mkdir, rm, readdir } from "node:fs/promises";
import { join } from "node:path";
const txt = await readFile("/etc/hostname", "utf8");
await writeFile("/tmp/out.txt", "hello\n", { mode: 0o600 });
const s = await stat("/tmp/out.txt");
console.log(s.size, s.mtimeMs, s.isFile(), s.isDirectory(), s.isSymbolicLink());
await mkdir("/tmp/data/sub", { recursive: true });
await rm("/tmp/old", { recursive: true, force: true });
for (const entry of await readdir("/tmp", { withFileTypes: true })) {
if (entry.isFile()) console.log("file:", entry.name);
}// Atomic write — pattern incontournable en prod
import { rename, open, mkdir, stat, unlink } from "node:fs/promises";
import { dirname, join } from "node:path";
import { randomBytes } from "node:crypto";
interface AtomicOpts {
mode?: number; // permissions si le fichier n'existe pas encore
fsync?: boolean; // durabilité dure (défaut true) — désactive pour un cache
}
async function writeAtomic(
path: string,
data: string | Buffer,
opts: AtomicOpts = {},
): Promise<void> {
const { mode, fsync = true } = opts;
const dir = dirname(path);
await mkdir(dir, { recursive: true });
// Préserve les permissions d'un fichier existant (sinon utilise opts.mode / 0o600).
let targetMode = mode ?? 0o600;
try {
targetMode = mode ?? ((await stat(path)).mode & 0o777);
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
}
// Le tmpfile DOIT être dans le même répertoire que la cible (même filesystem → rename atomique).
const tmp = join(dir, `.tmp.${process.pid}.${randomBytes(6).toString("hex")}`);
const fh = await open(tmp, "wx", targetMode); // 'wx' = échoue si existe (pas de collision)
try {
await fh.writeFile(data);
if (fsync) await fh.sync(); // fsync : force le flush vers disque (durabilité)
} catch (e) {
await fh.close();
await unlink(tmp).catch(() => {}); // ne laisse jamais de déchet derrière soi
throw e;
}
await fh.close();
await rename(tmp, path); // atomique sur le même filesystem (POSIX rename(2))
// fsync du répertoire pour persister le rename lui-même (sinon la durabilité du
// contenu ne garantit pas que l'entrée de répertoire pointe bien sur le nouveau fichier).
if (fsync) {
const dh = await open(dir, "r");
try { await dh.sync(); } finally { await dh.close(); }
}
}
await writeAtomic("/var/lib/app/state.json", JSON.stringify({ v: 1 }));Pourquoi cet ordre exact ?
open(wx)→write→fsync(fichier)→rename→fsync(répertoire). Inverserfsyncetrenamecasse la garantie : après un crash, tu peux avoir une entrée de répertoire qui pointe sur un fichier dont le contenu n'a pas atteint le disque. C'est la même séquence que SQLite et quewrite-file-atomic(npm). Lefsyncdu répertoire est l'étape la plus souvent oubliée — sans elle, sur ext4 en modedata=writeback, le rename peut être perdu alors que le contenu est durable.
// Streaming d'un gros fichier (copy avec back-pressure)
import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
await pipeline(
createReadStream("/data/big.csv"),
createGzip({ level: 6 }),
createWriteStream("/data/big.csv.gz")
);
// Copie native (plus efficace, utilise reflink/clone si dispo : btrfs, APFS)
import { copyFile, constants } from "node:fs/promises";
await copyFile("/src.bin", "/dst.bin", constants.COPYFILE_FICLONE);// Watch fiable avec chokidar (npm i chokidar)
import chokidar from "chokidar";
const watcher = chokidar.watch("/srv/config", {
ignored: /(^|[/\\])\../, // ignore les dot-files
persistent: true,
awaitWriteFinish: { // attend que l'écriture soit stable
stabilityThreshold: 200,
pollInterval: 50,
},
ignoreInitial: true, // ne pas émettre pour les fichiers existants au boot
});
watcher
.on("add", (p) => console.log("added:", p))
.on("change", (p) => reloadConfig(p))
.on("unlink", (p) => console.log("removed:", p))
.on("error", (e) => console.error("watch error:", e));
// Cleanup propre
process.on("SIGTERM", () => watcher.close());// Sécurité : empêcher le directory traversal
import { resolve, relative, sep } from "node:path";
function safeJoin(base: string, userPath: string): string {
const target = resolve(base, userPath);
const rel = relative(base, target);
if (rel.startsWith("..") || rel.startsWith(sep) || rel === "") {
throw new Error("path_outside_base");
}
return target;
}
const file = safeJoin("/var/uploads", req.query.path as string);
const content = await readFile(file);🎯 Patterns courants
Lock files. Pour empêcher deux process d'écrire la même ressource, crée un fichier .lock avec open(path, 'wx') (échoue si existe). Pose un timeout pour le retirer si crashé. Utilise plutôt proper-lockfile qui gère les locks stale via mtime.
Append-only logs. fs.appendFile ouvre/écrit/ferme à chaque appel — inefficient sous charge. Préfère un WriteStream long-lived avec flags: 'a'. Pour la rotation, garde un quota de taille (stat().size) et rename quand dépassé. Pas de write concurrents sur le même FD.
Cache file-based. Pour cacher des données coûteuses (resize d'image, build, etc.), hash le contenu (sha256) et stocke dans cache/<hash[0:2]>/<hash>. Avantage : pas de pression mémoire, partagé entre redémarrages, vérifiable.
Tail un fichier. Pour lire un log en continu : createReadStream(path, { start: position }) puis fs.watch sur le fichier pour détecter change et relire à partir de la nouvelle taille. Attention au rename (logrotate) : surveille aussi l'inode.
Walk récursif performant. fs.readdir(path, { withFileTypes: true, recursive: true }) (Node 18.17+) est plus rapide que ta propre récursion. Pour des trees énormes, parallélise par chunks avec p-limit pour éviter de saturer le threadpool.
Permission scoping. Quand tu crées un fichier de configuration sensible (token, clé privée), force mode: 0o600 (lecture/écriture pour le propriétaire uniquement). Ne te fie pas à l'umask par défaut.
Découpe par chunks pour les transferts massifs. Quand tu dois copier ou hasher 100 GB, ne fais pas un seul read-write : ouvre le FD, lis par chunks de 64 KiB-1 MiB selon ton stockage, et progresse. Tu peux ainsi reporter la progression (ETA, %), tolérer des annulations, et reprendre où tu en étais.
import { open } from "node:fs/promises";
import { createHash } from "node:crypto";
async function hashFile(path: string, signal?: AbortSignal): Promise<string> {
const fh = await open(path, "r");
const hasher = createHash("sha256");
const buf = Buffer.alloc(1 << 16); // 64 KiB
try {
while (true) {
signal?.throwIfAborted();
const { bytesRead } = await fh.read(buf, 0, buf.length);
if (bytesRead === 0) break;
hasher.update(buf.subarray(0, bytesRead));
}
} finally {
await fh.close();
}
return hasher.digest("hex");
}Sync au boot. Lire la config au démarrage avec readFileSync est parfaitement acceptable et plus simple. Réserve la version sync à : (1) chargement de config au boot avant que la app commence à servir ; (2) hooks de shutdown ; (3) scripts CLI courts.
// Tail robuste avec rotation
import { createReadStream, watch } from "node:fs";
import { stat } from "node:fs/promises";
async function tail(path: string, onLine: (l: string) => void) {
const initial = await stat(path);
let pos = initial.size;
let inode = initial.ino;
const reread = async () => {
const s = await stat(path);
if (s.ino !== inode) { inode = s.ino; pos = 0; } // rotation
if (s.size < pos) pos = 0; // truncate
if (s.size === pos) return;
let buf = "";
await new Promise<void>((res, rej) => {
createReadStream(path, { start: pos, end: s.size - 1, encoding: "utf8" })
.on("data", (c) => { buf += c; })
.on("end", () => {
buf.split("\n").forEach((l) => l && onLine(l));
pos = s.size; res();
})
.on("error", rej);
});
};
watch(path, () => { reread().catch(() => {}); });
}🔄 Versions — Node 18 / 20 / 22 / 24
Node 18. fs.promises.cp() stable (copie récursive). fs.promises.readdir({ recursive: true }) ajouté en 18.17. fs.watch({ recursive: true }) toujours instable sous Linux (utilise un fallback fd par dossier, coûteux).
Node 20. node:fs/promises ajoute glob() et globSync() (initialement expérimentaux, stabilisés plus tard). Améliorations sur cp (préservation des permissions, dereference des symlinks). fs.openAsBlob(path) retourne un Blob paresseux (utile pour passer à fetch en upload sans charger en mémoire).
Node 22. fs.promises.glob devient stable. Le permission model expérimental (--experimental-permission) gagne --allow-fs-read=path / --allow-fs-write=path — utile pour sandbox des scripts.
Node 24. Permission model stable (sans flag --experimental). --permission --allow-fs-read=/srv --allow-fs-write=/var/tmp bloque tout accès hors zones. fs.watch voit ses callbacks dédupliqués plus agressivement sous Linux. API Dir (handle de répertoire) gagne dir.read() typé Dirent plus précis. Optimisations significatives sur cp recursif pour les arborescences contenant beaucoup de petits fichiers.
Note sur Bun et Deno. Si tu cibles plusieurs runtimes, sache que Bun expose node:fs quasi compatible mais ses primitives natives sont plus rapides pour readFile (mmap dans certains cas). Deno utilise Deno.readTextFile qui est différent — un layer de compat node:fs/promises existe mais pas 100 % fidèle.
// Glob natif (Node 20+ expérimental, 22+ stable)
import { glob } from "node:fs/promises";
for await (const f of glob("src/**/*.ts", { exclude: ["**/*.test.ts"] })) {
console.log(f);
}🔄 Streams fs en profondeur
Les streams fs exposent les concepts standard Node (Readable/Writable) au-dessus des FDs.
createReadStream(path, { start, end, highWaterMark, encoding }).
start/end: range d'octets (endinclus). Idéal pour Range requests HTTP.highWaterMark: taille du buffer interne. Défaut 64 KiB. Augmenter à 256 KiB-1 MiB peut améliorer le débit sur SSD rapide. Diminuer économise la RAM si beaucoup de streams concurrents.encoding: si set, tu reçois des strings au lieu de Buffer. À ne pas utiliser sur du binaire — risque de corrompre des surrogates UTF-8 si le chunk coupe au milieu d'un caractère.
import { createReadStream } from "node:fs";
import { ServerResponse, IncomingMessage } from "node:http";
import { stat } from "node:fs/promises";
async function serveFileWithRange(req: IncomingMessage, res: ServerResponse, path: string) {
const s = await stat(path);
const range = req.headers.range;
if (!range) {
res.writeHead(200, {
"content-length": s.size,
"accept-ranges": "bytes",
});
createReadStream(path).pipe(res);
return;
}
const m = range.match(/^bytes=(\d+)-(\d+)?$/);
if (!m) {
res.writeHead(416);
res.end();
return;
}
const start = Number(m[1]);
const end = m[2] ? Math.min(Number(m[2]), s.size - 1) : s.size - 1;
res.writeHead(206, {
"content-range": `bytes ${start}-${end}/${s.size}`,
"content-length": end - start + 1,
"accept-ranges": "bytes",
});
createReadStream(path, { start, end }).pipe(res);
}createWriteStream. Avec flags: 'a' pour append, flags: 'wx' pour échouer si existe (anti-écrasement). Gère le back-pressure automatiquement quand consommé via pipeline. Toujours écouter l'event error ; un disque plein produit ENOSPC.
pipeline vs pipe. stream.pipeline (ou stream/promises.pipeline) gère les erreurs et propage destroy() à tous les streams en cas de bug. .pipe() legacy laisse des sockets/fichiers ouverts si une erreur survient. Toujours préférer pipeline.
🧮 Comparaison readFile vs createReadStream
| Critère | readFile | createReadStream |
|---|---|---|
| Mémoire | Tout en RAM (taille fichier) | Buffer config (64 KiB déf.) |
| Latence first byte | Élevée (lit tout puis retourne) | Faible (commence vite) |
| Code | Simple (1 ligne async) | Plus verbeux (events/pipe) |
| Back-pressure | Pas applicable | Géré automatiquement avec pipeline |
| Convient pour | < 10 MiB | > 10 MiB ou streaming |
⚠️ Pitfalls — 6 à 10
fs.watchconfondue rename et modify. Sur macOS, FSEvents émet souventrenamepour ce qui ressemble à unchange. Sur Linux, inotify émet plusieurs events pour une seule sauvegarde d'éditeur (write + rename). Pour du watch fiable, passe parchokidarqui normalise et debounce.renamecross-device. Si tu écris ton tmpfile dans/tmp(souvent un tmpfs séparé) et turenamevers/var/lib/app/, tu obtiensEXDEV: cross-device link. Garde TOUJOURS le tmpfile dans le même répertoire que la cible finale.- Lecture entière d'un gros fichier.
await readFile('5gb.bin')tente d'allouer 5 GiB en mémoire — OOM garanti. Plafond historique 2 GiB sur 32-bit. UtilisecreateReadStreamou un range avecfileHandle.read(buffer, offset, length, position). - Pas de
fsync= pas de durabilité.writeFileretourne quand l'OS a accepté la donnée, mais le contenu est en page cache. En cas de coupure d'alimentation, tu peux perdre le fichier malgré le succès apparent. AppellefileHandle.sync()si la durabilité compte (config persistante, état de transaction). EMFILE: too many open files. Limite ulimit (256 par défaut sur macOS). Si tu ouvres 10k fichiers en parallèle, tu satures. Limite la concurrence avecp-limit(10-50 selon le cas) et ferme TOUJOURS lesFileHandle(try/finally).EACCESsous Docker / non-root. Le UID dans le container ne match pas l'owner du volume monté. Soit tu changes l'ownership (chown), soit tu fais tourner l'app avec le bon UID (USER 1000).- Symlinks et
realpath.readlinkte donne la cible brute ;realpathla résout complètement (suit la chaîne). Attention aux loops (A -> B -> A) qui font échouerrealpathavecELOOP. - Permissions sur Windows.
chmodn'a quasi aucun effet sur Windows (NTFS gère via ACL). Ne te fie pas aux permissions Unix-style pour la sécurité en cross-platform. - Atomic write et tooling externe. Si un éditeur (VSCode, vim) lit ton fichier juste après
rename, il peut voir une absence brève si tu utiliseswrite + delete + rename. Le patternwrite tmp + rename(sans delete intermédiaire) est seul atomique POSIX. - Threadpool saturation. Si tu fais beaucoup d'I/O fs parallèle (parsing 1000 fichiers en
Promise.all), tu satures les 4 threads de libuv et ralentis aussi DNS, crypto, zlib. Augmente avecUV_THREADPOOL_SIZE=16(jusqu'à 1024) ou limite la concurrence côté app. fs.watchqui s'arrête après une rotation. Si l'éditeur (ou logrotate) supprime puis recrée le fichier, le watch initial reste attaché à l'ancien inode (qui n'existe plus) — il devient silencieux. Ré-attache un watch après détection de l'eventrename.fileHandlenon fermé. Chaqueopen()doit avoir unclose()correspondant. Sans, tu fuites des FDs (limite ulimit). Toujours entry/finally. Leusing(ES proposal, supporté par TypeScript 5.2+ avec--target esnext) permetawait using fh = await open(...)qui ferme automatiquement.- Chemins Windows.
\\?\prefix pour les chemins longs (> 260 chars).path.win32vspath.posixselon contexte. Ne hardcode jamais/en séparateur cross-platform — utilisepath.join.
// Pattern : using pour cleanup automatique (Node 22+ avec ESM stage 3)
import { open } from "node:fs/promises";
async function readPart(path: string): Promise<Buffer> {
await using fh = await open(path, "r");
const buf = Buffer.alloc(1024);
const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
return buf.subarray(0, bytesRead);
// fh.close() automatique à la sortie du scope
}🧪 Testing — node --test
// fs.test.ts
import { test, before, after, describe } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm, readFile, writeFile, stat, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
let dir: string;
before(async () => { dir = await mkdtemp(join(tmpdir(), "fs-test-")); });
after(async () => { await rm(dir, { recursive: true, force: true }); });
describe("writeAtomic", () => {
test("crée le fichier avec le contenu attendu", async () => {
const p = join(dir, "config.json");
await writeAtomic(p, '{"k":1}');
assert.equal(await readFile(p, "utf8"), '{"k":1}');
});
test("ne laisse pas de tmp file après succès", async () => {
const { readdir } = await import("node:fs/promises");
const entries = await readdir(dir);
const tmps = entries.filter((e) => e.startsWith(".tmp."));
assert.equal(tmps.length, 0);
});
test("écrasement préserve les permissions cibles", async () => {
const p = join(dir, "perms");
await writeAtomic(p, "a");
const before = await stat(p);
await writeAtomic(p, "b");
const after = await stat(p);
assert.equal(before.mode & 0o777, after.mode & 0o777);
});
});
describe("safeJoin", () => {
test("autorise un chemin sous le base", () => {
assert.equal(safeJoin("/srv", "users/42.json"), "/srv/users/42.json");
});
test("bloque le traversal", () => {
assert.throws(() => safeJoin("/srv", "../etc/passwd"), /outside/);
assert.throws(() => safeJoin("/srv", "/etc/passwd"), /outside/);
});
});node --test --test-reporter=spec fs.test.tsPour tester les comportements liés à fs.watch, simule des modifications réelles avec writeFile puis vérifie que ton observateur déclenche le bon nombre de callbacks (avec debounce). Évite les setTimeout arbitraires — préfère attendre un event explicite.
Tester l'atomicité. Simule un crash en plein milieu d'une écriture pour vérifier que ton code de récupération fonctionne. Pattern : injecte une fonction crashHook dans writeAtomic qui peut throw au milieu, et vérifie que (1) le fichier final est soit l'ancien soit le nouveau (jamais corrompu), (2) le tmpfile est nettoyé.
test("crash entre write et rename : ancien fichier intact", async () => {
const p = join(dir, "atomic.json");
await writeFile(p, '{"v":1}');
await assert.rejects(async () => {
await writeAtomicWithCrash(p, '{"v":2}', "before-rename");
});
// Le fichier original doit toujours contenir {"v":1}
assert.equal(await readFile(p, "utf8"), '{"v":1}');
});Sandboxing dans les tests. N'écris jamais dans /tmp directement — utilise mkdtemp pour obtenir un répertoire unique qui sera supprimé en after(). Évite la pollution entre tests parallèles et te permet de lancer --test-concurrency=N sans race conditions.
🎬 Cas d'usage concrets
Scénario 1 — Ingestion dossiers cabinet juridique batch
Cabinet d'avocats avec un système d'ingestion nocturne : dépose de dossiers clients en SFTP, structurés en arborescence /dossiers/<client>/<matiere>/<dossier>/*.pdf. Volume : 800 dossiers/nuit, 12 000 PDF totalisant 18 GB.
Job lit récursivement via fs.opendir (async iterator, économie mémoire vs readdir récursif qui retourne tout d'un coup). Pour chaque PDF, calcule SHA-256 streaming, vérifie dans Postgres si déjà ingéré, sinon move vers /processed/<sha>.pdf via rename (atomique sur même filesystem) et upsert en base. Limite de concurrence 8 via p-limit pour ne pas saturer les inodes.
Astuce : utilisation de fs.promises.cp avec { recursive: true, force: false, errorOnExist: true } pour la copie initiale vers une zone de quarantaine. Et fs.access(path, constants.W_OK) au démarrage pour fail-fast si la zone est read-only (déjà vu en prod après un mount NFS qui flip).
Scénario 2 — Atomic writes config banque
Banque dont un service expose une config dynamique (paramètres scoring, seuils) modifiable par admins, rechargée à chaud par les workers via fs.watch. Toute écriture partielle = workers qui lisent un JSON tronqué = scoring buggé en prod.
Pattern atomique strict : fs.writeFile(path + ".tmp", JSON.stringify(config)) puis fs.fsync (forcer le flush disque) puis fs.rename(path + ".tmp", path) (atomique POSIX). Les watchers ne voient jamais un fichier partial — rename est une opération atomique du filesystem. La séquence est encapsulée dans une fonction atomicWriteJson testée avec un fuzz test qui kill -9 le process aléatoirement et vérifie que le fichier final est soit l'ancien valide, soit le nouveau valide, jamais corrompu.
Audit RGPD : chaque écriture est aussi loggée avec checksum SHA-256 du contenu et user qui a déclenché le changement.
Scénario 3 — Watch dossiers immo annonces
Plateforme immobilière qui reçoit en partenariat des dépôts FTP de plusieurs portails (SeLoger, LeBonCoin enterprise). Chaque partenaire dépose un XML d'annonces dans /inbound/<partenaire>/. Le service doit ingérer dans la minute.
fs.watch(directory, { recursive: true }) avec debounce 500 ms (un dépôt = parfois write + chmod + rename = 3 événements). À chaque événement de type rename (nouveau fichier), lecture, validation XSD, conversion en JSON normalisé, push Postgres. Si lecture échoue (ENOENT), retry 3 fois à 200 ms d'intervalle — sur certains FTP le fichier apparaît avant que close() ne soit terminé.
Sur Linux (inotify) le watch est fiable. Sur macOS (FSEvents) recursive peut manquer des fichiers sous-jacents — fallback : poll readdir toutes les 10 s en parallèle pour réconcilier. Aucune dépendance à chokidar ; le code natif suffit pour ce cas borné.
🛠️ Exemple end-to-end
Watcher dossier inbound annonces immo : détection nouveaux fichiers, atomic processing, dédoublonnage SHA-256, atomic move vers archive ou rejected.
import { watch } from "node:fs";
import { open, rename, stat, mkdir, access } from "node:fs/promises";
import { createReadStream } from "node:fs";
import { createHash } from "node:crypto";
import { join, basename } from "node:path";
import { constants } from "node:fs";
import { setTimeout as delay } from "node:timers/promises";
const INBOUND = "/data/inbound";
const PROCESSED = "/data/processed";
const REJECTED = "/data/rejected";
async function ensureDir(d: string) {
await mkdir(d, { recursive: true });
}
async function sha256(path: string): Promise<string> {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk as Buffer);
return hash.digest("hex");
}
async function isStable(path: string, settleMs = 400): Promise<boolean> {
// retry up to 5 times: file may still be uploading
let prev: number | null = null;
for (let i = 0; i < 5; i++) {
try {
const s = await stat(path);
if (prev !== null && s.size === prev) return true;
prev = s.size;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
throw err;
}
await delay(settleMs);
}
return false;
}
async function processFile(src: string) {
if (!(await isStable(src))) return;
let fd;
try {
fd = await open(src, "r");
const buf = Buffer.alloc(5);
await fd.read(buf, 0, 5, 0);
if (buf.toString() !== "<?xml") {
const dst = join(REJECTED, basename(src));
await rename(src, dst);
console.warn(`[rejected] ${src} not xml`);
return;
}
} finally {
await fd?.close();
}
const digest = await sha256(src);
const dst = join(PROCESSED, `${digest}.xml`);
try {
await access(dst, constants.F_OK);
// already ingested: drop incoming
console.log(`[dup] ${src} sha=${digest}`);
return;
} catch {
// not present, proceed
}
await rename(src, dst); // atomic on same filesystem
console.log(`[ok] ${src} -> ${dst}`);
// here: enqueue downstream processing via SQS/BullMQ using the canonical path
}
async function main() {
await Promise.all([ensureDir(INBOUND), ensureDir(PROCESSED), ensureDir(REJECTED)]);
const debounce = new Map<string, NodeJS.Timeout>();
const watcher = watch(INBOUND, { recursive: true }, (event, filename) => {
if (!filename || event !== "rename") return;
const full = join(INBOUND, filename);
clearTimeout(debounce.get(full));
debounce.set(
full,
setTimeout(() => {
debounce.delete(full);
processFile(full).catch((err) => console.error(`[err] ${full}`, err));
}, 500)
);
});
process.on("SIGTERM", () => watcher.close());
}
await main();Points clés : isStable détecte fichier en cours d'écriture, atomic move via rename (même FS), dédoublonnage par hash content-addressed, debounce 500 ms pour absorber les bursts d'événements, séparation processed/rejected pour audit, pas de dépendance externe.
🔁 Quand utiliser / éviter
Utilise fs.promises pour tout le code applicatif. Bibliothèque officielle, intégration native avec await, gestion d'erreurs propre (typage NodeJS.ErrnoException). Plus aucun argument pour fs.callback à part le legacy.
Utilise fs.*Sync quand : tu fais un script CLI court (chargement de config, génération de fichier), tu es dans un hook synchrone (CommonJS require interceptors), ou pendant le boot avant que l'event loop ne serve quoi que ce soit. Sur des serveurs en cours d'exécution, c'est tabou : tu bloques le thread principal.
Utilise chokidar pour le watch en prod. fs.watch est correct pour des scripts simples mono-fichier, mais dès que tu touches à des arborescences, des éditeurs externes, ou tu veux survivre à des rotations, chokidar évite des semaines de bugs subtils.
Utilise les streams pour > 10 MiB. Au-delà, le coût mémoire de readFile + le pic GC le rendent contre-productif. Pipeline + back-pressure = mémoire stable.
Évite fs.realpathSync.native sur Windows réseau. Sur des shares SMB, c'est très lent (jusqu'à 100x). Préfère stocker les paths déjà résolus.
Évite d'ouvrir/fermer un fichier à chaque write. Pour des logs ou append-only, garde un WriteStream ou un FileHandle long-lived.
Évite de te fier à fs.exists (déprécié depuis longtemps). Le pattern correct : tente l'opération et catch ENOENT. stat() peut être trompeur (race condition entre check et action — TOCTOU).
Évite de stocker des secrets en plain dans un fichier sans chiffrement. Préfère node:crypto avec une master key dérivée d'une variable d'environnement, ou un secret manager (AWS Secrets Manager, Vault).
📦 Patterns d'intégration concrets
Cache atomique avec staleness check. Pour un cache disque qui ne sert pas une valeur obsolète :
import { stat, readFile } from "node:fs/promises";
import { join } from "node:path";
async function cacheGet<T>(key: string, maxAgeMs: number): Promise<T | null> {
const path = join("/var/cache/app", key);
try {
const s = await stat(path);
if (Date.now() - s.mtimeMs > maxAgeMs) return null;
return JSON.parse(await readFile(path, "utf8"));
} catch (e: any) {
if (e.code === "ENOENT") return null;
throw e;
}
}
async function cacheSet<T>(key: string, value: T): Promise<void> {
await writeAtomic(join("/var/cache/app", key), JSON.stringify(value));
}Upload streaming vers S3 / disque distant. Pour streamer un upload reçu en HTTP vers un stockage objet, pipeline le req (Readable) vers ton client S3 qui supporte Body: ReadableStream. Pas d'accumulation en mémoire ; back-pressure entre client HTTP, ton serveur Node, et S3.
Tail-then-watch d'un log applicatif. Combine lecture historique (depuis le début ou les N dernières lignes) puis watch pour la suite. Pattern utilisé par pm2 logs, docker logs -f, etc.
Indexation d'arborescence. Pour servir des fichiers à la demande, pré-calcule un index en RAM au boot (readdir({ recursive: true })) puis un watch chokidar pour invalider/ajouter. Évite des stat répétés sous charge.
Snapshots immuables. Plutôt qu'éditer un fichier en place, écris une nouvelle version à chaque change (/data/v123/state.json) et garde un symlink current qui pointe vers la dernière. Rollback = ln -sfn vers une version antérieure. Idéal pour de la config en production.
🔗 Liens
- Documentation
node:fs/promises: https://nodejs.org/api/fs.html#promises-api - Documentation
node:fs(callbacks et streams) : https://nodejs.org/api/fs.html - chokidar : https://github.com/paulmillr/chokidar
- proper-lockfile : https://github.com/moxystudio/node-proper-lockfile
- libuv threadpool : https://docs.libuv.org/en/v1.x/threadpool.html
- POSIX rename atomicity : https://pubs.opengroup.org/onlinepubs/9699919799/functions/rename.html
- Node permission model : https://nodejs.org/api/permissions.html
- write-file-atomic (référence d'impl) : https://github.com/npm/write-file-atomic
🐧 Spécificités plateformes
Linux. inotify est le mécanisme de watch (limité par /proc/sys/fs/inotify/max_user_watches, parfois trop bas pour des grosses arborescences — augmente à 524288). fallocate peut pré-allouer un fichier (perf pour DBs). Reflink natif sur Btrfs/XFS via copy_file_range. Permissions POSIX strictes ; xattr disponibles via packages tiers.
macOS. FSEvents pour le watch (niveau dossier, plus efficace qu'inotify mais moins granulaire). APFS supporte les clones (instantané, copies COW). Attention aux paths sensibles à la casse selon le filesystem (HFS+ insensible, APFS configurable).
Windows. ReadDirectoryChangesW pour le watch. Locks plus stricts qu'Unix : impossible de supprimer un fichier ouvert sans flag FILE_SHARE_DELETE. EBUSY fréquent quand un autre process a le handle. Pas de fork — child_process ne peut pas hériter de FDs facilement. Chemins UNC (\\server\share) et longueur max 260 chars sauf préfixe \\?\.
Conteneurs (Docker, K8s). Volumes montés ont leur propre filesystem (overlay2, tmpfs, NFS). Latence fsync peut être beaucoup plus haute sur des volumes réseau. inotify ne fonctionne PAS bien sur certains FUSE — fallback polling avec chokidar option usePolling: true. Le UID dans le container doit matcher l'owner du volume.
💾 Durabilité — le contrat fsync
Quand fs.writeFile résout, Node t'a transmis au kernel. Mais le kernel a probablement gardé la donnée en page cache (RAM). En cas de coupure d'alimentation, tu perds tout ce qui n'a pas été flushé sur disque physique.
fileHandle.sync()(équivalentfsync(2)) force le flush des données et metadata du fichier.fileHandle.datasync()(équivalentfdatasync(2)) flush données + metadata essentielles (pas atime). Plus rapide, suffisant pour la plupart des cas.- Pour un
renamedurable, il faut ALSOfsyncle répertoire parent (sinon le fichier est sur disque mais le rename pas encore committé).
Quand t'en soucies-tu ? Pour des données critiques (état d'une transaction, position dans un log immuable, identifiants uniques alloués). Pour du cache, c'est inutile et coûteux (fsync = 1-100 ms selon SSD/HDD/NFS).
// Persistence durable d'un counter monotone
async function persistCounter(value: number) {
const fh = await open("/var/lib/app/counter", "w");
try {
await fh.write(String(value));
await fh.datasync(); // assure que la donnée est sur disque
} finally {
await fh.close();
}
}🏋️ Exercices
Progression : implémenter → durcir pour la prod → casser puis réparer. Chaque exercice suppose Node 22+ et TypeScript (ou JS + JSDoc). Lance tes tests avec node --test.
Exercice 1 — writeAtomic qui survit à un kill -9 (fondation)
Objectif : écrire une fonction writeAtomicJson(path, obj) telle qu'à tout instant un lecteur concurrent voie soit l'ancienne version complète, soit la nouvelle complète, jamais un JSON tronqué — et le prouver par un test de fuzz.
Indice/Solution : tmpfile dans le même répertoire, fh.sync(), puis rename. Pour le test : fork() un worker qui boucle en lisant + JSON.parse le fichier ; dans le parent, écris 500 fois en alternant deux payloads de tailles très différentes (forcer des écritures multi-blocs) ; après chaque écriture, kill -9 aléatoirement un second writer. Le worker lecteur ne doit jamais lever de SyntaxError. Vérifie aussi qu'aucun .tmp.* ne subsiste (readdir + filtre).
Exercice 2 — Hash content-addressed d'une arborescence, borné en mémoire et en FDs (parallélisme)
Objectif : hashTree(root): Promise<Map<relpath, sha256>> qui parcourt récursivement, hash chaque fichier en streaming, et ne dépasse jamais 16 FDs ouverts simultanément ni n'alloue plus de ~1 MiB par fichier — même sur 100 000 fichiers.
Indice/Solution : opendir (async iterator) pour le parcours plutôt que readdir({recursive:true}) qui matérialise tout. Un p-limit(16) (ou une mini-sémaphore maison) autour du hash. Mesure : instrumente open/close avec un compteur global et assert qu'il ne dépasse jamais 16. Bonus : ajoute un AbortSignal qui annule proprement le walk en cours (signal.throwIfAborted() dans la boucle), et compare le temps mur avec UV_THREADPOOL_SIZE=4 vs =16.
Exercice 3 — Tail-with-rotation production-grade (observabilité)
Objectif : un Tailer qui suit un fichier de log, survit à logrotate (rename + recreate et copytruncate), n'émet jamais une ligne en double ni une ligne tronquée à la coupure de chunk, et expose des métriques (linesEmitted, rotations, truncations).
Indice/Solution : surveille l'inode (stat().ino) pour détecter le rename-recreate, et size < pos pour détecter le copytruncate. Garde un buffer résiduel pour la dernière ligne sans \n (ne l'émets qu'une fois \n arrivé). Préfère fs.watch + un poll de fallback (chokidar usePolling sur FUSE/NFS). Casse-le d'abord : prouve par un test que la version naïve (qui ne regarde que la taille) saute des lignes après rotation, puis corrige.
Exercice 4 — File-based lock correct, avec locks stale (concurrence + failure modes)
Objectif : withLock(resource, fn) qui sérialise l'accès entre process distincts, libère le lock même si le détenteur a crashé (kill -9), et ne deadlock jamais.
Indice/Solution : open(lockPath, 'wx') pour acquérir (échoue atomiquement si existe). Écris pid + timestamp dans le lock. Pour la détection de lock stale : si l'acquisition échoue, lis le lock, et si le PID n'existe plus (process.kill(pid, 0) lève ESRCH) ou que le mtime dépasse un TTL, supprime-le et retente (avec un petit jitter pour éviter la thundering herd). Casse-le : lance 50 process en parallèle qui incrémentent un compteur dans un fichier sous lock ; sans lock correct tu perds des incréments (prouve-le), avec lock le compteur final = 50.
Exercice 5 — Serveur de fichiers statiques sûr et streamé (sécurité + perf)
Objectif : un handler HTTP qui sert des fichiers depuis /var/www, supporte les Range requests (206), pose Content-Type/ETag/Last-Modified, gère If-None-Match (304), et résiste au directory traversal et aux symlinks d'évasion.
Indice/Solution : safeJoin + realpath du résultat, puis re-vérifie que le realpath est toujours sous la racine (un symlink dans /var/www pointant vers /etc/passwd doit être rejeté). createReadStream(path,{start,end}) + pipeline(stream, res) (jamais .pipe nu). ETag = ${stat.size}-${stat.mtimeMs} (faible) ou hash (fort). Casse-le : envoie GET /..%2f..%2fetc%2fpasswd (encodé) et un symlink piège — les deux doivent renvoyer 403/404, jamais le contenu.
Exercice 6 — Réparation : le service qui fuit des FDs et corrompt sa config (break-then-fix)
Objectif : on te donne un service (à reproduire) qui (a) sous charge lève EMFILE après quelques minutes et (b) laisse occasionnellement une config corrompue après reload. Diagnostique et corrige les deux sans changer l'API publique.
Indice/Solution : (a) cherche les open() sans close() correspondant dans un chemin d'erreur — typiquement un await readFile-style FileHandle ouvert manuellement sans try/finally, ou un WriteStream jamais end(). Reproduis avec lsof -p <pid> qui croît sans cesse, ou node --inspect + heap. Migre vers await using ou try/finally. (b) la corruption vient d'un writeFile direct (non atomique) lu par le watcher au mauvais moment → applique le pattern atomique de l'exercice 1. Vérifie avec un test de charge qui reload 1000×.
🎤 En entretien
Q : Pourquoi rename est-il atomique et write ne l'est pas ? Et que casse l'atomicité ? R : rename(2) est garanti atomique par POSIX sur le même filesystem : l'entrée de répertoire bascule d'un seul coup, un lecteur voit l'ancien inode ou le nouveau, jamais d'état intermédiaire. write ne l'est pas car un gros payload est découpé en plusieurs blocs et un lecteur (ou un crash) peut tomber au milieu. Ce qui casse l'atomicité : un tmpfile sur un autre filesystem (EXDEV, le rename devient copy+delete non atomique), ou un pattern delete + rename qui crée une fenêtre où le fichier n'existe pas.
Q : writeFile a résolu sans erreur, puis coupure de courant, et le fichier est vide. Comment ? R : la résolution de writeFile signifie seulement que le kernel a accepté la donnée — elle est en page cache (RAM), pas sur le plateau/NAND. Sans fsync/fdatasync, une coupure avant le flush perd tout. Et même avec fsync du fichier, si tu viens de faire un rename, il faut aussi fsync le répertoire parent pour que l'entrée de répertoire elle-même soit durable. C'est le contrat que les gens oublient : durabilité = données ET metadata flushées.
Q : Ton parsing de 5000 fichiers en Promise.all ralentit aussi tes requêtes DNS et ta compression gzip. Pourquoi ? R : toutes les opérations fs asynchrones (sauf *Sync), dns.lookup (getaddrinfo), crypto.pbkdf2 et zlib passent par le threadpool libuv, par défaut 4 threads seulement. Saturer ce pool avec des I/O fs bloque tout le reste en file d'attente. Remèdes : borner la concurrence applicative (p-limit), augmenter UV_THREADPOOL_SIZE (jusqu'à 1024) si le workload est réellement I/O-bound, et préférer dns.resolve (qui ne passe pas par le pool) quand c'est possible.
Q : Pourquoi fs.watch ne suffit pas en prod et qu'apporte chokidar ? R : fs.watch expose les primitives OS brutes (inotify/FSEvents/ReadDirectoryChangesW) sans normalisation : sémantique rename/change incohérente entre plateformes, événements doublonnés ou manquants, recursive non fiable sous Linux, et il devient silencieux quand le fichier surveillé est remplacé (l'inode d'origine disparaît). chokidar normalise les événements, gère le awaitWriteFinish (n'émet qu'une fois l'écriture stable), ré-arme après rotation, et offre un fallback usePolling indispensable sur NFS/FUSE/volumes Docker où inotify ne propage pas.
Récap final. Le système de fichiers en Node.js est trompeur de simplicité — l'écrasante majorité des incidents en prod liés au fs viennent de trois erreurs : (1) écritures non atomiques qui laissent des états corrompus après un crash ; (2) absence de gestion du back-pressure sur les gros fichiers (OOM ou GC pressure) ; (3) chemins utilisateur non validés permettant du directory traversal. Le fs/promises est la bonne API moderne, fs.watch ne doit jamais être utilisé directement en prod (chokidar à la place), et fsync est ton seul allié pour la durabilité. Sur la concurrence, n'oublie pas que UV_THREADPOOL_SIZE te limite à 4 ops fs parallèles par défaut — augmente-le quand ton workload est I/O fs intensif.