package ws import ( "encoding/json" "fmt" "log/slog" "net/http" "strings" "sync" "time" "github.com/gorilla/websocket" "github.com/sandrodach/mindsift/internal/domain" ) var upgrader = websocket.Upgrader{ ReadBufferSize: 4096, WriteBufferSize: 4096, CheckOrigin: func(r *http.Request) bool { return true // Self-hosted reverse proxy compatible }, } type Client struct { hub *Hub conn *websocket.Conn roomID string userID string send chan []byte subscribedRooms map[string]bool sendMu sync.RWMutex closed bool } func (c *Client) closeSend() { c.sendMu.Lock() defer c.sendMu.Unlock() if !c.closed { c.closed = true close(c.send) } } func (c *Client) trySend(data []byte) { c.sendMu.RLock() defer c.sendMu.RUnlock() if c.closed { return } select { case c.send <- data: default: } } type SSEClient struct { id string userID string roomID string send chan []byte subscribedRooms map[string]bool sendMu sync.RWMutex closed bool } func (c *SSEClient) closeSend() { c.sendMu.Lock() defer c.sendMu.Unlock() if !c.closed { c.closed = true close(c.send) } } func (c *SSEClient) trySend(data []byte) { c.sendMu.RLock() defer c.sendMu.RUnlock() if c.closed { return } select { case c.send <- data: default: } } type Hub struct { mu sync.RWMutex rooms map[string]map[*Client]bool userRooms map[string]map[*Client]bool allClients map[*Client]bool sseClients map[*SSEClient]bool sseUserRooms map[string]map[*SSEClient]bool sseRooms map[string]map[*SSEClient]bool closed bool } func NewHub() *Hub { return &Hub{ rooms: make(map[string]map[*Client]bool), userRooms: make(map[string]map[*Client]bool), allClients: make(map[*Client]bool), sseClients: make(map[*SSEClient]bool), sseUserRooms: make(map[string]map[*SSEClient]bool), sseRooms: make(map[string]map[*SSEClient]bool), } } func (h *Hub) Register(c *Client) { h.mu.Lock() h.allClients[c] = true if c.roomID != "" { if h.rooms[c.roomID] == nil { h.rooms[c.roomID] = make(map[*Client]bool) } h.rooms[c.roomID][c] = true } if c.userID != "" && c.userID != "anonymous" { if h.userRooms[c.userID] == nil { h.userRooms[c.userID] = make(map[*Client]bool) } h.userRooms[c.userID][c] = true } onlineCount := len(h.allClients) + len(h.sseClients) h.mu.Unlock() if c.roomID != "" { h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{ "onlineCount": onlineCount, }) } } func (h *Hub) Unregister(c *Client) { h.mu.Lock() delete(h.allClients, c) if c.roomID != "" { if room, ok := h.rooms[c.roomID]; ok { delete(room, c) if len(room) == 0 { delete(h.rooms, c.roomID) } } } if c.subscribedRooms != nil { for rID := range c.subscribedRooms { if room, ok := h.rooms[rID]; ok { delete(room, c) if len(room) == 0 { delete(h.rooms, rID) } } } } if c.userID != "" && c.userID != "anonymous" { if uRoom, ok := h.userRooms[c.userID]; ok { delete(uRoom, c) if len(uRoom) == 0 { delete(h.userRooms, c.userID) } } } c.closeSend() h.mu.Unlock() } func (h *Hub) RegisterSSE(c *SSEClient) { h.mu.Lock() h.sseClients[c] = true if c.roomID != "" { if h.sseRooms[c.roomID] == nil { h.sseRooms[c.roomID] = make(map[*SSEClient]bool) } h.sseRooms[c.roomID][c] = true } if c.userID != "" && c.userID != "anonymous" { if h.sseUserRooms[c.userID] == nil { h.sseUserRooms[c.userID] = make(map[*SSEClient]bool) } h.sseUserRooms[c.userID][c] = true } if c.subscribedRooms != nil { for rID := range c.subscribedRooms { if h.sseRooms[rID] == nil { h.sseRooms[rID] = make(map[*SSEClient]bool) } h.sseRooms[rID][c] = true } } onlineCount := len(h.allClients) + len(h.sseClients) h.mu.Unlock() if c.roomID != "" { h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{ "onlineCount": onlineCount, }) } } func (h *Hub) UnregisterSSE(c *SSEClient) { h.mu.Lock() delete(h.sseClients, c) if c.roomID != "" { if room, ok := h.sseRooms[c.roomID]; ok { delete(room, c) if len(room) == 0 { delete(h.sseRooms, c.roomID) } } } if c.subscribedRooms != nil { for rID := range c.subscribedRooms { if room, ok := h.sseRooms[rID]; ok { delete(room, c) if len(room) == 0 { delete(h.sseRooms, rID) } } } } if c.userID != "" && c.userID != "anonymous" { if uRoom, ok := h.sseUserRooms[c.userID]; ok { delete(uRoom, c) if len(uRoom) == 0 { delete(h.sseUserRooms, c.userID) } } } c.closeSend() h.mu.Unlock() } func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool { h.mu.Lock() defer h.mu.Unlock() for client := range h.sseClients { if client.id == sseID { if h.sseRooms[roomID] == nil { h.sseRooms[roomID] = make(map[*SSEClient]bool) } h.sseRooms[roomID][client] = true if client.subscribedRooms == nil { client.subscribedRooms = make(map[string]bool) } client.subscribedRooms[roomID] = true return true } } return false } func (h *Hub) LeaveSSERoomByID(sseID, roomID string) bool { h.mu.Lock() defer h.mu.Unlock() for client := range h.sseClients { if client.id == sseID { if room, ok := h.sseRooms[roomID]; ok { delete(room, client) if len(room) == 0 { delete(h.sseRooms, roomID) } } if client.subscribedRooms != nil { delete(client.subscribedRooms, roomID) } return true } } return false } // Close gracefully disconnects all WebSocket and SSE connections so HTTP shutdown completes immediately. func (h *Hub) Close() { h.mu.Lock() if h.closed { h.mu.Unlock() return } h.closed = true allWsClients := make([]*Client, 0, len(h.allClients)) for c := range h.allClients { allWsClients = append(allWsClients, c) } allSSEClients := make([]*SSEClient, 0, len(h.sseClients)) for c := range h.sseClients { allSSEClients = append(allSSEClients, c) } h.mu.Unlock() for _, c := range allWsClients { _ = c.conn.Close() c.closeSend() } for _, c := range allSSEClients { c.closeSend() } } // Broadcasting Methods func (h *Hub) BroadcastToAll(event string, payload interface{}) { msg := domain.WSMessage{ Event: event, Payload: payload, Timestamp: domain.NowISO(), } data, err := json.Marshal(msg) if err != nil { return } h.mu.RLock() if h.closed { h.mu.RUnlock() return } defer h.mu.RUnlock() for client := range h.allClients { client.trySend(data) } for sseClient := range h.sseClients { sseClient.trySend(data) } } func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) { msg := domain.WSMessage{ Event: event, Payload: payload, Timestamp: domain.NowISO(), } data, err := json.Marshal(msg) if err != nil { return } h.mu.RLock() if h.closed { h.mu.RUnlock() return } defer h.mu.RUnlock() if room, ok := h.userRooms[userID]; ok { for client := range room { client.trySend(data) } } if sseRoom, ok := h.sseUserRooms[userID]; ok { for sseClient := range sseRoom { sseClient.trySend(data) } } } func (h *Hub) BroadcastTaskEvent(userID, event string, payload interface{}) { h.BroadcastToUser(userID, event, payload) } func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) { if docID != "" { h.BroadcastRoom("doc:"+docID, event, payload, nil) } else { h.BroadcastToAll(event, payload) } } func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) { msg := domain.WSMessage{ Event: event, ListID: listID, Payload: payload, Timestamp: domain.NowISO(), } data, err := json.Marshal(msg) if err != nil { return } h.mu.RLock() if h.closed { h.mu.RUnlock() return } defer h.mu.RUnlock() targetRoom := listID if targetRoom == "" { targetRoom = spaceID } if room, ok := h.rooms[targetRoom]; ok { for client := range room { client.trySend(data) } } if sseRoom, ok := h.sseRooms[targetRoom]; ok { for sseClient := range sseRoom { sseClient.trySend(data) } } } func (h *Hub) BroadcastListEvent(listID, event string, payload interface{}) { h.Broadcast("", listID, event, payload) } func (h *Hub) JoinRoom(c *Client, roomID string) { h.mu.Lock() defer h.mu.Unlock() if h.rooms[roomID] == nil { h.rooms[roomID] = make(map[*Client]bool) } h.rooms[roomID][c] = true if c.subscribedRooms == nil { c.subscribedRooms = make(map[string]bool) } c.subscribedRooms[roomID] = true } func (h *Hub) LeaveRoom(c *Client, roomID string) { h.mu.Lock() defer h.mu.Unlock() if room, ok := h.rooms[roomID]; ok { delete(room, c) if len(room) == 0 { delete(h.rooms, roomID) } } if c.subscribedRooms != nil { delete(c.subscribedRooms, roomID) } } func (h *Hub) BroadcastRoom(roomID, event string, payload interface{}, excludeClient *Client) { msg := domain.WSMessage{ Event: event, Payload: payload, Timestamp: domain.NowISO(), } data, err := json.Marshal(msg) if err != nil { return } h.mu.RLock() if h.closed { h.mu.RUnlock() return } defer h.mu.RUnlock() if room, ok := h.rooms[roomID]; ok { for client := range room { if excludeClient != nil && client == excludeClient { continue } client.trySend(data) } } if sseRoom, ok := h.sseRooms[roomID]; ok { for sseClient := range sseRoom { sseClient.trySend(data) } } } func (c *Client) SendEvent(event string, payload interface{}) { msg := domain.WSMessage{ Event: event, Payload: payload, Timestamp: domain.NowISO(), } data, err := json.Marshal(msg) if err == nil { c.trySend(data) } } func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, roomID, userID string) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { slog.Error("WebSocket upgrade error", "room_id", roomID, "user_id", userID, "error", err) return } client := &Client{ hub: h, conn: conn, roomID: roomID, userID: userID, send: make(chan []byte, 128), } h.Register(client) go client.writePump() go client.readPump() } func (h *Hub) ServeSSE(w http.ResponseWriter, r *http.Request, roomID, userID string, initialRooms []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 := &SSEClient{ id: domain.NewID("sse"), userID: userID, roomID: roomID, send: make(chan []byte, 128), subscribedRooms: make(map[string]bool), } for _, room := range initialRooms { if strings.TrimSpace(room) != "" { client.subscribedRooms[strings.TrimSpace(room)] = true } } h.RegisterSSE(client) defer h.UnregisterSSE(client) // Send initial connected comment and event fmt.Fprintf(w, ": connected (transport: sse, id: %s)\n\n", client.id) connectedMsg, _ := json.Marshal(domain.WSMessage{ Event: "SSE_CONNECTED", Payload: map[string]interface{}{ "sseId": client.id, "transport": "sse", "userId": userID, }, Timestamp: domain.NowISO(), }) fmt.Fprintf(w, "data: %s\n\n", connectedMsg) flusher.Flush() ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() notify := r.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: // Periodic ping comment to keep HTTP connection alive and prevent proxy timeouts fmt.Fprintf(w, ": ping\n\n") flusher.Flush() } } } func (c *Client) readPump() { defer func() { c.hub.Unregister(c) _ = c.conn.Close() }() c.conn.SetReadLimit(4 * 1024 * 1024) // 4MB for large document CRDT 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 { _, message, err := c.conn.ReadMessage() if err != nil { break } // Handle client-initiated lock, CRDT, room subscription, and presence messages var incoming struct { Event string `json:"event"` DocID string `json:"docId"` Room string `json:"room"` Payload json.RawMessage `json:"payload"` } if json.Unmarshal(message, &incoming) == nil && c.userID != "" && c.userID != "anonymous" { docID := incoming.DocID if docID == "" && len(incoming.Payload) > 0 { var p struct { DocID string `json:"docId"` } _ = json.Unmarshal(incoming.Payload, &p) docID = p.DocID } // Room subscription without requiring docID if incoming.Event == "JOIN_ROOM" { room := incoming.Room if room == "" { room = docID } if room != "" { c.hub.JoinRoom(c, room) } } else if incoming.Event == "LEAVE_ROOM" { room := incoming.Room if room == "" { room = docID } if room != "" { c.hub.LeaveRoom(c, room) } } } } } func (c *Client) 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 } } } }