Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

c-cpp-zig-build

Build native C/C++ Node.js modules with Zig, without a system toolchain.

npm run build downloads the Zig compiler and the Node headers, compiles your C and C++ sources, and writes a .node addon. There is no node-gyp, no Python, no Visual Studio, no build-essential — and cross compiling to another platform is one flag.

npm i -D c-cpp-zig-build
npx c-cpp-zig-build init
npm run build

Apache-2.0 · Changelog · requires Node 18.17+, nothing else


Contents


Why

A native module normally needs a C toolchain on every machine that builds it, and a different one per platform. Zig ships a C and C++ compiler, libc for every target it supports, and a build system, in a single 50 MB download — so the toolchain can be a dependency of the project rather than a prerequisite of the machine.

This package makes that practical:

  • Nothing to install. The Zig toolchain and the Node headers are downloaded on first build and cached in ~/.zig-build, shared across all your projects. node-addon-api and node-api-headers come along as dependencies, so C++ bindings and Windows builds work without a project adding anything.
  • Verified downloads. The Zig archive is checked twice before it is unpacked: against the SHA-256 published by ziglang.org, and against the Zig project's minisign signature using a key pinned in this package. Node headers are checked against nodejs.org's SHASUMS256.txt.
  • A build file you can read. build.zig is usually four lines. The template behind it is a normal Zig package, and the artifact it returns is a normal std.Build.Step.Compile, so nothing is out of reach.
  • Cross compilation as a flag. --target aarch64-macos from a Linux box produces a macOS addon. So does --target x86_64-windows.
  • Both runtimes. Node and Bun, including the separate addon Bun needs on Windows.
  • No lock-in for the C code. The same sources build as a static library and as a command line tool, so the logic stays testable without a JS runtime.

What this package does not do is write your bindings. It compiles and links them, and it puts the Node-API headers in front of the compiler — but the code that turns a JavaScript value into a C one is yours, in C with node_api.h or in C++ with node-addon-api. The examples show both.


Requirements

Node.js 18.17 or newer (Bun and Yarn work too — they run the same CLI)
Disk ~150 MB in ~/.zig-build for the toolchain and headers
Network On the first build only, then never again
tar Present on macOS, Linux, and Windows 10 1803+

No compiler, no Python, no Visual Studio Build Tools.

This package depends on node-addon-api and node-api-headers and nothing else. Both are header-only, carry no dependencies of their own, are pinned to an exact version, and together add about 300 kB — they are what makes C++ bindings and Windows builds work with no setup. See What is verified.


Quick start

A new module

mkdir my-native && cd my-native
npm init -y
npm i -D c-cpp-zig-build
npx c-cpp-zig-build init
npm run build

init writes build.zig, build.zig.zon, a small example library in src/ and include/, a Node-API binding in napi/, an index.cjs that loads the result, and the build scripts in package.json. It never overwrites a file that already exists.

const native = require('./index.cjs')
native.add(20, 22) // 42

An existing module

If you already have C sources, run init and then delete the example files it wrote. Point build.zig at your layout if it differs from the default:

const std = @import("std");
const czb = @import("c_cpp_zig_build");

pub fn build(b: *std.Build) !void {
    _ = try czb.addNodeAddon(b, .{
        .name = "my_native",
        .sources = &.{ .{ .dir = "lib" }, .{ .dir = "bindings/node" } },
        .include = &.{ "lib/include", "vendor" },
    });
}

For C++

npx c-cpp-zig-build init --language c++

node-addon-api ships as a dependency of this package, so #include <napi.h> works straight away, and libc++ is linked automatically when any C++ source is present.

Declare it in your own project anyway if you care which version you compile against — a version the project declares always wins over the bundled one:

npm i -D node-addon-api

c-cpp-zig-build info prints which copy is in use.


How it works

