📝

Rustで書かれたBiomeがNode.jsで動く仕組み

に公開

はじめに

Rust で自作したパッケージを Node.js のプロジェクトで動かしてみたくなりました。
同じく Rust で書かれた Biome ではどのように動かしているのだろう?と気になり調べてみました。

執筆時点(2025/03/15)での main ブランチのコードを読んでみたいと思います。

結論

今回参考にした Biome では、
「各 OS 向けにビルドしたバイナリを Node.js のchild_process.spawnSync()で実行」していました。

具体的には、

  1. 各 OS 向けにバイナリファイルのビルドを行う
  2. 各 OS 向けのpackage.jsonを生成する
  3. npm install時に、実行環境にあったバイナリをダウンロードする
  4. npx biomeコマンドを実行した際に、child_process.spawnSync()経由でバイナリを実行する

というような流れになっています。

GitHub Actions でビルド

まず、GitHub Actions でバイナリファイルのビルドを行います。
strategy.matrixを使って、各 OS 向けの設定を変数化します。
https://github.com/biomejs/biome/blob/5d73b1da50da81004f05c02edf4324c967028c99/.github/workflows/release.yml#L77-L106

上記で設定したmatrixの値を--target ${{ matrix.target }}のように指定して各 OS 向けのバイナリを生成します。
例えばtargetaarch64-apple-darwinの場合、
cargo build -p biome_cli --release --target aarch64-apple-darwinが実行されます。

https://github.com/biomejs/biome/blob/5d73b1da50da81004f05c02edf4324c967028c99/.github/workflows/release.yml#L144-L153

ビルドしたバイナリはtarget/${{ matrix.target }}に配置されます。
target/${{ matrix.target }}配下にはビルドしたバイナリ以外にも様々なファイルが生成されるため、
./dist/biome-${{ matrix.code-target }}のようにリネームし、./dist配下にバイナリファイルを集めます。

https://github.com/biomejs/biome/blob/5d73b1da50da81004f05c02edf4324c967028c99/.github/workflows/release.yml#L155-L165

これを、actions/upload-artifactを使ってアップロードすることで、
後続のジョブでビルド済みのバイナリを参照できるようにします。

https://github.com/biomejs/biome/blob/5d73b1da50da81004f05c02edf4324c967028c99/.github/workflows/release.yml#L167-L173

各 OS 向けのpackage.jsonを生成

続いて、npmに公開するため、各 OS 向けのpackage.jsonを生成します。
各 OS 向けのpackage.jsonは、GitHub Actions の中でpackages/@biomejs/biome/scripts/generate-packages.mjsを実行することで生成されます。

https://github.com/biomejs/biome/blob/5d73b1da50da81004f05c02edf4324c967028c99/packages/%40biomejs/biome/scripts/generate-packages.mjs

上記のスクリプトでは、まずベースとなるpackages/@biomejs/biome/package.jsonを読み込み、
各 OS 用のpackage.jsonを生成します。

https://github.com/biomejs/biome/blob/5d73b1da50da81004f05c02edf4324c967028c99/packages/%40biomejs/biome/scripts/generate-packages.mjs#L25-L51

ここで、manifestPath@biomejs/<packageName>/package.jsonというパスになります。
例えば Apple Silicon の Mac 向けの場合、@biomejs/cli-darwin-arm64/package.jsonという値になります。

続いて、先ほどアップロードしたバイナリをactions/download-artifactでダウンロードし、
先ほどの@biomejs/<packageName>配下にコピーします。
その際に、実行権限の付与も行います。

https://github.com/biomejs/biome/blob/5d73b1da50da81004f05c02edf4324c967028c99/packages/%40biomejs/biome/scripts/generate-packages.mjs#L53-L70

これで、各 OS 向けのディレクトリ配下には、
package.jsonとバイナリが配置されました。

あとは各ディレクトリをループで回してnpmに公開すれば準備完了です。

https://github.com/biomejs/biome/blob/772dcf565d95f14e06bbd12a18afdf38ecdee4d6/.github/workflows/release.yml#L287-L290

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 で実行したため、
biomecli-darwin-arm64というディレクトリが作成されています。
(.binについては後述します。)

npm i -D @biomejs/biomeを実行した際、
まず下記のパッケージが参照されます。
https://www.npmjs.com/package/@biomejs/biome

続いて、上記のパッケージのpackage.json(= @biomejs/biome/package.json)の
optionalDependenciesに登録されている値(= 各 OS 向けに生成したpackage.json)を確認します。

https://github.com/biomejs/biome/blob/772dcf565d95f14e06bbd12a18afdf38ecdee4d6/packages/%40biomejs/biome/package.json#L48-L57

package.jsonoscpuの項目を確認し、
実行環境と一致するパッケージのみが追加でダウンロードされ、
それ以外のパッケージは無視されます。
(この時、実行環境に一致しないパッケージがあってもエラーにならないように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.jsonbinに記載されているキー名が、
.bin配下の名前として登録できるためです。

実際にpackage.jsonを確認すると、"biome": "bin/biome"という値で登録されていることがわかります。

https://github.com/biomejs/biome/blob/772dcf565d95f14e06bbd12a18afdf38ecdee4d6/packages/%40biomejs/biome/package.json#L4-L6

さらに、bin/biomeのファイルを確認すると、下記のようになっています。
https://github.com/biomejs/biome/blob/772dcf565d95f14e06bbd12a18afdf38ecdee4d6/packages/%40biomejs/biome/bin/biome

簡単に処理内容を説明すると、

  • Node.js のprocess.platformprocess.archの値から、バイナリのパスを判定する
  • 判定したパスのバイナリをchild_process.spawnSync()で実行する

という処理を行なっています。

まとめると、

  1. npx biomeを実行する
  2. node_modules/.bin/biomeのシンボリックリンクを参照する
  3. シンボリックリンクの参照先の@biomejs/biome/bin/biomeが実行される
  4. @biomejs/biome/bin/biomeの中でバイナリをパスを判定する
  5. 判定したバイナリをchild_process.spawnSync()で実行する

という流れになっています。

おわりに

今回は Rust で書かれた Biome が、Node.js のプロジェクトでどのように実行されているかを調べてみました。
気になっていたことが解消されただけでなく、
GitHub Actions の書き方や自動でリリースノートを作成する方法など、
学びになる部分がたくさんありました。

今回 Cursor ちゃんと一緒にコードリーディングを行いましたが、
改めて LLM の便利さを実感しました。
コードリーディングへのハードルがかなり下がったため、
今後も機会があれば、気になったリポジトリを読んでみようと思います。

Aidemy Tech Blog

Discussion