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/*.
61 lines
1.5 KiB
Go
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/")
|
|
}
|