// Package web embeds the compiled SPA assets (produced by `just web-build`)
// into the Go binary via go:embed so the server binary is self-contained.
// At build time `dist/` must be non-empty; the committed placeholder
// index.html satisfies this on a fresh clone. The HTTP handler applies an
// SPA fallback shim: any path that does not match a real file in the
// embedded tree is rewritten to index.html so the client-side router can
// handle it. Real static assets (hashed JS/CSS chunks) are served normally
// with their correct Content-Type; the fallback only fires on 404s.
package web
import (
"embed"
"fmt"
"io/fs"
"net/http"
)
//go:embed all:dist
var distFS embed.FS
// Handler returns an http.Handler that serves the embedded SPA. Paths that
// exist in the embedded tree are served directly. Paths that do not exist
// fall back to index.html with HTTP 200 so the client-side router handles
// them. If index.html itself cannot be opened from the embedded FS, the
// handler returns 500 with a plain-text error.
//
// Routes beginning with /api/, /healthz, /readyz, or /metrics are NOT
// this handler's concern; they must be mounted before this handler in the
// router so they shadow the catch-all.
func Handler() http.Handler {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
// This should never happen for a hard-coded path; blow up loudly.
panic(fmt.Sprintf("web: fs.Sub on embedded dist: %v", err))
}
fileServer := http.FileServer(http.FS(sub))
return &spaHandler{fs: sub, fileServer: fileServer}
}
// spaHandler wraps a standard file server with a SPA fallback: if the
// requested path does not exist in the embedded FS, it serves index.html
// instead of a 404.
type spaHandler struct {
fs fs.FS
fileServer http.Handler
}
func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Strip the leading "/" to obtain the relative path within the FS.
path := r.URL.Path
if len(path) > 0 && path[0] == '/' {
path = path[1:]
}
if path == "" {
path = "index.html"
}
// Try to open the file to see if it exists.
f, err := h.fs.Open(path)
if err != nil {
// File not found — apply SPA fallback: serve index.html at status 200.
h.serveIndex(w, r)
return
}
// Stat to confirm it's a regular file (not a directory that would
// trigger a redirect to path+"/").
info, statErr := f.Stat()
_ = f.Close()
if statErr != nil || info.IsDir() {
h.serveIndex(w, r)
return
}
// File exists — let the standard file server handle it with correct
// Content-Type, ETag, and range support.
h.fileServer.ServeHTTP(w, r)
}
// serveIndex serves the embedded index.html with HTTP 200. It returns 500
// (plain-text) if the embedded FS cannot open the file — this is a build
// invariant violation and must not be silently swallowed.
func (h *spaHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
// Rewrite the URL to "/" so the file server finds index.html and sets
// the correct Content-Type header.
r2 := r.Clone(r.Context())
r2.URL.Path = "/"
// Verify index.html is present before delegating; a missing file
// produces a 500 rather than a misleading file-server 404.
if _, err := h.fs.Open("index.html"); err != nil {
http.Error(w, "internal server error: embedded index.html missing", http.StatusInternalServerError)
return
}
h.fileServer.ServeHTTP(w, r2)
}