From c6d42ebbd50d694cad2ede1463e880defb280d7e Mon Sep 17 00:00:00 2001 From: phosit Date: Wed, 15 Jan 2025 20:38:37 +0100 Subject: [PATCH] Support JavaScript modules - With modules JavaScript code can be split up into multiple files. We already implemented such a mechanism (`Engine.LoadLibrary`) in multiple parts of the engine. The advantage of using modules is that it's standart (JS-devs are familiar with it) and it doesn't has to be implemented multiple times. Note that `Engine.LoadLibrary` loads all files in a directory while the new `import` only loads one file. - With modules seemingly global variables are local to that script/module. We already implemented such a mechanism (`ScriptInterface::LoadScript`). --- .../_test.scriptinterface/module/empty.js | 0 .../module/import_inside_function.js | 4 + .../module/include/circle.js | 18 +++ .../module/include/entry.js | 8 ++ .../module/include/geometry/area.js | 6 + .../module/include/pi.js | 1 + eslint.config.mjs | 3 + source/scriptinterface/ModuleLoader.cpp | 136 ++++++++++++++++++ source/scriptinterface/ModuleLoader.h | 62 ++++++++ source/scriptinterface/ScriptContext.cpp | 6 + source/scriptinterface/ScriptInterface.cpp | 9 +- source/scriptinterface/ScriptInterface.h | 15 +- source/scriptinterface/tests/test_Module.h | 100 +++++++++++++ 13 files changed, 360 insertions(+), 8 deletions(-) create mode 100644 binaries/data/mods/_test.scriptinterface/module/empty.js create mode 100644 binaries/data/mods/_test.scriptinterface/module/import_inside_function.js create mode 100644 binaries/data/mods/_test.scriptinterface/module/include/circle.js create mode 100644 binaries/data/mods/_test.scriptinterface/module/include/entry.js create mode 100644 binaries/data/mods/_test.scriptinterface/module/include/geometry/area.js create mode 100644 binaries/data/mods/_test.scriptinterface/module/include/pi.js create mode 100644 source/scriptinterface/ModuleLoader.cpp create mode 100644 source/scriptinterface/ModuleLoader.h create mode 100644 source/scriptinterface/tests/test_Module.h diff --git a/binaries/data/mods/_test.scriptinterface/module/empty.js b/binaries/data/mods/_test.scriptinterface/module/empty.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/binaries/data/mods/_test.scriptinterface/module/import_inside_function.js b/binaries/data/mods/_test.scriptinterface/module/import_inside_function.js new file mode 100644 index 0000000000..2539c52688 --- /dev/null +++ b/binaries/data/mods/_test.scriptinterface/module/import_inside_function.js @@ -0,0 +1,4 @@ +function foo() +{ + import "include/pi.js"; +} diff --git a/binaries/data/mods/_test.scriptinterface/module/include/circle.js b/binaries/data/mods/_test.scriptinterface/module/include/circle.js new file mode 100644 index 0000000000..e9a0c602c2 --- /dev/null +++ b/binaries/data/mods/_test.scriptinterface/module/include/circle.js @@ -0,0 +1,18 @@ +import { circleArea } from "include/geometry/area.js"; + +class Circle +{ + radius; + + constructor(radius) + { + this.radius = radius; + } + + get area() + { + return circleArea(this.radius); + } +} + +export default Circle; diff --git a/binaries/data/mods/_test.scriptinterface/module/include/entry.js b/binaries/data/mods/_test.scriptinterface/module/include/entry.js new file mode 100644 index 0000000000..158369236d --- /dev/null +++ b/binaries/data/mods/_test.scriptinterface/module/include/entry.js @@ -0,0 +1,8 @@ +import RenamedCircle from "include/circle.js"; + +const area = new RenamedCircle(10).area; + +if (area === (Math.PI * 100)) + log("Test succeeded"); +else + throw new Error("Module Evalutation Error"); diff --git a/binaries/data/mods/_test.scriptinterface/module/include/geometry/area.js b/binaries/data/mods/_test.scriptinterface/module/include/geometry/area.js new file mode 100644 index 0000000000..c7fe622437 --- /dev/null +++ b/binaries/data/mods/_test.scriptinterface/module/include/geometry/area.js @@ -0,0 +1,6 @@ +import importedPi from "include/pi.js"; + +export function circleArea(radius) +{ + return importedPi * (radius * radius); +} diff --git a/binaries/data/mods/_test.scriptinterface/module/include/pi.js b/binaries/data/mods/_test.scriptinterface/module/include/pi.js new file mode 100644 index 0000000000..77051ea169 --- /dev/null +++ b/binaries/data/mods/_test.scriptinterface/module/include/pi.js @@ -0,0 +1 @@ +export default Math.PI; diff --git a/eslint.config.mjs b/eslint.config.mjs index 63e9cf5565..84661a4863 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,9 @@ const configIgnores = { "source/tools/profiler2/jquery*", "source/tools/replayprofile/jquery*", "source/tools/templatesanalyzer/tablefilter/", + + // This files deliberately contain errors + "binaries/data/mods/_test.scriptinterface/module/import_inside_function.js", ], }; diff --git a/source/scriptinterface/ModuleLoader.cpp b/source/scriptinterface/ModuleLoader.cpp new file mode 100644 index 0000000000..cc86a1cdf0 --- /dev/null +++ b/source/scriptinterface/ModuleLoader.cpp @@ -0,0 +1,136 @@ +/* Copyright (C) 2025 Wildfire Games. + * This file is part of 0 A.D. + * + * 0 A.D. is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * 0 A.D. is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with 0 A.D. If not, see . + */ + +#include "precompiled.h" + +#include "ModuleLoader.h" + +#include "ps/CStr.h" +#include "ps/Filesystem.h" +#include "scriptinterface/Object.h" +#include "scriptinterface/ScriptConversions.h" +#include "scriptinterface/ScriptInterface.h" + +#include "js/Modules.h" +#include +#include + +namespace Script +{ +namespace +{ +[[nodiscard]] std::string GetCode(const VfsPath& filePath) +{ + if (!VfsFileExists(filePath)) + throw std::runtime_error{fmt::format("The file \"{}\" does not exist.", filePath.string8())}; + + if (filePath.Extension().string8() != ".js") + { + throw std::runtime_error{fmt::format("The file \"{}\" is not a JavaScript module.", + filePath.string8())}; + } + + CVFSFile file; + const PSRETURN ret{file.Load(g_VFS, filePath)}; + if (ret != PSRETURN_OK) + { + throw std::runtime_error{fmt::format("Failed to load file \"{}\": {}.", filePath.string8(), + GetErrorString(ret))}; + } + + return file.DecodeUTF8(); +} + +[[nodiscard]] JSObject* CompileModule(const ScriptRequest& rq, ModuleLoader::RegistryType& registry, + const VfsPath& filePath) +{ + const auto insertResult = registry.try_emplace(filePath, rq, filePath); + return std::get<1>(*std::get<0>(insertResult)).m_ModuleObject; +} + +void Evaluate(const ScriptRequest& rq, JS::HandleObject mod) +{ + if (!JS::ModuleLink(rq.cx, mod)) + { + ScriptException::CatchPending(rq); + throw std::invalid_argument{"Unable to link module."}; + } + + JS::RootedValue val{rq.cx}; + if (!JS::ModuleEvaluate(rq.cx, mod, &val)) + { + ScriptException::CatchPending(rq); + throw std::invalid_argument{"Unable to evaluate module."}; + } +} +} // anonymous namespace + +ModuleLoader::CompiledModule::CompiledModule(const ScriptRequest& rq, const VfsPath& filePath): + m_ModuleObject(rq.cx) +{ + const std::string code{GetCode(filePath)}; + + JS::CompileOptions options{rq.cx}; + const std::string filePathStr{filePath.string8()}; + options.setFileAndLine(filePathStr.c_str(), 1); + + JS::SourceText src; + if (!src.init(rq.cx, code.c_str(), code.length(), JS::SourceOwnership::Borrowed)) + throw std::invalid_argument{fmt::format("Unable to read code file: \"{}\".", filePathStr)}; + + m_ModuleObject = JS::CompileModule(rq.cx, options, src); + + if (!m_ModuleObject) + { + ScriptException::CatchPending(rq); + throw std::invalid_argument{fmt::format("Unable to compile module: \"{}\".", + filePathStr)}; + } +} + +void ModuleLoader::LoadModule(const ScriptRequest& rq, const VfsPath& modulePath) +{ + JS::RootedObject mod{rq.cx, CompileModule(rq, m_Registry, modulePath)}; + Evaluate(rq, mod); +} + +[[nodiscard]] JSObject* ModuleLoader::ResolveHook(JSContext* cx, JS::HandleValue, + JS::HandleObject moduleRequest) noexcept +{ + try + { + const ScriptRequest rq{cx}; + std::string includeString; + const JS::RootedValue pathValue{rq.cx, + JS::StringValue(JS::GetModuleRequestSpecifier(rq.cx, moduleRequest))}; + if (!Script::FromJSVal(rq, pathValue, includeString)) + throw std::logic_error{"The module-name to import isn't a string."}; + + return CompileModule(rq, rq.GetScriptInterface().GetModuleLoader().m_Registry, includeString); + } + catch (const std::exception& e) + { + LOGERROR("%s", e.what()); + return nullptr; + } + catch (...) + { + LOGERROR("Error compiling module."); + return nullptr; + } +} +} // namespace Script diff --git a/source/scriptinterface/ModuleLoader.h b/source/scriptinterface/ModuleLoader.h new file mode 100644 index 0000000000..314cb8c5cc --- /dev/null +++ b/source/scriptinterface/ModuleLoader.h @@ -0,0 +1,62 @@ +/* Copyright (C) 2025 Wildfire Games. + * This file is part of 0 A.D. + * + * 0 A.D. is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * 0 A.D. is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with 0 A.D. If not, see . + */ + +#ifndef INCLUDED_SCRIPTMODULELOADER +#define INCLUDED_SCRIPTMODULELOADER + +#include "lib/file/vfs/vfs_path.h" +#include "scriptinterface/ScriptTypes.h" + +#include + +class ScriptContext; +class ScriptRequest; + +namespace Script +{ +class ModuleLoader +{ +public: + friend ScriptContext; + + class CompiledModule + { + public: + CompiledModule(const ScriptRequest& rq, const VfsPath& filePath); + JS::PersistentRootedObject m_ModuleObject; + }; + + using RegistryType = std::unordered_map; + + /** + * Load the specified module and all module it imports recursively. + * + * @param rq @c globalThis is taken from this @c ScriptRequest. + * @param modulePath The path to the file which should be loaded as a module. + */ + void LoadModule(const ScriptRequest& rq, const VfsPath& modulePath); + +private: + // Functions used by the `ScriptContext`. + [[nodiscard]] static JSObject* ResolveHook(JSContext* cx, JS::HandleValue, + JS::HandleObject moduleRequest) noexcept; + + RegistryType m_Registry; +}; +} // namespace Script + +#endif // INCLUDED_SCRIPTMODULELOADER diff --git a/source/scriptinterface/ScriptContext.cpp b/source/scriptinterface/ScriptContext.cpp index 93be1df663..a3eec88f26 100644 --- a/source/scriptinterface/ScriptContext.cpp +++ b/source/scriptinterface/ScriptContext.cpp @@ -22,12 +22,14 @@ #include "lib/alignment.h" #include "ps/GameSetup/Config.h" #include "ps/Profile.h" +#include "scriptinterface/ModuleLoader.h" #include "scriptinterface/Promises.h" #include "scriptinterface/ScriptExtraHeaders.h" #include "scriptinterface/ScriptEngine.h" #include "scriptinterface/ScriptInterface.h" #include "js/friend/PerformanceHint.h" +#include "js/Modules.h" void GCSliceCallbackHook(JSContext* UNUSED(cx), JS::GCProgress progress, const JS::GCDescription& UNUSED(desc)) { @@ -151,12 +153,16 @@ ScriptContext::ScriptContext(int contextSize, uint32_t heapGrowthBytesGCTrigger) JS::SetJobQueue(m_cx, m_JobQueue.get()); JS::SetPromiseRejectionTrackerCallback(m_cx, &Script::UnhandledRejectedPromise); + + JS::SetModuleResolveHook(JS_GetRuntime(m_cx), &Script::ModuleLoader::ResolveHook); } ScriptContext::~ScriptContext() { ENSURE(ScriptEngine::IsInitialised() && "The ScriptEngine must be active (initialized and not yet shut down) when destroying a ScriptContext!"); + JS::SetModuleResolveHook(JS_GetRuntime(m_cx), nullptr); + // Switch back to normal performance mode to avoid assertion in debug mode. js::gc::SetPerformanceHint(m_cx, js::gc::PerformanceHint::Normal); diff --git a/source/scriptinterface/ScriptInterface.cpp b/source/scriptinterface/ScriptInterface.cpp index 22deb97e41..db283c65d2 100644 --- a/source/scriptinterface/ScriptInterface.cpp +++ b/source/scriptinterface/ScriptInterface.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2024 Wildfire Games. +/* Copyright (C) 2025 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -25,6 +25,7 @@ #include "ps/Filesystem.h" #include "ps/Profile.h" #include "scriptinterface/FunctionWrapper.h" +#include "scriptinterface/ModuleLoader.h" #include "scriptinterface/Object.h" #include "scriptinterface/ScriptContext.h" #include "scriptinterface/ScriptExtraHeaders.h" @@ -67,6 +68,7 @@ struct ScriptInterface_impl public: boost::rand48* m_rng; JS::PersistentRootedObject m_nativeScope; // native function scope object + Script::ModuleLoader m_ModuleLoader; }; /** @@ -467,6 +469,11 @@ ScriptContext& ScriptInterface::GetContext() const return m->m_context; } +Script::ModuleLoader& ScriptInterface::GetModuleLoader() const +{ + return m->m_ModuleLoader; +} + void ScriptInterface::CallConstructor(JS::HandleValue ctor, JS::HandleValueArray argv, JS::MutableHandleValue out) const { ScriptRequest rq(this); diff --git a/source/scriptinterface/ScriptInterface.h b/source/scriptinterface/ScriptInterface.h index 9f9af17113..928a38d5b3 100644 --- a/source/scriptinterface/ScriptInterface.h +++ b/source/scriptinterface/ScriptInterface.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2024 Wildfire Games. +/* Copyright (C) 2025 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -47,19 +47,18 @@ ERROR_TYPE(Scripting_DefineType, CreationFailed); // but as large as necessary for all wrapped functions) #define SCRIPT_INTERFACE_MAX_ARGS 8 +namespace boost { namespace random { class rand48; } } +class Path; +class ScriptContext; class ScriptInterface; struct ScriptInterface_impl; +namespace Script { class ModuleLoader; } +using VfsPath = Path; -class ScriptContext; // Using a global object for the context is a workaround until Simulation, AI, etc, // use their own threads and also their own contexts. extern thread_local std::shared_ptr g_ScriptContext; -namespace boost { namespace random { class rand48; } } - -class Path; -using VfsPath = Path; - /** * Abstraction around a SpiderMonkey JS::Realm. * @@ -137,6 +136,8 @@ public: return ObjectFromCBData(rq); } + Script::ModuleLoader& GetModuleLoader() const; + /** * GetGeneralJSContext returns the context without starting a GC request and without * entering the ScriptInterface compartment. It should only be used in specific situations, diff --git a/source/scriptinterface/tests/test_Module.h b/source/scriptinterface/tests/test_Module.h new file mode 100644 index 0000000000..9969deed15 --- /dev/null +++ b/source/scriptinterface/tests/test_Module.h @@ -0,0 +1,100 @@ +/* Copyright (C) 2025 Wildfire Games. + * This file is part of 0 A.D. + * + * 0 A.D. is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * 0 A.D. is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with 0 A.D. If not, see . + */ + +#include "lib/self_test.h" + +#include "ps/CLogger.h" +#include "ps/Filesystem.h" +#include "scriptinterface/ModuleLoader.h" +#include "scriptinterface/ScriptInterface.h" + +class TestScriptModule : public CxxTest::TestSuite +{ +public: + void setUp() + { + g_VFS = CreateVfs(); + TS_ASSERT_OK(g_VFS->Mount(L"", DataDir() / "mods" / "_test.scriptinterface" / "module" / "", + VFS_MOUNT_MUST_EXIST)); + } + + void tearDown() + { + g_VFS.reset(); + } + + void test_StaticImport() + { + ScriptInterface script{"Test", "Test", g_ScriptContext}; + const ScriptRequest rq{script}; + + TestLogger logger; + script.GetModuleLoader().LoadModule(rq, "include/entry.js"); + + // This test does not rely on export to the engine. So use the logger to check if it succeeded. + TS_ASSERT_STR_CONTAINS(logger.GetOutput(),"Test succeeded"); + } + + void test_Sequential() + { + { + ScriptInterface script{"Test", "Test", g_ScriptContext}; + const ScriptRequest rq{script}; + script.GetModuleLoader().LoadModule(rq, "empty.js"); + } + { + ScriptInterface script{"Test", "Test", g_ScriptContext}; + const ScriptRequest rq{script}; + script.GetModuleLoader().LoadModule(rq, "empty.js"); + } + } + + void test_Stacked() + { + ScriptInterface scriptOuter{"Test", "Test", g_ScriptContext}; + const ScriptRequest rqOuter{scriptOuter}; + { + ScriptInterface scriptInner{"Test", "Test", g_ScriptContext}; + const ScriptRequest rqInner{scriptInner}; + scriptInner.GetModuleLoader().LoadModule(rqInner, "empty.js"); + } + scriptOuter.GetModuleLoader().LoadModule(rqOuter, "empty.js"); + } + + void test_ImportInFunction() + { + ScriptInterface script{"Test", "Test", g_ScriptContext}; + const ScriptRequest rq{script}; + + TestLogger logger; + TS_ASSERT_THROWS(script.GetModuleLoader().LoadModule(rq, "import_inside_function.js"), + const std::invalid_argument&); + const std::string log{logger.GetOutput()}; + TS_ASSERT_STR_CONTAINS(log, "import_inside_function.js line 3"); + TS_ASSERT_STR_CONTAINS(log, "import declarations may only appear at top level of a module"); + } + + void test_NonExistent() + { + ScriptInterface script{"Test", "Test", g_ScriptContext}; + const ScriptRequest rq{script}; + + const TestLogger _; + TS_ASSERT_THROWS(script.GetModuleLoader().LoadModule(rq, "nonexistent.js"), + const std::runtime_error&); + } +};