1
0

refactor: 迁移 Bun fullstack 架构

This commit is contained in:
2026-05-14 00:23:37 +08:00
parent bcfac52112
commit 6e485cc991
36 changed files with 403 additions and 1081 deletions

View File

@@ -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 shutdownengine.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 importSPA 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/` 下创建 `<name>.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: <code> }`,状态码 400/404/405/503
- **API 错误**`{ error: "描述", status: <code> }`,状态码 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 importfullstack 模式) | 开发服务与生产构建 |
| 语言 | 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<string, Blob>` + `indexHtml: Blob`),后端同时提供 API 和静态文件服务。
```
./dist/dial-server probes.yaml
启动后:
访问 http://127.0.0.1:3000/ → 返回前端 SPAindex.html
访问 http://127.0.0.1:3000/api/* → 返回后端 API
访问 /assets/* → 返回带不可变缓存的静态资源
```
SPA fallback 逻辑(`src/server/static.ts`
- `/` → index.html
- 匹配 `/assets/*` → 返回对应文件(未匹配则 404
- 其他路径(如 `/dashboard`)→ fallback 到 index.htmlSPA 路由)
未匹配 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 + 构建)
```
## 已知限制

View File

@@ -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 路由 |
## 运行参数

View File

@@ -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=="],

View File

@@ -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; 多行描述空行后写详细说明

View File

@@ -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 的客户端路由

View File

@@ -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 注入)

View File

@@ -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` 中任一阶段失败

View File

@@ -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 在前端开发工作流文档中说明日常检查和完整验证命令。

View File

@@ -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 在收到终止信号时正确清理资源。

View File

@@ -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 headerbody 为空
#### Scenario: 不支持的 method 请求
- **WHEN** 客户端使用 POST/PUT/DELETE/HEAD 等未声明 method 请求 `/api/meta`
- **THEN** `/api/*` 通配符 SHALL 返回 JSON 404 响应
### Requirement: MetaResponse 共享类型
系统 SHALL 在 `src/shared/api.ts` 中定义 `MetaResponse` 类型。

View File

@@ -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 headerbody 为空
#### Scenario: 不支持的 method 请求
- **WHEN** 客户端使用 POST/PUT/DELETE/HEAD 等未声明 method 请求 `/api/meta`
- **THEN** `/api/*` 通配符 SHALL 返回 JSON 404 响应
### Requirement: MetaResponse 共享类型
系统 SHALL 在 `src/shared/api.ts` 中定义 `MetaResponse` 类型。

View File

@@ -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` 完成启动

View File

@@ -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 使命令失败

View File

@@ -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",

View File

@@ -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<string[]> {
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);
});
`,
);
}

View File

@@ -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);
}

View File

@@ -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<HealthResponse>(`${baseUrl}/health`, 200);
assert(health.ok === true, "健康检查响应缺少 ok=true");
assertSecurityHeaders(healthResponse, "/health");
const { body: summary } = await expectJson<SummaryResponse>(`${baseUrl}/api/summary`, 200);
assert(summary.total === 1, "总览统计: total 应为 1");
assertSecurityHeaders(await fetch(`${baseUrl}/api/summary`), "/api/summary");
const { body: targets } = await expectJson<unknown[]>(`${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<T = unknown>(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<Uint8Array>): Promise<string> {
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}`);
}

View File

@@ -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<string, Blob>;
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);
}

View File

@@ -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<ProbeEngine, "start" | "stop">;
@@ -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) {

View File

@@ -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,
});
}

12
src/server/main.ts Normal file
View File

@@ -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);
});

View File

@@ -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,

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -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}`);

View File

@@ -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);
}

1
src/web/css.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "*.css";

View File

@@ -8,6 +8,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -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(['<!doctype html><title>DiAL</title><div id="root"></div>'], {
type: "text/html",
}),
};
describe("API 路由", () => {
let tempDir: string;
let store: ProbeStore;
let fetchHandler: ReturnType<typeof createFetchHandler>;
let server: ReturnType<typeof startServer>;
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<string, unknown>;
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<string, unknown>;
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
@@ -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);

View File

@@ -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();

View File

@@ -7,7 +7,7 @@
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"types": ["bun", "vite/client"],
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",

View File

@@ -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,
},
});