npm run build
  └─ c-cpp-zig-build
       ├─ copies the Zig template into .zig-native/          (every build, fast)
       ├─ downloads Zig            → ~/.zig-build/zig/0.15.2/
       ├─ downloads Node headers   → ~/.zig-build/node/v22.11.0/
       ├─ finds node-addon-api (yours if you declare one, otherwise its own)
       └─ runs: zig build -Dtarget=… -Doptimize=… -Dnode-headers=… -p build
            └─ your build.zig
                 └─ the template: walks src/ and napi/, compiles, links,
                    installs build/my_native.node, writes compile_commands.json

Where things go

Path What Commit it?
~/.zig-build/zig/<version>/ the Zig toolchain shared, outside the project
~/.zig-build/node/v<version>/ Node headers and node.lib shared, outside the project
~/.zig-build/zig-global-cache/ fetched Zig packages shared, outside the project
.zig-native/ the build template, copied in no
.zig-cache/ Zig's build cache no
build/ the addon and any other artifacts no
compile_commands.json for clangd no
build.zig, build.zig.zon yours yes

init adds the first four to .gitignore.

Why the template is copied into the project. A build.zig.zon dependency needs a path, and where node_modules puts a package depends on the package manager: npm hoists, pnpm symlinks into a store, Bun has its own layout, and a workspace changes all three. A copy inside the project is the same path everywhere. It is refreshed on every build, so upgrading the npm package upgrades the template — do not edit it.


What is verified

Everything this tool downloads is checked before it is used. Nothing is unpacked, and no compiler is run, on bytes that failed a check.

The Zig toolchain

Two independent checks, and both must pass:

Check What it proves Where the truth comes from
SHA-256 this is the file ziglang.org listed for this release https://ziglang.org/download/index.json, over TLS
minisign (Ed25519) this file was built and signed by the Zig project a public key pinned in this package

The signature is the one that matters when a mirror is involved. Zig asks tooling to prefer the community mirrors over ziglang.org, and this tool does — but a mirror can serve any bytes it likes, so the archive is only trusted once it verifies against Zig's key. The .minisig itself is always fetched from ziglang.org, never from the mirror: a signature served by the same host as the file it signs would prove nothing.

The mirrors are volunteer-run and a few are always either down or barely moving, so three are queued at random before ziglang.org is used as a last resort, and a source that is not delivering is abandoned rather than waited out — no data for 15 seconds, a 30-second stall, or a sustained rate under 64 KiB/s, and the next one is tried. A line like

warning https://some.mirror/zig-….tar.xz: giving up, only 7 KiB/s

is that working as intended; the build carries on with another source. Set ZIG_MIRROR to pin one you trust.

Three things are checked in the signature, not one: that it was made with the expected key, that it covers this file's contents, and that its trusted comment — which records the file name — is itself signed, so the name cannot be edited after the fact.

A file that fails is deleted rather than left in the cache, so a later run cannot pick it up and skip the download. A cached archive left behind by an interrupted run is re-checked before use rather than trusted on the strength of its file name.

You can see it happen:

c-cpp-zig-build --verbose        # logs "minisign signature verified (…)"
c-cpp-zig-build info             # reports whether verification is on

Turning it off is possible and inadvisable:

c-cpp-zig-build --no-verify-signature

Node headers

Downloads from nodejs.org are checked against the SHA-256 in that release's SHASUMS256.txt, fetched over TLS from nodejs.org itself. Node signs that file with the release manager's GPG key; verifying that would require shipping a keyring, which this package does not do.

This package's own dependencies

There are two, both header-only, both with no dependencies of their own, and both pinned to an exact version rather than a range — the headers decide what compiles, so the version that ships is the version that was tested:

Pinned Why it is here
node-addon-api 8.9.2 the C++ layer, so #include <napi.h> works with no setup
node-api-headers 1.9.0 the Windows .def files, and an offline header fallback

Both are published with npm provenance attestations, so the whole tree can be checked:

npm audit signatures

A version your own project declares always wins over the pinned one, so the pin constrains this package, not yours.


Project layout

The defaults follow this shape. None of it is mandatory; all of it is one option away from being something else.

