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:
2026-05-25 12:17:40 +08:00
parent 13d1fea5fb
commit c592f2b97c
20 changed files with 1169 additions and 87 deletions

View File

@@ -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 → ESLintMD/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` 子树
- 无国际化和多语言支持

View File

@@ -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/) | 高性能工具库(推荐优先使用) |
### 前端

View File

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

View File

@@ -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
View 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#"
}

View File

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

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

View File

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

View 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";

View 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}`;
}

View 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 };
}

View 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;
}

View File

@@ -0,0 +1,5 @@
import { createExternalConfigSchema } from "./builder";
export function createConfigJsonSchema(): Record<string, unknown> {
return createExternalConfigSchema();
}

View File

@@ -0,0 +1,3 @@
import { Type } from "@sinclair/typebox";
export const variableValueSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean()]);

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

View 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;
}

View 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;
}

View File

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

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

View 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();
});
});