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 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 and the Node headers are downloaded
on first build and cached in
~/.zig-build, shared across all your projects.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. Node headers
are checked against nodejs.org's
SHASUMS256.txt. - 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 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.
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/
├─ 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.
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-signatureDownloads 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.
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 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 |
--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.
| 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 |
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.
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 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`)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
.deffile, turned into an import library byzig dlltool. This is the default:node-api-headersis 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 inbun.exerather thannode.exe. node.lib, downloaded from nodejs.org — what node-gyp does. The fallback for whennode-api-headerscannot 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.
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 |
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.
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 --node-version 22.11.0
c-cpp-zig-build --offline # fails rather than reaching the networkEach 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 --testIf 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.
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.
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 four examples
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.