Skip to content

Node.js native C modules

Graham Wakefield edited this page Mar 1, 2021 · 25 revisions

Writing Node.js modules in C/C++ ("native modules")

Overview:

  • make project folder as a git repo
  • as a Node.js module, it is defined for npm in a package.json file
  • write C++ code against the Node.js API
  • build it with node-gyp via definitions in a binding.gyp file
  • test it / wrap it with a index.js file

Starting a new module project:

Assuming a module called "addon":

mkdir addon
cd addon
git init
npm init
npm install --save bindings
npm install --save-dev node-gyp

Edit the package.json to add "gypfile": true. You might also want to set a postinstall script to run a test after each build:

{
  "name": "<module name>",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "postinstall": "node test.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "bindings": "^1.5.0"
  },
  "devDependencies": {
    "node-gyp": "^6.1.0"
  },
  "gypfile": true
}

Create a binding.gyp file (the code below assumes module name is "addon"):

{
    "targets": [{
        "target_name": "addon",
        "sources": [ "addon.cpp" ],
        "defines": [],
        "cflags": ["-std=c++11", "-Wall", "-pedantic"],
        "include_dirs": [],
        "libraries": [],
        "dependencies": [],
        "conditions": [
            ['OS=="win"', {}],
            ['OS=="mac"', {}],
            ['OS=="linux"', {}],
        ],
    }]
}

A minimal addon.cpp:

#include <node_api.h> 
#include <assert.h>
#include <stdio.h> 
#include <stdlib.h>

napi_value Hello(napi_env env, const napi_callback_info info) {
    printf("hello\n");
    return nullptr;
}

napi_value Goodbye(napi_env env, const napi_callback_info info) {
    napi_value msg;
    napi_create_string_utf8(env, "ciao", NAPI_AUTO_LENGTH, &msg);
    return msg;
}

napi_value Init(napi_env env, napi_value exports) {
    napi_property_descriptor export_properties[] = {
	{
		"Hello", nullptr, Hello,
		nullptr, nullptr, nullptr, napi_default, nullptr
	},
	{
		"Goodbye", nullptr, Goodbye,
		nullptr, nullptr, nullptr, napi_default, nullptr
	},
    };
    assert(napi_define_properties(env, exports, 
	sizeof(export_properties) / sizeof(export_properties[0]), export_properties) == napi_ok);
    return exports;
}

NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)

A minimal index.js as the entry-point for the module:

const addon = require('bindings')('addon');
// module.exports defines what the module actually exposes to code that loads it:
module.exports = addon;

Why module.exports = addon? In fact most native modules use a javascript wrapper, setting index.js as the entry point for the module. This lets you wrap the native code with extra features that are much easier to write in javascript than in C++.

A minimal test.js:

const assert = require("assert")
const addon = require("./index.js");
console.log(addon);

Build & test:

npm install
node test.js

Node-gyp

Node-gyp is a command-line utility for compiling C/C++ code into binaries. It was designed for building node.js modules, but it can also compile general libraries and executables.

The main methods are node-gyp rebuild to build the module and node-gyp clean to delete all build files again

Bindings.gyp

This file is a JSON configuration to tell node-gyp how to build files. Similar to a makefile or CMakeLists.txt file, this specifies the details of how to compile the code, including things like additional include paths, library paths, defines, linker options, etc., but it does so as a JSON structure rather than a sequence of commands. It has very many options.

Bindings.gyp reference docs

Tips

Relative paths are OK in a binding.gyp file, and in most cases are relative to the binding.gyp file location. However for some reason, relative library search paths need an extra ../ in front of them.

Node-gyp disables exceptions by default, but if you need them:

		"cflags!": [ "-fno-exceptions" ],
		"cflags_cc!": [ "-fno-exceptions" ],

To specify things according to the OS platform (important for different libraries, for example):

        "conditions": [
            ['OS=="win"', {}],
            ['OS=="mac"', {}],
            ['OS=="linux"', {}],
        ],

Does your module depend on a dll? If so, you might get weird 'module could not be found' errors, because node can't find the dll in question. To fix, add a 'copies' section to the binding.gyp. Probably this should be inside the "conditions" section for 'OS=="win"'.

        "copies": [{
            'destination': './build/Release',
            'files': [
                "<path to dll>"
            ]
        }],

N-API, AKA Node.js C API, "napi" or "node_api.h"

The API reference docs are here -- but they are really a reference, and not a good place to learn from initially.

There is an official repository of examples here

Here's a quick tutorial on the napi API

Note that there is a confusingly similar named node-addon-api, which is just a C++ wrapper around the N-API.

Getting argument values in a call:

