コンテンツへスキップ
HexaTransfer
ブログへ戻る
技術詳解

JavaScriptでチャンク分割アップロードを実装する方法

大容量ファイルの処理、進捗追跡、ネットワーク切断からの復旧を含む、JavaScriptでの再開可能なチャンク分割アップロードの実装方法を解説します。

JavaScriptのチャンク分割アップロードは大容量ファイルを固定サイズの断片(通常5〜10MB)に分割し、各々を個別のHTTPリクエストとしてアップロードし、サーバー上で再組み立てする。このパターンは3つの現実的な問題を解決する:ブラウザとプロキシは2GBを超えるリクエストを終了させ、モバイルネットワークはアップロード途中で接続を切断し、ユーザーは進捗フィードバックを求める。実装にはFile.slice()でチャンクを切り出し、各チャンクにAbortSignal付きのfetchを使い、S3マルチパートまたはカスタムマージャーでサーバー側の組み立てを行い、再開がタブリロードでも生き残るようIndexedDBにローカルインデックスを保持する。

チャンクが単一ショットアップロードに勝る理由

4GBのファイルを1リクエストとしてアップロードすると予測可能な理由で失敗する:Nginxのデフォルトclient_max_body_sizeは1MB、Cloudflareは無料プランのリクエストを100MBで制限し、AWS API Gatewayは10MBで強制停止し、モバイルSafariは4GBのArrayBufferをメモリに保持するタブを終了させる。チャンク分割アップロードはこれらの上限をすべて回避する。進捗バーが実際に動き、ゼロから再開しないリトライが可能で、一時停止と再開ができる。トレードオフはより多くのサーバーサイドの状態とより多くのラウンドトリップ—10GBのファイルで5MBごとに約1つのHTTPリクエスト、つまり2,000リクエストになる。

チャンクサイズの選択

チャンクサイズはスループット対レジリエンスのトレードオフだ。小さすぎると(1MB未満)TLSハンドシェイクにデータより多くの時間を費やす。大きすぎると(100MB超)接続断で数分のアップロードが無駄になる。ほとんどのネットワークに最適なのは5〜10MBで、S3の5MBマルチパート最小値と一致し、スロースタート後の典型的なTCPウィンドウサイズとも合っている。

const downlink = navigator.connection?.downlink ?? 10;
const chunkSize = downlink > 20 ? 10 * 1024 * 1024 : 5 * 1024 * 1024;

100Mbitの接続では10MBのチャンクが約1秒で完了する。4Gでは5MBのチャンクがトンネルにさしかかったときの復旧が良くなる。

ファイルのスライスとハッシュ化

File.slice()はコピーなしに同じ基盤のディスクバイト列を参照するBlobを返すため、20GBのファイルをスライスしてもコストがかからない:

function* sliceFile(file, chunkSize) {
  for (let offset = 0; offset < file.size; offset += chunkSize) {
    yield {
      index: Math.floor(offset / chunkSize),
      blob: file.slice(offset, offset + chunkSize),
    };
  }
}

サーバーが整合性を検証できるよう、アップロード前に各チャンクのSHA-256ハッシュを計算する:

const buffer = await chunk.blob.arrayBuffer();
const digest = await crypto.subtle.digest('SHA-256', buffer);
const hash = Array.from(new Uint8Array(digest))
  .map(b => b.toString(16).padStart(2, '0')).join('');

10GBのデータをハッシュ化するのに最新のラップトップで約20秒かかる—不安定なセルラーアップリンクでのサイレントな破損を検出するには価値がある。

制御された並行性によるアップロード

順次アップロードは帯域幅を無駄にし、無制限の並行処理はブラウザをクラッシュさせる。3〜4個の同時進行チャンクという並行性制限が両者のバランスを取る:

async function uploadAll(file, sessionId) {
  const queue = [...sliceFile(file, 5 * 1024 * 1024)];
  const workers = Array.from({ length: 4 }, async () => {
    while (queue.length) {
      const chunk = queue.shift();
      await uploadChunk(chunk, sessionId);
      emitProgress(chunk.index);
    }
  });
  await Promise.all(workers);
}

サーバーを過負荷にしないリトライ

ネットワークエラーには指数バックオフが必要で、タイトなリトライループは不適切だ。合理的なポリシー:3回の試行、基本遅延500ms、ジッター最大50%:

async function uploadChunk(chunk, sessionId, attempt = 0) {
  try {
    const res = await fetch(`/upload/${sessionId}/${chunk.index}`, {
      method: 'PUT', body: chunk.blob, headers: { 'X-Hash': chunk.hash }
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
  } catch (e) {
    if (attempt >= 3) throw e;
    const delay = 500 * 2 ** attempt + Math.random() * 250;
    await new Promise(r => setTimeout(r, delay));
    return uploadChunk(chunk, sessionId, attempt + 1);
  }
}

5xxレスポンスはリトライ可能、4xxは致命的として扱う(408と429を除く)。429では自分のバックオフでなくRetry-Afterヘッダーを尊重する。

タブリロード後の再開

各成功チャンク後にIndexedDBにアップロード状態を永続化する:

await db.put('uploads', {
  sessionId, fileName: file.name, fileSize: file.size,
  completedChunks: [...completedSet], updatedAt: Date.now()
}, sessionId);

同じファイルでユーザーがページを再び開いたとき、ファイルのsizelastModified、名前を保存済みセッションと比較する。一致があればサーバーにすでに受信したチャンクを確認(GET /upload/:sessionId/statusがビットマップを返す)し、不足しているものだけをアップロードする。tusプロトコルはこのパターンをUpload-Offsetヘッダーで正式化している。tus.ioはクライアントライブラリを提供しており、独自実装を避けたい場合に利用できる。

サーバー上でのチャンク組み立て

2つの有力なオプション:各チャンクがPartNumberとなる最後のCompleteMultipartUploadで結合するS3マルチパートアップロード、またはテンプファイルに各チャンクを書き込んで最後に連結するカスタムアセンブラ。S3マルチパートはスケール時に安価で、組み立て中はエグレス料金がかからずR2はゼロエグレスで読み取れる。カスタムアプローチはデバッグが単純で、組み立て中にストリーム暗号化を追加できる。10,000パート制限に注意—50GB以上のファイルには5MB以上のチャンクが必要だ。

HexaTransferはこのようなチャンク分割・再開可能パイプラインを10GBアップロードの内部処理に使用し、各チャンクにPUT前にクライアントサイドAES-256-GCMを追加している。詳細は https://hexatransfer.com で。無料、アカウント不要、最大10GB。

エンドツーエンド暗号化で大容量ファイルを安全に送信

エンドツーエンド暗号化で最大10GBのファイルを無料で転送。アカウント不要。ファイルはアップロード前にブラウザで暗号化されるため、他の誰にも読まれません。

ファイルを送信