Primer into NodeJS Native Modules
Note: This article does not cover anything concerning WASM standard. Here is discussed only the old-fashioned C++ API for building Node.js modules.
A lot was said on the internets about the subject of writing modules in C++ for Node.js. A lot of abstractions were built. The most of the them won’t be able to beat the robustness and conciseness of the official docs but I’m going to take it a bit slower and just document the way I arrived at a very basic level of understanding of the subject. This exploration is, of course, just a very basic starting point with a lot of details left out to be figured as we move forward.
Lifecycle of a node module #
First of all, we need to understand the fact that in order to be able to use C++
code from JavaScript, we need to get the C++ code compiled into a special binary
file. These files end with .node
extension and they contain a low-level
representation of a Node.js module. Node’s require()
function knows how to
treat them properly and a properly compiled C++ module just works out of the
box.
That’s how a manual require looks:
const nativeModule = require('./build/Release/native');
In this case, the module is named native.node
and very often they’re located
in the build/Release
folder relative to the project root folder. More on that
folder structure later.
Running the Hello world #
We’ll start with the obligatory hello world.
You’ll need a C++ toolkit already installed on your system (g++
on Unix-like
systems and Visual Studio on Windows). More details can be read on
node-gyp
’s README file).
mkdir native-modules
cd native-modules
touch binding.gyp
touch package.json
touch main.js
# Here we're going to put C++ source code
touch main.cpp
Fill package.json
:
{
"name": "node-native-modules-hello-world",
"version": "0.0.1",
"main": "index.js",
"license": "MIT",
"gypfile": true,
"scripts": {
"install": "node-gyp rebuild",
"start": "node index.js"
}
}
Putting node-gyp rebuild
command in the install
script will make sure your
native modules will get compiled every time you run npm install
, this is
actually called a hook and here’s more of
them. Don’t worry about node-gyp
binary, it is pre-installed nowadays
alongside Node on every system.
This node-gyp
binary is actually where all the convenience lives. It is a very
smart utility that knows how to generate build systems on a cross-platform
basis, depending on where it is being run. That’s actually where its name comes
from: GYP for Generate Your Projects and it has its roots from the
GYP project of the Chromium team. It knows how to
generate a Visual Studio project on Windows and a
make-based process on Unix, but we’re
getting into details here and I really want to keep everything simple.
The next important bit is the gypfile: true
flag in our package.json
file.
It indicates that node-gyp
should take the binding.gyp
file, which we
already created, into consideration. Here’s what we are going to fill this file
with:
{
"targets": [
{
"target_name": "native",
"sources": [ "main.cpp" ]
}
]
}
Here we indicate that we intend to generate a native.node
module and it should
be the result of compiling main.cpp
.
Here’s what will suffice for our example on the C++ side of things (put that in
main.cpp
):
#include <node.h>
void HelloWorld(const v8::FunctionCallbackInfo<v8::Value>& args)
{
v8::Isolate* isolate = args.GetIsolate();
auto message =
v8::String::NewFromUtf8(isolate, "Hello from the native side!");
args.GetReturnValue().Set(message);
}
void Initialize(v8::Local<v8::Object> exports)
{
NODE_SET_METHOD(exports, "helloWorld", HelloWorld);
}
NODE_MODULE(module_name, Initialize)
This will look very familiar if you have any level of proficiency with C++. Here
we define a function named HelloWorld
that just returns a string. Next, we
declare the helloWorld
property onto the exports
object to have the value
HelloWorld
. This effectively results in a module that exports a function,
which returns a basic string. That’s the equivalent JS code:
function HelloWorld() {
return 'Hello from the native side!';
}
module.exports.helloWorld = HelloWorld;
Now we have the job of compiling this bit of code into a native.node
file.
npm install
ls build/Release
You can see that it generated a ./build/Release/native.node
file, which is a
module waiting us to require and use it!
Now, we’ll go ahead and use this module (put that in main.js
):
let native = require('./build/Release/native.node');
console.log(native.helloWorld());
Because the native.node
module is already compile, we can safely run main.js
file and watch it run:
node main.js
Hello from the native side!
The require(...)
part looks a bit ugly but we can very easily fix it with the
help of a very small npm module called
bindings
.
npm install bindings
And use the module right away. Here’s the resulting main.js
file:
let native = require('bindings')('native');
console.log(native.helloWorld());
A lot simpler and no need to manually trace the path to the native.node
file!
bindings
will do the heavy lifting for us.
An little more complex example #
Next, we’re going to perform a computation that’s a bit more involved on the C++ side of things, just to prove we’re heading into the right direction.
We are going to create a function in C++ which takes a variable amount of
arguments and print them using the famous printf()
function. The trick is to
pass only numbers from JavaScript and output each number in as much base systems
as possible. We’re going to handle as 2, 6, 7, 8 and 16 bases. That will be
enough for us to get dangerous enough.
The folder structure we are going to use:
├── binding.gyp
├── build
│ ├── Makefile
│ ├── Release
│ ├── binding.Makefile
│ ├── config.gypi
│ ├── gyp-mac-tool
│ └── native.target.mk
├── main.cpp # C++ code for actually outputting formatted strings
├── main.js # the JS source code for running the program
└── package.json
4 directories, 11 files
Here's the actual C++ code that implements the logic for converting numbers:
void NativePrintf(const v8::FunctionCallbackInfo<v8::Value>& args)
{
int number = (int) args[0]->NumberValue();
std::cout << "Base 10: ";
convertDecimalToOtherBase(number, 10);
std::cout << std::endl;
std::cout << "Base 2: ";
convertDecimalToOtherBase(number, 2);
std::cout << std::endl;
std::cout << "Base 6: ";
convertDecimalToOtherBase(number, 6);
std::cout << std::endl;
std::cout << "Base 7: ";
convertDecimalToOtherBase(number, 7);
std::cout << std::endl;
std::cout << "Base 8: ";
convertDecimalToOtherBase(number, 8);
std::cout << std::endl;
std::cout << "Base 16: ";
convertDecimalToOtherBase(number, 16);
std::cout << std::endl;
std::cout << "-------------";
std::cout << std::endl;
}
The function convertDecimalToOtherBase()
is ommited for brevity.
The full source code for the example can be found on the GitHub repository.
As you can see, with a little bit of help from C++, you can achieve pretty complex stuff very easily. You can implement complex apps that launch pipes or FIFOs and embed them in its entirety into your existing Node app, or you can use popular networking libraries for C++ into your small Node program. The imagination is the limit.
Andrei Glingeanu's notes and thoughts. You should follow him on Twitter, Instagram or contact via email. The stuff he loves to read can be found here on this site or on goodreads. Wanna vent or buy me a coffee?