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
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,56 @@ pub fn asyncOp(val: Number) !Promise(Number) {
}
```

`Promise(T)` in this DSL path is synchronous-only: resolve or reject it before the exported function returns. For truly asynchronous completion, keep the `Deferred` handle in lower-level N-API code and bridge back with `napi.AsyncWork` or `napi.ThreadSafeFunction`.
`Promise(T)` in this DSL path is synchronous-only: resolve or reject it before the exported function returns. For work that must run off the JS thread, use `js.spawn` below.

### Async Tasks

`js.spawn` runs a task on the libuv worker pool and returns a JS Promise that settles when it finishes. A task is any struct with `compute`, `resolve`, and `deinit`:

```zig
const ScaleTask = struct {
data: []u32,
factor: u32,

// Worker thread — must not touch napi or DSL values.
pub fn compute(self: *ScaleTask) !void {
for (self.data) |*value| value.* *= self.factor;
}

// JS thread — the DSL env context is established, so DSL types work here.
pub fn resolve(self: *ScaleTask, _: napi.Env) !js.OwnedUint32Array {
const owned: js.OwnedUint32Array = .fromOwnedSlice(js.allocator(), self.data);
self.data = &.{}; // ownership handed to JS, no copy
return owned;
}

// Safe to call after resolve transferred ownership.
pub fn deinit(self: *ScaleTask) void {
js.allocator().free(self.data);
}
};

