Repository: pmcelhaney/counterfact
Pull request #176 (2022-09-05 23:14:37)

add a transpiler class that watches TS file and transforms them

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 61b500fdd8e02f8df7b75924f08a5c3785b39eff

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/pmcelhaney/counterfact/new/170-transform-typescript-files-on-the-fly?filename=.changeset/cool-dogs-carry.md&value=---%0A%22counterfact%22%3A%20patch%0A---%0A%0Aadd%20a%20transpiler%20class%20that%20watches%20TS%20file%20and%20transforms%20them%0A)



Patch:
diff --git a/package.json b/package.json
index f66fa62..def56c4 100644
--- a/package.json
+++ b/package.json
@@ -45,6 +45,7 @@
     "eslint-config-hardcore": "24.15.0",
     "eslint-formatter-github-annotations": "0.1.0",
     "eslint-import-resolver-typescript": "^3.2.5",
+    "eslint-plugin-etc": "^2.0.2",
     "eslint-plugin-file-progress": "^1.3.0",
     "eslint-plugin-import": "^2.26.0",
     "eslint-plugin-jest": "^27.0.1",
@@ -65,6 +66,7 @@
     "jsonwebtoken": "^8.5.1",
     "koa": "^2.13.4",
     "koa-bodyparser": "^4.3.0",
-    "prettier": "^2.7.1"
+    "prettier": "^2.7.1",
+    "typescript": "^4.8.2"
   }
 }
diff --git a/src/transpiler.js b/src/transpiler.js
new file mode 100644
index 0000000..e6a9771
--- /dev/null
+++ b/src/transpiler.js
@@ -0,0 +1,77 @@
+import fs from "node:fs/promises";
+import nodePath from "node:path";
+import { constants as fsConstants } from "node:fs";
+import { once } from "node:events";
+
+import ts from "typescript";
+import chokidar from "chokidar";
+
+async function ensureDirectoryExists(filePath) {
+  const directory = nodePath.dirname(filePath);
+
+  try {
+    await fs.access(directory, fsConstants.W_OK);
+  } catch {
+    await fs.mkdir(directory, { recursive: true });
+  }
+}
+
+export class Transpiler extends EventTarget {
+  constructor(sourcePath, destinationPath) {
+    super();
+    this.sourcePath = sourcePath;
+    this.destinationPath = destinationPath;
+  }
+
+  async watch() {
+    this.watcher = chokidar.watch(`${this.sourcePath}/**/*.{js,mjs,ts,mts}`);
+
+    this.watcher.on("all", async (eventName, sourcePath) => {
+      const destinationPath = sourcePath
+        .replace(this.sourcePath, this.destinationPath)
+        .replace(".ts", ".js");
+
+      if (["add", "change"].includes(eventName)) {
+        this.transpileFile(eventName, sourcePath, destinationPath);
+      }
+
+      if (eventName === "unlink") {
+        try {
+          await fs.rm(destinationPath);
+        } catch (error) {
+          if (error.code !== "ENOENT") {
+            throw error;
+          }
+        }
+
+        this.dispatchEvent(new Event("delete"));
+      }
+    });
+    await once(this.watcher, "ready");
+  }
+
+  async stopWatching() {
+    await this.watcher?.close();
+  }
+
+  async transpileFile(eventName, sourcePath, destinationPath) {
+    await ensureDirectoryExists(destinationPath);
+
+    const source = await fs.readFile(sourcePath, "utf8");
+
+    const result = ts.transpileModule(source, {
+      compilerOptions: { module: ts.ModuleKind.ES2022 },
+    }).outputText;
+
+    await fs.writeFile(
+      nodePath.join(
+        sourcePath
+          .replace(this.sourcePath, this.destinationPath)
+          .replace(".ts", ".js")
+      ),
+      result
+    );
+
+    this.dispatchEvent(new Event("write"));
+  }
+}


