zudo-paper

TauriでNode.jsベースのWebアプリをラップするときのTips集

Author: Takazudo | 作成: 2026/03/19
注記

以下は開発セッションで得た知見をAI(Claude)がまとめたものです。Tauri v2Astro製ドキュメントサイトをmacOSネイティブアプリとしてラップするプロジェクトにおいて、実際に遭遇した問題と解決策を記録しています。

この記事で紹介しているコードやスキルは claude-resources リポジトリで公開している。記事中のリンクは執筆時点のコミットに固定してある。最新版とは異なる場合がある。

概要

Tauri v2でAstro製ドキュメントサイト(Claude Code設定ビューア)をmacOSアプリとしてラップした。最初はpnpm dev:stableをシェル経由で起動する方式だったが、Finderからの起動でPATHが通らない問題があり、Node.jsバイナリをTauriのexternalBinとしてバンドルする自己完結型に移行した。

その過程で色々な問題に遭遇して解決策を見つけたので、Tips集としてまとめたもの。各Tipsは独立して読める構成にしてある。

このアプリで閲覧しているのは、Claude Codeのリソース(CLAUDE.md、コマンド、スキル、エージェント)をドキュメントサイトとして表示するビューア。以下がそのスクリーンショット。

スキルページの表示例

起動時はAstroビルドが走るため、ローディング画面が表示される。

ビルド中のローディング画面

シェル経由のサイドカー起動を避ける

最初のアプローチは/bin/zsh -c "source ~/.zshrc; pnpm dev:stable"でサイドカーを起動する方式だった。

// Before: シェル経由(Finderから起動すると壊れる)
Command::new("/bin/zsh")
    .args(["-c", "source ~/.zshrc 2>/dev/null; pnpm dev:stable"])

ターミナルからcargo tauri devで開発している間は動く。ただしFinderから.appをダブルクリックして起動すると、GUIアプリはシェルのPATHを継承しないため、pnpmが見つからない。/bin/zsh -l -cでログインシェルにしても、nodenvやpnpmのパスが通らないケースがある。

シェルの設定に依存する起動方式は、開発環境では動くが配布や他のマシンでの利用を考えると脆い。

Node.jsバイナリをexternalBinとしてバンドルする

pnm/nodenv/.zshrc依存を排除するために、Node.js公式バイナリをTauriのexternalBinとしてバンドルする方式に移行した。

app/scripts/download-node.shでNode.js公式サイトからバイナリをダウンロードする。SHA256チェックサム検証つき。

// tauri.conf.json
{
  "bundle": {
    "externalBin": ["binaries/node"]
  }
}

バイナリはapp/binaries/node-aarch64-apple-darwinに配置する(gitignored、約112MB)。

これでpnm、nodenv、.zshrc、シェル設定に一切依存しない自己完結型のアプリになる。

dev vs productionでのバイナリ名の違い

Tauriはdev時とproduction bundle時でexternalBinの命名規則が異なる。

  • dev時: node-aarch64-apple-darwin(ターゲットトリプルつき)
  • production bundle時: node(トリプルなし)

両方に対応するコードはこうなる。

fn node_binary_path() -> std::path::PathBuf {
    let exe = std::env::current_exe().expect("Failed to get current exe path");
    let dir = exe.parent().expect("Failed to get exe directory");
    // Dev mode: Tauri keeps the target triple
    let target_triple = format!("{}-apple-darwin", std::env::consts::ARCH);
    let dev_path = dir.join(format!("node-{}", target_triple));
    if dev_path.exists() {
        return dev_path;
    }
    // Production bundle: Tauri strips the triple
    dir.join("node")
}

dev時のパスを先にチェックして、存在すればそれを使う。なければproduction用のパスにフォールバックする。

サイドカー起動からpnpmを排除する

Node.jsバイナリを直接バンドルしたら、起動コマンドからもpnmを排除する必要がある。

Rustサイドの起動コマンドはこう変わる。

