🎈

マルチプレイヤーアプリケーションが作れる PartyKit ってなんだ....?

2024/05/31に公開

Cloudflare に PartyKit が買収されました。すごいですね。僕も Cloudflare に入りたいぜ。
というのは置いといて....。PartyKit 面白そうなので触ってみようと思います。

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

TL;DR

  • CloudflareWorker で WebSocket を簡単に扱いたいときに使うライブラリ(プラットフォーム)
  • DurableObjects と前段の Cloudfalre Workers を 1 つの class で書くことができる
  • DurableObjects でできることはすべてできる
    • RateLimit も簡単に実装できる
      • connection ごとに前回のメッセージ送信時間が格納されている
  • Hibernation API にも対応している
    • 料金を抑えやすい
  • Binding には対応していない
  • デプロイは partykit コマンドで行う
    • wrangler で deploy することはできない
  • CloudflareWorker にデプロイされる形に変換する工程は PartyKit のサーバーで行われている
    • ハックして binding を使うようにすることはできなさそう

PartyKit とは

https://docs.partykit.io/#learn-more

PartyKit simplifies developing multiplayer applications. With PartyKit, you can focus on building multiplayer apps or adding real-time experiences to your existing projects with as little as one line of code. Meanwhile, PartyKit will handle operational complexity and real-time infrastructure scaling.

ゲームの作成、リアルタイムなアプリを作るのに便利なライブラリ、プラットフォームです。つまり WebSocket を使用するときに便利です。後述しますが、PartyKit のバックエンドは Cloudflare Workers と Durable Objects で構築されています。

Cloudfalre Worker で Durable Objects を使うには DurableObjects Worker(class) の記述と Worker の記述を分けて書くことになりますが、PartyKit はそれを 1 つの class で書くことができるため簡単に扱えます。

今回はデモアプリとして Emoji Reaction を作ってみました。別タブで開いて Emoji をクリックするとリアルタイムで反映されます。
ルーム機能の検証として、URL にルーム名を指定することで複数のルームを作成できるようにしてあるので試してみてください。

絵文字が押されるとリアルタイムにカウントが増えるデモサイトの動画

https://partykit.napochaan.dev/?id=room

https://github.com/napolab/partykit-sandbox

PartyKit を使ってみる

デフォルトの deploy コマンドだと PartyKit のプラットフォームにデプロイされますが、個人 Cloudflare アカウントにデプロイすることもできます。[1]

環境の準備

まずは、新しいプロジェクトを作成します。

npm create partykit@latest

次に、bun を使って環境設定ファイルを簡単に読み込む設定を行います。このためには、package-lock.json を削除してから bun でパッケージをインストールします。[2]

rm -rf package-lock.json
bun i

.env ファイルを作成し、必要な情報を記入します。

  • API_TOKEN: Cloudflare Dashboard から「Create Token > Edit Cloudflare Workers」を選択してトークンを作成します。
  • ACCOUNT_ID: Worker&Pages のサイドメニューに表示されています。
CLOUDFLARE_API_TOKEN=xxxxxxxxxxxxxxx
CLOUDFLARE_ACCOUNT_ID=yyyyyyyyyyyyyyy

デプロイ

bun --env-file .env partykit deploy --domain zzz.yyy.xxx
npx を使用する公式と同じやり方
CLOUDFLARE_ACCOUNT_ID=<your account id> CLOUDFLARE_API_TOKEN=<your api token> npx partykit deploy --domain zzz.yyy.xxx

これでデプロイ完了です。めっちゃ簡単。

TypeScript Starter を選んだ場合はデフォルトだとこうなってるはずです。

デプロイされたサイト

PartyKit でリクエストを受け取る

PartyKit は、Worker と DurableObjects の両方の実装をクラス内で行うことができ、特に Worker が受けるリクエストは static として定義する必要があります。これにより、Worker が実行するメソッドと DurableObjects が実行するメソッドを明確に区別しています。

各メソッドの実行環境と対象リクエスト

PartyKit には、onFetchonRequest など、リクエストを処理するための複数のメソッドが用意されています。
これらの実行環境はメソッドが static か否かを見ることによってわかりやすいです。

  • Static メソッド:Worker で直接処理されるため、クラスの static として定義します。
  • 非 Static メソッド:DurableObjects 内で実行され、各インスタンスの状態管理が可能です。
