Next.jsのウェブアプリをサーバーサイドAPI部分も含めてデスクトップアプリとしてElectron内で動くようにtRPCで頑張る

最近、Next.js/Vercelでちょっとしたウェブアプリケーションのツールを個人的に作っているのですが、これをデスクトップアプリケーションとしても動かしたいなと思うようになりました。私のようなふだんウェブばかりやってる人間にとっては、**デスクトップアプリは憧れ**なのです。

このアプリケーションは、ブラウザ側で動く部分と、サーバー側(Vercel)上のNode環境で動く部分とに分かれているわけですが、Webの技術でクロスプラットフォームなデスクトップアプリケーションを作れる[**Electron**](https://www.electronjs.org/)上でもこれの全体が動くように頑張っていきたいと思います。ここでやりたいのは、ブラウザ部分だけをElectronに乗せてそのデスクトップアプリからVercel上にデプロイされたWeb APIを叩くということではなくて、あくまで**Web APIがやっているのと同等の処理をデスクトップアプリケーション内でローカルに実行したい**ということです。ElectronのデスクトップアプリからVercel上のWeb APIをそのまま叩いたほうが楽なのですが、すべてデスクトップ上で完結すればVercelのコストもかかりませんし、実行速度も早くなりますし、ネットワークがない環境でも動作できますし、アプリのデータが外部のサーバーを経由しないのでセキュリティ的にも安全です。そして何より、単独で動くほうがデスクトップアプリ感があります。いくつかハマった点があったので、記事に残しておきたいと思います。


# 対象読者

- Next.jsとかでウェブアプリケーションを作ってVercelとかに載せている人
- そのウェブアプリが単独で完結するデスクトップアプリとしても動いたらいいなと思っている人
- Electronの詳しい説明はしないので、知らない人は予めElectronのプロセス間通信の予備知識を仕入れておくとよし

# まずはとにかくNext.JSのアプリをElectronに読み込ませる

何はともあれ、現状のNext.jsアプリをElectronに読み込ませてみます。`next export`コマンドで`out`ディレクトリに静的にエクスポートし、そこにElectronの通常の作法に従って`main.js`を追加して`electron out`コマンドで起動してみたのですが、まあ普通に動きません。というのも、Next.jsのSGでビルドしたアプリはJSやCSSを絶対パスで読み込もうとするので、`index.html`から参照しているすべてのJSやCSSの読み込みに失敗します。たとえば、`index.html`では以下のようにJSやCSSを参照しています。

```html:out/index.html
<link rel="preload" href="/_next/static/css/e240837ddb08ea1a.css" as="style"/>
<link rel="stylesheet" href="/_next/static/css/e240837ddb08ea1a.css" data-n-g=""/>
<noscript data-n-css=""></noscript>
<script defer="" nomodule="" src="/_next/static/chunks/polyfills-c67a75d1b6f99dc8.js"></script>
<script src="/_next/static/chunks/webpack-3433a2a2d0cf6fb6.js" defer=""></script>
<script src="/_next/static/chunks/framework-9b5d6ec4444c80fa.js" defer=""></script>
<script src="/_next/static/chunks/main-3123a443c688934f.js" defer=""></script>
<script src="/_next/static/chunks/pages/_app-e5fe666c93d679a0.js" defer=""></script>
```

electronの`loadFile`で`index.html`を読み込むとページのURLが`file///`になるため、上記のリソースを次のようにすべて`file:///C:/`以下を基準に読み込んでしまい、軒並み参照に失敗してしまいます。

```
GET file:///C:/_next/static/css/e240837ddb08ea1a.css net::ERR_FILE_NOT_FOUND
GET file:///C:/_next/static/chunks/webpack-3433a2a2d0cf6fb6.js net::ERR_FILE_NOT_FOUND
GET file:///C:/_next/static/chunks/framework-9b5d6ec4444c80fa.js net::ERR_FILE_NOT_FOUND
GET file:///C:/_next/static/chunks/main-3123a443c688934f.js net::ERR_FILE_NOT_FOUND
GET file:///C:/_next/static/chunks/pages/_app-e5fe666c93d679a0.js net::ERR_FILE_NOT_FOUND
GET file:///C:/_next/static/chunks/814-53fdebc0ba6a8e46.js net::ERR_FILE_NOT_FOUND
GET file:///C:/_next/static/chunks/pages/index-f93f2b6ce910ec23.js net::ERR_FILE_NOT_FOUND
GET file:///C:/_next/static/OzhzgQJwlti6YUIYW4KQh/_buildManifest.js net::ERR_FILE_NOT_FOUND
GET file:///C:/_next/static/OzhzgQJwlti6YUIYW4KQh/_ssgManifest.js net::ERR_FILE_NOT_FOUND
```

ではどうすればいいかというと、electronのメインプロセス`main.ts`(`main.js`)で`interceptFileProtocol`を呼び出して、`file`プロトコルのリクエストをすべて`__dirname`以下を指すように置き換えてやります。

```ts:main.ts
  protocol.interceptFileProtocol("file", (req, callback) => {
    const requestedUrl = req.url.slice("file://".length);
    if (path.isAbsolute(requestedUrl)) {
      callback(path.normalize(path.join(__dirname, decodeURI(requestedUrl))));
    } else {
      callback(decodeURI(requestedUrl));
    }
  });
```

ちなみにこのとき`[`や`]`を含んだパスがエスケープされていてそれが原因でも読み込みに失敗したので、`decodeURI`を挟んでいます。これでひとますNext.jsアプリを正しく読み込めるようになったと思います。このくらいのつまづきじゃ、憧れは止められねえんだ！


# trpcで ipcMain と ipcRenderer をつなぐ

一応ここからが本題です。これでNext.jsのアプリをElectronに読み込めはしたのですが、APIの呼び出しが動きませんので直していきます。実はいま私が作っているそのアプリは、クライアント・サーバー間のAPI呼び出しを[**tRPC**](https://trpc.io/)で実装しています。

- https://trpc.io/

これはAPIの呼び出しを型安全にしてくれるライブラリなのですが、APIの呼び出しを抽象化してくれるという役目もあります。trpcは通常はWeb APIのエンドポイントをURLで指定してAjaxで呼び出すわけですが、**TRPCとしてはエンドポイントの名前と渡すデータの型さえ決まっていれば、その中間の経路がブラウザ/ウェブサーバー間のajaxだろうが、ElectronのipcMain/ipcRendererのプロセス間通信だろうが、べつに何でも構わない**わけです。ちょうどいいので、このtrpcの動作をカスタマイズしてipcMainとipcRendererをつなげてみたいと思います。開発者としても、ウェブアプリケーションとして動作させる場合とデスクトップアプリとして動作させる場合で、それらを区別せずに実装することができます。しかもtRPCのおかげで完全に**型安全**です。

## trpcのサーバー側（メインプロセス側）の実装

それれではサーバー側（メインプロセス側）を実装していきます。Next.jsとtRPCを使ったときの通常の手順として、 `pages/api/trpc\/[trpc].ts`のようなモジュールに`Router`と`createContext`が定義されているはずです。それを `main.ts` に読み込んできます。そして、それらを`ipcMain.handle("electron-trpc", eventHandler)`のイベントハンドラの中で使い、Electronのプロセス間通信で受信したリクエストを、tRPCのルーターで処理します。

```ts:main.ts

import * as api from "../pages/api/trpc/[trpc]";

...

app.whenReady().then(async () => {

  ...

  ipcMain.handle("electron-trpc", (_event, opts) => {
    const { input, path, type } = opts;
    return resolveIPCResponse({
      createContext: api.createContext,
      input,
      path,
      router: api.appRouter,
      type,
    });
  });

  ...
}
```

ここでこの`resolveIPCResponse`の中身は、要点だけ抜き出すとこんなかんじになっています。`callProcedure`を使うとルーターでリクエストを処理させることができるんですね。

```ts
// コンテキストを作成
ctx = await createContext();

// inputをデシリアライズ
const deserializedInput = typeof input !== "undefined" ? router._def.transformer.input.deserialize(input) : input;

// callProcedureでAPIを実行
const output = await callProcedure({ ...props, ctx, input: deserializedInput });

// transformTRPCResponseでTRPCResponseに変換
const res: TRPCResponse = {
  id: null,
  result: { type: "data", data: output },
};
return transformTRPCResponse(router, res);
```

このあたりのコードは [jsonnull/electron-trpc](https://github.com/jsonnull/electron-trpc) というライブラリの[このあたりのコード](https://github.com/jsonnull/electron-trpc/blob/main/src/main/resolveIPCResponse.ts)を参考にさせてもらって書いたので、より詳しく知りたい人はそちらも参照してみてください。




## trpcのクライアント側(レンダラプロセス側)の実装

次にクライアント側（レンダラプロセス側）も実装していきます。Next.jsでtRPCを使っていると、通常は`_app.tsx`で`withTRPC`でアプリ全体を包むことでコンテキストを作っていると思いますが、この`withTRPC`のコンフィグを変更します。

```ts:_app.tsx
const trpcClientOptions: CreateTRPCClientOptions<AppRouter> = isElectron
  ? { links: [createTRPCLink()] } 
  : { url: trpcURL };

export default withTRPC<AppRouter>({
  config() {
    return trpcClientOptions;
  },
  ssr: false, // false or error on build
})(MyApp);
```

ここでは、現在実行されているのがElectron上なのかVercel上なのかを調べて、どちらのコンフィグでtrpcを初期化するのか切り替えています。`withTRPC`のコンフィグで`url`を持つオブジェクトを渡すと通常のAJAXになりますが、`links`プロパティを持ったオブジェクトを渡すとtRPCクライアントの動作をカスタマイズすることができます。

trpcの具体的な振る舞いは、`createTRPCLink`で作成される`TRPCLink`で定義されています。細かいエラーハンドリングなどを省略すると、こんな感じです。

```ts
function createTRPCLink<T extends AnyRouter>(): TRPCLink<T> {
  return (runtime: LinkRuntimeOptions) => {
    return async ({ op, prev }) => {
      const envelope = await window.electronTRPC.trpc(op);
      prev(transformRPCResponse({ envelope, runtime }));
    };
  };
}
```

`op`にリクエストの内容が入っているので、これを `window.electronTRPC.trpc` に渡して ipcMainとのプロセス間通信を実行します。その結果が帰ってきたら、 `transformRPCResponse`でそれを `OperationResponse`に変換し、最初の呼び出し元に結果を戻してやります。このへんも [jsonnull/electron-trpcのこのへん](https://github.com/jsonnull/electron-trpc/blob/main/src/renderer/ipcLink.ts)を参考にしています。

これで、APIの両端の実装には手を加えず、そのあいだの通信の経路の部分だけ、Next.jsの通常のブラウザ/サーバーのHTTP通信から、ElectronのipcMain/ipcRendererのプロセス間通信へとすげ替えることができました。通常のウェブアプリケーションとしてデプロイした場合でも、もちろん今まで通りに機能します。


# おまけ:Electronでリンクをクリックしたとき、そのElectron上ではなくシステムのブラウザでページを開くには

Electron上で`<a>`要素のリンクをクリックするとデフォルトでは、普段使っているブラウザではなく、そのElectron自体のプロセスのブラウザでページが開いてしまうようです。この動作だと、外部のサイトを開いたときに未ログインの状態で開かれてしまうので不便ですが、メインプロセスの`main.ts`に次のようなコードを書くだけで、通常のブラウザでリンクを開くことができるそうです。

```ts:main.ts
win.webContents.setWindowOpenHandler((details) => {
    require("electron").shell.openExternal(details.url);
    return { action: "deny" };
});
```

地味ですが、こういうところが意外に使い勝手に響いてくるので、Next.js/Electronで作りたい人のためにメモしておきます。


# おわりに

ウェブアプリとしても動くしデスクトップアプリとしても動く、というのはまあまあ贅沢な要求というか風変わりな要求だとは思うのですが、これでそんなアプリが現実的に作れる目処がたちました。いったんtrpcのカスタマイズ部分だけ構築できてしまえば、あとはアプリに機能を追加していったとしてもElectronのプロセス間通信の仕組みを意識せずに済むので、ウェブとデスクトップの両方に対応しても複雑になりません。

trpcは単体で使ってもいいライブラリだと思うのですが、こんなふうに「ブラウザからのWeb APIの呼び出し」と「Electronのレンダラプロセスからのメインプロセスの関数の呼び出し」を抽象化するために使うこともできます。良いライブラリなので、もっと使っていこうかなと思います。

ちなみに tRPCは v10 のリリースがもうすぐ控えていて、v9からAPIがかなり変更されるようです。いまからtRPCを使う人は心の準備だけしておきましょう。

- https://trpc.io/docs/v10/migrate-from-v9-to-v10

# 参考文献

- https://github.com/jsonnull/electron-trpc 実装の多くはこちらのライブラリのコードを参考にしています。このライブラリ自体はなんかうまく組み込めず、今回は普通にコードを書いて実装しました
- Electronで絶対パスの基点をアプリケーションがあるディレクトリにする https://qiita.com/whitphx/items/4123784f1eb68a1d3925
- Make a link from Electron open in browser https://stackoverflow.com/a/59499271