feat(console): go:embed the console2 SPA into cloud — the one-binary foundation

Hanzo V8: Open Edition. cloud/clients/console go:embeds dist/ (the console2 static
export) and mounts the SPA at "/" with SPA-fallback, order 990 — the last-resort
catch-all AFTER every /v1/* route (isAPIPath refuses to HTML-fallback /v1,/zap,/_,
/healthz so JSON clients get honest 404s). Registered in subsystems.go.

This is the seam that makes ONE Go binary the whole cloud — edge + gateway + every
subsystem + the frontend. Placeholder dist/index.html is overwritten by the
console2 static-export bundle at image-build time. Build verified: go build -tags
cloud ./clients/console/ clean.
This commit is contained in:
2026-07-01 05:59:48 -07:00
parent 2d89b8d4f7
commit 625c234e14
3 changed files with 132 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
//go:build cloud
// Package console serves the Hanzo console2 single-page app FROM INSIDE the
// unified cloud binary — the "one binary" endgame (Hanzo V8: Open Edition).
//
// The whole frontend ships as static assets go:embed'd into `dist/` (produced by
// `console2`'s static export). This subsystem mounts them at "/" with SPA
// fallback: a request for a real embedded file serves that file; anything else
// serves index.html so the client-side router takes over. It registers LAST
// (order 990) so every /v1/* API route and every other subsystem wins first —
// the SPA is the catch-all of last resort.
//
// Result: ONE Go binary is the entire cloud — TLS/edge + gateway + every backend
// subsystem + the console UI. One load balancer, N stateless replicas, per-tenant
// SQLite → SeaweedFS/S3. Any dev runs the binary and has the whole cloud; any node
// can join. No separate ingress/gateway/console2/cloud-api pods to operate.
package console
import (
"embed"
"fmt"
"io/fs"
"net/http"
"strings"
"github.com/hanzoai/cloud"
"github.com/hanzoai/zip"
)
// dist holds the built console2 SPA. The committed placeholder keeps the build
// green until `console2`'s static export is wired into CI to overwrite it; the
// real bundle is dropped here at image-build time.
//
//go:embed dist
var dist embed.FS
// order 990 — after every subsystem and every /v1/* route; the SPA is the
// last-resort catch-all for browser (non-API) paths only.
const order = 990
func init() {
cloud.Register("console", order, func(app any, deps cloud.Deps) error {
zapp, ok := app.(*zip.App)
if !ok {
return fmt.Errorf("console.Mount: expected *zip.App, got %T", app)
}
return Mount(zapp, deps)
})
}
// Mount serves the embedded SPA at "/" with SPA fallback.
func Mount(app *zip.App, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("console.Mount: nil zip.App")
}
sub, err := fs.Sub(dist, "dist")
if err != nil {
return fmt.Errorf("console.Mount: embed sub: %w", err)
}
app.Mount("/", spaHandler(sub))
if deps.Logger != nil {
deps.Logger.New("subsystem", "console").
Info("console SPA mounted (embedded)", "brand", deps.Brand, "path", "/")
}
return nil
}
// spaHandler serves static files from fsys, falling back to index.html for any
// path that is not an embedded file (client-side routing). API namespaces are
// never reached here — they are matched by earlier-order routes — but we still
// refuse to serve index.html for /v1, /zap and /_ so a stray miss is an honest
// 404 rather than an HTML body that breaks JSON clients.
func spaHandler(fsys fs.FS) http.Handler {
files := http.FileServer(http.FS(fsys))
index, _ := fs.ReadFile(fsys, "index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
if p == "" {
serveIndex(w, r, index)
return
}
// Never HTML-fallback API/edge namespaces — honest 404 for JSON clients.
if isAPIPath(r.URL.Path) {
http.NotFound(w, r)
return
}
if f, err := fsys.Open(p); err == nil {
_ = f.Close()
files.ServeHTTP(w, r)
return
}
serveIndex(w, r, index) // SPA route → client router
})
}
func serveIndex(w http.ResponseWriter, r *http.Request, index []byte) {
if index == nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(index)
}
func isAPIPath(p string) bool {
for _, pre := range []string{"/v1/", "/zap", "/_/", "/healthz", "/readyz"} {
if p == strings.TrimSuffix(pre, "/") || strings.HasPrefix(p, pre) {
return true
}
}
return false
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hanzo Cloud</title>
</head>
<body>
<!-- Placeholder. The console2 static export overwrites this dist/ at image
build time; the real SPA bundle is served from here inside the one
unified cloud binary (Hanzo V8: Open Edition). -->
<div id="root">Hanzo Cloud — console bundle not embedded in this build.</div>
</body>
</html>
+5
View File
@@ -69,4 +69,9 @@ import (
// Deployment) so hanzoai/o11y's /v1/o11y/* surface serves real telemetry
// instead of the "runtime not initialized" 503.
_ "github.com/hanzoai/cloud/clients/o11y" // order 71 — installs o11y.SetHandler
// The console2 SPA, go:embed'd and served at "/" (order 990 — the last-resort
// catch-all after every /v1/* route). This is the "one binary" endgame: the
// unified cloud binary IS the frontend too. Hanzo V8: Open Edition.
_ "github.com/hanzoai/cloud/clients/console" // order 990 — SPA at /
)