🌊

Node.jsのSingle Executable Applications (SEA) で作る、配布しやすいNode.js アプリ

に公開

はじめに

Node.js v20 から Single Executable Applications (SEA) が実験的サポートされ、Node.js アプリケーションを単一の実行可能ファイルとして配布できるようになりました。
これにより、Node.js をインストールしていない環境でも Node.js で動作するアプリケーションを簡単に実行できるようになります。

本記事では、SEA を利用して Native Messaging Host を作成し、ブラウザ拡張機能と連携させる例を解説します。

「ブラウザ拡張機能なんて作らないよ!」という方も、SEA としてパッケージングするまでの手順は参考になると思いますので、ぜひ最後までお読みください。

SEA ビルドのフロー

SEA ビルドの基本的なフローは以下の通りです。

  1. sea-config.json を作成し、Node.js に読み込ませるための Blob ファイルを作成
  2. Node.js アプリケーションを CommonJS モジュールとして 1ファイル にまとめる
  3. 実行ファイルのベースとなる Node.js バイナリをコピー
  4. postject を使って、Blob ファイルとアプリケーションコードを Node.js バイナリに注入する

2~4 の手順を繰り返し、アプリケーションの更新を行います。

see: https://nodejs.org/docs/latest/api/single-executable-applications.html

SEA 用の Blob ファイルの作成

まず、SEA 用の設定ファイル sea-config.json を作成します。

{
  "main": "dist/index.cjs",
  "output": "dist/index.blob",
  "disableExperimentalSEAWarning": true
}

main には、アプリケーションのエントリーポイントとなるファイルを指定します。
output には、生成される Blob ファイルのパスを指定します。
今回は disableExperimentalSEAWarning オプションも指定して、アプリケーション実行時に出力される ExperimentalWarning を抑制しています(今回は NativeMessaging を使用するため、stdout が汚されると困るのです…)。

各種フィールドの詳細は公式ドキュメントを参照してください。

https://nodejs.org/docs/latest/api/single-executable-applications.html#generating-single-executable-preparation-blobs

ファイルを用意したら、以下のコマンドで Blob ファイルを生成します。

$ node --experimental-sea-config sea-config.json 

これで dist/index.blob が生成されます。

Node.js アプリケーションのバンドル

ここは通常の Node.js アプリケーションのビルドと同様です。
今回は CommonJS モジュールとしてまとめる必要があるため、esbuild を使用してバンドルします。

$ esbuild src/index.ts --outfile=dist/index.cjs --bundle --platform=node --format=cjs

Node.js バイナリのコピー

次に、Node.js の実行ファイルをコピーします。
公式ドキュメントでは Windows 以外の環境では cp $(command -v node) hello の様に記載されていますが、例えば nodenv などのバージョン管理ツールを使用していると、Node.js の実行ファイルではなく、実行のための ShellScript がコピーされてしまう場合があります。

そのため、以下のように Windows 向けの手順と同様に、直接 Node.js の実行ファイルを指定してコピーすることをお勧めします。

$ node -e "require('fs').copyFileSync(process.execPath, 'hello')" 

上記コマンドでは hello という名前で Node.js の実行ファイルをカレントディレクトリにコピーしています。

postject を使って Blob ファイルを注入

ここまで用意したら、最後に postject を使って Blob ファイルを Node.js バイナリに注入します。

macOS では、これを実行する前に Node.js バイナリの署名の削除の手順が挟まります、が、今回は割愛します。
macOS だと以下のようなコマンドで postject を実行します。

$ npx postject hello NODE_SEA_BLOB dist/index.blob \
    --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \
    --macho-segment-name NODE_SEA 

macOS のみ --macho-segment-name オプションが必要です。

また macOS の場合はその後に実行ファイルに対してバイナリの署名を付与する必要があります。

Makefile による自動化

上記の手順を毎回手動で実行するのは面倒で、かつ macOS の場合は署名の削除や付与の手順も必要になります。

加えて、バンドルした index.cjs の更新が行われたら、index.blob の再生成も必要です。
そのため、一つ更新したらだいたい全部のフローを再度やり直す必要があるのです。

そういう場合に役に立つのが、Make です。
Makefile にまとめて自動化すると便利です。

https://github.com/yamachu/play-native-messaging-with-sea/blob/74d94cc001b42f7a7e6143ace2f252b76d3356fd/native-host/Makefile

Makefile の内容は上記リポジトリを参照してください。

基本的には、コードを書き換えたら make build を実行するだけで、SEA として扱える実行ファイルが生成されます。

