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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,25 @@ Namespaces nest arbitrarily — a sub-module with more `pub const` imports creat

---

## Constants and Enums

Scalar and string `pub const` decls (int, float, bool, `[]const u8`) export as enumerable value properties — at the module root, inside namespaces, and on classes (as statics on the constructor):

```zig
pub const SHUFFLE_ROUNDS_MAINNET: u32 = 90; // → exports.SHUFFLE_ROUNDS_MAINNET
pub const LIBRARY = "zapi"; // → exports.LIBRARY
```

A `pub const` enum exports as a frozen plain object mapping each tag name (verbatim, no case conversion) to its integer value — also at the module root, inside namespaces, and on classes:

```zig
pub const ByteCount = enum(u8) { one = 1, two = 2 }; // → exports.ByteCount = {one: 1, two: 2}
```

`pub var` decls are mutable state, not constants, and are never exported. Other const shapes (struct values, arrays, etc.) are skipped; export those via the `.register` hook. Integer consts and enum tags must fit in an `i64` (compile error otherwise).

---

## Module Lifecycle

`exportModule` accepts optional lifecycle hooks with atomic env refcounting:
Expand Down
9 changes: 9 additions & 0 deletions examples/js_dsl/constants.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//! Namespace demonstrating scalar/string const and enum export.

pub const MAX_ITERATIONS: u32 = 90;
pub const EPSILON: f64 = 0.001;
pub const LIBRARY = "zapi";
pub const IS_FAST = true;

/// Exported to JS as a frozen plain object `{single: 1, double: 2}`.
pub const Precision = enum(u8) { single = 1, double = 2 };
57 changes: 57 additions & 0 deletions examples/js_dsl/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -783,3 +783,60 @@ describe("static class fields", () => {
expect(Object.prototype.hasOwnProperty.call(pk, "COMPRESS_SIZE")).toBe(false);
});
});

// Section 17: Module-Level Constants and Enums
describe("module and namespace constants", () => {
it("exports module-root scalar and string consts", () => {
expect(mod.VERSION_MAJOR).toEqual(3);
expect(mod.MODULE_NAME).toEqual("js_dsl_example");
});

it("exports namespace consts", () => {
expect(mod.constants.MAX_ITERATIONS).toEqual(90);
expect(mod.constants.EPSILON).toEqual(0.001);
expect(mod.constants.LIBRARY).toEqual("zapi");
expect(mod.constants.IS_FAST).toEqual(true);
});

it("exports a namespace containing only consts", () => {
expect(mod.limits.MAX_U8).toEqual(255);
});

it("consts are enumerable, matching namespace functions", () => {
expect(Object.keys(mod.constants)).toContain("MAX_ITERATIONS");
expect(Object.keys(mod)).toContain("VERSION_MAJOR");
});

it("does not export pub var decls", () => {
expect(mod.mutable_counter).toBeUndefined();
expect(mod.BlsPublicKey.instance_count).toBeUndefined();
});

it("skips non-scalar const shapes", () => {
expect(mod.IDENTITY_MATRIX).toBeUndefined();
expect(mod.VERSION_INFO).toBeUndefined();
});
});

