// Package ui provides an embedded SPA file server for the Sparrow web frontend.
//
// The static build output from web/build/ is embedded at compile time.
// When SPARROW_SERVE_UI=true, the Go binary serves the frontend on the
// same port as the REST API. Chi router ensures API routes always
// take precedence; the UI handler is registered as the NotFound fallback.
//
// Build the frontend first:
//
// cd web && npm run build
// cd .. && go build ./cmd/server
package ui
import (
"crypto/sha256"
"embed"
"encoding/base64"
"io/fs"
"log/slog"
"net/http"
"regexp"
"strings"
)
//go:embed all:dist
var embeddedFS embed.FS
// Handler returns an http.Handler that serves the embedded SPA.
// It serves static files from the embedded filesystem, and falls back to
// index.html for any path that doesn't match a static file (SPA client-side routing).
//
// Pages are served exactly as built: no credential is ever written into them.
// The UI signs in with the API key, an access token, or an invite link.
func Handler(logger *slog.Logger) http.Handler {
// Strip the "dist" prefix from the embedded FS so files are served from root.
staticFS, err := fs.Sub(embeddedFS, "dist")
if err != nil {
// This should never happen since "dist" is embedded at compile time.
panic("ui: failed to create sub filesystem: " + err.Error())
}
return newHandler(logger, staticFS)
}
// newHandler serves the SPA from staticFS (the build output root).
func newHandler(logger *slog.Logger, staticFS fs.FS) http.Handler {
fileServer := http.FileServer(http.FS(staticFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Try to serve the requested file directly.
path := strings.TrimPrefix(r.URL.Path, "/")
// Check if the file exists in the embedded FS.
if path != "" {
if f, err := staticFS.Open(path); err == nil {
_ = f.Close()
// File exists — serve it with proper caching.
// Immutable assets (hashed filenames) get long cache.
if strings.HasPrefix(path, "_app/immutable/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
fileServer.ServeHTTP(w, r)
return
}
}
// File doesn't exist — serve index.html for SPA client-side routing.
indexBytes, err := fs.ReadFile(staticFS, "index.html")
if err != nil {
logger.ErrorContext(r.Context(), "ui: index.html not found in embedded filesystem — was the frontend built?")
http.Error(w, "UI not available. Build the frontend with: cd web && npm run build", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(indexBytes)
})
}
// scriptRe matches all blocks including empty-body
// ones (external scripts with src="..."). The opening-tag portion is captured
// separately so we can check for a src attribute without re-parsing.
var scriptRe = regexp.MustCompile(`(?s)(`)
var srcAttrRe = regexp.MustCompile(`\bsrc\s*=`)
// InlineScriptHashes reads index.html from the embedded build output,
// extracts the bodies of all inline