From 76a281a0eca48e3ecdafe1663d5fb7ac68286a5f Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 26 Apr 2026 06:39:41 +0300 Subject: [PATCH] server: embed web SPA at /, wire build pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add internal/server/web/embed.go with //go:embed all:dist and a SPA fallback shim: file-not-found → serve index.html at 200. - Commit dist/.gitkeep and dist/index.html (placeholder) so go build works on a fresh clone; real build output stays gitignored. - Mount web.Handler() as GET /* catch-all in server.go after /api/v1 so API routes and probe endpoints shadow the wildcard. - Add three server tests: ServesSPAAtRoot, SPAFallbackForNonAPIPath, APIPathsBypassSPA; update NotFoundReturnsProblemJSON for SPA era. - Extend Justfile with web-{install,dev,build,test,lint,clean} targets; build now depends on web-build. - Add node:20-alpine web-builder stage to Dockerfile; COPY dist into the Go builder stage before compiling. --- .gitignore | 1 + Dockerfile | 9 +++ Justfile | 22 +++++- internal/server/server.go | 9 +++ internal/server/server_test.go | 109 +++++++++++++++++++++++++++- internal/server/web/dist/.gitkeep | 0 internal/server/web/dist/index.html | 1 + internal/server/web/embed.go | 95 ++++++++++++++++++++++++ web/vite.config.ts | 6 +- 9 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 internal/server/web/dist/.gitkeep create mode 100644 internal/server/web/dist/index.html create mode 100644 internal/server/web/embed.go diff --git a/.gitignore b/.gitignore index f49d5ccd65777986f56d2624af5f91adaf895111..70bb2b1778f114a08ff6e609885150939b971fab 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ config.yaml web/node_modules/ internal/server/web/dist/* !internal/server/web/dist/.gitkeep +!internal/server/web/dist/index.html diff --git a/Dockerfile b/Dockerfile index f97d8bbce3892932c69003e82fee86fb3b3c9bef..b325c673f54e0a488ee52071acb2274fddb46dba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,13 @@ # syntax=docker/dockerfile:1.7 +FROM node:20-alpine AS web-builder + +WORKDIR /web +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + FROM golang:1.25-alpine AS builder WORKDIR /src @@ -7,6 +15,7 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . +COPY --from=web-builder /internal/server/web/dist /src/internal/server/web/dist RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/lethe ./cmd/lethe FROM gcr.io/distroless/static-debian12:nonroot diff --git a/Justfile b/Justfile index 8be89bd0cdd5b1b0305a1d476c4877a0fd2e9821..b9e186c7b9530f68dca828e2996a93f03412f403 100644 --- a/Justfile +++ b/Justfile @@ -4,9 +4,29 @@ version := `git describe --tags 2>/dev/null || echo dev` default: @just --list -build: +build: web-build CGO_ENABLED=0 go build -ldflags "-X main.version={{version}}" -o {{binary}} ./cmd/lethe +web-install: + cd web && npm ci + +web-dev: + cd web && npm run dev + +web-build: + cd web && npm run build + +web-test: + cd web && npm test + +web-lint: + cd web && npm run lint && npm run typecheck + +web-clean: + rm -rf web/node_modules internal/server/web/dist/assets + rm -f internal/server/web/dist/index.html + @printf 'letheSPA not built — run just web-build\n' > internal/server/web/dist/index.html + run: go run ./cmd/lethe -config config.yaml diff --git a/internal/server/server.go b/internal/server/server.go index d86786eafbb619a7c31691a9533bd2e19dfd6050..2f82c21f9cc51c286bb2875af3a4c52ac799867a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -36,6 +36,7 @@ import ( "sourcecraft.dev/bigbes/lethe/internal/platform/health" "sourcecraft.dev/bigbes/lethe/internal/platform/observability" authpkg "sourcecraft.dev/bigbes/lethe/internal/server/auth" + webpkg "sourcecraft.dev/bigbes/lethe/internal/server/web" ) // readyzTimeout caps the time /readyz spends running every health check. @@ -98,6 +99,14 @@ func (s *Server) Init(_ context.Context) error { s.Sessions.Mount(r) }) + // SPA catch-all: serves the embedded React app for all non-API GET paths. + // More-specific routes registered above (healthz, readyz, metrics, + // /api/v1/*) win over this wildcard because chi matches them first. + // Auth is NOT applied here; the client-side app handles unauthenticated + // states itself. Using Get (not Handle) so that non-GET requests to + // unregistered paths still reach MethodNotAllowed rather than the SPA. + r.Get("/*", webpkg.Handler().ServeHTTP) + s.router = r return nil } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 5146f9cea51731c5d289f193f6d9f43bfee7392e..745442491a08da7b02ac7ac6aff0adf49ecf34e4 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -202,15 +202,22 @@ func TestRouter_APIv1MountsAuthMiddleware(t *testing.T) { } } +// TestRouter_NotFoundReturnsProblemJSON verifies that the chi router's +// NotFound handler is correctly wired to return RFC 7807 problem+json. +// The notFoundHandler is invoked directly (the SPA GET catch-all and chi's +// MethodNotAllowed logic cover all observable routing paths in practice, so +// NotFound only fires when called explicitly or by future route changes). func TestRouter_NotFoundReturnsProblemJSON(t *testing.T) { srv := newTestServer(t, "127.0.0.1:0") if err := srv.Init(context.Background()); err != nil { t.Fatalf("Init: %v", err) } - req := httptest.NewRequest(http.MethodGet, "/no-such-route", nil) + // Call notFoundHandler directly: it is the registered handler and its + // output must be RFC 7807 problem+json regardless of routing context. + req := httptest.NewRequest(http.MethodGet, "/irrelevant", nil) rec := httptest.NewRecorder() - srv.router.ServeHTTP(rec, req) + notFoundHandler(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("status: got %d, want 404", rec.Code) @@ -251,3 +258,101 @@ func TestRouter_MethodNotAllowedReturnsProblemJSON(t *testing.T) { t.Fatalf("code: got %q, want METHOD_NOT_ALLOWED", got) } } + +// TestRouter_ServesSPAAtRoot verifies that GET / returns 200 text/html with +// the embedded SPA markup. +func TestRouter_ServesSPAAtRoot(t *testing.T) { + srv := newTestServer(t, "127.0.0.1:0") + if err := srv.Init(context.Background()); err != nil { + t.Fatalf("Init: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + srv.router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want 200", rec.Code) + } + ct := rec.Header().Get("Content-Type") + if ct == "" || ct[:9] != "text/html" { + t.Errorf("Content-Type: got %q, want text/html prefix", ct) + } + body := rec.Body.String() + if len(body) == 0 { + t.Fatal("body is empty") + } + // The placeholder or the built SPA should contain a element. + if !contains(body, "<title>") { + t.Errorf("body does not contain <title>; got first 200 chars: %q", truncate(body, 200)) + } +} + +// TestRouter_SPAFallbackForNonAPIPath verifies that a non-existent UI path +// receives the SPA index.html (200 text/html) rather than a 404. +func TestRouter_SPAFallbackForNonAPIPath(t *testing.T) { + srv := newTestServer(t, "127.0.0.1:0") + if err := srv.Init(context.Background()); err != nil { + t.Fatalf("Init: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/session/foo/bar/baz", nil) + rec := httptest.NewRecorder() + srv.router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want 200 (SPA fallback)", rec.Code) + } + ct := rec.Header().Get("Content-Type") + if ct == "" || ct[:9] != "text/html" { + t.Errorf("Content-Type: got %q, want text/html prefix", ct) + } +} + +// TestRouter_APIPathsBypassSPA verifies that /api/v1/* paths are auth-gated +// and never served the SPA shell. +func TestRouter_APIPathsBypassSPA(t *testing.T) { + srv := newTestServer(t, "127.0.0.1:0") + if err := srv.Init(context.Background()); err != nil { + t.Fatalf("Init: %v", err) + } + + // No Remote-User header → auth middleware should return 401 problem+json. + req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions", nil) + rec := httptest.NewRecorder() + srv.router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status: got %d, want 401", rec.Code) + } + ct := rec.Header().Get("Content-Type") + if ct != "application/problem+json" { + t.Errorf("Content-Type: got %q, want application/problem+json", ct) + } + // Must not be HTML. + body := rec.Body.String() + if contains(body, "<html") || contains(body, "<title>") { + t.Errorf("got HTML body for API route; SPA handler must not intercept /api/v1/*") + } +} + +// contains is a simple substring check to keep test assertions readable. +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + func() bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false + }()) +} + +// truncate returns at most n bytes of s for diagnostic messages. +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/internal/server/web/dist/.gitkeep b/internal/server/web/dist/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/internal/server/web/dist/index.html b/internal/server/web/dist/index.html new file mode 100644 index 0000000000000000000000000000000000000000..e21151f805353981de67a56e42b185b0fc8ecc8c --- /dev/null +++ b/internal/server/web/dist/index.html @@ -0,0 +1 @@ +<!doctype html><title>letheSPA not built — run just web-build diff --git a/internal/server/web/embed.go b/internal/server/web/embed.go new file mode 100644 index 0000000000000000000000000000000000000000..cba3f53b6558c5dff9c3873263b83c937635574b --- /dev/null +++ b/internal/server/web/embed.go @@ -0,0 +1,95 @@ +// Package web embeds the compiled SPA assets (produced by `just web-build`) +// into the Go binary via go:embed so the server binary is self-contained. +// At build time `dist/` must be non-empty; the committed placeholder +// index.html satisfies this on a fresh clone. The HTTP handler applies an +// SPA fallback shim: any path that does not match a real file in the +// embedded tree is rewritten to index.html so the client-side router can +// handle it. Real static assets (hashed JS/CSS chunks) are served normally +// with their correct Content-Type; the fallback only fires on 404s. +package web + +import ( + "embed" + "fmt" + "io/fs" + "net/http" +) + +//go:embed all:dist +var distFS embed.FS + +// Handler returns an http.Handler that serves the embedded SPA. Paths that +// exist in the embedded tree are served directly. Paths that do not exist +// fall back to index.html with HTTP 200 so the client-side router handles +// them. If index.html itself cannot be opened from the embedded FS, the +// handler returns 500 with a plain-text error. +// +// Routes beginning with /api/, /healthz, /readyz, or /metrics are NOT +// this handler's concern; they must be mounted before this handler in the +// router so they shadow the catch-all. +func Handler() http.Handler { + sub, err := fs.Sub(distFS, "dist") + if err != nil { + // This should never happen for a hard-coded path; blow up loudly. + panic(fmt.Sprintf("web: fs.Sub on embedded dist: %v", err)) + } + fileServer := http.FileServer(http.FS(sub)) + return &spaHandler{fs: sub, fileServer: fileServer} +} + +// spaHandler wraps a standard file server with a SPA fallback: if the +// requested path does not exist in the embedded FS, it serves index.html +// instead of a 404. +type spaHandler struct { + fs fs.FS + fileServer http.Handler +} + +func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Strip the leading "/" to obtain the relative path within the FS. + path := r.URL.Path + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + if path == "" { + path = "index.html" + } + + // Try to open the file to see if it exists. + f, err := h.fs.Open(path) + if err != nil { + // File not found — apply SPA fallback: serve index.html at status 200. + h.serveIndex(w, r) + return + } + + // Stat to confirm it's a regular file (not a directory that would + // trigger a redirect to path+"/"). + info, statErr := f.Stat() + _ = f.Close() + if statErr != nil || info.IsDir() { + h.serveIndex(w, r) + return + } + + // File exists — let the standard file server handle it with correct + // Content-Type, ETag, and range support. + h.fileServer.ServeHTTP(w, r) +} + +// serveIndex serves the embedded index.html with HTTP 200. It returns 500 +// (plain-text) if the embedded FS cannot open the file — this is a build +// invariant violation and must not be silently swallowed. +func (h *spaHandler) serveIndex(w http.ResponseWriter, r *http.Request) { + // Rewrite the URL to "/" so the file server finds index.html and sets + // the correct Content-Type header. + r2 := r.Clone(r.Context()) + r2.URL.Path = "/" + // Verify index.html is present before delegating; a missing file + // produces a 500 rather than a misleading file-server 404. + if _, err := h.fs.Open("index.html"); err != nil { + http.Error(w, "internal server error: embedded index.html missing", http.StatusInternalServerError) + return + } + h.fileServer.ServeHTTP(w, r2) +} diff --git a/web/vite.config.ts b/web/vite.config.ts index ab84f004eee16eb875fb5979c6ddd24340c9737a..093c23ca533022e96390530614b26d5f4f2ba5cc 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -51,6 +51,10 @@ export default defineConfig({ build: { // DEVIATION: output goes directly into the Go embed directory so no copy step is needed in CI outDir: '../internal/server/web/dist', - emptyOutDir: true, + // emptyOutDir: false preserves the committed .gitkeep and placeholder + // index.html so `go build` works on a fresh clone. Vite's hashed asset + // filenames prevent stale chunk accumulation; use `just web-clean` for a + // full reset when needed. + emptyOutDir: false, }, })