🔧

Cloudflare Workers のためのフルスタックツールキット Superflare を試してみた

2023/04/02に公開
3

Superflare は Cloudflare Workers 用のフルスタックツールキットです。D1 Database 向けの ORM や R2 Storage 向けのユーティリティなどの機能を提供しています。

https://superflare.dev/

Superflare 自体はフレームワークを謳っておりません。実際に、Superflare は RemixNext.jsNuxt.js などのフレームワークと組み合わせることで効果を発揮します。

Getting Started

Cloudflare Workers アカウントの作成

Cloudflare Workers を動かすためには(ローカル環境も含めて)Cloudflare Workers のアカウントを作成する必要があります。アカウントを作成するには下記サイトから「Sign up」をクリックします。

https://workers.cloudflare.com/

プランの選択は無料プランである「Free」プランで問題ありません。

プロジェクトの作成

Cloudflare Workers のプロジェクトを作成するためにはコマンドラインツールである wrangler をインストールします。

npm install -g wrangler

インストールが完了したことを確認しましょう。

wrangler --version
 ⛅️ wrangler 2.13.0 
--------------------

アカウントの作成が完了したら、以下のコマンドを実行して Superflare のプロジェクトを作成します。

npx superflare@latest new

Cloudflare のリソースにアクセスするためには認証が必要です。ログインしていない状態で上記のコマンドを実行すると、以下のようにログインを促されます。Yes を選択してログインします。

◇  Hmm. Looks like you're not logged in yet.
│
◆  You need to be logged into Wrangler to create a Superflare app. Log in now?
│  ● Yes / ○ No
└

コマンドを実行するとブラウザのタブが開きアクセスを許可するか聞かれますので「Allow」を選択します。認証に成功していると、ターミナルに以下のように表示されます。

◇  Everything looks good!
│
◇  👋 Heads-up: ───────────────────────────────────────────────╮
│                                                              │
│  Before using R2, Queues, and Durable objects,               │
│  make sure you've enabled them in the Cloudflare Dashboard.  │
│  https://dash.cloudflare.com/                                │
│  Otherwise, the following commands might fail! 😬            │
│                                                              │
├──────────────────────────────────────────────────────────────╯
│
◆  Where would you like to create your app?
│  ./my-superflare-app
└

Hands-up にあるように、R2、Queues、Durable objects を使用する前に Cloudflare のダッシュボードで有効化する必要があります。今回のチュートリアルでは R2 を使用するため、ダッシュボードで R2 を有効化しておきます。https://dash.cloudflare.com/ にアクセスして、左のメニューから「R2」を選択し、「Purchase R2 Plan」をクリックします。

有効化が完了したら、ターミナルに戻りプロジェクトのディレクトリ名(ここでは ./my-superflare-app)を入力して Enter キーを押します。

有効化する機能を選択する画面が表示されます。Database ModelsStorage のみを選択します。

◆  What features of Superflare do you plan to use? We'll create the resources
for you.
│  ◼ Database Models (We'll create D1 a database for you)
│  ◼ Storage
│  ◻ Queues
│  ◻ Broadcasting
└

選択したリソースを作成するかどうか聞かれます。Yes を選択しましょう。

◆  We'll create the following resources for you:

- D1 Database: my-superflare-app-db (bound as DB)
- R2 Bucket: my-superflare-app-bucket (bound as BUCKET)

Do you want to continue?
│  ● Yes / ○ No
└

D1 の作成でエラーが発生した場合

私が実行した際には、以下のようにエラーが発生して D1 Database の作成に失敗しました。

│  ❌ D1 Database:                                                                                                      │
│                                                                                                                      │
│  ✘ [ERROR] A request to the Cloudflare API (/accounts/xxxxxx/d1/database) failed.          │
│                                                                                                                      │
│    Authentication error [code: 10000] 

このエラーが表示された場合、一度ログアウトして再度ログインしてみてください。

wrangler logout
wrangler login

ログインし直した後、以下のコマンドで D1 Database の作成を再試行します。

wrangler d1 create my-superflare-app-db

その後、wrangler.json データベースの設定を手動で追記します。database_idwrangler d1 create コマンドの実行時に表示されるほか、wrangler d1 list コマンドで確認できます。

wrangler.json
{
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "my-superflare-app-db",
      "database_id": "xxxxxx"
    }
  ],
}

superflare.config.ts にも追加が必要です。

superflare.config.ts
  import { defineConfig } from "superflare";

  export default defineConfig<Env>((ctx) => {
    return {
      appKey: ctx.env.APP_KEY,
+     database: {
+       default: ctx.env.DB,
+     },
      storage: {
        default: {
          binding: ctx.env.BUCKET,
        },
      },
    };
  });

リソースの作成が完了したら、以下のコマンドでプロジェクトの作成を完了します。

cd ./my-superflare-app
npm install --legacy-peer-deps
npx superflare migrate

npx superflare migrate は D1 Database のマイグレーションを行うコマンドです。デフォルトの状態では users テーブルが作成されます。

