Skip to content
This repository was archived by the owner on Mar 10, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
Expand Down
11 changes: 11 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 .

Expand Down
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 1 addition & 2 deletions manager/app/rest_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
49 changes: 49 additions & 0 deletions manager/rest/frontend.go
Original file line number Diff line number Diff line change
@@ -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
}
Comment on lines +16 to +19
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error from fs.Sub is silently ignored. This could hide configuration issues or embed problems during startup. Consider logging the error to help with debugging if the frontend fails to load. The codebase uses logger.Logger for similar initialization issues (e.g., in manager/app/rest_app.go and manager/service/strategy_svc.go).

Copilot uses AI. Check for mistakes.

// 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
}
Comment on lines +27 to +29
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider logging when the frontend is not registered (either because fs.Sub failed or index.html is missing). This would help developers understand whether the frontend is being served or not, especially in development environments. For example, log an info message like "Frontend not registered: index.html not found" when hasIndex is false.

Copilot uses AI. Check for mistakes.

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)
})))
}
90 changes: 90 additions & 0 deletions manager/rest/frontend_test.go
Original file line number Diff line number Diff line change
@@ -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("<html>app</html>")},
"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(), "<html>app</html>")
}

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)
})))
}
3 changes: 2 additions & 1 deletion web/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
dist
dist/*
!dist/.gitkeep
node_modules
Empty file added web/dist/.gitkeep
Empty file.
6 changes: 6 additions & 0 deletions web/embed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package web

import "embed"

//go:embed all:dist
var DistFS embed.FS