Add adapter implementation guide

This commit is contained in:
Jared Miller 2026-01-27 21:48:25 -05:00
parent 5d4b144604
commit 8663071299
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C

177
docs/ADAPTERS.md Normal file
View file

@ -0,0 +1,177 @@
# 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
```json
{"type": "connect", "room": "myroom"}
{"type": "disconnect"}
{"type": "content", "text": "full buffer contents"}
{"type": "cursor", "line": 5, "col": 10}
```
### Bridge → Plugin
```json
{"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:
```json
{"type": "connect", "room": "myroom"}
```
### 3. Handle Content Updates
When you receive `{"type": "content", "text": "..."}`:
1. Save cursor position
2. Replace buffer contents with received text
3. 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:
1. Get full buffer text
2. Send `{"type": "content", "text": "full text"}`
The bridge handles diffing - just send the whole buffer.
### 5. Cursor Sync (Optional)
On cursor move:
```json
{"type": "cursor", "line": 5, "col": 10}
```
On receiving cursor:
```json
{"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:
1. **Connect to daemon** at `ws://localhost:4040/ws`
2. **Manage Y.Doc** - create on connect, apply updates
3. **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
```
4. **Forward updates** - when Y.Doc changes from remote:
```
send full text to plugin as {"type": "content", ...}
```
5. **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 plugin
- `bridge.ts` - TypeScript bridge process
The vim plugin:
1. Spawns bridge as job
2. Communicates via channels (vim's async IPC)
3. Sets TextChanged autocmd to detect edits
4. Applies remote content with `:delete` + `setline()`
## Testing Your Adapter
1. Start daemon: `just dev`
2. Open editor A, join room "test"
3. Open editor B (vim works), join room "test"
4. Type in one - should appear in other within 100ms
5. 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`.