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().

Практический пример

js
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-агент