ライブラリを使わずにNodeでZIPファイルを読み書きする
Node.js 26.8.0の実験的なnode:zlib APIでZIPを読み書きする方法を解説。ストリーミング、zip-slip対策、旧バージョン向けの代替手段も紹介。
Node.js 26.8.0以降では、組み込みのnode:zlibモジュールだけでZIPアーカイブの読み書きができるようになりました。そのため、adm-zip、archiver、yauzlは不要になります。なお、このAPIは実験的(experimental)な機能です。
アップロードを受け付けたりリリース成果物をビルドしたりするNodeプロジェクトの多くは、ZIP関連のパッケージを少なくとも1つ、多くの場合は2つ抱えています。読み込み用のyauzlと書き込み用のarchiverです。どちらも、信頼できないバイナリ入力を扱うサードパーティ製のコードです。
この機能は、2026年8月26日にNode.js 26.8.0リリースで、PR #64339を通じて提供されました。ZIPアーカイブAPIは実験的な機能です。API内のいずれかの機能を初めて呼び出すと実験的機能の警告が出力されますが、node:zlibをインポートするだけでは出力されません。現時点では、Node 24 LTSのどのリリースにもこの機能は含まれていません。24.21.0までの24.x系の変更履歴に、ZIPクラスは登場しません。これは、npmパッケージを置き換えるNode.jsの他の組み込みAPIと同じ流れに沿ったものです。APIは新しく、現在も変更が続いているため、本番環境に投入する前にnode:zlibのドキュメントで正確なシグネチャを確認してください。
重要なポイント
- Node.js 26.8.0では、
ZipFile、ZipBuffer、ZipEntry、createZipArchive()を通じて、ZIPの読み書きを行う実験的なサポートがnode:zlibに追加されました。 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() |
ZipFileを使ってNode.jsでZIPファイルを読み込むには?
Node.jsでディスク上の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でrejectされます。そのため、この例ではまずzip.has()で確認しています。zipFile.values()はイテレータを返し、その各要素はZipEntryに解決されるPromiseです。for awaitが適しているのはこのためです。各エントリはnameとsize(展開後のサイズ、単位はバイト)を公開しています。非同期の呼び出しには同期版(openSync()、contentSync()、valuesSync())も用意されています。例外はストリーミングAPIで、contentIterator()とZipEntry.createStream()には同期版がありません。
contentIterator()による大きなアーカイブのストリーミング
Node.jsで大きなZIPメンバーを読み込むには、contentIterator()を使用します。展開後の内容を一連のBufferチャンクとして渡すため、メンバー全体を一度にメモリに載せる必要がありません。非同期イテラブルなので、pipeline()に直接渡すことができます。このプル型のモデルは、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()はzip爆弾(zip bomb)への防御策です。zip爆弾とは、圧縮時は小さくても、展開すると巨大なサイズに膨れ上がるアップロードファイルのことです。この関数は、content()のようなバッファリング型の読み込みに対して、モジュール全体のデフォルト上限を設定します。ドキュメントには、ストリーミングでは大きなメモリ割り当てが一度に発生しないため、contentIterator()にはこのデフォルト値が適用されないと明記されています。content()の呼び出しごとのmaxSizeオプションは、宣言された展開後サイズが指定値を超えるエントリを拒否します。このオプションを省略した場合、content()はgetMaxZipContentSize()で取得できるモジュール全体の上限を使用します。contentIterator()は独自のmaxSizeオプションを受け付けますが、こちらはデフォルトでは無制限です。
ストリーミング読み込みには、別の安全策があります。後続のハードニングPRによると、ストリーミングデコーダーは、メンバーで宣言された展開後サイズを超えるバイトを生成しません。データがそのサイズを超えて展開されようとすると、即座にERR_ZIP_ENTRY_CORRUPTがスローされます。上記のループでストリーミングを開始する前にentry.sizeをチェックしているのはこのためです。
フォルダからZIPアーカイブを作成するには?
Node.jsでZIPアーカイブを作成するには、ファイルごとにZipEntryを1つずつ作成し、そのリストをcreateZipArchive()に渡します。この関数はエントリ群をアーカイブのバイト列を出力するReadableストリームに変換します。あとはそのストリームをファイルにパイプします。まず、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()は、第1引数にファイル名、第2引数にデータを受け取ります。同期版はzlib.ZipEntry.createSync(filename, data, options)としてドキュメント化されています。この例では、各ファイルをバッファに読み込んでから追加しています。大きなファイルの場合は、代わりにZipEntry.createStream()を使用してください。createZipArchive()がアーカイブを書き出すのと並行してソースを圧縮するため、ファイル全体をメモリに保持する必要がありません。ストリーミングエントリは一度しか使えません。createZipArchive()が書き出した後は内容が消費済みとなり、再度読み込むことはできません。
ネストしたフォルダとzip-slip対策
ZIPアーカイブ内のパスは、作成したOSに関係なくスラッシュ(/)を使用します。ZIPファイルフォーマット仕様のファイル名に関するセクション(4.4.17)で、これが必須とされています。そのためcollectFilesではpath.sepを/に変換しており、assets\img\logo.pngのようなWindowsのパスはassets/img/logo.pngとして格納されます。/で終わるエントリ名はディレクトリを表します。
zip-slipはパストラバーサル攻撃の一種です。悪意のあるアーカイブに../../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系の変更履歴を確認してください。本番環境で使用しているNodeのバージョンにこのAPIが搭載されるまでは、パッケージを残しておきましょう。ランタイムチェックを行えば、共有ツールで適切な処理を選択できます。
const [major, minor] = process.versions.node.split('.').map(Number);
const hasNativeZip = major > 26 || (major === 26 && minor >= 8);
以下は、組み込みAPIで置き換えられるarchiverのコードです。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のドキュメントを読み直してください。最初の一歩としては、アップロードファイルの展開など、読み込み処理を1つだけhasNativeZipチェックの背後で置き換えるのがよいでしょう。本番環境のNodeがこのAPIをサポートするまでは、パッケージによるフォールバックを残しておいてください。
よくある質問
Nodeで createZipArchive() を呼び出したとき、同じ名前のエントリが2つあるとどうなりますか?
createZipArchive() は両方のエントリを書き込みます。受け取った順にエントリを書き出し、名前の重複をチェックしないため、アーカイブには重複したエントリが含まれることになります。多くの展開ツールでは、最後に出現したものが残ります。一方、ZipBuffer と ZipFile の add() メソッドは動作が異なり、同じ名前の既存エントリを置き換えます。ファイルリストでパスが重複する可能性がある場合は、シリアライズする前に重複を除去してください。
node:zlib で4GBを超えるZIPアーカイブを書き込めますか?
はい。createZipArchive() は、エントリ数や、いずれかのオフセットまたはサイズが従来のZIPの16ビットおよび32ビットのフィールドに収まらなくなった時点で、自動的にZip64構造に切り替えます。これにより、4GBを超えるメンバーやアーカイブ、65,535個を超えるエントリを持つアーカイブにも対応できます。そのためのオプション指定は不要で、ZipBuffer.toBuffer() もエントリをシリアライズする際に同様にZip64へ切り替えます。
node:zlib でZIPエントリを読み込む際、どのエラーコードを処理すべきですか?
エントリの宣言サイズが maxSize を超えた場合に発生する ERR_ZIP_ENTRY_TOO_LARGE と、内容のCRC-32チェックサムが不正な場合や長さが宣言サイズと異なる場合に発生する ERR_ZIP_ENTRY_CORRUPT を処理してください。アーカイブに存在しない名前を要求すると ERR_ZIP_ENTRY_NOT_FOUND となります(ZipFile.get() はこのエラーでrejectされます)。PR 65016によるハードニング以降、ローカルヘッダーとセントラルヘッダーの内容が一致しないアーカイブは ERR_ZIP_INVALID_ARCHIVE で拒否されます。
Nodeの組み込みZIP APIはどの圧縮方式を使用しますか?
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