JSPI: Лучший мост между JavaScript и Wasm
JSPI связывает JavaScript и WebAssembly, чтобы синхронный Wasm вызывал API на Promise, такие как fetch, с Suspending, promising и статусом браузеров.
JavaScript Promise Integration (JSPI) позволяет модулю WebAssembly вызывать JavaScript-импорт, возвращающий Promise, как если бы это была синхронная функция: модуль приостанавливается, когда импорт возвращает Promise, и возобновляет работу с разрешённым значением — без какого-либо ручного управления коллбэками. Эта единственная возможность устраняет давно существующий пробел: синхронный Wasm, скомпилированный из C, C++ или Rust, не мог использовать await для асинхронных браузерных API, таких как fetch или IndexedDB, без сложного инструментария. В статье рассматриваются: проблема, которую решает JSPI, текущий API из двух функций, рабочий пример с fetch, а также состояние поддержки по состоянию на 2026 год. Важное предупреждение: большинство руководств по JSPI по-прежнему демонстрируют удалённый API на основе объекта Suspender — всё изложенное ниже использует актуальный интерфейс.
Ключевые выводы
- Публичный API JSPI состоит ровно из двух элементов:
new WebAssembly.Suspending(fn)помечает импорт, возвращающий Promise, аWebAssembly.promising(exportFn)оборачивает экспортируемую Wasm-функцию так, чтобы её вызов возвращал Promise. - Для проверки поддержки используйте
'Suspending' in WebAssembly— но ни в коем случае не'Suspender' in WebAssembly, что проверяет наличие удалённого API образца до 2024 года. - По состоянию на середину 2026 года JSPI является предложением четвёртой фазы (фактически стандартизированным): оно поставляется в Chrome 137+, доступно в бета-версии Safari 27 и работает в Firefox в Nightly-сборках за флагом, с планируемым включением по умолчанию в Firefox 153.
- Если импортированный Promise отклоняется, JSPI бросает исключение в приостановленное вычисление, а не возвращает значение ошибки в Wasm.
- В отличие от Asyncify из Binaryen, JSPI использует нативное переключение стеков на уровне движка, поэтому бинарный файл сохраняет прямолинейный синхронный код без какого-либо инструментирования.
Болезненный мост: синхронный Wasm встречает асинхронный веб
Суть проблемы — архитектурное несоответствие. WebAssembly, скомпилированный из C, C++ или Rust, предполагает блокирующие вызовы: функция вызывает другую функцию, ожидает возвращаемого значения и продолжает выполнение. Веб-платформа устроена противоположным образом: fetch, IndexedDB и большинство современных браузерных API возвращают Promise и разрешаются позже, управляемые циклом событий. Когда Wasm вызывает JavaScript-функцию, возвращающую Promise, у модуля нет нативного способа приостановиться, дождаться разрешения и возобновить выполнение с того места, где оно было прервано.
До появления JSPI стандартным решением был Asyncify из Binaryen — преобразование всей программы, которое переписывает Wasm-бинарник так, чтобы он мог разматывать собственный стек в линейную память и восстанавливать его впоследствии. Это работает, но издержки вполне реальны: преобразование увеличивает размер бинарного файла и добавляет накладные расходы на каждый вызов инструментированных функций. Для высокопроизводительной вычислительной процедуры, которой лишь изредка требуется выполнить fetch конфигурации или обратиться к IndexedDB в середине вычисления, платить такую цену по всему модулю — неудачный компромисс.
Что делает JavaScript Promise Integration
Discover how at OpenReplay.com.
JavaScript Promise Integration соединяет синхронный WebAssembly и асинхронные веб-API, отображая синхронный Wasm-вызов в асинхронный: модуль приостанавливается при вызове импорта, возвращающего Promise, и возобновляет работу, когда Promise завершается. Это позволяет WebAssembly-приложению вызывать так называемые импорты, возвращающие Promise, и получать доступ к их значению без необходимости явно управлять асинхронными коллбэками, обычно связанными с Promise.
Важно понимать: это не изменение языка. Предложение не вносит никаких изменений ни в язык JavaScript, ни в язык WebAssembly. Никаких новых инструкций или типов WebAssembly не предусмотрено. Семантически все описанные изменения находятся на границе между WebAssembly и JavaScript. Такое разграничение принципиально важно как для дизайна API, так и для понимания области действия приостановки.
API из двух элементов и рабочий пример с fetch
Весь публичный интерфейс JSPI состоит из двух элементов. В API JSPI есть два элемента: конструктор WebAssembly.Suspending и функция WebAssembly.promising. new WebAssembly.Suspending(fn) помечает импорт, возвращающий Promise; функция WebAssembly.promising используется для оборачивания экспортируемой WebAssembly-функции в функцию, возвращающую Promise. Обратите внимание на регистр: Suspending — это конструктор (с заглавной буквы), promising — функция (со строчной буквы).
Ниже приведена каноническая структура, адаптированная из примера спецификации: импорт на основе fetch, обёрнутый в Suspending, экспорт, обёрнутый в promising, и результирующий Promise, ожидаемый из JavaScript.
// Асинхронный импорт, возвращающий Promise, разрешающийся в число.
const computeDelta = () =>
fetch('https://example.com/data.txt')
.then(res => res.text())
.then(txt => parseFloat(txt));
const importObject = {
js: {
// Помечаем импорт, возвращающий Promise, как suspending.
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(); // приостанавливается внутри Wasm на compute_delta, возобновляется со значением
Внутри update_state Wasm-код вызывает compute_delta с обычной синхронной сигнатурой вызова. Когда этот импорт возвращает Promise, модуль приостанавливается; когда Promise разрешается, разрешённое значение становится возвращаемым значением импорта, и выполнение продолжается.
Важные особенности поведения
Три детали отличают JSPI на практике от упрощённой ментальной модели.
Приостановка ограничена границей JS/Wasm. Импорт Suspending и экспорт promising образуют пару — самый внутренний вызов обёрнутого экспорта определяет точку разреза для того, что приостанавливается. Приостанавливать с помощью JSPI можно только WebAssembly-вычисления; это обеспечивается требованием, чтобы между вызовом promising-функции и любым вызовом Suspending-обёрнутого импорта были активны только WebAssembly-фреймы.
Приостановка происходит только при фактическом возврате Promise. Вместо того чтобы всегда приостанавливаться при вызове JavaScript-функции из suspending-импорта, приостановка происходит только тогда, когда JavaScript-функция действительно возвращает Promise. Обычное возвращаемое значение передаётся напрямую без обращения к циклу событий.
Отклонённый Promise бросает исключение в Wasm. Если Promise отклоняется, вместо возобновления WebAssembly-модуля со значением в приостановленное вычисление распространяется исключение. На практике обработка отклонений обычно выполняется на стороне JavaScript, поскольку такой язык, как Rust, зачастую не может напрямую обработать брошенное исключение — в проекте wasm-bindgen обсуждается добавление явного типа, сигнализирующего об ошибке, однако это пока открытая дискуссия, а не устоявшийся API.
Состояние поддержки в браузерах и инструментарии (2026)
JSPI достигло четвёртой фазы процесса W3C WebAssembly — это 4-я фаза в W3C WebAssembly WG, что означает: спецификация была поставлена на голосование в W3C Wasm CG и фактически стандартизирована. Эта спецификация была стандартизирована W3C WebAssembly CG в апреле 2025 года.
| Среда | Статус (середина 2026) |
|---|---|
| Chrome / Edge | Поставляется в стабильной версии начиная с Chrome 137 (май 2025) |
| Safari | Доступно в бета-версии Safari 27 |
| Firefox | Только в Nightly за флагом; планируется включение по умолчанию в Firefox 153 |
| Node.js | За флагом --experimental-wasm-jspi |
Что касается Firefox: используйте официальную информацию от Mozilla, а не цифру «Firefox 139», которая встречается в интернете. Согласно Intent to Ship (10 июня 2026 года), функция разработана и поставляется за флагом, включена только в Nightly начиная с Fx152. Mozilla намерена включить WebAssembly JS-Promise-Integration (JSPI) по умолчанию на всех платформах начиная с Firefox 153. На момент написания статьи caniuse по-прежнему указывает, что в стабильной версии Firefox функция ещё не включена по умолчанию — проверяйте актуальный статус перед тем, как полагаться на неё.
Что касается инструментария: большинству проектов на C/C++ не потребуется никаких изменений в исходном коде. Если вы используете Emscripten, переход на новый API, как правило, не потребует изменений в коде. Необходимо использовать версию Emscripten не ниже 3.1.61. Проверку поддержки можно выполнить следующим образом:
if ('Suspending' in WebAssembly) {
// JSPI доступен — подключаем Suspending / promising
} else {
// переключаемся на модуль, собранный с Asyncify
}
Проверяйте WebAssembly.Suspending, а не Suspender: старый API продолжал работать как минимум до 29 октября 2024 года (Chrome M128), после чего был удалён. Обратите внимание, что сам Emscripten прекратил поддержку старого API начиная с версии 3.1.61. Ранее существовал API на основе объекта Suspender, который был удалён — если в каком-либо руководстве фигурирует WebAssembly.Suspender или new WebAssembly.Function(...) с returnPromiseOnSuspend, оно устарело.
JSPI против Asyncify: краткое сравнение
Принципиальное различие заключается в том, где находится логика приостановки. Asyncify помещает её в ваш бинарный файл; JSPI — в движок. Поскольку механизмы, используемые при приостановке и возобновлении WebAssembly-модулей, работают за практически постоянное время, мы не ожидаем высоких накладных расходов при использовании JSPI — особенно по сравнению с подходами, основанными на трансформации. Это означает меньший размер выходного файла и более низкие накладные расходы на вызов при нативном переключении стеков вместо переписывания всей программы. Текущая реализация выделяет стеки фиксированного размера для каждого приостановленного вычисления; растущие (сегментированные) стеки включены в дорожную карту для поддержки большого числа корутин, однако пока не реализованы.
Если вы компилируете в Wasm и столкнулись с ситуацией, когда синхронному коду требуется асинхронный веб-API, JSPI — это актуальное решение: оберните импорт в WebAssembly.Suspending, оберните экспорт в WebAssembly.promising, добавьте проверку 'Suspending' in WebAssembly и оставьте Asyncify только как запасной вариант для движков, которые ещё не реализовали JSPI.
Часто задаваемые вопросы
В чём разница между JSPI и Asyncify?
Asyncify — это преобразование всей программы с помощью Binaryen, которое переписывает весь Wasm-бинарник для разматывания и перемотки собственного стека в линейную память, увеличивая размер бинарного файла и добавляя накладные расходы на каждый вызов инструментированных функций. JSPI переносит эту логику в движок, используя нативное переключение стеков, поэтому модуль сохраняет прямолинейный синхронный код без какого-либо инструментирования. V8 характеризует механизмы приостановки и возобновления в JSPI как работающие за практически постоянное время, тогда как Asyncify нагружает весь модуль.
Нужно ли изменять исходный код на C или C++ для Emscripten, чтобы использовать JSPI?
Нет. Начиная с версии 3.1.61 Emscripten автоматически генерирует код для текущего API JSPI, поэтому большинству проектов на C и C++ не потребуется никаких изменений в исходном коде для перехода со старого API на основе объекта Suspender на новый интерфейс Suspending и promising. Достаточно собрать проект с Emscripten 3.1.61 или более новой версией; поддержка API до 2024 года была исключена из Emscripten в той же версии, поэтому более старые версии инструментария по-прежнему генерируют удалённый интерфейс.
Что происходит, если импортированный Promise отклоняется?
Отклонённый Promise не возвращает значение ошибки в Wasm; вместо этого JSPI распространяет исключение в приостановленное вычисление. На практике обработка отклонений обычно выполняется на стороне JavaScript, поскольку такой язык, как Rust, зачастую не может напрямую обработать брошенное исключение. Сигнатура 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