プロジェクトの作成が完了したら、以下のコマンドでローカルサーバーを起動します。

npm run dev

http://127.0.0.1:8788 にアクセスすると、以下のように表示されます。

ブラウザで表示される画面。Hello, Superflare と表示されている

npx superflare@latest new で作成したプロジェクトは Remix の上に構築されています。ですので、基本的な機能やディレクトリ構造などは Remix のドキュメントを参考にするとよいでしょう。

認証機能

新しく Superflare のプロジェクトを作成したときのコードを見てみましょう。Superflare ではデフォルトで認証機能が備わっています。

/auth/regiser/auth/login は Superflare により提供された基本的な認証画面です。/auth/register にアクセスすると素朴なユーザー登録画面が表示されます。適当なメールアドレスとパスワードを入力して登録してみましょう。

ユーザー登録画面。Register という見出しが表示され、その下に Email と Password を入力するフォームが表示されている。

ログインに成功すると、/dashboard にリダイレクトされ。ログイン状態であることが確認できます。

ログイン後の画面。Dashboard You're logged in as test@example.com と表示されている

「Log out」ボタンをクリックするとログアウトできます。ログアウト後再度ダッシュボードに訪れると、ログイン画面にリダイレクトされます。ログイン画面では登録画面で入力したメールアドレスとパスワードを入力することでログインできます。

登録画面

それでは、Superflare ではどのように認証機能が実装されているのかを見ていきましょう。まずは、ユーザー登録画面です。Remix はファイルベースのルーティングを提供しています。/auth/register に該当するファイルは app/routes/auth/register.tsx です。

ここでは action 関数によってフォームから送信されたデータを受け取り処理が行われています。

app/routes/auth/register.tsx
import { json, redirect, type ActionArgs } from "@remix-run/cloudflare";
import { User } from "~/models/User";
import { hash } from "superflare";

export async function action({ request, context: { auth } }: ActionArgs) {
  //  既にログインしている場合はダッシュボードにリダイレクト
  if (await auth.check(User)) {
    return redirect("/dashboard");
  }

  // フォームから送信されたデータを受け取る
  const formData = new URLSearchParams(await request.text());
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  // メールアドレスが既に登録されているかどうかをチェック
  if (await User.where("email", email).count()) {
    return json({ error: "Email already exists" }, { status: 400 });
  }

  // ユーザーを作成
  const user = await User.create({
    email,
    password: await hash().make(password),
  });

  // ログイン状態にする
  auth.login(user);

  return redirect("/dashboard");
}

この action 関数は Remix により提供されている機能です。action 関数はサーバーのみで実行される関数であり、主にデータの更新などの処理を行います。サーバー側で実行される関数には同じく loader 関数があります。loaderaction 関数の違いは処理が実行される順番です。GET 以外のリクエストが送信された場合は、action 関数が先に実行され、その後 loader 関数が実行されます。

https://remix.run/docs/en/main/route/action

action 関数の引数では requestcontext が渡されています。requestFetch API の Request オブジェクト であり、フォームから送信したデータを取得できます。

context に含まれる auth は Superflare により提供されている Superflare Auth です。API は Laravel の Auth Facade とよく似ています。

auth.check() はログイン状態を確認する関数です。User モデルを引数に受け取り、ログインしている場合は true を返します。

app/routes/auth/register.tsx
import { User } from "~/models/User";

if (await auth.check(User)) {
  return redirect("/dashboard");
}

User モデル

この User モデルは認証機能を利用する場合必須です。モデルとは D1 データベースのテーブルに対応するアプリケーション層の抽象化されたレイヤーです。Superflare ではモデルを作成することで、データベースのリレーションを定義したり、データの操作が簡単に行えます。

Userusers テーブルに対応するモデルです。プロジェクトを作成したときにあらかじめ UserModel に対応する users テーブルのマイグレーションが作成されているはずです。

users テーブルは idemailpassword カラムを持っています。

db/migrations/0000_create_users_table.ts
import { Schema } from "superflare";

export default function () {
  return Schema.create("users", (table) => {
    table.increments("id");
    table.string("email").unique();
    table.string("password");
    table.timestamps();
  });
}

モデルは TypeScript の Class として定義されています。モデルのクラス名はテーブル名の単数形として対応しており、Model クラスを継承しています。モデルクラスを作成した後 Model.register を呼び出すことでモデルを登録し、モデルを利用できるようになります。

User モデルのクラスは app/models/User.ts にあります。

app/models/User.ts
import { Model } from "superflare";

export class User extends Model {
  toJSON(): Omit<UserRow, "password"> {
    const { password, ...rest } = super.toJSON();
    return rest;
  }
}

Model.register(User);

export interface User extends UserRow {}

UserRow interface は users テーブルのレコードを表しています。この型定義は superflare.env.d.ts ファイルに配置されています。これは npx superflare migrate を実行したときに自動的に生成されます。

superflare.env.d.ts
// This file is automatically generated by Superflare. Do not edit directly.

