web-files-file-upload-patterns について
File Upload Patterns は、ブラウザーでのファイル選択、検証、アップロードの実装を支援するエージェントスキルです。ストレージへの直接アップロードや再開可能な方式を扱い、アクセシビリティとセキュリティにも配慮します。
主な機能
- 01クリック、キーボード、ドラッグで選択
- 02クライアント検証とマジックバイト確認
- 03XHR による進捗表示とキャンセル
- 04署名付き URL で直接アップロード
- 05チャンク分割、再試行、再開
- 06状態と進捗のアクセシブルな通知
活用シーン
- 01キーボード対応ドロップゾーンの構築
- 02進捗表示とキャンセル機能の追加
- 03オブジェクトストレージへの直接送信
- 04中断した大容量アップロードの再開
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ agents-inc/ skills --skill web-files-file-upload-patternsインストール後、Claude Code で/web-files-file-upload-patternsを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
metadata.yamlreference.mdexamples/accessibility.mdcore.mdpresigned-upload.mdpreview.mdprogress.mdresumable.mdvalidation.md詳細
ファイルアップロードのパターン
クイックガイド: ドロップゾーンは、非表示のファイル入力を内包するキーボード操作可能なボタンであり、ドラッグ操作はその拡張機能です。ユーザーの利便性のため、クライアント側では拡張子、MIME タイプ、ファイル自体のマジックバイトの順に検証し、さらにサーバー側でも検証してください。クライアント側のチェックは、どれもセキュリティ対策にはならないためです。進捗の取得には
XMLHttpRequestが必要です。fetchにはアップロード進捗イベントがありません。およそ 100MB を超える場合は、失敗時のやり直しを 1 チャンク分に抑えるため、ファイルをチャンクに分割してください。大きなファイルは、サーバーが発行する署名付き URL を使ってストレージに直接アップロードし、リクエストボディがプロキシされないようにします。
詳細リソース:
- examples/core.md — ファイル入力、ドロップゾーン、ファイルリストの状態管理と描画、組み立て済みコンポーネント
- examples/validation.md — ルールベースのバリデーター、マジックバイトの検出、寸法チェック、検証フック
- examples/progress.md — 速度と残り時間を含む XHR の進捗、進捗バー、フォーマッター、並行アップロード
- examples/preview.md — 選択したファイルのプレビューサムネイルとクリーンアップ
- examples/presigned-upload.md — PUT と POST ポリシーによるアップロード、サーバーとの契約、マルチパートの各パート、フックとしての全体フロー
- examples/resumable.md — リトライ対応のチャンクアップローダー、再読み込み後の再開、tus クライアント、tus サーバーとの契約
- examples/accessibility.md — 選択と進捗の通知、ファイルダイアログを閉じた後のフォーカス復帰
- reference.md — サイズに応じた方式の選択、有効期限の指針、検証順序、CORS、確認用チェックリスト
どの方法を選ぶか
ほぼすべては、アップロード先によって決まります。
- ファイルを自分のエンドポイントに送信する場合 —
FormDataを使った 1 回のPOST、XHR による進捗取得、サーバーで適用できるサイズ上限を使用します。examples/core.md と examples/progress.md で必要な内容はすべて揃います。 - ファイルをオブジェクトストレージに送信する場合 — サーバーが署名付き URL を発行し、ブラウザーから直接アップロードすることで、アプリケーションをバイトデータが経由しないようにします。 examples/presigned-upload.md。
- 失敗時の影響が大きいサイズのファイルの場合 — ファイルを分割して、同時実行数を制限しながら各パートをアップロードし、アップロード済みのパートを記録してリトライ時に再開できるようにします。 examples/resumable.md。
<critical_requirements>
アップロードコードを書く前に
ブラウザーだけでなく、サーバーでも検証してください。 クライアント側の検証は、何が拒否されるかをユーザーにすばやく知らせるためのものです。誰でも完全に回避できるため、安全性については何も保証しません。
種類が重要な場合は、ファイルの先頭のバイトを読み取ってください。 拡張子も MIME タイプも、どちらもファイルを作成した人が指定するものです。それらを信頼するチェックは、名前を変更した実行ファイルも通してしまいます。
作成したオブジェクト URL はすべて無効化してください。 プレビューは URL.revokeObjectURL() が実行されるまでファイル全体をメモリに保持するため、ユーザーが気を変えて 3 回ファイルを選び直すと、3 つのファイルがメモリに残ります。
ドロップゾーンにキーボードでアクセスできるようにしてください。 role="button"、tabIndex={0}、ファイルダイアログを開く Enter/Space ハンドラーを用意し、その上にドラッグ操作を追加します。モバイルではドラッグ操作ができないため、実際に使うのはクリック操作です。
リクエスト本体をプロキシするのではなく、サーバーから有効期限の短い署名付き URL を発行してください。 これにより、アップロードによってアプリケーションに負荷がかからず、ストレージの認証情報がブラウザーから参照されることもありません。
</critical_requirements>
自動検出: dropzone、dataTransfer.files、dragenter、dragleave、dragover、input type="file"、event.target.files、accept 属性、xhr.upload.addEventListener、lengthComputable、署名付き URL、uploadUrl、マルチパートアップロード、UploadPart、ETag、チャンク分割アップロード、file.slice、Content-Range、中断したアップロードの再開、tus、Tus-Resumable、Upload-Offset、マジックバイト、ファイルシグネチャ、FormData append file
対象:
- クリック、キーボード、ドラッグによるファイル選択
- 送信前の種類、サイズ、寸法の検証
- 進捗、速度、残り時間の報告とキャンセル
- サーバーが署名した URL を使ったストレージへの直接アップロード
- 大きなファイルのチャンク分割と、中断したアップロードの再開
- スクリーンリーダーへの選択、進捗、失敗の通知
別の場所で扱う内容:
- バイトの到着後の受信、スキャン、保存
- 送信前の画像のリサイズ、切り抜き、変換。このスキルが送信するのは、渡された
Fileです - 保存されたオブジェクトの格納場所、配信方法、URL の形式
- アップロード済みメディアのストリーミング再生
基本方針
アップロードは、混同されがちな 3 つの独立した課題から成り立っています。ファイルの選択、検証、そしてバイト列の転送です。それぞれを分けておくことで、どの部分も置き換え可能になります。
検証には、決して曲げてはならないルールがあります。クライアント側の検証はユーザー体験のための機能であり、制御手段となるのはサーバー側の検証だけです。 ブラウザーが把握しているファイルの情報、つまり名前、拡張子、type は、すべてファイル自体に由来します。マジックバイトを読み取れば基準は厳しくなりますが、本質は変わりません。依然として、クライアントが回避できるチェックです。
転送のスケーリングを左右するのは、ファイルの数ではなく、1 つのリクエストが開いたままになる時間です。短いリクエストなら、失敗しても全体を再試行できます。長いリクエストでは、接続が切れる可能性が積み重なり、全体を再試行するのが許容できなくなります。その時点で初めて、特定のバイト数に達したときではなく、チャンク分割の複雑さに見合う効果が得られるようになります。
基本パターン
パターン 1:ドロップゾーン
真偽値を追跡するのではなく、ドラッグイベントを数えてください。dragenter と dragleave は入れ子になった要素ごとに発生するため、ポインターが子要素を横切っただけで真偽値が切り替わってしまいます。
const dragCounterRef = useRef(0);
<div
onDragEnter={() => { dragCounterRef.current++; setState("drag-over"); }}
onDragLeave={() => {
dragCounterRef.current--;
if (dragCounterRef.current === 0) setState("idle");
}}
onDragOver={(e) => e.preventDefault()} // without this, drop never fires
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
}}
role="button"
tabIndex={disabled ? -1 : 0}
aria-label="File upload area. Click or drag files to upload."
>
<input ref={inputRef} type="file" hidden aria-hidden="true" tabIndex={-1} />
</div>
完全なコード:examples/core.md
パターン 2:ファイルリストの状態管理
ファイルごとに独自の状態を持つエントリーを 1 つ作成し、バッチ単位ではなくファイル単位で失敗を扱います。拒否されたファイルには理由が返され、UI に表示できます。
interface FileWithId {
id: string;
file: File;
preview?: string;
status: "pending" | "uploading" | "success" | "error";
progress: number;
error?: string;
}
// addFiles returns { added, rejected }, each rejection carrying its reason
// removeFile and clearFiles revoke any preview URL before dropping the entry
完全なコード:examples/core.md
パターン 3:XHR による進捗状況
fetch で取得時の進捗状況は報告されますが、アップロード時の進捗状況は報告されないため、アップロードの進捗状況には
XMLHttpRequest を使います。速度の読み取り値が不安定にならないよう、直近数回のサンプルを平均します。
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (event) => {
if (!event.lengthComputable) return; // no total: show a spinner, not a bar
const speed = rollingAverageSpeed(event.loaded, performance.now());
setProgress({
loaded: event.loaded,
total: event.total,
percentage: Math.round((event.loaded / event.total) * 100),
speed,
remainingTime: (event.total - event.loaded) / speed,
});
});
キャンセルには xhr.abort() を使います。fetch のボディをストリーミングして測定できるのは、ネットワーク上で送信されたバイト数ではなく、ストリームに渡したバイト数です。そのため、代用にはなりません。
完全なコード:examples/progress.md
パターン 4:マジックバイトによる検出
先頭の 12 バイトを読み取り、既知のシグネチャと照合します。ファイル全体を読み込んではいけません。大きなファイルではタブがフリーズします。
const FILE_SIGNATURES = [
{ mime: "image/jpeg", extension: "jpg", signature: [0xff, 0xd8, 0xff] },
{ mime: "image/png", extension: "png", signature: [0x89, 0x50, 0x4e, 0x47] },
{
mime: "application/pdf",
extension: "pdf",
signature: [0x25, 0x50, 0x44, 0x46],
},
{
mime: "application/zip",
extension: "zip",
signature: [0x50, 0x4b, 0x03, 0x04],
},
];
const buffer = await file.slice(0, 12).arrayBuffer();
const bytes = new Uint8Array(buffer);
Office ドキュメントは ZIP アーカイブなので、ZIP に一致した場合はさらに確認が必要です。先頭の 1 キロバイトに含まれる word/、xl/、ppt/ のいずれかで、どの形式かを特定できます。
完全なコード:examples/validation.md
パターン 5:署名付きアップロード
4 つのステップで完了し、アプリケーションがバイトデータを保持することはありません。
- クライアントが名前、種類、サイズを送信して、サーバーに URL をリクエストします。
- サーバーがリクエストを認可し、名前をサニタイズしてキーを作成し、有効期限の短い URL に署名します。
- クライアントがその URL にファイルを
PUTします。 - クライアントがキーをサーバーに伝え、サーバーが記録します。
const { uploadUrl, key } = await requestPresignedUrl(file);
const xhr = new XMLHttpRequest();
xhr.open("PUT", uploadUrl);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.addEventListener("progress", reportProgress);
xhr.send(file);
PUT の代わりに POST ポリシー URL を使うと、ストレージサービス側でサイズとコンテンツタイプを適用できます。ただし、FormData のフィールド順序が重要になるため、ファイルは最後に追加します。
完全なコード:examples/presigned-upload.md
パターン 6:チャンク分割と再開
ファイルを分割し、同時実行数を制限して各チャンクをアップロードします。失敗したチャンクは最初からやり直すのではなく、指数バックオフで再試行します。
const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
const start = chunkIndex * chunkSize;
const chunk = file.slice(start, Math.min(start + chunkSize, file.size));
完了したチャンクのインデックスは、name-size-lastModified をキーにして永続化します。そうすれば、再読み込み時に最初からではなく途中から再開できます。1 日後にそのレコードを期限切れにします。独自のプロトコルではなく相互運用可能なプロトコルを使う場合、tus では作成に POST を使い、オフセットの取得に HEAD を使い、追記に PATCH を使います。
完全なコード:examples/resumable.md
<red_flags>
要注意事項
実行時に問題が発生します:
dragoverにevent.preventDefault()がないと、dropが発生せず、ブラウザーがファイルに移動します。dragoverとdropの両方でデフォルト動作を防止してください- ドラッグ状態を真偽値で管理すると、ポインターが子要素を横切る際にちらつきます。
refでdragenterとdragleaveをカウントしてください - 選択後もファイル入力の値が設定されたままだと、同じファイルを 2 回選択しても
changeイベントが発生しません。filesを読み取った後、event.target.value = ""を代入してください - 進捗状況が必要な箇所で
fetchを使うと、アップロードの進捗イベントがなく、ストリームの進捗状況も誤った値になります。XMLHttpRequestを使ってください - 種類の判定に
file.text()やreadAsDataURL()を使うと、ファイル全体がメモリに読み込まれます。先頭の 12 バイトだけをスライスしてください - レンダー関数内でオブジェクト URL を作成すると、レンダーごとに新しい URL が作られ、どれも解放されません。エフェクト内で作成し、クリーンアップ時に解放してください
- ストレージバケットに CORS の設定がないと、直接アップロードはプリフライトで失敗します。オリジンとメソッドを許可し、マルチパート用に
ETagを公開してください
予想外の動作:
- 署名付き URL はベアラートークンです。保持している人は誰でも、有効期限が切れるまでその操作を実行できます
- 拡張子、MIME タイプ、
File.typeはすべてクライアント側から提供され、偽装可能です - モバイルブラウザーにはドラッグ&ドロップがないため、そこで使えるのはクリック操作だけです
- 長さが不明なリクエストでは
lengthComputableは false になり、true になるまではパーセンテージに意味がありません - 完了も中止もされていないマルチパートアップロードでは、パーツに課金される一方、表示されません。パーツを期限切れにするライフサイクルルールを設定してください
- Safari のドラッグイベントは Chromium と十分に異なるため、別途テストする価値があります
- 写真には EXIF の向き情報が含まれており、ブラウザーは表示時に適用しますが、Canvas 処理では適用されません
</red_flags>