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:
@@ -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<ServerConfig> {
|
||||
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<string, unknown>;
|
||||
const server = configRecord["server"] as Record<string, unknown> | undefined;
|
||||
const listen = server?.["listen"] as Record<string, unknown> | 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<string, unknown>;
|
||||
const server = configRecord["server"] as Record<string, unknown> | undefined;
|
||||
const listen = server?.["listen"] as Record<string, unknown> | 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;
|
||||
}
|
||||
|
||||
17
src/server/config/index.ts
Normal file
17
src/server/config/index.ts
Normal file
@@ -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";
|
||||
43
src/server/config/issues.ts
Normal file
43
src/server/config/issues.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export interface ConfigValidationIssue {
|
||||
code: string;
|
||||
message: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] {
|
||||
const seen = new Set<string>();
|
||||
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}`;
|
||||
}
|
||||
18
src/server/config/normalizer.ts
Normal file
18
src/server/config/normalizer.ts
Normal file
@@ -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<string, unknown>) };
|
||||
return { config: normalized, issues: variableResult.issues };
|
||||
}
|
||||
65
src/server/config/schema/builder.ts
Normal file
65
src/server/config/schema/builder.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
return JSON.parse(JSON.stringify(schema)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function createAuthoringFieldSchema(schema: TSchema): TSchema {
|
||||
return Type.Unsafe({ anyOf: [schema, { pattern: "^\\$\\{[^}]+\\}$", type: "string" }] });
|
||||
}
|
||||
|
||||
function createConfigSchemaForKind(kind: SchemaKind): TSchema {
|
||||
const properties: Record<string, TSchema> = {
|
||||
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<typeof Type.Integer>[0]): TSchema {
|
||||
const schema = Type.Integer(options);
|
||||
return kind === "authoring" ? createAuthoringFieldSchema(schema) : schema;
|
||||
}
|
||||
5
src/server/config/schema/export.ts
Normal file
5
src/server/config/schema/export.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createExternalConfigSchema } from "./builder";
|
||||
|
||||
export function createConfigJsonSchema(): Record<string, unknown> {
|
||||
return createExternalConfigSchema();
|
||||
}
|
||||
3
src/server/config/schema/fragments.ts
Normal file
3
src/server/config/schema/fragments.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
export const variableValueSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean()]);
|
||||
110
src/server/config/schema/validate.ts
Normal file
110
src/server/config/schema/validate.ts
Normal file
@@ -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<string>): 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<string, Set<string>>();
|
||||
|
||||
for (const error of candidates) {
|
||||
const path = buildIssuePath(basePath, error);
|
||||
const keywords = keywordsByPath.get(path) ?? new Set<string>();
|
||||
keywords.add(error.keyword);
|
||||
keywordsByPath.set(path, keywords);
|
||||
}
|
||||
|
||||
const seenValueErrors = new Set<string>();
|
||||
return candidates.filter((error) => {
|
||||
const path = buildIssuePath(basePath, error);
|
||||
const keywords = keywordsByPath.get(path) ?? new Set<string>();
|
||||
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;
|
||||
});
|
||||
}
|
||||
37
src/server/config/types.ts
Normal file
37
src/server/config/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface AuthoringConfig {
|
||||
server?: AuthoringServer;
|
||||
variables?: Record<string, ConfigVariableValue>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
188
src/server/config/variables.ts
Normal file
188
src/server/config/variables.ts
Normal file
@@ -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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user