From 20aa932244d16b6c4cbbbf585031a7633a61d4ea Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 12 Aug 2026 17:26:40 -0700 Subject: [PATCH 1/8] feat(runtime): ESM resolver hardening and async module-graph loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canonicalize module identity into three registry shapes — http(s) URLs, custom schemes (node:, blob:, optional:), and absolute file paths — and key the module registries by v8::Isolate instead of thread_local storage. import() now rejects missing bare specifiers instead of installing placeholders; optional-module placeholders are built without string interpolation, detection is unified in IsLikelyOptionalModule, and module source preserves embedded NUL bytes. Thenables handed to the loader from JS are adopted properly. Blob URLs (blob:nativescript/) become first-class module identities via URL.createObjectURL and URL.InternalAccessor. The prewarm/prefetch machinery is replaced by an async module-graph loader; boot hands off to a manual runloop that pumps pending module work when the entry script has not reached the main looper yet (e.g. a top-level-await entry still loading its graph). Load surfaces the failure cause to callers, and relative import() against a filesystem referrer keeps the already-absolute path instead of prefixing the application root twice. --- test-app/runtime/CMakeLists.txt | 3 +- test-app/runtime/src/main/cpp/HttpLoader.cpp | 1046 ++++ test-app/runtime/src/main/cpp/HttpLoader.h | 179 + .../runtime/src/main/cpp/MetadataNode.cpp | 60 +- .../runtime/src/main/cpp/ModuleInternal.cpp | 170 +- .../src/main/cpp/ModuleInternalCallbacks.cpp | 4405 +++++++++++++---- .../src/main/cpp/ModuleInternalCallbacks.h | 139 +- test-app/runtime/src/main/cpp/Runtime.cpp | 28 + test-app/runtime/src/main/cpp/Runtime.h | 4 + .../src/main/java/com/tns/DexFactory.java | 2 +- 10 files changed, 5045 insertions(+), 991 deletions(-) create mode 100644 test-app/runtime/src/main/cpp/HttpLoader.cpp create mode 100644 test-app/runtime/src/main/cpp/HttpLoader.h diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index 1346bc6cb..ea7d9b61f 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -207,8 +207,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/HttpLoader.cpp b/test-app/runtime/src/main/cpp/HttpLoader.cpp new file mode 100644 index 000000000..8d26e6f12 --- /dev/null +++ b/test-app/runtime/src/main/cpp/HttpLoader.cpp @@ -0,0 +1,1046 @@ +#include "HttpLoader.h" + +#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" + +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 CanonicalizationConfig g_canonConfig; +static bool g_canonConfigured = false; + +static void SetCanonicalizationConfig(CanonicalizationConfig config) { + g_canonConfig = std::move(config); + g_canonConfigured = true; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE( + "[ns:module configureLoader] canonicalization set (strip=%lu devPrefixes=%lu " + "preserve=%lu)", + (unsigned long)g_canonConfig.stripParams.size(), + (unsigned long)g_canonConfig.devPathPrefixes.size(), + (unsigned long)g_canonConfig.preserveQueryPrefixes.size()); + } +} + +static void ResetCanonicalizationConfig() { + g_canonConfig = CanonicalizationConfig{}; + g_canonConfigured = false; +} + +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); + + { + std::string pathOnly = originAndPath.substr(pathStart); + if (g_canonConfigured) { + for (const auto& p : g_canonConfig.preserveQueryPrefixes) { + if (!p.empty() && pathOnly.find(p) != std::string::npos) { + return noHash; + } + } + bool isDevEndpoint = false; + for (const auto& p : g_canonConfig.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 (g_canonConfigured) { + drop = std::find(g_canonConfig.stripParams.begin(), g_canonConfig.stripParams.end(), + name) != g_canonConfig.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); + while (true) { + jint n = env.CallIntMethod(inStream, readMethod, buffer); + if (n < 0) break; + if (n == 0) continue; + env.CallVoidMethod(baos, baosWrite, buffer, 0, n); + } + + env.CallVoidMethod(inStream, closeIS); + 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 { + 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 jsonObj = + ctx->Global() + ->Get(ctx, ToV8String(isolate, "JSON")) + .ToLocalChecked() + .As(); + v8::Local stringify = + jsonObj->Get(ctx, ToV8String(isolate, "stringify")) + .ToLocalChecked() + .As(); + v8::Local args[] = {importMapVal}; + v8::Local result; + if (stringify->Call(ctx, jsonObj, 1, args).ToLocal(&result) && result->IsString()) { + v8::String::Utf8Value utf8(isolate, result); + 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..3249a5408 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,11 +1842,49 @@ 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); + const string& appRoot = Constants::APP_ROOT_FOLDER_PATH; + if (!appRoot.empty()) { + stripPrefix(normalized, appRoot); + } + + 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; std::replace(fullPathToFile.begin(), fullPathToFile.end(), '/', '_'); std::replace(fullPathToFile.begin(), fullPathToFile.end(), '.', '_'); @@ -1856,10 +1892,18 @@ bool MetadataNode::GetExtendLocation(v8::Isolate* isolate, string& extendLocatio std::replace(fullPathToFile.begin(), fullPathToFile.end(), ' ', '_'); 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..e246db1e1 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,13 +30,59 @@ #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; +} + +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 path; +} + +static std::string PromiseRejectionMessage(Isolate* isolate, Local promise, + const std::string& path) { + std::string errorMessage = "Module evaluation promise rejected: " + path; + 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); + } + } + } + return errorMessage; +} // Helper function to check if a module name looks like an optional external module bool ModuleInternal::IsLikelyOptionalModule(const std::string& moduleName) { @@ -224,6 +271,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 +286,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 +573,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 +666,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 +676,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/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/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index a2b8c530d..e6ad56cd6 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,34 @@ 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) { + 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 +984,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/java/com/tns/DexFactory.java b/test-app/runtime/src/main/java/com/tns/DexFactory.java index 56b37462e..29f302e50 100644 --- a/test-app/runtime/src/main/java/com/tns/DexFactory.java +++ b/test-app/runtime/src/main/java/com/tns/DexFactory.java @@ -194,7 +194,7 @@ && injectDexIntoClassLoader((BaseDexClassLoader) classLoader, jarFilePath)) { } public Class findClass(String className) throws ClassNotFoundException { - String canonicalName = className.replace('/', '.'); + String canonicalName = className.replace('/', '.').replace('$', '_'); if (logger.isEnabled()) { logger.write(canonicalName); } From 9bc156113467c7fb4451226606631160ad7f71aa Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 12 Aug 2026 17:26:44 -0700 Subject: [PATCH 2/8] feat(runtime): HMR dev-sessions and a hardened HTTP session loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev sessions serve the app's module graph over HTTP during development, with a mechanism-only dev-loader contract: policy stays in JS tooling, the runtime supplies fetch/registry/invalidations. The loader is deny-by-default — remote allowlist entries only authorize URLs on a URL-component boundary ('/', '?', '#' or exact match), refusing lookalike-host and lookalike-port bypasses; a specific port must be listed explicitly. Hot-path hash containers use robin_hood maps. Per-fetch URL logging is opt-in via the httpFetchUrlLog config flag (volume is one line per fetch), alongside the existing logScriptLoading-gated diagnostics. The previous HMRSupport/DevFlags sources are replaced by HttpLoader (JNI HttpURLConnection). --- test-app/runtime/src/main/cpp/DevFlags.cpp | 141 ------- test-app/runtime/src/main/cpp/DevFlags.h | 24 -- test-app/runtime/src/main/cpp/HMRSupport.cpp | 353 ------------------ test-app/runtime/src/main/cpp/HMRSupport.h | 25 -- .../src/main/java/com/tns/AppConfig.java | 15 +- .../src/main/java/com/tns/Runtime.java | 48 ++- 6 files changed, 58 insertions(+), 548 deletions(-) delete mode 100644 test-app/runtime/src/main/cpp/DevFlags.cpp delete mode 100644 test-app/runtime/src/main/cpp/DevFlags.h delete mode 100644 test-app/runtime/src/main/cpp/HMRSupport.cpp delete mode 100644 test-app/runtime/src/main/cpp/HMRSupport.h 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/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/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. From 97ff0143367aaef7896e571840c94255e3ce6794 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 12 Aug 2026 17:27:11 -0700 Subject: [PATCH 3/8] feat(runtime): expose the dev-loader surface as the ns:module builtin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev-loader control surface (HttpLoader) is reachable from JS as the ns:module builtin module: NsBuiltinModules routes ns:module through BuildNsModuleBinding — the binding builder decides build-dependent membership — and ns-module.js (compiled in via js2c) shapes and freezes whatever arrives. docs/ns-builtin-modules.md documents the surface. --- docs/ns-builtin-modules.md | 22 ++++++++++++++++ test-app/runtime/CMakeLists.txt | 1 + .../runtime/src/main/cpp/NsBuiltinModules.cpp | 8 ++++++ test-app/runtime/src/main/cpp/js/README.md | 1 + test-app/runtime/src/main/cpp/js/ns-module.js | 25 +++++++++++++++++++ 5 files changed, 57 insertions(+) create mode 100644 test-app/runtime/src/main/cpp/js/ns-module.js diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md index 589b42acc..bbecf6a72 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -52,6 +52,28 @@ Rules: versions for readability; it is intended for humans and must not be parsed programmatically. +### `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. + ## `node:` compatibility shims The same registry serves the `node:` scheme with **compatibility shims** so diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index ea7d9b61f..7dd531e61 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -71,6 +71,7 @@ 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-util.js ${RUNTIME_BUILTIN_JS_DIR}/performance.js ${RUNTIME_BUILTIN_JS_DIR}/primordials.js diff --git a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp index e1b43ddce..d6c23bfc3 100644 --- a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp +++ b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp @@ -7,6 +7,7 @@ #include "ArgConverter.h" #include "BuiltinLoader.h" +#include "HttpLoader.h" #include "console/Console.h" #include "robin_hood.h" @@ -31,6 +32,7 @@ struct Registration { * never carries compatibility code. */ constexpr Registration kRegistry[] = { + {"ns:module", BuiltinId::kNsModule}, {"ns:util", BuiltinId::kNsUtil}, {"node:util", BuiltinId::kNodeUtil}, }; @@ -93,6 +95,12 @@ 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::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/js/README.md b/test-app/runtime/src/main/cpp/js/README.md index e0599e6fb..bb317aef3 100644 --- a/test-app/runtime/src/main/cpp/js/README.md +++ b/test-app/runtime/src/main/cpp/js/README.md @@ -46,6 +46,7 @@ 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. - 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); From 2f5bd386abf5348154e38941651cd744c0e33306 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 12 Aug 2026 17:27:15 -0700 Subject: [PATCH 4/8] fix(worker): surface entry-script load errors and buffer early messages Worker entry-script load failures now reach worker.onerror instead of failing silently. Messages posted before the worker's entry script has installed onmessage are no longer dropped: ConcurrentQueue::Signal re-arms the drain source without enqueueing (a silent no-op when racing Terminate), and WorkerWrapper retries delivery through a deferred drain, with drainRetryPending_ preventing one stacked retry per attempt. --- .../runtime/src/main/cpp/ConcurrentQueue.cpp | 14 +++++++ .../runtime/src/main/cpp/ConcurrentQueue.h | 2 + .../runtime/src/main/cpp/WorkerWrapper.cpp | 41 ++++++++++++++++--- test-app/runtime/src/main/cpp/WorkerWrapper.h | 2 + 4 files changed, 54 insertions(+), 5 deletions(-) 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/WorkerWrapper.cpp b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp index 2412d77ff..2c593b9b4 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp @@ -42,6 +42,7 @@ WorkerWrapper::WorkerWrapper(Isolate* parentIsolate, int workerId, std::string w isClosing_(false), isTerminating_(false), isDisposed_(false), + drainRetryPending_(false), javaLooperRef_(nullptr) {} void WorkerWrapper::Start() { @@ -143,11 +144,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 +151,37 @@ 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 (drainRetryPending_.compare_exchange_strong(expected, true)) { + 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; + } + } + + auto messages = queue_.PopAll(); + if (messages.empty()) { + return; + } + for (auto& message : messages) { if (isTerminating_ || isClosing_) { break; @@ -186,6 +213,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..d9dc38701 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,7 @@ class WorkerWrapper : public std::enable_shared_from_this { std::atomic_bool isClosing_; std::atomic_bool isTerminating_; std::atomic_bool isDisposed_; + std::atomic_bool drainRetryPending_; ConcurrentQueue queue_; From 3205f4962f9f27e8f21a932b6f7b2f3c3a635461 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 12 Aug 2026 17:27:19 -0700 Subject: [PATCH 5/8] test: cover the ESM loader, remote-module security, and worker behavior The ns:module surface, remote-module allowlist boundary matching, and relative ESM dynamic-import cases exercise the async loader and the deny-by-default HTTP gate. The on-device result harvester falls back to run-as when adb root is unavailable (Play Store emulator images), and -Pabis is forwarded so a single-ABI V8 tree can build and test locally. --- build.gradle | 6 + .../src/main/assets/app/tests/testNsModule.js | 105 ++++++++++++++++++ .../app/tests/testRemoteModuleSecurity.js | 9 ++ test-app/runtests.gradle | 8 +- .../tools/try_to_find_test_result_file.js | 23 +++- 5 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 test-app/app/src/main/assets/app/tests/testNsModule.js 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/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/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/tools/try_to_find_test_result_file.js b/test-app/tools/try_to_find_test_result_file.js index b9bebb19a..763bb32e3 100644 --- a/test-app/tools/try_to_find_test_result_file.js +++ b/test-app/tools/try_to_find_test_result_file.js @@ -135,7 +135,28 @@ 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 (text.trimStart().startsWith(" Date: Wed, 12 Aug 2026 17:27:24 -0700 Subject: [PATCH 6/8] refactor(runtime): drop the require() optional-module placeholder --- test-app/runtime/src/main/cpp/ModuleInternal.cpp | 10 ---------- test-app/runtime/src/main/cpp/ModuleInternal.h | 1 - 2 files changed, 11 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index e246db1e1..bd661b232 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -84,16 +84,6 @@ static std::string PromiseRejectionMessage(Isolate* isolate, Local prom return errorMessage; } -// 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; - } - return false; -} - // Helper function to check if a file path is an ES module (.mjs) but not a source map (.mjs.map) bool ModuleInternal::IsESModule(const std::string& path) { return path.size() >= 4 && path.compare(path.size() - 4, 4, ".mjs") == 0 && 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); From a20f2bc5882375817cec4636e9c72c485c0e3518 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 12 Aug 2026 17:27:28 -0700 Subject: [PATCH 7/8] refactor(runtime): rename HMRSupport to HttpLoader and fold DevFlags into ns:runtime Live log flags (logScriptLoading, httpFetchUrlLog) move onto ns:runtime setConfig/getConfig. Remote-module security stays boot-time nativescript.config only. Android does not expose releasedObjectPolicy. --- docs/ns-builtin-modules.md | 32 ++++++- .../main/assets/app/tests/testNsRuntime.js | 67 +++++++++++++ test-app/runtime/CMakeLists.txt | 1 + .../runtime/src/main/cpp/NsBuiltinModules.cpp | 94 +++++++++++++++++++ test-app/runtime/src/main/cpp/js/README.md | 3 +- .../runtime/src/main/cpp/js/ns-runtime.js | 14 +++ 6 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 test-app/app/src/main/assets/app/tests/testNsRuntime.js create mode 100644 test-app/runtime/src/main/cpp/js/ns-runtime.js diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md index bbecf6a72..b050ad765 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -52,6 +52,32 @@ 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 @@ -72,7 +98,11 @@ 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. +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 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/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index 7dd531e61..d07667ad4 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -72,6 +72,7 @@ set(RUNTIME_BUILTIN_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 diff --git a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp index d6c23bfc3..3f8285556 100644 --- a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp +++ b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp @@ -8,6 +8,7 @@ #include "ArgConverter.h" #include "BuiltinLoader.h" #include "HttpLoader.h" +#include "Runtime.h" #include "console/Console.h" #include "robin_hood.h" @@ -33,10 +34,89 @@ struct Registration { */ 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) { @@ -101,6 +181,20 @@ MaybeLocal BuildBinding(Local context, BuiltinId builtin) { } 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/js/README.md b/test-app/runtime/src/main/cpp/js/README.md index bb317aef3..e874300e2 100644 --- a/test-app/runtime/src/main/cpp/js/README.md +++ b/test-app/runtime/src/main/cpp/js/README.md @@ -46,7 +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. +- `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-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); From 054748c67b06927c70efdcfd34ec50047e0da349 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 13 Aug 2026 11:04:35 -0700 Subject: [PATCH 8/8] fix(runtime): harden HTTP fetch, extend names, and worker drain retries JNI mid-body read exceptions no longer spin the JS thread, async fetch threads detach from the JVM, and canonicalization config is published as an immutable snapshot so configureLoader cannot race a background fetch. --- test-app/runtime/src/main/cpp/HttpLoader.cpp | 92 +++++++++++++------ .../runtime/src/main/cpp/MetadataNode.cpp | 20 +++- .../runtime/src/main/cpp/ModuleInternal.cpp | 4 + test-app/runtime/src/main/cpp/Runtime.cpp | 1 + .../runtime/src/main/cpp/WorkerWrapper.cpp | 14 ++- test-app/runtime/src/main/cpp/WorkerWrapper.h | 2 + .../src/main/java/com/tns/DexFactory.java | 19 +++- .../tools/try_to_find_test_result_file.js | 12 ++- 8 files changed, 126 insertions(+), 38 deletions(-) diff --git a/test-app/runtime/src/main/cpp/HttpLoader.cpp b/test-app/runtime/src/main/cpp/HttpLoader.cpp index 8d26e6f12..b2a0b6976 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.cpp +++ b/test-app/runtime/src/main/cpp/HttpLoader.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +21,7 @@ #include "NativeScriptException.h" #include "Runtime.h" #include "robin_hood.h" +#include "v8-json.h" namespace tns { @@ -232,25 +234,33 @@ struct CanonicalizationConfig { std::vector devPathPrefixes; std::vector preserveQueryPrefixes; }; -static CanonicalizationConfig g_canonConfig; -static bool g_canonConfigured = false; +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) { - g_canonConfig = std::move(config); - g_canonConfigured = true; + 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)g_canonConfig.stripParams.size(), - (unsigned long)g_canonConfig.devPathPrefixes.size(), - (unsigned long)g_canonConfig.preserveQueryPrefixes.size()); + (unsigned long)snapshot->stripParams.size(), + (unsigned long)snapshot->devPathPrefixes.size(), + (unsigned long)snapshot->preserveQueryPrefixes.size()); } } static void ResetCanonicalizationConfig() { - g_canonConfig = CanonicalizationConfig{}; - g_canonConfigured = false; + std::lock_guard lock(g_canonConfigMutex); + g_canonConfig.reset(); } std::string CanonicalizeHttpUrlKey(const std::string& url) { @@ -278,16 +288,17 @@ std::string CanonicalizeHttpUrlKey(const std::string& url) { 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 (g_canonConfigured) { - for (const auto& p : g_canonConfig.preserveQueryPrefixes) { + 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 : g_canonConfig.devPathPrefixes) { + for (const auto& p : canon->devPathPrefixes) { if (!p.empty() && StartsWith(pathOnly, p.c_str())) { isDevEndpoint = true; break; @@ -322,9 +333,9 @@ std::string CanonicalizeHttpUrlKey(const std::string& url) { size_t eq = pair.find('='); std::string name = (eq == std::string::npos) ? pair : pair.substr(0, eq); bool drop; - if (g_canonConfigured) { - drop = std::find(g_canonConfig.stripParams.begin(), g_canonConfig.stripParams.end(), - name) != g_canonConfig.stripParams.end(); + if (canon) { + drop = std::find(canon->stripParams.begin(), canon->stripParams.end(), + name) != canon->stripParams.end(); } else { drop = (name == "import" || name == "t" || name == "v"); } @@ -698,14 +709,29 @@ static bool PerformHttpFetchOnceSync(const std::string& url, std::string& out, 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); @@ -773,6 +799,26 @@ void FetchModuleBodyAsync(const std::string& url, } 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; @@ -870,19 +916,9 @@ void ConfigureLoaderCallback(const v8::FunctionCallbackInfo& info) { v8::String::Utf8Value utf8(isolate, importMapVal); if (*utf8) jsonStr = *utf8; } else if (importMapVal->IsObject()) { - v8::Local jsonObj = - ctx->Global() - ->Get(ctx, ToV8String(isolate, "JSON")) - .ToLocalChecked() - .As(); - v8::Local stringify = - jsonObj->Get(ctx, ToV8String(isolate, "stringify")) - .ToLocalChecked() - .As(); - v8::Local args[] = {importMapVal}; - v8::Local result; - if (stringify->Call(ctx, jsonObj, 1, args).ToLocal(&result) && result->IsString()) { - v8::String::Utf8Value utf8(isolate, result); + v8::Local stringified; + if (v8::JSON::Stringify(ctx, importMapVal).ToLocal(&stringified)) { + v8::String::Utf8Value utf8(isolate, stringified); if (*utf8) jsonStr = *utf8; } } diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index 3249a5408..28177a794 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -1869,6 +1869,11 @@ bool MetadataNode::GetExtendLocation(v8::Isolate* isolate, string& extendLocatio } } + size_t queryOrFragment = normalized.find_first_of("?#"); + if (queryOrFragment != string::npos) { + normalized.resize(queryOrFragment); + } + const string& appRoot = Constants::APP_ROOT_FOLDER_PATH; if (!appRoot.empty()) { stripPrefix(normalized, appRoot); @@ -1886,10 +1891,17 @@ bool MetadataNode::GetExtendLocation(v8::Isolate* isolate, string& extendLocatio fullPathToFile = normalized; - std::replace(fullPathToFile.begin(), fullPathToFile.end(), '/', '_'); - std::replace(fullPathToFile.begin(), fullPathToFile.end(), '.', '_'); - std::replace(fullPathToFile.begin(), fullPathToFile.end(), '-', '_'); - std::replace(fullPathToFile.begin(), fullPathToFile.end(), ' ', '_'); + 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); diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index bd661b232..a071e9abb 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -53,6 +53,7 @@ static std::string NormalizeHttpModuleUrl(const std::string& 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; @@ -81,6 +82,9 @@ static std::string PromiseRejectionMessage(Isolate* isolate, Local prom } } } + if (tc.HasCaught()) { + tc.Reset(); + } return errorMessage; } diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index e6ad56cd6..f883e46db 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -349,6 +349,7 @@ static void PumpPendingHttpModuleGraph(v8::Isolate* isolate) { 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; } } diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp index 2c593b9b4..e49067c84 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp @@ -43,6 +43,7 @@ WorkerWrapper::WorkerWrapper(Isolate* parentIsolate, int workerId, std::string w isTerminating_(false), isDisposed_(false), drainRetryPending_(false), + drainRetryAttempts_(0), javaLooperRef_(nullptr) {} void WorkerWrapper::Start() { @@ -162,7 +163,9 @@ void WorkerWrapper::DrainPendingTasks() { .ToLocal(&onMessageValue); if (!gotHandler || !onMessageValue->IsFunction()) { bool expected = false; - if (drainRetryPending_.compare_exchange_strong(expected, true)) { + if (drainRetryAttempts_ < kMaxDrainRetryAttempts && + drainRetryPending_.compare_exchange_strong(expected, true)) { + ++drainRetryAttempts_; const int workerId = workerId_; std::thread([workerId]() { usleep(50 * 1000); @@ -172,8 +175,15 @@ void WorkerWrapper::DrainPendingTasks() { wrapper->SignalMessageDrain(); } }).detach(); + return; } - 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; } } diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.h b/test-app/runtime/src/main/cpp/WorkerWrapper.h index d9dc38701..86a05e84b 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.h +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.h @@ -171,6 +171,8 @@ class WorkerWrapper : public std::enable_shared_from_this { 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/java/com/tns/DexFactory.java b/test-app/runtime/src/main/java/com/tns/DexFactory.java index 29f302e50..345295cab 100644 --- a/test-app/runtime/src/main/java/com/tns/DexFactory.java +++ b/test-app/runtime/src/main/java/com/tns/DexFactory.java @@ -194,7 +194,7 @@ && injectDexIntoClassLoader((BaseDexClassLoader) classLoader, jarFilePath)) { } public Class findClass(String className) throws ClassNotFoundException { - String canonicalName = className.replace('/', '.').replace('$', '_'); + String canonicalName = className.replace('/', '.'); if (logger.isEnabled()) { logger.write(canonicalName); } @@ -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/tools/try_to_find_test_result_file.js b/test-app/tools/try_to_find_test_result_file.js index 763bb32e3..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,6 +131,14 @@ 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}`); @@ -138,7 +146,7 @@ async function tryPullResultsFile() { const fs = require("fs"); try { const text = fs.readFileSync("android_unit_test_results.xml", "utf8"); - if (text.trimStart().startsWith("