From 70a406c73c03d16ff17d72da2b6e03f41f0060b3 Mon Sep 17 00:00:00 2001 From: M1ngdaXie <156019134+M1ngdaXie@users.noreply.github.com> Date: Tue, 3 Feb 2026 17:25:11 -0800 Subject: [PATCH] Refactor API configuration and improve WebSocket handling in frontend and backend --- backend/.env.example | 58 +++++++--- backend/internal/config/config.go | 141 ++++++++++++++++++++++++ backend/internal/handlers/auth.go | 100 +++++++++-------- backend/internal/handlers/share.go | 22 +--- backend/internal/handlers/share_test.go | 2 +- backend/internal/handlers/suite_test.go | 21 +++- backend/internal/handlers/websocket.go | 55 ++++----- frontend/.env.example | 13 ++- frontend/src/api/client.ts | 2 +- frontend/src/config/index.ts | 24 ++++ frontend/src/hooks/useYjsDocument.ts | 13 ++- frontend/src/lib/yjs.ts | 9 +- frontend/src/pages/LandingPage.tsx | 3 +- frontend/src/pages/LoginPage.tsx | 3 +- 14 files changed, 335 insertions(+), 131 deletions(-) create mode 100644 backend/internal/config/config.go create mode 100644 frontend/src/config/index.ts diff --git a/backend/.env.example b/backend/.env.example index 14924b1..e673368 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,32 +1,64 @@ # Backend Environment Variables # Copy this file to .env.local for local development -# Server Configuration -PORT=8080 +# =================== +# REQUIRED VARIABLES +# =================== # Database Configuration # Format: postgres://username:password@host:port/database?sslmode=disable DATABASE_URL=postgres://collab:collab123@localhost:5432/collaboration?sslmode=disable -# Redis Configuration (optional, for future use) -REDIS_URL=redis://localhost:6379 +# JWT Secret - Use a strong random string (min 32 characters) +# Generate with: openssl rand -hex 32 +JWT_SECRET=your-secret-key-change-this-in-production -# CORS Configuration -# Comma-separated list of allowed origins -ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 +# =================== +# SERVER CONFIGURATION +# =================== -# JWT Secret (use a strong random string in production) -JWT_SECRET=your-secret-key-change-this +# Server port (default: 8080) +# Can be overridden with --port flag: go run cmd/server/main.go --port 8081 +PORT=8080 + +# Environment: development, staging, production +# Controls: cookie security (Secure flag), logging verbosity +ENVIRONMENT=development + +# Backend URL (used to auto-construct OAuth callback URLs) +# If not set, defaults to http://localhost:${PORT} +# Set explicitly for production: https://api.yourdomain.com +BACKEND_URL=http://localhost:8080 # Frontend URL (for OAuth redirects after login) FRONTEND_URL=http://localhost:5173 -# Google OAuth (get from https://console.cloud.google.com/) +# =================== +# CORS CONFIGURATION +# =================== + +# Comma-separated list of allowed origins +ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 + +# =================== +# OAUTH CONFIGURATION +# =================== + +# Google OAuth (https://console.cloud.google.com/) GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret -GOOGLE_REDIRECT_URL=http://localhost:8080/api/auth/google/callback +# Redirect URL - auto-derived from BACKEND_URL if not set +# GOOGLE_REDIRECT_URL=http://localhost:8080/api/auth/google/callback -# GitHub OAuth (get from https://github.com/settings/developers) +# GitHub OAuth (https://github.com/settings/developers) GITHUB_CLIENT_ID=your-github-client-id GITHUB_CLIENT_SECRET=your-github-client-secret -GITHUB_REDIRECT_URL=http://localhost:8080/api/auth/github/callback +# Redirect URL - auto-derived from BACKEND_URL if not set +# GITHUB_REDIRECT_URL=http://localhost:8080/api/auth/github/callback + +# =================== +# OPTIONAL SERVICES +# =================== + +# Redis (for future use) +REDIS_URL=redis://localhost:6379 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..61bc83a --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,141 @@ +package config + +import ( + "fmt" + "os" + "strings" + + "github.com/joho/godotenv" +) + +// Config holds all application configuration +type Config struct { + // Server + Port string + Environment string // "development", "staging", "production" + + // Database + DatabaseURL string + + // Redis (optional) + RedisURL string + + // Security + JWTSecret string + SecureCookie bool // Derived from Environment + + // CORS + AllowedOrigins []string + + // URLs + FrontendURL string + BackendURL string // Used to construct OAuth callback URLs + + // OAuth - Google + GoogleClientID string + GoogleClientSecret string + GoogleRedirectURL string + + // OAuth - GitHub + GitHubClientID string + GitHubClientSecret string + GitHubRedirectURL string +} + +// Load loads configuration from environment variables with optional CLI flag overrides. +// flagPort overrides the PORT env var if non-empty. +func Load(flagPort string) (*Config, error) { + // Load .env files (ignore errors - files may not exist) + _ = godotenv.Load() + _ = godotenv.Overload(".env.local") + + cfg := &Config{} + + // Required fields + var missing []string + + cfg.DatabaseURL = os.Getenv("DATABASE_URL") + if cfg.DatabaseURL == "" { + missing = append(missing, "DATABASE_URL") + } + + cfg.JWTSecret = os.Getenv("JWT_SECRET") + if cfg.JWTSecret == "" { + missing = append(missing, "JWT_SECRET") + } + + if len(missing) > 0 { + return nil, fmt.Errorf("missing required environment variables: %s", strings.Join(missing, ", ")) + } + + // Port: CLI flag > env var > default + cfg.Port = getEnvOrDefault("PORT", "8080") + if flagPort != "" { + cfg.Port = flagPort + } + + // Other optional vars with defaults + cfg.Environment = getEnvOrDefault("ENVIRONMENT", "development") + cfg.FrontendURL = getEnvOrDefault("FRONTEND_URL", "http://localhost:5173") + cfg.BackendURL = getEnvOrDefault("BACKEND_URL", fmt.Sprintf("http://localhost:%s", cfg.Port)) + cfg.RedisURL = os.Getenv("REDIS_URL") + + // Derived values + cfg.SecureCookie = cfg.Environment == "production" + + // CORS origins + originsStr := os.Getenv("ALLOWED_ORIGINS") + if originsStr != "" { + cfg.AllowedOrigins = strings.Split(originsStr, ",") + for i := range cfg.AllowedOrigins { + cfg.AllowedOrigins[i] = strings.TrimSpace(cfg.AllowedOrigins[i]) + } + } else { + cfg.AllowedOrigins = []string{ + "http://localhost:5173", + "http://localhost:3000", + "https://realtime-collab-snowy.vercel.app", + } + } + + // OAuth - Google + cfg.GoogleClientID = os.Getenv("GOOGLE_CLIENT_ID") + cfg.GoogleClientSecret = os.Getenv("GOOGLE_CLIENT_SECRET") + cfg.GoogleRedirectURL = getEnvOrDefault("GOOGLE_REDIRECT_URL", + fmt.Sprintf("%s/api/auth/google/callback", cfg.BackendURL)) + + // OAuth - GitHub + cfg.GitHubClientID = os.Getenv("GITHUB_CLIENT_ID") + cfg.GitHubClientSecret = os.Getenv("GITHUB_CLIENT_SECRET") + cfg.GitHubRedirectURL = getEnvOrDefault("GITHUB_REDIRECT_URL", + fmt.Sprintf("%s/api/auth/github/callback", cfg.BackendURL)) + + return cfg, nil +} + +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// IsProduction returns true if running in production environment +func (c *Config) IsProduction() bool { + return c.Environment == "production" +} + +// IsDevelopment returns true if running in development environment +func (c *Config) IsDevelopment() bool { + return c.Environment == "development" +} + +// HasGoogleOAuth returns true if Google OAuth is configured +func (c *Config) HasGoogleOAuth() bool { + return c.GoogleClientID != "" && c.GoogleClientSecret != "" +} + +// HasGitHubOAuth returns true if GitHub OAuth is configured +func (c *Config) HasGitHubOAuth() bool { + return c.GitHubClientID != "" && c.GitHubClientSecret != "" +} diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go index 5b0abe7..bd515fc 100644 --- a/backend/internal/handlers/auth.go +++ b/backend/internal/handlers/auth.go @@ -8,10 +8,10 @@ import ( "io" "log" "net/http" - "os" "time" "github.com/M1ngdaXie/realtime-collab/internal/auth" + "github.com/M1ngdaXie/realtime-collab/internal/config" "github.com/M1ngdaXie/realtime-collab/internal/models" "github.com/M1ngdaXie/realtime-collab/internal/store" "github.com/gin-gonic/gin" @@ -20,41 +20,45 @@ import ( ) type AuthHandler struct { - store store.Store - googleConfig *oauth2.Config - githubConfig *oauth2.Config - jwtSecret string - frontendURL string + store store.Store + cfg *config.Config + googleConfig *oauth2.Config + githubConfig *oauth2.Config } -func NewAuthHandler(store store.Store, jwtSecret, frontendURL string) *AuthHandler { - googleConfig := auth.GetGoogleOAuthConfig( - os.Getenv("GOOGLE_CLIENT_ID"), - os.Getenv("GOOGLE_CLIENT_SECRET"), - os.Getenv("GOOGLE_REDIRECT_URL"), - ) +func NewAuthHandler(store store.Store, cfg *config.Config) *AuthHandler { + var googleConfig *oauth2.Config + if cfg.HasGoogleOAuth() { + googleConfig = auth.GetGoogleOAuthConfig( + cfg.GoogleClientID, + cfg.GoogleClientSecret, + cfg.GoogleRedirectURL, + ) + } - githubConfig := auth.GetGitHubOAuthConfig( - os.Getenv("GITHUB_CLIENT_ID"), - os.Getenv("GITHUB_CLIENT_SECRET"), - os.Getenv("GITHUB_REDIRECT_URL"), - ) + var githubConfig *oauth2.Config + if cfg.HasGitHubOAuth() { + githubConfig = auth.GetGitHubOAuthConfig( + cfg.GitHubClientID, + cfg.GitHubClientSecret, + cfg.GitHubRedirectURL, + ) + } return &AuthHandler{ store: store, + cfg: cfg, googleConfig: googleConfig, githubConfig: githubConfig, - jwtSecret: jwtSecret, - frontendURL: frontendURL, } } // GoogleLogin redirects to Google OAuth func (h *AuthHandler) GoogleLogin(c *gin.Context) { - // Generate random state and set cookie - oauthState := generateStateOauthCookie(c.Writer) - url := h.googleConfig.AuthCodeURL(oauthState, oauth2.AccessTypeOffline) - c.Redirect(http.StatusTemporaryRedirect, url) + // Generate random state and set cookie + oauthState := h.generateStateOauthCookie(c.Writer) + url := h.googleConfig.AuthCodeURL(oauthState, oauth2.AccessTypeOffline) + c.Redirect(http.StatusTemporaryRedirect, url) } // GoogleCallback handles Google OAuth callback @@ -122,15 +126,15 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) { } // Redirect to frontend with token - redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", h.frontendURL, jwt) + redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", h.cfg.FrontendURL, jwt) c.Redirect(http.StatusTemporaryRedirect, redirectURL) } // GithubLogin redirects to GitHub OAuth func (h *AuthHandler) GithubLogin(c *gin.Context) { - oauthState := generateStateOauthCookie(c.Writer) - url := h.githubConfig.AuthCodeURL(oauthState) - c.Redirect(http.StatusTemporaryRedirect, url) + oauthState := h.generateStateOauthCookie(c.Writer) + url := h.githubConfig.AuthCodeURL(oauthState) + c.Redirect(http.StatusTemporaryRedirect, url) } // GithubCallback handles GitHub OAuth callback @@ -227,7 +231,7 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) { } // Redirect to frontend with token - redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", h.frontendURL, jwt) + redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", h.cfg.FrontendURL, jwt) c.Redirect(http.StatusTemporaryRedirect, redirectURL) } @@ -274,7 +278,7 @@ func (h *AuthHandler) createSessionAndJWT(c *gin.Context, user *models.User) (st expiresAt := time.Now().Add(7 * 24 * time.Hour) // 7 days // Generate JWT first (we need it for session) - now includes avatar URL - jwt, err := auth.GenerateJWT(user.ID, user.Name, user.Email, user.AvatarURL, h.jwtSecret, 7*24*time.Hour) + jwt, err := auth.GenerateJWT(user.ID, user.Name, user.Email, user.AvatarURL, h.cfg.JWTSecret, 7*24*time.Hour) if err != nil { return "", err } @@ -298,25 +302,25 @@ func (h *AuthHandler) createSessionAndJWT(c *gin.Context, user *models.User) (st return jwt, nil } -func generateStateOauthCookie(w http.ResponseWriter) string { - b := make([]byte, 16) -n, err := rand.Read(b) -if err != nil || n != 16 { - fmt.Printf("Failed to generate random state: %v\n", err) - return "" // Critical for CSRF security -} - state := base64.URLEncoding.EncodeToString(b) +func (h *AuthHandler) generateStateOauthCookie(w http.ResponseWriter) string { + b := make([]byte, 16) + n, err := rand.Read(b) + if err != nil || n != 16 { + fmt.Printf("Failed to generate random state: %v\n", err) + return "" // Critical for CSRF security + } + state := base64.URLEncoding.EncodeToString(b) - cookie := http.Cookie{ - Name: "oauthstate", - Value: state, - Expires: time.Now().Add(10 * time.Minute), - HttpOnly: true, // Prevents JavaScript access (XSS protection) - Secure: false, // Must be false for http://localhost (set true in production) - SameSite: http.SameSiteLaxMode, // ✅ Allows same-site OAuth redirects - Path: "/", // ✅ Ensures cookie is sent to all backend paths - } - http.SetCookie(w, &cookie) + cookie := http.Cookie{ + Name: "oauthstate", + Value: state, + Expires: time.Now().Add(10 * time.Minute), + HttpOnly: true, // Prevents JavaScript access (XSS protection) + Secure: h.cfg.SecureCookie, // true in production, false for localhost + SameSite: http.SameSiteLaxMode, // Allows same-site OAuth redirects + Path: "/", // Ensures cookie is sent to all backend paths + } + http.SetCookie(w, &cookie) - return state + return state } diff --git a/backend/internal/handlers/share.go b/backend/internal/handlers/share.go index 367e710..4ab0bbf 100644 --- a/backend/internal/handlers/share.go +++ b/backend/internal/handlers/share.go @@ -3,9 +3,9 @@ package handlers import ( "fmt" "net/http" - "os" // Add this "github.com/M1ngdaXie/realtime-collab/internal/auth" + "github.com/M1ngdaXie/realtime-collab/internal/config" "github.com/M1ngdaXie/realtime-collab/internal/models" "github.com/M1ngdaXie/realtime-collab/internal/store" "github.com/gin-gonic/gin" @@ -14,10 +14,11 @@ import ( type ShareHandler struct { store store.Store + cfg *config.Config } -func NewShareHandler(store store.Store) *ShareHandler { - return &ShareHandler{store: store} +func NewShareHandler(store store.Store, cfg *config.Config) *ShareHandler { + return &ShareHandler{store: store, cfg: cfg} } // CreateShare creates a new document share @@ -193,13 +194,7 @@ func (h *ShareHandler) CreateShareLink(c *gin.Context) { return } - // Get frontend URL from env - frontendURL := os.Getenv("FRONTEND_URL") - if frontendURL == "" { - frontendURL = "http://localhost:5173" - } - - shareURL := fmt.Sprintf("%s/editor/%s?share=%s", frontendURL, documentID.String(), token) + shareURL := fmt.Sprintf("%s/editor/%s?share=%s", h.cfg.FrontendURL, documentID.String(), token) c.JSON(http.StatusOK, gin.H{ "url": shareURL, @@ -253,12 +248,7 @@ func (h *ShareHandler) GetShareLink(c *gin.Context) { permission = "edit" // Default fallback } - frontendURL := os.Getenv("FRONTEND_URL") - if frontendURL == "" { - frontendURL = "http://localhost:5173" - } - - shareURL := fmt.Sprintf("%s/editor/%s?share=%s", frontendURL, documentID.String(), token) + shareURL := fmt.Sprintf("%s/editor/%s?share=%s", h.cfg.FrontendURL, documentID.String(), token) c.JSON(http.StatusOK, gin.H{ "url": shareURL, diff --git a/backend/internal/handlers/share_test.go b/backend/internal/handlers/share_test.go index d44b676..64d88da 100644 --- a/backend/internal/handlers/share_test.go +++ b/backend/internal/handlers/share_test.go @@ -25,7 +25,7 @@ func (s *ShareHandlerSuite) SetupTest() { // Create handler and router authMiddleware := auth.NewAuthMiddleware(s.store, s.jwtSecret) - s.handler = NewShareHandler(s.store) + s.handler = NewShareHandler(s.store, s.cfg) s.router = gin.New() // Custom auth middleware for tests that sets user_id as pointer diff --git a/backend/internal/handlers/suite_test.go b/backend/internal/handlers/suite_test.go index 383c775..458a4ba 100644 --- a/backend/internal/handlers/suite_test.go +++ b/backend/internal/handlers/suite_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" + "github.com/M1ngdaXie/realtime-collab/internal/config" "github.com/M1ngdaXie/realtime-collab/internal/store" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -17,18 +18,26 @@ import ( // BaseHandlerSuite provides common setup for all handler tests type BaseHandlerSuite struct { suite.Suite - store *store.PostgresStore - cleanup func() - testData *store.TestData - jwtSecret string - frontendURL string + store *store.PostgresStore + cleanup func() + testData *store.TestData + jwtSecret string + cfg *config.Config } // SetupSuite runs once before all tests in the suite func (s *BaseHandlerSuite) SetupSuite() { s.store, s.cleanup = store.SetupTestDB(s.T()) s.jwtSecret = "test-secret-key-do-not-use-in-production" - s.frontendURL = "http://localhost:5173" + s.cfg = &config.Config{ + Port: "8080", + Environment: "development", + JWTSecret: s.jwtSecret, + FrontendURL: "http://localhost:5173", + BackendURL: "http://localhost:8080", + AllowedOrigins: []string{"http://localhost:5173", "http://localhost:3000"}, + SecureCookie: false, + } gin.SetMode(gin.TestMode) } diff --git a/backend/internal/handlers/websocket.go b/backend/internal/handlers/websocket.go index 7ab64ac..084d656 100644 --- a/backend/internal/handlers/websocket.go +++ b/backend/internal/handlers/websocket.go @@ -3,10 +3,9 @@ package handlers import ( "log" "net/http" - "os" - "strings" "github.com/M1ngdaXie/realtime-collab/internal/auth" + "github.com/M1ngdaXie/realtime-collab/internal/config" "github.com/M1ngdaXie/realtime-collab/internal/hub" "github.com/M1ngdaXie/realtime-collab/internal/store" "github.com/gin-gonic/gin" @@ -14,36 +13,33 @@ import ( "github.com/gorilla/websocket" ) -var upgrader = websocket.Upgrader{ - ReadBufferSize: 1024, - WriteBufferSize: 1024, - CheckOrigin: func(r *http.Request) bool { - origin := r.Header.Get("Origin") - allowedOrigins := os.Getenv("ALLOWED_ORIGINS") - if allowedOrigins == "" { - // Default for development - return origin == "http://localhost:5173" || origin == "http://localhost:3000" - } - // Production: validate against ALLOWED_ORIGINS - origins := strings.Split(allowedOrigins, ",") - for _, allowed := range origins { - if strings.TrimSpace(allowed) == origin { - return true - } - } - return false - }, -} - type WebSocketHandler struct { hub *hub.Hub store store.Store + cfg *config.Config } -func NewWebSocketHandler(h *hub.Hub, s store.Store) *WebSocketHandler { +func NewWebSocketHandler(h *hub.Hub, s store.Store, cfg *config.Config) *WebSocketHandler { return &WebSocketHandler{ hub: h, store: s, + cfg: cfg, + } +} + +func (wsh *WebSocketHandler) getUpgrader() websocket.Upgrader { + return websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + for _, allowed := range wsh.cfg.AllowedOrigins { + if allowed == origin { + return true + } + } + return false + }, } } @@ -70,16 +66,8 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) { // Check for JWT token in query parameter jwtToken := c.Query("token") if jwtToken != "" { - // Validate JWT signature and expiration - STATELESS, no DB query! - jwtSecret := os.Getenv("JWT_SECRET") - if jwtSecret == "" { - log.Println("JWT_SECRET not configured") - c.JSON(http.StatusInternalServerError, gin.H{"error": "Server configuration error"}) - return - } - // Direct JWT validation - fast path (~1ms) - claims, err := auth.ValidateJWT(jwtToken, jwtSecret) + claims, err := auth.ValidateJWT(jwtToken, wsh.cfg.JWTSecret) if err == nil { // Extract user data from JWT claims uid, parseErr := uuid.Parse(claims.Subject) @@ -151,6 +139,7 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) { } // Upgrade connection + upgrader := wsh.getUpgrader() conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { log.Printf("Failed to upgrade connection: %v", err) diff --git a/frontend/.env.example b/frontend/.env.example index e122839..ae24531 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,10 +1,15 @@ # Frontend Environment Variables # Copy this file to .env.local for local development -# API Configuration -# Backend API base URL (must include /api path) +# =================== +# API CONFIGURATION +# =================== + +# Backend API URL (must include /api path) +# Default for development: http://localhost:8080/api VITE_API_URL=http://localhost:8080/api -# WebSocket Configuration -# WebSocket server URL for real-time collaboration (must include /ws path) +# WebSocket URL (must include /ws path) +# Default for development: ws://localhost:8080/ws +# Use wss:// for production (secure WebSocket) VITE_WS_URL=ws://localhost:8080/ws diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index efc4d35..8f1a1b1 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,4 @@ -const API_BASE_URL = import.meta.env.VITE_API_URL || "https://docnest-backend-mingda.fly.dev/api"; +import { API_BASE_URL } from '../config'; export async function authFetch(url: string, options?: RequestInit): Promise { const token = localStorage.getItem('auth_token'); diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts new file mode 100644 index 0000000..2490650 --- /dev/null +++ b/frontend/src/config/index.ts @@ -0,0 +1,24 @@ +interface Config { + apiUrl: string; + wsUrl: string; + environment: 'development' | 'production'; +} + +function loadConfig(): Config { + const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080/api'; + const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:8080/ws'; + const environment = import.meta.env.MODE === 'production' ? 'production' : 'development'; + + return { + apiUrl, + wsUrl, + environment, + }; +} + +export const config = loadConfig(); + +// Convenience exports +export const API_BASE_URL = config.apiUrl; +export const WS_URL = config.wsUrl; +export const isProduction = config.environment === 'production'; diff --git a/frontend/src/hooks/useYjsDocument.ts b/frontend/src/hooks/useYjsDocument.ts index 57f3923..d2a2584 100644 --- a/frontend/src/hooks/useYjsDocument.ts +++ b/frontend/src/hooks/useYjsDocument.ts @@ -47,6 +47,13 @@ export const useYjsDocument = (documentId: string, shareToken?: string) => { let currentProviders: YjsProviders | null = null; const initializeDocument = async () => { + // Read port override from query param for testing multiple backends + const searchParams = new URLSearchParams(window.location.search); + const portOverride = searchParams.get('port'); + const wsUrlOverride = portOverride + ? `ws://localhost:${portOverride}/ws` + : undefined; + // For share token access, use placeholder user info // Extract user data (handle both direct user object and nested structure for backwards compat) const realUser = user || { @@ -69,12 +76,16 @@ export const useYjsDocument = (documentId: string, shareToken?: string) => { }); const authToken = token || ""; console.log("authToken is " + token); + if (wsUrlOverride) { + console.log(`🔧 Using WebSocket URL override: ${wsUrlOverride}`); + } const yjsProviders = await createYjsDocument( documentId, { id: currentId, name: currentName, avatar_url: currentAvatar }, authToken, - shareToken + shareToken, + wsUrlOverride ); currentProviders = yjsProviders; diff --git a/frontend/src/lib/yjs.ts b/frontend/src/lib/yjs.ts index 4cc6963..241a1f8 100644 --- a/frontend/src/lib/yjs.ts +++ b/frontend/src/lib/yjs.ts @@ -3,8 +3,7 @@ import { Awareness } from "y-protocols/awareness"; import { WebsocketProvider } from "y-websocket"; import * as Y from "yjs"; import { documentsApi } from "../api/document"; - -const WS_URL = import.meta.env.VITE_WS_URL || "wss://docnest-backend-mingda.fly.dev/ws"; +import { WS_URL } from "../config"; export interface YjsProviders { ydoc: Y.Doc; @@ -23,7 +22,8 @@ export const createYjsDocument = async ( documentId: string, _user: YjsUser, token: string, - shareToken?: string + shareToken?: string, + wsUrlOverride?: string ): Promise => { // Create Yjs document const ydoc = new Y.Doc(); @@ -46,8 +46,9 @@ export const createYjsDocument = async ( const wsParams: { [key: string]: string } = shareToken ? { share: shareToken } : { token: token }; + const wsUrl = wsUrlOverride || WS_URL; const websocketProvider = new WebsocketProvider( - WS_URL, + wsUrl, documentId, ydoc, { params: wsParams } diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index 8c7f5e5..c76621f 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -1,9 +1,8 @@ import FloatingGem from '../components/PixelSprites/FloatingGem'; import PixelIcon from '../components/PixelIcon/PixelIcon'; +import { API_BASE_URL } from '../config'; import './LandingPage.css'; -const API_BASE_URL = import.meta.env.VITE_API_URL || "https://docnest-backend-mingda.fly.dev/api"; - function LandingPage() { const handleGoogleLogin = () => { window.location.href = `${API_BASE_URL}/auth/google`; diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 4798309..20003ab 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,10 +1,9 @@ import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; +import { API_BASE_URL } from '../config'; import './LoginPage.css'; -const API_BASE_URL = import.meta.env.VITE_API_URL || "https://docnest-backend-mingda.fly.dev/api"; - function LoginPage() { const { user, loading } = useAuth(); const navigate = useNavigate();