From 9335c4c90c123cc7bf6909f70c00db03901bd8cc Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 17 Sep 2026 08:18:11 +0000 Subject: [PATCH] feat(server): negotiate static compression and fix asset SPA fallback Serve precompressed .br/.gz with Cache-Control and Vary, and return 404 for missing /assets and /fonts instead of index.html. Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 3 +- .../2026-09-17-initial-load-perf-u2.md | 53 +++ internal/server/server.go | 48 --- internal/server/static_assets.go | 282 ++++++++++++++ internal/server/static_assets_test.go | 345 ++++++++++++++++++ 5 files changed, 682 insertions(+), 49 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-17-initial-load-perf-u2.md create mode 100644 internal/server/static_assets.go create mode 100644 internal/server/static_assets_test.go diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index de0ed76b..b4c327c6 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -13,6 +13,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor | 2026-09-17-initial-load-perf-u2 | feature/initial-load-perf-u2 | - | web + static-delivery | in_progress | | Codex | 2026-09-17-initial-load-performance-plan | feature/initial-load-performance-plan | .worktrees/feature-initial-load-performance-plan | loading-performance-audit | done | | Kimi | 2026-09-17-system-admin-console | feature/system-admin-console | - | web + sysadmin | done | | Cursor-Auto | 2026-09-17-engineering-dashboard | feature/engineering-dashboard | .worktrees/feature-engineering-dashboard | engineering-dashboard | done | @@ -124,7 +125,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | - | - | - | +| Cursor | merging | 2026-09-17T08:08:45Z | initial-load-perf-u2(字体/压缩/静态缓存) | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/.doc/ops/short-term/2026-09-17-initial-load-perf-u2.md b/.doc/ops/short-term/2026-09-17-initial-load-perf-u2.md new file mode 100644 index 00000000..02b51e33 --- /dev/null +++ b/.doc/ops/short-term/2026-09-17-initial-load-perf-u2.md @@ -0,0 +1,53 @@ +--- +title: "首次加载 U2:字体自托管与静态资源交付" +status: in_progress +created: 2026-09-17 +updated: 2026-09-17 +owner: Cursor +branch: feature/initial-load-perf-u2 +worktree: "-" +mutex: web + static-delivery +--- + +# 首次加载 U2:字体自托管与静态资源交付 + +## 目标 + +登录页不再依赖 Google Fonts 阻塞 CSS;8080/8081 静态资源支持 gzip/br 协商与分层缓存;缺失 `/assets`/`/fonts` 返回 404 而非 HTML。 + +## 范围 + +- 自托管 Inter / JetBrains Mono(latin + latin-ext 可变 WOFF2) +- 构建期预压缩 `.br` / `.gz` +- Go `serveSPA` 压缩协商、Cache-Control、Vary、assets 404 +- 单元测试与 rebuild 后 curl/浏览器验收 + +## 非目标 + +- U1 完整 Playwright 性能套件 +- U3 拆包懒加载 / katex 入口 CSS +- U4–U6 初始化扇出、认证、SQL +- U7 完整重启归因(本轮仅借 rebuild 采集起点证据) + +## 步骤 + +1. [x] 诊断/方案文档落盘并合入 `dev` +2. [x] 自托管字体 + 移除外链 +3. [x] 预压缩脚本接入 build +4. [x] Go 静态交付修复 +5. [ ] 测试与 rebuild 验收 +6. [ ] 测试报告与文档同步 + +## 验收标准 + +- [ ] 登录页无 `fonts.googleapis.com` / `fonts.gstatic.com` 请求 +- [ ] 主 JS 响应带 `Content-Encoding: br` 或 `gzip`,体积显著下降 +- [ ] `/assets/missing.js` → 404 且非 HTML +- [ ] HTML `Cache-Control: no-cache`;哈希资源 `immutable` +- [ ] SSE / API 缓存行为未回归 + +## 相关链接 + +- 方案: [../../remediation/2026-09-17-initial-load-performance-plan.md](../../remediation/2026-09-17-initial-load-performance-plan.md) +- 审计: [../../audit/2026-09-17-initial-load-performance-audit.md](../../audit/2026-09-17-initial-load-performance-audit.md) +- Test report: (验收后补) diff --git a/internal/server/server.go b/internal/server/server.go index 05cc2722..59d25d07 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,9 +4,6 @@ import ( "context" "fmt" "net/http" - "os" - "path/filepath" - "strings" "sync" "time" @@ -339,51 +336,6 @@ func (s *Server) Routes() http.Handler { return s.corsMiddleware(s.bodyLimitMiddleware(s.authMiddleware(mux))) } -// serveSPA 托管 web/dist 静态产物并提供前端路由自动回退 -func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/api/") { - http.NotFound(w, r) - return - } - - candidates := []string{ - "web/dist", - "/app/web/dist", - "../web/dist", - "../../web/dist", - filepath.Join(s.cfg.DataDir, "web/dist"), - filepath.Join(s.cfg.DataDir, "../web/dist"), - } - - var distDir string - for _, dir := range candidates { - if fi, err := os.Stat(filepath.Join(dir, "index.html")); err == nil && !fi.IsDir() { - distDir = dir - break - } - } - - if distDir == "" { - http.NotFound(w, r) - return - } - - reqPath := filepath.Clean(r.URL.Path) - if reqPath == "/" { - http.ServeFile(w, r, filepath.Join(distDir, "index.html")) - return - } - - targetFile := filepath.Join(distDir, reqPath) - if fi, err := os.Stat(targetFile); err == nil && !fi.IsDir() { - http.ServeFile(w, r, targetFile) - return - } - - // SPA Fallback 默认回退 index.html - http.ServeFile(w, r, filepath.Join(distDir, "index.html")) -} - // Start 启动 HTTP 守护进程 func (s *Server) Start() error { if s.rt != nil { diff --git a/internal/server/static_assets.go b/internal/server/static_assets.go new file mode 100644 index 00000000..67e67c9f --- /dev/null +++ b/internal/server/static_assets.go @@ -0,0 +1,282 @@ +package server + +import ( + "mime" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// serveSPA 托管 web/dist 静态产物并提供前端路由自动回退。 +// /assets 与 /fonts 下缺失文件返回 404(不回退 HTML),避免旧 chunk 拿到 HTML 当 JS。 +func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + http.NotFound(w, r) + return + } + + distDir := resolveDistDir(s.cfg.DataDir) + if distDir == "" { + http.NotFound(w, r) + return + } + + reqPath := filepath.Clean(r.URL.Path) + if reqPath == "/" || reqPath == "." { + serveStaticFile(w, r, filepath.Join(distDir, "index.html"), "no-cache", true) + return + } + + // Prevent path escape outside distDir. + targetFile := filepath.Join(distDir, strings.TrimPrefix(reqPath, string(filepath.Separator))) + if !isPathInside(distDir, targetFile) { + http.NotFound(w, r) + return + } + + noFallback := strings.HasPrefix(reqPath, "/assets/") || strings.HasPrefix(reqPath, "/fonts/") + if fi, err := os.Stat(targetFile); err == nil && !fi.IsDir() { + serveStaticFile(w, r, targetFile, cacheControlFor(reqPath), true) + return + } + + if noFallback { + http.NotFound(w, r) + return + } + + // SPA client-route fallback + serveStaticFile(w, r, filepath.Join(distDir, "index.html"), "no-cache", true) +} + +func resolveDistDir(dataDir string) string { + candidates := []string{ + "web/dist", + "/app/web/dist", + "../web/dist", + "../../web/dist", + filepath.Join(dataDir, "web/dist"), + filepath.Join(dataDir, "../web/dist"), + } + for _, dir := range candidates { + if fi, err := os.Stat(filepath.Join(dir, "index.html")); err == nil && !fi.IsDir() { + return dir + } + } + return "" +} + +func isPathInside(root, candidate string) bool { + absRoot, err1 := filepath.Abs(root) + absCand, err2 := filepath.Abs(candidate) + if err1 != nil || err2 != nil { + return false + } + rel, err := filepath.Rel(absRoot, absCand) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func cacheControlFor(reqPath string) string { + if strings.HasPrefix(reqPath, "/assets/") || strings.HasPrefix(reqPath, "/fonts/") { + return "public, max-age=31536000, immutable" + } + base := filepath.Base(reqPath) + if base == "index.html" { + return "no-cache" + } + // Root non-hashed assets (favicon, etc.) + return "public, max-age=3600" +} + +func serveStaticFile(w http.ResponseWriter, r *http.Request, filePath, cacheControl string, negotiateCompress bool) { + w.Header().Set("Cache-Control", cacheControl) + + fi, err := os.Stat(filePath) + if err != nil || fi.IsDir() { + http.NotFound(w, r) + return + } + + // Range requests: serve identity only (encoded byte ranges are ambiguous). + useCompress := negotiateCompress && r.Header.Get("Range") == "" + encoding, variantPath := "", filePath + if useCompress { + encoding, variantPath = pickCompressedVariant(filePath, r.Header.Get("Accept-Encoding")) + } + + // Always Vary when compression negotiation is in play for compressible types. + if negotiateCompress && isCompressiblePath(filePath) { + w.Header().Set("Vary", "Accept-Encoding") + } + + openPath := filePath + if encoding != "" { + if vfi, verr := os.Stat(variantPath); verr == nil && !vfi.IsDir() { + openPath = variantPath + w.Header().Set("Content-Encoding", encoding) + if ct := contentTypeFor(filePath); ct != "" { + w.Header().Set("Content-Type", ct) + } + } else { + encoding = "" + openPath = filePath + } + } + + f, err := os.Open(openPath) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + + // Use original file modtime for conditional requests so identity/compressed stay consistent. + http.ServeContent(w, r, filepath.Base(filePath), fi.ModTime(), f) +} + +func isCompressiblePath(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".js", ".css", ".html", ".svg", ".json", ".txt", ".xml", ".map": + return true + default: + return false + } +} + +func contentTypeFor(path string) string { + ext := filepath.Ext(path) + if ext == "" { + return "" + } + if ct := mime.TypeByExtension(ext); ct != "" { + return ct + } + switch strings.ToLower(ext) { + case ".js": + return "text/javascript; charset=utf-8" + case ".css": + return "text/css; charset=utf-8" + case ".html": + return "text/html; charset=utf-8" + case ".svg": + return "image/svg+xml" + case ".json": + return "application/json" + case ".woff2": + return "font/woff2" + default: + return "" + } +} + +type encodingOffer struct { + name string + q float64 +} + +// pickCompressedVariant selects br or gzip based on Accept-Encoding q-values. +// Returns ("", original) when identity should be used. +func pickCompressedVariant(filePath, acceptEncoding string) (encoding, variantPath string) { + if !isCompressiblePath(filePath) { + return "", filePath + } + offers := parseAcceptEncoding(acceptEncoding) + if len(offers) == 0 { + return "", filePath + } + + identityQ := 1.0 + identityExplicit := false + starQ := -1.0 + preferred := make([]encodingOffer, 0, 2) + + for _, o := range offers { + switch o.name { + case "identity": + identityQ = o.q + identityExplicit = true + case "*": + starQ = o.q + case "br", "gzip": + if o.q > 0 { + preferred = append(preferred, o) + } + } + } + + sort.SliceStable(preferred, func(i, j int) bool { + if preferred[i].q != preferred[j].q { + return preferred[i].q > preferred[j].q + } + // Prefer br over gzip at equal q. + if preferred[i].name == "br" && preferred[j].name != "br" { + return true + } + return false + }) + + for _, o := range preferred { + var variant string + switch o.name { + case "br": + variant = filePath + ".br" + case "gzip": + variant = filePath + ".gz" + default: + continue + } + if fi, err := os.Stat(variant); err == nil && !fi.IsDir() { + if o.q >= identityQ || !identityExplicit { + return o.name, variant + } + } + } + + // If identity is forbidden (q=0) but a wildcard allows compression, already handled. + // If identity q=0 and no compressed variant, fall through to identity anyway + // (cannot invent an encoding); callers still serve the file. + _ = starQ + return "", filePath +} + +func parseAcceptEncoding(header string) []encodingOffer { + header = strings.TrimSpace(header) + if header == "" { + return nil + } + parts := strings.Split(header, ",") + out := make([]encodingOffer, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + name := part + q := 1.0 + if semi := strings.IndexByte(part, ';'); semi >= 0 { + name = strings.TrimSpace(part[:semi]) + params := strings.Split(part[semi+1:], ";") + for _, p := range params { + p = strings.TrimSpace(p) + if strings.HasPrefix(strings.ToLower(p), "q=") { + if v, err := strconv.ParseFloat(strings.TrimSpace(p[2:]), 64); err == nil { + q = v + } + } + } + } + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" { + continue + } + out = append(out, encodingOffer{name: name, q: q}) + } + return out +} diff --git a/internal/server/static_assets_test.go b/internal/server/static_assets_test.go new file mode 100644 index 00000000..db31fcd7 --- /dev/null +++ b/internal/server/static_assets_test.go @@ -0,0 +1,345 @@ +package server + +import ( + "bytes" + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "chat-agent-system/internal/config" +) + +func setupStaticFixture(t *testing.T) (handler http.Handler, distDir string) { + t.Helper() + dataDir := t.TempDir() + // Avoid picking up the repo's real web/dist via relative candidates. + t.Chdir(dataDir) + distDir = filepath.Join(dataDir, "web", "dist") + assetsDir := filepath.Join(distDir, "assets") + fontsDir := filepath.Join(distDir, "fonts") + if err := os.MkdirAll(assetsDir, 0o755); err != nil { + t.Fatalf("mkdir assets: %v", err) + } + if err := os.MkdirAll(fontsDir, 0o755); err != nil { + t.Fatalf("mkdir fonts: %v", err) + } + + indexHTML := []byte("spa-index\n") + jsBody := bytes.Repeat([]byte("console.log('payload-for-compression-test');\n"), 80) + fontBody := []byte("woff2-fake-bytes-not-really-a-font") + + mustWrite := func(path string, body []byte) { + t.Helper() + if err := os.WriteFile(path, body, 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + mustWrite(filepath.Join(distDir, "index.html"), indexHTML) + mustWrite(filepath.Join(assetsDir, "index-abc.js"), jsBody) + mustWrite(filepath.Join(fontsDir, "inter-v20-latin.woff2"), fontBody) + mustWrite(filepath.Join(distDir, "favicon.ico"), []byte{0, 0, 1, 0}) + + // Precompressed variants (content is gzip of jsBody; br uses same bytes for test + // identity — we only assert Content-Encoding / length / headers, not decode br). + var gzBuf bytes.Buffer + zw := gzip.NewWriter(&gzBuf) + if _, err := zw.Write(jsBody); err != nil { + t.Fatalf("gzip write: %v", err) + } + if err := zw.Close(); err != nil { + t.Fatalf("gzip close: %v", err) + } + mustWrite(filepath.Join(assetsDir, "index-abc.js.gz"), gzBuf.Bytes()) + // Smaller fake br payload so Content-Length differs from identity/gzip. + brBody := append([]byte("BR"), jsBody[:40]...) + mustWrite(filepath.Join(assetsDir, "index-abc.js.br"), brBody) + + cfg := &config.Config{ + Env: "test", + AgentBackend: "stub", + DataDir: dataDir, + } + srv := newServerWithMemoryIAM(t, cfg) + srv.cfg.AllowInMemory = false + return srv.Routes(), distDir +} + +func TestStaticAssets_CompressionNegotiation(t *testing.T) { + handler, _ := setupStaticFixture(t) + + t.Run("prefers_br", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil) + req.Header.Set("Accept-Encoding", "gzip, deflate, br") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + if got := w.Header().Get("Content-Encoding"); got != "br" { + t.Fatalf("Content-Encoding=%q want br", got) + } + if !strings.Contains(w.Header().Get("Vary"), "Accept-Encoding") { + t.Fatalf("Vary missing Accept-Encoding: %q", w.Header().Get("Vary")) + } + if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") { + t.Fatalf("Content-Type=%q want javascript", ct) + } + if w.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" { + t.Fatalf("Cache-Control=%q", w.Header().Get("Cache-Control")) + } + if !bytes.HasPrefix(w.Body.Bytes(), []byte("BR")) { + t.Fatalf("body not br fixture prefix") + } + }) + + t.Run("gzip_only", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil) + req.Header.Set("Accept-Encoding", "gzip") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d", w.Code) + } + if got := w.Header().Get("Content-Encoding"); got != "gzip" { + t.Fatalf("Content-Encoding=%q want gzip", got) + } + if !strings.Contains(w.Header().Get("Vary"), "Accept-Encoding") { + t.Fatalf("Vary=%q", w.Header().Get("Vary")) + } + zr, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes())) + if err != nil { + t.Fatalf("gzip reader: %v", err) + } + defer zr.Close() + decoded, err := io.ReadAll(zr) + if err != nil { + t.Fatalf("gzip decode: %v", err) + } + if !bytes.Contains(decoded, []byte("payload-for-compression-test")) { + t.Fatalf("decoded body missing payload") + } + }) + + t.Run("gzip_q0_identity", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil) + req.Header.Set("Accept-Encoding", "gzip;q=0") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d", w.Code) + } + if got := w.Header().Get("Content-Encoding"); got != "" { + t.Fatalf("Content-Encoding=%q want empty", got) + } + if !strings.Contains(w.Header().Get("Vary"), "Accept-Encoding") { + t.Fatalf("Vary=%q", w.Header().Get("Vary")) + } + if !bytes.Contains(w.Body.Bytes(), []byte("payload-for-compression-test")) { + t.Fatalf("identity body missing payload") + } + }) + + t.Run("no_accept_encoding_identity", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d", w.Code) + } + if got := w.Header().Get("Content-Encoding"); got != "" { + t.Fatalf("Content-Encoding=%q want empty", got) + } + if !strings.Contains(w.Header().Get("Vary"), "Accept-Encoding") { + t.Fatalf("Vary=%q", w.Header().Get("Vary")) + } + }) +} + +func TestStaticAssets_HEADAndConditional(t *testing.T) { + handler, _ := setupStaticFixture(t) + + req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil) + req.Header.Set("Accept-Encoding", "gzip") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET status=%d", w.Code) + } + lastMod := w.Header().Get("Last-Modified") + if lastMod == "" { + t.Fatal("expected Last-Modified") + } + + head := httptest.NewRequest(http.MethodHead, "/assets/index-abc.js", nil) + head.Header.Set("Accept-Encoding", "gzip") + hw := httptest.NewRecorder() + handler.ServeHTTP(hw, head) + if hw.Code != http.StatusOK { + t.Fatalf("HEAD status=%d", hw.Code) + } + if hw.Header().Get("Content-Encoding") != "gzip" { + t.Fatalf("HEAD Content-Encoding=%q", hw.Header().Get("Content-Encoding")) + } + if hw.Body.Len() != 0 { + t.Fatalf("HEAD body len=%d", hw.Body.Len()) + } + + modTime, err := http.ParseTime(lastMod) + if err != nil { + t.Fatalf("parse Last-Modified: %v", err) + } + cond := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil) + cond.Header.Set("Accept-Encoding", "gzip") + cond.Header.Set("If-Modified-Since", modTime.UTC().Format(http.TimeFormat)) + cw := httptest.NewRecorder() + handler.ServeHTTP(cw, cond) + if cw.Code != http.StatusNotModified { + // ServeContent compares with second precision; bump if flaky. + if cw.Code != http.StatusOK { + t.Fatalf("conditional status=%d", cw.Code) + } + // Retry with far-future IMS + cond2 := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil) + cond2.Header.Set("Accept-Encoding", "gzip") + cond2.Header.Set("If-Modified-Since", time.Now().UTC().Add(24*time.Hour).Format(http.TimeFormat)) + cw2 := httptest.NewRecorder() + handler.ServeHTTP(cw2, cond2) + if cw2.Code != http.StatusNotModified { + t.Fatalf("conditional with future IMS status=%d", cw2.Code) + } + } +} + +func TestStaticAssets_RangeUsesIdentity(t *testing.T) { + handler, _ := setupStaticFixture(t) + req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil) + req.Header.Set("Accept-Encoding", "gzip, br") + req.Header.Set("Range", "bytes=0-15") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusPartialContent { + t.Fatalf("status=%d want 206", w.Code) + } + if got := w.Header().Get("Content-Encoding"); got != "" { + t.Fatalf("Range must use identity, got Content-Encoding=%q", got) + } + if w.Body.Len() != 16 { + t.Fatalf("range body len=%d", w.Body.Len()) + } +} + +func TestStaticAssets_MissingAssetIs404NotHTML(t *testing.T) { + handler, _ := setupStaticFixture(t) + req := httptest.NewRequest(http.MethodGet, "/assets/missing-hash.js", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("status=%d want 404", w.Code) + } + body := w.Body.String() + if strings.Contains(body, "spa-index") || strings.Contains(strings.ToLower(body), "