interface UserRow {
  id: number;
  email: string;
  password: string;
  createdAt: string;
  updatedAt: string;
}

toJSON メソッドは User モデルのインスタンスがどのように JSON にシリアライズされるかを定義しています。デフォルトではリレーションを含む全てのカラムがプロパティとして返されます。ここでは password カラムを除外してシリアライズされるようにしています。

モデルのシリアライズは Remix ではサーバー側の loader 関数からクライアントに渡されるときに行われます。

ユーザーの作成

User モデルの機能を一通り説明したので、action 関数の実装に戻りましょう。request オブジェクトよりフォームから送信されたデータを取得してします。前述の通り、request オブジェクトは Fetch API の Request オブジェクトですから、Web API の標準的な方法でフォームデータを取得できます。

app/routes/auth/register.tsx
const formData = new URLSearchParams(await request.text());
const email = formData.get("email") as string;
const password = formData.get("password") as string;

続いて既に登録されているメールアドレスかどうかを確認します。モデルの where() メソッドを使うことで、テーブルのレコードを検索できます。count() メソッドは取得したレコードの数を返します。レコードの数が 1 以上であれば 400 エラーを返します。Remix では json 関数を使うことで簡単に application/json レスポンスを返すことができます。

app/routes/auth/register.tsx
if (await User.where("email", email).count()) {
  return json({ error: "Email already exists" }, { status: 400 });
}

登録されていないメールアドレスであればユーザーを作成します。create() メソッドを使うことで、モデルのインスタンスを作成し、データベースに保存されます。パスワードは Superflare が提供する hash() 関数を使ってハッシュ化します。

app/routes/auth/register.tsx
const user = await User.create({
  email,
  password: await hash().make(password),
});

create() メソッドは作成したモデルのインスタンスを返します。このインスタンスを auth.login() 関数に渡すことで、ログイン状態にします。ログイン状態とした後 redirect() 関数を使って /dashboard にリダイレクトします。

app/routes/auth/register.tsx
auth.login(user);

return redirect("/dashboard");

ログイン状態は SuperflareSession クラスによりセッションに保存されています。

ダッシュボード画面

ダッシュボード画面はログイン状態でないと表示できないようになっています。このような典型的な処理はサーバー側で実行される loader 関数において auth.check を呼び出してログイン状態かどうかチェックすることで実現しています。ログイン状態でない場合は redirect() 関数を使って /login にリダイレクトします。

app/routes/dashboard.tsx
import { type LoaderArgs, redirect, json } from "@remix-run/cloudflare";
import { User } from "~/models/User";

export async function loader({ context: { auth } }: LoaderArgs) {
  if (!(await auth.check(User))) {
    return redirect("/auth/login");
  }

  return json({
    user: (await auth.user(User)) as User,
  });
}

loader 関数はログインしているユーザーを user プロパティに含む JSON として返します。Remix において loader 関数が返すデータを取得するには
useLoaderData() 関数を使います。

app/routes/dashboard.tsx
import { useLoaderData } from "@remix-run/react";

export default function Dashboard() {
  const { user } = useLoaderData<typeof loader>();

  return (
    <>
      <h1>Dashboard</h1>
      <p>You're logged in as {user.email}</p>

      <form method="post" action="/auth/logout">
        <button type="submit">Log out</button>
      </form>
    </>
  );
}

ログアウト

ダッシュボード画面でログアウトボタンを押すと、/auth/logout に POST リクエストが送信されます。app/routes/auth/logout.ts では action 関数内で auth.logout() 関数を呼び出してログアウト状態にします。

app/routes/auth/logout.ts
import { type ActionArgs, redirect } from "@remix-run/server-runtime";

export async function action({ context: { auth } }: ActionArgs) {
  auth.logout();

  return redirect("/");
}

ログイン画面

ログイン画面の実装は登録画面と大きく変わりません。ここでは、auth.login 関数の代わりに auth.attempt 関数を使っています。auth.attempt 関数は、ユーザーのクレデンシャル情報(=メールアドレスとパスワード)を引数に取り、ログインに成功した場合は true を返します。ログインに失敗した場合は false を返します。

app/routes/auth/login.tsx
import { Form, Link, useActionData } from "@remix-run/react";
import { json, redirect, type ActionArgs } from "@remix-run/cloudflare";
import { User } from "~/models/User";

export async function action({ request, context: { auth } }: ActionArgs) {
  if (await auth.check(User)) {
    return redirect("/dashboard");
  }

  const formData = new URLSearchParams(await request.text());
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  if (await auth.attempt(User, { email, password })) {
    return redirect("/dashboard");
  }

  return json({ error: "Invalid credentials" }, { status: 400 });
}

アルバムアプリの作成

ここまで Superflare プロジェクトを作成した時点での基本的な実装を確認しました。ここからは実際に簡単なアルバムアプリを作成していきましょう。このアプリケーションでは、ユーザーは自由に画像をアップロードして、アルバムを作成できます。また、アルバムにはタイトルと説明をつけることができます。

