From 08b5254e186007b79e99a44df9df86d255945c53 Mon Sep 17 00:00:00 2001 From: Gary Hsu Date: Mon, 28 Sep 2026 16:47:21 -0700 Subject: [PATCH] Add resolver-backed dynamic script loading Expose promise-based script loading for hosts with asynchronous resources and a synchronous importScripts adapter for bundles with packaged chunks. Keep resource lookup host-owned and test resolution, ordering, and failure propagation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CMakeLists.txt | 2 + Polyfills/CMakeLists.txt | 8 ++ Polyfills/DynamicScriptLoader/CMakeLists.txt | 15 +++ .../Babylon/Polyfills/DynamicScriptLoader.h | 20 ++++ .../Source/DynamicScriptLoader.cpp | 60 ++++++++++ Polyfills/ImportScripts/CMakeLists.txt | 15 +++ .../Include/Babylon/Polyfills/ImportScripts.h | 20 ++++ .../ImportScripts/Source/ImportScripts.cpp | 60 ++++++++++ README.md | 52 ++++++++ Tests/UnitTests/CMakeLists.txt | 4 + .../Source/Tests.DynamicScriptLoader.cpp | 86 ++++++++++++++ .../UnitTests/Source/Tests.ImportScripts.cpp | 112 ++++++++++++++++++ 12 files changed, 454 insertions(+) create mode 100644 Polyfills/DynamicScriptLoader/CMakeLists.txt create mode 100644 Polyfills/DynamicScriptLoader/Include/Babylon/Polyfills/DynamicScriptLoader.h create mode 100644 Polyfills/DynamicScriptLoader/Source/DynamicScriptLoader.cpp create mode 100644 Polyfills/ImportScripts/CMakeLists.txt create mode 100644 Polyfills/ImportScripts/Include/Babylon/Polyfills/ImportScripts.h create mode 100644 Polyfills/ImportScripts/Source/ImportScripts.cpp create mode 100644 Tests/UnitTests/Source/Tests.DynamicScriptLoader.cpp create mode 100644 Tests/UnitTests/Source/Tests.ImportScripts.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 21882d3e..19bf43d4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -102,6 +102,8 @@ option(JSRUNTIMEHOST_POLYFILL_FILE "Include JsRuntimeHost Polyfill File and File option(JSRUNTIMEHOST_POLYFILL_PERFORMANCE "Include JsRuntimeHost Polyfill Performance." ON) option(JSRUNTIMEHOST_POLYFILL_TEXTDECODER "Include JsRuntimeHost Polyfill TextDecoder." ON) option(JSRUNTIMEHOST_POLYFILL_TEXTENCODER "Include JsRuntimeHost Polyfill TextEncoder." ON) +option(JSRUNTIMEHOST_POLYFILL_IMPORT_SCRIPTS "Include JsRuntimeHost Polyfill importScripts." ON) +option(JSRUNTIMEHOST_POLYFILL_DYNAMIC_SCRIPT_LOADER "Include JsRuntimeHost asynchronous script loader." ON) # Sanitizers option(ENABLE_SANITIZERS "Enable AddressSanitizer and UBSan" OFF) diff --git a/Polyfills/CMakeLists.txt b/Polyfills/CMakeLists.txt index a44765fb..e44b4ad3 100644 --- a/Polyfills/CMakeLists.txt +++ b/Polyfills/CMakeLists.txt @@ -44,4 +44,12 @@ endif() if(JSRUNTIMEHOST_POLYFILL_TEXTENCODER) add_subdirectory(TextEncoder) +endif() + +if(JSRUNTIMEHOST_POLYFILL_IMPORT_SCRIPTS) + add_subdirectory(ImportScripts) +endif() + +if(JSRUNTIMEHOST_POLYFILL_DYNAMIC_SCRIPT_LOADER) + add_subdirectory(DynamicScriptLoader) endif() \ No newline at end of file diff --git a/Polyfills/DynamicScriptLoader/CMakeLists.txt b/Polyfills/DynamicScriptLoader/CMakeLists.txt new file mode 100644 index 00000000..7f126829 --- /dev/null +++ b/Polyfills/DynamicScriptLoader/CMakeLists.txt @@ -0,0 +1,15 @@ +set(SOURCES + "Include/Babylon/Polyfills/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 napi) + +set_property(TARGET DynamicScriptLoader PROPERTY FOLDER Polyfills) +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) diff --git a/Polyfills/DynamicScriptLoader/Include/Babylon/Polyfills/DynamicScriptLoader.h b/Polyfills/DynamicScriptLoader/Include/Babylon/Polyfills/DynamicScriptLoader.h new file mode 100644 index 00000000..bf891207 --- /dev/null +++ b/Polyfills/DynamicScriptLoader/Include/Babylon/Polyfills/DynamicScriptLoader.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +#include +#include + +namespace Babylon::Polyfills::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 loadScript(name): Promise. The resolver alone determines + // which resources can be loaded; no file or network fallback is provided. + void BABYLON_API Initialize(Napi::Env env, ResolverT resolver); +} diff --git a/Polyfills/DynamicScriptLoader/Source/DynamicScriptLoader.cpp b/Polyfills/DynamicScriptLoader/Source/DynamicScriptLoader.cpp new file mode 100644 index 00000000..fa81fdeb --- /dev/null +++ b/Polyfills/DynamicScriptLoader/Source/DynamicScriptLoader.cpp @@ -0,0 +1,60 @@ +#include + +#include +#include + +namespace Babylon::Polyfills::DynamicScriptLoader +{ + void BABYLON_API Initialize(Napi::Env env, ResolverT resolver) + { + if (!resolver) + { + throw std::invalid_argument{"loadScript requires a resource resolver"}; + } + + auto global = env.Global(); + if (!global.Get("loadScript").IsUndefined()) + { + throw Napi::Error::New(env, "loadScript is already defined"); + } + + global.Set("loadScript", + Napi::Function::New(env, [resolver = std::move(resolver)](const Napi::CallbackInfo& info) -> Napi::Value { + auto env = info.Env(); + const auto deferred = Napi::Promise::Deferred::New(env); + if (info.Length() != 1 || !info[0].IsString()) + { + deferred.Reject(Napi::TypeError::New(env, "loadScript expects one resource name").Value()); + return deferred.Promise(); + } + + const auto name = info[0].As().Utf8Value(); + try + { + auto source = resolver(env, name); + 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(); + Napi::Eval(callback.Env(), text.c_str(), name.c_str()); + }); + return resolved.Get("then").As().Call(resolved, {evaluate}); + } + catch (const Napi::Error& error) + { + deferred.Reject(error.Value()); + } + catch (const std::exception& error) + { + deferred.Reject(Napi::Error::New(env, error.what()).Value()); + } + return deferred.Promise(); + }, + "loadScript")); + } +} diff --git a/Polyfills/ImportScripts/CMakeLists.txt b/Polyfills/ImportScripts/CMakeLists.txt new file mode 100644 index 00000000..4554e80b --- /dev/null +++ b/Polyfills/ImportScripts/CMakeLists.txt @@ -0,0 +1,15 @@ +set(SOURCES + "Include/Babylon/Polyfills/ImportScripts.h" + "Source/ImportScripts.cpp") + +add_library(ImportScripts ${SOURCES}) +warnings_as_errors(ImportScripts) + +target_include_directories(ImportScripts PUBLIC "Include") + +target_link_libraries(ImportScripts + PUBLIC Foundation + PUBLIC napi) + +set_property(TARGET ImportScripts PROPERTY FOLDER Polyfills) +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) diff --git a/Polyfills/ImportScripts/Include/Babylon/Polyfills/ImportScripts.h b/Polyfills/ImportScripts/Include/Babylon/Polyfills/ImportScripts.h new file mode 100644 index 00000000..b410c2be --- /dev/null +++ b/Polyfills/ImportScripts/Include/Babylon/Polyfills/ImportScripts.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace Babylon::Polyfills::ImportScripts +{ + // Return source for an explicitly packaged script, or nullopt when it is unavailable. + // The resolver runs synchronously on the JavaScript thread and must not load arbitrary files. + using ResolverT = std::function BABYLON_API (const std::string&)>; + + // Install importScripts(...names) for bundles split by a bundler. Resource + // names must be strings; each is evaluated in order in the current context. + // This does not enable native ES module syntax in engines such as Chakra. + void BABYLON_API Initialize(Napi::Env env, ResolverT resolver); +} diff --git a/Polyfills/ImportScripts/Source/ImportScripts.cpp b/Polyfills/ImportScripts/Source/ImportScripts.cpp new file mode 100644 index 00000000..67cf61e4 --- /dev/null +++ b/Polyfills/ImportScripts/Source/ImportScripts.cpp @@ -0,0 +1,60 @@ +#include + +#include +#include + +namespace Babylon::Polyfills::ImportScripts +{ + void BABYLON_API Initialize(Napi::Env env, ResolverT resolver) + { + if (!resolver) + { + throw std::invalid_argument{"importScripts requires a resource resolver"}; + } + + auto global = env.Global(); + if (!global.Get("importScripts").IsUndefined()) + { + throw Napi::Error::New(env, "importScripts is already defined"); + } + + if (global.Get("self").IsUndefined()) + { + global.Set("self", global); + } + + global.Set("importScripts", + Napi::Function::New(env, [resolver = std::move(resolver)](const Napi::CallbackInfo& info) { + for (size_t index = 0; index < info.Length(); ++index) + { + if (!info[index].IsString()) + { + throw Napi::TypeError::New(info.Env(), "importScripts expects resource names as strings"); + } + + const auto name = info[index].As().Utf8Value(); + std::optional source; + try + { + source = resolver(name); + } + catch (const Napi::Error&) + { + throw; + } + catch (const std::exception& error) + { + throw Napi::Error::New(info.Env(), error.what()); + } + + if (!source) + { + throw Napi::Error::New(info.Env(), "Embedded script not found: " + name); + } + + Napi::Eval(info.Env(), source->c_str(), name.c_str()); + } + }, + "importScripts")); + } +} diff --git a/README.md b/README.md index 334bed98..e5a81f54 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,58 @@ polyfills that consumers can include if required. > not supported on Apple platforms (iOS or macOS)** — configuring the build with > `NAPI_JAVASCRIPT_ENGINE=Hermes` on those targets will fail with a CMake error. +## Dynamic JavaScript loading + +The optional `DynamicScriptLoader` polyfill installs `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, so resources may be available immediately or retrieved +asynchronously. Missing resources (null or undefined), rejected lookups, and +evaluation errors reject the returned promise. The resolver runs on the +JavaScript thread; if an asynchronous host operation finishes on another thread, +dispatch back to the JavaScript thread before resolving its promise. No file or +network loader is installed by JsRuntimeHost. + +```cpp +#include + +Babylon::Polyfills::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(); + }); +``` + +For hosts with synchronous packaged resources, the optional `ImportScripts` +polyfill provides a worker-style `importScripts(...names)` loader. Install it before +evaluating the entry script, and return source only for exact packaged names: + +```cpp +#include + +Babylon::Polyfills::ImportScripts::Initialize(env, + [](const std::string& name) -> std::optional { + return FindEmbeddedChunk(name); // nullopt for names not packaged by the host + }); +``` + +This loader also exposes `self` if absent. It accepts string resource names +and evaluates them synchronously in order; missing chunks and evaluation +failures throw JavaScript errors. Both resolvers +control which names are accepted; neither polyfill adds a file or network +fallback. The `JSRUNTIMEHOST_POLYFILL_DYNAMIC_SCRIPT_LOADER` and +`JSRUNTIMEHOST_POLYFILL_IMPORT_SCRIPTS` options control the respective libraries. + +For source-level `import("./chunk")`, configure the native Webpack build with +`output.chunkLoading: "import-scripts"` and `output.chunkFormat: "array-push"`, +then package its emitted chunks for the resolver. Webpack still returns a promise +from `import()`; its native loader calls `importScripts` to install a chunk before +resolving that promise. Hosts with only asynchronous resource access instead need +a bundler chunk loader that awaits `loadScript(name)`; Webpack's built-in +`import-scripts` loader cannot await it. A browser build can use its normal +asynchronous URL-based chunk loader. Neither polyfill adds native ES module parsing +to engines such as Chakra. + ## **Building - All Development Platforms** diff --git a/Tests/UnitTests/CMakeLists.txt b/Tests/UnitTests/CMakeLists.txt index 69cc6cac..a08ebdcb 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -26,7 +26,9 @@ set(SOURCES "Source/Tests.AppRuntime.cpp" "Source/Tests.Console.cpp" "Source/Tests.DelayedTaskScheduler.cpp" + "Source/Tests.DynamicScriptLoader.cpp" "Source/Tests.JavaScript.cpp" + "Source/Tests.ImportScripts.cpp" "Source/Tests.NodeApi.cpp" "Source/Tests.Scheduling.cpp" "Source/Tests.StandardStreamLogger.cpp" @@ -79,6 +81,8 @@ endif() target_link_libraries(UnitTests PRIVATE AppRuntime + PRIVATE DynamicScriptLoader + PRIVATE ImportScripts PRIVATE Console PRIVATE AbortController PRIVATE SchedulingInternal diff --git a/Tests/UnitTests/Source/Tests.DynamicScriptLoader.cpp b/Tests/UnitTests/Source/Tests.DynamicScriptLoader.cpp new file mode 100644 index 00000000..4e4cc46f --- /dev/null +++ b/Tests/UnitTests/Source/Tests.DynamicScriptLoader.cpp @@ -0,0 +1,86 @@ +#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::Polyfills::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"( + loadScript("immediate.js") + .then(function () { return loadScript("deferred.js"); }) + .then(function () { return 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::Polyfills::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([ + loadScript("broken.js").then(null, function (error) { return error.message; }), + loadScript("rejected.js").then(null, function (error) { return error.message; }), + loadScript("throw.js").then(null, function (error) { return error.message; }), + 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"); +} diff --git a/Tests/UnitTests/Source/Tests.ImportScripts.cpp b/Tests/UnitTests/Source/Tests.ImportScripts.cpp new file mode 100644 index 00000000..cefeded8 --- /dev/null +++ b/Tests/UnitTests/Source/Tests.ImportScripts.cpp @@ -0,0 +1,112 @@ +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +TEST(ImportScripts, LoadsOnlyResolvedResources) +{ + std::promise result; + Babylon::AppRuntime runtime{}; + runtime.Dispatch([&result](Napi::Env env) { + Babylon::Polyfills::ImportScripts::Initialize(env, [](const std::string& name) -> std::optional { + if (name == "chunk.js") + { + return "self.chunkValue = 42;"; + } + return std::nullopt; + }); + 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"( + var messages = []; + importScripts("chunk.js", "chunk.js"); + messages.push(String(self.chunkValue)); + try { importScripts("https://example.com/chunk.js"); } + catch (error) { messages.push(error.message); } + try { importScripts("../chunk.js"); } + catch (error) { messages.push(error.message); } + reportResult(messages.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(), + "42|Embedded script not found: https://example.com/chunk.js|Embedded script not found: ../chunk.js"); +} + +TEST(ImportScripts, PropagatesResolverAndEvaluationErrors) +{ + std::promise result; + Babylon::AppRuntime runtime{}; + runtime.Dispatch([&result](Napi::Env env) { + Babylon::Polyfills::ImportScripts::Initialize(env, [](const std::string& name) -> std::optional { + if (name == "broken.js") + { + return "throw new Error('chunk failed')"; + } + throw std::runtime_error{"resource lookup 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"( + var messages = []; + try { importScripts("broken.js"); } + catch (error) { messages.push(error.message); } + try { importScripts("unavailable.js"); } + catch (error) { messages.push(error.message); } + try { importScripts(42); } + catch (error) { messages.push(error.name); } + reportResult(messages.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(), "chunk failed|resource lookup failed|TypeError"); +} + +TEST(ImportScripts, LoadsDeferredChunksInsidePromise) +{ + std::promise result; + Babylon::AppRuntime runtime{}; + runtime.Dispatch([&result](Napi::Env env) { + Babylon::Polyfills::ImportScripts::Initialize(env, [](const std::string& name) -> std::optional { + if (name == "deferred.js") + { + return "self.deferredValue = 42;"; + } + return std::nullopt; + }); + 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.resolve() + .then(function () { importScripts("deferred.js"); return self.deferredValue; }) + .then(function (value) { + return Promise.resolve().then(function () { importScripts("missing.js"); }) + .then(function () { reportResult("missing chunk unexpectedly loaded"); }, + function (error) { reportResult(value + "|" + 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|Embedded script not found: missing.js"); +}