mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Serve built React app from web mode
This commit is contained in:
@@ -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).
|
||||
|
||||
123
internal/web/server_static_test.go
Normal file
123
internal/web/server_static_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user