my-native/
├── build.zig             # what to build   ← you write this (4 lines)
├── build.zig.zon         # Zig dependencies
├── package.json
├── index.cjs             # loads build/my_native.node
├── include/              # public headers            → on the include path
├── src/                  # the library itself        → compiled
├── napi/                 # the Node-API bindings     → compiled
├── third_party/          # vendored libraries        → on the include path
├── build/                # output      (generated)
└── .zig-native/          # the template (generated)

Everything under src/ and napi/ is compiled, recursively, by extension (.c, .cpp, .cc, .cxx, .m, .mm, .S). Add a file, build, done — no list to maintain. The file list is sorted, so the build cache key does not depend on the order your filesystem happens to return entries in.

include/ and third_party/ are on the header search path if they exist. A directory that does not exist is skipped rather than being an error, so third_party/ can appear the day you first vendor something.

Keep the bindings thin. napi/ should convert values and call into src/. That split is what lets the same code build as a static library and a CLI (see example 3) and be tested without a JavaScript runtime in the way.


The build.zig API

const std = @import("std");
const czb = @import("c_cpp_zig_build");

pub fn build(b: *std.Build) !void {
    _ = try czb.addNodeAddon(b, .{ .name = "my_native" });
}

Entry points

Function Produces
addNodeAddon(b, options) a shared library installed as <name>.node
addStaticLibrary(b, options) lib<name>.a
addSharedLibrary(b, options) .so / .dylib / .dll
addExecutable(b, options) a program

All four take the same Options and return the same Artifact. Call as many as you like in one build script.

One function stands apart from them:

Function Produces
dependency(b, name, args) a *std.Build.Dependency, resolved in the package's own directory

Reach for it in place of b.dependency() when you resolve a package by hand rather than through an artifact — see example 5 for what it is for.

Options

Only name is required.

_ = try czb.addNodeAddon(b, .{
    .name = "my_native",

    // --- what to compile -------------------------------------------------
    .sources = &.{ .{ .dir = "src" }, .{ .dir = "napi", .optional = true } },
    .include = &.{ "include", "third_party" },

    // --- how to compile it -----------------------------------------------
    .flags = &.{"-DMY_FEATURE=1"},   // every translation unit
    .c_flags = &.{},                  // C only
    .cpp_flags = &.{},                // C++ only
    .c_std = "c17",                   // -std=c17
    .cpp_std = "c++20",               // -std=c++20
    .warnings = true,                 // -Wall -Wextra            (default)
    .pedantic = false,                // -Wpedantic               (see below)
    .warnings_as_errors = false,      // -Werror
    .defines = &.{ .{ .name = "VERSION", .value = "\"1.2.3\"" } },
    .exceptions = true,               // false also sets the node-addon-api
                                      // no-exception macros
    .link_libc = true,
    .link_libcpp = null,              // null = when C++ sources are found

    // --- output ----------------------------------------------------------
    .out_name = null,                 // default: "<name>.node"
    .flat = false,                    // libraries: skip the bin/ and lib/ dirs
    .compile_commands = true,

    // --- overrides -------------------------------------------------------
    .target = null,                   // default: what --target selected
    .optimize = null,                 // default: what --optimize selected
    .bun = null,                      // Windows: null = when Bun is installed
});

SourceSet

A source set is a group of files that share compiler flags.

.{
    .dir = "third_party/CRoaring",   // walked recursively when .files is empty
    .files = &.{"roaring.c"},        // or listed explicitly, relative to .dir
    .extensions = &.{".c"},          // which extensions the walk picks up
    .exclude = &.{ "tests/", "_win32" }, // substring match on the relative path
    .flags = &.{"-DCROARING_COMPILER_SUPPORTS_AVX512=0"}, // added to the artifact's
    .warnings = false,               // no -W flags for this set (see below)
    .optional = false,               // true: a missing directory is not an error
}

A set's flags are added to the artifact's flags, not used instead of them — the usual reason to set them is one extra -D for a vendored file.