pub fn asyncScale(data: js.Uint32Array, factor: Number) !Value {
const copy = try js.allocator().dupe(u32, try data.toSlice());
errdefer js.allocator().free(copy);
return js.spawn(ScaleTask, .{ .data = copy, .factor = @intCast(factor.assertI32()) }, "asyncScale");
}
```

`resolve` may return a DSL type (`js.Number`), an owned typed array (transferred without copying), `napi.Value`, or `void`.

If `compute` returns an error the promise rejects with `Error(@errorName(err))`. Add an optional `reject(self: *Task, env: napi.Env, err: anyerror) !napi.Value` to build the rejection value yourself — `js.errorWithMessage(env, msg)` builds a plain `Error` for the common case:

```zig
pub fn reject(_: *Task, env: napi.Env, err: anyerror) !napi.Value {
return js.errorWithMessage(env, switch (err) {
error.ComputeFailed => "worker could not finish the job",
else => @errorName(err),
});
}
```

Ownership: if `spawn` fails the task is not consumed, so the caller's `errdefer`s must free it (as above). Once `spawn` succeeds the helper owns the task and calls `deinit` after the promise settles.

### Callbacks

Expand Down
46 changes: 46 additions & 0 deletions examples/js_dsl/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -840,3 +840,49 @@ describe("enum export", () => {
expect(Object.isFrozen(mod.BlsPublicKey.Encoding)).toBe(true);
});
});

// Async Tasks
describe("async tasks", () => {
it("resolves with a DSL value built in the complete callback", async () => {
await expect(mod.asyncDouble(21)).resolves.toEqual(42);
});

it("returns a real Promise", () => {
const promise = mod.asyncDouble(1);
expect(promise).toBeInstanceOf(Promise);
return promise;
});

it("runs concurrent tasks independently", async () => {
const results = await Promise.all([1, 2, 3, 4, 5].map((n) => mod.asyncDouble(n)));
expect(results).toEqual([2, 4, 6, 8, 10]);
});

it("transfers an owned typed array without copying", async () => {
const result = await mod.asyncScale(new Uint32Array([1, 2, 3]), 3);
expect(result).toBeInstanceOf(Uint32Array);
expect(Array.from(result)).toEqual([3, 6, 9]);
});

it("handles empty typed array transfer", async () => {
const result = await mod.asyncScale(new Uint32Array(0), 2);
expect(result).toBeInstanceOf(Uint32Array);
expect(result.length).toEqual(0);
});

it("rejects with the task-supplied error message", async () => {
await expect(mod.asyncFail()).rejects.toThrow("worker could not finish the job");
});

it("rejects with the error name when reject is absent", async () => {
await expect(mod.asyncFailBare()).rejects.toThrow("Unlucky");
});

it("rejects with an Error instance", async () => {
await expect(mod.asyncFail()).rejects.toBeInstanceOf(Error);
});

it("rejects with the pending exception when settling fails", async () => {
await expect(mod.asyncPendingException()).rejects.toThrow("exception raised while resolving");
});
});
117 changes: 117 additions & 0 deletions examples/js_dsl/mod.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const std = @import("std");
const js = @import("zapi").js;
const napi = @import("zapi").napi;
const Number = js.Number;
const String = js.String;
const Boolean = js.Boolean;
Expand Down Expand Up @@ -722,6 +723,122 @@ pub var mutable_counter: u32 = 5;
pub const IDENTITY_MATRIX = [_]u32{ 1, 0, 0, 1 };
pub const VERSION_INFO = .{ .major = 3, .minor = 1 };

// ============================================================================
// Async Tasks
// ============================================================================

Comment on lines +726 to +729

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.

Can we remove this task number comments by AI?

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 was not just added by AI, but rather followed existing pattern.

/// `resolve` returns a DSL `js.Number`, which needs the complete callback's env.
const DoubleTask = struct {
value: i32,

pub fn compute(self: *DoubleTask) !void {
self.value *= 2;
}

pub fn resolve(self: *DoubleTask, _: napi.Env) !Number {
return Number.from(self.value);
}

pub fn deinit(_: *DoubleTask) void {}
};

/// JS: asyncDouble(n): Promise<number>
pub fn asyncDouble(n: Number) !Value {
return js.spawn(DoubleTask, .{ .value = n.assertI32() }, "asyncDouble");
}

/// Hands the result back without copying, via `js.OwnedUint32Array`.
const ScaleTask = struct {
data: []u32,
factor: u32,

pub fn compute(self: *ScaleTask) !void {
for (self.data) |*value| value.* *= self.factor;
}

pub fn resolve(self: *ScaleTask, _: napi.Env) !js.OwnedUint32Array {
const owned: js.OwnedUint32Array = .fromOwnedSlice(js.allocator(), self.data);
self.data = &.{};
return owned;
}

pub fn deinit(self: *ScaleTask) void {
// Empty (a no-op free) once resolve transferred ownership to JS.
js.allocator().free(self.data);
}
};

/// JS: asyncScale(data, factor): Promise<Uint32Array>
pub fn asyncScale(data: js.Uint32Array, factor: Number) !Value {
const copy = try js.allocator().dupe(u32, try data.toSlice());
errdefer js.allocator().free(copy);
return js.spawn(ScaleTask, .{
.data = copy,
.factor = @intCast(factor.assertI32()),
}, "asyncScale");
}

/// Builds its own rejection value via the optional `reject` decl.
const FailTask = struct {
pub fn compute(_: *FailTask) !void {
return error.ComputeFailed;
}

pub fn resolve(_: *FailTask, _: napi.Env) !Number {
return Number.from(0);
}

pub fn reject(_: *FailTask, env: napi.Env, err: anyerror) !napi.Value {
return js.errorWithMessage(env, switch (err) {
error.ComputeFailed => "worker could not finish the job",
else => @errorName(err),
});
}

pub fn deinit(_: *FailTask) void {}
};

/// JS: asyncFail(): Promise<never>
pub fn asyncFail() !Value {
return js.spawn(FailTask, .{}, "asyncFail");
}

/// Without `reject`, the rejection message defaults to `@errorName`.
const BareFailTask = struct {
pub fn compute(_: *BareFailTask) !void {
return error.Unlucky;
}

pub fn resolve(_: *BareFailTask, _: napi.Env) !Number {
return Number.from(0);
}

pub fn deinit(_: *BareFailTask) void {}
};

/// JS: asyncFailBare(): Promise<never>
pub fn asyncFailBare() !Value {
return js.spawn(BareFailTask, .{}, "asyncFailBare");
}

/// Leaves a pending JS exception, so settling the promise itself fails.
const PendingExceptionTask = struct {
pub fn compute(_: *PendingExceptionTask) !void {}

pub fn resolve(_: *PendingExceptionTask, env: napi.Env) !Number {
const value = Number.from(0);
try env.throwError("PendingBoom", "exception raised while resolving");
return value;
}

pub fn deinit(_: *PendingExceptionTask) void {}
};

/// JS: asyncPendingException(): Promise<never>
pub fn asyncPendingException() !Value {
return js.spawn(PendingExceptionTask, .{}, "asyncPendingException");
}

comptime {
js.exportModule(@This(), .{
.identity = @import("zapi_addon_identity"),
Expand Down
2 changes: 2 additions & 0 deletions src/js.zig
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ pub const OwnedBigInt64Array = typed_arrays.OwnedBigInt64Array;
pub const OwnedBigUint64Array = typed_arrays.OwnedBigUint64Array;

pub const Promise = @import("js/promise.zig").Promise;
pub const spawn = @import("js/async_task.zig").spawn;
pub const errorWithMessage = @import("js/error.zig").errorWithMessage;
pub const createPromise = @import("js/promise.zig").createPromise;

pub const NoAddonIdentity = @import("js/class_runtime.zig").NoAddonIdentity;
Expand Down
Loading
Loading