From cfd74ceeb481c8e7fe305715f271d111c480d0cd Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Mon, 27 Jul 2026 22:15:23 +0800 Subject: [PATCH 1/6] fix!: isolate DSL class tags across addons --- README.md | 16 ++++- build.zig.zon | 15 +++++ examples/addon_isolation/mod.test.ts | 16 +++++ examples/addon_isolation/mod.zig | 27 ++++++++ examples/js_dsl/mod.zig | 1 + src/js/class_meta.zig | 31 ++++++++- src/js/class_runtime.zig | 99 ++++++++++++++++++++++------ src/js/export_module.zig | 46 ++++++++++--- src/js/wrap_class.zig | 82 +++++++++++++++++++---- src/js/wrap_function.zig | 99 ++++++++++++++++++++++------ 10 files changed, 364 insertions(+), 68 deletions(-) create mode 100644 examples/addon_isolation/mod.test.ts create mode 100644 examples/addon_isolation/mod.zig diff --git a/README.md b/README.md index a76ef6e..6edd66a 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,11 @@ pub const Counter = struct { } }; -comptime { js.exportModule(@This(), .{}); } +comptime { + js.exportModule(@This(), .{ + .type_tag = "6f9619ff-8b86-d011-b42d-00cf4fc964ff", + }); +} ``` **JavaScript usage:** @@ -73,7 +77,7 @@ 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. `type_tag` is a stable addon-unique salt used to derive every class's 128-bit Node-API type tag. Generate it once (a UUID is recommended) and keep it unchanged across builds so class identities remain stable across addon reloads. --- @@ -257,6 +261,7 @@ JS: `cfg.volume = 80; cfg.label; // "default"` **Rules:** - `pub const js_meta = js.class(.{})` marks a struct as a JS class +- `.type_tag = "..."` optionally replaces the module's type-tag salt for one class; a UUID is recommended - `.properties = .{ .name = js.prop(.{ .get = true, .set = false }) }` registers a readonly getter backed by `pub fn name(...)` - `.properties = .{ .name = js.prop(.{ .get = true, .set = true }) }` registers getter/setter methods using `name` and `setName` - `.properties = .{ .name = js.prop(.{ .get = "customGetter", .set = false }) }` registers a getter backed by a specifically named method @@ -319,7 +324,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(), .{ + .type_tag = "6f9619ff-8b86-d011-b42d-00cf4fc964ff", + }); +} ``` Namespaces nest arbitrarily — a sub-module with more `pub const` imports creates deeper nesting. @@ -333,6 +342,7 @@ Namespaces nest arbitrarily — a sub-module with more `pub const` imports creat ```zig comptime { js.exportModule(@This(), .{ + .type_tag = "6f9619ff-8b86-d011-b42d-00cf4fc964ff", .init = fn (refcount: u32) !void, // called before registration (0 = first env) .cleanup = fn (refcount: u32) void, // called on env exit (0 = last env) }); diff --git a/build.zig.zon b/build.zig.zon index 14ef97a..66d2407 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -49,6 +49,11 @@ .imports = .{.zapi}, .link_libc = true, }, + .example_addon_isolation = .{ + .root_source_file = "examples/addon_isolation/mod.zig", + .imports = .{.zapi}, + .link_libc = true, + }, .example_register_decls = .{ .root_source_file = "examples/register_decls/mod.zig", .imports = .{.zapi}, @@ -74,6 +79,12 @@ .linker_allow_shlib_undefined = true, .dest_sub_path = "example_js_dsl.node", }, + .example_addon_isolation = .{ + .root_module = .example_addon_isolation, + .linkage = .dynamic, + .linker_allow_shlib_undefined = true, + .dest_sub_path = "example_addon_isolation.node", + }, .example_register_decls = .{ .root_module = .example_register_decls, .linkage = .dynamic, @@ -99,5 +110,9 @@ .root_module = .example_js_dsl, .linker_allow_shlib_undefined = true, }, + .example_addon_isolation = .{ + .root_module = .example_addon_isolation, + .linker_allow_shlib_undefined = true, + }, }, } diff --git a/examples/addon_isolation/mod.test.ts b/examples/addon_isolation/mod.test.ts new file mode 100644 index 0000000..47e1774 --- /dev/null +++ b/examples/addon_isolation/mod.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const primary = require("../../zig-out/lib/example_js_dsl.node"); +const secondary = require("../../zig-out/lib/example_addon_isolation.node"); + +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); + + expect(() => primary.incrementCounter(secondaryCounter)).toThrow(); + expect(() => secondary.incrementCounter(primaryCounter)).toThrow(); + }); +}); diff --git a/examples/addon_isolation/mod.zig b/examples/addon_isolation/mod.zig new file mode 100644 index 0000000..ca03408 --- /dev/null +++ b/examples/addon_isolation/mod.zig @@ -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(), .{ + .type_tag = "bf260670-570b-48a8-9343-8dc4dcbce5aa", + }); +} diff --git a/examples/js_dsl/mod.zig b/examples/js_dsl/mod.zig index 0d13d9c..e020954 100644 --- a/examples/js_dsl/mod.zig +++ b/examples/js_dsl/mod.zig @@ -665,6 +665,7 @@ pub const BlsPublicKey = struct { comptime { js.exportModule(@This(), .{ + .type_tag = "8ef3566d-9606-4133-8ca6-fb50bb2b4d2e", .init = struct { fn f(refcount: u32) !void { const count = module_init_count.fetchAdd(1, .monotonic); diff --git a/src/js/class_meta.zig b/src/js/class_meta.zig index 0236095..fbda1d9 100644 --- a/src/js/class_meta.zig +++ b/src/js/class_meta.zig @@ -48,6 +48,8 @@ fn ClassMeta(comptime Options: type) type { /// name is used. Can be an `?[]const u8` or `[]const u8`. /// - `.properties: struct`: A struct literal where each field corresponds to a /// JS property. The value for each field must be a `js.prop(...)` call. +/// - `.type_tag: []const u8`: Optional stable salt overriding the addon's +/// default type-tag salt for this class. A UUID is recommended. /// /// Compile-time errors will be raised if `opts` contains unsupported fields or /// if `properties` are not correctly defined using `js.prop`. @@ -152,6 +154,13 @@ pub fn getClassName(comptime T: type, comptime default_name: []const u8) []const } } +/// Returns the class-specific type-tag salt, if one was configured. +pub fn getTypeTagSalt(comptime T: type) ?[]const u8 { + if (!hasClassMeta(T)) return null; + if (!@hasField(@TypeOf(T.js_meta.options), "type_tag")) return null; + return coerceStringLike(T.js_meta.options.type_tag); +} + /// Determines the kind of property specification for a given compile-time value. /// /// This internal comptime function classifies whether a value is a valid @@ -183,8 +192,11 @@ fn validateClassOptions(comptime Opts: type, comptime opts: Opts) void { } inline for (@typeInfo(Opts).@"struct".fields) |field_info| { - if (!std.mem.eql(u8, field_info.name, "name") and !std.mem.eql(u8, field_info.name, "properties")) { - @compileError("js.class only supports .name and .properties"); + const supported = std.mem.eql(u8, field_info.name, "name") or + std.mem.eql(u8, field_info.name, "properties") or + std.mem.eql(u8, field_info.name, "type_tag"); + if (!supported) { + @compileError("js.class only supports .name, .properties, and .type_tag"); } } @@ -201,6 +213,14 @@ fn validateClassOptions(comptime Opts: type, comptime opts: Opts) void { if (@hasField(Opts, "properties")) { validateProperties(@TypeOf(opts.properties), opts.properties); } + + if (@hasField(Opts, "type_tag")) { + const SaltType = @TypeOf(opts.type_tag); + _ = comptime coerceStringLikeType(SaltType, "js.class .type_tag"); + if (coerceStringLike(opts.type_tag).len == 0) { + @compileError("js.class .type_tag must not be empty"); + } + } } fn validateProperties(comptime Props: type, comptime props: Props) void { @@ -259,6 +279,13 @@ test "js.class accepts empty options" { try std.testing.expect(isClassMetaValue(meta)); } +test "js.class accepts a type tag salt" { + const Tagged = struct { + pub const js_meta = class(.{ .type_tag = "addon-uuid" }); + }; + try std.testing.expectEqualStrings("addon-uuid", getTypeTagSalt(Tagged).?); +} + test "js.prop accepts derived getter and setter" { const spec = prop(.{ .get = true, .set = true }); try std.testing.expect(spec.get == .derived); diff --git a/src/js/class_runtime.zig b/src/js/class_runtime.zig index 5549f62..8200761 100644 --- a/src/js/class_runtime.zig +++ b/src/js/class_runtime.zig @@ -1,12 +1,13 @@ const std = @import("std"); const napi = @import("../napi.zig"); const context = @import("context.zig"); +const class_meta = @import("class_meta.zig"); -pub fn typeTag(comptime T: type) napi.c.napi_type_tag { - return .{ - .lower = fnv1a64Parts(.{ "zapi:dsl:type-tag:lower:", @typeName(T) }), - .upper = fnv1a64Parts(.{ "zapi:dsl:type-tag:upper:", @typeName(T) }), - }; +/// Returns a stable tag derived from the addon's salt and the Zig class name. +/// A class-level `.type_tag` salt replaces the module salt when present. +pub fn typeTag(comptime T: type, comptime module_salt: []const u8) napi.c.napi_type_tag { + const salt = comptime class_meta.getTypeTagSalt(T) orelse module_salt; + return typeTagFromParts(.{ salt, "::", @typeName(T) }); } /// Tags `object`, then wraps `native_object` into it with a finalizer. @@ -14,30 +15,37 @@ pub fn typeTag(comptime T: type) napi.c.napi_type_tag { /// The wrap is done last so it is the only fallible step that transfers /// ownership: once it succeeds N-API's finalizer owns `native_object`, and any /// earlier failure leaves nothing wrapped, so the caller still owns and frees it. -pub fn wrapTaggedObject(comptime T: type, env: napi.Env, object: napi.Value, native_object: *T) !void { - const tag = typeTag(T); +pub fn wrapTaggedObject( + comptime T: type, + comptime module_salt: []const u8, + env: napi.Env, + object: napi.Value, + native_object: *T, +) !void { + const tag = typeTag(T, module_salt); if (!(try env.checkObjectTypeTag(object, tag))) { try env.typeTagObject(object, tag); } try env.wrap(object, T, native_object, defaultFinalize(T), null, null); } -/// Generates a deterministic 64-bit FNV-1a hash at compile-time. -/// This is used to create stable `napi_type_tag` values for DSL classes -/// based on their type names. FNV-1a is chosen for its simplicity, speed, -/// and suitability for non-cryptographic unique-ish identification. -/// -/// The `parts` argument allows concatenating multiple compile-time strings -/// (e.g., prefixes and type names) into a single input for hashing. -fn fnv1a64Parts(comptime parts: anytype) u64 { - var hash: u64 = 0xcbf29ce484222325; +/// Builds a stable 128-bit Node-API type tag from a content identity using FNV-1a. +fn typeTagFromParts(comptime parts: anytype) napi.c.napi_type_tag { + var hash: u128 = 0x6c62272e07bb0142_62b821756295c58d; inline for (parts) |part| { inline for (part) |byte| { hash ^= byte; - hash *%= 0x100000001b3; + hash *%= 0x0000000001000000_000000000000013B; } } - return hash; + return .{ + .lower = @truncate(hash), + .upper = @truncate(hash >> 64), + }; +} + +fn typeTagFromIdent(comptime ident: []const u8) napi.c.napi_type_tag { + return typeTagFromParts(.{ident}); } pub fn destroyNativeObject(comptime T: type, obj: *T) void { @@ -113,7 +121,13 @@ pub fn consumeMaterialization(comptime T: type, env: napi.Env, this_arg: napi.c. return true; } -pub fn materializeClassInstance(comptime T: type, env: napi.Env, instance: T, preferred_ctor: ?napi.Value) !napi.Value { +pub fn materializeClassInstance( + comptime T: type, + comptime module_salt: []const u8, + env: napi.Env, + instance: T, + preferred_ctor: ?napi.Value, +) !napi.Value { const ctor = preferred_ctor orelse try getConstructor(T, env); const obj_ptr = try context.allocator().create(T); @@ -147,7 +161,7 @@ pub fn materializeClassInstance(comptime T: type, env: napi.Env, instance: T, pr // `napi_new_instance`; otherwise a subclass returned a replacement object. if (!(try expected_instance.strictEquals(js_instance))) return error.InvalidMaterializationConstructor; - try wrapTaggedObject(T, env, js_instance, obj_ptr); + try wrapTaggedObject(T, module_salt, env, js_instance, obj_ptr); return js_instance; } @@ -227,3 +241,48 @@ fn markers(comptime T: type) type { fn internalCtorMarkerPtr(comptime T: type) *u8 { return &markers(T).ctor_marker; } + +test "distinct type tag identities produce distinct tags" { + try std.testing.expect( + !std.meta.eql( + typeTagFromIdent("addon@example::mod.Foo"), + typeTagFromIdent("addon@example::mod.Bar"), + ), + ); +} + +test "module salts distinguish the same Zig class" { + const Tagged = struct { + pub const js_meta = class_meta.class(.{}); + }; + try std.testing.expect( + !std.meta.eql(typeTag(Tagged, "addon-a"), typeTag(Tagged, "addon-b")), + ); +} + +test "class salt overrides the module salt" { + const Tagged = struct { + pub const js_meta = class_meta.class(.{ .type_tag = "class-uuid" }); + }; + try std.testing.expectEqual( + typeTag(Tagged, "addon-a"), + typeTag(Tagged, "addon-b"), + ); +} + +test "type tag identity hashing is deterministic" { + try std.testing.expectEqual( + typeTagFromIdent("addon@example::mod.Foo"), + typeTagFromIdent("addon@example::mod.Foo"), + ); +} + +test "type tag identity hashing matches the FNV-1a known vector" { + try std.testing.expectEqual( + napi.c.napi_type_tag{ + .lower = 9205288767444028904, + .upper = 12488879969338687231, + }, + typeTagFromIdent("napi@1.0.0::x::Foo"), + ); +} diff --git a/src/js/export_module.zig b/src/js/export_module.zig index 48e6caa..994b408 100644 --- a/src/js/export_module.zig +++ b/src/js/export_module.zig @@ -15,7 +15,11 @@ const class_runtime = @import("class_runtime.zig"); /// Node.js. It inspects the `Module`'s `pub` declarations and automatically /// creates corresponding JavaScript functions, classes, and sub-namespaces. /// -/// Optional `options` can be provided to customize module lifecycle hooks: +/// `options.type_tag` is a stable, addon-unique salt used to derive every DSL +/// class's 128-bit Node-API type tag. A UUID is recommended. Individual classes +/// can override it with `js.class(.{ .type_tag = "..." })`. +/// +/// Additional `options` customize module lifecycle hooks: /// /// - `.init = fn (refcount: u32) !void`: Called when the module is initialized /// in a new N-API environment. `refcount` is the number of active environments @@ -42,26 +46,31 @@ const class_runtime = @import("class_runtime.zig"); /// Usage Examples: /// ```zig /// comptime { -/// // Basic export of all `pub` functions, classes, and sub-namespaces -/// js.exportModule(@This()); +/// // Basic export of all `pub` functions, classes, and sub-namespaces. +/// js.exportModule(@This(), .{ +/// .type_tag = "6f9619ff-8b86-d011-b42d-00cf4fc964ff", +/// }); /// } /// /// comptime { -/// // Export with custom initialization and cleanup hooks +/// // Export with custom initialization and cleanup hooks. /// js.exportModule(@This(), .{ +/// .type_tag = "6f9619ff-8b86-d011-b42d-00cf4fc964ff", /// .init = myInitFunction, /// .cleanup = myCleanupFunction, /// }); /// } /// /// comptime { -/// // Export with a manual registration function +/// // Export with a manual registration function. /// js.exportModule(@This(), .{ +/// .type_tag = "6f9619ff-8b86-d011-b42d-00cf4fc964ff", /// .register = myCustomRegisterFunction, /// }); /// } /// ``` pub fn exportModule(comptime Module: type, comptime options: anytype) void { + const type_tag_salt = comptime moduleTypeTagSalt(options); const has_init = @hasField(@TypeOf(options), "init"); const has_cleanup = @hasField(@TypeOf(options), "cleanup"); const has_register = @hasField(@TypeOf(options), "register"); @@ -110,7 +119,7 @@ pub fn exportModule(comptime Module: type, comptime options: anytype) void { State.Lifecycle.release(); }; - _ = try registerDecls(Module, env, module, 0); + _ = try registerDecls(Module, type_tag_salt, env, module, 0); if (has_register) { try options.register(env, module); @@ -129,7 +138,13 @@ pub fn exportModule(comptime Module: type, comptime options: anytype) void { } /// Iterates module declarations and registers DSL functions and js_meta classes. -fn registerDecls(comptime Module: type, env: napi.Env, module: napi.Value, comptime depth: usize) !bool { +fn registerDecls( + comptime Module: type, + comptime type_tag_salt: []const u8, + env: napi.Env, + module: napi.Value, + comptime depth: usize, +) !bool { const decls = @typeInfo(Module).@"struct".decls; var exported_any = false; @@ -151,7 +166,7 @@ fn registerDecls(comptime Module: type, env: napi.Env, module: napi.Value, compt if (!is_dsl_fn) @compileError("zapi: cannot export non-DSL `pub fn " ++ @typeName(Module) ++ "." ++ decl.name ++ "` — use DSL params (e.g. `js.Number`), drop `pub`, or pass `.register` to `exportModule` to export it manually"); // DSL function — wrap and register - const cb = wrap_function.wrapFunction(field); + const cb = wrap_function.wrapFunction(field, type_tag_salt); const name: [:0]const u8 = decl.name ++ ""; var js_fn: napi.c.napi_value = null; @@ -171,7 +186,7 @@ fn registerDecls(comptime Module: type, env: napi.Env, module: napi.Value, compt const InnerType = field; if (@typeInfo(InnerType) == .@"struct") { if (comptime class_meta.hasClassMeta(InnerType)) { - const wrapped = wrap_class.wrapClass(InnerType); + const wrapped = wrap_class.wrapClass(InnerType, type_tag_salt); const props = wrapped.getPropertyDescriptors(); const class_name = comptime class_meta.getClassName(InnerType, decl.name); const name: [:0]const u8 = class_name ++ ""; @@ -195,7 +210,7 @@ fn registerDecls(comptime Module: type, env: napi.Env, module: napi.Value, compt exported_any = true; } else { const ns_obj = try env.createObject(); - if (try registerDecls(InnerType, env, ns_obj, depth + 1)) { + if (try registerDecls(InnerType, type_tag_salt, env, ns_obj, depth + 1)) { const name: [:0]const u8 = decl.name ++ ""; try module.setNamedProperty(name, ns_obj); exported_any = true; @@ -207,6 +222,17 @@ fn registerDecls(comptime Module: type, env: napi.Env, module: napi.Value, compt return exported_any; } +fn moduleTypeTagSalt(comptime options: anytype) []const u8 { + if (!@hasField(@TypeOf(options), "type_tag")) { + @compileError("js.exportModule requires .type_tag with a stable addon-unique salt"); + } + const salt: []const u8 = options.type_tag; + if (salt.len == 0) { + @compileError("js.exportModule .type_tag must not be empty"); + } + return salt; +} + test "exportModule comptime smoke test" { try std.testing.expect(true); } diff --git a/src/js/wrap_class.zig b/src/js/wrap_class.zig index 4d61239..2e836b0 100644 --- a/src/js/wrap_class.zig +++ b/src/js/wrap_class.zig @@ -4,8 +4,6 @@ const context = @import("context.zig"); const class_meta = @import("class_meta.zig"); const class_runtime = @import("class_runtime.zig"); const wrap_function = @import("wrap_function.zig"); -const convertArg = wrap_function.convertArg; -const callAndConvert = wrap_function.callAndConvert; /// Given a class type `T` (a struct with `pub const js_meta = js.class(...)`), returns a type with comptime-generated /// N-API constructor, finalizer, property descriptors, and method wrappers. @@ -23,7 +21,7 @@ const callAndConvert = wrap_function.callAndConvert; /// /// This result is typically passed to `js.exportModule` to register the class /// with Node-API. -pub fn wrapClass(comptime T: type) type { +pub fn wrapClass(comptime T: type, comptime type_tag_salt: []const u8) type { if (!class_meta.isClassType(T)) { @compileError("wrapClass: " ++ @typeName(T) ++ " must declare `pub const js_meta = js.class(...)`"); } @@ -395,7 +393,14 @@ pub fn wrapClass(comptime T: type) type { var args: std.meta.ArgsTuple(InitFnType) = undefined; inline for (0..init_argc) |i| { const ParamType = init_params[i].type.?; - args[i] = wrap_function.convertArgWithOptional(ParamType, raw_args[i], raw_env, i, actual_argc) catch { + args[i] = wrap_function.convertArgWithOptional( + ParamType, + type_tag_salt, + raw_args[i], + raw_env, + i, + actual_argc, + ) catch { wrap_function.throwArgTypeError(e, ParamType, i); return null; }; @@ -415,7 +420,7 @@ pub fn wrapClass(comptime T: type) type { obj_ptr.* = init_result; const this_val = napi.Value{ .env = raw_env, .value = this_arg }; - class_runtime.wrapTaggedObject(T, e, this_val, obj_ptr) catch { + class_runtime.wrapTaggedObject(T, type_tag_salt, e, this_val, obj_ptr) catch { class_runtime.destroyNativeObject(T, obj_ptr); e.throwError("", "Failed to wrap native object") catch {}; return null; @@ -496,7 +501,11 @@ pub fn wrapClass(comptime T: type) type { } const this_val = napi.Value{ .env = raw_env, .value = this_arg }; - const self_ptr = e.unwrapChecked(Class, this_val, class_runtime.typeTag(Class)) catch { + const self_ptr = e.unwrapChecked( + Class, + this_val, + class_runtime.typeTag(Class, type_tag_salt), + ) catch { e.throwTypeError("", "Invalid class receiver") catch {}; return null; }; @@ -509,7 +518,14 @@ pub fn wrapClass(comptime T: type) type { inline for (0..js_argc) |i| { const ParamType = method_params[i + 1].type.?; - args[i + 1] = wrap_function.convertArgWithOptional(ParamType, raw_args[i], raw_env, i, actual_argc) catch { + args[i + 1] = wrap_function.convertArgWithOptional( + ParamType, + type_tag_salt, + raw_args[i], + raw_env, + i, + actual_argc, + ) catch { wrap_function.throwArgTypeError(e, ParamType, i); return null; }; @@ -519,7 +535,13 @@ pub fn wrapClass(comptime T: type) type { (this_val.getNamedProperty("constructor") catch null) else null; - return wrap_function.callAndConvertWithCtor(method, args, raw_env, preferred_ctor); + return wrap_function.callAndConvertWithCtor( + method, + type_tag_salt, + args, + raw_env, + preferred_ctor, + ); } }; return method_cb.callback; @@ -551,7 +573,11 @@ pub fn wrapClass(comptime T: type) type { }; const this_val = napi.Value{ .env = raw_env, .value = this_arg }; - const self_ptr = e.unwrapChecked(Class, this_val, class_runtime.typeTag(Class)) catch { + const self_ptr = e.unwrapChecked( + Class, + this_val, + class_runtime.typeTag(Class, type_tag_salt), + ) catch { e.throwTypeError("", "Invalid class receiver") catch {}; return null; }; @@ -566,7 +592,13 @@ pub fn wrapClass(comptime T: type) type { (this_val.getNamedProperty("constructor") catch null) else null; - return wrap_function.callAndConvertWithCtor(getter_fn, args, raw_env, preferred_ctor); + return wrap_function.callAndConvertWithCtor( + getter_fn, + type_tag_salt, + args, + raw_env, + preferred_ctor, + ); } }; return getter_cb.callback; @@ -610,7 +642,14 @@ pub fn wrapClass(comptime T: type) type { var args: std.meta.ArgsTuple(MethodFnType) = undefined; inline for (0..method_argc) |i| { const ParamType = method_params[i].type.?; - args[i] = wrap_function.convertArgWithOptional(ParamType, raw_args[i], raw_env, i, actual_argc) catch { + args[i] = wrap_function.convertArgWithOptional( + ParamType, + type_tag_salt, + raw_args[i], + raw_env, + i, + actual_argc, + ) catch { wrap_function.throwArgTypeError(e, ParamType, i); return null; }; @@ -620,7 +659,13 @@ pub fn wrapClass(comptime T: type) type { napi.Value{ .env = raw_env, .value = this_arg } else null; - return wrap_function.callAndConvertWithCtor(method, args, raw_env, preferred_ctor); + return wrap_function.callAndConvertWithCtor( + method, + type_tag_salt, + args, + raw_env, + preferred_ctor, + ); } }; return static_cb.callback; @@ -654,7 +699,11 @@ pub fn wrapClass(comptime T: type) type { }; const this_val = napi.Value{ .env = raw_env, .value = this_arg }; - const self_ptr = e.unwrapChecked(Class, this_val, class_runtime.typeTag(Class)) catch { + const self_ptr = e.unwrapChecked( + Class, + this_val, + class_runtime.typeTag(Class, type_tag_salt), + ) catch { e.throwTypeError("", "Invalid class receiver") catch {}; return null; }; @@ -662,7 +711,12 @@ pub fn wrapClass(comptime T: type) type { const prev_this = context.setThis(this_val); defer context.restoreThis(prev_this); - const value_arg = convertArg(ValueParamType, raw_args[0], raw_env) catch { + const value_arg = wrap_function.convertArg( + ValueParamType, + type_tag_salt, + raw_args[0], + raw_env, + ) catch { wrap_function.throwArgTypeError(e, ValueParamType, 0); return null; }; diff --git a/src/js/wrap_function.zig b/src/js/wrap_function.zig index fcaf689..bbe25cc 100644 --- a/src/js/wrap_function.zig +++ b/src/js/wrap_function.zig @@ -150,7 +150,12 @@ fn missingClassMetaHint(comptime T: type) ?type { /// pointers for class types. /// Returns `error.TypeMismatch` if type validation fails or an N-API class /// cannot be unwrapped. Compile-time error for unsupported `T` type. -pub fn convertArg(comptime T: type, raw: napi.c.napi_value, env: napi.c.napi_env) !T { +pub fn convertArg( + comptime T: type, + comptime type_tag_salt: []const u8, + raw: napi.c.napi_value, + env: napi.c.napi_env, +) !T { const value = napi.Value{ .env = env, .value = raw }; if (T == napi.Value) { @@ -162,14 +167,22 @@ pub fn convertArg(comptime T: type, raw: napi.c.napi_value, env: napi.c.napi_env } if (comptime class_meta.isClassType(T)) { const e = napi.Env{ .env = env }; - const ptr = e.unwrapChecked(T, value, class_runtime.typeTag(T)) catch return error.TypeMismatch; + const ptr = e.unwrapChecked( + T, + value, + class_runtime.typeTag(T, type_tag_salt), + ) catch return error.TypeMismatch; return ptr.*; } switch (@typeInfo(T)) { .pointer => |ptr| { if (comptime ptr.size == .one and class_meta.isClassType(ptr.child)) { const e = napi.Env{ .env = env }; - return e.unwrapChecked(ptr.child, value, class_runtime.typeTag(ptr.child)) catch error.TypeMismatch; + return e.unwrapChecked( + ptr.child, + value, + class_runtime.typeTag(ptr.child, type_tag_salt), + ) catch error.TypeMismatch; } }, else => {}, @@ -188,6 +201,7 @@ pub fn convertArg(comptime T: type, raw: napi.c.napi_value, env: napi.c.napi_env /// Otherwise, it performs conversion using `convertArg`. pub fn convertArgWithOptional( comptime T: type, + comptime type_tag_salt: []const u8, raw: napi.c.napi_value, env: napi.c.napi_env, param_index: usize, @@ -198,9 +212,9 @@ pub fn convertArgWithOptional( const raw_value = napi.Value{ .env = env, .value = raw }; if ((try raw_value.typeof()) == .undefined) return null; const Inner = @typeInfo(T).optional.child; - return try convertArg(Inner, raw, env); + return try convertArg(Inner, type_tag_salt, raw, env); } - return try convertArg(T, raw, env); + return try convertArg(T, type_tag_salt, raw, env); } /// Converts a Zig value into a raw `napi.c.napi_value`, handling various DSL @@ -213,7 +227,13 @@ pub fn convertArgWithOptional( /// materialization. /// Panics if N-API operations fail for `void` or class materialization. /// Compile-time error for unsupported `T` type. -pub fn convertReturnWithCtor(comptime T: type, value: T, env: napi.c.napi_env, preferred_ctor: ?napi.Value) napi.c.napi_value { +pub fn convertReturnWithCtor( + comptime T: type, + comptime type_tag_salt: []const u8, + value: T, + env: napi.c.napi_env, + preferred_ctor: ?napi.Value, +) napi.c.napi_value { if (T == void) { var result: napi.c.napi_value = null; napi.status.check(napi.c.napi_get_undefined(env, &result)) catch return null; @@ -227,7 +247,13 @@ pub fn convertReturnWithCtor(comptime T: type, value: T, env: napi.c.napi_env, p } if (comptime class_meta.isClassType(T)) { const e = napi.Env{ .env = env }; - const instance = class_runtime.materializeClassInstance(T, e, value, preferred_ctor) catch { + const instance = class_runtime.materializeClassInstance( + T, + type_tag_salt, + e, + value, + preferred_ctor, + ) catch { e.throwError("", "Failed to materialize returned class instance") catch {}; return null; }; @@ -243,8 +269,13 @@ pub fn convertReturnWithCtor(comptime T: type, value: T, env: napi.c.napi_env, p /// /// This is a convenience wrapper around `convertReturnWithCtor` that does not /// provide a preferred constructor for class materialization. -pub fn convertReturn(comptime T: type, value: T, env: napi.c.napi_env) napi.c.napi_value { - return convertReturnWithCtor(T, value, env, null); +pub fn convertReturn( + comptime T: type, + comptime type_tag_salt: []const u8, + value: T, + env: napi.c.napi_env, +) napi.c.napi_value { + return convertReturnWithCtor(T, type_tag_salt, value, env, null); } /// Calls the user-provided Zig function with the given arguments and converts @@ -254,7 +285,13 @@ pub fn convertReturn(comptime T: type, value: T, env: napi.c.napi_env) napi.c.na /// and combinations (`!?T`). If the function returns an error, it throws a /// JavaScript `Error`. If it returns `null` or `undefined`, it returns JS `undefined`. /// A `preferred_ctor` can be provided for class materialization in return types. -pub fn callAndConvertWithCtor(comptime func: anytype, args: std.meta.ArgsTuple(@TypeOf(func)), env: napi.c.napi_env, preferred_ctor: ?napi.Value) napi.c.napi_value { +pub fn callAndConvertWithCtor( + comptime func: anytype, + comptime type_tag_salt: []const u8, + args: std.meta.ArgsTuple(@TypeOf(func)), + env: napi.c.napi_env, + preferred_ctor: ?napi.Value, +) napi.c.napi_value { const ReturnType = @typeInfo(@TypeOf(func)).@"fn".return_type.?; const ret_info = @typeInfo(ReturnType); @@ -273,7 +310,13 @@ pub fn callAndConvertWithCtor(comptime func: anytype, args: std.meta.ArgsTuple(@ // !?T — optional inside error union if (payload_info == .optional) { if (result) |val| { - return convertReturnWithCtor(payload_info.optional.child, val, env, preferred_ctor); + return convertReturnWithCtor( + payload_info.optional.child, + type_tag_salt, + val, + env, + preferred_ctor, + ); } else { var undef: napi.c.napi_value = null; napi.status.check(napi.c.napi_get_undefined(env, &undef)) catch return null; @@ -282,14 +325,20 @@ pub fn callAndConvertWithCtor(comptime func: anytype, args: std.meta.ArgsTuple(@ } // !T — plain error union - return convertReturnWithCtor(Payload, result, env, preferred_ctor); + return convertReturnWithCtor(Payload, type_tag_salt, result, env, preferred_ctor); } // ?T — optional (no error) if (ret_info == .optional) { const result = @call(.auto, func, args); if (result) |val| { - return convertReturnWithCtor(ret_info.optional.child, val, env, preferred_ctor); + return convertReturnWithCtor( + ret_info.optional.child, + type_tag_salt, + val, + env, + preferred_ctor, + ); } else { var undef: napi.c.napi_value = null; napi.status.check(napi.c.napi_get_undefined(env, &undef)) catch return null; @@ -299,7 +348,7 @@ pub fn callAndConvertWithCtor(comptime func: anytype, args: std.meta.ArgsTuple(@ // Plain T const result = @call(.auto, func, args); - return convertReturnWithCtor(ReturnType, result, env, preferred_ctor); + return convertReturnWithCtor(ReturnType, type_tag_salt, result, env, preferred_ctor); } /// Calls the user-provided Zig function with the given arguments and converts @@ -307,8 +356,13 @@ pub fn callAndConvertWithCtor(comptime func: anytype, args: std.meta.ArgsTuple(@ /// /// This is a convenience wrapper around `callAndConvertWithCtor` that does not /// provide a preferred constructor for class materialization. -pub fn callAndConvert(comptime func: anytype, args: std.meta.ArgsTuple(@TypeOf(func)), env: napi.c.napi_env) napi.c.napi_value { - return callAndConvertWithCtor(func, args, env, null); +pub fn callAndConvert( + comptime func: anytype, + comptime type_tag_salt: []const u8, + args: std.meta.ArgsTuple(@TypeOf(func)), + env: napi.c.napi_env, +) napi.c.napi_value { + return callAndConvertWithCtor(func, type_tag_salt, args, env, null); } /// Generates a C-ABI `napi_callback` that wraps a ZAPI DSL-typed Zig function. @@ -323,7 +377,7 @@ pub fn callAndConvert(comptime func: anytype, args: std.meta.ArgsTuple(@TypeOf(f /// 5. Sets up thread-local `thisArg` context if it's an instance method/getter/setter. /// /// Panics if N-API operations fail during callback info retrieval or argument conversion. -pub fn wrapFunction(comptime func: anytype) napi.c.napi_callback { +pub fn wrapFunction(comptime func: anytype, comptime type_tag_salt: []const u8) napi.c.napi_callback { const FnType = @TypeOf(func); const fn_info = @typeInfo(FnType).@"fn"; const params = fn_info.params; @@ -359,13 +413,20 @@ pub fn wrapFunction(comptime func: anytype) napi.c.napi_callback { var args: std.meta.ArgsTuple(FnType) = undefined; inline for (0..argc) |i| { const ParamType = params[i].type.?; - args[i] = convertArgWithOptional(ParamType, raw_args[i], raw_env, i, actual_argc) catch { + args[i] = convertArgWithOptional( + ParamType, + type_tag_salt, + raw_args[i], + raw_env, + i, + actual_argc, + ) catch { throwArgTypeError(e, ParamType, i); return null; }; } - return callAndConvert(func, args, raw_env); + return callAndConvert(func, type_tag_salt, args, raw_env); } }; return wrapper.callback; From 925c5349cbd8a0aee5608c1836a414ae99ba856b Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Tue, 28 Jul 2026 13:20:36 +0800 Subject: [PATCH 2/6] fix!: derive class tags from addon build identity --- README.md | 46 ++++++++- build.zig | 39 +++++++- build.zig.zon | 9 ++ examples/addon_isolation/mod.test.ts | 29 +++++- examples/addon_isolation/mod.zig | 2 +- examples/function_only_dsl/mod.zig | 9 ++ examples/js_dsl/mod.zig | 2 +- package.json | 2 +- src/js.zig | 1 + src/js/class_meta.zig | 31 +----- src/js/class_runtime.zig | 142 +++++++++++++++++++++------ src/js/export_module.zig | 42 ++++---- src/js/wrap_class.zig | 27 ++--- src/js/wrap_function.zig | 46 +++++---- 14 files changed, 300 insertions(+), 127 deletions(-) create mode 100644 examples/function_only_dsl/mod.zig diff --git a/README.md b/README.md index 6edd66a..7ab36cb 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,30 @@ Add the Zig dependency to your `build.zig.zon`: }, ``` +After creating the shared-library compile step for the final `.node` artifact, +attach its package and artifact identity in the addon's `build.zig`: + +```zig +const std = @import("std"); +const zapi_build = @import("zapi"); + +fn configureAddonIdentity( + b: *std.Build, + addon: *std.Build.Step.Compile, +) void { + const manifest = @import("build.zig.zon"); + zapi_build.addAddonIdentity(b, addon, manifest); +} +``` + +Each logically distinct addon needs a unique compile-step name and its own root +module. Configure each root module once. Aliases or installed copies of one +compiled addon keep the identity embedded in that artifact. + +Zig 0.16 does not expose a `.path` dependency's `build.zig` as an import. Keep +the URL/hash dependency declaration above and use +`zig build --fork=/path/to/zapi` when developing against a local checkout. + --- ## Zig Library — Quick Start @@ -62,7 +86,7 @@ pub const Counter = struct { comptime { js.exportModule(@This(), .{ - .type_tag = "6f9619ff-8b86-d011-b42d-00cf4fc964ff", + .identity = @import("zapi_addon_identity"), }); } ``` @@ -77,7 +101,20 @@ 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. `type_tag` is a stable addon-unique salt used to derive every class's 128-bit Node-API type tag. Generate it once (a UUID is recommended) and keep it unchanged across builds so class identities remain stable across addon reloads. +`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. 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`. --- @@ -261,7 +298,6 @@ JS: `cfg.volume = 80; cfg.label; // "default"` **Rules:** - `pub const js_meta = js.class(.{})` marks a struct as a JS class -- `.type_tag = "..."` optionally replaces the module's type-tag salt for one class; a UUID is recommended - `.properties = .{ .name = js.prop(.{ .get = true, .set = false }) }` registers a readonly getter backed by `pub fn name(...)` - `.properties = .{ .name = js.prop(.{ .get = true, .set = true }) }` registers getter/setter methods using `name` and `setName` - `.properties = .{ .name = js.prop(.{ .get = "customGetter", .set = false }) }` registers a getter backed by a specifically named method @@ -326,7 +362,7 @@ pub const crypto = @import("crypto.zig"); // → exports.crypto.PublicKey, etc. comptime { js.exportModule(@This(), .{ - .type_tag = "6f9619ff-8b86-d011-b42d-00cf4fc964ff", + .identity = @import("zapi_addon_identity"), }); } ``` @@ -342,7 +378,7 @@ Namespaces nest arbitrarily — a sub-module with more `pub const` imports creat ```zig comptime { js.exportModule(@This(), .{ - .type_tag = "6f9619ff-8b86-d011-b42d-00cf4fc964ff", + .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) }); diff --git a/build.zig b/build.zig index 7965398..76f5a43 100644 --- a/build.zig +++ b/build.zig @@ -3,5 +3,42 @@ 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"); + const result = try zbuild.configureBuild(b, manifest, .{}); + + configureExampleAddonIdentities(b, manifest, result); +} + +/// 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); +} + +fn configureExampleAddonIdentities( + b: *std.Build, + comptime manifest: anytype, + result: zbuild.BuildResult, +) void { + addAddonIdentity(b, result.library("example_js_dsl").?, manifest); + addAddonIdentity(b, result.library("example_addon_isolation").?, manifest); } diff --git a/build.zig.zon b/build.zig.zon index 66d2407..2bf45e8 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -59,6 +59,11 @@ .imports = .{.zapi}, .link_libc = true, }, + .function_only_dsl_compile = .{ + .root_source_file = "examples/function_only_dsl/mod.zig", + .imports = .{.zapi}, + .link_libc = true, + }, }, .libraries = .{ .example_hello_world = .{ @@ -114,5 +119,9 @@ .root_module = .example_addon_isolation, .linker_allow_shlib_undefined = true, }, + .function_only_dsl_compile = .{ + .root_module = .function_only_dsl_compile, + .linker_allow_shlib_undefined = true, + }, }, } diff --git a/examples/addon_isolation/mod.test.ts b/examples/addon_isolation/mod.test.ts index 47e1774..4bd172a 100644 --- a/examples/addon_isolation/mod.test.ts +++ b/examples/addon_isolation/mod.test.ts @@ -1,16 +1,37 @@ -import { describe, expect, it } from "vitest"; +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 primary = require("../../zig-out/lib/example_js_dsl.node"); +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); - expect(() => primary.incrementCounter(secondaryCounter)).toThrow(); - expect(() => secondary.incrementCounter(primaryCounter)).toThrow(); + 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); }); }); diff --git a/examples/addon_isolation/mod.zig b/examples/addon_isolation/mod.zig index ca03408..13033a7 100644 --- a/examples/addon_isolation/mod.zig +++ b/examples/addon_isolation/mod.zig @@ -22,6 +22,6 @@ pub fn incrementCounter(counter: *Counter) void { comptime { js.exportModule(@This(), .{ - .type_tag = "bf260670-570b-48a8-9343-8dc4dcbce5aa", + .identity = @import("zapi_addon_identity"), }); } diff --git a/examples/function_only_dsl/mod.zig b/examples/function_only_dsl/mod.zig new file mode 100644 index 0000000..af3ed4c --- /dev/null +++ b/examples/function_only_dsl/mod.zig @@ -0,0 +1,9 @@ +const js = @import("zapi").js; + +pub fn double(value: js.Number) js.Number { + return js.Number.from(value.assertI32() * 2); +} + +comptime { + js.exportModule(@This(), .{}); +} diff --git a/examples/js_dsl/mod.zig b/examples/js_dsl/mod.zig index e020954..840cf53 100644 --- a/examples/js_dsl/mod.zig +++ b/examples/js_dsl/mod.zig @@ -665,7 +665,7 @@ pub const BlsPublicKey = struct { comptime { js.exportModule(@This(), .{ - .type_tag = "8ef3566d-9606-4133-8ca6-fb50bb2b4d2e", + .identity = @import("zapi_addon_identity"), .init = struct { fn f(refcount: u32) !void { const count = module_init_count.fetchAdd(1, .monotonic); diff --git a/package.json b/package.json index 1c9988c..60d6a8d 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "build": "pnpm build:js && pnpm build:zig", "build:js": "tsc --outDir lib", "build:zig": "zig build", - "test:zig": "zig build test:zapi", + "test:zig": "zig build test:zapi test:function_only_dsl_compile", "test:js": "vitest run examples/**/*.test.ts", "test": "pnpm test:zig && pnpm test:js", "lint:zig": "zig fmt src", diff --git a/src/js.zig b/src/js.zig index 34af21a..35a3695 100644 --- a/src/js.zig +++ b/src/js.zig @@ -40,6 +40,7 @@ pub const BigUint64Array = typed_arrays.BigUint64Array; pub const Promise = @import("js/promise.zig").Promise; pub const createPromise = @import("js/promise.zig").createPromise; +pub const NoAddonIdentity = @import("js/class_runtime.zig").NoAddonIdentity; pub const wrapFunction = @import("js/wrap_function.zig").wrapFunction; pub const wrapClass = @import("js/wrap_class.zig").wrapClass; pub const exportModule = @import("js/export_module.zig").exportModule; diff --git a/src/js/class_meta.zig b/src/js/class_meta.zig index fbda1d9..0236095 100644 --- a/src/js/class_meta.zig +++ b/src/js/class_meta.zig @@ -48,8 +48,6 @@ fn ClassMeta(comptime Options: type) type { /// name is used. Can be an `?[]const u8` or `[]const u8`. /// - `.properties: struct`: A struct literal where each field corresponds to a /// JS property. The value for each field must be a `js.prop(...)` call. -/// - `.type_tag: []const u8`: Optional stable salt overriding the addon's -/// default type-tag salt for this class. A UUID is recommended. /// /// Compile-time errors will be raised if `opts` contains unsupported fields or /// if `properties` are not correctly defined using `js.prop`. @@ -154,13 +152,6 @@ pub fn getClassName(comptime T: type, comptime default_name: []const u8) []const } } -/// Returns the class-specific type-tag salt, if one was configured. -pub fn getTypeTagSalt(comptime T: type) ?[]const u8 { - if (!hasClassMeta(T)) return null; - if (!@hasField(@TypeOf(T.js_meta.options), "type_tag")) return null; - return coerceStringLike(T.js_meta.options.type_tag); -} - /// Determines the kind of property specification for a given compile-time value. /// /// This internal comptime function classifies whether a value is a valid @@ -192,11 +183,8 @@ fn validateClassOptions(comptime Opts: type, comptime opts: Opts) void { } inline for (@typeInfo(Opts).@"struct".fields) |field_info| { - const supported = std.mem.eql(u8, field_info.name, "name") or - std.mem.eql(u8, field_info.name, "properties") or - std.mem.eql(u8, field_info.name, "type_tag"); - if (!supported) { - @compileError("js.class only supports .name, .properties, and .type_tag"); + if (!std.mem.eql(u8, field_info.name, "name") and !std.mem.eql(u8, field_info.name, "properties")) { + @compileError("js.class only supports .name and .properties"); } } @@ -213,14 +201,6 @@ fn validateClassOptions(comptime Opts: type, comptime opts: Opts) void { if (@hasField(Opts, "properties")) { validateProperties(@TypeOf(opts.properties), opts.properties); } - - if (@hasField(Opts, "type_tag")) { - const SaltType = @TypeOf(opts.type_tag); - _ = comptime coerceStringLikeType(SaltType, "js.class .type_tag"); - if (coerceStringLike(opts.type_tag).len == 0) { - @compileError("js.class .type_tag must not be empty"); - } - } } fn validateProperties(comptime Props: type, comptime props: Props) void { @@ -279,13 +259,6 @@ test "js.class accepts empty options" { try std.testing.expect(isClassMetaValue(meta)); } -test "js.class accepts a type tag salt" { - const Tagged = struct { - pub const js_meta = class(.{ .type_tag = "addon-uuid" }); - }; - try std.testing.expectEqualStrings("addon-uuid", getTypeTagSalt(Tagged).?); -} - test "js.prop accepts derived getter and setter" { const spec = prop(.{ .get = true, .set = true }); try std.testing.expect(spec.get == .derived); diff --git a/src/js/class_runtime.zig b/src/js/class_runtime.zig index 8200761..21e9f33 100644 --- a/src/js/class_runtime.zig +++ b/src/js/class_runtime.zig @@ -1,13 +1,27 @@ const std = @import("std"); const napi = @import("../napi.zig"); const context = @import("context.zig"); -const class_meta = @import("class_meta.zig"); -/// Returns a stable tag derived from the addon's salt and the Zig class name. -/// A class-level `.type_tag` salt replaces the module salt when present. -pub fn typeTag(comptime T: type, comptime module_salt: []const u8) napi.c.napi_type_tag { - const salt = comptime class_meta.getTypeTagSalt(T) orelse module_salt; - return typeTagFromParts(.{ salt, "::", @typeName(T) }); +pub const NoAddonIdentity = struct {}; + +/// Returns a stable tag derived from the package, addon artifact, and Zig class. +pub fn typeTag(comptime T: type, comptime Identity: type) napi.c.napi_type_tag { + validateIdentity(Identity); + const fingerprint = comptime std.fmt.comptimePrint( + "{x}", + .{Identity.package_fingerprint}, + ); + return typeTagFromParts(.{ + Identity.package_name, + "@", + Identity.package_version, + "#", + fingerprint, + "::", + Identity.addon_name, + "::", + @typeName(T), + }); } /// Tags `object`, then wraps `native_object` into it with a finalizer. @@ -17,12 +31,12 @@ pub fn typeTag(comptime T: type, comptime module_salt: []const u8) napi.c.napi_t /// earlier failure leaves nothing wrapped, so the caller still owns and frees it. pub fn wrapTaggedObject( comptime T: type, - comptime module_salt: []const u8, + comptime Identity: type, env: napi.Env, object: napi.Value, native_object: *T, ) !void { - const tag = typeTag(T, module_salt); + const tag = typeTag(T, Identity); if (!(try env.checkObjectTypeTag(object, tag))) { try env.typeTagObject(object, tag); } @@ -44,8 +58,36 @@ fn typeTagFromParts(comptime parts: anytype) napi.c.napi_type_tag { }; } -fn typeTagFromIdent(comptime ident: []const u8) napi.c.napi_type_tag { - return typeTagFromParts(.{ident}); +fn validateIdentity(comptime Identity: type) void { + if (Identity == NoAddonIdentity) { + @compileError( + "DSL class bindings require .identity = @import(\"zapi_addon_identity\"); " ++ + "call zapi.addAddonIdentity from the addon's build.zig", + ); + } + inline for (.{ + "package_name", + "package_version", + "package_fingerprint", + "addon_name", + }) |decl_name| { + if (!@hasDecl(Identity, decl_name)) { + @compileError("zapi addon identity is missing ." ++ decl_name); + } + } + if (@TypeOf(Identity.package_name) != []const u8 or + @TypeOf(Identity.package_version) != []const u8 or + @TypeOf(Identity.addon_name) != []const u8 or + @TypeOf(Identity.package_fingerprint) != u64) + { + @compileError("zapi addon identity has invalid field types"); + } + if (Identity.package_name.len == 0 or + Identity.package_version.len == 0 or + Identity.addon_name.len == 0) + { + @compileError("zapi addon identity strings must not be empty"); + } } pub fn destroyNativeObject(comptime T: type, obj: *T) void { @@ -123,7 +165,7 @@ pub fn consumeMaterialization(comptime T: type, env: napi.Env, this_arg: napi.c. pub fn materializeClassInstance( comptime T: type, - comptime module_salt: []const u8, + comptime Identity: type, env: napi.Env, instance: T, preferred_ctor: ?napi.Value, @@ -161,7 +203,7 @@ pub fn materializeClassInstance( // `napi_new_instance`; otherwise a subclass returned a replacement object. if (!(try expected_instance.strictEquals(js_instance))) return error.InvalidMaterializationConstructor; - try wrapTaggedObject(T, module_salt, env, js_instance, obj_ptr); + try wrapTaggedObject(T, Identity, env, js_instance, obj_ptr); return js_instance; } @@ -242,38 +284,74 @@ fn internalCtorMarkerPtr(comptime T: type) *u8 { return &markers(T).ctor_marker; } -test "distinct type tag identities produce distinct tags" { +test "package versions distinguish the same Zig class" { + const Tagged = struct {}; + const VersionOne = struct { + pub const package_name: []const u8 = "addon"; + pub const package_version: []const u8 = "1.0.0"; + pub const package_fingerprint: u64 = 0x1234; + pub const addon_name: []const u8 = "native"; + }; + const VersionTwo = struct { + pub const package_name: []const u8 = "addon"; + pub const package_version: []const u8 = "2.0.0"; + pub const package_fingerprint: u64 = 0x1234; + pub const addon_name: []const u8 = "native"; + }; try std.testing.expect( - !std.meta.eql( - typeTagFromIdent("addon@example::mod.Foo"), - typeTagFromIdent("addon@example::mod.Bar"), - ), + !std.meta.eql(typeTag(Tagged, VersionOne), typeTag(Tagged, VersionTwo)), ); } -test "module salts distinguish the same Zig class" { - const Tagged = struct { - pub const js_meta = class_meta.class(.{}); +test "package fingerprints distinguish the same Zig class" { + const Tagged = struct {}; + const PackageOne = struct { + pub const package_name: []const u8 = "package"; + pub const package_version: []const u8 = "1.0.0"; + pub const package_fingerprint: u64 = 0x1111; + pub const addon_name: []const u8 = "native"; + }; + const PackageTwo = struct { + pub const package_name: []const u8 = "package"; + pub const package_version: []const u8 = "1.0.0"; + pub const package_fingerprint: u64 = 0x2222; + pub const addon_name: []const u8 = "native"; }; try std.testing.expect( - !std.meta.eql(typeTag(Tagged, "addon-a"), typeTag(Tagged, "addon-b")), + !std.meta.eql(typeTag(Tagged, PackageOne), typeTag(Tagged, PackageTwo)), ); } -test "class salt overrides the module salt" { - const Tagged = struct { - pub const js_meta = class_meta.class(.{ .type_tag = "class-uuid" }); +test "addon artifacts distinguish the same Zig class" { + const Tagged = struct {}; + const AddonOne = struct { + pub const package_name: []const u8 = "package"; + pub const package_version: []const u8 = "1.0.0"; + pub const package_fingerprint: u64 = 0x1234; + pub const addon_name: []const u8 = "addon-one"; }; - try std.testing.expectEqual( - typeTag(Tagged, "addon-a"), - typeTag(Tagged, "addon-b"), + const AddonTwo = struct { + pub const package_name: []const u8 = "package"; + pub const package_version: []const u8 = "1.0.0"; + pub const package_fingerprint: u64 = 0x1234; + pub const addon_name: []const u8 = "addon-two"; + }; + try std.testing.expect( + !std.meta.eql(typeTag(Tagged, AddonOne), typeTag(Tagged, AddonTwo)), ); } -test "type tag identity hashing is deterministic" { - try std.testing.expectEqual( - typeTagFromIdent("addon@example::mod.Foo"), - typeTagFromIdent("addon@example::mod.Foo"), +test "Zig class names distinguish types within the same addon" { + const First = struct {}; + const Second = struct {}; + const Identity = struct { + pub const package_name: []const u8 = "package"; + pub const package_version: []const u8 = "1.0.0"; + pub const package_fingerprint: u64 = 0x1234; + pub const addon_name: []const u8 = "native"; + }; + try std.testing.expect( + !std.meta.eql(typeTag(First, Identity), typeTag(Second, Identity)), ); } @@ -283,6 +361,6 @@ test "type tag identity hashing matches the FNV-1a known vector" { .lower = 9205288767444028904, .upper = 12488879969338687231, }, - typeTagFromIdent("napi@1.0.0::x::Foo"), + typeTagFromParts(.{"napi@1.0.0::x::Foo"}), ); } diff --git a/src/js/export_module.zig b/src/js/export_module.zig index 994b408..ce74053 100644 --- a/src/js/export_module.zig +++ b/src/js/export_module.zig @@ -15,9 +15,9 @@ const class_runtime = @import("class_runtime.zig"); /// Node.js. It inspects the `Module`'s `pub` declarations and automatically /// creates corresponding JavaScript functions, classes, and sub-namespaces. /// -/// `options.type_tag` is a stable, addon-unique salt used to derive every DSL -/// class's 128-bit Node-API type tag. A UUID is recommended. Individual classes -/// can override it with `js.class(.{ .type_tag = "..." })`. +/// Addons that export DSL classes pass the build-generated identity module: +/// `.identity = @import("zapi_addon_identity")`. The addon's `build.zig` creates +/// this import with `zapi.addAddonIdentity`. Function-only modules do not need it. /// /// Additional `options` customize module lifecycle hooks: /// @@ -48,14 +48,14 @@ const class_runtime = @import("class_runtime.zig"); /// comptime { /// // Basic export of all `pub` functions, classes, and sub-namespaces. /// js.exportModule(@This(), .{ -/// .type_tag = "6f9619ff-8b86-d011-b42d-00cf4fc964ff", +/// .identity = @import("zapi_addon_identity"), /// }); /// } /// /// comptime { /// // Export with custom initialization and cleanup hooks. /// js.exportModule(@This(), .{ -/// .type_tag = "6f9619ff-8b86-d011-b42d-00cf4fc964ff", +/// .identity = @import("zapi_addon_identity"), /// .init = myInitFunction, /// .cleanup = myCleanupFunction, /// }); @@ -64,13 +64,12 @@ const class_runtime = @import("class_runtime.zig"); /// comptime { /// // Export with a manual registration function. /// js.exportModule(@This(), .{ -/// .type_tag = "6f9619ff-8b86-d011-b42d-00cf4fc964ff", /// .register = myCustomRegisterFunction, /// }); /// } /// ``` pub fn exportModule(comptime Module: type, comptime options: anytype) void { - const type_tag_salt = comptime moduleTypeTagSalt(options); + const Identity = moduleTypeTagIdentity(options); const has_init = @hasField(@TypeOf(options), "init"); const has_cleanup = @hasField(@TypeOf(options), "cleanup"); const has_register = @hasField(@TypeOf(options), "register"); @@ -119,7 +118,7 @@ pub fn exportModule(comptime Module: type, comptime options: anytype) void { State.Lifecycle.release(); }; - _ = try registerDecls(Module, type_tag_salt, env, module, 0); + _ = try registerDecls(Module, Identity, env, module, 0); if (has_register) { try options.register(env, module); @@ -140,7 +139,7 @@ pub fn exportModule(comptime Module: type, comptime options: anytype) void { /// Iterates module declarations and registers DSL functions and js_meta classes. fn registerDecls( comptime Module: type, - comptime type_tag_salt: []const u8, + comptime Identity: type, env: napi.Env, module: napi.Value, comptime depth: usize, @@ -166,7 +165,7 @@ fn registerDecls( if (!is_dsl_fn) @compileError("zapi: cannot export non-DSL `pub fn " ++ @typeName(Module) ++ "." ++ decl.name ++ "` — use DSL params (e.g. `js.Number`), drop `pub`, or pass `.register` to `exportModule` to export it manually"); // DSL function — wrap and register - const cb = wrap_function.wrapFunction(field, type_tag_salt); + const cb = wrap_function.wrapFunction(field, Identity); const name: [:0]const u8 = decl.name ++ ""; var js_fn: napi.c.napi_value = null; @@ -186,7 +185,7 @@ fn registerDecls( const InnerType = field; if (@typeInfo(InnerType) == .@"struct") { if (comptime class_meta.hasClassMeta(InnerType)) { - const wrapped = wrap_class.wrapClass(InnerType, type_tag_salt); + const wrapped = wrap_class.wrapClass(InnerType, Identity); const props = wrapped.getPropertyDescriptors(); const class_name = comptime class_meta.getClassName(InnerType, decl.name); const name: [:0]const u8 = class_name ++ ""; @@ -210,7 +209,7 @@ fn registerDecls( exported_any = true; } else { const ns_obj = try env.createObject(); - if (try registerDecls(InnerType, type_tag_salt, env, ns_obj, depth + 1)) { + if (try registerDecls(InnerType, Identity, env, ns_obj, depth + 1)) { const name: [:0]const u8 = decl.name ++ ""; try module.setNamedProperty(name, ns_obj); exported_any = true; @@ -222,21 +221,24 @@ fn registerDecls( return exported_any; } -fn moduleTypeTagSalt(comptime options: anytype) []const u8 { - if (!@hasField(@TypeOf(options), "type_tag")) { - @compileError("js.exportModule requires .type_tag with a stable addon-unique salt"); +fn moduleTypeTagIdentity(comptime options: anytype) type { + if (!@hasField(@TypeOf(options), "identity")) { + return class_runtime.NoAddonIdentity; } - const salt: []const u8 = options.type_tag; - if (salt.len == 0) { - @compileError("js.exportModule .type_tag must not be empty"); - } - return salt; + const Identity: type = options.identity; + return Identity; } test "exportModule comptime smoke test" { try std.testing.expect(true); } +test "missing module identity selects the class-free sentinel" { + try std.testing.expect( + moduleTypeTagIdentity(.{}) == class_runtime.NoAddonIdentity, + ); +} + test "exportModule shares a single js.io across the env lifecycle" { // moduleInit retains the shared io and the env cleanup hook releases it. // Mirror one env's lifecycle here: while retained, every io() call must diff --git a/src/js/wrap_class.zig b/src/js/wrap_class.zig index 2e836b0..d558e72 100644 --- a/src/js/wrap_class.zig +++ b/src/js/wrap_class.zig @@ -20,8 +20,9 @@ const wrap_function = @import("wrap_function.zig"); /// - (Potentially) `getFactoryDescriptors` for static factory methods. /// /// This result is typically passed to `js.exportModule` to register the class -/// with Node-API. -pub fn wrapClass(comptime T: type, comptime type_tag_salt: []const u8) type { +/// with Node-API. `Identity` is the build-generated identity for the final +/// addon artifact and must be used consistently for every class conversion. +pub fn wrapClass(comptime T: type, comptime Identity: type) type { if (!class_meta.isClassType(T)) { @compileError("wrapClass: " ++ @typeName(T) ++ " must declare `pub const js_meta = js.class(...)`"); } @@ -395,7 +396,7 @@ pub fn wrapClass(comptime T: type, comptime type_tag_salt: []const u8) type { const ParamType = init_params[i].type.?; args[i] = wrap_function.convertArgWithOptional( ParamType, - type_tag_salt, + Identity, raw_args[i], raw_env, i, @@ -420,7 +421,7 @@ pub fn wrapClass(comptime T: type, comptime type_tag_salt: []const u8) type { obj_ptr.* = init_result; const this_val = napi.Value{ .env = raw_env, .value = this_arg }; - class_runtime.wrapTaggedObject(T, type_tag_salt, e, this_val, obj_ptr) catch { + class_runtime.wrapTaggedObject(T, Identity, e, this_val, obj_ptr) catch { class_runtime.destroyNativeObject(T, obj_ptr); e.throwError("", "Failed to wrap native object") catch {}; return null; @@ -504,7 +505,7 @@ pub fn wrapClass(comptime T: type, comptime type_tag_salt: []const u8) type { const self_ptr = e.unwrapChecked( Class, this_val, - class_runtime.typeTag(Class, type_tag_salt), + class_runtime.typeTag(Class, Identity), ) catch { e.throwTypeError("", "Invalid class receiver") catch {}; return null; @@ -520,7 +521,7 @@ pub fn wrapClass(comptime T: type, comptime type_tag_salt: []const u8) type { const ParamType = method_params[i + 1].type.?; args[i + 1] = wrap_function.convertArgWithOptional( ParamType, - type_tag_salt, + Identity, raw_args[i], raw_env, i, @@ -537,7 +538,7 @@ pub fn wrapClass(comptime T: type, comptime type_tag_salt: []const u8) type { null; return wrap_function.callAndConvertWithCtor( method, - type_tag_salt, + Identity, args, raw_env, preferred_ctor, @@ -576,7 +577,7 @@ pub fn wrapClass(comptime T: type, comptime type_tag_salt: []const u8) type { const self_ptr = e.unwrapChecked( Class, this_val, - class_runtime.typeTag(Class, type_tag_salt), + class_runtime.typeTag(Class, Identity), ) catch { e.throwTypeError("", "Invalid class receiver") catch {}; return null; @@ -594,7 +595,7 @@ pub fn wrapClass(comptime T: type, comptime type_tag_salt: []const u8) type { null; return wrap_function.callAndConvertWithCtor( getter_fn, - type_tag_salt, + Identity, args, raw_env, preferred_ctor, @@ -644,7 +645,7 @@ pub fn wrapClass(comptime T: type, comptime type_tag_salt: []const u8) type { const ParamType = method_params[i].type.?; args[i] = wrap_function.convertArgWithOptional( ParamType, - type_tag_salt, + Identity, raw_args[i], raw_env, i, @@ -661,7 +662,7 @@ pub fn wrapClass(comptime T: type, comptime type_tag_salt: []const u8) type { null; return wrap_function.callAndConvertWithCtor( method, - type_tag_salt, + Identity, args, raw_env, preferred_ctor, @@ -702,7 +703,7 @@ pub fn wrapClass(comptime T: type, comptime type_tag_salt: []const u8) type { const self_ptr = e.unwrapChecked( Class, this_val, - class_runtime.typeTag(Class, type_tag_salt), + class_runtime.typeTag(Class, Identity), ) catch { e.throwTypeError("", "Invalid class receiver") catch {}; return null; @@ -713,7 +714,7 @@ pub fn wrapClass(comptime T: type, comptime type_tag_salt: []const u8) type { const value_arg = wrap_function.convertArg( ValueParamType, - type_tag_salt, + Identity, raw_args[0], raw_env, ) catch { diff --git a/src/js/wrap_function.zig b/src/js/wrap_function.zig index bbe25cc..3bead65 100644 --- a/src/js/wrap_function.zig +++ b/src/js/wrap_function.zig @@ -150,9 +150,11 @@ fn missingClassMetaHint(comptime T: type) ?type { /// pointers for class types. /// Returns `error.TypeMismatch` if type validation fails or an N-API class /// cannot be unwrapped. Compile-time error for unsupported `T` type. +/// `Identity` must be the build-generated addon identity whenever `T` is a class. +/// Class-free conversions may use `js.NoAddonIdentity`. pub fn convertArg( comptime T: type, - comptime type_tag_salt: []const u8, + comptime Identity: type, raw: napi.c.napi_value, env: napi.c.napi_env, ) !T { @@ -170,7 +172,7 @@ pub fn convertArg( const ptr = e.unwrapChecked( T, value, - class_runtime.typeTag(T, type_tag_salt), + class_runtime.typeTag(T, Identity), ) catch return error.TypeMismatch; return ptr.*; } @@ -181,7 +183,7 @@ pub fn convertArg( return e.unwrapChecked( ptr.child, value, - class_runtime.typeTag(ptr.child, type_tag_salt), + class_runtime.typeTag(ptr.child, Identity), ) catch error.TypeMismatch; } }, @@ -201,7 +203,7 @@ pub fn convertArg( /// Otherwise, it performs conversion using `convertArg`. pub fn convertArgWithOptional( comptime T: type, - comptime type_tag_salt: []const u8, + comptime Identity: type, raw: napi.c.napi_value, env: napi.c.napi_env, param_index: usize, @@ -212,9 +214,9 @@ pub fn convertArgWithOptional( const raw_value = napi.Value{ .env = env, .value = raw }; if ((try raw_value.typeof()) == .undefined) return null; const Inner = @typeInfo(T).optional.child; - return try convertArg(Inner, type_tag_salt, raw, env); + return try convertArg(Inner, Identity, raw, env); } - return try convertArg(T, type_tag_salt, raw, env); + return try convertArg(T, Identity, raw, env); } /// Converts a Zig value into a raw `napi.c.napi_value`, handling various DSL @@ -227,9 +229,11 @@ pub fn convertArgWithOptional( /// materialization. /// Panics if N-API operations fail for `void` or class materialization. /// Compile-time error for unsupported `T` type. +/// `Identity` must be the build-generated addon identity for class return values. +/// Class-free conversions may use `js.NoAddonIdentity`. pub fn convertReturnWithCtor( comptime T: type, - comptime type_tag_salt: []const u8, + comptime Identity: type, value: T, env: napi.c.napi_env, preferred_ctor: ?napi.Value, @@ -249,7 +253,7 @@ pub fn convertReturnWithCtor( const e = napi.Env{ .env = env }; const instance = class_runtime.materializeClassInstance( T, - type_tag_salt, + Identity, e, value, preferred_ctor, @@ -271,11 +275,11 @@ pub fn convertReturnWithCtor( /// provide a preferred constructor for class materialization. pub fn convertReturn( comptime T: type, - comptime type_tag_salt: []const u8, + comptime Identity: type, value: T, env: napi.c.napi_env, ) napi.c.napi_value { - return convertReturnWithCtor(T, type_tag_salt, value, env, null); + return convertReturnWithCtor(T, Identity, value, env, null); } /// Calls the user-provided Zig function with the given arguments and converts @@ -287,7 +291,7 @@ pub fn convertReturn( /// A `preferred_ctor` can be provided for class materialization in return types. pub fn callAndConvertWithCtor( comptime func: anytype, - comptime type_tag_salt: []const u8, + comptime Identity: type, args: std.meta.ArgsTuple(@TypeOf(func)), env: napi.c.napi_env, preferred_ctor: ?napi.Value, @@ -312,7 +316,7 @@ pub fn callAndConvertWithCtor( if (result) |val| { return convertReturnWithCtor( payload_info.optional.child, - type_tag_salt, + Identity, val, env, preferred_ctor, @@ -325,7 +329,7 @@ pub fn callAndConvertWithCtor( } // !T — plain error union - return convertReturnWithCtor(Payload, type_tag_salt, result, env, preferred_ctor); + return convertReturnWithCtor(Payload, Identity, result, env, preferred_ctor); } // ?T — optional (no error) @@ -334,7 +338,7 @@ pub fn callAndConvertWithCtor( if (result) |val| { return convertReturnWithCtor( ret_info.optional.child, - type_tag_salt, + Identity, val, env, preferred_ctor, @@ -348,7 +352,7 @@ pub fn callAndConvertWithCtor( // Plain T const result = @call(.auto, func, args); - return convertReturnWithCtor(ReturnType, type_tag_salt, result, env, preferred_ctor); + return convertReturnWithCtor(ReturnType, Identity, result, env, preferred_ctor); } /// Calls the user-provided Zig function with the given arguments and converts @@ -358,11 +362,11 @@ pub fn callAndConvertWithCtor( /// provide a preferred constructor for class materialization. pub fn callAndConvert( comptime func: anytype, - comptime type_tag_salt: []const u8, + comptime Identity: type, args: std.meta.ArgsTuple(@TypeOf(func)), env: napi.c.napi_env, ) napi.c.napi_value { - return callAndConvertWithCtor(func, type_tag_salt, args, env, null); + return callAndConvertWithCtor(func, Identity, args, env, null); } /// Generates a C-ABI `napi_callback` that wraps a ZAPI DSL-typed Zig function. @@ -377,7 +381,9 @@ pub fn callAndConvert( /// 5. Sets up thread-local `thisArg` context if it's an instance method/getter/setter. /// /// Panics if N-API operations fail during callback info retrieval or argument conversion. -pub fn wrapFunction(comptime func: anytype, comptime type_tag_salt: []const u8) napi.c.napi_callback { +/// All class conversions generated by one addon must use the same `Identity`. +/// Class-free callbacks may use `js.NoAddonIdentity`. +pub fn wrapFunction(comptime func: anytype, comptime Identity: type) napi.c.napi_callback { const FnType = @TypeOf(func); const fn_info = @typeInfo(FnType).@"fn"; const params = fn_info.params; @@ -415,7 +421,7 @@ pub fn wrapFunction(comptime func: anytype, comptime type_tag_salt: []const u8) const ParamType = params[i].type.?; args[i] = convertArgWithOptional( ParamType, - type_tag_salt, + Identity, raw_args[i], raw_env, i, @@ -426,7 +432,7 @@ pub fn wrapFunction(comptime func: anytype, comptime type_tag_salt: []const u8) }; } - return callAndConvert(func, type_tag_salt, args, raw_env); + return callAndConvert(func, Identity, args, raw_env); } }; return wrapper.callback; From b934988f8595e5a5fdc497b7212631004f800116 Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Tue, 28 Jul 2026 13:23:11 +0800 Subject: [PATCH 3/6] test: compile class-free DSL as an addon --- build.zig.zon | 10 ++++++---- package.json | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 2bf45e8..51a382a 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -96,6 +96,12 @@ .linker_allow_shlib_undefined = true, .dest_sub_path = "example_register_decls.node", }, + .function_only_dsl_compile = .{ + .root_module = .function_only_dsl_compile, + .linkage = .dynamic, + .linker_allow_shlib_undefined = true, + .dest_sub_path = "function_only_dsl_compile.node", + }, }, .tests = .{ .napi = .{ .root_module = .napi }, @@ -119,9 +125,5 @@ .root_module = .example_addon_isolation, .linker_allow_shlib_undefined = true, }, - .function_only_dsl_compile = .{ - .root_module = .function_only_dsl_compile, - .linker_allow_shlib_undefined = true, - }, }, } diff --git a/package.json b/package.json index 60d6a8d..1c9988c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "build": "pnpm build:js && pnpm build:zig", "build:js": "tsc --outDir lib", "build:zig": "zig build", - "test:zig": "zig build test:zapi test:function_only_dsl_compile", + "test:zig": "zig build test:zapi", "test:js": "vitest run examples/**/*.test.ts", "test": "pnpm test:zig && pnpm test:js", "lint:zig": "zig fmt src", From a1f2f11c62b822eb68467bf75ad11ee75b97da0d Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Mon, 10 Aug 2026 15:19:23 +0800 Subject: [PATCH 4/6] docs: explain addon identity configuration --- README.md | 139 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 128 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7ab36cb..7998e86 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,18 @@ zapi provides two main components: npm install -D @chainsafe/zapi ``` -Add the Zig dependency to your `build.zig.zon`: +A Zig package may build more than one Node addon. Each logical addon is one +final `.node` library compile step with its own root module. For example, this +package builds two addons whose root files can both contain a `mod.Counter`: + +```text +src/ +├── analytics/mod.zig -> analytics_addon.node +└── storage/mod.zig -> storage_addon.node +``` + +Add `zapi` and `zbuild` as direct dependencies in `build.zig.zon`, then give +each addon a separate root module and a unique library name: ```zig .dependencies = .{ @@ -21,28 +32,134 @@ Add the Zig dependency to your `build.zig.zon`: .url = "https://github.com/chainsafe/zapi/archive/.tar.gz", .hash = "...", }, + .zbuild = .{ + .url = "git+https://github.com/chainsafe/zbuild.git#f7d5f19de09f2808e54acf599605f9e18c9bd804", + .hash = "zbuild-0.4.0-XJFav-1nAgDmfVY1nVSIIzbkcbuHZj7yMb7Fv8yvznPO", + }, +}, + +.modules = .{ + .analytics_addon = .{ + .root_source_file = "src/analytics/mod.zig", + .imports = .{.zapi}, + .link_libc = true, + }, + .storage_addon = .{ + .root_source_file = "src/storage/mod.zig", + .imports = .{.zapi}, + .link_libc = true, + }, +}, + +.libraries = .{ + .analytics_addon = .{ + .root_module = .analytics_addon, + .linkage = .dynamic, + .linker_allow_shlib_undefined = true, + .dest_sub_path = "analytics_addon.node", + }, + .storage_addon = .{ + .root_module = .storage_addon, + .linkage = .dynamic, + .linker_allow_shlib_undefined = true, + .dest_sub_path = "storage_addon.node", + }, }, ``` -After creating the shared-library compile step for the final `.node` artifact, -attach its package and artifact identity in the addon's `build.zig`: +Configure the build and attach an identity to each final library compile step +in `build.zig`: ```zig const std = @import("std"); const zapi_build = @import("zapi"); +const zbuild = @import("zbuild"); -fn configureAddonIdentity( - b: *std.Build, - addon: *std.Build.Step.Compile, -) void { +pub fn build(b: *std.Build) !void { const manifest = @import("build.zig.zon"); - zapi_build.addAddonIdentity(b, addon, manifest); + const result = try zbuild.configureBuild(b, manifest, .{}); + + zapi_build.addAddonIdentity( + b, + result.library("analytics_addon").?, + manifest, + ); + zapi_build.addAddonIdentity( + b, + result.library("storage_addon").?, + manifest, + ); } ``` -Each logically distinct addon needs a unique compile-step name and its own root -module. Configure each root module once. Aliases or installed copies of one -compiled addon keep the identity embedded in that artifact. +`addAddonIdentity` generates a `zapi_addon_identity` import for that root +module. It is generated by the build; do not create a source file with that +name. Every root module that exports or exchanges DSL classes passes the import +to `js.exportModule`. + +For example, `src/analytics/mod.zig` may contain: + +```zig +const js = @import("zapi").js; + +pub const Counter = struct { + pub const js_meta = js.class(.{}); + + count: i32, + + pub fn init() Counter { + return .{ .count = 0 }; + } +}; + +comptime { + js.exportModule(@This(), .{ + .identity = @import("zapi_addon_identity"), + }); +} +``` + +`src/storage/mod.zig` uses the same export pattern, even if it defines a +different native type with the same Zig name: + +```zig +const js = @import("zapi").js; + +pub const Counter = struct { + pub const js_meta = js.class(.{}); + + bytes: u64, + pending: bool, + + pub fn init() Counter { + return .{ .bytes = 0, .pending = false }; + } +}; + +comptime { + js.exportModule(@This(), .{ + .identity = @import("zapi_addon_identity"), + }); +} +``` + +The generated import contains the package name, version, fingerprint, and final +library compile-step name. `js.exportModule` combines it with the Zig type name: + +| Output artifact | Compile-step name | Class tag input | +|---|---|---| +| `analytics_addon.node` | `analytics_addon` | `package@version#fingerprint::analytics_addon::mod.Counter` | +| `storage_addon.node` | `storage_addon` | `package@version#fingerprint::storage_addon::mod.Counter` | + +The two `Counter` classes therefore receive different Node-API type tags and +cannot be passed to each other's wrapped functions. Configure each final root +module exactly once. The identity uses the compile-step name, not +`dest_sub_path` or the path from which Node loads the file. Copies or filesystem +aliases of one compiled `.node` therefore keep the embedded identity and remain +compatible with each other. + +Function-only modules that never accept or return DSL classes do not need an +addon identity and may continue to use `js.exportModule(@This(), .{})`. Zig 0.16 does not expose a `.path` dependency's `build.zig` as an import. Keep the URL/hash dependency declaration above and use From 7125e5d19aa8efab94ead595208d0b20b5c6d9ec Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Mon, 10 Aug 2026 17:25:17 +0800 Subject: [PATCH 5/6] refactor: move examples into consumer project --- README.md | 153 +++++++-------------------- build.zig | 13 +-- build.zig.zon | 89 +--------------- examples/addon_isolation/mod.test.ts | 4 +- examples/build.zig | 20 ++++ examples/build.zig.zon | 118 +++++++++++++++++++++ examples/hello_world/mod.test.ts | 2 +- examples/js_dsl/mod.test.ts | 4 +- examples/register_decls/mod.test.ts | 2 +- examples/type_tag/mod.test.ts | 2 +- package.json | 2 +- 11 files changed, 188 insertions(+), 221 deletions(-) create mode 100644 examples/build.zig create mode 100644 examples/build.zig.zon diff --git a/README.md b/README.md index 7998e86..37c3062 100644 --- a/README.md +++ b/README.md @@ -13,62 +13,24 @@ zapi provides two main components: npm install -D @chainsafe/zapi ``` -A Zig package may build more than one Node addon. Each logical addon is one -final `.node` library compile step with its own root module. For example, this -package builds two addons whose root files can both contain a `mod.Counter`: +## Zig addon project setup -```text -src/ -├── analytics/mod.zig -> analytics_addon.node -└── storage/mod.zig -> storage_addon.node -``` - -Add `zapi` and `zbuild` as direct dependencies in `build.zig.zon`, then give -each addon a separate root module and a unique library name: - -```zig -.dependencies = .{ - .zapi = .{ - .url = "https://github.com/chainsafe/zapi/archive/.tar.gz", - .hash = "...", - }, - .zbuild = .{ - .url = "git+https://github.com/chainsafe/zbuild.git#f7d5f19de09f2808e54acf599605f9e18c9bd804", - .hash = "zbuild-0.4.0-XJFav-1nAgDmfVY1nVSIIzbkcbuHZj7yMb7Fv8yvznPO", - }, -}, +[`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. -.modules = .{ - .analytics_addon = .{ - .root_source_file = "src/analytics/mod.zig", - .imports = .{.zapi}, - .link_libc = true, - }, - .storage_addon = .{ - .root_source_file = "src/storage/mod.zig", - .imports = .{.zapi}, - .link_libc = true, - }, -}, - -.libraries = .{ - .analytics_addon = .{ - .root_module = .analytics_addon, - .linkage = .dynamic, - .linker_allow_shlib_undefined = true, - .dest_sub_path = "analytics_addon.node", - }, - .storage_addon = .{ - .root_module = .storage_addon, - .linkage = .dynamic, - .linker_allow_shlib_undefined = true, - .dest_sub_path = "storage_addon.node", - }, -}, -``` +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. -Configure the build and attach an identity to each final library compile step -in `build.zig`: +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 const std = @import("std"); @@ -76,42 +38,26 @@ 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("analytics_addon").?, + result.library("example_js_dsl").?, manifest, ); zapi_build.addAddonIdentity( b, - result.library("storage_addon").?, + result.library("example_addon_isolation").?, manifest, ); } ``` -`addAddonIdentity` generates a `zapi_addon_identity` import for that root -module. It is generated by the build; do not create a source file with that -name. Every root module that exports or exchanges DSL classes passes the import -to `js.exportModule`. - -For example, `src/analytics/mod.zig` may contain: +The corresponding root modules pass the generated import to `js.exportModule`: ```zig -const js = @import("zapi").js; - -pub const Counter = struct { - pub const js_meta = js.class(.{}); - - count: i32, - - pub fn init() Counter { - return .{ .count = 0 }; - } -}; - comptime { js.exportModule(@This(), .{ .identity = @import("zapi_addon_identity"), @@ -119,51 +65,32 @@ comptime { } ``` -`src/storage/mod.zig` uses the same export pattern, even if it defines a -different native type with the same Zig name: - -```zig -const js = @import("zapi").js; - -pub const Counter = struct { - pub const js_meta = js.class(.{}); +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(), .{})`. - bytes: u64, - pending: bool, +`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. - pub fn init() Counter { - return .{ .bytes = 0, .pending = false }; - } -}; +The examples declare `zapi` and `zbuild` as URL/hash dependencies. Build them +against this checkout with: -comptime { - js.exportModule(@This(), .{ - .identity = @import("zapi_addon_identity"), - }); -} +```bash +cd examples +zig build --fork=.. ``` -The generated import contains the package name, version, fingerprint, and final -library compile-step name. `js.exportModule` combines it with the Zig type name: - -| Output artifact | Compile-step name | Class tag input | -|---|---|---| -| `analytics_addon.node` | `analytics_addon` | `package@version#fingerprint::analytics_addon::mod.Counter` | -| `storage_addon.node` | `storage_addon` | `package@version#fingerprint::storage_addon::mod.Counter` | - -The two `Counter` classes therefore receive different Node-API type tags and -cannot be passed to each other's wrapped functions. Configure each final root -module exactly once. The identity uses the compile-step name, not -`dest_sub_path` or the path from which Node loads the file. Copies or filesystem -aliases of one compiled `.node` therefore keep the embedded identity and remain -compatible with each other. - -Function-only modules that never accept or return DSL classes do not need an -addon identity and may continue to use `js.exportModule(@This(), .{})`. - -Zig 0.16 does not expose a `.path` dependency's `build.zig` as an import. Keep -the URL/hash dependency declaration above and use -`zig build --fork=/path/to/zapi` when developing against a local checkout. +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. --- diff --git a/build.zig b/build.zig index 76f5a43..bdd4c74 100644 --- a/build.zig +++ b/build.zig @@ -4,9 +4,7 @@ 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, .{}); - - configureExampleAddonIdentities(b, manifest, result); + _ = try zbuild.configureBuild(b, manifest, .{}); } /// Makes the final addon's package and artifact identity available to its root @@ -33,12 +31,3 @@ pub fn addAddonIdentity( identity.addOption([]const u8, "addon_name", addon.name); addon.root_module.addOptions(import_name, identity); } - -fn configureExampleAddonIdentities( - b: *std.Build, - comptime manifest: anytype, - result: zbuild.BuildResult, -) void { - addAddonIdentity(b, result.library("example_js_dsl").?, manifest); - addAddonIdentity(b, result.library("example_addon_isolation").?, manifest); -} diff --git a/build.zig.zon b/build.zig.zon index 51a382a..00ffd78 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -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"}, @@ -34,96 +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_addon_isolation = .{ - .root_source_file = "examples/addon_isolation/mod.zig", - .imports = .{.zapi}, - .link_libc = true, - }, - .example_register_decls = .{ - .root_source_file = "examples/register_decls/mod.zig", - .imports = .{.zapi}, - .link_libc = true, - }, - .function_only_dsl_compile = .{ - .root_source_file = "examples/function_only_dsl/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_addon_isolation = .{ - .root_module = .example_addon_isolation, - .linkage = .dynamic, - .linker_allow_shlib_undefined = true, - .dest_sub_path = "example_addon_isolation.node", - }, - .example_register_decls = .{ - .root_module = .example_register_decls, - .linkage = .dynamic, - .linker_allow_shlib_undefined = true, - .dest_sub_path = "example_register_decls.node", - }, - .function_only_dsl_compile = .{ - .root_module = .function_only_dsl_compile, - .linkage = .dynamic, - .linker_allow_shlib_undefined = true, - .dest_sub_path = "function_only_dsl_compile.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, - }, - .example_addon_isolation = .{ - .root_module = .example_addon_isolation, - .linker_allow_shlib_undefined = true, - }, }, } diff --git a/examples/addon_isolation/mod.test.ts b/examples/addon_isolation/mod.test.ts index 4bd172a..00d9b6a 100644 --- a/examples/addon_isolation/mod.test.ts +++ b/examples/addon_isolation/mod.test.ts @@ -4,9 +4,9 @@ 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 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 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); diff --git a/examples/build.zig b/examples/build.zig new file mode 100644 index 0000000..edaae8c --- /dev/null +++ b/examples/build.zig @@ -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, + ); +} diff --git a/examples/build.zig.zon b/examples/build.zig.zon new file mode 100644 index 0000000..00364fa --- /dev/null +++ b/examples/build.zig.zon @@ -0,0 +1,118 @@ +.{ + .name = .zapi_examples, + .version = "0.0.0", + .fingerprint = 0x5ce1fd8530722186, + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "addon_isolation", + "function_only_dsl", + "hello_world", + "js_dsl", + "register_decls", + "type_tag", + }, + .dependencies = .{ + // Repository builds use --fork so the examples consume the local + // checkout while preserving the URL/hash dependency boundary. + .zapi = .{ + .url = "https://github.com/ChainSafe/zapi/archive/b934988f8595e5a5fdc497b7212631004f800116.tar.gz", + .hash = "zapi-3.1.0-rIqzUfHFBADg6qo5AXJzbfHgs6LqpwgVKYal4qCG4XSW", + }, + .zbuild = .{ + .url = "git+https://github.com/chainsafe/zbuild.git#f7d5f19de09f2808e54acf599605f9e18c9bd804", + .hash = "zbuild-0.4.0-XJFav-1nAgDmfVY1nVSIIzbkcbuHZj7yMb7Fv8yvznPO", + }, + }, + .modules = .{ + .example_hello_world = .{ + .root_source_file = "hello_world/mod.zig", + .imports = .{.zapi}, + .link_libc = true, + }, + .example_type_tag = .{ + .root_source_file = "type_tag/mod.zig", + .imports = .{.zapi}, + .link_libc = true, + }, + .example_js_dsl = .{ + .root_source_file = "js_dsl/mod.zig", + .imports = .{.zapi}, + .link_libc = true, + }, + .example_addon_isolation = .{ + .root_source_file = "addon_isolation/mod.zig", + .imports = .{.zapi}, + .link_libc = true, + }, + .example_register_decls = .{ + .root_source_file = "register_decls/mod.zig", + .imports = .{.zapi}, + .link_libc = true, + }, + .function_only_dsl_compile = .{ + .root_source_file = "function_only_dsl/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_addon_isolation = .{ + .root_module = .example_addon_isolation, + .linkage = .dynamic, + .linker_allow_shlib_undefined = true, + .dest_sub_path = "example_addon_isolation.node", + }, + .example_register_decls = .{ + .root_module = .example_register_decls, + .linkage = .dynamic, + .linker_allow_shlib_undefined = true, + .dest_sub_path = "example_register_decls.node", + }, + .function_only_dsl_compile = .{ + .root_module = .function_only_dsl_compile, + .linkage = .dynamic, + .linker_allow_shlib_undefined = true, + .dest_sub_path = "function_only_dsl_compile.node", + }, + }, + .tests = .{ + // Node supplies these N-API symbols when it loads the addon. Standalone + // Zig test binaries allow them to remain unresolved. + .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, + }, + .example_addon_isolation = .{ + .root_module = .example_addon_isolation, + .linker_allow_shlib_undefined = true, + }, + }, +} diff --git a/examples/hello_world/mod.test.ts b/examples/hello_world/mod.test.ts index 491e99c..b7b87ca 100644 --- a/examples/hello_world/mod.test.ts +++ b/examples/hello_world/mod.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); -const example = require("../../zig-out/lib/example_hello_world.node"); +const example = require("../zig-out/lib/example_hello_world.node"); describe("example mod", () => { it("addon parameters", () => { diff --git a/examples/js_dsl/mod.test.ts b/examples/js_dsl/mod.test.ts index 5d8b4b1..ee029b1 100644 --- a/examples/js_dsl/mod.test.ts +++ b/examples/js_dsl/mod.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); -const mod = require("../../zig-out/lib/example_js_dsl.node"); +const mod = require("../zig-out/lib/example_js_dsl.node"); function expectTypeErrorWithMessage(fn: () => unknown, message: string) { try { @@ -726,7 +726,7 @@ describe("module lifecycle - worker threads", () => { // Build an absolute path to the .node file so the worker can load it const thisDir = fileURLToPath(new URL(".", import.meta.url)); - const nativePath = resolve(thisDir, "../../zig-out/lib/example_js_dsl.node"); + const nativePath = resolve(thisDir, "../zig-out/lib/example_js_dsl.node"); // Spawn a worker that loads the same native module const worker = new Worker( diff --git a/examples/register_decls/mod.test.ts b/examples/register_decls/mod.test.ts index 341039f..790f455 100644 --- a/examples/register_decls/mod.test.ts +++ b/examples/register_decls/mod.test.ts @@ -2,7 +2,7 @@ import { createRequire } from "node:module"; import { describe, expect, it } from "vitest"; const require = createRequire(import.meta.url); -const mod = require("../../zig-out/lib/example_register_decls.node"); +const mod = require("../zig-out/lib/example_register_decls.node"); describe("registerDecls", () => { it("registers functions and strings", () => { diff --git a/examples/type_tag/mod.test.ts b/examples/type_tag/mod.test.ts index e49d300..071a5d5 100644 --- a/examples/type_tag/mod.test.ts +++ b/examples/type_tag/mod.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); -const mod = require("../../zig-out/lib/example_type_tag.node"); +const mod = require("../zig-out/lib/example_type_tag.node"); describe("type-tagged classes", () => { it("Cat.name() returns the name", () => { diff --git a/package.json b/package.json index 1c9988c..d5dc6a2 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "scripts": { "build": "pnpm build:js && pnpm build:zig", "build:js": "tsc --outDir lib", - "build:zig": "zig build", + "build:zig": "cd examples && zig build --fork=..", "test:zig": "zig build test:zapi", "test:js": "vitest run examples/**/*.test.ts", "test": "pnpm test:zig && pnpm test:js", From 591f719f892f11ac61fb45669e98eaac7f714da3 Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Thu, 13 Aug 2026 16:40:08 +0800 Subject: [PATCH 6/6] docs: clarify class identity across versions --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 37c3062..f599991 100644 --- a/README.md +++ b/README.md @@ -149,8 +149,11 @@ c.count; // 1 (getter, not a method call) 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. Modules that export only functions and never -accept or return DSL classes may continue to use `js.exportModule(@This(), .{})`. +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)