Files
scrabble-game/gateway/internal/webui/webui.go
T
Ilia Denisov 8700fbfae1
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 19s
CI / deploy (pull_request) Failing after 1s
Stage 16: deploy infra & test contour
- backend + gateway multi-stage distroless Dockerfiles; the gateway embeds and
  serves the SPA at / and /telegram/ via go:embed (committed dist placeholder,
  real build baked in by the image's node stage)
- deploy/docker-compose.yml: backend + gateway + Postgres + Telegram connector
  (VPN sidecar) + OTel Collector + Prometheus (15d) + Tempo (72h) + Grafana,
  fronted by a caddy owning a single /_gm Basic-Auth (admin console + Grafana
  subpath); inter-service on a private network, only caddy on the edge network
- new metrics: backend accounts_created_total{kind} (robots excluded) and an
  in-memory gateway active_users{window=24h,7d} gauge
- CI: single .gitea/workflows/ci.yaml (unit/integration/ui + a gated test-contour
  deploy) on the new feature/* -> development -> master branch model; the old
  go-unit/integration/ui-test workflows are folded in; the connector-scoped
  compose is retired (superseded by deploy/)
- docs: ARCHITECTURE §11/§12/§13, root + gateway READMEs, CLAUDE.md branching,
  PLAN.md (stage 16 done + refinements + Stage 17 forward-notes)
2026-06-05 11:42:26 +02:00

72 lines
2.4 KiB
Go

// 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)
}