🈂️

SurrealDBとNext.jsでリアルタイムチャットアプリを作ってみた

に公開

https://github.com/chantakan/surrealdb-chat-app

はじめに

最近話題の次世代データベースSurrealDBを使って、リアルタイムチャットアプリケーションを構築してみました。SurrealDBはWebSocketによるリアルタイム機能をネイティブでサポートしており、従来のようにSocket.ioやFirebaseを使わなくてもリアルタイム通信が実現できます。

今回は、Next.js 15 + TypeScript + Tailwind CSS v4の環境で、SurrealDBの特徴的な機能であるLive Queryを活用したチャットアプリを作成しました。

完成イメージ


シンプルなチャットUIで、複数ユーザーがリアルタイムでメッセージをやり取りできます。

SurrealDBの特徴

SurrealDBは2022年に登場した比較的新しいデータベースで、以下のような特徴があります:

  • マルチモデル: リレーショナル、ドキュメント、グラフデータベースの機能を併せ持つ
  • リアルタイム: WebSocket経由でのLive Queryをネイティブサポート
  • ACID準拠: 強整合性を保証
  • スキーマフレキシブル: 動的スキーマとスキーマフルの両方に対応
  • SQL++: 拡張SQLクエリ言語

特にリアルタイム機能は、従来のSocket.ioのような追加ライブラリを必要とせず、データベースレベルで変更を監視してクライアントに通知できる点が魅力的です。

環境構築

1. SurrealDBの起動

Dockerを使用してSurrealDBを起動します:

docker run -d \
  --name surrealdb-dev \
  -p 8000:8000 \
  -v surreal_data:/data \
  docker.io/surrealdb/surrealdb:latest \
  start --log=trace --user=root --pass=root --bind=0.0.0.0:8000 memory

ポイント:

  • メモリモードで起動(開発用)
  • ポート8000でRPC接続を受け付け
  • 認証情報はroot/root

2. Next.jsプロジェクトの作成

npx create-next-app@latest surrealdb-chat-app --typescript --tailwind --eslint
cd surrealdb-chat-app

3. 依存関係のインストール

npm install surrealdb date-fns

実装詳細

データベース接続サービス(lib/surrealdb.ts)

SurrealDBへの接続を管理するサービスクラスを作成します。複数の接続方法を試行する堅牢な接続処理を実装しています:

import { Surreal, RecordId } from 'surrealdb';

export interface ChatMessage {
  id?: RecordId<string> | string;
  username: string;
  message: string;
  timestamp: Date;
  [key: string]: any;
}

class SurrealDBService {
  private db: Surreal;
  private connected: boolean = false;

  constructor() {
    this.db = new Surreal();
  }

  async connect() {
    const connectionConfigs = [
      { url: 'ws://localhost:8000/rpc', type: 'WebSocket' },
      { url: 'http://localhost:8000/rpc', type: 'HTTP' },
      { url: 'ws://127.0.0.1:8000/rpc', type: 'WebSocket' },
      { url: 'http://127.0.0.1:8000/rpc', type: 'HTTP' },
    ];

    const errors: string[] = [];

    for (const config of connectionConfigs) {
      try {
        console.log(`🔄 Trying ${config.type} connection to ${config.url}...`);
        
        this.db = new Surreal();
        await this.db.connect(config.url);
        
        await this.db.signin({
          username: 'root',
          password: 'root'
        });

        await this.db.use({
          namespace: 'chat',
          database: 'realtime'
        });

        this.connected = true;
        await this.initializeSchema();
        return;
      } catch (error) {
        const errorMessage = error instanceof Error ? error.message : String(error);
        errors.push(`${config.url} (${config.type}): ${errorMessage}`);
        continue;
      }
    }

    throw new Error(`All connection attempts failed. Last error: ${errors[errors.length - 1] || 'Unknown'}`);
  }

