【爆速で作る】Deno × NextJSのチャットアプリ

はじめに

この記事はサーバーにログが残らない匿名形式のチャットアプリ作成するための記事です。 匿名と言っても、WebSocketを
この記事はWebSocketの使い方を学ぶためのものであり、フレームワーク等の詳しい使い方には触れません。

使う技術・フレームワーク

名前 目的
Deno バックエンド実装
Deno Deploy Denoをデプロイする
NextJS サイトで使うフレームワーク
TailwindCSS フロントエンドのCSS
Vercel サイトのデプロイ

この記事で書くこと

  • DenoでWebSocketを使ったチャットアプリのバックエンドを作る方法
  • バックエンドのコードの解説
  • WebSocketの簡単な説明
  • NextJSで作るシンプルなフロントエンドの書き方

この記事で書かないこと

  • 書くこと以外のほぼ全て

バックエンドの作成

バックエンドを先に作ったほうが後々楽なので、先にバックエンドを作ります。
バックエンド用のフォルダを作って直下にmain.tsファイルを作成して以下のようにする。

import { serve } from "https://deno.land/std@0.191.0/http/server.ts";

const useLog = false;

const clients = new Map<string, WebSocket>();

//接続してる全員にメッセージを送る
const sendMessageToAll = (message: string) => {
  clients.forEach((client) => {
    if (client.readyState === client.OPEN) {
      client.send(message);
    }
  });

  log(JSON.parse(message));
};

function wsHandler(ws: WebSocket, url: URL) {
  //ユーザーにUUIDを振る
  const id = crypto.randomUUID();
  clients.set(id, ws);

  ws.onopen = () => {
    log("connected");
  };
  ws.onmessage = (e) => {
    const payload = JSON.parse(e.data);

    if (payload.type === "message") {
      sendMessageToAll(e.data);
    }
  };
  ws.onclose = () => {
    clients.delete(id);
    log("disconnected");
  };
}

//HttpをWebsocketにUpgradeして
//レスポンスを返す関数
function handler(req: Request) {
  if (req.method != "GET") {
    const err = new Response("Method Not Allowed", {
      status: 405,
      headers: { "Content-Type": "text/plain" },
    });
    return err;
  }

  const url = new URL(req.url);
  //Upgrade
  const { response, socket } = Deno.upgradeWebSocket(req);

  //Websocketの処理
  wsHandler(socket, url);
  return response;
}

//Denoのエントリーポイント
serve(handler, { port: parseInt(Deno.env.get("PORT") ?? "") || 443 });

//開発時はログを表示しないようにするための
//ログのラップ関数
function log(content: string) {
  if (useLog == false) return;
  console.log(content);
}

serve関数がDenoのエントリーポイントで、ここを起点にいろいろな処理をします。
第一引数でhandler関数を呼び出してますが後ほど。
ポート番号は環境変数があればそいつを、なければ443を使うようにしてます。ポート番号443はwssプロトコルでのデフォルトらしいです。参考

handler関数は主にリクエストを捌く部分です。
まず、GET以外のメソッドは弾いて405を返すようにしてます。
そしてプロトコルがwsかwssじゃなければ400を返します。
GETメソッドなら、HTTP通信をWebSocketにUpgradeします。 Upgradeとは、HTTP通信のリクエストとレスポンスが1対1となっている状態から、WebSocket通信という双方向にデータを送受信できる状態にすることです。 UpgradeしたらwsHandlerを呼びます。

wsHandlerはWebSocketクライアントの追加と、各イベントに対応する処理を書きます。 最初にUUIDを生成してユーザーに割り振って、クライアントのリストに追加します。 IDをクライアントのリストに追加するのは、一気にデータを送信するときに使うためです。 また、UUIDは2128通りあるので多分重複しないです。この確率のお陰でチェックする手間が省けます。気になる人は重複がないかチェックする処理書いてください。

ws.onopenはクライアントがWebSocketを開始したときに呼ばれます。今回はログを出すだけで特に処理はしてないです。

ws.onmessageは接続されてるクライアントからデータを受信したときに呼ばれます。今回は受信したデータのtypeがmessageのときにsendMessageToAllを読んで、接続されてるクライアント全員にメッセージを送っています。送ったメッセージをログとして出力してます。ちなみに、接続されてるクライアントには自分も含まれています。

ws.oncloseはクライアントの接続が切れたときに呼ばれます。今回はクライアントのリストから接続されたクライアントのIDを削除して切断された旨のログを出してます。

ここまでできたらOK。
Deno Deployにデプロイします。 やり方は調べてください。先人方のわかりやすい記事がたくさんあります。 デプロイするとき、エントリーポイントをmain.tsにしてください。

