Сборка нативных аддонов, prebuilds и когда не надо: WASM и альтернативы
Нативные аддоны собираются node-gyp из binding.gyp — значит, компилятор на этапе установки, если не отгружены prebuilds. WebAssembly, дочерний процесс или обычный JS часто обходят это совсем — поэтому ход сеньора обычно НЕ писать аддон.
Новый сотрудник запускает npm install, и он взрывается. Три тысячи строк красного заканчиваются на gyp ERR! find Python и node-gyp rebuild failed. Транзитивная зависимость двумя уровнями ниже — нативный аддон, а на его свежей машине нет ни Python, ни C++-тулчейна, ни подходящих заголовков, — поэтому установка пытается компилировать C++ из исходников и не может. Тот же пакет позже ломает CI-образ на Alpine (musl, не glibc) и контрибьютора на Windows (нет Visual Studio build tools). Никто в команде не выбирал поддерживать C++-сборку; они просто npm install-нули JSON-парсер, у которого случайно оказался нативный быстрый путь. Цену одной нативной зависимости платит каждая машина, которая её ставит, — а вопрос, который команда так и не задала: нужен ли тому аддону вообще быть нативным.
Как аддоны собираются: node-gyp и binding.gyp
Почему установка одного npm-пакета иногда тянет за собой целую C++-сборку? Ответ — node-gyp; понимание механики объясняет взрыв из хука и показывает, как именно prebuilds это предотвращают.
Когда ты ставишь нативный аддон без предсобранных бинарников, npm запускает его install-скрипт, который обычно зовёт node-gyp — оркестратор сборки Node. node-gyp читает файл binding.gyp (Python-образный JSON-диалект, унаследованный от GYP из Chromium), генерирует платформенные build-файлы (Makefile, проект Xcode или проект MSVC) и зовёт хостовый компилятор, чтобы получить бинарник .node.
# binding.gyp — описывает, что компилировать и как
{
"targets": [
{
"target_name": "addon",
"sources": [ "src/addon.cc", "src/resize.cc" ],
"include_dirs": [
"<!@(node -p \"require('node-addon-api').include\")"
],
"cflags_cc": [ "-O3", "-std=c++17" ]
}
]
}Следствие — ловушка из хука: сборка из исходников требует полного тулчейна на устанавливающей машине — самому node-gyp нужен Python, плюс C/C++-компилятор (GCC/Clang или MSVC на Windows) и заголовки Node под запущенную версию. На dev-машине, которую ты контролируешь, это нормально, но делает аддон хрупким везде остальном: минимальные Docker-образы без build-инструментов падают, musl libc в Alpine ломает допущения, заточенные под glibc, Windows требует Visual Studio build tools, и даже на рабочей конфигурации каждый npm install платит минуты времени компиляции. Нативная зависимость превращает install из «скачать файлы» в «скомпилировать C++-проект» со всей платформенной разнородностью, которую это влечёт.
Prebuilds: отгружай бинарник, пропусти компилятор
Фикс боли на этапе установки — не компилировать при установке: отгружать предсобранные бинарники внутри пакета, по одному на платформу/архитектуру, а при установке просто выбирать подходящий. Это делают две экосистемы: node-pre-gyp (скачивает предсобранные бинарники с хоста вроде S3/GitHub Releases) и prebuildify (упаковывает все предсобранные бинарники в публикуемый npm-тарбол, так что установка делает ноль сети и ноль компиляции — просто копирует нужный файл). prebuildify — модель проще и надёжнее: нет сервера загрузки, от которого зависишь, полностью офлайновые установки.
// package.json — prebuildify кладёт prebuilds/<platform>-<arch>/*.node
{
"scripts": {
// запусти раз на каждой целевой платформе (или в CI-матрице) перед публикацией:
"prebuild": "prebuildify --napi --strip"
}
}Вот где Node-API окупается второй раз. Поскольку бинарник Node-API стабилен по ABI между версиями Node (прошлый урок), ты отгружаешь один prebuild на платформу/арх, и он обслуживает каждую версию Node, которую гоняют твои пользователи. Raw-V8/NAN аддон, напротив, требует prebuild на платформу на каждую ABI-версию Node — комбинаторная матрица, раздувающая публикуемый пакет и ломающаяся в момент выхода нового мажора Node, пока ты не опубликовал подходящий бинарник. Флаг --napi выше делает именно это: режет матрицу prebuild’ов с (платформы × версии Node) до (платформы). В рантайме загрузчик вроде node-gyp-build находит нужный prebuild и падает обратно к компиляции из исходников, только если ни один не подошёл.
▸Почему это работает
Prebuilds сжимают перекрёстное произведение риска нативной сборки, но не стирают его: тебе всё равно нужна сборка каждого бинарника под каждую платформу/арх/libc, которые ты поддерживаешь (linux-x64-glibc, linux-x64-musl, linux-arm64, darwin-arm64, win32-x64…), а это CI-матрица и дисциплина публиковать новый prebuild до того, как появится платформа, которой он нужен. Забудь про musl — и пользователи Alpine откатываются к компиляции из исходников, ровно тому отказу, который prebuilds должны были предотвратить. Prebuilds переносят требование тулчейна с твоих пользователей на твой релизный пайплайн; они его не удаляют.
Альтернативы: WASM, дочерний процесс или просто JS
Прежде чем писать всё это, спроси, нужен ли тебе нативный аддон вообще. Три альтернативы обходят проблему сборки совсем.
WebAssembly. Скомпилируй C, C++ или Rust в .wasm-модуль и загрузи через WebAssembly.instantiate. Артефакт — единственный, переносимый бинарник: нет компиляции под каждую платформу, нет тулчейна при установке, один и тот же .wasm бежит на каждой ОС и архитектуре. Он исполняется в песочнице без окружающего доступа к файловой системе или сети; когда коду нужны syscalls, WASI (WebAssembly System Interface, доступный через node:wasi) выдаёт их явно и с ограничением по capability. Трейдофф: данные переходят в линейную память wasm копированием (нельзя передать JS-объект), а сырая интеграция с ОС/C-библиотекой за пределами WASI недоступна. Отлично для переносимых вычислительных ядер (парсеры, кодеки, крипто, фильтры изображений); не путь к зову произвольного OS API.
import { readFile } from "node:fs/promises";
const bytes = await readFile("./resize.wasm");
const { instance } = await WebAssembly.instantiate(bytes);
const out = instance.exports.resize(/* указатели в линейную память */);Дочерний процесс к нативному бинарнику. Если обкатанный нативный CLI уже существует (ffmpeg, ImageMagick, Rust-утилита), запусти его через child_process и общайся через stdio или файлы. Граница грубая, а процесс изолирован: крэш или баг памяти в бинарнике убивает дочерний процесс, а не твой Node-процесс. Цена тоже реальна — накладные расходы сериализации/IPC, надо деплоить и версионировать внешний бинарник, и это процесс-на-вызов, если не пулить. Подходит, когда работа уже оформлена как инструмент и ты ценишь изоляцию выше скорости в процессе.
Обычный JavaScript. Часто честный ответ. JIT V8 быстр; для очень многих порывов «нам нужен C ради скорости» аккуратная JS-реализация (типизированные массивы, избегание аллокаций в горячих циклах) укладывается в небольшой множитель и несёт ноль риска установки, ноль build-матрицы и ноль кроссплатформенной разнородности. Бремя доказательства — на уходе в нативный код, а не на оставании в JS.
Тебе нужно быстрое, переносимое CPU-ядро (парсер) в Node-библиотеке, которую тысячи команд npm install-нут на Linux, macOS и Windows. Какой подход?
Правило решения: когда НЕ уходить в нативный код
Собери это в правило, применимое до добавления зависимости или написания кода.
Нативный аддон отгружается с prebuildify --napi. Почему флаг --napi драматически сжимает матрицу prebuild'ов по сравнению с NAN/raw-V8 аддоном?
Тебе нужно звать существующий, хорошо поддерживаемый нативный CLI-инструмент (например, ffmpeg) из Node-сервиса, и ты ценишь изоляцию при крэше. Лучший вариант?
- 01Почему нативная зависимость так часто ломает npm install на CI/Alpine/Windows и как prebuilds это чинят?
- 02Сформулируй правило решения: когда писать нативный аддон, а когда — альтернативу.
Нативный аддон ставится через компиляцию: npm запускает node-gyp, который читает binding.gyp, генерирует платформенные build-файлы и зовёт хостовый компилятор — поэтому каждая установка требует Python, C/C++-тулчейн и подходящие заголовки Node, и именно поэтому нативные зависимости ломаются на минимальных Docker-образах, musl в Alpine и Windows, и нагружают CI временем компиляции. Фикс этой боли — prebuilds: отгружать предсобранные бинарники (node-pre-gyp их скачивает, prebuildify упаковывает в тарбол), чтобы установка просто выбирала нужный, а поскольку бинарник Node-API ABI-стабилен, ты отгружаешь один prebuild на платформу вместо одного на платформу на версию Node. Но более глубокий ход — усомниться, нужен ли аддон вообще. WebAssembly даёт почти-нативные, песочничные, единый-артефакт переносимые вычисления (WASI для явных syscalls) без компилятора при установке — идеально для парсеров и кодеков, но не для произвольного доступа к ОС. Дочерний процесс к существующему нативному CLI даёт грубую, изолированную границу ценой IPC и деплоя бинарника. А обычного JavaScript часто хватает без всякого риска. Правило: дефолт — JS, предпочитай WASM для переносимых вычислений и child_process для существующих инструментов, и резервируй настоящий Node-API аддон для доказанной интеграции в процессе с C-библиотекой или OS API — и тогда отгружай prebuilds, чтобы никто не компилировал при установке. Теперь, когда в CI появится нативная зависимость с «gyp ERR!» или коллега откроет PR с новым аддоном, ты умеешь читать ситуацию: не хватает prebuild, неподдерживаемая платформа или зависимость вообще не должна быть нативной?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.