clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / test / http.ts
http.ts 4.5K
1// An HTTP client for scenarios, so they don't need curl (the forge's2// container has none): the few curl options they use, with curl's output3// and exit codes, over Node's own http client. test/lib.sh calls it `http`.4//5// node test/http.ts [options] <url>6// -o <file> the body to file instead of stdout (- is stdout)7// -D <file> the status line and headers to file (- is stdout)8// -i the status line and headers before the body9// -I a HEAD request, its status line and headers10// -w <format> then format, with %{http_code} and %{redirect_url}11// -H 'Name: value' a request header (more than one: repeat it)12// -X <method> the request's method13// -d <data> a POST body, form-encoded unless -H says otherwise; @file reads it from file14// -u user:password basic auth15// --max-time <s> give up after that long (exit 28, as curl)16// -s, -N accepted, and what it does anyway: no progress, no buffering17// Flags without a value may share a dash (-sN).18// Exit 0 for any response, 7 when nothing answers, 28 on --max-time.19import fs from 'node:fs';20import http from 'node:http';2122const args = process.argv.slice(2).flatMap((a) => (/^-[sNiI]{2,}$/.test(a) ? [...a.slice(1)].map((c) => `-${c}`) : [a]));23let url = '';24let out = '-';25let dump = '';26let include = false;27let head = false;28let format = '';29let method = '';30let data: string | undefined;31let maxTime = 0;32const headers: [string, string][] = [];33for (let i = 0; i < args.length; i++) {34 const a = args[i];35 const next = (): string => {36 if (i + 1 >= args.length) usage(`${a} wants a value`);37 return args[++i];38 };39 if (a === '-s' || a === '-N') continue;40 else if (a === '-o') out = next();41 else if (a === '-D') dump = next();42 else if (a === '-i') include = true;43 else if (a === '-I') head = true;44 else if (a === '-w') format = next();45 else if (a === '-X') method = next();46 else if (a === '-d') {47 // @file: the file's contents less carriage returns and newlines, as curl48 // reads it, for bodies longer than an argument may be.49 const d = next();50 data = d.startsWith('@') ? fs.readFileSync(d.slice(1), 'utf8').replace(/[\r\n]/g, '') : d;51 }52 else if (a === '--max-time') maxTime = Number(next());53 else if (a === '-u') headers.push(['Authorization', `Basic ${Buffer.from(next()).toString('base64')}`]);54 else if (a === '-H') {55 const h = next();56 const c = h.indexOf(':');57 if (c <= 0) usage(`not a header: ${h}`);58 headers.push([h.slice(0, c).trim(), h.slice(c + 1).trim()]);59 } else if (a.startsWith('-')) usage(`unknown option ${a}`);60 else if (url) usage('one URL at a time');61 else url = a;62}63if (!url) usage('no URL');6465function usage(why: string): never {66 process.stderr.write(`test/http.ts: ${why}\n`);67 process.exit(2);68}6970const has = (name: string): boolean => headers.some(([k]) => k.toLowerCase() === name);71if (data !== undefined) {72 if (!has('content-type')) headers.push(['Content-Type', 'application/x-www-form-urlencoded']);73 headers.push(['Content-Length', String(Buffer.byteLength(data))]);74}75if (!has('host')) headers.unshift(['Host', new URL(url).host]);76if (!has('accept')) headers.push(['Accept', '*/*']);77method ||= head ? 'HEAD' : data !== undefined ? 'POST' : 'GET';7879const fd = (f: string): number => (f === '-' ? 1 : fs.openSync(f, 'w'));80const body = fd(out);81const headFd = dump ? fd(dump) : -1;82let code = 0;83let location = '';8485function finish(exit: number): never {86 if (format) {87 const w = format88 .replaceAll('%{http_code}', String(code).padStart(3, '0'))89 .replaceAll('%{redirect_url}', location ? new URL(location, url).href : '')90 .replaceAll('\\n', '\n');91 fs.writeSync(1, w);92 }93 process.exit(exit);94}9596if (maxTime > 0) setTimeout(() => finish(28), maxTime * 1000).unref();9798const req = http.request(url, { method, headers: headers.flat() }, (res) => {99 code = res.statusCode ?? 0;100 location = typeof res.headers.location === 'string' ? res.headers.location : '';101 let lines = `HTTP/${res.httpVersion} ${res.statusCode} ${res.statusMessage}\r\n`;102 for (let i = 0; i < res.rawHeaders.length; i += 2) lines += `${res.rawHeaders[i]}: ${res.rawHeaders[i + 1]}\r\n`;103 lines += '\r\n';104 if (headFd >= 0) fs.writeSync(headFd, lines);105 if (include || head) fs.writeSync(body, lines);106 res.on('data', (d: Buffer) => {107 if (!head) fs.writeSync(body, d);108 });109 res.on('end', () => finish(0));110 res.on('error', () => finish(56));111});112req.on('error', () => finish(code ? 56 : 7));113if (data !== undefined) req.write(data);114req.end();