Restructure Web App into server/ + web/ (GsmNode parity)
Reorganize the Web App to match the GsmNode project layout: a Go backend-for-frontend in server/ that embeds the built SPA and reverse-proxies /api/* to the API Server, with the Vue 3 + Vite frontend moved into web/. - Move all frontend files into web/ (history preserved via renames) - Point vite build output at ../server/dist for Go embedding - Add server/ Go BFF (main.go, go.mod, .env.example, Run-WebApp.ps1) - Drop Docker/nginx deploy (Dockerfile, docker-compose.yml, nginx.conf.template, .dockerignore) in favor of the BFF, matching GsmNode - Update .claude/launch.json to run the dev server from web/ - Rewrite README.md for the new layout Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ba3f227361
commit
75a2ccc226
@@ -0,0 +1,8 @@
|
||||
# Web App (BFF) configuration
|
||||
|
||||
# Address the Web App listens on
|
||||
WEB_ADDR=:8090
|
||||
|
||||
# Base URL of the API Server. The BFF reverse-proxies /api/* to this host, so
|
||||
# the browser only ever talks same-origin to the Web App.
|
||||
API_BASE=http://localhost:8080
|
||||
@@ -0,0 +1,5 @@
|
||||
# Build artifacts (generated)
|
||||
dist/
|
||||
*.exe
|
||||
.env
|
||||
*.log
|
||||
@@ -0,0 +1,29 @@
|
||||
# Builds the Vue frontend (if needed) and runs the Web App BFF.
|
||||
#
|
||||
# ./Run-WebApp.ps1 # build frontend then serve
|
||||
# ./Run-WebApp.ps1 -SkipBuild # serve existing dist only
|
||||
|
||||
param([switch]$SkipBuild)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$serverDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$root = Split-Path -Parent $serverDir # the "Web App" folder
|
||||
$webDir = Join-Path $root "web"
|
||||
|
||||
$goBin = "C:\Program Files\Go\bin"
|
||||
if (Test-Path $goBin) { $env:Path = "$goBin;$env:Path" }
|
||||
|
||||
if (-not $SkipBuild) {
|
||||
Write-Host "Building frontend..." -ForegroundColor Cyan
|
||||
Push-Location $webDir
|
||||
try {
|
||||
if (-not (Test-Path "node_modules")) { npm install }
|
||||
npm run build
|
||||
} finally { Pop-Location }
|
||||
}
|
||||
|
||||
Push-Location $serverDir
|
||||
try {
|
||||
Write-Host "Starting Web App on :8090 (Ctrl+C to stop)..." -ForegroundColor Cyan
|
||||
go run .
|
||||
} finally { Pop-Location }
|
||||
@@ -0,0 +1,3 @@
|
||||
module drivervault/webapp
|
||||
|
||||
go 1.26
|
||||
@@ -0,0 +1,113 @@
|
||||
// Command webapp is the Web App backend-for-frontend. It serves the embedded
|
||||
// Vue single-page app and reverse-proxies /api/* to the API Server, so the
|
||||
// browser only ever talks to this server (same-origin) and all data access
|
||||
// still flows through the API Server.
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
|
||||
func getenv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
||||
log.SetPrefix("[web] ")
|
||||
|
||||
loadDotEnv(".env")
|
||||
addr := getenv("WEB_ADDR", ":8090")
|
||||
apiBase := strings.TrimRight(getenv("API_BASE", "http://localhost:8080"), "/")
|
||||
|
||||
apiURL, err := url.Parse(apiBase)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid API_BASE %q: %v", apiBase, err)
|
||||
}
|
||||
|
||||
// Reverse proxy: /api/* -> API Server (path preserved).
|
||||
proxy := httputil.NewSingleHostReverseProxy(apiURL)
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) {
|
||||
log.Printf("proxy error for %s: %v", r.URL.Path, e)
|
||||
http.Error(w, `{"error":"api server unavailable"}`, http.StatusBadGateway)
|
||||
}
|
||||
|
||||
// Embedded SPA file server.
|
||||
sub, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
log.Fatalf("embed dist: %v", err)
|
||||
}
|
||||
spa := http.FileServer(http.FS(sub))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/api/", proxy)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Serve static assets when they exist; otherwise fall back to index.html
|
||||
// so client-side routing works on deep links.
|
||||
if r.URL.Path != "/" {
|
||||
if f, err := sub.Open(strings.TrimPrefix(r.URL.Path, "/")); err == nil {
|
||||
f.Close()
|
||||
spa.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.Path = "/"
|
||||
spa.ServeHTTP(w, r2)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: logRequests(mux),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
log.Printf("listening on %s (proxying /api -> %s)", addr, apiBase)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func logRequests(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
|
||||
// loadDotEnv loads KEY=VALUE pairs from a .env file if present.
|
||||
func loadDotEnv(path string) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k = strings.TrimSpace(k)
|
||||
v = strings.Trim(strings.TrimSpace(v), `"'`)
|
||||
if _, exists := os.LookupEnv(k); !exists {
|
||||
_ = os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user