Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
178 lines
4.8 KiB
Go
178 lines
4.8 KiB
Go
// Package webui serves the embedded single-page app. The build copies web/dist
|
|
// into ./dist (make web); the committed placeholder keeps `go build` working
|
|
// on machines without node.
|
|
package webui
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"crypto/sha256"
|
|
"embed"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io/fs"
|
|
"mime"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
)
|
|
|
|
// dist is the build copied from web/dist (git-ignored, so a build never dirties the tree);
|
|
// placeholder is committed and used when dist holds no index.html.
|
|
//
|
|
//go:embed all:dist
|
|
var dist embed.FS
|
|
|
|
//go:embed placeholder
|
|
var placeholder embed.FS
|
|
|
|
// Dist returns the embedded UI build, or a one-line "UI not built" page when
|
|
// the binary was built without `make web`.
|
|
func Dist() fs.FS {
|
|
if _, err := fs.Stat(dist, "dist/"+indexName); err == nil {
|
|
return mustSub(dist, "dist")
|
|
}
|
|
return mustSub(placeholder, "placeholder")
|
|
}
|
|
|
|
func mustSub(f fs.FS, dir string) fs.FS {
|
|
sub, err := fs.Sub(f, dir)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("webui: embedded %s missing: %v", dir, err)) // cannot happen: go:embed guarantees the directory
|
|
}
|
|
return sub
|
|
}
|
|
|
|
// reserved are the path prefixes that belong to the API handler, never the SPA.
|
|
var reserved = []string{"/api/", "/docs", "/openapi", "/metrics", "/schemas", "/auth/", "/healthz"}
|
|
|
|
const (
|
|
indexName = "index.html"
|
|
cacheImmutable = "public, max-age=31536000, immutable"
|
|
minGzipBytes = 512
|
|
)
|
|
|
|
// asset is one embedded file, with its gzip form precomputed so serving is a write.
|
|
type asset struct {
|
|
body, gz []byte
|
|
etag string
|
|
ctype string
|
|
}
|
|
|
|
type server struct {
|
|
assets map[string]*asset // keyed by slash-rooted path, "/index.html"
|
|
next http.Handler
|
|
}
|
|
|
|
// Handler serves the files of fsys at "/" and sends everything else to next.
|
|
// Unknown GET and HEAD paths outside the API prefixes fall back to index.html
|
|
// so client-side routes survive a reload. Hashed files under /assets/ are
|
|
// immutable; index.html is always revalidated.
|
|
func Handler(fsys fs.FS, next http.Handler) (http.Handler, error) {
|
|
s := &server{assets: map[string]*asset{}, next: next}
|
|
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return err
|
|
}
|
|
body, err := fs.ReadFile(fsys, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.assets["/"+p] = newAsset(p, body)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("webui: read dist: %w", err)
|
|
}
|
|
if s.assets["/"+indexName] == nil {
|
|
return nil, fmt.Errorf("webui: no %s in the embedded build", indexName)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func newAsset(name string, body []byte) *asset {
|
|
sum := sha256.Sum256(body)
|
|
ct := mime.TypeByExtension(path.Ext(name))
|
|
if ct == "" {
|
|
ct = http.DetectContentType(body)
|
|
}
|
|
a := &asset{body: body, etag: `"` + hex.EncodeToString(sum[:8]) + `"`, ctype: ct}
|
|
if len(body) >= minGzipBytes && compressible(ct) {
|
|
var buf bytes.Buffer
|
|
zw := gzip.NewWriter(&buf)
|
|
// Writes to a bytes.Buffer cannot fail.
|
|
_, _ = zw.Write(body)
|
|
_ = zw.Close()
|
|
if buf.Len() < len(body) {
|
|
a.gz = buf.Bytes()
|
|
}
|
|
}
|
|
return a
|
|
}
|
|
|
|
func compressible(ct string) bool {
|
|
return strings.HasPrefix(ct, "text/") || strings.Contains(ct, "javascript") || strings.Contains(ct, "json") ||
|
|
strings.Contains(ct, "svg") || strings.Contains(ct, "xml")
|
|
}
|
|
|
|
func isReserved(p string) bool {
|
|
for _, r := range reserved {
|
|
if strings.HasPrefix(p, r) || p == strings.TrimSuffix(r, "/") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if isReserved(r.URL.Path) || (r.Method != http.MethodGet && r.Method != http.MethodHead) {
|
|
s.next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
name := path.Clean("/" + r.URL.Path)
|
|
a := s.assets[name]
|
|
if a == nil || name == "/" {
|
|
if path.Ext(name) != "" && strings.HasPrefix(name, "/assets/") {
|
|
http.NotFound(w, r) // a missing hashed file must not come back as HTML
|
|
return
|
|
}
|
|
name, a = "/"+indexName, s.assets["/"+indexName]
|
|
}
|
|
s.write(w, r, name, a)
|
|
}
|
|
|
|
func (s *server) write(w http.ResponseWriter, r *http.Request, name string, a *asset) {
|
|
h := w.Header()
|
|
h.Set("Content-Type", a.ctype)
|
|
h.Set("ETag", a.etag)
|
|
h.Add("Vary", "Accept-Encoding")
|
|
if strings.HasPrefix(name, "/assets/") {
|
|
h.Set("Cache-Control", cacheImmutable)
|
|
} else {
|
|
h.Set("Cache-Control", "no-cache")
|
|
}
|
|
if r.Header.Get("If-None-Match") == a.etag {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
body := a.body
|
|
if a.gz != nil && acceptsGzip(r) {
|
|
h.Set("Content-Encoding", "gzip")
|
|
body = a.gz
|
|
}
|
|
h.Set("Content-Length", fmt.Sprint(len(body)))
|
|
w.WriteHeader(http.StatusOK)
|
|
if r.Method != http.MethodHead {
|
|
_, _ = w.Write(body) // a client that hung up has nobody to tell
|
|
}
|
|
}
|
|
|
|
func acceptsGzip(r *http.Request) bool {
|
|
for _, part := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
|
|
if strings.TrimSpace(strings.SplitN(part, ";", 2)[0]) == "gzip" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|