diff --git a/Meta/lint-prettier.sh b/Meta/lint-prettier.sh index 7a6701e8ee3..00f0f70b4ee 100755 --- a/Meta/lint-prettier.sh +++ b/Meta/lint-prettier.sh @@ -10,12 +10,12 @@ if [ "$#" -eq "0" ]; then git ls-files \ --exclude-from .prettierignore \ -- \ - '*.js' + '*.js' '*.mjs' ) else files=() for file in "$@"; do - if [[ "${file}" == *".js" ]]; then + if [[ "${file}" == *".js" ]] || [[ "${file}" == *".mjs" ]]; then files+=("${file}") fi done @@ -34,5 +34,5 @@ if (( ${#files[@]} )); then prettier --check "${files[@]}" else - echo "No .js files to check." + echo "No .js or .mjs files to check." fi diff --git a/Userland/Libraries/LibJS/AST.cpp b/Userland/Libraries/LibJS/AST.cpp index 5b036590ee8..57a6d35fbbd 100644 --- a/Userland/Libraries/LibJS/AST.cpp +++ b/Userland/Libraries/LibJS/AST.cpp @@ -3190,7 +3190,39 @@ void ImportCall::dump(int indent) const Completion ImportCall::execute(Interpreter& interpreter, GlobalObject& global_object) const { InterpreterNodeScope node_scope { interpreter, *this }; - return interpreter.vm().throw_completion(global_object, ErrorType::NotImplemented, "'import(...)' in modules"); + // 1. Let referencingScriptOrModule be ! GetActiveScriptOrModule(). + auto referencing_script_or_module = interpreter.vm().get_active_script_or_module(); + + if (m_options) + return interpreter.vm().throw_completion(global_object, ErrorType::NotImplemented, "import call with assertions/options"); + + // 2. Let argRef be the result of evaluating AssignmentExpression. + // 3. Let specifier be ? GetValue(argRef). + auto specifier = TRY(m_specifier->execute(interpreter, global_object)); + + // 4. Let promiseCapability be ! NewPromiseCapability(%Promise%). + auto promise_capability = MUST(new_promise_capability(global_object, global_object.promise_constructor())); + + VERIFY(!interpreter.exception()); + // 5. Let specifierString be ToString(specifier). + auto specifier_string = specifier->to_string(global_object); + + // 6. IfAbruptRejectPromise(specifierString, promiseCapability). + // Note: Since we have to use completions and not ThrowCompletionOr's in AST we have to do this manually. + if (specifier_string.is_throw_completion()) { + // FIXME: We shouldn't have to clear this exception + interpreter.vm().clear_exception(); + (void)TRY(call(global_object, promise_capability.reject, js_undefined(), *specifier_string.throw_completion().value())); + return Value { promise_capability.promise }; + } + + ModuleRequest request { specifier_string.release_value() }; + + // 7. Perform ! HostImportModuleDynamically(referencingScriptOrModule, specifierString, promiseCapability). + interpreter.vm().host_import_module_dynamically(referencing_script_or_module, request, promise_capability); + + // 8. Return promiseCapability.[[Promise]]. + return Value { promise_capability.promise }; } // 13.2.3.1 Runtime Semantics: Evaluation, https://tc39.es/ecma262/#sec-literals-runtime-semantics-evaluation diff --git a/Userland/Libraries/LibJS/Runtime/ErrorTypes.h b/Userland/Libraries/LibJS/Runtime/ErrorTypes.h index b83c4bf8996..bb63ac29bed 100644 --- a/Userland/Libraries/LibJS/Runtime/ErrorTypes.h +++ b/Userland/Libraries/LibJS/Runtime/ErrorTypes.h @@ -29,6 +29,7 @@ M(DescWriteNonWritable, "Cannot write to non-writable property '{}'") \ M(DetachedArrayBuffer, "ArrayBuffer is detached") \ M(DivisionByZero, "Division by zero") \ + M(DynamicImportNotAllowed, "Dynamic Imports are not allowed") \ M(FinalizationRegistrySameTargetAndValue, "Target and held value must not be the same") \ M(GetCapabilitiesExecutorCalledMultipleTimes, "GetCapabilitiesExecutor was called multiple times") \ M(GlobalEnvironmentAlreadyHasBinding, "Global environment already has binding '{}'") \ @@ -68,6 +69,7 @@ M(MissingRequiredProperty, "Required property {} is missing or undefined") \ M(ModuleNoEnvironment, "Cannot find module environment for imported binding") \ M(ModuleNotFound, "Cannot find/open module: '{}'") \ + M(ModuleNotFoundNoReferencingScript, "Cannot resolve module {} without any active script or module") \ M(NegativeExponent, "Exponent must be positive") \ M(NonExtensibleDefine, "Cannot define property {} on non-extensible object") \ M(NotAConstructor, "{} is not a constructor") \ diff --git a/Userland/Libraries/LibJS/Runtime/VM.cpp b/Userland/Libraries/LibJS/Runtime/VM.cpp index 4773c244590..cbbff28fe69 100644 --- a/Userland/Libraries/LibJS/Runtime/VM.cpp +++ b/Userland/Libraries/LibJS/Runtime/VM.cpp @@ -50,6 +50,37 @@ VM::VM(OwnPtr custom_data) return resolve_imported_module(move(referencing_script_or_module), specifier); }; + host_import_module_dynamically = [&](ScriptOrModule, ModuleRequest const&, PromiseCapability promise_capability) { + // By default, we throw on dynamic imports this is to prevent arbitrary file access by scripts. + VERIFY(current_realm()); + auto& global_object = current_realm()->global_object(); + auto* promise = Promise::create(global_object); + + // If you are here because you want to enable dynamic module importing make sure it won't be a security problem + // by checking the default implementation of HostImportModuleDynamically and creating your own hook or calling + // vm.enable_default_host_import_module_dynamically_hook(). + promise->reject(Error::create(global_object, ErrorType::DynamicImportNotAllowed.message())); + + promise->perform_then( + NativeFunction::create(global_object, "", [](auto&, auto&) -> ThrowCompletionOr { + VERIFY_NOT_REACHED(); + }), + NativeFunction::create(global_object, "", [reject = make_handle(promise_capability.reject)](auto& vm, auto& global_object) -> ThrowCompletionOr { + auto error = vm.argument(0); + + // a. Perform ! Call(promiseCapability.[[Reject]], undefined, « error »). + MUST(JS::call(global_object, reject.cell(), js_undefined(), error)); + + // b. Return undefined. + return js_undefined(); + }), + {}); + }; + + host_finish_dynamic_import = [&](ScriptOrModule referencing_script_or_module, ModuleRequest const& specifier, PromiseCapability promise_capability, Promise* promise) { + return finish_dynamic_import(move(referencing_script_or_module), specifier, promise_capability, promise); + }; + #define __JS_ENUMERATE(SymbolName, snake_name) \ m_well_known_symbol_##snake_name = js_symbol(*this, "Symbol." #SymbolName, false); JS_ENUMERATE_WELL_KNOWN_SYMBOLS @@ -60,6 +91,13 @@ VM::~VM() { } +void VM::enable_default_host_import_module_dynamically_hook() +{ + host_import_module_dynamically = [&](ScriptOrModule referencing_script_or_module, ModuleRequest const& specifier, PromiseCapability promise_capability) { + return import_module_dynamically(move(referencing_script_or_module), specifier, promise_capability); + }; +} + Interpreter& VM::interpreter() { VERIFY(!m_interpreters.is_empty()); @@ -848,4 +886,127 @@ ThrowCompletionOr> VM::resolve_imported_module(ScriptOrMod return module; } +// 16.2.1.8 HostImportModuleDynamically ( referencingScriptOrModule, specifier, promiseCapability ), https://tc39.es/ecma262/#sec-hostimportmoduledynamically +void VM::import_module_dynamically(ScriptOrModule referencing_script_or_module, ModuleRequest const& specifier, PromiseCapability promise_capability) +{ + auto& global_object = current_realm()->global_object(); + + // Success path: + // - At some future time, the host environment must perform FinishDynamicImport(referencingScriptOrModule, specifier, promiseCapability, promise), + // where promise is a Promise resolved with undefined. + // - Any subsequent call to HostResolveImportedModule after FinishDynamicImport has completed, + // given the arguments referencingScriptOrModule and specifier, must complete normally. + // - The completion value of any subsequent call to HostResolveImportedModule after FinishDynamicImport has completed, + // given the arguments referencingScriptOrModule and specifier, must be a module which has already been evaluated, + // i.e. whose Evaluate concrete method has already been called and returned a normal completion. + // Failure path: + // - At some future time, the host environment must perform + // FinishDynamicImport(referencingScriptOrModule, specifier, promiseCapability, promise), + // where promise is a Promise rejected with an error representing the cause of failure. + + auto* promise = Promise::create(global_object); + + ScopeGuard finish_dynamic_import = [&] { + host_finish_dynamic_import(referencing_script_or_module, specifier, promise_capability, promise); + }; + + // Generally within ECMA262 we always get a referencing_script_or_moulde. However, ShadowRealm gives an explicit null. + // To get around this is we attempt to get the active script_or_module otherwise we might start loading "random" files from the working directory. + if (referencing_script_or_module.has()) { + referencing_script_or_module = get_active_script_or_module(); + + // If there is no ScriptOrModule in any of the execution contexts + if (referencing_script_or_module.has()) { + // Throw an error for now + promise->reject(InternalError::create(global_object, String::formatted(ErrorType::ModuleNotFoundNoReferencingScript.message(), specifier.module_specifier))); + return; + } + } + + VERIFY(!exception()); + // Note: If host_resolve_imported_module returns a module it has been loaded successfully and the next call in finish_dynamic_import will retrieve it again. + auto module_or_error = host_resolve_imported_module(referencing_script_or_module, specifier); + dbgln_if(JS_MODULE_DEBUG, "[JS MODULE] HostImportModuleDynamically(..., {}) -> {}", specifier.module_specifier, module_or_error.is_error() ? "failed" : "passed"); + if (module_or_error.is_throw_completion()) { + // Note: We should not leak the exception thrown in host_resolve_imported_module. + clear_exception(); + promise->reject(*module_or_error.throw_completion().value()); + } else { + // Note: If you are here because this VERIFY is failing overwrite host_import_module_dynamically + // because this is LibJS internal logic which won't always work + auto module = module_or_error.release_value(); + VERIFY(is(*module)); + auto& source_text_module = static_cast(*module); + + auto evaluated_or_error = link_and_eval_module(source_text_module); + + if (evaluated_or_error.is_throw_completion()) { + // Note: Again we don't want to leak the exception from link_and_eval_module. + clear_exception(); + promise->reject(*evaluated_or_error.throw_completion().value()); + } else { + VERIFY(!exception()); + promise->fulfill(js_undefined()); + } + } + + // It must return NormalCompletion(undefined). + // Note: Just return void always since the resulting value cannot be accessed by user code. +} + +// 16.2.1.9 FinishDynamicImport ( referencingScriptOrModule, specifier, promiseCapability, innerPromise ), https://tc39.es/ecma262/#sec-finishdynamicimport +void VM::finish_dynamic_import(ScriptOrModule referencing_script_or_module, ModuleRequest const& specifier, PromiseCapability promise_capability, Promise* inner_promise) +{ + dbgln_if(JS_MODULE_DEBUG, "[JS MODULE] finish_dynamic_import on {}", specifier.module_specifier); + + // 1. Let fulfilledClosure be a new Abstract Closure with parameters (result) that captures referencingScriptOrModule, specifier, and promiseCapability and performs the following steps when called: + auto fulfilled_closure = [referencing_script_or_module, specifier, promise_capability](VM& vm, GlobalObject& global_object) -> ThrowCompletionOr { + auto result = vm.argument(0); + // a. Assert: result is undefined. + VERIFY(result.is_undefined()); + // b. Let moduleRecord be ! HostResolveImportedModule(referencingScriptOrModule, specifier). + auto module_record = MUST(vm.host_resolve_imported_module(referencing_script_or_module, specifier)); + + // c. Assert: Evaluate has already been invoked on moduleRecord and successfully completed. + // Note: If HostResolveImportedModule returns a module evaluate will have been called on it. + + // d. Let namespace be GetModuleNamespace(moduleRecord). + auto namespace_ = module_record->get_module_namespace(vm); + + VERIFY(!vm.exception()); + // e. If namespace is an abrupt completion, then + if (namespace_.is_throw_completion()) { + // i. Perform ! Call(promiseCapability.[[Reject]], undefined, « namespace.[[Value]] »). + MUST(JS::call(global_object, promise_capability.reject, js_undefined(), *namespace_.throw_completion().value())); + } + // f. Else, + else { + // i. Perform ! Call(promiseCapability.[[Resolve]], undefined, « namespace.[[Value]] »). + MUST(JS::call(global_object, promise_capability.resolve, js_undefined(), namespace_.release_value())); + } + // g. Return undefined. + return js_undefined(); + }; + + // 2. Let onFulfilled be ! CreateBuiltinFunction(fulfilledClosure, 0, "", « »). + auto* on_fulfilled = NativeFunction::create(current_realm()->global_object(), "", move(fulfilled_closure)); + + // 3. Let rejectedClosure be a new Abstract Closure with parameters (error) that captures promiseCapability and performs the following steps when called: + auto rejected_closure = [promise_capability](VM& vm, GlobalObject& global_object) -> ThrowCompletionOr { + auto error = vm.argument(0); + // a. Perform ! Call(promiseCapability.[[Reject]], undefined, « error »). + MUST(JS::call(global_object, promise_capability.reject, js_undefined(), error)); + // b. Return undefined. + return js_undefined(); + }; + + // 4. Let onRejected be ! CreateBuiltinFunction(rejectedClosure, 0, "", « »). + auto* on_rejected = NativeFunction::create(current_realm()->global_object(), "", move(rejected_closure)); + + // 5. Perform ! PerformPromiseThen(innerPromise, onFulfilled, onRejected). + inner_promise->perform_then(on_fulfilled, on_rejected, {}); + + VERIFY(!exception()); +} + } diff --git a/Userland/Libraries/LibJS/Runtime/VM.h b/Userland/Libraries/LibJS/Runtime/VM.h index ba68592c4ec..ddc590f44bb 100644 --- a/Userland/Libraries/LibJS/Runtime/VM.h +++ b/Userland/Libraries/LibJS/Runtime/VM.h @@ -251,6 +251,8 @@ public: Function(SourceTextModule const&)> host_get_import_meta_properties; Function host_finalize_import_meta; + void enable_default_host_import_module_dynamically_hook(); + private: explicit VM(OwnPtr); @@ -262,6 +264,9 @@ private: ThrowCompletionOr> resolve_imported_module(ScriptOrModule referencing_script_or_module, ModuleRequest const& specifier); ThrowCompletionOr link_and_eval_module(SourceTextModule& module); + void import_module_dynamically(ScriptOrModule referencing_script_or_module, ModuleRequest const& specifier, PromiseCapability promise_capability); + void finish_dynamic_import(ScriptOrModule referencing_script_or_module, ModuleRequest const& specifier, PromiseCapability promise_capability, Promise* inner_promise); + Exception* m_exception { nullptr }; HashMap m_string_cache; diff --git a/Userland/Libraries/LibJS/Tests/modules/basic-export-types.mjs b/Userland/Libraries/LibJS/Tests/modules/basic-export-types.mjs new file mode 100644 index 00000000000..68f45e23420 --- /dev/null +++ b/Userland/Libraries/LibJS/Tests/modules/basic-export-types.mjs @@ -0,0 +1,13 @@ +export const constValue = 1; + +export let letValue = 2; + +export var varValue = 3; + +const namedConstValue = 4; +let namedLetValue = 5; +var namedVarValue = 6; + +export { namedConstValue, namedLetValue, namedVarValue }; + +export const passed = true; diff --git a/Userland/Libraries/LibJS/Tests/modules/basic-modules.js b/Userland/Libraries/LibJS/Tests/modules/basic-modules.js new file mode 100644 index 00000000000..7693b1df14a --- /dev/null +++ b/Userland/Libraries/LibJS/Tests/modules/basic-modules.js @@ -0,0 +1,144 @@ +// Because you can't easily load modules directly we load them via here and check +// if they passed by checking the result + +function expectModulePassed(filename) { + if (!filename.endsWith(".mjs") || !filename.startsWith("./")) { + throw new ExpectationError( + "Expected module name to start with './' " + + "and end with '.mjs' but got '" + + filename + + "'" + ); + } + + async function getModule() { + return import(filename); + } + + let moduleLoaded = false; + let moduleResult = null; + let thrownError = null; + + getModule() + .then(result => { + moduleLoaded = true; + moduleResult = result; + expect(moduleResult).toHaveProperty("passed", true); + }) + .catch(error => { + thrownError = error; + }); + + runQueuedPromiseJobs(); + + if (thrownError) { + throw thrownError; + } + + expect(moduleLoaded).toBeTrue(); + + return moduleResult; +} + +describe("testing behavior", () => { + // To ensure the other tests are interpreter correctly we first test the underlying + // mechanisms so these tests don't use expectModulePassed. + + test("can load a module", () => { + let passed = false; + let error = null; + + import("./empty.mjs") + .then(() => { + passed = true; + }) + .catch(err => { + error = err; + }); + + runQueuedPromiseJobs(); + if (error) throw error; + + expect(passed).toBeTrue(); + }); + + test("can load a module twice", () => { + let passed = false; + let error = null; + + import("./empty.mjs") + .then(() => { + passed = true; + }) + .catch(err => { + error = err; + }); + + runQueuedPromiseJobs(); + if (error) throw error; + + expect(passed).toBeTrue(); + }); + + test("can retrieve exported value", () => { + async function getValue(filename) { + const imported = await import(filename); + expect(imported).toHaveProperty("passed", true); + } + + let passed = false; + let error = null; + + getValue("./single-const-export.mjs") + .then(obj => { + passed = true; + }) + .catch(err => { + error = err; + }); + + runQueuedPromiseJobs(); + + if (error) throw error; + + expect(passed).toBeTrue(); + }); + + test("expectModulePassed works", () => { + expectModulePassed("./single-const-export.mjs"); + }); +}); + +describe("in- and exports", () => { + test("variable and lexical declarations", () => { + const result = expectModulePassed("./basic-export-types.mjs"); + expect(result).not.toHaveProperty("default", null); + expect(result).toHaveProperty("constValue", 1); + expect(result).toHaveProperty("letValue", 2); + expect(result).toHaveProperty("varValue", 3); + + expect(result).toHaveProperty("namedConstValue", 1 + 3); + expect(result).toHaveProperty("namedLetValue", 2 + 3); + expect(result).toHaveProperty("namedVarValue", 3 + 3); + }); + + test("default exports", () => { + const result = expectModulePassed("./module-with-default.mjs"); + expect(result).toHaveProperty("defaultValue"); + expect(result.default).toBe(result.defaultValue); + }); + + test("declaration exports which can be used in the module it self", () => { + expectModulePassed("./declarations-tests.mjs"); + }); +}); + +describe("loops", () => { + test("import and export from own file", () => { + expectModulePassed("./loop-self.mjs"); + }); + + test("import something which imports a cycle", () => { + expectModulePassed("./loop-entry.mjs"); + }); +}); diff --git a/Userland/Libraries/LibJS/Tests/modules/declarations-tests.mjs b/Userland/Libraries/LibJS/Tests/modules/declarations-tests.mjs new file mode 100644 index 00000000000..1cccb86b8bf --- /dev/null +++ b/Userland/Libraries/LibJS/Tests/modules/declarations-tests.mjs @@ -0,0 +1,28 @@ +export function returnsOne() { + return 1; +} + +export class hasStaticFieldTwo { + static two = 2; +} + +const expectedValue = 10; +const didNotHoistClass = (() => { + try { + new ShouldNotBeHoisted(); + } catch (e) { + if (e instanceof ReferenceError) return 4; + } + return 0; +})(); + +export const passed = + returnsOne() + hasStaticFieldTwo.two + shouldBeHoisted() + didNotHoistClass === expectedValue; + +export function shouldBeHoisted() { + return 3; +} + +export class ShouldNotBeHoisted { + static no = 5; +} diff --git a/Userland/Libraries/LibJS/Tests/modules/empty.mjs b/Userland/Libraries/LibJS/Tests/modules/empty.mjs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Userland/Libraries/LibJS/Tests/modules/loop-a.mjs b/Userland/Libraries/LibJS/Tests/modules/loop-a.mjs new file mode 100644 index 00000000000..fc994711f71 --- /dev/null +++ b/Userland/Libraries/LibJS/Tests/modules/loop-a.mjs @@ -0,0 +1,3 @@ +export { bValue } from "./loop-b.mjs"; + +export const aValue = 1; diff --git a/Userland/Libraries/LibJS/Tests/modules/loop-b.mjs b/Userland/Libraries/LibJS/Tests/modules/loop-b.mjs new file mode 100644 index 00000000000..68151b4d85f --- /dev/null +++ b/Userland/Libraries/LibJS/Tests/modules/loop-b.mjs @@ -0,0 +1,3 @@ +import "./loop-a.mjs"; + +export const bValue = 2; diff --git a/Userland/Libraries/LibJS/Tests/modules/loop-entry.mjs b/Userland/Libraries/LibJS/Tests/modules/loop-entry.mjs new file mode 100644 index 00000000000..c5e7f77ee7e --- /dev/null +++ b/Userland/Libraries/LibJS/Tests/modules/loop-entry.mjs @@ -0,0 +1,3 @@ +import { aValue, bValue } from "./loop-a.mjs"; + +export const passed = aValue < bValue; diff --git a/Userland/Libraries/LibJS/Tests/modules/loop-self.mjs b/Userland/Libraries/LibJS/Tests/modules/loop-self.mjs new file mode 100644 index 00000000000..a37da739820 --- /dev/null +++ b/Userland/Libraries/LibJS/Tests/modules/loop-self.mjs @@ -0,0 +1,5 @@ +import { value as importValue } from "./loop-self.mjs"; + +export const value = "loop de loop whooo"; + +export const passed = value === importValue; diff --git a/Userland/Libraries/LibJS/Tests/modules/module-with-default.mjs b/Userland/Libraries/LibJS/Tests/modules/module-with-default.mjs new file mode 100644 index 00000000000..bcce0164795 --- /dev/null +++ b/Userland/Libraries/LibJS/Tests/modules/module-with-default.mjs @@ -0,0 +1,7 @@ +const value = "Well hello importer :^)"; + +export const defaultValue = value; + +export default value; + +export const passed = true; diff --git a/Userland/Libraries/LibJS/Tests/modules/single-const-export.mjs b/Userland/Libraries/LibJS/Tests/modules/single-const-export.mjs new file mode 100644 index 00000000000..58c90699082 --- /dev/null +++ b/Userland/Libraries/LibJS/Tests/modules/single-const-export.mjs @@ -0,0 +1 @@ +export const passed = true; diff --git a/Userland/Libraries/LibTest/JavaScriptTestRunnerMain.cpp b/Userland/Libraries/LibTest/JavaScriptTestRunnerMain.cpp index 6ed697ea8eb..a4aa7f7e449 100644 --- a/Userland/Libraries/LibTest/JavaScriptTestRunnerMain.cpp +++ b/Userland/Libraries/LibTest/JavaScriptTestRunnerMain.cpp @@ -182,8 +182,10 @@ int main(int argc, char** argv) if (g_main_hook) g_main_hook(); - if (!g_vm) + if (!g_vm) { g_vm = JS::VM::create(); + g_vm->enable_default_host_import_module_dynamically_hook(); + } Test::JS::TestRunner test_runner(test_root, common_path, print_times, print_progress, print_json); test_runner.run(test_glob); diff --git a/Userland/Utilities/js.cpp b/Userland/Utilities/js.cpp index 40b960e1ab1..ea486e09b9f 100644 --- a/Userland/Utilities/js.cpp +++ b/Userland/Utilities/js.cpp @@ -1290,6 +1290,8 @@ ErrorOr serenity_main(Main::Arguments arguments) bool syntax_highlight = !disable_syntax_highlight; vm = JS::VM::create(); + vm->enable_default_host_import_module_dynamically_hook(); + // NOTE: These will print out both warnings when using something like Promise.reject().catch(...) - // which is, as far as I can tell, correct - a promise is created, rejected without handler, and a // handler then attached to it. The Node.js REPL doesn't warn in this case, so it's something we