From 3671c39b7c899979c80c1ab46cccd5324c342eba Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 28 Jul 2026 14:52:53 +0800 Subject: [PATCH] feat: single Docker image with frontend served from backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile: 3-stage build (frontend npm build → backend go build → alpine runtime) - Frontend dist files served from disk at /app/frontend/dist - server.go: catch-all GET handler serves static files with SPA fallback to index.html - config.go: new server.frontend_dir option (env: SUB_STORE_SERVER_FRONTEND_DIR) - API routes (/api/*), download routes, and /health take priority over static serving --- Dockerfile | 14 ++++++++++++-- cmd/server.go | 38 ++++++++++++++++++++++++++++++++++++++ internal/config/config.go | 2 ++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5631208..1036509 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,12 @@ -# ─── Stage 1: Build backend ─── +# ─── Stage 1: Build frontend ─── +FROM node:22-alpine AS frontend-builder +WORKDIR /frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci --production=false +COPY frontend/ . +RUN npm run build + +# ─── Stage 2: Build backend ─── FROM golang:1.26-alpine AS backend-builder RUN apk add --no-cache git ca-certificates WORKDIR /app @@ -7,17 +15,19 @@ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /sub-store . -# ─── Stage 2: Runtime ─── +# ─── Stage 3: Runtime ─── FROM alpine:3.21 RUN apk add --no-cache ca-certificates tzdata WORKDIR /app COPY --from=backend-builder /sub-store /app/sub-store +COPY --from=frontend-builder /frontend/dist /app/frontend/dist COPY config/config.example.yaml /app/config/config.example.yaml RUN mkdir -p /app/data ENV TZ=Asia/Shanghai ENV SUB_STORE_CONFIG=/app/config/config.yaml +ENV SUB_STORE_FRONTEND_DIR=/app/frontend/dist EXPOSE 3000 diff --git a/cmd/server.go b/cmd/server.go index 4f79a67..51e1d1f 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -129,6 +129,14 @@ func startFiber(cfg *config.Config, db *sqlx.DB) error { return c.JSON(fiber.Map{"status": "ok"}) }) + // Serve frontend static files with SPA fallback. + // Registered after API/download routes so those take priority. + frontendDir := cfg.Server.FrontendDir + if frontendDir == "" { + frontendDir = "./frontend/dist" + } + registerStaticFrontend(app, frontendDir) + // Start server addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) logrus.Infof("Starting %s on %s", cfg.App.Name, addr) @@ -159,3 +167,33 @@ func startFiber(cfg *config.Config, db *sqlx.DB) error { logrus.Info("Server stopped") return nil } + +// registerStaticFrontend serves static files from the given directory with +// SPA fallback: any GET request that doesn't match a real file returns +// index.html (so client-side routing works on fresh page loads). +func registerStaticFrontend(app *fiber.App, dir string) { + if _, err := os.Stat(dir); err != nil { + logrus.WithError(err).Warn("frontend directory not found, static serving disabled") + return + } + + indexFile := filepath.Join(dir, "index.html") + + // Catch-all GET handler: try to serve the requested file from the dist + // directory; if the file doesn't exist, fall back to index.html (SPA). + app.Use(func(c fiber.Ctx) error { + if c.Method() != "GET" { + return c.Next() + } + path := c.Path() + + // Try the exact file in the dist directory + filePath := filepath.Join(dir, filepath.Clean(path)) + if info, err := os.Stat(filePath); err == nil && !info.IsDir() { + return c.SendFile(filePath) + } + + // SPA fallback → index.html + return c.SendFile(indexFile) + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index 77fab6d..0283ee5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,7 @@ type ServerConfig struct { ReadTimeout time.Duration `mapstructure:"read_timeout"` WriteTimeout time.Duration `mapstructure:"write_timeout"` BodyLimit int `mapstructure:"body_limit"` + FrontendDir string `mapstructure:"frontend_dir"` } type DatabaseConfig struct { @@ -62,6 +63,7 @@ func defaults() { viper.SetDefault("server.read_timeout", 30*time.Second) viper.SetDefault("server.write_timeout", 60*time.Second) viper.SetDefault("server.body_limit", 4*1024*1024) + viper.SetDefault("server.frontend_dir", "./frontend/dist") viper.SetDefault("database.path", "./data/sub-store.db") viper.SetDefault("auth.admin_token", "") viper.SetDefault("auth.download_token", "")