Warnings, and vendored code

-Wall -Wextra are on by default. -Wpedantic is not, deliberately: clang promotes several pedantic findings to hard errors, and real third-party C does not survive it — CRoaring's SIMD headers, for one, fail outright. Turn it on for your own code if you want it:

_ = try czb.addNodeAddon(b, .{ .name = "my_native", .pedantic = true });

Either way, silence the warnings for code you did not write, per source set:

addon.addSources(.{ .dir = "third_party/CRoaring", .files = &.{"roaring.c"}, .warnings = false });

Artifact

What the four entry points return.

const addon = try czb.addNodeAddon(b, .{ .name = "my_native" });

addon.linkDependency("zstd", "zstd");                   // a build.zig.zon package
addon.linkDependencyWith("libsodium", "sodium", .{      // ...one that takes options
    .static = true,
    .shared = false,
});
addon.dependency("zstd", .{}); // the raw *std.Build.Dependency, for anything else
addon.addDependencyIncludePath("stb", "include");       // its headers only
addon.addIncludePath("third_party/CRoaring/include");   // a path in this project
addon.addDefine("MY_FLAG", "1");
addon.addSources(.{ .dir = "third_party/yyjson/src", .files = &.{"yyjson.c"} });
addon.linkSystemLibrary("pthread");

addon.compile        // the underlying *std.Build.Step.Compile — do anything
addon.target         // the resolved target
addon.optimize       // the optimisation mode

addon.compile is an ordinary Zig compile step. Nothing the template does not cover is out of reach:

addon.compile.addWin32ResourceFile(.{ .file = b.path("res/version.rc") });
addon.compile.root_module.addCMacro("NDEBUG", "1");

Extra steps

Anything you add to build.zig is reachable with --step:

const run = b.addRunArtifact(cli.compile);
if (b.args) |args| run.addArgs(args);
b.step("run", "Run the tool").dependOn(&run.step);
c-cpp-zig-build --step run -- input.txt

Adding dependencies

Three ways, in the order you should reach for them.

1. A Zig package — for anything that has one

This is the "add extensions via zig" route: no vendoring, versioned, and the dependency is cross compiled along with your code.

# `zig` here is the toolchain this package manages, so there is nothing to install
npx c-cpp-zig-build zig -- fetch --save \
  https://github.com/allyourcodebase/zstd/archive/refs/tags/1.5.7-2.tar.gz

That writes the URL and its hash into build.zig.zon. Then link it:

const addon = try czb.addNodeAddon(b, .{ .name = "my_native" });
addon.linkDependency("zstd", "zstd");   // dependency name, artifact name

linkDependency resolves the package with the artifact's own target and optimisation mode, so --target aarch64-macos cross compiles zstd too. The package's installed headers come with it — no include path to add. It also resolves the package with its own directory as the working directory, which is what keeps a dependency that reads std.fs.cwd() from looking for its sources in your project; for a package you resolve by hand, use czb.dependency(b, "zstd", .{}) rather than b.dependency to get the same treatment. Example 5 is that case.

A working example is in examples/04-zig-package-dependency. Many C libraries have a Zig package under github.com/allyourcodebase.

2. A file drop — for single-file and vendored libraries

Unpack it under third_party/ and it is on the include path immediately. Compiling it is one line:

const addon = try czb.addNodeAddon(b, .{ .name = "my_native" });

// Header-only: nothing to do beyond the default include path.

// One source file, with a flag of its own:
addon.addSources(.{
    .dir = "third_party/CRoaring",
    .files = &.{"roaring.c"},
    .flags = &.{"-DCROARING_COMPILER_SUPPORTS_AVX512=0"},
});
addon.addIncludePath("third_party/CRoaring/include");

// A whole library, skipping its tests:
addon.addSources(.{
    .dir = "third_party/yyjson",
    .exclude = &.{ "test/", "fuzz" },
});

To compile everything under third_party/ with no special flags, just add it to sources:

.sources = &.{ .{ .dir = "src" }, .{ .dir = "napi" }, .{ .dir = "third_party" } },

3. A git submodule — for a library you track upstream

git submodule add https://github.com/RoaringBitmap/CRoaring third_party/CRoaring

From the build's point of view a submodule is a file drop; use the same addSources calls as above. Remember git submodule update --init --recursive in your CI checkout.


Configuration

Most projects need none. When they do, in order of precedence:

  1. command line flags
  2. zig-native.config.js / .mjs / .cjs / .json
  3. the zigNative field in package.json
  4. defaults
// zig-native.config.mjs
import { defineConfig } from 'c-cpp-zig-build'

export default defineConfig({
  optimize: 'small',
  napiVersion: 8,
  targets: ['x86_64-linux-gnu', 'aarch64-linux-gnu'],
  zigOptions: { 'my-feature': true },   // -Dmy-feature, read by build.zig
})
// package.json
{
  "zigNative": { "optimize": "fast", "nodeVersion": "22.11.0" }
}

Command line

c-cpp-zig-build [build]              build (the default command)
c-cpp-zig-build init                 create the build files
c-cpp-zig-build clean                remove build/, .zig-cache/, .zig-native/
c-cpp-zig-build info                 report what a build would use
c-cpp-zig-build zig -- <args>        run the managed Zig toolchain
Flag Meaning
--root <dir> project directory
-O, --optimize <mode> debug, safe, fast, small (default small)
--debug shorthand for -O debug
--target <triple> cross compile; repeat for several
--step <name> run an extra zig build step
-v, --verbose print the commands being run
--zig-version <ver> which Zig release to download
--zig-exe <path> use this Zig instead of downloading one
--system-zig use zig from PATH when its version matches
--node-version <ver> Node headers to compile against
--node-headers <mode> auto, download, package, or a path
--offline fail rather than download anything
--no-verify-signature skip the Zig archive signature check (inadvisable)
--no-napi build a plain library, not an addon

Anything after -- is passed to zig build, which is how --step run -- input.txt reaches the program being run.

Environment

Variable Effect
C_CPP_ZIG_BUILD_HOME where downloads are cached (default ~/.zig-build)
ZIG_EXE use this Zig binary
ZIG_MIRROR try this Zig mirror first
NODEJS_ORG_MIRROR where to fetch Node headers from
NO_COLOR plain output
C_CPP_ZIG_BUILD_DEBUG print stack traces on failure

The Node version

Headers are chosen from, in order: --node-version, nodeVersion in the config, the nearest .nvmrc walking up from the project, then the Node that is running the build. Node-API is ABI stable, so an addon built against one version loads in every later one — the version mostly decides which v8.h and uv.h you can reach for.


Cross compilation

c-cpp-zig-build --target x86_64-linux-gnu \
                 --target aarch64-linux-gnu \
                 --target aarch64-macos \
                 --target x86_64-windows

With one target the output is build/. With several, each gets its own subdirectory:

build/
├── x86_64-linux-gnu/my_native.node
├── aarch64-linux-gnu/my_native.node
├── aarch64-macos/my_native.node
└── x86_64-windows/my_native.node

Targets build in parallel. Cross compiling to Windows downloads that architecture's node.lib automatically.

glibc version. To build for an older Linux than the one you are on, name the glibc you want:

export default defineConfig({
  targets: { linux: { triple: 'x86_64-linux-gnu', glibc: '2.28' } },
})

Zig ships the headers and stubs for every glibc it supports, so this needs no container.

Loading the right one at runtime. Pick by platform in your entry point:

const { platform, arch } = process
module.exports = require(`./build/${arch}-${platform}/my_native.node`)

Windows and Bun

