Electron

Desktop React Validation

レンダラーが Vite+ でバンドルされた ReScript + React UI である、動作する Electron デスクトップアプリである。同梱サンプルは "Hello World" ではなく、レンダラーとメインプロセス間の完全なリクエスト/レスポンスループを示している。本番環境で実際に必要となるセーフティネットも含まれており、レンダラーに再入する IPC ペイロードはすべて Validation.res (zod もしくは sury) を通してパースされる。そのため main.cjs からの不正な応答は React 内部の TypeError ではなく UI エラーとなる。

デスクトップアプリをリリースするにあたり、contextIsolation: truenodeIntegration: falsecontextBridge.exposeInMainWorld がすでに配線されたスタックから始めたい場合に本テンプレートを選ぶ。レンダラーは Vite + React テンプレートと同じ Vite ベースの React サーフェスであるため、React の知識はそのまま活用できる。新たに学ぶのは Electron の 2 プロセスモデルと、その間にある IPC コントラクトである。

生成内容

my-project/
├── rescript.json                  # JSX enabled, depends on @rescript/react + validation lib
├── package.json                   # type: "module", electron + vite-plus dev deps
├── main.cjs                       # main process — BrowserWindow + ipcMain.handle("app:getInfo")
├── preload.cjs                    # contextBridge.exposeInMainWorld("electronAPI", { ... })
├── index.html                     # renderer entry — loads /src/Main.res.mjs as a module
├── vite.config.mjs                # vite-plus + @vitejs/plugin-react, base: "./"
├── src/
│   ├── Main.res                   # ReactDOM.Client.createRoot(rootEl) → <App />
│   ├── App.res                    # Button → invoke IPC → validate → render result/error
│   ├── Electron.res               # @val external electronAPI bindings over window.electronAPI
│   ├── Validation.res             # zod or sury — parses the IPC response into typed `info`
│   └── __tests__/App.test.mjs     # vitest smoke test that imports App.res.mjs
├── README.md                      # IPC security model + "About Vite+" section
├── LICENSE                        # MIT, holder = project name
├── .nvmrc                         # Node 24
├── .gitignore                     # node_modules + ReScript output + dist/, out/, .vite/
├── .editorconfig                  # 2-space indent, LF line endings
└── .github/
    ├── dependabot.yml             # weekly npm updates
    └── workflows/ci.yml           # install + rescript build + vitest

ウィザードオプション

オプション

効果

Project name

npm nameBrowserWindow のタイトル (main.cjs{{projectName}} を置換)、index.html<title>、および LICENSE のホルダーになる

Package manager

npm / pnpm / yarn / bun から選択。packageManager フィールド、README のインストール/実行スニペット、および CI キャッシュキーを設定する

Validation library

zodsury。同梱する src/Validation.res のバリアントと、dependencies に追加される zod / sury のいずれかを選択する

主要な依存

パッケージ

用途

バージョン

rescript

ReScript コンパイラ

TemplateVersions.RESCRIPT

@rescript/core

標準ライブラリ

TemplateVersions.RESCRIPT_CORE

@rescript/runtime

コンパイル済み .res.mjs がインポートするランタイムスタブ

TemplateVersions.RESCRIPT_RUNTIME

@rescript/react

React バインディング (JSX 有効化された rescript.json)

TemplateVersions.RESCRIPT_REACT

react / react-dom

レンダラーの UI ライブラリ

TemplateVersions.REACT / REACT_DOM

zod または sury

バリデーションバックエンド (IPC ペイロードパーサー)

TemplateVersions.ZOD / SURY

electron (dev)

デスクトップランタイム + バンドラー側の型定義

TemplateVersions.ELECTRON

vite-plus (dev)

dist/ を生成する Vite ベースのバンドラー

TemplateVersions.VITE_PLUS

@voidzero-dev/vite-plus-core (dev)

Vite+ コアランタイム (vite-plus の peer)

TemplateVersions.VITE_PLUS_CORE

vite (dev)

@vitejs/plugin-react が使用する vite の直接ピン

TemplateVersions.VITE

@vitejs/plugin-react (dev)

React refresh + JSX 変換プラグイン

TemplateVersions.VITEJS_PLUGIN_REACT

vitest (dev)

スモークテストランナー

TemplateVersions.VITEST

@vitest/coverage-v8 (dev)

test:coverage のカバレッジプロバイダ

TemplateVersions.VITEST_COVERAGE_V8

electron-builder はデフォルトでは 含まれない。理由と追加すべきタイミングについては補足セクションを参照のこと。

主要なファイル

main.cjs

