counterfact
Version:
Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.
323 lines (322 loc) • 12.7 kB
JavaScript
import createDebugger from "debug";
import { format } from "prettier";
import { escapePathForWindows } from "../util/windows-escape.js";
import { pathJoin, pathRelative, pathDirname, } from "../util/forward-slash-path.js";
const debug = createDebugger("counterfact:typescript-generator:script");
/**
* Represents a single TypeScript file being assembled by the code generator.
*
* A `Script` accumulates exports, imports, and external imports contributed by
* {@link Coder} instances. Once all coders have resolved, {@link contents}
* formats the result with Prettier and returns the final source text.
*
* Scripts are created and retrieved through a {@link Repository} so that the
* same module path always maps to the same `Script` instance.
*/
export class Script {
repository;
comments;
exports;
versions;
versionFormatters;
imports;
externalImport;
cache;
typeCache;
path;
constructor(repository, path) {
this.repository = repository;
this.comments = [];
this.exports = new Map();
this.versions = new Map();
this.versionFormatters = new Map();
this.imports = new Map();
this.externalImport = new Map();
this.cache = new Map();
this.typeCache = new Map();
this.path = path;
}
/**
* A `"../"` path fragment that points from this script's directory back to
* the repository root, used to resolve relative import paths.
*/
get relativePathToBase() {
return this.path
.split("/")
.slice(0, -1)
.map(() => "..")
.join("/");
}
/**
* Picks the first name from `coder.names()` that is not already used as an
* import in this script, ensuring export/import name uniqueness.
*
* @param coder - The coder needing a name.
* @throws When all 100 candidate names are already taken.
*/
firstUniqueName(coder) {
for (const name of coder.names()) {
if (!this.imports.has(name)) {
return name;
}
}
throw new Error(`could not find a unique name for ${coder.id}`);
}
/**
* Registers an export for `coder` in this script and returns the export name.
*
* If the same coder has already been exported (cache hit), the previously
* assigned name is returned without creating a duplicate.
*
* @param coder - The coder to export.
* @param isType - Emit `export type` instead of `export const`.
* @param isDefault - Emit `export default` instead of a named export.
* @returns The name under which the coder is exported.
*/
export(coder, isType = false, isDefault = false) {
const cacheKey = isDefault ? "default" : `${coder.id}:${isType}`;
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey);
}
const name = this.firstUniqueName(coder);
this.cache.set(cacheKey, name);
const exportStatement = {
beforeExport: coder.beforeExport(this.path),
done: false,
id: coder.id,
isDefault,
isType,
jsdoc: "",
typeDeclaration: coder.typeDeclaration(this.exports, this),
};
exportStatement.promise = coder
.delegate()
.then((availableCoder) => {
exportStatement.name = name;
exportStatement.code = availableCoder.write(this);
exportStatement.jsdoc = availableCoder.jsdoc();
return availableCoder;
})
.catch((error) => {
exportStatement.code = `{/* error creating export "${name}" for ${this.path}: ${error.stack} */}`;
exportStatement.error = error;
return undefined;
})
.finally(() => {
exportStatement.done = true;
});
this.exports.set(name, exportStatement);
return name;
}
exportDefault(coder, isType = false) {
this.export(coder, isType, true);
}
/**
* Registers an import of `coder` from its owning module and returns the
* local alias used in this script.
*
* The coder is also exported from its home module as a side effect.
*
* @param coder - The coder to import.
* @param isType - Use a `import type` declaration.
* @param isDefault - Import the default export rather than a named export.
*/
import(coder, isType = false, isDefault = false) {
debug("import coder: %s", coder.id);
const modulePath = coder.modulePath();
const cacheKey = `${coder.id}@${modulePath}:${isType}:${isDefault}`;
debug("cache key: %s", cacheKey);
if (this.cache.has(cacheKey)) {
debug("cache hit: %s", cacheKey);
return this.cache.get(cacheKey);
}
debug("cache miss: %s", cacheKey);
const name = this.firstUniqueName(coder);
this.cache.set(cacheKey, name);
const scriptFromWhichToExport = this.repository.get(modulePath);
const exportedName = scriptFromWhichToExport.export(coder, isType, isDefault);
this.imports.set(name, {
isDefault,
isType,
name: exportedName,
script: scriptFromWhichToExport,
});
return name;
}
importType(coder) {
return this.import(coder, true);
}
importDefault(coder, isType = false) {
return this.import(coder, isType, true);
}
/**
* Registers an import from an external npm package or absolute module path
* (not managed by the repository).
*
* @param name - The local binding name.
* @param modulePath - The module specifier (e.g. `"counterfact-types/index"`).
* @param isType - Use a `import type` declaration.
* @returns `name` (for convenience in method chaining).
*/
importExternal(name, modulePath, isType = false) {
this.externalImport.set(name, { isType, modulePath });
return name;
}
/**
* Convenience wrapper that calls {@link importExternal} with `isType = true`.
*/
importExternalType(name, modulePath) {
return this.importExternal(name, modulePath, true);
}
/**
* Imports a type from the shared `counterfact-types/index.ts` module,
* resolving the path relative to this script's location in the repository.
*
* @param name - The type name to import (e.g. `"WideOperationArgument"`).
*/
importSharedType(name) {
return this.importExternal(name, pathJoin(this.relativePathToBase, "counterfact-types/index.ts"), true);
}
/**
* Imports a type from the generated `types/versions.ts` module,
* resolving the path relative to this script's location in the repository.
*
* @param name - The type name to import (e.g. `"Versioned"`).
*/
importVersionsType(name) {
return this.importExternal(name, pathJoin(this.relativePathToBase, "types/versions.ts"), true);
}
exportType(coder) {
return this.export(coder, true);
}
/**
* Registers a formatter function for the merged versioned type emitted under
* `name` by {@link versionsTypeStatements}.
*
* When a formatter is present for a name, `versionsTypeStatements` delegates
* the entire type declaration to it instead of generating the default
* `Versions` object type. The formatter receives a `Map<version, importAlias>`
* and must return the complete TypeScript source for that operation type.
*/
setVersionFormatter(name, formatter) {
this.versionFormatters.set(name, formatter);
}
declareVersion(coder, name) {
const version = coder.version;
const versions = this.versions.get(name) ?? new Map();
this.versions.set(name, versions);
if (versions.has(version)) {
return;
}
const versionStatement = {
beforeExport: "",
done: false,
id: coder.id,
isDefault: false,
isType: true,
jsdoc: "",
typeDeclaration: "",
};
versionStatement.promise = coder
.delegate()
.then((availableCoder) => {
versionStatement.code = availableCoder.write(this);
return availableCoder;
})
.catch((error) => {
versionStatement.code = `unknown /* error declaring version "${name}" (${version}) for ${this.path}: ${error.message} */`;
versionStatement.error = error;
return undefined;
})
.finally(() => {
versionStatement.done = true;
});
versions.set(version, versionStatement);
}
/** `true` while at least one export promise is still pending. */
isInProgress() {
return (Array.from(this.exports.values()).some((exportStatement) => !exportStatement.done) ||
Array.from(this.versions.values())
.flatMap((versions) => Array.from(versions.values()))
.some((versionStatement) => !versionStatement.done));
}
/** Returns a promise that resolves when all pending export promises settle. */
finished() {
return Promise.all([
...Array.from(this.exports.values(), (value) => value.promise),
...Array.from(this.versions.values())
.flatMap((versions) => Array.from(versions.values()))
.map((value) => value.promise),
]);
}
externalImportStatements() {
return Array.from(this.externalImport, ([name, { isDefault, isType, modulePath }]) => `import${isType ? " type" : ""} ${isDefault ? name : `{ ${name} }`} from "${modulePath}";`);
}
importStatements() {
return Array.from(this.imports, ([name, { isDefault, isType, script }]) => {
const resolvedPath = escapePathForWindows(pathRelative(pathDirname(this.path), script.path.replace(/\.ts$/u, ".js")));
return `import${isType ? " type" : ""} ${isDefault ? name : `{ ${name} }`} from "${resolvedPath.includes("../") ? "" : "./"}${resolvedPath}";`;
});
}
exportStatements() {
return Array.from(this.exports.values(), ({ beforeExport, code, isDefault, isType, jsdoc, name, typeDeclaration, }) => {
if (typeof code === "object" && code !== null && "raw" in code) {
return code.raw;
}
if (isDefault) {
return `${jsdoc}${beforeExport}export default ${code};`;
}
const keyword = isType ? "type" : "const";
const typeAnnotation = (typeDeclaration ?? "").length === 0
? ""
: `:${typeDeclaration ?? ""}`;
return `${jsdoc}${beforeExport}export ${keyword} ${name ?? ""}${typeAnnotation} = ${code};`;
});
}
versionsTypeStatements() {
if (this.versions.size === 0) {
return [];
}
const statements = [];
const unformatted = [];
for (const [name, versions] of this.versions) {
const formatter = this.versionFormatters.get(name);
if (formatter) {
const versionCodes = new Map(Array.from(versions, ([version, stmt]) => [
version,
stmt.code,
]));
statements.push(formatter(versionCodes));
}
else {
unformatted.push([name, versions]);
}
}
if (unformatted.length > 0) {
const names = unformatted.map(([name, versions]) => {
const mappedVersions = Array.from(versions, ([version, versionStatement]) => `"${version}": ${versionStatement.code}`);
return `"${name}": { ${mappedVersions.join(", ")} }`;
});
statements.push(`export type Versions = { ${names.join(", ")} };`);
}
return statements;
}
/**
* Formats the fully assembled script source with Prettier and returns it.
*
* All pending export promises are awaited before formatting.
*/
async contents() {
await this.finished();
return format([
this.comments.map((comment) => `// ${comment}`).join("\n"),
this.comments.length > 0 ? "\n\n" : "",
this.externalImportStatements().join("\n"),
this.importStatements().join("\n"),
"\n\n",
this.versionsTypeStatements().join("\n"),
this.versions.size > 0 ? "\n\n" : "",
this.exportStatements().join("\n\n"),
].join(""), { parser: "typescript" });
}
}