A Zig N-API wrapper library and CLI for building and publishing cross-platform Node.js native addons.
zapi provides two main components:
- Zig Library (
src/) - Write Node.js native addons in Zig with a high-level DSL that mirrors JavaScript's type system - CLI Tool (
ts/) - Build tooling for cross-compiling and publishing multi-platform npm packages
npm install -D @chainsafe/zapiexamples/ is a complete Zig consumer project with its own
build.zig.zon and
build.zig. The zapi package build does not configure the
examples.
Most projects produce one addon. A second logical addon exists only when the
package produces another final .node library with a separate root module. The
examples project produces six addons; example_js_dsl.node and
example_addon_isolation.node both use DSL classes and therefore each receive
their own identity. A project does not need to produce multiple addons for the
identity to matter: one Node process can load addons from unrelated packages,
and each addon must reject class objects created by the others.
The consumer's build.zig configures the package, then attaches the generated
identity to every final compile step whose module exports or exchanges a
js.class:
const std = @import("std");
const zapi_build = @import("zapi");
const zbuild = @import("zbuild");
pub fn build(b: *std.Build) !void {
@setEvalBranchQuota(200_000);
const manifest = @import("build.zig.zon");
const result = try zbuild.configureBuild(b, manifest, .{});
zapi_build.addAddonIdentity(
b,
result.library("example_js_dsl").?,
manifest,
);
zapi_build.addAddonIdentity(
b,
result.library("example_addon_isolation").?,
manifest,
);
}The corresponding root modules pass the generated import to js.exportModule:
comptime {
js.exportModule(@This(), .{
.identity = @import("zapi_addon_identity"),
});
}See examples/js_dsl/mod.zig for a normal class
addon and examples/addon_isolation/mod.zig
for the same-named class in a second addon used to test cross-addon isolation.
A function-only module does not need an identity; the complete project keeps
examples/function_only_dsl/mod.zig on
js.exportModule(@This(), .{}).
addAddonIdentity creates the zapi_addon_identity import from the consumer
package name, version, fingerprint, and final compile-step name. Do not create
that import as a source file. Distinct addons in one package need unique
compile-step names and separate root modules. The identity does not use
dest_sub_path or the path from which Node loads the file, so copies and
filesystem aliases of the same compiled .node remain compatible.
The examples declare zapi and zbuild as URL/hash dependencies. Build them
against this checkout with:
cd examples
zig build --fork=..For another consumer project, use zig build normally. During local zapi
development, keep the URL/hash dependency and override it with
zig build --fork=/path/to/zapi; Zig 0.16 .path dependencies do not expose
the dependency's build.zig as an import.
The DSL is the default approach for writing native addons. Import js from zapi and write normal Zig functions — zapi handles all the N-API marshalling automatically.
const js = @import("zapi").js;
pub fn add(a: js.Number, b: js.Number) !js.Number {
return js.Number.from(try a.toI32() + try b.toI32());
}
pub const Counter = struct {
pub const js_meta = js.class(.{
.properties = .{
.count = js.prop(.{ .get = true, .set = false }),
},
});
_count: i32,
pub fn init(start: js.Number) !Counter {
return .{ ._count = try start.toI32() };
}
pub fn increment(self: *Counter) void {
self._count += 1;
}
// Getter: obj.count (not obj.count())
pub fn count(self: Counter) js.Number {
return js.Number.from(self._count);
}
};
comptime {
js.exportModule(@This(), .{
.identity = @import("zapi_addon_identity"),
});
}JavaScript usage:
const mod = require('./my_module.node');
mod.add(1, 2); // 3
const c = new mod.Counter(0);
c.increment();
c.count; // 1 (getter, not a method call)pub functions are auto-exported, and structs with js_meta = js.class(...)
become JS classes. Class type tags are derived at compile time from the Zig
package name, version, fingerprint, addon artifact name, and class type name.
This keeps two loaded copies of one addon compatible while isolating different
addons and package versions. Consequently, addons built from v1.0.0 and
v1.0.1 cannot exchange DSL class objects in the same process, even if their
native class definitions are otherwise compatible. Modules that export only
functions and never accept or return DSL classes may continue to use
js.exportModule(@This(), .{}).
FNV-1a-128(package@version#fingerprint::addon::ZigType)
Low-level wrapper and conversion APIs take the same identity type explicitly.
Code paths that cannot accept or return DSL classes pass
js.NoAddonIdentity.
| Type | JS Equivalent | Key Methods |
|---|---|---|
Number |
number |
toI32(), toF64(), assertI32(), from(anytype) |
String |
string |
toSlice(buf), toOwnedSlice(alloc), len(), from([]const u8) |
Boolean |
boolean |
toBool(), assertBool(), from(bool) |
BigInt |
bigint |
toI64(), toU64(), toI128(), from(anytype) |
Date |
Date |
toTimestamp(), from(f64) |
Array |
Array |
get(i), getNumber(i), length(), set(i, val) |
Object(T) |
object |
get(), set(value) — T fields must be DSL types |
Function |
Function |
call(args) |
Value |
any |
isNumber(), asNumber(), type checking/narrowing |
Uint8Array etc. |
TypedArray |
toSlice(), toArray(len), from(slice) |
Promise(T) |
Promise |
resolve(value), reject(err) |
Three patterns for exporting functions:
pub fn add(a: Number, b: Number) !Number {
return Number.from(try a.toI32() + try b.toI32());
}pub fn safeDivide(a: Number, b: Number) !Number {
const divisor = try b.toI32();
if (divisor == 0) return error.DivisionByZero;
return Number.from(@divTrunc(try a.toI32(), divisor));
}JS: try { safeDivide(10, 0) } catch (e) { /* "DivisionByZero" */ }
pub fn findValue(arr: Array, target: Number) ?Number {
const len = arr.length() catch return null;
// ... search, return null if not found
}Structs with js_meta = js.class(...) are exported as JavaScript classes:
pub const Timer = struct {
pub const js_meta = js.class(.{});
start: i64,
pub fn init() Timer {
return .{ .start = std.time.milliTimestamp() };
}
pub fn elapsed(self: Timer) js.Number {
return js.Number.from(std.time.milliTimestamp() - self.start);
}
pub fn reset(self: *Timer) void {
self.start = std.time.milliTimestamp();
}
pub fn deinit(self: *Timer) void {
_ = self;
}
};Method classification:
| Signature | JS Behavior |
|---|---|
pub fn init(...) |
Constructor (new Class(...)) — must return T or !T |
pub fn method(self: T, ...) |
Immutable instance method |
pub fn method(self: *T, ...) |
Mutable instance method |
pub fn method(self: T, ...) !T |
Instance method returning a new JS instance |
pub fn method(...) !T |
Static method returning a new JS instance |
pub fn method(...) |
Static method (no self, returns non-T) |
pub fn deinit(self: *T) |
Optional GC destructor |
Methods or functions that return the class type automatically materialize a fresh JS instance. There is no separate author-facing "factory" marker:
pub const PublicKey = struct {
pub const js_meta = js.class(.{});
pk: bls.PublicKey,
pub fn init() PublicKey {
return .{ .pk = undefined };
}
// Static factory: PublicKey.fromBytes(bytes)
pub fn fromBytes(bytes: js.Uint8Array) !PublicKey {
const slice = try bytes.toSlice();
return .{ .pk = try bls.PublicKey.deserialize(slice) };
}
};JS: const pk = PublicKey.fromBytes(bytes);
Same-class instance methods also work:
pub fn clone(self: MyState) !MyState {
const cloned = try self.data.clone();
return .{ .data = cloned };
}JS: const newState = state.clone(); — returns a new instance, original unchanged.
Parameters with optional DSL types (?js.Number, ?js.Boolean, etc.) become optional JS arguments:
pub fn fromBytes(bytes: js.Uint8Array, validate: ?js.Boolean) !PublicKey {
const do_validate = if (validate) |v| try v.toBool() else false;
// ...
}JS: PublicKey.fromBytes(bytes) or PublicKey.fromBytes(bytes, true)
Declare properties inside js_meta with js.prop to register property accessors:
pub const Config = struct {
pub const js_meta = js.class(.{
.properties = .{
.volume = js.prop(.{ .get = true, .set = true }),
.muted = js.prop(.{ .get = true, .set = true }),
.label = js.prop(.{ .get = true, .set = false }),
},
});
_volume: i32,
_muted: bool,
_label: []const u8,
pub fn init() Config {
return .{ ._volume = 50, ._muted = false, ._label = "default" };
}
// Read-write: obj.volume / obj.volume = 80
pub fn volume(self: Config) js.Number {
return js.Number.from(self._volume);
}
pub fn setVolume(self: *Config, value: js.Number) !void {
const v = try value.toI32();
if (v < 0 or v > 100) return error.VolumeOutOfRange;
self._volume = v;
}
// Read-only: obj.label
pub fn label(self: Config) js.String {
return js.String.from(self._label);
}
};JS: cfg.volume = 80; cfg.label; // "default"
Rules:
pub const js_meta = js.class(.{})marks a struct as a JS class.properties = .{ .name = js.prop(.{ .get = true, .set = false }) }registers a readonly getter backed bypub fn name(...).properties = .{ .name = js.prop(.{ .get = true, .set = true }) }registers getter/setter methods usingnameandsetName.properties = .{ .name = js.prop(.{ .get = "customGetter", .set = false }) }registers a getter backed by a specifically named method- Accessor backing methods are not exported as callable JS methods
const Config = struct { host: String, port: Number, verbose: Boolean };
pub fn connect(config: Object(Config)) !String {
const c = try config.get();
// access c.host, c.port, c.verbose
}pub fn sum(data: Uint8Array) !Number {
const slice = try data.toSlice();
var total: i32 = 0;
for (slice) |byte| total += @intCast(byte);
return Number.from(total);
}pub fn asyncOp(val: Number) !Promise(Number) {
var promise = try js.createPromise(Number);
try promise.resolve(val); // must resolve or reject before returning
return promise;
}Promise(T) in this DSL path is synchronous-only: resolve or reject it before the exported function returns. For truly asynchronous completion, keep the Deferred handle in lower-level N-API code and bridge back with napi.AsyncWork or napi.ThreadSafeFunction.
pub fn applyCallback(val: Number, cb: Function) !Value {
return try cb.call(.{val});
}Import Zig modules as pub const to create JS namespaces. The DSL recursively registers all DSL-compatible declarations:
// root.zig
pub const math = @import("math.zig"); // → exports.math.multiply(...)
pub const crypto = @import("crypto.zig"); // → exports.crypto.PublicKey, etc.
comptime {
js.exportModule(@This(), .{
.identity = @import("zapi_addon_identity"),
});
}Namespaces nest arbitrarily — a sub-module with more pub const imports creates deeper nesting.
exportModule accepts optional lifecycle hooks with atomic env refcounting:
comptime {
js.exportModule(@This(), .{
.identity = @import("zapi_addon_identity"),
.init = fn (refcount: u32) !void, // called before registration (0 = first env)
.cleanup = fn (refcount: u32) void, // called on env exit (0 = last env)
});
}This enables safe shared-state initialization for worker thread scenarios.
pub fn advanced() !Value {
const e = js.env(); // access low-level napi.Env
const obj = try e.createObject();
// use any napi.Env method...
return .{ .val = obj };
}Context accessors:
| Function | Description |
|---|---|
js.env() |
Current N-API environment (thread-local, set by DSL callbacks) |
js.io() |
Shared std.Io handle retained for the addon while at least one N-API environment is active |
js.allocator() |
C allocator for native allocations |
js.thisArg() |
JS this value (available inside instance methods/getters/setters) |
The DSL layer handles most use cases. Drop down to the N-API layer when you need full control over handle scopes, async work, thread-safe functions, or other advanced features.
| Type | Description |
|---|---|
Env |
The N-API environment, provides methods to create values, throw errors, manage scopes |
Value |
A JavaScript value handle with methods for type checking, property access, conversions |
CallbackInfo |
Provides access to function arguments and this binding |
HandleScope |
Prevents garbage collection of values within a scope |
EscapableHandleScope |
Like HandleScope but allows one value to escape |
Ref |
A persistent reference to a value that survives garbage collection |
Deferred |
Resolver/rejecter for promises |
AsyncWork |
Run work on a thread pool with completion callback on main thread |
ThreadSafeFunction |
Call JavaScript from any thread safely |
AsyncContext |
Context for async resource tracking |
Full control using raw Env and Value:
fn add_manual(env: napi.Env, info: napi.CallbackInfo(2)) !napi.Value {
const a = try info.arg(0).getValueInt32();
const b = try info.arg(1).getValueInt32();
return try env.createInt32(a + b);
}Let zapi handle argument/return conversion:
const napi = @import("zapi").napi;
// Arguments and return value are automatically converted
fn add(a: i32, b: i32) i32 {
return a + b;
}
// Register with automatic wrapping
try env.createFunction("add", 2, napi.createCallback(2, add, .{}), null);Control how arguments are converted:
napi.createCallback(2, myFunc, .{
.args = .{ .env, .auto, .value, .data, .string, .buffer },
.returns = .value, // or .string, .buffer, .external_buffer, .auto
});| Hint | Description |
|---|---|
.auto |
Automatic type conversion; byte-slice returns are copied into a JS Buffer |
.env |
Inject napi.Env |
.value |
Pass raw napi.Value |
.data |
User data pointer passed to createFunction |
.string |
Convert to/from []const u8 |
.buffer |
Borrow Buffer arguments as byte slices; copy byte-slice returns |
.external_buffer |
Transfer an OwnedBuffer to JavaScript without copying |
External buffers require explicit ownership transfer:
const allocator = std.heap.page_allocator;
fn makeExternalBuffer() !napi.OwnedBuffer {
const data = try allocator.dupe(u8, "external data");
return napi.OwnedBuffer.fromOwnedSlice(allocator, data);
}
const callback = napi.createCallback(0, makeExternalBuffer, .{
.returns = .external_buffer,
});OwnedBuffer.intoValue consumes the buffer even if conversion fails, so the caller must not
deinitialize it afterwards. Once N-API accepts the external buffer, the allocator must remain valid
until its finalizer runs, including if N-API subsequently reports an error. If the environment
disallows external buffers, intoValue copies the bytes and releases the original allocation before
returning.
const napi = @import("zapi").napi;
const Timer = struct {
start: i64,
pub fn read(self: *Timer) i64 {
return std.time.milliTimestamp() - self.start;
}
};
try env.defineClass(
"Timer",
0,
timerConstructor,
null,
&[_]napi.c.napi_property_descriptor{
.{ .utf8name = "read", .method = napi.wrapCallback(0, Timer.read) },
},
);Run CPU-intensive work off the main thread:
const napi = @import("zapi").napi;
const Work = struct {
a: i32,
b: i32,
result: i32,
deferred: napi.Deferred,
};
fn execute(env: napi.Env, data: *Work) void {
// Runs on thread pool - don't call JS here!
data.result = data.a + data.b;
}
fn complete(env: napi.Env, status: napi.status.Status, data: *Work) void {
// Back on main thread - resolve the promise
const result = env.createInt32(data.result) catch return;
data.deferred.resolve(result) catch return;
}
// Create async work
const work = try napi.AsyncWork(Work).create(env, null, name, execute, complete, &data);
try work.queue();Call JavaScript from any thread:
const napi = @import("zapi").napi;
const tsfn = try env.createThreadsafeFunction(
jsCallback, // JS function to call
context, // User context
"name",
0, // Max queue size (0 = unlimited)
1, // Initial thread count
null, // Finalize data
null, // Finalize callback
myCallJsCallback, // Called on main thread
);
// From any thread:
try tsfn.call(&data, .blocking);All N-API calls return NapiError on failure:
const napi = @import("zapi").napi;
fn myFunction(env: napi.Env) !void {
// Errors propagate naturally
const value = try env.createStringUtf8("hello");
// Throw JavaScript errors
try env.throwError("ERR_CODE", "Something went wrong");
try env.throwTypeError("ERR_TYPE", "Expected a number");
}Add a zapi field to your package.json:
{
"name": "my-addon",
"zapi": {
"binaryName": "my-addon",
"step": "my-lib",
"targets": [
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc"
]
}
}| Target | Platform | Arch | ABI |
|---|---|---|---|
aarch64-apple-darwin |
macOS | arm64 | - |
x86_64-apple-darwin |
macOS | x64 | - |
aarch64-unknown-linux-gnu |
Linux | arm64 | glibc |
aarch64-unknown-linux-musl |
Linux | arm64 | musl |
x86_64-unknown-linux-gnu |
Linux | x64 | glibc |
x86_64-unknown-linux-musl |
Linux | x64 | musl |
x86_64-pc-windows-msvc |
Windows | x64 | msvc |
| Option | Description |
|---|---|
--help, -h |
Show help message |
--version, -v |
Show version number |
Build for a single target platform.
zapi build [options]| Option | Description | Default |
|---|---|---|
--step |
Zig build step | zapi.step from package.json |
--target |
Target triple | Current platform |
--optimize |
Debug, ReleaseSafe, ReleaseFast, ReleaseSmall |
- |
--zig-cwd |
Working directory for zig build | . |
Build for all configured targets and collect artifacts.
zapi build-artifacts [options]| Option | Description | Default |
|---|---|---|
--step |
Zig build step | zapi.step from package.json |
--optimize |
Optimization level | - |
--zig-cwd |
Working directory for zig build | . |
--artifacts-dir |
Output directory for artifacts | artifacts |
Example output:
▶ Building my-addon for 6 target(s)...
[1/6] Building for x86_64-unknown-linux-gnu...
→ Moving artifact to artifacts/x86_64-unknown-linux-gnu
[2/6] Building for aarch64-apple-darwin...
→ Moving artifact to artifacts/aarch64-apple-darwin
...
✓ Built 6 artifact(s) to artifacts/
Prepare npm packages for publishing:
- Creates
npm/<target>/directories for each target - Moves compiled
.nodebinaries from artifacts into target packages - Generates
package.jsonfor each target package (with correctos,cpu,libc) - Updates the main
package.jsonwithoptionalDependencies
zapi prepublish [options]| Option | Description | Default |
|---|---|---|
--artifacts-dir |
Directory containing built artifacts | artifacts |
--npm-dir |
Directory for npm packages | npm |
Example output:
▶ Preparing my-addon@1.0.0 for publishing...
▶ Moving artifacts to npm packages...
→ x86_64-unknown-linux-gnu → npm/x86_64-unknown-linux-gnu/my-addon.node
▶ Generating target package.json files...
→ Created npm/x86_64-unknown-linux-gnu/package.json
▶ Updating package.json with optionalDependencies...
✓ Prepared 6 target package(s) in npm/
Publish all target-specific packages and the main package to npm.
zapi publish [options] [-- <npm-args>]| Option | Description | Default |
|---|---|---|
--npm-dir |
Directory containing npm packages | npm |
--dry-run |
Preview what would be published without publishing | false |
Any arguments after -- are passed directly to npm publish (e.g., --access public, --tag beta).
Example dry-run:
zapi publish --dry-run▶ [DRY RUN] Would publish 6 target package(s) + main package
→ Extra npm args: (none)
[1/7] Would publish x86_64-unknown-linux-gnu
→ Directory: /path/to/npm/x86_64-unknown-linux-gnu
...
✓ [DRY RUN] 7 package(s) would be published
GitHub releases are managed with release-please:
- Conventional commits merged to
mainupdate or create the release PR. - Merging that PR tags a new GitHub release and bumps
package.json. build.zig.zonis kept in sync from the same release-please version.- The release workflow installs dependencies, runs
pnpm build:js, and publishes the root package directly withnpm publishvia npm trusted publishing. - No
NPM_TOKENsecret is required for npm publish; GitHub Actions OIDC (id-token: write) is used together with--provenance. - The published npm package is the JS distribution only (
lib/andts/).
Set DEBUG=1 for full stack traces on errors.
Load the native addon, automatically selecting the correct binary for the current platform:
import { requireNapiLibrary } from "@chainsafe/zapi";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const addon = requireNapiLibrary(__dirname);Resolution order:
- Local build:
zig-out/lib/<binaryName>.node - Published package:
<pkg-name>-<target>
See the examples/ directory for comprehensive examples including:
- All DSL types (Number, String, Boolean, BigInt, Date, Array, Object, TypedArrays, Promise)
- Error handling and nullable returns
- Classes with static factories, instance factories, and optional parameters
- Computed getters and setters
- Nested namespaces
- Module lifecycle hooks (init/cleanup with worker thread refcounting)
- Callbacks and mixed DSL/N-API usage
- Low-level N-API with manual registration
MIT