From 6e485cc9916ba7d63935f83de7776e5d2c2edc2b Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Thu, 14 May 2026 00:23:37 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E8=BF=81=E7=A7=BB=20Bun=20fullstac?= =?UTF-8?q?k=20=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT.md | 297 ++++++++---------- README.md | 13 +- bun.lock | 80 ----- openspec/config.yaml | 2 +- openspec/specs/api-route-separation/spec.md | 37 +-- openspec/specs/bun-fullstack-routing/spec.md | 49 +++ openspec/specs/code-quality-gates/spec.md | 6 +- .../frontend-development-workflow/spec.md | 40 ++- openspec/specs/fullstack-app-runtime/spec.md | 62 ++-- openspec/specs/meta-api/spec.md | 12 +- openspec/specs/probe-api/spec.md | 12 +- openspec/specs/server-bootstrap/spec.md | 20 +- .../specs/single-executable-packaging/spec.md | 61 +--- package.json | 11 +- scripts/build.ts | 102 +----- scripts/dev.ts | 57 ---- scripts/smoke.ts | 168 ---------- src/server/app.ts | 91 ------ src/server/bootstrap.ts | 3 - src/server/helpers.ts | 17 +- src/server/main.ts | 12 + src/server/middleware.ts | 9 +- src/server/routes/health.ts | 10 +- src/server/routes/history.ts | 6 +- src/server/routes/meta.ts | 4 +- src/server/routes/summary.ts | 4 +- src/server/routes/targets.ts | 4 +- src/server/routes/trend.ts | 6 +- src/server/server.ts | 47 ++- src/server/static.ts | 55 ---- src/web/css.d.ts | 1 + src/web/index.html | 2 +- tests/server/app.test.ts | 129 +++----- tests/server/bootstrap.test.ts | 17 +- tsconfig.json | 2 +- vite.config.ts | 36 --- 36 files changed, 403 insertions(+), 1081 deletions(-) create mode 100644 openspec/specs/bun-fullstack-routing/spec.md delete mode 100644 scripts/dev.ts delete mode 100644 scripts/smoke.ts delete mode 100644 src/server/app.ts create mode 100644 src/server/main.ts delete mode 100644 src/server/static.ts create mode 100644 src/web/css.d.ts delete mode 100644 vite.config.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f8753aa..b66cb38 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -20,16 +20,16 @@ ```text src/ server/ - app.ts Bun HTTP 路由入口(路由分发 + API 汇聚、StaticAssets 接口定义) bootstrap.ts 后端统一启动引导(loadConfig → store → engine → startServer → shutdown) config.ts CLI 参数解析(仅提取配置文件路径) - dev.ts 开发模式启动入口 - server.ts HTTP server 启动工厂(接收 StartServerOptions) + dev.ts 开发模式启动入口(mode: "development",HMR 自动注入) + main.ts 生产模式启动入口(mode: "production",安全头启用) + server.ts HTTP server 启动工厂(Bun.serve routes 声明式路由 + HTML import) helpers.ts 共享响应格式化工具(见下方函数清单) - middleware.ts API 参数校验中间件(guardGetHead、validateTargetId、validateTimeRange、validatePagination) - static.ts 静态资源服务与 SPA fallback - routes/ API 路由 handler(按端点拆分,签名因端点而异) + middleware.ts API 参数校验中间件(validateTargetId、validateTimeRange、validatePagination) + routes/ API 路由 handler(按端点拆分) health.ts GET /health(无 store 参数) + meta.ts GET /api/meta summary.ts GET /api/summary targets.ts GET /api/targets history.ts GET /api/targets/:id/history @@ -61,7 +61,7 @@ src/ command/ Command Checker(自包含模块,含 types/schema/execute/expect/validate/text) shared/ api.ts 前后端共享 TypeScript 类型 - web/ Vite + React 前端 Dashboard + web/ React 前端 Dashboard(通过 Bun HTML import 集成) app.tsx 根组件(编排全局状态与布局) main.tsx 入口(QueryClient 挂载 + ErrorBoundary + ReactQueryDevtools) styles.css 全局样式与自定义 CSS 变量 @@ -78,7 +78,7 @@ src/ utils/ 前端工具函数 time.ts 时间处理(subtractHours) stats.ts 趋势统计计算(computeTrendStats) -scripts/ 开发、构建、schema 生成和 smoke test 脚本 + scripts/ 构建、schema 生成和清理脚本 tests/ Bun test 测试(结构镜像 src 目录) openspec/ OpenSpec 变更与规格文档 probe-config.schema.json 用户配置 JSON Schema 导出物(用于 IDE 自动补全和校验) @@ -98,12 +98,12 @@ probe-config.schema.json 用户配置 JSON Schema 导出物(用于 IDE 自动 ``` 启动流程: - dev.ts / build entry → readRuntimeConfig(cli args, 仅提取 configPath) - → bootstrap({ configPath, mode, staticAssets? }) + dev.ts / main.ts → readRuntimeConfig(cli args, 仅提取 configPath) + → bootstrap({ configPath, mode }) → loadConfig(yaml) → ResolvedConfig{ host, port, dataDir, maxConcurrentChecks, retentionMs, targets } → ProbeStore(db) → store.syncTargets(targets) → ProbeEngine(store, targets, maxConcurrentChecks, retentionMs) → engine.start() - → startServer({ config, mode, store, staticAssets? }) + → startServer({ config, mode, store }) → 注册 SIGINT/SIGTERM shutdown(engine.stop + store.close) 运行时: @@ -113,8 +113,9 @@ probe-config.schema.json 用户配置 JSON Schema 导出物(用于 IDE 自动 数据清理: 定时 prune(retentionMs),每小时执行一次 HTTP 请求: - Request → app.ts(路由分发) → routes/*.ts(handler) + Request → Bun.serve routes 声明式匹配 → routes/*.ts(handler) → middleware.ts(参数校验) → helpers.ts(响应格式化) → Response + 前端: "/*": homepage (HTML import) → SPA fallback + HMR(开发模式) ``` ### 1.2 库使用优先级 @@ -133,50 +134,63 @@ HTTP 请求: ### 1.3 API 路由开发 -路由文件位于 `src/server/routes/`,每个端点一个文件。handler 函数签名因端点而异: +路由文件位于 `src/server/routes/`,每个端点一个文件。路由通过 `server.ts` 的 `Bun.serve({ routes })` 声明式注册,使用 per-method handler 对象: ```typescript -// 无 store 的路由(健康检查不依赖数据库) -export function handleHealth(method: string, mode: RuntimeMode): Response; +// server.ts 中的路由注册 +routes: { + "/*": homepage, // HTML import,SPA fallback + "/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }), + "/api/meta": { GET: () => handleMeta(mode) }, + "/api/summary": { GET: () => handleSummary(store, mode) }, + "/api/targets": { GET: () => handleTargets(store, mode) }, + "/api/targets/:id/history": { GET: (req) => handleHistory(req.params.id, new URL(req.url), store, mode) }, + "/api/targets/:id/trend": { GET: (req) => handleTrend(req.params.id, new URL(req.url), store, mode) }, + "/health": { GET: () => handleHealth(mode) }, +} +``` + +Handler 函数签名因端点而异: + +```typescript +// 无 store 的路由 +export function handleHealth(mode: RuntimeMode): Response; +export function handleMeta(mode: RuntimeMode): Response; // 仅有 store 的路由 -export function handleSummary(store: ProbeStore, method: string, mode: RuntimeMode): Response; -export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMode): Response; +export function handleSummary(store: ProbeStore, mode: RuntimeMode): Response; +export function handleTargets(store: ProbeStore, mode: RuntimeMode): Response; // 带 target ID 和查询参数的路由 -export function handleHistory(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response; -export function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response; +export function handleHistory(idStr: string, url: URL, store: ProbeStore, mode: RuntimeMode): Response; +export function handleTrend(idStr: string, url: URL, store: ProbeStore, mode: RuntimeMode): Response; ``` **请求处理流程**: -1. `app.ts` 的 `createFetchHandler` 作为总入口,根据 URL pattern 匹配路由 -2. `/health` 路由独立处理,不经过 `guardGetHead`(使用 `helpers.ts` 的 `allowsGetHead` 自行校验方法) -3. `/api/*` 路由统一经过 `guardGetHead` 做方法检查(仅允许 GET/HEAD),返回 `null` 表示通过 -4. 各 handler 内部通过 `middleware.ts` 提供的 `validateTargetId`、`validateTimeRange`、`validatePagination` 做参数校验,`pageSize` 最大值为 `200` -5. 校验函数返回 `Response` 实例表示校验失败(直接返回),返回数据对象表示通过 -6. 业务逻辑通过 `store` 查询数据,用 `helpers.ts` 的 `jsonResponse`、`mapCheckResult`、`formatDuration` 等格式化输出 +1. `Bun.serve` 的 `routes` 对象按路径 + HTTP 方法匹配请求 +2. 未匹配方法的请求落入 `/api/*` 通配符(返回 404) +3. 各 handler 内部通过 `middleware.ts` 提供的 `validateTargetId`、`validateTimeRange`、`validatePagination` 做参数校验,`pageSize` 最大值为 `200` +4. 校验函数返回 `Response` 实例表示校验失败(直接返回),返回数据对象表示通过 +5. 业务逻辑通过 `store` 查询数据,用 `helpers.ts` 的 `jsonResponse`、`mapCheckResult`、`formatDuration` 等格式化输出 **新增路由步骤**: 1. 在 `src/server/routes/` 下创建 `.ts` 2. 实现 handler 函数并 export -3. 在 `app.ts` 的 `createFetchHandler` 中注册路径匹配和调用 +3. 在 `server.ts` 的 `routes` 对象中注册路径和 method handler 4. 在 `tests/server/app.test.ts` 中添加对应测试 ### 1.4 共享工具 - **`helpers.ts`**:跨路由共用的响应工具函数 - - `allowsGetHead(method)` — 判断是否为 GET/HEAD 方法 - `createApiError(error, status)` — 构造 API 错误体 - `createHeaders(mode, init)` — 创建响应 Headers(生产模式附加安全头) - `createHealthResponse()` — 构造健康检查响应 - `formatDuration(ms)` — 毫秒转为可读时长字符串 - - `jsonResponse(body, options)` — JSON 响应构造(自动处理 HEAD 空体) + - `jsonResponse(body, options)` — JSON 响应构造 - `mapCheckResult(row)` — 数据库行转 API CheckResult - - `methodNotAllowedResponse(allow, mode)` — 构造 405 响应 -- **`middleware.ts`**:API 参数校验函数(`guardGetHead`、`validateTargetId`、`validateTimeRange`、`validatePagination`,其中 `pageSize` 上限为 `200`) -- **`static.ts`**:生产模式下的静态资源服务与 SPA fallback +- **`middleware.ts`**:API 参数校验函数(`validateTargetId`、`validateTimeRange`、`validatePagination`,其中 `pageSize` 上限为 `200`) ### 1.5 类型定义规范 @@ -403,7 +417,7 @@ TcpChecker implements Checker □ probes.example.yaml — 配置示例 □ bun run schema + bun run schema:check — Schema 导出同步 □ bun run check — 全量质量检查通过 -□ bun run verify — 完整验证(含 build + smoke test) +□ bun run verify — 完整验证(check + build) □ README.md — 用户文档 □ DEVELOPMENT.md — 项目结构目录树 ``` @@ -496,7 +510,7 @@ CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs) ### 1.11 错误模式 -- **API 错误**:`{ error: "描述", status: }`,状态码 400/404/405/503 +- **API 错误**:`{ error: "描述", status: }`,状态码 400/404/503 - **CheckFailure**:`{ kind: "error"|"mismatch", phase, path, expected?, actual?, message }` - **错误处理**:expect 校验失败记录首个失败原因;网络/超时/进程崩溃统一为 `kind:"error"`,请求/TLS/timeout 错误归属 `phase:"request"`,body 超限/解码/解析错误归属 `phase:"body"` - **日志**:解析失败等非致命异常用 `console.warn`,启动失败用 `console.error` + `process.exit(1)` @@ -522,14 +536,14 @@ CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs) | 层面 | 技术 | 用途 | | ------ | --------------------------------------------------- | ---------------------------- | | 框架 | React 19 | UI 组件开发 | -| 构建 | Vite 8 | 开发服务与生产构建 | +| 构建 | Bun HTML import(fullstack 模式) | 开发服务与生产构建 | | 语言 | TypeScript 6 | 类型安全 | | UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 | | 数据层 | TanStack Query (React Query) + React Query Devtools | 服务端状态管理与自动轮询 | | 图表 | Recharts | 拨测趋势折线图与状态环状图 | | 路由 | 无(单页面 Dashboard) | 仅需 Drawer/Tab 做页面内导航 | -**不引入的依赖**:React Router(单页面场景不需要)、状态管理库(TanStack Query 即服务端状态层,组件内用 `useState` 足够) +**不引入的依赖**:React Router(单页面场景不需要)、状态管理库(TanStack Query 即服务端状态层,组件内用 `useState` 足够)、Vite(已由 Bun 原生 fullstack 替代) ### 2.2 组件树与数据流 @@ -729,81 +743,47 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps) ### 3.1 开发期运行 -#### 同时启动前后端 - ```bash bun run dev probes.yaml ``` -`scripts/dev.ts` 通过 `Bun.spawn` 同时启动两个子进程: +`bun --watch src/server/dev.ts` 启动单进程 fullstack 开发服务器: -``` -bun run dev probes.yaml -├── bun run dev:server probes.yaml → Bun HTTP 后端(默认 3000 端口) -└── bun run dev:web → Vite 前端开发服务器(5173 端口) -``` - -- 任一子进程退出会导致整体退出 -- `SIGINT`/`SIGTERM` 信号会同时终止两个子进程 -- `BACKEND_PORT` 环境变量可覆盖后端端口 - -#### 分别启动 - -```bash -# 启动后端(含 watch 模式自动重启) -bun run dev:server probes.yaml - -# 另开终端启动前端 -bun run dev:web -``` +- 后端 API + 前端 SPA 在同一端口(默认 3000) +- `development` 模式自动注入 HMR,前端修改即时热更新 +- `--watch` 监听后端文件变更自动重启 +- 访问 `http://127.0.0.1:3000` 即可使用完整应用 ### 3.2 前后端集成方式 -#### 开发期代理 +#### 统一进程架构 -Vite 配置了开发代理(`vite.config.ts`)和代码分割策略: +前后端通过 Bun 的 HTML import 机制集成为单进程应用: ```typescript -// 开发代理 -server: { - proxy: { - "/api": { - target: `http://127.0.0.1:${backendPort}`, - changeOrigin: true, - }, +// server.ts +import homepage from "../web/index.html"; + +const server = Bun.serve({ + development: mode === "development" ? { hmr: true, console: true } : false, + routes: { + "/*": homepage, // SPA fallback(开发模式自动注入 HMR) + "/api/*": () => ..., // API 通配符(未匹配路由返回 404) + "/api/summary": { GET: () => handleSummary(store, mode) }, + "/health": { GET: () => handleHealth(mode) }, + // ... }, -} - -// 生产代码分割(rolldownOptions.output.codeSplitting.groups) -// vendor-react: react/react-dom/scheduler -// vendor-tdesign: tdesign -// vendor-chart: recharts/d3-* +}); ``` -前端访问 `/api/*` 时,Vite 开发服务器自动转发到后端 `http://127.0.0.1:${backendPort}`,无需 CORS 配置。 +- 开发模式(`development: { hmr: true, console: true }`):Bun 自动为 HTML import 注入 HMR client,前端修改无需手动刷新,并将浏览器 console 回显到终端 +- 生产模式:HTML 及其引用的 JS/CSS 资源在 `bun build --compile` 时自动打包进可执行文件 -前端开发地址为 `http://127.0.0.1:5173`(严格端口 `strictPort: true`)。 +#### 路由优先级 -后端在开发模式下不提供静态资源服务,访问 `http://127.0.0.1:3000` 会提示"请通过 Vite 前端地址访问"。 +Bun routes 的匹配规则:具体路径 > 通配符。`/api/summary` 优先于 `/api/*`,`/health` 优先于 `/*`。 -#### 生产期集成 - -生产可执行文件是单体应用:前端静态资源嵌入 binary(通过 `StaticAssets` 接口:`files: Record` + `indexHtml: Blob`),后端同时提供 API 和静态文件服务。 - -``` -./dist/dial-server probes.yaml - -启动后: - 访问 http://127.0.0.1:3000/ → 返回前端 SPA(index.html) - 访问 http://127.0.0.1:3000/api/* → 返回后端 API - 访问 /assets/* → 返回带不可变缓存的静态资源 -``` - -SPA fallback 逻辑(`src/server/static.ts`): - -- `/` → index.html -- 匹配 `/assets/*` → 返回对应文件(未匹配则 404) -- 其他路径(如 `/dashboard`)→ fallback 到 index.html(SPA 路由) +未匹配 method 的请求(如 POST /api/summary)会落入 `/api/*` 通配符返回 404。 ### 3.3 构建打包 @@ -813,33 +793,28 @@ SPA fallback 逻辑(`src/server/static.ts`): bun run build ``` -#### 构建流程详解 +#### 构建流程 -`scripts/build.ts` 执行以下步骤: +`scripts/build.ts` 执行单步构建: ``` -1. vite build - ├── 入口:src/web/index.html - └── 输出:dist/web/(index.html + assets/) - -2. 生成 .build/static-assets.ts(临时文件) - ├── import Vite 产物为 Bun.file - └── 导出 staticAssets: StaticAssets 对象 - -3. 生成 .build/server-entry.ts(临时文件) - └── import bootstrap + staticAssets,调用 production bootstrap,作为 Bun.build 入口 - -4. Bun.build({ compile, minify, sourcemap: "linked" }) - └── 输出:dist/dial-server(单文件可执行 binary) +Bun.build({ + compile: { outfile: "dist/dial-server" }, + entrypoints: ["src/server/main.ts"], + minify: true, + sourcemap: "linked", +}) ``` +- 入口为 `src/server/main.ts`(`mode: "production"`,启用安全头) +- HTML import 的前端资源自动打包进可执行文件(Bun 自动生成 manifest) +- 无需中间产物目录,一步生成最终 binary + #### 产物 -| 产物 | 用途 | -| ------------------ | -------------------------- | -| `dist/dial-server` | 生产可执行文件 | -| `dist/web/` | Vite 构建产物(中间产物) | -| `.build/` | 临时生成文件(构建后清理) | +| 产物 | 用途 | +| ------------------ | ---------------------------------------- | +| `dist/dial-server` | 生产可执行文件(含前端资源,单文件部署) | #### 构建参数 @@ -853,11 +828,17 @@ bun run build ./dist/dial-server probes.yaml ``` +启动后: + +- 访问 `http://127.0.0.1:3000/` → 返回前端 SPA +- 访问 `http://127.0.0.1:3000/api/*` → 返回后端 API +- 访问 `/dashboard` 等前端路由 → SPA fallback 到 index.html + #### 清理 ```bash bun run clean -# 清理 dist/ 构建产物、.build/ 缓存和 *.bun-build 临时文件 +# 清理 dist/ 构建产物和 *.bun-build 临时文件 ``` ### 3.4 开发工作流 @@ -865,8 +846,8 @@ bun run clean #### 日常开发循环 ```bash -bun run dev probes.yaml # 启动开发环境 -# 修改代码 → Vite HMR(前端)/ bun --watch(后端自动重启) +bun run dev probes.yaml # 启动开发环境(单进程,含 HMR) +# 修改前端代码 → HMR 热更新 / 修改后端代码 → --watch 自动重启 bun run check # 提交前运行完整质量检查 ``` @@ -874,44 +855,30 @@ bun run check # 提交前运行完整质量检查 ```bash bun run verify -# = bun run check + bun run build + bun run test:smoke +# = bun run check + bun run build ``` -`verify` 适合 CI 或正式提交前,会完整验证类型检查、lint、格式、单元测试、构建、smoke test。 +`verify` 适合 CI 或正式提交前,会完整验证类型检查、lint、格式、单元测试和生产构建。 -### 3.5 Smoke Test +### 3.5 Executable/E2E 验证 -```bash -bun run test:smoke -``` - -`scripts/smoke.ts` 构建后验证流程: - -1. 动态分配空闲端口 -2. 用临时配置文件启动 `dist/dial-server` -3. 等待健康检查通过 -4. 验证所有 API 端点返回正确数据 -5. 验证静态资源服务(含 SPA fallback 和 404 处理) -6. 验证安全 headers -7. 测试结束清理临时目录和进程 +原 `scripts/smoke.ts` 覆盖过薄,已从当前工作流移除。后续如需验证 production executable 的 API、HTML import manifest、SPA fallback 和静态资源行为,应重新设计独立的 executable/E2E 测试。 ### 3.6 脚本说明 -| 脚本 | 文件 | 说明 | -| ---------------------- | ----------------------------------- | ------------------------------- | -| `bun run dev` | `scripts/dev.ts` | 同时启动前后端开发服务 | -| `bun run build` | `scripts/build.ts` | Vite 构建 + 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 test:smoke` | `scripts/smoke.ts` | 构建后的端到端验证 | -| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 | +| 脚本 | 文件 | 说明 | +| ---------------------- | ----------------------------------- | ----------------------------------- | +| `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` | 清理构建缓存与临时文件 | ### 3.7 环境变量 -| 变量 | 用途 | 默认值 | -| --------------------------- | ---------------------------------------------------- | -------- | -| `PORT`/`BACKEND_PORT` | 后端监听端口(开发期 Vite 代理目标、生产期监听端口) | `3000` | -| `BUN_TARGET`/`BUILD_TARGET` | 交叉编译目标平台(仅在 `bun run build` 时有效) | 当前平台 | +| 变量 | 用途 | 默认值 | +| --------------------------- | ----------------------------------------------- | -------- | +| `BUN_TARGET`/`BUILD_TARGET` | 交叉编译目标平台(仅在 `bun run build` 时有效) | 当前平台 | ### 3.8 项目配置文件 @@ -919,7 +886,6 @@ bun run test:smoke | ---------------------- | ---------------------------------------------- | | `package.json` | 项目信息、脚本、依赖声明 | | `tsconfig.json` | TypeScript 配置(ESNext 模块、严格模式) | -| `vite.config.ts` | Vite 开发代理与构建配置(含代码分割策略) | | `eslint.config.js` | ESLint 规则(含前端不得 import server 的检查) | | `commitlint.config.js` | commitlint 提交信息格式校验 | | `.prettierrc.json` | Prettier 格式化规则(`printWidth: 120`) | @@ -936,17 +902,16 @@ bun run test:smoke ### 3.10 目录约定 -| 目录 | 约定 | -| ------------- | -------------------------------------------- | -| `src/server/` | 后端代码,不能 import `src/web/` | -| `src/web/` | 前端代码,不能 import `src/server/` | -| `src/shared/` | 前后端共享类型,双向可引用 | -| `scripts/` | 独立运行脚本,可 import 项目源码 | -| `tests/` | 测试目录,结构镜像 src 目录 | -| `dist/` | 构建产物(gitignore) | -| `.build/` | 构建临时文件(gitignore) | -| `openspec/` | OpenSpec 变更管理与规格文档 | -| `data/` | 默认数据目录(gitignore,运行期生成 SQLite) | +| 目录 | 约定 | +| ------------- | ---------------------------------------------------- | +| `src/server/` | 后端代码,不能 import `src/web/`(HTML import 除外) | +| `src/web/` | 前端代码,不能 import `src/server/` | +| `src/shared/` | 前后端共享类型,双向可引用 | +| `scripts/` | 独立运行脚本,可 import 项目源码 | +| `tests/` | 测试目录,结构镜像 src 目录 | +| `dist/` | 构建产物(gitignore) | +| `openspec/` | OpenSpec 变更管理与规格文档 | +| `data/` | 默认数据目录(gitignore,运行期生成 SQLite) | --- @@ -986,15 +951,15 @@ bun run check # 一键运行 schema:check + typecheck + lint + test ### TypeScript 严格标志 -| 标志 | 值 | 说明 | -| ------------------------------------ | ----- | -------------------------------------------------------------------------- | -| `strict` | true | 全局严格模式 | -| `noUnusedLocals` | true | 未使用局部变量视为错误 | -| `noUnusedParameters` | false | 保留关闭(路由 handler 统一签名需要,如 `handleXxx(store, method, mode)`) | -| `noPropertyAccessFromIndexSignature` | true | 禁止通过点号访问索引签名属性,强制使用括号语法 | -| `noUncheckedIndexedAccess` | true | 数组/Map 访问必须运行时真值检查 | -| `noImplicitOverride` | true | 子类覆盖父类方法时必须显式使用 `override` 关键字 | -| `verbatimModuleSyntax` | true | 强制 `import type` 纯类型导入 | +| 标志 | 值 | 说明 | +| ------------------------------------ | ----- | ------------------------------------------------ | +| `strict` | true | 全局严格模式 | +| `noUnusedLocals` | true | 未使用局部变量视为错误 | +| `noUnusedParameters` | false | 保留关闭(路由 handler 统一签名需要) | +| `noPropertyAccessFromIndexSignature` | true | 禁止通过点号访问索引签名属性,强制使用括号语法 | +| `noUncheckedIndexedAccess` | true | 数组/Map 访问必须运行时真值检查 | +| `noImplicitOverride` | true | 子类覆盖父类方法时必须显式使用 `override` 关键字 | +| `verbatimModuleSyntax` | true | 强制 `import type` 纯类型导入 | ### Git Hooks @@ -1013,7 +978,7 @@ bun run check # 一键运行 schema:check + typecheck + lint + test ```bash bun run check # 日常开发(类型检查 + lint(含格式) + 单元测试) -bun run verify # 完整验证(check + 构建 + smoke test) +bun run verify # 完整验证(check + 构建) ``` ## 已知限制 diff --git a/README.md b/README.md index 89336e1..8cf031d 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,17 @@ cp probes.example.yaml probes.yaml bun run dev probes.yaml ``` -`bun run dev` 会同时启动 Bun 后端和 Vite 前端。开发期请打开 Vite 前端地址 `http://127.0.0.1:5173`。 +`bun run dev` 启动单进程 fullstack 开发服务器(后端 API + 前端 SPA + HMR),访问 `http://127.0.0.1:3000`。 -也可以分别运行: +## 开发验证 ```bash -bun run dev:server probes.yaml -bun run dev:web +bun run check # schema:check + typecheck + lint + bun test +bun run verify # check + build ``` +`verify` 会基于当前源码重新构建生产 executable。原 smoke test 已移除,executable/E2E 验证后续单独补充。 + ## 配置文件 程序通过 YAML 配置文件定义所有运行参数: @@ -200,8 +202,7 @@ API 错误返回 `ApiErrorResponse` 格式: | 状态码 | 触发场景 | | ------ | ------------------------------------------------------------------------------------------ | | 400 | 参数格式错误(无效 ID、from/to 缺失或格式错误、page/pageSize 非正整数、pageSize 超过 200) | -| 404 | 目标不存在、API 路由未匹配 | -| 405 | 非 GET 方法请求 API 路由 | +| 404 | 目标不存在、API 路由未匹配、非 GET 方法请求 API 路由 | ## 运行参数 diff --git a/bun.lock b/bun.lock index 2f9aa93..52193f4 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,6 @@ "@types/bun": "^1.3.13", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", @@ -40,7 +39,6 @@ "prettier": "^3.8.3", "typescript": "^6.0.3", "typescript-eslint": "^8.59.2", - "vite": "^8.0.11", }, }, }, @@ -159,46 +157,12 @@ "@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.128.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.128.0.tgz", {}, "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ=="], - "@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.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", { "os": "android", "cpu": "arm64" }, "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ=="], - - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ=="], - - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g=="], - - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw=="], - - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "arm" }, "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg=="], - - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ=="], - - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug=="], - - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg=="], - - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA=="], - - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "x64" }, "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw=="], - - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "x64" }, "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA=="], - - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz", { "os": "none", "cpu": "arm64" }, "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A=="], - - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg=="], - - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ=="], - - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz", { "os": "win32", "cpu": "x64" }, "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg=="], - - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], - "@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=="], @@ -319,8 +283,6 @@ "@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.1", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "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-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], - "@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=="], @@ -455,8 +417,6 @@ "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=="], @@ -567,8 +527,6 @@ "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=="], @@ -723,30 +681,6 @@ "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=="], @@ -777,8 +711,6 @@ "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=="], @@ -841,8 +773,6 @@ "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=="], @@ -893,8 +823,6 @@ "rfdc": ["rfdc@1.4.1", "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], - "rolldown": ["rolldown@1.0.0-rc.18", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.18.tgz", { "dependencies": { "@oxc-project/types": "=0.128.0", "@rolldown/pluginutils": "1.0.0-rc.18" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-x64": "1.0.0-rc.18", "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg=="], - "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=="], @@ -931,8 +859,6 @@ "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=="], @@ -1003,8 +929,6 @@ "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.11", "https://registry.npmmirror.com/vite/-/vite-8.0.11.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.0-rc.18", "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-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow=="], - "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=="], @@ -1057,8 +981,6 @@ "@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=="], @@ -1095,8 +1017,6 @@ "prop-types/react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", {}, "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw=="], - "tdesign-react/@babel/runtime": ["@babel/runtime@7.26.10", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.26.10.tgz", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw=="], "tdesign-react/react-is": ["react-is@18.3.1", "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], diff --git a/openspec/config.yaml b/openspec/config.yaml index e269907..4938a64 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -10,7 +10,7 @@ context: | - 这是基于bun实现的前端后一体化项目,使用bun作为唯一包管理器,严禁使用pnpm、npm,使用bunx运行工具,严禁使用npx、pnpx - src/server目录下是基于bun实现的后端代码 - 后端库使用优先级:Bun 内置 API > es-toolkit > 主流三方库 > 项目公共工具 > 自行实现 - - src/web目录下是基于vite、react、TDesign实现的前端代码 + - src/web目录下是基于Bun HTML import、React、TDesign实现的前端代码 - 前端样式开发优先级:TDesign组件 > 组件props > TDesign CSS tokens(--td-*) > styles.css CSS类 > 自行开发组件 - 前端严禁:组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值 - Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明 diff --git a/openspec/specs/api-route-separation/spec.md b/openspec/specs/api-route-separation/spec.md index 5cb06df..09712e1 100644 --- a/openspec/specs/api-route-separation/spec.md +++ b/openspec/specs/api-route-separation/spec.md @@ -1,12 +1,12 @@ ## Purpose -定义后端 API 路由的组织规范:按端点拆分为独立 handler、共享响应工具集中管理、参数校验逻辑抽取为中间件、静态资源服务独立维护。 +定义后端 API 路由的组织规范:按端点拆分为独立 handler、共享响应工具集中管理、路径参数由 Bun routes 解析,静态资源服务由 HTML import manifest 管理。 ## Requirements ### Requirement: 路由按职责拆分 -系统 SHALL 将 HTTP 路由处理逻辑按 API 端点拆分为独立模块,每个模块导出一个 handler 函数供 app.ts 统一注册。 +系统 SHALL 将 HTTP 路由处理逻辑按 API 端点拆分为独立模块,每个模块导出 route handler 函数供 routes 对象统一注册。 #### Scenario: health 端点独立路由 - **WHEN** 客户端请求 `GET /health` @@ -22,11 +22,11 @@ #### Scenario: history 端点独立路由 - **WHEN** 客户端请求 `GET /api/targets/:id/history?from=ISO&to=ISO` -- **THEN** `routes/history.ts` 导出的 handler 负责处理,包含参数校验、store 查询和 HistoryResponse 返回 +- **THEN** `routes/history.ts` 导出的 handler 负责处理,通过 `req.params.id` 获取路径参数,包含参数校验、store 查询和 HistoryResponse 返回 #### Scenario: trend 端点独立路由 - **WHEN** 客户端请求 `GET /api/targets/:id/trend?from=ISO&to=ISO` -- **THEN** `routes/trend.ts` 导出的 handler 负责处理,包含参数校验、store 查询和 TrendPoint[] 返回 +- **THEN** `routes/trend.ts` 导出的 handler 负责处理,通过 `req.params.id` 获取路径参数,包含参数校验、store 查询和 TrendPoint[] 返回 ### Requirement: 共享辅助函数集中管理 系统 SHALL 将跨路由共享的响应格式化函数抽取到 helpers.ts 模块,单一职责、集中管理。 @@ -43,36 +43,17 @@ - **WHEN** 需要将 StoredCheckResult 映射为 API CheckResult - **THEN** 从 `helpers.ts` 导入 `mapCheckResult` 函数,处理 failure JSON 解析和格式转换 -### Requirement: 参数校验逻辑抽取为中间件 -系统 SHALL 将重复的参数校验逻辑(target ID 解析、时间范围校验、分页参数校验、方法检查)抽取到 middleware.ts 模块。 - -#### Scenario: 方法检查中间件 -- **WHEN** 请求方法不是 GET 或 HEAD -- **THEN** `guardGetHead(request, mode)` SHALL 返回 405 Response,否则返回 null 表示放行 - -#### Scenario: Target ID 校验 -- **WHEN** URL 中的 id 参数不是正整数 -- **THEN** `validateTargetId(idStr)` SHALL 返回 400 ApiError - -#### Scenario: 时间范围参数校验 -- **WHEN** from 或 to 参数缺失或格式无效 -- **THEN** `validateTimeRange(from, to)` SHALL 返回 400 ApiError - -#### Scenario: 分页参数校验 -- **WHEN** page 或 pageSize 参数不是正整数 -- **THEN** `validatePagination(page, pageSize)` SHALL 返回 400 ApiError - ### Requirement: 静态资源服务独立管理 -系统 SHALL 将静态资源服务、SPA fallback 和 Content-Type 映射逻辑抽取到 static.ts 模块。 +系统 SHALL 将 SPA fallback 逻辑交给 routes 对象中的 HTML import 通配符处理,静态资源服务由 Bun 内置 manifest 机制自动处理。 #### Scenario: 根路径返回 index.html - **WHEN** 客户端请求 `/` -- **THEN** `static.ts` 的 handler 返回 index.html,设置正确的 Content-Type 和 Cache-Control +- **THEN** routes 中注册的 HTML import 自动返回 index.html #### Scenario: 资源文件返回正确 Content-Type -- **WHEN** 客户端请求 `/assets/main.js` -- **THEN** `static.ts` 的 handler 根据文件扩展名返回正确的 Content-Type(如 `.js` → `text/javascript`) +- **WHEN** 客户端请求构建后的静态资源 +- **THEN** Bun 内置 manifest 机制自动返回正确的 Content-Type 和缓存头 #### Scenario: SPA fallback - **WHEN** 客户端请求非 API、非资源的路径(如 `/dashboard`) -- **THEN** `static.ts` 的 handler 返回 index.html 实现 SPA 的客户端路由 +- **THEN** routes 中注册的 `"/*"` HTML import 通配符返回 index.html 实现 SPA 的客户端路由 diff --git a/openspec/specs/bun-fullstack-routing/spec.md b/openspec/specs/bun-fullstack-routing/spec.md new file mode 100644 index 0000000..4fd51c5 --- /dev/null +++ b/openspec/specs/bun-fullstack-routing/spec.md @@ -0,0 +1,49 @@ +## Purpose + +定义基于 Bun.serve `routes` 对象的全栈声明式路由注册、路径参数、HTTP method 声明和 fallback 行为。 + +## Requirements + +### Requirement: 声明式路由注册 +系统 SHALL 使用 Bun.serve 的 `routes` 对象以声明式方式注册所有 HTTP 路由,包括 HTML 页面路由和 API 端点路由。 + +#### Scenario: HTML 页面路由注册 +- **WHEN** server 启动时 +- **THEN** 系统 SHALL 通过 HTML import 将前端入口注册到 `routes` 对象的 `"/*"` 通配符路径 + +#### Scenario: API 端点路由注册 +- **WHEN** server 启动时 +- **THEN** 系统 SHALL 将所有 API 端点以 method handler 对象形式注册到 `routes` 对象 + +### Requirement: 路径参数支持 +系统 SHALL 使用 routes 对象的 `:param` 语法声明路径参数,替代手动 regex 匹配。 + +#### Scenario: 带路径参数的 API 路由 +- **WHEN** 客户端请求 `/api/targets/123/history` +- **THEN** 系统 SHALL 通过 `routes` 中注册的 `/api/targets/:id/history` 匹配,并通过 `req.params.id` 获取参数值 `"123"` + +#### Scenario: 路径参数类型 +- **WHEN** route handler 接收到路径参数 +- **THEN** 参数值 SHALL 为字符串类型,handler 负责进行类型转换和校验 + +### Requirement: HTTP Method 声明 +系统 SHALL 在 routes 对象中为每个 API 端点以 per-method handler 形式声明支持的 HTTP method;未匹配 method 的 API 请求 SHALL 落入 `/api/*` 通配符并返回 JSON 404。 + +#### Scenario: 单 method 端点 +- **WHEN** API 端点只支持 GET 方法 +- **THEN** 该端点 SHALL 以 `{ GET(req) { ... } }` 形式注册 + +#### Scenario: 不支持的 method 请求 +- **WHEN** 客户端使用未声明的 method 请求 API 端点 +- **THEN** `/api/*` 通配符 SHALL 返回 JSON 格式的 404 错误响应 + +### Requirement: Fetch Fallback 处理 +系统 SHALL 使用 `fetch` handler 作为兜底,理论上不应被触发(所有路径都被 routes 通配符覆盖)。 + +#### Scenario: 未匹配的 API 路由 +- **WHEN** 请求路径以 `/api/` 开头但未在具体 API 路由中注册 +- **THEN** `/api/*` 通配符 SHALL 返回 JSON 格式的 404 错误响应 + +#### Scenario: 未匹配的非 API 路由 +- **WHEN** 请求路径不以 `/api/` 开头且未在具体路由中注册 +- **THEN** `"/*": homepage` 通配符 SHALL 返回前端入口 HTML 文档(带 HMR 注入) diff --git a/openspec/specs/code-quality-gates/spec.md b/openspec/specs/code-quality-gates/spec.md index 6762f14..440d1d4 100644 --- a/openspec/specs/code-quality-gates/spec.md +++ b/openspec/specs/code-quality-gates/spec.md @@ -1,6 +1,6 @@ ## Purpose -定义项目代码质量门禁、格式化检查、快速检查和完整验证命令的行为要求,确保开发者可以通过文档化命令稳定验证源码质量、基础测试和生产 executable 行为。 +定义项目代码质量门禁、格式化检查、快速检查和完整验证命令的行为要求,确保开发者可以通过文档化命令稳定验证源码质量、基础测试和生产构建。 ## Requirements @@ -108,11 +108,11 @@ - **THEN** `check` MUST 以非零状态退出且不静默忽略失败 ### Requirement: 完整验证命令 -项目 SHALL 提供完整 `verify` 命令,用于提交前或发布前验证当前源码、测试和生产 executable 行为。 +项目 SHALL 提供完整 `verify` 命令,用于提交前或发布前验证当前源码、测试和生产构建。原 executable smoke test 暂时移除,后续通过独立变更重新设计。 #### Scenario: 运行完整验证 - **WHEN** 开发者运行 `bun run verify` -- **THEN** 系统 SHALL 先运行 `check`,再运行生产构建和 executable smoke test +- **THEN** 系统 SHALL 先运行 `check`,再运行生产构建 #### Scenario: 完整验证失败 - **WHEN** `verify` 中任一阶段失败 diff --git a/openspec/specs/frontend-development-workflow/spec.md b/openspec/specs/frontend-development-workflow/spec.md index 04f436a..bf170cd 100644 --- a/openspec/specs/frontend-development-workflow/spec.md +++ b/openspec/specs/frontend-development-workflow/spec.md @@ -1,45 +1,41 @@ ## Purpose -定义 Vite + React + TypeScript 前端开发工作流、开发期 API 代理和共享契约的行为要求。 +定义 Bun.serve fullstack + React + TypeScript 前端开发工作流、开发期 API 访问和共享契约的行为要求。 ## Requirements ### Requirement: Vite React 开发服务器 -系统 SHALL 提供基于 Vite + React + TypeScript 的前端开发工作流,并支持热模块替换。 +系统 SHALL 提供基于 Bun.serve fullstack 模式的前端开发工作流,并支持热模块替换和 React Fast Refresh。 #### Scenario: 启动前端开发服务器 -- **WHEN** 开发者启动前端开发命令 -- **THEN** 前端 SHALL 由 Vite 提供服务,并启用 React 热模块替换 +- **WHEN** 开发者启动开发命令 +- **THEN** 前端 SHALL 由 Bun.serve 的 HTML import 机制提供服务,并通过 `development: { hmr: true, console: true }` 启用 HMR、React Fast Refresh 和浏览器 console 回显 #### Scenario: 构建前端静态资源 - **WHEN** 开发者运行前端生产构建命令 -- **THEN** 系统 SHALL 产出可由 Bun 后端服务的前端静态资源 +- **THEN** 系统 SHALL 通过 Bun.build 的 HTML import ahead-of-time bundling 产出可由 Bun 后端服务的前端静态资源 ### Requirement: 前端开发期 API 代理 -前端开发服务器 SHALL 在本地开发期间将 `/api/*` 请求代理到 Bun 后端服务。 +前端开发服务器 SHALL 在本地开发期间无需代理配置即可访问后端 API,因为前后端运行在同一进程同一端口。 #### Scenario: 前端开发期调用拨测 API -- **WHEN** 浏览器从 Vite 开发源请求 `/api/summary`、`/api/targets` 等拨测 API -- **THEN** Vite SHALL 将请求转发到 Bun 后端服务,且不需要浏览器 CORS 配置 +- **WHEN** 浏览器从开发服务器请求 `/api/summary`、`/api/targets` 等拨测 API +- **THEN** Bun.serve SHALL 直接由 routes 中注册的 API handler 处理请求,无需 proxy 转发 #### Scenario: 开发期访问非 API 前端路由 -- **WHEN** 浏览器从 Vite 开发源请求非 API 前端路由 -- **THEN** Vite SHALL 将该请求作为前端应用流量处理,而不是转发到后端 +- **WHEN** 浏览器从开发服务器请求非 API 前端路由 +- **THEN** Bun.serve SHALL 将该请求作为前端应用流量处理(SPA fallback 返回 HTML) -### Requirement: 开发期后端端口一致性 -项目 SHALL 保证文档化的全栈开发命令中,Vite proxy 目标端口与 Bun 后端监听端口来自同一配置来源。 +### Requirement: 开发期单端口运行 +项目 SHALL 保证开发命令中前端页面、HMR 和后端 API 由同一个 Bun.serve 进程在同一端口提供服务。 #### Scenario: 使用默认开发端口 -- **WHEN** 开发者未提供端口覆盖并运行文档化的全栈开发命令 -- **THEN** Bun 后端 SHALL 监听默认端口,且 Vite SHALL 将 `/api/*` 代理到同一端口 +- **WHEN** 开发者未提供端口覆盖并运行开发命令 +- **THEN** Bun.serve SHALL 在默认端口同时提供前端页面、HMR 和后端 API -#### Scenario: 使用 PORT 覆盖开发端口 -- **WHEN** 开发者通过 `PORT` 覆盖后端端口并运行文档化的全栈开发命令 -- **THEN** Bun 后端 SHALL 监听该端口,且 Vite SHALL 将 `/api/*` 代理到同一端口 - -#### Scenario: 避免代理端口与后端端口分叉 -- **WHEN** 开发期脚本需要向 Vite 传递后端端口 -- **THEN** 该代理端口 MUST 从文档化的后端端口配置派生,而不是作为独立对外配置导致分叉 +#### Scenario: 使用配置覆盖开发端口 +- **WHEN** 开发者通过配置文件覆盖端口并运行开发命令 +- **THEN** Bun.serve SHALL 在配置端口同时提供前端页面、HMR 和后端 API ### Requirement: 前端使用相对 API 路径 除非有文档化的部署配置覆盖该行为,前端代码 MUST 通过相对 `/api/*` URL 调用后端 API。 @@ -57,7 +53,7 @@ #### Scenario: 启动全栈开发 - **WHEN** 开发者运行文档化的全栈开发命令 -- **THEN** 系统 SHALL 启动 Vite 前端开发服务器和 Bun 后端服务器 +- **THEN** 系统 SHALL 启动单个 Bun.serve 进程,同时提供前端 HMR 和后端 API 服务 ### Requirement: 开发质量命令文档化 项目 SHALL 在前端开发工作流文档中说明日常检查和完整验证命令。 diff --git a/openspec/specs/fullstack-app-runtime/spec.md b/openspec/specs/fullstack-app-runtime/spec.md index cc94d30..c183ca2 100644 --- a/openspec/specs/fullstack-app-runtime/spec.md +++ b/openspec/specs/fullstack-app-runtime/spec.md @@ -5,11 +5,11 @@ ## Requirements ### Requirement: Bun HTTP 运行时 -系统 SHALL 运行一个 Bun HTTP server,由单个进程提供后端 API、健康检查、生产静态资源和 SPA fallback 行为。 +系统 SHALL 运行一个 Bun HTTP server,使用 `routes` 对象声明式注册 HTML 页面路由和 API 端点,由单个进程提供后端 API、健康检查和前端服务。 #### Scenario: 启动运行时服务器 - **WHEN** server 进程成功启动 -- **THEN** 它 SHALL 监听 YAML 配置文件中指定的 host 和 port,并记录实际 server URL +- **THEN** 它 SHALL 监听配置文件中指定的 host 和 port,通过 routes 对象注册所有路由,并记录实际 server URL #### Scenario: 通过 YAML 配置提供运行时参数 - **WHEN** 通过 YAML 配置文件提供 host、port、数据目录等参数 @@ -21,22 +21,18 @@ #### Scenario: 提供拨测相关 API - **WHEN** server 启动完成 -- **THEN** 系统 SHALL 提供 `/api/summary`、`/api/targets`、`/api/targets/:id/history`、`/api/targets/:id/trend` 端点 +- **THEN** 系统 SHALL 通过 routes 对象提供 `/api/summary`、`/api/targets`、`/api/targets/:id/history`、`/api/targets/:id/trend` 端点 ### Requirement: HTTP method 语义 -系统 SHALL 为运行时端点提供明确的 HTTP method 语义,避免不支持的 method 被错误地当作成功请求处理。 +系统 SHALL 只为运行时端点声明实际支持的 GET handler;不支持的 API method SHALL 按未匹配 API 路由处理,不再保留自定义 405 和 Allow header 语义。 #### Scenario: GET 请求访问运行时端点 - **WHEN** 客户端使用 `GET` 请求 `/health` 或 `/api/*` 端点 - **THEN** Bun server SHALL 返回对应端点的成功响应 -#### Scenario: HEAD 请求访问运行时端点 -- **WHEN** 客户端使用 `HEAD` 请求 `/health` 或 `/api/*` 端点 -- **THEN** Bun server SHALL 返回与 `GET` 相同的成功状态和 headers,但 MUST NOT 返回响应体 - -#### Scenario: 不支持的 method 访问运行时端点 -- **WHEN** 客户端使用不支持的 method 请求 `/health` 或 `/api/*` 端点 -- **THEN** Bun server SHALL 返回 405 状态码和 Allow header +#### Scenario: 不支持的 API method 请求 +- **WHEN** 客户端使用不支持的 method 请求已存在的 `/api/*` 端点 +- **THEN** `/api/*` 通配符 SHALL 返回包含 `error` 和 `status` 字段的 JSON 404 响应 ### Requirement: API 路由命名空间 系统 MUST 将 `/api/*` 保留给后端 API 路由。 @@ -50,15 +46,15 @@ - **THEN** Bun server MUST 返回 JSON 404 响应,而不是前端 HTML 文档 ### Requirement: API 错误响应一致性 -系统 SHALL 为 API 命名空间内的错误返回机器可读 JSON 响应。 +系统 SHALL 为 API 命名空间内的未匹配路由和未匹配 method 返回机器可读 JSON 404 响应。 #### Scenario: 未知 API 路由 - **WHEN** 客户端请求未知的 `/api/*` 路由 - **THEN** Bun server MUST 返回包含 `error` 和 `status` 字段的 JSON 404 响应,而不是前端 HTML 文档 -#### Scenario: API method 不允许 +#### Scenario: API method 不匹配 - **WHEN** 客户端使用不支持的 method 请求已存在的 API 路由 -- **THEN** Bun server MUST 返回包含 `error` 和 `status` 字段的 JSON 405 响应 +- **THEN** Bun server MUST 返回包含 `error` 和 `status` 字段的 JSON 404 响应 ### Requirement: 健康检查端点 系统 SHALL 在前端 SPA fallback 之外暴露健康检查端点。 @@ -68,56 +64,48 @@ - **THEN** Bun server SHALL 返回成功的、机器可读的健康检查响应 ### Requirement: 生产静态资源服务 -系统 SHALL 在生产模式下由 Bun runtime 服务 Vite 生产资源。 +系统 SHALL 在生产模式下通过 Bun 内置的 HTML import manifest 机制服务前端资源。 #### Scenario: 请求构建后的资源 -- **WHEN** 客户端请求构建后的前端资源,例如 `/assets/app.js` -- **THEN** Bun server SHALL 返回该资源并带有适当的 content type +- **WHEN** 客户端请求构建后的前端资源 +- **THEN** Bun server SHALL 通过 manifest 自动返回该资源并带有适当的 content type 和 content-addressable hash URL #### Scenario: 请求前端根路径 - **WHEN** 客户端请求 `/` -- **THEN** Bun server SHALL 返回前端入口 HTML 文档 +- **THEN** Bun server SHALL 通过 routes 中注册的 HTML import 返回前端入口 HTML 文档 ### Requirement: 生产缓存策略 -系统 SHALL 为生产静态资源和前端入口 HTML 使用明确的缓存策略。 +系统 SHALL 利用 Bun 内置的缓存机制为生产静态资源提供缓存策略。 #### Scenario: 请求前端入口 HTML - **WHEN** 生产 Bun server 返回前端入口 HTML 文档 -- **THEN** 响应 SHALL 使用 `Cache-Control: no-cache` +- **THEN** 响应 SHALL 包含 Bun 自动生成的 ETag header #### Scenario: 请求构建后的静态资源 -- **WHEN** 生产 Bun server 返回 Vite 构建后的静态资源 -- **THEN** 响应 SHALL 使用长缓存策略 `public, max-age=31536000, immutable` - -#### Scenario: 请求未知静态资源 -- **WHEN** 客户端请求不存在的 `/assets/*` 资源或带文件扩展名的未知路径 -- **THEN** Bun server MUST 返回 404,且 MUST NOT 返回前端入口 HTML 文档 +- **WHEN** 生产 Bun server 返回构建后的静态资源 +- **THEN** 响应 SHALL 包含 Bun 自动生成的 ETag header 和 content-addressable hash URL ### Requirement: 低风险安全响应头 -系统 SHALL 在生产运行时响应中附加低风险安全响应头,提升基础安全性且不提前约束未来前端资源策略。 - -#### Scenario: 生产 HTML 响应包含安全头 -- **WHEN** 生产 Bun server 返回前端 HTML 文档 -- **THEN** 响应 SHALL 包含 `X-Content-Type-Options: nosniff` 和 `Referrer-Policy` headers +系统 SHALL 在生产运行时的 JSON API 响应中附加低风险安全响应头;HTML 和静态资源响应由 Bun HTML import manifest 返回其内置 headers。 #### Scenario: 生产 JSON 响应包含安全头 - **WHEN** 生产 Bun server 返回 `/health` 或 `/api/*` JSON 响应 - **THEN** 响应 SHALL 包含 `X-Content-Type-Options: nosniff` 和 `Referrer-Policy` headers -#### Scenario: 生产静态资源响应包含安全头 -- **WHEN** 生产 Bun server 返回 Vite 构建后的静态资源 -- **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 ### Requirement: SPA fallback 行为 -系统 SHALL 在生产环境中为非 API、非静态资源的前端路由返回前端入口 HTML 文档。 +系统 SHALL 通过 routes 中注册的 `"/*"` HTML import 通配符为非 API 路径返回前端入口 HTML 文档。 #### Scenario: 刷新前端路由 - **WHEN** 客户端请求前端路由,例如 `/dashboard` -- **THEN** Bun server SHALL 返回前端入口 HTML 文档 +- **THEN** routes 中的 `"/*"` 通配符 SHALL 返回前端入口 HTML 文档 #### Scenario: 保留 API 错误语义 - **WHEN** 客户端请求未知的 `/api/*` 路由 -- **THEN** Bun server MUST NOT 返回前端入口 HTML 文档 +- **THEN** `/api/*` 通配符 MUST 返回 JSON 404 响应,而不是前端入口 HTML 文档 ### Requirement: 优雅关机 系统 SHALL 在收到终止信号时正确清理资源。 diff --git a/openspec/specs/meta-api/spec.md b/openspec/specs/meta-api/spec.md index 9af66e7..a30e45c 100644 --- a/openspec/specs/meta-api/spec.md +++ b/openspec/specs/meta-api/spec.md @@ -5,7 +5,7 @@ ## Requirements ### Requirement: Meta 信息 API -系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。 +系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。未匹配 method SHALL 按 API 通配符处理为 JSON 404,不再保留自定义 HEAD/405 语义。 #### Scenario: 获取 checker 类型列表 - **WHEN** 客户端请求 `GET /api/meta` @@ -15,13 +15,9 @@ - **WHEN** 系统启动并注册了 checker - **THEN** `/api/meta` 返回的 `checkerTypes` SHALL 与 `CheckerRegistry.supportedTypes` 完全一致 -#### Scenario: 仅允许 GET/HEAD 方法 -- **WHEN** 客户端使用 POST/PUT/DELETE 等方法请求 `/api/meta` -- **THEN** 系统 SHALL 返回 405 状态码 - -#### Scenario: HEAD 请求返回空体 -- **WHEN** 客户端使用 HEAD 方法请求 `/api/meta` -- **THEN** 系统 SHALL 返回 200 状态码和正确的 Content-Type header,body 为空 +#### Scenario: 不支持的 method 请求 +- **WHEN** 客户端使用 POST/PUT/DELETE/HEAD 等未声明 method 请求 `/api/meta` +- **THEN** `/api/*` 通配符 SHALL 返回 JSON 404 响应 ### Requirement: MetaResponse 共享类型 系统 SHALL 在 `src/shared/api.ts` 中定义 `MetaResponse` 类型。 diff --git a/openspec/specs/probe-api/spec.md b/openspec/specs/probe-api/spec.md index 5eb038a..eccfbf3 100644 --- a/openspec/specs/probe-api/spec.md +++ b/openspec/specs/probe-api/spec.md @@ -132,7 +132,7 @@ - **THEN** API SHALL 返回 failure 为 null ### Requirement: Meta 信息 API -系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。 +系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。未匹配 method SHALL 按 API 通配符处理为 JSON 404,不再保留自定义 HEAD/405 语义。 #### Scenario: 获取 checker 类型列表 - **WHEN** 客户端请求 `GET /api/meta` @@ -142,13 +142,9 @@ - **WHEN** 系统启动并注册了 checker - **THEN** `/api/meta` 返回的 `checkerTypes` SHALL 与 `CheckerRegistry.supportedTypes` 完全一致 -#### Scenario: 仅允许 GET/HEAD 方法 -- **WHEN** 客户端使用 POST/PUT/DELETE 等方法请求 `/api/meta` -- **THEN** 系统 SHALL 返回 405 状态码 - -#### Scenario: HEAD 请求返回空体 -- **WHEN** 客户端使用 HEAD 方法请求 `/api/meta` -- **THEN** 系统 SHALL 返回 200 状态码和正确的 Content-Type header,body 为空 +#### Scenario: 不支持的 method 请求 +- **WHEN** 客户端使用 POST/PUT/DELETE/HEAD 等未声明 method 请求 `/api/meta` +- **THEN** `/api/*` 通配符 SHALL 返回 JSON 404 响应 ### Requirement: MetaResponse 共享类型 系统 SHALL 在 `src/shared/api.ts` 中定义 `MetaResponse` 类型。 diff --git a/openspec/specs/server-bootstrap/spec.md b/openspec/specs/server-bootstrap/spec.md index 5dd6230..d086ee3 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。 +系统 SHALL 提供 `src/server/bootstrap.ts` 导出 `bootstrap(options: BootstrapOptions)` 函数,封装完整的服务启动序列:加载配置、创建 store、同步 targets、创建并启动 engine、启动 HTTP server、注册 shutdown handler。`bootstrap` SHALL 不接收或传递静态资源对象,前端资源由 Bun HTML import manifest 自动接管。 #### Scenario: 开发模式启动 - **WHEN** `dev.ts` 调用 `bootstrap({ configPath, mode: "development" })` - **THEN** 系统 SHALL 完成完整启动序列,不传入 staticAssets #### Scenario: 生产模式启动 -- **WHEN** build entry 调用 `bootstrap({ configPath, mode: "production", staticAssets })` -- **THEN** 系统 SHALL 完成完整启动序列,并将 staticAssets 传递给 startServer +- **WHEN** `main.ts` 调用 `bootstrap({ configPath, mode: "production" })` +- **THEN** 系统 SHALL 完成完整启动序列,并由 `server.ts` 中的 HTML import 路由接管前端资源 #### Scenario: 启动失败处理 - **WHEN** 启动过程中任何步骤抛出异常 @@ -24,19 +24,19 @@ TBD - 统一服务启动引导函数,封装开发和生产模式的完整启 - **THEN** bootstrap 注册的 shutdown handler SHALL 调用 engine.stop() 和 store.close() 后退出 ### Requirement: BootstrapOptions 接口 -`bootstrap` 函数 SHALL 接受 `BootstrapOptions` 参数,包含 `configPath: string`、`mode: RuntimeMode`、`staticAssets?: StaticAssets`。 +`bootstrap` 函数 SHALL 接受 `BootstrapOptions` 参数,仅包含 `configPath: string` 和 `mode: RuntimeMode`。 #### Scenario: 最小配置 - **WHEN** 仅传入 configPath 和 mode -- **THEN** 系统 SHALL 正常启动,staticAssets 为 undefined +- **THEN** 系统 SHALL 正常启动 -### Requirement: dev.ts 和 build entry 使用 bootstrap -`dev.ts` 和 `scripts/build.ts` 生成的 server entry SHALL 调用 `bootstrap()` 而非各自维护启动序列。 +### Requirement: dev.ts 和生产入口使用 bootstrap +`dev.ts` 和 `src/server/main.ts` SHALL 调用 `bootstrap()` 而非各自维护启动序列。 #### Scenario: dev.ts 调用 bootstrap -- **WHEN** 开发者运行 `bun run dev:server` +- **WHEN** 开发者运行 `bun run dev` - **THEN** `dev.ts` SHALL 调用 `bootstrap` 完成启动 -#### Scenario: build entry 调用 bootstrap +#### Scenario: main.ts 调用 bootstrap - **WHEN** 生产可执行文件启动 -- **THEN** 生成的 entry SHALL 调用 `bootstrap` 完成启动 +- **THEN** `main.ts` SHALL 调用 `bootstrap` 完成启动 diff --git a/openspec/specs/single-executable-packaging/spec.md b/openspec/specs/single-executable-packaging/spec.md index 7389dba..5bc0195 100644 --- a/openspec/specs/single-executable-packaging/spec.md +++ b/openspec/specs/single-executable-packaging/spec.md @@ -1,61 +1,42 @@ ## Purpose -定义将 Vite 前端资源与 Bun 后端打包为单个 standalone executable 的生产构建、运行配置和验证要求。 +定义将 Bun HTML import 前端资源与 Bun 后端打包为单个 standalone executable 的生产构建、运行配置和验证要求。 ## Requirements ### Requirement: 生产构建顺序 -生产构建 MUST 在编译 Bun 后端 executable 之前先构建 Vite 前端。 +生产构建 MUST 通过 Bun.build 的 HTML import 识别机制一步完成前端资源打包和后端编译。 #### Scenario: 运行生产构建 - **WHEN** 开发者运行生产构建命令 -- **THEN** 系统 MUST 在调用 Bun standalone executable 编译之前生成前端静态资源 +- **THEN** 系统 MUST 调用 Bun.build,自动识别 server 入口中的 HTML import 并完成前端 bundling 和后端编译 -#### Scenario: 前端构建失败 -- **WHEN** 前端生产构建失败 +#### Scenario: 前端 bundling 失败 +- **WHEN** Bun.build 在处理 HTML import 中的前端资源时失败 - **THEN** 系统 MUST 停止生产构建,且不能输出 stale executable -### Requirement: 构建生成确定性 -生产构建 SHALL 以稳定顺序生成嵌入静态资源清单,减少重复构建产生无意义差异。 - -#### Scenario: 生成静态资源清单 -- **WHEN** 生产构建扫描 Vite 输出目录并生成嵌入资源模块 -- **THEN** 资源条目 SHALL 按稳定顺序输出 - -#### Scenario: 重复构建相同前端产物 -- **WHEN** Vite 输出内容未变化且生产构建重复运行 -- **THEN** 生成的嵌入资源模块 SHALL 保持语义一致且不依赖文件系统遍历顺序 - ### Requirement: 单 executable 输出 -生产构建 SHALL 输出一个 standalone executable,其中包含 Bun 后端、必要 server 依赖和构建后的前端资源。构建成功后 SHALL 自动清理中间产物目录(`.build/`),构建失败时 SHALL 保留中间产物以便排查。生成的入口代码 SHALL 通过 import config-loader 模块隐式触发 checker 注册,而非显式调用注册函数。生成的入口 SHALL 注册 SIGINT 和 SIGTERM 信号处理器,在收到信号时依次调用 engine.stop() 和 store.close() 后退出进程。 +生产构建 SHALL 输出一个 standalone executable,其中包含 Bun 后端和通过 HTML import manifest 嵌入的前端资源。构建流程 SHALL 不再生成项目自定义中间产物目录,构建失败时 SHALL 不保留 stale executable。 #### Scenario: 在目标机器运行 executable - **WHEN** 生成的 executable 在兼容目标平台上运行 -- **THEN** 它 SHALL 启动全栈应用,且不要求目标机器安装 Node.js、Bun、Vite 或 `node_modules` +- **THEN** 它 SHALL 启动全栈应用,且不要求目标机器安装 Node.js、Bun 或 `node_modules` #### Scenario: 服务嵌入的前端 - **WHEN** executable 收到前端根路径请求 -- **THEN** 它 SHALL 从 executable 内包含的资源服务前端,且不需要外部 `dist/` 目录 +- **THEN** 它 SHALL 通过 Bun 内置的 HTML import manifest 机制服务前端资源,且不需要外部 `dist/` 目录 #### Scenario: 服务嵌入 API 和页面 - **WHEN** 生成的 executable 启动,且浏览器打开前端根路径 - **THEN** 页面 SHALL 展示同一个 executable 进程中 `/api/summary` 和 `/api/targets` 返回的数据 -#### Scenario: 构建成功后清理中间产物 +#### Scenario: 构建成功不生成自定义中间产物 - **WHEN** 生产构建成功完成并输出 executable -- **THEN** 系统 SHALL 自动删除 `.build/` 目录及其所有内容 +- **THEN** 系统 SHALL 不生成 `.build/` 静态资源清单或 server entry 中间产物 -#### Scenario: 构建失败时保留中间产物 -- **WHEN** 生产构建在任意步骤失败(前端构建、中间产物生成、Bun 编译) -- **THEN** `.build/` 目录 SHALL 保留在磁盘上以供排查 - -#### Scenario: checker 注册通过 import 链触发 -- **WHEN** 生成的入口代码 import config-loader 模块 -- **THEN** checkerRegistry 单例 SHALL 通过模块依赖链自动完成注册,入口代码 SHALL NOT 显式调用任何注册函数 - -#### Scenario: 生产入口优雅关闭 -- **WHEN** executable 进程收到 SIGINT 或 SIGTERM 信号 -- **THEN** 系统 SHALL 调用 engine.stop() 停止所有定时器,调用 store.close() 关闭数据库连接,然后以退出码 0 退出进程 +#### Scenario: 构建失败时不保留 stale executable +- **WHEN** 生产构建在任意步骤失败 +- **THEN** 系统 SHALL 不输出上一次构建遗留的 stale executable ### Requirement: 外部运行时配置 executable MUST 将环境相关运行时配置保留在嵌入的前端和 server bundle 之外。 @@ -69,20 +50,12 @@ executable MUST 将环境相关运行时配置保留在嵌入的前端和 server - **THEN** executable SHALL 使用文档化的默认值 ### Requirement: 构建验证 -项目 SHALL 提供验证,证明生产 executable 可以服务 API、健康检查、静态资源和 SPA fallback 路由,并且完整验证 MUST 针对当前源码重新构建后的 executable 运行。 - -#### Scenario: 验证 executable 路由 -- **WHEN** 构建验证针对生成的 executable 运行 -- **THEN** 它 SHALL 检查 `/api/summary`、`/api/targets`、`/health`、前端根路径、静态资源、未知 API、未知静态资源和前端 fallback 请求 - -#### Scenario: 验证生产模式和响应头 -- **WHEN** 构建验证针对生成的 executable 运行 -- **THEN** 它 SHALL 检查 API 响应处于 production runtime mode,并验证代表性 HTML、JSON 和静态资源响应的缓存或低风险安全 headers +项目 SHALL 提供 `verify` 命令执行质量检查和生产构建;原 smoke test 暂时移除,executable 路由验证由后续变更重新设计。 #### Scenario: 完整验证重新构建 executable - **WHEN** 开发者运行完整验证命令 -- **THEN** 系统 MUST 先基于当前源码执行生产构建,再对新生成的 executable 运行 smoke test +- **THEN** 系统 MUST 先执行质量检查,再基于当前源码执行生产构建 #### Scenario: 验证失败 -- **WHEN** 任一代表性生产路由、响应头、生产模式或构建阶段检查失败 -- **THEN** 验证 SHALL 使构建或测试命令失败 +- **WHEN** 质量检查或构建阶段失败 +- **THEN** 验证 SHALL 使命令失败 diff --git a/package.json b/package.json index f8cbe37..108c4b5 100644 --- a/package.json +++ b/package.json @@ -3,18 +3,15 @@ "type": "module", "private": true, "scripts": { - "dev": "bun run scripts/dev.ts", - "dev:server": "bun --watch src/server/dev.ts", - "dev:web": "bunx --bun vite --host 127.0.0.1", + "dev": "bun --watch src/server/dev.ts", "build": "bun run scripts/build.ts", "lint": "eslint .", "format": "prettier . --write", "schema": "bun run scripts/generate-config-schema.ts", "schema:check": "bun run scripts/generate-config-schema.ts --check", "check": "bun run schema:check && bun run typecheck && bun run lint && bun test", - "verify": "bun run check && bun run build && bun run test:smoke", + "verify": "bun run check && bun run build", "test": "bun test", - "test:smoke": "bun run scripts/smoke.ts", "clean": "bun run scripts/clean.ts", "typecheck": "tsc --noEmit", "prepare": "husky" @@ -27,7 +24,6 @@ "@types/bun": "^1.3.13", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", @@ -40,8 +36,7 @@ "lint-staged": "^17.0.4", "prettier": "^3.8.3", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.2", - "vite": "^8.0.11" + "typescript-eslint": "^8.59.2" }, "dependencies": { "@sinclair/typebox": "^0.34.49", diff --git a/scripts/build.ts b/scripts/build.ts index a277814..7a9cea6 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,30 +1,10 @@ -import { $ } from "bun"; -import { mkdir, readdir, rm, writeFile } from "node:fs/promises"; -import { dirname, relative, sep } from "node:path"; +import { rm } from "node:fs/promises"; import { fileURLToPath } from "node:url"; -const buildDir = fileURLToPath(new URL("../.build/", import.meta.url)); -const webDistDir = fileURLToPath(new URL("../dist/web/", import.meta.url)); const executablePath = fileURLToPath(new URL("../dist/dial-server", import.meta.url)); -const generatedAssetsPath = fileURLToPath(new URL("../.build/static-assets.ts", import.meta.url)); -const generatedEntryPath = fileURLToPath(new URL("../.build/server-entry.ts", import.meta.url)); +const entrypoint = fileURLToPath(new URL("../src/server/main.ts", import.meta.url)); -await rm(buildDir, { force: true, recursive: true }); await rm(executablePath, { force: true }); -await mkdir(buildDir, { recursive: true }); - -await $`bunx --bun vite build`; - -const files = await listFiles(webDistDir); -const indexPath = files.find((file) => normalize(relative(webDistDir, file)) === "index.html"); - -if (!indexPath) { - throw new Error("Vite build 未生成 dist/web/index.html"); -} - -const assetFiles = files.filter((file) => file !== indexPath); -await writeGeneratedAssets(indexPath, assetFiles); -await writeGeneratedEntry(); const target = process.env["BUN_TARGET"] ?? process.env["BUILD_TARGET"]; const result = await Bun.build({ @@ -40,86 +20,14 @@ const result = await Bun.build({ autoloadDotenv: true, outfile: executablePath, }, - entrypoints: [generatedEntryPath], + entrypoints: [entrypoint], minify: true, sourcemap: "linked", }); if (!result.success) { - await rm(executablePath, { force: true }); - throw new Error("Bun executable 构建失败"); + console.error("构建失败:", result.logs); + process.exit(1); } console.log(`Built executable: ${executablePath}`); - -await rm(buildDir, { force: true, recursive: true }); - -async function listFiles(directory: string): Promise { - const entries = await readdir(directory, { withFileTypes: true }); - const files = await Promise.all( - entries.map(async (entry) => { - const path = `${directory.replace(/\/$/, "")}/${entry.name}`; - - if (entry.isDirectory()) { - return listFiles(path); - } - - return [path]; - }), - ); - - return files.flat().sort((left, right) => normalize(left).localeCompare(normalize(right))); -} - -function normalize(path: string): string { - return path.split(sep).join("/"); -} - -function toImportPath(path: string): string { - const rel = normalize(relative(buildDir, path)); - return rel.startsWith(".") ? rel : `./${rel}`; -} - -async function writeGeneratedAssets(indexPath: string, assetFiles: string[]) { - const imports = [ - `import type { StaticAssets } from "../src/server/app";`, - `import indexPath from "${toImportPath(indexPath)}" with { type: "file" };`, - ...assetFiles.map((file, index) => `import asset${index}Path from "${toImportPath(file)}" with { type: "file" };`), - ]; - const assetEntries = assetFiles.map((file, index) => { - const urlPath = `/${normalize(relative(webDistDir, file))}`; - return ` ${JSON.stringify(urlPath)}: Bun.file(asset${index}Path),`; - }); - const source = `${imports.join("\n")} - -export const staticAssets: StaticAssets = { - indexHtml: Bun.file(indexPath), - files: { -${assetEntries.join("\n")} - }, -}; -`; - - await mkdir(dirname(generatedAssetsPath), { recursive: true }); - await writeFile(generatedAssetsPath, source); -} - -async function writeGeneratedEntry() { - await writeFile( - generatedEntryPath, - `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); -}); -`, - ); -} diff --git a/scripts/dev.ts b/scripts/dev.ts deleted file mode 100644 index 0d9db15..0000000 --- a/scripts/dev.ts +++ /dev/null @@ -1,57 +0,0 @@ -interface ChildProcessInfo { - name: string; - process: Bun.Subprocess; -} - -const configPath = process.argv[2]; - -const env = { - ...process.env, - BACKEND_PORT: process.env["PORT"] ?? "3000", -}; - -const children: ChildProcessInfo[] = [ - { - name: "server", - process: Bun.spawn(["bun", "run", "dev:server", ...(configPath ? [configPath] : [])], { - env, - stderr: "inherit", - stdout: "inherit", - }), - }, - { - name: "web", - process: Bun.spawn(["bun", "run", "dev:web"], { - env, - stderr: "inherit", - stdout: "inherit", - }), - }, -]; - -const stopChildren = () => { - for (const child of children) { - child.process.kill(); - } -}; - -process.on("SIGINT", () => { - stopChildren(); - process.exit(130); -}); - -process.on("SIGTERM", () => { - stopChildren(); - process.exit(143); -}); - -const firstExit = await Promise.race( - children.map(async (child) => ({ code: await child.process.exited, name: child.name })), -); - -stopChildren(); - -if (firstExit.code !== 0) { - console.error(`${firstExit.name} exited with code ${firstExit.code}`); - process.exit(firstExit.code ?? 1); -} diff --git a/scripts/smoke.ts b/scripts/smoke.ts deleted file mode 100644 index 93a8a83..0000000 --- a/scripts/smoke.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { access } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import type { HealthResponse, SummaryResponse } from "../src/shared/api"; - -const executablePath = process.argv[2] ?? fileURLToPath(new URL("../dist/dial-server", import.meta.url)); - -await assertExecutableExists(executablePath); - -const tempDir = mkdtempSync(join(tmpdir(), "dial-smoke-")); -const configPath = join(tempDir, "probes.yaml"); - -const port = getFreePort(); -const baseUrl = `http://127.0.0.1:${port}`; - -writeFileSync( - configPath, - `server: - port: ${port} -targets: - - name: "httpbin" - type: http - http: - url: "https://httpbin.org/get" - interval: "5m" - timeout: "15s" - expect: - status: [200] -`, -); -const app = Bun.spawn([executablePath, configPath], { - env: { ...process.env }, - stderr: "pipe", - stdout: "pipe", -}); -const stdout = readStream(app.stdout); -const stderr = readStream(app.stderr); - -try { - await waitForServer(`${baseUrl}/health`); - - const { body: health, response: healthResponse } = await expectJson(`${baseUrl}/health`, 200); - assert(health.ok === true, "健康检查响应缺少 ok=true"); - assertSecurityHeaders(healthResponse, "/health"); - - const { body: summary } = await expectJson(`${baseUrl}/api/summary`, 200); - assert(summary.total === 1, "总览统计: total 应为 1"); - assertSecurityHeaders(await fetch(`${baseUrl}/api/summary`), "/api/summary"); - - const { body: targets } = await expectJson(`${baseUrl}/api/targets`, 200); - assert(Array.isArray(targets), "/api/targets 应返回数组"); - assert(targets.length === 1, "/api/targets 应有 1 个目标"); - assert((targets[0] as { name: string }).name === "httpbin", "目标名称应为 httpbin"); - - const missingApi = await fetch(`${baseUrl}/api/not-found`); - assert(missingApi.status === 404, "未知 API 应返回 404"); - - const missingTarget = await fetch(`${baseUrl}/api/targets/99999/history`); - assert(missingTarget.status === 404, "不存在的目标应返回 404"); - - const { body: rootHtml, response: rootResponse } = await expectText(`${baseUrl}/`, 200); - assert(rootHtml.includes("DiAL"), "前端根页面缺少标题"); - assert(rootResponse.headers.get("cache-control") === "no-cache", "前端根页面应使用 no-cache"); - - const { body: fallbackHtml } = await expectText(`${baseUrl}/dashboard`, 200); - assert(fallbackHtml.includes("DiAL"), "SPA fallback 未返回前端入口页面"); - - const assetPath = /(?:src|href)="(\/assets\/[^"]+)"/.exec(rootHtml)?.[1]; - assert(assetPath !== undefined, "前端入口页面未引用 /assets/* 资源"); - - const asset = await fetch(`${baseUrl}${assetPath}`); - assert(asset.status === 200, `静态资源 ${assetPath} 未返回 200`); - - const missingAsset = await expectText(`${baseUrl}/assets/not-found.js`, 404); - assert(!missingAsset.body.includes("DiAL"), "未知静态资源不应返回前端入口页面"); - - console.log(`Smoke test passed: ${baseUrl}`); -} catch (error) { - app.kill(); - const [out, err] = await Promise.all([stdout, stderr]); - const message = error instanceof Error ? error.message : String(error); - - throw new Error(`executable smoke test 失败: ${message}\nstdout:\n${out}\nstderr:\n${err}`, { cause: error }); -} finally { - app.kill(); - rmSync(tempDir, { force: true, recursive: true }); -} - -function assert(condition: boolean, message: string): asserts condition { - if (!condition) { - throw new Error(message); - } -} - -async function assertExecutableExists(path: string) { - try { - await access(path); - } catch (error) { - throw new Error(`找不到 executable: ${path},请先运行 bun run build`, { cause: error }); - } -} - -function assertSecurityHeaders(response: Response, label: string) { - assert(response.headers.get("x-content-type-options") === "nosniff", `${label} 缺少 nosniff 安全头`); - assert( - response.headers.get("referrer-policy") === "strict-origin-when-cross-origin", - `${label} 缺少 Referrer-Policy 安全头`, - ); -} - -async function expectJson(url: string, status: number): Promise<{ body: T; response: Response }> { - const response = await fetch(url); - - assert(response.status === status, `${url} 应返回 ${status},实际为 ${response.status}`); - assert(response.headers.get("content-type")?.includes("application/json") === true, `${url} 应返回 JSON`); - - return { body: (await response.json()) as T, response }; -} - -async function expectText(url: string, status: number): Promise<{ body: string; response: Response }> { - const response = await fetch(url); - - assert(response.status === status, `${url} 应返回 ${status},实际为 ${response.status}`); - - return { body: await response.text(), response }; -} - -function getFreePort(): number { - const server = Bun.serve({ - fetch: () => new Response("ok"), - hostname: "127.0.0.1", - port: 0, - }); - const port = server.port; - - void server.stop(true); - - if (port === undefined) { - throw new Error("无法分配 smoke test 端口"); - } - - return port; -} - -async function readStream(stream: null | ReadableStream): Promise { - if (!stream) return ""; - - return new Response(stream).text(); -} - -async function waitForServer(url: string) { - const deadline = Date.now() + 8_000; - - while (Date.now() < deadline) { - try { - const response = await fetch(url); - - if (response.ok) return; - } catch { - await Bun.sleep(100); - } - } - - throw new Error(`服务未在超时时间内启动: ${url}`); -} diff --git a/src/server/app.ts b/src/server/app.ts deleted file mode 100644 index 7fda131..0000000 --- a/src/server/app.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { RuntimeMode } from "../shared/api"; -import type { ProbeStore } from "./checker/store"; - -import { createApiError, jsonResponse } from "./helpers"; -import { guardGetHead } from "./middleware"; -import { handleHealth } from "./routes/health"; -import { handleHistory } from "./routes/history"; -import { handleMeta } from "./routes/meta"; -import { handleSummary } from "./routes/summary"; -import { handleTargets } from "./routes/targets"; -import { handleTrend } from "./routes/trend"; -import { serveStaticAsset } from "./static"; - -export interface AppOptions { - mode: RuntimeMode; - staticAssets?: StaticAssets; - store?: ProbeStore; -} - -export interface StaticAssets { - files: Record; - indexHtml: Blob; -} - -export function createFetchHandler(options: AppOptions) { - return (request: Request): Response => { - const url = new URL(request.url); - - if (url.pathname === "/health") { - return handleHealth(request.method, options.mode); - } - - if (url.pathname === "/api/meta") { - return handleMetaRoute(request, options.mode); - } - - if (url.pathname.startsWith("/api/") && options.store) { - return handleApiRoute(url, request, options.store, options.mode); - } - - if (url.pathname.startsWith("/api/")) { - return jsonResponse(createApiError("Service not ready", 503), { - method: request.method, - mode: options.mode, - status: 503, - }); - } - - if (options.staticAssets) { - return serveStaticAsset(url.pathname, options.staticAssets, options.mode); - } - - return new Response("开发期请通过 Vite 前端地址访问页面。", { - headers: { "Content-Type": "text/plain; charset=utf-8" }, - status: 404, - }); - }; -} - -function handleApiRoute(url: URL, request: Request, store: ProbeStore, mode: RuntimeMode): Response { - const guardResult = guardGetHead(request.method, mode); - if (guardResult) return guardResult; - - const method = request.method; - - if (url.pathname === "/api/summary") { - return handleSummary(store, method, mode); - } - - if (url.pathname === "/api/targets") { - return handleTargets(store, method, mode); - } - - const historyMatch = /^\/api\/targets\/([^/]+)\/history$/.exec(url.pathname); - if (historyMatch) { - return handleHistory(historyMatch[1]!, url, method, store, mode); - } - - const trendMatch = /^\/api\/targets\/([^/]+)\/trend$/.exec(url.pathname); - if (trendMatch) { - return handleTrend(trendMatch[1]!, url, method, store, mode); - } - - return jsonResponse(createApiError("API route not found", 404), { method, mode, status: 404 }); -} - -function handleMetaRoute(request: Request, mode: RuntimeMode): Response { - const guardResult = guardGetHead(request.method, mode); - if (guardResult) return guardResult; - return handleMeta(request.method, mode); -} diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 2ccc086..97baa01 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -1,7 +1,6 @@ import { join } from "node:path"; import type { RuntimeMode } from "../shared/api"; -import type { StaticAssets } from "./app"; import type { StartServerOptions } from "./server"; import { loadConfig, type ResolvedConfig } from "./checker/config-loader"; @@ -27,7 +26,6 @@ export interface BootstrapDependencies { export interface BootstrapOptions { configPath: string; mode: RuntimeMode; - staticAssets?: StaticAssets; } type BootstrapEngine = Pick; @@ -71,7 +69,6 @@ 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/helpers.ts b/src/server/helpers.ts index e87cf5d..a7cfadb 100644 --- a/src/server/helpers.ts +++ b/src/server/helpers.ts @@ -1,10 +1,6 @@ import type { ApiErrorResponse, CheckFailure, CheckResult, HealthResponse, RuntimeMode } from "../shared/api"; import type { StoredCheckResult } from "./checker/types"; -export function allowsGetHead(method: string): boolean { - return method === "GET" || method === "HEAD"; -} - export function createApiError(error: string, status: number): ApiErrorResponse { return { error, status }; } @@ -36,15 +32,14 @@ export function formatDuration(ms: number): string { export function jsonResponse( body: unknown, - options: { headers?: HeadersInit; method?: string; mode: RuntimeMode; status?: number }, + options: { headers?: HeadersInit; mode: RuntimeMode; status?: number }, ): Response { const headers = createHeaders(options.mode, { "Content-Type": "application/json; charset=utf-8", ...options.headers, }); - const responseBody = options.method === "HEAD" ? null : JSON.stringify(body); - return new Response(responseBody, { + return new Response(JSON.stringify(body), { headers, status: options.status, }); @@ -69,11 +64,3 @@ export function mapCheckResult(row: StoredCheckResult): CheckResult { timestamp: row.timestamp, }; } - -export function methodNotAllowedResponse(allow: string[], mode: RuntimeMode): Response { - return jsonResponse(createApiError("Method not allowed", 405), { - headers: { Allow: allow.join(", ") }, - mode, - status: 405, - }); -} diff --git a/src/server/main.ts b/src/server/main.ts new file mode 100644 index 0000000..a6f3d24 --- /dev/null +++ b/src/server/main.ts @@ -0,0 +1,12 @@ +import { bootstrap } from "./bootstrap"; +import { readRuntimeConfig } from "./config"; + +async function main() { + const { configPath } = readRuntimeConfig(); + await bootstrap({ configPath, mode: "production" }); +} + +void main().catch((error) => { + console.error("启动失败:", error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/src/server/middleware.ts b/src/server/middleware.ts index 8e91a66..c72d83c 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -1,16 +1,9 @@ import type { RuntimeMode } from "../shared/api"; -import { allowsGetHead, createApiError, jsonResponse, methodNotAllowedResponse } from "./helpers"; +import { createApiError, jsonResponse } from "./helpers"; const MAX_PAGE_SIZE = 200; -export function guardGetHead(method: string, mode: RuntimeMode): null | Response { - if (!allowsGetHead(method)) { - return methodNotAllowedResponse(["GET", "HEAD"], mode); - } - return null; -} - export function validatePagination( pageParam: null | string, pageSizeParam: null | string, diff --git a/src/server/routes/health.ts b/src/server/routes/health.ts index d698d89..6fbca35 100644 --- a/src/server/routes/health.ts +++ b/src/server/routes/health.ts @@ -1,11 +1,7 @@ import type { RuntimeMode } from "../../shared/api"; -import { allowsGetHead, createHealthResponse, jsonResponse, methodNotAllowedResponse } from "../helpers"; +import { createHealthResponse, jsonResponse } from "../helpers"; -export function handleHealth(method: string, mode: RuntimeMode): Response { - if (!allowsGetHead(method)) { - return methodNotAllowedResponse(["GET", "HEAD"], mode); - } - - return jsonResponse(createHealthResponse(), { method, mode }); +export function handleHealth(mode: RuntimeMode): Response { + return jsonResponse(createHealthResponse(), { mode }); } diff --git a/src/server/routes/history.ts b/src/server/routes/history.ts index 448d31f..438bc7c 100644 --- a/src/server/routes/history.ts +++ b/src/server/routes/history.ts @@ -4,13 +4,13 @@ import type { ProbeStore } from "../checker/store"; import { jsonResponse, mapCheckResult } from "../helpers"; import { validatePagination, validateTargetId, validateTimeRange } from "../middleware"; -export function handleHistory(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response { +export function handleHistory(idStr: string, url: URL, store: ProbeStore, mode: RuntimeMode): Response { const idResult = validateTargetId(idStr, mode); if (idResult instanceof Response) return idResult; const target = store.getTargetById(idResult.id); if (!target) { - return jsonResponse({ error: "Target not found", status: 404 } as const, { method, mode, status: 404 }); + return jsonResponse({ error: "Target not found", status: 404 } as const, { mode, status: 404 }); } const timeResult = validateTimeRange(url.searchParams.get("from"), url.searchParams.get("to"), mode); @@ -27,5 +27,5 @@ export function handleHistory(idStr: string, url: URL, method: string, store: Pr total: result.total, }; - return jsonResponse(response, { method, mode }); + return jsonResponse(response, { mode }); } diff --git a/src/server/routes/meta.ts b/src/server/routes/meta.ts index 79873bd..60350fc 100644 --- a/src/server/routes/meta.ts +++ b/src/server/routes/meta.ts @@ -3,10 +3,10 @@ import type { MetaResponse, RuntimeMode } from "../../shared/api"; import { checkerRegistry } from "../checker/runner"; import { jsonResponse } from "../helpers"; -export function handleMeta(method: string, mode: RuntimeMode): Response { +export function handleMeta(mode: RuntimeMode): Response { const response: MetaResponse = { checkerTypes: checkerRegistry.supportedTypes, }; - return jsonResponse(response, { method, mode }); + return jsonResponse(response, { mode }); } diff --git a/src/server/routes/summary.ts b/src/server/routes/summary.ts index 7aa19a7..5d7d277 100644 --- a/src/server/routes/summary.ts +++ b/src/server/routes/summary.ts @@ -3,7 +3,7 @@ import type { ProbeStore } from "../checker/store"; import { jsonResponse } from "../helpers"; -export function handleSummary(store: ProbeStore, method: string, mode: RuntimeMode): Response { +export function handleSummary(store: ProbeStore, mode: RuntimeMode): Response { const summary = store.getSummary(); const response: SummaryResponse = { down: summary.down, @@ -12,5 +12,5 @@ export function handleSummary(store: ProbeStore, method: string, mode: RuntimeMo up: summary.up, }; - return jsonResponse(response, { method, mode }); + return jsonResponse(response, { mode }); } diff --git a/src/server/routes/targets.ts b/src/server/routes/targets.ts index 79f5b53..0872606 100644 --- a/src/server/routes/targets.ts +++ b/src/server/routes/targets.ts @@ -3,7 +3,7 @@ import type { ProbeStore } from "../checker/store"; import { formatDuration, jsonResponse, mapCheckResult } from "../helpers"; -export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMode): Response { +export function handleTargets(store: ProbeStore, mode: RuntimeMode): Response { const targets = store.getTargets(); const latestChecksMap = store.getLatestChecksMap(); const allStats = store.getAllTargetStats(); @@ -34,5 +34,5 @@ export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMo }; }); - return jsonResponse(result, { method, mode }); + return jsonResponse(result, { mode }); } diff --git a/src/server/routes/trend.ts b/src/server/routes/trend.ts index 9dd8b84..2724e58 100644 --- a/src/server/routes/trend.ts +++ b/src/server/routes/trend.ts @@ -4,13 +4,13 @@ import type { ProbeStore } from "../checker/store"; import { jsonResponse } from "../helpers"; import { validateTargetId, validateTimeRange } from "../middleware"; -export function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response { +export function handleTrend(idStr: string, url: URL, store: ProbeStore, mode: RuntimeMode): Response { const idResult = validateTargetId(idStr, mode); if (idResult instanceof Response) return idResult; const target = store.getTargetById(idResult.id); if (!target) { - return jsonResponse({ error: "Target not found", status: 404 } as const, { method, mode, status: 404 }); + return jsonResponse({ error: "Target not found", status: 404 } as const, { mode, status: 404 }); } const timeResult = validateTimeRange(url.searchParams.get("from"), url.searchParams.get("to"), mode); @@ -23,5 +23,5 @@ export function handleTrend(idStr: string, url: URL, method: string, store: Prob totalChecks: row.totalChecks, })); - return jsonResponse(trend, { method, mode }); + return jsonResponse(trend, { mode }); } diff --git a/src/server/server.ts b/src/server/server.ts index bc8b933..125937a 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1,27 +1,54 @@ import type { RuntimeMode } from "../shared/api"; -import type { StaticAssets } from "./app"; import type { ProbeStore } from "./checker/store"; import type { RuntimeConfig } from "./config"; -import { createFetchHandler } from "./app"; +import homepage from "../web/index.html"; +import { createApiError, jsonResponse } from "./helpers"; +import { handleHealth } from "./routes/health"; +import { handleHistory } from "./routes/history"; +import { handleMeta } from "./routes/meta"; +import { handleSummary } from "./routes/summary"; +import { handleTargets } from "./routes/targets"; +import { handleTrend } from "./routes/trend"; export interface StartServerOptions { config: RuntimeConfig; mode: RuntimeMode; - staticAssets?: StaticAssets; - store?: ProbeStore; + store: ProbeStore; } export function startServer(options: StartServerOptions) { - const { config, mode, staticAssets, store } = options; + const { config, mode, store } = options; + const server = Bun.serve({ - fetch: createFetchHandler({ - mode, - staticAssets, - store, - }), + development: mode === "development" ? { console: true, hmr: true } : false, + fetch() { + return new Response("Not found", { status: 404 }); + }, hostname: config.host, port: config.port, + routes: { + "/*": homepage, + "/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }), + "/api/meta": { + GET: () => handleMeta(mode), + }, + "/api/summary": { + GET: () => handleSummary(store, mode), + }, + "/api/targets": { + GET: () => handleTargets(store, mode), + }, + "/api/targets/:id/history": { + GET: (req) => handleHistory(req.params.id, new URL(req.url), store, mode), + }, + "/api/targets/:id/trend": { + GET: (req) => handleTrend(req.params.id, new URL(req.url), store, mode), + }, + "/health": { + GET: () => handleHealth(mode), + }, + }, }); console.log(`DiAL listening on ${server.url}`); diff --git a/src/server/static.ts b/src/server/static.ts deleted file mode 100644 index ed029eb..0000000 --- a/src/server/static.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { RuntimeMode } from "../shared/api"; -import type { StaticAssets } from "./app"; - -import { createHeaders } from "./helpers"; - -export function contentTypeFor(pathname: string): string { - if (pathname.endsWith(".js") || pathname.endsWith(".mjs")) return "text/javascript; charset=utf-8"; - if (pathname.endsWith(".css")) return "text/css; charset=utf-8"; - if (pathname.endsWith(".svg")) return "image/svg+xml"; - if (pathname.endsWith(".json")) return "application/json; charset=utf-8"; - if (pathname.endsWith(".png")) return "image/png"; - if (pathname.endsWith(".jpg") || pathname.endsWith(".jpeg")) return "image/jpeg"; - if (pathname.endsWith(".ico")) return "image/x-icon"; - - return "application/octet-stream"; -} - -export function hasFileExtension(pathname: string): boolean { - return /\/[^/]+\.[^/]+$/.test(pathname); -} - -export function htmlResponse(indexHtml: Blob, mode: RuntimeMode): Response { - return new Response(indexHtml, { - headers: createHeaders(mode, { - "Cache-Control": "no-cache", - "Content-Type": "text/html; charset=utf-8", - }), - }); -} - -export function serveStaticAsset(pathname: string, staticAssets: StaticAssets, mode: RuntimeMode): Response { - if (pathname === "/") { - return htmlResponse(staticAssets.indexHtml, mode); - } - - const asset = staticAssets.files[pathname]; - - if (asset) { - return new Response(asset, { - headers: createHeaders(mode, { - "Cache-Control": "public, max-age=31536000, immutable", - "Content-Type": contentTypeFor(pathname), - }), - }); - } - - if (pathname.startsWith("/assets/") || hasFileExtension(pathname)) { - return new Response("Not Found", { - headers: createHeaders(mode, { "Content-Type": "text/plain; charset=utf-8" }), - status: 404, - }); - } - - return htmlResponse(staticAssets.indexHtml, mode); -} diff --git a/src/web/css.d.ts b/src/web/css.d.ts new file mode 100644 index 0000000..cbe652d --- /dev/null +++ b/src/web/css.d.ts @@ -0,0 +1 @@ +declare module "*.css"; diff --git a/src/web/index.html b/src/web/index.html index e63e76c..48d42a6 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -8,6 +8,6 @@
- + diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index d119471..f5304dc 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -11,11 +11,11 @@ import type { TargetStatus, } from "../../src/shared/api"; -import { createFetchHandler, type StaticAssets } from "../../src/server/app"; import { checkerRegistry } from "../../src/server/checker/runner"; import { CommandChecker } from "../../src/server/checker/runner/command/execute"; import { HttpChecker } from "../../src/server/checker/runner/http/execute"; import { ProbeStore } from "../../src/server/checker/store"; +import { startServer } from "../../src/server/server"; import { rmRetry } from "../helpers"; function ensureRegistered() { @@ -29,19 +29,11 @@ beforeAll(() => { ensureRegistered(); }); -const staticAssets: StaticAssets = { - files: { - "/assets/app.js": new Blob(["console.log('app');"], { type: "text/javascript" }), - }, - indexHtml: new Blob(['DiAL
'], { - type: "text/html", - }), -}; - describe("API 路由", () => { let tempDir: string; let store: ProbeStore; - let fetchHandler: ReturnType; + let server: ReturnType; + let baseUrl: string; beforeAll(async () => { tempDir = join(tmpdir(), `dial-api-test-${Date.now()}`); @@ -104,16 +96,22 @@ describe("API 路由", () => { timestamp: "2025-01-01T00:00:30.000Z", }); - fetchHandler = createFetchHandler({ mode: "test", staticAssets, store }); + server = startServer({ + config: { host: "127.0.0.1", port: 0 }, + mode: "test", + store, + }); + baseUrl = `http://127.0.0.1:${server.port}`; }); afterAll(async () => { + await server.stop(true); store.close(); await rmRetry(tempDir); }); test("/health 返回健康检查", async () => { - const response = fetchHandler(new Request("http://localhost/health")); + const response = await fetch(`${baseUrl}/health`); const body = (await response.json()) as HealthResponse; expect(response.status).toBe(200); @@ -122,7 +120,7 @@ describe("API 路由", () => { }); test("/api/summary 返回总览统计", async () => { - const response = fetchHandler(new Request("http://localhost/api/summary")); + const response = await fetch(`${baseUrl}/api/summary`); const body = (await response.json()) as SummaryResponse; expect(response.status).toBe(200); expect(body.total).toBe(2); @@ -133,7 +131,7 @@ describe("API 路由", () => { }); test("/api/targets 返回目标列表", async () => { - const response = fetchHandler(new Request("http://localhost/api/targets")); + const response = await fetch(`${baseUrl}/api/targets`); const body = (await response.json()) as TargetStatus[]; expect(response.status).toBe(200); @@ -158,7 +156,7 @@ describe("API 路由", () => { }); test("/api/meta 返回 checker 类型列表", async () => { - const response = fetchHandler(new Request("http://localhost/api/meta")); + const response = await fetch(`${baseUrl}/api/meta`); const body = (await response.json()) as MetaResponse; expect(response.status).toBe(200); @@ -167,29 +165,16 @@ describe("API 路由", () => { expect(body.checkerTypes).toContain("command"); }); - test("/api/meta HEAD 请求返回 headers 无 body", async () => { - const response = fetchHandler(new Request("http://localhost/api/meta", { method: "HEAD" })); - const body = await response.text(); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("application/json"); - expect(body).toBe(""); - }); - - test("/api/meta 不支持的 method 返回 405", () => { - const response = fetchHandler(new Request("http://localhost/api/meta", { method: "POST" })); - - expect(response.status).toBe(405); - expect(response.headers.get("allow")).toBe("GET, HEAD"); + test("不支持的 method 在有 API 通配符时返回 404", async () => { + const response = await fetch(`${baseUrl}/api/summary`, { method: "POST" }); + expect(response.status).toBe(404); }); test("/api/targets/:id/history 返回历史记录", async () => { const targets = store.getTargets(); const from = "2024-01-01T00:00:00.000Z"; const to = "2026-12-31T23:59:59.999Z"; - const response = fetchHandler( - new Request(`http://localhost/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}`), - ); + const response = await fetch(`${baseUrl}/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}`); const body = (await response.json()) as HistoryResponse; expect(response.status).toBe(200); @@ -205,9 +190,7 @@ describe("API 路由", () => { const targets = store.getTargets(); const from = "2024-01-01T00:00:00.000Z"; const to = "2026-12-31T23:59:59.999Z"; - const response = fetchHandler( - new Request(`http://localhost/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}&pageSize=1`), - ); + const response = await fetch(`${baseUrl}/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}&pageSize=1`); const body = (await response.json()) as HistoryResponse; expect(response.status).toBe(200); @@ -219,9 +202,7 @@ describe("API 路由", () => { const targets = store.getTargets(); const from = "2024-01-01T00:00:00.000Z"; const to = "2026-12-31T23:59:59.999Z"; - const response = fetchHandler( - new Request(`http://localhost/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}&pageSize=201`), - ); + const response = await fetch(`${baseUrl}/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}&pageSize=201`); const body = (await response.json()) as Record; expect(response.status).toBe(400); @@ -232,9 +213,7 @@ describe("API 路由", () => { const targets = store.getTargets(); const from = "2024-01-01T00:00:00.000Z"; const to = "2026-12-31T23:59:59.999Z"; - const response = fetchHandler( - new Request(`http://localhost/api/targets/${targets[0]!.id}/trend?from=${from}&to=${to}`), - ); + const response = await fetch(`${baseUrl}/api/targets/${targets[0]!.id}/trend?from=${from}&to=${to}`); const body = (await response.json()) as unknown[]; expect(response.status).toBe(200); @@ -242,10 +221,8 @@ describe("API 路由", () => { }); test("查询不存在的目标返回 404", async () => { - const response = fetchHandler( - new Request( - "http://localhost/api/targets/99999/history?from=2024-01-01T00:00:00.000Z&to=2026-12-31T23:59:59.999Z", - ), + const response = await fetch( + `${baseUrl}/api/targets/99999/history?from=2024-01-01T00:00:00.000Z&to=2026-12-31T23:59:59.999Z`, ); const body = (await response.json()) as Record; @@ -255,7 +232,7 @@ describe("API 路由", () => { test("history 缺少 from/to 参数返回 400", async () => { const targets = store.getTargets(); - const response = fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history`)); + const response = await fetch(`${baseUrl}/api/targets/${targets[0]!.id}/history`); const body = (await response.json()) as Record; expect(response.status).toBe(400); @@ -264,7 +241,7 @@ describe("API 路由", () => { test("trend 缺少 from/to 参数返回 400", async () => { const targets = store.getTargets(); - const response = fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/trend`)); + const response = await fetch(`${baseUrl}/api/targets/${targets[0]!.id}/trend`); const body = (await response.json()) as Record; expect(response.status).toBe(400); @@ -272,8 +249,8 @@ describe("API 路由", () => { }); test("trend 无效 targetId 返回 400", async () => { - const response = fetchHandler( - new Request("http://localhost/api/targets/invalid/trend?from=2024-01-01T00:00:00Z&to=2024-01-02T00:00:00Z"), + const response = await fetch( + `${baseUrl}/api/targets/invalid/trend?from=2024-01-01T00:00:00Z&to=2024-01-02T00:00:00Z`, ); const body = (await response.json()) as Record; @@ -281,44 +258,24 @@ describe("API 路由", () => { expect(body["error"]).toBe("Invalid target ID"); }); - test("未知 /api/* 返回 404", () => { - const response = fetchHandler(new Request("http://localhost/api/missing")); - + test("未知 /api/* 返回 404", async () => { + const response = await fetch(`${baseUrl}/api/missing`); expect(response.status).toBe(404); }); - test("HEAD 请求返回 headers 无 body", async () => { - const response = fetchHandler(new Request("http://localhost/api/summary", { method: "HEAD" })); - const body = await response.text(); - - expect(response.status).toBe(200); - expect(body).toBe(""); - }); - - test("不支持的 method 返回 405", () => { - const response = fetchHandler(new Request("http://localhost/api/summary", { method: "POST" })); - - expect(response.status).toBe(405); - expect(response.headers.get("allow")).toBe("GET, HEAD"); - }); - - test("生产响应包含安全 headers", () => { - const prodHandler = createFetchHandler({ mode: "production", staticAssets, store }); - const response = prodHandler(new Request("http://localhost/api/summary")); - - expect(response.headers.get("x-content-type-options")).toBe("nosniff"); - expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin"); - }); - - test("静态资源和 SPA fallback 正常工作", () => { - const root = fetchHandler(new Request("http://localhost/")); - expect(root.status).toBe(200); - - const fallback = fetchHandler(new Request("http://localhost/dashboard")); - expect(fallback.status).toBe(200); - - const asset = fetchHandler(new Request("http://localhost/assets/app.js")); - expect(asset.status).toBe(200); + test("生产响应包含安全 headers", async () => { + const prodServer = startServer({ + config: { host: "127.0.0.1", port: 0 }, + mode: "production", + store, + }); + try { + const response = await fetch(`http://127.0.0.1:${prodServer.port}/api/summary`); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin"); + } finally { + await prodServer.stop(true); + } }); test("损坏的 failure JSON 返回 null 而不崩溃", async () => { @@ -340,7 +297,7 @@ describe("API 路由", () => { const from = "2025-06-01T00:00:00.000Z"; const to = "2025-06-01T23:59:59.999Z"; - const response = fetchHandler(new Request(`http://localhost/api/targets/${t1Id}/history?from=${from}&to=${to}`)); + const response = await fetch(`${baseUrl}/api/targets/${t1Id}/history?from=${from}&to=${to}`); const body = (await response.json()) as HistoryResponse; expect(response.status).toBe(200); diff --git a/tests/server/bootstrap.test.ts b/tests/server/bootstrap.test.ts index 04cca28..073526c 100644 --- a/tests/server/bootstrap.test.ts +++ b/tests/server/bootstrap.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; -import type { StaticAssets } from "../../src/server/app"; import type { ResolvedConfig } from "../../src/server/checker/config-loader"; import type { ProbeEngine } from "../../src/server/checker/engine"; import type { ProbeStore } from "../../src/server/checker/store"; @@ -75,7 +75,7 @@ function createHarness(overrides: BootstrapDependencies = {}) { startServer(options) { expect(options.config).toEqual({ host: config.host, port: config.port }); expect(options.store).toBe(store); - calls.push(`startServer:${options.mode}:${options.staticAssets ? "static" : "no-static"}`); + calls.push(`startServer:${options.mode}`); }, ...overrides, }; @@ -91,25 +91,16 @@ describe("bootstrap", () => { expect(calls).toEqual([ "loadConfig:/tmp/probes.yaml", - "createStore:/tmp/dial-data/probe.db", + `createStore:${join("/tmp/dial-data", "probe.db")}`, "syncTargets:1", "createEngine:1:3:1000", "engine.start", "onSignal:SIGINT", "onSignal:SIGTERM", - "startServer:development:no-static", + "startServer:development", ]); }); - test("生产模式传递 staticAssets", async () => { - const { calls, dependencies } = createHarness(); - const staticAssets: StaticAssets = { files: {}, indexHtml: new Blob(["ok"]) }; - - await bootstrap({ configPath: "/tmp/probes.yaml", mode: "production", staticAssets }, dependencies); - - expect(calls.at(-1)).toBe("startServer:production:static"); - }); - test("收到退出信号时停止 engine 并关闭 store", async () => { const { calls, dependencies, shutdownHandlers } = createHarness(); diff --git a/tsconfig.json b/tsconfig.json index 7ee70d8..fbde3c6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "moduleDetection": "force", "jsx": "react-jsx", "allowJs": true, - "types": ["bun", "vite/client"], + "types": ["bun"], // Bundler mode "moduleResolution": "bundler", diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index b1d3375..0000000 --- a/vite.config.ts +++ /dev/null @@ -1,36 +0,0 @@ -import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; - -const backendPort = Number(process.env["BACKEND_PORT"] ?? process.env["PORT"] ?? 3000); - -export default defineConfig({ - build: { - assetsDir: "assets", - emptyOutDir: true, - outDir: "../../dist/web", - rolldownOptions: { - output: { - codeSplitting: { - groups: [ - { name: "vendor-react", test: /node_modules\/(react|react-dom|scheduler)/ }, - { name: "vendor-tdesign", test: /node_modules\/tdesign/ }, - { name: "vendor-chart", test: /node_modules\/(recharts|d3-)/ }, - ], - }, - }, - }, - }, - plugins: [react()], - root: "src/web", - server: { - host: "127.0.0.1", - port: 5173, - proxy: { - "/api": { - changeOrigin: true, - target: `http://127.0.0.1:${backendPort}`, - }, - }, - strictPort: true, - }, -});