メソッド 実行環境 Static 対象リクエスト
onFetch Worker /party/* 以外の HTTP リクエスト
onSocket Worker /party/* 以外の WebSocket リクエスト
onRequest DurableObjects × /party/* の HTTP リクエスト
onConnect DurableObjects × /party/* の WebSocket リクエスト

認証

onBeforeRequestonBeforeConnect というメソッドがあり、これらは onRequestonConnect の前に実行されるため、ここで認証を行うことができます。

static メソッドなので DurableObjects の前段にいる Worker で実行されることが予想されます。

https://docs.partykit.io/guides/authentication/

ルームの作成

https://docs.partykit.io/reference/partysocket-api/

クライアントサイドはこう書かれます。

client.ts
import PartySocket from "partysocket";

const ws = new PartySocket({
  host: "zzz.yyy.xxx",
  room: "my-room",
});

PartySocket が実行されると、新しく room が作られます。この時新しく DurableObjects インスタンスが生成されます。

SDK を経由せずにリクエストを送る場合

/party/:id にリクエストを送ると、個別のルームが作成されます。/party/:id/rest のように id の後に path をつなげても id が同じなら同じ room としてみなされるようです。

Hibernation API を用いてスケーリングする

https://docs.partykit.io/guides/scaling-partykit-servers-with-hibernation/

DurableObjects には Hibernation API[4] という機能があり、message のやり取りがないときは WebSocket の接続を維持したままスリープさせることができます。Cloudflare Docs では特に料金面でのメリットがあると紹介されています。[5]

さらに PartyKiy のドキュメントには Hibernation API を使用した場合、1 つの room が処理できるアクティブな接続を大幅に増やすことができると書かれています。

Hibernation allows a single room to handle vastly more active connections than it otherwise would:

  • Without Hibernation: up to 100 connections per room
  • With Hibernation: up to 32,000 connections per room

options に hibernate: true を設定することで Hibernation API を有効にすることができます。

export default class Server implements Party.Server {
  options: Party.ServerOptions = {
    hibernate: true,
  };
}

サンプルで作成した Emoji Reaction にも適用しています。

https://github.com/napolab/partykit-sandbox/blob/main/src/server.ts#L57

Hibernation API を使用するときの注意

スリーブ状態になる時間はかなり短く 10s ほどたつとスリープ状態になります。スリーブ状態になるとインメモリ状態は破棄されるので、DurableObjects の状態を定期的に外部に保存する必要があります。

スリーブ状態になるイベントも存在しないのでスリーブになったことを DurableObjects が検知する方法もないので、メッセージが送信されるたびに保存する、Alarm を使用したバッチ保存などの対策が必要です。

Emoji Reaction では onMessage の度に保存しています。

https://github.com/napolab/partykit-sandbox/blob/main/src/server.ts#L163-L177

PartyKit で Yjs を使う

PartyKit は Cloudflare Worker と DurableObjects の連携を簡単にするためのライブラリなので、デフォルトでは共同編集のような機能は提供されていません。しかし、Y-PartyKit というライブラリが提供されておりそれを使用することで Yjs の機能を使うことができます。

サーバー実装では Yjs の WebSocket Provider を使うので onConnect メソッド内部に実装を書いていきます。

https://docs.partykit.io/reference/y-partykit-api/

server.ts
import type * as Party from "partykit/server";
import { onConnect } from "y-partykit";

export default class YjsServer implements Party.Server {
  constructor(public party: Party.Room) {}
  onConnect(conn: Party.Connection) {
    return onConnect(conn, this.party, {
      // ...options
    });
  }
}

クライアント実装では Y-PartyKit から提供されている Provider に同期したい YDoc を渡すことで変更がリアルタイムに同期されるようになります。

client.ts
import YPartyKitProvider from "y-partykit/provider";
import * as Y from "yjs";

const yDoc = new Y.Doc();

const provider = new YPartyKitProvider(
  "localhost:1999",
  "my-document-name",
  yDoc
);

サーバー側の変更履歴の保存

接続しているクライアントの変更情報はすべてサーバーの YDoc にインメモリで保存されています。
DurableObjects のインスタンスが破棄されたとき、インメモリ状態は揮発するので次に起動したときには変更情報が失われるため、サーバー側で変更情報を保存する必要があります。

Y-PartyKit 状態を保存するための option が用意されています。onConnect 関数の persist を設定するだけで 2 パターンの保存方法を選択できます。

https://docs.partykit.io/reference/y-partykit-api/#:~:text=}-,Persistence,-By default%2C PartyKit

persist snapshot

こちらを使用するほうが多くの場合適していると思われます。接続が存在するときは変更のスナップショットをストレージに書き込み、接続がなくなったらスナップショットをマージして保存します。

server.ts
onConnect(connection, party, {
  persist: {
    mode: "snapshot"
  }
});

persist history

こちらはすべての変更履歴を保存するやり方です。長期間存在するようなドキュメントだと無期限に増加するので maxBytes maxUpdates を設定することで merge するタイミングを制御する必要があります。

server.ts
onConnect(connection, party, {
  persist: {
    mode: "history"
  }
});

終わりに

PartyKit を使用することで DurableObjects の独特な記法や CloudflareWorker の binding を意識せずとも WebSocket を簡単に扱うことができます。また、Hibernation API や Yjs との連携も簡単に行うことができるため、リアルタイムなアプリケーションを作る際には非常に便利なライブラリだと感じました。

しかし、D1 や KV, R2 などの binding には対応していないことや、ビルドを一度 partykit サーバーに送信してから Cloudflare Worker にデプロイされるため、どうしてもハックせざるを得ない状況が発生したときは対応が難しそうだと感じました。

しかし、これだけ簡単に WebSocket を扱えるようになったことはフロントエンドにとって非常に強力な武器になると思います。今後の PartyKit の発展に期待したいです。

おまけ PartyKit の挙動を詳しく見てみる

PartyKit の Build の仕組み

Worker と DurableObjects を一つのクラスに同時に書くことができる PartyKit の仕組みについて、どのように展開されているのかを探ってみました。

deploy コマンドの中身を確認してみると、build が行われているようです。

試しに deploy コマンドをいじり build 後の成果物を見てみましたが、そのまま Worker に deploy できるような形には見えませんでした。

// node_modules/partykit/inject-process.js
import * as process from "partykit-exposed-node-process";
try {
  Object.assign(process.env, JSON.parse('{"CLOUDFLARE_API_TOKEN":"xxxx","CLOUDFLARE_ACCOUNT_ID":"yyyyy"}'));
} catch (err) {}

var Server = class {
  constructor(room) {
    this.room = room;
    this.options = { hibernate: true };
  }
  async onConnect(conn, ctx) {
    console.log(`Connected: id: ${conn.id} room: ${this.room.id} url: ${new URL(ctx.request.url).pathname}`);
    console.log("connect", await this.room.storage.list());
    conn.send("hello from server");
  }
  onMessage(message, sender) {
    this.room.broadcast(`${sender.id}: ${message}`, [sender.id]);
  }
};

// <stdin>
var stdin_default = Server;
export { stdin_default as default };

実際に deploy している部分を見ると、fetchResult関数がpartiesエンドポイントに Binding とコードを送信しています。

送信されるドメインはPARTYKIT_API_BASEに設定された値になるため、PartyKit のサーバーで Worker に対する Binding の設定とコードの詳細なビルドが行われているようです。

これからわかることは、どうハックしても Binding を追加することはできないため、PartyKit の今後の発展を見守るしかないようです。

試しに Binding をダッシュボードから変えてみる

めっちゃ怒られる

API Request Failed: POST /api/v4/accounts/<account_id>/workers/scripts/<worker_name>/versions (403)
`

API Request Failed: POST /api/v4/accounts/<account_id>/workers/scripts/<worker_name>/versions (403)`

脚注
  1. https://docs.partykit.io/guides/deploy-to-cloudflare/ ↩︎

  2. https://bun.sh/docs/runtime/env#setting-environment-variables ↩︎

  3. https://docs.partykit.io/guides/deploy-to-cloudflare/#:~:text=in your shell.-,Future development,-This is our ↩︎

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

  5. https://developers.cloudflare.com/durable-objects/platform/pricing/ ↩︎

Discussion