// Before: シェル経由でpnmを呼ぶ
Command::new("/bin/zsh")
    .args(["-c", "source ~/.zshrc 2>/dev/null; pnpm dev:stable"])

// After: バンドルしたNode.jsバイナリで直接実行
Command::new(&node_binary_path())
    .args(["scripts/dev-stable.js"])
    .current_dir(&doc_dir)

dev-stable.js内部でも、pnm経由のコマンド呼び出しを直接パスに置き換える。

// Before: pnm経由でAstro CLIを呼ぶ
spawn("pnpm", ["exec", "astro", "build", "--outDir", "dist_tmp"]);

// After: node_modulesのバイナリを直接指定
const astroBin = join(ROOT, "node_modules", "astro", "astro.js");
spawn(process.execPath, [astroBin, "build", "--outDir", "dist_tmp"]);

process.execPathを使うと、起動に使われたNode.jsバイナリのパスが取得できる。これでpnm依存が完全になくなる。

ポート占有の二重キル

アプリがクラッシュしたり強制終了されたりすると、前回のサーバープロセスがポートを掴んだまま残る。新しいインスタンスがポートをバインドできない。

これに対してRustサイドとJSサイドの両方でポートキルを実装する。Belt and suspenders方式。

Rustサイドではspawn前にSIGTERMを送る。

fn kill_port() {
    if let Ok(output) = Command::new("/usr/bin/lsof")
        .args(["-ti", &format!(":{PORT}")])
        .output()
    {
        // parse PIDs, send SIGTERM, wait 500ms
    }
}

JSサイドでもサーバー起動前に同じことをやる。

なぜ両方でやるかというと、Rustのkill_portが実行されるタイミングとJSプロセスが起動するタイミングにラグがあるため。Rustがkillした後、JSが起動するまでの間に別のプロセスがポートを掴む可能性はほぼないが、どちらか片方が失敗した場合のフォールバックとして機能する。

SSEライブリロード

ファイル変更→自動リビルド→ブラウザ自動更新のワークフローをSSE(Server-Sent Events)で実装した。

SSEはWebSocketより構成がシンプル。サーバーからクライアントへの一方通行の通信で、ライブリロードにはこれで十分。

サーバーサイドでは/___eventsというSSEエンドポイントを用意する。クライアントはこのエンドポイントに接続して、イベントを待つ。

送信するイベントは2種類。

  • building — ビルド開始時に送信。クライアントはスピナーオーバーレイを表示する(80x80px、右下固定)
  • rebuild — ビルド完了時に送信。クライアントはlocation.reload()でページを更新する

CLAUDE.mdを編集すると、右下にスピナーが表示され、リビルド完了後にページが自動更新される。

CLAUDE.md編集時のライブリロードスピナー

クライアントサイドのスクリプトは、HTMLレスポンスの</body>の前に自動注入する。サーバーがHTMLを配信する際に、レスポンスを書き換えてスクリプトを埋め込む方式。

ビルドが失敗した場合もSSEでbroadcastしてスピナーを消す。ビルド失敗時にスピナーが表示されたままだとブラウザが固まっているように見えるため。

wait_for_buildでWindow作成を待つ

Rustサイドでサイドカーを起動した後、ビルドが完了するまでWindow作成を待つ必要がある。curlポーリングでシンプルに実装できる。

wait_for_build(Duration::from_secs(120));
WebviewWindowBuilder::new(app, "main", WebviewUrl::External(url))
    .title("Claude Resources")
    .build()?;

wait_for_build()/usr/bin/curlでlocalhostにリクエストを送り、200が返るまでループする。IPC不要のシンプルなアプローチ。

タイムアウトを120秒に設定しているのは、初回ビルドが遅い場合があるため。通常は数秒で完了するが、Content Collectionが大きいと時間がかかる。

FAQ

ポート占有キルはアプリがクラッシュした場合でも機能する?

