Привет, Хабр! Меня зовут Сергей Маркизов и я бэкенд-разработчик в Далее.
Периодически я сталкиваюсь с задачами, где привычного JavaScript недостаточно: нужно быстрее считать, вручную управлять памятью или подключить существующую нативную библиотеку.
Node.js отлично чувствует себя в I/O-bound задачах (API, микросервисы, работа с базами данных и очередями), но как только в приложение попадает тяжелая CPU-bound логика (хеширование больших файлов, обработка изображений, видео, криптография) event loop может стать узким местом.
Один из способов решить эту проблему — вынести тяжелую часть в нативный аддон. В статье разберемся, как делать такие аддоны на Rust с помощью NAPI-RS: посмотрим на базовые типы, классы, асинхронные задачи, работу с JavaScript-объектами и управление ресурсами через Reference и External.
Материал будет полезен Node.js-разработчикам, backend-инженерам и всем, кто хочет понять, когда Rust можно встроить в JavaScript-проект без боли для пользователей библиотеки.
Кратко про внутреннее устройство Node.js
Node.js построен на основе event loop — однопоточной модели выполнения, где все I/O-bound операции выполняются асинхронно, а JavaScript код работает в одном потоке. Это архитектурное решение стало ключом к успеху Node.js: оно позволяет эффективно обрабатывать тысячи одновременных подключений без затрат на переключение контекста между потоками. Однако за этой элегантностью скрывается фундаментальное ограничение: когда встречается CPU bound задача, event loop блокируется до её завершения. Представьте веб-сервер, обрабатывающий изображения — пока один запрос выполняет ресурсоёмкие вычисления, все остальные запросы ждут своей очереди.
Сильные стороны Node.js проявляются в сценариях с высоким I/O нагрузками: веб-серверы, API шлюзы, микросервисы, работающие с базами данных. Движок V8 оптимизирован для типичных веб-задач, а event loop обеспечивает предсказуемое поведение при высокой нагрузке. Cлабые стороны становятся очевидны в вычислительных задачах — обработка видео, машинное обучение, сложные математические расчеты превращаются в узкое горлышко производительности.
Именно здесь появляется смысл вынести горячий участок за пределы обычного JavaScript-кода.
Аддоны как встроенный инструмент для написания высокопроизводительного кода под Node.js
Нативные аддоны дают прямой доступ к системным ресурсам и многопоточности, поэтому помогают обходить ограничения event loop в CPU-bound задачах. Worker Threads в Node.js тоже позволяют вынести вычисления из основного потока, но добавляют накладные расходы из-за изоляции контекстов. Нативные расширения работают ближе к системе: они могут создавать отдельные потоки и выполнять вычисления параллельно. Это особенно полезно там, где задачу легко распараллелить: при обработке изображений, научных расчетах или криптографических операциях.
В таких сценариях Rust в связке NAPI-RS становится компромиссом. Rust компилируется в оптимизированный машинный код, близкий по производительности к C++, и при этом помогает держать под контролем память и потокобезопасность. Библиотеки вроде rayon упрощают параллельную обработку данных без ручной работы с потоками. В итоге основной поток Node.js продолжает принимать запросы и работать с асинхронным I/O, а тяжелые вычисления уходят в Rust-аддон. Получается гибридная схема: Node.js отвечает за прикладную логику и экосистему, а Rust за участки, где важны CPU и системная производительность.

