12k
All articles

JSPI: Лучший мост между JavaScript и Wasm

JSPI связывает JavaScript и WebAssembly, чтобы синхронный Wasm вызывал API на Promise, такие как fetch, с Suspending, promising и статусом браузеров.

OpenReplay Team
OpenReplay Team
JSPI: Лучший мост между JavaScript и Wasm

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

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, поэтому один и тот же обёрнутый импорт может вести себя синхронно или асинхронно в зависимости от того, что возвращает базовая функция во время выполнения.

Open-source session replay

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

We use cookies to improve your experience. By using our site, you accept cookies.