LibWeb: Implement multiple import map support

This commit is contained in:
Shannon Booth 2024-12-03 20:31:14 +13:00 committed by Luke Wilde
commit ac6fe2e211
Notes: github-actions[bot] 2024-12-10 12:02:45 +00:00
19 changed files with 294 additions and 100 deletions

View file

@ -460,25 +460,10 @@ void HTMLScriptElement::prepare_script()
}
// -> "importmap"
else if (m_script_type == ScriptType::ImportMap) {
// FIXME: need to check if relevant global object is a Window - is this correct?
auto& global = relevant_global_object(*this);
// 1. If el's relevant global object's import maps allowed is false, then queue an element task on the DOM manipulation task source given el to fire an event named error at el, and return.
if (is<Window>(global) && !verify_cast<Window>(global).import_maps_allowed()) {
queue_an_element_task(HTML::Task::Source::DOMManipulation, [this] {
dispatch_event(DOM::Event::create(realm(), HTML::EventNames::error));
});
return;
}
// 2. Set el's relevant global object's import maps allowed to false.
if (is<Window>(global))
verify_cast<Window>(global).set_import_maps_allowed(false);
// 3. Let result be the result of creating an import map parse result given source text and base URL.
// 1. Let result be the result of creating an import map parse result given source text and base URL.
auto result = ImportMapParseResult::create(realm(), source_text.to_byte_string(), base_url);
// 4. Mark as ready el given result.
// 2. Mark as ready el given result.
mark_as_ready(Result(move(result)));
}
}

View file

@ -279,9 +279,8 @@ bool module_type_allowed(JS::Realm const&, StringView module_type)
return true;
}
// https://html.spec.whatwg.org/multipage/webappapis.html#disallow-further-import-maps
// https://whatpr.org/html/9893/webappapis.html#disallow-further-import-maps
void disallow_further_import_maps(JS::Realm& realm)
// https://html.spec.whatwg.org/multipage/webappapis.html#add-module-to-resolved-module-set
void add_module_to_resolved_module_set(JS::Realm& realm, String const& serialized_base_url, String const& normalized_specifier, Optional<URL::URL> const& as_url)
{
// 1. Let global be realm's global object.
auto& global = realm.global_object();
@ -290,8 +289,18 @@ void disallow_further_import_maps(JS::Realm& realm)
if (!is<Window>(global))
return;
// 3. Set global's import maps allowed to false.
verify_cast<Window>(global).set_import_maps_allowed(false);
// 3. Let record be a new specifier resolution record, with serialized base URL set to serializedBaseURL,
// specifier set to normalizedSpecifier, and specifier as a URL set to asURL.
//
// NOTE: We set 'specifier as a URL set to asURL' as a bool to simplify logic when merging import maps.
SpecifierResolution resolution {
.serialized_base_url = serialized_base_url,
.specifier = normalized_specifier,
.specifier_is_null_or_url_like_that_is_special = !as_url.has_value() || as_url->is_special(),
};
// 4. Append record to global's resolved module set.
return verify_cast<Window>(global).append_resolved_module(move(resolution));
}
// https://whatpr.org/html/9893/webappapis.html#concept-realm-module-map

View file

@ -139,7 +139,8 @@ void prepare_to_run_callback(JS::Realm&);
void clean_up_after_running_callback(JS::Realm const&);
ModuleMap& module_map_of_realm(JS::Realm&);
bool module_type_allowed(JS::Realm const&, StringView module_type);
void disallow_further_import_maps(JS::Realm&);
void add_module_to_resolved_module_set(JS::Realm&, String const& serialized_base_url, String const& normalized_specifier, Optional<URL::URL> const& as_url);
EnvironmentSettingsObject& incumbent_settings_object();
JS::Realm& incumbent_realm();

View file

