Lire et écrire des fichiers ZIP dans Node sans bibliothèque
Lisez et créez des fichiers ZIP avec l’API expérimentale node:zlib de Node.js 26.8.0. Découvrez le streaming, les protections contre zip-slip et les alternatives.
Depuis Node.js 26.8.0, le module natif node:zlib sait lire et écrire des archives ZIP sans aide extérieure : adm-zip, archiver et yauzl ne sont donc plus indispensables. Cette API est expérimentale.
La plupart des projets Node qui acceptent des téléversements ou produisent des artefacts de release embarquent au moins un paquet ZIP, souvent deux : yauzl pour la lecture et archiver pour l’écriture. Dans les deux cas, du code tiers traite des données binaires non fiables.
La fonctionnalité est arrivée avec la version 26.8.0 de Node.js, le 26 août 2026, via la PR #64339. L’API d’archives ZIP est expérimentale. Le premier appel à l’une de ses parties affiche un avertissement expérimental, alors qu’un simple import de node:zlib n’en déclenche aucun. Aucune version de Node 24 LTS ne l’inclut pour l’instant : les classes ZIP sont absentes des changelogs 24.x jusqu’à la 24.21.0 incluse. Cette évolution s’inscrit dans la lignée des autres API natives de Node.js qui remplacent des paquets npm. L’API est récente et continue d’évoluer : vérifiez les signatures exactes dans la documentation de node:zlib avant toute mise en production.
Points clés
- Node.js 26.8.0 ajoute à
node:zlibune prise en charge expérimentale de la lecture et de l’écriture ZIP, viaZipFile,ZipBuffer,ZipEntryetcreateZipArchive(). ZipEntry.content()charge un membre entier en mémoire, tandis quecontentIterator()le diffuse en flux sous forme de chunks de type Buffer.setMaxZipContentSize()définit le plafond par défaut des lectures en mémoire tampon, commecontent(). Il ne s’applique pas àcontentIterator(), qui accepte sa propre optionmaxSizeà chaque appel.- L’API ZIP ne rejette ni les noms d’entrées contenant
../ni les chemins absolus : chaque extracteur doit donc implémenter sa propre vérification anti-zip-slip. - Sous Node 24 LTS, conservez yauzl pour la lecture et archiver pour l’écriture.
| Paquet | Rôle | Remplacement natif |
|---|---|---|
| adm-zip | Lecture/écriture d’archives complètes en mémoire | ZipBuffer, ZipFile, createZipArchive() |
| yauzl | Lecture en streaming | ZipFile + contentIterator() |
| archiver | Écriture en streaming | ZipEntry.create()/createStream() + createZipArchive() |
Comment lire un fichier ZIP dans Node.js avec ZipFile ?
Pour lire une archive ZIP stockée sur disque dans Node.js, ouvrez-la avec ZipFile.open(), parcourez ses entrées, puis appelez content() sur le membre voulu. ZipFile s’appuie sur un descripteur de fichier : il accède directement au membre demandé, ne le lit sur le disque qu’en cas de besoin et ne conserve pas son contenu ensuite. ZipBuffer remplit le même rôle pour une archive déjà présente en mémoire, par exemple un fichier téléversé conservé dans un Buffer, et lit directement cette mémoire sans la copier. ZipEntry représente un membre individuel.
// Requires Node 26.8.0+ (experimental)
import { ZipFile } from 'node:zlib';
const zip = await ZipFile.open('upload.zip');
try {
for await (const entry of zip.values()) {
console.log(entry.name, entry.size);
}
if (zip.has('README.md')) {
const readme = await zip.get('README.md');
const buf = await readme.content({ maxSize: 1024 * 1024 });
console.log(buf.toString('utf8'));
}
} finally {
await zip.close();
}
zip.get() est rejeté avec ERR_ZIP_ENTRY_NOT_FOUND lorsque l’archive ne contient aucun membre portant ce nom ; c’est pourquoi l’exemple vérifie d’abord zip.has(). zipFile.values() renvoie un itérateur dont chaque élément est une Promise résolue en ZipEntry, d’où l’utilisation de for await. Chaque entrée expose name et size, cette dernière correspondant à la taille décompressée en octets. Les appels asynchrones ont aussi des équivalents synchrones (openSync(), contentSync(), valuesSync()), à l’exception des API de streaming : contentIterator() et ZipEntry.createStream() n’ont pas de version synchrone.
Traiter de grandes archives en streaming avec contentIterator()
Pour lire des membres ZIP volumineux dans Node.js, utilisez contentIterator(). Cette méthode fournit le contenu décompressé sous forme d’une série de chunks de type Buffer, de sorte que le membre n’a jamais besoin d’être entièrement chargé en mémoire. Comme il s’agit d’un itérable asynchrone, vous pouvez le passer directement à pipeline(). Ce modèle en mode pull est le même que celui présenté dans le fonctionnement des streams pour les développeurs web.
// Requires Node 26.8.0+ (experimental)
import { ZipFile, setMaxZipContentSize } from 'node:zlib';
import { createWriteStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { safeDestination } from './safe-destination.js';
setMaxZipContentSize(32 * 1024 * 1024); // caps buffered content() reads
const MAX_ENTRY_BYTES = 2 * 1024 ** 3;
const zip = await ZipFile.open('large.zip');
try {
for await (const entry of zip.values()) {
if (entry.isSymlink) continue; // don't recreate links from untrusted archives
const target = safeDestination('out', entry.name);
if (entry.name.endsWith('/')) {
await mkdir(target, { recursive: true });
continue;
}
if (entry.size > MAX_ENTRY_BYTES) throw new Error(`Too large: ${entry.name}`);
await mkdir(path.dirname(target), { recursive: true });
await pipeline(entry.contentIterator(), createWriteStream(target));
}
} finally {
await zip.close();
}
setMaxZipContentSize() protège contre les bombes de décompression (zip bombs) : un fichier téléversé de petite taille une fois compressé peut atteindre une taille énorme après décompression. Cette fonction définit le plafond par défaut, à l’échelle du module, des lectures en mémoire tampon comme content(). La documentation précise que contentIterator() n’est pas soumis à cette valeur par défaut, puisque le streaming n’effectue jamais d’allocation importante en une seule fois. L’option maxSize de content(), passée à chaque appel, refuse toute entrée dont la taille décompressée déclarée dépasse la valeur indiquée. Si vous l’omettez, content() utilise la limite globale du module renvoyée par getMaxZipContentSize(). contentIterator() accepte sa propre option maxSize, qui n’impose aucune limite par défaut.
Les lectures en streaming disposent d’une protection distincte. Une PR de durcissement ultérieure explique que le décodeur en streaming ne produit jamais plus d’octets que la taille décompressée déclarée du membre. Si les données tentent de dépasser cette taille lors de la décompression, il lève immédiatement ERR_ZIP_ENTRY_CORRUPT. C’est pourquoi la boucle ci-dessus vérifie entry.size avant de lancer le streaming.
Comment créer une archive ZIP à partir d’un dossier ?
Pour créer une archive ZIP dans Node.js, créez un ZipEntry par fichier, puis passez la liste à createZipArchive(), qui transforme ces entrées en un flux lisible d’octets d’archive. Il ne reste qu’à rediriger ce flux vers un fichier. Commencez par collecter les fichiers avec fs.readdir et son option recursive :
// collect-files.js
import { readdir, lstat } from 'node:fs/promises';
import path from 'node:path';
export async function collectFiles(root) {
const files = [];
for (const rel of await readdir(root, { recursive: true })) {
const abs = path.join(root, rel);
if (!(await lstat(abs)).isFile()) continue; // skips dirs and symlinks
files.push({ abs, name: rel.split(path.sep).join('/') });
}
return files.sort((a, b) => a.name.localeCompare(b.name));
}
Comme la fonction utilise lstat, elle ignore les liens symboliques au lieu de les suivre. Ensuite, sérialisez les entrées :
// Requires Node 26.8.0+ (experimental)
import { readFile } from 'node:fs/promises';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { ZipEntry, createZipArchive } from 'node:zlib';
import { collectFiles } from './collect-files.js';
const entries = [];
for (const f of await collectFiles('dist')) {
entries.push(await ZipEntry.create(f.name, await readFile(f.abs)));
}
const archive = await createZipArchive(entries);
await pipeline(archive, createWriteStream('release.zip'));
ZipEntry.create() prend en paramètres le nom du fichier, puis les données. La version synchrone est documentée sous la forme zlib.ZipEntry.createSync(filename, data, options). Cet exemple charge chaque fichier en mémoire avant de l’ajouter. Pour les fichiers volumineux, utilisez plutôt ZipEntry.createStream() : la source est compressée pendant que createZipArchive() écrit l’archive, si bien que le fichier n’a jamais besoin d’être entièrement chargé en mémoire. Une entrée en streaming n’est utilisable qu’une seule fois : une fois écrite par createZipArchive(), son contenu est consommé et ne peut plus être relu.
Dossiers imbriqués et protection contre le zip-slip
Les chemins à l’intérieur d’une archive ZIP utilisent des barres obliques (/), quel que soit le système d’exploitation qui les a créés. La spécification du format ZIP l’impose dans sa section consacrée aux noms de fichiers (4.4.17). collectFiles convertit donc path.sep en /, de sorte qu’un chemin Windows comme assets\img\logo.png est stocké sous la forme assets/img/logo.png. Les noms d’entrées se terminant par / désignent des répertoires.
Le zip-slip est une attaque par traversée de répertoire (path traversal) : une archive malveillante contient une entrée nommée par exemple ../../etc/passwd, et un extracteur naïf l’écrit en dehors du répertoire cible. L’API native transmet ces noms exactement tels qu’ils figurent dans l’archive, sans nettoyage ni erreur : la vérification vous incombe donc. Résolvez chaque nom par rapport au répertoire de destination et refusez tout ce qui en sort :
// safe-destination.js
import path from 'node:path';
export function safeDestination(destDir, entryName) {
if (entryName.includes('\0')) throw new Error(`NUL byte in ${entryName}`);
const root = path.resolve(destDir);
const target = path.resolve(root, entryName);
if (target !== root && !target.startsWith(root + path.sep)) {
throw new Error(`Blocked entry outside ${root}: ${entryName}`);
}
return target;
}
Cette fonction rejette à la fois les traversées en ../ et les noms absolus tels que /etc/passwd. Appelez-la sur chaque entry.name avant toute écriture. Les entrées de type lien symbolique appellent la même prudence. La documentation avertit que tout extracteur qui honore une entrée de lien symbolique crée un véritable lien sur le disque, dont la cible n’est pas digne de confiance. L’approche la plus simple consiste à ignorer les entrées pour lesquelles entry.isSymlink vaut true, comme le fait la boucle de streaming ci-dessus.
Sur une version plus ancienne de Node ou une LTS : conservez yauzl et archiver
Si vous utilisez Node 24 LTS ou une version antérieure, conservez yauzl pour la lecture et archiver pour l’écriture. Les nouvelles fonctionnalités de la branche Current peuvent être rétroportées vers une branche LTS active : consultez donc les changelogs 24.x avant de supprimer ces paquets. Tant que l’API n’est pas disponible dans la version de Node utilisée en production, gardez-les. Une vérification à l’exécution permet à un outillage partagé de choisir la bonne implémentation :
const [major, minor] = process.versions.node.split('.').map(Number);
const hasNativeZip = major > 26 || (major === 26 && minor >= 8);
Voici le code archiver que l’API native remplace. Archiver 8 a supprimé l’export par défaut ; ce code utilise donc la classe nommée ZipArchive. Avec archiver 7 ou une version antérieure, écrivez plutôt import archiver from 'archiver' et archiver('zip') :
import { ZipArchive } from 'archiver'; // archiver 8+
import { createWriteStream } from 'node:fs';
import { once } from 'node:events';
const output = createWriteStream('release.zip');
const closed = once(output, 'close');
const archive = new ZipArchive();
archive.pipe(output);
archive.directory('dist/', false);
await archive.finalize();
await closed; // release.zip is fully written and closed
finalize() est résolue lorsqu’archiver a fini de produire les données, et non lorsque le fichier a été vidé sur le disque et fermé. Attendre l’événement close du flux de sortie garantit que release.zip peut être lu immédiatement après.
Conclusion
À partir de Node 26.8.0, node:zlib prend en charge les opérations ZIP courantes : la lecture avec ZipFile, le streaming de membres volumineux avec contentIterator() et l’écriture avec ZipEntry et createZipArchive(). Cela couvre ce que faisaient adm-zip, archiver et yauzl. L’API étant expérimentale, figez votre version de Node, conservez la protection anti-zip-slip et les plafonds de taille, et relisez la documentation de node:zlib à chaque mise à jour. Une bonne première étape consiste à remplacer un seul chemin de lecture, comme l’extraction des fichiers téléversés, derrière la vérification hasNativeZip. Conservez la solution de repli à base de paquets jusqu’à ce que votre version de production prenne en charge l’API.
FAQ
Que se passe-t-il si deux entrées portent le même nom lorsque j'appelle createZipArchive() dans Node ?
createZipArchive() écrit les deux entrées. Elle écrit les entrées dans l'ordre où elle les reçoit et ne vérifie pas les noms en double : l'archive contient alors des doublons, et la plupart des outils d'extraction conservent la dernière copie. Les méthodes add() de ZipBuffer et ZipFile fonctionnent différemment : elles remplacent toute entrée existante portant le même nom. Si votre liste de fichiers peut contenir des chemins en double, dédoublonnez-la avant la sérialisation.
node:zlib peut-il écrire des archives ZIP de plus de 4 Go ?
Oui. createZipArchive() bascule automatiquement vers les structures Zip64 dès que le nombre d'entrées, ou un décalage ou une taille quelconque, dépasse la capacité des champs ZIP d'origine de 16 et 32 bits. Cela couvre les membres ou archives de plus de 4 Go ainsi que les archives de plus de 65 535 entrées. Aucune option n'est nécessaire, et ZipBuffer.toBuffer() passe en Zip64 de la même manière lors de la sérialisation des entrées.
Quels codes d'erreur dois-je gérer lors de la lecture d'entrées ZIP avec node:zlib ?
Gérez ERR_ZIP_ENTRY_TOO_LARGE, levée lorsque la taille déclarée d'une entrée dépasse maxSize, et ERR_ZIP_ENTRY_CORRUPT, levée lorsque la somme de contrôle CRC-32 du contenu est incorrecte ou que sa longueur diffère de la taille déclarée. Demander un nom absent de l'archive produit ERR_ZIP_ENTRY_NOT_FOUND (ZipFile.get() est rejetée avec cette erreur). Depuis le durcissement apporté par la PR 65016, les archives dont les en-têtes locaux et centraux divergent sont rejetées avec ERR_ZIP_INVALID_ARCHIVE.
Quelles méthodes de compression l'API ZIP native de Node utilise-t-elle ?
La documentation de node:zlib indique deflate et Zstandard comme méthodes de compression pour les entrées compressées. Les entrées peuvent également être stockées sans compression. Chaque ZipEntry possède une propriété booléenne compressed : elle vaut true si l'une de ces méthodes a été utilisée et false si le contenu a été stocké sans compression. La documentation présente cette liste comme étant celle en vigueur actuellement, et l'API est expérimentale : vérifiez donc la documentation correspondant à votre version de Node.
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