🧱

みんなで粘土をこねる web アプリを hono で作った

2024/06/27に公開4

はじめに

リアルタイムでみんなの反応が見れるアプリ、作りたいですよね。
X や instagram など各種 SNS では、何かアクションがあれば通知に数字がつきます。
Slack や Chatwork でも同じですね。メンションされたらチャンネルに数字がつきます。

モンスターハンターは凄まじいです。
最大4名のプレイヤーで同じモンスターを狩るのですが、どこに誰がいるかリアルタイムで確認できます。
ディアボロスに追いかけ回されながら、半泣きで逃げ回っているとき、共闘しているはずのプレイヤーがホームから動かなかったり、明らかに素材集めに勤しんでいたらx意が湧きます。

このようなリアルタイム通信を実現するために、どのような方法があるか探したところ

  • WebSocket
  • WebRTC
  • SSE(Server-Send Events)

というものが見つかりました。

WebSocket を試したい

本当は WebRTC を試してみたかったのですが、自前で STUN サーバを構築することができず諦めました。
機会があれば時雨堂が提供されている WebRTC SFU Sora を触ってみたいですね。

hono の SSE のサンプルを見たのですが、なんらかの stream が開いているときにサーバからデータ受信できるもの?のようなイメージがあり、開始するタイミングを統一できない要件で SSE を使うことがイメージできず諦めました。

というわけで消極的な消去法なのですが WebSocket を使うことに決めました。

どのようなアプリを作ったか?

Cloudflare や hono という名前から自分も「火」に関係する名前のものが作りたくて VOYA というアプリを考えていました。
みんなで家屋に火をつけて、火事になったところには「その VOYA いいね」というリアクションをつけるものを考えていました。
シェーダーをいじっているとたまたま粘土をこねるような感じのものができたので、みんなで粘土こねたら面白いかもしれないと考えました。

今考えると「出落ちだし悪趣味だな」と思います。
やめてよかったな……

粘土っぽいという意味で「Clayish」と名前をつけました。
配色に悩んでいましたが、同僚のデザイナー minami さん に相談したところとてもかわいい配色を教えてくれました。

同僚にも遊んでもらったのですが、紫式部の横顔だそうです。
しぶいですね〜〜〜〜

Cloudflare を使う

Cloudflare、最近とても目にします。
Cloudflare は R2(storage) や D1(DB) と KV がついた Cloud Function です。
他にも Queue や AI 機能もありますね。
なんといっても安い!
Workers 有料プランであれば5ドルで利用できます。

EC2, ECS のようなサービスは、ある程度サーバ知識があればとりあえず動くものを作ることはできます。
しかしクラウドベンダーのやり方に沿った設計にするために、アクセス数を予想し、サーバスペックを見積もってオプションの選定など高度な知識が求められます。
他にも、サーバの更新などドメイン外の作業が多いです。
またオートスケーリングがあるといっても閾値の設定や一瞬のアクセスに耐えるような設定ができるようになるには、負荷テストの結果を見て地道に計算する必要があります。

今回のアプリでそこまで考える必要はないですが、Cloudflare Worker は以下の点が魅力的でした。

  • 数百か所のデータセンターへ自動でグローバルデプロイメント
  • 自動スケール機能のあるメンテナンス不要のインフラストラクチャ
  • コールドスタートなしの高パフォーマンスのランタイム

参考

hono を使う

できるだけネイティブな機能を使うようにデザインされているところが素敵です。
例えば Request にどのようなプロパティがあるか framework ごとに特色があり「頼むから普通に書かせてくれ」と頭を抱えることがあります。
「新しいモノはすきだけど不変的なことに新しさを入れたくない」と考えているのですが hono のアーキテクチャに共感できることが多くあり、さわっていてとても楽しい気持ちになれました。

また vite と組み合わせることでサーバサイドでもクライアントサイドでも jsx が動き、React Like な hooks もあり、インタラクティブなアプリも作れます。

できるだけ SSG にするために必要な機能も揃っていますし、集計データなどあるタイミングで収集されたデータを格納する KV と組み合わせることでコストを抑えた設計ができるようになると思います。

Clayish

作ったものを紹介します。

DurableObjects で WebSocket を管理しているのですが、ほぼ毎フレーム配信される設計なので、公開するのが怖くなりました。
こちら を確認する限り請求が跳ね上がることはないと思いますが、今はローカルで動かしています。

この画像を見てください。

下に4つのメニューボタンがあります。

左から

  • オブジェクトを回転させる
  • 粘土を引っ張る
  • 粘土を引っ込める
  • 色とブラシサイズを変更する

という機能があります。

この内

  • 粘土を引っ張る
  • 粘土を引っ込める

