🔥

HonoのWebsocketを使用して簡易チャット機能を作ってみた

2025/01/05に公開

はじめに

お正月休みで時間があるのでHonoの学習がてらWebsocketを使って簡易なチャットを作って遊んでみました。(とても簡易な実装です)

準備するもの

  • bun
  • nodejs

基本的にクライアント側はbun create vite client --template react-tsで作成し、
server側はbun create hono@latest serverで作成しています(Typescriptなどの各種設定は各自で〜)

ディレクトリ構成

client

./src
├── App.css
├── App.tsx
├── assets
│   └── react.svg
├── index.css
├── main.tsx
└── vite-env.d.ts

server

./src
└── index.ts

実装まわり

Client

// App.tsx
import { useEffect, useRef, useState } from "react";
import "./App.css";

interface Message {
  id: string;
  text: string;
}

function App() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [inputText, changeInputText] = useState("");
  const [isEditMode, changeEditMode] = useState(false);
  const edittingMessageId = useRef<string>();

  useEffect(() => {
    const websocket = new WebSocket("ws://localhost:3000/ws");
    const getMessages = async () => {
      const res = await fetch("http://localhost:3000/messages");

      const jsonData = await res.json();

      setMessages(jsonData.messages);
    };
    getMessages();

    websocket.onmessage = (event) => {
      const parsedData = JSON.parse(event.data);
      if (parsedData.eventType === "CREATE") {
        setMessages((prev) => [parsedData.message, ...prev]);
        return;
      }
      if (parsedData.eventType === "UPDATE") {
        setMessages((prev) =>
          prev.map((msg) => {
            if (parsedData.message.id === msg.id) {
              return parsedData.message;
            }
            return msg;
          })
        );
        return;
      }
      if (parsedData.eventType === "DELETE") {
        setMessages((prev) =>
          prev.filter((msg) => parsedData.messageId !== msg.id)
        );
        return;
      }
    };
    websocket.onclose = (event) => {
      console.log("WebSocket client closed", event);
    };

    return () => {
      websocket.close();
    };
  }, []);

  const submit = async () => {
    if (inputText !== "") {
      await fetch("http://localhost:3000/messages", {
        method: "POST",
        body: JSON.stringify({
          message: inputText,
        }),
      });
    }
    changeInputText("");
  };

  const updateMessage = async () => {
    if (inputText !== "" && edittingMessageId) {
      await fetch(
        `http://localhost:3000/messages/${edittingMessageId.current}`,
        {
          method: "PATCH",
          body: JSON.stringify({
            message: inputText,
          }),
        }
      );
    }
    changeEditMode(false);
    changeInputText("");
  };

  const deleteMessage = async (id: string) => {
    await fetch(`http://localhost:3000/messages/${id}`, {
      method: "DELETE",
    });
  };

  return (
    <div className="App">
      <div>
        <h1>Messages</h1>
        <ul>
          {messages.length === 0 ? (
            <div>No messages</div>
          ) : (
            messages.map((message) => (
              <li key={message.id}>
                {message.text}
                <button
                  onClick={(e) => {
                    e.preventDefault();
                    changeEditMode(true);
                    edittingMessageId.current = message.id;
                    changeInputText(message.text);
                  }}
                >
                  edit
                </button>
                <button
                  onClick={(e) => {
                    e.preventDefault();
                    deleteMessage(message.id);
                  }}
                >
                  delete
                </button>
              </li>
            ))
          )}
        </ul>
        <div>
          <input
            value={inputText}
            onChange={(e) => changeInputText(e.target.value)}
          />
          <button onClick={isEditMode ? updateMessage : submit}>
            {isEditMode ? "update" : "send"}
          </button>
        </div>
      </div>
    </div>
  );
}

export default App;

Server

// index.ts
import { Hono } from "hono";
import { createBunWebSocket } from "hono/bun";
import type { ServerWebSocket } from "bun";

import { cors } from "hono/cors";
const { upgradeWebSocket, websocket } = createBunWebSocket<ServerWebSocket>();

const chatRoom = "chat-room";

interface Message {
  id: string;
  text: string;
}

type EventType = "CREATE" | "UPDATE" | "DELETE";

interface ResponseData {
  eventType: EventType;
  message?: Message;
  messageId?: string;
}

let messages: Message[] = [];

const app = new Hono();

app.use(
  "*",
  cors({
    origin: ["http://localhost:5173"],
  })
);

app.get("/", (c) => {
  return c.text("Hello!");
});

app.get("/messages", (c) => {
  return c.json({ messages });
});

app.post("/messages", async (c) => {
  const uuid = crypto.randomUUID();
  const { message } = await c.req.json();
  const newMessage = {
    id: uuid,
    text: message,
  };

  messages.unshift(newMessage);

  const data: ResponseData = {
    eventType: "CREATE",
    message: newMessage,
  };
  server.publish(chatRoom, JSON.stringify({ ...data }));
  return c.text("OK");
});

app.patch("/messages/:id", async (c) => {
  const id = c.req.param("id");
  const { message } = await c.req.json();
  const updateMessage = {
    id,
    text: message,
  };

  messages = messages.map((msg) => {
    if (msg.id === id) {
      return updateMessage;
    }

    return msg;
  });

  const data: ResponseData = {
    eventType: "UPDATE",
    message: updateMessage,
  };
  server.publish(chatRoom, JSON.stringify({ ...data }));
  return c.text("OK");
});

app.delete("/messages/:id", async (c) => {
  const id = c.req.param("id");
  messages = messages.filter((msg) => msg.id !== id);
  const data: ResponseData = {
    eventType: "DELETE",
    messageId: id,
  };
  server.publish(chatRoom, JSON.stringify({ ...data }));
  return c.text("OK");
});

app.get(
  "/ws",
  upgradeWebSocket((c) => {
    return {
      onOpen: (_event, ws) => {
        ws.raw?.subscribe(chatRoom);
      },
      onClose: (_event, ws) => {
        ws.raw?.unsubscribe(chatRoom);
      },
    };
  })
);

export const server = Bun.serve({
  fetch: app.fetch,
  websocket,
});

export default app;

解説

Pub/Sub機能を使って同一のチャットルームにいるユーザにチャットが届くようにしています。
Websocketでコネクションを張った(onOpen)"chat-room"にSubscribeし、POST,UPDATE
,DELETEリクエストが送信された際にmessages変数を操作しclientにpublishしています。

Discussion