🤪

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

2022/03/02に公開

後編

前編はMutationsまで進みました。後編はAuthenticationから進めます。よろしくお願いします。
https://zenn.dev/smallstall/articles/e9ca2195b1f435

Authentication

Supabaseでの認証は色々な方法を選択できますが、今回はメールアドレス+パスワード方式を採用します。アドレスとパスワードを入力すると、認証メールが届く仕組みのものです。
Supabaseでのユーザー管理にはauth.usersが使用されています。ここにはメールアドレスや暗号化されたパスワードなどの機密情報が格納されます。

Preparing the database

Supabaseのサイトに行き、ユーザー登録しプロジェクトを作成します。
プロジェクトを開き、Authentication→SettingにてSite URLを変更しました。

Site URL
http://localhost:8787/login

これは後で送る認証メールに書かれているリンク先になります。
新しくテーブルを作成し、auth.usersのidを外部参照して追加のユーザー情報を管理します。
https://supabase.com/docs/guides/auth/managing-user-data
以下のコードをSQL Editorで実行し、public.userテーブルを作成します。

create_user_table
create table public.user (
  id uuid references auth.users on delete cascade not null,
  created_at timestamp default now() not null,
  updated_at timestamp default now() not null,
  username varchar(20) not null unique,
  primary key (id)
);

--RLSをオンにする
alter table public.user enable row level security;

create policy "Anyone can select."
  on public.user for select
  using ( true );

create policy "Anyone can insert their own data."
  on public.user for insert
  with check ( auth.role() = 'anon' );

create policy "Users can update own data."
  on public.user for update
  with check ( auth.uid() = id );

現在、ユーザー情報に関する2つのテーブルがあることに注意が必要です。公式のPrisma+SQLiteと最も異なる点です。

  • auth.users メールアドレスと暗号化されたパスワードなど機密情報が格納されたテーブル
  • public.user ユーザー名などの追加の情報が格納されたテーブル

auth.usersを直接操作することはセキュリティ上できません。その代わり、authにあるsignIn(), signOut(), signUp()などの関数を使用し、間接的にauth.usersにアクセスすることになります。
referencesにてpublic.userのidをauth.usersのidに外部参照しています。また、delete cascadeによって、auth.usersのユーザーが削除されると連鎖的にpublic.userのユーザー情報も削除されます。

jokeテーブルを削除して作り直します(alterを使ってカラムを追加しても良いと思います)。

create_joke_table
create table public.joke (
  id uuid default uuid_generate_v4(),
  jokester_id uuid references public.user on delete cascade not null,
  created_at timestamp default now() not null,
  updated_at timestamp default now() not null,
  name varchar(30) not null,
  content varchar(255) not null,
  primary key (id)
);
--jokester ジョークの作り手

alter table public.joke enable row level security;

create policy "Anyone can select."
  on public.joke for select
  using ( true );

create policy "Users can insert their own joke."
  on public.joke for insert
  with check ( auth.uid() = jokester_id );

create policy "Users can update own joke."
  on public.joke for update
  with check ( auth.uid() = jokester_id );

create policy "Users can delete own joke."
  on public.joke for delete
  using ( auth.uid() = jokester_id );

SQLでRow Level Security(RLS)をオンにしています。
https://www.postgresql.jp/document/13/html/ddl-rowsecurity.html
これでセキュリティは良くなったのですが、RLSをオンにしたことを忘れないようにしないといけません。すごく当たり前のことを言っていて申し訳ないですが、念の為の確認です。私は謎のエラーが出たと思ったら、RLSがちゃんと動いていただけだったことがありました。
テーブルは一旦作ったので、もう一度型を入れ直します。

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

jokeのseedデータを入れ直したいのですが、public.userで外部参照しているauth.usersデータが必要になります。ここはRemixの公式と順序が飛んでしまいますが、loginフォームを先に作ってからseedを入れ直すことにします。

Build the login form

ここが最も長いセクションになります。あまりに長いので全体の流れを把握しておきます。

  • UIの作成
  • ユーザー登録
  • jokeのseedを入れる
  • バリデーション
  • ログイン処理
  • セッション管理

