Index: binaries/data/mods/_test.scriptinterface/modules/geometry/test_area.mjs =================================================================== --- /dev/null +++ binaries/data/mods/_test.scriptinterface/modules/geometry/test_area.mjs @@ -0,0 +1,5 @@ +import IMPORTED_PI from "../test_pi.mjs"; + +export function circle_area(radius) { + return IMPORTED_PI * (radius * radius); +} Index: binaries/data/mods/_test.scriptinterface/modules/test_circle.mjs =================================================================== --- /dev/null +++ binaries/data/mods/_test.scriptinterface/modules/test_circle.mjs @@ -0,0 +1,13 @@ +import {circle_area} from "./geometry/test_area.mjs"; + +class Circle { + constructor(radius) { + this.radius = radius; + } + + get area() { + return circle_area(this.radius); + } +} + +export default Circle; Index: binaries/data/mods/_test.scriptinterface/modules/test_pi.mjs =================================================================== --- /dev/null +++ binaries/data/mods/_test.scriptinterface/modules/test_pi.mjs @@ -0,0 +1 @@ +export default Math.PI; Index: binaries/data/mods/_test.scriptinterface/test.mjs =================================================================== --- /dev/null +++ binaries/data/mods/_test.scriptinterface/test.mjs @@ -0,0 +1,7 @@ +import circle from "./modules/test_circle.mjs"; + +const area = new circle(10).area; + +if (area != (Math.PI * 100)) { + throw new Error("Module Evalutation Error"); +} Index: source/lib/path.h =================================================================== --- source/lib/path.h +++ source/lib/path.h @@ -42,6 +42,8 @@ #include #include #include +#include +#include namespace ERR { @@ -217,6 +219,34 @@ return Parent() / Path(Basename().string() + extension.string()); } + Path Normalize() const + { + Path ret; + + std::vector dirs; + std::wstringstream wss(path); + std::wstring dir; + + while (std::getline(wss, dir, separator)) + dirs.push_back(dir); + + for (const std::wstring& dir : dirs) + { + if (dir == L".") + continue; + + if (dir == L"..") + { + ret = ret.Parent(); + continue; + } + + ret = ret / Path(dir); + } + + return ret; + } + Path operator/(Path rhs) const { Path ret = *this; Index: source/scriptinterface/ScriptExtraHeaders.h =================================================================== --- source/scriptinterface/ScriptExtraHeaders.h +++ source/scriptinterface/ScriptExtraHeaders.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2022 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -56,8 +56,9 @@ #include "js/ForOfIterator.h" #include "js/GCAPI.h" #include "js/GCHashTable.h" -#include "js/SourceText.h" +#include #include "js/Proxy.h" +#include "js/SourceText.h" #include "js/Warnings.h" #undef signbit Index: source/scriptinterface/ScriptInterface.h =================================================================== --- source/scriptinterface/ScriptInterface.h +++ source/scriptinterface/ScriptInterface.h @@ -55,6 +55,8 @@ // use their own threads and also their own contexts. extern thread_local std::shared_ptr g_ScriptContext; +class ScriptModuleLoader; + namespace boost { namespace random { class rand48; } } class Path; @@ -140,6 +142,8 @@ JSContext* GetGeneralJSContext() const; std::shared_ptr GetContext() const; + const ScriptModuleLoader& GetScriptModuleLoader() const; + /** * Load global scripts that most script interfaces need, * located in the /globalscripts directory. VFS must be initialized. @@ -203,6 +207,11 @@ */ bool LoadGlobalScriptFile(const VfsPath& path) const; + /** + * Load an ES module. VFS must be initialized. + */ + bool LoadESModule(const VfsPath& path) const; + /** * Evaluate some JS code in the global scope. * @return true on successful compilation and execution; false otherwise Index: source/scriptinterface/ScriptInterface.cpp =================================================================== --- source/scriptinterface/ScriptInterface.cpp +++ source/scriptinterface/ScriptInterface.cpp @@ -21,6 +21,7 @@ #include "ScriptContext.h" #include "ScriptExtraHeaders.h" #include "ScriptInterface.h" +#include "ScriptModuleLoader.h" #include "ScriptStats.h" #include "StructuredClone.h" @@ -66,6 +67,7 @@ public: boost::rand48* m_rng; JS::PersistentRootedObject m_nativeScope; // native function scope object + std::unique_ptr m_moduleLoader; }; /** @@ -300,7 +302,7 @@ } ScriptInterface_impl::ScriptInterface_impl(const char* nativeScopeName, const std::shared_ptr& context, JS::Compartment* compartment) : - m_context(context), m_cx(context->GetGeneralJSContext()), m_glob(context->GetGeneralJSContext()), m_nativeScope(context->GetGeneralJSContext()) + m_context(context), m_cx(context->GetGeneralJSContext()), m_glob(context->GetGeneralJSContext()), m_nativeScope(context->GetGeneralJSContext()), m_moduleLoader(std::make_unique()) { JS::RealmCreationOptions creationOpt; // Keep JIT code during non-shrinking GCs. This brings a quite big performance improvement. @@ -463,6 +465,11 @@ return m->m_context; } +const ScriptModuleLoader& ScriptInterface::GetScriptModuleLoader() const +{ + return *(m->m_moduleLoader); +} + void ScriptInterface::CallConstructor(JS::HandleValue ctor, JS::HandleValueArray argv, JS::MutableHandleValue out) const { ScriptRequest rq(this); @@ -684,6 +691,12 @@ return LoadGlobalScript(path, code); } +bool ScriptInterface::LoadESModule(const VfsPath& path) const +{ + ScriptRequest rq(this); + return m->m_moduleLoader->Load(rq.cx, path); +} + bool ScriptInterface::Eval(const char* code) const { ScriptRequest rq(this); Index: source/scriptinterface/ScriptModuleLoader.h =================================================================== --- /dev/null +++ source/scriptinterface/ScriptModuleLoader.h @@ -0,0 +1,54 @@ +/* Copyright (C) 2022 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 "ps/Errors.h" +#include "scriptinterface/ScriptConversions.h" +#include "scriptinterface/ScriptExceptions.h" +#include "scriptinterface/ScriptRequest.h" +#include "scriptinterface/ScriptTypes.h" + +#include +#include + +class ScriptModuleLoader +{ +public: + ScriptModuleLoader() = default; + ~ScriptModuleLoader() = default; + + bool Load(JSContext* cx, const VfsPath& modulePath); + +private: + + static JSObject* ModuleResolutionHook(JSContext* cx, JS::HandleValue referencingModule, JS::HandleString path); + + static Status FileChangedHook(void* param, const VfsPath& path); + + JSObject* CompileModule(JSContext* cx, const VfsPath& filePath) const; + + VfsPath GetScriptPath(JSContext* cx, JS::HandleValue moduleHandle) const; + + Status HandleChangedFile(const VfsPath& path) const; + + mutable std::map m_ResolvedModules; +}; + +#endif // INCLUDED_SCRIPTMODULELOADER Index: source/scriptinterface/ScriptModuleLoader.cpp =================================================================== --- /dev/null +++ source/scriptinterface/ScriptModuleLoader.cpp @@ -0,0 +1,165 @@ +/* Copyright (C) 2022 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 "ScriptModuleLoader.h" + +#include "ScriptContext.h" +#include "ScriptConversions.h" +#include "ScriptExtraHeaders.h" +#include "ScriptInterface.h" + +#include "lib/debug.h" +#include "lib/utf8.h" +#include "ps/CLogger.h" +#include "ps/CStr.h" +#include "ps/Filesystem.h" + +JSObject* ScriptModuleLoader::CompileModule(JSContext* cx, const VfsPath& filePath) const +{ + ScriptRequest rq(cx); + if (!VfsFileExists(filePath)) + { + LOGERROR("File '%s' does not exist", filePath.string8()); + return nullptr; + } + + if (filePath.Extension() != L".mjs" && false) + { + LOGERROR("File '%s' is not an ES module", filePath.string8()); + return nullptr; + } + + CVFSFile file; + + PSRETURN ret = file.Load(g_VFS, filePath); + if (ret != PSRETURN_OK) + { + LOGERROR("Failed to load file '%s': %s", filePath.string8(), GetErrorString(ret)); + return nullptr; + } + + CStr code = file.DecodeUTF8(); + + JS::CompileOptions options(cx); + std::string filePathStr = filePath.string8(); + options.setFileAndLine(filePathStr.c_str(), 1); + + JS::SourceText src; + ENSURE(src.init(cx, code.c_str(), code.length(), JS::SourceOwnership::Borrowed)); + + JS::RootedObject mod(cx, JS::CompileModule(cx, options, src)); + JS::RootedObject modInfo(cx, JS_NewPlainObject(cx)); + JS::RootedValue modPath(cx); + Script::ToJSVal(rq, &modPath, filePath.Parent()); + JS_DefineProperty(cx, modInfo, "path", modPath, JSPROP_ENUMERATE); + JS::SetModulePrivate(mod, JS::ObjectValue(*modInfo)); + + return mod; +} + +VfsPath ScriptModuleLoader::GetScriptPath(JSContext* cx, JS::HandleValue moduleHandle) const +{ + std::wstring ret; + + if (!moduleHandle.isUndefined()) + { + ScriptRequest rq(cx); + Script::FromJSProperty(rq, moduleHandle, "path", ret); + } + + return VfsPath(ret); +} + +JSObject* ScriptModuleLoader::ModuleResolutionHook(JSContext* cx, JS::HandleValue referencingModule, JS::HandleString path) +{ + ScriptRequest rq(cx); + const ScriptModuleLoader& instance = rq.GetScriptInterface().GetScriptModuleLoader(); + + std::wstring pathString; + JS::RootedValue pathValue(cx, JS::StringValue(path)); + Script::FromJSVal(rq, pathValue, pathString); + + VfsPath filename = VfsPath(pathString); + VfsPath refFilename = instance.GetScriptPath(cx, referencingModule); + + VfsPath actualPath = (refFilename / filename).Normalize(); + + std::map::iterator it = instance.m_ResolvedModules.find(actualPath); + if (it != instance.m_ResolvedModules.end()) + return it->second; + + JS::RootedObject mod(cx, instance.CompileModule(cx, actualPath)); + + if (!mod) + { + ScriptException::CatchPending(rq); + return nullptr; + } + + instance.m_ResolvedModules.emplace(actualPath, JS::PersistentRootedObject(cx, mod)); + + return mod; +} + +Status ScriptModuleLoader::FileChangedHook(void* param, const VfsPath& path) +{ + return static_cast(param)->HandleChangedFile(path); +} + +Status ScriptModuleLoader::HandleChangedFile(const VfsPath& path) const +{ + if (!VfsFileExists(path)) + { + LOGERROR("File '%s' does not exist", path.string8()); + return ERR::FAIL; + } + + if (path.Extension() != L".mjs" && false) + { + LOGERROR("File '%s' is not an ES module", path.string8()); + return ERR::FAIL; + } + + std::map::iterator it = m_ResolvedModules.find(path); + if (it != m_ResolvedModules.end()) + m_ResolvedModules.erase(it); // Let the next resolution recompile the module + + return INFO::OK; +} + +bool ScriptModuleLoader::Load(JSContext* cx, const VfsPath& modulePath) +{ + ScriptRequest rq(cx); + + JS::RootedObject global(cx, rq.glob); + if (!global) + return false; + + JS::SetModuleResolveHook(JS_GetRuntime(cx), ModuleResolutionHook); + + JS::RootedObject topModule(cx, CompileModule(cx, modulePath)); + + if (!topModule || !JS::ModuleInstantiate(cx, topModule) || !JS::ModuleEvaluate(cx, topModule)) + { + ScriptException::CatchPending(rq); + return false; + } + + return true; +} Index: source/scriptinterface/tests/test_ScriptInterface.h =================================================================== --- source/scriptinterface/tests/test_ScriptInterface.h +++ source/scriptinterface/tests/test_ScriptInterface.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2022 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -30,6 +30,17 @@ class TestScriptInterface : public CxxTest::TestSuite { public: + void setUp() + { + g_VFS = CreateVfs(); + TS_ASSERT_OK(g_VFS->Mount(L"", DataDir() / "mods" / "_test.scriptinterface" / "", VFS_MOUNT_MUST_EXIST)); + } + + void tearDown() + { + g_VFS.reset(); + } + void test_loadscript_basic() { ScriptInterface script("Test", "Test", g_ScriptContext); @@ -268,4 +279,13 @@ Script::FromJSVal(rq, out, outNbr); TS_ASSERT_EQUALS(2, outNbr); } + + void test_loadmodule() + { + ScriptInterface script("Test", "Test", g_ScriptContext); + TestLogger logger; + TS_ASSERT(script.LoadESModule(L"test.mjs")); + TS_ASSERT_STR_NOT_CONTAINS(logger.GetOutput(), "JavaScript error"); + TS_ASSERT_STR_NOT_CONTAINS(logger.GetOutput(), "JavaScript warning"); + } };