- 新增 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 同步测试
189 lines
5.8 KiB
TypeScript
189 lines
5.8 KiB
TypeScript
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;
|
|
}
|