始める前に、Scriptタグをrootに入れます。このタグを入れないとクライアントサイドにいくらスクリプトを書いても無力化されるので気をつける必要があります。私は突然パソコンに無視されたので、ハードディスクが壊れたんじゃないかと思いました。

app/root.tsx
...
<body>
  <Outlet />
  <Scripts />
</body>
...

UIの作成

今回は認証方法が公式と異なるため、メールアドレスが必要になります。loginではユーザー名の代わりにメールアドレスを使用し、registerではユーザー名も入力するようにします。


コードはこんな感じにしました。

app/routes/jokes/login.tsx
import type { LinksFunction } from "remix";
import { Link, useSearchParams } from "remix";
import { useState } from "react";

import stylesUrl from "../styles/login.css";

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

export default function Login() {
  const [register, setRegister] = useState(false);
  const [searchParams] = useSearchParams();
  return (
    <div className="container">
      <div className="content" data-light="">
        <h1>Login</h1>
        <form method="post">
          {/*actionで後から使う*/}
          <input
            type="hidden"
            name="redirectTo"
            value={searchParams.get("redirectTo") ?? undefined}
          />
          <fieldset onChange={() => setRegister((prev) => !prev)}>
            <legend className="sr-only">Login or Register?</legend>
            <label>
              <input
                type="radio"
                name="loginType"
                value="login"
                defaultChecked
              />{" "}
              Login
            </label>
            <label>
              <input type="radio" name="loginType" value="register" /> Register
            </label>
          </fieldset>
          <div style={register ? { display: "block" } : { display: "none" }}>
            <label htmlFor="username-input">Username</label>
            <input
              type="text"
              id="username-input"
              name="username"
              autoComplete="username"
            />
          </div>
          <div>
            <label htmlFor="mail-input">Email</label>
            <input
              type="email"
              id="email-input"
              name="email"
              autoComplete="email"
            />
          </div>

          <div>
            <label htmlFor="password-input">Password</label>
            <input
              id="password-input"
              name="password"
              type="password"
              autoComplete={register ? "new-password" : "current-password"}
            />
          </div>
          <button type="submit" className="button">
            Submit
          </button>
        </form>
      </div>
      <div className="links">
        <ul>
          <li>
            <Link to="/">Home</Link>
          </li>
          <li>
            <Link to="/jokes">Jokes</Link>
          </li>
        </ul>
      </div>
    </div>
  );
}

useStateで状態を持たせてラジオボタンが変わるとregisterが変化し、それに応じてusername-inputが見えたり見えなかったりするようにしました。

ユーザー登録

auth.usersにデータが入っていないとデータベースの確認ができないので、公式とは順序が異なりますが、まずregisterを実装します。session.server.tsを作成します。

app/utils/session.server.ts
import { register } from "~/utils/session.server";

type LoginForm = {
  email: string;
  password: string;
};
type SignUpForm = LoginForm & { username: string }

export async function register({
  email,
  password,
  username
}: SignUpForm) {
  const { user, error: signUpError } = await db.auth.signUp(
    { email, password },
  );
  if (signUpError || !user) {
    return { error: signUpError }
  }
  const { error: userError } = await db.from("user").insert({
    username: username, id: user.id
  }, { returning: 'minimal' });
  if (userError) return { error: userError };

  return { user };
}

一旦、auth.signUpでauth.usersテーブルにデータを入れています。成功したら、今度はpublic.userテーブルにユーザー名を入れています。{ returning: 'minimal' }はエラー回避のために必要になります。
https://github.com/supabase/supabase/discussions/270
actionを書き加えます。

app/routes/jokes/login.tsx
export const action: ActionFunction = async ({ request }) => {
  const form = await request.formData();
  const loginType = form.get("loginType");
  const username = form.get("username");
  const email = form.get("email");
  const password = form.get("password");
  const redirectTo = form.get("redirectTo") || "/jokes";
  switch (loginType) {
    case "login": {
    }
    case "register": {
      const { error } = await register({ email, password, username });
      if(error){
        return badRequest({
          fields,
          formError: error.message,
        });  
      }
    }
    default: {
    });
    }
  }
};

