mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user