Files
Agent-System/internal/server/static_assets.go
T
weichenandCursor 9335c4c90c 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 <cursoragent@cursor.com>
2026-09-17 08:18:11 +00:00

283 lines
6.8 KiB
Go

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
}