Remix on Cloudflare WorkersからCloudflare R2を使う

Cloudflare WorkersにデプロイしたRemixアプリケーションからCloudflare R2を扱う方法についてご紹介します。
2022.05.15

はじめに

こんにちは、CX事業本部MAD事業部の森茂です。
RemixをCloudflare Workersで動かす最初の一歩をブログ記事で紹介させていただきましたが、今回は引き続きCloudflare WorkersにデプロイしたRemixアプリケーションから先日オープンベータとしてサービスが開始されたCloudflare R2を扱う方法について紹介させていただきます。

Cloudflare R2について

Cloudflare R2はAWS S3対抗となるCloudflare社のサービスのひとつでS3互換のAPIを備えたサービスです。まだベータ版という位置づけながら利便性はもちろんコストに対しての優位性も感じられます。

項目 無料枠 金額
外部への転送量 --- 無料
ストレージ 10GB/月 $0.015/GB(月額)
Class A Operation(主に書き込みなど) 1,000,000リクエスト/月 $4.50/1000,000リクエスト
Class B Operation(主に読み込みなど) 10,000,000リクエスト/月 $0.36/1000,000リクエスト

*2022年5月15日現在

R2 サービスの登録

R2は従量課金制となっているため事前に支払い情報の登録が必要です。とはいえ無料枠がかなり大きいので検証範囲の範囲では無料枠を超えることは少なそうです。

R2はダッシュボードからR2有料プランを購入することですぐに利用できるようになります。(購入といってもこの時点で料金はかかりません)

R2バケットの作成

ダッシュボードから作成することもできますが、今回はCLIから作成します。
Wranglerがインストールされていない場合はインストールとアカウントの紐付けをあらかじめ行っておいてください。

Wrangler v2のインストールとアカウントの紐付け

$ npm install -g wrangler
$ wrangler login

ログイン状況の確認

$ wrangler whoami

バケットの作成

今回はremix-r2-exampleという名前でバケットを作成します。

$ wrangler r2 bucket create remix-r2-example
⛅️ wrangler 2.0.5
-------------------
Creating bucket remix-r2-example.
Created bucket remix-r2-example.

CLIでバケットが作成できているか確認しておきます。

$ wrangler r2 bucket list                   
[
  {
    "name": "remix-r2-example",
    "creation_date": "2022-05-14T10:19:15.075Z"
  }
]

念の為ダッシュボードからも確認しておきます。

なお、初期状態ではPrivateモードとなり外部からはアクセスのできないバケットが作成されます。紐付けられたユーザー、バインディングしたWorkersやサービスからのみ接続が可能な状態です。

Remix on Cloudflare WorkersからR2へアクセスする

アプリケーションの用意

下記の記事を参考にベースとなるRemixアプリケーションを用意します。

Remixのテンプレートから一部パッケージを更新したボイラープレートも用意していますのでこちらもご利用ください。

$ npx create-remix@latest --template himorishige/remix-cloudflare-workers-boilerplate

バインディング

また、Workersで動くアプリケーションからR2バケットにアクセスするためにはバインディングという紐付けが必要になります。

バインディングは、WorkerがKVの名前空間、Durable Objects、R2 Bucketなどの外部リソースと相互作用する方法です。バインディングを設定することでユーザーが設定した名前空間で各リソースへの相互アクセスが可能となります。Workersで動作するアプリケーションからはwrangler.tomlファイルを使った指定が可能です。

まず、wrangler.tomlファイルにR2へのバインディングを追記します。なおOAuth認証を行っているCLI環境からのデプロイはaccount_idの記載は不要です。(CI/CD環境での利用時にはaccount_idの他にトークンの用意も必要となります)

wrangler.toml

name = "remix-cloudflare-workers"
main = "./build/index.js"
compatibility_date = "2022-04-05"

account_id = ""
workers_dev = true

[site]
bucket = "./public"

[build]
command = "npm run build"

