🎩

Remix JokesをSupabase+Cloudflare Workersで(前編)

2022/02/21に公開

Jokes App

RemixのチュートリアルではJokes Appが紹介されていますが、データベースがPrisma+SQLiteの組み合わせになっています。これをSupabaseに変えて実行環境はCloudflare Workersにしてみます。データベース操作はRESTful APIです。進行はRemix公式の章立てに従い、違いがある部分だけを書きます。Remix, Supabase, Cloudflare Workersはどれも活発に更新されていますのでバージョン違いにご注意ください。
それでは行ってみましょう。
https://remix.run/docs/en/v1/tutorials/jokes

GitHub

ソースを上げました。
https://github.com/smallStall/JokesAppUsingSupabaseAndWorkers

Generating a new Remix project

Remixプロジェクトの作成です。本来はnpx create-remixを実行するのが定石です。
ただ、バージョン違いによって動かなくなったりするので、次のリポジトリをgit cloneすることにします。Remixのバージョンは@1.1.3です。
https://github.com/smallStall/RSCStarter

git clone https://github.com/smallStall/RSCStarter
cd RSCStarter
npm install

npm installしたら、Cloudflare Workersの設定ファイルwrangler.tomlを以下の通り作成します。

wrangler.toml
name = "remix-jokes"
type = "javascript"

zone_id = ""
account_id = ""
route = ""
workers_dev = true

[site]
bucket = "./public"
entry-point = "."

[build]
command = "npm run build:worker"
watch_dir = "build/index.js"

[build.upload]
format="service-worker"

Explore the project structure

ファイルを作成します。

app/root.tsx
export default function App() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <title>Remix: So great, it's funny!</title>
      </head>
      <body>
        Hello world
      </body>
    </html>
  );
}

ターミナルで以下を実行すると、ブラウザのタブが開きます。

npm run dev
#ターミナルをもう1つ開く
npm start

公式では次にLiveReloadタグを付けていますが、live reloadはnpm startでMiniflareのオプションにつけています。RemixではなくMiniflareにやらせようということですね。なので、LiveReloadタグは付けません。
https://miniflare.dev/get-started/cli#reference

Database

Supabaseのプロジェクト作成が済んでいるものとして話を進めます。
SQL Editor→+New queryを選択します。

SQLでJokeテーブルを作成します。

create table public.joke (
  id uuid default uuid_generate_v4(),
  created_at timestamp default now() not null,
  updated_at timestamp default now() not null,
  name varchar(255) not null,
  content varchar(255) not null,
  primary key (id)
);

--TODO後でRLSをオンにする
--alter table public.joke enable row level security;

RUNを押すとjokeテーブルが作成されます。

RLSは今オンにすると説明の都合上ちょっと面倒なので、後でオンにします。

データをSQLで入力します。

insert into joke
  (name, content)
values
  ('Road worker', 'I never wanted to believe that my Dad was stealing from his job as a road worker. But when I got home, all the signs were there.'),
  ('Frisbee', 'I was wondering why the frisbee was getting bigger, then it hit me.'),
  ('Trees', 'Why do trees seem suspicious on sunny days? Dunno, they''re just a bit shady.'),
  ('Skeletons', 'Why don''t skeletons ride roller coasters? They don''t have the stomach for it.'),
  ('Hippos', 'Why don''t you find hippopotamuses hiding in trees? They''re really good at it.'),
  ('Dinner', 'What did one plate say to the other plate? Dinner is on me!'),
  ('Elevator', 'My first time using an elevator was an uplifting experience. The second time let me down.');

実行するとデータが入ります。

jokeテーブルの型を生成します。
https://supabase.com/docs/reference/javascript/generating-types
ターミナルで以下のコードを実行します。

npx openapi-typescript https://SupabaseのURL.supabase.co/rest/v1/?apikey=anonキー --output app/types/tables.ts

SupabaseのURLとanonキーはSettings→APIにて表示されます。

実行するとjokeテーブルの型その他色々が出力されます。

app/types/tables.ts
...
export interface definitions {
  joke: {
    /**
     * Format: uuid
     * @description Note:
     * This is a Primary Key.<pk/>
     * @default extensions.uuid_generate_v4()
     */
    id: string;
    /**
     * Format: timestamp without time zone
     * @default now()
     */
    created_at: string;
    /**
     * Format: timestamp without time zone
     * @default now()
     */
    updated_at: string;
    /** Format: character varying */
    name: string;
    /** Format: character varying */
    content: string;
  };
}

Connect to the database

以下の手順に従ってsupabase-jsとenvをインストールし、.env, .gitignore, bindings.tsを更新します。
https://zenn.dev/smallstall/articles/a2f1c9462a29b6#remixからsupabaseに接続する
app/utils/db.server.tsを作成します。ファイル名に.serverを付けています。このファイルはserver only codeであることをRemixコンパイラに指定しています。
https://remix.run/docs/en/v1/guides/constraints#no-module-side-effects

