Secretlint v7でCommonJS からES Modulesへの移行を行いました。

この記事では、CommonJS(CJS)からES Modules(ESM)への移行を行った経緯と、移行する方法について紹介します。

CJSからESMへの移行は、率直に言えば単調な作業で、メリットが見えにくい作業です。 しかし、将来的にCJSよりもESMが主流になることは間違いないので、移行することは必要です。 移行の作業は、移行方法が決まれば大部分は機械的な書き換えが可能です。

では、実際にどうやって移行したのかを紹介します。

ESMへの移行の影響は依存元へと連鎖する

Secretlintのリポジトリはmonorepoになっていて、だいたい40コぐらいのパッケージが含まれています。 そしてパッケージ間で依存関係があります。

たとえば、次のような依存関係がmonorepo内にあります。

  • @secretlint/quick-startsecretlintパッケージをラップしたものなので、secretlintに依存
  • secretlintは、Node.js向けのAPIを持つ@secretlint/nodeに依存
  • @secretlint/nodeは、SecretlintのコアAPIを持つ@secretlint/coreに依存
  • @secretlint/coreは、パッケージ間で参照する型定義だけをまとめた@secretlint/typesに依存

Secretlint Dependencies Tree

ESMへの移行は、必然的にこの依存関係全体的へ影響します。 なぜなら、ESMのモジュールは基本的にESMのコードからしかインポートできないためです。 そのため、CommonJS(CJS)からESMのモジュールをrequireで読み込むことはできません。

CJSからESMを読む方法としてDynamic Import(import())がありますが、 Dynamic Importは非同期ロードであるため、require(同期処理)で読んでいた場合はコードの書き換えは必要になります。

ESMへの移行は、影響が依存元へと連鎖します。 そのため依存の一番末端(他への依存がない)である@secretlint/typesをESMへと移行すると、typesに依存してるcoreをESMにする必要があります。 coreがESMになるとnodeをESMに必要があり といった形で影響が依存元(依存のルート)へと連鎖していきます。

この影響の連鎖を止める方法としては、Dual packages(ESMとCJSどちらの形式も出力する)か先ほど紹介していたDynamic Importで依存を読み込む方法です。

このようにESMへの移行は、依存の末端からやると自動的に依存元(読み込む側)へと影響してしまいます。 一方で、Node.jsではESMからCJSを読み込みことはできます。 そのため、@secretlint/quick-startをESMにしても、CJSであるsecretlintは読みことが可能です。

これは、依存のルートと末端どちらから移行していくかを考えるのに重要です。

ESMへの移行はボトムアップではなく、トップダウン

先ほども紹介したように、ESMへの依存を依存関係の末端からルートへとボトムアップで行うと、ESM化への影響が自動的に連鎖します。 そのため、@secretlint/typesをESMにすると、typesに依存した全てのパッケージを書き換えるなどしないとテストすら実行できません。 一方で、依存関係のルートから末端へとトップダウンで行うと、パッケージを1つずつESMへと移行していくことが可能です。

ボトムアップの場合

@secretlint/typesをESM化すると、一度に全てのパッケージをESMに変更するなどの対応が必要になる。

トップダウンの場合

どこからも依存されていない@secretlint/quick-startからESMへ移行することで、パッケージを徐々に移行できる。

一般的なリファクタリングでは、依存の末端から同じインターフェースを保って移行することが多いと思います。 しかし、ESMでは同じインターフェースを保つ = Dual Packageなので難易度がとても高くメンテナンスコストも高いです(少なくてもbundlerなどのビルドツールが必要です)。

Secretlintでは、ESMへの移行が目標であったため、トップダウンアプローチを採用することしました。 こういった移行をトップダウンアプローチでやるのは珍しい気がしていて、Node.jsはESMからはCJSを読み込める互換レイヤーがあるため可能になっています。

実際にトップダウンでパッケージを1つずつESMへと移行しながら書き換えを進めました。

依存ツリーの作成

ボトムアップ、トップダウンどちらの方法でやるにしても、依存ツリーの把握は必須です。 monorepoではnpmやyarnなどのworkspaces機能を使っていることがほとんどだと思います。

Nxnx graphコマンドを使うことで、workspacesの依存関係から依存ツリーの図を作成できます。 依存ツリーをインタラクティブに見れるのと、JSONなども取得できるので、Nxを使ってなくてもnx graphコマンドは便利です。

