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.