というイベントが発生するたびに WebSocket で配信され別の人が開いている粘土にも同じ操作が反映されます。

WebSocket の実装について

リクエストをまたいで WebSocket のインスタンスを保持するために DurableObjects を使用します。
JavaScript のオブジェクトがそのまま共有できるので、非常に使いやすいですが、少し実装にコツが要ります。

1. wrangler.toml を設定する

DurableObjects の実装は class を使用します。
Cloudflare worker の設定を記述する wrangler.toml に以下の内容を追加します。

[[durable_objects.bindings]]
class_name = "WebSocketConnection"
name = "WEBSOCKET"

[[migrations]]
new_classes = ["WebSocketConnection"]
tag = "v1" # Should be unique for each entry

class_name = "WebSocketConnection" は、実際のJavaScriptクラス名と紐付きます。
worker内のコードから c.env.WEBSOCKET を通じてDurableObjectsにアクセスできるよう、name = "WEBSOCKET" と設定します。

2. Env の設定

Hono のインスタンス内で WEBSOCKET にアクセスするために型を定義します。

import type { DurableObjectNamespace } from '@cloudflare/workers-types'

type Env = {
  Bindings: {
    WEBSOCKET: DurableObjectNamespace
  }
}

3. DurableObjects を定義

DurableObjects の実装です。
interface はできるだけ書きたい派ですので書いてますが、なくても動きます。
tsconfig.json"types": ["@cloudflare/workers-types"] と書いているからか、DurableObject interface を import しなくても使えます。
むしろ import type { DurableObject } from '@cloudflare/workers-types' と書くとなぜかエラーになります。

export class WebSocketConnection implements DurableObject {
  private readonly sessions = new Set<WebSocket>()

  async fetch(request: Request) {
    if (request.headers.get('Upgrade') !== 'websocket') {
      return new Response('Expected WebSocket', { status: 426 })
    }

    const pair = new WebSocketPair()
    const [client, server] = Object.values(pair)

    await this.handleSession(server as WebSocket)

    return new Response(null, {
      status: 101,
      webSocket: client,
    })
  }

  private async handleSession(webSocket: WebSocket): Promise<void> {
    (webSocket as any).accept()
    this.sessions.add(webSocket)

    webSocket.addEventListener('message', async (event: MessageEvent) => {
      this.sessions.forEach((session) => session.readyState === WebSocket.OPEN && session.send(event.data))
    })

    webSocket.addEventListener('close', async (event: CloseEvent) => {
      this.sessions.delete(webSocket)
    })
  }
}

request のたびに fetch が呼ばれます。
WebSocket の型がうまく使えていないのか webSocket.accept() と書くとエラーになります。
敗北感はありますが any キャストを行いました。

Worker の実装

後は Worker を実装しました。
hono には upgradeWebSocket という helper があるのですが、自分のやり方が悪いのかうまく動かすことができず、以下のように実装しました。

const app = new Hono<Env>()
  .use('/*', serveStatic({ root: './', manifest: {} }))
  // @ts-ignore
  .get('/ws', c => {
    const upgradeHeader = c.req.header('Upgrade')
    if (upgradeHeader !== 'websocket') {
      return c.text('Expected WebSocket', 426)
    }

    const id = c.env.WEBSOCKET.idFromName('websocket')
    const connection = c.env.WEBSOCKET.get(id)
    // @ts-ignore
    return connection.fetch(c.req.raw)
  })

