This article was contributed to the Node.js Collection by Gabriel Schulhof.
Node.js provides a wide variety of extremely useful APIs for accomplishing the work of an application via its built-in modules such as
OK, so native add-ons have been a part of Node.js since the very beginning. So have JS modules 😉 So, what does it mean that they are now close to being “on-par”? Why only now?
Let’s first look at the similarities between native add-ons and JS modules. Both can be
// A JS module can be loaded from a package:
const jsModulePackage = require('my-js-module');
// It can also be loaded from a specific file:
const jsModuleFile = require('./my-js-module.js');
// A native add-on can also be loaded from a file:
const nativeAddonFile = require('./build/Release/my-native-add-on.node');
Nevertheless, the similarities end there. Let’s now highlight some constraints and requirements unique to native add-ons, in order to better understand the above-mentioned maintenance burden, and the recently achieved progress towards alleviating it. Subsequently, we elaborate on each constraint/requirement and the steps taken to lessen its impact on the maintenance of native add-ons.
In contrast, native add-ons are tied to the version of Node.js for which they were written. For a given platform and architecture, a different binary has to be provided for version 8.x of Node.js than for version 10.x. The reason for this is that the native API provided by Node.js to native add-on developers changes from one major version to the next in such a way that the native add-on will no longer load, or in the worst case, will load but crash seemingly inexplicably. To save many hours of hunting down seemingly inexplicable crashes, a simple versioning scheme based on a single integer value was added, whereby the Node.js version against which an add-on was built is recorded within the add-on at compile time and examined upon loading the add-on. If the value does not match the value carried by Node.js, it will fail to load the add-on, producing a message familiar to many:
Error: The module '/home/user/node_hello_world/build/Release/hello.node'
was compiled against a different Node.js version using
NODE_MODULE_VERSION 57. This version of Node.js requires
NODE_MODULE_VERSION 64. Please try re-compiling or re-installing
the module (for instance, using `npm rebuild` or `npm install`).
at Object.Module._extensions..node (internal/modules/cjs/loader.js:718:18)
at Module.load (internal/modules/cjs/loader.js:599:32)
at tryModuleLoad (internal/modules/cjs/loader.js:538:12)
at Function.Module._load (internal/modules/cjs/loader.js:530:3)
at Module.require (internal/modules/cjs/loader.js:637:17)
at require (internal/modules/cjs/helpers.js:22:18)
at bindings (/home/user/node_hello_world/node_modules/bindings/bindings.js:76:44)
at Object.<anonymous> (/home/user/node_hello_world/hello.js:1:94)
at Module._compile (internal/modules/cjs/loader.js:689:30)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:700:10)
Such errors can also be avoided by the use of prebuild utilities because they allow add-on maintainers to distribute binaries for each Node.js version they wish to support. However, it places even more burden on add-on maintainers, namely, to rebuild the binaries they provide at every release of Node.js. It also increases the number of binaries they need to maintain to support the large number of architecture/platform/version combinations. Additionally, it prevents application maintainers from simply dropping a new version of Node.js into their production environment. Indeed they need to reinstall their application even though no part of its functionality has changed.
N-API was introduced to address the problem of the binary compatibility of native add-ons with versions of Node.js against which they were built as well as all subsequent versions. This binary compatibility means not only that the source code need not be touched in order to build against later Node.js version, but also that the binaries built against an earlier version of Node.js will continue to link to and work correctly with all later versions of Node.js. Its ultimate aim is to eliminate issues related to add-ons breaking when a new version of Node.js is released.
N-API eliminates one of the three variables that causes the proliferation of binaries required to provide a single Node.js native add-on: the integer value that represents the Node.js version against which the binary is designed to work (
The advent of worker threads in Node.js, and even earlier modules such as
- Node.js calculates the absolute path to the native add-on.
- Node.js calls the method
process.dlopen(), passing in the absolute path previously calculated.
- On the native side, Node.js calls
- The add-on is loaded and it executes a so-called DSO constructor function as part of the loading process. The function passes a pointer to a structure of type
node::node_module::nm_context_register_funcmember of the structure holds a pointer to a function which is responsible for populating the object which will become the result of having loaded the module (
The problem with DSO constructor functions is that they only run the first time an add-on is loaded into memory. On subsequent occasions,
uv_dlopen() will short-circuit, returning a reference to an already loaded add-on, and the function will not run. Thus, subsequent attempts to load the add-on fail.
The solution to this problem was introduced in 3828fc62. In addition to relying on a DSO constructor, as a fallback Node.js now looks for a well-known symbol exported by the module:
<number> is an integer representing the version of the add-on. It only does so if the DSO constructor fails to run. The well-known symbols are the address of the module initialization function that is also stored in
Although originally this change was made with the intention of allowing well-known symbols for multiple versions of Node.js to cohabitate within the same binary, it also enables multiple loading. Nevertheless, the ability to load a native add-on multiple times means that the add-on must adhere to a context-aware add-on structure.
N-API modules can also be loaded multiple times. They have their own, Node.js-version-independent well-known symbol which Node.js attempts to retrieve as a backup.
To remove this obstacle, cleanup hooks were added which allow a native add-on to safely clean up resources it uses knowing that the environment is in the process of being torn down in a controlled fashion.
The picture that emerges for native add-on maintainers is that they now have the tools to provide application maintainers with native add-on packages that
- need not be re-published whenever a new version of Node.js is released
- need not be re-installed by application maintainers solely because they wish to drop a new version of Node.js into their production environment,
It takes an effort on the part of native add-on maintainers to make use of these new tools, however, the payoff is that as a result of such an effort they will not have to re-publish their packages in response to a new Node.js release. For application maintainers, the availability of packages that take advantage of these new tools means that as soon as all their dependencies are satisfied by native add-ons that are enabled using these tools, they too can avoid a rebuild/redeploy in response to new Node.js releases.
Thank you all who contributed to this text!