open atlas
↑ К треку
Node.js с нуля до senior NODE · 12 · 02

Сборка нативных аддонов, prebuilds и когда не надо: WASM и альтернативы

Нативные аддоны собираются node-gyp из binding.gyp — значит, компилятор на этапе установки, если не отгружены prebuilds. WebAssembly, дочерний процесс или обычный JS часто обходят это совсем — поэтому ход сеньора обычно НЕ писать аддон.

NODE Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Новый сотрудник запускает 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-сервиса, и ты ценишь изоляцию при крэше. Лучший вариант?

Вспомните перед уходом
  1. 01
    Почему нативная зависимость так часто ломает npm install на CI/Alpine/Windows и как prebuilds это чинят?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.