TauriとElectronの関心分離: デスクトップアプリのフロントエンド/バックエンド分離戦略
概要
自分はTauri v2でzudo-textというメッセージ作成支援デスクトップアプリを開発している。Rustバックエンド + React/TypeScriptフロントエンドという構成で、バックエンドとの通信を1つのTypeScriptインターフェースに集約した。
このインターフェースに対して3つのアダプターを実装した。本番用のTauri adapter、テスト/開発用のmock adapter、そしてChrome DevToolsでデバッグするためのREST adapter。さらにこのアダプターパターンを起点に、Rust側のユニットテスト90件、TypeScript側のアダプターテスト68件というテスト戦略も構築した。
構想から実装、テスト戦略まで、一連の流れのまとめ。
zudo-textのアーキテクチャ
zudo-textは設定駆動のテキスト生成支援デスクトップアプリで、構成はこうなっている。
- バックエンド: pure Rust(Tauri v2)。ファイルI/O、PTY管理、ファイル監視、すべてRustで実装
- フロントエンド: React + TypeScript。Viteでバンドル
- 通信: Tauri invoke/events(IPC)
Node.jsはメインプロセスにいない。フロントエンドからバックエンドの関数を呼ぶ唯一の手段がTauriのinvoke()で、ファイルの変更通知などのイベントはlisten()で受け取る。
ここで「フロントエンドからRustの関数を直接呼べない」という事実が、設計上の分岐点になる。
BackendAPIインターフェース
フロントエンドからバックエンドへの通信を、1つのTypeScriptインターフェースに集約した。@takazudo/backend-bridgeというパッケージがそれ。
export interface BackendAPI {
messages: {
list: () => Promise<MessageMeta[]>;
listWithBody: () => Promise<MessageWithBody[]>;
read: (filename: string) => Promise<string | null>;
write: (filename: string, content: string) => Promise<boolean>;
delete: (filename: string) => Promise<boolean>;
create: (name: string, content: string) => Promise<string>;
onChanged: (callback: (filename?: string) => void) => () => void;
};
pins: {
list: (pinIndex: number) => Promise<PinEntry[]>;
read: (pinIndex: number, entryPath: string) => Promise<string | null>;
write: (
pinIndex: number,
entryPath: string,
content: string,
) => Promise<boolean>;
// ...
};
settings: {
get: () => Promise<AppSettings | null>;
save: (settings: AppSettings) => Promise<boolean>;
};
// draft, tabs, terminal, workspace, vimrc, dialog...
}
メッセージの読み書き、ピン(サイドバーに表示するコンテンツディレクトリ)の管理、設定の取得・保存、ターミナル操作、ダイアログ表示など、バックエンドが提供するすべての機能がこのインターフェースに定義されている。
フロントエンドのReactコンポーネントは、このインターフェースだけを知っている。その裏側が何なのか — Tauriのinvoke()なのか、インメモリのモックなのか、HTTPリクエストなのか — は知らない。
Tauri adapter: 本番用
本番環境ではTauriのinvoke()とlisten()を使ったアダプターが動く。
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
export function createTauriAdapter(): BackendAPI {
return {
messages: {
list: () =>
invoke<MessageMeta[]>("messages_list", { includeBody: false }),
read: (filename) => invoke<string | null>("messages_read", { filename }),
write: (filename, content) =>
invoke<boolean>("messages_write", { filename, content }),
onChanged: (callback) =>
syncListen<{ filename?: string }>("messages:changed", (payload) => {
callback(payload.filename);
}),
},
settings: {
get: () => invoke<AppSettings | null>("settings_get"),
save: (settings) => invoke<boolean>("settings_save", { settings }),
},
// ...
};
}
各メソッドがinvoke()の薄いラッパーになっている。invoke()はTauriのIPC呼び出しで、Rust側の#[tauri::command]で定義された関数を呼び出す。イベントリスナーはlisten()で登録する。
このアダプターはTauriのWebView内でしか動かない。invoke()がTauriのランタイムに依存しているから。
Mock adapter: テスト/開発用
テストやStorybook、Rustコンパイルなしのフロントエンド開発のために、すべてインメモリで動くモックアダプターを用意した。
export function createMockAdapter(options?: MockAdapterOptions): {
api: BackendAPI;
controls: MockControls;
} {
const files = new Map<string, string>();
let settings: AppSettings = options?.settings ?? { ...defaultSettings };
const api: BackendAPI = {
messages: {
list: async () => {
const entries: MessageMeta[] = [];
for (const [filename, content] of files) {
const { meta } = parseFrontmatter(content);
entries.push({
filename,
title: meta.title ?? filename,
/* ... */
});
}
entries.sort((a, b) => b.filename.localeCompare(a.filename));
return entries;
},
read: async (filename) => files.get(filename) ?? null,
write: async (filename, content) => {
files.set(filename, content);
return true;
},
// ...
},
settings: {
get: async () => structuredClone(settings),
save: async (s) => {
settings = s;
return true;
},
},
};
return { api, controls };
}
Mapでファイルを保持し、frontmatterのパースやソートもインメモリで行う。Rustのバックエンドが行っている処理のうち、フロントエンドのテストに必要な範囲をTypeScriptで再実装している形。
MockControlsは、テストやStorybookからイベントを手動発火するためのもの。「ファイルが変更された」というイベントをコードから発火させて、UIの更新をテストできる。
初期化コードの切り替え
本番とモックの切り替えは、エントリーポイントで使うアダプターを変えるだけ。
// main.tsx(本番 — Tauriバックエンド)
import { createTauriAdapter } from "@takazudo/backend-bridge/tauri-adapter";
initBackend(createTauriAdapter());
// main-mock.tsx(開発 — モックバックエンド)
import { createMockAdapter } from "@takazudo/backend-bridge";
initBackend(createMockAdapter().api);
// main-rest.tsx(開発 — RESTバックエンド)
import { createRestAdapter } from "@takazudo/backend-bridge";
initBackend(createRestAdapter());
1行の違い。フロントエンドのコードは一切変更不要。
dev
pnpm dev:mockを実行すると、Viteの開発サーバーだけが起動して、mock adapterを使ったアプリがブラウザで動く。
これが何をもたらすかというと以下。
- Rustコンパイルが不要。Tauriの開発サーバーを起動する必要がない。フロントエンドの変更がVite HMRで即座に反映される
- Chrome DevToolsがフルに使える。Network、React DevTools、Performanceパネル。TauriのWebView上ではDevToolsの一部機能に制約がある場合があるが、Chromeならすべて使える
- CIでTauriツールチェーンが不要。フロントエンドのテスト、Storybookのビルド、ビジュアルリグレッションテストをRustなしで実行できる
- 新しくフロントエンドの開発に参加する人がRust環境を構築せずに作業を開始できる
実装としては、vite.config.mock.tsでindex-mock.htmlをエントリーポイントに指定し、ポート1421で開発サーバーを起動する。index-mock.htmlはmain-mock.tsxを読み込む。
seed dataの必要性
mock adapterを空の状態で起動すると、設定がnull、メッセージリストが空、ピンツリーが空になる。この状態ではUIが成立しない。設定に依存するカラーテーマやレイアウトが表示されないし、メッセージリストが空だとエディタに何も表示されない。
なのでcreateMockAdapterには初期データを渡せるようにしている。defaultSettingsを初期値として渡し、サンプルメッセージを数件入れることで、アプリを開いた瞬間からリアルな操作感でUIを確認できる。
Storybookでも同じで、各ストーリーごとに異なるseed dataを渡すことで、「メッセージが10件ある状態」「設定がデフォルトの状態」「ピンツリーが深い階層の状態」など、様々なシナリオをテストできる。
Barrel importの問題
mock modeの実装中に遭遇した問題がある。@takazudo/backend-bridgeパッケージのindex.tsがbarrel export(re-export)を使っていて、mock adapterと一緒にTauri adapterもre-exportしていた。
// index.ts (before)
export { createTauriAdapter } from "./tauri-adapter";
export { createMockAdapter } from "./mock-adapter";
export { createRestAdapter } from "./rest-adapter";
この状態でmain-mock.tsxがimport { createMockAdapter } from "@takazudo/backend-bridge"とすると、barrel exportによりtauri-adapter.tsもロードされる。tauri-adapter.tsは@tauri-apps/apiをimportしているので、ブラウザ上でTauriランタイムが存在しない環境では@tauri-apps/apiの初期化がクラッシュする。
解決策として、Tauri adapterをbarrel exportから除外して、deep importでのみ使用する形にした。
// index.ts (after)
export { createMockAdapter } from "./mock-adapter";
export { createRestAdapter } from "./rest-adapter";
// Tauri adapterは@takazudo/backend-bridge/tauri-adapterでimportする
Tauri adapter側は@takazudo/backend-bridge/tauri-adapterというパスで直接importする。main.tsx(本番エントリーポイント)はTauriのWebView内で動くので問題ない。mock modeやrest modeのエントリーポイントからは、Tauri adapterのコードが一切ロードされなくなった。
ViteのHTMLエントリーポイント
もう1つ、index-mock.htmlをViteの開発サーバーで使うために、Viteプラグインが必要だった。Viteはデフォルトではindex.htmlをルートのHTMLファイルとして扱う。index-mock.htmlに対してリクエストが来たとき、/へのリクエストをindex-mock.htmlにリダイレクトする設定をViteのconfigureServerフックで書いた。
REST adapter: 構想から実装へ
元々REST adapterは「将来やるかもしれない」という構想だった。が、実際に実装した。
axum HTTPサーバー
Tauri v2アプリの中にaxumベースのHTTPサーバーを埋め込んだ。http_server.rsとして527行。
仕組みはこうなっている。
- Tauriアプリが起動すると、バックエンド側で
AppState(設定、ファイルパス、PTYセッション等を保持する構造体)が作られる - この
AppStateをArcで包んで、Tauriのマネージドステートとaxumのルーターステートの両方で共有する - axumサーバーは
127.0.0.1:16127でリッスンする - すべてのTauri commandに対応するHTTPエンドポイントが用意されている
Tauri commandとHTTPエンドポイントの対応例を挙げると、messages_listはGET /api/messagesに、messages_readはGET /api/messages/:filenameに、messages_writeはPUT /api/messages/:filenameにマッピングされる。
イベント通知(ファイル変更、設定変更など)はSSE(Server-Sent Events)で実現している。GET /api/eventsに接続すると、バックエンドで発生したイベントがリアルタイムにストリーミングされる。
TypeScript REST adapter
フロントエンド側では、rest-adapter.tsがfetch()とEventSourceを使ってBackendAPIインターフェースを実装する。
export function createRestAdapter(
baseUrl = "http://127.0.0.1:16127",
): BackendAPI {
return {
messages: {
list: () => fetch(`${baseUrl}/api/messages`).then((r) => r.json()),
read: (filename) =>
fetch(`${baseUrl}/api/messages/${filename}`).then((r) =>
r.ok ? r.text() : null,
),
write: (filename, content) =>
fetch(`${baseUrl}/api/messages/${filename}`, {
method: "PUT",
body: content,
}).then((r) => r.ok),
onChanged: (callback) => {
const es = new EventSource(`${baseUrl}/api/events`);
es.addEventListener("messages:changed", (e) => {
const data = JSON.parse(e.data);
callback(data.filename);
});
return () => es.close();
},
},
// ...
};
}
Tauriのinvoke()がfetch()に、listen()がEventSourceに変わっただけ。フロントエンドのコードは一切変わらない。
dev
pnpm dev:restを実行すると、ポート1422でViteの開発サーバーが起動する。フロントエンドはREST adapterを使い、同時にTauriアプリ(axumサーバー内蔵)が起動している前提で動く。
mock adapterとの違いは「本物のバックエンドロジックが動いている」という点。ファイルの読み書きは実際のファイルシステムに対して行われるし、ファイル監視も本物のOS通知を使う。Chrome DevToolsのNetworkパネルで実際のHTTPリクエスト/レスポンスを確認でき、デバッグが容易になる。
セキュリティの注意
最初の実装では、axumサーバーを0.0.0.0でバインドしていた。つまり同じネットワーク上のどの端末からもアクセスできる状態。コードレビューで指摘されて127.0.0.1に修正した。ローカルの開発サーバーなので、localhost以外からのアクセスは不要。
3つの開発モード
3つのアダプターに対応する3つの開発モードがある。
BackendAPI interface
├── tauri-adapter.ts → invoke() (本番: Tauri WebView内)
├── mock-adapter.ts → in-memory (テスト/Storybook/dev:mock)
└── rest-adapter.ts → fetch() + SSE (Chromeデバッグ: dev:rest)
比較表にするとこうなる。
| Mode | Command | Port | Backend | Browser DevTools | Real Data |
|---|---|---|---|---|---|
| Tauri | pnpm tauri:dev | 1420 | Rust (IPC) | Limited | Yes |
| Mock | pnpm dev:mock | 1421 | In-memory JS | Full | Sample |
| REST | pnpm dev:rest | 1422 | Rust (HTTP) | Full | Yes |
使い分けとしては、UIの実装・スタイル調整にはdev:mock、バックエンドとの結合確認やデータの実動作確認にはdev:rest、最終的な動作確認にはtauri:devという形。
テスト戦略: アダプターパターンが生むテスタビリティ
アダプターパターンを入れたことで、テスト戦略が自然に決まった。BackendAPIインターフェースがレイヤーの境界を定義しているので、各レイヤーを独立してテストできる。
Layer 1: Rust unit tests(90テスト)
tauri-app/core/にzudotext-coreというスタンドアロンのRustクレートを作った。
このクレートにはTauriへの依存がない。依存しているのはserde、serde_json、chronoのみ。Tauri commandの中にあるビジネスロジック — 設定の読み込み・保存、メッセージの一覧取得・フィルタリング、ドラフトの管理、タブの状態管理、ピンディレクトリの走査 — を純粋なRust関数として切り出した。
// zudotext-core/src/messages.rs
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_message_frontmatter() {
let content = "---\ntitle: テストメッセージ\n---\n本文です";
let meta = parse_frontmatter(content).unwrap();
assert_eq!(meta.title, Some("テストメッセージ".to_string()));
}
#[test]
fn test_messages_sorted_by_filename_desc() {
let mut messages = vec![
create_meta("20260101-msg.md"),
create_meta("20260301-msg.md"),
create_meta("20260201-msg.md"),
];
sort_messages(&mut messages);
assert_eq!(messages[0].filename, "20260301-msg.md");
assert_eq!(messages[2].filename, "20260101-msg.md");
}
}
なぜ別クレートにしたかというと、フルのTauriアプリをビルドするにはLinux/WSL2環境ではGTKライブラリ等のGUI依存が必要になるから。ビジネスロジックのテストにGUIライブラリは不要なので、GUI依存を持たない別クレートに切り出すことで、cd tauri-app/core && cargo testだけでテストが走る。
90件のテストが以下の領域をカバーしている。
- settings: 設定ファイルの読み書き、デフォルト値のマージ、バリデーション
- messages: frontmatterパース、ファイル名生成、ソート、フィルタリング
- draft: ドラフトの読み込み・保存・削除
- tabs: タブ状態の永続化・復元
- pins: ピンディレクトリの走査、エントリのソート
Layer 2: TypeScript adapter tests(68テスト)
TypeScript側では、mock adapterとREST adapterそれぞれに対するテストを書いた。
mock adapterのテスト(20件)は、seed dataを使った初期状態の検証、frontmatterパースの正確性、リストのソート順、ファイルの作成・削除後の状態変化をカバーしている。
REST adapterのテスト(48件)は、fetch mockを使ってHTTPリクエスト/レスポンスのシリアライズ・デシリアライズ、エラーハンドリング(404、500、ネットワークエラー)、SSEイベントのパースをカバーしている。
Layer 3: b4push(プッシュ前の検証)
git pushの前に実行するb4pushスクリプトに、テストの実行を組み込んだ。
- workspaceパッケージのテスト(
pnpm test) - Rust coreクレートのテスト(
cd tauri-app/core && cargo test) - docサイトのビルド検証
CIパイプラインにもRustツールチェーンとcargoキャッシュを追加して、プッシュごとにRustのテストも走るようにした。このタイミングでElectron時代のテスト参照(もう存在しないパス)もクリーンアップした。
将来のLayer 4: アプリライフサイクルテスト
issue #82として計画しているのが、アプリ全体のライフサイクルテスト。アプリを起動し、REST APIをポーリングしてレスポンスを検証し、クリーンシャットダウンを確認して、孤児PTYプロセスが残っていないことをチェックする。REST adapterがあるからこそ、アプリの外側からHTTP経由で状態を確認できる。
TauriとElectronの設計思想の違い
ここで、Electronとの比較を通じて「なぜTauriではこのパターンが自然に生まれるのか」を整理する。
Tauriの場合: 言語の壁が分離を強制する
TauriのバックエンドはRust、フロントエンドはJavaScript。異なる言語なので、フロントエンドからバックエンドの関数を直接呼ぶことが物理的にできない。IPCが唯一の通信手段になる。
「通信手段がIPCに限られる」ということは、自然とすべてのバックエンド呼び出しがinvoke()経由になる。そしてそれをTypeScriptのインターフェースに集約するのは、ほんの一歩先のことに過ぎない。
Electronの場合: 分離は選択制
Electronのレンダラープロセスでは、歴史的にnodeIntegration: trueでNode.jsのAPIが直接使えた。
// Electronのrendererプロセス(nodeIntegration: true の場合)
const fs = require("fs");
const path = require("path");
function MessageList() {
const [messages, setMessages] = useState([]);
useEffect(() => {
const dir = path.join(projectRoot, "archives");
const files = fs.readdirSync(dir);
setMessages(
files.map((f) => {
const content = fs.readFileSync(path.join(dir, f), "utf-8");
return { filename: f, body: content };
}),
);
}, []);
// ...
}
Reactコンポーネントの中でfs.readdirSync()を直接呼べてしまう。これは便利だが、フロントエンドとバックエンドの境界が曖昧になる。
現在のElectronではセキュリティの観点からcontextBridgeでIPCを使う方法が推奨されている。
// preload.js (contextBridge)
contextBridge.exposeInMainWorld("backend", {
messages: {
list: () => ipcRenderer.invoke("messages:list"),
read: (filename) => ipcRenderer.invoke("messages:read", filename),
},
});
これを作ればTauriと同じアダプターパターンにできる。しかしfs.readFileSync()が「使える」ので、ショートカットする開発者は少なくない。技術的には同じパターンが実現可能だが、「わざわざ抽象化層を挟む動機が薄い」という状況になりやすい。
壁が設計を導く
Tauriでは「言語の壁」によってIPC通信が強制される。この制約がインターフェースの集約を促し、アダプターパターンを自然な帰結にする。
Electronでは「言語が同じ」なので、抽象化は開発者の意志に委ねられる。contextBridgeで同じパターンを実現できるが、require('fs')で済むところに抽象化層を挟むかどうかは規律の問題になる。
これは「TauriがElectronより優れている」という話ではない。設計上の制約が、どのようなアーキテクチャを自然に生むか、という構造の話。
1つの決定が複数の能力を生む
今回の開発を通じて見えたのは、この関心分離がもたらす効果は「mock modeが使える」だけに留まらないということ。BackendAPIインターフェースという1つの決定から、以下が派生した。
- dev — Rustなしでフロントエンド開発
- dev — Chrome DevToolsで実データをデバッグ
- mock adapterテスト — フロントエンドの単体テスト
- REST adapterテスト — HTTP通信のテスト
- 将来のライフサイクルテスト — REST API経由でアプリ全体をテスト
Electronでも同じ構造は作れる。ただ、Tauriの場合は言語の壁があるのでこの設計が必須になり、結果としてこれらの副産物が自然に手に入る。Electronの場合は意識的にこの設計を選ぶ必要がある。効果は同じだが、到達経路が異なる。
まとめ
Tauriの「言語の壁」は制約というよりarchitectural giftと言えそう。
1つのインターフェース(BackendAPI)を定義したことから、mock dev mode、REST debugging、Rust unit tests、TypeScript adapter testsという一連のテスト・開発インフラがすべて派生した。構想として書いていたREST adapterも実際に実装して、dev
。デスクトップアプリでフロントエンド開発効率を最大化する鍵は、バックエンドとの通信を1つのインターフェースに集約すること。Tauriはその設計を構造的に促し、Electronではそれが意識的な選択になる。どちらにせよ、「バックエンドを差し替え可能にする」ことで得られるものは大きい。