Top View


Author Daiki Urata

electron-trpcで型安全なIPCを実現する

2023/05/23

はじめに

前回は Electron ForgeでReact + Vite + TypeScriptのデスクトップアプリ開発環境構築 を行いました。

その際の問題点としてメインプロセスとレンダラープロセスのデータ通信を行う際、IPCを利用しますが、その部分の型はglobal.d.tsのようなファイルを用意して手動で定義する必要がありました。

人の手で型定義する場合、ミスが生まれバグを生んでしまう可能性があるので、ここをなんとか解決したいです。

今回はelectron-trpc というライブラリを利用して型安全なIPCを実現したいと思います。

環境

  • Node 18.7.0
  • TypeScript 5.0.4
  • Electron Forge 6.1.1
  • Electron 24.3.0
  • React 18.2.0

必要パッケージのインストール

まずは必要なパッケージをすべてインストールします。

$ npm install @trpc/client @trpc/server @trpc/react-query @tanstack/react-query electron-trpc zod

メインプロセスでルーターを定義する

最初はメインプロセス側を書いていきます。

tRPCのルーターを定義して必要なAPIをそろえます。入力値のバリデーションにはzodを使っています。

src/api.tsに以下のように書きます。

import z from 'zod';
import { initTRPC } from '@trpc/server';

const t = initTRPC.create({ isServer: true });
const procedure = t.procedure;

export const router = t.router({
  getTodos: procedure.query(async () => {
    const todos = // Todoデータ一覧取得処理
    return {
      todos
    };
  }),
  createTodo: procedure.input(z.object({ text: z.string(), })).mutation(async (req) => {
    const { input } = req;
    const todo = // Todoデータ作成処理
    return todo;
  }),
  deleteTodo: t.procedure.input(z.object({ id: z.number() })).mutation(async (req) => {
    const { input } = req;
    // Todoデータ削除処理
  })
});

export type AppRouter = typeof router;

コメントしている箇所にはSQLiteなどの処理や、メインプロセス側で必要な処理を記述することになります。

あとはエントリファイルであるsrc/main.tsでelectron-trpcのcreateIPCHandler() を読んであげるだけになります。


import { app, BrowserWindow } from 'electron';
import path from 'path';
import { createIPCHandler } from 'electron-trpc/main'; // ←追加
import { router } from './api'; // ←追加

const createWindow = () => {
  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
    },
  });

	// ↓追加
	createIPCHandler({ router, windows: [mainWindow] });

  if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
    mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
  } else {
    mainWindow.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`));
  }
};

app.on('ready', () => {
  createWindow();
});

// 省略

preload.tsの設定

プロセス間でのやり取りを許可するにはpreload.tsにも記述が必要です。

electron-trpcの場合はexposeElectronTRPC() だけ呼び出すだけで良くなります。

import { exposeElectronTRPC } from 'electron-trpc/main';

process.once('loaded', async () => {
  exposeElectronTRPC();
});

レンダラープロセスでAPI呼び出し

あとはReactの世界の話で @trpc/react-query というライブラリを使うと簡単にAPI呼び出し、取得データの状態管理ができるようになります。

まずはsrc/trpc.tsを用意して以下のように書きます。

import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from './api';

export const trpcReact = createTRPCReact<AppRouter>();

用意したtrpcReactを使ってクライアント作成、ReactのAppコンポーネントに追加します。

src/app.tsxで以下のように書きます。

import * as ReactDOM from 'react-dom';
import { useState } from 'react';
import { ipcLink } from 'electron-trpc/renderer';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import Todo from './components/Todo';
import { trpcReact } from './trpc';

const queryClient = new QueryClient();
const trpcClient = trpcReact.createClient({
  links: [ipcLink()],
})
function App() {
  return (
    <trpcReact.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <Todo />
      </QueryClientProvider>
    </trpcReact.Provider>
  );
}

function render() {
  ReactDOM.render(<App/>, document.body);
}
render();

そしてsrc/components/Todo.tsxを作成して、そこでAPI呼び出しを行います。

import { FormEvent, useState } from "react";
import { trpcReact } from "../trpc";

export default function Todo() {
  const { data, refetch } = trpcReact.getTodos.useQuery();
  const [text, setText] = useState("");
  const { mutate: createTodo } = trpcReact.createTodo.useMutation({
    onSuccess: () => {
      refetch();
    },
  });
  const { mutate: deleteTodo } = trpcReact.deleteTodo.useMutation({
    onSuccess: () => {
      refetch();
    },
  });

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    createTodo({ text });
  };

  const handleDelete = (id: number) => {
    deleteTodo({ id });
  };

  return (
    <>
      <form onSubmit={handleSubmit}>
        <input value={text} onChange={(e) => setText(e.target.value)} />
        <button type="submit">Add Todo</button>
      </form>
      <ul>
        {data?.todos?.map((todo) => (
          <li>
            {todo.text}
            <button onClick={() => handleDelete(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </>
  );
}

今回は必要最低限でエラーハンドリングなどせず、雑に作ってます。

書いてる途中で気づくと思いますが、ちゃんと trpcReact. と書いてる間に補完が動き、メインプロセスで定義したAPIの型がちゃんと効いてるのがわかると思います。

queryのレスポンスデータやmutationの入力値までもしっかり型安全になっています。

完成したので npm start を実行してTodoの追加、一覧表示、削除ができるアプリが見えると思います。※ src/api.tsのコメント部分の記述は必要

おわりに

本記事で解説したように、preload.tsで定義せずにメインプロセス側でtRPCのルーターとzodで入力値のバリデーションを書いてあげることで、レンダラープロセス側で型安全なAPI呼び出しが実現できました。

これでメインプロセスとレンダラープロセスのデータ通信の齟齬起因のバグを防ぐこともでき、開発体験も上がるはずです。

Daiki Urata

Daiki Urata

Twitter X

フロントエンド/モバイルアプリなどを主に開発しています。