diff --git a/README.md b/README.md index 09cec47..0393ec8 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ c.count; // 1 (getter, not a method call) | `Function` | `Function` | `call(args)` | | `Value` | `any` | `isNumber()`, `asNumber()`, type checking/narrowing | | `Uint8Array` etc. | `TypedArray` | `toSlice()`, `from(slice)` | +| `OwnedUint8Array` etc. | `TypedArray` | `fromOwnedSlice(allocator, data)`, `fromSlice(allocator, data)` | | `Promise(T)` | `Promise` | `resolve(value)`, `reject(err)` | --- @@ -288,6 +289,24 @@ pub fn sum(data: Uint8Array) !Number { } ``` +Use an owned return type to transfer an allocator-owned slice to JavaScript +without copying its elements: + +```zig +pub fn serialize() !js.OwnedUint8Array { + const allocator = js.allocator(); + const data = try allocator.alloc(u8, 32); + // Fill data... + return js.OwnedUint8Array.fromOwnedSlice(allocator, data); +} +``` + +Returning the value transfers its allocation to JavaScript without copying and +leaves the Zig owner empty. Failures before N-API accepts the external memory +leave ownership in Zig so it can be released normally. The allocator must remain +valid until the ArrayBuffer finalizer runs. If external ArrayBuffers are +unsupported, the original error is returned; no copy fallback is performed. + ### Promises ```zig diff --git a/examples/js_dsl/mod.test.ts b/examples/js_dsl/mod.test.ts index c694890..637457e 100644 --- a/examples/js_dsl/mod.test.ts +++ b/examples/js_dsl/mod.test.ts @@ -246,6 +246,14 @@ describe("typed arrays", () => { expect(result).toBeInstanceOf(Uint8Array); } }); + + it("ownedUint8Array returns allocator-owned native data", () => { + for (const input of [[], [10, 20, 30, 40]]) { + const result = mod.ownedUint8Array(input); + expect(result).toBeInstanceOf(Uint8Array); + expect(Array.from(result)).toEqual(input); + } + }); }); // Section 7: Promises diff --git a/examples/js_dsl/mod.zig b/examples/js_dsl/mod.zig index 0d13d9c..8675323 100644 --- a/examples/js_dsl/mod.zig +++ b/examples/js_dsl/mod.zig @@ -12,6 +12,7 @@ const Object = js.Object; const Function = js.Function; const Value = js.Value; const Uint8Array = js.Uint8Array; +const OwnedUint8Array = js.OwnedUint8Array; const Float64Array = js.Float64Array; const Promise = js.Promise; @@ -233,6 +234,20 @@ pub fn externalUint8Array(arr: Array) !Uint8Array { return Uint8Array.fromExternal(tmp); } +/// Transfer an allocator-owned native allocation to a JavaScript Uint8Array. +pub fn ownedUint8Array(arr: Array) !OwnedUint8Array { + const len = try arr.length(); + const alloc = js.allocator(); + const data = try alloc.alloc(u8, len); + errdefer alloc.free(data); + + var i: u32 = 0; + while (i < len) : (i += 1) { + data[i] = @intCast((try arr.getNumber(i)).assertI32()); + } + return OwnedUint8Array.fromOwnedSlice(alloc, data); +} + // ============================================================================ // Section 7: Promises // ============================================================================ diff --git a/src/js.zig b/src/js.zig index 34af21a..3bbaef2 100644 --- a/src/js.zig +++ b/src/js.zig @@ -25,6 +25,7 @@ pub const Function = @import("js/function.zig").Function; pub const Value = @import("js/value.zig").Value; pub const TypedArray = typed_arrays.TypedArray; +pub const OwnedTypedArray = typed_arrays.OwnedTypedArray; pub const Int8Array = typed_arrays.Int8Array; pub const Uint8Array = typed_arrays.Uint8Array; pub const Uint8ClampedArray = typed_arrays.Uint8ClampedArray; @@ -36,6 +37,17 @@ pub const Float32Array = typed_arrays.Float32Array; pub const Float64Array = typed_arrays.Float64Array; pub const BigInt64Array = typed_arrays.BigInt64Array; pub const BigUint64Array = typed_arrays.BigUint64Array; +pub const OwnedInt8Array = typed_arrays.OwnedInt8Array; +pub const OwnedUint8Array = typed_arrays.OwnedUint8Array; +pub const OwnedUint8ClampedArray = typed_arrays.OwnedUint8ClampedArray; +pub const OwnedInt16Array = typed_arrays.OwnedInt16Array; +pub const OwnedUint16Array = typed_arrays.OwnedUint16Array; +pub const OwnedInt32Array = typed_arrays.OwnedInt32Array; +pub const OwnedUint32Array = typed_arrays.OwnedUint32Array; +pub const OwnedFloat32Array = typed_arrays.OwnedFloat32Array; +pub const OwnedFloat64Array = typed_arrays.OwnedFloat64Array; +pub const OwnedBigInt64Array = typed_arrays.OwnedBigInt64Array; +pub const OwnedBigUint64Array = typed_arrays.OwnedBigUint64Array; pub const Promise = @import("js/promise.zig").Promise; pub const createPromise = @import("js/promise.zig").createPromise; diff --git a/src/js/typed_arrays.zig b/src/js/typed_arrays.zig index 87eb5cd..2d1bf37 100644 --- a/src/js/typed_arrays.zig +++ b/src/js/typed_arrays.zig @@ -134,6 +134,140 @@ pub fn TypedArray(comptime Element: type, comptime array_type: TypedarrayType) t }; } +fn elementType(comptime array_type: TypedarrayType) type { + return switch (array_type) { + .int8 => i8, + .uint8, .uint8_clamped => u8, + .int16 => i16, + .uint16 => u16, + .int32 => i32, + .uint32 => u32, + .float32 => f32, + .float64 => f64, + .bigint64 => i64, + .biguint64 => u64, + }; +} + +/// Allocator-backed elements whose ownership can be transferred to a JavaScript +/// TypedArray without copying the element data. +/// +/// Like other Zig owning values, an OwnedTypedArray must not be copied. After +/// ownership transfers, the source is empty and may be deinitialized normally. +pub fn OwnedTypedArray(comptime Element: type, comptime array_type: TypedarrayType) type { + if (Element != elementType(array_type)) { + @compileError( + "OwnedTypedArray element type `" ++ @typeName(Element) ++ + "` does not match `" ++ @tagName(array_type) ++ "`", + ); + } + + return struct { + allocator: std.mem.Allocator, + data: []Element, + + const Self = @This(); + pub const owned_typed_array = {}; + pub const expected_array_type = array_type; + + /// Takes ownership of `data`, which must have been allocated by `allocator`. + /// The allocator must remain valid until the value is deinitialized or + /// finalized by JavaScript. + pub fn fromOwnedSlice(allocator: std.mem.Allocator, data: []Element) Self { + return .{ + .allocator = allocator, + .data = data, + }; + } + + /// Copies `data` into a new owned allocation. + pub fn fromSlice(allocator: std.mem.Allocator, data: []const Element) !Self { + return .fromOwnedSlice(allocator, try allocator.dupe(Element, data)); + } + + /// Releases data that has not been transferred to JavaScript. This is + /// also safe after a successful transfer, when `data` is empty. + pub fn deinit(self: *Self) void { + self.allocator.free(self.data); + self.* = undefined; + } + + /// Transfers ownership to a JavaScript TypedArray. + /// + /// On success, `self.data` is empty and JavaScript releases the original + /// allocation through the ArrayBuffer finalizer. Failures before N-API + /// accepts the external memory leave ownership in `self`; failures after + /// ownership may have transferred leave `self.data` empty. + /// + /// Unsupported external ArrayBuffers return + /// `error.NoExternalBuffersAllowed` without a copy fallback. The caller + /// may deinitialize `self` after this function returns. + pub fn intoValue(self: *Self, env: napi.Env) !napi.Value { + const data = self.data; + + if (data.len == 0) { + const arraybuffer = try env.createArrayBuffer(0, null); + const value = try env.createTypedarray(array_type, 0, arraybuffer, 0); + self.data = &.{}; + return value; + } + + const owner = try moveToHeap(self); + const arraybuffer = env.createExternalArrayBuffer( + std.mem.sliceAsBytes(data), + finalize, + owner, + ) catch |err| { + switch (err) { + error.NoExternalBuffersAllowed, + error.PendingException, + error.CannotRunJS, + => restoreFromHeap(self, owner), + else => {}, + } + return err; + }; + + return env.createTypedarray(array_type, data.len, arraybuffer, 0); + } + + fn moveToHeap(self: *Self) !*Self { + const owner = try self.allocator.create(Self); + owner.* = self.*; + self.data = &.{}; + return owner; + } + + fn restoreFromHeap(self: *Self, owner: *Self) void { + const allocator = owner.allocator; + std.debug.assert(self.data.len == 0); + self.* = owner.*; + allocator.destroy(owner); + } + + fn finalize( + _: napi.c.napi_env, + _: ?*anyopaque, + finalize_hint: ?*anyopaque, + ) callconv(.c) void { + const owner: *Self = + @ptrCast(@alignCast(finalize_hint orelse unreachable)); + release(owner); + } + + fn release(owner: *Self) void { + const allocator = owner.allocator; + allocator.free(owner.data); + allocator.destroy(owner); + } + }; +} + +/// Returns whether `T` is an OwnedTypedArray specialization. +pub fn isOwnedTypedArray(comptime T: type) bool { + return @typeInfo(T) == .@"struct" and @hasDecl(T, "owned_typed_array"); +} + // Concrete typed array types /// Wrapper around JavaScript `Int8Array`. pub const Int8Array = TypedArray(i8, .int8); @@ -168,7 +302,81 @@ pub const BigInt64Array = TypedArray(i64, .bigint64); /// Wrapper around JavaScript `BigUint64Array`. pub const BigUint64Array = TypedArray(u64, .biguint64); +/// Owned native elements transferable to a JavaScript `Int8Array`. +pub const OwnedInt8Array = OwnedTypedArray(i8, .int8); + +/// Owned native elements transferable to a JavaScript `Uint8Array`. +pub const OwnedUint8Array = OwnedTypedArray(u8, .uint8); + +/// Owned native elements transferable to a JavaScript `Uint8ClampedArray`. +pub const OwnedUint8ClampedArray = OwnedTypedArray(u8, .uint8_clamped); + +/// Owned native elements transferable to a JavaScript `Int16Array`. +pub const OwnedInt16Array = OwnedTypedArray(i16, .int16); + +/// Owned native elements transferable to a JavaScript `Uint16Array`. +pub const OwnedUint16Array = OwnedTypedArray(u16, .uint16); + +/// Owned native elements transferable to a JavaScript `Int32Array`. +pub const OwnedInt32Array = OwnedTypedArray(i32, .int32); + +/// Owned native elements transferable to a JavaScript `Uint32Array`. +pub const OwnedUint32Array = OwnedTypedArray(u32, .uint32); + +/// Owned native elements transferable to a JavaScript `Float32Array`. +pub const OwnedFloat32Array = OwnedTypedArray(f32, .float32); + +/// Owned native elements transferable to a JavaScript `Float64Array`. +pub const OwnedFloat64Array = OwnedTypedArray(f64, .float64); + +/// Owned native elements transferable to a JavaScript `BigInt64Array`. +pub const OwnedBigInt64Array = OwnedTypedArray(i64, .bigint64); + +/// Owned native elements transferable to a JavaScript `BigUint64Array`. +pub const OwnedBigUint64Array = OwnedTypedArray(u64, .biguint64); + test "TypedArray exposes expected subtype metadata" { try @import("std").testing.expect(Uint8Array.expected_array_type == .uint8); try @import("std").testing.expect(Float64Array.expected_array_type == .float64); } + +test "OwnedTypedArray fromSlice owns an independent copy" { + var source = [_]u8{ 1, 2, 3 }; + var array = try OwnedUint8Array.fromSlice(std.testing.allocator, &source); + defer array.deinit(); + + source[0] = 9; + try std.testing.expectEqualSlices(u8, &.{ 1, 2, 3 }, array.data); +} + +test "OwnedTypedArray retains data when moving the owner to the heap fails" { + var failing_allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{ + .fail_index = 1, + }); + var array = try OwnedUint8Array.fromSlice( + failing_allocator.allocator(), + &.{ 1, 2, 3 }, + ); + + try std.testing.expectError( + error.OutOfMemory, + OwnedUint8Array.moveToHeap(&array), + ); + try std.testing.expectEqualSlices(u8, &.{ 1, 2, 3 }, array.data); + + array.deinit(); + try std.testing.expectEqual(@as(usize, 1), failing_allocator.deallocations); +} + +test "OwnedTypedArray empties the source when ownership moves to the heap" { + var array = try OwnedUint8Array.fromSlice( + std.testing.allocator, + &.{ 1, 2, 3 }, + ); + const owner = try OwnedUint8Array.moveToHeap(&array); + defer OwnedUint8Array.release(owner); + + try std.testing.expectEqual(@as(usize, 0), array.data.len); + array.deinit(); + try std.testing.expectEqualSlices(u8, &.{ 1, 2, 3 }, owner.data); +} diff --git a/src/js/wrap_function.zig b/src/js/wrap_function.zig index fcaf689..48937fa 100644 --- a/src/js/wrap_function.zig +++ b/src/js/wrap_function.zig @@ -3,6 +3,7 @@ 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 typed_arrays = @import("typed_arrays.zig"); /// Checks whether `T` is a ZAPI DSL wrapper type (a struct with a `val: napi.Value` field). /// @@ -222,6 +223,16 @@ pub fn convertReturnWithCtor(comptime T: type, value: T, env: napi.c.napi_env, p if (T == napi.Value) { return value.value; } + if (comptime typed_arrays.isOwnedTypedArray(T)) { + const e = napi.Env{ .env = env }; + var owned = value; + defer owned.deinit(); + const result = owned.intoValue(e) catch |err| { + e.throwError(@errorName(err), @errorName(err)) catch {}; + return null; + }; + return result.value; + } if (comptime isDslType(T)) { return value.val.value; } @@ -385,8 +396,6 @@ test "isDslType rejects non-DSL types" { } test "argTypeDescription names typed arrays and unwraps optionals" { - const typed_arrays = @import("typed_arrays.zig"); - try std.testing.expectEqualStrings("a number", argTypeDescription(?@import("number.zig").Number)); try std.testing.expectEqualStrings("a Uint8Array", argTypeDescription(typed_arrays.Uint8Array)); }