🎈

VercelでもWebSocketが使える!partyserver + Cloudflare Workersで無料リアルタイム通信

に公開

TL;DR

  • Vercel の Serverless Functions は WebSocket 非対応だが、partyserver + Cloudflare Workers無料のリアルタイム通信を実現できる。
  • 5 分で「自分の Cloudflare アカウントにデプロイ」+「Next.js クライアント」まで動く最短手順を紹介。
  • Cloudflare 公式が 2024 年 4 月に PartyKit を買収。partyserverは Cloudflare Workers 向けのライブラリとして提供されている。

はじめに

Vercel を使っていて、リアルタイム機能を実装しようとした経験はありませんか?

残念ながら、Vercel Serverless Functions は WebSocket 接続を直接サポートしていません

しかし、諦める必要はありません。partyserver + Cloudflare Workersの組み合わせで、無料でリアルタイム通信機能を実現できます。

https://vercel.com/kb/guide/do-vercel-serverless-functions-support-websocket-connections


PartyKit と partyserver の違い(2024 年 4 月買収以降)

この買収後、PartyKit エコシステムは 2 つのデプロイ方法を提供しています:

方式 デプロイ先 ツール 料金
PartyKit Cloud PartyKit のマネージドプラットフォーム npx partykit deploy 無料枠あり(制限付き)
Cloudflare Workers 自分の Cloudflare アカウント npx wrangler deploy Cloudflare の無料枠

https://blog.cloudflare.com/cloudflare-acquires-partykit/

PartyKit の創設者 Sunil Pai 氏は元 Cloudflare 社員で、PartyKit はもともと Cloudflare の Durable Objects を活用しやすくするために作られたプロジェクトでした。今回の買収で「里帰り」した形です。


partyserver とは

partyserverは、Cloudflare Workers 上でリアルタイム・マルチユーザーアプリケーションを簡単に構築できるライブラリです。

主なユースケース

  • 協調編集 - Google Docs のような同時編集
  • マルチプレイヤーゲーム - リアルタイム対戦
  • チャット - リアルタイムメッセージング
  • AI エージェント - ストリーミング応答
  • ローカルファースト - オフライン対応アプリ

なぜ Cloudflare Workers + partyserver なのか

技術的な背景

partyserver は内部的にCloudflare Durable Objectsを使用しています。

Durable Objects とは:

  • ステートフルな Serverless Functions
  • クライアント間で状態を同期可能
  • グローバルエッジで実行

partyserver はこの Durable Objects を抽象化し、より簡単にリアルタイム機能を実装できるようにしたものです。


これから作るゴール

  • my-party.<account>.workers.dev にデプロイされた partyserver が稼働
  • Next.js (Vercel) から partysocket 経由で接続しチャットが動く
  • Cloudflare の無料枠の範囲で運用開始
┌─────────────────┐           ┌─────────────────┐
│     Vercel      │           │   Cloudflare    │
│   (Frontend)    │◀────────▶ │   (PartyKit)    │
│    Next.js      │ WebSocket │  Durable Objects│
└─────────────────┘           └─────────────────┘

最短クイックスタート(6 ステップ)

  1. Cloudflare アカウント作成
    https://dash.cloudflare.com/sign-up

  2. プロジェクト作成 & パッケージインストール

    mkdir my-party && cd my-party
    npm init -y
    npm install hono hono-party partyserver
    npm install -D wrangler typescript @cloudflare/workers-types
    
  3. wrangler.json を作成

    wrangler.json
    {
      "$schema": "node_modules/wrangler/config-schema.json",
      "name": "my-party",
      "main": "src/index.ts",
      "compatibility_date": "2025-01-01",
      "durable_objects": {
        "bindings": [
          { "name": "ChatRoom", "class_name": "ChatRoom" }
        ]
      },
      "migrations": [
        { "tag": "v1", "new_sqlite_classes": ["ChatRoom"] }
      ]
    }
    
  4. サーバー実装を配置(後述の src/index.ts をコピペ)

  5. デプロイ

    npx wrangler deploy
    

    初回は Cloudflare ログインが求められます。

  6. 接続テスト

    npx wscat -c wss://my-party.<account>.workers.dev/parties/chatroom/general
    

    {"type":"ping"} を送って {"type":"pong"} が返れば OK。


サーバー実装(hono-party 使用)

実用的な最小構成。タイピングインジケーターや Server Actions 用 HTTP エンドポイントも含む。

src/index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
import { partyserverMiddleware } from "hono-party";
import { Server } from "partyserver";
import type { Connection, WSMessage } from "partyserver";

/**
 * チャットルーム: ルームごとのリアルタイムメッセージ配信
 * 接続URL: /parties/chatroom/{roomId}
 */
export class ChatRoom extends Server {
  // 接続中のユーザー数
  private getConnectionCount(): number {
    return [...this.getConnections()].length;
  }

  onConnect(conn: Connection) {
    // 接続時に確認メッセージを送信
    conn.send(
      JSON.stringify({
        type: "connected",
        roomId: this.name,
        connectionCount: this.getConnectionCount(),
      })
    );
  }

  onMessage(conn: Connection, message: WSMessage) {
    if (typeof message !== "string") return;

    try {
      const data = JSON.parse(message);

      // ping/pong でコネクション維持
      if (data.type === "ping") {
        conn.send(JSON.stringify({ type: "pong" }));
        return;
      }

      // タイピングインジケーターは送信者以外にブロードキャスト
      if (data.type === "typing") {
        this.broadcast(message, [conn.id]);
        return;
      }

      // 他のメッセージは全クライアントにブロードキャスト
      this.broadcast(message);
    } catch {
      console.error("Failed to parse message:", message);
    }
  }

