feat(js): add high-level DSL with JS-aligned types#11
feat(js): add high-level DSL with JS-aligned types#11nazarhussain wants to merge 31 commits intomainfrom
Conversation
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>
lodekeeper-z
left a comment
There was a problem hiding this comment.
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:
- Create the promise, extract the deferred
- Return the promise JS value
- Post work to another thread (via
napi.AsyncWork) - 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 acceptsjs.String. Can't reject with a JSErrorobject (the idiomatic JS rejection type). Consider also acceptingValueornapi.Value.toOwnedSliceallocates two buffers (one for the null-terminated read, one for the result). Could use a singlestr_len + 1allocation and slice off the null.Buffer.getSize()castsusizetoi32via@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.
- 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>
|
@lodekeeper-z Please re-review this PR. |
lodekeeper-z
left a comment
There was a problem hiding this comment.
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 == truewith type check (@TypeOf(...) == bool). ✅ - wrap_function.zig: Argument count validation is in place — throws
TypeErrorifactual_argc < argc. ✅ - wrap_class.zig:
utf8nameuses[:0]const u8(decl.name ++ "") so the pointer is null-terminated. ✅ - typed_arrays.zig:
toSlicenow has a clear doc comment warning about the V8-managed backing store lifetime.fromcopies data into a new ArrayBuffer. ✅ - value.zig: All
as*()narrowing methods now callnapi_typeof(orisArray/isDate/isTypedarray) and returnerror.TypeMismatchon mismatch. ✅ - promise.zig:
rejectnow accepts any DSL/napi value, andrejectWithMessagecreates a proper JS Error object. ✅
LGTM. 🚀
lodekeeper-z
left a comment
There was a problem hiding this comment.
Re-reviewed after fix commits. All previously raised issues addressed:
as*()methods now validate JS types at runtime viaexpectType()✅reject()acceptsanytype, withrejectWithMessage()for proper Error objects ✅exportModulefilters by DSL function signature, validatesjs_classasbool✅- Null-terminated names via
decl.name ++ ""trick ✅ - Typed array subtype validation added ✅
Clean design. LGTM.
|
@lodekeeper-z Please review the last commit and see what implications can there be for adding |
…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
Summary
@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.Valuepubfunctions auto-export to JS, structs withjs_class = truebecome JS classes — one line:comptime { js.exportModule(@This()); }@import("zapi").napi) stays untouched with full backwards compatibilityMotivation
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 passnpx vitest run— 34 JS integration tests pass (all types, error handling, classes, promises, callbacks, mixed DSL/N-API)hello_worldandtype_tagexamples still work unchanged