// 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 (
"bytes"
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
)
//go:embed all:dist
var distFS embed.FS
// Config holds the values injected into the SPA's index.html as
// window.__LETHE_CONFIG__. It must not contain any tokens (IV4).
type Config struct {
Issuer string `json:"issuer"`
ClientID string `json:"client_id"`
}
// DistFS returns the sub-filesystem rooted at "dist" within the embedded FS.
// Exported for tests that need to discover actual asset filenames.
func DistFS() (fs.FS, error) {
return fs.Sub(distFS, "dist")
}
// 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.
//
// On responses that serve index.html (root path and SPA fallback paths), the
// handler injects before the
// closing tag so the SPA can read OIDC config without hard-coding it.
// Paths beginning with /assets/ bypass injection and are served as raw bytes.
//
// 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(cfg Config) 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, cfg: cfg}
}
// 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. It also injects window.__LETHE_CONFIG__ into index.html
// responses.
type spaHandler struct {
fs fs.FS
fileServer http.Handler
cfg Config
}
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"
}
// Static assets bypass injection; serve directly via the file server.
if len(r.URL.Path) >= 8 && r.URL.Path[:8] == "/assets/" {
h.fileServer.ServeHTTP(w, r)
return
}
// 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
}
// The file exists — check if it is index.html.
if path == "index.html" {
h.serveIndex(w, r)
return
}
// Non-index file — let the standard file server handle it with correct
// Content-Type, ETag, and range support.
h.fileServer.ServeHTTP(w, r)
}
// serveIndex reads index.html from the embedded FS, injects the config script
// before , and writes the result with HTTP 200. It returns 500 (plain-
// text) if the embedded FS cannot open or read the file — this is a build
// invariant violation and must not be silently swallowed.
func (h *spaHandler) serveIndex(w http.ResponseWriter, _ *http.Request) {
f, err := h.fs.Open("index.html")
if err != nil {
http.Error(w, "internal server error: embedded index.html missing", http.StatusInternalServerError)
return
}
defer f.Close()
var buf bytes.Buffer
if _, err := buf.ReadFrom(f); err != nil {
http.Error(w, "internal server error: read embedded index.html", http.StatusInternalServerError)
return
}
cfgJSON, err := json.Marshal(h.cfg)
if err != nil {
// Config is a simple struct with string fields; Marshal must not fail.
http.Error(w, "internal server error: marshal config", http.StatusInternalServerError)
return
}
original := buf.Bytes()
script := []byte("")
injected := bytes.Replace(original, []byte(""), script, 1)
if bytes.Equal(injected, original) {
// is required for config injection. Failing loud beats serving
// uninjected HTML and letting the SPA render its "auth-config missing"
// card with no server-side diagnostic.
http.Error(w, "internal server error: index.html missing for config injection", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(injected)
}