1
0

refactor: checker 模块内聚化 — 每个 checker 自包含于独立目录

将 checker 架构重构为完全内聚模式:每个 checker 目录包含自身的
types、schema、validate、execute、expect 和 index,新增 checker
只需创建一个目录并在 runner/index.ts 添加一行注册。

主要变更:
- runner/shared/ 拆分:断言基础设施迁入 checker/expect/,
  body.ts 迁入 http/,text.ts 迁入 command/
- config-contract/ 重命名为 schema/,schema.ts → builder.ts
- size.ts + parseDuration 合并为 utils.ts
- 顶层 types.ts 改为 base interface + index signature,
  checker 专属类型下沉到各自 types.ts
- runner/index.ts 改为显式数组注册模式
- 更新 DEVELOPMENT.md 项目结构和开发新 Checker 指南
This commit is contained in:
2026-05-13 14:38:21 +08:00
parent c396c29402
commit bb6b2bc20b
52 changed files with 789 additions and 820 deletions

View File

@@ -1,12 +1,13 @@
import { dirname, resolve } from "node:path";
import type { ConfigValidationIssue } from "./config-contract/issues";
import type { DefaultsConfig, EngineRuntimeConfig, ResolvedTarget, TargetConfig } from "./types";
import type { ConfigValidationIssue } from "./schema/issues";
import type { DefaultsConfig, EngineRuntimeConfig, RawTargetConfig, ResolvedTargetBase } 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";
import { issue, throwConfigIssues } from "./schema/issues";
import { asValidatedConfig, type RawProbeConfig } from "./schema/types";
import { validateProbeConfigContract } from "./schema/validate";
import { parseDuration } from "./utils";
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000;
@@ -21,7 +22,7 @@ export interface ResolvedConfig {
host: string;
maxConcurrentChecks: number;
port: number;
targets: ResolvedTarget[];
targets: ResolvedTargetBase[];
}
export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
@@ -76,13 +77,35 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL);
const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT);
const targets: ResolvedTarget[] = validated.targets.map((target) =>
const targets: ResolvedTargetBase[] = validated.targets.map((target) =>
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
);
return { configDir, dataDir, host, maxConcurrentChecks, port, targets };
}
function canRunSemanticValidation(value: unknown): boolean {
return typeof value === "object" && value !== null;
}
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}:${item.targetName ?? ""}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export { parseDuration } from "./utils";
function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
if (
@@ -95,12 +118,12 @@ function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
}
function resolveTarget(
target: TargetConfig,
target: RawTargetConfig,
defaults: DefaultsConfig,
defaultIntervalMs: number,
defaultTimeoutMs: number,
configDir: string,
): ResolvedTarget {
): ResolvedTargetBase {
const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL);
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT);
@@ -192,44 +215,6 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
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<string>();
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function validateDurationValue(
value: string | undefined,
path: string,

View File

@@ -1,7 +1,7 @@
import { groupBy, Semaphore } from "es-toolkit";
import type { ProbeStore } from "./store";
import type { CheckResult, ResolvedTarget } from "./types";
import type { CheckResult, ResolvedTargetBase } from "./types";
import { checkerRegistry } from "./runner";
@@ -9,10 +9,10 @@ export class ProbeEngine {
private semaphore: Semaphore;
private store: ProbeStore;
private targetNameToId = new Map<string, number>();
private targets: ResolvedTarget[];
private targets: ResolvedTargetBase[];
private timers: Array<ReturnType<typeof setInterval>> = [];
constructor(store: ProbeStore, targets: ResolvedTarget[], maxConcurrentChecks?: number) {
constructor(store: ProbeStore, targets: ResolvedTargetBase[], maxConcurrentChecks?: number) {
this.store = store;
this.targets = targets;
this.semaphore = new Semaphore(maxConcurrentChecks ?? 20);
@@ -40,7 +40,7 @@ export class ProbeEngine {
this.timers = [];
}
private async probeGroup(targets: ResolvedTarget[]): Promise<void> {
private async probeGroup(targets: ResolvedTargetBase[]): Promise<void> {
const results = await Promise.allSettled(
targets.map(async (target) => {
await this.semaphore.acquire();
@@ -68,7 +68,7 @@ export class ProbeEngine {
}
}
private async runCheck(target: ResolvedTarget): Promise<CheckResult> {
private async runCheck(target: ResolvedTargetBase): Promise<CheckResult> {
const checker = checkerRegistry.get(target.type);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs);

View File

@@ -1,12 +1,7 @@
import type { CheckFailure } from "../../types";
import type { ExpectResult } from "./types";
import { mismatchFailure } from "./failure";
export interface ExpectResult {
failure: CheckFailure | null;
matched: boolean;
}
export function checkDuration(durationMs: number, maxDurationMs?: number): ExpectResult {
if (maxDurationMs === undefined) return { failure: null, matched: true };
if (durationMs > maxDurationMs) {

View File

@@ -1,4 +1,4 @@
import type { CheckFailure } from "../../types";
import type { CheckFailure } from "../types";
export function errorFailure(phase: CheckFailure["phase"], path: string, message: string): CheckFailure {
return {

View File

@@ -1,6 +1,6 @@
import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
import type { ExpectOperator, ExpectValue } from "../../types";
import type { ExpectOperator, ExpectValue } from "../types";
const OPERATOR_KEYS = new Set(["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "match"]);

View File

@@ -0,0 +1,6 @@
import type { CheckFailure } from "../types";
export interface ExpectResult {
failure: CheckFailure | null;
matched: boolean;
}

View File

@@ -0,0 +1,80 @@
import type { ConfigValidationIssue } from "../schema/issues";
import type { JsonValue } from "../types";
import { OperatorKeys } from "../schema/fragments";
import { issue, joinPath } from "../schema/issues";
const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
export function isJsonValue(value: unknown): value is JsonValue {
if (value === null) return true;
if (typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isJsonValue);
if (typeof value === "object") {
return Object.values(value as Record<string, unknown>).every(isJsonValue);
}
return false;
}
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function validateOperatorObject(
operators: unknown,
path: string,
targetName?: string,
options: { requireAtLeastOne: boolean } = { requireAtLeastOne: true },
): ConfigValidationIssue[] {
if (!isPlainRecord(operators)) return [issue("invalid-type", path, "必须为操作符对象", targetName)];
const issues: ConfigValidationIssue[] = [];
let found = 0;
for (const [key, value] of Object.entries(operators)) {
if (!OPERATOR_KEY_SET.has(key)) {
issues.push(issue("unknown-operator", joinPath(path, key), "是未知 operator", targetName));
continue;
}
if (value === undefined) continue;
found++;
issues.push(...validateOperatorValue(key, value, joinPath(path, key), targetName));
}
if (options.requireAtLeastOne && found === 0) {
issues.push(issue("empty-operator", path, "必须包含至少一个合法 operator", targetName));
}
return issues;
}
export function validateOperatorValue(
key: string,
value: unknown,
path: string,
targetName?: string,
): ConfigValidationIssue[] {
switch (key) {
case "contains":
return typeof value === "string" ? [] : [issue("invalid-type", path, "必须为字符串", targetName)];
case "empty":
case "exists":
return typeof value === "boolean" ? [] : [issue("invalid-type", path, "必须为布尔值", targetName)];
case "equals":
return isJsonValue(value) ? [] : [issue("invalid-type", path, "必须为 JSON value", targetName)];
case "gt":
case "gte":
case "lt":
case "lte":
return typeof value === "number" && Number.isFinite(value)
? []
: [issue("invalid-type", path, "必须为有限数字", targetName)];
case "match":
if (typeof value !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(value);
return [];
} catch {
return [issue("invalid-regex", path, "正则不合法", targetName)];
}
default:
return [issue("unknown-operator", path, "是未知 operator", targetName)];
}
}

View File

@@ -1,21 +1,16 @@
import { isError } from "es-toolkit";
import { resolve } from "node:path";
import type {
CheckResult,
CommandTargetConfig,
ResolvedCommandTarget,
ResolvedTarget,
TargetConfig,
} from "../../types";
import type { CheckResult, RawTargetConfig, ResolvedTargetBase } from "../../types";
import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types";
import type { CommandExpectConfig, CommandTargetConfig, ResolvedCommandTarget } from "./types";
import { parseSize } from "../../size";
import { checkDuration } from "../shared/duration";
import { errorFailure } from "../shared/failure";
import { checkTextRules } from "../shared/text";
import { commandCheckerSchemas } from "./contract";
import { checkDuration } from "../../expect/duration";
import { errorFailure } from "../../expect/failure";
import { parseSize } from "../../utils";
import { checkExitCode } from "./expect";
import { commandCheckerSchemas } from "./schema";
import { checkTextRules } from "./text";
import { validateCommandConfig } from "./validate";
export class CommandChecker implements Checker {
@@ -25,7 +20,7 @@ export class CommandChecker implements Checker {
readonly type = "command";
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
async execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise<CheckResult> {
const t = target as ResolvedCommandTarget;
const timestamp = new Date().toISOString();
const start = performance.now();
@@ -174,9 +169,9 @@ export class CommandChecker implements Checker {
};
}
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget {
const t = target as TargetConfig & { command: CommandTargetConfig; type: "command" };
const commandDefaults = context.defaults.command;
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase {
const t = target as RawTargetConfig & { command: CommandTargetConfig; type: "command" };
const commandDefaults = context.defaults["command"] as undefined | { cwd?: string; maxOutputBytes?: string };
const cwd = t.command.cwd ?? commandDefaults?.cwd ?? ".";
const resolvedCwd = resolve(context.configDir, cwd);
@@ -193,7 +188,7 @@ export class CommandChecker implements Checker {
exec: t.command.exec,
maxOutputBytes,
},
expect: target.expect,
expect: target.expect as CommandExpectConfig | undefined,
group: target.group ?? "default",
intervalMs: context.defaultIntervalMs,
name: t.name,
@@ -202,7 +197,7 @@ export class CommandChecker implements Checker {
} satisfies ResolvedCommandTarget;
}
serialize(target: ResolvedTarget): { config: string; target: string } {
serialize(target: ResolvedTargetBase): { config: string; target: string } {
const t = target as ResolvedCommandTarget;
const parts = [t.command.exec, ...t.command.args];
return {

View File

@@ -1,6 +1,6 @@
import type { ExpectResult } from "../shared/duration";
import type { ExpectResult } from "../../expect/types";
import { mismatchFailure } from "../shared/failure";
import { mismatchFailure } from "../../expect/failure";
export function checkExitCode(exitCode: number, allowed: number[]): ExpectResult {
if (!allowed.includes(exitCode)) {

View File

@@ -0,0 +1 @@
export { CommandChecker } from "./execute";

View File

@@ -2,7 +2,7 @@ import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import { createTextRulesSchema, sizeSchema, stringMapSchema } from "../../config-contract/fragments";
import { createTextRulesSchema, sizeSchema, stringMapSchema } from "../../schema/fragments";
export const commandCheckerSchemas: CheckerSchemas = {
config: Type.Object(

View File

@@ -1,8 +1,8 @@
import type { TextRule } from "../../types";
import type { ExpectResult } from "./duration";
import type { ExpectResult } from "../../expect/types";
import type { TextRule } from "./types";
import { mismatchFailure } from "./failure";
import { applyOperator } from "./operator";
import { mismatchFailure } from "../../expect/failure";
import { applyOperator } from "../../expect/operator";
export function checkTextRules(text: string, rules: TextRule[], phase: string): ExpectResult {
for (let i = 0; i < rules.length; i++) {

View File

@@ -0,0 +1,41 @@
import type { ExpectOperator, ResolvedTargetBase } from "../../types";
export interface CommandDefaultsConfig {
cwd?: string;
maxOutputBytes?: string;
}
export interface CommandExpectConfig {
exitCode?: number[];
maxDurationMs?: number;
stderr?: TextRule[];
stdout?: TextRule[];
}
export interface CommandTargetConfig {
args?: string[];
cwd?: string;
env?: Record<string, string>;
exec: string;
maxOutputBytes?: string;
}
export interface ResolvedCommandConfig {
args: string[];
cwd: string;
env: Record<string, string>;
exec: string;
maxOutputBytes: number;
}
export interface ResolvedCommandTarget extends ResolvedTargetBase {
command: ResolvedCommandConfig;
expect?: CommandExpectConfig;
group: string;
intervalMs: number;
name: string;
timeoutMs: number;
type: "command";
}
export type TextRule = ExpectOperator;

View File

@@ -1,9 +1,9 @@
import type { ConfigValidationIssue } from "../../config-contract/issues";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { issue, joinPath } from "../../config-contract/issues";
import { parseSize } from "../../size";
import { validateTextRules } from "../shared/validate";
import { validateOperatorObject } from "../../expect/validate-operator";
import { issue, joinPath } from "../../schema/issues";
import { parseSize } from "../../utils";
export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
@@ -91,3 +91,8 @@ function validateSizeValue(value: number | string, path: string, targetName?: st
return [issue("invalid-size", path, error instanceof Error ? error.message : "size 格式不合法", targetName)];
}
}
function validateTextRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!Array.isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)];
return rules.flatMap((rule, index) => validateOperatorObject(rule, `${path}[${index}]`, targetName));
}

View File

@@ -2,11 +2,11 @@ import { DOMParser } from "@xmldom/xmldom";
import * as cheerio from "cheerio";
import * as xpath from "xpath";
import type { BodyRule, CssRule, JsonRule, XpathRule } from "../../types";
import type { ExpectResult } from "./duration";
import type { ExpectResult } from "../../expect/types";
import type { BodyRule, CssRule, JsonRule, XpathRule } from "./types";
import { errorFailure, mismatchFailure } from "./failure";
import { applyOperator, evaluateJsonPath } from "./operator";
import { errorFailure, mismatchFailure } from "../../expect/failure";
import { applyOperator, evaluateJsonPath } from "../../expect/operator";
export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult {
if (!rules || rules.length === 0) return { failure: null, matched: true };

View File

@@ -1,14 +1,15 @@
import { isError } from "es-toolkit";
import type { CheckResult, HttpTargetConfig, ResolvedHttpTarget, ResolvedTarget, TargetConfig } from "../../types";
import type { CheckResult, RawTargetConfig, ResolvedTargetBase } from "../../types";
import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types";
import type { HttpExpectConfig, HttpTargetConfig, ResolvedHttpTarget } from "./types";
import { parseSize } from "../../size";
import { checkBodyExpect } from "../shared/body";
import { checkDuration } from "../shared/duration";
import { errorFailure, mismatchFailure } from "../shared/failure";
import { httpCheckerSchemas } from "./contract";
import { checkDuration } from "../../expect/duration";
import { errorFailure, mismatchFailure } from "../../expect/failure";
import { parseSize } from "../../utils";
import { checkBodyExpect } from "./body";
import { checkHeaders, checkStatus } from "./expect";
import { httpCheckerSchemas } from "./schema";
import { validateHttpConfig } from "./validate";
const CHARSET_RE = /charset="?([^";\s]+)"?/i;
@@ -22,7 +23,7 @@ export class HttpChecker implements Checker {
readonly type = "http";
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
async execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise<CheckResult> {
const t = target as ResolvedHttpTarget;
const timestamp = new Date().toISOString();
const expect = t.expect;
@@ -116,15 +117,17 @@ export class HttpChecker implements Checker {
}
}
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget {
const t = target as TargetConfig & { http: HttpTargetConfig; type: "http" };
const httpDefaults = context.defaults.http;
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase {
const t = target as RawTargetConfig & { http: HttpTargetConfig; type: "http" };
const httpDefaults = context.defaults["http"] as
| undefined
| { headers?: Record<string, string>; maxBodyBytes?: string; method?: string };
const method = t.http.method ?? httpDefaults?.method ?? "GET";
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
return {
expect: target.expect,
expect: target.expect as HttpExpectConfig | undefined,
group: target.group ?? "default",
http: {
body: t.http.body,
@@ -142,7 +145,7 @@ export class HttpChecker implements Checker {
} satisfies ResolvedHttpTarget;
}
serialize(target: ResolvedTarget): { config: string; target: string } {
serialize(target: ResolvedTargetBase): { config: string; target: string } {
const t = target as ResolvedHttpTarget;
return {
config: JSON.stringify({

View File

@@ -1,8 +1,8 @@
import type { HeaderExpect } from "../../types";
import type { ExpectResult } from "../shared/duration";
import type { ExpectResult } from "../../expect/types";
import type { HeaderExpect } from "./types";
import { mismatchFailure } from "../shared/failure";
import { applyOperator } from "../shared/operator";
import { mismatchFailure } from "../../expect/failure";
import { applyOperator } from "../../expect/operator";
export function checkHeaders(
headers: Record<string, string>,

View File

@@ -0,0 +1 @@
export { HttpChecker } from "./execute";

View File

@@ -9,7 +9,7 @@ import {
sizeSchema,
statusCodePatternSchema,
stringMapSchema,
} from "../../config-contract/fragments";
} from "../../schema/fragments";
export const httpCheckerSchemas: CheckerSchemas = {
config: Type.Object(

View File

@@ -0,0 +1,59 @@
import type { ExpectOperator, ResolvedTargetBase } from "../../types";
export type BodyRule =
| { contains: string }
| { css: CssRule }
| { json: JsonRule }
| { regex: string }
| { xpath: XpathRule };
export type CssRule = ExpectOperator & { attr?: string; selector: string };
export type HeaderExpect = ExpectOperator | string;
export interface HttpDefaultsConfig {
headers?: Record<string, string>;
maxBodyBytes?: string;
method?: string;
}
export interface HttpExpectConfig {
body?: BodyRule[];
headers?: Record<string, HeaderExpect>;
maxDurationMs?: number;
status?: Array<number | string>;
}
export interface HttpTargetConfig {
body?: string;
headers?: Record<string, string>;
ignoreSSL?: boolean;
maxBodyBytes?: string;
maxRedirects?: number;
method?: string;
url: string;
}
export type JsonRule = ExpectOperator & { path: string };
export interface ResolvedHttpConfig {
body?: string;
headers: Record<string, string>;
ignoreSSL: boolean;
maxBodyBytes: number;
maxRedirects: number;
method: string;
url: string;
}
export interface ResolvedHttpTarget extends ResolvedTargetBase {
expect?: HttpExpectConfig;
group: string;
http: ResolvedHttpConfig;
intervalMs: number;
name: string;
timeoutMs: number;
type: "http";
}
export type XpathRule = ExpectOperator & { path: string };

View File

@@ -1,15 +1,26 @@
import type { ConfigValidationIssue } from "../../config-contract/issues";
import { DOMParser } from "@xmldom/xmldom";
import * as xpath from "xpath";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { issue, joinPath } from "../../config-contract/issues";
import { parseSize } from "../../size";
import { validateBodyRules, validateOperatorObject } from "../shared/validate";
import { isPlainRecord, validateOperatorObject } from "../../expect/validate-operator";
import { BodyRuleTypeKeys, OperatorKeys } from "../../schema/fragments";
import { issue, joinPath } from "../../schema/issues";
import { parseSize } from "../../utils";
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
export function validateBodyRules(body: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!Array.isArray(body)) return [issue("invalid-type", path, "必须为数组", targetName)];
return body.flatMap((rule, index) => validateSingleBodyRule(rule, `${path}[${index}]`, targetName));
}
export function validateHttpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults = isRecord(input.defaults) && isRecord(input.defaults["http"]) ? input.defaults["http"] : undefined;
const defaults =
isPlainRecord(input.defaults) && isPlainRecord(input.defaults["http"]) ? input.defaults["http"] : undefined;
if (isSizeInput(defaults?.["maxBodyBytes"])) {
issues.push(...validateSizeValue(defaults["maxBodyBytes"], "defaults.http.maxBodyBytes"));
@@ -17,7 +28,7 @@ export function validateHttpConfig(input: CheckerValidationInput): ConfigValidat
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isRecord(target)) continue;
if (!isPlainRecord(target)) continue;
if (target["type"] !== "http") continue;
issues.push(...validateHttpTarget(target, `targets[${i}]`));
}
@@ -25,6 +36,43 @@ export function validateHttpConfig(input: CheckerValidationInput): ConfigValidat
return issues;
}
export function validateJsonPath(path: string, rulePath: string, targetName?: string): ConfigValidationIssue[] {
if (!path.startsWith("$.") || path.length <= 2) {
return [issue("invalid-jsonpath", joinPath(rulePath, "path"), '必须为以 "$." 开头的有效 JSONPath', targetName)];
}
const issues: ConfigValidationIssue[] = [];
const segments = path.slice(2).split(".");
for (const seg of segments) {
if (seg === "") {
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "包含空段", targetName));
}
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
if (bracketMatch?.[1]!.trim() === "") {
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "数组访问缺少属性名", targetName));
}
}
return issues;
}
function collectOperatorObject(
object: Record<string, unknown>,
allowedKeys: Set<string>,
path: string,
targetName?: string,
): { issues: ConfigValidationIssue[]; operators: Record<string, unknown> } {
const issues: ConfigValidationIssue[] = [];
const operators: Record<string, unknown> = {};
for (const [key, value] of Object.entries(object)) {
if (allowedKeys.has(key)) continue;
if (OPERATOR_KEY_SET.has(key)) {
operators[key] = value;
} else {
issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
}
return { issues, operators };
}
function getTargetName(target: Record<string, unknown>): string | undefined {
return typeof target["name"] === "string" ? target["name"] : undefined;
}
@@ -33,22 +81,35 @@ function isNonNegativeFiniteNumber(value: unknown): boolean {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isSizeInput(value: unknown): value is number | string {
return typeof value === "number" || typeof value === "string";
}
function validateCssRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["selector"] !== "string" || rule["selector"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "selector"), "必须为非空字符串", targetName));
}
if ("attr" in rule && typeof rule["attr"] !== "string") {
issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName));
}
const result = collectOperatorObject(rule, new Set(["attr", "selector"]), path, targetName);
issues.push(
...result.issues,
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
);
return issues;
}
function validateHttpExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isRecord(expect)) return [];
if (expect === undefined || expect === null || !isPlainRecord(expect)) return [];
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
if (isRecord(expect["headers"])) {
if (isPlainRecord(expect["headers"])) {
for (const [key, value] of Object.entries(expect["headers"])) {
if (typeof value === "string") continue;
issues.push(...validateOperatorObject(value, joinPath(joinPath(expectPath, "headers"), key), targetName));
@@ -74,7 +135,7 @@ function validateHttpTarget(target: Record<string, unknown>, path: string): Conf
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const http = target["http"];
if (!isRecord(http)) {
if (!isPlainRecord(http)) {
issues.push(issue("required", joinPath(path, "http"), "缺少 http.url 字段", targetName));
issues.push(...validateHttpExpect(target, path));
return issues;
@@ -107,6 +168,61 @@ function validateHttpTarget(target: Record<string, unknown>, path: string): Conf
return issues;
}
function validateJsonRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["path"] !== "string") {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName));
} else {
issues.push(...validateJsonPath(rule["path"], path, targetName));
}
const result = collectOperatorObject(rule, new Set(["path"]), path, targetName);
issues.push(
...result.issues,
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
);
return issues;
}
function validateRegexRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (typeof rule !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(rule);
return [];
} catch {
return [issue("invalid-regex", path, "正则不合法", targetName)];
}
}
function validateSingleBodyRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const found = BodyRuleTypeKeys.filter((type) => type in rule);
if (found.length === 0) return [issue("missing-body-rule", path, "缺少支持的规则类型", targetName)];
if (found.length > 1) return [issue("multiple-body-rules", path, "只能配置一种规则类型", targetName)];
const ruleType = found[0]!;
const issues: ConfigValidationIssue[] = [];
for (const key of Object.keys(rule)) {
if (key !== ruleType) issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
if (issues.length > 0) return issues;
switch (ruleType) {
case "contains":
return typeof rule["contains"] === "string"
? []
: [issue("invalid-type", joinPath(path, "contains"), "必须为字符串", targetName)];
case "css":
return validateCssRule(rule["css"], joinPath(path, "css"), targetName);
case "json":
return validateJsonRule(rule["json"], joinPath(path, "json"), targetName);
case "regex":
return validateRegexRule(rule["regex"], joinPath(path, "regex"), targetName);
case "xpath":
return validateXpathRule(rule["xpath"], joinPath(path, "xpath"), targetName);
}
}
function validateSizeValue(value: number | string, path: string, targetName?: string): ConfigValidationIssue[] {
try {
parseSize(value);
@@ -137,3 +253,24 @@ function validateStatusValues(values: unknown[], path: string, targetName?: stri
}
return issues;
}
function validateXpathRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["path"] !== "string" || rule["path"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName));
} else {
try {
const doc = new DOMParser().parseFromString("<x/>", "text/xml");
xpath.select(rule["path"], doc as unknown as Node);
} catch {
issues.push(issue("invalid-xpath", joinPath(path, "path"), "xpath 不合法", targetName));
}
}
const result = collectOperatorObject(rule, new Set(["path"]), path, targetName);
issues.push(
...result.issues,
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
);
return issues;
}

View File

@@ -1,16 +1,15 @@
import { CommandChecker } from "./command/runner";
import { HttpChecker } from "./http/runner";
import { CheckerRegistry, checkerRegistry } from "./registry";
import { CommandChecker } from "./command";
import { HttpChecker } from "./http";
import { CheckerRegistry } from "./registry";
const checkers = [new HttpChecker(), new CommandChecker()];
export function createDefaultCheckerRegistry(): CheckerRegistry {
const registry = new CheckerRegistry();
registerCheckers(registry);
for (const checker of checkers) {
registry.register(checker);
}
return registry;
}
export function registerCheckers(registry = checkerRegistry): void {
registry.register(new HttpChecker());
registry.register(new CommandChecker());
}
export { checkerRegistry } from "./registry";
export const checkerRegistry = createDefaultCheckerRegistry();

View File

@@ -30,5 +30,3 @@ export class CheckerRegistry {
return this.checkers.get(type);
}
}
export const checkerRegistry = new CheckerRegistry();

View File

@@ -1,223 +0,0 @@
import { DOMParser } from "@xmldom/xmldom";
import * as xpath from "xpath";
import type { ConfigValidationIssue } from "../../config-contract/issues";
import type { JsonValue } from "../../types";
import { BodyRuleTypeKeys, OperatorKeys } from "../../config-contract/fragments";
import { issue, joinPath } from "../../config-contract/issues";
const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
export function isJsonValue(value: unknown): value is JsonValue {
if (value === null) return true;
if (typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isJsonValue);
if (typeof value === "object") {
return Object.values(value as Record<string, unknown>).every(isJsonValue);
}
return false;
}
export function validateBodyRules(body: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!Array.isArray(body)) return [issue("invalid-type", path, "必须为数组", targetName)];
return body.flatMap((rule, index) => validateSingleBodyRule(rule, `${path}[${index}]`, targetName));
}
export function validateJsonPath(path: string, rulePath: string, targetName?: string): ConfigValidationIssue[] {
if (!path.startsWith("$.") || path.length <= 2) {
return [issue("invalid-jsonpath", joinPath(rulePath, "path"), '必须为以 "$." 开头的有效 JSONPath', targetName)];
}
const issues: ConfigValidationIssue[] = [];
const segments = path.slice(2).split(".");
for (const seg of segments) {
if (seg === "") {
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "包含空段", targetName));
}
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
if (bracketMatch?.[1]!.trim() === "") {
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "数组访问缺少属性名", targetName));
}
}
return issues;
}
export function validateOperatorObject(
operators: unknown,
path: string,
targetName?: string,
options: { requireAtLeastOne: boolean } = { requireAtLeastOne: true },
): ConfigValidationIssue[] {
if (!isPlainRecord(operators)) return [issue("invalid-type", path, "必须为操作符对象", targetName)];
const issues: ConfigValidationIssue[] = [];
let found = 0;
for (const [key, value] of Object.entries(operators)) {
if (!OPERATOR_KEY_SET.has(key)) {
issues.push(issue("unknown-operator", joinPath(path, key), "是未知 operator", targetName));
continue;
}
if (value === undefined) continue;
found++;
issues.push(...validateOperatorValue(key, value, joinPath(path, key), targetName));
}
if (options.requireAtLeastOne && found === 0) {
issues.push(issue("empty-operator", path, "必须包含至少一个合法 operator", targetName));
}
return issues;
}
export function validateTextRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!Array.isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)];
return rules.flatMap((rule, index) => validateOperatorObject(rule, `${path}[${index}]`, targetName));
}
function collectOperatorObject(
object: Record<string, unknown>,
allowedKeys: Set<string>,
path: string,
targetName?: string,
): { issues: ConfigValidationIssue[]; operators: Record<string, unknown> } {
const issues: ConfigValidationIssue[] = [];
const operators: Record<string, unknown> = {};
for (const [key, value] of Object.entries(object)) {
if (allowedKeys.has(key)) continue;
if (OPERATOR_KEY_SET.has(key)) {
operators[key] = value;
} else {
issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
}
return { issues, operators };
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function validateCssRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["selector"] !== "string" || rule["selector"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "selector"), "必须为非空字符串", targetName));
}
if ("attr" in rule && typeof rule["attr"] !== "string") {
issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName));
}
const result = collectOperatorObject(rule, new Set(["attr", "selector"]), path, targetName);
issues.push(
...result.issues,
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
);
return issues;
}
function validateJsonRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["path"] !== "string") {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName));
} else {
issues.push(...validateJsonPath(rule["path"], path, targetName));
}
const result = collectOperatorObject(rule, new Set(["path"]), path, targetName);
issues.push(
...result.issues,
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
);
return issues;
}
function validateOperatorValue(
key: string,
value: unknown,
path: string,
targetName?: string,
): ConfigValidationIssue[] {
switch (key) {
case "contains":
return typeof value === "string" ? [] : [issue("invalid-type", path, "必须为字符串", targetName)];
case "empty":
case "exists":
return typeof value === "boolean" ? [] : [issue("invalid-type", path, "必须为布尔值", targetName)];
case "equals":
return isJsonValue(value) ? [] : [issue("invalid-type", path, "必须为 JSON value", targetName)];
case "gt":
case "gte":
case "lt":
case "lte":
return typeof value === "number" && Number.isFinite(value)
? []
: [issue("invalid-type", path, "必须为有限数字", targetName)];
case "match":
if (typeof value !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(value);
return [];
} catch {
return [issue("invalid-regex", path, "正则不合法", targetName)];
}
default:
return [issue("unknown-operator", path, "是未知 operator", targetName)];
}
}
function validateRegexRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (typeof rule !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(rule);
return [];
} catch {
return [issue("invalid-regex", path, "正则不合法", targetName)];
}
}
function validateSingleBodyRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const found = BodyRuleTypeKeys.filter((type) => type in rule);
if (found.length === 0) return [issue("missing-body-rule", path, "缺少支持的规则类型", targetName)];
if (found.length > 1) return [issue("multiple-body-rules", path, "只能配置一种规则类型", targetName)];
const ruleType = found[0]!;
const issues: ConfigValidationIssue[] = [];
for (const key of Object.keys(rule)) {
if (key !== ruleType) issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
if (issues.length > 0) return issues;
switch (ruleType) {
case "contains":
return typeof rule["contains"] === "string"
? []
: [issue("invalid-type", joinPath(path, "contains"), "必须为字符串", targetName)];
case "css":
return validateCssRule(rule["css"], joinPath(path, "css"), targetName);
case "json":
return validateJsonRule(rule["json"], joinPath(path, "json"), targetName);
case "regex":
return validateRegexRule(rule["regex"], joinPath(path, "regex"), targetName);
case "xpath":
return validateXpathRule(rule["xpath"], joinPath(path, "xpath"), targetName);
}
}
function validateXpathRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["path"] !== "string" || rule["path"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName));
} else {
try {
const doc = new DOMParser().parseFromString("<x/>", "text/xml");
xpath.select(rule["path"], doc as unknown as Node);
} catch {
issues.push(issue("invalid-xpath", joinPath(path, "path"), "xpath 不合法", targetName));
}
}
const result = collectOperatorObject(rule, new Set(["path"]), path, targetName);
issues.push(
...result.issues,
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
);
return issues;
}

