0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OS WebView上のReactからNode.jsでServer Actionsを実行する仕組みを作った

0
Last updated at Posted at 2026-07-23

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へまとめています。

Murasakiで作成したOscilla。REST、GraphQL、WebSocketと通信タイムラインを扱うAPIワークベンチ

検証環境は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があった場合でも、
fschild_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点を同時に満たすことでした。

  1. サーバー関数本体をレンダラーへ配信しない
  2. レンダラーへNode.jsの権限を渡さない
  3. 開発時と配布後で同じTypeScript APIを使う
  4. React 19のフォーム処理に自然につなげる

この実装はOSSのMurasakiに含まれています。
仕様と制約はServer Actionsのドキュメント
セキュリティ
プラットフォーム対応状況で公開しています。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?