collaborative-text-editor について
RivetKitとYjsを使った共同編集テキストエディターのサンプルで、共有ドキュメントを作る開発者向けです。文書ごとのActorがCRDT更新を中継してスナップショットを保存し、カーソルとプレゼンスも配信します。
主な機能
- 01Yjs CRDT文書同期
- 02文書ごとのActor
- 03バイナリスナップショット保存
- 04カーソルとプレゼンスの中継
- 05ワークスペース文書インデックス
活用シーン
- 01共有ノートや文書の構築
- 02再接続後のローカル編集のマージ
- 03文書へのリモートカーソルとプレゼンス追加
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ rivet-dev/ skills --skill collaborative-text-editorインストール後、Claude Code で/collaborative-text-editorを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
BASE_SKILL.mdindex.jsonopenapi.jsonreference/actors/access-control.mdactions.mdactor-runtime-socket.mdappearance.mdauthentication.mdcommunicating-between-actors.mdconnections.mddebugging.mddesign-patterns.mddestroy.mderrors.mdevents.mdfetch-and-websocket-handler.mdhelper-types.mdhttp-api.mdinput.mdinspector-tabs.mdkeys.mdkv.mdlifecycle.mdlimits.mdmetadata.mdqueues.mdreference/actors/quickstart/backend.mdcloudflare.mdeffect.mdnext-js.md詳細
共同編集テキストエディター
重要:何かを始める前に、このスキルのディレクトリにある BASE_SKILL.md を必ず読んでください。デバッグ、エラー処理、状態管理、デプロイ、プロジェクトのセットアップに関する重要なガイダンスが記載されています。これらのルールとパターンは、すべての RivetKit の作業に適用されます。以下の内容は、すでに目を通して理解していることを前提としています。
動作するサンプル
参考実装が必要な場合は、次のテンプレートにある動作するサンプルのコードを直接読んでください。
RivetKit で Yjs サーバーを構築するためのパターンです。CRDT ドキュメントの同期、プレゼンスとカーソル、スナップショットの永続化を扱い、ドキュメントごとに 1 つの Rivet Actor が中継役を担います。
スターターコード
GitHub にある動作するサンプルから始め、エディターに合わせて調整してください。シンプルなテキストエリア、リモートカーソルのオーバーレイ、ワークスペースのドキュメントインデックスを備えた React フロントエンドが含まれています。
| ユースケース | スターターコード | よくある例 |
|---|---|---|
| ドキュメントの共同編集 | GitHub | Notion 風ドキュメント、共有メモ、ペアライティングツール、フォームの共同編集 |
CRDT と OT の比較
同時テキスト編集を解決するアルゴリズムには、2 つの系統があります。どちらを選ぶかによって、サーバーに必要な処理が決まります。
| 項目 | CRDT(Yjs) | Operational Transformation |
|---|---|---|
| 競合解決モデル | 可換なマージです。更新はどのピアでも任意の順序で適用でき、同じ結果に収束します。 | サーバーは各操作を、並行して行われたすべての操作に対して変換します。正しく動作するには、一元化された順序制御が必要です。 |
| オフライン対応 | 優れています。クライアントはローカルで編集を続け、再接続時にバッファーされた更新をマージします。 | 限定的です。長期にわたる不一致によって、変換チェーンが複雑で壊れやすくなります。 |
| サーバーの役割 | 中継と永続化です。サーバーは内容を解釈しない更新を適用して再配信します。ドキュメントの意味を理解する必要はありません。 | 権威ある変換処理です。サーバーは、すべての操作タイプに対する変換ロジックを実装する必要があります。 |
| ライブラリーの成熟度 | Yjs は成熟して広く導入されており、ProseMirror、CodeMirror、Monaco などのバインディングがあります。 | 本番品質の実装は、ほとんどが独自実装(Google Docs)か、古くなっています(ShareDB)。 |
このサンプルでは CRDT を使用しています。CRDT を使うと、サーバーを中継型の Rivet Actor のままにできるためです。Actor は受信した各更新をサーバー側の Y.Doc に適用し、マージ後の状態を永続化して後から参加したユーザーに提供しますが、操作の変換や競合の調整は行いません。Yjs のマージは可換であるため、順序は関係ありません。
ドキュメントアクターモデル
| トピック | 概要 |
|---|---|
| トポロジー | ドキュメントごとに document[workspaceId, documentId] アクターを1つ、ワークスペースごとに documentList[workspaceId] コーディネーターを1つ配置します。 |
| 同期モデル | 各クライアントはローカルの Y.Doc を保持します。ドキュメントアクターは、増分 Yjs 更新をブロードキャスト イベント として中継し、サーバー側にマージ済みのコピーを vars に保持します。 |
| 永続化 | 同期更新ごとに、マージ済みの Yjs スナップショット全体を1つのバイナリ アクター KV キー(yjs:doc)に上書き保存します。ドキュメントのメタデータは JSON ステート に保存されます。 |
| キュー | ありません。このサンプルは アクション とブロードキャストイベントのみで構成されています。 |
| プレゼンス | Yjs Awareness は同じ applyUpdate アクションを通じて中継されます。接続ごとの connState では、切断時のクリーンアップのために、その接続が申告した Awareness の clientId を追跡します。 |
2つのアクターに分ける構成は、設計パターン のコーディネーターパターンに沿っています。コーディネーターは検索と作成を担当し、各ドキュメントアクターは1つのドキュメントのリアルタイム状態を管理します。複数要素の キー によって、両方のアクターをワークスペースのスコープ内に配置します。
アクター
-
キー:
document[workspaceId, documentId] -
責務:受信した同期更新と Awareness 更新をサーバー側の
Y.DocおよびAwarenessに適用し、マージ済みの Yjs スナップショットをアクター KV に永続化して、接続中のすべての共同作業者に更新をブロードキャストします。 -
アクション
getContentapplyUpdategetAwareness
-
キュー
- ありません
-
ステート
- JSON メタデータのみ:
title、createdAt、updatedAt - マージ済みの Yjs スナップショット全体を保持するバイナリ KV キー
yjs:doc - 一時的な vars:
createVarsで作成され、アクターの起動時に KV から再水和される、稼働中のY.DocとAwareness - 接続ごとの
connState:その接続が申告した Awareness クライアントのclientIds
- JSON メタデータのみ:
-
キー:
documentList[workspaceId] -
責務:1つのワークスペースのコーディネーターです。アクター間クライアントを通じてドキュメントアクターを作成し、ドキュメント概要のインデックスを管理します。
-
アクション
createDocumentlistDocumentsdeleteDocument
-
キュー
- ありません
-
ステート
- JSON
DocumentSummaryエントリー(id、title、createdAt、updatedAt)の配列documents
コーディネーターの createDocument は UUID を生成し、続いて c.client<typeof registry>() を使用してドキュメントアクターを明示的に作成し、作成時の 入力 として { title, createdAt } を渡します。これはドキュメントアクターの createState によって使用されます。アクター間クライアントについては、アクター間の通信 を参照してください。
更新リレー
単一の applyUpdate(update, kind, clientId?) アクションで、両方の更新種別を処理します。更新はアクションの境界を number[] のバイト配列として通過し、各側で Uint8Array に変換されます。
| 種別 | サーバーでの適用先 | 永続化 | ブロードキャスト |
|---|---|---|---|
"sync" | Y.applyUpdate を使い、オリジン "client" で c.vars.doc に適用 | マージ済みのスナップショット全体を KV キー yjs:doc に保存し、その後 updatedAt を更新します | 差分更新を含む sync イベント |
"awareness" | オリジン "client" で applyAwarenessUpdate を使い、c.vars.awareness に適用 | なし。プレゼンスは一時的です。 | 更新を含む awareness イベント |
同期ブランチには非対称な点があります。ブロードキャストには小さな差分更新のみが含まれますが、KV への書き込みでは Y.encodeStateAsUpdate で再エンコードした、マージ済みドキュメント全体が保存されます。
Yjs のオリジンタグは、リレーのループを防ぐエコーガードです。
| オリジンタグ | 設定場所 | 効果 |
|---|---|---|
"local" | クライアントでの編集時に doc.transact(..., "local") 内で設定 | クライアントの更新リスナーが実行され、applyUpdate がアクターに送信されます。 |
"client" | サーバーが受信した更新を Y.Doc または Awareness に適用する際に設定 | サーバー側のコピーで、変更がクライアント起点であることを示します。 |
"remote" | クライアントがブロードキャストイベントまたは初期同期データを適用する際に設定 | 更新リスナーは "remote" の場合に早期リターンするため、クライアントが自身のエコーを再送信することはありません。 |
接続時または再接続時に、クライアントは getContent と getAwareness を呼び出し、両方の結果をオリジン "remote" でローカルの Y.Doc と Awareness に適用します。その後、すべての変更は applyUpdate とブロードキャストイベントを介して伝播します。
Awareness とプレゼンス
プレゼンス(ユーザー名、色、カーソル位置)は、アクターの状態ではなく Yjs Awareness プロトコルを介して伝達されます。
- クライアントは
awareness.setLocalStateFieldを使って、userフィールドとcursorフィールドにプレゼンスを設定します。Awareness の更新リスナーは変更をエンコードし、applyUpdate(update, "awareness", awareness.clientID)を送信します。 - アクターは、接続ごとの
connState.clientIdsに通知された各clientIdを記録し、その更新をサーバー側のAwarenessに適用して、すべてのピアにawarenessイベントをブロードキャストします。接続ごとの状態については、接続を参照してください。 onDisconnectは接続のclientIdsを読み取り、サーバー側のAwarenessでremoveAwarenessStatesを呼び出して、エンコードした削除情報をブロードキャストします。これにより、残りのすべてのクライアントで、切断したユーザーのカーソルが削除されます。このフックについては、ライフサイクルを参照してください。
アクターは、どの Awareness の clientId がどの接続に属するかを追跡するため、クライアント側での協力がなくても、切断時にプレゼンスが自動的にクリーンアップされます。
永続化とコンパクション
この例では、完全なスナップショットで上書きして永続化します。"sync" の更新ごとに、アクターは Y.encodeStateAsUpdate でマージ済みドキュメント全体を再エンコードし、単一のバイナリ KV キー yjs:doc を上書きします。追記専用の更新ログも、個別のコンパクションジョブもありません。Y.encodeStateAsUpdate がドキュメントをマージしたコンパクトな表現を出力するため、Yjs のマージセマンティクスによって、保存された Blob は自動的にコンパクトに保たれます。
| プロパティ | 完全なスナップショットでの上書き(この例) |
|---|---|
| 書き込みコスト | 同期更新ごとにドキュメント全体を KV に書き込むため、キー入力のたびに Blob 全体を書き換えます。 |
| 読み取りコスト | アクターの起動時に createVars でバイナリ KV を 1 回読み取り、ドキュメントを復元します。 |
| クラッシュ時の安全性 | 最後に完了した applyUpdate は永続化されています。ログの再生は不要です。 |
| 最適な用途 | 書き込みの増幅よりシンプルさを優先する、小規模から中規模のドキュメントです。 |
推奨される拡張(この例には含まれません):大規模なドキュメントや非常に頻繁な編集では、差分更新を KV 更新ログに追記し、定期的(たとえば N 回の更新ごと)にマージ済みスナップショットを書き込む方式に切り替えてください。起動時にはスナップショットにログの再生を組み合わせ、スナップショットの書き込みをログを切り詰める明示的なコンパクション手順とします。完全なスナップショットの書き込みが実測でボトルネックになった場合、または Blob が KV の値サイズ上限に近づいた場合にのみ、この方法を採用してください。
ライフサイクル
sequenceDiagram
participant A as Client A
participant B as Client B
participant DL as documentList
participant D as document
A->>DL: listDocuments()
A->>DL: createDocument(title)
DL->>D: create([workspaceId, documentId], input)
DL-->>A: DocumentSummary
A->>D: connect
B->>D: connect
Note over D: createVars rehydrates Y.Doc from KV "yjs:doc"
A->>D: getContent() + getAwareness()
D-->>A: encoded doc + awareness state
Note over A: local edit with origin "local"
A->>D: applyUpdate(update, "sync")
Note over D: apply with origin "client", overwrite KV snapshot
D-->>B: sync event (incremental update)
Note over B: apply with origin "remote", no echo
A->>D: applyUpdate(update, "awareness", clientId)
D-->>B: awareness event
B-->>D: disconnect
Note over D: onDisconnect removes B's awareness clientIds
D-->>A: awareness event (removal)
セキュリティチェックリスト
この例には、認証も認可も実装されていません。本番環境で使用する前に、以下を基本としてセキュリティを強化してください。これらはいずれも、この例では実装されていません。
- 接続前に認証する:ワークスペース ID を知っている、または推測できる人なら誰でも接続できます。また、
useActorは暗黙的に getOrCreates するため、存在しないワークスペース ID で接続すると、空のdocumentListコーディネーターが黙って作成されます。認証されていないクライアントがアクターに到達しないよう、接続時の認証を追加してください。認証を参照してください。 - ドキュメントごとのアクセス制御:認証済みユーザーが任意のドキュメントではなく、特定の
[workspaceId, documentId]キーにアクセスする権限を持つことを検証してください。 applyUpdateに上限を設け、レート制限する:更新ペイロードはサイズ制限のない未検証のnumber[]配列です。また、この例のクライアントは、スロットリングを一切行わず、キー入力やカーソル移動のたびにアクションを送信します。サーバーでペイロードサイズの上限と接続ごとのレート制限を適用し、クライアントではデバウンスしてください。- クライアントが申告する awareness の clientIds を信頼しない:
applyUpdateのclientId引数はクライアントから送信され、そのまま信頼されています。接続スコープのサーバー状態からプレゼンス ID を導出するか、検証してください。 - 削除時にアクターと KV を破棄する:
deleteDocumentはコーディネーターのインデックスからエントリーを除外するだけです。ドキュメントアクターとその KV スナップショットは孤立したままになります。削除時には、削除権限のチェックを行ったうえで、ドキュメントアクターとそのストレージも破棄してください。
リファレンスマップ
アクター
- アクセス制御
- アクション
- アクターキー
- アクターランタイムソケット
- アクターのステータス
- 認証
- Cloudflare Workers クイックスタート
- アクター間の通信
- 接続
- カスタムインスペクタータブ
- デバッグ
- 設計パターン
- アクターの破棄
- Effect.ts クイックスタート(ベータ)
- エラー
- Fetch および WebSocket ハンドラー
- ヘルパー型
- アイコンと名前
- インメモリ状態
- 入力パラメーター
- ライフサイクル
- 制限
- 低レベル HTTP リクエストハンドラー
- 低レベル KV ストレージ
- 低レベル WebSocket ハンドラー
- メタデータ
- Next.js クイックスタート
- Node.js と Bun のクイックスタート
- キューと実行ループ
- React クイックスタート
- リアルタイム
- Rust クイックスタート(ベータ)
- スケーリングと並行処理
- スケジュールと Cron
- 状態の共有と参加
- SQLite
- SQLite と Drizzle
- Supabase Functions クイックスタート
- テスト
- トラブルシューティング
- 型
- 標準 HTTP API
- バージョンとアップグレード
- ワークフロー
CLI
クライアント
クックブック
- AI エージェント
- チャットルーム
- 共同編集テキストエディター
- Cron ジョブとスケジュールタスク
- テナントごとのデータベース
- VPC またはエアギャップネットワークへの Rivet のデプロイ
- ライブカーソルとプレゼンス
- マルチプレイヤーゲーム
デプロイ
- コンテナランナー
- Amazon Web Services Lambda へのデプロイ
- AWS ECS へのデプロイ
- Cloudflare Workers へのデプロイ
- Freestyle へのデプロイ
- Google Cloud Run へのデプロイ
- Hetzner へのデプロイ
- Kubernetes へのデプロイ
- Railway へのデプロイ
- Rivet Compute へのデプロイ
- Supabase Functions へのデプロイ
- Vercel へのデプロイ
- VM およびベアメタルへのデプロイ
全般
- アクター設定
- クロスオリジンリソース共有
- LLM および AI 向けドキュメント
- エッジネットワーキング
- エンドポイント
- 環境変数
- HTTP サーバー
- ロギング
- プール設定
- 本番環境チェックリスト
- レジストリ設定
- ランタイムモード
- WASM とネイティブ SDK の比較