feat(flow): flowweb — native Go front door, embeds UI + landing, proxies API

One binary fronts flow.hanzo.ai instead of the split flow-site landing + a
separate app host + a disabled-ingress backend. flowweb go:embeds the built flow
UI (Vite frontend) plus a self-contained Hanzo Flow landing and serves it with
SPA fallback, while reverse-proxying /api, /health, /openapi.json, /docs, /logs
etc. to the flow backend (FLOW_BACKEND_URL, default 127.0.0.1:7860). One image,
one origin — hanzoai/world + hanzoai/cloud's embedded-SPA pattern.

At plain `go build` it ships the committed landing placeholder; the image build
overwrites core/cmd/flowweb/frontend/ with the real Vite bundle before `go build`.
Verified: go build clean, GET / serves the landing (200), /api/* proxies.
This commit is contained in:
2026-07-17 22:41:34 -07:00
parent a5397cc135
commit b0a85aec05
3 changed files with 213 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hanzo Flow</title>
<meta name="description" content="Hanzo Flow — the one visual builder for AI, agent, and data flows. Multi-tenant, org-scoped, native." />
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; margin: 0; }
body {
font: 15px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: #000; color: #ededed; min-height: 100vh;
display: flex; align-items: center; justify-content: center; padding: 24px;
}
.wrap { max-width: 560px; text-align: center; }
.mark { width: 44px; height: 44px; margin: 0 auto 22px; display: block; color: #fff; }
h1 { font-size: 30px; font-weight: 650; letter-spacing: -0.02em; }
h1 span { color: #8b8b8b; font-weight: 400; }
p.lede { margin: 14px 0 28px; color: #a1a1a1; font-size: 16px; }
.cta { display: inline-flex; gap: 10px; }
a.btn {
display: inline-block; padding: 10px 20px; border-radius: 10px; text-decoration: none;
font-weight: 550; transition: transform .12s ease, background .12s ease;
}
a.primary { background: #fff; color: #000; }
a.primary:hover { transform: translateY(-1px); }
a.ghost { border: 1px solid #262626; color: #ededed; }
a.ghost:hover { border-color: #3a3a3a; }
.foot { margin-top: 34px; color: #6b6b6b; font-size: 12.5px; }
</style>
</head>
<body>
<main class="wrap">
<svg class="mark" viewBox="0 0 67 67" fill="currentColor" aria-hidden="true">
<path d="M22.21 67V44.6369H0V67H22.21Z"/>
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/>
<path d="M22.21 0H0V22.3184H22.21V0Z"/>
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/>
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/>
</svg>
<h1>Hanzo <span>Flow</span></h1>
<p class="lede">The one visual builder for AI, agent, and data flows — multi-tenant, org-scoped, native.</p>
<div class="cta">
<a class="btn primary" href="/flows">Open the builder</a>
<a class="btn ghost" href="/login">Sign in</a>
</div>
<div class="foot">Starting the app… if the builder doesn't load, it's warming up.</div>
</main>
<script>
// When the real Vite bundle is embedded, this file is overwritten by the app's
// own index.html. As a standalone landing, nudge signed-in users to the builder.
fetch('/api/v1/auto_login', { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(d => { if (d && d.frontend_timeout !== undefined) location.replace('/flows'); })
.catch(() => {});
</script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
// Command flowweb is the ONE native binary that fronts flow.hanzo.ai.
//
// It go:embeds the built flow UI (the Vite frontend + the landing) and serves it
// directly, while reverse-proxying the API surface to the flow backend. One image,
// one origin — no separate static landing site, no second app host, no
// disabled-ingress split. This mirrors hanzoai/world and hanzoai/cloud's
// embedded-SPA pattern: the Go binary is the front door; the Python backend (or,
// over time, the Go flow-core engine next door in this module) answers the API.
package main
import (
"log"
"net/http"
"os"
"time"
)
func main() {
addr := ":" + env("FLOW_WEB_PORT", "8080")
backend := env("FLOW_BACKEND_URL", "http://127.0.0.1:7860")
h, err := newHandler(backend)
if err != nil {
log.Fatalf("flowweb: %v", err)
}
srv := &http.Server{
Addr: addr,
Handler: h,
ReadHeaderTimeout: 15 * time.Second,
}
log.Printf("flowweb: embedded UI on %s, API → %s", addr, backend)
log.Fatal(srv.ListenAndServe())
}
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
+114
View File
@@ -0,0 +1,114 @@
package main
import (
"embed"
"io"
"io/fs"
"net/http"
"net/http/httputil"
"net/url"
"path"
"strings"
)
// frontend is the built flow UI. At a plain `go build` this is the committed
// landing placeholder (frontend/index.html); the image build runs the Vite build
// (`src/frontend` → `build/`) and OVERWRITES this directory with the real bundle
// BEFORE `go build`, so the shipped binary carries the full app. Same one-artifact
// discipline as hanzoai/world's webui/dist.
//
//go:embed all:frontend
var frontendFS embed.FS
// apiPrefixes go to the backend, never the SPA — an unmatched path here is the
// backend's real JSON 404, never index.html (so a client never gets HTML where it
// expects JSON). Everything else is a client-side route → the SPA shell.
var apiPrefixes = []string{
"/api/", "/health", "/health_check", "/openapi.json", "/docs", "/redoc",
"/.well-known/", "/logs", "/metrics",
}
type handler struct {
fsys fs.FS
index []byte
proxy *httputil.ReverseProxy
}
func newHandler(backend string) (*handler, error) {
sub, err := fs.Sub(frontendFS, "frontend")
if err != nil {
return nil, err
}
index, err := fs.ReadFile(sub, "index.html")
if err != nil {
return nil, err
}
u, err := url.Parse(backend)
if err != nil {
return nil, err
}
return &handler{fsys: sub, index: index, proxy: httputil.NewSingleHostReverseProxy(u)}, nil
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
upath := r.URL.Path
if !strings.HasPrefix(upath, "/") {
upath = "/" + upath
}
// API + backend surfaces → the flow backend verbatim (all methods).
for _, p := range apiPrefixes {
if upath == strings.TrimSuffix(p, "/") || strings.HasPrefix(upath, p) {
h.proxy.ServeHTTP(w, r)
return
}
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Exact static asset if it exists, else the SPA shell — the app's own home /
// login IS the landing, so one binary serves landing + app.
name := path.Clean(strings.TrimPrefix(upath, "/"))
if name != "" && name != "." && h.serveAsset(w, r, name) {
return
}
h.serveIndex(w, r)
}
// serveAsset writes the embedded file at name, or returns false (writing nothing)
// when it is missing or a directory, so the caller falls back to the SPA shell.
func (h *handler) serveAsset(w http.ResponseWriter, r *http.Request, name string) bool {
f, err := h.fsys.Open(name)
if err != nil {
return false
}
defer f.Close()
info, err := f.Stat()
if err != nil || info.IsDir() {
return false
}
rs, ok := f.(io.ReadSeeker)
if !ok {
return false
}
// Vite emits content-hashed filenames under assets/ — cache them hard.
if strings.HasPrefix(name, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
http.ServeContent(w, r, name, info.ModTime(), rs)
return true
}
// serveIndex writes the SPA shell (never cached, so a new build is picked up at
// once; the fingerprinted assets it references are cached hard above).
func (h *handler) serveIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = w.Write(h.index)
}
}
// compile-time assertion: handler is a stdlib http.Handler.
var _ http.Handler = (*handler)(nil)