NAPI-RS: возможности, которые предотвращают ошибки
Дальше разберем, какие особенности NAPI-RS помогают писать нативные аддоны безопаснее: типизацию на границе Rust и JavaScript, макросы для генерации обвязки, обработку ошибок, асинхронность и автоматическую генерацию TypeScript-деклараций.
NAPI-RS — один из самых развитых инструментов для создания нативных аддонов Node.js на Rust. Он работает поверх N-API — стабильного интерфейса Node.js для нативных расширений. В отличие от низкоуровневых C++-решений, где часто приходится работать с сырыми указателями и вручную следить за памятью, NAPI-RS дает типобезопасный API и переносит часть проверок на этап компиляции. Система владения Rust помогает избегать гонок данных и утечек памяти, а типы NAPI-RS отвечают за корректное преобразование значений между Rust и JavaScript. Макросы берут на себя интеграционный бойлерплейт: экспорт функций, преобразование типов, проброс ошибок и связку с асинхронным кодом. В результате разработчик больше занимается логикой аддона, а не ручной обвязкой вокруг N-API.
Создание проекта
Для создания NAPI-RS проекта и управления им существует специальный инструмент командной строки @napi-rs/cli. Установить его можно с помощью npm, yarn или pnpm.
yarn global add @napi-rs/cli
# or
npm install -g @napi-rs/cli
# or
pnpm add -g @napi-rs/cli
После этого можно создавать проект командой
napi new
после ответа на вопросы мы получим проект с примером функции. Для работы с проектом в корневом package.json сразу будут добавлены скрипты. Соберите проект с помощью
npm run build
на выходе вы получите один или несколько *.node файлов аддонов.
Базовые концепции
Основой для создания интерфейса между Node.js и Rust в NAPI-RS является процедурный макрос #[napi]. Именно он скрывает от нас преобразование типов между разными средами и прочий бойлерплейт код. Давайте рассмотрим простой пример:
Посмотреть код
use napi::bindgen_prelude::*; use napi_derive::napi; use sha2::{Digest, Sha256}; #[napi] pub fn calculate_buffer_sha256_checksum(data: Buffer) -> Result<String> { let mut hasher = Sha256::new(); hasher.update(data.as_ref()); let result = hasher.finalize(); Ok(format!("{:x}", result)) } #[napi] pub fn get_file_sha256_checksum(file_path: String) -> napi::Result<String> { let content = std::fs::read(file_path).map_err(|e| { Error::new( Status::GenericFailure, format!("Failed to read file: {}", e), ) })?; calculate_buffer_sha256_checksum(content.into()) }
const checksum = getFileSha256Checksum("package.json"); console.log(checksum) // 4cd754c06ad6f9c23d30ee475bb542f19505487ee44a378aae0b32c243602b72
В данном примере строки автоматически будут сконвертированны между Rust и JavaScript, а если в napi::Result вернется ошибка, то будет вызвано исключение. Имена функций автоматически преобразуются из snake_case в camelCase, но можно задать вручную.
Типы данных
Для входных и выходных значений в NAPI-RS можно использовать различные типы значений. Undefined можно получить различными способами, а null всего одним. Булево значение используется тривиальным способом.
Посмотреть код
#[napi] pub fn get_void() {} #[napi] pub fn get_empty_tuple() -> () { () } #[napi] pub fn get_undefined() -> Undefined {} #[napi] pub fn get_null() -> Null { Null } #[napi] pub fn get_reversed_bool(input: bool) -> bool { !input }
Числовые значения представлены следующими типами. Для использования больших целочисленных значений в качестве аргументов необходимо использовать тип BigInt.
Посмотреть код
#[napi] pub fn get_i32() -> i32 { 42 } #[napi] pub fn get_i64() -> i64 { 42 } #[napi] pub fn get_i128() -> i128 { 100 } #[napi] pub fn get_u32() -> u32 { 42 } #[napi] pub fn get_f64() -> f64 { 42. } #[napi] pub fn use_big_int(a: BigInt) -> i128 { 32_i128 * a.get_i128().0 }
Строки можно конвертировать в обе стороны.
Посмотреть код
#[napi] pub fn greet(name: String) -> String { format!("Hello, {}!", name) } // Буффер совместим с вектором Vec<u8> #[napi] pub fn get_buffer() -> Buffer { Buffer::from(vec![1, 2, 3, 4]) }
Работать с JavaScript объектами можно прямо из Rust. Специальное значение Env описывает глобальное окружение JavaScript и будет автоматически передано в функцию или метод. Создание JavaScript объектов требует регистрации их в JavaScript окружении, чтобы сборщик мусора знал об их существовании.
Посмотреть код
#[napi] pub fn get_object_keys(obj: Object) -> Vec<String> { Object::keys(&obj).unwrap() // Не используйте unwrap в боевом коде } #[napi] pub fn get_object(env: &Env) -> Object { let mut obj = Object::new(env).unwrap(); obj.set("key", 64).unwrap(); obj } #[napi] pub fn get_global(env: &Env) -> Result<JsGlobal<'_>> { env.get_global() }
Можно работать как с нативными массивами/векторами, так и с JavaScript массивами. С последними работаем также, как с объектами, через Env.
Посмотреть код
#[napi] pub fn get_num_array() -> [u32; 2] { [1, 2] } #[napi] pub fn get_num_vector() -> Vec<i64> { vec![1, 2, 3] } #[napi] pub fn get_js_array(env: &Env) -> napi::Result<Array> { let mut array = env.create_array(0)?; array.insert("a string")?; array.insert(42)?; Ok(array) }
Перечисления можно задать числовыми и строковыми.
Посмотреть код
#[napi] pub enum NumEnum { First, Second, Third, Another, } #[napi(string_enum)] pub enum StringEnum { First, Second, Third, Another, }
Можно строго описать структуру объектов, но для использования методов потребуется создавать класс.
Посмотреть код
#[napi(object, js_name = "SomeJsObject")] pub struct MyJsObject { pub id: u32, pub name: String, pub order: NumEnum, } #[napi] pub fn greet_object(obj: MyJsObject) -> String { format!("Hello, {}!", obj.name) } #[napi(constructor, js_name = "SomeJsClass")] pub struct MyJsClass { pub id: u32, pub name: String, pub order: StringEnum, } #[napi] impl MyJsClass { #[napi] pub fn greet(&self) -> String { format!("Hello, {}!", self.name) } }
Использование функций
NAPI-RS позволяет создавать доступные из JavaScript функции с помощью макроса #[napi]. Однако на этом возможности не ограничиваются, вы можете принимать в качестве аргументов обычные JavaScript функции и вызывать их из Rust.
Посмотреть код
#[napi] pub fn call_function_with_one_argument(callback: Function<u32, u32>) -> Result<u32> { callback.call(1) } #[napi] pub fn call_function_with_many_arguments(callback: Function<FnArgs<(u32, u32)>, u32>) -> Result<u32> { callback.call((1, 2).into()) }
Создание классов
Классы в napi-rs позволяют создавать JavaScript-объекты с методами и свойствами, которые управляются Rust-кодом. Их использование намного удобней, чем объектов, поскольку они могут иметь методы, при этом разрешая наследование на стороне JavaScript. Можно использовать конструктор по умолчанию, или реализовать свой собственный, а также создавать фабричные методы. При добавлении геттеров/сеттеров следует избегать коллизий имен.
Посмотреть код
#[napi(constructor)] pub struct ClassWithDefaultConstructor { pub id: u32, pub name: String, } #[napi] pub struct ClassWithCustomConstructor { pub id: u32, pub name: String, } #[napi] impl ClassWithCustomConstructor { #[napi(constructor)] pub fn new(id: u32, name: String) -> Self { Self { id, name } } } #[napi] #[derive(Debug, Clone)] pub struct ClassWithFactory { #[napi(getter, setter)] pub count: u32, } #[napi] impl ClassWithFactory { #[napi(constructor)] pub fn one() -> Self { Self { count: 1 } } #[napi(factory)] pub fn with_initial_count(count: u32) -> Self { Self { count } } #[napi(getter)] pub fn double_count(&self) -> u32 { self.count * 2 } #[napi(setter)] pub fn half_count(&mut self, count: u32) { self.count = count / 2; } #[napi] pub fn as_string(&self) -> String { format!("{:?}", self) } }
Чтобы шире использовать возможности интеграции между Rust и JavaScript существует также возможность работать с классом как JavaScript-объектом. Это дает доступ к полям, которых нет в описании класса, но которые могут быть добавлены из JavaScript. Таким образом, Rust-код получает доступ к динамическим данным, не декларируя их в структуре, но сохраняя типобезопасность. Также мы можем использовать метод apply функции, для передачи необходимого контекста this там, где это необходимо.
#[napi] pub struct InjectThis {} #[napi] impl InjectThis { #[napi(constructor)] pub fn new() -> Result<Self> { Ok(Self {}) } #[napi] pub fn get_count(&self, this: This<Object>) -> Result<Option<i32>> { this.get::<i32>("count") } } #[napi] pub fn call_function_with_apply( callback: Function<(), ()>, this: ClassInstance<InjectThis>, ) -> Result<()> { callback.apply(this, ()) }
Перенос вычислений в отдельный поток с помощью Task и AsyncTask
Node.js использует один поток для выполнения логики приложения, это подходит для быстрых по времени вычислений, но попытка произвести длительные вычисления заблокирует поток до их окончания. Task и AsyncTask — это простейший способ отдать Rust-функцию в рабочий поток и вернуть результат в JavaScript как Promise, не писав вручную код для работы с потоками. Достаточно реализовать два метода — compute, где выполняется тяжёлая логика, и resolve, который превращает результат в JS-значение, и библиотека сама запустит задачу вне event-loop Node.js.
Без такой абстракции пришлось бы вручную создавать потоки, синхронизировать их с циклом libuv и правильно вызывать napi_create_promise, что требует десятков строк шаблонного и легко ломающегося кода. Task убирает всё это: вы пишете только Rust-код, а низкоуровневую логику генерирует макрос.
Ещё одно преимущество — безопасность: интерфейс гарантирует, что JS-окружение доступно только в resolve, тогда как compute выполняется в отдельном потоке без возможности случайно вызвать несуществующий объект или нарушить правила памяти V8. Это избавляет от segfault-ов и гонок, которые иначе неизбежны при прямом использовании napi-sys.
Давайте рассмотрим на примере:
pub struct FakeCalculator { iterations: u32, should_fail: bool, } #[napi] impl Task for FakeCalculator { type Output = u32; type JsValue = u32; fn compute(&mut self) -> Result<Self::Output> { if self.should_fail { let mut rng = rand::rng(); sleep(Duration::from_millis(rng.random_range(0..=self.iterations as u64))); Err(napi::Error::new(Status::GenericFailure, "Unexpected task failure")) } else { sleep(Duration::from_millis(self.iterations as u64)); Ok(self.iterations * 10) } } fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> { println!("Hello from resolve"); Ok(output) } fn reject(&mut self, _: Env, _err: napi::Error) -> Result<Self::JsValue> { println!("Hello from reject"); Ok(0) } fn finally(self, _: Env) -> napi::Result<()> { println!("Hello from finally"); Ok(()) } } #[napi] pub fn run_async_fake_task(iterations: u32, should_fail: bool) -> AsyncTask<FakeCalculator> { AsyncTask::new(FakeCalculator { iterations, should_fail }) }
const runJsAsyncFakeTask = async (iterations: number = 30000, shouldFail: boolean = false) => { const logger = globalLogger.child({ id: randomUUID(), scope: 'runFakeTask', iterations, shouldFail }); logger.info({ stage: 'call', }); const result = await runAsyncFakeTask(iterations, shouldFail); logger.info({ stage: 'done', result, }); }; let interval = setInterval(() => globalLogger.info({ msg: 'Interval message from main thread', }), 1000); await Promise.all([ runJsAsyncFakeTask(15000), runJsAsyncFakeTask(20000, true), runJsAsyncFakeTask(30000), ]); clearInterval(interval);
Здесь мы реализуем трейт Task для нашей структуры, а затем преобразуем все в JavaScript Promise с помощью AsyncTask. Длительные операции описываются в методе compute, а reject и finally является опциональными. Используйте reject, чтобы преобразовать ошибку. А освободить используемые ресурсы можно через finally. Запуская данный пример можно убедиться, что основной поток не блокируется.
Интеграция асинхронного Rust в связке с Node.js
Для использования асинхронных Rust функций из JavaScript доступны фичи "async" и "tokio_rt". Фича async сообщает макросу napi, что экспортируемые функции могут возвращать impl Future. И не зависит от конкретного асинхронного рантайма, просто дает возможность взаимной конвертации между Promise в JavaScript и Future в Rust. Однако подключать конкретный рантайм в данном случае придется вручную. Фича tokio_rt полностью привязана к рантайму tokio, но имеет сверх возможностей async, еще и запускает tokio рантайм в отдельном потоке (треде) libuv. Поэтому можно сразу использовать в коде на rust все возможности tokio.
Посмотреть код
#[napi] pub async fn use_tokio_delay(ms: u32) -> napi::Result<()> { tokio::time::sleep(Duration::from_millis(ms.into())).await; Ok(()) } #[napi] pub async fn use_tokio_sum(a: u32, b: u32) -> Result<u32> { let h1 = tokio::spawn(async move { a * 2 }); let h2 = tokio::spawn(async move { b * 3 }); Ok(h1.await.unwrap() + h2.await.unwrap()) } #[napi] pub async fn use_tokio_read_file(file_path: String) -> napi::Result<String> { tokio::fs::read_to_string(file_path).await.map_err(|e| e.into()) } #[napi] pub async fn use_tokio_fetch_url(url: String) -> napi::Result<String> { let response = reqwest::get(&url).await .map_err(|e| napi::Error::from_reason(e.to_string()))?; let text = response.text().await .map_err(|e| napi::Error::from_reason(e.to_string()))?; Ok(text) }
Ссылки Reference и WeakReference для защиты ресурсов от освобождения
В ситуациях, когда функция Rust получает JavaScript-объект и должна сохранить его для использования позже — например, для асинхронного колбэка, обработки события или отложенной валидации — возникает проблема: сборщик мусора V8 не «видит», что этот объект всё ещё нужен. Если в JavaScript-коде не осталось переменных, ссылающихся на объект, он может быть уничтожен в любой момент, даже если Rust-код ещё планирует с ним работать.
Чтобы предотвратить это, napi-rs предоставляет тип Reference— обёртку, которая увеличивает внутренний счетчик ссылок V8 и тем самым «удерживает» объект от сборки мусора.
Важно различать сильные и слабые ссылки. Сильная ссылка (Reference, созданная через create_ref()) гарантирует, что объект останется в памяти, пока ссылка существует в Rust. Слабая ссылка создаётся вызовом .unref(&env) на том же объекте: она позволяет сборщику мусора удалить объект, если на него не осталось сильных ссылок в JavaScript, но при этом сама ссылка остаётся валидной для проверки. Это полезно для кэшей, наблюдателей и других сценариев, где объект может быть удалён, и вы хотите корректно обработать эту ситуацию. В napi-rs v3 оба варианта реализованы через один тип ObjectRef, где поведение определяется вызовом unref(), а не отдельным типом.
Жизненный цикл Reference управляется автоматически: при выходе из области видимости срабатывает Drop, который уменьшает счётчик ссылок V8 и освобождает ресурсы. Это означает, что вам не нужно вручную освобождать ссылки — достаточно не сохранять их дольше, чем требуется. Однако для долгосрочного хранения ссылок (дольше одного вызова функции) этот подход не подходит: Reference привязан к времени жизни Env, и попытка сохранить его в структуре потребует сложных хаков с unsafe. В таких случаях лучше использовать паттерны с External, глобальными реестрами или #[napi]-классами, которые берут управление памятью на себя.

