Michiaki Mizoguchi Michiaki Mizoguchi

React Router v7でCloudflare R2にファイルアップロード

2025-02-15

ふと思い立って趣味開発がしたくなりできるだけ安くでできないかを模索したところ、Cloudflare の Workers, R2, D1 を使えば Free Plan で RDB を使いつつそれなりのことが出来そうな雰囲気を感じた。

まずは R2 にファイルをアップロードを試すため、以下を実現するサンプルを実装した。

  1. フォームからファイル送信する
  2. ファイルを受け取って R2 に保存する
  3. フォームからではなく Fetchers を使ってファイルを送信する

Cloudflare R2 とは?

Cloudflare R2 は、AWS S3 のようなオブジェクトストレージサービス。

プロジェクトのセットアップ

テンプレートを利用して React Router プロジェクトを作成する。

pnpm dlx create-react-router@latest --template remix-run/react-router-templates/cloudflare

GitHub のリポジトリと連携する場合は wrangler.tomlname をリポジトリ名と一致するよう修正する(ように警告が出る)。

R2 の Bucket を作成

以下のコマンドで R2 のバケットを作成する。

pnpm exec wrangler r2 bucket create <bucket-name>

次に、バケットへのアクセス設定を wrangler.toml に記述する。

# wrangler.toml

[[r2_buckets]]
binding = "BUCKET" # context.cloudflare.env に bind される
bucket_name = "<bucket-name>"

# ついでにログを有効にする
[observability.logs]
enabled = true

フォームからのファイルアップロード

import { parseFormData, type FileUpload } from "@mjackson/form-data-parser";
import type { ActionFunctionArgs } from "react-router";

type AppEnv = {
  BUCKET: R2Bucket; // wrangler.toml の binding に設定した名前になる
};

export async function action({ request, context }: ActionFunctionArgs) {
  const env = context.cloudflare.env as AppEnv;
  const uploadHandler = async (fileUpload: FileUpload) => {
    if (fileUpload.fieldName === "file") {
      const { name } = fileUpload;
      await env.BUCKET.put(name, await fileUpload.arrayBuffer());
    }
  };

  await parseFormData(request, uploadHandler);
  return Response.json({ message: "File uploaded" });
}

export default function Form() {
  return (
    <div className="w-full p-4">
      <div className="md:w-1/2 mx-auto">
        <form method="post" encType="multipart/form-data">
          <label
            htmlFor="file-input"
            className="block mb-2 text-sm font-medium"
          >
            Upload a file
          </label>
          <input
            className="block w-full p-2 text-sm border border-gray-300 rounded-lg cursor-pointer focus:outline-none"
            id="file-input"
            type="file"
            name="file"
          />
          <div className="h-4" />
          <button className="p-2 text-sm border border-gray-300 rounded-lg focus:ring-4 focus:ring-blue-300">
            Upload
          </button>
        </form>
      </div>
    </div>
  );
}

Fetch API を使ったファイルアップロード

import { parseFormData, type FileUpload } from "@mjackson/form-data-parser";
import { useFetcher, type ActionFunctionArgs } from "react-router";

type AppEnv = {
  BUCKET: R2Bucket;
};

export async function action({ request, context }: ActionFunctionArgs) {
  const env = context.cloudflare.env as AppEnv;
  const uploadHandler = async (fileUpload: FileUpload) => {
    if (fileUpload.fieldName === "file") {
      const { name } = fileUpload;
      await env.BUCKET.put(name, await fileUpload.arrayBuffer());
    }
  };

  await parseFormData(request, uploadHandler);
  return Response.json({ message: "File uploaded" });
}

export default function Home() {
  const fetcher = useFetcher();
  const upload = async () => {
    const formData = new FormData();
    formData.append(
      "file",
      new Blob(["Hello, world!"], { type: "text/plain" }),
      "hello.txt",
    );

    await fetcher.submit(formData, {
      encType: "multipart/form-data",
      method: "post",
    });
  };

  return (
    <div className="w-full p-4">
      <div className="md:w-1/2 mx-auto">
        <button
          className="p-2 text-sm border border-gray-300 rounded-lg focus:ring-4 focus:ring-blue-300"
          onClick={() => upload()}
        >
          サンプルファイルアップロードボタン
        </button>
      </div>
    </div>
  );
}

まとめ

Cloudflare の workers, R2 を初めて触ったが、かなりスムーズに実装できた。

R2 の実装がこうなら D1 もこんな感じか? という予想がつくので趣味開発環境としてかなり期待できそう。

参考