0ad/source/scriptinterface/ModuleLoader.cpp
phosit 4043f954a7 Allow mods to write appendices
Module local values are visible to Appendixes. This mechanism can be
used for mods.
2025-06-06 17:36:35 +02:00

369 lines
12 KiB
C++

/* 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/FunctionWrapper.h"
#include "scriptinterface/Object.h"
#include "scriptinterface/Promises.h"
#include "scriptinterface/ScriptConversions.h"
#include "scriptinterface/ScriptInterface.h"
#include "js/Modules.h"
#include <fmt/format.h>
#include <numeric>
#include <stdexcept>
namespace Script
{
namespace
{
/**
* When provided with an appendix name (containing a "~" and ending with
* ".append.js") the name of the base file is returned. When it's not an
* appendix name an empty string is returned. E.g.
* "base_file~mod_name.append.js" -> "base_file.js"
* "base-name~0.append.js" -> "base-name.js"
* "base_file~mod_name.js" -> ""
* "base_file_mod_name.append.js" -> ""
*/
VfsPath GetBaseFilename(const VfsPath& filename)
{
constexpr std::string_view appendixExtension{".append.js"};
const std::string nameString{filename.string8()};
if (nameString.size() < appendixExtension.size())
return {};
if (nameString.substr(nameString.size() - appendixExtension.size()) != appendixExtension)
return {};
const size_t pos{nameString.find('~')};
if (pos == std::string::npos)
return {};
return nameString.substr(0, pos) + ".js";
}
[[nodiscard]] std::vector<VfsPath> GetAppendices(const VfsPath& baseFilepath)
{
const VfsPath directory{baseFilepath.Parent()};
CFileInfos fileInfos;
if (g_VFS->GetDirectoryEntries(baseFilepath, &fileInfos, nullptr) != INFO::OK)
{
throw std::runtime_error{fmt::format("Unable to load files in directory: \"{}\"",
directory.string8())};
}
std::vector<VfsPath> filenames;
std::transform(fileInfos.begin(), fileInfos.end(), std::back_inserter(filenames),
[](const CFileInfo fileInfo)
{
return fileInfo.Name();
});
const VfsPath baseFilename{baseFilepath.Filename()};
const auto endPoint = std::remove_if(filenames.begin(), filenames.end(), [&](const VfsPath& filename)
{
const VfsPath base{GetBaseFilename(filename)};
return base != baseFilename;
});
filenames.erase(endPoint, filenames.end());
for (VfsPath& filename : filenames)
filename = directory / filename;
return filenames;
}
[[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 VfsPath normalizedPath{filePath.fileSystemPath().lexically_normal().generic_string()};
const auto insertResult = registry.try_emplace(normalizedPath, rq, normalizedPath);
return std::get<1>(*std::get<0>(insertResult)).m_ModuleObject;
}
[[nodiscard]] JSObject* Resolve(const ScriptRequest& rq,
ModuleLoader::RegistryType& registry, JS::HandleObject moduleRequest)
{
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, registry, includeString);
}
[[nodiscard]] JSObject* 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) || !val.isObject())
{
ScriptException::CatchPending(rq);
throw std::invalid_argument{"Unable to evaluate module."};
}
return &val.toObject();
}
template<bool reject>
bool Call(JSContext* cx, const unsigned argc, JS::Value* vp)
{
JS::CallArgs args{JS::CallArgsFromVp(argc, vp)};
const ScriptRequest rq{cx};
const auto statusPtr{JS::GetMaybePtrFromReservedSlot<ModuleLoader::Future::Status>(
&args.callee(), 0)};
if (!statusPtr)
return true;
auto& status = *statusPtr;
if (reject)
{
JS::HandleValue error{args.get(0)};
std::string asString;
ScriptFunction::Call(rq, error, "toString", asString);
std::string stack;
Script::GetProperty(rq, error, "stack", stack);
status = ModuleLoader::Future::Rejected{std::make_exception_ptr(std::runtime_error{
asString + '\n' + stack})};
return true;
}
const auto evaluatingStatus{std::get_if<ModuleLoader::Future::Evaluating>(&status)};
if (!evaluatingStatus)
{
status = ModuleLoader::Future::Rejected{std::make_exception_ptr(std::runtime_error{
"Future is not Pending."})};
return true;
}
status = ModuleLoader::Future::Fulfilled{evaluatingStatus->moduleNamespace};
return true;
}
template<bool reject>
constexpr JSClassOps callbackClassOps{nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
/*call =*/Call<reject>, nullptr, nullptr};
template<bool reject>
constexpr JSClass callbackClass{"Callback", JSCLASS_HAS_RESERVED_SLOTS(1), &callbackClassOps<reject>};
} // anonymous namespace
ModuleLoader::CompiledModule::CompiledModule(const ScriptRequest& rq, const VfsPath& filePath):
m_ModuleObject(rq.cx)
{
const std::vector<VfsPath> appendices{GetAppendices(filePath)};
const std::string code{std::accumulate(appendices.begin(), appendices.end(), GetCode(filePath),
[](std::string code, const VfsPath& fileToAppend)
{
return std::move(code) + GetCode(fileToAppend);
})};
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)};
}
JS::RootedValue modInfo{rq.cx};
Script::CreateObject(rq, &modInfo, "path", filePathStr);
JS::SetModulePrivate(m_ModuleObject, modInfo);
}
ModuleLoader::Future::Future(const ScriptRequest& rq, ModuleLoader& loader, const VfsPath& modulePath):
m_Status{Evaluating{{rq.cx, nullptr}, {rq.cx, JS_NewObject(rq.cx, &callbackClass<false>)},
{rq.cx, JS_NewObject(rq.cx, &callbackClass<true>)}}}
{
// It's possible to access exported values before the complete module is evaluated (whenever
// something is `export`-ed before a top-level `await`).
// Those "partial" module namespaces are not exposed for the following reasons:
// - The use case for them is too limited.
// - JS developers are used to getting either a complete namespace or nothing.
// - Accessing values which are not yet exported results in an error. These errors might implicitly be
// dropped.
JS::RootedObject mod{rq.cx, CompileModule(rq, loader.m_Registry, modulePath)};
JS::RootedObject promise{rq.cx, Evaluate(rq, mod)};
Evaluating& evaluatingStatus{std::get<Evaluating>(m_Status)};
evaluatingStatus.moduleNamespace = JS::GetModuleNamespace(rq.cx, mod);
SetReservedSlot(JS::PrivateValue(static_cast<void*>(&m_Status)));
if (!JS::AddPromiseReactions(rq.cx, promise, evaluatingStatus.fulfill, evaluatingStatus.reject))
throw std::runtime_error{"Failed adding promise reaction."};
}
ModuleLoader::Future::Future(Future&& other) noexcept:
m_Status{std::exchange(other.m_Status, Invalid{})}
{
SetReservedSlot(JS::PrivateValue(static_cast<void*>(&m_Status)));
}
ModuleLoader::Future& ModuleLoader::Future::operator=(Future&& other) noexcept
{
SetReservedSlot(JS::UndefinedValue());
m_Status = std::exchange(other.m_Status, Invalid{});
SetReservedSlot(JS::PrivateValue(static_cast<void*>(&m_Status)));
return *this;
}
ModuleLoader::Future::~Future()
{
SetReservedSlot(JS::UndefinedValue());
}
[[nodiscard]] bool ModuleLoader::Future::IsDone() const noexcept
{
return std::holds_alternative<Fulfilled>(m_Status) || std::holds_alternative<Rejected>(m_Status);
}
[[nodiscard]] JSObject* ModuleLoader::Future::Get()
{
if (std::holds_alternative<Fulfilled>(m_Status))
return std::get<Fulfilled>(std::exchange(m_Status, Invalid{})).moduleNamespace;
std::exception_ptr error{std::move(std::get<Rejected>(m_Status).error)};
m_Status = Invalid{};
std::rethrow_exception(std::move(error));
}
void ModuleLoader::Future::SetReservedSlot(JS::Value privateValue) noexcept
{
Evaluating* evaluatingStatus{std::get_if<Evaluating>(&m_Status)};
if (!evaluatingStatus)
return;
if (evaluatingStatus->fulfill)
JS::SetReservedSlot(evaluatingStatus->fulfill, 0, privateValue);
if (evaluatingStatus->reject)
JS::SetReservedSlot(evaluatingStatus->reject, 0, privateValue);
}
[[nodiscard]] ModuleLoader::Future ModuleLoader::LoadModule(const ScriptRequest& rq,
const VfsPath& modulePath)
{
return Future{rq, *this, modulePath};
}
/**
* This is only executed once per module. Following accesses of `import.meta`
* evaluate to the same object.
*/
[[nodiscard]] bool ModuleLoader::MetadataHook(JSContext* cx, JS::HandleValue privateValue,
JS::HandleObject metaObject) noexcept
{
const ScriptRequest rq{cx};
JS::RootedValue path{cx};
if (!Script::GetProperty(rq, privateValue, "path", &path))
return false;
JS::RootedValue metaValue{cx, JS::ObjectValue(*metaObject)};
if (!Script::SetProperty(rq, metaValue, "path", path))
return false;
return true;
}
[[nodiscard]] JSObject* ModuleLoader::ResolveHook(JSContext* cx, JS::HandleValue,
JS::HandleObject moduleRequest) noexcept
{
try
{
const ScriptRequest rq{cx};
return Resolve(rq, rq.GetScriptInterface().GetModuleLoader().m_Registry, moduleRequest);
}
catch (const std::exception& e)
{
LOGERROR("%s", e.what());
return nullptr;
}
catch (...)
{
LOGERROR("Error compiling module.");
return nullptr;
}
}
[[nodiscard]] bool ModuleLoader::DynamicImportHook(JSContext* cx, JS::HandleValue referencingPrivate,
JS::HandleObject moduleRequest, JS::HandleObject promise) noexcept
{
const ScriptRequest rq{cx};
try
{
JS::RootedObject mod{rq.cx, Resolve(rq, rq.GetScriptInterface().GetModuleLoader().m_Registry,
moduleRequest)};
JS::RootedObject evaluationPromise{rq.cx, Evaluate(rq, mod)};
return JS::FinishDynamicModuleImport(rq.cx, evaluationPromise, referencingPrivate,
moduleRequest, promise);
}
catch (const std::exception& e)
{
LOGERROR("%s", e.what());
return JS::FinishDynamicModuleImport(rq.cx, nullptr, referencingPrivate, moduleRequest,
promise);
}
catch (...)
{
return JS::FinishDynamicModuleImport(rq.cx, nullptr, referencingPrivate, moduleRequest,
promise);
}
}
} // namespace Script