From 78989a42ac568c7df85cca024882ac8d817b582b Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Wed, 29 Apr 2026 10:43:42 +0800 Subject: [PATCH] feat(server): embed SPA dist with fallback handler; single-binary deploy --- .gitignore | 5 +++++ Makefile | 1 + cmd/server/embed.go | 10 +++++++++ cmd/server/main.go | 2 +- cmd/server/web/dist/.gitkeep | 0 internal/app/app.go | 13 +++++++++++- internal/transport/http/router.go | 7 +++++++ internal/transport/http/spa.go | 34 +++++++++++++++++++++++++++++++ 8 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 cmd/server/embed.go create mode 100644 cmd/server/web/dist/.gitkeep create mode 100644 internal/transport/http/spa.go diff --git a/.gitignore b/.gitignore index a455754..d611fa7 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,8 @@ Thumbs.db # Internal docs (specs / plans should never be committed) /docs/superpowers/ + +# Embedded SPA dist: ignore real built artifacts but keep the .gitkeep +# placeholder so `go build ./cmd/server` succeeds before `make build` runs. +/cmd/server/web/dist/* +!/cmd/server/web/dist/.gitkeep diff --git a/Makefile b/Makefile index cf68d01..519c139 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,7 @@ dev: build: cd web && pnpm install && pnpm build + rm -rf cmd/server/web/dist && mkdir -p cmd/server/web/dist && cp -r web/dist/. cmd/server/web/dist/ go build -o bin/server ./cmd/server test: diff --git a/cmd/server/embed.go b/cmd/server/embed.go new file mode 100644 index 0000000..d35530d --- /dev/null +++ b/cmd/server/embed.go @@ -0,0 +1,10 @@ +// Package main 的 embed.go 持有前端构建产物。 +// build 流程会先 pnpm build 再把 web/dist 复制到 cmd/server/web/dist/, +// go:embed 在编译期把整个目录注入到二进制。.gitkeep 占位文件保证未跑 +// `make build` 时 go build 也能成功(embed 不接受空目录)。 +package main + +import "embed" + +//go:embed all:web/dist +var distFS embed.FS diff --git a/cmd/server/main.go b/cmd/server/main.go index 4e9b9bd..e814e27 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -25,7 +25,7 @@ func main() { rootCtx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() - a, err := app.New(rootCtx, cfg) + a, err := app.New(rootCtx, cfg, distFS) if err != nil { _, _ = fmt.Fprintln(os.Stderr, "app new:", err) os.Exit(1) diff --git a/cmd/server/web/dist/.gitkeep b/cmd/server/web/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/app/app.go b/internal/app/app.go index fab2f9d..9611fe7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -18,8 +18,10 @@ package app import ( "context" + "embed" "errors" "fmt" + "io/fs" "log/slog" "net/http" "time" @@ -43,7 +45,9 @@ type App struct { } // New 把所有模块按启动顺序拼装好。任何阶段失败都会返回错误,不留半成品资源。 -func New(ctx context.Context, cfg *config.Config) (*App, error) { +// dist 是 cmd/server 用 //go:embed 注入的前端构建产物,根目录为 web/dist; +// fs.Sub 之后交给 SPA handler 提供静态文件 + index.html 回退。 +func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { log := logger.FromEnv(cfg.Env, nil) // 跑 migrations 必须在开池前;否则首次启动连不存在的表会让 ping 失败。 @@ -68,6 +72,12 @@ func New(ctx context.Context, cfg *config.Config) (*App, error) { // 通过 App 字段透出去,目前保留局部变量以避免暴露未稳定 API。 _ = notifyDispatcher + spaSub, err := fs.Sub(dist, "web/dist") + if err != nil { + return nil, fmt.Errorf("sub fs: %w", err) + } + spaHandler := httpx.NewSPAHandler(spaSub) + r := httpx.NewRouter(httpx.RouterDeps{ HealthCheck: func(req *http.Request) error { return db.HealthCheck(req.Context(), pool) @@ -75,6 +85,7 @@ func New(ctx context.Context, cfg *config.Config) (*App, error) { ReadyCheck: func(req *http.Request) error { return db.HealthCheck(req.Context(), pool) }, + SPAHandler: spaHandler, }) // chi.Router 接口本身已经声明 Use,无需类型断言到 *chi.Mux。 diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go index 9ba08b9..132a4ee 100644 --- a/internal/transport/http/router.go +++ b/internal/transport/http/router.go @@ -17,6 +17,9 @@ type RouterDeps struct { HealthCheck func(*http.Request) error // ReadyCheck is invoked from GET /readyz with the same contract. ReadyCheck func(*http.Request) error + // SPAHandler 为 nil 时禁用前端 fallback;非 nil 时挂到 chi NotFound, + // 把未匹配的请求交给 SPA handler 处理(静态文件 + index.html 回退)。 + SPAHandler http.Handler } // NewRouter builds the application's chi.Router with the standard @@ -46,5 +49,9 @@ func NewRouter(deps RouterDeps) chi.Router { _, _ = w.Write([]byte("ok")) }) + if deps.SPAHandler != nil { + r.NotFound(deps.SPAHandler.ServeHTTP) + } + return r } diff --git a/internal/transport/http/spa.go b/internal/transport/http/spa.go new file mode 100644 index 0000000..6435d53 --- /dev/null +++ b/internal/transport/http/spa.go @@ -0,0 +1,34 @@ +// Package httpx 内的 spa.go 提供 SPA 静态资源 + 404 fallback。 +// 命中文件直接 ServeFile;不存在但路径无后缀视为前端路由,回退 index.html; +// /api/ 前缀始终返回 404(避免把 API 拼写错误掩盖成 HTML)。 +package httpx + +import ( + "errors" + "io/fs" + "net/http" + "strings" +) + +// NewSPAHandler 把 distFS 暴露成 SPA handler。 +func NewSPAHandler(distFS fs.FS) http.Handler { + fileServer := http.FileServer(http.FS(distFS)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + http.NotFound(w, r) + return + } + path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" { + path = "index.html" + } + if _, err := fs.Stat(distFS, path); err != nil { + if errors.Is(err, fs.ErrNotExist) && !strings.Contains(path, ".") { + r.URL.Path = "/index.html" + fileServer.ServeHTTP(w, r) + return + } + } + fileServer.ServeHTTP(w, r) + }) +}