2026/06/04

大容量PDFを守る再開可能アップロード(tus.io)完全ガイド

tusuploadresumableapilarge-file

大容量のPDFを顧客からアップロードしてもらう機能を作るとき、必ずといっていいほど壁にぶつかります。PHPのupload_max_filesizeを超えてエラー、Nginxのclient_max_body_sizeでブロック、モバイル回線が不安定で接続が切れたら最初からやり直し——という光景は、PDF処理を扱う開発者であれば一度は経験しているはずです。

この記事では、FUNBREW PDFが2026年5月31日にリリースしたtus.ioプロトコル対応の再開可能アップロード機能を使って、これらの問題を根本から解決する方法を解説します。curl・JavaScript SDK・tus-js-clientの実装コードと合わせて、本番運用での注意点まで網羅します。


大容量アップロードの何が問題か

通常のマルチパートファイルアップロード(multipart/form-data)は、ファイル全体を1リクエストで送信する設計です。この設計には以下の問題があります。

問題 症状
サーバーサイドのサイズ制限 upload_max_filesize(PHP)・client_max_body_size(Nginx)超過で413エラー
タイムアウト 大容量ファイルのアップロード中にプロキシタイムアウトが発生
ネットワーク断 Wi-Fi切断・スマホ圏外で中断 → 最初からやり直し
進捗表示の困難 アップロード全体の進捗をUIに正確に反映しにくい

特にモバイルユーザーや法人向けの書類提出ワークフローでは、数十MB〜数百MBのPDFを扱うケースが増えています。「圏外に入っただけでやり直し」はユーザーエクスペリエンスとして致命的です。


tusプロトコルとは

tus.io再開可能なファイルアップロードのためのオープンプロトコルです。仕様はv1.0.0が公開されており、GitLab・Vimeo・Transloaditなど多くのサービスが採用しています。

tus の核心はシンプルで、チャンク(分割)+ オフセット管理の2つです。

  1. POSTでアップロードを予約し、サーバーからアップロードキーを受け取る
  2. PATCHでチャンクを分割して送信する(どこまで受信済みか Upload-Offset で管理)
  3. 中断後はHEADでオフセットを確認し、中断ポイントから再開する

これにより、PHPのupload_max_filesizeはチャンクごとに送るため無効化されます。Nginxのclient_max_body_size 0(無制限)設定と組み合わせることで、ファイルサイズ上限を実質的に撤廃できます。


FUNBREW PDF APIでのtus実装

エンドポイント一覧

FUNBREW PDF APIはtusプロトコル v1.0.0 に準拠したエンドポイントを提供しています。

Method Path 役割
OPTIONS /api/pdf/uploads サーバー capability・対応バージョンの確認
POST /api/pdf/uploads アップロード予約 → Locationヘッダでuploadキーを発行
HEAD /api/pdf/uploads/{key} Upload-Offsetを取得(再開ポイント確認)
PATCH /api/pdf/uploads/{key} チャンクの書き込み
DELETE /api/pdf/uploads/{key} アップロードの中断・破棄

認証: 既存のPDF APIキー(X-API-Keyヘッダ)をそのまま使用できます。

主要ヘッダ

ヘッダ 方向 説明
Tus-Resumable: 1.0.0 リクエスト tusプロトコルバージョン(必須)
Upload-Length POSTリクエスト ファイルの総バイト数
Upload-Offset PATCHリクエスト / HEADレスポンス チャンクの開始位置(受信済みバイト数)
Upload-Metadata POSTリクエスト ファイル名などのメタデータ(base64エンコード)
Content-Type: application/offset+octet-stream PATCHリクエスト チャンク送信時は必須

実装例

1. curlで生プロトコルを確認する

tusのプロトコルを理解するには、curlで手動実行するのが最も確実です。

export KEY="sk-your-api-key"
export BASE="https://pdf.funbrew.cloud"
export FILE="large-document.pdf"

