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
113 changes: 103 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,85 @@ zapi provides two main components:
npm install -D @chainsafe/zapi
```

Add the Zig dependency to your `build.zig.zon`:
## Zig addon project setup

[`examples/`](examples/) is a complete Zig consumer project with its own
[`build.zig.zon`](examples/build.zig.zon) and
[`build.zig`](examples/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`:

```zig
.dependencies = .{
.zapi = .{
.url = "https://github.com/chainsafe/zapi/archive/<commit>.tar.gz",
.hash = "...",
},
},
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`:

```zig
comptime {
js.exportModule(@This(), .{
.identity = @import("zapi_addon_identity"),
});
}
```

See [`examples/js_dsl/mod.zig`](examples/js_dsl/mod.zig) for a normal class
addon and [`examples/addon_isolation/mod.zig`](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`](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:

```bash
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.

---

## Zig Library — Quick Start
Expand Down Expand Up @@ -60,7 +128,11 @@ pub const Counter = struct {
}
};

comptime { js.exportModule(@This(), .{}); }
comptime {
js.exportModule(@This(), .{
.identity = @import("zapi_addon_identity"),
});
}
```

**JavaScript usage:**
Expand All @@ -73,7 +145,23 @@ 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.
`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(), .{})`.

```text
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`.

---

Expand Down Expand Up @@ -319,7 +407,11 @@ Import Zig modules as `pub const` to create JS namespaces. The DSL recursively r
pub const math = @import("math.zig"); // → exports.math.multiply(...)
pub const crypto = @import("crypto.zig"); // → exports.crypto.PublicKey, etc.

comptime { js.exportModule(@This(), .{}); }
comptime {
js.exportModule(@This(), .{
.identity = @import("zapi_addon_identity"),
});
}
```

