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/3] 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/3] 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/3] 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",