Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions cmd/capi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8382,18 +8382,65 @@ func (s *Server) serveStatic(c *gin.Context) bool {
cleanPath := strings.TrimPrefix(filepath.Clean("/"+requestPath), string(filepath.Separator))
target := filepath.Join(s.staticDir, cleanPath)
if info, err := os.Stat(target); err == nil && !info.IsDir() {
setStaticCacheHeaders(c, cleanPath)
c.File(target)
return true
}

indexPath := filepath.Join(s.staticDir, "index.html")
if info, err := os.Stat(indexPath); err == nil && !info.IsDir() {
setStaticCacheHeaders(c, "index.html")
c.File(indexPath)
return true
}
return false
}

// setStaticCacheHeaders keeps the SPA entry point revalidated while letting
// content-hashed build assets cache forever.
//
// index.html is served for every unmatched route and its filename carries no
// hash, so a cached copy pins the browser (and any intermediary such as
// Cloudflare) to whatever assets existed at cache time. Without an explicit
// directive that stale copy is only re-fetched by heuristic freshness, which is
// why a redeploy can appear to have no effect. Vite's hashed filenames are
// immutable by construction, so they are safe to cache aggressively.
func setStaticCacheHeaders(c *gin.Context, requestPath string) {
if isImmutableBuildAsset(requestPath) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
return
}
c.Header("Cache-Control", "no-cache, must-revalidate")
}

func isImmutableBuildAsset(requestPath string) bool {
base := filepath.Base(requestPath)
extension := filepath.Ext(base)
if extension == "" {
return false
}
name := strings.TrimSuffix(base, extension)
index := strings.LastIndex(name, "-")
if index < 0 {
return false
}
hash := name[index+1:]
if len(hash) < 8 {
return false
}
for _, char := range hash {
switch {
case char >= 'a' && char <= 'z':
case char >= 'A' && char <= 'Z':
case char >= '0' && char <= '9':
case char == '-' || char == '_':
default:
return false
}
}
return true
}

func containsString(values []string, needle string) bool {
for _, value := range values {
if value == needle {
Expand Down
74 changes: 74 additions & 0 deletions cmd/capi/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4844,3 +4844,77 @@ func TestOwnAPIKeyCanBeDeletedByOwner(t *testing.T) {
}
}


func TestStaticServingSetsCacheHeadersForSPAEntryPoint(t *testing.T) {
staticDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(staticDir, "assets"), 0755); err != nil {
t.Fatalf("create assets dir: %v", err)
}
writeFile := func(name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(staticDir, name), []byte(content), 0644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
writeFile("index.html", "<!doctype html><div id=\"root\"></div>")
writeFile(filepath.Join("assets", "index-CkIGdnVN.js"), "console.log(1)")
writeFile(filepath.Join("assets", "index-BvkROHFS.css"), "body{}")
writeFile("favicon.svg", "<svg/>")

withEnv(t, map[string]string{"PERSISTENCE": "memory", "STATIC_DIR": staticDir})
_, router := testServerRouter(t)

cacheControlFor := func(path string) string {
t.Helper()
response := perform(router, http.MethodGet, path, "", nil)
if response.Code != http.StatusOK {
t.Fatalf("GET %s status = %d body = %s", path, response.Code, response.Body.String())
}
return response.Header().Get("Cache-Control")
}

// The entry point must always revalidate, including for unmatched SPA routes.
for _, path := range []string{"/", "/users", "/some/deep/link"} {
if got := cacheControlFor(path); got != "no-cache, must-revalidate" {
t.Fatalf("index.html cache-control for %s = %q, want revalidation", path, got)
}
}

// Content-hashed build assets are immutable, so they cache for a year.
for _, path := range []string{"/assets/index-CkIGdnVN.js", "/assets/index-BvkROHFS.css"} {
if got := cacheControlFor(path); got != "public, max-age=31536000, immutable" {
t.Fatalf("hashed asset cache-control for %s = %q", path, got)
}
}

// A non-hashed file must not be treated as immutable.
if got := cacheControlFor("/favicon.svg"); got != "no-cache, must-revalidate" {
t.Fatalf("favicon cache-control = %q, want revalidation", got)
}
}

func TestIsImmutableBuildAsset(t *testing.T) {
immutable := []string{
"assets/index-CkIGdnVN.js",
"index-BvkROHFS.css",
"assets/chunk-AbCdEf12.js",
"assets/x-________.js",
}
for _, path := range immutable {
if !isImmutableBuildAsset(path) {
t.Fatalf("isImmutableBuildAsset(%q) = false, want true", path)
}
}
mutable := []string{
"index.html",
"favicon.svg",
"assets/short-abc.js",
"assets/name-with space.js",
"assets/noextension",
}
for _, path := range mutable {
if isImmutableBuildAsset(path) {
t.Fatalf("isImmutableBuildAsset(%q) = true, want false", path)
}
}
}
Loading