// Package webui embeds the compiled Next.js static export so the single // backend binary can serve the full product UI. The build pipeline is // responsible for populating dist/ (see scripts/build.ps1) by copying // frontend/out/ into this directory before running go build. package webui import ( "embed" "errors" "io" "io/fs" "mime" "net/http" "path" "path/filepath" "strconv" "strings" ) //go:embed all:dist var distFS embed.FS // placeholderHTML is served whenever a real UI has not been staged into // dist/ before `go build`. It lets the backend start and respond on the // UI routes during pure-Go development without tracking any generated // HTML in source control. const placeholderHTML = ` OrchestrAD

OrchestrAD

The web UI has not been staged into this binary. Run scripts/build.ps1 (or its -SkipFrontend companion after a separate frontend build) to produce a release binary that bundles the UI.

The API is fully functional at /api/v1.

` // FS returns the embedded UI filesystem rooted at dist/. func FS() fs.FS { sub, err := fs.Sub(distFS, "dist") if err != nil { return distFS } return sub } // IsBuilt reports whether a real built UI is present. When only the // .gitignore stub has been embedded, the frontend build was skipped and // callers should fall back to the placeholder document. func IsBuilt() bool { f, err := FS().Open("index.html") if err != nil { return false } _ = f.Close() return true } // Handler returns an http.Handler that serves the embedded UI with SPA // fallback: any request that does not resolve to a real embedded file is // served index.html with HTTP 200 so client-side routing can handle it. // Hashed asset paths under _next/static are served with a long-lived cache // header; everything else uses a short revalidating cache. // // Files are streamed directly from the embedded fs.FS rather than through // http.FileServer to avoid its built-in "/foo/index.html -> ./" redirect // behavior, which creates infinite loops when index.html is the resolved // file (e.g. for /, /rules, /auth/auth1/login, etc.). func Handler() http.Handler { return handlerForFS(FS()) } // handlerForFS is the testable core of Handler, parameterized over the // filesystem so tests can build synthetic UI trees with fstest.MapFS // without depending on whether a real frontend has been staged into dist/. func handlerForFS(root fs.FS) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if target, ok := legacyRedirect(r.URL.Path); ok { http.Redirect(w, r, target, http.StatusMovedPermanently) return } reqPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/") if reqPath == "" { reqPath = "index.html" } resolved, ok := resolve(root, reqPath) if !ok { serveIndex(w, r, root) return } if strings.HasPrefix(resolved, "_next/static/") { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "no-cache") } serveFile(w, r, root, resolved) }) } // serveFile streams name from root with a content-type derived from the file // extension and an explicit Content-Length header so HEAD requests and range // behavior match http.ServeContent's output. func serveFile(w http.ResponseWriter, r *http.Request, root fs.FS, name string) { f, err := root.Open(name) if err != nil { if errors.Is(err, fs.ErrNotExist) { serveIndex(w, r, root) return } http.Error(w, err.Error(), http.StatusInternalServerError) return } defer f.Close() info, err := fs.Stat(root, name) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if ct := mime.TypeByExtension(filepath.Ext(name)); ct != "" { w.Header().Set("Content-Type", ct) } else { w.Header().Set("Content-Type", "application/octet-stream") } w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10)) if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) return } w.WriteHeader(http.StatusOK) _, _ = io.Copy(w, f) } // legacyRedirect maps pre-flatten template paths (e.g. the Spike template's // /auth/auth1/login) to their current locations with HTTP 301. Returning ok // short-circuits asset resolution so stale bookmarks never feed back into // the SPA router, where they have historically produced malformed redirect // URLs like /auth/http://host/loginauth1/login. func legacyRedirect(reqPath string) (string, bool) { trimmed := strings.TrimRight(reqPath, "/") switch { case trimmed == "/auth/auth1/login": return "/login", true case strings.HasPrefix(trimmed, "/auth/auth1/"), trimmed == "/auth/auth1", strings.HasPrefix(trimmed, "/auth/"): return "/login", true } return "", false } // resolve mirrors Next.js static export's trailing-slash behavior: a request // for /rules first tries rules (file), then rules/index.html, then rules.html. // Returns the resolved path (without leading slash) and whether a real file // was found. func resolve(root fs.FS, reqPath string) (string, bool) { if exists(root, reqPath) && !isDir(root, reqPath) { return reqPath, true } if strings.HasSuffix(reqPath, "/") { candidate := reqPath + "index.html" if exists(root, candidate) { return candidate, true } } else { candidate := reqPath + "/index.html" if exists(root, candidate) { return candidate, true } candidate = reqPath + ".html" if exists(root, candidate) { return candidate, true } } return "", false } func exists(root fs.FS, name string) bool { _, err := fs.Stat(root, name) return err == nil } func isDir(root fs.FS, name string) bool { info, err := fs.Stat(root, name) if err != nil { return false } return info.IsDir() } // serveIndex writes the SPA entry document with HTTP 200 so deep links work. // Falls back to placeholderHTML when the real index.html has not been staged // into the embedded filesystem (dev builds without a prior frontend build). func serveIndex(w http.ResponseWriter, r *http.Request, root fs.FS) { f, err := root.Open("index.html") if err != nil { if errors.Is(err, fs.ErrNotExist) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") w.WriteHeader(http.StatusOK) _, _ = io.WriteString(w, placeholderHTML) return } http.Error(w, err.Error(), http.StatusInternalServerError) return } defer f.Close() w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") w.WriteHeader(http.StatusOK) _, _ = io.Copy(w, f) }