UNPKG

counterfact

Version:

Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.

261 lines (258 loc) 12.5 kB
import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import nodePath, { dirname } from "node:path"; import { fileURLToPath } from "node:url"; /* eslint-disable security/detect-non-literal-fs-filename -- repository writes and stats generated files only inside destination output directories. */ import createDebug from "debug"; import { ensureDirectoryExists } from "../util/ensure-directory-exists.js"; import { toForwardSlashPath, pathJoin, pathRelative, pathDirname, } from "../util/forward-slash-path.js"; import { CONTEXT_FILE_TOKEN } from "./context-file-token.js"; import { Script } from "./script.js"; import { escapePathForWindows } from "../util/windows-escape.js"; const debug = createDebug("counterfact:server:repository"); const __dirname = toForwardSlashPath(dirname(fileURLToPath(import.meta.url))); debug("dirname is %s", __dirname); /** * Collection of {@link Script} objects keyed by their repository-relative * path. * * Coders call {@link get} to obtain (or create) the script where they should * export their generated TypeScript. After all coders have been registered, * {@link writeFiles} waits for every script to finish and writes the output to * disk. */ export class Repository { scripts; constructor() { this.scripts = new Map(); } /** * Returns the {@link Script} for `path`, creating it if it does not yet * exist. * * @param path - Repository-relative path (e.g. `"routes/pets.ts"`). */ get(path) { debug("getting script at %s", path); if (this.scripts.has(path)) { debug("already have script %s, returning it", path); return this.scripts.get(path); } debug("don't have %s, creating it", path); const script = new Script(this, path); this.scripts.set(path, script); return script; } /** Waits until all scripts have resolved all of their pending export promises. */ async finished() { while (Array.from(this.scripts.values()).some((script) => script.isInProgress())) { debug("waiting for %i scripts to finish", this.scripts.size); await Promise.all(Array.from(this.scripts.values(), (script) => script.finished())); } } /** * Copies the compiled `counterfact-types` directory from the Counterfact * distribution into the generated output tree. * * Returns `false` when the source directory does not exist (e.g. running * from source without a prior build). * * @param destination - The root of the generated output tree. */ async copyCoreFiles(destination) { const sourcePath = nodePath.join(__dirname, "../../dist/server/counterfact-types"); const destinationPath = nodePath.join(destination, "counterfact-types"); if (!existsSync(sourcePath)) { return false; } return fs.cp(sourcePath, destinationPath, { recursive: true }); } /** * Waits for all scripts to finish, then writes each one to disk. * * Route files (`routes/…`) are never fully overwritten if they already exist * on disk, preserving user edits. However, if the generated script contains * HTTP-method handler exports that are absent from the existing file, those * new exports (and their `import type` statements) are appended to the file. * Type files (`types/…`) are always overwritten. * * @param destination - Absolute path to the output root directory. * @param options - Controls which artefacts are written. */ async writeFiles(destination, { routes, types }) { debug("waiting for %i or more scripts to finish before writing files", this.scripts.size); await this.finished(); debug("all %i scripts are finished", this.scripts.size); const writeFiles = Array.from(this.scripts.entries(), async ([path, script]) => { const contents = await script.contents(); const fullPath = escapePathForWindows(pathJoin(destination, path)); await ensureDirectoryExists(fullPath); const shouldWriteRoutes = routes && path.startsWith("routes"); const shouldWriteTypes = types && !path.startsWith("routes"); if (shouldWriteRoutes) { const fileExists = await fs .stat(fullPath) .then((stat) => stat.isFile()) .catch(() => false); if (fileExists) { debug(`route file exists, checking for new handlers: ${fullPath}`); await this.appendNewHandlers(fullPath, contents.replaceAll(CONTEXT_FILE_TOKEN, this.findContextPath(destination, path))); return; } } if (shouldWriteRoutes || shouldWriteTypes) { debug("about to write", fullPath); await fs.writeFile(fullPath, contents.replaceAll(CONTEXT_FILE_TOKEN, this.findContextPath(destination, path))); debug("did write", fullPath); } }); await Promise.all(writeFiles); await this.copyCoreFiles(destination); if (routes) { await this.createDefaultContextFile(destination); } } /** * Creates the default `routes/_.context.ts` file if it does not already * exist. * * @param destination - Absolute path to the output root directory. */ async createDefaultContextFile(destination) { const contextFilePath = nodePath.join(destination, "routes", "_.context.ts"); if (existsSync(contextFilePath)) { return; } await ensureDirectoryExists(contextFilePath); await fs.writeFile(contextFilePath, `import type { Context$ } from "../types/_.context.js"; /** * This is the default context for Counterfact. * * It defines the context object in the REPL * and the $.context object in the code. * * Add properties and methods to suit your needs. * * See https://github.com/counterfact/api-simulator/blob/main/docs/features/state.md */ export class Context { constructor($: Context$) { void $; } } `); } /** * Appends any HTTP-method handler exports that appear in `generatedContent` * but are absent from the existing file at `fullPath`. * * For each new export the corresponding `import type` statement is inserted * after the last existing import line (or prepended when no imports exist), * and the export block is appended at the end of the file. * * @param fullPath - Absolute path of the route file to update. * @param generatedContent - The fully-generated file content (used as the * source of new import and export statements). */ async appendNewHandlers(fullPath, generatedContent) { const existingContent = await fs.readFile(fullPath, "utf8"); // Names already exported by the existing file (e.g. GET, POST). // RegExp match groups are typed as optional strings, so narrow defensively. const existingExportNames = new Set(Array.from(existingContent.matchAll(/^export\s+const\s+(\w+)/gmu), (m) => m[1]).filter((name) => name !== undefined)); // All named exports in the generated content together with their type names. const generatedExports = Array.from(generatedContent.matchAll(/^export\s+const\s+(\w+)\s*:\s*(\w+)/gmu), (m) => ({ methodName: m[1], typeName: m[2] })).filter((value) => value.methodName !== undefined && value.typeName !== undefined); const newExports = generatedExports.filter(({ methodName }) => !existingExportNames.has(methodName)); if (newExports.length === 0) { debug(`no new handlers to append to ${fullPath}`); return; } debug(`appending ${newExports.length} new handler(s) to ${fullPath}: %o`, newExports.map(({ methodName }) => methodName)); const newImportLines = []; const newExportBlocks = []; for (const { methodName, typeName } of newExports) { // Both names come from \w+ captures so they are safe identifiers, but // guard explicitly to satisfy static analysis and avoid RegExp injection. if (!/^\w+$/u.test(typeName) || !/^\w+$/u.test(methodName)) { debug(`skipping handler with unsafe name – methodName: %s, typeName: %s`, methodName, typeName); continue; } // Find the `import type { TypeName } from "..."` line for this type. const importMatch = generatedContent.match(new RegExp(`^import\\s+type\\s+\\{[^}]*\\b${typeName}\\b[^}]*\\}\\s+from\\s+["'][^"']+["'];`, "mu")); if (importMatch?.[0] && !existingContent.includes(importMatch[0])) { newImportLines.push(importMatch[0]); } // Find the export block: from `export const METHOD` to the closing `};`. // The generated code is always Prettier-formatted, so the closing brace // and semicolon of every top-level arrow-function export appear on their // own line as `\n};`. const startMatch = new RegExp(`^export\\s+const\\s+${methodName}\\b`, "mu").exec(generatedContent); if (startMatch) { const fromExport = generatedContent.slice(startMatch.index); const closingIndex = fromExport.indexOf("\n};"); if (closingIndex !== -1) { // Include the closing `};` (3 chars: \n, }, ;) newExportBlocks.push(fromExport.slice(0, closingIndex + 3)); } } } let updatedContent = existingContent; // Insert new import lines right after the last existing import statement. if (newImportLines.length > 0) { const importMatches = [...existingContent.matchAll(/^import\s[^\n]*/gmu)]; if (importMatches.length > 0) { const lastImport = importMatches[importMatches.length - 1]; const importIndex = lastImport?.index; const insertPos = importIndex === undefined ? 0 : (() => { const lineEnd = existingContent.indexOf("\n", importIndex); return lineEnd === -1 ? existingContent.length : lineEnd + 1; })(); updatedContent = existingContent.slice(0, insertPos) + newImportLines.join("\n") + "\n" + existingContent.slice(insertPos); } else { updatedContent = newImportLines.join("\n") + "\n" + existingContent; } } // Append new export blocks at the end of the file. if (newExportBlocks.length > 0) { const separator = updatedContent.endsWith("\n") ? "\n" : "\n\n"; updatedContent += separator + newExportBlocks.join("\n\n") + "\n"; } await fs.writeFile(fullPath, updatedContent); debug(`appended new handlers to ${fullPath}`); } /** * Returns the path of the `_.context.ts` file that is nearest to `path` in * the directory hierarchy, relative to the script's output directory. * * @param destination - Output root directory. * @param path - Repository-relative path of the script being generated. */ findContextPath(destination, path) { return pathRelative(nodePath.join(destination, nodePath.dirname(path)), this.nearestContextFile(destination, path)); } /** * Walks up the directory tree from `path` to find the nearest * `_.context.ts` file, falling back to `routes/_.context.ts` at the root. * * @param destination - Output root directory. * @param path - Repository-relative path to start from. */ nearestContextFile(destination, path) { const directory = pathDirname(path).replace("types/paths", "routes"); const candidate = nodePath.join(destination, directory, "_.context.ts"); if (directory.length <= 1) { // No _context.ts was found so import the one that should be in the root return nodePath.join(destination, "routes", "_.context.ts"); } if (existsSync(candidate)) { return candidate; } return this.nearestContextFile(destination, nodePath.join(path, "..")); } }