Раздел 6 · Node.js

Native, WebAssembly, изоляция кода

senior~40 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Native, WebAssembly, изоляция кода

Когда чистого JS не хватает, у Node есть три выхода: написать C/C++ модуль через N-API, скомпилировать критичный код в WebAssembly, загрузить его как “чёрный ящик”. Параллельная история, изоляция: что делать, когда нужно запустить чужой код в том же процессе и не дать ему достать твои секреты.

Этот урок про обе эти темы вместе, потому что в реальности они часто встречаются в одном проекте: “pluginы пишут на любом языке, мы их вкомпилируем в WASM и запустим в sandbox-е”.

1. Что такое native module

Native module это динамически линкуемая библиотека (.node файл, формально это .so/.dylib/.dll), скомпилированная для конкретной ABI Node. Внутри, код на C/C++ (или Rust через FFI), который вызывает JavaScript через специальный API.

Раньше использовали API под названием V8 (Local<Value>, HandleScope и тому подобное). Это был ад: каждый раз при апгрейде Node все native-модули в экосистеме приходилось перекомпилировать, потому что V8 API менялся между версиями.

Сейчас стандарт, Node-API (N-API), ABI-стабильный слой: один и тот же .node работает на разных мажорах Node. Это снимает половину боли поддержки native-модулей.

// hello.c, минимальный N-API модуль
#include <node_api.h>

napi_value Hello(napi_env env, napi_callback_info info) {
  napi_value greeting;
  napi_create_string_utf8(env, "hello from C", NAPI_AUTO_LENGTH, &greeting);
  return greeting;
}

NAPI_MODULE_INIT() {
  napi_value fn;
  napi_create_function(env, NULL, 0, Hello, NULL, &fn);
  napi_set_named_property(env, exports, "hello", fn);
  return exports;
}

Сборка через node-gyp (он же читает binding.gyp-файл с описанием). Из JS вызывается просто:

import bindings from 'bindings';
const native = bindings('hello'); // ищет hello.node в build/Release
console.log(native.hello()); // 'hello from C'

2. Когда тащить native, когда нет

Native, тяжёлая зависимость:

  • Пользователю нужен toolchain (Python, make, C-компилятор) для установки.
  • Нужны бинарники под каждую платформу (linux x64, linux arm64, macos x64, macos arm64, windows x64). Часто публикуют через prebuild или node-pre-gyp.
  • Утечка памяти на C-стороне, и V8 GC ничем не поможет.
  • Отладка через gdb/lldb, не Chrome DevTools.

Когда оправдано:

  • Доступ к системному API, которого нет в Node (низкоуровневая криптография, специфичные ioctl, hardware).
  • CPU-критичный код, где WebAssembly уже не вытягивает (редко).
  • Существующая C-библиотека, переписать которую дорого.

Когда обычно НЕ нужно:

  • “JS медленный, ускорим через C++”. Чаще проще оптимизировать JS (убрать аллокации, переписать через Buffer, использовать worker_threads).
  • “Хочу типизированную математику”. TypedArray и SIMD-128 в WASM закроют 90% задач.
  • “Защитить исходники”. Native не защищает: его так же реверсят, как и обычный код. Plus распространение бинарников, отдельная боль.

Альтернативы native:

  • worker_threads для параллельного CPU.
  • WebAssembly для скорости и переносимости (см. § 4).
  • Вынос критичного куска в отдельный сервис на Rust/Go, общение по сети.

3. ABI-стабильность Node-API

N-API специально спроектирован так, что один и тот же скомпилированный .node-файл работает на нескольких мажорах Node. Это означает:

  • Меньше прекомпилированных бинарников: один build для всех поддерживаемых LTS.
  • Реже падает после nvm install 22: модуль не нуждается в пересборке.
  • Стабильный ABI до major-bump N-API, обещание поддерживать годами.