  private async initializeSchema() {
    try {
      await this.db.query(`
        DEFINE TABLE IF NOT EXISTS messages SCHEMAFULL;
        DEFINE FIELD IF NOT EXISTS username ON messages TYPE string ASSERT $value != NONE;
        DEFINE FIELD IF NOT EXISTS message ON messages TYPE string ASSERT $value != NONE;
        DEFINE FIELD IF NOT EXISTS timestamp ON messages TYPE datetime DEFAULT time::now();
        DEFINE INDEX IF NOT EXISTS messages_timestamp_idx ON messages COLUMNS timestamp;
      `);
      console.log('✅ Messages table schema initialized successfully');
    } catch (error) {
      console.warn('Schema initialization warning:', error);
    }
  }

  async sendMessage(username: string, message: string): Promise<ChatMessage[]> {
    if (!this.connected) {
      throw new Error('Database not connected');
    }

    const result = await this.db.create('messages', {
      username,
      message,
      timestamp: new Date()
    });

    return Array.isArray(result) ? result as ChatMessage[] : [result as ChatMessage];
  }

  async getMessages(limit: number = 50): Promise<ChatMessage[]> {
    if (!this.connected) {
      throw new Error('Database not connected');
    }

    const result = await this.db.select('messages');
    
    if (result && Array.isArray(result)) {
      const messages = result as ChatMessage[];
      return messages
        .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
        .slice(-limit);
    }
    return [];
  }

  // リアルタイムメッセージ監視
  async subscribeToMessages(callback: (message: ChatMessage) => void) {
    if (!this.connected) {
      throw new Error('Database not connected');
    }

    // SurrealDBのLive Queryでリアルタイム更新を監視
    const queryUuid = await this.db.live('messages', (action, result) => {
      if (action === 'CREATE' && result) {
        callback(result as ChatMessage);
      }
    });

    return queryUuid.toString();
  }

  async unsubscribe(queryUuid: string) {
    try {
      await this.db.kill(queryUuid as any);
    } catch (error) {
      console.error('Failed to unsubscribe:', error);
    }
  }

  async disconnect() {
    if (this.connected) {
      await this.db.close();
      this.connected = false;
    }
  }

  isConnected(): boolean {
    return this.connected;
  }
}

export const surrealDB = new SurrealDBService();

ポイント:

  • フォールバック接続: WebSocketとHTTPの両方を試行
  • スキーマ定義: SurrealDBの柔軟なスキーマ機能を活用
  • Live Query: db.live()でリアルタイム更新を監視
  • シングルトンパターン: アプリ全体で接続を共有

チャットコンポーネント(components/Chat.tsx)

React Hooksを使用してチャット機能を実装:

'use client';

import { useState, useEffect, useRef } from 'react';
import { surrealDB, ChatMessage } from '@/lib/surrealdb';