app/utils/db.server.ts
import { createClient, SupabaseClient } from "@supabase/supabase-js";

export const db: SupabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
  fetch: (...args) => fetch(...args),
})

次に、Supabaseに接続します。

app/routes/jokes.tsx
import type { LinksFunction, LoaderFunction } from "remix";
import { Link, Outlet, useLoaderData } from "remix";
import type { definitions } from "~/types/tables";
import { db } from "~/utils/db.server";
import stylesUrl from "~/styles/jokes.css";

export const links: LinksFunction = () => {
  return [{ rel: "stylesheet", href: stylesUrl }];
};

type LoaderData = {
  jokeListItems: Array<definitions["joke"]>;
};

export const loader: LoaderFunction = async () => {
  
  const { data } = await db.from("joke").select("*");
  if (!data) return {};
  const jokes: LoaderData = { jokeListItems: data };
  return jokes;
};

export default function JokesRoute() {
  const data = useLoaderData<LoaderData>();

  return (
    <div className="jokes-layout">
      <header className="jokes-header">
        <div className="container">
          <h1 className="home-link">
            <Link to="/" title="Remix Jokes" aria-label="Remix Jokes">
              <span className="logo">🤪</span>
              <span className="logo-medium">J🤪KES</span>
            </Link>
          </h1>
        </div>
      </header>
      <main className="jokes-main">
        <div className="container">
          <div className="jokes-list">
            <Link to=".">Get a random joke</Link>
            <p>Here are a few more jokes to check out:</p>
            <ul>
              {data.jokeListItems.map((joke) => (
                <li key={joke.id}>
                  <Link to={joke.id}>{joke.name}</Link>
                </li>
              ))}
            </ul>
            <Link to="new" className="button">
              Add your own
            </Link>
          </div>
          <div className="jokes-outlet">
            <Outlet />
          </div>
        </div>
      </main>
    </div>
  );
}

tableの型をapp/types/tablesから引っ張ってきています。Supabaseではテーブルの選択はfromで行い、データの取得はselectです。Supabaseの方がSQLに近い見た目ですね。

Data overfetching

Prismaの場合、findManyの引数に{take: 5, select: { id: true, name: true }, orderBy: { createdAt: "desc" }}としていますが、Supabaseの場合、以下の通りすれば同様の操作になります。

app/routes/jokes.tsx
...
const { data } = await db
  .from("joke")
  .select("id, name")
  .limit(5)
  .order("created_at", { ascending: false });

https://supabase.com/docs/reference/javascript/limit
https://supabase.com/docs/reference/javascript/order

Wrap up database queries

app/routes/jokes/$jokeId.tsx
import type { LoaderFunction } from "remix";
import { Link, useLoaderData } from "remix";
import type { definitions } from "~/types/tables";
import { db } from "~/utils/db.server";

type LoaderData = definitions["joke"];

export const loader: LoaderFunction = async ({ params }) => {
  const { data, error } = await db
    .from("joke")
    .select("*")
    .eq("id", params.jokeId)
    .maybeSingle();
  if (error) throw new Error("Joke not found");

  const res: LoaderData = data;
  return res;
};

export default function JokeRoute() {
  const data = useLoaderData<LoaderData>();

  return (
    <div>
      <p>Here's your hilarious joke:</p>
      <p>{data.content}</p>
      <Link to=".">{data.name} Permalink</Link>
    </div>
  );
}

maybeSingle()を使ってIDに合致するものを1つ取り出しています。
https://supabase.com/docs/reference/javascript/maybesingle

app/routes/jokes/index.tsx
import type { LoaderFunction } from "remix";
import { useLoaderData, Link } from "remix";
import type { definitions } from "~/types/tables";
import { db } from "~/utils/db.server";

type LoaderData = definitions["joke"];

export const loader: LoaderFunction = async () => {
  const { count, error } = await db
    .from("joke")
    .select("*", { count: "exact" });
  if (!count) throw new Error(error?.message);
  const randomRowNumber = Math.floor(Math.random() * count);
  const { data } = await db
    .from("joke")
    .select("*")
    .range(randomRowNumber, randomRowNumber)
    .maybeSingle();
  const res: LoaderData = data;
  return res;
};

export default function JokesIndexRoute() {
  const data = useLoaderData<LoaderData>();

  return (
    <div>
      <p>Here's a random joke:</p>
      <p>{data.content}</p>
      <Link to={data.id}>"{data.name}" Permalink</Link>
    </div>
  );
}

countを使って行数を取得しています。
https://supabase.com/docs/reference/javascript/select#querying-with-count-option
findManyで使われているskipに相当するものはPostgreではOFFSETではないかと思います。しかし、supabase/postgrest-jsでは見つかりませんでした。代わりにrangeを使っています。
https://supabase.com/docs/reference/javascript/range

