12k
All articles

Gestion moderne des fichiers dans Node.js

Gestion moderne des fichiers Node.js avec fs/promises, streams, file handles, codes derreur, concurrence et protection contre le path traversal.

OpenReplay Team
OpenReplay Team
Gestion moderne des fichiers dans Node.js

Dans les versions récentes de Node.js, le choix par défaut pour les entrées/sorties fichiers est le module node:fs/promises basé sur les promesses, utilisé avec async/await ; optez pour les streams (pipeline depuis node:stream/promises) lorsque les fichiers sont volumineux ou de taille inconnue, et pour les descripteurs de fichiers (open/read/close) lorsque vous avez besoin d’un contrôle positionnel au niveau des octets. Le module fs continue de proposer trois API parallèles — synchrone, par callback et basée sur les promesses — et la plupart des tutoriels plus anciens présentent l’API par callback avec le require() CommonJS. Cette approche est dépassée : pour tout nouveau code sur un runtime actuel, node:fs/promises avec l’import ES module est le point de départ approprié.

Cet article montre comment lire, écrire et traiter des fichiers avec cette API moderne : comportement de l’encodage, sémantique d’écrasement, gestion des erreurs par code, concurrence avec Promise.all et Promise.allSettled, les limites mémoire qui font échouer readFile, les streams et descripteurs de fichiers pour les données volumineuses, les opérations sur les répertoires et la mitigation des attaques par traversée de chemin. Les exemples ciblent Node 24, la version Active LTS en 2026 (calendrier des versions Node.js). Toutes les fonctionnalités présentées sont bien en deçà de cette ligne de base, le code s’exécute donc sans modification sur Node 24 ; tous les exemples utilisent l’import ESM avec le préfixe node: et le await de premier niveau.

Points clés à retenir

  • Utilisez node:fs/promises avec async/await par défaut pour les entrées/sorties fichiers ; les méthodes synchrones comme readFileSync bloquent la boucle d’événements et ne sont acceptables que dans des scripts CLI à exécution unique, jamais dans des serveurs.
  • readFile charge l’intégralité du fichier en mémoire et lève ERR_FS_FILE_TOO_LARGE pour tout fichier dépassant 2 Gio — une limite fixe de libuv, indépendante des plafonds de Buffer et de longueur de chaîne — aussi, au-delà de quelques centaines de Mio, vous devriez déjà utiliser les streams.
  • pipeline() depuis node:stream/promises (stable depuis Node 15) connecte les streams et gère automatiquement la propagation des erreurs et le nettoyage.
  • Utilisez Promise.all lorsque chaque fichier doit être traité avec succès ; utilisez Promise.allSettled lorsqu’un échec partiel est acceptable et que vous souhaitez traiter ce qui a réussi.
  • Ne transmettez jamais d’entrées utilisateur non validées aux fonctions fs : résolvez le chemin avec path.resolve et vérifiez qu’il reste dans votre répertoire de base prévu pour bloquer les traversées de chemin.

Les trois API fs, et pourquoi fs/promises est le choix par défaut

Node.js expose les mêmes opérations sur les fichiers via trois API : synchrone (readFileSync), par callback (readFile(path, cb)) et basée sur les promesses (node:fs/promises). L’API basée sur les promesses est devenue stable dans Node 14.0.0 (notes de version Node.js 14) et constitue le choix par défaut moderne, car elle s’intègre parfaitement avec async/await et ne bloque jamais la boucle d’événements. L’API par callback est héritée — elle est antérieure aux promesses et conduit à du code profondément imbriqué — et l’API synchrone bloque la boucle d’événements pendant toute la durée de l’opération d’E/S. Utilisez l’API basée sur les promesses pour tout nouveau code.

import { readFile } from 'node:fs/promises'

const data = await readFile('config.json', 'utf8')
console.log(data.length)

Le préfixe node: identifie l’import comme un module natif de Node.js et ne peut pas être masqué par un package npm portant le même nom ; il identifie explicitement le module comme faisant partie du cœur de Node.js. Cet exemple utilise également le await de premier niveau, disponible dans les modules ES sans indicateur depuis Node 14.8.0 (notes de version Node.js 14.8.0). Il n’est plus nécessaire d’encapsuler les lectures de fichiers dans une IIFE async au niveau du module.