  // HTTP経由でメッセージを受信(Server Actionsから呼び出し用)
  async onRequest(req: Request): Promise<Response> {
    if (req.method === "POST") {
      try {
        const data = await req.json();
        this.broadcast(JSON.stringify(data));

        return new Response(
          JSON.stringify({
            success: true,
            connectionCount: this.getConnectionCount(),
          }),
          {
            status: 200,
            headers: { "Content-Type": "application/json" },
          }
        );
      } catch {
        return new Response(JSON.stringify({ error: "Invalid request" }), {
          status: 400,
          headers: { "Content-Type": "application/json" },
        });
      }
    }

    // GET: 接続状態を返す
    if (req.method === "GET") {
      return new Response(
        JSON.stringify({
          roomId: this.name,
          connectionCount: this.getConnectionCount(),
        }),
        {
          status: 200,
          headers: { "Content-Type": "application/json" },
        }
      );
    }

    return new Response("Method not allowed", { status: 405 });
  }
}

// Honoアプリケーション
const app = new Hono<{ Bindings: { ChatRoom: DurableObjectNamespace } }>();

// CORS設定
app.use(
  "*",
  cors({
    origin: ["http://localhost:3000", "https://your-app.vercel.app"],
    allowMethods: ["GET", "POST", "OPTIONS"],
    allowHeaders: ["Content-Type", "Authorization"],
  })
);

// ヘルスチェック
app.get("/health", (c) => c.json({ status: "ok" }));

// partyserverミドルウェア(/parties/* へのリクエストをルーティング)
app.use("*", partyserverMiddleware());

export default app;
この実装のポイント
  • onConnect: 接続時に確認メッセージと接続数を返す
  • onMessage: ping/pong、タイピングインジケーター、通常メッセージを処理
  • onRequest: HTTP 経由でのブロードキャスト(Server Actions からの呼び出しに便利)
  • CORS 設定: Vercel 側のオリジンを許可
  • URL パス: hono-party はデフォルトで /parties/{server}/{room} 形式の URL をルーティング

クライアント側の実装(Vercel / Next.js)

partysocket のインストール

npm install partysocket

React コンポーネント

app/chat/page.tsx
"use client";

import { usePartySocket } from "partysocket/react";
import { useState } from "react";

type Message = {
  type: "connected" | "message" | "typing";
  userId?: string;
  content?: string;
  roomId?: string;
  connectionCount?: number;
};

export default function ChatPage() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");

  const socket = usePartySocket({
    // Cloudflare Workersにデプロイした際のホスト名
    host: "my-party.<your-account>.workers.dev",
    // partyserverのクラス名(小文字)
    party: "chatroom",
    // ルーム名
    room: "general",
    onMessage: (event) => {
      const data: Message = JSON.parse(event.data);
      setMessages((prev) => [...prev, data]);
    },
  });

  const sendMessage = () => {
    if (input.trim()) {
      socket.send(
        JSON.stringify({
          type: "message",
          content: input,
          userId: "user-" + Math.random().toString(36).slice(2, 6),
        })
      );
      setInput("");
    }
  };

  return (
    <div className="p-4">
      <h1 className="text-2xl font-bold mb-4">チャットルーム</h1>

      <div className="border rounded p-4 h-64 overflow-y-auto mb-4">
        {messages.map((msg, i) => (
          <div key={i} className="mb-2">
            {msg.type === "message" ? (
              <p>
                <strong>{msg.userId}:</strong> {msg.content}
              </p>
            ) : msg.type === "connected" ? (
              <p className="text-green-500 text-sm">
                接続しました(ルーム: {msg.roomId}</p>
            ) : null}
          </div>
        ))}
      </div>

      <div className="flex gap-2">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && sendMessage()}
          className="flex-1 border rounded px-3 py-2"
          placeholder="メッセージを入力..."
        />
        <button
          onClick={sendMessage}
          className="bg-blue-500 text-white px-4 py-2 rounded"
        >
          送信
        </button>
      </div>
    </div>
  );
}

デプロイ後の動作確認チェックリスト

  • GET https://my-party.<account>.workers.dev/health{ "status": "ok" } を返す
  • npx wscat -c wss://my-party.<account>.workers.dev/parties/chatroom/general で接続できる
  • {"type":"ping"} を送信して {"type":"pong"} が返る
  • Next.js ページで 2 タブ開き、片方の入力がもう片方に即時反映される
  • CORS エラーが出ない(origin 設定を確認)

料金と代替サービス(クイック表)

サービス 料金感(個人〜小規模) 強み 向き
partyserver + Cloudflare DO は無料枠、従量も低額 エッジ × ステートフル、OSS 汎用リアルタイム
Ably 無料枠少、小さく有料 高信頼メッセージング 大規模・SLA
Pusher 無料枠あり 導入容易 通知・小規模
Liveblocks 無料枠あり 協調編集 UI 特化 共同編集
Supabase Realtime 無料枠あり OSS/PostgreSQL 連携 DB 駆動更新
Convex 無料枠あり バックエンド一体型 フルスタック

まとめ

  • Vercel Serverless Functions は WebSocket を直接サポートしていない
  • partyserver + Cloudflare Workersで無料リアルタイム通信を実現できる
  • 2024 年 4 月の買収により、PartyKit エコシステムはCloudflare に完全統合
  • partyserverを使って自分の Cloudflare アカウントにデプロイし、使った分だけ支払う(無料枠あり)
  • hono-partyを使えば Hono アプリケーションに簡単に統合できる
  • まずはクイックスタートの 6 ステップでデプロイし、wscat と Next.js で動作確認しよう

リアルタイム機能が必要になったら、ぜひ partyserver + Cloudflare Workers を試してみてください。


参考リンク

Discussion