diff --git a/.gitignore b/.gitignore index f49d5ccd65777986f56d2624af5f91adaf895111..70bb2b1778f114a08ff6e609885150939b971fab 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ # Frontend 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,8 +4,28 @@ 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 '
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 @@ "sourcecraft.dev/bigbes/lethe/internal/pkg/httputil"
"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.
@@ -97,6 +98,14 @@ r.Use(s.Auth.Middleware)
s.Ingest.Mount(r)
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 @@ t.Fatalf("status = %d; want 418", rec.Code)
}
}
+// 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 @@ if got, _ := p["code"].(string); got != "METHOD_NOT_ALLOWED" {
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 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 @@ },
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,
},
})