Cross compiling a Node.js addon for windows / macOS

I’m on Linux (OS: Xubuntu 22.04.3 LTS x86_64, Kernel: 5.15.0-86-generic) and I am trying to cross compile a Node.js addon for Windows / macOS.

This Node.js addon uses the Node-API, a C API whose header files I need to include when building the addon. I keep these header files in the deps directory of my project. The compiled artifact is a shared library.

I created a repo with a minimal reproducible example: GitHub - jackdbd/zig-nodeapi-example

Compiling for Linux

I’m able to compile for Linux either using this command…

zig build-lib -ODebug -dynamic -lc \
  -isystem ./deps/node-v18.17.0/include/node \
  -femit-bin=dist/debug/addon.node \
  src/addon.zig

…or using this build.zig

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const name = "addon";

    const lib = b.addSharedLibrary(.{
        .name = name,
        .root_source_file = .{ .path = "src" ++ std.fs.path.sep_str ++ name ++ ".zig" },
        .link_libc = true,
        .target = target,
        .optimize = optimize,
    });

    lib.addSystemIncludePath(.{ .path = "deps/node-v18.17.0/include/node" });

    b.installArtifact(lib);
}

Cross-compiling for Windows

I am now trying to cross-compile for Windows using this command…

zig build-lib -ODebug -dynamic -lc \
  -isystem ./deps/node-v18.17.0/include/node \
  -target x86_64-windows-gnu \
  src/addon.zig

…but I am getting many linker errors like this one:

error: lld-link: undefined symbol: napi_create_function
    note: referenced by /home/jack/repos/zig-nodeapi-example/src/napi.zig:30
    note:               addon.dll.obj:(napi.register_function__anon_3460)
    note: referenced by /home/jack/repos/zig-nodeapi-example/src/napi.zig:30
    note:               addon.dll.obj:(napi.register_function__anon_3468)

Note: the function napi_create_function is defined in js_native_api.h, which is a header file included by napi_api.h.

Cross-compiling for macOS

I also tried to cross-compile for macOS using -target aarch-macos-none and -target x86_64-macos-none. That also didn’t work, and MachO gives me errors like this one:

error: undefined reference to symbol _napi_get_value_string_utf8
    note: referenced in dist/debug/addon.node.o

Why am I getting these linker errors? Isn’t enough to add -isystem when using zig build-lib, or calling lib.addSystemIncludePath() when using build.zig? I also tried to add --verbose-link but can’t really understand what the issue is.

I think you need to specify where to find the NAPI libraries where the compiled code for those missing functions lives.

Maybe these libraries are installed on linux, and it finds them at runtime? What does lld .../addon.node say?

Here is the output of ldd dist/debug/addon.node:

linux-vdso.so.1 (0x00007fff1d8fa000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fb254097000)
/lib64/ld-linux-x86-64.so.2 (0x00007fb2543ad000)

napi* symbols seem to be undefined in Linux too, if I am not mistaken.

Command:

objdump dist/debug/addon.node --syms | grep napi

Output:

00000000000293c0 l     F .text  000000000000016d              napi.register_function__anon_2050
0000000000029530 l     F .text  0000000000000075              napi.throw__anon_2054
0000000000029680 l     F .text  0000000000000075              napi.throw__anon_2057
0000000000012878 l     O .rodata        000000000000002a              napi.register_function__anon_2050__anon_2053
00000000000128a2 l     O .rodata        0000000000000029              napi.register_function__anon_2050__anon_2056
0000000000029700 l     F .text  000000000000016d              napi.register_function__anon_2058
0000000000029870 l     F .text  0000000000000075              napi.throw__anon_2061
00000000000298f0 l     F .text  0000000000000075              napi.throw__anon_2064
00000000000128cb l     O .rodata        0000000000000023              napi.register_function__anon_2058__anon_2060
00000000000128ee l     O .rodata        0000000000000022              napi.register_function__anon_2058__anon_2063
000000000002a430 l     F .text  000000000000016f              napi.create_string
000000000002a6d0 l     F .text  0000000000000075              napi.throw__anon_2092
000000000002a750 l     F .text  0000000000000075              napi.throw__anon_2093
000000000002aa50 l     F .text  0000000000000075              napi.throw__anon_2495
000000000002a5a0 l     F .text  0000000000000075              napi.throw__anon_2065
00000000000127b0 l     O .rodata        0000000000000004              cimport.napi_ok
00000000000127b4 l     O .rodata        0000000000000004              cimport.napi_pending_exception
0000000000000000         *UND*  0000000000000000              napi_create_function
0000000000000000         *UND*  0000000000000000              napi_set_named_property
0000000000000000         *UND*  0000000000000000              napi_throw_error
0000000000029970 g     F .text  0000000000000131              napi_register_module_v1
0000000000000000         *UND*  0000000000000000              napi_get_cb_info
0000000000000000         *UND*  0000000000000000              napi_get_value_string_utf8
0000000000000000         *UND*  0000000000000000              napi_create_string_utf8

Command:

objdump dist/debug/addon.node --dynamic-syms | grep napi