# ステップ1: アップロードを予約する(POST)
LOCATION=$(curl -s -D - -X POST "$BASE/api/pdf/uploads" \
  -H "X-API-Key: $KEY" \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Length: $(wc -c < $FILE)" \
  -H "Upload-Metadata: filename $(basename $FILE | base64)" \
  | grep -i "^location:" | tr -d '\r' | awk '{print $2}')

echo "Upload URL: $LOCATION"
# → https://pdf.funbrew.cloud/api/pdf/uploads/abc-def-uuid

# ステップ2: 最初のチャンクを送信する(PATCH)
# チャンクサイズは5MB推奨。ここでは全データを1チャンクで送る例
curl -i -X PATCH "$LOCATION" \
  -H "X-API-Key: $KEY" \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Offset: 0" \
  -H "Content-Type: application/offset+octet-stream" \
  --data-binary @$FILE
# → 204 No Content, Upload-Offset: <total_bytes>

# ステップ3: 中断した場合は現在のオフセットを確認する(HEAD)
curl -i -X HEAD "$LOCATION" \
  -H "X-API-Key: $KEY" \
  -H "Tus-Resumable: 1.0.0"
# → 200 OK
# Upload-Offset: <bytes_received>
# Upload-Length: <total_bytes>

Upload-Offsetの値から中断ポイントがわかるので、次回のPATCHはそのオフセットから送信します。

2. @funbrew/pdf-client SDK(推奨)

FUNBREW PDF専用のJavaScript SDKを使うと、tus-js-clientの細かい設定を意識せずに再開可能アップロードが実装できます。

<!-- CDN経由(バンドラー不要) -->
<script src="https://pdf.funbrew.cloud/sdk/pdf-client.umd.js"></script>
import { PdfClient } from 'https://pdf.funbrew.cloud/sdk/pdf-client.esm.js';

const client = new PdfClient({
  baseUrl: 'https://pdf.funbrew.cloud',
  apiKey: 'sk-your-api-key',
});

// ファイルアップロード(再開可能・自動リトライ付き)
const result = await client.uploadFile(file, {
  chunkSize: 5 * 1024 * 1024,  // 5MB チャンク
  onProgress: (sent, total) => {
    const pct = ((sent / total) * 100).toFixed(1);
    progressBar.style.width = `${pct}%`;
    label.textContent = `${pct}%`;
  },
});

console.log(result.key);  // → アップロードキー(UUID)

// そのままPDF処理に使用できる
const compressed = await client.compress({
  upload_id: result.key,
  quality: 'medium',
});
console.log(compressed.download_url);

TypeScript型定義は https://pdf.funbrew.cloud/sdk/types/index.d.ts から取得できます。

3. tus-js-clientでの実装

より詳細な制御が必要な場合は、tus公式クライアントを直接使用します。

npm install tus-js-client
import * as tus from 'tus-js-client';

interface UploadOptions {
  file: File;
  apiKey: string;
  onProgress?: (percentage: number) => void;
  onSuccess?: (uploadUrl: string) => void;
  onError?: (error: Error) => void;
}

function startResumableUpload({
  file,
  apiKey,
  onProgress,
  onSuccess,
  onError,
}: UploadOptions): tus.Upload {
  const upload = new tus.Upload(file, {
    endpoint: 'https://pdf.funbrew.cloud/api/pdf/uploads',
    headers: { 'X-API-Key': apiKey },

    // チャンクサイズ: 5MB推奨(モバイルでは2MBが安定しやすい)
    chunkSize: 5 * 1024 * 1024,

    // 指数バックオフリトライ(ミリ秒)
    retryDelays: [0, 1000, 3000, 5000, 10000],

    metadata: {
      filename: file.name,
      filetype: file.type,
    },

    onProgress: (bytesUploaded, bytesTotal) => {
      const pct = (bytesUploaded / bytesTotal) * 100;
      onProgress?.(pct);
    },

    onSuccess: () => {
      // upload.url に完了済みアップロードのURLが入る
      // 末尾のUUIDがアップロードキー
      onSuccess?.(upload.url ?? '');
    },

    onError: (error) => {
      onError?.(error as Error);
    },
  });

  // 前回の中断箇所を確認して再開
  // localStorageにフィンガープリントが保存されるためリロード後も自動再開
  upload.findPreviousUploads().then((previousUploads) => {
    if (previousUploads.length > 0) {
      upload.resumeFromPreviousUpload(previousUploads[0]);
    }
    upload.start();
  });

  return upload;
}

// キャンセル
// upload.abort();

findPreviousUploads() / resumeFromPreviousUpload() を使うと、ページをリロードした後でも中断箇所から自動的に再開されます。


中断・再開のフロー

実際のネットワーク断でどう動くかを図示します。

クライアント                      サーバー
    |                                |
    |-- POST /api/pdf/uploads -----→|  アップロード予約
    |←-- 201, Location: /..../abc --|  uploadキー発行
    |                                |
    |-- PATCH (offset=0, 5MB) ----→ |  チャンク1送信
    |←-- 204, Upload-Offset: 5MB --|
    |                                |
    |-- PATCH (offset=5MB, 5MB) --→ |  チャンク2送信
    ~~~ ネットワーク断 ~~~
    |                                |
    |  (復旧後)                     |
    |-- HEAD /..../abc -----------→ |  オフセット確認
    |←-- 200, Upload-Offset: 8MB --|  8MBまで受信済みと判明
    |                                |
    |-- PATCH (offset=8MB, ...) --→ |  8MB地点から再開
    |←-- 204, Upload-Offset: 全量--|  完了

サーバーは常に受信済みのバイト数(Upload-Offset)を記録しており、クライアントがHEADで問い合わせることでどこから再開すればよいかが正確にわかります


並列アップロード(Concatenation extension)

大容量ファイルをさらに高速にアップロードしたい場合は、Concatenation拡張が使えます。ファイルをN個のパーツに分割して並列送信し、サーバー側で連結します。高レイテンシのモバイル回線や衛星回線で特に効果的です。

// tus-js-clientでの並列アップロード(parallelUploads: Nを指定するだけ)
const upload = new tus.Upload(file, {
  endpoint: 'https://pdf.funbrew.cloud/api/pdf/uploads',
  parallelUploads: 4,  // 4並列で分割送信
  headers: { 'X-API-Key': apiKey },
});
upload.start();

JS SDKでも同様です。

const result = await client.uploadFile(file, {
  parallelUploads: 4,
  onProgress: (sent, total) => console.log(`${sent}/${total}`),
});
// result.key が連結後の最終uploadキー

注意: PDF_TUS_STORAGE=s3-multipart(S3直流しモード)の場合、Concatenationは利用できません(400が返ります)。S3モードを使用していない場合は問題ありません。


Webhookでアップロード完了を受け取る

アップロード完了をサーバー側からプッシュ通知で受け取りたい場合は、Webhookイベントを設定できます。

設定: ダッシュボードのWebhook設定で tus.upload.completed を追加。

// 受信するペイロード例
{
  "event": "tus.upload.completed",
  "timestamp": "2026-06-04T09:00:00.000Z",
  "data": {
    "upload_key": "abc-def-uuid",
    "original_filename": "quarterly-report.pdf",
    "size": 15728640,
    "storage": "local",
    "s3_key": null,
    "content_type": "application/pdf"
  }
}
イベント 発火タイミング
tus.upload.completed アップロードが最終チャンクまで完了した瞬間
tus.upload.aborted DELETE受信時、またはGCによるタイムアウト掃除時

既存のpdf.generated系Webhookと同じ署名・リトライ・配信記録の仕組みが使えます。Webhookの詳細はPDF API Webhook連携ガイドを参照してください。


アップロード後の処理

完了したアップロードのキー(LocationヘッダのUUID末尾部分)は、そのまま各PDF処理エンドポイントに渡せます。

# アップロード → 直接マージ
curl -X POST https://pdf.funbrew.cloud/api/pdf/merge \
  -H "X-API-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "upload_ids": ["abc-def-uuid", "xyz-uvw-uuid"],
    "expiration_hours": 24
  }'
