💡 Tips

Cloudflare Workers × Durable ObjectsでWebSocketリアルタイム通信を実装する

TL;DR(結論)

Cloudflare Workers の Durable Objects(DO) を使えば、サーバーレス環境でも WebSocket の永続的な接続を保持できます。単一インスタンスが状態を持つ DO の仕組みを活かすと、チャット・リアルタイム通知・コラボ編集 などの機能を、月数百円〜の運用コストで個人開発 SaaS に組み込めます。

この記事では実際に動く TypeScript コードを示しながら、DO × WebSocket の実装手順を解説します。


なぜ Durable Objects が必要なのか

通常の Cloudflare Workers は ステートレス です。リクエストごとに独立したワーカーが起動するため、複数クライアントの WebSocket 接続を束ねる「状態」を持てません。

Durable Objects はこの問題を解決します。

項目通常の WorkersDurable Objects
状態の保持❌ なし✅ インスタンス単位で保持
WebSocket 管理❌ 困難✅ Hibernation API で効率的に管理
課金リクエスト数使用時間 + ストレージ
難易度

DO の無料枠(1日 100 万リクエスト・1 GB ストレージ)は個人開発には十分すぎるほどです。


事前準備

  • Node.js 18 以上
  • npm create cloudflare@latest で Workers プロジェクト作成済み
  • wrangler CLI がインストール・ログイン済み
npm create cloudflare@latest realtime-app -- --type worker-ts
cd realtime-app

実装手順

1. wrangler.toml に Durable Objects を登録

# wrangler.toml
name = "realtime-app"
compatibility_date = "2024-09-01"
compatibility_flags = ["nodejs_compat"]

[[durable_objects.bindings]]
name = "CHAT_ROOM"
class_name = "ChatRoom"

[[migrations]]
tag = "v1"
new_classes = ["ChatRoom"]

2. Durable Objects クラスを実装

src/chatRoom.ts を作成します。WebSocket Hibernation API(this.ctx.acceptWebSocket())を使うと、接続がアイドル状態のときは DO 自体がスリープし、コストを大幅に削減できます。

// src/chatRoom.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
  CHAT_ROOM: DurableObjectNamespace;
}

export class ChatRoom extends DurableObject {
  // 接続中の WebSocket ごとにメタ情報を保持
  private sessions: Map<WebSocket, { name: string }> = new Map();

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    const name = url.searchParams.get("name") ?? "anonymous";

    // WebSocket アップグレードのみ受け付ける
    if (request.headers.get("Upgrade") !== "websocket") {
      return new Response("Expected WebSocket", { status: 426 });
    }

    // Hibernation API で WebSocket を受け入れる
    const { 0: client, 1: server } = new WebSocketPair();
    this.ctx.acceptWebSocket(server);
    this.sessions.set(server, { name });

    // 入室通知をブロードキャスト
    this.broadcast(`${name} が入室しました`, server);

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

  // Hibernation API のイベントハンドラ
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    const session = this.sessions.get(ws);
    if (!session) return;

    const text = typeof message === "string" ? message : "[binary]";
    // 送信者以外の全員にブロードキャスト
    this.broadcast(`[${session.name}] ${text}`, ws);
  }

  async webSocketClose(ws: WebSocket) {
    const session = this.sessions.get(ws);
    this.sessions.delete(ws);
    if (session) {
      this.broadcast(`${session.name} が退室しました`, ws);
    }
  }

  async webSocketError(ws: WebSocket, error: unknown) {
    console.error("WebSocket error:", error);
    this.sessions.delete(ws);
  }

  private broadcast(message: string, exclude?: WebSocket) {
    // getWebSockets() で Hibernation 中のソケットも含めて取得できる
    for (const ws of this.ctx.getWebSockets()) {
      if (ws !== exclude) {
        ws.send(message);
      }
    }
  }
}

3. Workers エントリポイントを実装

// src/index.ts
import { ChatRoom } from "./chatRoom";

export { ChatRoom };

export interface Env {
  CHAT_ROOM: DurableObjectNamespace;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/chat") {
      // ルーム名でインスタンスを分離(例: ?room=general)
      const roomName = url.searchParams.get("room") ?? "default";
      const id = env.CHAT_ROOM.idFromName(roomName);
      const stub = env.CHAT_ROOM.get(id);
      return stub.fetch(request);
    }

    return new Response("Not Found", { status: 404 });
  },
};

4. ローカルで動作確認

npx wrangler dev

別ターミナルで wscat(または Postman)を使って接続します。

# 2つのターミナルでそれぞれ実行
npx wscat -c "ws://localhost:8787/chat?room=general&name=Alice"
npx wscat -c "ws://localhost:8787/chat?room=general&name=Bob"

Alice のターミナルで何かメッセージを入力すると、Bob 側に [Alice] こんにちは のように届けば成功です。

5. 本番デプロイ

npx wrangler deploy

デプロイ後は wss://realtime-app.<your-subdomain>.workers.dev/chat?room=general&name=Alice でアクセスできます。


個人開発 SaaS への応用パターン

チャット機能

上記のコードがそのまま使えます。ルーム名を idFromName(roomName) で切り替えるだけで複数チャンネルに対応可能です。

リアルタイム通知

DO 内に通知キューを持たせ、管理画面から REST API で broadcast() を呼ぶパターンが簡単です。this.ctx.storage.put() を組み合わせれば未読件数の永続化も可能。

コラボレーション編集(Yjs との連携)

Yjs の y-websocket サーバーを DO 上に乗せる実装が OSS で公開されています。Notion ライクな共同編集機能を DO 1 つで実現できます。


コスト感・注意点

  • Durable Objects は Workers Paid プラン(月 $5)以上が必要。無料プランでは利用できません。
  • DO はリージョンが固定されます(作成時に最も近いロケーションに配置)。グローバルなレイテンシ最適化が必要な場合は locationHint オプションを検討してください。
  • Hibernation API を使わない場合、アイドル中も課金されるため必ず Hibernation を使いましょう。
  • 1 インスタンスあたりの同時接続数の上限は公式ドキュメントで確認してください(現時点では数千接続が現実的)。
📚 おすすめ書籍

Cloudflare Workers実践開発入門 サーバーレスWebアプリ開発

Cloudflare活用のリファレンスに最適

Amazonで見る →

まとめ

ステップやること
1wrangler.toml に DO バインディングを追加
2DurableObject を継承したクラスで Hibernation API を実装
3Workers エントリで idFromName() でルームを分離
4wrangler dev でローカル確認 → wrangler deploy で本番公開

Durable Objects × WebSocket の組み合わせは、インフラ管理ゼロでスケールするリアルタイム機能を個人開発にもたらしてくれます。月 $5 の Paid プランさえ契約すれば、専用サーバーなしに本格的なチャットや通知機能を SaaS に組み込める点は大きな競争優位です。

個人開発のサービスをもっと早くリリースしたい、技術選定に迷っているという方は、副業・フリーランスの技術相談ができる {{A8:coconala}} も活用してみてください。

まずは wrangler dev でローカルを立ち上げ、2 つのターミナルで WebSocket 接続を試してみましょう。動いた瞬間の感動はひとしおです。