From e565e3c5579f575c3128438701e3ec03738a50c1 Mon Sep 17 00:00:00 2001 From: Shannon Booth Date: Sat, 23 Nov 2024 20:45:26 +1300 Subject: [PATCH] LibWeb: Implement BroadcastChannel.postMessage The repository being in static storage is a bit of a hodgepodge, but in line with how our current storage partitioning is done. We should eventually move this, along with other across browsing context APIs to a proper location at a later stage. But for now, this makes progress on the meat of the BroadcastChannel API. --- Libraries/LibWeb/HTML/BroadcastChannel.cpp | 154 +++++++++++++++++- Libraries/LibWeb/HTML/BroadcastChannel.h | 5 + Libraries/LibWeb/HTML/BroadcastChannel.idl | 2 +- .../broadcastchannel/basics.any.txt | 17 ++ .../broadcastchannel/interface.any.txt | 23 +++ .../broadcastchannel/basics.any.html | 15 ++ .../broadcastchannel/basics.any.js | 128 +++++++++++++++ .../broadcastchannel/interface.any.html | 15 ++ .../broadcastchannel/interface.any.js | 65 ++++++++ 9 files changed, 422 insertions(+), 2 deletions(-) create mode 100644 Tests/LibWeb/Text/expected/wpt-import/webmessaging/broadcastchannel/basics.any.txt create mode 100644 Tests/LibWeb/Text/expected/wpt-import/webmessaging/broadcastchannel/interface.any.txt create mode 100644 Tests/LibWeb/Text/input/wpt-import/webmessaging/broadcastchannel/basics.any.html create mode 100644 Tests/LibWeb/Text/input/wpt-import/webmessaging/broadcastchannel/basics.any.js create mode 100644 Tests/LibWeb/Text/input/wpt-import/webmessaging/broadcastchannel/interface.any.html create mode 100644 Tests/LibWeb/Text/input/wpt-import/webmessaging/broadcastchannel/interface.any.js diff --git a/Libraries/LibWeb/HTML/BroadcastChannel.cpp b/Libraries/LibWeb/HTML/BroadcastChannel.cpp index b421898dd69..ccd7a9d5c2f 100644 --- a/Libraries/LibWeb/HTML/BroadcastChannel.cpp +++ b/Libraries/LibWeb/HTML/BroadcastChannel.cpp @@ -1,5 +1,6 @@ /* * Copyright (c) 2024, Jamie Mansfield + * Copyright (c) 2024, Shannon Booth * * SPDX-License-Identifier: BSD-2-Clause */ @@ -9,14 +10,62 @@ #include #include #include +#include +#include +#include +#include namespace Web::HTML { +class BroadcastChannelRepository { +public: + void register_channel(GC::Root); + void unregister_channel(GC::Ref); + Vector> const& registered_channels_for_key(StorageAPI::StorageKey) const; + +private: + HashMap>> m_channels; +}; + +void BroadcastChannelRepository::register_channel(GC::Root channel) +{ + auto storage_key = Web::StorageAPI::obtain_a_storage_key_for_non_storage_purposes(relevant_settings_object(*channel)); + + auto maybe_channels = m_channels.find(storage_key); + if (maybe_channels != m_channels.end()) { + maybe_channels->value.append(move(channel)); + return; + } + + Vector> channels; + channels.append(move(channel)); + m_channels.set(storage_key, move(channels)); +} + +void BroadcastChannelRepository::unregister_channel(GC::Ref channel) +{ + auto storage_key = Web::StorageAPI::obtain_a_storage_key_for_non_storage_purposes(relevant_settings_object(channel)); + auto& relevant_channels = m_channels.get(storage_key).value(); + relevant_channels.remove_first_matching([&](auto c) { return c == channel; }); +} + +Vector> const& BroadcastChannelRepository::registered_channels_for_key(StorageAPI::StorageKey key) const +{ + auto maybe_channels = m_channels.get(key); + VERIFY(maybe_channels.has_value()); + return maybe_channels.value(); +} + +// FIXME: This should not be static, and live at a storage partitioned level of the user agent. +static BroadcastChannelRepository s_broadcast_channel_repository; + GC_DEFINE_ALLOCATOR(BroadcastChannel); GC::Ref BroadcastChannel::construct_impl(JS::Realm& realm, FlyString const& name) { - return realm.create(realm, name); + auto channel = realm.create(realm, name); + s_broadcast_channel_repository.register_channel(channel); + return channel; } BroadcastChannel::BroadcastChannel(JS::Realm& realm, FlyString const& name) @@ -31,11 +80,114 @@ void BroadcastChannel::initialize(JS::Realm& realm) WEB_SET_PROTOTYPE_FOR_INTERFACE(BroadcastChannel); } +// https://html.spec.whatwg.org/multipage/web-messaging.html#eligible-for-messaging +bool BroadcastChannel::is_eligible_for_messaging() const +{ + // A BroadcastChannel object is said to be eligible for messaging when its relevant global object is either: + auto const& global = relevant_global_object(*this); + + // * a Window object whose associated Document is fully active, or + if (is(global)) + return static_cast(global).associated_document().is_fully_active(); + + // * a WorkerGlobalScope object whose closing flag is false and whose worker is not a suspendable worker. + // FIXME: Suspendable worker + if (is(global)) + return !static_cast(global).is_closing(); + + return false; +} + +// https://html.spec.whatwg.org/multipage/web-messaging.html#dom-broadcastchannel-postmessage +WebIDL::ExceptionOr BroadcastChannel::post_message(JS::Value message) +{ + auto& vm = this->vm(); + + // 1. If this is not eligible for messaging, then return. + if (!is_eligible_for_messaging()) + return {}; + + // 2. If this's closed flag is true, then throw an "InvalidStateError" DOMException. + if (m_closed_flag) + return WebIDL::InvalidStateError::create(realm(), "BroadcastChannel.postMessage() on a closed channel"_string); + + // 3. Let serialized be StructuredSerialize(message). Rethrow any exceptions. + auto serialized = TRY(structured_serialize(vm, message)); + + // 4. Let sourceOrigin be this's relevant settings object's origin. + auto source_origin = relevant_settings_object(*this).origin(); + + // 5. Let sourceStorageKey be the result of running obtain a storage key for non-storage purposes with this's relevant settings object. + auto source_storage_key = Web::StorageAPI::obtain_a_storage_key_for_non_storage_purposes(relevant_settings_object(*this)); + + // 6. Let destinations be a list of BroadcastChannel objects that match the following criteria: + GC::MarkedVector> destinations(vm.heap()); + + // * The result of running obtain a storage key for non-storage purposes with their relevant settings object equals sourceStorageKey. + auto same_origin_broadcast_channels = s_broadcast_channel_repository.registered_channels_for_key(source_storage_key); + + for (auto const& channel : same_origin_broadcast_channels) { + // * They are eligible for messaging. + if (!channel->is_eligible_for_messaging()) + continue; + + // * Their channel name is this's channel name. + if (channel->name() != name()) + continue; + + destinations.append(*channel); + } + + // 7. Remove source from destinations. + destinations.remove_first_matching([&](auto destination) { return destination == this; }); + + // FIXME: 8. Sort destinations such that all BroadcastChannel objects whose relevant agents are the same are sorted in creation order, oldest first. + // (This does not define a complete ordering. Within this constraint, user agents may sort the list in any implementation-defined manner.) + + // 9. For each destination in destinations, queue a global task on the DOM manipulation task source given destination's relevant global object to perform the following steps: + for (auto destination : destinations) { + HTML::queue_global_task(HTML::Task::Source::DOMManipulation, relevant_global_object(destination), GC::create_function(vm.heap(), [&vm, serialized, destination, source_origin] { + // 1. If destination's closed flag is true, then abort these steps. + if (destination->m_closed_flag) + return; + + // 2. Let targetRealm be destination's relevant realm. + auto& target_realm = relevant_realm(destination); + + // 3. Let data be StructuredDeserialize(serialized, targetRealm). + // If this throws an exception, catch it, fire an event named messageerror at destination, using MessageEvent, with the + // origin attribute initialized to the serialization of sourceOrigin, and then abort these steps. + auto data_or_error = structured_deserialize(vm, serialized, target_realm); + if (data_or_error.is_exception()) { + MessageEventInit event_init {}; + event_init.origin = source_origin.serialize(); + auto event = MessageEvent::create(target_realm, HTML::EventNames::messageerror, event_init); + event->set_is_trusted(true); + destination->dispatch_event(event); + return; + } + + // 4. Fire an event named message at destination, using MessageEvent, with the data attribute initialized to data and the + // origin attribute initialized to the serialization of sourceOrigin. + MessageEventInit event_init {}; + event_init.data = data_or_error.release_value(); + event_init.origin = source_origin.serialize(); + auto event = MessageEvent::create(target_realm, HTML::EventNames::message, event_init); + event->set_is_trusted(true); + destination->dispatch_event(event); + })); + } + + return {}; +} + // https://html.spec.whatwg.org/multipage/web-messaging.html#dom-broadcastchannel-close void BroadcastChannel::close() { // The close() method steps are to set this's closed flag to true. m_closed_flag = true; + + s_broadcast_channel_repository.unregister_channel(*this); } // https://html.spec.whatwg.org/multipage/web-messaging.html#handler-broadcastchannel-onmessage diff --git a/Libraries/LibWeb/HTML/BroadcastChannel.h b/Libraries/LibWeb/HTML/BroadcastChannel.h index 19ee7033bbc..87fb8280ef3 100644 --- a/Libraries/LibWeb/HTML/BroadcastChannel.h +++ b/Libraries/LibWeb/HTML/BroadcastChannel.h @@ -1,5 +1,6 @@ /* * Copyright (c) 2024, Jamie Mansfield + * Copyright (c) 2024, Shannon Booth * * SPDX-License-Identifier: BSD-2-Clause */ @@ -24,6 +25,8 @@ public: return m_channel_name; } + WebIDL::ExceptionOr post_message(JS::Value message); + void close(); void set_onmessage(GC::Ptr); @@ -36,6 +39,8 @@ private: virtual void initialize(JS::Realm&) override; + bool is_eligible_for_messaging() const; + FlyString m_channel_name; bool m_closed_flag { false }; }; diff --git a/Libraries/LibWeb/HTML/BroadcastChannel.idl b/Libraries/LibWeb/HTML/BroadcastChannel.idl index 6b415bb7d48..04fec6196cf 100644 --- a/Libraries/LibWeb/HTML/BroadcastChannel.idl +++ b/Libraries/LibWeb/HTML/BroadcastChannel.idl @@ -7,7 +7,7 @@ interface BroadcastChannel : EventTarget { constructor(DOMString name); readonly attribute DOMString name; - [FIXME] undefined postMessage(any message); + undefined postMessage(any message); undefined close(); attribute EventHandler onmessage; attribute EventHandler onmessageerror; diff --git a/Tests/LibWeb/Text/expected/wpt-import/webmessaging/broadcastchannel/basics.any.txt b/Tests/LibWeb/Text/expected/wpt-import/webmessaging/broadcastchannel/basics.any.txt new file mode 100644 index 00000000000..3b65cd06f92 --- /dev/null +++ b/Tests/LibWeb/Text/expected/wpt-import/webmessaging/broadcastchannel/basics.any.txt @@ -0,0 +1,17 @@ +Summary + +Harness status: OK + +Rerun + +Found 7 tests + +7 Pass +Details +Result Test Name MessagePass BroadcastChannel constructor called as normal function +Pass postMessage results in correct event +Pass messages are delivered in port creation order +Pass messages aren't delivered to a closed port +Pass messages aren't delivered to a port closed after calling postMessage. +Pass closing and creating channels during message delivery works correctly +Pass Closing a channel in onmessage prevents already queued tasks from firing onmessage events \ No newline at end of file diff --git a/Tests/LibWeb/Text/expected/wpt-import/webmessaging/broadcastchannel/interface.any.txt b/Tests/LibWeb/Text/expected/wpt-import/webmessaging/broadcastchannel/interface.any.txt new file mode 100644 index 00000000000..3f0af5285b4 --- /dev/null +++ b/Tests/LibWeb/Text/expected/wpt-import/webmessaging/broadcastchannel/interface.any.txt @@ -0,0 +1,23 @@ +Summary + +Harness status: OK + +Rerun + +Found 13 tests + +13 Pass +Details +Result Test Name MessagePass Should throw if no name is provided +Pass Null name should not throw +Pass Undefined name should not throw +Pass Non-empty name should not throw +Pass Non-string name should not throw +Pass postMessage without parameters should throw +Pass postMessage with null should not throw +Pass close should not throw +Pass close should not throw when called multiple times +Pass postMessage after close should throw +Pass BroadcastChannel should have an onmessage event +Pass postMessage should throw with uncloneable data +Pass postMessage should throw InvalidStateError after close, even with uncloneable data \ No newline at end of file diff --git a/Tests/LibWeb/Text/input/wpt-import/webmessaging/broadcastchannel/basics.any.html b/Tests/LibWeb/Text/input/wpt-import/webmessaging/broadcastchannel/basics.any.html new file mode 100644 index 00000000000..b6d12e0ad8e --- /dev/null +++ b/Tests/LibWeb/Text/input/wpt-import/webmessaging/broadcastchannel/basics.any.html @@ -0,0 +1,15 @@ + + + + + + + +
+ diff --git a/Tests/LibWeb/Text/input/wpt-import/webmessaging/broadcastchannel/basics.any.js b/Tests/LibWeb/Text/input/wpt-import/webmessaging/broadcastchannel/basics.any.js new file mode 100644 index 00000000000..eec09d65a3a --- /dev/null +++ b/Tests/LibWeb/Text/input/wpt-import/webmessaging/broadcastchannel/basics.any.js @@ -0,0 +1,128 @@ +test(function() { + assert_throws_js( + TypeError, + () => BroadcastChannel(""), + "Calling BroadcastChannel constructor without 'new' must throw" + ); +}, "BroadcastChannel constructor called as normal function"); + +async_test(t => { + let c1 = new BroadcastChannel('eventType'); + let c2 = new BroadcastChannel('eventType'); + + c2.onmessage = t.step_func(e => { + assert_true(e instanceof MessageEvent); + assert_equals(e.target, c2); + assert_equals(e.type, 'message'); + assert_equals(e.origin, location.origin, 'origin'); + assert_equals(e.data, 'hello world'); + assert_equals(e.source, null, 'source'); + t.done(); + }); + c1.postMessage('hello world'); + }, 'postMessage results in correct event'); + +async_test(t => { + let c1 = new BroadcastChannel('order'); + let c2 = new BroadcastChannel('order'); + let c3 = new BroadcastChannel('order'); + + let events = []; + let doneCount = 0; + let handler = t.step_func(e => { + events.push(e); + if (e.data == 'done') { + doneCount++; + if (doneCount == 2) { + assert_equals(events.length, 6); + assert_equals(events[0].target, c2, 'target for event 0'); + assert_equals(events[0].data, 'from c1'); + assert_equals(events[1].target, c3, 'target for event 1'); + assert_equals(events[1].data, 'from c1'); + assert_equals(events[2].target, c1, 'target for event 2'); + assert_equals(events[2].data, 'from c3'); + assert_equals(events[3].target, c2, 'target for event 3'); + assert_equals(events[3].data, 'from c3'); + assert_equals(events[4].target, c1, 'target for event 4'); + assert_equals(events[4].data, 'done'); + assert_equals(events[5].target, c3, 'target for event 5'); + assert_equals(events[5].data, 'done'); + t.done(); + } + } + }); + c1.onmessage = handler; + c2.onmessage = handler; + c3.onmessage = handler; + + c1.postMessage('from c1'); + c3.postMessage('from c3'); + c2.postMessage('done'); + }, 'messages are delivered in port creation order'); + +async_test(t => { + let c1 = new BroadcastChannel('closed'); + let c2 = new BroadcastChannel('closed'); + let c3 = new BroadcastChannel('closed'); + + c2.onmessage = t.unreached_func(); + c2.close(); + c3.onmessage = t.step_func(() => t.done()); + c1.postMessage('test'); + }, 'messages aren\'t delivered to a closed port'); + + async_test(t => { + let c1 = new BroadcastChannel('closed'); + let c2 = new BroadcastChannel('closed'); + let c3 = new BroadcastChannel('closed'); + + c2.onmessage = t.unreached_func(); + c3.onmessage = t.step_func(() => t.done()); + c1.postMessage('test'); + c2.close(); +}, 'messages aren\'t delivered to a port closed after calling postMessage.'); + +async_test(t => { + let c1 = new BroadcastChannel('create-in-onmessage'); + let c2 = new BroadcastChannel('create-in-onmessage'); + + c2.onmessage = t.step_func(e => { + assert_equals(e.data, 'first'); + c2.close(); + let c3 = new BroadcastChannel('create-in-onmessage'); + c3.onmessage = t.step_func(event => { + assert_equals(event.data, 'done'); + t.done(); + }); + c1.postMessage('done'); + }); + c1.postMessage('first'); + c2.postMessage('second'); + }, 'closing and creating channels during message delivery works correctly'); + +async_test(t => { + let c1 = new BroadcastChannel('close-in-onmessage'); + let c2 = new BroadcastChannel('close-in-onmessage'); + let c3 = new BroadcastChannel('close-in-onmessage'); + let events = []; + c1.onmessage = e => events.push('c1: ' + e.data); + c2.onmessage = e => events.push('c2: ' + e.data); + c3.onmessage = e => events.push('c3: ' + e.data); + + // c2 closes itself when it receives the first message + c2.addEventListener('message', e => { + c2.close(); + }); + + c3.addEventListener('message', t.step_func(e => { + if (e.data == 'done') { + assert_array_equals(events, [ + 'c2: first', + 'c3: first', + 'c3: done']); + t.done(); + } + })); + c1.postMessage('first'); + c1.postMessage('done'); + }, 'Closing a channel in onmessage prevents already queued tasks from firing onmessage events'); diff --git a/Tests/LibWeb/Text/input/wpt-import/webmessaging/broadcastchannel/interface.any.html b/Tests/LibWeb/Text/input/wpt-import/webmessaging/broadcastchannel/interface.any.html new file mode 100644 index 00000000000..4df53761ea3 --- /dev/null +++ b/Tests/LibWeb/Text/input/wpt-import/webmessaging/broadcastchannel/interface.any.html @@ -0,0 +1,15 @@ + + + + + + + +
+ diff --git a/Tests/LibWeb/Text/input/wpt-import/webmessaging/broadcastchannel/interface.any.js b/Tests/LibWeb/Text/input/wpt-import/webmessaging/broadcastchannel/interface.any.js new file mode 100644 index 00000000000..35e09d34b41 --- /dev/null +++ b/Tests/LibWeb/Text/input/wpt-import/webmessaging/broadcastchannel/interface.any.js @@ -0,0 +1,65 @@ +test(() => assert_throws_js(TypeError, () => new BroadcastChannel()), + 'Should throw if no name is provided'); + +test(() => { + let c = new BroadcastChannel(null); + assert_equals(c.name, 'null'); + }, 'Null name should not throw'); + +test(() => { + let c = new BroadcastChannel(undefined); + assert_equals(c.name, 'undefined'); + }, 'Undefined name should not throw'); + +test(() => { + let c = new BroadcastChannel('fooBar'); + assert_equals(c.name, 'fooBar'); + }, 'Non-empty name should not throw'); + +test(() => { + let c = new BroadcastChannel(123); + assert_equals(c.name, '123'); + }, 'Non-string name should not throw'); + +test(() => { + let c = new BroadcastChannel(''); + assert_throws_js(TypeError, () => c.postMessage()); + }, 'postMessage without parameters should throw'); + +test(() => { + let c = new BroadcastChannel(''); + c.postMessage(null); + }, 'postMessage with null should not throw'); + +test(() => { + let c = new BroadcastChannel(''); + c.close(); + }, 'close should not throw'); + +test(() => { + let c = new BroadcastChannel(''); + c.close(); + c.close(); + }, 'close should not throw when called multiple times'); + +test(() => { + let c = new BroadcastChannel(''); + c.close(); + assert_throws_dom('InvalidStateError', () => c.postMessage('')); + }, 'postMessage after close should throw'); + +test(() => { + let c = new BroadcastChannel(''); + assert_not_equals(c.onmessage, undefined); + }, 'BroadcastChannel should have an onmessage event'); + +test(() => { + let c = new BroadcastChannel(''); + assert_throws_dom('DataCloneError', () => c.postMessage(Symbol())); + }, 'postMessage should throw with uncloneable data'); + +test(() => { + let c = new BroadcastChannel(''); + c.close(); + assert_throws_dom('InvalidStateError', () => c.postMessage(Symbol())); + }, 'postMessage should throw InvalidStateError after close, even with uncloneable data');