colabbd/docs/ADAPTERS.md

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": "..."}:

  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:

{"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:

  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.