SvelteKit × Cloudflare R2 でファイルアップロードを実装した話

SvelteKit + Cloudflare Workers の構成でCloudflare R2を使ったファイルアップロード機能を実装しました。


Cloudflare R2 とは

R2はCloudflareのオブジェクトストレージサービスです。AWS S3互換のAPIを持ちながら、エグレス料金(転送料金)が無料なのが特徴です。

無料枠

項目 無料枠
ストレージ 10 GB / 月
書き込み操作 1,000,000 回 / 月
読み取り操作 10,000,000 回 / 月

セットアップ

1. Cloudflareダッシュボードでのr2有効化

初回利用時はダッシュボードから R2 Object Storage を有効化する必要があります。クレジットカードの登録が必要ですが、無料枠内では課金されません。

2. R2バケットを作成

npx wrangler r2 bucket create sveltekit-blog-uploads

--local をつけずに実行すると本番Cloudflareにバケットが作成されます。

3. wrangler.jsonc にバインディングを追加

"r2_buckets": [
  {
    "binding": "BUCKET",
    "bucket_name": "sveltekit-blog-uploads"
  }
]

4. tsconfig.json@cloudflare/workers-types を追加

R2Object などのCloudflare固有の型を認識させるために追加します。

{
  "compilerOptions": {
    "types": ["vitest/globals", "@cloudflare/workers-types"]
  }
}

5. src/app.d.ts に型を追加

interface Platform {
  env: {
    DB: D1Database;
    BUCKET: R2Bucket;  // ← 追加
    GITHUB_CLIENT_ID: string;
    GITHUB_CLIENT_SECRET: string;
  };
}

実装

サーバー側(+page.server.ts)

import type { PageServerLoad, Actions } from './$types';
import { fail } from '@sveltejs/kit';

export const load: PageServerLoad = async ({ platform }) => {
  const bucket = platform!.env.BUCKET;
  const list = await bucket.list();

  return {
    files: list.objects.map((obj: R2Object) => ({
      key: obj.key,
      size: obj.size,
    }))
  };
};

export const actions: Actions = {
  upload: async ({ request, platform }) => {
    const data = await request.formData();
    const file = data.get('file') as File;

    if (!file || file.size === 0) {
      return fail(400, { error: 'ファイルを選択してください' });
    }

    const bucket = platform!.env.BUCKET;
    await bucket.put(file.name, file.stream(), {
      httpMetadata: { contentType: file.type },
    });
  },
  delete: async ({ request, platform }) => {
    const data = await request.formData();
    const key = data.get('key') as string;

    const bucket = platform!.env.BUCKET;
    await bucket.delete(key);
  },
};

ポイント:load でR2オブジェクトをそのまま返さない

bucket.list() が返す R2Object はクラスインスタンスなので、SvelteKitがシリアライズできずエラーになります。.map() でプレーンなオブジェクト(POJO)に変換して返す必要があります。

// ❌ これはエラーになる
return { files: list.objects };

// ✅ これが正しい(型も明示する)
return {
  files: list.objects.map((obj: R2Object) => ({
    key: obj.key,
    size: obj.size,
  }))
};

クライアント側(+page.svelte)

<script lang="ts">
  import { enhance } from '$app/forms';
  let { data, form } = $props();
</script>

<h1>ファイルアップロード</h1>

<form method="POST" action="?/upload" enctype="multipart/form-data" use:enhance>
  <input type="file" name="file" />
  <button type="submit">アップロード</button>
  {#if form?.error}
    <p style="color: red">{form.error}</p>
  {/if}
</form>

<h2>アップロード済みファイル</h2>
<ul>
  {#each data.files as file}
    <li>
      <a href="/upload/{file.key}" target="_blank">{file.key}</a>
      ({Math.round(file.size / 1024)} KB)

      <form method="POST" action="?/delete" use:enhance style="display: inline">
        <input type="hidden" name="key" value={file.key} />
        <button type="submit">削除</button>
      </form>
    </li>
  {/each}
</ul>

ポイント:enctype="multipart/form-data" が必須

ファイルアップロードのフォームには必ず enctype="multipart/form-data" をつけます。これがないとファイルの中身がサーバーに届きません。

ファイル取得用APIルート(/upload/[key]/+server.ts)

R2は非公開なので、Workerを通じてファイルを返す専用ルートが必要です。

import type { RequestHandler } from './$types';
import { error } from '@sveltejs/kit';

export const GET: RequestHandler = async ({ params, platform }) => {
  const bucket = platform!.env.BUCKET;
  const object = await bucket.get(params.key);

  if (!object) {
    error(404, 'ファイルが見つかりません');
  }

  return new Response(object.body, {
    headers: {
      'Content-Type': object.httpMetadata?.contentType ?? 'application/octet-stream',
    },
  });
};

ローカル開発

ローカルではwranglerがR2をシミュレートしてくれます。実体は .wrangler/state/v3/r2/ 以下に保存されます。

npm run build
npx wrangler dev

ローカルのR2の中身はCLIで操作できますが、list サブコマンドは存在しないため、アプリ自体か .wrangler/state/v3/r2/ ディレクトリを直接確認するのが現実的です。

# ファイルをダウンロードして確認
npx wrangler r2 object get sveltekit-blog-uploads/ファイル名 --local --file ./downloaded.png

# ファイルを削除
npx wrangler r2 object delete sveltekit-blog-uploads/ファイル名 --local

本番デプロイ時の注意

初回デプロイ時、R2が有効化されていないと以下のエラーが出ます:

Please enable R2 through the Cloudflare Dashboard. [code: 10042]

ダッシュボードでR2を有効化してから再デプロイすると解決します。バケットの再作成は不要で、デプロイ時に自動的にバインディングが認識されます。


まとめ

項目 内容
バケット作成 npx wrangler r2 bucket create <name>
バインディング wrangler.jsoncr2_buckets に追記
アップロード bucket.put(key, stream, { httpMetadata })
一覧取得 bucket.list() → POJOに変換して返す
ファイル取得 専用の +server.ts でWorker経由で返す
削除 bucket.delete(key)
← トップページに戻る