feat: 引入分层配置生命周期,支持变量引用和 JSON Schema 校验
- 新增 src/server/config/ 模块(types、issues、variables、normalizer、schema)
- 配置布局从 server.host/server.port 切换为 server.listen.host/server.listen.port
- 移除 HOST/PORT 隐式环境变量覆盖,改为 YAML 显式 ${KEY} 变量引用
- 支持 ${KEY}、${KEY|default}、${KEY|}、$${KEY} 变量语法
- 使用 @sinclair/typebox + ajv 实现运行时严格契约校验和 JSON Schema 导出
- 新增 scripts/generate-config-schema.ts 和 config.schema.json
- 新增 bun run schema / schema:check 命令,check 先执行 schema:check
- 更新 README.md 和 DEVELOPMENT.md 匹配新配置体系
- 新增变量解析、schema 校验和 schema 同步测试
This commit is contained in:
@@ -23,7 +23,8 @@ my-app 开发文档
|
||||
src/
|
||||
server/
|
||||
bootstrap.ts 后端统一启动引导(loadServerConfig → startServer)
|
||||
config.ts CLI 参数解析与配置文件加载(可选 YAML configPath,支持 --help/-h)
|
||||
config.ts CLI 参数解析与配置文件加载 facade(可选 YAML configPath,支持 --help/-h)
|
||||
config/ 配置解析模块(types、issues、variables、normalizer、schema)
|
||||
dev.ts 开发模式启动入口(mode: "development")
|
||||
main.ts 生产模式启动入口(mode: "production",安全头启用)
|
||||
server.ts HTTP server 启动工厂(Bun.serve routes 声明式路由 + fetch fallback 静态资源服务)
|
||||
@@ -63,10 +64,11 @@ src/
|
||||
time.ts 时间处理(formatCountdown、formatDurationUnit、formatRelativeTime、isOlderThan、subtractHours)
|
||||
menu.tsx 菜单配置(路由与菜单项统一数据源)
|
||||
routes.tsx 路由配置(定义所有页面路由)
|
||||
scripts/
|
||||
dev.ts 双进程开发服务(Bun API server + Vite dev server)
|
||||
build.ts Vite → codegen → Bun compile 三步构建流水线(含版本号注入)
|
||||
bump-version-logic.ts 纯版本管理逻辑(parse、validate、bump、format)
|
||||
scripts/
|
||||
dev.ts 双进程开发服务(Bun API server + Vite dev server)
|
||||
build.ts Vite → codegen → Bun compile 三步构建流水线(含版本号注入)
|
||||
generate-config-schema.ts 配置 JSON Schema 生成与同步校验
|
||||
bump-version-logic.ts 纯版本管理逻辑(parse、validate、bump、format)
|
||||
bump-version.ts 版本升迁 CLI 脚本
|
||||
clean.ts 清理构建产物与临时文件
|
||||
tests/ Bun test 测试(结构镜像 src 目录)
|
||||
@@ -75,13 +77,17 @@ tests/ Bun test 测试(结构镜像 src 目录)
|
||||
server/ 后端测试
|
||||
bootstrap.test.ts
|
||||
config.test.ts
|
||||
config/ 配置模块测试
|
||||
variables.test.ts
|
||||
schema.test.ts
|
||||
middleware.test.ts
|
||||
static.test.ts
|
||||
web/ 前端测试
|
||||
App.test.tsx
|
||||
test-utils.tsx
|
||||
openspec/ OpenSpec 变更、规格文档与 fast-drive workflow schema
|
||||
config.example.yaml 配置文件示例
|
||||
config.example.yaml 配置文件示例(server.listen 布局 + 显式变量引用)
|
||||
config.schema.json 配置文件 JSON Schema(由 bun run schema 生成)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -188,30 +194,74 @@ export function handleMeta(mode: RuntimeMode, version: string): Response;
|
||||
|
||||
### 1.6 配置文件规范
|
||||
|
||||
配置加载流程:
|
||||
配置采用分层生命周期:`unknown → AuthoringConfig → NormalizedConfig → ValidatedConfig → ServerConfig`。
|
||||
|
||||
```
|
||||
CLI argv → parseRuntimeArgs → { configPath? }
|
||||
→ loadServerConfig(configPath)
|
||||
→ 可选 YAML 文件解析 → env 覆盖 → 默认值
|
||||
→ ServerConfig{ host, port }
|
||||
→ 无 configPath → 默认值 { host: "127.0.0.1", port: 3000 }
|
||||
→ 有 configPath → YAML 解析 → normalize(变量替换) → strict validate → resolve → ServerConfig{ host, port }
|
||||
```
|
||||
|
||||
配置加载流程:
|
||||
|
||||
1. **解析 YAML**:`Bun.YAML.parse` 将配置文件解析为 `unknown`
|
||||
2. **normalize**:提取 `variables`,替换 `${KEY}` / `${KEY|default}` 变量引用,移除 `variables` 段,产出 `NormalizedConfig`
|
||||
3. **strict validate**:使用 Ajv + TypeBox 生成的 schema 严格校验(拒绝未知字段、校验类型和范围)
|
||||
4. **resolve**:从校验后的配置中提取 `server.listen.host` 和 `server.listen.port`,填充默认值
|
||||
|
||||
`ServerConfig` 包含以下字段:
|
||||
|
||||
| 字段 | 来源 | 默认值 |
|
||||
| ------ | ------------------------------------------------- | ----------- |
|
||||
| `host` | `process.env["HOST"]` → YAML `server.host` → 默认 | `127.0.0.1` |
|
||||
| `port` | `process.env["PORT"]` → YAML `server.port` → 默认 | `3000` |
|
||||
| 字段 | 来源 | 默认值 |
|
||||
| ------ | ------------------------------------------ | ----------- |
|
||||
| `host` | `server.listen.host`(可含变量引用)→ 默认 | `127.0.0.1` |
|
||||
| `port` | `server.listen.port`(可含变量引用)→ 默认 | `3000` |
|
||||
|
||||
配置文件示例(`config.example.yaml`):
|
||||
|
||||
```yaml
|
||||
# yaml-language-server: $schema=./config.schema.json
|
||||
server:
|
||||
host: "127.0.0.1"
|
||||
port: 3000
|
||||
listen:
|
||||
host: "${HOST|127.0.0.1}"
|
||||
port: ${PORT|3000}
|
||||
```
|
||||
|
||||
变量语法:
|
||||
|
||||
| 语法 | 说明 |
|
||||
| --------------- | ------------------------------ |
|
||||
| `${KEY}` | 引用变量,未定义时报错 |
|
||||
| `${KEY\|value}` | 引用变量,未定义时使用默认值 |
|
||||
| `${KEY\|}` | 引用变量,未定义时使用空字符串 |
|
||||
| `$${KEY}` | 转义,输出 `${KEY}` 原文 |
|
||||
|
||||
变量解析优先级:`variables 字段` → `process.env` → `默认值` → `unresolved 报错`
|
||||
|
||||
完整变量引用保留原始类型,部分拼接转为 string。环境变量不会隐式覆盖配置。
|
||||
|
||||
配置模块结构(`src/server/config/`):
|
||||
|
||||
| 文件 | 职责 |
|
||||
| --------------------- | -------------------------------------------------------- |
|
||||
| `types.ts` | AuthoringConfig、NormalizedConfig、ValidatedConfig 类型 |
|
||||
| `issues.ts` | 结构化配置问题、路径渲染、去重、中文格式化 |
|
||||
| `variables.ts` | 变量提取、引用解析、默认值、环境变量查找、类型推断 |
|
||||
| `normalizer.ts` | Authoring → Normalized 转换(变量替换 + 移除 variables) |
|
||||
| `schema/fragments.ts` | TypeBox schema 片段 |
|
||||
| `schema/builder.ts` | Authoring/Normalized schema 构建 |
|
||||
| `schema/validate.ts` | Ajv strict 校验 + 错误映射 |
|
||||
| `schema/export.ts` | JSON Schema 导出(用于生成 config.schema.json) |
|
||||
|
||||
JSON Schema 相关命令:
|
||||
|
||||
```bash
|
||||
bun run schema # 重新生成 config.schema.json
|
||||
bun run schema:check # 校验 config.schema.json 是否同步
|
||||
```
|
||||
|
||||
运行时依赖 `@sinclair/typebox`(JSON Schema 类型构建)和 `ajv`(JSON Schema 校验),用于配置启动时严格契约校验。
|
||||
|
||||
### 1.7 版本管理
|
||||
|
||||
项目使用 `package.json.version` 作为版本号唯一来源,严格 `MAJOR.MINOR.PATCH` 格式。
|
||||
@@ -573,11 +623,9 @@ bun run verify
|
||||
|
||||
### 3.6 环境变量
|
||||
|
||||
| 变量 | 用途 | 默认值 |
|
||||
| --------------------------- | ----------------------------------------------- | ----------- |
|
||||
| `BUN_TARGET`/`BUILD_TARGET` | 交叉编译目标平台(仅在 `bun run build` 时有效) | 当前平台 |
|
||||
| `HOST` | 服务监听地址 | `127.0.0.1` |
|
||||
| `PORT` | 服务监听端口 | `3000` |
|
||||
| 变量 | 用途 | 默认值 |
|
||||
| --------------------------- | ----------------------------------------------- | -------- |
|
||||
| `BUN_TARGET`/`BUILD_TARGET` | 交叉编译目标平台(仅在 `bun run build` 时有效) | 当前平台 |
|
||||
|
||||
### 3.7 项目配置文件
|
||||
|
||||
@@ -590,7 +638,8 @@ bun run verify
|
||||
| `.prettierrc.json` | Prettier 格式化规则(`printWidth: 120`) |
|
||||
| `.prettierignore` | Prettier 排除路径 |
|
||||
| `.lintstagedrc.json` | lint-staged 配置(TS/TSX → ESLint,MD/JSON/YAML → Prettier) |
|
||||
| `config.example.yaml` | 配置文件示例 |
|
||||
| `config.example.yaml` | 配置文件示例(server.listen 布局 + 显式变量引用) |
|
||||
| `config.schema.json` | 配置文件 JSON Schema(由 bun run schema 生成) |
|
||||
| `vite.config.ts` | Vite 构建配置(React 插件、代码分割、API proxy) |
|
||||
| `bunfig.toml` | Bun 配置(测试 preload、排除规则) |
|
||||
| `opencode.json` | OpenCode 工具配置 |
|
||||
@@ -624,8 +673,10 @@ bun run verify
|
||||
bun run lint # ESLint 检查(含类型感知规则、导入排序、导入验证、Prettier 格式)
|
||||
bun run format # Prettier 自动格式化
|
||||
bun run typecheck # TypeScript 类型检查
|
||||
bun run schema # 生成 config.schema.json
|
||||
bun run schema:check # 校验 config.schema.json 是否同步
|
||||
bun test # 运行所有测试
|
||||
bun run check # 一键运行 typecheck + lint + test
|
||||
bun run check # 一键运行 schema:check + typecheck + lint + test
|
||||
bun run verify # 完整验证(check + build)
|
||||
```
|
||||
|
||||
@@ -735,4 +786,5 @@ bun run verify # 完整验证(check + 构建)
|
||||
- 当前仅为单页面应用,不涉及用户认证和权限控制
|
||||
- 不支持集群部署,单进程运行
|
||||
- 配置文件仅支持 YAML 格式,不支持热加载
|
||||
- 变量替换范围仅限 `server` 子树
|
||||
- 无国际化和多语言支持
|
||||
|
||||
64
README.md
64
README.md
@@ -77,7 +77,9 @@ bun run dev
|
||||
| `bun run lint` | ESLint 代码风格检查 |
|
||||
| `bun run format` | Prettier 代码格式化 |
|
||||
| `bun run typecheck` | TypeScript 类型检查 |
|
||||
| `bun run check` | 完整质量检查:typecheck + lint + test |
|
||||
| `bun run schema` | 生成 config.schema.json |
|
||||
| `bun run schema:check` | 校验 config.schema.json 是否同步 |
|
||||
| `bun run check` | 完整质量检查:schema:check + typecheck + lint + test |
|
||||
| `bun run verify` | 验证构建流程:check + build |
|
||||
| `bun run clean` | 清理构建产物和临时文件 |
|
||||
| `bun run version:patch` | 升迁 patch 版本(x.y.Z) |
|
||||
@@ -89,7 +91,8 @@ bun run dev
|
||||
|
||||
```text
|
||||
.
|
||||
├── config.example.yaml # 配置文件示例
|
||||
├── config.example.yaml # 配置文件示例(server.listen 布局 + 显式变量引用)
|
||||
├── config.schema.json # 配置文件 JSON Schema(由 bun run schema 生成)
|
||||
├── bunfig.toml # Bun 配置(测试预加载等)
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
├── vite.config.ts # Vite 构建配置(代码分包、代理)
|
||||
@@ -100,13 +103,15 @@ bun run dev
|
||||
├── scripts/
|
||||
│ ├── dev.ts # 开发启动脚本(并行启动 API + Vite)
|
||||
│ ├── build.ts # 生产构建脚本(Vite → 代码生成 → Bun compile,含版本号注入)
|
||||
│ ├── generate-config-schema.ts # 配置 JSON Schema 生成与同步校验脚本
|
||||
│ ├── bump-version-logic.ts # 纯版本管理逻辑(parse、validate、bump、format)
|
||||
│ ├── bump-version.ts # 版本升迁 CLI 脚本
|
||||
│ └── clean.ts # 清理脚本
|
||||
├── src/
|
||||
│ ├── server/ # 后端代码
|
||||
│ │ ├── bootstrap.ts # 统一启动引导(配置加载 → 服务启动 → 优雅关闭)
|
||||
│ │ ├── config.ts # CLI 参数解析 + YAML 配置加载
|
||||
│ │ ├── config.ts # CLI 参数解析 + YAML 配置加载 facade
|
||||
│ │ ├── config/ # 配置解析模块(types、issues、variables、normalizer、schema)
|
||||
│ │ ├── dev.ts # 开发模式入口
|
||||
│ │ ├── main.ts # 生产模式入口
|
||||
│ │ ├── server.ts # HTTP 服务器(Bun.serve routes 声明式路由)
|
||||
@@ -145,31 +150,54 @@ bun run dev
|
||||
|
||||
## 配置
|
||||
|
||||
项目使用 YAML 配置文件,支持环境变量覆盖。
|
||||
项目使用 YAML 配置文件,支持通过 JSON Schema 编辑器提示和显式变量引用。
|
||||
|
||||
### 配置文件
|
||||
|
||||
复制 `config.example.yaml` 为 `config.yaml`(或任意名称),根据需要修改:
|
||||
|
||||
```yaml
|
||||
# yaml-language-server: $schema=./config.schema.json
|
||||
server:
|
||||
host: "127.0.0.1"
|
||||
port: 3000
|
||||
listen:
|
||||
host: "${HOST|127.0.0.1}"
|
||||
port: ${PORT|3000}
|
||||
```
|
||||
|
||||
### 环境变量覆盖
|
||||
配置文件唯一合法布局为 `server.listen.host` 和 `server.listen.port`。
|
||||
|
||||
| 环境变量 | 对应配置字段 | 默认值 |
|
||||
| -------- | ------------- | ----------- |
|
||||
| `HOST` | `server.host` | `127.0.0.1` |
|
||||
| `PORT` | `server.port` | `3000` |
|
||||
### JSON Schema
|
||||
|
||||
根目录 `config.schema.json` 为配置文件的 JSON Schema,支持 IDE 自动补全和校验。通过 `# yaml-language-server: $schema=./config.schema.json` 注释启用。
|
||||
|
||||
```bash
|
||||
bun run schema # 重新生成 config.schema.json
|
||||
bun run schema:check # 校验 schema 是否同步
|
||||
```
|
||||
|
||||
### 变量语法
|
||||
|
||||
YAML 配置中支持显式变量引用:
|
||||
|
||||
| 语法 | 说明 |
|
||||
| --------------- | ------------------------------ |
|
||||
| `${KEY}` | 引用变量,未定义时报错 |
|
||||
| `${KEY\|value}` | 引用变量,未定义时使用默认值 |
|
||||
| `${KEY\|}` | 引用变量,未定义时使用空字符串 |
|
||||
| `$${KEY}` | 转义,输出 `${KEY}` 原文字面量 |
|
||||
|
||||
变量解析优先级:`variables 字段` → `process.env` → `默认值` → `unresolved 报错`
|
||||
|
||||
完整变量引用(整个值只有 `${...}`)保留原始类型:`${PORT|3000}` 解析为 number `3000`。部分拼接统一转为 string。
|
||||
|
||||
### 配置优先级
|
||||
|
||||
```
|
||||
环境变量 > YAML 配置文件 > 代码默认值
|
||||
variables 字段 > 环境变量 > 默认值 > unresolved 报错
|
||||
```
|
||||
|
||||
环境变量**不会隐式覆盖**配置,只有通过 `${KEY}` 显式引用时才生效。
|
||||
|
||||
### 使用自定义配置
|
||||
|
||||
```bash
|
||||
@@ -187,11 +215,13 @@ bun run dev custom-config.yaml
|
||||
|
||||
### 后端
|
||||
|
||||
| 技术 | 说明 |
|
||||
| -------------------------------------------- | ---------------------------- |
|
||||
| `Bun.serve` | HTTP 服务器,声明式路由匹配 |
|
||||
| `Bun.YAML` | YAML 配置文件解析 |
|
||||
| [es-toolkit](https://es-toolkit.slash.page/) | 高性能工具库(推荐优先使用) |
|
||||
| 技术 | 说明 |
|
||||
| ------------------------------------------------------------ | ---------------------------- |
|
||||
| `Bun.serve` | HTTP 服务器,声明式路由匹配 |
|
||||
| `Bun.YAML` | YAML 配置文件解析 |
|
||||
| [@sinclair/typebox](https://github.com/sinclairzx81/typebox) | JSON Schema 类型构建器 |
|
||||
| [Ajv](https://ajv.js.org/) | JSON Schema 运行时校验 |
|
||||
| [es-toolkit](https://es-toolkit.slash.page/) | 高性能工具库(推荐优先使用) |
|
||||
|
||||
### 前端
|
||||
|
||||
|
||||
16
bun.lock
16
bun.lock
@@ -5,7 +5,9 @@
|
||||
"": {
|
||||
"name": "gateway-checker",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.34.49",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"ajv": "^8.20.0",
|
||||
"es-toolkit": "^1.46.1",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
@@ -228,6 +230,8 @@
|
||||
|
||||
"@simple-libs/stream-utils": ["@simple-libs/stream-utils@1.2.0", "https://registry.npmmirror.com/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", {}, "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA=="],
|
||||
|
||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.49", "https://registry.npmmirror.com/@sinclair/typebox/-/typebox-0.34.49.tgz", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
@@ -356,7 +360,7 @@
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"ajv": ["ajv@6.15.0", "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||
"ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"ansi-escapes": ["ansi-escapes@7.3.0", "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
|
||||
|
||||
@@ -740,7 +744,7 @@
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
|
||||
@@ -1092,8 +1096,6 @@
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"@commitlint/config-validator/ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"@commitlint/is-ignored/semver": ["semver@7.8.0", "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
|
||||
"@conventional-changelog/git-client/semver": ["semver@7.8.0", "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
@@ -1144,6 +1146,8 @@
|
||||
|
||||
"cliui/wrap-ansi": ["wrap-ansi@9.0.2", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||
|
||||
"eslint/ajv": ["ajv@6.15.0", "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||
|
||||
"eslint-import-resolver-node/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"eslint-module-utils/debug": ["debug@3.2.7", "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
@@ -1178,8 +1182,6 @@
|
||||
|
||||
"wrap-ansi/string-width": ["string-width@8.2.1", "https://registry.npmmirror.com/string-width/-/string-width-8.2.1.tgz", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
|
||||
|
||||
"@commitlint/config-validator/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.3.tgz", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.3.tgz", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="],
|
||||
@@ -1198,6 +1200,8 @@
|
||||
|
||||
"eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.14", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
|
||||
|
||||
"eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"log-update/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# yaml-language-server: $schema=./config.schema.json
|
||||
server:
|
||||
host: "127.0.0.1"
|
||||
port: 3000
|
||||
listen:
|
||||
host: "${HOST|127.0.0.1}"
|
||||
port: ${PORT|3000}
|
||||
|
||||
54
config.schema.json
Normal file
54
config.schema.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"listen": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"pattern": "^\\$\\{[^}]+\\}$",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"variables": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^[a-zA-Z_][a-zA-Z0-9_]*$": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$id": "https://app.local/config.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
}
|
||||
@@ -10,7 +10,9 @@
|
||||
"build": "bun run scripts/build.ts",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier . --write",
|
||||
"check": "bun run typecheck && bun run lint && bun test",
|
||||
"check": "bun run schema:check && bun run typecheck && bun run lint && bun test",
|
||||
"schema": "bun run scripts/generate-config-schema.ts",
|
||||
"schema:check": "bun run scripts/generate-config-schema.ts -- --check",
|
||||
"verify": "bun run check && bun run build",
|
||||
"test": "bun test",
|
||||
"clean": "bun run scripts/clean.ts",
|
||||
@@ -49,7 +51,9 @@
|
||||
"vite": "^8.0.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.34.49",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"ajv": "^8.20.0",
|
||||
"es-toolkit": "^1.46.1",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
|
||||
15
scripts/generate-config-schema.ts
Normal file
15
scripts/generate-config-schema.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createConfigJsonSchema } from "../src/server/config/schema/export";
|
||||
|
||||
const schemaPath = "config.schema.json";
|
||||
const schema = `${JSON.stringify(createConfigJsonSchema(), null, 2)}\n`;
|
||||
|
||||
if (process.argv.includes("--check")) {
|
||||
const existing = await Bun.file(schemaPath)
|
||||
.text()
|
||||
.catch(() => null);
|
||||
if (existing !== schema) {
|
||||
throw new Error(`${schemaPath} 未同步,请运行 bun run schema`);
|
||||
}
|
||||
} else {
|
||||
await Bun.write(schemaPath, schema);
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import { isNumber, isString } from "es-toolkit";
|
||||
|
||||
import type { ConfigValidationIssue } from "./config/issues";
|
||||
|
||||
import { APP } from "../shared/app";
|
||||
import { dedupeIssues, issue, throwConfigIssues } from "./config/issues";
|
||||
import { normalizeAuthoringConfig } from "./config/normalizer";
|
||||
import { validateConfigContract } from "./config/schema/validate";
|
||||
|
||||
export interface ServerConfig {
|
||||
host: string;
|
||||
@@ -8,37 +15,39 @@ export interface ServerConfig {
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 3000;
|
||||
|
||||
interface YAMLConfigFile {
|
||||
server?: YAMLServerBlock;
|
||||
}
|
||||
|
||||
interface YAMLServerBlock {
|
||||
host?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export async function loadServerConfig(configPath?: string): Promise<ServerConfig> {
|
||||
const fileConfig: { host?: string; port?: number } = {};
|
||||
|
||||
if (configPath) {
|
||||
const file = Bun.file(configPath);
|
||||
if (!(await file.exists())) {
|
||||
throw new Error(`配置文件不存在: ${configPath}`);
|
||||
}
|
||||
const content = await file.text();
|
||||
const parsed = Bun.YAML.parse(content) as YAMLConfigFile;
|
||||
if (parsed.server) {
|
||||
if (parsed.server.host !== undefined) fileConfig.host = parsed.server.host;
|
||||
if (parsed.server.port !== undefined) fileConfig.port = parsed.server.port;
|
||||
}
|
||||
if (!configPath) {
|
||||
return { host: DEFAULT_HOST, port: DEFAULT_PORT };
|
||||
}
|
||||
|
||||
const envPortNum = parseInt(process.env["PORT"] ?? "", 10);
|
||||
const file = Bun.file(configPath);
|
||||
if (!(await file.exists())) {
|
||||
throw new Error(`配置文件不存在: ${configPath}`);
|
||||
}
|
||||
|
||||
return {
|
||||
host: process.env["HOST"] ?? fileConfig.host ?? DEFAULT_HOST,
|
||||
port: !isNaN(envPortNum) ? envPortNum : (fileConfig.port ?? DEFAULT_PORT),
|
||||
};
|
||||
const content = await file.text();
|
||||
const parsed = Bun.YAML.parse(content);
|
||||
|
||||
const normalizeResult = normalizeAuthoringConfig(parsed);
|
||||
if (normalizeResult.issues.length > 0) {
|
||||
throwConfigIssues(dedupeIssues(normalizeResult.issues));
|
||||
}
|
||||
|
||||
const normalizedConfig = normalizeResult.config;
|
||||
const contractResult = validateConfigContract(normalizedConfig);
|
||||
if (contractResult.config === null) {
|
||||
throwConfigIssues(dedupeIssues(contractResult.issues));
|
||||
}
|
||||
|
||||
const allIssues: ConfigValidationIssue[] = [...contractResult.issues];
|
||||
const runtimeIssues = validateRuntimeConfig(contractResult.config);
|
||||
allIssues.push(...runtimeIssues);
|
||||
|
||||
if (allIssues.length > 0) {
|
||||
throwConfigIssues(dedupeIssues(allIssues));
|
||||
}
|
||||
|
||||
return resolveServerConfig(contractResult.config);
|
||||
}
|
||||
|
||||
export function parseRuntimeArgs(argv: string[] = Bun.argv.slice(2)): { configPath?: string } {
|
||||
@@ -51,3 +60,34 @@ export function parseRuntimeArgs(argv: string[] = Bun.argv.slice(2)): { configPa
|
||||
}
|
||||
return { configPath: firstArg };
|
||||
}
|
||||
|
||||
function resolveServerConfig(config: object): ServerConfig {
|
||||
const configRecord = config as Record<string, unknown>;
|
||||
const server = configRecord["server"] as Record<string, unknown> | undefined;
|
||||
const listen = server?.["listen"] as Record<string, unknown> | undefined;
|
||||
|
||||
const host = (listen?.["host"] as string | undefined) ?? DEFAULT_HOST;
|
||||
const port = (listen?.["port"] as number | undefined) ?? DEFAULT_PORT;
|
||||
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
function validateRuntimeConfig(config: object): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const configRecord = config as Record<string, unknown>;
|
||||
const server = configRecord["server"] as Record<string, unknown> | undefined;
|
||||
const listen = server?.["listen"] as Record<string, unknown> | undefined;
|
||||
|
||||
if (listen !== undefined) {
|
||||
const portValue = listen["port"];
|
||||
if (isString(portValue)) {
|
||||
issues.push(
|
||||
issue("invalid-type", "server.listen.port", "端口必须为整数,不能为字符串(如需使用变量请使用 ${VAR} 语法)"),
|
||||
);
|
||||
} else if (isNumber(portValue) && (!Number.isInteger(portValue) || portValue < 0 || portValue > 65535)) {
|
||||
issues.push(issue("invalid-range", "server.listen.port", "端口必须为 0-65535 之间的整数"));
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
17
src/server/config/index.ts
Normal file
17
src/server/config/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export { issue, joinPath, renderPath, throwConfigIssues } from "./issues";
|
||||
export { normalizeAuthoringConfig } from "./normalizer";
|
||||
export {
|
||||
createAuthoringConfigSchema,
|
||||
createExternalConfigSchema,
|
||||
createNormalizedConfigSchema,
|
||||
} from "./schema/builder";
|
||||
export { createConfigJsonSchema } from "./schema/export";
|
||||
export { createConfigAjv, issuesFromAjvErrors, validateConfigContract } from "./schema/validate";
|
||||
export type {
|
||||
AuthoringConfig,
|
||||
ConfigVariableValue,
|
||||
NormalizedConfig,
|
||||
NormalizedServer,
|
||||
ValidatedConfig,
|
||||
} from "./types";
|
||||
export { extractVariables, resolveVariables } from "./variables";
|
||||
43
src/server/config/issues.ts
Normal file
43
src/server/config/issues.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export interface ConfigValidationIssue {
|
||||
code: string;
|
||||
message: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] {
|
||||
const seen = new Set<string>();
|
||||
const result: ConfigValidationIssue[] = [];
|
||||
for (const item of issues) {
|
||||
const key = `${item.code}:${item.path}:${item.message}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatConfigIssues(issues: ConfigValidationIssue[]): string {
|
||||
return issues.map(formatConfigIssue).join("\n");
|
||||
}
|
||||
|
||||
export function issue(code: string, path: string, message: string): ConfigValidationIssue {
|
||||
return { code, message, path };
|
||||
}
|
||||
|
||||
export function joinPath(base: string, key: string): string {
|
||||
if (base === "") return key;
|
||||
if (key.startsWith("[")) return `${base}${key}`;
|
||||
return `${base}.${key}`;
|
||||
}
|
||||
|
||||
export function renderPath(path: string): string {
|
||||
return path === "" ? "配置文件" : path;
|
||||
}
|
||||
|
||||
export function throwConfigIssues(issues: ConfigValidationIssue[]): never {
|
||||
throw new Error(formatConfigIssues(issues));
|
||||
}
|
||||
|
||||
function formatConfigIssue(i: ConfigValidationIssue): string {
|
||||
return `${renderPath(i.path)} ${i.message}`;
|
||||
}
|
||||
18
src/server/config/normalizer.ts
Normal file
18
src/server/config/normalizer.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { isPlainObject } from "es-toolkit";
|
||||
|
||||
import type { ConfigValidationIssue } from "./issues";
|
||||
|
||||
import { resolveVariables } from "./variables";
|
||||
|
||||
export function normalizeAuthoringConfig(config: unknown): {
|
||||
config: unknown;
|
||||
issues: ConfigValidationIssue[];
|
||||
} {
|
||||
const variableResult = resolveVariables(config);
|
||||
if (!isPlainObject(variableResult.config)) {
|
||||
return variableResult;
|
||||
}
|
||||
|
||||
const normalized = { ...(variableResult.config as Record<string, unknown>) };
|
||||
return { config: normalized, issues: variableResult.issues };
|
||||
}
|
||||
65
src/server/config/schema/builder.ts
Normal file
65
src/server/config/schema/builder.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { TSchema } from "@sinclair/typebox";
|
||||
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import { variableValueSchema } from "./fragments";
|
||||
|
||||
type SchemaKind = "authoring" | "normalized";
|
||||
|
||||
export function createAuthoringConfigSchema(): TSchema {
|
||||
return createConfigSchemaForKind("authoring");
|
||||
}
|
||||
|
||||
export function createExternalConfigSchema(): Record<string, unknown> {
|
||||
return {
|
||||
...cloneSchema(createAuthoringConfigSchema()),
|
||||
$id: "https://app.local/config.schema.json",
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
};
|
||||
}
|
||||
|
||||
export function createNormalizedConfigSchema(): TSchema {
|
||||
return createConfigSchemaForKind("normalized");
|
||||
}
|
||||
|
||||
function cloneSchema(schema: TSchema): Record<string, unknown> {
|
||||
return JSON.parse(JSON.stringify(schema)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function createAuthoringFieldSchema(schema: TSchema): TSchema {
|
||||
return Type.Unsafe({ anyOf: [schema, { pattern: "^\\$\\{[^}]+\\}$", type: "string" }] });
|
||||
}
|
||||
|
||||
function createConfigSchemaForKind(kind: SchemaKind): TSchema {
|
||||
const properties: Record<string, TSchema> = {
|
||||
server: Type.Optional(createServerSchema(kind)),
|
||||
};
|
||||
if (kind === "authoring") {
|
||||
properties["variables"] = Type.Optional(
|
||||
Type.Record(Type.String({ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$" }), variableValueSchema),
|
||||
);
|
||||
}
|
||||
return Type.Object(properties, { additionalProperties: false });
|
||||
}
|
||||
|
||||
function createServerSchema(kind: SchemaKind): TSchema {
|
||||
return Type.Object(
|
||||
{
|
||||
listen: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
host: Type.Optional(Type.String()),
|
||||
port: Type.Optional(integerForKind(kind, { maximum: 65535, minimum: 0 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
}
|
||||
|
||||
function integerForKind(kind: SchemaKind, options?: Parameters<typeof Type.Integer>[0]): TSchema {
|
||||
const schema = Type.Integer(options);
|
||||
return kind === "authoring" ? createAuthoringFieldSchema(schema) : schema;
|
||||
}
|
||||
5
src/server/config/schema/export.ts
Normal file
5
src/server/config/schema/export.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createExternalConfigSchema } from "./builder";
|
||||
|
||||
export function createConfigJsonSchema(): Record<string, unknown> {
|
||||
return createExternalConfigSchema();
|
||||
}
|
||||
3
src/server/config/schema/fragments.ts
Normal file
3
src/server/config/schema/fragments.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
export const variableValueSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean()]);
|
||||
110
src/server/config/schema/validate.ts
Normal file
110
src/server/config/schema/validate.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { ErrorObject } from "ajv";
|
||||
|
||||
import Ajv from "ajv";
|
||||
|
||||
import type { ConfigValidationIssue } from "../issues";
|
||||
|
||||
import { issue } from "../issues";
|
||||
import { createNormalizedConfigSchema } from "./builder";
|
||||
|
||||
export function createConfigAjv(): Ajv {
|
||||
return new Ajv({ allErrors: true, coerceTypes: false, removeAdditional: false, strict: true, useDefaults: false });
|
||||
}
|
||||
|
||||
export function issuesFromAjvErrors(errors: ErrorObject[], root: unknown, basePath = ""): ConfigValidationIssue[] {
|
||||
return normalizeAjvErrors(errors, basePath).map((error) => issueFromAjvError(error, root, basePath));
|
||||
}
|
||||
|
||||
export function validateConfigContract(
|
||||
config: unknown,
|
||||
): { config: null; issues: ConfigValidationIssue[] } | { config: object; issues: [] } {
|
||||
const ajv = createConfigAjv();
|
||||
const rootValidate = ajv.compile(createNormalizedConfigSchema());
|
||||
if (!rootValidate(config)) {
|
||||
const issues = issuesFromAjvErrors(rootValidate.errors ?? [], config);
|
||||
return { config: null, issues };
|
||||
}
|
||||
|
||||
return { config: config as object, issues: [] as [] };
|
||||
}
|
||||
|
||||
function buildIssuePath(basePath: string, error: ErrorObject): string {
|
||||
const pointerPath = jsonPointerToPath(error.instancePath);
|
||||
let path = basePath ? joinBasePath(basePath, pointerPath) : pointerPath;
|
||||
if (error.keyword === "required" && "missingProperty" in error.params) {
|
||||
path = joinBasePath(path, String(error.params["missingProperty"]));
|
||||
}
|
||||
if (error.keyword === "additionalProperties" && "additionalProperty" in error.params) {
|
||||
path = joinBasePath(path, String(error.params["additionalProperty"]));
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function hasMoreSpecificError(keywords: Set<string>): boolean {
|
||||
return ["const", "enum", "maximum", "minimum", "minLength", "pattern"].some((keyword) => keywords.has(keyword));
|
||||
}
|
||||
|
||||
function issueFromAjvError(error: ErrorObject, _root: unknown, basePath: string): ConfigValidationIssue {
|
||||
const path = buildIssuePath(basePath, error);
|
||||
switch (error.keyword) {
|
||||
case "additionalProperties":
|
||||
return issue("unknown-field", path, "是未知字段");
|
||||
case "const":
|
||||
case "enum":
|
||||
return issue("invalid-value", path, "不在允许范围内");
|
||||
case "maximum":
|
||||
case "minimum":
|
||||
return issue("invalid-range", path, "数值范围不合法");
|
||||
case "minLength":
|
||||
return issue("invalid-format", path, "不能为空");
|
||||
case "pattern":
|
||||
return issue("invalid-format", path, "格式不合法");
|
||||
case "required":
|
||||
return issue("required", path, "缺少必填字段");
|
||||
case "type":
|
||||
return issue("invalid-type", path, "类型不合法");
|
||||
default:
|
||||
return issue("invalid-config", path, error.message ?? "配置不合法");
|
||||
}
|
||||
}
|
||||
|
||||
function joinBasePath(basePath: string, path: string): string {
|
||||
if (basePath === "") return path;
|
||||
if (path === "") return basePath;
|
||||
if (path.startsWith("[")) return `${basePath}${path}`;
|
||||
return `${basePath}.${path}`;
|
||||
}
|
||||
|
||||
function jsonPointerToPath(pointer: string): string {
|
||||
if (pointer === "") return "";
|
||||
return pointer
|
||||
.slice(1)
|
||||
.split("/")
|
||||
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||
.reduce((path, part) => (/^\d+$/.test(part) ? `${path}[${part}]` : joinBasePath(path, part)), "");
|
||||
}
|
||||
|
||||
function normalizeAjvErrors(errors: ErrorObject[], basePath: string): ErrorObject[] {
|
||||
const nonCompositeErrors = errors.filter((error) => error.keyword !== "anyOf" && error.keyword !== "oneOf");
|
||||
const candidates = nonCompositeErrors.length > 0 ? nonCompositeErrors : errors;
|
||||
const keywordsByPath = new Map<string, Set<string>>();
|
||||
|
||||
for (const error of candidates) {
|
||||
const path = buildIssuePath(basePath, error);
|
||||
const keywords = keywordsByPath.get(path) ?? new Set<string>();
|
||||
keywords.add(error.keyword);
|
||||
keywordsByPath.set(path, keywords);
|
||||
}
|
||||
|
||||
const seenValueErrors = new Set<string>();
|
||||
return candidates.filter((error) => {
|
||||
const path = buildIssuePath(basePath, error);
|
||||
const keywords = keywordsByPath.get(path) ?? new Set<string>();
|
||||
if (error.keyword === "type" && hasMoreSpecificError(keywords)) return false;
|
||||
if (error.keyword === "const" || error.keyword === "enum") {
|
||||
if (seenValueErrors.has(path)) return false;
|
||||
seenValueErrors.add(path);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
37
src/server/config/types.ts
Normal file
37
src/server/config/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface AuthoringConfig {
|
||||
server?: AuthoringServer;
|
||||
variables?: Record<string, ConfigVariableValue>;
|
||||
}
|
||||
|
||||
export interface AuthoringServer {
|
||||
listen?: AuthoringServerListen;
|
||||
}
|
||||
|
||||
export interface AuthoringServerListen {
|
||||
host?: string;
|
||||
port?: number | string;
|
||||
}
|
||||
|
||||
export type ConfigVariableValue = boolean | number | string;
|
||||
|
||||
export interface NormalizedConfig {
|
||||
server?: NormalizedServer;
|
||||
}
|
||||
|
||||
export interface NormalizedServer {
|
||||
listen?: NormalizedServerListen;
|
||||
}
|
||||
|
||||
export interface NormalizedServerListen {
|
||||
host?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export interface ResolvedConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface ValidatedConfig {
|
||||
server?: NormalizedServer;
|
||||
}
|
||||
188
src/server/config/variables.ts
Normal file
188
src/server/config/variables.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit";
|
||||
|
||||
import type { ConfigValidationIssue } from "./issues";
|
||||
import type { ConfigVariableValue } from "./types";
|
||||
|
||||
import { issue, joinPath } from "./issues";
|
||||
|
||||
const VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
const VARIABLE_REFERENCE_PATTERN = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?:\|([^}]*))?\}/g;
|
||||
const COMPLETE_VARIABLE_REFERENCE_PATTERN = /^\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?:\|([^}]*))?\}$/;
|
||||
const ESCAPED_VARIABLE_PATTERN = /\$\$\{([^}]*)\}/g;
|
||||
|
||||
interface VariableReference {
|
||||
defaultValue?: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface VariableResolutionContext {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function extractVariables(config: unknown): {
|
||||
issues: ConfigValidationIssue[];
|
||||
variables: Map<string, ConfigVariableValue>;
|
||||
} {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const variables = new Map<string, ConfigVariableValue>();
|
||||
|
||||
if (!isPlainObject(config)) {
|
||||
return { issues, variables };
|
||||
}
|
||||
const configRecord = config as Record<string, unknown>;
|
||||
if (configRecord["variables"] === undefined) {
|
||||
return { issues, variables };
|
||||
}
|
||||
|
||||
const rawVariables: unknown = configRecord["variables"];
|
||||
if (!isPlainObject(rawVariables)) {
|
||||
issues.push(issue("invalid-type", "variables", "必须为对象"));
|
||||
return { issues, variables };
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(rawVariables as Record<string, unknown>)) {
|
||||
const path = joinPath("variables", key);
|
||||
if (!VARIABLE_NAME_PATTERN.test(key)) {
|
||||
issues.push(issue("invalid-format", path, "变量名不符合命名规则"));
|
||||
continue;
|
||||
}
|
||||
if (!isVariableValue(value)) {
|
||||
issues.push(issue("invalid-type", path, `变量值不允许为 ${describeInvalidVariableValue(value)}`));
|
||||
continue;
|
||||
}
|
||||
variables.set(key, value);
|
||||
}
|
||||
|
||||
return { issues, variables };
|
||||
}
|
||||
|
||||
export function resolveVariables(config: unknown): { config: unknown; issues: ConfigValidationIssue[] } {
|
||||
const { issues, variables } = extractVariables(config);
|
||||
if (!isPlainObject(config)) {
|
||||
return { config, issues };
|
||||
}
|
||||
|
||||
return { config: resolveConfigValue(config, variables, issues), issues };
|
||||
}
|
||||
|
||||
function describeInvalidVariableValue(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return "array";
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
function inferStringValue(value: string): ConfigVariableValue {
|
||||
if (value === "") return value;
|
||||
const numberValue = Number(value);
|
||||
if (Number.isFinite(numberValue)) return numberValue;
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return value;
|
||||
}
|
||||
|
||||
function isVariableValue(value: unknown): value is ConfigVariableValue {
|
||||
return isString(value) || isNumber(value) || isBoolean(value);
|
||||
}
|
||||
|
||||
function parseVariableReference(match: RegExpExecArray): VariableReference {
|
||||
return { defaultValue: match[2], key: match[1]! };
|
||||
}
|
||||
|
||||
function replaceStringValue(
|
||||
value: string,
|
||||
variables: Map<string, ConfigVariableValue>,
|
||||
issues: ConfigValidationIssue[],
|
||||
context: VariableResolutionContext,
|
||||
): ConfigVariableValue | string {
|
||||
const trimmed = value.trim();
|
||||
const completeMatch = COMPLETE_VARIABLE_REFERENCE_PATTERN.exec(trimmed);
|
||||
if (completeMatch) {
|
||||
const resolved = resolveVariableReference(parseVariableReference(completeMatch), variables, issues, context);
|
||||
return resolved ?? value;
|
||||
}
|
||||
|
||||
const escaped: string[] = [];
|
||||
const protectedValue = value.replace(ESCAPED_VARIABLE_PATTERN, (_match, body: string) => {
|
||||
const token = `\u0000${escaped.length}\u0000`;
|
||||
escaped.push(`\${${body}}`);
|
||||
return token;
|
||||
});
|
||||
|
||||
const replaced = protectedValue.replace(
|
||||
VARIABLE_REFERENCE_PATTERN,
|
||||
(match, key: string, defaultValue: string | undefined) => {
|
||||
const resolved = resolveVariableReference({ defaultValue, key }, variables, issues, context);
|
||||
return resolved === undefined ? match : String(resolved);
|
||||
},
|
||||
);
|
||||
|
||||
return escaped.reduce((result, literal, index) => result.replace(`\u0000${index}\u0000`, literal), replaced);
|
||||
}
|
||||
|
||||
function resolveConfigValue(
|
||||
value: unknown,
|
||||
variables: Map<string, ConfigVariableValue>,
|
||||
issues: ConfigValidationIssue[],
|
||||
): unknown {
|
||||
if (!isPlainObject(value)) return value;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (key === "variables") {
|
||||
continue;
|
||||
}
|
||||
const itemPath = joinPath("", key);
|
||||
result[key] = key === "server" ? resolveValue(item, itemPath, variables, issues) : item;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveValue(
|
||||
value: unknown,
|
||||
path: string,
|
||||
variables: Map<string, ConfigVariableValue>,
|
||||
issues: ConfigValidationIssue[],
|
||||
): unknown {
|
||||
if (isString(value)) {
|
||||
return replaceStringValue(value, variables, issues, { path });
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item, index) => resolveValue(item, `${path}[${index}]`, variables, issues));
|
||||
}
|
||||
if (!isPlainObject(value)) return value;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const itemPath = joinPath(path, key);
|
||||
result[key] = resolveValue(item, itemPath, variables, issues);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveVariableReference(
|
||||
reference: VariableReference,
|
||||
variables: Map<string, ConfigVariableValue>,
|
||||
issues: ConfigValidationIssue[],
|
||||
context: VariableResolutionContext,
|
||||
): ConfigVariableValue | undefined {
|
||||
if (variables.has(reference.key)) {
|
||||
return variables.get(reference.key);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(process.env, reference.key)) {
|
||||
return inferStringValue(process.env[reference.key] ?? "");
|
||||
}
|
||||
|
||||
if (reference.defaultValue !== undefined) {
|
||||
return inferStringValue(reference.defaultValue);
|
||||
}
|
||||
|
||||
issues.push(
|
||||
issue(
|
||||
"unresolved-variable",
|
||||
context.path,
|
||||
`引用了未定义的变量 "${reference.key}",且环境变量中也不存在,未设置默认值`,
|
||||
),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
@@ -15,6 +15,58 @@ describe("parseRuntimeArgs", () => {
|
||||
const result = parseRuntimeArgs(["config.yaml"]);
|
||||
expect(result).toEqual({ configPath: "config.yaml" });
|
||||
});
|
||||
|
||||
test("--help 输出用法并退出", () => {
|
||||
const logs: string[] = [];
|
||||
let exitCode: number | undefined;
|
||||
const originalLog = console.log;
|
||||
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
Object.defineProperty(process, "exit", {
|
||||
configurable: true,
|
||||
value: (code: number) => {
|
||||
exitCode = code;
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
try {
|
||||
parseRuntimeArgs(["--help"]);
|
||||
} finally {
|
||||
Object.defineProperty(process, "exit", {
|
||||
configurable: true,
|
||||
value: process.exit.bind(process),
|
||||
writable: true,
|
||||
});
|
||||
console.log = originalLog;
|
||||
}
|
||||
expect(exitCode).toBe(0);
|
||||
expect(logs.some((l) => l.includes("用法"))).toBe(true);
|
||||
});
|
||||
|
||||
test("-h 输出用法并退出", () => {
|
||||
const logs: string[] = [];
|
||||
let exitCode: number | undefined;
|
||||
const originalLog = console.log;
|
||||
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
Object.defineProperty(process, "exit", {
|
||||
configurable: true,
|
||||
value: (code: number) => {
|
||||
exitCode = code;
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
try {
|
||||
parseRuntimeArgs(["-h"]);
|
||||
} finally {
|
||||
Object.defineProperty(process, "exit", {
|
||||
configurable: true,
|
||||
value: process.exit.bind(process),
|
||||
writable: true,
|
||||
});
|
||||
console.log = originalLog;
|
||||
}
|
||||
expect(exitCode).toBe(0);
|
||||
expect(logs.some((l) => l.includes("用法"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadServerConfig", () => {
|
||||
@@ -24,12 +76,12 @@ describe("loadServerConfig", () => {
|
||||
expect(config.port).toBe(3000);
|
||||
});
|
||||
|
||||
test("环境变量 HOST 覆盖默认值", async () => {
|
||||
test("环境变量 HOST 不影响默认值(无隐式覆盖)", async () => {
|
||||
const prev = process.env["HOST"];
|
||||
process.env["HOST"] = "0.0.0.0";
|
||||
try {
|
||||
const config = await loadServerConfig();
|
||||
expect(config.host).toBe("0.0.0.0");
|
||||
expect(config.host).toBe("127.0.0.1");
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env["HOST"];
|
||||
@@ -39,12 +91,12 @@ describe("loadServerConfig", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("环境变量 PORT 覆盖默认值", async () => {
|
||||
test("环境变量 PORT 不影响默认值(无隐式覆盖)", async () => {
|
||||
const prev = process.env["PORT"];
|
||||
process.env["PORT"] = "8080";
|
||||
try {
|
||||
const config = await loadServerConfig();
|
||||
expect(config.port).toBe(8080);
|
||||
expect(config.port).toBe(3000);
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env["PORT"];
|
||||
@@ -63,10 +115,10 @@ describe("loadServerConfig", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("YAML 配置文件加载 server 配置", async () => {
|
||||
test("新布局 server.listen 加载成功", async () => {
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "test-config.yaml");
|
||||
const yamlContent = 'server:\n host: "0.0.0.0"\n port: 9999\n';
|
||||
const yamlPath = join(temp, "test-listen.yaml");
|
||||
const yamlContent = 'server:\n listen:\n host: "0.0.0.0"\n port: 9999\n';
|
||||
await writeFile(yamlPath, yamlContent);
|
||||
|
||||
try {
|
||||
@@ -78,16 +130,73 @@ describe("loadServerConfig", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("YAML 缺少 server 字段时使用默认值", async () => {
|
||||
test("旧布局 server.host/server.port 被拒绝", async () => {
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "test-empty.yaml");
|
||||
const yamlContent = "runtime:\n debug: true\n";
|
||||
const yamlPath = join(temp, "test-old-layout.yaml");
|
||||
const yamlContent = 'server:\n host: "0.0.0.0"\n port: 9999\n';
|
||||
await writeFile(yamlPath, yamlContent);
|
||||
|
||||
try {
|
||||
await loadServerConfig(yamlPath);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain("未知字段");
|
||||
} finally {
|
||||
await rm(yamlPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("非法端口被拒绝", async () => {
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "test-bad-port.yaml");
|
||||
const yamlContent = "server:\n listen:\n port: 99999\n";
|
||||
await writeFile(yamlPath, yamlContent);
|
||||
|
||||
try {
|
||||
await loadServerConfig(yamlPath);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toBeTruthy();
|
||||
} finally {
|
||||
await rm(yamlPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("显式变量引用环境变量生效", async () => {
|
||||
const prevHost = process.env["HOST"];
|
||||
const prevPort = process.env["PORT"];
|
||||
process.env["HOST"] = "10.0.0.1";
|
||||
process.env["PORT"] = "4000";
|
||||
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "test-env-var.yaml");
|
||||
const yamlContent = 'server:\n listen:\n host: "${HOST}"\n port: ${PORT}\n';
|
||||
await writeFile(yamlPath, yamlContent);
|
||||
|
||||
try {
|
||||
const config = await loadServerConfig(yamlPath);
|
||||
expect(config.host).toBe("127.0.0.1");
|
||||
expect(config.port).toBe(3000);
|
||||
expect(config.host).toBe("10.0.0.1");
|
||||
expect(config.port).toBe(4000);
|
||||
} finally {
|
||||
await rm(yamlPath, { force: true });
|
||||
if (prevHost === undefined) delete process.env["HOST"];
|
||||
else process.env["HOST"] = prevHost;
|
||||
if (prevPort === undefined) delete process.env["PORT"];
|
||||
else process.env["PORT"] = prevPort;
|
||||
}
|
||||
});
|
||||
|
||||
test("变量带默认值生效", async () => {
|
||||
delete process.env["MY_HOST"];
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "test-default.yaml");
|
||||
const yamlContent = 'server:\n listen:\n host: "${MY_HOST|0.0.0.0}"\n port: ${MY_PORT|5000}\n';
|
||||
await writeFile(yamlPath, yamlContent);
|
||||
|
||||
try {
|
||||
const config = await loadServerConfig(yamlPath);
|
||||
expect(config.host).toBe("0.0.0.0");
|
||||
expect(config.port).toBe(5000);
|
||||
} finally {
|
||||
await rm(yamlPath, { force: true });
|
||||
}
|
||||
|
||||
115
tests/server/config/schema.test.ts
Normal file
115
tests/server/config/schema.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createAuthoringConfigSchema, createNormalizedConfigSchema } from "../../../src/server/config/schema/builder";
|
||||
import { createConfigJsonSchema } from "../../../src/server/config/schema/export";
|
||||
import {
|
||||
createConfigAjv,
|
||||
issuesFromAjvErrors,
|
||||
validateConfigContract,
|
||||
} from "../../../src/server/config/schema/validate";
|
||||
|
||||
describe("导出 schema 生成", () => {
|
||||
test("createConfigJsonSchema 返回有效 JSON Schema", () => {
|
||||
const schema = createConfigJsonSchema();
|
||||
expect(schema["$schema"]).toBe("http://json-schema.org/draft-07/schema#");
|
||||
expect(schema["$id"]).toBe("https://app.local/config.schema.json");
|
||||
expect(schema["type"]).toBe("object");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authoring schema 校验", () => {
|
||||
const ajv = createConfigAjv();
|
||||
const validate = ajv.compile(createAuthoringConfigSchema());
|
||||
|
||||
test("接受空对象", () => {
|
||||
expect(validate({})).toBe(true);
|
||||
});
|
||||
|
||||
test("接受新布局 server.listen", () => {
|
||||
expect(validate({ server: { listen: { host: "127.0.0.1", port: 3000 } } })).toBe(true);
|
||||
});
|
||||
|
||||
test("接受变量引用语法", () => {
|
||||
expect(validate({ server: { listen: { port: "${PORT|3000}" } } })).toBe(true);
|
||||
});
|
||||
|
||||
test("接受 variables 字段", () => {
|
||||
expect(validate({ variables: { HOST: "127.0.0.1" } })).toBe(true);
|
||||
});
|
||||
|
||||
test("拒绝未知字段 server.host", () => {
|
||||
expect(validate({ server: { host: "127.0.0.1" } })).toBe(false);
|
||||
const issues = issuesFromAjvErrors(validate.errors ?? [], {});
|
||||
expect(issues.some((i) => i.code === "unknown-field")).toBe(true);
|
||||
});
|
||||
|
||||
test("拒绝未知字段 server.port", () => {
|
||||
expect(validate({ server: { port: 3000 } })).toBe(false);
|
||||
const issues = issuesFromAjvErrors(validate.errors ?? [], {});
|
||||
expect(issues.some((i) => i.code === "unknown-field")).toBe(true);
|
||||
});
|
||||
|
||||
test("拒绝非法类型 port", () => {
|
||||
expect(validate({ server: { listen: { port: "not-a-number" } } })).toBe(false);
|
||||
});
|
||||
|
||||
test("拒绝超出范围的 port", () => {
|
||||
expect(validate({ server: { listen: { port: 70000 } } })).toBe(false);
|
||||
});
|
||||
|
||||
test("拒绝负数 port", () => {
|
||||
expect(validate({ server: { listen: { port: -1 } } })).toBe(false);
|
||||
});
|
||||
|
||||
test("拒绝顶层未知字段", () => {
|
||||
expect(validate({ unknown: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Normalized schema 校验", () => {
|
||||
const ajv = createConfigAjv();
|
||||
const validate = ajv.compile(createNormalizedConfigSchema());
|
||||
|
||||
test("接受新布局 server.listen", () => {
|
||||
expect(validate({ server: { listen: { host: "127.0.0.1", port: 3000 } } })).toBe(true);
|
||||
});
|
||||
|
||||
test("Normalized 不接受 variables 字段", () => {
|
||||
expect(validate({ variables: { HOST: "127.0.0.1" } })).toBe(false);
|
||||
});
|
||||
|
||||
test("Normalized 不接受变量引用语法", () => {
|
||||
expect(validate({ server: { listen: { port: "${PORT|3000}" } } })).toBe(false);
|
||||
});
|
||||
|
||||
test("接受空对象", () => {
|
||||
expect(validate({})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateConfigContract", () => {
|
||||
test("有效配置通过校验", () => {
|
||||
const result = validateConfigContract({ server: { listen: { host: "0.0.0.0", port: 8080 } } });
|
||||
expect(result.config).not.toBeNull();
|
||||
});
|
||||
|
||||
test("空配置通过校验", () => {
|
||||
const result = validateConfigContract({});
|
||||
expect(result.config).not.toBeNull();
|
||||
});
|
||||
|
||||
test("包含未知字段的配置被拒绝", () => {
|
||||
const result = validateConfigContract({ server: { host: "bad" } });
|
||||
expect(result.config).toBeNull();
|
||||
expect(result.issues.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema 同步测试", () => {
|
||||
test("config.schema.json 与 createConfigJsonSchema() 输出一致", async () => {
|
||||
const file = Bun.file("config.schema.json");
|
||||
const existing = await file.text();
|
||||
const generated = `${JSON.stringify(createConfigJsonSchema(), null, 2)}\n`;
|
||||
expect(existing).toBe(generated);
|
||||
});
|
||||
});
|
||||
171
tests/server/config/variables.test.ts
Normal file
171
tests/server/config/variables.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { extractVariables, resolveVariables } from "../../../src/server/config/variables";
|
||||
|
||||
describe("extractVariables", () => {
|
||||
test("空对象返回空 variables", () => {
|
||||
const result = extractVariables({});
|
||||
expect(result.variables.size).toBe(0);
|
||||
expect(result.issues.length).toBe(0);
|
||||
});
|
||||
|
||||
test("无 variables 字段返回空", () => {
|
||||
const result = extractVariables({ server: {} });
|
||||
expect(result.variables.size).toBe(0);
|
||||
});
|
||||
|
||||
test("variables 非对象报错", () => {
|
||||
const result = extractVariables({ variables: "bad" });
|
||||
expect(result.issues.length).toBe(1);
|
||||
expect(result.issues[0]!.code).toBe("invalid-type");
|
||||
});
|
||||
|
||||
test("提取有效变量", () => {
|
||||
const result = extractVariables({ variables: { HOST: "127.0.0.1", PORT: 3000 } });
|
||||
expect(result.variables.get("HOST")).toBe("127.0.0.1");
|
||||
expect(result.variables.get("PORT")).toBe(3000);
|
||||
});
|
||||
|
||||
test("无效变量名报错", () => {
|
||||
const result = extractVariables({ variables: { "123bad": "val" } });
|
||||
expect(result.issues.length).toBe(1);
|
||||
expect(result.issues[0]!.code).toBe("invalid-format");
|
||||
});
|
||||
|
||||
test("null 值报错", () => {
|
||||
const result = extractVariables({ variables: { KEY: null } });
|
||||
expect(result.issues.length).toBe(1);
|
||||
expect(result.issues[0]!.code).toBe("invalid-type");
|
||||
});
|
||||
|
||||
test("数组值报错", () => {
|
||||
const result = extractVariables({ variables: { KEY: [1, 2] } });
|
||||
expect(result.issues.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveVariables", () => {
|
||||
test("${KEY} 从 variables 解析", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${MY_HOST}" } },
|
||||
variables: { MY_HOST: "0.0.0.0" },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("0.0.0.0");
|
||||
});
|
||||
|
||||
test("${KEY|default} 使用默认值", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${MY_HOST|0.0.0.0}" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("0.0.0.0");
|
||||
});
|
||||
|
||||
test("${KEY|} 空默认值", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${MY_HOST|}" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("");
|
||||
});
|
||||
|
||||
test("$${KEY} 转义不解析", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "$${NOT_A_VAR}" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("${NOT_A_VAR}");
|
||||
});
|
||||
|
||||
test("variables 优先于 process.env", () => {
|
||||
const prev = process.env["TEST_PRIORITY"];
|
||||
process.env["TEST_PRIORITY"] = "from-env";
|
||||
try {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${TEST_PRIORITY}" } },
|
||||
variables: { TEST_PRIORITY: "from-var" },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("from-var");
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env["TEST_PRIORITY"];
|
||||
else process.env["TEST_PRIORITY"] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test("process.env fallback", () => {
|
||||
const prev = process.env["TEST_FALLBACK"];
|
||||
process.env["TEST_FALLBACK"] = "from-env";
|
||||
try {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${TEST_FALLBACK}" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("from-env");
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env["TEST_FALLBACK"];
|
||||
else process.env["TEST_FALLBACK"] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test("完整引用保留类型 - number", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { port: "${PORT|3000}" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["port"]).toBe(3000);
|
||||
expect(typeof listen["port"]).toBe("number");
|
||||
});
|
||||
|
||||
test("完整引用保留类型 - boolean", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${FLAG|false}" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe(false);
|
||||
});
|
||||
|
||||
test("部分插值转为 string", () => {
|
||||
const prev = process.env["PARTIAL_HOST"];
|
||||
process.env["PARTIAL_HOST"] = "192.168";
|
||||
try {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "prefix-${PARTIAL_HOST}-suffix" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("prefix-192.168-suffix");
|
||||
expect(typeof listen["host"]).toBe("string");
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env["PARTIAL_HOST"];
|
||||
else process.env["PARTIAL_HOST"] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test("unresolved-variable 报错", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${UNDEFINED_VAR}" } },
|
||||
});
|
||||
expect(result.issues.length).toBe(1);
|
||||
expect(result.issues[0]!.code).toBe("unresolved-variable");
|
||||
expect(result.issues[0]!.message).toContain("UNDEFINED_VAR");
|
||||
});
|
||||
|
||||
test("variables 段被移除", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "test" } },
|
||||
variables: { KEY: "val" },
|
||||
});
|
||||
const config = result.config as Record<string, unknown>;
|
||||
expect(config["variables"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user