import type { CommandDefaultsConfig, CommandTargetConfig, DefaultsConfig, HttpDefaultsConfig, HttpExpectConfig, HttpTargetConfig, ProbeConfig, ResolvedCommandTarget, ResolvedHttpTarget, ResolvedTarget, RuntimeConfig, TargetConfig, TargetType, } from "./types"; import { parseSize } from "./size"; import { resolve } from "node:path"; import { dirname } from "node:path"; 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_HTTP_METHOD = "GET"; const DEFAULT_MAX_BODY_BYTES = "100MB"; const DEFAULT_MAX_OUTPUT_BYTES = "100MB"; const DEFAULT_MAX_CONCURRENT_CHECKS = 20; const SUPPORTED_TYPES: TargetType[] = ["http", "command"]; export interface ResolvedConfig { host: string; port: number; dataDir: string; configDir: string; maxConcurrentChecks: 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 raw = Bun.YAML.parse(content) as ProbeConfig | null; if (!raw) { throw new Error("配置文件内容为空或格式无效"); } validateConfig(raw); const configDir = dirname(resolve(configPath)); const server = raw.server ?? {}; const runtime = raw.runtime ?? {}; const defaults = raw.defaults ?? {}; const host = server.host ?? DEFAULT_HOST; const port = server.port ?? DEFAULT_PORT; const dataDir = server.dataDir ?? DEFAULT_DATA_DIR; if (!Number.isInteger(port) || port < 0 || port > 65535) { throw new Error(`无效端口号: ${port},需要 0-65535 之间的整数`); } const maxConcurrentChecks = validateRuntime(runtime); const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL); const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT); const targets: ResolvedTarget[] = raw.targets.map((target) => resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir), ); return { host, port, dataDir, configDir, maxConcurrentChecks, targets }; } function validateRuntime(runtime: RuntimeConfig): number { if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS; if ( typeof runtime.maxConcurrentChecks !== "number" || !Number.isInteger(runtime.maxConcurrentChecks) || runtime.maxConcurrentChecks <= 0 ) { throw new Error("runtime.maxConcurrentChecks 必须为正整数"); } 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 group = target.group ?? "default"; if (target.type === "http") { return resolveHttpTarget(target, defaults.http, intervalMs, timeoutMs, group); } return resolveCommandTarget(target, defaults.command, intervalMs, timeoutMs, configDir, group); } function resolveHttpTarget( target: TargetConfig & { type: "http"; http: HttpTargetConfig }, httpDefaults: HttpDefaultsConfig | undefined, intervalMs: number, timeoutMs: number, group: string, ): ResolvedHttpTarget { const maxBodyBytes = parseSize(target.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES); return { type: "http", name: target.name, group, http: { url: target.http.url, method: target.http.method ?? httpDefaults?.method ?? DEFAULT_HTTP_METHOD, headers: { ...(httpDefaults?.headers ?? {}), ...(target.http.headers ?? {}) }, body: target.http.body, maxBodyBytes, }, intervalMs, timeoutMs, expect: target.expect as HttpExpectConfig | undefined, }; } function resolveCommandTarget( target: TargetConfig & { type: "command"; command: CommandTargetConfig }, commandDefaults: CommandDefaultsConfig | undefined, intervalMs: number, timeoutMs: number, configDir: string, group: string, ): ResolvedCommandTarget { const cwd = target.command.cwd ?? commandDefaults?.cwd ?? "."; const resolvedCwd = resolve(configDir, cwd); const maxOutputBytes = parseSize( target.command.maxOutputBytes ?? commandDefaults?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, ); const env = { ...process.env, ...(target.command.env ?? {}) } as Record; return { type: "command", name: target.name, group, command: { exec: target.command.exec, args: target.command.args ?? [], cwd: resolvedCwd, env, maxOutputBytes, }, intervalMs, timeoutMs, expect: target.expect as import("./types").CommandExpectConfig | undefined, }; } function validateConfig(config: ProbeConfig): void { if (!config.targets || !Array.isArray(config.targets) || config.targets.length === 0) { throw new Error("配置文件必须包含至少一个 target"); } const names = new Set(); for (let i = 0; i < config.targets.length; i++) { const raw = config.targets[i] as unknown as Record; const name = raw["name"]; if (!name || typeof name !== "string" || (name as string).trim() === "") { throw new Error(`第 ${i + 1} 个 target 缺少 name 字段`); } const type = raw["type"]; if (!type || typeof type !== "string") { throw new Error(`target "${name}" 缺少 type 字段`); } if (!SUPPORTED_TYPES.includes(type as TargetType)) { throw new Error(`target "${name}" 使用不支持的 type: "${type}",支持: ${SUPPORTED_TYPES.join(", ")}`); } if (type === "http") { const http = raw["http"] as Record | undefined; if (!http?.["url"] || typeof http["url"] !== "string" || (http["url"] as string).trim() === "") { throw new Error(`target "${name}" 缺少 http.url 字段`); } } if (type === "command") { const cmd = raw["command"] as Record | undefined; if (!cmd?.["exec"] || typeof cmd["exec"] !== "string" || (cmd["exec"] as string).trim() === "") { throw new Error(`target "${name}" 缺少 command.exec 字段`); } } const group = raw["group"]; if (group !== undefined && typeof group !== "string") { throw new Error(`target "${name}" 的 group 字段必须为字符串`); } if (names.has(name as string)) { throw new Error(`target name 重复: "${name}"`); } names.add(name as string); } } 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]!; if (unit === "ms") return num; if (unit === "s") return num * 1000; return num * 60 * 1000; }