機能する。Tauriアプリ(Rustプロセス)がクラッシュしても、Node.jsサイドカーは自分自身のプロセスグループ(process_group(0)で作成)で動いている。OSは親プロセスがクラッシュしても子プロセスグループを自動的にkillしない。そのためNode.jsプロセスはポート4892を掴んだまま孤児プロセスとして残る。

次回のアプリ起動時、Rustのkill_port()lsof -ti :4892を実行すると、この孤児プロセスが見つかり、SIGTERMでkillされる。正常終了時はkill_sidecar()がプロセスグループにSIGTERMを送るので問題ない。kill_port()は主にクラッシュ時の保険として機能している。

SSEとは何?ポーリングやWebSocketとの違いは?

SSE(Server-Sent Events)はブラウザネイティブの仕様(HTML5の一部)。サーバーからブラウザへの一方通行の通信チャネル。

ポーリングSSEWebSocket
方向クライアント→サーバー(繰り返し)サーバー→クライアント双方向
接続毎回新しいHTTPリクエスト単一の長寿命HTTP接続ws://にアップグレード
ブラウザAPIfetch/setIntervalEventSourceWebSocket
再接続手動自動(組み込み)手動
複雑さ最もシンプルシンプル最も複雑

SSEはポーリングなしでサーバーからの通知を待てる?

待てる。フロントエンドはポーリングループなしで、サーバーからの通知をただ待つだけ。仕組みはこうなっている。

ブラウザは通常のGETリクエストを送る。

GET /___events HTTP/1.1

サーバーはレスポンスを返すが、接続を閉じない。

HTTP/1.1 200 OK
Content-Type: text/event-stream    ← このヘッダーがキー
Cache-Control: no-cache
Connection: keep-alive

data: connected                    ← 最初のメッセージ、即座に送信

                                   ← ...沈黙、接続は開いたまま...
                                   ← ...数分経過、何も起こらない...

event: building                    ← サーバーが好きなタイミングで書き込む
data: start

event: rebuild                     ← これも、数秒/数分後に
data: 1

ポイントはres.end()呼ばれないこと。resオブジェクトは開いたまま保持される。サーバーはその参照を保持し、言いたいことがあるときにres.write()を呼ぶ。

// サーバーは接続中の全クライアントを保持
const sseClients = new Set();

// GET /___events — レスポンスを返すが閉じない
res.writeHead(200, { "Content-Type": "text/event-stream" });
sseClients.add(res);
req.on("close", () => sseClients.delete(res));

// 後で、リビルドが完了したら — 全ての開いている接続に書き込む
for (const res of sseClients) {
  res.write("event: rebuild\ndata: 1\n\n");
}

つまり通常のHTTPレスポンスと同じ — 同じヘッダー、同じTCP接続 — サーバーが接続を開いたまま、好きなときにデータを書き込むだけ。text/event-streamのContent-Typeがブラウザに対して、このストリームを通常のレスポンスボディではなくイベントとしてパースするよう指示する。

まとめ

Tauri v2 + バンドルNode.jsの組み合わせで、pnm/nodenv/zshrcに一切依存しない自己完結型のmacOSアプリが作れた。SSEライブリロードにより、設定ファイル編集→自動リビルド→ブラウザ自動更新のワークフローも実現できている。

各Tipsの要点を並べるとこうなる。

  • シェル経由(zsh -c)のサイドカー起動は避ける — Finderからの起動でPATHが通らない
  • Node.jsバイナリを直接バンドルすれば環境依存ゼロ
  • Tauriのdev/production間でバイナリ名が変わるので、両方に対応するコードが必要
  • ポートキルはRust・JS両方で実施する(belt and suspenders)
  • SSEはWebSocketより簡素でライブリロードに向いている — ポーリング不要でサーバーから通知を待てる
  • ビルド失敗時もSSE broadcastしてスピナーを消す

今回作成したTauriアプリや関連するスキル・スクリプトは claude-resources リポジトリで公開している。主な関連ファイルは以下。