collaborative-text-editor

작성자: rivet-dev

Yjs CRDT와 Rivet Actors를 사용하여 협업 텍스트 편집기 백엔드를 구축합니다: 문서별 액터가 동기화 및 인식 업데이트를 중계하고 스냅샷을 유지합니다.

npx skills add https://github.com/rivet-dev/skills --skill collaborative-text-editor

Collaborative Text Editor

IMPORTANT: Before doing anything, you MUST read BASE_SKILL.md in this skill's directory. It contains essential guidance on debugging, error handling, state management, deployment, and project setup. Those rules and patterns apply to all RivetKit work. Everything below assumes you have already read and understood it.

Working Examples

If you need a reference implementation, read the raw working example code in these templates:

Patterns for building a Yjs server on RivetKit: CRDT document sync, presence and cursors, and snapshot persistence, with one Rivet Actor per document acting as a relay.

Starter Code

Start with the working example on GitHub and adapt it to your editor. It ships a React frontend with a plain textarea, remote cursor overlays, and a workspace document index.

Use CaseStarter CodeCommon Examples
Shared document editingGitHubNotion-style docs, shared notes, pair-writing tools, form co-editing

CRDT vs OT

Two families of algorithms solve concurrent text editing. The choice decides what your server has to do.

DimensionCRDT (Yjs)Operational Transformation
Conflict resolution modelCommutative merges. Updates apply in any order on any peer and converge to the same result.Server transforms each operation against every concurrent operation. Correctness depends on a central sequencer.
Offline supportStrong. Clients keep editing locally and merge buffered updates on reconnect.Weak. Long-lived divergence makes transformation chains complex and fragile.
Server roleRelay plus persistence. The server applies opaque updates and rebroadcasts them. It never needs to understand document semantics.Authoritative transformer. The server must implement transformation logic for every operation type.
Library maturityYjs is mature and widely deployed, with bindings for ProseMirror, CodeMirror, Monaco, and others.Production-grade implementations are mostly proprietary (Google Docs) or aging (ShareDB).

The example uses Yjs because CRDTs let the server stay a relay-style Rivet Actor. The actor applies each incoming update to a server-side Y.Doc so it can persist the merged state and serve late joiners, but it never transforms operations or arbitrates conflicts. Ordering does not matter because Yjs merges are commutative.

Document Actor Model

TopicSummary
TopologyOne document[workspaceId, documentId] actor per document plus one documentList[workspaceId] coordinator per workspace.
Sync modelEach client holds a local Y.Doc. The document actor relays incremental Yjs updates as broadcast events and keeps a server-side merged copy in vars.
PersistenceFull merged Yjs snapshot overwritten in one binary actor KV key (yjs:doc) on every sync update. Document metadata lives in JSON state.
QueuesNone. The example is purely actions plus broadcast events.
PresenceYjs Awareness relayed through the same applyUpdate action. Per-connection connState tracks asserted awareness clientIds for disconnect cleanup.

The two-actor split follows the coordinator pattern from Design Patterns: the coordinator owns discovery and creation, and each document actor owns one document's realtime state. Multi-part keys scope both actors to a workspace.

Actors

  • Key: document[workspaceId, documentId]

  • Responsibility: Applies incoming sync and awareness updates to a server-side Y.Doc and Awareness, persists the merged Yjs snapshot to actor KV, and broadcasts updates to all connected collaborators.

  • Actions

    • getContent
    • applyUpdate
    • getAwareness
  • Queues

    • None
  • State

    • JSON metadata only: title, createdAt, updatedAt
    • Binary KV key yjs:doc holding the full merged Yjs snapshot
    • Ephemeral vars: the live Y.Doc and Awareness, created in createVars and rehydrated from KV on actor start
    • Per-connection connState: clientIds of awareness clients asserted by that connection
  • Key: documentList[workspaceId]

  • Responsibility: Coordinator for one workspace. Creates document actors through the actor-to-actor client and maintains the index of document summaries.

  • Actions

    • createDocument
    • listDocuments
    • deleteDocument
  • Queues

    • None
  • State

    • JSON
    • documents array of DocumentSummary entries (id, title, createdAt, updatedAt)

The coordinator's createDocument generates a UUID, then explicitly creates the document actor with c.client<typeof registry>() and passes { title, createdAt } as creation input, which the document actor's createState consumes. See Communicating Between Actors for the actor-to-actor client.

Update Relay

A single applyUpdate(update, kind, clientId?) action handles both update kinds. Updates cross the action boundary as number[] byte arrays and are converted back to Uint8Array on each side.

KindServer Applies ToPersistsBroadcasts
"sync"c.vars.doc via Y.applyUpdate with origin "client"Full merged snapshot to KV key yjs:doc, then bumps updatedAtsync event carrying the incremental update
"awareness"c.vars.awareness via applyAwarenessUpdate with origin "client"Nothing. Presence is ephemeral.awareness event carrying the update

Note the asymmetry on the sync branch: the broadcast carries only the small incremental update, while the KV write stores the full merged document re-encoded with Y.encodeStateAsUpdate.

Yjs origin tags are the echo guards that keep the relay loop-free:

Origin TagSet WhereEffect
"local"Client edits inside doc.transact(..., "local")The client's update listener fires and sends applyUpdate to the actor.
"client"Server applying an incoming update to its Y.Doc or AwarenessMarks the change as client-originated on the server copy.
"remote"Client applying broadcast events or initial sync dataUpdate listeners early-return on "remote", so a client never re-sends its own echo.

On connect or reconnect, the client calls getContent and getAwareness, then applies both results to its local Y.Doc and Awareness with origin "remote". After that, every change flows through applyUpdate and the broadcast events.

Awareness And Presence

Presence (user names, colors, cursor positions) rides on the Yjs Awareness protocol instead of actor state:

  • Clients set presence with awareness.setLocalStateField for the user and cursor fields. The awareness update listener encodes the change and sends applyUpdate(update, "awareness", awareness.clientID).
  • The actor records each asserted clientId in that connection's connState.clientIds, applies the update to the server-side Awareness, and broadcasts the awareness event to all peers. See Connections for per-connection state.
  • onDisconnect reads the connection's clientIds, calls removeAwarenessStates on the server-side Awareness, and broadcasts the encoded removal so every remaining client drops the departed user's cursor. See Lifecycle for the hook.

Because the actor tracks which awareness clientIds belong to which connection, presence cleanup is automatic on disconnect with no client cooperation required.

Persistence And Compaction

The example persists with a full-snapshot overwrite: on every "sync" update, the actor re-encodes the entire merged document with Y.encodeStateAsUpdate and overwrites the single binary KV key yjs:doc. There is no append-only update log and no separate compaction job. Compaction is implicit because Y.encodeStateAsUpdate emits one compact merged representation of the document, so Yjs merge semantics keep the stored blob compact on their own.

PropertyFull-Snapshot Overwrite (the example)
Write costOne full-document KV write per sync update, so every keystroke rewrites the whole blob.
Read costOne binary KV read in createVars rehydrates the document on actor start.
Crash safetyThe last completed applyUpdate is durable. No log replay needed.
Sweet spotSmall to medium documents where simplicity beats write amplification.

Recommended extension (not in the example): for large documents or very high edit rates, switch to appending incremental updates to a KV update log and writing a merged snapshot only periodically (for example every N updates). Boot becomes snapshot plus log replay, and the snapshot write becomes the explicit compaction step that truncates the log. Adopt this only when full-snapshot writes become the measured bottleneck or the blob approaches KV value size limits.

Lifecycle

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)

Security Checklist

The example ships with no authentication or authorization. Harden it with this baseline before production. None of these are implemented in the example.

  • Authenticate before connect: Anyone who knows or guesses a workspace ID can connect, and because useActor implicitly getOrCreates, connecting with a nonexistent workspace ID silently creates a blank documentList coordinator. Add connection auth so unauthenticated clients never reach an actor. See Authentication.
  • Per-document access control: Validate that the authenticated user is allowed to access the specific [workspaceId, documentId] key, not just any document.
  • Cap and rate limit applyUpdate: Update payloads are unvalidated number[] arrays with no size limit, and the example client sends one action per keystroke and per cursor move with zero throttling. Enforce payload size caps and per-connection rate limits on the server, and debounce on the client.
  • Do not trust client-asserted awareness clientIds: The clientId argument to applyUpdate is client-supplied and trusted as-is. Derive or verify presence identity from connection-scoped server state instead.
  • Destroy actors and KV on delete: deleteDocument only filters the entry out of the coordinator's index. The document actor and its KV snapshot are orphaned. On delete, also destroy the document actor and its storage, with a permission check on who may delete.

Reference Map

Actors

Cli

Clients

Cookbook

Deploy

General

Self Hosting

rivet-dev의 다른 스킬

ai-agent
rivet-dev
지속적 메모리를 갖춘 AI 에이전트 백엔드 구축: 대화당 하나의 Rivet Actor, 대기열 메시지 처리, 실시간 이벤트로 스트리밍되는 LLM 응답.
official
ai-agent-workspace
rivet-dev
모든 AI 에이전트에게 자신만의 컴퓨터를 부여하세요: 경량 인프로세스 상에서 파일 시스템, 프로세스, 셸, 네트워킹 및 에이전트 세션을 갖춘 영구 작업 공간입니다…
official
chat-room
rivet-dev
Rivet Actors로 실시간 채팅방 백엔드 구축: 방마다 하나의 액터, SQLite 기반 메시지 기록, 모든 연결된 클라이언트에 WebSocket 브로드캐스트.
official
cron-jobs
rivet-dev
Rivet Actors를 사용한 내구성 있는 크론 작업: schedule.after 및 schedule.at 타이머는 재시작과 충돌에도 유지되며, 반복 작업 재설정 및 멱등성 핸들러를 지원합니다.
official
live-cursors
rivet-dev
Rivet Actors를 사용한 라이브 커서 및 멀티플레이어 프레즌스: 연결별 커서 상태, 이벤트 또는 원시 WebSocket을 통한 실시간 업데이트, 스로틀링.
official
per-tenant-database
rivet-dev
멀티 테넌트 데이터 격리를 위해 테넌트당 하나의 Rivet 액터를 사용합니다. 액터 키는 테넌트 ID이므로 각 테넌트는 자체 격리된 데이터셋과 마이그레이션을 갖습니다.
official
rivetkit-client-javascript
rivet-dev
JavaScript 클라이언트로, 무상태 또는 상태 저장 연결을 통해 Rivet Actors에 연결합니다. 브라우저, Node.js 및 Bun 환경을 지원하며, 환경 변수 또는 명시적 구성을 통한 자동 엔드포인트 감지 기능을 제공합니다. 독립적인 요청을 위한 무상태 액션 호출과 실시간 이벤트 구독이 가능한 상태 저장 연결의 두 가지 상호작용 모드를 제공합니다. onRequest 또는 onWebSocket 핸들러를 구현하는 액터를 위한 저수준 HTTP 및 WebSocket 액세스를 포함하며, 복합 배열 기반...
official
rivetkit-client-react
rivet-dev
React 클라이언트로, 훅과 실시간 상태 관리를 통해 Rivet Actors에 연결합니다. createRivetKit()으로 타입이 지정된 훅을 생성하고, useActor()로 키와 선택적 매개변수를 사용하여 액터 인스턴스에 연결합니다. useEvent()로 액터 이벤트를 구독하고, connStatus와 error 상태를 통해 연결 수명 주기를 모니터링합니다. createClient()를 사용하여 상태 비저장 단일 호출, 액터 검색 메서드(get, getOrCreate, create, getForId), 그리고 저수준 HTTP/WebSocket 액세스를 수행합니다. 복합 배열 키를 지원합니다...
official