同梱した Node.js スクリプトを実行する (SEA)

Node.js のスクリプトを esbuild で 1 ファイルにまとめ、SEA で実行ファイルにしてサイドカーとして同梱する。--build-sea と postject の手順、Command.sidecar() での呼び出しを示す。

外部プロセス 対象: Tauri 2.x 更新日: 読了目安: 約9分 shell-011
目次
  1. 前提条件
  2. Node.js スクリプトを書く
  3. SEA で実行ファイルにする
  4. Node.js 25.5 以降: --build-sea
  5. Node.js 22 / 24: blob を作って postject で注入する
  6. 1. フロントエンドから実装する (TypeScript)
  7. 2. バックエンドから実装する (Rust)
  8. 動作確認
  9. よくあるエラーと対処法
  10. OS ごとの違いと注意点
  11. 関連レシピ

npm パッケージを使った既存の Node.js スクリプトや社内ツールを、Tauri アプリから動かしたいことがあります。WebView の中は Node.js ではないので fs などはそのまま使えず、利用者の PC に Node.js が入っているとも限りません。Node.js の Single Executable Applications(SEA)機能で Node.js 本体とスクリプトを 1 つの実行ファイルにまとめ、サイドカーとして同梱すれば、インストールなしで動かせます。ファイル名の規則や externalBin など、サイドカー自体の基本は 同梱したバイナリファイルを実行する にまとめています。

前提条件

npm run tauri add shell
npm install yaml
npm install --save-dev esbuild postject

postject は Node.js 22 / 24 で使う旧手順でだけ必要です。tauri.conf.json の bundle.externalBin に "binaries/nodetool" を登録し、capability で「1 番目は yaml2json 固定、2 番目は .yaml か .yml で終わる値だけ」を許可します。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    {
      "identifier": "shell:allow-execute",
      "allow": [
        { "name": "binaries/nodetool", "sidecar": true, "args": ["yaml2json", { "validator": ".+\\.ya?ml" }] }
      ]
    }
  ]
}

Node.js スクリプトを書く

YAML ファイルを読み、JSON にして標準出力に書く例です。

// sidecar/tool.js
const fs = require('node:fs');
const YAML = require('yaml'); // npm パッケージ。esbuild で 1 ファイルに取り込む

// SEA でも、利用者が渡した引数は process.argv[2] から入る
const [command, file] = process.argv.slice(2);

if (command === 'yaml2json' && file) {
  try {
    const data = YAML.parse(fs.readFileSync(file, 'utf8'));
    process.stdout.write(JSON.stringify(data) + '\n');
  } catch (e) {
    console.error(e instanceof Error ? e.message : String(e));
    process.exitCode = 1; // process.exit() は使わない
  }
} else {
  console.error('usage: nodetool yaml2json <file.yaml>');
  process.exitCode = 2;
}
  • 終了コードは process.exitCode で返す: process.exit() を呼ぶと、書き終わっていない標準出力が捨てられることがあります。
  • 文字コード: Node.js の標準出力は UTF-8 なので、Python のような Windows での文字化けは起きません。
  • 1 ファイルにまとめる: SEA に入れたスクリプトの require() は、node:fs のような組み込みモジュールしか読めません。npm パッケージは esbuild で取り込みます。
npx esbuild sidecar/tool.js --bundle --platform=node --format=cjs --outfile=sidecar/dist/tool.cjs

SEA で実行ファイルにする

設定ファイルを作ります。disableExperimentalSEAWarning を付けないと、起動のたびに実験的機能だという警告が標準エラー出力に出ます。Windows では output に .exe が必要です。

{
  "main": "sidecar/dist/tool.cjs",
  "output": "sidecar/dist/nodetool.exe",
  "disableExperimentalSEAWarning": true
}

Node.js 25.5 以降: --build-sea

--build-sea 1 回で実行ファイルができます。できたらトリプル付きの名前でコピーします。

# Windows (PowerShell)
node --build-sea sea-config.json
$triple = (rustc -Vv | Select-String "host:").Line.Split(" ")[1]
Copy-Item sidecar/dist/nodetool.exe "src-tauri/binaries/nodetool-$triple.exe"

macOS では output を sidecar/dist/nodetool にし、ビルド後に codesign --sign - sidecar/dist/nodetool で署名し直してからコピーします。

Node.js 22 / 24: blob を作って postject で注入する

--build-sea がない版では、設定の output を blob の出力先(例: sidecar/dist/sea-prep.blob)にして、node 本体のコピーに注入します。blob を作る node と注入先の node は同じ版でなければならないので、実行中の node 自身をコピーします。

# macOS(Linux は codesign の 2 行と --macho-segment-name を除く)
node --experimental-sea-config sea-config.json
cp "$(command -v node)" sidecar/dist/nodetool
codesign --remove-signature sidecar/dist/nodetool
npx postject sidecar/dist/nodetool NODE_SEA_BLOB sidecar/dist/sea-prep.blob \
  --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \
  --macho-segment-name NODE_SEA
