package main import ( "context" "fmt" "log" "net/http" "os" "os/signal" "os/user" "strconv" "strings" "sync/atomic" "syscall" "time" "github.com/gin-gonic/gin" "monica-go-completion-backend/internal/api" "monica-go-completion-backend/internal/cluster" "monica-go-completion-backend/internal/completion" "monica-go-completion-backend/internal/lsp" ) var requestIDSeed atomic.Int64 type config struct { Port string WorkspaceDir string AllowOrigin string APIToken string RequestTimeout time.Duration MaxBodyBytes int64 SessionTTL time.Duration CleanupInterval time.Duration MaxSessions int InstanceID string InstanceURL string EnableRedis bool RedisAddr string RedisPassword string RedisDB int RedisKeyPrefix string InstanceTTL time.Duration Heartbeat time.Duration Servers []completion.LanguageServerSpec } func main() { cfg := loadConfig() var registry completion.SessionRegistry if cfg.EnableRedis { var err error registry, err = cluster.NewRedisRegistry(context.Background(), cluster.RedisRegistryConfig{ Addr: cfg.RedisAddr, Password: cfg.RedisPassword, DB: cfg.RedisDB, KeyPrefix: cfg.RedisKeyPrefix, InstanceID: cfg.InstanceID, InstanceEndpoint: cfg.InstanceURL, SessionTTL: cfg.SessionTTL, InstanceTTL: cfg.InstanceTTL, HeartbeatInterval: cfg.Heartbeat, }) if err != nil { log.Fatalf("create redis registry failed: %v", err) } } manager := completion.NewManager(completion.ManagerConfig{ WorkspaceDir: cfg.WorkspaceDir, MaxSessions: cfg.MaxSessions, SessionTTL: cfg.SessionTTL, CleanupInterval: cfg.CleanupInterval, InstanceID: cfg.InstanceID, Registry: registry, }, cfg.Servers, func(ctx context.Context, spec completion.LanguageServerSpec, workspaceDir string) (completion.RuntimeClient, error) { return lsp.NewClient(ctx, lsp.Config{ Command: spec.Command, Args: spec.Args, RootPath: workspaceDir, LanguageID: spec.LanguageID, ClientName: "monica-lsp-gateway", }) }) defer func() { _ = manager.Close() }() router := gin.New() router.Use(gin.Logger()) router.Use(gin.Recovery()) router.Use(requestIDMiddleware()) router.Use(corsMiddleware(cfg.AllowOrigin)) router.Use(apiTokenMiddleware(cfg.APIToken)) api.RegisterRoutes(router, manager, api.RouteOptions{ RequestTimeout: cfg.RequestTimeout, MaxBodyBytes: cfg.MaxBodyBytes, }) server := &http.Server{ Addr: ":" + cfg.Port, Handler: router, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } go func() { log.Printf( "lsp gateway listening on :%s, workspace=%s, instanceID=%s, redis=%t", cfg.Port, cfg.WorkspaceDir, cfg.InstanceID, cfg.EnableRedis, ) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("http server failed: %v", err) } }() sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) <-sigCh shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := server.Shutdown(shutdownCtx); err != nil { log.Printf("http shutdown failed: %v", err) } if err := manager.Close(); err != nil { log.Printf("lsp manager close failed: %v", err) } } func loadConfig() config { cwd, err := os.Getwd() if err != nil { cwd = "." } return config{ Port: getenv("PORT", "8080"), WorkspaceDir: getenv("WORKSPACE_DIR", cwd), AllowOrigin: getenv("CORS_ALLOW_ORIGIN", "*"), APIToken: strings.TrimSpace(os.Getenv("LSP_API_TOKEN")), RequestTimeout: getenvDuration("REQUEST_TIMEOUT", 10*time.Second), MaxBodyBytes: getenvInt64("MAX_BODY_BYTES", 2<<20), SessionTTL: getenvDuration("SESSION_TTL", 20*time.Minute), CleanupInterval: getenvDuration("SESSION_CLEANUP_INTERVAL", 2*time.Minute), MaxSessions: getenvInt("MAX_SESSIONS", 256), InstanceID: getenv("INSTANCE_ID", defaultInstanceID()), InstanceURL: getenv("INSTANCE_URL", "http://127.0.0.1:"+getenv("PORT", "8080")), EnableRedis: getenvBool("ENABLE_REDIS_STICKY", true), RedisAddr: getenv("REDIS_ADDR", "10.0.0.10:6379"), RedisPassword: getenv("REDIS_PASSWORD", ""), RedisDB: getenvInt("REDIS_DB", 1), RedisKeyPrefix: getenv("REDIS_KEY_PREFIX", "lsp-gateway"), InstanceTTL: getenvDuration("INSTANCE_TTL", 30*time.Second), Heartbeat: getenvDuration("INSTANCE_HEARTBEAT_INTERVAL", 10*time.Second), Servers: []completion.LanguageServerSpec{ { Language: "go", LanguageID: "go", Command: getenv("GO_LSP_COMMAND", "gopls"), Args: getenvArgs("GO_LSP_ARGS", ""), }, { Language: "javascript", LanguageID: "javascript", Command: getenv("JAVASCRIPT_LSP_COMMAND", "typescript-language-server"), Args: getenvArgs("JAVASCRIPT_LSP_ARGS", "--stdio"), }, { Language: "typescript", LanguageID: "typescript", Command: getenv("TYPESCRIPT_LSP_COMMAND", "typescript-language-server"), Args: getenvArgs("TYPESCRIPT_LSP_ARGS", "--stdio"), }, }, } } func defaultInstanceID() string { host, err := os.Hostname() if err != nil || strings.TrimSpace(host) == "" { host = "unknown-host" } userName := "unknown-user" if u, err := user.Current(); err == nil && strings.TrimSpace(u.Username) != "" { userName = strings.ReplaceAll(u.Username, "\\", "-") } return fmt.Sprintf("%s-%s-%d", host, userName, os.Getpid()) } func getenv(key, fallback string) string { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } return v } func getenvArgs(key, fallback string) []string { value := getenv(key, fallback) if value == "" { return nil } return strings.Fields(value) } func getenvInt(key string, fallback int) int { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } parsed, err := strconv.Atoi(v) if err == nil { return parsed } return fallback } func getenvInt64(key string, fallback int64) int64 { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } parsed, err := strconv.ParseInt(v, 10, 64) if err == nil { return parsed } return fallback } func getenvDuration(key string, fallback time.Duration) time.Duration { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } parsed, err := time.ParseDuration(v) if err != nil { return fallback } return parsed } func getenvBool(key string, fallback bool) bool { v := strings.TrimSpace(strings.ToLower(os.Getenv(key))) if v == "" { return fallback } switch v { case "1", "true", "yes", "on": return true case "0", "false", "no", "off": return false default: return fallback } } func corsMiddleware(allowOrigin string) gin.HandlerFunc { return func(c *gin.Context) { c.Header("Access-Control-Allow-Origin", allowOrigin) c.Header("Access-Control-Allow-Methods", "GET,POST,OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type,Authorization,X-API-Key") if c.Request.Method == http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) return } c.Next() } } func apiTokenMiddleware(token string) gin.HandlerFunc { required := strings.TrimSpace(token) if required == "" { return func(c *gin.Context) { c.Next() } } return func(c *gin.Context) { provided := strings.TrimSpace(c.GetHeader("X-API-Key")) if provided == required { c.Next() return } c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) } } func requestIDMiddleware() gin.HandlerFunc { return func(c *gin.Context) { rid := strings.TrimSpace(c.GetHeader("X-Request-Id")) if rid == "" { rid = fmt.Sprintf("req-%d", requestIDSeed.Add(1)) } c.Header("X-Request-Id", rid) c.Set("requestId", rid) c.Next() } }