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&);
+ }
+};