マルチプレイヤーアプリケーションが作れる PartyKit ってなんだ....?
Cloudflare に PartyKit が買収されました。すごいですね。僕も Cloudflare に入りたいぜ。
というのは置いといて....。PartyKit 面白そうなので触ってみようと思います。
TL;DR
- CloudflareWorker で WebSocket を簡単に扱いたいときに使うライブラリ(プラットフォーム)
- DurableObjects と前段の Cloudfalre Workers を 1 つの class で書くことができる
- DurableObjects でできることはすべてできる
- RateLimit も簡単に実装できる
- connection ごとに前回のメッセージ送信時間が格納されている
- RateLimit も簡単に実装できる
- Hibernation API にも対応している
- 料金を抑えやすい
- Binding には対応していない
- デプロイは
partykit
コマンドで行う- wrangler で deploy することはできない
- CloudflareWorker にデプロイされる形に変換する工程は PartyKit のサーバーで行われている
- ハックして binding を使うようにすることはできなさそう
PartyKit とは
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 にルーム名を指定することで複数のルームを作成できるようにしてあるので試してみてください。
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 には、onFetch
や onRequest
など、リクエストを処理するための複数のメソッドが用意されています。
これらの実行環境はメソッドが static か否かを見ることによってわかりやすいです。
- Static メソッド:Worker で直接処理されるため、クラスの static として定義します。
- 非 Static メソッド:DurableObjects 内で実行され、各インスタンスの状態管理が可能です。
メソッド | 実行環境 | Static | 対象リクエスト |
---|---|---|---|
onFetch |
Worker | 〇 |
/party/* 以外の HTTP リクエスト |
onSocket |
Worker | 〇 |
/party/* 以外の WebSocket リクエスト |
onRequest |
DurableObjects | × |
/party/* の HTTP リクエスト |
onConnect |
DurableObjects | × |
/party/* の WebSocket リクエスト |
認証
onBeforeRequest
と onBeforeConnect
というメソッドがあり、これらは onRequest
と onConnect
の前に実行されるため、ここで認証を行うことができます。
static メソッドなので DurableObjects の前段にいる Worker で実行されることが予想されます。
ルームの作成
クライアントサイドはこう書かれます。
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 を用いてスケーリングする
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 にも適用しています。
Hibernation API を使用するときの注意
スリーブ状態になる時間はかなり短く 10s ほどたつとスリープ状態になります。スリーブ状態になるとインメモリ状態は破棄されるので、DurableObjects の状態を定期的に外部に保存する必要があります。
スリーブ状態になるイベントも存在しないのでスリーブになったことを DurableObjects が検知する方法もないので、メッセージが送信されるたびに保存する、Alarm
を使用したバッチ保存などの対策が必要です。
Emoji Reaction では onMessage
の度に保存しています。
PartyKit で Yjs を使う
PartyKit は Cloudflare Worker と DurableObjects の連携を簡単にするためのライブラリなので、デフォルトでは共同編集のような機能は提供されていません。しかし、Y-PartyKit
というライブラリが提供されておりそれを使用することで Yjs の機能を使うことができます。
サーバー実装では Yjs の WebSocket Provider を使うので onConnect
メソッド内部に実装を書いていきます。
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 を渡すことで変更がリアルタイムに同期されるようになります。
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 パターンの保存方法を選択できます。
persist snapshot
こちらを使用するほうが多くの場合適していると思われます。接続が存在するときは変更のスナップショットをストレージに書き込み、接続がなくなったらスナップショットをマージして保存します。
onConnect(connection, party, {
persist: {
mode: "snapshot"
}
});
persist history
こちらはすべての変更履歴を保存するやり方です。長期間存在するようなドキュメントだと無期限に増加するので maxBytes
maxUpdates
を設定することで merge するタイミングを制御する必要があります。
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)
`
-
https://bun.sh/docs/runtime/env#setting-environment-variables ↩︎
-
https://docs.partykit.io/guides/deploy-to-cloudflare/#:~:text=in your shell.-,Future development,-This is our ↩︎
-
https://developers.cloudflare.com/durable-objects/api/websockets/ ↩︎
-
https://developers.cloudflare.com/durable-objects/platform/pricing/ ↩︎
Discussion