フロントエンドの作成

プロジェクトを作成

npx create-next-app chat-app --use-npm --typescript

プロジェクト作成のオプションは以下のとおりです。お好みの場合、()内はおすすめを書いてます。

  • ESLint → お好み(TypeScriptのフォーマットをやってくれるので基本はYesがおすすめです)
  • Tailwind CSS → Yes
  • src directory → お好み(ディレクトリを読み替えるのがめんどくさい場合はNoがおすすめです)
  • App Router → お好み(ディレクトリを読み替えるのがめんどくさい場合はNoがおすすめです)
  • import alias → No

プロジェクトフォルダに移動

cd chat-app

プロジェクトを立ち上げる。 デフォルトだとローカルホストのポートは3000、http://localhost:3000で開くことができる。

npm run dev

以下の画面が出たら成功です。

ここまでできたら爆速でフロントエンドを組み立てて行きましょう。

import { useEffect, useState } from "react";

let messagesArr: any[] = [];
const author = randomString();

//ランダムなユーザーIDを生成するための関数
function randomString(): String {
  let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
  let name = "";
  for (var i = 0; i < 8; i++) {
    name += chars.charAt(Math.floor(Math.random() * chars.length));
  }

  return name;
}

export default function Home() {
  const [socket, setSocket] = useState<WebSocket | null>(null);
  const [messages, setMessages] = useState<any[]>([]);
  const url = process.env.NEXT_PUBLIC_API_SERVER as string;

  useEffect(() => {
    setSocket((socket: WebSocket | null) => {
      // 既に入ってたら新しくsocketを作成しない
      // これをしないと2回以上接続するという変なことになってしまう
      if (socket) return socket;
      return new WebSocket(url);
    });
  }, [setSocket]);

  useEffect(() => {
    if (socket) {
      // WebSocketからメッセージを受け取った時の処理
      socket.onmessage = (res: MessageEvent) => {
        const payload = JSON.parse(res.data);
        messagesArr = [...messagesArr, payload];
        setMessages(messagesArr);
      };
    }
  }, [socket]);

  // メッセージを送信する処理
  const sendMessage = (content: string) => {
    const packet = {
      type: "message",
      data: {
        author: author,
        text: content,
        ts: Date.now(),
      },
    };

    socket?.send(JSON.stringify(packet));
  };

  function sendMessageWrapper() {
    const field = document.querySelector("#message") as HTMLInputElement;
    if (field == undefined) return;
    const message = field?.value as string;

    if (message == "") return;
    sendMessage(message);
    field.value = "";
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-between bg-gray-900">
      <div className="overflow-y-scroll overscroll-contain">
        {messages &&
          messages
            .filter((x) => x.type == "message")
            .map((x) => {
              return (
                <div
                  className="m-2 h-full w-96 bg-white px-4 py-2"
                  key={x.data.ts}
                >
                  <div>
                    <p className="text-md justify-items-start text-black">
                      ID : {x.data.author}
                    </p>
                    <p className="text-2xl text-black">{x.data.text}</p>
                  </div>
                </div>
              );
            })}
      </div>
      <div className="sticky bottom-0 flex justify-between">
        <input
          type="text"
          id="message"
          className="w-80 mx-6 text-black md:w-96"
          placeholder="Enter your message"
        ></input>
        <button
          className="w-16 p-2 bg-white text-black"
          onClick={sendMessageWrapper}
        >
          Send
        </button>
      </div>
    </main>
  );
}

Reactにまつわるオマジナイは調べてください。useEffectとか。先人方の(以下略)。

クライアント側では、WebSocketをURL Sendボタンを押したら、sendMessageWrapperがsendMessageを呼んで、その中でWebSocketで送るオブジェクトをJSONにして送信します。あとはバックエンドがよしなに通信してクライアントにメッセージが送られます。

ここまでできたらOK。
Vercelにデプロイします。

Vercelの環境変数、NEXT_PUBLIC_API_SERVERにDeno DeployにデプロイしたURLを貼り付けるのを忘れないでください。 URLの初めにws://かwss://にしないとプロトコルが不正で通信できないし、WebSocketのインスタンス生成するところで死にます。 例 : wss://sample.deno.dev

以上で完成です。お疲れ様でした!

なにか質問・ツッコミがあればコチラまでどうぞ。DMでもリプライでも分かるところまで返します。

参考

【受験生が作る】リアルタイム性を重視した新感覚チャットアプリ「のーろぐちゃっと」 | Next.js + WebSocket