mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-07 13:56:37 +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
|
// serveSPA serves the React build artifacts and falls back to index.html for
|
||||||
// non-API, non-asset paths so client-side routing keeps working.
|
// non-API, non-asset paths so client-side routing keeps working.
|
||||||
func serveSPA(r *gin.Engine, webDir string) {
|
func serveSPA(r *gin.Engine, webDir string) {
|
||||||
r.Static("/assets", filepath.Join(webDir, "assets"))
|
assets := r.Group("/assets")
|
||||||
r.StaticFile("/favicon.ico", filepath.Join(webDir, "favicon.ico"))
|
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) {
|
r.NoRoute(func(c *gin.Context) {
|
||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
// Do not swallow API / Emby compatibility routes; clients expect JSON
|
// 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)
|
c.Status(http.StatusNotFound)
|
||||||
return
|
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 {
|
func shouldBypassSPAFallback(path string) bool {
|
||||||
lower := strings.ToLower(path)
|
lower := strings.ToLower(path)
|
||||||
for _, prefix := range []string{
|
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