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.
This commit is contained in:
Justin Visser 2026-06-11 14:02:38 +02:00
parent bbba450c6e
commit 3684488d75
4 changed files with 58 additions and 5 deletions

5
.gitignore vendored
View file

@ -3,3 +3,8 @@
# prettier cache (npm run format in web/ walks up here) # prettier cache (npm run format in web/ walks up here)
/node_modules/ /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

View file

@ -1,6 +1,7 @@
// Command spreadlab serves the spreadlab API (and, from milestone 4, the // Command spreadlab serves the dashboard and its API from one binary:
// dashboard itself). The -table flag instead prints the three-scenario // the embedded frontend on /, the API under /api, and a trivial
// comparison and exits, a quick engine sanity check. // /healthz for container healthchecks. The -table flag instead prints
// the three-scenario comparison and exits, a quick engine sanity check.
package main package main
import ( import (
@ -14,6 +15,7 @@ import (
"github.com/JustinZeus/spreadlab/internal/api" "github.com/JustinZeus/spreadlab/internal/api"
"github.com/JustinZeus/spreadlab/internal/engine" "github.com/JustinZeus/spreadlab/internal/engine"
"github.com/JustinZeus/spreadlab/internal/webdist"
) )
func main() { func main() {
@ -29,8 +31,17 @@ func main() {
return return
} }
log.Printf("spreadlab API listening on http://%s", *addr) // One mux, one origin: the longer /api/ pattern wins over / for API
if err := http.ListenAndServe(*addr, api.NewServer()); err != nil { // 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) log.Fatal(err)
} }
} }

4
internal/webdist/dist/index.html vendored Normal file
View file

@ -0,0 +1,4 @@
<!doctype html>
<meta charset="utf-8" />
<title>spreadlab</title>
<p>Placeholder page: this binary was built without the frontend. Run <code>npm run build</code> in <code>web/</code> and copy <code>web/dist</code> over <code>internal/webdist/dist</code>, or use the Docker image.</p>

View file

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