完成したコードは以下のレポジトリを参照してください。

https://github.com/azukiazusa1/superflare-album-app

テーブルの設計は以下のように提案していただきました。この設計を元に、モデルを作成していきます。

## ユーザー(users)テーブル

ID (主キー)
ユーザー名
パスワード
メールアドレス

## アルバム(albums)テーブル

ID (主キー)
タイトル
説明
ユーザーID (外部キー)

## 画像(images)テーブル

ID (主キー)
キー (R1 Storage のキー)
アルバムID (外部キー)
ユーザーID (外部キー)
アップロード日時

マイグレーションの作成

アルバムモデルから作成しましょう。まずはモデルに対応するテーブルを作成する必要があります。テーブルを作成するには、マイグレーションファイルを作成して、マイグレーションを実行します。

以下のコマンドでマイグレーションを作成できます。albums は作成するテーブル名です。

npx superflare generate migration albums

コマンドの実行に成功すると db/migrations ディレクトリにマイグレーションファイルが作成されます。

db/migrations/0001_albums.ts
import { Schema } from 'superflare';

export default function () {
  // return ...
}

マイグレーションフィルでは Schema インスタンスを返す関数を default export する必要があります。Schemacreate() メソッドを使ってテーブルを作成します。

db/migrations/0001_albums.ts
import { Schema } from "superflare";

export default function () {
  return Schema.create("albums", (table) => {
    table.increments("id");
    table.string("title");
    table.string("description");
    table.integer("userId");
    table.timestamps();
  });
}

id はテーブルの主キーになります。Superflare ではテーブルの主キーは必ず id という名前であることが求められます。

userIdusers テーブルの id と紐付けるための外部キーです。users テーブルと albums テーブルは、1 対多の関係となります。

timestamps() メソッドを呼び出すと、createdatupdatedAt のカラムが追加されます。これらのカラムは、レコードの作成日時と更新日時を自動で管理するためのものです。

同じように、画像モデルに対応するマイグレーションを作成します。

npx superflare generate migration images
db/migrations/0002_images.ts
import { Schema } from "superflare";

export default function () {
  return Schema.create("images", (table) => {
    table.increments("id");
    table.string("key");
    table.integer("albumId");
    table.integer("userId");
    table.timestamps();
  });
}

マイグレーションファイルを作成できたので、マイグレーションを実行しましょう。npx superflare migrate コマンドを実行すると、マイグレーションが実行されます。--create オプションを渡すと、自動でモデルを作成してくれます。

npx superflare migrate --create

コマンドが成功すると、migrations ディレクトリに実行された SQL が出力されます。

migrations/0001_albums.sql
-- Migration number: 0001 	 2023-04-01T06:45:58.421Z
-- Autogenerated by Superflare. Do not edit this file directly.
CREATE TABLE albums (
  id INTEGER PRIMARY KEY,
  title TEXT NOT NULL,
  description TEXT NOT NULL,
  userId INTEGER NOT NULL,
  createdAt DATETIME NOT NULL,
  updatedAt DATETIME NOT NULL
);
migrations/0002_images.sql
-- Migration number: 0002 	 2023-04-01T06:45:58.421Z
-- Autogenerated by Superflare. Do not edit this file directly.
CREATE TABLE images (
  id INTEGER PRIMARY KEY,
  key TEXT NOT NULL,
  albumId INTEGER NOT NULL,
  userId INTEGER NOT NULL,
  createdAt DATETIME NOT NULL,
  updatedAt DATETIME NOT NULL
);

モデルの作成

--create オプションを渡してマイグレーションを実行した場合、app/models ディレクトリに Album.tsImage.ts が作成されます。モデル同士のリレーションを定義するために、それぞれのファイルを編集する必要があります。

それぞれのテーブルのリレーションを確認しておきましょう。ユーザーは複数のアルバムを所有できます。アルバムは複数の画像を含むことができ、画像は特定のアルバムに所属し、またアップロードしたユーザーにも関連しています。

1 人のユーザーが複数のアルバムを所有できるというリレーションは one-to-many リレーションと呼ばれています。このリレーションを表現するために、ユーザーモデルに hasMany() メソッドを使います。

app/models/User.ts
+ import { Album } from "./Album";
+ import { Image } from "./Image";

  export class User extends Model {
+   albums!: Album[] | Promise<Album[]>;
+   images!: Image[] | Promise<Image[]>;
+
+   $alubms() {
+     return this.hasMany(Album);
+   }
+
+   $images() {
+     return this.hasMany(Image);
+   }

    toJSON(): Omit<UserRow, "password"> {
      const { password, ...rest } = super.toJSON();
      return rest;
    }
  }

one-to-many の many 側のモデルには、belongsTo() メソッドを使いモデルに所属していること表します。

app/models/Image.ts
import { Model } from "superflare";
import { Album } from "./Album";
import { User } from "./User";

export class Image extends Model {
  album!: Album | Promise<Album>;
  user!: User | Promise<User>;

  $album() {
    return this.belongsTo(Album);
  }

