Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 50 additions & 3 deletions examples/js_dsl/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ describe("primitive types", () => {

describe("getValueBigintWords", () => {
it("reads words with null sign_bit (unsigned only)", () => {
expect(mod.bigIntFirstWord(0n)).toEqual(0);
expect(mod.bigIntFirstWord(1n)).toEqual(1);
expect(mod.bigIntFirstWord(0xdeadbeefn)).toEqual(0xdeadbeef);
expect(mod.bigIntSingleWord(0n)).toEqual(0);
expect(mod.bigIntSingleWord(1n)).toEqual(1);
expect(mod.bigIntSingleWord(0xdeadbeefn)).toEqual(0xdeadbeef);
});

it("reads correct sign (non-null sign_bit path)", () => {
Expand All @@ -110,6 +110,28 @@ describe("primitive types", () => {
expect(mod.bigIntSign(-0xffffffffffffffffn)).toEqual(1);
});

it("rejects BigInts wider than the provided word buffer", () => {
expect(() => mod.bigIntSingleWord(2n ** 64n)).toThrow();
expect(() => mod.bigIntSingleWord(2n ** 200n)).toThrow();
});
});

describe("toI128", () => {
it("round-trips values across the i128 range", () => {
expect(mod.bigIntToI128String(0n)).toEqual("0");
expect(mod.bigIntToI128String(123n)).toEqual("123");
expect(mod.bigIntToI128String(-123n)).toEqual("-123");
expect(mod.bigIntToI128String(2n ** 127n - 1n)).toEqual((2n ** 127n - 1n).toString());
expect(mod.bigIntToI128String(-(2n ** 127n))).toEqual((-(2n ** 127n)).toString());
});

it("rejects BigInts outside the i128 range instead of crashing", () => {
expect(() => mod.bigIntToI128String(2n ** 127n)).toThrow();
expect(() => mod.bigIntToI128String(-(2n ** 127n) - 1n)).toThrow();
expect(() => mod.bigIntToI128String(2n ** 128n)).toThrow();
expect(() => mod.bigIntToI128String(2n ** 200n)).toThrow();
expect(() => mod.bigIntToI128String(-(2n ** 200n))).toThrow();
});
});

it("tomorrow adds one day", () => {
Expand All @@ -125,6 +147,25 @@ describe("primitive types", () => {
});
});

describe("value narrowing", () => {
it("narrows numbers", () => {
expect(mod.narrowToNumber(42)).toEqual(42);
});

it("rejects non-numbers", () => {
expect(() => mod.narrowToNumber("42")).toThrow();
expect(() => mod.narrowToNumber({})).toThrow();
expect(() => mod.narrowToNumber(42n)).toThrow();
});

it("narrows typed arrays by exact subtype", () => {
expect(mod.narrowToUint8ArrayLen(new Uint8Array(3))).toEqual(3);
expect(() => mod.narrowToUint8ArrayLen(new Int8Array(3))).toThrow();
expect(() => mod.narrowToUint8ArrayLen(new Uint8ClampedArray(3))).toThrow();
expect(() => mod.narrowToUint8ArrayLen([1, 2, 3])).toThrow();
});
});

// Section 4: Typed Objects
describe("typed objects", () => {
it("formatConfig returns formatted string", () => {
Expand Down Expand Up @@ -235,6 +276,12 @@ describe("Counter class", () => {
expect(() => getCount.call(new mod.Buffer(4))).toThrow();
});

it("rejects constructor calls without new", () => {
expect(() => mod.Counter(5)).toThrow(TypeError);
expect(() => mod.Point()).toThrow(TypeError);
expect(() => Reflect.apply(mod.Counter, undefined, [5])).toThrow(TypeError);
});

it("increments", () => {
const c = new mod.Counter(0);
c.increment();
Expand Down
29 changes: 24 additions & 5 deletions examples/js_dsl/mod.zig
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,12 @@ pub fn doubleBigInt(n: BigInt) !BigInt {
return BigInt.from(val * 2);
}

/// Read a BigInt's first u64 word via `getValueBigintWords` passing `null` for `sign_bit`.
/// Read a single-word BigInt as a u64 via `getValueBigintWords` passing `null` for `sign_bit`.
///
/// Throws if word_count > 1.
pub fn bigIntFirstWord(n: BigInt) !Number {
/// Throws `Overflow` if the BigInt needs more than one word.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is the intended behaviour, maybe rename to bigIntSingleWord? bigIntFirstWord seems to imply there's more than one word and we're taking only the first

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — renamed to bigIntSingleWord (6a6231f). It reads a single-word BigInt and throws otherwise, so "first" was misleading.

pub fn bigIntSingleWord(n: BigInt) !Number {
var words: [1]u64 = .{0};
const got = try n.toValue().getValueBigintWords(null, &words);
if (got.len > 1) return error.BigIntTooLarge;
_ = try n.toValue().getValueBigintWords(null, &words);
return Number.from(words[0]);
}

Expand All @@ -118,6 +117,14 @@ pub fn bigIntSign(n: BigInt) !Number {
return Number.from(@as(u32, sign));
}

/// Convert a BigInt to an i128 and render it as a decimal string.
pub fn bigIntToI128String(n: BigInt) !String {
const v = try n.toI128();
var buf: [48]u8 = undefined;
const s = std.fmt.bufPrint(&buf, "{d}", .{v}) catch return error.FormatError;
return String.from(s);
}

/// Add one day (86400000ms) to a Date.
pub fn tomorrow(d: Date) Date {
const ts = d.assertTimestamp();
Expand Down Expand Up @@ -309,6 +316,18 @@ pub const Buffer = struct {
// Section 10: Mixed DSL + N-API
// ============================================================================

/// Narrow an untyped value to a Number via the `js.Value` narrowing API.
pub fn narrowToNumber(v: Value) !Number {
return v.asNumber();
}

/// Narrow an untyped value to a Uint8Array and return its length.
pub fn narrowToUint8ArrayLen(v: Value) !Number {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need the Len suffix?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept it — the fn returns the length (a Number), not the array, so the suffix names what comes back. Happy to drop it if you'd rather; it's just example code.

const arr = try v.asUint8Array();
const slice = try arr.toSlice();
return Number.from(@as(u32, @intCast(slice.len)));
}

/// 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 {
Expand Down
7 changes: 6 additions & 1 deletion src/Value.zig
Original file line number Diff line number Diff line change
Expand Up @@ -272,16 +272,21 @@ pub fn getValueBigintUint64(self: Value, lossless: ?*bool) NapiError!u64 {
/// In Ethereum's context, this is useful for big integers defined in the spec to be
/// unsigned.
///
/// Returns `error.Overflow` if the BigInt needs more words than `words` can hold:
/// napi sets the out `word_count` to the *required* count, which may exceed the
/// buffer, so slicing by it unchecked would read out of bounds.
///
/// NOTE: napi's C entry takes `int*` (4-byte aligned). Casting a u1 to int* is UB, since that
/// is 4-bytes aligned. We use a local `c_int` for the napi call and narrow back to `u1` for the caller.
///
/// Source: https://nodejs.org/api/n-api.html#napi_get_value_bigint_words
pub fn getValueBigintWords(self: Value, sign_bit: ?*u1, words: []u64) NapiError![]u64 {
pub fn getValueBigintWords(self: Value, sign_bit: ?*u1, words: []u64) (NapiError || error{Overflow})![]u64 {
var word_count: usize = words.len;
var raw_sign: c_int = 0;
try status.check(
c.napi_get_value_bigint_words(self.env, self.value, &raw_sign, &word_count, words.ptr),
);
if (word_count > words.len) return error.Overflow;
// napi guarantees raw_sign ∈ {0, 1}
if (sign_bit) |s| s.* = @intCast(raw_sign);
return words[0..word_count];
Expand Down
4 changes: 3 additions & 1 deletion src/callback.zig
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ pub fn wrapCallback(
e.throwError(@errorName(err), "NapiError") catch {};
} else {
const message: [:0]const u8 = "Native callback failed";
e.throwError(@tagName(error_info_status), message) catch {};
// @tagName would panic on a status the enum doesn't name.
const code = std.enums.tagName(status.Status, error_info_status) orelse "NapiError";
e.throwError(code, message) catch {};
}
} else {
e.throwError(@errorName(err), @errorName(err)) catch {};
Expand Down
11 changes: 7 additions & 4 deletions src/js/bigint.zig
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,22 @@ pub const BigInt = struct {
///
/// This function reads the BigInt as two 64-bit words and reconstructs it
/// into a Zig `i128`. It handles both positive and negative BigInts.
/// Returns an error if N-API operations fail.
/// Returns `error.Overflow` if the value is outside the i128 range, and an
/// error if N-API operations fail.
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 lo: u128 = if (result.len > 0) result[0] else 0;
const hi: u128 = if (result.len > 1) result[1] else 0;
const magnitude: u128 = (hi << 64) | lo;
const max_positive: u128 = std.math.maxInt(i128);
if (sign_bit == 1) {
// Negative: negate the magnitude
if (magnitude == 0) return 0;
if (magnitude > max_positive + 1) return error.Overflow;
if (magnitude == max_positive + 1) return std.math.minInt(i128);
return -@as(i128, @intCast(magnitude));
}
if (magnitude > max_positive) return error.Overflow;
return @intCast(magnitude);
}

Expand Down
26 changes: 16 additions & 10 deletions src/js/class_runtime.zig
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const std = @import("std");
const napi = @import("../napi.zig");
const context = @import("context.zig");

pub fn typeTag(comptime T: type) napi.c.napi_type_tag {
return .{
Expand All @@ -8,15 +9,17 @@ pub fn typeTag(comptime T: type) napi.c.napi_type_tag {
};
}

/// Tags `object`, then wraps `native_object` into it with a finalizer.
///
/// The wrap is done last so it is the only fallible step that transfers
/// ownership: once it succeeds N-API's finalizer owns `native_object`, and any
/// earlier failure leaves nothing wrapped, so the caller still owns and frees it.
pub fn wrapTaggedObject(comptime T: type, env: napi.Env, object: napi.Value, native_object: *T) !void {
const tag = typeTag(T);
try env.wrap(object, T, native_object, defaultFinalize(T), null, null);
errdefer if (env.removeWrap(T, object)) |removed| {
destroyNativeObject(T, removed);
} else |_| {};
if (!(try env.checkObjectTypeTag(object, tag))) {
try env.typeTagObject(object, tag);
}
try env.wrap(object, T, native_object, defaultFinalize(T), null, null);
}

/// Generates a deterministic 64-bit FNV-1a hash at compile-time.
Expand All @@ -41,7 +44,7 @@ pub fn destroyNativeObject(comptime T: type, obj: *T) void {
if (@hasDecl(T, "deinit")) {
obj.deinit();
}
std.heap.c_allocator.destroy(obj);
context.allocator().destroy(obj);
}

pub fn defaultFinalize(comptime T: type) napi.FinalizeCallback(T) {
Expand All @@ -60,17 +63,20 @@ pub fn registerClass(comptime T: type, env: napi.Env, ctor: napi.Value) !void {

if (State.find(env.env) != null) return;

const entry = try std.heap.c_allocator.create(State.Entry);
errdefer std.heap.c_allocator.destroy(entry);
const entry = try context.allocator().create(State.Entry);
errdefer context.allocator().destroy(entry);

entry.* = .{
.env = env.env,
.ctor_ref = try env.createReference(ctor, 1),
.next = State.head,
};
State.head = entry;
errdefer entry.ctor_ref.delete() catch {};

// Link only after the cleanup hook is registered: a hook failure must not
// leave a freed entry reachable from the list head.
try env.addEnvCleanupHook(State.Entry, entry, State.cleanupHook);
State.head = entry;
}

/// Per-thread marker set by `materializeClassInstance` to tell the generated
Expand Down Expand Up @@ -110,7 +116,7 @@ pub fn consumeMaterialization(comptime T: type, env: napi.Env, this_arg: napi.c.
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 obj_ptr = try std.heap.c_allocator.create(T);
const obj_ptr = try context.allocator().create(T);
errdefer destroyNativeObject(T, obj_ptr);
obj_ptr.* = instance;

Expand Down Expand Up @@ -198,7 +204,7 @@ fn state(comptime T: type) type {
if (current == entry) {
cursor.* = current.next;
current.ctor_ref.delete() catch {};
std.heap.c_allocator.destroy(current);
context.allocator().destroy(current);
return;
}
cursor = &current.next;
Expand Down
11 changes: 11 additions & 0 deletions src/js/value.zig
Original file line number Diff line number Diff line change
Expand Up @@ -245,4 +245,15 @@ pub const Value = struct {
pub fn toValue(self: Value) napi.Value {
return self.val;
}

fn expectType(self: Value, expected: ValueType) TypeError!void {
const actual = self.val.typeof() catch return error.TypeMismatch;
if (actual != expected) return error.TypeMismatch;
}

fn expectTypedArrayOfType(self: Value, expected: TypedarrayType) TypeError!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;
}
};
23 changes: 20 additions & 3 deletions src/js/wrap_class.zig
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,17 @@ pub fn wrapClass(comptime T: type) type {
return null;
};

// Without `new`, `this` is the global proxy; wrapping it would
// attach native state and a finalizer to globalThis.
const new_target = e.getNewTarget(cb_info) catch {
e.throwError("", "Failed to get new.target in constructor") catch {};
return null;
};
if (new_target.value == null) {
e.throwTypeError("", "Class constructor cannot be invoked without 'new'") catch {};
return null;
}

// Fast path: materializeClassInstance is creating this instance.
// Skip normal init; materialize will wrap the returned JS instance
// with the real native pointer after napi_new_instance returns.
Expand Down Expand Up @@ -390,16 +401,22 @@ pub fn wrapClass(comptime T: type) type {
};
}

const init_result = callInit(init_fn, args) orelse return null;

const obj_ptr = std.heap.c_allocator.create(T) catch {
// Allocate before init so a failed allocation can't strand
// the resources init_result owns.
const obj_ptr = context.allocator().create(T) catch {
e.throwError("", "Out of memory allocating native object") catch {};
return null;
};

const init_result = callInit(init_fn, args) orelse {
context.allocator().destroy(obj_ptr);
return null;
};
obj_ptr.* = init_result;

const this_val = napi.Value{ .env = raw_env, .value = this_arg };
class_runtime.wrapTaggedObject(T, e, this_val, obj_ptr) catch {
class_runtime.destroyNativeObject(T, obj_ptr);
e.throwError("", "Failed to wrap native object") catch {};
return null;
};
Expand Down
4 changes: 3 additions & 1 deletion src/module.zig
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ pub fn register(comptime f: fn (Env, Value) anyerror!void) void {
.value = module,
};
f(e, v) catch |err| {
e.throwError(@errorName(err), "Error in module registration") catch unreachable;
// throwError fails if an exception is already pending; that
// exception is the failure report, so never abort here.
e.throwError(@errorName(err), "Error in module registration") catch {};
};
return module;
}
Expand Down
9 changes: 9 additions & 0 deletions src/status.zig
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ pub const Status = enum(c.napi_status) {
would_deadlock = c.napi_would_deadlock,
no_external_buffers_allowed = c.napi_no_external_buffers_allowed,
cannot_run_js = c.napi_cannot_run_js,
// Non-exhaustive: future Node versions may add statuses.
_,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this still panic

@nazarhussain nazarhussain Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to std.enums.tagName(...) orelse "NapiError"

};

pub const NapiError = error{
Expand Down Expand Up @@ -80,6 +82,7 @@ pub fn check(code: c_uint) NapiError!void {
.would_deadlock => return error.WouldDeadlock,
.no_external_buffers_allowed => return error.NoExternalBuffersAllowed,
.cannot_run_js => return error.CannotRunJS,
_ => return error.GenericFailure,
}
}

Expand Down Expand Up @@ -112,3 +115,9 @@ test "isNapiError identifies napi errors" {
try std.testing.expect(isNapiError(error.GenericFailure));
try std.testing.expect(!isNapiError(error.OutOfMemory));
}

test "check maps unknown status codes to GenericFailure" {
// Future Node versions may return statuses this enum doesn't know about;
// they must map to an error, not invalid-enum UB.
try std.testing.expectError(error.GenericFailure, check(9999));
}
7 changes: 7 additions & 0 deletions src/value_types.zig
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ pub const ValueType = enum(c.napi_valuetype) {
function = c.napi_function,
external = c.napi_external,
bigint = c.napi_bigint,
// Non-exhaustive: future Node versions may add value types.
_,
};

/// https://nodejs.org/api/n-api.html#napi_typedarray_type
Expand Down Expand Up @@ -76,6 +78,11 @@ pub const TypedarrayType = enum(c.napi_typedarray_type) {
}
};

test "elementSize covers known typedarray types" {
try @import("std").testing.expectEqual(@as(usize, 1), TypedarrayType.uint8.elementSize());
try @import("std").testing.expectEqual(@as(usize, 8), TypedarrayType.biguint64.elementSize());
}

/// https://nodejs.org/api/n-api.html#napi_property_attributes
pub const PropertyAttributes = enum(c.napi_property_attributes) {
default = c.napi_default,
Expand Down
Loading