Namespaces nest arbitrarily — a sub-module with more `pub const` imports creates deeper nesting.
Expand All @@ -333,6 +425,7 @@ Namespaces nest arbitrarily — a sub-module with more `pub const` imports creat
```zig
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)
});
Expand Down
28 changes: 27 additions & 1 deletion build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,31 @@ const zbuild = @import("zbuild");

pub fn build(b: *std.Build) !void {
@setEvalBranchQuota(200_000);
_ = try zbuild.configureBuild(b, @import("build.zig.zon"), .{});
const manifest = @import("build.zig.zon");
_ = try zbuild.configureBuild(b, manifest, .{});
}

/// Makes the final addon's package and artifact identity available to its root
/// module as `@import("zapi_addon_identity")`. Distinct logical addons in one
/// package must use unique compile-step names and root modules.
pub fn addAddonIdentity(
b: *std.Build,
addon: *std.Build.Step.Compile,
comptime manifest: anytype,
) void {
const import_name = "zapi_addon_identity";
if (addon.root_module.import_table.contains(import_name)) {
@panic(
"this root module already has a zapi addon identity; configure it once " ++
"for aliases of the same addon, or create a separate root module " ++
"for a distinct addon",
);
}

const identity = b.addOptions();
identity.addOption([]const u8, "package_name", @tagName(manifest.name));
identity.addOption([]const u8, "package_version", manifest.version);
identity.addOption(u64, "package_fingerprint", manifest.fingerprint);
identity.addOption([]const u8, "addon_name", addon.name);
addon.root_module.addOptions(import_name, identity);
}
63 changes: 1 addition & 62 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
.modules = .{
// `napi` and `zapi` share the same source file but expose different
// imports — `napi` is used by `napi.zig`-only consumers, while `zapi`
// links libc and is the canonical entry point for example modules.
// links libc and is the canonical entry point for consumers.
.napi = .{
.root_source_file = "src/root.zig",
.include_paths = .{"include"},
Expand All @@ -34,70 +34,9 @@
.imports = .{.build_options},
.link_libc = true,
},
.example_hello_world = .{
.root_source_file = "examples/hello_world/mod.zig",
.imports = .{.zapi},
.link_libc = true,
},
.example_type_tag = .{
.root_source_file = "examples/type_tag/mod.zig",
.imports = .{.zapi},
.link_libc = true,
},
.example_js_dsl = .{
.root_source_file = "examples/js_dsl/mod.zig",
.imports = .{.zapi},
.link_libc = true,
},
.example_register_decls = .{
.root_source_file = "examples/register_decls/mod.zig",
.imports = .{.zapi},
.link_libc = true,
},
},
.libraries = .{
.example_hello_world = .{
.root_module = .example_hello_world,
.linkage = .dynamic,
.linker_allow_shlib_undefined = true,
.dest_sub_path = "example_hello_world.node",
},
.example_type_tag = .{
.root_module = .example_type_tag,
.linkage = .dynamic,
.linker_allow_shlib_undefined = true,
.dest_sub_path = "example_type_tag.node",
},
.example_js_dsl = .{
.root_module = .example_js_dsl,
.linkage = .dynamic,
.linker_allow_shlib_undefined = true,
.dest_sub_path = "example_js_dsl.node",
},
.example_register_decls = .{
.root_module = .example_register_decls,
.linkage = .dynamic,
.linker_allow_shlib_undefined = true,
.dest_sub_path = "example_register_decls.node",
},
},
.tests = .{
.napi = .{ .root_module = .napi },
.zapi = .{ .root_module = .zapi },
// Example tests reference napi C symbols (`napi_wrap`, `napi_typeof`, …)
// which Node provides at dlopen time. Standalone zig test binaries don't
// have Node around, so allow undefined shared-library symbols.
.example_hello_world = .{
.root_module = .example_hello_world,
.linker_allow_shlib_undefined = true,
},
.example_type_tag = .{
.root_module = .example_type_tag,
.linker_allow_shlib_undefined = true,
},
.example_js_dsl = .{
.root_module = .example_js_dsl,
.linker_allow_shlib_undefined = true,
},
},
}
37 changes: 37 additions & 0 deletions examples/addon_isolation/mod.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { copyFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";

const require = createRequire(import.meta.url);
const primaryPath = require.resolve("../zig-out/lib/example_js_dsl.node");
const primary = require(primaryPath);
const secondary = require("../zig-out/lib/example_addon_isolation.node");
const duplicatePath = join(dirname(primaryPath), "example_js_dsl_duplicate.node");
copyFileSync(primaryPath, duplicatePath);
const duplicate = require(duplicatePath);

describe("DSL class isolation across addons", () => {
it("keeps matching class names isolated by addon", () => {
const primaryCounter = new primary.Counter(1);
const secondaryCounter = new secondary.Counter(2);

primary.incrementCounter(primaryCounter);
secondary.incrementCounter(secondaryCounter);
expect(primaryCounter.getCount()).toBe(2);
expect(secondaryCounter.getCount()).toBe(3);

expect(() => primary.incrementCounter(secondaryCounter)).toThrow(TypeError);
expect(() => secondary.incrementCounter(primaryCounter)).toThrow(TypeError);
});

it("keeps class identity across two loaded copies of one addon", () => {
const primaryCounter = new primary.Counter(1);
const duplicateCounter = new duplicate.Counter(2);

primary.incrementCounter(duplicateCounter);
duplicate.incrementCounter(primaryCounter);
expect(primaryCounter.getCount()).toBe(2);
expect(duplicateCounter.getCount()).toBe(3);
});
});
27 changes: 27 additions & 0 deletions examples/addon_isolation/mod.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const js = @import("zapi").js;
const Number = js.Number;

/// Matches `example_js_dsl.Counter`'s layout so the regression fails safely
/// instead of reinterpreting incompatible native memory.
pub const Counter = struct {
pub const js_meta = js.class(.{});
count: i32,

pub fn init(start: Number) Counter {
return .{ .count = start.assertI32() };
}

pub fn getCount(self: Counter) Number {
return Number.from(self.count);
}
};

pub fn incrementCounter(counter: *Counter) void {
counter.count += 1;
}

comptime {
js.exportModule(@This(), .{
.identity = @import("zapi_addon_identity"),
});
}
20 changes: 20 additions & 0 deletions examples/build.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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,
);
}
Loading
Loading