Node.js 26.8: нативный ZIP внутри node:zlib
26 августа 2026 года вышел Node.js 26.8.0 на ветке Current, а вместе с ним — возможность, которая годами висела открытой задачей в трекере проекта: чтение и запись ZIP-архивов прямо из node:zlib, без единого стороннего пакета.
Проблема: ZIP — это не алгоритм сжатия
Модуль node:zlib давно умеет Gzip, Deflate, Brotli и Zstd. Все четыре — потоковые алгоритмы: байты на входе, меньше байт на выходе. ZIP устроен иначе — это формат-контейнер, и в этом различии вся суть. Архив содержит центральный каталог (central directory), который лежит в конце файла, а у каждой записи есть собственный локальный заголовок, сжатые данные, контрольная сумма crc32 и метаданные о правах доступа и времени изменения.
Такая раскладка — индекс в хвосте, а не в начале — как раз и делает ZIP эффективным для чтения: разобрал последние килобайты и знаешь все записи и их расположение, не тронув ни байта содержимого. Она же превращает работу с ZIP в задачу разбора формата, а не распаковки. Именно поэтому одного zlib всегда было мало.
Практическое следствие: любой проект, открывающий ZIP-файл — приём загрузки от клиента, распаковка plugin-бандла, чтение артефакта из pipeline — тянул зависимость: adm-zip, yauzl, jszip. И это никогда не бесплатно. Каждый пакет — ещё один узел в дереве зависимостей, ещё одна сущность, которую нужно обновлять, и ещё один квадратный метр поверхности атаки в npm. Маленькая библиотека, вызываемая раз в год, всё равно присутствует при каждой установке.
Что изменилось
26.8.0 добавляет в node:zlib три класса и две вспомогательные функции:
- ZipFile — произвольный доступ к архиву на диске. При открытии читается только хвост файла и central directory, содержимое записей подгружается лениво по запросу. Доступны
open(),get(),stream(),add(),delete()иcompact(), у каждого есть синхронный вариант. Правильный выбор для большого архива, из которого нужен один-два файла. - ZipBuffer — zero-copy представление над архивом, уже находящимся в памяти в виде Buffer. Ничего не копирует и читает записи прямо из той же области. Подходит для архива, пришедшего по сети одним запросом, которому незачем попадать на диск.
- ZipEntry — одна запись, с
content()для буферизованного чтения иcontentIterator()для потокового чтения с ограниченной памятью, плюсcreate(),createStream()иcreateSymlink()для сборки. Открывает свойстваname,size,compressedSize,crc32,isFileиmode.
Рядом с ними — createZipArchive(), превращающая последовательность объектов ZipEntry в читаемый stream и автоматически переключающаяся на структуры Zip64 при выходе за пределы классического формата, и setMaxZipContentSize(), задающая потолок памяти по умолчанию для content().
Практический пример
import { ZipFile, ZipEntry, createZipArchive } from 'node:zlib';
import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
// Чтение: при открытии разбираются только хвост и central directory
const zip = await ZipFile.open('release.zip');
try {
for (const entry of await zip.entries()) {
if (!entry.isFile) continue;
console.log(entry.name, entry.size, '->', entry.compressedSize);
}
// Маленькая запись: читаем в память
const manifest = await zip.get('manifest.json');
const config = JSON.parse((await manifest.content()).toString('utf8'));
// Большая запись: отдаём потоком, память ограничена
await pipeline(zip.stream('bundle.js'), createWriteStream('bundle.js'));
} finally {
await zip.close();
}
// Запись: записи на входе, архив потоком на выходе
await pipeline(
createZipArchive([
await ZipEntry.create('manifest.json', JSON.stringify({ ok: true })),
ZipEntry.createStream('bundle.js', createReadStream('dist/bundle.js')),
]),
createWriteStream('out.zip'),
);Деталь, на которую стоит обратить внимание: API построен вокруг потоков, а не полной загрузки файла. createZipArchive() потребляет записи по мере выдачи результата, а ZipEntry.createStream() позволяет собрать архив из источника, который в память не поместился бы никогда.
Оговорки из реальной практики
- Все эти API помечены как Stability 1.0 — Early development. Сигнатуры могут меняться между релизами, так что закладывать их в стабильный публичный контракт пока рано.
- У
setMaxZipContentSize()значение по умолчанию — 256 MiB (268435456 байт), и это защита от zip-бомб: запись больше потолка отклоняется до выделения памяти. Но защита распространяется только наcontent()— чтение черезcontentIterator()ею не покрывается. Ещё один довод в пользу потокового чтения недоверенных данных, и довод не только производительный. - Изменение на месте через ZipFile, открытый на запись, не является crash-atomic. Обрыв посреди записи может оставить архив нечитаемым, поэтому безопасный шаблон прежний: записать новый файл и атомарно подменить.
- ZipBuffer не копирует. Изменение или переиспользование исходного Buffer во время работы тихо портит чтение — ровно тот класс багов, который не всплывает в тестах.
- Имена записей приходят из файла, который вы не контролируете, и могут содержать
../или абсолютный путь. Проверка на path traversal перед записью на диск — задача приложения, а не модуля. Классическая уязвимость, задевшая немало архивных библиотек. - Возможность вышла на Current, а не LTS; тем, кто на 24.x, есть смысл подождать. И практическая деталь: 26.8.1 вышел почти сразу следом, исправляя строку версии, которая ошибочно сообщала об alpha. Фиксируйтесь на 26.8.1.
Вывод
ZIP в node:zlib закрывает больше дверей, чем открывает. Распространённая операция, стоившая постоянной зависимости, стала частью платформы. Направление в Node.js читается давно — сначала test runner, затем SQLite, теперь ZIP — и каждый шаг подрезает дерево зависимостей, а вместе с ним и поверхность, на которой строятся атаки на цепочку поставок. Решение простое: попробовать в отдельной ветке, измерить и не тащить в production, пока API не устоится.
Первоисточник: примечания к релизу Node.js 26.8.0
Про безопасность дерева зависимостей: npm-червь ставит хуки в ваш AI-агент