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

@ -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());
}
}