import { dirname, resolve } from "node:path"; import type { ConfigValidationIssue } from "./config-contract/issues"; import type { DefaultsConfig, EngineRuntimeConfig, ResolvedTarget, TargetConfig } from "./types"; import { issue, throwConfigIssues } from "./config-contract/issues"; import { asValidatedConfig, type RawProbeConfig } from "./config-contract/types"; import { validateProbeConfigContract } from "./config-contract/validate"; import { checkerRegistry } from "./runner"; const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_PORT = 3000; const DEFAULT_DATA_DIR = "./data"; const DEFAULT_INTERVAL = "30s"; const DEFAULT_TIMEOUT = "10s"; const DEFAULT_MAX_CONCURRENT_CHECKS = 20; export interface ResolvedConfig { configDir: string; dataDir: string; host: string; maxConcurrentChecks: number; port: number; targets: ResolvedTarget[]; } export async function loadConfig(configPath: string): Promise { const file = Bun.file(configPath); if (!(await file.exists())) { throw new Error(`配置文件不存在: ${configPath}`); } const content = await file.text(); const parsed = Bun.YAML.parse(content); if (!parsed) { throw new Error("配置文件内容为空或格式无效"); } const contractResult = validateProbeConfigContract(parsed, checkerRegistry); if (contractResult.config === null && !canRunSemanticValidation(parsed)) { throwConfigIssues(contractResult.issues); } const semanticInput = (contractResult.config ?? parsed) as RawProbeConfig; const validationIssues = validateConfig(semanticInput); const allIssues = [...contractResult.issues, ...validationIssues]; if (contractResult.config === null) { if (allIssues.length > 0) { throwConfigIssues(dedupeIssues(allIssues)); } throw new Error("配置文件内容为空或格式无效"); } const raw = contractResult.config; const validated = asValidatedConfig(raw); const configDir = dirname(resolve(configPath)); const server = validated.server ?? {}; const runtime = validated.runtime ?? {}; const defaults = validated.defaults ?? {}; const host = server.host ?? DEFAULT_HOST; const port = server.port ?? DEFAULT_PORT; const dataDir = server.dataDir ?? DEFAULT_DATA_DIR; const maxConcurrentChecks = resolveMaxConcurrentChecks(runtime); const allRuntimeIssues = [...allIssues]; if (allRuntimeIssues.length > 0) { throwConfigIssues(dedupeIssues(allRuntimeIssues)); } const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL); const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT); const targets: ResolvedTarget[] = validated.targets.map((target) => resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir), ); return { configDir, dataDir, host, maxConcurrentChecks, port, targets }; } function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number { if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS; if ( typeof runtime.maxConcurrentChecks !== "number" || !Number.isInteger(runtime.maxConcurrentChecks) || runtime.maxConcurrentChecks <= 0 ) return DEFAULT_MAX_CONCURRENT_CHECKS; return runtime.maxConcurrentChecks; } function resolveTarget( target: TargetConfig, defaults: DefaultsConfig, defaultIntervalMs: number, defaultTimeoutMs: number, configDir: string, ): ResolvedTarget { const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL); const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT); const checker = checkerRegistry.get(target.type); const result = checker.resolve(target, { configDir, defaultIntervalMs, defaults, defaultTimeoutMs }); result.intervalMs = intervalMs; result.timeoutMs = timeoutMs; return result; } function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] { const issues: ConfigValidationIssue[] = []; if (!Array.isArray(config.targets) || config.targets.length === 0) { issues.push(issue("required", "targets", "配置文件必须包含至少一个 target")); return issues; } const names = new Set(); const supportedTypes = checkerRegistry.supportedTypes; for (let i = 0; i < config.targets.length; i++) { const rawTarget = config.targets[i] as unknown; if (!isRecord(rawTarget)) { issues.push(issue("invalid-type", `targets[${i}]`, "必须为对象")); continue; } const raw = rawTarget; const name = raw["name"]; if (!name || typeof name !== "string" || name.trim() === "") { issues.push(issue("required", `targets[${i}].name`, "缺少 name 字段")); continue; } const type = raw["type"]; if (!type || typeof type !== "string") { issues.push(issue("required", `targets[${i}].type`, "缺少 type 字段", name)); continue; } if (!supportedTypes.includes(type)) { issues.push( issue( "unsupported-type", `targets[${i}].type`, `使用不支持的 type: "${type}",支持: ${supportedTypes.join(", ")}`, name, ), ); } const group = raw["group"]; if (group !== undefined && typeof group !== "string") { issues.push(issue("invalid-type", `targets[${i}].group`, "必须为字符串", name)); } if (names.has(name)) { issues.push(issue("duplicate-name", `targets[${i}].name`, `target name 重复: "${name}"`, name)); } names.add(name); } for (const checker of checkerRegistry.definitions) { issues.push(...checker.validate({ defaults: config.defaults ?? {}, targets: config.targets })); } validateDurationValue(config.defaults?.interval, "defaults.interval", issues); validateDurationValue(config.defaults?.timeout, "defaults.timeout", issues); for (let i = 0; i < config.targets.length; i++) { const target = config.targets[i] as unknown; if (!isRecord(target)) continue; const targetName = typeof target["name"] === "string" ? target["name"] : undefined; validateDurationValue( typeof target["interval"] === "string" ? target["interval"] : undefined, `targets[${i}].interval`, issues, targetName, ); validateDurationValue( typeof target["timeout"] === "string" ? target["timeout"] : undefined, `targets[${i}].timeout`, issues, targetName, ); } return issues; } const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/; export function parseDuration(value: string): number { const match = DURATION_REGEX.exec(value); if (!match) { throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"500ms"`); } const num = parseFloat(match[1]!); const unit = match[2]!; const durationMs = unit === "ms" ? num : unit === "s" ? num * 1000 : num * 60 * 1000; if (!Number.isInteger(durationMs) || durationMs <= 0 || !Number.isFinite(durationMs)) { throw new Error(`无效的时长格式: "${value}",解析结果必须为正整数毫秒`); } return durationMs; } function canRunSemanticValidation(value: unknown): boolean { return typeof value === "object" && value !== null; } function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] { const seen = new Set(); const result: ConfigValidationIssue[] = []; for (const item of issues) { const key = `${item.code}:${item.path}:${item.message}:${item.targetName ?? ""}`; if (seen.has(key)) continue; seen.add(key); result.push(item); } return result; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function validateDurationValue( value: string | undefined, path: string, issues: ConfigValidationIssue[], targetName?: string, ): void { if (value === undefined) return; try { parseDuration(value); } catch (error) { issues.push(issue("invalid-duration", path, error instanceof Error ? error.message : "时长格式不合法", targetName)); } }