Cross-compiling

Contents

napikit build --release

That single command builds your addon for every platform listed in .npm.platforms and lays out the npm package structure ready to publish. Zig's cross-compilation is built in: there are no toolchains to install, no Docker images, no QEMU.

The same command is also safe to run repeatedly. The build owns and reconciles the files it generates; anything you've added or edited (your seam over the addon, user fields on the main package.json, the version, files unrelated to the build) is preserved. Reconciliation happens in a sibling staging directory, then claims the destination with an exclusive directory create and materializes every entry without overwriting an existing path. Content, modes, identities, temporary trees, and the recovery backup are revalidated at their commit and cleanup boundaries, so a copy failure, manifest-write failure, or concurrent edit cannot silently replace or recursively delete user work.

Output layout

The first napikit build --release writes a complete npm/ tree:

npm/<name>/
├── package.json              # main package
├── index.js                  # your seam over the addon
├── binding.js                # platform detection + dynamic require
├── index.d.ts                # only with .dts = .auto or .{ .file = ... }
└── <scope>/
    ├── binding-darwin-arm64/
    │   ├── package.json
    │   └── <name>.node
    ├── binding-linux-x64-gnu/
    │   └── ...
    └── ...                   # one binding per platform

<name> is your addon's name. <scope> is the npm scope from the .scope field of the .npm block in build.zig. Every per-platform binding lives under that scope.

Calling addLib more than once in build.zig is supported; each addon gets its own npm/<name>/ subtree and is reconciled independently. A newly generated addon inherits the one strict semantic version already shared by the repository's generated and extra packages; only a repository with no existing publishable package starts at 0.0.0. Existing version drift fails before Zig instead of assigning an arbitrary version to the new addon. See Multiple addons in one repo.

What every release build does

napikit build --release is the only command you need during release. It cross-compiles, then reconciles npm/ against the policy in build.zig. Re-run it as often as you like; you cannot drift npm/ out of sync with build.zig.

The reconciler is conservative about your work:

File or directoryBehavior on every release build
<name>.node (each platform)Refreshed.
binding.jsRefreshed.
index.d.tsRefreshed with an ownership header for .auto or .{ .file = … }; removed on a later switch to .none only when that header proves it is build-owned. Arbitrary handwritten declarations are preserved.
package.json (main, policy fields)Refreshed: name, license, type, main, optionalDependencies, npmjs/public publishConfig, plus types only when declarations are enabled. The dependency keys track .platforms; repository is refreshed when .npm.repository is configured.
package.json (main, files)Merged: index.js and binding.js are canonical; index.d.ts is canonical only when enabled. Other entries you add (for example assets/) are preserved.
package.json (main, user fields)Preserved: description, homepage, keywords, author, bugs, funding, engines, scripts, an existing repository when .npm.repository is empty, and anything else you've added.
package.json (main, version)Preserved. Only napikit bump changes it. The bindings' optionalDependencies values are kept in lockstep with this version.
package.json (per-binding)Refreshed: name, license, os, cpu, libc, main, files, and npmjs/public publishConfig. version is pinned to the main package's version.
<scope>/binding-*/Fully build-owned and recreated to match .platforms. A removed platform deletes its entire binding directory, including anything added inside it; a new platform creates a new directory.
<scope>/A scope change removes the old build-owned binding directories, removes the old scope only when empty, and creates the new scope. Unrelated contents under the old scope are preserved.
index.jsSeeded once on the first release build, then preserved. Your seam: edit it freely.
Other main-package top-level contentPreserved outside build-owned scope/binding directories. Add CHANGELOG.md, .npmignore, etc.; the build will not touch them.

Before synchronization, a full release build verifies that every generated source is a regular non-symlink file, every optionalDependencies entry has exactly one binding directory, main/dependency/binding versions and licenses agree with generated policy, binding repository provenance matches the main package, and entry points, files, npmjs/public publication policy, and target-specific os/cpu/libc metadata are canonical. Unexpected manifest fields such as lifecycle scripts are rejected. Reconciliation copies from a fingerprint-verified private snapshot so a source replacement during synchronization cannot leak unvalidated bytes into npm/. A --current build permits configured bindings that are intentionally absent from its temporary host-only output, but requires the exact host binding and still rejects extra or inconsistent generated bindings.

