Skip to content

feat(js): add high-level DSL with JS-aligned types#11

Open
nazarhussain wants to merge 31 commits intomainfrom
nh/zapi-dsl
Open

feat(js): add high-level DSL with JS-aligned types#11
nazarhussain wants to merge 31 commits intomainfrom
nh/zapi-dsl

Conversation

@nazarhussain
Copy link
Copy Markdown
Contributor

Summary

  • Add a high-level DSL layer (@import("zapi").js) that mirrors JavaScript's type system — zero-cost wrapper types (Number, String, Boolean, BigInt, Date, Array, Object(T), Function, Value, TypedArrays, Promise) over napi.Value
  • pub functions auto-export to JS, structs with js_class = true become JS classes — one line: comptime { js.exportModule(@This()); }
  • Existing low-level N-API (@import("zapi").napi) stays untouched with full backwards compatibility
  • Restructured README with DSL-first documentation
  • Comprehensive examples and 34 integration tests covering all types and patterns

Motivation

Writing N-API addons required too much boilerplate — manual initModule, setNamedProperty, createFunction, type conversions, etc. The DSL eliminates this by aligning Zig function signatures with JS types directly. Inspired by Zigar but with zero JS runtime overhead — all conversion happens at Zig compile time.

Test plan

  • zig build test:napi — Zig unit tests pass
  • npx vitest run — 34 JS integration tests pass (all types, error handling, classes, promises, callbacks, mixed DSL/N-API)
  • Existing hello_world and type_tag examples still work unchanged

nazarhussain and others added 21 commits March 24, 2026 13:41
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…se async model, Object(T) accessors, init contract, wrapFunction error handling, null/undefined distinction

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ucture

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use raw C APIs instead of Env.createFunction/defineClass
- Make convertArg/callAndConvert/convertReturn pub
- Fix invalid unit tests in context.zig
- Fix isDslType to use @typeinfo field iteration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restructure root.zig into napi + js namespaces while preserving
backwards-compatible flat exports via usingnamespace. Add thread-local
context (env/allocator), and zero-cost DSL wrappers for Number, String,
Boolean, BigInt, and Date.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add Array, Object(T), Function, TypedArray variants, Promise(T),
and Value escape hatch. Update js.zig with full re-exports including
all 11 typed array types, createPromise, and throwError helper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Array.get() and Function.call() now return !Value (DSL type) instead of !napi.Value
- Promise(T) stores val field alongside deferred; createPromise returns error union
- Value is* methods return bool (using catch return false pattern)
- Value as* methods return error unions
- Added missing Value.asObject(comptime T) method
- throwError uses catch {} instead of catch @Panic

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Core comptime module that converts DSL-typed Zig functions into N-API
C callbacks. Includes isDslType, convertArg, convertReturn, callAndConvert,
and wrapFunction which handles error unions, optionals, and plain returns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Generates N-API constructor, finalizer, property descriptors, and method
wrappers for Zig structs marked with js_class=true. Instance methods are
detected by self-param type; static methods use wrapFunction directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Iterates module declarations at comptime, wrapping public functions via
wrapFunction and js_class structs via wrapClass, then registers them
using raw N-API C calls and napi.module.register.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add wrapFunction, wrapClass, exportModule, and helper functions
(isDslType, convertArg, convertReturn, callAndConvert) to the
public js module API. Reference new modules in test block.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a class method takes `self: T` (by value), wrapMethod was passing
`*T` instead of dereferencing to `T`. Now correctly distinguishes
by-value (`T`) from pointer (`*T`, `*const T`) self parameters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add example demonstrating the JS DSL with functions (add, greet,
findValue, willThrow) and a Counter class. Update build.zig to build
the example as a .node addon. Fix wrapClass property descriptor
generation to use a comptime block for Zig 0.14 compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cover all DSL types: Number, String, Boolean, BigInt, Date, Array,
Object, Function, Value, Uint8Array, Float64Array, Promise, and
classes with deinit. Includes error handling, typed objects, callbacks,
and mixed DSL + N-API usage sections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add 29 tests across 11 describe blocks: basic functions, error handling,
primitives (Number, Boolean, String, BigInt, Date), typed objects,
arrays, typed arrays, promises, callbacks, Counter/Buffer classes,
and mixed DSL + N-API interop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@lodekeeper-z lodekeeper-z left a comment