とりあえずこれでSupabaseにユーザー登録できるようになっているはずです。やってみましょう。ユーザー名、メールアドレス、パスワードを入力してSubmitを押します。Supabaseのプロジェクトに移動してTable Editor→userを選択します。
public.userにデータが入りました。

auth.usersはAuthentication→Usersで見れます。また、ユーザーの削除もできます。

ここで注意したいのは、まだ承認されていないことです。ユーザー欄にWaiting for verification.と出ていますね。入力したメールアドレスに認証用のメールが届いているはずです。リンクをクリックすると、Authenticatedに変わります。これでSupabaseに承認されました。

jokeのseedを入れる

ユーザーを入れることができたので、削除したjokeのデータを入れ直します。
まずはSupabaseでユーザーIDを確認します。
Authentication→UsersにてUser UIDを見てみます。この値は外部参照によりpublic.userのIDと同じはずです。この値をコピーします。SQLに貼り付けます。私のSQL力がないため、全然スマートなやり方じゃないですね。

SQL Editor
insert into joke
  (name, content, jokester_id)
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.', 'さっきのID'),
  ('Frisbee', 'I was wondering why the frisbee was getting bigger, then it hit me.', 'さっきのID'),
  ('Trees', 'Why do trees seem suspicious on sunny days? Dunno, they''re just a bit shady.', 'さっきのID'),
  ('Skeletons', 'Why don''t skeletons ride roller coasters? They don''t have the stomach for it.', 'さっきのID'),
  ('Hippos', 'Why don''t you find hippopotamuses hiding in trees? They''re really good at it.', 'さっきのID'),
  ('Dinner', 'What did one plate say to the other plate? Dinner is on me!', 'さっきのID'),
  ('Elevator', 'My first time using an elevator was an uplifting experience. The second time let me down.', 'さっきのID');

実行すれば、jokeにデータを入れることができます。

バリデーション

バリデーションを実装します。
https://developer.mozilla.org/ja/docs/Web/HTML/Element/input/email

(コードが長いので折りたたみます)
app/routes/jokes/login.tsx
import { LinksFunction, ActionFunction, useActionData } from "remix";
import { Link, useSearchParams, json } from "remix";
import { useState } from "react";
import { register } from "~/utils/session.server";
import stylesUrl from "../styles/login.css";

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

function validateUsername(username: unknown, logintype: string) {
  if (logintype == "login") {
    return;
  }
  if (typeof username !== "string" || username.length < 3) {
    return `Usernames must be at least 3 characters long`;
  }
}

function validateEmail(email: unknown) {
  if (typeof email !== "string" || email.length < 3) {
    return `You will need to enter your email.`;
  }
}

function validatePassword(password: unknown) {
  if (typeof password !== "string" || password.length < 6) {
    return `Passwords must be at least 6 characters long`;
  }
}
type ActionData = {
  formError?: string;
  fieldErrors?: {
    username: string | undefined;
    email: string | undefined;
    password: string | undefined;
  };
  fields?: {
    loginType: string;
    username: string;
    email: string;
    password: string;
  };
};

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

export const action: ActionFunction = async ({ request }) => {
  const form = await request.formData();
  const loginType = form.get("loginType");
  const username = form.get("username");
  const email = form.get("email");
  const password = form.get("password");
  const redirectTo = form.get("redirectTo") || "/jokes";
  if (
    typeof loginType !== "string" ||
    typeof username !== "string" ||
    typeof email !== "string" ||
    typeof password !== "string" ||
    typeof redirectTo !== "string"
  ) {
    return badRequest({
      formError: `Form not submitted correctly.`,
    });
  }
  const fields = { loginType, username, email, password };
  const fieldErrors = {
    username: validateUsername(username, loginType),
    email: validateEmail(email),
    password: validatePassword(password),
  };
  if (Object.values(fieldErrors).some(Boolean))
    return badRequest({ fieldErrors, fields });

  switch (loginType) {
    case "login": {
      // login to get the user
      // if there's no user, return the fields and a formError
      // if there is a user, create their session and redirect to /jokes
      return badRequest({
        fields,
        formError: "Not implemented",
      });
    }

    case "register": {
      const { error } = await register({ email, password, username });
      if (error?.message) {
        return badRequest({
          formError: error.message,
        });
      }
      return {};
    }
    default: {
      return badRequest({
        fields,
        formError: `Login type invalid`,
      });
    }
  }
};

