diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1f71f12..5411f7c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -22,9 +22,9 @@ src/ server/ bootstrap.ts 后端统一启动引导(loadConfig → store → engine → startServer → shutdown) config.ts CLI 参数解析(仅提取配置文件路径) - dev.ts 开发模式启动入口(mode: "development",HMR 自动注入) + dev.ts 开发模式启动入口(mode: "development",仅 API server) main.ts 生产模式启动入口(mode: "production",安全头启用) - server.ts HTTP server 启动工厂(Bun.serve routes 声明式路由 + HTML import) + server.ts HTTP server 启动工厂(Bun.serve routes 声明式路由 + fetch fallback 静态资源服务) helpers.ts 共享响应格式化工具(见下方函数清单) middleware.ts API 参数校验中间件(validateTargetId、validateTimeRange、validatePagination) routes/ API 路由 handler(按端点拆分) @@ -113,7 +113,7 @@ probe-config.schema.json 用户配置 JSON Schema 导出物(用于 IDE 自动 HTTP 请求: Request → Bun.serve routes 声明式匹配 → routes/*.ts(handler) → middleware.ts(参数校验) → helpers.ts(响应格式化) → Response - 前端: "/*": homepage (HTML import) → SPA fallback + HMR(开发模式) + 前端: fetch fallback → serveStaticAsset (生产) / Vite proxy (开发) ``` ### 1.2 库使用优先级 @@ -748,44 +748,58 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps) bun run dev probes.yaml ``` -`bun --watch src/server/dev.ts` 启动单进程 fullstack 开发服务器: +`scripts/dev.ts` 同时启动两个进程: -- 后端 API + 前端 SPA 在同一端口(默认 3000) -- `development` 模式自动注入 HMR,前端修改即时热更新 -- `--watch` 监听后端文件变更自动重启 -- 访问 `http://127.0.0.1:3000` 即可使用完整应用 +- **Bun API server**(端口 3000):后端 API 服务,`--watch` 监听后端文件变更自动重启 +- **Vite dev server**(端口 5173):前端 SPA + HMR 热更新 + +开发时访问 `http://127.0.0.1:5173`,Vite 自动将 `/api` 和 `/health` 请求代理到后端。 + +也可以单独启动: + +```bash +bun run dev:server probes.yaml # 仅启动后端 API server +bun run dev:web # 仅启动 Vite dev server +``` ### 3.2 前后端集成方式 -#### 统一进程架构 +#### 双进程开发架构 -前后端通过 Bun 的 HTML import 机制集成为单进程应用: +开发模式下前后端分别由 Vite 和 Bun 服务: + +- Vite dev server 负责前端 SPA、HMR、模块热替换 +- Bun API server 负责后端 API 路由 +- Vite 通过 proxy 配置将 `/api/*` 和 `/health` 转发到 Bun + +#### 生产模式架构 + +生产模式下前端通过 Vite 构建为静态资源,通过 `import with { type: "file" }` 嵌入 Bun 可执行文件: ```typescript // server.ts -import homepage from "../web/index.html"; - const server = Bun.serve({ - development: mode === "development" ? { hmr: true, console: true } : false, + fetch(req) { + // staticAssets 存在时服务嵌入的前端资源 + return serveStaticAsset(new URL(req.url).pathname, staticAssets); + }, routes: { - "/*": homepage, // SPA fallback(开发模式自动注入 HMR) "/api/*": () => ..., // API 通配符(未匹配路由返回 404) - "/api/dashboard": { GET: (req) => handleDashboard(new URL(req.url), store, mode) }, + "/api/dashboard": { GET: (req) => handleDashboard(...) }, "/health": { GET: () => handleHealth(mode) }, // ... }, }); ``` -- 开发模式(`development: { hmr: true, console: true }`):Bun 自动为 HTML import 注入 HMR client,前端修改无需手动刷新,并将浏览器 console 回显到终端 -- 生产模式:HTML 及其引用的 JS/CSS 资源在 `bun build --compile` 时自动打包进可执行文件 - #### 路由优先级 Bun routes 的匹配规则:具体路径 > 通配符。`/api/dashboard` 优先于 `/api/*`,`/health` 优先于 `/*`。 未匹配 method 的请求(如 POST /api/dashboard)会落入 `/api/*` 通配符返回 404。 +非 API 路径由 fetch fallback 处理:有文件扩展名的返回对应静态资源或 404,无扩展名的返回 SPA index.html。 + ### 3.3 构建打包 #### 构建命令 @@ -796,26 +810,25 @@ bun run build #### 构建流程 -`scripts/build.ts` 执行单步构建: +`scripts/build.ts` 执行三步流水线: ``` -Bun.build({ - compile: { outfile: "dist/dial-server" }, - entrypoints: ["src/server/main.ts"], - minify: true, - sourcemap: "linked", -}) +1. Vite build → dist/web/ (前端静态资源,含 code splitting) +2. Code generation → .build/static-assets.ts + .build/server-entry.ts +3. Bun compile → dist/dial-server (单可执行文件) ``` -- 入口为 `src/server/main.ts`(`mode: "production"`,启用安全头) -- HTML import 的前端资源自动打包进可执行文件(Bun 自动生成 manifest) -- 无需中间产物目录,一步生成最终 binary +- Vite 构建前端资源到 `dist/web/`,自动 code splitting(vendor-react、vendor-tdesign、vendor-chart) +- Code generation 扫描 `dist/web/` 生成 `import with { type: "file" }` 声明,将资源嵌入 binary +- Bun compile 以 `.build/server-entry.ts` 为入口编译最终可执行文件 +- `.build/` 临时目录在构建完成后自动清理 #### 产物 | 产物 | 用途 | | ------------------ | ---------------------------------------- | | `dist/dial-server` | 生产可执行文件(含前端资源,单文件部署) | +| `dist/web/` | Vite 构建的前端资源(构建中间产物) | #### 构建参数 @@ -839,7 +852,7 @@ Bun.build({ ```bash bun run clean -# 清理 dist/ 构建产物和 *.bun-build 临时文件 +# 清理 dist/ 构建产物和 .build/ 临时文件 ``` ### 3.4 开发工作流 @@ -847,8 +860,9 @@ bun run clean #### 日常开发循环 ```bash -bun run dev probes.yaml # 启动开发环境(单进程,含 HMR) -# 修改前端代码 → HMR 热更新 / 修改后端代码 → --watch 自动重启 +bun run dev probes.yaml # 启动双进程开发环境(Vite + API server) +# 访问 http://127.0.0.1:5173 +# 修改前端代码 → Vite HMR 热更新 / 修改后端代码 → --watch 自动重启 bun run check # 提交前运行完整质量检查 ``` @@ -863,17 +877,19 @@ bun run verify ### 3.5 Executable/E2E 验证 -原 `scripts/smoke.ts` 覆盖过薄,已从当前工作流移除。后续如需验证 production executable 的 API、HTML import manifest、SPA fallback 和静态资源行为,应重新设计独立的 executable/E2E 测试。 +原 `scripts/smoke.ts` 覆盖过薄,已从当前工作流移除。后续如需验证 production executable 的 API、静态资源服务、SPA fallback 行为,应重新设计独立的 executable/E2E 测试。 ### 3.6 脚本说明 -| 脚本 | 文件 | 说明 | -| ---------------------- | ----------------------------------- | ----------------------------------- | -| `bun run dev` | `src/server/dev.ts` | 单进程 fullstack 开发服务(含 HMR) | -| `bun run build` | `scripts/build.ts` | Bun 编译可执行文件(含前端资源) | -| `bun run schema` | `scripts/generate-config-schema.ts` | 生成 `probe-config.schema.json` | -| `bun run schema:check` | `scripts/generate-config-schema.ts` | 检查配置 schema 导出物是否同步 | -| `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 | +| `bun run dev:web` | Vite CLI | 仅启动 Vite dev server | +| `bun run build` | `scripts/build.ts` | Vite → codegen → Bun compile 三步构建 | +| `bun run schema` | `scripts/generate-config-schema.ts` | 生成 `probe-config.schema.json` | +| `bun run schema:check` | `scripts/generate-config-schema.ts` | 检查配置 schema 导出物是否同步 | +| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 | ### 3.7 环境变量 diff --git a/README.md b/README.md index ed212d9..b063885 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ cp probes.example.yaml probes.yaml bun run dev probes.yaml ``` -`bun run dev` 启动单进程 fullstack 开发服务器(后端 API + 前端 SPA + HMR),访问 `http://127.0.0.1:3000`。 +`bun run dev` 启动双进程开发服务器(Vite :5173 + Bun API :3000),访问 `http://127.0.0.1:5173`。 ## 开发验证 diff --git a/bun.lock b/bun.lock index 52193f4..275ceda 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,7 @@ "@types/bun": "^1.3.13", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", @@ -39,6 +40,7 @@ "prettier": "^3.8.3", "typescript": "^6.0.3", "typescript-eslint": "^8.59.2", + "vite": "^8.0.13", }, }, }, @@ -157,12 +159,46 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + "@oxc-project/types": ["@oxc-project/types@0.130.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.130.0.tgz", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], + "@pkgr/core": ["@pkgr/core@0.2.9", "https://registry.npmmirror.com/@pkgr/core/-/core-0.2.9.tgz", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], "@popperjs/core": ["@popperjs/core@2.11.8", "https://registry.npmmirror.com/@popperjs/core/-/core-2.11.8.tgz", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.1", "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.1", "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.1", "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.1", "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.1", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.1", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.1", "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.1", "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.1", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.1", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.1", "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", { "os": "none", "cpu": "arm64" }, "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.1", "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.1", "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.1", "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@rtsao/scc": ["@rtsao/scc@1.1.0", "https://registry.npmmirror.com/@rtsao/scc/-/scc-1.1.0.tgz", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], "@simple-libs/child-process-utils": ["@simple-libs/child-process-utils@1.0.2", "https://registry.npmmirror.com/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0" } }, "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw=="], @@ -283,6 +319,8 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.9.10.tgz", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], "acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -417,6 +455,8 @@ "define-properties": ["define-properties@1.2.1", "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "doctrine": ["doctrine@2.1.0", "https://registry.npmmirror.com/doctrine/-/doctrine-2.1.0.tgz", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dom-helpers": ["dom-helpers@5.2.1", "https://registry.npmmirror.com/dom-helpers/-/dom-helpers-5.2.1.tgz", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], @@ -527,6 +567,8 @@ "for-each": ["for-each@0.3.5", "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + "fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function.prototype.name": ["function.prototype.name@1.1.8", "https://registry.npmmirror.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], @@ -681,6 +723,30 @@ "levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lines-and-columns": ["lines-and-columns@1.2.4", "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "lint-staged": ["lint-staged@17.0.4", "https://registry.npmmirror.com/lint-staged/-/lint-staged-17.0.4.tgz", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.1.2" }, "optionalDependencies": { "yaml": "^2.8.4" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-+rU9lSUyVOZ/hDUmRLVGzyS2v73cDdQjX+XQz1AaOdIE4RysLq0HoPW2HrrgeNCLklkhi904VBU1bmgWLHVnkA=="], @@ -711,6 +777,8 @@ "ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanoid": ["nanoid@3.3.12", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "napi-postinstall": ["napi-postinstall@0.3.4", "https://registry.npmmirror.com/napi-postinstall/-/napi-postinstall-0.3.4.tgz", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="], "natural-compare": ["natural-compare@1.4.0", "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -773,6 +841,8 @@ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + "postcss": ["postcss@8.5.14", "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], + "prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.8.3", "https://registry.npmmirror.com/prettier/-/prettier-3.8.3.tgz", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], @@ -823,6 +893,8 @@ "rfdc": ["rfdc@1.4.1", "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "rolldown": ["rolldown@1.0.1", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.1.tgz", { "dependencies": { "@oxc-project/types": "=0.130.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.1", "@rolldown/binding-darwin-arm64": "1.0.1", "@rolldown/binding-darwin-x64": "1.0.1", "@rolldown/binding-freebsd-x64": "1.0.1", "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", "@rolldown/binding-linux-arm64-gnu": "1.0.1", "@rolldown/binding-linux-arm64-musl": "1.0.1", "@rolldown/binding-linux-ppc64-gnu": "1.0.1", "@rolldown/binding-linux-s390x-gnu": "1.0.1", "@rolldown/binding-linux-x64-gnu": "1.0.1", "@rolldown/binding-linux-x64-musl": "1.0.1", "@rolldown/binding-openharmony-arm64": "1.0.1", "@rolldown/binding-wasm32-wasi": "1.0.1", "@rolldown/binding-win32-arm64-msvc": "1.0.1", "@rolldown/binding-win32-x64-msvc": "1.0.1" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ=="], + "safe-array-concat": ["safe-array-concat@1.1.4", "https://registry.npmmirror.com/safe-array-concat/-/safe-array-concat-1.1.4.tgz", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], "safe-push-apply": ["safe-push-apply@1.0.0", "https://registry.npmmirror.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], @@ -859,6 +931,8 @@ "sortablejs": ["sortablejs@1.15.7", "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz", {}, "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A=="], + "source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "stable-hash-x": ["stable-hash-x@0.2.0", "https://registry.npmmirror.com/stable-hash-x/-/stable-hash-x-0.2.0.tgz", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -929,6 +1003,8 @@ "victory-vendor": ["victory-vendor@37.3.6", "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + "vite": ["vite@8.0.13", "https://registry.npmmirror.com/vite/-/vite-8.0.13.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.1", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw=="], + "whatwg-encoding": ["whatwg-encoding@3.1.1", "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], @@ -981,6 +1057,8 @@ "@reduxjs/toolkit/immer": ["immer@11.1.8", "https://registry.npmmirror.com/immer/-/immer-11.1.8.tgz", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="], + "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@tybys/wasm-util/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], diff --git a/openspec/specs/bun-fullstack-routing/spec.md b/openspec/specs/bun-fullstack-routing/spec.md index 4fd51c5..393ea81 100644 --- a/openspec/specs/bun-fullstack-routing/spec.md +++ b/openspec/specs/bun-fullstack-routing/spec.md @@ -5,16 +5,16 @@ ## Requirements ### Requirement: 声明式路由注册 -系统 SHALL 使用 Bun.serve 的 `routes` 对象以声明式方式注册所有 HTTP 路由,包括 HTML 页面路由和 API 端点路由。 - -#### Scenario: HTML 页面路由注册 -- **WHEN** server 启动时 -- **THEN** 系统 SHALL 通过 HTML import 将前端入口注册到 `routes` 对象的 `"/*"` 通配符路径 +系统 SHALL 使用 Bun.serve 的 `routes` 对象以声明式方式注册 API 端点路由,非 API 请求由 `fetch` fallback 处理。 #### Scenario: API 端点路由注册 - **WHEN** server 启动时 - **THEN** 系统 SHALL 将所有 API 端点以 method handler 对象形式注册到 `routes` 对象 +#### Scenario: 非 API 请求处理 +- **WHEN** 请求路径不匹配任何 `routes` 中注册的路由 +- **THEN** `fetch` fallback SHALL 将请求交给静态资源服务处理(production)或返回提示文本(development) + ### Requirement: 路径参数支持 系统 SHALL 使用 routes 对象的 `:param` 语法声明路径参数,替代手动 regex 匹配。 @@ -38,12 +38,12 @@ - **THEN** `/api/*` 通配符 SHALL 返回 JSON 格式的 404 错误响应 ### Requirement: Fetch Fallback 处理 -系统 SHALL 使用 `fetch` handler 作为兜底,理论上不应被触发(所有路径都被 routes 通配符覆盖)。 +系统 SHALL 使用 `fetch` handler 作为非 API 请求的入口,负责静态资源服务和 SPA fallback。 -#### Scenario: 未匹配的 API 路由 -- **WHEN** 请求路径以 `/api/` 开头但未在具体 API 路由中注册 -- **THEN** `/api/*` 通配符 SHALL 返回 JSON 格式的 404 错误响应 +#### Scenario: Production 模式 fetch fallback +- **WHEN** production 模式下请求未匹配 routes 中的 API 路由 +- **THEN** `fetch` handler SHALL 调用 `serveStaticAsset` 返回对应静态资源或 SPA fallback -#### Scenario: 未匹配的非 API 路由 -- **WHEN** 请求路径不以 `/api/` 开头且未在具体路由中注册 -- **THEN** `"/*": homepage` 通配符 SHALL 返回前端入口 HTML 文档(带 HMR 注入) +#### Scenario: Development 模式 fetch fallback +- **WHEN** development 模式下请求未匹配 routes 中的 API 路由 +- **THEN** `fetch` handler SHALL 返回提示文本,引导开发者通过 Vite dev server 访问前端 diff --git a/openspec/specs/frontend-development-workflow/spec.md b/openspec/specs/frontend-development-workflow/spec.md index bf170cd..177bd91 100644 --- a/openspec/specs/frontend-development-workflow/spec.md +++ b/openspec/specs/frontend-development-workflow/spec.md @@ -1,41 +1,45 @@ ## Purpose -定义 Bun.serve fullstack + React + TypeScript 前端开发工作流、开发期 API 访问和共享契约的行为要求。 +定义基于 Vite dev server + Bun API server 的前端开发工作流、开发期 API 访问和共享契约的行为要求。 ## Requirements ### Requirement: Vite React 开发服务器 -系统 SHALL 提供基于 Bun.serve fullstack 模式的前端开发工作流,并支持热模块替换和 React Fast Refresh。 +系统 SHALL 提供基于 Vite dev server 的前端开发工作流,支持热模块替换和 React Fast Refresh。 #### Scenario: 启动前端开发服务器 - **WHEN** 开发者启动开发命令 -- **THEN** 前端 SHALL 由 Bun.serve 的 HTML import 机制提供服务,并通过 `development: { hmr: true, console: true }` 启用 HMR、React Fast Refresh 和浏览器 console 回显 +- **THEN** 前端 SHALL 由 Vite dev server 提供服务,支持 HMR 和 React Fast Refresh,监听 :5173 端口 #### Scenario: 构建前端静态资源 - **WHEN** 开发者运行前端生产构建命令 -- **THEN** 系统 SHALL 通过 Bun.build 的 HTML import ahead-of-time bundling 产出可由 Bun 后端服务的前端静态资源 +- **THEN** 系统 SHALL 通过 Vite build(Rolldown)产出优化的前端静态资源到 `dist/web/` ### Requirement: 前端开发期 API 代理 -前端开发服务器 SHALL 在本地开发期间无需代理配置即可访问后端 API,因为前后端运行在同一进程同一端口。 +前端开发服务器 SHALL 通过 Vite proxy 配置将 API 请求转发到后端 server。 #### Scenario: 前端开发期调用拨测 API -- **WHEN** 浏览器从开发服务器请求 `/api/summary`、`/api/targets` 等拨测 API -- **THEN** Bun.serve SHALL 直接由 routes 中注册的 API handler 处理请求,无需 proxy 转发 +- **WHEN** 浏览器从 Vite dev server 请求 `/api/*` 路径 +- **THEN** Vite SHALL 通过 proxy 将请求转发到 Bun API server(默认 :3000) + +#### Scenario: 前端开发期访问健康检查 +- **WHEN** 浏览器从 Vite dev server 请求 `/health` +- **THEN** Vite SHALL 通过 proxy 将请求转发到 Bun API server #### Scenario: 开发期访问非 API 前端路由 -- **WHEN** 浏览器从开发服务器请求非 API 前端路由 -- **THEN** Bun.serve SHALL 将该请求作为前端应用流量处理(SPA fallback 返回 HTML) +- **WHEN** 浏览器从 Vite dev server 请求非 API 前端路由 +- **THEN** Vite SHALL 将该请求作为前端应用流量处理(SPA fallback 返回 HTML) -### Requirement: 开发期单端口运行 -项目 SHALL 保证开发命令中前端页面、HMR 和后端 API 由同一个 Bun.serve 进程在同一端口提供服务。 +### Requirement: 开发期双进程运行 +项目 SHALL 在开发命令中同时启动 Vite dev server 和 Bun API server 两个进程。 #### Scenario: 使用默认开发端口 -- **WHEN** 开发者未提供端口覆盖并运行开发命令 -- **THEN** Bun.serve SHALL 在默认端口同时提供前端页面、HMR 和后端 API +- **WHEN** 开发者运行开发命令 +- **THEN** Vite dev server SHALL 监听 :5173,Bun API server SHALL 监听配置文件指定的端口(默认 :3000) -#### Scenario: 使用配置覆盖开发端口 -- **WHEN** 开发者通过配置文件覆盖端口并运行开发命令 -- **THEN** Bun.serve SHALL 在配置端口同时提供前端页面、HMR 和后端 API +#### Scenario: 开发者访问前端 +- **WHEN** 开发者打开浏览器 +- **THEN** 开发者 SHALL 访问 Vite dev server 地址(:5173)获取前端页面 ### Requirement: 前端使用相对 API 路径 除非有文档化的部署配置覆盖该行为,前端代码 MUST 通过相对 `/api/*` URL 调用后端 API。 @@ -46,21 +50,14 @@ #### Scenario: 运行环境变化 - **WHEN** host 或 port 在开发环境和生产环境之间变化 -- **THEN** 前端 API 调用 SHALL 无需修改源码即可继续工作 +- **THEN** 前端 API 调用 SHALL 无需修改源码即可继续工作(开发期通过 Vite proxy,生产期通过同源请求) ### Requirement: 集成开发命令 -项目 SHALL 提供一个文档化命令,用于在开发期间同时运行前端和后端。 +项目 SHALL 提供一个文档化命令,用于在开发期间同时运行 Vite dev server 和 Bun API server。 #### Scenario: 启动全栈开发 - **WHEN** 开发者运行文档化的全栈开发命令 -- **THEN** 系统 SHALL 启动单个 Bun.serve 进程,同时提供前端 HMR 和后端 API 服务 - -### Requirement: 开发质量命令文档化 -项目 SHALL 在前端开发工作流文档中说明日常检查和完整验证命令。 - -#### Scenario: 查阅开发命令 -- **WHEN** 开发者阅读 README 的开发或测试章节 -- **THEN** 文档 SHALL 说明 `check` 用于日常开发检查,`verify` 用于提交前或发布前完整验证 +- **THEN** 系统 SHALL 同时启动 Vite dev server 和 Bun API server,任一进程异常退出时终止另一个 ### Requirement: 共享 TypeScript 契约 项目 SHALL 为前端和后端共同使用的请求与响应类型提供共享 TypeScript 边界。 diff --git a/openspec/specs/fullstack-app-runtime/spec.md b/openspec/specs/fullstack-app-runtime/spec.md index c183ca2..a049d05 100644 --- a/openspec/specs/fullstack-app-runtime/spec.md +++ b/openspec/specs/fullstack-app-runtime/spec.md @@ -1,6 +1,6 @@ ## Purpose -定义 Bun 全栈应用运行时的 HTTP server、API 命名空间、健康检查、生产静态资源服务和 SPA fallback 行为。 +定义基于 Vite + Bun 的全栈应用运行时,包括 HTTP server、API 命名空间、健康检查、生产静态资源服务和 SPA fallback 行为。 ## Requirements @@ -64,26 +64,26 @@ - **THEN** Bun server SHALL 返回成功的、机器可读的健康检查响应 ### Requirement: 生产静态资源服务 -系统 SHALL 在生产模式下通过 Bun 内置的 HTML import manifest 机制服务前端资源。 +系统 SHALL 在生产模式下通过自定义 `serveStaticAsset` 函数服务嵌入的 Vite 前端产出。 #### Scenario: 请求构建后的资源 -- **WHEN** 客户端请求构建后的前端资源 -- **THEN** Bun server SHALL 通过 manifest 自动返回该资源并带有适当的 content type 和 content-addressable hash URL +- **WHEN** 客户端请求 `/assets/*` 路径下的前端资源 +- **THEN** 系统 SHALL 从 StaticAssets 的 files map 中查找并返回对应资源,Content-Type 根据扩展名推断 #### Scenario: 请求前端根路径 - **WHEN** 客户端请求 `/` -- **THEN** Bun server SHALL 通过 routes 中注册的 HTML import 返回前端入口 HTML 文档 +- **THEN** 系统 SHALL 返回 StaticAssets 中的 indexHtml,Content-Type 为 `text/html; charset=utf-8` ### Requirement: 生产缓存策略 -系统 SHALL 利用 Bun 内置的缓存机制为生产静态资源提供缓存策略。 +系统 SHALL 为生产静态资源提供基于文件名 content hash 的缓存策略。 #### Scenario: 请求前端入口 HTML -- **WHEN** 生产 Bun server 返回前端入口 HTML 文档 -- **THEN** 响应 SHALL 包含 Bun 自动生成的 ETag header +- **WHEN** 生产 server 返回前端入口 HTML 文档 +- **THEN** 响应 SHALL 包含 `Cache-Control: no-cache` header #### Scenario: 请求构建后的静态资源 -- **WHEN** 生产 Bun server 返回构建后的静态资源 -- **THEN** 响应 SHALL 包含 Bun 自动生成的 ETag header 和 content-addressable hash URL +- **WHEN** 生产 server 返回 `/assets/*` 路径下的静态资源 +- **THEN** 响应 SHALL 包含 `Cache-Control: public, max-age=31536000, immutable` header ### Requirement: 低风险安全响应头 系统 SHALL 在生产运行时的 JSON API 响应中附加低风险安全响应头;HTML 和静态资源响应由 Bun HTML import manifest 返回其内置 headers。 @@ -92,20 +92,20 @@ - **WHEN** 生产 Bun server 返回 `/health` 或 `/api/*` JSON 响应 - **THEN** 响应 SHALL 包含 `X-Content-Type-Options: nosniff` 和 `Referrer-Policy` headers -#### Scenario: 生产 HTML 和静态资源响应使用 Bun 内置 headers -- **WHEN** 生产 Bun server 返回前端 HTML 文档或构建后的静态资源 -- **THEN** 响应 SHALL 使用 Bun HTML import manifest 提供的内置 headers,不要求附加自定义安全 headers +#### Scenario: 生产静态资源响应 +- **WHEN** 生产 server 返回前端 HTML 文档或构建后的静态资源 +- **THEN** 响应 SHALL 不要求附加自定义安全 headers(仅需 Content-Type 和 Cache-Control) ### Requirement: SPA fallback 行为 -系统 SHALL 通过 routes 中注册的 `"/*"` HTML import 通配符为非 API 路径返回前端入口 HTML 文档。 +系统 SHALL 通过 fetch fallback 为非 API、非静态资源路径返回前端入口 HTML 文档。 #### Scenario: 刷新前端路由 -- **WHEN** 客户端请求前端路由,例如 `/dashboard` -- **THEN** routes 中的 `"/*"` 通配符 SHALL 返回前端入口 HTML 文档 +- **WHEN** 客户端请求不包含文件扩展名的非 API 路径(如 `/dashboard`) +- **THEN** fetch fallback SHALL 返回前端入口 HTML 文档 #### Scenario: 保留 API 错误语义 - **WHEN** 客户端请求未知的 `/api/*` 路由 -- **THEN** `/api/*` 通配符 MUST 返回 JSON 404 响应,而不是前端入口 HTML 文档 +- **THEN** routes 中的 `/api/*` 通配符 MUST 返回 JSON 404 响应,而不是前端入口 HTML 文档 ### Requirement: 优雅关机 系统 SHALL 在收到终止信号时正确清理资源。 diff --git a/openspec/specs/server-bootstrap/spec.md b/openspec/specs/server-bootstrap/spec.md index d086ee3..fcdd890 100644 --- a/openspec/specs/server-bootstrap/spec.md +++ b/openspec/specs/server-bootstrap/spec.md @@ -5,15 +5,15 @@ TBD - 统一服务启动引导函数,封装开发和生产模式的完整启 ## Requirements ### Requirement: 统一启动引导函数 -系统 SHALL 提供 `src/server/bootstrap.ts` 导出 `bootstrap(options: BootstrapOptions)` 函数,封装完整的服务启动序列:加载配置、创建 store、同步 targets、创建并启动 engine、启动 HTTP server、注册 shutdown handler。`bootstrap` SHALL 不接收或传递静态资源对象,前端资源由 Bun HTML import manifest 自动接管。 +系统 SHALL 提供 `src/server/bootstrap.ts` 导出 `bootstrap(options: BootstrapOptions)` 函数,封装完整的服务启动序列:加载配置、创建 store、同步 targets、创建并启动 engine、启动 HTTP server、注册 shutdown handler。 #### Scenario: 开发模式启动 - **WHEN** `dev.ts` 调用 `bootstrap({ configPath, mode: "development" })` - **THEN** 系统 SHALL 完成完整启动序列,不传入 staticAssets -#### Scenario: 生产模式启动 -- **WHEN** `main.ts` 调用 `bootstrap({ configPath, mode: "production" })` -- **THEN** 系统 SHALL 完成完整启动序列,并由 `server.ts` 中的 HTML import 路由接管前端资源 +#### Scenario: 生产模式启动(带静态资源) +- **WHEN** code-generated entry 调用 `bootstrap({ configPath, mode: "production", staticAssets })` +- **THEN** 系统 SHALL 完成完整启动序列,并将 staticAssets 传递给 startServer #### Scenario: 启动失败处理 - **WHEN** 启动过程中任何步骤抛出异常 @@ -24,11 +24,15 @@ TBD - 统一服务启动引导函数,封装开发和生产模式的完整启 - **THEN** bootstrap 注册的 shutdown handler SHALL 调用 engine.stop() 和 store.close() 后退出 ### Requirement: BootstrapOptions 接口 -`bootstrap` 函数 SHALL 接受 `BootstrapOptions` 参数,仅包含 `configPath: string` 和 `mode: RuntimeMode`。 +`bootstrap` 函数 SHALL 接受 `BootstrapOptions` 参数,包含 `configPath: string`、`mode: RuntimeMode` 和可选的 `staticAssets?: StaticAssets`。 -#### Scenario: 最小配置 +#### Scenario: 最小配置(开发模式) - **WHEN** 仅传入 configPath 和 mode -- **THEN** 系统 SHALL 正常启动 +- **THEN** 系统 SHALL 正常启动,startServer 不接收 staticAssets 参数 + +#### Scenario: 生产模式配置 +- **WHEN** 传入 configPath、mode 和 staticAssets +- **THEN** 系统 SHALL 将 staticAssets 传递给 startServer ### Requirement: dev.ts 和生产入口使用 bootstrap `dev.ts` 和 `src/server/main.ts` SHALL 调用 `bootstrap()` 而非各自维护启动序列。 diff --git a/openspec/specs/single-executable-packaging/spec.md b/openspec/specs/single-executable-packaging/spec.md index 5bc0195..54e625f 100644 --- a/openspec/specs/single-executable-packaging/spec.md +++ b/openspec/specs/single-executable-packaging/spec.md @@ -1,22 +1,26 @@ ## Purpose -定义将 Bun HTML import 前端资源与 Bun 后端打包为单个 standalone executable 的生产构建、运行配置和验证要求。 +定义将 Vite 构建的前端资源通过 code generation 嵌入 Bun 后端,打包为单个 standalone executable 的生产构建、运行配置和验证要求。 ## Requirements ### Requirement: 生产构建顺序 -生产构建 MUST 通过 Bun.build 的 HTML import 识别机制一步完成前端资源打包和后端编译。 +生产构建 MUST 通过三步流水线完成:Vite 前端构建 → code generation → Bun compile。 #### Scenario: 运行生产构建 - **WHEN** 开发者运行生产构建命令 -- **THEN** 系统 MUST 调用 Bun.build,自动识别 server 入口中的 HTML import 并完成前端 bundling 和后端编译 +- **THEN** 系统 MUST 依次执行 Vite build、资源导入 code generation、Bun.build compile,最终输出单可执行文件 -#### Scenario: 前端 bundling 失败 -- **WHEN** Bun.build 在处理 HTML import 中的前端资源时失败 -- **THEN** 系统 MUST 停止生产构建,且不能输出 stale executable +#### Scenario: Vite 构建失败 +- **WHEN** Vite build 步骤失败 +- **THEN** 系统 MUST 停止后续步骤,不生成 code generation 文件或 executable + +#### Scenario: Bun compile 失败 +- **WHEN** Bun.build compile 步骤失败 +- **THEN** 系统 MUST 清理 `.build/` 临时目录,不保留 stale executable ### Requirement: 单 executable 输出 -生产构建 SHALL 输出一个 standalone executable,其中包含 Bun 后端和通过 HTML import manifest 嵌入的前端资源。构建流程 SHALL 不再生成项目自定义中间产物目录,构建失败时 SHALL 不保留 stale executable。 +生产构建 SHALL 输出一个 standalone executable,其中包含 Bun 后端和通过 `import with { type: "file" }` 嵌入的 Vite 前端产出。 #### Scenario: 在目标机器运行 executable - **WHEN** 生成的 executable 在兼容目标平台上运行 @@ -24,19 +28,22 @@ #### Scenario: 服务嵌入的前端 - **WHEN** executable 收到前端根路径请求 -- **THEN** 它 SHALL 通过 Bun 内置的 HTML import manifest 机制服务前端资源,且不需要外部 `dist/` 目录 +- **THEN** 它 SHALL 通过内嵌的 Vite 构建产出服务前端资源,且不需要外部 `dist/` 目录 #### Scenario: 服务嵌入 API 和页面 - **WHEN** 生成的 executable 启动,且浏览器打开前端根路径 - **THEN** 页面 SHALL 展示同一个 executable 进程中 `/api/summary` 和 `/api/targets` 返回的数据 -#### Scenario: 构建成功不生成自定义中间产物 -- **WHEN** 生产构建成功完成并输出 executable -- **THEN** 系统 SHALL 不生成 `.build/` 静态资源清单或 server entry 中间产物 +### Requirement: 构建中间产物管理 +构建流程 SHALL 使用 `.build/` 临时目录存放 code generation 产物,构建完成后清理。 -#### Scenario: 构建失败时不保留 stale executable -- **WHEN** 生产构建在任意步骤失败 -- **THEN** 系统 SHALL 不输出上一次构建遗留的 stale executable +#### Scenario: 构建成功后清理中间产物 +- **WHEN** 生产构建成功完成并输出 executable +- **THEN** 系统 SHALL 删除 `.build/` 临时目录 + +#### Scenario: 构建失败时清理中间产物 +- **WHEN** 生产构建在 Bun compile 步骤失败 +- **THEN** 系统 SHALL 删除 `.build/` 临时目录和 stale executable ### Requirement: 外部运行时配置 executable MUST 将环境相关运行时配置保留在嵌入的前端和 server bundle 之外。 diff --git a/openspec/specs/static-asset-embedding/spec.md b/openspec/specs/static-asset-embedding/spec.md new file mode 100644 index 0000000..7417891 --- /dev/null +++ b/openspec/specs/static-asset-embedding/spec.md @@ -0,0 +1,58 @@ +# Static Asset Embedding + +定义构建时将 Vite 产出的前端静态资源嵌入 Bun 可执行文件的 code generation 流程和运行时静态资源服务逻辑。 + +## Purpose + +支持将 Vite 构建的前端资源通过 `import with { type: "file" }` 嵌入 Bun 可执行文件,实现单文件交付的同时保持正确的缓存策略和 Content-Type 处理。 + +## Requirements + +### Requirement: 构建时资源扫描与 Code Generation +构建脚本 SHALL 在 Vite build 完成后扫描 `dist/web/` 目录,自动生成 TypeScript 文件,为每个静态资源创建 `import ... with { type: "file" }` 声明。 + +#### Scenario: 生成资源导入文件 +- **WHEN** 构建脚本扫描 `dist/web/` 目录 +- **THEN** 系统 SHALL 在 `.build/static-assets.ts` 中为每个文件生成 `import fN from "" with { type: "file" }` 语句,并导出 `StaticAssets` 对象 + +#### Scenario: StaticAssets 对象结构 +- **WHEN** `static-assets.ts` 被生成 +- **THEN** 导出的对象 SHALL 包含 `indexHtml: Blob` 和 `files: Record` 两个字段,其中 files 的 key 为 URL 路径(如 `/assets/index-a1b2c3.js`) + +#### Scenario: 生成 production server entry +- **WHEN** 构建脚本生成资源导入文件后 +- **THEN** 系统 SHALL 在 `.build/server-entry.ts` 中生成 production 入口,import bootstrap、config 和 staticAssets 并调用 bootstrap + +### Requirement: 运行时静态资源服务 +系统 SHALL 提供 `serveStaticAsset` 函数,根据请求路径从 StaticAssets 中查找并返回对应资源。 + +#### Scenario: 请求根路径 +- **WHEN** 请求路径为 `/` +- **THEN** 系统 SHALL 返回 `indexHtml`,Content-Type 为 `text/html; charset=utf-8`,Cache-Control 为 `no-cache` + +#### Scenario: 请求已知静态资源 +- **WHEN** 请求路径匹配 `files` 中的某个 key +- **THEN** 系统 SHALL 返回对应 Blob,Content-Type 根据文件扩展名推断,Cache-Control 为 `public, max-age=31536000, immutable` + +#### Scenario: 请求未知带扩展名路径 +- **WHEN** 请求路径包含文件扩展名但未匹配任何已知资源 +- **THEN** 系统 SHALL 返回 404 响应 + +#### Scenario: SPA Fallback +- **WHEN** 请求路径不包含文件扩展名且不以 `/api/` 开头 +- **THEN** 系统 SHALL 返回 `indexHtml`(SPA fallback) + +### Requirement: Content-Type 推断 +系统 SHALL 根据文件扩展名推断正确的 Content-Type header。 + +#### Scenario: JavaScript 文件 +- **WHEN** 请求路径以 `.js` 或 `.mjs` 结尾 +- **THEN** Content-Type SHALL 为 `text/javascript; charset=utf-8` + +#### Scenario: CSS 文件 +- **WHEN** 请求路径以 `.css` 结尾 +- **THEN** Content-Type SHALL 为 `text/css; charset=utf-8` + +#### Scenario: SVG 文件 +- **WHEN** 请求路径以 `.svg` 结尾 +- **THEN** Content-Type SHALL 为 `image/svg+xml` diff --git a/openspec/specs/vite-frontend-bundling/spec.md b/openspec/specs/vite-frontend-bundling/spec.md new file mode 100644 index 0000000..bc4c963 --- /dev/null +++ b/openspec/specs/vite-frontend-bundling/spec.md @@ -0,0 +1,46 @@ +# Vite Frontend Bundling + +定义 Vite 作为前端构建工具的配置、产出结构和优化策略。 + +## Purpose + +使用 Vite 的 Rolldown 引擎完成前端打包,实现 code splitting、vendor chunk 分离和 CSS 优化,解决 Bun bundler 前端性能问题。 + +## Requirements + +### Requirement: Vite 前端构建配置 +系统 SHALL 使用 Vite 作为前端构建工具,配置文件位于项目根目录 `vite.config.ts`,以 `src/web` 为 root,产出到 `dist/web/`。 + +#### Scenario: 运行 Vite 生产构建 +- **WHEN** 构建脚本执行 `bunx --bun vite build` +- **THEN** Vite SHALL 将 `src/web/index.html` 及其引用的所有模块构建到 `dist/web/` 目录,包含 `index.html` 和 `assets/` 子目录 + +#### Scenario: 产出文件名包含 content hash +- **WHEN** Vite 构建完成 +- **THEN** `assets/` 目录下的 JS 和 CSS 文件名 SHALL 包含 content hash(如 `index-a1b2c3.js`) + +### Requirement: Code Splitting 策略 +系统 SHALL 配置 Vite 的 Rolldown code splitting,将 vendor 库分离为独立 chunks。 + +#### Scenario: React 相关库分离 +- **WHEN** Vite 构建完成 +- **THEN** `react`、`react-dom`、`scheduler` SHALL 被打包到名为 `vendor-react` 的独立 chunk + +#### Scenario: TDesign 相关库分离 +- **WHEN** Vite 构建完成 +- **THEN** `tdesign-react`、`tdesign-icons-react` 相关模块 SHALL 被打包到名为 `vendor-tdesign` 的独立 chunk + +#### Scenario: 图表库分离 +- **WHEN** Vite 构建完成 +- **THEN** `recharts` 和 `d3-*` 相关模块 SHALL 被打包到名为 `vendor-chart` 的独立 chunk + +### Requirement: CSS 处理 +系统 SHALL 通过 Vite 处理 CSS 导入,产出独立的 CSS 文件。 + +#### Scenario: CSS 文件产出 +- **WHEN** Vite 构建完成 +- **THEN** 所有 CSS 导入 SHALL 被提取为独立的 `.css` 文件到 `assets/` 目录 + +#### Scenario: CSS 压缩 +- **WHEN** Vite 执行生产构建 +- **THEN** 产出的 CSS 文件 SHALL 经过压缩处理 diff --git a/package.json b/package.json index 108c4b5..6ad8eba 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,9 @@ "type": "module", "private": true, "scripts": { - "dev": "bun --watch src/server/dev.ts", + "dev": "bun run scripts/dev.ts", + "dev:server": "bun --watch src/server/dev.ts", + "dev:web": "bunx --bun vite --host", "build": "bun run scripts/build.ts", "lint": "eslint .", "format": "prettier . --write", @@ -24,6 +26,7 @@ "@types/bun": "^1.3.13", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", @@ -36,7 +39,8 @@ "lint-staged": "^17.0.4", "prettier": "^3.8.3", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.2" + "typescript-eslint": "^8.59.2", + "vite": "^8.0.13" }, "dependencies": { "@sinclair/typebox": "^0.34.49", diff --git a/scripts/build.ts b/scripts/build.ts index 7a9cea6..246f9b4 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,33 +1,151 @@ -import { rm } from "node:fs/promises"; +import { readdir, rm, writeFile } from "node:fs/promises"; +import { join, relative } from "node:path"; import { fileURLToPath } from "node:url"; -const executablePath = fileURLToPath(new URL("../dist/dial-server", import.meta.url)); -const entrypoint = fileURLToPath(new URL("../src/server/main.ts", import.meta.url)); +const projectRoot = fileURLToPath(new URL("..", import.meta.url)); +const distWebDir = join(projectRoot, "dist/web"); +const buildDir = join(projectRoot, ".build"); +const executablePath = join(projectRoot, "dist/dial-server"); -await rm(executablePath, { force: true }); - -const target = process.env["BUN_TARGET"] ?? process.env["BUILD_TARGET"]; -const result = await Bun.build({ - compile: target - ? { - autoloadBunfig: true, - autoloadDotenv: true, - outfile: executablePath, - target: target as Bun.Build.CompileTarget, - } - : { - autoloadBunfig: true, - autoloadDotenv: true, - outfile: executablePath, - }, - entrypoints: [entrypoint], - minify: true, - sourcemap: "linked", -}); - -if (!result.success) { - console.error("构建失败:", result.logs); - process.exit(1); +async function build() { + try { + await viteBuild(); + await codeGeneration(); + await bunCompile(); + await cleanup(); + console.log(`Built executable: ${executablePath}`); + } catch (error) { + await cleanup(); + console.error("Build failed:", error); + process.exit(1); + } } -console.log(`Built executable: ${executablePath}`); +async function bunCompile() { + console.log("Step 3/3: Bun compile..."); + await rm(executablePath, { force: true }); + + const target = process.env["BUN_TARGET"] ?? process.env["BUILD_TARGET"]; + const result = await Bun.build({ + compile: target + ? { + autoloadBunfig: true, + autoloadDotenv: true, + outfile: executablePath, + target: target as Bun.Build.CompileTarget, + } + : { + autoloadBunfig: true, + autoloadDotenv: true, + outfile: executablePath, + }, + entrypoints: [join(buildDir, "server-entry.ts")], + minify: true, + sourcemap: "linked", + }); + + if (!result.success) { + console.error("Bun compile failed:", result.logs); + await cleanup(); + process.exit(1); + } +} + +async function cleanup() { + await rm(buildDir, { force: true, recursive: true }); +} + +async function codeGeneration() { + console.log("Step 2/3: Code generation..."); + await rm(buildDir, { force: true, recursive: true }); + await Bun.write(join(buildDir, ".gitkeep"), ""); + + const allFiles = await scanDir(distWebDir, "/"); + const importLines: string[] = []; + const fileEntries: string[] = []; + let indexHtmlVar = ""; + + for (let i = 0; i < allFiles.length; i++) { + const urlPath = allFiles[i]!; + const varName = `f${i}`; + const filePath = relative(buildDir, join(distWebDir, urlPath.slice(1))); + importLines.push(`import ${varName} from "./${filePath}" with { type: "file" };`); + + if (urlPath === "/index.html") { + indexHtmlVar = varName; + } else { + fileEntries.push(` "${urlPath}": Bun.file(${varName}),`); + } + } + + if (!indexHtmlVar) { + console.error("index.html not found in dist/web/"); + process.exit(1); + } + + const staticAssetsTs = [ + `import type { StaticAssets } from "../src/server/static";`, + "", + ...importLines, + "", + `export const staticAssets: StaticAssets = {`, + ` files: {`, + ...fileEntries, + ` },`, + ` indexHtml: Bun.file(${indexHtmlVar}),`, + `};`, + "", + ].join("\n"); + + await writeFile(join(buildDir, "static-assets.ts"), staticAssetsTs); + + const serverEntryTs = [ + `import { bootstrap } from "../src/server/bootstrap";`, + `import { readRuntimeConfig } from "../src/server/config";`, + `import { staticAssets } from "./static-assets";`, + "", + `async function main() {`, + ` const { configPath } = readRuntimeConfig();`, + ` await bootstrap({ configPath, mode: "production", staticAssets });`, + `}`, + "", + `void main().catch((error) => {`, + ` console.error("启动失败:", error instanceof Error ? error.message : error);`, + ` process.exit(1);`, + `});`, + "", + ].join("\n"); + + await writeFile(join(buildDir, "server-entry.ts"), serverEntryTs); +} + +async function scanDir(dir: string, prefix: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const paths: string[] = []; + for (const entry of entries) { + const fullPath = join(dir, entry.name); + const urlPath = `${prefix}${entry.name}`; + if (entry.isDirectory()) { + paths.push(...(await scanDir(fullPath, `${urlPath}/`))); + } else { + paths.push(urlPath); + } + } + return paths; +} + +async function viteBuild() { + console.log("Step 1/3: Vite build..."); + const proc = Bun.spawn(["bunx", "--bun", "vite", "build"], { + cwd: projectRoot, + stderr: "inherit", + stdout: "inherit", + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + console.error("Vite build failed"); + process.exit(1); + } +} + +await build(); diff --git a/scripts/dev.ts b/scripts/dev.ts new file mode 100644 index 0000000..a0f8133 --- /dev/null +++ b/scripts/dev.ts @@ -0,0 +1,26 @@ +import { fileURLToPath } from "node:url"; + +const projectRoot = fileURLToPath(new URL("..", import.meta.url)); + +const apiServer = Bun.spawn(["bun", "--watch", "src/server/dev.ts", ...process.argv.slice(2)], { + cwd: projectRoot, + stderr: "inherit", + stdout: "inherit", +}); + +const viteServer = Bun.spawn(["bunx", "--bun", "vite", "--host"], { + cwd: projectRoot, + stderr: "inherit", + stdout: "inherit", +}); + +function shutdown() { + apiServer.kill(); + viteServer.kill(); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); + +await Promise.race([apiServer.exited, viteServer.exited]); +shutdown(); diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 97baa01..8f29bd8 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import type { RuntimeMode } from "../shared/api"; import type { StartServerOptions } from "./server"; +import type { StaticAssets } from "./static"; import { loadConfig, type ResolvedConfig } from "./checker/config-loader"; import { ProbeEngine } from "./checker/engine"; @@ -26,6 +27,7 @@ export interface BootstrapDependencies { export interface BootstrapOptions { configPath: string; mode: RuntimeMode; + staticAssets?: StaticAssets; } type BootstrapEngine = Pick; @@ -69,6 +71,7 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr serve({ config: { host: config.host, port: config.port }, mode: options.mode, + staticAssets: options.staticAssets, store, }); } catch (error) { diff --git a/src/server/server.ts b/src/server/server.ts index 4965806..e95eae8 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1,33 +1,36 @@ import type { RuntimeMode } from "../shared/api"; import type { ProbeStore } from "./checker/store"; import type { RuntimeConfig } from "./config"; +import type { StaticAssets } from "./static"; -import homepage from "../web/index.html"; import { createApiError, jsonResponse } from "./helpers"; import { handleDashboard } from "./routes/dashboard"; import { handleHealth } from "./routes/health"; import { handleHistory } from "./routes/history"; import { handleMeta } from "./routes/meta"; import { handleMetrics } from "./routes/metrics"; +import { serveStaticAsset } from "./static"; export interface StartServerOptions { config: RuntimeConfig; mode: RuntimeMode; + staticAssets?: StaticAssets; store: ProbeStore; } export function startServer(options: StartServerOptions) { - const { config, mode, store } = options; + const { config, mode, staticAssets, store } = options; const server = Bun.serve({ - development: mode === "development" ? { console: true, hmr: true } : false, - fetch() { - return new Response("Not found", { status: 404 }); + fetch(req) { + if (staticAssets) { + return serveStaticAsset(new URL(req.url).pathname, staticAssets); + } + return new Response("Frontend is served by Vite dev server on :5173", { status: 404 }); }, hostname: config.host, port: config.port, routes: { - "/*": homepage, "/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }), "/api/dashboard": { GET: (req) => handleDashboard(new URL(req.url), store, mode), diff --git a/src/server/static.ts b/src/server/static.ts new file mode 100644 index 0000000..384931b --- /dev/null +++ b/src/server/static.ts @@ -0,0 +1,60 @@ +export interface StaticAssets { + files: Record; + indexHtml: Blob; +} + +const CONTENT_TYPES: Record = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +export function contentTypeFor(path: string): string { + const dot = path.lastIndexOf("."); + if (dot === -1) return "application/octet-stream"; + const ext = path.slice(dot); + return CONTENT_TYPES[ext] ?? "application/octet-stream"; +} + +export function hasFileExtension(path: string): boolean { + const lastSlash = path.lastIndexOf("/"); + const segment = lastSlash === -1 ? path : path.slice(lastSlash + 1); + return segment.includes("."); +} + +export function htmlResponse(html: Blob): Response { + return new Response(html, { + headers: { + "Cache-Control": "no-cache", + "Content-Type": "text/html; charset=utf-8", + }, + }); +} + +export function serveStaticAsset(pathname: string, assets: StaticAssets): Response { + if (pathname === "/") { + return htmlResponse(assets.indexHtml); + } + + const file = assets.files[pathname]; + if (file) { + return new Response(file, { + headers: { + "Cache-Control": "public, max-age=31536000, immutable", + "Content-Type": contentTypeFor(pathname), + }, + }); + } + + if (hasFileExtension(pathname)) { + return new Response("Not found", { status: 404 }); + } + + return htmlResponse(assets.indexHtml); +} diff --git a/tests/server/static.test.ts b/tests/server/static.test.ts new file mode 100644 index 0000000..8e654a8 --- /dev/null +++ b/tests/server/static.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test"; + +import { + contentTypeFor, + hasFileExtension, + htmlResponse, + serveStaticAsset, + type StaticAssets, +} from "../../src/server/static"; + +function createTestAssets(): StaticAssets { + return { + files: { + "/assets/index-a1b2c3.css": new Blob([".app{}"], { type: "text/css" }), + "/assets/index-a1b2c3.js": new Blob(["console.log(1)"], { type: "text/javascript" }), + "/assets/vendor-react-x9y8z7.js": new Blob(["react"], { type: "text/javascript" }), + "/favicon.svg": new Blob([""], { type: "image/svg+xml" }), + }, + indexHtml: new Blob([""], { type: "text/html" }), + }; +} + +describe("contentTypeFor", () => { + test("JavaScript 文件", () => { + expect(contentTypeFor("/assets/index-a1b2c3.js")).toBe("text/javascript; charset=utf-8"); + }); + + test("mjs 文件", () => { + expect(contentTypeFor("/assets/chunk.mjs")).toBe("text/javascript; charset=utf-8"); + }); + + test("CSS 文件", () => { + expect(contentTypeFor("/assets/style.css")).toBe("text/css; charset=utf-8"); + }); + + test("SVG 文件", () => { + expect(contentTypeFor("/icon.svg")).toBe("image/svg+xml"); + }); + + test("未知扩展名返回 octet-stream", () => { + expect(contentTypeFor("/file.xyz")).toBe("application/octet-stream"); + }); + + test("无扩展名返回 octet-stream", () => { + expect(contentTypeFor("/noext")).toBe("application/octet-stream"); + }); +}); + +describe("hasFileExtension", () => { + test("有扩展名", () => { + expect(hasFileExtension("/assets/index.js")).toBe(true); + expect(hasFileExtension("/favicon.svg")).toBe(true); + }); + + test("无扩展名", () => { + expect(hasFileExtension("/dashboard")).toBe(false); + expect(hasFileExtension("/")).toBe(false); + expect(hasFileExtension("/api/targets")).toBe(false); + }); +}); + +describe("htmlResponse", () => { + test("返回 HTML 响应带正确 headers", async () => { + const blob = new Blob([""]); + const response = htmlResponse(blob); + + expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8"); + expect(response.headers.get("Cache-Control")).toBe("no-cache"); + expect(await response.text()).toBe(""); + }); +}); + +describe("serveStaticAsset", () => { + test("根路径返回 indexHtml", async () => { + const assets = createTestAssets(); + const response = serveStaticAsset("/", assets); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8"); + expect(response.headers.get("Cache-Control")).toBe("no-cache"); + expect(await response.text()).toBe(""); + }); + + test("已知资源返回对应文件和 immutable 缓存", async () => { + const assets = createTestAssets(); + const response = serveStaticAsset("/assets/index-a1b2c3.js", assets); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("text/javascript; charset=utf-8"); + expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable"); + expect(await response.text()).toBe("console.log(1)"); + }); + + test("未知带扩展名路径返回 404", () => { + const assets = createTestAssets(); + const response = serveStaticAsset("/assets/missing.js", assets); + + expect(response.status).toBe(404); + }); + + test("SPA fallback — 无扩展名路径返回 indexHtml", async () => { + const assets = createTestAssets(); + const response = serveStaticAsset("/dashboard", assets); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8"); + expect(response.headers.get("Cache-Control")).toBe("no-cache"); + expect(await response.text()).toBe(""); + }); + + test("SVG 资源返回正确 Content-Type", () => { + const assets = createTestAssets(); + const response = serveStaticAsset("/favicon.svg", assets); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("image/svg+xml"); + expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable"); + }); + + test("CSS 资源返回正确 Content-Type", () => { + const assets = createTestAssets(); + const response = serveStaticAsset("/assets/index-a1b2c3.css", assets); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("text/css; charset=utf-8"); + }); +}); diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..6d52349 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,36 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + outDir: "../../dist/web", + rolldownOptions: { + output: { + codeSplitting: { + groups: [ + { + name: "vendor-react", + test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/, + }, + { + name: "vendor-tdesign", + test: /[\\/]node_modules[\\/](tdesign-react|tdesign-icons-react)[\\/]/, + }, + { + name: "vendor-chart", + test: /[\\/]node_modules[\\/](recharts|d3-.*)[\\/]/, + }, + ], + }, + }, + }, + }, + plugins: [react()], + root: "src/web", + server: { + proxy: { + "/api": "http://127.0.0.1:3000", + "/health": "http://127.0.0.1:3000", + }, + }, +});