A DLL may not have undefined symbols, so a Windows addon must link an import library for the Node-API. There are two routes and the right one is chosen for you:

  • A .def file, turned into an import library by zig dlltool. This is the default: node-api-headers is a dependency of this package, the file is a few kilobytes, nothing is downloaded, and it is the only route that works for Bun, whose Node-API exports live in bun.exe rather than node.exe.
  • node.lib, downloaded from nodejs.org — what node-gyp does. The fallback for when node-api-headers cannot be resolved at all. Node only.

So Bun on Windows needs nothing extra. The build produces both my_native.node and my_native.bun.node whenever Bun is installed, and the entry point picks:

const os = require('node:os')
const isBun = 'bun' in process.versions
const isWindows = os.platform() === 'win32'

module.exports =
  isBun && isWindows
    ? require('./build/my_native.bun.node')
    : require('./build/my_native.node')

On Linux and macOS one file serves both runtimes — the loader resolves the symbols either way.


Editor support

Every build writes compile_commands.json in the project root, with the real include paths, macros and target. clangd, ccls, VS Code's C/C++ extension and CLion all read it, so go-to-definition works on your C sources including the Node headers.

c-cpp-zig-build --step cdb    # write it without building anything else

It is rewritten only when its content changes, so your editor does not re-index the project on every build.


Package managers and monorepos

The CLI is the same everywhere; only the wrapper differs.

{
  "scripts": {
    "build": "c-cpp-zig-build",
    "build:debug": "c-cpp-zig-build --debug",
    "clean": "c-cpp-zig-build clean"
  }
}
npm npm run build
Yarn yarn build
pnpm pnpm build
Bun bun run build

In a monorepo, put the package in the workspace that holds the native module. Turborepo and Nx work unchanged; a task that depends on the addon should depend on build:

// turbo.json
{ "tasks": { "build": { "outputs": ["build/**"] }, "test": { "dependsOn": ["build"] } } }

node-addon-api and node-api-headers are resolved from the project that is being built before this package's own copies, so hoisting them to the workspace root is fine.


Continuous integration

Cache ~/.zig-build and the build is fast after the first run.

- uses: actions/cache@v4
  with:
    path: ~/.zig-build
    key: c-cpp-zig-build-${{ runner.os }}-${{ hashFiles('**/build.zig.zon') }}

- run: npm ci
- run: npm run build

No setup-python, no MSVC step, no apt-get install build-essential.

To build every platform's artifact from one Linux runner, use --target several times instead of a matrix. macOS and Windows binaries come out of a Linux job unchanged — only the platform's own tests need that platform's runner.

For a hermetic build, pin the toolchain and forbid downloads after a warm-up:

c-cpp-zig-build --zig-version 0.15.2 --node-version 22.11.0
c-cpp-zig-build --offline        # fails rather than reaching the network

Examples

Each directory is a complete, working project.

Example Shows
01-minimal-c-addon the smallest useful addon: plain C, four-line build.zig
02-cpp-node-addon-api C++ with node-addon-api, exceptions crossing into JS
03-library-cli-and-addon one core library behind an addon, a static library and a CLI, plus a third_party/ file drop with its own flags
04-zig-package-dependency linking zstd as a Zig package — nothing vendored
05-dependency-reading-cwd a dependency whose build.zig reads the working directory, and why it still builds

To run them from a clone of this repository:

node lib/cli.js build --root examples/01-minimal-c-addon
cd examples/01-minimal-c-addon && node --test

Porting an existing Zig build

If you already drive zig build from a hand-written build.zig, the move is mostly deletion. Two shapes that come up often, and what they collapse to:

A vendored SIMD library and a package dependency

Recursive source walking, Node-API include paths, Windows import-library generation, the .node install step, a compile_commands.json step and CPU feature detection are all handled, so what is left is the part that is actually specific to the project:

const std = @import("std");
const czb = @import("c_cpp_zig_build");

