Files
enkly-ai/services/gateway/cmd/server/main.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

174 lines
6.7 KiB
Go

// Command server runs the gateway: the BFF that terminates the public
// HTTP/HTMX surface and composes it from every other bounded context
// over gRPC. Replaces the net/http wiring half of the monolith's
// cmd/server/main.go.
package main
import (
"context"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
agentv1 "enkly/gen/agent/v1"
billingv1 "enkly/gen/billing/v1"
conversationv1 "enkly/gen/conversation/v1"
iamv1 "enkly/gen/iam/v1"
knowledgev1 "enkly/gen/knowledge/v1"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/envkit"
"enkly/services/gateway/internal/handlers"
)
func dial(addr string, extraOpts ...grpc.DialOption) *grpc.ClientConn {
opts := append([]grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}, extraOpts...)
conn, err := grpc.NewClient(addr, opts...)
if err != nil {
panic(err) // unreachable at startup is fatal; see main()'s recover-free startup sequence below
}
return conn
}
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
if err := envkit.LoadDotEnv(".env"); err != nil {
logger.Error("load .env", "err", err)
os.Exit(1)
}
var (
httpAddr = envkit.String("ENKLY_GATEWAY_HTTP_ADDR", ":8080")
baseURL = envkit.String("ENKLY_BASE_URL", "http://localhost:8080")
templatesDir = envkit.String("ENKLY_TEMPLATES_DIR", "web/templates")
staticDir = envkit.String("ENKLY_STATIC_DIR", "web/static")
iamAddr = envkit.String("ENKLY_IAM_GRPC_ADDR", "iam:9001")
billingAddr = envkit.String("ENKLY_BILLING_GRPC_ADDR", "billing:9002")
conversationAddr = envkit.String("ENKLY_CONVERSATION_GRPC_ADDR", "conversation:9003")
workspaceAddr = envkit.String("ENKLY_WORKSPACE_GRPC_ADDR", "workspace:9004")
knowledgeAddr = envkit.String("ENKLY_KNOWLEDGE_GRPC_ADDR", "knowledge:9005")
agentAddr = envkit.String("ENKLY_AGENT_GRPC_ADDR", "agent:9006")
stripePublishKey = envkit.String("STRIPE_PUBLISH_KEY", "")
)
templates, err := handlers.LoadTemplates(templatesDir)
if err != nil {
logger.Error("load templates", "err", err)
os.Exit(1)
}
bigMsg := grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(24<<20), grpc.MaxCallSendMsgSize(24<<20))
h := &handlers.Handlers{
IAM: iamv1.NewIAMServiceClient(dial(iamAddr)),
Billing: billingv1.NewBillingServiceClient(dial(billingAddr)),
Conversation: conversationv1.NewConversationServiceClient(dial(conversationAddr)),
Workspace: workspacev1.NewWorkspaceServiceClient(dial(workspaceAddr, bigMsg)),
Knowledge: knowledgev1.NewKnowledgeServiceClient(dial(knowledgeAddr)),
Agent: agentv1.NewAgentServiceClient(dial(agentAddr, bigMsg)),
Templates: templates,
Logger: logger,
BaseURL: baseURL,
StripePublishKey: stripePublishKey,
}
mux := http.NewServeMux()
// --- Health check (probed by k8s readinessProbe/livenessProbe in
// k8s/27-gateway.yaml at GET /healthz — must stay unauthenticated
// and dependency-free so a downstream gRPC outage doesn't also
// take the gateway pod itself out of rotation). ---
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// --- Public (no auth) ---
mux.HandleFunc("GET /login", h.LoginPage)
mux.HandleFunc("POST /login", h.RequireCSRF(h.Login))
mux.HandleFunc("GET /signup", h.SignupPage)
mux.HandleFunc("POST /signup", h.RequireCSRF(h.Signup))
mux.HandleFunc("GET /verify-email", h.VerifyEmailPage)
mux.HandleFunc("POST /verify-email", h.RequireCSRF(h.VerifyEmail))
mux.HandleFunc("POST /verify-email/resend", h.RequireCSRF(h.ResendVerification))
mux.HandleFunc("GET /forgot-password", h.ForgotPasswordPage)
mux.HandleFunc("POST /forgot-password", h.RequireCSRF(h.ForgotPassword))
mux.HandleFunc("GET /reset-password", h.ResetPasswordPage)
mux.HandleFunc("POST /reset-password", h.RequireCSRF(h.ResetPassword))
mux.HandleFunc("POST /logout", h.Logout)
mux.HandleFunc("POST /webhooks/stripe", h.StripeWebhook)
mux.HandleFunc("POST /theme", h.SetTheme)
mux.HandleFunc("POST /language", h.SetLanguage)
// --- Authenticated ---
mux.HandleFunc("GET /{$}", h.RequireAuth(h.Index))
mux.HandleFunc("GET /c/{id}", h.RequireAuth(h.SwitchConversation))
mux.HandleFunc("POST /conversations/new", h.RequireAuth(h.RequireCSRF(h.NewConversation)))
mux.HandleFunc("POST /conversations/{id}/rename", h.RequireAuth(h.RequireCSRF(h.RenameConversation)))
mux.HandleFunc("POST /conversations/{id}/delete", h.RequireAuth(h.RequireCSRF(h.DeleteConversationHandler)))
mux.HandleFunc("POST /chat", h.RequireAuth(h.RequireCSRF(h.Chat)))
mux.HandleFunc("GET /chat/stream/{id}", h.RequireAuth(h.StreamChat))
mux.HandleFunc("POST /chat/stream/{id}/stop", h.RequireAuth(h.RequireCSRF(h.StopStream)))
mux.HandleFunc("GET /tree", h.RequireAuth(h.FileTree))
mux.HandleFunc("GET /status", h.RequireAuth(h.StatusBar))
mux.HandleFunc("GET /files", h.RequireAuth(h.FilesPanel))
mux.HandleFunc("POST /files/upload", h.RequireAuth(h.RequireCSRFUpload(h.UploadFile)))
mux.HandleFunc("POST /files/attach", h.RequireAuth(h.RequireCSRFUpload(h.AttachFile)))
mux.HandleFunc("GET /files/download/{path...}", h.RequireAuth(h.DownloadFile))
mux.HandleFunc("GET /subscription", h.RequireAuth(h.SubscriptionPage))
mux.HandleFunc("POST /subscription/checkout", h.RequireAuth(h.RequireCSRF(h.CreateCheckout)))
mux.HandleFunc("POST /subscription/portal", h.RequireAuth(h.RequireCSRF(h.BillingPortal)))
mux.HandleFunc("GET /subscription/success", h.RequireAuth(h.SubscriptionSuccess))
mux.HandleFunc("GET /subscription/cancel", h.RequireAuth(h.SubscriptionCancel))
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))
// Shared edge middleware — security headers, gzip, structured
// request logging, panic recovery — applied around the whole mux,
// identical ordering to the monolith's.
root := handlers.Chain(mux,
handlers.Recover(logger),
handlers.RequestLogger(logger),
handlers.SecurityHeaders,
handlers.Gzip,
)
lis, err := net.Listen("tcp", httpAddr)
if err != nil {
logger.Error("listen", "addr", httpAddr, "err", err)
os.Exit(1)
}
srv := &http.Server{
Handler: root,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
logger.Info("gateway listening", "addr", httpAddr, "base_url", baseURL)
if err := srv.Serve(lis); err != nil && err != http.ErrServerClosed {
logger.Error("serve", "err", err)
os.Exit(1)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-ctx.Done()
logger.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}