Build native C/C++ Node.js modules with Zig, without a system toolchain.
npm run build downloads the Zig compiler, compiles your C and C++ sources
against the Node-API headers that ship with this package, 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 buildApache-2.0 · Changelog · requires Node 18.17+, nothing else
- Why
- Requirements
- Quick start
- How it works
- What is verified
- Project layout
- The
build.zigAPI - Adding dependencies
- Configuration
- Cross compilation
- Windows and Bun
- Editor support
- Package managers and monorepos
- Continuous integration
- Examples
- Porting an existing Zig build
- Coming from node-gyp
- Troubleshooting
- Reference
- Working on this package
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 is downloaded on first build and
cached in
~/.zig-build, shared across all your projects. The Node-API headers are not downloaded at all:node-addon-apiandnode-api-headerscome 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.
- A build file you can read.
build.zigis usually four lines. The template behind it is a normal Zig package, and the artifact it returns is a normalstd.Build.Step.Compile, so nothing is out of reach. - Cross compilation as a flag.
--target aarch64-macosfrom 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.
| 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 |
| Network | On the first build only, then never again |
tar |
Always present on macOS and Linux. On Windows, the bundled tar.exe (Windows 10 1803+); older Windows unpacks with PowerShell |
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.
mkdir my-native && cd my-native
npm init -y
npm i -D c-cpp-zig-build
npx c-cpp-zig-build init
npm run buildinit 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) // 42If 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" },
});
}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-apic-cpp-zig-build info prints which copy is in use.
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/
├─ finds node-api-headers and node-addon-api
│ (yours if you declare them, otherwise the copies shipped here)
└─ 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/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.
Everything this tool downloads is checked before it is used. Nothing is unpacked, and no compiler is run, on bytes that failed a check.
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 onTurning it off is possible and inadvisable:
c-cpp-zig-build --no-verify-signatureNothing to verify: they are not downloaded, and neither is anything else from
nodejs.org. Every addon compiles against the node-api-headers package, and on
Windows the import library is generated from that same package's module
definition file. Both arrive through npm with the rest of the dependency tree
and are covered by npm audit signatures below.
nodejs.org is not contacted at all — not for headers, not for node.lib.
The Zig toolchain is the one download left, and it is not always ziglang.org
that serves it. Three community mirrors are tried at random first, with
ziglang.org as the fallback; Zig asks tooling to prefer the mirrors, which is
why. ziglang.org always serves the three things that make a mirror safe to use:
the release index with its SHA-256, the mirror list itself, and the minisign
signature — a mirror never gets to vouch for its own archive. ZIG_MIRROR pins
one source, and --offline allows none.
| Host | What it serves |
|---|---|
| ziglang.org | the release index, the mirror list, and the signature |
| a community mirror | the archive itself, checked against both of the above |
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 Node-API headers every addon compiles against, and the Windows .def files |
Both are published with npm provenance attestations, so the whole tree can be checked:
npm audit signaturesA version your own project declares always wins over the pinned one, so the pin constrains this package, not yours.
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.
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" });
}| 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.
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
});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.
-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 });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 modeaddon.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");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.txtThree ways, in the order you should reach for them.
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.gzThat 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 namelinkDependency 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.
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" } },git submodule add https://github.com/RoaringBitmap/CRoaring third_party/CRoaringFrom 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.
Most projects need none. When they do, in order of precedence:
- command line flags
zig-native.config.js/.mjs/.cjs/.json- the
zigNativefield inpackage.json - 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
})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 |
--napi-version <n> |
Node-API level to target (default 8) |
--node-headers <dir> |
headers to compile against, instead of node-api-headers |
--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.
| 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 |
NO_COLOR |
plain output |
C_CPP_ZIG_BUILD_PROGRESS |
lines or off for the download progress |
C_CPP_ZIG_BUILD_DEBUG |
print stack traces on failure |
There is nothing to point at a Node version, and no --node-version flag.
Node-API is ABI stable and its headers come from node-api-headers, so an
addon is built once and loads in every runtime that implements the Node-API
level it asked for — Bun included.
--napi-version is what asks. It becomes the NAPI_VERSION macro, which gates
what the headers expose. The default is 8, which every Node this package
supports implements. Raising it reaches newer functions at the cost of the
older runtimes — level 9 wants Node 18.17 or 20.3 and up:
c-cpp-zig-build --napi-version 9node-api-headers carries the Node-API and nothing else: node_api.h,
js_native_api.h and their _types headers. v8.h, node.h and uv.h are
not there, and are not downloaded — an addon that includes them is pinned to
one Node build in exactly the way Node-API exists to avoid.
If you need them regardless, supply them yourself:
c-cpp-zig-build --node-headers /path/to/node-v22.11.0/include/nodeThe directory is passed to the compiler verbatim; it must contain
node_api.h. Nothing else about the build changes — on Windows the import
library still comes from node-api-headers, which stays installed either
way.
c-cpp-zig-build --target x86_64-linux-gnu \
--target aarch64-linux-gnu \
--target aarch64-macos \
--target x86_64-windowsWith 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 needs nothing extra:
the import library is generated for that architecture from node-api-headers'
module definition file.
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`)A DLL may not have undefined symbols, so a Windows addon must link an import
library for the Node-API. It is built locally, from the node_api.def that
node-api-headers ships:
zig dlltool -m i386:x86-64 -D node.exe -d node_api.def -l node_api.libThe file is a few kilobytes and needs no download. It is also the only route
that works for Bun, whose Node-API exports live in bun.exe rather than
node.exe — the same .def produces a second import library against
bun.exe, which is how one build serves both runtimes.
node-gyp instead downloads node.lib from nodejs.org. This package used to
fall back to that and no longer does: it is a download for something that can
be produced offline, and it cannot serve Bun. If you have a reason to link a
real node.lib, add it to the compile step yourself — the artifact
addNodeAddon returns is a plain std.Build.Step.Compile.
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.
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 elseIt is rewritten only when its content changes, so your editor does not re-index the project on every build.
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 |
With Bun, keep the run: build, test and info are Bun sub-commands of
their own, so a bare bun test runs Bun's test runner and bun info queries
the npm registry — neither reaches the script of that name.
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"] } } }outputs is the line that matters. A compiled .node file is the one
build output nothing else will ever recreate, and turbo restores only what a
task declares. Leave it out and turbo still reports a cache hit and still
replays the log line saying the addon was built — with no file on disk, and a
Cannot find module in whatever runs next.
examples/06-turborepo is a working workspace that
asserts both halves of that, including the failure.
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.
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 buildNo 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
c-cpp-zig-build --offline # fails rather than reaching the networkThe toolchain is the only thing pinning applies to; everything else a build
needs is in node_modules, at the version your lockfile already pins.
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 |
06-turborepo |
a turborepo workspace: declaring the addon as a task output, and what a cache hit does without it |
To run them from a clone of this repository — nothing to install first:
cd examples/01-minimal-c-addon
bun run build # or: npm run build
bun run test # or: npm testEach example's scripts call node ../../lib/cli.js, so they build with the
checkout rather than a release from npm. In a project of your own you would
install c-cpp-zig-build and write c-cpp-zig-build in the script instead;
examples/README.md has
the two-line diff. All six, plus their tests, run at once with npm run test:examples.
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:
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));
}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.
npm i -D c-cpp-zig-build- Add the template to
build.zig.zon, keeping your existing dependencies:.dependencies = .{ .c_cpp_zig_build = .{ .path = ".zig-native" }, // ...yours, unchanged },
- Rewrite
build.zigas above. - Delete the
zig_compile_commandsdependency if you have one — the template brings its own generator, so that entry is no longer needed. - Replace the build script in
package.jsonwithc-cpp-zig-build, and delete the helper that used to drive Zig. - A dependency you link with
linkDependencymust not be.lazy = true. Drop the flag, or resolve it yourself withb.lazyDependency.
| 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.
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.
'v8.h' file not found, or the same for node.h or uv.h
Since 0.3.0 the headers come from node-api-headers, which carries the
Node-API and nothing else. Either drop the include — a Node-API addon rarely
needs V8 — or supply the full set yourself with
--node-headers /path/to/node/include/node. See
Reaching past Node-API.
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
node-api-headers could not be resolved, so there is no node_api.def to turn
into one. It ships as a dependency of this package, so this is normally a
broken install: reinstall, or add node-api-headers to the project.
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 to an internal mirror, or pre-populate ~/.zig-build and
build with --offline. The Zig toolchain is the only thing fetched, so a
warmed cache is enough to build entirely offline — Windows targets
included.
c-cpp-zig-build info prints every resolved path and version. It is the
first thing to run when a build behaves unexpectedly.
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.
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.
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.
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 six examples
npm run test:windows # cross compiles and checks the Windows import libraries
npm run lint # Biome
npm run fmt:check # zig fmt, for the Zig templateThe 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.
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.