napi_value methodname(napi_env, const napi_callback_info info) {
	size_t argc = 1; // expect 1 arg
	napi_value argv[1]; // this array holds the args
	assert(napi_ok == napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr));

// or
	napi_value this; // this holds the `this` value of the function
	void * data; // this is the implicit void * data attached to the function (if any)
	assert(napi_ok == napi_get_cb_info(env, info, &argc, argv, &this, &data));

Why use N-API?

There are several different APIs for developing native (i.e. C/C++) Node.js modules. Until recently, the recommended API was called nan, however this C++ API does not guarantee stability. But nowadays it is recommended instead to use the napi API, which does guarantee ABI stability. There is also a C++ wrapper of this API, with the confusingly-similar name of node-addon-api.


The C++ node-addon-api

If you want to use the C++ wrapper of napi:

  • install it: npm install --save-dev node-addon-api

  • add to binding.gyp:

  • The C++ file looks like this instead:

#include <napi.h>

Napi::Object InitAll(Napi::Env env, Napi::Object exports) {

  return exports;
}

Error handling

Two options, defined per the gyp configuration: C++ exceptions, or error state flag.

https://github.com/nodejs/node-addon-api/blob/HEAD/doc/error_handling.md

With C++ exceptions

throw Napi::Error::New(env, "Example exception");

The exception will bubble up as a C++ exception of type Napi::Error, until it is either caught while still in C++, or else automatically propagated as a JavaScript exception when returning to JavaScript. Catch in C++ like this:

try {
    result = jsFunctionThatThrows({ arg1, arg2 });
} catch (const Error& e) {
    cerr << "Caught JavaScript exception: " + e.what();
}

Without C++ exceptions

Napi::Error::New(env, "Example exception").ThrowAsJavaScriptException();

Napi::Value result = jsFunctionThatThrows({ arg1, arg2 });
if (env.IsExceptionPending()) {
    Error e = env.GetAndClearPendingException();
    return e.Value();
}

Basic Types

Napi::Env env;
env.Null(), env.Undefined(), env.Global()
env.RunScript(std::string||char *||Napi::String)

Do not store data in C++ global (static) space, since a module may be loaded into more than one running environment/thread.

Option 1: Associate a C++ data item with the current instance of the addon:

template <typename T> using Finalizer = void (*)(Env, T*);
template <typename T, Finalizer<T> fini = Env::DefaultFini<T>>
env.SetInstanceData(T* data);
T* data = env.GetInstanceData();

Option 2: Use the Napi::Addon<T> method: https://github.com/nodejs/node-addon-api/blob/main/doc/addon.md:

class Module : public Napi::Addon<Module> {
public:
	Napi::Value example(const Napi::CallbackInfo& info) {
		return Napi::Number::New(info.Env(), 1);
	}
	Module(Napi::Env env, Napi::Object exports) {
		// See https://github.com/nodejs/node-addon-api/blob/main/doc/class_property_descriptor.md
		DefineAddon(exports, {
			InstanceMethod("index", &Module::example),
			// InstanceValue, InstanceAccessor etc.
		});
	}
};
NODE_API_ADDON(Module)

CallbackInfo (i.e. arguments to a native method)

Napi::Value mymethod(const Napi::CallbackInfo& info) { ... }

info.Env(), info.Length(), info[0] etc., 
info.This() // whatever the JS `this` points to
info.Data() / info.SetData(void*)
if (info.Length() >= 1 && info[0].IsObject()) {
   Napi::Object obj = info[0].As<Napi::Object>();
}

// or, coerced:
if (info.Length() >= 1) {
   Napi::Object obj = info[0].ToObject>();
}

Napi::Value

Represents a JS value, base class of Napi::Number, Boolean, String, etc. https://github.com/nodejs/node-addon-api/blob/main/doc/value.md

ObjectWrap

Defines a class whose instances behave like a JS object, but which has a C++ object backing with members and methods. https://github.com/nodejs/node-addon-api/blob/main/doc/object_wrap.md

Memory lifetime

Normally, Napi values live for as long as the native method call To limit to a shorter scope (e.g. efficient for loop):

for (int i = 0; i < LOOP_MAX; i++) {
  Napi::HandleScope scope(info.Env());
  Napi::Value newValue = // whatever. This newValue dies at the end of this loop iteration.
};

https://github.com/nodejs/node-addon-api/blob/main/doc/object_lifetime_management.md

Create a reference-counted wrapper to prevent a value from garbage collection https://github.com/nodejs/node-addon-api/blob/main/doc/reference.md

ref = Napi::Reference::Reference(env, value)
ref.Env(), ref.IsEmpty(), ref.Value(), 
ref.Ref() / ref.Unref() // incr/decr

Docs

API

Resources

Introductory slides here

There's a full introduction here