feat: enhance Makefile commands and update README for improved development workflow
refactor: separate API and SPA handling in server routes and add static file serving options test: add tests for SPA handling and static file serving feat: implement embedded SPA support for production and development environments
This commit is contained in:
@@ -1,134 +1,120 @@
|
||||
.PHONY: help install run run-server run-web build build-all build-assets build-web \
|
||||
package-current package-platform build-linux-amd64 clean-dist clean-temp generator enums migration testdata
|
||||
APP := cs-agent
|
||||
MAIN := ./cmd/server
|
||||
WEB_DIR := web
|
||||
SPA_INDEX := $(WEB_DIR)/out/index.html
|
||||
|
||||
DIST_DIR ?= dist
|
||||
TMP_DIR := $(DIST_DIR)/.tmp
|
||||
APP_NAME ?= cs-agent
|
||||
ARCHIVE_NAME ?= cs-ai-agent
|
||||
PACKAGE_ROOT_DIR ?= $(ARCHIVE_NAME)
|
||||
APP_ENTRY ?= ./cmd/server
|
||||
PLATFORMS := linux-amd64 linux-arm64 darwin-amd64 darwin-arm64 windows-amd64
|
||||
CURRENT_GOOS := $(shell go env GOOS)
|
||||
CURRENT_GOARCH := $(shell go env GOARCH)
|
||||
CURRENT_PLATFORM := $(CURRENT_GOOS)-$(CURRENT_GOARCH)
|
||||
PLATFORM ?= $(CURRENT_PLATFORM)
|
||||
GO ?= go
|
||||
PNPM ?= pnpm
|
||||
GOOS ?= $(shell $(GO) env GOOS)
|
||||
GOARCH ?= $(shell $(GO) env GOARCH)
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: all help build build-go build-linux release run run-go dev test check clean clean-web \
|
||||
web-install web-dev web-build-spa ensure-spa build-spa web-build-ssr web-typecheck web-lint \
|
||||
generator enums migration testdata
|
||||
|
||||
all: build
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " make help Show this help message"
|
||||
@echo " make install Install Go and web dependencies"
|
||||
@echo " make run Run server and web"
|
||||
@echo " make run-server Run Go server"
|
||||
@echo " make run-web Run web app"
|
||||
@echo " make build Build assets and package current platform"
|
||||
@echo " make build-all Build and package all platforms"
|
||||
@echo " make build-assets Build web assets"
|
||||
@echo " make build-web Build web app and sdk"
|
||||
@echo " make package-current Package current platform"
|
||||
@echo " make package-platform Package specified PLATFORM"
|
||||
@echo " make build-linux-amd64 Build and package linux amd64 release"
|
||||
@echo " make clean-dist Remove dist directory"
|
||||
@echo " make clean-temp Remove temporary packaging files"
|
||||
@echo " make build Build web SPA and Go binary"
|
||||
@echo " make build-go Build Go binary only, ensuring SPA exists"
|
||||
@echo " make build-linux Build linux amd64 binary"
|
||||
@echo " make release Build release binaries for common platforms"
|
||||
@echo " make run Build web SPA then run server"
|
||||
@echo " make run-go Run server only, ensuring SPA exists"
|
||||
@echo " make dev Run Go server with dev tag and web dev server"
|
||||
@echo " make test Run Go tests, ensuring SPA exists"
|
||||
@echo " make check Run Go tests, web typecheck, and web lint"
|
||||
@echo " make clean Remove Go binaries"
|
||||
@echo " make clean-web Remove web build output"
|
||||
@echo " make web-install Install web dependencies"
|
||||
@echo " make web-dev Run web dev server"
|
||||
@echo " make web-build-spa Build static web SPA"
|
||||
@echo " make web-typecheck Run web typecheck"
|
||||
@echo " make web-lint Run web lint"
|
||||
@echo " make generator Run code generator"
|
||||
@echo " make enums Generate frontend enums"
|
||||
@echo " make migration Run migration command"
|
||||
@echo " make testdata Run testdata generator"
|
||||
|
||||
install:
|
||||
go mod download
|
||||
cd web && pnpm install
|
||||
@echo "[install] done"
|
||||
build: web-build-spa
|
||||
@$(MAKE) build-go
|
||||
|
||||
run:
|
||||
@server_pid=0; \
|
||||
web_pid=0; \
|
||||
trap 'kill $$server_pid $$web_pid 2>/dev/null || true' INT TERM EXIT; \
|
||||
$(MAKE) run-server & \
|
||||
build-go: ensure-spa
|
||||
@echo "Building $(APP)..."
|
||||
@$(GO) build -v -o $(APP) $(MAIN)
|
||||
|
||||
build-linux: web-build-spa
|
||||
@echo "Building $(APP) for linux/amd64..."
|
||||
@GOOS=linux GOARCH=amd64 $(GO) build -v -o $(APP)-linux-amd64 $(MAIN)
|
||||
|
||||
release: web-build-spa
|
||||
@echo "Building release binaries..."
|
||||
@GOOS=linux GOARCH=amd64 $(GO) build -v -o $(APP)-linux-amd64 $(MAIN)
|
||||
@GOOS=linux GOARCH=arm64 $(GO) build -v -o $(APP)-linux-arm64 $(MAIN)
|
||||
@GOOS=darwin GOARCH=amd64 $(GO) build -v -o $(APP)-darwin-amd64 $(MAIN)
|
||||
@GOOS=darwin GOARCH=arm64 $(GO) build -v -o $(APP)-darwin-arm64 $(MAIN)
|
||||
@GOOS=windows GOARCH=amd64 $(GO) build -v -o $(APP)-windows-amd64.exe $(MAIN)
|
||||
|
||||
run: web-build-spa
|
||||
@$(GO) run $(MAIN)
|
||||
|
||||
run-go: ensure-spa
|
||||
@$(GO) run $(MAIN)
|
||||
|
||||
dev:
|
||||
@$(GO) run -tags dev $(MAIN) & \
|
||||
server_pid=$$!; \
|
||||
$(MAKE) run-web & \
|
||||
web_pid=$$!; \
|
||||
while kill -0 $$server_pid 2>/dev/null && kill -0 $$web_pid 2>/dev/null; do \
|
||||
sleep 1; \
|
||||
done; \
|
||||
status=0; \
|
||||
wait $$server_pid || status=$$?; \
|
||||
wait $$web_pid || status=$$?; \
|
||||
kill $$server_pid $$web_pid 2>/dev/null || true; \
|
||||
exit $$status
|
||||
trap 'kill $$server_pid 2>/dev/null || true' EXIT INT TERM; \
|
||||
$(MAKE) web-dev
|
||||
|
||||
run-server:
|
||||
go run ./cmd/server
|
||||
test: ensure-spa
|
||||
@$(GO) test ./...
|
||||
|
||||
run-web:
|
||||
cd web && pnpm dev
|
||||
check: test web-typecheck web-lint
|
||||
|
||||
build: clean-dist build-assets package-current clean-temp
|
||||
@echo "[build] done"
|
||||
clean:
|
||||
@rm -f $(APP) $(APP)-linux-amd64 $(APP)-linux-arm64 $(APP)-darwin-amd64 $(APP)-darwin-arm64 $(APP)-windows-amd64.exe
|
||||
|
||||
build-all: clean-dist build-assets
|
||||
@echo "[build-all] packaging platforms: $(PLATFORMS)"
|
||||
@for platform in $(PLATFORMS); do \
|
||||
echo "[build-all] packaging $$platform"; \
|
||||
$(MAKE) package-platform PLATFORM=$$platform; \
|
||||
done
|
||||
@$(MAKE) clean-temp
|
||||
@echo "[build-all] done"
|
||||
clean-web:
|
||||
@rm -rf $(WEB_DIR)/out
|
||||
|
||||
build-assets: build-web
|
||||
@echo "[build-assets] done"
|
||||
web-install:
|
||||
@cd $(WEB_DIR) && $(PNPM) install --frozen-lockfile
|
||||
|
||||
build-web:
|
||||
@echo "[build-web] building web sdk and app"
|
||||
cd web && pnpm build:sdk && pnpm build
|
||||
@echo "[build-web] done"
|
||||
web-dev:
|
||||
@cd $(WEB_DIR) && $(PNPM) dev
|
||||
|
||||
package-current:
|
||||
@$(MAKE) package-platform PLATFORM=$(CURRENT_PLATFORM)
|
||||
web-build-spa:
|
||||
@cd $(WEB_DIR) && $(PNPM) build:sdk && $(PNPM) build
|
||||
|
||||
build-linux-amd64: clean-dist build-assets
|
||||
@$(MAKE) package-platform PLATFORM=linux-amd64
|
||||
@$(MAKE) clean-temp
|
||||
@echo "[build-linux-amd64] done"
|
||||
ensure-spa:
|
||||
@if [ ! -f "$(SPA_INDEX)" ]; then \
|
||||
echo "SPA build missing; running web-build-spa..."; \
|
||||
$(MAKE) web-build-spa; \
|
||||
fi
|
||||
|
||||
package-platform:
|
||||
@platform="$(PLATFORM)"; \
|
||||
goos=$${platform%-*}; \
|
||||
goarch=$${platform#*-}; \
|
||||
stage_dir="$(TMP_DIR)/$$platform"; \
|
||||
package_dir="$$stage_dir/$(PACKAGE_ROOT_DIR)"; \
|
||||
dist_dir="$(CURDIR)/$(DIST_DIR)"; \
|
||||
archive_base="$$dist_dir/$(ARCHIVE_NAME)-$$platform"; \
|
||||
binary_name="$(APP_NAME)"; \
|
||||
if [ "$$goos" = "windows" ]; then binary_name="$(APP_NAME).exe"; fi; \
|
||||
echo "[package] start $$platform"; \
|
||||
rm -rf "$$stage_dir"; \
|
||||
mkdir -p "$$package_dir/web" "$$package_dir/config" "$$dist_dir"; \
|
||||
echo "[package] go build $$platform"; \
|
||||
GOOS=$$goos GOARCH=$$goarch go build -o "$$package_dir/$$binary_name" $(APP_ENTRY); \
|
||||
echo "[package] copy assets $$platform"; \
|
||||
cp -R web/out "$$package_dir/web/out"; \
|
||||
echo "[package] include example config only"; \
|
||||
cp config/config.example.yaml "$$package_dir/config/config.example.yaml"; \
|
||||
rm -f "$$archive_base.zip"; \
|
||||
echo "[package] archive $$archive_base.zip"; \
|
||||
cd "$$stage_dir" && zip -rXq "$$archive_base.zip" "$(PACKAGE_ROOT_DIR)" -x "*.DS_Store" "__MACOSX/*"; \
|
||||
rm -rf "$$stage_dir"; \
|
||||
echo "[package] done $$platform -> $$archive_base.zip"
|
||||
build-spa: web-build-spa
|
||||
|
||||
clean-dist:
|
||||
rm -rf $(DIST_DIR)
|
||||
web-build-ssr:
|
||||
@cd $(WEB_DIR) && $(PNPM) build
|
||||
|
||||
clean-temp:
|
||||
rm -rf $(TMP_DIR)
|
||||
web-typecheck:
|
||||
@cd $(WEB_DIR) && $(PNPM) typecheck
|
||||
|
||||
web-lint:
|
||||
@cd $(WEB_DIR) && $(PNPM) lint
|
||||
|
||||
generator:
|
||||
go run ./cmd/generator/generator.go
|
||||
@$(GO) run ./cmd/generator/generator.go
|
||||
|
||||
enums:
|
||||
go run ./cmd/enums/generator.go
|
||||
@$(GO) run ./cmd/enums/generator.go
|
||||
|
||||
migration:
|
||||
go run ./cmd/migration
|
||||
@$(GO) run ./cmd/migration
|
||||
|
||||
testdata:
|
||||
go run ./cmd/testdata
|
||||
@$(GO) run ./cmd/testdata
|
||||
|
||||
@@ -241,14 +241,14 @@ cd ..
|
||||
同时启动后端和前端:
|
||||
|
||||
```bash
|
||||
make run
|
||||
make dev
|
||||
```
|
||||
|
||||
或分别启动:
|
||||
|
||||
```bash
|
||||
make run-server
|
||||
make run-web
|
||||
make run-go
|
||||
make web-dev
|
||||
```
|
||||
|
||||
开发环境默认入口:
|
||||
@@ -263,16 +263,19 @@ make run-web
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
make run # 同时启动后端和前端
|
||||
make run-server # 启动后端
|
||||
make run-web # 启动前端
|
||||
make build # 构建前端静态资源并打包当前平台
|
||||
make build-web # 构建 web 应用和嵌入式 SDK
|
||||
make test # 运行 Go 测试
|
||||
make tidy # go mod tidy
|
||||
make generator # 执行代码生成
|
||||
make enums # 生成前端枚举
|
||||
make migration # 执行 migration
|
||||
make dev # 同时启动后端和前端开发服务
|
||||
make run # 构建前端 SPA 后启动后端
|
||||
make run-go # 启动后端,自动确保 SPA 已构建
|
||||
make web-dev # 启动前端开发服务
|
||||
make build # 构建前端 SPA 和当前平台 Go 二进制
|
||||
make build-linux # 构建 linux/amd64 二进制
|
||||
make release # 构建常用平台二进制
|
||||
make web-build-spa # 构建 web 静态 SPA 和嵌入式 SDK
|
||||
make test # 运行 Go 测试,自动确保 SPA 已构建
|
||||
make check # 运行 Go 测试、前端 typecheck 和 lint
|
||||
make generator # 执行代码生成
|
||||
make enums # 生成前端枚举
|
||||
make migration # 执行 migration
|
||||
```
|
||||
|
||||
## 系统视角
|
||||
|
||||
@@ -3,8 +3,6 @@ package bootstrap
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -12,9 +10,13 @@ import (
|
||||
_ "cs-agent/internal/ai/runtime"
|
||||
"cs-agent/internal/middleware"
|
||||
"cs-agent/internal/pkg/config"
|
||||
"cs-agent/internal/pkg/ginx"
|
||||
"cs-agent/internal/pkg/httpx"
|
||||
"cs-agent/internal/services"
|
||||
webspa "cs-agent/web"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mlogclub/simple/web"
|
||||
|
||||
_ "cs-agent/internal/services/wx_callback_handlers"
|
||||
)
|
||||
@@ -29,8 +31,25 @@ func NewServer() (*gin.Engine, error) {
|
||||
|
||||
addRouter(app)
|
||||
|
||||
app.StaticFS(cfg.Storage.Local.BaseURL, http.Dir(cfg.Storage.Local.Root))
|
||||
registerDashboardStatic(app, "web/out")
|
||||
notFoundPrefixes := []string{"/api/"}
|
||||
if baseURL := strings.TrimRight(cfg.Storage.Local.BaseURL, "/"); baseURL != "" {
|
||||
notFoundPrefixes = append(notFoundPrefixes, baseURL+"/")
|
||||
}
|
||||
app.StaticFS(cfg.Storage.Local.BaseURL, ginx.StaticFiles(cfg.Storage.Local.Root))
|
||||
ginx.HandleSPA(app, ginx.SPAOptions{
|
||||
Root: "./web/out",
|
||||
EmbeddedFS: webspa.SPA,
|
||||
EmbeddedRoot: "out",
|
||||
DirOptions: ginx.DirOptions{
|
||||
ShowList: false,
|
||||
SPA: true,
|
||||
IndexName: "index.html",
|
||||
},
|
||||
NotFoundPrefixes: notFoundPrefixes,
|
||||
NotFoundHandler: func(ctx *gin.Context) {
|
||||
httpx.WriteHttpStatusJSON(ctx, http.StatusNotFound, web.JsonErrorCode(http.StatusNotFound, "Not found"))
|
||||
},
|
||||
})
|
||||
|
||||
return app, nil
|
||||
}
|
||||
@@ -134,26 +153,3 @@ func addRouter(app *gin.Engine) {
|
||||
thirdGroup := app.Group("/api/third")
|
||||
registerThirdWechatRoutes(thirdGroup.Group("/wechat"))
|
||||
}
|
||||
|
||||
func registerDashboardStatic(app *gin.Engine, root string) {
|
||||
app.NoRoute(func(ctx *gin.Context) {
|
||||
if strings.HasPrefix(ctx.Request.URL.Path, "/api/") {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"})
|
||||
return
|
||||
}
|
||||
requestPath := filepath.Clean(strings.TrimPrefix(ctx.Request.URL.Path, "/"))
|
||||
if strings.HasPrefix(requestPath, "..") {
|
||||
ctx.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if requestPath == "." {
|
||||
requestPath = "index.html"
|
||||
}
|
||||
fullPath := filepath.Join(root, requestPath)
|
||||
if stat, err := os.Stat(fullPath); err == nil && !stat.IsDir() {
|
||||
ctx.File(fullPath)
|
||||
return
|
||||
}
|
||||
ctx.File(filepath.Join(root, "index.html"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cs-agent/internal/pkg/config"
|
||||
@@ -43,3 +45,40 @@ func TestNewServerRegistersGinRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerSeparatesAPIStaticAndSPA(t *testing.T) {
|
||||
config.SetCurrent(&config.Config{
|
||||
Storage: config.StorageConfig{
|
||||
Local: config.LocalStorageConfig{
|
||||
Root: "storage",
|
||||
BaseURL: "/storage",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
app, err := NewServer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
wantStatus int
|
||||
contentType string
|
||||
}{
|
||||
{path: "/api/not-exists", wantStatus: http.StatusNotFound, contentType: "application/json"},
|
||||
{path: "/dashboard/not-exists", wantStatus: http.StatusOK, contentType: "text/html"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("%s status=%d want %d", tt.path, rec.Code, tt.wantStatus)
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Type"), tt.contentType) {
|
||||
t.Fatalf("%s Content-Type=%q want %q", tt.path, rec.Header().Get("Content-Type"), tt.contentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package ginx
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type noDirFS struct {
|
||||
fs http.FileSystem
|
||||
}
|
||||
|
||||
type DirOptions struct {
|
||||
ShowList bool
|
||||
SPA bool
|
||||
IndexName string
|
||||
}
|
||||
|
||||
type SPAOptions struct {
|
||||
Root string
|
||||
EmbeddedFS fs.FS
|
||||
EmbeddedRoot string
|
||||
DirOptions DirOptions
|
||||
NotFoundPrefixes []string
|
||||
NotFoundHandler gin.HandlerFunc
|
||||
}
|
||||
|
||||
func StaticFiles(root string) http.FileSystem {
|
||||
return noDirFS{fs: http.Dir(root)}
|
||||
}
|
||||
|
||||
func (n noDirFS) Open(name string) (http.File, error) {
|
||||
file, err := n.fs.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
_ = file.Close()
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func HandleSPA(engine *gin.Engine, options SPAOptions) gin.HandlerFunc {
|
||||
handler := NewSPAHandler(options.Root, options.EmbeddedFS, options.EmbeddedRoot, options.DirOptions)
|
||||
engine.GET("/", handler)
|
||||
engine.HEAD("/", handler)
|
||||
engine.NoRoute(func(ctx *gin.Context) {
|
||||
for _, prefix := range options.NotFoundPrefixes {
|
||||
if strings.HasPrefix(ctx.Request.URL.Path, prefix) {
|
||||
if options.NotFoundHandler != nil {
|
||||
options.NotFoundHandler(ctx)
|
||||
return
|
||||
}
|
||||
ctx.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
handler(ctx)
|
||||
})
|
||||
return handler
|
||||
}
|
||||
|
||||
func NewSPAHandler(root string, embeddedFS fs.FS, embeddedRoot string, options DirOptions) gin.HandlerFunc {
|
||||
if _, err := os.Stat(path.Join(root, options.IndexName)); err == nil {
|
||||
return DirHandler(http.Dir(root), options)
|
||||
}
|
||||
|
||||
spaFS, err := fs.Sub(embeddedFS, embeddedRoot)
|
||||
if err != nil {
|
||||
slog.Error("failed to load embedded SPA files", slog.Any("err", err))
|
||||
return func(ctx *gin.Context) {
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
return DirHandler(http.FS(spaFS), options)
|
||||
}
|
||||
|
||||
func DirHandler(fileSystem http.FileSystem, options DirOptions) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
name := path.Clean(ctx.Request.URL.Path)
|
||||
if name == "." || name == "/" {
|
||||
name = "/" + options.IndexName
|
||||
}
|
||||
|
||||
file, err := fileSystem.Open(name)
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if info.IsDir() {
|
||||
if options.IndexName != "" {
|
||||
indexName := path.Join(name, options.IndexName)
|
||||
indexFile, err := fileSystem.Open(indexName)
|
||||
if err == nil {
|
||||
defer indexFile.Close()
|
||||
indexInfo, statErr := indexFile.Stat()
|
||||
if statErr != nil {
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !indexInfo.IsDir() {
|
||||
serveFile(ctx, indexName, indexFile, indexInfo)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !options.ShowList {
|
||||
ctx.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
serveFile(ctx, name, file, info)
|
||||
return
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if options.SPA && options.IndexName != "" {
|
||||
indexName := "/" + options.IndexName
|
||||
indexFile, err := fileSystem.Open(indexName)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
ctx.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer indexFile.Close()
|
||||
indexInfo, err := indexFile.Stat()
|
||||
if err != nil {
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
serveFile(ctx, indexName, indexFile, indexInfo)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.AbortWithStatus(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func serveFile(ctx *gin.Context, name string, file http.File, info os.FileInfo) {
|
||||
http.ServeContent(ctx.Writer, ctx.Request, name, info.ModTime(), file)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package ginx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestDirHandlerWithSPA(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("<html>spa</html>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "manifest"), []byte("manifest content"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{path: "/assets/not-exists.css", wantStatus: http.StatusOK, wantBody: "<html>spa</html>"},
|
||||
{path: "/images/not-exists.png", wantStatus: http.StatusOK, wantBody: "<html>spa</html>"},
|
||||
{path: "/fonts/not-exists", wantStatus: http.StatusOK, wantBody: "<html>spa</html>"},
|
||||
{path: "/manifest", wantStatus: http.StatusOK, wantBody: "manifest content"},
|
||||
{path: "/dashboard", wantStatus: http.StatusOK, wantBody: "<html>spa</html>"},
|
||||
}
|
||||
handler := DirHandler(http.Dir(root), DirOptions{
|
||||
ShowList: false,
|
||||
SPA: true,
|
||||
IndexName: "index.html",
|
||||
})
|
||||
|
||||
for _, tt := range tests {
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, tt.path, nil)
|
||||
|
||||
handler(ctx)
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("%s status=%d want %d; body=%q", tt.path, rec.Code, tt.wantStatus, rec.Body.String())
|
||||
}
|
||||
if tt.wantBody != "" && strings.TrimSpace(rec.Body.String()) != tt.wantBody {
|
||||
t.Fatalf("%s body=%q want %q", tt.path, strings.TrimSpace(rec.Body.String()), tt.wantBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticFilesDoesNotOpenDirectories(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "file.txt"), []byte("content"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fileSystem := StaticFiles(root)
|
||||
if file, err := fileSystem.Open("/file.txt"); err != nil {
|
||||
t.Fatalf("Open(file.txt) err=%v", err)
|
||||
} else {
|
||||
_ = file.Close()
|
||||
}
|
||||
|
||||
if file, err := fileSystem.Open("/"); err == nil {
|
||||
_ = file.Close()
|
||||
t.Fatal("Open(/) succeeded, want error")
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("Open(/) err=%v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSPAKeepsNotFoundPrefixes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("<html>spa</html>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
HandleSPA(engine, SPAOptions{
|
||||
Root: root,
|
||||
DirOptions: DirOptions{
|
||||
ShowList: false,
|
||||
SPA: true,
|
||||
IndexName: "index.html",
|
||||
},
|
||||
NotFoundPrefixes: []string{"/api/"},
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{path: "/", wantStatus: http.StatusOK, wantBody: "<html>spa</html>"},
|
||||
{path: "/dashboard", wantStatus: http.StatusOK, wantBody: "<html>spa</html>"},
|
||||
{path: "/api/not-exists", wantStatus: http.StatusNotFound},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("%s status=%d want %d", tt.path, rec.Code, tt.wantStatus)
|
||||
}
|
||||
if tt.wantBody != "" && strings.TrimSpace(rec.Body.String()) != tt.wantBody {
|
||||
t.Fatalf("%s body=%q want %q", tt.path, strings.TrimSpace(rec.Body.String()), tt.wantBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !dev
|
||||
|
||||
package webspa
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed out
|
||||
var SPA embed.FS
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build dev
|
||||
|
||||
package webspa
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
var SPA fs.FS = os.DirFS("web")
|
||||
Reference in New Issue
Block a user