Electron¶
Desktop React Validation
レンダラーが Vite+ でバンドルされた ReScript + React UI である、動作する Electron デスクトップアプリである。同梱サンプルは "Hello World" ではなく、レンダラーとメインプロセス間の完全なリクエスト/レスポンスループを示している。本番環境で実際に必要となるセーフティネットも含まれており、レンダラーに再入する IPC ペイロードはすべて Validation.res (zod もしくは sury) を通してパースされる。そのため main.cjs からの不正な応答は React 内部の TypeError ではなく UI エラーとなる。
デスクトップアプリをリリースするにあたり、contextIsolation: true、nodeIntegration: false、contextBridge.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 |
Package manager |
npm / pnpm / yarn / bun から選択。 |
Validation library |
|
主要な依存¶
パッケージ |
用途 |
バージョン |
|---|---|---|
|
ReScript コンパイラ |
|
|
標準ライブラリ |
|
|
コンパイル済み |
|
|
React バインディング (JSX 有効化された |
|
|
レンダラーの UI ライブラリ |
|
|
バリデーションバックエンド (IPC ペイロードパーサー) |
|
|
デスクトップランタイム + バンドラー側の型定義 |
|
|
|
|
|
Vite+ コアランタイム ( |
|
|
|
|
|
React refresh + JSX 変換プラグイン |
|
|
スモークテストランナー |
|
|
|
|
electron-builder はデフォルトでは 含まれない。理由と追加すべきタイミングについては補足セクションを参照のこと。
主要なファイル¶
main.cjs¶
Electron のメインプロセス。テンプレート作成時点で Electron の app/BrowserWindow/ipcMain モジュールが CJS 専用であるため CommonJS (.cjs) を使用している。.cjs を使うことで、package.json の "type": "module" に左右されずに動作する。
次の 3 つを行う:
硬化されたデフォルト値で
BrowserWindowを 1 つ生成する —contextIsolation: true、nodeIntegration: false、およびpreload: path.join(__dirname, "preload.cjs")。dist/index.html(Vite+ が生成した本番バンドル) をロードする。アプリ情報とシステム情報を返す
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.cjs と src/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.html を file:// でロードするため、絶対パスのアセット URL は 404 になってしまう。
index.html¶
レンダラーのエントリ HTML。React ルートをホストし、/src/Main.res.mjs を ES モジュールとして取り込む。これは ReScript コンパイラが Main.res の隣に出力するファイルである。
npm スクリプト¶
スクリプト |
説明 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
新しい IPC チャネルの追加¶
チャネルの追加は常に 3 層に触れる — README には手順を追ったウォークスルーが同梱されている。いずれかの層を忘れると黙って失敗する (レンダラー側で undefined is not a function が発生する) ため、その形を記憶しておく価値がある。
ハンドラを
main.cjsで定義する:ipcMain.handle("window:setTitle", (_event, title) => { BrowserWindow.getFocusedWindow()?.setTitle(String(title)); });
preload.cjsから公開する:contextBridge.exposeInMainWorld("electronAPI", { getInfo: () => ipcRenderer.invoke("app:getInfo"), setWindowTitle: (title) => ipcRenderer.invoke("window:setTitle", title), });
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)
ハンドラが UI の消費するデータを返す場合は
Validation.resでレスポンスをバリデートする — メイン側でうっかりnullが返ったり形状が変わったりした場合、React 内部のTypeErrorよりもパースエラーとしてデバッグするほうがはるかに簡単である。fire-and-forget なチャネル (setWindowTitleはunitを返す) ではこの手順を省略できる。
セキュリティモデル¶
同梱される BrowserWindow は現代的な硬化済みデフォルト値で設定されている:
設定 |
値 |
理由 |
|---|---|---|
|
|
preload のグローバルをページの |
|
|
レンダラーから |
Preload ブリッジ |
|
明示的にオプトインしたサーフェスのみが境界を越える |
レンダラーは Electron を直接インポートしない。すべてのプロセス間呼び出しは window.electronAPI.* → ipcRenderer.invoke(...) → ipcMain.handle(...) を経由する。レンダラーに再入するペイロードはすべて Validation.res でバリデートすることで、メインプロセス側のバグによる変更が UI を黙ってクラッシュさせるのを防ぐ。
2日目以降のレシピ¶
React コンポーネントの作成 — 同じ React 規約がレンダラーにも適用される
Import の最適化 — IPC サーフェスが拡大するにつれて
src/Electron.resとsrc/Validation.resを整理しておくデッドコードの検出 — どのレンダラーからも呼び出されていない IPC チャネルの剪定に役立つ
プロジェクトを開いた後の 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.mjsはimport("../App.res.mjs")が解決されることを検証するだけで、Electron を起動したり IPC を動かしたりはしない。レンダラーを拡張する際にドメインテストを追加すること。vite-plusは 1.0 未満である。 バージョンピンは^0.1.xである; 1.0 に到達するまでリリース間でマイナーな破壊的変更を想定しておくこと。Vite+ は内部に独自のviteをバンドルしている — 直接のvite依存は@vitejs/plugin-reactの peer を解決するためだけに存在する。