Test patch:
diff --git a/test/transpiler.test.js b/test/transpiler.test.js
new file mode 100644
index 0000000..e6a9937
--- /dev/null
+++ b/test/transpiler.test.js
@@ -0,0 +1,114 @@
+import { once } from "node:events";
+import fs from "node:fs/promises";
+import { constants as fsConstants } from "node:fs";
+
+import { Transpiler } from "../src/transpiler.js";
+
+import { withTemporaryFiles } from "./lib/with-temporary-files.js";
+
+const TYPESCRIPT_SOURCE = "const x:number = 1;\n";
+const JAVASCRIPT_SOURCE = "var x = 1;\n";
+
+describe("a Transpiler", () => {
+  // eslint-disable-next-line init-declarations
+  let transpiler;
+
+  // eslint-disable-next-line jest/no-hooks
+  afterEach(() => {
+    transpiler.stopWatching();
+  });
+
+  it("finds a file and transpiles it", async () => {
+    const files = {
+      "src/found.ts": TYPESCRIPT_SOURCE,
+    };
+
+    await withTemporaryFiles(files, async (basePath, { path }) => {
+      transpiler = new Transpiler(path("src"), path("dist"));
+
+      await transpiler.watch();
+
+      await once(transpiler, "write");
+
+      await expect(fs.readFile(path("dist/found.js"), "utf8")).resolves.toBe(
+        JAVASCRIPT_SOURCE
+      );
+
+      transpiler.stopWatching();
+    });
+  });
+
+  it("discovers a new file and transpiles it", async () => {
+    // on Linux the watcher doesn't seem to work consistently if there's not a file in the directory to begin with
+
+    await withTemporaryFiles(
+      { "src/starter.ts": TYPESCRIPT_SOURCE },
+      async (basePath, { path, add }) => {
+        transpiler = new Transpiler(path("src"), path("dist"));
+
+        const writeTheFirstFile = once(transpiler, "write");
+
+        await transpiler.watch();
+        await writeTheFirstFile;
+
+        const write = once(transpiler, "write");
+
+        await add("src/added.ts", TYPESCRIPT_SOURCE);
+        await write;
+
+        await expect(fs.readFile(path("dist/added.js"), "utf8")).resolves.toBe(
+          JAVASCRIPT_SOURCE
+        );
+
+        transpiler.stopWatching();
+      }
+    );
+  });
+
+  it("sees an updated file and transpiles it", async () => {
+    const files = {
+      "src/update-me.ts": "const x = 'code to be overwritten';\n",
+    };
+
+    await withTemporaryFiles(files, async (basePath, { path, add }) => {
+      transpiler = new Transpiler(path("src"), path("dist"));
+
+      const initialWrite = once(transpiler, "write");
+
+      await transpiler.watch();
+      await initialWrite;
+
+      const overwrite = once(transpiler, "write");
+
+      add("src/update-me.ts", TYPESCRIPT_SOURCE);
+      await overwrite;
+
+      await expect(
+        fs.readFile(path("dist/update-me.js"), "utf8")
+      ).resolves.toBe(JAVASCRIPT_SOURCE);
+
+      transpiler.stopWatching();
+    });
+  }, 10_000);
+
+  it("sees a removed TypeScript file and deletes the JavaScript file", async () => {
+    const files = {
+      "src/delete-me.ts": TYPESCRIPT_SOURCE,
+    };
+
+    await withTemporaryFiles(files, async (basePath, { path, remove }) => {
+      transpiler = new Transpiler(path("src"), path("dist"));
+
+      await transpiler.watch();
+      await once(transpiler, "write");
+      await remove("src/delete-me.ts");
+      await once(transpiler, "delete");
+
+      await expect(() =>
+        fs.access(path("dist/delete-me.js"), fsConstants.F_OK)
+      ).rejects.toThrow(/ENOENT/u);
+
+      transpiler.stopWatching();
+    });
+  });
+});