// 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