M .gitignore => .gitignore +1 -0
@@ 19,3 19,4 @@ config.yaml
web/node_modules/
internal/server/web/dist/*
!internal/server/web/dist/.gitkeep
+!internal/server/web/dist/index.html
M Dockerfile => Dockerfile +9 -0
@@ 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
M Justfile => Justfile +21 -1
@@ 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 '<!doctype html><title>lethe</title><body>SPA not built — run <code>just web-build</code></body>\n' > internal/server/web/dist/index.html
+
run:
go run ./cmd/lethe -config config.yaml
M internal/server/server.go => internal/server/server.go +9 -0
@@ 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
}
M internal/server/server_test.go => internal/server/server_test.go +107 -2
@@ 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 <title> 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]
+}
A internal/server/web/dist/.gitkeep => internal/server/web/dist/.gitkeep +0 -0
A internal/server/web/dist/index.html => internal/server/web/dist/index.html +1 -0
@@ 0,0 1,1 @@
+<!doctype html><title>lethe</title><body>SPA not built — run <code>just web-build</code></body>
A internal/server/web/embed.go => internal/server/web/embed.go +95 -0
@@ 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)
+}
M web/vite.config.ts => web/vite.config.ts +5 -1
@@ 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,
},
})