diff --git a/internal/web/server.go b/internal/web/server.go index 7283b90..994fff3 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "os" + "path/filepath" "strings" "github.com/AmanTahiliani/box-box/internal/api" @@ -41,6 +42,21 @@ func NewServer(client *api.OpenF1Client, port int, st *store.Store) *Server { // Start registers routes, launches background goroutines, and begins serving. func (s *Server) Start() error { + handler, err := s.routes() + if err != nil { + return err + } + + // Start background goroutines. + go s.hub.run() + if os.Getenv("BOXBOX_DISABLE_LIVE") != "1" { + go s.runLiveFeeds() + } + + return http.ListenAndServe(s.addr, withCORS(withLogging(handler))) +} + +func (s *Server) routes() (http.Handler, error) { mux := http.NewServeMux() // REST API — /api/v1/laps/comparison must be registered before /api/v1/laps @@ -68,34 +84,63 @@ func (s *Server) Start() error { mux.HandleFunc("/api/v1/live/stream", s.handleSSEStream) // Static files + SPA catchall - subFS, err := fs.Sub(assetsFS, "assets") + staticFS, err := selectStaticFS() if err != nil { - return err + return nil, err } - fileServer := http.FileServer(http.FS(subFS)) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + mux.Handle("/", spaFileServer(staticFS)) + + return mux, nil +} + +func selectStaticFS() (fs.FS, error) { + if dist, ok := findFrontendDist("."); ok { + return os.DirFS(dist), nil + } + return fs.Sub(assetsFS, "assets") +} + +func findFrontendDist(start string) (string, bool) { + dir, err := filepath.Abs(start) + if err != nil { + return "", false + } + + for { + dist := filepath.Join(dir, "frontend", "dist") + index := filepath.Join(dist, "index.html") + if info, err := os.Stat(index); err == nil && !info.IsDir() { + return dist, true + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", false + } + dir = parent + } +} + +func spaFileServer(staticFS fs.FS) http.Handler { + fileServer := http.FileServer(http.FS(staticFS)) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // If the path maps to a real asset file, serve it directly. if r.URL.Path != "/" { p := strings.TrimPrefix(r.URL.Path, "/") - if f, err := subFS.Open(p); err == nil { - f.Close() - fileServer.ServeHTTP(w, r) - return + if fs.ValidPath(p) { + if info, err := fs.Stat(staticFS, p); err == nil && !info.IsDir() { + fileServer.ServeHTTP(w, r) + return + } } } + // SPA catchall: all unknown paths serve index.html. r2 := *r r2.URL.Path = "/" fileServer.ServeHTTP(w, &r2) }) - - // Start background goroutines. - go s.hub.run() - if os.Getenv("BOXBOX_DISABLE_LIVE") != "1" { - go s.runLiveFeeds() - } - - return http.ListenAndServe(s.addr, withCORS(withLogging(mux))) } // withCORS adds permissive CORS headers (localhost use only). diff --git a/internal/web/server_static_test.go b/internal/web/server_static_test.go new file mode 100644 index 0000000..21dd12e --- /dev/null +++ b/internal/web/server_static_test.go @@ -0,0 +1,123 @@ +package web + +import ( + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" +) + +func TestSPAFileServerServesAssetsAndRoutesToIndex(t *testing.T) { + staticFS := fstest.MapFS{ + "index.html": {Data: []byte("react app")}, + "assets/app-123.js": {Data: []byte("console.log('react')")}, + "assets/app-123.css": {Data: []byte("body{}")}, + "nested/real-page.js": {Data: []byte("export {}")}, + } + handler := spaFileServer(staticFS) + + tests := []struct { + name string + path string + want string + }{ + {name: "root", path: "/", want: "react app"}, + {name: "race hub route", path: "/race-hub", want: "react app"}, + {name: "data library route", path: "/data-library", want: "react app"}, + {name: "real asset", path: "/assets/app-123.js", want: "console.log('react')"}, + {name: "nested real asset", path: "/nested/real-page.js", want: "export {}"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), tt.want) { + t.Fatalf("body = %q, want substring %q", rec.Body.String(), tt.want) + } + }) + } +} + +func TestSelectStaticFSPrefersFrontendDist(t *testing.T) { + workspace := t.TempDir() + dist := filepath.Join(workspace, "frontend", "dist") + if err := os.MkdirAll(filepath.Join(dist, "assets"), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte("built react"), 0o644); err != nil { + t.Fatalf("WriteFile(index.html) error = %v", err) + } + if err := os.WriteFile(filepath.Join(dist, "assets", "bundle.js"), []byte("built bundle"), 0o644); err != nil { + t.Fatalf("WriteFile(bundle.js) error = %v", err) + } + + child := filepath.Join(workspace, "internal", "web") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("MkdirAll(child) error = %v", err) + } + t.Chdir(child) + + staticFS, err := selectStaticFS() + if err != nil { + t.Fatalf("selectStaticFS() error = %v", err) + } + + body, err := fs.ReadFile(staticFS, "index.html") + if err != nil { + t.Fatalf("ReadFile(index.html) error = %v", err) + } + if string(body) != "built react" { + t.Fatalf("index.html = %q, want built React index", string(body)) + } +} + +func TestSelectStaticFSFallsBackToEmbeddedAssets(t *testing.T) { + t.Chdir(t.TempDir()) + + staticFS, err := selectStaticFS() + if err != nil { + t.Fatalf("selectStaticFS() error = %v", err) + } + + body, err := fs.ReadFile(staticFS, "index.html") + if err != nil { + t.Fatalf("ReadFile(index.html) error = %v", err) + } + if !strings.Contains(string(body), "box-box") { + t.Fatalf("embedded index.html = %q, want legacy box-box asset", string(body)) + } +} + +func TestRoutesPreserveAPIPrecedenceOverSPA(t *testing.T) { + t.Chdir(t.TempDir()) + srv := testServer(t, nil) + handler, err := srv.routes() + if err != nil { + t.Fatalf("routes() error = %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/seasons", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if strings.Contains(rec.Body.String(), "