Webアプリをデスクトップ化するとき、UIはReactのまま書けても、バックエンドとの境界は
別物になりがちです。ElectronならIPC、TauriならCommandを定義し、レンダラー側と
バックエンド側を手動で接続します。
そこで、React 19のuseActionStateに近い形を保ったまま、'use server'を付けた関数を
ローカルのNode.jsプロセスで実行する仕組みを実装しました。
ここでいうServer ActionsはNext.js / React Server Componentsの実装ではなく、
Murasaki独自のローカルRPCです。この記事では、次の設計をどう実現したかをコードと
プロセス境界から説明します。
- UIはOS標準のWebViewで描画する
- レンダラーへNode.jsのグローバルを公開しない
- サーバー関数の本体をクライアントバンドルへ含めない
- 開発時とパッケージ後で同じ呼び出し方を維持する
- TypeScriptから呼び出せる形を崩さない
完成例のOscillaでは、REST / GraphQL / WebSocketの実行と通信タイムラインを一つの
デスクトップUIへまとめています。
検証環境はNode.js 22.12以上、React 19、Vite、Murasaki 0.55.6です。
3つのプロセスに責務を分ける
実行時の構成は次の3層です。
┌─────────────────────────────────────────────┐
│ Native host (Rust / tao / wry) │
│ window, WebView, menu, lifecycle │
└───────────────────┬─────────────────────────┘
│ start / monitor
┌───────────────────▼─────────────────────────┐
│ Local Node.js runtime │
│ Server Actions, API routes, main process │
└───────────────────┬─────────────────────────┘
↕ authenticated loopback HTTP
┌─────────────────────────────────────────────┐
│ OS WebView renderer │
│ React 19, routing, browser APIs │
└─────────────────────────────────────────────┘
重要なのは、レンダラーがNode.jsを直接使わないことです。UIにXSSがあった場合でも、
fsやchild_processへそのまま到達できる構成にはしません。
ファイル操作やDBアクセス、シークレットを扱う処理はローカルNode.js側に置き、レンダラーは
許可された境界を通して呼び出します。
Server Actionを定義する
サーバー側で実行したいモジュールには'use server'を付けます。
// src/actions.ts
'use server'
import { defineAction } from 'murasaki'
import type { ActionState } from 'murasaki'
export const greet = defineAction(
async (
_previous: ActionState<string>,
formData: FormData,
): Promise<ActionState<string>> => {
const name = formData.get('name')
if (typeof name !== 'string' || name.trim() === '') {
return {
data: null,
error: '名前を入力してください',
isPending: false,
}
}
return {
data: `Hello, ${name}!`,
error: null,
isPending: false,
}
},
)
defineAction自体は型を保つための薄い関数です。関数本体をサーバー側へ残す処理は、
'use server'ディレクティブを検出するViteプラグインが担当します。
Reactから呼び出す
ページ側ではuseActionを使います。
// src/app/page.tsx
import { useAction } from 'murasaki'
import { greet } from '../actions'
export default function Home() {
const [state, run, isPending] = useAction(greet, {
data: null,
error: null,
isPending: false,
})
return (
<main>
<form action={run}>
<label>
Name
<input name="name" />
</label>
<button disabled={isPending}>
{isPending ? 'Sending...' : 'Greet'}
</button>
</form>
{state.data && <p>{state.data}</p>}
{state.error && <p role="alert">{state.error}</p>}
</main>
)
}
呼び出し側はReact 19のuseActionStateと同じ[state, action, isPending]の形です。
デスクトップ固有のIPCメッセージ名やチャンネル名を、各フォームで管理する必要はありません。
ただし、Actionを定義しただけではレンダラーから実行できません。0.55ではNode側の境界も
deny-by-defaultになり、ウィンドウごとにbackendCapabilitiesを宣言します。
// murasaki.config.ts
export default defineConfig({
appId: 'com.example.actions',
productName: 'Actions Demo',
window: {
backendCapabilities: [
'action:src/actions.ts#greet',
],
},
})
ネイティブAPI用のcapabilitiesと、Node Main / Server Actions / API Routes用の
backendCapabilitiesは別の信頼境界です。必要なexportだけを許可し、Action内部でも
ユーザーやオブジェクト単位の認可を行います。
ビルド時にモジュールを2つへ分ける
Viteプラグインは'use server'モジュールを検出し、クライアント用とNode.js用に分割します。
レンダラーへ渡すのは、概念的には次のようなスタブです。
export async function greet(previous: unknown, formData: FormData) {
return fetch('/__murasaki/action/greet', {
method: 'POST',
body: serialize([previous, formData]),
}).then((response) => deserialize(response))
}
元の関数本体はNode.js側のアクションレジストリへ残します。そのため、次のようなコードを
クライアントバンドルへ混入させずに済みます。
'use server'
import { readFile } from 'node:fs/promises'
export const loadSettings = defineAction(async () => {
const json = await readFile('./settings.json', 'utf8')
return JSON.parse(json)
})
もちろん、関数がNode.js側にあるだけで安全になるわけではありません。引数の検証、操作可能な
パスの制限、ペイロード上限、認可は別途必要です。
開発時とパッケージ後で実行先をそろえる
開発時はViteミドルウェアが/__murasaki/action/...を受け、ssrLoadModuleで関数を
読み込みます。これにより、UIだけでなくサーバー関数の変更も開発ループへ取り込めます。
React form
-> Vite middleware
-> ssrLoadModule()
-> action body
パッケージ後は、ビルド済みのアクションレジストリを同梱Node.jsが読み込みます。
React form in OS WebView
-> window-bound authenticated loopback endpoint
-> bundled Node.js
-> compiled action body
接続先は変わりますが、アプリコードから見えるAPIは同じです。開発専用のモックIPCを用意し、
本番だけ別実装へ差し替える構成を避けられます。
最小構成を動かす
実装を試す場合は、雛形を作成して起動できます。
pnpm create murasaki@0.55.6 my-app
cd my-app
pnpm dev
src/app/page.tsxはファイルベースのルートになります。ViteのHMRとReact Fast Refreshが
OS WebView内でも動くため、保存するとネイティブウィンドウが更新されます。
macOSでは、完成したサンプルをチェックサム検証付きの開発者プレビューとして
ワンコマンドで試すこともできます。たとえばOscillaはREST / GraphQL / WebSocketと
通信タイムラインを備えたAPIワークベンチです。
pnpm dlx murasaki@0.55.6 demo oscilla
パッケージ作成は次の2段階です。
pnpm bundle
pnpm installer
この設計で割り切ったこと
Next.jsそのものではない
src/appや'use server'など、Next.jsで馴染みのある形を採用していますが、Next.jsの
ランタイムをデスクトップへ埋め込んだわけではありません。React Server Componentsや
Edge Runtime、完全なApp Router互換性はありません。
Node.jsを同梱するためサイズは増える
OS WebViewを使うためChromiumは同梱しませんが、Server ActionsとAPI Routesを実際の
Node.jsで動かすためNode.jsランタイムは同梱します。最小サイズが最優先なら、Rust側へ
バックエンドを寄せる構成の方が適しています。
ローカルHTTPも信頼境界になる
loopbackだから安全、とは考えません。0.55.6ではnative hostが各ウィンドウのlabelと
generationからHMAC identityを導出し、document startでそのウィンドウだけへ渡します。
Node側ではHost / Origin / Fetch Metadata、backendCapabilities、リクエストサイズ、
wire versionを検証します。ウィンドウを閉じるとidentityは失効し、同じlabelを再生成しても
別のidentityになります。
安定性宣言は1.0到達と同義ではない
0.55.6ではcapability manifestにある26機能がすべてstableになり、Server Actionsの
wire version 1にも互換性契約と上限が定義されました。一方でMurasaki自体は1.0未満です。
minor releaseに文書化された破壊的変更が入る可能性があるため、業務利用ではバージョンを固定し、
migration guide、platform別status、test evidenceを確認する必要があります。
まとめ
ReactからローカルNode.jsを呼ぶだけなら、単純なIPCでも実現できます。今回重視したのは、
次の4点を同時に満たすことでした。
- サーバー関数本体をレンダラーへ配信しない
- レンダラーへNode.jsの権限を渡さない
- 開発時と配布後で同じTypeScript APIを使う
- React 19のフォーム処理に自然につなげる
この実装はOSSのMurasakiに含まれています。
仕様と制約はServer Actionsのドキュメントと
セキュリティ、
プラットフォーム対応状況で公開しています。
