feat: embed React frontend in Go binary with SPA fallback

This commit is contained in:
2026-05-21 21:39:07 +00:00
parent 74d6130dd5
commit 6b6b66ab89
3 changed files with 87 additions and 0 deletions
+4
View File
@@ -13,6 +13,10 @@ dist
dist-ssr dist-ssr
*.local *.local
# Frontend build output (embedded at Go build time)
# Allow the fallback placeholder so embed always has at least index.html
!src/dist/index.html
# Environment files # Environment files
.env .env
.env.local .env.local
+41
View File
@@ -3,7 +3,9 @@ package main
import ( import (
"context" "context"
"embed"
"fmt" "fmt"
"io/fs"
"log" "log"
"net/http" "net/http"
"os" "os"
@@ -22,6 +24,9 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
//go:embed all:src/dist
var frontendFS embed.FS
// Config holds the application configuration. // Config holds the application configuration.
type Config struct { type Config struct {
DBPath string `yaml:"db_path"` DBPath string `yaml:"db_path"`
@@ -84,6 +89,9 @@ func main() {
// API routes (auth required if API key is configured) // API routes (auth required if API key is configured)
r.Mount("/api/v1", auth.Middleware(cfg.APIKey)(apiRouter(sseHub, sqlDB))) r.Mount("/api/v1", auth.Middleware(cfg.APIKey)(apiRouter(sseHub, sqlDB)))
// Serve embedded React frontend with SPA fallback
r.Mount("/", frontendHandler())
// Create server // Create server
httpServer := &http.Server{ httpServer := &http.Server{
Addr: ":" + cfg.Port, Addr: ":" + cfg.Port,
@@ -159,3 +167,36 @@ func loadConfig(path string) (*Config, error) {
return &cfg, nil return &cfg, nil
} }
// frontendHandler returns an http.Handler that serves the embedded React
// frontend from src/dist/ with SPA-style fallback: any path that doesn't
// match a static file serves index.html for client-side routing.
//
// The frontend is embedded via //go:embed all:src/dist at build time.
// If src/dist/ is empty or missing at build time, the embedded fallback
// index.html (committed to the repo) is served instead, showing a
// "run npm run build" message.
func frontendHandler() http.Handler {
distFS, err := fs.Sub(frontendFS, "src/dist")
if err != nil {
// Shouldn't happen if embed worked, but be defensive.
panic("embedded frontend filesystem not found: " + err.Error())
}
fileServer := http.FileServer(http.FS(distFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Try to serve the requested file.
f, err := distFS.Open(r.URL.Path[1:]) // strip leading "/"
if err != nil {
// File not found — serve index.html for SPA routing.
r.URL.Path = "/"
fileServer.ServeHTTP(w, r)
return
}
f.Close()
// File exists, serve it.
fileServer.ServeHTTP(w, r)
})
}
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RemoteRig - Frontend Not Built</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
color: #333;
}
.message {
text-align: center;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 { color: #e74c3c; margin-bottom: 0.5rem; }
code {
background: #eee;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 1.1rem;
}
</style>
</head>
<body>
<div class="message">
<h1>Frontend Not Built</h1>
<p>The React frontend has not been built yet.</p>
<p>Run <code>npm run build</code> from the project root, then rebuild the Go binary.</p>
<p><small>API is still available at <code>/api/v1/</code> and health at <code>/health</code></small></p>
</div>
</body>
</html>