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) + } + } +}