Guidance for coding agents working in a project that builds a native Node.js module with this package. Copy this file into such a project (or link to it) so an agent has the conventions to hand.
If you are working on c-cpp-zig-build itself, read
the maintainer section at the end.
C and C++ sources live in src/, Node-API bindings in napi/, public headers
in include/, vendored libraries in third_party/. Every .c and .cpp file
under src/ and napi/ is compiled automatically — there is no file list to
maintain. build.zig says what to build and is usually four lines. Build with
npm run build (or bun run build / yarn build / pnpm build); never invoke
a compiler directly. The Zig toolchain is downloaded on first build and the
Node-API headers ship with the tool; nothing needs to be installed.
npm run build # build (ReleaseSmall by default)
npm run lint # biome, if the project uses it
npx c-cpp-zig-build --debug # a debug build, with assertions
npx c-cpp-zig-build --verbose # print the exact compiler command lines
npx c-cpp-zig-build info # every resolved path, version and target
npx c-cpp-zig-build clean # remove build/, .zig-cache/, .zig-native/
npx c-cpp-zig-build --target aarch64-macos # cross compile
npx c-cpp-zig-build zig -- <args> # run the managed Zig toolchainSubstitute your package manager's runner for npx (bunx, yarn dlx,
pnpm exec) — the tool is the same.
When a build fails, run npx c-cpp-zig-build info first. It prints which
Zig, which headers, which target and which output directory are in play,
and most confusing failures are explained by one of those being unexpected.
| Directory | Contents | Compiled? |
|---|---|---|
src/ |
the library — the actual logic | yes, recursively |
napi/ |
Node-API bindings, nothing else | yes, recursively |
include/ |
public headers | no, but on the include path |
third_party/ |
vendored libraries | only when build.zig says so |
build/ |
output — generated, never edit | — |
.zig-native/ |
the build template — generated, never edit | — |
Create it under src/ and build. That is the whole procedure — do not add
it to a list anywhere, because there is no list. The same is true for
subdirectories: the walk is recursive.
Bindings may be C (#include <node_api.h>) or C++ (#include <napi.h>).
Both headers are available without the project installing anything:
c-cpp-zig-build depends on node-api-headers and node-addon-api. Declare
either in the project's package.json when the version matters — the declared
one wins.
node-api-headers carries the Node-API and nothing else, so v8.h, node.h
and uv.h are not available. A binding that needs them is doing something
Node-API is meant to make unnecessary; if it genuinely does, pass
--node-headers <dir> with a full Node header set.
Put it in napi/, and keep it thin. A binding should convert JavaScript values
to C types, call a function in src/, and convert the result back. Logic in
napi/ cannot be tested or profiled without a JavaScript runtime, which is the
whole reason for the split.
/* napi/binding.c — the shape to follow */
#include <node_api.h>
#include "thing.h" /* from include/ */
static napi_value Thing(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value argv[1];
if (napi_get_cb_info(env, info, &argc, argv, NULL, NULL) != napi_ok || argc < 1) {
napi_throw_type_error(env, NULL, "thing(x) expects one argument");
return NULL; /* returning NULL with a pending
exception is how you throw */
}
int64_t x = 0;
napi_get_value_int64(env, argv[0], &x);
napi_value result;
napi_create_int64(env, thing_compute(x), &result); /* the work is in src/ */
return result;
}The whole file is often this:
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" });
}Change it when, and only when, one of these is true:
| Situation | What to add |
|---|---|
| a C or C++ standard is required | .c_std = "c17", .cpp_std = "c++20" |
| a macro is needed everywhere | .defines = &.{ .{ .name = "FOO", .value = "1" } } |
sources live somewhere other than src/ and napi/ |
.sources = &.{ .{ .dir = "lib" } } |
| a header directory is elsewhere | .include = &.{ "include", "vendor/inc" } |
| a vendored file needs its own flag | artifact.addSources(.{ .dir = "…", .files = &.{"…"}, .flags = &.{"-D…"} }) |
a vendored library warns or fails on -W… |
add .warnings = false to its source set |
| a Zig package is being linked | artifact.linkDependency("dep", "artifact") |
| that package takes build options | artifact.linkDependencyWith("dep", "artifact", .{ .static = true }) |
| a system library is needed | artifact.linkSystemLibrary("pthread") |
| the same code should also be a CLI or static library | a second czb.addExecutable / addStaticLibrary call |
Everything else — libc, libc++, the Node headers, the .node extension, the
Windows import library, compile_commands.json — is handled and should not be
added by hand.
The escape hatch. artifact.compile is an ordinary
*std.Build.Step.Compile. Anything Zig can do to a compile step can be done to
it. Reach for that before working around the template.
In this order of preference:
npx c-cpp-zig-build zig -- fetch --save https://github.com/allyourcodebase/zstd/archive/refs/tags/1.5.7-2.tar.gzThis edits build.zig.zon for you — do not write the .hash by hand, it will
be wrong. Then in build.zig:
const addon = try czb.addNodeAddon(b, .{ .name = "my_native" });
addon.linkDependency("zstd", "zstd");The first argument is the key in build.zig.zon, the second is the artifact
name the package installs. When unsure of the artifact name, read the
dependency's own build.zig — look for b.addLibrary(.{ .name = … }).
Unpack the library there. Its headers are on the include path immediately. To compile it:
addon.addSources(.{ .dir = "third_party/thelib", .exclude = &.{ "test/", "examples/" } });
addon.addIncludePath("third_party/thelib/include");Same as a file drop from the build's point of view. Remember that CI needs
git submodule update --init --recursive.
Never add a dependency by committing prebuilt .a, .so, .dll or .lib
files. They defeat cross compilation and will break the first time someone
builds for another platform.
A dependency linked with linkDependency must not be marked .lazy = true in
build.zig.zon — lazy dependencies are only reachable through
b.lazyDependency, which returns null until Zig has fetched them.
Tests belong at two levels, and both are worth having:
JavaScript tests exercise the addon through its public interface:
// test.mjs
import assert from 'node:assert/strict'
import test from 'node:test'
import addon from './index.cjs'
test('computes the thing', () => {
assert.equal(addon.thing(21), 42)
})npm run build && node --testAlways build before testing — a stale build/ is the most common cause of a
test that passes locally and fails in CI, or the reverse.
C tests exercise src/ without a JavaScript runtime. Add an executable in
build.zig and run it as a step:
const tests = try czb.addExecutable(b, .{
.name = "test_thing",
.sources = &.{ .{ .dir = "src" }, .{ .dir = "tests" } },
});
const run = b.addRunArtifact(tests.compile);
b.step("test", "Run the C tests").dependOn(&run.step);npx c-cpp-zig-build --step testDo not edit generated files. .zig-native/ is overwritten on every build,
and build/ on every compile. A change in either is lost without warning. If
something in .zig-native/ needs to change, the change belongs in the
c-cpp-zig-build package.
Do not add a system compiler. No gcc, clang, cl.exe, make,
CMakeLists.txt or binding.gyp. Zig is the toolchain; a second one defeats
the point and will not be reproducible on another machine.
Do not commit build output. build/, .zig-cache/, .zig-native/ and
compile_commands.json belong in .gitignore.
Do not hand-write .hash values in build.zig.zon. Use
c-cpp-zig-build zig -- fetch --save.
Do not pin the artifact name in two places. The name in
build.zig decides the output file; index.cjs must load exactly that name.
Prefer --debug while investigating. The default is ReleaseSmall, which
strips assertions and makes stack traces useless. A crash reproduced under
--debug is a crash you can read.
- Check every
napi_*return value. They fail, and ignoring the failure turns a clear error into a segfault. - Return
NULLfrom a callback only with a pending exception. ReturningNULLwithout throwing producesundefinedand hides the bug. - Values above 2^53 must cross as
BigInt(napi_create_bigint_uint64), not asnapi_create_double— a hash or a 64-bit id silently loses precision otherwise. napi_get_buffer_infoborrows;napi_get_value_string_utf8copies and you own the memory. Free what you allocate on every path, including the error ones.- A C++ exception must not cross into JavaScript. Catch it and throw a
Napi::Error, or the process goes down. NAPI_VERSIONis set to 8 by the build. Raising it costs compatibility with older Node versions; do not raise it casually.
| Message | Cause and fix |
|---|---|
no build.zig.zon in <dir> |
not set up yet — run c-cpp-zig-build init |
build.zig.zon does not declare the build template |
add .c_cpp_zig_build = .{ .path = ".zig-native" }, to .dependencies |
invalid fingerprint: 0x…; use this value: 0x… |
paste the printed value into build.zig.zon |
source directory 'napi' does not exist |
create it, drop it from .sources, or mark the set .optional = true |
no Node headers were provided |
zig build was run directly; use c-cpp-zig-build |
error: call to undeclared function |
a missing #include — Zig's C compiler is strict about C99 and later |
artifact name '…' is ambiguous |
the package builds several artifacts of that name; pick one with linkDependencyWith(…, .{ .static = true, .shared = false }) |
static function '…' is used in an inline function with external linkage |
-Wpedantic on third-party code; set .warnings = false on that source set |
always_inline function '…' requires target feature |
a SIMD library compiled for a CPU without those features; pass the library's own "disable" define in the source set's flags |
undefined symbol: napi_… at load time |
the addon was built for another Node-API version, or is stale — rebuild |
Cannot find module './build/x.node' |
not built yet, or index.cjs names a different file than build.zig does |
| the addon loads but is missing an export | the napi_define_properties count argument does not match the array length |
lib/ JavaScript side (ESM, no build step)
cli.js argument parsing and command dispatch
index.js build / clean / info / zig
config.js defaults, config files, auto-detection
zig.js toolchain download, mirrors, checksum + signature checks
minisign.js Ed25519 signature verification for the Zig archive
node-headers.js node-api-headers / node-addon-api discovery
scaffold.js the `init` command
template.js copying the Zig template into a project
download.js fetch, verify, extract, lock
host.js platform, musl, target triple, Node version
proc.js fsutil.js log.js
zig/ the Zig template, copied into projects verbatim
build.zig the public API: addNodeAddon, addStaticLibrary, …
src/sources.zig recursive source collection
src/napi.zig Windows import libraries
src/compile_commands.zig the clangd database generator
examples/ six complete projects, all of which must keep building
(06-turborepo is a workspace, driven by its own scripts)
tests/ node --test
scripts/ test-examples.mjs — builds and tests every example
test-windows.mjs — cross compiles and inspects the PE
index.d.ts hand-written types for the JavaScript API
Ground rules:
- A user-visible change needs a
CHANGELOG.mdentry and a version bump, and the Zig template inzig/build.zig.zoncarries the same version as the package. Tests enforce both. The template counts as public interface: abuild.zigchange an existing project would have to react to is a breaking change, not a patch. - Every dependency is pinned to an exact version, not a range. For the two
header packages that is because the headers decide what compiles, so the
version that ships is the version that was tested; for the linter it is
because one that moves under you turns an unrelated commit into a diff full
of reformatting. Upgrade deliberately: bump the pin, run
npx biome migrate --writefor Biome, and rebuild every example — a header bump can only be judged by compiling against it. - A header package may only be bumped to a version that still supports the
oldest Node in
engines. There is a test for it; when it fails, either the pin stops moving orenginesdoes. - The only runtime dependencies are
node-addon-apiandnode-api-headers. Both are header-only with no dependencies of their own, and both earn their place: they are what lets a C++ addon and a Windows or Bun build work with nothing installed in the project. Adding a third needs a reason of that weight — a build tool that breaks because a transitive dependency broke is a bad build tool. - Both are resolved from the consuming project first. A project that pins
its own
node-addon-apicompiles against that one;c-cpp-zig-build inforeports which copy was used. - The JavaScript is plain ESM with no compile step. Types live in
index.d.ts, written by hand. zig/must stay self-contained. It carries its owncompile_commands.jsongenerator precisely so that a consuming project'sbuild.zig.zonstays free for the project's own dependencies. Do not add a Zig dependency to it.- Every download is verified before it is unpacked, and extracted to a
staging directory before being renamed into place. Keep both properties.
The Zig archive gets two independent checks — the SHA-256 from ziglang.org's
index, and a minisign signature against a key pinned in
lib/minisign.js. Neither replaces the other: the checksum says the file is the one ziglang.org listed, the signature says the Zig project made it. The signature is what makes the community mirrors safe to use, so it must always be fetched from ziglang.org rather than from the mirror serving the archive. - A file that fails verification is deleted, never merely rejected. Leaving it in the download cache would let the next run find it and skip the download.
lib/minisign.jsis security-critical and has its own test file. Its tests generate a keypair and craft signatures, so they need no network. Any change there needs the negative cases to still fail: tampered file, wrong key, edited trusted comment, mismatched file name.- Zig's build API is unstable. Changing the supported Zig version means re-testing every example, not just the unit tests.
Before proposing a change:
npm run lint
node --test tests/
npm run test:examples # every example, through its own build and test scripts
npm run test:windows # the Windows import libraries, by cross compiling
node lib/cli.js zig -- fmt --check zig/build.zig zig/src/test:examples drives each example with npm run build and npm test rather
than calling the CLI directly, so it runs what the READMEs tell a reader to
type — and so 06-turborepo, which is a workspace rather than a single
project, needs no special case.