Node.js native addon without node-gyp

🌐 - 简体中文

Contents

Why

It's certain that we need to bind native code for some compute-intensive tasks. Nowadays, NAPI (node-api) + node-gyp is the most popular pair for Node.js native addons.

But due to historical reasons node-gyp is really sucks using node-gyp can be difficult. Some of the disadvantages of node-gyp include that it can easily result in problems that are hard to resolve. And the gyp, which node-gyp based on, is more opaque and less used by normal C / C++ developers. So why not use our general-purpose build tools instead of the node-gyp?

Build an Addon Manually

git clone https://github.com/kkocdko/hinapi

NAPI is a set of ABI stable functions, include the node_api.h the crux of the matter. And here we want to use node-addon-api (c++), certainly, you can use only C.

The easiest way if you already have CMake installed (don't run away! We will soon mention no cmake method):

# in ./hinapi
cmake -B build
cmake --build build
node src/main.js
du third_party -sh

Outputs:

[kkocdko@klf hinapi]$ cmake -B build
-- The C compiler identification is GNU 12.2.1
...
added 2 packages in 624ms
...
-- Build files have been written to: /home/kkocdko/misc/code/hinapi/build

[kkocdko@klf hinapi]$ cmake --build build
[ 50%] Building CXX object CMakeFiles/hinapi.dir/src/hinapi.cc.o
[100%] Linking CXX shared library hinapi.node
[100%] Built target hinapi

[kkocdko@klf hinapi]$ node src/main.js
calc 1 + 2 = 3
created object = { name: 'tom', age: 'tom' }
callback argument = hello world
promise resolved value = 1.2

[kkocdko@klf hinapi]$ du third_party -sh
508K    third_party

The CMakeLists.txt in template repo is "just works", supports MSVC, GCC, Clang, on Linux, Windows (MSVC and MinGW), and macOS. See this repo's GitHub Actions.

If you wouldn't want CMake, I can tell you what CMakeLists.txt do:

  1. Download node-api-headers (currently, it's from Node.js 19) and node-addon-api (c++) to ./third_party.

  2. What we want is hinapi.node, a dynamic library. On Linux, run g++ src/hinapi.cc -o build/hinapi.node -I third_party/node-addon-api -I third_party/node-api-headers/include -shared -fPIC.

  3. However, Windows require a DLL to resolve all symbols at linking. so we need a .def file which defined the exported NAPI functions, and use lib (MSVC) / dlltool (MinGW) to create libnode.lib (MSVC) / libnode.a (MinGW), then link the static libs. Thank goodness this is much easier on macOS, just add the -undefined dynamic_lookup.

Pros: 1. Lightweight, less than 0.6 MiB dependencies. 2. Transparent, fully control your compilation process.

That's all! Now, try node src/main.js.

Do You Really Need NAPI?

Let's go back to the beginning:

It's certain that we need to bind native code for some compute-intensive tasks.

Is this really true? Not always. For some workloads, you can try the following methods to bring native speed to your Node.js app.

C FFI

Wouldn't want advanced features like async, promise and complex JavaScript objects? Call dlopen() and just invoke dynamic libs' exported functions.

This function is also implemented in both Bun and Deno, you'll able to run your app on these runtimes less painful.

By Stdio

There's a slogan in Effective Go:

Do not communicate by sharing memory; instead, share memory by communicating.

ESBuild, a bundler for web, explain this for us perfectly! It use stdio to communicate between ESBuild process and Node.js process. Not only, but many famous projects like LSP use this.

You may doubt it's performance, however, in many cases, the bottleneck of stdio is terminal, not shell / program. On my machine, stdio takes 3x time compare to memcpy while transport same data.

The pros are simpler and better compatibility (more languages' invoking support), yeah, stdio is one of the most common and initial IPC methods.

But the cons are not to be overlooked. You need to process and wrap data on both ends, an extra process is needed, causing bigger latency and heavier memory footprint.

End

Hoping this post will give you more options when you need native performance in Node.js apps. Thanks for every project / link mentioned above.

Translation

简体中文

目录

  • 为何要尝试更多的原生代码绑定方式?

  • 在 Linux 和 macOS 上手动编译原生插件。

  • 在 Windows 上遇到的限制和解决方法。

  • 你可能不需要 NAPI。例如,ESBuild 使用了一种特殊的方法。

为什么

显然,我们需要为一些计算密集型的任务绑定原生代码。当前,NAPI (node-api) + node-gyp 这个组合是为 Node.js 构建原生插件的最流行方式。

但由于历史原因,使用 node-gyp 会让人感到沮丧。node-gyp 的一些缺点包括:它很容易导致 难以解决的问题。而且,node-gyp 所基于的 gyp 较为不透明,很少被普通 C / C++ 开发人员使用。因此,为什么不使用我们常用的通用构建工具来替代 node-gyp?

手动构建一个插件

git clone https://github.com/kkocdko/hinapi

NAPI 是一组 ABI stable 的函数,导入 node_api.h 是最重要的步骤。这里我们还要使用 node-addon-api (c++),当然,你可以只使用 C。

最简单的途径是,假设你已经安装了 CMake(别跑!我们很快就会提到没有 cmake 的方法):

# 在目录 ./hinapi
cmake -B build
cmake --build build
node src/main.js
du third_party -sh

输出:

[kkocdko@klf hinapi]$ cmake -B build
-- The C compiler identification is GNU 12.2.1
...
added 2 packages in 624ms
...
-- Build files have been written to: /home/kkocdko/misc/code/hinapi/build

[kkocdko@klf hinapi]$ cmake --build build
[ 50%] Building CXX object CMakeFiles/hinapi.dir/src/hinapi.cc.o
[100%] Linking CXX shared library hinapi.node
[100%] Built target hinapi

[kkocdko@klf hinapi]$ node src/main.js
calc 1 + 2 = 3
created object = { name: 'tom', age: 'tom' }
callback argument = hello world
promise resolved value = 1.2

[kkocdko@klf hinapi]$ du third_party -sh
508K    third_party

模板 repo 中的 CMakeLists.txt 是“刚好够用”的,支持 MSVC、GCC、Clang,在 Linux、Windows(MSVC 和 MinGW)、macOS 上运行。参见 该 repo 的 GitHub Actions

如果你不想要 CMake,我可以告诉你 CMakeLists.txt 具体做了什么:

  1. 下载 node-api-headers(当前它提取自 Node.js 19)和 node-addon-api./third_party 目录。

  2. 我们的目标是编译出一个名为 hinapi.node 的动态库。在 Linux 上,运行 g++ src/hinapi.cc -o build/hinapi.node -I third_party/node-addon-api -I third_party/node-api-headers/include -shared -fPIC

  3. 然而,Windows 要求 我们提供一个 DLL 来确定链接时的所有符号。所以我们需要 一个 .def 文件,它定义了导出的 NAPI 函数,并使用 lib (MSVC) / dlltool (MinGW) 来创建 libnode.lib(MSVC)/ libnode.a(MinGW),然后链接静态库。值得庆幸的是,这在 macOS 上要容易得多,只需添加 -undefined dynamic_lookup 就可以了。

优点:1. 轻量级,依赖体积小于 0.6 MiB。2. 透明,可以完全控制的编译过程。

完成啦!现在,试试运行 node src/main.js

你真的需要 NAPI 吗?

让我们回到开头:

显然,我们需要为一些计算密集型的任务绑定原生代码。

真的是这样吗?并非总是如此。对于某些工作负载,你可以尝试以下方法,为你的 Node.js 应用带来原生速度。

C FFI

不需要 async、Promise 和复杂对象这样的高级功能?尝试调用 dlopen(),并直接执行动态库的导出函数。

这个函数在 BunDeno 中也有实现,这意味着你将能够在这些运行时上运行你的应用程序,为适配问题少操点心。

通过 Stdio

Effective Go 中有一句 格言

不要通过共享内存来通信;相反,通过通信来共享内存。

ESBuild 是一个用于 Web 技术栈的打包器,它为我们完美地解释了这一点!它 使用 stdio 在 ESBuild 进程和 Node.js 进程之间通信。不仅如此,许多著名的项目,例如 LSP,都在使用这个方法。

你可能对它的性能有所疑虑,然而,在很多情况下,stdio 的瓶颈是终端模拟器,而不是 shell 和程序本身。在我的机器上传输相同数据时,stdio 使用了 3 倍于 memcpy 的时间。

优点是更简单的概念和更好的兼容性(更多语言的调用支持),是的,stdio 是最常见和最原始的 IPC 方法之一。

但缺点也是不容忽视的。你需要对两端的数据进行处理和包装,额外的数据处理,导致更大的延迟和更多的内存占用。

结束

希望这篇文章能让你在 Node.js 应用中需要原生性能时有更多选择。感谢上面提到的每个项目 / 链接,谢谢~