Serve built React app from web mode

This commit is contained in:
2026-05-25 02:49:31 -04:00
parent 0992fc03e8
commit 95060b07a5
2 changed files with 184 additions and 16 deletions

View File

@@ -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).

View File

@@ -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("<html>react app</html>")},
"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("<html>built react</html>"), 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) != "<html>built react</html>" {
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(), "<html") {
t.Fatalf("API response was served by SPA fallback: %q", rec.Body.String())
}
if strings.TrimSpace(rec.Body.String()) != "[]" {
t.Fatalf("body = %q, want []", rec.Body.String())
}
}