export default function Login() {
  const actionData = useActionData<ActionData>();
  const [searchParams] = useSearchParams();
  const [register, setRegister] = useState(
    actionData?.fields?.loginType === "register"
  );
  return (
    <div className="container">
      <div className="content" data-light="">
        <h1>Login</h1>
        <form method="post">
          <input
            type="hidden"
            name="redirectTo"
            value={searchParams.get("redirectTo") ?? undefined}
          />
          <fieldset onChange={() => setRegister((prev) => !prev)}>
            <legend className="sr-only">Login or Register?</legend>
            <label>
              <input
                type="radio"
                name="loginType"
		value="login"
                defaultChecked={
                  !actionData?.fields?.loginType ||
                  actionData?.fields?.loginType === "login"
                }
              />{" "}
              Login
            </label>
            <label>
              <input
                type="radio"
                name="loginType"
                value="register"
                defaultChecked={actionData?.fields?.loginType === "register"}
              />{" "}
              Register
            </label>
          </fieldset>
          <div style={register ? { display: "block" } : { display: "none" }}>
            <label htmlFor="username-input">Username</label>
            <input
              type="text"
              id="username-input"
              name="username"
              autoComplete="username"
              defaultValue={actionData?.fields?.username}
              aria-invalid={Boolean(actionData?.fieldErrors?.username)}
              aria-errormessage={
                actionData?.fieldErrors?.username ? "username-error" : undefined
              }
            />
            {actionData?.fieldErrors?.username ? (
              <p
                className="form-validation-error"
                role="alert"
                id="username-error"
              >
                {actionData.fieldErrors.username}
              </p>
            ) : null}
          </div>
          <div>
            <label htmlFor="mail-input">Email</label>
            <input
              type="email"
              id="email-input"
              name="email"
              autoComplete="email"
              defaultValue={actionData?.fields?.email}
              aria-invalid={Boolean(actionData?.fieldErrors?.email)}
              aria-errormessage={
                actionData?.fieldErrors?.email ? "email-error" : undefined
              }
            />
            {actionData?.fieldErrors?.email ? (
              <p
                className="form-validation-error"
                role="alert"
                id="email-error"
              >
                {actionData.fieldErrors.email}
              </p>
            ) : null}
          </div>

          <div>
            <label htmlFor="password-input">Password</label>
            <input
              id="password-input"
              name="password"
              autoComplete={register ? "new-password" : "current-password"}
              defaultValue={actionData?.fields?.password}
              type="password"
              aria-invalid={
                Boolean(actionData?.fieldErrors?.password) || undefined
              }
              aria-errormessage={
                actionData?.fieldErrors?.password ? "password-error" : undefined
              }
            />
            {actionData?.fieldErrors?.password ? (
              <p
                className="form-validation-error"
                role="alert"
                id="password-error"
              >
                {actionData.fieldErrors.password}
              </p>
            ) : null}
          </div>
          <div id="form-error-message">
            {actionData?.formError ? (
              <p className="form-validation-error" role="alert">
                {actionData.formError}
              </p>
            ) : null}
          </div>
          <button type="submit" className="button">
            Submit
          </button>
        </form>
      </div>
      <div className="links">
        <ul>
          <li>
            <Link to="/">Home</Link>
          </li>
          <li>
            <Link to="/jokes">Jokes</Link>
          </li>
        </ul>
      </div>
    </div>
  );
}

バリデーションを追加するとコードが長くなりますね。
メールアドレスもバリデーションに追加しています。また、usernameはloginのときはいらなくなったので、validateUsername関数を変更しています。実行すると以下のように、ちゃんとエラーはエラーと返してくれます。Remixだといとも簡単にやっていますが、サーバー側の処理なんですよね。

ログイン処理

ログイン処理を実装します。

import { db } from "./db.server"

type LoginForm = {
  email: string;
  password: string;
};

