// Package frontend serves the Wild Cloud web UI. // // In development (WILD_CENTRAL_ENV=development), it reverse-proxies all // non-API requests to the Vite dev server for hot module replacement. // // In production, it serves pre-built static files from a directory // with SPA fallback (unknown paths serve index.html). package frontend import ( "errors" "io/fs" "log/slog" "net/http" "net/http/httputil" "net/url" "os" "path/filepath" "strings" ) // Handler returns an http.Handler that serves the web UI. // // In dev mode it proxies to the Vite dev server at viteURL (e.g. "http://localhost:5173"). // In prod mode it serves static files from staticDir with SPA fallback. func Handler(staticDir, viteURL string) http.Handler { if isDev() && viteURL != "" { return devProxy(viteURL) } return prodHandler(staticDir) } func isDev() bool { return os.Getenv("WILD_CENTRAL_ENV") == "development" } // devProxy reverse-proxies everything to the Vite dev server, // including WebSocket upgrades for HMR (/__vite_hmr). func devProxy(rawURL string) http.Handler { target, err := url.Parse(rawURL) if err != nil { slog.Error("invalid vite dev server URL", "url", rawURL, "error", err) return http.NotFoundHandler() } proxy := httputil.NewSingleHostReverseProxy(target) // Preserve the original Director, rewrite Host for Vite's allowedHosts check, // and forward WebSocket upgrade headers for HMR. defaultDirector := proxy.Director proxy.Director = func(req *http.Request) { defaultDirector(req) req.Host = target.Host if strings.EqualFold(req.Header.Get("Upgrade"), "websocket") { req.Header.Set("Connection", "Upgrade") } } slog.Info("frontend dev proxy active", "target", rawURL) return proxy } // prodHandler serves static files with SPA fallback. // If the requested path matches a real file, serve it. // Otherwise serve index.html so client-side routing works. func prodHandler(dir string) http.Handler { slog.Info("frontend serving static files", "dir", dir) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Clean the path and try to find the file. p := filepath.Join(dir, filepath.Clean(r.URL.Path)) // Check if the file exists and is not a directory. fi, err := os.Stat(p) if err == nil && !fi.IsDir() { http.ServeFile(w, r, p) return } // Check for index.html inside directories. if err == nil && fi.IsDir() { index := filepath.Join(p, "index.html") if _, err := os.Stat(index); err == nil { http.ServeFile(w, r, index) return } } // SPA fallback: serve root index.html for unmatched paths. index := filepath.Join(dir, "index.html") if _, err := os.Stat(index); errors.Is(err, fs.ErrNotExist) { http.NotFound(w, r) return } http.ServeFile(w, r, index) }) }