ひどい事実が判明した。ElectronではIPC通信における制約のせいで標準APInet.request, https.requestを使ったとき、リクエスト後のJSONなどの結果を受け取って自由に実装することができないようだ。

単にAPIを発行するだけならいいが、その後の結果を受け取ってDOMに渡すような処理ができない。これは致命的すぎる。ありえない。何かの勘違いであってほしい。

経緯

記事 GitHub API
Electronのnet.requestでWebAPIを叩くも失敗(エラー) users
Electronのnet.requestでWebAPIを叩く(成功) users
Electronのnet.requestで謎エラー(net:ERR_CONNECTION_REFUSED) createRepo
ElectronでNode.jsのhttps.requestを試す createRepo

ElectronでWebAPIを実行したくて試行錯誤している。その過程でしばしばAn object could not be cloned.エラーに阻まれてきた。そして今回、このエラーについて調査したところ、IPC通信における制約であることが判明した。

ElectronはNode.js経由でローカルファイルシステムなどにアクセスし、ブラウザ描画エンジンでアプリ画面を作成するためのフレームワークである。このNode.js側とブラウザ側は異なる実行エンジン・プロセスのため、データのやりとりはIPC通信を介して行う必要がある。つまりElectronを使う以上、必ずIPC通信することになるし、IPC通信に制約があるなら、それは事実上、Electronを使う上で必ず縛られる枷になる。この前提で考える。

ipceventdrivendilemma.svg

問題

