//go:build embed package web import ( "bytes" "context" "embed" "encoding/json" "io" "io/fs" "net/http" "strings" "time" "github.com/gin-gonic/gin" ) //go:embed all:dist var frontendFS embed.FS // PublicSettingsProvider is an interface to fetch public settings type PublicSettingsProvider interface { GetPublicSettingsForInjection(ctx context.Context) (any, error) } // FrontendServer serves the embedded frontend with settings injection type FrontendServer struct { distFS fs.FS fileServer http.Handler baseHTML []byte cache *HTMLCache settings PublicSettingsProvider } // NewFrontendServer creates a new frontend server with settings injection func NewFrontendServer(settingsProvider PublicSettingsProvider) (*FrontendServer, error) { distFS, err := fs.Sub(frontendFS, "dist") if err != nil { return nil, err } // Read base HTML once file, err := distFS.Open("index.html") if err != nil { return nil, err } defer func() { _ = file.Close() }() baseHTML, err := io.ReadAll(file) if err != nil { return nil, err } cache := NewHTMLCache() cache.SetBaseHTML(baseHTML) return &FrontendServer{ distFS: distFS, fileServer: http.FileServer(http.FS(distFS)), baseHTML: baseHTML, cache: cache, settings: settingsProvider, }, nil } // InvalidateCache invalidates the HTML cache (call when settings change) func (s *FrontendServer) InvalidateCache() { if s != nil && s.cache != nil { s.cache.Invalidate() } } // Middleware returns the Gin middleware handler func (s *FrontendServer) Middleware() gin.HandlerFunc { return func(c *gin.Context) { path := c.Request.URL.Path // Skip API routes if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/v1/") || strings.HasPrefix(path, "/v1beta/") || strings.HasPrefix(path, "/antigravity/") || strings.HasPrefix(path, "/setup/") || path == "/health" || path == "/responses" { c.Next() return } cleanPath := strings.TrimPrefix(path, "/") if cleanPath == "" { cleanPath = "index.html" } // For index.html or SPA routes, serve with injected settings if cleanPath == "index.html" || !s.fileExists(cleanPath) { s.serveIndexHTML(c) return } // Serve static files normally s.fileServer.ServeHTTP(c.Writer, c.Request) c.Abort() } } func (s *FrontendServer) fileExists(path string) bool { file, err := s.distFS.Open(path) if err != nil { return false } _ = file.Close() return true } func (s *FrontendServer) serveIndexHTML(c *gin.Context) { // Check cache first cached := s.cache.Get() if cached != nil { // Check If-None-Match for 304 response if match := c.GetHeader("If-None-Match"); match == cached.ETag { c.Status(http.StatusNotModified) c.Abort() return } c.Header("ETag", cached.ETag) c.Header("Cache-Control", "no-cache") // Must revalidate c.Data(http.StatusOK, "text/html; charset=utf-8", cached.Content) c.Abort() return } // Cache miss - fetch settings and render ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) defer cancel() settings, err := s.settings.GetPublicSettingsForInjection(ctx) if err != nil { // Fallback: serve without injection c.Data(http.StatusOK, "text/html; charset=utf-8", s.baseHTML) c.Abort() return } settingsJSON, err := json.Marshal(settings) if err != nil { // Fallback: serve without injection c.Data(http.StatusOK, "text/html; charset=utf-8", s.baseHTML) c.Abort() return } rendered := s.injectSettings(settingsJSON) s.cache.Set(rendered, settingsJSON) cached = s.cache.Get() if cached != nil { c.Header("ETag", cached.ETag) } c.Header("Cache-Control", "no-cache") c.Data(http.StatusOK, "text/html; charset=utf-8", rendered) c.Abort() } func (s *FrontendServer) injectSettings(settingsJSON []byte) []byte { // Create the script tag to inject script := []byte(``) // Inject before headClose := []byte("") return bytes.Replace(s.baseHTML, headClose, append(script, headClose...), 1) } // ServeEmbeddedFrontend returns a middleware for serving embedded frontend // This is the legacy function for backward compatibility when no settings provider is available func ServeEmbeddedFrontend() gin.HandlerFunc { distFS, err := fs.Sub(frontendFS, "dist") if err != nil { panic("failed to get dist subdirectory: " + err.Error()) } fileServer := http.FileServer(http.FS(distFS)) return func(c *gin.Context) { path := c.Request.URL.Path if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/v1/") || strings.HasPrefix(path, "/v1beta/") || strings.HasPrefix(path, "/antigravity/") || strings.HasPrefix(path, "/setup/") || path == "/health" || path == "/responses" { c.Next() return } cleanPath := strings.TrimPrefix(path, "/") if cleanPath == "" { cleanPath = "index.html" } if file, err := distFS.Open(cleanPath); err == nil { _ = file.Close() fileServer.ServeHTTP(c.Writer, c.Request) c.Abort() return } serveIndexHTML(c, distFS) } } func serveIndexHTML(c *gin.Context, fsys fs.FS) { file, err := fsys.Open("index.html") if err != nil { c.String(http.StatusNotFound, "Frontend not found") c.Abort() return } defer func() { _ = file.Close() }() content, err := io.ReadAll(file) if err != nil { c.String(http.StatusInternalServerError, "Failed to read index.html") c.Abort() return } c.Data(http.StatusOK, "text/html; charset=utf-8", content) c.Abort() } func HasEmbeddedFrontend() bool { _, err := frontendFS.ReadFile("dist/index.html") return err == nil }