refactor(crdt): implement proxy-resilient Yjs dual-transport sync with indexeddb offline-first and debounced sqlite persistence
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m11s

This commit is contained in:
sandrodach committed 2026-10-26 19:54:51 +01:00
1 parent 297f3ed111
commit f3b3f07e04
10 files changed
+1248 -484

No files matched your search

+163 -91
View File
@@ -69,6 +69,9 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/ws", s.handleWebSocket)
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
r.Get("/api/v1/ws/docs/{id}", s.handleDocWebSocket)
r.Get("/api/v1/sync/docs/{id}/stream", s.handleDocSyncStream)
r.Post("/api/v1/sync/docs/{id}/update", s.handleDocSyncUpdate)
r.Get("/api/v1/events", s.handleSSE)
r.Post("/api/v1/events/rooms", s.handleSSERooms)
@@ -868,16 +871,6 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
// Check document lock
if lock := s.wsHub.GetDocLock(docID); lock != nil && lock.UserID != userID {
w.WriteHeader(http.StatusLocked)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Dokument wird aktuell bearbeitet von " + lock.UserName,
"currentLock": lock,
})
return
}
var req struct {
Title *string `json:"title"`
Icon *string `json:"icon"`
@@ -907,16 +900,6 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
// Check document lock
if lock := s.wsHub.GetDocLock(docID); lock != nil && lock.UserID != userID {
w.WriteHeader(http.StatusLocked)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Dokument wird aktuell bearbeitet von " + lock.UserName,
"currentLock": lock,
})
return
}
var req domain.DocBlocksPatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
@@ -1324,68 +1307,178 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, users)
}
// --- DOCUMENT LOCKING ---
// --- DOCUMENT LOCKING (Neutralized - Yjs CRDT Pessimistic Locks Removed) ---
func (s *Server) handleGetDocLock(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
lock := s.wsHub.GetDocLock(docID)
if lock == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": false})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": true, "lock": lock})
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": false})
}
func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
user, err := s.store.GetUserByID(userID)
userName := "Ein Benutzer"
if err == nil && user != nil && user.DisplayName != "" {
userName = user.DisplayName
}
info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 20*time.Second)
if !acquired {
w.WriteHeader(http.StatusLocked)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"locked": true,
"acquired": false,
"currentLock": currentLock,
"message": "Wird aktuell bearbeitet von " + currentLock.UserName,
})
return
}
s.wsHub.BroadcastDocEvent(docID, "DOC_LOCKED", info)
writeJSON(w, http.StatusOK, map[string]interface{}{
"locked": true,
"locked": false,
"acquired": true,
"lock": info,
"docId": docID,
})
}
func (s *Server) handleHeartbeatDocLock(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
ok := s.wsHub.HeartbeatDocLock(docID, userID, 20*time.Second)
writeJSON(w, http.StatusOK, map[string]bool{"extended": ok})
writeJSON(w, http.StatusOK, map[string]bool{"extended": true})
}
func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
user, _ := s.store.GetUserByID(userID)
isAdmin := user != nil && user.Role == "admin"
released := s.wsHub.ReleaseDocLock(docID, userID, isAdmin)
if released {
s.wsHub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
}
writeJSON(w, http.StatusOK, map[string]bool{"unlocked": released})
writeJSON(w, http.StatusOK, map[string]bool{"unlocked": true})
}
// --- DOCUMENT CRDT REALTIME SYNC ---
// --- DOCUMENT CRDT REALTIME SYNC & DUAL TRANSPORT ---
func (s *Server) authenticateUserFromReq(r *http.Request) string {
token := r.URL.Query().Get("token")
if token == "" {
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
token = strings.TrimPrefix(authHdr, "Bearer ")
}
}
if token != "" {
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
return claims.UserID
}
}
return ""
}
// GET /api/v1/ws/docs/{id}
func (s *Server) handleDocWebSocket(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Sec-Websocket-Key") != "" {
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") {
r.Header.Set("Connection", "Upgrade")
}
if !strings.Contains(strings.ToLower(r.Header.Get("Upgrade")), "websocket") {
r.Header.Set("Upgrade", "websocket")
}
}
docID := chi.URLParam(r, "id")
if docID == "" {
writeError(w, http.StatusBadRequest, "docId erforderlich")
return
}
userID := s.authenticateUserFromReq(r)
if userID == "" {
userID = auth.GetUserID(r.Context())
}
if userID == "" {
userID = "anonymous"
}
if userID != "anonymous" {
if _, err := s.store.GetDocumentByID(userID, docID); err != nil {
writeError(w, http.StatusForbidden, "Kein Zugriff auf dieses Dokument")
return
}
}
room := s.wsHub.GetOrCreateDocRoom(docID)
room.ServeDocWS(w, r, userID)
}
// GET /api/v1/sync/docs/{id}/stream
func (s *Server) handleDocSyncStream(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
if docID == "" {
writeError(w, http.StatusBadRequest, "docId erforderlich")
return
}
userID := s.authenticateUserFromReq(r)
if userID == "" {
userID = auth.GetUserID(r.Context())
}
if userID == "" {
userID = "anonymous"
}
if userID != "anonymous" {
if _, err := s.store.GetDocumentByID(userID, docID); err != nil {
writeError(w, http.StatusForbidden, "Kein Zugriff auf dieses Dokument")
return
}
}
room := s.wsHub.GetOrCreateDocRoom(docID)
room.ServeDocSSE(w, r, userID)
}
// POST /api/v1/sync/docs/{id}/update
func (s *Server) handleDocSyncUpdate(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
if docID == "" {
writeError(w, http.StatusBadRequest, "docId erforderlich")
return
}
userID := s.authenticateUserFromReq(r)
if userID == "" {
userID = auth.GetUserID(r.Context())
}
if userID == "" {
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
return
}
doc, err := s.store.GetDocumentByID(userID, docID)
if err != nil {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
if !doc.IsOwner && doc.Permission != "write" {
writeError(w, http.StatusForbidden, "Keine Schreibberechtigung")
return
}
contentType := r.Header.Get("Content-Type")
var updateBytes []byte
if strings.Contains(contentType, "application/octet-stream") {
body, err := io.ReadAll(io.LimitReader(r.Body, 8*1024*1024))
if err != nil {
writeError(w, http.StatusBadRequest, "Fehler beim Lesen des Payloads")
return
}
updateBytes = body
} else {
var req struct {
Update string `json:"update"`
State string `json:"state"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.State != "" {
if raw, err := base64.StdEncoding.DecodeString(req.State); err == nil && len(raw) > 0 {
room := s.wsHub.GetOrCreateDocRoom(docID)
room.CompactState(raw, userID)
}
}
if req.Update != "" {
raw, err := base64.StdEncoding.DecodeString(req.Update)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges Base64 für Update")
return
}
updateBytes = raw
}
}
if len(updateBytes) > 0 {
room := s.wsHub.GetOrCreateDocRoom(docID)
room.ApplyUpdate(updateBytes, userID, nil, nil)
}
writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "docId": docID})
}
func (s *Server) handleGetDocCRDT(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
@@ -1394,19 +1487,8 @@ func (s *Server) handleGetDocCRDT(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
stateBlob, updates, err := s.store.GetDocumentCRDTState(docID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
b64State := ""
if len(stateBlob) > 0 {
b64State = base64.StdEncoding.EncodeToString(stateBlob)
}
var b64Updates []string
for _, u := range updates {
b64Updates = append(b64Updates, base64.StdEncoding.EncodeToString(u))
}
room := s.wsHub.GetOrCreateDocRoom(docID)
b64State, b64Updates := room.GetInitialSnapshot()
writeJSON(w, http.StatusOK, map[string]interface{}{
"docId": docID,
"state": b64State,
@@ -1434,21 +1516,14 @@ func (s *Server) handleSaveDocCRDT(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
return
}
room := s.wsHub.GetOrCreateDocRoom(docID)
if req.Update != "" {
raw, err := base64.StdEncoding.DecodeString(req.Update)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges Base64 Format für Update")
return
}
if err := s.store.AppendDocumentCRDTUpdate(docID, raw, userID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastRoom("doc:"+docID, "CRDT_UPDATE", map[string]interface{}{
"docId": docID,
"update": req.Update,
"fromUserId": userID,
}, nil)
room.ApplyUpdate(raw, userID, nil, nil)
}
if req.State != "" {
raw, err := base64.StdEncoding.DecodeString(req.State)
@@ -1456,10 +1531,7 @@ func (s *Server) handleSaveDocCRDT(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "Ungültiges Base64 Format")
return
}
if err := s.store.SaveDocumentCRDTState(docID, raw, userID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
room.CompactState(raw, userID)
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
+81
View File
@@ -3,6 +3,7 @@ package api
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -1015,3 +1016,83 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
}
}
func TestDocRoomDualTransportAndDebouncedPersistence(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test_dual_transport.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("failed to init store: %v", err)
}
defer store.Close()
cfg := &config.Config{
JWTSecret: "test-secret-dual-transport",
AllowRegistration: true,
}
hub := ws.NewHub()
srv := NewServer(cfg, store, hub, nil)
router := srv.Router()
user, _ := store.CreateUser("dual@test.local", "hash", "Dual User", "member")
token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role)
doc, _ := store.CreateDocument(user.ID, "", "Dual Transport Doc", "📄", "", nil, nil, false)
// 1. Test POST /api/v1/sync/docs/{id}/update with JSON base64
upd1 := base64.StdEncoding.EncodeToString([]byte{0x01, 0x02, 0x03})
req := httptest.NewRequest(http.MethodPost, "/api/v1/sync/docs/"+doc.ID+"/update", bytes.NewReader([]byte(`{"update":"`+upd1+`"}`)))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from sync update, got %d: %s", rec.Code, rec.Body.String())
}
// 2. Test POST /api/v1/sync/docs/{id}/update with octet-stream binary
binaryUpd := []byte{0x04, 0x05, 0x06}
req = httptest.NewRequest(http.MethodPost, "/api/v1/sync/docs/"+doc.ID+"/update", bytes.NewReader(binaryUpd))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/octet-stream")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from binary sync update, got %d: %s", rec.Code, rec.Body.String())
}
// 3. Test GET /api/v1/sync/docs/{id}/stream SSE connection
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req = httptest.NewRequest(http.MethodGet, "/api/v1/sync/docs/"+doc.ID+"/stream?token="+token, nil).WithContext(ctx)
rec = httptest.NewRecorder()
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
router.ServeHTTP(rec, req)
if !strings.Contains(rec.Header().Get("Content-Type"), "text/event-stream") {
t.Fatalf("expected text/event-stream, got %s", rec.Header().Get("Content-Type"))
}
bodyStr := rec.Body.String()
if !strings.Contains(bodyStr, "CRDT_INIT") {
t.Fatalf("expected CRDT_INIT in SSE stream, got %s", bodyStr)
}
// 4. Test Lock Neutrality: document update and block patch work without 423 Locked
updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader([]byte(`{"title":"Updated Without Locks"}`)))
updateReq.Header.Set("Authorization", "Bearer "+token)
updateReq.Header.Set("Content-Type", "application/json")
updateRec := httptest.NewRecorder()
router.ServeHTTP(updateRec, updateReq)
if updateRec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from document update, got %d: %s", updateRec.Code, updateRec.Body.String())
}
}
+473
View File
@@ -0,0 +1,473 @@
package ws
import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/sandrodach/mindsift/internal/domain"
)
// DocWSClient represents an active WebSocket connection to a specific document room.
type DocWSClient struct {
hub *Hub
room *DocRoom
conn *websocket.Conn
userID string
send chan []byte
}
// DocSSEClient represents an active SSE connection to a specific document room.
type DocSSEClient struct {
hub *Hub
room *DocRoom
id string
userID string
send chan []byte
}
// DocRoom coordinates in-memory real-time updates and debounced persistence for one document.
type DocRoom struct {
mu sync.Mutex
docID string
hub *Hub
stateBlob []byte
pendingUpdates [][]byte
persistedUpdatesCount int
wsClients map[*DocWSClient]bool
sseClients map[*DocSSEClient]bool
debounceTimer *time.Timer
dirty bool
lastUserID string
}
func (h *Hub) GetOrCreateDocRoom(docID string) *DocRoom {
h.mu.Lock()
defer h.mu.Unlock()
if h.docRooms == nil {
h.docRooms = make(map[string]*DocRoom)
}
if room, exists := h.docRooms[docID]; exists {
return room
}
room := &DocRoom{
docID: docID,
hub: h,
wsClients: make(map[*DocWSClient]bool),
sseClients: make(map[*DocSSEClient]bool),
}
// Pre-load current state from CRDT store if available
if h.crdtStore != nil {
stateBlob, updates, err := h.crdtStore.GetDocumentCRDTState(docID)
if err == nil {
room.stateBlob = stateBlob
room.pendingUpdates = updates
room.persistedUpdatesCount = len(updates)
}
}
h.docRooms[docID] = room
return room
}
// ApplyUpdate processes an incremental Yjs binary update in-memory, broadcasts it immediately
// to all other connected clients (WS & SSE), and schedules an asynchronous debounced flush to SQLite.
func (r *DocRoom) ApplyUpdate(update []byte, fromUserID string, senderWS *DocWSClient, senderSSE *DocSSEClient) {
if len(update) == 0 {
return
}
r.mu.Lock()
r.pendingUpdates = append(r.pendingUpdates, update)
r.dirty = true
if fromUserID != "" {
r.lastUserID = fromUserID
}
b64Update := base64.StdEncoding.EncodeToString(update)
msg := domain.WSMessage{
Event: "CRDT_UPDATE",
Payload: map[string]interface{}{
"docId": r.docID,
"update": b64Update,
"fromUserId": fromUserID,
},
Timestamp: domain.NowISO(),
}
msgBytes, _ := json.Marshal(msg)
// Broadcast to all WS clients except sender
for client := range r.wsClients {
if senderWS != nil && client == senderWS {
continue
}
select {
case client.send <- msgBytes:
default:
}
}
// Broadcast to all SSE clients except sender
for client := range r.sseClients {
if senderSSE != nil && client == senderSSE {
continue
}
select {
case client.send <- msgBytes:
default:
}
}
// Schedule 2.5s debounced persistence
r.scheduleDebouncedFlushLocked()
r.mu.Unlock()
// Also inform legacy room subscribers if any
r.hub.BroadcastRoom("doc:"+r.docID, "CRDT_UPDATE", map[string]interface{}{
"docId": r.docID,
"update": b64Update,
"fromUserId": fromUserID,
}, nil)
}
// CompactState replaces the incremental pending updates with a full compacted state blob.
func (r *DocRoom) CompactState(stateBlob []byte, fromUserID string) {
if len(stateBlob) == 0 {
return
}
r.mu.Lock()
r.stateBlob = stateBlob
r.pendingUpdates = nil
r.persistedUpdatesCount = 0
r.dirty = true
if fromUserID != "" {
r.lastUserID = fromUserID
}
r.scheduleDebouncedFlushLocked()
r.mu.Unlock()
}
// BroadcastAwareness forwards user awareness state to all other participants in the room.
func (r *DocRoom) BroadcastAwareness(payload json.RawMessage, fromUserID string, senderWS *DocWSClient, senderSSE *DocSSEClient) {
msg := domain.WSMessage{
Event: "CRDT_AWARENESS",
Payload: payload,
Timestamp: domain.NowISO(),
}
msgBytes, _ := json.Marshal(msg)
r.mu.Lock()
defer r.mu.Unlock()
for client := range r.wsClients {
if senderWS != nil && client == senderWS {
continue
}
select {
case client.send <- msgBytes:
default:
}
}
for client := range r.sseClients {
if senderSSE != nil && client == senderSSE {
continue
}
select {
case client.send <- msgBytes:
default:
}
}
}
func (r *DocRoom) scheduleDebouncedFlushLocked() {
if r.debounceTimer != nil {
r.debounceTimer.Stop()
}
r.debounceTimer = time.AfterFunc(2500*time.Millisecond, func() {
r.Flush()
})
}
// Flush persists in-memory pending updates to SQLite asynchronously in a background goroutine.
func (r *DocRoom) Flush() {
r.mu.Lock()
if !r.dirty {
r.mu.Unlock()
return
}
// Prepare data to flush
docID := r.docID
userID := r.lastUserID
stateBlob := r.stateBlob
var updatesToFlush [][]byte
if len(stateBlob) > 0 && len(r.pendingUpdates) == 0 {
// Compacted state snapshot
r.dirty = false
r.mu.Unlock()
go func() {
if r.hub.crdtStore != nil {
_ = r.hub.crdtStore.SaveDocumentCRDTState(docID, stateBlob, userID)
}
}()
return
}
if r.persistedUpdatesCount < len(r.pendingUpdates) {
updatesToFlush = make([][]byte, len(r.pendingUpdates)-r.persistedUpdatesCount)
copy(updatesToFlush, r.pendingUpdates[r.persistedUpdatesCount:])
r.persistedUpdatesCount = len(r.pendingUpdates)
}
r.dirty = false
r.mu.Unlock()
if len(updatesToFlush) > 0 {
go func() {
if r.hub.crdtStore != nil {
for _, u := range updatesToFlush {
_ = r.hub.crdtStore.AppendDocumentCRDTUpdate(docID, u, userID)
}
}
}()
}
}
// GetInitialSnapshot returns the base64-encoded state blob and array of incremental updates.
func (r *DocRoom) GetInitialSnapshot() (string, []string) {
r.mu.Lock()
defer r.mu.Unlock()
b64State := ""
if len(r.stateBlob) > 0 {
b64State = base64.StdEncoding.EncodeToString(r.stateBlob)
}
var b64Updates []string
for _, u := range r.pendingUpdates {
if len(u) > 0 {
b64Updates = append(b64Updates, base64.StdEncoding.EncodeToString(u))
}
}
return b64State, b64Updates
}
// ServeDocWS handles WebSocket connection for GET /api/v1/ws/docs/{id}
func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID string) {
conn, err := upgrader.Upgrade(w, req, nil)
if err != nil {
log.Printf("[doc-ws] upgrade error for doc %s: %v", r.docID, err)
return
}
client := &DocWSClient{
hub: r.hub,
room: r,
conn: conn,
userID: userID,
send: make(chan []byte, 256),
}
r.mu.Lock()
r.wsClients[client] = true
r.mu.Unlock()
// Send initial snapshot
state, updates := r.GetInitialSnapshot()
initMsg := domain.WSMessage{
Event: "CRDT_INIT",
Payload: map[string]interface{}{
"docId": r.docID,
"state": state,
"updates": updates,
},
Timestamp: domain.NowISO(),
}
initBytes, _ := json.Marshal(initMsg)
client.send <- initBytes
go client.writePump()
go client.readPump()
}
// ServeDocSSE handles SSE stream fallback for GET /api/v1/sync/docs/{id}/stream
func (r *DocRoom) ServeDocSSE(w http.ResponseWriter, req *http.Request, userID string) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("Access-Control-Allow-Origin", "*")
client := &DocSSEClient{
hub: r.hub,
room: r,
id: domain.NewID("sse-doc"),
userID: userID,
send: make(chan []byte, 256),
}
r.mu.Lock()
r.sseClients[client] = true
r.mu.Unlock()
defer func() {
r.mu.Lock()
delete(r.sseClients, client)
clientCount := len(r.wsClients) + len(r.sseClients)
r.mu.Unlock()
if clientCount == 0 {
r.Flush()
}
}()
// Send initial connected comment and CRDT_INIT
fmt.Fprintf(w, ": connected (transport: sse, docId: %s)\n\n", r.docID)
state, updates := r.GetInitialSnapshot()
initMsg, _ := json.Marshal(domain.WSMessage{
Event: "CRDT_INIT",
Payload: map[string]interface{}{
"docId": r.docID,
"state": state,
"updates": updates,
},
Timestamp: domain.NowISO(),
})
fmt.Fprintf(w, "data: %s\n\n", initMsg)
flusher.Flush()
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
notify := req.Context().Done()
for {
select {
case <-notify:
return
case msg, ok := <-client.send:
if !ok {
return
}
fmt.Fprintf(w, "data: %s\n\n", msg)
flusher.Flush()
case <-ticker.C:
// Ping to keep proxy connection alive
fmt.Fprintf(w, ": ping\n\n")
flusher.Flush()
}
}
}
func (c *DocWSClient) readPump() {
defer func() {
c.room.mu.Lock()
delete(c.room.wsClients, c)
clientCount := len(c.room.wsClients) + len(c.room.sseClients)
c.room.mu.Unlock()
_ = c.conn.Close()
// Broadcast awareness leave
c.room.BroadcastAwareness([]byte(fmt.Sprintf(`{"userId":"%s","activeBlockId":null}`, c.userID)), c.userID, nil, nil)
if clientCount == 0 {
c.room.Flush()
}
}()
c.conn.SetReadLimit(8 * 1024 * 1024) // 8MB limit for binary/text updates
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error {
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
msgType, message, err := c.conn.ReadMessage()
if err != nil {
break
}
if msgType == websocket.BinaryMessage {
// Direct binary Yjs update
c.room.ApplyUpdate(message, c.userID, c, nil)
continue
}
// Text JSON message
var incoming struct {
Event string `json:"event"`
Payload json.RawMessage `json:"payload"`
}
if json.Unmarshal(message, &incoming) == nil {
switch incoming.Event {
case "CRDT_UPDATE":
var p struct {
Update string `json:"update"`
}
if json.Unmarshal(incoming.Payload, &p) == nil && p.Update != "" {
if raw, err := base64.StdEncoding.DecodeString(p.Update); err == nil && len(raw) > 0 {
c.room.ApplyUpdate(raw, c.userID, c, nil)
}
}
case "CRDT_COMPACT":
var p struct {
State string `json:"state"`
}
if json.Unmarshal(incoming.Payload, &p) == nil && p.State != "" {
if raw, err := base64.StdEncoding.DecodeString(p.State); err == nil && len(raw) > 0 {
c.room.CompactState(raw, c.userID)
}
}
case "CRDT_AWARENESS":
c.room.BroadcastAwareness(incoming.Payload, c.userID, c, nil)
}
}
}
}
func (c *DocWSClient) writePump() {
ticker := time.NewTicker(30 * time.Second)
defer func() {
ticker.Stop()
_ = c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
_ = c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
return
}
case <-ticker.C:
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
+18 -28
View File
@@ -62,6 +62,7 @@ type Hub struct {
sseUserRooms map[string]map[*SSEClient]bool
sseRooms map[string]map[*SSEClient]bool
locks map[string]*docLockItem
docRooms map[string]*DocRoom
crdtStore CRDTStore
}
@@ -74,6 +75,7 @@ func NewHub() *Hub {
sseUserRooms: make(map[string]map[*SSEClient]bool),
sseRooms: make(map[string]map[*SSEClient]bool),
locks: make(map[string]*docLockItem),
docRooms: make(map[string]*DocRoom),
}
go h.cleanupLocksLoop()
return h
@@ -811,23 +813,14 @@ func (c *Client) readPump() {
}
case "CRDT_JOIN":
c.hub.JoinRoom(c, "doc:"+docID)
if c.hub.crdtStore != nil {
stateBlob, updates, err := c.hub.crdtStore.GetDocumentCRDTState(docID)
if err == nil && (len(stateBlob) > 0 || len(updates) > 0) {
var b64Updates []string
for _, u := range updates {
b64Updates = append(b64Updates, base64.StdEncoding.EncodeToString(u))
}
b64State := ""
if len(stateBlob) > 0 {
b64State = base64.StdEncoding.EncodeToString(stateBlob)
}
c.SendEvent("CRDT_INIT", map[string]interface{}{
"docId": docID,
"state": b64State,
"updates": b64Updates,
})
}
room := c.hub.GetOrCreateDocRoom(docID)
b64State, b64Updates := room.GetInitialSnapshot()
if len(b64State) > 0 || len(b64Updates) > 0 {
c.SendEvent("CRDT_INIT", map[string]interface{}{
"docId": docID,
"state": b64State,
"updates": b64Updates,
})
}
case "CRDT_UPDATE":
var p struct {
@@ -835,15 +828,9 @@ func (c *Client) readPump() {
}
_ = json.Unmarshal(incoming.Payload, &p)
if p.Update != "" {
c.hub.BroadcastRoom("doc:"+docID, "CRDT_UPDATE", map[string]interface{}{
"docId": docID,
"update": p.Update,
"fromUserId": c.userID,
}, c)
if c.hub.crdtStore != nil {
if raw, err := base64.StdEncoding.DecodeString(p.Update); err == nil && len(raw) > 0 {
_ = c.hub.crdtStore.AppendDocumentCRDTUpdate(docID, raw, c.userID)
}
if raw, err := base64.StdEncoding.DecodeString(p.Update); err == nil && len(raw) > 0 {
room := c.hub.GetOrCreateDocRoom(docID)
room.ApplyUpdate(raw, c.userID, nil, nil)
}
}
case "CRDT_COMPACT":
@@ -851,13 +838,16 @@ func (c *Client) readPump() {
State string `json:"state"`
}
_ = json.Unmarshal(incoming.Payload, &p)
if p.State != "" && c.hub.crdtStore != nil {
if p.State != "" {
if raw, err := base64.StdEncoding.DecodeString(p.State); err == nil && len(raw) > 0 {
_ = c.hub.crdtStore.SaveDocumentCRDTState(docID, raw, c.userID)
room := c.hub.GetOrCreateDocRoom(docID)
room.CompactState(raw, c.userID)
}
}
case "CRDT_AWARENESS":
c.hub.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS", incoming.Payload, c)
room := c.hub.GetOrCreateDocRoom(docID)
room.BroadcastAwareness(incoming.Payload, c.userID, nil, nil)
case "CRDT_LEAVE":
c.hub.LeaveRoom(c, "doc:"+docID)
c.hub.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{