.get('/ws', c => { で敗北し return connection.fetch(c.req.raw) でも敗北しています。
「僕のアプリは @ts-ignore つけるとうごくんだなぁ」と思いました。

他のサンプルを見ると connection.fetch(c.req.raw) で動いているのですが…verが違うからでしょうか…

こちらのサンプルはとてもわかりやすく簡潔に WebSocket が実装されており大変参考になりました。

client

当初は client も hono で書いていたのですが vite サーバを介した hono で WebSocket を動かそうとするとなぜか動かなく client は別にすることにしました。
ほぼ client が完成していたのでできるだけ hono の jsx に近いものを探しました。
hono の jsx が use* なので react にしようかとも思いましたが hono は仮想DOM ではないとどこかで見た記憶があり、同じリアルな DOM を操作する solid にしました。

styling は css helper を使用していたので、その書き方に近い solid-styled-components を使いました。

基本的に RxJS をベースに作っています。
マウス操作を簡単に実装できるのでとても楽に作れました。
またブラシサイズや色の変更を取得するために solidjs の state を見に行かなくて良くなる点も気に入っています。

dragging$.pipe(
  withLatestFrom(toolUpdated$.pipe(startWith<Tool>('rotate'))),
  withLatestFrom(colorUpdated$.pipe(startWith<Color>(red))),
  withLatestFrom(brushSizeUpdated$.pipe(startWith<number>(1))),
)

たったこれだけで最新の tool, color, brushSize を取得できるようになるのがいいですね。
ですが subscribe でデータを分解するのが難解になってしまったので、次の機会にまた考えたいです。

.subscribe(([[[interaction, tool], color], brushSize]) => {

})

RxJS の stream に各機能を依存させることで、以下のように開発できました。

  • 機能ごとに自分の役割に集中できる(副作用を起こすときはstreamを発行する)
  • client/src/app/flow.ts でアプリの挙動を集中的に管理できる

client の WebSocket

このようにしています。
サンプルに setInterval で 20000ミリ秒ごとに ping しているのが特徴的でした。
完全に理解できていませんが、コネクションを維持するために、周期的にアクセスしているのだと思います。

const ws = new WebSocket(
  `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`
)
setInterval(() => ws.readyState === 1 && ws.send('ping'), 20000)

ws.addEventListener('message', (event) => {
  try {
    var json = JSON.parse(event.data)
  } catch {
    return
  }
  if (json.tool === 'pull') {
    renderer.pull({
      phase: json.phase,
      x: json.x,
      y: json.y,
      brushSize: json.brushSize,
      color: ColorClass.fromString(json.color),
    })
  } else if (json.tool === 'push') {
    renderer.push({
      phase: json.phase,
      x: json.x,
      y: json.y,
      brushSize: json.brushSize,
      color: ColorClass.fromString(json.color),
    })
  }
})

おわりに

DurableObjects の WebSocket は非常に使いやすく、また費用を抑えることができると感じました。
今後も Cloudflare を使ってより高度なアプリを、ランニングコスト抑えて開発できるように考えていきたいと思います。

PR

株式会社 blue で開発に携わっている VOTE は、二択のお題に投票する Web アプリです。
技術的な話題から日常の選択など、多様なお題でお気軽に遊んでみてください。

株式会社blue TechBlog

Discussion

EdamAmexEdamAmex

worker-の実装

ここで起きた型エラーをざっくりで良いので教えて貰うことって出来ますか?

--

こちらです。

.get('/ws', c => {

この呼び出しに一致するオーバーロードはありません。
  前回のオーバーロードにより、次のエラーが発生しました。
    型 '(c: Context<Env, "/ws", BlankInput>) => (Response & TypedResponse<"Expected WebSocket", 426, "text">) | Promise<Response>' の引数を型 'H<Env, "/ws", BlankInput, HandlerResponse<any>>' のパラメーターに割り当てることはできません。
      型 '(c: Context<Env, "/ws", BlankInput>) => (Response & TypedResponse<"Expected WebSocket", 426, "text">) | Promise<Response>' を型 'MiddlewareHandler<Env, "/ws", BlankInput>' に割り当てることはできません。
        型 '(Response & TypedResponse<"Expected WebSocket", 426, "text">) | Promise<Response>' を型 'Promise<void | Response>' に割り当てることはできません。
          型 'Response & TypedResponse<"Expected WebSocket", 426, "text">' には 型 'Promise<void | Response>' からの次のプロパティがありません: then, catch, finally, [Symbol.toStringTag]

return connection.fetch(c.req.raw)

型 'Request' の引数を型 'RequestInfo<unknown, CfProperties<unknown>>' のパラメーターに割り当てることはできません。
  プロパティ 'fetcher' は型 'Request' にありませんが、型 'Request<unknown, CfProperties<unknown>>' では必須です。
monicamonica

こんにちは、とても面白い試みの記事ありがとうございます。

一点質問なのですが、Durable Objectの実装でたまにセッションが切れることはなかったでしょうか…?(10秒程度イベントを送らないなどで)

DOのフィールドにWebSocketのセッションをもつと、特性上Hibernationのタイミングでセッションが消えるのではないかなと思いまして…

もしあれば WebSocket Hibernation APIを使ってみてください!

https://developers.cloudflare.com/durable-objects/api/websockets/

--

こんにちは。
こちらこそお読みいただきありがとうございます。

一点質問なのですが、Durable Objectの実装でたまにセッションが切れることはなかったでしょうか…?

確かにセッションが切断されたのか繋がらなくなったことがありました…
セッション切断の話とはずれるのですが この記事 で同時接続上限の話も出ていて、単純なセッション保持( readonly sessions = new Set() ) ではだめなんだろうな…と悩んでいました。

もしあれば WebSocket Hibernation APIを使ってみてください!

ありがとうございます!
今あたらしい記事用のサンプルを作っていて、この問題の解決案を試したいなと思っていたところでした。
いい情報をいただきありがとうございます。

試してみます。