// SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2026 Bryan Joshua Pedini /** * HTTP clients: Playwright APIRequestContext, real curl, and a raw TCP socket. */ import fs from 'node:fs'; import net from 'node:net'; import type { APIRequestContext } from '@playwright/test'; import { HarnessError } from './errors'; import { runProcess, toolEnv } from './proc'; import type { Recorder } from './recorder'; import type { Workspace } from './workspace'; // --------------------------------------------------------------------------------------------- // Shared types // --------------------------------------------------------------------------------------------- export interface HeaderEntry { name: string; value: string; } export interface HttpResult { client: 'playwright' | 'curl'; method: string; url: string; status: number; statusText: string; /** Lower-cased header names; repeated headers joined with ', '. */ headers: Record; /** Every header line in order, names as received. */ headersArray: HeaderEntry[]; body: string; bodyBuffer: Buffer; /** JSON.parse(body) (throws when the body is not JSON). */ json(): T; durationMs: number; } export function bearer(token: string): Record { return { Authorization: `Bearer ${token}` }; } function headersRecord(arr: HeaderEntry[]): Record { const out: Record = {}; for (const { name, value } of arr) { const k = name.toLowerCase(); out[k] = k in out ? `${out[k]}, ${value}` : value; } return out; } function withJson(r: T): T & { json(): J } { return Object.assign(r, { json(): J { return JSON.parse(r.body) as J; }, }); } // --------------------------------------------------------------------------------------------- // Raw socket // --------------------------------------------------------------------------------------------- export interface RawHttpResponse { httpVersion: string; status: number; statusText: string; headers: Array<[string, string]>; /** First value of a header (case-insensitive). */ header(name: string): string | undefined; /** Every value of a header (case-insensitive). */ headerAll(name: string): string[]; body: Buffer; bodyText: string; } /** * - response: at least one complete response was parsed; * - closed: the server closed the connection without a complete response; * - reset: the connection was reset (ECONNRESET/EPIPE) without a complete response; * - refused: nothing listening (ECONNREFUSED); * - timeout: only returned when allowTimeout is set (otherwise HarnessError). */ export type RawOutcome = 'response' | 'closed' | 'reset' | 'refused' | 'timeout'; export interface RawResult { outcome: RawOutcome; responses: RawHttpResponse[]; /** responses[0] */ response?: RawHttpResponse; /** Every byte received. */ raw: Buffer; /** True when the server closed the connection (FIN) before the harness did. */ closedByServer: boolean; error?: string; durationMs: number; } export interface RawHttpOptions { host: string; port: number; /** Bytes to send. An array is sent as separate writes `chunkDelayMs` apart. */ data: string | Buffer | Array; chunkDelayMs?: number; /** Default 10 s. */ timeoutMs?: number; /** Resolve once this many complete responses were parsed (default 1). */ expectResponses?: number; /** Keep reading until the server closes (default false). */ waitForClose?: boolean; /** Half-close (FIN) after writing (default false). */ endAfterWrite?: boolean; /** Return outcome 'timeout' instead of throwing HarnessError. */ allowTimeout?: boolean; /** Treat responses as bodiless (HEAD). Default: data starts with "HEAD ". */ head?: boolean; } /** Parses as many complete HTTP/1.x responses as `buf` contains. `eof` completes a read-until-close body. */ export function parseHttpResponses(buf: Buffer, eof: boolean, head = false): { responses: RawHttpResponse[]; rest: Buffer } { const responses: RawHttpResponse[] = []; let off = 0; while (off < buf.length) { const hdrEnd = buf.indexOf('\r\n\r\n', off); if (hdrEnd < 0) break; const headText = buf.subarray(off, hdrEnd).toString('latin1'); const [statusLine, ...headerLines] = headText.split('\r\n'); const m = /^(HTTP\/\d(?:\.\d)?) (\d{3})(?: (.*))?$/.exec(statusLine); if (!m) throw new HarnessError('protocol', `unparseable status line ${JSON.stringify(statusLine)}`); const headers: Array<[string, string]> = headerLines.map((l) => { const i = l.indexOf(':'); if (i < 0) throw new HarnessError('protocol', `unparseable header line ${JSON.stringify(l)}`); return [l.slice(0, i), l.slice(i + 1).trim()]; }); const get = (n: string) => headers.filter(([k]) => k.toLowerCase() === n.toLowerCase()).map(([, v]) => v); const status = Number(m[2]); let bodyStart = hdrEnd + 4; let body: Buffer; let next: number; const noBody = (head && responses.length === 0) || (status >= 100 && status < 200) || status === 204 || status === 304; if (noBody) { body = Buffer.alloc(0); next = bodyStart; } else if (get('transfer-encoding').some((v) => v.toLowerCase().includes('chunked'))) { const chunks: Buffer[] = []; let p = bodyStart; let done = false; while (true) { const le = buf.indexOf('\r\n', p); if (le < 0) break; const size = parseInt(buf.subarray(p, le).toString('latin1').split(';')[0], 16); if (Number.isNaN(size)) throw new HarnessError('protocol', 'bad chunk size'); if (size === 0) { const end = buf.indexOf('\r\n\r\n', le); if (end < 0 && buf.length < le + 4) break; p = end < 0 ? le + 4 : end + 4; done = true; break; } if (buf.length < le + 2 + size + 2) break; chunks.push(buf.subarray(le + 2, le + 2 + size)); p = le + 2 + size + 2; } if (!done) break; body = Buffer.concat(chunks); next = p; } else if (get('content-length').length > 0) { const len = Number(get('content-length')[0]); if (buf.length < bodyStart + len) break; body = buf.subarray(bodyStart, bodyStart + len); next = bodyStart + len; } else { if (!eof) break; body = buf.subarray(bodyStart); next = buf.length; } responses.push({ httpVersion: m[1], status, statusText: m[3] ?? '', headers, header: (n) => get(n)[0], headerAll: (n) => get(n), body: Buffer.from(body), bodyText: body.toString('utf8'), }); off = next; bodyStart = next; } return { responses, rest: buf.subarray(off) }; } /** Sends raw bytes over TCP and parses the HTTP response(s). */ export function rawHttp(opts: RawHttpOptions): Promise { const timeoutMs = opts.timeoutMs ?? 10_000; const expectResponses = opts.expectResponses ?? 1; const chunks = (Array.isArray(opts.data) ? opts.data : [opts.data]).map((c) => (typeof c === 'string' ? Buffer.from(c, 'latin1') : c)); const head = opts.head ?? chunks[0]?.subarray(0, 5).toString('latin1') === 'HEAD '; const started = performance.now(); return new Promise((resolve, reject) => { const received: Buffer[] = []; let closedByServer = false; let settled = false; const sock = net.connect({ host: opts.host, port: opts.port }); const finish = (outcome: RawOutcome | 'auto', error?: string) => { if (settled) return; settled = true; clearTimeout(timer); sock.destroy(); const raw = Buffer.concat(received); let responses: RawHttpResponse[] = []; try { responses = parseHttpResponses(raw, closedByServer || outcome !== 'auto', head).responses; } catch (e) { reject(e); return; } let o: RawOutcome = outcome === 'auto' ? 'response' : outcome; if (responses.length > 0 && (o === 'closed' || o === 'reset')) o = 'response'; if (o === 'response' && responses.length === 0) o = 'closed'; const durationMs = performance.now() - started; if (o === 'timeout' && !opts.allowTimeout) { reject( new HarnessError('timeout', `raw HTTP to ${opts.host}:${opts.port}: no complete response within ${timeoutMs} ms (received ${raw.length} bytes: ${JSON.stringify(raw.subarray(0, 300).toString('latin1'))})`), ); return; } resolve({ outcome: o, responses, response: responses[0], raw, closedByServer, error, durationMs }); }; const timer = setTimeout(() => finish('timeout'), timeoutMs); sock.on('connect', async () => { try { for (let i = 0; i < chunks.length; i++) { if (i > 0 && opts.chunkDelayMs) await new Promise((r) => setTimeout(r, opts.chunkDelayMs)); if (sock.destroyed) return; sock.write(chunks[i]); } if (opts.endAfterWrite) sock.end(); } catch { /* surfaced via 'error' */ } }); sock.on('data', (d: Buffer) => { received.push(d); if (opts.waitForClose) return; try { const { responses } = parseHttpResponses(Buffer.concat(received), false, head); if (responses.length >= expectResponses) finish('auto'); } catch (e) { settled = true; clearTimeout(timer); sock.destroy(); reject(e); } }); sock.on('end', () => { closedByServer = true; finish('closed'); }); sock.on('error', (e: NodeJS.ErrnoException) => { if (e.code === 'ECONNREFUSED') finish('refused', e.code); else if (e.code === 'ECONNRESET' || e.code === 'EPIPE') { closedByServer = true; finish('reset', e.code); } else { if (settled) return; settled = true; clearTimeout(timer); reject(new HarnessError('transport', `raw socket error to ${opts.host}:${opts.port}: ${e.message}`)); } }); }); } // --------------------------------------------------------------------------------------------- // curl // --------------------------------------------------------------------------------------------- export interface CurlOptions { url: string; /** -X (use head: true for HEAD so curl does not wait for a body). */ method?: string; /** Raw header lines, repeatable, passed as `-H ` verbatim ('Name: value', 'Name;' = empty value, 'Name:' = remove). */ headers?: string[]; /** Bearer token shortcut: adds `-H 'Authorization: Bearer '`. */ token?: string; /** Request body via --data-binary @file (curl then defaults to POST unless method is set). */ data?: string | Buffer; /** --path-as-is: send /./ and /../ segments unmodified. */ pathAsIs?: boolean; /** -I (HEAD request). */ head?: boolean; /** Default '1.1'. */ httpVersion?: '1.0' | '1.1'; /** curl --max-time in ms (default 15 s); exceeding it is a HarnessError unless allowTimeout. */ timeoutMs?: number; allowTimeout?: boolean; /** Extra raw arguments appended before the URL. */ extraArgs?: string[]; } export interface CurlResult { client: 'curl'; /** curl argv (body/header file paths are harness temp files). */ argv: string[]; /** curl's exit code (0 ok, 7 refused, 52 empty reply, 56 recv failure, ...). */ exitCode: number; /** HTTP status (0 when no response). */ status: number; httpVersion: string; headers: Record; headersArray: HeaderEntry[]; body: string; bodyBuffer: Buffer; json(): T; stderr: string; /** curl's own -w %{json} metadata (time_total etc.). */ writeOut: Record; durationMs: number; } let curlSeq = 0; export async function runCurl(ws: Workspace, opts: CurlOptions): Promise { const n = ++curlSeq; const bodyFile = ws.path(`.curl-${n}.body`); const headFile = ws.path(`.curl-${n}.headers`); const dataFile = ws.path(`.curl-${n}.data`); const maxTimeMs = opts.timeoutMs ?? 15_000; const argv = ['-sS', '--max-time', (maxTimeMs / 1000).toFixed(3), '-o', bodyFile, '-D', headFile, '-w', '%{json}']; argv.push(opts.httpVersion === '1.0' ? '--http1.0' : '--http1.1'); if (opts.head) argv.push('-I'); if (opts.method) argv.push('-X', opts.method); if (opts.token !== undefined) argv.push('-H', `Authorization: Bearer ${opts.token}`); for (const h of opts.headers ?? []) argv.push('-H', h); if (opts.data !== undefined) { fs.writeFileSync(dataFile, opts.data); argv.push('--data-binary', `@${dataFile}`); } if (opts.pathAsIs) argv.push('--path-as-is'); argv.push(...(opts.extraArgs ?? []), opts.url); try { const r = await runProcess('curl', argv, { env: toolEnv(), timeoutMs: maxTimeMs + 10_000, label: 'curl' }); let writeOut: Record = {}; try { writeOut = JSON.parse(r.stdout) as Record; } catch { throw new HarnessError('protocol', `curl -w %{json} output unparseable: ${JSON.stringify(r.stdout.slice(0, 300))}`); } if (r.exitCode === 28 && !opts.allowTimeout) { throw new HarnessError('timeout', `curl ${opts.url}: no response within ${maxTimeMs} ms (${r.stderr.trim()})`); } const bodyBuffer = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile) : Buffer.alloc(0); const headText = fs.existsSync(headFile) ? fs.readFileSync(headFile, 'latin1') : ''; // -D may contain several blocks (1xx, redirects); keep the last const blocks = headText.split(/\r\n\r\n/).filter((b) => b.trim() !== ''); const last = blocks[blocks.length - 1] ?? ''; const [statusLine, ...lines] = last.split('\r\n'); const sm = /^(HTTP\/[\d.]+) (\d{3})/.exec(statusLine ?? ''); const headersArray = lines.filter((l) => l.includes(':')).map((l) => ({ name: l.slice(0, l.indexOf(':')), value: l.slice(l.indexOf(':') + 1).trim() })); // with -I curl writes the header block to the output file: a HEAD response has no body const realBody = opts.head ? Buffer.alloc(0) : bodyBuffer; const body = realBody.toString('utf8'); return withJson({ client: 'curl' as const, argv, exitCode: r.exitCode ?? -1, status: Number(writeOut.http_code ?? (sm ? sm[2] : 0)), httpVersion: sm?.[1] ?? '', headers: headersRecord(headersArray), headersArray, body, bodyBuffer: realBody, stderr: r.stderr, writeOut, durationMs: r.durationMs, }); } finally { for (const f of [bodyFile, headFile, dataFile]) fs.rmSync(f, { force: true }); } } // --------------------------------------------------------------------------------------------- // Client facade (bound to a test by the `http` fixture) // --------------------------------------------------------------------------------------------- export interface HttpRequestOptions { method?: string; headers?: Record; /** Bearer token shortcut (adds Authorization: Bearer ). */ token?: string; data?: string | Buffer; /** Default 30 s. Exceeding it is a HarnessError. */ timeoutMs?: number; } export class HttpClient { constructor( private readonly request: APIRequestContext, private readonly ws: Workspace, private readonly recorder: Recorder, ) {} /** * Playwright APIRequestContext request (no redirects followed, never throws on status). * Transport failures (refused, reset, timeout) throw HarnessError('transport'/'timeout'). */ async fetch(url: string, opts: HttpRequestOptions = {}): Promise { const method = opts.method ?? 'GET'; const headers = { ...(opts.token !== undefined ? bearer(opts.token) : {}), ...(opts.headers ?? {}) }; const started = performance.now(); const base = { client: 'playwright' as const, method, url, requestHeaders: Object.entries(headers) as Array<[string, string]>, requestBodyBytes: opts.data === undefined ? 0 : Buffer.byteLength(opts.data), }; let res; try { res = await this.request.fetch(url, { method, headers, data: opts.data, timeout: opts.timeoutMs ?? 30_000, maxRedirects: 0, failOnStatusCode: false }); } catch (e) { const msg = (e as Error).message; this.recorder.addHttp({ ...base, durationMs: performance.now() - started, outcome: msg }); if (/Timeout \d+ms exceeded|timed out/i.test(msg)) throw new HarnessError('timeout', `${method} ${url}: ${msg}`); throw new HarnessError('transport', `${method} ${url}: ${msg}`); } const bodyBuffer = await res.body(); const durationMs = performance.now() - started; const headersArray = res.headersArray(); const result = withJson({ client: 'playwright' as const, method, url, status: res.status(), statusText: res.statusText(), headers: headersRecord(headersArray), headersArray, body: bodyBuffer.toString('utf8'), bodyBuffer, durationMs, }); await res.dispose(); this.recorder.addHttp({ ...base, status: result.status, responseHeaders: headersArray.map((h) => [h.name, h.value]), bodyPreview: result.body, durationMs, }); return result; } get(url: string, opts: Omit = {}): Promise { return this.fetch(url, { ...opts, method: 'GET' }); } /** Real curl process; see CurlOptions. */ async curl(opts: CurlOptions): Promise { const started = performance.now(); try { const r = await runCurl(this.ws, opts); this.recorder.addHttp({ client: 'curl', method: opts.head ? 'HEAD' : (opts.method ?? (opts.data !== undefined ? 'POST' : 'GET')), url: opts.url, requestHeaders: [...(opts.token !== undefined ? [['Authorization', `Bearer ${opts.token}`] as [string, string]] : []), ...(opts.headers ?? []).map((h) => [h, ''] as [string, string])], requestBodyBytes: opts.data === undefined ? 0 : Buffer.byteLength(opts.data), status: r.status, responseHeaders: r.headersArray.map((h) => [h.name, h.value]), bodyPreview: r.body, durationMs: r.durationMs, curlExitCode: r.exitCode, outcome: r.exitCode === 0 ? undefined : r.stderr.trim(), }); return r; } catch (e) { this.recorder.addHttp({ client: 'curl', method: opts.method ?? 'GET', url: opts.url, requestHeaders: [], requestBodyBytes: 0, durationMs: performance.now() - started, outcome: (e as Error).message }); throw e; } } /** Raw TCP request; see RawHttpOptions. */ async raw(opts: RawHttpOptions): Promise { const first = (Array.isArray(opts.data) ? opts.data.map((c) => c.toString()).join('') : opts.data.toString()).split('\r\n')[0] ?? ''; const started = performance.now(); try { const r = await rawHttp(opts); this.recorder.addHttp({ client: 'raw', method: first.split(' ')[0] ?? '', url: `tcp://${opts.host}:${opts.port} ${first}`, requestHeaders: [], requestBodyBytes: Array.isArray(opts.data) ? opts.data.reduce((n, c) => n + Buffer.byteLength(c), 0) : Buffer.byteLength(opts.data), status: r.response?.status, responseHeaders: r.response?.headers, bodyPreview: r.response?.bodyText, durationMs: r.durationMs, outcome: r.outcome, }); return r; } catch (e) { this.recorder.addHttp({ client: 'raw', method: '', url: `tcp://${opts.host}:${opts.port} ${first}`, requestHeaders: [], requestBodyBytes: 0, durationMs: performance.now() - started, outcome: (e as Error).message }); throw e; } } } /** Builds a raw HTTP/1.1 request. Header lines are written verbatim (duplicates and odd spacing allowed). */ export function buildRawRequest(opts: { method?: string; target: string; headers?: Array; body?: string; host?: string; version?: string; close?: boolean }): string { const lines = [`${opts.method ?? 'GET'} ${opts.target} ${opts.version ?? 'HTTP/1.1'}`]; lines.push(`Host: ${opts.host ?? 'localhost'}`); for (const h of opts.headers ?? []) lines.push(typeof h === 'string' ? h : `${h[0]}: ${h[1]}`); if (opts.body !== undefined) lines.push(`Content-Length: ${Buffer.byteLength(opts.body)}`); if (opts.close ?? true) lines.push('Connection: close'); return lines.join('\r\n') + '\r\n\r\n' + (opts.body ?? ''); } /** host:port of a base URL like http://127.0.0.1:1234 or http://[::1]:1234. */ export function hostPortOf(baseURL: string): { host: string; port: number } { const u = new URL(baseURL); return { host: u.hostname.replace(/^\[|\]$/g, ''), port: Number(u.port) }; }