// Package webui serves the embedded single-page UI build over the public edge. // // The committed dist/ holds only a placeholder index.html so the gateway module // compiles with a plain `go build` (and in CI) without a UI build. The production // gateway image replaces dist/ with the real Vite build before compiling (see // gateway/Dockerfile), so the binary ships the UI inside it. Because Vite is built // with a relative asset base, one build serves under any path: Handler is mounted // both at "/" (web) and at "/telegram/" (the Telegram Mini App), matching the // single-origin model in docs/ARCHITECTURE.md ยง13. package webui import ( "embed" "io/fs" "net/http" "path" "strings" ) //go:embed all:dist var dist embed.FS // distFS returns the embedded build rooted at dist/. The directory is embedded at // compile time, so its absence is a build error rather than a runtime condition. func distFS() fs.FS { sub, err := fs.Sub(dist, "dist") if err != nil { panic("webui: embedded dist/ missing: " + err.Error()) } return sub } // Handler serves the embedded SPA. An existing file is served directly (with the // standard content-type and caching headers); every other path falls back to // index.html so the client-side hash router can take over a deep link. When // stripPrefix is non-empty it is removed from the request path before lookup, so // the same build serves under a sub-path (e.g. "/telegram/"). func Handler(stripPrefix string) http.Handler { content := distFS() files := http.FileServer(http.FS(content)) h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") if name == "" { serveIndex(w, content) return } if info, err := fs.Stat(content, name); err != nil || info.IsDir() { // Unknown path or a directory: serve the SPA shell, never a listing. serveIndex(w, content) return } files.ServeHTTP(w, r) }) if p := strings.TrimSuffix(stripPrefix, "/"); p != "" { return http.StripPrefix(p, h) } return h } // serveIndex writes the SPA shell with a 200 status, so a client-routed deep link // still loads the app rather than a 404. func serveIndex(w http.ResponseWriter, content fs.FS) { data, err := fs.ReadFile(content, "index.html") if err != nil { http.Error(w, "ui not built", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write(data) }