上一篇文章的最後,我提到了為了要把 TypeScript 檔案同時輸出成 CommonJS 和 ECMAScript modules (ESM),所以開發了 tsc-multi

tsc-multi 的運作方式其實非常簡單,就是同時執行多個 TypeScript compiler 平行運作而已;除此之外,我還對 compiler 動了一點手腳。

修改 ts.System

ts.System 是 TypeScript 用來和作業系統互動的 interface,其中包含了跟檔案系統 (file system) 有關的 method。因為 TypeScript 預設的輸出檔案的副檔名是 .js ,為了要變更輸出檔案的副檔名,我修改了 ts.System 裡跟讀寫檔案有關的 method。

function rewritePath(path) {
  if (path.endsWith(".js") return path.replace(/\.js$/, ".mjs");
  return path;
}

const sys: ts.System = {
  ...ts.sys,
  fileExists(path) {
    return ts.sys.fileExists(rewritePath(path)) || ts.sys.fileExists(path);
  },
  readFile(path, encoding) {
    return ts.sys.readFile(rewritePath(path), encoding) ??
      ts.sys.readFile(path, encoding);
  },
  writeFile(path, data, writeBOM) {
    ts.sys.writeFile(rewritePath(path), data, writeBOM);
  },
  deleteFile(path) {
    ts.sys.deleteFile(rewritePath(path));
  }
};

我修改了 fileExists , readFile , writeFile , deleteFile 這四個 method,上面是簡化過的版本,詳細內容可參考原始碼

改寫 Import 路徑

因為輸出檔案的副檔名被改寫了,為了讓 CommonJS 和 ESM 能夠 import 到正確的檔案,必須在 import 路徑加上副檔名。

這個部份我用 transformer 的形式來實作,在 transformer 內,可以把 TypeScript AST 替換成任意的程式碼。以這次的案例來說,我們需要替換的 node 有以下四種。

// ESM import (ImportDeclaration)
import foo from "./foo";

// ESM export (ExportDeclaration)
export foo from "./foo";

// ESM dynamic import (CallExpression)
import("./foo");

// CommonJS require (CallExpression)
require("./foo");

從上面這四種 node 可以取得 import 路徑,如果是相對路徑的話(開頭是 ./../),就是需要修改的路徑。

在 Node.js 裡,import 路徑可能會是檔案或資料夾,但是 ESM 的 import 路徑一定要加上副檔名,所以必須要把資料夾的 import 路徑加上 /index.js 。

// Input
import "./file";
import "./dir";

// Output
import "./file.js";
import "./dir/index.js";

總結來說,可以把修改 import 路徑的部分統整成以下程式碼。

function updateModuleSpecifier(sourceFile: ts.SourceFile, node: ts.Expression) {
  if (!ts.isStringLiteral(node) || !isRelativePath(node.text)) return node;

  if (isDirectory(sourceFile, node.text)) {
    return ts.factory.createStringLiteral(
      `${node.text}/index${options.extname}`
    );
  }

  const ext = extname(node.text);
  const base = ext === ".js" ? trimSuffix(node.text, ".js") : node.text;

  return ts.factory.createStringLiteral(`${base}${options.extname}`);
}

詳細內容可參考原始碼

避免 Race Condition

一開始 tsc-multi 在小規模的專案(例如 Koskokubernetes-models)使用時,都沒有任何異常。可是一旦在 Dcard 這種大規模的 monorepo 使用時,就很容易發生問題。

主要原因是 TypeScript 在使用 Project References 功能時,為了要加快未來編譯的速度,會寫入 .tsbuildinfo 檔案,內容包含了目前的 build state,檔案大小大約會是幾百 KB。

因為 tsc-multi 會同時執行多個 TypeScript compiler,在寫入 TS build info 時,其他 compiler 可能就會剛好讀取寫入到一半的檔案,這種問題在一般的電腦上通常不會發生,但是在 CI 等資源有限的環境下偶爾會觸發。

我的解決方法是變更 tsconfig.json 的 tsBuildInfoFile 設定,讓 TypeScript compiler 不會同時寫入到同一個路徑。

const host = ts.createSolutionBuilderHost();

host.getParsedCommandLine = (path: string) => {
  const config = ts.getParsedCommandLineOfConfigFile(path, {}, ts.sys);

  config.options.tsBuildInfoFile = `${basePath}${data.extname}.tsbuildinfo`;

  return config;
};

詳細內容可參考原始碼