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

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