Чтение и запись ZIP-файлов в Node без сторонних библиотек
Читайте и создавайте ZIP-архивы с экспериментальным API node:zlib в Node.js 26.8.0. Примеры потоковой обработки, защита от zip-slip и варианты для старых версий.
Начиная с Node.js 26.8.0, встроенный модуль node:zlib умеет самостоятельно читать и создавать ZIP-архивы, поэтому adm-zip, archiver и yauzl больше не нужны. API пока экспериментальный.
В большинстве Node-проектов, которые принимают загрузки пользователей или собирают релизные артефакты, есть хотя бы один пакет для работы с ZIP, а нередко и два: yauzl для чтения и archiver для записи. Оба пакета — сторонний код, обрабатывающий недоверенные бинарные данные.
Функциональность появилась в релизе Node.js 26.8.0 26 августа 2026 года в рамках PR #64339. API для работы с ZIP-архивами экспериментальный: при первом обращении к любой его части выводится предупреждение об этом, а сам по себе импорт node:zlib предупреждения не вызывает. Ни один релиз Node 24 LTS эту возможность пока не включает: ZIP-классы отсутствуют в журналах изменений ветки 24.x вплоть до 24.21.0. Это вписывается в общую тенденцию: встроенные API Node.js постепенно заменяют npm-пакеты. API новый и продолжает меняться, поэтому перед выкаткой в продакшен сверяйте точные сигнатуры с документацией node:zlib.
Ключевые выводы
- В Node.js 26.8.0 модуль
node:zlibполучил экспериментальную поддержку чтения и записи ZIP черезZipFile,ZipBuffer,ZipEntryиcreateZipArchive(). ZipEntry.content()загружает элемент архива в память целиком, аcontentIterator()отдаёт его потоком в виде чанков Buffer.setMaxZipContentSize()задаёт лимит по умолчанию для буферизованного чтения, например черезcontent(). НаcontentIterator()он не распространяется: этот метод принимает собственный параметрmaxSizeпри каждом вызове.- ZIP API не отклоняет имена элементов, содержащие
../или абсолютные пути, поэтому в любом распаковщике нужна собственная проверка на zip-slip. - На Node 24 LTS продолжайте использовать yauzl для чтения и archiver для записи.
| Пакет | Назначение | Встроенная замена |
|---|---|---|
| adm-zip | Чтение и запись архивов целиком в памяти | ZipBuffer, ZipFile, createZipArchive() |
| yauzl | Потоковое чтение | ZipFile + contentIterator() |
| archiver | Потоковая запись | ZipEntry.create()/createStream() + createZipArchive() |
Как прочитать ZIP-файл в Node.js с помощью ZipFile?
Чтобы прочитать ZIP-архив с диска, откройте его через ZipFile.open(), переберите элементы и вызовите content() у нужного. ZipFile работает через файловый дескриптор: он сразу переходит к запрошенному элементу, читает его с диска только по необходимости и не удерживает содержимое в памяти после чтения. ZipBuffer выполняет ту же задачу для архива, который уже находится в памяти (например, для загруженного файла в Buffer), и читает данные напрямую, без копирования. ZipEntry представляет отдельный элемент архива.
// 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() отклоняется с ошибкой ERR_ZIP_ENTRY_NOT_FOUND, поэтому в примере сначала вызывается zip.has(). zipFile.values() возвращает итератор, каждый элемент которого — промис, разрешающийся в ZipEntry. Именно поэтому здесь уместен for await. У каждого элемента есть свойства name и size (размер в несжатом виде, в байтах). У асинхронных методов есть синхронные аналоги (openSync(), contentSync(), valuesSync()). Исключение составляют потоковые API: у contentIterator() и ZipEntry.createStream() синхронных версий нет.
Потоковая обработка больших архивов с contentIterator()
Для чтения больших элементов ZIP-архива используйте contentIterator(). Метод отдаёт распакованное содержимое последовательностью чанков Buffer, так что элемент никогда не оказывается в памяти целиком. Поскольку это асинхронный итерируемый объект, его можно передать напрямую в pipeline(). Здесь используется та же pull-модель, что описана в статье о том, как работают потоки, для веб-разработчиков.
// 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() защищает от zip-бомб — файлов, которые в сжатом виде занимают мало места, но при распаковке разрастаются до огромных размеров. Функция задаёт общий для модуля лимит по умолчанию для буферизованного чтения, например через content(). Согласно документации, на contentIterator() этот лимит не распространяется, поскольку при потоковом чтении не происходит одного крупного выделения памяти. Параметр maxSize метода content() отклоняет любой элемент, заявленный несжатый размер которого превышает переданное значение. Если параметр не указан, content() использует общий лимит модуля, который возвращает getMaxZipContentSize(). contentIterator() принимает собственный параметр maxSize, и по умолчанию он не ограничен.
Для потокового чтения предусмотрена отдельная защита. Как поясняется в последующем PR с усилением безопасности, потоковый декодер никогда не выдаёт больше байтов, чем заявленный несжатый размер элемента. Если данные пытаются распаковаться сверх этого размера, сразу выбрасывается ошибка ERR_ZIP_ENTRY_CORRUPT. Поэтому цикл выше проверяет entry.size до начала потоковой обработки.
Как создать ZIP-архив из папки?
Чтобы собрать ZIP-архив в Node.js, создайте по одному ZipEntry на каждый файл и передайте список в createZipArchive(). Эта функция превращает элементы в читаемый поток байтов архива, который затем остаётся направить в файл. Сначала соберите список файлов с помощью fs.readdir с параметром 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));
}
Поскольку функция использует lstat, символические ссылки пропускаются, а не разыменовываются. Далее сериализуем элементы:
// 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() принимает сначала имя файла, затем данные. Синхронная версия описана в документации как zlib.ZipEntry.createSync(filename, data, options). В этом примере каждый файл перед добавлением буферизуется целиком. Для больших файлов используйте ZipEntry.createStream(): он сжимает исходные данные по мере того, как createZipArchive() записывает архив, поэтому файл не приходится держать в памяти полностью. Потоковый элемент одноразовый: после того как createZipArchive() его записал, содержимое израсходовано и повторно прочитать его нельзя.
Вложенные папки и защита от zip-slip
Пути внутри ZIP-архива разделяются прямыми слешами независимо от ОС, в которой архив был создан. Этого требует спецификация формата ZIP в разделе об именах файлов (4.4.17). Поэтому collectFiles заменяет path.sep на /, и путь Windows вида assets\img\logo.png сохраняется как assets/img/logo.png. Элементы, имена которых заканчиваются на /, являются каталогами.
Zip-slip — это атака типа path traversal: вредоносный архив содержит элемент с именем вроде ../../etc/passwd, и наивный распаковщик записывает его за пределы целевого каталога. Встроенный API передаёт такие имена ровно в том виде, в каком они записаны в архиве, без нормализации и без ошибок, так что проверка — ваша ответственность. Разрешайте каждое имя относительно каталога назначения и отклоняйте всё, что выходит за его пределы:
// 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;
}
Функция отклоняет как обход через ../, так и абсолютные имена вроде /etc/passwd. Вызывайте её для каждого entry.name, прежде чем что-либо записывать. С элементами-симлинками нужна такая же осторожность. Документация предупреждает: любой распаковщик, который обрабатывает элемент-симлинк, создаст на диске настоящую ссылку, и доверять её цели нельзя. Проще всего пропускать элементы, у которых entry.isSymlink равно true, как это сделано в потоковом цикле выше.
На старых версиях Node и LTS: оставьте yauzl и archiver
Если вы работаете на Node 24 LTS или более ранней версии, продолжайте использовать yauzl для чтения и archiver для записи. Новые возможности из ветки Current могут быть бэкпортированы в активную LTS-ветку, поэтому перед отказом от пакетов проверьте журналы изменений 24.x. Пока API не появился в версии Node, которая используется у вас в продакшене, пакеты оставляйте. Проверка во время выполнения позволит общему инструментарию выбирать нужную реализацию:
const [major, minor] = process.versions.node.split('.').map(Number);
const hasNativeZip = major > 26 || (major === 26 && minor >= 8);
Вот код на archiver, который заменяет встроенный API. В archiver 8 убрали экспорт по умолчанию, поэтому используется именованный класс ZipArchive; в archiver 7 и более ранних версиях вместо этого пишите import archiver from 'archiver' и 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() разрешается, когда archiver закончил генерировать данные, а не когда файл сброшен на диск и закрыт. Ожидание события close выходного потока гарантирует, что release.zip можно сразу же читать.
Заключение
В Node 26.8.0 и новее модуль node:zlib справляется с повседневными задачами по работе с ZIP: чтение через ZipFile, потоковая обработка больших элементов через contentIterator() и запись с помощью ZipEntry и createZipArchive(). Это покрывает всё, для чего использовались adm-zip, archiver и yauzl. Поскольку API экспериментальный, зафиксируйте версию Node, сохраните защиту от zip-slip и ограничения размера, а при каждом обновлении перечитывайте документацию node:zlib. Хороший первый шаг — заменить один сценарий чтения, например распаковку загружаемых файлов, за проверкой hasNativeZip. Сохраняйте запасной вариант на пакетах, пока ваша продакшен-версия не начнёт поддерживать API.
Часто задаваемые вопросы
Что произойдёт, если при вызове createZipArchive() в Node у двух элементов окажется одинаковое имя?
createZipArchive() запишет оба элемента. Функция записывает элементы в порядке поступления и не проверяет повторяющиеся имена, поэтому в архиве окажутся дубликаты, а большинство утилит распаковки оставят последнюю копию. Методы add() у ZipBuffer и ZipFile работают иначе: они заменяют существующий элемент с тем же именем. Если в вашем списке файлов пути могут повторяться, удалите дубликаты перед сериализацией.
Может ли node:zlib создавать ZIP-архивы размером больше 4 ГБ?
Да. createZipArchive() автоматически переходит на структуры Zip64, как только количество элементов, какое-либо смещение или размер перестают помещаться в исходные 16- и 32-битные поля формата ZIP. Это касается элементов и архивов размером более 4 ГБ, а также архивов, содержащих более 65 535 элементов. Никаких дополнительных параметров для этого не требуется; ZipBuffer.toBuffer() точно так же переключается на Zip64 при сериализации элементов.
Какие коды ошибок нужно обрабатывать при чтении элементов ZIP с помощью node:zlib?
Обрабатывайте ERR_ZIP_ENTRY_TOO_LARGE — она возникает, когда заявленный размер элемента превышает maxSize, и ERR_ZIP_ENTRY_CORRUPT — когда контрольная сумма CRC-32 содержимого не совпадает или его длина отличается от заявленной. Запрос имени, которого нет в архиве, приводит к ERR_ZIP_ENTRY_NOT_FOUND (с этой ошибкой отклоняется промис ZipFile.get()). После усиления безопасности в PR 65016 архивы, у которых локальные заголовки расходятся с центральным каталогом, отклоняются с ошибкой ERR_ZIP_INVALID_ARCHIVE.
Какие методы сжатия использует встроенный ZIP API в Node?
В документации node:zlib в качестве методов сжатия указаны deflate и Zstandard. Элементы также можно хранить без сжатия. У каждого ZipEntry есть логическое свойство compressed: оно равно true, если применялся любой из этих методов, и false, если содержимое хранится без сжатия. В документации этот список описан как актуальный на текущий момент, а API экспериментальный, поэтому сверяйтесь с документацией для своей версии 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