低レイヤー知識皆無だけど ZIP 圧縮を Javascript で実装したい!

学習メモ。
やりたいこと:
- CompressionStream を使う
- Cloudflare Workers みたいなメモリ制限があるエッジサーバでも使えるように stream で圧縮する
- 仕様的に解凍は難しそうなので圧縮のみ
ユースケース: ユーザーが選択した複数個の画像を ZIP に圧縮してからクライアントに渡す、など
ビット操作だのなんだのはナニモワカラナイ

制限:
- エッジ環境で使えるようにしたいので stream で処理する
- その為、圧縮前に Content-Length を計算することはできない

仕様

ZIP ファイルのフォーマット
- ローカルファイルヘッダ
- ファイル情報をここに載せる
- 圧縮したファイルデータ
- 頼んだぞ CompressionStream
- データディスクリプタ
- 一部のファイル情報をここに載せる
(上記3つをファイル数分繰り返し)
- ファイル数分のセントラルディレクトリエントリ
- エンドオブセントラルディレクトリ
- 解凍時はまずここから読み込むらしい、「仕様的に解凍は難しそう」と書いたのはこの為(2回以上読み込めば出来るけど)

普通の ZIP フォーマットではローカルファイルヘッダに圧縮後ファイルサイズと CRC-32 (どちらもデータをすべて読み込み終わった後にしかわからない)を記述しておく 必要がある のが普通らしいが、ZIP64 フォーマットであれば その代わりに実データの後に来るデータディスクリプタに書いておけば大丈夫らしい
ZIP64 じゃなくても問題ない模様

CRC-32 は一度で計算を行うのではなく stream に合わせて値を更新していき、最後に計算結果を確定させるやり方で取得する
名前は要改善かもしれない
class Crc32Calculator {
#value = 0xFFFFFFFF
constructor() {}
add(data: Uint8Array) {
for (const byte of data) {
this.#value = this.#value ^ byte
for (let i = 0; i < 8; i++) {
this.#value = ((this.#value & 1) === 1)
? (this.value >>> 1) ^ 0xEDB88320
: this.#value >>> 1
}
}
}
finish(): number {
return this.#value ^ 0xFFFFFFFF
}
}
使い方
const crc32Calculator = new Crc32Calculator()
let crc32: number
new TransformStream({
transform(chunk, controller) {
crc32Calculator.add(chunk)
controller.enqueue(chunk)
},
flush() {
crc32 = crc32Calculator.finish()
}
})

node に zlib.crc32(data[, value])
があるのでテスト時に比較する用として使うと良さげ

ファイルを追加する時は、
- ローカルファイルヘッダを作って先に入れる
- 圧縮前のファイルデータのサイズを記録しておく(uncompressed file size)
- 圧縮前のファイルデータで CRC-32 の値を更新する
- 圧縮したファイルデータのサイズを記録しておく(compressed file size)
- 圧縮したファイルデータを入れる
- データディスクリプタを作って入れる
これらすべてをやっておかないといけないのでなかなかめんどくさい

できた!🥳
詳細はあとで書く

動作確認はできたけどインターフェイスというか API の設計があまり良くないので再設計する

TransformStream を extend した ZipStream と ZipEntryStream を作る
複数個の ZipEntryStream を ZipStream へ pipeTo していく感じ
データディスクリプタの追加や必要な情報の計算は ZipEntryStream で行うとして、問題はそれを ZipStream にどう渡すか
コールバック持たせて flush() のタイミングで使うのも考えたが、やはり使いやすさを考えると深く考えずにそのまま pipe で繋げて使える方がいい
なので ZipStream でパースするしかない

パースするとなるとやはり誤検知の対策が必要
データディスクリプタにはオプショナルなシグネチャ 0x08074b50 があるようなのでこれをちゃんと設定する
本格的にやるならローカルファイルヘッダの file name length を見ればどこからファイルデータが始まるのかがわかるので、それを DecompressionStream に流しデータディスクリプタの再計算・比較検証を行うこともできる、うーん複雑

とはいえ、セントラルディレクトリヘッダを組み立てる為にはローカルファイルヘッダ情報とデータディスクリプタ情報の両方が必要なので、どっちみちある程度ちゃんとしたパーサーが必要になりそう

ZipEntryStream 側でローカルファイルヘッダの extra field に適当な UUID を入れて、Map にその UUID をキーとして各種情報を保存しておき、ZipStream でその Map から情報を読み込むという技を思い付いた
extra field を読み込む為にローカルファイルヘッダをパースする必要はあるが、(ZipEntryStream から流されたものであれば)最初の10 byte が予めどういう値なのかわかっているのでだいぶ手間がマシになる
元々 relative offset of local header の計算の為にここの検知が必要だし

extra field にも専用のフォーマットがあるらしい、extra field header としてヘッダ ID とデータサイズをそれぞれ 2 byte ずつ extra field に設定する値の始めに書く
ヘッダ ID は色々あるけどとりあえず個人用なら 0xFFFF が一番いいそうな
ローカルファイルヘッダなどにある extra field length はこの 4 byte も含まれたサイズを設定しないといけないが、extra field header の方は入れたいデータのサイズのみを設定するのに注意

とりあえずこんな感じの API にすることができた
ちゃんと TransformStream っぽい感じ
const files = await fetchFiles()
const zipStream = new ZipStream()
;(async () => {
for (const file of files) {
await file
.stream()
.pipeThrough(
new ZipEntryStream({ fileName: file.name, lastModified: file.lastModified })
)
.pipeTo(zipStream.writable, { preventClose: true })
}
await zipStream.writable.close()
})()
return new Response(zipStream.readable, ...)

エラーハンドリングはどうしたらいいのか全くわからん、TransformStream からだとほぼ拾いようがない気がするが…
this.readable / this.writable を書き換えても大丈夫なんだろうか?