For Parcel CSS, I'm implementing the CSS OM API, which includes many classes which inherit from one another. For example, CSSRule <- CSSGroupingRule <- CSSConditionRule <- CSSMediaRule. So far, I've been doing this by patching the prototype chain of the generated classes from the JavaScript side, along with some tricks on the Rust side to make the memory layout work.
On the Rust side:
#[napi]
#[repr(C)]
struct CSSRule {
// ...
}
#[napi]
#[repr(C)]
struct CSSGroupingRule {
parent: CSSRule,
// ...
}
and the JS:
Object.setPrototypeOf(CSSGroupingRule.prototype, CSSRule.prototype);
Object.setPrototypeOf(CSSGroupingRule, CSSRule);
Basically, the super class is always listed first in the subclass struct, and #[repr(C)] ensures that the fields won't get reordered by the Rust compiler. This means that when accessing a property or method from a super class in JS, it will be resolved in the prototype chain, and on the Rust side, the pointer will be viewed as the parent struct (fields after in the child will be ignored).
This works but it might be nice for napi-rs to do this for me. For example, you could write this instead:
#[napi(extends = CSSRule)]
struct CSSGroupingRule {
// ...
}
and napi-rs could generate the above structure, and calls to setup the prototype chain properly.
Problems with super class methods
One problem with this approach currently is that methods don't work when defined in a super class but accessed from an instance of a subclass. For example:
#[napi]
impl CSSRule {
#[napi]
pub fn super_method(&self) {}
}
let rule = new CSSGroupingRule();
rule.superMethod();
This will fail with an "Illegal invocation" error from v8. I traced this back to a few issues in Node, e.g. nodejs/node-addon-api#246, nodejs/node#38038, nodejs/node#20267, nodejs/node-addon-api#229, etc.
The crux of it is this line in napi_define_class: https://github.com/nodejs/node/blame/55079bbebf02d12e56b7068ff0d3e32f9cf83bcc/src/js_native_api_v8.cc#L872-L873
The v8::Signature there makes v8 verify that the function is only called on instances of the class it is defined for. This is generally a good security feature, so you aren't viewing the memory of one struct as another. But in the case of subclasses, it is problematic. I'm not sure why getters and setters are not also subject to this, they probably should be.
I was able to get around this by defining the methods as functions outside the class, using #[js_function], and then attaching them to the prototype of the napi-derived class manually. That gives me access to the this object using the CallContext, which I can then convert to my struct as needed.
#[js_function(1)]
fn super_method(ctx: CallContext) -> Result<JsUndefined> {
let this: JsObject = ctx.this()?;
// Unsafe at the moment.
let napi_value = unsafe { napi::bindgen_prelude::ToNapiValue::to_napi_value(ctx.env.raw(), this).unwrap() };
let rule = unsafe { CSSRule::from_napi_mut_ref(ctx.env.raw(), napi_value).unwrap() };
// ...
}
// Some initialization code to add the method to the class prototype
fn init(env: Env) {
let constructor_value = napi::bindgen_prelude::get_class_constructor("CSSRule\0").unwrap();
let mut value = std::ptr::null_mut();
unsafe { napi::sys::napi_get_reference_value(env.raw(), constructor_value, &mut value) };
let constructor = unsafe { JsFunction::from_raw(env.raw(), value).unwrap() };
let constructor = constructor.coerce_to_object().unwrap();
let mut prototype: JsObject = constructor.get_named_property("prototype").unwrap();
prototype
.set_named_property(
"superMethod",
env.create_function("superMethod", super_method).unwrap(),
)
.unwrap();
}
Functions defined this way don't have a v8::Signature associated with them, so v8 doesn't do any checking, and it works as expected.
One thing missing here is checking to ensure that the this object is of the correct class (or a subclass) when unwrapping it. This is done normally by v8 for methods but not getters. env.unwrap() also does something like that, but it also only supports exact objects and not subclasses. I think napi_type_tag_object and napi_check_object_type_tag were added for exactly this purpose. You can associate multiple tags with an object (e.g. one for each class in a hierarchy), and check if any of them are associated with a JS object before unwrapping.
So, to put this all together, I think napi-rs could support subclassing using the method described above. Instead of using napi_define_class to add all methods, it could attach each method to the prototype chain itself afterward, and use napi_check_object_type_tag to ensure that the object is in the class hierarchy.
Sorry for the super long issue, there was a lot of details here. Would you be open to supporting something like this? I'm happy to contribute if so.
For Parcel CSS, I'm implementing the CSS OM API, which includes many classes which inherit from one another. For example,
CSSRule <- CSSGroupingRule <- CSSConditionRule <- CSSMediaRule. So far, I've been doing this by patching the prototype chain of the generated classes from the JavaScript side, along with some tricks on the Rust side to make the memory layout work.On the Rust side:
and the JS:
Basically, the super class is always listed first in the subclass struct, and
#[repr(C)]ensures that the fields won't get reordered by the Rust compiler. This means that when accessing a property or method from a super class in JS, it will be resolved in the prototype chain, and on the Rust side, the pointer will be viewed as the parent struct (fields after in the child will be ignored).This works but it might be nice for napi-rs to do this for me. For example, you could write this instead:
and napi-rs could generate the above structure, and calls to setup the prototype chain properly.
Problems with super class methods
One problem with this approach currently is that methods don't work when defined in a super class but accessed from an instance of a subclass. For example:
This will fail with an "Illegal invocation" error from v8. I traced this back to a few issues in Node, e.g. nodejs/node-addon-api#246, nodejs/node#38038, nodejs/node#20267, nodejs/node-addon-api#229, etc.
The crux of it is this line in
napi_define_class: https://github.com/nodejs/node/blame/55079bbebf02d12e56b7068ff0d3e32f9cf83bcc/src/js_native_api_v8.cc#L872-L873The
v8::Signaturethere makes v8 verify that the function is only called on instances of the class it is defined for. This is generally a good security feature, so you aren't viewing the memory of one struct as another. But in the case of subclasses, it is problematic. I'm not sure why getters and setters are not also subject to this, they probably should be.I was able to get around this by defining the methods as functions outside the class, using
#[js_function], and then attaching them to the prototype of the napi-derived class manually. That gives me access to thethisobject using theCallContext, which I can then convert to my struct as needed.Functions defined this way don't have a
v8::Signatureassociated with them, so v8 doesn't do any checking, and it works as expected.One thing missing here is checking to ensure that the
thisobject is of the correct class (or a subclass) when unwrapping it. This is done normally by v8 for methods but not getters.env.unwrap()also does something like that, but it also only supports exact objects and not subclasses. I think napi_type_tag_object and napi_check_object_type_tag were added for exactly this purpose. You can associate multiple tags with an object (e.g. one for each class in a hierarchy), and check if any of them are associated with a JS object before unwrapping.So, to put this all together, I think napi-rs could support subclassing using the method described above. Instead of using
napi_define_classto add all methods, it could attach each method to the prototype chain itself afterward, and usenapi_check_object_type_tagto ensure that the object is in the class hierarchy.Sorry for the super long issue, there was a lot of details here. Would you be open to supporting something like this? I'm happy to contribute if so.