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

@@ -0,0 +1,311 @@
import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig, ResolvedTargetBase } from "../../types";
import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types";
import type { HttpExpectConfig, HttpTargetConfig, ResolvedHttpTarget } from "./types";
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;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);
export class HttpChecker implements Checker {
readonly configKey = "http";
readonly schemas = httpCheckerSchemas;
readonly type = "http";
async execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise<CheckResult> {
const t = target as ResolvedHttpTarget;
const timestamp = new Date().toISOString();
const expect = t.expect;
const start = performance.now();
try {
const response = await fetchWithRedirects(t.http.url, t.http.maxRedirects, {
body: t.http.method !== "GET" && t.http.method !== "HEAD" ? t.http.body : undefined,
headers: t.http.headers,
method: t.http.method,
redirect: "manual",
signal: ctx.signal,
...(t.http.ignoreSSL ? { tls: { rejectUnauthorized: false } } : {}),
});
const statusCode = response.status;
const responseHeaders = Object.fromEntries(response.headers);
const statusResult = checkStatus(statusCode, expect?.status ?? [200]);
if (!statusResult.matched) {
return makeResult(t, timestamp, performance.now() - start, statusResult.failure, statusCode);
}
const headersResult = checkHeaders(responseHeaders, expect?.headers);
if (!headersResult.matched) {
return makeResult(t, timestamp, performance.now() - start, headersResult.failure, statusCode);
}
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
if (hasBodyRules && expect?.maxDurationMs !== undefined) {
const elapsed = performance.now() - start;
if (elapsed > expect.maxDurationMs) {
const durationMs = Math.round(elapsed);
return makeResult(
t,
timestamp,
elapsed,
mismatchFailure(
"duration",
"duration",
`<=${expect.maxDurationMs}ms`,
durationMs,
`duration ${durationMs}ms > ${expect.maxDurationMs}ms`,
),
statusCode,
);
}
}
if (hasBodyRules) {
const bodyReadResult = await readBodyStream(response, t.http.maxBodyBytes);
if (!bodyReadResult.ok) {
return makeResult(t, timestamp, performance.now() - start, bodyReadResult.failure, statusCode);
}
const decodeResult = decodeBody(bodyReadResult.data, response.headers);
if (!decodeResult.ok) {
return makeResult(t, timestamp, performance.now() - start, decodeResult.failure, statusCode);
}
const bodyResult = checkBodyExpect(decodeResult.text, expect.body);
if (!bodyResult.matched) {
return makeResult(t, timestamp, performance.now() - start, bodyResult.failure, statusCode);
}
}
const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
if (!durationResult.matched) {
return makeResult(t, timestamp, durationMs, durationResult.failure, statusCode);
}
return makeResult(t, timestamp, durationMs, null, statusCode);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
return {
durationMs,
failure: errorFailure(
"request",
"request",
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
),
matched: false,
statusDetail: null,
targetName: t.name,
timestamp,
};
}
}
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 as HttpExpectConfig | undefined,
group: target.group ?? "default",
http: {
body: t.http.body,
headers: { ...(httpDefaults?.headers ?? {}), ...(t.http.headers ?? {}) },
ignoreSSL: t.http.ignoreSSL ?? false,
maxBodyBytes,
maxRedirects: t.http.maxRedirects ?? 0,
method,
url: t.http.url,
},
intervalMs: context.defaultIntervalMs,
name: t.name,
timeoutMs: context.defaultTimeoutMs,
type: "http",
} satisfies ResolvedHttpTarget;
}
serialize(target: ResolvedTargetBase): { config: string; target: string } {
const t = target as ResolvedHttpTarget;
return {
config: JSON.stringify({
body: t.http.body,
headers: t.http.headers,
ignoreSSL: t.http.ignoreSSL,
maxBodyBytes: t.http.maxBodyBytes,
maxRedirects: t.http.maxRedirects,
method: t.http.method,
url: t.http.url,
}),
target: t.http.url,
};
}
validate(input: CheckerValidationInput) {
return validateHttpConfig(input);
}
}
function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: string, toUrl: string): RequestInit {
let newInit = { ...init };
const method = init.method?.toUpperCase();
if (statusCode === 303 || ((statusCode === 301 || statusCode === 302) && method === "POST")) {
const headers =
typeof init.headers === "object" && init.headers !== null
? { ...(init.headers as Record<string, string>) }
: undefined;
if (headers) {
for (const key of Object.keys(headers)) {
const lower = key.toLowerCase();
if (lower === "content-type" || lower === "content-length") {
delete headers[key];
}
}
}
newInit = { ...newInit, body: undefined, headers, method: "GET" };
}
try {
const fromOrigin = new URL(fromUrl).origin;
const toOrigin = new URL(toUrl).origin;
if (fromOrigin !== toOrigin && newInit.headers && typeof newInit.headers === "object") {
const headers = { ...(newInit.headers as Record<string, string>) };
for (const key of Object.keys(headers)) {
if (SENSITIVE_HEADERS.has(key.toLowerCase())) {
delete headers[key];
}
}
newInit.headers = headers;
}
} catch {
/* URL parsing failed, keep headers */
}
return newInit;
}
function decodeBody(
data: Uint8Array,
headers: Headers,
): { failure: CheckResult["failure"]; ok: false } | { ok: true; text: string } {
const contentType = headers.get("content-type") ?? "";
const charsetMatch = CHARSET_RE.exec(contentType);
const encoding = charsetMatch?.[1]?.toLowerCase() ?? "utf-8";
try {
const text = new TextDecoder(encoding).decode(data);
return { ok: true, text };
} catch {
return {
failure: errorFailure("body", "body", `不支持的字符编码: ${encoding}`),
ok: false,
};
}
}
async function fetchWithRedirects(url: string, maxRedirects: number, init: RequestInit): Promise<Response> {
let currentUrl = url;
let currentInit = init;
for (let followed = 0; ; followed++) {
const response = await fetch(currentUrl, currentInit);
if (!REDIRECT_STATUSES.has(response.status)) return response;
const location = response.headers.get("location");
if (!location || followed >= maxRedirects) return response;
try {
await response.arrayBuffer();
} catch {
/* ignore body drain error */
}
const nextUrl = new URL(location, currentUrl).toString();
currentInit = buildRedirectInit(currentInit, response.status, currentUrl, nextUrl);
currentUrl = nextUrl;
}
}
function makeResult(
t: ResolvedHttpTarget,
timestamp: string,
elapsed: number,
failure: CheckResult["failure"],
statusCode: number,
): CheckResult {
return {
durationMs: Math.round(elapsed),
failure,
matched: failure === null,
statusDetail: `HTTP ${statusCode}`,
targetName: t.name,
timestamp,
};
}
async function readBodyStream(
response: Response,
maxBodyBytes: number,
): Promise<{ data: Uint8Array; ok: true } | { failure: CheckResult["failure"]; ok: false }> {
const reader = response.body?.getReader();
if (!reader) {
return { data: new Uint8Array(0), ok: true };
}
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > maxBodyBytes) {
try {
await reader.cancel();
} catch {
/* ignore cancel error */
}
return {
failure: errorFailure("body", "body", `响应体大小超过限制 ${maxBodyBytes}`),
ok: false,
};
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const result = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return { data: result, ok: true };
}