export async function login({
  email,
  password
}: LoginForm) {
  const { user, session } = await db.auth.signIn({ email, password })
  if (!user) return null;
  return { session }
}
...

usernameがemailになり、データベースを直接操作するのではなくSupabaseのauth経由になりました。つまり、ログイン処理に必要なテーブルはauth.usersだけで、public.userは必要ありません。また、supabaseのセッション、Json Web Token(JWT)が戻り値で返ってきます。useridではなく、これを返します。
追加したログイン処理を使います。

...
  switch (loginType) {
    case "login": {
      const token = await login({ email, password });
      if (!token) {
        return badRequest({
          fields,
          formError: `Username/Password combination is incorrect`,
        });
      }
    }
...

これでログインはできるようになりましたが、セッション管理がまだです。

セッション管理

セッション管理は悩ましい、と私は思っています。セキュリティを気にしないといけないし、体感速度も変わるし、昨今の法律でクッキーが敵視されているし。どうするのが良いんでしょう。SupabaseではJSON Web Token(JWT)を使ってセッション管理します。
https://supabase.com/docs/guides/auth#how-it-works
一方で、Remixは明言した記事が見つかりませんでしたが、実装を見る限りウェブのデファクトスタンダードであるクッキーを使う想定でいそうです。ここではRemixに寄せて、クッキーにJWTを入れてみます。また、サーバー側のセッションの保存場所にはCloudflare Workers KVを使います。
クッキーを作る関数を書きます。

session.server.ts
import { createCloudflareKVSessionStorage, redirect } from "remix";
import { db } from "./db.server"
...

const storage =
  createCloudflareKVSessionStorage({
    cookie: {
      name: "RJ_session",
      // normally you want this to be `secure: true`
      // but that doesn't work on localhost for Safari
      // https://web.dev/when-to-use-local-https/
      secure: process.env.NODE_ENV === "production",
      secrets: [SESSION_SECRET],
      sameSite: "lax",
      path: "/",
      maxAge: 3600, //1時間
      httpOnly: true,
      expires: new Date(Date.now() + 3600),
    },
    kv: JOKES_SESSION_STORAGE,
  });
  
export async function createUserSession(
  supabaseSession: Session | null,
  redirectTo: string
) {
  const session = await storage.getSession("Cookie");
  session.set("access_token", supabaseSession?.access_token)
  return redirect(redirectTo, {
    headers: {
      "Set-Cookie": await storage.commitSession(session),
    },
  });
}

コードの流れを説明します。session.setのところでクッキーにaccess_tokenを入れています。これをリダイレクトする際に渡します。commitSessionはSet-Cookie用のヘッダーを返す関数です。
クッキーの寿命は適宜決めてください。
SESSION_SECRETとJOKES_SESSION_STORAGEとで2つエラーが出たと思います。それぞれ対処します。

  • SESSION_SECRET

.envファイルにSESSION_SECRETを定義しておきます。

.env
SESSION_KEY=適当なワード

bindings.tsにも型を定義しておきます。

app/bindings.ts
export {};

declare global {
  const SUPABASE_ANON_KEY: string;
  const SUPABASE_URL: string;
  const SESSION_SECRET: string;
}
  • JOKES_SESSION_STORAGE

コードの流れを説明します。
createCloudflareKVSessionStorageでクッキーを作って、Cloudflare Worker KV(Key Value)に保存します。
公式ではcreateCookieSessionStorageを使っていますが、KVの方がスケールしやすいと思ったので試してみます。2022年2月ですと、1日1000回までが無料枠です。
エラーを解消するにはCloudflare Workersに登録し、KVの名前空間を定義しなければなりません。まずはCloudflare WorkerにSign upします。
https://workers.cloudflare.com/
Cloudflare Workersのダッシュボードが開いたら、次にCLIをダウンロードします。

npm install -g @cloudflare/wrangler

https://developers.cloudflare.com/workers/#installing-the-workers-cli
wranglerコマンドでログインします。

wrangler login
Allow Wrangler to open a page in your browser? y

https://developers.cloudflare.com/workers/get-started/guide#3-configure-the-workers-cli
「You have granted authorization to Wrangler!」と表示されればユーザー承認されました。
ターミナルで以下のコマンドを実行します。

ターミナル
wrangler kv:namespace create "JOKES_SESSION_STORAGE"

Success!と表示されていれば、KV名前空間JOKES_SESSION_STORAGEがWorkers上に作成されました。Add the following to your configuration fileと言われるので追記します。

wrangler.toml
kv_namespaces = [
  { binding = "JOKES_SESSION_STORAGE", id = "......" }
]

型を追記します。

app/bindings.ts
export {};

declare global {
  const SUPABASE_ANON_KEY: string;
  const SUPABASE_URL: string;
  const SESSION_SECRET: string;
  const JOKES_SESSION_STORAGE: KVNamespace;
}

これでエラーがなくなり、createCloudflareKVSessionStorageが使えるようになりました。
createUserSessionの使い方は公式とあまり変わりません。受け取るものがIDからトークンに変わります。

app/routes/login.tsx
...
  switch (loginType) {
    case "login": {
      const token = await login({ email, password });
      if (!token) {
        return badRequest({
          fields,
          formError: `Username/Password combination is incorrect`,
        });
      }
      const { session } = token;
      return createUserSession(session, redirectTo);
    }
...

ログインするとサーバーからクッキーがもらえます。Chromeですと、Applicationタグでクッキーを見ることができました。

セッション管理にはクライアントにクッキーを渡すだけではダメで、クライアントからのクッキーをサーバーが読み取る必要があります。関数を3つ作ります。公式はIDをクッキーに入れていましたが、今回はトークンをクッキーに入れたので関数の名前をIdからTokenに変えています。

app/utils/session.server.ts
...
function getUserSession(request: Request) {
  return storage.getSession(request.headers.get("Cookie"));
}

export async function getUserToken(request: Request) {
  const session = await getUserSession(request);
  const accessToken = await session.get("access_token");
  if (!accessToken || typeof accessToken !== "string") return null;
  return accessToken;
}

export async function requireUserToken(
  request: Request,
  redirectTo: string = new URL(request.url).pathname) {
  const session = await getUserSession(request);
  const accessToken = await session.get("access_token");

  const user = await db.auth.api.getUser(accessToken);
  if (!accessToken || !user) {
    const searchParams = new URLSearchParams([
      ["redirectTo", redirectTo],
    ]);
    throw redirect(`/login?${searchParams}`);
  }
  return user.data?.id;
}
...

下から4行目のところでredirectをthrowしています。redirectはResponseを返すRemixの関数です。Remixは積極的にエラー処理を使っていく方針なのが見て取れます。
requireUserTokenを使って、クライアントからのクッキーをサーバーで読み取ります。

app/routes/jokes/new.tsx
...
export const action: ActionFunction = async ({ request }) => {
  const userId = await requireUserToken(request);
...
  const { data, error } = await db
    .from("joke")
    .insert({ ...fields, jokester_id: userId })
    .maybeSingle();
  if (error) throw Error(error?.message);
  return redirect(`/jokes/${data.id}`);
 };
...

ログインして新しいジョークを入れます。クッキーの期限切れにはご注意ください。無事に入れられたらBuild the login formは終わりです。
冗談のように長いセクションでした。その分使いこなせるようになったと思います。

Build Logout Action

公式ではapp/utils/session.server.tsにて、getUser関数を定義しています。似たような感じでクッキーからユーザー情報を取り出せないかとSupabaseのHPを見てみました。
getUserByCookieという関数は、requestを入れればuserが返ってくるそうなのですが、試したところnullしか返ってきませんでした。Issueに上がっていました。
https://github.com/supabase/supabase/issues/3783
代わりにauth.api.getUserは使えました。

app/utils/session.server.ts
...
import { definitions } from "~/types/tables";
...
export async function getUser(request: Request) {
  try {
    const session = await getUserSession(request);
    const accessToken = await session.get("access_token");
    const { user } = await db.auth.api.getUser(accessToken);
    if (user) {
      return db.from<definitions["user"]>("user").
        select("*").eq("id", user.id).maybeSingle();
    }
  } catch {
    throw logout(request);
  }
}

export async function logout(request: Request) {
  const session = await getUserSession(request);
  if (!session) { return redirect("/login") }
  return redirect("/login", {
    headers: {
      "Set-Cookie": await storage.destroySession(session),
    },
  });
}
...

app/routes/logout.tsxは公式と変わらないので、app/routes/jokes.tsxだけ書きます。

(コードが長いので折りたたみます)
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 { getUser } from "~/utils/session.server";
import stylesUrl from "~/styles/jokes.css";

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

type LoaderData = {
  user: Awaited<ReturnType<typeof getUser>>;
  jokeListItems: Array<definitions["joke"]>;
};

export const loader: LoaderFunction = async ({ request }) => {
  const { data: jokeListItems } = await db
    .from("joke")
    .select("id, name")
    .limit(5)
    .order("created_at", { ascending: false });
  if (!jokeListItems) return {};
  const user = await getUser(request);

  const data: LoaderData = { jokeListItems, user };
  return data;
};

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>
          {data.user ? (
            <div className="user-info">
              <span>{`Hi ${data.user.data?.username}`}</span>
              <form action="/logout" method="post">
                <button type="submit" className="button">
                  Logout
                </button>
              </form>
            </div>
          ) : (
            <Link to="/login">Login</Link>
          )}
        </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>
  );
}

