UNPKG

counterfact

Version:

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

272 lines (271 loc) 12.7 kB
import { once } from "node:events"; import fs from "node:fs/promises"; import nodePath, { basename } from "node:path"; /* eslint-disable security/detect-non-literal-fs-filename -- readJson resolves paths against the current context directory before file access. */ import { watch } from "chokidar"; import createDebug from "debug"; import { CHOKIDAR_OPTIONS } from "./constants.js"; import { ContextRegistry } from "./context-registry.js"; import { determineModuleKind } from "./determine-module-kind.js"; import { FileDiscovery } from "./file-discovery.js"; import { isContextModule, isMiddlewareModule, } from "./middleware-detector.js"; import { ModuleDependencyGraph } from "./module-dependency-graph.js"; import { uncachedImport } from "./uncached-import.js"; import { sendTelemetry } from "../cli/telemetry.js"; import { toForwardSlashPath, pathDirname, pathRelative, } from "../util/forward-slash-path.js"; import { unescapePathForWindows } from "../util/windows-escape.js"; const { uncachedRequire } = await import("./uncached-require.cjs"); const debug = createDebug("counterfact:server:module-loader"); /** * Watches the compiled routes directory and dynamically loads/reloads route * modules, context files, and middleware as files are added, changed, or * removed. * * Loaded modules are registered in the {@link Registry} (route handlers) or * the {@link ContextRegistry} (context files). An optional * {@link ScenarioRegistry} receives scenario modules loaded from a separate * `scenarios/` directory. * * Emits DOM-style events (`"add"`, `"remove"`) so callers can react to module * lifecycle changes. */ export class ModuleLoader extends EventTarget { basePath; registry; watcher; scenariosWatcher; contextRegistry; scenariosPath; scenarioRegistry; dependencyGraph = new ModuleDependencyGraph(); fileDiscovery; uncachedImport = async function (moduleName) { throw new Error(`uncachedImport not set up; importing ${moduleName}`); }; constructor(basePath, registry, contextRegistry = new ContextRegistry(), scenariosPath, scenarioRegistry) { super(); this.basePath = toForwardSlashPath(basePath); this.registry = registry; this.contextRegistry = contextRegistry; this.scenariosPath = scenariosPath === undefined ? undefined : toForwardSlashPath(scenariosPath); this.scenarioRegistry = scenarioRegistry; this.fileDiscovery = new FileDiscovery(this.basePath); } /** * Starts watching the routes directory (and optionally the scenarios * directory) for file-system changes, loading or reloading modules on * `"add"` and `"change"` events and deregistering them on `"unlink"`. * * Resolves once the initial directory scan is complete. */ async watch() { this.watcher = watch(this.basePath, CHOKIDAR_OPTIONS).on("all", (eventName, pathNameOriginal) => { const JS_EXTENSIONS = ["js", "mjs", "cjs", "ts", "mts", "cts"]; if (!JS_EXTENSIONS.some((extension) => pathNameOriginal.endsWith(`.${extension}`))) return; const pathName = toForwardSlashPath(pathNameOriginal); if (pathName.includes("$.context") && eventName === "add") { process.stdout.write(`\n\n!!! The file at ${pathName} needs a minor update.\n See https://github.com/pmcelhaney/counterfact/blob/main/docs/faq.md\n\n\n`); return; } if (!["add", "change", "unlink"].includes(eventName)) { return; } const parts = nodePath.parse(pathName.replace(this.basePath, "")); sendTelemetry("file_change_detected", { changeType: eventName, fileType: this.isContextFile(pathName) ? "context" : "route", }); const url = unescapePathForWindows(toForwardSlashPath(`/${parts.dir}/${parts.name}`).replaceAll(/\/+/gu, "/")); if (eventName === "unlink") { this.registry.remove(url); this.dispatchEvent(new Event("remove")); if (this.isContextFile(pathName)) { this.contextRegistry.remove(unescapePathForWindows(toForwardSlashPath(parts.dir)) || "/"); } return; } const dependencies = this.dependencyGraph.dependentsOf(pathName); void this.loadEndpoint(pathName); for (const dependency of dependencies) { void this.loadEndpoint(dependency); } }); await once(this.watcher, "ready"); if (this.scenariosPath && this.scenarioRegistry) { const JS_EXTENSIONS = ["js", "mjs", "cjs", "ts", "mts", "cts"]; const scenariosPath = this.scenariosPath; this.scenariosWatcher = watch(scenariosPath, CHOKIDAR_OPTIONS).on("all", (eventName, pathNameOriginal) => { if (!JS_EXTENSIONS.some((ext) => pathNameOriginal.endsWith(`.${ext}`))) return; if (!["add", "change", "unlink"].includes(eventName)) return; const pathName = toForwardSlashPath(pathNameOriginal); if (eventName === "unlink") { const fileKey = this.scenarioFileKey(pathName); this.scenarioRegistry?.remove(fileKey); return; } void this.loadScenarioFile(pathName); }); await once(this.scenariosWatcher, "ready"); } } /** Closes both file-system watchers (routes and scenarios). */ async stopWatching() { await this.watcher?.close(); await this.scenariosWatcher?.close(); } isContextFile(pathName) { return basename(pathName).startsWith("_.context."); } /** * Performs a one-shot load of all modules found under `directory` (relative * to the configured base path) and all scenario files. * * @param directory - Sub-directory to load, defaults to the root (`""`). */ async load(directory = "") { const files = await this.fileDiscovery.findFiles(directory); await Promise.all(files.map((file) => this.loadEndpoint(file))); await this.loadScenarios(); } shouldLoadScenarioFile(pathName) { return !pathName.endsWith(".d.ts") && !pathName.endsWith(".map"); } async loadScenarios() { if (!this.scenariosPath || !this.scenarioRegistry) return; try { const fileDiscovery = new FileDiscovery(this.scenariosPath); const files = await fileDiscovery.findFiles(); const loadableFiles = files.filter((file) => this.shouldLoadScenarioFile(file)); await Promise.all(loadableFiles.map((file) => this.loadScenarioFile(file))); } catch { // Scenarios directory does not exist yet — that's fine. } } scenarioFileKey(pathName) { const normalizedScenariosPath = toForwardSlashPath(this.scenariosPath ?? ""); const directory = pathDirname(pathName.slice(normalizedScenariosPath.length)); const name = nodePath.parse(basename(pathName)).name; const url = unescapePathForWindows(toForwardSlashPath(`/${nodePath.join(directory, name)}`).replaceAll(/\/+/gu, "/")); return url.slice(1); // strip leading "/" } async loadScenarioFile(pathName) { if (!this.scenariosPath || !this.scenarioRegistry) return; const fileKey = this.scenarioFileKey(pathName); try { const doImport = (await determineModuleKind(pathName)) === "commonjs" ? uncachedRequire : uncachedImport; const module = await doImport(pathName); if (module) { this.scenarioRegistry.add(fileKey, module); } } catch (error) { process.stdout.write(`\nError loading scenario ${pathName}:\n${String(error)}\n`); } } async loadEndpoint(pathName) { debug("importing module: %s", pathName); const directory = pathDirname(pathName.slice(this.basePath.length)); const url = unescapePathForWindows(toForwardSlashPath(`/${nodePath.join(directory, nodePath.parse(basename(pathName)).name)}`).replaceAll(/\/+/gu, "/")); debug(`loading pathName from dependencyGraph: ${pathName}`); this.dependencyGraph.load(pathName); try { const doImport = (await determineModuleKind(pathName)) === "commonjs" ? uncachedRequire : uncachedImport; let importError; const endpoint = (await doImport(pathName).catch((error) => { importError = error; })); if (importError !== undefined) { const isSyntaxError = importError instanceof SyntaxError || String(importError).startsWith("SyntaxError:"); const displayPath = pathRelative(process.cwd(), unescapePathForWindows(pathName)); if (this.isContextFile(pathName)) { const warning = isSyntaxError ? `Warning: There is a syntax error in the context file: ${displayPath}` : `Warning: There was an error loading the context file: ${displayPath}`; process.stdout.write(`\n${warning}\n`); return; } const message = isSyntaxError ? `There is a syntax error in the route file: ${displayPath}` : `There was an error loading the route file: ${displayPath}`; const errorResponse = () => ({ body: message, status: 500, }); this.registry.add(url, { DELETE: errorResponse, GET: errorResponse, HEAD: errorResponse, OPTIONS: errorResponse, PATCH: errorResponse, POST: errorResponse, PUT: errorResponse, TRACE: errorResponse, }); this.dispatchEvent(new Event("add")); return; } if (!endpoint) { return; } this.dispatchEvent(new Event("add")); if (this.isContextFile(pathName) && isContextModule(endpoint)) { const loadContext = (path) => this.contextRegistry.find(path); const contextDir = nodePath.dirname(unescapePathForWindows(pathName)); const readJson = async (relativePath) => { const absolutePath = nodePath.resolve(contextDir, relativePath); let content; try { content = await fs.readFile(absolutePath, "utf8"); } catch { throw new Error(`readJson: could not read file at "${absolutePath}" (resolved from "${relativePath}" relative to "${contextDir}")`); } try { return JSON.parse(content); } catch { throw new Error(`readJson: file at "${absolutePath}" does not contain valid JSON`); } }; this.contextRegistry.update(directory, // @ts-expect-error TS says Context has no constructable signatures but that's not true? new endpoint.Context({ loadContext, readJson, })); return; } if (basename(pathName).startsWith("_.middleware.") && isMiddlewareModule(endpoint)) { this.registry.addMiddleware(url.slice(0, url.lastIndexOf("/")) || "/", endpoint.middleware); } if (url === "/index") this.registry.add("/", endpoint); debug(`adding "${url}" to registry`); this.registry.add(url, endpoint); } catch (error) { if (String(error) === "SyntaxError: Identifier 'Context' has already been declared") { // Not sure why Node throws this error. It doesn't seem to matter. return; } process.stdout.write(`\nError loading ${pathName}:\n${String(error)}\n`); throw error; } } }