// Package webui serves the embedded static UI build over the public edge. // // The committed dist/ holds only placeholder index.html / landing.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: the game SPA is mounted at /app/ // (web) and /telegram/ (the Telegram Mini App), with a separate landing page at / — the // single-origin model in docs/ARCHITECTURE.md §13. // // Caching (Stage 17): Vite emits hash-named files under assets/, so those are immutable and // cached hard (a reload/relaunch is a cache hit, not a re-download); the HTML shells carry // no-cache so a new deploy is picked up immediately. 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 UI. An existing file is served directly (hash-named assets get // an immutable cache); every other path falls back to indexName (the SPA shell or the landing // page) so a client-side deep link still loads. 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. // "/app/" or "/telegram/"). func Handler(stripPrefix, indexName 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, indexName) return } if info, err := fs.Stat(content, name); err != nil || info.IsDir() { // Unknown path or a directory: serve the shell, never a listing. serveIndex(w, content, indexName) return } // Hash-named build assets are immutable — cache them for a year so reopening the // app (notably a relaunched Telegram Mini App) is a cache hit, not a re-download. if strings.HasPrefix(name, "assets/") { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } files.ServeHTTP(w, r) }) if p := strings.TrimSuffix(stripPrefix, "/"); p != "" { return http.StripPrefix(p, h) } return h } // serveIndex writes the named HTML shell with a 200 status, so a client-routed deep link // still loads the app rather than a 404. The shell is marked no-cache so a new deploy's // shell (and the asset URLs it references) is fetched fresh. func serveIndex(w http.ResponseWriter, content fs.FS, indexName string) { data, err := fs.ReadFile(content, indexName) if err != nil { http.Error(w, "ui not built", http.StatusInternalServerError) return } w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write(data) }