diff --git a/CMakeLists.txt b/CMakeLists.txt index a195c1eb..894b2b1e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -103,6 +103,9 @@ option(JSRUNTIMEHOST_POLYFILL_PERFORMANCE "Include JsRuntimeHost Polyfill Perfor option(JSRUNTIMEHOST_POLYFILL_TEXTDECODER "Include JsRuntimeHost Polyfill TextDecoder." ON) option(JSRUNTIMEHOST_POLYFILL_TEXTENCODER "Include JsRuntimeHost Polyfill TextEncoder." ON) +# Plugins +option(JSRUNTIMEHOST_PLUGIN_DYNAMICSCRIPTLOADER "Include JsRuntimeHost asynchronous resource script loader." ON) + # Compatibility with the previous AbortController option. if(DEFINED JSRUNTIMEHOST_POLYFILL_ABORT_CONTROLLER) set(JSRUNTIMEHOST_POLYFILL_ABORTCONTROLLER "${JSRUNTIMEHOST_POLYFILL_ABORT_CONTROLLER}") @@ -369,6 +372,7 @@ endif() add_subdirectory(Core) add_subdirectory(Polyfills) +add_subdirectory(Plugins) if(JSRUNTIMEHOST_TESTS AND NOT WINDOWS_STORE) add_subdirectory(Tests) diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt new file mode 100644 index 00000000..191f7370 --- /dev/null +++ b/Plugins/CMakeLists.txt @@ -0,0 +1,3 @@ +if(JSRUNTIMEHOST_PLUGIN_DYNAMICSCRIPTLOADER) + add_subdirectory(DynamicScriptLoader) +endif() diff --git a/Plugins/DynamicScriptLoader/CMakeLists.txt b/Plugins/DynamicScriptLoader/CMakeLists.txt new file mode 100644 index 00000000..4834d183 --- /dev/null +++ b/Plugins/DynamicScriptLoader/CMakeLists.txt @@ -0,0 +1,16 @@ +set(SOURCES + "Include/Babylon/Plugins/DynamicScriptLoader.h" + "Source/DynamicScriptLoader.cpp") + +add_library(DynamicScriptLoader ${SOURCES}) +warnings_as_errors(DynamicScriptLoader) + +target_include_directories(DynamicScriptLoader PUBLIC "Include") + +target_link_libraries(DynamicScriptLoader + PUBLIC Foundation + PUBLIC JsRuntime + PUBLIC napi) + +set_property(TARGET DynamicScriptLoader PROPERTY FOLDER Plugins) +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) diff --git a/Plugins/DynamicScriptLoader/Include/Babylon/Plugins/DynamicScriptLoader.h b/Plugins/DynamicScriptLoader/Include/Babylon/Plugins/DynamicScriptLoader.h new file mode 100644 index 00000000..1841bdfe --- /dev/null +++ b/Plugins/DynamicScriptLoader/Include/Babylon/Plugins/DynamicScriptLoader.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +#include +#include + +namespace Babylon::Plugins::DynamicScriptLoader +{ + // Return source as a JavaScript string or a Promise resolving to one. + // Return null/undefined (or reject) when the resource is unavailable. + // Called on the JavaScript thread; any asynchronous completion must also use + // the runtime dispatcher before accessing Node-API values. + using ResolverT = std::function; + + // Installs _native.loadScript(name): Promise. The resolver alone determines + // which resources can be loaded; no file or network fallback is provided. + // The host must stop asynchronous producers before destroying the runtime. + void BABYLON_API Initialize(Napi::Env env, ResolverT resolver); +} diff --git a/Plugins/DynamicScriptLoader/README.md b/Plugins/DynamicScriptLoader/README.md new file mode 100644 index 00000000..fca496ac --- /dev/null +++ b/Plugins/DynamicScriptLoader/README.md @@ -0,0 +1,85 @@ +# Dynamic script loader + +The optional C++ `DynamicScriptLoader` plugin installs `_native.loadScript(name)`, which returns a promise that settles after evaluating source in the current JavaScript context. The host supplies a resolver that returns either a JavaScript string or a promise of a string. Missing resources (null or undefined), rejected lookups, and evaluation errors reject the returned promise. No file or network loader is installed by JsRuntimeHost. + +```cpp +#include + +Babylon::Plugins::DynamicScriptLoader::Initialize(env, + [](Napi::Env env, const std::string& name) -> Napi::Value { + auto source = FindPackagedScript(name); + return source ? Napi::String::New(env, *source) : env.Null(); + }); +``` + +Initialize the plugin after `JsRuntime` and before evaluating the entry bundle. Link the `DynamicScriptLoader` target; `JSRUNTIMEHOST_PLUGIN_DYNAMICSCRIPTLOADER` controls whether it is built. It uses the existing `_native` object and does not install global `loadScript`, `importScripts`, or native `import()` syntax. Names must be nonempty strings without NUL characters; source containing NUL characters is rejected because the evaluation API accepts null-terminated strings. Empty source is allowed. + +The resolver runs on the JavaScript thread. For asynchronous resolvers, return `Napi::Promise::Deferred::Promise()` and resolve it with source on a later JavaScript dispatch turn, or reject it with the lookup error. The host must stop asynchronous producers before destroying the runtime; it must not access Node-API values or dispatch through a destroyed runtime. The loader does not cache scripts or cancel host operations. + +## Bundled imports + +The runtime plugin is bundler-independent: it evaluates classic scripts, not untransformed ECMAScript modules. A bundler transforms source-level `import()` and manages exports, dependencies, caching, and concurrent imports. Its chunk transport must call `_native.loadScript` instead of a browser or filesystem loader. + +### Webpack 5 example + +The following `webpack.config.cjs` is an application-side example, not a separate JsRuntimeHost build package. It keeps Webpack's chunk registration and adds the host evaluation promise to the promises each import already waits for. JsRuntimeHost supplies `window` as an alias for the script global; no document is required. + +```js +module.exports = { + entry: "./src/index.js", + target: ["web", "es5"], + mode: "production", + devtool: false, + output: { + filename: "entry.js", + chunkFilename: "[name].js", + chunkFormat: "array-push", + chunkLoading: "jsonp", + globalObject: "window", + publicPath: "", + }, + plugins: [{ + apply(compiler) { + const { RuntimeGlobals, RuntimeModule } = compiler.webpack; + compiler.hooks.thisCompilation.tap("HostScriptLoading", compilation => { + compilation.hooks.runtimeModule.tap("HostScriptLoading", module => { + if (module.name === "load script") { + module.generate = () => ""; + } + }); + compilation.hooks.runtimeRequirementInTree.for(RuntimeGlobals.loadScript) + .tap("HostScriptLoading", chunk => { + const runtime = new RuntimeModule("host script loading", RuntimeModule.STAGE_ATTACH + 1); + runtime.generate = () => ` +var loads = Object.create(null); +${RuntimeGlobals.loadScript} = function(name, done) { + var load = loads[name] = Promise.resolve().then(function() { + return _native.loadScript(name); + }); + load.then(function() { + done({ type: "load", target: { src: name } }); + }, function() { + done({ type: "error", target: { src: name } }); + }); +}; +var ensureChunk = ${RuntimeGlobals.ensureChunkHandlers}.j; +${RuntimeGlobals.ensureChunkHandlers}.j = function(chunkId, promises) { + ensureChunk(chunkId, promises); + var load = loads[${RuntimeGlobals.publicPath} + ${RuntimeGlobals.getChunkScriptFilename}(chunkId)]; + if (load) { + promises.push(load); + } +};`; + compilation.addRuntimeModule(chunk, runtime); + }); + }); + }, + }], +}; +``` + +Initialize the runtime plugin before evaluating `entry.js`. Package every emitted JavaScript chunk under its emitted relative name; the host resolver decides which names are accepted. For browser builds, omit the example's plugin and use the normal browser output settings. Other bundlers need their own chunk-transport integration. + +The import promise waits for both chunk registration and complete script evaluation. Lookup and evaluation failures reject it; successful evaluation without the expected registration rejects with Webpack's `ChunkLoadError`. Failed lookups can be retried. Evaluation failure after registration is not rolled back; discard the runtime if that failure leaves application state unusable. + +This example uses classic JavaScript chunks only. Do not combine it with hot module replacement, a development server, browser prefetch/preload hints, CSS chunk loading, or native-module externals. The host must complete or reject each resource operation; no timeout is added. Select source transpilation appropriate for the engine: Webpack's `target` controls its generated runtime, not all syntax in application source. diff --git a/Plugins/DynamicScriptLoader/Source/DynamicScriptLoader.cpp b/Plugins/DynamicScriptLoader/Source/DynamicScriptLoader.cpp new file mode 100644 index 00000000..2d8ccae7 --- /dev/null +++ b/Plugins/DynamicScriptLoader/Source/DynamicScriptLoader.cpp @@ -0,0 +1,82 @@ +#include +#include + +#include +#include + +namespace Babylon::Plugins::DynamicScriptLoader +{ + namespace + { + Napi::Value LoadScript(const Napi::CallbackInfo& info, const ResolverT& resolver) + { + auto env = info.Env(); + const auto reject = [env](Napi::Value error) { + const auto deferred = Napi::Promise::Deferred::New(env); + deferred.Reject(error); + return deferred.Promise(); + }; + if (info.Length() != 1 || !info[0].IsString()) + { + return reject(Napi::TypeError::New(env, "loadScript expects one resource name").Value()); + } + + const auto name = info[0].As().Utf8Value(); + if (name.empty() || name.find('\0') != std::string::npos) + { + return reject(Napi::TypeError::New(env, "loadScript expects a nonempty resource name without NUL characters").Value()); + } + try + { + auto source = resolver(env, name); + if (env.IsExceptionPending()) + { + throw env.GetAndClearPendingException(); + } + auto promise = env.Global().Get("Promise").As(); + auto resolve = promise.Get("resolve").As(); + auto resolved = resolve.Call(promise, {source}).As(); + auto evaluate = Napi::Function::New(env, [name](const Napi::CallbackInfo& callback) { + if (!callback[0].IsString()) + { + throw Napi::TypeError::New(callback.Env(), "Script source not found: " + name); + } + auto text = callback[0].As().Utf8Value(); + // The engine-independent Eval API takes a null-terminated string. + if (text.find('\0') != std::string::npos) + { + throw Napi::TypeError::New(callback.Env(), "Script source contains a NUL character: " + name); + } + Napi::Eval(callback.Env(), text.c_str(), name.c_str()); + }); + return resolved.Get("then").As().Call(resolved, {evaluate}); + } + catch (const Napi::Error& error) + { + return reject(error.Value()); + } + catch (const std::exception& error) + { + return reject(Napi::Error::New(env, error.what()).Value()); + } + } + } + + void BABYLON_API Initialize(Napi::Env env, ResolverT resolver) + { + if (!resolver) + { + throw std::invalid_argument{"loadScript requires a resource resolver"}; + } + + auto native = JsRuntime::NativeObject::GetFromJavaScript(env); + if (native.Has("loadScript")) + { + throw Napi::Error::New(env, "_native.loadScript is already defined"); + } + + native.Set("loadScript", Napi::Function::New(env, [resolver = std::move(resolver)](const Napi::CallbackInfo& info) { + return LoadScript(info, resolver); + }, "loadScript")); + } +} diff --git a/Tests/UnitTests/CMakeLists.txt b/Tests/UnitTests/CMakeLists.txt index 4171ff13..c5568472 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -72,6 +72,10 @@ elseif(NAPI_JAVASCRIPT_ENGINE STREQUAL "JavaScriptCore") endif() add_executable(UnitTests ${SOURCES} ${TEST_JAVASCRIPT} ${SCRIPTS} ${ASSETS}) +if(JSRUNTIMEHOST_PLUGIN_DYNAMICSCRIPTLOADER) + target_sources(UnitTests PRIVATE "Source/Tests.DynamicScriptLoader.cpp") + target_link_libraries(UnitTests PRIVATE DynamicScriptLoader) +endif() if(NAPI_JAVASCRIPT_ENGINE STREQUAL "V8") target_sources(UnitTests PRIVATE "Source/Tests.V8ForegroundTaskRunner.cpp") target_link_libraries(UnitTests PRIVATE AppRuntimeInternal) diff --git a/Tests/UnitTests/Source/Android/app/src/main/cpp/CMakeLists.txt b/Tests/UnitTests/Source/Android/app/src/main/cpp/CMakeLists.txt index aa933dba..bcc869e5 100644 --- a/Tests/UnitTests/Source/Android/app/src/main/cpp/CMakeLists.txt +++ b/Tests/UnitTests/Source/Android/app/src/main/cpp/CMakeLists.txt @@ -29,6 +29,11 @@ add_library(UnitTestsJNI SHARED ${UNIT_TESTS_DIR}/Source/Tests.StandardStreamLogger.cpp ${UNIT_TESTS_DIR}/Source/Tests.TimeoutDispatcher.cpp) +if(JSRUNTIMEHOST_PLUGIN_DYNAMICSCRIPTLOADER) + target_sources(UnitTestsJNI PRIVATE ${UNIT_TESTS_DIR}/Source/Tests.DynamicScriptLoader.cpp) + target_link_libraries(UnitTestsJNI PRIVATE DynamicScriptLoader) +endif() + if(NAPI_JAVASCRIPT_ENGINE STREQUAL "V8") target_sources(UnitTestsJNI PRIVATE ${UNIT_TESTS_DIR}/Source/Tests.V8ForegroundTaskRunner.cpp) target_link_libraries(UnitTestsJNI PRIVATE AppRuntimeInternal) diff --git a/Tests/UnitTests/Source/Tests.DynamicScriptLoader.cpp b/Tests/UnitTests/Source/Tests.DynamicScriptLoader.cpp new file mode 100644 index 00000000..2b43f1a9 --- /dev/null +++ b/Tests/UnitTests/Source/Tests.DynamicScriptLoader.cpp @@ -0,0 +1,156 @@ +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +TEST(DynamicScriptLoader, SupportsImmediateAndDeferredResources) +{ + std::promise result; + Babylon::AppRuntime runtime{}; + runtime.Dispatch([&result](Napi::Env env) { + Babylon::Plugins::DynamicScriptLoader::Initialize(env, [](Napi::Env env, const std::string& name) -> Napi::Value { + if (name == "immediate.js") + { + return Napi::String::New(env, "window.loaded = 1;"); + } + if (name == "deferred.js") + { + auto deferred = Napi::Promise::Deferred::New(env); + Babylon::JsRuntime::GetFromJavaScript(env).Dispatch([deferred](Napi::Env callbackEnv) { + deferred.Resolve(Napi::String::New(callbackEnv, "window.loaded += 41;")); + }); + return deferred.Promise(); + } + return env.Null(); + }); + env.Global().Set("reportResult", Napi::Function::New(env, [&result](const Napi::CallbackInfo& info) { + result.set_value(info[0].As().Utf8Value()); + })); + }); + + Babylon::ScriptLoader loader{runtime}; + loader.Eval(R"( + _native.loadScript("immediate.js") + .then(function () { return _native.loadScript("deferred.js"); }) + .then(function () { return _native.loadScript("missing.js"); }) + .then(function () { reportResult("missing script loaded"); }, + function (error) { reportResult(window.loaded + "|" + error.message); }) + .catch(function (error) { reportResult("unexpected: " + error.message); }); + )", "entry.js"); + + auto future = result.get_future(); + ASSERT_EQ(future.wait_for(std::chrono::seconds(10)), std::future_status::ready); + EXPECT_EQ(future.get(), "42|Script source not found: missing.js"); +} + +TEST(DynamicScriptLoader, RejectsResolverAndScriptErrors) +{ + std::promise result; + Babylon::AppRuntime runtime{}; + runtime.Dispatch([&result](Napi::Env env) { + Babylon::Plugins::DynamicScriptLoader::Initialize(env, [](Napi::Env env, const std::string& name) -> Napi::Value { + if (name == "broken.js") + { + return Napi::String::New(env, "throw new Error('script failed')"); + } + if (name == "rejected.js") + { + return Napi::Eval(env, "Promise.reject(new Error('lookup failed'))", "resolver.js"); + } + throw std::runtime_error{"resolver failed"}; + }); + env.Global().Set("reportResult", Napi::Function::New(env, [&result](const Napi::CallbackInfo& info) { + result.set_value(info[0].As().Utf8Value()); + })); + }); + + Babylon::ScriptLoader loader{runtime}; + loader.Eval(R"( + Promise.all([ + _native.loadScript("broken.js").then(null, function (error) { return error.message; }), + _native.loadScript("rejected.js").then(null, function (error) { return error.message; }), + _native.loadScript("throw.js").then(null, function (error) { return error.message; }), + _native.loadScript().then(null, function (error) { return error.name; }) + ]).then(function (errors) { reportResult(errors.join("|")); }); + )", "entry.js"); + + auto future = result.get_future(); + ASSERT_EQ(future.wait_for(std::chrono::seconds(10)), std::future_status::ready); + EXPECT_EQ(future.get(), "script failed|lookup failed|resolver failed|TypeError"); +} + +TEST(DynamicScriptLoader, RejectsInvalidNamesAndSources) +{ + std::promise result; + Babylon::AppRuntime runtime{}; + runtime.Dispatch([&result](Napi::Env env) { + Babylon::Plugins::DynamicScriptLoader::Initialize(env, [](Napi::Env env, const std::string& name) -> Napi::Value { + if (name == "nul-source") + { + std::string source{"window.truncated = true;"}; + source.push_back('\0'); + source += "throw new Error('ignored');"; + return Napi::String::New(env, source); + } + if (name == "pending-error") + { + Napi::Error::New(env, "pending resolver error").ThrowAsJavaScriptException(); + return env.Undefined(); + } + if (name == "number") + { + return Napi::Number::New(env, 42); + } + return Napi::String::New(env, ""); + }); + env.Global().Set("reportResult", Napi::Function::New(env, [&result](const Napi::CallbackInfo& info) { + result.set_value(info[0].As().Utf8Value()); + })); + Napi::Eval(env, R"( + function rejected(promise) { + return promise.then(function () { return "accepted"; }, function (error) { return error.name; }); + } + Promise.all([ + rejected(_native.loadScript("")), + rejected(_native.loadScript("nul\u0000name")), + rejected(_native.loadScript(1)), + rejected(_native.loadScript("one", "two")), + rejected(_native.loadScript("nul-source")), + rejected(_native.loadScript("number")), + _native.loadScript("pending-error").then(null, function (error) { return error.message; }), + _native.loadScript("empty-source").then(function (value) { return typeof value; }) + ]).then(function (results) { + results.push(typeof window.truncated); + reportResult(results.join("|")); + }); + )", "invalid-input.js"); + }); + auto future = result.get_future(); + ASSERT_EQ(future.wait_for(std::chrono::seconds(10)), std::future_status::ready); + EXPECT_EQ(future.get(), "TypeError|TypeError|TypeError|TypeError|TypeError|TypeError|pending resolver error|undefined|undefined"); +} + +TEST(DynamicScriptLoader, PreservesExistingNativeProperties) +{ + std::promise result; + Babylon::AppRuntime runtime{}; + runtime.Dispatch([&result](Napi::Env env) { + auto native = Babylon::JsRuntime::NativeObject::GetFromJavaScript(env); + native.Set("existing", Napi::Number::New(env, 42)); + auto resolver = [](Napi::Env env, const std::string&) -> Napi::Value { return env.Undefined(); }; + EXPECT_THROW(Babylon::Plugins::DynamicScriptLoader::Initialize(env, {}), std::invalid_argument); + Babylon::Plugins::DynamicScriptLoader::Initialize(env, resolver); + EXPECT_THROW(Babylon::Plugins::DynamicScriptLoader::Initialize(env, resolver), Napi::Error); + result.set_value(native.Get("existing").As().Int32Value() == 42 && + env.Global().Get("loadScript").IsUndefined() && env.Global().Get("importScripts").IsUndefined()); + }); + auto future = result.get_future(); + ASSERT_EQ(future.wait_for(std::chrono::seconds(10)), std::future_status::ready); + EXPECT_TRUE(future.get()); +}