[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "remix-r2-example"

wrangler.tomlファイルの反映のためCloudflare Workersへデプロイしておきます。

$ npm run deploy

RemixからR2へのアクセス

Workers上ではバインディングしたサービスに対してグローバルにアクセスできます。そのためTypeScript環境で構築する場合は型定義ファイルを用意しておく必要があります。なおR2BucketはRemixインストール時にCloudflare Workersテンプレートを選択していれば最初から用意してくれている型情報です。(@cloudflare/workers-types

types/bindings.d.ts

export {};

// cloudflare/workers-types
// https://github.com/cloudflare/workers-types#using-bindings
declare global {
  const MY_BUCKET: R2Bucket;
}

Remixのトップページからバケットの中身を一覧で取得するように書き換えていきます。

app/routes/index.tsx

import { json } from '@remix-run/cloudflare';
import type { LoaderFunction } from '@remix-run/cloudflare';
import { useLoaderData } from '@remix-run/react';

export const loader: LoaderFunction = async () => {
  // MY_BUCKETというグローバルの値にアクセスする
  const list = await MY_BUCKET.list();

  if (!list) return null;

  return json(list);
};

export default function Index() {
  const data = useLoaderData();

  return (
    <div>
      <h1>Welcome to Remix</h1>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
}

まだアプリケーションからファイルをアップロードできないので、いったんダッシュボードからR2バケットへいくつか画像ファイルをアップロードしておき、RemixをCloudflare Workersへデプロイしてみましょう。

$ npm run deploy

デプロイ成功時に表示されたURLへアクセスしてみると、バケットの中身が一覧表示されます。

なお、RemixではMiniflareというライブラリを使い開発サーバー起動時にはローカルにCloudflare Workersの環境をシミュレートしてくれます。しかしR2はまだ対応していないためローカルでシミュレートすることができません。少々手間ではありますがR2を使ったアプリケーションを構築する際はまだ都度デプロイして検証する必要がありそうです。

R2バケットへファイルをアップロードする

必要なライブラリのインストール

Remixで画像のアップロードなどmutltipart/form-dataを取り扱う場合はマルチパートをパースしてくれるunstable_parseMultipartFormDataが便利なのですが、Node.js環境に依存するため今回は利用できません。

Remix 1.5.1より`unstable_parsemultipartformdata`がBufferではなくUnit8Arrayを扱うように変更されNode.js依存がなくなりました。そのため`js-cfw-formdata-polyfill`を利用しない方法も取ることができるようになりました。

Cloudflare Workersのはまりどころとして、ServiceWorker環境で動作していること、つまりESMであり、Node.jsではないことがあげられるでしょう。Cloudflare Workersは、AWS LambdaのようにESMを実行するサーバーレス環境ではありますが、Node.jsの代わりにV8を使用しています。そのためNode.js依存のライブラリはそのまま利用できないので開発時には注意が必要です。

たとえば今回のようなアップロード機能に関わる部分としては、Cloudflare WorkersではFormDataによるファイルのパースができません。(Bufferがいないなど)

しかしながら、Node.jsでよく利用されている機能をCloudflare Workers環境で動作させるユーティリティーやライブラリ、ポリフィルが多数OSSとして公開されています。今回もFormDataをパースするポリフィルjs-cfw-formdata-polyfillが公開されているのでそちらを利用させていただきましょう。

またあわせてuuidjwtなどNode.js依存のライブラリをWorkers環境で利用できるユーティリティーライブラリからuuidを生成する@cfworker/uuidもインストールしておきます。

$ npm install @ssttevee/cfw-formdata-polyfill @cfworker/uuid

アップロードコンポーネントの実装

ファイルをアップロードするためのFormコンポーネントとAction Functionを用意します。

app/routes/index.tsx

import { json } from '@remix-run/cloudflare';
import type { LoaderFunction, ActionFunction } from '@remix-run/cloudflare';
import {
  Form,
  useActionData,
  useLoaderData,
  useTransition,
} from '@remix-run/react';
// import parseFormData from '@ssttevee/cfw-formdata-polyfill/ponyfill';
import { useEffect, useRef } from 'react';
import invariant from 'tiny-invariant';
import { uuid } from '@cfworker/uuid';

export const action: ActionFunction = async ({ request }) => {
  // polyfillを使ってFormDataをパースする(Remix v1.5.1より不要)
  // const form = await parseFormData.call(request);

  // Remix v1.5.1からunstable_parseMultipartFormDataがWorkersでも利用できるようになったため
  const uploadHandler = unstable_createMemoryUploadHandler({
    maxPartSize: 1024 * 1024 * 10,
  }); // default 3000000B(3MB)

  const form = await unstable_parseMultipartFormData(request, uploadHandler);
  // ここまで

  const file = form.get('file') as Blob;
  invariant(file, 'File is required');

  // ファイル名をuuidに変換、拡張子はいったんmime-typeから流用
  const fileName = `${uuid()}.${file.type.split('/')[1]}`;

  // R2バケットへファイルをアップロード
  const response = await MY_BUCKET.put(fileName, await file.arrayBuffer(), {
    httpMetadata: {
      contentType: file.type,
    },
  });

  return json(
    { message: `Put ${fileName} successfully!`, object: response },
    { status: 200 },
  );
};

export const loader: LoaderFunction = async () => {
  // バケットの一覧を取得
  const list = await MY_BUCKET.list();

  if (!list) return null;

  return json(list);
};

export default function Index() {
  const data = useLoaderData();
  const actionData = useActionData<{ message: string; object: R2Object }>();

  // useTransitionを使いアップロードの状態を取得
  const transition = useTransition();
  const isUploading = !!transition.submission;
  const formRef = useRef<HTMLFormElement>(null);

  // アップロード後にファイル選択をクリアする
  useEffect(() => {
    if (!isUploading) {
      formRef.current?.reset();
    }
  }, [isUploading]);

  return (
    <div>
      <h1>Welcome to Remix</h1>
      <div>
        <Form replace method="post" encType="multipart/form-data" ref={formRef}>
          <input type="file" name="file" accept="image/*" />
          <button type="submit" disabled={isUploading}>
            Upload
          </button>
        </Form>
      </div>
      {actionData && (
        <>
          <p>{actionData.message}</p>
          <p>{actionData && JSON.stringify(actionData.object, null, 2)}</p>
        </>
      )}
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
}

FormコンポーネントやAction Functionの使い方については下記ブログ記事でも紹介していますのでよろしければこちらも参照ください。

動作の確認

ここまで実装したところで、Cloudflare Workersにデプロイして確認してみましょう。

$ npm run deploy

バケットが空の状態から、適当な画像ファイルなどをフォームからアップロードしてみましょう。

ファイルのアップロードが行われ、完了後にバケットの一覧にアップロードされたファイルが表示されるのが確認できました。

R2バケットのファイルを表示する

次はR2バケットにアップロードしたファイルを取得し、表示する部分を実装します。

PrivateモードのではR2バケットのURLを直接開いても中身を閲覧することはできません。(バインディングしたWorkersのアプリケーションを経由した場合は可能)そのため画像(ファイル)を閲覧できるためのページを用意していきます。

Remixでは画像やファイルを処理しそのまま出力するための機能としてResource Routesがあります。

OG画像の自動生成にも利用できるため下記の記事でも紹介しています。よろしければこちらも参照ください。

app/routes/images/$key.tsx

import type { LoaderFunction } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import invariant from 'tiny-invariant';

export const loader: LoaderFunction = async ({ params }) => {
  // URLパラメーターを取得
  const key = params.key;
  invariant(key, 'Key is required');

  // URLパラメーターをKeyにR2オブジェクトを取得
  const object = await MY_BUCKET.get(key);

  if (object === null) {
    return json({ message: 'Object not found' }, { status: 404 });
  }

  // R2オブジェクトのメタデータからheaderを生成
  const headers: HeadersInit = new Headers();
  object.writeHttpMetadata(headers);
  headers.set('etag', object.etag);

  // オブジェクトを返す
  return new Response(object.body, { headers });
}

Cloudflare Workersにデプロイして確認してみましょう。

$ npm run deploy

https://WorkersのURL/images/R2オブジェクトのkey名でアクセスするとファイルをブラウザに表示できます。

画像一覧のページの作成

Resource Routesを利用した画像の一覧ページも用意してみます。R2バケットのオブジェクト一覧を取得して、key名をimgタグ、Linkタグへ反映します。

app/routes/list.tsx

import type { LoaderFunction } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import { Link, useLoaderData } from '@remix-run/react';

export const loader: LoaderFunction = async () => {
  // バケットのオブジェクト一覧を取得
  const objects = await MY_BUCKET.list();

  if (!objects) return null;

  return json(objects);
};

export default function () {
  const { objects } = useLoaderData<{ objects: R2Object[] }>();

  return (
    <div>
      <h1>R2 Bucket List</h1>
      <ul>
        {objects.map((object) => (
          <li key={object.key}>
            <p style={{ width: '320px' }}>
              <Link
                to={`/images/${object.key}`}
                target="_blank"
                rel="noreferrer"
              >
                <img
                  src={`/images/${object.key}`}
                  alt=""
                  style={{ width: '100%', height: 'auto' }}
                />
              </Link>
            </p>
          </li>
        ))}
      </ul>
    </div>
  );
}

デプロイして確認してみましょう

$ npm run deploy

ファイルのサイズや数によっては少し表示に時間がかかるものもあるかもしれませんが、R2バケットのオブジェクトが表示されます。そもそも画像を扱うのであればCloudflareにはCloudflare Imagesというサービスがあるのでこのような用途として利用する機会は少ないかもしれませんが。。R2バケットのオブジェクト取得の例として参考にしていただければと思います。

さいごに

いかがでしょうか?今回はCloudflare WorkersにデプロイしたRemixからR2バケットを利用する実装例を紹介させていただきました。AWS S3と同様のAPIとなっているためS3を利用したアプリケーションの移行も比較的容易にできそうです。しかもaws-sdk-jsboto3も利用することができてしまうのも面白いところです。