mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
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.
201 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|