Hono Advent Calendar 20249日目の記事です。前回は@Stead08さんによる「HonoとCloudflareのサービスでHeadless CMSを作った」でした。
(この記事は「リアルタイム共同編集を実現できるライブラリLoroを試してみる」の続きです。今回使用するLoroに関してはこちらの記事をご覧ください。)

今日はHonoとCloudflare Durable Objectsを使ってリアルタイムで共同編集できるオンラインホワイトボードを作ってみようと思います。
ホワイトボードはtldrawを使い、共同編集機能の実現にはLoroを使用します。
今回作ったアプリのデモサイトを用意したのでぜひ触ってみてください。(予告なくサイトを閉じる可能性があるのでご了承ください) ソースコードも公開しています。
(ちなみに記事のアイコンはホワイトボードをイメージしました)
ローカルにシンプルなWebsocketサーバーを立てる
送ったものをオウム返しするWebsocketサーバーを立ててみます。
はじめにCloudflare Workersのプロジェクトを作成します。
npm install -g wrangler
wrangler login
wrangler init -y server
cd server
hono
をinstallします。
npm i hono
ドキュメントを参考に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を使うと対処できます。

Durable Objectsはエッジで動くWorkers上の状態を管理するオブジェクトで、IDが同じであれば同一のオブジェクトにアクセスできます。チャットアプリなどでルームIDに対応したオブジェクトを作成できるので、ルーム内の状態を一元管理できます。
ということでDurable Objectsを作っていきます。
まずは wrangler.toml
に以下を追記します。(Doc
となっているところは任意の名前でOKです。)
[durable_objects]
bindings = [{name = "DOC", class_name = "Doc"}]
[[migrations]]
tag = "v1" # Should be unique for each entry
new_classes = ["Doc"]
続いてDocクラスを作ります。
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が生成され、部屋ごとに別々の状態を持つことができます。
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サーバーで確認しましょう。
こんな感じでパスごとに部屋を分けることができました。
Loroを使って状態を管理する
今回はホワイトボードの状態を管理するためにLoroを使用します。 (Loroについては前回の記事をご覧ください)
npm i loro-crdt
LoroDocをDurableObjectで管理して、新規接続時に保存された状態を返すように手を加えます。サンプルコードではDurableObjectsのStorage APIを使って永続化しています。

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 });
}
}
クライアント側の処理は記述量が多いのでリポジトリを参考にしてください。
大まかな処理の流れは以下の画像のような感じです。
毎フレームWebsocketと通信してると大変なことになるので、いい感じにthrottleする工夫が必要です。今回はahook のuseThrottoleFn
を使って、一定間隔で操作を間引きました。
// updateの処理は高頻度で起こるので一定間隔で処理する
const { run: throttledUpdate } = useThrottleFn(
(updated: Record<string, [from: TLRecord, to: TLRecord]>) => {
handleUpdatedObject(updated);
},
{ wait: THROTTLE_INTERVAL },
);
ということで完成したのがこちら。
(一応パスパラメータで部屋を指定できます。)
アクセス数によっては予告なしに止める可能性があるのでご了承ください。
まとめ
今日はHonoとCloudflareのDurableObjectsを使って共同編集できるオンラインホワイトボードを作ってみました。
Websocketでステートフルなアプリケーションが簡単に作れちゃうのでおすすめです。ぜひ触ってみてください。
ではまた次回お会いしましょう。
宣伝
我が家の猫のLGTM画像サイトです。ぜひご自由に使ってください。
