From 1e97e4cee4daccb9af4018aacb5c1a13b4d7fb58 Mon Sep 17 00:00:00 2001 From: Drswith <49299002+Drswith@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:14:15 +0800 Subject: [PATCH] fix(web): set long-lived Cache-Control for embedded static assets Direct Go/Docker deployments were serving Vite-hashed /assets without Cache-Control, so browsers re-downloaded the console on every visit. Align with deploy/Caddyfile while keeping index.html on no-cache. Closes #4129 --- backend/internal/web/embed_on.go | 6 +- backend/internal/web/static_cache.go | 31 ++++++++++ backend/internal/web/static_cache_test.go | 71 +++++++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 backend/internal/web/static_cache.go create mode 100644 backend/internal/web/static_cache_test.go diff --git a/backend/internal/web/embed_on.go b/backend/internal/web/embed_on.go index 41738e7a5d..829d375100 100644 --- a/backend/internal/web/embed_on.go +++ b/backend/internal/web/embed_on.go @@ -109,7 +109,8 @@ func (s *FrontendServer) Middleware() gin.HandlerFunc { return } - // Serve static files normally + // Serve static files normally (hashed assets get long-lived cache headers) + applyStaticAssetCacheHeaders(c.Writer.Header(), cleanPath) s.fileServer.ServeHTTP(c.Writer, c.Request) c.Abort() } @@ -135,6 +136,7 @@ func (s *FrontendServer) tryServeOverride(c *gin.Context, cleanPath string) bool if err != nil || info.IsDir() { return false } + applyStaticAssetCacheHeaders(c.Writer.Header(), cleanPath) c.File(filePath) c.Abort() return true @@ -273,6 +275,7 @@ func ServeEmbeddedFrontend() gin.HandlerFunc { if tryServeOverrideFile(c, overrideDir, cleanPath) { return } + applyStaticAssetCacheHeaders(c.Writer.Header(), cleanPath) fileServer.ServeHTTP(c.Writer, c.Request) c.Abort() return @@ -292,6 +295,7 @@ func tryServeOverrideFile(c *gin.Context, overrideDir, cleanPath string) bool { if err != nil || info.IsDir() { return false } + applyStaticAssetCacheHeaders(c.Writer.Header(), cleanPath) c.File(filePath) c.Abort() return true diff --git a/backend/internal/web/static_cache.go b/backend/internal/web/static_cache.go new file mode 100644 index 0000000000..09abc8642a --- /dev/null +++ b/backend/internal/web/static_cache.go @@ -0,0 +1,31 @@ +//go:build embed || unit + +package web + +import ( + "net/http" + "strings" +) + +// staticAssetsCacheControl matches deploy/Caddyfile for hashed frontend assets. +// Vite emits content-hashed filenames under assets/, so long-lived immutable +// caching is safe without relying on a reverse proxy. +const staticAssetsCacheControl = "public, max-age=31536000, immutable" + +// isLongCacheStaticPath reports whether a cleaned URL path (no leading slash) +// should receive long-lived Cache-Control headers. Aligned with deploy/Caddyfile. +func isLongCacheStaticPath(cleanPath string) bool { + cleanPath = strings.TrimPrefix(cleanPath, "/") + return strings.HasPrefix(cleanPath, "assets/") || + cleanPath == "logo.png" || + cleanPath == "favicon.ico" +} + +// applyStaticAssetCacheHeaders sets Cache-Control for long-cacheable static paths. +// index.html / SPA routes must keep no-cache and are not handled here. +func applyStaticAssetCacheHeaders(header http.Header, cleanPath string) { + if header == nil || !isLongCacheStaticPath(cleanPath) { + return + } + header.Set("Cache-Control", staticAssetsCacheControl) +} diff --git a/backend/internal/web/static_cache_test.go b/backend/internal/web/static_cache_test.go new file mode 100644 index 0000000000..130347c41e --- /dev/null +++ b/backend/internal/web/static_cache_test.go @@ -0,0 +1,71 @@ +//go:build unit + +package web + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsLongCacheStaticPath(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + path string + want bool + }{ + {name: "hashed_js", path: "assets/index-abc123.js", want: true}, + {name: "hashed_css", path: "assets/app-def456.css", want: true}, + {name: "nested_asset", path: "assets/vendor/chunk.js", want: true}, + {name: "leading_slash_asset", path: "/assets/index.js", want: true}, + {name: "logo", path: "logo.png", want: true}, + {name: "favicon", path: "favicon.ico", want: true}, + {name: "index_html", path: "index.html", want: false}, + {name: "spa_route", path: "dashboard", want: false}, + {name: "assets_prefix_only", path: "assets", want: false}, + {name: "similar_name", path: "assets-backup/x.js", want: false}, + {name: "empty", path: "", want: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, isLongCacheStaticPath(tc.path)) + }) + } +} + +func TestApplyStaticAssetCacheHeaders(t *testing.T) { + t.Parallel() + + t.Run("sets_immutable_cache_for_assets", func(t *testing.T) { + t.Parallel() + header := make(http.Header) + applyStaticAssetCacheHeaders(header, "assets/index-abc.js") + assert.Equal(t, staticAssetsCacheControl, header.Get("Cache-Control")) + }) + + t.Run("sets_immutable_cache_for_logo", func(t *testing.T) { + t.Parallel() + header := make(http.Header) + applyStaticAssetCacheHeaders(header, "logo.png") + assert.Equal(t, staticAssetsCacheControl, header.Get("Cache-Control")) + }) + + t.Run("skips_index_html", func(t *testing.T) { + t.Parallel() + header := make(http.Header) + applyStaticAssetCacheHeaders(header, "index.html") + assert.Empty(t, header.Get("Cache-Control")) + }) + + t.Run("nil_header_is_noop", func(t *testing.T) { + t.Parallel() + assert.NotPanics(t, func() { + applyStaticAssetCacheHeaders(nil, "assets/x.js") + }) + }) +}