I’ve always felt like a good way to fully understand a language is to play with the native interface it exposes. I’ve already done a post playing with a native C Ruby extension, and now that I’m using Node.js more I felt like I should dig into it. Here is how I ended up playing with a native C extension for Node.js. I did not have a real goal while playing around, so I did the exact same thing I did for Ruby. As always, the code is hosted on my GitHub repository!
Why a native extension
Node.js is a great tool for event-based concurrency. Having a garbage collector and being single-threaded makes for a language that is easy to get into and fast. But like any language, it is not perfect.
For example, Node.js is not the best if you need to do some CPU-bound computations. Even using threads won’t help since only one Node.js thread will run at a time. There is also the case of wanting to interface with existing native libraries or with the OS.
For all these reasons, and many others, a native extension could be useful.
Needed setup
There are multiple tooling frameworks that can be used to do a native library for Node.js. In the end, most end up wrapping around N-API, the Node.js native API. I decided to use node-gyp.
The initial repository setup was quite trivial:
- Create (and fill) the binding.gyp file
- Run npm init
The binding.gyp file is used to configure how node-gyp will build the native part of the extension. In my case, I went with something simple:
1 2 3 4 5 6 7 8 |
{ 'targets': [ { 'target_name': 'nci', 'sources': [ 'csrc/nci.cc' ] } ] } |
Once you have the file in your directory, npm init will recognize it and do the rest of the setup.
Loading the native interface can be tricky, luckily there is a package for that! The library is called node-bindings. Using this library, you can load your library with one simple line: require('bindings')('nci.node').
This will find your native bundle (in my case nci.node) and just load it. No need to think about release vs debug, about platforms, or anything else.
Module management
Exposing your module is done through the NAPI_MODULE macro. The macro requires a function as a second argument, this function will be called to initialize the module. The initialization method needs to return everything that is exported from the module. For example, the following would register a new module with the method Init as initializer: NAPI_MODULE(NODE_GYP_MODULE_NAME, Init).
In my case, I add the newly created class definition to the existing exports and return that. I also take this time to store a few global variables that I’ll need to reuse in various calls.
Creating the class definition is simple enough: register the class by giving it a name and a constructor callback and then export it. For example, I went with the following:
1 2 3 4 5 6 7 8 9 10 11 |
// Define NCINativeDevice NODE_API_CALL( napi_define_class(env, NCI_CLASS_NAME, NCI_CLASS_NAME_LENGTH, nci_constructor, NULL, 0, NULL, &nci_class), "Could not define class" ); // Export NCINativeDevice NODE_API_CALL( napi_set_named_property(env, exports, NCI_CLASS_NAME, nci_class), "Could not export class" ); |
Class/instance management
There are two Node.js syntaxes that can get your constructor called:
1 2 |
let device = new nci.NCINativeDevice(12); let device = nci.NCINativeDevice(12); |
I decided to support only the first version simply because it is a bit easier to do so. In order to distinguish the two versions, you’ll need to check the target of the call. Basically, if the target is set, you are in the new MyClass() version. Doing so is pretty easy:
1 2 3 4 5 6 7 8 9 |
NODE_API_CALL( napi_get_new_target(env, info, &target), "Could not get new target" ); bool is_constructor = target != NULL; if (is_constructor) { // [snip] } |
Once in the constructor, I get and validate the first argument, expecting an int. Once this is done, I simply create the native instance and wrap it like so:
1 2 3 4 5 |
nci_native_device_struct* obj = new_nci_native_device(value); NODE_API_CALL( napi_wrap(env, jsthis, reinterpret_cast<void*>(obj), free_nci_native_device, NULL, NULL), "Could not wrap native device" ); |
When wrapping the instance is the moment where you define a cleanup callback. That callback will be called when the object is garbage collected.
Once the object is wrapped, you’ll need to register all functions on the instance. In order to do so, simply repeatedly call napi_create_function and napi_set_named_property to create and add the function to the object.
Accessing the native object
In the function callbacks, you’ll need to access your native object. In order to do so, you’ll need to get the call information and then unwrap the target object (this) like so:
1 2 3 4 5 6 7 8 9 |
NODE_API_CALL( napi_get_cb_info(env, info, &argc, NULL, &jsthis, NULL), "Could not get call info" ); NODE_API_CALL( napi_unwrap(env, jsthis, reinterpret_cast<void**>(&self)), "Could not unwrap native instance" ); |
Loading the module
If you are using node-bindings like me, loading the library is done simply through: require('bindings')('nci.node'). It is a good idea to wrap that into your own module and not require your customer to do this for you.