// netcheck's HTTP check — the module's own code, in TypeScript (novox/hq ADR 0039, ADR 0188). One // request, HEAD or GET, never a body sent and never a body read: the status, how long it took and // a few headers that say what answered. Redirects are reported, not followed, so a check reaches // exactly the address it was given. export const METHODS = ["HEAD", "GET"] as const; export type Method = (typeof METHODS)[number]; /** The headers worth reporting: what answered and what it says it is, nothing it set for a client. */ export const REPORTED_HEADERS = [ "content-type", "content-length", "server", "location", "date", "cache-control", "last-modified", "etag", ] as const; export const DEFAULT_TIMEOUT_MS = 5000; export const MOST_TIMEOUT_MS = 30000; export interface HttpResult { url: string; method: Method; status?: number; statusText?: string; elapsed_ms: number; headers: Record; error?: string; } /** Only http and https are checked; anything else — file:, data:, ftp: — is refused by name. */ export function checkedUrl(raw: unknown): URL { const text = typeof raw === "string" ? raw.trim() : ""; if (!text) throw new Error("url is required"); let url: URL; try { url = new URL(text); } catch { throw new Error(`${JSON.stringify(text)} is not a URL`); } if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error(`netcheck_http checks http and https URLs only, not ${url.protocol}`); } return url; } export function checkedMethod(raw: unknown): Method { const m = (typeof raw === "string" && raw.trim() ? raw.trim() : "HEAD").toUpperCase(); if (!(METHODS as readonly string[]).includes(m)) { throw new Error(`method ${m} is not one netcheck_http uses (${METHODS.join(", ")}): a check never changes anything`); } return m as Method; } export function checkedTimeout(raw: unknown): number { if (raw === undefined || raw === null || raw === "") return DEFAULT_TIMEOUT_MS; const n = Number(raw); if (!Number.isInteger(n) || n < 1) throw new Error(`timeout_ms must be a whole number of at least 1, not ${String(raw)}`); return Math.min(n, MOST_TIMEOUT_MS); } /** Make one request and report how it went. A refused connection or a timeout is a result with its * error; only a malformed question throws. */ export async function checkHttp(args: Readonly>, fetcher: typeof fetch = fetch): Promise { const url = checkedUrl(args.url); const method = checkedMethod(args.method); const timeout = checkedTimeout(args.timeout_ms); const started = performance.now(); const out: HttpResult = { url: url.toString(), method, elapsed_ms: 0, headers: {} }; try { const res = await fetcher(url, { method, redirect: "manual", signal: AbortSignal.timeout(timeout) }); out.elapsed_ms = Math.round(performance.now() - started); out.status = res.status; out.statusText = res.statusText; for (const h of REPORTED_HEADERS) { const v = res.headers.get(h); if (v !== null) out.headers[h] = v; } // The body is not read: a check asks whether something answers, not what it says. await res.body?.cancel().catch(() => {}); } catch (err) { out.elapsed_ms = Math.round(performance.now() - started); const e = err as Error & { cause?: { message?: string; code?: string } }; out.error = e.name === "TimeoutError" ? `no answer within ${timeout} ms` : (e.cause?.code ?? e.cause?.message ?? e.message); } return out; }