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:
@@ -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
|
||||
}
|
||||
Vendored
+14
@@ -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>
|
||||
@@ -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 /
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user