From f22548e32fec9b49614deeda53e9e9bd6e848503 Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Fri, 9 Oct 2026 08:43:17 -0700 Subject: [PATCH 1/2] Support bundled imports through host-provided resources Expose _native.loadScript with a host resolver for immediate or asynchronous script resources, without a file or network fallback. Document a Webpack 5 configuration that waits for both chunk registration and host evaluation using Webpack's existing import promises. Create deferred promises only for rejected loader calls to avoid retaining unused promises during runtime shutdown. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CMakeLists.txt | 4 + Plugins/CMakeLists.txt | 3 + Plugins/DynamicScriptLoader/CMakeLists.txt | 16 ++ .../Babylon/Plugins/DynamicScriptLoader.h | 21 +++ Plugins/DynamicScriptLoader/README.md | 85 ++++++++++ .../Source/DynamicScriptLoader.cpp | 82 +++++++++ Tests/UnitTests/CMakeLists.txt | 4 + .../Source/Tests.DynamicScriptLoader.cpp | 156 ++++++++++++++++++ 8 files changed, 371 insertions(+) create mode 100644 Plugins/CMakeLists.txt create mode 100644 Plugins/DynamicScriptLoader/CMakeLists.txt create mode 100644 Plugins/DynamicScriptLoader/Include/Babylon/Plugins/DynamicScriptLoader.h create mode 100644 Plugins/DynamicScriptLoader/README.md create mode 100644 Plugins/DynamicScriptLoader/Source/DynamicScriptLoader.cpp create mode 100644 Tests/UnitTests/Source/Tests.DynamicScriptLoader.cpp 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..9aa8197d --- /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.HasOwnProperty("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/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()); +} From f82f1aec843839b5a6b40af9dc42bbdb015d3e27 Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Mon, 12 Oct 2026 15:23:22 -0700 Subject: [PATCH 2/2] Fix script loader installation and Android test coverage Use the supported named-property check to preserve an existing loadScript entry. HasOwnProperty misses duplicates on JavaScriptCore and is unimplemented on JSI. Include the loader tests and conditional library dependency in Android's separately executed UnitTestsJNI target. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Plugins/DynamicScriptLoader/Source/DynamicScriptLoader.cpp | 2 +- .../UnitTests/Source/Android/app/src/main/cpp/CMakeLists.txt | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Plugins/DynamicScriptLoader/Source/DynamicScriptLoader.cpp b/Plugins/DynamicScriptLoader/Source/DynamicScriptLoader.cpp index 9aa8197d..2d8ccae7 100644 --- a/Plugins/DynamicScriptLoader/Source/DynamicScriptLoader.cpp +++ b/Plugins/DynamicScriptLoader/Source/DynamicScriptLoader.cpp @@ -70,7 +70,7 @@ namespace Babylon::Plugins::DynamicScriptLoader } auto native = JsRuntime::NativeObject::GetFromJavaScript(env); - if (native.HasOwnProperty("loadScript")) + if (native.Has("loadScript")) { throw Napi::Error::New(env, "_native.loadScript is already defined"); } 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)