User Registration

実装はすでにやってしまったので、バリデーションの話をします。
Remix公式では、ここの部分で「アカウント持ってたのにサイトに来るの久々すぎてアカウント作ってしまう問題」に対処しています。

app/routes/jokes/login.tsx
    case "register": {
      const { error } = await register({ email, password, username });
      if(error){
        return badRequest({
          fields,
          formError: error.message,
        });  
      }
    }

一方、Supabaseではブルートフォース攻撃を受ける可能性があることを考慮して、重複チェックをJavaScriptでは返しません。auth.signUpでメールアドレスがすでに存在していてもエラーを返さず、代わりにメールを送ります。認証済みユーザーなら認証メールではなく、アカウント回復メールを送るようにするようです。なので、重複に関するバリデーションはしないようにしました。
https://github.com/supabase/supabase-js/issues/296

Unexpected errors

公式と変わりありません。throwでエラーを投げてエラー画面を確認しました。

Expected errors

jokeテーブルのデータを一旦消すSQLを実行します。

SQL Editor
delete from joke

app/routes/jokes/new.tsxにおいて、getUserIdをgetUserに変えました。
app/routes/jokes/index.tsxのloaderも変えています。

app/routes/jokes/index.tsx
export const loader: LoaderFunction = async () => {
  const { count } = await db
    .from("joke")
    .select("*", { count: "exact" });
  if (!count || count === 0) {
    throw new Response("No random joke found", {
      status: 404,
    });
  }
  const randomRowNumber = Math.floor(Math.random() * count);
  const { data } = await db
    .from("joke")
    .select("*")
    .range(randomRowNumber, randomRowNumber)
    .maybeSingle();
  const res: LoaderData = data;
  return res;
};

