Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/node-addon.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Node Addon Matrix

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

permissions:
contents: read

jobs:
node-addon-tests:
runs-on: ${{ matrix.os }}
name: Node.js addon tests ${{ matrix.os }} node@${{ matrix.node }}
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest, windows-latest ]
node: [ '12', '14', '16', '18', '20', '22', '24' ]
include:
- os: macos-latest
node: '20'
- os: macos-latest
node: '22'
- os: macos-latest
node: '24'

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Zig
uses: mlugg/setup-zig@v2
with:
version: 0.16.0
cache-key: node-${{ matrix.node }}

- name: Verify Zig
run: zig version

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}

- name: Build and test Node addon matrix
if: runner.os != 'Windows'
run: |
cd node-test
npm install --no-package-lock
zig build --summary all
find . -maxdepth 2 -name '*.node' -print
npm test

- name: Build and test Node addon matrix
if: runner.os == 'Windows'
shell: pwsh
run: |
cd node-test
npm install --no-package-lock
zig build -Dtarget=x86_64-windows-msvc --summary all
Get-ChildItem -Recurse -Filter *.node | ForEach-Object { $_.FullName }
npm test
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
.DS_Store
.zig-cache
zig-out
node_modules
node-test/*.node
.tmp_arkvm_runner
.tmp_arkvm_memory_runner
77 changes: 67 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# zig-napi

This project can help us to build a native module library for OpenHarmony/HarmonyNext with zig-lang.
This project can help us to build native module libraries for OpenHarmony/HarmonyNext ArkTS and Node.js with zig-lang.

## Require

Expand Down Expand Up @@ -40,27 +40,67 @@ pub fn build(b: *std.Build) !void {

const napi = zig_napi.module("napi");

// Build ArkTS/OpenHarmony artifacts.
const result = try napi_build.nativeAddonBuild(b, .{
.name = "hello",
.napi_module = napi,
.root_module_options = .{
.root_source_file = b.path("./src/hello.zig"),
.target = target,
.optimize = optimize,
},
});
_ = result;
}
```

For a Node.js addon, call `nodeAddonBuild` instead. The Node target defaults to the host target unless `root_module_options.target` is provided.

```zig
const std = @import("std");
const napi_build = @import("zig-napi").napi_build;

pub fn build(b: *std.Build) !void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});

if (result.arm64) |arm64| {
arm64.root_module.addImport("napi", napi);
}
if (result.arm) |arm| {
arm.root_module.addImport("napi", napi);
}
if (result.x64) |x64| {
x64.root_module.addImport("napi", napi);
}
const zig_napi = b.dependency("zig-napi", .{});
const napi = zig_napi.module("napi");

const addon = try napi_build.nodeAddonBuild(b, .{
.name = "hello",
.napi_module = napi,
.node_api = .{
.version = .v8,
.experimental = false,
},
.root_module_options = .{
.root_source_file = b.path("./src/hello.zig"),
.target = target,
.optimize = optimize,
},
});
_ = addon;
}
```

OpenHarmony and Node addons request Node-API v8 by default. To request a newer runtime API version or experimental Node-API, configure `node_api` in `nativeAddonBuild` or `nodeAddonBuild`:

```zig
.node_api = .{
.version = .v10,
.experimental = false,
},
```

When `.experimental = true`, the addon requests Node-API experimental version and enables experimental declarations in `napi-sys`.

Version-gated APIs follow the same shape as NAPI-RS feature gates: for example `ThreadSafeFunction` and `Async` require v4, while `BigInt` requires v6. If an addon selects a lower version, those wrappers fail at compile time with a message pointing back to `.node_api.version`. For OpenHarmony builds, pass `.napi_module = napi` to `nativeAddonBuild` so the configured N-API version is applied to both the addon root module and the `napi` wrapper module. If type definitions compile the same source, pass the same `.node_api` to `generateTypeDefinition`.

Node addon builds use the hand-written `src/sys/node.zig` sys layer, matching napi-rs' hand-written `napi-sys` model. OpenHarmony/ArkTS builds still use the OHOS header set under `src/sys/ohos` through `native_api.h`.

On Windows MSVC, `nodeAddonBuild` follows napi-rs and does not require a `node.lib` lookup by default; the Node-API symbols are resolved from the current Node.js process at runtime. If a build needs to force an import library, pass `.node_import_lib`, set `NODE_LIB_FILE`, or set `NODE_LIB_DIR`. Windows GNU builds follow napi-rs' `LIBNODE_PATH`, `LIBPATH`, then `PATH` search for `libnode.dll` before linking `node`.

## Usage

```zig
Expand Down Expand Up @@ -98,6 +138,23 @@ zig build -Dtarget=aarch64-linux-ohos

And you can get `libhello.so` in `zig-out`.

The Node.js example is in `examples/node`:

```bash
cd examples/node
zig build
node test.js
```

It installs the addon as `zig-out/node/hello.<platform-arch-abi>.node`, for example `hello.darwin-arm64.node`, `hello.linux-x64-gnu.node`, or `hello.win32-x64-msvc.node`.

Node.js matrix tests live in `node-test`. It mirrors the NAPI-RS example split with two independent demos:

- `node-test/napi-compat-mode` covers compat-mode style APIs and runtime-gated N-API v4/v5/v6/v7/v8 scenarios.
- `node-test/napi` covers the non compat-mode example surface such as values, strict validation, async, ThreadSafeFunction, and worker-thread loading.

The Node addon CI runs those tests on Linux, macOS, and Windows for Node.js 12, 14, 16, 18, 20, and 22.

## Credits

This zig-napi project is heavily inspired by:
Expand Down
2 changes: 1 addition & 1 deletion benchmark/native-c/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ mkdir -p "${OUT_DIR}"
-O3 \
-fPIC \
-shared \
-I"${REPO_ROOT}/src/sys/header" \
-I"${REPO_ROOT}/src/sys/ohos" \
"${EXTRA_CFLAGS[@]}" \
"${SCRIPT_DIR}/napi_benchmark.c" \
-o "${OUT_DIR}/libnapi_benchmark.so" \
Expand Down
5 changes: 3 additions & 2 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ pub fn build(b: *std.Build) !void {

napi.addImport("napi-sys", napi_sys);
napi.addImport("build_options", build_options);
napi_sys.addImport("build_options", build_options);

napi.addIncludePath(b.path("src/sys/header"));
napi_sys.addIncludePath(b.path("src/sys/header"));
napi.addIncludePath(b.path("src/sys/ohos"));
napi_sys.addIncludePath(b.path("src/sys/ohos"));
}
114 changes: 114 additions & 0 deletions docs/napi-conversion-refactor-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# N-API Value Conversion Refactor Plan

## Background

Current conversion APIs such as `Napi.from_napi_value_auto(...)` return `T` directly and use `NapiError.last_error` as an out-of-band error channel. This can produce unsafe behavior: a failed conversion may still return a placeholder/default value, and callers must remember to check `last_error` before storing or cleaning up the converted value.

The immediate target is to align the exported-call behavior with napi-rs semantics:

- Convert JS arguments before calling native code.
- If conversion fails, throw a JS error and return immediately.
- Never store or clean up failed conversion results.
- Treat missing JS arguments as `undefined`, not uninitialized `napi_value`.

## Phase 1: Minimal `!T` Refactor

Goal: make failed conversion impossible to accidentally use by changing conversion helpers from `T` to `!T`, while keeping `NapiError.last_error` temporarily for detailed error payloads.

### Scope

- Change `Napi.from_napi_value_fast(env, raw, T) T` to `!T`.
- Change `Napi.from_napi_value_auto(env, raw, T) T` to `!T`.
- Change `Napi.from_napi_value(env, raw, T) T` to `!T`.
- Update all direct call sites under:
- `src/napi/value/function.zig`
- `src/napi/wrapper/class.zig`
- `src/napi/value/array.zig`
- `src/napi/value/object.zig`
- Update wrapper/value `from_napi_value` implementations that currently return `T` directly:
- number
- bool
- string
- bigint
- array
- object
- buffer
- arraybuffer
- reference
- abort signal if needed

### Required Behavior

- Function/class entrypoints use `try`/`catch` style flow instead of reading failed values.
- `initialized_params` / `initialized_args` are incremented only after conversion succeeds.
- Array/object recursive conversion frees already-created owned values if a later element/property conversion fails.
- Missing arguments continue to be normalized to JS `undefined`.

### Tests

- Existing `node-test/napi/__tests__/strict.spec.js` must stay green.
- Add focused tests for nested partial-conversion failure:
- array with first element valid and second invalid
- object with first field valid and second invalid
- union variants with slice/string payloads
- Run:
- `zig build --summary failures`
- `zig build --summary failures` in `node-test`
- `just test-node-matrix`
- `git diff --check`

### Estimate

0.5 to 1 day.

## Phase 2: Structured Conversion Result

Goal: remove or sharply reduce `NapiError.last_error` by returning structured conversion errors, closer to napi-rs `Result<T, Error>`.

Possible Zig shape:

```zig
pub fn NapiResult(comptime T: type) type {
return union(enum) {
ok: T,
err: napi.Error,
};
}
```

Alternative: keep Zig `!T` for control flow and add a scoped error payload object passed through conversion helpers. This avoids threadlocal state but is more invasive at call sites.

### Scope

- Replace `last_error`-based conversion failure propagation.
- Update async/worker/class/function error handling to consume structured errors.
- Decide public API breakage for helpers like:
- `Object.Get(...)`
- `Object.GetNamed(...)`
- `Array.Get(...)`
- Update docs and examples if these APIs become fallible.

### Tests

- Keep all Phase 1 tests.
- Add class constructor/method/setter invalid-argument tests.
- Add worker/async conversion error propagation tests if exposed by current API surface.
- Run Node matrix across Node 12/14/16/18/20/22 on Linux/macOS/Windows in CI.

### Estimate

2 to 4 days.

## Recommended Order

1. Land Phase 1 first to eliminate unsafe failed-value usage.
2. Expand strict invalid-input tests around array/object/class conversion.
3. Re-run CI matrix and watch for platform-specific N-API behavior.
4. Plan Phase 2 only after Phase 1 is stable, because it may require public API changes.

## Risks

- `Object.Get` / `Array.Get` becoming fallible may be a source-compatible breaking change.
- Array/object conversion must carefully deinit partially converted owned values.
- Some wrappers currently assume N-API conversion calls cannot fail; those assumptions need explicit handling.
- Keeping `last_error` during Phase 1 means detailed error propagation is still threadlocal, but failed values will no longer be used.
12 changes: 2 additions & 10 deletions examples/allocator-builtin/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,14 @@ pub fn build(b: *std.Build) !void {

const result = try napi_build.nativeAddonBuild(b, .{
.name = "hello",
.napi_module = napi,
.root_module_options = .{
.root_source_file = b.path("./src/hello.zig"),
.target = target,
.optimize = optimize,
},
});

if (result.arm64) |arm64| {
arm64.root_module.addImport("napi", napi);
}
if (result.arm) |arm| {
arm.root_module.addImport("napi", napi);
}
if (result.x64) |x64| {
x64.root_module.addImport("napi", napi);
}
_ = result;

const dts = try napi_build.generateTypeDefinition(b, .{
.root_source_file = b.path("./src/hello.zig"),
Expand Down
12 changes: 2 additions & 10 deletions examples/allocator-custom/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,14 @@ pub fn build(b: *std.Build) !void {

const result = try napi_build.nativeAddonBuild(b, .{
.name = "hello",
.napi_module = napi,
.root_module_options = .{
.root_source_file = b.path("./src/hello.zig"),
.target = target,
.optimize = optimize,
},
});

if (result.arm64) |arm64| {
arm64.root_module.addImport("napi", napi);
}
if (result.arm) |arm| {
arm.root_module.addImport("napi", napi);
}
if (result.x64) |x64| {
x64.root_module.addImport("napi", napi);
}
_ = result;

const dts = try napi_build.generateTypeDefinition(b, .{
.root_source_file = b.path("./src/hello.zig"),
Expand Down
4 changes: 1 addition & 3 deletions examples/basic/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub fn build(b: *std.Build) !void {

const result = try napi_build.nativeAddonBuild(b, .{
.name = "hello",
.napi_module = napi,
.root_module_options = .{
.root_source_file = b.path("./src/hello.zig"),
.target = target,
Expand All @@ -19,19 +20,16 @@ pub fn build(b: *std.Build) !void {
});

if (result.arm64) |arm64| {
arm64.root_module.addImport("napi", napi);
if (arm64.rootModuleTarget().abi.isOpenHarmony()) {
arm64.root_module.linkSystemLibrary("hilog_ndk.z", .{});
}
}
if (result.arm) |arm| {
arm.root_module.addImport("napi", napi);
if (arm.rootModuleTarget().abi.isOpenHarmony()) {
arm.root_module.linkSystemLibrary("hilog_ndk.z", .{});
}
}
if (result.x64) |x64| {
x64.root_module.addImport("napi", napi);
if (x64.rootModuleTarget().abi.isOpenHarmony()) {
x64.root_module.linkSystemLibrary("hilog_ndk.z", .{});
}
Expand Down
Loading
Loading