mooriii's blog

article icon

Hono×DurableObjectsでオンラインホワイトボードを作る

Hono Advent Calendar 20249日目の記事です。前回は@Stead08さんによる「HonoとCloudflareのサービスでHeadless CMSを作った」でした。

(この記事は「リアルタイム共同編集を実現できるライブラリLoroを試してみる」の続きです。今回使用するLoroに関してはこちらの記事をご覧ください。)

リアルタイム共同編集を実現できるライブラリLoroを試してみる - mooriii's blogCRDTというデータ構造を用いた共同編集機能を実現するライブラリの新たな選択肢Loroを試してみました
favicon of https://blog.mooriii.com/entry/real-time-edit-loroblog.mooriii.com
ogp of https://blog.mooriii.com/ogps/real-time-edit-loro.png

今日はHonoCloudflare Durable Objectsを使ってリアルタイムで共同編集できるオンラインホワイトボードを作ってみようと思います。

ホワイトボードはtldrawを使い、共同編集機能の実現にはLoroを使用します。

今回作ったアプリのデモサイトを用意したのでぜひ触ってみてください。(予告なくサイトを閉じる可能性があるのでご了承ください) ソースコードも公開しています。

GitHub - mr04vv/loro-tldraw-durable-objectsContribute to mr04vv/loro-tldraw-durable-objects development by creating an account on GitHub.
favicon of https://github.com/mr04vv/loro-tldraw-durable-objectsgithub.com
ogp of https://opengraph.githubassets.com/015a28505013e5ac3032aa65947660a3fc04650ff99f9f8bfa26392266497394/mr04vv/loro-tldraw-durable-objects

(ちなみに記事のアイコンはホワイトボードをイメージしました)

ローカルにシンプルなWebsocketサーバーを立てる

送ったものをオウム返しするWebsocketサーバーを立ててみます。

はじめにCloudflare Workersのプロジェクトを作成します。

npm install -g wrangler
wrangler login
wrangler init -y server
cd server

hono をinstallします。

npm i hono

ドキュメントを参考にsrc/index.ts を以下に書きかえます。

src/index.ts
import { Hono } from "hono";
 
const app = new Hono();
 
app.get("/ws", async (c) => {
  const upgradeHeader = c.req.header("Upgrade");
  if (!upgradeHeader || upgradeHeader !== "websocket") {
    return new Response("Expected Upgrade: websocket", { status: 426 });
  }
 
  const webSocketPair = new WebSocketPair();
  const [client, server] = Object.values(webSocketPair);
 
  server.accept();
  server.addEventListener("message", (event) => {
    server.send(event.data);
  });
 
  return new Response(null, {
    status: 101,
    webSocket: client,
  });
});
 
export default app;

ここまでで一度devサーバーを立ち上げてwscatコマンドでアクセスしてみましょう。

npm run dev
 wscat -c ws://localhost:8787
Connected (press CTRL+C to quit)
> こんにちは
< こんにちは

オウム返しされましたね。

他のクライアントにブロードキャストする

このままでは自分が送ったメッセージしか返ってこないので、他に接続しているクライアントにもメッセージを返せるようにしてみましょう。

Cloudflare Workersはリクエストごとに別のプロセスでサーバーが起動します。そのため接続情報をglobalに持つことができません

const servers: WebSocket[] = [] // 接続情報を管理するグローバル変数
app.get("/ws", async (c) => {
  // 中略
  const webSocketPair = new WebSocketPair();
  const [client, server] = Object.values(webSocketPair);
  // ↓こういうことができない
  servers.push(server);
  server.addEventListener("message", (event) => {
    for (const _server of servers) {
      if (_server !== server) {
        // 送信元以外にブロードキャスト
        client
        _server.send(event.data);
      }
    }
  });
})

これはDurable Objectsを使うと対処できます。

Cloudflare Durable Objects · Cloudflare Durable Objects docsDurable Objects provide a powerful compute API for coordinating multiple clients or users. Each Durable Object has private, transactional and strongly consistent storage attached.
favicon of https://developers.cloudflare.com/durable-objects/developers.cloudflare.com
ogp of https://developers.cloudflare.com/cf-twitter-card.png

Durable Objectsはエッジで動くWorkers上の状態を管理するオブジェクトで、IDが同じであれば同一のオブジェクトにアクセスできます。チャットアプリなどでルームIDに対応したオブジェクトを作成できるので、ルーム内の状態を一元管理できます。

ということでDurable Objectsを作っていきます。

まずは wrangler.toml に以下を追記します。(Docとなっているところは任意の名前でOKです。)

wrangler.toml
[durable_objects]
bindings = [{name = "DOC", class_name = "Doc"}]
 
[[migrations]]
tag = "v1" # Should be unique for each entry
new_classes = ["Doc"]

続いてDocクラスを作ります。

doc.ts
import { DurableObject } from "cloudflare:workers";
 
export class Doc extends DurableObject {
  // websocketの接続情報を管理
  connections: Set<WebSocket>;
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    this.connections = new Set<WebSocket>();
  }
 
  async fetch() {
    const webSocketPair = new WebSocketPair();
    const [client, server] = Object.values(webSocketPair);
 
    this.connections.add(server);
 
    server.accept();
    server.addEventListener("message", (event) => {
      for (const conn of this.connections) {
        // 送信元以外にブロードキャスト
        if (conn === server) continue;
        conn.send(event.data);
      }
    });
    return new Response(null, { status: 101, webSocket: client });
  }
}

