refactor(webapi): add native cross-origin member declarations

Generate stateless method and accessor tables with receiver checks and native
value callbacks. Cache FunctionTemplates per isolate and use V8's per-context
function instances, without retaining target Windows or observer realms.

Cover function identity, realm prototypes, descriptors, receiver validation,
exception and termination propagation, and context collection.
This commit is contained in:
ldm0
2026-10-17 03:31:45 +08:00
committed by Donough Liu
parent d4b7493259
commit b4f9a9677f
6 changed files with 618 additions and 1 deletions
+3
View File
@@ -15,6 +15,9 @@ struct DeclaredField {
attrs: FieldAttrs,
}
mod cross_origin;
pub(crate) use cross_origin::expand_webapi_cross_origin;
fn declared_fields(
data: &Data,
rename_all: RenameRule,
@@ -0,0 +1,234 @@
use super::*;
pub(crate) fn expand_webapi_cross_origin(
input: DeriveInput,
) -> Result<proc_macro2::TokenStream, Error> {
let name = &input.ident;
if !input.generics.params.is_empty() {
return Err(Error::new_spanned(
&input.generics,
"cross-origin declarations cannot carry runtime state",
));
}
let attrs = parse_object_attrs(&input.attrs)?;
if !matches!(attrs.role, None | Some(ObjectRole::Plain))
|| attrs.prototype.is_some()
|| attrs.own_to_string_tag.is_some()
|| attrs.fallback_to_string_tag.is_some()
|| attrs.scope_lifetime.is_some()
|| attrs.default_data_properties
|| attrs.default_enumerable
{
return Err(Error::new_spanned(
input,
"cross-origin declarations describe stateless property tables, not object initialization",
));
}
let fields = declared_fields(
&input.data,
attrs.rename_all,
FieldDefaults {
receiver: attrs.receiver.as_ref(),
..FieldDefaults::default()
},
)?;
let mut helpers = Vec::new();
let mut entries = Vec::new();
for DeclaredField { field, attrs } in fields {
if !type_is_unit(&field.ty)
|| attrs.data.is_some()
|| attrs.setter_data.is_some()
|| attrs.returns_promise
|| attrs.symbol.is_some()
|| attrs.getter_value.is_some()
|| attrs.value.is_some()
|| attrs.init.is_some()
|| attrs.enumerable
|| attrs.dont_delete
{
return Err(Error::new_spanned(
field,
"cross-origin members must be stateless string-keyed methods or accessors",
));
}
let property_name = match attrs.name.as_ref() {
Some(syn::Expr::Lit(syn::ExprLit {
lit: Lit::Str(name),
..
})) => name.clone(),
_ => {
return Err(Error::new_spanned(
&field,
"cross-origin member names must be string literals",
));
}
};
let ident = field.ident.as_ref().expect("named field");
let receiver_check = attrs.receiver.as_ref().map(|receiver| {
quote! {
if !#receiver(__moli_scope, __moli_receiver) {
::moli_webapi_declare::__private::throw_illegal_invocation(__moli_scope);
return None;
}
}
});
let function = |suffix: &str,
callback: proc_macro2::TokenStream,
length: i32,
function_name: LitStr| {
let factory = format_ident!("__cross_origin_{}_{}_template", ident, suffix);
let helper = quote! {
fn #factory<'s>(
scope: &::moli_webapi_declare::v8::PinScope<'s, '_>,
) -> ::moli_webapi_declare::v8::Local<'s, ::moli_webapi_declare::v8::FunctionTemplate> {
let template = ::moli_webapi_declare::v8::FunctionTemplate::builder(#callback)
.length(#length)
.constructor_behavior(::moli_webapi_declare::v8::ConstructorBehavior::Throw)
.build(scope);
template.remove_prototype();
template.set_class_name(::moli_webapi_declare::__private::v8str(scope, #function_name));
template
}
};
(
helper,
quote!(::moli_webapi_declare::CrossOriginFunction { build: Self::#factory }),
)
};
match attrs.kind {
FieldKind::Method => {
let callback = attrs
.callback
.as_ref()
.ok_or_else(|| Error::new_spanned(&field, "method requires callback"))?;
let callback = expand_callback(callback, &attrs, false);
let (helper, function) = function(
"method",
callback,
attrs.length.unwrap_or(0),
property_name.clone(),
);
helpers.push(helper);
entries.push(quote! {
::moli_webapi_declare::CrossOriginProperty::Method { name: #property_name, function: #function }
});
}
FieldKind::AccessorProperty => {
if attrs.getter.is_none() && attrs.setter.is_none() {
return Err(Error::new_spanned(
field,
"cross-origin accessor requires a getter or setter",
));
}
let mut accessors = Vec::new();
for (suffix, callback, length) in [
("get", attrs.getter.as_ref(), 0),
("set", attrs.setter.as_ref(), 1),
] {
let Some(callback) = callback else {
accessors.push(quote!(None));
continue;
};
let value_helper = format_ident!("__cross_origin_{}_{}", ident, suffix);
let callback_helper =
format_ident!("__cross_origin_{}_{}_callback", ident, suffix);
let function_name = LitStr::new(
&format!("{suffix} {}", property_name.value()),
property_name.span(),
);
let (helper, function) = function(
suffix,
quote!(Self::#callback_helper),
length,
function_name,
);
helpers.push(helper);
if length == 0 {
helpers.push(quote! {
fn #value_helper<'s>(
__moli_scope: &mut ::moli_webapi_declare::v8::PinScope<'s, '_>,
__moli_receiver: ::moli_webapi_declare::v8::Local<'s, ::moli_webapi_declare::v8::Object>,
) -> Option<::moli_webapi_declare::v8::Local<'s, ::moli_webapi_declare::v8::Value>> {
#receiver_check
#callback(__moli_scope, __moli_receiver)
}
fn #callback_helper<'s>(
__moli_scope: &mut ::moli_webapi_declare::v8::PinScope<'s, '_>,
args: ::moli_webapi_declare::v8::FunctionCallbackArguments<'s>,
mut rv: ::moli_webapi_declare::v8::ReturnValue<'s>,
) {
if let Some(value) = Self::#value_helper(__moli_scope, args.this()) { rv.set(value); }
}
});
} else {
helpers.push(quote! {
fn #value_helper<'s>(
__moli_scope: &mut ::moli_webapi_declare::v8::PinScope<'s, '_>,
__moli_receiver: ::moli_webapi_declare::v8::Local<'s, ::moli_webapi_declare::v8::Object>,
value: ::moli_webapi_declare::v8::Local<'s, ::moli_webapi_declare::v8::Value>,
) -> Option<()> {
#receiver_check
#callback(__moli_scope, __moli_receiver, value)
}
fn #callback_helper<'s>(
__moli_scope: &mut ::moli_webapi_declare::v8::PinScope<'s, '_>,
args: ::moli_webapi_declare::v8::FunctionCallbackArguments<'s>,
_rv: ::moli_webapi_declare::v8::ReturnValue<'s>,
) {
let _ = Self::#value_helper(__moli_scope, args.this(), args.get(0));
}
});
}
accessors.push(quote!(Some((Self::#value_helper, #function))));
}
let getter = &accessors[0];
let setter = &accessors[1];
entries.push(quote! {
::moli_webapi_declare::CrossOriginProperty::Accessor {
name: #property_name, getter: #getter, setter: #setter,
}
});
}
_ => {
return Err(Error::new_spanned(
&field,
"cross-origin declarations only support method and accessor_property",
));
}
}
}
Ok(quote! {
impl #name { #(#helpers)* }
impl ::moli_webapi_declare::WebApiCrossOriginDeclaration for #name {
fn cross_origin_properties() -> &'static [::moli_webapi_declare::CrossOriginProperty] {
&[#(#entries),*]
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_declarations_that_would_capture_runtime_state() {
let invalid: [DeriveInput; 3] = [
syn::parse_quote! {
#[webapi(plain)]
struct Bad<T> { #[webapi(method, callback = callback)] operation: T }
},
syn::parse_quote! {
#[webapi(plain)]
struct Bad { #[webapi(method, callback = callback, data = self.state)] operation: (), state: usize }
},
syn::parse_quote! {
#[webapi(plain)]
struct Bad { #[webapi(method, name = runtime_name(), callback = callback)] operation: () }
},
];
for input in invalid {
assert!(expand_webapi_cross_origin(input).is_err());
}
}
}
+11
View File
@@ -4,6 +4,17 @@ mod expand;
use proc_macro::TokenStream;
use syn::{DeriveInput, parse_macro_input};
/// Declares native cross-origin methods and accessors without an intermediate
/// JavaScript object. Accessor callbacks return Option<Value> / Option<()> and
/// are also adapted to realm-correct descriptor functions.
#[proc_macro_derive(WebApiCrossOrigin, attributes(webapi))]
pub fn derive_webapi_cross_origin(input: TokenStream) -> TokenStream {
match expand::expand_webapi_cross_origin(parse_macro_input!(input as DeriveInput)) {
Ok(tokens) => tokens.into(),
Err(error) => error.to_compile_error().into(),
}
}
/// Derives a Web API function-template declaration binder.
///
/// The generated implementation creates a V8 `FunctionTemplate`, sets its class
+174
View File
@@ -0,0 +1,174 @@
//! Native cross-origin member declarations. Templates contain callback metadata
//! only; V8 owns each context's unique function instance and its lifetime.
use std::collections::HashMap;
use moli_v8_util::v8str;
pub type CrossOriginGetter = for<'s, 'i> fn(
&mut v8::PinScope<'s, 'i>,
v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Value>>;
pub type CrossOriginSetter = for<'s, 'i> fn(
&mut v8::PinScope<'s, 'i>,
v8::Local<'s, v8::Object>,
v8::Local<'s, v8::Value>,
) -> Option<()>;
pub struct CrossOriginFunction {
pub build: for<'s, 'i> fn(&v8::PinScope<'s, 'i>) -> v8::Local<'s, v8::FunctionTemplate>,
}
#[derive(Default)]
struct FunctionTemplates(HashMap<usize, v8::Global<v8::FunctionTemplate>>);
impl CrossOriginFunction {
pub fn get<'s>(
&'static self,
scope: &mut v8::PinScope<'s, '_>,
) -> Option<v8::Local<'s, v8::Function>> {
// The key identifies an immutable declaration member, never a Window
// or a realm. No callback data can retain a target or an observer.
let key = self as *const Self as usize;
let cached = scope
.get_slot::<FunctionTemplates>()
.and_then(|templates| templates.0.get(&key))
.map(|template| v8::Local::new(scope, template));
let template = match cached {
Some(template) => template,
None => {
let template = (self.build)(scope);
let retained = v8::Global::new(scope, template);
if scope.get_slot::<FunctionTemplates>().is_none() {
scope.set_slot(FunctionTemplates::default());
}
scope
.get_slot_mut::<FunctionTemplates>()
.expect("template store was initialized")
.0
.insert(key, retained);
template
}
};
template.get_function(scope)
}
}
pub enum CrossOriginProperty {
Method {
name: &'static str,
function: CrossOriginFunction,
},
Accessor {
name: &'static str,
getter: Option<(CrossOriginGetter, CrossOriginFunction)>,
setter: Option<(CrossOriginSetter, CrossOriginFunction)>,
},
}
impl CrossOriginProperty {
pub const fn name(&self) -> &'static str {
match self {
Self::Method { name, .. } | Self::Accessor { name, .. } => name,
}
}
pub fn get<'s>(
&'static self,
scope: &mut v8::PinScope<'s, '_>,
receiver: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Value>> {
match self {
Self::Method { function, .. } => function.get(scope).map(Into::into),
Self::Accessor {
getter: Some((getter, _)),
..
} => getter(scope, receiver),
_ => None,
}
}
pub fn set<'s>(
&'static self,
scope: &mut v8::PinScope<'s, '_>,
receiver: v8::Local<'s, v8::Object>,
value: v8::Local<'s, v8::Value>,
) -> Option<()> {
match self {
Self::Accessor {
setter: Some((setter, _)),
..
} => setter(scope, receiver, value),
_ => None,
}
}
pub const fn has_getter(&self) -> bool {
matches!(
self,
Self::Method { .. }
| Self::Accessor {
getter: Some(_),
..
}
)
}
pub const fn has_setter(&self) -> bool {
matches!(
self,
Self::Accessor {
setter: Some(_),
..
}
)
}
pub fn descriptor<'s>(
&'static self,
scope: &mut v8::PinScope<'s, '_>,
) -> Option<v8::Local<'s, v8::Object>> {
let descriptor = v8::Object::new(scope);
let fields: [(&str, v8::Local<v8::Value>); 2] = match self {
Self::Method { function, .. } => [
("value", function.get(scope)?.into()),
("writable", v8::Boolean::new(scope, false).into()),
],
Self::Accessor { getter, setter, .. } => {
let get = match getter {
Some((_, function)) => function.get(scope)?.into(),
None => v8::undefined(scope).into(),
};
let set = match setter {
Some((_, function)) => function.get(scope)?.into(),
None => v8::undefined(scope).into(),
};
[("get", get), ("set", set)]
}
};
for (name, value) in fields.into_iter().chain([
("enumerable", v8::Boolean::new(scope, false).into()),
("configurable", v8::Boolean::new(scope, true).into()),
]) {
descriptor
.define_own_property(
scope,
v8str(scope, name).into(),
value,
v8::PropertyAttribute::NONE,
)
.filter(|defined| *defined)?;
}
Some(descriptor)
}
}
pub trait WebApiCrossOriginDeclaration {
fn cross_origin_properties() -> &'static [CrossOriginProperty];
fn cross_origin_property(name: &str) -> Option<&'static CrossOriginProperty> {
Self::cross_origin_properties()
.iter()
.find(|property| property.name() == name)
}
}
+14 -1
View File
@@ -5,6 +5,14 @@
//! `moli-webidl` and focuses on constructing V8 interface objects,
//! prototypes, and branded object instances.
//!
//! `#[derive(WebApiCrossOrigin)]` describes a stateless cross-origin property
//! table for native access-check handlers. Methods use ordinary V8 callbacks;
//! accessors use native value callbacks returning `Option`, with receiver
//! predicates checked before either invocation. Function templates are cached
//! per isolate and produce a unique function in each accessing context. The
//! table and templates never capture a target Window or an observer context.
//! Runtime V8 failures propagate to the script execution boundary.
//!
//! # Object declaration model
//!
//! A `#[derive(WebApiObject)]` struct describes the JavaScript surface that must
@@ -241,6 +249,7 @@ extern crate self as moli_webapi_declare;
mod brand;
mod callback;
mod cross_origin;
mod declaration;
mod error;
mod interface;
@@ -251,7 +260,7 @@ mod value;
pub mod __private;
pub use interface::WebApiInterfaceDescriptor;
pub use moli_webapi_declare_derive::{WebApiFunctionTemplate, WebApiObject};
pub use moli_webapi_declare_derive::{WebApiCrossOrigin, WebApiFunctionTemplate, WebApiObject};
pub use v8;
/// Adapts a manual native constructor to the same identity initialization used
@@ -282,6 +291,10 @@ pub use brand::{
register_web_api_proxy, web_api_object_target, web_api_object_type,
};
pub use cross_origin::{
CrossOriginFunction, CrossOriginGetter, CrossOriginProperty, CrossOriginSetter,
WebApiCrossOriginDeclaration,
};
pub use declaration::{
DataPropertyDescriptorDeclaration, ObjectLiteralDeclaration, WebApiFunctionTemplateDeclaration,
WebApiObjectDeclaration,
+182
View File
@@ -0,0 +1,182 @@
use std::pin::pin;
use moli_v8_test_util::ensure_v8;
use moli_v8_util::{get_private_value, set_private_value, v8str};
use moli_webapi_declare::{WebApiCrossOrigin, WebApiCrossOriginDeclaration};
const VALUE: &str = "__crossOriginTestValue";
fn receiver<'s>(scope: &mut v8::PinScope<'s, '_>, object: v8::Local<'s, v8::Object>) -> bool {
get_private_value(scope, object, VALUE).is_some()
}
fn get<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Value>> {
get_private_value(scope, object, VALUE)
}
fn set<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
value: v8::Local<'s, v8::Value>,
) -> Option<()> {
let value = value.to_string(scope)?;
set_private_value(scope, object, VALUE, value.into());
Some(())
}
fn method<'s>(
_scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s>,
) {
rv.set(args.this().into());
}
// Fields are declaration metadata; no runtime object carries this state.
#[allow(dead_code)]
#[derive(WebApiCrossOrigin)]
#[webapi(plain, receiver = receiver)]
struct Members {
#[webapi(method, length = 1, callback = method)]
operation: (),
#[webapi(accessor_property, getter = get, setter = set)]
value: (),
#[webapi(accessor_property, setter = set)]
write_only: (),
}
fn eval<'s>(scope: &mut v8::PinScope<'s, '_>, source: &str) -> v8::Local<'s, v8::Value> {
let source = v8::String::new(scope, source).unwrap();
v8::Script::compile(scope, source, None)
.unwrap()
.run(scope)
.unwrap()
}
#[test]
fn native_members_share_templates_but_keep_functions_in_the_accessing_context() {
ensure_v8();
let mut isolate = v8::Isolate::new(Default::default());
let scope = pin!(v8::HandleScope::new(&mut isolate));
let scope = &mut scope.init();
let a = v8::Context::new(scope, Default::default());
let b = v8::Context::new(scope, Default::default());
let member = Members::cross_origin_property("operation").unwrap();
let function_a = {
let scope = &mut v8::ContextScope::new(scope, a);
let object = v8::Object::new(scope);
let value = v8str(scope, "initial");
set_private_value(scope, object, VALUE, value.into());
let function = member.get(scope, object).unwrap();
assert!(function.strict_equals(member.get(scope, object).unwrap()));
let global = a.global(scope);
global
.set(scope, v8str(scope, "object").into(), object.into())
.unwrap();
for (name, value) in [
("method", function),
("methodDescriptor", member.descriptor(scope).unwrap().into()),
(
"descriptor",
Members::cross_origin_property("value")
.unwrap()
.descriptor(scope)
.unwrap()
.into(),
),
(
"writeOnly",
Members::cross_origin_property("writeOnly")
.unwrap()
.descriptor(scope)
.unwrap()
.into(),
),
] {
global.set(scope, v8str(scope, name).into(), value).unwrap();
}
assert!(eval(scope, r#"
(() => {
function throwsTypeError(callback) { try { callback(); } catch (e) { return e instanceof TypeError; } return false; }
if (Object.getPrototypeOf(method) !== Function.prototype || method.name !== 'operation' || method.length !== 1 || Object.hasOwn(method, 'prototype')) return false;
if (method.call(object) !== object || !throwsTypeError(() => method.call({})) || !throwsTypeError(() => new method())) return false;
if (methodDescriptor.writable || methodDescriptor.enumerable || !methodDescriptor.configurable) return false;
if (descriptor.get.name !== 'get value' || descriptor.get.length !== 0 || descriptor.set.name !== 'set value' || descriptor.set.length !== 1 || descriptor.enumerable || !descriptor.configurable) return false;
if (descriptor.get.call(object) !== 'initial' || !throwsTypeError(() => descriptor.get.call({}))) return false;
let conversions = 0;
if (!throwsTypeError(() => descriptor.set.call({}, {toString() { conversions++; return 'bad'; }})) || conversions !== 0) return false;
const exception = new Error('conversion');
try { descriptor.set.call(object, {toString() { throw exception; }}); return false; } catch (e) { if (e !== exception) return false; }
descriptor.set.call(object, 'updated');
return descriptor.get.call(object) === 'updated' && writeOnly.get === undefined && writeOnly.set.name === 'set writeOnly';
})()
"#).is_true());
v8::Global::new(scope, function)
};
let scope = &mut v8::ContextScope::new(scope, b);
let object = v8::Object::new(scope);
let function_b = member.get(scope, object).unwrap();
let function_a = v8::Local::new(scope, &function_a);
assert!(!function_a.strict_equals(function_b));
let function_b = v8::Local::<v8::Object>::try_from(function_b).unwrap();
assert!(
function_b
.get_prototype(scope)
.unwrap()
.strict_equals(eval(scope, "Function.prototype"))
);
}
#[test]
fn native_member_failure_propagates_script_termination() {
ensure_v8();
let mut isolate = v8::Isolate::new(Default::default());
let scope = pin!(v8::HandleScope::new(&mut isolate));
let scope = &mut scope.init();
let context = v8::Context::new(scope, Default::default());
let scope = &mut v8::ContextScope::new(scope, context);
let member = Members::cross_origin_property("value").unwrap();
let object = v8::Object::new(scope);
let value = v8str(scope, "initial");
set_private_value(scope, object, VALUE, value.into());
let input = v8::Object::new(scope);
{
v8::tc_scope!(let scope, scope);
scope.terminate_execution();
assert!(member.set(scope, object, input.into()).is_none());
assert!(
scope.has_terminated(),
"termination must reach the execution boundary"
);
assert!(!scope.can_continue());
scope.cancel_terminate_execution();
}
assert!(member.set(scope, object, input.into()).is_some());
}
#[test]
fn isolate_template_cache_does_not_retain_an_accessing_context() {
ensure_v8();
let mut isolate = v8::Isolate::new(Default::default());
let weak = {
let scope = pin!(v8::HandleScope::new(&mut isolate));
let scope = &mut scope.init();
let context = v8::Context::new(scope, Default::default());
let scope = &mut v8::ContextScope::new(scope, context);
let object = v8::Object::new(scope);
assert!(
Members::cross_origin_property("operation")
.unwrap()
.get(scope, object)
.is_some()
);
v8::Weak::new(scope, context)
};
isolate.low_memory_notification();
let scope = pin!(v8::HandleScope::new(&mut isolate));
let scope = &mut scope.init();
assert!(weak.to_local(scope).is_none());
}