- Introduced session management using Redis for tracking active sessions. - Added session claiming and releasing functionality in the completion manager. - Enhanced HTTP and WebSocket completion endpoints to support multiple languages. - Implemented request timeout and maximum body size configurations for API routes. - Updated client-side code to handle session IDs and language parameters in completion requests. - Improved error handling for unsupported languages and session conflicts. - Added tests for the completion manager to ensure proper session handling and cleanup.
309 lines
7.8 KiB
Go
309 lines
7.8 KiB
Go
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()
|
|
}
|
|
}
|