Electron のメインプロセス。テンプレート作成時点で Electron の app/BrowserWindow/ipcMain モジュールが CJS 専用であるため CommonJS (.cjs) を使用している。.cjs を使うことで、package.json"type": "module" に左右されずに動作する。

次の 3 つを行う:

  1. 硬化されたデフォルト値で BrowserWindow を 1 つ生成する — contextIsolation: truenodeIntegration: false、および preload: path.join(__dirname, "preload.cjs")

  2. dist/index.html (Vite+ が生成した本番バンドル) をロードする。

  3. アプリ情報とシステム情報を返す ipcMain.handle("app:getInfo", ...) ハンドラを登録する。

app:getInfo チャネルは推奨される domain:action という命名規約を示している (README の "Renderer ↔ Main IPC" セクションを参照)。getInfo のような単独の動詞は避けること — サーフェスが拡大するにつれて衝突する。

preload.cjs

contextIsolation 境界をまたぐ唯一のブリッジである。contextBridge.exposeInMainWorld を使って小さな electronAPI オブジェクトを window に公開する:

contextBridge.exposeInMainWorld("electronAPI", {
  getInfo: () => ipcRenderer.invoke("app:getInfo"),
});

このオブジェクトに 含まれない ものはレンダラーから見えない。新しいチャネルを追加する際は必ずこのファイルに加え、main.cjssrc/Electron.res も編集する必要がある — README には 3 層すべての手順を追ったウォークスルーが同梱されている。

src/Electron.res

公開されたオブジェクトに対する薄い ReScript バインディング。同梱バインディングは意図的に JSON.t として型付けされており、最終的な info レコードではない。バリデーションがさらに 1 層内側にあるためである:

@val external electronAPI: {"getInfo": unit => promise<JSON.t>} = "electronAPI"

let getInfoRaw = (): promise<JSON.t> => electronAPI["getInfo"]()

Raw というサフィックスは意図的な規約である。「この値はまだバリデーション されていない」を示し、呼び出し側に対して分解前に Validation.res を通す必要があることを伝える。

src/Validation.res

parseInfo: JSON.t => result<info, string>。シグネチャは zod バリアントと sury バリアントで同一であるため、レンダラーの呼び出し側はどちらのライブラリが同梱されたかで分岐しない:

type info = {name: string, electronVersion: string, platform: string, arch: string}
let parseInfo: JSON.t => result<info, string>

なぜそもそもバリデーションするのか? レンダラーは contextIsolation の背後にあるが、メインプロセス は不適切なリファクタリング時に不正なペイロードを送出しうる。parseInfo は UI が形状を仮定する前にそれらを人間可読なエラーへと変換する。

src/App.res

対話的なデモ。ボタンクリックで次が実行される:

let raw = await Electron.getInfoRaw()
switch Validation.parseInfo(raw) {
| Ok(result) => setInfo(_ => Some(result))
| Error(message) => setError(_ => Some(message))
}

エラー分岐は赤い IPC validation error: ... バナーをレンダリングする。main.cjs を改変して予期しないペイロードを返させると、React ツリーが破綻することなくバリデーターがそれを捕捉する様子を確認できる。

src/Main.res

index.html<script type="module"> がロードするレンダラーのエントリ。<App />#root にマウントする:

switch ReactDOM.querySelector("#root") {
| Some(rootEl) =>
  ReactDOM.Client.Root.render(ReactDOM.Client.createRoot(rootEl), <App />)
| None => Console.error("Could not find root element")
}

vite.config.mjs

最小限の Vite+ 設定 — vite ではなく vite-plus からの defineConfig、React プラグイン、および base: "./"。相対的な base が重要である: Electron は dist/index.htmlfile:// でロードするため、絶対パスのアセット URL は 404 になってしまう。

index.html

レンダラーのエントリ HTML。React ルートをホストし、/src/Main.res.mjs を ES モジュールとして取り込む。これは ReScript コンパイラが Main.res の隣に出力するファイルである。

npm スクリプト

スクリプト

説明

dev

vp dev — レンダラー向け Vite+ 開発サーバー (Electron を起動せずブラウザで開いて UI を反復開発できる)

build

vp build — レンダラーを dist/ にバンドルする

electron

electron . — すでにビルド済みの dist/ に対して Electron を起動する

start

vp build && electron . — 本番相当のワンショット: ビルドしてから起動

test

vp test — Vite+ ランナー上で Vitest を実行

test:coverage

vp test --coverage — 同上、v8 カバレッジ付き

res:build

rescript — ワンショットの ReScript コンパイル

res:dev

rescript -w — 保存時に再コンパイル

res:clean

rescript clean — 生成された .res.mjs を削除