@ -120,8 +120,8 @@ WebIDL::ExceptionOr<URL::URL> resolve_module_specifier(Optional<Script&> referri
if (is<Window>(realm->global_object()))
import_map = verify_cast<Window>(realm->global_object()).import_map();
// 6. Let baseURLString be baseURL, serialized.
auto base_url_string = base_url->serialize();
// 6. Let serializedBaseURL be baseURL, serialized.
auto serialized_base_url = base_url->serialize();
// 7. Let asURL be the result of resolving a URL-like module specifier given specifier and baseURL.
auto as_url = resolve_url_like_module_specifier(specifier, *base_url);
@ -129,37 +129,49 @@ WebIDL::ExceptionOr<URL::URL> resolve_module_specifier(Optional<Script&> referri
// 8. Let normalizedSpecifier be the serialization of asURL, if asURL is non-null; otherwise, specifier.
auto normalized_specifier = as_url.has_value() ? as_url->serialize().to_byte_string() : specifier;
// 9. For each scopePrefix → scopeImports of importMap's scopes:
// 9. Let result be a URL-or-null, initially null.
Optional<URL::URL> result;
// 10. For each scopePrefix → scopeImports of importMap's scopes:
for (auto const& entry : import_map.scopes()) {
// FIXME: Clarify if the serialization steps need to be run here. The steps below assume
// scopePrefix to be a string.
auto const& scope_prefix = entry.key.serialize();
auto const& scope_imports = entry.value;
// 1. If scopePrefix is baseURLString, or if scopePrefix ends with U+002F (/) and scopePrefix is a code unit prefix of baseURLString, then:
if (scope_prefix == base_url_string || (scope_prefix.ends_with('/') && Infra::is_code_unit_prefix(scope_prefix, base_url_string))) {
// 1. If scopePrefix is serializedBaseURL, or if scopePrefix ends with U+002F (/) and scopePrefix is a code unit prefix of serializedBaseURL, then:
if (scope_prefix == serialized_base_url || (scope_prefix.ends_with('/') && Infra::is_code_unit_prefix(scope_prefix, serialized_base_url))) {
// 1. Let scopeImportsMatch be the result of resolving an imports match given normalizedSpecifier, asURL, and scopeImports.
auto scope_imports_match = TRY(resolve_imports_match(normalized_specifier, as_url, scope_imports));
// 2. If scopeImportsMatch is not null, then return scopeImportsMatch.
if (scope_imports_match.has_value())
return scope_imports_match.release_value();
// 2. If scopeImportsMatch is not null, then set result to scopeImportsMatch, and break.
if (scope_imports_match.has_value()) {
result = scope_imports_match.release_value();
break;
}
}
}
// 10. Let topLevelImportsMatch be the result of resolving an imports match given normalizedSpecifier, asURL, and importMap's imports.
auto top_level_imports_match = TRY(resolve_imports_match(normalized_specifier, as_url, import_map.imports()));
// 11. If result is null, set result be the result of resolving an imports match given normalizedSpecifier, asURL, and importMap's imports.
if (!result.has_value())
result = TRY(resolve_imports_match(normalized_specifier, as_url, import_map.imports()));
// 11. If topLevelImportsMatch is not null, then return topLevelImportsMatch.
if (top_level_imports_match.has_value())
return top_level_imports_match.release_value();
// 12. If result is null, set it to asURL.
// Spec-Note: By this point, if result was null, specifier wasn't remapped to anything by importMap, but it might have been able to be turned into a URL.
if (!result.has_value())
result = as_url;
// 12. If asURL is not null, then return asURL.
if (as_url.has_value())
return as_url.release_value();
// 13. If result is not null, then:
if (result.has_value()) {
// 1. Add module to resolved module set given realm, serializedBaseURL, normalizedSpecifier, and asURL.
add_module_to_resolved_module_set(*realm, serialized_base_url, MUST(String::from_byte_string(normalized_specifier)), as_url);
// 13. Throw a TypeError indicating that specifier was a bare specifier, but was not remapped to anything by importMap.
return WebIDL::SimpleException { WebIDL::SimpleExceptionType::TypeError, String::formatted("Failed to resolve non relative module specifier '{}' from an import map.", specifier).release_value_but_fixme_should_propagate_errors() };
// 2. Return result.
return result.release_value();
}
// 14. Throw a TypeError indicating that specifier was a bare specifier, but was not remapped to anything by importMap.
return WebIDL::SimpleException { WebIDL::SimpleExceptionType::TypeError, MUST(String::formatted("Failed to resolve non relative module specifier '{}' from an import map.", specifier)) };
}
// https://html.spec.whatwg.org/multipage/webappapis.html#resolving-an-imports-match
@ -292,34 +304,27 @@ ScriptFetchOptions get_descendant_script_fetch_options(ScriptFetchOptions const&
// 1. Let newOptions be a copy of originalOptions.
auto new_options = original_options;
// 2. Let integrity be the empty string.
String integrity;
// 2. Let integrity be the result of resolving a module integrity metadata with url and settingsObject.
String integrity = resolve_a_module_integrity_metadata(url, settings_object);
// 3. If settingsObject's global object is a Window object, then set integrity to the result of resolving a module integrity metadata with url and settingsObject.
if (is<Window>(settings_object.global_object()))
integrity = resolve_a_module_integrity_metadata(url, settings_object);
// 4. Set newOptions's integrity metadata to integrity.
// 3. Set newOptions's integrity metadata to integrity.
new_options.integrity_metadata = integrity;
// 5. Set newOptions's fetch priority to "auto".
// 4. Set newOptions's fetch priority to "auto".
new_options.fetch_priority = Fetch::Infrastructure::Request::Priority::Auto;
// 6. Return newOptions.
// 5. Return newOptions.
return new_options;
}
// https://html.spec.whatwg.org/multipage/webappapis.html#resolving-a-module-integrity-metadata
String resolve_a_module_integrity_metadata(const URL::URL& url, EnvironmentSettingsObject& settings_object)
{
// 1. Assert: settingsObject's global object is a Window object.
VERIFY(is<Window>(settings_object.global_object()));
// 1. Let map be settingsObject's global object's import map.
auto map = verify_cast<Window>(settings_object.global_object()).import_map();
// 2. Let map be settingsObject's global object's import map.
auto map = static_cast<Window const&>(settings_object.global_object()).import_map();
// 3. If map's integrity[url] does not exist, then return the empty string.
// 4. Return map's integrity[url].
// 2. If map's integrity[url] does not exist, then return the empty string.
// 3. Return map's integrity[url].
return MUST(String::from_byte_string(map.integrity().get(url).value_or("")));
}
@ -860,9 +865,6 @@ void fetch_single_module_script(JS::Realm& realm,
// https://whatpr.org/html/9893/webappapis.html#fetch-a-module-script-tree
void fetch_external_module_script_graph(JS::Realm& realm, URL::URL const& url, EnvironmentSettingsObject& settings_object, ScriptFetchOptions const& options, OnFetchScriptComplete on_complete)
{
// 1. Disallow further import maps given settingsObject's realm.
disallow_further_import_maps(settings_object.realm());
auto steps = create_on_fetch_script_complete(realm.heap(), [&realm, &settings_object, on_complete, url](auto result) mutable {
// 1. If result is null, run onComplete given null, and abort these steps.
if (!result) {
@ -875,27 +877,17 @@ void fetch_external_module_script_graph(JS::Realm& realm, URL::URL const& url, E
fetch_descendants_of_and_link_a_module_script(realm, module_script, settings_object, Fetch::Infrastructure::Request::Destination::Script, nullptr, on_complete);
});
// 2. Fetch a single module script given url, settingsObject, "script", options, settingsObject's realm, "client", true, and with the following steps given result:
// 1. Fetch a single module script given url, settingsObject, "script", options, settingsObject's realm, "client", true, and with the following steps given result:
fetch_single_module_script(realm, url, settings_object, Fetch::Infrastructure::Request::Destination::Script, options, settings_object.realm(), Web::Fetch::Infrastructure::Request::Referrer::Client, {}, TopLevelModule::Yes, nullptr, steps);
}
// https://html.spec.whatwg.org/multipage/webappapis.html#fetch-an-inline-module-script-graph
// https://whatpr.org/html/9893/webappapis.html#fetch-an-inline-module-script-graph
void fetch_inline_module_script_graph(JS::Realm& realm, ByteString const& filename, ByteString const& source_text, URL::URL const& base_url, EnvironmentSettingsObject& settings_object, OnFetchScriptComplete on_complete)
{
// 1. Disallow further import maps given settingsObject's realm.
disallow_further_import_maps(settings_object.realm());
// 2. Let script be the result of creating a JavaScript module script using sourceText, settingsObject's realm, baseURL, and options.
// 1. Let script be the result of creating a JavaScript module script using sourceText, settingsObject's realm, baseURL, and options.
auto script = JavaScriptModuleScript::create(filename, source_text.view(), settings_object.realm(), base_url).release_value_but_fixme_should_propagate_errors();
// 3. If script is null, run onComplete given null, and return.
if (!script) {
on_complete->function()(nullptr);
return;
}
// 5. Fetch the descendants of and link script, given settingsObject, "script", and onComplete.
// 2. Fetch the descendants of and link script, given settingsObject, "script", and onComplete.
fetch_descendants_of_and_link_a_module_script(realm, *script, settings_object, Fetch::Infrastructure::Request::Destination::Script, nullptr, on_complete);
}

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2024, Jamie Mansfield <jmansfield@cadixdev.org>
* Copyright (c) 2024, Shannon Booth <shannon@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
@ -10,7 +11,9 @@
#include <LibWeb/HTML/Scripting/Fetching.h>
#include <LibWeb/HTML/Scripting/ImportMap.h>
#include <LibWeb/HTML/Scripting/TemporaryExecutionContext.h>
#include <LibWeb/HTML/Window.h>
#include <LibWeb/Infra/JSON.h>
#include <LibWeb/Infra/Strings.h>
namespace Web::HTML {
@ -263,4 +266,134 @@ WebIDL::ExceptionOr<ModuleIntegrityMap> normalize_module_integrity_map(JS::Realm
return normalised;
}
// https://html.spec.whatwg.org/multipage/webappapis.html#merge-module-specifier-maps
static ModuleSpecifierMap merge_module_specifier_maps(JS::Realm& realm, ModuleSpecifierMap const& new_map, ModuleSpecifierMap const& old_map)
{
// 1. Let mergedMap be a deep copy of oldMap.
ModuleSpecifierMap merged_map = old_map;
// 2. For each specifier → url of newMap:
for (auto const& [specifier, url] : new_map) {
// 1. If specifier exists in oldMap, then:
if (old_map.contains(specifier)) {
// 1. The user agent may report a warning to the console indicating the ignored rule. They may choose to
// avoid reporting if the rule is identical to an existing one.
auto& console = realm.intrinsics().console_object()->console();
console.output_debug_message(JS::Console::LogLevel::Warn,
MUST(String::formatted("An import map rule for specifier '{}' was ignored as one was already present in the existing import map", specifier)));
// 2. Continue.
continue;
}
// 2. Set mergedMap[specifier] to url.
merged_map.set(specifier, url);
}
// 3. Return mergedMap.
return merged_map;
}
// https://html.spec.whatwg.org/multipage/webappapis.html#merge-existing-and-new-import-maps
void merge_existing_and_new_import_maps(Window& global, ImportMap& new_import_map)
{
auto& realm = global.realm();
// 1. Let newImportMapScopes be a deep copy of newImportMap's scopes.
auto new_import_map_scopes = new_import_map.scopes();
// Spec-Note: We're mutating these copies and removing items from them when they are used to ignore scope-specific
// rules. This is true for newImportMapScopes, as well as to newImportMapImports below.
// 2. Let oldImportMap be global's import map.
auto& old_import_map = global.import_map();
// 3. Let newImportMapImports be a deep copy of newImportMap's imports.
auto new_import_map_imports = new_import_map.imports();
// 4. For each scopePrefix → scopeImports of newImportMapScopes:
for (auto& [scope_prefix, scope_imports] : new_import_map_scopes) {
// 1. For each record of global's resolved module set:
for (auto const& record : global.resolved_module_set()) {
// 1. If scopePrefix is record's serialized base URL, or if scopePrefix ends with U+002F (/) and scopePrefix is a code unit prefix of record's serialized base URL, then:
if (scope_prefix == record.serialized_base_url || (scope_prefix.to_string().ends_with('/') && record.serialized_base_url.has_value() && Infra::is_code_unit_prefix(scope_prefix.to_string(), *record.serialized_base_url))) {
// 1. For each specifierKey → resolutionResult of scopeImports:
scope_imports.remove_all_matching([&](ByteString const& specifier_key, Optional<URL::URL> const&) {
// 1. If specifierKey is record's specifier, or if all of the following conditions are true:
// * specifierKey ends with U+002F (/);
// * specifierKey is a code unit prefix of record's specifier;
// * either record's specifier as a URL is null or is special,
// then:
if (specifier_key.view() == record.specifier
|| (specifier_key.ends_with('/')
&& Infra::is_code_unit_prefix(specifier_key, record.specifier)
&& record.specifier_is_null_or_url_like_that_is_special)) {
// 1. The user agent may report a warning to the console indicating the ignored rule. They
// may choose to avoid reporting if the rule is identical to an existing one.
auto& console = realm.intrinsics().console_object()->console();
console.output_debug_message(JS::Console::LogLevel::Warn,
MUST(String::formatted("An import map rule for specifier '{}' was ignored as one was already present in the existing import map", specifier_key)));
// 2. Remove scopeImports[specifierKey].
return true;
}
return false;
});
}
}
// 2. If scopePrefix exists in oldImportMap's scopes, then set oldImportMap's scopes[scopePrefix] to the result
// of merging module specifier maps, given scopeImports and oldImportMap's scopes[scopePrefix].
if (auto it = old_import_map.scopes().find(scope_prefix); it != old_import_map.scopes().end()) {
it->value = merge_module_specifier_maps(realm, scope_imports, it->value);
}
// 3. Otherwise, set oldImportMap's scopes[scopePrefix] to scopeImports.
else {
old_import_map.scopes().set(scope_prefix, scope_imports);
}
}
// 5. For each url → integrity of newImportMap's integrity:
for (auto const& [url, integrity] : new_import_map.integrity()) {
// 1. If url exists in oldImportMap's integrity, then:
if (old_import_map.integrity().contains(url)) {
// 1. The user agent may report a warning to the console indicating the ignored rule. They may choose to
// avoid reporting if the rule is identical to an existing one.
auto& console = realm.intrinsics().console_object()->console();
console.output_debug_message(JS::Console::LogLevel::Warn,
MUST(String::formatted("An import map integrity rule for url '{}' was ignored as one was already present in the existing import map", url)));
// 2. Continue.
continue;
}
// 2. Set oldImportMap's integrity[url] to integrity.
old_import_map.integrity().set(url, integrity);
}
// 6. For each record of global's resolved module set:
for (auto const& record : global.resolved_module_set()) {
// 1. For each specifier → url of newImportMapImports:
new_import_map_imports.remove_all_matching([&](ByteString const& specifier, Optional<URL::URL> const&) {
// 1. If specifier starts with record's specifier, then:
if (specifier.starts_with(record.specifier)) {
// 1. The user agent may report a warning to the console indicating the ignored rule. They may choose to
// avoid reporting if the rule is identical to an existing one.
auto& console = realm.intrinsics().console_object()->console();
console.output_debug_message(JS::Console::LogLevel::Warn,
MUST(String::formatted("An import map rule for specifier '{}' was ignored as one was already present in the existing import map", specifier)));
// 2. Remove newImportMapImports[specifier].
return true;
}
return false;
});
}
// 7. Set oldImportMap's imports to the result of merge module specifier maps, given newImportMapImports and oldImportMap's imports.
old_import_map.set_imports(merge_module_specifier_maps(realm, new_import_map_imports, old_import_map.imports()));
}
}

View file

@ -44,5 +44,6 @@ Optional<DeprecatedFlyString> normalise_specifier_key(JS::Realm& realm, Deprecat
WebIDL::ExceptionOr<ModuleSpecifierMap> sort_and_normalise_module_specifier_map(JS::Realm& realm, JS::Object& original_map, URL::URL base_url);
WebIDL::ExceptionOr<HashMap<URL::URL, ModuleSpecifierMap>> sort_and_normalise_scopes(JS::Realm& realm, JS::Object& original_map, URL::URL base_url);
WebIDL::ExceptionOr<ModuleIntegrityMap> normalize_module_integrity_map(JS::Realm& realm, JS::Object& original_map, URL::URL base_url);
void merge_existing_and_new_import_maps(Window&, ImportMap&);
}

View file

@ -73,12 +73,9 @@ void ImportMapParseResult::register_import_map(Window& global)
return;
}
// 2. Assert: global's import map is an empty import map.
VERIFY(global.import_map().imports().is_empty() && global.import_map().scopes().is_empty());
// 3. Set global's import map to result's import map.
// 2. Merge existing and new import maps, given global and result's import map.
VERIFY(m_import_map.has_value());
global.set_import_map(m_import_map.value());
merge_existing_and_new_import_maps(global, m_import_map.value());
}
}

View file

@ -12,6 +12,7 @@
#include <AK/String.h>
#include <LibJS/Runtime/Value.h>
#include <LibWeb/Forward.h>
#include <LibWeb/HTML/Scripting/ImportMap.h>
#include <LibWeb/WebIDL/ExceptionOr.h>
namespace Web::HTML {

View file

@ -21,7 +21,6 @@
#include <LibWeb/HTML/MimeType.h>
#include <LibWeb/HTML/Navigable.h>
#include <LibWeb/HTML/Plugin.h>
#include <LibWeb/HTML/Scripting/ImportMap.h>
#include <LibWeb/HTML/ScrollOptions.h>
#include <LibWeb/HTML/StructuredSerializeOptions.h>
#include <LibWeb/HTML/UniversalGlobalScope.h>
@ -45,6 +44,25 @@ struct WindowPostMessageOptions : public StructuredSerializeOptions {
String target_origin { "/"_string };
};
// https://html.spec.whatwg.org/multipage/webappapis.html#specifier-resolution-record
// A specifier resolution record is a struct. It has the following items:
struct SpecifierResolution {
// A serialized base URL
// A string-or-null that represents the base URL of the specifier, when one exists.
Optional<String> serialized_base_url;
// A specifier
// A string representing the specifier.
String specifier;
// A specifier as a URL
// A URL-or-null that represents the URL in case of a URL-like module specifier.
//
// Spec-Note: Implementations can replace specifier as a URL with a boolean that indicates
// that the specifier is either bare or URL-like that is special.
bool specifier_is_null_or_url_like_that_is_special { false };
};
class Window final
: public DOM::EventTarget
, public GlobalEventHandlers
@ -96,11 +114,12 @@ public:
GC::Ptr<Navigable> navigable() const;
ImportMap& import_map() { return m_import_map; }
ImportMap const& import_map() const { return m_import_map; }
void set_import_map(ImportMap const& import_map) { m_import_map = import_map; }
bool import_maps_allowed() const { return m_import_maps_allowed; }
void set_import_maps_allowed(bool import_maps_allowed) { m_import_maps_allowed = import_maps_allowed; }
void append_resolved_module(SpecifierResolution resolution) { m_resolved_module_set.append(move(resolution)); }
Vector<SpecifierResolution> const& resolved_module_set() const { return m_resolved_module_set; }
WebIDL::ExceptionOr<GC::Ptr<WindowProxy>> window_open_steps(StringView url, StringView target, StringView features);
@ -269,11 +288,18 @@ private:
GC::Ptr<DOM::Event> m_current_event;
// https://html.spec.whatwg.org/multipage/webappapis.html#concept-window-import-map
// https://html.spec.whatwg.org/multipage/webappapis.html#concept-global-import-map
// A global object has an import map, initially an empty import map.
ImportMap m_import_map;
// https://html.spec.whatwg.org/multipage/webappapis.html#import-maps-allowed
bool m_import_maps_allowed { true };
// https://html.spec.whatwg.org/multipage/webappapis.html#resolved-module-set
// A global object has a resolved module set, a set of specifier resolution records, initially empty.
//
// Spec-Note: The resolved module set ensures that module specifier resolution returns the same result when called
// multiple times with the same (referrer, specifier) pair. It does that by ensuring that import map rules
// that impact the specifier in its referrer's scope cannot be defined after its initial resolution. For
// now, only Window global objects have their module set data structures modified from the initial empty one.
Vector<SpecifierResolution> m_resolved_module_set;
GC::Ptr<CSS::Screen> m_screen;
GC::Ptr<Navigator> m_navigator;