diff --git a/README.md b/README.md index a76ef6e..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 @@ -60,7 +84,11 @@ pub const Counter = struct { } }; -comptime { js.exportModule(@This(), .{}); } +comptime { + js.exportModule(@This(), .{ + .identity = @import("zapi_addon_identity"), + }); +} ``` **JavaScript usage:** @@ -73,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. One line — `comptime { js.exportModule(@This(), .{}); }` — registers everything. +`pub` functions are auto-exported, and structs with `js_meta = js.class(...)` +become JS classes. Class type tags are derived at compile time from the Zig +package name, version, fingerprint, addon artifact name, and class type name. +This keeps two loaded copies of one addon compatible while isolating different +addons and package versions. 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`. --- @@ -319,7 +360,11 @@ Import Zig modules as `pub const` to create JS namespaces. The DSL recursively r pub const math = @import("math.zig"); // → exports.math.multiply(...) pub const crypto = @import("crypto.zig"); // → exports.crypto.PublicKey, etc. -comptime { js.exportModule(@This(), .{}); } +comptime { + js.exportModule(@This(), .{ + .identity = @import("zapi_addon_identity"), + }); +} ``` Namespaces nest arbitrarily — a sub-module with more `pub const` imports creates deeper nesting. @@ -333,6 +378,7 @@ Namespaces nest arbitrarily — a sub-module with more `pub const` imports creat ```zig comptime { js.exportModule(@This(), .{ + .identity = @import("zapi_addon_identity"), .init = fn (refcount: u32) !void, // called before registration (0 = first env) .cleanup = fn (refcount: u32) void, // called on env exit (0 = last env) }); 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 14ef97a..51a382a 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -49,11 +49,21 @@ .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 = .{ @@ -74,12 +84,24 @@ .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 }, @@ -99,5 +121,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..4bd172a --- /dev/null +++ b/examples/addon_isolation/mod.test.ts @@ -0,0 +1,37 @@ +import { copyFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const primaryPath = require.resolve("../../zig-out/lib/example_js_dsl.node"); +const primary = require(primaryPath); +const secondary = require("../../zig-out/lib/example_addon_isolation.node"); +const duplicatePath = join(dirname(primaryPath), "example_js_dsl_duplicate.node"); +copyFileSync(primaryPath, duplicatePath); +const duplicate = require(duplicatePath); + +describe("DSL class isolation across addons", () => { + it("keeps matching class names isolated by addon", () => { + const primaryCounter = new primary.Counter(1); + const secondaryCounter = new secondary.Counter(2); + + primary.incrementCounter(primaryCounter); + secondary.incrementCounter(secondaryCounter); + expect(primaryCounter.getCount()).toBe(2); + expect(secondaryCounter.getCount()).toBe(3); + + expect(() => primary.incrementCounter(secondaryCounter)).toThrow(TypeError); + expect(() => secondary.incrementCounter(primaryCounter)).toThrow(TypeError); + }); + + it("keeps class identity across two loaded copies of one addon", () => { + const primaryCounter = new primary.Counter(1); + const duplicateCounter = new duplicate.Counter(2); + + primary.incrementCounter(duplicateCounter); + duplicate.incrementCounter(primaryCounter); + expect(primaryCounter.getCount()).toBe(2); + expect(duplicateCounter.getCount()).toBe(3); + }); +}); diff --git a/examples/addon_isolation/mod.zig b/examples/addon_isolation/mod.zig new file mode 100644 index 0000000..13033a7 --- /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(), .{ + .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 0d13d9c..840cf53 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(), .{ + .identity = @import("zapi_addon_identity"), .init = struct { fn f(refcount: u32) !void { const count = module_init_count.fetchAdd(1, .monotonic); 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_runtime.zig b/src/js/class_runtime.zig index 5549f62..21e9f33 100644 --- a/src/js/class_runtime.zig +++ b/src/js/class_runtime.zig @@ -2,11 +2,26 @@ const std = @import("std"); const napi = @import("../napi.zig"); const context = @import("context.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) }), - }; +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. @@ -14,30 +29,65 @@ 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 Identity: type, + env: napi.Env, + object: napi.Value, + native_object: *T, +) !void { + const tag = typeTag(T, Identity); 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 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 { @@ -113,7 +163,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 Identity: type, + 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 +203,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, Identity, env, js_instance, obj_ptr); return js_instance; } @@ -227,3 +283,84 @@ fn markers(comptime T: type) type { fn internalCtorMarkerPtr(comptime T: type) *u8 { return &markers(T).ctor_marker; } + +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(typeTag(Tagged, VersionOne), typeTag(Tagged, VersionTwo)), + ); +} + +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, PackageOne), typeTag(Tagged, PackageTwo)), + ); +} + +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"; + }; + 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 "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)), + ); +} + +test "type tag identity hashing matches the FNV-1a known vector" { + try std.testing.expectEqual( + napi.c.napi_type_tag{ + .lower = 9205288767444028904, + .upper = 12488879969338687231, + }, + typeTagFromParts(.{"napi@1.0.0::x::Foo"}), + ); +} diff --git a/src/js/export_module.zig b/src/js/export_module.zig index 48e6caa..ce74053 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: +/// 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: /// /// - `.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,30 @@ 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(), .{ +/// .identity = @import("zapi_addon_identity"), +/// }); /// } /// /// comptime { -/// // Export with custom initialization and cleanup hooks +/// // Export with custom initialization and cleanup hooks. /// js.exportModule(@This(), .{ +/// .identity = @import("zapi_addon_identity"), /// .init = myInitFunction, /// .cleanup = myCleanupFunction, /// }); /// } /// /// comptime { -/// // Export with a manual registration function +/// // Export with a manual registration function. /// js.exportModule(@This(), .{ /// .register = myCustomRegisterFunction, /// }); /// } /// ``` pub fn exportModule(comptime Module: type, comptime options: anytype) void { + 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"); @@ -110,7 +118,7 @@ pub fn exportModule(comptime Module: type, comptime options: anytype) void { State.Lifecycle.release(); }; - _ = try registerDecls(Module, env, module, 0); + _ = try registerDecls(Module, Identity, env, module, 0); if (has_register) { try options.register(env, module); @@ -129,7 +137,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 Identity: type, + env: napi.Env, + module: napi.Value, + comptime depth: usize, +) !bool { const decls = @typeInfo(Module).@"struct".decls; var exported_any = false; @@ -151,7 +165,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, Identity); const name: [:0]const u8 = decl.name ++ ""; var js_fn: napi.c.napi_value = null; @@ -171,7 +185,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, Identity); const props = wrapped.getPropertyDescriptors(); const class_name = comptime class_meta.getClassName(InnerType, decl.name); const name: [:0]const u8 = class_name ++ ""; @@ -195,7 +209,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, Identity, env, ns_obj, depth + 1)) { const name: [:0]const u8 = decl.name ++ ""; try module.setNamedProperty(name, ns_obj); exported_any = true; @@ -207,10 +221,24 @@ fn registerDecls(comptime Module: type, env: napi.Env, module: napi.Value, compt return exported_any; } +fn moduleTypeTagIdentity(comptime options: anytype) type { + if (!@hasField(@TypeOf(options), "identity")) { + return class_runtime.NoAddonIdentity; + } + 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 4d61239..d558e72 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. @@ -22,8 +20,9 @@ const callAndConvert = wrap_function.callAndConvert; /// - (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) 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 +394,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, + Identity, + raw_args[i], + raw_env, + i, + actual_argc, + ) catch { wrap_function.throwArgTypeError(e, ParamType, i); return null; }; @@ -415,7 +421,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, Identity, e, this_val, obj_ptr) catch { class_runtime.destroyNativeObject(T, obj_ptr); e.throwError("", "Failed to wrap native object") catch {}; return null; @@ -496,7 +502,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, Identity), + ) catch { e.throwTypeError("", "Invalid class receiver") catch {}; return null; }; @@ -509,7 +519,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, + Identity, + raw_args[i], + raw_env, + i, + actual_argc, + ) catch { wrap_function.throwArgTypeError(e, ParamType, i); return null; }; @@ -519,7 +536,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, + Identity, + args, + raw_env, + preferred_ctor, + ); } }; return method_cb.callback; @@ -551,7 +574,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, Identity), + ) catch { e.throwTypeError("", "Invalid class receiver") catch {}; return null; }; @@ -566,7 +593,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, + Identity, + args, + raw_env, + preferred_ctor, + ); } }; return getter_cb.callback; @@ -610,7 +643,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, + Identity, + raw_args[i], + raw_env, + i, + actual_argc, + ) catch { wrap_function.throwArgTypeError(e, ParamType, i); return null; }; @@ -620,7 +660,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, + Identity, + args, + raw_env, + preferred_ctor, + ); } }; return static_cb.callback; @@ -654,7 +700,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, Identity), + ) catch { e.throwTypeError("", "Invalid class receiver") catch {}; return null; }; @@ -662,7 +712,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, + Identity, + 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..3bead65 100644 --- a/src/js/wrap_function.zig +++ b/src/js/wrap_function.zig @@ -150,7 +150,14 @@ 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 { +/// `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 Identity: type, + 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 +169,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, Identity), + ) 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, Identity), + ) catch error.TypeMismatch; } }, else => {}, @@ -188,6 +203,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 Identity: type, raw: napi.c.napi_value, env: napi.c.napi_env, param_index: usize, @@ -198,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, raw, env); + return try convertArg(Inner, Identity, raw, env); } - return try convertArg(T, raw, env); + return try convertArg(T, Identity, raw, env); } /// Converts a Zig value into a raw `napi.c.napi_value`, handling various DSL @@ -213,7 +229,15 @@ 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 { +/// `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 Identity: type, + 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 +251,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, + Identity, + e, + value, + preferred_ctor, + ) catch { e.throwError("", "Failed to materialize returned class instance") catch {}; return null; }; @@ -243,8 +273,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 Identity: type, + value: T, + env: napi.c.napi_env, +) napi.c.napi_value { + return convertReturnWithCtor(T, Identity, value, env, null); } /// Calls the user-provided Zig function with the given arguments and converts @@ -254,7 +289,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 Identity: type, + 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 +314,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, + Identity, + 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 +329,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, Identity, 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, + Identity, + 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 +352,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, Identity, result, env, preferred_ctor); } /// Calls the user-provided Zig function with the given arguments and converts @@ -307,8 +360,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 Identity: type, + args: std.meta.ArgsTuple(@TypeOf(func)), + env: napi.c.napi_env, +) napi.c.napi_value { + return callAndConvertWithCtor(func, Identity, args, env, null); } /// Generates a C-ABI `napi_callback` that wraps a ZAPI DSL-typed Zig function. @@ -323,7 +381,9 @@ 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 { +/// 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; @@ -359,13 +419,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, + Identity, + raw_args[i], + raw_env, + i, + actual_argc, + ) catch { throwArgTypeError(e, ParamType, i); return null; }; } - return callAndConvert(func, args, raw_env); + return callAndConvert(func, Identity, args, raw_env); } }; return wrapper.callback;