From 7dc3a270aeed8ca94f70cd61c873f03f02546772 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Sun, 24 May 2026 23:32:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=B3=BB=E7=BB=9F=EF=BC=8C=E9=87=8D=E6=9E=84?= =?UTF-8?q?=20/health=20=E2=86=92=20/api/meta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT.md | 129 +++++++++++++++++----------- README.md | 46 +++++----- package.json | 7 +- scripts/build.ts | 14 ++- scripts/bump-version-logic.ts | 40 +++++++++ scripts/bump-version.ts | 45 ++++++++++ src/server/bootstrap.ts | 3 +- src/server/helpers.ts | 5 +- src/server/routes/health.ts | 7 -- src/server/routes/meta.ts | 7 ++ src/server/server.ts | 18 +++- src/server/version.ts | 17 ++++ src/shared/api.ts | 3 +- src/shared/app.ts | 1 - src/web/app.tsx | 17 ++++ src/web/pages/dashboard/index.tsx | 18 ++-- src/web/styles.css | 10 ++- tests/scripts/build.test.ts | 48 +++++++++++ tests/scripts/bump-version.test.ts | 73 ++++++++++++++++ tests/server/bootstrap.test.ts | 19 ++++ tests/web/App.test.tsx | 22 +++-- tests/web/routes/dashboard.test.tsx | 11 ++- vite.config.ts | 1 - 23 files changed, 450 insertions(+), 111 deletions(-) create mode 100644 scripts/bump-version-logic.ts create mode 100644 scripts/bump-version.ts delete mode 100644 src/server/routes/health.ts create mode 100644 src/server/routes/meta.ts create mode 100644 src/server/version.ts create mode 100644 tests/scripts/build.test.ts create mode 100644 tests/scripts/bump-version.test.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b2fb902..c0d233a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -30,11 +30,12 @@ src/ static.ts 生产模式静态资源服务(SPA fallback、Content-Type 映射、immutable 缓存) helpers.ts 共享响应格式化工具(见下方函数清单) middleware.ts API 参数校验中间件(validateIdParam、validatePagination、validateTimeRange) - routes/ API 路由 handler(按端点拆分) - health.ts GET /health - shared/ - api.ts 前后端共享 TypeScript 类型 - app.ts 应用全局常量(name、title、subtitle、description、version) + routes/ API 路由 handler(按端点拆分) + meta.ts GET /api/meta + version.ts 运行时版本号读取(从 package.json 读取并验证) + shared/ + api.ts 前后端共享 TypeScript 类型 + app.ts 应用全局常量(name、title、subtitle、description) web/ React 前端(通过 Vite 构建) index.html HTML 入口 app.tsx 根组件(Admin 布局:Header + Sidebar + Content) @@ -43,8 +44,8 @@ src/ styles.css 全局样式与自定义 CSS 变量 css.d.ts CSS 模块类型声明 pages/ 页面组件 - dashboard/ - index.tsx 仪表盘页(欢迎语 + /health 联调示例) + dashboard/ + index.tsx 仪表盘页(欢迎语 + /api/meta 联调示例) users/ index.tsx 用户管理页(占位) settings/ @@ -62,10 +63,12 @@ src/ time.ts 时间处理(formatCountdown、formatDurationUnit、formatRelativeTime、isOlderThan、subtractHours) menu.tsx 菜单配置(路由与菜单项统一数据源) routes.tsx 路由配置(定义所有页面路由) -scripts/ - dev.ts 双进程开发服务(Bun API server + Vite dev server) - build.ts Vite → codegen → Bun compile 三步构建流水线 - clean.ts 清理构建产物与临时文件 + scripts/ + dev.ts 双进程开发服务(Bun API server + Vite dev server) + build.ts Vite → codegen → Bun compile 三步构建流水线(含版本号注入) + bump-version-logic.ts 纯版本管理逻辑(parse、validate、bump、format) + bump-version.ts 版本升迁 CLI 脚本 + clean.ts 清理构建产物与临时文件 tests/ Bun test 测试(结构镜像 src 目录) setup.ts 全局测试配置(jsdom、polyfill) helpers.ts 测试辅助工具(rmRetry) @@ -85,7 +88,7 @@ config.example.yaml 配置文件示例 ## 前后端边界 -前端只通过 HTTP 调用后端,API 路径为 `/api/*` 和 `/health`。共享类型放在 `src/shared`,前端不得 `import src/server` 的运行时实现。 +前端只通过 HTTP 调用后端,API 路径为 `/api/*`。共享类型放在 `src/shared`,前端不得 `import src/server` 的运行时实现。 --- @@ -129,8 +132,8 @@ HTTP 请求: // server.ts 中的路由注册 routes: { "/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }), - "/health": { - GET: () => handleHealth(mode), + "/api/meta": { + GET: async () => handleMeta(mode, await resolveVersion()), }, } ``` @@ -138,8 +141,8 @@ routes: { Handler 函数签名: ```typescript -// 无依赖的路由 -export function handleHealth(mode: RuntimeMode): Response; +// 带版本号参数的路由 +export function handleMeta(mode: RuntimeMode, version: string): Response; ``` **请求处理流程**: @@ -161,7 +164,7 @@ export function handleHealth(mode: RuntimeMode): Response; - **`helpers.ts`**:跨路由共用的响应工具函数 - `createApiError(error, status)` — 构造 API 错误体 - `createHeaders(mode, init)` — 创建响应 Headers(生产模式附加安全头:`X-Content-Type-Options`、`Referrer-Policy`) - - `createHealthResponse()` — 构造健康检查响应 `{ ok: true, service, timestamp }` + - `createMetaResponse(version)` — 构造应用元信息响应 `{ ok: true, service, timestamp, version }` - `formatDuration(ms)` — 毫秒转为可读时长字符串 - `jsonResponse(body, options)` — JSON 响应构造 @@ -177,10 +180,11 @@ export function handleHealth(mode: RuntimeMode): Response; ### 1.5 类型定义规范 - **共享类型**以 `src/shared/api.ts` 为唯一源头,前后端共同引用 -- **应用常量**以 `src/shared/app.ts` 为唯一源头,定义 `APP` 对象(name、title、subtitle、description、version),前后端及构建脚本共同引用 +- **应用常量**以 `src/shared/app.ts` 为唯一源头,定义 `APP` 对象(name、title、subtitle、description),前后端及构建脚本共同引用 +- **版本号**以 `package.json.version` 为唯一源头,通过 `src/server/version.ts` 运行时读取或构建时注入字面量 - 前端不得 `import src/server/` 下的任何文件 - **严格联合类型**优先于宽类型:如 `RuntimeMode: "development" | "production" | "test"` 而非 `RuntimeMode: string` -- API 响应类型(`ApiErrorResponse`、`HealthResponse`)定义在 shared 中 +- API 响应类型(`ApiErrorResponse`、`MetaResponse`)定义在 shared 中 ### 1.6 配置文件规范 @@ -208,7 +212,29 @@ server: port: 3000 ``` -### 1.7 错误模式 +### 1.7 版本管理 + +项目使用 `package.json.version` 作为版本号唯一来源,严格 `MAJOR.MINOR.PATCH` 格式。 + +**版本获取方式**: + +- 开发模式:`src/server/version.ts` 运行时从 `package.json` 读取版本号 +- 生产模式:`scripts/build.ts` 在构建时将版本号烘焙为 `APP_VERSION` 字面量注入 `server-entry.ts` + +**版本升迁命令**: + +```bash +bun run version:patch # 升迁 patch 版本(0.1.0 → 0.1.1) +bun run version:minor # 升迁 minor 版本(0.1.0 → 0.2.0) +bun run version:major # 升迁 major 版本(0.1.0 → 1.0.0) +bun run version:set 2.0.0 # 显式设置版本号 +``` + +版本升迁仅更新 `package.json.version`,不自动创建 git commit、tag 或 changelog。 + +**API 暴露**:`GET /api/meta` 返回 `{ ok, service, timestamp, version }`,前端通过此接口获取并展示版本号。 + +### 1.8 错误模式 - **API 错误**:`{ error: "描述", status: }`,状态码 400/404 - **日志**:非致命异常用 `console.warn`,启动失败用 `console.error` + `process.exit(1)` @@ -248,7 +274,7 @@ main.tsx │ │ │ └── Sidebar(TDesign Menu,菜单项点击导航) │ │ └── Content │ │ └── AppRoutes(路由配置) - │ │ ├── / → DashboardPage(欢迎语 + /health 联调) + │ │ ├── / → DashboardPage(欢迎语 + /api/meta 联调) │ │ ├── /users → UsersPage(占位) │ │ ├── /settings → SettingsPage(占位) │ │ └── * → NotFoundPage(404) @@ -288,7 +314,7 @@ utils/menu-config.ts(路由与菜单统一数据源) ```typescript // 使用 structured array(非字符串),以便精确匹配和按 prefix 失效 const queryKeys = { - health: () => ["health"] as const, + meta: () => ["meta"] as const, }; ``` @@ -300,8 +326,8 @@ const queryKeys = { ```typescript // 全局级查询(需要持续刷新) useQuery({ - queryKey: queryKeys.health(), - queryFn: () => fetchJson("/health"), + queryKey: queryKeys.meta(), + queryFn: () => fetchJson("/api/meta"), refetchInterval: 30000, // 30s 轮询 refetchIntervalInBackground: false, // 切后台不轮询 staleTime: 5000, // 5s 内视为 fresh @@ -344,7 +370,7 @@ new QueryClient({ - 类型从 `../shared/api` 导入,使用 `type` 导入(`import type { ... }`) ```typescript -import type { HealthResponse } from "../shared/api"; +import type { MetaResponse } from "../shared/api"; interface AppProps { title?: string; @@ -409,7 +435,7 @@ bun run dev [config.yaml] - **Bun API server**(端口 3000):后端 API 服务,`--watch` 监听后端文件变更自动重启 - **Vite dev server**(端口 5173):前端 SPA + HMR 热更新 -开发时访问 `http://127.0.0.1:5173`,Vite 自动将 `/api` 和 `/health` 请求代理到后端。 +开发时访问 `http://127.0.0.1:5173`,Vite 自动将 `/api` 请求代理到后端。 也可以单独启动: @@ -426,7 +452,7 @@ bun run dev:web # 仅启动 Vite dev server - Vite dev server 负责前端 SPA、HMR、模块热替换 - Bun API server 负责后端 API 路由 -- Vite 通过 proxy 配置将 `/api/*` 和 `/health` 转发到 Bun +- Vite 通过 proxy 配置将 `/api/*` 转发到 Bun #### 生产模式架构 @@ -444,16 +470,16 @@ const server = Bun.serve({ }, routes: { "/api/*": () => ..., - "/health": { GET: () => handleHealth(mode) }, + "/api/meta": { GET: async () => handleMeta(mode, await resolveVersion()) }, }, }); ``` #### 路由优先级 -Bun routes 的匹配规则:具体路径 > 通配符。`/health` 优先于 `/*`。 +Bun routes 的匹配规则:具体路径 > 通配符。`/api/meta` 优先于 `/api/*`。 -未匹配 method 的请求(如 POST /health)会落入 `/api/*` 通配符返回 404;若无该通配符会落入 fetch fallback。 +未匹配 method 的请求(如 POST /api/meta)会落入 `/api/*` 通配符返回 404;若无该通配符会落入 fetch fallback。 非 API 路径由 fetch fallback 处理:有文件扩展名的返回对应静态资源或 404,无扩展名的返回 SPA index.html。 @@ -471,8 +497,8 @@ bun run build ``` 1. Vite build → dist/web/ (前端静态资源,含 code splitting) -2. Code generation → .build/static-assets.ts + .build/server-entry.ts -3. Bun compile → dist/dial-server (单可执行文件) +2. Code generation → .build/static-assets.ts + .build/server-entry.ts(含版本号字面量注入) +3. Bun compile → dist/my-app (单可执行文件) ``` - Vite 构建前端资源到 `dist/web/`,自动 code splitting(vendor-react、vendor-tdesign、vendor-chart) @@ -482,10 +508,10 @@ bun run build #### 产物 -| 产物 | 用途 | -| ------------------ | ---------------------------------------- | -| `dist/dial-server` | 生产可执行文件(含前端资源,单文件部署) | -| `dist/web/` | Vite 构建的前端资源(构建中间产物) | +| 产物 | 用途 | +| ------------- | ---------------------------------------- | +| `dist/my-app` | 生产可执行文件(含前端资源,单文件部署) | +| `dist/web/` | Vite 构建的前端资源(构建中间产物) | #### 构建参数 @@ -496,14 +522,13 @@ bun run build #### 运行可执行文件 ```bash -./dist/dial-server [config.yaml] +./dist/my-app [config.yaml] ``` 启动后: - 访问 `http://127.0.0.1:3000/` → 返回前端 SPA -- 访问 `http://127.0.0.1:3000/api/*` → 返回后端 API -- 访问 `http://127.0.0.1:3000/health` → 返回健康检查 JSON +- 访问 `http://127.0.0.1:3000/api/meta` → 返回应用元信息 JSON(含版本号) #### 清理 @@ -534,13 +559,17 @@ bun run verify ### 3.5 脚本说明 -| 脚本 | 文件 | 说明 | -| -------------------- | ------------------- | ---------------------------------------- | -| `bun run dev` | `scripts/dev.ts` | 双进程开发服务(Vite :5173 + API :3000) | -| `bun run dev:server` | `src/server/dev.ts` | 仅启动后端 API server(--watch 模式) | -| `bun run dev:web` | Vite CLI | 仅启动 Vite dev server | -| `bun run build` | `scripts/build.ts` | Vite → codegen → Bun compile 三步构建 | -| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 | +| 脚本 | 文件 | 说明 | +| ----------------------- | ------------------------- | ---------------------------------------- | +| `bun run dev` | `scripts/dev.ts` | 双进程开发服务(Vite :5173 + API :3000) | +| `bun run dev:server` | `src/server/dev.ts` | 仅启动后端 API server(--watch 模式) | +| `bun run dev:web` | Vite CLI | 仅启动 Vite dev server | +| `bun run build` | `scripts/build.ts` | Vite → codegen → Bun compile 三步构建 | +| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 | +| `bun run version:patch` | `scripts/bump-version.ts` | 升迁 patch 版本(x.y.Z) | +| `bun run version:minor` | `scripts/bump-version.ts` | 升迁 minor 版本(x.Y.0) | +| `bun run version:major` | `scripts/bump-version.ts` | 升迁 major 版本(X.0.0) | +| `bun run version:set` | `scripts/bump-version.ts` | 显式设置版本号 | ### 3.6 环境变量 @@ -668,10 +697,10 @@ CI 或正式提交前执行完整验证(类型检查 + lint + 格式 + 测试 ### 测试分层 -| 层级 | 覆盖范围 | 位置 | 命令 | -| -------- | ---------------------- | ------------------------------------------------------------------- | --------------------------------------------- | -| 单元测试 | 后端函数、纯函数、常量 | `tests/server/**/*.test.ts`、`tests/web/{utils,hooks}/**/*.test.ts` | `bun test tests/server`、`bun test tests/web` | -| 组件测试 | React 组件渲染和交互 | `tests/web/components/**/*.test.tsx` | `bun test tests/web` | +| 层级 | 覆盖范围 | 位置 | 命令 | +| -------- | ---------------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| 单元测试 | 后端函数、纯函数、常量 | `tests/server/**/*.test.ts`、`tests/scripts/**/*.test.ts`、`tests/web/{utils,hooks}/**/*.test.ts` | `bun test tests/server`、`bun test tests/web` | +| 组件测试 | React 组件渲染和交互 | `tests/web/components/**/*.test.tsx` | `bun test tests/web` | ### 运行命令 diff --git a/README.md b/README.md index e2e57bd..db92f55 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,10 @@ export const APP = { title: "Your App", // 人类可读标题 subtitle: "你的副标题", // 副标题 description: "应用描述", // SEO meta 描述 - version: "0.1.0", // 版本号 } as const; ``` -同时修改 `package.json` 的 `name` 字段保持一致。 +同时修改 `package.json` 的 `name` 字段保持一致,`version` 字段管理应用版本号。 > **注意**:localStorage key 已从 `"my-app.theme.preference"` 变更为 `"theme.preference"`。如果从旧版本升级,用户的主题偏好设置将丢失,需重新选择。 @@ -68,19 +67,23 @@ bun run dev ## 项目管理 -| 命令 | 说明 | -| -------------------- | ---------------------------------------------------------- | -| `bun run dev` | 启动开发模式(并行启动后端 + 前端 Vite 开发服务器) | -| `bun run dev:server` | 仅启动后端开发服务(`--watch` 热重载) | -| `bun run dev:web` | 仅启动前端 Vite 开发服务器 | -| `bun run build` | 生产构建(Vite 打包前端 → Bun compile 生成独立可执行文件) | -| `bun test` | 运行全部测试 | -| `bun run lint` | ESLint 代码风格检查 | -| `bun run format` | Prettier 代码格式化 | -| `bun run typecheck` | TypeScript 类型检查 | -| `bun run check` | 完整质量检查:typecheck + lint + test | -| `bun run verify` | 验证构建流程:check + build | -| `bun run clean` | 清理构建产物和临时文件 | +| 命令 | 说明 | +| ----------------------- | ---------------------------------------------------------- | +| `bun run dev` | 启动开发模式(并行启动后端 + 前端 Vite 开发服务器) | +| `bun run dev:server` | 仅启动后端开发服务(`--watch` 热重载) | +| `bun run dev:web` | 仅启动前端 Vite 开发服务器 | +| `bun run build` | 生产构建(Vite 打包前端 → Bun compile 生成独立可执行文件) | +| `bun test` | 运行全部测试 | +| `bun run lint` | ESLint 代码风格检查 | +| `bun run format` | Prettier 代码格式化 | +| `bun run typecheck` | TypeScript 类型检查 | +| `bun run check` | 完整质量检查:typecheck + lint + test | +| `bun run verify` | 验证构建流程:check + build | +| `bun run clean` | 清理构建产物和临时文件 | +| `bun run version:patch` | 升迁 patch 版本(x.y.Z) | +| `bun run version:minor` | 升迁 minor 版本(x.Y.0) | +| `bun run version:major` | 升迁 major 版本(X.0.0) | +| `bun run version:set` | 显式设置版本号 | ## 项目结构 @@ -96,7 +99,9 @@ bun run dev ├── .lintstagedrc.json # lint-staged 暂存区检查配置 ├── scripts/ │ ├── dev.ts # 开发启动脚本(并行启动 API + Vite) -│ ├── build.ts # 生产构建脚本(Vite → 代码生成 → Bun compile) +│ ├── build.ts # 生产构建脚本(Vite → 代码生成 → Bun compile,含版本号注入) +│ ├── bump-version-logic.ts # 纯版本管理逻辑(parse、validate、bump、format) +│ ├── bump-version.ts # 版本升迁 CLI 脚本 │ └── clean.ts # 清理脚本 ├── src/ │ ├── server/ # 后端代码 @@ -108,15 +113,16 @@ bun run dev │ │ ├── helpers.ts # 共享响应工具(健康检查、JSON 响应) │ │ ├── middleware.ts # API 参数校验中间件 │ │ ├── static.ts # 静态资源服务 -│ │ └── routes/ # API 路由处理器 -│ │ └── health.ts # 健康检查端点 +│ │ └── routes/ # API 路由处理器 +│ │ └── meta.ts # 应用元信息端点(GET /api/meta) +│ │ version.ts # 版本号读取 │ ├── shared/ │ │ ├── api.ts # 前后端共享 TypeScript 类型定义 -│ │ └── app.ts # 应用全局常量(name、title、version 等) +│ │ └── app.ts # 应用全局常量(name、title、subtitle、description) │ └── web/ # 前端代码 │ ├── index.html # HTML 入口 │ ├── main.tsx # React 入口(BrowserRouter + QueryClient + ErrorBoundary) -│ ├── app.tsx # 根组件(Admin 布局:Header + Sidebar + Content) +│ ├── app.tsx # 根组件(Admin 布局:Header + Sidebar + Content + 版本号展示) │ ├── routes.tsx # 路由配置 │ ├── styles.css # 全局样式 │ ├── css.d.ts # CSS 模块类型声明 diff --git a/package.json b/package.json index 815fe8f..5d216cd 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "my-app", + "version": "0.1.0", "type": "module", "private": true, "scripts": { @@ -14,7 +15,11 @@ "test": "bun test", "clean": "bun run scripts/clean.ts", "typecheck": "tsc --noEmit", - "prepare": "husky" + "prepare": "husky", + "version:patch": "bun run scripts/bump-version.ts patch", + "version:minor": "bun run scripts/bump-version.ts minor", + "version:major": "bun run scripts/bump-version.ts major", + "version:set": "bun run scripts/bump-version.ts set" }, "devDependencies": { "@commitlint/cli": "^21.0.1", diff --git a/scripts/build.ts b/scripts/build.ts index df018ba..9c041ed 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -3,11 +3,13 @@ import { join, relative, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { APP } from "../src/shared/app"; +import { validateVersion } from "./bump-version-logic"; const projectRoot = fileURLToPath(new URL("..", import.meta.url)); const distWebDir = join(projectRoot, "dist/web"); const buildDir = join(projectRoot, ".build"); const executablePath = join(projectRoot, `dist/${APP.name}`); +const packageJsonPath = join(projectRoot, "package.json"); async function build() { try { @@ -62,6 +64,14 @@ async function codeGeneration() { await rm(buildDir, { force: true, recursive: true }); await Bun.write(join(buildDir, ".gitkeep"), ""); + const packageJson = (await Bun.file(packageJsonPath).json()) as { version: string }; + const version = packageJson.version; + if (typeof version !== "string") { + console.error("package.json does not have a valid version field"); + process.exit(1); + } + validateVersion(version); + const allFiles = await scanDir(distWebDir, "/"); const importLines: string[] = []; const fileEntries: string[] = []; @@ -106,9 +116,11 @@ async function codeGeneration() { `import { parseRuntimeArgs } from "../src/server/config";`, `import { staticAssets } from "./static-assets";`, "", + `const APP_VERSION = "${version}" as const;`, + "", `async function main() {`, ` const { configPath } = parseRuntimeArgs();`, - ` await bootstrap({ configPath, mode: "production", staticAssets });`, + ` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`, `}`, "", `void main().catch((error) => {`, diff --git a/scripts/bump-version-logic.ts b/scripts/bump-version-logic.ts new file mode 100644 index 0000000..a8fab63 --- /dev/null +++ b/scripts/bump-version-logic.ts @@ -0,0 +1,40 @@ +const VERSION_REGEX = /^\d+\.\d+\.\d+$/; + +export function bumpVersion(current: string, command: "major" | "minor" | "patch" | "set", target?: string): string { + validateVersion(current); + const [major, minor, patch] = parseVersion(current); + + switch (command) { + case "major": + return formatVersion(major + 1, 0, 0); + case "minor": + return formatVersion(major, minor + 1, 0); + case "patch": + return formatVersion(major, minor, patch + 1); + case "set": { + if (!target) { + throw new Error("set command requires a target version"); + } + validateVersion(target); + return target; + } + } +} + +export function formatVersion(major: number, minor: number, patch: number): string { + return `${major}.${minor}.${patch}`; +} + +export function parseVersion(version: string): [number, number, number] { + const parts = version.split(".").map((p) => parseInt(p, 10)); + if (parts.length !== 3 || parts.some(isNaN)) { + throw new Error(`Invalid version format: ${version}`); + } + return [parts[0]!, parts[1]!, parts[2]!]; +} + +export function validateVersion(version: string): void { + if (!VERSION_REGEX.test(version)) { + throw new Error(`Invalid version format: ${version}. Expected MAJOR.MINOR.PATCH (e.g., 0.1.0)`); + } +} diff --git a/scripts/bump-version.ts b/scripts/bump-version.ts new file mode 100644 index 0000000..f4e868d --- /dev/null +++ b/scripts/bump-version.ts @@ -0,0 +1,45 @@ +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { bumpVersion, validateVersion } from "./bump-version-logic"; + +const PACKAGE_JSON_PATH = resolve(import.meta.dir, "..", "package.json"); + +async function main() { + const args = process.argv.slice(2); + if (args.length === 0) { + console.error("Usage: bun run bump-version.ts [version]"); + process.exit(1); + } + + const command = args[0]; + if (command !== "patch" && command !== "minor" && command !== "major" && command !== "set") { + console.error(`Unknown command: ${command}. Expected patch, minor, major, or set`); + process.exit(1); + } + + if (command === "set" && args.length < 2) { + console.error("Usage: bun run bump-version.ts set "); + process.exit(1); + } + + const packageJson = (await Bun.file(PACKAGE_JSON_PATH).json()) as { version: string }; + const currentVersion = packageJson.version; + + if (typeof currentVersion !== "string") { + console.error("package.json does not have a valid version field"); + process.exit(1); + } + + validateVersion(currentVersion); + + const targetVersion = command === "set" ? args[1] : undefined; + const nextVersion = bumpVersion(currentVersion, command, targetVersion); + + packageJson.version = nextVersion; + writeFileSync(PACKAGE_JSON_PATH, JSON.stringify(packageJson, null, 2) + "\n"); + + console.log(`${currentVersion} -> ${nextVersion}`); +} + +void main(); diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 71fa9ac..306059f 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -17,6 +17,7 @@ export interface BootstrapOptions { configPath?: string; mode: RuntimeMode; staticAssets?: StartServerOptions["staticAssets"]; + version?: string; } export async function bootstrap(options: BootstrapOptions, dependencies: BootstrapDependencies = {}): Promise { @@ -38,7 +39,7 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr onSignal("SIGINT", shutdown); onSignal("SIGTERM", shutdown); - serve({ config, mode: options.mode, staticAssets: options.staticAssets }); + serve({ config, mode: options.mode, staticAssets: options.staticAssets, version: options.version }); } catch (error) { logError("启动失败:", error instanceof Error ? error.message : error); process.exit(1); diff --git a/src/server/helpers.ts b/src/server/helpers.ts index d5d4a67..e1747fe 100644 --- a/src/server/helpers.ts +++ b/src/server/helpers.ts @@ -1,4 +1,4 @@ -import type { ApiErrorResponse, HealthResponse, RuntimeMode } from "../shared/api"; +import type { ApiErrorResponse, MetaResponse, RuntimeMode } from "../shared/api"; import { APP } from "../shared/app"; @@ -17,11 +17,12 @@ export function createHeaders(mode: RuntimeMode, init: HeadersInit): Headers { return headers; } -export function createHealthResponse(): HealthResponse { +export function createMetaResponse(version: string): MetaResponse { return { ok: true, service: APP.name, timestamp: new Date().toISOString(), + version, }; } diff --git a/src/server/routes/health.ts b/src/server/routes/health.ts deleted file mode 100644 index 6fbca35..0000000 --- a/src/server/routes/health.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { RuntimeMode } from "../../shared/api"; - -import { createHealthResponse, jsonResponse } from "../helpers"; - -export function handleHealth(mode: RuntimeMode): Response { - return jsonResponse(createHealthResponse(), { mode }); -} diff --git a/src/server/routes/meta.ts b/src/server/routes/meta.ts new file mode 100644 index 0000000..8a59345 --- /dev/null +++ b/src/server/routes/meta.ts @@ -0,0 +1,7 @@ +import type { RuntimeMode } from "../../shared/api"; + +import { createMetaResponse, jsonResponse } from "../helpers"; + +export function handleMeta(mode: RuntimeMode, version: string): Response { + return jsonResponse(createMetaResponse(version), { mode }); +} diff --git a/src/server/server.ts b/src/server/server.ts index c756c38..706f07c 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -4,17 +4,24 @@ import type { StaticAssets } from "./static"; import { APP } from "../shared/app"; import { createApiError, jsonResponse } from "./helpers"; -import { handleHealth } from "./routes/health"; +import { handleMeta } from "./routes/meta"; import { serveStaticAsset } from "./static"; +import { readAppVersion } from "./version"; export interface StartServerOptions { config: ServerConfig; mode: RuntimeMode; staticAssets?: StaticAssets; + version?: string; } export function startServer(options: StartServerOptions) { - const { config, mode, staticAssets } = options; + const { config, mode, staticAssets, version } = options; + + const resolveVersion = (): Promise => { + if (version) return Promise.resolve(version); + return readAppVersion(); + }; const server = Bun.serve({ fetch(req) { @@ -27,8 +34,11 @@ export function startServer(options: StartServerOptions) { port: config.port, routes: { "/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }), - "/health": { - GET: () => handleHealth(mode), + "/api/meta": { + GET: async () => { + const resolvedVersion = await resolveVersion(); + return handleMeta(mode, resolvedVersion); + }, }, }, }); diff --git a/src/server/version.ts b/src/server/version.ts new file mode 100644 index 0000000..15de640 --- /dev/null +++ b/src/server/version.ts @@ -0,0 +1,17 @@ +import { resolve } from "node:path"; + +import { validateVersion } from "../../scripts/bump-version-logic"; + +const PACKAGE_JSON_PATH = resolve(import.meta.dir, "..", "..", "package.json"); + +export async function readAppVersion(): Promise { + const packageJson = (await Bun.file(PACKAGE_JSON_PATH).json()) as { version: string }; + const version = packageJson.version; + + if (typeof version !== "string") { + throw new Error("package.json does not have a valid version field"); + } + + validateVersion(version); + return version; +} diff --git a/src/shared/api.ts b/src/shared/api.ts index bd0d9b7..694ab31 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -3,10 +3,11 @@ export interface ApiErrorResponse { status: number; } -export interface HealthResponse { +export interface MetaResponse { ok: true; service: string; timestamp: string; + version: string; } export type RuntimeMode = "development" | "production" | "test"; diff --git a/src/shared/app.ts b/src/shared/app.ts index 09b5262..edeba04 100644 --- a/src/shared/app.ts +++ b/src/shared/app.ts @@ -3,5 +3,4 @@ export const APP = { name: "my-app", subtitle: "Bun 全栈应用", title: "My App", - version: "0.1.0", } as const; diff --git a/src/web/app.tsx b/src/web/app.tsx index 2836c1d..232c500 100644 --- a/src/web/app.tsx +++ b/src/web/app.tsx @@ -1,8 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; import { useEffect } from "react"; import { useLocation } from "react-router"; import { ChevronLeftIcon, ChevronRightIcon } from "tdesign-icons-react"; import { Button, Layout, RadioGroup } from "tdesign-react"; +import type { MetaResponse } from "../shared/api"; + import { APP } from "../shared/app"; import { Sidebar } from "./components/Sidebar"; import { useSidebarCollapsed } from "./hooks/use-sidebar-collapsed"; @@ -22,6 +25,12 @@ export function App() { const { preference: themePreference, setPreference: setThemePreference } = useThemePreference(); const { collapsed, toggleCollapsed } = useSidebarCollapsed(); const location = useLocation(); + const { data: meta } = useQuery({ + queryFn: fetchMeta, + queryKey: ["meta"], + refetchInterval: 30000, + staleTime: 5000, + }); useEffect(() => { document.title = APP.title; @@ -35,6 +44,7 @@ export function App() { const currentPath = location.pathname; const currentItem = MENU_ITEMS.find((item) => item.path === currentPath); const pageTitle = currentItem?.label ?? APP.title; + const versionDisplay = meta?.version ? `v${meta.version}` : null; return ( @@ -44,6 +54,7 @@ export function App() { {collapsed ? : } {APP.title} + {versionDisplay && {versionDisplay}} {pageTitle}
@@ -69,3 +80,9 @@ export function App() { ); } + +async function fetchMeta(): Promise { + const response = await fetch("/api/meta"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} diff --git a/src/web/pages/dashboard/index.tsx b/src/web/pages/dashboard/index.tsx index 17b90dd..ff553dd 100644 --- a/src/web/pages/dashboard/index.tsx +++ b/src/web/pages/dashboard/index.tsx @@ -1,14 +1,14 @@ import { useQuery } from "@tanstack/react-query"; import { Space } from "tdesign-react"; -import type { HealthResponse } from "../../../shared/api"; +import type { MetaResponse } from "../../../shared/api"; import { APP } from "../../../shared/app"; export function DashboardPage() { - const { data: health } = useQuery({ - queryFn: fetchHealth, - queryKey: ["health"], + const { data: meta } = useQuery({ + queryFn: fetchMeta, + queryKey: ["meta"], refetchInterval: 30000, staleTime: 5000, }); @@ -16,14 +16,14 @@ export function DashboardPage() { return (

欢迎使用 {APP.title}

-

在此构建你的应用。以下是 /health API 的返回数据(前后端联调示例):

- {health &&
{JSON.stringify(health, null, 2)}
} +

在此构建你的应用。以下是 /api/meta 的返回数据(前后端联调示例):

+ {meta &&
{JSON.stringify(meta, null, 2)}
}
); } -async function fetchHealth(): Promise { - const response = await fetch("/health"); +async function fetchMeta(): Promise { + const response = await fetch("/api/meta"); if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json() as Promise; + return response.json() as Promise; } diff --git a/src/web/styles.css b/src/web/styles.css index ac87752..f4c76b8 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -39,6 +39,14 @@ color: var(--td-text-color-primary); font-size: calc(var(--td-font-size-title-large) + 6px); font-weight: 700; + line-height: 64px; +} + +.app-version { + color: var(--td-text-color-placeholder); + font-size: var(--td-font-size-body-small); + font-weight: 400; + line-height: 64px; } .app-page-title { @@ -64,7 +72,7 @@ min-height: calc(100vh - 64px); } -.health-response { +.meta-response { background: var(--td-bg-color-component); border-radius: var(--td-radius-default); padding: var(--td-comp-paddingTB-l) var(--td-comp-paddingLR-l); diff --git a/tests/scripts/build.test.ts b/tests/scripts/build.test.ts new file mode 100644 index 0000000..39c7f0d --- /dev/null +++ b/tests/scripts/build.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; + +import { validateVersion } from "../../scripts/bump-version-logic"; + +describe("build 版本注入", () => { + test("validateVersion 接受有效版本", () => { + expect(() => validateVersion("0.1.0")).not.toThrow(); + expect(() => validateVersion("1.2.3")).not.toThrow(); + }); + + test("validateVersion 拒绝无效版本", () => { + expect(() => validateVersion("invalid")).toThrow(); + expect(() => validateVersion("1.0.0-beta.1")).toThrow(); + }); + + test("生成的 server-entry 包含版本字面量", () => { + const version = "0.1.0"; + const serverEntryTs = [ + `import { bootstrap } from "../src/server/bootstrap";`, + `import { parseRuntimeArgs } from "../src/server/config";`, + `import { staticAssets } from "./static-assets";`, + "", + `const APP_VERSION = "${version}" as const;`, + "", + `async function main() {`, + ` const { configPath } = parseRuntimeArgs();`, + ` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`, + `}`, + "", + `void main().catch((error) => {`, + ` console.error("启动失败:", error instanceof Error ? error.message : error);`, + ` process.exit(1);`, + `});`, + "", + ].join("\n"); + + expect(serverEntryTs).toContain(`const APP_VERSION = "${version}"`); + expect(serverEntryTs).toContain("version: APP_VERSION"); + }); + + test("版本字面量不依赖外部 package.json", () => { + const serverEntryTs = [`const APP_VERSION = "0.1.0" as const;`].join("\n"); + + expect(serverEntryTs).not.toContain("package.json"); + expect(serverEntryTs).not.toContain("Bun.file"); + expect(serverEntryTs).toContain('"0.1.0"'); + }); +}); diff --git a/tests/scripts/bump-version.test.ts b/tests/scripts/bump-version.test.ts new file mode 100644 index 0000000..bbab1d3 --- /dev/null +++ b/tests/scripts/bump-version.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; + +import { bumpVersion, formatVersion, parseVersion, validateVersion } from "../../scripts/bump-version-logic"; + +describe("版本解析与校验", () => { + test("parseVersion 解析有效版本", () => { + expect(parseVersion("0.1.0")).toEqual([0, 1, 0]); + expect(parseVersion("1.2.3")).toEqual([1, 2, 3]); + expect(parseVersion("10.20.30")).toEqual([10, 20, 30]); + }); + + test("parseVersion 拒绝无效版本", () => { + expect(() => parseVersion("invalid")).toThrow(); + expect(() => parseVersion("1.2")).toThrow(); + expect(() => parseVersion("1.2.3.4")).toThrow(); + expect(() => parseVersion("1.2.a")).toThrow(); + }); + + test("validateVersion 接受有效版本", () => { + expect(() => validateVersion("0.1.0")).not.toThrow(); + expect(() => validateVersion("1.2.3")).not.toThrow(); + expect(() => validateVersion("10.20.30")).not.toThrow(); + }); + + test("validateVersion 拒绝无效版本", () => { + expect(() => validateVersion("")).toThrow(); + expect(() => validateVersion("invalid")).toThrow(); + expect(() => validateVersion("1.2")).toThrow(); + expect(() => validateVersion("1.2.3.4")).toThrow(); + expect(() => validateVersion("1.0.0-beta.1")).toThrow(); + expect(() => validateVersion("v1.0.0")).toThrow(); + }); + + test("formatVersion 格式化版本", () => { + expect(formatVersion(0, 1, 0)).toBe("0.1.0"); + expect(formatVersion(1, 2, 3)).toBe("1.2.3"); + expect(formatVersion(10, 20, 30)).toBe("10.20.30"); + }); +}); + +describe("版本升迁逻辑", () => { + test("bumpVersion patch 升迁", () => { + expect(bumpVersion("1.2.3", "patch")).toBe("1.2.4"); + expect(bumpVersion("0.1.0", "patch")).toBe("0.1.1"); + expect(bumpVersion("0.0.1", "patch")).toBe("0.0.2"); + }); + + test("bumpVersion minor 升迁", () => { + expect(bumpVersion("1.2.3", "minor")).toBe("1.3.0"); + expect(bumpVersion("0.1.0", "minor")).toBe("0.2.0"); + expect(bumpVersion("0.0.1", "minor")).toBe("0.1.0"); + }); + + test("bumpVersion major 升迁", () => { + expect(bumpVersion("1.2.3", "major")).toBe("2.0.0"); + expect(bumpVersion("0.1.0", "major")).toBe("1.0.0"); + expect(bumpVersion("0.0.1", "major")).toBe("1.0.0"); + }); + + test("bumpVersion set 设置版本", () => { + expect(bumpVersion("1.2.3", "set", "2.0.0")).toBe("2.0.0"); + expect(bumpVersion("0.1.0", "set", "0.2.0")).toBe("0.2.0"); + }); + + test("bumpVersion set 拒绝无效版本", () => { + expect(() => bumpVersion("1.2.3", "set", "invalid")).toThrow(); + expect(() => bumpVersion("1.2.3", "set", "1.0.0-beta.1")).toThrow(); + }); + + test("bumpVersion set 缺少目标版本报错", () => { + expect(() => bumpVersion("1.2.3", "set")).toThrow("set command requires a target version"); + }); +}); diff --git a/tests/server/bootstrap.test.ts b/tests/server/bootstrap.test.ts index 6c7f40e..2533c4d 100644 --- a/tests/server/bootstrap.test.ts +++ b/tests/server/bootstrap.test.ts @@ -21,6 +21,7 @@ describe("bootstrap", () => { signalRegistered = true; }; const mockStartServer = (_options: StartServerOptions) => { + expect(_options.version).toBeUndefined(); started = true; return {}; }; @@ -38,6 +39,24 @@ describe("bootstrap", () => { expect(signalRegistered).toBe(true); }); + test("传递 version 给 startServer", async () => { + let receivedVersion: string | undefined; + + const deps: BootstrapDependencies = { + loadConfig: async () => ({ host: "127.0.0.1", port: 0 }), + logError: () => {}, + onSignal: () => {}, + startServer: (options: StartServerOptions) => { + receivedVersion = options.version; + return {}; + }, + }; + + await bootstrap({ mode: "production", version: "1.2.3" }, deps); + + expect(receivedVersion).toBe("1.2.3"); + }); + test("启动失败时调用 logError", async () => { let errorLogged = false; diff --git a/tests/web/App.test.tsx b/tests/web/App.test.tsx index e4f6952..876ad20 100644 --- a/tests/web/App.test.tsx +++ b/tests/web/App.test.tsx @@ -10,10 +10,13 @@ import { renderWithProviders } from "./test-utils"; describe("App", () => { test("渲染 Layout 骨架和品牌名", () => { window.fetch = (async () => { - return new Response(JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString() }), { - headers: { "Content-Type": "application/json" }, - status: 200, - }); + return new Response( + JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }), + { + headers: { "Content-Type": "application/json" }, + status: 200, + }, + ); }) as unknown as typeof fetch; renderWithProviders(createElement(App)); @@ -26,10 +29,13 @@ describe("App", () => { test("渲染侧边栏菜单项", () => { window.fetch = (async () => { - return new Response(JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString() }), { - headers: { "Content-Type": "application/json" }, - status: 200, - }); + return new Response( + JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }), + { + headers: { "Content-Type": "application/json" }, + status: 200, + }, + ); }) as unknown as typeof fetch; renderWithProviders(createElement(App)); diff --git a/tests/web/routes/dashboard.test.tsx b/tests/web/routes/dashboard.test.tsx index 932d704..fa3bd84 100644 --- a/tests/web/routes/dashboard.test.tsx +++ b/tests/web/routes/dashboard.test.tsx @@ -9,10 +9,13 @@ import { renderWithProviders } from "../test-utils"; describe("DashboardPage", () => { test("渲染欢迎信息", () => { window.fetch = (async () => { - return new Response(JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString() }), { - headers: { "Content-Type": "application/json" }, - status: 200, - }); + return new Response( + JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }), + { + headers: { "Content-Type": "application/json" }, + status: 200, + }, + ); }) as unknown as typeof fetch; renderWithProviders(createElement(DashboardPage)); diff --git a/vite.config.ts b/vite.config.ts index d38855c..83d7b81 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -35,7 +35,6 @@ export default defineConfig({ server: { proxy: { "/api": "http://127.0.0.1:3000", - "/health": "http://127.0.0.1:3000", }, }, });