pub fn build(b: *std.Build) !void {
    const addon = try czb.addNodeAddon(b, .{ .name = "search_native" });

    // A package dependency that does not install its headers, so the include
    // path is added by hand. Most packages do install them and need only the
    // first line.
    addon.linkDependency("core", "core");
    addon.addDependencyIncludePath("core", "include");

    // CRoaring, vendored under third_party/. Its SIMD headers do not survive
    // the default warning flags, and AVX512 is switched off unless the target
    // CPU is known to have it.
    addon.addSources(.{
        .dir = "third_party/roaring",
        .files = &.{"roaring.c"},
        .warnings = false,
        .flags = if (hasAvx512(addon.target)) &.{} else &.{"-DCROARING_COMPILER_SUPPORTS_AVX512=0"},
    });
}

fn hasAvx512(target: std.Build.ResolvedTarget) bool {
    if (target.result.cpu.arch != .x86_64) return false;
    const features = target.result.cpu.features;
    return features.isEnabled(@intFromEnum(std.Target.x86.Feature.avx512f)) and
        features.isEnabled(@intFromEnum(std.Target.x86.Feature.avx512dq)) and
        features.isEnabled(@intFromEnum(std.Target.x86.Feature.avx512bw));
}

A layout that is not the default, and a multi-artifact package

const std = @import("std");
const czb = @import("c_cpp_zig_build");

pub fn build(b: *std.Build) !void {
    const addon = try czb.addNodeAddon(b, .{
        .name = "crypto_native",
        // third_party/ is compiled here as well as included, and its warnings
        // are not this project's business.
        .sources = &.{
            .{ .dir = "src" },
            .{ .dir = "napi" },
            .{ .dir = "third_party", .warnings = false },
        },
        // Generated headers live deeper than the convention expects.
        .include = &.{
            "include",
            "include/generated/proto",
            "third_party",
            "third_party/protobuf-c",
        },
        .defines = &.{.{ .name = "USE_SODIUM" }},
    });

    // libsodium builds a static and a shared artifact, so the choice has to be
    // made explicitly or the artifact name is ambiguous.
    addon.linkDependencyWith(
        "libsodium",
        if (addon.target.result.os.tag == .windows) "libsodium-static" else "sodium",
        .{ .static = true, .shared = false },
    );
}

Both of these are real ports, of build files of roughly two hundred lines each. The addons they produce are within a kilobyte of what the originals produced, with the same exports.

The steps

  1. npm i -D c-cpp-zig-build
  2. Add the template to build.zig.zon, keeping your existing dependencies:
    .dependencies = .{
        .c_cpp_zig_build = .{ .path = ".zig-native" },
        // ...yours, unchanged
    },
  3. Rewrite build.zig as above.
  4. Delete the zig_compile_commands dependency if you have one — the template brings its own generator, so that entry is no longer needed.
  5. Replace the build script in package.json with c-cpp-zig-build, and delete the helper that used to drive Zig.
  6. A dependency you link with linkDependency must not be .lazy = true. Drop the flag, or resolve it yourself with b.lazyDependency.

Coming from node-gyp

node-gyp here
binding.gyp build.zig
sources: [...] nothing — directories are walked
include_dirs .include
defines .defines
cflags / cflags_cc .flags / .c_flags / .cpp_flags
dependencies .linkDependency(...) or addSources
<!(node -p "require('node-addon-api').include") automatic
build/Release/addon.node build/addon.node
a system compiler per platform one downloaded toolchain for all of them
prebuild / prebuildify for other platforms --target

The C and C++ sources themselves need no changes: node_api.h and napi.h are the same headers.

What you give up: binding.gyp conditionals ('conditions') have no direct equivalent — use Zig's if (target.result.os.tag == .windows) in build.zig, which is a real language and rather more readable.


Troubleshooting

no build.zig.zon in <dir> Run c-cpp-zig-build init.

build.zig.zon does not declare the build template Add this to its .dependencies:

.c_cpp_zig_build = .{ .path = ".zig-native" },

invalid fingerprint: 0x…; if this is a new or forked package, use this value: 0x… Zig computes the fingerprint from the package name. Paste the value it prints into build.zig.zon.

source directory 'napi' does not exist Either create it, remove it from .sources, or mark the set .optional = true.

