📱 Подписаться
IT и цифровая трансформация

[Перевод] Почему я пишу Bun‑native альтернативу napi‑rs

📰 Habr 👁️ 0 просмотров

DotBlood1 час назад

Почему я пишу Bun‑native альтернативу napi‑rs

Уровень сложностиСреднийВремя на прочтение8 минОхват и читатели2KJavaScript*TypeScript*Open source*C*Rust*ОбзорИз песочницыПереводАвтор оригинала: DotBloodЯ работаю над 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

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, который явно показывает, что происходит на границе, именно эту задачу я пытаюсь решить.

Один источник правды для нативного API

Основной пользовательский 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]
}

Асинхронный код не выполняется тайно в JavaScript‑потоке

#[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

Особенно интересна критика и любая обратная связь.Теги:• rust
• bun
• typescript
• ffi
• napi rs
• bindingsХабы:• JavaScript
• TypeScript
• Open source
• C
• Rust

Получайте больше инсайтов о систематизации бизнеса

Подписывайтесь на Telegram-канал Business Operations — ежедневные материалы о бизнес-процессах, операционном управлении и повышении эффективности

💬 Подписаться на канал