// 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 ClientID string } // 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 } script := []byte("") injected := bytes.Replace(buf.Bytes(), []byte(""), script, 1) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write(injected) }