关于 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 文档同步、在线状态与光标,以及快照持久化;每个文档由一个 Rivet Actor 充当中继。
入门代码
从 GitHub 上的可运行示例开始,并根据你的编辑器进行改造。该示例附带一个 React 前端,包含纯文本区域、远程光标叠加层和工作区文档索引。
| 使用场景 | 入门代码 | 常见示例 |
|---|---|---|
| 共享文档编辑 | GitHub | 类似 Notion 的文档、共享笔记、协作写作工具、表单协同编辑 |
CRDT 与 OT
有两类算法可以解决并发文本编辑问题。选择哪一种决定了你的服务端需要做什么。
| 维度 | CRDT(Yjs) | 操作转换 |
|---|---|---|
| 冲突解决模型 | 可交换合并。更新可以在任意对等端按任意顺序应用,并最终收敛到相同结果。 | 服务端会针对每个并发操作转换每项操作。正确性取决于中央排序器。 |
| 离线支持 | 强。客户端可以在本地持续编辑,并在重新连接时合并缓冲的更新。 | 弱。长期分歧会使转换链变得复杂且脆弱。 |
| 服务端职责 | 中继加持久化。服务端应用不透明更新并将其广播出去,无需理解文档语义。 | 权威转换器。服务端必须为每种操作类型实现转换逻辑。 |
| 库的成熟度 | Yjs 已成熟且广泛部署,并支持 ProseMirror、CodeMirror、Monaco 等绑定。 | 生产级实现大多是专有的(Google Docs)或已显陈旧(ShareDB)。 |
该示例使用 Yjs,因为 CRDT 可以让服务端保持为中继式 Rivet Actor。Actor 会将每个传入更新应用到服务端的 Y.Doc,以便持久化合并后的状态并为后来加入的用户提供数据,但它不会转换操作,也不会仲裁冲突。由于 Yjs 合并具有可交换性,因此顺序并不重要。
文档 Actor 模型
| 主题 | 摘要 |
|---|---|
| 拓扑结构 | 每个文档对应一个 document[workspaceId, documentId] Actor,每个工作区对应一个 documentList[workspaceId] 协调器。 |
| 同步模型 | 每个客户端都持有一个本地 Y.Doc。文档 Actor 通过广播 事件 转发增量 Yjs 更新,并在 vars 中保留一份服务端合并副本。 |
| 持久化 | 每次同步更新时,将完整合并后的 Yjs 快照覆盖写入一个二进制 Actor KV 键(yjs:doc)。文档元数据存储在 JSON 状态 中。 |
| 队列 | 无。此示例仅使用 操作 和广播事件。 |
| 在线状态 | Yjs Awareness 通过同一个 applyUpdate 操作转发。每个连接的 connState 会跟踪该连接声明的 awareness clientIds,以便断开连接时清理。 |
这两个 Actor 的拆分遵循 设计模式 中的协调器模式:协调器负责发现和创建,每个文档 Actor 负责一个文档的实时状态。多段式 键 将两个 Actor 都限定在一个工作区内。
Actor
-
键:
document[workspaceId, documentId] -
职责:将传入的同步和 awareness 更新应用到服务端的
Y.Doc和Awareness,将合并后的 Yjs 快照持久化到 Actor KV,并向所有已连接的协作者广播更新。 -
操作
getContentapplyUpdategetAwareness
-
队列
- 无
-
状态
- 仅包含 JSON 元数据:
title、createdAt、updatedAt - 二进制 KV 键
yjs:doc,用于保存完整合并后的 Yjs 快照 - 临时 vars:实时的
Y.Doc和Awareness,在createVars中创建,并在 Actor 启动时从 KV 重新加载 - 每个连接的
connState:该连接声明的 awareness 客户端的clientIds
- 仅包含 JSON 元数据:
-
键:
documentList[workspaceId] -
职责:一个工作区的协调器。通过 Actor 间客户端创建文档 Actor,并维护文档摘要索引。
-
操作
createDocumentlistDocumentsdeleteDocument
-
队列
- 无
-
状态
- JSON
documents数组,包含DocumentSummary条目(id、title、createdAt、updatedAt)
协调器的 createDocument 会生成一个 UUID,然后使用 c.client<typeof registry>() 显式创建文档 Actor,并传入 { title, createdAt } 作为创建输入,由文档 Actor 的 createState 使用。关于 Actor 间客户端,请参阅在 Actor 之间通信。
更新中继
一个 applyUpdate(update, kind, clientId?) 操作可处理这两种更新。更新以 number[] 字节数组的形式跨越操作边界,并在每一端转换回 Uint8Array。
| 类型 | 服务端应用到 | 持久化 | 广播 |
|---|---|---|---|
"sync" | 通过 Y.applyUpdate 应用到 c.vars.doc,来源标记为 "client" | 将完整合并快照写入 KV 键 yjs:doc,然后更新 updatedAt | 携带增量更新的 sync 事件 |
"awareness" | 通过 applyAwarenessUpdate 应用到 c.vars.awareness,来源标记为 "client" | 不持久化。在线状态是临时的。 | 携带更新的 awareness 事件 |
请注意,sync 分支存在不对称之处:广播只携带较小的增量更新,而 KV 写入会存储使用 Y.encodeStateAsUpdate 重新编码后的完整合并文档。
Yjs 来源标签可防止中继循环:
| 来源标签 | 设置位置 | 效果 |
|---|---|---|
"local" | 客户端在 doc.transact(..., "local") 中进行编辑 | 客户端的更新监听器会触发,并向 actor 发送 applyUpdate。 |
"client" | 服务端将传入的更新应用到其 Y.Doc 或 Awareness 时 | 在服务端副本上将更改标记为源自客户端。 |
"remote" | 客户端应用广播事件或初始同步数据时 | 更新监听器会在遇到 "remote" 时提前返回,因此客户端不会重新发送自己的回显。 |
连接或重新连接时,客户端会调用 getContent 和 getAwareness,然后将两者的结果以来源标记 "remote" 应用到本地 Y.Doc 和 Awareness。此后,所有更改都会通过 applyUpdate 和广播事件流转。
Awareness 与在线状态
在线状态(用户名、颜色、光标位置)通过 Yjs Awareness 协议传递,而非存储在 actor 状态中:
- 客户端使用
awareness.setLocalStateField设置user和cursor字段的在线状态。Awareness 更新监听器会对更改进行编码,并发送applyUpdate(update, "awareness", awareness.clientID)。 - actor 会将每个声明的
clientId记录到该连接的connState.clientIds,将更新应用到服务端的Awareness,并向所有对等端广播awareness事件。有关每个连接的状态,请参阅连接。 onDisconnect会读取连接的clientIds,在服务端的Awareness上调用removeAwarenessStates,并广播编码后的移除信息,以便所有剩余客户端移除离开用户的光标。有关此钩子的说明,请参阅生命周期。
由于 actor 会跟踪哪些 awareness clientId 属于哪个连接,因此断开连接时会自动清理在线状态,无需客户端配合。
持久化与压缩
此示例使用完整快照覆盖进行持久化:每次收到 "sync" 更新时,actor 都会通过 Y.encodeStateAsUpdate 重新编码整个合并后的文档,并覆盖单个二进制 KV 键 yjs:doc。这里没有只追加的更新日志,也没有单独的压缩任务。压缩是隐式完成的,因为 Y.encodeStateAsUpdate 会生成文档的单个紧凑合并表示,因此 Yjs 的合并语义会自动让存储的 blob 保持紧凑。
| 属性 | 完整快照覆盖(此示例) |
|---|---|
| 写入成本 | 每次同步更新都要写入一次完整文档 KV,因此每次按键都会重写整个 blob。 |
| 读取成本 | actor 启动时,在 createVars 中读取一次二进制 KV,以恢复文档。 |
| 崩溃安全性 | 最近一次完成的 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协调器。添加连接身份验证,确保未经身份验证的客户端无法访问 actor。请参阅身份验证。 - 按文档进行访问控制:验证经过身份验证的用户有权访问特定的
[workspaceId, documentId]键,而不只是任意文档。 - 限制
applyUpdate的大小和频率:更新负载是未经验证的number[]数组,没有大小限制;示例客户端会在每次按键和光标移动时发送一个操作,且完全没有节流。请在服务端限制负载大小并按连接实施速率限制,同时在客户端进行防抖。 - 不要信任客户端声明的 awareness clientIds:
applyUpdate的clientId参数由客户端提供,并会被直接信任。应从连接范围内的服务端状态中推导或验证在线状态身份。 - 删除时销毁 actor 和 KV:
deleteDocument只会从协调器的索引中移除该条目。文档 actor 及其 KV 快照会成为孤立数据。删除时还应销毁文档 actor 及其存储,并检查执行删除的用户是否具有相应权限。
参考索引
Actors
- 访问控制
- 操作
- Actor 密钥
- Actor 运行时套接字
- Actor 状态
- 身份验证
- Cloudflare Workers 快速入门
- Actor 之间的通信
- 连接
- 自定义检查器选项卡
- 调试
- 设计模式
- 销毁 Actor
- Effect.ts 快速入门(Beta)
- 错误
- Fetch 和 WebSocket 处理器
- 辅助类型
- 图标与名称
- 内存中状态
- 输入参数
- 生命周期
- 限制
- 底层 HTTP 请求处理器
- 底层 KV 存储
- 底层 WebSocket 处理器
- 元数据
- Next.js 快速入门
- Node.js 与 Bun 快速入门
- 队列与运行循环
- React 快速入门
- 实时功能
- Rust 快速入门(Beta)
- 扩展与并发
- 计划任务与 Cron
- 共享与加入状态
- SQLite
- SQLite + Drizzle
- Supabase Functions 快速入门
- 测试
- 故障排除
- 类型
- 原生 HTTP API
- 版本与升级
- 工作流
命令行工具
客户端
教程集
部署
- 容器运行器
- 部署到 Amazon Web Services Lambda
- 部署到 AWS ECS
- 部署到 Cloudflare Workers
- 部署到 Freestyle
- 部署到 Google Cloud Run
- 部署到 Hetzner
- 部署到 Kubernetes
- 部署到 Railway
- 部署到 Rivet Compute
- 部署到 Supabase Functions
- 部署到 Vercel
- 部署到虚拟机和裸金属服务器
通用
- Actor 配置
- 跨源资源共享
- 面向大模型和 AI 的文档
- 边缘网络
- 端点
- 环境变量
- HTTP 服务端
- 日志记录
- 池配置
- 生产环境检查清单
- Registry 配置
- 运行时模式
- WASM 与原生 SDK