Output:

0000000000000000      D  *UND*  0000000000000000  Base        napi_create_function
0000000000000000      D  *UND*  0000000000000000  Base        napi_set_named_property
0000000000000000      D  *UND*  0000000000000000  Base        napi_throw_error
0000000000000000      D  *UND*  0000000000000000  Base        napi_get_cb_info
0000000000000000      D  *UND*  0000000000000000  Base        napi_get_value_string_utf8
0000000000000000      D  *UND*  0000000000000000  Base        napi_create_string_utf8
0000000000029970 g    DF .text  0000000000000131  Base        napi_register_module_v1

My guess is that you need to link against a Node .lib file for Windows. My suggestion would be to compile an example C++ Node addon on Windows and see what it links against.

I downloaded node.lib from here and added this line to my build.zig:

lib.addObjectFile(.{ .path = "deps/node-v18.17.0/node.lib" });

Still no luck cross-compiling it for Windows.
Yet if I search node.lib for napi* symbols, I see they are there.

nm deps/node-v18.17.0/node.lib | grep napi_get_value

Related: How to link against a static library?

Hi, it works on my machine with WSL2. You probably mixed-up the two node.lib version that they have for windows.
I’ve downloaded both win-x86/node.lib and win-64/node.lib in separate folders and added the following code to build.zig:

    var buffer: [std.os.PATH_MAX]u8 = undefined;
    const node_lib_path = std.fmt.bufPrint(&buffer, "./deps/{s}-{s}/node.lib", .{ @tagName(target.os_tag orelse builtin.os.tag), @tagName(target.cpu_arch orelse builtin.cpu.arch) }) catch unreachable;
    std.debug.print("path: {s}\n", .{node_lib_path});
    switch (target.os_tag orelse builtin.os.tag) {
        .windows => lib.addObjectFile(std.build.LazyPath.relative(node_lib_path)),
        .linux => {},
        else => @panic("Not Implemented"),
    }

Now all three build executions are successful:

[zig-nodeapi-example]$ ll zig-out/lib/
total 0
[zig-nodeapi-example]$ zig build -Dtarget=x86-windows
path: ./deps/windows-x86/node.lib
[zig-nodeapi-example]$ file zig-out/lib/addon.dll
zig-out/lib/addon.dll: PE32 executable (DLL) (GUI) Intel 80386, for MS Windows, 7 sections
[zig-nodeapi-example]$ zig build -Dtarget=x86_64-windows
path: ./deps/windows-x86_64/node.lib
[zig-nodeapi-example]$ file zig-out/lib/addon.dll
zig-out/lib/addon.dll: PE32+ executable (DLL) (GUI) x86-64, for MS Windows, 7 sections
[zig-nodeapi-example]$ zig build
path: ./deps/linux-x86_64/node.lib
[zig-nodeapi-example]$ file zig-out/lib/libaddon.so
zig-out/lib/libaddon.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, with debug_info, not stripped

Thanks for the heads up! I am know able to cross-compile without any warnings.

To recap, here is a build.zig that compiles a Node.js addon for x86_64-windows.

const std = @import("std");

pub fn build(b: *std.Build) void {
    const optimize = b.standardOptimizeOption(.{});
    const name = "addon";

    const lib = b.addSharedLibrary(.{
        .name = name,
        .root_source_file = .{ .path = "src" ++ std.fs.path.sep_str ++ name ++ ".zig" },
        .link_libc = true,
        .target = .{
            .cpu_arch = .x86_64,
            .os_tag = .windows,
        },
        .optimize = optimize,
    });
   
    // include Node.js C header files
    lib.addSystemIncludePath(.{ .path = "deps/node-v18.17.0/include/node" });

    // include the node.lib ar archive (it's a static executable)
    lib.addObjectFile(.{ .path = "deps/node-v18.17.0/win-x64/node.lib" });

    b.installArtifact(lib);
}

The compilation generates these files in zig-out/lib/:

  • addon.dll => you must rename it to addon.node
  • addon.lib
  • addon.pdb

Writw a JS script to test your native addon out:

const addon = require("./zig-out/lib/addon.node");

console.log(addon.yourNativeFunction("foo"));

Execute the script with Wine (you need to set NODE_SKIP_PLATFORM_CHECK=1 otherwise a Node.js check immediately stops the script execution).

NODE_SKIP_PLATFORM_CHECK=1 wine deps/node-v18.17.0/win-x64/node.exe app.js

Note: seems weird to me that there is very little documentation on how to build a native Node.js addon for Windows…

3 Likes

Good progress!

What happens if you don’t rename addon.dll? Can you simply do this?

const addon = require("./zig-out/lib/addon.dll");

You can use this instead of b.installArtifact(lib); to get zig to name it with the .node extension for you:

    const install_lib = b.addInstallArtifact(lib, .{
        .dest_sub_path = "addon.node",
    });
    b.getInstallStep().dependOn(&install_lib.step);
> zig build

> ls zig-out\lib
addon.lib
addon.node
addon.pdb
2 Likes