From c592f2b97cf7d8af5213e072f8c1dca093db8b44 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Mon, 25 May 2026 12:17:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=95=E5=85=A5=E5=88=86=E5=B1=82?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8F=98=E9=87=8F=E5=BC=95=E7=94=A8=E5=92=8C?= =?UTF-8?q?=20JSON=20Schema=20=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 同步测试 --- DEVELOPMENT.md | 96 ++++++++++--- README.md | 64 ++++++--- bun.lock | 16 ++- config.example.yaml | 6 +- config.schema.json | 54 ++++++++ package.json | 6 +- scripts/generate-config-schema.ts | 15 ++ src/server/config.ts | 94 +++++++++---- src/server/config/index.ts | 17 +++ src/server/config/issues.ts | 43 ++++++ src/server/config/normalizer.ts | 18 +++ src/server/config/schema/builder.ts | 65 +++++++++ src/server/config/schema/export.ts | 5 + src/server/config/schema/fragments.ts | 3 + src/server/config/schema/validate.ts | 110 +++++++++++++++ src/server/config/types.ts | 37 +++++ src/server/config/variables.ts | 188 ++++++++++++++++++++++++++ tests/server/config.test.ts | 133 ++++++++++++++++-- tests/server/config/schema.test.ts | 115 ++++++++++++++++ tests/server/config/variables.test.ts | 171 +++++++++++++++++++++++ 20 files changed, 1169 insertions(+), 87 deletions(-) create mode 100644 config.schema.json create mode 100644 scripts/generate-config-schema.ts create mode 100644 src/server/config/index.ts create mode 100644 src/server/config/issues.ts create mode 100644 src/server/config/normalizer.ts create mode 100644 src/server/config/schema/builder.ts create mode 100644 src/server/config/schema/export.ts create mode 100644 src/server/config/schema/fragments.ts create mode 100644 src/server/config/schema/validate.ts create mode 100644 src/server/config/types.ts create mode 100644 src/server/config/variables.ts create mode 100644 tests/server/config/schema.test.ts create mode 100644 tests/server/config/variables.test.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 63fb5ae..c73bb1b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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` 子树 - 无国际化和多语言支持 diff --git a/README.md b/README.md index db92f55..d4c6a36 100644 --- a/README.md +++ b/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/) | 高性能工具库(推荐优先使用) | ### 前端 diff --git a/bun.lock b/bun.lock index a57f429..973b599 100644 --- a/bun.lock +++ b/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=="], diff --git a/config.example.yaml b/config.example.yaml index 596c828..5698d0e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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} diff --git a/config.schema.json b/config.schema.json new file mode 100644 index 0000000..fa880be --- /dev/null +++ b/config.schema.json @@ -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#" +} diff --git a/package.json b/package.json index 5d216cd..ec44c50 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/generate-config-schema.ts b/scripts/generate-config-schema.ts new file mode 100644 index 0000000..583c63d --- /dev/null +++ b/scripts/generate-config-schema.ts @@ -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); +} diff --git a/src/server/config.ts b/src/server/config.ts index 6628f7a..969d4ac 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -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 { - 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; + const server = configRecord["server"] as Record | undefined; + const listen = server?.["listen"] as Record | 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; + const server = configRecord["server"] as Record | undefined; + const listen = server?.["listen"] as Record | 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; +} diff --git a/src/server/config/index.ts b/src/server/config/index.ts new file mode 100644 index 0000000..ae5a4c2 --- /dev/null +++ b/src/server/config/index.ts @@ -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"; diff --git a/src/server/config/issues.ts b/src/server/config/issues.ts new file mode 100644 index 0000000..3887452 --- /dev/null +++ b/src/server/config/issues.ts @@ -0,0 +1,43 @@ +export interface ConfigValidationIssue { + code: string; + message: string; + path: string; +} + +export function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] { + const seen = new Set(); + 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}`; +} diff --git a/src/server/config/normalizer.ts b/src/server/config/normalizer.ts new file mode 100644 index 0000000..1ed4462 --- /dev/null +++ b/src/server/config/normalizer.ts @@ -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) }; + return { config: normalized, issues: variableResult.issues }; +} diff --git a/src/server/config/schema/builder.ts b/src/server/config/schema/builder.ts new file mode 100644 index 0000000..f5db787 --- /dev/null +++ b/src/server/config/schema/builder.ts @@ -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 { + 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 { + return JSON.parse(JSON.stringify(schema)) as Record; +} + +function createAuthoringFieldSchema(schema: TSchema): TSchema { + return Type.Unsafe({ anyOf: [schema, { pattern: "^\\$\\{[^}]+\\}$", type: "string" }] }); +} + +function createConfigSchemaForKind(kind: SchemaKind): TSchema { + const properties: Record = { + 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[0]): TSchema { + const schema = Type.Integer(options); + return kind === "authoring" ? createAuthoringFieldSchema(schema) : schema; +} diff --git a/src/server/config/schema/export.ts b/src/server/config/schema/export.ts new file mode 100644 index 0000000..3218e0d --- /dev/null +++ b/src/server/config/schema/export.ts @@ -0,0 +1,5 @@ +import { createExternalConfigSchema } from "./builder"; + +export function createConfigJsonSchema(): Record { + return createExternalConfigSchema(); +} diff --git a/src/server/config/schema/fragments.ts b/src/server/config/schema/fragments.ts new file mode 100644 index 0000000..ecaaeea --- /dev/null +++ b/src/server/config/schema/fragments.ts @@ -0,0 +1,3 @@ +import { Type } from "@sinclair/typebox"; + +export const variableValueSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean()]); diff --git a/src/server/config/schema/validate.ts b/src/server/config/schema/validate.ts new file mode 100644 index 0000000..564b656 --- /dev/null +++ b/src/server/config/schema/validate.ts @@ -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): 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>(); + + for (const error of candidates) { + const path = buildIssuePath(basePath, error); + const keywords = keywordsByPath.get(path) ?? new Set(); + keywords.add(error.keyword); + keywordsByPath.set(path, keywords); + } + + const seenValueErrors = new Set(); + return candidates.filter((error) => { + const path = buildIssuePath(basePath, error); + const keywords = keywordsByPath.get(path) ?? new Set(); + 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; + }); +} diff --git a/src/server/config/types.ts b/src/server/config/types.ts new file mode 100644 index 0000000..38cd436 --- /dev/null +++ b/src/server/config/types.ts @@ -0,0 +1,37 @@ +export interface AuthoringConfig { + server?: AuthoringServer; + variables?: Record; +} + +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; +} diff --git a/src/server/config/variables.ts b/src/server/config/variables.ts new file mode 100644 index 0000000..ad94631 --- /dev/null +++ b/src/server/config/variables.ts @@ -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; +} { + const issues: ConfigValidationIssue[] = []; + const variables = new Map(); + + if (!isPlainObject(config)) { + return { issues, variables }; + } + const configRecord = config as Record; + 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)) { + 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, + 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, + issues: ConfigValidationIssue[], +): unknown { + if (!isPlainObject(value)) return value; + + const result: Record = {}; + 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, + 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 = {}; + 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, + 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; +} diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index d93c4fd..512b924 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -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 }); } diff --git a/tests/server/config/schema.test.ts b/tests/server/config/schema.test.ts new file mode 100644 index 0000000..2452273 --- /dev/null +++ b/tests/server/config/schema.test.ts @@ -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); + }); +}); diff --git a/tests/server/config/variables.test.ts b/tests/server/config/variables.test.ts new file mode 100644 index 0000000..92dd342 --- /dev/null +++ b/tests/server/config/variables.test.ts @@ -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)["server"] as Record; + const listen = server["listen"] as Record; + 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)["server"] as Record; + const listen = server["listen"] as Record; + expect(listen["host"]).toBe("0.0.0.0"); + }); + + test("${KEY|} 空默认值", () => { + const result = resolveVariables({ + server: { listen: { host: "${MY_HOST|}" } }, + }); + const server = (result.config as Record)["server"] as Record; + const listen = server["listen"] as Record; + expect(listen["host"]).toBe(""); + }); + + test("$${KEY} 转义不解析", () => { + const result = resolveVariables({ + server: { listen: { host: "$${NOT_A_VAR}" } }, + }); + const server = (result.config as Record)["server"] as Record; + const listen = server["listen"] as Record; + 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)["server"] as Record; + const listen = server["listen"] as Record; + 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)["server"] as Record; + const listen = server["listen"] as Record; + 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)["server"] as Record; + const listen = server["listen"] as Record; + 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)["server"] as Record; + const listen = server["listen"] as Record; + 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)["server"] as Record; + const listen = server["listen"] as Record; + 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; + expect(config["variables"]).toBeUndefined(); + }); +});