  $user() {
    return this.belongsTo(User);
  }

  toJSON(): ImageRow {
    return super.toJSON();
  }
}

Model.register(Image);

export interface Image extends ImageRow {}
app/models/Album.ts
import { Model } from "superflare";
import { Image } from "./Image";
import { User } from "./User";

export class Album extends Model {
  images!: Image[] | Promise<Image[]>;
  user!: User | Promise<User>;

  $images() {
    return this.hasMany(Image);
  }

  $user() {
    return this.belongsTo(User);
  }

  toJSON(): AlbumRow {
    return super.toJSON();
  }
}
Model.register(Album);

export interface Album extends AlbumRow {}

これでモデルの定義が完了しました。

ダッシュボードからアルバムを作成する

ダッシュボードからアルバムを作成するために、アルバムを作成するフォームを作成します。Remix ではプレーンな HTML でフォームを作成します。つまり、React や Vue.js などのライブラリで作成するフォームのように JavaScript を用いて状態管理やフォームのサブミットの制御を行わないということです。

app/routes/dashboard.tsx
  export default function Dashboard() {
    const { user } = useLoaderData<typeof loader>();

    return (
      <>
        <h1>Dashboard</h1>
        <p>You're logged in as {user.email}</p>

+       <form method="post">
+         <fieldset>
+           <legend>Create Album</legend>
+
+           <div>
+             <label htmlFor="title">Title</label>
+             <input name="title" type="text" required />
+           </div>
+
+           <div>
+             <label htmlFor="description">Description</label>
+             <input name="description" type="text" required />
+           </div>
+
+           <button type="submit">Create</button>
+         </fieldset>
+       </form>

        <form method="post" action="/auth/logout" style={{ marginTop: "2rem" }}>
          <button type="submit">Log out</button>
        </form>
      </>
    );
  }

フォームを作成したら、フォームのサブミットを処理するための action 関数を作成します。formaction 属性を指定しない場合、フォームは同じページに POST リクエストとして送信されるので、app/routes/dashboard.tsx ファイルに action 関数を定義します。

フォームデータは await request.formData() メソッドで取得できます。取得したフォームフォームデータを引数とし、AlbumModelcreate() メソッドでアルバムを作成します。

現在ログインしているユーザーの ID は auth.id() メソッドで取得できます。

?> ここでは簡単のため、バリデーションやエラー処理を無視しています。

app/routes/dashboard.tsx
export async function action({ request, context: { auth, session } }: LoaderArgs) {
  // ログインしているか確認
  if (!(await auth.check(User))) {
    return redirect("/auth/login");
  }

  // フォームデータを取得
  const body = await request.formData();
  const title = body.get("title") as string;
  const description = body.get("description") as string;

  // ログインしているユーザーのIDを取得
  const userId = await auth.id();

  // アルバムを作成
  await Album.create({
    title,
    description,
    userId,
  });

  // フラッシュメッセージをセット
  session.flash("success", "Album created successfully");

  // ダッシュボードにリダイレクト
  return redirect("/dashboard");
}

アルバムを作成した後は session オブジェクトを使ってフラッシュメッセージをセットしています。

フラッシュメッセージは一度だけ表示されるメッセージです。フラッシュメッセージはセッションに保存されるので、リダイレクトされた先のページで表示されます。

sessionauth オブジェクトと同じく loaderaction 関数の引数から受け取ることで利用できます。

アルバムを作成した後は、ダッシュボードにリダイレクトします。

フラッシュメッセージを表示するためには、session.getFlash() メソッドでセッションから読み取る必要があります。フラッシュメッセージが存在する場合にはその要素が、存在しない場合には undefined が返ります。

フラッシュメッセージが存在すれば、すべてのページに共通で表示したいため app/routes/root.tsx ファイル内の loader 関数内でフラッシュメッセージを読み取ります。コンポーネント側では undefined 以外の場合にのみフラッシュメッセージを表示するように制御します。

app/root.tsx
import { json, LoaderArgs, MetaFunction } from "@remix-run/cloudflare";
import {
  useLoaderData,
} from "@remix-run/react";

export async function loader({ context: { session } }: LoaderArgs) {
  const flash = session.getFlash("success");

  return json({
    flash,
  });
}

export default function App() {
  const { flash } = useLoaderData<typeof loader>();

  return (
    <html lang="en" className="h-full bg-gray-100 dark:bg-black">
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        {flash && <div style={{ color: "green" }}>{flash}</div>}
        <Outlet />
        <ScrollRestoration />
        <Scripts />
        <LiveReload />
      </body>
    </html>
  );
}

試しにアルバムを作成してみましょう。フラッシュメッセージが表示されれば成功です。

アルバム一覧を表示する

ダッシュボード画面で作成したアルバムの一覧を表示するようにします。app/routes/dashboard.tsx ファイルの loader 関数内で Album モデルの where() メソッドを使って、ログインしているユーザーに属するアルバムをすべて取得します。

where の第一引数にはフィールド名、第二引数には条件値を指定します。また、orderBy メソッドを使って、作成日時の降順でソートします。

