/** * uat/cli/lib/http.ts — Measured HTTP for the UAT runners. * * One helper, `timedFetch`, wraps the platform fetch with the three metrics every * UAT axis reports — status, duration (ms), response size (bytes) — plus a parsed * JSON body when the response is JSON. It NEVER throws: connection failures and * timeouts come back as `{ ok:false, status:0, error }` so runners classify them * instead of crashing mid-run. The fetch implementation is injectable for tests. */ export type FetchLike = (url: string, init?: RequestInit) => Promise; export interface TimedFetchOptions { /** Abort the request after this many ms. Default 15000. */ timeoutMs?: number; /** Injectable fetch (tests). Default: globalThis.fetch. */ fetchImpl?: FetchLike; } export interface TimedResponse { /** True when an HTTP response was received (any status) — false only on network error/timeout. */ ok: boolean; /** HTTP status, or 0 when no response was received. */ status: number; /** Wall-clock duration of the round-trip in ms (integer). */ durationMs: number; /** Response body size in bytes (decoded buffer length; 0 when no response). */ sizeBytes: number; /** Raw body text ('' when no response or empty). */ bodyText: string; /** Parsed JSON body when the body parses as JSON, else undefined. */ json: unknown | undefined; /** Network/timeout error message when ok is false. */ error?: string; } /** Fetch a URL and measure status + duration + size. Never throws. */ export async function timedFetch( url: string, init: RequestInit = {}, opts: TimedFetchOptions = {}, ): Promise { const timeoutMs = opts.timeoutMs ?? 15000; const fetchImpl = opts.fetchImpl ?? (globalThis.fetch as FetchLike); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); const started = performance.now(); try { const res = await fetchImpl(url, { ...init, signal: controller.signal }); const buf = await res.arrayBuffer(); const durationMs = Math.round(performance.now() - started); const bodyText = new TextDecoder('utf-8').decode(buf); let json: unknown | undefined; if (bodyText.length > 0) { try { json = JSON.parse(bodyText); } catch { json = undefined; } } return { ok: true, status: res.status, durationMs, sizeBytes: buf.byteLength, bodyText, json }; } catch (e) { const durationMs = Math.round(performance.now() - started); const aborted = controller.signal.aborted; return { ok: false, status: 0, durationMs, sizeBytes: 0, bodyText: '', json: undefined, error: aborted ? `timeout after ${timeoutMs}ms` : (e as Error).message, }; } finally { clearTimeout(timer); } } /** Max characters kept from a response body when excerpting a failure. */ export const BODY_EXCERPT_MAX_LEN = 2000; const CONTROL_CHARS_RE = /[\u0000-\u0008\u000B-\u001F\u007F]/g; function capExcerpt(text: string, maxLen: number): string { const clean = text.replace(CONTROL_CHARS_RE, ' ').trim(); return clean.length > maxLen ? `${clean.slice(0, maxLen)} … [truncated]` : clean; } /** * Bounded, diagnosis-first excerpt of a response body for FAILED calls. * * When the parsed body is an RFC7807 ProblemDetails-ish object (`title`/`detail` * strings, optional `errors` record of string[]), compose the human parts; * otherwise fall back to the raw text. Control chars are scrubbed so the JSON * artifact stays clean — this is NOT an XSS defense, the HTML renderer escapes. * Returns undefined for an empty/whitespace body. */ export function bodyExcerptOf(bodyText: string, json: unknown, maxLen = BODY_EXCERPT_MAX_LEN): string | undefined { if (json !== null && typeof json === 'object' && !Array.isArray(json)) { const pd = json as { title?: unknown; detail?: unknown; errors?: unknown }; const parts: string[] = []; const head = [pd.title, pd.detail].filter((v): v is string => typeof v === 'string' && v.trim().length > 0); if (head.length > 0) parts.push(head.join(' — ')); if (pd.errors !== null && typeof pd.errors === 'object' && !Array.isArray(pd.errors)) { for (const [field, messages] of Object.entries(pd.errors as Record)) { const list = Array.isArray(messages) ? messages.filter((m): m is string => typeof m === 'string') : []; if (list.length > 0) parts.push(`${field}: ${list.join('; ')}`); } } if (parts.length > 0) return capExcerpt(parts.join('\n'), maxLen); } const fallback = capExcerpt(bodyText, maxLen); return fallback.length > 0 ? fallback : undefined; } /** JSON-POST convenience over timedFetch (Content-Type set, body stringified). */ export async function timedJsonPost( url: string, body: unknown, headers: Record = {}, opts: TimedFetchOptions = {}, ): Promise { return timedFetch( url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), }, opts, ); }