Skip to content
This repository was archived by the owner on Nov 9, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -369,6 +372,7 @@ endif()

add_subdirectory(Core)
add_subdirectory(Polyfills)
add_subdirectory(Plugins)

if(JSRUNTIMEHOST_TESTS AND NOT WINDOWS_STORE)
add_subdirectory(Tests)
Expand Down
3 changes: 3 additions & 0 deletions Plugins/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
if(JSRUNTIMEHOST_PLUGIN_DYNAMICSCRIPTLOADER)
add_subdirectory(DynamicScriptLoader)
endif()
16 changes: 16 additions & 0 deletions Plugins/DynamicScriptLoader/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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})
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#pragma once

#include <Babylon/Api.h>
#include <napi/env.h>

#include <functional>
#include <string>

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<Napi::Value BABYLON_API (Napi::Env, const std::string&)>;

// Installs _native.loadScript(name): Promise<void>. 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);
}
85 changes: 85 additions & 0 deletions Plugins/DynamicScriptLoader/README.md
Original file line number Diff line number Diff line change
@@ -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.h>

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.
82 changes: 82 additions & 0 deletions Plugins/DynamicScriptLoader/Source/DynamicScriptLoader.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#include <Babylon/Plugins/DynamicScriptLoader.h>
#include <Babylon/JsRuntime.h>

#include <stdexcept>
#include <utility>

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<Napi::String>().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<Napi::Function>();
auto resolve = promise.Get("resolve").As<Napi::Function>();
auto resolved = resolve.Call(promise, {source}).As<Napi::Promise>();
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<Napi::String>().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<Napi::Function>().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"));
}
}
4 changes: 4 additions & 0 deletions Tests/UnitTests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment thread
bghgary marked this conversation as resolved.
Resolved
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)
Expand Down
156 changes: 156 additions & 0 deletions Tests/UnitTests/Source/Tests.DynamicScriptLoader.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
#include <Babylon/AppRuntime.h>
#include <Babylon/JsRuntime.h>
#include <Babylon/Plugins/DynamicScriptLoader.h>
#include <Babylon/ScriptLoader.h>
#include <gtest/gtest.h>

#include <chrono>
#include <future>
#include <stdexcept>
#include <string>

TEST(DynamicScriptLoader, SupportsImmediateAndDeferredResources)
{
std::promise<std::string> 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<Napi::String>().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<std::string> 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<Napi::String>().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<std::string> 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<Napi::String>().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<bool> 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<Napi::Number>().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());
}
Loading