Shipping node addons with libraries

The thing with linked 3rd-party libraries is that you'll have to provide locations to search for them.
node addons are dynamically linked, shared libraries, so whenever you import an addon, the respective dynamic loader will have to load all required libraries.

How can we check which libraries we require?

  • macOS: otool -L your_addon.node
  • Linux: ldd you_addon.node

In order to load these required libraries, our loader is going to search in several places, e.g. in all paths listed in LD_LIBRARY_PATH on Linux.

But we're shipping our own lib, which is not in one of the standard search paths?

It is possible for a lib or executable (ELF format on Linux or Mach-O format e.g. macOS) to specify a runtime loader path. These paths are hardcoded into the binary and may specify additional paths to search for libraries.

How can we retrieve a binaries RPATH?

  • macOS: otool -l your_addon.node | grep RPATH -A2
  • Linux: objdump -x your_addon.node | grep RPATH

Ok, rpath it is!

We can configure our rpath via linker flags:

"conditions": [
    ["OS==\"mac\"",
	    {
	        "link_settings": {
	            "libraries": [
	                "-Wl,-rpath,@loader_path",
	                "-Wl,-rpath,@loader_path/..",
	            ],
	         }
	    }
	],
	["OS==\"linux\"",
	    {
	        "link_settings": {
	            "libraries": [
	                "-Wl,-rpath,'$$ORIGIN'",
	                "-Wl,-rpath,'$$ORIGIN'/.."
	            ],
	        }
	    }
	]
],
bindings.gyp

$$ORIGIN? @loader_path? What's this?

rpath entries are hardcoded, so having it fixed to e.g. /home/youruser/libs/foo/bar/ will break as soon as you try to use your addon on a different machine.

Both $ORIGIN and @loader_path are token which our dynamic loader is going to replace with the directory containing our binary. So no matter where our library will be installed, if we specify paths relative to our binaries location, the dynamic loader will be able to find it. ('$$ORIGIN' is just a little workaround required so node-gyp doesn't mess things up when trying to substitute values)

A good read regarding this topic is this article

Example

.
├── build
│   └── Release
│       └── addon.node
├── index.js
├── lib
│   └── my_library.dylib
├── package-lock.json
└── package.json
Example project structure

Our build generates the ./build/Release/addon.node file, which is linked against ./lib/my_library.dylib at build time.

Using @loader_path we can now specify an rpath relative to our binaries location, since @loader_path will be replaced with /whatever/path/to/our/package/build/Release.

{
    "link_settings": {
        "libraries": [
            "-Wl,-rpath,@loader_path/../../lib",
         ],
    }
}

At runtime, this will result in /whatever/path/to/our/package/build/Release/../../lib, exactly where our lib is located.

What about Windows?

Windows binaries do not have / use an rpath property.

However, the DLL search order will start searching in the directory from which the application is loaded.

A cross-platform approach

My approach to shipping required libraries is as follows:

  1. Link your libraries during build
  2. Copy your libraries to the resulting output directory, e.g. build/Release
  3. Set the rpath to either $ORIGIN or @loader_path on Linux and macOS

This way, we're instructing the dynamic loader on Linux and macOS to search for our lib in the same folder as our resulting binary, which is the default behaviour on Windows.

Copying files can be done via an additional target in our gyp file:

{
    "target_name": "action_before_build",
    "type": "none",
    "copies": [{
        "files": [ "/path/to/your/lib.dylib" ],
        "destination": "<(PRODUCT_DIR)"
    }]
}