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

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