Windows や macOS など、プラットフォーム固有の処理も、Makefile 内で分岐させているため、同じコマンドでビルドできます。

例えば署名に関しては、以下のように分岐させています。

_codesign:
ifeq ($(OS),Windows_NT)
# 	signtool sign /fd SHA256 $(EXECUTABLE)
else ifeq ($(shell uname -s),Darwin)
	codesign --sign - $(EXECUTABLE)
endif

_remove_sign:
ifeq ($(OS),Windows_NT)
# 	signtool remove /s $(EXECUTABLE_BASE)
else ifeq ($(shell uname -s),Darwin)
	codesign --remove-signature $(EXECUTABLE_BASE)
endif

…と言いつつも、Windows での動作検証を行っていないため、ぜひ試して、だめだったら Issue に報告したり、Pull Request を送っていただけると嬉しいです。

応用: Native Messaging Host の開発

上記の手順で SEA 化した実行ファイルを、Native Messaging Host として使用してみましょう。

ネイティブメッセージングはユーザーのコンピューターにインストールされたアプリケーションと拡張機能との間のメッセージ交換を可能にします。ネイティブメッセージングにより、拡張機能が追加のウェブを介したアクセスなしにサービスを提供できます。

インストール後、拡張機能はネイティブアプリケーションと JSON メッセージを交換することができます。 runtime API の一連の関数を使用します。ネイティブアプリ側では、メッセージは標準入力 (stdin) を使用して受信し、標準出力 (stdout) を使用して送信します。

引用: https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/Native_messaging

この様に、stdin / stdout を使用して JSON メッセージをやり取りするアプリケーションを作成すれば、Native Messaging Host として動作させることができます。

SEA 化してしまえば、シングルバイナリぽん置きで動作するため、配布も簡単です。
加えて、Node.js で動作するアプリケーションで Native Messaging Host を作成すると、Message の Schema などを共通のコードで共有もしやすいです。

そう考えると、Native Messaging Host の開発に SEA は非常に適していると言えます。

Native Messaging でやり取りするメッセージは以下のようなコードで送受信出来ます。

https://github.com/yamachu/play-native-messaging-with-sea/blob/74d94cc001b42f7a7e6143ace2f252b76d3356fd/native-messaging/src/index.ts

ざっくり説明すると、以下のような感じです。

  1. 標準入力から 4 バイト読み込み、メッセージの長さを取得(LE 符号なし整数)
  2. 取得した長さ分、標準入力からメッセージ本体を読み込み
  3. JSON.parse でオブジェクトに変換
  4. メッセージを処理し、レスポンスオブジェクトを生成
  5. JSON.stringify でレスポンスオブジェクトを文字列化
  6. レスポンスの長さを 4 バイトで標準出力に書き込み
  7. レスポンス本体を標準出力に書き込み

これを繰り返す、という流れです。

動作させてみる

上記の処理を行うコードを SEA 化し、Native Messaging Host として動作させてみましょう。

Native Messaging 用の Manifest ファイルを以下のように用意します。

{
  "name": "com.example.native_host",
  "description": "反転した文字を返すNativeHost",
  "path": "絶対パス/com.example.native_host",
  "type": "stdio",
  "allowed_origins": [
    "chrome-extension://ここにChrome拡張のID/"
  ]
}

path には、SEA 化した実行ファイルの絶対パスを指定します。

今回準備した Makefile の install ターゲットを使用すると、自動的に適切な場所に Manifest ファイルと Native Messaging Host の実行ファイルをコピーしてくれます。(簡単のため、HOME ディレクトリにコピーしています、汚してすまない…)

Chrome 拡張機能は、当該リポジトリの browser-extension ディレクトリ以下に用意しています。

ビルドした拡張機能を chrome://extensions/ の「パッケージ化されていない拡張機能を読み込む」から読み込んでください。

これで Chrome 拡張と Native Messaging Host の連携ができるようになります。

まとめ

SEA を使用すると、Node.js アプリケーションを単一の実行可能ファイルとして配布できるため、Native Messaging Host の開発に非常に適しています。
加えて、SEA は CLI ツールの配布などにも便利に使えます。

とっかかりのビルドフローが煩雑ですが、一度整えてしまえば Makefile などで自動化できるため、以降の開発は楽になります。
ぜひ、SEA を活用して、配布しやすい Node.js アプリケーションを作成してみてください。

現在応用例として、Portability の高い Chrome 拡張として提供する MCP Client を作成しています。
こちらも併せてご覧いただけると嬉しいです。

https://github.com/yamachu/browser-mcp-client

Discussion