diff --git a/.gitignore b/.gitignore index c7d7b04..0d3a418 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ test/spec/static_tests.zig .yarn node_modules/ lib/ +docs/superpowers/ +.claude/settings.local.json diff --git a/README.md b/README.md index 2c282a0..bfee2bc 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,9 @@ A Zig N-API wrapper library and CLI for building and publishing cross-platform Node.js native addons. -## Overview - zapi provides two main components: -1. **Zig Library** (`src/`) - Idiomatic Zig bindings for the Node.js N-API, making it easy to write native addons in Zig +1. **Zig Library** (`src/`) - Write Node.js native addons in Zig with a high-level DSL that mirrors JavaScript's type system 2. **CLI Tool** (`ts/`) - Build tooling for cross-compiling and publishing multi-platform npm packages ## Installation @@ -28,30 +26,346 @@ Add the Zig dependency to your `build.zig.zon`: --- -## Zig Library +## Zig Library — Quick Start -### Quick Start +The DSL is the default approach for writing native addons. Import `js` from zapi and write normal Zig functions — zapi handles all the N-API marshalling automatically. ```zig -const napi = @import("napi"); +const js = @import("zapi").js; -comptime { - napi.module.register(initModule); +pub fn add(a: js.Number, b: js.Number) js.Number { + return js.Number.from(a.assertI32() + b.assertI32()); } -fn initModule(env: napi.Env, module: napi.Value) !void { - // Export a string - try module.setNamedProperty("greeting", try env.createStringUtf8("Hello from Zig!")); - - // Export a function - try module.setNamedProperty("add", try env.createFunction("add", 2, napi.createCallback(2, add, .{}), null)); +pub const Counter = struct { + pub const js_meta = js.class(.{ + .properties = .{ + .count = true, + }, + }); + + _count: i32, + + pub fn init(start: js.Number) Counter { + return .{ ._count = start.assertI32() }; + } + + pub fn increment(self: *Counter) void { + self._count += 1; + } + + // Getter: obj.count (not obj.count()) + pub fn count(self: Counter) js.Number { + return js.Number.from(self._count); + } +}; + +comptime { js.exportModule(@This(), .{}); } +``` + +**JavaScript usage:** + +```js +const mod = require('./my_module.node'); +mod.add(1, 2); // 3 +const c = new mod.Counter(0); +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. + +--- + +## JS Types Reference + +| Type | JS Equivalent | Key Methods | +|------|--------------|-------------| +| `Number` | `number` | `toI32()`, `toF64()`, `assertI32()`, `from(anytype)` | +| `String` | `string` | `toSlice(buf)`, `toOwnedSlice(alloc)`, `len()`, `from([]const u8)` | +| `Boolean` | `boolean` | `toBool()`, `assertBool()`, `from(bool)` | +| `BigInt` | `bigint` | `toI64()`, `toU64()`, `toI128()`, `from(anytype)` | +| `Date` | `Date` | `toTimestamp()`, `from(f64)` | +| `Array` | `Array` | `get(i)`, `getNumber(i)`, `length()`, `set(i, val)` | +| `Object(T)` | `object` | `get()`, `set(value)` — `T` fields must be DSL types | +| `Function` | `Function` | `call(args)` | +| `Value` | `any` | `isNumber()`, `asNumber()`, type checking/narrowing | +| `Uint8Array` etc. | `TypedArray` | `toSlice()`, `from(slice)` | +| `Promise(T)` | `Promise` | `resolve(value)`, `reject(err)` | + +--- + +## Functions + +Three patterns for exporting functions: + +### Basic — direct mapping + +```zig +pub fn add(a: Number, b: Number) Number { + return Number.from(a.assertI32() + b.assertI32()); } +``` -fn add(a: i32, b: i32) i32 { - return a + b; +### Error handling — `!T` becomes a thrown JS exception + +```zig +pub fn safeDivide(a: Number, b: Number) !Number { + const divisor = b.assertI32(); + if (divisor == 0) return error.DivisionByZero; + return Number.from(@divTrunc(a.assertI32(), divisor)); } ``` +JS: `try { safeDivide(10, 0) } catch (e) { /* "DivisionByZero" */ }` + +### Nullable returns — `?T` becomes `undefined` + +```zig +pub fn findValue(arr: Array, target: Number) ?Number { + const len = arr.length() catch return null; + // ... search, return null if not found +} +``` + +--- + +## Classes + +Structs with `js_meta = js.class(...)` are exported as JavaScript classes: + +```zig +pub const Timer = struct { + pub const js_meta = js.class(.{}); + start: i64, + + pub fn init() Timer { + return .{ .start = std.time.milliTimestamp() }; + } + + pub fn elapsed(self: Timer) js.Number { + return js.Number.from(std.time.milliTimestamp() - self.start); + } + + pub fn reset(self: *Timer) void { + self.start = std.time.milliTimestamp(); + } + + pub fn deinit(self: *Timer) void { + _ = self; + } +}; +``` + +**Method classification:** + +| Signature | JS Behavior | +|-----------|-------------| +| `pub fn init(...)` | Constructor (`new Class(...)`) — must return `T` or `!T` | +| `pub fn method(self: T, ...)` | Immutable instance method | +| `pub fn method(self: *T, ...)` | Mutable instance method | +| `pub fn method(self: T, ...) !T` | Instance method returning a new JS instance | +| `pub fn method(...) !T` | Static method returning a new JS instance | +| `pub fn method(...)` | Static method (no self, returns non-T) | +| `pub fn deinit(self: *T)` | Optional GC destructor | + +Methods or functions that return the class type automatically materialize a fresh JS instance. There is no separate author-facing "factory" marker: + +```zig +pub const PublicKey = struct { + pub const js_meta = js.class(.{}); + pk: bls.PublicKey, + + pub fn init() PublicKey { + return .{ .pk = undefined }; + } + + // Static factory: PublicKey.fromBytes(bytes) + pub fn fromBytes(bytes: js.Uint8Array) !PublicKey { + const slice = try bytes.toSlice(); + return .{ .pk = try bls.PublicKey.deserialize(slice) }; + } +}; +``` + +JS: `const pk = PublicKey.fromBytes(bytes);` + +Same-class instance methods also work: + +```zig +pub fn clone(self: MyState) !MyState { + const cloned = try self.data.clone(); + return .{ .data = cloned }; +} +``` + +JS: `const newState = state.clone();` — returns a new instance, original unchanged. + +### Optional Parameters + +Parameters with optional DSL types (`?js.Number`, `?js.Boolean`, etc.) become optional JS arguments: + +```zig +pub fn fromBytes(bytes: js.Uint8Array, validate: ?js.Boolean) !PublicKey { + const do_validate = if (validate) |v| try v.toBool() else false; + // ... +} +``` + +JS: `PublicKey.fromBytes(bytes)` or `PublicKey.fromBytes(bytes, true)` + +### Getters and Setters + +Declare properties inside `js_meta` to register computed or field-backed property accessors: + +```zig +pub const Config = struct { + pub const js_meta = js.class(.{ + .properties = .{ + .volume = js.prop(.{ .get = true, .set = true }), + .muted = js.prop(.{ .get = true, .set = true }), + .label = true, + }, + }); + + _volume: i32, + _muted: bool, + _label: []const u8, + + pub fn init() Config { + return .{ ._volume = 50, ._muted = false, ._label = "default" }; + } + + // Read-write: obj.volume / obj.volume = 80 + pub fn volume(self: Config) js.Number { + return js.Number.from(self._volume); + } + pub fn setVolume(self: *Config, value: js.Number) !void { + const v = value.assertI32(); + if (v < 0 or v > 100) return error.VolumeOutOfRange; + self._volume = v; + } + + // Read-only: obj.label + pub fn label(self: Config) js.String { + return js.String.from(self._label); + } +}; +``` + +JS: `cfg.volume = 80; cfg.label; // "default"` + +**Rules:** +- `pub const js_meta = js.class(.{})` marks a struct as a JS class +- `.properties = .{ .name = true }` registers a readonly computed getter backed by `pub fn name(...)` +- `.properties = .{ .name = js.field("field_name") }` registers a readonly field-backed property +- `.properties = .{ .name = js.prop(.{ .get = true, .set = true }) }` registers getter/setter methods using `name` and `setName` +- Accessor backing methods are not exported as callable JS methods + +--- + +## Working with Types + +### Typed Objects + +```zig +const Config = struct { host: String, port: Number, verbose: Boolean }; + +pub fn connect(config: Object(Config)) !String { + const c = try config.get(); + // access c.host, c.port, c.verbose +} +``` + +### TypedArrays + +```zig +pub fn sum(data: Uint8Array) !Number { + const slice = try data.toSlice(); + var total: i32 = 0; + for (slice) |byte| total += @intCast(byte); + return Number.from(total); +} +``` + +### Promises + +```zig +pub fn asyncOp(val: Number) !Promise(Number) { + var promise = try js.createPromise(Number); + try promise.resolve(val); // or dispatch async work + return promise; +} +``` + +### Callbacks + +```zig +pub fn applyCallback(val: Number, cb: Function) !Value { + return try cb.call(.{val}); +} +``` + +--- + +## Namespaces + +Import Zig modules as `pub const` to create JS namespaces. The DSL recursively registers all DSL-compatible declarations: + +```zig +// root.zig +pub const math = @import("math.zig"); // → exports.math.multiply(...) +pub const crypto = @import("crypto.zig"); // → exports.crypto.PublicKey, etc. + +comptime { js.exportModule(@This(), .{}); } +``` + +Namespaces nest arbitrarily — a sub-module with more `pub const` imports creates deeper nesting. + +--- + +## Module Lifecycle + +`exportModule` accepts optional lifecycle hooks with atomic env refcounting: + +```zig +comptime { + js.exportModule(@This(), .{ + .init = fn (refcount: u32) !void, // called before registration (0 = first env) + .cleanup = fn (refcount: u32) void, // called on env exit (0 = last env) + }); +} +``` + +This enables safe shared-state initialization for worker thread scenarios. + +--- + +## Mixing DSL and N-API + +```zig +pub fn advanced() !Value { + const e = js.env(); // access low-level napi.Env + const obj = try e.createObject(); + // use any napi.Env method... + return .{ .val = obj }; +} +``` + +**Context accessors:** + +| Function | Description | +|----------|-------------| +| `js.env()` | Current N-API environment (thread-local, set by DSL callbacks) | +| `js.allocator()` | C allocator for native allocations | +| `js.thisArg()` | JS `this` value (available inside instance methods/getters/setters) | + +--- + +## Advanced: Low-Level N-API + +The DSL layer handles most use cases. Drop down to the N-API layer when you need full control over handle scopes, async work, thread-safe functions, or other advanced features. + ### Core Types | Type | Description | @@ -86,6 +400,8 @@ fn add_manual(env: napi.Env, info: napi.CallbackInfo(2)) !napi.Value { Let zapi handle argument/return conversion: ```zig +const napi = @import("zapi").napi; + // Arguments and return value are automatically converted fn add(a: i32, b: i32) i32 { return a + b; @@ -118,9 +434,11 @@ napi.createCallback(2, myFunc, .{ ### Creating Classes ```zig +const napi = @import("zapi").napi; + const Timer = struct { start: i64, - + pub fn read(self: *Timer) i64 { return std.time.milliTimestamp() - self.start; } @@ -142,6 +460,8 @@ try env.defineClass( Run CPU-intensive work off the main thread: ```zig +const napi = @import("zapi").napi; + const Work = struct { a: i32, b: i32, @@ -170,6 +490,8 @@ try work.queue(); Call JavaScript from any thread: ```zig +const napi = @import("zapi").napi; + const tsfn = try env.createThreadsafeFunction( jsCallback, // JS function to call context, // User context @@ -190,10 +512,12 @@ try tsfn.call(&data, .blocking); All N-API calls return `NapiError` on failure: ```zig +const napi = @import("zapi").napi; + fn myFunction(env: napi.Env) !void { // Errors propagate naturally const value = try env.createStringUtf8("hello"); - + // Throw JavaScript errors try env.throwError("ERR_CODE", "Something went wrong"); try env.throwTypeError("ERR_TYPE", "Expected a number"); @@ -386,24 +710,18 @@ Resolution order: --- -## Example +## Examples -See the [example/](example/) directory for a comprehensive example including: -- String properties -- Functions with manual and automatic argument handling -- Classes with methods -- Async work with promises -- Thread-safe functions - -```bash -# Build the example -zig build - -# Test it -node example/test.js -``` +See the [examples/](examples/) directory for comprehensive examples including: +- All DSL types (Number, String, Boolean, BigInt, Date, Array, Object, TypedArrays, Promise) +- Error handling and nullable returns +- Classes with static factories, instance factories, and optional parameters +- Computed getters and setters +- Nested namespaces +- Module lifecycle hooks (init/cleanup with worker thread refcounting) +- Callbacks and mixed DSL/N-API usage +- Low-level N-API with manual registration ## License MIT - diff --git a/build.zig b/build.zig index 07d54dd..868b3c5 100644 --- a/build.zig +++ b/build.zig @@ -11,10 +11,19 @@ pub fn build(b: *std.Build) void { options_build_options.addOption([]const u8, "napi_version", option_napi_version); const options_module_build_options = options_build_options.createModule(); + const module_napi = b.createModule(.{ + .root_source_file = b.path("src/root.zig"), + .target = target, + .optimize = optimize, + }); + module_napi.addIncludePath(b.path("include")); + b.modules.put(b.dupe("napi"), module_napi) catch @panic("OOM"); + const module_zapi = b.createModule(.{ .root_source_file = b.path("src/root.zig"), .target = target, .optimize = optimize, + .link_libc = true, }); module_zapi.addIncludePath(b.path("include")); b.modules.put(b.dupe("zapi"), module_zapi) catch @panic("OOM"); @@ -65,8 +74,45 @@ pub fn build(b: *std.Build) void { tls_install_lib_example_type_tag.dependOn(&install_lib_example_type_tag.step); b.getInstallStep().dependOn(&install_lib_example_type_tag.step); + const module_example_js_dsl = b.createModule(.{ + .root_source_file = b.path("examples/js_dsl/mod.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + b.modules.put(b.dupe("example_js_dsl"), module_example_js_dsl) catch @panic("OOM"); + + const lib_example_js_dsl = b.addLibrary(.{ + .name = "example_js_dsl", + .root_module = module_example_js_dsl, + .linkage = .dynamic, + }); + + lib_example_js_dsl.linker_allow_shlib_undefined = true; + const install_lib_example_js_dsl = b.addInstallArtifact(lib_example_js_dsl, .{ + .dest_sub_path = "example_js_dsl.node", + }); + + const tls_install_lib_example_js_dsl = b.step("build-lib:example_js_dsl", "Install the example_js_dsl library"); + tls_install_lib_example_js_dsl.dependOn(&install_lib_example_js_dsl.step); + b.getInstallStep().dependOn(&install_lib_example_js_dsl.step); + const tls_run_test = b.step("test", "Run all tests"); + const test_napi = b.addTest(.{ + .name = "napi", + .root_module = module_napi, + .filters = b.option([][]const u8, "napi.filters", "napi test filters") orelse &[_][]const u8{}, + }); + const install_test_napi = b.addInstallArtifact(test_napi, .{}); + const tls_install_test_napi = b.step("build-test:napi", "Install the napi test"); + tls_install_test_napi.dependOn(&install_test_napi.step); + + const run_test_napi = b.addRunArtifact(test_napi); + const tls_run_test_napi = b.step("test:napi", "Run the napi test"); + tls_run_test_napi.dependOn(&run_test_napi.step); + tls_run_test.dependOn(&run_test_napi.step); + const test_zapi = b.addTest(.{ .name = "zapi", .root_module = module_zapi, @@ -109,9 +155,27 @@ pub fn build(b: *std.Build) void { tls_run_test_example_type_tag.dependOn(&run_test_example_type_tag.step); tls_run_test.dependOn(&run_test_example_type_tag.step); + const test_example_js_dsl = b.addTest(.{ + .name = "example_js_dsl", + .root_module = module_example_js_dsl, + .filters = b.option([][]const u8, "example_js_dsl.filters", "example_js_dsl test filters") orelse &[_][]const u8{}, + }); + const install_test_example_js_dsl = b.addInstallArtifact(test_example_js_dsl, .{}); + const tls_install_test_example_js_dsl = b.step("build-test:example_js_dsl", "Install the example_js_dsl test"); + tls_install_test_example_js_dsl.dependOn(&install_test_example_js_dsl.step); + + const run_test_example_js_dsl = b.addRunArtifact(test_example_js_dsl); + const tls_run_test_example_js_dsl = b.step("test:example_js_dsl", "Run the example_js_dsl test"); + tls_run_test_example_js_dsl.dependOn(&run_test_example_js_dsl.step); + tls_run_test.dependOn(&run_test_example_js_dsl.step); + + module_napi.addImport("build_options", options_module_build_options); + module_zapi.addImport("build_options", options_module_build_options); module_example_hello_world.addImport("zapi", module_zapi); module_example_type_tag.addImport("zapi", module_zapi); + + module_example_js_dsl.addImport("zapi", module_zapi); } diff --git a/examples/js_dsl/math.zig b/examples/js_dsl/math.zig new file mode 100644 index 0000000..ca9cce5 --- /dev/null +++ b/examples/js_dsl/math.zig @@ -0,0 +1,17 @@ +// examples/js_dsl/math.zig +const js = @import("zapi").js; +const Number = js.Number; + +/// Multiply two numbers. +pub fn multiply(a: Number, b: Number) Number { + return Number.from(a.assertI32() * b.assertI32()); +} + +/// Square a number. +pub fn square(a: Number) Number { + const v = a.assertI32(); + return Number.from(v * v); +} + +/// Nested sub-module for utility functions. +pub const utils = @import("utils.zig"); diff --git a/examples/js_dsl/mod.test.ts b/examples/js_dsl/mod.test.ts new file mode 100644 index 0000000..d21840e --- /dev/null +++ b/examples/js_dsl/mod.test.ts @@ -0,0 +1,441 @@ +import { describe, it, expect } from "vitest"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const mod = require("../../zig-out/lib/example_js_dsl.node"); + +// Section 1: Basic Functions +describe("basic functions", () => { + it("add two numbers", () => { + expect(mod.add(1, 2)).toEqual(3); + }); + + it("add negative numbers", () => { + expect(mod.add(-5, 3)).toEqual(-2); + }); + + it("greet returns formatted string", () => { + expect(mod.greet("World")).toEqual("Hello, World!"); + }); +}); + +// Section 2: Error Handling +describe("error handling", () => { + it("safeDivide returns result", () => { + expect(mod.safeDivide(10, 3)).toEqual(3); + }); + + it("safeDivide throws on zero", () => { + expect(() => mod.safeDivide(10, 0)).toThrow(); + }); + + it("findValue returns index when found", () => { + expect(mod.findValue([10, 20, 30], 20)).toEqual(1); + }); + + it("findValue returns undefined when not found", () => { + expect(mod.findValue([10, 20, 30], 99)).toBeUndefined(); + }); +}); + +// Section 3: All Primitive Types +describe("primitive types", () => { + it("doubleNumber", () => { + expect(mod.doubleNumber(21)).toEqual(42); + }); + + it("toggleBool", () => { + expect(mod.toggleBool(true)).toBe(false); + expect(mod.toggleBool(false)).toBe(true); + }); + + it("reverseString", () => { + expect(mod.reverseString("hello")).toEqual("olleh"); + expect(mod.reverseString("a")).toEqual("a"); + }); + + it("doubleBigInt", () => { + expect(mod.doubleBigInt(50n)).toEqual(100n); + }); + + it("largeUnsignedBoundary survives u64 values above i64 max", () => { + const value = mod.largeUnsignedBoundary(); + expect(Number.isFinite(value)).toBe(true); + expect(value).toEqual(2 ** 63); + }); + + it("tomorrow adds one day", () => { + const now = new Date("2025-01-01T00:00:00Z"); + const result = mod.tomorrow(now); + expect(result).toBeInstanceOf(Date); + expect(result.toISOString()).toEqual("2025-01-02T00:00:00.000Z"); + }); +}); + +// Section 4: Typed Objects +describe("typed objects", () => { + it("formatConfig returns formatted string", () => { + const config = { host: "localhost", port: 8080, verbose: true }; + expect(mod.formatConfig(config)).toEqual("localhost:8080 (verbose: true)"); + }); + + it("formatConfig with verbose false", () => { + const config = { host: "example.com", port: 443, verbose: false }; + expect(mod.formatConfig(config)).toEqual("example.com:443 (verbose: false)"); + }); +}); + +// Section 5: Arrays +describe("arrays", () => { + it("arraySum sums all elements", () => { + expect(mod.arraySum([1, 2, 3, 4])).toEqual(10); + }); + + it("arraySum of empty array", () => { + expect(mod.arraySum([])).toEqual(0); + }); + + it("arrayLength returns length", () => { + expect(mod.arrayLength([10, 20, 30])).toEqual(3); + }); +}); + +// Section 6: TypedArrays +describe("typed arrays", () => { + it("uint8Sum sums bytes", () => { + const data = new Uint8Array([1, 2, 3, 4, 5]); + expect(mod.uint8Sum(data)).toEqual(15); + }); + + it("float64Scale scales values", () => { + const data = new Float64Array([1.0, 2.0, 3.0]); + const result = mod.float64Scale(data, 2.5); + expect(result).toBeInstanceOf(Float64Array); + expect(Array.from(result)).toEqual([2.5, 5.0, 7.5]); + }); +}); + +// Section 7: Promises +describe("promises", () => { + it("resolvedPromise resolves with value", async () => { + const result = await mod.resolvedPromise(42); + expect(result).toEqual(42); + }); +}); + +// Section 8: Callbacks +describe("callbacks", () => { + it("applyCallback invokes function", () => { + const result = mod.applyCallback(5, (n: number) => n * 3); + expect(result).toEqual(15); + }); +}); + +// Section 9: Classes +describe("Counter class", () => { + it("creates with initial value", () => { + const c = new mod.Counter(5); + expect(c.getCount()).toEqual(5); + }); + + it("increments", () => { + const c = new mod.Counter(0); + c.increment(); + c.increment(); + expect(c.getCount()).toEqual(2); + }); + + it("isAbove returns boolean", () => { + const c = new mod.Counter(10); + expect(c.isAbove(5)).toBe(true); + expect(c.isAbove(15)).toBe(false); + }); +}); + +describe("Buffer class", () => { + it("creates with size", () => { + const b = new mod.Buffer(16); + expect(b.getSize()).toEqual(16); + }); + + it("getByte returns zero-initialized data", () => { + const b = new mod.Buffer(4); + expect(b.getByte(0)).toEqual(0); + expect(b.getByte(3)).toEqual(0); + }); + + it("getByte throws on out of bounds", () => { + const b = new mod.Buffer(4); + expect(() => b.getByte(4)).toThrow(); + }); +}); + +// Section 10: Mixed DSL + N-API +describe("mixed DSL + N-API", () => { + it("getTypeOf coerces value to string", () => { + expect(mod.getTypeOf(42)).toEqual("42"); + expect(mod.getTypeOf(true)).toEqual("true"); + expect(mod.getTypeOf("hello")).toEqual("hello"); + }); + + it("makeObject creates object with property", () => { + const obj = mod.makeObject("x", 10); + expect(obj).toEqual({ x: 10 }); + }); +}); + +// Section 11: Module Lifecycle +describe("module lifecycle", () => { + it("init was called at least once", () => { + expect(mod.getInitCount()).toBeGreaterThanOrEqual(1); + }); + + it("first init received refcount 0", () => { + expect(mod.getFirstRefcount()).toEqual(0); + }); + + it("current refcount is at least 1", () => { + expect(mod.getEnvRefcount()).toBeGreaterThanOrEqual(1); + }); +}); + +// Section 12: Nested Namespaces +describe("nested namespaces", () => { + it("math.multiply", () => { + expect(mod.math.multiply(3, 4)).toEqual(12); + }); + + it("math.square", () => { + expect(mod.math.square(5)).toEqual(25); + }); + + it("math.utils.clamp within range", () => { + expect(mod.math.utils.clamp(5, 0, 10)).toEqual(5); + }); + + it("math.utils.clamp below min", () => { + expect(mod.math.utils.clamp(-5, 0, 10)).toEqual(0); + }); + + it("math.utils.clamp above max", () => { + expect(mod.math.utils.clamp(15, 0, 10)).toEqual(10); + }); +}); + +// Section 13: Static Factory Methods + Optional Parameters +describe("static factories", () => { + it("Point.create returns new instance", () => { + const p = mod.Point.create(3, 4); + expect(p.getX()).toEqual(3); + expect(p.getY()).toEqual(4); + }); + + it("Point.fromArray without offset", () => { + const p = mod.Point.fromArray([10, 20]); + expect(p.getX()).toEqual(10); + expect(p.getY()).toEqual(20); + }); + + it("Point.fromArray with offset", () => { + const p = mod.Point.fromArray([0, 0, 5, 7], 2); + expect(p.getX()).toEqual(5); + expect(p.getY()).toEqual(7); + }); + + it("new Point() creates zero point", () => { + const p = new mod.Point(); + expect(p.getX()).toEqual(0); + expect(p.getY()).toEqual(0); + }); + + it("preserves subclass constructor for same-class static returns", () => { + class DerivedPoint extends mod.Point {} + const p = DerivedPoint.create(3, 4); + expect(p).toBeInstanceOf(DerivedPoint); + expect(p.getX()).toEqual(3); + }); +}); + +describe("optional parameters", () => { + it("translate with both args", () => { + const p = mod.Point.create(1, 1); + p.translate(5, 10); + expect(p.getX()).toEqual(6); + expect(p.getY()).toEqual(11); + }); + + it("translate with optional omitted", () => { + const p = mod.Point.create(1, 1); + p.translate(5); + expect(p.getX()).toEqual(6); + expect(p.getY()).toEqual(1); + }); + + it("translate treats explicit undefined like omitted optional", () => { + const p = mod.Point.create(1, 1); + p.translate(5, undefined); + expect(p.getX()).toEqual(6); + expect(p.getY()).toEqual(1); + }); + + it("fromArray treats explicit undefined like omitted optional", () => { + const p = mod.Point.fromArray([10, 20], undefined); + expect(p.getX()).toEqual(10); + expect(p.getY()).toEqual(20); + }); +}); + +describe("class materialization", () => { + it("static class return avoids constructor placeholder allocation", () => { + const initBefore = mod.getFactoryResourceInitCount(); + const deinitBefore = mod.getFactoryResourceDeinitCount(); + + const resource = mod.FactoryResource.withByte(7); + + expect(resource.getByte()).toEqual(7); + expect(mod.getFactoryResourceInitCount()).toEqual(initBefore + 1); + expect(mod.getFactoryResourceDeinitCount()).toEqual(deinitBefore); + }); + + it("instance class return avoids constructor placeholder allocation", () => { + const base = mod.FactoryResource.withByte(1); + const initBefore = mod.getFactoryResourceInitCount(); + const deinitBefore = mod.getFactoryResourceDeinitCount(); + + const clone = base.cloneWithByte(9); + + expect(clone.getByte()).toEqual(9); + expect(mod.getFactoryResourceInitCount()).toEqual(initBefore + 1); + expect(mod.getFactoryResourceDeinitCount()).toEqual(deinitBefore); + }); + + it("preserves subclass constructor for same-class instance returns", () => { + class DerivedFactoryResource extends mod.FactoryResource {} + const base = DerivedFactoryResource.withByte(1); + const clone = base.cloneWithByte(9); + expect(clone).toBeInstanceOf(DerivedFactoryResource); + expect(clone.getByte()).toEqual(9); + }); +}); + +// Section 15: Getters and Setters +describe("Settings class (getters/setters)", () => { + it("has getter for volume with default value", () => { + const s = new mod.Settings(); + expect(s.volume).toEqual(50); + }); + + it("has setter for volume", () => { + const s = new mod.Settings(); + s.volume = 80; + expect(s.volume).toEqual(80); + }); + + it("setter validates volume range", () => { + const s = new mod.Settings(); + expect(() => { s.volume = 101; }).toThrow(); + expect(() => { s.volume = -1; }).toThrow(); + expect(s.volume).toEqual(50); // unchanged after errors + }); + + it("has getter/setter for muted", () => { + const s = new mod.Settings(); + expect(s.muted).toBe(false); + s.muted = true; + expect(s.muted).toBe(true); + }); + + it("has read-only getter for label", () => { + const s = new mod.Settings(); + expect(s.label).toEqual("default"); + // In ESM strict mode, assigning to a getter-only property throws TypeError + expect(() => { (s as any).label = "changed"; }).toThrow(); + }); + + it("has read-only field-backed property", () => { + const s = new mod.Settings(); + expect(s.kind).toEqual("settings"); + expect(() => { (s as any).kind = "changed"; }).toThrow(); + }); + + it("getter is not callable as a method", () => { + const s = new mod.Settings(); + expect(typeof s.volume).toBe("number"); + expect(typeof s.volume).not.toBe("function"); + }); + + it("reset method still works alongside getters", () => { + const s = new mod.Settings(); + s.volume = 80; + s.muted = true; + s.reset(); + expect(s.volume).toEqual(50); + expect(s.muted).toBe(false); + }); + + it("multiple instances have independent state", () => { + const s1 = new mod.Settings(); + const s2 = new mod.Settings(); + s1.volume = 10; + s2.volume = 90; + expect(s1.volume).toEqual(10); + expect(s2.volume).toEqual(90); + }); +}); + +describe("class return interop", () => { + it("free function returning class materializes a Token instance", () => { + const token = mod.makeToken(4); + expect(token).toBeInstanceOf(mod.Token); + expect(token.getValue()).toEqual(5); + }); + + it("instance method returning different class materializes a Token instance", () => { + const issuer = new mod.TokenIssuer(6); + const token = issuer.issue(); + expect(token).toBeInstanceOf(mod.Token); + expect(token.getValue()).toEqual(12); + }); +}); + +describe("module lifecycle - worker threads", () => { + it("worker thread increments refcount and cleanup decrements it", async () => { + const { Worker } = await import("node:worker_threads"); + const { resolve } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + + const refcountBefore = mod.getEnvRefcount(); + + // Build an absolute path to the .node file so the worker can load it + const thisDir = fileURLToPath(new URL(".", import.meta.url)); + const nativePath = resolve(thisDir, "../../zig-out/lib/example_js_dsl.node"); + + // Spawn a worker that loads the same native module + const worker = new Worker( + ` + const { parentPort, workerData } = require("node:worker_threads"); + const m = require(workerData.nativePath); + parentPort.postMessage({ refcount: m.getEnvRefcount() }); + `, + { eval: true, workerData: { nativePath } }, + ); + + // Worker should see an incremented refcount + const workerRefcount = await new Promise((resolve) => { + worker.on("message", (msg) => { + resolve(msg.refcount); + }); + }); + expect(workerRefcount).toBeGreaterThan(refcountBefore); + + // Wait for worker to exit (triggers cleanup hook) + await new Promise((resolve) => { + worker.on("exit", () => resolve(undefined)); + }); + + // After worker exits, refcount should be back to what it was + // Give a small delay for cleanup hook to fire + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(mod.getEnvRefcount()).toEqual(refcountBefore); + }); +}); diff --git a/examples/js_dsl/mod.zig b/examples/js_dsl/mod.zig new file mode 100644 index 0000000..67e7683 --- /dev/null +++ b/examples/js_dsl/mod.zig @@ -0,0 +1,517 @@ +//! Comprehensive DSL example demonstrating all zapi.js types and patterns. + +const std = @import("std"); +const js = @import("zapi").js; +const Number = js.Number; +const String = js.String; +const Boolean = js.Boolean; +const BigInt = js.BigInt; +const Date = js.Date; +const Array = js.Array; +const Object = js.Object; +const Function = js.Function; +const Value = js.Value; +const Uint8Array = js.Uint8Array; +const Float64Array = js.Float64Array; +const Promise = js.Promise; + +/// Sub-module demonstrating nested namespaces. +pub const math = @import("math.zig"); + +// ============================================================================ +// Section 1: Basic Functions +// ============================================================================ + +/// Add two numbers. +pub fn add(a: Number, b: Number) Number { + return Number.from(a.assertI32() + b.assertI32()); +} + +/// Return a greeting string. +pub fn greet(name: String) !String { + var buf: [256]u8 = undefined; + const slice = try name.toSlice(&buf); + var result: [512]u8 = undefined; + const greeting = "Hello, "; + @memcpy(result[0..greeting.len], greeting); + @memcpy(result[greeting.len .. greeting.len + slice.len], slice); + const total_len = greeting.len + slice.len; + result[total_len] = '!'; + return String.from(result[0 .. total_len + 1]); +} + +// ============================================================================ +// Section 2: Error Handling +// ============================================================================ + +/// Divide two numbers. Throws on division by zero. +pub fn safeDivide(a: Number, b: Number) !Number { + const divisor = b.assertI32(); + if (divisor == 0) return error.DivisionByZero; + return Number.from(@divTrunc(a.assertI32(), divisor)); +} + +/// Find index of target in array. Returns undefined if not found. +pub fn findValue(arr: Array, target: Number) ?Number { + const len = arr.length() catch return null; + const t = target.assertI32(); + var i: u32 = 0; + while (i < len) : (i += 1) { + const item = arr.getNumber(i) catch continue; + if (item.assertI32() == t) return Number.from(i); + } + return null; +} + +// ============================================================================ +// Section 3: All Primitive Types +// ============================================================================ + +/// Double a number. +pub fn doubleNumber(n: Number) Number { + return Number.from(n.assertI32() * 2); +} + +/// Return a Number created from a u64 value just above i64 max. +pub fn largeUnsignedBoundary() Number { + return Number.from(@as(u64, std.math.maxInt(i64)) + 1); +} + +/// Negate a boolean. +pub fn toggleBool(b: Boolean) Boolean { + return Boolean.from(!b.assertBool()); +} + +/// Reverse a string. +pub fn reverseString(s: String) !String { + var buf: [256]u8 = undefined; + const slice = try s.toSlice(&buf); + var reversed: [256]u8 = undefined; + for (slice, 0..) |ch, i| { + reversed[slice.len - 1 - i] = ch; + } + return String.from(reversed[0..slice.len]); +} + +/// Double a BigInt value. +pub fn doubleBigInt(n: BigInt) !BigInt { + var lossless: bool = false; + const val = try n.toI64(&lossless); + return BigInt.from(val * 2); +} + +/// Add one day (86400000ms) to a Date. +pub fn tomorrow(d: Date) Date { + const ts = d.assertTimestamp(); + return Date.from(ts + 86_400_000.0); +} + +// ============================================================================ +// Section 4: Typed Objects +// ============================================================================ + +const Config = struct { host: String, port: Number, verbose: Boolean }; + +/// Format a config object as a string: "host:port (verbose: true/false)" +pub fn formatConfig(config: Object(Config)) !String { + const c = try config.get(); + var host_buf: [128]u8 = undefined; + const host = try c.host.toSlice(&host_buf); + const port = c.port.assertI32(); + const verbose = c.verbose.assertBool(); + + var result: [256]u8 = undefined; + const written = std.fmt.bufPrint(&result, "{s}:{d} (verbose: {s})", .{ + host, + port, + if (verbose) "true" else "false", + }) catch return error.FormatError; + return String.from(written); +} + +// ============================================================================ +// Section 5: Arrays +// ============================================================================ + +/// Sum all numbers in an array. +pub fn arraySum(arr: Array) Number { + const len = arr.length() catch return Number.from(@as(i32, 0)); + var sum: i32 = 0; + var i: u32 = 0; + while (i < len) : (i += 1) { + const item = arr.getNumber(i) catch continue; + sum += item.assertI32(); + } + return Number.from(sum); +} + +/// Return the length of an array. +pub fn arrayLength(arr: Array) !Number { + const len = try arr.length(); + return Number.from(len); +} + +// ============================================================================ +// Section 6: TypedArrays +// ============================================================================ + +/// Sum all bytes in a Uint8Array. +pub fn uint8Sum(data: Uint8Array) !Number { + const slice = try data.toSlice(); + var sum: i32 = 0; + for (slice) |byte| { + sum += @intCast(byte); + } + return Number.from(sum); +} + +/// Scale all values in a Float64Array by a factor. Returns a new array. +pub fn float64Scale(data: Float64Array, factor: Number) !Float64Array { + const slice = try data.toSlice(); + const f = factor.assertF64(); + const alloc = js.allocator(); + const scaled = try alloc.alloc(f64, slice.len); + defer alloc.free(scaled); + for (slice, 0..) |val, i| { + scaled[i] = val * f; + } + return Float64Array.from(scaled); +} + +// ============================================================================ +// Section 7: Promises +// ============================================================================ + +/// Create a promise that resolves immediately with the given value. +pub fn resolvedPromise(val: Number) !Promise(Number) { + var promise = try js.createPromise(Number); + try promise.resolve(val); + return promise; +} + +// ============================================================================ +// Section 8: Callbacks +// ============================================================================ + +/// Apply a callback function to a value and return the result. +pub fn applyCallback(val: Number, cb: Function) !Value { + return try cb.call(.{val}); +} + +// ============================================================================ +// Section 9: Classes +// ============================================================================ + +/// A simple counter class. +pub const Counter = struct { + pub const js_meta = js.class(.{}); + count: i32, + + pub fn init(start: Number) Counter { + return .{ .count = start.assertI32() }; + } + + pub fn increment(self: *Counter) void { + self.count += 1; + } + + pub fn getCount(self: Counter) Number { + return Number.from(self.count); + } + + pub fn isAbove(self: Counter, threshold: Number) Boolean { + return Boolean.from(self.count > threshold.assertI32()); + } +}; + +/// A resource-owning buffer class demonstrating deinit. +pub const Buffer = struct { + pub const js_meta = js.class(.{}); + data: []u8, + + pub fn init(size: Number) !Buffer { + const len: usize = @intCast(size.assertI32()); + const alloc = js.allocator(); + const data = try alloc.alloc(u8, len); + @memset(data, 0); + return .{ .data = data }; + } + + pub fn getSize(self: Buffer) Number { + return Number.from(@as(i32, @intCast(self.data.len))); + } + + pub fn getByte(self: Buffer, index: Number) !Number { + const i: usize = @intCast(index.assertI32()); + if (i >= self.data.len) return error.IndexOutOfBounds; + return Number.from(@as(i32, @intCast(self.data[i]))); + } + + pub fn deinit(self: *Buffer) void { + js.allocator().free(self.data); + } +}; + +// ============================================================================ +// Section 10: Mixed DSL + N-API +// ============================================================================ + +/// Return the JS typeof string for any value. +/// Demonstrates dropping down to low-level napi to call raw N-API methods. +pub fn getTypeOf(val: Value) !String { + // Use the low-level napi.Value to coerce value to string via N-API + const coerced = try val.toValue().coerceToString(); + // Then wrap it back into the DSL String type + return .{ .val = coerced }; +} + +/// Create a JS object with a property, using low-level env for object creation. +pub fn makeObject(key: String, value: Number) !Value { + const e = js.env(); + // Use low-level Env to create a plain JS object + const obj = try e.createObject(); + // Use low-level property setting with DSL values + var key_buf: [128]u8 = undefined; + const key_slice = try key.toSlice(&key_buf); + var name_buf: [129]u8 = undefined; + @memcpy(name_buf[0..key_slice.len], key_slice); + name_buf[key_slice.len] = 0; + const name: [:0]const u8 = name_buf[0..key_slice.len :0]; + try obj.setNamedProperty(name, value.toValue()); + return .{ .val = obj }; +} + +// ============================================================================ +// Section 11: Module Lifecycle (init/cleanup with env refcounting) +// ============================================================================ + +/// Tracks how many env registrations have occurred. +var module_init_count: std.atomic.Value(u32) = std.atomic.Value(u32).init(0); + +/// Captures the refcount value passed to init on the very first call. +var first_init_refcount: std.atomic.Value(u32) = std.atomic.Value(u32).init(999); + +/// Tracks the current env refcount as seen by the DSL (exposed for testing). +var current_refcount: std.atomic.Value(u32) = std.atomic.Value(u32).init(0); + +/// Returns how many times init has been called. +pub fn getInitCount() Number { + return Number.from(@as(i32, @intCast(module_init_count.load(.acquire)))); +} + +/// Returns the refcount value that was passed to init on the first call. +pub fn getFirstRefcount() Number { + return Number.from(@as(i32, @intCast(first_init_refcount.load(.acquire)))); +} + +/// Returns the current env refcount. +pub fn getEnvRefcount() Number { + return Number.from(@as(i32, @intCast(current_refcount.load(.acquire)))); +} + +// ============================================================================ +// Section 13: Static Factory Methods + Optional Parameters +// ============================================================================ + +var factory_resource_init_count: std.atomic.Value(u32) = std.atomic.Value(u32).init(0); +var factory_resource_deinit_count: std.atomic.Value(u32) = std.atomic.Value(u32).init(0); + +fn makeFactoryResource(byte: u8) !FactoryResource { + const data = try js.allocator().alloc(u8, 1); + data[0] = byte; + _ = factory_resource_init_count.fetchAdd(1, .monotonic); + return .{ .data = data }; +} + +pub fn getFactoryResourceInitCount() Number { + return Number.from(@as(i32, @intCast(factory_resource_init_count.load(.acquire)))); +} + +pub fn getFactoryResourceDeinitCount() Number { + return Number.from(@as(i32, @intCast(factory_resource_deinit_count.load(.acquire)))); +} + +/// A point class demonstrating static factories and optional params. +pub const Point = struct { + pub const js_meta = js.class(.{}); + x: i32, + y: i32, + + pub fn init() Point { + return .{ .x = 0, .y = 0 }; + } + + /// Static factory: Point.create(x, y) + pub fn create(x: Number, y: Number) Point { + return .{ .x = x.assertI32(), .y = y.assertI32() }; + } + + /// Static factory with optional: Point.fromArray(arr, offset?) + pub fn fromArray(arr: Array, offset: ?Number) !Point { + const off: u32 = if (offset) |o| o.assertU32() else 0; + const x_val = try arr.getNumber(off); + const y_val = try arr.getNumber(off + 1); + return .{ .x = x_val.assertI32(), .y = y_val.assertI32() }; + } + + /// Instance method + pub fn getX(self: Point) Number { + return Number.from(self.x); + } + + pub fn getY(self: Point) Number { + return Number.from(self.y); + } + + /// Instance method with optional param + pub fn translate(self: *Point, dx: Number, dy: ?Number) void { + self.x += dx.assertI32(); + if (dy) |d| { + self.y += d.assertI32(); + } + } +}; + +/// A resource-owning class used to verify placeholder cleanup in factory paths. +pub const FactoryResource = struct { + pub const js_meta = js.class(.{}); + data: []u8, + + pub fn init() !FactoryResource { + return makeFactoryResource(0); + } + + pub fn withByte(value: Number) !FactoryResource { + return makeFactoryResource(@intCast(value.assertU32())); + } + + pub fn cloneWithByte(self: FactoryResource, value: Number) !FactoryResource { + _ = self; + return makeFactoryResource(@intCast(value.assertU32())); + } + + pub fn getByte(self: FactoryResource) Number { + return Number.from(@as(i32, @intCast(self.data[0]))); + } + + pub fn deinit(self: *FactoryResource) void { + _ = factory_resource_deinit_count.fetchAdd(1, .monotonic); + js.allocator().free(self.data); + } +}; + +// ============================================================================ +// Module Export +// ============================================================================ + +// ============================================================================ +// Section 15: Getters and Setters +// ============================================================================ + +/// A settings class demonstrating computed getters and setters. +pub const Settings = struct { + pub const js_meta = js.class(.{ + .properties = .{ + .volume = js.prop(.{ .get = true, .set = true }), + .muted = js.prop(.{ .get = true, .set = true }), + .label = true, + .kind = js.field("_kind"), + }, + }); + + _volume: i32, + _muted: bool, + _label: []const u8, + _kind: []const u8, + + pub fn init() Settings { + return .{ + ._volume = 50, + ._muted = false, + ._label = "default", + ._kind = "settings", + }; + } + + // Read-write getter/setter: obj.volume / obj.volume = 80 + pub fn volume(self: Settings) Number { + return Number.from(self._volume); + } + + pub fn setVolume(self: *Settings, value: Number) !void { + const v = value.assertI32(); + if (v < 0 or v > 100) return error.VolumeOutOfRange; + self._volume = v; + } + + // Read-write getter/setter: obj.muted / obj.muted = true + pub fn muted(self: Settings) Boolean { + return Boolean.from(self._muted); + } + + pub fn setMuted(self: *Settings, value: Boolean) void { + self._muted = value.assertBool(); + } + + // Read-only getter: obj.label + pub fn label(self: Settings) String { + return String.from(self._label); + } + + // Regular method (not a getter) + pub fn reset(self: *Settings) void { + self._volume = 50; + self._muted = false; + } +}; + +pub const Token = struct { + pub const js_meta = js.class(.{}); + + value: i32, + + pub fn init(value: Number) Token { + return .{ .value = value.assertI32() }; + } + + pub fn getValue(self: Token) Number { + return Number.from(self.value); + } +}; + +pub const TokenIssuer = struct { + pub const js_meta = js.class(.{}); + + seed: i32, + + pub fn init(seed: Number) TokenIssuer { + return .{ .seed = seed.assertI32() }; + } + + pub fn issue(self: TokenIssuer) Token { + return .{ .value = self.seed * 2 }; + } +}; + +pub fn makeToken(value: Number) Token { + return .{ .value = value.assertI32() + 1 }; +} + +comptime { + js.exportModule(@This(), .{ + .init = struct { + fn f(refcount: u32) !void { + const count = module_init_count.fetchAdd(1, .monotonic); + if (count == 0) { + first_init_refcount.store(refcount, .release); + } + current_refcount.store(refcount + 1, .release); + } + }.f, + .cleanup = struct { + fn f(refcount: u32) void { + current_refcount.store(refcount, .release); + } + }.f, + }); +} diff --git a/examples/js_dsl/utils.zig b/examples/js_dsl/utils.zig new file mode 100644 index 0000000..791616d --- /dev/null +++ b/examples/js_dsl/utils.zig @@ -0,0 +1,13 @@ +// examples/js_dsl/utils.zig +const js = @import("zapi").js; +const Number = js.Number; + +/// Clamp a number between min and max. +pub fn clamp(val: Number, min_val: Number, max_val: Number) Number { + const v = val.assertI32(); + const lo = min_val.assertI32(); + const hi = max_val.assertI32(); + if (v < lo) return Number.from(lo); + if (v > hi) return Number.from(hi); + return Number.from(v); +} diff --git a/src/js.zig b/src/js.zig new file mode 100644 index 0000000..8cc38a7 --- /dev/null +++ b/src/js.zig @@ -0,0 +1,87 @@ +const napi = @import("napi.zig"); +const context = @import("js/context.zig"); +const typed_arrays = @import("js/typed_arrays.zig"); +const class_meta = @import("js/class_meta.zig"); + +// Context +pub const env = context.env; +pub const allocator = context.allocator; +pub const setEnv = context.setEnv; +pub const restoreEnv = context.restoreEnv; +pub const thisArg = context.thisArg; +pub const setThis = context.setThis; +pub const restoreThis = context.restoreThis; + +// Primitive types +pub const Number = @import("js/number.zig").Number; +pub const String = @import("js/string.zig").String; +pub const Boolean = @import("js/boolean.zig").Boolean; +pub const BigInt = @import("js/bigint.zig").BigInt; +pub const Date = @import("js/date.zig").Date; + +// Complex types +pub const Array = @import("js/array.zig").Array; +pub const Object = @import("js/object.zig").Object; +pub const Function = @import("js/function.zig").Function; +pub const Value = @import("js/value.zig").Value; + +// TypedArrays +pub const TypedArray = typed_arrays.TypedArray; +pub const Int8Array = typed_arrays.Int8Array; +pub const Uint8Array = typed_arrays.Uint8Array; +pub const Uint8ClampedArray = typed_arrays.Uint8ClampedArray; +pub const Int16Array = typed_arrays.Int16Array; +pub const Uint16Array = typed_arrays.Uint16Array; +pub const Int32Array = typed_arrays.Int32Array; +pub const Uint32Array = typed_arrays.Uint32Array; +pub const Float32Array = typed_arrays.Float32Array; +pub const Float64Array = typed_arrays.Float64Array; +pub const BigInt64Array = typed_arrays.BigInt64Array; +pub const BigUint64Array = typed_arrays.BigUint64Array; + +// Promise +pub const Promise = @import("js/promise.zig").Promise; +pub const createPromise = @import("js/promise.zig").createPromise; + +// Comptime machinery +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; +pub const class = class_meta.class; +pub const field = class_meta.field; +pub const prop = class_meta.prop; + +// Comptime helpers (public for advanced use) +pub const isDslType = @import("js/wrap_function.zig").isDslType; +pub const convertArg = @import("js/wrap_function.zig").convertArg; +pub const convertReturn = @import("js/wrap_function.zig").convertReturn; +pub const callAndConvert = @import("js/wrap_function.zig").callAndConvert; + +/// Throws a JS Error with the given message. +pub fn throwError(message: [:0]const u8) void { + const e = context.env(); + e.throwError("", message) catch {}; +} + +test { + // Reference all sub-modules so their tests run, but avoid refAllDecls + // which would force-link free functions (throwError) against C symbols + // unavailable in the native test runner. + _ = @import("js/context.zig"); + _ = @import("js/number.zig"); + _ = @import("js/string.zig"); + _ = @import("js/boolean.zig"); + _ = @import("js/bigint.zig"); + _ = @import("js/date.zig"); + _ = @import("js/array.zig"); + _ = @import("js/object.zig"); + _ = @import("js/function.zig"); + _ = @import("js/typed_arrays.zig"); + _ = @import("js/promise.zig"); + _ = @import("js/value.zig"); + _ = @import("js/wrap_function.zig"); + _ = @import("js/wrap_class.zig"); + _ = @import("js/export_module.zig"); + _ = @import("js/class_meta.zig"); + _ = @import("js/class_runtime.zig"); +} diff --git a/src/js/array.zig b/src/js/array.zig new file mode 100644 index 0000000..72ed8a4 --- /dev/null +++ b/src/js/array.zig @@ -0,0 +1,78 @@ +const napi = @import("../napi.zig"); +const context = @import("context.zig"); +const Number = @import("number.zig").Number; +const String = @import("string.zig").String; +const Boolean = @import("boolean.zig").Boolean; + +pub const Array = struct { + val: napi.Value, + + /// Returns the element at `index` as an untyped Value. + pub fn get(self: Array, index: u32) !@import("value.zig").Value { + const element = try self.val.getElement(index); + return .{ .val = element }; + } + + /// Returns the element at `index` narrowed to a Number. + /// Returns error.TypeMismatch if the element is not a number. + pub fn getNumber(self: Array, index: u32) !Number { + const element = try self.val.getElement(index); + if ((try element.typeof()) != .number) return error.TypeMismatch; + return .{ .val = element }; + } + + /// Returns the element at `index` narrowed to a String. + /// Returns error.TypeMismatch if the element is not a string. + pub fn getString(self: Array, index: u32) !String { + const element = try self.val.getElement(index); + if ((try element.typeof()) != .string) return error.TypeMismatch; + return .{ .val = element }; + } + + /// Returns the element at `index` narrowed to a Boolean. + /// Returns error.TypeMismatch if the element is not a boolean. + pub fn getBoolean(self: Array, index: u32) !Boolean { + const element = try self.val.getElement(index); + if ((try element.typeof()) != .boolean) return error.TypeMismatch; + return .{ .val = element }; + } + + /// Returns the length of the array. + pub fn length(self: Array) !u32 { + return self.val.getArrayLength(); + } + + /// Sets the element at `index`. Accepts any DSL wrapper type (anything with + /// a `.val` field of type `napi.Value`) or a raw `napi.Value`. + pub fn set(self: Array, index: u32, item: anytype) !void { + const raw = toNapiValue(item); + try self.val.setElement(index, raw); + } + + /// Creates a new empty JS array. + pub fn create() Array { + const e = context.env(); + const val = e.createArray() catch @panic("Array.create failed"); + return .{ .val = val }; + } + + /// Creates a new JS array with a pre-allocated length. + pub fn createWithLength(len: usize) Array { + const e = context.env(); + const val = e.createArrayWithLength(len) catch @panic("Array.createWithLength failed"); + return .{ .val = val }; + } + + pub fn toValue(self: Array) napi.Value { + return self.val; + } +}; + +/// Extracts the underlying `napi.Value` from a DSL wrapper (has `.val` field) +/// or passes through a raw `napi.Value`. +fn toNapiValue(item: anytype) napi.Value { + const T = @TypeOf(item); + if (T == napi.Value) return item; + if (@hasField(T, "val")) return item.val; + @compileError("Expected a DSL wrapper type (with .val field) or napi.Value, got " ++ @typeName(T)); +} diff --git a/src/js/bigint.zig b/src/js/bigint.zig new file mode 100644 index 0000000..6c3f695 --- /dev/null +++ b/src/js/bigint.zig @@ -0,0 +1,82 @@ +const std = @import("std"); +const napi = @import("../napi.zig"); +const context = @import("context.zig"); + +pub const BigInt = struct { + val: napi.Value, + + /// Returns the i64 value. If lossless is provided, it indicates whether the + /// conversion was lossless. + pub fn toI64(self: BigInt, lossless: ?*bool) !i64 { + return self.val.getValueBigintInt64(lossless); + } + + /// Returns the u64 value. If lossless is provided, it indicates whether the + /// conversion was lossless. + pub fn toU64(self: BigInt, lossless: ?*bool) !u64 { + return self.val.getValueBigintUint64(lossless); + } + + /// Returns the i128 value by reading two 64-bit words. + pub fn toI128(self: BigInt) !i128 { + var sign_bit: u1 = 0; + var words: [2]u64 = .{ 0, 0 }; + const result = try self.val.getValueBigintWords(&sign_bit, &words); + const lo: u128 = result[0]; + const hi: u128 = if (result.len > 1) result[1] else 0; + const magnitude: u128 = (hi << 64) | lo; + if (sign_bit == 1) { + // Negative: negate the magnitude + if (magnitude == 0) return 0; + return -@as(i128, @intCast(magnitude)); + } + return @intCast(magnitude); + } + + pub fn assertI64(self: BigInt) i64 { + return self.toI64(null) catch @panic("BigInt.assertI64 failed"); + } + + pub fn assertU64(self: BigInt) u64 { + return self.toU64(null) catch @panic("BigInt.assertU64 failed"); + } + + pub fn assertI128(self: BigInt) i128 { + return self.toI128() catch @panic("BigInt.assertI128 failed"); + } + + /// Creates a JS BigInt from a Zig integer value. + /// Accepts i64, u64, and comptime_int. + pub fn from(value: anytype) BigInt { + const e = context.env(); + const T = @TypeOf(value); + const val = switch (@typeInfo(T)) { + .int, .comptime_int => blk: { + if (@typeInfo(T) == .comptime_int) { + if (value >= std.math.minInt(i64) and value <= std.math.maxInt(i64)) { + break :blk e.createBigintInt64(@intCast(value)) catch @panic("BigInt.from: createBigintInt64 failed"); + } else if (value >= 0 and value <= std.math.maxInt(u64)) { + break :blk e.createBigintUint64(@intCast(value)) catch @panic("BigInt.from: createBigintUint64 failed"); + } else { + @compileError("BigInt.from: comptime_int value out of range for i64/u64. Use fromWords for larger values."); + } + } else { + const info = @typeInfo(T).int; + if (info.signedness == .signed and info.bits <= 64) { + break :blk e.createBigintInt64(@intCast(value)) catch @panic("BigInt.from: createBigintInt64 failed"); + } else if (info.signedness == .unsigned and info.bits <= 64) { + break :blk e.createBigintUint64(@intCast(value)) catch @panic("BigInt.from: createBigintUint64 failed"); + } else { + @compileError("BigInt.from: integer too large. Use fromWords for i128/u128."); + } + } + }, + else => @compileError("BigInt.from: unsupported type " ++ @typeName(T) ++ ". Use integer types."), + }; + return .{ .val = val }; + } + + pub fn toValue(self: BigInt) napi.Value { + return self.val; + } +}; diff --git a/src/js/boolean.zig b/src/js/boolean.zig new file mode 100644 index 0000000..0bd553b --- /dev/null +++ b/src/js/boolean.zig @@ -0,0 +1,24 @@ +const napi = @import("../napi.zig"); +const context = @import("context.zig"); + +pub const Boolean = struct { + val: napi.Value, + + pub fn toBool(self: Boolean) !bool { + return self.val.getValueBool(); + } + + pub fn assertBool(self: Boolean) bool { + return self.toBool() catch @panic("Boolean.assertBool failed"); + } + + pub fn from(value: bool) Boolean { + const e = context.env(); + const val = e.getBoolean(value) catch @panic("Boolean.from failed"); + return .{ .val = val }; + } + + pub fn toValue(self: Boolean) napi.Value { + return self.val; + } +}; diff --git a/src/js/class_meta.zig b/src/js/class_meta.zig new file mode 100644 index 0000000..9e3ccbc --- /dev/null +++ b/src/js/class_meta.zig @@ -0,0 +1,203 @@ +const std = @import("std"); + +pub const AccessorRef = union(enum) { + none, + derived, + named: []const u8, +}; + +pub const FieldSpec = struct { + pub const zapi_js_property_kind = "field"; + field_name: []const u8, +}; + +pub const PropSpec = struct { + pub const zapi_js_property_kind = "prop"; + get: AccessorRef, + set: AccessorRef, +}; + +fn ClassMeta(comptime Options: type) type { + return struct { + pub const zapi_js_meta_kind = "class"; + options: Options, + }; +} + +pub fn class(comptime opts: anytype) ClassMeta(@TypeOf(opts)) { + validateClassOptions(@TypeOf(opts), opts); + return .{ .options = opts }; +} + +pub fn field(comptime name: []const u8) FieldSpec { + return .{ .field_name = name }; +} + +pub fn prop(comptime spec: anytype) PropSpec { + const Spec = @TypeOf(spec); + if (@typeInfo(Spec) != .@"struct") { + @compileError("js.prop expects a struct literal"); + } + + if (!@hasField(Spec, "get")) { + @compileError("js.prop requires .get"); + } + + return .{ + .get = parseAccessor("get", @field(spec, "get")), + .set = if (@hasField(Spec, "set")) parseAccessor("set", @field(spec, "set")) else .none, + }; +} + +pub fn hasClassMeta(comptime T: type) bool { + if (@typeInfo(T) != .@"struct") return false; + return @hasDecl(T, "js_meta") and isClassMetaValue(@field(T, "js_meta")); +} + +pub fn isClassType(comptime T: type) bool { + return hasClassMeta(T) or isLegacyClassType(T); +} + +pub fn isLegacyClassType(comptime T: type) bool { + if (@typeInfo(T) != .@"struct") return false; + return @hasDecl(T, "js_class") and + @TypeOf(@field(T, "js_class")) == bool and + @field(T, "js_class") == true; +} + +pub fn isClassMetaValue(comptime value: anytype) bool { + return @hasDecl(@TypeOf(value), "zapi_js_meta_kind") and + std.mem.eql(u8, @field(@TypeOf(value), "zapi_js_meta_kind"), "class"); +} + +pub fn hasProperties(comptime T: type) bool { + if (!hasClassMeta(T)) return false; + return @hasField(@TypeOf(T.js_meta.options), "properties"); +} + +pub fn propertyFields(comptime T: type) []const std.builtin.Type.StructField { + if (!hasProperties(T)) return &.{}; + return @typeInfo(@TypeOf(T.js_meta.options.properties)).@"struct".fields; +} + +pub fn getClassName(comptime T: type, comptime default_name: []const u8) []const u8 { + if (!hasClassMeta(T)) return default_name; + if (!@hasField(@TypeOf(T.js_meta.options), "name")) return default_name; + const name = T.js_meta.options.name; + switch (@typeInfo(@TypeOf(name))) { + .optional => return if (name) |n| coerceStringLike(n) else default_name, + else => return coerceStringLike(name), + } +} + +pub fn propertyKind(comptime value: anytype) enum { computed, field, prop, invalid } { + if (@TypeOf(value) == bool) { + return if (value) .computed else .invalid; + } + if (isFieldSpec(value)) return .field; + if (isPropSpec(value)) return .prop; + return .invalid; +} + +pub fn isFieldSpec(comptime value: anytype) bool { + return @hasDecl(@TypeOf(value), "zapi_js_property_kind") and + std.mem.eql(u8, @field(@TypeOf(value), "zapi_js_property_kind"), "field"); +} + +pub fn isPropSpec(comptime value: anytype) bool { + return @hasDecl(@TypeOf(value), "zapi_js_property_kind") and + std.mem.eql(u8, @field(@TypeOf(value), "zapi_js_property_kind"), "prop"); +} + +fn validateClassOptions(comptime Opts: type, comptime opts: Opts) void { + if (@typeInfo(Opts) != .@"struct") { + @compileError("js.class expects a struct literal"); + } + + 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"); + } + } + + if (@hasField(Opts, "name")) { + const NameType = @TypeOf(opts.name); + switch (@typeInfo(NameType)) { + .optional => |opt| { + _ = comptime coerceStringLikeType(opt.child, "js.class .name"); + }, + else => _ = comptime coerceStringLikeType(NameType, "js.class .name"), + } + } + + if (@hasField(Opts, "properties")) { + validateProperties(@TypeOf(opts.properties), opts.properties); + } +} + +fn validateProperties(comptime Props: type, comptime props: Props) void { + if (@typeInfo(Props) != .@"struct") { + @compileError("js.class .properties must be a struct literal"); + } + + inline for (@typeInfo(Props).@"struct".fields) |field_info| { + const value = @field(props, field_info.name); + switch (propertyKind(value)) { + .computed, .field, .prop => {}, + .invalid => @compileError("unsupported property spec for '" ++ field_info.name ++ "'"), + } + } +} + +fn parseAccessor(comptime _: []const u8, comptime value: anytype) AccessorRef { + return switch (@TypeOf(value)) { + bool => if (value) .derived else .none, + else => .{ .named = coerceStringLike(value) }, + }; +} + +fn coerceStringLike(comptime value: anytype) []const u8 { + const T = @TypeOf(value); + _ = comptime coerceStringLikeType(T, "string"); + switch (@typeInfo(T)) { + .pointer => |ptr| { + if (ptr.size == .slice and ptr.child == u8 and ptr.is_const) return value; + if (ptr.size == .one and @typeInfo(ptr.child) == .array) { + const arr = @typeInfo(ptr.child).array; + if (arr.child == u8) return value[0..arr.len]; + } + }, + else => {}, + } + unreachable; +} + +fn coerceStringLikeType(comptime T: type, comptime label: []const u8) type { + switch (@typeInfo(T)) { + .pointer => |ptr| { + if (ptr.size == .slice and ptr.child == u8 and ptr.is_const) return T; + if (ptr.size == .one and @typeInfo(ptr.child) == .array) { + const arr = @typeInfo(ptr.child).array; + if (arr.child == u8) return T; + } + }, + else => {}, + } + @compileError(label ++ " must be a string literal or []const u8"); +} + +test "js.class accepts empty options" { + const meta = class(.{}); + try std.testing.expect(isClassMetaValue(meta)); +} + +test "js.field accepts field names" { + const spec = field("label_"); + try std.testing.expectEqualStrings("label_", spec.field_name); +} + +test "js.prop accepts derived getter and setter" { + const spec = prop(.{ .get = true, .set = true }); + try std.testing.expect(spec.get == .derived); + try std.testing.expect(spec.set == .derived); +} diff --git a/src/js/class_runtime.zig b/src/js/class_runtime.zig new file mode 100644 index 0000000..4a8a195 --- /dev/null +++ b/src/js/class_runtime.zig @@ -0,0 +1,153 @@ +const std = @import("std"); +const napi = @import("../napi.zig"); + +pub fn destroyNativeObject(comptime T: type, obj: *T) void { + if (@hasDecl(T, "deinit")) { + obj.deinit(); + } + std.heap.c_allocator.destroy(obj); +} + +pub fn destroyInternalPlaceholder(comptime T: type, obj: *T) void { + std.heap.c_allocator.destroy(obj); +} + +pub fn defaultFinalize(comptime T: type) @import("../finalize_callback.zig").FinalizeCallback(T) { + return struct { + fn f(_: napi.Env, obj: *T, _: ?*anyopaque) void { + destroyNativeObject(T, obj); + } + }.f; +} + +pub fn registerClass(comptime T: type, env: napi.Env, ctor: napi.Value) !void { + const State = state(T); + + State.mutex.lock(); + defer State.mutex.unlock(); + + if (State.find(env.env) != null) return; + + const entry = try std.heap.c_allocator.create(State.Entry); + errdefer std.heap.c_allocator.destroy(entry); + + entry.* = .{ + .env = env.env, + .ctor_ref = try env.createReference(ctor, 1), + .next = State.head, + }; + State.head = entry; + + try env.addEnvCleanupHook(State.Entry, entry, State.cleanupHook); +} + +pub fn materializeClassInstance(comptime T: type, env: napi.Env, instance: T, preferred_ctor: ?napi.Value) !napi.Value { + const ctor = preferred_ctor orelse try getConstructor(T, env); + const internal_arg = try env.createExternal(@ptrCast(internalCtorMarkerPtr(T)), null, null); + var raw_args = [_]napi.c.napi_value{internal_arg.value}; + + var js_instance_raw: napi.c.napi_value = null; + try napi.status.check(napi.c.napi_new_instance( + env.env, + ctor.value, + 1, + &raw_args, + &js_instance_raw, + )); + + const js_instance = napi.Value{ .env = env.env, .value = js_instance_raw }; + const placeholder = try env.removeWrap(T, js_instance); + destroyInternalPlaceholder(T, placeholder); + + const obj_ptr = try std.heap.c_allocator.create(T); + errdefer std.heap.c_allocator.destroy(obj_ptr); + obj_ptr.* = instance; + + try env.wrap(js_instance, T, obj_ptr, defaultFinalize(T), null, null); + return js_instance; +} + +fn getConstructor(comptime T: type, env: napi.Env) !napi.Value { + const State = state(T); + + State.mutex.lock(); + defer State.mutex.unlock(); + + const entry = State.find(env.env) orelse return error.ClassNotRegistered; + return try entry.ctor_ref.getValue(); +} + +pub fn isInternalCtorArg(comptime T: type, value: napi.Value) bool { + const raw = value.getValueExternal() catch return false; + return raw == @as(*anyopaque, @ptrCast(internalCtorMarkerPtr(T))); +} + +pub fn internalPlaceholderHint(comptime T: type) ?*anyopaque { + return @ptrCast(&markers(T).placeholder_hint); +} + +pub fn isInternalPlaceholderHint(comptime T: type, hint: ?*anyopaque) bool { + return hint == internalPlaceholderHint(T); +} + +fn state(comptime T: type) type { + return struct { + const Class = T; + comptime { + _ = Class; + } + + const Entry = struct { + env: napi.c.napi_env, + ctor_ref: @import("../Ref.zig"), + next: ?*Entry, + }; + + var head: ?*Entry = null; + var mutex: std.Thread.Mutex = .{}; + + fn find(env_ptr: napi.c.napi_env) ?*Entry { + var current = head; + while (current) |entry| : (current = entry.next) { + if (entry.env == env_ptr) return entry; + } + return null; + } + + fn cleanupHook(entry: *Entry) void { + mutex.lock(); + defer mutex.unlock(); + + var cursor = &head; + while (cursor.*) |current| { + if (current == entry) { + cursor.* = current.next; + current.ctor_ref.delete() catch {}; + std.heap.c_allocator.destroy(current); + return; + } + cursor = ¤t.next; + } + } + }; +} + +fn markers(comptime T: type) type { + return struct { + const Class = T; + comptime { + _ = Class; + } + + var ctor_marker: u8 = 0; + var placeholder_hint: u8 = 0; + }; +} + +fn internalCtorMarker(comptime T: type) [*]const u8 { + return internalCtorMarkerPtr(T); +} + +fn internalCtorMarkerPtr(comptime T: type) *u8 { + return &markers(T).ctor_marker; +} diff --git a/src/js/context.zig b/src/js/context.zig new file mode 100644 index 0000000..00ecc25 --- /dev/null +++ b/src/js/context.zig @@ -0,0 +1,62 @@ +const std = @import("std"); +const napi = @import("../napi.zig"); + +/// Thread-local N-API environment, set by the generated callback wrappers. +/// +/// SAFETY: DSL types (Number, String, etc.) and `js.env()` are only valid +/// within the synchronous scope of a JS callback. Do not store DSL types +/// across callbacks or use them from worker threads. For async work, use +/// `napi.AsyncWork` or `napi.ThreadSafeFunction` from the low-level API. +threadlocal var current_env: ?napi.Env = null; + +pub fn env() napi.Env { + return current_env orelse @panic("js.env() called outside of a JS callback context"); +} + +pub fn allocator() std.mem.Allocator { + return std.heap.c_allocator; +} + +pub fn setEnv(e: napi.Env) ?napi.Env { + const prev = current_env; + current_env = e; + return prev; +} + +pub fn restoreEnv(prev: ?napi.Env) void { + current_env = prev; +} + +/// Thread-local JS `this` value, set by method/getter/setter callback wrappers. +/// Only valid within instance method or getter/setter scope. +threadlocal var current_this: ?napi.Value = null; + +pub fn thisArg() napi.Value { + return current_this orelse @panic("js.thisArg() called outside of an instance method/getter/setter context"); +} + +pub fn setThis(t: napi.Value) ?napi.Value { + const prev = current_this; + current_this = t; + return prev; +} + +pub fn restoreThis(prev: ?napi.Value) void { + current_this = prev; +} + +test "allocator returns c_allocator" { + const alloc = allocator(); + const mem = try alloc.alloc(u8, 16); + defer alloc.free(mem); + try std.testing.expect(mem.len == 16); +} + +test "current_env is null by default" { + try std.testing.expect(current_env == null); +} + +test "restoreEnv with null preserves null state" { + restoreEnv(null); + try std.testing.expect(current_env == null); +} diff --git a/src/js/date.zig b/src/js/date.zig new file mode 100644 index 0000000..eb6e8b7 --- /dev/null +++ b/src/js/date.zig @@ -0,0 +1,26 @@ +const napi = @import("../napi.zig"); +const context = @import("context.zig"); + +pub const Date = struct { + val: napi.Value, + + /// Returns the timestamp (milliseconds since Unix epoch) as f64. + pub fn toTimestamp(self: Date) !f64 { + return self.val.getDateValue(); + } + + pub fn assertTimestamp(self: Date) f64 { + return self.toTimestamp() catch @panic("Date.assertTimestamp failed"); + } + + /// Creates a JS Date from a timestamp (milliseconds since Unix epoch). + pub fn from(time: f64) Date { + const e = context.env(); + const val = e.createDate(time) catch @panic("Date.from failed"); + return .{ .val = val }; + } + + pub fn toValue(self: Date) napi.Value { + return self.val; + } +}; diff --git a/src/js/export_module.zig b/src/js/export_module.zig new file mode 100644 index 0000000..250c72f --- /dev/null +++ b/src/js/export_module.zig @@ -0,0 +1,170 @@ +const std = @import("std"); +const napi = @import("../napi.zig"); +const context = @import("context.zig"); +const wrap_function = @import("wrap_function.zig"); +const wrap_class = @import("wrap_class.zig"); +const class_meta = @import("class_meta.zig"); +const class_runtime = @import("class_runtime.zig"); + +/// Scans pub decls of `Module` and registers them as JS exports. +/// +/// Optional second argument for lifecycle hooks: +/// js.exportModule(@This(), .{ +/// .init = fn (refcount: u32) !void, — called during registration +/// .cleanup = fn (refcount: u32) void, — called on env exit +/// .register = fn (napi.Env, napi.Value) !void, — called with (env, exports) for manual registration +/// }) +/// +/// The DSL manages an atomic refcount internally: +/// - .init receives the refcount BEFORE increment (0 = first env) +/// - .cleanup receives the refcount AFTER decrement (0 = last env) +/// +/// Usage: +/// comptime { js.exportModule(@This()); } +/// comptime { js.exportModule(@This(), .{ .init = ..., .cleanup = ... }); } +/// comptime { js.exportModule(@This(), .{ .register = myRegisterFn }); } +pub fn exportModule(comptime Module: type, comptime options: anytype) void { + const has_init = @hasField(@TypeOf(options), "init"); + const has_cleanup = @hasField(@TypeOf(options), "cleanup"); + const has_register = @hasField(@TypeOf(options), "register"); + const has_lifecycle = has_init or has_cleanup; + + const State = struct { + var env_refcount: std.atomic.Value(u32) = std.atomic.Value(u32).init(0); + + // addEnvCleanupHook requires a non-null *Data pointer. + const CleanupData = struct { + _dummy: u8 = 0, + }; + 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); + } + } + }; + + const init = struct { + pub fn moduleInit(env: napi.Env, module: napi.Value) anyerror!void { + const prev = context.setEnv(env); + defer context.restoreEnv(prev); + + // Lifecycle: init + if (has_lifecycle) { + const prev_refcount = State.env_refcount.fetchAdd(1, .monotonic); + + if (has_init) { + options.init(prev_refcount) catch |err| { + // Rollback refcount on init failure + _ = State.env_refcount.fetchSub(1, .acq_rel); + return err; + }; + } + } + + // 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); + } + + // Lifecycle: register cleanup hook + if (has_cleanup) { + try env.addEnvCleanupHook( + State.CleanupData, + &State.cleanup_data, + State.cleanupHook, + ); + } + } + }; + + napi.module.register(init.moduleInit); +} + +/// 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; + var exported_any = false; + + inline for (decls) |decl| { + const field = @field(Module, decl.name); + const FieldType = @TypeOf(field); + const field_info = @typeInfo(FieldType); + + if (field_info == .@"fn") { + // Skip functions whose parameters aren't DSL types + const fn_params = field_info.@"fn".params; + const is_dsl_fn = comptime blk: { + for (fn_params) |p| { + const PT = p.type orelse break :blk false; + if (!wrap_function.isDslOrOptionalDsl(PT)) break :blk false; + } + break :blk true; + }; + if (!is_dsl_fn) continue; + + // DSL function — wrap and register + const cb = wrap_function.wrapFunction(field); + const name: [:0]const u8 = decl.name ++ ""; + + var js_fn: napi.c.napi_value = null; + try napi.status.check(napi.c.napi_create_function( + env.env, + name.ptr, + name.len, + cb, + null, + &js_fn, + )); + + const fn_val = napi.Value{ .env = env.env, .value = js_fn }; + try module.setNamedProperty(name, fn_val); + exported_any = true; + } else if (field_info == .type) { + const InnerType = field; + if (@typeInfo(InnerType) == .@"struct") { + if (comptime class_meta.hasClassMeta(InnerType) or class_meta.isLegacyClassType(InnerType)) { + const wrapped = wrap_class.wrapClass(InnerType); + const props = wrapped.getPropertyDescriptors(); + const class_name = comptime class_meta.getClassName(InnerType, decl.name); + const name: [:0]const u8 = class_name ++ ""; + + var class_val: napi.c.napi_value = null; + try napi.status.check(napi.c.napi_define_class( + env.env, + name.ptr, + name.len, + wrapped.constructor, + null, + props.len, + if (props.len > 0) props.ptr else null, + &class_val, + )); + + const cls = napi.Value{ .env = env.env, .value = class_val }; + try class_runtime.registerClass(InnerType, env, cls); + try module.setNamedProperty(name, cls); + exported_any = true; + } else { + const ns_obj = try env.createObject(); + if (try registerDecls(InnerType, env, ns_obj, depth + 1)) { + const name: [:0]const u8 = decl.name ++ ""; + try module.setNamedProperty(name, ns_obj); + exported_any = true; + } + } + } + } + } + return exported_any; +} + +test "exportModule comptime smoke test" { + try std.testing.expect(true); +} diff --git a/src/js/function.zig b/src/js/function.zig new file mode 100644 index 0000000..8c021c4 --- /dev/null +++ b/src/js/function.zig @@ -0,0 +1,42 @@ +const napi = @import("../napi.zig"); +const context = @import("context.zig"); + +pub const Function = struct { + val: napi.Value, + + /// Calls the function with `undefined` as the receiver. + /// `args` is a tuple where each element is either a DSL wrapper type + /// (has `.val` field) or a raw `napi.Value`. + pub fn call(self: Function, args: anytype) !@import("value.zig").Value { + const e = context.env(); + const recv = try e.getUndefined(); + const ArgsType = @TypeOf(args); + const args_info = @typeInfo(ArgsType); + + if (args_info != .@"struct" or !args_info.@"struct".is_tuple) { + @compileError("Function.call expects a tuple of arguments"); + } + + const fields = args_info.@"struct".fields; + var raw_args: [fields.len]napi.c.napi_value = undefined; + + inline for (fields, 0..) |field, i| { + const arg = @field(args, field.name); + raw_args[i] = toRawValue(arg); + } + + const result = try e.callFunctionRaw(self.val, recv, raw_args[0..]); + return .{ .val = result }; + } + + pub fn toValue(self: Function) napi.Value { + return self.val; + } +}; + +fn toRawValue(item: anytype) napi.c.napi_value { + const T = @TypeOf(item); + if (T == napi.Value) return item.value; + if (@hasField(T, "val")) return item.val.value; + @compileError("Expected a DSL wrapper type (with .val field) or napi.Value, got " ++ @typeName(T)); +} diff --git a/src/js/number.zig b/src/js/number.zig new file mode 100644 index 0000000..ff08bd0 --- /dev/null +++ b/src/js/number.zig @@ -0,0 +1,98 @@ +const std = @import("std"); +const napi = @import("../napi.zig"); +const context = @import("context.zig"); + +/// Zero-cost wrapper around a JS `number` value. +/// +/// `from()` panics on N-API failure (e.g. invalid env). This is a deliberate +/// design choice to keep DSL signatures clean (no `try` on every construction). +/// N-API creation calls only fail if the environment is invalid, which indicates +/// a programming error. Use `assert*()` variants for the same panic-on-failure +/// pattern when extracting values, or `to*()` variants to get error unions. +pub const Number = struct { + val: napi.Value, + + pub fn toI32(self: Number) !i32 { + return self.val.getValueInt32(); + } + + pub fn toU32(self: Number) !u32 { + return self.val.getValueUint32(); + } + + pub fn toF64(self: Number) !f64 { + return self.val.getValueDouble(); + } + + pub fn toI64(self: Number) !i64 { + return self.val.getValueInt64(); + } + + pub fn assertI32(self: Number) i32 { + return self.toI32() catch @panic("Number.assertI32 failed"); + } + + pub fn assertU32(self: Number) u32 { + return self.toU32() catch @panic("Number.assertU32 failed"); + } + + pub fn assertF64(self: Number) f64 { + return self.toF64() catch @panic("Number.assertF64 failed"); + } + + pub fn assertI64(self: Number) i64 { + return self.toI64() catch @panic("Number.assertI64 failed"); + } + + /// Creates a JS Number from a Zig numeric value. + /// Accepts integer types (i8..i64, u8..u64), float types (f32, f64), + /// comptime_int, and comptime_float. + /// + /// Unsigned 64-bit values above `i64` max are created via JS `number` + /// (`double`) rather than `int64`, so values above `2^53 - 1` may lose + /// integer precision. Use `BigInt` when exact large integers matter. + pub fn from(value: anytype) Number { + const e = context.env(); + const T = @TypeOf(value); + const val = switch (@typeInfo(T)) { + .int, .comptime_int => blk: { + if (@typeInfo(T) == .comptime_int) { + if (value >= std.math.minInt(i32) and value <= std.math.maxInt(i32)) { + break :blk e.createInt32(@intCast(value)) catch @panic("Number.from: createInt32 failed"); + } else if (value >= std.math.minInt(i64) and value <= std.math.maxInt(i64)) { + break :blk e.createInt64(@intCast(value)) catch @panic("Number.from: createInt64 failed"); + } else if (value >= 0 and value <= std.math.maxInt(u64)) { + break :blk e.createDouble(@floatFromInt(value)) catch @panic("Number.from: createDouble failed"); + } else { + @compileError("Number.from: value out of range for JS number. Use BigInt for i128/u128."); + } + } else { + const info = @typeInfo(T).int; + if (info.bits <= 32 and info.signedness == .signed) { + break :blk e.createInt32(@intCast(value)) catch @panic("Number.from: createInt32 failed"); + } else if (info.bits <= 32 and info.signedness == .unsigned) { + break :blk e.createUint32(@intCast(value)) catch @panic("Number.from: createUint32 failed"); + } else if (info.bits <= 64 and info.signedness == .signed) { + break :blk e.createInt64(@intCast(value)) catch @panic("Number.from: createInt64 failed"); + } else if (info.bits <= 64 and info.signedness == .unsigned) { + if (value <= std.math.maxInt(i64)) { + break :blk e.createInt64(@intCast(value)) catch @panic("Number.from: createInt64 failed"); + } + break :blk e.createDouble(@floatFromInt(value)) catch @panic("Number.from: createDouble failed"); + } else { + @compileError("Number.from: integer too large for JS number. Use BigInt for i128/u128."); + } + } + }, + .float, .comptime_float => blk: { + break :blk e.createDouble(@floatCast(value)) catch @panic("Number.from: createDouble failed"); + }, + else => @compileError("Number.from: unsupported type " ++ @typeName(T) ++ ". Use integer or float types."), + }; + return .{ .val = val }; + } + + pub fn toValue(self: Number) napi.Value { + return self.val; + } +}; diff --git a/src/js/object.zig b/src/js/object.zig new file mode 100644 index 0000000..47d6ca1 --- /dev/null +++ b/src/js/object.zig @@ -0,0 +1,37 @@ +const napi = @import("../napi.zig"); + +/// A generic Object wrapper that maps a Zig struct `T` to a JS object. +/// Each field of `T` must be a DSL wrapper type (i.e., a struct with a `.val: napi.Value` field). +pub fn Object(comptime T: type) type { + const fields = @typeInfo(T).@"struct".fields; + + return struct { + val: napi.Value, + + const Self = @This(); + + /// Reads all properties from the JS object into a Zig struct. + pub fn get(self: Self) !T { + var result: T = undefined; + inline for (fields) |field| { + const name: [:0]const u8 = field.name ++ ""; + const prop = try self.val.getNamedProperty(name); + @field(result, field.name) = .{ .val = prop }; + } + return result; + } + + /// Writes all fields of the Zig struct onto the JS object. + pub fn set(self: Self, value: T) !void { + inline for (fields) |field| { + const name: [:0]const u8 = field.name ++ ""; + const field_val = @field(value, field.name); + try self.val.setNamedProperty(name, field_val.val); + } + } + + pub fn toValue(self: Self) napi.Value { + return self.val; + } + }; +} diff --git a/src/js/promise.zig b/src/js/promise.zig new file mode 100644 index 0000000..4f19c24 --- /dev/null +++ b/src/js/promise.zig @@ -0,0 +1,66 @@ +const napi = @import("../napi.zig"); +const context = @import("context.zig"); +const String = @import("string.zig").String; + +/// A Promise wrapper parameterized on the resolve type `T`. +/// `T` must be a DSL wrapper type (has `.val` field) or `napi.Value`. +/// +/// IMPORTANT: When returning `Promise(T)` from a DSL function, the promise must +/// be resolved or rejected *before* the function returns. The `deferred` handle +/// is not preserved across the JS boundary — only the `.val` (the JS promise +/// object) is returned to the caller. For async resolution (e.g., from a worker +/// thread), store the `Deferred` handle separately and use `napi.AsyncWork` or +/// `napi.ThreadSafeFunction` from the low-level API layer. +pub fn Promise(comptime T: type) type { + return struct { + val: napi.Value, + deferred: napi.Deferred, + + const Self = @This(); + + /// Resolves the promise with the given value. + pub fn resolve(self: Self, value: T) !void { + const raw = toNapiValue(value); + try self.deferred.resolve(raw); + } + + /// Rejects the promise with any JS value (typically an Error object). + /// Use `rejectWithMessage` for convenience when you only have a string. + pub fn reject(self: Self, err: anytype) !void { + const raw = toNapiValue(err); + try self.deferred.reject(raw); + } + + /// Convenience: rejects with a new JS Error object created from a message string. + /// This ensures `.message` and `.stack` are available in JS catch blocks. + pub fn rejectWithMessage(self: Self, message: String) !void { + const e = context.env(); + const error_obj = try e.createError( + try e.createStringUtf8("Error"), + message.val, + ); + try self.deferred.reject(error_obj); + } + + /// Returns the underlying JS promise value (to return to JS callers). + pub fn toValue(self: Self) napi.Value { + return self.val; + } + }; +} + +/// Creates a new Promise(T) and returns it. The caller should return +/// `promise.toValue()` to JS and later call `promise.resolve()` or `promise.reject()`. +pub fn createPromise(comptime T: type) !Promise(T) { + const e = context.env(); + const deferred = try e.createPromise(); + const val = deferred.getPromise(); + return .{ .val = val, .deferred = deferred }; +} + +fn toNapiValue(item: anytype) napi.Value { + const T = @TypeOf(item); + if (T == napi.Value) return item; + if (@hasField(T, "val")) return item.val; + @compileError("Expected a DSL wrapper type (with .val field) or napi.Value, got " ++ @typeName(T)); +} diff --git a/src/js/string.zig b/src/js/string.zig new file mode 100644 index 0000000..cea2ee9 --- /dev/null +++ b/src/js/string.zig @@ -0,0 +1,49 @@ +const std = @import("std"); +const napi = @import("../napi.zig"); +const context = @import("context.zig"); + +pub const String = struct { + val: napi.Value, + + /// Copies the string value into the provided buffer. + /// Returns a slice of the buffer containing the string data. + pub fn toSlice(self: String, buf: []u8) ![]const u8 { + return self.val.getValueStringUtf8(buf); + } + + /// Allocates a null-terminated string and returns it as a sentinel slice. + /// Caller owns the returned memory and must free it with the same allocator. + pub fn toOwnedSlice(self: String, alloc: std.mem.Allocator) ![:0]u8 { + const str_len = try self.len(); + // Allocate str_len + 1 for the null terminator that N-API writes. + const buf = try alloc.allocSentinel(u8, str_len, 0); + errdefer alloc.free(buf); + _ = try self.val.getValueStringUtf8(buf[0 .. str_len + 1]); + return buf; + } + + /// Returns the length of the string in bytes (UTF-8). + pub fn len(self: String) !usize { + var str_len: usize = 0; + const status_code = napi.c.napi_get_value_string_utf8( + self.val.env, + self.val.value, + null, + 0, + &str_len, + ); + try napi.status.check(status_code); + return str_len; + } + + /// Creates a JS String from a Zig string slice. + pub fn from(value: []const u8) String { + const e = context.env(); + const val = e.createStringUtf8(value) catch @panic("String.from failed"); + return .{ .val = val }; + } + + pub fn toValue(self: String) napi.Value { + return self.val; + } +}; diff --git a/src/js/typed_arrays.zig b/src/js/typed_arrays.zig new file mode 100644 index 0000000..f5bbddb --- /dev/null +++ b/src/js/typed_arrays.zig @@ -0,0 +1,57 @@ +const napi = @import("../napi.zig"); +const context = @import("context.zig"); +const TypedarrayType = napi.value_types.TypedarrayType; + +/// Generates a typed array wrapper for a specific element type and NAPI array type. +pub fn TypedArray(comptime Element: type, comptime array_type: TypedarrayType) type { + return struct { + val: napi.Value, + + const Self = @This(); + + /// Returns a slice pointing directly into the V8 ArrayBuffer backing store. + /// + /// WARNING: This slice is only valid within the current N-API callback scope. + /// The backing store may be moved or freed by the GC after the callback returns + /// or after any JS call that could trigger GC. Do NOT store this slice across + /// callbacks, async work boundaries, or JS function calls. For data that must + /// outlive the callback, copy the slice contents to a heap allocation. + pub fn toSlice(self: Self) ![]Element { + const info = try self.val.getTypedarrayInfo(); + const byte_ptr: [*]u8 = info.data.ptr; + const typed_ptr: [*]Element = @ptrCast(@alignCast(byte_ptr)); + return typed_ptr[0..info.length]; + } + + /// Creates a new JS TypedArray from a Zig slice by copying the data. + pub fn from(slice: []const Element) Self { + const e = context.env(); + const byte_len = slice.len * @sizeOf(Element); + var buf_ptr: [*]u8 = undefined; + const arraybuffer = e.createArrayBuffer(byte_len, &buf_ptr) catch + @panic("TypedArray.from: createArrayBuffer failed"); + const dest: [*]Element = @ptrCast(@alignCast(buf_ptr)); + @memcpy(dest[0..slice.len], slice); + const val = e.createTypedarray(array_type, slice.len, arraybuffer, 0) catch + @panic("TypedArray.from: createTypedarray failed"); + return .{ .val = val }; + } + + pub fn toValue(self: Self) napi.Value { + return self.val; + } + }; +} + +// Concrete typed array types +pub const Int8Array = TypedArray(i8, .int8); +pub const Uint8Array = TypedArray(u8, .uint8); +pub const Uint8ClampedArray = TypedArray(u8, .uint8_clamped); +pub const Int16Array = TypedArray(i16, .int16); +pub const Uint16Array = TypedArray(u16, .uint16); +pub const Int32Array = TypedArray(i32, .int32); +pub const Uint32Array = TypedArray(u32, .uint32); +pub const Float32Array = TypedArray(f32, .float32); +pub const Float64Array = TypedArray(f64, .float64); +pub const BigInt64Array = TypedArray(i64, .bigint64); +pub const BigUint64Array = TypedArray(u64, .biguint64); diff --git a/src/js/value.zig b/src/js/value.zig new file mode 100644 index 0000000..7d08138 --- /dev/null +++ b/src/js/value.zig @@ -0,0 +1,191 @@ +const std = @import("std"); +const napi = @import("../napi.zig"); +const ValueType = napi.value_types.ValueType; +const TypedarrayType = napi.value_types.TypedarrayType; +const Number = @import("number.zig").Number; +const String = @import("string.zig").String; +const Boolean = @import("boolean.zig").Boolean; +const BigInt = @import("bigint.zig").BigInt; +const Date = @import("date.zig").Date; +const Array = @import("array.zig").Array; +const Function = @import("function.zig").Function; +const typed_arrays = @import("typed_arrays.zig"); + +/// Error returned when a Value narrowing method finds a type mismatch. +pub const TypeError = error{TypeMismatch}; + +/// Untyped escape hatch: wraps a raw napi.Value and provides type-checking +/// and narrowing methods to convert into specific DSL wrapper types. +/// Narrowing methods validate the JS type at runtime and return +/// `error.TypeMismatch` if the value is not the expected type. +pub const Value = struct { + val: napi.Value, + + // -- Type checking -- + + pub fn isNumber(self: Value) bool { + return (self.val.typeof() catch return false) == .number; + } + + pub fn isString(self: Value) bool { + return (self.val.typeof() catch return false) == .string; + } + + pub fn isBigInt(self: Value) bool { + return (self.val.typeof() catch return false) == .bigint; + } + + pub fn isBoolean(self: Value) bool { + return (self.val.typeof() catch return false) == .boolean; + } + + pub fn isSymbol(self: Value) bool { + return (self.val.typeof() catch return false) == .symbol; + } + + pub fn isFunction(self: Value) bool { + return (self.val.typeof() catch return false) == .function; + } + + pub fn isObject(self: Value) bool { + return (self.val.typeof() catch return false) == .object; + } + + pub fn isNull(self: Value) bool { + return (self.val.typeof() catch return false) == .null; + } + + pub fn isUndefined(self: Value) bool { + return (self.val.typeof() catch return false) == .undefined; + } + + pub fn isArray(self: Value) bool { + return self.val.isArray() catch return false; + } + + pub fn isDate(self: Value) bool { + return self.val.isDate() catch return false; + } + + pub fn isTypedArray(self: Value) bool { + return self.val.isTypedarray() catch return false; + } + + pub fn isPromise(self: Value) bool { + return self.val.isPromise() catch return false; + } + + // -- Narrowing methods (type-checked) -- + + fn expectType(self: Value, expected: ValueType) !void { + const actual = try self.val.typeof(); + if (actual != expected) return error.TypeMismatch; + } + + pub fn asNumber(self: Value) !Number { + try self.expectType(.number); + return .{ .val = self.val }; + } + + pub fn asString(self: Value) !String { + try self.expectType(.string); + return .{ .val = self.val }; + } + + pub fn asBoolean(self: Value) !Boolean { + try self.expectType(.boolean); + return .{ .val = self.val }; + } + + pub fn asBigInt(self: Value) !BigInt { + try self.expectType(.bigint); + return .{ .val = self.val }; + } + + pub fn asDate(self: Value) !Date { + if (!(self.val.isDate() catch return error.TypeMismatch)) return error.TypeMismatch; + return .{ .val = self.val }; + } + + pub fn asArray(self: Value) !Array { + if (!(self.val.isArray() catch return error.TypeMismatch)) return error.TypeMismatch; + return .{ .val = self.val }; + } + + pub fn asFunction(self: Value) !Function { + try self.expectType(.function); + return .{ .val = self.val }; + } + + pub fn asObject(self: Value, comptime T: type) !@import("object.zig").Object(T) { + try self.expectType(.object); + return .{ .val = self.val }; + } + + // -- TypedArray narrowing (validates isTypedArray + specific subtype) -- + + fn expectTypedArrayOfType(self: Value, expected: TypedarrayType) !void { + if (!(self.val.isTypedarray() catch return error.TypeMismatch)) return error.TypeMismatch; + const info = self.val.getTypedarrayInfo() catch return error.TypeMismatch; + if (info.array_type != expected) return error.TypeMismatch; + } + + pub fn asInt8Array(self: Value) !typed_arrays.Int8Array { + try self.expectTypedArrayOfType(.int8); + return .{ .val = self.val }; + } + + pub fn asUint8Array(self: Value) !typed_arrays.Uint8Array { + try self.expectTypedArrayOfType(.uint8); + return .{ .val = self.val }; + } + + pub fn asUint8ClampedArray(self: Value) !typed_arrays.Uint8ClampedArray { + try self.expectTypedArrayOfType(.uint8_clamped); + return .{ .val = self.val }; + } + + pub fn asInt16Array(self: Value) !typed_arrays.Int16Array { + try self.expectTypedArrayOfType(.int16); + return .{ .val = self.val }; + } + + pub fn asUint16Array(self: Value) !typed_arrays.Uint16Array { + try self.expectTypedArrayOfType(.uint16); + return .{ .val = self.val }; + } + + pub fn asInt32Array(self: Value) !typed_arrays.Int32Array { + try self.expectTypedArrayOfType(.int32); + return .{ .val = self.val }; + } + + pub fn asUint32Array(self: Value) !typed_arrays.Uint32Array { + try self.expectTypedArrayOfType(.uint32); + return .{ .val = self.val }; + } + + pub fn asFloat32Array(self: Value) !typed_arrays.Float32Array { + try self.expectTypedArrayOfType(.float32); + return .{ .val = self.val }; + } + + pub fn asFloat64Array(self: Value) !typed_arrays.Float64Array { + try self.expectTypedArrayOfType(.float64); + return .{ .val = self.val }; + } + + pub fn asBigInt64Array(self: Value) !typed_arrays.BigInt64Array { + try self.expectTypedArrayOfType(.bigint64); + return .{ .val = self.val }; + } + + pub fn asBigUint64Array(self: Value) !typed_arrays.BigUint64Array { + try self.expectTypedArrayOfType(.biguint64); + return .{ .val = self.val }; + } + + pub fn toValue(self: Value) napi.Value { + return self.val; + } +}; diff --git a/src/js/wrap_class.zig b/src/js/wrap_class.zig new file mode 100644 index 0000000..1cfb48f --- /dev/null +++ b/src/js/wrap_class.zig @@ -0,0 +1,785 @@ +const std = @import("std"); +const napi = @import("../napi.zig"); +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(...)` +/// or legacy `pub const js_class = true`), returns a type with comptime-generated +/// N-API constructor, finalizer, property descriptors, and method wrappers. +pub fn wrapClass(comptime T: type) type { + if (!class_meta.isClassType(T)) { + @compileError("wrapClass: " ++ @typeName(T) ++ " must declare `pub const js_meta = js.class(...)`"); + } + + if (!@hasDecl(T, "init")) { + @compileError("wrapClass: " ++ @typeName(T) ++ " must have a `pub fn init(...)` constructor"); + } + + return struct { + const all_decls = @typeInfo(T).@"struct".decls; + const max_property_count = if (class_meta.hasProperties(T)) + class_meta.propertyFields(T).len + else if (@hasDecl(T, "js_getters")) + @typeInfo(@TypeOf(T.js_getters)).@"struct".fields.len + else + 0; + + const MethodCategory = enum { instance_method, static_method, skip }; + const GetterKind = enum { method, field }; + + const PropertyMeta = struct { + name: []const u8, + getter_kind: GetterKind, + getter_name: ?[]const u8 = null, + setter_name: ?[]const u8 = null, + field_name: ?[]const u8 = null, + is_by_value: bool = false, + }; + + const MethodMeta = struct { + name: []const u8, + category: MethodCategory = .skip, + is_by_value: bool = false, + }; + + const ClassAnalysis = struct { + properties: [max_property_count]PropertyMeta, + property_count: usize, + methods: [all_decls.len]MethodMeta, + method_count: usize, + }; + + const analysis = analyzeClass(); + + pub const constructor: napi.c.napi_callback = genConstructor(); + + pub fn defaultFinalize(_: napi.Env, obj: *T, hint: ?*anyopaque) void { + if (class_runtime.isInternalPlaceholderHint(T, hint)) { + class_runtime.destroyInternalPlaceholder(T, obj); + return; + } + class_runtime.destroyNativeObject(T, obj); + } + + fn isClassSelfParam(comptime ParamType: type) bool { + return ParamType == *T or ParamType == *const T or ParamType == T; + } + + fn isStaticMethod(comptime params: []const std.builtin.Type.Fn.Param) bool { + return params.len == 0 or !isClassSelfParam(params[0].type.?); + } + + fn returnedClassType(comptime Func: type) ?type { + const ReturnType = @typeInfo(Func).@"fn".return_type.?; + return switch (@typeInfo(ReturnType)) { + .error_union => |eu| switch (@typeInfo(eu.payload)) { + .optional => |opt| if (class_meta.isClassType(opt.child)) opt.child else null, + else => if (class_meta.isClassType(eu.payload)) eu.payload else null, + }, + .optional => |opt| if (class_meta.isClassType(opt.child)) opt.child else null, + else => if (class_meta.isClassType(ReturnType)) ReturnType else null, + }; + } + + fn shouldSkipDecl(comptime name: []const u8) bool { + return std.mem.eql(u8, name, "init") or + std.mem.eql(u8, name, "deinit") or + std.mem.eql(u8, name, "js_meta") or + std.mem.eql(u8, name, "js_class") or + std.mem.eql(u8, name, "js_getters") or + std.mem.eql(u8, name, "js_setters"); + } + + fn legacySetterTarget(comptime name: []const u8) ?[]const u8 { + if (name.len > 4 and std.mem.eql(u8, name[0..4], "set_")) { + return name[4..]; + } + return null; + } + + fn tupleContains(comptime tuple: anytype, comptime name: []const u8) bool { + inline for (tuple) |entry| { + if (std.mem.eql(u8, entry, name)) return true; + } + return false; + } + + fn capitalizeFirst(comptime name: []const u8) []const u8 { + if (name.len == 0) return name; + comptime var buf: [name.len]u8 = undefined; + buf[0] = std.ascii.toUpper(name[0]); + inline for (name[1..], 1..) |ch, idx| { + buf[idx] = ch; + } + return &buf; + } + + fn derivedSetterName(comptime property_name: []const u8) []const u8 { + return "set" ++ capitalizeFirst(property_name); + } + + fn accessorName(comptime property_name: []const u8, accessor: class_meta.AccessorRef, comptime is_setter: bool) ?[]const u8 { + return switch (accessor) { + .none => null, + .derived => if (is_setter) derivedSetterName(property_name) else property_name, + .named => |name| name, + }; + } + + fn validateGetterMethod(comptime getter_name: []const u8, comptime getter_field: anytype) bool { + const getter_info = @typeInfo(@TypeOf(getter_field)); + if (getter_info != .@"fn") { + @compileError("property getter '" ++ getter_name ++ "' is not a function in " ++ @typeName(T)); + } + + const getter_params = getter_info.@"fn".params; + if (getter_params.len == 0 or !isClassSelfParam(getter_params[0].type.?)) { + @compileError("getter '" ++ getter_name ++ "' must have a self parameter in " ++ @typeName(T)); + } + if (getter_params.len > 1) { + @compileError("getter '" ++ getter_name ++ "' must take only self"); + } + + const ReturnType = getter_info.@"fn".return_type.?; + const InnerReturn = if (@typeInfo(ReturnType) == .error_union) + @typeInfo(ReturnType).error_union.payload + else + ReturnType; + if (InnerReturn == void) { + @compileError("getter '" ++ getter_name ++ "' must return a value"); + } + + return getter_params[0].type.? == T; + } + + fn validateSetterMethod(comptime setter_name: []const u8, comptime setter_field: anytype) void { + const setter_info = @typeInfo(@TypeOf(setter_field)); + if (setter_info != .@"fn") { + @compileError("property setter '" ++ setter_name ++ "' is not a function in " ++ @typeName(T)); + } + + const setter_params = setter_info.@"fn".params; + if (setter_params.len == 0 or setter_params[0].type.? != *T) { + @compileError("setter '" ++ setter_name ++ "' must take self as *" ++ @typeName(T)); + } + if (setter_params.len != 2) { + @compileError("setter '" ++ setter_name ++ "' must take exactly one argument besides self"); + } + + const SetterReturn = setter_info.@"fn".return_type.?; + const SetterInner = if (@typeInfo(SetterReturn) == .error_union) + @typeInfo(SetterReturn).error_union.payload + else + SetterReturn; + if (SetterInner != void) { + @compileError("setter '" ++ setter_name ++ "' must return void or !void"); + } + } + + fn validateFieldProperty(comptime field_name: []const u8) void { + const fields = @typeInfo(T).@"struct".fields; + inline for (fields) |field_info| { + if (std.mem.eql(u8, field_info.name, field_name)) return; + } + @compileError("js.field references missing field '" ++ field_name ++ "' in " ++ @typeName(T)); + } + + fn addProperty(props: anytype, count: *usize, meta: PropertyMeta) void { + props[count.*] = meta; + count.* += 1; + } + + fn analyzeClass() ClassAnalysis { + @setEvalBranchQuota(@max(50_000, 1000 + all_decls.len * 64 + max_property_count * 256)); + + var properties: [max_property_count]PropertyMeta = undefined; + var property_count: usize = 0; + + var consumed_methods = [_]bool{false} ** all_decls.len; + var methods: [all_decls.len]MethodMeta = undefined; + inline for (all_decls, 0..) |decl, idx| { + methods[idx] = .{ .name = decl.name }; + } + + if (class_meta.hasProperties(T)) { + inline for (class_meta.propertyFields(T)) |prop_field| { + const property_name = prop_field.name; + const spec = @field(T.js_meta.options.properties, property_name); + switch (class_meta.propertyKind(spec)) { + .computed => { + const getter_field = @field(T, property_name); + const is_by_value = validateGetterMethod(property_name, getter_field); + addProperty(&properties, &property_count, .{ + .name = property_name, + .getter_kind = .method, + .getter_name = property_name, + .is_by_value = is_by_value, + }); + consumedMethod(&consumed_methods, property_name); + }, + .field => { + validateFieldProperty(spec.field_name); + addProperty(&properties, &property_count, .{ + .name = property_name, + .getter_kind = .field, + .field_name = spec.field_name, + }); + }, + .prop => { + const getter_name = accessorName(property_name, spec.get, false) orelse + @compileError("js.prop for '" ++ property_name ++ "' requires a getter"); + const getter_field = @field(T, getter_name); + const is_by_value = validateGetterMethod(getter_name, getter_field); + + const setter_name = accessorName(property_name, spec.set, true); + if (setter_name) |name| { + validateSetterMethod(name, @field(T, name)); + consumedMethod(&consumed_methods, name); + } + + addProperty(&properties, &property_count, .{ + .name = property_name, + .getter_kind = .method, + .getter_name = getter_name, + .setter_name = setter_name, + .is_by_value = is_by_value, + }); + consumedMethod(&consumed_methods, getter_name); + }, + .invalid => @compileError("unsupported property spec for '" ++ property_name ++ "'"), + } + } + } else if (@hasDecl(T, "js_getters")) { + inline for (T.js_getters) |getter_name| { + const getter_field = @field(T, getter_name); + const is_by_value = validateGetterMethod(getter_name, getter_field); + const setter_name = if (@hasDecl(T, "js_setters") and tupleContains(T.js_setters, getter_name)) + "set_" ++ getter_name + else + null; + + if (setter_name) |name| { + validateSetterMethod(name, @field(T, name)); + consumedMethod(&consumed_methods, name); + } + + addProperty(&properties, &property_count, .{ + .name = getter_name, + .getter_kind = .method, + .getter_name = getter_name, + .setter_name = setter_name, + .is_by_value = is_by_value, + }); + consumedMethod(&consumed_methods, getter_name); + } + } + + var method_count: usize = 0; + inline for (all_decls, 0..) |decl, idx| { + const name = decl.name; + if (shouldSkipDecl(name) or consumed_methods[idx]) continue; + + const field = @field(T, name); + const field_info = @typeInfo(@TypeOf(field)); + if (field_info != .@"fn") continue; + + if (@hasDecl(T, "js_setters")) { + if (legacySetterTarget(name)) |target| { + if (tupleContains(T.js_setters, target)) continue; + } + } + + const params = field_info.@"fn".params; + methods[idx].category = if (isStaticMethod(params)) .static_method else .instance_method; + methods[idx].is_by_value = if (params.len > 0 and isClassSelfParam(params[0].type.?)) + params[0].type.? == T + else + false; + method_count += 1; + } + + return .{ + .properties = properties, + .property_count = property_count, + .methods = methods, + .method_count = method_count, + }; + } + + fn consumedMethod(flags: *[all_decls.len]bool, comptime name: []const u8) void { + inline for (all_decls, 0..) |decl, idx| { + if (std.mem.eql(u8, decl.name, name)) { + flags[idx] = true; + return; + } + } + @compileError("property accessor '" ++ name ++ "' does not match any public declaration in " ++ @typeName(T)); + } + + pub fn getPropertyDescriptors() []const napi.c.napi_property_descriptor { + const descriptor_count = analysis.property_count + analysis.method_count; + if (descriptor_count == 0) return &[0]napi.c.napi_property_descriptor{}; + + const descriptors = comptime blk: { + var descs: [descriptor_count]napi.c.napi_property_descriptor = undefined; + var idx: usize = 0; + + for (analysis.properties[0..analysis.property_count]) |prop| { + var desc = std.mem.zeroes(napi.c.napi_property_descriptor); + const property_name: [:0]const u8 = prop.name ++ ""; + desc.utf8name = property_name.ptr; + desc.getter = switch (prop.getter_kind) { + .method => wrapGetter(T, @field(T, prop.getter_name.?), prop.is_by_value), + .field => wrapFieldGetter(T, prop.field_name.?), + }; + if (prop.setter_name) |setter_name| { + desc.setter = wrapSetter(T, @field(T, setter_name)); + } + desc.attributes = @intFromEnum(napi.value_types.PropertyAttributes.default_jsproperty); + descs[idx] = desc; + idx += 1; + } + + for (analysis.methods) |meta| { + if (meta.category == .skip) continue; + const field = @field(T, meta.name); + var desc = std.mem.zeroes(napi.c.napi_property_descriptor); + const method_name: [:0]const u8 = meta.name ++ ""; + desc.utf8name = method_name.ptr; + switch (meta.category) { + .instance_method => { + desc.method = wrapMethod(T, field, meta.is_by_value); + desc.attributes = @intFromEnum(napi.value_types.PropertyAttributes.default_method); + }, + .static_method => { + desc.method = wrapStaticMethod(T, field); + desc.attributes = @intFromEnum(napi.value_types.PropertyAttributes.default_method) | + @intFromEnum(napi.value_types.PropertyAttributes.static); + }, + .skip => unreachable, + } + descs[idx] = desc; + idx += 1; + } + + break :blk descs; + }; + + return &descriptors; + } + + pub fn hasFactories() bool { + return false; + } + + pub fn getFactoryDescriptors(_: napi.c.napi_value) []const napi.c.napi_property_descriptor { + return &[0]napi.c.napi_property_descriptor{}; + } + + fn genConstructor() napi.c.napi_callback { + const init_fn = @field(T, "init"); + const InitFnType = @TypeOf(init_fn); + const init_info = @typeInfo(InitFnType).@"fn"; + const init_params = init_info.params; + const init_argc = init_params.len; + + const cb = struct { + pub fn callback(raw_env: napi.c.napi_env, cb_info: napi.c.napi_callback_info) callconv(.C) napi.c.napi_value { + const e = napi.Env{ .env = raw_env }; + const prev = context.setEnv(e); + defer context.restoreEnv(prev); + + const cb_argc = if (init_argc > 0) init_argc else 1; + var raw_args: [cb_argc]napi.c.napi_value = std.mem.zeroes([cb_argc]napi.c.napi_value); + var actual_argc: usize = cb_argc; + var this_arg: napi.c.napi_value = null; + napi.status.check(napi.c.napi_get_cb_info( + raw_env, + cb_info, + &actual_argc, + &raw_args, + &this_arg, + null, + )) catch { + e.throwError("", "Failed to get callback info in constructor") catch {}; + return null; + }; + + if (actual_argc == 1) { + const internal_arg = napi.Value{ .env = raw_env, .value = raw_args[0] }; + if ((internal_arg.typeof() catch null) == .external) { + const obj_ptr = std.heap.c_allocator.create(T) catch { + e.throwError("", "Out of memory allocating internal placeholder") catch {}; + return null; + }; + obj_ptr.* = std.mem.zeroes(T); + + const this_val = napi.Value{ .env = raw_env, .value = this_arg }; + _ = e.wrap(this_val, T, obj_ptr, defaultFinalize, class_runtime.internalPlaceholderHint(T), null) catch { + std.heap.c_allocator.destroy(obj_ptr); + e.throwError("", "Failed to wrap internal placeholder") catch {}; + return null; + }; + return this_arg; + } + } + + const required_init_argc = comptime wrap_function.requiredArgCount(init_params); + if (required_init_argc > 0 and actual_argc < required_init_argc) { + e.throwTypeError("", "Constructor expects at least " ++ std.fmt.comptimePrint("{d}", .{required_init_argc}) ++ " arguments") catch {}; + return null; + } + + 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); + } + + const init_result = callInit(init_fn, args) orelse return null; + + const obj_ptr = std.heap.c_allocator.create(T) catch { + e.throwError("", "Out of memory allocating native object") catch {}; + return null; + }; + obj_ptr.* = init_result; + + const this_val = napi.Value{ .env = raw_env, .value = this_arg }; + _ = e.wrap(this_val, T, obj_ptr, defaultFinalize, class_runtime.internalPlaceholderHint(T), null) catch { + std.heap.c_allocator.destroy(obj_ptr); + e.throwError("", "Failed to wrap native object") catch {}; + return null; + }; + + return this_arg; + } + }; + return cb.callback; + } + + fn callInit(comptime init_fn: anytype, args: std.meta.ArgsTuple(@TypeOf(init_fn))) ?T { + const ReturnType = @typeInfo(@TypeOf(init_fn)).@"fn".return_type.?; + const ret_info = @typeInfo(ReturnType); + + if (ret_info == .error_union) { + const result = @call(.auto, init_fn, args) catch |err| { + const e = napi.Env{ .env = context.env().env }; + e.throwError(@errorName(err), @errorName(err)) catch {}; + return null; + }; + + const Payload = ret_info.error_union.payload; + if (@typeInfo(Payload) == .optional) { + return result orelse { + const e = napi.Env{ .env = context.env().env }; + e.throwError("", "Constructor returned null") catch {}; + return null; + }; + } + return result; + } + + if (ret_info == .optional) { + return @call(.auto, init_fn, args) orelse { + const e = napi.Env{ .env = context.env().env }; + e.throwError("", "Constructor returned null") catch {}; + return null; + }; + } + + return @call(.auto, init_fn, args); + } + + fn wrapMethod(comptime Class: type, comptime method: anytype, comptime is_by_value: bool) napi.c.napi_callback { + const MethodFnType = @TypeOf(method); + const method_info = @typeInfo(MethodFnType).@"fn"; + const method_params = method_info.params; + const js_argc = method_params.len - 1; + const ReturnClass = comptime returnedClassType(MethodFnType); + const prefers_receiver_ctor = ReturnClass != null and ReturnClass.? == Class; + + const method_cb = struct { + pub fn callback(raw_env: napi.c.napi_env, cb_info: napi.c.napi_callback_info) callconv(.C) napi.c.napi_value { + const e = napi.Env{ .env = raw_env }; + const prev_env = context.setEnv(e); + defer context.restoreEnv(prev_env); + + var raw_args: [if (js_argc > 0) js_argc else 1]napi.c.napi_value = std.mem.zeroes([if (js_argc > 0) js_argc else 1]napi.c.napi_value); + var actual_argc: usize = js_argc; + var this_arg: napi.c.napi_value = null; + napi.status.check(napi.c.napi_get_cb_info( + raw_env, + cb_info, + &actual_argc, + if (js_argc > 0) &raw_args else null, + &this_arg, + null, + )) catch { + e.throwError("", "Failed to get callback info in method") catch {}; + return null; + }; + + const required_js_argc = comptime wrap_function.requiredArgCount(method_params[1..]); + if (required_js_argc > 0 and actual_argc < required_js_argc) { + e.throwTypeError("", "Method expects at least " ++ std.fmt.comptimePrint("{d}", .{required_js_argc}) ++ " arguments") catch {}; + return null; + } + + const this_val = napi.Value{ .env = raw_env, .value = this_arg }; + const self_ptr = e.unwrap(Class, this_val) catch { + e.throwError("", "Failed to unwrap native object") catch {}; + return null; + }; + + const prev_this = context.setThis(this_val); + defer context.restoreThis(prev_this); + + var args: std.meta.ArgsTuple(MethodFnType) = undefined; + args[0] = if (is_by_value) self_ptr.* else self_ptr; + + 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); + } + + const preferred_ctor = if (prefers_receiver_ctor) + (this_val.getNamedProperty("constructor") catch null) + else + null; + return wrap_function.callAndConvertWithCtor(method, args, raw_env, preferred_ctor); + } + }; + return method_cb.callback; + } + + fn wrapGetter(comptime Class: type, comptime getter_fn: anytype, comptime is_by_value: bool) napi.c.napi_callback { + const GetterFnType = @TypeOf(getter_fn); + const ReturnClass = comptime returnedClassType(GetterFnType); + const prefers_receiver_ctor = ReturnClass != null and ReturnClass.? == Class; + + const getter_cb = struct { + pub fn callback(raw_env: napi.c.napi_env, cb_info: napi.c.napi_callback_info) callconv(.C) napi.c.napi_value { + const e = napi.Env{ .env = raw_env }; + const prev_env = context.setEnv(e); + defer context.restoreEnv(prev_env); + + var argc: usize = 0; + var this_arg: napi.c.napi_value = null; + napi.status.check(napi.c.napi_get_cb_info( + raw_env, + cb_info, + &argc, + null, + &this_arg, + null, + )) catch { + e.throwError("", "Failed to get callback info in getter") catch {}; + return null; + }; + + const this_val = napi.Value{ .env = raw_env, .value = this_arg }; + const self_ptr = e.unwrap(Class, this_val) catch { + e.throwError("", "Failed to unwrap native object in getter") catch {}; + return null; + }; + + const prev_this = context.setThis(this_val); + defer context.restoreThis(prev_this); + + var args: std.meta.ArgsTuple(GetterFnType) = undefined; + args[0] = if (is_by_value) self_ptr.* else self_ptr; + + const preferred_ctor = if (prefers_receiver_ctor) + (this_val.getNamedProperty("constructor") catch null) + else + null; + return wrap_function.callAndConvertWithCtor(getter_fn, args, raw_env, preferred_ctor); + } + }; + return getter_cb.callback; + } + + fn wrapStaticMethod(comptime Class: type, comptime method: anytype) napi.c.napi_callback { + const MethodFnType = @TypeOf(method); + const method_info = @typeInfo(MethodFnType).@"fn"; + const method_params = method_info.params; + const method_argc = method_params.len; + const required_argc = comptime wrap_function.requiredArgCount(method_params); + const ReturnClass = comptime returnedClassType(MethodFnType); + const prefers_this_ctor = ReturnClass != null and ReturnClass.? == Class; + + const static_cb = struct { + pub fn callback(raw_env: napi.c.napi_env, cb_info: napi.c.napi_callback_info) callconv(.C) napi.c.napi_value { + const e = napi.Env{ .env = raw_env }; + const prev_env = context.setEnv(e); + defer context.restoreEnv(prev_env); + + var raw_args: [if (method_argc > 0) method_argc else 1]napi.c.napi_value = std.mem.zeroes([if (method_argc > 0) method_argc else 1]napi.c.napi_value); + var actual_argc: usize = method_argc; + var this_arg: napi.c.napi_value = null; + napi.status.check(napi.c.napi_get_cb_info( + raw_env, + cb_info, + &actual_argc, + if (method_argc > 0) &raw_args else null, + &this_arg, + null, + )) catch { + e.throwError("", "Failed to get callback info in static method") catch {}; + return null; + }; + + if (required_argc > 0 and actual_argc < required_argc) { + e.throwTypeError("", "Method expects at least " ++ std.fmt.comptimePrint("{d}", .{required_argc}) ++ " arguments") catch {}; + return null; + } + + 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); + } + + const preferred_ctor = if (prefers_this_ctor) + napi.Value{ .env = raw_env, .value = this_arg } + else + null; + return wrap_function.callAndConvertWithCtor(method, args, raw_env, preferred_ctor); + } + }; + return static_cb.callback; + } + + fn wrapFieldGetter(comptime Class: type, comptime field_name: []const u8) napi.c.napi_callback { + const FieldType = fieldType(field_name); + + const getter_cb = struct { + pub fn callback(raw_env: napi.c.napi_env, cb_info: napi.c.napi_callback_info) callconv(.C) napi.c.napi_value { + const e = napi.Env{ .env = raw_env }; + const prev_env = context.setEnv(e); + defer context.restoreEnv(prev_env); + + var argc: usize = 0; + var this_arg: napi.c.napi_value = null; + napi.status.check(napi.c.napi_get_cb_info( + raw_env, + cb_info, + &argc, + null, + &this_arg, + null, + )) catch { + e.throwError("", "Failed to get callback info in field getter") catch {}; + return null; + }; + + const this_val = napi.Value{ .env = raw_env, .value = this_arg }; + const self_ptr = e.unwrap(Class, this_val) catch { + e.throwError("", "Failed to unwrap native object in field getter") catch {}; + return null; + }; + + const field_value: FieldType = @field(self_ptr.*, field_name); + return convertFieldValue(FieldType, field_value, raw_env); + } + }; + return getter_cb.callback; + } + + fn fieldType(comptime field_name: []const u8) type { + inline for (@typeInfo(T).@"struct".fields) |field_info| { + if (std.mem.eql(u8, field_info.name, field_name)) return field_info.type; + } + @compileError("unknown field '" ++ field_name ++ "' on " ++ @typeName(T)); + } + + fn convertFieldValue(comptime FieldType: type, value: FieldType, raw_env: napi.c.napi_env) napi.c.napi_value { + if (FieldType == bool) return @import("boolean.zig").Boolean.from(value).toValue().value; + + switch (@typeInfo(FieldType)) { + .int, .comptime_int, .float, .comptime_float => return @import("number.zig").Number.from(value).toValue().value, + .pointer => |ptr| { + if (ptr.size == .slice and ptr.child == u8 and ptr.is_const) { + return @import("string.zig").String.from(value).toValue().value; + } + }, + else => {}, + } + + if (wrap_function.isDslType(FieldType)) { + return value.val.value; + } + if (class_meta.isClassType(FieldType)) { + return wrap_function.convertReturn(FieldType, value, raw_env); + } + + @compileError("js.field unsupported field type " ++ @typeName(FieldType) ++ " on " ++ @typeName(T)); + } + + fn wrapSetter(comptime Class: type, comptime setter_fn: anytype) napi.c.napi_callback { + const SetterFnType = @TypeOf(setter_fn); + const setter_info = @typeInfo(SetterFnType).@"fn"; + const setter_params = setter_info.params; + const ValueParamType = setter_params[1].type.?; + + const setter_cb = struct { + pub fn callback(raw_env: napi.c.napi_env, cb_info: napi.c.napi_callback_info) callconv(.C) napi.c.napi_value { + const e = napi.Env{ .env = raw_env }; + const prev_env = context.setEnv(e); + defer context.restoreEnv(prev_env); + + var raw_args: [1]napi.c.napi_value = .{null}; + var argc: usize = 1; + var this_arg: napi.c.napi_value = null; + napi.status.check(napi.c.napi_get_cb_info( + raw_env, + cb_info, + &argc, + &raw_args, + &this_arg, + null, + )) catch { + e.throwError("", "Failed to get callback info in setter") catch {}; + return null; + }; + + const this_val = napi.Value{ .env = raw_env, .value = this_arg }; + const self_ptr = e.unwrap(Class, this_val) catch { + e.throwError("", "Failed to unwrap native object in setter") catch {}; + return null; + }; + + const prev_this = context.setThis(this_val); + defer context.restoreThis(prev_this); + + const value_arg = convertArg(ValueParamType, raw_args[0], raw_env); + + var args: std.meta.ArgsTuple(SetterFnType) = undefined; + args[0] = self_ptr; + args[1] = value_arg; + + const SetterReturnType = setter_info.return_type.?; + if (@typeInfo(SetterReturnType) == .error_union) { + @call(.auto, setter_fn, args) catch |err| { + e.throwError(@errorName(err), @errorName(err)) catch {}; + return null; + }; + } else { + @call(.auto, setter_fn, args); + } + + return null; + } + }; + return setter_cb.callback; + } + }; +} + +test "wrapClass compile-time validation requires class metadata" { + try std.testing.expect(true); +} diff --git a/src/js/wrap_function.zig b/src/js/wrap_function.zig new file mode 100644 index 0000000..1ccd17e --- /dev/null +++ b/src/js/wrap_function.zig @@ -0,0 +1,240 @@ +const std = @import("std"); +const napi = @import("../napi.zig"); +const context = @import("context.zig"); +const class_meta = @import("class_meta.zig"); +const class_runtime = @import("class_runtime.zig"); + +/// Checks whether `T` is a DSL wrapper type (a struct with a `val: napi.Value` field). +pub fn isDslType(comptime T: type) bool { + const info = @typeInfo(T); + if (info != .@"struct") return false; + inline for (info.@"struct".fields) |field| { + if (std.mem.eql(u8, field.name, "val") and field.type == napi.Value) { + return true; + } + } + return false; +} + +/// Checks if T is a DSL type, napi.Value, or an optional wrapping one of those. +pub fn isDslOrOptionalDsl(comptime T: type) bool { + if (T == napi.Value) return true; + if (isDslType(T)) return true; + if (class_meta.isClassType(T)) return true; + if (@typeInfo(T) == .pointer) { + const ptr = @typeInfo(T).pointer; + if (ptr.size == .one and class_meta.isClassType(ptr.child)) return true; + } + if (@typeInfo(T) == .optional) { + const Inner = @typeInfo(T).optional.child; + if (Inner == napi.Value or isDslType(Inner) or class_meta.isClassType(Inner)) return true; + if (@typeInfo(Inner) == .pointer) { + const ptr = @typeInfo(Inner).pointer; + return ptr.size == .one and class_meta.isClassType(ptr.child); + } + } + return false; +} + +/// Counts the number of required (non-optional) parameters. +pub fn requiredArgCount(comptime params: []const std.builtin.Type.Fn.Param) usize { + var count: usize = 0; + for (params) |p| { + const PT = p.type orelse continue; + if (@typeInfo(PT) != .optional) count += 1; + } + return count; +} + +/// Wraps a raw napi.Value into a DSL wrapper type by setting its `val` field. +pub fn convertArg(comptime T: type, raw: napi.c.napi_value, env: napi.c.napi_env) T { + if (T == napi.Value) { + return napi.Value{ .env = env, .value = raw }; + } + if (comptime isDslType(T)) { + return T{ .val = napi.Value{ .env = env, .value = raw } }; + } + if (comptime class_meta.isClassType(T)) { + const e = napi.Env{ .env = env }; + const obj = napi.Value{ .env = env, .value = raw }; + const ptr = e.unwrap(T, obj) catch @panic("convertArg: failed to unwrap class instance"); + return ptr.*; + } + switch (@typeInfo(T)) { + .pointer => |ptr| { + if (ptr.size == .one and class_meta.isClassType(ptr.child)) { + const e = napi.Env{ .env = env }; + const obj = napi.Value{ .env = env, .value = raw }; + return e.unwrap(ptr.child, obj) catch @panic("convertArg: failed to unwrap class pointer"); + } + }, + else => {}, + } + @compileError("convertArg: unsupported type " ++ @typeName(T)); +} + +/// Like convertArg, but handles optional types (?T). +/// Returns null if the argument is omitted or explicitly `undefined`, +/// otherwise wraps as the inner type. +pub fn convertArgWithOptional( + comptime T: type, + raw: napi.c.napi_value, + env: napi.c.napi_env, + param_index: usize, + actual_argc: usize, +) T { + if (@typeInfo(T) == .optional) { + if (param_index >= actual_argc) return null; + const raw_value = napi.Value{ .env = env, .value = raw }; + if ((raw_value.typeof() catch null) == .undefined) return null; + const Inner = @typeInfo(T).optional.child; + return convertArg(Inner, raw, env); + } + return convertArg(T, raw, env); +} + +/// Extracts the raw napi_value from a DSL type, napi.Value, or handles void. +pub fn convertReturnWithCtor(comptime T: 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; + return result; + } + if (T == napi.Value) { + return value.value; + } + if (comptime isDslType(T)) { + return value.val.value; + } + if (comptime class_meta.isClassType(T)) { + const e = napi.Env{ .env = env }; + const instance = class_runtime.materializeClassInstance(T, e, value, preferred_ctor) catch { + e.throwError("", "Failed to materialize returned class instance") catch {}; + return null; + }; + return instance.value; + } + @compileError("convertReturn: unsupported return type " ++ @typeName(T)); +} + +pub fn convertReturn(comptime T: type, value: T, env: napi.c.napi_env) napi.c.napi_value { + return convertReturnWithCtor(T, value, env, null); +} + +/// Calls the user function with the given args tuple and converts the return value, +/// handling error unions (`!T`), optionals (`?T`), and combinations (`!?T`). +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 { + const ReturnType = @typeInfo(@TypeOf(func)).@"fn".return_type.?; + + const ret_info = @typeInfo(ReturnType); + + // !T or !?T — error union + if (ret_info == .error_union) { + const result = @call(.auto, func, args) catch |err| { + const e = napi.Env{ .env = env }; + e.throwError(@errorName(err), @errorName(err)) catch {}; + return null; + }; + + const Payload = ret_info.error_union.payload; + const payload_info = @typeInfo(Payload); + + // !?T — optional inside error union + if (payload_info == .optional) { + if (result) |val| { + return convertReturnWithCtor(payload_info.optional.child, 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; + return undef; + } + } + + // !T — plain error union + return convertReturnWithCtor(Payload, 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); + } else { + var undef: napi.c.napi_value = null; + napi.status.check(napi.c.napi_get_undefined(env, &undef)) catch return null; + return undef; + } + } + + // Plain T + const result = @call(.auto, func, args); + return convertReturnWithCtor(ReturnType, result, env, preferred_ctor); +} + +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); +} + +/// Generates a C-ABI napi_callback that wraps a DSL-typed Zig function. +/// The generated callback: +/// 1. Sets the thread-local env via context.setEnv/restoreEnv +/// 2. Extracts JS arguments via napi_get_cb_info +/// 3. Converts each arg to its DSL type via convertArg +/// 4. Calls the user function and handles the return via callAndConvert +pub fn wrapFunction(comptime func: anytype) napi.c.napi_callback { + const FnType = @TypeOf(func); + const fn_info = @typeInfo(FnType).@"fn"; + const params = fn_info.params; + const argc = params.len; + const required_argc = comptime requiredArgCount(params); + + const wrapper = struct { + pub fn callback(raw_env: napi.c.napi_env, cb_info: napi.c.napi_callback_info) callconv(.C) napi.c.napi_value { + const e = napi.Env{ .env = raw_env }; + const prev = context.setEnv(e); + defer context.restoreEnv(prev); + + var raw_args: [if (argc > 0) argc else 1]napi.c.napi_value = std.mem.zeroes([if (argc > 0) argc else 1]napi.c.napi_value); + var actual_argc: usize = argc; + var this_arg: napi.c.napi_value = null; + napi.status.check(napi.c.napi_get_cb_info( + raw_env, + cb_info, + &actual_argc, + if (argc > 0) &raw_args else null, + &this_arg, + null, + )) catch { + e.throwError("", "Failed to get callback info") catch {}; + return null; + }; + + if (required_argc > 0 and actual_argc < required_argc) { + e.throwTypeError("", "Expected at least " ++ std.fmt.comptimePrint("{d}", .{required_argc}) ++ " arguments") catch {}; + return null; + } + + 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); + } + + return callAndConvert(func, args, raw_env); + } + }; + return wrapper.callback; +} + +// Comptime tests — these validate the type-checking logic at compile time. +// They do not require N-API runtime, so they can run in the native test runner. + +test "isDslType recognizes DSL types" { + const FakeDsl = struct { val: napi.Value }; + try std.testing.expect(isDslType(FakeDsl)); +} + +test "isDslType rejects non-DSL types" { + try std.testing.expect(!isDslType(u32)); + try std.testing.expect(!isDslType(struct { x: u32 })); +} diff --git a/src/napi.zig b/src/napi.zig new file mode 100644 index 0000000..080f852 --- /dev/null +++ b/src/napi.zig @@ -0,0 +1,29 @@ +const std = @import("std"); + +pub const c = @import("c.zig"); +pub const AsyncContext = @import("AsyncContext.zig"); +pub const Env = @import("Env.zig"); +pub const Value = @import("Value.zig"); +pub const Deferred = @import("Deferred.zig"); +pub const EscapableHandleScope = @import("EscapableHandleScope.zig"); +pub const HandleScope = @import("HandleScope.zig"); +pub const NodeVersion = @import("NodeVersion.zig"); +pub const status = @import("status.zig"); +pub const module = @import("module.zig"); +pub const CallbackInfo = @import("callback_info.zig").CallbackInfo; +pub const Callback = @import("callback.zig").Callback; +pub const value_types = @import("value_types.zig"); + +pub const createCallback = @import("create_callback.zig").createCallback; +pub const registerDecls = @import("register_decls.zig").registerDecls; +pub const wrapFinalizeCallback = @import("finalize_callback.zig").wrapFinalizeCallback; +pub const wrapCallback = @import("callback.zig").wrapCallback; + +pub const AsyncWork = @import("async_work.zig").AsyncWork; +pub const ThreadSafeFunction = @import("threadsafe_function.zig").ThreadSafeFunction; +pub const CallMode = @import("threadsafe_function.zig").CallMode; +pub const ReleaseMode = @import("threadsafe_function.zig").ReleaseMode; + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/src/root.zig b/src/root.zig index 080f852..2525e67 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,29 +1,12 @@ const std = @import("std"); -pub const c = @import("c.zig"); -pub const AsyncContext = @import("AsyncContext.zig"); -pub const Env = @import("Env.zig"); -pub const Value = @import("Value.zig"); -pub const Deferred = @import("Deferred.zig"); -pub const EscapableHandleScope = @import("EscapableHandleScope.zig"); -pub const HandleScope = @import("HandleScope.zig"); -pub const NodeVersion = @import("NodeVersion.zig"); -pub const status = @import("status.zig"); -pub const module = @import("module.zig"); -pub const CallbackInfo = @import("callback_info.zig").CallbackInfo; -pub const Callback = @import("callback.zig").Callback; -pub const value_types = @import("value_types.zig"); +pub const napi = @import("napi.zig"); +pub const js = @import("js.zig"); -pub const createCallback = @import("create_callback.zig").createCallback; -pub const registerDecls = @import("register_decls.zig").registerDecls; -pub const wrapFinalizeCallback = @import("finalize_callback.zig").wrapFinalizeCallback; -pub const wrapCallback = @import("callback.zig").wrapCallback; - -pub const AsyncWork = @import("async_work.zig").AsyncWork; -pub const ThreadSafeFunction = @import("threadsafe_function.zig").ThreadSafeFunction; -pub const CallMode = @import("threadsafe_function.zig").CallMode; -pub const ReleaseMode = @import("threadsafe_function.zig").ReleaseMode; +// Backwards-compatible flat exports: all existing napi symbols +// remain accessible at the top level. +pub usingnamespace @import("napi.zig"); test { - @import("std").testing.refAllDecls(@This()); + std.testing.refAllDecls(@This()); } diff --git a/zbuild.zon b/zbuild.zon index c750ca9..014c3f8 100644 --- a/zbuild.zon +++ b/zbuild.zon @@ -14,10 +14,16 @@ }, }, .modules = .{ + .napi = .{ + .root_source_file = "src/root.zig", + .include_paths = .{"include"}, + .imports = .{.build_options}, + }, .zapi = .{ .root_source_file = "src/root.zig", .include_paths = .{"include"}, .imports = .{.build_options}, + .link_libc = true, }, }, .libraries = .{ @@ -41,5 +47,15 @@ .linker_allow_shlib_undefined = true, .dest_sub_path = "example_type_tag.node", }, + .example_js_dsl = .{ + .root_module = .{ + .root_source_file = "examples/js_dsl/mod.zig", + .imports = .{.zapi}, + .link_libc = true, + }, + .linkage = .dynamic, + .linker_allow_shlib_undefined = true, + .dest_sub_path = "example_js_dsl.node", + }, }, }