From 72f3c32ac02cce4722813547a30a7bfa9ac01530 Mon Sep 17 00:00:00 2001 From: Nathan Whitaker Date: Mon, 26 Oct 2026 14:08:32 -0700 Subject: [PATCH 1/3] Register Rust resource members through a MemberRegistry Resource::members adds each member to a registry, members(registry, flags), instead of returning a Vec, like the Registry parameter of C++ registerMembers. Generated member factories return `impl jsg::MemberDefinition`, which template creation builds into a Member; a hand-built Member is also a MemberDefinition. This prepares for RTTI: an RTTI registry can run the same registration code and collect type descriptions instead of callbacks, so TypeScript declarations follow the members a resource registers under a given set of compatibility flags. --- src/rust/AGENTS.md | 2 +- src/rust/jsg-macros/README.md | 30 +++++------ src/rust/jsg-macros/lib.rs | 2 +- src/rust/jsg-macros/resource.rs | 14 ++--- .../tests/resource_lazy_properties.rs | 10 ++-- src/rust/jsg-test/tests/resource_template.rs | 35 ++++++------- src/rust/jsg-test/tests/wildcard_property.rs | 8 +-- src/rust/jsg/README.md | 6 +-- src/rust/jsg/lib.rs | 2 + src/rust/jsg/resource-test.rs | 18 +++---- src/rust/jsg/resource.rs | 51 +++++++++++++++---- 11 files changed, 101 insertions(+), 77 deletions(-) diff --git a/src/rust/AGENTS.md b/src/rust/AGENTS.md index e64727d49ea..4a9c4ccd3af 100644 --- a/src/rust/AGENTS.md +++ b/src/rust/AGENTS.md @@ -37,7 +37,7 @@ _Snapshot — the set drifts as crates come and go; `bazel query //src/rust/...` - **JSG nullish values**: `Option` is `T | undefined`; `Nullable` is `T | null` (coerces `undefined` to `Null`, but method arguments remain required). Prefer `NullOrUndefined` to `Option>` for three distinct `Some(T)`, `Null`, and `Undefined` states; omitted arguments become `Undefined`. `Lenient` additionally preserves shallow mismatches as `Unconvertable`; converting it to `NullOrUndefined` maps those to `Undefined`, and `Lenient::to_parts()` also returns the original JS value. See [Rust JSG nullish values](jsg/README.md#nullablet-and-nullorundefinedt). - **JSG resources**: `#[jsg_resource]` on struct + impl block; `#[jsg_method]` auto-converts `snake_case` → `camelCase`; methods with `&self` become instance methods, methods without a receiver become static methods, and any other receiver is a compile error (JavaScript can re-enter a resource, so state that methods change goes in `Cell`/`RefCell` fields; never hold a `RefCell` borrow across a call that can run JavaScript, nor a `borrow_mut()` of one holding GC references across a call that can allocate on the V8 heap, wrap the resource or create or drop a `jsg::Rc` to it, since tracing skips a mutably borrowed `RefCell`); a method or accessor can take `this: jsg::Rc` as its first parameter instead of `&self` to get a `jsg::Rc` of its resource (like `JSG_THIS`) to hand to a callback; don't share the resource's state with a callback through an `std::rc::Rc` (see `jsg/README.md` §Reaching the resource from a callback); an eligible `#[jsg_method]` also gets a V8 Fast API callback (types in `jsg/fast_api.rs`) and must not call into V8; a `#[jsg_constructor]` returns `Self` or `Result` (an `Err` is thrown) and throws `TypeError` when called without `new`; `#[jsg_static_constant]` on `const` items exposes read-only numeric constants on both constructor and prototype (name kept as-is, no camelCase); `#[jsg_resource(nested_types(Foo, Name = Bar))]` on the impl block puts other resources' constructors on the prototype (`JSG_NESTED_TYPE`); resources integrate with GC via `Traced` + `GarbageCollected`: every named field is traced via `Traced::trace(&self.field, visitor)` and all non-traceable types use no-op `Traced` impls - **Resources created by C++**: C++ creates a Rust resource through an `extern "Rust"` function of the crate that defines it, which returns the wrapper through `jsg::v8::run_ffi_callback`; C++ holds the wrapper (a `jsg::JsRef`), since it has no typed reference to a Rust resource, and checks a value's type through one that returns `jsg::resource::ffi_is::`, a `kj::Maybe` that is none when an exception is pending. See `jsg/README.md` §Creating resources from C++. -- **Custom JSG registration**: `#[jsg_resource(custom_members)]` on an impl generates member factories but leaves `impl Resource` to the author. `Resource::members(flags)` uses ordinary Rust to select members, order, and property placement. Factories are `_member()` (constant names lowercased); ordinary property factories are `_member(PropertyPlacement)` (`Prototype`, `Instance` or `LazyInstance`) and include the paired setter. See `jsg-macros/README.md` §Custom member registration. +- **Custom JSG registration**: `#[jsg_resource(custom_members)]` on an impl generates member factories but leaves `impl Resource` to the author. `Resource::members(registry, flags)` uses ordinary Rust to select members, order, and property placement. Factories are `_member()` (constant names lowercased); ordinary property factories are `_member(PropertyPlacement)` (`Prototype`, `Instance` or `LazyInstance`) and include the paired setter. See `jsg-macros/README.md` §Custom member registration. - **JSG properties**: three property macros on `#[jsg_resource]` impl blocks — `#[jsg_property(prototype|instance|lazy_instance [, name = "..."] [, readonly])]` (`prototype` registers an accessor on the prototype, equivalent to `JSG_PROTOTYPE_PROPERTY`; `instance` registers an own data property via V8 `SetNativeDataProperty`, equivalent to `JSG_INSTANCE_PROPERTY`; `lazy_instance` registers an own data property via V8 `SetLazyDataProperty` whose getter runs once, on first access, equivalent to `JSG_LAZY_INSTANCE_PROPERTY`: it has no setter, is writable unless `readonly` (`JSG_LAZY_READONLY_INSTANCE_PROPERTY`); declaration order sets `Object.keys()` order for automatic registration; `readonly` is a compile-time check preventing a paired setter; `name = "..."` overrides the JS name; prefer `prototype` in almost all cases), `#[jsg_inspect_property]` (registered under a unique symbol, invisible to normal enumeration and string-key lookup, surfaced by `node:util` `inspect()`, equivalent to `JSG_INSPECT_PROPERTY`), and `#[jsg_wildcard_property]` (answers string-named properties the object and its prototypes lack, at most one per type, equivalent to `JSG_WILDCARD_PROPERTY`; see `jsg/README.md`); setter auto-detected from `set_` prefix; read-only when no setter present; getter/setter `.length` and `.name` are set correctly when `spec_compliant_property_attributes` compat flag is enabled - **`jsg::v8::Global` / `TracedGlobal`**: persistent handles. A `Global` is for handles Rust owns outside resources: GC never visits it, so it is always strong, holds its handle inline (no box), has no `Traced` impl (it cannot be a resource field), and is `Send` and `Sync`. A `TracedGlobal` is for resource fields, which GC traces to collect cycles; it is `Send` (not `Sync`). Either may be dropped on any thread. Under the isolate lock its strong handle is reset at once; without the lock it is queued and reset at the next `IsolateBase::Lock`, through `IsolateBase::applyDeferredActions()`; once the `Realm` is dropped (under the lock, before `Dispose`) the handles are left to `Dispose`. See `jsg/isolate_state.rs`. Hand-written FFI that resets a V8 handle directly must hold the lock. Rust never resets a `TracedGlobal`'s traced reference: every GC visit lets go of it and makes the strong handle strong again (`ClearWeak`) before C++ installs a new one if needed, and a drop just lets go of it; V8 frees untraced nodes at the next full GC. See `GcVisitor::visit_traced_global`. A `TracedGlobal` keeps its handles in a `Box`, since V8 holds the address of a weak handle's slot; the slot of a `TracedGlobal` dropped after the `Realm` is kept until the isolate is disposed - **JSG parameters**: function `.length` is the Web IDL required-argument count under `spec_compliant_property_attributes` (`this: jsg::Rc` and a leading `&mut Lock` are not counted; counting stops at the first type whose `FromJS::is_optional()` returns true), so put optional parameters last diff --git a/src/rust/jsg-macros/README.md b/src/rust/jsg-macros/README.md index 1a6acd93d34..d113b546b03 100644 --- a/src/rust/jsg-macros/README.md +++ b/src/rust/jsg-macros/README.md @@ -125,7 +125,7 @@ Generates JSG boilerplate for a resource type and its impl block. field whose type is or contains a traceable JSG handle (see [Garbage Collection](#garbage-collection) below). Use `#[jsg_resource(name = "JSName")]` to override the JavaScript class name. -**On an impl block** — emits `jsg::Resource::members(flags)`, registering every +**On an impl block** — emits `jsg::Resource::members(registry, flags)`, registering every `#[jsg_method]`, `#[jsg_constructor]`, `#[jsg_property]`, `#[jsg_inspect_property]`, `#[jsg_wildcard_property]`, and `#[jsg_static_constant]` item. It also generates member factories used by both automatic and custom registration. @@ -153,14 +153,15 @@ impl DnsUtil { ### Custom member registration Use `#[jsg_resource(custom_members)]` on the impl block to generate the bindings and member -factories without an automatic `Resource` implementation. Implement `Resource::members(flags)` -to choose which members to expose, their order, and property placement using ordinary Rust. -The flags belong to the realm constructing the template, as in C++ `JSG_RESOURCE_TYPE(T, flags)`. -Templates are cached per resource type within that realm; registration runs when a template is -created, not on each property access. +factories without an automatic `Resource` implementation. Implement +`Resource::members(registry, flags)` to choose which members to expose, their order, and property +placement using ordinary Rust, adding each to `registry`. The flags belong to the realm +constructing the template, as in C++ `JSG_RESOURCE_TYPE(T, flags)`. Templates are cached per +resource type within that realm; registration runs when a template is created, not on each +property access. ```rust -use jsg::{Member, PropertyPlacement, Resource}; +use jsg::{MemberRegistry, PropertyPlacement, Resource}; use jsg::feature_flags::compatibility_flags; use jsg_macros::{jsg_constructor, jsg_property, jsg_resource}; @@ -180,25 +181,22 @@ impl Example { } impl Resource for Example { - fn members(flags: compatibility_flags::Reader<'_>) -> Vec { + fn members(registry: &mut impl MemberRegistry, flags: compatibility_flags::Reader<'_>) { let placement = if flags.get_jsg_property_on_prototype_template() { PropertyPlacement::Prototype } else { PropertyPlacement::Instance }; - let mut members = vec![ - Self::constructor_member(), - Self::get_label_member(placement), - ]; + registry.add(Self::constructor_member()); + registry.add(Self::get_label_member(placement)); if flags.get_cache_option_enabled() && flags.get_jsg_property_on_prototype_template() { - members.push(Self::get_cache_member(PropertyPlacement::Prototype)); + registry.add(Self::get_cache_member(PropertyPlacement::Prototype)); } - members } } ``` -Factories are private associated functions returning `jsg::Member`. Their names derive from +Factories are private associated functions returning `impl jsg::MemberDefinition`. Their names derive from the **Rust declaration**, independently of any JavaScript `name = "..."` override: | Annotated declaration | Factory | @@ -217,7 +215,7 @@ handling apply in both modes. Getter and setter annotations must agree on placem Inspect properties remain symbol-keyed and do not take a placement argument. `LazyInstance` keeps the getter's `readonly` (see `lazy_instance` below); asking for it for a property with a setter panics when the template is created. -Property registration follows the returned member list's order. V8 enumerates instance native +Properties are registered in the order they are added to `registry`. V8 enumerates instance native data properties in reverse registration order, as it does for C++ JSG. `nested_types(...)` cannot be combined with `custom_members`; a custom `members` lists nested types itself, as `jsg::Member::NestedType { name, nested_type: jsg::NestedType::of::() }`. diff --git a/src/rust/jsg-macros/lib.rs b/src/rust/jsg-macros/lib.rs index b797950519d..d6f84924258 100644 --- a/src/rust/jsg-macros/lib.rs +++ b/src/rust/jsg-macros/lib.rs @@ -690,7 +690,7 @@ fn reject( /// The generated `Traced::trace` body simply delegates to `Traced::trace()` on /// every named field. /// -/// **On an impl block** — emits `jsg::Resource::members(flags)` registering every +/// **On an impl block** — emits `jsg::Resource::members(registry, flags)` registering every /// `#[jsg_method]`, `#[jsg_property]`, `#[jsg_inspect_property]`, `#[jsg_wildcard_property]`, /// `#[jsg_constructor]`, and `#[jsg_static_constant]` item, plus member factories. /// With `custom_members`, emits only the factories; implement `jsg::Resource` yourself diff --git a/src/rust/jsg-macros/resource.rs b/src/rust/jsg-macros/resource.rs index b82124ee2da..3c171152ea7 100644 --- a/src/rust/jsg-macros/resource.rs +++ b/src/rust/jsg-macros/resource.rs @@ -174,7 +174,7 @@ impl MemberRegistration { factory: quote! { #[automatically_derived] #[must_use] - fn #factory_name() -> jsg::Member { + fn #factory_name() -> impl jsg::MemberDefinition { #member } }, @@ -597,12 +597,12 @@ fn generate_resource_bindings( #[automatically_derived] impl jsg::Resource for #self_ty { fn members( + registry: &mut impl jsg::MemberRegistry, _flags: jsg::feature_flags::compatibility_flags::Reader<'_>, - ) -> Vec - where - Self: Sized, - { - vec![#(#automatic,)* #(#nested_type_registrations,)*] + ) { + let _ = ®istry; + #(registry.add(#automatic);)* + #(registry.add(#nested_type_registrations);)* } } } @@ -906,7 +906,7 @@ fn emit_property_group( factory: quote! { #[automatically_derived] #[must_use] - fn #factory_name(placement: jsg::PropertyPlacement) -> jsg::Member { + fn #factory_name(placement: jsg::PropertyPlacement) -> impl jsg::MemberDefinition { match placement { jsg::PropertyPlacement::Prototype => #prototype_member, jsg::PropertyPlacement::Instance => jsg::Member::InstanceProperty { diff --git a/src/rust/jsg-test/tests/resource_lazy_properties.rs b/src/rust/jsg-test/tests/resource_lazy_properties.rs index a95955afc63..75e94bb581d 100644 --- a/src/rust/jsg-test/tests/resource_lazy_properties.rs +++ b/src/rust/jsg-test/tests/resource_lazy_properties.rs @@ -6,7 +6,7 @@ use std::cell::Cell; -use jsg::Member; +use jsg::MemberRegistry; use jsg::PropertyPlacement; use jsg::Resource; use jsg::feature_flags::compatibility_flags; @@ -225,13 +225,13 @@ impl Placed { } impl Resource for Placed { - fn members(flags: compatibility_flags::Reader<'_>) -> Vec { + fn members(registry: &mut impl MemberRegistry, flags: compatibility_flags::Reader<'_>) { let placement = if flags.get_jsg_property_on_prototype_template() { PropertyPlacement::Prototype } else { PropertyPlacement::LazyInstance }; - vec![Self::get_value_member(placement)] + registry.add(Self::get_value_member(placement)); } } @@ -284,8 +284,8 @@ impl Settable { } impl Resource for Settable { - fn members(_flags: compatibility_flags::Reader<'_>) -> Vec { - vec![Self::get_value_member(PropertyPlacement::LazyInstance)] + fn members(registry: &mut impl MemberRegistry, _flags: compatibility_flags::Reader<'_>) { + registry.add(Self::get_value_member(PropertyPlacement::LazyInstance)); } } diff --git a/src/rust/jsg-test/tests/resource_template.rs b/src/rust/jsg-test/tests/resource_template.rs index aadeb26052c..e70f8db54f6 100644 --- a/src/rust/jsg-test/tests/resource_template.rs +++ b/src/rust/jsg-test/tests/resource_template.rs @@ -6,7 +6,7 @@ use std::cell::RefCell; -use jsg::Member; +use jsg::MemberRegistry; use jsg::PropertyPlacement; use jsg::Resource; use jsg::feature_flags::compatibility_flags; @@ -124,32 +124,27 @@ impl Sample { } impl Resource for Sample { - fn members(flags: compatibility_flags::Reader<'_>) -> Vec { + fn members(registry: &mut impl MemberRegistry, flags: compatibility_flags::Reader<'_>) { let placement = if flags.get_jsg_property_on_prototype_template() { PropertyPlacement::Prototype } else { PropertyPlacement::Instance }; - let mut members = vec![ - Self::constructor_member(), - Self::two_member(), - Self::with_lock_member(), - Self::add_member(), - Self::custom_optional_member(), - Self::value_member(), - Self::get_kind_member(placement), - Self::get_label_member(placement), - ]; + registry.add(Self::constructor_member()); + registry.add(Self::two_member()); + registry.add(Self::with_lock_member()); + registry.add(Self::add_member()); + registry.add(Self::custom_optional_member()); + registry.add(Self::value_member()); + registry.add(Self::get_kind_member(placement)); + registry.add(Self::get_label_member(placement)); if flags.get_cache_option_enabled() && flags.get_jsg_property_on_prototype_template() { - members.extend([ - Self::get_cache_member(placement), - Self::format_label_member(), - Self::describe_member(), - Self::debug_label_member(), - Self::extra_value_member(), - ]); + registry.add(Self::get_cache_member(placement)); + registry.add(Self::format_label_member()); + registry.add(Self::describe_member()); + registry.add(Self::debug_label_member()); + registry.add(Self::extra_value_member()); } - members } } diff --git a/src/rust/jsg-test/tests/wildcard_property.rs b/src/rust/jsg-test/tests/wildcard_property.rs index 7813a7f07b2..9a452fa1c59 100644 --- a/src/rust/jsg-test/tests/wildcard_property.rs +++ b/src/rust/jsg-test/tests/wildcard_property.rs @@ -8,7 +8,7 @@ use std::cell::RefCell; use jsg::ExceptionType; -use jsg::Member; +use jsg::MemberRegistry; use jsg::Resource; use jsg::feature_flags::compatibility_flags; use jsg::v8; @@ -144,11 +144,11 @@ impl Versioned { } impl Resource for Versioned { - fn members(flags: compatibility_flags::Reader<'_>) -> Vec { + fn members(registry: &mut impl MemberRegistry, flags: compatibility_flags::Reader<'_>) { if flags.get_cache_option_enabled() { - vec![Self::get_new_member()] + registry.add(Self::get_new_member()); } else { - vec![Self::get_old_member()] + registry.add(Self::get_old_member()); } } } diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index ae7665cc2f1..a193d63dab9 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -752,9 +752,9 @@ It does not reproduce worker-specific initialization such as logging, inspectors ### Compatibility-dependent API surfaces -`#[jsg_resource(custom_members)]` generates member factories while leaving `Resource::members(flags)` -to the resource author. Use ordinary Rust conditionals to select members and choose property placement -when the template is built. See [custom member registration](../jsg-macros/README.md#custom-member-registration) +`#[jsg_resource(custom_members)]` generates member factories while leaving +`Resource::members(registry, flags)` to the resource author. Use ordinary Rust conditionals to select +members and choose property placement when the template is built. See [custom member registration](../jsg-macros/README.md#custom-member-registration) for the factory naming conventions and a complete example. ### Key types and files diff --git a/src/rust/jsg/lib.rs b/src/rust/jsg/lib.rs index 3897896ed89..ea42131ea0e 100644 --- a/src/rust/jsg/lib.rs +++ b/src/rust/jsg/lib.rs @@ -33,6 +33,8 @@ use isolate_state::IsolateState; pub use lenient::Lenient; pub use null_or_undefined::NullOrUndefined; pub use nullable::Nullable; +pub use resource::MemberDefinition; +pub use resource::MemberRegistry; pub use resource::NestedType; pub use resource::Rc; pub use resource::Resource; diff --git a/src/rust/jsg/resource-test.rs b/src/rust/jsg/resource-test.rs index 31f9ffc1753..17818bbfc86 100644 --- a/src/rust/jsg/resource-test.rs +++ b/src/rust/jsg/resource-test.rs @@ -27,13 +27,13 @@ impl GarbageCollected for Bad { extern "C" fn getter(_args: *mut v8::ffi::FunctionCallbackInfo) {} impl Resource for Bad { - fn members(_flags: compatibility_flags::Reader<'_>) -> Vec { - vec![Member::Property { + fn members(registry: &mut impl MemberRegistry, _flags: compatibility_flags::Reader<'_>) { + registry.add(Member::Property { name: "bad".to_owned(), kind: crate::PropertyKind::Instance, getter_callback: getter, setter_callback: None, - }] + }); } } @@ -65,13 +65,13 @@ impl GarbageCollected for BadLazy { } impl Resource for BadLazy { - fn members(_flags: compatibility_flags::Reader<'_>) -> Vec { - vec![Member::Property { + fn members(registry: &mut impl MemberRegistry, _flags: compatibility_flags::Reader<'_>) { + registry.add(Member::Property { name: "bad".to_owned(), kind: crate::PropertyKind::LazyInstance, getter_callback: getter, setter_callback: None, - }] + }); } } @@ -103,13 +103,13 @@ impl GarbageCollected for BadLazyReadonly { } impl Resource for BadLazyReadonly { - fn members(_flags: compatibility_flags::Reader<'_>) -> Vec { - vec![Member::Property { + fn members(registry: &mut impl MemberRegistry, _flags: compatibility_flags::Reader<'_>) { + registry.add(Member::Property { name: "bad".to_owned(), kind: crate::PropertyKind::LazyReadonlyInstance, getter_callback: getter, setter_callback: None, - }] + }); } } diff --git a/src/rust/jsg/resource.rs b/src/rust/jsg/resource.rs index c8544015487..3836531356f 100644 --- a/src/rust/jsg/resource.rs +++ b/src/rust/jsg/resource.rs @@ -375,7 +375,7 @@ impl Default for Weak { /// /// **Prototype**: The returned object's prototype is set up from the resource's /// [`FunctionTemplate`], which includes all `#[jsg_method]` instance methods and -/// `#[jsg_static_constant]` values registered via [`Resource::members(flags)`](Resource::members). +/// `#[jsg_static_constant]` values registered via [`Resource::members`]. /// /// **Ownership**: The `Rc` is consumed. The JS wrapper keeps the `Wrappable` /// alive via `CppgcShim`. When the JS object is garbage collected and all Rust @@ -459,7 +459,17 @@ pub unsafe fn ffi_is( .into() } -/// Builds a [`ResourceDescriptor`] from `R`'s [`Resource::members(flags)`](Resource::members) list. +/// The registry through which template creation collects the members of a resource type. +struct TemplateMembers(Vec); + +impl MemberRegistry for TemplateMembers { + fn add(&mut self, member: impl MemberDefinition) { + self.0.push(member.into_member()); + } +} + +/// Builds a [`ResourceDescriptor`] from the members that `R`'s +/// [`Resource::members`] registers under `flags`. /// The descriptor is passed to C++ `create_resource_template` to set up the /// V8 `FunctionTemplate` with the correct methods, constants, and constructor. /// Nested types are returned separately, since their templates are installed @@ -482,7 +492,9 @@ fn get_resource_descriptor( wildcard_property: KjMaybe::None, }; - for m in R::members(flags) { + let mut members = TemplateMembers(Vec::new()); + R::members(&mut members, flags); + for m in members.0 { match m { Member::Constructor { callback, length } => { descriptor.constructor = KjMaybe::Some(v8::ffi::ConstructorDescriptor { @@ -739,22 +751,39 @@ impl Resources { } } +/// Receives the members that [`Resource::members`] registers, like the `Registry` parameter of +/// C++ `registerMembers`. +pub trait MemberRegistry { + fn add(&mut self, member: impl MemberDefinition); +} + +/// A member that [`Resource::members`] can register: either a [`Member`], or the result of a +/// generated member factory. +pub trait MemberDefinition { + /// Builds the member for template creation. + fn into_member(self) -> Member; +} + +impl MemberDefinition for Member { + fn into_member(self) -> Member { + self + } +} + /// Rust types exposed to JavaScript as resource types. /// /// Resource types are passed by reference and call back into Rust when JavaScript accesses /// their members. This is analogous to `JSG_RESOURCE_TYPE` in C++ JSG. Resources must provide /// member declarations, a cleanup function for GC, and access to their V8 wrapper state. pub trait Resource: Type + GarbageCollected + Sized + 'static { - /// Returns the list of methods, properties, and constructors exposed to JavaScript. + /// Registers the methods, properties, and constructors exposed to JavaScript. /// /// `flags` are the compatibility flags of the isolate the template is built for, so the - /// member list can vary with them, as with the `CompatibilityFlags::Reader flags` - /// parameter of C++ `JSG_RESOURCE_TYPE`. Templates are cached per resource type in the realm. - /// Use `#[jsg_resource(custom_members)]` to generate member factories while supplying - /// this implementation yourself. - fn members(flags: compatibility_flags::Reader<'_>) -> Vec - where - Self: Sized; + /// members can vary with them, as with the `CompatibilityFlags::Reader flags` parameter of + /// C++ `JSG_RESOURCE_TYPE`. Templates are cached per resource type in the realm. Use + /// `#[jsg_resource(custom_members)]` to generate member factories while supplying this + /// implementation yourself. + fn members(registry: &mut impl MemberRegistry, flags: compatibility_flags::Reader<'_>); } #[cfg(test)] From 28b74ed3440ec4efbc581822370d82f23cd5a113 Mon Sep 17 00:00:00 2001 From: Nathan Whitaker Date: Mon, 26 Oct 2026 14:10:32 -0700 Subject: [PATCH 2/3] Describe Rust JSG bindings through TypeInfo Add jsg::TypeInfo, which describes a binding type's JavaScript representation in the shared rtti.capnp schema without an isolate. #[jsg_resource], #[jsg_struct] and #[jsg_oneof] generate it; built-in conversion types implement it by hand, as custom conversion types must. A resource is described with the members it registers. Member factories return a definition that can both build the member and describe it, and RTTI runs Resource::members with its own registry under the compatibility flags of the jsg::rtti::Context, as C++ runs registerMembers with the RTTI builder's flags. Template creation never runs the descriptions, and RTTI never builds callbacks. As in C++, a nested type is described with its whole structure, through which the generator reaches it, and inspect properties, wildcard properties and other hand-built members have no description. The Context interns named types by TypeId before describing their members, so shared and recursive types are emitted once. Their type names identify them to the generator, so two types with one type name are rejected. Declaration names follow `name = "..."` separately, and ts_root marks a root of the ambient declarations, like JSG_TS_ROOT. Parameter names are camelCased, lose unused-parameter underscores and escape TypeScript reserved words; parameters whose names collide are rejected. Cover the mappings with an end-to-end test that runs a Rust fixture's RTTI through the types/ generator and type-checks the result. The fixture runs as test data in the target configuration, to avoid building V8 again as an exec-configuration tool. Teach the formatter to cover Rust sources under types/. --- build/wd_ts_test.bzl | 4 +- src/rust/AGENTS.md | 3 +- .../duplicate-parameter-names.rs | 17 + src/rust/jsg-macros/README.md | 20 +- src/rust/jsg-macros/lib.rs | 57 +- src/rust/jsg-macros/resource-test.rs | 145 ++++- src/rust/jsg-macros/resource.rs | 285 +++++++-- src/rust/jsg-macros/utils.rs | 6 +- src/rust/jsg-test/tests/jsg_struct.rs | 6 + src/rust/jsg-test/tests/resource_template.rs | 6 + src/rust/jsg-test/tests/to_js_errors.rs | 7 + src/rust/jsg/BUILD.bazel | 1 + src/rust/jsg/README.md | 43 +- src/rust/jsg/feature_flags.rs | 14 + src/rust/jsg/lib.rs | 4 + src/rust/jsg/resource.rs | 70 ++- src/rust/jsg/rtti-test.rs | 353 +++++++++++ src/rust/jsg/rtti.rs | 567 ++++++++++++++++++ src/workerd/jsg/README.md | 6 + tools/cross/format.json | 5 + types/AGENTS.md | 2 +- types/BUILD.bazel | 15 +- types/README.md | 5 +- types/rtti-fixture/BUILD.bazel | 10 + types/rtti-fixture/main.rs | 339 +++++++++++ types/test/generator/rust.spec.ts | 253 ++++++++ 26 files changed, 2165 insertions(+), 78 deletions(-) create mode 100644 src/rust/jsg-compile-tests/duplicate-parameter-names.rs create mode 100644 src/rust/jsg/rtti-test.rs create mode 100644 src/rust/jsg/rtti.rs create mode 100644 types/rtti-fixture/BUILD.bazel create mode 100644 types/rtti-fixture/main.rs create mode 100644 types/test/generator/rust.spec.ts diff --git a/build/wd_ts_test.bzl b/build/wd_ts_test.bzl index 11bdee878da..68815f77b8b 100644 --- a/build/wd_ts_test.bzl +++ b/build/wd_ts_test.bzl @@ -2,7 +2,7 @@ load("@aspect_rules_js//js:defs.bzl", "js_test") load("//:build/typescript.bzl", "js_name", "module_name") load("//:build/wd_ts_project.bzl", "wd_ts_project") -def wd_ts_test(src, tsconfig_json, deps = [], eslintrc_json = None, composite = True, **kwargs): +def wd_ts_test(src, tsconfig_json, deps = [], data = [], eslintrc_json = None, composite = True, **kwargs): """Bazel rule to compile and run a TypeScript test""" name = module_name(src) @@ -20,7 +20,7 @@ def wd_ts_test(src, tsconfig_json, deps = [], eslintrc_json = None, composite = js_test( name = name, entry_point = js_name(src), - data = deps + [name + "@compile"], + data = deps + data + [name + "@compile"], tags = ["js-test"], **kwargs ) diff --git a/src/rust/AGENTS.md b/src/rust/AGENTS.md index 4a9c4ccd3af..1eadf0ee123 100644 --- a/src/rust/AGENTS.md +++ b/src/rust/AGENTS.md @@ -37,7 +37,7 @@ _Snapshot — the set drifts as crates come and go; `bazel query //src/rust/...` - **JSG nullish values**: `Option` is `T | undefined`; `Nullable` is `T | null` (coerces `undefined` to `Null`, but method arguments remain required). Prefer `NullOrUndefined` to `Option>` for three distinct `Some(T)`, `Null`, and `Undefined` states; omitted arguments become `Undefined`. `Lenient` additionally preserves shallow mismatches as `Unconvertable`; converting it to `NullOrUndefined` maps those to `Undefined`, and `Lenient::to_parts()` also returns the original JS value. See [Rust JSG nullish values](jsg/README.md#nullablet-and-nullorundefinedt). - **JSG resources**: `#[jsg_resource]` on struct + impl block; `#[jsg_method]` auto-converts `snake_case` → `camelCase`; methods with `&self` become instance methods, methods without a receiver become static methods, and any other receiver is a compile error (JavaScript can re-enter a resource, so state that methods change goes in `Cell`/`RefCell` fields; never hold a `RefCell` borrow across a call that can run JavaScript, nor a `borrow_mut()` of one holding GC references across a call that can allocate on the V8 heap, wrap the resource or create or drop a `jsg::Rc` to it, since tracing skips a mutably borrowed `RefCell`); a method or accessor can take `this: jsg::Rc` as its first parameter instead of `&self` to get a `jsg::Rc` of its resource (like `JSG_THIS`) to hand to a callback; don't share the resource's state with a callback through an `std::rc::Rc` (see `jsg/README.md` §Reaching the resource from a callback); an eligible `#[jsg_method]` also gets a V8 Fast API callback (types in `jsg/fast_api.rs`) and must not call into V8; a `#[jsg_constructor]` returns `Self` or `Result` (an `Err` is thrown) and throws `TypeError` when called without `new`; `#[jsg_static_constant]` on `const` items exposes read-only numeric constants on both constructor and prototype (name kept as-is, no camelCase); `#[jsg_resource(nested_types(Foo, Name = Bar))]` on the impl block puts other resources' constructors on the prototype (`JSG_NESTED_TYPE`); resources integrate with GC via `Traced` + `GarbageCollected`: every named field is traced via `Traced::trace(&self.field, visitor)` and all non-traceable types use no-op `Traced` impls - **Resources created by C++**: C++ creates a Rust resource through an `extern "Rust"` function of the crate that defines it, which returns the wrapper through `jsg::v8::run_ffi_callback`; C++ holds the wrapper (a `jsg::JsRef`), since it has no typed reference to a Rust resource, and checks a value's type through one that returns `jsg::resource::ffi_is::`, a `kj::Maybe` that is none when an exception is pending. See `jsg/README.md` §Creating resources from C++. -- **Custom JSG registration**: `#[jsg_resource(custom_members)]` on an impl generates member factories but leaves `impl Resource` to the author. `Resource::members(registry, flags)` uses ordinary Rust to select members, order, and property placement. Factories are `_member()` (constant names lowercased); ordinary property factories are `_member(PropertyPlacement)` (`Prototype`, `Instance` or `LazyInstance`) and include the paired setter. See `jsg-macros/README.md` §Custom member registration. +- **Custom JSG registration**: `#[jsg_resource(custom_members)]` on an impl generates member factories but leaves `impl Resource` to the author. `Resource::members(registry, flags)` uses ordinary Rust to select members, order, and property placement, and also drives RTTI under the export's flags. Factories are `_member()` (constant names lowercased); ordinary property factories are `_member(PropertyPlacement)` (`Prototype`, `Instance` or `LazyInstance`) and include the paired setter. See `jsg-macros/README.md` §Custom member registration. - **JSG properties**: three property macros on `#[jsg_resource]` impl blocks — `#[jsg_property(prototype|instance|lazy_instance [, name = "..."] [, readonly])]` (`prototype` registers an accessor on the prototype, equivalent to `JSG_PROTOTYPE_PROPERTY`; `instance` registers an own data property via V8 `SetNativeDataProperty`, equivalent to `JSG_INSTANCE_PROPERTY`; `lazy_instance` registers an own data property via V8 `SetLazyDataProperty` whose getter runs once, on first access, equivalent to `JSG_LAZY_INSTANCE_PROPERTY`: it has no setter, is writable unless `readonly` (`JSG_LAZY_READONLY_INSTANCE_PROPERTY`); declaration order sets `Object.keys()` order for automatic registration; `readonly` is a compile-time check preventing a paired setter; `name = "..."` overrides the JS name; prefer `prototype` in almost all cases), `#[jsg_inspect_property]` (registered under a unique symbol, invisible to normal enumeration and string-key lookup, surfaced by `node:util` `inspect()`, equivalent to `JSG_INSPECT_PROPERTY`), and `#[jsg_wildcard_property]` (answers string-named properties the object and its prototypes lack, at most one per type, equivalent to `JSG_WILDCARD_PROPERTY`; see `jsg/README.md`); setter auto-detected from `set_` prefix; read-only when no setter present; getter/setter `.length` and `.name` are set correctly when `spec_compliant_property_attributes` compat flag is enabled - **`jsg::v8::Global` / `TracedGlobal`**: persistent handles. A `Global` is for handles Rust owns outside resources: GC never visits it, so it is always strong, holds its handle inline (no box), has no `Traced` impl (it cannot be a resource field), and is `Send` and `Sync`. A `TracedGlobal` is for resource fields, which GC traces to collect cycles; it is `Send` (not `Sync`). Either may be dropped on any thread. Under the isolate lock its strong handle is reset at once; without the lock it is queued and reset at the next `IsolateBase::Lock`, through `IsolateBase::applyDeferredActions()`; once the `Realm` is dropped (under the lock, before `Dispose`) the handles are left to `Dispose`. See `jsg/isolate_state.rs`. Hand-written FFI that resets a V8 handle directly must hold the lock. Rust never resets a `TracedGlobal`'s traced reference: every GC visit lets go of it and makes the strong handle strong again (`ClearWeak`) before C++ installs a new one if needed, and a drop just lets go of it; V8 frees untraced nodes at the next full GC. See `GcVisitor::visit_traced_global`. A `TracedGlobal` keeps its handles in a `Box`, since V8 holds the address of a weak handle's slot; the slot of a `TracedGlobal` dropped after the `Realm` is kept until the isolate is disposed - **JSG parameters**: function `.length` is the Web IDL required-argument count under `spec_compliant_property_attributes` (`this: jsg::Rc` and a leading `&mut Lock` are not counted; counting stops at the first type whose `FromJS::is_optional()` returns true), so put optional parameters last @@ -70,6 +70,7 @@ _Snapshot — the set drifts as crates come and go; `bazel query //src/rust/...` - **FFI naming**: instance methods on an existing handle use a `local__` prefix (e.g. `local_array_buffer_byte_length`). Static constructors that create a new value do **not** use the `local_` prefix — name them `_` (e.g. `array_buffer_new_with_mode`, `array_buffer_maybe_new`, `backing_store_new_resizable`). - **FFI groups**: `v8.rs` `mod ffi`, `ffi.h`, and `ffi.c++` all use matching comment groups (e.g. `// Local`, `// Local`, `// Local`, `// Local`, `// Local`, `// Local`, `// BackingStore`, `// Unwrappers`, `// TracedGlobal`, `// FunctionCallbackInfo`). When adding new FFI functions, place them in the correct group in **all three files**. Do not scatter related functions across groups. - **Feature flags**: `Lock::feature_flags()` returns a capnp `compatibility_flags::Reader` for the current worker. Use `lock.feature_flags().get_node_js_compat()`. Flags are parsed once and stored in the `Realm` at construction; C++ passes canonical capnp bytes to `realm_create()`. Schema: `src/workerd/io/compatibility-date.capnp`, generated Rust bindings: `compatibility_date_capnp` crate. +- **RTTI**: JSG macros generate `TypeInfo` descriptions in `jsg/rtti.rs`, serialized using the shared `jsg/rtti.capnp` schema. `ts_root` on a resource/struct marks an ambient TypeScript root; internal module exports are not roots by default. Custom conversion types implement `TypeInfo`. ## UNIT TEST FILES diff --git a/src/rust/jsg-compile-tests/duplicate-parameter-names.rs b/src/rust/jsg-compile-tests/duplicate-parameter-names.rs new file mode 100644 index 00000000000..52e826df8be --- /dev/null +++ b/src/rust/jsg-compile-tests/duplicate-parameter-names.rs @@ -0,0 +1,17 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +use jsg_macros::jsg_method; +use jsg_macros::jsg_resource; + +#[jsg_resource] +struct Example; + +#[jsg_resource] +impl Example { + #[jsg_method] + fn method(&self, _a: String, a: String) { + //~^ ERROR duplicate TypeScript parameter name `a` + } +} diff --git a/src/rust/jsg-macros/README.md b/src/rust/jsg-macros/README.md index d113b546b03..cb47e6f5bbf 100644 --- a/src/rust/jsg-macros/README.md +++ b/src/rust/jsg-macros/README.md @@ -16,7 +16,7 @@ boilerplate when implementing the JSG type system for Rust-backed JavaScript API ## `#[jsg_struct]` -Generates `jsg::Struct`, `jsg::Type`, `jsg::ToJS`, and `jsg::FromJS` for a plain data +Generates `jsg::Struct`, `jsg::Type`, `jsg::TypeInfo`, `jsg::ToJS`, and `jsg::FromJS` for a plain data struct. Only `pub` fields are projected into the JavaScript object. Use `#[jsg_struct(name = "MyName")]` to override the JavaScript class name. @@ -32,6 +32,9 @@ produces a property whose value is `null`. An absent input property becomes `Non or `Undefined`, respectively. If all public fields are optional, a `null` or `undefined` input is accepted as an empty dictionary. +`ts_root` marks a TypeScript generation root; it can be combined with `name`. +RTTI describes the public fields using their types' `TypeInfo` implementations. + ```rust #[jsg_struct] pub struct CaaRecord { @@ -120,10 +123,11 @@ Eligible methods also get a V8 Fast API callback; see "Fast API" in `src/rust/js Generates JSG boilerplate for a resource type and its impl block. -**On a struct** — emits `jsg::Type`, `jsg::ToJS`, `jsg::FromJS`, and +**On a struct** — emits `jsg::Type`, `jsg::TypeInfo`, `jsg::ToJS`, `jsg::FromJS`, and `jsg::GarbageCollected`. The `trace()` body is synthesised automatically for every field whose type is or contains a traceable JSG handle (see [Garbage Collection](#garbage-collection) below). Use `#[jsg_resource(name = "JSName")]` to override the JavaScript class name. +The optional `ts_root` argument marks a TypeScript generation root. **On an impl block** — emits `jsg::Resource::members(registry, flags)`, registering every `#[jsg_method]`, `#[jsg_constructor]`, `#[jsg_property]`, `#[jsg_inspect_property]`, @@ -132,6 +136,11 @@ and custom registration. Use `#[jsg_resource(nested_types(Foo, Name = Bar))]` to expose other resource types' constructors on the prototype (equivalent to `JSG_NESTED_TYPE(Foo)` and `JSG_NESTED_TYPE_NAMED(Bar, Name)` in C++ JSG). +Each factory also describes its member for RTTI, which describes a resource type with the +members its `Resource::members` registers, in registration order. Methods, properties, +constructors, and constants are described; inspect and wildcard properties are not. Parameter +names are retained, injected `this` and lock parameters are excluded, and fallible return types +describe their success value. See [RTTI](../jsg/README.md#rtti-and-typescript-declarations). ```rust #[jsg_resource] @@ -160,6 +169,13 @@ constructing the template, as in C++ `JSG_RESOURCE_TYPE(T, flags)`. Templates ar resource type within that realm; registration runs when a template is created, not on each property access. +RTTI export runs the same function with its own registry and the flags of the export, so the +TypeScript declarations follow the same choices, like the `Registry` parameter of C++ +`registerMembers`. Template creation never runs the type descriptions, and RTTI export never +builds callbacks. A hand-built `jsg::Member` can also be added. RTTI describes a hand-built +`Member::NestedType` through its nested type, and omits other hand-built members, which carry no +type description. + ```rust use jsg::{MemberRegistry, PropertyPlacement, Resource}; use jsg::feature_flags::compatibility_flags; diff --git a/src/rust/jsg-macros/lib.rs b/src/rust/jsg-macros/lib.rs index d6f84924258..2dc11cc3adf 100644 --- a/src/rust/jsg-macros/lib.rs +++ b/src/rust/jsg-macros/lib.rs @@ -44,6 +44,7 @@ use utils::error; use utils::extract_name_attribute; use utils::extract_named_fields; use utils::fast_api_signature; +use utils::has_flag; use utils::is_lock_ref; use utils::is_named_this; use utils::is_null_or_undefined_type; @@ -66,17 +67,18 @@ use utils::this_param; /// Optional fields use `Option` or `jsg::NullOrUndefined`; prefer the latter to /// `Option>` to distinguish `null` from `undefined`. /// Use `#[jsg_struct(name = "MyName")]` to override `Type::class_name()` -/// metadata (used in diagnostics/type reporting), not to define a JS class. +/// metadata and the TypeScript declaration name; it does not define a JS class. #[proc_macro_attribute] pub fn jsg_struct(attr: TokenStream, item: TokenStream) -> TokenStream { let input = parse_macro_input!(item as DeriveInput); let name = &input.ident; - let class_name = extract_name_attribute(attr).unwrap_or_else(|| name.to_string()); + let class_name = extract_name_attribute(attr.clone()).unwrap_or_else(|| name.to_string()); let named_fields = match extract_named_fields(&input, "jsg_struct") { Ok(fields) => fields, Err(err) => return err, }; + let rtti = describe_struct(name, &class_name, has_flag(&attr, "ts_root"), &named_fields); let mut to_js_fields = Vec::new(); let mut field_extractions = Vec::new(); @@ -175,6 +177,8 @@ pub fn jsg_struct(attr: TokenStream, item: TokenStream) -> TokenStream { impl jsg::Struct for #name {} + #rtti + #traced_impl } .into() @@ -253,6 +257,41 @@ fn to_js_fn_body( } } +fn describe_struct( + name: &syn::Ident, + class_name: &str, + ts_root: bool, + fields: &syn::punctuated::Punctuated, +) -> proc_macro2::TokenStream { + let members = fields.iter().filter_map(|field| { + if !matches!(field.vis, syn::Visibility::Public(_)) { + return None; + } + let name = field.ident.as_ref()?.to_string(); + let ty = &field.ty; + Some(quote! { + jsg::rtti::Member::Property { + name: #name, + ty: <#ty as jsg::TypeInfo>::describe(cx), + readonly: false, + prototype: false, + lazy: false, + } + }) + }); + quote! { + #[automatically_derived] + impl jsg::TypeInfo for #name { + fn describe(cx: &mut jsg::rtti::Context) -> jsg::rtti::Type { + cx.structure::(#class_name, #ts_root, |cx| { + let _ = &cx; + vec![#(#members),*] + }) + } + } + } +} + // ============================================================================= // #[jsg_method] // ============================================================================= @@ -692,7 +731,8 @@ fn reject( /// /// **On an impl block** — emits `jsg::Resource::members(registry, flags)` registering every /// `#[jsg_method]`, `#[jsg_property]`, `#[jsg_inspect_property]`, `#[jsg_wildcard_property]`, -/// `#[jsg_constructor]`, and `#[jsg_static_constant]` item, plus member factories. +/// `#[jsg_constructor]`, and `#[jsg_static_constant]` item, plus member factories, which also +/// describe their members for RTTI. /// With `custom_members`, emits only the factories; implement `jsg::Resource` yourself /// to select members using ordinary Rust control flow and compatibility flags. /// @@ -902,6 +942,10 @@ pub fn jsg_oneof(_attr: TokenStream, item: TokenStream) -> TokenStream { .map(|(_, inner_type)| quote! { <#inner_type as jsg::Type>::is_exact(value) }) .collect(); + let rtti_variants = variants.iter().map(|(_, ty)| { + quote! { <#ty as jsg::TypeInfo>::describe(cx) } + }); + let error_msg = quote! { let expected: Vec<&str> = vec![#(#type_names),*]; format!( @@ -925,6 +969,13 @@ pub fn jsg_oneof(_attr: TokenStream, item: TokenStream) -> TokenStream { } } + #[automatically_derived] + impl jsg::TypeInfo for #name { + fn describe(cx: &mut jsg::rtti::Context) -> jsg::rtti::Type { + jsg::rtti::Type::OneOf(vec![#(#rtti_variants),*]) + } + } + #[automatically_derived] impl<'s> jsg::FromJS<'s> for #name { type ResultType = Self; diff --git a/src/rust/jsg-macros/resource-test.rs b/src/rust/jsg-macros/resource-test.rs index 4e1d572e5af..43f026dab3e 100644 --- a/src/rust/jsg-macros/resource-test.rs +++ b/src/rust/jsg-macros/resource-test.rs @@ -297,31 +297,98 @@ fn constant_factory_lowercases_rust_name_without_changing_js_name() { }; let registrations = collect_constant_registrations(&input); let registration = ®istrations[0]; - let factory: syn::ImplItemFn = syn::parse2(registration.factory.clone()).unwrap(); + let factory: syn::ImplItemFn = syn::parse2(registration.factory()).unwrap(); assert_eq!(factory.sig.ident, "max_bytes_member"); assert_eq!(registration.source_name, "MAX_BYTES"); assert_eq!( registration.automatic.to_string(), quote!(Self::max_bytes_member()).to_string() ); - let syn::Stmt::Expr(member, None) = &factory.block.stmts[0] else { - panic!("expected the factory to return a Member"); - }; + let member: syn::Expr = syn::parse2(registration.member.clone()).unwrap(); assert_eq!( - member_field(member, "name").to_token_stream().to_string(), + member_field(&member, "name").to_token_stream().to_string(), quote!("maxPayload".to_owned()).to_string() ); assert_eq!( - member_field(member, "value").to_token_stream().to_string(), + member_field(&member, "value").to_token_stream().to_string(), quote!(jsg::ConstantValue::from(Self::MAX_BYTES)).to_string() ); + let description = description(registration).unwrap(); + assert_eq!( + member_field(&description, "name") + .to_token_stream() + .to_string(), + quote!("maxPayload").to_string() + ); +} + +/// The `jsg::rtti::Member` expression that a registration's factory describes its member with. +fn description(registration: &MemberRegistration) -> Option { + registration + .description + .clone() + .map(|description| syn::parse2(description).unwrap()) +} + +#[test] +fn factories_build_members_and_descriptions_separately() { + let input: ItemImpl = parse_quote! { + impl Sample { + #[jsg_method] + fn foo(&self) {} + } + }; + let registrations = collect_method_registrations(&input); + let factory: syn::ImplItemFn = syn::parse2(registrations[0].factory()).unwrap(); + assert_eq!( + factory.sig.output.to_token_stream().to_string(), + quote!(-> impl jsg::MemberDefinition).to_string() + ); + let syn::Stmt::Expr(syn::Expr::Call(definition), None) = &factory.block.stmts[0] else { + panic!("expected a described member"); + }; + assert_eq!( + definition.func.to_token_stream().to_string(), + quote!(jsg::described_member).to_string() + ); + assert_eq!(definition.args.len(), 2); +} + +#[test] +fn method_descriptions_skip_injected_parameters() { + let input: ItemImpl = parse_quote! { + impl Sample { + #[jsg_method] + fn with_this(this: jsg::Rc, lock: &mut jsg::Lock, value: u32) {} + #[jsg_method] + fn with_self(&self, lock: &mut jsg::Lock, value: u32) {} + #[jsg_method] + fn without_self(lock: &mut jsg::Lock, value: u32) {} + } + }; + let registrations = collect_method_registrations(&input); + for (registration, is_static) in registrations.iter().zip([false, false, true]) { + let description = description(registration).unwrap(); + assert_eq!( + member_field(&description, "is_static") + .to_token_stream() + .to_string(), + quote!(#is_static).to_string() + ); + let syn::Expr::Macro(args) = member_field(&description, "args") else { + panic!("expected a vec! of parameters"); + }; + let args = args.mac.tokens.to_string(); + assert!(args.contains("\"value\""), "{args}"); + assert!(!args.contains("this") && !args.contains("lock"), "{args}"); + } } /// The arms of the `match placement` in a property factory, by pattern. fn placement_arms(registration: MemberRegistration) -> Vec<(String, syn::Expr)> { - let factory: syn::ImplItemFn = syn::parse2(registration.factory).unwrap(); + let factory: syn::ImplItemFn = syn::parse2(registration.factory()).unwrap(); assert_eq!(factory.sig.inputs.len(), 1); - let syn::Stmt::Expr(syn::Expr::Match(placement), None) = &factory.block.stmts[0] else { + let syn::Expr::Match(placement) = syn::parse2(registration.member).unwrap() else { panic!("expected a placement-dependent factory"); }; placement @@ -371,6 +438,7 @@ fn property_factories_select_matching_getter_and_setter_callback_shapes() { Some(parse_quote!(get_value)), Some(parse_quote!(set_value)), false, + "e!(jsg::rtti::Type::Number), ) .unwrap(); assert_eq!( @@ -436,6 +504,7 @@ fn readonly_property_factories_omit_setters_for_every_placement() { Some(parse_quote!(get_value)), None, false, + "e!(jsg::rtti::Type::Number), ) .unwrap(); let arms = placement_arms(registration); @@ -470,6 +539,7 @@ fn lazy_instance_property_factory_is_writable_unless_readonly() { Some(parse_quote!(get_value)), None, is_readonly, + "e!(jsg::rtti::Type::Number), ) .unwrap(); assert_eq!( @@ -666,3 +736,62 @@ fn nested_types_are_registered_after_members() { let nested = expanded.find("jsg :: NestedType :: of :: < Bar >").unwrap(); assert!(method < nested, "{expanded}"); } + +#[test] +fn property_descriptions_follow_the_requested_placement() { + let ty = quote!(jsg::rtti::Type::Number); + for (setter, is_readonly, accessor_readonly, lazy_readonly) in [ + (Some(parse_quote!(set_value)), false, false, false), + (None, false, true, false), + (None, true, true, true), + ] { + let registration = emit_property_group( + "value", + PropertyKind::Prototype, + Some(parse_quote!(get_value)), + setter, + is_readonly, + &ty, + ) + .unwrap(); + let description = description(®istration).unwrap(); + assert_eq!( + member_field(&description, "ty") + .to_token_stream() + .to_string(), + ty.to_string() + ); + assert_eq!( + member_field(&description, "prototype") + .to_token_stream() + .to_string(), + quote!(matches!(placement, jsg::PropertyPlacement::Prototype)).to_string() + ); + let syn::Expr::Match(readonly) = member_field(&description, "readonly") else { + panic!("expected a placement-dependent `readonly`"); + }; + let arms: Vec<_> = readonly + .arms + .iter() + .map(|arm| arm.body.to_token_stream().to_string()) + .collect(); + assert_eq!( + arms, + [ + quote!({ #accessor_readonly }).to_string(), + quote!(#lazy_readonly).to_string() + ] + ); + } + + let inspect = emit_property_group( + "value", + PropertyKind::Inspect, + Some(parse_quote!(get_value)), + None, + false, + &ty, + ) + .unwrap(); + assert!(inspect.description.is_none()); +} diff --git a/src/rust/jsg-macros/resource.rs b/src/rust/jsg-macros/resource.rs index 3c171152ea7..c0851b8ffa5 100644 --- a/src/rust/jsg-macros/resource.rs +++ b/src/rust/jsg-macros/resource.rs @@ -6,10 +6,12 @@ //! //! - On a **struct**: emits `jsg::Type`, `jsg::ToJS`, `jsg::FromJS`, //! `jsg::Traced`, and `jsg::GarbageCollected` implementations. -//! - On an **impl block**: emits member factories and, unless `custom_members` is set, -//! the `jsg::Resource` trait with automatic member and nested type registration. +//! - On an **impl block**: emits member factories, which also describe their members for +//! RTTI, and, unless `custom_members` is set, the `jsg::Resource` trait with automatic +//! member and nested type registration. use std::collections::HashMap; +use std::collections::HashSet; use proc_macro::TokenStream; use quote::quote; @@ -21,6 +23,7 @@ use crate::utils::elide_named_lifetimes; use crate::utils::extract_name_attribute; use crate::utils::extract_named_fields; use crate::utils::has_custom_trace_flag; +use crate::utils::has_flag; use crate::utils::is_attr; use crate::utils::is_lock_ref; use crate::utils::is_result_type; @@ -47,6 +50,7 @@ pub fn generate_resource_struct(attr: TokenStream, input: &syn::DeriveInput) -> // Check for `#[jsg_resource(custom_trace)]` before consuming `attr`. let custom_trace = has_custom_trace_flag(&attr); + let ts_root = has_flag(&attr, "ts_root"); // Clone the name before mutating `input` so we can borrow freely later. let name = input.ident.clone(); @@ -108,6 +112,13 @@ pub fn generate_resource_struct(attr: TokenStream, input: &syn::DeriveInput) -> } } + #[automatically_derived] + impl jsg::TypeInfo for #name { + fn describe(cx: &mut jsg::rtti::Context) -> jsg::rtti::Type { + cx.structure::(#class_name, #ts_root, jsg::rtti::resource_members::) + } + } + #[automatically_derived] impl<'s> jsg::ToJS<'s> for #name { fn to_js( @@ -149,10 +160,19 @@ pub fn generate_resource_struct(attr: TokenStream, input: &syn::DeriveInput) -> } /// A generated member factory and its invocation for automatic registration. +/// +/// The factory returns a `jsg::MemberDefinition`: template creation builds `member`, and RTTI +/// export evaluates `description`, with the `jsg::rtti::Context` bound to `cx`. Both see the +/// factory's parameters. struct MemberRegistration { source_name: syn::Ident, factory_name: syn::Ident, - factory: proc_macro2::TokenStream, + params: proc_macro2::TokenStream, + /// A `jsg::Member` expression. + member: proc_macro2::TokenStream, + /// A `jsg::rtti::Member` expression, or `None` for a member without a TypeScript + /// declaration. + description: Option, automatic: proc_macro2::TokenStream, } @@ -161,28 +181,63 @@ fn member_factory_name(rust_name: &syn::Ident) -> syn::Ident { } impl MemberRegistration { - fn new(rust_name: &syn::Ident, member: &proc_macro2::TokenStream) -> Self { - Self::with_factory_name(rust_name, member_factory_name(rust_name), member) + fn new( + rust_name: &syn::Ident, + member: proc_macro2::TokenStream, + description: Option, + ) -> Self { + Self::with_factory_name( + rust_name, + member_factory_name(rust_name), + member, + description, + ) } fn with_factory_name( rust_name: &syn::Ident, factory_name: syn::Ident, - member: &proc_macro2::TokenStream, + member: proc_macro2::TokenStream, + description: Option, ) -> Self { Self { - factory: quote! { - #[automatically_derived] - #[must_use] - fn #factory_name() -> impl jsg::MemberDefinition { - #member - } - }, automatic: quote! { Self::#factory_name() }, + params: quote! {}, + member, + description, source_name: rust_name.clone(), factory_name, } } + + fn factory(&self) -> proc_macro2::TokenStream { + let Self { + factory_name, + params, + member, + description, + .. + } = self; + let definition = match description { + Some(description) => quote! { + jsg::described_member( + move || #member, + move |cx: &mut jsg::rtti::Context| { + let _ = &cx; + #description + }, + ) + }, + None => member.clone(), + }; + quote! { + #[automatically_derived] + #[must_use] + fn #factory_name(#params) -> impl jsg::MemberDefinition { + #definition + } + } + } } /// Scans `impl_block` for `#[jsg_method]`-annotated functions. @@ -197,13 +252,7 @@ fn collect_method_registrations(impl_block: &ItemImpl) -> Vec Vec Vec Vec { + let args: Vec<_> = method + .sig + .inputs + .iter() + .filter_map(|arg| match arg { + FnArg::Typed(arg) => Some(arg), + FnArg::Receiver(_) => None, + }) + .collect(); + // Injected parameters come first: a method's `this`, then the lock. + let this_params = match callable { + Callable::Method => usize::from(this_param(&method.sig).is_some()), + Callable::Constructor => 0, + }; + let offset = this_params + + usize::from( + args.get(this_params) + .is_some_and(|arg| is_lock_ref(&arg.ty)), + ); + let mut names = HashSet::new(); + args.iter().skip(offset).enumerate().map(|(i, arg)| { + let ty = elide_named_lifetimes(&arg.ty); + let name = match arg.pat.as_ref() { + syn::Pat::Ident(pat) => { + let ident = pat.ident.to_string(); + let name = ident.trim_start_matches("r#").trim_start_matches('_'); + if name.is_empty() { format!("param{i}") } else { snake_to_camel(name) } + } + _ => format!("param{i}"), + }; + if !names.insert(name.clone()) { + return syn::Error::new_spanned( + &arg.pat, + format!("duplicate TypeScript parameter name `{name}`"), + ).to_compile_error(); + } + quote! { jsg::rtti::Parameter { name: #name, ty: <#ty as jsg::TypeInfo>::describe(cx) } } + }).collect() +} + +fn describe_result(method: &syn::ImplItemFn) -> proc_macro2::TokenStream { + match &method.sig.output { + syn::ReturnType::Default => quote! { jsg::rtti::Type::Void }, + syn::ReturnType::Type(_, ty) => { + let ty = elide_named_lifetimes(ty); + quote! { <#ty as jsg::TypeInfo>::describe(cx) } + } + } +} + +fn explicit_js_name(attr: &syn::Attribute) -> Option { + attr.meta + .require_list() + .ok() + .and_then(|list| extract_name_attribute(list.tokens.clone())) +} + +fn method_js_name(attr: &syn::Attribute, ident: &syn::Ident) -> String { + explicit_js_name(attr).unwrap_or_else(|| snake_to_camel(&ident.to_string())) +} + +fn constant_js_name(attr: &syn::Attribute, ident: &syn::Ident) -> String { + explicit_js_name(attr).unwrap_or_else(|| ident.to_string()) +} + // --------------------------------------------------------------------------- // Constructor helpers // --------------------------------------------------------------------------- @@ -809,7 +949,12 @@ fn generate_constructor_registration( length: #length, } }; - Ok(Some(MemberRegistration::new(rust_method_name, &member))) + let args = describe_args(method, Callable::Constructor); + Ok(Some(MemberRegistration::new( + rust_method_name, + member, + Some(quote! { jsg::rtti::Member::Constructor(vec![#(#args),*]) }), + ))) } // --------------------------------------------------------------------------- @@ -820,13 +965,15 @@ fn generate_constructor_registration( /// /// `is_readonly` only affects the lazy instance placement: other placements are read-only /// exactly when there is no setter, but a lazy instance property never has a setter and is -/// writable unless `readonly` is given, like C++ `JSG_LAZY_INSTANCE_PROPERTY`. +/// writable unless `readonly` is given, like C++ `JSG_LAZY_INSTANCE_PROPERTY`. `ty` is the +/// `jsg::rtti::Type` expression of the getter's result. fn emit_property_group( js_name: &str, kind: PropertyKind, getter: Option, setter: Option, is_readonly: bool, + ty: &proc_macro2::TokenStream, ) -> Result { let Some(getter_name) = getter else { return Err(quote! { @@ -856,7 +1003,12 @@ fn emit_property_group( } }; if kind == PropertyKind::Inspect { - return Ok(MemberRegistration::new(&getter_name, &prototype_member)); + // Inspect properties are symbol-keyed and have no TypeScript declaration. + return Ok(MemberRegistration::new( + &getter_name, + prototype_member, + None, + )); } let instance_getter_cb = syn::Ident::new( @@ -864,6 +1016,7 @@ fn emit_property_group( getter_name.span(), ); let has_setter = setter.is_some(); + let accessor_readonly = !has_setter; let instance_setter = if let Some(setter_name) = setter { let setter_cb = syn::Ident::new( &format!("{setter_name}_instance_callback"), @@ -903,27 +1056,55 @@ fn emit_property_group( } }; Ok(MemberRegistration { - factory: quote! { - #[automatically_derived] - #[must_use] - fn #factory_name(placement: jsg::PropertyPlacement) -> impl jsg::MemberDefinition { - match placement { - jsg::PropertyPlacement::Prototype => #prototype_member, - jsg::PropertyPlacement::Instance => jsg::Member::InstanceProperty { - name: #js_name.to_owned(), - getter_callback: Self::#instance_getter_cb(), - setter_callback: #instance_setter, - }, - jsg::PropertyPlacement::LazyInstance => #lazy_instance_member, - } + params: quote! { placement: jsg::PropertyPlacement }, + member: quote! { + match placement { + jsg::PropertyPlacement::Prototype => #prototype_member, + jsg::PropertyPlacement::Instance => jsg::Member::InstanceProperty { + name: #js_name.to_owned(), + getter_callback: Self::#instance_getter_cb(), + setter_callback: #instance_setter, + }, + jsg::PropertyPlacement::LazyInstance => #lazy_instance_member, } }, + description: Some(property_description( + js_name, + ty, + accessor_readonly, + is_readonly, + )), automatic: quote! { Self::#factory_name(#placement) }, source_name: getter_name, factory_name, }) } +/// Returns the RTTI description of an ordinary property, given its factory's `placement`. +/// Accessor placements are read-only when there is no setter, and the lazy instance placement +/// when `readonly` is given. +fn property_description( + js_name: &str, + ty: &proc_macro2::TokenStream, + accessor_readonly: bool, + lazy_readonly: bool, +) -> proc_macro2::TokenStream { + quote! { + jsg::rtti::Member::Property { + name: #js_name, + ty: #ty, + readonly: match placement { + jsg::PropertyPlacement::Prototype | jsg::PropertyPlacement::Instance => { + #accessor_readonly + } + jsg::PropertyPlacement::LazyInstance => #lazy_readonly, + }, + prototype: matches!(placement, jsg::PropertyPlacement::Prototype), + lazy: matches!(placement, jsg::PropertyPlacement::LazyInstance), + } + } +} + /// Parses the argument list of `#[jsg_property([placement,] [name = "..."] [, readonly])]`. /// /// `placement` is `prototype`, `instance` or `lazy_instance`; when omitted it defaults to @@ -1006,6 +1187,8 @@ pub fn parse_jsg_property_args( struct PropMethod { rust_name: syn::Ident, + /// The `jsg::rtti::Type` expression of the method's result. + result: proc_macro2::TokenStream, is_setter: bool, is_readonly: bool, } @@ -1072,6 +1255,7 @@ fn scan_property_annotations( let js_name = js_name_opt.unwrap_or_else(|| derive_js_name(&rust_name_str)); prop_groups_find_or_insert(&mut groups, (js_name, kind)).push(PropMethod { rust_name: rust_method_name, + result: describe_result(method), is_setter, is_readonly, }); @@ -1094,6 +1278,7 @@ fn scan_property_annotations( prop_groups_find_or_insert(&mut groups, (js_name, PropertyKind::Inspect)).push( PropMethod { rust_name: rust_method_name, + result: describe_result(method), is_setter, is_readonly: false, }, @@ -1151,6 +1336,8 @@ fn validate_and_emit_property( let mut getter: Option = None; let mut setter: Option = None; + // Without a getter, `emit_property_group` reports an error and the type is unused. + let mut ty = quote! { jsg::rtti::Type::Unknown }; for m in methods { if m.is_setter { if setter.replace(m.rust_name).is_some() { @@ -1162,9 +1349,11 @@ fn validate_and_emit_property( return Err( quote! { compile_error!(concat!("duplicate getter for property \"", #js_name, "\"")); }, ); + } else { + ty = m.result; } } - emit_property_group(js_name, kind, getter, setter, has_readonly_getter) + emit_property_group(js_name, kind, getter, setter, has_readonly_getter, &ty) } /// Scans an impl block for `#[jsg_property]` and `#[jsg_inspect_property]` annotations diff --git a/src/rust/jsg-macros/utils.rs b/src/rust/jsg-macros/utils.rs index 99966bc38e6..b0c0bbd217f 100644 --- a/src/rust/jsg-macros/utils.rs +++ b/src/rust/jsg-macros/utils.rs @@ -44,6 +44,10 @@ pub fn is_attr(attr: &syn::Attribute, name: &str) -> bool { /// When set, `#[jsg_resource]` on a struct suppresses the auto-generated `Traced` /// impl, letting the user write their own. pub fn has_custom_trace_flag(attr: &TokenStream) -> bool { + has_flag(attr, "custom_trace") +} + +pub fn has_flag(attr: &TokenStream, name: &str) -> bool { use syn::Meta; use syn::punctuated::Punctuated; @@ -56,7 +60,7 @@ pub fn has_custom_trace_flag(attr: &TokenStream) -> bool { parsed .iter() - .any(|meta| matches!(meta, Meta::Path(path) if path.is_ident("custom_trace"))) + .any(|meta| matches!(meta, Meta::Path(path) if path.is_ident(name))) } /// Emits a `compile_error!` token stream anchored to `tokens` with message `msg`. diff --git a/src/rust/jsg-test/tests/jsg_struct.rs b/src/rust/jsg-test/tests/jsg_struct.rs index 9908ddebfce..32a7cd2cea7 100644 --- a/src/rust/jsg-test/tests/jsg_struct.rs +++ b/src/rust/jsg-test/tests/jsg_struct.rs @@ -48,6 +48,12 @@ impl<'s> ToJS<'s> for Unconvertible { } } +impl jsg::TypeInfo for Unconvertible { + fn describe(_cx: &mut jsg::rtti::Context) -> jsg::rtti::Type { + jsg::rtti::Type::Unknown + } +} + impl<'s> jsg::FromJS<'s> for Unconvertible { type ResultType = Self; diff --git a/src/rust/jsg-test/tests/resource_template.rs b/src/rust/jsg-test/tests/resource_template.rs index e70f8db54f6..d7fa53a31b2 100644 --- a/src/rust/jsg-test/tests/resource_template.rs +++ b/src/rust/jsg-test/tests/resource_template.rs @@ -23,6 +23,12 @@ type OptionalNumber = Option; struct OptionalValue; +impl jsg::TypeInfo for OptionalValue { + fn describe(_cx: &mut jsg::rtti::Context) -> jsg::rtti::Type { + jsg::rtti::Type::Unknown + } +} + impl<'s> jsg::FromJS<'s> for OptionalValue { type ResultType = Self; diff --git a/src/rust/jsg-test/tests/to_js_errors.rs b/src/rust/jsg-test/tests/to_js_errors.rs index 0291a1552e6..98c6259f9a1 100644 --- a/src/rust/jsg-test/tests/to_js_errors.rs +++ b/src/rust/jsg-test/tests/to_js_errors.rs @@ -27,6 +27,13 @@ impl<'s> ToJS<'s> for CallsWhenConverted { } } +/// Converts to whatever the callback returns. +impl jsg::TypeInfo for CallsWhenConverted { + fn describe(_cx: &mut jsg::rtti::Context) -> jsg::rtti::Type { + jsg::rtti::Type::Unknown + } +} + #[jsg_resource] struct Converter; diff --git a/src/rust/jsg/BUILD.bazel b/src/rust/jsg/BUILD.bazel index bfe2ab71bee..880f76ae7e9 100644 --- a/src/rust/jsg/BUILD.bazel +++ b/src/rust/jsg/BUILD.bazel @@ -46,6 +46,7 @@ wd_rust_crate( visibility = ["//visibility:public"], deps = [ "//src/workerd/io:compatibility-date_capnp_rust", + "//src/workerd/jsg:rtti_capnp_rust", "@crates_vendor//:capnp", ], ) diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index a193d63dab9..634afb2cfdb 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -2,6 +2,46 @@ Rust bindings for the JSG (JavaScript Glue) layer, enabling Rust code to integrate with workerd's JavaScript runtime. +## RTTI and TypeScript declarations + +`TypeInfo::describe()` describes a binding's JavaScript representation without +constructing a resource or acquiring a V8 lock. `#[jsg_struct]`, `#[jsg_resource]`, +and `#[jsg_oneof]` generate implementations that compose the descriptions of +their fields, signatures, and variants. Rust resolves aliases and imported types +through trait dispatch. Custom conversion types implement `TypeInfo` explicitly. + +- One `jsg::rtti::Context` collects all modules in an export, interning shared and + recursive definitions before visiting their members. +- Rust's fully qualified type identity is separate from the declaration name, + which honors `name = "..."`. Explicit names must be unique among emitted Rust + and C++ declarations; inactive compatibility variants may reuse a name. +- `Option` describes `T | undefined` and `Nullable` describes `T | null`, + so a `Nullable` parameter is required, as at runtime. `NullOrUndefined`, + `Option>` and `Lenient` describe an optional `T | null | undefined`. +- `Dict` describes `Record`, as C++ describes `jsg::Dict`. +- Vectors and slices describe JavaScript arrays, numbers included; a typed array + is described by its `v8` handle type, such as `v8::Local`. +- Integers of up to 32 bits describe `number`; `i64` and `u64` describe + `number | bigint`, as C++ RTTI describes `int64_t`. +- `v8::Promise` describes `Promise`; as in C++, a promise parameter also + accepts a value that is not a promise. +- Fallible returns describe their success type. Injected `this` and `Lock` + parameters and inspect-only properties are omitted. +- A nested type is described with its whole structure, as in C++, so it is + declared as a class even when nesting is the only way to reach it. +- Parameter names use camelCase, omit unused-parameter underscores, and escape + TypeScript reserved words. Bindings with colliding normalized parameter names + are rejected. Members are described in registration order, as in C++. + +Both C++ and Rust emit `src/workerd/jsg/rtti.capnp` for the `types/` generator. +Resources are described with the members they register under the context's +compatibility flags. + +Definitions are emitted into ambient TypeScript only when reachable from a +TypeScript root. Use `#[jsg_resource(ts_root)]` or `#[jsg_struct(ts_root)]` on a +struct to mark such a root, analogous to C++ `JSG_TS_ROOT`. Internal module +registration alone does not make a type an ambient declaration. + ## Core Types ### `Lock` @@ -754,7 +794,8 @@ It does not reproduce worker-specific initialization such as logging, inspectors `#[jsg_resource(custom_members)]` generates member factories while leaving `Resource::members(registry, flags)` to the resource author. Use ordinary Rust conditionals to select -members and choose property placement when the template is built. See [custom member registration](../jsg-macros/README.md#custom-member-registration) +members and choose property placement when the template is built. RTTI export runs the same function +with the export's flags, so TypeScript declarations follow the same choices. See [custom member registration](../jsg-macros/README.md#custom-member-registration) for the factory naming conventions and a complete example. ### Key types and files diff --git a/src/rust/jsg/feature_flags.rs b/src/rust/jsg/feature_flags.rs index 78da5426251..09bc50ca4b7 100644 --- a/src/rust/jsg/feature_flags.rs +++ b/src/rust/jsg/feature_flags.rs @@ -38,6 +38,20 @@ impl FeatureFlags { Self { message } } + /// Creates flags from a message built in Rust, such as for an RTTI export. + pub(crate) fn from_message( + message: &capnp::message::Builder, + ) -> Self { + let segments = message + .get_segments_for_output() + .iter() + .map(|segment| segment.to_vec()) + .collect(); + Self { + message: capnp::message::Reader::new(segments, ReaderOptions::new()), + } + } + /// Returns the `CompatibilityFlags` reader. /// /// The reader has a getter for each flag defined in `compatibility-date.capnp` diff --git a/src/rust/jsg/lib.rs b/src/rust/jsg/lib.rs index ea42131ea0e..222da672b69 100644 --- a/src/rust/jsg/lib.rs +++ b/src/rust/jsg/lib.rs @@ -20,6 +20,7 @@ pub mod null_or_undefined; pub mod nullable; mod promise; pub mod resource; +pub mod rtti; mod scope; pub mod ser; pub mod v8; @@ -33,12 +34,15 @@ use isolate_state::IsolateState; pub use lenient::Lenient; pub use null_or_undefined::NullOrUndefined; pub use nullable::Nullable; +pub use resource::DescribedMember; pub use resource::MemberDefinition; pub use resource::MemberRegistry; pub use resource::NestedType; pub use resource::Rc; pub use resource::Resource; pub use resource::Weak; +pub use resource::described_member; +pub use rtti::TypeInfo; pub use scope::CallbackScope; pub use scope::CallbackScopeParam; pub use scope::Intercepted; diff --git a/src/rust/jsg/resource.rs b/src/rust/jsg/resource.rs index 3836531356f..220e3796a12 100644 --- a/src/rust/jsg/resource.rs +++ b/src/rust/jsg/resource.rs @@ -28,6 +28,7 @@ use crate::Traced; use crate::Type; use crate::feature_flags::compatibility_flags; use crate::isolate_state::IsolateState; +use crate::rtti; use crate::v8; use crate::v8::ffi::Wrappable; @@ -642,14 +643,16 @@ pub struct NestedType { compatibility_flags::Reader<'f>, &mut Nesting, ) -> &'a v8::Global, + describe: fn(&mut rtti::Context) -> &'static str, } impl NestedType { /// The nested type `R`. #[must_use] - pub fn of() -> Self { + pub fn of() -> Self { Self { template: Resources::get_nested_constructor::, + describe: rtti::Context::describe_structure::, } } } @@ -751,23 +754,72 @@ impl Resources { } } -/// Receives the members that [`Resource::members`] registers, like the `Registry` parameter of -/// C++ `registerMembers`. +/// Receives the members that [`Resource::members`] registers. +/// +/// Template creation collects each member's callbacks, while RTTI export collects its +/// JavaScript type description, so both follow the same registration code, like the +/// `Registry` parameter of C++ `registerMembers`. pub trait MemberRegistry { fn add(&mut self, member: impl MemberDefinition); } /// A member that [`Resource::members`] can register: either a [`Member`], or the result of a -/// generated member factory. +/// generated member factory, which can also describe the member's JavaScript type. pub trait MemberDefinition { /// Builds the member for template creation. fn into_member(self) -> Member; + + /// Describes the member for RTTI, or returns `None` if the member has no TypeScript + /// declaration. + fn describe(self, cx: &mut rtti::Context) -> Option; } +/// A member built by hand. Only a nested type, which describes itself, has a description; +/// RTTI omits other hand-built members. impl MemberDefinition for Member { fn into_member(self) -> Member { self } + + fn describe(self, cx: &mut rtti::Context) -> Option { + match self { + Self::NestedType { name, nested_type } => Some(rtti::Member::Nested { + name, + identity: (nested_type.describe)(cx), + }), + _ => None, + } + } +} + +/// A member definition that builds the member and describes its type on demand. Template +/// creation never runs the description, and RTTI export never builds the member. +pub struct DescribedMember { + member: M, + describe: D, +} + +/// Returns a member definition built by `member` and described by `describe`. +pub fn described_member(member: M, describe: D) -> DescribedMember +where + M: FnOnce() -> Member, + D: FnOnce(&mut rtti::Context) -> rtti::Member, +{ + DescribedMember { member, describe } +} + +impl MemberDefinition for DescribedMember +where + M: FnOnce() -> Member, + D: FnOnce(&mut rtti::Context) -> rtti::Member, +{ + fn into_member(self) -> Member { + (self.member)() + } + + fn describe(self, cx: &mut rtti::Context) -> Option { + Some((self.describe)(cx)) + } } /// Rust types exposed to JavaScript as resource types. @@ -778,11 +830,11 @@ impl MemberDefinition for Member { pub trait Resource: Type + GarbageCollected + Sized + 'static { /// Registers the methods, properties, and constructors exposed to JavaScript. /// - /// `flags` are the compatibility flags of the isolate the template is built for, so the - /// members can vary with them, as with the `CompatibilityFlags::Reader flags` parameter of - /// C++ `JSG_RESOURCE_TYPE`. Templates are cached per resource type in the realm. Use - /// `#[jsg_resource(custom_members)]` to generate member factories while supplying this - /// implementation yourself. + /// `flags` are the compatibility flags of the isolate the template is built for, or of the + /// RTTI export, so the members can vary with them, as with the + /// `CompatibilityFlags::Reader flags` parameter of C++ `JSG_RESOURCE_TYPE`. Templates are + /// cached per resource type in the realm. Use `#[jsg_resource(custom_members)]` to generate + /// member factories while supplying this implementation yourself. fn members(registry: &mut impl MemberRegistry, flags: compatibility_flags::Reader<'_>); } diff --git a/src/rust/jsg/rtti-test.rs b/src/rust/jsg/rtti-test.rs new file mode 100644 index 00000000000..05c0a13ef04 --- /dev/null +++ b/src/rust/jsg/rtti-test.rs @@ -0,0 +1,353 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +use super::*; + +struct Node; + +impl TypeInfo for Node { + fn describe(cx: &mut Context) -> Type { + cx.structure::("Node", true, |cx| { + vec![Member::Property { + name: "next", + ty: Type::Maybe { + value: Box::new(Self::describe(cx)), + allows_null: false, + allows_undefined: true, + }, + readonly: true, + prototype: true, + lazy: false, + }] + }) + } +} + +struct FirstModule; +struct SecondModule; + +fn describe_module(cx: &mut Context, name: &'static str) -> Type { + cx.structure::(name, false, |cx| { + vec![Member::Property { + name: "node", + ty: Node::describe(cx), + readonly: true, + prototype: false, + lazy: false, + }] + }) +} + +impl TypeInfo for FirstModule { + fn describe(cx: &mut Context) -> Type { + describe_module::(cx, "FirstModule") + } +} + +impl TypeInfo for SecondModule { + fn describe(cx: &mut Context) -> Type { + describe_module::(cx, "SecondModule") + } +} + +fn read_context(cx: &Context) -> capnp::message::Reader { + let bytes = cx.encode("test"); + capnp::serialize::read_message(&mut bytes.as_slice(), capnp::message::ReaderOptions::new()) + .unwrap() +} + +fn property(member: rtti_capnp::member::Reader<'_>) -> rtti_capnp::property::Reader<'_> { + match member.which().unwrap() { + rtti_capnp::member::Property(value) => value.unwrap(), + _ => panic!("expected a property"), + } +} + +fn structure_reference( + ty: rtti_capnp::type_::Reader<'_>, +) -> rtti_capnp::structure_type::Reader<'_> { + match ty.which().unwrap() { + rtti_capnp::type_::Structure(value) => value.unwrap(), + _ => panic!("expected a structure reference"), + } +} + +fn module_identity(module: rtti_capnp::module::Reader<'_>) -> capnp::text::Reader<'_> { + match module.which().unwrap() { + rtti_capnp::module::StructureName(value) => value.unwrap(), + rtti_capnp::module::TsDeclarations(_) => panic!("expected a module structure name"), + } +} + +#[test] +fn recursive_types_are_interned_before_visiting_members() { + let mut cx = Context::default(); + Node::describe(&mut cx); + Node::describe(&mut cx); + let message = read_context(&cx); + let root = message + .get_root::() + .unwrap(); + let structures = root.get_groups().unwrap().get(0).get_structures().unwrap(); + assert_eq!(structures.len(), 1); + let node = structures.get(0); + assert!(node.get_ts_root()); + assert_eq!(node.get_ts_name().unwrap(), "Node"); + let next = property(node.get_members().unwrap().get(0)); + assert_eq!(next.get_name().unwrap(), "next"); + assert!(next.get_readonly()); + assert!(next.get_prototype()); + let rtti_capnp::type_::Maybe(maybe) = next.get_type().unwrap().which().unwrap() else { + panic!("expected an optional type"); + }; + let maybe = maybe.unwrap(); + assert!(!maybe.get_allows_null()); + assert!(maybe.get_allows_undefined()); + let reference = structure_reference(maybe.get_value().unwrap()); + assert_eq!( + reference.get_fully_qualified_name().unwrap(), + type_name::() + ); +} + +#[test] +fn module_roots_share_the_encoded_type_graph() { + let mut cx = Context::default(); + cx.add_module::("test:first"); + cx.add_module::("test:second"); + let message = read_context(&cx); + let root = message + .get_root::() + .unwrap(); + let structures = root.get_groups().unwrap().get(0).get_structures().unwrap(); + assert_eq!(structures.len(), 3); + assert_eq!( + structures + .iter() + .filter(|s| s.get_ts_name().unwrap() == "Node") + .count(), + 1 + ); + for module in [structures.get(0), structures.get(2)] { + assert!(!module.get_ts_root()); + let property = property(module.get_members().unwrap().get(0)); + let reference = structure_reference(property.get_type().unwrap()); + assert_eq!( + reference.get_fully_qualified_name().unwrap(), + type_name::() + ); + } + let modules = root.get_modules().unwrap(); + assert_eq!(modules.len(), 2); + assert_eq!(modules.get(0).get_specifier().unwrap(), "test:first"); + assert_eq!(module_identity(modules.get(0)), type_name::()); + assert_eq!(modules.get(1).get_specifier().unwrap(), "test:second"); + assert_eq!(module_identity(modules.get(1)), type_name::()); +} + +struct Flagged; + +impl crate::Type for Flagged { + fn class_name() -> &'static str { + "Flagged" + } + + fn is_exact(_value: v8::Local<'_, v8::Value>) -> bool { + false + } +} + +impl crate::Traced for Flagged {} + +impl crate::GarbageCollected for Flagged { + fn memory_name(&self) -> &'static std::ffi::CStr { + c"Flagged" + } +} + +impl Resource for Flagged { + fn members(registry: &mut impl MemberRegistry, flags: compatibility_flags::Reader<'_>) { + let prototype = flags.get_jsg_property_on_prototype_template(); + registry.add(crate::described_member( + || panic!("RTTI export built a member"), + |_| Member::Method { + name: "always", + args: Vec::new(), + result: Type::Void, + is_static: false, + }, + )); + // A hand-built member has no description. + registry.add(crate::Member::StaticConstant { + name: "HIDDEN".to_owned(), + value: crate::ConstantValue::Number(1.0), + }); + if flags.get_cache_option_enabled() { + registry.add(crate::described_member( + || panic!("RTTI export built a member"), + move |_| Member::Property { + name: "cache", + ty: Type::String, + readonly: true, + prototype, + lazy: false, + }, + )); + } + } +} + +impl TypeInfo for Flagged { + fn describe(cx: &mut Context) -> Type { + cx.structure::("Flagged", true, resource_members::) + } +} + +/// Describes `Flagged` and returns its members as `(name, prototype)`, where `prototype` is +/// `None` for anything but a property. +fn flagged_members(mut cx: Context) -> Vec<(String, Option)> { + Flagged::describe(&mut cx); + let message = read_context(&cx); + let root = message + .get_root::() + .unwrap(); + let structures = root.get_groups().unwrap().get(0).get_structures().unwrap(); + structures + .get(0) + .get_members() + .unwrap() + .iter() + .map(|member| match member.which().unwrap() { + rtti_capnp::member::Method(method) => ( + method.unwrap().get_name().unwrap().to_string().unwrap(), + None, + ), + rtti_capnp::member::Property(property) => { + let property = property.unwrap(); + ( + property.get_name().unwrap().to_string().unwrap(), + Some(property.get_prototype()), + ) + } + _ => panic!("unexpected member"), + }) + .collect() +} + +#[test] +fn resource_members_follow_registration_under_the_context_flags() { + assert_eq!( + flagged_members(Context::default()), + [("always".to_owned(), None)] + ); + assert_eq!( + flagged_members(Context::with_flags(|mut flags| { + flags.set_cache_option_enabled(true); + })), + [ + ("always".to_owned(), None), + ("cache".to_owned(), Some(false)) + ] + ); + assert_eq!( + flagged_members(Context::with_flags(|mut flags| { + flags.set_cache_option_enabled(true); + flags.set_jsg_property_on_prototype_template(true); + })), + [ + ("always".to_owned(), None), + ("cache".to_owned(), Some(true)) + ] + ); +} + +struct Outer; + +impl crate::Type for Outer { + fn class_name() -> &'static str { + "Outer" + } + + fn is_exact(_value: v8::Local<'_, v8::Value>) -> bool { + false + } +} + +impl crate::Traced for Outer {} + +impl crate::GarbageCollected for Outer { + fn memory_name(&self) -> &'static std::ffi::CStr { + c"Outer" + } +} + +impl Resource for Outer { + fn members(registry: &mut impl MemberRegistry, _flags: compatibility_flags::Reader<'_>) { + // A hand-built nested type is described through the nested type. + registry.add(crate::Member::NestedType { + name: "Renamed".to_owned(), + nested_type: crate::NestedType::of::(), + }); + } +} + +impl TypeInfo for Outer { + fn describe(cx: &mut Context) -> Type { + cx.structure::("Outer", true, resource_members::) + } +} + +#[test] +fn nested_types_contain_their_whole_structure() { + let mut cx = Context::default(); + Outer::describe(&mut cx); + let message = read_context(&cx); + let root = message + .get_root::() + .unwrap(); + let structures = root.get_groups().unwrap().get(0).get_structures().unwrap(); + assert_eq!(structures.len(), 2); + assert_eq!(structures.get(1).get_ts_name().unwrap(), "Flagged"); + let rtti_capnp::member::Nested(nested) = structures + .get(0) + .get_members() + .unwrap() + .get(0) + .which() + .unwrap() + else { + panic!("expected a nested type"); + }; + assert_eq!(nested.get_name().unwrap(), "Renamed"); + let structure = nested.get_structure().unwrap(); + assert_eq!( + structure.get_fully_qualified_name().unwrap(), + type_name::() + ); + let members = structure.get_members().unwrap(); + assert_eq!(members.len(), 1); + let rtti_capnp::member::Method(method) = members.get(0).which().unwrap() else { + panic!("expected a method"); + }; + assert_eq!(method.unwrap().get_name().unwrap(), "always"); +} + +fn describe_named_after(cx: &mut Context, _value: &T) { + cx.structure::("Closure", false, |_| Vec::new()); +} + +#[test] +#[should_panic(expected = "are both named")] +fn types_with_one_type_name_are_rejected() { + // Two closures in one function have the same `type_name`. + let (first, second) = (|| {}, || {}); + assert_eq!(type_name_of(&first), type_name_of(&second)); + let mut cx = Context::default(); + describe_named_after(&mut cx, &first); + describe_named_after(&mut cx, &second); +} + +fn type_name_of(_value: &T) -> &'static str { + type_name::() +} diff --git a/src/rust/jsg/rtti.rs b/src/rust/jsg/rtti.rs new file mode 100644 index 00000000000..86ff1698f7c --- /dev/null +++ b/src/rust/jsg/rtti.rs @@ -0,0 +1,567 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +//! JavaScript type descriptions shared with the C++ JSG RTTI exporter. +//! +//! Descriptions contain no V8 handles and can be built without an isolate. Named +//! structures are interned before their members are visited, allowing recursive +//! resource graphs. Rust type identities and TypeScript declaration names are +//! separate: aliases resolve through trait dispatch rather than source parsing. + +use std::any::TypeId; +use std::any::type_name; +use std::collections::HashMap; +use std::rc::Rc; + +pub use rtti_capnp::builtin_type::Type as Builtin; + +use crate::FeatureFlags; +use crate::MemberDefinition; +use crate::MemberRegistry; +use crate::Resource; +use crate::feature_flags::compatibility_flags; +use crate::v8; + +/// The JavaScript representation of a Rust binding type. +pub trait TypeInfo { + fn describe(cx: &mut Context) -> Type; +} + +#[derive(Debug)] +pub enum Type { + Unknown, + Void, + Boolean, + Number, + /// Encoded as C++ `jsg::JsBigInt`, which TypeScript declares as `number | bigint`. + BigInt, + String, + Object, + Builtin(Builtin), + Array(Box), + /// A record with string keys, like C++ `jsg::Dict`. + Dict(Box), + Promise(Box), + Maybe { + value: Box, + allows_null: bool, + allows_undefined: bool, + }, + OneOf(Vec), + Structure { + identity: &'static str, + name: &'static str, + }, +} + +pub struct Parameter { + pub name: &'static str, + pub ty: Type, +} + +pub enum Member { + Constructor(Vec), + Method { + name: &'static str, + args: Vec, + result: Type, + is_static: bool, + }, + Property { + name: &'static str, + ty: Type, + readonly: bool, + prototype: bool, + /// Whether the property is an own data property of each instance, computed on first + /// access. + lazy: bool, + }, + /// A nested resource type, exposed as a constructor property named `name`. + Nested { + name: String, + identity: &'static str, + }, + Constant { + name: &'static str, + value: crate::ConstantValue, + }, +} + +struct Structure { + identity: &'static str, + name: &'static str, + members: Vec, + root: bool, +} + +/// A deterministic, cycle-safe collection of reachable binding definitions. +/// +/// Resource types are described with the members they register under the context's +/// compatibility flags, as the C++ RTTI builder runs `registerMembers` with its flags. +pub struct Context { + structures: Vec, + // Structures are interned by `TypeId`, since `type_name` is not guaranteed to be unique. + // `type_name` is the `fullyQualifiedName` that the generator identifies structures by, + // so it must be unique too. + indices: HashMap, + identities: HashMap<&'static str, usize>, + modules: Vec<(String, &'static str)>, + // Shared so that a resource's registration can read the flags while describing members + // into the context. + flags: Rc, +} + +/// A context whose compatibility flags are all false. +impl Default for Context { + fn default() -> Self { + Self::with_flags(|_| {}) + } +} + +impl Context { + /// Creates a context whose compatibility flags are set by `set_flags`. All flags start + /// false; no compatibility date or flag implications are applied. + pub fn with_flags(set_flags: impl FnOnce(compatibility_flags::Builder<'_>)) -> Self { + let mut message = capnp::message::Builder::new_default(); + set_flags(message.init_root::>()); + Self::new(FeatureFlags::from_message(&message)) + } + + fn new(flags: FeatureFlags) -> Self { + Self { + structures: Vec::new(), + indices: HashMap::new(), + identities: HashMap::new(), + modules: Vec::new(), + flags: Rc::new(flags), + } + } + + /// Registers a module in the same type graph as every other module in this export. + pub fn add_module(&mut self, specifier: &str) { + let identity = self.describe_structure::(); + self.modules.push((specifier.to_owned(), identity)); + } + + /// Describes `T`, which must be a named structure such as a resource type, and returns + /// its identity. + pub(crate) fn describe_structure(&mut self) -> &'static str { + let Type::Structure { identity, .. } = T::describe(self) else { + unreachable!("{} must describe a named structure", type_name::()); + }; + identity + } + + /// Describes the structure `T` the first time it is reached, and returns a reference to it. + /// + /// The structure is interned before `members` runs, so members can refer to it + /// recursively. + /// + /// # Panics + /// + /// Panics if another type has the same [`type_name`] as `T`. + pub fn structure( + &mut self, + name: &'static str, + root: bool, + members: impl FnOnce(&mut Self) -> Vec, + ) -> Type { + let identity = type_name::(); + if !self.indices.contains_key(&TypeId::of::()) { + let index = self.structures.len(); + assert!( + self.identities.insert(identity, index).is_none(), + "two Rust binding types are both named {identity}" + ); + self.indices.insert(TypeId::of::(), index); + self.structures.push(Structure { + identity, + name, + members: Vec::new(), + root, + }); + let members = members(self); + self.structures[index].members = members; + } + Type::Structure { identity, name } + } + + /// Serializes the collected definitions. A module export identifies its root + /// without making that root an ambient global declaration. + pub fn encode(&self, group: &str) -> Vec { + let mut message = capnp::message::Builder::new_default(); + let mut root = message.init_root::(); + let mut groups = root.reborrow().init_groups(1); + let mut group_builder = groups.reborrow().get(0); + group_builder.set_name(group); + let mut structures = group_builder.init_structures(length(self.structures.len())); + for i in 0..self.structures.len() { + self.write_structure(i, structures.reborrow().get(length(i)), &mut Vec::new()); + } + let mut modules = root.init_modules(length(self.modules.len())); + for (i, (specifier, identity)) in self.modules.iter().enumerate() { + let mut module = modules.reborrow().get(length(i)); + module.set_specifier(specifier.as_str()); + module.set_structure_name(*identity); + } + capnp::serialize::write_message_to_words(&message) + } + + /// Writes the structure at `index`, within the nested types at the indices in `nesting`. + /// + /// As in C++, a nested type's member contains the nested type's whole structure, through + /// which the generator reaches the types that the nested type refers to. + fn write_structure( + &self, + index: usize, + mut builder: rtti_capnp::structure::Builder<'_>, + nesting: &mut Vec, + ) { + let structure = &self.structures[index]; + builder.set_name(structure.name); + builder.set_ts_name(structure.name); + builder.set_fully_qualified_name(structure.identity); + // Template creation rejects a type that nests itself; its nested member only names it. + if nesting.contains(&index) { + return; + } + nesting.push(index); + builder.set_ts_root(structure.root); + let mut members = builder.init_members(length(structure.members.len())); + for (i, member) in structure.members.iter().enumerate() { + member.write(self, members.reborrow().get(length(i)), nesting); + } + nesting.pop(); + } +} + +/// The registry through which RTTI export collects the members of a resource type. +struct DescribedMembers<'a> { + cx: &'a mut Context, + members: Vec, +} + +impl MemberRegistry for DescribedMembers<'_> { + fn add(&mut self, member: impl MemberDefinition) { + if let Some(member) = member.describe(self.cx) { + self.members.push(member); + } + } +} + +/// Describes the members that `R` registers under the context's compatibility flags, in +/// registration order. Hand-built members other than nested types carry no description and +/// are omitted. +pub fn resource_members(cx: &mut Context) -> Vec { + let flags = Rc::clone(&cx.flags); + let mut registry = DescribedMembers { + cx, + members: Vec::new(), + }; + R::members(&mut registry, flags.reader()); + registry.members +} + +#[expect( + clippy::expect_used, + reason = "binding metadata cannot contain more than u32::MAX entries" +)] +fn length(value: usize) -> u32 { + u32::try_from(value).expect("RTTI list length exceeds Cap'n Proto limit") +} + +impl Type { + fn write(&self, mut builder: rtti_capnp::type_::Builder<'_>) { + match self { + Self::Unknown => builder.set_unknown(()), + Self::Void => builder.set_voidt(()), + Self::Boolean => builder.set_boolt(()), + Self::Number => builder.init_number().set_name("number"), + Self::BigInt => builder.init_number().set_name("jsg::JsBigInt"), + Self::String => builder.init_string().set_name("string"), + Self::Object => builder.set_object(()), + Self::Builtin(ty) => builder.init_builtin().set_type(*ty), + Self::Array(element) => element.write(builder.init_array().init_element()), + Self::Dict(value) => { + let mut dict = builder.init_dict(); + Self::String.write(dict.reborrow().init_key()); + value.write(dict.init_value()); + } + Self::Promise(value) => value.write(builder.init_promise().init_value()), + Self::Maybe { + value, + allows_null, + allows_undefined, + } => { + let mut maybe = builder.init_maybe(); + maybe.set_allows_null(*allows_null); + maybe.set_allows_undefined(*allows_undefined); + value.write(maybe.init_value()); + } + Self::OneOf(variants) => { + let mut list = builder.init_one_of().init_variants(length(variants.len())); + for (i, variant) in variants.iter().enumerate() { + variant.write(list.reborrow().get(length(i))); + } + } + Self::Structure { identity, name } => { + let mut structure = builder.init_structure(); + structure.set_name(*name); + structure.set_ts_name(*name); + structure.set_fully_qualified_name(*identity); + } + } + } +} + +fn write_args( + args: &[Parameter], + mut builder: capnp::struct_list::Builder<'_, rtti_capnp::type_::Owned>, +) { + for (i, arg) in args.iter().enumerate() { + arg.ty.write(builder.reborrow().get(length(i))); + } +} + +fn write_arg_names(args: &[Parameter], mut builder: capnp::text_list::Builder<'_>) { + for (i, arg) in args.iter().enumerate() { + builder.set(length(i), arg.name); + } +} + +impl Member { + fn write( + &self, + cx: &Context, + builder: rtti_capnp::member::Builder<'_>, + nesting: &mut Vec, + ) { + match self { + Self::Constructor(args) => { + let mut constructor = builder.init_constructor(); + write_args(args, constructor.reborrow().init_args(length(args.len()))); + write_arg_names(args, constructor.init_arg_names(length(args.len()))); + } + Self::Method { + name, + args, + result, + is_static, + } => { + let mut method = builder.init_method(); + method.set_name(*name); + method.set_static(*is_static); + write_args(args, method.reborrow().init_args(length(args.len()))); + write_arg_names(args, method.reborrow().init_arg_names(length(args.len()))); + result.write(method.init_return_type()); + } + Self::Property { + name, + ty, + readonly, + prototype, + lazy, + } => { + let mut property = builder.init_property(); + property.set_name(*name); + property.set_readonly(*readonly); + property.set_prototype(*prototype); + property.set_lazy(*lazy); + ty.write(property.init_type()); + } + Self::Nested { name, identity } => { + let mut nested = builder.init_nested(); + nested.set_name(name.as_str()); + cx.write_structure(cx.identities[identity], nested.init_structure(), nesting); + } + Self::Constant { name, value } => { + let mut constant = builder.init_constant(); + constant.set_name(*name); + match value { + crate::ConstantValue::Number(value) => constant.set_number_value(*value), + } + } + } + } +} + +macro_rules! primitive { + ($ty:ty, $description:expr) => { + impl TypeInfo for $ty { + fn describe(_: &mut Context) -> Type { + $description + } + } + }; +} + +primitive!((), Type::Void); +primitive!(bool, Type::Boolean); +primitive!(String, Type::String); +primitive!(str, Type::String); +primitive!(std::time::SystemTime, Type::Builtin(Builtin::KjDate)); + +impl TypeInfo for &T { + fn describe(cx: &mut Context) -> Type { + T::describe(cx) + } +} + +/// `T | undefined`. An `Option>` is a single `T | null | undefined`, so an +/// optional parameter of that type is declared optional. +impl TypeInfo for Option { + fn describe(cx: &mut Context) -> Type { + match T::describe(cx) { + Type::Maybe { + value, allows_null, .. + } => Type::Maybe { + value, + allows_null, + allows_undefined: true, + }, + value => Type::Maybe { + value: Box::new(value), + allows_null: false, + allows_undefined: true, + }, + } + } +} + +/// `T | null`. Conversion from JavaScript also reads `undefined` as null, as C++ +/// `kj::Maybe` does, which C++ RTTI also describes as `T | null`; a parameter of this type +/// is still required. +impl TypeInfo for crate::Nullable { + fn describe(cx: &mut Context) -> Type { + Type::Maybe { + value: Box::new(T::describe(cx)), + allows_null: true, + allows_undefined: false, + } + } +} + +/// `T | null | undefined`, like `Option>`. +impl TypeInfo for crate::Lenient { + fn describe(cx: &mut Context) -> Type { + Option::>::describe(cx) + } +} + +/// `T | null | undefined`, like `Option>`, so a parameter of this type is declared +/// optional. +impl TypeInfo for crate::NullOrUndefined { + fn describe(cx: &mut Context) -> Type { + Option::>::describe(cx) + } +} + +impl TypeInfo for crate::NonCoercible { + fn describe(cx: &mut Context) -> Type { + T::describe(cx) + } +} + +impl TypeInfo for Result { + fn describe(cx: &mut Context) -> Type { + T::describe(cx) + } +} + +impl TypeInfo for crate::Rc { + fn describe(cx: &mut Context) -> Type { + T::describe(cx) + } +} + +/// A JavaScript array, numbers included; typed arrays are their `v8` handle types. +impl TypeInfo for Vec { + fn describe(cx: &mut Context) -> Type { + Type::Array(Box::new(T::describe(cx))) + } +} + +impl TypeInfo for crate::Dict { + fn describe(cx: &mut Context) -> Type { + Type::Dict(Box::new(V::describe(cx))) + } +} + +impl TypeInfo for [T] { + fn describe(cx: &mut Context) -> Type { + Type::Array(Box::new(T::describe(cx))) + } +} + +// Integers of up to 32 bits convert to numbers, and `i64` and `u64` to BigInts, which accept +// numbers too, as C++ RTTI describes `int64_t` and `uint64_t`. +primitive!(u8, Type::Number); +primitive!(u16, Type::Number); +primitive!(u32, Type::Number); +primitive!(i8, Type::Number); +primitive!(i16, Type::Number); +primitive!(i32, Type::Number); +primitive!(f32, Type::Number); +primitive!(f64, Type::Number); +primitive!(i64, Type::BigInt); +primitive!(u64, Type::BigInt); + +primitive!(v8::Uint8Array, Type::Builtin(Builtin::V8Uint8Array)); +primitive!(v8::Uint16Array, Type::Builtin(Builtin::V8Uint16Array)); +primitive!(v8::Uint32Array, Type::Builtin(Builtin::V8Uint32Array)); +primitive!(v8::Int8Array, Type::Builtin(Builtin::V8Int8Array)); +primitive!(v8::Int16Array, Type::Builtin(Builtin::V8Int16Array)); +primitive!(v8::Int32Array, Type::Builtin(Builtin::V8Int32Array)); +primitive!(v8::Float32Array, Type::Builtin(Builtin::V8Float32Array)); +primitive!(v8::Float64Array, Type::Builtin(Builtin::V8Float64Array)); +primitive!(v8::BigInt64Array, Type::Builtin(Builtin::V8BigInt64Array)); +primitive!(v8::BigUint64Array, Type::Builtin(Builtin::V8BigUint64Array)); + +primitive!(v8::Value, Type::Unknown); +primitive!(v8::Object, Type::Object); +primitive!(v8::String, Type::String); +primitive!(v8::Boolean, Type::Boolean); +primitive!(v8::Number, Type::Number); +primitive!(v8::Integer, Type::Number); +primitive!(v8::Int32, Type::Number); +primitive!(v8::Uint32, Type::Number); +primitive!(v8::Symbol, Type::Builtin(Builtin::V8Symbol)); +primitive!(v8::BigInt, Type::BigInt); +primitive!( + v8::Name, + Type::OneOf(vec![Type::String, Type::Builtin(Builtin::V8Symbol)]) +); +primitive!(v8::TypedArray, Type::Builtin(Builtin::V8TypedArray)); +primitive!(crate::Error, Type::Builtin(Builtin::JsgError)); +primitive!(v8::Array, Type::Array(Box::new(Type::Unknown))); +primitive!(v8::Function, Type::Builtin(Builtin::V8Function)); +// As in C++, an untyped promise resolves to an unknown value. +primitive!(v8::Promise, Type::Promise(Box::new(Type::Unknown))); +primitive!(v8::ArrayBuffer, Type::Builtin(Builtin::V8ArrayBuffer)); +// As C++ describes `jsg::JsDate`. +primitive!(v8::Date, Type::Builtin(Builtin::KjDate)); +primitive!( + v8::ArrayBufferView, + Type::Builtin(Builtin::V8ArrayBufferView) +); +primitive!( + v8::Uint8ClampedArray, + Type::Builtin(Builtin::V8Uint8ClampedArray) +); + +impl TypeInfo for v8::Local<'_, T> { + fn describe(cx: &mut Context) -> Type { + T::describe(cx) + } +} + +impl TypeInfo for v8::Global { + fn describe(cx: &mut Context) -> Type { + T::describe(cx) + } +} + +#[cfg(test)] +#[path = "rtti-test.rs"] +mod tests; diff --git a/src/workerd/jsg/README.md b/src/workerd/jsg/README.md index 22d8444f082..30c0b2d4b0e 100644 --- a/src/workerd/jsg/README.md +++ b/src/workerd/jsg/README.md @@ -604,6 +604,12 @@ Resolve `Source`: `STATIC_IMPORT`, `DYNAMIC_IMPORT`, `REQUIRE`, `INTERNAL`. ## RTTI Type Mapping +Rust JSG emits the same `rtti.capnp` schema through trait-based descriptions; +see [Rust RTTI](../../rust/jsg/README.md#rtti-and-typescript-declarations). +`MaybeType.allowsNull` and `MaybeType.allowsUndefined` describe which alternatives +the wrapped type accepts. When both are false, the generator uses the native name +to decode descriptions from legacy producers. + | C++ Type | RTTI Kind | | ------------------------- | ----------- | | `void` | `voidt` | diff --git a/tools/cross/format.json b/tools/cross/format.json index 80767337922..e213594325e 100644 --- a/tools/cross/format.json +++ b/tools/cross/format.json @@ -38,5 +38,10 @@ "directory": "src", "globs": ["*.rs"], "formatter": "rustfmt" + }, + { + "directory": "types", + "globs": ["*.rs"], + "formatter": "rustfmt" } ] diff --git a/types/AGENTS.md b/types/AGENTS.md index cb42d94f211..781f9498fb9 100644 --- a/types/AGENTS.md +++ b/types/AGENTS.md @@ -2,7 +2,7 @@ ## OVERVIEW -Generates `@cloudflare/workers-types` `.d.ts` files from C++ RTTI (via `jsg/rtti.capnp`) + hand-written `defines/*.d.ts`. A workerd-hosted Worker extracts RTTI at runtime per compat-date; TypeScript transforms post-process into ambient and importable outputs. CI validates `generated-snapshot/` matches. +Generates `@cloudflare/workers-types` `.d.ts` files from C++ and Rust JSG RTTI (via `jsg/rtti.capnp`) + hand-written `defines/*.d.ts`. Rust descriptions come from `TypeInfo` in `src/rust/jsg/`; internal module registration does not make a type an ambient root. A workerd-hosted Worker extracts RTTI at runtime per compat-date; TypeScript transforms post-process into ambient and importable outputs. CI validates `generated-snapshot/` matches. ## PIPELINE diff --git a/types/BUILD.bazel b/types/BUILD.bazel index 14c92c27191..997022a1350 100644 --- a/types/BUILD.bazel +++ b/types/BUILD.bazel @@ -90,9 +90,22 @@ js_run_binary( tsconfig_json = "//types:tsconfig.json", deps = [":types_lib"], ) - for src in glob(["test/**/*.spec.ts"]) + for src in glob( + ["test/**/*.spec.ts"], + exclude = ["test/generator/rust.spec.ts"], + ) ] +wd_ts_test( + src = "test/generator/rust.spec.ts", + composite = True, + data = ["//types/rtti-fixture"], + env = {"RUST_RTTI_FIXTURE_BINARY": "$(rootpath //types/rtti-fixture:rtti-fixture)"}, + node_options = ["--enable-source-maps"], + tsconfig_json = "//types:tsconfig.json", + deps = [":types_lib"], +) + [ wd_ts_type_test(src = src) for src in glob(["test/types/**/*.ts"]) diff --git a/types/README.md b/types/README.md index fce77bb3829..bd7c1f31ea6 100644 --- a/types/README.md +++ b/types/README.md @@ -1,7 +1,10 @@ # Workers Types Generator This directory contains scripts for automatically generating TypeScript types -from [JSG RTTI](../src/workerd/jsg/rtti.h). +from the shared [JSG RTTI schema](../src/workerd/jsg/rtti.capnp). C++ JSG and +[Rust JSG](../src/rust/jsg/README.md#rtti-and-typescript-declarations) both produce +these descriptions. Explicit declaration names, parameter names, and nullability +let the generator consume Rust bindings without interpreting Rust type spellings. ## Generating Types diff --git a/types/rtti-fixture/BUILD.bazel b/types/rtti-fixture/BUILD.bazel new file mode 100644 index 00000000000..3734e16678e --- /dev/null +++ b/types/rtti-fixture/BUILD.bazel @@ -0,0 +1,10 @@ +load("//:build/wd_rust_binary.bzl", "wd_rust_binary") + +wd_rust_binary( + name = "rtti-fixture", + link_deps = ["//src/workerd/jsg"], + proc_macro_deps = ["//src/rust/jsg-macros"], + test = False, + visibility = ["//types:__pkg__"], + deps = ["//src/rust/jsg"], +) diff --git a/types/rtti-fixture/main.rs b/types/rtti-fixture/main.rs new file mode 100644 index 00000000000..49a1aa92051 --- /dev/null +++ b/types/rtti-fixture/main.rs @@ -0,0 +1,339 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +use std::cell::Cell; +use std::io::Write; + +use jsg::ToJsTypedArray; +use jsg::TypeInfo; +use jsg_macros::jsg_constructor; +use jsg_macros::jsg_inspect_property; +use jsg_macros::jsg_method; +use jsg_macros::jsg_oneof; +use jsg_macros::jsg_property; +use jsg_macros::jsg_resource; +use jsg_macros::jsg_static_constant; +use jsg_macros::jsg_struct; + +type Label = String; +type OptionalLabel = Option