no Node headers were provided build.zig was run directly instead of through the CLI. Either use c-cpp-zig-build, or pass -Dnode-headers=<dir> yourself.

undefined symbol: _napi_… when cross compiling to macOS This should not happen — the template sets the flag that allows it. If you replaced addNodeAddon with a hand-written compile step, set compile.linker_allow_shlib_undefined = true for macOS targets.

building a Windows addon needs an import library Only when you run zig build by hand. Through the CLI, node.lib is downloaded automatically.

A dependency panics with FileNotFound on a directory of yours A package whose build.zig reaches for std.fs.cwd() rather than b.build_root.handle is looking for its own sources in your project. linkDependency and dependency already resolve a package with its own directory current, so this should not reach you — if it does, the resolution went through b.dependency() directly. Route it through czb.dependency(b, name, args) instead, and see example 5. The package is still worth a pull request.

The addon does not reflect my changes c-cpp-zig-build clean then build again. If that fixes it, the build cache went stale — please report it.

warning: … giving up, only N KiB/s during the first build A community mirror was too slow, so it was dropped and another was tried. The build continues; nothing is wrong. Set ZIG_MIRROR to a fast mirror, or to https://ziglang.org/download to skip the mirrors entirely.

Behind a proxy or an air-gapped network Set ZIG_MIRROR and NODEJS_ORG_MIRROR to internal mirrors, or pre-populate ~/.zig-build and build with --offline.

c-cpp-zig-build info prints every resolved path and version. It is the first thing to run when a build behaves unexpectedly.


Reference

Node API

import { build, clean, info, zig, defineConfig } from 'c-cpp-zig-build'

await build({ root: 'packages/native', optimize: 'fast' })
await build({ targets: ['x86_64-linux-gnu', 'aarch64-macos'] })
await clean()
await info()
await zig(['fmt', '--check', 'build.zig'])

Full types are in index.d.ts.

Zig version

The package targets Zig 0.15.2. Zig's build API is not yet stable, so a different release may need a different template; pin with --zig-version or zigVersion and upgrade deliberately.

Versioning

Semantic versioning, with the Zig template counted as part of the public interface: a change to build.zig that an existing project would have to react to is a breaking change, not a patch. Changes are listed in CHANGELOG.md.


Working on this package

Only relevant if you are changing c-cpp-zig-build itself. AGENTS.md has the full layout and ground rules.

npm install
npm test              # unit tests
npm run test:examples # builds and tests all four examples
npm run lint          # Biome
npm run fmt:check     # zig fmt, for the Zig template

Forking or renaming

The package name appears in five derived forms. Publishing under a different name, or under a scope, means changing all of them together — a half-rename is worse than either state:

Form Where
c-cpp-zig-build package.json name, both bin entries, every doc and example
czb the short bin alias, and the @import alias in every build.zig
c_cpp_zig_build the Zig package name in zig/build.zig.zon, the .dependencies key every project writes, and ZIG_PACKAGE_NAME in lib/template.js
C_CPP_ZIG_BUILD_HOME, C_CPP_ZIG_BUILD_DEBUG environment variables
a fingerprint zig/build.zig.zon

The fingerprint is the one that bites: Zig derives it from the package name and rejects a mismatch. After renaming the Zig package, delete the .fingerprint line, build once, and paste in the value Zig prints — or compute it with the same function the scaffolder uses:

node -e "import('./lib/scaffold.js').then(m => console.log(m.fingerprint('new_name')))"

Scoping the npm name (@you/c-cpp-zig-build) touches only package.json; the Zig side is independent of the npm scope.


License

Apache-2.0.

The idea of driving a Zig cross-compile from a small Node script, and the shape of its target description, were taken from @solarwinds/zig-build (MIT). No code from it is reproduced here.

About

Build native C/C++ Node.js addons and libraries with Zig \u2014 downloads the Zig toolchain and the Node headers for you, and ships a build.zig template that configures a whole project from a handful of parameters.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages