4.9 KiB
Writing Editor Adapters for collabd
This guide explains how to write an adapter that lets any editor participate in collaborative editing sessions.
Architecture
┌─────────────┐ json/channel ┌─────────────┐ websocket ┌─────────────┐
│ Editor │ ◄──────────────────► │ Bridge │ ◄────────────────► │ Daemon │
│ (plugin) │ │ (bun) │ │ :4040 │
└─────────────┘ └─────────────┘ └─────────────┘
The bridge is a Bun process that:
- Manages the Yjs document (CRDT state)
- Translates editor buffer changes to Yjs operations
- Translates remote Yjs updates to buffer content
- Speaks a simple JSON protocol with the editor plugin
The plugin is editor-specific code that:
- Hooks buffer change events
- Sends buffer content to bridge
- Applies remote content from bridge
- Optionally displays peer cursors
Why a Bridge?
Most editors can't embed Yjs directly:
- Vim: No npm/node, limited async
- Helix: Rust-only plugins
- Kakoune: Shell-based scripting
The bridge isolates CRDT complexity. Plugins stay simple.
Protocol: Plugin ↔ Bridge
Messages are newline-delimited JSON.
Plugin → Bridge
{"type": "connect", "room": "myroom"}
{"type": "disconnect"}
{"type": "content", "text": "full buffer contents"}
{"type": "cursor", "line": 5, "col": 10}
Bridge → Plugin
{"type": "ready"}
{"type": "connected", "room": "myroom"}
{"type": "disconnected"}
{"type": "content", "text": "full buffer contents"}
{"type": "peers", "count": 2}
{"type": "cursor", "data": {"clientId": 123, "line": 5, "col": 10, "name": "peer-123"}}
{"type": "error", "message": "connection failed"}
Implementing a Plugin
1. Spawn the Bridge
bun run adapters/vim/bridge.ts
Or use your own bridge (see next section).
Communication via stdin/stdout with JSON lines.
2. Wait for Ready
Bridge sends {"type": "ready"} when it starts. Then send connect:
{"type": "connect", "room": "myroom"}
3. Handle Content Updates
When you receive {"type": "content", "text": "..."}:
- Save cursor position
- Replace buffer contents with received text
- Restore cursor position (clamped to valid range)
Critical: Do NOT trigger your change handler when applying remote content, or you'll create an infinite loop.
4. Send Local Changes
When user edits the buffer:
- Get full buffer text
- Send
{"type": "content", "text": "full text"}
The bridge handles diffing - just send the whole buffer.
5. Cursor Sync (Optional)
On cursor move:
{"type": "cursor", "line": 5, "col": 10}
On receiving cursor:
{"type": "cursor", "data": {"line": 5, "col": 10, "name": "peer"}}
Render a highlight/virtual text at that position.
Implementing a Custom Bridge
If you need a bridge in a different language (e.g., Rust for Helix), implement the same protocol. Key responsibilities:
-
Connect to daemon at
ws://localhost:4040/ws -
Manage Y.Doc - create on connect, apply updates
-
Diff buffer changes - when receiving content from plugin:
old_text = current Y.Text content new_text = received buffer content diff = compute_diff(old, new) apply diff as Y.Text operations -
Forward updates - when Y.Doc changes from remote:
send full text to plugin as {"type": "content", ...} -
Handle awareness - forward cursor positions both ways
Yjs Libraries by Language
- JavaScript/TypeScript:
yjs(npm) - Rust:
yrs(crates.io) - Yjs port - Python:
pycrdt(pypi) - Go:
yjs-go(experimental)
Reference: Vim Adapter
See adapters/vim/ for a complete example:
collab.vim- Vim9script pluginbridge.ts- TypeScript bridge process
The vim plugin:
- Spawns bridge as job
- Communicates via channels (vim's async IPC)
- Sets TextChanged autocmd to detect edits
- Applies remote content with
:delete+setline()
Testing Your Adapter
- Start daemon:
just dev - Open editor A, join room "test"
- Open editor B (vim works), join room "test"
- Type in one - should appear in other within 100ms
- Type in both simultaneously - should converge
Common Pitfalls
Feedback loops: Applying remote content triggers your change handler, which sends it back. Use a flag to suppress during remote apply.
Cursor jitter: Cursor moves during remote apply. Save/restore position.
Large files: Sending full buffer on every keystroke is fine for <1MB. For larger files, consider debouncing or incremental updates.
Encoding: Always UTF-8. Line endings should be \n.