describe("enum export", () => {
it("exports enums as plain objects with verbatim tag names", () => {
expect(mod.ByteCount).toEqual({ one: 1, two: 2 });
expect(mod.constants.Precision).toEqual({ single: 1, double: 2 });
});

it("preserves signed tag values", () => {
expect(mod.Direction).toEqual({ backward: -1, forward: 1 });
});

it("exported enum objects are frozen", () => {
expect(Object.isFrozen(mod.ByteCount)).toBe(true);
expect(() => {
mod.ByteCount.one = 99;
}).toThrow(TypeError);
});

it("exports class-level enums as frozen objects on the constructor", () => {
expect(mod.BlsPublicKey.Encoding).toEqual({ compressed: 1, uncompressed: 2 });
expect(Object.isFrozen(mod.BlsPublicKey.Encoding)).toBe(true);
});
});
34 changes: 34 additions & 0 deletions examples/js_dsl/mod.zig
Original file line number Diff line number Diff line change
Expand Up @@ -681,13 +681,47 @@ pub const BlsPublicKey = struct {
pub const COMPRESS_SIZE = 48;
pub const SERIALIZE_SIZE = 96;

/// Class-level enums export like namespace enums, on the constructor.
pub const Encoding = enum(u8) { compressed = 1, uncompressed = 2 };

/// Mutable state is never exported as a static.
pub var instance_count: u32 = 0;

bytes: [96]u8,

pub fn init() BlsPublicKey {
return .{ .bytes = [_]u8{0} ** 96 };
}
};

// ============================================================================
// Section 17: Module-Level Constants and Enums
// ============================================================================

pub const VERSION_MAJOR: u32 = 3;
pub const MODULE_NAME = "js_dsl_example";

/// Sub-module demonstrating namespace-level consts and enums.
pub const constants = @import("constants.zig");

/// Enums export as frozen plain objects mapping tag name to integer value.
pub const ByteCount = enum(u8) { one = 1, two = 2 };

/// Signed tag values survive the mapping.
pub const Direction = enum(i8) { backward = -1, forward = 1 };

/// A namespace containing only constants still exports.
pub const limits = struct {
pub const MAX_U8: u8 = 255;
};

/// `pub var` decls are mutable state, not constants — never exported.
pub var mutable_counter: u32 = 5;

/// Non-scalar const shapes (arrays, struct values) are skipped.
pub const IDENTITY_MATRIX = [_]u32{ 1, 0, 0, 1 };
pub const VERSION_INFO = .{ .major = 3, .minor = 1 };

