🏔️

Supabase と Nuxt 3 で SSR を実装して Cloudflare Workers にデプロイ

2022/01/17に公開

私はもともと Nuxt2 や Firebase, App Engine などを使って Web サービスを作っていた身ですが、最近賑わっている技術がどう良くなっているのか体感したく、試しに極めてシンプルなサービスを作って記事化してみることにしました(あと単純に Zenn の使い心地を知りたかった)。

以下がそのサービスです。

Image from Gyazo

ユーザーのアクションが即座に OGP に反映されるようなケースを作りたかったので、以下のようなサービスにしました。

  • /: それぞれのユーザーが自分のステータスを設定(認証前提・クライアントレンダリング)
  • /[username]: そのユーザーのステータスを閲覧(サーバーレンダリング)

個人情報とかめんどくさいので登録はできないようにしてますが、サーバーレンダリングのページなら見てもらえます。

http://urin.kiyopikko.workers.dev/kiyopikko

リポジトリはこちらです。

https://github.com/kiyopikko/supabase-ssr-nuxt

Supabase

https://supabase.com/

The Open Source Firebase Alternative

とかなり喧嘩腰なキャッチコピーで話題を呼んでいる BaaS(なんか久しぶりに聞きました)です。

個人的な印象としては、公式が出してる以下の画像にもあるように PostgreSQL as a Service かなあと思っています。


–– Architecture | Supabase

Firebase Alternative と言っているだけあって、認証や REST API、リアルタイムアップデートなど欲しいものが十分に揃っている印象です。

Nuxt 3

言わずとしれた Nuxt のバージョン3です。以下の記事が言いたいことを全部言ってくれているので一読をおすすめします。

https://zenn.dev/ytr0903/articles/d0a91f6180d34e

個人的に、サクサク開発できるようになったり TypeScript が標準搭載されていたりといった使い勝手向上とは一線を画していると思うのが Nitro Engine です。

Nuxt3 のドキュメントのデプロイのところを見るとわかりますが、Nuxt で作ったアプリをこれらのサービスへ至極簡単にデプロイができます。触っていて一番感動しました。Cloudflare Workers にもこれでデプロイします。

Cloudflare Workers

コールドスタートがないサーバレス実行環境です。詳しくは以下の記事を見ると良さがわかります。

https://zenn.dev/catnose99/articles/dfc9c1197daec3

Nuxt 3 でセットアップ

それでは開発を始めます。

https://v3.nuxtjs.org/getting-started/installation#new-project

上記の通り

npx nuxi init アプリ名

すれば最小限のプロジェクトを作成できます。

Supabase プロジェクト立ち上げ

https://app.supabase.io/

上記から Github でログインしてなんやかんやプロジェクト作成までします。特に迷うことはないかと思います。

Supabase のアクセス制御

Supabase は Postgres のアクセス制御 RLS(Row Level Security) を採用しています(立ち位置は Firebase の Security Rule です。Firebase の request.auth みたいに Supabase 独自の認証されたユーザーのパラメーターも使えるように拡張されています)。以下の記事が参考になります。

https://zenn.dev/hrtk/articles/2a1492c9c2cc15

Supabase は Firebase のようにクライアントから直接使えるのはもちろん、一般的なデータベースのようにミドルウェア経由でも使えます。

「クライアントから直接」パターンでは、最小限の権限を持つ API Key である anon キーを使って認証 API にアクセスし、認証後 RLS でアクセス制御を行います。RLS はテーブル単位で設定できますが、RLS を設定していない API は anon キーでアクセスし放題になります。


–– Supabaseの行単位セキュリティーについて学ぶ

一方で、従来の「間にミドルウェア挟む」パターンでは、同様に anon キーで認証後、 RLS をすべて有効かつクライアントからのアクセスを禁止(ポリシーを作成しない限りそうなります)するようにして、ミドルウェアから RLS を無視できる API Key である service_role キーを使って Supabase へアクセスします。Firebase でいうと Security Rules をすべて false にしてミドルウェアで firebase-admin を使ってアクセスする感じです。


–– Supabaseの行単位セキュリティーについて学ぶ

今回は前者の「クライアントから直接」パターンを採用します。

Supabase を Nuxt で使えるように

Supabase の Home タブに先ほど述べた anon キーとプロジェクト URL があるので、そちらを環境変数にセットします。

Image from Gyazo

nuxt.config.ts
import { defineNuxtConfig } from "nuxt3";

export default defineNuxtConfig({
  publicRuntimeConfig: {
    SUPABASE_URL: process.env.SUPABASE_URL,
    SUPABASE_ANON_KEY: process.env.SUPABASE_ANON_KEY,
  },
});

そして Supabase クライアントをプラグインとして NuxtApp に注入します。 @supabase/supabase-js はインストールしておきましょう。 Nuxt3 ではプラグインをnuxt.config.ts で指定しなくても自動読み込みしてくれるようになりましたね。

plugins/supabase.ts
import { createClient } from "@supabase/supabase-js";
import { defineNuxtPlugin } from "#app";

