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 { readonly configKey = "http"; readonly schemas = httpCheckerSchemas; readonly type = "http"; buildDetail(observation: Record): null | string { const statusCode = observation["statusCode"]; return typeof statusCode === "number" ? `HTTP ${statusCode}` : null; } async execute(t: ResolvedHttpTarget, ctx: CheckerContext): Promise { 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; 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) } : 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) }; 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 { 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, 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 = { 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, maxCount = 20): Record { const entries = Object.entries(headers); if (entries.length <= maxCount) return headers; return Object.fromEntries(entries.slice(0, maxCount)); }