Files
tasks/ui/embed.go
zeekay 51f359f630 chore: purge tamagui — use @hanzo/gui only natively
- Makefile, ui/README.md, ui/embed.go: comments Tamagui → @hanzo/gui
2026-04-28 08:45:07 -07:00

109 lines
3.7 KiB
Go

// Package ui exposes the built admin-tasks (@hanzo/gui) bundle as an
// embedded filesystem.
//
// The bundle is produced externally in the @hanzo/gui workspace at
// ~/work/hanzo/gui/code/admin-tasks (Vite + @hanzo/gui) and synced into
// ui/dist by scripts/sync-admin-ui.sh. The synced dist/ is baked into
// the tasksd binary at compile time — no external static-assets
// directory, no sidecar, no separate deploy. See ui/README.md.
//
// Mount the returned handler at /_/tasks/ in the tasksd HTTP router
// so it serves the SPA shell for every non-API request:
//
// import tasksui "github.com/hanzoai/tasks/ui"
// router.PathPrefix("/_/tasks/").Handler(http.StripPrefix("/_/tasks", tasksui.Handler()))
//
// API routes under /v1/* take precedence via earlier route
// registration; anything else falls through to the SPA, which uses
// client-side routing so deep links survive reload.
package ui
import (
"embed"
"io/fs"
"net/http"
"path"
"strings"
)
//go:embed all:dist
var distFS embed.FS
// FS returns the embedded built-UI filesystem rooted at dist/.
// Empty when scripts/sync-admin-ui.sh has not been run (dev workflow:
// run the Vite dev server inside ~/work/hanzo/gui/code/admin-tasks).
func FS() fs.FS {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
// Impossible at runtime because embed.FS entries are
// validated at compile time; if dist/ is missing the
// binary simply carries an empty FS.
return distFS
}
return sub
}
// Handler returns an http.Handler that serves the embedded SPA.
//
// Behaviour:
// - Exact-match static assets (JS/CSS/images) ship with
// immutable cache hints because Vite hashes filenames.
// - Anything else rewrites to /index.html so the React router
// handles the route client-side. This is the standard SPA
// fallback and is the only way react-router's BrowserRouter
// survives a page reload on a deep link.
// - If the build hasn't run and index.html is missing, every
// request returns 503 so operators notice in staging before
// shipping a blank image to production.
func Handler() http.Handler {
root := FS()
fileServer := http.FileServer(http.FS(root))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only GET/HEAD — the UI is static; any other method on a
// non-/api path is a client error, not a route we own.
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
reqPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if reqPath == "" {
reqPath = "index.html"
}
// Probe for the asset. If it doesn't exist, serve the SPA
// shell so client-side routing picks up the deep link.
if _, err := fs.Stat(root, reqPath); err != nil {
serveIndex(w, r, root)
return
}
// Hashed assets under /assets/ are immutable — Vite emits
// them with content-addressed names so it's safe to tell
// browsers to cache forever.
if strings.HasPrefix(reqPath, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
fileServer.ServeHTTP(w, r)
})
}
// serveIndex writes index.html with no-cache so a newly-deployed
// build replaces the stale shell on the next request.
func serveIndex(w http.ResponseWriter, r *http.Request, root fs.FS) {
data, err := fs.ReadFile(root, "index.html")
if err != nil {
// No UI bundled — report honestly. Operators see this in
// logs and in the browser; no blank screen in production.
http.Error(w, "tasks UI not built (run scripts/sync-admin-ui.sh)", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(data)
_ = r
}