Vellum/internal/httpapi/static.go
mithral 5283e073f5 Web UI: Svelte app, embedded into the binary
The frontend the app needs to be usable at all.

- web/: Svelte 5 + Vite + TypeScript SPA. Login, app shell with per-type nav,
  library list/create/scan/delete, item grid with covers, admin user
  management, and an epub.js reader that syncs progress.
- Reader resumes by spine index — an integer — which is why the polymorphic
  progress table needs no text column.
- Session state uses classic stores in a plain .ts module rather than runes in
  a .svelte.ts module, avoiding special module resolution.
- Embedding: web/embed.go embeds web/dist. A committed .gitkeep keeps the
  directive valid before the frontend is built, so `go build ./...` never
  fails. Unknown non-API paths serve index.html for client routes; /api/* and
  /healthz always return JSON and never fall back to HTML.
- Dockerfile builds the UI in a node stage and overlays dist into the Go stage.
- pnpm 12 needs allowBuilds in pnpm-workspace.yaml; esbuild approved, the two
  unused transitive scripts explicitly denied so install exits 0.

svelte-check: 0 errors, 0 warnings. Verified served: SPA shell at /, route
fallback, hashed asset, and JSON 404 on /api/*.
2026-10-09 16:40:58 -04:00

61 lines
1.5 KiB
Go

package httpapi
import (
"io/fs"
"net/http"
"path"
"strings"
"github.com/mithral/vellum/web"
)
// mountWeb serves the embedded single-page app at the root path.
//
// Unknown non-API paths fall back to index.html so client-side routes work on
// reload. API paths never fall back: a typo in an endpoint returns JSON, not
// an HTML page.
func (s *Server) mountWeb() {
sub, err := fs.Sub(web.Dist, "dist")
if err != nil {
s.mux.HandleFunc("/", s.handleNotFound)
return
}
index, err := fs.ReadFile(sub, "index.html")
if err != nil {
// Frontend not built into this binary. Keep JSON 404s rather than
// pretending the app exists.
s.log.Warn("web UI not embedded; run the frontend build to include it")
s.mux.HandleFunc("/", s.handleNotFound)
return
}
files := http.FileServer(http.FS(sub))
s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if isAPIPath(r.URL.Path) {
s.handleNotFound(w, r)
return
}
if r.URL.Path == "/" || r.URL.Path == "" {
serveIndex(w, index)
return
}
name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if f, err := sub.Open(name); err == nil {
_ = f.Close()
files.ServeHTTP(w, r)
return
}
// SPA route: hand back the shell and let the client router decide.
serveIndex(w, index)
})
}
func serveIndex(w http.ResponseWriter, index []byte) {
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 {
return p == "/healthz" || strings.HasPrefix(p, "/api/")
}