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`).
This commit is contained in:
phosit 2025-01-15 20:38:37 +01:00 committed by phosit
parent 475053ea7c
commit c6d42ebbd5
13 changed files with 360 additions and 8 deletions

View file

@ -0,0 +1,4 @@
function foo()
{
import "include/pi.js";
}

View file

@ -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;

View file

@ -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");

View file

@ -0,0 +1,6 @@
import importedPi from "include/pi.js";
export function circleArea(radius)
{
return importedPi * (radius * radius);
}

View file

@ -0,0 +1 @@
export default Math.PI;

View file

@ -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",
],
};

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
#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 <fmt/format.h>
#include <stdexcept>
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<mozilla::Utf8Unit> 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

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef INCLUDED_SCRIPTMODULELOADER
#define INCLUDED_SCRIPTMODULELOADER
#include "lib/file/vfs/vfs_path.h"
#include "scriptinterface/ScriptTypes.h"
#include <unordered_map>
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<VfsPath, CompiledModule>;
/**
* 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

View file

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

View file

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

View file

@ -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<ScriptContext> 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<T>(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,

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
#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&);
}
};