export default defineNuxtPlugin((nuxtApp) => {
  const supabase = createClient(
    nuxtApp.$config.SUPABASE_URL as string,
    nuxtApp.$config.SUPABASE_ANON_KEY as string
  );

  nuxtApp.provide("supabase", supabase);
});

declare module "#app" {
  interface NuxtApp {
    $supabase: SupabaseClient;
  }
}

これで Supabase が使えるようになりました。

新規登録・ログイン

Supabase はデフォルトでメールアドレスログインが有効になっています。

適当にフォームを用意して supabase.auth.signUp に メールアドレスとパスワード渡してあげると新規登録完了です。新規登録するとメールアドレス検証メールが飛びます(メール文言も Authentication > Templates から変更できます)。

pages/signup/index.vue
<template>
  <!-- UI省略 -->
</template>

<script setup lang="ts">
const nuxtApp = useNuxtApp();

const email = ref("");
const password = ref("");
const username = ref("");

const submit = async () => {
  const session = await nuxtApp.$supabase.auth.signUp({
    email: email.value,
    password: password.value,
  });
  // TODO: データベースに初期値入れる
};
</script>

ログインも同じ感じで

await nuxtApp.$supabase.auth.signInWithPassword({
  email: email.value,
  password: password.value,
});

を使ってあげれば良いですが、デフォルトでメールアドレス検証しないとログインできないようになっているので注意です(以下のように Authentication > Settings から設定可能)。

Image from Gyazo

今回はシンプルすぎてやってないですが、コンポーネントから直接 supabase にアクセスするより、 composables に認証系メソッドをまとめてそちらを呼び出す方がより筋が良いかもしれません。

認証済み判定

読み込み時にログインしているかどうかを判定します。

Firebase と同様 onAuthStateChange(地味に onAuthStateChanged ではない)がありますが、ページ読み込み時に発火してはくれず、以下のイシューのように読み込み時は supabase.auth.session() を利用するワークアラウンドをよく見ます。

https://github.com/supabase/gotrue-js/issues/78

以下のようなログイン中のユーザー情報を取得するモジュールを作成します。

composables/useAuth.ts
import { Session } from "@supabase/supabase-js";

export const useAuth = () => {
  const nuxtApp = useNuxtApp();

  const currentSession = ref<Session>(null);

  const session = nuxtApp.$supabase.auth.session();
  if (session) {
    currentSession.value = session;
  }

  nuxtApp.$supabase.auth.onAuthStateChange((event, session) => {
    if (event === "SIGNED_IN") {
      currentSession.value = session;
    }
  });

  return {
    currentSession,
  };
};

このモジュールを使って認証済みページにつかうレイアウトを作成します。

layouts/authenticated.vue
<template>
  <div>
    <slot v-if="currentUser" />
    <div v-else>Loading...</div>
  </div>
</template>

<script setup lang="ts">
import { useAuth } from "~/composables/useAuth";

const { currentSession } = useAuth();
const router = useRouter();

const currentUser = ref(null);

onMounted(() => {
  if (!currentSession.value) {
    router.replace("/signin");
    return;
  }
  currentUser.value = currentSession.value.user;
});
</script>

currentSession でそのまま要素を出し分けると Hydration エラーがでるので onMounted で改めて currentUser に入れてそれを使うというワンクッションを置いてます。

これだと認証前提のページはすべて、 SSR では pages の中身を一切表示せず Loading... になります。認証前提のページなので気にしなければそれでいいですが、OGP を表示したいとかサーバーもガワだけは表示してあげたいなどあると思います。その場合はレイアウトで分岐せず、ページ側で対処してあげれば良いです。

トップのステータス設定ページが認証前提なので仕込んでおきます。

pages/index.vue
<script lang="ts">
export default {
  layout: "authenticated",
};
</script>

<script setup lang="ts">
<!---->
</script>
pages/index.vue
<script setup lang="ts">
definePageMeta({
  layout: "authenticated",
});
</script>

Supabase でテーブル作成

認証ができたのでデータベースを作っていきます。

Supabase のダッシュボード Table Editor から user_statuses というテーブルを作成しました。 SQL Editor から SQL でも操作できるので慣れている人はそっちでも良いかもしれません。

id はデフォルトで用意されている auth データベースの users テーブルの id を外部キー設定しています。

Image from Gyazo

username はユニーク制約をつけています。文字数制限などは結局 SQL でやらないといけない?っぽいです。

RLS の設定

以下のようなルールを設定してあげます。

  • 読み取りは誰でもできる
  • 作成・更新・削除は自分自身のみ

Authentication > Policies にある user_statusesNew Policy からポリシーを作成します。ゼロから作成かテンプレートから作成を聞かれるのでテンプレートからにします。もろもろ参考にしながら以下を作成しました。書き込みは全部 auth.uid() = id ですね。

クライアントで登録・取得・更新

実際にデータをあれこれしていきます。基本的に SQL をメソッドチェーンで書いていく感じになります。

初回登録

新規登録時に初期値を入れます。

pages/signup/index.vue
const nuxtApp = useNuxtApp();

const email = ref("");
const password = ref("");
const username = ref("");

