From 32dc0d9c0272dc72cb0cb463ead79fc8315e3b10 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:56:04 +0000 Subject: [PATCH 1/4] Initial plan From af8e64e844ecf6c1533c1cc9c8056f410c34451f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:00:52 +0000 Subject: [PATCH 2/4] Embed React frontend into manager binary via go:embed - Add web/embed.go with go:embed directive for web/dist - Add manager/rest/frontend.go to serve embedded SPA with index.html fallback - Register frontend routes in manager's StartRestApp - Add Node.js build stage to Dockerfile - Add build-web target to Makefile - Update .gitignore for web/dist and web/node_modules Co-authored-by: ianchen0119 <42661015+ianchen0119@users.noreply.github.com> --- .gitignore | 7 ++- Dockerfile | 11 +++++ Makefile | 7 ++- manager/app/rest_app.go | 3 +- manager/rest/frontend.go | 49 +++++++++++++++++++ manager/rest/frontend_test.go | 90 +++++++++++++++++++++++++++++++++++ web/embed.go | 6 +++ 7 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 manager/rest/frontend.go create mode 100644 manager/rest/frontend_test.go create mode 100644 web/embed.go diff --git a/.gitignore b/.gitignore index c3c8e88..d04bc5f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,9 @@ bin api config/manager_config.toml config/*_config.toml -*.ref \ No newline at end of file +*.ref + +# Frontend build output (keep .gitkeep) +web/dist/* +!web/dist/.gitkeep +web/node_modules \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 0afa22c..496a4d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,11 @@ +# Frontend build stage +FROM node:22-alpine AS frontend +WORKDIR /web +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ . +RUN npm run build + # Build stage FROM golang:1.24-alpine3.22 AS builder @@ -13,6 +21,9 @@ RUN go mod download # Copy source code COPY . . +# Copy built frontend assets +COPY --from=frontend /web/dist ./web/dist + # Build the application RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . diff --git a/Makefile b/Makefile index e1c3d88..e403b57 100644 --- a/Makefile +++ b/Makefile @@ -24,8 +24,13 @@ deps: go mod tidy go mod download +# Build frontend +build-web: + @echo "Building frontend..." + cd web && npm ci && npm run build + # Build the application -build: deps +build: deps build-web @echo "Building application..." go build -o bin/api-server diff --git a/manager/app/rest_app.go b/manager/app/rest_app.go index 1f76872..c1ea49f 100644 --- a/manager/app/rest_app.go +++ b/manager/app/rest_app.go @@ -48,8 +48,7 @@ func NewRestApp(configName string, configDirPath string) (*fx.App, error) { func StartRestApp(lc fx.Lifecycle, cfg config.ServerConfig, handler *rest.Handler) error { engine := echo.New() handler.SetupRoutes(engine) - - // TODO: setup middleware, logging, etc. + rest.RegisterFrontend(engine) lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { diff --git a/manager/rest/frontend.go b/manager/rest/frontend.go new file mode 100644 index 0000000..1f5890e --- /dev/null +++ b/manager/rest/frontend.go @@ -0,0 +1,49 @@ +package rest + +import ( + "io/fs" + "net/http" + "strings" + + "github.com/Gthulhu/api/web" + "github.com/labstack/echo/v4" +) + +// RegisterFrontend serves the embedded React SPA from web/dist. +// It serves static assets directly and falls back to index.html +// for client-side routing. +func RegisterFrontend(e *echo.Echo) { + distFS, err := fs.Sub(web.DistFS, "dist") + if err != nil { + return + } + + // Check whether the frontend was actually built (more than just .gitkeep). + hasIndex := false + if f, err := distFS.Open("index.html"); err == nil { + f.Close() + hasIndex = true + } + if !hasIndex { + return + } + + fileServer := http.FileServer(http.FS(distFS)) + + e.GET("/*", echo.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/") + + // Try to open the requested file. If it exists, serve it directly. + if path != "" { + if f, err := distFS.Open(path); err == nil { + f.Close() + fileServer.ServeHTTP(w, r) + return + } + } + + // Fallback to index.html for SPA client-side routing. + r.URL.Path = "/" + fileServer.ServeHTTP(w, r) + }))) +} diff --git a/manager/rest/frontend_test.go b/manager/rest/frontend_test.go new file mode 100644 index 0000000..6e40312 --- /dev/null +++ b/manager/rest/frontend_test.go @@ -0,0 +1,90 @@ +package rest + +import ( + "io/fs" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" +) + +func TestRegisterFrontend_WithIndex(t *testing.T) { + e := echo.New() + + // Create a mock filesystem that simulates embedded dist contents. + mockFS := fstest.MapFS{ + "index.html": {Data: []byte("app")}, + "assets/main.js": {Data: []byte("console.log('ok')")}, + "assets/style.css": {Data: []byte("body{}")}, + } + + registerFrontendFromFS(e, mockFS) + + // Requesting an existing asset should return it directly. + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/assets/main.js", nil) + e.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "console.log") + + // Requesting a non-existent path should fall back to index.html (SPA). + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/some/spa/route", nil) + e.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "app") +} + +func TestRegisterFrontend_WithoutIndex(t *testing.T) { + e := echo.New() + + // Filesystem without index.html should not register any routes. + mockFS := fstest.MapFS{ + ".gitkeep": {Data: []byte{}}, + } + + registerFrontendFromFS(e, mockFS) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/anything", nil) + e.ServeHTTP(rec, req) + + // Should get 404/405 because no wildcard route was registered. + assert.NotEqual(t, http.StatusOK, rec.Code) +} + +// registerFrontendFromFS is a test helper that mirrors RegisterFrontend logic +// but accepts an arbitrary fs.FS instead of the embedded one. +func registerFrontendFromFS(e *echo.Echo, distFS fs.FS) { + hasIndex := false + if f, err := distFS.Open("index.html"); err == nil { + f.Close() + hasIndex = true + } + if !hasIndex { + return + } + + fileServer := http.FileServer(http.FS(distFS)) + + e.GET("/*", echo.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + + if path != "" { + if f, err := distFS.Open(path); err == nil { + f.Close() + fileServer.ServeHTTP(w, r) + return + } + } + + r.URL.Path = "/" + fileServer.ServeHTTP(w, r) + }))) +} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..5f89293 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,6 @@ +package web + +import "embed" + +//go:embed all:dist +var DistFS embed.FS From dbd0a48e6f0af17f0e4b48aa0a9ef11e20ecb1b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:01:39 +0000 Subject: [PATCH 3/4] Fix web/dist/.gitkeep tracking by updating web/.gitignore Co-authored-by: ianchen0119 <42661015+ianchen0119@users.noreply.github.com> --- .gitignore | 7 +------ web/.gitignore | 3 ++- web/dist/.gitkeep | 0 3 files changed, 3 insertions(+), 7 deletions(-) create mode 100644 web/dist/.gitkeep diff --git a/.gitignore b/.gitignore index d04bc5f..c3c8e88 100644 --- a/.gitignore +++ b/.gitignore @@ -15,9 +15,4 @@ bin api config/manager_config.toml config/*_config.toml -*.ref - -# Frontend build output (keep .gitkeep) -web/dist/* -!web/dist/.gitkeep -web/node_modules \ No newline at end of file +*.ref \ No newline at end of file diff --git a/web/.gitignore b/web/.gitignore index db4c6d9..a645f1c 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,2 +1,3 @@ -dist +dist/* +!dist/.gitkeep node_modules \ No newline at end of file diff --git a/web/dist/.gitkeep b/web/dist/.gitkeep new file mode 100644 index 0000000..e69de29 From 986922170cb7ea1c546ad5141e4e20331965e7f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:33:44 +0000 Subject: [PATCH 4/4] ci: replace go build with make build and make image Switch build verification to use Makefile targets: - Add Node.js setup for frontend build - Use make build (deps + frontend + Go binary) - Add make image to verify Docker image builds Co-authored-by: ianchen0119 <42661015+ianchen0119@users.noreply.github.com> --- .github/workflows/ci.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 590a3fc..9ddf682 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,16 +23,21 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.24.5' - - name: Download dependencies - run: go mod download - - name: Build application - run: go build -v ./... + run: make build + + - name: Build Docker image + run: make image - name: Run tests run: go test -v ./...