From 63569b00bc75f3eb3772b522d968ffcffcddd8e2 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 29 Sep 2026 09:10:53 +0800 Subject: [PATCH] feat(webrtc): implement ICE and data channel event constructors Expose RTCPeerConnectionIceEvent and RTCDataChannelEvent with native Event inheritance, dictionary conversion, branded payload validation and readonly attributes. Tests cover conversion order, exceptions, cross-realm values, subclassing, reinitialization and Window-only exposure. Validation: all branch checks passed. Summary [ 104.941s] 18927 tests run: 18927 passed (9 slow), 16 skipped --- .../assets/constructor_templates.rs | 14 +- .../src/context_bootstrap/events.rs | 1 + .../src/context_bootstrap/events/init.rs | 12 +- .../src/context_bootstrap/runtime_state.rs | 2 + .../src/context_bootstrap/specs/registry.rs | 8 + .../src/context_bootstrap/specs/types.rs | 2 + .../src/context_bootstrap/webrtc.rs | 7 + .../src/context_bootstrap/webrtc/events.rs | 220 ++++++++++++++++ .../context_bootstrap/webrtc/ice_candidate.rs | 7 + .../src/script_vm/tests/browser_api/mod.rs | 1 + .../tests/browser_api/webrtc_events.rs | 235 ++++++++++++++++++ moli-renderer-v8/src/web_api_interfaces.rs | 2 + .../src/worker/thread/tests/postmessage.rs | 15 ++ 13 files changed, 522 insertions(+), 4 deletions(-) create mode 100644 moli-renderer-v8/src/context_bootstrap/webrtc/events.rs create mode 100644 moli-renderer-v8/src/script_vm/tests/browser_api/webrtc_events.rs diff --git a/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs b/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs index 9346b77601..cec46ed2a4 100644 --- a/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs +++ b/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs @@ -61,7 +61,9 @@ use super::super::{ offline_audio_context_constructor_callback, }, webrtc::{ - rtc_ice_candidate_constructor_callback, rtc_peer_connection_constructor_callback, + rtc_data_channel_event_constructor_callback, rtc_ice_candidate_constructor_callback, + rtc_peer_connection_constructor_callback, + rtc_peer_connection_ice_event_constructor_callback, rtc_session_description_constructor_callback, }, websocket::{ @@ -600,6 +602,16 @@ pub(in crate::context_bootstrap) fn build_constructor_template<'s>( .length(1) .build(scope) } + ConstructorKind::RtcPeerConnectionIceEvent => { + v8::FunctionTemplate::builder(rtc_peer_connection_ice_event_constructor_callback) + .length(1) + .build(scope) + } + ConstructorKind::RtcDataChannelEvent => { + v8::FunctionTemplate::builder(rtc_data_channel_event_constructor_callback) + .length(2) + .build(scope) + } ConstructorKind::Navigator | ConstructorKind::WorkerNavigator | ConstructorKind::Permissions diff --git a/moli-renderer-v8/src/context_bootstrap/events.rs b/moli-renderer-v8/src/context_bootstrap/events.rs index 73659b7a6c..2c1ba1669f 100644 --- a/moli-renderer-v8/src/context_bootstrap/events.rs +++ b/moli-renderer-v8/src/context_bootstrap/events.rs @@ -349,6 +349,7 @@ pub(super) use base::{ event_target_getter_function, event_type_getter_function, set_event_dispatch_fields, set_event_initialized, }; +pub(in crate::context_bootstrap) use init::parse_event_init; pub(super) use kind::EventSubclassKind; pub(super) use methods::{ event_cancel_bubble_getter_function, event_cancel_bubble_setter_function, diff --git a/moli-renderer-v8/src/context_bootstrap/events/init.rs b/moli-renderer-v8/src/context_bootstrap/events/init.rs index d268a971ec..0cc8271cef 100644 --- a/moli-renderer-v8/src/context_bootstrap/events/init.rs +++ b/moli-renderer-v8/src/context_bootstrap/events/init.rs @@ -94,10 +94,16 @@ pub(super) fn read_event_init<'s>( scope: &mut v8::PinScope<'s, '_>, args: &v8::FunctionCallbackArguments<'s>, ) -> (bool, bool, bool) { - let Some(init) = webidl::optional_object_arg(args, 1) else { - return (false, false, false); + parse_event_init(scope, webidl::optional_object_arg(args, 1)).unwrap_or((false, false, false)) +} + +pub(in crate::context_bootstrap) fn parse_event_init<'s>( + scope: &mut v8::PinScope<'s, '_>, + init: Option>, +) -> Result<(bool, bool, bool), webidl::WebIdlError> { + let Some(init) = init else { + return Ok((false, false, false)); }; webidl::parse_dictionary_object::(scope, init) .map(|parsed| (parsed.bubbles, parsed.cancelable, parsed.composed)) - .unwrap_or((false, false, false)) } diff --git a/moli-renderer-v8/src/context_bootstrap/runtime_state.rs b/moli-renderer-v8/src/context_bootstrap/runtime_state.rs index 9cd9305503..2d2a88a4a8 100644 --- a/moli-renderer-v8/src/context_bootstrap/runtime_state.rs +++ b/moli-renderer-v8/src/context_bootstrap/runtime_state.rs @@ -1933,6 +1933,8 @@ pub(crate) fn finish_context_bootstrap( ("RTCPeerConnection", "RTCPeerConnection"), ("RTCIceCandidate", "RTCIceCandidate"), ("RTCSessionDescription", "RTCSessionDescription"), + ("RTCPeerConnectionIceEvent", "RTCPeerConnectionIceEvent"), + ("RTCDataChannelEvent", "RTCDataChannelEvent"), ("RTCRtpReceiver", "RTCRtpReceiver"), ("RTCDataChannel", "RTCDataChannel"), ("Blob", "Blob"), diff --git a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs index 32550392d4..1822a1e6f3 100644 --- a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs +++ b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs @@ -867,6 +867,14 @@ const CONSTRUCTOR_SPECS_AFTER_STREAMS: &[ConstructorSpec] = &[ interface: web_api_interfaces::RTCSessionDescription::DESCRIPTOR, kind: ConstructorKind::RtcSessionDescription, }, + ConstructorSpec { + interface: web_api_interfaces::RTCPeerConnectionIceEvent::DESCRIPTOR, + kind: ConstructorKind::RtcPeerConnectionIceEvent, + }, + ConstructorSpec { + interface: web_api_interfaces::RTCDataChannelEvent::DESCRIPTOR, + kind: ConstructorKind::RtcDataChannelEvent, + }, ConstructorSpec { interface: web_api_interfaces::RTCRtpReceiver::DESCRIPTOR, kind: ConstructorKind::Illegal, diff --git a/moli-renderer-v8/src/context_bootstrap/specs/types.rs b/moli-renderer-v8/src/context_bootstrap/specs/types.rs index 93db2156af..93a222a1eb 100644 --- a/moli-renderer-v8/src/context_bootstrap/specs/types.rs +++ b/moli-renderer-v8/src/context_bootstrap/specs/types.rs @@ -90,6 +90,8 @@ pub(in crate::context_bootstrap) enum ConstructorKind { RtcPeerConnection, RtcIceCandidate, RtcSessionDescription, + RtcPeerConnectionIceEvent, + RtcDataChannelEvent, Navigator, WorkerNavigator, Permissions, diff --git a/moli-renderer-v8/src/context_bootstrap/webrtc.rs b/moli-renderer-v8/src/context_bootstrap/webrtc.rs index 3392fb8ccb..6a08991f6b 100644 --- a/moli-renderer-v8/src/context_bootstrap/webrtc.rs +++ b/moli-renderer-v8/src/context_bootstrap/webrtc.rs @@ -5,9 +5,13 @@ use crate::util::{ use crate::web_api_interfaces; use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; +mod events; mod ice_candidate; mod ice_candidate_parser; mod session_description; +pub(in crate::context_bootstrap) use events::{ + rtc_data_channel_event_constructor_callback, rtc_peer_connection_ice_event_constructor_callback, +}; pub(in crate::context_bootstrap) use ice_candidate::rtc_ice_candidate_constructor_callback; pub(in crate::context_bootstrap) use session_description::rtc_session_description_constructor_callback; @@ -203,6 +207,9 @@ pub(in crate::context_bootstrap) fn install_webrtc_template_bindings<'s>( ) { let prototype = template.prototype_template(scope); match interface_name { + "RTCPeerConnectionIceEvent" | "RTCDataChannelEvent" => { + events::install_event_template_bindings(scope, template, interface_name) + } "RTCIceCandidate" => { ice_candidate::install_ice_candidate_template_bindings(scope, template) } diff --git a/moli-renderer-v8/src/context_bootstrap/webrtc/events.rs b/moli-renderer-v8/src/context_bootstrap/webrtc/events.rs new file mode 100644 index 0000000000..23b2d54df6 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/webrtc/events.rs @@ -0,0 +1,220 @@ +use super::{ice_candidate::ice_candidate_receiver_branded, rtc_data_channel_receiver_branded}; +use crate::web_api_interfaces; +use crate::{ + context_bootstrap::events::{initialize_event_object_with_type, parse_event_init}, + util::{ + apply_webidl_constructor_prototype_fallback, callback_data_index_value, callback_data_item, + get_private_value, throw_type_error, v8str, + }, + webidl::{self, WebIdlConverter}, +}; +use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; + +const ICE_EVENT_CANDIDATE_SLOT: &str = "__moliRtcIceEventCandidate"; +const ICE_EVENT_URL_SLOT: &str = "__moliRtcIceEventUrl"; +const DATA_CHANNEL_EVENT_CHANNEL_SLOT: &str = "__moliRtcDataChannelEventChannel"; +const EVENT_MEMBER_SLOTS: &[&str] = &[ + ICE_EVENT_CANDIDATE_SLOT, + ICE_EVENT_URL_SLOT, + DATA_CHANNEL_EVENT_CHANNEL_SLOT, +]; + +#[derive(WebApiObject)] +#[webapi(interface = web_api_interfaces::RTCPeerConnectionIceEvent)] +struct IceEventObjectDeclaration<'scope> { + #[webapi(data_property, enumerable)] + composed: bool, + #[webapi(slot = ICE_EVENT_CANDIDATE_SLOT)] + candidate: v8::Local<'scope, v8::Value>, + #[webapi(slot = ICE_EVENT_URL_SLOT)] + url: v8::Local<'scope, v8::Value>, +} + +#[derive(WebApiObject)] +#[webapi(interface = web_api_interfaces::RTCDataChannelEvent)] +struct DataChannelEventObjectDeclaration<'scope> { + #[webapi(data_property, enumerable)] + composed: bool, + #[webapi(slot = DATA_CHANNEL_EVENT_CHANNEL_SLOT)] + channel: v8::Local<'scope, v8::Value>, +} + +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::RTCPeerConnectionIceEvent, receiver, enumerable)] +struct IceEventPrototypeDeclaration { + #[webapi(accessor_property, getter = event_member_getter, data = callback_data_index_value(scope, 0))] + candidate: (), + #[webapi(accessor_property, getter = event_member_getter, data = callback_data_index_value(scope, 1))] + url: (), +} + +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::RTCDataChannelEvent, receiver, enumerable)] +struct DataChannelEventPrototypeDeclaration { + #[webapi(accessor_property, getter = event_member_getter, data = callback_data_index_value(scope, 2))] + channel: (), +} + +pub(super) fn install_event_template_bindings<'s>( + scope: &mut v8::PinScope<'s, '_, ()>, + template: v8::Local<'s, v8::FunctionTemplate>, + interface_name: &str, +) { + let prototype = template.prototype_template(scope); + match interface_name { + "RTCPeerConnectionIceEvent" => { + IceEventPrototypeDeclaration::initialize_prototype_template(scope, prototype) + } + "RTCDataChannelEvent" => { + DataChannelEventPrototypeDeclaration::initialize_prototype_template(scope, prototype) + } + _ => unreachable!("unsupported WebRTC event interface"), + } +} + +pub(in crate::context_bootstrap) fn rtc_peer_connection_ice_event_constructor_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + rv: v8::ReturnValue<'s, v8::Value>, +) { + construct_event(scope, args, rv, false); +} + +pub(in crate::context_bootstrap) fn rtc_data_channel_event_constructor_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + rv: v8::ReturnValue<'s, v8::Value>, +) { + construct_event(scope, args, rv, true); +} + +fn construct_event<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, + is_data_channel_event: bool, +) { + let (name, required_arguments, member) = if is_data_channel_event { + ("RTCDataChannelEvent", 2, "channel") + } else { + ("RTCPeerConnectionIceEvent", 1, "candidate") + }; + if !args.is_construct_call() || args.length() < required_arguments { + throw_type_error( + scope, + &format!("{name} requires 'new' and {required_arguments} argument(s)."), + ); + return; + } + if args.get(0).is_symbol() { + throw_type_error(scope, "Cannot convert a Symbol to a DOMString."); + return; + } + // Retain the DOMString in V8 instead of replacing lone UTF-16 surrogates. + let Some(event_type) = args.get(0).to_string(scope) else { + return; + }; + let init = match webidl::dictionary_arg(&args, 1, webidl::Context::argument(name, 2)) { + Ok(init) => init, + Err(error) => { + webidl::throw_error(scope, &error); + return; + } + }; + // Convert inherited EventInit members before the subclass's own members. + let (bubbles, cancelable, composed) = match parse_event_init(scope, init) { + Ok(flags) => flags, + Err(error) => { + webidl::throw_error(scope, &error); + return; + } + }; + let value = if let Some(init) = init { + let Some(value) = init.get(scope, v8str(scope, member).into()) else { + return; + }; + value + } else { + v8::undefined(scope).into() + }; + let value = if !is_data_channel_event && value.is_null_or_undefined() { + v8::null(scope).into() + } else { + let branded = v8::Local::::try_from(value) + .ok() + .is_some_and(|object| { + if is_data_channel_event { + rtc_data_channel_receiver_branded(scope, object) + } else { + ice_candidate_receiver_branded(scope, object) + } + }); + if !branded { + throw_type_error( + scope, + &format!( + "{name}.{member} must be a genuine {} object.", + if is_data_channel_event { + "RTCDataChannel" + } else { + "RTCIceCandidate" + } + ), + ); + return; + } + value + }; + let mut url = v8::null(scope).into(); + if !is_data_channel_event && let Some(init) = init { + let Some(raw) = init.get(scope, v8str(scope, "url").into()) else { + return; + }; + if !raw.is_null_or_undefined() { + let converted = match webidl::UsvString::convert( + scope, + raw, + webidl::Context::member("RTCPeerConnectionIceEventInit", "url"), + &Default::default(), + ) { + Ok(value) => value.0, + Err(error) => { + webidl::throw_error(scope, &error); + return; + } + }; + let Some(converted) = v8::String::new(scope, &converted) else { + return; + }; + url = converted.into(); + } + } + let event = args.this(); + initialize_event_object_with_type(scope, event, event_type, bubbles, cancelable); + let initialized = if is_data_channel_event { + DataChannelEventObjectDeclaration::new(composed, value).initialize(scope, event) + } else { + IceEventObjectDeclaration::new(composed, value, url).initialize(scope, event) + }; + if initialized.is_err() { + return; + } + apply_webidl_constructor_prototype_fallback(scope, event, args.new_target(), name); + rv.set(event.into()); +} + +fn event_member_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let Some(slot) = callback_data_item(scope, &args, EVENT_MEMBER_SLOTS, "WebRTC event attribute") + else { + return; + }; + if let Some(value) = get_private_value(scope, args.this(), slot) { + rv.set(value); + } else { + throw_type_error(scope, "Illegal invocation"); + } +} diff --git a/moli-renderer-v8/src/context_bootstrap/webrtc/ice_candidate.rs b/moli-renderer-v8/src/context_bootstrap/webrtc/ice_candidate.rs index 4dae57aa3a..d48729509c 100644 --- a/moli-renderer-v8/src/context_bootstrap/webrtc/ice_candidate.rs +++ b/moli-renderer-v8/src/context_bootstrap/webrtc/ice_candidate.rs @@ -202,6 +202,13 @@ fn parse_candidate_init<'s>( Some(values) } +pub(super) fn ice_candidate_receiver_branded<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, +) -> bool { + web_api_interfaces::RTCIceCandidate::is_instance(scope, receiver) +} + fn candidate_values<'s>( scope: &mut v8::PinScope<'s, '_>, receiver: v8::Local<'s, v8::Object>, diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs index 774887d010..860caba5a5 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs @@ -53,6 +53,7 @@ mod trusted_types; mod value_events; mod web_audio; mod webrtc; +mod webrtc_events; mod window_legacy_objects; mod worker_listener_invocation; diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/webrtc_events.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/webrtc_events.rs new file mode 100644 index 0000000000..11ef1720b6 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/webrtc_events.rs @@ -0,0 +1,235 @@ +use super::*; + +#[test] +fn webrtc_ice_event_constructor_preserves_candidates_and_converts_nullable_urls() { + let mut vm = new_storage_test_vm("http://webrtc-ice-event.test/"); + let result = vm + .eval( + r#" + (() => { + const rejects = fn => { + try { fn(); return false; } catch (error) { return error instanceof TypeError; } + }; + const candidate = new RTCIceCandidate({sdpMid: 'audio'}); + const rawType = 'ice\ud800\0\udc00'; + const event = new RTCPeerConnectionIceEvent(rawType, {candidate, url: 'turn:\ud800'}); + const checks = [RTCPeerConnectionIceEvent.length === 1, + rejects(() => new RTCPeerConnectionIceEvent()), + rejects(() => RTCPeerConnectionIceEvent('ice')), + rejects(() => new RTCPeerConnectionIceEvent(Symbol())), + event.type === rawType, event.candidate === candidate, event.url === 'turn:\ufffd', + new RTCPeerConnectionIceEvent(undefined).type === 'undefined', + new RTCPeerConnectionIceEvent(null).type === 'null']; + for (const init of [undefined, null, {}, {candidate: null, url: null}, + {candidate: undefined, url: undefined}]) { + const empty = new RTCPeerConnectionIceEvent('ice', init); + checks.push(empty.candidate === null, empty.url === null, + !empty.bubbles, !empty.cancelable, !empty.composed); + } + for (const url of ['', false, 4, 5n, ['not a URL']]) { + checks.push(new RTCPeerConnectionIceEvent('ice', {url}).url === String(url)); + } + for (const init of [true, 1, 'init', Symbol(), 1n, {url: Symbol()}, + {candidate: {}}, {candidate: false}, {candidate: 1}, + {candidate: Object.create(candidate)}, {candidate: new Proxy(candidate, {})}]) { + checks.push(rejects(() => new RTCPeerConnectionIceEvent('ice', init))); + } + Object.setPrototypeOf(candidate, null); + checks.push(new RTCPeerConnectionIceEvent('ice', {candidate}).candidate === candidate); + return checks.every(Boolean) || JSON.stringify(checks); + })() + "#, + ) + .expect( + "ICE events should preserve candidate identity and the DOMString/USVString distinction", + ); + assert_eq!(result, "true"); +} + +#[test] +fn webrtc_data_channel_event_requires_a_genuine_channel_and_checks_arity_first() { + let mut vm = new_storage_test_vm("https://webrtc-data-channel-event.test/"); + let result = vm.eval(r#" + (() => { + const rejects = fn => { + try { fn(); return false; } catch (error) { return error instanceof TypeError; } + }; + const peer = new RTCPeerConnection(); + const channel = peer.createDataChannel('test'); + const event = new RTCDataChannelEvent('channel', {channel}); + let converted = false; + const checks = [RTCDataChannelEvent.length === 2, event.channel === channel, + rejects(() => RTCDataChannelEvent('channel', {channel})), + rejects(() => new RTCDataChannelEvent()), + rejects(() => new RTCDataChannelEvent({toString() { converted = true; return 'channel'; }})), + !converted]; + for (const init of [undefined, null, {}, {channel: null}, {channel: undefined}, + false, 1, 'init', Symbol(), 1n, {channel: {}}, + {channel: Object.create(RTCDataChannel.prototype)}, + {channel: Object.create(channel)}, {channel: new Proxy(channel, {})}, + {channel: new RTCIceCandidate({sdpMid: 'audio'})}]) { + checks.push(rejects(() => new RTCDataChannelEvent('channel', init))); + } + Object.defineProperty(RTCDataChannel, Symbol.hasInstance, { + configurable: true, value() { throw new Error('must not use instanceof'); } + }); + try { + checks.push(rejects(() => new RTCDataChannelEvent('channel', {channel: {}})), + new RTCDataChannelEvent('channel', {channel}).channel === channel); + } finally { delete RTCDataChannel[Symbol.hasInstance]; } + Object.setPrototypeOf(channel, null); + checks.push(new RTCDataChannelEvent('channel', {channel}).channel === channel); + peer.close(); + return checks.every(Boolean) || JSON.stringify(checks); + })() + "#).expect("data channel events should validate arity before conversion and use native interface branding"); + assert_eq!(result, "true"); +} + +#[test] +fn webrtc_event_dictionary_conversion_reads_inherited_members_first_and_preserves_exceptions() { + let mut vm = new_storage_test_vm("https://webrtc-event-conversion.test/"); + let result = vm.eval(r#" + (() => { + const candidate = new RTCIceCandidate({sdpMid: 'audio'}); + const peer = new RTCPeerConnection(); + const channel = peer.createDataChannel('test'); + const checks = []; + for (const [Ctor, ownMembers] of [ + [RTCPeerConnectionIceEvent, ['candidate', 'url']], [RTCDataChannelEvent, ['channel']] + ]) { + const names = ['bubbles', 'cancelable', 'composed', ...ownMembers]; + const reads = []; + const dictionary = Object.create({bubbles: [], cancelable: 1, composed: 'yes', + candidate, channel, url: 'url'}); + const event = new Ctor({toString() { reads.push('type'); return 'converted'; }}, + new Proxy(dictionary, {get(object, name) { reads.push(name); return object[name]; }})); + checks.push(reads.join(',') === ['type', ...names].join(','), + event.type === 'converted', event.bubbles, event.cancelable, event.composed); + const sentinel = new RangeError('original getter failure'); + for (const name of names) { + const bad = {candidate, channel}; + Object.defineProperty(bad, name, {get() { throw sentinel; }}); + try { new Ctor('event', bad); checks.push(false); } + catch (error) { checks.push(error === sentinel); } + } + try { new Ctor({toString() { throw sentinel; }}, {candidate, channel}); checks.push(false); } + catch (error) { checks.push(error === sentinel); } + } + const sentinel = new RangeError('URL conversion'); + try { + new RTCPeerConnectionIceEvent('ice', {url: {toString() { throw sentinel; }}}); + checks.push(false); + } catch (error) { checks.push(error === sentinel); } + let urlRead = false; + try { + new RTCPeerConnectionIceEvent('ice', {candidate: {}, get url() { urlRead = true; return ''; }}); + checks.push(false); + } catch (error) { checks.push(error instanceof TypeError, !urlRead); } + Object.defineProperty(Object.prototype, 'bubbles', { + configurable: true, get() { throw sentinel; } + }); + try { + checks.push(new RTCPeerConnectionIceEvent('ice', null).candidate === null); + try { new RTCDataChannelEvent('channel', null); checks.push(false); } + catch (error) { checks.push(error instanceof TypeError); } + } finally { delete Object.prototype.bubbles; } + peer.close(); + return checks.every(Boolean) || JSON.stringify(checks); + })() + "#).expect("WebRTC event dictionaries should convert EventInit first without swallowing getters or exceptions"); + assert_eq!(result, "true"); +} + +#[test] +fn webrtc_event_members_are_readonly_branded_and_survive_dispatch_and_reinitialization() { + let mut vm = new_storage_test_vm("https://webrtc-event-dispatch.test/"); + let result = vm.eval(r#" + (() => { + const candidate = new RTCIceCandidate({sdpMid: 'audio'}); + const peer = new RTCPeerConnection(); + const channel = peer.createDataChannel('test'); + const checks = []; + const rejects = fn => { + try { fn(); return false; } catch (error) { return error instanceof TypeError; } + }; + for (const [Ctor, members] of [ + [RTCPeerConnectionIceEvent, {candidate, url: 'turn:server'}], [RTCDataChannelEvent, {channel}] + ]) { + const event = new Ctor('test', {...members, bubbles: true, cancelable: true, composed: true}); + checks.push(event instanceof Ctor, event instanceof Event, + Object.getPrototypeOf(Ctor.prototype) === Event.prototype, + Object.prototype.toString.call(event) === '[object ' + Ctor.name + ']', + event.isTrusted === false, typeof event.timeStamp === 'number'); + for (const [name, original] of Object.entries(members)) { + const descriptor = Object.getOwnPropertyDescriptor(Ctor.prototype, name); + if (!descriptor) return 'missing:' + name; + checks.push(!Object.hasOwn(event, name), descriptor.enumerable, descriptor.configurable, + descriptor.get.name === 'get ' + name, descriptor.get.length === 0, !descriptor.set); + event[name] = 'changed'; + checks.push(event[name] === original, rejects(() => { 'use strict'; event[name] = null; })); + for (const fake of [undefined, null, {}, Ctor.prototype, new Event('test'), + Object.create(event), new Proxy(event, {})]) { + checks.push(rejects(() => descriptor.get.call(fake))); + } + } + const target = new EventTarget(); + let delivered = false; + target.addEventListener('test', received => { + delivered = received === event && received.target === target && received.currentTarget === target; + received.preventDefault(); + }); + checks.push(target.dispatchEvent(event) === false, delivered, event.defaultPrevented, + event.isTrusted === false, event.currentTarget === null); + event.initEvent('again', false, false); + checks.push(event.type === 'again', !event.defaultPrevented, !event.bubbles, !event.cancelable); + for (const [name, original] of Object.entries(members)) checks.push(event[name] === original); + } + peer.close(); + return checks.every(Boolean) || JSON.stringify(checks); + })() + "#).expect("WebRTC event attributes should retain native identity through normal Event operations"); + assert_eq!(result, "true"); +} + +#[test] +fn webrtc_event_constructors_support_subclasses_foreign_members_and_new_target_realms() { + let mut vm = new_parsed_test_vm( + "https://webrtc-event-realms.test/", + "", + ); + let result = vm.eval(r#" + (() => { + const child = document.querySelector('iframe').contentWindow; + const foreignPeer = new child.RTCPeerConnection(); + const candidate = new child.RTCIceCandidate({sdpMid: 'foreign'}); + const channel = foreignPeer.createDataChannel('foreign'); + const checks = []; + for (const [name, member, value] of [ + ['RTCPeerConnectionIceEvent', 'candidate', candidate], ['RTCDataChannelEvent', 'channel', channel] + ]) { + const Ctor = window[name]; + const init = {[member]: value}; + class Derived extends Ctor {} + const derived = new Derived('event', init); + const foreign = new child[name]('event', init); + const newTarget = child.Function(''); + newTarget.prototype = 0; + let prototypeReads = 0; + const target = new Proxy(newTarget, {get(object, name) { + if (name === 'prototype') prototypeReads++; + return Reflect.get(object, name); + }}); + const fallback = Reflect.construct(Ctor, ['event', init], target); + checks.push(derived instanceof Derived, derived instanceof Ctor, + derived[member] === value, foreign[member] === value, + Object.getOwnPropertyDescriptor(Ctor.prototype, member).get.call(foreign) === value, + Object.getPrototypeOf(fallback) === child[name].prototype, fallback[member] === value, + prototypeReads === 1); + } + foreignPeer.close(); + return checks.every(Boolean) || JSON.stringify(checks); + })() + "#).expect("WebRTC event constructors should preserve genuine cross-realm members and NewTarget semantics"); + assert_eq!(result, "true"); +} diff --git a/moli-renderer-v8/src/web_api_interfaces.rs b/moli-renderer-v8/src/web_api_interfaces.rs index ee32e318c3..e8181b3372 100644 --- a/moli-renderer-v8/src/web_api_interfaces.rs +++ b/moli-renderer-v8/src/web_api_interfaces.rs @@ -353,6 +353,8 @@ interfaces! { RTCDataChannel: EventTarget; RTCIceCandidate; RTCPeerConnection: EventTarget; + RTCPeerConnectionIceEvent: Event; + RTCDataChannelEvent: Event; RTCRtpReceiver; RTCSessionDescription; RadioNodeList: NodeList; diff --git a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs index da05687198..bdb53b404a 100644 --- a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs +++ b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs @@ -71,6 +71,21 @@ async fn worker_console_capture_does_not_serialize_or_coerce_page_objects() { } } +#[tokio::test] +async fn worker_does_not_expose_window_only_webrtc_event_interfaces() { + ensure_v8(); + let mut handle = spawn_worker( + r#"postMessage([typeof RTCPeerConnectionIceEvent, typeof RTCDataChannelEvent]); close();"# + .into(), + "test://webrtc-event-exposure".into(), + ); + let msg = timeout(TIMEOUT, handle.recv()) + .await + .expect("timed out waiting for worker WebRTC event exposure") + .expect("channel closed"); + assert_eq!(expect_post_json(msg), r#"["undefined","undefined"]"#); +} + #[tokio::test] async fn worker_postmessage_to_parent() { ensure_v8();