Files
Pulse/internal/api/frontend_embed.go
rcourtman fa3b0db243 Improve static asset caching for hashed files
Hashed static assets (e.g., index-BXHytNQV.js, index-TvhSzimt.css) are
now cached for 1 year with immutable flag since content hash changes
when files change.

Benefits:
- Faster page loads on subsequent visits
- Reduced server bandwidth
- Better user experience on demo and production instances

Only index.html and non-hashed assets remain uncached to ensure
users always get the latest version.
2025-11-06 13:54:26 +00:00

201 lines
5.5 KiB
Go

package api
import (
"embed"
"io"
"io/fs"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"sync"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog/log"
)
// Embed the entire frontend dist directory
//
//go:embed all:frontend-modern/dist
var embeddedFrontend embed.FS
var (
devProxyOnce sync.Once
devProxy *httputil.ReverseProxy
devProxyErr error
)
func getFrontendDevProxy() (*httputil.ReverseProxy, error) {
devProxyOnce.Do(func() {
devURL := utils.GetenvTrim("FRONTEND_DEV_SERVER")
if devURL == "" {
return
}
target, err := url.Parse(devURL)
if err != nil {
devProxyErr = err
return
}
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
log.Error().Err(err).Str("path", r.URL.Path).Msg("Frontend dev proxy error")
w.WriteHeader(http.StatusBadGateway)
}
devProxy = proxy
log.Warn().Str("frontend_dev_server", target.String()).Msg("Serving frontend via development proxy")
})
if devProxyErr != nil {
return nil, devProxyErr
}
return devProxy, nil
}
// getFrontendFS returns the embedded frontend filesystem
func getFrontendFS() (http.FileSystem, error) {
if dir := utils.GetenvTrim("PULSE_FRONTEND_DIR"); dir != "" {
log.Warn().Str("frontend_dir", dir).Msg("Serving frontend from filesystem override")
return http.Dir(dir), nil
}
// Strip the prefix to serve files from root
fsys, err := fs.Sub(embeddedFrontend, "frontend-modern/dist")
if err != nil {
return nil, err
}
return http.FS(fsys), nil
}
// serveFrontendHandler returns a handler for serving the embedded frontend
func serveFrontendHandler() http.HandlerFunc {
if proxy, err := getFrontendDevProxy(); err != nil {
log.Error().Err(err).Msg("Failed to initialize frontend dev proxy, falling back to embedded assets")
} else if proxy != nil {
return func(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r)
}
}
// Get the embedded filesystem
fsys, err := getFrontendFS()
if err != nil {
log.Fatal().Err(err).Msg("Failed to get embedded frontend")
}
return func(w http.ResponseWriter, r *http.Request) {
// Clean the path
p := r.URL.Path
// Handle root path specially to avoid FileServer's directory redirect
// Issue #334: Serve index.html directly without using FileServer for root
if p == "/" || p == "" {
// Directly serve index.html content
file, err := fsys.Open("index.html")
if err != nil {
http.NotFound(w, r)
return
}
defer file.Close()
// Check that it's not a directory
_, err = file.Stat()
if err != nil {
http.NotFound(w, r)
return
}
// Read the file content
content, err := io.ReadAll(file)
if err != nil {
http.NotFound(w, r)
return
}
// Serve the content with cache-busting headers
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.Write(content)
return
}
// Remove leading slash for filesystem lookup
lookupPath := strings.TrimPrefix(p, "/")
// Check if file exists in embedded FS
file, err := fsys.Open(lookupPath)
if err == nil {
defer file.Close()
// Get file info
stat, err := file.Stat()
if err == nil && !stat.IsDir() {
// Read and serve the file
content, err := io.ReadAll(file)
if err == nil {
// Detect content type
contentType := "application/octet-stream"
isImmutable := false
if strings.HasSuffix(lookupPath, ".html") {
contentType = "text/html; charset=utf-8"
} else if strings.HasSuffix(lookupPath, ".css") {
contentType = "text/css; charset=utf-8"
// CSS files with hashes are immutable (e.g., index-abc123.css)
isImmutable = strings.Contains(lookupPath, "-") && strings.Contains(lookupPath, ".css")
} else if strings.HasSuffix(lookupPath, ".js") {
contentType = "application/javascript; charset=utf-8"
// JS files with hashes are immutable (e.g., index-BXHytNQV.js)
isImmutable = strings.Contains(lookupPath, "-") && strings.Contains(lookupPath, ".js")
} else if strings.HasSuffix(lookupPath, ".json") {
contentType = "application/json"
} else if strings.HasSuffix(lookupPath, ".svg") {
contentType = "image/svg+xml"
}
w.Header().Set("Content-Type", contentType)
// Hashed assets are immutable - cache aggressively
// Non-hashed assets should not be cached
if isImmutable {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
}
w.Write(content)
return
}
}
}
// For SPA routing, serve index.html for non-API routes
if !strings.HasPrefix(p, "/api/") &&
!strings.HasPrefix(p, "/ws") &&
!strings.HasPrefix(p, "/socket.io/") {
// Serve index.html for client-side routing
indexFile, err := fsys.Open("index.html")
if err == nil {
defer indexFile.Close()
content, err := io.ReadAll(indexFile)
if err == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.Write(content)
return
}
}
}
// Not found
http.NotFound(w, r)
}
}