~bigbes/lethe

76a281a0eca48e3ecdafe1663d5fb7ac68286a5f — Eugene Blikh a month ago f903c87
server: embed web SPA at /, wire build pipeline

- 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.
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,
  },
})