最近、Next.js/Vercelでちょっとしたウェブアプリケーションのツールを個人的に作っているのですが、これをデスクトップアプリケーションとしても動かしたいなと思うようになりました。私のようなふだんウェブばかりやってる人間にとっては、デスクトップアプリは憧れなのです。
このアプリケーションは、ブラウザ側で動く部分と、サーバー側(Vercel)上のNode環境で動く部分とに分かれているわけですが、Webの技術でクロスプラットフォームなデスクトップアプリケーションを作れるElectron上でもこれの全体が動くように頑張っていきたいと思います。ここでやりたいのは、ブラウザ部分だけを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を参照しています。
<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
以下を指すように置き換えてやります。
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で実装しています。
これは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のルーターで処理します。
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
を使うとルーターでリクエストを処理させることができるんですね。
// コンテキストを作成
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 というライブラリのこのあたりのコードを参考にさせてもらって書いたので、より詳しく知りたい人はそちらも参照してみてください。
trpcのクライアント側(レンダラプロセス側)の実装
次にクライアント側(レンダラプロセス側)も実装していきます。Next.jsでtRPCを使っていると、通常は_app.tsx
でwithTRPC
でアプリ全体を包むことでコンテキストを作っていると思いますが、このwithTRPC
のコンフィグを変更します。
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
で定義されています。細かいエラーハンドリングなどを省略すると、こんな感じです。
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のこのへんを参考にしています。
これで、APIの両端の実装には手を加えず、そのあいだの通信の経路の部分だけ、Next.jsの通常のブラウザ/サーバーのHTTP通信から、ElectronのipcMain/ipcRendererのプロセス間通信へとすげ替えることができました。通常のウェブアプリケーションとしてデプロイした場合でも、もちろん今まで通りに機能します。
おまけ:Electronでリンクをクリックしたとき、そのElectron上ではなくシステムのブラウザでページを開くには
Electron上で<a>
要素のリンクをクリックするとデフォルトでは、普段使っているブラウザではなく、そのElectron自体のプロセスのブラウザでページが開いてしまうようです。この動作だと、外部のサイトを開いたときに未ログインの状態で開かれてしまうので不便ですが、メインプロセスの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://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