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.
This commit is contained in:
tiennm99 committed 2026-09-05 12:55:09 +07:00
1 parent 64106735ec
commit e5b03d041d
2 files changed
+60

No files matched your search

+48
View File
@@ -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("<!doctype html>"), 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)
}
})
}
}
+12
View File
@@ -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)
})
}