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/zapiAdd the Zig dependency to your build.zig.zon:
.dependencies = .{
.zapi = .{
.url = "https://github.com/chainsafe/zapi/archive/<commit>.tar.gz",
.hash = "...",
},
},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(), .{}); }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. One line — comptime { js.exportModule(@This(), .{}); } — registers everything.
| 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(), 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(), .{}); }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(), .{
.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.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, .auto
});| Hint | Description |
|---|---|
.auto |
Automatic type conversion |
.env |
Inject napi.Env |
.value |
Pass raw napi.Value |
.data |
User data pointer passed to createFunction |
.string |
Convert to/from []const u8 |
.buffer |
Convert to/from byte slice |
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",
"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 |
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.zonandzbuild.zonare 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