JSPI解説:JavaScriptとWasmをつなぐより良いブリッジ
JSPIはJavaScriptとWebAssemblyをつなぎ、同期WasmがfetchのようなPromiseベースAPIを呼べるようにします。Suspendingとpromising、対応状況も解説。
JavaScript Promise Integration(JSPI)を使用すると、WebAssemblyモジュールはPromiseを返すJavaScriptインポートを同期関数であるかのように呼び出すことができます。インポートがPromiseを返すとモジュールはサスペンドし、解決された値とともに再開します。コールバックを手動で管理する必要はありません。この単一の機能により、長年の課題が解消されます。C、C++、またはRustからコンパイルされた同期的なWasmは、重いツールなしではfetchやIndexedDBのような非同期ブラウザAPIをawaitすることができませんでした。本記事では、JSPIが解決する問題、現在の2関数API、動作するfetchの例、そして2026年時点での提供状況について解説します。最初に一点注意しておきます。ほとんどのJSPIチュートリアルは削除済みのSuspenderオブジェクトAPIを示していますが、以下ではすべて現在のAPIを使用します。
重要なポイント
- JSPIのパブリックAPIはちょうど2つの要素で構成されています。
new WebAssembly.Suspending(fn)はPromiseを返すインポートをマークし、WebAssembly.promising(exportFn)はエクスポートされたWasm関数をラップしてPromiseを返すようにします。 - 機能検出には
'Suspending' in WebAssemblyを使用してください。2024年以前の削除済みAPIを確認する'Suspender' in WebAssemblyは絶対に使用しないでください。 - 2026年中頃時点で、JSPIはフェーズ4(事実上標準化済み)のプロポーザルであり、Chrome 137以降で出荷済み、Safari 27ベータで利用可能、Firefoxでは設定フラグの背後でNightlyのみ有効で、Firefox 153でデフォルト有効化が予定されています。
- インポートされたPromiseがリジェクトされた場合、JSPIはWasmにエラー値を返すのではなく、サスペンドされた計算に例外をスローします。
- BinaryenのAsyncifyとは異なり、JSPIはネイティブエンジンのスタックスイッチングを使用するため、バイナリは直線的な同期コードを保持し、インストルメンテーションによるサイズ増加が発生しません。
困難なブリッジ:同期的なWasmと非同期なWebの出会い
根本的な不一致はアーキテクチャにあります。C、C++、またはRustからコンパイルされたWebAssemblyはブロッキング呼び出しを前提としています。関数が別の関数を呼び出し、戻り値を待ち、そして処理を続けます。Webプラットフォームはその逆です。fetch、IndexedDB、そして最新のブラウザAPIのほとんどはPromiseを返し、イベントループによって後から解決されます。WasmがPromiseを返すJavaScript関数を呼び出す場合、モジュールにはネイティブな方法で一時停止し、解決を待ち、中断した箇所から再開する手段がありません。
JSPI以前の標準的な回避策はBinaryenのAsyncifyでした。これはWasmバイナリを書き換えるプログラム全体の変換で、自身のスタックをリニアメモリに巻き戻し、後で再び巻き戻せるようにします。機能はしますが、コストは現実的です。この変換によってバイナリサイズが膨張し、インストルメント化された関数に呼び出しごとのオーバーヘッドが加わります。設定をフェッチしたり計算の途中でIndexedDBから読み取ったりする必要が時々あるだけの高スループット計算ルーチンにとって、モジュール全体にそのコストを支払うのは割に合いません。
JavaScript Promise Integrationの仕組み
Discover how at OpenReplay.com.
JavaScript Promise Integrationは、同期的なWebAssemblyと非同期なWeb APIをブリッジします。Promiseを持つインポートが呼び出されるとモジュールをサスペンドし、Promiseが解決されると再開することで、同期的なWasm呼び出しを非同期なものにマッピングします。これにより、WebAssemblyアプリケーションはいわゆるPromiseを持つインポートを呼び出し、Promiseの値にアクセスできるようになります。通常Promiseに関連付けられた非同期コールバックを明示的に管理する必要はありません。
重要なのは、これは言語の変更ではないということです。このプロポーザルはJavaScript言語にもWebAssembly言語にも変更を加えません。新しいWebAssembly命令や型は規定されていません。意味論的には、概説されているすべての変更はWebAssemblyとJavaScriptの境界に存在します。この境界というフレームワークは、APIの設計とサスペンションのスコープ方法において重要です。
2要素のAPIと動作するfetchの例
JSPIのパブリックインターフェース全体は2つの要素で構成されています。JSPIのAPIには2つの要素があります。WebAssembly.SuspendingコンストラクタとWebAssembly.promising関数です。new WebAssembly.Suspending(fn)はPromiseを返すインポートをマークし、WebAssembly.promising関数はエクスポートされたWebAssembly関数をPromiseを返す関数にラップするために使用されます。大文字小文字に注意してください。Suspendingはコンストラクタ(大文字始まり)、promisingは関数(小文字始まり)です。
以下は仕様の例を基にした標準的な形式です。Suspendingでラップされたfetchベースのインポート、promisingでラップされたエクスポート、そしてJavaScriptからawaitされる結果のPromiseです。
// 数値に解決するPromiseを返す非同期インポート
const computeDelta = () =>
fetch('https://example.com/data.txt')
.then(res => res.text())
.then(txt => parseFloat(txt));
const importObject = {
js: {
// Promiseを返すインポートをサスペンディングとしてマーク
compute_delta: new WebAssembly.Suspending(computeDelta),
},
};
const { instance } = await WebAssembly.instantiateStreaming(
fetch('module.wasm'),
importObject,
);
// エクスポートをラップしてPromiseを返すようにする
const updateState = WebAssembly.promising(instance.exports.update_state);
const result = await updateState(); // compute_deltaでWasm内部でサスペンドし、値とともに再開
update_stateの内部では、Wasmコードは通常の同期呼び出しシグネチャでcompute_deltaを呼び出します。そのインポートがPromiseを返すと、モジュールはサスペンドします。Promiseが解決されると、解決された値がインポートの戻り値となり、実行が継続されます。
知っておくべき動作
実際のJSPIをナイーブなメンタルモデルから区別する3つの詳細があります。
サスペンションはJS/Wasm境界によって区切られます。 Suspendingインポートとpromisingエクスポートはペアを形成します。ラップされたエクスポートへの最も内側の呼び出しが、何がサスペンドするかのカットポイントを決定します。JSPIを使用してサスペンドできるのはWebAssemblyの計算のみです。これは、promising関数への呼び出しとSuspendingでラップされたインポートへの呼び出しの間にWebAssemblyフレームのみがアクティブであることを要求することで強制されます。
Promiseが実際に返された場合にのみサスペンドします。 サスペンディングインポートからJavaScript関数を呼び出す際に常にサスペンドするのではなく、JavaScript関数が実際にPromiseを返した場合にのみサスペンドします。プレーンな戻り値はイベントループを経由せずにそのまま渡されます。
リジェクトされたPromiseはWasmに例外をスローします。 Promiseがリジェクトされた場合、WebAssemblyモジュールを値とともに再開する代わりに、サスペンドされた計算に例外が伝播されます。Rustのような言語はそのスローされた例外を直接処理できないことが多いため、実際にはリジェクトは通常JavaScript側で処理されます。wasm-bindgenプロジェクトでは明示的なエラー表示型の追加について議論されていますが、確定したAPIではなく現在も議論中です。
ブラウザとツールチェーンの状況(2026年)
JSPIはW3C WebAssemblyプロセスのフェーズ4に達しました。W3C WebAssembly WGのフェーズ4であり、仕様がW3C Wasm CGによって投票されたことを意味します。事実上標準化されています。この仕様は2025年4月にW3C WebAssembly CGによって標準化されました。
| 環境 | 状況(2026年中頃) |
|---|---|
| Chrome / Edge | Chrome 137(2025年5月)以降、安定版で出荷済み |
| Safari | Safari 27ベータで利用可能 |
| Firefox | 設定フラグの背後でNightlyのみ有効。Firefox 153でデフォルト有効化予定 |
| Node.js | --experimental-wasm-jspiフラグが必要 |
Firefoxについては、出回っている「Firefox 139」という数字ではなく、Mozillaの公式ステータスを参照してください。Intent to Ship(2026年6月10日)によると、この機能は設定フラグの背後で開発・出荷されており、Fx152以降はNightlyのみで有効化されています。MozillaはFirefox 153以降、すべてのプラットフォームでWebAssembly JS-Promise-Integration(JSPI)をデフォルトで有効化する予定です。執筆時点では、caniuseは安定版Firefoxをまだデフォルト有効化されていないとリストしているため、依存する前に確認してください。
ツールチェーンについては、ほとんどのC/C++プロジェクトはソースを変更する必要がありません。Emscriptenユーザーの場合、新しいAPIを使用するにあたって通常コードを変更する必要はありません。少なくともバージョン3.1.61以降のEmscriptenを使用する必要があります。サポートを明確に検出するには以下を使用してください。
if ('Suspending' in WebAssembly) {
// JSPIが利用可能 — Suspendingとpromisingをセットアップする
} else {
// Asyncifyビルドのモジュールにフォールバック
}
WebAssembly.Suspendingを確認してください。Suspenderではありません。旧APIは少なくとも2024年10月29日(Chrome M128)まで動作し続けます。その後、旧APIを削除する予定です。Emscripten自体もバージョン3.1.61以降は旧APIをサポートしなくなることに注意してください。以前のSuspenderオブジェクトAPIが存在しましたが削除されました。チュートリアルにWebAssembly.SuspenderやreturnPromiseOnSuspendを伴うnew WebAssembly.Function(...)が示されている場合、それは古い情報です。
JSPIとAsyncifyの簡単な比較
決定的な違いはサスペンションロジックの置き場所にあります。Asyncifyはそれをバイナリに組み込みますが、JSPIはエンジンに組み込みます。WebAssemblyモジュールのサスペンドと再開に使用されるメカニズムは本質的に一定時間であるため、JSPIの使用に高いコストは見込まれません。特に他の変換ベースのアプローチと比較した場合はなおさらです。これはプログラム全体の書き換えではなくネイティブスタックスイッチングを使用することで、出力サイズが小さくなり、呼び出しごとのオーバーヘッドが低下することを意味します。現在の実装はサスペンドされた計算ごとに固定サイズのスタックを割り当てます。多数のコルーチンをサポートするための拡張可能な(セグメント化された)スタックはロードマップにありますが、まだ出荷されていません。
Wasmにコンパイルしていて、同期コードが非同期なWeb APIを必要とする壁にぶつかった場合、JSPIが現時点での答えです。インポートをWebAssembly.Suspendingでラップし、エクスポートをWebAssembly.promisingでラップし、'Suspending' in WebAssemblyでガードし、まだ出荷されていないエンジン向けのフォールバックとしてのみAsyncifyを保持してください。
よくある質問
JSPIとAsyncifyの違いは何ですか?
AsyncifyはBinaryenのプログラム全体の変換で、Wasmバイナリ全体を書き換えて自身のスタックをリニアメモリに巻き戻し・再巻き戻しできるようにします。これによりバイナリサイズが膨張し、インストルメント化された関数に呼び出しごとのオーバーヘッドが加わります。JSPIはそのロジックをネイティブスタックスイッチングを使用してエンジンに移動させるため、モジュールはインストルメンテーションなしで直線的な同期コードを保持します。V8はJSPIのサスペンドと再開のメカニズムを本質的に一定時間と説明していますが、Asyncifyはモジュール全体にコストを課します。
JSPIを使用するためにEmscriptenのCまたはC++ソースを変更する必要がありますか?
いいえ。Emscriptenはバージョン3.1.61以降、現在のJSPI APIを自動的に出力するため、ほとんどのCおよびC++プロジェクトは旧SuspenderオブジェクトAPIから新しいSuspendingとpromisingのインターフェースに移行するためにソースを変更する必要はありません。Emscripten 3.1.61以降でビルドするだけです。2024年以前のAPIは同じバージョンでEmscriptenから削除されているため、古いツールチェーンは削除済みのインターフェースを出力します。
インポートされたPromiseがリジェクトされた場合はどうなりますか?
リジェクトされたPromiseはWasmにエラー値を返しません。代わりにJSPIはサスペンドされた計算に例外を伝播します。Rustのような言語はそのスローされた例外を直接処理できないことが多いため、実際にはリジェクトは通常JavaScript側で処理されます。WasmのインポートシグネチャはそれがPromiseを表していても単純な整数を報告する場合があり、wasm-bindgenでは確定したAPIではなく明示的なエラー表示型の追加についての議論が進行中です。
JSPIインポートを呼び出すと常にモジュールがサスペンドしますか?
いいえ。JSPIはJavaScriptインポートが実際にPromiseを返した場合にのみサスペンドします。インポートされた関数がプレーンな同期値を返す場合、結果はサスペンションなしでイベントループを経由せずにWasm呼び出し元にそのまま渡されます。この動作はJavaScriptとWebAssemblyの境界で定義されているため、同じラップされたインポートが実行時に基礎となる関数が返す値に応じて同期的または非同期的に動作する可能性があります。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k