取り組みの背景 — なぜ Cloudflare R2 なのか
Web アプリケーション開発において、ファイルアップロードとストレージは避けて通れない課題です。ユーザーアバター、商品画像、ドキュメントなど、あらゆるアプリでファイル管理が必要になります。
Cloudflare R2 は、S3 互換のオブジェクトストレージでありながらエグレス料金が無料という大きなメリットを持っています。AWS S3 では帯域幅に応じた課金が発生しますが、R2 ではデータの読み出し(ダウンロード)にかかる帯域コストがゼロです。画像や動画を多く配信するアプリにとって、これは運用コストの大幅な削減につながります。
ここではAntigravity の AI エージェントを使って Cloudflare R2 へのファイルアップロード機能と画像最適化パイプラインを効率的に構築する方法を、ステップバイステップで解説します。
この記事で学べること
- Cloudflare R2 バケットのセットアップと Workers との連携方法
- Antigravity エージェントを活用した API ルート&型定義の自動生成
- アップロード時の画像リサイズ・WebP 変換パイプラインの構築
- 署名付き URL によるセキュアなアップロードフローの実装
- 本番運用で役立つエラーハンドリングとバリデーション
対象読者
- Cloudflare Workers / R2 を使ったことがある方(初歩的な知識がある前提)
- ファイルアップロード機能を自分のアプリに実装したい方
- 画像最適化の自動化に興味がある方
Cloudflare R2 の基本セットアップ
まず Antigravity のターミナルで Wrangler CLI を使い、R2 バケットを作成します。Antigravity のエージェントに「R2 バケットを作成して wrangler.toml に追記して」と指示すれば、以下のような手順を自動実行してくれます。
# R2 バケットを作成
npx wrangler r2 bucket create my-app-uploads
# 作成されたバケットを確認
npx wrangler r2 bucket list次に wrangler.toml にバインディングを追加します。
# wrangler.toml
name = "my-upload-worker"
main = "src/index.ts"
compatibility_date = "2026-03-01"
[[r2_buckets]]
binding = "UPLOADS_BUCKET"
bucket_name = "my-app-uploads"この設定により、Worker コード内で env.UPLOADS_BUCKET として R2 バケットにアクセスできるようになります。
Antigravity エージェントでアップロード API を自動生成する
Antigravity のエージェント機能を活用すると、アップロード API の雛形を素早く生成できます。エージェントに以下のように指示してみましょう。
「Cloudflare Workers で R2 にファイルをアップロードする API を TypeScript で作成して。CORS 対応、ファイルサイズ制限(10MB)、許可する MIME タイプの制限を含めてください。」
Antigravity が生成するコードは以下のようになります。
// src/upload.ts
// R2 ファイルアップロードハンドラー
interface Env {
UPLOADS_BUCKET: R2Bucket;
ALLOWED_ORIGINS: string;
}
// 許可するMIMEタイプ
const ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
"application/pdf",
];
// 最大ファイルサイズ: 10MB
const MAX_FILE_SIZE = 10 * 1024 * 1024;
export async function handleUpload(
request: Request,
env: Env
): Promise<Response> {
// CORS プリフライト対応
if (request.method === "OPTIONS") {
return new Response(null, {
headers: corsHeaders(env.ALLOWED_ORIGINS),
});
}
if (request.method !== "POST") {
return jsonResponse({ error: "Method not allowed" }, 405);
}
try {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return jsonResponse({ error: "No file provided" }, 400);
}
// ファイルサイズチェック
if (file.size > MAX_FILE_SIZE) {
return jsonResponse(
{ error: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit` },
413
);
}
// MIMEタイプチェック
if (!ALLOWED_TYPES.includes(file.type)) {
return jsonResponse(
{ error: `File type ${file.type} is not allowed` },
415
);
}
// ユニークなキーを生成
const key = generateKey(file.name);
// R2 にアップロード
await env.UPLOADS_BUCKET.put(key, file.stream(), {
httpMetadata: {
contentType: file.type,
},
customMetadata: {
originalName: file.name,
uploadedAt: new Date().toISOString(),
},
});
return jsonResponse(
{
success: true,
key,
url: `/files/${key}`,
size: file.size,
type: file.type,
},
201,
corsHeaders(env.ALLOWED_ORIGINS)
);
} catch (err) {
console.error("Upload error:", err);
return jsonResponse({ error: "Upload failed" }, 500);
}
}
// ユニークなファイルキーを生成
function generateKey(originalName: string): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
const ext = originalName.split(".").pop() || "bin";
return `uploads/${timestamp}-${random}.${ext}`;
}
// CORS ヘッダー
function corsHeaders(origin: string): HeadersInit {
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
}
// JSON レスポンスヘルパー
function jsonResponse(
data: Record<string, unknown>,
status: number,
headers?: HeadersInit
): Response {
return new Response(JSON.stringify(data), {
status,
headers: {
"Content-Type": "application/json",
...headers,
},
});
}エージェントが生成したコードには CORS 対応、ファイルサイズ制限、MIME タイプ検証がすべて含まれています。この出力をベースに、自分のプロジェクトに合わせてカスタマイズしていく流れが効率的です。
画像最適化パイプラインの構築
ファイルアップロードだけでなく、アップロードされた画像を自動的にリサイズ・WebP 変換する処理を追加しましょう。Cloudflare Workers では cf.image プロパティを使ったリサイズが可能ですが、ここでは R2 への保存時に複数サイズのバリアントを生成するアプローチを紹介します。
// src/image-optimizer.ts
// 画像最適化パイプライン
interface ImageVariant {
suffix: string;
width: number;
quality: number;
}
// 生成するバリアント定義
const VARIANTS: ImageVariant[] = [
{ suffix: "thumb", width: 150, quality: 75 },
{ suffix: "medium", width: 600, quality: 80 },
{ suffix: "large", width: 1200, quality: 85 },
];
export async function optimizeAndStore(
file: File,
key: string,
bucket: R2Bucket
): Promise<{ original: string; variants: Record<string, string> }> {
// オリジナルを保存
const originalKey = `originals/${key}`;
await bucket.put(originalKey, file.stream(), {
httpMetadata: { contentType: file.type },
});
const variantKeys: Record<string, string> = {};
// Cloudflare Image Resizing を利用してバリアント生成
for (const variant of VARIANTS) {
const variantKey = `optimized/${variant.suffix}/${key.replace(
/\.[^.]+$/,
".webp"
)}`;
// Image Resizing API を使ったリサイズリクエスト
const resizeUrl = new URL(`https://your-domain.com/originals/${key}`);
const resizedResponse = await fetch(resizeUrl.toString(), {
cf: {
image: {
width: variant.width,
quality: variant.quality,
format: "webp",
fit: "cover",
},
},
});
if (resizedResponse.ok) {
await bucket.put(variantKey, resizedResponse.body, {
httpMetadata: { contentType: "image/webp" },
});
variantKeys[variant.suffix] = variantKey;
}
}
return { original: originalKey, variants: variantKeys };
}このパイプラインにより、ユーザーが1枚の画像をアップロードするだけで、サムネイル(150px)・中サイズ(600px)・大サイズ(1200px)の WebP バリアントが自動生成されます。
署名付き URL によるセキュアなアップロード
クライアントから直接 R2 にアップロードさせたい場合は、署名付き URL(Presigned URL)を使います。サーバー側で署名付き URL を発行し、クライアントはその URL に対して直接ファイルを PUT するフローです。
// src/presigned.ts
// 署名付きURL発行エンドポイント
import {
S3Client,
PutObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
interface Env {
R2_ACCESS_KEY_ID: string;
R2_SECRET_ACCESS_KEY: string;
R2_ACCOUNT_ID: string;
R2_BUCKET_NAME: string;
}
export async function generatePresignedUrl(
env: Env,
filename: string,
contentType: string
): Promise<{ uploadUrl: string; key: string }> {
const s3 = new S3Client({
region: "auto",
endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
},
});
const key = `uploads/${Date.now()}-${filename}`;
const command = new PutObjectCommand({
Bucket: env.R2_BUCKET_NAME,
Key: key,
ContentType: contentType,
});
// 署名付きURLの有効期限: 15分
const uploadUrl = await getSignedUrl(s3, command, {
expiresIn: 900,
});
return { uploadUrl, key };
}クライアント側の実装は以下のようになります。
// フロントエンド: 署名付きURLを取得してアップロード
async function uploadFile(file: File): Promise<string> {
// Step 1: サーバーから署名付きURLを取得
const res = await fetch("/api/presigned-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
}),
});
const { uploadUrl, key } = await res.json();
// Step 2: 署名付きURLに直接アップロード
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
return key;
}この方式では、ファイルデータがアプリケーションサーバーを経由しないため、サーバーの負荷を大幅に軽減できます。大容量ファイルのアップロードに特に有効です。
本番運用のためのベストプラクティス
Antigravity のエージェントに「本番運用を意識したエラーハンドリングとリトライロジックを追加して」と依頼すると、以下のような堅牢な実装を提案してくれます。
// src/upload-with-retry.ts
// リトライ付きアップロード関数
async function uploadWithRetry(
bucket: R2Bucket,
key: string,
body: ReadableStream,
metadata: R2PutOptions,
maxRetries = 3
): Promise<R2Object> {
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const result = await bucket.put(key, body, metadata);
if (result === null) {
throw new Error("R2 put returned null");
}
return result;
} catch (err) {
lastError = err as Error;
console.warn(
`Upload attempt ${attempt}/${maxRetries} failed: ${lastError.message}`
);
if (attempt < maxRetries) {
// 指数バックオフ: 1秒 → 2秒 → 4秒
await new Promise((r) =>
setTimeout(r, Math.pow(2, attempt - 1) * 1000)
);
}
}
}
throw new Error(
`Upload failed after ${maxRetries} attempts: ${lastError?.message}`
);
}運用時に気をつけるポイント
- ファイル名のサニタイズ: ユーザーが入力するファイル名には危険な文字が含まれる可能性があります。キーを生成する際はタイムスタンプ+ランダム文字列を使い、オリジナルのファイル名はメタデータとして保存しましょう
- Content-Type の検証: クライアントが送信する
Content-Typeヘッダーだけでなく、ファイルのマジックバイト(先頭数バイト)も検証すると、偽装されたファイルを防げます - バケットのライフサイクルルール: 一時ファイルや古いバリアントを自動削除するルールを R2 のダッシュボードから設定しておくと、ストレージコストを抑えられます
- アクセス制御: 公開すべきファイル(プロフィール画像など)と非公開ファイル(請求書PDFなど)は別バケットに分けるか、キープレフィックスで区別します
Cloudflare Workers でのセキュアな API 構築パターンについて、さらに詳しい認証・バリデーション・レート制限の実装例を知りたい方は Antigravity エージェントでセキュアな API バックエンドを構築する で体系的に解説しています。
R2 + Workers AI で画像解析を追加する
Cloudflare の Workers AI を組み合わせると、アップロード画像に対する自動タグ付けや不適切コンテンツの検出も実現できます。
// src/image-analysis.ts
// Workers AI による画像解析
interface Env {
AI: Ai;
UPLOADS_BUCKET: R2Bucket;
}
export async function analyzeImage(
env: Env,
imageKey: string
): Promise<{ labels: string[]; safe: boolean }> {
// R2 から画像を取得
const object = await env.UPLOADS_BUCKET.get(imageKey);
if (!object) {
throw new Error("Image not found");
}
const imageBytes = await object.arrayBuffer();
// Workers AI で画像分類
const classificationResult = await env.AI.run(
"@cf/microsoft/resnet-50",
{
image: [...new Uint8Array(imageBytes)],
}
);
// 上位5ラベルを取得
const labels = classificationResult
.slice(0, 5)
.map((r: { label: string }) => r.label);
return {
labels,
safe: true, // 実際のプロジェクトではNSFW検出モデルを使用
};
}Workers AI と R2 を組み合わせたエッジ AI アプリの構築方法については Antigravity エージェント × Cloudflare Workers AI でエッジ AI アプリを構築する でさらに深く掘り下げています。
まとめ
ここではAntigravity の AI エージェントを活用して Cloudflare R2 ベースのファイルアップロード&画像最適化パイプラインを構築する方法を解説しました。R2 のエグレス無料という強みを活かせば、画像配信コストを大幅に抑えながらスケーラブルなファイル管理基盤を作れます。
Antigravity のエージェントに実装方針を伝えるだけで、型安全なアップロード API、バリデーション、リトライロジックまで自動生成してくれるのは、開発効率の観点で非常に大きなメリットです。まずは小さなプロジェクトで R2 バケットを作成し、実際にアップロード機能を動かしてみてください。