Quelle API utiliser ?

Le choix dépend de la taille du fichier, du type d’accès requis et de la gestion de la concurrence par le processus. Utilisez ce tableau comme règle de décision :

ScénarioAPI recommandéePourquoi
Petits fichiers, environ moins de 100 Monode:fs/promises readFile/writeFileAPI la plus simple ; le fichier entier tient aisément en mémoire et un seul await suffit
Fichiers volumineux ou de taille inconnueStreams : createReadStream/createWriteStream avec pipelineMémoire constante quelle que soit la taille, et vous évitez entièrement le plafond de 2 Gio de readFile
Accès positionnel ou au niveau des octets (lecture/écriture à des décalages précis)Descripteurs de fichiers : open/read/closeSeule l’API des descripteurs permet de lire ou d’écrire des plages d’octets spécifiques à des positions choisies
Scripts CLI ponctuels et outils de build (sans concurrence)Les méthodes synchrones sont acceptables : readFileSync/writeFileSyncBloquer la boucle d’événements est sans conséquence quand rien d’autre ne s’exécute
Serveurs HTTP ou tout code concurrentAPI asynchrone basée sur les promesses uniquement — jamais synchroneUne boucle d’événements bloquée met en attente toutes les requêtes pendantes simultanément

Le seuil d’environ 100 Mo est une règle pratique pour savoir quand passer aux streams, et non une limite stricte ; le point de défaillance dur pour readFile est le plafond libuv de 2 Gio. En cas de doute entre fs/promises et les streams, le streaming est le choix le plus sûr pour tout fichier dont vous ne contrôlez pas la taille.

Lecture de fichiers avec fs/promises

readFile() charge l’intégralité du fichier en mémoire et retourne soit une chaîne de caractères, soit un Buffer. Passez un encodage tel que 'utf8' pour obtenir une chaîne décodée ; omettez l’encodage pour obtenir les octets bruts sous forme de Buffer. Pour les fichiers de configuration et de données, lisez en tant que chaîne et analysez :

import { readFile } from 'node:fs/promises'

const raw = await readFile('config.json', 'utf8')
const config = JSON.parse(raw)
console.log(config.port)

Sans encodage, la valeur retournée est un Buffer — le choix approprié pour les images, l’audio ou tout contenu non textuel :

import { readFile } from 'node:fs/promises'

const bytes = await readFile('logo.png') // Buffer
console.log(bytes.length, 'bytes')

JSON.parse lève une SyntaxError en cas d’entrée malformée, donc un try/catch autour de l’analyse gère à la fois les échecs d’E/S et le JSON invalide. Consultez la documentation de fs.readFile pour l’ensemble des options disponibles.

Écriture de fichiers avec fs/promises

writeFile() crée le fichier s’il n’existe pas, ou l’écrase entièrement s’il existe — du point de vue de l’appelant, un seul await remplace l’intégralité du contenu du fichier. Pour ajouter du contenu à un fichier plutôt que de le remplacer, utilisez appendFile(), qui crée également le fichier s’il est absent. Pour réduire un fichier à une longueur fixe, utilisez truncate().

import { writeFile, appendFile } from 'node:fs/promises'

const user = { name: 'Ada', email: 'ada@example.com' }

// Crée user.json ou remplace intégralement son contenu
await writeFile('user.json', JSON.stringify(user, null, 2), 'utf8')

// Ajoute une ligne sans réécrire le fichier ; le crée s'il est absent
await appendFile('events.log', `${new Date().toISOString()} user-created\n`, 'utf8')

truncate(path, n) conserve les n premiers octets du fichier et supprime le reste — l’argument est le nombre d’octets à conserver, et non le nombre d’octets à supprimer, ce qui est l’inverse de ce que le nom suggère. Tronquer 1234567890 à 5 laisse 12345 :

import { writeFile, truncate, readFile } from 'node:fs/promises'

