diff --git a/build.gradle b/build.gradle index 9c401ae8d..f44764212 100644 --- a/build.gradle +++ b/build.gradle @@ -192,6 +192,9 @@ def getAssembleReleaseBuildArguments = { -> if (onlyX86) { arguments.add("-PonlyX86") } + if (project.hasProperty("abis")) { + arguments.add("-Pabis=${project.property('abis')}") + } if (useCCache) { arguments.add("-PuseCCache") } @@ -461,6 +464,9 @@ def getRunTestsBuildArguments = { taskName -> if (onlyX86) { arguments.add("-PonlyX86") } + if (project.hasProperty("abis")) { + arguments.add("-Pabis=${project.property('abis')}") + } if (useCCache) { arguments.add("-PuseCCache") } diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md index 589b42acc..b050ad765 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -52,6 +52,58 @@ Rules: versions for readability; it is intended for humans and must not be parsed programmatically. +### `ns:runtime` (v1) + +Runtime-level configuration. Keys, value domains, and scope are defined and +validated natively; the module surface is a thin frozen wrapper. + +| export | description | +|---|---| +| `setConfig(key, value)` | Sets a runtime config key. Throws `TypeError` on an unknown key, an invalid value, or (for process-wide keys) when called from a worker isolate. | +| `getConfig(key)` | Returns the current value of a config key. Throws `TypeError` on an unknown key. Readable from any isolate. | + +Config keys: + +| key | values | scope | default | +|---|---|---|---| +| `logScriptLoading` | `true` \| `false` | process-wide (main-isolate writes only; read live by every isolate) | `false`, or the `logScriptLoading` value from nativescript.config / package.json at boot | +| `httpFetchUrlLog` | `true` \| `false` | process-wide (main-isolate writes only; read live by every isolate) | `false`, or the `httpFetchUrlLog` value from nativescript.config / package.json at boot | + +Remote-module security (`security.allowRemoteModules`, +`security.remoteModuleAllowlist`) is **not** part of this surface. Those +values are read once from nativescript.config / package.json the first time +the HTTP loader gates a fetch, and they cannot be inspected or changed +through `getConfig` / `setConfig`. + +iOS additionally registers `releasedObjectPolicy`; Android does not (it has +no released-native-counterpart machinery). + +### `ns:module` (v1) + +The module-loader control surface consumed by development tooling +(`@nativescript/vite`). Mechanism only: every policy concern (boot +orchestration, `import.meta.hot`, full reload, CSS apply, worker teardown, +WebSocket protocol) lives in the tooling. + +| export | description | +|---|---| +| `configureLoader(config)` | Install loader policy before the session imports anything: `importMap` (bare specifier → URL, consulted inside the synchronous resolver), `volatilePatterns` (URL substrings always re-fetched), `canonicalization` (`stripParams`/`forPathPrefixes`/`preserveQueryFor` vocabulary for registry keying). Each present section replaces its state wholesale. | +| `invalidateModules(urls)` | Evict the given URLs (canonicalized) from the module registry and mark them bust-next-fetch, so the next network fetch bypasses every HTTP cache layer. | +| `getLoadedModuleUrls()` | URL-like keys currently in the module registry (used to compute full-reload eviction sets). | +| `setDevBootComplete(value?)` | Flip the dev-boot-complete signal (defaults to `true`); disarms cold-boot-only behaviors. | + +Debug builds additionally carry `canonicalizeHttpUrlKey(url)`, a pure test +diagnostic; release builds omit it. Missing members are simply absent — +never present-but-throwing — so feature checks work. The module is +registered in every build; the security boundary for remote module loading +sits at the network layer (`security.allowRemoteModules` in +nativescript.config, enforced inside `HttpLoader`), not the module +registry and not `ns:runtime` getConfig/setConfig. + +Note: `ns:module` (loader policy, structured, boot-time) is deliberately +separate from `ns:runtime` (live key-value runtime flags, `setConfig`/ +`getConfig`). + ## `node:` compatibility shims The same registry serves the `node:` scheme with **compatibility shims** so diff --git a/test-app/app/src/main/assets/app/tests/testNsModule.js b/test-app/app/src/main/assets/app/tests/testNsModule.js new file mode 100644 index 000000000..6c33b9b2b --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testNsModule.js @@ -0,0 +1,105 @@ +describe("ns:module", function () { + it("should expose the dev-loader primitives via the ns:module builtin", function () { + var nsModule = require("ns:module"); + expect(Object.isFrozen(nsModule)).toBe(true); + expect(typeof nsModule.configureLoader).toBe("function"); + expect(typeof nsModule.invalidateModules).toBe("function"); + expect(typeof nsModule.getLoadedModuleUrls).toBe("function"); + expect(typeof nsModule.setDevBootComplete).toBe("function"); + expect(nsModule.terminateAllWorkers).toBeUndefined(); + expect(global.__NS_DEV__).toBeUndefined(); + }); + + it("exposes exactly the declared surface", function () { + var nsModule = require("ns:module"); + var expected = ["configureLoader", "getLoadedModuleUrls", "invalidateModules", "setDevBootComplete"]; + if (typeof nsModule.canonicalizeHttpUrlKey === "function") { + expected.push("canonicalizeHttpUrlKey"); + } + expect(Object.keys(nsModule).sort()).toEqual(expected.sort()); + }); + + it("resolves ns:module to the same members for require and import()", function (done) { + var nsModule = require("ns:module"); + import("ns:module").then(function (ns) { + expect(ns.default).toBe(nsModule); + expect(ns.invalidateModules).toBe(nsModule.invalidateModules); + expect(ns.configureLoader).toBe(nsModule.configureLoader); + done(); + }).catch(function (error) { + fail("import('ns:module') rejected: " + error.message); + done(); + }); + }); + + it("setDevBootComplete flips the JS-visible boot-complete global", function () { + var nsModule = require("ns:module"); + nsModule.setDevBootComplete(true); + expect(global.__NS_HMR_BOOT_COMPLETE__).toBe(true); + nsModule.setDevBootComplete(false); + expect(global.__NS_HMR_BOOT_COMPLETE__).toBe(false); + nsModule.setDevBootComplete(); + expect(global.__NS_HMR_BOOT_COMPLETE__).toBe(true); + nsModule.setDevBootComplete(false); + }); +}); + +describe("HTTP canonical key (ns:module canonicalizeHttpUrlKey)", function () { + function getCanon() { + return require("ns:module").canonicalizeHttpUrlKey; + } + + function checkKey(input, expected) { + var canon = getCanon(); + if (typeof canon !== "function") { + pending("ns:module.canonicalizeHttpUrlKey not exposed (release build)"); + return; + } + expect(canon(input)).toBe(expected); + } + + it("is exposed as a function in debug builds", function () { + var canon = getCanon(); + if (typeof canon !== "function") { + pending("ns:module.canonicalizeHttpUrlKey not exposed (release build)"); + return; + } + expect(typeof canon).toBe("function"); + }); + + it("drops dev cache-busters (t/v/import) but keeps real query params", function () { + checkKey("http://h/ns/core?p=x&t=123&v=9&import=1", "http://h/ns/core?p=x"); + }); + + it("leaves public (non-dev, non-volatile) URLs untouched", function () { + checkKey("https://cdn.example.com/lib.js?token=abc", "https://cdn.example.com/lib.js?token=abc"); + }); + + it("treats module identity as literally the URL — no path-tag collapses", function () { + checkKey("http://h/ns/m/foo.js", "http://h/ns/m/foo.js"); + checkKey("http://h/ns/rt", "http://h/ns/rt"); + checkKey("http://h/ns/core", "http://h/ns/core"); + }); + + it("ignores URL fragments for dev endpoints", function () { + checkKey("http://h/ns/m/foo.js#frag", "http://h/ns/m/foo.js"); + }); + + it("honors a client-supplied canonicalization vocabulary via configureLoader", function () { + var canon = getCanon(); + if (typeof canon !== "function") { + pending("ns:module.canonicalizeHttpUrlKey not exposed (release build)"); + return; + } + require("ns:module").configureLoader({ + canonicalization: { + stripParams: ["t", "v", "import"], + forPathPrefixes: ["/ns/", "/node_modules/.vite/", "/@id/", "/@fs/"], + preserveQueryFor: ["/@ng/component"], + }, + }); + expect(canon("http://h/ns/core?p=x&t=123&v=9&import=1")).toBe("http://h/ns/core?p=x"); + expect(canon("http://h/ns/m/comp/@ng/component?c=a&t=42")).toBe("http://h/ns/m/comp/@ng/component?c=a&t=42"); + expect(canon("https://cdn.example.com/lib.js?token=abc")).toBe("https://cdn.example.com/lib.js?token=abc"); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testNsRuntime.js b/test-app/app/src/main/assets/app/tests/testNsRuntime.js new file mode 100644 index 000000000..dd9984815 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testNsRuntime.js @@ -0,0 +1,67 @@ +describe("ns:runtime", function () { + var runtime = require("ns:runtime"); + + it("exposes frozen exports", function () { + expect(Object.isFrozen(runtime)).toBe(true); + expect(typeof runtime.setConfig).toBe("function"); + expect(typeof runtime.getConfig).toBe("function"); + }); + + it("exposes exactly the declared surface", function () { + expect(Object.keys(runtime).sort()).toEqual(["getConfig", "setConfig"]); + }); + + it("rejects unknown keys", function () { + expect(function () { + runtime.setConfig("noSuchKey", 1); + }).toThrowError(TypeError, /Unknown runtime config key/); + expect(function () { + runtime.getConfig("noSuchKey"); + }).toThrowError(TypeError, /Unknown runtime config key/); + }); + + it("defaults logScriptLoading and httpFetchUrlLog from app config", function () { + expect(runtime.getConfig("logScriptLoading")).toBe(false); + expect(runtime.getConfig("httpFetchUrlLog")).toBe(false); + }); + + it("round-trips logScriptLoading and httpFetchUrlLog", function () { + runtime.setConfig("logScriptLoading", true); + expect(runtime.getConfig("logScriptLoading")).toBe(true); + runtime.setConfig("logScriptLoading", false); + expect(runtime.getConfig("logScriptLoading")).toBe(false); + + runtime.setConfig("httpFetchUrlLog", true); + expect(runtime.getConfig("httpFetchUrlLog")).toBe(true); + runtime.setConfig("httpFetchUrlLog", false); + expect(runtime.getConfig("httpFetchUrlLog")).toBe(false); + }); + + it("rejects non-boolean log flag values and keeps the current one", function () { + expect(function () { + runtime.setConfig("logScriptLoading", "yes"); + }).toThrowError(TypeError, /must be a boolean/); + expect(runtime.getConfig("logScriptLoading")).toBe(false); + expect(function () { + runtime.setConfig("httpFetchUrlLog", 1); + }).toThrowError(TypeError, /must be a boolean/); + expect(runtime.getConfig("httpFetchUrlLog")).toBe(false); + }); + + it("does not expose remote-module security through getConfig or setConfig", function () { + ["security", "allowRemoteModules", "remoteModuleAllowlist"].forEach(function (key) { + expect(function () { + runtime.getConfig(key); + }).toThrowError(TypeError, /Unknown runtime config key/); + expect(function () { + runtime.setConfig(key, true); + }).toThrowError(TypeError, /Unknown runtime config key/); + }); + }); + + it("does not expose releasedObjectPolicy (iOS-only)", function () { + expect(function () { + runtime.getConfig("releasedObjectPolicy"); + }).toThrowError(TypeError, /Unknown runtime config key/); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js b/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js index 0398634b3..62d9153a6 100644 --- a/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js +++ b/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js @@ -142,6 +142,15 @@ describe("Remote Module Security", function() { // In debug mode, this returns true because debug bypasses allowlist expect(isAllowed).toBe(true); }); + + it("should refuse lookalike-host prefixes at a URL-component boundary (Java helper)", function() { + // The Java helper is the production-path twin of the native gate. + // Debug still short-circuits to true, so this only asserts the + // helper exists and debug bypass still holds; production matching + // is covered by the native RemoteUrlMatchesAllowlistEntry logic. + expect(typeof com.tns.Runtime.isRemoteUrlAllowed).toBe("function"); + expect(com.tns.Runtime.isRemoteUrlAllowed("https://cdn.example.com.attacker.com/x.js")).toBe(true); + }); }); describe("Static Import HTTP Loading", function() { diff --git a/test-app/runtests.gradle b/test-app/runtests.gradle index 6af55d065..317d05cd3 100644 --- a/test-app/runtests.gradle +++ b/test-app/runtests.gradle @@ -34,6 +34,9 @@ def getBuildArguments = { -> if (onlyX86) { arguments.add("-PonlyX86") } + if (project.hasProperty("abis")) { + arguments.add("-Pabis=${project.property('abis')}") + } if (useCCache) { arguments.add("-PuseCCache") } @@ -64,13 +67,14 @@ task runAdbAsRoot(type: Exec) { } task deletePreviousResultXml(type: Exec) { + ignoreExitValue = true doFirst { println "Removing previous android_unit_test_results.xml" if (isWinOs) { - commandLine "cmd", "/c", "adb", runOnDeviceOrEmulator, "-e", "shell", "rm", "-rf", "/data/data/com.tns.testapplication/android_unit_test_results.xml" + commandLine "cmd", "/c", "adb", runOnDeviceOrEmulator, "-e", "shell", "run-as", "com.tns.testapplication", "rm", "-f", "android_unit_test_results.xml" } else { - commandLine "adb", runOnDeviceOrEmulator, "-e", "shell", "rm", "-rf", "/data/data/com.tns.testapplication/android_unit_test_results.xml" + commandLine "adb", runOnDeviceOrEmulator, "-e", "shell", "run-as", "com.tns.testapplication", "rm", "-f", "android_unit_test_results.xml" } } } diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index 1346bc6cb..d07667ad4 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -71,6 +71,8 @@ set(RUNTIME_BUILTIN_JS ${RUNTIME_BUILTIN_JS_DIR}/json-helper.js ${RUNTIME_BUILTIN_JS_DIR}/message-loop-timer.js ${RUNTIME_BUILTIN_JS_DIR}/node-util.js + ${RUNTIME_BUILTIN_JS_DIR}/ns-module.js + ${RUNTIME_BUILTIN_JS_DIR}/ns-runtime.js ${RUNTIME_BUILTIN_JS_DIR}/ns-util.js ${RUNTIME_BUILTIN_JS_DIR}/performance.js ${RUNTIME_BUILTIN_JS_DIR}/primordials.js @@ -207,8 +209,7 @@ add_library( src/main/cpp/URLImpl.cpp src/main/cpp/URLSearchParamsImpl.cpp src/main/cpp/URLPatternImpl.cpp - src/main/cpp/HMRSupport.cpp - src/main/cpp/DevFlags.cpp + src/main/cpp/HttpLoader.cpp ${RUNTIME_BUILTINS_GENERATED_DIR}/RuntimeBuiltins.cpp diff --git a/test-app/runtime/src/main/cpp/ConcurrentQueue.cpp b/test-app/runtime/src/main/cpp/ConcurrentQueue.cpp index cc43b238c..0a5fcd52b 100644 --- a/test-app/runtime/src/main/cpp/ConcurrentQueue.cpp +++ b/test-app/runtime/src/main/cpp/ConcurrentQueue.cpp @@ -58,6 +58,20 @@ void ConcurrentQueue::Push(std::shared_ptr message) { } } +void ConcurrentQueue::Signal() { + std::unique_lock lock(initializationMutex_); + if (terminated_ || this->fd_ == -1) { + return; + } + uint64_t value = 1; + write(this->fd_, &value, sizeof(value)); +} + +bool ConcurrentQueue::IsEmpty() { + std::unique_lock mlock(this->mutex_); + return this->messagesQueue_.empty(); +} + std::vector> ConcurrentQueue::PopAll() { std::unique_lock mlock(this->mutex_); std::vector> messages; diff --git a/test-app/runtime/src/main/cpp/ConcurrentQueue.h b/test-app/runtime/src/main/cpp/ConcurrentQueue.h index 33526f443..bbcbbd688 100644 --- a/test-app/runtime/src/main/cpp/ConcurrentQueue.h +++ b/test-app/runtime/src/main/cpp/ConcurrentQueue.h @@ -21,6 +21,8 @@ struct ConcurrentQueue { public: void Initialize(ALooper* looper, ALooper_callbackFunc performWork, void* data); void Push(std::shared_ptr message); + void Signal(); + bool IsEmpty(); std::vector> PopAll(); void Terminate(); diff --git a/test-app/runtime/src/main/cpp/DevFlags.cpp b/test-app/runtime/src/main/cpp/DevFlags.cpp deleted file mode 100644 index 224601b10..000000000 --- a/test-app/runtime/src/main/cpp/DevFlags.cpp +++ /dev/null @@ -1,141 +0,0 @@ -// DevFlags.cpp -#include "DevFlags.h" -#include "JEnv.h" -#include -#include -#include -#include - -namespace tns { - -bool IsScriptLoadingLogEnabled() { - static std::atomic cached{-1}; // -1 unknown, 0 false, 1 true - int v = cached.load(std::memory_order_acquire); - if (v != -1) { - return v == 1; - } - - static std::once_flag initFlag; - std::call_once(initFlag, []() { - bool enabled = false; - try { - JEnv env; - jclass runtimeClass = env.FindClass("com/tns/Runtime"); - if (runtimeClass != nullptr) { - jmethodID mid = env.GetStaticMethodID(runtimeClass, "getLogScriptLoadingEnabled", "()Z"); - if (mid != nullptr) { - jboolean res = env.CallStaticBooleanMethod(runtimeClass, mid); - enabled = (res == JNI_TRUE); - } - } - } catch (...) { - // keep default false - } - cached.store(enabled ? 1 : 0, std::memory_order_release); - }); - - return cached.load(std::memory_order_acquire) == 1; -} - -// Security config - -static std::once_flag s_securityConfigInitFlag; -static bool s_allowRemoteModules = false; -static std::vector s_remoteModuleAllowlist; -static bool s_isDebuggable = false; - -// Helper to check if a URL starts with a given prefix -static bool UrlStartsWith(const std::string& url, const std::string& prefix) { - if (prefix.size() > url.size()) return false; - return url.compare(0, prefix.size(), prefix) == 0; -} - -void InitializeSecurityConfig() { - std::call_once(s_securityConfigInitFlag, []() { - try { - JEnv env; - jclass runtimeClass = env.FindClass("com/tns/Runtime"); - if (runtimeClass == nullptr) { - return; - } - - // Check isDebuggable first - jmethodID isDebuggableMid = env.GetStaticMethodID(runtimeClass, "isDebuggable", "()Z"); - if (isDebuggableMid != nullptr) { - jboolean res = env.CallStaticBooleanMethod(runtimeClass, isDebuggableMid); - s_isDebuggable = (res == JNI_TRUE); - } - - // If debuggable, we don't need to check further - always allow - if (s_isDebuggable) { - s_allowRemoteModules = true; - return; - } - - // Check isRemoteModulesAllowed - jmethodID allowRemoteMid = env.GetStaticMethodID(runtimeClass, "isRemoteModulesAllowed", "()Z"); - if (allowRemoteMid != nullptr) { - jboolean res = env.CallStaticBooleanMethod(runtimeClass, allowRemoteMid); - s_allowRemoteModules = (res == JNI_TRUE); - } - - // Get the allowlist - jmethodID getAllowlistMid = env.GetStaticMethodID(runtimeClass, "getRemoteModuleAllowlist", "()[Ljava/lang/String;"); - if (getAllowlistMid != nullptr) { - jobjectArray allowlistArray = (jobjectArray)env.CallStaticObjectMethod(runtimeClass, getAllowlistMid); - if (allowlistArray != nullptr) { - jsize len = env.GetArrayLength(allowlistArray); - for (jsize i = 0; i < len; i++) { - jstring jstr = (jstring)env.GetObjectArrayElement(allowlistArray, i); - if (jstr != nullptr) { - const char* str = env.GetStringUTFChars(jstr, nullptr); - if (str != nullptr) { - s_remoteModuleAllowlist.push_back(std::string(str)); - env.ReleaseStringUTFChars(jstr, str); - } - env.DeleteLocalRef(jstr); - } - } - env.DeleteLocalRef(allowlistArray); - } - } - } catch (...) { - // Keep defaults (remote modules disabled) - } - }); -} - -bool IsRemoteModulesAllowed() { - InitializeSecurityConfig(); - return s_allowRemoteModules || s_isDebuggable; -} - -bool IsRemoteUrlAllowed(const std::string& url) { - InitializeSecurityConfig(); - - // Debug mode always allows all URLs - if (s_isDebuggable) { - return true; - } - - // Production: first check if remote modules are allowed at all - if (!s_allowRemoteModules) { - return false; - } - - // If no allowlist is configured, allow all URLs (user explicitly enabled remote modules) - if (s_remoteModuleAllowlist.empty()) { - return true; - } - - // Check if URL matches any allowlist prefix - for (const std::string& prefix : s_remoteModuleAllowlist) { - if (UrlStartsWith(url, prefix)) { - return true; - } - } - - return false; -} - -} // namespace tns diff --git a/test-app/runtime/src/main/cpp/DevFlags.h b/test-app/runtime/src/main/cpp/DevFlags.h deleted file mode 100644 index db571d49f..000000000 --- a/test-app/runtime/src/main/cpp/DevFlags.h +++ /dev/null @@ -1,24 +0,0 @@ -// DevFlags.h -#pragma once - -#include - -namespace tns { - -// Fast cached flag: whether to log script loading diagnostics. -// First call queries Java once; subsequent calls are atomic loads only. -bool IsScriptLoadingLogEnabled(); - -// Security config - -// "security.allowRemoteModules" from nativescript.config -bool IsRemoteModulesAllowed(); - -// "security.remoteModuleAllowlist" array from nativescript.config -// If no allowlist is configured but allowRemoteModules is true, all URLs are allowed. -bool IsRemoteUrlAllowed(const std::string& url); - -// Init security configuration -void InitializeSecurityConfig(); - -} diff --git a/test-app/runtime/src/main/cpp/HMRSupport.cpp b/test-app/runtime/src/main/cpp/HMRSupport.cpp deleted file mode 100644 index 16cac04d8..000000000 --- a/test-app/runtime/src/main/cpp/HMRSupport.cpp +++ /dev/null @@ -1,353 +0,0 @@ -// HMRSupport.cpp -#include "HMRSupport.h" -#include "ArgConverter.h" -#include "JEnv.h" -#include "DevFlags.h" -#include "NativeScriptAssert.h" -#include -#include -#include -#include -#include -#include - -namespace tns { - -static inline bool StartsWith(const std::string& s, const char* prefix) { - size_t n = strlen(prefix); - return s.size() >= n && s.compare(0, n, prefix) == 0; -} - -// Per-module hot data and callbacks. Keyed by canonical module path (file path or URL). -static std::unordered_map> g_hotData; -static std::unordered_map>> g_hotAccept; -static std::unordered_map>> g_hotDispose; - -v8::Local GetOrCreateHotData(v8::Isolate* isolate, const std::string& key) { - auto it = g_hotData.find(key); - if (it != g_hotData.end() && !it->second.IsEmpty()) { - return it->second.Get(isolate); - } - v8::Local obj = v8::Object::New(isolate); - g_hotData[key].Reset(isolate, obj); - return obj; -} - -void RegisterHotAccept(v8::Isolate* isolate, const std::string& key, v8::Local cb) { - if (cb.IsEmpty()) return; - g_hotAccept[key].emplace_back(v8::Global(isolate, cb)); -} - -void RegisterHotDispose(v8::Isolate* isolate, const std::string& key, v8::Local cb) { - if (cb.IsEmpty()) return; - g_hotDispose[key].emplace_back(v8::Global(isolate, cb)); -} - -std::vector> GetHotAcceptCallbacks(v8::Isolate* isolate, const std::string& key) { - std::vector> out; - auto it = g_hotAccept.find(key); - if (it != g_hotAccept.end()) { - for (auto& gfn : it->second) { - if (!gfn.IsEmpty()) out.push_back(gfn.Get(isolate)); - } - } - return out; -} - -std::vector> GetHotDisposeCallbacks(v8::Isolate* isolate, const std::string& key) { - std::vector> out; - auto it = g_hotDispose.find(key); - if (it != g_hotDispose.end()) { - for (auto& gfn : it->second) { - if (!gfn.IsEmpty()) out.push_back(gfn.Get(isolate)); - } - } - return out; -} - -void InitializeImportMetaHot(v8::Isolate* isolate, - v8::Local context, - v8::Local importMeta, - const std::string& modulePath) { - using v8::Function; - using v8::FunctionCallbackInfo; - using v8::Local; - using v8::Object; - using v8::String; - using v8::Value; - - v8::HandleScope scope(isolate); - - auto makeKeyData = [&](const std::string& key) -> Local { - return ArgConverter::ConvertToV8String(isolate, key); - }; - - auto acceptCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - Local data = info.Data(); - std::string key; - if (!data.IsEmpty()) { - v8::String::Utf8Value s(iso, data); - key = *s ? *s : ""; - } - v8::Local cb; - if (info.Length() >= 1 && info[0]->IsFunction()) { - cb = info[0].As(); - } else if (info.Length() >= 2 && info[1]->IsFunction()) { - cb = info[1].As(); - } - if (!cb.IsEmpty()) { - RegisterHotAccept(iso, key, cb); - } - info.GetReturnValue().Set(v8::Undefined(iso)); - }; - - auto disposeCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - Local data = info.Data(); - std::string key; - if (!data.IsEmpty()) { v8::String::Utf8Value s(iso, data); key = *s ? *s : ""; } - if (info.Length() >= 1 && info[0]->IsFunction()) { - RegisterHotDispose(iso, key, info[0].As()); - } - info.GetReturnValue().Set(v8::Undefined(iso)); - }; - - auto declineCb = [](const FunctionCallbackInfo& info) { - info.GetReturnValue().Set(v8::Undefined(info.GetIsolate())); - }; - - auto invalidateCb = [](const FunctionCallbackInfo& info) { - info.GetReturnValue().Set(v8::Undefined(info.GetIsolate())); - }; - - Local hot = Object::New(isolate); - hot->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "data"), - GetOrCreateHotData(isolate, modulePath)).Check(); - hot->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "prune"), - v8::Boolean::New(isolate, false)).Check(); - hot->CreateDataProperty( - context, ArgConverter::ConvertToV8String(isolate, "accept"), - v8::Function::New(context, acceptCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, ArgConverter::ConvertToV8String(isolate, "dispose"), - v8::Function::New(context, disposeCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, ArgConverter::ConvertToV8String(isolate, "decline"), - v8::Function::New(context, declineCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, ArgConverter::ConvertToV8String(isolate, "invalidate"), - v8::Function::New(context, invalidateCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); - - importMeta->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "hot"), hot).Check(); -} - -// Drop fragments and normalize parameters for consistent registry keys. -std::string CanonicalizeHttpUrlKey(const std::string& url) { - if (!(StartsWith(url, "http://") || StartsWith(url, "https://"))) { - return url; - } - // Remove fragment - size_t hashPos = url.find('#'); - std::string noHash = (hashPos == std::string::npos) ? url : url.substr(0, hashPos); - - // Split into origin+path and query - size_t qPos = noHash.find('?'); - std::string originAndPath = (qPos == std::string::npos) ? noHash : noHash.substr(0, qPos); - std::string query = (qPos == std::string::npos) ? std::string() : noHash.substr(qPos + 1); - - // Normalize bridge endpoints to keep a single realm across HMR updates: - // - /ns/rt/ -> /ns/rt - // - /ns/core/ -> /ns/core - size_t schemePos = originAndPath.find("://"); - if (schemePos != std::string::npos) { - size_t pathStart = originAndPath.find('/', schemePos + 3); - if (pathStart != std::string::npos) { - std::string pathOnly = originAndPath.substr(pathStart); - auto normalizeBridge = [&](const char* needle) { - size_t nlen = strlen(needle); - if (pathOnly.size() <= nlen) return false; - if (pathOnly.compare(0, nlen, needle) != 0) return false; - if (pathOnly.size() == nlen) return true; - if (pathOnly[nlen] != '/') return false; - size_t i = nlen + 1; - size_t j = i; - while (j < pathOnly.size() && isdigit((unsigned char)pathOnly[j])) j++; - // Only normalize exact version segment: /ns/*/ (no further segments) - if (j == i) return false; - if (j != pathOnly.size()) return false; - originAndPath = originAndPath.substr(0, pathStart) + std::string(needle); - return true; - }; - if (!normalizeBridge("/ns/rt")) { - normalizeBridge("/ns/core"); - } - } - } - - if (query.empty()) return originAndPath; - - // Strip ?import markers and sort remaining query params for stability - std::vector kept; - size_t start = 0; - while (start <= query.size()) { - size_t amp = query.find('&', start); - std::string pair = (amp == std::string::npos) ? query.substr(start) : query.substr(start, amp - start); - if (!pair.empty()) { - size_t eq = pair.find('='); - std::string name = (eq == std::string::npos) ? pair : pair.substr(0, eq); - if (!(name == "import")) kept.push_back(pair); - } - if (amp == std::string::npos) break; - start = amp + 1; - } - if (kept.empty()) return originAndPath; - std::sort(kept.begin(), kept.end()); - std::string rebuilt = originAndPath + "?"; - for (size_t i = 0; i < kept.size(); i++) { - if (i > 0) rebuilt += "&"; - rebuilt += kept[i]; - } - return rebuilt; -} - -// Minimal HTTP fetch using java.net.* via JNI. Returns true on success (2xx) and non-empty body. -// Security: This is the single point of enforcement for remote module loading. -// In debug mode, all URLs are allowed. In production, checks security.allowRemoteModules -// and security.remoteModuleAllowlist from the app config. -bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status) { - out.clear(); - contentType.clear(); - status = 0; - - // Security gate: check if remote module loading is allowed before any HTTP fetch. - if (!IsRemoteUrlAllowed(url)) { - status = 403; // Forbidden - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][security][blocked] %s", url.c_str()); - } - return false; - } - - try { - JEnv env; - - // Allow network operations on the current thread (dev-only HMR path) - // Some Android environments enforce StrictMode which throws NetworkOnMainThreadException - // when performing network I/O on the main thread. Since this fetch runs on the JS/V8 thread - // during development, explicitly relax the policy here. - { - jclass clsStrict = env.FindClass("android/os/StrictMode"); - jclass clsPolicyBuilder = env.FindClass("android/os/StrictMode$ThreadPolicy$Builder"); - if (clsStrict && clsPolicyBuilder) { - jmethodID builderCtor = env.GetMethodID(clsPolicyBuilder, "", "()V"); - jobject builder = env.NewObject(clsPolicyBuilder, builderCtor); - if (builder) { - jmethodID permitAll = env.GetMethodID(clsPolicyBuilder, "permitAll", "()Landroid/os/StrictMode$ThreadPolicy$Builder;"); - jobject builder2 = permitAll ? env.CallObjectMethod(builder, permitAll) : builder; - jmethodID build = env.GetMethodID(clsPolicyBuilder, "build", "()Landroid/os/StrictMode$ThreadPolicy;"); - jobject policy = build ? env.CallObjectMethod(builder2 ? builder2 : builder, build) : nullptr; - if (policy) { - jmethodID setThreadPolicy = env.GetStaticMethodID(clsStrict, "setThreadPolicy", "(Landroid/os/StrictMode$ThreadPolicy;)V"); - if (setThreadPolicy) { - env.CallStaticVoidMethod(clsStrict, setThreadPolicy, policy); - } - } - } - } - } - - jclass clsURL = env.FindClass("java/net/URL"); - if (!clsURL) return false; - jmethodID urlCtor = env.GetMethodID(clsURL, "", "(Ljava/lang/String;)V"); - jmethodID openConnection = env.GetMethodID(clsURL, "openConnection", "()Ljava/net/URLConnection;"); - jstring jUrlStr = env.NewStringUTF(url.c_str()); - jobject urlObj = env.NewObject(clsURL, urlCtor, jUrlStr); - - jobject conn = env.CallObjectMethod(urlObj, openConnection); - if (!conn) return false; - - jclass clsConn = env.GetObjectClass(conn); - jmethodID setConnectTimeout = env.GetMethodID(clsConn, "setConnectTimeout", "(I)V"); - jmethodID setReadTimeout = env.GetMethodID(clsConn, "setReadTimeout", "(I)V"); - jmethodID setDoInput = env.GetMethodID(clsConn, "setDoInput", "(Z)V"); - jmethodID setUseCaches = env.GetMethodID(clsConn, "setUseCaches", "(Z)V"); - jmethodID setReqProp = env.GetMethodID(clsConn, "setRequestProperty", "(Ljava/lang/String;Ljava/lang/String;)V"); - env.CallVoidMethod(conn, setConnectTimeout, 15000); - env.CallVoidMethod(conn, setReadTimeout, 15000); - if (setDoInput) { env.CallVoidMethod(conn, setDoInput, JNI_TRUE); } - if (setUseCaches) { env.CallVoidMethod(conn, setUseCaches, JNI_FALSE); } - env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Accept"), env.NewStringUTF("application/javascript, text/javascript, */*;q=0.1")); - env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Accept-Encoding"), env.NewStringUTF("identity")); - env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Cache-Control"), env.NewStringUTF("no-cache")); - env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Connection"), env.NewStringUTF("close")); - env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("User-Agent"), env.NewStringUTF("NativeScript-HTTP-ESM")); - - // Try to get status via HttpURLConnection if possible - jclass clsHttp = env.FindClass("java/net/HttpURLConnection"); - bool isHttp = clsHttp && env.IsInstanceOf(conn, clsHttp); - jmethodID getResponseCode = isHttp ? env.GetMethodID(clsHttp, "getResponseCode", "()I") : nullptr; - jmethodID getErrorStream = isHttp ? env.GetMethodID(clsHttp, "getErrorStream", "()Ljava/io/InputStream;") : nullptr; - if (isHttp && getResponseCode) { - status = env.CallIntMethod(conn, getResponseCode); - } - - // Read InputStream (prefer error stream on HTTP error codes) - jmethodID getInputStream = env.GetMethodID(clsConn, "getInputStream", "()Ljava/io/InputStream;"); - jobject inStream = nullptr; - if (isHttp && status >= 400 && getErrorStream) { - inStream = env.CallObjectMethod(conn, getErrorStream); - } - if (!inStream) { - inStream = env.CallObjectMethod(conn, getInputStream); - } - if (!inStream) return false; - - jclass clsIS = env.GetObjectClass(inStream); - jmethodID readMethod = env.GetMethodID(clsIS, "read", "([B)I"); - jmethodID closeIS = env.GetMethodID(clsIS, "close", "()V"); - - jclass clsBAOS = env.FindClass("java/io/ByteArrayOutputStream"); - jmethodID baosCtor = env.GetMethodID(clsBAOS, "", "()V"); - jmethodID baosWrite = env.GetMethodID(clsBAOS, "write", "([BII)V"); - jmethodID baosToByteArray = env.GetMethodID(clsBAOS, "toByteArray", "()[B"); - jmethodID baosClose = env.GetMethodID(clsBAOS, "close", "()V"); - jobject baos = env.NewObject(clsBAOS, baosCtor); - - jbyteArray buffer = env.NewByteArray(8192); - while (true) { - jint n = env.CallIntMethod(inStream, readMethod, buffer); - if (n < 0) break; // -1 indicates EOF - if (n == 0) { - // Defensive: continue reading if zero bytes returned - continue; - } - env.CallVoidMethod(baos, baosWrite, buffer, 0, n); - } - - env.CallVoidMethod(inStream, closeIS); - jbyteArray bytes = (jbyteArray) env.CallObjectMethod(baos, baosToByteArray); - env.CallVoidMethod(baos, baosClose); - - if (!bytes) return false; - jsize len = env.GetArrayLength(bytes); - out.resize(static_cast(len)); - if (len > 0) { - env.GetByteArrayRegion(bytes, 0, len, reinterpret_cast(&out[0])); - } - - // Content-Type if available - jmethodID getContentType = env.GetMethodID(clsConn, "getContentType", "()Ljava/lang/String;"); - jstring jct = (jstring) env.CallObjectMethod(conn, getContentType); - if (jct) { - contentType = ArgConverter::jstringToString(jct); - } - - if (status == 0) status = 200; // assume OK if not HTTP - return status >= 200 && status < 300 && !out.empty(); - } catch (...) { - return false; - } -} - -} // namespace tns diff --git a/test-app/runtime/src/main/cpp/HMRSupport.h b/test-app/runtime/src/main/cpp/HMRSupport.h deleted file mode 100644 index f08e7fa09..000000000 --- a/test-app/runtime/src/main/cpp/HMRSupport.h +++ /dev/null @@ -1,25 +0,0 @@ -// HMRSupport.h -#pragma once - -#include -#include -#include - -namespace tns { - -// import.meta.hot support -v8::Local GetOrCreateHotData(v8::Isolate* isolate, const std::string& key); -void RegisterHotAccept(v8::Isolate* isolate, const std::string& key, v8::Local cb); -void RegisterHotDispose(v8::Isolate* isolate, const std::string& key, v8::Local cb); -std::vector> GetHotAcceptCallbacks(v8::Isolate* isolate, const std::string& key); -std::vector> GetHotDisposeCallbacks(v8::Isolate* isolate, const std::string& key); -void InitializeImportMetaHot(v8::Isolate* isolate, - v8::Local context, - v8::Local importMeta, - const std::string& modulePath); - -// Dev HTTP loader helpers -std::string CanonicalizeHttpUrlKey(const std::string& url); -bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status); - -} // namespace tns diff --git a/test-app/runtime/src/main/cpp/HttpLoader.cpp b/test-app/runtime/src/main/cpp/HttpLoader.cpp new file mode 100644 index 000000000..b2a0b6976 --- /dev/null +++ b/test-app/runtime/src/main/cpp/HttpLoader.cpp @@ -0,0 +1,1082 @@ +#include "HttpLoader.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ArgConverter.h" +#include "JEnv.h" +#include "ModuleInternalCallbacks.h" +#include "NativeScriptAssert.h" +#include "NativeScriptException.h" +#include "Runtime.h" +#include "robin_hood.h" +#include "v8-json.h" + +namespace tns { + +static inline bool StartsWith(const std::string& s, const char* prefix) { + size_t n = strlen(prefix); + return s.size() >= n && s.compare(0, n, prefix) == 0; +} + +static inline v8::Local ToV8String(v8::Isolate* isolate, const char* str) { + return ArgConverter::ConvertToV8String(isolate, str ? std::string(str) : std::string()); +} + +static inline v8::Local ToV8String(v8::Isolate* isolate, const std::string& str) { + return ArgConverter::ConvertToV8String(isolate, str); +} + +// ───────────────────────────────────────────────────────────── +// Live ns:runtime log flags (boot default from Java, then setConfig) + +static std::atomic g_logScriptLoading{false}; +static std::atomic g_httpFetchUrlLog{false}; +static std::once_flag s_logFlagsInitFlag; + +static void EnsureLogFlagsInitialized() { + std::call_once(s_logFlagsInitFlag, []() { + try { + JEnv env; + jclass runtimeClass = env.FindClass("com/tns/Runtime"); + if (runtimeClass == nullptr) { + return; + } + jmethodID logMid = + env.GetStaticMethodID(runtimeClass, "getLogScriptLoadingEnabled", "()Z"); + if (logMid != nullptr) { + g_logScriptLoading.store(env.CallStaticBooleanMethod(runtimeClass, logMid) == + JNI_TRUE, + std::memory_order_relaxed); + } + jmethodID urlLogMid = + env.GetStaticMethodID(runtimeClass, "getHttpFetchUrlLogEnabled", "()Z"); + if (urlLogMid != nullptr) { + g_httpFetchUrlLog.store(env.CallStaticBooleanMethod(runtimeClass, urlLogMid) == + JNI_TRUE, + std::memory_order_relaxed); + } + } catch (...) { + // keep defaults (false) + } + }); +} + +bool IsScriptLoadingLogEnabled() { + EnsureLogFlagsInitialized(); + return g_logScriptLoading.load(std::memory_order_relaxed); +} + +void SetScriptLoadingLogEnabled(bool enabled) { + EnsureLogFlagsInitialized(); + g_logScriptLoading.store(enabled, std::memory_order_relaxed); +} + +bool IsHttpFetchUrlLogEnabled() { + EnsureLogFlagsInitialized(); + return g_httpFetchUrlLog.load(std::memory_order_relaxed); +} + +void SetHttpFetchUrlLogEnabled(bool enabled) { + EnsureLogFlagsInitialized(); + g_httpFetchUrlLog.store(enabled, std::memory_order_relaxed); +} + +// ───────────────────────────────────────────────────────────── +// Remote-module security gate + +static std::once_flag s_securityConfigInitFlag; +static bool s_allowRemoteModules = false; +static std::vector s_remoteModuleAllowlist; +static bool s_isDebuggable = false; + +static bool RemoteUrlMatchesAllowlistEntry(const std::string& url, const std::string& entry) { + if (entry.empty()) return false; + if (url.size() < entry.size()) return false; + if (url.compare(0, entry.size(), entry) != 0) return false; + if (url.size() == entry.size()) return true; + if (entry.back() == '/') return true; + const char next = url[entry.size()]; + return next == '/' || next == '?' || next == '#'; +} + +static void InitializeSecurityConfig() { + std::call_once(s_securityConfigInitFlag, []() { + try { + JEnv env; + jclass runtimeClass = env.FindClass("com/tns/Runtime"); + if (runtimeClass == nullptr) { + return; + } + + jmethodID isDebuggableMid = env.GetStaticMethodID(runtimeClass, "isDebuggable", "()Z"); + if (isDebuggableMid != nullptr) { + s_isDebuggable = env.CallStaticBooleanMethod(runtimeClass, isDebuggableMid) == + JNI_TRUE; + } + + if (s_isDebuggable) { + s_allowRemoteModules = true; + return; + } + + jmethodID allowRemoteMid = + env.GetStaticMethodID(runtimeClass, "isRemoteModulesAllowed", "()Z"); + if (allowRemoteMid != nullptr) { + s_allowRemoteModules = + env.CallStaticBooleanMethod(runtimeClass, allowRemoteMid) == JNI_TRUE; + } + + jmethodID getAllowlistMid = env.GetStaticMethodID( + runtimeClass, "getRemoteModuleAllowlist", "()[Ljava/lang/String;"); + if (getAllowlistMid != nullptr) { + jobjectArray allowlistArray = static_cast( + env.CallStaticObjectMethod(runtimeClass, getAllowlistMid)); + if (allowlistArray != nullptr) { + jsize len = env.GetArrayLength(allowlistArray); + for (jsize i = 0; i < len; i++) { + jstring jstr = + static_cast(env.GetObjectArrayElement(allowlistArray, i)); + if (jstr != nullptr) { + const char* str = env.GetStringUTFChars(jstr, nullptr); + if (str != nullptr) { + s_remoteModuleAllowlist.emplace_back(str); + env.ReleaseStringUTFChars(jstr, str); + } + env.DeleteLocalRef(jstr); + } + } + env.DeleteLocalRef(allowlistArray); + } + } + } catch (...) { + // Keep defaults (remote modules disabled) + } + }); +} + +bool IsDebuggable() { + InitializeSecurityConfig(); + return s_isDebuggable; +} + +bool IsRemoteModulesAllowed() { + if (IsDebuggable()) { + return true; + } + InitializeSecurityConfig(); + return s_allowRemoteModules; +} + +bool IsRemoteUrlAllowed(const std::string& url) { + if (IsDebuggable()) { + return true; + } + + InitializeSecurityConfig(); + if (!s_allowRemoteModules) { + return false; + } + + if (s_remoteModuleAllowlist.empty()) { + return true; + } + + for (const std::string& entry : s_remoteModuleAllowlist) { + if (RemoteUrlMatchesAllowlistEntry(url, entry)) { + return true; + } + } + + return false; +} + +static void SetBooleanGlobal(v8::Isolate* isolate, v8::Local context, const char* key, + bool value) { + context->Global() + ->Set(context, ToV8String(isolate, key), v8::Boolean::New(isolate, value)) + .FromMaybe(false); +} + +// ───────────────────────────────────────────────────────────── +// Dev-boot completion flag + +static std::atomic g_devSessionBootComplete{false}; + +static inline bool IsDevSessionBootComplete() { + return g_devSessionBootComplete.load(std::memory_order_relaxed); +} + +void SetDevBootComplete(v8::Isolate* isolate, v8::Local context, bool value) { + SetBooleanGlobal(isolate, context, "__NS_HMR_BOOT_COMPLETE__", value); + g_devSessionBootComplete.store(value, std::memory_order_relaxed); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[dev-boot] __NS_HMR_BOOT_COMPLETE__=%s", value ? "true" : "false"); + } +} + +// ───────────────────────────────────────────────────────────── +// Canonicalization vocabulary + +struct CanonicalizationConfig { + std::vector stripParams; + std::vector devPathPrefixes; + std::vector preserveQueryPrefixes; +}; +static std::mutex g_canonConfigMutex; +static std::shared_ptr g_canonConfig; + +static std::shared_ptr CurrentCanonicalizationConfig() { + std::lock_guard lock(g_canonConfigMutex); + return g_canonConfig; +} + +static void SetCanonicalizationConfig(CanonicalizationConfig config) { + auto snapshot = std::make_shared(std::move(config)); + { + std::lock_guard lock(g_canonConfigMutex); + g_canonConfig = snapshot; + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE( + "[ns:module configureLoader] canonicalization set (strip=%lu devPrefixes=%lu " + "preserve=%lu)", + (unsigned long)snapshot->stripParams.size(), + (unsigned long)snapshot->devPathPrefixes.size(), + (unsigned long)snapshot->preserveQueryPrefixes.size()); + } +} + +static void ResetCanonicalizationConfig() { + std::lock_guard lock(g_canonConfigMutex); + g_canonConfig.reset(); +} + +std::string CanonicalizeHttpUrlKey(const std::string& url) { + std::string normalizedUrl = url; + if (StartsWith(normalizedUrl, "file://http://") || StartsWith(normalizedUrl, "file://https://")) { + normalizedUrl = normalizedUrl.substr(strlen("file://")); + } + if (!(StartsWith(normalizedUrl, "http://") || StartsWith(normalizedUrl, "https://"))) { + return normalizedUrl; + } + size_t hashPos = normalizedUrl.find('#'); + std::string noHash = + (hashPos == std::string::npos) ? normalizedUrl : normalizedUrl.substr(0, hashPos); + + size_t schemePos = noHash.find("://"); + if (schemePos == std::string::npos) { + size_t q = noHash.find('?'); + return (q == std::string::npos) ? noHash : noHash.substr(0, q); + } + size_t pathStart = noHash.find('/', schemePos + 3); + if (pathStart == std::string::npos) { + return noHash; + } + size_t qPos = noHash.find('?', pathStart); + std::string originAndPath = (qPos == std::string::npos) ? noHash : noHash.substr(0, qPos); + std::string query = (qPos == std::string::npos) ? std::string() : noHash.substr(qPos + 1); + + auto canon = CurrentCanonicalizationConfig(); + { + std::string pathOnly = originAndPath.substr(pathStart); + if (canon) { + for (const auto& p : canon->preserveQueryPrefixes) { + if (!p.empty() && pathOnly.find(p) != std::string::npos) { + return noHash; + } + } + bool isDevEndpoint = false; + for (const auto& p : canon->devPathPrefixes) { + if (!p.empty() && StartsWith(pathOnly, p.c_str())) { + isDevEndpoint = true; + break; + } + } + if (!isDevEndpoint) { + return noHash; + } + } else { + if (pathOnly.find("/@ng/component") != std::string::npos) { + return noHash; + } + const bool isDevEndpoint = StartsWith(pathOnly, "/ns/") || + StartsWith(pathOnly, "/node_modules/.vite/") || + StartsWith(pathOnly, "/@id/") || + StartsWith(pathOnly, "/@fs/"); + if (!isDevEndpoint) { + return noHash; + } + } + } + + if (query.empty()) return originAndPath; + + std::vector kept; + size_t start = 0; + while (start <= query.size()) { + size_t amp = query.find('&', start); + std::string pair = + (amp == std::string::npos) ? query.substr(start) : query.substr(start, amp - start); + if (!pair.empty()) { + size_t eq = pair.find('='); + std::string name = (eq == std::string::npos) ? pair : pair.substr(0, eq); + bool drop; + if (canon) { + drop = std::find(canon->stripParams.begin(), canon->stripParams.end(), + name) != canon->stripParams.end(); + } else { + drop = (name == "import" || name == "t" || name == "v"); + } + if (!drop) kept.push_back(pair); + } + if (amp == std::string::npos) break; + start = amp + 1; + } + if (kept.empty()) return originAndPath; + std::sort(kept.begin(), kept.end()); + std::string rebuilt = originAndPath + "?"; + for (size_t i = 0; i < kept.size(); i++) { + if (i > 0) rebuilt += "&"; + rebuilt += kept[i]; + } + return rebuilt; +} + +// ───────────────────────────────────────────────────────────── +// Eviction-driven fetch cache-bust + +static std::mutex g_bustNextFetchMutex; +static robin_hood::unordered_set g_bustNextFetchKeys; + +void MarkUrlsForCacheBust(const std::vector& urls) { + if (urls.empty()) return; + std::lock_guard lock(g_bustNextFetchMutex); + for (const auto& url : urls) { + if (url.empty()) continue; + if (!(StartsWith(url, "http://") || StartsWith(url, "https://"))) continue; + g_bustNextFetchKeys.insert(CanonicalizeHttpUrlKey(url)); + } +} + +static bool IsUrlMarkedForCacheBust(const std::string& url) { + std::lock_guard lock(g_bustNextFetchMutex); + if (g_bustNextFetchKeys.empty()) return false; + return g_bustNextFetchKeys.find(CanonicalizeHttpUrlKey(url)) != g_bustNextFetchKeys.end(); +} + +static void ClearCacheBustForUrl(const std::string& url) { + std::lock_guard lock(g_bustNextFetchMutex); + if (g_bustNextFetchKeys.empty()) return; + g_bustNextFetchKeys.erase(CanonicalizeHttpUrlKey(url)); +} + +static void ClearAllCacheBustMarks() { + std::lock_guard lock(g_bustNextFetchMutex); + g_bustNextFetchKeys.clear(); +} + +// ───────────────────────────────────────────────────────────── +// JNI fetch diagnostics + request builder + +static thread_local std::string g_lastHttpFetchErrorReason; + +static void RecordLastHttpFetchError(const char* stage, const std::string& excClass, + const std::string& excMsg) { + g_lastHttpFetchErrorReason.assign("stage="); + g_lastHttpFetchErrorReason.append(stage ? stage : "?"); + g_lastHttpFetchErrorReason.append(" class="); + g_lastHttpFetchErrorReason.append(excClass); + g_lastHttpFetchErrorReason.append(" msg="); + g_lastHttpFetchErrorReason.append(excMsg); +} + +static void ClearLastHttpFetchErrorReason() { + g_lastHttpFetchErrorReason.clear(); +} + +std::string TakeLastHttpFetchErrorReason() { + std::string out = std::move(g_lastHttpFetchErrorReason); + g_lastHttpFetchErrorReason.clear(); + return out; +} + +static bool DrainPendingJniException(JEnv& env, std::string& outClassName, std::string& outMessage) { + outClassName.clear(); + outMessage.clear(); + jthrowable th = env.ExceptionOccurred(); + if (!th) return false; + env.ExceptionClear(); + + jclass clsThrowable = env.GetObjectClass(th); + if (clsThrowable) { + jclass clsClass = env.FindClass("java/lang/Class"); + if (clsClass) { + jmethodID getName = env.GetMethodID(clsClass, "getName", "()Ljava/lang/String;"); + if (getName) { + jstring jName = static_cast(env.CallObjectMethod(clsThrowable, getName)); + env.ExceptionClear(); + if (jName) { + outClassName = ArgConverter::jstringToString(jName); + } + } + } + jmethodID toString = env.GetMethodID(clsThrowable, "toString", "()Ljava/lang/String;"); + if (toString) { + jstring jMsg = static_cast(env.CallObjectMethod(th, toString)); + env.ExceptionClear(); + if (jMsg) { + outMessage = ArgConverter::jstringToString(jMsg); + } + } + } + env.ExceptionClear(); + return true; +} + +static bool PerformHttpFetchOnceSync(const std::string& url, std::string& out, + std::string& contentType, int& status); +static void MaybePumpJSThreadDuringBoot(); +static inline void InvokeHttpFetchYield(); + +static std::string ApplyCacheBustNonce(const std::string& url, bool* outBustRequested) { + std::string fetchUrl = url; + const bool bustRequested = IsUrlMarkedForCacheBust(url); + if (outBustRequested) *outBustRequested = bustRequested; + if (bustRequested) { + static std::atomic s_fetchSeq{0}; + const uint64_t seq = s_fetchSeq.fetch_add(1, std::memory_order_relaxed); + const uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count()); + fetchUrl += (url.find('?') == std::string::npos) ? '?' : '&'; + fetchUrl += "__ns_dev_nonce="; + fetchUrl += std::to_string(nowMs); + fetchUrl += "-"; + fetchUrl += std::to_string(seq); + } + return fetchUrl; +} + +static void DisableHttpKeepAliveOnce(JEnv& env) { + static std::atomic sKeepAliveDisabled{false}; + if (sKeepAliveDisabled.exchange(true)) { + return; + } + jclass clsSystem = env.FindClass("java/lang/System"); + if (clsSystem) { + jmethodID setProperty = env.GetStaticMethodID( + clsSystem, "setProperty", + "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"); + if (setProperty) { + jstring jKey = env.NewStringUTF("http.keepAlive"); + jstring jVal = env.NewStringUTF("false"); + env.CallStaticObjectMethod(clsSystem, setProperty, jKey, jVal); + env.ExceptionClear(); + } + } +} + +static void PermitAllStrictMode(JEnv& env) { + jclass clsStrict = env.FindClass("android/os/StrictMode"); + jclass clsPolicyBuilder = env.FindClass("android/os/StrictMode$ThreadPolicy$Builder"); + if (!clsStrict || !clsPolicyBuilder) { + return; + } + jmethodID builderCtor = env.GetMethodID(clsPolicyBuilder, "", "()V"); + jobject builder = env.NewObject(clsPolicyBuilder, builderCtor); + if (!builder) { + return; + } + jmethodID permitAll = env.GetMethodID(clsPolicyBuilder, "permitAll", + "()Landroid/os/StrictMode$ThreadPolicy$Builder;"); + jobject builder2 = permitAll ? env.CallObjectMethod(builder, permitAll) : builder; + jmethodID build = env.GetMethodID(clsPolicyBuilder, "build", + "()Landroid/os/StrictMode$ThreadPolicy;"); + jobject policy = build ? env.CallObjectMethod(builder2 ? builder2 : builder, build) : nullptr; + if (policy) { + jmethodID setThreadPolicy = env.GetStaticMethodID( + clsStrict, "setThreadPolicy", "(Landroid/os/StrictMode$ThreadPolicy;)V"); + if (setThreadPolicy) { + env.CallStaticVoidMethod(clsStrict, setThreadPolicy, policy); + } + } +} + +bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status) { + out.clear(); + contentType.clear(); + status = 0; + ClearLastHttpFetchErrorReason(); + + if (!IsRemoteUrlAllowed(url)) { + status = 403; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[http-esm][security][blocked] %s", url.c_str()); + } + return false; + } + + const bool urlLogEnabled = IsHttpFetchUrlLogEnabled(); + const auto netStart = urlLogEnabled ? std::chrono::steady_clock::now() + : std::chrono::steady_clock::time_point{}; + + bool ok = PerformHttpFetchOnceSync(url, out, contentType, status); + if (!ok) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[http-loader] retrying %s after initial fetch error", url.c_str()); + } + usleep(120 * 1000); + ok = PerformHttpFetchOnceSync(url, out, contentType, status); + } + if (!ok || status < 200 || status >= 300) { + return false; + } + if (out.empty()) { + out = "export {};\n"; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE( + "[http-loader] empty 2xx body for %s — serving canonical empty module", + url.c_str()); + } + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[http-loader] fetched status=%d content-type=%s bytes=%llu", status, + contentType.empty() ? "" : contentType.c_str(), + (unsigned long long)out.size()); + } + if (urlLogEnabled) { + const auto netMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - netStart) + .count(); + DEBUG_WRITE_FORCE("[http-loader][fetch][network] %s bytes=%lu ms=%lld", url.c_str(), + (unsigned long)out.size(), (long long)netMs); + } + + InvokeHttpFetchYield(); + return true; +} + +static bool PerformHttpFetchOnceSync(const std::string& url, std::string& out, + std::string& contentType, int& status) { + out.clear(); + contentType.clear(); + status = 0; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[http-esm][fetch][enter] url=%s", url.c_str()); + } + + bool bustRequested = false; + const std::string fetchUrl = ApplyCacheBustNonce(url, &bustRequested); + + try { + JEnv env; + DisableHttpKeepAliveOnce(env); + PermitAllStrictMode(env); + + jclass clsURL = env.FindClass("java/net/URL"); + if (!clsURL) return false; + jmethodID urlCtor = env.GetMethodID(clsURL, "", "(Ljava/lang/String;)V"); + jmethodID openConnection = + env.GetMethodID(clsURL, "openConnection", "()Ljava/net/URLConnection;"); + jstring jUrlStr = env.NewStringUTF(fetchUrl.c_str()); + jobject urlObj = env.NewObject(clsURL, urlCtor, jUrlStr); + + { + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + RecordLastHttpFetchError("url-ctor", excClass, excMsg); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE( + "[http-esm][fetch][exception] stage=url-ctor url=%s class=%s msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); + } + return false; + } + } + + jobject conn = env.CallObjectMethod(urlObj, openConnection); + { + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + RecordLastHttpFetchError("open-connection", excClass, excMsg); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE( + "[http-esm][fetch][exception] stage=open-connection url=%s class=%s " + "msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); + } + return false; + } + } + if (!conn) return false; + + jclass clsConn = env.GetObjectClass(conn); + jmethodID setConnectTimeout = env.GetMethodID(clsConn, "setConnectTimeout", "(I)V"); + jmethodID setReadTimeout = env.GetMethodID(clsConn, "setReadTimeout", "(I)V"); + jmethodID setDoInput = env.GetMethodID(clsConn, "setDoInput", "(Z)V"); + jmethodID setUseCaches = env.GetMethodID(clsConn, "setUseCaches", "(Z)V"); + jmethodID setReqProp = + env.GetMethodID(clsConn, "setRequestProperty", "(Ljava/lang/String;Ljava/lang/String;)V"); + env.CallVoidMethod(conn, setConnectTimeout, 15000); + env.CallVoidMethod(conn, setReadTimeout, 15000); + if (setDoInput) { + env.CallVoidMethod(conn, setDoInput, JNI_TRUE); + } + if (setUseCaches) { + env.CallVoidMethod(conn, setUseCaches, JNI_FALSE); + } + env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Accept"), + env.NewStringUTF("application/javascript, text/javascript, */*;q=0.1")); + env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Accept-Encoding"), + env.NewStringUTF("identity")); + env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Cache-Control"), + env.NewStringUTF("no-cache, no-store, max-age=0")); + env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Pragma"), + env.NewStringUTF("no-cache")); + env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Connection"), + env.NewStringUTF("close")); + env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("User-Agent"), + env.NewStringUTF("NativeScript-HTTP-ESM")); + + jclass clsHttp = env.FindClass("java/net/HttpURLConnection"); + bool isHttp = clsHttp && env.IsInstanceOf(conn, clsHttp); + jmethodID getResponseCode = + isHttp ? env.GetMethodID(clsHttp, "getResponseCode", "()I") : nullptr; + jmethodID getErrorStream = + isHttp ? env.GetMethodID(clsHttp, "getErrorStream", "()Ljava/io/InputStream;") + : nullptr; + if (isHttp && getResponseCode) { + status = env.CallIntMethod(conn, getResponseCode); + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + RecordLastHttpFetchError("get-response-code", excClass, excMsg); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE( + "[http-esm][fetch][exception] stage=get-response-code url=%s class=%s " + "msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); + } + return false; + } + } + + jmethodID getInputStream = + env.GetMethodID(clsConn, "getInputStream", "()Ljava/io/InputStream;"); + jobject inStream = nullptr; + if (isHttp && status >= 400 && getErrorStream) { + inStream = env.CallObjectMethod(conn, getErrorStream); + } + if (!inStream) { + inStream = env.CallObjectMethod(conn, getInputStream); + } + { + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + RecordLastHttpFetchError("get-input-stream", excClass, excMsg); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE( + "[http-esm][fetch][exception] stage=get-input-stream url=%s class=%s " + "msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); + } + return false; + } + } + if (!inStream) return false; + + jclass clsIS = env.GetObjectClass(inStream); + jmethodID readMethod = env.GetMethodID(clsIS, "read", "([B)I"); + jmethodID closeIS = env.GetMethodID(clsIS, "close", "()V"); + + jclass clsBAOS = env.FindClass("java/io/ByteArrayOutputStream"); + jmethodID baosCtor = env.GetMethodID(clsBAOS, "", "()V"); + jmethodID baosWrite = env.GetMethodID(clsBAOS, "write", "([BII)V"); + jmethodID baosToByteArray = env.GetMethodID(clsBAOS, "toByteArray", "()[B"); + jmethodID baosClose = env.GetMethodID(clsBAOS, "close", "()V"); + jobject baos = env.NewObject(clsBAOS, baosCtor); + + jbyteArray buffer = env.NewByteArray(8192); + bool readFailed = false; + while (true) { + jint n = env.CallIntMethod(inStream, readMethod, buffer); + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + RecordLastHttpFetchError("read-body", excClass, excMsg); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE( + "[http-esm][fetch][exception] stage=read-body url=%s class=%s msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); + } + readFailed = true; + break; + } + if (n < 0) break; + if (n == 0) continue; + env.CallVoidMethod(baos, baosWrite, buffer, 0, n); + } + + env.CallVoidMethod(inStream, closeIS); + if (readFailed) { + return false; + } + jbyteArray bytes = static_cast(env.CallObjectMethod(baos, baosToByteArray)); + env.CallVoidMethod(baos, baosClose); + + if (!bytes) return false; + jsize len = env.GetArrayLength(bytes); + out.resize(static_cast(len)); + if (len > 0) { + env.GetByteArrayRegion(bytes, 0, len, reinterpret_cast(&out[0])); + } + + jmethodID getContentType = + env.GetMethodID(clsConn, "getContentType", "()Ljava/lang/String;"); + jstring jct = static_cast(env.CallObjectMethod(conn, getContentType)); + if (jct) { + contentType = ArgConverter::jstringToString(jct); + } + + if (status == 0) status = 200; + const bool emptyNon2xx = out.empty() && (status < 200 || status >= 300); + if (emptyNon2xx) { + return false; + } + if (status >= 200 && status < 300 && bustRequested) { + ClearCacheBustForUrl(url); + } + return status >= 200 && status < 300; + } catch (NativeScriptException& nse) { + std::string what = nse.what() ? nse.what() : ""; + if (what.empty()) { + what = nse.GetErrorMessage(); + } + RecordLastHttpFetchError("native-script-exception", "tns::NativeScriptException", what); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE( + "[http-esm][fetch][exception] stage=native-script-exception url=%s msg=%s", + url.c_str(), what.c_str()); + } + return false; + } catch (std::exception& ex) { + std::string what = ex.what() ? ex.what() : ""; + RecordLastHttpFetchError("std-exception", "std::exception", what); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[http-esm][fetch][exception] stage=std-exception url=%s msg=%s", + url.c_str(), what.c_str()); + } + return false; + } catch (...) { + RecordLastHttpFetchError("unknown-cpp-exception", "", ""); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[http-esm][fetch][exception] stage=unknown-cpp-exception url=%s", + url.c_str()); + } + return false; + } +} + +void FetchModuleBodyAsync(const std::string& url, + std::function completion) { + if (!IsRemoteUrlAllowed(url)) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[http-esm][security][blocked] %s", url.c_str()); + } + completion(false, 403, std::string()); + return; + } + + std::thread([url, completion = std::move(completion)]() mutable { + JavaVM* jvm = Runtime::GetJVM(); + bool attachedHere = false; + if (jvm != nullptr) { + JNIEnv* raw = nullptr; + if (jvm->GetEnv(reinterpret_cast(&raw), JNI_VERSION_1_6) != JNI_OK) { + if (jvm->AttachCurrentThread(&raw, nullptr) == JNI_OK) { + attachedHere = true; + } + } + } + struct DetachIfAttached { + JavaVM* jvm; + bool attached; + ~DetachIfAttached() { + if (attached && jvm != nullptr) { + jvm->DetachCurrentThread(); + } + } + } detachGuard{jvm, attachedHere}; + + std::string out; + std::string contentType; + int status = 0; + const auto start = std::chrono::steady_clock::now(); + bool ok = PerformHttpFetchOnceSync(url, out, contentType, status); + if (!ok) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[http-loader][fetch-async] retrying %s after transport error", + url.c_str()); + } + usleep(120 * 1000); + ok = PerformHttpFetchOnceSync(url, out, contentType, status); + } + ok = ok && status >= 200 && status < 300; + if (ok && out.empty()) { + out = "export {};\n"; + } + if (!ok && IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[http-loader][fetch-async][error] url=%s status=%d", url.c_str(), + status); + } + if (ok && IsHttpFetchUrlLogEnabled()) { + const auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + DEBUG_WRITE_FORCE("[http-loader][fetch][async] %s bytes=%lu ms=%lld", url.c_str(), + (unsigned long)out.size(), (long long)ms); + } + completion(ok, status, std::move(out)); + }).detach(); +} + +static void MaybePumpJSThreadDuringBoot() { + v8::Isolate* isolate = v8::Isolate::TryGetCurrent(); + if (isolate == nullptr) return; + if (IsDevSessionBootComplete()) return; + if (isolate->GetData((uint32_t)Runtime::IsolateData::RUNTIME) == nullptr) return; + + isolate->PerformMicrotaskCheckpoint(); + ALooper_pollOnce(0, nullptr, nullptr, nullptr); + isolate->PerformMicrotaskCheckpoint(); +} + +static std::atomic g_httpFetchYield{&MaybePumpJSThreadDuringBoot}; + +void RegisterHttpFetchYield(void (*callback)()) { + g_httpFetchYield.store(callback, std::memory_order_release); +} + +static inline void InvokeHttpFetchYield() { + auto cb = g_httpFetchYield.load(std::memory_order_acquire); + if (cb != nullptr) cb(); +} + +void CleanupHttpLoaderGlobals() { + ClearAllCacheBustMarks(); + g_devSessionBootComplete.store(false, std::memory_order_relaxed); + ResetCanonicalizationConfig(); +} + +// ───────────────────────────────────────────────────────────── +// ns:module binding + +namespace { + +void InstallDevFunction(v8::Isolate* isolate, v8::Local context, + v8::Local target, const char* name, + v8::FunctionCallback callback) { + v8::Local fnTpl = v8::FunctionTemplate::New(isolate, callback); + v8::Local fn = fnTpl->GetFunction(context).ToLocalChecked(); + fn->SetName(ToV8String(isolate, name)); + target->CreateDataProperty(context, ToV8String(isolate, name), fn).Check(); +} + +void ConfigureLoaderCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + bool logScriptLoading = tns::IsScriptLoadingLogEnabled(); + + if (info.Length() < 1 || !info[0]->IsObject()) { + if (logScriptLoading) { + DEBUG_WRITE_FORCE("[ns:module configureLoader] expected config object argument"); + } + return; + } + + v8::Local config = info[0].As(); + + v8::Local importMapKey = ToV8String(isolate, "importMap"); + v8::Local importMapVal; + if (config->Get(ctx, importMapKey).ToLocal(&importMapVal) && !importMapVal->IsUndefined()) { + std::string jsonStr; + if (importMapVal->IsString()) { + v8::String::Utf8Value utf8(isolate, importMapVal); + if (*utf8) jsonStr = *utf8; + } else if (importMapVal->IsObject()) { + v8::Local stringified; + if (v8::JSON::Stringify(ctx, importMapVal).ToLocal(&stringified)) { + v8::String::Utf8Value utf8(isolate, stringified); + if (*utf8) jsonStr = *utf8; + } + } + if (!jsonStr.empty()) { + SetImportMap(jsonStr); + if (logScriptLoading) { + DEBUG_WRITE_FORCE("[ns:module configureLoader] import map set (%zu bytes)", + jsonStr.size()); + } + } + } + + auto readStringArray = [&](v8::Local obj, const char* key, + std::vector& out) -> bool { + v8::Local val; + if (!obj->Get(ctx, ToV8String(isolate, key)).ToLocal(&val) || !val->IsArray()) { + return false; + } + v8::Local arr = val.As(); + for (uint32_t i = 0; i < arr->Length(); i++) { + v8::Local elem; + if (arr->Get(ctx, i).ToLocal(&elem) && elem->IsString()) { + v8::String::Utf8Value utf8(isolate, elem); + if (*utf8) out.push_back(*utf8); + } + } + return true; + }; + + { + std::vector patterns; + if (readStringArray(config, "volatilePatterns", patterns) && !patterns.empty()) { + SetVolatilePatterns(patterns); + if (logScriptLoading) { + DEBUG_WRITE_FORCE("[ns:module configureLoader] %zu volatile patterns set", + patterns.size()); + } + } + } + + { + v8::Local canonVal; + if (config->Get(ctx, ToV8String(isolate, "canonicalization")).ToLocal(&canonVal) && + canonVal->IsObject()) { + v8::Local canonObj = canonVal.As(); + CanonicalizationConfig canon; + readStringArray(canonObj, "stripParams", canon.stripParams); + readStringArray(canonObj, "forPathPrefixes", canon.devPathPrefixes); + readStringArray(canonObj, "preserveQueryFor", canon.preserveQueryPrefixes); + SetCanonicalizationConfig(std::move(canon)); + } + } +} + +void InvalidateModulesCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + + if (info.Length() < 1 || !info[0]->IsArray()) { + DEBUG_WRITE_FORCE("[ns:module invalidateModules] expected array of URL strings"); + return; + } + + v8::Local urlsArray = info[0].As(); + std::vector urls; + urls.reserve(urlsArray->Length()); + for (uint32_t index = 0; index < urlsArray->Length(); index++) { + v8::Local value; + if (!urlsArray->Get(ctx, index).ToLocal(&value) || !value->IsString()) { + continue; + } + v8::String::Utf8Value utf8(isolate, value); + if (*utf8) { + urls.emplace_back(*utf8); + } + } + + if (tns::IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[ns-hmr][android-invalidate] called urls.count=%zu", urls.size()); + size_t shown = 0; + for (const auto& u : urls) { + if (shown >= 32) break; + DEBUG_WRITE_FORCE("[ns-hmr][android-invalidate] url[%zu]=%s", shown, u.c_str()); + shown++; + } + if (urls.size() > shown) { + DEBUG_WRITE_FORCE("[ns-hmr][android-invalidate] (hidden %zu more URL(s))", + urls.size() - shown); + } + } + + tns::InvalidateModules(isolate, ctx, urls); +} + +void GetLoadedModuleUrlsCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + + std::vector urls = tns::GetLoadedModuleUrls(); + v8::Local result = v8::Array::New(isolate, static_cast(urls.size())); + + for (uint32_t index = 0; index < urls.size(); index++) { + result->Set(ctx, index, ToV8String(isolate, urls[index])).FromMaybe(false); + } + + info.GetReturnValue().Set(result); +} + +void SetDevBootCompleteCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + + bool value = true; + if (info.Length() >= 1 && !info[0]->IsUndefined() && !info[0]->IsNull()) { + value = info[0]->BooleanValue(isolate); + } + + tns::SetDevBootComplete(isolate, ctx, value); +} + +} // namespace + +bool BuildNsModuleBinding(v8::Local context, v8::Local binding) { + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + + InstallDevFunction(isolate, context, binding, "configureLoader", ConfigureLoaderCallback); + InstallDevFunction(isolate, context, binding, "invalidateModules", InvalidateModulesCallback); + InstallDevFunction(isolate, context, binding, "getLoadedModuleUrls", + GetLoadedModuleUrlsCallback); + InstallDevFunction(isolate, context, binding, "setDevBootComplete", SetDevBootCompleteCallback); + + if (IsDebuggable()) { + auto canonicalizeCb = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + if (info.Length() < 1 || !info[0]->IsString()) { + info.GetReturnValue().SetEmptyString(); + return; + } + v8::String::Utf8Value u(iso, info[0]); + std::string key = CanonicalizeHttpUrlKey(*u ? std::string(*u) : std::string()); + info.GetReturnValue().Set(ToV8String(iso, key)); + }; + v8::Local fn; + if (v8::Function::New(context, canonicalizeCb).ToLocal(&fn)) { + fn->SetName(ToV8String(isolate, "canonicalizeHttpUrlKey")); + if (!binding + ->CreateDataProperty(context, ToV8String(isolate, "canonicalizeHttpUrlKey"), + fn) + .FromMaybe(false)) { + return false; + } + } + } + + return true; +} + +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/HttpLoader.h b/test-app/runtime/src/main/cpp/HttpLoader.h new file mode 100644 index 000000000..f1a22ae65 --- /dev/null +++ b/test-app/runtime/src/main/cpp/HttpLoader.h @@ -0,0 +1,179 @@ +#pragma once + +#include +#include +#include + +// Forward declare v8 types to keep this header lightweight and avoid +// requiring V8 headers at include sites. +namespace v8 { +class Isolate; +template +class Local; +class Object; +class Function; +class Context; +class Value; +} // namespace v8 + +namespace tns { + +// HttpLoader: the native half of the NativeScript HTTP module-loader +// contract. +// +// The runtime deliberately exposes *mechanism* only: +// - the synchronous HTTP text fetch backing the HTTP ESM loader's +// fallback path (V8's ResolveModuleCallback is synchronous — still +// true as of 14.9.207.39 — so the fallback must be native), +// - the async background-thread fetch behind the phase-1 module-graph +// walk (StartAsyncHttpModuleGraphLoad), which is how module bodies +// normally arrive, +// - eviction plumbing (an eviction-driven fetch nonce that defeats +// any HTTP cache layer between the runtime and the origin), +// - the dev-boot-complete signal that disarms cold-boot-only +// behaviors (host yield pump), +// - the remote-module security gate, seeded once from nativescript.config +// at boot and never exposed on ns:runtime getConfig/setConfig. + +// ───────────────────────────────────────────────────────────── +// HTTP loader helpers (used by dev/HMR and general-purpose HTTP module loading) +// +// Normalize an HTTP(S) URL into a stable module registry/cache key. +// - Always strips URL fragments. +// - For NativeScript dev endpoints, drops known cache busters (t/v/import) +// and sorts remaining query params for stability. +// - For non-dev/public URLs, preserves the full query string as part of the +// cache key. +// Module identity IS the (canonical) URL — the dev server serves every +// module under exactly one URL and never varies it for freshness. +std::string CanonicalizeHttpUrlKey(const std::string& url); + +// Minimal text fetch for HTTP ESM loader. Returns true on 2xx. +// - out: response body +// - contentType: Content-Type header if present +// - status: HTTP status code +// +// Synchronous fetch with one retry — this is the fallback path for +// anything the async module-graph walk missed. Empty 2xx bodies are +// normalized to the canonical empty module (`export {};\n`). +bool HttpFetchText(const std::string& url, std::string& out, + std::string& contentType, int& status); + +// Asynchronous single-URL module body fetch — the I/O primitive behind the +// phase-1 module-graph walk (see StartAsyncHttpModuleGraphLoad in +// ModuleInternalCallbacks.h). Same semantics as HttpFetchText, minus the +// JS-thread block: +// - security gate (IsRemoteUrlAllowed) checked up front, +// - a JNI HttpURLConnection GET on a background thread with the same +// request shape as the sync path (cache-bust nonce, zero-cache headers, +// no cookies) and one retry on transport error, +// - empty 2xx bodies normalize to the canonical empty module. +// `completion(ok, status, body)` is invoked exactly once, on an arbitrary +// thread — callers must hop to their JS thread before touching V8. +void FetchModuleBodyAsync( + const std::string& url, + std::function completion); + +// Return the most recent low-level fetch error reason for the calling +// thread, or an empty string if the last fetch succeeded (or no fetch +// has run on this thread yet). Take semantics — the slot is cleared on +// read. Android-only diagnostic for splicing JNI exceptions into JS +// errors when HttpFetchText returns status=0. +std::string TakeLastHttpFetchErrorReason(); + +// Register a "yield" callback that `HttpFetchText` should invoke around its +// synchronous network turn so the caller can pump its own runloop (e.g. the +// JS-thread looper so a placeholder UI can repaint during cold-boot). +// +// Default: a built-in pump that no-ops outside the JS thread / after the +// dev boot completes (see `MaybePumpJSThreadDuringBoot` in HttpLoader.cpp). +// +// Pass `nullptr` to disable any yielding (used by hosts that drive their own +// run loop or by tests that want bit-for-bit deterministic fetch timing). +// Safe to call from any thread; reads use acquire/release ordering. +void RegisterHttpFetchYield(void (*callback)()); + +// Mark a URL set (canonicalized internally) so that the NEXT network +// fetch of each URL carries a unique `__ns_dev_nonce` query parameter, +// guaranteeing no HTTP cache layer between the runtime and the origin +// can satisfy the request. Called by `InvalidateModules` for the +// eviction set; marks are consumed when a fresh body arrives. +// The nonce is transport-only and never affects module identity. +void MarkUrlsForCacheBust(const std::vector& urls); + +// Flip the dev-boot-complete signal: sets the JS-visible +// `__NS_HMR_BOOT_COMPLETE__` global and the native atomic that gates the +// cold-boot-only behaviors (JS-thread looper pump between synchronous +// fetches). Exposed to JS as ns:module +// `setDevBootComplete(value?: boolean)`. +void SetDevBootComplete(v8::Isolate* isolate, v8::Local context, + bool value); + +// Clear process-wide HTTP-loader state (cache-bust marks, boot-complete +// flag, canonicalization vocabulary). MUST be called inside +// Runtime::DestroyRuntime() before isolate disposal — and only for the MAIN +// isolate (worker teardown must not wipe shared state the main isolate +// still uses). +void CleanupHttpLoaderGlobals(); + +// ───────────────────────────────────────────────────────────── +// Remote-module security gate +// +// Seeded once from nativescript.config / package.json (`security.allowRemoteModules`, +// `security.remoteModuleAllowlist`) the first time a fetch is gated. Debug +// apps always allow. These values are not readable or writable through +// ns:runtime getConfig/setConfig — only nativescript.config at boot. + +// In debug mode (Runtime.isDebuggable()): always returns true. +// Otherwise returns the boot-time `security.allowRemoteModules` value. +bool IsRemoteModulesAllowed(); + +// Whether `url` may be fetched as a remote ES module. Debug apps always +// allow. Production requires allowRemoteModules, then an allowlist match +// (or all URLs if the allowlist is empty). +bool IsRemoteUrlAllowed(const std::string& url); + +// Mirrors com.tns.Runtime.isDebuggable(), cached once via the security +// config init. Fail-safe false until initialized. +bool IsDebuggable(); + +// Verbose script/module-loading diagnostics. Process-wide ns:runtime key +// `logScriptLoading`; boot default is the nativescript.config / package.json +// value (false when absent). Live value is readable via getConfig and +// writable via setConfig from the main isolate. +bool IsScriptLoadingLogEnabled(); +void SetScriptLoadingLogEnabled(bool enabled); + +// One log line per HTTP fetch URL (high volume). Process-wide ns:runtime +// key `httpFetchUrlLog`; boot default is the nativescript.config / +// package.json value (false when absent). +bool IsHttpFetchUrlLogEnabled(); +void SetHttpFetchUrlLogEnabled(bool enabled); + +// ───────────────────────────────────────────────────────────── +// The `ns:module` builtin binding +// +// Populates the native half of the `ns:module` builtin module — the one +// namespace carrying every JS-callable dev primitive that any tooling can +// depend on. Called from NsBuiltinModules::BuildBinding the first time a +// realm resolves `ns:module` (via require, static import, or import()); +// ns-module.js shapes and freezes the exports. +// +// `ns:module` members: +// - configureLoader(config) (import map + volatile patterns + +// canonicalization vocabulary) +// - invalidateModules(urls) (registry + cache eviction) +// - getLoadedModuleUrls() (registry introspection) +// - setDevBootComplete(value?) (boot-complete signal) +// - canonicalizeHttpUrlKey(url) (debug builds only; test diagnostic) +// +// Worker teardown across HMR cycles is userland: the dev client intercepts +// the global `Worker` constructor and terminates tracked instances +// (worker.terminate() cascades to nested workers via Runtime::DestroyRuntime). +// +// Returns false (with an exception pending or a failed Set) when the +// binding could not be populated. +bool BuildNsModuleBinding(v8::Local context, + v8::Local binding); + +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index 449fc7722..28177a794 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -1831,8 +1831,6 @@ bool MetadataNode::GetExtendLocation(v8::Isolate* isolate, string& extendLocatio } string srcFileName = ArgConverter::ConvertToString(scriptName); - // trim 'file://' to normalize path to always begin with "/data/" - srcFileName = Util::ReplaceAll(srcFileName, "file://", ""); string fullPathToFile; if (srcFileName == "") { @@ -1844,22 +1842,80 @@ bool MetadataNode::GetExtendLocation(v8::Isolate* isolate, string& extendLocatio // preceding the underscore (_) fullPathToFile = "script"; } else { - string hardcodedPathToSkip = Constants::APP_ROOT_FOLDER_PATH; + // srcFileName is not always `file:///.js`: + // HTTP ESM loading (HMR dev workflow) passes a full URL like + // `http://127.0.0.1:5173/ns/core/...` with no `.js` suffix and + // no app-root prefix, so naive scheme/app-root/`.js` stripping + // can yield an empty `fullPathToFile` and crash downstream on + // an empty token list. + string normalized = srcFileName; + + auto stripPrefix = [](string& s, const string& prefix) { + if (s.size() >= prefix.size() && + s.compare(0, prefix.size(), prefix) == 0) { + s.erase(0, prefix.size()); + } + }; + + stripPrefix(normalized, "file://"); + if (normalized.rfind("http://", 0) == 0 || + normalized.rfind("https://", 0) == 0) { + size_t schemeEnd = normalized.find("://"); + size_t pathStart = normalized.find('/', schemeEnd + 3); + if (pathStart == string::npos) { + normalized.clear(); + } else { + normalized.erase(0, pathStart + 1); + } + } - int startIndex = hardcodedPathToSkip.length(); - int strToTakeLen = (srcFileName.length() - startIndex - 3); // 3 refers to .js at the end of file name - fullPathToFile = srcFileName.substr(startIndex, strToTakeLen); + size_t queryOrFragment = normalized.find_first_of("?#"); + if (queryOrFragment != string::npos) { + normalized.resize(queryOrFragment); + } - std::replace(fullPathToFile.begin(), fullPathToFile.end(), '/', '_'); - std::replace(fullPathToFile.begin(), fullPathToFile.end(), '.', '_'); - std::replace(fullPathToFile.begin(), fullPathToFile.end(), '-', '_'); - std::replace(fullPathToFile.begin(), fullPathToFile.end(), ' ', '_'); + const string& appRoot = Constants::APP_ROOT_FOLDER_PATH; + if (!appRoot.empty()) { + stripPrefix(normalized, appRoot); + } - std::vector pathParts; + auto endsWith = [](const string& s, const string& suffix) { + return s.size() >= suffix.size() && + s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; + }; + if (endsWith(normalized, ".mjs")) { + normalized.resize(normalized.size() - 4); + } else if (endsWith(normalized, ".js")) { + normalized.resize(normalized.size() - 3); + } + fullPathToFile = normalized; + + for (char& ch : fullPathToFile) { + const unsigned char c = static_cast(ch); + const bool isIdentifierChar = + (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + ch == '_'; + if (!isIdentifierChar) { + ch = '_'; + } + } + + std::vector pathParts; Util::SplitString(fullPathToFile, "_", pathParts); - std::string lastPathPart = pathParts.back(); + std::string lastPathPart; + for (auto it = pathParts.rbegin(); it != pathParts.rend(); ++it) { + if (!it->empty()) { + lastPathPart = *it; + break; + } + } + if (lastPathPart.empty()) { + lastPathPart = "script"; + } fullPathToFile = lastPathPart; } diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index eb214512f..a071e9abb 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -8,6 +8,7 @@ #include "ModuleInternalCallbacks.h" #include "BuiltinLoader.h" #include "File.h" +#include "HttpLoader.h" #include "JniLocalRef.h" #include "ArgConverter.h" #include "V8GlobalHelpers.h" @@ -29,22 +30,62 @@ #include #include #include +#include +#include +#include using namespace v8; using namespace std; using namespace tns; -// Global module registry for ES modules: maps absolute file paths → compiled Module handles -std::unordered_map> g_moduleRegistry; +static bool IsHttpModulePath(const std::string& path) { + return path.rfind("http://", 0) == 0 || path.rfind("https://", 0) == 0 || + path.rfind("file://http://", 0) == 0 || path.rfind("file://https://", 0) == 0; +} -// Helper function to check if a module name looks like an optional external module -bool ModuleInternal::IsLikelyOptionalModule(const std::string& moduleName) { - // Check if it's a bare module name (no path separators) that could be an npm package - if (moduleName.find('/') == std::string::npos && moduleName.find('\\') == std::string::npos && - moduleName[0] != '.' && moduleName[0] != '~' && moduleName[0] != '/') { - return true; +static std::string NormalizeHttpModuleUrl(const std::string& path) { + if (path.rfind("file://http://", 0) == 0 || path.rfind("file://https://", 0) == 0) { + return path.substr(strlen("file://")); } - return false; + return path; +} + +static std::string PromiseRejectionMessage(Isolate* isolate, Local promise, + const std::string& path) { + std::string errorMessage = "Module evaluation promise rejected: " + path; + TryCatch tc(isolate); + Local reason = promise->Result(); + if (reason.IsEmpty()) { + return errorMessage; + } + if (reason->IsObject()) { + Local context = isolate->GetCurrentContext(); + Local errorObj = reason.As(); + Local messageVal; + if (errorObj->Get(context, ArgConverter::ConvertToV8String(isolate, "message")) + .ToLocal(&messageVal) && + messageVal->IsString()) { + v8::String::Utf8Value messageUtf8(isolate, messageVal); + if (*messageUtf8) { + errorMessage.append(" — "); + errorMessage.append(*messageUtf8); + } + } + } else { + Local context = isolate->GetCurrentContext(); + auto maybeReasonStr = reason->ToString(context); + if (!maybeReasonStr.IsEmpty()) { + v8::String::Utf8Value reasonUtf8(isolate, maybeReasonStr.ToLocalChecked()); + if (*reasonUtf8) { + errorMessage.append(" — "); + errorMessage.append(*reasonUtf8); + } + } + } + if (tc.HasCaught()) { + tc.Reset(); + } + return errorMessage; } // Helper function to check if a file path is an ES module (.mjs) but not a source map (.mjs.map) @@ -224,6 +265,10 @@ void ModuleInternal::RequireNativeCallback(const v8::FunctionCallbackInfo context, const string& path) { TNSPERF(); auto isolate = m_isolate; + if (IsHttpModulePath(path) || IsESModule(path)) { + LoadESModule(isolate, path); + return; + } auto globalObject = context->Global(); auto require = globalObject->Get(context, ArgConverter::ConvertToV8String(isolate, "require")).ToLocalChecked().As(); Local args[] = { ArgConverter::ConvertToV8String(isolate, path) }; @@ -235,7 +280,11 @@ void ModuleInternal::LoadWorker(Local context, const string& path) { auto isolate = m_isolate; TryCatch tc(isolate); - Load(context, path); + try { + Load(context, path); + } catch (NativeScriptException& e) { + e.ReThrowToV8(); + } if (tc.HasCaught()) { // This will handle any errors that occur when first loading a script (new worker) @@ -518,54 +567,72 @@ Local ModuleInternal::LoadData(Isolate* isolate, const string& path) { Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& path) { auto context = isolate->GetCurrentContext(); + const bool isHttpModule = IsHttpModulePath(path); + const std::string requestPath = isHttpModule ? NormalizeHttpModuleUrl(path) : path; - // 1) Prepare URL & source - string url = "file://" + path; - string content = Runtime::GetRuntime(isolate)->ReadFileText(path); - - Local sourceText = ArgConverter::ConvertToV8String(isolate, content); - ScriptCompiler::CachedData* cacheData = nullptr; // TODO: Implement cache support for ES modules + Local module; + ScriptCompiler::CachedData* cacheData = nullptr; - Local urlString; - if (!String::NewFromUtf8(isolate, url.c_str(), NewStringType::kNormal).ToLocal(&urlString)) { - throw NativeScriptException(string("Failed to create URL string for ES module ") + path); - } + if (isHttpModule) { + RunAsyncHttpModuleGraphLoadPumped(isolate, context, requestPath, 60.0); + MaybeLocal maybeMod = LoadHttpModuleForUrl(isolate, context, requestPath); + if (!maybeMod.ToLocal(&module)) { + std::string reason = TakeLastHttpFetchErrorReason(); + std::string message = "Cannot load ES module " + requestPath; + if (!reason.empty()) { + message.append(" — "); + message.append(reason); + } + throw NativeScriptException(message); + } + if (module->GetStatus() == Module::kEvaluated) { + UpdateModuleFallback(isolate, CanonicalizeHttpUrlKey(requestPath), module); + return module->GetModuleNamespace(); + } + } else { + // 1) Prepare URL & source + string url = "file://" + path; + string content = Runtime::GetRuntime(isolate)->ReadFileText(path); - ScriptOrigin origin(urlString, 0, 0, false, -1, Local(), false, false, - true // ← is_module - ); - ScriptCompiler::Source source(sourceText, origin, cacheData); + Local sourceText = ArgConverter::ConvertToV8String(isolate, content); - // 2) Compile with its own TryCatch - Local module; - { - TryCatch tcCompile(isolate); - MaybeLocal maybeMod = ScriptCompiler::CompileModule( - isolate, &source, - cacheData ? ScriptCompiler::kConsumeCodeCache : ScriptCompiler::kNoCompileOptions); + Local urlString; + if (!String::NewFromUtf8(isolate, url.c_str(), NewStringType::kNormal).ToLocal(&urlString)) { + throw NativeScriptException(string("Failed to create URL string for ES module ") + path); + } - if (!maybeMod.ToLocal(&module)) { - if (tcCompile.HasCaught()) { - throw NativeScriptException(tcCompile, "Cannot compile ES module " + path); - } else { - throw NativeScriptException(string("Cannot compile ES module ") + path); + ScriptOrigin origin(urlString, 0, 0, false, -1, Local(), false, false, + true // ← is_module + ); + ScriptCompiler::Source source(sourceText, origin, cacheData); + + // 2) Compile with its own TryCatch + { + TryCatch tcCompile(isolate); + MaybeLocal maybeMod = ScriptCompiler::CompileModule( + isolate, &source, + cacheData ? ScriptCompiler::kConsumeCodeCache : ScriptCompiler::kNoCompileOptions); + + if (!maybeMod.ToLocal(&module)) { + if (tcCompile.HasCaught()) { + throw NativeScriptException(tcCompile, "Cannot compile ES module " + path); + } else { + throw NativeScriptException(string("Cannot compile ES module ") + path); + } } } - } - // 3) Register for resolution callback - // Safe Global handle management: Clear any existing entry first - auto it = g_moduleRegistry.find(path); - if (it != g_moduleRegistry.end()) { - // Clear the existing Global handle before replacing it - it->second.Reset(); + // 3) Register for resolution callback + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + auto it = g_moduleRegistry.find(path); + if (it != g_moduleRegistry.end()) { + it->second.Reset(); + } + g_moduleRegistry[path].Reset(isolate, module); } - // Now safely set the new module handle - g_moduleRegistry[path].Reset(isolate, module); - // 4) Instantiate (link) with ResolveModuleCallback - { + if (module->GetStatus() < Module::kInstantiated) { TryCatch tcLink(isolate); bool linked = module->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false); @@ -593,12 +660,9 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p // Handle the case where evaluation returns a Promise (for top-level await) if (result->IsPromise()) { Local promise = result.As(); - - // Process microtasks to allow Promise resolution - int maxAttempts = 100; - int attempts = 0; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); - while (attempts < maxAttempts) { + while (true) { isolate->PerformMicrotaskCheckpoint(); Promise::PromiseState state = promise->State(); @@ -606,13 +670,17 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p if (state == Promise::kRejected) { Local reason = promise->Result(); isolate->ThrowException(reason); - throw NativeScriptException(string("Module evaluation promise rejected: ") + path); + throw NativeScriptException(PromiseRejectionMessage(isolate, promise, path)); } break; } - attempts++; - usleep(100); // 0.1ms delay + if (std::chrono::steady_clock::now() >= deadline) { + throw NativeScriptException(string("Module evaluation promise timed out: ") + path); + } + + ALooper_pollOnce(10, nullptr, nullptr, nullptr); + usleep(100); } } } diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.h b/test-app/runtime/src/main/cpp/ModuleInternal.h index 437694864..823a1fb28 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.h +++ b/test-app/runtime/src/main/cpp/ModuleInternal.h @@ -38,7 +38,6 @@ class ModuleInternal { static void CheckFileExists(v8::Isolate* isolate, const std::string& path, const std::string& baseDir); // Helper functions for ES module support - static bool IsLikelyOptionalModule(const std::string& moduleName); static bool IsESModule(const std::string& path); static v8::Local LoadESModule(v8::Isolate* isolate, const std::string& path); diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 828fc0c9a..698ecb45e 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -1,1069 +1,3648 @@ -#include "ModuleInternal.h" -#include "ArgConverter.h" -#include "NativeScriptException.h" -#include "NativeScriptAssert.h" -#include "NsBuiltinModules.h" -#include "Runtime.h" -#include "Util.h" +// ModuleInternalCallbacks.cpp +#include "ModuleInternalCallbacks.h" + +#include #include -#include -#include +#include + #include #include +#include +#include +#include #include -#include "HMRSupport.h" -#include "DevFlags.h" +#include +#include +#include +#include +#include +#include + +#include "ArgConverter.h" +#include "Constants.h" +#include "HttpLoader.h" #include "JEnv.h" +#include "ModuleInternal.h" +#include "NativeScriptAssert.h" +#include "NativeScriptException.h" +#include "NsBuiltinModules.h" +#include "Runtime.h" +#include "Util.h" +#include "robin_hood.h" using namespace v8; using namespace std; using namespace tns; -// External global module registry declared in ModuleInternal.cpp -extern std::unordered_map> g_moduleRegistry; +namespace tns { -// Forward declaration used by logging helper -std::string GetApplicationPath(); +// ───────────────────────────────────────────────────────────── +// Small string helpers (kept file-local — used everywhere below). +static inline bool StartsWith(const std::string& s, const char* prefix) { + size_t n = strlen(prefix); + return s.size() >= n && s.compare(0, n, prefix) == 0; +} -// Diagnostic helper: emit detailed V8 compile error info for HTTP ESM sources. -static void LogHttpCompileDiagnostics(v8::Isolate* isolate, - v8::Local context, - const std::string& url, - const std::string& code, - v8::TryCatch& tc) { - if (!IsScriptLoadingLogEnabled()) { - return; +static inline bool EndsWith(const std::string& value, const std::string& suffix) { + if (suffix.size() > value.size()) return false; + return std::equal(suffix.rbegin(), suffix.rend(), value.rbegin()); +} + +// Node.js built-in namespace check (node:url, node:module, node:path, ...). +static bool IsNodeBuiltinModule(const std::string& moduleName) { + return moduleName.rfind("node:", 0) == 0; +} + +// Filesystem: `path` names an existing regular file. +static bool IsFile(const std::string& path) { + struct stat st; + if (stat(path.c_str(), &st) != 0) { + return false; + } + return (st.st_mode & S_IFMT) == S_IFREG; +} + +// Append `ext` if `path` doesn't already carry it. +static std::string WithExtension(const std::string& path, const std::string& ext) { + if (path.size() >= ext.size() && + path.compare(path.size() - ext.size(), ext.size(), ext) == 0) { + return path; + } + return path + ext; +} + +// Application filesystem root for on-disk .mjs/.js resolution. +// Mirrors Module.java's getApplicationFilesPath + "/app". Cached after first +// JNI call — the value is process-stable, and re-entering JNI on every +// resolver hit would add avoidable overhead to hot module-graph walks. +static std::string GetApplicationPath() { + static std::string cached; + static std::once_flag flag; + std::call_once(flag, []() { + JEnv env; + jstring applicationFilesPath = (jstring)env.CallStaticObjectMethod( + ModuleInternal::MODULE_CLASS, + ModuleInternal::GET_APPLICATION_FILES_PATH_METHOD_ID); + if (applicationFilesPath != nullptr) { + cached = ArgConverter::jstringToString(applicationFilesPath) + "/app"; } - using namespace v8; - - const char* classification = "unknown"; - std::string msgStr; - std::string srcLineStr; - int lineNum = 0; - int startCol = 0; - int endCol = 0; - - Local message = tc.Message(); - if (!message.IsEmpty()) { - String::Utf8Value m8(isolate, message->Get()); - if (*m8) msgStr = *m8; - lineNum = message->GetLineNumber(context).FromMaybe(0); - startCol = message->GetStartColumn(); - endCol = message->GetEndColumn(); - MaybeLocal maybeLine = message->GetSourceLine(context); - if (!maybeLine.IsEmpty()) { - String::Utf8Value l8(isolate, maybeLine.ToLocalChecked()); - if (*l8) srcLineStr = *l8; + }); + return cached; +} + +// Collapse "." and ".." segments, preserving a leading "/". +static std::string NormalizeDotSegments(const std::string& path) { + std::vector stack; + bool absolute = !path.empty() && path[0] == '/'; + size_t i = 0; + while (i <= path.size()) { + size_t j = path.find('/', i); + std::string seg = (j == std::string::npos) ? path.substr(i) : path.substr(i, j - i); + if (seg.empty() || seg == ".") { + // skip + } else if (seg == "..") { + if (!stack.empty()) stack.pop_back(); + } else { + stack.push_back(std::move(seg)); + } + if (j == std::string::npos) break; + i = j + 1; + } + std::string norm = absolute ? "/" : std::string(); + for (size_t k = 0; k < stack.size(); k++) { + if (k > 0) norm += "/"; + norm += stack[k]; + } + return norm; +} + +// Normalize a filesystem path: collapse duplicate slashes, "./" and "../" +// segments. Same intent as iOS's `stringByStandardizingPath`, minus the +// Foundation dependency (no HOME expansion, which we never used anyway). +static std::string NormalizePath(const std::string& path) { + if (path.empty()) return path; + return NormalizeDotSegments(path); +} + +// Convert a file:// URL to a filesystem path. Handles both file:///a/b and +// file:/a/b variants. Percent-decoding is deliberately omitted — the runtime +// only emits ASCII file:// URLs internally. +static std::string FileURLToPath(const std::string& url) { + if (url.empty()) return url; + if (!StartsWith(url, "file://")) return url; + std::string tail = url.substr(7); + // Strip host component when present (file://host/path → /path). NS never + // emits a host, but be tolerant. + if (!tail.empty() && tail[0] != '/') { + size_t slash = tail.find('/'); + tail = (slash == std::string::npos) ? std::string() : tail.substr(slash); + } + // Drop query and fragment — these have no meaning for filesystem paths. + size_t cut = tail.find_first_of("?#"); + if (cut != std::string::npos) tail = tail.substr(0, cut); + return NormalizePath(tail); +} + +// Resolve a relative or root-absolute spec against an HTTP(S) referrer URL. +// Returns empty string if resolution is not applicable. +static std::string ResolveHttpRelative(const std::string& referrerUrl, + const std::string& spec) { + if (referrerUrl.empty()) return std::string(); + if (!(StartsWith(referrerUrl, "http://") || StartsWith(referrerUrl, "https://"))) { + return std::string(); + } + // Normalize referrer: drop fragment and query. + std::string base = referrerUrl; + size_t hashPos = base.find('#'); + if (hashPos != std::string::npos) base = base.substr(0, hashPos); + size_t qPos = base.find('?'); + if (qPos != std::string::npos) base = base.substr(0, qPos); + + size_t schemePos = base.find("://"); + if (schemePos == std::string::npos) return std::string(); + size_t pathStart = base.find('/', schemePos + 3); + std::string origin = (pathStart == std::string::npos) ? base : base.substr(0, pathStart); + std::string path = (pathStart == std::string::npos) ? std::string("/") + : base.substr(pathStart); + + std::string specPath = spec; + std::string specSuffix; + size_t specQ = specPath.find('?'); + size_t specH = specPath.find('#'); + size_t cut = std::string::npos; + if (specQ != std::string::npos && specH != std::string::npos) { + cut = std::min(specQ, specH); + } else if (specQ != std::string::npos) { + cut = specQ; + } else if (specH != std::string::npos) { + cut = specH; + } + if (cut != std::string::npos) { + specSuffix = specPath.substr(cut); + specPath = specPath.substr(0, cut); + } + + std::string newPath; + if (!specPath.empty() && specPath[0] == '/') { + newPath = specPath; + } else { + size_t lastSlash = path.find_last_of('/'); + std::string baseDir = (lastSlash == std::string::npos) + ? std::string("/") + : path.substr(0, lastSlash + 1); + newPath = baseDir + specPath; + } + return origin + NormalizeDotSegments(newPath) + specSuffix; +} + +// Resolve a relative "./" or "../" specifier against a file:// referrer URL. +// Returns an absolute file:// URL, or empty when not applicable. Preserved +// for parity with the earlier Android loader; the current resolver builds +// filesystem candidates directly against GetApplicationPath() so this helper +// is unused for now. +[[maybe_unused]] static std::string ResolveFileRelative( + const std::string& referrerUrl, const std::string& spec) { + const std::string filePrefix = "file://"; + if (!StartsWith(referrerUrl, filePrefix.c_str())) return std::string(); + if (spec.empty() || spec[0] != '.') return std::string(); + std::string refPath = referrerUrl.substr(filePrefix.size()); + size_t hashPos = refPath.find('#'); + if (hashPos != std::string::npos) refPath = refPath.substr(0, hashPos); + size_t qPos = refPath.find('?'); + if (qPos != std::string::npos) refPath = refPath.substr(0, qPos); + size_t lastSlash = refPath.find_last_of('/'); + std::string baseDir = (lastSlash == std::string::npos) + ? std::string("/") + : refPath.substr(0, lastSlash + 1); + return filePrefix + NormalizeDotSegments(baseDir + spec); +} + +// Forward declarations for helpers referenced before their definitions. +static bool ShouldTraceRegistryKey(const std::string& rawKey, + const std::string& registryKey); +static std::string CanonicalizeRegistryKey(const std::string& key); +static const char* ModuleStatusToString(v8::Module::Status status); +static void KillAsyncGraphLoadsForIsolate(v8::Isolate* isolate); +static bool IsCurrentIsolateWorker(v8::Isolate* isolate); +static std::string ExtractRelativePath(const std::string& path); +static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey); +static bool IsVolatileUrl(const std::string& url); + +// ───────────────────────────────────────────────────────────── +// AdoptThenable +// +// Turn any thenable value into a real v8::Promise. Promises returned by +// V8 itself (Module::Evaluate) are genuine and take the fast path; +// user-space thenables (e.g. Proxy'd Promises) fail v8::Value::IsPromise +// but adopting them via Promise::Resolver::New + Resolve preserves their +// state. +static v8::MaybeLocal AdoptThenable(v8::Isolate* isolate, + v8::Local context, + v8::Local value) { + if (value.IsEmpty()) return v8::MaybeLocal(); + if (value->IsPromise()) return value.As(); + if (!value->IsObject()) return v8::MaybeLocal(); + + v8::Local thenVal; + if (!value.As() + ->Get(context, ArgConverter::ConvertToV8String(isolate, "then")) + .ToLocal(&thenVal) || + !thenVal->IsFunction()) { + return v8::MaybeLocal(); + } + + v8::Local adopter; + if (!v8::Promise::Resolver::New(context).ToLocal(&adopter) || + adopter->Resolve(context, value).IsNothing()) { + return v8::MaybeLocal(); + } + return adopter->GetPromise(); +} + +// ───────────────────────────────────────────────────────────── +// Compile helpers + +static v8::MaybeLocal CompileModuleFromSource( + v8::Isolate* isolate, v8::Local context, + const std::string& code, const std::string& urlStr) { + v8::EscapableHandleScope hs(isolate); + // NUL-preserving conversion: module source may contain embedded NUL bytes; + // the char* path would truncate. + v8::Local sourceText = ArgConverter::ConvertToV8String(isolate, code); + v8::Local urlV8; + if (!v8::String::NewFromUtf8(isolate, urlStr.c_str(), + v8::NewStringType::kNormal) + .ToLocal(&urlV8)) { + return v8::MaybeLocal(); + } + v8::ScriptOrigin origin(urlV8, 0, 0, false, -1, v8::Local(), + false, false, true /* is_module */); + v8::ScriptCompiler::Source src(sourceText, origin); + v8::Local mod; + if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&mod)) { + return v8::MaybeLocal(); + } + if (mod->GetStatus() == v8::Module::kUninstantiated) { + if (!mod->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { + return v8::MaybeLocal(); + } + } + if (mod->GetStatus() != v8::Module::kEvaluated) { + if (mod->Evaluate(context).IsEmpty()) { + return v8::MaybeLocal(); + } + } + return hs.Escape(mod); +} + +// Compile-only variant used inside ResolveModuleCallback. Compiles a +// v8::Module and registers it under urlStr but does NOT instantiate or +// evaluate. V8 is currently instantiating the importer and will handle +// instantiation of this dependency. +static v8::MaybeLocal CompileModuleForResolveRegisterOnly( + v8::Isolate* isolate, v8::Local context, + const std::string& code, const std::string& urlStr) { + v8::EscapableHandleScope hs(isolate); + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + const std::string registryKey = CanonicalizeRegistryKey(urlStr); + if (IsScriptLoadingLogEnabled() && ShouldTraceRegistryKey(urlStr, registryKey)) { + DEBUG_WRITE("[resolver][register-resolve-only] raw=%s key=%s", + urlStr.c_str(), registryKey.c_str()); + } + + v8::Local sourceText = + ArgConverter::ConvertToV8String(isolate, code); + v8::Local urlV8; + if (!v8::String::NewFromUtf8(isolate, urlStr.c_str(), + v8::NewStringType::kNormal) + .ToLocal(&urlV8)) { + return v8::MaybeLocal(); + } + v8::ScriptOrigin origin(urlV8, 0, 0, false, -1, v8::Local(), + false, false, true /* is_module */); + v8::ScriptCompiler::Source src(sourceText, origin); + v8::Local mod; + { + v8::TryCatch tcCompile(isolate); + if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&mod)) { + if (IsDebuggable() && IsScriptLoadingLogEnabled()) { + uint64_t h = 1469598103934665603ull; // FNV-1a 64-bit + for (unsigned char c : code) { + h ^= c; + h *= 1099511628211ull; + } + std::string snippet = code.substr(0, 600); + for (char& ch : snippet) { + if (ch == '\n' || ch == '\r') ch = ' '; } - // Heuristics similar to iOS for quick triage - if (msgStr.find("Unexpected identifier") != std::string::npos || - msgStr.find("Unexpected token") != std::string::npos) { + const char* classification = "unknown"; + v8::Local message = tcCompile.Message(); + std::string msgStr; + std::string srcLineStr; + int lineNum = 0; + int startCol = 0; + int endCol = 0; + if (!message.IsEmpty()) { + v8::String::Utf8Value m8(isolate, message->Get()); + if (*m8) msgStr = *m8; + lineNum = message->GetLineNumber(context).FromMaybe(0); + startCol = message->GetStartColumn(); + endCol = message->GetEndColumn(); + v8::MaybeLocal maybeLine = message->GetSourceLine(context); + if (!maybeLine.IsEmpty()) { + v8::String::Utf8Value l8(isolate, maybeLine.ToLocalChecked()); + if (*l8) srcLineStr = *l8; + } + if (msgStr.find("Unexpected identifier") != std::string::npos || + msgStr.find("Unexpected token") != std::string::npos) { if (msgStr.find("export") != std::string::npos && code.find("export default") == std::string::npos && - code.find("__sfc__") != std::string::npos) { - classification = "missing-export-default"; - } else { - classification = "syntax"; - } - } else if (msgStr.find("Cannot use import statement") != std::string::npos) { + code.find("__sfc__") != std::string::npos) + classification = "missing-export-default"; + else + classification = "syntax"; + } else if (msgStr.find("Cannot use import statement") != std::string::npos) { classification = "wrap-error"; + } } + if (classification == std::string("unknown")) { + if (code.find("export default") == std::string::npos && + code.find("__sfc__") != std::string::npos) + classification = "missing-export-default"; + else if (code.find("__sfc__") != std::string::npos && + code.find("export {") == std::string::npos && + code.find("export ") == std::string::npos) + classification = "no-exports"; + else if (code.find("import ") == std::string::npos && + code.find("export ") == std::string::npos) + classification = "not-module"; + else if (code.find("_openBlock") != std::string::npos && + code.find("openBlock") == std::string::npos) + classification = "underscore-helper-unmapped"; + } + if (srcLineStr.size() > 240) srcLineStr = srcLineStr.substr(0, 240); + DEBUG_WRITE( + "[http-esm][compile][v8-error][%s] %s line=%d col=%d..%d " + "hash=%llx bytes=%lu msg=%s srcLine=%s snippet=%s", + classification, urlStr.c_str(), lineNum, startCol, endCol, + (unsigned long long)h, (unsigned long)code.size(), + msgStr.c_str(), srcLineStr.c_str(), snippet.c_str()); + } + return v8::MaybeLocal(); } - if (strcmp(classification, "unknown") == 0) { - if (code.find("export default") == std::string::npos && code.find("__sfc__") != std::string::npos) classification = "missing-export-default"; - else if (code.find("__sfc__") != std::string::npos && code.find("export {") == std::string::npos && code.find("export ") == std::string::npos) classification = "no-exports"; - else if (code.find("import ") == std::string::npos && code.find("export ") == std::string::npos) classification = "not-module"; - else if (code.find("_openBlock") != std::string::npos && code.find("openBlock") == std::string::npos) classification = "underscore-helper-unmapped"; + } + auto itExisting = g_moduleRegistry.find(registryKey); + if (itExisting != g_moduleRegistry.end()) { + v8::Local existing = itExisting->second.Get(isolate); + if (!existing.IsEmpty()) { + return hs.Escape(existing); } + } + g_moduleRegistry[registryKey].Reset(isolate, mod); + return hs.Escape(mod); +} - // FNV-1a 64-bit hash of source for correlation - unsigned long long h = 1469598103934665603ull; // offset basis - for (unsigned char c : code) { h ^= c; h *= 1099511628211ull; } +// ───────────────────────────────────────────────────────────── +// Per-isolate module registries +// +// Why per-isolate (not process-global, not thread_local): v8::Global +// handles are bound to the isolate that created them; reading their internal +// state from a different isolate is undefined behaviour. NS Workers each run +// a separate v8::Isolate on their own thread and, under HMR, may fetch the +// same URLs the main thread already loaded — a shared map would hand the +// worker isolate a Module the main isolate compiled, and V8's linker would +// read the cross-isolate export table and emit bogus errors like: +// SyntaxError: The requested module 'X' does not provide an export named 'Y' +// Keying by v8::Isolate* stays correct even if an isolate is ever entered +// from another thread under v8::Locker. +// +// Lifetime: the per-isolate state is created lazily on first access and torn +// down by DestroyModuleStateForIsolate(), which the Runtime destructor +// should call while the isolate is still alive (before disposal) — so every +// v8::Global is Reset() at a safe time. + +namespace { +struct PerIsolateModuleState { + ModuleHandleMap registry; // canonical key -> compiled module + ModuleHandleMap fallbackRegistry; // canonical key -> last good module + ModuleHandleMap fallbackByRelative; // relative path -> last good module +}; + +std::mutex& ModuleStateTableMutex() { + static std::mutex* mutex = new std::mutex(); + return *mutex; +} - // Trim the snippet for readability - std::string snippet = code.substr(0, 600); - for (char& ch : snippet) { if (ch == '\n' || ch == '\r') ch = ' '; } - if (srcLineStr.size() > 240) srcLineStr = srcLineStr.substr(0, 240); +robin_hood::unordered_map>& +ModuleStateTable() { + static auto* table = new robin_hood::unordered_map< + v8::Isolate*, std::unique_ptr>(); + return *table; +} - DEBUG_WRITE("[http-esm][compile][v8-error][%s] %s line=%d col=%d..%d hash=%llx bytes=%lu msg=%s srcLine=%s snippet=%s", - classification, - url.c_str(), - lineNum, - startCol, - endCol, - (unsigned long long)h, - (unsigned long)code.size(), - msgStr.c_str(), - srcLineStr.c_str(), - snippet.c_str()); +PerIsolateModuleState& ModuleStateFor(v8::Isolate* isolate) { + std::lock_guard lock(ModuleStateTableMutex()); + auto& table = ModuleStateTable(); + auto it = table.find(isolate); + if (it == table.end()) { + it = table.emplace(isolate, std::make_unique()).first; + } + return *it->second; } +} // namespace -// Helper: collapse "." and ".." path segments, preserving a leading "/". -static std::string NormalizeDotSegments(const std::string& path) { - std::vector stack; - bool absolute = !path.empty() && path[0] == '/'; - size_t i = 0; - while (i <= path.size()) { - size_t j = path.find('/', i); - std::string seg = (j == std::string::npos) ? path.substr(i) : path.substr(i, j - i); - if (seg.empty() || seg == ".") { - // skip - } else if (seg == "..") { - if (!stack.empty()) stack.pop_back(); - } else { - stack.push_back(seg); - } - if (j == std::string::npos) break; - i = j + 1; - } - std::string norm = absolute ? "/" : std::string(); - for (size_t k = 0; k < stack.size(); k++) { - if (k > 0) norm += "/"; - norm += stack[k]; - } - return norm; -} - -// Helper: resolve relative or root-absolute spec against an HTTP(S) referrer URL. -// Returns empty string if resolution is not possible. -static std::string ResolveHttpRelative(const std::string& referrerUrl, const std::string& spec) { - if (referrerUrl.empty()) { - return std::string(); - } - auto startsWith = [](const std::string& s, const char* pre) -> bool { - size_t n = strlen(pre); - return s.size() >= n && s.compare(0, n, pre) == 0; - }; - if (!(startsWith(referrerUrl, "http://") || startsWith(referrerUrl, "https://"))) { - return std::string(); - } - // Normalize referrer: drop fragment and query - std::string base = referrerUrl; - size_t hashPos = base.find('#'); - if (hashPos != std::string::npos) base = base.substr(0, hashPos); - size_t qPos = base.find('?'); - if (qPos != std::string::npos) base = base.substr(0, qPos); - - // Extract origin and path - size_t schemePos = base.find("://"); - if (schemePos == std::string::npos) { - return std::string(); - } - size_t pathStart = base.find('/', schemePos + 3); - std::string origin = (pathStart == std::string::npos) ? base : base.substr(0, pathStart); - std::string path = (pathStart == std::string::npos) ? std::string("/") : base.substr(pathStart); - - // Separate query/fragment from spec - std::string specPath = spec; - std::string specSuffix; - size_t specQ = specPath.find('?'); - size_t specH = specPath.find('#'); - size_t cut = std::string::npos; - if (specQ != std::string::npos && specH != std::string::npos) { - cut = std::min(specQ, specH); - } else if (specQ != std::string::npos) { - cut = specQ; - } else if (specH != std::string::npos) { - cut = specH; - } - if (cut != std::string::npos) { - specSuffix = specPath.substr(cut); - specPath = specPath.substr(0, cut); - } - - // Build new path - std::string newPath; - if (!specPath.empty() && specPath[0] == '/') { - // Root-absolute relative to origin - newPath = specPath; +ModuleHandleMap& ModuleRegistryFor(v8::Isolate* isolate) { + return ModuleStateFor(isolate).registry; +} + +static ModuleHandleMap& ModuleFallbackRegistryFor(v8::Isolate* isolate) { + return ModuleStateFor(isolate).fallbackRegistry; +} + +static ModuleHandleMap& ModuleFallbackByRelativeFor(v8::Isolate* isolate) { + return ModuleStateFor(isolate).fallbackByRelative; +} + +void DestroyModuleStateForIsolate(v8::Isolate* isolate) { + // First: neutralize any in-flight async graph loads for this isolate. Their + // fetch completions check the dead flag before touching V8, and their + // context Globals are Reset here while the isolate is still alive. + KillAsyncGraphLoadsForIsolate(isolate); + + std::unique_ptr state; + { + std::lock_guard lock(ModuleStateTableMutex()); + auto& table = ModuleStateTable(); + auto it = table.find(isolate); + if (it == table.end()) return; + state = std::move(it->second); + table.erase(it); + } + for (auto& kv : state->registry) kv.second.Reset(); + for (auto& kv : state->fallbackRegistry) kv.second.Reset(); + for (auto& kv : state->fallbackByRelative) kv.second.Reset(); +} + +// ───────────────────────────────────────────────────────────── +// Import map: bare specifier → resolved URL (populated by ns:module +// configureLoader). Instead of rewriting import statements on the bundler +// side, the runtime resolves bare specifiers through this map to HTTP module +// URLs. Source code is served as-is. +static robin_hood::unordered_map g_importMap; + +// Volatile URL patterns: URLs matching these substrings are always re-fetched +// (cache is evicted before loading). Configured at boot by the dev client — +// the vocabulary is server/framework policy, so the runtime carries no +// framework-specific URL strings here. +static std::vector g_volatilePatterns; + +static bool ShouldTraceRegistryKey(const std::string& rawKey, + const std::string& registryKey) { + if (rawKey != registryKey) return true; + return StartsWith(registryKey, "optional:") || + StartsWith(registryKey, "node:") || + StartsWith(registryKey, "blob:"); +} + +static std::string CanonicalizeRegistryKey(const std::string& key) { + if (key.empty()) return key; + + std::string registryKey; + const char* classification = "path"; + bool traceEvenWithoutChange = false; + + if (StartsWith(key, "http://") || StartsWith(key, "https://") || + StartsWith(key, "file://http://") || StartsWith(key, "file://https://")) { + registryKey = CanonicalizeHttpUrlKey(key); + classification = "http"; + } else if (StartsWith(key, "file://")) { + registryKey = NormalizePath(FileURLToPath(key)); + classification = "file-url"; + } else if (StartsWith(key, "blob:")) { + registryKey = key; + classification = "blob"; + traceEvenWithoutChange = true; + } else { + // Preserve non-filesystem module namespaces such as optional: and node: + // so synthetic/in-memory modules keep their exact registry identity. + size_t schemePos = key.find(':'); + size_t slashPos = key.find('/'); + if (schemePos != std::string::npos && + (slashPos == std::string::npos || schemePos < slashPos)) { + registryKey = key; + classification = "custom-scheme"; + traceEvenWithoutChange = true; } else { - // Relative to directory of referrer path - size_t lastSlash = path.find_last_of('/'); - std::string baseDir = (lastSlash == std::string::npos) ? std::string("/") : path.substr(0, lastSlash + 1); - newPath = baseDir + specPath; - } - - // Normalize "." and ".." segments - std::string normPath = NormalizeDotSegments(newPath); - return origin + normPath + specSuffix; -} - -// Helper: resolve a relative "./" or "../" specifier against a file:// referrer -// URL, returning an absolute file:// URL. Returns empty if not applicable. -static std::string ResolveFileRelative(const std::string& referrerUrl, const std::string& spec) { - const std::string filePrefix = "file://"; - if (referrerUrl.rfind(filePrefix, 0) != 0) { - return std::string(); - } - if (spec.empty() || spec[0] != '.') { - return std::string(); - } - // Referrer path: strip scheme, drop query and fragment - std::string refPath = referrerUrl.substr(filePrefix.size()); - size_t hashPos = refPath.find('#'); - if (hashPos != std::string::npos) refPath = refPath.substr(0, hashPos); - size_t qPos = refPath.find('?'); - if (qPos != std::string::npos) refPath = refPath.substr(0, qPos); - - size_t lastSlash = refPath.find_last_of('/'); - std::string baseDir = (lastSlash == std::string::npos) ? std::string("/") : refPath.substr(0, lastSlash + 1); - return filePrefix + NormalizeDotSegments(baseDir + spec); -} - -// Import meta callback to support import.meta.url and import.meta.dirname -void InitializeImportMetaObject(Local context, Local module, Local meta) { - Isolate* isolate = v8::Isolate::GetCurrent(); - - // Look up the module path in the global module registry (with safety checks) - std::string modulePath; - - try { - for (auto& kv : g_moduleRegistry) { - // Check if Global handle is empty before accessing - if (kv.second.IsEmpty()) { - continue; - } - - Local registered = kv.second.Get(isolate); - if (!registered.IsEmpty() && registered == module) { - modulePath = kv.first; - break; - } - } - } catch (...) { - DEBUG_WRITE("InitializeImportMetaObject: Exception during module registry lookup, using fallback"); - modulePath = ""; // Will use fallback path + registryKey = NormalizePath(key); + } + } + + if (IsScriptLoadingLogEnabled() && + (traceEvenWithoutChange || registryKey != key)) { + DEBUG_WRITE("[resolver][registry-key][%s] raw=%s key=%s", classification, + key.c_str(), registryKey.c_str()); + } + return registryKey; +} + +v8::MaybeLocal LoadHttpModuleForUrl(v8::Isolate* isolate, + v8::Local context, + const std::string& requestedUrl) { + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + const std::string registryKey = CanonicalizeHttpUrlKey(requestedUrl); + + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][load][begin] request=%s key=%s", + requestedUrl.c_str(), registryKey.c_str()); + } + + auto itExisting = g_moduleRegistry.find(registryKey); + if (itExisting != g_moduleRegistry.end()) { + v8::Local existing = itExisting->second.Get(isolate); + if (!existing.IsEmpty() && existing->GetStatus() != v8::Module::kErrored) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][load][cache-hit] key=%s", registryKey.c_str()); + } + return v8::MaybeLocal(existing); + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][load][drop-errored] key=%s", registryKey.c_str()); } - + RemoveModuleFromRegistry(registryKey); + } + + std::string body; + std::string contentType; + int status = 0; + if (!HttpFetchText(requestedUrl, body, contentType, status) || body.empty()) { if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("InitializeImportMetaObject: Module lookup: found path = %s", - modulePath.empty() ? "(empty)" : modulePath.c_str()); - DEBUG_WRITE("InitializeImportMetaObject: Registry size: %zu", g_moduleRegistry.size()); - } - - // Convert to URL for import.meta.url; keep http(s) untouched, file paths with file:// - std::string moduleUrl; - if (!modulePath.empty()) { - if (modulePath.rfind("http://", 0) == 0 || modulePath.rfind("https://", 0) == 0) { - moduleUrl = modulePath; + DEBUG_WRITE("[http-esm][load][fetch-fail] request=%s key=%s status=%d", + requestedUrl.c_str(), registryKey.c_str(), status); + } + if (IsDebuggable()) { + std::string msg = "HTTP import failed: " + requestedUrl + + " (status=" + std::to_string(status) + ")"; + isolate->ThrowException(v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, msg))); + } + return v8::MaybeLocal(); + } + + v8::MaybeLocal loaded = + CompileModuleForResolveRegisterOnly(isolate, context, body, registryKey); + if (loaded.IsEmpty()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][load][compile-fail] request=%s key=%s bytes=%zu", + requestedUrl.c_str(), registryKey.c_str(), body.size()); + } + if (IsDebuggable()) { + std::string msg = "HTTP import compile failed: " + requestedUrl; + isolate->ThrowException(v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, msg))); + } + return v8::MaybeLocal(); + } + + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][load][ok] request=%s key=%s type=%s bytes=%zu", + requestedUrl.c_str(), registryKey.c_str(), + contentType.c_str(), body.size()); + } + return loaded; +} + +// ───────────────────────────────────────────────────────────── +// Import map helpers + +// Small hand-rolled JSON scanner for a flat {"imports": {"key": "value", ...}} +// shape. Only strings are accepted; anything malformed is silently skipped — +// same behaviour as the iOS Foundation-based parser for non-object roots. +namespace { +struct JsonScanner { + const std::string& s; + size_t i = 0; + + explicit JsonScanner(const std::string& src) : s(src) {} + + void SkipWs() { + while (i < s.size() && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || + s[i] == '\r')) { + ++i; + } + } + + bool Peek(char c) { + SkipWs(); + return i < s.size() && s[i] == c; + } + + bool Consume(char c) { + if (Peek(c)) { + ++i; + return true; + } + return false; + } + + // Parses a JSON string into `out`. Handles standard escape sequences + // (\", \\, \/, \b, \f, \n, \r, \t) and \uXXXX (BMP only; surrogate pairs + // are decoded to their two escapes as-is when not paired — good enough + // for the small import-map vocabulary the dev server emits). + bool ReadString(std::string& out) { + SkipWs(); + if (i >= s.size() || s[i] != '"') return false; + ++i; + out.clear(); + while (i < s.size()) { + char c = s[i++]; + if (c == '"') return true; + if (c != '\\') { + out.push_back(c); + continue; + } + if (i >= s.size()) return false; + char e = s[i++]; + switch (e) { + case '"': + case '\\': + case '/': + out.push_back(e); + break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + case 'n': out.push_back('\n'); break; + case 'r': out.push_back('\r'); break; + case 't': out.push_back('\t'); break; + case 'u': { + if (i + 4 > s.size()) return false; + unsigned int cp = 0; + for (int k = 0; k < 4; ++k) { + char h = s[i++]; + cp <<= 4; + if (h >= '0' && h <= '9') cp |= (unsigned)(h - '0'); + else if (h >= 'a' && h <= 'f') cp |= (unsigned)(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= (unsigned)(h - 'A' + 10); + else return false; + } + if (cp < 0x80) { + out.push_back((char)cp); + } else if (cp < 0x800) { + out.push_back((char)(0xC0 | (cp >> 6))); + out.push_back((char)(0x80 | (cp & 0x3F))); + } else { + out.push_back((char)(0xE0 | (cp >> 12))); + out.push_back((char)(0x80 | ((cp >> 6) & 0x3F))); + out.push_back((char)(0x80 | (cp & 0x3F))); + } + break; + } + default: + return false; + } + } + return false; + } + + // Skip an arbitrary JSON value (object/array/string/number/keyword) — + // used to step over "imports" siblings we don't care about. + bool SkipValue() { + SkipWs(); + if (i >= s.size()) return false; + char c = s[i]; + if (c == '"') { + std::string tmp; + return ReadString(tmp); + } + if (c == '{' || c == '[') { + char open = c, close = (c == '{') ? '}' : ']'; + int depth = 0; + bool inString = false; + while (i < s.size()) { + char ch = s[i++]; + if (inString) { + if (ch == '\\' && i < s.size()) ++i; + else if (ch == '"') inString = false; } else { - moduleUrl = "file://" + modulePath; + if (ch == '"') inString = true; + else if (ch == open) ++depth; + else if (ch == close) { + --depth; + if (depth == 0) return true; + } } - } else { - // Fallback URL if module not found in registry - moduleUrl = "file:///android_asset/app/"; + } + return false; + } + // Number / true / false / null — read until the next value terminator. + while (i < s.size()) { + char ch = s[i]; + if (ch == ',' || ch == '}' || ch == ']' || ch == ' ' || ch == '\t' || + ch == '\n' || ch == '\r') { + return true; + } + ++i; } - + return true; + } +}; +} // namespace + +void SetImportMap(const std::string& json) { + g_importMap.clear(); + if (json.empty()) return; + + JsonScanner sc(json); + if (!sc.Consume('{')) { if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("InitializeImportMetaObject: Final URL: %s", moduleUrl.c_str()); - } - - Local url = ArgConverter::ConvertToV8String(isolate, moduleUrl); - - // Set import.meta.url property - meta->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "url"), url).Check(); - - // Add import.meta.dirname support (extract directory) - std::string dirname; - if (!modulePath.empty()) { - if (modulePath.rfind("http://", 0) == 0 || modulePath.rfind("https://", 0) == 0) { - // For URLs, compute dirname by trimming after last '/' - size_t q = modulePath.find('?'); - std::string noQuery = (q == std::string::npos) ? modulePath : modulePath.substr(0, q); - size_t lastSlash = noQuery.find_last_of('/'); - dirname = (lastSlash == std::string::npos) ? modulePath : noQuery.substr(0, lastSlash); + DEBUG_WRITE("[import-map] parse failed: not an object"); + } + return; + } + + // Find and enter the "imports" object; skip any siblings. + bool foundImports = false; + while (!sc.Peek('}')) { + std::string key; + if (!sc.ReadString(key)) break; + if (!sc.Consume(':')) break; + if (key == "imports") { + if (!sc.Consume('{')) break; + foundImports = true; + // Parse the flat {"k":"v", ...} body. + while (!sc.Peek('}')) { + std::string k, v; + if (!sc.ReadString(k)) break; + if (!sc.Consume(':')) break; + if (sc.Peek('"')) { + if (!sc.ReadString(v)) break; + g_importMap[k] = v; } else { - size_t lastSlash = modulePath.find_last_of("/\\"); - if (lastSlash != std::string::npos) { - dirname = modulePath.substr(0, lastSlash); - } else { - dirname = "/android_asset/app"; // fallback - } + // Skip non-string values (arrays, objects, etc.) — mirrors iOS. + if (!sc.SkipValue()) break; } + if (!sc.Consume(',')) break; + } + sc.Consume('}'); } else { - dirname = "/android_asset/app"; // fallback + if (!sc.SkipValue()) break; } - - Local dirnameStr = ArgConverter::ConvertToV8String(isolate, dirname); - - // Set import.meta.dirname property - meta->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "dirname"), dirnameStr).Check(); + if (!sc.Consume(',')) break; + } + + if (IsScriptLoadingLogEnabled()) { + if (!foundImports) { + DEBUG_WRITE("[import-map] no 'imports' object found"); + } + DEBUG_WRITE("[import-map] loaded %lu entries", + (unsigned long)g_importMap.size()); + } +} - // Attach import.meta.hot for HMR - tns::InitializeImportMetaHot(isolate, context, meta, modulePath); +void SetVolatilePatterns(const std::vector& patterns) { + g_volatilePatterns = patterns; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map] volatile patterns: %lu", + (unsigned long)g_volatilePatterns.size()); + } } -// Helper function to check if a file exists and is a regular file -bool IsFile(const std::string& path) { - struct stat st; - if (stat(path.c_str(), &st) != 0) { - return false; +static bool IsVolatileUrl(const std::string& url) { + for (const auto& pat : g_volatilePatterns) { + if (url.find(pat) != std::string::npos) return true; + } + return false; +} + +// Normalize a Vite-rewritten specifier into the canonical import-map key. +// Handles two common patterns: +// 1. Prebundled deps: "/node_modules/.vite/deps/solid-js.js?v=abc" → "solid-js" +// "/node_modules/.vite/deps/@tanstack_solid-router.js" → +// "@tanstack/solid-router" +// 2. Explicit node_modules paths: +// "/node_modules/@angular/core/fesm2022/core.mjs" → "@angular/core/fesm2022/core.mjs" +// "/node_modules/tslib/tslib.es6.mjs" → "tslib" +static std::string NormalizeViteSpecifier(const std::string& specifier) { + // Pattern 1: Vite prebundled deps. + { + const std::string viteDepsPrefix = "/node_modules/.vite/deps/"; + const std::string viteDepsPrefix2 = "node_modules/.vite/deps/"; + std::string prefix; + if (specifier.compare(0, viteDepsPrefix.size(), viteDepsPrefix) == 0) + prefix = viteDepsPrefix; + else if (specifier.compare(0, viteDepsPrefix2.size(), viteDepsPrefix2) == 0) + prefix = viteDepsPrefix2; + + if (!prefix.empty()) { + std::string id = specifier.substr(prefix.size()); + auto qpos = id.find('?'); + if (qpos != std::string::npos) id = id.substr(0, qpos); + auto dotpos = id.rfind('.'); + if (dotpos != std::string::npos) id = id.substr(0, dotpos); + if (!id.empty() && id[0] == '@') { + auto upos = id.find('_'); + if (upos != std::string::npos) { + id = id.substr(0, upos) + "/" + id.substr(upos + 1); + auto upos2 = id.find('_', upos + 1); + if (upos2 != std::string::npos) { + id = id.substr(0, upos2); + } + } + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map][normalize] vite-deps: %s -> %s", + specifier.c_str(), id.c_str()); + } + return id; + } + } + + // Pattern 2: Resolved node_modules path — /node_modules//... + { + const std::string nmPrefix = "/node_modules/"; + const std::string nmPrefix2 = "node_modules/"; + std::string sub; + if (specifier.compare(0, nmPrefix.size(), nmPrefix) == 0) + sub = specifier.substr(nmPrefix.size()); + else if (specifier.compare(0, nmPrefix2.size(), nmPrefix2) == 0) + sub = specifier.substr(nmPrefix2.size()); + + if (!sub.empty() && sub[0] != '.') { + if (sub.compare(0, 6, ".vite/") == 0) return ""; + + std::string subNoQuery = sub; + std::string querySuffix; + auto subQueryPos = sub.find('?'); + if (subQueryPos != std::string::npos) { + subNoQuery = sub.substr(0, subQueryPos); + querySuffix = sub.substr(subQueryPos); + } + + std::string pkgName; + if (subNoQuery[0] == '@') { + auto slash1 = subNoQuery.find('/'); + if (slash1 != std::string::npos) { + auto slash2 = subNoQuery.find('/', slash1 + 1); + pkgName = (slash2 != std::string::npos) ? subNoQuery.substr(0, slash2) + : subNoQuery; + } + } else { + auto slash = subNoQuery.find('/'); + pkgName = (slash != std::string::npos) ? subNoQuery.substr(0, slash) + : subNoQuery; + } + if (!pkgName.empty()) { + std::string normalized = pkgName; + std::string remainder; + if (subNoQuery.size() > pkgName.size()) { + remainder = subNoQuery.substr(pkgName.size()); + if (!remainder.empty() && remainder[0] == '/') { + remainder.erase(0, 1); + } + } + + if (!remainder.empty()) { + bool preserveSubpath = remainder.find('/') != std::string::npos; + + if (!preserveSubpath) { + const std::string pkgBaseName = + pkgName.substr(pkgName.find_last_of('/') + 1); + std::string withoutExt = remainder; + auto dot = withoutExt.rfind('.'); + if (dot != std::string::npos) { + withoutExt = withoutExt.substr(0, dot); + } + std::string withoutPlatform = withoutExt; + for (const auto& suffix : {std::string(".ios"), std::string(".android"), + std::string(".visionos")}) { + if (EndsWith(withoutPlatform, suffix)) { + withoutPlatform = + withoutPlatform.substr(0, withoutPlatform.size() - suffix.size()); + break; + } + } + const bool isRootLevelMainEntry = + withoutPlatform == "index" || + withoutPlatform == pkgBaseName || + withoutPlatform.rfind(pkgBaseName + ".", 0) == 0; + preserveSubpath = !isRootLevelMainEntry; + } + + if (preserveSubpath) { + normalized = pkgName + "/" + remainder + querySuffix; + } + } + + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map][normalize] node_modules: %s -> %s", + specifier.c_str(), normalized.c_str()); + } + return normalized; + } } - return (st.st_mode & S_IFMT) == S_IFREG; + } + return ""; } -// Helper function to add extension if missing -std::string WithExtension(const std::string& path, const std::string& ext) { - if (path.size() >= ext.size() && path.compare(path.size() - ext.size(), ext.size(), ext) == 0) { - return path; +// Look up a specifier in the import map. Supports exact and prefix matches +// (trailing-slash entries like "solid-js/" that map subpaths). +static std::string LookupImportMap(const std::string& specifier) { + auto it = g_importMap.find(specifier); + if (it != g_importMap.end()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map] exact: %s -> %s", specifier.c_str(), + it->second.c_str()); } - return path + ext; + return it->second; + } + std::string bestKey; + std::string bestValue; + for (const auto& kv : g_importMap) { + const std::string& key = kv.first; + if (key.back() != '/') continue; + if (specifier.size() > key.size() && + specifier.compare(0, key.size(), key) == 0) { + if (key.size() > bestKey.size()) { + bestKey = key; + bestValue = kv.second; + } + } + } + if (!bestKey.empty()) { + std::string remainder = specifier.substr(bestKey.size()); + std::string resolved = bestValue + remainder; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map] prefix: %s -> %s (via %s)", specifier.c_str(), + resolved.c_str(), bestKey.c_str()); + } + return resolved; + } + return ""; } -// Helper function to check if a module is a Node.js built-in (e.g., node:url) -bool IsNodeBuiltinModule(const std::string& spec) { - return spec.size() > 5 && spec.substr(0, 5) == "node:"; +void CleanupImportMapGlobals() { + // Process-global import-map state (not isolate-bound). The per-isolate + // module handle maps (registry / fallback / fallbackByRelative) are torn + // down separately by DestroyModuleStateForIsolate(), which the Runtime + // destructor invokes for every isolate before disposal. + g_importMap.clear(); + g_volatilePatterns.clear(); } -// Helper function to get application path (for Android, we'll use a simple approach) -std::string GetApplicationPath() { - // For Android, use the actual file system path instead of asset path - // This should match the ApplicationFilesPath + "/app" from Module.java - JEnv env; - jstring applicationFilesPath = (jstring) env.CallStaticObjectMethod(ModuleInternal::MODULE_CLASS, ModuleInternal::GET_APPLICATION_FILES_PATH_METHOD_ID); - std::string path = ArgConverter::jstringToString(applicationFilesPath); - return path + "/app"; +// ───────────────────────────────────────────────────────────── +// Worker isolate detection: iOS keys off Caches::Get(isolate)->isWorker. +// Android encodes the same signal by installing a WORKER_WRAPPER pointer in +// the isolate's data slot on worker isolates only (see Runtime.h). +static bool IsCurrentIsolateWorker(v8::Isolate* isolate) { + if (isolate == nullptr) return false; + return isolate->GetData((uint32_t)Runtime::IsolateData::WORKER_WRAPPER) != + nullptr; +} + +// Monotonic microseconds since some fixed epoch — matches iOS's +// CFAbsoluteTimeGetCurrent() semantic (used for internal timing only, never +// exposed to JS). +static uint64_t MonotonicUs() { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000ull + (uint64_t)(ts.tv_nsec / 1000); +} + +// ───────────────────────────────────────────────────────────── +// Async HTTP module-graph pipeline +// +// See the contract comment in ModuleInternalCallbacks.h. Mechanically: +// +// EnqueueUrl(root) +// → FetchModuleBodyAsync (background thread — see HttpLoader.cpp) +// → hop to the isolate's JS thread via LooperTasks::Post +// → CompileModuleForResolveRegisterOnly (registers under the canonical +// URL key — the exact entry ResolveModuleCallback will look up) +// → GetModuleRequests() → ResolveModuleRequestForWalk → EnqueueUrl(…) +// → when pendingFetches drains, onComplete fires on the JS thread. +// +// Thread discipline: `visited`, `pendingFetches`, `failed`, `completed` are +// touched ONLY on the isolate's JS thread (every fetch completion hops there +// first). Only raw I/O runs off-thread. The one crossing signal is `dead`, +// an atomic set by isolate teardown so in-flight completions become no-ops +// instead of touching a disposed isolate. + +namespace { +struct AsyncGraphLoad { + v8::Isolate* isolate = nullptr; + v8::Global context; + std::shared_ptr jsTasks; // isolate's JS thread queue + std::string rootKey; // canonical registry key of the root URL + robin_hood::unordered_set visited; // canonical keys (JS thread only) + int pendingFetches = 0; // JS thread only + bool failed = false; // JS thread only (root failure) + bool completed = false; // JS thread only + std::string failureMessage; + size_t fetchedCount = 0; + size_t compiledCount = 0; + uint64_t startUs = 0; + std::atomic dead{false}; // set by isolate teardown (any thread) + std::function context)> + onComplete; + + ~AsyncGraphLoad() { + g_asyncGraphLoadsInFlightCounter().fetch_sub(1, std::memory_order_acq_rel); + } + + static std::atomic& g_asyncGraphLoadsInFlightCounter() { + static std::atomic counter{0}; + return counter; + } +}; + +std::mutex& AsyncGraphLoadsMutex() { + static std::mutex* mutex = new std::mutex(); + return *mutex; +} + +robin_hood::unordered_map>>& +AsyncGraphLoadsByIsolate() { + static auto* table = new robin_hood::unordered_map< + v8::Isolate*, std::vector>>(); + return *table; +} + +void RegisterAsyncGraphLoad(v8::Isolate* isolate, + const std::shared_ptr& load) { + std::lock_guard lock(AsyncGraphLoadsMutex()); + auto& loads = AsyncGraphLoadsByIsolate()[isolate]; + loads.erase(std::remove_if(loads.begin(), loads.end(), + [](const std::weak_ptr& w) { + return w.expired(); + }), + loads.end()); + loads.push_back(load); } +} // namespace -// ResolveModuleCallback - Main callback invoked by V8 to resolve import statements -v8::MaybeLocal ResolveModuleCallback(v8::Local context, - v8::Local specifier, - v8::Local import_assertions, - v8::Local referrer) { - v8::Isolate* isolate = v8::Isolate::GetCurrent(); +bool HasPendingAsyncModuleGraphWork() { + return AsyncGraphLoad::g_asyncGraphLoadsInFlightCounter().load( + std::memory_order_acquire) > 0; +} - // 1) Convert specifier to std::string - v8::String::Utf8Value specUtf8(isolate, specifier); - std::string spec = *specUtf8 ? *specUtf8 : ""; - if (spec.empty()) { - return v8::MaybeLocal(); +// Isolate-teardown hook: mark every in-flight load owned by `isolate` dead +// (pending fetch completions become no-ops) and Reset their context Globals +// NOW, while the isolate is still alive. +static void KillAsyncGraphLoadsForIsolate(v8::Isolate* isolate) { + std::vector> doomed; + { + std::lock_guard lock(AsyncGraphLoadsMutex()); + auto& table = AsyncGraphLoadsByIsolate(); + auto it = table.find(isolate); + if (it == table.end()) return; + for (auto& weak : it->second) { + if (auto load = weak.lock()) { + doomed.push_back(std::move(load)); + } } + table.erase(it); + } + for (auto& load : doomed) { + load->dead.store(true, std::memory_order_release); + load->context.Reset(); + } +} - // Debug logging - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: Resolving '%s'", spec.c_str()); +// Resolve one static module request to an absolute HTTP(S) URL using the +// SAME logic ResolveModuleCallback applies, in the same order: malformed +// scheme repair → import map (direct, then Vite-normalized) → absolute +// HTTP passthrough → relative/root-absolute resolution against an HTTP +// referrer. Returns empty for everything the walk should NOT touch. +static std::string ResolveModuleRequestForWalk(const std::string& rawSpec, + const std::string& referrerUrl) { + if (rawSpec.empty() || rawSpec == "@") return ""; + std::string spec = rawSpec; + if (spec.rfind("http:/", 0) == 0 && spec.rfind("http://", 0) != 0) { + spec.insert(5, "/"); + } else if (spec.rfind("https:/", 0) == 0 && spec.rfind("https://", 0) != 0) { + spec.insert(6, "/"); + } + + if (!g_importMap.empty()) { + std::string mapped = LookupImportMap(spec); + if (mapped.empty()) { + std::string normalized = NormalizeViteSpecifier(spec); + if (!normalized.empty()) { + mapped = LookupImportMap(normalized); + } + } + if (!mapped.empty()) spec = mapped; + } + + if (StartsWith(spec, "http://") || StartsWith(spec, "https://")) { + return spec; + } + + const bool specIsRelative = !spec.empty() && spec[0] == '.'; + const bool specIsRootAbs = !spec.empty() && spec[0] == '/'; + const bool referrerIsHttp = StartsWith(referrerUrl, "http://") || + StartsWith(referrerUrl, "https://"); + if ((specIsRelative || specIsRootAbs) && referrerIsHttp) { + std::string resolved = ResolveHttpRelative(referrerUrl, spec); + if (StartsWith(resolved, "http://") || StartsWith(resolved, "https://")) { + return resolved; } + } + return ""; +} - // Builtin modules resolve before any path handling. Unshimmed "node:" - // names fall through to the legacy polyfills below. - if (NsBuiltinModules::IsRegistered(spec) || NsBuiltinModules::IsNsScheme(spec)) { - v8::Local builtin; - if (NsBuiltinModules::GetModule(context, spec).ToLocal(&builtin)) { - return v8::MaybeLocal(builtin); - } - if (!NsBuiltinModules::IsRegistered(spec)) { - isolate->ThrowException(v8::Exception::Error( - ArgConverter::ConvertToV8String(isolate, NsBuiltinModules::NotFoundMessage(spec)))); +static void AsyncGraphEnqueueUrl(const std::shared_ptr& load, + const std::string& url); + +// Walk `mod`'s static module requests and enqueue every HTTP-resolvable +// dependency. JS thread only; `moduleUrl` is the canonical URL the module +// was registered under (the referrer for relative resolution). +static void AsyncGraphWalkModuleRequests( + const std::shared_ptr& load, + v8::Local /*context*/, v8::Local mod, + const std::string& moduleUrl) { + v8::Isolate* isolate = load->isolate; + v8::Local requests = mod->GetModuleRequests(); + const int length = requests->Length(); + for (int i = 0; i < length; i++) { + v8::Local request = + requests->Get(i).As(); + if (request.IsEmpty()) continue; + v8::Local specV8 = request->GetSpecifier(); + v8::String::Utf8Value specUtf8(isolate, specV8); + if (!*specUtf8) continue; + std::string resolved = ResolveModuleRequestForWalk(*specUtf8, moduleUrl); + if (resolved.empty()) continue; + AsyncGraphEnqueueUrl(load, resolved); + } +} + +// Fire onComplete exactly once, when the frontier has drained. JS thread only. +static void AsyncGraphMaybeComplete(const std::shared_ptr& load, + v8::Local context) { + if (load->completed || load->pendingFetches > 0) return; + load->completed = true; + if (IsScriptLoadingLogEnabled()) { + const uint64_t endUs = MonotonicUs(); + const uint64_t ms = endUs > load->startUs ? (endUs - load->startUs) / 1000ull : 0ull; + DEBUG_WRITE( + "[async-graph][done] root=%s urls=%lu fetched=%lu compiled=%lu ms=%llu ok=%d", + load->rootKey.c_str(), (unsigned long)load->visited.size(), + (unsigned long)load->fetchedCount, (unsigned long)load->compiledCount, + (unsigned long long)ms, load->failed ? 0 : 1); + } + auto onComplete = std::move(load->onComplete); + load->onComplete = nullptr; + if (onComplete) { + v8::TryCatch tc(load->isolate); + onComplete(!load->failed, load->failureMessage, context); + (void)tc; // swallow any pending exception; failures already surface as rejections + } +} + +// A fetched body arrived on the isolate's JS thread: compile + register it, +// then walk its requests. Runs outside any V8 scope, so it enters the isolate +// the same way other cross-thread callbacks do. +static void AsyncGraphOnFetchCompleted( + const std::shared_ptr& load, const std::string& url, + bool ok, int status, const std::shared_ptr& body) { + if (load->dead.load(std::memory_order_acquire)) return; + v8::Isolate* isolate = load->isolate; + if (Runtime::GetRuntime(isolate) == nullptr) return; + + v8::Locker locker(isolate); + v8::Isolate::Scope isolate_scope(isolate); + v8::HandleScope handle_scope(isolate); + v8::Local context = load->context.Get(isolate); + if (context.IsEmpty()) return; + v8::Context::Scope context_scope(context); + + load->pendingFetches--; + + const std::string key = CanonicalizeHttpUrlKey(url); + const bool isRoot = (key == load->rootKey); + + if (!load->failed) { + if (!ok) { + if (isRoot) { + load->failed = true; + load->failureMessage = "HTTP import failed: " + url + + " (status=" + std::to_string(status) + ")"; + } else if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[async-graph][dep-fetch-fail] %s status=%d (left to sync resolver)", + url.c_str(), status); + } + } else { + load->fetchedCount++; + v8::MaybeLocal maybeMod = + CompileModuleForResolveRegisterOnly(isolate, context, *body, key); + v8::Local mod; + if (!maybeMod.ToLocal(&mod)) { + if (isRoot) { + load->failed = true; + load->failureMessage = "HTTP import compile failed: " + url; + } else if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[async-graph][dep-compile-fail] %s (left to sync resolver)", + url.c_str()); } - return v8::MaybeLocal(); + } else { + load->compiledCount++; + AsyncGraphWalkModuleRequests(load, context, mod, key); + } } + } - // Normalize malformed http:/ and https:/ prefixes - if (spec.rfind("http:/", 0) == 0 && spec.rfind("http://", 0) != 0) { - spec.insert(5, "/"); - } else if (spec.rfind("https:/", 0) == 0 && spec.rfind("https://", 0) != 0) { - spec.insert(6, "/"); - } + AsyncGraphMaybeComplete(load, context); + isolate->PerformMicrotaskCheckpoint(); +} - // Attempt to resolve relative or root-absolute specifiers against an HTTP referrer URL - std::string referrerPath; - for (auto& kv : g_moduleRegistry) { - v8::Local registered = kv.second.Get(isolate); - if (!registered.IsEmpty() && registered == referrer) { - referrerPath = kv.first; - break; +// Enqueue one URL into the walk frontier. JS thread only. +static void AsyncGraphEnqueueUrl(const std::shared_ptr& load, + const std::string& url) { + const std::string key = CanonicalizeHttpUrlKey(url); + if (!load->visited.insert(key).second) return; + + v8::Isolate* isolate = load->isolate; + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + auto it = g_moduleRegistry.find(key); + if (it != g_moduleRegistry.end()) { + v8::Local existing = it->second.Get(isolate); + if (!existing.IsEmpty() && existing->GetStatus() != v8::Module::kErrored) { + if (existing->GetStatus() == v8::Module::kUninstantiated) { + v8::Local context = load->context.Get(isolate); + if (!context.IsEmpty()) { + AsyncGraphWalkModuleRequests(load, context, existing, key); } + } + return; } - bool specIsRelative = !spec.empty() && spec[0] == '.'; - bool specIsRootAbs = !spec.empty() && spec[0] == '/'; - auto startsWithHttp = [](const std::string& s) -> bool { - return s.rfind("http://", 0) == 0 || s.rfind("https://", 0) == 0; - }; - if (!startsWithHttp(spec) && (specIsRelative || specIsRootAbs)) { - if (!referrerPath.empty() && startsWithHttp(referrerPath)) { - std::string resolved = ResolveHttpRelative(referrerPath, spec); - if (!resolved.empty()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: HTTP-relative resolved '%s' + '%s' -> '%s'", - referrerPath.c_str(), spec.c_str(), resolved.c_str()); - } - spec = resolved; - } - } else if (specIsRootAbs) { - // Fallback: use global __NS_HTTP_ORIGIN__ if present to anchor root-absolute specs - v8::Local key = ArgConverter::ConvertToV8String(isolate, "__NS_HTTP_ORIGIN__"); - v8::Local global = context->Global(); - v8::MaybeLocal maybeOriginVal = global->Get(context, key); - v8::Local originVal; - if (!maybeOriginVal.IsEmpty() && maybeOriginVal.ToLocal(&originVal) && originVal->IsString()) { - v8::String::Utf8Value o8(isolate, originVal); - std::string origin = *o8 ? *o8 : ""; - if (!origin.empty() && (origin.rfind("http://", 0) == 0 || origin.rfind("https://", 0) == 0)) { - std::string refBase = origin; - if (refBase.back() != '/') refBase += '/'; - std::string resolved = ResolveHttpRelative(refBase, spec); - if (!resolved.empty()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][http-origin][fallback] origin=%s spec=%s -> %s", refBase.c_str(), spec.c_str(), resolved.c_str()); - } - spec = resolved; - } - } - } - } + RemoveModuleFromRegistry(key); + } + + load->pendingFetches++; + std::shared_ptr jsTasks = load->jsTasks; + std::shared_ptr loadRef = load; + FetchModuleBodyAsync(url, [loadRef, url, jsTasks](bool ok, int status, + std::string body) { + // Arbitrary thread. Hop to the isolate's JS thread before touching any + // walk state or V8. If the isolate died in between, drop everything — + // the context Global was already Reset by the teardown hook. + if (loadRef->dead.load(std::memory_order_acquire) || jsTasks == nullptr) { + return; } + auto bodyPtr = std::make_shared(std::move(body)); + jsTasks->Post([loadRef, url, ok, status, bodyPtr]() { + AsyncGraphOnFetchCompleted(loadRef, url, ok, status, bodyPtr); + }); + }); +} - // HTTP(S) ESM support: resolve, fetch and compile from dev server - // Security: HttpFetchText gates remote module access centrally. - if (spec.rfind("http://", 0) == 0 || spec.rfind("https://", 0) == 0) { - std::string canonical = tns::CanonicalizeHttpUrlKey(spec); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][resolve] spec=%s canonical=%s", spec.c_str(), canonical.c_str()); - } - auto it = g_moduleRegistry.find(canonical); - if (it != g_moduleRegistry.end()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][cache] hit %s", canonical.c_str()); - } - return v8::MaybeLocal(it->second.Get(isolate)); - } +void StartAsyncHttpModuleGraphLoad( + v8::Isolate* isolate, v8::Local context, + const std::string& rootUrl, + std::function context)> + onComplete) { + auto load = std::make_shared(); + load->isolate = isolate; + load->context.Reset(isolate, context); + load->rootKey = CanonicalizeHttpUrlKey(rootUrl); + load->startUs = MonotonicUs(); + load->onComplete = std::move(onComplete); + + Runtime* runtime = Runtime::GetRuntime(isolate); + load->jsTasks = runtime != nullptr ? runtime->GetLooperTasks() : nullptr; + + AsyncGraphLoad::g_asyncGraphLoadsInFlightCounter().fetch_add( + 1, std::memory_order_acq_rel); + RegisterAsyncGraphLoad(isolate, load); + + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[async-graph][start] root=%s key=%s", rootUrl.c_str(), + load->rootKey.c_str()); + } + + AsyncGraphEnqueueUrl(load, rootUrl); + // Root already registered (or nothing fetchable): complete inline. + AsyncGraphMaybeComplete(load, context); +} - std::string body, ct; - int status = 0; - if (!tns::HttpFetchText(spec, body, ct, status)) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][fetch][fail] url=%s status=%d", spec.c_str(), status); - } - std::string msg = std::string("Failed to fetch ") + spec + ", status=" + std::to_string(status); - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); - } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][fetch][ok] url=%s status=%d bytes=%lu ct=%s", spec.c_str(), status, (unsigned long)body.size(), ct.c_str()); - } +bool RunAsyncHttpModuleGraphLoadPumped(v8::Isolate* isolate, + v8::Local context, + const std::string& rootUrl, + double timeoutSeconds) { + if (timeoutSeconds <= 0.0) timeoutSeconds = 60.0; + auto done = std::make_shared(false); + StartAsyncHttpModuleGraphLoad( + isolate, context, rootUrl, + [done](bool /*ok*/, const std::string& /*errorMessage*/, + v8::Local) { *done = true; }); + + // Manual looper pump ("until either all is settled or the app takes + // over"): the walk's completion tasks are posted to this thread's + // LooperTasks queue and dispatched via ALooper — polling the looper here + // services them. ALooper_pollOnce with a small timeout keeps the pump + // responsive without spinning. + const auto deadline = + std::chrono::steady_clock::now() + + std::chrono::milliseconds(static_cast(timeoutSeconds * 1000.0)); + while (!*done && std::chrono::steady_clock::now() < deadline) { + ALooper_pollOnce(10 /* ms */, nullptr, nullptr, nullptr); + } + if (!*done && IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[async-graph][pumped][timeout] root=%s after %.1fs (sync loader takes over)", + rootUrl.c_str(), timeoutSeconds); + } + return *done; +} - v8::Local sourceText = ArgConverter::ConvertToV8String(isolate, body); - v8::Local urlString = ArgConverter::ConvertToV8String(isolate, canonical); - v8::ScriptOrigin origin(urlString, 0, 0, false, -1, v8::Local(), false, false, true); - v8::ScriptCompiler::Source src(sourceText, origin); - v8::Local mod; - { - v8::TryCatch tc(isolate); - if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&mod)) { - LogHttpCompileDiagnostics(isolate, context, canonical, body, tc); - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "HTTP module compile failed"))); - return v8::MaybeLocal(); - } - } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][compile][ok] %s bytes=%lu", canonical.c_str(), (unsigned long)body.size()); - } - // Register before instantiation to allow cyclic imports to resolve to same instance - g_moduleRegistry[canonical].Reset(isolate, mod); - // Do not evaluate here; allow V8 to handle instantiation/evaluation in importer context. - // Instantiate proactively if desired (safe), but not required. - // if (mod->GetStatus() == v8::Module::kUninstantiated) { - // if (!mod->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { - // g_moduleRegistry.erase(canonical); - // return v8::MaybeLocal(); - // } - // } - // Let V8 evaluate during importer evaluation. Returning compiled module is fine. - return v8::MaybeLocal(mod); - } - - // 2) Find which filepath the referrer was compiled under (local filesystem case) - // referrerPath may already be set above; leave as-is if found. - if (referrerPath.empty()) { - for (auto& kv : g_moduleRegistry) { - v8::Local registered = kv.second.Get(isolate); - if (registered == referrer) { - referrerPath = kv.first; - break; - } - } +// ───────────────────────────────────────────────────────────── +// Registry mutation + diagnostics + +// Compute a relative path key for fallback lookup (mirrors iOS's helper). +// On Android there is no separate Documents directory — everything lives +// under the application path. +static std::string ExtractRelativePath(const std::string& path) { + std::string appPrefix = NormalizePath(GetApplicationPath()); + if (!appPrefix.empty()) { + std::string directPrefix = appPrefix + "/"; + if (path.rfind(directPrefix, 0) == 0) { + return path.substr(directPrefix.size()); + } + // Some code paths carry "…/app/…" twice (bundled app folder). + std::string appFolderPrefix = appPrefix + "/app/"; + if (path.rfind(appFolderPrefix, 0) == 0) { + return path.substr(appFolderPrefix.size()); } + } + return ""; +} - // If we couldn't identify the referrer and the specifier is relative, - // assume the base directory is the application root - bool specIsRelativeFs = !spec.empty() && spec[0] == '.'; - if (referrerPath.empty() && specIsRelativeFs) { - referrerPath = GetApplicationPath() + "/index.mjs"; // Default referrer +static const char* ModuleStatusToString(v8::Module::Status status) { + switch (status) { + case v8::Module::kUninstantiated: + return "Uninstantiated"; + case v8::Module::kInstantiating: + return "Instantiating"; + case v8::Module::kInstantiated: + return "Instantiated"; + case v8::Module::kEvaluating: + return "Evaluating"; + case v8::Module::kEvaluated: + return "Evaluated"; + case v8::Module::kErrored: + return "Errored"; + } + return "Unknown"; +} + +void RemoveModuleFromRegistry(const std::string& canonicalPath) { + // Only ever called on an isolate's own JS thread during module + // resolution/loading, so the entered isolate owns the maps to mutate. + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + if (isolate == nullptr) return; + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + auto& g_moduleFallbackRegistry = ModuleFallbackRegistryFor(isolate); + auto& g_moduleFallbackByRelative = ModuleFallbackByRelativeFor(isolate); + const std::string registryKey = CanonicalizeRegistryKey(canonicalPath); + + // Defensive: never operate on an anomalous/sentinel key. + auto isSentinel = [](const std::string& s) -> bool { + if (s == "@") return true; + return s.find("__invalid_at__.mjs") != std::string::npos; + }; + if (isSentinel(registryKey)) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][guard-v3] ignore remove for sentinel %s", + registryKey.c_str()); + } + return; + } + + auto classify = [](const std::string& s) -> const char* { + if (s == "@") return "sentinel:@"; + if (s.find("__invalid_at__.mjs") != std::string::npos) + return "sentinel:invalid_at"; + bool http = StartsWith(s, "http://") || StartsWith(s, "https://"); + if (http) { + if (IsVolatileUrl(s)) return "http:volatile"; + if (s.find("/@ns/sfc/") != std::string::npos) return "http:sfc"; + if (s.find("/@ns/m/") != std::string::npos) return "http:m"; + return "http:other"; } + if (StartsWith(s, "file://")) return "file-url"; + return "path"; + }; + + if (IsScriptLoadingLogEnabled()) { + if (registryKey != canonicalPath) { + DEBUG_WRITE("[resolver][remove:pre] raw=%s key=%s class=%s", + canonicalPath.c_str(), registryKey.c_str(), + classify(registryKey)); + } else { + DEBUG_WRITE("[resolver][remove:pre] key=%s class=%s", registryKey.c_str(), + classify(registryKey)); + } + } + + size_t regPre = g_moduleRegistry.size(); + size_t fbPre = g_moduleFallbackRegistry.size(); + size_t relPre = g_moduleFallbackByRelative.size(); + + auto it = g_moduleRegistry.find(registryKey); + if (it != g_moduleRegistry.end()) { + bool isHttpKey = + StartsWith(registryKey, "http://") || StartsWith(registryKey, "https://"); + if (IsScriptLoadingLogEnabled() && !isHttpKey) { + DEBUG_WRITE("[resolver] removing stale module %s", registryKey.c_str()); + } + it->second.Reset(); + g_moduleRegistry.erase(it); + } else if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[resolver][remove:miss] key not found, proceed to clear fallbacks (%s)", + registryKey.c_str()); + } + auto fb = g_moduleFallbackRegistry.find(registryKey); + if (fb != g_moduleFallbackRegistry.end()) { + fb->second.Reset(); + g_moduleFallbackRegistry.erase(fb); + } + std::string rel = ExtractRelativePath(registryKey); + if (!rel.empty()) { + auto fbr = g_moduleFallbackByRelative.find(rel); + if (fbr != g_moduleFallbackByRelative.end()) { + fbr->second.Reset(); + g_moduleFallbackByRelative.erase(fbr); + } + } + + if (IsScriptLoadingLogEnabled()) { + size_t regPost = g_moduleRegistry.size(); + size_t fbPost = g_moduleFallbackRegistry.size(); + size_t relPost = g_moduleFallbackByRelative.size(); + DEBUG_WRITE( + "[resolver][remove:post] reg %lu->%lu fb %lu->%lu rel %lu->%lu", + (unsigned long)regPre, (unsigned long)regPost, (unsigned long)fbPre, + (unsigned long)fbPost, (unsigned long)relPre, (unsigned long)relPost); + } +} - // 3) Compute base directory from referrer path - size_t slash = referrerPath.find_last_of("/\\"); - std::string baseDir = slash == std::string::npos ? "" : referrerPath.substr(0, slash + 1); +std::vector GetLoadedModuleUrls() { + std::vector urls; + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + if (isolate == nullptr) return urls; + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + urls.reserve(g_moduleRegistry.size()); + + for (const auto& entry : g_moduleRegistry) { + const std::string& key = entry.first; + if (key.empty()) continue; + if (StartsWith(key, "blob:") || key.find("://") != std::string::npos) { + urls.push_back(key); + } + } + std::sort(urls.begin(), urls.end()); + urls.erase(std::unique(urls.begin(), urls.end()), urls.end()); + return urls; +} - // 4) Build candidate paths for resolution - std::vector candidateBases; - std::string appPath = GetApplicationPath(); +void InvalidateModules(v8::Isolate* isolate, v8::Local context, + const std::vector& urls) { + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + if (urls.empty()) return; + + robin_hood::unordered_set seen; + std::vector uniqueUrls; + uniqueUrls.reserve(urls.size()); + + for (const auto& url : urls) { + if (url.empty()) continue; + std::string registryKey = CanonicalizeRegistryKey(url); + if (registryKey.empty()) continue; + if (!seen.insert(registryKey).second) continue; + uniqueUrls.push_back(registryKey); + } + + const bool logScriptLoading = IsScriptLoadingLogEnabled(); + size_t hits = 0, misses = 0; + for (const auto& url : uniqueUrls) { + bool present = g_moduleRegistry.find(url) != g_moduleRegistry.end(); + if (present) hits++; + else misses++; + if (logScriptLoading) { + DEBUG_WRITE("[ns-hmr][android-invalidate] %s key=%s", + present ? "HIT " : "MISS", url.c_str()); + } + RejectAndClearInvalidatedModuleState(isolate, context, url); + RemoveModuleFromRegistry(url); + } + + // Second layer: the OS HTTP cache is outside our control and may serve + // a previous save's body even with no-store headers. Mark every + // invalidated key so the NEXT network fetch carries a unique + // `__ns_dev_nonce` query param — the network sees a URL it has never + // cached and must go to origin. The nonce is transport-only; module + // identity stays the canonical URL. + MarkUrlsForCacheBust(uniqueUrls); + + if (logScriptLoading) { + DEBUG_WRITE( + "[ns-hmr][android-invalidate] summary unique=%lu hits=%lu misses=%lu " + "(registry now=%lu)", + (unsigned long)uniqueUrls.size(), (unsigned long)hits, + (unsigned long)misses, (unsigned long)g_moduleRegistry.size()); + } +} - if (!spec.empty() && spec[0] == '.') { - // Relative import (./ or ../) - std::string cleanSpec = spec.substr(0, 2) == "./" ? spec.substr(2) : spec; - std::string candidate = baseDir + cleanSpec; - candidateBases.push_back(candidate); +void UpdateModuleFallback(v8::Isolate* isolate, + const std::string& canonicalPath, + v8::Local module) { + auto& g_moduleFallbackRegistry = ModuleFallbackRegistryFor(isolate); + auto& g_moduleFallbackByRelative = ModuleFallbackByRelativeFor(isolate); + auto fallbackIt = g_moduleFallbackRegistry.find(canonicalPath); + if (fallbackIt != g_moduleFallbackRegistry.end()) { + fallbackIt->second.Reset(); + } + if (!module.IsEmpty()) { + g_moduleFallbackRegistry[canonicalPath].Reset(isolate, module); if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: Relative import: '%s' + '%s' -> '%s'", - baseDir.c_str(), cleanSpec.c_str(), candidate.c_str()); - } - } else if (spec.size() > 7 && spec.substr(0, 7) == "file://") { - // Absolute file URL - std::string tail = spec.substr(7); // strip file:// - if (tail.empty() || tail[0] != '/') { - tail = "/" + tail; - } - - // Map common virtual roots to the real appPath - const std::string appVirtualRoot = "/app/"; // e.g. file:///app/foo.mjs - const std::string androidAssetAppRoot = "/android_asset/app/"; // e.g. file:///android_asset/app/foo.mjs + DEBUG_WRITE("[resolver] fallback updated for %s from evaluated module", + canonicalPath.c_str()); + } + std::string relative = ExtractRelativePath(canonicalPath); + if (!relative.empty()) { + auto relativeIt = g_moduleFallbackByRelative.find(relative); + if (relativeIt != g_moduleFallbackByRelative.end()) { + relativeIt->second.Reset(); + } + g_moduleFallbackByRelative[relative].Reset(isolate, module); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver] fallback relative updated for %s", + relative.c_str()); + } + } + } +} - std::string candidate; - if (tail.rfind(appVirtualRoot, 0) == 0) { - // Drop the leading "/app/" and prepend real appPath - candidate = appPath + "/" + tail.substr(appVirtualRoot.size()); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: file:// to appPath mapping: '%s' -> '%s'", tail.c_str(), candidate.c_str()); - } - } else if (tail.rfind(androidAssetAppRoot, 0) == 0) { - // Replace "/android_asset/app/" with the real appPath - candidate = appPath + "/" + tail.substr(androidAssetAppRoot.size()); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: file:// android_asset mapping: '%s' -> '%s'", tail.c_str(), candidate.c_str()); - } - } else if (tail.rfind(appPath, 0) == 0) { - // Already an absolute on-disk path to the app folder - candidate = tail; - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: file:// absolute path preserved: '%s'", candidate.c_str()); - } - } else { - // Fallback: treat as absolute on-disk path - candidate = tail; - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: file:// generic absolute: '%s'", candidate.c_str()); - } - } +// ───────────────────────────────────────────────────────────── +// Thread-local resolver state +// +// Recursion detection + module in-flight/waiter tracking. Everything here is +// touched only from the isolate's own JS thread, so thread_local is safe. +static thread_local std::vector g_moduleResolutionStack; +static thread_local robin_hood::unordered_map g_moduleReentryCounts; +static thread_local robin_hood::unordered_map> + g_moduleReentryParents; +static thread_local robin_hood::unordered_map g_modulePrimaryImporters; +static thread_local robin_hood::unordered_set g_modulesInFlight; +static thread_local robin_hood::unordered_set g_modulesPendingReset; +static constexpr size_t kMaxModuleReentryCount = 256; +// Waiters: module registry key -> list of Promise resolvers waiting for +// completion (instantiated/evaluated or errored). +static robin_hood::unordered_map>> + g_moduleWaiters; +// Dynamic HTTP import waiters: resolve to module namespace when available. +static thread_local robin_hood::unordered_map< + std::string, std::vector>> + g_httpDynamicWaiters; + +static bool IsModuleEvaluationInProgress(v8::Module::Status status) { + return status == v8::Module::kInstantiating || + status == v8::Module::kEvaluating; +} - candidateBases.push_back(candidate); - } else if (!spec.empty() && spec[0] == '~') { - // Alias to application root using ~/path - std::string tail = spec.size() >= 2 && spec[1] == '/' ? spec.substr(2) : spec.substr(1); - std::string candidate = appPath + "/" + tail; - candidateBases.push_back(candidate); - } else if (!spec.empty() && spec[0] == '/') { - // Absolute path within the bundle - candidateBases.push_back(appPath + spec); - } else { - // Bare specifier – resolve relative to the application root - std::string candidate = appPath + "/" + spec; - candidateBases.push_back(candidate); - - // Try converting underscores to slashes (bundler heuristic) - std::string withSlashes = spec; - std::replace(withSlashes.begin(), withSlashes.end(), '_', '/'); - std::string candidateSlashes = appPath + "/" + withSlashes; - if (candidateSlashes != candidate) { - candidateBases.push_back(candidateSlashes); - } +static void ResolveResolversWithModuleNamespace( + v8::Isolate* isolate, v8::Local context, + std::vector>& resolvers, + v8::Local module, const std::string& registryKey) { + if (resolvers.empty()) return; + if (module.IsEmpty() || module->GetStatus() != v8::Module::kEvaluated) { + std::string msg = "Module did not finish evaluation: " + registryKey; + v8::Local errObj = + v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg)); + for (auto& resGlobal : resolvers) { + v8::Local resolver = resGlobal.Get(isolate); + if (!resolver.IsEmpty()) { + resolver->Reject(context, errObj).FromMaybe(false); + } + resGlobal.Reset(); } + return; + } + v8::Local moduleNamespace = module->GetModuleNamespace(); + for (auto& resGlobal : resolvers) { + v8::Local resolver = resGlobal.Get(isolate); + if (!resolver.IsEmpty()) { + resolver->Resolve(context, moduleNamespace).FromMaybe(false); + } + resGlobal.Reset(); + } +} - // 5) Attempt to resolve to an actual file - std::string absPath; - bool found = false; +static void RejectResolversWithReason( + v8::Isolate* isolate, v8::Local context, + std::vector>& resolvers, + v8::Local reason) { + if (resolvers.empty()) return; + for (auto& resGlobal : resolvers) { + v8::Local resolver = resGlobal.Get(isolate); + if (!resolver.IsEmpty()) { + resolver->Reject(context, reason).FromMaybe(false); + } + resGlobal.Reset(); + } +} - for (const std::string& baseCandidate : candidateBases) { - absPath = baseCandidate; +static bool QueueModuleWaiterIfInFlight(v8::Isolate* isolate, + const std::string& registryKey, + v8::Local module, + v8::Local resolver) { + if (registryKey.empty() || module.IsEmpty() || + !IsModuleEvaluationInProgress(module->GetStatus()) || + g_modulesInFlight.find(registryKey) == g_modulesInFlight.end()) { + return false; + } + g_moduleWaiters[registryKey].emplace_back(isolate, resolver); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][await] queued module waiter for %s status=%s", + registryKey.c_str(), + ModuleStatusToString(module->GetStatus())); + } + return true; +} - // Check if file exists as-is - if (IsFile(absPath)) { - found = true; - break; - } +static bool QueueHttpDynamicWaiterIfInFlight( + v8::Isolate* isolate, const std::string& registryKey, + v8::Local module, v8::Local resolver) { + if (registryKey.empty() || module.IsEmpty() || + !IsModuleEvaluationInProgress(module->GetStatus()) || + g_modulesInFlight.find(registryKey) == g_modulesInFlight.end()) { + return false; + } + g_httpDynamicWaiters[registryKey].emplace_back(isolate, resolver); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][http-await] queued waiter for %s status=%s", + registryKey.c_str(), + ModuleStatusToString(module->GetStatus())); + } + return true; +} - // Try adding extensions - const char* exts[] = {".mjs", ".js"}; - for (const char* ext : exts) { - std::string candidate = WithExtension(absPath, ext); - if (IsFile(candidate)) { - absPath = candidate; - found = true; - break; - } - } - if (found) break; - - // Try index files if path is a directory - const char* indexExts[] = {"/index.mjs", "/index.js"}; - for (const char* idx : indexExts) { - std::string candidate = absPath + idx; - if (IsFile(candidate)) { - absPath = candidate; - found = true; - break; - } - } - if (found) break; - } - - // Canonicalize "." / ".." segments so a file reached through different - // spellings (e.g. "./x" from /a/b and "../x" from /a/b/c both name /a/b/x) - // maps to one registry key and is compiled once. The HTTP branch - // canonicalizes via CanonicalizeHttpUrlKey. - if (found) { - absPath = NormalizeDotSegments(absPath); - } - - // 6) Handle special cases if file not found - if (!found) { - // Check for Node.js built-in modules - if (IsNodeBuiltinModule(spec)) { - std::string builtinName = spec.substr(5); // Remove "node:" prefix - - // Create polyfill content for Node.js built-in modules - std::string polyfillContent; - - if (builtinName == "url") { - // Create a polyfill for node:url with fileURLToPath - polyfillContent = "// Polyfill for node:url\n" - "export function fileURLToPath(url) {\n" - " if (typeof url === 'string') {\n" - " if (url.startsWith('file://')) {\n" - " return decodeURIComponent(url.slice(7));\n" - " }\n" - " return url;\n" - " }\n" - " if (url && typeof url.href === 'string') {\n" - " return fileURLToPath(url.href);\n" - " }\n" - " throw new Error('Invalid URL');\n" - "}\n" - "\n" - "export function pathToFileURL(path) {\n" - " const encoded = encodeURIComponent(path).replace(/%2F/g, '/');\n" - " return new URL('file://' + encoded);\n" - "}\n"; - } else if (builtinName == "module") { - // Create a polyfill for node:module with createRequire - polyfillContent = "// Polyfill for node:module\n" - "export function createRequire(filename) {\n" - " // Return the global require function\n" - " // In NativeScript, require is globally available\n" - " if (typeof require === 'function') {\n" - " return require;\n" - " }\n" - " \n" - " // Fallback: create a basic require function\n" - " return function(id) {\n" - " throw new Error('Module ' + id + ' not found. NativeScript require() not available.');\n" - " };\n" - "}\n" - "\n" - "// Export as default as well for compatibility\n" - "export default { createRequire };\n"; - } else if (builtinName == "path") { - // Create a polyfill for node:path - polyfillContent = "// Polyfill for node:path\n" - "export const sep = '/';\n" - "export const delimiter = ':';\n" - "\n" - "export function basename(path, ext) {\n" - " const name = path.split('/').pop() || '';\n" - " return ext && name.endsWith(ext) ? name.slice(0, -ext.length) : name;\n" - "}\n" - "\n" - "export function dirname(path) {\n" - " const parts = path.split('/');\n" - " return parts.slice(0, -1).join('/') || '/';\n" - "}\n" - "\n" - "export function extname(path) {\n" - " const name = basename(path);\n" - " const dot = name.lastIndexOf('.');\n" - " return dot > 0 ? name.slice(dot) : '';\n" - "}\n" - "\n" - "export function join(...paths) {\n" - " return paths.filter(Boolean).join('/').replace(/\\/+/g, '/');\n" - "}\n" - "\n" - "export function resolve(...paths) {\n" - " let resolved = '';\n" - " for (let path of paths) {\n" - " if (path.startsWith('/')) {\n" - " resolved = path;\n" - " } else {\n" - " resolved = join(resolved, path);\n" - " }\n" - " }\n" - " return resolved || '/';\n" - "}\n" - "\n" - "export function isAbsolute(path) {\n" - " return path.startsWith('/');\n" - "}\n" - "\n" - "export default { basename, dirname, extname, join, resolve, isAbsolute, sep, delimiter };\n"; - } else { - isolate->ThrowException(v8::Exception::Error( - ArgConverter::ConvertToV8String(isolate, NsBuiltinModules::NotFoundMessage(spec)))); - return v8::MaybeLocal(); - } - - // Create module source and compile it in-memory - v8::Local sourceText = ArgConverter::ConvertToV8String(isolate, polyfillContent); - - // Build URL for stack traces - std::string moduleUrl = "node:" + builtinName; - v8::Local urlString = ArgConverter::ConvertToV8String(isolate, moduleUrl); - - v8::ScriptOrigin origin(urlString, 0, 0, false, -1, v8::Local(), false, false, true /* is_module */); - v8::ScriptCompiler::Source src(sourceText, origin); - - v8::Local polyfillModule; - if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&polyfillModule)) { - std::string msg = "Failed to compile polyfill for: " + spec; - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); - } - - // Store in registry before instantiation - g_moduleRegistry[spec].Reset(isolate, polyfillModule); - - // Instantiate the module - if (!polyfillModule->InstantiateModule(context, ResolveModuleCallback).FromMaybe(false)) { - g_moduleRegistry.erase(spec); - std::string msg = "Failed to instantiate polyfill for: " + spec; - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); - } - - // Evaluate the module - v8::MaybeLocal evalResult = polyfillModule->Evaluate(context); - if (evalResult.IsEmpty()) { - g_moduleRegistry.erase(spec); - std::string msg = "Failed to evaluate polyfill for: " + spec; - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); - } - - return v8::MaybeLocal(polyfillModule); - - } else if (tns::ModuleInternal::IsLikelyOptionalModule(spec)) { - // For optional modules, create a placeholder - std::string msg = "Optional module not found: " + spec; - DEBUG_WRITE("ResolveModuleCallback: %s", msg.c_str()); - // Return empty to indicate module not found gracefully - return v8::MaybeLocal(); - } else { - // Regular module not found - std::string msg = "Cannot find module " + spec + " (tried " + absPath + ")"; - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); +// Build a rejection reason that PRESERVES the underlying V8 exception text. +static v8::Local BuildModuleFailureReason(v8::Isolate* isolate, + v8::TryCatch& tc, + const char* stage, + const std::string& urlOrKey) { + std::string message = std::string(stage) + ": " + urlOrKey; + if (tc.HasCaught()) { + v8::Local excMessage = tc.Message(); + if (!excMessage.IsEmpty()) { + v8::String::Utf8Value text(isolate, excMessage->Get()); + if (*text != nullptr && strlen(*text) > 0) { + message += std::string(" — ") + *text; + } + } else { + v8::Local exception = tc.Exception(); + if (!exception.IsEmpty()) { + v8::String::Utf8Value text(isolate, exception); + if (*text != nullptr && strlen(*text) > 0) { + message += std::string(" — ") + *text; } + } } + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][failure] %s", message.c_str()); + } + return v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, message)); +} - // 7) Handle JSON modules - if (absPath.size() >= 5 && absPath.compare(absPath.size() - 5, 5, ".json") == 0) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: Handling JSON module '%s'", absPath.c_str()); - } +static void ResolveModuleWaiters(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey, + v8::Local module) { + auto waitIt = g_moduleWaiters.find(registryKey); + if (waitIt == g_moduleWaiters.end()) return; + std::vector> resolvers; + resolvers.swap(waitIt->second); + g_moduleWaiters.erase(waitIt); + ResolveResolversWithModuleNamespace(isolate, context, resolvers, module, + registryKey); +} - // Read JSON file content - std::string jsonText = Runtime::GetRuntime(isolate)->ReadFileText(absPath); +static void RejectModuleWaiters(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey, + v8::Local reason) { + auto waitIt = g_moduleWaiters.find(registryKey); + if (waitIt == g_moduleWaiters.end()) return; + std::vector> resolvers; + resolvers.swap(waitIt->second); + g_moduleWaiters.erase(waitIt); + RejectResolversWithReason(isolate, context, resolvers, reason); +} - // Create ES module that exports the JSON as default - std::string moduleSource = "export default " + jsonText + ";"; +static void ResolveHttpDynamicWaiters(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey, + v8::Local module) { + auto waitIt = g_httpDynamicWaiters.find(registryKey); + if (waitIt != g_httpDynamicWaiters.end()) { + std::vector> resolvers; + resolvers.swap(waitIt->second); + g_httpDynamicWaiters.erase(waitIt); + ResolveResolversWithModuleNamespace(isolate, context, resolvers, module, + registryKey); + } + g_modulesInFlight.erase(registryKey); +} - v8::Local sourceText = ArgConverter::ConvertToV8String(isolate, moduleSource); - std::string url = "file://" + absPath; +static void RejectHttpDynamicWaiters(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey, + v8::Local reason) { + auto waitIt = g_httpDynamicWaiters.find(registryKey); + if (waitIt != g_httpDynamicWaiters.end()) { + std::vector> resolvers; + resolvers.swap(waitIt->second); + g_httpDynamicWaiters.erase(waitIt); + RejectResolversWithReason(isolate, context, resolvers, reason); + } + g_modulesInFlight.erase(registryKey); +} - v8::Local urlString; - if (!v8::String::NewFromUtf8(isolate, url.c_str(), v8::NewStringType::kNormal).ToLocal(&urlString)) { - isolate->ThrowException(v8::Exception::Error( - ArgConverter::ConvertToV8String(isolate, "Failed to create URL string for JSON module"))); - return v8::MaybeLocal(); - } +static void RejectResolversForInvalidation( + v8::Isolate* isolate, v8::Local context, + std::vector>& resolvers, + const std::string& registryKey) { + if (resolvers.empty()) return; + std::string message = "Module invalidated during dev reload: " + registryKey; + v8::Local error = + v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, message)); + for (auto& resolverGlobal : resolvers) { + v8::Local resolver = resolverGlobal.Get(isolate); + if (!resolver.IsEmpty()) { + resolver->Reject(context, error).FromMaybe(false); + } + resolverGlobal.Reset(); + } +} - v8::ScriptOrigin origin(urlString, 0, 0, false, -1, v8::Local(), false, - false, true /* is_module */); +static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey) { + g_moduleReentryCounts.erase(registryKey); + g_moduleReentryParents.erase(registryKey); + g_modulePrimaryImporters.erase(registryKey); + g_modulesInFlight.erase(registryKey); + g_modulesPendingReset.erase(registryKey); + + auto waitIt = g_moduleWaiters.find(registryKey); + if (waitIt != g_moduleWaiters.end()) { + std::vector> resolvers; + resolvers.swap(waitIt->second); + g_moduleWaiters.erase(waitIt); + RejectResolversForInvalidation(isolate, context, resolvers, registryKey); + } + + auto dynamicWaitIt = g_httpDynamicWaiters.find(registryKey); + if (dynamicWaitIt != g_httpDynamicWaiters.end()) { + std::vector> resolvers; + resolvers.swap(dynamicWaitIt->second); + g_httpDynamicWaiters.erase(dynamicWaitIt); + RejectResolversForInvalidation(isolate, context, resolvers, registryKey); + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][invalidate-state] cleared in-flight state for %s", + registryKey.c_str()); + } +} - v8::ScriptCompiler::Source src(sourceText, origin); +namespace { +struct ResolutionStackGuard { + ResolutionStackGuard(v8::Isolate* isolate, std::vector& stack, + const std::string& entry) + : isolate_(isolate), stack_(stack), entry_(entry), active_(true) { + stack_.push_back(entry_); + g_moduleReentryCounts[entry_] = 0; + g_moduleReentryParents.erase(entry_); + if (stack_.size() > 1) { + g_modulePrimaryImporters[entry_] = stack_[stack_.size() - 2]; + } else { + g_modulePrimaryImporters.erase(entry_); + } + g_modulesInFlight.insert(entry_); + g_modulesPendingReset.erase(entry_); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][stack] push (%lu) %s", + static_cast(stack_.size()), entry_.c_str()); + } + } - v8::Local jsonModule; - if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&jsonModule)) { - isolate->ThrowException(v8::Exception::SyntaxError( - ArgConverter::ConvertToV8String(isolate, "Failed to compile JSON module"))); - return v8::MaybeLocal(); + ~ResolutionStackGuard() { + if (!active_ || stack_.empty()) return; + auto& g_moduleRegistry = ModuleRegistryFor(isolate_); + auto& g_moduleFallbackRegistry = ModuleFallbackRegistryFor(isolate_); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][stack] pop (%lu) %s", + static_cast(stack_.size()), entry_.c_str()); + } + g_moduleReentryCounts.erase(entry_); + g_moduleReentryParents.erase(entry_); + g_modulePrimaryImporters.erase(entry_); + g_modulesInFlight.erase(entry_); + + v8::Module::Status finalStatus = v8::Module::kErrored; + auto regIt = g_moduleRegistry.find(entry_); + if (regIt != g_moduleRegistry.end()) { + v8::Local m = regIt->second.Get(isolate_); + if (!m.IsEmpty()) finalStatus = m->GetStatus(); + } + bool isError = finalStatus == v8::Module::kErrored; + auto waitIt = g_moduleWaiters.find(entry_); + if (waitIt != g_moduleWaiters.end()) { + v8::Local currentContext = isolate_->GetCurrentContext(); + if (isError || regIt == g_moduleRegistry.end()) { + std::string msg = "Module evaluation failed: " + entry_; + RejectModuleWaiters( + isolate_, currentContext, entry_, + v8::Exception::Error(ArgConverter::ConvertToV8String(isolate_, msg))); + } else { + v8::Local resolvedModule = regIt->second.Get(isolate_); + ResolveModuleWaiters(isolate_, currentContext, entry_, resolvedModule); + } + } + stack_.pop_back(); + auto pendingIt = g_modulesPendingReset.find(entry_); + if (pendingIt != g_modulesPendingReset.end()) { + auto it = g_moduleRegistry.find(entry_); + if (it != g_moduleRegistry.end()) { + v8::Local module = it->second.Get(isolate_); + v8::Module::Status status = + module.IsEmpty() ? v8::Module::kErrored : module->GetStatus(); + if (status != v8::Module::kEvaluated && status != v8::Module::kErrored) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[resolver] dropping incomplete module after unwind %s (status=%s)", + entry_.c_str(), ModuleStatusToString(status)); + } + RemoveModuleFromRegistry(entry_); } + } + g_modulesPendingReset.erase(pendingIt); + } - // Instantiate and evaluate the JSON module - if (!jsonModule->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { - return v8::MaybeLocal(); + auto activeIt = g_moduleRegistry.find(entry_); + if (activeIt != g_moduleRegistry.end()) { + v8::Local activeModule = activeIt->second.Get(isolate_); + if (!activeModule.IsEmpty() && + activeModule->GetStatus() == v8::Module::kEvaluated) { + g_moduleFallbackRegistry[entry_].Reset(isolate_, activeModule); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[resolver] updated fallback module for %s after successful evaluation", + entry_.c_str()); } + } + } + } + + void Release() { active_ = false; } + + private: + v8::Isolate* isolate_; + std::vector& stack_; + std::string entry_; + bool active_; +}; +} // namespace + +// ───────────────────────────────────────────────────────────── +// JSON module → synthetic ES module + +// Compile a `.json` file as an ES module whose default export is the parsed +// JSON value. Handles registry insertion and eager evaluation. +static v8::MaybeLocal CompileJsonAsEsModule( + v8::Isolate* isolate, v8::Local context, + const std::string& absPath, const std::string& registryAbsPath, + bool isWorker) { + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + if (isWorker && IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver] Worker handling JSON module '%s'", absPath.c_str()); + } + + std::string jsonText = Runtime::GetRuntime(isolate)->ReadFileText(absPath); + std::string moduleSource = "export default " + jsonText + ";"; + v8::Local sourceText = + ArgConverter::ConvertToV8String(isolate, moduleSource); + std::string url = "file://" + absPath; + + v8::Local urlString; + if (!v8::String::NewFromUtf8(isolate, url.c_str(), + v8::NewStringType::kNormal) + .ToLocal(&urlString)) { + isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Failed to create URL string for JSON module"))); + return v8::MaybeLocal(); + } + + v8::ScriptOrigin origin(urlString, 0, 0, false, -1, v8::Local(), + false, false, true /* is_module */); + v8::ScriptCompiler::Source src(sourceText, origin); + + v8::Local jsonModule; + if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&jsonModule)) { + isolate->ThrowException(v8::Exception::SyntaxError( + ArgConverter::ConvertToV8String(isolate, "Failed to compile JSON module"))); + return v8::MaybeLocal(); + } + + if (!jsonModule->InstantiateModule(context, &ResolveModuleCallback) + .FromMaybe(false)) { + return v8::MaybeLocal(); + } + v8::MaybeLocal evalResult = jsonModule->Evaluate(context); + if (evalResult.IsEmpty()) return v8::MaybeLocal(); + + auto it = g_moduleRegistry.find(registryAbsPath); + if (it != g_moduleRegistry.end()) it->second.Reset(); + g_moduleRegistry[registryAbsPath].Reset(isolate, jsonModule); + return v8::MaybeLocal(jsonModule); +} - v8::MaybeLocal evalResult = jsonModule->Evaluate(context); - if (evalResult.IsEmpty()) { - return v8::MaybeLocal(); - } +// ───────────────────────────────────────────────────────────── +// node: builtin polyfills (Android). iOS ships node:url only; Android has +// carried node:url / node:module / node:path shims for longer. Kept here to +// avoid a behavior regression relative to current Android main. +static const char* NodeUrlPolyfill() { + return "// In-memory polyfill for node:url\n" + "export function fileURLToPath(url) {\n" + " if (typeof url === 'string') {\n" + " if (url.startsWith('file://')) {\n" + " return decodeURIComponent(url.slice(7));\n" + " }\n" + " return url;\n" + " }\n" + " if (url && typeof url.href === 'string') {\n" + " return fileURLToPath(url.href);\n" + " }\n" + " throw new Error('Invalid URL');\n" + "}\n" + "\n" + "export function pathToFileURL(path) {\n" + " const encoded = encodeURIComponent(path).replace(/%2F/g, '/');\n" + " return new URL('file://' + encoded);\n" + "}\n"; +} + +static const char* NodeModulePolyfill() { + return "// In-memory polyfill for node:module\n" + "export function createRequire(filename) {\n" + " if (typeof require === 'function') {\n" + " return require;\n" + " }\n" + " return function(id) {\n" + " throw new Error('Module ' + id + ' not found. NativeScript require() not available.');\n" + " };\n" + "}\n" + "export default { createRequire };\n"; +} + +static const char* NodePathPolyfill() { + return "// In-memory polyfill for node:path\n" + "export const sep = '/';\n" + "export const delimiter = ':';\n" + "\n" + "export function basename(path, ext) {\n" + " const name = path.split('/').pop() || '';\n" + " return ext && name.endsWith(ext) ? name.slice(0, -ext.length) : name;\n" + "}\n" + "\n" + "export function dirname(path) {\n" + " const parts = path.split('/');\n" + " return parts.slice(0, -1).join('/') || '/';\n" + "}\n" + "\n" + "export function extname(path) {\n" + " const name = basename(path);\n" + " const dot = name.lastIndexOf('.');\n" + " return dot > 0 ? name.slice(dot) : '';\n" + "}\n" + "\n" + "export function join(...paths) {\n" + " return paths.filter(Boolean).join('/').replace(/\\/+/g, '/');\n" + "}\n" + "\n" + "export function resolve(...paths) {\n" + " let resolved = '';\n" + " for (let path of paths) {\n" + " if (path.startsWith('/')) {\n" + " resolved = path;\n" + " } else {\n" + " resolved = join(resolved, path);\n" + " }\n" + " }\n" + " return resolved || '/';\n" + "}\n" + "\n" + "export function isAbsolute(path) {\n" + " return path.startsWith('/');\n" + "}\n" + "\n" + "export default { basename, dirname, extname, join, resolve, isAbsolute, sep, delimiter };\n"; +} + +// Compile + register a node: builtin polyfill under `key`. Returns the +// compiled (but not instantiated) module on success. +static v8::MaybeLocal CompileNodeBuiltinPolyfill( + v8::Isolate* isolate, v8::Local context, + const std::string& spec, const std::string& key) { + const std::string builtinName = spec.substr(5); // drop "node:" + const char* polyfill = nullptr; + if (builtinName == "url") polyfill = NodeUrlPolyfill(); + else if (builtinName == "module") polyfill = NodeModulePolyfill(); + else if (builtinName == "path") polyfill = NodePathPolyfill(); + else { + isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, NsBuiltinModules::NotFoundMessage(spec)))); + return v8::MaybeLocal(); + } + return CompileModuleForResolveRegisterOnly(isolate, context, polyfill, key); +} - // Store in registry with safe handle management - auto it = g_moduleRegistry.find(absPath); - if (it != g_moduleRegistry.end()) { - it->second.Reset(); +// ───────────────────────────────────────────────────────────── +// ResolveModuleCallback — invoked by V8 to resolve `import X from ''`. +// +// Structure mirrors iOS: import-map first, then HTTP fast path, then +// filesystem resolution against the application root using the Android +// virtual-root mappings (file:///app/ and file:///android_asset/app/). + +v8::MaybeLocal ResolveModuleCallback( + v8::Local context, v8::Local specifier, + v8::Local /*import_assertions*/, + v8::Local referrer) { + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + auto& g_moduleFallbackRegistry = ModuleFallbackRegistryFor(isolate); + + v8::String::Utf8Value specUtf8(isolate, specifier); + const std::string rawSpec = *specUtf8 ? *specUtf8 : ""; + if (rawSpec.empty()) return v8::MaybeLocal(); + + // Builtins resolve before any path handling. + if (NsBuiltinModules::IsRegistered(rawSpec) || + NsBuiltinModules::IsNsScheme(rawSpec)) { + v8::Local builtin; + if (NsBuiltinModules::GetModule(context, rawSpec).ToLocal(&builtin)) { + return v8::MaybeLocal(builtin); + } + if (!NsBuiltinModules::IsRegistered(rawSpec)) { + isolate->ThrowException( + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, NsBuiltinModules::NotFoundMessage(rawSpec)))); + } + return v8::MaybeLocal(); + } + + std::string normalizedSpec = rawSpec; + // Repair malformed http:/ or https:/ prefixes so the HTTP fast path fires. + if (normalizedSpec.rfind("http:/", 0) == 0 && + normalizedSpec.rfind("http://", 0) != 0) { + normalizedSpec.insert(5, "/"); + } else if (normalizedSpec.rfind("https:/", 0) == 0 && + normalizedSpec.rfind("https://", 0) != 0) { + normalizedSpec.insert(6, "/"); + } + + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][spec] %s", normalizedSpec.c_str()); + } + + // Guard against a bare '@' spec — invalid; refuse to poison the registry. + if (normalizedSpec == "@") { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][normalize] ignoring invalid '@' static spec"); + } + return v8::MaybeLocal(); + } + + // Import map resolution (bare specifiers → resolved URLs). + if (!g_importMap.empty()) { + std::string mapped = LookupImportMap(normalizedSpec); + if (mapped.empty()) { + std::string normalized = NormalizeViteSpecifier(normalizedSpec); + if (!normalized.empty()) { + mapped = LookupImportMap(normalized); + if (!mapped.empty() && IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][import-map] normalized: %s -> %s -> %s", + normalizedSpec.c_str(), normalized.c_str(), + mapped.c_str()); + } + } + } + if (!mapped.empty()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][import-map] rewrite: %s -> %s", + normalizedSpec.c_str(), mapped.c_str()); + } + normalizedSpec = mapped; + } else { + bool looksBare = !normalizedSpec.empty() && normalizedSpec[0] != '/' && + normalizedSpec[0] != '.' && + normalizedSpec.find("://") == std::string::npos && + normalizedSpec.find('\\') == std::string::npos; + if (looksBare && IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[resolver][import-map][miss] bare='%s' importMap.size=%lu", + normalizedSpec.c_str(), (unsigned long)g_importMap.size()); + } + } + } + + const std::string& spec = normalizedSpec; + + // Early absolute-HTTP fast path. + if (StartsWith(spec, "http://") || StartsWith(spec, "https://")) { + return LoadHttpModuleForUrl(isolate, context, spec); + } + + const bool isWorker = IsCurrentIsolateWorker(isolate); + if (isWorker && IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver] Worker trying to resolve '%s'", spec.c_str()); + } + + // Find the referrer's registered path so we can resolve relative specs + // against its directory. + std::string referrerPath; + for (auto& kv : g_moduleRegistry) { + v8::Local registered = kv.second.Get(isolate); + if (!registered.IsEmpty() && registered == referrer) { + referrerPath = kv.first; + break; + } + } + bool specIsRelative = !spec.empty() && spec[0] == '.'; + if (referrerPath.empty() && specIsRelative) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver] No referrer for relative '%s' - assuming app root", + spec.c_str()); + } + referrerPath = GetApplicationPath() + "/index.mjs"; + } + + size_t slash = referrerPath.find_last_of("/\\"); + std::string baseDir = + slash == std::string::npos ? "" : referrerPath.substr(0, slash + 1); + + // Relative or root-absolute against an HTTP referrer resolves via HTTP. + bool referrerIsHttp = !referrerPath.empty() && + (StartsWith(referrerPath, "http://") || + StartsWith(referrerPath, "https://")); + bool specIsRootAbs = !spec.empty() && spec[0] == '/'; + if (referrerIsHttp && (specIsRelative || specIsRootAbs)) { + std::string resolvedHttp = ResolveHttpRelative(referrerPath, spec); + if (!resolvedHttp.empty() && + (StartsWith(resolvedHttp, "http://") || + StartsWith(resolvedHttp, "https://"))) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][http-rel] base=%s spec=%s -> %s", + referrerPath.c_str(), spec.c_str(), resolvedHttp.c_str()); + } + return LoadHttpModuleForUrl(isolate, context, resolvedHttp); + } + } else if (!referrerIsHttp && specIsRootAbs) { + // Fallback: use __NS_HTTP_ORIGIN__ if present to anchor bare root-absolute + // specs (matches historical Android behavior). + v8::Local key = + ArgConverter::ConvertToV8String(isolate, "__NS_HTTP_ORIGIN__"); + v8::Local global = context->Global(); + v8::MaybeLocal maybeOriginVal = global->Get(context, key); + v8::Local originVal; + if (!maybeOriginVal.IsEmpty() && maybeOriginVal.ToLocal(&originVal) && + originVal->IsString()) { + v8::String::Utf8Value o8(isolate, originVal); + std::string origin = *o8 ? *o8 : ""; + if (!origin.empty() && (StartsWith(origin, "http://") || + StartsWith(origin, "https://"))) { + std::string refBase = origin; + if (refBase.back() != '/') refBase += '/'; + std::string resolved = ResolveHttpRelative(refBase, spec); + if (StartsWith(resolved, "http://") || + StartsWith(resolved, "https://")) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][http-origin][fallback] origin=%s spec=%s -> %s", + refBase.c_str(), spec.c_str(), resolved.c_str()); + } + return LoadHttpModuleForUrl(isolate, context, resolved); } - g_moduleRegistry[absPath].Reset(isolate, jsonModule); - return v8::MaybeLocal(jsonModule); + } } + } + + // ── Build filesystem candidate paths ── + const std::string appPath = GetApplicationPath(); + std::vector candidateBases; - // 8) Check if we've already compiled this module - auto it = g_moduleRegistry.find(absPath); - if (it != g_moduleRegistry.end()) { + if (!spec.empty() && spec[0] == '.') { + std::string cleanSpec = spec.rfind("./", 0) == 0 ? spec.substr(2) : spec; + std::string candidate = NormalizePath(baseDir + cleanSpec); + candidateBases.push_back(candidate); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][normalize-rel] %s + %s -> %s", baseDir.c_str(), + cleanSpec.c_str(), candidate.c_str()); + } + } else if (StartsWith(spec, "file://")) { + // Absolute file URL. Handle the two virtual roots the runtime emits. + std::string tail = spec.substr(7); + if (tail.empty() || tail[0] != '/') tail = "/" + tail; + + const std::string appVirtualRoot = "/app/"; + const std::string androidAssetAppRoot = "/android_asset/app/"; + std::string candidate; + if (tail.rfind(appVirtualRoot, 0) == 0) { + candidate = appPath + "/" + tail.substr(appVirtualRoot.size()); + } else if (tail.rfind(androidAssetAppRoot, 0) == 0) { + candidate = appPath + "/" + tail.substr(androidAssetAppRoot.size()); + } else if (tail.rfind(appPath, 0) == 0) { + candidate = tail; + } else { + candidate = tail; + } + candidateBases.push_back(NormalizePath(candidate)); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][file-url] tail=%s -> %s", tail.c_str(), + candidateBases.back().c_str()); + } + } else if (!spec.empty() && spec[0] == '~') { + std::string tail = spec.size() >= 2 && spec[1] == '/' ? spec.substr(2) + : spec.substr(1); + std::string base = NormalizePath(appPath + "/" + tail); + candidateBases.push_back(base); + // Also try appPath/app for projects that bundle JS under an app folder. + std::string baseApp = NormalizePath(appPath + "/app/" + tail); + if (baseApp != base) candidateBases.push_back(baseApp); if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: Found cached module '%s'", absPath.c_str()); + DEBUG_WRITE("[resolver][tilde] spec=%s base=%s appBase=%s", spec.c_str(), + base.c_str(), baseApp.c_str()); + } + } else if (!spec.empty() && spec[0] == '/') { + // Absolute path. Dynamic import may already have resolved a relative + // specifier to a real filesystem path under the application root; use + // that as-is so we don't prefix ApplicationPath twice. Bundle-relative + // paths like /app/... or /src/... still resolve against appPath. + if (!appPath.empty() && spec.rfind(appPath, 0) == 0) { + candidateBases.push_back(NormalizePath(spec)); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][abs-fs] spec=%s", spec.c_str()); + } + } else { + std::string base = NormalizePath(appPath + spec); + candidateBases.push_back(base); + const std::string appPrefix = "/app/"; + if (spec.rfind(appPrefix, 0) == 0) { + std::string tailNoApp = spec.substr(appPrefix.size() - 1); + std::string baseNoApp = NormalizePath(appPath + tailNoApp); + if (baseNoApp != base) candidateBases.push_back(baseNoApp); + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][abs] spec=%s base=%s", spec.c_str(), + base.c_str()); + } + } + } else { + // Bare specifier — resolve relative to the application root. + std::string base = NormalizePath(appPath + "/" + spec); + candidateBases.push_back(base); + // Underscore-separated bundler chunk heuristic. + std::string withSlashes = spec; + std::replace(withSlashes.begin(), withSlashes.end(), '_', '/'); + std::string baseSlashes = NormalizePath(appPath + "/" + withSlashes); + if (baseSlashes != base) candidateBases.push_back(baseSlashes); + } + + // Reroute a candidate that accidentally embeds a collapsed HTTP URL. + auto rerouteHttpIfEmbedded = [&](const std::string& p, + v8::MaybeLocal* moduleOut) -> bool { + size_t pos1 = p.find("/http:/"); + size_t pos2 = p.find("/https:/"); + size_t pos = std::min(pos1 == std::string::npos ? SIZE_MAX : pos1, + pos2 == std::string::npos ? SIZE_MAX : pos2); + if (pos == SIZE_MAX) return false; + std::string tail = p.substr(pos + 1); + if (StartsWith(tail, "http:/") && !StartsWith(tail, "http://")) { + tail.insert(5, "/"); + } else if (StartsWith(tail, "https:/") && !StartsWith(tail, "https://")) { + tail.insert(6, "/"); + } + if (!(StartsWith(tail, "http://") || StartsWith(tail, "https://"))) + return false; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][http-embedded] %s -> %s", p.c_str(), tail.c_str()); + } + if (moduleOut != nullptr) { + *moduleOut = LoadHttpModuleForUrl(isolate, context, tail); + } + return true; + }; + + // ── Resolve on disk ── + std::string absPath; + bool found = false; + + for (const std::string& baseCandidate : candidateBases) { + absPath = baseCandidate; + + v8::MaybeLocal embeddedHttpModule; + if (rerouteHttpIfEmbedded(absPath, &embeddedHttpModule)) { + return embeddedHttpModule; + } + + if (IsFile(absPath)) { + found = true; + break; + } + const char* exts[] = {".mjs", ".js"}; + for (const char* e : exts) { + std::string cand = NormalizePath(WithExtension(absPath, e)); + if (IsFile(cand)) { + absPath = cand; + found = true; + break; + } + } + if (found) break; + const char* idxExts[] = {"/index.mjs", "/index.js"}; + for (const char* idx : idxExts) { + std::string cand = NormalizePath(absPath + idx); + if (IsFile(cand)) { + absPath = cand; + found = true; + break; + } + } + if (found) break; + } + + if (found) absPath = NormalizePath(absPath); + const std::string registryAbsPath = CanonicalizeRegistryKey(absPath); + + if (!found) { + // node: builtins that don't exist on disk get an in-memory polyfill + // module. Anything else throws Cannot find module (matches iOS HEAD; + // no optional-module empty-return placeholder). + if (IsNodeBuiltinModule(spec)) { + std::string key = spec; // e.g. "node:url" + auto itExisting = g_moduleRegistry.find(key); + if (itExisting != g_moduleRegistry.end()) { + v8::Local existing = itExisting->second.Get(isolate); + if (!existing.IsEmpty() && existing->GetStatus() != v8::Module::kErrored) { + return v8::MaybeLocal(existing); } - return v8::MaybeLocal(it->second.Get(isolate)); + RemoveModuleFromRegistry(key); + } + v8::MaybeLocal m = + CompileNodeBuiltinPolyfill(isolate, context, spec, key); + v8::Local mod; + if (m.ToLocal(&mod)) return m; + // CompileNodeBuiltinPolyfill already threw (unknown builtin, or + // compile failure). Do not overwrite that exception. + return v8::MaybeLocal(); + } + std::string msg = "Cannot find module '" + spec + "' (tried " + absPath + ")"; + isolate->ThrowException( + v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); + return v8::MaybeLocal(); + } + + // JSON module: compile a synthetic ESM. + if (EndsWith(absPath, ".json")) { + return CompileJsonAsEsModule(isolate, context, absPath, registryAbsPath, + isWorker); + } + + // Cache lookup. + auto it = g_moduleRegistry.find(registryAbsPath); + if (it != g_moduleRegistry.end()) { + v8::Local existing = it->second.Get(isolate); + v8::Module::Status status = + existing.IsEmpty() ? v8::Module::kErrored : existing->GetStatus(); + bool inCurrentStack = + std::find(g_moduleResolutionStack.begin(), + g_moduleResolutionStack.end(), + registryAbsPath) != g_moduleResolutionStack.end(); + bool shouldReuse = !existing.IsEmpty() && status != v8::Module::kErrored; + if (shouldReuse && + (status == v8::Module::kUninstantiated || + status == v8::Module::kInstantiating || + status == v8::Module::kEvaluating)) { + if (!inCurrentStack) shouldReuse = false; + } + if (shouldReuse) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver] cache hit %s (status=%s)", absPath.c_str(), + ModuleStatusToString(status)); + } + return v8::MaybeLocal(existing); } + if (!existing.IsEmpty() && status == v8::Module::kEvaluated) { + auto fallbackIt = g_moduleFallbackRegistry.find(registryAbsPath); + if (fallbackIt != g_moduleFallbackRegistry.end()) { + fallbackIt->second.Reset(); + } + g_moduleFallbackRegistry[registryAbsPath].Reset(isolate, existing); + } + RemoveModuleFromRegistry(absPath); + } - // 9) Compile and register the new module + // Detect recursive load prior to LoadESModule. + auto cycleIt = std::find(g_moduleResolutionStack.begin(), + g_moduleResolutionStack.end(), registryAbsPath); + if (cycleIt != g_moduleResolutionStack.end()) { if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: Compiling new module '%s'", absPath.c_str()); + DEBUG_WRITE( + "[resolver] Detected recursive load for %s (stack len %lu)", + absPath.c_str(), (unsigned long)g_moduleResolutionStack.size()); + } + auto existing = g_moduleRegistry.find(registryAbsPath); + if (existing != g_moduleRegistry.end()) { + return v8::MaybeLocal(existing->second.Get(isolate)); + } + if (IsDebuggable()) { + DEBUG_WRITE("[resolver] Debug mode - empty return for recursive load: %s", + absPath.c_str()); + return v8::MaybeLocal(); } - try { - // Use our existing LoadESModule function to compile the module - tns::ModuleInternal::LoadESModule(isolate, absPath); - } catch (NativeScriptException& ex) { - DEBUG_WRITE("ResolveModuleCallback: Failed to compile module '%s'", absPath.c_str()); - ex.ReThrowToV8(); - return v8::MaybeLocal(); + std::string msg = "Recursive module resolution detected for " + absPath; + isolate->ThrowException( + v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); + return v8::MaybeLocal(); + } + + ResolutionStackGuard stackGuard(isolate, g_moduleResolutionStack, + registryAbsPath); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver] -> LoadESModule %s", absPath.c_str()); + } + try { + tns::ModuleInternal::LoadESModule(isolate, absPath); + } catch (NativeScriptException& ex) { + if (isWorker) { + DEBUG_WRITE("[resolver] Worker failed to compile '%s' -> '%s'", + spec.c_str(), absPath.c_str()); } + ex.ReThrowToV8(); + return v8::MaybeLocal(); + } + auto it2 = g_moduleRegistry.find(registryAbsPath); + if (it2 == g_moduleRegistry.end()) { + return v8::MaybeLocal(); + } + return v8::MaybeLocal(it2->second.Get(isolate)); +} - // LoadESModule should have added it to g_moduleRegistry - auto it2 = g_moduleRegistry.find(absPath); - if (it2 == g_moduleRegistry.end()) { - // Something went wrong - std::string msg = "Failed to register compiled module: " + absPath; - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); +// ───────────────────────────────────────────────────────────── +// FinishHttpDynamicImport +// +// Called on the JS thread once the async graph walk has fetched (and +// registered as uninstantiated) the transitive closure for an HTTP dynamic +// import. Instantiates + evaluates the root and settles all queued +// dynamic-import waiters. Top-level await is fanned out to a Then handler so +// waiters only settle after the returned promise settles. +static void FinishHttpDynamicImport(v8::Isolate* isolate, + v8::Local context, + const std::string& key, + const std::string& requestUrl) { + if (IsScriptLoadingLogEnabled()) { + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + if (g_moduleRegistry.find(key) == g_moduleRegistry.end()) { + DEBUG_WRITE("[async-graph][fallback-sync-load] root missed walk: %s", + key.c_str()); } + } + v8::MaybeLocal modMaybe = + LoadHttpModuleForUrl(isolate, context, requestUrl); + if (!modMaybe.IsEmpty()) { + v8::Local mod; + if (modMaybe.ToLocal(&mod)) { + if (mod->GetStatus() == v8::Module::kUninstantiated) { + v8::TryCatch tcInstantiate(isolate); + if (!mod->InstantiateModule(context, &ResolveModuleCallback) + .FromMaybe(false)) { + RemoveModuleFromRegistry(key); + RejectHttpDynamicWaiters( + isolate, context, key, + BuildModuleFailureReason(isolate, tcInstantiate, + "Instantiation failed (http-loader)", + requestUrl)); + return; + } + } - return v8::MaybeLocal(it2->second.Get(isolate)); + if (IsModuleEvaluationInProgress(mod->GetStatus())) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[dyn-import][http-loader] waiting on existing evaluation for %s status=%s", + key.c_str(), ModuleStatusToString(mod->GetStatus())); + } + return; + } + + if (mod->GetStatus() != v8::Module::kEvaluated) { + v8::Local evalResult; + { + v8::TryCatch tcEvaluate(isolate); + if (!mod->Evaluate(context).ToLocal(&evalResult)) { + RemoveModuleFromRegistry(key); + RejectHttpDynamicWaiters( + isolate, context, key, + BuildModuleFailureReason(isolate, tcEvaluate, + "Evaluation failed (http-loader)", + requestUrl)); + return; + } + } + if (!evalResult.IsEmpty() && evalResult->IsPromise()) { + v8::Local p = evalResult.As(); + struct EvalWaitData2 { + std::string key; + v8::Global ctx; + v8::Global mod; + }; + auto* data2 = new EvalWaitData2{ + key, v8::Global(isolate, context), + v8::Global(isolate, mod)}; + auto onFulfilled2 = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + std::string keyLocal = d->key; + v8::Local modLocal = d->mod.Get(iso); + ResolveHttpDynamicWaiters(iso, ctx, keyLocal, modLocal); + delete d; + }; + auto onRejected2 = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + std::string keyLocal = d->key; + v8::Local reason = + (info.Length() > 0) + ? info[0] + : v8::Exception::Error(ArgConverter::ConvertToV8String( + iso, "Evaluation failed (http-loader TLA)")); + if (IsScriptLoadingLogEnabled()) { + v8::String::Utf8Value r(iso, reason); + if (*r) { + DEBUG_WRITE("[dyn-import][http-loader][tla] rejected: %s", *r); + } + } + RejectHttpDynamicWaiters(iso, ctx, keyLocal, reason); + delete d; + }; + v8::Local thenFulfillTpl2 = + v8::FunctionTemplate::New( + isolate, onFulfilled2, + v8::External::New(isolate, data2, + v8::kExternalPointerTypeTagDefault)); + v8::Local thenFulfill2 = + thenFulfillTpl2->GetFunction(context).ToLocalChecked(); + v8::Local thenRejectTpl2 = + v8::FunctionTemplate::New( + isolate, onRejected2, + v8::External::New(isolate, data2, + v8::kExternalPointerTypeTagDefault)); + v8::Local thenReject2 = + thenRejectTpl2->GetFunction(context).ToLocalChecked(); + p->Then(context, thenFulfill2, thenReject2).ToLocalChecked(); + return; + } + } + ResolveHttpDynamicWaiters(isolate, context, key, mod); + return; + } + } + RejectHttpDynamicWaiters( + isolate, context, key, + v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, "HTTP fetch/compile failed"))); } -// Dynamic import() host callback +// ───────────────────────────────────────────────────────────── +// ImportModuleDynamicallyCallback — host callback for `import()` expressions. +// +// Structure mirrors iOS: builtins → import-map → invalid-'@' guard → blob URL +// path → HTTP fast path (with coalescing + cache) → filesystem resolution via +// ResolveModuleCallback → instantiate/evaluate/TLA settle. v8::MaybeLocal ImportModuleDynamicallyCallback( - v8::Local context, v8::Local host_defined_options, + v8::Local context, v8::Local /*host_defined_options*/, v8::Local resource_name, v8::Local specifier, v8::Local import_assertions) { - v8::Isolate* isolate = v8::Isolate::GetCurrent(); - - // Convert specifier to std::string for logging - v8::String::Utf8Value specUtf8(isolate, specifier); - std::string spec = *specUtf8 ? *specUtf8 : ""; - + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + + v8::String::Utf8Value specUtf8(isolate, specifier); + const char* cSpec = (*specUtf8) ? *specUtf8 : ""; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import] -> %s", cSpec); + v8::Local resName = resource_name; + if (!resName.IsEmpty() && resName->IsString()) { + v8::String::Utf8Value rn(isolate, resName); + if (*rn) { + DEBUG_WRITE("[dyn-import][referrer] %s", *rn); + } + } + } + + std::string rawSpec = cSpec ? std::string(cSpec) : std::string(); + + // Builtin modules never touch the loader below; the namespace comes straight + // from the realm's synthetic module. + if (NsBuiltinModules::IsRegistered(rawSpec) || + NsBuiltinModules::IsNsScheme(rawSpec)) { + v8::EscapableHandleScope builtinScope(isolate); + v8::Local builtinResolver; + if (!v8::Promise::Resolver::New(context).ToLocal(&builtinResolver)) { + return v8::MaybeLocal(); + } + v8::TryCatch tc(isolate); + v8::Local builtin; + if (NsBuiltinModules::GetModule(context, rawSpec).ToLocal(&builtin)) { + builtinResolver->Resolve(context, builtin->GetModuleNamespace()) + .FromMaybe(false); + } else { + v8::Local error = + tc.HasCaught() + ? tc.Exception() + : v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, NsBuiltinModules::NotFoundMessage(rawSpec))); + // Reject must not run with a pending exception on the isolate. + tc.Reset(); + builtinResolver->Reject(context, error).FromMaybe(false); + } + return builtinScope.Escape(builtinResolver->GetPromise()); + } + + std::string normalizedSpec = rawSpec; + // remove query/hash ONLY for non-HTTP specs + bool isHttpLike = + (!normalizedSpec.empty() && (StartsWith(normalizedSpec, "http://") || + StartsWith(normalizedSpec, "https://"))); + if (!isHttpLike) { + size_t qpos = normalizedSpec.find_first_of("?#"); + if (qpos != std::string::npos) { + normalizedSpec = normalizedSpec.substr(0, qpos); + } + } + if (normalizedSpec != rawSpec) { + specifier = ArgConverter::ConvertToV8String(isolate, normalizedSpec); if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ImportModuleDynamicallyCallback: Dynamic import for '%s'", spec.c_str()); + DEBUG_WRITE("[dyn-import][normalize] %s -> %s", rawSpec.c_str(), + normalizedSpec.c_str()); } - - v8::EscapableHandleScope scope(isolate); - - // Create a Promise resolver we'll resolve/reject synchronously for now. - v8::Local resolver; - if (!v8::Promise::Resolver::New(context).ToLocal(&resolver)) { - // Failed to create resolver, return empty promise - return v8::MaybeLocal(); + } + + v8::EscapableHandleScope scope(isolate); + + v8::Local resolver; + if (!v8::Promise::Resolver::New(context).ToLocal(&resolver)) { + return v8::MaybeLocal(); + } + + // ── Import map resolution for dynamic import() ── + if (!g_importMap.empty() && !normalizedSpec.empty() && normalizedSpec != "@") { + std::string mapped = LookupImportMap(normalizedSpec); + if (mapped.empty()) { + std::string normalized = NormalizeViteSpecifier(normalizedSpec); + if (!normalized.empty()) { + mapped = LookupImportMap(normalized); + if (!mapped.empty() && IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][import-map] normalized: %s -> %s -> %s", + normalizedSpec.c_str(), normalized.c_str(), + mapped.c_str()); + } + } } - - // Builtin modules never reach the loader below; the namespace comes - // straight from the realm's synthetic module. - if (NsBuiltinModules::IsRegistered(spec) || NsBuiltinModules::IsNsScheme(spec)) { - v8::TryCatch tc(isolate); - v8::Local builtin; - if (NsBuiltinModules::GetModule(context, spec).ToLocal(&builtin)) { - resolver->Resolve(context, builtin->GetModuleNamespace()).FromMaybe(false); - } else { - v8::Local error = - tc.HasCaught() ? tc.Exception() - : v8::Exception::Error(ArgConverter::ConvertToV8String( - isolate, NsBuiltinModules::NotFoundMessage(spec))); - // Reject must not run with the exception still pending on the isolate. - tc.Reset(); - resolver->Reject(context, error).FromMaybe(false); + if (!mapped.empty()) { + normalizedSpec = mapped; + specifier = ArgConverter::ConvertToV8String(isolate, normalizedSpec); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][import-map] rewrite: %s -> %s", + rawSpec.c_str(), normalizedSpec.c_str()); + } + } + } + + try { + // Defensive guard: some dev-time toolchains emit a stray import('@') during + // bootstrap. Treat it as a no-op module to avoid a hard failure. + if (!normalizedSpec.empty() && normalizedSpec == "@") { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[dyn-import] ignoring invalid '@' spec (returning empty module)"); + } + const char* kEmptySrc = "export {}\n"; + std::string url = "file:///app/__invalid_at__.mjs"; + v8::MaybeLocal modMaybe = + CompileModuleFromSource(isolate, context, kEmptySrc, url); + v8::Local mod; + if (modMaybe.ToLocal(&mod)) { + g_moduleRegistry[CanonicalizeRegistryKey(url)].Reset(isolate, mod); + if (mod->GetStatus() != v8::Module::kEvaluated) { + if (mod->Evaluate(context).IsEmpty()) { + resolver + ->Reject(context, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Evaluation failed for empty module"))) + .FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } } + resolver->Resolve(context, mod->GetModuleNamespace()).FromMaybe(false); return scope.Escape(resolver->GetPromise()); + } } - // Resolve relative or root-absolute dynamic imports against the referrer's URL when provided - auto isHttpLike = [](const std::string& s) -> bool { - return s.rfind("http://", 0) == 0 || s.rfind("https://", 0) == 0; - }; - bool specIsRelative = !spec.empty() && spec[0] == '.'; - bool specIsRootAbs = !spec.empty() && spec[0] == '/'; - std::string referrerUrl; - if (!resource_name.IsEmpty() && resource_name->IsString()) { - v8::String::Utf8Value r8(isolate, resource_name); - referrerUrl = *r8 ? *r8 : ""; - } - if ((specIsRelative || specIsRootAbs) && isHttpLike(referrerUrl)) { - std::string resolved = ResolveHttpRelative(referrerUrl, spec); - if (!resolved.empty()) { + // ── Blob URL support (e.g. blob:nativescript/) ── + // Retrieve the blob content from the global BLOB_STORE via + // URL.InternalAccessor.getData() (installed by Android's blob-url.js) and + // compile it as an ES module. + if (!normalizedSpec.empty() && + StartsWith(normalizedSpec, "blob:nativescript/")) { + const std::string blobRegistryKey = CanonicalizeRegistryKey(normalizedSpec); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] trying blob URL %s key=%s", + normalizedSpec.c_str(), blobRegistryKey.c_str()); + } + + auto existingIt = g_moduleRegistry.find(blobRegistryKey); + if (existingIt != g_moduleRegistry.end()) { + v8::Local existing = existingIt->second.Get(isolate); + if (!existing.IsEmpty()) { + v8::Module::Status existingStatus = existing->GetStatus(); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob-cache] hit %s status=%s", + blobRegistryKey.c_str(), + ModuleStatusToString(existingStatus)); + } + if (existingStatus == v8::Module::kErrored) { + RemoveModuleFromRegistry(blobRegistryKey); + } else if (IsModuleEvaluationInProgress(existingStatus)) { + g_modulesInFlight.insert(blobRegistryKey); + g_httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][http-rel] base=%s spec=%s -> %s", referrerUrl.c_str(), spec.c_str(), resolved.c_str()); + DEBUG_WRITE( + "[dyn-import][blob-await] queued waiter for %s status=%s", + blobRegistryKey.c_str(), ModuleStatusToString(existingStatus)); } - spec = resolved; - } else if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][http-rel][skip] base=%s spec=%s", referrerUrl.c_str(), spec.c_str()); + return scope.Escape(resolver->GetPromise()); + } else { + resolver->Resolve(context, existing->GetModuleNamespace()) + .FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } + } else { + RemoveModuleFromRegistry(blobRegistryKey); + } + } + + if (g_modulesInFlight.find(blobRegistryKey) != g_modulesInFlight.end()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] coalesce in-flight %s", + blobRegistryKey.c_str()); } + g_httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); + return scope.Escape(resolver->GetPromise()); + } + + g_modulesInFlight.insert(blobRegistryKey); + g_httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); + + v8::TryCatch tc(isolate); + v8::Local globalObj = context->Global(); + + v8::Local urlCtorVal; + if (!globalObj + ->Get(context, ArgConverter::ConvertToV8String(isolate, "URL")) + .ToLocal(&urlCtorVal) || + !urlCtorVal->IsFunction()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] URL constructor not found"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "URL constructor not available"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local urlCtor = urlCtorVal.As(); + + v8::Local internalAccessorVal; + if (!urlCtor + ->Get(context, ArgConverter::ConvertToV8String(isolate, + "InternalAccessor")) + .ToLocal(&internalAccessorVal) || + !internalAccessorVal->IsObject()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] URL.InternalAccessor not found"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "URL.InternalAccessor not available"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local internalAccessor = + internalAccessorVal.As(); + + v8::Local getDataVal; + if (!internalAccessor + ->Get(context, + ArgConverter::ConvertToV8String(isolate, "getData")) + .ToLocal(&getDataVal) || + !getDataVal->IsFunction()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] URL.InternalAccessor.getData not found"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "URL.InternalAccessor.getData not available"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local getDataFn = getDataVal.As(); + + v8::Local urlArg = + ArgConverter::ConvertToV8String(isolate, normalizedSpec); + v8::Local blobDataVal; + if (!getDataFn->Call(context, internalAccessor, 1, &urlArg) + .ToLocal(&blobDataVal) || + blobDataVal->IsNullOrUndefined()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] blob not found in BLOB_STORE: %s", + normalizedSpec.c_str()); + } + std::string msg = "Blob not found: " + normalizedSpec; + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); + return scope.Escape(resolver->GetPromise()); + } + + if (!blobDataVal->IsObject()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] blob data is not an object"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, "Invalid blob data"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local blobData = blobDataVal.As(); + + v8::Local blobVal; + if (!blobData + ->Get(context, ArgConverter::ConvertToV8String(isolate, "blob")) + .ToLocal(&blobVal) || + !blobVal->IsObject()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] blob property not found"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Blob object not found"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local blobObj = blobVal.As(); + + v8::Local textFnVal; + if (!blobObj + ->Get(context, ArgConverter::ConvertToV8String(isolate, "text")) + .ToLocal(&textFnVal) || + !textFnVal->IsFunction()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] Blob.text() not available"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Blob.text() not available"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local textFn = textFnVal.As(); + + // Keep the two failure modes distinct — a throw out of text() and a + // non-thenable return — and carry the thrown value's text into the + // rejection to preserve diagnostics. + v8::Local textResultVal; + std::string textFailure; + { + v8::TryCatch textTc(isolate); + if (!textFn->Call(context, blobObj, 0, nullptr) + .ToLocal(&textResultVal)) { + textFailure = "Blob.text() threw"; + if (textTc.HasCaught()) { + v8::String::Utf8Value thrown(isolate, textTc.Exception()); + if (*thrown) { + textFailure += std::string(": ") + *thrown; + } + } + } + } + + v8::Local textPromise; + if (textFailure.empty() && + !AdoptThenable(isolate, context, textResultVal).ToLocal(&textPromise)) { + textFailure = "Blob.text() did not return a thenable"; + } + if (!textFailure.empty()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] %s", textFailure.c_str()); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, textFailure))); + return scope.Escape(resolver->GetPromise()); + } + + struct BlobImportData { + v8::Global ctx; + std::string blobUrl; + std::string registryKey; + }; + auto* data = new BlobImportData{v8::Global(isolate, context), + normalizedSpec, blobRegistryKey}; + + auto onFulfilled = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + + if (info.Length() < 1 || !info[0]->IsString()) { + RejectHttpDynamicWaiters(iso, ctx, d->registryKey, + v8::Exception::Error( + ArgConverter::ConvertToV8String( + iso, "Blob text is not a string"))); + delete d; + return; + } + + v8::String::Utf8Value codeUtf8(iso, info[0]); + std::string code = *codeUtf8 ? *codeUtf8 : ""; + + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] compiling blob module, code length=%zu", + code.size()); + } + + v8::MaybeLocal modMaybe = + CompileModuleForResolveRegisterOnly(iso, ctx, code, d->blobUrl); + v8::Local mod; + if (!modMaybe.ToLocal(&mod)) { + RejectHttpDynamicWaiters(iso, ctx, d->registryKey, + v8::Exception::Error( + ArgConverter::ConvertToV8String( + iso, "Failed to compile blob module"))); + delete d; + return; + } + + if (mod->GetStatus() == v8::Module::kUninstantiated && + !mod->InstantiateModule(ctx, &ResolveModuleCallback) + .FromMaybe(false)) { + RemoveModuleFromRegistry(d->registryKey); + RejectHttpDynamicWaiters( + iso, ctx, d->registryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + iso, "Failed to instantiate blob module"))); + delete d; + return; + } + + if (IsModuleEvaluationInProgress(mod->GetStatus())) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[dyn-import][blob] waiting on existing evaluation for %s status=%s", + d->registryKey.c_str(), ModuleStatusToString(mod->GetStatus())); + } + delete d; + return; + } + + if (mod->GetStatus() != v8::Module::kEvaluated) { + v8::Local evalResult; + if (!mod->Evaluate(ctx).ToLocal(&evalResult)) { + RemoveModuleFromRegistry(d->registryKey); + RejectHttpDynamicWaiters( + iso, ctx, d->registryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + iso, "Failed to evaluate blob module"))); + delete d; + return; + } + + if (!evalResult.IsEmpty() && evalResult->IsPromise()) { + struct BlobEvalData { + std::string registryKey; + v8::Global ctx; + v8::Global mod; + }; + auto* evalData = new BlobEvalData{ + d->registryKey, v8::Global(iso, ctx), + v8::Global(iso, mod)}; + + auto onEvalFulfilled = + [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + v8::Local mod = d->mod.Get(iso); + ResolveHttpDynamicWaiters(iso, ctx, d->registryKey, mod); + delete d; + }; + + auto onEvalRejected = + [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + v8::Local reason = + info.Length() > 0 + ? info[0] + : v8::Exception::Error( + ArgConverter::ConvertToV8String( + iso, "Blob module evaluation failed")); + RemoveModuleFromRegistry(d->registryKey); + RejectHttpDynamicWaiters(iso, ctx, d->registryKey, reason); + delete d; + }; + + v8::Local evalPromise = evalResult.As(); + v8::Local onEvalFulfilledFn = + v8::Function::New( + ctx, onEvalFulfilled, + v8::External::New(iso, evalData, + v8::kExternalPointerTypeTagDefault)) + .ToLocalChecked(); + v8::Local onEvalRejectedFn = + v8::Function::New( + ctx, onEvalRejected, + v8::External::New(iso, evalData, + v8::kExternalPointerTypeTagDefault)) + .ToLocalChecked(); + evalPromise->Then(ctx, onEvalFulfilledFn, onEvalRejectedFn) + .FromMaybe(v8::Local()); + delete d; + return; + } + } + + ResolveHttpDynamicWaiters(iso, ctx, d->registryKey, mod); + delete d; + }; + + auto onRejected = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + v8::Local reason = + info.Length() > 0 + ? info[0] + : v8::Exception::Error(ArgConverter::ConvertToV8String( + iso, "Blob text() failed")); + RejectHttpDynamicWaiters(iso, ctx, d->registryKey, reason); + delete d; + }; + + v8::Local onFulfilledFn = + v8::Function::New( + context, onFulfilled, + v8::External::New(isolate, data, + v8::kExternalPointerTypeTagDefault)) + .ToLocalChecked(); + v8::Local onRejectedFn = + v8::Function::New( + context, onRejected, + v8::External::New(isolate, data, + v8::kExternalPointerTypeTagDefault)) + .ToLocalChecked(); + + textPromise->Then(context, onFulfilledFn, onRejectedFn) + .FromMaybe(v8::Local()); + + return scope.Escape(resolver->GetPromise()); } - // Handle HTTP(S) dynamic import directly + // ── HTTP(S) fast path ── // Security: HttpFetchText gates remote module access centrally. - if (!spec.empty() && isHttpLike(spec)) { - std::string canonical = tns::CanonicalizeHttpUrlKey(spec); + if (!normalizedSpec.empty() && + (StartsWith(normalizedSpec, "http://") || + StartsWith(normalizedSpec, "https://"))) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][http-loader] trying URL %s", + normalizedSpec.c_str()); + } + std::string key = CanonicalizeHttpUrlKey(normalizedSpec); + + // Volatile-pattern eviction: if the URL matches any configured volatile + // pattern, evict the cached module so we always re-fetch. Policy is + // supplied exclusively by JS via ns:module `configureLoader({ + // volatilePatterns })` — the runtime carries no framework or server URL + // vocabulary of its own. + bool isVolatile = IsVolatileUrl(normalizedSpec); + if (isVolatile) { + auto ex = g_moduleRegistry.find(key); + if (ex != g_moduleRegistry.end()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][http-cache] drop volatile %s", key.c_str()); + } + RemoveModuleFromRegistry(key); + } + } + // Coalesce concurrent dynamic imports for the same HTTP key. + auto inflight = g_modulesInFlight.find(key) != g_modulesInFlight.end(); + if (inflight) { if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][resolve] spec=%s canonical=%s", spec.c_str(), canonical.c_str()); + DEBUG_WRITE("[dyn-import][http] coalesce in-flight %s", key.c_str()); } - v8::Local mod; - auto it = g_moduleRegistry.find(canonical); - if (it != g_moduleRegistry.end()) { - mod = it->second.Get(isolate); + g_httpDynamicWaiters[key].emplace_back(isolate, resolver); + return scope.Escape(resolver->GetPromise()); + } + // If module was already compiled, resolve immediately. + auto itExisting = g_moduleRegistry.find(key); + if (itExisting != g_moduleRegistry.end()) { + v8::Local existing = itExisting->second.Get(isolate); + if (!existing.IsEmpty()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][http-cache] hit %s status=%s", key.c_str(), + ModuleStatusToString(existing->GetStatus())); + } + v8::Module::Status st = existing->GetStatus(); + if (st == v8::Module::kErrored) { if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][cache] hit %s", canonical.c_str()); + DEBUG_WRITE("[dyn-import][http-cache] dropping errored module for %s", + key.c_str()); } - } else { - std::string body, ct; int status = 0; - if (!tns::HttpFetchText(spec, body, ct, status)) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][fetch][fail] url=%s status=%d", spec.c_str(), status); - } - resolver->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, std::string("Failed to fetch ")+spec))).Check(); - return scope.Escape(resolver->GetPromise()); + RemoveModuleFromRegistry(key); + } else if (IsModuleEvaluationInProgress(st)) { + if (QueueHttpDynamicWaiterIfInFlight(isolate, key, existing, + resolver)) { + return scope.Escape(resolver->GetPromise()); } if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][fetch][ok] url=%s status=%d bytes=%lu ct=%s", spec.c_str(), status, (unsigned long)body.size(), ct.c_str()); + DEBUG_WRITE( + "[dyn-import][http-cache] avoiding re-entrant Evaluate for %s status=%s", + key.c_str(), ModuleStatusToString(st)); } - v8::Local sourceText = ArgConverter::ConvertToV8String(isolate, body); - v8::Local urlString = ArgConverter::ConvertToV8String(isolate, canonical); - v8::ScriptOrigin origin(urlString, 0, 0, false, -1, v8::Local(), false, false, true); - v8::ScriptCompiler::Source src(sourceText, origin); - { - v8::TryCatch tc(isolate); - if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&mod)) { - LogHttpCompileDiagnostics(isolate, context, canonical, body, tc); - resolver->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "HTTP module compile failed"))).Check(); - return scope.Escape(resolver->GetPromise()); + resolver->Resolve(context, existing->GetModuleNamespace()) + .FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } else { + if (st != v8::Module::kEvaluated) { + g_modulesInFlight.insert(key); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][http-cache] awaiting evaluation %s", + key.c_str()); + } + g_httpDynamicWaiters[key].emplace_back(isolate, resolver); + if (st == v8::Module::kUninstantiated) { + v8::TryCatch tcInstantiate(isolate); + if (!existing->InstantiateModule(context, &ResolveModuleCallback) + .FromMaybe(false)) { + RemoveModuleFromRegistry(key); + RejectHttpDynamicWaiters( + isolate, context, key, + BuildModuleFailureReason( + isolate, tcInstantiate, + "Instantiation failed (http-cache hit)", key)); + return scope.Escape(resolver->GetPromise()); } - } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][compile][ok] %s bytes=%lu", canonical.c_str(), (unsigned long)body.size()); - } - g_moduleRegistry[canonical].Reset(isolate, mod); - } - if (mod->GetStatus() == v8::Module::kUninstantiated) { - if (!mod->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { - resolver->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "Instantiate failed"))).Check(); + } + + if (IsModuleEvaluationInProgress(existing->GetStatus())) { return scope.Escape(resolver->GetPromise()); - } - } - if (mod->GetStatus() != v8::Module::kEvaluated) { - if (mod->Evaluate(context).IsEmpty()) { - resolver->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "Evaluation failed"))).Check(); + } + + v8::Local evalResult; + { + v8::TryCatch tcEvaluate(isolate); + if (!existing->Evaluate(context).ToLocal(&evalResult)) { + RemoveModuleFromRegistry(key); + RejectHttpDynamicWaiters( + isolate, context, key, + BuildModuleFailureReason( + isolate, tcEvaluate, + "Evaluation failed (http-cache hit)", key)); + return scope.Escape(resolver->GetPromise()); + } + } + if (!evalResult.IsEmpty() && evalResult->IsPromise()) { + v8::Local p = evalResult.As(); + struct EvalWaitData { + std::string key; + v8::Global ctx; + v8::Global mod; + }; + auto* data = new EvalWaitData{ + key, v8::Global(isolate, context), + v8::Global(isolate, existing)}; + auto onFulfilled = + [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + std::string keyLocal = d->key; + v8::Local modLocal = d->mod.Get(iso); + ResolveHttpDynamicWaiters(iso, ctx, keyLocal, modLocal); + delete d; + }; + auto onRejected = + [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + std::string keyLocal = d->key; + v8::Local reason = + (info.Length() > 0) + ? info[0] + : v8::Exception::Error( + ArgConverter::ConvertToV8String( + iso, "Evaluation failed (http-cache TLA)")); + if (IsScriptLoadingLogEnabled()) { + v8::String::Utf8Value r(iso, reason); + if (*r) { + DEBUG_WRITE("[dyn-import][http-cache][tla] rejected: %s", + *r); + } + } + RejectHttpDynamicWaiters(iso, ctx, keyLocal, reason); + delete d; + }; + v8::Local thenFulfillTpl = + v8::FunctionTemplate::New( + isolate, onFulfilled, + v8::External::New(isolate, data, + v8::kExternalPointerTypeTagDefault)); + v8::Local thenFulfill = + thenFulfillTpl->GetFunction(context).ToLocalChecked(); + v8::Local thenRejectTpl = + v8::FunctionTemplate::New( + isolate, onRejected, + v8::External::New(isolate, data, + v8::kExternalPointerTypeTagDefault)); + v8::Local thenReject = + thenRejectTpl->GetFunction(context).ToLocalChecked(); + p->Then(context, thenFulfill, thenReject).ToLocalChecked(); return scope.Escape(resolver->GetPromise()); + } + ResolveHttpDynamicWaiters(isolate, context, key, existing); + return scope.Escape(resolver->GetPromise()); } + resolver->Resolve(context, existing->GetModuleNamespace()) + .FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } } - resolver->Resolve(context, mod->GetModuleNamespace()).Check(); - return scope.Escape(resolver->GetPromise()); + } + // Mark in-flight and start the async graph load. + g_modulesInFlight.insert(key); + g_httpDynamicWaiters[key].emplace_back(isolate, resolver); + const std::string requestUrl = normalizedSpec; + StartAsyncHttpModuleGraphLoad( + isolate, context, requestUrl, + [key, requestUrl, isolate](bool ok, const std::string& errorMessage, + v8::Local completionContext) { + v8::Isolate* iso = isolate; + if (!ok) { + RejectHttpDynamicWaiters( + iso, completionContext, key, + v8::Exception::Error( + ArgConverter::ConvertToV8String(iso, errorMessage))); + return; + } + FinishHttpDynamicImport(iso, completionContext, key, requestUrl); + }); + return scope.Escape(resolver->GetPromise()); } - // Re-use the static resolver to locate / compile the module for non-HTTP cases. - try { - // V8 exposes only the referrer's URL here (resource_name), not its Module, - // so anchor a relative specifier at the referrer's directory and hand the - // resolver an absolute file:// URL. Other specifiers pass through unchanged - // (the resolver applies its own ~/, bare and absolute heuristics). - v8::Local resolvedSpecifier = specifier; - if (specIsRelative) { - std::string fileResolved = ResolveFileRelative(referrerUrl, spec); - if (!fileResolved.empty()) { - resolvedSpecifier = ArgConverter::ConvertToV8String(isolate, fileResolved); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[esm][dyn][file-rel] base=%s spec=%s -> %s", - referrerUrl.c_str(), spec.c_str(), fileResolved.c_str()); - } + // ── Filesystem path ── + // For relative specs, adjust against the referrer's resource URL so + // ../-segments collapse and the resolver can find the target on disk. + v8::Local refMod; + v8::Local adjustedSpecifier = specifier; + if (!normalizedSpec.empty() && + (normalizedSpec.rfind("./", 0) == 0 || + normalizedSpec.rfind("../", 0) == 0)) { + v8::Local resName = resource_name; + if (!resName.IsEmpty() && resName->IsString()) { + v8::String::Utf8Value rn(isolate, resName); + std::string refUrl = *rn ? *rn : std::string(); + if (!refUrl.empty()) { + std::string refPath = FileURLToPath(refUrl); + size_t slash = refPath.find_last_of("/\\"); + std::string baseDir = slash == std::string::npos + ? std::string() + : refPath.substr(0, slash + 1); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][ref] url=%s base=%s spec=%s", refUrl.c_str(), + baseDir.c_str(), normalizedSpec.c_str()); + } + std::string fsPath = NormalizePath(baseDir + normalizedSpec); + if (!fsPath.empty()) { + adjustedSpecifier = + ArgConverter::ConvertToV8String(isolate, fsPath); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][normalize-rel] %s + %s -> %s", + baseDir.c_str(), normalizedSpec.c_str(), + fsPath.c_str()); } + } } + } else if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[dyn-import][ref] missing resource name; cannot normalize relative " + "spec against referrer"); + } + } - // Pass empty referrer: this V8 version does not expose GetModule() on - // ScriptOrModule, and the specifier above is already absolute when needed. - v8::Local refMod; + v8::TryCatch resolveTc(isolate); + v8::MaybeLocal maybeModule = ResolveModuleCallback( + context, adjustedSpecifier, import_assertions, refMod); + if (IsScriptLoadingLogEnabled()) { + v8::String::Utf8Value adj(isolate, adjustedSpecifier); + const char* cAdj = (*adj) ? *adj : ""; + DEBUG_WRITE("[dyn-import][resolver-call] raw=%s normalized=%s adjusted=%s", + rawSpec.c_str(), normalizedSpec.c_str(), cAdj); + } + v8::String::Utf8Value adjustedSpecUtf8(isolate, adjustedSpecifier); + std::string adjustedRegistryKey = + *adjustedSpecUtf8 ? CanonicalizeRegistryKey(*adjustedSpecUtf8) + : std::string(); + if (maybeModule.IsEmpty()) { + if (resolveTc.HasCaught()) { + resolver->Reject(context, resolveTc.Exception()).FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } else { + std::string msg = "Module resolution failed for dynamic import: "; + msg += normalizedSpec.empty() ? "" : normalizedSpec; + resolver + ->Reject(context, v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, msg))) + .FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } + } - v8::Local module; - { - v8::TryCatch resolveTc(isolate); - v8::MaybeLocal maybeModule = - ResolveModuleCallback(context, resolvedSpecifier, import_assertions, refMod); - - if (!maybeModule.ToLocal(&module)) { - // Resolution failed; reject to avoid leaving a pending Promise (white screen) - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ImportModuleDynamicallyCallback: Resolution failed for '%s'", spec.c_str()); - } - // The resolver's own error carries the reason (a missing - // builtin names the exact contract message); only invent one - // when resolution failed without throwing. - v8::Local ex = - resolveTc.HasCaught() - ? resolveTc.Exception() - : v8::Exception::Error(ArgConverter::ConvertToV8String( - isolate, std::string("Failed to resolve module: ") + spec)); - resolveTc.Reset(); - resolver->Reject(context, ex).Check(); - return scope.Escape(resolver->GetPromise()); - } - } + v8::Local module = maybeModule.ToLocalChecked(); - // If not yet instantiated/evaluated, do it now - if (module->GetStatus() == v8::Module::kUninstantiated) { - if (!module->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ImportModuleDynamicallyCallback: Instantiate failed for '%s'", spec.c_str()); - } - resolver - ->Reject(context, - v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "Failed to instantiate module"))) - .Check(); - return scope.Escape(resolver->GetPromise()); - } + if (module->GetStatus() == v8::Module::kUninstantiated) { + v8::TryCatch ictc(isolate); + if (!module->InstantiateModule(context, &ResolveModuleCallback) + .FromMaybe(false)) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import] instantiate failed %s", + normalizedSpec.c_str()); } - - if (module->GetStatus() != v8::Module::kEvaluated) { - if (module->Evaluate(context).IsEmpty()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ImportModuleDynamicallyCallback: Evaluation failed for '%s'", spec.c_str()); - } - v8::Local ex = - v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "Evaluation failed")); - resolver->Reject(context, ex).Check(); - return scope.Escape(resolver->GetPromise()); - } + std::string msg = + std::string("Failed to instantiate module: ") + normalizedSpec; + if (ictc.HasCaught()) { + std::string exStr = ArgConverter::ToString(isolate, ictc.Exception()); + if (!exStr.empty()) { + msg.append(" - "); + msg.append(exStr); + } } + resolver + ->Reject(context, v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, msg))) + .Check(); + return scope.Escape(resolver->GetPromise()); + } + } - resolver->Resolve(context, module->GetModuleNamespace()).Check(); + if (IsModuleEvaluationInProgress(module->GetStatus())) { + if (QueueModuleWaiterIfInFlight(isolate, adjustedRegistryKey, module, + resolver)) { + return scope.Escape(resolver->GetPromise()); + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[dyn-import] avoiding re-entrant Evaluate for %s status=%s", + adjustedRegistryKey.empty() ? rawSpec.c_str() + : adjustedRegistryKey.c_str(), + ModuleStatusToString(module->GetStatus())); + } + resolver->Resolve(context, module->GetModuleNamespace()).Check(); + return scope.Escape(resolver->GetPromise()); + } + + if (module->GetStatus() != v8::Module::kEvaluated) { + v8::Local evalResult; + if (!module->Evaluate(context).ToLocal(&evalResult)) { if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ImportModuleDynamicallyCallback: Successfully resolved '%s'", spec.c_str()); + DEBUG_WRITE("[dyn-import] evaluation failed %s", + normalizedSpec.c_str()); } - } catch (NativeScriptException& ex) { - ex.ReThrowToV8(); + std::string msg = + std::string("Evaluation failed for module: ") + normalizedSpec; + v8::Local ex = v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, msg)); + resolver->Reject(context, ex).Check(); + return scope.Escape(resolver->GetPromise()); + } + if (!evalResult.IsEmpty() && evalResult->IsPromise()) { + v8::Local p = evalResult.As(); + struct DynEvalData { + v8::Global ctx; + v8::Global mod; + v8::Global res; + }; + auto* d = new DynEvalData{ + v8::Global(isolate, context), + v8::Global(isolate, module), + v8::Global(isolate, resolver)}; + auto onFulfilled = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + v8::Local modLocal = d->mod.Get(iso); + v8::Local res = d->res.Get(iso); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][tla] fulfilled, resolving namespace"); + } + if (!res.IsEmpty()) + res->Resolve(ctx, modLocal->GetModuleNamespace()).FromMaybe(false); + delete d; + }; + auto onRejected = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + v8::Local res = d->res.Get(iso); + v8::Local reason = + (info.Length() > 0) + ? info[0] + : v8::Exception::Error(ArgConverter::ConvertToV8String( + iso, "Evaluation failed (TLA)")); + if (IsScriptLoadingLogEnabled()) { + v8::String::Utf8Value r(iso, reason); + if (*r) { + DEBUG_WRITE("[dyn-import][tla] rejected: %s", *r); + } + } + if (!res.IsEmpty()) res->Reject(ctx, reason).FromMaybe(false); + delete d; + }; + v8::Local fulfillTpl = v8::FunctionTemplate::New( + isolate, onFulfilled, + v8::External::New(isolate, d, v8::kExternalPointerTypeTagDefault)); + v8::Local fulfill = + fulfillTpl->GetFunction(context).ToLocalChecked(); + v8::Local rejectTpl = v8::FunctionTemplate::New( + isolate, onRejected, + v8::External::New(isolate, d, v8::kExternalPointerTypeTagDefault)); + v8::Local reject = + rejectTpl->GetFunction(context).ToLocalChecked(); + p->Then(context, fulfill, reject).ToLocalChecked(); + return scope.Escape(resolver->GetPromise()); + } + } + + // Final verify before resolving for non-HTTP paths. + v8::Local nsFinal = module->GetModuleNamespace(); + if (nsFinal->IsObject()) { + v8::Local o = nsFinal.As(); + v8::TryCatch tc3(isolate); + v8::Local defVal; + if (!o->Get(context, ArgConverter::ConvertToV8String(isolate, "default")) + .ToLocal(&defVal)) { if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ImportModuleDynamicallyCallback: Native exception for '%s'", spec.c_str()); + DEBUG_WRITE( + "[dyn-import][verify] ns.default threw after eval (generic) %s", + normalizedSpec.c_str()); } resolver - ->Reject(context, v8::Exception::Error( - ArgConverter::ConvertToV8String(isolate, "Native error during dynamic import"))) + ->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "TDZ on default after eval (generic)"))) .Check(); + return scope.Escape(resolver->GetPromise()); + } + } + resolver->Resolve(context, module->GetModuleNamespace()).Check(); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import] resolved %s", normalizedSpec.c_str()); } + } catch (NativeScriptException& ex) { + ex.ReThrowToV8(); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import] native failed %s", normalizedSpec.c_str()); + } + resolver + ->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Native error during dynamic import"))) + .Check(); + } + + return scope.Escape(resolver->GetPromise()); +} - return scope.Escape(resolver->GetPromise()); +// ───────────────────────────────────────────────────────────── +// InitializeImportMetaObject — populates `import.meta.url` and +// `import.meta.dirname`. `import.meta.hot` is JS policy and is deliberately +// NOT set here (matches the port spec). +void InitializeImportMetaObject(v8::Local context, + v8::Local module, + v8::Local meta) { + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + + std::string modulePath; + for (auto& kv : g_moduleRegistry) { + v8::Local registered = kv.second.Get(isolate); + if (!registered.IsEmpty() && registered == module) { + modulePath = kv.first; + break; + } + } + if (modulePath.empty()) return; + + std::string moduleUrl; + std::string moduleDirname; + if (StartsWith(modulePath, "http://") || StartsWith(modulePath, "https://")) { + moduleUrl = modulePath; + size_t slash = modulePath.find_last_of('/'); + moduleDirname = slash == std::string::npos ? modulePath + : modulePath.substr(0, slash); + } else if (StartsWith(modulePath, "blob:")) { + moduleUrl = modulePath; + moduleDirname = modulePath; + } else { + moduleUrl = StartsWith(modulePath, "file://") ? modulePath + : ("file://" + modulePath); + std::string filesystemPath = FileURLToPath(moduleUrl); + size_t slash = filesystemPath.find_last_of("/\\"); + moduleDirname = slash == std::string::npos ? filesystemPath + : filesystemPath.substr(0, slash); + } + + meta->CreateDataProperty( + context, ArgConverter::ConvertToV8String(isolate, "url"), + ArgConverter::ConvertToV8String(isolate, moduleUrl)) + .FromMaybe(false); + meta->CreateDataProperty( + context, ArgConverter::ConvertToV8String(isolate, "dirname"), + ArgConverter::ConvertToV8String(isolate, moduleDirname)) + .FromMaybe(false); } + +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h index 908c30ba7..6e447f9bc 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h @@ -1,29 +1,130 @@ -#ifndef MODULE_INTERNAL_CALLBACKS_H -#define MODULE_INTERNAL_CALLBACKS_H +// ModuleInternalCallbacks.h +#pragma once +#include -#include "v8.h" +#include +#include +#include -// Module resolution callback for ES modules -v8::MaybeLocal ResolveModuleCallback(v8::Local context, - v8::Local specifier, - v8::Local import_assertions, - v8::Local referrer); +#include "robin_hood.h" -// InitializeImportMetaObject - Callback invoked by V8 to initialize import.meta object -void InitializeImportMetaObject(v8::Local context, - v8::Local module, - v8::Local meta); +namespace tns { + +// Canonical module key → compiled-module handle map used by the per-isolate +// registries below. +using ModuleHandleMap = + robin_hood::unordered_map>; + +// Per-isolate module registry accessor: map canonical keys → compiled +// v8::Module handles for `isolate`. Keyed by v8::Isolate* (not thread) because +// v8::Global handles are isolate-bound; see the long-form comment +// above the definition in ModuleInternalCallbacks.cpp for the +// cross-isolate-handle bug this prevents. Callers bind a local alias, e.g. +// `auto& g_moduleRegistry = tns::ModuleRegistryFor(isolate);`. +ModuleHandleMap& ModuleRegistryFor(v8::Isolate* isolate); + +// Reset + drop every module handle owned by `isolate`. Must be called while +// the isolate is still alive (the Runtime destructor should call this before +// disposal). +void DestroyModuleStateForIsolate(v8::Isolate* isolate); + +// Utility to drop modules from the registry when compilation/instantiation +// fails. Operates on the *current* isolate's maps (resolved internally); only +// ever called on the isolate's own JS thread during module resolution/loading. +void RemoveModuleFromRegistry(const std::string& canonicalPath); + +// Authoritative HTTP URL loader for dev-served ESM. This compiles and +// registers the module under its canonical URL key without evaluating it. +v8::MaybeLocal LoadHttpModuleForUrl( + v8::Isolate* isolate, v8::Local context, + const std::string& requestedUrl); + +// ── Async HTTP module-graph pipeline ───────────── +// +// Standard three-phase module-map pipeline (the Node/Blink shape) under V8's +// synchronous ResolveModuleCallback: the sync constraint applies to +// *resolution*, not *fetching*. Starting from `rootUrl`, the walk fetches +// bodies concurrently off-thread (FetchModuleBodyAsync), compiles each on the +// isolate's JS thread (ScriptCompiler::CompileModule parses without +// resolving), resolves every static module request with the same import-map + +// relative-URL logic ResolveModuleCallback uses, and recurses until the +// transitive closure is compiled + registered. By InstantiateModule time the +// resolver is a pure registry lookup for the walked graph; anything the walk +// missed falls back to the legacy synchronous fetch inside the resolver. +// +// `onComplete(ok, errorMessage, context)` runs exactly once on the isolate's +// JS thread with the isolate entered and `context` (the context captured at +// start) already scoped. `ok` is false only when the ROOT fetch/compile +// failed — dependency failures are logged and left to surface through the +// resolver during instantiation, so the walk itself introduces no new +// failure modes. +void StartAsyncHttpModuleGraphLoad( + v8::Isolate* isolate, v8::Local context, + const std::string& rootUrl, + std::function context)> + onComplete); + +// Synchronous wrapper for callers that need the graph ready before +// continuing (static HTTP entry loads): starts the walk, then pumps the +// current thread's Android Looper until it settles or `timeoutSeconds` +// elapses. Returns true when the walk completed (regardless of root success +// — the caller's own load path reports root failures). This is the "manual +// run loop until settled" boot handoff. +bool RunAsyncHttpModuleGraphLoadPumped(v8::Isolate* isolate, + v8::Local context, + const std::string& rootUrl, + double timeoutSeconds); + +// True while any async graph load (any isolate) has fetches or compiles +// outstanding. +bool HasPendingAsyncModuleGraphWork(); -// Dynamic import() host callback +// Keep a fallback copy of the last evaluated module so it could be served +// while reloading if needed. +void UpdateModuleFallback(v8::Isolate* isolate, + const std::string& canonicalPath, + v8::Local module); + +// Drop exact URL-keyed modules from the registry and clear any in-flight +// invalidation bookkeeping tied to those canonical keys. +void InvalidateModules(v8::Isolate* isolate, v8::Local context, + const std::vector& urls); + +// Diagnostics helper: returns URL-like keys currently loaded in the module +// registry. +std::vector GetLoadedModuleUrls(); + +// Resolve callback signature (with import‑assertions slot) +v8::MaybeLocal ResolveModuleCallback( + v8::Local context, v8::Local specifier, + v8::Local import_assertions, + v8::Local referrer); + +// Host callback for dynamic import() expressions v8::MaybeLocal ImportModuleDynamicallyCallback( v8::Local context, v8::Local host_defined_options, v8::Local resource_name, v8::Local specifier, v8::Local import_assertions); -// Helper functions -bool IsFile(const std::string& path); -std::string WithExtension(const std::string& path, const std::string& ext); -bool IsNodeBuiltinModule(const std::string& spec); -std::string GetApplicationPath(); +// Host callback for import.meta initialization — Android-specific. Populates +// `import.meta.url` and `import.meta.dirname`. Kept here (not on iOS) because +// Runtime.cpp installs it via SetHostInitializeImportMetaObjectCallback. No +// `import.meta.hot` — that surface is JS policy, not native. +void InitializeImportMetaObject(v8::Local context, + v8::Local module, + v8::Local meta); + +// Import map support. +// Parse and store an import map from JSON. Expected shape: +// {"imports": {"key": "value", ...}} +void SetImportMap(const std::string& json); + +// Set URL patterns that should bypass module cache (e.g. "/@ns/sfc/", "?v="). +void SetVolatilePatterns(const std::vector& patterns); + +// Clear import map state and vendor module cache. Must be called before +// isolate disposal. +void CleanupImportMapGlobals(); -#endif // MODULE_INTERNAL_CALLBACKS_H +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp index e1b43ddce..3f8285556 100644 --- a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp +++ b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp @@ -7,6 +7,8 @@ #include "ArgConverter.h" #include "BuiltinLoader.h" +#include "HttpLoader.h" +#include "Runtime.h" #include "console/Console.h" #include "robin_hood.h" @@ -31,10 +33,90 @@ struct Registration { * never carries compatibility code. */ constexpr Registration kRegistry[] = { + {"ns:module", BuiltinId::kNsModule}, + {"ns:runtime", BuiltinId::kNsRuntime}, {"ns:util", BuiltinId::kNsUtil}, {"node:util", BuiltinId::kNodeUtil}, }; +constexpr const char* kLogScriptLoadingKey = "logScriptLoading"; +constexpr const char* kHttpFetchUrlLogKey = "httpFetchUrlLog"; + +void ThrowTypeError(Isolate* isolate, const std::string& message) { + isolate->ThrowException(Exception::TypeError(ArgConverter::ConvertToV8String(isolate, message))); +} + +bool EnsureMainIsolateWrite(Isolate* isolate, const std::string& key) { + Runtime* runtime = Runtime::GetRuntime(isolate); + if (runtime == nullptr || !runtime->IsMainThread()) { + ThrowTypeError(isolate, "'" + key + + "' is process-wide and can only be set from the main " + "isolate"); + return false; + } + return true; +} + +bool ParseBooleanValue(Isolate* isolate, const FunctionCallbackInfo& info, + const std::string& key, bool* out) { + if (!info[1]->IsBoolean()) { + ThrowTypeError(isolate, "'" + key + "' must be a boolean"); + return false; + } + *out = info[1].As()->Value(); + return true; +} + +void SetConfigCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 2 || !info[0]->IsString()) { + ThrowTypeError(isolate, "setConfig expects (key: string, value)"); + return; + } + std::string key = ArgConverter::ConvertToString(info[0].As()); + if (key == kLogScriptLoadingKey) { + if (!EnsureMainIsolateWrite(isolate, key)) { + return; + } + bool value = false; + if (!ParseBooleanValue(isolate, info, key, &value)) { + return; + } + tns::SetScriptLoadingLogEnabled(value); + return; + } + if (key == kHttpFetchUrlLogKey) { + if (!EnsureMainIsolateWrite(isolate, key)) { + return; + } + bool value = false; + if (!ParseBooleanValue(isolate, info, key, &value)) { + return; + } + tns::SetHttpFetchUrlLogEnabled(value); + return; + } + ThrowTypeError(isolate, "Unknown runtime config key: '" + key + "'"); +} + +void GetConfigCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 1 || !info[0]->IsString()) { + ThrowTypeError(isolate, "getConfig expects (key: string)"); + return; + } + std::string key = ArgConverter::ConvertToString(info[0].As()); + if (key == kLogScriptLoadingKey) { + info.GetReturnValue().Set(v8::Boolean::New(isolate, tns::IsScriptLoadingLogEnabled())); + return; + } + if (key == kHttpFetchUrlLogKey) { + info.GetReturnValue().Set(v8::Boolean::New(isolate, tns::IsHttpFetchUrlLogEnabled())); + return; + } + ThrowTypeError(isolate, "Unknown runtime config key: '" + key + "'"); +} + const Registration* Find(const std::string& specifier) { for (const Registration& registration : kRegistry) { if (specifier == registration.specifier) { @@ -93,6 +175,26 @@ MaybeLocal BuildBinding(Local context, BuiltinId builtin) { Local binding = Object::New(isolate); switch (builtin) { + case BuiltinId::kNsModule: { + if (!BuildNsModuleBinding(context, binding)) { + return MaybeLocal(); + } + break; + } + case BuiltinId::kNsRuntime: { + Local setConfig, getConfig; + if (!v8::Function::New(context, SetConfigCallback).ToLocal(&setConfig) || + !v8::Function::New(context, GetConfigCallback).ToLocal(&getConfig) || + !binding->Set(context, ArgConverter::ConvertToV8String(isolate, "setConfig"), + setConfig) + .FromMaybe(false) || + !binding->Set(context, ArgConverter::ConvertToV8String(isolate, "getConfig"), + getConfig) + .FromMaybe(false)) { + return MaybeLocal(); + } + break; + } case BuiltinId::kNsUtil: { // The console formatter is built once per realm; ns:util // re-exports that instance instead of creating a second one. diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index a2b8c530d..f883e46db 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -19,6 +19,7 @@ #include "Events.h" #include "File.h" #include "FrameCallbacks.h" +#include "HttpLoader.h" #include "Interop.h" #include "IsolateDisposer.h" #include "JType.h" @@ -338,17 +339,35 @@ void Runtime::Unlock() { #endif } +static void PumpPendingHttpModuleGraph(v8::Isolate* isolate) { + if (!tns::HasPendingAsyncModuleGraphWork()) { + return; + } + const auto start = std::chrono::steady_clock::now(); + while (tns::HasPendingAsyncModuleGraphWork()) { + isolate->PerformMicrotaskCheckpoint(); + ALooper_pollOnce(10, nullptr, nullptr, nullptr); + isolate->PerformMicrotaskCheckpoint(); + if (std::chrono::duration(std::chrono::steady_clock::now() - start).count() > 60.0) { + DEBUG_WRITE("PumpPendingHttpModuleGraph: deadline expired with pending async module work"); + break; + } + } +} + void Runtime::RunModule(JNIEnv* _env, jobject obj, jstring scriptFile) { JEnv env(_env); string filePath = ArgConverter::jstringToString(scriptFile); auto context = this->GetContext(); m_module.Load(context, filePath); + PumpPendingHttpModuleGraph(m_isolate); } void Runtime::RunModule(const char* moduleName) { auto context = this->GetContext(); m_module.Load(context, moduleName); + PumpPendingHttpModuleGraph(m_isolate); } void Runtime::RunWorker(const std::string& filePath) { @@ -966,6 +985,16 @@ void Runtime::DestroyRuntime() { m_dispatchUnhandledRejectionFunc.Reset(); m_dispatchRejectionHandledFunc.Reset(); m_dispatchNativeUncaughtErrorFunc.Reset(); + // Drop this isolate's module registry (compiled modules, fallbacks, + // in-flight async graph loads) while the isolate is still alive. + tns::DestroyModuleStateForIsolate(m_isolate); + // Process-wide HTTP-loader / import-map state is shared across isolates; + // only the main isolate may clear it (worker teardown must not wipe the + // main isolate's session). + if (m_isMainThread) { + tns::CleanupHttpLoaderGlobals(); + tns::CleanupImportMapGlobals(); + } tns::disposeIsolate(m_isolate); } diff --git a/test-app/runtime/src/main/cpp/Runtime.h b/test-app/runtime/src/main/cpp/Runtime.h index b1a5b970d..f73dfc386 100644 --- a/test-app/runtime/src/main/cpp/Runtime.h +++ b/test-app/runtime/src/main/cpp/Runtime.h @@ -62,6 +62,10 @@ class Runtime { v8::Isolate* GetIsolate() const; + bool IsMainThread() const { + return m_isMainThread; + } + jobject GetJavaRuntime() const; ObjectManager* GetObjectManager() const; diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp index 2412d77ff..e49067c84 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp @@ -42,6 +42,8 @@ WorkerWrapper::WorkerWrapper(Isolate* parentIsolate, int workerId, std::string w isClosing_(false), isTerminating_(false), isDisposed_(false), + drainRetryPending_(false), + drainRetryAttempts_(0), javaLooperRef_(nullptr) {} void WorkerWrapper::Start() { @@ -143,11 +145,6 @@ void WorkerWrapper::DrainPendingTasks() { return; } - auto messages = queue_.PopAll(); - if (messages.empty()) { - return; - } - v8::Locker locker(isolate); Isolate::Scope isolate_scope(isolate); HandleScope handle_scope(isolate); @@ -155,6 +152,46 @@ void WorkerWrapper::DrainPendingTasks() { Context::Scope context_scope(context); auto globalObject = context->Global(); + // WHATWG parity: buffer inbound messages until the entry script has + // installed `onmessage`. Async ESM entries (HTTP dev sessions, TLA) + // finish evaluating after the wrapper starts draining; silently dropping + // messages with no handler would leave the sender waiting forever. + if (!isTerminating_ && !isClosing_ && !queue_.IsEmpty()) { + Local onMessageValue; + bool gotHandler = + globalObject->Get(context, ArgConverter::ConvertToV8String(isolate, "onmessage")) + .ToLocal(&onMessageValue); + if (!gotHandler || !onMessageValue->IsFunction()) { + bool expected = false; + if (drainRetryAttempts_ < kMaxDrainRetryAttempts && + drainRetryPending_.compare_exchange_strong(expected, true)) { + ++drainRetryAttempts_; + const int workerId = workerId_; + std::thread([workerId]() { + usleep(50 * 1000); + auto wrapper = WorkerWrapper::GetById(workerId); + if (wrapper != nullptr) { + wrapper->drainRetryPending_ = false; + wrapper->SignalMessageDrain(); + } + }).detach(); + return; + } + if (drainRetryAttempts_ < kMaxDrainRetryAttempts) { + return; + } + // Retry budget exhausted: fall through so the per-message loop + // logs the missing handler and drops the messages. + } else { + drainRetryAttempts_ = 0; + } + } + + auto messages = queue_.PopAll(); + if (messages.empty()) { + return; + } + for (auto& message : messages) { if (isTerminating_ || isClosing_) { break; @@ -186,6 +223,10 @@ void WorkerWrapper::DrainPendingTasks() { } } +void WorkerWrapper::SignalMessageDrain() { + queue_.Signal(); +} + void WorkerWrapper::FireMessageOnParentWorkerObject(int workerId, std::shared_ptr message) { auto wrapper = WorkerWrapper::GetById(workerId); diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.h b/test-app/runtime/src/main/cpp/WorkerWrapper.h index 464145d2f..86a05e84b 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.h +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.h @@ -142,6 +142,7 @@ class WorkerWrapper : public std::enable_shared_from_this { private: void BackgroundLooper(std::shared_ptr self); void DrainPendingTasks(); + void SignalMessageDrain(); void QuitLooper(); static int DrainCallback(int fd, int events, void* data); static void FireMessageOnParentWorkerObject(int workerId, @@ -169,6 +170,9 @@ class WorkerWrapper : public std::enable_shared_from_this { std::atomic_bool isClosing_; std::atomic_bool isTerminating_; std::atomic_bool isDisposed_; + std::atomic_bool drainRetryPending_; + int drainRetryAttempts_ = 0; + static constexpr int kMaxDrainRetryAttempts = 40; ConcurrentQueue queue_; diff --git a/test-app/runtime/src/main/cpp/js/README.md b/test-app/runtime/src/main/cpp/js/README.md index e0599e6fb..e874300e2 100644 --- a/test-app/runtime/src/main/cpp/js/README.md +++ b/test-app/runtime/src/main/cpp/js/README.md @@ -46,6 +46,8 @@ module.exports = somethingTheCallSiteNeeds; `node:util` shim: one source file per specifier, the shim owning every bit of Node compatibility. See `docs/ns-builtin-modules.md` for the cross-runtime contract. +- `ns-module.js` is the `ns:module` loader-control surface and `ns-runtime.js` + is the `ns:runtime` live config surface (`setConfig`/`getConfig`). - Destructure `binding` and `primordials` once, at the top of the file, so the file's dependencies are visible and greppable. diff --git a/test-app/runtime/src/main/cpp/js/ns-module.js b/test-app/runtime/src/main/cpp/js/ns-module.js new file mode 100644 index 000000000..9e3b6ce7a --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/ns-module.js @@ -0,0 +1,25 @@ +"use strict"; + +// The `ns:module` builtin: the dev-loader control surface the runtime +// exposes to development tooling (docs/ns-builtin-modules.md). Every member +// is a native function handed in through `binding`; this file only shapes +// and freezes the exports. +// +// Membership varies by build: +// - `canonicalizeHttpUrlKey` exists only in debug builds (test diagnostic). +// Missing members are simply absent — never present-but-throwing — so +// feature checks work. + +const { ObjectFreeze } = primordials; + +const surface = { + configureLoader: binding.configureLoader, + invalidateModules: binding.invalidateModules, + getLoadedModuleUrls: binding.getLoadedModuleUrls, + setDevBootComplete: binding.setDevBootComplete, +}; +if (binding.canonicalizeHttpUrlKey !== undefined) { + surface.canonicalizeHttpUrlKey = binding.canonicalizeHttpUrlKey; +} + +module.exports = ObjectFreeze(surface); diff --git a/test-app/runtime/src/main/cpp/js/ns-runtime.js b/test-app/runtime/src/main/cpp/js/ns-runtime.js new file mode 100644 index 000000000..fc026722e --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/ns-runtime.js @@ -0,0 +1,14 @@ +"use strict"; + +// The `ns:runtime` builtin module: runtime-level configuration and (future) +// runtime introspection. See docs/ns-builtin-modules.md for the contract and +// the key registry — keys, their value domains, and their scope (process-wide +// vs per-isolate) are defined and validated on the native side, so this file +// stays a thin, frozen surface. + +const { setConfig, getConfig } = binding; +const { ObjectFreeze } = primordials; + +exports.setConfig = setConfig; +exports.getConfig = getConfig; +ObjectFreeze(exports); diff --git a/test-app/runtime/src/main/java/com/tns/AppConfig.java b/test-app/runtime/src/main/java/com/tns/AppConfig.java index d1379a440..ff0df0048 100644 --- a/test-app/runtime/src/main/java/com/tns/AppConfig.java +++ b/test-app/runtime/src/main/java/com/tns/AppConfig.java @@ -26,7 +26,8 @@ protected enum KnownKeys { EnableMultithreadedJavascript("enableMultithreadedJavascript", false), LogScriptLoading("logScriptLoading", false), // Appended last: native code reads this array by ordinal. - UncaughtErrorPolicy("uncaughtErrorPolicy", "report"); + UncaughtErrorPolicy("uncaughtErrorPolicy", "report"), + HttpFetchUrlLog("httpFetchUrlLog", false); private final String name; private final Object defaultValue; @@ -88,6 +89,9 @@ public AppConfig(File appDir) { if (rootObject.has(KnownKeys.LogScriptLoading.getName())) { values[KnownKeys.LogScriptLoading.ordinal()] = rootObject.getBoolean(KnownKeys.LogScriptLoading.getName()); } + if (rootObject.has(KnownKeys.HttpFetchUrlLog.getName())) { + values[KnownKeys.HttpFetchUrlLog.ordinal()] = rootObject.getBoolean(KnownKeys.HttpFetchUrlLog.getName()); + } if (rootObject.has(KnownKeys.DiscardUncaughtJsExceptions.getName())) { boolean discard = rootObject.getBoolean(KnownKeys.DiscardUncaughtJsExceptions.getName()); if (discard) { @@ -226,8 +230,13 @@ public boolean getEnableMultithreadedJavascript() { } public boolean getLogScriptLoading() { - Object v = values[KnownKeys.LogScriptLoading.ordinal()]; - return (v instanceof Boolean) ? ((Boolean)v).booleanValue() : false; + Object v = values[KnownKeys.LogScriptLoading.ordinal()]; + return (v instanceof Boolean) ? ((Boolean)v).booleanValue() : false; + } + + public boolean getHttpFetchUrlLog() { + Object v = values[KnownKeys.HttpFetchUrlLog.ordinal()]; + return (v instanceof Boolean) ? ((Boolean)v).booleanValue() : false; } // Security conf diff --git a/test-app/runtime/src/main/java/com/tns/DexFactory.java b/test-app/runtime/src/main/java/com/tns/DexFactory.java index 56b37462e..345295cab 100644 --- a/test-app/runtime/src/main/java/com/tns/DexFactory.java +++ b/test-app/runtime/src/main/java/com/tns/DexFactory.java @@ -204,7 +204,22 @@ public Class findClass(String className) throws ClassNotFoundException { return existingClass; } - return classLoader.loadClass(canonicalName); + String underscored = canonicalName.replace('$', '_'); + if (!underscored.equals(canonicalName)) { + existingClass = this.injectedDexClasses.get(underscored); + if (existingClass != null) { + return existingClass; + } + } + + try { + return classLoader.loadClass(canonicalName); + } catch (ClassNotFoundException e) { + if (!underscored.equals(canonicalName)) { + return classLoader.loadClass(underscored); + } + throw e; + } } public static String strJoin(String[] array, String separator) { diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index 4a02c22c4..1fcce9083 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -304,6 +304,17 @@ public static boolean getLogScriptLoadingEnabled() { } return false; } + + public static boolean getHttpFetchUrlLogEnabled() { + Runtime runtime = com.tns.Runtime.getCurrentRuntime(); + if (runtime != null && runtime.config != null && runtime.config.appConfig != null) { + return runtime.config.appConfig.getHttpFetchUrlLog(); + } + if (staticConfiguration != null && staticConfiguration.appConfig != null) { + return staticConfiguration.appConfig.getHttpFetchUrlLog(); + } + return false; + } // Security config @@ -349,15 +360,48 @@ public static boolean isRemoteUrlAllowed(String url) { return true; } - // Check if URL matches any allowlist prefix + // Check if URL matches any allowlist prefix at a URL-component boundary + // (exact match, entry ends in '/', or next char is '/', '?', or '#'). + // This refuses lookalike-host and lookalike-port bypasses. for (String prefix : allowlist) { - if (url != null && prefix != null && url.startsWith(prefix)) { + if (url != null && prefix != null && remoteUrlMatchesAllowlistEntry(url, prefix)) { return true; } } return false; } + + private static boolean remoteUrlMatchesAllowlistEntry(String url, String entry) { + if (entry.isEmpty() || url.length() < entry.length()) { + return false; + } + if (!url.startsWith(entry)) { + return false; + } + if (url.length() == entry.length()) { + return true; + } + if (entry.charAt(entry.length() - 1) == '/') { + return true; + } + char next = url.charAt(entry.length()); + return next == '/' || next == '?' || next == '#'; + } + + /** + * Test/JNI helper: boot-time security.allowRemoteModules (debug always true). + */ + public static boolean getSecurityAllowRemoteModules() { + return isRemoteModulesAllowed(); + } + + /** + * Test/JNI helper: boot-time security.remoteModuleAllowlist. + */ + public static String[] getSecurityRemoteModuleAllowlist() { + return getRemoteModuleAllowlist(); + } /** * Returns the remote module allowlist as a String array for JNI. diff --git a/test-app/tools/try_to_find_test_result_file.js b/test-app/tools/try_to_find_test_result_file.js index b9bebb19a..d12cfc7d8 100644 --- a/test-app/tools/try_to_find_test_result_file.js +++ b/test-app/tools/try_to_find_test_result_file.js @@ -131,11 +131,40 @@ async function checkForErrorActivity() { } } +function isCompleteJunitXml(text) { + if (!text || typeof text !== "string") { + return false; + } + const trimmed = text.trim(); + return /]/.test(trimmed) && trimmed.includes(""); +} + async function tryPullResultsFile() { const { error } = await execAndStream(`${adbPrefix} pull ${resultsPath}`); if (!error) { - console.log("Tests results file found!"); + const fs = require("fs"); + try { + const text = fs.readFileSync("android_unit_test_results.xml", "utf8"); + if (isCompleteJunitXml(text)) { + console.log("Tests results file found!"); + process.exit(0); + } + } catch (e) { + // Missing or unreadable; keep polling. + } + } + + // Play Store / userdebug-less images reject `adb root` and cannot pull + // /data/data directly. Debug apps can still read their own files via run-as. + const localPath = "android_unit_test_results.xml"; + const { error: runAsError, stdout } = await execAndStream( + `${adbPrefix} exec-out run-as ${appId} cat android_unit_test_results.xml` + ); + if (!runAsError && isCompleteJunitXml(stdout)) { + const fs = require("fs"); + fs.writeFileSync(localPath, stdout); + console.log("Tests results file found via run-as!"); process.exit(0); } }