1
0
Files
DiAL/src/server/checker/runner/http/execute.ts
lanyuanxiaoyao 60a54b483f refactor: expect 类型模型重构,Raw/Resolved 双层分离与断言基础设施内聚
- 重命名 ContentRules→ContentExpectations, KeyValueExpect→KeyedExpectations
- 新增 Raw/Resolved 双层模型:resolve 阶段物化为执行计划,store 持久化 Raw 快照
- HTTP body 按需读取:status/headers 失败或无 body expectation 时不读取 body
- 新增 displayValueExpectation() 解包 failure.expected 用户可读展示
- 修复 checkEarlyTimeout 独立 lte/lt 检查,修复 KeyedExpectations JSON Schema
- 新增 expect/value.ts(resolve/check/display)、keyed.ts、content.ts、headers.ts、status.ts
- 删除旧 normalize.ts/matcher.ts/validate-matcher.ts/key-value.ts
- 更新 DEVELOPMENT.md:expect 五层管线表、displayValueExpectation、1.7↔1.10 交叉引用
- 同步 13 个 main specs,归档 refactor-expect-type-model 变更(62/62 tasks)
2026-05-20 16:12:48 +08:00

448 lines
14 KiB
TypeScript

import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { HttpTargetConfig, RawHttpExpectConfig, ResolvedHttpExpectConfig, ResolvedHttpTarget } from "./types";
import { checkContentExpectations, resolveContentExpectations } from "../../expect/content";
import { errorFailure, mismatchFailure } from "../../expect/failure";
import { checkHeaderExpectations } from "../../expect/headers";
import { resolveKeyedExpectations } from "../../expect/keyed";
import { checkStatusCode } from "../../expect/status";
import { checkValueExpectation, displayValueExpectation, resolveValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
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 CheckerDefinition<ResolvedHttpTarget> {
readonly configKey = "http";
readonly schemas = httpCheckerSchemas;
readonly type = "http";
buildDetail(observation: Record<string, unknown>): null | string {
const statusCode = observation["statusCode"];
return typeof statusCode === "number" ? `HTTP ${statusCode}` : null;
}
async execute(t: ResolvedHttpTarget, ctx: CheckerContext): Promise<CheckResult> {
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 = truncateHeaders(Object.fromEntries(response.headers));
let bodyPreview: null | string = null;
let bodyText: null | string = null;
const hasBodyExpectations = !!(expect?.body && expect.body.length > 0);
const statusResult = checkStatusCode(statusCode, expect?.status ?? [200]);
if (!statusResult.matched) {
return makeResult(
t,
timestamp,
performance.now() - start,
statusResult.failure,
response,
responseHeaders,
bodyPreview,
);
}
const headersResult = checkHeaderExpectations(Object.fromEntries(response.headers), expect?.headers);
if (!headersResult.matched) {
return makeResult(
t,
timestamp,
performance.now() - start,
headersResult.failure,
response,
responseHeaders,
bodyPreview,
);
}
const earlyTimeout = hasBodyExpectations ? checkEarlyTimeout(start, expect?.durationMs) : null;
if (earlyTimeout) {
return makeResult(
t,
timestamp,
earlyTimeout.elapsed,
earlyTimeout.failure,
response,
responseHeaders,
bodyPreview,
);
}
if (hasBodyExpectations) {
const bodyReadResult = await readBodyStream(response, t.http.maxBodyBytes);
let bodyDecodeFailure: CheckResult["failure"] = null;
if (bodyReadResult.data.byteLength > 0) {
const decodeResult = decodeBody(bodyReadResult.data, response.headers);
if (decodeResult.ok) {
bodyText = decodeResult.text;
bodyPreview = truncateBodyPreview(decodeResult.text);
} else {
bodyDecodeFailure = decodeResult.failure;
}
}
if (!bodyReadResult.ok) {
return makeResult(
t,
timestamp,
performance.now() - start,
bodyReadResult.failure,
response,
responseHeaders,
bodyPreview,
);
}
if (bodyDecodeFailure) {
return makeResult(
t,
timestamp,
performance.now() - start,
bodyDecodeFailure,
response,
responseHeaders,
bodyPreview,
);
}
const bodyResult = checkContentExpectations(bodyText ?? "", expect.body, { path: "body", phase: "body" });
if (!bodyResult.matched) {
return makeResult(
t,
timestamp,
performance.now() - start,
bodyResult.failure,
response,
responseHeaders,
bodyPreview,
);
}
}
const durationMs = Math.round(performance.now() - start);
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
if (!durationResult.matched) {
return makeResult(t, timestamp, durationMs, durationResult.failure, response, responseHeaders, bodyPreview);
}
return makeResult(t, timestamp, durationMs, null, response, responseHeaders, bodyPreview);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
return {
detail: null,
durationMs,
failure: errorFailure(
"request",
"request",
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
),
matched: false,
observation: null,
targetId: t.id,
timestamp,
};
}
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedHttpTarget {
const t = target as RawTargetConfig & { http: HttpTargetConfig; type: "http" };
const httpDefaults = context.defaults["http"] as
| undefined
| { headers?: Record<string, string>; maxBodyBytes?: string };
const method = t.http.method ?? "GET";
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
const rawExpect = target.expect as RawHttpExpectConfig | undefined;
const resolvedExpect: ResolvedHttpExpectConfig = rawExpect
? {
body: resolveContentExpectations(rawExpect.body),
durationMs: resolveValueExpectation(rawExpect.durationMs),
headers: resolveKeyedExpectations(rawExpect.headers),
status: rawExpect.status ?? [200],
}
: { status: [200] };
return {
description: null,
expect: resolvedExpect,
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,
},
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
rawExpect,
timeoutMs: context.defaultTimeoutMs,
type: "http",
} satisfies ResolvedHttpTarget;
}
serialize(t: ResolvedHttpTarget): { config: string; target: string } {
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 assembleChunks(chunks: Uint8Array[], totalBytes: number): Uint8Array {
const result = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
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 && typeof newInit.headers === "object" && newInit.headers !== null) {
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 checkEarlyTimeout(
start: number,
durationMatcher: ResolvedHttpExpectConfig["durationMs"] | undefined,
): null | { elapsed: number; failure: CheckResult["failure"] } {
if (!durationMatcher) return null;
const elapsed = performance.now() - start;
const lteFailed = durationMatcher.lte !== undefined && elapsed > durationMatcher.lte;
const ltFailed = durationMatcher.lt !== undefined && elapsed >= durationMatcher.lt;
if (!lteFailed && !ltFailed) return null;
const durationMs = Math.round(elapsed);
const durationResult = checkValueExpectation(durationMs, durationMatcher, {
message: "durationMs mismatch",
path: "durationMs",
phase: "duration",
});
return {
elapsed,
failure:
durationResult.failure ??
mismatchFailure(
"duration",
"durationMs",
displayValueExpectation(durationMatcher),
durationMs,
"durationMs mismatch",
),
};
}
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"],
response: Response,
headers: Record<string, string>,
bodyPreview: null | string = null,
): CheckResult {
const contentType = response.headers.get("content-type");
const contentLengthHeader = response.headers.get("content-length");
const contentLength = contentLengthHeader ? Number(contentLengthHeader) : null;
const observation: Record<string, unknown> = {
bodyPreview,
contentLength: Number.isFinite(contentLength) ? contentLength : null,
contentType,
headers,
statusCode: response.status,
};
return {
detail: null,
durationMs: Math.round(elapsed),
failure,
matched: failure === null,
observation,
targetId: t.id,
timestamp,
};
}
async function readBodyStream(
response: Response,
maxBodyBytes: number,
truncateOnLimit = false,
): Promise<{ data: Uint8Array; failure: CheckResult["failure"]; ok: false } | { data: Uint8Array; ok: true }> {
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) {
const allowedBytes = value.byteLength - (totalBytes - maxBodyBytes);
if (truncateOnLimit && allowedBytes > 0) {
chunks.push(value.slice(0, allowedBytes));
}
try {
await reader.cancel();
} catch {
/* ignore cancel error */
}
const data = assembleChunks(chunks, Math.min(totalBytes, maxBodyBytes));
if (truncateOnLimit) return { data, ok: true };
return {
data,
failure: errorFailure("body", "body", `响应体大小超过限制 ${maxBodyBytes}`),
ok: false,
};
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
return { data: assembleChunks(chunks, totalBytes), ok: true };
}
function truncateBodyPreview(text: string, maxLen = 1024): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen);
}
function truncateHeaders(headers: Record<string, string>, maxCount = 20): Record<string, string> {
const entries = Object.entries(headers);
if (entries.length <= maxCount) return headers;
return Object.fromEntries(entries.slice(0, maxCount));
}