# nxを使ってなくてもworkspacesの設定があれば動きます
$ npx nx graph

Secretlint Dependencies Tree

この依存ツリーから、トップダウンアプローチではどういう順番で書き換えていけば、1つのパッケージを書き換えた時点で動作する状態になるのか作業順を作成します。

実際にやった作業順は次のようなツリーになっています。

  1. @secretlint/quick-start
  2. secretlint
  3. node/tester
  4. formatter/node/config-creator/messages-to-markdown/config-loader/core (ここはどれからでもOK)
  5. types/profile

それぞれのステップでのパッケージをESMにした時点で、全体のテストが通る(Secretlintとして配布できる)状態でマイグレーションしていきました。

ESMの段階的な移行の境界線

トップダウンアプローチで、secretlintのコマンドを構成するパッケージは移行できそうでした。 しかし、Secretlintのリポジトリはmonorepoにはルールも一緒に含まれています。

先ほどの依存関係には含めていませんでしたが、ルールを含めた依存ツリーは膨大です。

SecretlintのルールはAWS/GCP/npmのクレデンシャルの検知などさまざまなものがあります。 ルールの数が多いため、コアであるパッケージとルールであるパッケージは、別々でESMへの移行を進めようと思いました。 (両方をまとめて移行し切るのは大変なので、区切りをつけたかった)

このコアパッケージとルールのパッケージの依存関係をよく観察してみると、ルールはコアパッケージの中でも@secretlint/tester@secretlint/typesだけに依存していることがわかりました。

つまり、ルールとコアの境界は@secretlint/tester@secretlint/typesにあります。 この境界線となるパッケージをどうにかすれば、コアの移行とルールの移行を分離できそうです。

基本的にはESMのパッケージはESMのコードからしか読み込ません。 例外として、Dual packages(ESMとCJSどちらの形式も出力する)かDynamic Importがあります。

ここでは、この境界線となるパッケージの読み込みに、Dual packagesとDynamic Importを使うことで、コアとルールの移行を切り離しました。

具体的には、次のような対応をしています。

Dual packagesに対応する

@secretlint/typesは、monorepo内で共有する型定義を配布するパッケージです。 中身は型定義だけです。

普通のパッケージをDual packagesにするにはPackemonのようなビルドツールでTranspileしないと難しいですが、型定義だけならESMの出力とCJSの出力のtsconfig.jsonを用意するだけで可能です。

ESMとCJSのtsconfigを用意し、tsconfig-to-dual-packageを使うことでDual Pacakgeとして配布できます。

Dual PackageはCJSとESMどちらのパッケージからも読み込めるため、Dual Package化した@secretlint/typesはESMに移行する前のルールからも読み込めるようになりました。

Dynamic ImportでESMを読み込む

@secretlint/testerはルールのテストを実行するためのライブラリです。 @secretlint/testerとMochaを使ってスナップショットテストをするためのUtility的なものになっています。

そのため@secretlint/testerをESMにすると、全てのルールをESMにしないとルールのテストが動かなくなります。 まとめてルールのテストを移行するのは大変そうであったため、まずはCJSのままESM化した@secretlint/testerを使うように移行しました。

ここで利用したのは、CJSからはESMのパッケージをDynamic Importで読むという方法です。

次のようにCJSからrequireでESMのパッケージは読み込めません。

const pkg = require("esm-pkg");

一方で、Dynamic Importを使う場合は、CJSからESMのパッケージを読み込めます。 ただしDynamic Importは非同期処理であるため、CJSの処理も非同期にする必要があります。

(async function (){
    const pkg = await import("esm-pkg");
})();

Dynamic Importを利用することで、ルールのテストコードはCJSのままでESMのパッケージに変更した@secretlint/testerを利用できるようにしました。

具体的なDiffは次のようになります。

