Почему я пишу Bun‑native альтернативу napi‑rs
- вторник, 29 сентября 2026 г. в 00:00:06
Я работаю над bffi‑rs, экспериментальным фреймворком для нативных биндингов Bun, написанным на Rust.
Проект появился из довольно практичной задачи. Я хотел сделать небольшое desktop‑приложение с нативным WebView, но при этом оставить Bun основным рантаймом.
В экосистеме уже есть решения для подобных приложений. Например, некоторые интеграции WebView для Bun используют Rust и napi-rs. В варианте Electrobun, который я рассматривал, для WebView используется Wry, а связка между Rust и JavaScript строится через napi-rs. Эти проекты решают реальные задачи, но у меня появился другой вопрос: что будет, если строить нативную часть приложения вокруг Bun, а не вокруг совместимости с Node.js?
В Bun 1.4 появилось много изменений, важных именно для нативных интеграций. bun:ffi стал частью JavaScriptCore, горячие FFI‑вызовы получили возможность компилироваться JIT в прямые вызовы C‑функций, а новый тип аргумента buffer_length позволил передавать указатель и длину буфера так, чтобы они не могли случайно расходиться.
Bun заявляет ускорение до 3 раз для ряда FFI‑сценариев в версии 1.4. В релизах 1.4.x также были изменения и исправления, связанные с Worker‑потоками, доставкой событий между потоками, JavaScriptCore, GC и передачей указателей через FFI.
Это заставило меня по‑другому посмотреть на границу между JavaScript и Rust. Если приложение работает на Bun, а нативный модуль подключается через Node‑API, то между приложением и Rust появляется интерфейс совместимости, изначально ориентированный прежде всего на Node.js.
Это может быть правильным компромиссом, если главная цель проекта заключается в переносимости. Но такой слой не проектировался вокруг собственного FFI и модели потоков Bun.
napi-rs сам описывает себя как фреймворк для создания Node.js add‑on‑модулей через Node‑API. В его issue tracker есть отдельные обсуждения о том, что Bun не является строго поддерживаемым рантаймом, в том числе случаи, когда асинхронное поведение Rust‑кода под Bun отличается от Node.js.
В моём случае это тоже оказалось не только теорией. Когда я экспериментировал с WebView, часть кода, которая выглядела нормально с точки зрения Node‑API, под Bun могла завершаться раньше времени или падать.
Поэтому я решил попробовать другой путь: не адаптировать Node‑API‑библиотеку под Bun, а построить нативный слой вокруг экспериментального bun:ffi и поведения, которое появилось в Bun 1.4.x.
Цель проекта не в том, чтобы объявить bun:ffi более зрелым, чем Node‑API. Это было бы неправдой. И bun:ffi, и bffi являются экспериментальными библиотеками. Я хочу сделать Bun‑native слой, который напрямую использует возможности Bun, не скрывает границу между рантаймом и Rust и позволяет принимать отдельные решения для callbacks, workers, буферов и event loop.
Я также сознательно оставил MIT‑лицензию. Проект можно форкнуть, изменить ABI, переделать упаковку нативных бинарников или адаптировать реализацию под другой сценарий. Мне интереснее увидеть, как этот дизайн будут критиковать и менять, чем превратить его в закрытую чёрную коробку.
Сейчас главная проблема проекта уже не только в реализации. Мне не хватает обратной связи от людей, которые могли бы использовать такой инструмент. Недавно я увидел около 600 активных скачиваний npm‑пакета, но почти не получил обсуждений или отзывов. Поэтому я не понимаю, решает ли текущий дизайн реальную проблему или интересен только мне.
Дальше я расскажу, что именно уже сделано и какие решения всё ещё можно спокойно пересмотреть.
Технические детали, которые повлияли на направление проекта, описаны в релизе Bun 1.4, Bun 1.4.1 и Bun 1.4.2. Сам napi-rs описывает проект как Node‑API‑фреймворк для Node.js в своём репозитории.
napi-rs построен вокруг Node‑API. Это хороший выбор, если нужно выпускать один нативный модуль для нескольких Node‑совместимых рантаймов.
Моя задача другая. Я хочу использовать собственный bun:ffi и строить binding‑модель вокруг его реальных ограничений, а не прятать их за compatibility layer.
Это означает, что проект сознательно принимает меньшую область совместимости ради более явной архитектуры:
нативный код компилируется в Rust cdylib и загружается через bun:ffi;
на границе используется тонкий C ABI, а не Node‑API;
C‑функция возвращает статус ошибки;
фактический результат передаётся через последний out‑параметр;
ошибки передаются через thread‑local error slot;
полный экспортируемый API описывается сгенерированными дескрипторами;
TypeScript‑обёртки создаются на основе этих дескрипторов.
bffi-rs не совместим по API с napi-rs и не пытается им быть. Если нужна поддержка Node.js или Deno, это неподходящий инструмент. Если нужен Bun‑native binding layer, который явно показывает, что происходит на границе, именно эту задачу я пытаюсь решить.
Основной пользовательский macro выглядит так:
#[bffi] pub fn add(a: u32, b: u32) -> u32 { a.wrapping_add(b) }
Он создаёт три артефакта:
исходную Rust‑функцию;
extern "C" shim с ABI bffi;
compile‑time descriptor с именем функции, параметрами, типом результата, документацией и ABI‑сигнатурой.
Дескрипторы объединяются в ModuleDef. Бинарник emit-json записывает его в .bffi/bffi.api.json. Пакет @z2net/bffi проверяет схему, генерирует .bffi/api.gen.ts, находит нативный бинарник и открывает его через bun:ffi.
Обычная точка входа выглядит так:
import { bffi } from "@z2net/bffi"; import type { Api } from "./.bffi/api.gen.ts"; const api: Api = await bffi(); api.add(1, 2);
Для каждой функции не нужно вручную писать отдельную binding‑обёртку. API генерируется под конкретный модуль, а схема встраивается в сгенерированный файл, чтобы TypeScript проверял точные типы, которые использует приложение.
Перед первым вызовом loader также выполняет ABI‑ и exports‑hash handshake. Если manifest устарел или не соответствует бинарнику, ошибка возникает заранее, а не превращается в непонятный вызов отсутствующего символа.
Именно на FFI‑границе заканчиваются гарантии Rust. Поэтому я хотел сделать ownership и обработку ошибок явными, а не разносить эти правила по каждой отдельной binding‑функции.
Основные правила сейчас такие:
данные по умолчанию копируются при переходе через границу;
zero‑copy доступен только через явно названный путь bffi::unsafe_zero_copy;
объекты, буферы и callbacks используют generational u64 handles с type tags;
устаревший handle не может попасть в повторно использованный слот;
строки используют UTF-8 как единую каноническую кодировку;
64-битные числа остаются точными JavaScript bigint;
в release‑сборках Rust panic перехватывается на ABI‑границе и превращается в JavaScript Error;
публичный Rust API остаётся safe, а внутренний unsafe спрятан внутри модулей фреймворка.
C ABI не может напрямую вернуть Rust Result. Ошибка возвращается числовым кодом, а подробный BffiError забирается из last‑error slot. Типизированные domain errors могут использовать собственные стабильные коды в диапазоне 0x1000..=0xFFFF. На стороне JavaScript вариант ошибки доступен через e.name, а его поля через e.payload.
Например, Rust‑ошибка может перейти в JavaScript без потери структуры:
#[derive(BffiError, Debug)] pub enum UsersError { #[bffi(code = 0x1001)] NotFound { id: u64 }, }
try { api.find_user(99n); } catch (error) { error.code; // 0x1001 error.name; // "NotFound" error.payload; // [99n] }
#[bffi_async] превращает Rust future в типизированный Promise<T>. Worker‑потоки опрашивают future, но не вызывают JavaScript напрямую. Когда future завершается, bffi кодирует результат, ставит задачу в очередь и доставляет её в JavaScript‑потоке.
Доставка выполняется через явный pump:
await pumpUntil(api.compute(5), () => api.loopPump());
Нативный event loop Bun нельзя просто перехватить из Rust cdylib. Поэтому bffi не устанавливает скрытый timer и не делает вид, что Promise сам собой разрешится. Код, в который встроен модуль, сам решает, когда и как вызывать pump очереди bffi.
Та же модель используется для callbacks из native‑потоков. invoke_wait отправляет callback в JavaScript‑поток, ждёт ответ с обязательным timeout и возвращает результат native‑коду. Это нужно для GUI и event‑driven библиотек, где обработчик работает в OS‑потоке, но должен синхронно обратиться к JavaScript.
В репозитории есть рабочий пример с Wry: native WebView‑поток, IPC‑сообщения, JavaScript callbacks и ответ, который возвращается обратно в страницу. Именно поэтому bffi не ограничивается простым вызовом нативной функции.
Runtime является только частью задачи. В проекте также есть @z2net/bffi-cli. Он умеет создать каркас binding‑проекта, собрать Rust crate, проверить сгенерированные файлы, сгенерировать TypeScript API, упаковать native binary и скачать готовый release artifact.
Нативные модули распространяются через platform packages по модели, похожей на napi-rs: основной TypeScript‑пакет фиксирует platform packages в optionalDependencies, а каждый platform package содержит нужный .dll, .so или .dylib.
Reference package рассчитан на Windows, Linux glibc, Linux musl и macOS. Версии platform packages фиксируются точно, потому что сочетание нового JavaScript loader со старым native binary легко превращается в сломанный release.
В отдельном репозитории bffi‑examples находятся end‑to‑end модули для следующих сценариев:
SQLite handles и реальные запросы к базе;
Rust futures как JavaScript Promises;
cancellation и timeouts;
event‑loop pumping и ошибки wrong‑thread;
callbacks в обе стороны;
несколько Bun Worker isolates;
records, enums и Vec<T> sequences;
pull‑ и push‑streams как async iterators;
typed domain errors;
Wry WebView с IPC из native‑потока.
Это не просто набор фрагментов кода. Каждый пример проходит полный путь: Rust source, macro expansion, cdylib, loader JSON, generated TypeScript, dlopen и Bun‑тесты.
Есть и небольшой benchmark для reference native module. Специализированная сгенерированная обёртка показала примерно 172 миллиона вызовов в секунду на benchmark runner GitHub Actions, а generic wrapper на том же модуле показал примерно 11 миллионов. Это не универсальный benchmark для любого компьютера, а проверка того, зачем default‑путь использует специализированную генерацию.
И bun:ffi, и bffi являются экспериментальными библиотеками. Bun прямо указывает, что у bun:ffi есть известные ошибки и ограничения. bffi тоже экспериментален, поэтому я пока не рекомендую использовать его как production dependency.
Я не планирую бросать проект. Наоборот, хочу продолжать его развивать, пока архитектуру ещё можно менять без необходимости ломать большую установленную базу.
Сейчас мне особенно нужна обратная связь от людей, которые работают с Bun, Rust, native modules или FFI.
Нужен ли вам Bun‑only binding framework, или переносимость важнее?
Понятна ли модель с явным pump для async‑кода и callbacks?
Разумна ли политика copy‑by‑default?
Какие части ABI или generated API вы бы перепроектировали?
Оправдывает ли GUI и event‑driven сценарий дополнительную сложность?
Что помешало бы вам попробовать проект в реальном приложении?
Репозиторий проекта:
https://github.com/z2net/bffi‑rs
Особенно интересна критика и любая обратная связь.