comptime {
js.exportModule(@This(), .{
.identity = @import("zapi_addon_identity"),
Expand Down
35 changes: 33 additions & 2 deletions src/js/export_module.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@ const wrap_class = @import("wrap_class.zig");
const class_meta = @import("class_meta.zig");
const class_runtime = @import("class_runtime.zig");

/// Registers a Zig `Module`'s public declarations (functions, classes, namespaces)
/// as JavaScript exports in the current Node-API environment at compile time.
/// Registers a Zig `Module`'s public declarations (functions, classes,
/// namespaces, constants, enums) as JavaScript exports in the current
/// Node-API environment at compile time.
///
/// This is the primary entry point for integrating ZAPI DSL-based Zig code into
/// Node.js. It inspects the `Module`'s `pub` declarations and automatically
/// creates corresponding JavaScript functions, classes, and sub-namespaces.
/// Scalar/string `pub const` decls (int, float, bool, `[]const u8`) export as
/// enumerable value properties at the module root and inside namespaces,
/// matching class static fields (also enumerable, on the constructor).
/// `pub const X = enum {...}` exports as a frozen plain object mapping each
/// tag name (verbatim, no case conversion) to its integer value, in all three
/// scopes. `pub var` decls are never exported (a snapshot would go stale).
///
/// Addons that export DSL classes pass the build-generated identity module:
/// `.identity = @import("zapi_addon_identity")`. The addon's `build.zig` creates
Expand Down Expand Up @@ -215,7 +222,31 @@ fn registerDecls(
exported_any = true;
}
}
} else if (@typeInfo(InnerType) == .@"enum") {
// Enum exports as a frozen plain object mapping tag name (verbatim)
// to its integer value, mirroring napi-rs `#[napi] pub enum`.
const enum_obj = try wrap_class.createEnumObject(
InnerType,
@typeName(Module) ++ "." ++ decl.name,
env,
);
const name: [:0]const u8 = decl.name ++ "";
try module.setNamedProperty(name, enum_obj);
exported_any = true;
}
} else if (comptime (wrap_class.isConstDecl(Module, decl.name) and
wrap_class.isStaticValueType(FieldType)))
{
// Scalar/string const (never a `pub var` snapshot) — same
// auto-export as class static fields.
comptime wrap_class.assertExportableInt(
@typeName(Module) ++ "." ++ decl.name,
@field(Module, decl.name),
);
const const_val = try wrap_class.createStaticFieldValue(env, field);
const name: [:0]const u8 = decl.name ++ "";
try module.setNamedProperty(name, const_val);
exported_any = true;
}
}
return exported_any;
Expand Down
13 changes: 11 additions & 2 deletions src/js/typed_arrays.zig
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,20 @@ pub fn TypedArray(comptime Element: type, comptime array_type: TypedarrayType) t
/// Creates a new JavaScript TypedArray backed by an *external* (native-heap)
/// ArrayBuffer.
///
/// The contents of `slice` are copied into a freshly allocated native buffer
/// (via `context.allocator()`).
/// Copies and duplicates the contents of `slice`
/// into a freshly allocated native buffer (via `context.allocator()`).
/// Caller keeps ownership of `slice`.
///
/// To transfer ownership of an
/// existing allocation without copying, use the owned typed arrays
/// (e.g. `js.OwnedUint8Array.fromOwnedSlice`).
///
/// V8 holds the pointer to manage the JS-side lifetime; the
/// native buffer is freed by a finalizer when V8 collects the ArrayBuffer.
///
/// Panics if called outside a DSL callback (`context.env()` requires the
/// wrapped-function context) — e.g. inside a raw `napi.AsyncWork` complete
/// callback. Use the owned typed arrays' env-explicit `intoValue(env)` there.
pub fn fromExternal(slice: []const Element) !Self {
const e = context.env();
const buf = try context.allocator().dupe(Element, slice);
Expand Down
82 changes: 70 additions & 12 deletions src/js/wrap_class.zig
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,8 @@ pub fn wrapClass(comptime T: type, comptime Identity: type) type {
inline for (all_decls, 0..) |decl, idx| {
const name = decl.name;
if (shouldSkipDecl(name) or consumed_methods[idx]) continue;
// A `pub var`'s value can't be read at comptime.
if (!isConstDecl(T, name)) continue;

const field = @field(T, name);
const field_info = @typeInfo(@TypeOf(field));
Expand Down Expand Up @@ -744,26 +746,72 @@ pub fn wrapClass(comptime T: type, comptime Identity: type) type {
};
}

/// Walks `T`'s public declarations and attaches each scalar/string `pub const`
/// as an own property of the just-defined JS class constructor `class_val`.
/// Walks `T`'s public declarations and attaches each scalar/string/enum
/// `pub const` as an own property of the just-defined JS class constructor
/// `class_val`.
///
/// Called by `export_module.zig` right after `napi_define_class` returns, so
/// the values land on the constructor itself (i.e. `MyClass.MY_CONST` in JS),
/// not on instances. A decl is exposed iff its value's type is an int, float,
/// bool, or string (`[]const u8` or `*const [N]u8`); functions, types, and
/// other decls are silently skipped — so existing methods/getters and the
/// `js_meta` decl naturally fall out.
/// not on instances. A decl is exposed iff it is a `const` (never a `var`) and
/// its value is an int, float, bool, string (`[]const u8` or `*const [N]u8`),
/// or an enum type (exported via `createEnumObject`); functions, other types,
/// and remaining decls are silently skipped — so existing methods/getters and
/// the `js_meta` decl naturally fall out.
pub fn applyStaticFields(comptime T: type, env: napi.Env, class_val: napi.Value) !void {
inline for (@typeInfo(T).@"struct".decls) |decl| {
if (comptime !isConstDecl(T, decl.name)) continue;
const value = @field(T, decl.name);
if (comptime !isStaticValueType(@TypeOf(value))) continue;
const napi_value = try createStaticFieldValue(env, value);
const name: [:0]const u8 = decl.name ++ "";
try class_val.setNamedProperty(name, napi_value);
const ValueType = @TypeOf(value);
if (comptime ValueType == type and @typeInfo(value) == .@"enum") {
const enum_obj = try createEnumObject(value, @typeName(T) ++ "." ++ decl.name, env);
const name: [:0]const u8 = decl.name ++ "";
try class_val.setNamedProperty(name, enum_obj);
} else if (comptime isStaticValueType(ValueType)) {
comptime assertExportableInt(@typeName(T) ++ "." ++ decl.name, @field(T, decl.name));
const napi_value = try createStaticFieldValue(env, value);
const name: [:0]const u8 = decl.name ++ "";
try class_val.setNamedProperty(name, napi_value);
}
}
}

fn isStaticValueType(comptime T: type) bool {
/// True when `T`'s decl `name` is a `const` (not a `var`). Reading a `var`'s
/// value at comptime is a compile error and its runtime value would only be a
/// registration-time snapshot, so reflection checks this before `@field`.
pub fn isConstDecl(comptime T: type, comptime name: []const u8) bool {
return @typeInfo(@TypeOf(&@field(T, name))).pointer.is_const;
}

/// Builds the frozen plain JS object for an exported enum type: tag names
/// verbatim, values as numbers. `path` names the decl in compile errors.
pub fn createEnumObject(comptime E: type, comptime path: []const u8, env: napi.Env) !napi.Value {
const enum_obj = try env.createObject();
inline for (@typeInfo(E).@"enum".fields) |tag| {
comptime assertExportableInt(path ++ "." ++ tag.name, tag.value);
const tag_value = try createStaticFieldValue(env, tag.value);
const tag_name: [:0]const u8 = tag.name ++ "";
try enum_obj.setNamedProperty(tag_name, tag_value);
}
try enum_obj.objectFreeze();
return enum_obj;
}

/// Comptime guard: exported integer consts and enum tags must fit in an i64
/// (`createStaticFieldValue` caps at `napi_create_int64`), otherwise the
/// `@intCast` there fails without naming the offending decl.
pub fn assertExportableInt(comptime path: []const u8, comptime value: anytype) void {
switch (@typeInfo(@TypeOf(value))) {
.comptime_int, .int => if (value < std.math.minInt(i64) or value > std.math.maxInt(i64)) {
@compileError("zapi: cannot export `" ++ path ++ "` — integer value doesn't fit in an i64");
},
else => {},
}
}

/// True for types exportable as plain JS values: ints, floats, bools, and
/// `[]const u8`/`*const [N]u8` strings. Shared by class statics and
/// namespace/module-level const export in `export_module.zig`.
pub fn isStaticValueType(comptime T: type) bool {
return switch (@typeInfo(T)) {
.comptime_int, .int, .comptime_float, .float, .bool => true,
.pointer => |ptr| blk: {
Expand All @@ -776,7 +824,8 @@ fn isStaticValueType(comptime T: type) bool {
};
}

fn createStaticFieldValue(env: napi.Env, value: anytype) !napi.Value {
/// Converts a static-value const (see `isStaticValueType`) to a `napi.Value`.
pub fn createStaticFieldValue(env: napi.Env, value: anytype) !napi.Value {
const T = @TypeOf(value);
switch (@typeInfo(T)) {
.comptime_int => {
Expand Down Expand Up @@ -818,3 +867,12 @@ fn createStaticFieldValue(env: napi.Env, value: anytype) !napi.Value {
test "wrapClass compile-time validation requires class metadata" {
try std.testing.expect(true);
}

test "isConstDecl distinguishes const from var decls" {
const S = struct {
pub const answer: u32 = 42;
pub var counter: u32 = 0;
};
try std.testing.expect(isConstDecl(S, "answer"));
try std.testing.expect(!isConstDecl(S, "counter"));
}
Loading