P2P Collab File Editing

#14 · open · 0 comments

View on GitHub ↗

SeanPedersen

# Yjs P2P Collaborative Editing for Marko ## Context Marko currently has no collaborative editing. Users want to co-edit markdown files with trusted peers on the same LAN (or remotely) without any cloud server. We'll use Yjs (CRDT) + y-codemirror.next for conflict-free offline-capable editing, WebRTC data channels for transport, and mDNS for zero-config LAN discovery. Up to 12 peers per session, persistent trust, colored cursors with name labels. ## Requirements | Decision | Choice | |----------|--------| | Discovery | mDNS auto-discovery on LAN | | Offline behavior | Both keep editing, CRDT merges on reconnect | | Trust model | Persistent trusted peers (revocable) | | Session size | Multi-peer, up to 12 | | Presence | Colored cursor + name label | | Peers UI | New sidebar pane (like FolderExplorer) | | Transport | WebRTC data channels (y-webrtc) | | Identity | Device-based (Ed25519 keypair, user-chosen display name) | | Guest file saving | Continuous auto-save to guest-chosen local path with `marko-collab` frontmatter metadata | ## Libraries - [yjs](https://github.com/yjs/yjs) — CRDT document model - [y-codemirror.next](https://github.com/yjs/y-codemirror.next) — CodeMirror 6 binding for Yjs - [y-webrtc](https://github.com/nicoth-in/y-webrtc) — WebRTC provider for Yjs - [y-indexeddb](https://github.com/nicoth-in/y-indexeddb) — IndexedDB persistence for offline resilience - Rust: `ed25519-dalek`, `mdns-sd`, `tokio-tungstenite` --- ## Phase 1: Device Identity & Trust Store **Goal**: Persistent device identity + trusted peers list managed from Rust backend. **New Rust crates**: `ed25519-dalek 2`, `rand 0.8`, `base64 0.22` ### New files | File | Purpose | |------|---------| | `src-tauri/src/identity.rs` | Generate Ed25519 keypair on first launch → `identity.json` in `app_data_dir()`. Exports: `init_identity()`, `get_device_id()` (base64 pubkey), `get_display_name()`, `set_display_name()` | | `src-tauri/src/trust.rs` | `TrustedPeer { device_id, display_name, last_seen }`. CRUD + persist to `trusted_peers.json`. Exports: `load_trusted_peers()`, `add_trusted_peer()`, `remove_trusted_peer()`, `is_trusted()` | | `src/lib/stores/collab.svelte.ts` | Svelte 5 runes store: `deviceId`, `displayName`, `trustedPeers[]`, `discoveredPeers[]`, `activeSessions` (Map<tabId, SessionState>). Calls Tauri commands on init. | ### Modified files | File | Changes | |------|---------| | `src-tauri/src/lib.rs` | Add `mod identity; mod trust;`, register commands, call `identity::init_identity()` in `setup()` | | `src-tauri/Cargo.toml` | Add `ed25519-dalek`, `rand`, `base64` | | `src/lib/stores/settings.svelte.ts` | Add `displayName: string`, `peersPosition: SidebarPosition` | --- ## Phase 2: mDNS LAN Discovery **Goal**: Auto-discover Marko instances on the same network. **New Rust crate**: `mdns-sd 0.11` ### New files | File | Purpose | |------|---------| | `src-tauri/src/discovery.rs` | Registers `_marko-collab._tcp.local` service. TXT records: device_id, display_name, signaling_port. Emits `peer-discovered` / `peer-lost` events to frontend via `AppHandle::emit()`. Commands: `start_discovery()`, `stop_discovery()`, `get_discovered_peers()` | ### Modified files | File | Changes | |------|---------| | `src-tauri/src/lib.rs` | Add `mod discovery;`, register commands, start mDNS in `setup()` | | `src/lib/stores/collab.svelte.ts` | Listen for Tauri `peer-discovered`/`peer-lost` events, maintain `discoveredPeers` as `$state` | --- ## Phase 3: Signaling & WebRTC Transport **Goal**: Establish WebRTC data channels between peers for Yjs document sync. **New npm packages**: `yjs ^13`, `y-webrtc ^10`, `y-codemirror.next ^0.3`, `lib0 ^0.2` **New Rust crate**: `tokio-tungstenite 0.24` ### Signaling approach Run a local WebSocket server in Rust (via `tokio-tungstenite`) on a random port. The port is advertised via mDNS TXT record. `y-webrtc` connects to the host's `ws://host-ip:port` for SDP exchange. After WebRTC handshake, the WS connection is only used for new peer joins. ### New files | File | Purpose | |------|---------| | `src-tauri/src/signaling.rs` | Minimal WS signaling server compatible with y-webrtc protocol. Verifies connecting peer's device_id against trusted list before relaying. Commands: `start_signaling_server()` → returns port, `stop_signaling_server()` | | `src/lib/collab/yjsManager.ts` | Core module. Creates `Y.Doc` per file path. Manages `WebrtcProvider` lifecycle. Room name = `sha256(filePath + hostDeviceId)`. Exports: `startSession()`, `stopSession()`, `getSession()` | | `src/lib/collab/presence.ts` | Sets local awareness state `{ name, color, cursor }`. Exports CM6 extension that renders remote cursors as colored lines + name labels via `Decoration.widget` | | `src/lib/collab/colors.ts` | `peerColor(index): { cursor, selection, label }` — 12 distinct colors, deterministic by peer index | --- ## Phase 4: Dual-Mode CodeMirror (Value-Prop vs Yjs Binding) **Goal**: When a file enters collab mode, Yjs owns the document. Non-collab files keep the current `value` prop flow. ### Key design: coexistence of value-prop and Yjs ``` Non-collab tab: value prop → $effect → view.setState() → onchange → auto-save Collab tab: Y.Text ←→ yCollab binding ←→ EditorView → onchange → auto-save (host saves to original, guest saves to local copy) ↕ (WebRTC) Remote peers ``` ### New files | File | Purpose | |------|---------| | `src/lib/collab/collabExtension.ts` | `createCollabExtensions(ytext, awareness, user)` → returns `Extension[]` containing `yCollab(ytext, awareness)` + presence cursor decorations. Replaces `history()` with `Y.UndoManager`-backed undo/redo. | ### Modified files | File | Changes | |------|---------| | `CodeMirrorEditor.svelte` | New optional props: `collabYText`, `collabAwareness`, `collabUser`. `createExtensions()` swaps history for collab extensions when in collab mode. Value-sync `$effect` early-returns when Yjs is active. `onchange` still fires for auto-save. | | `tabs.svelte.ts` | Add `collabSessionId?: string`, `isCollabHost?: boolean`, `collabLocalPath?: string` to Tab interface. | | `MarkdownViewer.svelte` | Wire collab session lookup, pass Yjs props to editor. | --- ## Phase 5: Guest Local Copy & Frontmatter Metadata **Goal**: Guests continuously auto-save a local copy of the shared file with provenance metadata in frontmatter. ### Guest join flow 1. Guest accepts collab invite → file picker dialog to choose save location (folder + filename) 2. Local file is created with frontmatter metadata + current Y.Doc content 3. Auto-save (1s debounce, same as host) writes Y.Doc content to the guest's local path 4. On session end, guest keeps their local copy (already saved) ### Frontmatter metadata (injected/updated by guest) ```yaml --- marko-collab: host-device-id: "base64-pubkey-of-host" host-display-name: "Alice's MacBook" host-file-path: "/Users/alice/notes/project.md" session-room: "sha256-room-id" last-sync: "2026-03-16T14:32:00Z" --- ``` ### Modified files | File | Changes | |------|---------| | `src/lib/collab/yjsManager.ts` | On guest join: prompt for save path, create local file with frontmatter + content. On Y.Doc update (debounced): write `frontmatter + ytext.toString()` to guest's local path. Update `last-sync` timestamp on each save. | | `src/lib/utils/frontmatter.ts` | Add `injectCollabFrontmatter(content, metadata)` and `stripCollabFrontmatter(content)` utilities. Preserve existing frontmatter — merge `marko-collab` key into it. | | `tabs.svelte.ts` | Add `collabLocalPath?: string` to Tab interface (guest's chosen save path). | | `MarkdownViewer.svelte` | Guest auto-save: when `collabSessionId` is set and `!isCollabHost`, save to `collabLocalPath` with frontmatter. | --- ## Phase 6: Peers Sidebar & Share Button (UI) **Goal**: Add the user-facing collaboration controls. ### New files | File | Purpose | |------|---------| | `PeersSidebar.svelte` | 220px fixed sidebar (follows FolderExplorer pattern). Sections: **Identity** (display name inline-editable + device ID with copy), **Discovered Peers** (LAN peers from mDNS, green dot = online, trust/untrust button), **Trusted Peers** (persistent list, last-seen timestamp, remove button), **Active Session** (colored dots per peer, connection quality indicator) | ### Modified files | File | Changes | |------|---------| | `EditorHeader.svelte` | Add **Share button** (users icon). Click → peer picker dropdown → "Start Session". During collab: peer count badge, "Stop Sharing" option. Connection status dot. | | `TitleBar.svelte` | Add **Peers toggle button** next to folder explorer toggle. Badge shows connected peer count. | | `MarkdownViewer.svelte` | Add `peersVisible` state. Wire PeersSidebar. Handle incoming collab invites (notification banner with accept/reject). | | `settings.svelte.ts` | Add `peersPosition` setting. | ### Share flow 1. User clicks "Share" in EditorHeader 2. Dropdown shows discovered + trusted peers 3. User selects peers → clicks "Start Session" 4. `yjsManager.startSession()` creates Y.Doc, populates Y.Text from current file content 5. Host's signaling server room is created 6. Selected peers receive invite notification (via signaling WS) 7. Accepting peer picks local save path → file created with `marko-collab` frontmatter 8. Both editors bind to shared Y.Doc, show colored cursors 9. Both host and guest auto-save continuously (host to original path, guest to chosen path with frontmatter) --- ## Phase 7: Offline Resilience & Reconnection **Goal**: Peers continue editing when disconnected; CRDT merges on reconnect. **New npm package**: `y-indexeddb ^9` ### Modified files | File | Changes | |------|---------| | `yjsManager.ts` | Add `IndexeddbPersistence` per Y.Doc. On app restart: restore Y.Doc from IndexedDB, attempt reconnect. Track connection state (`connected`/`reconnecting`/`disconnected`). Stale session cleanup: >24h without activity are purged. | | `PeersSidebar.svelte` | Per-peer status dot (green/yellow/red). "Offline" banner when all peers disconnected. | | `EditorHeader.svelte` | Connection status indicator next to share button. | --- ## Phase 8: Security & Trust Enforcement **Goal**: Only trusted peers can join sessions. Ed25519 challenge-response auth. ### Modified files | File | Changes | |------|---------| | `signaling.rs` | On WS connect: peer sends signed challenge. Server verifies signature against device_id pubkey, checks trusted list. Rejects untrusted peers. | | `trust.rs` | Add `verify_signature(device_id, challenge, signature)` using ed25519-dalek. | | `yjsManager.ts` | On incoming WebRTC connection: verify peer identity. Disconnect untrusted peers. | | `PeersSidebar.svelte` | "Connection rejected" notification for untrusted peers. One-click "Trust this peer" action. | Trust is **bidirectional**: both peers must have each other in their trusted list. --- ## Architecture Diagram ``` ┌──────────────────────────────────────────────────────────┐ │ Frontend (Svelte 5) │ │ │ │ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │ │ │ TitleBar │ │ EditorHeader │ │ PeersSidebar │ │ │ │ [Peers btn] │ │ [Share btn] │ │ [Discovery] │ │ │ └──────┬───────┘ └──────┬───────┘ │ [Trust mgmt] │ │ │ │ │ │ [Active peers] │ │ │ │ ┌────────────▼───┐ └───────┬───────┘ │ │ │ │ MarkdownViewer │ │ │ │ └────┤ (orchestrator) ├───────────────┘ │ │ └───────┬───────┘ │ │ │ │ │ ┌────────────▼────────────┐ │ │ │ CodeMirrorEditor │ │ │ │ [dual-mode: value/Yjs] │ │ │ └────────────┬────────────┘ │ │ │ │ │ ┌────────────▼────────────┐ │ │ │ collab/ │ │ │ │ ├─ yjsManager.ts │ Y.Doc lifecycle │ │ │ ├─ collabExtension.ts │ CM6 ↔ Yjs binding │ │ │ ├─ presence.ts │ Remote cursors │ │ │ └─ colors.ts │ Peer colors │ │ └────────────┬────────────┘ │ │ │ WebRTC │ └──────────────────────┼────────────────────────────────────┘ │ ┌──────────────────────┼────────────────────────────────────┐ │ Backend (Rust/Tauri)│ │ │ │ │ │ ┌───────────────┐ ┌─▼──────────────┐ ┌───────────────┐ │ │ │ identity.rs │ │ signaling.rs │ │ discovery.rs │ │ │ │ Ed25519 keys │ │ WS relay │ │ mDNS broadcast│ │ │ └───────────────┘ │ trust-gated │ │ + scan │ │ │ ┌───────────────┐ └────────────────┘ └───────────────┘ │ │ │ trust.rs │ │ │ │ trusted peers │ │ │ └───────────────┘ │ └──────────────────────────────────────────────────────────┘ ``` --- ## File Summary ### New files (10) | File | Phase | |------|-------| | `src-tauri/src/identity.rs` | 1 | | `src-tauri/src/trust.rs` | 1 | | `src-tauri/src/discovery.rs` | 2 | | `src-tauri/src/signaling.rs` | 3 | | `src/lib/stores/collab.svelte.ts` | 1 | | `src/lib/collab/yjsManager.ts` | 3 | | `src/lib/collab/presence.ts` | 3 | | `src/lib/collab/colors.ts` | 3 | | `src/lib/collab/collabExtension.ts` | 4 | | `src/lib/components/PeersSidebar.svelte` | 6 | ### Modified files (9) | File | Phases | |------|--------| | `src-tauri/src/lib.rs` | 1, 2, 3 | | `src-tauri/Cargo.toml` | 1, 2, 3 | | `package.json` | 3, 7 | | `src/lib/components/CodeMirrorEditor.svelte` | 4 | | `src/lib/stores/tabs.svelte.ts` | 4, 5 | | `src/lib/stores/settings.svelte.ts` | 1, 6 | | `src/lib/MarkdownViewer.svelte` | 4, 5, 6 | | `src/lib/components/EditorHeader.svelte` | 6, 7 | | `src/lib/components/TitleBar.svelte` | 6 | ## Dependencies **npm**: `yjs ^13`, `y-webrtc ^10`, `y-codemirror.next ^0.3`, `lib0 ^0.2`, `y-indexeddb ^9` **Rust**: `ed25519-dalek 2`, `rand 0.8`, `base64 0.22`, `mdns-sd 0.11`, `tokio-tungstenite 0.24` ## Risks & Mitigations 1. **y-webrtc signaling**: expects WebSocket. We run a local WS server in Rust (`tokio-tungstenite`) rather than patching y-webrtc internals. 2. **`setState()` destroys collab**: early-return in the value-sync `$effect` when collab is active. Y.Doc owns the document state. 3. **Auto-save during collab**: host writes to original path, guest writes to chosen local path with `marko-collab` frontmatter. Both use same 1s debounce. 4. **macOS firewall**: mDNS + signaling server will trigger system network permission dialog. Standard Tauri behavior. 5. **Large doc initial sync**: show "Syncing..." overlay during first Y.Doc state exchange.

Comments