Обработка нативных .node модулей

Файлы с расширением .node представляют собой нативные бинарные дополнения для среды Node.js. По сути это динамические библиотеки, скомпилированные из C, C++ или Rust и подключаемые через механизм require().

Пример подключения:

const nativeAddon = require('./build/Release/addon.node');

Webpack по умолчанию ориентирован на работу с JavaScript, JSON, WASM и ассетами. Нативные бинарники не анализируются как обычные модули и требуют отдельной настройки.

На практике .node файлы используются:

  • в криптографических библиотеках;
  • в системных API;
  • при интеграции с SQLite;
  • в драйверах оборудования;
  • в библиотеках машинного обучения;
  • в высокопроизводительных вычислениях;
  • в Electron-приложениях;
  • в серверных Node.js-приложениях.

Популярные пакеты с нативными модулями:

  • bcrypt
  • sharp
  • sqlite3
  • better-sqlite3
  • node-sass
  • ffi-napi
  • canvas

Почему Webpack не умеет работать с .node автоматически

Webpack анализирует граф зависимостей на этапе сборки. Для JavaScript это возможно благодаря AST-анализу. Нативный бинарный файл не содержит JS-кода, поэтому Webpack не может:

  • разобрать зависимости;
  • выполнить tree shaking;
  • встроить бинарник в bundle;
  • определить платформу бинарника;
  • оптимизировать содержимое.

При попытке импортировать .node напрямую обычно возникает ошибка:

Module parse failed: Unexpected character

или:

You may need an appropriate loader to handle this file type

Особенности нативных модулей

Платформозависимость

.node файл собирается под конкретную ОС и архитектуру:

  • Windows x64
  • Linux ARM64
  • macOS ARM
  • и так далее

Один бинарник нельзя использовать на всех платформах.


ABI-совместимость

Node.js использует ABI-интерфейс. Бинарник, собранный под одну версию Node.js, может не работать в другой версии.

Пример ошибки:

Module version mismatch

Зависимость от системных библиотек

Нативный модуль может требовать:

  • libc
  • Visual C++ Runtime
  • libstdc++
  • OpenSSL
  • GTK
  • Cairo

Webpack не управляет такими зависимостями.


Невозможность браузерного запуска

.node работает только внутри Node.js или Electron.

В браузере такие файлы не исполняются.


Использование node-loader

Наиболее распространённый способ работы с .node — загрузчик node-loader.

Установка:

npm install node-loader --save-dev

Конфигурация:

module.exports = {
  target: 'node',

  module: {
    rules: [
      {
        test: /\.node$/,
        loader: 'node-loader'
      }
    ]
  }
};

Как работает node-loader

node-loader:

  1. копирует бинарный файл в output-директорию;
  2. генерирует JS-обёртку;
  3. подключает бинарник через process.dlopen().

Итоговый код примерно эквивалентен:

process.dlopen(module, binaryPath);

Пример структуры проекта

project/
├── src/
│   └── index.js
├── native/
│   └── addon.node
├── webpack.config.js
└── package.json

Импорт нативного модуля

import addon from '../native/addon.node';

console.log(addon.sum(2, 3));

Конфигурация для Node.js

const path = require('path');

module.exports = {
  mode: 'production',

  target: 'node',

  entry: './src/index.js',

  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'server.js'
  },

  module: {
    rules: [
      {
        test: /\.node$/,
        loader: 'node-loader'
      }
    ]
  }
};

Почему нужен target: 'node'

Webpack может собирать проекты для:

  • браузера;
  • Node.js;
  • Electron;
  • WebWorker;
  • edge runtime.

Нативные модули требуют серверной среды.

Без target: 'node' Webpack попытается:

  • использовать browser polyfills;
  • заменить Node API;
  • собрать код под браузер.

Это приведёт к неработоспособному bundle.


Использование externals

Во многих случаях .node вообще не нужно включать в bundle.

Гораздо безопаснее оставить пакет внешней зависимостью.

Пример:

module.exports = {
  target: 'node',

  externals: {
    sharp: 'commonjs sharp'
  }
};

Тогда:

  • Webpack не трогает пакет;
  • Node.js загружает модуль самостоятельно;
  • нативные бинарники работают штатно.

Когда лучше использовать externals

externals предпочтительнее, если:

  • приложение запускается только в Node.js;
  • deployment включает node_modules;
  • используется Docker;
  • проект серверный;
  • нет необходимости в полном single-bundle.

Когда нужен node-loader

node-loader полезен:

  • в Electron;
  • при упаковке desktop-приложений;
  • в portable build;
  • при создании standalone bundle;
  • при кастомной структуре deployment.

