Нативные аддоны (Native Addons) — это модули, написанные на языках низкого уровня, чаще всего на C или C++, которые подключаются к приложениям Node.js как обычные JavaScript-пакеты. Они используются для решения задач, где требуется высокая производительность, доступ к системным API или взаимодействие с существующими библиотеками операционной системы.
Типичные примеры:
После компиляции такие модули обычно представлены файлами с
расширением .node, которые являются динамическими
библиотеками.
Структура проекта может выглядеть следующим образом:
project/
├── src/
├── node_modules/
│ └── native-package/
│ ├── binding.gyp
│ ├── src/
│ └── build/
│ └── Release/
│ └── addon.node
└── package.json
Esbuild специализируется на обработке JavaScript, TypeScript, JSX и CSS. Его основная задача — анализ графа зависимостей и создание оптимизированного набора выходных файлов.
При работе с нативными аддонами возникают дополнительные сложности:
Например:
const sharp = require("sharp");
С точки зрения Esbuild это обычный импорт. Однако внутри пакета
sharp находится набор нативных бинарников для различных
платформ.
Esbuild не компилирует подобные зависимости и не умеет
преобразовывать C++-код в .node-модули.
Esbuild не является заменой:
Рассмотрим типичный процесс сборки аддона:
C++ исходники
↓
node-gyp
↓
Компилятор C++
↓
addon.node
↓
Node.js Runtime
Esbuild участвует только в сборке JavaScript-части приложения.
Он не выполняет:
#include <napi.h>
Napi::String Hello(const Napi::CallbackInfo& info) {
return Napi::String::New(info.Env(), "Hello");
}
Компиляция такого кода должна происходить отдельно.
Рассмотрим код:
const addon = require("./build/Release/addon.node");
При анализе зависимостей Esbuild обнаружит импорт, однако не встроит бинарный файл внутрь итогового JavaScript-бандла.
Например:
await esbuild.build({
entryPoints: ["src/index.js"],
bundle: true,
outfile: "dist/app.js"
});
После сборки:
dist/
└── app.js
Файл:
build/Release/addon.node
не окажется внутри бандла автоматически.
Поэтому при деплое необходимо отдельно переносить бинарные файлы.
Нативные аддоны создаются для конкретной платформы.
Например:
| Платформа | Архитектура |
|---|---|
| Windows | x64 |
| Windows | ARM64 |
| Linux | x64 |
| Linux | ARM64 |
| macOS | x64 |
| macOS | ARM64 |
Бинарник, собранный для Linux:
addon-linux-x64.node
не будет работать на Windows:
addon-win32-x64.node
Esbuild не выполняет кросс-компиляцию таких файлов.
Если приложение собирается под несколько платформ:
node build-linux.js
node build-windows.js
node build-macos.js
то соответствующие нативные зависимости должны подготавливаться отдельно.
JavaScript-файлы могут быть встроены непосредственно в выходной бандл:
import helper from "./helper.js";
После сборки содержимое модуля окажется внутри итогового файла.
Для нативных библиотек подобный подход невозможен.
Файл:
addon.node
представляет собой платформенный машинный код и должен существовать как отдельный объект файловой системы.
Node.js загружает его через механизм динамических библиотек:
require("./addon.node");
Поэтому даже при создании одного выходного файла полностью автономный бандл получить не удастся.
Многие популярные библиотеки определяют платформу во время выполнения.
Пример упрощённой логики:
switch (process.platform) {
case "win32":
module.exports = require("./win32/addon.node");
break;
case "linux":
module.exports = require("./linux/addon.node");
break;
case "darwin":
module.exports = require("./darwin/addon.node");
break;
}
Для Esbuild подобный код представляет проблему.
На этапе сборки невозможно гарантированно определить:
В результате некоторые бинарники могут не попасть в финальную поставку.
Наиболее распространённое решение — исключение нативных зависимостей из бандла.
Пример:
await esbuild.build({
entryPoints: ["src/index.js"],
bundle: true,
outfile: "dist/app.js",
external: ["sharp"]
});
После сборки:
require("sharp");
останется без изменений.
Esbuild не будет пытаться анализировать внутреннее устройство пакета.
Такой подход считается рекомендуемым для большинства нативных библиотек.
Многие аддоны ожидают наличие полной структуры пакета.
Например:
node_modules/
└── package/
├── index.js
├── vendor/
├── bindings/
└── build/
Если оставить только один собранный файл:
dist/app.js
модуль может перестать работать.
Причина заключается в использовании относительных путей:
path.join(__dirname, "build", "Release");
или
fs.readFileSync(...)
Esbuild не способен автоматически восстановить такую структуру.
Во многих старых нативных модулях используется библиотека
bindings.
Пример:
const bindings = require("bindings");
module.exports = bindings("addon");
Во время выполнения библиотека пытается найти подходящий бинарник:
build/Release/addon.node
build/Debug/addon.node
compiled/version/platform/arch/addon.node
Поиск происходит динамически.
Для статического анализатора Esbuild подобное поведение практически непрозрачно.
Из-за этого могут возникать ошибки:
Could not resolve module
или
Cannot find addon.node
после деплоя.
Tree Shaking хорошо работает с ESM-модулями:
import { foo } from "./lib.js";
Однако нативные библиотеки часто используют:
require(...)
и динамические конструкции:
require(pathToAddon);
Подобный код затрудняет статический анализ.
В результате Esbuild вынужден сохранять дополнительные участки кода, что уменьшает эффективность оптимизации.
Для JavaScript-файлов Esbuild выполняет:
Например:
const longVariableName = 1;
может превратиться в:
const a=1;
Для файлов:
addon.node
такие преобразования невозможны.
Esbuild рассматривает их как непрозрачные бинарные объекты.
Иногда применяются специальные загрузчики:
await esbuild.build({
loader: {
".node": "file"
}
});
В этом случае Esbuild копирует бинарник в выходную директорию.
Например:
dist/
├── app.js
└── addon-XYZ.node
Импорт преобразуется в путь к скопированному файлу.
Однако такой подход работает не для всех библиотек и не устраняет платформенные ограничения.
Пакет:
import sharp from "sharp";
использует набор предсобранных бинарников.
Обычно рекомендуется:
external: ["sharp"]
и перенос каталога node_modules вместе с
приложением.
Пакет:
const Database = require("better-sqlite3");
также содержит нативный код.
При упаковке приложения часто используется:
external: ["better-sqlite3"]
чтобы избежать проблем с поиском бинарников.
Библиотека:
const { createCanvas } = require("canvas");
зависит от системных графических библиотек.
Даже если Esbuild корректно обработает JavaScript-код, наличие необходимых системных зависимостей остаётся обязательным.
Для проектов с нативными аддонами наиболее надёжной считается следующая схема:
await esbuild.build({
entryPoints: ["src/index.js"],
bundle: true,
platform: "node",
outfile: "dist/app.js",
external: [
"sharp",
"better-sqlite3",
"canvas"
]
});
После сборки:
dist/
├── app.js
└── node_modules/
Вместе с приложением поставляются:
.node-файлы;Использование Esbuild остаётся эффективным в следующих сценариях:
При этом нативные аддоны рассматриваются как внешние зависимости, жизненный цикл которых остаётся за пределами возможностей Esbuild.
Ключевое ограничение заключается в том, что Esbuild является
высокоскоростным сборщиком JavaScript и TypeScript, а не инструментом
компиляции нативного кода. Файлы .node требуют отдельной
сборки, зависят от платформы и обычно должны поставляться вместе с
приложением как внешние бинарные компоненты.