index.ts でDocを使うように手を加えます。この時エントリーポイントにDurable Objectsを直接書くか、exportする記述が無いとエラーになるので気をつけてください。

20行目の idFromName() でroomIdごとに異なるDurableObjectsが生成され、部屋ごとに別々の状態を持つことができます。

src/index.ts
import { Hono } from "hono";
// エントリーポイントにDurableObjectsを直接書くか、exportしないとダメ
export { Doc } from "./doc";
 
type Env = {
  Bindings: {
    DOC: DurableObjectNamespace;
  };
};
const app = new Hono<Env>();
 
app.get("/ws/:roomId", async (c) => {
  const upgradeHeader = c.req.header("Upgrade");
  if (!upgradeHeader || upgradeHeader !== "websocket") {
    return new Response("Expected Upgrade: websocket", { status: 426 });
  }
 
  const { roomId } = c.req.param();
  // これでroomIdごとに異なるDurable Objectsが生成される
  const id = c.env.DOC.idFromName(roomId);
  const obj = c.env.DOC.get(id);
 
  return obj.fetch(c.req.raw);
});
 
export default app;

ここまで来たら一度devサーバーで確認しましょう。

こんな感じでパスごとに部屋を分けることができました。

websocketで部屋ごとにメッセージを送り分けている様子

Loroを使って状態を管理する

今回はホワイトボードの状態を管理するためにLoroを使用します。 (Loroについては前回の記事をご覧ください)

npm i loro-crdt

LoroDocをDurableObjectで管理して、新規接続時に保存された状態を返すように手を加えます。サンプルコードではDurableObjectsのStorage APIを使って永続化しています。

Durable Object Storage · Cloudflare Durable Objects docsThe Durable Object Storage API allows Durable Objects to access transactional and strongly consistent storage. A Durable Object&#39;s attached storage is private to its unique instance and cannot be accessed by other objects.
favicon of https://developers.cloudflare.com/durable-objects/api/storage-api/developers.cloudflare.com
ogp of https://developers.cloudflare.com/cf-twitter-card.png
doc.ts
import { DurableObject } from "cloudflare:workers";
import { LoroDoc } from "loro-crdt";
 
export class Doc extends DurableObject {
  // websocketの接続情報を管理
  connections: Set<WebSocket>;
  doc: LoroDoc;
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    this.connections = new Set<WebSocket>();
    this.doc = new LoroDoc();
  }
 
  async fetch() {
    const webSocketPair = new WebSocketPair();
    const [client, server] = Object.values(webSocketPair);
 
    this.connections.add(server);
 
    server.accept();
    server.addEventListener("message", (event) => {
      const message = event.data as ArrayBuffer;
 
      const array = new Uint8Array(message);
      for (const conn of this.connections) {
         const data = array.slice(1);
         this.doc.import(data);
         if (conn === server) continue;
         // 送信元以外にブロードキャスト
         conn.send(data);
      }
    });
 
    // 保存された状態からsnapshotを取得
    const snapshot = this.doc.export({ mode: "snapshot" });
    server.send(snapshot);
    return new Response(null, { status: 101, webSocket: client });
  }
}

クライアント側の処理は記述量が多いのでリポジトリを参考にしてください。

loro-tldraw-durable-objects/apps/client at main · mr04vv/loro-tldraw-durable-objectsContribute to mr04vv/loro-tldraw-durable-objects development by creating an account on GitHub.
favicon of https://github.com/mr04vv/loro-tldraw-durable-objects/tree/main/apps/clientgithub.com
ogp of https://opengraph.githubassets.com/015a28505013e5ac3032aa65947660a3fc04650ff99f9f8bfa26392266497394/mr04vv/loro-tldraw-durable-objects

大まかな処理の流れは以下の画像のような感じです。

ボード操作時の処理の流れ
新規クライアント接続時の処理の流れ

毎フレームWebsocketと通信してると大変なことになるので、いい感じにthrottleする工夫が必要です。今回はahookuseThrottoleFnを使って、一定間隔で操作を間引きました。

App.tsx
// updateの処理は高頻度で起こるので一定間隔で処理する
const { run: throttledUpdate } = useThrottleFn(
  (updated: Record<string, [from: TLRecord, to: TLRecord]>) => {
    handleUpdatedObject(updated);
  },
  { wait: THROTTLE_INTERVAL },
);

ということで完成したのがこちら

(一応パスパラメータで部屋を指定できます。)

アクセス数によっては予告なしに止める可能性があるのでご了承ください。

まとめ

今日はHonoとCloudflareのDurableObjectsを使って共同編集できるオンラインホワイトボードを作ってみました。

Websocketでステートフルなアプリケーションが簡単に作れちゃうのでおすすめです。ぜひ触ってみてください。

ではまた次回お会いしましょう。

LGTM

宣伝

我が家の猫のLGTM画像サイトです。ぜひご自由に使ってください。

Lgtlatte - らてのLGTM画像飼い猫らてのLGTM画像を集めました
favicon of https://lgtlatte.mooriii.com/lgtlatte.mooriii.com
ogp of https://images.ctfassets.net/rd8mwctho8md/CvOcilcY3CGMuYLJPqxGL/4866b02e655a7d52c1fb19afcf9eb4c1/ogp.png
この記事をシェアするx icon
アイコン画像
Takuto Mori@_mooriii

Wevoxというサービスのフロントエンジニアをしています。趣味は猫を眺めることです🐱