mirror of
https://gitea.wildfiregames.com/0ad/0ad
synced 2026-06-16 05:13:58 -07:00
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:
parent
475053ea7c
commit
c6d42ebbd5
13 changed files with 360 additions and 8 deletions
0
binaries/data/mods/_test.scriptinterface/module/empty.js
Normal file
0
binaries/data/mods/_test.scriptinterface/module/empty.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
function foo()
|
||||
{
|
||||
import "include/pi.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;
|
||||
|
|
@ -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");
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import importedPi from "include/pi.js";
|
||||
|
||||
export function circleArea(radius)
|
||||
{
|
||||
return importedPi * (radius * radius);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export default Math.PI;
|
||||
|
|
@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
136
source/scriptinterface/ModuleLoader.cpp
Normal file
136
source/scriptinterface/ModuleLoader.cpp
Normal 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
|
||||
62
source/scriptinterface/ModuleLoader.h
Normal file
62
source/scriptinterface/ModuleLoader.h
Normal 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
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
100
source/scriptinterface/tests/test_Module.h
Normal file
100
source/scriptinterface/tests/test_Module.h
Normal 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&);
|
||||
}
|
||||
};
|
||||
Loading…
Reference in a new issue