From 533ed8f20165dc68a55697d0f4dd71705b79aa29 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Oct 2025 16:55:23 +0000 Subject: [PATCH] Add real-time WebSocket functionality for link updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented full WebSocket support to enable real-time updates across the application: Backend Changes: - Added gorilla/websocket dependency for WebSocket support - Created WebSocket hub for managing client connections and broadcasting - Implemented WebSocket handler with JWT authentication - Updated middleware to support token authentication via query parameters - Integrated WebSocket broadcasts in link handlers (create/update/delete) - Added WebSocket message types to backend models - Updated API router to include WebSocket endpoint at /v1/ws Frontend Changes: - Created useWebSocket custom hook for WebSocket connection management - Added WebSocket message types to frontend type definitions - Created RealTimeLinks component for live link updates - Updated group detail page to use real-time updates - Added connection status indicators Infrastructure Changes: - Updated Nginx configuration to support WebSocket protocol upgrades - Added WebSocket-specific proxy headers and long timeouts Features: - Real-time link creation notifications - Real-time link comment updates - Real-time link deletion notifications - Automatic reconnection with exponential backoff - Per-group subscription model - JWT authentication for secure connections 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- backend/cmd/server.go | 8 +- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/api/router.go | 7 +- backend/internal/handlers/links.go | 41 ++++- backend/internal/middleware/middleware.go | 22 ++- backend/internal/models/models.go | 11 ++ backend/internal/utils/utils.go | 6 + backend/internal/websocket/handler.go | 65 +++++++ backend/internal/websocket/hub.go | 187 ++++++++++++++++++++ frontend/src/app/groups/[id]/page.tsx | 20 +-- frontend/src/components/real-time-links.tsx | 99 +++++++++++ frontend/src/hooks/useWebSocket.ts | 143 +++++++++++++++ frontend/src/types/api.ts | 23 +++ nginx/conf.d/backend.conf | 6 + 15 files changed, 612 insertions(+), 29 deletions(-) create mode 100644 backend/internal/websocket/handler.go create mode 100644 backend/internal/websocket/hub.go create mode 100644 frontend/src/components/real-time-links.tsx create mode 100644 frontend/src/hooks/useWebSocket.ts diff --git a/backend/cmd/server.go b/backend/cmd/server.go index 7b44657..7ef853b 100644 --- a/backend/cmd/server.go +++ b/backend/cmd/server.go @@ -10,6 +10,7 @@ import ( "github.com/egeuysall/cove/internal/api" supabase "github.com/egeuysall/cove/internal/supabase" generated "github.com/egeuysall/cove/internal/supabase/generated" + "github.com/egeuysall/cove/internal/websocket" "github.com/joho/godotenv" ) @@ -26,7 +27,12 @@ func main() { utils.Init(queries) - router := api.Router() + // Initialize WebSocket hub + hub := websocket.NewHub() + go hub.Run() + utils.SetHub(hub) + + router := api.Router(hub) portStr := os.Getenv("PORT") diff --git a/backend/go.mod b/backend/go.mod index c51587b..4c5802f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -13,6 +13,7 @@ require ( require ( github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/backend/go.sum b/backend/go.sum index 8c61fa0..b09c196 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -11,6 +11,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index b804961..5279c71 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -5,12 +5,13 @@ import ( "github.com/egeuysall/cove/internal/handlers" appmid "github.com/egeuysall/cove/internal/middleware" + "github.com/egeuysall/cove/internal/websocket" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/httprate" ) -func Router() *chi.Mux { +func Router(hub *websocket.Hub) *chi.Mux { r := chi.NewRouter() // Global middleware @@ -33,6 +34,10 @@ func Router() *chi.Mux { r.Route("/v1", func(r chi.Router) { r.Use(appmid.RequireAuth()) + // WebSocket + wsHandler := websocket.NewHandler(hub) + r.Get("/ws", wsHandler.ServeWs) + // Groups r.Post("/groups", handlers.HandleCreateGroup) r.Get("/groups", handlers.HandleGetGroupsByUser) diff --git a/backend/internal/handlers/links.go b/backend/internal/handlers/links.go index a5eac2b..89a5745 100644 --- a/backend/internal/handlers/links.go +++ b/backend/internal/handlers/links.go @@ -85,7 +85,14 @@ func HandleCreateLink(w http.ResponseWriter, r *http.Request) { return } - utils.SendJson(w, convertToResponse(link), http.StatusCreated) + linkResponse := convertToResponse(link) + + // Broadcast to WebSocket clients + if utils.Hub != nil { + utils.Hub.BroadcastToGroup(req.GroupID, "link_created", linkResponse) + } + + utils.SendJson(w, linkResponse, http.StatusCreated) } func HandleGetLinkById(w http.ResponseWriter, r *http.Request) { @@ -263,7 +270,14 @@ func HandleUpdateLinkComment(w http.ResponseWriter, r *http.Request) { return } - utils.SendJson(w, convertToResponse(updatedLink), http.StatusOK) + linkResponse := convertToResponse(updatedLink) + + // Broadcast to WebSocket clients + if utils.Hub != nil { + utils.Hub.BroadcastToGroup(utils.UUIDToString(updatedLink.GroupID), "link_updated", linkResponse) + } + + utils.SendJson(w, linkResponse, http.StatusOK) } func HandleDeleteLink(w http.ResponseWriter, r *http.Request) { @@ -291,6 +305,22 @@ func HandleDeleteLink(w http.ResponseWriter, r *http.Request) { return } + // Get link before deleting to broadcast group ID + link, err := utils.Queries.GetLinkByID(r.Context(), linkId) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + utils.SendError(w, "Link not found", http.StatusNotFound) + return + } + utils.SendError(w, "Failed to get link", http.StatusInternalServerError) + return + } + + if link.UserID != userId { + utils.SendError(w, "Not authorized to delete this link", http.StatusForbidden) + return + } + deleteParams := supabase.DeleteLinkParams{ ID: linkId, UserID: userId, @@ -302,6 +332,13 @@ func HandleDeleteLink(w http.ResponseWriter, r *http.Request) { return } + // Broadcast to WebSocket clients + if utils.Hub != nil { + utils.Hub.BroadcastToGroup(utils.UUIDToString(link.GroupID), "link_deleted", map[string]string{ + "id": utils.UUIDToString(link.ID), + }) + } + utils.SendJson(w, "Link deleted successfully", http.StatusOK) } diff --git a/backend/internal/middleware/middleware.go b/backend/internal/middleware/middleware.go index a01f07b..884cba2 100644 --- a/backend/internal/middleware/middleware.go +++ b/backend/internal/middleware/middleware.go @@ -37,18 +37,26 @@ func RequireAuth() func(http.Handler) http.Handler { return } + var tokenStr string + + // Try to get token from Authorization header first authHeader := r.Header.Get("Authorization") - if authHeader == "" { - utils.SendError(w, "Unauthorized: missing Authorization header", http.StatusUnauthorized) - return + if authHeader != "" { + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { + tokenStr = parts[1] + } + } + + // If no token in header, try query parameter (for WebSocket connections) + if tokenStr == "" { + tokenStr = r.URL.Query().Get("token") } - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { - utils.SendError(w, "Unauthorized: invalid Authorization header format", http.StatusUnauthorized) + if tokenStr == "" { + utils.SendError(w, "Unauthorized: missing token", http.StatusUnauthorized) return } - tokenStr := parts[1] // Parse and validate the token token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index 8d8432a..51fef9b 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -44,3 +44,14 @@ type LinkResponse struct { Comment string `json:"comment,omitempty"` CreatedAt time.Time `json:"created_at"` } + +// WebSocket message types +type WebSocketMessage struct { + Type string `json:"type"` + Data interface{} `json:"data"` +} + +type SubscribeMessage struct { + Type string `json:"type"` + GroupID string `json:"groupId"` +} diff --git a/backend/internal/utils/utils.go b/backend/internal/utils/utils.go index 2c4fde6..7f4330a 100644 --- a/backend/internal/utils/utils.go +++ b/backend/internal/utils/utils.go @@ -3,6 +3,7 @@ package utils import ( "encoding/json" generated "github.com/egeuysall/cove/internal/supabase/generated" + "github.com/egeuysall/cove/internal/websocket" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "log" @@ -10,11 +11,16 @@ import ( ) var Queries *generated.Queries +var Hub *websocket.Hub func Init(q *generated.Queries) { Queries = q } +func SetHub(h *websocket.Hub) { + Hub = h +} + func SendJson(w http.ResponseWriter, message interface{}, statusCode int) { w.WriteHeader(statusCode) diff --git a/backend/internal/websocket/handler.go b/backend/internal/websocket/handler.go new file mode 100644 index 0000000..6c9da57 --- /dev/null +++ b/backend/internal/websocket/handler.go @@ -0,0 +1,65 @@ +package websocket + +import ( + "log" + "net/http" + + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + // Allow connections from configured origins + origin := r.Header.Get("Origin") + return origin == "https://www.cove.egeuysal.com" || + origin == "http://localhost:3000" || + origin == "http://localhost:3001" + }, +} + +// Handler handles WebSocket upgrade requests +type Handler struct { + Hub *Hub +} + +// NewHandler creates a new WebSocket handler +func NewHandler(hub *Hub) *Handler { + return &Handler{ + Hub: hub, + } +} + +// ServeWs handles WebSocket requests from clients +func (h *Handler) ServeWs(w http.ResponseWriter, r *http.Request) { + // Get user ID from context (set by JWT middleware) + userID, ok := r.Context().Value("userID").(string) + if !ok || userID == "" { + log.Printf("No user ID in context") + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("Failed to upgrade connection: %v", err) + return + } + + client := &Client{ + Hub: h.Hub, + Conn: conn, + Send: make(chan []byte, 256), + UserID: userID, + GroupIDs: make(map[string]bool), + } + + client.Hub.register <- client + + // Start goroutines for reading and writing + go client.WritePump() + go client.ReadPump() + + log.Printf("WebSocket connection established for user: %s", userID) +} diff --git a/backend/internal/websocket/hub.go b/backend/internal/websocket/hub.go new file mode 100644 index 0000000..44dfb46 --- /dev/null +++ b/backend/internal/websocket/hub.go @@ -0,0 +1,187 @@ +package websocket + +import ( + "encoding/json" + "log" + "sync" + + "github.com/gorilla/websocket" +) + +// Client represents a WebSocket client connection +type Client struct { + Hub *Hub + Conn *websocket.Conn + Send chan []byte + UserID string + GroupIDs map[string]bool // Set of group IDs this client is subscribed to + mu sync.RWMutex +} + +// Hub maintains active clients and broadcasts messages to them +type Hub struct { + // Registered clients by user ID + clients map[string]*Client + + // Register requests from clients + register chan *Client + + // Unregister requests from clients + unregister chan *Client + + // Broadcast message to all clients in a group + broadcast chan *BroadcastMessage + + mu sync.RWMutex +} + +// BroadcastMessage contains a message and the group ID to broadcast to +type BroadcastMessage struct { + GroupID string + Message []byte +} + +// NewHub creates a new Hub instance +func NewHub() *Hub { + return &Hub{ + clients: make(map[string]*Client), + register: make(chan *Client), + unregister: make(chan *Client), + broadcast: make(chan *BroadcastMessage, 256), + } +} + +// Run starts the hub's main loop +func (h *Hub) Run() { + for { + select { + case client := <-h.register: + h.mu.Lock() + h.clients[client.UserID] = client + h.mu.Unlock() + log.Printf("Client registered: %s", client.UserID) + + case client := <-h.unregister: + h.mu.Lock() + if _, ok := h.clients[client.UserID]; ok { + delete(h.clients, client.UserID) + close(client.Send) + log.Printf("Client unregistered: %s", client.UserID) + } + h.mu.Unlock() + + case message := <-h.broadcast: + h.mu.RLock() + for _, client := range h.clients { + client.mu.RLock() + isSubscribed := client.GroupIDs[message.GroupID] + client.mu.RUnlock() + + if isSubscribed { + select { + case client.Send <- message.Message: + default: + // Client's send channel is full, close the connection + h.mu.RUnlock() + h.mu.Lock() + close(client.Send) + delete(h.clients, client.UserID) + h.mu.Unlock() + h.mu.RLock() + } + } + } + h.mu.RUnlock() + } + } +} + +// BroadcastToGroup sends a message to all clients subscribed to a group +func (h *Hub) BroadcastToGroup(groupID string, messageType string, data interface{}) { + message := map[string]interface{}{ + "type": messageType, + "data": data, + } + + messageBytes, err := json.Marshal(message) + if err != nil { + log.Printf("Error marshaling broadcast message: %v", err) + return + } + + h.broadcast <- &BroadcastMessage{ + GroupID: groupID, + Message: messageBytes, + } +} + +// SubscribeToGroup adds a group ID to the client's subscription list +func (c *Client) SubscribeToGroup(groupID string) { + c.mu.Lock() + defer c.mu.Unlock() + c.GroupIDs[groupID] = true + log.Printf("Client %s subscribed to group %s", c.UserID, groupID) +} + +// UnsubscribeFromGroup removes a group ID from the client's subscription list +func (c *Client) UnsubscribeFromGroup(groupID string) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.GroupIDs, groupID) + log.Printf("Client %s unsubscribed from group %s", c.UserID, groupID) +} + +// ReadPump pumps messages from the WebSocket connection to the hub +func (c *Client) ReadPump() { + defer func() { + c.Hub.unregister <- c + c.Conn.Close() + }() + + for { + _, message, err := c.Conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("WebSocket error: %v", err) + } + break + } + + // Handle incoming messages (e.g., subscribe/unsubscribe) + var msg map[string]interface{} + if err := json.Unmarshal(message, &msg); err != nil { + log.Printf("Error unmarshaling message: %v", err) + continue + } + + msgType, ok := msg["type"].(string) + if !ok { + continue + } + + switch msgType { + case "subscribe": + if groupID, ok := msg["groupId"].(string); ok { + c.SubscribeToGroup(groupID) + } + case "unsubscribe": + if groupID, ok := msg["groupId"].(string); ok { + c.UnsubscribeFromGroup(groupID) + } + } + } +} + +// WritePump pumps messages from the hub to the WebSocket connection +func (c *Client) WritePump() { + defer func() { + c.Conn.Close() + }() + + for message := range c.Send { + if err := c.Conn.WriteMessage(websocket.TextMessage, message); err != nil { + log.Printf("Error writing message: %v", err) + return + } + } +} diff --git a/frontend/src/app/groups/[id]/page.tsx b/frontend/src/app/groups/[id]/page.tsx index ff82312..39566ca 100644 --- a/frontend/src/app/groups/[id]/page.tsx +++ b/frontend/src/app/groups/[id]/page.tsx @@ -3,8 +3,8 @@ import {notFound} from "next/navigation"; import {cookies} from "next/headers"; import React from "react"; import {Group, Links} from "@/types/api"; -import {LinkCard} from "@/components/link-card"; import {CreateLink} from "@/components/create-link"; +import {RealTimeLinks} from "@/components/real-time-links"; const DynamicGroups = async ({ params }: { params: Promise<{ id: string}> }) => { const { id } = await params; @@ -85,23 +85,7 @@ const DynamicGroups = async ({ params }: { params: Promise<{ id: string}> }) => by {data?.user?.user_metadata?.name || data?.user?.user_metadata?.email} - {links.length > 0 ? ( - links.slice().reverse().map((link: Links) => ( - - )) - ) : ( -
- No links found. Add a link below. -
- )} + {/* Create Link Form at bottom of page */}
diff --git a/frontend/src/components/real-time-links.tsx b/frontend/src/components/real-time-links.tsx new file mode 100644 index 0000000..f7d7e36 --- /dev/null +++ b/frontend/src/components/real-time-links.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Links } from "@/types/api"; +import { LinkCard } from "@/components/link-card"; +import { useWebSocket } from "@/hooks/useWebSocket"; + +interface RealTimeLinksProps { + initialLinks: Links[]; + groupId: string; +} + +export function RealTimeLinks({ initialLinks, groupId }: RealTimeLinksProps) { + const [links, setLinks] = useState(initialLinks); + const { status, lastMessage } = useWebSocket(groupId); + + useEffect(() => { + if (!lastMessage) return; + + console.log("Received WebSocket message:", lastMessage); + + switch (lastMessage.type) { + case "link_created": { + const newLink = lastMessage.data as any; + // Convert backend format to frontend format + const formattedLink: Links = { + id: newLink.id, + user_id: newLink.user_id, + url: newLink.url, + title: newLink.title || "", + comment: newLink.comment || "", + created_at: newLink.created_at, + }; + setLinks((prev) => [formattedLink, ...prev]); + break; + } + + case "link_updated": { + const updatedLink = lastMessage.data as any; + setLinks((prev) => + prev.map((link) => + link.id === updatedLink.id + ? { + ...link, + comment: updatedLink.comment || "", + title: updatedLink.title || "", + } + : link + ) + ); + break; + } + + case "link_deleted": { + const deletedData = lastMessage.data as { id: string }; + setLinks((prev) => prev.filter((link) => link.id !== deletedData.id)); + break; + } + } + }, [lastMessage]); + + return ( + <> + {status === "connected" && ( +
+ Real-time updates enabled +
+ )} + {status === "connecting" && ( +
+ Connecting to real-time updates... +
+ )} + {status === "error" && ( +
+ Real-time updates unavailable +
+ )} + + {links.length > 0 ? ( + links.map((link: Links) => ( + + )) + ) : ( +
+ No links found. Add a link below. +
+ )} + + ); +} diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..58a0372 --- /dev/null +++ b/frontend/src/hooks/useWebSocket.ts @@ -0,0 +1,143 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; +import { createClient } from "@/utils/supabase/client"; + +export type WebSocketMessage = { + type: "link_created" | "link_updated" | "link_deleted"; + data: any; +}; + +export type WebSocketStatus = "connecting" | "connected" | "disconnected" | "error"; + +export function useWebSocket(groupId: string | null) { + const [status, setStatus] = useState("disconnected"); + const [lastMessage, setLastMessage] = useState(null); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const reconnectAttemptsRef = useRef(0); + const maxReconnectAttempts = 5; + + const connect = useCallback(async () => { + if (!groupId) return; + + try { + const supabase = createClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session?.access_token) { + console.error("No access token available"); + setStatus("error"); + return; + } + + // Determine WebSocket URL based on environment + const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const apiHost = + process.env.NODE_ENV === "production" + ? "coveapi.egeuysal.com" + : "localhost:8080"; + const wsUrl = `${wsProtocol}//${apiHost}/v1/ws?token=${encodeURIComponent(session.access_token)}`; + + console.log("Connecting to WebSocket"); + setStatus("connecting"); + + // Create WebSocket connection with auth token in URL + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + console.log("WebSocket connected"); + setStatus("connected"); + reconnectAttemptsRef.current = 0; + + // Send authentication and subscribe to group + ws.send( + JSON.stringify({ + type: "subscribe", + groupId: groupId, + }) + ); + }; + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data) as WebSocketMessage; + console.log("WebSocket message received:", message); + setLastMessage(message); + } catch (error) { + console.error("Failed to parse WebSocket message:", error); + } + }; + + ws.onerror = (error) => { + console.error("WebSocket error:", error); + setStatus("error"); + }; + + ws.onclose = () => { + console.log("WebSocket disconnected"); + setStatus("disconnected"); + wsRef.current = null; + + // Attempt to reconnect with exponential backoff + if (reconnectAttemptsRef.current < maxReconnectAttempts) { + const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000); + console.log(`Reconnecting in ${delay}ms...`); + + reconnectTimeoutRef.current = setTimeout(() => { + reconnectAttemptsRef.current++; + connect(); + }, delay); + } + }; + } catch (error) { + console.error("Failed to connect to WebSocket:", error); + setStatus("error"); + } + }, [groupId]); + + const disconnect = useCallback(() => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + + setStatus("disconnected"); + reconnectAttemptsRef.current = 0; + }, []); + + const sendMessage = useCallback((message: any) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(message)); + } else { + console.warn("WebSocket is not connected"); + } + }, []); + + useEffect(() => { + if (groupId) { + connect(); + } else { + disconnect(); + } + + return () => { + disconnect(); + }; + }, [groupId, connect, disconnect]); + + return { + status, + lastMessage, + sendMessage, + reconnect: connect, + }; +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index f4df301..61b663f 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -12,4 +12,27 @@ export type Links = { title: string comment: string created_at: string +} + +export type LinkResponse = { + id: string + group_id: string + user_id: string + url: string + title?: string + comment?: string + created_at: string +} + +// WebSocket message types +export type WebSocketMessageType = "link_created" | "link_updated" | "link_deleted" + +export type WebSocketMessage = { + type: WebSocketMessageType + data: LinkResponse | { id: string } +} + +export type SubscribeMessage = { + type: "subscribe" | "unsubscribe" + groupId: string } \ No newline at end of file diff --git a/nginx/conf.d/backend.conf b/nginx/conf.d/backend.conf index 6fb6a5c..4e37f8f 100644 --- a/nginx/conf.d/backend.conf +++ b/nginx/conf.d/backend.conf @@ -39,5 +39,11 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400; } } \ No newline at end of file