Choose a reason for hiding this comment

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

test review

Copy link
Copy Markdown

@lodekeeper-z lodekeeper-z left a comment

Choose a reason for hiding this comment

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

zapi PR #11 — High-Level DSL Review

Great architectural PR. The comptime machinery in wrapFunction/wrapClass is well-designed, the setEnv/restoreEnv thread-local pattern correctly handles nested callbacks, the !T/?T/!?T return-type dispatch is clean, and the example coverage is excellent. This should meaningfully reduce NAPI boilerplate for lodestar-z bindings.

I have a few issues that need attention before this lands — one is a fundamental Zig limitation that affects the public API contract, and a few are safety/correctness gaps in the generated callbacks. Inline comments below.

Critical

1. exportModule exports ALL functions, not just pub fns
In Zig 0.12+, std.builtin.Type.Declaration no longer has an is_pub field — it was removed. So @typeInfo(Module).@"struct".decls iterates every declaration regardless of visibility. The README says "pub functions are auto-exported" but that's not true: any function in the file, public or private, will be picked up. If a user has private helpers with native Zig types (not DSL), wrapFunction will @compileError on them, making the whole module fail to compile. This is a breaking UX issue for non-trivial modules that have helper functions. Options:

  • Require an explicit pub const js_exports = .{add, Counter, ...} list (most explicit, no magic)
  • Document that all helper functions must be in a separate file
  • Use a naming convention (e.g. skip _-prefixed functions)

2. Missing argument count validation in wrapFunction and wrapMethod
After napi_get_cb_info, actual_argc reflects how many args JS actually passed. But the code always iterates 0..argc unconditionally, using zeroed raw_args[i] entries for slots beyond actual_argc. A JS caller that passes too few arguments silently gets a null napi_value wrapped in a DSL struct. The first meaningful operation on it (e.g. assertI32()) will panic and crash Node.js — rather than throwing a JS TypeError. Same issue exists in wrapMethod.

3. utf8name pointer in getPropertyDescriptors is not null-terminated
desc.utf8name = decl.name.ptr uses the raw pointer from a []const u8 slice. napi_define_class treats utf8name as a C string (const char*) and reads until \0. decl.name as a Zig slice is not guaranteed to be null-terminated in the type system — even if comptime string literals happen to have a trailing null in practice. Compare: exportModule correctly does decl.name ++ "" to get [:0]const u8. getPropertyDescriptors should do the same.

Important

4. TypedArray slice lifetime is too loose
toSlice() returns a []Element pointing directly into the ArrayBuffer's V8-managed backing buffer. This is safe only within the current callback frame, before any JS interop that could trigger GC. If the slice escapes (stored in a Zig struct, passed to an async work callback, etc.) it becomes a dangling pointer. NAPI pins the buffer during the callback, but not past it. The doc comment says "valid as long as the ArrayBuffer is alive" but that's not actionable — callers can't know when V8 will collect it. Consider: return a copied []Element (safer, clear ownership) and separately offer a withSlice(callback) API for zero-copy work within a bounded scope.

5. js_class presence checked but not its value
@hasDecl(InnerType, "js_class") is true for pub const js_class = false or pub const js_class = "oops". Should be @field(InnerType, "js_class") == true.

6. as*() narrowing methods don't actually validate the JS type
asNumber(), asString(), etc. are typed !T suggesting they can fail — but they blindly wrap the value without calling napi_typeof. Type errors are deferred to the first toI32()/toBool() call, which will return a NAPI error. At minimum, the docs should be updated. Ideally, these methods should actually check the type and return a descriptive error (e.g. error.TypeMismatch) if it doesn't match.

