ladybird/Libraries/LibWeb/HTML/Scripting/ImportMap.cpp
Andreas Kling e3841a7392 LibJS: Compact Shape property table to a sorted flat array
Replace the lazy per-shape OrderedHashMap cache for non-dictionary
shapes with a GC-allocated descriptor array. Store descriptors in hash
order for lookup while keeping an enum index so callers can still walk
properties in insertion order.

Keep dictionary shapes on the mutable OrderedHashMap path, and migrate
callers that enumerated Shape::property_table() to the new insertion
order iterator. Cap descriptor arrays to their compact u16 index range
and keep larger dictionary shapes on the mutable table path across
prototype transitions and prototype clones.

Add coverage for setting the prototype of a dictionary object with more
than 65536 named properties.
2026-05-14 19:59:40 +02:00

422 lines
22 KiB
C++

/*
* Copyright (c) 2024, Jamie Mansfield <jmansfield@cadixdev.org>
* Copyright (c) 2024, Shannon Booth <shannon@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibJS/Console.h>
#include <LibJS/Runtime/Array.h>
#include <LibJS/Runtime/ConsoleObject.h>
#include <LibWeb/DOMURL/DOMURL.h>
#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 {
// Check that the JS value was produced from a JSON object ({}).
// https://infra.spec.whatwg.org/#ordered-map
static bool
value_is_ordered_map(JS::Value const& value)
{
return value.is_object() && !is<JS::Array>(value.as_object());
}
// https://html.spec.whatwg.org/multipage/webappapis.html#parse-an-import-map-string
WebIDL::ExceptionOr<ImportMap> parse_import_map_string(JS::Realm& realm, ByteString const& input, URL::URL base_url)
{
HTML::TemporaryExecutionContext execution_context { realm };
// 1. Let parsed be the result of parsing a JSON string to an Infra value given input.
auto parsed = TRY(Infra::parse_json_string_to_javascript_value(realm, input));
// 2. If parsed is not an ordered map, then throw a TypeError indicating that the top-level value needs to be a JSON object.
if (!value_is_ordered_map(parsed))
return WebIDL::SimpleException { WebIDL::SimpleExceptionType::TypeError, "The top-level value of an importmap needs to be a JSON object."_string };
auto& parsed_object = parsed.as_object();
// 3. Let sortedAndNormalizedImports be an empty ordered map.
ModuleSpecifierMap sorted_and_normalized_imports;
// 4. If parsed["imports"] exists, then:
if (TRY(parsed_object.has_property("imports"_utf16_fly_string))) {
auto imports = TRY(parsed_object.get("imports"_utf16_fly_string));
// If parsed["imports"] is not an ordered map, then throw a TypeError indicating that the value for the "imports" top-level key needs to be a JSON object.
if (!value_is_ordered_map(imports))
return WebIDL::SimpleException { WebIDL::SimpleExceptionType::TypeError, "The 'imports' top-level value of an importmap needs to be a JSON object."_string };
// Set sortedAndNormalizedImports to the result of sorting and normalizing a module specifier map given parsed["imports"] and baseURL.
sorted_and_normalized_imports = TRY(sort_and_normalise_module_specifier_map(realm, imports.as_object(), base_url));
}
// 5. Let sortedAndNormalizedScopes be an empty ordered map.
HashMap<URL::URL, ModuleSpecifierMap> sorted_and_normalized_scopes;
// 6. If parsed["scopes"] exists, then:
if (TRY(parsed_object.has_property("scopes"_utf16_fly_string))) {
auto scopes = TRY(parsed_object.get("scopes"_utf16_fly_string));
// If parsed["scopes"] is not an ordered map, then throw a TypeError indicating that the value for the "scopes" top-level key needs to be a JSON object.
if (!value_is_ordered_map(scopes))
return WebIDL::SimpleException { WebIDL::SimpleExceptionType::TypeError, "The 'scopes' top-level value of an importmap needs to be a JSON object."_string };
// Set sortedAndNormalizedScopes to the result of sorting and normalizing scopes given parsed["scopes"] and baseURL.
sorted_and_normalized_scopes = TRY(sort_and_normalise_scopes(realm, scopes.as_object(), base_url));
}
// 7. Let normalizedIntegrity be an empty ordered map.
ModuleIntegrityMap normalized_integrity;
// 8. If parsed["integrity"] exists, then:
if (TRY(parsed_object.has_property("integrity"_utf16_fly_string))) {
auto integrity = TRY(parsed_object.get("integrity"_utf16_fly_string));
// 1. If parsed["integrity"] is not an ordered map, then throw a TypeError indicating that the value for the "integrity" top-level key needs to be a JSON object.
if (!value_is_ordered_map(integrity))
return WebIDL::SimpleException { WebIDL::SimpleExceptionType::TypeError, "The 'integrity' top-level value of an importmap needs to be a JSON object."_string };
// 2. Set normalizedIntegrity to the result of normalizing a module integrity map given parsed["integrity"] and baseURL.
normalized_integrity = TRY(normalize_module_integrity_map(realm, integrity.as_object(), base_url));
}
// 9. If parsed's keys contains any items besides "imports", "scopes", or "integrity", then the user agent should report a warning to the console indicating that an invalid top-level key was present in the import map.
Vector<JS::PropertyKey> parsed_keys;
parsed_object.shape().for_each_property_in_insertion_order([&](auto const& key, auto const&) {
parsed_keys.append(key);
});
for (auto& key : parsed_keys) {
if (key.as_string().is_one_of("imports"sv, "scopes"sv, "integrity"sv))
continue;
auto& console = realm.intrinsics().console_object()->console();
console.output_debug_message(JS::Console::LogLevel::Warn, MUST(String::formatted("An invalid top-level key ({}) was present in the import map", key.as_string())));
}
// 10. Return an import map whose imports are sortedAndNormalizedImports, whose scopes are sortedAndNormalizedScopes, and whose integrity are normalizedIntegrity.
ImportMap import_map;
import_map.set_imports(sorted_and_normalized_imports);
import_map.set_scopes(sorted_and_normalized_scopes);
import_map.set_integrity(normalized_integrity);
return import_map;
}
// https://html.spec.whatwg.org/multipage/webappapis.html#normalizing-a-specifier-key
Optional<FlyString> normalize_specifier_key(JS::Realm& realm, FlyString specifier_key, URL::URL base_url)
{
// 1. If specifierKey is the empty string, then:
if (specifier_key.is_empty()) {
// 1. The user agent may report a warning to the console indicating that specifier keys may not be the empty string.
auto& console = realm.intrinsics().console_object()->console();
console.output_debug_message(JS::Console::LogLevel::Warn, "Specifier keys may not be empty"sv);
// 2. Return null.
return Optional<FlyString> {};
}
// 2. Let url be the result of resolving a URL-like module specifier, given specifierKey and baseURL.
auto url = resolve_url_like_module_specifier(specifier_key.to_string().to_byte_string(), base_url);
// 3. If url is not null, then return the serialization of url.
if (url.has_value())
return url->serialize();
// 4. Return specifierKey.
return specifier_key;
}
// https://html.spec.whatwg.org/multipage/webappapis.html#sorting-and-normalizing-a-module-specifier-map
WebIDL::ExceptionOr<ModuleSpecifierMap> sort_and_normalise_module_specifier_map(JS::Realm& realm, JS::Object& original_map, URL::URL base_url)
{
// 1. Let normalized be an empty ordered map.
ModuleSpecifierMap normalized;
// 2. For each specifierKey → value of originalMap:
Vector<JS::PropertyKey> specifier_keys;
original_map.shape().for_each_property_in_insertion_order([&](auto const& specifier_key, auto const&) {
specifier_keys.append(specifier_key);
});
for (auto& specifier_key : specifier_keys) {
auto value = TRY(original_map.get(specifier_key.as_string()));
// 1. Let normalizedSpecifierKey be the result of normalizing a specifier key given specifierKey and baseURL.
auto normalized_specifier_key = normalize_specifier_key(realm, specifier_key.as_string().view().to_utf8_but_should_be_ported_to_utf16(), base_url);
// 2. If normalizedSpecifierKey is null, then continue.
if (!normalized_specifier_key.has_value())
continue;
// 3. If value is not a string, then:
if (!value.is_string()) {
// 1. The user agent may report a warning to the console indicating that addresses need to be strings.
auto& console = realm.intrinsics().console_object()->console();
console.output_debug_message(JS::Console::LogLevel::Warn, "Addresses need to be strings"sv);
// 2. Set normalized[normalizedSpecifierKey] to null.
normalized.set(normalized_specifier_key.value().to_string(), {});
// 3. Continue.
continue;
}
// 4. Let addressURL be the result of resolving a URL-like module specifier given value and baseURL.
auto address_url = resolve_url_like_module_specifier(value.as_string().utf8_string_view(), base_url);
// 5. If addressURL is null, then:
if (!address_url.has_value()) {
// 1. The user agent may report a warning to the console indicating that the address was invalid.
auto& console = realm.intrinsics().console_object()->console();
console.output_debug_message(JS::Console::LogLevel::Warn, "Address was invalid"sv);
// 2. Set normalized[normalizedSpecifierKey] to null.
normalized.set(normalized_specifier_key.value().to_string(), {});
// 3. Continue.
continue;
}
// 6. If specifierKey ends with U+002F (/), and the serialization of addressURL does not end with U+002F (/), then:
if (specifier_key.as_string().view().ends_with('/') && !address_url->serialize().ends_with('/')) {
// 1. The user agent may report a warning to the console indicating that an invalid address was given for the specifier key specifierKey; since specifierKey ends with a slash, the address needs to as well.
auto& console = realm.intrinsics().console_object()->console();
console.output_debug_message(JS::Console::LogLevel::Warn,
MUST(String::formatted("An invalid address was given for the specifier key ({}); since specifierKey ends with a slash, the address needs to as well", specifier_key.as_string())));
// 2. Set normalized[normalizedSpecifierKey] to null.
normalized.set(normalized_specifier_key.value().to_string(), {});
// 3. Continue.
continue;
}
// 7. Set normalized[normalizedSpecifierKey] to addressURL.
normalized.set(normalized_specifier_key.value().to_string(), address_url.value());
}
// 3. Return the result of sorting in descending order normalized, with an entry a being less than an entry b if a's key is code unit less than b's key.
return normalized;
}
// https://html.spec.whatwg.org/multipage/webappapis.html#sorting-and-normalizing-scopes
WebIDL::ExceptionOr<HashMap<URL::URL, ModuleSpecifierMap>> sort_and_normalise_scopes(JS::Realm& realm, JS::Object& original_map, URL::URL base_url)
{
// 1. Let normalized be an empty ordered map.
HashMap<URL::URL, ModuleSpecifierMap> normalized;
// 2. For each scopePrefix → potentialSpecifierMap of originalMap:
Vector<JS::PropertyKey> scope_prefixes;
original_map.shape().for_each_property_in_insertion_order([&](auto const& scope_prefix, auto const&) {
scope_prefixes.append(scope_prefix);
});
for (auto& scope_prefix : scope_prefixes) {
auto potential_specifier_map = TRY(original_map.get(scope_prefix.as_string()));
// 1. If potentialSpecifierMap is not an ordered map, then throw a TypeError indicating that the value of the scope with prefix scopePrefix needs to be a JSON object.
if (!potential_specifier_map.is_object())
return WebIDL::SimpleException { WebIDL::SimpleExceptionType::TypeError, String::formatted("The value of the scope with the prefix '{}' needs to be a JSON object.", scope_prefix.as_string()).release_value_but_fixme_should_propagate_errors() };
// 2. Let scopePrefixURL be the result of URL parsing scopePrefix with baseURL.
auto scope_prefix_url = DOMURL::parse(scope_prefix.as_string().view().to_utf8_but_should_be_ported_to_utf16(), base_url);
// 3. If scopePrefixURL is failure, then:
if (!scope_prefix_url.has_value()) {
// 1. The user agent may report a warning to the console that the scope prefix URL was not parseable.
auto& console = realm.intrinsics().console_object()->console();
console.output_debug_message(JS::Console::LogLevel::Warn,
MUST(String::formatted("The scope prefix URL ({}) was not parseable", scope_prefix.as_string())));
// 2. Continue.
continue;
}
// 4. Let normalizedScopePrefix be the serialization of scopePrefixURL.
// 5. Set normalized[normalizedScopePrefix] to the result of sorting and normalizing a module specifier map given potentialSpecifierMap and baseURL.
normalized.set(scope_prefix_url.value(), TRY(sort_and_normalise_module_specifier_map(realm, potential_specifier_map.as_object(), base_url)));
}
// 3. Return the result of sorting in descending order normalized, with an entry a being less than an entry b if a's key is code unit less than b's key.
return normalized;
}
// https://html.spec.whatwg.org/multipage/webappapis.html#normalizing-a-module-integrity-map
WebIDL::ExceptionOr<ModuleIntegrityMap> normalize_module_integrity_map(JS::Realm& realm, JS::Object& original_map, URL::URL base_url)
{
// 1. Let normalized be an empty ordered map.
ModuleIntegrityMap normalized;
// 2. For each key → value of originalMap:
Vector<JS::PropertyKey> keys;
original_map.shape().for_each_property_in_insertion_order([&](auto const& key, auto const&) {
keys.append(key);
});
for (auto& key : keys) {
auto value = TRY(original_map.get(key.as_string()));
// 1. Let resolvedURL be the result of resolving a URL-like module specifier given key and baseURL.
auto resolved_url = resolve_url_like_module_specifier(key.as_string().view().to_utf8_but_should_be_ported_to_utf16(), base_url);
// 2. If resolvedURL is null, then:
if (!resolved_url.has_value()) {
// 1. The user agent may report a warning to the console indicating that the key failed to resolve.
auto& console = realm.intrinsics().console_object()->console();
console.output_debug_message(JS::Console::LogLevel::Warn,
MUST(String::formatted("Failed to resolve key ({})", key.as_string())));
// 2. Continue.
continue;
}
// 3. If value is not a string, then:
if (!value.is_string()) {
// 1. The user agent may report a warning to the console indicating that integrity metadata values need to be strings.
auto& console = realm.intrinsics().console_object()->console();
console.output_debug_message(JS::Console::LogLevel::Warn,
MUST(String::formatted("Integrity metadata value for '{}' needs to be a string", key.as_string())));
// 2. Continue.
continue;
}
// 4. Set normalized[resolvedURL] to value.
normalized.set(resolved_url.release_value(), value.as_string().utf8_string());
}
// 3. Return normalized.
return normalized;
}
// 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();
// 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.to_string() == 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([&](String 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.bytes_as_string_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([&](String const& specifier, Optional<URL::URL> const&) {
// 1. If specifier starts with record's specifier, then:
if (specifier.bytes_as_string_view().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()));
}
}