counterfact
Version:
Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.
242 lines (241 loc) • 11 kB
JavaScript
import fs from "node:fs/promises";
import nodePath from "node:path";
import { createHttpTerminator } from "http-terminator";
import { ApiRunner } from "./api-runner.js";
import { startRepl as startReplServer } from "./repl/repl.js";
import { createRouteFunction } from "./repl/route-builder.js";
import { createKoaApp } from "./server/web-server/create-koa-app.js";
import { Repository } from "./typescript-generator/repository.js";
import { ensureDirectoryExists } from "./util/ensure-directory-exists.js";
import { generateVersionsTsContent } from "./typescript-generator/versions-ts-generator.js";
export { loadOpenApiDocument } from "./server/load-openapi-document.js";
export { createMswHandlers, handleMswRequest, } from "./msw.js";
export async function runStartupScenario(scenarioRegistry, contextRegistry, config, openApiDocument) {
const indexModule = scenarioRegistry.getModule("index");
if (!indexModule || typeof indexModule["startup"] !== "function") {
return;
}
const scenario$ = {
context: contextRegistry.find("/"),
loadContext: (path) => contextRegistry.find(path),
route: createRouteFunction(config.port, "localhost", openApiDocument),
routes: {},
};
await indexModule["startup"](scenario$);
}
/**
* Derives the URL prefix for a spec entry.
*
* Applies the following precedence rules:
* 1. Explicit `prefix` (even `""`) → returned as-is.
* 2. `group` + `version` both present → `/<group>/<version>`.
* 3. `group` present (no `version`) → `/<group>`.
* 4. Neither → `""` (root).
*/
function derivePrefix(spec) {
if (spec.prefix !== undefined) {
return spec.prefix;
}
if (spec.group && spec.version) {
return `/${spec.group}/${spec.version}`;
}
if (spec.group) {
return `/${spec.group}`;
}
return "";
}
/**
* Normalises the spec configuration to an array.
*
* When `specs` is provided, each entry's `prefix` is resolved via
* {@link derivePrefix} so the rest of the code can assume `prefix` is always
* a string. When `specs` is omitted, a single-entry array is constructed from
* `config.openApiPath`, `config.prefix`, and `group = ""`.
*/
function normalizeSpecs(config, specs) {
if (specs !== undefined) {
return specs.map((spec) => ({ ...spec, prefix: derivePrefix(spec) }));
}
return [
{
source: config.openApiPath,
prefix: config.prefix,
group: "",
version: "",
},
];
}
function validateSpecGroups(specs) {
if (specs.length <= 1) {
return;
}
const invalidSpecNumbers = specs
.map((spec, index) => ({ group: spec.group.trim(), index }))
.filter(({ group }) => group === "")
.map(({ index }) => String(index + 1));
if (invalidSpecNumbers.length === 0) {
const seenKeys = new Set();
const duplicateKeys = new Set();
for (const spec of specs) {
const group = spec.group.trim();
const version = spec.version?.trim() ?? "";
// Use group@version as the uniqueness key so that the same group can
// appear with different versions (e.g. v1 and v2 of the same API).
// The empty-group case is already rejected above, so `group` is always
// non-empty here and the `@version` suffix remains unambiguous.
const key = version ? `${group}@${version}` : group;
if (seenKeys.has(key)) {
duplicateKeys.add(key);
continue;
}
seenKeys.add(key);
}
if (duplicateKeys.size === 0) {
return;
}
throw new Error(`Each spec must define a unique group (and version) when multiple APIs are configured (duplicates: ${[...duplicateKeys].join(", ")}).`);
}
throw new Error(`Each spec must define a non-empty group when multiple APIs are configured (invalid spec entries: ${invalidSpecNumbers.join(", ")}).`);
}
/**
* Creates and configures a full Counterfact server instance.
*
* Supports one or more API specifications. Each spec produces its own
* {@link ApiRunner}. When `specs` is omitted a single runner is created from
* `config.openApiPath` and `config.prefix`.
*
* The returned object exposes handles for starting the server, stopping it,
* and launching the interactive REPL.
*
* @param config - Runtime configuration (port, paths, feature flags, etc.).
* @param specs - Optional array of spec entries. Omit to use a single spec
* derived from `config.openApiPath` and `config.prefix`.
* @returns An object containing the configured sub-systems and two entry-point
* functions:
* - `start(options)` — generates/watches code and optionally starts the HTTP
* server; returns a `stop()` handle.
* - `startRepl()` — launches the interactive Node.js REPL connected to the
* live server state.
*/
export async function counterfact(config, specs) {
const normalizedSpecs = normalizeSpecs({ openApiPath: config.openApiPath, prefix: config.prefix }, specs);
validateSpecGroups(normalizedSpecs);
// Compute the ordered versions per group (oldest first, as declared in specs).
// This list is passed to each runner so that $.minVersion() can compare
// version positions at runtime.
const versionsByGroup = new Map();
for (const spec of normalizedSpecs) {
const version = spec.version ?? "";
if (version) {
const existing = versionsByGroup.get(spec.group) ?? [];
versionsByGroup.set(spec.group, [...existing, version]);
}
}
const runners = await Promise.all(normalizedSpecs.map((spec) => ApiRunner.create({
...config,
openApiPath: spec.source,
// Per-spec overlays take precedence; fall back to config-level overlays
// so that the --overlay CLI flag works in single-spec mode.
overlays: spec.overlays ?? config.overlays ?? [],
prefix: spec.prefix,
}, spec.group, spec.version ?? "", versionsByGroup.get(spec.group) ?? [])));
const koaApp = createKoaApp({
runners,
config,
});
// The REPL is configured using the first runner.
const primaryRunner = runners[0];
async function start(options) {
// Serialize generate() calls within each group to avoid concurrent writes
// to the same output directory. Runners that share a group share the same
// basePath subdirectory (and therefore the same counterfact-types
// destination), so running them in parallel would cause a race when both
// try to create that directory at startup. Different groups are still
// generated in parallel.
//
// When multiple versioned specs share the same group, they also share a
// single Repository instance so that the shared `types/paths/…` files
// accumulate all versions into a merged Versioned<…> type instead of each
// overwriting the previous version's types.
const runnersByGroup = new Map();
for (const runner of runners) {
const bucket = runnersByGroup.get(runner.group) ?? [];
bucket.push(runner);
runnersByGroup.set(runner.group, bucket);
}
await Promise.all(Array.from(runnersByGroup.values()).map(async (bucket) => {
const sharedRepository = bucket.length > 1 ? new Repository() : undefined;
for (const runner of bucket) {
await runner.generate(sharedRepository);
}
}));
if (options.generate?.types) {
// Build a per-group map of unique non-empty version strings in
// declaration order. new Set() preserves insertion order so the first
// occurrence of each version is kept and duplicates are dropped without
// reordering.
const versionsByGroup = new Map();
for (const spec of normalizedSpecs) {
const group = spec.group;
const version = (spec.version ?? "").trim();
if (version === "") {
continue;
}
const existing = versionsByGroup.get(group) ?? [];
if (!existing.includes(version)) {
existing.push(version);
}
versionsByGroup.set(group, existing);
}
// Write <basePath>/<group>/types/versions.ts for every group that has
// at least one versioned spec. When the group is empty the path
// collapses to <basePath>/types/versions.ts (the single-spec case).
await Promise.all(Array.from(versionsByGroup.entries()).map(async ([group, versions]) => {
const content = await generateVersionsTsContent(versions);
const versionsFilePath = group
? nodePath.join(config.basePath, group, "types", "versions.ts")
: nodePath.join(config.basePath, "types", "versions.ts");
/* eslint-disable security/detect-non-literal-fs-filename -- path is derived from the caller-supplied basePath and fixed suffixes. */
await ensureDirectoryExists(versionsFilePath);
await fs.writeFile(versionsFilePath, content, "utf8");
/* eslint-enable security/detect-non-literal-fs-filename */
}));
}
await Promise.all(runners.map((runner) => runner.watch()));
await Promise.all(runners.map((runner) => runner.start(options)));
let httpTerminator;
if (options.startServer) {
await runStartupScenario(primaryRunner.scenarioRegistry, primaryRunner.contextRegistry, { port: config.port }, primaryRunner.openApiDocument);
const server = koaApp.listen({
port: config.port,
});
httpTerminator = createHttpTerminator({
server,
});
}
return {
async stop() {
await Promise.all(runners.map((runner) => runner.stopWatching()));
await httpTerminator?.terminate();
},
};
}
return {
contextRegistry: primaryRunner.contextRegistry,
koaApp,
registry: primaryRunner.registry,
start,
startRepl: () => startReplServer(primaryRunner.contextRegistry, primaryRunner.registry, {
port: config.port,
proxyPaths: config.proxyPaths,
proxyUrl: config.proxyUrl,
}, undefined, // use the default print function (stdout)
primaryRunner.openApiDocument, primaryRunner.scenarioRegistry, runners.map((runner) => ({
contextRegistry: runner.contextRegistry,
group: runner.group,
openApiDocument: runner.openApiDocument,
registry: runner.registry,
scenarioRegistry: runner.scenarioRegistry,
}))),
};
}