このようにモデルではメソッドチェーンを使ってクエリを組み立てることができます。

app/routes/dashboard.tsx
+ import { Album } from "~/models/Album";

  export async function loader({ context: { auth } }: LoaderArgs) {
    if (!(await auth.check(User))) {
      return redirect("/auth/login");
    }

+   // ログインしているユーザーのアルバムを取得
+   const albums = await Album.where("userId", await auth.id()).orderBy(
+     "createdAt",
+     "desc"
+   );

    return json({
      user: (await auth.user(User)) as User,
+     albums,
    });
  }

コンポーネント側では useLoaderData 関数から取得した albums プロパティを使ってアルバムの一覧を表示します。

app/routes/dashboard.tsx
  export default function Dashboard() {
-   const { user } = useLoaderData<typeof loader>();
+   const { user, albums } = useLoaderData<typeof loader>();

    return (
      <>
        <h1>Dashboard</h1>
        <p>You're logged in as {user.email}</p>

        <form method="post">
          // ...
        </form>

+       <h2>Albums</h2>
+       {albums.length ? (
+         <ul>
+           {albums.map((album) => (
+             <li key={album.id}>
+               <a href={`/albums/${album.id}`}>{album.title}</a>
+             </li>
+           ))}
+         </ul>
+       ) : (
+         <p>You don't have any albums yet</p>
+       )}

        <form method="post" action="/auth/logout" style={{ marginTop: "2rem" }}>
          <button type="submit">Log out</button>
        </form>
      </>
    );
  }

もう少し一覧から情報を取得できるように、アルバムが持っている写真の数を表示するようにしましょう。アルバムと写真のリレーションは one-to-many です。クエリを発行する際にリレーションを先にロードするため with メソッドを使います。

これは eager load と呼ばれるもので、N + 1 問題を回避するために ORM でよく使われる手法です。

app/routes/dashboard.tsx
  const albums = await Album.where("userId", await auth.id())
    .orderBy("createdAt", "desc")
+   .with("images");

さらに、Album モデルの仮想プロパティとして imageCount を定義します。あるモデルに対してゲッターメソッドとして定義することで、データベースに保存されていないフィールドも追加できます。

仮想プロパティもシリアライズされるように、toJSON() メソッドの返り値のプロパティに追加します。

app/models/Album.ts
export class Album extends Model {
  images!: Image[] | Promise<Image[]>;
  user!: User | Promise<User>;

  $images() {
    return this.hasMany(Image);
  }

  $user() {
    return this.belongsTo(User);
  }

  get imageCount(): number {
    return this.$images.length;
  }

  toJSON(): AlbumRow & { imageCount: number } {
    return {
      ...super.toJSON(),
      imageCount: this.imageCount,
    };
  }
}

アルバムの一覧を表示している箇所に album.imageCount を表示するようにします。

app/routes/dashboard.tsx
  <ul>
    {albums.map((album) => (
      <li key={album.id}>
        <a href={`/albums/${album.id}`}>{album.title}</a>
+       <span> - {album.imageCount} images</span>
      </li>
    ))}
  </ul>

アルバム詳細ページを作成する

アルバムに画像をアップロードできるように、詳細ページを作成します。動的なルーティングを使って、アルバムの ID に応じてアルバムの詳細ページを表示します。Remix では動的なセグメントは $id のように先頭に $ をつけて定義します。

app/routes/albums/$id.tsx という名前でファイルを作成します。

app/routes/albums/[id].tsx
import { type LoaderArgs, redirect, json } from "@remix-run/cloudflare";
import { useCatch, useLoaderData, useParams } from "@remix-run/react";
import { Album } from "~/models/Album";
import { User } from "~/models/User";

export async function loader({ params, context: { auth } }: LoaderArgs) {
  if (!(await auth.check(User))) {
    return redirect("/auth/login");
  }

  // ログインしているユーザーのIDを取得
  const userId = await auth.id();
  // パスパラメーターからアルバムのIDを取得
  const albumId = Number(params.id);

  // アルバムのIDとユーザーのIDをもとにアルバムを取得
  const album = await Album.where("id", albumId)
    .where("userId", userId)
    .first(); // first() は最初の1件のみを取得するメソッド

  // アルバムが存在しない場合、404を返す
  if (!album) {
    throw new Response("Not found", { status: 404 });
  }

  return json({ album });
}

export default function AlbumPage() {
  const { album } = useLoaderData<typeof loader>();

  return (
    <>
      <h1>{album.title}</h1>
      <p>{album.description}</p>
    </>
  );
}

ダッシュボード画面と同様に、ログイン状態をチェックしてログインしていない場合はログインページにリダイレクトします。

動的なパラメーターは loader 関数の引数 params から受け取ります。ルーティングから受け取った albumId とログインしているユーザーの userId をもとにアルバムを取得します。もしアルバムが存在しない場合は 404 として Response オブジェクトを throw します。