View File

@@ -1,7 +1,7 @@
import type { TSchema } from "@sinclair/typebox";
import type { ConfigValidationIssue } from "../config-contract/issues";
import type { CheckResult, DefaultsConfig, ResolvedTarget, TargetConfig } from "../types";
import type { ConfigValidationIssue } from "../schema/issues";
import type { CheckResult, DefaultsConfig, RawTargetConfig, ResolvedTargetBase } from "../types";
export type Checker = CheckerDefinition;
@@ -11,10 +11,10 @@ export interface CheckerContext {
export interface CheckerDefinition {
readonly configKey: string;
execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult>;
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget;
execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise<CheckResult>;
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase;
readonly schemas: CheckerSchemas;
serialize(target: ResolvedTarget): { config: string; target: string };
serialize(target: ResolvedTargetBase): { config: string; target: string };
readonly type: string;
validate(input: CheckerValidationInput): ConfigValidationIssue[];
}
@@ -27,7 +27,7 @@ export interface CheckerSchemas {
export interface CheckerValidationInput {
defaults: DefaultsConfig;
targets: TargetConfig[];
targets: RawTargetConfig[];
}
export interface ResolveContext {

View File

@@ -1,6 +1,6 @@
import type { CheckerRegistry } from "../runner/registry";
import { createExternalProbeConfigSchema } from "./schema";
import { createExternalProbeConfigSchema } from "./builder";
export function createProbeConfigJsonSchema(registry: CheckerRegistry): Record<string, unknown> {
return createExternalProbeConfigSchema(registry.definitions);

View File

@@ -6,8 +6,8 @@ import type { CheckerRegistry } from "../runner/registry";
import type { ConfigValidationIssue } from "./issues";
import type { RawProbeConfig } from "./types";
import { createProbeConfigSchema, createTargetSchema } from "./builder";
import { issue } from "./issues";
import { createProbeConfigSchema, createTargetSchema } from "./schema";
export function createConfigAjv(): Ajv {
return new Ajv({ allErrors: true, coerceTypes: false, removeAdditional: false, strict: true, useDefaults: false });

View File

@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
import { mkdirSync as fsMkdirSync } from "node:fs";
import { dirname } from "node:path";
import type { CheckFailure, ResolvedTarget, StoredCheckResult, StoredTarget } from "./types";
import type { CheckFailure, ResolvedTargetBase, StoredCheckResult, StoredTarget } from "./types";
import { checkerRegistry } from "./runner";
@@ -257,7 +257,7 @@ export class ProbeStore {
);
}
syncTargets(targets: ResolvedTarget[]): void {
syncTargets(targets: ResolvedTargetBase[]): void {
if (this.closed) return;
const existingRows = this.db.query("SELECT id, name FROM targets").all() as Array<{
id: number;

View File

@@ -1,41 +1,11 @@
import type { CheckResult as ApiCheckResult, CheckFailure } from "../../shared/api";
export type BodyRule =
| { contains: string }
| { css: CssRule }
| { json: JsonRule }
| { regex: string }
| { xpath: XpathRule };
export interface CheckResult extends ApiCheckResult {
targetName: string;
}
export interface CommandDefaultsConfig {
cwd?: string;
maxOutputBytes?: string;
}
export interface CommandExpectConfig {
exitCode?: number[];
maxDurationMs?: number;
stderr?: TextRule[];
stdout?: TextRule[];
}
export interface CommandTargetConfig {
args?: string[];
cwd?: string;
env?: Record<string, string>;
exec: string;
maxOutputBytes?: string;
}
export type CssRule = ExpectOperator & { attr?: string; selector: string };
export interface DefaultsConfig {
command?: CommandDefaultsConfig;
http?: HttpDefaultsConfig;
[checkerKey: string]: unknown;
interval?: string;
timeout?: string;
}
@@ -44,8 +14,6 @@ export interface EngineRuntimeConfig {
maxConcurrentChecks?: number;
}
export type ExpectConfig = CommandExpectConfig | HttpExpectConfig;
export interface ExpectOperator {
contains?: string;
empty?: boolean;
@@ -60,82 +28,35 @@ export interface ExpectOperator {
export type ExpectValue = ExpectOperator | JsonValue;
export type HeaderExpect = ExpectOperator | string;
export interface HttpDefaultsConfig {
headers?: Record<string, string>;
maxBodyBytes?: string;
method?: string;
}
export interface HttpExpectConfig {
body?: BodyRule[];
headers?: Record<string, HeaderExpect>;
maxDurationMs?: number;
status?: Array<number | string>;
}
export interface HttpTargetConfig {
body?: string;
headers?: Record<string, string>;
ignoreSSL?: boolean;
maxBodyBytes?: string;
maxRedirects?: number;
method?: string;
url: string;
}
export type JsonRule = ExpectOperator & { path: string };
export type JsonValue = boolean | JsonValue[] | null | number | string | { [key: string]: JsonValue };
export interface ProbeConfig {
defaults?: DefaultsConfig;
runtime?: EngineRuntimeConfig;
server?: ServerConfig;
targets: TargetConfig[];
targets: RawTargetConfig[];
}
export interface ResolvedCommandConfig {
args: string[];
cwd: string;
env: Record<string, string>;
exec: string;
maxOutputBytes: number;
export interface RawTargetConfig {
[configKey: string]: unknown;
expect?: unknown;
group?: string;
interval?: string;
name: string;
timeout?: string;
type: string;
}
export interface ResolvedCommandTarget {
command: ResolvedCommandConfig;
expect?: CommandExpectConfig;
export interface ResolvedTargetBase {
[key: string]: unknown;
expect?: unknown;
group: string;
intervalMs: number;
name: string;
timeoutMs: number;
type: "command";
type: string;
}
export interface ResolvedHttpConfig {
body?: string;
headers: Record<string, string>;
ignoreSSL: boolean;
maxBodyBytes: number;
maxRedirects: number;
method: string;
url: string;
}
export interface ResolvedHttpTarget {
expect?: HttpExpectConfig;
group: string;
http: ResolvedHttpConfig;
intervalMs: number;
name: string;
timeoutMs: number;
type: "http";
}
export type ResolvedTarget = ResolvedCommandTarget | ResolvedHttpTarget;
export interface ServerConfig {
dataDir?: string;
host?: string;
@@ -161,22 +82,7 @@ export interface StoredTarget {
name: string;
target: string;
timeout_ms: number;
type: TargetType;
type: string;
}
export type TargetConfig = BaseTargetConfig &
({ command: CommandTargetConfig; type: "command" } | { http: HttpTargetConfig; type: "http" });
export type { CheckFailure };
export type TargetType = "command" | "http";
export type TextRule = ExpectOperator;
export type XpathRule = ExpectOperator & { path: string };
interface BaseTargetConfig {
expect?: ExpectConfig;
group?: string;
interval?: string;
name: string;
timeout?: string;
}

View File

@@ -1,5 +1,23 @@
const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/;
const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/;
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;
}
export function parseSize(value: number | string): number {
if (typeof value === "number") {
if (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) {

View File

@@ -1,13 +1,10 @@
import { loadConfig } from "./checker/config-loader";
import { ProbeEngine } from "./checker/engine";
import { registerCheckers } from "./checker/runner";
import { ProbeStore } from "./checker/store";
import { readRuntimeConfig } from "./config";
import { startServer } from "./server";
async function main() {
registerCheckers();
const { configPath } = readRuntimeConfig();
const config = await loadConfig(configPath);