Базовую работу со ссылками можно рассмотреть в данном примере:
use napi_derive::napi; #[napi] pub fn demo_strong_reference(obj: Object) -> Result<()> { let _reference: ObjectRef<true> = obj.create_ref::<true>()?; eprintln!("📎 [RUST] Strong reference created"); Ok(()) } #[napi] pub fn demo_weak_reference(env: Env, obj: Object) -> Result<()> { let mut reference: ObjectRef<true> = obj.create_ref::<true>()?; reference.unref(&env)?; eprintln!("🕸️ [RUST] Weak reference created"); Ok(()) } #[napi] pub fn read_property(obj: Object, prop: String) -> Result<Option<String>> { obj.get_named_property::<String>(&prop) .map(Some) .or(Ok(None)) } #[napi] pub fn test_reference_holds(obj: Object) -> Result<bool> { let _reference: ObjectRef<true> = obj.create_ref::<true>()?; let has_name = obj.get_named_property::<String>("name").is_ok(); Ok(has_name) }
console.log('📋 Сценарий 1: Сильная ссылка (предотвращает GC)'); const obj1 = { name: 'StrongObject', value: 42 }; console.log('[TS] Создан объект:', obj1); demoStrongReference(obj1); console.log('[TS] ✅ Сильная ссылка создана и автоматически освобождена после вызова\n'); console.log('📋 Сценарий 2: Слабая ссылка (не предотвращает GC)'); const obj2 = { name: 'WeakObject', data: 'test' }; console.log('[TS] Создан объект:', obj2); demoWeakReference(obj2); console.log('[TS] ✅ Слабая ссылка создана (объект может быть собран в любой момент)\n'); console.log('📋 Сценарий 3: Чтение свойства объекта'); const obj3 = { status: 'active', payload: [10, 20, 30] }; const status = readProperty(obj3, 'status'); console.log(`[TS] property 'status': ${status}`); const missing = readProperty(obj3, 'missing'); console.log(`[TS] property 'missing': ${missing}`); const payload = readProperty(obj3, 'payload'); console.log(`[TS] property 'payload' (ожидаем string, получим null): ${payload}\n`); console.log('📋 Сценарий 4: Ссылка удерживает объект'); const obj4 = { name: 'Temporary' }; const holds = testReferenceHolds(obj4); console.log(`[TS] Объект доступен через ссылку: ${holds}\n`)
Перенос управления Rust ресурсами на сторону JavaScript с помощью External
В сценариях, когда мы хотим управлять Rust-объектами так, будто это JavaScript-объекты, стоит воспользоваться специальной оберткой External. Это механизм, который позволяет «прикрепить» произвольные данные Rust к JavaScript-значению, создавая непрозрачный маркер (opaque handle). Для разработчика на стороне TS этот маркер выглядит как обычный объект, но под капотом он хранит указатель на данные в памяти Rust, обеспечивая прямой доступ к ним без дорогостоящей сериализации.
Такой подход открывает несколько важных возможностей.
1. External незаменим при работе с объектами, которые нельзя просто клонировать или сериализовать: например, мьютексы, атомарные счётчики или сложные графы с циклическими ссылками.
2. Он позволяет передавать большие вложенные структуры по ссылке — вместо копирования мегабайтов данных между кучами V8 и Rust вы передаёте лишь 8-байтовый указатель, что критично для производительности в высоконагруженных сценариях.
3. External предоставляет детерминированный жизненный цикл: когда сборщик мусора JavaScript удаляет маркер, автоматически вызывается деструктор Rust (Drop), что гарантирует корректное освобождение ресурсов — закрытие сокетов, файловых дескрипторов или соединений с базой данных.
Важно понимать, что External — это не просто «сырой указатель», а типобезопасная абстракция. Метод get_value::() гарантирует, что вы получите именно тот тип данных, который был положен внутрь, а система владения Rust предотвращает гонки данных и двойное освобождение памяти. В связке с Reference (который, наоборот, хранит JS-значения в Rust) External образует полноценный двусторонний мост, позволяя строить сложные гибридные архитектуры, где каждая сторона выполняет ту работу, которую умеет лучше всего.
Работу с External можно продемонстрировать на следующем примере:
use napi::{bindgen_prelude::*, JsExternal}; use napi_derive::napi; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Instant; pub struct ExternalLifecycle { pub id: u32, pub created_at: Instant, pub closed: AtomicBool, pub data: Vec<u8>, } impl ExternalLifecycle { pub fn new(id: u32) -> Self { eprintln!("🔨 [RUST] ExternalLifecycle #{} создан", id); Self { id, created_at: Instant::now(), closed: AtomicBool::new(false), data: vec![0; 1024 * 1024], // 1MB для наглядности } } pub fn close(&self) { if self.closed.swap(true, Ordering::SeqCst) { eprintln!("⚠️ [RUST] ExternalLifecycle #{} уже закрыт", self.id); return; } let elapsed = self.created_at.elapsed().as_millis(); eprintln!("🔒 [RUST] ExternalLifecycle #{} закрыт явно (жил {} мс)", self.id, elapsed); } } impl Drop for ExternalLifecycle { fn drop(&mut self) { let elapsed = self.created_at.elapsed().as_millis(); if self.closed.load(Ordering::SeqCst) { eprintln!("✅ [RUST] ExternalLifecycle #{} уничтожен (был закрыт явно)", self.id); } else { eprintln!("⚠️ [RUST] ExternalLifecycle #{} уничтожен сборщиком мусора (НЕ был закрыт явно!)", self.id); eprintln!(" └─ Жил {} мс", elapsed); } } } #[napi] pub fn create_external_lifecycle_resource(id: u32) -> External<ExternalLifecycle> { eprintln!("📦 [RUST] create_resource вызван, создаём External"); External::new(ExternalLifecycle::new(id)) } #[napi] pub fn close_external_lifecycle_resource(resource: JsExternal) -> Result<()> { eprintln!("🔑 [RUST] close_resource вызван из TS"); resource.get_value::<ExternalLifecycle>()?.close(); Ok(()) } #[napi] pub fn get_external_lifecycle_resource_info(resource: JsExternal) -> Result<String> { let res = resource.get_value::<ExternalLifecycle>()?; let elapsed = res.created_at.elapsed().as_millis(); let closed = res.closed.load(Ordering::SeqCst); Ok(format!( "Resource #{}: живёт {} мс, закрыт = {}", res.id, elapsed, closed )) } #[napi] pub fn is_external_lifecycle_resource_closed(resource: JsExternal) -> Result<bool> { Ok(resource.get_value::<ExternalLifecycle>()?.closed.load(Ordering::SeqCst)) }
console.log("📋 Сценарий 1: Явное закрытие ресурса\n"); let resource1 = createExternalLifecycleResource(1); console.log("[TS] Ресурс 1 создан:", resource1); console.log("[TS] Инфо:", getExternalLifecycleResourceInfo(resource1)); console.log("[TS] Работаем с ресурсом..."); console.log("[TS] Вызываем явное закрытие"); closeExternalLifecycleResource(resource1); console.log("[TS] Закрыт?", isExternalLifecycleResourceClosed(resource1)); console.log("[TS] Удаляем ссылку\n"); resource1 = null; console.log("📋 Сценарий 2: Ожидаем сборку мусора\n"); letresource2 = createExternalLifecycleResource(2); console.log("[TS] Ресурс 2 создан:", resource2); console.log("[TS] Инфо:", getExternalLifecycleResourceInfo(resource2)); console.log("[TS] Работаем с ресурсом..."); console.log("[TS] НЕ вызываем closeResource, просто удаляем ссылку"); resource2 = null; console.log("\n📋 Сценарий 3: Защита от двойного закрытия\n"); const resource3 = createExternalLifecycleResource(3); closeExternalLifecycleResource(resource3); console.log("[TS] Повторное закрытие:"); closeExternalLifecycleResource(resource3); // Не должно паниковать console.log("\n🏁 [TS] Скрипт завершён\n"); console.log("[TS] Если запущено с --expose-gc, можно вызвать global.gc()"); console.log("[TS] для принудительной сборки мусора и срабатывания Drop\n")
И еще пара слов про External.
Это низкоуровневый инструмент для случаев, где обычные #[napi]-классы не подходят. Он полезен, когда нужно обернуть чужую Rust-библиотеку, работать с FFI/C-библиотеками вроде libgit2 или sqlite3, спрятать разные Rust-типы за одним JS-интерфейсом либо вернуть временный handle без полноценного объекта.
В остальных ситуациях лучше начинать с #[napi]-класса. Для новых проектов и публичных библиотек он дает более привычный API: new Class(), методы объекта, TypeScript-типы, подсказки в IDE и наследование на стороне JavaScript. External стоит доставать только при реальных ограничениях: чужой код, FFI, полиморфизм, функциональный API в стиле openDb(), query(), close() или жесткие требования к накладным расходам.
Заключение
Node.js все еще прекрасный инструмент для I/O-bound задач: API, микросервисов, работы с базами данных и очередями. Но если в приложении появляется тяжелая CPU-bound логика или нужно подключить существующую Rust-библиотеку, NAPI-RS дает возможность вынести эту часть в нативный аддон без переписывания всего проекта.
Главная польза NAPI-RS — не только в производительности. Он помогает аккуратно провести границу между JavaScript и Rust.
Использовать NAPI-RS стоит точечно: для горячих участков, где важны CPU, память, параллельное выполнение или доступ к готовому нативному коду. В остальных случаях лучше сначала проверить более простые варианты — оптимизацию JavaScript, Worker Threads или изменение архитектуры. Хороший принцип здесь простой: Node.js отвечает за прикладную логику и экосистему, Rust — за участки, где нужна системная производительность.
Делитесь вашим опытом боевого использования NAPI-RS, как относитесь к инструменту и для каких задач используете?

