Rustで書かれたBiomeがNode.jsで動く仕組み
はじめに
Rust で自作したパッケージを Node.js のプロジェクトで動かしてみたくなりました。
同じく Rust で書かれた Biome ではどのように動かしているのだろう?と気になり調べてみました。
執筆時点(2025/03/15)での main ブランチのコードを読んでみたいと思います。
結論
今回参考にした Biome では、
「各 OS 向けにビルドしたバイナリを Node.js のchild_process.spawnSync()
で実行」していました。
具体的には、
- 各 OS 向けにバイナリファイルのビルドを行う
- 各 OS 向けの
package.json
を生成する -
npm install
時に、実行環境にあったバイナリをダウンロードする -
npx biome
コマンドを実行した際に、child_process.spawnSync()
経由でバイナリを実行する
というような流れになっています。
GitHub Actions でビルド
まず、GitHub Actions でバイナリファイルのビルドを行います。
strategy.matrixを使って、各 OS 向けの設定を変数化します。
上記で設定したmatrix
の値を--target ${{ matrix.target }}
のように指定して各 OS 向けのバイナリを生成します。
例えばtarget
がaarch64-apple-darwin
の場合、
cargo build -p biome_cli --release --target aarch64-apple-darwin
が実行されます。
ビルドしたバイナリはtarget/${{ matrix.target }}
に配置されます。
target/${{ matrix.target }}
配下にはビルドしたバイナリ以外にも様々なファイルが生成されるため、
./dist/biome-${{ matrix.code-target }}
のようにリネームし、./dist
配下にバイナリファイルを集めます。
これを、actions/upload-artifactを使ってアップロードすることで、
後続のジョブでビルド済みのバイナリを参照できるようにします。
package.json
を生成
各 OS 向けの続いて、npm
に公開するため、各 OS 向けのpackage.json
を生成します。
各 OS 向けのpackage.json
は、GitHub Actions の中でpackages/@biomejs/biome/scripts/generate-packages.mjs
を実行することで生成されます。
上記のスクリプトでは、まずベースとなるpackages/@biomejs/biome/package.json
を読み込み、
各 OS 用のpackage.json
を生成します。
ここで、manifestPath
は@biomejs/<packageName>/package.json
というパスになります。
例えば Apple Silicon の Mac 向けの場合、@biomejs/cli-darwin-arm64/package.json
という値になります。
続いて、先ほどアップロードしたバイナリをactions/download-artifactでダウンロードし、
先ほどの@biomejs/<packageName>
配下にコピーします。
その際に、実行権限の付与も行います。
これで、各 OS 向けのディレクトリ配下には、
package.json
とバイナリが配置されました。
あとは各ディレクトリをループで回してnpm
に公開すれば準備完了です。
optionalDependencies
で実行環境に合わせたバイナリをダウンロード
ここまでで、各 OS に向けたpackage.json
とバイナリが配置されました。
ここからは、実際にnpm install
した際の挙動を確認します。
biome
は、下記のコマンドを実行することでダウンロードすることができます。
npm i -D @biomejs/biome
上記のコマンドを実行すると、node_modules
配下の@biomejs/biome
は下記のような形になります。
node_modules
├── .bin
│ └── biome -> ../@biomejs/biome/bin/biome
├── .package-lock.json
└── @biomejs
├── biome
│ ├── LICENSE-APACHE
│ ├── LICENSE-MIT
│ ├── README.hi.md
│ ├── README.ja.md
│ ├── README.kr.md
│ ├── README.md
│ ├── README.pt-BR.md
│ ├── README.zh-CN.md
│ ├── README.zh-TW.md
│ ├── ROME-LICENSE-MIT
│ ├── bin
│ │ └── biome
│ ├── configuration_schema.json
│ ├── package.json
│ └── scripts
│ └── postinstall.js
└── cli-darwin-arm64
├── biome
└── package.json
今回私は Apple Silicon の Mac で実行したため、
biome
とcli-darwin-arm64
というディレクトリが作成されています。
(.bin
については後述します。)
npm i -D @biomejs/biome
を実行した際、
まず下記のパッケージが参照されます。
続いて、上記のパッケージのpackage.json
(= @biomejs/biome/package.json
)の
optionalDependencies
に登録されている値(= 各 OS 向けに生成したpackage.json
)を確認します。
package.json
のos
やcpu
の項目を確認し、
実行環境と一致するパッケージのみが追加でダウンロードされ、
それ以外のパッケージは無視されます。
(この時、実行環境に一致しないパッケージがあってもエラーにならないようにoptionalDependencies
になっているのだと思います)
npx biome
コマンドを実行
最後に、
npx biome
した際の挙動を確認します。
ダウンロードする際は、@biomejs/biome
という名前でダウンロードしたのに、
npx biome xxx
として実行できるのはなぜでしょうか?
先ほどのnode_modules
配下を改めて見ると、
下記の通り.bin/biome
が@biomejs/biome/bin/biome
へのシンボリックリンクになっていることが確認できます。
node_modules
├── .bin
│ └── biome -> ../@biomejs/biome/bin/biome
├── .package-lock.json
└── @biomejs
├── biome
│ ├── LICENSE-APACHE
│ ├── LICENSE-MIT
│ ├── README.hi.md
│ ├── README.ja.md
│ ├── README.kr.md
│ ├── README.md
│ ├── README.pt-BR.md
│ ├── README.zh-CN.md
│ ├── README.zh-TW.md
│ ├── ROME-LICENSE-MIT
│ ├── bin
│ │ └── biome
│ ├── configuration_schema.json
│ ├── package.json
│ └── scripts
│ └── postinstall.js
└── cli-darwin-arm64
├── biome
└── package.json
これは、package.json
のbin
に記載されているキー名が、
.bin
配下の名前として登録できるためです。
実際にpackage.json
を確認すると、"biome": "bin/biome"
という値で登録されていることがわかります。
さらに、bin/biome
のファイルを確認すると、下記のようになっています。
簡単に処理内容を説明すると、
- Node.js の
process.platform
やprocess.arch
の値から、バイナリのパスを判定する - 判定したパスのバイナリを
child_process.spawnSync()
で実行する
という処理を行なっています。
まとめると、
-
npx biome
を実行する -
node_modules/.bin/biome
のシンボリックリンクを参照する - シンボリックリンクの参照先の
@biomejs/biome/bin/biome
が実行される -
@biomejs/biome/bin/biome
の中でバイナリをパスを判定する - 判定したバイナリを
child_process.spawnSync()
で実行する
という流れになっています。
おわりに
今回は Rust で書かれた Biome が、Node.js のプロジェクトでどのように実行されているかを調べてみました。
気になっていたことが解消されただけでなく、
GitHub Actions の書き方や自動でリリースノートを作成する方法など、
学びになる部分がたくさんありました。
今回 Cursor ちゃんと一緒にコードリーディングを行いましたが、
改めて LLM の便利さを実感しました。
コードリーディングへのハードルがかなり下がったため、
今後も機会があれば、気になったリポジトリを読んでみようと思います。
Discussion