counterfact
Version:
Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.
192 lines (191 loc) • 7.63 kB
JavaScript
import net from "net";
const colors = {
reset: "\x1b[0m",
dim: "\x1b[2m",
cyan: "\x1b[36m",
green: "\x1b[32m",
yellow: "\x1b[33m",
red: "\x1b[31m",
bold: "\x1b[1m",
magenta: "\x1b[35m",
blue: "\x1b[34m",
};
function isLikelyJson(headersBlock, body) {
const m = headersBlock.match(/^content-type:\s*(?<contentType>[^\r\n;]+)/im);
const ct = (m?.groups?.["contentType"] ?? "").toLowerCase();
if (ct.includes("application/json") || ct.includes("+json"))
return true;
const s = body.trim();
if (!s)
return false;
return ((s.startsWith("{") && s.endsWith("}")) ||
(s.startsWith("[") && s.endsWith("]")));
}
function highlightJson(text) {
let obj;
try {
obj = JSON.parse(text);
}
catch {
return text;
}
const pretty = JSON.stringify(obj, null, 2);
return pretty.replace(
// eslint-disable-next-line security/detect-unsafe-regex -- This alternation is linear: the quoted-string branch consumes either an escaped pair (`\\.`) or one non-quote/non-backslash char per iteration, so it cannot catastrophically backtrack.
/(?<str>"(?:\\.|[^"\\])*")(?<colon>\s*:)?|\b(?<boolOrNull>true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, (match, str, colon, boolOrNull) => {
if (str) {
if (colon)
return `${colors.blue}${str}${colors.reset}${colon}`;
return `${colors.green}${str}${colors.reset}`;
}
if (boolOrNull) {
return `${colors.magenta}${match}${colors.reset}`;
}
return `${colors.yellow}${match}${colors.reset}`;
});
}
function stringifyBody(body) {
if (typeof body === "string") {
return body;
}
if (typeof body === "undefined") {
return body;
}
return JSON.stringify(body);
}
/**
* A minimal HTTP/1.1 client that communicates over a raw TCP socket.
*
* Used in the Counterfact REPL (`client.*`) to send requests to the local mock
* server and pretty-print the request and response to `stdout` with ANSI
* colours.
*
* Unlike `fetch` or Axios, `RawHttpClient` does not buffer or parse the
* response — the raw HTTP response string is returned from every method.
*/
export class RawHttpClient {
host;
port;
requestNumber = 0;
constructor(host = "localhost", port = 80) {
this.host = host;
this.port = port;
}
/** Sends a `GET` request and returns the raw HTTP response string. */
get(path, headers = {}) {
return this.#send("GET", path, "", headers);
}
/** Sends a `HEAD` request and returns the raw HTTP response string. */
head(path, headers = {}) {
return this.#send("HEAD", path, "", headers);
}
/** Sends a `POST` request with `body` and returns the raw HTTP response string. */
post(path, body = "", headers = {}) {
return this.#send("POST", path, body, headers);
}
/** Sends a `PUT` request with `body` and returns the raw HTTP response string. */
put(path, body = "", headers = {}) {
return this.#send("PUT", path, body, headers);
}
/** Sends a `DELETE` request and returns the raw HTTP response string. */
delete(path, headers = {}) {
return this.#send("DELETE", path, "", headers);
}
/** Sends a `CONNECT` request and returns the raw HTTP response string. */
connect(path, headers = {}) {
return this.#send("CONNECT", path, "", headers);
}
/** Sends an `OPTIONS` request and returns the raw HTTP response string. */
options(path, headers = {}) {
return this.#send("OPTIONS", path, "", headers);
}
/** Sends a `TRACE` request and returns the raw HTTP response string. */
trace(path, headers = {}) {
return this.#send("TRACE", path, "", headers);
}
/** Sends a `PATCH` request with `body` and returns the raw HTTP response string. */
patch(path, body = "", headers = {}) {
return this.#send("PATCH", path, body, headers);
}
#send(method, path, bodyAsStringOrObject, headers) {
const requestNumber = ++this.requestNumber;
const body = stringifyBody(bodyAsStringOrObject);
const effectiveHeaders = { ...headers };
if (typeof bodyAsStringOrObject === "object" &&
bodyAsStringOrObject !== null &&
!Object.keys(effectiveHeaders).some((k) => k.toLowerCase() === "content-type")) {
effectiveHeaders["Content-Type"] = "application/json";
}
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host: this.host, port: this.port }, () => {
let request = `${method} ${path} HTTP/1.1\r\n`;
request += `Host: ${this.host}\r\n`;
request += `Connection: close\r\n`;
if (body != null) {
request += `Content-Length: ${Buffer.byteLength(body)}\r\n`;
}
for (const [key, value] of Object.entries(effectiveHeaders)) {
request += `${key}: ${value}\r\n`;
}
request += `\r\n`;
if (body != null)
request += body;
this.#printRequest(request, requestNumber);
socket.write(request);
});
const chunks = [];
socket.on("data", (chunk) => {
chunks.push(chunk);
});
socket.on("end", () => {
const raw = Buffer.concat(chunks).toString("utf8");
this.#printResponse(raw, requestNumber);
process.stdout.write(`${colors.bold}⬣> ${colors.reset}`);
resolve(raw);
});
socket.on("error", reject);
});
}
#printRequest(raw, requestNumber) {
const [head = "", body = ""] = raw.split("\r\n\r\n");
const lines = head.split("\r\n");
process.stdout.write(`${colors.cyan}\n----- REQUEST #${requestNumber} -----${colors.reset}\n`);
process.stdout.write(`${colors.cyan}${lines[0]}${colors.reset}\n`);
for (const line of lines.slice(1)) {
process.stdout.write(`${colors.dim}${line}${colors.reset}\n`);
}
if (body) {
process.stdout.write("\n");
const outBody = isLikelyJson(head.toLowerCase(), body)
? highlightJson(body)
: body;
process.stdout.write(outBody + "\n");
}
}
#printResponse(raw, requestNumber) {
const [head = "", body = ""] = raw.split("\r\n\r\n");
const lines = head.split("\r\n");
const statusLine = lines[0] ?? "";
let statusColor = colors.green;
const match = statusLine.match(/HTTP\/\d+\.\d+\s+(?<statusCode>\d+)/);
if (match) {
const code = Number(match.groups?.["statusCode"]);
if (code >= 400)
statusColor = colors.red;
else if (code >= 300)
statusColor = colors.yellow;
}
process.stdout.write(`\n${statusColor}----- RESPONSE #${requestNumber} -----${colors.reset}\n`);
process.stdout.write(`${statusColor}${statusLine}${colors.reset}\n`);
for (const line of lines.slice(1)) {
process.stdout.write(`${colors.dim}${line}${colors.reset}\n`);
}
if (body) {
process.stdout.write("\n");
const outBody = isLikelyJson(head.toLowerCase(), body)
? highlightJson(body)
: body;
process.stdout.write(outBody + "\n");
}
}
}