From 71cd03430a6715db6c2b7bf05840cfbc7e626ba8 Mon Sep 17 00:00:00 2001 From: wenjie Date: Fri, 6 Mar 2026 17:44:03 +0800 Subject: [PATCH] fix(web): add SPA index fallback for embedded frontend routes Serve existing static assets as-is, keep /api/* and missing asset paths returning 404, and add tests for SPA fallback behavior on refresh. --- web/backend/embed.go | 44 ++++++++++++++++++++++++++++++++-- web/backend/embed_test.go | 50 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 web/backend/embed_test.go diff --git a/web/backend/embed.go b/web/backend/embed.go index 23c635771..556fb7384 100644 --- a/web/backend/embed.go +++ b/web/backend/embed.go @@ -5,6 +5,8 @@ import ( "io/fs" "log" "net/http" + "path" + "strings" ) //go:embed all:dist @@ -24,6 +26,44 @@ func registerEmbedRoutes(mux *http.ServeMux) { return } - // Serve the static files at the root route - mux.Handle("/", http.FileServer(http.FS(subFS))) + fileServer := http.FileServer(http.FS(subFS)) + + // Serve static assets and fallback to index.html for SPA routes. + mux.Handle( + "/", + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.NotFound(w, r) + return + } + + // Keep unknown API paths as 404 instead of falling back to SPA entry. + if r.URL.Path == "/api" || strings.HasPrefix(r.URL.Path, "/api/") { + http.NotFound(w, r) + return + } + + cleanPath := path.Clean(strings.TrimPrefix(r.URL.Path, "/")) + if cleanPath == "." { + cleanPath = "" + } + + // Existing static files/directories should be served directly. + if cleanPath != "" { + if _, statErr := fs.Stat(subFS, cleanPath); statErr == nil { + fileServer.ServeHTTP(w, r) + return + } + // Missing asset-like paths should remain 404. + if strings.Contains(path.Base(cleanPath), ".") { + fileServer.ServeHTTP(w, r) + return + } + } + + indexReq := r.Clone(r.Context()) + indexReq.URL.Path = "/" + fileServer.ServeHTTP(w, indexReq) + }), + ) } diff --git a/web/backend/embed_test.go b/web/backend/embed_test.go new file mode 100644 index 000000000..27a3deaef --- /dev/null +++ b/web/backend/embed_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSPARouteFallsBackToIndex(t *testing.T) { + mux := http.NewServeMux() + registerEmbedRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/providers", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) + } + if !strings.Contains(rr.Body.String(), `
`) { + t.Fatalf("response does not look like index.html") + } +} + +func TestUnknownAPIPathStays404(t *testing.T) { + mux := http.NewServeMux() + registerEmbedRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/not-found", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotFound) + } +} + +func TestMissingAssetStays404(t *testing.T) { + mux := http.NewServeMux() + registerEmbedRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/assets/not-found.js", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotFound) + } +}