新しい IPC チャネルの追加

チャネルの追加は常に 3 層に触れる — README には手順を追ったウォークスルーが同梱されている。いずれかの層を忘れると黙って失敗する (レンダラー側で undefined is not a function が発生する) ため、その形を記憶しておく価値がある。

  1. ハンドラを main.cjs で定義する:

    ipcMain.handle("window:setTitle", (_event, title) => {
      BrowserWindow.getFocusedWindow()?.setTitle(String(title));
    });
    
  2. preload.cjs から公開する:

    contextBridge.exposeInMainWorld("electronAPI", {
      getInfo: () => ipcRenderer.invoke("app:getInfo"),
      setWindowTitle: (title) => ipcRenderer.invoke("window:setTitle", title),
    });
    
  3. src/Electron.res でバインドする (externals オブジェクトを拡張して ReScript から新しいメソッドが見えるようにする):

    @val external electronAPI: {
      "getInfo": unit => promise<JSON.t>,
      "setWindowTitle": string => promise<unit>,
    } = "electronAPI"
    
    let setWindowTitle = (title: string) => electronAPI["setWindowTitle"](title)
    
  4. ハンドラが UI の消費するデータを返す場合は Validation.res でレスポンスをバリデートする — メイン側でうっかり null が返ったり形状が変わったりした場合、React 内部の TypeError よりもパースエラーとしてデバッグするほうがはるかに簡単である。fire-and-forget なチャネル (setWindowTitleunit を返す) ではこの手順を省略できる。

セキュリティモデル

同梱される BrowserWindow は現代的な硬化済みデフォルト値で設定されている:

設定

理由

contextIsolation

true

preload のグローバルをページの window から隔離する

nodeIntegration

false

レンダラーから require("fs") できない; 侵害された依存からの Node エスケープを防ぐ

Preload ブリッジ

contextBridge.exposeInMainWorld

明示的にオプトインしたサーフェスのみが境界を越える

レンダラーは Electron を直接インポートしない。すべてのプロセス間呼び出しは window.electronAPI.*ipcRenderer.invoke(...)ipcMain.handle(...) を経由する。レンダラーに再入するペイロードはすべて Validation.res でバリデートすることで、メインプロセス側のバグによる変更が UI を黙ってクラッシュさせるのを防ぐ。

2日目以降のレシピ

プロジェクトを開いた後の ReScript 側のエディタワークフローについては 機能概要 を参照のこと。

補足

  • Electron はホットリロードされない。 vp dev はブラウザ内のレンダラーを更新するが、動作中の Electron ウィンドウは main.cjs / preload.cjs の変更で自動リフレッシュされない。メインプロセスのファイルを触ったあとは npm start を停止して再実行すること。

  • electron-builder は意図的に省いている。 パッケージング (署名 ID、アイコン、ターゲットプラットフォーム、自動更新) はオピニオネイテッドであり、プロジェクトごとに大きく異なる。そのためテンプレートは「ローカルで動く」までで止めている。リリース戦略を決めた段階で electron-builder (もしくは Forge / Tauri スタイルの代替) を追加すること。

  • レンダラーは Electron を直接インポートしない。 すべてのプロセス間呼び出しは window.electronAPI.*ipcRenderer.invoke(...)ipcMain.handle(...) を経由する。レンダラーで require("electron") を使いたくなった場合、それはほぼ確実に新しい IPC チャネルが必要なケースである。

  • ipcRenderer.send は fire-and-forget である。 同梱パターンは invoke/handle のみを使用する — バリデーターが検査できる型付きの戻り値を伴うリクエスト/レスポンスである。ストリーミングのユースケースがない限りこれを守ること。

  • 関数、クラスインスタンス、および DOM ノードはブリッジを越えられない。 ipcMain.handle から返す前に文字列化するかプレーンオブジェクトに変換すること。

  • チャネル名には domain:action を使う。 同梱されている app:getInfo が標準例である。app:* プレフィックスはアプリのライフサイクルチャネル用に予約されている — 機能開発では別のドメイン (fs:*db:*window:*) を選ぶこと。

  • スモークテストは意図的に小さい。 App.test.mjsimport("../App.res.mjs") が解決されることを検証するだけで、Electron を起動したり IPC を動かしたりはしない。レンダラーを拡張する際にドメインテストを追加すること。

  • vite-plus は 1.0 未満である。 バージョンピンは ^0.1.x である; 1.0 に到達するまでリリース間でマイナーな破壊的変更を想定しておくこと。Vite+ は内部に独自の vite をバンドルしている — 直接の vite 依存は @vitejs/plugin-react の peer を解決するためだけに存在する。