Real-time collaboration (Yjs + WebSocket)
- Go WebSocket hub: rooms per document, broadcast updates, persist state - Yjs integration: connect/disconnect, sync document state - Collab toggle button in toolbar (Solo/Live) - When Live: edits broadcast to all connected users in real-time - Yjs state persisted to SQLite (survives server restart) - gorilla/websocket dependency added
This commit is contained in:
+8
-1
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"markdownhub/internal/api"
|
||||
"markdownhub/internal/auth"
|
||||
"markdownhub/internal/collab"
|
||||
"markdownhub/internal/db"
|
||||
"markdownhub/internal/files"
|
||||
)
|
||||
@@ -33,8 +34,14 @@ func main() {
|
||||
|
||||
router := api.NewRouter(database, dataDir, secret)
|
||||
|
||||
// Collab WebSocket hub
|
||||
hub := collab.NewHub(database)
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/ws/collab/", http.HandlerFunc(hub.HandleWebSocket))
|
||||
mux.Handle("/", router)
|
||||
|
||||
fmt.Printf("MarkdownHub listening on :%s\n", port)
|
||||
log.Fatal(http.ListenAndServe(":"+port, router))
|
||||
log.Fatal(http.ListenAndServe(":"+port, mux))
|
||||
}
|
||||
|
||||
func ensureAdminUser(database *db.DB, dataDir string) {
|
||||
|
||||
@@ -59,6 +59,9 @@
|
||||
<div class="toolbar-right">
|
||||
<span class="file-name">{{ currentFile || 'No file open' }}</span>
|
||||
<span class="file-meta" v-if="fileMeta">{{ fileMeta }}</span>
|
||||
<button v-if="currentFile" class="toolbar-btn" @click="toggleCollab" :title="collabActive ? 'Disconnect collab' : 'Enable collab'">
|
||||
{{ collabActive ? '👥 Live' : '👤 Solo' }}
|
||||
</button>
|
||||
<button v-if="currentFile" class="toolbar-btn" @click="showHistory = !showHistory" title="History">📜</button>
|
||||
<button v-if="currentFile" class="toolbar-btn" @click="showShareDialog = !showShareDialog" title="Share">🤝</button>
|
||||
<button v-if="currentFile" class="toolbar-btn" @click="aiVerify" title="Verify with AI">🤖 Verify</button>
|
||||
@@ -248,6 +251,7 @@ import MilkdownEditor from './components/MilkdownEditor.vue'
|
||||
import { api, setToken } from './lib/api.js'
|
||||
import { renderMarkdown } from './lib/markdown.js'
|
||||
import { cacheFile, getCachedFile, addPendingChange, getPendingChanges, clearAllPending } from './lib/offline.js'
|
||||
import { connectCollab, disconnectCollab, setCollabContent } from './lib/collab.js'
|
||||
|
||||
const authenticated = ref(false)
|
||||
const email = ref('')
|
||||
@@ -277,6 +281,7 @@ const shareMsg = ref('')
|
||||
const gitDirty = ref(0)
|
||||
const aiResult = ref('')
|
||||
const trashItems = ref([])
|
||||
const collabActive = ref(false)
|
||||
|
||||
// Preferences
|
||||
const prefs = ref({ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, defaultMode: 'split', theme: 'dark' })
|
||||
@@ -678,6 +683,23 @@ async function aiVerify() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Collab ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function toggleCollab() {
|
||||
if (collabActive.value) {
|
||||
disconnectCollab()
|
||||
collabActive.value = false
|
||||
} else {
|
||||
if (!currentFile.value) return
|
||||
const fileId = currentFile.value.replace(/[^a-zA-Z0-9]/g, '-')
|
||||
connectCollab(fileId, (newContent) => {
|
||||
content.value = newContent
|
||||
})
|
||||
setCollabContent(content.value)
|
||||
collabActive.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Formatting ──────────────────────────────────────────────────────────────
|
||||
|
||||
function insertFormat(before, after) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as Y from 'yjs'
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
|
||||
let ydoc = null
|
||||
let provider = null
|
||||
let ytext = null
|
||||
|
||||
/**
|
||||
* Connect to the collab WebSocket for a given file.
|
||||
* Returns the Yjs Text type that can be bound to an editor.
|
||||
*/
|
||||
export function connectCollab(fileId, onUpdate) {
|
||||
disconnectCollab()
|
||||
|
||||
ydoc = new Y.Doc()
|
||||
ytext = ydoc.getText('content')
|
||||
|
||||
const wsUrl = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws/collab/${fileId}`
|
||||
provider = new WebsocketProvider(wsUrl, fileId, ydoc, { connect: true })
|
||||
|
||||
ytext.observe((event) => {
|
||||
if (onUpdate) {
|
||||
onUpdate(ytext.toString())
|
||||
}
|
||||
})
|
||||
|
||||
provider.on('status', (event) => {
|
||||
console.log('[collab]', event.status)
|
||||
})
|
||||
|
||||
return { ydoc, ytext, provider }
|
||||
}
|
||||
|
||||
export function disconnectCollab() {
|
||||
if (provider) {
|
||||
provider.destroy()
|
||||
provider = null
|
||||
}
|
||||
if (ydoc) {
|
||||
ydoc.destroy()
|
||||
ydoc = null
|
||||
}
|
||||
ytext = null
|
||||
}
|
||||
|
||||
export function setCollabContent(text) {
|
||||
if (!ytext) return
|
||||
ydoc.transact(() => {
|
||||
ytext.delete(0, ytext.length)
|
||||
ytext.insert(0, text)
|
||||
})
|
||||
}
|
||||
|
||||
export function getCollabContent() {
|
||||
return ytext ? ytext.toString() : ''
|
||||
}
|
||||
|
||||
export function isConnected() {
|
||||
return provider && provider.wsconnected
|
||||
}
|
||||
@@ -5,6 +5,7 @@ go 1.23
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
golang.org/x/crypto v0.32.0
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlG
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package collab
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// Room holds all connected clients for a single document.
|
||||
type Room struct {
|
||||
mu sync.Mutex
|
||||
clients map[*websocket.Conn]bool
|
||||
}
|
||||
|
||||
// Hub manages all active collaboration rooms.
|
||||
type Hub struct {
|
||||
mu sync.Mutex
|
||||
rooms map[string]*Room
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewHub(db *sql.DB) *Hub {
|
||||
return &Hub{
|
||||
rooms: make(map[string]*Room),
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) getRoom(fileID string) *Room {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if r, ok := h.rooms[fileID]; ok {
|
||||
return r
|
||||
}
|
||||
r := &Room{clients: make(map[*websocket.Conn]bool)}
|
||||
h.rooms[fileID] = r
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *Hub) removeClient(fileID string, conn *websocket.Conn) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if r, ok := h.rooms[fileID]; ok {
|
||||
r.mu.Lock()
|
||||
delete(r.clients, conn)
|
||||
empty := len(r.clients) == 0
|
||||
r.mu.Unlock()
|
||||
if empty {
|
||||
delete(h.rooms, fileID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleWebSocket handles the Yjs WebSocket sync protocol.
|
||||
// Clients send binary Yjs update messages; the hub broadcasts to all others in the room.
|
||||
func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract file ID from path: /ws/collab/{fileID}
|
||||
path := r.URL.Path
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/ws/collab/"), "/")
|
||||
fileID := parts[0]
|
||||
if fileID == "" {
|
||||
http.Error(w, "file ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
room := h.getRoom(fileID)
|
||||
room.mu.Lock()
|
||||
room.clients[conn] = true
|
||||
room.mu.Unlock()
|
||||
|
||||
defer h.removeClient(fileID, conn)
|
||||
|
||||
// Send stored Yjs state if available
|
||||
var storedState []byte
|
||||
h.db.QueryRow("SELECT yjs_state FROM collab_state WHERE file_id = ?", fileID).Scan(&storedState)
|
||||
if storedState != nil {
|
||||
conn.WriteMessage(websocket.BinaryMessage, storedState)
|
||||
}
|
||||
|
||||
// Read loop: receive updates from this client, broadcast to others, persist
|
||||
for {
|
||||
msgType, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if msgType != websocket.BinaryMessage {
|
||||
continue
|
||||
}
|
||||
|
||||
// Persist latest state
|
||||
h.db.Exec(
|
||||
`INSERT INTO collab_state (file_id, yjs_state, updated_at) VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(file_id) DO UPDATE SET yjs_state = ?, updated_at = datetime('now')`,
|
||||
fileID, msg, msg,
|
||||
)
|
||||
|
||||
// Broadcast to other clients in the room
|
||||
room.mu.Lock()
|
||||
for client := range room.clients {
|
||||
if client != conn {
|
||||
client.WriteMessage(websocket.BinaryMessage, msg)
|
||||
}
|
||||
}
|
||||
room.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveUsers returns the number of connected users for a file.
|
||||
func (h *Hub) ActiveUsers(fileID string) int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if r, ok := h.rooms[fileID]; ok {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.clients)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user