await writeFile('data.txt', '1234567890')
await truncate('data.txt', 5)
console.log(await readFile('data.txt', 'utf8')) // '12345'

Gestion des erreurs : intercepter par error.code

Les opérations sur les fichiers échouent avec des erreurs système qui portent une propriété code ; branchez-vous sur error.code plutôt que d’analyser les messages. Les codes que vous gérerez le plus souvent sont documentés dans la référence des erreurs système courantes de Node.js :

error.codeSignification
ENOENTLe fichier ou répertoire n’existe pas
EACCESPermission refusée
EISDIRTentative de lecture d’un répertoire comme un fichier
ENOSPCPlus d’espace disponible sur le périphérique (disque plein)
EMFILETrop de descripteurs de fichiers ouverts

Un seul try/catch couvre l’échec d’E/S et, pour le JSON, l’échec d’analyse :

import { readFile } from 'node:fs/promises'

try {
  const config = JSON.parse(await readFile('config.json', 'utf8'))
  console.log('loaded', config)
} catch (error) {
  if (error.code === 'ENOENT') {
    console.error('config.json est absent ; utilisation des valeurs par défaut')
  } else if (error.code === 'EACCES') {
    console.error('permission refusée pour lire config.json')
  } else {
    throw error // inclut SyntaxError de JSON.parse et les codes inattendus
  }
}

Relancez les codes que vous ne gérez pas spécifiquement plutôt que de les avaler silencieusement — des erreurs ENOSPC ou EMFILE silencieusement interceptées constituent un mode de défaillance courant en production qui masque la cause réelle.

Synchrone vs asynchrone : quand les méthodes synchrones sont acceptables

Les méthodes fs synchrones (readFileSync, writeFileSync) bloquent la boucle d’événements Node.js pendant toute la durée de l’opération d’E/S — aucun autre code JavaScript ne s’exécute entre-temps. Elles sont acceptables dans les scripts CLI ponctuels et les outils de build où il n’y a pas de concurrence, mais jamais dans des serveurs HTTP ou tout code gérant des requêtes concurrentes, car une boucle d’événements bloquée met en attente toutes les requêtes pendantes simultanément (guide de la boucle d’événements Node.js).

// Acceptable : un script ponctuel qui s'exécute et se termine
import { readFileSync } from 'node:fs'

const pkg = JSON.parse(readFileSync('package.json', 'utf8'))
console.log(pkg.version)

La règle de décision : si le processus gère plusieurs choses simultanément, utilisez l’API asynchrone basée sur les promesses.

Concurrence : Promise.all vs Promise.allSettled

Utilisez Promise.all lorsque chaque lecture de fichier doit réussir et qu’un seul échec doit interrompre le lot ; utilisez Promise.allSettled lorsqu’un échec partiel est acceptable et que vous souhaitez traiter ce qui a réussi. Promise.allSettled se résout toujours en retournant un tableau où chaque entrée est soit { status: 'fulfilled', value } soit { status: 'rejected', reason }.

Mapper les noms de fichiers vers des promesses sans utiliser await à l’intérieur de la boucle exécute les lectures en parallèle :

import { readFile } from 'node:fs/promises'

const files = ['a.json', 'b.json', 'c.json']

// Tout ou rien : un fichier manquant rejette l'ensemble du lot
const all = await Promise.all(
  files.map((f) => readFile(f, 'utf8').then(JSON.parse)),
)

Lorsque vous préférez charger ce qui existe et signaler le reste, inspectez les résultats résolus :

import { readFile } from 'node:fs/promises'

const files = ['a.json', 'b.json', 'missing.json']

const results = await Promise.allSettled(
  files.map((f) => readFile(f, 'utf8')),
)

const loaded = results.filter((r) => r.status === 'fulfilled').map((r) => r.value)
const failed = results
  .filter((r) => r.status === 'rejected')
  .map((r) => r.reason.code) // ex. 'ENOENT'

console.log(`loaded ${loaded.length}, failed:`, failed)

Promesses fs et fichiers volumineux : les limites mémoire qui font échouer readFile

