Привет, Хабр! Меня зовут Сергей Маркизов и я бэкенд-разработчик в Далее

Периодически я сталкиваюсь с задачами, где привычного 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 и системная производительность.

Так Node.js-микросервис получает возможность обрабатывать данные в десятки потоков, используя все ядра процессора, в то время как основной поток event loop остаётся свободным для обработки новых запросов.
Так Node.js-микросервис получает возможность обрабатывать данные в десятки потоков, используя все ядра процессора, в то время как основной поток event loop остаётся свободным для обработки новых запросов.

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, как относитесь к инструменту и для каких задач используете?