Skip to content
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 @@ -220,6 +261,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
10 changes: 8 additions & 2 deletions src/Value.zig
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,8 @@ pub fn getTypedarrayInfo(self: Value) NapiError!TypedarrayInfo {
try status.check(
c.napi_get_typedarray_info(self.env, self.value, @ptrCast(&info.array_type), &info.length, @ptrCast(&data), @ptrCast(&info.arraybuffer), &info.byte_offset),
);
info.data = data[0 .. info.length * info.array_type.elementSize()];
const elem_size = info.array_type.elementSize() orelse return error.GenericFailure;
info.data = data[0 .. info.length * elem_size];
return info;
}

Expand Down Expand Up @@ -223,16 +224,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
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
15 changes: 11 additions & 4 deletions src/js/class_runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@ pub fn typeTag(comptime T: type) napi.c.napi_type_tag {
};
}

/// After a successful wrap, N-API owns `native_object` and frees it via the
/// finalizer when the JS object is GC'd. On a later error the caller frees it
/// instead, so the finalizer is detached first — it must not also fire, or the
/// object is freed twice.
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 |_| {};
// Assumed infallible right after a successful wrap; if removeWrap ever
// failed the finalizer would stay live and both it and the caller would free.
errdefer _ = env.removeWrap(T, object) catch {};

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.

What is the behavior when catch {} triggered, is it possible double free or memory leak? Is it possible testing this case?

This comment also confused me it should infallible after a successful wrap, but actually below statements still can throw error, why not make the env.wrap after these checks?

if (!(try env.checkObjectTypeTag(object, tag))) {
try env.typeTagObject(object, tag);
}
Expand Down Expand Up @@ -68,9 +72,12 @@ pub fn registerClass(comptime T: type, env: napi.Env, ctor: napi.Value) !void {
.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
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;
}
};
12 changes: 12 additions & 0 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 @@ -400,6 +411,7 @@ pub fn wrapClass(comptime T: type) type {

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
1 change: 1 addition & 0 deletions src/js/wrap_function.zig
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ fn typedArrayName(comptime array_type: napi.value_types.TypedarrayType) []const
.float64 => "Float64Array",
.bigint64 => "BigInt64Array",
.biguint64 => "BigUint64Array",
_ => "TypedArray",
};
}

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

};

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));
}
21 changes: 20 additions & 1 deletion 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 All @@ -49,13 +51,18 @@ pub const TypedarrayType = enum(c.napi_typedarray_type) {
float64 = c.napi_float64_array,
bigint64 = c.napi_bigint64_array,
biguint64 = c.napi_biguint64_array,
// Non-exhaustive: engines already ship array types napi doesn't name yet
// (e.g. Float16Array); unknown values must be representable, not UB.

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.

question: i know the _ is to account for future upgrades adding new types, but do we necessarily want to silently accept and not UB?

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.

It's not silent — _ just makes an unknown value representable (reading an unknown into an exhaustive enum is the UB). We then reject it: elementSize() → null → error.GenericFailureTypeMismatch thrown to JS. An exhaustive enum would instead be UB/panic at @enumFromInt, before we ever get to reject. So this is what lets us turn an unknown type into a clean error instead of UB.

_,

pub fn elementSize(self: TypedarrayType) usize {
/// Returns null for typedarray types this binding doesn't know about.
pub fn elementSize(self: TypedarrayType) ?usize {
switch (self) {
.int8, .uint8, .uint8_clamped => return 1,
.int16, .uint16 => return 2,
.int32, .uint32, .float32 => return 4,
.float64, .bigint64, .biguint64 => return 8,
_ => return null,
}
}

Expand All @@ -72,10 +79,22 @@ pub const TypedarrayType = enum(c.napi_typedarray_type) {
.float64 => return f64,
.bigint64 => return i64,
.biguint64 => return u64,
_ => @compileError("unknown typedarray type"),
}
}
};

test "elementSize returns null for unknown typedarray types" {
// e.g. Float16Array on newer engines: unknown values must not be UB.
const unknown: TypedarrayType = @enumFromInt(999);
try @import("std").testing.expect(unknown.elementSize() == null);
}

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