counterfact
Version:
Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.
237 lines (230 loc) • 9.28 kB
JavaScript
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import nodePath from "node:path";
/* eslint-disable security/detect-non-literal-fs-filename -- scenario files are discovered and generated under the configured destination tree. */
import { watch } from "chokidar";
import { CHOKIDAR_OPTIONS } from "../server/constants.js";
import { pathRelative } from "../util/forward-slash-path.js";
import { waitForEvent } from "../util/wait-for-event.js";
async function collectContextFiles(destination) {
const routesDir = nodePath.join(destination, "routes");
const results = [];
if (!existsSync(routesDir)) {
return results;
}
await walkForContextFiles(routesDir, routesDir, results);
results.sort((a, b) => b.depth - a.depth);
return results;
}
async function walkForContextFiles(routesDir, currentDir, results) {
let entries;
try {
entries = await fs.readdir(currentDir, { withFileTypes: true });
}
catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
await walkForContextFiles(routesDir, nodePath.join(currentDir, entry.name), results);
}
else if (entry.name === "_.context.ts") {
const relDir = pathRelative(routesDir, currentDir);
const routePath = relDir === "" ? "/" : `/${relDir}`;
const depth = relDir === "" ? 0 : relDir.split("/").length;
const importPath = relDir === "" ? "../routes/_.context" : `../routes/${relDir}/_.context`;
const alias = routePathToAlias(routePath);
results.push({ importPath, alias, routePath, depth });
}
}
}
function routePathToAlias(routePath) {
if (routePath === "/") {
return "Context";
}
return (routePath
.split("/")
.filter(Boolean)
.map((seg) => seg
.replace(/\{(.+?)\}/g, (_match, name) => name.replace(/[^a-z0-9]/gi, " "))
.replace(/[-_\s]([a-z])/g, (_match, c) => c.toUpperCase())
.replace(/^[a-z]/, (c) => c.toUpperCase())
.replace(/[^a-z0-9]/gi, ""))
.join("") + "Context");
}
const PARAM_SEGMENT_REGEX = /^\{.+\}$/u;
function buildLoadContextOverload(routePath, alias) {
if (routePath === "/") {
return ' loadContext(path: "/" | `/${string}`): ' + alias + ";";
}
const segments = routePath.split("/").filter(Boolean);
const hasParam = segments.some((seg) => PARAM_SEGMENT_REGEX.test(seg));
if (!hasParam) {
return ` loadContext(path: "${routePath}" | \`${routePath}/\${string}\`): ${alias};`;
}
const templatePath = `/${segments
.map((seg) => (PARAM_SEGMENT_REGEX.test(seg) ? "${string}" : seg))
.join("/")}`;
return ` loadContext(path: \`${templatePath}\`): ${alias};`;
}
function buildScenarioContextContent(contextFiles) {
const rootContext = contextFiles.find((f) => f.routePath === "/");
const contextType = rootContext
? rootContext.alias
: "Record<string, unknown>";
const importLines = contextFiles.map(({ importPath, alias }) => alias === "Context"
? `import type { Context } from "${importPath}";`
: `import type { Context as ${alias} } from "${importPath}";`);
const overloadLines = contextFiles.map(({ alias, routePath }) => buildLoadContextOverload(routePath, alias));
const parts = [
"// This file is generated by Counterfact. Do not edit manually.",
...importLines,
"",
"interface LoadContextDefinitions {",
" /* code generator adds additional signatures here */",
...overloadLines,
" loadContext(path: string): Record<string, unknown>;",
"}",
"",
"export interface Scenario$ {",
' /** Root context, same as loadContext("/") */',
` readonly context: ${contextType};`,
' readonly loadContext: LoadContextDefinitions["loadContext"];',
" /** Named route builders stored in the REPL execution context */",
" readonly routes: Record<string, unknown>;",
" /** Create a new route builder for a given path */",
" readonly route: (path: string) => unknown;",
"}",
"",
"/** A scenario function that receives the live REPL environment */",
"export type Scenario = ($: Scenario$) => Promise<void> | void;",
"",
"/** Interface for Context objects defined in _.context.ts files */",
"export interface Context$ {",
" /** Load a context object for a specific path */",
' readonly loadContext: LoadContextDefinitions["loadContext"];',
" /** Load a JSON file relative to this file's path */",
" readonly readJson: (relativePath: string) => Promise<unknown>;",
"}",
"",
];
return parts.join("\n");
}
/**
* Writes the `types/_.context.ts` file, which exports the
* `Scenario$` interface used to type scenario functions.
*
* The interface is generated from all `_.context.ts` files found under the
* `routes/` directory, providing strongly typed `loadContext()` overloads for
* every route path that has a context file.
*
* @param destination - Root output directory.
*/
async function writeScenarioContextType(destination) {
const typesDir = nodePath.join(destination, "types");
const filePath = nodePath.join(typesDir, "_.context.ts");
const contextFiles = await collectContextFiles(destination);
const content = buildScenarioContextContent(contextFiles);
await fs.mkdir(typesDir, { recursive: true });
await fs.writeFile(filePath, content, "utf8");
}
const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/_.context.js";
/**
* Scenario scripts are plain TypeScript functions that receive the live REPL
* environment and can read or mutate server state. Run them from the REPL with:
* .scenario <functionName>
*/
/**
* Read or mutate the root context (same object routes see as $.context):
* $.context.<property> = <value>;
*
* Load a context for a specific path:
* const petsCtx = $.loadContext("/pets");
*
* Store a pre-configured route builder for later use in the REPL:
* $.routes.myRequest = $.route("/pets").method("get");
*/
/**
* startup() runs automatically when the server initializes, right before the
* REPL starts. Use it to seed dummy data so the server is ready to use
* immediately. It receives the same $ argument as all other scenario functions.
*
* Tip: delegate to other scenario functions and pass $ along so each function
* stays focused on a single concern. You can also pass additional arguments to
* configure them, e.g. addPets($, 20, "dog").
*
* If you don't need a startup scenario, delete this function or leave it empty.
*/
export const startup: Scenario = ($) => {
void $;
};
/**
* An example scenario. To use it in the REPL, type:
* .scenario help
*/
export const help: Scenario = ($) => {
void $;
console.log(
[
"Scenarios are functions that populate the context object",
"and / or the REPL environment. They are intended to",
"populate your environment with specific data and",
"configurations for testing purposes.",
].join("\\n"),
);
console.log(
"\\nScenarios (including this one) are defined in the ./scenarios directory.",
);
};
`;
async function writeDefaultScenariosIndex(destination) {
const scenariosDir = nodePath.join(destination, "scenarios");
const filePath = nodePath.join(scenariosDir, "index.ts");
if (existsSync(filePath)) {
return;
}
await fs.mkdir(scenariosDir, { recursive: true });
await fs.writeFile(filePath, DEFAULT_SCENARIOS_INDEX, "utf8");
}
/**
* Encapsulates the generation of scenario-related files:
* - `types/_.context.ts` — the typed `Scenario$` interface derived from all
* `_.context.ts` files found under `routes/`.
* - `scenarios/index.ts` — the default scenarios entry-point (created only if
* it does not already exist).
*
* When {@link watch} is called, a file-system watcher monitors the `routes/`
* directory for changes to `_.context.ts` files and automatically regenerates
* `types/_.context.ts`.
*/
export class ScenarioFileGenerator {
destination;
watcher;
constructor(destination) {
this.destination = destination;
}
/** Generates both scenario-related files once and resolves when complete. */
async generate() {
await writeScenarioContextType(this.destination);
await writeDefaultScenariosIndex(this.destination);
}
/**
* Starts watching the `routes/` directory for `_.context.ts` changes and
* regenerates `types/_.context.ts` on every change.
*
* Resolves once the watcher is ready.
*/
async watch() {
const routesDir = nodePath.join(this.destination, "routes");
this.watcher = watch(routesDir, CHOKIDAR_OPTIONS).on("all", (_event, filePath) => {
if (filePath.endsWith("_.context.ts")) {
void writeScenarioContextType(this.destination);
}
});
await waitForEvent(this.watcher, "ready");
}
/** Closes the file-system watcher. */
async stopWatching() {
await this.watcher?.close();
}
}