Comptime N-API bindings for Zig.
NOTE: This library is still in early development and the API is subject to change.
You need to use latest Zig master to use this library.
See ggml-js for a complete, real-world example.
- Primitives, tuples, structs (value types), optionals
- Strings (valid for the function scope)
- Struct pointers (see below)
- Functions (no classes, see below)
- all the
napi_xxx
functions and types are re-exported asnapigen.napi_xxx
,
so you can do pretty much anything if you don't mind going lower-level.
The library provides a simple and thin API, supporting only basic types. This design choice is intentional, as it is often difficult to determine the ideal mapping for more complex types. The library allows users to hook into the mapping process or use the N-API directly for finer control.
Specifically, there is no support for classes.
When returning a struct/tuple by value, it is mapped to an anonymous JavaScript object/array with all properties/elements mapped recursively. Similarly, when accepting a struct/tuple by value, it is mapped back from JavaScript to the respective native type.
In both cases, a copy is created, so changes to the JS object are not reflected in the native part and vice versa.
When returning a pointer to a struct, an empty JavaScript object will be created with the pointer wrapped inside. If this JavaScript object is passed to a function that accepts a pointer, the pointer is unwrapped back.
The same JavaScript object is obtained for the same pointer, unless it has already been collected. This is useful for attaching state to the JavaScript counterpart and accessing that data later.
Changes to JavaScript objects are not reflected in the native part, but getters/setters can be provided in JavaScript and native functions can be called as necessary.
JavaScript functions can be created with ctx.createFunction(zig_fn) and then exported like any other value. Only comptime-known functions are supported. If an error is returned from a function call, an exception is thrown in JavaScript.
fn add(a: i32, b: i32) i32 {
return a + b;
}
// Somewhere where the JsContext is available
const js_fun: napigen.napi_value = try js.createFunction(add);
// Make the function accessible to JavaScript
try js.setNamedProperty(exports, "add", js_fun);
Note that the number of arguments must match exactly. So if you need to support optional arguments, you will have to provide a wrapper function in JS, which calls the native function with the correct arguments.
Functions can also accept the current *JsContext
, which is useful for calling
the N-API directly or performing callbacks. To get a raw JavaScript value,
simply use napi_value
as an argument type.
fn callMeBack(js: *napigen.JsContext, recv: napigen.napi_value, fun: napigen.napi_value) !void {
try js.callFunction(recv, fun, .{ "Hello from Zig" });
}
And then
native.callMeBack(console, console.log)
If you need to store the callback for a longer period of time, you should create
a ref. For now, you have to do that directly, using napi_create_reference()
.
N-API modules need to export a function which will also init & return the
exports
object. You could export napi_register_module_v1
and call
JsContext.init()
yourself but there's also a shorthand using comptime
block
which will allow you to use try
anywhere inside:
comptime { napigen.defineModule(initModule) }
fn initModule(js: *napigen.JsContext, exports: napigen.napi_value) anyerror!napigen.napi_value {
try js.setNamedProperty(exports, ...);
...
return exports;
}
Whenever a value is passed from Zig to JS or vice versa, the library will call a hook function, if one is defined. This allows you to customize the mapping process.
Hooks have to be defined in the root module, and they need to be named
napigenRead
and napigenWrite
respectively. They must have the following
signature:
fn napigenRead(js: *napigen.JsContext, comptime T: type, value: napigen.napi_value) !T {
return switch (T) {
// we can easily customize the mapping for specific types
// for example, we can allow passing regular JS strings anywhere where we expect an InternedString
InternedString => InternedString.from(try js.read([]const u8)),
// otherwise, just use the default mapping, note that this time
// we call js.defaultRead() explicitly, to avoid infinite recursion
else => js.defaultRead(T, value),
}
}
pub fn napigenWrite(js: *napigen.JsContext, value: anytype) !napigen.napi_value {
return switch (@TypeOf(value) {
// convert InternedString to back to a JS string (hypothetically)
InternedString => try js.write(value.ptr),
// same thing here
else => js.defaultWrite(value),
}
}
First, create a new library:
mkdir example
cd example
zig init-lib
Then, change your build.zig
to something like this:
...
const lib = b.addSharedLibrary(.{
.name = "example",
.root_source_file = .{ .path = "src/main.zig" },
.target = target,
.optimize = optimize,
});
// weak-linkage
lib.linker_allow_shlib_undefined = true;
// add correct path to this lib
const napigen = b.createModule(.{ .root_source_file = .{ .path = "deps/napigen/napigen.zig" } });
lib.root_module.addImport("napigen", napigen);
// build the lib
b.installArtifact(lib);
// copy the result to a *.node file so we can require() it
const copy_node_step = b.addInstallLibFile(lib.getEmittedBin(), "example.node");
b.getInstallStep().dependOn(©_node_step.step);
...
Next, define some functions and the N-API module itself in src/main.zig
const std = @import("std");
const napigen = @import("napigen");
export fn add(a: i32, b: i32) i32 {
return a + b;
}
comptime {
napigen.defineModule(initModule);
}
fn initModule(js: *napigen.JsContext, exports: napigen.napi_value) !napigen.napi_value {
try js.setNamedProperty(exports, "add", try js.createFunction(add));
return exports;
}
Finally, use it from JavaScript as expected:
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const native = require('./zig-out/lib/example.node')
console.log('1 + 2 =', native.add(1, 2))
To build the library and run the script:
> zig build && node example.js
1 + 2 = 3
MIT