- const { snapshot } = require("@secretlint/tester");
const path = require("path");
const { creator: rule } = require("../src/index");
+ const test = require("node:test");
- describe("@secretlint/secretlint-rule-example", () => {
+ test("@secretlint/secretlint-rule-example", async (t) => {
+      const snapshot = (await import("@secretlint/tester")).snapshot;
-      snapshot({
+      await snapshot({
          defaultConfig: {
              rules: [
                  {
                      id: "@secretlint/secretlint-rule-preset-canary",
                      rule,
                      rules: [],
                      options: {},
                  },
              ],
          },
          updateSnapshot: !!process.env.UPDATE_SNAPSHOT,
          snapshotDirectory: path.join(__dirname, "snapshots"),
      }).forEach((name, test) => {
-          it(name, async function () {
+          return it(name, async (context) => {
              const status = await test();
              if (status === "skip") {
-                  this.skip();
+                  context.skip();
              }
          });
      });
  });

ここでは、Mochaからnode:test(Node.js Test runner)ベースのテストにしています。 APIはそこまで大きく変わらないので、文字列置換だけで変更できました。

Mochaではなくnode:testに変更したのは、非同期で初期化処理を行う必要があったためです。 MochaのdescribeはAsyncに対応していないため、非同期処理が必要な動的なテスト生成はできませんでした。

node:testではAsyncに対応しているため、Sub Testsを動的に生成することができます。

ここまでの移行をコアパッケージの移行として行いました。

その後、ルールのパッケージを同じようにESMに移行しています。

CJSからESMへコードを書き換える

ESMへの移行とは具体的にどういうことをやるのかを紹介します。

CJSでは使えて、ESMでは使えない機能がいくつかあります。 コード的にはそれを書き換えることと、package.jsontype=moduleを設定することでESMなパッケージとして配布できます。

変更の9割ぐらいは、importするファイルパスに.jsを追加するのと、__dirnameimport.meta.urlに変更だと思います。

CJSでは、requireするファイルの拡張子は省略可能でしたが、ESMはimportするファイルの拡張子が必須になります。

import assert from "assert"; // モジュール
import foo from "./foo.js"; // 相対パス
import type Foo from "./foo"; // import type - typeは省略可能

CJSでは、__dirname__filenameが利用可能でした。

__dirname; // スクリプトのディレクトリパス
__filename; // スクリプトのファイルパス

ESMには、これらはないためimport.meta.urlから定義するかnew URL("./path", import.meta.url)のようにURLオブジェクトを使うように変更が必要です。

import url from "url";
const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

また、Node.jsのnode:fsなどはURLオブジェクトそのものに対応してるケースも多いため、 URLオブジェクトを引数として受け取るように変更するといった変更も可能です。

- path.join(__dirname, "./fixtures/");
// pathモジュールを使わなくても良くなる
+ new URL("./fixtures/", import.meta.url);

これらの書き換えは機械的に検知でき、書き換えも大部分は可能です。 実際に次のツールを使って検知と書き換えを行いました。

CJSからESMのパッケージに変換する

CJSからESMのパッケージにするには、package.jsonの書き換えやtsconfig.jsonの出力の書き換えなども必要です。 Secretlintのリポジトリでは、それぞれのパッケージはほぼ同じ構成だったので、次のようなスクリプトを回して変換しました。

#!/bin/bash

#
# TypeScriptのESMのみのプロジェクトに変換する
# - TypeScript から TypeScript ESMへの変更
declare scriptDir=$(cd $(dirname ${BASH_SOURCE:-$0}); pwd)
declare currentDir=$(pwd)
declare currentDirName=$(basename "${currentDir}")
declare entryFileName=$(npe main | xargs -I{} basename {} .js)
# Install
echo_message "npm install"
function installDev() {
  local isNpm=$(test -e "${currentDir}/package-lock.json" && echo "true" || echo "false")
  if [[ "$isNpm" == "true" ]]; then
    npm install -D "$@"
  else
    yarn add --dev "$@"
  fi
}
function uninstall() {
  local isNpm=$(test -e "${currentDir}/package-lock.json" && echo "true" || echo "false")
  # iterate $@ and delete each package from dependencies/devDependencies/peerDependencies
  for depName in "$@"; do
    npm pkg delete "dependencies.$depName"
    npm pkg delete "devDependencies.$depName"
    npm pkg delete "peerDependencies.$depName"
  done
  if [[ "$isNpm" == "true" ]]; then
    npm install --ignore-scripts
  else
    yarn install --ignore-scripts
  fi
}
function echo_message(){
  echo "\033[31m=>\033[0m \033[036m$1\033[0m"
}
installDev typescript ts-node mocha @types/node @types/mocha || true
uninstall ts-node-test-register tsconfig-to-dual-package || true
# Copy config
cp "${scriptDir}/resources/dual-package/.mocharc.json" ./.mocharc.json
cp "${scriptDir}/resources/dual-package/tsconfig.json" ./tsconfig.json
rm -rf ./test/tsconfig.json # ts-node-test-registerの設定を削除
rm -rf tsconfig.module.json # module対応していた場合
rm -rf tsconfig.cjs.json    # cjs対応していた場合
# Edit package.json
## Add script
echo_message "Add npm run-script"
npe scripts.build "tsc --build"
npe scripts.clean "tsc --build --clean"
npe scripts.watch "tsc --build --watch"
npe scripts.prepublishOnly "npm run clean && npm run build"
npe scripts.test "mocha"
# update fields
npm pkg set "type"="module"
npm pkg set "main"="./module/${entryFileName}.js"
npm pkg set "types"="./module/${entryFileName}.d.ts"
npm pkg set "exports[.].import.types"="./module/${entryFileName}.d.ts"
npm pkg set "exports[.].import.default"="./module/${entryFileName}.js"
npm pkg set "exports[.].default"="./module/${entryFileName}.js"
npm pkg set "exports[./package.json]"="./package.json"
npe files "bin/, module/, src/"
npx @turbo/codemod add-package-manager --force
sort-package-json
# git
git add .

ツールやスクリプトを使うことで9割ぐらいは、機械的に変換できます。 とにかく数が多いので、機械的に変換できるものは機械的に変換していきました。

Tips: monorepoを移行するときはturborepoとかでキャッシュを効かせて、常に全体をnpm testして進めるのが楽

1つのパッケージを書き換えたSecretlint全体のテストが通るかを確認しながら進めました。

この時に、Nx/Turborepo/moonなどのmonorepo向けのツールを使っておくと、パッケージ間のタスクの結果をキャッシュできます。 それによって、npm testで全体のテストを実行しても、変更したパッケージに関係あるテストのみが実行できます。

これによって、全体のテストを実行するという1つの方法で、パッケージを徐々に書き換えていくことが簡単になります。 ESMへのパッケージに影響が連鎖しやすいので、全体のテストを常に走らせるというのは、問題が起きた時にすぐ気付けるので重要です。

SecretlintではTurborepoを使っていたので、元からその仕組みがありましたが、今回の移行ではかなりこの仕組みがスピードに貢献したと思います。 実際に40コぐらいパッケージがありましたが、1日とちょっとで全て移行し終わっています。

Tips: Rollup commonjsプラグインとNode.js ESMのCJSの挙動は異なる

RollupはモジュールをESMとして扱います。 そのためデフォルトではCJSを扱えません。 そのため、bundle依存のどこかにCJSがある場合は@rollup/plugin-commonjsを使う必要があります。

しかし、この@rollup/plugin-commonjsとNode.js ESMからCJSを読み込む挙動はデフォルトでは互換性がありません。 どういう違いがあるかというと@rollup/plugin-commonjsは、__esModuleフラグという歴史的なフラグを考慮するのに対して、Node.js ESMは__esModuleフラグを考慮しません。

この挙動の違いによって、Node.jsでESMとして実行できても、rollupでbundleしたものをNode.jsで実行するとエラーになるという現象が起きます。 (.default.defaultの問題といえばわかる人はわかると思います)

この挙動の違いをなくすには、@rollup/plugin-commonjscommonjs({ defaultIsModuleExports: true })オプションを有効化する必要があります。

defaultIsModuleExportstrueにするとNode.js ESMからCJSを読み込んだ時と同じ動作になります。

Secretlintでは@secretlint/secretlint-rule-preset-recommendをRollupでbundleすることで、I/Oを減らすという最適化をしているので、この問題を踏みました。

おわりに

ESMへの移行は、現時点だと面倒で直接的なメリットが少なく、そして面倒臭いです。

ただ工夫すれば段階的な移行はできるので、移行はしていく必要があります。 Secretlintのようにプラガブルな仕組みの場合は、まずはプラグインをESMで書けるようにするところからスタートして、本体をESMへ移行していくというのがよいとは思います。

今回のSecretlintの書き換えで、ESMへの移行の仕方がだいぶ身についた気はします。 実際にやっていることは大部分が自動化できるとは思うので、徐々に移行ツールなども増えていくと思います。 (組み合わせが色々あるので汎用ツールが難しいが、個々のパターンに特化した自動変換は可能)