The node-api module provides Node-API bindings for writing idiomatic Zig addons
for V8-based runtimes like Node.JS or Bun. Thanks to its conventions-based approach it bridges the gap seamlessly,
with almost no Node-API specific code!
- function mapping, including async support (auto-conversion to Promises)
- class mapping, incl. support for fields, instance (async) methods and satic (async) methods.
- wrapping/unwrapping of native objects instances
- memory management with convention-based
init,deinitsupport &allocatorinjection - error handling with
errorunionsupport - Convention-base type conversion
- by value: through (de)serialization or various types with automatic memory management
- by reference:
- Zig-managed values: through pointers to (wrapped) native Zig values
- JS-managed values: through wrappers types (
NodeValueet.al.) for read/write
- type-safe callbacks:
NodeFunction(fn (u32, u32) !u32)
Install the node_api (note the underscore) dependency
> zig fetch --save https://github.com/typesafe/node-api-zig/archive/refs/tags/v0.0.4-beta.tar.gzAdd the node-api module to your library:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const mod = b.addModule("root", .{
.root_source_file = b.path("src/root.zig"),
.target = target,
.optimize = optimize,
});
const node_api = b.dependency("node_api", .{});
mod.addImport("node-api", node_api.module("node-api"));
const lib = b.addLibrary(.{
// if Linux
.use_llvm = true,
.name = "my-native-node-module",
.root_module = mod,
});
b.installArtifact(lib);
}Initialize your Node-API extension:
const node_api = @import("node-api");
comptime {
// export encrypt function (or types, or values or pointers)
node_api.@"export"(.{
.encrypt = encrypt,
// ...
});
}
fn encrypt(
// serialized from JS string, borrowed memory
value: []const u8,
// serialized from JS object by value (use *Options for wrapped native instances)
options: Options,
// "injected" by convention, any memory allocated with it is freed after returning to JS
allocator: std.mem.Allocator,
) ![]const u8 {
const res = allocator.alloc(u8, 123);
// ...
return res; // freed by node-api
}Use your library as node module:
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { encrypt } = require("my-native-node-module.node");
const m = encrypt("secret", { salt: "..." });There are 2 options to initialize a module:
node_api.@"export": conveniant for exporting comptime values, which is very likelynode_api.init(fn (node: NodeContext) !NodeValue): for exporting runtime values
comptime {
node_api.register(init);
}
fn init(node: node_api.NodeContext) !?node_api.NodeValue {
// init & return `exports` value/object/function
}The node_api.init initialization function as well as any
Struct types, functions, fields, parameters and return values are all converted by convention. Unsupported types result in compile errors.
| Native type | Node type | Remarks |
|---|---|---|
type |
Class or Function |
Returning or passing a struct type to JS, turns it into a class.Returning or passing a fn, turns it into a JS-callable, well, function. |
i32,i64,u32 |
number |
|
u64 |
BigInt |
|
[]const u8, []u8 |
string |
UTF-8 |
[]const T, []T |
array |
|
*T |
Object |
Passing struct pointers to JS will wrap & track them. |
NodeValue |
any |
NodeValue can be used to access JS values by reference. |
Function parameters and return types can be
- native Zig types (unsupported types will result in compile time errors)
- one of the NodeValue types to access values by reference.
Native values and NodeValue instance can be converted using Convert:
nativeFromNode(comptime T: type, value: NodeValue, allocator. Allocator) TnodeFromNative(value: anytype) NodeValue
Arguments can be of type:
- NodeContext => will result in the injection of the current NodeContext
- allocator => will inject the (arean) allocator for the current invocation, memory is freed after returning
- native Zig type => will be deserialized
- pointer => will return the native instance of a wrapped object
- optional
- enum
- NodeXxx values for references
node.defineClass transforms a Zig struct to a JS-accessible class by convention:
comptime {
node_api.register(init);
}
fn init(node: node_api.NodeContext) !?node_api.NodeValue {
// epo
return try node.serialize(.{
.MyClass = try node.defineClass(MyClass),
});
}
const MyClass = struct {
Self = @This();
field1: i32,
field2: ?[]u8,
pub fn init() Self {
return .{ .field1 = 0, .field2 = null };
}
pub fn method1(self: Self, v: i32) !void {
}
}
init maps to new.
- parameters of type Self work as expected, they come from the unwrapped JS value
- you can inject the current NodeContext, simply by adding a parameter of that type
- you can pass NodeValue, NodeObject, NodeArray values parameters for by-ref semantics
- you can declare Zig types as parameters, these result in a deserialized copy
- note that parameters that require memory allocation will be owned by the class instance (see below)
- class instances are allocated and freed automatically
- new-ing instance (from JS) will allocate memory (and update V8 stats)
- GC finalizers will automatically free the memory (and update V8 stats)
- Zig type arguments that require allocations are "owned by the instance"
- when the are store as field values the will be freed as part of the finalization process
- existing field values must be freed manually when they are overwritten!
- when the are store as field values the will be freed as part of the finalization process
/*
Scenarios:
native (wrapped) instance lifecycle:
- new in JS -> finalize in Zig
- create in Zig -> finalize in Zig
external instance memory:
- arena per instance?
- managed by instance if instance has allocator field
parameters and return values:
- pointers to structs result in uwrapped values
- parameters and return type memory
- arena per function call
setting field values
-
frees previous value, if any
*/