Remix JokesをSupabase+Cloudflare Workersで(前編)
Jokes App
RemixのチュートリアルではJokes Appが紹介されていますが、データベースがPrisma+SQLiteの組み合わせになっています。これをSupabaseに変えて実行環境はCloudflare Workersにしてみます。データベース操作はRESTful APIです。進行はRemix公式の章立てに従い、違いがある部分だけを書きます。Remix, Supabase, Cloudflare Workersはどれも活発に更新されていますのでバージョン違いにご注意ください。
それでは行ってみましょう。
GitHub
ソースを上げました。
Generating a new Remix project
Remixプロジェクトの作成です。本来はnpx create-remixを実行するのが定石です。
ただ、バージョン違いによって動かなくなったりするので、次のリポジトリをgit cloneすることにします。Remixのバージョンは@1.1.3です。
git clone https://github.com/smallStall/RSCStarter
cd RSCStarter
npm install
npm installしたら、Cloudflare Workersの設定ファイル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
ファイルを作成します。
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タグは付けません。
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テーブルの型を生成します。
ターミナルで以下のコードを実行します。npx openapi-typescript https://SupabaseのURL.supabase.co/rest/v1/?apikey=anonキー --output app/types/tables.ts
SupabaseのURLとanonキーはSettings→APIにて表示されます。
実行するとjokeテーブルの型その他色々が出力されます。
...
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を更新します。
app/utils/db.server.tsを作成します。ファイル名に.serverを付けています。このファイルはserver only codeであることをRemixコンパイラに指定しています。import { createClient, SupabaseClient } from "@supabase/supabase-js";
export const db: SupabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
fetch: (...args) => fetch(...args),
})
次に、Supabaseに接続します。
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の場合、以下の通りすれば同様の操作になります。
...
const { data } = await db
.from("joke")
.select("id, name")
.limit(5)
.order("created_at", { ascending: false });
Wrap up database queries
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つ取り出しています。
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を使って行数を取得しています。
findManyで使われているskipに相当するものはPostgreではOFFSETではないかと思います。しかし、supabase/postgrest-jsでは見つかりませんでした。代わりにrangeを使っています。Mutations
jokeを入れられるようにします。createの代わりにinsertを使います。
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>
);
}
無事にジョークを入れることができました。
バリデーションを追加します。
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を上げておきます。
後編へ
長くなりそうなので前後編に分けようと思います。
現場からは以上です。
Discussion