$jokeId.tsxのloaderとactionはこんな感じにしました。

app/routes/jokes/$jokeId.tsx
...
export const loader: LoaderFunction = async ({ request, params }) => {
  const user = await getUser(request);
  const userId = user?.data?.id;
  const { data: joke, error } = await db
    .from<definitions["joke"]>("joke")
    .select("*")
    .eq("id", params.jokeId ?? "")
    .maybeSingle();
  if (error) throw new Error("Joke not found");
  if (!joke) {
    throw new Response("What a joke! Not found.", {
      status: 404,
    });
  }
  const res: LoaderData = {
    joke,
    isOwner: userId === joke.jokester_id,
  };
  return res;
};

export const action: ActionFunction = async ({ request, params }) => {
  const form = await request.formData();
  if (form.get("_method") !== "delete") {
    throw new Response(`The _method ${form.get("_method")} is not supported`, {
      status: 400,
    });
  }
  const userId = await requireUserToken(request);
  console.log(userId);
  const joke = await db
    .from("joke")
    .select("*")
    .eq("id", params.jokeId)
    .maybeSingle();
  if (!joke) {
    throw new Response("Can't delete what does not exist", {
      status: 404,
    });
  }
  if (joke.data.jokester_id !== userId) {
    throw new Response("Pssh, nice try. That's not your joke", {
      status: 401,
    });
  }
  await db
    .from("joke")
    .delete({ returning: "minimal" })
    .eq("id", params.jokeId);
  return redirect("/jokes");
};
...

