1
0

feat: HTTP 探针增强 — ignoreSSL、精确重定向控制、状态码范围匹配、编码自动检测

This commit is contained in:
2026-05-13 00:02:04 +08:00
parent 87d946a441
commit 2fd0f206be
16 changed files with 642 additions and 22 deletions

View File

@@ -79,8 +79,14 @@ export function checkHttpExpect(
return { failure: null, matched: true };
}
export function checkStatus(statusCode: number, allowed: number[]): ExpectResult {
if (!allowed.includes(statusCode)) {
export function checkStatus(statusCode: number, allowed: Array<number | string>): ExpectResult {
const matched = allowed.some((pattern) => {
if (typeof pattern === "number") return statusCode === pattern;
const base = parseInt(pattern[0]!, 10) * 100;
return statusCode >= base && statusCode < base + 100;
});
if (!matched) {
return {
failure: mismatchFailure(
"status",

View File

@@ -7,6 +7,12 @@ import { parseSize } from "../../size";
import { errorFailure } from "../shared/failure";
import { checkHttpExpect } from "./expect";
const ALLOWED_METHODS = new Set(["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]);
const CHARSET_RE = /charset=([^\s;]+)/i;
const STATUS_RANGE_RE = /^\dxx$/;
const URL_RE = /^https?:\/\/.+/;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
export class HttpChecker implements Checker {
readonly type = "http";
@@ -17,11 +23,13 @@ export class HttpChecker implements Checker {
try {
const start = performance.now();
const response = await fetch(t.http.url, {
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 durationMs = Math.round(performance.now() - start);
@@ -60,7 +68,10 @@ export class HttpChecker implements Checker {
};
}
const body = new TextDecoder().decode(bodyBuffer);
const contentType = response.headers.get("content-type") ?? "";
const charsetMatch = CHARSET_RE.exec(contentType);
const encoding = charsetMatch?.[1] ?? "utf-8";
const body = new TextDecoder(encoding).decode(bodyBuffer);
const fullResult = checkHttpExpect(statusCode, responseHeaders, body, durationMs, t.expect);
return {
@@ -93,10 +104,58 @@ export class HttpChecker implements Checker {
const t = target as TargetConfig & { http: HttpTargetConfig; type: "http" };
const httpDefaults = context.defaults.http;
if (!t.http.url || t.http.url.trim() === "") {
if (!t.http || typeof t.http !== "object") {
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
}
if (typeof t.http.url !== "string" || t.http.url.trim() === "") {
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
}
const rawMethod = t.http.method ?? httpDefaults?.method ?? "GET";
if (typeof rawMethod !== "string") {
throw new Error(`target "${t.name}" 的 http.method 必须为字符串`);
}
const method = rawMethod.toUpperCase();
if (!ALLOWED_METHODS.has(method)) {
throw new Error(
`target "${t.name}" 的 http.method "${method}" 不合法,合法值: ${[...ALLOWED_METHODS].join(", ")}`,
);
}
if (!URL_RE.test(t.http.url)) {
throw new Error(`target "${t.name}" 的 http.url "${t.http.url}" 格式不合法,必须以 http:// 或 https:// 开头`);
}
if (t.http.ignoreSSL !== undefined && typeof t.http.ignoreSSL !== "boolean") {
throw new Error(`target "${t.name}" 的 http.ignoreSSL 必须为布尔值`);
}
if (
t.http.maxRedirects !== undefined &&
(typeof t.http.maxRedirects !== "number" || !Number.isInteger(t.http.maxRedirects) || t.http.maxRedirects < 0)
) {
throw new Error(`target "${t.name}" 的 http.maxRedirects 必须为非负整数`);
}
const statusPatterns = target.expect && "status" in target.expect ? target.expect.status : undefined;
if (statusPatterns) {
if (!Array.isArray(statusPatterns)) {
throw new Error(`target "${t.name}" 的 expect.status 必须为数组`);
}
for (const p of statusPatterns) {
if (typeof p !== "number" && typeof p !== "string") {
throw new Error(`target "${t.name}" 的 expect.status 只能包含数字或范围模式字符串`);
}
if (typeof p === "string" && !STATUS_RANGE_RE.test(p)) {
throw new Error(
`target "${t.name}" 的 expect.status 模式 "${p}" 不合法,字符串必须为 "Nxx" 格式(如 "2xx"`,
);
}
}
}
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
return {
@@ -105,8 +164,10 @@ export class HttpChecker implements Checker {
http: {
body: t.http.body,
headers: { ...(httpDefaults?.headers ?? {}), ...(t.http.headers ?? {}) },
ignoreSSL: t.http.ignoreSSL ?? false,
maxBodyBytes,
method: t.http.method ?? httpDefaults?.method ?? "GET",
maxRedirects: t.http.maxRedirects ?? 0,
method,
url: t.http.url,
},
intervalMs: context.defaultIntervalMs,
@@ -122,7 +183,9 @@ export class HttpChecker implements Checker {
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,
}),
@@ -130,3 +193,28 @@ export class HttpChecker implements Checker {
};
}
}
function buildRedirectInit(init: RequestInit, statusCode: number): RequestInit {
const method = init.method?.toUpperCase();
if (statusCode === 303 || ((statusCode === 301 || statusCode === 302) && method === "POST")) {
return { ...init, body: undefined, method: "GET" };
}
return init;
}
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;
currentUrl = new URL(location, currentUrl).toString();
currentInit = buildRedirectInit(currentInit, response.status);
}
}