From e5b03d041d9389570e2939830436984c821bbf9f Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 5 Sep 2026 12:55:09 +0700 Subject: [PATCH] fix(wsapi): set cache headers on the served frontend Assets under _app/immutable carry a content hash in the name, so a changed file is a changed URL and the old one can be cached forever. The shell cannot: it names those hashed assets, so a copy cached across a deploy points at files that no longer exist and the app loads into a blank page with nothing in the log. --- server/internal/wsapi/regression_test.go | 48 ++++++++++++++++++++++++ server/internal/wsapi/server.go | 12 ++++++ 2 files changed, 60 insertions(+) diff --git a/server/internal/wsapi/regression_test.go b/server/internal/wsapi/regression_test.go index 88e6881..e5ab0c3 100644 --- a/server/internal/wsapi/regression_test.go +++ b/server/internal/wsapi/regression_test.go @@ -2,6 +2,9 @@ package wsapi import ( "context" + "net/http" + "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -376,3 +379,48 @@ func TestRoomCodesAreUnbiased(t *testing.T) { } } } + +// TestStaticCacheHeaders pins the two cache policies the single-page bundle +// depends on. The failure they prevent is silent and total: an index.html +// cached across a deploy names hashed assets that no longer exist, so the app +// loads into a blank page with nothing in the log. +func TestStaticCacheHeaders(t *testing.T) { + dir := t.TempDir() + assets := filepath.Join(dir, "_app", "immutable", "chunks") + if err := os.MkdirAll(assets, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(assets, "abc123.js"), []byte("export{}"), 0o644); err != nil { + t.Fatal(err) + } + + srv := NewServer(context.Background(), newTestDict("ngữ pháp", "pháp luật"), Config{WebDir: dir}) + defer srv.Shutdown() + + cases := []struct { + name string + path string + want string + }{ + {"hashed asset", "/_app/immutable/chunks/abc123.js", "public, max-age=31536000, immutable"}, + {"the shell", "/", "no-cache"}, + {"a deep link falling back to the shell", "/play?difficulty=2", "no-cache"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tc.path, nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := rec.Header().Get("Cache-Control"); got != tc.want { + t.Errorf("Cache-Control = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/server/internal/wsapi/server.go b/server/internal/wsapi/server.go index 0affa75..e68bff4 100644 --- a/server/internal/wsapi/server.go +++ b/server/internal/wsapi/server.go @@ -87,6 +87,9 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { s.hub.expireToken(sess.resumeToken, s.cfg.GraceFor) } +// immutablePrefix is where SvelteKit's adapter puts content-hashed assets. +const immutablePrefix = "/_app/immutable/" + // mountStatic serves the built frontend so one binary is the whole deployment. // // Unknown paths fall back to index.html because the frontend is a single-page @@ -116,9 +119,18 @@ func (s *Server) mountStatic() { return } if info, err := os.Stat(clean); err == nil && !info.IsDir() { + // Everything under immutablePrefix carries a content hash in its + // name, so a changed file is a changed URL and the old one can be + // cached forever. + if strings.HasPrefix(r.URL.Path, immutablePrefix) { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } files.ServeHTTP(w, r) return } + // The shell names those hashed assets, so a cached copy outlives the + // deploy that renamed them and the app loads into a blank page. + w.Header().Set("Cache-Control", "no-cache") http.ServeFile(w, r, index) }) }