counterfact
Version:
Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.
221 lines (220 loc) • 9.68 kB
JavaScript
import { rm } from "node:fs/promises";
import { ContextRegistry } from "./server/context-registry.js";
import { Dispatcher } from "./server/dispatcher.js";
import { loadOpenApiDocument } from "./server/load-openapi-document.js";
import { ModuleLoader } from "./server/module-loader.js";
import { Registry } from "./server/registry.js";
import { ScenarioRegistry } from "./server/scenario-registry.js";
import { Transpiler } from "./server/transpiler.js";
import { CodeGenerator } from "./typescript-generator/code-generator.js";
import { ScenarioFileGenerator } from "./typescript-generator/scenario-file-generator.js";
import { pathJoin } from "./util/forward-slash-path.js";
import { runtimeCanExecuteErasableTs } from "./util/runtime-can-execute-erasable-ts.js";
/**
* Encapsulates the creation and lifecycle management of all Counterfact
* sub-systems for a single API specification.
*
* Use the static {@link ApiRunner.create} factory method to construct an
* instance — the constructor requires async initialisation (loading the
* OpenAPI document and probing for native TypeScript support) that cannot
* be done in a synchronous constructor.
*
* Each sub-system is exposed as a public property so callers can interact
* with individual components directly when needed. The composite methods
* {@link generate}, {@link load}, {@link watch}, and {@link stopWatching}
* coordinate across multiple sub-systems and encapsulate the conditional
* logic driven by the configuration.
*/
export class ApiRunner {
/** Stores loaded route handlers, keyed by path. */
registry;
/** Stores context objects per route path and dispatches change events. */
contextRegistry;
/** Registry of loaded scenario modules (used by the REPL). */
scenarioRegistry;
/** Generates `types/_.context.ts` and the default `scenarios/index.ts`. */
scenarioFileGenerator;
/** Reads the OpenAPI spec and writes TypeScript route/type stub files. */
codeGenerator;
/** Routes incoming HTTP requests to the matching handler in `registry`. */
dispatcher;
/**
* Compiles TypeScript route files to JavaScript when the runtime cannot
* execute TypeScript natively.
*/
transpiler;
/**
* Imports compiled route/context/scenario modules and hot-reloads them on
* file-system changes.
*/
moduleLoader;
/**
* The loaded OpenAPI document, or `undefined` when `config.openApiPath`
* is `"_"` (spec-less mode).
*/
openApiDocument;
/** `true` when the current Node.js runtime can execute TypeScript natively. */
nativeTs;
/** Path or URL to the OpenAPI document for this runner. */
openApiPath;
/** URL prefix that this runner intercepts (default `""`). */
prefix;
/**
* Ordered list of overlay file paths/URLs applied to the OpenAPI document
* after loading. Empty when no overlays are configured.
*/
overlays;
/**
* Optional group name that places generated code in a subdirectory.
* Defaults to `""` (no subdirectory).
*/
group;
/**
* Optional version label for this runner's spec (e.g. `"v1"`, `"v2"`).
* Defaults to `""` (unversioned).
*/
version;
/**
* The subdirectory path segment derived from {@link group}.
* Returns `""` when `group` is empty, otherwise `"/${group}"`.
*/
get subdirectory() {
return this.group ? `/${this.group}` : "";
}
config;
constructor(config, nativeTs, openApiDocument, group, version = "", versions = []) {
this.group = group;
this.version = version;
const modulesPath = this.group
? pathJoin(config.basePath, this.group)
: config.basePath;
const compiledPathsDirectory = pathJoin(modulesPath, nativeTs ? "routes" : ".cache");
this.config = config;
this.nativeTs = nativeTs;
this.openApiDocument = openApiDocument;
this.openApiPath = config.openApiPath;
this.prefix = config.prefix;
this.overlays = config.overlays ?? [];
this.registry = new Registry();
this.contextRegistry = new ContextRegistry();
this.scenarioRegistry = new ScenarioRegistry();
this.scenarioFileGenerator = new ScenarioFileGenerator(modulesPath);
this.codeGenerator = new CodeGenerator(this.openApiPath, config.basePath + this.subdirectory, config.generate, version, config.overlays ?? []);
this.dispatcher = new Dispatcher(this.registry, this.contextRegistry, openApiDocument, config, version, versions);
this.transpiler = new Transpiler(pathJoin(modulesPath, "routes"), compiledPathsDirectory, "commonjs");
this.moduleLoader = new ModuleLoader(compiledPathsDirectory, this.registry, this.contextRegistry, pathJoin(modulesPath, "scenarios"), this.scenarioRegistry);
}
/**
* Creates and returns a fully initialised `ApiRunner`.
*
* Probes for native TypeScript support, optionally cleans the compiled
* output directory, and loads the OpenAPI document before constructing
* the runner.
*
* @param config - Runtime configuration for this runner instance.
* @param group - Optional group name placing generated code in a subdirectory (default `""`).
* @param version - Optional version label for this spec (e.g. `"v1"`, `"v2"`).
* @param versions - Optional ordered list of all version labels in this group (oldest first).
*/
static async create(config, group = "", version = "", versions = []) {
const nativeTs = await runtimeCanExecuteErasableTs();
const modulesPath = group
? pathJoin(config.basePath, group)
: config.basePath;
const compiledPathsDirectory = pathJoin(modulesPath, nativeTs ? "routes" : ".cache");
if (!nativeTs) {
await rm(compiledPathsDirectory, { force: true, recursive: true });
}
const openApiDocument = config.openApiPath === "_"
? undefined
: await loadOpenApiDocument(config.openApiPath, config.overlays ?? []);
return new ApiRunner(config, nativeTs, openApiDocument, group, version, versions);
}
/**
* Generates TypeScript route stubs and type files from the OpenAPI spec.
*
* Respects the `config.generate.routes` and `config.generate.types` flags:
* - Routes and types are only generated when `config.openApiPath` is not `"_"`.
* - The scenario context type file is always generated when
* `config.generate.types` is `true`, even without a spec.
*
* @param repository - Optional shared repository. Pass a shared instance
* when multiple versioned specs in the same group should merge their types
* into the same output tree.
*/
async generate(repository) {
if (this.config.openApiPath !== "_" &&
(this.config.generate.routes || this.config.generate.types)) {
await this.codeGenerator.generate(repository);
}
if (this.config.generate.types) {
await this.scenarioFileGenerator.generate();
}
}
/**
* Loads all compiled route, context, and scenario modules into the
* appropriate registries.
*/
async load() {
await this.moduleLoader.load();
}
/**
* Starts watching the OpenAPI spec and scenario context files for changes
* and re-generates the corresponding TypeScript files on each change.
*
* - Re-generates route stubs when the spec changes (when
* `config.watch.routes` or `config.watch.types` is `true`).
* - Re-generates `types/_.context.ts` when a `_.context.ts` file changes
* (when `config.watch.types` is `true`).
*/
async watch() {
if (this.config.openApiPath !== "_" &&
(this.config.watch.routes || this.config.watch.types)) {
await this.codeGenerator.watch();
}
if (this.config.watch.types) {
await this.scenarioFileGenerator.watch();
}
}
/**
* Starts the server-related sub-systems based on the supplied options.
*
* When `startServer` is `true`:
* - Watches the OpenAPI document for live reloads.
* - Transpiles TypeScript route files (when the runtime does not support
* native TypeScript execution).
* - Loads all compiled modules into their registries.
* - Watches compiled modules for hot-reload.
*
* When `buildCache` is `true` (and `startServer` is `false`):
* - Runs the transpiler once to build the compiled-output cache, then stops.
*
* @param options - Subset of the runtime config that governs startup behaviour.
*/
async start(options) {
const { startServer, buildCache } = options;
if (startServer) {
await this.openApiDocument?.watch();
if (!this.nativeTs) {
await this.transpiler.watch();
}
await this.moduleLoader.load();
await this.moduleLoader.watch();
}
else if (buildCache) {
// Transpile once to populate the cache, then immediately stop watching.
await this.transpiler.watch();
await this.transpiler.stopWatching();
}
}
/**
* Stops all active file-system watchers across every sub-system.
*/
async stopWatching() {
await this.codeGenerator.stopWatching();
await this.scenarioFileGenerator.stopWatching();
await this.transpiler.stopWatching();
await this.moduleLoader.stopWatching();
await this.openApiDocument?.stopWatching();
}
}