Mutations

jokeを入れられるようにします。createの代わりにinsertを使います。
https://supabase.com/docs/reference/javascript/insert

app/routes/jokes/new.tsx
import type { ActionFunction } from "remix";
import { redirect } from "remix";
import type { definitions } from "~/types/tables";

import { db } from "~/utils/db.server";

export const action: ActionFunction = async ({ request }) => {
  const form = await request.formData();
  const name = form.get("name");
  const content = form.get("content");
  // we do this type check to be extra sure and to make TypeScript happy
  // we'll explore validation next!
  if (typeof name !== "string" || typeof content !== "string") {
    throw new Error(`Form not submitted correctly.`);
  }

  const { data, error } = await db
    .from("joke")
    .insert({ name: name, content: content })
    .maybeSingle();
  if (!data) throw Error(error?.message);
  return redirect(`/jokes/${data.id}`);
};

export default function NewJokeRoute() {
  return (
    <div>
      <p>Add your own hilarious joke</p>
      <form method="post">
        <div>
          <label>
            Name: <input type="text" name="name" />
          </label>
        </div>
        <div>
          <label>
            Content: <textarea name="content" />
          </label>
        </div>
        <div>
          <button type="submit" className="button">
            Add
          </button>
        </div>
      </form>
    </div>
  );
}

無事にジョークを入れることができました。

バリデーションを追加します。

app/routes/jokes/new.tsx
import type { ActionFunction } from "remix";
import { useActionData, redirect, json } from "remix";

import { db } from "~/utils/db.server";

function validateJokeContent(content: string) {
  if (content.length < 10) {
    return `That joke is too short`;
  }
}

function validateJokeName(name: string) {
  if (name.length < 3) {
    return `That joke's name is too short`;
  }
}

type ActionData = {
  formError?: string;
  fieldErrors?: {
    name: string | undefined;
    content: string | undefined;
  };
  fields?: {
    name: string;
    content: string;
  };
};

const badRequest = (data: ActionData) =>
  json(data, { status: 400 });

export const action: ActionFunction = async ({
  request
}) => {
  const form = await request.formData();
  const name = form.get("name");
  const content = form.get("content");
  if (
    typeof name !== "string" ||
    typeof content !== "string"
  ) {
    return badRequest({
      formError: `Form not submitted correctly.`
    });
  }

  const fieldErrors = {
    name: validateJokeName(name),
    content: validateJokeContent(content)
  };
  const fields = { name, content };
  if (Object.values(fieldErrors).some(Boolean)) {
    return badRequest({ fieldErrors, fields });
  }

  const { data, error } = await db
    .from("joke")
    .insert({ name: name, content: content })
    .maybeSingle();
  if (!data) throw Error(error?.message);
  return redirect(`/jokes/${data.id}`);
};
export default function NewJokeRoute() {
  const actionData = useActionData<ActionData>();

  return (
    <div>
      <p>Add your own hilarious joke</p>
      <form method="post">
        <div>
          <label>
            Name:{" "}
            <input
              type="text"
              defaultValue={actionData?.fields?.name}
              name="name"
              aria-invalid={
                Boolean(actionData?.fieldErrors?.name) ||
                undefined
              }
              aria-errormessage={
                actionData?.fieldErrors?.name
                  ? "name-error"
                  : undefined
              }
            />
          </label>
          {actionData?.fieldErrors?.name ? (
            <p
              className="form-validation-error"
              role="alert"
              id="name-error"
            >
              {actionData.fieldErrors.name}
            </p>
          ) : null}
        </div>
        <div>
          <label>
            Content:{" "}
            <textarea
              defaultValue={actionData?.fields?.content}
              name="content"
              aria-invalid={
                Boolean(actionData?.fieldErrors?.content) ||
                undefined
              }
              aria-errormessage={
                actionData?.fieldErrors?.content
                  ? "content-error"
                  : undefined
              }
            />
          </label>
          {actionData?.fieldErrors?.content ? (
            <p
              className="form-validation-error"
              role="alert"
              id="content-error"
            >
              {actionData.fieldErrors.content}
            </p>
          ) : null}
        </div>
        <div>
          <button type="submit" className="button">
            Add
          </button>
        </div>
      </form>
    </div>
  );
}

ここはRemixのバージョンが@1.2.1だと私の環境ではエラーが出ました。@1.1.3ならだいじょうぶそうですが、どうなんでしょう。関連するIssueを上げておきます。
https://github.com/remix-run/remix/issues/1852

後編へ

長くなりそうなので前後編に分けようと思います。
https://zenn.dev/smallstall/articles/5f33b911033014
今回はバージョンによる挙動の違いを実感しました。最新のものよりマイナーバージョンが1つ2つ昔のほうが安定するのかもしれませんね。
現場からは以上です。

Discussion