Работа с Electron

Electron активно использует нативные модули:

  • SQLite;
  • SerialPort;
  • ffmpeg bindings;
  • GPU-ускорение;
  • системные API.

Конфигурация:

module.exports = {
  target: 'electron-main',

  module: {
    rules: [
      {
        test: /\.node$/,
        loader: 'node-loader'
      }
    ]
  }
};

Electron Renderer и .node

В renderer-процессе ситуация сложнее.

Если включён:

contextIsolation: true

или:

sandbox: true

нативные модули могут быть недоступны напрямую.

Часто используется схема:

Renderer
    ↓ IPC
Main Process
    ↓
Native Module

Использование @vercel/webpack-asset-relocator-loader

Некоторые нативные библиотеки динамически загружают бинарники.

Например:

require(path.join(__dirname, 'binding.node'));

Webpack не способен корректно обработать такие вызовы статически.

Для решения используется:

npm install @vercel/webpack-asset-relocator-loader --save-dev

Конфигурация:

module.exports = {
  module: {
    rules: [
      {
        test: /\.(m?js|node)$/,
        parser: {
          amd: false
        },
        use: {
          loader: '@vercel/webpack-asset-relocator-loader',
          options: {
            outputAssetBase: 'native_modules'
          }
        }
      }
    ]
  }
};

Что делает Asset Relocator

Лоадер:

  • анализирует require;
  • отслеживает бинарные зависимости;
  • копирует .node;
  • переписывает пути;
  • сохраняет runtime-совместимость.

Особенно полезен для Electron Builder.


Комбинирование нескольких loaders

Иногда используется связка:

rules: [
  {
    test: /\.node$/,
    use: 'node-loader'
  },
  {
    test: /\.(m?js|node)$/,
    parser: {
      amd: false
    },
    use: {
      loader: '@vercel/webpack-asset-relocator-loader'
    }
  }
]

Runtime-пути к бинарникам

Очень распространённая проблема — потеря путей после сборки.

Исходный код:

const addon = require('./build/Release/addon.node');

После bundling структура директорий меняется.

В результате:

Cannot find module addon.node

Использование __dirname

Для Node.js:

const path = require('path');

const addon = require(
  path.join(__dirname, 'addon.node')
);

Проблемы с Webpack и __dirname

Webpack может заменить __dirname.

Для Node.js рекомендуется:

module.exports = {
  node: {
    __dirname: false
  }
};

В Webpack 5 поведение изменилось, и настройка применяется реже, но в legacy-проектах проблема остаётся актуальной.


Динамический require

Плохо:

require('./addons/' + name + '.node');

Webpack не может определить зависимости во время сборки.

Лучше:

const addons = {
  sqlite: require('./addons/sqlite.node'),
  crypto: require('./addons/crypto.node')
};

Использование resolve.extensions

Иногда добавляют:

resolve: {
  extensions: ['.js', '.json', '.node']
}

Это позволяет:

require('./addon');

вместо:

require('./addon.node');

Обработка prebuild-бинарников

Многие библиотеки поставляют готовые бинарники:

prebuilds/
├── win32-x64/
├── linux-x64/
└── darwin-arm64/

Webpack может случайно включить лишние платформы.

Это увеличивает размер bundle в десятки раз.


Ограничение платформ

Полезно использовать:

new webpack.IgnorePlugin({
  resourceRegExp: /^\.\/linux-arm64$/,
});

или:

new webpack.ContextReplacementPlugin(
  /prebuilds/,
  path.resolve(__dirname, 'prebuilds/win32-x64')
);

Tree shaking и .node

Tree shaking не работает с бинарными модулями.

Webpack не знает:

  • какие функции экспортируются;
  • какие части используются;
  • какие символы можно удалить.

Любой .node рассматривается как opaque binary blob.


Code Splitting

Нативные модули могут участвовать в lazy loading.

Пример:

async function loadAddon() {
  return import('./addon.node');
}

Но фактическая выгода ограничена:

  • бинарник всё равно загружается целиком;
  • chunk не разбивается;
  • preload может не работать.

Source Maps

Source maps для .node отсутствуют.

Отладка обычно ведётся:

  • через gdb;
  • lldb;
  • Visual Studio;
  • CLion;
  • native debugger.

Node-API (N-API)

Современные нативные модули всё чаще используют Node-API.

Преимущества:

  • ABI-стабильность;
  • меньше проблем с версиями Node.js;
  • совместимость между релизами;
  • снижение необходимости пересборки.

Отличие NAN и N-API

NAN

Старый подход:

