From 415f549dab85a31ad67dc11f6e68bdd9ddbd4ca5 Mon Sep 17 00:00:00 2001 From: Catie Date: Thu, 10 Sep 2026 17:05:02 +0800 Subject: [PATCH] fix: stop serving a stale SPA entry point index.html is returned for every unmatched route and carries no content hash, but the static handler set no Cache-Control at all. Browsers therefore applied heuristic freshness and intermediaries such as Cloudflare cached it by extension, pinning clients to whatever bundle existed at cache time. Redeploys appeared to do nothing, which is what made the new account-page UI (for example the API key delete button) keep showing an older build. - index.html and other unhashed files: no-cache, must-revalidate - Vite's content-hashed assets: public, max-age=31536000, immutable Co-Authored-By: Claude Code --- cmd/capi/main.go | 47 +++++++++++++++++++++++++++ cmd/capi/main_test.go | 74 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/cmd/capi/main.go b/cmd/capi/main.go index 642e10d..7aad4d7 100644 --- a/cmd/capi/main.go +++ b/cmd/capi/main.go @@ -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 { diff --git a/cmd/capi/main_test.go b/cmd/capi/main_test.go index f38595a..bb0d50c 100644 --- a/cmd/capi/main_test.go +++ b/cmd/capi/main_test.go @@ -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", "
") + writeFile(filepath.Join("assets", "index-CkIGdnVN.js"), "console.log(1)") + writeFile(filepath.Join("assets", "index-BvkROHFS.css"), "body{}") + writeFile("favicon.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) + } + } +}