Process and API-only Playwright harness: binary and image setup, server/CLI runners, fake Docker Engine socket, read-only database inspection, secret redaction in reports, seeded fuzzing, leak detection and harness self-checks.
526 lines
20 KiB
TypeScript
526 lines
20 KiB
TypeScript
// 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<string, string>;
|
|
/** 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 = unknown>(): T;
|
|
durationMs: number;
|
|
}
|
|
|
|
export function bearer(token: string): Record<string, string> {
|
|
return { Authorization: `Bearer ${token}` };
|
|
}
|
|
|
|
function headersRecord(arr: HeaderEntry[]): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
for (const { name, value } of arr) {
|
|
const k = name.toLowerCase();
|
|
out[k] = k in out ? `${out[k]}, ${value}` : value;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function withJson<T extends { body: string }>(r: T): T & { json<J = unknown>(): J } {
|
|
return Object.assign(r, {
|
|
json<J = unknown>(): 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<string | Buffer>;
|
|
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<RawResult> {
|
|
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<RawResult>((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 <method> (use head: true for HEAD so curl does not wait for a body). */
|
|
method?: string;
|
|
/** Raw header lines, repeatable, passed as `-H <line>` verbatim ('Name: value', 'Name;' = empty value, 'Name:' = remove). */
|
|
headers?: string[];
|
|
/** Bearer token shortcut: adds `-H 'Authorization: Bearer <token>'`. */
|
|
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<string, string>;
|
|
headersArray: HeaderEntry[];
|
|
body: string;
|
|
bodyBuffer: Buffer;
|
|
json<T = unknown>(): T;
|
|
stderr: string;
|
|
/** curl's own -w %{json} metadata (time_total etc.). */
|
|
writeOut: Record<string, unknown>;
|
|
durationMs: number;
|
|
}
|
|
|
|
let curlSeq = 0;
|
|
|
|
export async function runCurl(ws: Workspace, opts: CurlOptions): Promise<CurlResult> {
|
|
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<string, unknown> = {};
|
|
try {
|
|
writeOut = JSON.parse(r.stdout) as Record<string, unknown>;
|
|
} 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<string, string>;
|
|
/** Bearer token shortcut (adds Authorization: Bearer <token>). */
|
|
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<HttpResult> {
|
|
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<HttpRequestOptions, 'method'> = {}): Promise<HttpResult> {
|
|
return this.fetch(url, { ...opts, method: 'GET' });
|
|
}
|
|
|
|
/** Real curl process; see CurlOptions. */
|
|
async curl(opts: CurlOptions): Promise<CurlResult> {
|
|
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<RawResult> {
|
|
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<string | [string, string]>; 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) };
|
|
}
|