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:
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user