Repository navigation
Add host-controlled script loading for bundled imports #259
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
bghgary
merged 2 commits into
BabylonJS:main
from
bghgary:bghgary-support-dynamic-imports-embedded
Oct 14, 2026
+376
−0
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| if(JSRUNTIMEHOST_PLUGIN_DYNAMICSCRIPTLOADER) | ||
| add_subdirectory(DynamicScriptLoader) | ||
| endif() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}) |
21 changes: 21 additions & 0 deletions
21
Plugins/DynamicScriptLoader/Include/Babylon/Plugins/DynamicScriptLoader.h
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
82
Plugins/DynamicScriptLoader/Source/DynamicScriptLoader.cpp
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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")); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.