コンポーネントでは useLoaderData フックを使って loader 関数から返されたアルバムを取得して表示します。

loader 関数内で Response オブジェクトを throw した場合には CatchBoundary でキャッチしてエラー画面を描画します。throw された Response オブジェクトは useCatch フックで取得できます。

さらに、Catchboundary でもキャッチされない想定外の例外は、ErrorBoundary でキャッチされます。

app/routes/albums/[id].tsx
import { useCatch, useParams } from "@remix-run/react";

export function CatchBoundary() {
  const caught = useCatch();
  const params = useParams();
  if (caught.status === 404) {
    return (
      <>
        <h1>404</h1>
        <p>Album {params.id} is not found.</p>
      </>
    );
  }

  throw new Error("Something went wrong");
}

export function ErrorBoundary() {
  return (
    <>
      <h1>Something went wrong</h1>
    </>
  );
}

画像をアップロードする

アルバムの詳細ページに画像をアップロードするフォームを追加して、アルバムに画像を追加できるようにしましょう。ファイルをアップロードするには multipart/form-data でフォームを送信する必要があります。

app/routes/albums/[id].tsx
  export default function AlbumPage() {
    const { album } = useLoaderData<typeof loader>();

    return (
      <>
        <h1>{album.title}</h1>
        <p>{album.description}</p>

+       <form method="post" encType="multipart/form-data">
+         <fieldset>
+           <legend>Upload Photo</legend>
+           <div>
+             <label htmlFor="file">Photo</label>
+             <input name="file" type="file" required />
+           </div>
+         </fieldset>
+       </form>
      </>
    );
  }

同ファイルの action 関数内でファイルアップロードを処理します。Superflare より提供されている parseMultipartFormData 関数を使うことで、ファイルをメモリに展開せず、R2 にファイルを直接ストリーミングできます。

R2 ストレージの操作は storage オブジェクトを使用します。storage().putRandom() メソッドはランダムな名前を生成してファイルをアップロードします。extension オプションを指定することで、ファイルの拡張子を指定できます。

app/routes/albums/[id].tsx
import { storage, parseMultipartFormData } from "superflare";

export async function action({ request }: LoaderArgs) {
  if (!(await auth.check(User))) {
    return redirect("/auth/login");
  }

  const formData = await parseMultipartFormData(
    request,
    async ({ name, filename, stream }) => {
      // ファイル以外のフィールドは無視
      if (name !== "file") {
        return undefined;
      }
      const r2Object = await storage().putRandom(stream, {
        extension: filename?.split(".").pop(),
      });
      return r2Object.key;
    }
  );
}

ファイルのアップロードに成功したら、ストレージのキーを取得して Image モデルに保存します。

app/routes/albums/[id].tsx
import { storage, parseMultipartFormData } from "superflare";

export async function action({
  request,
  params,
  context: { auth, session },
}: LoaderArgs) {
  const formData = // ...

  const albumId = Number(params.id);
  const userId = await auth.id();

  // データベースに画像のパスを保存
  await Image.create({
    key: formData.get("file") as string,
    albumId,
    userId,
  });

  session.flash("success", "Photo uploaded successfully");

  return redirect(`/albums/${albumId}`);
}

画像一覧を表示する

アルバムに紐づく画像を表示できるようにしましょう。はじめに、ストレージのキーを受け取り画像データを返すエンドポイントを作成します。

app/routes/images/[key].ts を作成します。

app/routes/images/[key].ts
import { type LoaderArgs, redirect, json } from "@remix-run/cloudflare";
import { User } from "~/models/User";
import { storage } from "superflare";

const getContentTypes = (extension: string) => {
  switch (extension) {
    case "png":
      return "image/png";
    case "jpg":
    case "jpeg":
      return "image/jpeg";
    case "gif":
      return "image/gif";
    case "svg":
      return "image/svg+xml";
    default:
      return "application/octet-stream";
  }
};

export async function loader({ context: { auth }, params }: LoaderArgs) {
  if (!(await auth.check(User))) {
   throw new Response("Unauthorized", { status: 401 });
  }

  // パスパラメーターからストレージのキーを取得
  const storageKey = params.key as string;
  // ストレージから画像を取得
  const obj = await storage().get(storageKey);

  // 存在しないキーの場合は 404 を返す
  if (!obj) {
    return new Response("Not found", { status: 404 });
  }

  const extension = obj.key.split(".").pop() || "";

  return new Response(obj.body, {
    headers: {
      "Content-Type": getContentTypes(extension),
    },
  });
}

パスパラメーターからストレージのキーを取得して、storage().get() メソッドでキーを指定して画像を取得します。obj.body にはストリームが入っているので、Response に渡すことで画像を返すことができます。

アルバム詳細画面に戻りましょう。await album.images でアルバムに紐づくすべての画像を取得します。

app/routes/album/$id.tsx
  export async function loader({ params, context: { auth } }: LoaderArgs) {
    // ...

    const album = await Album.where("id", albumId)
      .where("userId", userId)
      .first();

    // アルバムが存在しない場合、404を返す
    if (!album) {
      throw new Response("Not found", { status: 404 });
    }

    return json({
      album,
+     images: await album.images,
    });
  }