Resource Routes

jokes[.]rss.tsxを少し変えました。

(長いので折りたたみます)
import type { LoaderFunction } from "remix";

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

function escapeCdata(s: string) {
  return s.replace(/\]\]>/g, "]]]]><![CDATA[>");
}

function escapeHtml(s: string) {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
}

export const loader: LoaderFunction = async ({ request }) => {
  const {data: jokes} = await db
    .from("joke")
    .select("*, user!inner(*)")
    .neq('user.username', null)
    .limit(100)
    .order("created_at", { ascending: false });
  if(!jokes){
    throw new Error("No jokes")
  }

  const host =
    request.headers.get("X-Forwarded-Host") ?? request.headers.get("host");
  if (!host) {
    throw new Error("Could not determine domain URL.");
  }
  const protocol = host.includes("localhost") ? "http" : "https";
  const domain = `${protocol}://${host}`;
  const jokesUrl = `${domain}/jokes`;

  const rssString = `
    <rss xmlns:blogChannel="${jokesUrl}" version="2.0">
      <channel>
        <title>Remix Jokes</title>
        <link>${jokesUrl}</link>
        <description>Some funny jokes</description>
        <language>en-us</language>
        <generator>Kody the Koala</generator>
        <ttl>40</ttl>
        ${jokes
          .map((joke) =>
            `
            <item>
              <title><![CDATA[${escapeCdata(joke.name)}]]></title>
              <description><![CDATA[A funny joke called ${escapeHtml(
                joke.name
              )}]]></description>
              <author><![CDATA[${escapeCdata(
                joke.user.username
              )}]]></author>
              <pubDate>${joke.createdAt}</pubDate>
              <link>${jokesUrl}/${joke.id}</link>
              <guid>${jokesUrl}/${joke.id}</guid>
            </item>
          `.trim()
          )
          .join("\n")}
      </channel>
    </rss>
  `.trim();
  const encoder = new TextEncoder();
  return new Response(rssString, {
    headers: {
      "Cache-Control": `public, max-age=${60 * 10}, s-maxage=${60 * 60 * 24}`,
      "Content-Type": "application/xml",
      "Content-Length": String(encoder.encode(rssString).length),
    },
  });
};

Deployment

Cloudflare Workersにデプロイします。
ざっくり書くとこんな感じです。

wrangler login
wrangler secret put SUPABASE_URL
#supabaseURLを入力
wrangler secret put SUPABASE_ANON_KEY
#supabaseKEYを入力
wrangler secret put SESSION_SECRET
#SESSION_SECRETを入力
wrangler whoami
#表示されたaccount idをwrangler.tomlのaccount_idにコピペ
wrangler publish

secretを入れるところで「Worker …… doesn't exist in the API yet.」というエラーが出る場合は、wrangler.tomlのnameを確認してください。
過去記事にもう少し色々書きましたので、詳しくはそちらをご参照ください。

終わりに

Remixではコンポーネントごとにバリデーションからエラー処理までをまとめて書けるのが楽しかったです。Remixの名前通り、フロントからエッジまでの領域をカバーしていることを実感しました。
SupabaseはSQLデータベースを東京リージョンで提供してくれているのが大きいです。私はSQLを触ったことがあるので、RLSによるセキュリティ設定も馴染みやすかったです。今後Supabaseを用いたマルチテナント対応のプロダクトがたくさん出てくるのではないでしょうか。
Cloudflare WorkersはKVとMiniflareが用意されているのがとても便利でした。Node.jsではない環境ですが、あまり意識することなく動かせました。
セッションの期限切れなどには対応できていませんが、大まかな流れは理解できたので良しとします。
現場からは以上です。

Discussion