Open11

Supabase + Nuxt 3 で リアルタイムチャットを作る(メモ)

redamoonredamoon

チャットアプリ実装の勉強の一環として supabase を使ってNuxt3でチャットアプリを作ったので、それのスクラップ。

作ったのはこんな感じのもの
https://x.com/redamoon/status/1707026699622178879

作ったチャットアプリの構成

  • GitHubでログイン
  • チャットのチャンネルは1つ(複数の場合は、データベースの設計が必要なので今回は1チャンネルのみ)
  • メッセージの投稿
  • メッセージの削除・編集機能

Supabaseとは?

オープンソースのFirebase代替品として作られたクラウドベースのプラットフォームです。
開発者が作るWebアプリケーションに必要なツールやサービスを提供します。

Supabaseでできること

  • オープンソースのリアルタイムデータベース
  • アプリケーションに必要なバックエンドの機能を提供するプラットフォーム
  • PostgreSQLをベースとしたリアルタイムデータベースを提供
    • データの変更をリアルタイムで購読することができる
    • PostgreSQLの機能やエコシステムを活かせるため拡張性が高い
  • ユーザサインアップ・ログイン・認証トークン管理などの一般的な認証機能をサポート
  • ファイルアップアップロード・ダウンロード・管理といったストレージ機能を提供
  • フロントエンドの疎通にはRESTful API や GraphQLを提供するため、独自のAPI構築が不要
    • リアルタイムで購読し、フロントエンドへの反映が可能
  • オープンソースのためGitHubでコードを確認することができる
  • Supabase は完全オープンソースのため、Firebaseよりもカスタマイズ・拡張性が容易

デメリット?

利用シーンに応じてなので、デメリットではないがオープンソースであるためオンプレ環境やクラウド環境でも動作させることができる。
マネージド・サービスを利用しない場合、サーバメンテナンスなどの負荷が増える可能性があります。
セキュリティ部分な背景でFirebaseなどが利用できない場合は、Supabaseの利用できるといった認識でいる。

redamoonredamoon

https://zenn.dev/k_kind/articles/supabase-type-generate
型周りはこれを参考にしてみる

redamoonredamoon

Supabaseの型情報を生成するためには、Supabase CLI で生成可能。

packageインストール

packageに以下をインストールする

npm i supabase  // supabaseをインストール

型情報を生成する前に、アクセストークンを発行して、supabase へ ログインする前にジェネレートしておく。

アクセストークン

supabase login

npx supabase login  // Enter -> アクセストークンを入力する

supabase init

supabase init を実行して、supabase/ディレクトリが生成される。
このときVS Codeのワークスペースが必要な場合は Y にする

