counterfact
Version:
Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.
116 lines (115 loc) • 5.1 kB
JavaScript
// Stryker disable all
import { once } from "node:events";
import fs from "node:fs/promises";
/* eslint-disable security/detect-non-literal-fs-filename -- transpiler consumes watched source files and writes paired outputs under configured directories. */
import { watch as chokidarWatch } from "chokidar";
import createDebug from "debug";
import ts from "typescript";
import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
import { toForwardSlashPath, pathJoin } from "../util/forward-slash-path.js";
import { CHOKIDAR_OPTIONS } from "./constants.js";
import { convertFileExtensionsToCjs } from "./convert-js-extensions-to-cjs.js";
const debug = createDebug("counterfact:server:transpiler");
/**
* Watches TypeScript source files in `sourcePath` and compiles them to
* JavaScript in `destinationPath` using the TypeScript compiler API.
*
* Used when the runtime cannot execute TypeScript natively (i.e. Node.js
* without the `--experimental-strip-types` flag). Each file is compiled
* independently (no type-checking) for maximum speed.
*
* Emits DOM-style events: `"write"` after a successful transpile, `"delete"`
* after a source file is removed, and `"error"` on write or compilation errors.
*/
export class Transpiler extends EventTarget {
sourcePath;
destinationPath;
moduleKind;
watcher;
constructor(sourcePath, destinationPath, moduleKind) {
super();
this.sourcePath = sourcePath;
this.destinationPath = destinationPath;
this.moduleKind = moduleKind;
}
get extension() {
return this.moduleKind.toLowerCase() === "commonjs" ? ".cjs" : ".js";
}
/**
* Starts the file-system watcher and transpiles all existing files in the
* source path. Resolves once the initial scan and all pending transpiles
* are complete.
*/
async watch() {
debug("transpiler: watch");
this.watcher = chokidarWatch(this.sourcePath, {
...CHOKIDAR_OPTIONS,
ignored: `${this.sourcePath}/js`,
ignoreInitial: false,
});
const transpiles = [];
this.watcher.on("all", async (eventName, sourcePathOriginal) => {
debug("transpiler event: %s <%s>", eventName, sourcePathOriginal);
const JS_EXTENSIONS = ["js", "mjs", "ts", "mts"];
if (!JS_EXTENSIONS.some((extension) => sourcePathOriginal.endsWith(`.${extension}`)))
return;
const sourcePath = toForwardSlashPath(sourcePathOriginal);
const destinationPath = toForwardSlashPath(sourcePath
.replace(this.sourcePath, this.destinationPath)
.replace(".ts", this.extension));
if (["add", "change"].includes(eventName)) {
transpiles.push(this.transpileFile(eventName, sourcePath, destinationPath));
}
if (eventName === "unlink") {
try {
await fs.rm(destinationPath);
}
catch (error) {
if (error.code !== "ENOENT") {
debug("error removing %s: %o", destinationPath, error);
this.dispatchEvent(new Event("error"));
throw error;
}
}
this.dispatchEvent(new Event("delete"));
}
});
await once(this.watcher, "ready");
await Promise.all(transpiles);
}
/** Closes the file-system watcher. */
async stopWatching() {
await this.watcher?.close();
}
async transpileFile(eventName, sourcePath, destinationPath) {
ensureDirectoryExists(destinationPath);
const source = await fs.readFile(sourcePath, "utf8");
const transpileOutput = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind[this.moduleKind.toLowerCase() === "module" ? "ES2022" : "CommonJS"],
target: ts.ScriptTarget.ES2015,
},
reportDiagnostics: true,
});
if (transpileOutput.diagnostics?.length) {
for (const diagnostic of transpileOutput.diagnostics) {
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
debug("TypeScript diagnostic in %s: %s", sourcePath, message);
}
}
const result = transpileOutput.outputText;
const fullDestination = pathJoin(sourcePath
.replace(this.sourcePath, this.destinationPath)
.replace(".ts", this.extension));
const resultWithTransformedFileExtensions = convertFileExtensionsToCjs(result);
try {
await fs.writeFile(fullDestination, resultWithTransformedFileExtensions);
}
catch (error) {
debug("error writing transpiled output to %s: %o", fullDestination, error);
this.dispatchEvent(new Event("error"));
throw new Error("could not transpile", { cause: error });
}
this.dispatchEvent(new Event("write"));
}
}