diff --git a/changes/45682-cache-control-static-assets b/changes/45682-cache-control-static-assets new file mode 100644 index 0000000000..600e928297 --- /dev/null +++ b/changes/45682-cache-control-static-assets @@ -0,0 +1 @@ +* Added a long-lived immutable `Cache-Control` header to content-hashed static assets under `/assets/` so browsers and CDNs can cache them across loads instead of refetching the JS/CSS bundle from origin every time. diff --git a/server/service/frontend.go b/server/service/frontend.go index 4aa91f8887..717992f14b 100644 --- a/server/service/frontend.go +++ b/server/service/frontend.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/http" "net/url" + "regexp" assetfs "github.com/elazarl/go-bindata-assetfs" shared_mdm "github.com/fleetdm/fleet/v4/pkg/mdm" @@ -273,10 +274,16 @@ func initiateOTAEnrollSSO(svc fleet.Service, w http.ResponseWriter, r *http.Requ return nil } +// hashedAssetRe matches build-output filenames that embed a content hash, e.g. +// "bundle-3ccf015bc0fac64b4ce8.js" or "logo@1a2b3c4d.png". A content change +// produces a new hash and therefore a new URL, so these are safe to cache +// forever. Unhashed names (dev builds like "bundle.js") must keep revalidating. +var hashedAssetRe = regexp.MustCompile(`[-@][0-9a-f]{8,}\.[a-z0-9]+$`) + func ServeStaticAssets(path string, serveCSP bool) http.Handler { contentTypes := []string{"text/javascript", "text/css"} staticAssetsServer := endpointer.BrowserSecurityHeadersHandler(serveCSP, http.FileServer(newBinaryFileSystem("/assets"))) - withoutGzip := http.StripPrefix(path, staticAssetsServer) + withoutGzip := http.StripPrefix(path, assetCacheControl(staticAssetsServer)) withOpts, err := gzhttp.NewWrapper(gzhttp.ContentTypes(contentTypes)) if err != nil { // fall back to serving without gzip if serving with gzip somehow fails @@ -285,3 +292,41 @@ func ServeStaticAssets(path string, serveCSP bool) http.Handler { return withOpts(withoutGzip) } + +func assetCacheControl(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(&cacheControlResponseWriter{ResponseWriter: w, path: r.URL.Path}, r) + }) +} + +type cacheControlResponseWriter struct { + http.ResponseWriter + path string + wroteHeader bool +} + +// WriteHeader decides Cache-Control from the final status so the long-lived +// immutable cache is applied only to successful responses for hashed assets. +// Caching a transient 404/500 would otherwise pin a broken asset at the browser/CDN. +func (w *cacheControlResponseWriter) WriteHeader(status int) { + if !w.wroteHeader { + w.wroteHeader = true + if (status == http.StatusOK || status == http.StatusNotModified) && hashedAssetRe.MatchString(w.path) { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "no-cache") + } + } + w.ResponseWriter.WriteHeader(status) +} + +func (w *cacheControlResponseWriter) Write(b []byte) (int, error) { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + return w.ResponseWriter.Write(b) +} + +func (w *cacheControlResponseWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} diff --git a/server/service/frontend_test.go b/server/service/frontend_test.go index f66624eec4..4fe045fd3d 100644 --- a/server/service/frontend_test.go +++ b/server/service/frontend_test.go @@ -49,6 +49,48 @@ func TestServeFrontend(t *testing.T) { require.Equal(t, http.StatusMethodNotAllowed, response.StatusCode) } +func TestAssetCacheControl(t *testing.T) { + for _, tc := range []struct { + path string + status int + want string + }{ + // Content-hashed build output (JS/CSS, fonts, images) is safe to cache + // forever — webpack emits everything as [name]@[hash][ext]. + {"/bundle-3ccf015bc0fac64b4ce8.js", http.StatusOK, "public, max-age=31536000, immutable"}, + {"/bundle-1e51316ac7963e1112c1.css", http.StatusOK, "public, max-age=31536000, immutable"}, + {"/Inter-Bold@1a2b3c4d5e6f7890.woff2", http.StatusOK, "public, max-age=31536000, immutable"}, + {"/404-dark@1a2b3c4d5e6f7890.svg", http.StatusOK, "public, max-age=31536000, immutable"}, + {"/jira-preview-400x419@2x@1a2b3c4d5e6f7890.png", http.StatusOK, "public, max-age=31536000, immutable"}, + // A 304 keeps the immutable header (the cached copy is still valid). + {"/bundle-3ccf015bc0fac64b4ce8.js", http.StatusNotModified, "public, max-age=31536000, immutable"}, + // A missing/errored hashed asset (deploy race) must NOT be cached for a + // year, or a transient failure would pin a broken asset at the CDN/browser. + {"/bundle-deadbeefdeadbeef.js", http.StatusNotFound, "no-cache"}, + {"/Inter-Bold@deadbeef12345678.woff2", http.StatusInternalServerError, "no-cache"}, + // Unhashed (dev builds, favicon, static scripts) must keep revalidating. + {"/bundle.js", http.StatusOK, "no-cache"}, + {"/bundle.css", http.StatusOK, "no-cache"}, + {"/favicon.ico", http.StatusOK, "no-cache"}, + // status 0 = handler writes a body without calling WriteHeader, exercising + // the implicit-200 path in cacheControlResponseWriter.Write. + {"/bundle-3ccf015bc0fac64b4ce8.js", 0, "public, max-age=31536000, immutable"}, + } { + t.Run(fmt.Sprintf("%s_%d", tc.path, tc.status), func(t *testing.T) { + handler := assetCacheControl(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tc.status == 0 { + _, _ = w.Write([]byte("body")) + return + } + w.WriteHeader(tc.status) + })) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tc.path, nil)) + require.Equal(t, tc.want, rec.Header().Get("Cache-Control")) + }) + } +} + func TestServeEndUserEnrollOTA(t *testing.T) { if !hasBuildTag("full") { t.Skip("This test requires running with -tags full")