# アップロード → 圧縮
curl -X POST https://pdf.funbrew.cloud/api/pdf/compress \
  -H "X-API-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{"upload_id": "abc-def-uuid", "quality": "medium"}'

対応エンドポイントはmergecompressextract-textto-imageです。完了していないアップロードを渡すと422が返ります。

これらのエンドポイントはX-Job-Idヘッダを返し、GET /api/pdf/jobs/{jobId}/eventsでSSE進捗が購読できます(SSEについては別途SSE進捗ストリーミングガイドで解説します)。


本番運用の注意点

チャンクサイズの選択

環境 推奨チャンクサイズ
高速固定回線 10〜20MB
一般的なオフィス環境 5MB(デフォルト)
モバイル・不安定回線 1〜2MB

チャンクを小さくすると再送コストが下がりますが、オーバーヘッドが増えます。retryDelaysと合わせて環境に合わせて調整してください。

GC(中断アップロードの掃除)

中断されたままのアップロードは自動的にクリーンアップされます。サーバーのデフォルトではtus-phpのキャッシュTTL(1日)で expire し、専用コマンドpdf:cleanup-tus-uploadsが毎日03:25に定期実行されます。

アップロード後はできるだけ早くPDF処理エンドポイントに渡すことを推奨します(通常、アップロード完了から24時間以内)。

認証とアクセス制御

  • アップロードキーは作成した会社のみがアクセスできます(HEADでの他社キーへのアクセスは404)
  • キーはUUIDで推測不可能ですが、APIキーの管理は本番運用ガイドに従って適切に行ってください

Nginx設定

ブラウザや社内ツールからtusエンドポイントを直接呼ぶ場合は、Nginx設定の確認が必要です。

# /etc/nginx/conf.d/pdf-api.conf
location /api/pdf/uploads {
    client_max_body_size 0;       # チャンクサイズはtus側で制御するため無制限
    client_body_buffer_size 1m;
    proxy_request_buffering off;  # チャンクを逐次転送(バッファリングすると詰まる)
    proxy_read_timeout 3600s;     # 大容量チャンク送信中にタイムアウトしないよう設定
}

まとめ

tus.ioプロトコルを使った再開可能アップロードは、大容量PDFの扱いを根本から変えます。

  • upload_max_filesizeclient_max_body_sizeの制約を回避できる
  • ネットワーク断が起きてもオフセット地点から自動再開できる
  • チャンク単位の進捗表示がUIに組み込みやすい
  • アップロード完了後はそのままPDF処理APIに連携できる

FUNBREW PDFでは5GBまでのアップロードに対応しており、プレイグラウンドでアップロード〜PDF処理の一連のフローを試せます。大容量ファイルを扱うユースケースについてはユースケース一覧も参照してください。

実装の詳細は@funbrew/pdf-client SDKの記事でさらに詳しく解説しています。


関連記事

Powered by FUNBREW PDF