From 3684488d75ac5a75469fb90a9ab16bcc05f6bf1a Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Thu, 11 Jun 2026 14:02:38 +0200 Subject: [PATCH] serve the embedded frontend alongside the API, slice 1 of M4 //go:embed needs internal/webdist/dist to exist at compile time, so a one-line placeholder index.html is committed there and the real build (Docker, or a manual copy of web/dist) overwrites it. One mux serves /, /api, and a /healthz for the compose healthcheck to come. --- .gitignore | 5 +++++ cmd/spreadlab/main.go | 21 +++++++++++++++----- internal/webdist/dist/index.html | 4 ++++ internal/webdist/webdist.go | 33 ++++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 internal/webdist/dist/index.html create mode 100644 internal/webdist/webdist.go diff --git a/.gitignore b/.gitignore index ff6fc96..4d4680f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ # prettier cache (npm run format in web/ walks up here) /node_modules/ + +# real frontend builds copied over the committed placeholder (index.html +# stays tracked; `git restore internal/webdist/dist` after local testing) +/internal/webdist/dist/* +!/internal/webdist/dist/index.html diff --git a/cmd/spreadlab/main.go b/cmd/spreadlab/main.go index 13429cd..11890f5 100644 --- a/cmd/spreadlab/main.go +++ b/cmd/spreadlab/main.go @@ -1,6 +1,7 @@ -// Command spreadlab serves the spreadlab API (and, from milestone 4, the -// dashboard itself). The -table flag instead prints the three-scenario -// comparison and exits, a quick engine sanity check. +// Command spreadlab serves the dashboard and its API from one binary: +// the embedded frontend on /, the API under /api, and a trivial +// /healthz for container healthchecks. The -table flag instead prints +// the three-scenario comparison and exits, a quick engine sanity check. package main import ( @@ -14,6 +15,7 @@ import ( "github.com/JustinZeus/spreadlab/internal/api" "github.com/JustinZeus/spreadlab/internal/engine" + "github.com/JustinZeus/spreadlab/internal/webdist" ) func main() { @@ -29,8 +31,17 @@ func main() { return } - log.Printf("spreadlab API listening on http://%s", *addr) - if err := http.ListenAndServe(*addr, api.NewServer()); err != nil { + // One mux, one origin: the longer /api/ pattern wins over / for API + // calls, everything else falls through to the embedded frontend. + mux := http.NewServeMux() + mux.Handle("/api/", api.NewServer()) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + mux.Handle("/", webdist.Handler()) + + log.Printf("spreadlab listening on http://%s", *addr) + if err := http.ListenAndServe(*addr, mux); err != nil { log.Fatal(err) } } diff --git a/internal/webdist/dist/index.html b/internal/webdist/dist/index.html new file mode 100644 index 0000000..c3e14da --- /dev/null +++ b/internal/webdist/dist/index.html @@ -0,0 +1,4 @@ + + +spreadlab +

Placeholder page: this binary was built without the frontend. Run npm run build in web/ and copy web/dist over internal/webdist/dist, or use the Docker image.

diff --git a/internal/webdist/webdist.go b/internal/webdist/webdist.go new file mode 100644 index 0000000..b2e85ab --- /dev/null +++ b/internal/webdist/webdist.go @@ -0,0 +1,33 @@ +// Package webdist embeds the built frontend so one binary serves both +// the dashboard and the API. `//go:embed` needs its directory to exist +// at compile time, but the real build output (web/dist) is gitignored, +// so this package commits a one-line placeholder dist/index.html that +// keeps `go build` and `go test ./...` working everywhere. The +// Dockerfile overwrites the placeholder with the real `npm run build` +// output before compiling; to try that locally, copy web/dist over +// internal/webdist/dist and `git restore` it afterwards. +package webdist + +import ( + "embed" + "io/fs" + "net/http" +) + +// The all: prefix embeds files whose names start with . or _ too, which +// plain go:embed skips; vite output can contain such assets. +// +//go:embed all:dist +var embeddedFiles embed.FS + +// Handler serves the embedded frontend. The dashboard is a single page +// without client-side routes, so unknown paths get the file server's +// plain 404 rather than a fallback to index.html. +func Handler() http.Handler { + distRoot, err := fs.Sub(embeddedFiles, "dist") + if err != nil { + // Unreachable: "dist" is compiled into embeddedFiles above. + panic(err) + } + return http.FileServer(http.FS(distRoot)) +}