#include <nan.h>

Минусы:

  • зависимость от V8;
  • ABI-нестабильность;
  • частые rebuild.

N-API

Современный подход:

#include <node_api.h>

Плюсы:

  • стабильный ABI;
  • лучшая переносимость;
  • меньше проблем при обновлении Node.js.

Webpack и node-gyp

Webpack не компилирует .node.

Сборка выполняется отдельно:

node-gyp rebuild

или:

npm install

Webpack работает уже с готовым бинарником.


Типичный pipeline

C++ Source
    ↓
node-gyp
    ↓
addon.node
    ↓
Webpack
    ↓
Bundle

Проблемы CI/CD

Нативные модули часто ломают CI:

  • отсутствует Python;
  • нет gcc;
  • нет build-tools;
  • неподходящая архитектура;
  • несовместимая Node.js ABI.

Alpine Linux и musl

Многие prebuild-бинарники рассчитаны на glibc.

В Alpine используется musl.

Частая ошибка:

Error loading shared library

Решения:

  • использовать Debian-based image;
  • пересобирать бинарники;
  • применять musl-compatible builds.

Docker и .node

Рекомендуется multi-stage build:

FROM node:22 AS build

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

FROM node:22-slim

WORKDIR /app

COPY --from=build /app .

CMD ["node", "dist/server.js"]

Serverless и нативные модули

AWS Lambda и другие serverless-платформы требуют:

  • правильной архитектуры;
  • совместимого glibc;
  • Linux-compatible binary.

Сборка под Windows и запуск в Lambda почти всегда приводят к ошибкам.


Webpack Ignore Warnings

Некоторые пакеты содержат optional native bindings.

Пример:

try {
  module.exports = require('./native.node');
} catch {
  module.exports = require('./fallback.js');
}

Webpack может выдавать предупреждения.

Подавление:

ignoreWarnings: [
  {
    module: /native\.node/
  }
]

Fallback на JavaScript-реализацию

Многие библиотеки имеют два режима:

  • native;
  • pure JS.

Пример:

let binding;

try {
  binding = require('./native.node');
} catch {
  binding = require('./fallback.js');
}

module.exports = binding;

Это улучшает переносимость.


Runtime detection платформы

Иногда выбирается бинарник вручную:

const platform = process.platform;
const arch = process.arch;

Пример:

const addon = require(
  `./prebuilds/${platform}-${arch}/addon.node`
);

Такие конструкции плохо анализируются Webpack.


Контроль output-пути

Настройка:

{
  test: /\.node$/,
  loader: 'node-loader',
  options: {
    name: '[name].[ext]'
  }
}

или:

options: {
  name: 'native/[contenthash].[ext]'
}

Asset Modules и .node

Webpack 5 поддерживает Asset Modules, но .node не является обычным asset.

Использование:

{
  test: /\.node$/,
  type: 'asset/resource'
}

обычно недостаточно.

Файл скопируется, но корректная загрузка через dlopen не произойдёт автоматически.


Производительность

Нативные модули могут значительно ускорять:

  • хеширование;
  • обработку изображений;
  • сжатие;
  • SQL;
  • бинарные операции.

Но присутствуют издержки:

  • сложность deployment;
  • platform lock;
  • проблемы ABI;
  • сложность сборки;
  • увеличение размера дистрибутива.

Диагностика ошибок

Проверка ABI

node -p process.versions.modules

Проверка архитектуры

Linux:

file addon.node

Windows:

dumpbin /headers addon.node

Проверка зависимостей

Linux:

ldd addon.node

macOS:

otool -L addon.node

Частые ошибки

Module version mismatch

Причина:

  • бинарник собран под другую версию Node.js.

Invalid ELF Header

Причина:

  • неверная платформа;
  • повреждённый бинарник;
  • Windows binary в Linux.

Cannot open shared object file

Причина:

  • отсутствует системная библиотека.

Cannot find module ’*.node’

Причина:

  • Webpack потерял бинарник;
  • неверный output path;
  • проблема с loader.

Лучшие практики

Для серверных приложений

Наиболее стабильный вариант:

externals: {
  sqlite3: 'commonjs sqlite3'
}

Для Electron

Обычно используются:

  • node-loader
  • asset-relocator-loader

Для portability

Лучше выбирать библиотеки:

  • с N-API;
  • с prebuild binaries;
  • с active maintenance.

Для Docker

Желательно:

  • одинаковое окружение build/runtime;
  • одинаковая версия Node.js;
  • одинаковая libc.

Для CI

Полезно фиксировать:

Node.js version
npm version
architecture
platform
ABI