7. Async Promise model is incomplete for true async use
createPromise/resolve works for synchronous resolution but the intended async pattern (resolve from a work callback or another thread) requires napi_threadsafe_function + storing the napi_deferred across the callback boundary. Once napi_resolve_deferred is called the deferred is consumed. For actual async: the user needs to:

  1. Create the promise, extract the deferred
  2. Return the promise JS value
  3. Post work to another thread (via napi.AsyncWork)
  4. In the completion callback, call resolve/reject via a threadsafe function

The current Promise(T) struct doesn't help with steps 3–4. Consider adding a Promise.Resolver type that holds the deferred and can safely be passed to async work, or at least document the expected pattern.

Minor

  • Promise.reject() only accepts js.String. Can't reject with a JS Error object (the idiomatic JS rejection type). Consider also accepting Value or napi.Value.
  • toOwnedSlice allocates two buffers (one for the null-terminated read, one for the result). Could use a single str_len + 1 allocation and slice off the null.
  • Buffer.getSize() casts usize to i32 via @intCast — overflows for buffers > 2GiB (unlikely in practice, but worth a comment).
  • The assert*() panic philosophy (crash Node.js on type mismatch) is a valid design choice, but the README should explicitly document it so users know to validate inputs before using assert variants.

nazarhussain and others added 2 commits March 25, 2026 09:57
- Value.as* methods now validate JS type at runtime (returns error.TypeMismatch)
- String.toOwnedSlice uses allocSentinel for safe allocation (no OOB)
- Promise(T) documents sync-only resolve/reject limitation
- wrapFunction/wrapClass validate argument count (throws TypeError)
- context.zig documents thread-local safety constraints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- export_module: skip non-DSL functions, validate js_class is bool true
- wrap_class: use null-terminated string for property descriptor names
- typed_arrays: document slice lifetime safety (V8 backing store)
- promise: reject() accepts any JS value, add rejectWithMessage() for Error objects

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@nazarhussain
Copy link
Copy Markdown
Contributor Author

@lodekeeper-z Please re-review this PR.

Copy link
Copy Markdown

@lodekeeper-z lodekeeper-z left a comment

Choose a reason for hiding this comment

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

All five issues from the previous review have been addressed:

  • export_module.zig: Now skips non-DSL functions (checks each param type), and validates js_class == true with type check (@TypeOf(...) == bool). ✅
  • wrap_function.zig: Argument count validation is in place — throws TypeError if actual_argc < argc. ✅
  • wrap_class.zig: utf8name uses [:0]const u8 (decl.name ++ "") so the pointer is null-terminated. ✅
  • typed_arrays.zig: toSlice now has a clear doc comment warning about the V8-managed backing store lifetime. from copies data into a new ArrayBuffer. ✅
  • value.zig: All as*() narrowing methods now call napi_typeof (or isArray/isDate/isTypedarray) and return error.TypeMismatch on mismatch. ✅
  • promise.zig: reject now accepts any DSL/napi value, and rejectWithMessage creates a proper JS Error object. ✅

LGTM. 🚀

Copy link
Copy Markdown

@lodekeeper-z lodekeeper-z left a comment

Choose a reason for hiding this comment

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

Re-reviewed after fix commits. All previously raised issues addressed:

  • as*() methods now validate JS types at runtime via expectType()
  • reject() accepts anytype, with rejectWithMessage() for proper Error objects ✅
  • exportModule filters by DSL function signature, validates js_class as bool
  • Null-terminated names via decl.name ++ "" trick ✅
  • Typed array subtype validation added ✅

Clean design. LGTM.

@nazarhussain
Copy link
Copy Markdown
Contributor Author

@lodekeeper-z Please review the last commit and see what implications can there be for adding link_libc for zapi module?

nazarhussain and others added 4 commits March 31, 2026 17:58
…fcounting

Extract decl-registration into a registerDecls helper and add
exportModuleWithOptions supporting optional init/cleanup hooks with
atomic env refcounting. exportModule now delegates to the new function
with empty options.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add Section 11 to the DSL example demonstrating module init/cleanup
hooks with env refcounting via exportModuleWithOptions. Include worker
thread test that verifies refcount increment on worker load and
decrement on worker exit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
  exportModuleWithOptions into single function
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants