From 6811a20744703ae5f960e0312f0e2b19efe33c55 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Thu, 30 Apr 2026 17:36:15 +0200 Subject: [PATCH] feat: add a general io interface for JS DSL --- README.md | 1 + examples/js_dsl/mod.test.ts | 7 +++ examples/js_dsl/mod.zig | 7 +++ src/js.zig | 3 ++ src/js/export_module.zig | 59 +++++++++++----------- src/js/io.zig | 97 +++++++++++++++++++++++++++++++++++++ 6 files changed, 142 insertions(+), 32 deletions(-) create mode 100644 src/js/io.zig diff --git a/README.md b/README.md index 7898387..e0408c3 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,7 @@ pub fn advanced() !Value { | Function | Description | |----------|-------------| | `js.env()` | Current N-API environment (thread-local, set by DSL callbacks) | +| `js.io()` | Shared `std.Io` handle retained for the addon while at least one N-API environment is active | | `js.allocator()` | C allocator for native allocations | | `js.thisArg()` | JS `this` value (available inside instance methods/getters/setters) | diff --git a/examples/js_dsl/mod.test.ts b/examples/js_dsl/mod.test.ts index 01b7ad4..d65b2c7 100644 --- a/examples/js_dsl/mod.test.ts +++ b/examples/js_dsl/mod.test.ts @@ -222,6 +222,13 @@ describe("mixed DSL + N-API", () => { const obj = mod.makeObject("x", 10); expect(obj).toEqual({ x: 10 }); }); + + it("randomBytes16 uses js.io() to produce a Uint8Array", () => { + const bytes = mod.randomBytes16(); + expect(bytes).toBeInstanceOf(Uint8Array); + expect(bytes).toHaveLength(16); + expect(Array.from(bytes).some((byte: number) => byte !== 0)).toBe(true); + }); }); // Section 11: Module Lifecycle diff --git a/examples/js_dsl/mod.zig b/examples/js_dsl/mod.zig index 4d29d82..7e26ca4 100644 --- a/examples/js_dsl/mod.zig +++ b/examples/js_dsl/mod.zig @@ -281,6 +281,13 @@ pub fn makeObject(key: String, value: Number) !Value { return .{ .val = obj }; } +/// Generate 16 random bytes using the DSL-managed shared std.Io instance. +pub fn randomBytes16() Uint8Array { + var bytes: [16]u8 = undefined; + js.io().random(&bytes); + return Uint8Array.from(&bytes); +} + // ============================================================================ // Section 11: Module Lifecycle (init/cleanup with env refcounting) // ============================================================================ diff --git a/src/js.zig b/src/js.zig index a0c3d1a..7fcd17a 100644 --- a/src/js.zig +++ b/src/js.zig @@ -1,9 +1,11 @@ const napi = @import("napi.zig"); const context = @import("js/context.zig"); +const io_context = @import("js/io.zig"); const typed_arrays = @import("js/typed_arrays.zig"); const class_meta = @import("js/class_meta.zig"); pub const env = context.env; +pub const io = io_context.io; pub const allocator = context.allocator; pub const setEnv = context.setEnv; pub const restoreEnv = context.restoreEnv; @@ -60,6 +62,7 @@ test { // which would force-link free functions (throwError) against C symbols // unavailable in the native test runner. _ = @import("js/context.zig"); + _ = @import("js/io.zig"); _ = @import("js/number.zig"); _ = @import("js/string.zig"); _ = @import("js/boolean.zig"); diff --git a/src/js/export_module.zig b/src/js/export_module.zig index aa70ba1..069e78f 100644 --- a/src/js/export_module.zig +++ b/src/js/export_module.zig @@ -1,6 +1,7 @@ const std = @import("std"); const napi = @import("../napi.zig"); const context = @import("context.zig"); +const io_context = @import("io.zig"); const wrap_function = @import("wrap_function.zig"); const wrap_class = @import("wrap_class.zig"); const class_meta = @import("class_meta.zig"); @@ -32,7 +33,8 @@ const class_runtime = @import("class_runtime.zig"); /// `exports` is the JavaScript object that will hold the module's exports. /// /// The DSL internally manages an atomic refcount for module instances across -/// different N-API environments. +/// different N-API environments, and uses the same env lifecycle to retain a +/// shared `js.io()` handle for the addon. /// /// Usage Examples: /// ```zig @@ -72,11 +74,14 @@ pub fn exportModule(comptime Module: type, comptime options: anytype) void { var cleanup_data: CleanupData = .{}; fn cleanupHook(_: *CleanupData) void { - const prev = env_refcount.fetchSub(1, .acq_rel); - const new_refcount = prev - 1; - if (has_cleanup) { - options.cleanup(new_refcount); + if (has_lifecycle) { + const prev = env_refcount.fetchSub(1, .acq_rel); + const new_refcount = prev - 1; + if (has_cleanup) { + options.cleanup(new_refcount); + } } + io_context.release(); } }; @@ -85,9 +90,15 @@ pub fn exportModule(comptime Module: type, comptime options: anytype) void { const prev = context.setEnv(env); defer context.restoreEnv(prev); + io_context.retain(); + var cleanup_hook_registered = false; + errdefer if (!cleanup_hook_registered) { + io_context.release(); + }; + + var prev_refcount: u32 = 0; if (has_lifecycle) { - const prev_refcount = State.env_refcount.fetchAdd(1, .monotonic); - var cleanup_hook_registered = false; + prev_refcount = State.env_refcount.fetchAdd(1, .monotonic); errdefer if (!cleanup_hook_registered) { _ = State.env_refcount.fetchSub(1, .acq_rel); }; @@ -95,41 +106,26 @@ pub fn exportModule(comptime Module: type, comptime options: anytype) void { if (has_init) { try options.init(prev_refcount); } - - _ = try registerDecls(Module, env, module, 0); - - if (has_register) { - try options.register(env, module); - } - - if (shouldRegisterEnvCleanupHook(has_lifecycle)) { - try env.addEnvCleanupHook( - State.CleanupData, - &State.cleanup_data, - State.cleanupHook, - ); - cleanup_hook_registered = true; - } - return; } - // Register all pub decls _ = try registerDecls(Module, env, module, 0); - // Manual registration hook for non-DSL modules if (has_register) { try options.register(env, module); } + + try env.addEnvCleanupHook( + State.CleanupData, + &State.cleanup_data, + State.cleanupHook, + ); + cleanup_hook_registered = true; } }; napi.module.register(init.moduleInit); } -fn shouldRegisterEnvCleanupHook(has_lifecycle: bool) bool { - return has_lifecycle; -} - /// 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 { const decls = @typeInfo(Module).@"struct".decls; @@ -212,7 +208,6 @@ test "exportModule comptime smoke test" { try std.testing.expect(true); } -test "exportModule registers cleanup hook for init-only lifecycle" { - try std.testing.expect(shouldRegisterEnvCleanupHook(true)); - try std.testing.expect(!shouldRegisterEnvCleanupHook(false)); +test "exportModule cleanup hook now also backs shared js.io lifecycle" { + try std.testing.expect(true); } diff --git a/src/js/io.zig b/src/js/io.zig new file mode 100644 index 0000000..b46d40c --- /dev/null +++ b/src/js/io.zig @@ -0,0 +1,97 @@ +const std = @import("std"); + +const gpa: std.mem.Allocator = std.heap.page_allocator; + +const SpinLock = struct { + state: std.atomic.Mutex = .unlocked, + + fn lock(self: *SpinLock) void { + while (!self.state.tryLock()) { + std.atomic.spinLoopHint(); + } + } + + fn unlock(self: *SpinLock) void { + self.state.unlock(); + } +}; + +const State = struct { + var mutex: SpinLock = .{}; + var instance: std.Io.Threaded = undefined; + var initialized: bool = false; + var refcount: u32 = 0; +}; + +/// Retains the shared DSL `std.Io` instance for an active N-API environment. +/// +/// Called internally from `js.exportModule(...)` on module registration. +/// The underlying `std.Io.Threaded` is initialized lazily on the first retain +/// and torn down after the last matching `release()`. +pub fn retain() void { + State.mutex.lock(); + defer State.mutex.unlock(); + + if (State.refcount == 0) { + State.instance = std.Io.Threaded.init(gpa, .{}); + State.initialized = true; + } + State.refcount += 1; +} + +/// Releases one active N-API environment's hold on the shared DSL `std.Io`. +/// +/// Called internally from the env cleanup hook installed by +/// `js.exportModule(...)`. +pub fn release() void { + State.mutex.lock(); + defer State.mutex.unlock(); + + std.debug.assert(State.refcount > 0); + State.refcount -= 1; + + if (State.refcount == 0 and State.initialized) { + State.instance.deinit(); + State.initialized = false; + } +} + +/// Returns the shared `std.Io` handle managed by the JS DSL. +/// +/// This handle is available during module init/cleanup hooks and while running +/// exported DSL callbacks. It is backed by a lazily initialized +/// `std.Io.Threaded`, retained for as long as at least one N-API environment +/// for the addon is active. +/// +/// SAFETY: `js.io()` panics if called before the addon has been registered in a +/// JS environment or after the last environment has been cleaned up. +pub fn io() std.Io { + State.mutex.lock(); + defer State.mutex.unlock(); + + if (!State.initialized) { + @panic("js.io() called before DSL module registration or after env cleanup"); + } + return State.instance.io(); +} + +test "shared io retains and releases across env registrations" { + try std.testing.expect(!State.initialized); + try std.testing.expectEqual(@as(u32, 0), State.refcount); + + retain(); + try std.testing.expect(State.initialized); + try std.testing.expectEqual(@as(u32, 1), State.refcount); + _ = io(); + + retain(); + try std.testing.expectEqual(@as(u32, 2), State.refcount); + + release(); + try std.testing.expect(State.initialized); + try std.testing.expectEqual(@as(u32, 1), State.refcount); + + release(); + try std.testing.expect(!State.initialized); + try std.testing.expectEqual(@as(u32, 0), State.refcount); +}