codesign --sign - sidecar/dist/nodetool
cp sidecar/dist/nodetool "src-tauri/binaries/nodetool-$(rustc -Vv | grep host | cut -f2 -d' ')"
# Windows (PowerShell)
node --experimental-sea-config sea-config.json
node -e "require('fs').copyFileSync(process.execPath, 'sidecar/dist/nodetool.exe')"
npx postject sidecar/dist/nodetool.exe NODE_SEA_BLOB sidecar/dist/sea-prep.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2
$triple = (rustc -Vv | Select-String "host:").Line.Split(" ")[1]
Copy-Item sidecar/dist/nodetool.exe "src-tauri/binaries/nodetool-$triple.exe"

Windows では署名についての警告が出ますが、実行には影響しません。

1. フロントエンドから実装する (TypeScript)

import { Command } from '@tauri-apps/plugin-shell';

export async function yamlToJson(path: string): Promise<unknown> {
  // 引数は capability の args と同じ並びで渡す
  const output = await Command.sidecar('binaries/nodetool', ['yaml2json', path]).execute();
  if (output.code !== 0) {
    throw new Error(output.stderr.trim() || `nodetool exited with code ${output.code}`);
  }
  return JSON.parse(output.stdout);
}

呼ぶたびに Node.js ごと起動するので、その分の時間がかかります。設定に "useCodeCache": true を足すと起動が少し速くなります。頻繁に呼ぶなら、常駐させて標準入力で依頼を受ける作りにします(Python 版 の常駐の例と同じ形で、行ごとの受け取りは 出力をリアルタイムで受け取る)。

2. バックエンドから実装する (Rust)

Rust からはファイル名 nodetool だけを渡し、JSON は serde_json::Value で受けてそのまま JS に返します。JS 側の capability は不要です。

use tauri_plugin_shell::ShellExt;

#[tauri::command]
async fn yaml_to_json(app: tauri::AppHandle, path: String) -> Result<serde_json::Value, String> {
    let output = app
        .shell()
        .sidecar("nodetool")
        .map_err(|e| e.to_string())?
        .args(["yaml2json", path.as_str()])
        .output()
        .await
        .map_err(|e| e.to_string())?;
    if !output.status.success() {
        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
    }
    serde_json::from_slice(&output.stdout).map_err(|e| e.to_string())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_shell::init())
        .invoke_handler(tauri::generate_handler![yaml_to_json])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';

const data = await invoke<unknown>('yaml_to_json', { path: '/Users/me/config.yaml' });
console.log(data);

動作確認

組み込む前に、できた実行ファイルをターミナルで単体で動かしておくと、失敗したときに原因を切り分けやすくなります。

> sidecar\dist\nodetool.exe yaml2json sample.yaml
{"name":"demo","ports":[8080,8081]}

次に npm run tauri dev で起動し、YAML ファイルの絶対パスを渡して yamlToJson() を呼ぶと、同じ内容がオブジェクトで返ります。

よくあるエラーと対処法

  • モジュールが見つからない趣旨のエラーで終わる: SEA の中では npm パッケージを読めません。main に、esbuild でまとめた tool.cjs を指定しているかを確かめます。
  • yaml2json というファイルが見つからない趣旨のエラーになる: 注入に失敗していて、ただの node として動いています(最初の引数をスクリプト名と解釈する)。--sentinel-fuse の値と postject の出力を確かめます。
  • 標準エラー出力に実験的機能の警告が出る: disableExperimentalSEAWarning: true を付けてビルドし直します。
  • 出力の最後が欠ける: process.exit() で終わらせています。process.exitCode に置き換えます。
  • 「Cannot define a sidecar with the same name as the Cargo package name」: サイドカー名が src-tauri/Cargo.toml のパッケージ名と同じです。app のようなありがちな名前は避けます。名前や権限まわりのほかのエラーは shell-012 の一覧を参照してください。

OS ごとの違いと注意点

  • Windows: 実行ファイル名に .exe が必要です。node 本体の署名は注入で無効になるので、配布時は自分の証明書で署名し直すと警告を減らせます。
  • macOS: 署名の除去と再署名を忘れると起動できません。Node.js のドキュメントでは、SEA のテスト対象は arm64 だけで、x64(Intel)は現在対象外とされています。
  • Linux: 追加の手順はありません。ただし Alpine は対象外です。
  • OS ごとに作る: 注入先の node は配布先の OS 用が必要です。--build-sea なら executable に同じ版の別 OS 用 node を指定して作れますが、その場合は useCodeCache と useSnapshot を false にします。
  • サイズ: Node.js 本体が丸ごと入るので、スクリプトが小さくても数十 MB 以上になります。
  • ネイティブアドオン: .node ファイルを使うパッケージは esbuild で 1 ファイルにまとめられません。SEA の assets に入れて一時ファイルから読み込む手順が必要になるため、まずは純粋な JavaScript のパッケージで組むのが無難です。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する