export default function Chat() {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [newMessage, setNewMessage] = useState('');
  const [username, setUsername] = useState('');
  const [isConnected, setIsConnected] = useState(false);
  const [isJoined, setIsJoined] = useState(false);
  const [loading, setLoading] = useState(false);
  const messagesEndRef = useRef<HTMLDivElement>(null);
  const subscriptionRef = useRef<string | null>(null);

  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  };

  // SurrealDB接続の初期化
  useEffect(() => {
    const initConnection = async () => {
      try {
        setLoading(true);
        await surrealDB.connect();
        setIsConnected(true);

        const existingMessages = await surrealDB.getMessages();
        setMessages(existingMessages);
      } catch (error) {
        console.error('Connection error:', error);
        setIsConnected(false);
      } finally {
        setLoading(false);
      }
    };

    initConnection();

    return () => {
      if (subscriptionRef.current) {
        surrealDB.unsubscribe(subscriptionRef.current);
      }
      surrealDB.disconnect();
    };
  }, []);

  useEffect(() => {
    scrollToBottom();
  }, [messages]);

  // チャットに参加
  const joinChat = async () => {
    if (!username.trim() || !isConnected) return;

    try {
      const subscription = await surrealDB.subscribeToMessages((message: ChatMessage) => {
        setMessages(prev => [...prev, message]);
      });
      subscriptionRef.current = subscription;
      setIsJoined(true);
    } catch (error) {
      console.error('Failed to join chat:', error);
    }
  };

  // メッセージ送信
  const sendMessage = async (e: React.FormEvent) => {
    e.preventDefault();
    
    if (!newMessage.trim() || !isJoined || !isConnected) return;

    try {
      await surrealDB.sendMessage(username, newMessage.trim());
      setNewMessage('');
    } catch (error) {
      console.error('Failed to send message:', error);
    }
  };

  // ローディング状態
  if (loading) {
    return (
      <div className="flex items-center justify-center min-h-screen bg-gray-100">
        <div className="text-center">
          <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
          <p className="mt-4 text-gray-600">Connecting to SurrealDB...</p>
        </div>
      </div>
    );
  }

  // 接続エラー状態
  if (!isConnected) {
    return (
      <div className="flex items-center justify-center min-h-screen bg-gray-100">
        <div className="text-center bg-white p-8 rounded-lg shadow-md">
          <div className="text-red-500 text-6xl mb-4">⚠️</div>
          <h2 className="text-2xl font-bold text-gray-800 mb-2">Connection Error</h2>
          <p className="text-gray-600 mb-4">Unable to connect to SurrealDB server.</p>
        </div>
      </div>
    );
  }

  // ユーザー名入力画面
  if (!isJoined) {
    return (
      <div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-blue-500 to-purple-600">
        <div className="bg-white p-8 rounded-lg shadow-xl w-full max-w-md">
          <h1 className="text-3xl font-bold text-gray-800 mb-6 text-center">
            📱 Real-time chat using SurrealDB
          </h1>
          <form onSubmit={(e) => { e.preventDefault(); joinChat(); }}>
            <div className="mb-4">
              <label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
                Please enter your user name
              </label>
              <input
                type="text"
                id="username"
                value={username}
                onChange={(e) => setUsername(e.target.value)}
                className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
                placeholder="Your Name"
                required
              />
            </div>
            <button
              type="submit"
              disabled={!username.trim()}
              className="w-full bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 text-white font-semibold py-2 px-4 rounded-lg transition-colors"
            >
              Join the Chat
            </button>
          </form>
        </div>
      </div>
    );
  }

  // メインチャット画面
  return (
    <div className="flex flex-col h-screen bg-gray-100">
      {/* ヘッダー */}
      <header className="bg-white shadow-sm border-b border-gray-200 px-6 py-4">
        <div className="flex items-center justify-between">
          <h1 className="text-xl font-semibold text-gray-800">
            📱 Real-time chat using SurrealDB
          </h1>
          <div className="flex items-center space-x-4">
            <span className="text-sm text-gray-600">
              Welcome, <span className="font-medium text-blue-600">{username}</span>-san
            </span>
            <div className="flex items-center">
              <div className="w-2 h-2 bg-green-500 rounded-full mr-2"></div>
              <span className="text-sm text-gray-600">Connected</span>
            </div>
          </div>
        </div>
      </header>

      {/* メッセージリスト */}
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.length === 0 ? (
          <div className="text-center text-gray-500 mt-8">
            <p>No messages yet</p>
            <p className="text-sm mt-2">Try sending the first message!</p>
          </div>
        ) : (
          messages.map((message, index) => (
            <div
              key={message.id ? message.id.toString() : `${message.username}-${message.timestamp}-${index}`}
              className={`flex ${
                message.username === username ? 'justify-end' : 'justify-start'
              }`}
            >
              <div
                className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
                  message.username === username
                    ? 'bg-blue-500 text-white'
                    : 'bg-white text-gray-800 shadow-sm'
                }`}
              >
                {message.username !== username && (
                  <p className="text-xs font-medium text-gray-500 mb-1">
                    {message.username}
                  </p>
                )}
                <p className="break-words">{message.message}</p>
                <p
                  className={`text-xs mt-1 ${
                    message.username === username ? 'text-blue-100' : 'text-gray-400'
                  }`}
                >
                  {new Date(message.timestamp).toLocaleTimeString('ja-JP', {
                    hour: '2-digit',
                    minute: '2-digit'
                  })}
                </p>
              </div>
            </div>
          ))
        )}
        <div ref={messagesEndRef} />
      </div>

      {/* メッセージ入力フォーム */}
      <footer className="bg-white border-t border-gray-200 px-4 py-4">
        <form onSubmit={sendMessage} className="flex space-x-2">
          <input
            type="text"
            value={newMessage}
            onChange={(e) => setNewMessage(e.target.value)}
            placeholder="Type your message..."
            className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
          />
          <button
            type="submit"
            disabled={!newMessage.trim()}
            className="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 text-white px-6 py-2 rounded-lg font-medium transition-colors"
          >
            Send
          </button>
        </form>
      </footer>
    </div>
  );
}

Live Queryの仕組み

SurrealDBのLive Queryは、以下のような仕組みでリアルタイム通信を実現します:

// Live Queryの購読
const queryUuid = await this.db.live('messages', (action, result) => {
  if (action === 'CREATE' && result) {
    callback(result as ChatMessage);
  }
});
  1. 購読: db.live()でテーブルの変更を監視開始
  2. 変更検知: データベースでCRUD操作が発生すると自動検知
  3. 通知: WebSocket経由でクライアントにリアルタイム通知
  4. コールバック実行: 登録したコールバック関数でUIを更新

従来のSocket.ioと異なり、データベースレベルで変更を検知するため、複数のアプリケーションサーバーがあってもリアルタイム同期が保たれます。

プロジェクト構成

surrealdb-chat-app/
├── app/
│   ├── globals.css
│   ├── layout.tsx
│   └── page.tsx
├── components/
│   └── Chat.tsx
├── lib/
│   └── surrealdb.ts
├── package.json
└── README.md

実行方法

  1. SurrealDBの起動
docker run -d --name surrealdb-dev -p 8000:8000 surrealdb/surrealdb:latest start --user root --pass root
  1. 開発サーバーの起動
npm run dev
  1. ブラウザでアクセス
http://localhost:3000

動作確認

  1. 複数のブラウザタブでアプリにアクセス
  2. 異なるユーザー名でチャットに参加
  3. メッセージを送信すると、全てのタブでリアルタイムに反映される

まとめ

SurrealDBを使ったリアルタイムチャットアプリを構築してみて、以下の点が印象的でした:

良かった点

  • シンプルなリアルタイム実装: Socket.ioなしでリアルタイム機能を実現
  • 統一されたAPI: データベース操作とリアルタイム通信が同一のクライアントで完結
  • 柔軟なスキーマ: 開発初期段階でのスキーマ変更が容易
  • 強力なクエリ言語: SQLライクな構文で複雑なクエリも記述可能

注意点

  • 新しい技術: まだ情報が少なく、トラブルシューティングが困難な場合がある
  • プロダクション対応: 本格的な運用には十分な検証が必要
  • パフォーマンス: 大規模なリアルタイム処理での性能要件を要確認

SurrealDBは特にリアルタイム機能を必要とするアプリケーション(チャット、コラボレーションツール、ダッシュボードなど)で威力を発揮しそうです。

今後はより複雑な機能(ルーム機能、ファイル共有など)も実装してみたいと思います。

参考資料


🔗 完全なソースコードはこちら

この記事で紹介したすべてのコードとプロジェクト全体は以下のGitHubリポジトリで確認できます:
chantakan/surrealdb-chat-app

クローンしてすぐに動作確認できるよう、セットアップ手順も含めてREADMEに記載しています。

  • git clone https://github.com/chantakan/surrealdb-chat-app.git
  • Dockerでの簡単起動に対応
  • 開発環境の構築手順を詳細に記載

Discussion