→ supabase init
Generate VS Code workspace settings? [y/N] N (VS Code のワークスペースファイルを生成したい場合は Y
Open the supabase-nuxt3-chat.code-workspace file in VS Code.
Finished supabase init.

supabase linkでDBとリンクさせます。
実行時にDBのパスワードが求められる。
Reference ID は 作成したプロジェクトのIDです。

supabase link --project-ref <Reference ID>

ReferenceID

supabase gen types typescript --linked

実行完了が整ったら、該当のリポジトリのルートで supbase コマンドを実行します。
スキーマの型情報は、 types ディレクトリに格納した。

supabase gen types typescript --linked > ./types/schema.ts

schema.tsが生成され、APIからの型情報として利用できます。
GitHub Actionsの例も公式ドキュメントあるため、必要に応じて自動化できる。

redamoonredamoon

認証の実装

Supabaseには、Auth Providers が用意されており、今回は簡易的にGitHubを利用して認証機能の実装した。

実装したuseAuthカスタムフックは以下を提供できるようにした。

  • サインイン
  • サインアウト
  • 認証状態の監視
  • プロフィール情報の取得
import { Session, SupabaseClient } from "@supabase/supabase-js";  
import { onMounted, ref, computed } from "vue";  
  
const useAuth = () => {  
  const nuxtApp = useNuxtApp();  
  const supabase = nuxtApp.$supabase as SupabaseClient;  
  const session = ref<Session | null>(null);  
  const error = ref<string>("");  
  
  onMounted(() => {  
    const { data: authData } = supabase.auth.onAuthStateChange(  
      (_, newSession) => {  
        session.value = newSession;  
      },  
    );  
    return () => {  
      authData.subscription.unsubscribe();  
    };  
  });  
  
  const signInWithGithub = async () => {  
    try {  
      const { error: signInError } = await supabase.auth.signInWithOAuth({  
        provider: "github",  
      });  
      if (signInError) {  
        error.value = signInError.message;  
      }  
    } catch (signInException) {  
      if (signInException instanceof Error) {  
        error.value = signInException.message;  
      } else if (typeof signInException === "string") {  
        error.value = signInException;  
      } else {  
        console.error("GitHubとの連携に失敗しました。");  
      }  
    }  
  };  
  
  const profileFromGithub = computed(() => {  
    return {  
      nickName: session.value?.user?.user_metadata?.user_name || "",  
      avatarUrl: session.value?.user?.user_metadata?.avatar_url || "",  
    };  
  });  
  
  const signOut = async () => {  
    await supabase.auth.signOut();  
  };  
  
  return {  
    session,  
    error,  
    profileFromGithub,  
    signInWithGithub,  
    signOut,  
  };  
};  
  
export default useAuth;

詳細内容

Nuxt・Vueのお作法通り、composableディレクトリを作成しカスタムフックを実装した。
composable/useAuth.ts を作成した。

  1. インポート、必要なライブラリをインストール
  2. カスタムフックの定義: useAuth
  3. 変数の初期化
    1. nuxtApp:Nuxtアプリケーションのインスタンスを取得
    2. sessionとerror:ユーザのセッション情報とエラーメッセージをリアクティブな変数として定義
  4. 認証状態の監視:onMountedフック内で、Supabaseの認証状態変更されたときにセッション情報を更新する設定
  5. GitHubでのサインイン
    1. signInWithGitHub 関数は、ユーザをGitHubのOAuthを使用してサインインする関数
  6. GitHubプロフィール情報を取得
    1. profileFromGitHub は、GitHubのユーザ名とアバターURLを取得するための関数
  7. サインアウト
    1. signOut関数を定義して、ユーザのサインアウトさせる関数
  8. 戻り値
    1. カスタムフックから返す値を定義
  9. エクスポートは、呼び出し側で利用するための設定
redamoonredamoon

DataBase操作の実装

SupabaseのDataBase接続の実装を行っていきます。
schemaは、cliで自動生成した型情報です。

import { ref, onMounted } from "vue";  
import { Database } from "@/types/schema";  
import { SupabaseClient } from "@supabase/supabase-js";  
  
export default function useSupabase() {  
  const nuxtApp = useNuxtApp();  
  const supabase = nuxtApp.$supabase as SupabaseClient;  
  
  const data = ref<Database["public"]["Tables"]["messages"]["Row"][] | null>(  
    null,  
  );  
  
  const TABLE_NAME = "messages";  
  
  const fetchDatabase = async () => {  
    try {  
      const { data: fetchedData } = await supabase  
        .from(TABLE_NAME) // messagesテーブルを指定  
        .select("*") // 全てのカラムを取得  
        .order("createdAt"); // createdAtカラムでソート  
      data.value = fetchedData;  
    } catch (error) {  
      console.error(error);  
    }  
  };  
  
  onMounted(fetchDatabase); // Mounted時にデータをフェッチ  
  
  const deletedSupabaseData = async (id: string) => {  
    try {  
      await supabase.from(TABLE_NAME).delete().match({ id: id }); // 指定したIDの行を削除  
    } catch (error) {  
      console.error(error);  
    }  
  };  
  const editedSupabaseData = async (id: string, text: string) => {  
    try {  
      await supabase.from(TABLE_NAME).update({ message: text }).match({ id }); // 指定したIDの行を更新  
    } catch (error) {  
      console.error(error);  
    }  
  };  
  const addSupabaseData = async ({  
    message,  
    avatarUrl,  
    nickName,  
  }: Pick<  
    Database["public"]["Tables"]["messages"]["Row"],  
    "message" | "nickName" | "avatarUrl"  
  >) => {  
    try {  
      await supabase.from(TABLE_NAME).insert({ message, avatarUrl, nickName });  
    } catch (error) {  
      console.error(error);  
    }  
  };  
  
  return {  
    TABLE_NAME,  
    data,  
    fetchDatabase,  
    addSupabaseData,  
    deletedSupabaseData,  
    editedSupabaseData,  
  };  
}

詳細内容

  1. インポート、必要なライブラリをインストール
  2. カスタムフックの定義: useSupabase
  3. 変数の初期化
    1. nuxtApp:Nuxtアプリケーションのインスタンスを取得
    2. data:データベースから取得したメッセージデータを保持するためのリアクティブな変数
    3. TABLE_NAMEは、supabsaeで作成したテーブル名
  4. fetchDatabases でデータベースからのデータの取得
    1. ログイン後過去のチャットのやり取りを取得するための関数(ログイン後、データセットされる)※本来であれば、全データ取得せずlimitを利用しスクロールに応じてデータを再取得するが今回は未対
  5. deletedSupabaseData でメッセージの削除
    1. 自分自身が投稿したメッセージを削除する機能
  6. editedSupabaseData でメッセージの編集
    1. 自分自身の投稿メッセージを編集する機能
  7. addSupabaseData でメッセージを追加
    1. input textで入力送信で、メッセージを投稿する機能
  8. 戻り値
    1. カスタムフックから返す値を定義
    2. データやデータベースの操作する関数を提供
redamoonredamoon

ChatApp.vueの実装

チャット本体のvueファイルの実装をした。
ここで行うのはログイン・チャットの投稿・編集・削除・リアルタイムで更新される仕組みの実装をした。
リアルタイムの部分はカスタムフックにしても良かったが、手っ取り早くコンポーネント側で実装した。

  • supabase.channelは、Supabaseのリアルタイム機能を使用する際に、特定のデータベーステーブルの変更を受け取るためにチャンネルを設定するメソッド
    • ※リアルタイムのデータベースの変更を取得する必要がない場合・他の方法でリアルタイムのデータベースの変更取得する方法がある場合は不要
  • .on("postgres_changes", {...}, (payload) => {...}): postgres_changes` というイベントは、データベースの変更が検出されたら呼び出される
  • イベントのフィルタリング
    • { event: "*", schema: "public", table: TABLE_NAME } データベースの変更をリアルタイムで取得するかを指定する。publicスキーマのメッセージテーブルのすべてのイベントを取得するようにした。
  • イベントハンドラ
    • INSERT・DELETE・UPDATEに応じて、messagesを追加・変更・削除を行う
  • サブスクリプションの開始
    • .subscribe リアルタイムのデータベースの変更を取得するサブスクリプションが開始される
<script setup lang="ts">
import type { SupabaseClient } from "@supabase/supabase-js";
import { Database } from "@/types/schema";
import { ref, onMounted, watch } from "vue";
import { dateToString } from "@/utils/dateToString";

import useDatabase from "@/composable/useDatabase";
import useAuth from "@/composable/useAuth";

const nuxtApp = useNuxtApp();
const supabase = nuxtApp.$supabase as SupabaseClient;

const scrollAreaRef = ref(null);
const editingMessageId = ref("");
const inputText = ref("");
const messages = ref<Database["public"]["Tables"]["messages"]["Row"][]>([]);
const { session: isLogin, profileFromGithub } = useAuth();
const {
  data,
  fetchDatabase,
  addSupabaseData,
  deletedSupabaseData,
  editedSupabaseData,
  TABLE_NAME,
} = useDatabase();

const router = useRouter();

// ログアウト済みの場合はログインページにリダイレクト
if (!isLogin) router.push("/");

const scrollToBottom = () => {
  if (scrollAreaRef.value) {
    scrollAreaRef.value.scrollTop = scrollAreaRef.value.scrollHeight;
  }
};

const fetchRealtimeData = () => {
  try {
    supabase
      .channel("table_postgres_changes")
      .on(
        "postgres_changes",
        {
          event: "*",
          schema: "public",
          table: TABLE_NAME,
        },
        (payload) => {
          if (payload.eventType === "INSERT") {
            const { createdAt, id, message, avatarUrl, nickName } = payload.new;
            messages.value = [
              ...messages.value,
              { createdAt, id, message, avatarUrl, nickName },
            ];
          }
          if (payload.eventType === "DELETE") {
            const { id } = payload.old;
            messages.value = messages.value.filter((item) => item.id !== id);
          }
          if (payload.eventType === "UPDATE") {
            const { id, message } = payload.new;
            messages.value = messages.value.map((item) =>
              item.id === id ? { ...item, message } : item,
            );
          }
        },
      )
      .subscribe(); // subscribeで初期化作業が必要
  } catch (error) {
    console.error(error);
  }
};

// 初回のみ全データフェッチとリアルタイムリスナー登録
onMounted(async () => {
  await fetchDatabase();
  messages.value = !data.value ? [] : data.value;
  fetchRealtimeData();
  await nextTick();
  scrollToBottom();
});

const onChangeInputText = (event: Event) => {
  const target = event.target as HTMLInputElement;
  inputText.value = target.value;
};

const onSubmitNewMessage = (event: Event) => {
  event.preventDefault();
  if (!inputText.value) return;
  addSupabaseData({ message: inputText.value, ...profileFromGithub.value });
  inputText.value = "";
};

const onMessageDeleted = async (id: string) => {
  await deletedSupabaseData(id);
};

const onMessageEdited = (id: string) => {
  editingMessageId.value = id;
};

const onSaveEditedMessage = async (id: string, newMessage: string) => {
  await editedSupabaseData(id, newMessage); // DBの更新ロジックを実装する
  editingMessageId.value = ""; // 編集モードを終了
};

watch(messages, () => {
  scrollToBottom();
});
</script>
<template>
  <div class="flex flex-col h-screen">
    <div
      ref="scrollAreaRef"
      class="flex-grow overflow-y-auto pt-20 pb-20 px-4 bg-gray-100"
    >
      <div
        class="mt-8"
        v-for="item in messages"
        :key="item.id"
        :data-my-chat="item.nickName === profileFromGithub.nickName"
      >
        <div
          :class="[
            'flex',
            'items-start',
            'mb-2',
            item.nickName !== profileFromGithub.nickName && 'flex-row-reverse',
          ]"
        >
          <div
            :class="[
              'flex-shrink-0',
              item.nickName === profileFromGithub.nickName ? 'mr-3' : 'ml-3',
            ]"
          >
            <a
              :href="`https://github.com/${item.nickName}`"
              target="_blank"
              rel="noopener noreferrer"
            >
              <img
                v-if="item.avatarUrl"
                :src="item.avatarUrl"
                :alt="item.nickName ? item.nickName : '名無し'"
                class="w-10 h-10 rounded-full border"
              />
              <nuxt-img
                v-else
                src="/noimage.png"
                alt="no image"
                class="w-10 h-10 rounded-full border"
              />
            </a>
          </div>
          <div
            class="relative bg-blue-100 text-blue-900 p-3 rounded-lg min-w-[320px]"
          >
            <p class="absolute left-0 -top-4 z-10 text-[10px]">
              {{ item.nickName }}
            </p>
            <div v-if="item.id !== editingMessageId">
              <p>{{ item.message }}</p>
            </div>
            <div v-else>
              <input
                type="text"
                class="border border-gray-400 p-2 rounded w-full"
                v-model="item.message"
                @blur="onSaveEditedMessage(item.id, item.message as string)"
              />
            </div>
            <p class="absolute text-right text-[10px] -bottom-4 right-0">
              {{ dateToString(item.createdAt, "YYYY/MM/DD HH:mm") }}
            </p>
            <button
              class="absolute text-[10px] left-0 -bottom-4"
              @click="onMessageDeleted(item.id)"
              v-if="item.nickName === profileFromGithub.nickName"
            >
              削除
            </button>
            <button
              class="absolute text-[10px] left-7 -bottom-4"
              @click="onMessageEdited(item.id)"
              v-if="item.nickName === profileFromGithub.nickName"
            >
              編集
            </button>
          </div>
        </div>
      </div>
    </div>
    <div class="border-t border-gray-400 bg-gray-200 p-4">
      <form @submit.prevent="onSubmitNewMessage">
        <div class="flex justify-between items-center">
          <div class="w-10/12">
            <input
              type="text"
              name="message"
              v-model="inputText"
              @input="onChangeInputText"
              class="border border-gray-400 w-full p-2 rounded"
              aria-label="新規メッセージを入力"
            />
          </div>
          <div class="w-2/12 ml-5">
            <button
              type="submit"
              :disabled="!inputText"
              class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 border border-blue-700 rounded w-full"
            >
              送信
            </button>
          </div>
        </div>
      </form>
    </div>
  </div>
</template>

詳細内容

  1. インポート、必要なライブラリをインストール
    1. 先に実装したuseDatabase と useAuthをインポート
  2. 変数の初期化
    1. nuxtApp:Nuxtアプリケーションのインスタンスを取得
    2. scrollAreaRef, editingMessageId, inputText, messages: チャットアプリのUIやデータを制御するためのリアクティブな変数を定義
    3. isLoginprofileFromGithub: useAuthフックから取得した認証関連のデータ
    4. data, fetchDatabase, addSupabaseData, deletedSupabaseData, editedSupabaseData, TABLE_NAME: useDatabaseフックから取得したデータベース関連のデータや関数
  3. リダイレクト
    1. ログインしていない場合はログイン画面にリダイレクト
  4. スクロールロジック scrollToBottom
    1. チャットを投稿したあとに、位置情報を最下部に移動するための関数
  5. リアルタイムデータの取得: fetchRealtimeData
    1. Supabaseのリアルタイム機能を利用してデータベースの変更リアルタイムで取得するための関数(コアな部分)
  6. 初期データの取得:onMounted
    1. ページの読み込みタイミングで、データベースのデータを取得
  7. メッセージの操作
    1. onChangeInputText, onSubmitNewMessage, onMessageDeleted, onMessageEdited, onSaveEditedMessage: メッセージの追加、削除、編集を行うための関数を定義しています。
  8. 自動スクロール:watch
    1. Vue 3の watchフックを利用して、メッセージ変数に変更されたらチャットエリアを自動的に最下部にスクロールさせる
redamoonredamoon

マルチチャンネルの構成

  • チャンネル テーブル
  • メッセージ テーブル
CREATE TABLE channels (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    description TEXT
);

CREATE TABLE messages (
    id SERIAL PRIMARY KEY,
    channel_id INTEGER REFERENCES channels(id),
    content TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- チャンネルの作成
INSERT INTO channels (name, description) VALUES ('SampleChannel', 'This is a sample channel.');

-- 上記で作成したチャンネルに関連するメッセージの登録
-- (NOTE: 以下のSQLはchannelsテーブルのidがシーケンシャルに増加することを仮定しています。最後に追加されたchannelのidを使っています)
INSERT INTO messages (channel_id, content) VALUES ((SELECT id FROM channels WHERE name = 'SampleChannel' LIMIT 1), 'Hello, this is the first message.');
INSERT INTO messages (channel_id, content) VALUES ((SELECT id FROM channels WHERE name = 'SampleChannel' LIMIT 1), 'Hello, this is the second message.');
INSERT INTO messages (channel_id, content) VALUES ((SELECT id FROM channels WHERE name = 'SampleChannel' LIMIT 1), 'Hello, this is the third message.');