counterfact
Version:
Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.
415 lines (414 loc) • 18 kB
JavaScript
import repl from "node:repl";
import { sendTelemetry } from "../cli/telemetry.js";
import { RawHttpClient } from "./raw-http-client.js";
import { createRouteFunction } from "./route-builder.js";
function printToStdout(line) {
process.stdout.write(`${line}\n`);
}
const ROUTE_BUILDER_METHODS = [
"body(",
"headers(",
"help(",
"method(",
"missing(",
"path(",
"query(",
"ready(",
"send(",
];
function getScenarioCompletions(line, scenarioRegistry, groupedScenarioRegistries) {
function getPathCompletions(partial, registry) {
if (registry === undefined) {
return [[], partial];
}
const slashIdx = partial.lastIndexOf("/");
if (slashIdx === -1) {
const indexFunctions = registry.getExportedFunctionNames("index");
const fileKeys = registry.getFileKeys().filter((k) => k !== "index");
const topLevelPrefixes = [
...new Set(fileKeys.map((k) => k.split("/")[0] + "/")),
];
const allOptions = [...indexFunctions, ...topLevelPrefixes];
const matches = allOptions.filter((c) => c.startsWith(partial));
return [matches, partial];
}
const fileKey = partial.slice(0, slashIdx);
const funcPartial = partial.slice(slashIdx + 1);
const functions = registry.getExportedFunctionNames(fileKey);
const matches = functions
.filter((e) => e.startsWith(funcPartial))
.map((e) => `${fileKey}/${e}`);
return [matches, partial];
}
if (groupedScenarioRegistries !== undefined) {
if (!/^\.scenario(?:\s|$)/u.test(line)) {
return undefined;
}
const hasTrailingWhitespace = /\s$/u.test(line);
const args = line.trim().split(/\s+/u).slice(1);
const groupKeys = Object.keys(groupedScenarioRegistries);
if (args.length === 0) {
return [groupKeys, ""];
}
if (args.length === 1 && !hasTrailingWhitespace) {
const groupPartial = args[0] ?? "";
const matches = groupKeys.filter((key) => key.startsWith(groupPartial));
return [matches, groupPartial];
}
const selectedGroup = args[0] ?? "";
const selectedRegistry = groupedScenarioRegistries[selectedGroup];
if (selectedRegistry === undefined) {
const scenarioPartial = hasTrailingWhitespace
? ""
: (args[args.length - 1] ?? "");
return [[], scenarioPartial];
}
if (args.length === 1 && hasTrailingWhitespace) {
return getPathCompletions("", selectedRegistry);
}
if (args.length === 2 && !hasTrailingWhitespace) {
const scenarioPartial = args[1];
if (scenarioPartial === undefined) {
return [[], ""];
}
return getPathCompletions(scenarioPartial, selectedRegistry);
}
// More than two args (or trailing whitespace after the second arg) means
// no additional `.scenario` arguments are valid in multi-API mode.
return [[], args[args.length - 1] ?? ""];
}
const applyMatch = line.match(/^\.scenario\s+(?<partial>\S*)$/u);
if (!applyMatch) {
return undefined;
}
const partial = applyMatch.groups?.["partial"] ?? "";
return getPathCompletions(partial, scenarioRegistry);
}
function getRouteBuilderMethodCompletions(line) {
const builderMatch = line.match(/route\(.*\)\.(?<partial>[a-zA-Z]*)$/u);
if (!builderMatch) {
return undefined;
}
const partial = builderMatch.groups?.["partial"] ?? "";
const matches = ROUTE_BUILDER_METHODS.filter((m) => m.startsWith(partial));
return [matches, partial];
}
function getRoutesForCompletion(registry, openApiDocument) {
const openApiPaths = openApiDocument
? Object.keys(openApiDocument.paths)
: [];
if (openApiPaths.length > 0) {
return openApiPaths;
}
return registry.routes.map((route) => route.path);
}
function getRouteCompletions(line, routes) {
const routeMatch = line.match(/(?:client\.(?:get|post|put|patch|delete)|route)\("(?<partial>[^"]*)$/u);
if (!routeMatch) {
return undefined;
}
const partial = routeMatch.groups?.["partial"] ?? "";
const matches = routes.filter((route) => route.startsWith(partial));
return [matches, partial];
}
/**
* Creates a tab-completion function for the REPL.
*
* @param registry - The route registry used to complete path arguments for `route()` and `client.*()` calls.
* @param fallback - Optional fallback completer (e.g. the Node.js built-in completer) invoked when no custom completion matches.
* @param openApiDocument - Optional OpenAPI document used as the source of route completions when available.
* @param scenarioRegistry - When provided, enables tab completion for `.scenario` commands by enumerating
* exported function names and file-key prefixes from the loaded scenario modules.
*/
export function createCompleter(registry, fallback, openApiDocument, scenarioRegistry, groupedScenarioRegistries) {
const routes = getRoutesForCompletion(registry, openApiDocument);
return (line, callback) => {
const scenarioCompletions = getScenarioCompletions(line, scenarioRegistry, groupedScenarioRegistries);
if (scenarioCompletions !== undefined) {
callback(null, scenarioCompletions);
return;
}
const routeBuilderCompletions = getRouteBuilderMethodCompletions(line);
if (routeBuilderCompletions !== undefined) {
callback(null, routeBuilderCompletions);
return;
}
const routeCompletions = getRouteCompletions(line, routes);
if (routeCompletions === undefined) {
if (fallback) {
fallback(line, callback);
}
else {
callback(null, [[], line]);
}
return;
}
callback(null, routeCompletions);
};
}
/**
* Launches the interactive Counterfact REPL.
*
* The REPL is a standard Node.js REPL augmented with:
* - `context` / `loadContext(path)` globals wired to the {@link ContextRegistry}.
* - `client` — a {@link RawHttpClient} pre-configured for `localhost`.
* - `route(path)` — creates a {@link RouteBuilder} for the given path.
* - `.counterfact` — help command.
* - `.proxy` — proxy configuration command.
* - `.scenario` — runs a named scenario function from the scenarios directory.
*
* @param contextRegistry - The live context registry.
* @param registry - The route registry (used for tab completion).
* @param config - Server configuration.
* @param print - Output function; defaults to writing to `stdout`.
* @param openApiDocument - Optional OpenAPI document for tab completion.
* @param scenarioRegistry - Optional scenario registry for `.scenario` support.
* @returns The configured Node.js REPL server instance.
*/
export function startRepl(contextRegistry, registry, config, print = printToStdout, openApiDocument, scenarioRegistry, apiBindings) {
const bindings = apiBindings === undefined || apiBindings.length === 0
? [
{
contextRegistry,
group: "",
openApiDocument,
registry,
scenarioRegistry,
},
]
: apiBindings;
const isMultiApi = bindings.length > 1;
const groupedBindings = bindings.map((binding) => ({
...binding,
key: binding.group.trim(),
}));
if (isMultiApi) {
const invalidBindings = groupedBindings.filter((binding) => binding.key === "");
if (invalidBindings.length > 0) {
throw new Error("Each API binding must define a non-empty group when multiple APIs are configured.");
}
const seenGroups = new Set();
const duplicateGroups = new Set();
for (const binding of groupedBindings) {
if (seenGroups.has(binding.key)) {
duplicateGroups.add(binding.key);
}
seenGroups.add(binding.key);
}
}
const rootBinding = groupedBindings[0];
if (rootBinding === undefined) {
throw new Error("startRepl requires at least one API binding");
}
const groupedLoadContext = Object.fromEntries(groupedBindings.map((binding) => [
binding.key,
(path) => binding.contextRegistry.find(path),
]));
const groupedRoute = Object.fromEntries(groupedBindings.map((binding) => [
binding.key,
createRouteFunction(config.port, "localhost", binding.openApiDocument),
]));
function printProxyStatus() {
if (config.proxyUrl === "") {
print("The proxy URL is not set.");
print('To set it, type ".proxy url <url>');
return;
}
print("Proxy Configuration:");
print("");
print(`The proxy URL is ${config.proxyUrl}`);
print("");
print("Paths prefixed with [+] will be proxied.");
print("Paths prefixed with [-] will not be proxied.");
print("");
const entries = [...config.proxyPaths.entries()].sort(([path1], [path2]) => path1 < path2 ? -1 : 1);
for (const [path, state] of entries) {
print(`${state ? "[+]" : "[-]"} ${path}/`);
}
}
function setProxyUrl(url) {
if (url === undefined) {
print("usage: .proxy url <url>");
return;
}
config.proxyUrl = url;
print(`proxy URL is set to ${url}`);
}
function turnProxyOnOrOff(text) {
const [command, endpoint] = text.split(" ");
const printEndpoint = endpoint === undefined || endpoint === "" ? "/" : endpoint;
config.proxyPaths.set((endpoint ?? "").replace(/\/$/u, ""), command === "on");
if (command === "on") {
print(`Requests to ${printEndpoint} will be proxied to ${config.proxyUrl || "<proxy URL>"}${printEndpoint}`);
}
if (command === "off") {
print(`Requests to ${printEndpoint} will be handled by local code`);
}
}
const replServer = repl.start({
prompt: "\x1b[38;2;0;113;181m⬣> \x1b[0m",
});
const builtinCompleter = replServer.completer;
// completer is typed as readonly in @types/node but is writable at runtime
// eslint-disable-next-line @typescript-eslint/no-explicit-any
replServer.completer = createCompleter(rootBinding.registry, builtinCompleter, rootBinding.openApiDocument, rootBinding.scenarioRegistry, isMultiApi
? Object.fromEntries(groupedBindings.map((binding) => [
binding.key,
binding.scenarioRegistry,
]))
: undefined);
replServer.defineCommand("counterfact", {
action() {
sendTelemetry("repl_command_used", { command: "counterfact" });
print("This is a read-eval-print loop (REPL), the same as the one you get when you run node with no arguments.");
print("Except that it's connected to the running server, which you can access with the following globals:");
print("");
print("- loadContext('/some/path'): to access the context object for a given path");
print("- context: the root context ( same as loadContext('/') )");
print("- route('/some/path'): create a request builder for the given path");
print("");
print("For more information, see https://github.com/counterfact/api-simulator/blob/main/docs/usage.md");
print("");
this.clearBufferedCommand();
this.displayPrompt();
},
help: "Get help with Counterfact",
});
replServer.defineCommand("proxy", {
action(text) {
sendTelemetry("repl_command_used", { command: "proxy" });
if (text === "help" || text === "") {
print(".proxy [on|off] - turn the proxy on/off at the root level");
print(".proxy [on|off] <path-prefix> - turn the proxy on for a path");
print(".proxy status - show the proxy status");
print(".proxy help - show this message");
}
else if (text.startsWith("url")) {
setProxyUrl(text.split(" ")[1]);
}
else if (text === "status") {
printProxyStatus();
}
else {
turnProxyOnOrOff(text);
}
this.clearBufferedCommand();
this.displayPrompt();
},
help: 'proxy configuration (".proxy help" for details)',
});
replServer.context.loadContext = isMultiApi
? groupedLoadContext
: groupedLoadContext[rootBinding.key];
replServer.context.context = isMultiApi
? Object.fromEntries(groupedBindings.map((binding) => [
binding.key,
binding.contextRegistry.find("/"),
]))
: rootBinding.contextRegistry.find("/");
replServer.context.client = new RawHttpClient("localhost", config.port);
replServer.context.RawHttpClient = RawHttpClient;
replServer.context.route = isMultiApi
? groupedRoute
: groupedRoute[rootBinding.key];
replServer.context.routes = isMultiApi
? Object.fromEntries(groupedBindings.map((binding) => [binding.key, {}]))
: {};
replServer.defineCommand("scenario", {
async action(text) {
sendTelemetry("repl_command_used", { command: "scenario" });
const trimmedText = text.trim();
const parsedArgs = trimmedText.split(/\s+/u).filter(Boolean);
const usage = isMultiApi
? "usage: .scenario <group> <path>"
: "usage: .scenario <path>";
const { selectedBinding, scenarioPath } = (() => {
if (!isMultiApi) {
if (trimmedText === "") {
return { scenarioPath: undefined, selectedBinding: undefined };
}
return { scenarioPath: trimmedText, selectedBinding: rootBinding };
}
if (parsedArgs.length !== 2) {
return { scenarioPath: undefined, selectedBinding: undefined };
}
return {
scenarioPath: parsedArgs[1],
selectedBinding: groupedBindings.find((binding) => binding.key === parsedArgs[0]),
};
})();
if (selectedBinding === undefined || scenarioPath === undefined) {
if (isMultiApi &&
scenarioPath !== undefined &&
selectedBinding === undefined) {
const groupName = parsedArgs[0] ?? "";
const availableGroups = groupedBindings.map((binding) => binding.key);
print(`Error: Unknown API group "${groupName}". Available groups: ${availableGroups.join(", ")}`);
}
else {
print(usage);
}
this.clearBufferedCommand();
this.displayPrompt();
return;
}
const parts = scenarioPath.split("/").filter(Boolean);
if (parts.length === 0) {
print(usage);
this.clearBufferedCommand();
this.displayPrompt();
return;
}
if (parts.some((part) => part === ".." || part === ".")) {
print("Error: Path must not contain '.' or '..' segments");
this.clearBufferedCommand();
this.displayPrompt();
return;
}
const functionName = parts[parts.length - 1] ?? "";
const fileKey = parts.length === 1 ? "index" : parts.slice(0, -1).join("/");
const module = selectedBinding.scenarioRegistry?.getModule(fileKey);
if (module === undefined) {
print(`Error: Could not find scenario file "${fileKey}"`);
this.clearBufferedCommand();
this.displayPrompt();
return;
}
const fn = module[functionName];
if (typeof fn !== "function") {
print(`Error: "${functionName}" is not a function exported from "${fileKey}"`);
this.clearBufferedCommand();
this.displayPrompt();
return;
}
try {
const selectedRoutes = isMultiApi
? replServer.context["routes"][selectedBinding.key]
: replServer.context["routes"];
if (isMultiApi && selectedRoutes === undefined) {
print(`Error: Could not resolve routes for API group "${selectedBinding.key}"`);
this.clearBufferedCommand();
this.displayPrompt();
return;
}
const applyContext = {
context: selectedBinding.contextRegistry.find("/"),
loadContext: ((path) => selectedBinding.contextRegistry.find(path)),
route: groupedRoute[selectedBinding.key],
routes: selectedRoutes,
};
await fn(applyContext);
print(`Applied ${text.trim()}`);
}
catch (error) {
print(`Error: ${String(error)}`);
}
this.clearBufferedCommand();
this.displayPrompt();
},
help: isMultiApi
? 'apply a scenario script (".scenario <group> <path>" calls the named export from that group\'s scenarios/)'
: 'apply a scenario script (".scenario <path>" calls the named export from scenarios/)',
});
return replServer;
}