CompressionStreamでgzip圧縮ツールを作る|ブラウザだけで圧縮・展開

ブラウザでgzip圧縮・展開。Compression Streamsの入門 Web標準API
カテゴリー
Web標準API
公開日
2026.09.26

はじめに

ログやJSONをブラウザで圧縮してから保存したい場合、Compression Streams APIを利用できます。入力をStreamのままCompressionStreamへ通せるため、圧縮アルゴリズムをJavaScriptで実装する必要がありません。

ここでは選択したファイルを.gzにし、gzipファイルを元へ戻す関数を作ります。ファイルはサーバーへ送信しません。

gzipと展開を簡単に説明する

圧縮は、同じ内容をより少ないバイトで表現する変換です。展開すると元のバイト列へ戻ります。gzipはその形式の1つで、テキストやJSONは小さくなりやすい一方、JPEGや動画のように既に圧縮されたデータは小さくならない場合があります。

完成物では、ファイルを選んで「gzip圧縮」を押し、処理後に「結果を保存」を押すと元の名前.gzを保存できます。その.gzを選び直して「gzip展開」を押すと元の内容を取り出せます。展開後も「結果を保存」を押してファイルを保存します。

必要環境と起動

Node.js 22.12以上とnpm、Compression Streams API対応ブラウザを用意します。次のHTMLをindex.html、「圧縮と展開」のJavaScriptをapp.jsとして同じ新しいフォルダーに保存してください。同フォルダーでnpx --yes vite@7.3.6 --host 127.0.0.1を実行し、端末に表示されたURLを開きます。初回はnpmからViteを取得します。終了はCtrl+Cです。ファイルはブラウザ内で処理され、このサンプルにはアップロード処理はありません。

HTML

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>CompressionStream gzipツール</title>
</head>
<body>
  <h1>CompressionStream gzipツール</h1>
  <p>ファイルはブラウザ内で処理し、サーバーへ送信しません。</p>
  <label>対象ファイル <input id="file" type="file"></label>
  <button id="compress" type="button">gzip圧縮</button>
  <button id="decompress" type="button">gzip展開</button>
  <output id="status" role="status">ファイルを選択してください。</output>
  <a id="download" hidden>結果を保存</a>
  <script type="module" src="./app.js"></script>
</body>
</html>

圧縮と展開

const fileInput = document.querySelector("#file");
const status = document.querySelector("#status");
const downloadLink = document.querySelector("#download");
let resultUrl;
let busy = false;
const LIMIT = 16 * 1024 * 1024; // デモでは入力・出力それぞれ16 MiBまで

async function transformFile(file, mode) {
  if (!["compress", "decompress"].includes(mode)) throw new Error("不明な操作です");
  if (file.size > LIMIT) throw new RangeError("入力は16 MiB以下にしてください");
  const Constructor = mode === "compress" ? globalThis.CompressionStream : globalThis.DecompressionStream;
  if (!Constructor) throw new Error("このブラウザは指定の圧縮APIに対応していません");
  const stream = mode === "compress"
    ? new CompressionStream("gzip")
    : new DecompressionStream("gzip");

  const outputStream = file.stream().pipeThrough(stream);
  const reader = outputStream.getReader();
  const chunks = [];
  let size = 0;
  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      size += value.byteLength;
      if (size > LIMIT) throw new RangeError("出力が16 MiBを超えたため中止しました");
      chunks.push(value);
    }
    return new Blob(chunks);
  } catch (error) {
    await reader.cancel().catch(() => {});
    throw error;
  } finally {
    reader.releaseLock();
  }
}

async function sha256(blob) {
  const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
  return [...new Uint8Array(digest)]
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
}

function showDownload(blob, name) {
  if (resultUrl) URL.revokeObjectURL(resultUrl);
  resultUrl = URL.createObjectURL(blob);
  downloadLink.href = resultUrl;
  downloadLink.download = name;
  downloadLink.hidden = false;
}

