feat: HTTP 探针增强 — ignoreSSL、精确重定向控制、状态码范围匹配、编码自动检测
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user