readFile charge l’intégralité du fichier en mémoire et lève ERR_FS_FILE_TOO_LARGE pour tout fichier dépassant 2 Gio — une limite fixe de libuv, indépendante de la limite de taille des Buffer (node#55864, ERR_FS_FILE_TOO_LARGE). Bien avant ce plafond dur, charger des centaines de mégaoctets dans un seul buffer entraîne une pression mémoire et des temps de réponse dégradés ; aussi, au-delà de quelques centaines de Mio, vous devriez déjà utiliser les streams.

Trois plafonds distincts sont souvent confondus, et la plupart des tutoriels les amalgament :

LimiteValeur (64 bits)S’applique à
Plafond de lecture libuv2 GioreadFile sur tout fichier ; lève ERR_FS_FILE_TOO_LARGE
buffer.constants.MAX_STRING_LENGTH536 870 888 octets (~512 Mio)Les chaînes de caractères, c’est-à-dire readFile avec un encodage
buffer.constants.MAX_LENGTH2⁵³−1 octets (~8 Pio)Allocation maximale d’un Buffer

Lorsque vous passez un encodage à readFile (retournant une chaîne), le plafond opérant est buffer.constants.MAX_STRING_LENGTH — 536 870 888 octets sur les plateformes 64 bits, abaissé depuis environ 1 Go dans Node 14.4.0 (node#33960). Le dépassement lève une erreur “Cannot create a string longer than…”, et non ERR_FS_FILE_TOO_LARGE. La valeur buffer.constants.MAX_LENGTH représente l’allocation maximale d’un Buffer et constitue une limite distincte ; elle ne borne pas readFile, qui échoue à 2 Gio quoi qu’il arrive. La conclusion pratique : ne raisonnez pas sur readFile à partir du plafond Buffer — la limite libuv de 2 Gio est celle qui est atteinte en premier.

Streams : createReadStream, createWriteStream et pipeline

Les streams traitent les données de fichiers par blocs plutôt que de charger l’intégralité du fichier en mémoire, ce qui en fait l’outil approprié pour les fichiers volumineux ou de taille inconnue. createReadStream et createWriteStream produisent des streams lisibles et inscriptibles ; connectez-les avec pipeline depuis node:stream/promises, stable depuis Node 15. pipeline() connecte un stream lisible à un stream inscriptible et gère automatiquement la propagation des erreurs et le nettoyage des streams. Bien que pipe() gère déjà la contre-pression, pipeline() offre une gestion des erreurs et un nettoyage plus sûrs.

import { createReadStream, createWriteStream } from 'node:fs'
import { pipeline } from 'node:stream/promises'

// Copie un fichier de toute taille avec une mémoire constante
await pipeline(
  createReadStream('huge-input.log'),
  createWriteStream('huge-output.log'),
)

Comme pipeline() retourne une promesse qui est rejetée en cas d’erreur sur un stream et détruit les streams en cas d’échec, un seul try/catch suffit — il n’est pas nécessaire de câbler manuellement des écouteurs 'error'.

La contre-pression est le mécanisme qui empêche un lecteur rapide de submerger un écrivain lent : lorsque le buffer interne du stream inscriptible est plein, le stream lisible se met en pause jusqu’à ce qu’il se vide. Le seuil du buffer est le highWaterMark. Les streams de lecture de fichiers ont par défaut un highWaterMark de 64 Kio, contrairement à la valeur par défaut de 16 Kio d’un stream.Readable générique ; la documentation fs mentionne explicitement la valeur de 64 Kio comme valeur par défaut des streams de fichiers. Ajustez-la lorsque le profilage montre que cela est bénéfique :

import { createReadStream } from 'node:fs'

const stream = createReadStream('data.bin', { highWaterMark: 128 * 1024 })

Pour les formats délimités par des lignes comme le CSV, faites passer le stream de lecture à travers un parseur tel que csv-parser plutôt que d’analyser manuellement.

Descripteurs de fichiers : accès positionnel et au niveau des octets

Utilisez un descripteur de fichier depuis open() lorsque vous avez besoin de lire ou d’écrire des plages d’octets spécifiques à des décalages précis — ce que readFile et les streams n’exposent pas. Un FileHandle est une ressource de bas niveau : vous gérez vous-même le buffer et la position, et vous devez toujours le libérer avec close() dans un bloc finally, sinon le descripteur fuit (et suffisamment de fuites produisent EMFILE).

import { open } from 'node:fs/promises'

async function readInChunks(path) {
  let handle
  try {
    handle = await open(path, 'r')
    const chunkSize = 64 * 1024
    const buffer = Buffer.alloc(chunkSize)
    let position = 0
    while (true) {
      const { bytesRead } = await handle.read(buffer, 0, chunkSize, position)
      if (bytesRead === 0) break
      // traitement de buffer.subarray(0, bytesRead)
      position += bytesRead
    }
  } finally {
    await handle?.close()
  }
}

handle.read(buffer, offset, length, position) remplit buffer à partir d’une position donnée dans le fichier et rapporte bytesRead ; suivre position manuellement est ce qui rend l’accès positionnel possible. Pour la plupart des travaux de copie et de transformation, les streams sont l’outil plus adapté — les descripteurs de fichiers valent la verbosité uniquement lorsque vous avez réellement besoin d’un contrôle au niveau des octets.

AbortSignal est pris en charge par readFile et fs.watch, ce qui permet d’annuler une lecture lente — par exemple lorsqu’une requête expire. Une lecture annulée est rejetée avec une erreur dont le name est 'AbortError' :

import { readFile } from 'node:fs/promises'

const controller = new AbortController()
setTimeout(() => controller.abort(), 1000)

try {
  await readFile('slow-source.bin', { signal: controller.signal })
} catch (error) {
  if (error.name === 'AbortError') console.error('lecture annulée')
  else throw error
}

Répertoires et chemins

Créez des répertoires imbriqués avec mkdir({ recursive: true }) (sans erreur s’ils existent déjà, comme mkdir -p), listez les entrées avec readdir({ withFileTypes: true }) pour obtenir des objets Dirent, et supprimez une arborescence avec rm({ recursive: true }) — tout depuis le même module node:fs/promises. Construisez les chemins avec path.join pour que les séparateurs soient corrects selon les systèmes d’exploitation.

import { mkdir, readdir, rm } from 'node:fs/promises'
import { join } from 'node:path'

await mkdir('output/reports', { recursive: true })

const entries = await readdir('output', { withFileTypes: true })
for (const entry of entries) {
  const full = join('output', entry.name)
  if (entry.isFile()) console.log('file:', full)
  else if (entry.isDirectory()) console.log('dir: ', full)
}

await rm('output/reports', { recursive: true, force: true })

Itérez le tableau avec for...of, et non for...infor...in sur un tableau produit des indices sous forme de chaînes, et non des valeurs, ce qui est un bug fréquent dans les guides plus anciens. rm (avec recursive: true) est la méthode actuelle pour supprimer des arborescences de répertoires ; rmdir avec l’option récursive est dépréciée.

Pour référencer des fichiers relativement au module courant plutôt qu’au répertoire de travail du processus, utilisez import.meta.dirname. Disponible depuis Node 20.11.0 (import.meta.dirname), il fournit aux modules ESM la même référence au répertoire courant que celle fournie par __dirname en CommonJS :

import { readFile } from 'node:fs/promises'
import { join } from 'node:path'

const config = await readFile(join(import.meta.dirname, 'config.json'), 'utf8')

Les fonctions de permissions et de propriété (chmod, chown) ainsi que les fonctions de liens (symlink, link) existent également dans l’API basée sur les promesses, mais elles s’appliquent aux systèmes de type Unix et se comportent de manière inattendue ou génèrent des erreurs sous Windows ; traitez-les comme spécifiques à la plateforme.

Sécurité : prévenir la traversée de chemin avec les entrées utilisateur

Ne transmettez jamais de chaînes fournies par l’utilisateur directement à readFile, writeFile ou toute fonction fs. Normalisez le chemin avec path.resolve et vérifiez qu’il commence par votre répertoire de base prévu avant de continuer — une entrée brute de type ../../../etc/passwd traversera sinon tout chemin relatif que vous aviez l’intention de contraindre. Un simple join(baseDir, userInput) ne vous protège pas, car les segments .. se résolvent vers le haut.

import { resolve, sep } from 'node:path'
import { readFile } from 'node:fs/promises'

const baseDir = resolve('uploads')

async function readUserFile(userPath) {
  const target = resolve(baseDir, userPath)
  if (target !== baseDir && !target.startsWith(baseDir + sep)) {
    throw new Error('traversée de chemin bloquée')
  }
  return readFile(target, 'utf8')
}

Résoudre d’abord, puis vérifier le préfixe par rapport à baseDir + sep, confine la requête dans le répertoire autorisé quel que soit le nombre de segments .. que contient l’entrée. La protection + sep empêche un répertoire voisin comme uploads-private de passer une vérification startsWith('uploads') naïve.

Conclusion

Pour tout nouveau code Node.js, commencez avec node:fs/promises et async/await, passez aux streams avec pipeline() dès que les fichiers dépassent quelques centaines de mégaoctets ou ont une taille inconnue, et descendez aux descripteurs de fichiers uniquement lorsque vous avez besoin d’un accès positionnel au niveau des octets. Adaptez chaque opération à sa contrainte réelle — le plafond mémoire, la concurrence et les entrées non fiables étant les trois qui posent le plus de problèmes — et consultez la documentation officielle de fs lorsque vous avez besoin de l’ensemble des options disponibles. La prochaine étape consiste à auditer tout code fs par callback ou synchrone existant dans vos serveurs et à le migrer vers l’API basée sur les promesses.

FAQ

Quelle est la différence entre fs et fs/promises dans Node.js ?

Les deux font référence aux mêmes opérations sur les fichiers, mais exposent des interfaces différentes. Le module de base node:fs fournit des méthodes synchrones (readFileSync) et des méthodes asynchrones basées sur les callbacks (readFile avec un argument callback), tandis que node:fs/promises fournit des versions des mêmes opérations retournant des promesses, compatibles avec async et await. L'API basée sur les promesses est devenue stable dans Node 14.0.0 et constitue le choix par défaut recommandé pour tout nouveau code, car elle évite l'imbrication des callbacks et ne bloque jamais la boucle d'événements.

Puis-je utiliser require avec fs/promises, ou ai-je besoin des modules ES ?

Vous pouvez utiliser l'un ou l'autre. Dans les modules ES, écrivez import { readFile } from 'node:fs/promises'. En CommonJS, écrivez const { readFile } = require('node:fs/promises'). L'API basée sur les promesses elle-même ne nécessite pas ESM ; seuls le await de premier niveau et import.meta.dirname ont besoin d'un contexte de module ES. Le préfixe node: fonctionne dans les deux formats et identifie l'import comme un module natif de Node.js qu'aucun package npm ne peut masquer.

Pourquoi readFile lève-t-il ERR_FS_FILE_TOO_LARGE avant d'atteindre la limite de taille des Buffer ?

ERR_FS_FILE_TOO_LARGE est un plafond fixe de 2 Gio imposé par libuv sur une seule opération de lecture, indépendant de la limite d'allocation des Buffer. Un fichier légèrement supérieur à 2 Gio échoue même si buffer.constants.MAX_LENGTH est bien plus élevé, car le chemin d'appel système de lecture sous-jacent impose le plafond de 2 Gio indépendamment de la quantité de mémoire qu'un Buffer pourrait contenir. Pour traiter des fichiers plus volumineux, utilisez les streams avec pipeline plutôt que de charger l'intégralité du fichier en mémoire.

Comment lire une partie d'un fichier à un décalage d'octets spécifique ?

Ouvrez le fichier avec open() depuis node:fs/promises pour obtenir un FileHandle, puis appelez handle.read(buffer, offset, length, position), qui remplit buffer à partir de la position donnée dans le fichier et rapporte bytesRead. Suivez position manuellement entre les lectures pour parcourir le fichier. Libérez toujours le descripteur avec handle.close() dans un bloc finally, sinon le descripteur fuit et suffisamment de fuites produisent EMFILE. readFile et les streams n'exposent pas l'accès positionnel.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.