Add adapter implementation guide
This commit is contained in:
parent
5d4b144604
commit
8663071299
1 changed files with 177 additions and 0 deletions
177
docs/ADAPTERS.md
Normal file
177
docs/ADAPTERS.md
Normal 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`.
|
||||
Loading…
Reference in a new issue