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 e64727d49ea..ab4a0facbfc 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, 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. `modules::add_builtin()` takes a capture-free `for<'s> Fn(&mut Lock<'s>) -> jsg::Result>` callback; its inferred return type supplies both runtime wrapping and RTTI. RTTI export does not invoke callbacks. `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/api/lib-test.rs b/src/rust/api/lib-test.rs index e2a799e1f09..1984ed284fd 100644 --- a/src/rust/api/lib-test.rs +++ b/src/rust/api/lib-test.rs @@ -2,6 +2,7 @@ // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 +use jsg::ToJS; use jsg_test::Harness; use super::*; diff --git a/src/rust/api/lib.rs b/src/rust/api/lib.rs index eece9171cad..4ac78e99c0e 100644 --- a/src/rust/api/lib.rs +++ b/src/rust/api/lib.rs @@ -4,8 +4,6 @@ use std::pin::Pin; -use jsg::ToJS; - use crate::dns::DnsUtil; use crate::url::UrlUtil; @@ -30,13 +28,13 @@ pub fn register_nodejs_modules(mut registry: Pin<&mut ffi::ModuleRegistry>) { jsg::modules::add_builtin( registry.as_mut(), "node-internal:dns", - |lock| DnsUtil::new().to_js(lock), + |_lock| Ok(DnsUtil::new()), jsg::modules::ModuleType::Internal, ); jsg::modules::add_builtin( registry, "node-internal:url", - |lock| UrlUtil::new().to_js(lock), + |_lock| Ok(UrlUtil::new()), jsg::modules::ModuleType::Internal, ); } 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-compile-tests/ok-builtin-module.rs b/src/rust/jsg-compile-tests/ok-builtin-module.rs index e36c445309f..ebaee4a3233 100644 --- a/src/rust/jsg-compile-tests/ok-builtin-module.rs +++ b/src/rust/jsg-compile-tests/ok-builtin-module.rs @@ -2,15 +2,28 @@ // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 -// A builtin module's callback returns a handle of the scope it is given. -use jsg::ToJS; +// A builtin module's callback returns its exports resource, which registration wraps in the +// registry's scope. use jsg::v8; +use jsg_macros::jsg_method; +use jsg_macros::jsg_resource; + +#[jsg_resource] +pub struct Exports; + +#[jsg_resource] +impl Exports { + #[jsg_method] + pub fn answer(&self) -> u32 { + 42 + } +} pub fn register(registry: std::pin::Pin<&mut v8::ffi::ModuleRegistry>) { jsg::modules::add_builtin( registry, "node-internal:example", - |lock| vec![1.0_f64].to_js(lock), + |_lock| Ok(jsg::Rc::new(Exports)), jsg::modules::ModuleType::Internal, ); } diff --git a/src/rust/jsg-macros/README.md b/src/rust/jsg-macros/README.md index 1a6acd93d34..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,18 +123,24 @@ 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(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. 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] @@ -153,14 +162,22 @@ 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. + +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::{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 +197,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 +231,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..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] // ============================================================================= @@ -690,9 +729,10 @@ 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. +/// `#[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 b82124ee2da..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() -> jsg::Member { - #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 - where - Self: Sized, - { - vec![#(#automatic,)* #(#nested_type_registrations,)*] + ) { + let _ = ®istry; + #(registry.add(#automatic);)* + #(registry.add(#nested_type_registrations);)* } } } @@ -622,6 +687,81 @@ fn generate_resource_bindings( }) } +/// The kind of callable whose JavaScript parameters are described. +#[derive(Clone, Copy)] +enum Callable { + /// A method, whose first parameter receives the resource if it is named `this`. + Method, + /// A constructor, which has no receiver; a parameter named `this` is an argument. + Constructor, +} + +fn describe_args(method: &syn::ImplItemFn, callable: Callable) -> 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) -> jsg::Member { - 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/ffi.c++ b/src/rust/jsg-test/ffi.c++ index 16d3ac12cde..51b0487c71e 100644 --- a/src/rust/jsg-test/ffi.c++ +++ b/src/rust/jsg-test/ffi.c++ @@ -134,6 +134,7 @@ struct RecordingModuleRegistry final: public ::workerd::rust::jsg::ModuleRegistr void addBuiltinModule(::rust::Str specifier, ::workerd::rust::jsg::ModuleCallback callback, + ::workerd::rust::jsg::ModuleDescribeCallback, ::workerd::rust::jsg::ModuleType) override { modules.insert(kj::str(specifier), callback); } diff --git a/src/rust/jsg-test/tests/builtin_module.rs b/src/rust/jsg-test/tests/builtin_module.rs index 3771c19a003..ba3c7ae90a7 100644 --- a/src/rust/jsg-test/tests/builtin_module.rs +++ b/src/rust/jsg-test/tests/builtin_module.rs @@ -2,31 +2,40 @@ // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 -//! A builtin module's callback produces its exports when JavaScript imports it, and an error -//! it returns is thrown to the importer. +//! A builtin module's callback produces its exports resource when JavaScript imports it, and +//! an error it returns is thrown to the importer. use std::pin::Pin; -use jsg::ToJS; use jsg::modules::ModuleType; use jsg::v8; +use jsg_macros::jsg_property; +use jsg_macros::jsg_resource; + +#[jsg_resource] +struct Exports; + +#[jsg_resource] +impl Exports { + #[jsg_property(prototype, readonly)] + fn get_value(&self) -> String { + "exported".to_owned() + } +} fn register(mut registry: Pin<&mut v8::ffi::ModuleRegistry>) { jsg::modules::add_builtin( registry.as_mut(), "test:exports", - |lock| { - let exports = lock.new_object(); - let value = "exported".to_js(lock)?; - exports.set(lock, "value", value)?; - Ok(exports.into()) - }, + |_lock| Ok(jsg::Rc::new(Exports)), ModuleType::Internal, ); jsg::modules::add_builtin( registry, "test:fails", - |_lock| Err(jsg::Error::new_type_error("the module failed")), + |_lock| -> jsg::Result> { + Err(jsg::Error::new_type_error("the module failed")) + }, ModuleType::Internal, ); } 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_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..d7fa53a31b2 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; @@ -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; @@ -124,32 +130,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/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-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/BUILD.bazel b/src/rust/jsg/BUILD.bazel index a9e64773025..80ac358ed05 100644 --- a/src/rust/jsg/BUILD.bazel +++ b/src/rust/jsg/BUILD.bazel @@ -41,6 +41,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 ae7665cc2f1..b718924df36 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -2,6 +2,55 @@ 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; in `workerd:rtti`, these are the flags `jsg::rtti::Builder` +gives C++ resources. + +Register modules with a callback receiving `&mut Lock<'s>` and returning +`jsg::Result>`, for example `jsg::modules::add_builtin(registry, +"node-internal:dns", |_lock| Ok(DnsUtil::new()), ModuleType::Internal)`. The return +type determines both runtime wrapping and RTTI. `workerd:rtti` collects each module's +export type and reachable definitions without invoking the callback, using the same +context for every module. Registration-time compatibility gates also govern RTTI +discovery. + +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` @@ -390,7 +439,8 @@ scope. There is no public constructor; locks come from: accessors likewise with `jsg::getter_callback` / `jsg::setter_callback`, and `#[jsg_property(lazy_instance)]` accessors with `jsg::getter_callback`, - `jsg::modules::add_builtin`, whose callback is a closure that captures nothing, of type - `for<'s> Fn(&mut Lock<'s>) -> Result, jsg::Error>`, + `for<'s> Fn(&mut Lock<'s>) -> Result, jsg::Error>`; registration wraps its + result in the caller's scope and derives RTTI from the resource type, - `v8::Function::new`, whose closure captures nothing and gets a lock per call, - `jsg::callback_scope!(unsafe let lock, isolate)` for other Rust code that C++ calls with a scope already open; the caller vouches for the isolate's state, hence `unsafe`. A panic @@ -752,9 +802,10 @@ 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. 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/ffi.h b/src/rust/jsg/ffi.h index 037eb52411f..ac9706a6173 100644 --- a/src/rust/jsg/ffi.h +++ b/src/rust/jsg/ffi.h @@ -39,6 +39,10 @@ enum class FastApiType : ::std::uint8_t; // ModuleType enum is generated by CXX in v8.rs.h - forward declare it here enum class ModuleType : ::std::uint8_t; using ModuleCallback = ::rust::Fn; +// A Rust `jsg::rtti::Context`, defined by the CXX bridge in v8.rs. +struct RttiContext; +// Describes a module's exports in an RTTI context, given the module's specifier. +using ModuleDescribeCallback = ::rust::Fn; // Mirrors the CXX shared struct defined in v8.rs. Must be kept in sync. // The CXX-generated v8.rs.h provides the canonical definition; this is @@ -471,15 +475,18 @@ const uint8_t* utf8_value_data(const Utf8Value& value); struct ModuleRegistry { virtual ~ModuleRegistry() = default; - virtual void addBuiltinModule( - ::rust::Str specifier, ModuleCallback moduleCallback, ModuleType moduleType) = 0; + virtual void addBuiltinModule(::rust::Str specifier, + ModuleCallback moduleCallback, + ModuleDescribeCallback describe, + ModuleType moduleType) = 0; }; inline void register_add_builtin_module(ModuleRegistry& registry, ::rust::Str specifier, ModuleCallback callback, + ModuleDescribeCallback describe, ModuleType moduleType) { - registry.addBuiltinModule(specifier, kj::mv(callback), moduleType); + registry.addBuiltinModule(specifier, kj::mv(callback), kj::mv(describe), moduleType); } Global create_resource_template(Isolate* isolate, const ResourceDescriptor& descriptor); diff --git a/src/rust/jsg/jsg.h b/src/rust/jsg/jsg.h index 8b9afe6e5d7..4ab84008e1c 100644 --- a/src/rust/jsg/jsg.h +++ b/src/rust/jsg/jsg.h @@ -18,7 +18,8 @@ namespace workerd::rust::jsg { -// Calls a Rust module callback. An empty result means it panicked and threw. +// Calls a Rust module callback. An empty result means that the callback panicked, returned an +// error or failed to convert its result, and that the exception is pending. inline v8::Local callModuleCallback(ModuleCallback callback, ::workerd::jsg::Lock& js) { v8::Local value; if (!maybe_local_from_ffi(callback(js.v8Isolate)).ToLocal(&value)) { @@ -28,43 +29,51 @@ inline v8::Local callModuleCallback(ModuleCallback callback, ::worker } // Adapter for registering Rust-implemented modules into the OLD module registry -// (jsg::ModuleRegistryImpl). Translates Rust ModuleCallback into ModuleInfo. +// (jsg::ModuleRegistryImpl), which translates Rust ModuleCallback into ModuleInfo, or into the +// RTTI encoder, which describes each module without instantiating it. The encoder declares +// `describesRustModules`, so that its addRustBuiltinModule() must match the call below. template struct RustModuleRegistry: public ::workerd::rust::jsg::ModuleRegistry { virtual ~RustModuleRegistry() = default; RustModuleRegistry(Registry& registry): registry(registry) {} - void addBuiltinModule( - ::rust::Str specifier, ModuleCallback moduleCallback, ModuleType moduleType) override { - ::workerd::jsg::ModuleType jsgModuleType; - switch (moduleType) { - case ModuleType::Bundle: - jsgModuleType = ::workerd::jsg::ModuleType::BUNDLE; - break; - case ModuleType::Builtin: - jsgModuleType = ::workerd::jsg::ModuleType::BUILTIN; - break; - case ModuleType::Internal: - jsgModuleType = ::workerd::jsg::ModuleType::INTERNAL; - break; - default: - KJ_UNREACHABLE; - } + void addBuiltinModule(::rust::Str specifier, + ModuleCallback moduleCallback, + ModuleDescribeCallback describe, + ModuleType moduleType) override { + if constexpr (requires { Registry::describesRustModules; }) { + registry.addRustBuiltinModule(specifier, describe); + } else { + ::workerd::jsg::ModuleType jsgModuleType; + switch (moduleType) { + case ModuleType::Bundle: + jsgModuleType = ::workerd::jsg::ModuleType::BUNDLE; + break; + case ModuleType::Builtin: + jsgModuleType = ::workerd::jsg::ModuleType::BUILTIN; + break; + case ModuleType::Internal: + jsgModuleType = ::workerd::jsg::ModuleType::INTERNAL; + break; + default: + KJ_UNREACHABLE; + } + + registry.addBuiltinModule(kj::str(specifier), + [kj_specifier = kj::str(specifier), callback = kj::mv(moduleCallback)]( + ::workerd::jsg::Lock& js, ::workerd::jsg::ModuleRegistry::ResolveMethod, + kj::Maybe&) mutable + -> kj::Maybe<::workerd::jsg::ModuleRegistry::ModuleInfo> { + auto value = callModuleCallback(callback, js); + KJ_DASSERT(value->IsObject()); - registry.addBuiltinModule(kj::str(specifier), - [kj_specifier = kj::str(specifier), callback = kj::mv(moduleCallback)]( - ::workerd::jsg::Lock& js, ::workerd::jsg::ModuleRegistry::ResolveMethod, - kj::Maybe&) mutable - -> kj::Maybe<::workerd::jsg::ModuleRegistry::ModuleInfo> { - auto value = callModuleCallback(callback, js); - KJ_DASSERT(value->IsObject()); - - using ModuleInfo = ::workerd::jsg::ModuleRegistry::ModuleInfo; - using ObjectModuleInfo = ::workerd::jsg::ModuleRegistry::ObjectModuleInfo; - return kj::Maybe( - ModuleInfo(js, kj_specifier, kj::none, ObjectModuleInfo(js, value.As()))); - }, - jsgModuleType); + using ModuleInfo = ::workerd::jsg::ModuleRegistry::ModuleInfo; + using ObjectModuleInfo = ::workerd::jsg::ModuleRegistry::ObjectModuleInfo; + return kj::Maybe( + ModuleInfo(js, kj_specifier, kj::none, ObjectModuleInfo(js, value.As()))); + }, + jsgModuleType); + } } Registry& registry; @@ -85,8 +94,11 @@ struct RustBuiltinModuleAdapter: public ::workerd::rust::jsg::ModuleRegistry { explicit RustBuiltinModuleAdapter(::workerd::jsg::modules::ModuleBundle::BuiltinBuilder& builder) : builder(builder) {} - void addBuiltinModule( - ::rust::Str specifier, ModuleCallback moduleCallback, ModuleType moduleType) override { + // The new registry is only used at runtime, so it does not describe modules. + void addBuiltinModule(::rust::Str specifier, + ModuleCallback moduleCallback, + ModuleDescribeCallback, + ModuleType moduleType) override { auto specifierPtr = kj::ArrayPtr(specifier.data(), specifier.size()); // A BuiltinBuilder can only represent builtin modules. diff --git a/src/rust/jsg/lib.rs b/src/rust/jsg/lib.rs index 3897896ed89..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,10 +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/modules.rs b/src/rust/jsg/modules.rs index bc085ddb5a3..7238e180e19 100644 --- a/src/rust/jsg/modules.rs +++ b/src/rust/jsg/modules.rs @@ -11,40 +11,51 @@ use std::pin::Pin; use crate::CallbackScope; use crate::Lock; +use crate::Rc; +use crate::Resource; +use crate::ToJS; +use crate::TypeInfo; use crate::UnitType; +use crate::rtti::Context; use crate::v8; use crate::v8::ffi; pub use crate::v8::ffi::ModuleType; /// Registers a builtin module with the given specifier and module type. /// -/// `callback` produces the module's exports when JavaScript imports it. It runs in the module -/// registry's current scope, so it receives a [`Lock`] for that scope and returns a handle -/// branded with the same scope; an error it returns is thrown to the importer. It must capture -/// nothing: C++ calls it through a plain function pointer, so there is nowhere to keep captured -/// state (see [`UnitType`]). -pub fn add_builtin( +/// `callback` produces the module's exports resource when JavaScript imports it, and +/// registration wraps it. The callback runs in the module registry's current scope, so it +/// receives a [`Lock`] for that scope; an error it returns is thrown to the importer. It must +/// capture nothing: C++ calls it through a plain function pointer, so there is nowhere to keep +/// captured state (see [`UnitType`]). +/// +/// The exports type `T` also determines the module's RTTI, which the RTTI export collects +/// without invoking the callback. +pub fn add_builtin( registry: Pin<&mut ffi::ModuleRegistry>, specifier: &str, callback: F, module_type: ModuleType, ) where - F: UnitType + for<'s> Fn(&mut Lock<'s>) -> crate::Result>, + T: Resource + TypeInfo, + F: UnitType + for<'s> Fn(&mut Lock<'s>) -> crate::Result>, { // Called only by the C++ module registry, while `isolate` is locked, a context is entered // and a `HandleScope` is open that outlives the call. It is not an `unsafe fn` because cxx // passes function pointers as safe `fn`s, and no other code can name it. Returns an empty - // handle if `F` failed or panicked, with the exception pending. - fn trampoline(isolate: *mut ffi::Isolate) -> ffi::MaybeLocal + // handle if `F` or the conversion of its result failed, or `F` panicked, with the exception + // pending. + fn trampoline(isolate: *mut ffi::Isolate) -> ffi::MaybeLocal where - F: UnitType + for<'s> Fn(&mut Lock<'s>) -> crate::Result>, + T: Resource, + F: UnitType + for<'s> Fn(&mut Lock<'s>) -> crate::Result>, { // SAFETY: see above. let scope = std::pin::pin!(unsafe { CallbackScope::new(isolate) }); crate::catch_panic(scope, |lock| { // SAFETY: `add_builtin` was handed a value of `F`. let callback = unsafe { crate::scope::unit_value::() }; - let exports = match callback(lock) { + let exports = match callback(lock).and_then(|exports| exports.to_js(lock)) { Ok(exports) => Some(exports), Err(err) => { lock.throw_exception(&err); @@ -60,6 +71,12 @@ pub fn add_builtin( let _ = callback; // SAFETY: the registry calls `trampoline` as its contract requires. unsafe { - ffi::register_add_builtin_module(registry, specifier, trampoline::, module_type); + ffi::register_add_builtin_module( + registry, + specifier, + trampoline::, + Context::add_module::, + module_type, + ); } } 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..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; @@ -375,7 +376,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 +460,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 +493,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 { @@ -630,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::, } } } @@ -739,22 +754,88 @@ impl Resources { } } +/// 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, 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. /// /// 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; + /// `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<'_>); } #[cfg(test)] diff --git a/src/rust/jsg/rtti-test.rs b/src/rust/jsg/rtti-test.rs new file mode 100644 index 00000000000..52d2036377c --- /dev/null +++ b/src/rust/jsg/rtti-test.rs @@ -0,0 +1,369 @@ +// 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::() +} + +#[test] +fn bridge_contexts_take_canonical_flags() { + let mut message = capnp::message::Builder::new_default(); + message + .init_root::>() + .set_cache_option_enabled(true); + let words = message.into_reader().canonicalize().unwrap(); + assert_eq!( + flagged_members(*new_rtti_context(capnp::Word::words_to_bytes(&words))), + [ + ("always".to_owned(), None), + ("cache".to_owned(), Some(false)) + ] + ); +} diff --git a/src/rust/jsg/rtti.rs b/src/rust/jsg/rtti.rs new file mode 100644 index 00000000000..28611ab7132 --- /dev/null +++ b/src/rust/jsg/rtti.rs @@ -0,0 +1,571 @@ +// 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 +} + +pub(crate) fn new_rtti_context(feature_flags_data: &[u8]) -> Box { + Box::new(Context::new(FeatureFlags::from_bytes(feature_flags_data))) +} + +#[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/rust/jsg/v8.rs b/src/rust/jsg/v8.rs index 93c3c115976..2a9986266e6 100644 --- a/src/rust/jsg/v8.rs +++ b/src/rust/jsg/v8.rs @@ -59,6 +59,8 @@ use crate::UnitType; use crate::isolate_state::IsolateState; pub use crate::promise::Promise; pub use crate::promise::PromiseResolver; +use crate::rtti::Context as RttiContext; +use crate::rtti::new_rtti_context; #[expect( clippy::missing_safety_doc, reason = "bridge declarations of raw V8 FFI functions; their contracts are V8's own" @@ -870,13 +872,27 @@ pub mod ffi { Internal = 2, } + extern "Rust" { + type RttiContext; + + /// Creates a context that describes resource members as registered under the + /// compatibility flags in `feature_flags_data`, canonical `CompatibilityFlags` bytes as + /// for `realm_create`. + fn new_rtti_context(feature_flags_data: &[u8]) -> Box; + fn encode(self: &RttiContext, group: &str) -> Vec; + } + unsafe extern "C++" { type ModuleRegistry; + /// Registers a builtin module. The registry calls `callback` to instantiate the + /// module, while a metadata registry (the RTTI encoder) calls `describe` instead, to + /// describe the module's exports without instantiating it. pub unsafe fn register_add_builtin_module( registry: Pin<&mut ModuleRegistry>, specifier: &str, callback: unsafe fn(*mut Isolate) -> MaybeLocal, + describe: fn(&mut RttiContext, &str), module_type: ModuleType, ); } diff --git a/src/workerd/api/BUILD.bazel b/src/workerd/api/BUILD.bazel index c6332c91baa..895a1224701 100644 --- a/src/workerd/api/BUILD.bazel +++ b/src/workerd/api/BUILD.bazel @@ -811,6 +811,7 @@ kj_test( deps = [ ":encoding", ":html-rewriter", + ":rtti", ":streams", ":urlpattern", ":urlpattern-standard", diff --git a/src/workerd/api/api-rtti-test.c++ b/src/workerd/api/api-rtti-test.c++ index ef408af4f72..e1a846d8fcf 100644 --- a/src/workerd/api/api-rtti-test.c++ +++ b/src/workerd/api/api-rtti-test.c++ @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -25,6 +26,7 @@ #include #include +#include #include // Test building rtti for various APIs. @@ -77,5 +79,65 @@ KJ_TEST("JsReadableWritablePair delegates its RTTI to ReadableStream::Transform" type.getStructure().getFullyQualifiedName()); } +KJ_TEST("RTTI export discovers Rust modules and their referenced structs without evaluating JS") { + auto module = jsg::alloc(); + auto bytes = module->exportTypes(kj::str("2026-01-01"), kj::heapArray(0)); + auto words = kj::heapArray(bytes.size() / sizeof(capnp::word)); + words.asBytes().copyFrom(bytes); + capnp::FlatArrayMessageReader reader(words); + auto root = reader.getRoot(); + + kj::Maybe dnsIdentity; + kj::Maybe recordIdentity; + for (auto group: root.getGroups()) { + for (auto structure: group.getStructures()) { + if (structure.getTsName() == "DnsUtil") { + dnsIdentity = structure.getFullyQualifiedName(); + KJ_EXPECT(!structure.getTsRoot()); + for (auto member: structure.getMembers()) { + if (member.isMethod() && member.getMethod().getName() == "parseCaaRecord") { + auto method = member.getMethod(); + KJ_ASSERT(method.getArgs().size() == 1); + KJ_EXPECT(method.getArgs()[0].isString()); + KJ_EXPECT(method.getArgNames()[0] == "record"); + KJ_ASSERT(method.getReturnType().isStructure()); + recordIdentity = method.getReturnType().getStructure().getFullyQualifiedName(); + } + } + } + } + } + KJ_ASSERT(dnsIdentity != kj::none); + KJ_ASSERT(recordIdentity != kj::none); + bool foundRecord = false; + bool foundModule = false; + for (auto group: root.getGroups()) { + for (auto structure: group.getStructures()) { + if (structure.getFullyQualifiedName() == KJ_ASSERT_NONNULL(recordIdentity)) { + foundRecord = true; + KJ_EXPECT(structure.getTsName() == "CaaRecord"); + for (auto field: kj::arr("critical"_kj, "field"_kj, "value"_kj)) { + bool foundField = false; + for (auto member: structure.getMembers()) { + if (member.isProperty() && member.getProperty().getName() == field) { + foundField = true; + } + } + KJ_EXPECT(foundField, field); + } + KJ_EXPECT(!structure.getTsRoot()); + } + } + } + for (auto entry: root.getModules()) { + if (entry.getSpecifier() == "node-internal:dns") { + KJ_EXPECT(entry.getStructureName() == KJ_ASSERT_NONNULL(dnsIdentity)); + foundModule = true; + } + } + KJ_EXPECT(foundRecord); + KJ_EXPECT(foundModule); +} + } // namespace } // namespace workerd::api diff --git a/src/workerd/api/rtti.c++ b/src/workerd/api/rtti.c++ index a59c9e07ec1..269c824a922 100644 --- a/src/workerd/api/rtti.c++ +++ b/src/workerd/api/rtti.c++ @@ -102,6 +102,12 @@ namespace workerd::api { namespace { struct EncoderModuleRegistryImpl { + // Rust resources are described with the members they register under `flags`, as C++ + // resources are by `jsg::rtti::Builder`. + explicit EncoderModuleRegistryImpl(CompatibilityFlags::Reader flags) + : rustTypes(::workerd::rust::jsg::new_rtti_context( + capnp::canonicalize(flags).asBytes().as())) {} + struct CppModuleContents { CppModuleContents(kj::String structureName): structureName(kj::mv(structureName)) {} @@ -162,7 +168,16 @@ struct EncoderModuleRegistryImpl { modules.add(kj::mv(info)); } + // Tells RustModuleRegistry to describe Rust modules through addRustBuiltinModule(). + static constexpr bool describesRustModules = true; + + void addRustBuiltinModule( + ::rust::Str specifier, ::workerd::rust::jsg::ModuleDescribeCallback describe) { + describe(*rustTypes, specifier); + } + kj::Vector modules; + ::rust::Box<::workerd::rust::jsg::RttiContext> rustTypes; }; // typescript_implemented_streams replaces the C++ Web Streams globals with TypeScript @@ -244,6 +259,17 @@ struct TypesEncoder { capnp::MallocMessageBuilder message; auto root = message.initRoot(); + EncoderModuleRegistryImpl registry(flags); + registerModules(registry, flags); + + auto rustBytes = registry.rustTypes->encode("rust"); + KJ_REQUIRE(rustBytes.size() % sizeof(capnp::word) == 0, + "Rust RTTI message size must be a multiple of a Cap'n Proto word"); + auto rustWords = kj::heapArray(rustBytes.size() / sizeof(capnp::word)); + rustWords.asBytes().copyFrom(kj::arrayPtr(rustBytes.data(), rustBytes.size())); + capnp::FlatArrayMessageReader rustReader(rustWords); + auto rustTypes = rustReader.getRoot(); + // Encode RTTI structures auto builder = jsg::rtti::Builder(flags); @@ -252,22 +278,25 @@ struct TypesEncoder { unsigned int groupsSize = 0; EW_TYPE_GROUP_FOR_EACH(EW_TYPE_GROUP_COUNT) + groupsSize += rustTypes.getGroups().size(); auto groups = root.initGroups(groupsSize); groupsIndex = 0; EW_TYPE_GROUP_FOR_EACH(EW_TYPE_GROUP_WRITE) + for (auto group: rustTypes.getGroups()) { + groups.setWithCaveats(groupsIndex++, group); + } KJ_ASSERT(groupsIndex == groupsSize); #undef EW_TYPE_GROUP_COUNT #undef EW_TYPE_GROUP_WRITE // Encode modules - EncoderModuleRegistryImpl registry; - registerModules(registry, flags); - unsigned int i = 0; - auto modulesBuilder = root.initModules(registry.modules.size()); - for (auto moduleBuilder: modulesBuilder) { - auto &module = registry.modules[i++]; + auto moduleCount = registry.modules.size(); + moduleCount += rustTypes.getModules().size(); + auto modulesBuilder = root.initModules(moduleCount); + for (auto &module: registry.modules) { + auto moduleBuilder = modulesBuilder[i++]; moduleBuilder.setSpecifier(module.specifier); KJ_SWITCH_ONEOF(module.contents) { KJ_CASE_ONEOF(contents, EncoderModuleRegistryImpl::CppModuleContents) { @@ -278,6 +307,9 @@ struct TypesEncoder { } } } + for (auto module: rustTypes.getModules()) { + modulesBuilder.setWithCaveats(i++, module); + } auto words = capnp::messageToFlatArray(message); auto bytes = words.asBytes(); 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..ae817b827e6 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` and typed module registration 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