次のような理由で、ElectronとNode.jsの標準APInet.request, https.requestではHTTPリクエスト後の処理を実装できない。

  1. ブラウザ側でElectron API呼出するにはIPC通信を介する必要がある
  2. IPC通信は引数に関数を渡せない?(An object could not be cloned.エラーが出る。詳細は後述)
  3. net.request, https.request APIはリクエスト後の処理をres.on('data', (d) => {で実装せねばならない仕様(イベントドリブン)
  4. 3のため応答結果のJSONを呼出元にreturnできない(同期できない)
  5. 3のためコールバック関数で処理するしかない
  6. 2のためコールバック関数を渡せない
  7. 3,5のためリクエスト後の処理を実装できない

3,5以外の方法として、リクエスト後の処理をres.on('data', (d) => {の中に直接書けばいいと思うだろう。しかし、それができなさそうなのだ。DOMなどのオブジェクトが渡せないからだ。An object could not be cloned.エラーになってしまう。詳しくは以下。

An object could not be cloned.エラーの正体

私が調べたときは以下のように日本語訳された公式ドキュメントが存在した。

けれど今は英語になってて本文も削減されていた。

ドキュメントの右上には日本語などの各言語に切り替るUIがあるので翻訳されているだろうと期待するが、完全に英語だった。公式がこういう嘘をつくのはやめてほしい。

とりあえず新しい資料の英語をそのままGoogle翻訳にかけてみた。

In Electron 8.0, IPC was changed to use the Structured Clone Algorithm, bringing significant performance improvements. To help ease the transition, the old IPC serialization algorithm was kept and used for some objects that aren't serializable with Structured Clone. In particular, DOM objects (e.g. Element, Location and DOMMatrix), Node.js objects backed by C++ classes (e.g. process.env, some members of Stream), and Electron objects backed by C++ classes (e.g. WebContents, BrowserWindow and WebFrame) are not serializable with Structured Clone. Whenever the old algorithm was invoked, a deprecation warning was printed.

In Electron 9.0, the old serialization algorithm has been removed, and sending such non-serializable objects will now throw an "object could not be cloned" error.

Electron 8.0 では、構造化クローン アルゴリズムを使用するように IPC が変更され、パフォーマンスが大幅に向上しました。移行を容易にするために、以前の IPC シリアライゼーション アルゴリズムが維持され、Structured Clone でシリアライズできない一部のオブジェクトに使用されました。特に、DOM オブジェクト (Element、Location、DOMMatrix など)、C++ クラスに基づく Node.js オブジェクト (process.env、Stream の一部のメンバーなど)、および C++ クラスに基づく Electron オブジェクト (WebContents、BrowserWindow、WebFrame など) は、 Structured Clone ではシリアル化できません。古いアルゴリズムが呼び出されるたびに、非推奨の警告が出力されました。

Electron 9.0 では、古いシリアライゼーション アルゴリズムが削除され、そのようなシリアライズ不可能なオブジェクトを送信すると、「オブジェクトを複製できませんでした」というエラーがスローされるようになりました。

Electron 9.0からはIPC通信におけるElectron/ブラウザ文脈間の互換性が下がってしまったという話なのだろう。パフォーマンスを向上させたくてそうしたっぽい。

ちなみに私がこれまで使ってきたElectronのバージョンは20系。package.jsonに書いてある。余裕で9.0以上なので、このシリアライズ不可能問題の影響を受ける。

よくわからないが、ElectronはC++で実装されているのかな? だからJavaScript間においてはデータをシリアライズせねばならず、シリアライズできるデータ種別には制約がある、ということか。たとえばElectronオブジェクト(WebContents、BrowserWindow、WebFrame など)はIPC通信でシリアライズできず使えないという話だろう。

私はたぶんそのElectronオブジェクトとやらは使っていないはず。私が気づいたのはJavaScriptの関数をIPCの引数に渡すとAn object could not be cloned.エラーになったということだけ。この資料には「JavaScript関数はシリアライズできない」とは書いていない。資料に不足があるのか、あるいは私には理解できないような書き方がされているのかはわからないが。

また、この資料がまだ日本語翻訳されていたとき、私はローカルでメモをとっていた。そのときのメモによると、資料には次のように書いてあった。こちらのほうが詳しかった。メモっといてよかった。

IPC を介して (ipcRenderer.send、ipcRenderer.sendSync、WebContents.send 及び関連メソッドから) オブジェクトを送信できます。このオブジェクトのシリアライズに使用されるアルゴリズムが、カスタムアルゴリズムから V8 組み込みの 構造化複製アルゴリズム に切り替わります。これは postMessage のメッセージのシリアライズに使用されるものと同じアルゴリズムです。 これにより、大きなメッセージに対するパフォーマンスが 2 倍向上しますが、動作に重大な変更が加えられます。

関数、Promise、WeakMap、WeakSet、これらの値を含むオブジェクトを IPC 経由で送信すると、関数らを暗黙的に undefined に変換していましたが、代わりに例外が送出されるようになります。

// 以前:
ipcRenderer.send('channel', { value: 3, someFunction: () => {} })
// => メインプロセスに { value: 3 } が着く

// Electron 8 から:
ipcRenderer.send('channel', { value: 3, someFunction: () => {} })
// => Error("() => {} could not be cloned.") を投げる
  • NaN、Infinity、-Infinity は、null に変換するのではなく、正しくシリアライズします。
  • 循環参照を含むオブジェクトは、null に変換するのではなく、正しくシリアライズします。
  • Set、Map、Error、RegExp の値は、{} に変換するのではなく、正しくシリアライズします。
  • BigInt の値は、null に変換するのではなく、正しくシリアライズします。
  • 疎配列は、null の密配列に変換するのではなく、そのままシリアライズします。
  • Date オブジェクトは、ISO 文字列表現に変換するのではなく、Date オブジェクトとして転送します。
  • 型付き配列 (Uint8Array、Uint16Array、Uint32Array など) は、Node.js の Buffer に変換するのではなく、そのまま転送します。
  • Node.js の Buffer オブジェクトは、Uint8Array として転送します。 基底となる ArrayBuffer をラップすることで、Uint8Array を Node.js の Buffer に変換できます。
  • Buffer.from(value.buffer, value.byteOffset, value.byteLength)

ネイティブな JS 型ではないオブジェクト、すなわち DOM オブジェクト (Element、Location、DOMMatrix など)、Node.js オブジェクト (process.env、Stream のいくつかのメンバーなど)、Electron オブジェクト (WebContents、BrowserWindow、WebFrame など) のようなものは非推奨です。 Electron 8 では、これらのオブジェクトは DeprecationWarning メッセージで以前と同様にシリアライズされます。しかし、Electron 9 以降でこういった類のオブジェクトを送信すると "could not be cloned" エラーが送出されます。

というようなことが書いてあった。明らかに以前の日本語訳されていたほうが情報量が多くて助かる。なぜ消したのか謎。

いずれにせよ、DOM、Node.js、Electron オブジェクトはIPC通信で使えない。使うとAn object could not be cloned.エラーになる。ということがわかった。

特に気になるのはDOMが使えないということ。もしDOMが使えたら、main.js側のres.on('data', (d) => {内でHTTPリクエスト結果をDOMにセットすることだってできた。なのに実際はDOMさえ使えないため、それもできない。関数を渡しても同じエラーになるのでコールバック手法も使えない。

かくしてIPC通信による数々の制約により、もはや「HTTPリクエスト後の応答を使って思うように処理を実装することは不可能」と結論付けざるを得ない。そんなバカな?! と思うような悲惨な結果。

IPCの引数にDOMを渡せない確認

一応ソースコードを書いて確認した。たしかにDOMを引数に渡すとAn object could not be cloned.エラーになった。

main.js

const { app, BrowserWindow, ipcMain, dialog, net } = require('electron')
ipcMain.handle('testDom', async(event, dom)=>{
    dom.innerHTML = 'テスト用DOMに値をセットしました'
})

preload.js

const {remote,contextBridge,ipcRenderer} =  require('electron');
contextBridge.exposeInMainWorld('myApi', {
    testDom:async(dom)=>await ipcRenderer.invoke('testDom', dom),
})

renderer.js

window.addEventListener('DOMContentLoaded', async(event) => {
    // Uncaught (in promise) Error: An object could not be cloned.
    // DOM要素を渡すと上記エラーになることを確認できた
    await window.myApi.testDom(document.querySelector('#test-dom'))
})

仕様を整理する

Electronでネットワーク処理をするとき、以下2つの制約に阻まれてHTTPリクエスト後のJSONを取得して処理することができない。

  1. 標準API net.request, https.request はイベントドリブン形式で実装する仕様である
    1. リクエスト後の処理はres.on('data', (d) => {で実装する
      1. コールバック関数で実装する
  2. ネットワーク処理はElectronやNode.js APIが使える文脈でのみ実行可能である
    1. ブラウザ側とデータをやりとりするにはIPC通信が必要である
      1. IPC通信でのデータはシリアライズする必要がある
        1. シリアライズできない形式のデータがある
          • Electron オブジェクト
          • Node.js オブジェクト
          • DOM オブジェクト
          • JavaScript 関数(コールバック関数が渡せない!)

ようするに標準APIでHTTPリクエスト後のJSONを取得して処理するにはコールバック関数が必要であるにもかかわらず、IPC通信システムにコールバック関数を渡して実行するとAn object could not be cloned.エラーになって実行できないというジレンマのせいで実現できない。DOM要素も同様。よって標準APIでHTTPリクエスト後のJSONを取得して画面に表示する等のことができないという結論になる。

なぜこんなマヌケなことが起きるのか。理由はわからない。私が何か勘違いしているだけではないのか。少なくとも私の頭ではこれを解決する他の方法は思いつかなかった。

なんとかならないか?

一応考えてみたが、どうにもならない。

コールバック関数どころかDOMさえ受け取れないから、res.on('data', (d) => {の中で直接DOMにJSON結果をセットすることもできない。なら、JSON結果をファイルに書き込んで、完了後にイベントなりメッセージなりで通知し、その通知を受け取って、ファイルを読み込む方法はどうか。

  1. res.on('data', (d) => {の中でJSON結果をファイルに書き込む
  2. 1の後、完了した通知をイベントなりメッセージなりで行う
  3. 2のメッセージを受信したらそのイベント処理として1のファイルを読み込む

と思ったが、そもそも3のファイル読み込みもElectron, Node.js側の文脈で定義せねばならない。それがブラウザ側で受信できれば同期の問題を解決できると思ったが、ブラウザ側でメッセージ受信するシステムなんて存在しないはず。それに該当するものがIPC通信システムであり、そいつが今回のシリアライズ制約を持っていて実現不能たらしめている。どうあっても解決できない。

そもそもElectron側だけをみても肝心のメッセージ通知機能があるかどうかもわからない。仮にあったとしても、そんな面倒すぎるコードは絶対に書きたくない。ふつうにawaitしてreturnすれば済むだけの話。

Electronのシリアライズ問題は解決不能だろう。だとしたら、イベントドリブン形式でしか実装できない標準APIのほうをどうにかするしかない。とはいえ、標準APIを改造できるはずもない。どうしたものか。

どうしよう

今回の用途はGitHub API createRepoによりリポジトリを作成できればそれでいい。応答結果としてJSONを受け取れるけど、べつに使う理由はない。せいぜい成否だけわかれば十分だし、それはHTTPコードで判別できる。そこに制約はないため今回はたまたま十分だった。

けれどふつうWebAPIを使ったらJSONで情報を取得し、それを煮るなり焼くなりするし、DOM要素にその値をセットして画面表示したりするはず。なのにElectronとNode.jsの標準APInet.request, https.requestでは、応答結果のJSONをawaitで同期させてreturnさせることもできないし、コールバック関数を引数で受け取ることもできないし、DOMを引数で受け取ることもできない。そんなわけで、標準APIではリクエスト後のJSONデータを思うように処理することができない。

ふつうに考えて、応答後の処理は実装できるようにしたい。どうしたら解決できる?

ググったら標準APIが非推奨だった

ググったらそこかしこのブログ記事で標準APIhttps.requestは「非推奨」と書いてあった。標準APIなのに非推奨ってなんなの? マジなの? 公式APIリファレンスhttps.requestにはそれっぽいことは書いてないように見えるけど。

非推奨ってなんだっけ? 今回みたElectronのドキュメントにも「Electron オブジェクト (WebContents、BrowserWindow、WebFrame など) のようなものは非推奨」ってあるけど「エラーになる」と書くべきでは?

非推奨の理由はよくわからないけど、今回のようなジレンマで実装できないからかな? そんな情報どこにも見当たらなかったけど。でも実際そうだし。

これじゃもうElectronやNode.js自体が非推奨ソフトウェアだと思った。少なくとも私の中ではそんな気分。どうしてこんなひどいことになっているのだろう。

世の中の人たちはどうやってネット通信してるの?

標準APIが非推奨って、そんなバカな。もしマジならElectronを使って開発している人たちは一体どうやってネット通信を実現しているというのか。こんな有名な環境なんだから、ちゃんと解決方法はあるはずだと信じて探す。

「electron http request」でググっても、やはりElectronのAPIであるnet.requestが出てくるだけ。君は謎のnet:ERR_CONNECTION_REFUSEDエラーだったしなぁ。

質問されてた

「node.js http request」に変えてググってみる。

標準APIが非推奨という狂った環境のせいで、関係ない質問サイトに質問があがるくらい途方に暮れるようだ。ああ、やはりおかしいんだな。解答には第三者製ライブラリの使用により解決せよとのこと。ええー。

第三者製ライブラリを使うしかない

さらに以下のような記事もみつけた。

なんで5つもあるんだよ、と嫌な予感しかしない。

  • 標準ライブラリ
    • httpモジュール
    • httpsモジュール
  • 第三者製ライブラリ
    • Got
    • Axios
    • SuperAgent
    • node-fetch

httpモジュールは論外として、httpsモジュールはレスポンス処理できないからなぁ。

事実上、第三者製ライブラリを探して試すしかなさそうだ。やはりそうなるのか。

つまり、世の中のNode.jsを使って開発する人々はみんな標準APIでなく第三者製ライブラリを使ってネットワーク処理を行っていると。

けどそのライブラリってNode.jsのAPIを使って実装するものではないの? 気になるけど、ライブラリのソースコードを見ると本分から外れてしまう。今はまともにネットワーク通信できるようになることが先決。それができるのは第三者製ライブラリだけであって、標準APIではないと。

マジか……Node.jsお前マジか……使えねー(テクハラ)

いや、でも、OSのAPIを使うよりは簡単に実装できるはずだよ。たぶん。だからElectronやNode.jsはとてもありがたい存在のはずだよ。たぶん。……これ何度目の葛藤だろうか。

いずれにせよ、今度は第三者製ライブラリの調査をせねばならないという流れ。さすがにJSONレスポンスを処理できないのはありえないので、ちゃんと処理できる方法くらい見つけたい。あるよね? あるんだよね? 今度こそ大丈夫なんだよね?

ああ、はやく本題に入りたいなー。調査やエラーやドキュメントに騙されてばっかりでつらいなー。Electronさん、Node.jsさん、なんとかしてくんないかなー。