UNPKG

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
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(); } }