コンポーネントで useLoaderData から images を受け取り画像を表示します。src のパスにはさきほど作成したストレージのキーを受け取り画像データを返すエンドポイントを指定します。

app/routes/album/$id.tsx
  export default function AlbumPage() {
    const { album, images } = useLoaderData<typeof loader>();

    return (
      <>
        <h1>{album.title}</h1>
        <p>{album.description}</p>

        { // ...}

+       <h2>Photos</h2>
+       <ul
+          style={{
+            display: "grid",
+            gridTemplateColumns: "repeat(auto-fill, minmax(256px, 1fr))",
+            gap: "1rem",
+          }}
+        >
+         {images.map((image) => (
+           <li key={image.id}>
+             <img src={`/images/${image.key}`} alt="" width="256" height="144" />
+           </li>
+         ))}
+       </ul>
      </>
    );
  }

画像を削除する

最後に、画像を削除する機能を実装します。画像を削除するエンドポイントは app/routes/images/[id].ts に作成します。

HTML のフォームでは DELETE メソッドを送信できないので、POST メソッドで送信してサブミットボタンの value で削除リクエストかどうかを判定します。

app/routes/images/[id].ts
import { type LoaderArgs, redirect } from "@remix-run/cloudflare";
import { Image } from "~/models/Image";
import { storage } from "superflare";

export async function action({
  request,
  params,
  context: { auth },
}: LoaderArgs) {
  if (!(await auth.check(User))) {
    throw new Response("Unauthorized", { status: 401 });
  }

  const formData = await request.formData();
  const intent = formData.get("intent") as string;

  // 削除リクエストでない場合はエラーを返す
  if (intent !== "delete") {
    throw new Response(`The intent ${intent} is not supported`, {
      status: 400,
    });
  }

  // パスパラメーターからストレージのキーを取得
  const storageKey = params.key as string;
  // データベースから画像データを取得
  const image = await Image.where("key", storageKey).first();

  if (!image) {
    throw new Response("Not found", { status: 404 });
  }

  // リダイレクト先のアルバムID
  const albumId = image.albumId;

  // 画像を作成したユーザー以外は削除できない
  if (image.userId !== (await auth.id())) {
    throw new Response("Forbidden", { status: 403 });
  }

  // データベースとストレージから画像を削除
  await Promise.all([image.delete(), storage().delete(storageKey)]);

  return redirect(`/albums/${albumId}`);
}

デプロイ

Cloudflare Workers にデプロイするには npx wrangler コマンドを使用します。npx superflare@latest new コマンドでプロジェクトを作成していると wrangler.json が設定ファイルになります。設定ファイルに wrangler.json を使用している場合には -j オプションを指定する必要があります。

デプロイを実行する前に、以下を実行する必要があります。

  • 本番 DB のマイグレーション:npx wrangler d1 migrations apply <DB_NAME>
  • シークレットに APP_KEY を設定(dev.vars の値):npx wrangler secret put APP_KEY
npx wrangler d1 migrations apply my-superflare-app-db -j
npx wrangler secret put APP_KEY -j

また、wrangler.json に以下の設定を追加します。

wrangler.json
{
  "compatibility_date": "2023-04-02",
  "site": {
    "bucket": "./public"
  }
}

compatibility_data は Cloudflare Workers における API の互換性を制御するための設定です。このフィールドには、特定の日付を指定できます。Cloudflare Workers は日々ランタイムの更新が行われますが、その中には互換性のない変更も含まれています。

compatibility_date に設定した日付より後にリリースされた API は適用されなりので、互換性を壊す変更を避けることができます通常、プロジェクトを作成した日付を指定することになります。

https://developers.cloudflare.com/workers/platform/compatibility-dates/

準備ができたら、以下のコマンドを実行してデプロイします。

npm run build && npx wrangler publish -j

デプロイに成功すると、URL が表示されるのでブラウザでアクセスしてみましょう。

感想

Superflare の作者は長い間 Laravel で開発しており、また Laravel からも影響を受けていると述べられているだけあって、設計はだいぶ Laravel に似ている気がします。with メソッドを使った eager loading や、Auth などの機能も Laravel を触ったことがある人ならすぐに馴染めるかと思います。

フレームワークとして必要な機能が一通り揃っていますので、手軽にフルスタックなアプリケーションを作成したい場合には Superflare を使うのも良いかもしれません。

設計思想の中で、Verndor Lock-in as a Feature と述べられているのも興味深いです。Superflare は Cloudflare の上で構築されているのでベンダーロックインを回避するすべはありません。しかし、ベンダーロックインを受け入れることにより、物事をシンプルに解決できるメリットがあると主張されています。

https://superflare.dev/design

GitHubで編集を提案

Discussion

Tanner / テナーTanner / テナー

こんにちは!
もし可能であれば、タグの #clodflareを #cloudflareに修正いただけると助かります!