mirror of
https://github.com/hanzoai/flow.git
synced 2026-10-17 01:29:18 +00:00
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:
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user