В TypeScript-эргономике обычно пишут не на C, а через одну из обёрток:

  • node-addon-api (C++), официальная C++-обёртка над N-API. Удобнее raw-C, типы, RAII, exception bridging.
  • napi-rs (Rust), современная альтернатива на Rust. macros для экспорта функций, удобный async-bridge, готовый prebuild через GitHub Actions.

Если выбираешь стек native в 2026, napi-rs это дефолт. Меньше boilerplate, больше безопасности (Rust сам ловит большинство ошибок памяти), прекрасный async-bridge с tokio.

4. WebAssembly: переносимый CPU

WebAssembly (WASM), бинарный формат байт-кода для портативной виртуальной машины. Один и тот же .wasm работает в браузере, Node, Bun, edge-runtimes.

Доступно из коробки, без зависимостей:

import { readFile } from 'node:fs/promises';

const bytes = await readFile('./add.wasm');
const { instance } = await WebAssembly.instantiate(bytes);
const { add } = instance.exports as { add: (a: number, b: number) => number };

console.log(add(2, 3)); // 5

Что такое add.wasm? Самый простой пример, написанный руками в WAT (WebAssembly Text Format):

(module
  (func (export "add") (param $a i32) (param $b i32) (result i32)
    local.get $a
    local.get $b
    i32.add)
)

Компилируется через wat2wasm (часть WABT-toolchain). 26 байтов на выходе. WASM-стек-машина: положили $a, положили $b, выполнили i32.add, она кладёт результат на стек, функция возвращает верх стека.

В реальной жизни WAT мало кто пишет, обычно на:

  • Rust через wasm-bindgen (плюс wasm-pack). Дефолт-2026. Богатый ecosystem, async-bridge, типы между JS и Rust.
  • AssemblyScript, TS-подобный синтаксис, компилирует в WASM. Низкий порог входа, но узкая community.
  • C/C++ через emscripten. Старая школа, для портирования существующих C-проектов.
  • Go, через TinyGo (mainline Go даёт большие WASM, TinyGo оптимизирует).
// в Rust + wasm-bindgen
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 { a + b }

#[wasm_bindgen]
pub fn process_image(data: &[u8]) -> Vec<u8> {
    // тяжёлая обработка картинки
    /* ... */
}

wasm-pack build --target nodejs, и получаешь pkg/-каталог с .wasm плюс .js-обёрткой и TypeScript-определениями. Импортируется обычным import.

5. Когда WASM подходит, когда нет

WASM хорош:

  • CPU-bound в типизированной математике: image processing, encoders, validation grammars, парсеры.
  • Переносимый код: один .wasm для server и для browser.
  • Изоляция: WASM по умолчанию sandboxed, нет доступа к FS/network, только то, что хост явно прокинет.
  • Скорость: близко к native для int/float math. Запуск (“instantiate”) быстрый, ~миллисекунда.

WASM плохо подходит:

  • Работа с DOM, FS, network: всё через WASI или импорты от хоста. Boilerplate.
  • GC-heavy: пока WASM-GC только в стадии стандартизации. Java/Kotlin компилируется в большие модули.
  • Многопоточность: WASM-threads через SharedArrayBuffer, но поддержка между runtime-ами разная.
  • Когда нужны Promise, WASM сам не понимает async, нужен мост через JS.

6. WASI: если WASM нужен I/O

WASI, расширение WASM с системным API: файлы, время, окружение, случайность. Node поддерживает WASI Preview 1 экспериментально.

import { WASI } from 'node:wasi';

const wasi = new WASI({
  version: 'preview1',
  args: process.argv,
  env: process.env,
  preopens: { '/data': './local-data' },
});

const wasm = await WebAssembly.compile(await readFile('./tool.wasm'));
const instance = await WebAssembly.instantiate(wasm, wasi.getImportObject());
wasi.start(instance);

preopens это явный список того, что модуль может видеть. Внутри модуль видит /data, на хосте это ./local-data. За пределы модуль не выйдет, sandbox.

Это ровно тот паттерн, который продают edge-runtime (Cloudflare Workers, Fastly): модуль ничего лишнего не делает, потому что у него нет capability.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.