mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
fix(server): prevent stale SPA white screen after updates
This commit is contained in:
+38
-3
@@ -149,8 +149,17 @@ func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container)
|
||||
// serveSPA serves the React build artifacts and falls back to index.html for
|
||||
// non-API, non-asset paths so client-side routing keeps working.
|
||||
func serveSPA(r *gin.Engine, webDir string) {
|
||||
r.Static("/assets", filepath.Join(webDir, "assets"))
|
||||
r.StaticFile("/favicon.ico", filepath.Join(webDir, "favicon.ico"))
|
||||
assets := r.Group("/assets")
|
||||
assets.Use(func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
c.Next()
|
||||
})
|
||||
assets.Static("/", filepath.Join(webDir, "assets"))
|
||||
for _, icon := range []string{"/favicon.ico", "/favicon.svg"} {
|
||||
iconPath := filepath.Join(webDir, strings.TrimPrefix(icon, "/"))
|
||||
r.GET(icon, serveNoCacheFile(iconPath))
|
||||
r.HEAD(icon, serveNoCacheFile(iconPath))
|
||||
}
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
// Do not swallow API / Emby compatibility routes; clients expect JSON
|
||||
@@ -159,10 +168,36 @@ func serveSPA(r *gin.Engine, webDir string) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.File(filepath.Join(webDir, "index.html"))
|
||||
serveSPAIndex(c, filepath.Join(webDir, "index.html"))
|
||||
})
|
||||
}
|
||||
|
||||
func serveNoCacheFile(filePath string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
setNoCacheHeaders(c)
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.File(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
func serveSPAIndex(c *gin.Context, indexPath string) {
|
||||
setNoCacheHeaders(c)
|
||||
if _, err := os.Stat(indexPath); err != nil {
|
||||
c.String(http.StatusNotFound, "MediaStationGo web UI not found: %s", indexPath)
|
||||
return
|
||||
}
|
||||
c.File(indexPath)
|
||||
}
|
||||
|
||||
func setNoCacheHeaders(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.Header("Pragma", "no-cache")
|
||||
c.Header("Expires", "0")
|
||||
}
|
||||
|
||||
func shouldBypassSPAFallback(path string) bool {
|
||||
lower := strings.ToLower(path)
|
||||
for _, prefix := range []string{
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestServeSPANoCachesIndexAndServesRoutes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
webDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(webDir, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "index.html"), []byte("<html><div id=\"root\"></div></html>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "favicon.svg"), []byte("<svg></svg>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
serveSPA(router, webDir)
|
||||
|
||||
for _, path := range []string{"/", "/login", "/media/abc"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d, want 200", path, w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
|
||||
t.Fatalf("%s Cache-Control = %q, want no-store", path, got)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "root") {
|
||||
t.Fatalf("%s did not serve index.html: %q", path, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
webDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(webDir, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "index.html"), []byte("index"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
serveSPA(router, webDir)
|
||||
|
||||
assetReq := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)
|
||||
assetResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(assetResp, assetReq)
|
||||
if assetResp.Code != http.StatusOK {
|
||||
t.Fatalf("asset status = %d, want 200", assetResp.Code)
|
||||
}
|
||||
if got := assetResp.Header().Get("Cache-Control"); !strings.Contains(got, "immutable") {
|
||||
t.Fatalf("asset Cache-Control = %q, want immutable", got)
|
||||
}
|
||||
|
||||
apiReq := httptest.NewRequest(http.MethodGet, "/api/missing", nil)
|
||||
apiResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(apiResp, apiReq)
|
||||
if apiResp.Code != http.StatusNotFound {
|
||||
t.Fatalf("api fallback status = %d, want 404", apiResp.Code)
|
||||
}
|
||||
if strings.Contains(apiResp.Body.String(), "index") {
|
||||
t.Fatalf("api route should not serve SPA index: %q", apiResp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeSPAMissingIndexReportsExplicit404(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
serveSPA(router, t.TempDir())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "web UI not found") {
|
||||
t.Fatalf("body = %q, want explicit missing UI message", w.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user