const submit = async () => {
  const session = await nuxtApp.$supabase.auth.signUp({
    email: email.value,
    password: password.value,
  });
+ await nuxtApp.$supabase
+   .from("user_statuses")
+   .insert({ id: session.user.id, status: "home", username: username.value });
};

自分の情報を取得

Image from Gyazo

ステータス設定画面で初期値を入れるために、自分の情報を取得します。

pages/index.vue
<template>
  <!-- UI省略 -->
</template>

<script setup lang="ts">
const currentUserStatus = ref(null);
const nuxtApp = useNuxtApp();
const { currentSession } = useAuth();

onMounted(async () => {
  const { data: userStatus } = await nuxtApp.$supabase
    .from("user_statuses")
    .select("*")
    .eq("id", currentSession.value.user.id)
    .single();
  currentUserStatus.value = userStatus;
});
</script>

情報を更新

Image from Gyazo

ラジオボタンの入力に合わせてステータスを更新します。

pages/index.vue
<script setup lang="ts">
const currentUserStatus = ref(null);
const nuxtApp = useNuxtApp();
const { currentSession } = useAuth();

+ const changeStatus = async (status: string) => {
+  const { data: userStatus } = await nuxtApp.$supabase
+    .from("user_statuses")
+    .update({ status })
+    .match({ id: currentSession.value.user.id })
+    .single();
+  currentUserStatus.value = userStatus;
+ };

onMounted(async () => {
  const { data: userStatus } = await nuxtApp.$supabase
    .from("user_statuses")
    .select("*")
    .eq("id", currentSession.value.user.id)
    .single();
  currentUserStatus.value = userStatus;
});
</script>

毎回コンポーネント側で supabase を直呼び出しするのは微妙なので、これも composables とかに便利なラッパー作っておくと良さそうです。

ユーザーページはサーバー側でレンダリング

Image from Gyazo

/ユーザー名 は完全にサーバー側ですべてレンダリングします。

useFetch を使って REST API を叩きます。カラムの値に一致するものを取得したい場合は eq. を使って以下のようになります。

https://プロジェクトURL/rest/v1/テーブル名?select=*&カラム名=eq.値

なお、認証には anon キーを利用します。

pages/[username]/index.vue
<template>
  <div class="container">
    <Title>{{
      userStatuses && userStatuses.length
        ? `${userStatuses[0].username} のステータス`
        : `見つかりません`
    }}</Title>
    <!-- 省略 -->
  </div>
</template>

<script setup lang="ts">
const nuxtApp = useNuxtApp();
const { params } = useRoute();
const { data: userStatuses } = useFetch<string, any[]>(
  `${nuxtApp.$config.SUPABASE_URL}/rest/v1/user_statuses?select=*&username=eq.${params.username}`,
  {
    headers: {
      apikey: nuxtApp.$config.SUPABASE_ANON_KEY,
      Authorization: `Bearer ${nuxtApp.$config.SUPABASE_ANON_KEY}`,
    },
  }
);
</script>

Nuxt3 の新機能のメタコンポーネントである <Title> タグを使うことで、タイトル要素をテンプレート HTML 内に記述できます。その他に <Meta> タグなどあるのでそちらで OGP を設定しています。

これで実装完了です。

Cloudflare Workers にデプロイ

https://v3.nuxtjs.org/docs/deployment/cloudflare

上記に手順が書かれていますが、Cloudflare に登録後、 account_id を取得して以下を作成、

wrangler.toml
name = "好きなアプリ名"
type = "javascript"
account_id = "取得した account_id"
workers_dev = true
route = ""
zone_id = ""

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

[build]
command = "NITRO_PRESET=cloudflare yarn nuxt build"
upload.format = "service-worker"

その後

wrangler publish

とするだけでデプロイできます。とても楽ですね。

気になるスクリプトサイズ

Cloudflare Workers の制限について、前述の記事内のコメントを見る限り、スクリプトサイズは 1MB が上限(申請で 2MB 以上も増やせる?)とのことですが、今回は

✔ Server built
  └─ .output/server/index.mjs (507 kB) (157 kB gzip)
Σ Total size: 507 kB (157 kB gzip)

という結果になりました。 圧縮後のサイズと書いてあった気がするので 157kB のほうですかね🤔ライブラリとか入れていくと簡単に 1MB 超えそうですね...

感想

これを作る前に Firebase を使ったアプリも Cloudflare Workers へデプロイしようとしましたが Firestore を使おうとするとエラーが出ました。今回の組み合わせがうまくいくということは、キャッチコピー通り Firebase の代わりに Supabase を使う人が増えていく予感がしますね。

スクリプトサイズの肌感もちょっとわかって、この構成をする基準をなんとなく知れたのもよかったです。

総じてかなりサクサク開発できてしまうので、とても快適でした。もはやここまで楽になるとエンジニアがどんどん怠惰というかバカになっていくのではと思いましたが、悩む時間が減って考える時間が増えるんだと信じて今後もゆるくキャッチアップしようかと思います。

(ちなみに Zenn は Vue のハイライティングがちょっと残念でしたが、リンクを貼ったら展開されるのが便利でした。総じて使いやすかったです。)

Discussion