Creating a build.zig for code distributed with Windows .def files

I’m adding a build.zig for the nodejs N-API. To build a Node addon, you compile against a .h and tell the linker to leave those symbols undefined. On Windows, I’ve learned that you need to link against an import library, which you generate from a .def file, which is included in the repo above.

I’m having trouble integrating this step into the build.

I can generate .libs just fine, using b.addSystemCommand(&.{b.graph.zig_exe, "dlltool", ... }). Beyond that, I can’t find anything that works. If I add them (N-API comes with 2 .defs) with addObjFile, the addon crashes when linking against that library, and there are compiler warnings. Zig’s output with --verbose is this (shortened for brevity):

ar rcs node_api.lib obj_container.lib

That seems to be putting the .lib files in an archive rather than their object file contents, and zig ar t seems to confirm that theory. I don’t think that’s right, but I’m not sure. The same thing happens if I use addCSourceFile. Linking the addon against both of these .libs directly from the cache folder does make the addon work, so I know those alone are correct.

Does Zig or LLVM not understand .lib? Are .lib files not able to be “combined”? Or is there no way to addStaticLibrary, so it’s treated as an archive? If there was some way to expose the .lib paths as artifacts, that would be an acceptable work-around too.

build.zig
const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const node_api_headers = b.dependency("node_api_headers", .{});
    const node_api = b.addLibrary(.{
        .name = "node_api",
        .linkage = .static,
        .root_module = b.createModule(.{
            .target = target,
            .optimize = optimize,
        })
    });
    
    // so that upstream people can call linkLibrary on non-Windows
    node_api.addCSourceFile(.{.file = b.path("empty.c"), .flags = &.{}});

    node_api.installHeadersDirectory(node_api_headers.path("include"), "", .{});
    
    if (target.result.os.tag == .windows) {
        const node_api_lib = dlltool(b, target, node_api_headers.path("def/node_api.def"), "node_api.lib");
        const js_native_api_lib = dlltool(b, target, node_api_headers.path("def/js_native_api.def"), "js_native_api.lib");
        node_api.addObjectFile(node_api_lib);
        node_api.addObjectFile(js_native_api_lib);
    }
    
    b.installArtifact(node_api);
}

pub fn dlltool(
    b: *std.Build,
    target: std.Build.ResolvedTarget,
    def_file: std.Build.LazyPath,
    lib_file_basename: []const u8
) std.Build.LazyPath {
    const exe = b.addSystemCommand(&.{b.graph.zig_exe, "dlltool", "-d", def_file.getPath(b), "-l"});
    const lib_file = exe.addOutputFileArg(lib_file_basename);
    
    exe.addArgs(&.{
        "-m", switch (target.result.cpu.arch) {
            .x86 => "i386",
            .x86_64 => "i386:x86-64",
            .arm => "arm",
            .aarch64 => "arm64",
            else => "i386:x86-64",
        },
    });
    
    return lib_file;
}
build.zig.zon
.{
    .name = .node_api,
    .version = "0.0.0",
    .dependencies = .{
        .node_api_headers = .{
            .url = "https://github.com/nodejs/node-api-headers/archive/refs/tags/v1.5.0.tar.gz",
            .hash = "N-V-__8AAASNAQA0UI0mvQk4LyFEhdFuFBPgH8HSokdFFpnz"
        }
    },
}

Background: I’m hoping to get a static, cross-compile of node-canvas working with Zig. node-canvas has wasted lot of people’s collective time over the years due to being hard to install.

My understanding is that making a static library from an import library doesn’t make sense. An import library is used to “describe the exports from one image for use by another.”

(confusingly, .lib is used for both static libraries and import libraries on Windows)

Potentially relevant link:

1 Like

Thanks, that would explain a it. But that makes me sad because if true, there would be no way to wrap a project with .def files in such a way that the consumer can call addLibrary.

My plan B doesn’t seem to have a solution either: is there no way to have a dependency generate a file and expose the path to it? Then consumers can call addObjectFile manually when on Windows. I’ve been looking for a while but I can’t find any combination of APIs that would work.

Potentially relevant link:

I did read that thread but the nodejs.org .lib files aren’t for every architecture, and it also doesn’t have a solution for wrapping N-API into a dependency. That would be very nice for those developing nodejs addons.

I’m not sure exactly what your goals are, since it seems like you’re simultaneously describing two separate things that would need different solutions:

  • Building a specific node module (node-canvas) using the Zig build system
  • Making a Zig package that makes building node modules more convenient overall

If you’re aiming for the first, combining what you have with the linked thread above seems like a possible path forward.

If you’re aiming for the second, something like the work-in-progress translate-c package might be an example to follow.

More the latter, I suppose: there’s currently no great way to cross-compile Node addons unless you’re writing Rust. node-canvas is my motivation, but a lot of other node C++ addons could benefit from the Zig build system.

The working project is here: GitHub - chearon/node-api-zig: zig build instead of node-gyp

What I figured out, after reading the stdlib:

  1. Windows import .lib files (or .obj files, or .o files, or anything you want to be added to the upstream compile before linking) can be exposed by calling std.Build.addObject rather than addLibrary. Its module can have .lib files via std.Build.Module.addObjFile.

    This only worked with one .lib file, so I had to concatenate the .def files before calling zig dlltool. More than one .lib file added causes zig build-obj to fail with a "COFF does not support linking multiple objects into one" error.

  2. Instead of calling std.Build.installArtifact, which would try to install object files and fail, just create the dependency with b.getInstallStep().dependOn
  3. In the upstream library, use addObject on the object artifact. This adds the N-API headers to the include paths, and adds the object files to the compile.

I’m not totally sure all of that is supposed to work, and I’m not sure if/why import libraries are needed. It’d be great if there was some native Zig way to allow symbols to be undefined on Windows.