async function run(mode) {
  if (busy) return;
  const file = fileInput.files[0];
  if (!file) {
    status.textContent = "ファイルを選択してください。";
    return;
  }

  busy = true;
  const buttons = document.querySelectorAll("button");
  buttons.forEach(button => { button.disabled = true; });
  fileInput.disabled = true;
  status.textContent = "処理中です。";
  downloadLink.hidden = true;
  try {
    const result = await transformFile(file, mode);
    const name = mode === "compress"
      ? `${file.name}.gz`
      : (file.name.endsWith(".gz") ? file.name.slice(0, -3) : "output");

    const hash = await sha256(result);
    showDownload(result, name || "output");
    status.textContent = [
      mode === "compress" ? "gzip圧縮に成功しました。" : "gzip展開に成功しました。",
      `入力: ${file.size} byte`,
      `出力: ${result.size} byte`,
      `出力SHA-256: ${hash}`,
    ].join("\n");
  } catch (error) {
    status.textContent = `処理に失敗しました: ${error.message}`;
  } finally {
    busy = false;
    buttons.forEach(button => { button.disabled = false; });
    fileInput.disabled = false;
  }

}

document.querySelector("#compress").addEventListener("click", () => run("compress"));
document.querySelector("#decompress").addEventListener("click", () => run("decompress"));

addEventListener("beforeunload", () => {
  if (resultUrl) URL.revokeObjectURL(resultUrl);
});

本当にストリームのままか

圧縮処理自体はストリームですが、この例はチャンクを集めてBlobを作り、ハッシュ計算時にも全結果をarrayBuffer()で読みます。入力と出力にそれぞれ16 MiBの上限を設けましたが、内部バッファや一時コピーもあるため、総メモリが16 MiB以内になる保証ではありません。処理中の二重実行も禁止しています。

未知の圧縮データを無制限に展開しないことが重要です。巨大ファイル向けには、出力サイズの上限を保ちながらFile System Access API等の書き込みStreamへ直接pipeする構成を検討します。このデモには利用者による中断ボタンや処理時間上限はありません。

gzipとHTTP圧縮の違い

このAPIで作るのはアプリが扱うgzipデータです。WebサーバーがContent-Encoding: gzipでレスポンスを圧縮する設定とは別です。サイト配信の転送量を減らす目的なら、CDNまたはWebサーバーのHTTP圧縮を設定します。

動作確認

UTF-8テキスト、バイナリ、空ファイルをそれぞれ圧縮・展開し、元のSHA-256と一致するか確認します。

async function sha256(blob) {
  const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
  return [...new Uint8Array(digest)]
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
}

検証用の日本語テキストでは、入力348 byteが183 byteになりました。展開後は348 byteへ戻り、元ファイルと展開結果のSHA-256が5c7a2c07fd124dc376d53acb61aefac79475551cc482ff2c8ecc6b66a9f460b1で一致しました。圧縮サイズは内容によって変わるため、この数値を性能保証として扱わないでください。

空ファイルは20 byteのgzipになり、展開後は0 byteへ戻りました。gzipではないファイルを展開すると「処理に失敗しました」に続いて理由が表示されます。ブラウザによって詳細なエラー文は異なります。非対応API、サイズ上限、破損データをすべて「不正なgzip」と断定しないようにしています。

既に圧縮されたJPEGや動画は、gzipにしても小さくならないか増える場合があります。圧縮後サイズを表示し、常に小さくなると説明しないようにします。

よくあるトラブル

  • ボタンを押しても何も起きない:ファイルを選択しているか、クリック処理を登録した後に操作しているか確認する
  • CompressionStream is not defined:対応ブラウザを使うか、別ライブラリによるフォールバックを用意する
  • 展開処理が失敗する:表示された理由を読み、対応API、16 MiB上限、中身がgzip形式かを確認する
  • 日本語ファイル名が変わる:この例は圧縮データ内に元のファイル名を保存しないため、アプリ側で名前を管理する
  • 大きなファイルを扱いたい:出力上限を安易に外さず、結果を一括収集しない出力方法を検討する

関連記事

まとめ

CompressionStreamはブラウザ標準のStreamとしてgzip・deflateを扱えます。巨大ファイルでは出力を全メモリ化しない設計にし、破損gzipの例外、ファイル名、ハッシュ一致まで確認してください。

参考リンク

コメント

タイトルとURLをコピーしました