The practical guarantee: edit build.zig, then re-run napikit build --release. That works for every change, including renaming the scope, adding or removing platforms, changing .dts, and renaming .host_exe. You do not need to delete npm/ first; the reconciler removes a disabled build-owned declaration and otherwise preserves unrelated user files.

The one exception is renaming the addon's .name itself. The new tree is created fresh under npm/<new-name>/, the old npm/<old-name>/ becomes an orphan, and the build prints a warning that asks you to copy any user fields you want to keep on the new main package.json and delete the old folder. (Renaming a published npm package is a rare and disruptive event; this is intentional.)

Building a subset

A full release build cross-compiles every platform for every addon. During local development you often want something faster.

--current builds only the host platform's binding:

napikit build --release --current

This compiles a single .node for the machine you are on and skips the others. The host must be present in that addon's configured .npm.platforms; otherwise the command fails before writing an undeclared binding package. The main package.json still lists every configured platform in optionalDependencies, and the build is additive: bindings already in npm/ from a previous full build are left in place, not deleted. Run a full napikit build --release before publishing so every platform is present.

--only narrows the build to specific addons by .name, useful when build.zig calls addLib more than once:

napikit build --release --only math,crypto

Unlisted addons are skipped entirely and their npm/ subtrees are left untouched. Combine the two flags to rebuild just one addon for just your platform:

napikit build --release --only math --current

What index.js is for

binding.js is fully owned by the build. It implements platform detection and loads the matching <scope>/binding-… package. Do not edit it; your changes will be overwritten on the next build.

index.js is your seam. On the first napikit build --release, @teakit/napi walks the same public Zig declarations used by the runtime and declarations, then seeds both default and named exports. For an addon that exports add and greet, the generated shape is:

import _napiBinding from "./binding.js";
export const { add, greet } = _napiBinding;
export default _napiBinding;

That is already a working entry point for both import styles. Keep it as-is, or use it for JS-side wrapping, normalization, or higher-level helpers. After it is seeded, edits to index.js survive every rebuild; if you intentionally maintain a custom seam, you are also responsible for keeping its named exports aligned with the addon.

Default platforms

If .platforms is omitted from .npm, you get this set:

OSArchitectureslibc
Linuxx64, arm64, armglibc and musl
macOSx64, arm64n/a
Windowsx64, arm64n/a
FreeBSDx64n/a

That is 11 binaries from one napikit build --release call.

Custom platforms

Override the default list to ship only what you need:

napi.addLib(b, napi_dep, .{
    // ...
    .npm = .{
        .scope = "@myscope",
        .platforms = &.{
            .linux_x64_gnu,
            .macos_arm64,
        },
    },
});

Adding or removing entries from .platforms is a regular edit. The next napikit build --release adds bindings for the new entries and deletes bindings for ones you took out. The full list of Platform values is in build.zig (addLib).

The Zig enum uses names such as .macos_arm64 and .windows_x64; generated npm binding suffixes still use Node/npm platform names such as darwin-arm64 and win32-x64.

Electron and other hosts

By default, addons are compiled to load into node.exe on Windows. To target Electron, set .host_exe:

napi.addLib(b, napi_dep, .{
    // ...
    .host_exe = "electron.exe",
});

This only affects Windows import-library generation. On macOS and Linux the addon loads into whatever process imports it without any host-specific configuration.

Optimization

napikit build --release defaults every cross-compiled binary to ReleaseFast. For development, napikit build defaults to Debug. Override the release mode with:

napikit build --release --optimize=safe    # ReleaseSafe
napikit build --release --optimize=small   # ReleaseSmall

The selected mode applies to every release artifact; safe and small are not reduced to ReleaseFast internally.