Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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.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"));
}
}
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.
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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading