diff --git a/NativeScript/CMakeLists.txt b/NativeScript/CMakeLists.txt index b8e494301..766b2c936 100644 --- a/NativeScript/CMakeLists.txt +++ b/NativeScript/CMakeLists.txt @@ -342,6 +342,7 @@ if(ENABLE_JS_RUNTIME) runtime/modules/url/ada/ada.cpp runtime/modules/url/URL.cpp runtime/modules/url/URLSearchParams.cpp + runtime/modules/esm/ESModuleSupport.cpp ) if(TARGET_ENGINE_V8) diff --git a/NativeScript/ffi/jni/napi/exceptions/NativeScriptException.cpp b/NativeScript/ffi/jni/napi/exceptions/NativeScriptException.cpp index ec11c1b4e..fe8bfa51c 100644 --- a/NativeScript/ffi/jni/napi/exceptions/NativeScriptException.cpp +++ b/NativeScript/ffi/jni/napi/exceptions/NativeScriptException.cpp @@ -187,7 +187,31 @@ void NativeScriptException::CallJsFuncWithErr(napi_env env, napi_value errObj, b if (napi_util::is_of_type(env, handler, napi_function)) { napi_value result; NAPI_GUARD(napi_call_function(env, global, handler, 1, &errObj, &result)) {} + return; + } + + // Nothing in JS can observe this error yet: it happened before the app + // installed its handler, typically while the entry module was loading, so + // logcat is the only place left to report it. + std::string report; + for (const char* propertyName : {"stack", "message", "stackTrace"}) { + napi_value value = nullptr; + if (napi_get_named_property(env, errObj, propertyName, &value) != napi_ok || + !napi_util::is_of_type(env, value, napi_string)) { + continue; + } + std::string text = ArgConverter::ConvertToString(env, value); + if (text.empty() || report.find(text) != std::string::npos) { + continue; + } + if (!report.empty()) { + report += "\n"; + } + report += text; } + __android_log_print(ANDROID_LOG_ERROR, "JS", + "Uncaught error before a JS error handler was installed:\n%s", + report.c_str()); } napi_value NativeScriptException::WrapJavaToJsException(napi_env env) { diff --git a/NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm b/NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm index 761fe1680..c0946770a 100644 --- a/NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm +++ b/NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm @@ -729,7 +729,7 @@ throw JSError(runtime, newSymbol.name = className; newSymbol.runtimeName = className; newSymbol.superclassOffset = baseSymbol.offset; - return makeNativeClassValue(runtime, bridge, std::move(newSymbol)); + return makeExtendedNativeClassValue(runtime, bridge, std::move(newSymbol)); } Value invokeNativeApiBaseMethod( diff --git a/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm index 5baecf609..a7eeb4128 100644 --- a/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm +++ b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm @@ -2363,6 +2363,9 @@ Value makeNativeObjectValue(Runtime& runtime, Value makeNativeClassValue(Runtime& runtime, const std::shared_ptr& bridge, NativeApiSymbol symbol); +Value makeExtendedNativeClassValue(Runtime& runtime, + const std::shared_ptr& bridge, + NativeApiSymbol symbol); Object symbolToObject(Runtime& runtime, const NativeApiSymbol& symbol) { Object result(runtime); diff --git a/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm b/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm index e74c4032b..18cc277af 100644 --- a/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm +++ b/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm @@ -485,6 +485,23 @@ Value makeNativeClassValue(Runtime& runtime, std::make_shared(bridge, std::move(symbol))); } +// For a class the runtime just registered. Unlike makeNativeClassValue this +// never resolves by name: a global of the same name whose `kind` reads "class" +// may be the JS constructor being extended (it inherits that from its base +// wrapper through extendStatics), and taking it would hand back the base class. +Value makeExtendedNativeClassValue(Runtime& runtime, + const std::shared_ptr& bridge, + NativeApiSymbol symbol) { + Class cls = objc_lookUpClass(symbol.runtimeName.c_str()); + Value cachedClass = bridge->findClassValue(runtime, cls); + if (!cachedClass.isUndefined()) { + return cachedClass; + } + return Object::createFromHostObject( + runtime, + std::make_shared(bridge, std::move(symbol))); +} + Protocol* lookupProtocolByNativeName(const std::string& name) { Protocol* protocol = objc_getProtocol(name.c_str()); if (protocol != nullptr) { diff --git a/NativeScript/napi/hermes/jsr.cpp b/NativeScript/napi/hermes/jsr.cpp index 0da333809..f49726f1b 100644 --- a/NativeScript/napi/hermes/jsr.cpp +++ b/NativeScript/napi/hermes/jsr.cpp @@ -176,7 +176,8 @@ napi_status js_create_napi_env(napi_env* env, jsr_ns_runtime runtime) { // // Apple used to take a different route through a NativeScript-local // jsi::Runtime::createNodeApiEnv hook. That hook no longer exists upstream, - // and both platforms now build against the same headers, so there is one path. + // and both platforms now build against the same headers, so there is one + // path. auto hermesInterface = facebook::jsi::castInterface( runtime->hermes->rt); @@ -310,7 +311,19 @@ napi_status js_execute_pending_jobs(napi_env env) { if (jsr == nullptr) { return napi_invalid_arg; } - jsr->rt->drainMicrotasks(); + // drainMicrotasks() reports a failing job by throwing a C++ jsi::JSError + // (see jsr_drain_microtasks). Callers sit behind JNI and Looper callbacks, + // where an unwinding C++ exception aborts the process, so it is converted + // into a pending exception they already know how to report. + try { + jsr->rt->drainMicrotasks(); + } catch (const facebook::jsi::JSError& e) { + napi_throw_error(env, nullptr, e.getMessage().c_str()); + return napi_pending_exception; + } catch (const facebook::jsi::JSIException& e) { + napi_throw_error(env, nullptr, e.what()); + return napi_pending_exception; + } return napi_ok; #else bool result; diff --git a/NativeScript/runtime/android/napi/Runtime.cpp b/NativeScript/runtime/android/napi/Runtime.cpp index 49fc0455f..fc4d5bf47 100644 --- a/NativeScript/runtime/android/napi/Runtime.cpp +++ b/NativeScript/runtime/android/napi/Runtime.cpp @@ -594,6 +594,42 @@ void Runtime::RunModule(const char *moduleName) { void Runtime::RunWorker(const std::string &filePath) { m_module.LoadWorker(env, filePath); + js_execute_pending_jobs(env); +} + +void Runtime::EnterJsCall() { + m_jsCallDepth++; +} + +void Runtime::LeaveJsCall() { + m_jsCallDepth--; +} + +// Engines with an explicit job queue (Hermes, QuickJS, PrimJS) run promise +// reactions only when asked; V8 and JavaScriptCore do it themselves as soon as +// the JS stack empties. This reproduces that moment: the queue is drained after +// the outermost call from Java into JS and never inside a nested one, so jobs +// cannot interleave with a JS frame that is still on the stack. +void Runtime::RunMicrotaskCheckpoint() { + if (m_jsCallDepth > 1) { + return; + } + + napi_status status = js_execute_pending_jobs(env); + bool pendingException = false; + napi_is_exception_pending(env, &pendingException); + if (status == napi_ok && !pendingException) { + return; + } + + napi_value error = nullptr; + if (pendingException) { + napi_get_and_clear_last_exception(env, &error); + } + if (error != nullptr) { + throw NativeScriptException(env, error, "Error running microtasks"); + } + throw NativeScriptException("Error running microtasks"); } void Runtime::DisposeWorkerRuntime(Runtime *runtime) { diff --git a/NativeScript/runtime/android/napi/Runtime.h b/NativeScript/runtime/android/napi/Runtime.h index e3c78be88..6ee398dfd 100644 --- a/NativeScript/runtime/android/napi/Runtime.h +++ b/NativeScript/runtime/android/napi/Runtime.h @@ -90,6 +90,14 @@ namespace tns { std::string ReadFileText(const std::string &filePath); + // Java -> JS transitions are counted so the microtask checkpoint runs + // only when the outermost one returns; see RunMicrotaskCheckpoint. + void EnterJsCall(); + + void LeaveJsCall(); + + void RunMicrotaskCheckpoint(); + bool NotifyGC(JNIEnv *jEnv, jobject obj, jintArray object_ids); bool TryCallGC(); @@ -194,6 +202,7 @@ namespace tns { ArrayBufferHelper m_arrayBufferHelper; bool m_isMainThread; + int m_jsCallDepth = 0; ModuleInternal m_module; diff --git a/NativeScript/runtime/android/napi/com_tns_Runtime.cpp b/NativeScript/runtime/android/napi/com_tns_Runtime.cpp index 452104588..94b3dc2e2 100644 --- a/NativeScript/runtime/android/napi/com_tns_Runtime.cpp +++ b/NativeScript/runtime/android/napi/com_tns_Runtime.cpp @@ -129,6 +129,22 @@ Runtime* TryGetRuntime(int runtimeId) { return runtime; } +// Brackets a call from Java into JS so the runtime knows when the outermost +// one returns; see Runtime::RunMicrotaskCheckpoint. +class JsCallScope { +public: + explicit JsCallScope(Runtime* runtime) : m_runtime(runtime) { + m_runtime->EnterJsCall(); + } + + ~JsCallScope() { + m_runtime->LeaveJsCall(); + } + +private: + Runtime* m_runtime; +}; + extern "C" JNIEXPORT void Java_com_tns_Runtime_runModule(JNIEnv* _env, jobject obj, jint runtimeId, jstring scriptFile) { auto runtime = TryGetRuntime(runtimeId); if (runtime == nullptr) { @@ -136,9 +152,11 @@ extern "C" JNIEXPORT void Java_com_tns_Runtime_runModule(JNIEnv* _env, jobject o } NapiScope scope(runtime->GetNapiEnv()); + JsCallScope call(runtime); try { runtime->RunModule(_env, obj, scriptFile); + runtime->RunMicrotaskCheckpoint(); } catch (NativeScriptException& e) { e.ReThrowToJava(runtime->GetNapiEnv()); } catch (std::exception e) { @@ -160,8 +178,10 @@ extern "C" JNIEXPORT jobject Java_com_tns_Runtime_runScript(JNIEnv* _env, jobjec napi_env napiEnv = runtime->GetNapiEnv(); NapiScope scope(napiEnv); + JsCallScope call(runtime); try { result = runtime->RunScript(_env, obj, scriptFile); + runtime->RunMicrotaskCheckpoint(); } catch (NativeScriptException& e) { e.ReThrowToJava(napiEnv); } catch (std::exception e) { @@ -183,8 +203,10 @@ extern "C" JNIEXPORT jobject Java_com_tns_Runtime_callJSMethodNative(JNIEnv* _en if (runtime == nullptr) return result; NapiScope scope(runtime->GetNapiEnv()); + JsCallScope call(runtime); try { result = runtime->CallJSMethodNative(_env, obj, javaObjectID, claz, methodName, retType, isConstructor, packagedArgs); + runtime->RunMicrotaskCheckpoint(); } catch (NativeScriptException& e) { e.ReThrowToJava( runtime->GetNapiEnv()); } catch (std::exception e) { @@ -206,9 +228,11 @@ extern "C" JNIEXPORT void Java_com_tns_Runtime_createJSInstanceNative(JNIEnv* _e if (runtime == nullptr) return; NapiScope scope(runtime->GetNapiEnv()); + JsCallScope call(runtime); try { runtime->CreateJSInstanceNative(_env, obj, javaObject, javaObjectID, className); + runtime->RunMicrotaskCheckpoint(); } catch (NativeScriptException& e) { e.ReThrowToJava( runtime->GetNapiEnv()); } catch (std::exception e) { @@ -286,9 +310,11 @@ extern "C" JNIEXPORT void Java_com_tns_Runtime_passExceptionToJsNative(JNIEnv* j if (runtime == nullptr) return; NapiScope scope(runtime->GetNapiEnv()); + JsCallScope call(runtime); try { runtime->PassExceptionToJsNative(jEnv, obj, exception, message, fullStackTrace, jsStackTrace, isDiscarded, isPendingError); + runtime->RunMicrotaskCheckpoint(); } catch (NativeScriptException& e) { e.ReThrowToJava(runtime->GetNapiEnv()); } catch (std::exception e) { diff --git a/NativeScript/runtime/android/napi/modules/module/ModuleInternal.cpp b/NativeScript/runtime/android/napi/modules/module/ModuleInternal.cpp index 44549a386..6b72372ae 100644 --- a/NativeScript/runtime/android/napi/modules/module/ModuleInternal.cpp +++ b/NativeScript/runtime/android/napi/modules/module/ModuleInternal.cpp @@ -15,6 +15,7 @@ #include #include #include "GlobalHelpers.h" +#include "ESModuleSupport.h" #include @@ -34,6 +35,10 @@ void ThrowFallbackRequireError(napi_env env, const char* message) { napi_throw_error(env, nullptr, message); } +bool IsJavaScriptModulePath(const std::string& path) { + return Util::EndsWith(path, ".js") || Util::EndsWith(path, ".mjs") || Util::EndsWith(path, ".cjs"); +} + void ReThrowRequireError(napi_env env, NativeScriptException& exception) { try { exception.ReThrowToNapi(env); @@ -353,7 +358,7 @@ napi_value ModuleInternal::LoadImpl(napi_env env, const std::string& moduleName, auto it2 = m_loadedModules.find(path); if (it2 == m_loadedModules.end()) { - if (Util::EndsWith(path, ".js") || Util::EndsWith(path, ".so")) { + if (IsJavaScriptModulePath(path) || Util::EndsWith(path, ".so")) { isData = false; result = LoadModule(env, path, cachePathKey); } else if (Util::EndsWith(path, ".json")) { @@ -424,18 +429,26 @@ napi_value ModuleInternal::LoadModule(napi_env env, const std::string& modulePat napi_value moduleFunc; - if (Util::EndsWith(modulePath, ".js")) { + if (IsJavaScriptModulePath(modulePath)) { DEBUG_WRITE("%s", modulePath.c_str()); - // Fast path: if the build compiled this module to engine bytecode, run it - // directly. This peeks the file header only — the source is never read or - // wrapped for a bytecode module. Bytecode is the compiled form of the - // *wrapped* module content, so it yields the same wrapper function. - status = js_run_bytecode_file(env, EnsureFileProtocol(modulePath).c_str(), &moduleFunc); - if (status == napi_cannot_run_js) { - // Not bytecode — compile and run the wrapped source as usual. - napi_value script = LoadScript(env, modulePath, fullRequiredModulePath); + if (nativescript::esm::IsESModulePath(modulePath)) { + // ES module sources are rewritten to CommonJS at load time, so the + // bytecode compiler never produced a precompiled form to try first. + napi_util::define_property(env, exportsObj, "__esModule", napi_util::get_true(env)); + napi_value script = WrapESModuleContent(env, modulePath); status = js_execute_script(env, script, EnsureFileProtocol(modulePath).c_str(), &moduleFunc); + } else { + // Fast path: if the build compiled this module to engine bytecode, run it + // directly. This peeks the file header only — the source is never read or + // wrapped for a bytecode module. Bytecode is the compiled form of the + // *wrapped* module content, so it yields the same wrapper function. + status = js_run_bytecode_file(env, EnsureFileProtocol(modulePath).c_str(), &moduleFunc); + if (status == napi_cannot_run_js) { + // Not bytecode — compile and run the wrapped source as usual. + napi_value script = LoadScript(env, modulePath, fullRequiredModulePath); + status = js_execute_script(env, script, EnsureFileProtocol(modulePath).c_str(), &moduleFunc); + } } if (status != napi_ok) { bool pendingException; @@ -566,12 +579,27 @@ napi_value ModuleInternal::LoadData(napi_env env, const std::string& path) { } napi_value ModuleInternal::WrapModuleContent(napi_env env, const std::string& path) { + std::string content = nativescript::esm::RewriteCommonJSDynamicImportsForFallbackEngines( + nativescript::esm::StripShebang(Runtime::GetRuntime(m_env)->ReadFileText(path))); + return WrapWithModuleFunction(env, content, false /* isESModule */); +} - std::string content = Runtime::GetRuntime(m_env)->ReadFileText(path); +napi_value ModuleInternal::WrapESModuleContent(napi_env env, const std::string& path) { + std::string content = nativescript::esm::TransformESModuleForFallbackEngines( + nativescript::esm::StripShebang(Runtime::GetRuntime(m_env)->ReadFileText(path))); + return WrapWithModuleFunction(env, content, true /* isESModule */); +} - // TODO: Use statically allocated buffer for better performance +napi_value ModuleInternal::WrapWithModuleFunction(napi_env env, const std::string& content, bool isESModule) { + // The shims share the prologue's line so source line numbers are unchanged. + // MODULE_PROLOGUE itself stays byte-identical to the one the bytecode + // compiler wraps with; only source-evaluated modules get the shims. std::string result(MODULE_PROLOGUE); result.reserve(content.length() + 1024); + result += NS_ESM_FALLBACK_DYNAMIC_IMPORT_SHIM; + if (isESModule) { + result += NS_ESM_FALLBACK_MODULE_SHIM; + } result += content; result += MODULE_EPILOGUE; diff --git a/NativeScript/runtime/android/napi/modules/module/ModuleInternal.h b/NativeScript/runtime/android/napi/modules/module/ModuleInternal.h index fc048ed56..4f5db4337 100644 --- a/NativeScript/runtime/android/napi/modules/module/ModuleInternal.h +++ b/NativeScript/runtime/android/napi/modules/module/ModuleInternal.h @@ -63,6 +63,8 @@ class ModuleInternal { napi_value RequireCallbackImpl(napi_env env, napi_callback_info info); napi_value WrapModuleContent(napi_env env, const std::string& path); + napi_value WrapESModuleContent(napi_env env, const std::string& path); + napi_value WrapWithModuleFunction(napi_env env, const std::string& content, bool isESModule); napi_value LoadImpl(napi_env env, const std::string& moduleName, const std::string& baseDir, bool& isData); diff --git a/NativeScript/runtime/android/napi/workers/WorkerWrapper.cpp b/NativeScript/runtime/android/napi/workers/WorkerWrapper.cpp index 21fa72e02..bdcf323c7 100644 --- a/NativeScript/runtime/android/napi/workers/WorkerWrapper.cpp +++ b/NativeScript/runtime/android/napi/workers/WorkerWrapper.cpp @@ -1,3 +1,4 @@ +#include "jsr_common.h" #include "WorkerWrapper.h" #include @@ -180,6 +181,9 @@ void WorkerWrapper::DrainPendingTasks() { napi_value args[1] = {event}; napi_value result; status = napi_call_function(env, globalObject, callback, 1, args, &result); + if (status == napi_ok) { + status = js_execute_pending_jobs(env); + } if (status == napi_pending_exception && !isTerminating_) { napi_value error; NAPI_GUARD(napi_get_and_clear_last_exception(env, &error)) {} @@ -229,6 +233,9 @@ void WorkerWrapper::FireMessageOnParentWorkerObject(int workerId, napi_value args[1] = {event}; napi_value result; status = napi_call_function(env, worker, callback, 1, args, &result); + if (status == napi_ok) { + status = js_execute_pending_jobs(env); + } if (status == napi_pending_exception) { napi_value error; NAPI_GUARD(napi_get_and_clear_last_exception(env, &error)) {} @@ -313,6 +320,9 @@ void WorkerWrapper::FireErrorOnParentWorkerObject(int workerId, const std::strin napi_value args[1] = {errEvent}; napi_value result; status = napi_call_function(env, worker, callback, 1, args, &result); + if (status == napi_ok) { + status = js_execute_pending_jobs(env); + } if (status == napi_pending_exception) { napi_value exception; diff --git a/NativeScript/runtime/apple/modules/module/ModuleInternal.cpp b/NativeScript/runtime/apple/modules/module/ModuleInternal.cpp index 97179926b..c04bb55fa 100644 --- a/NativeScript/runtime/apple/modules/module/ModuleInternal.cpp +++ b/NativeScript/runtime/apple/modules/module/ModuleInternal.cpp @@ -22,6 +22,7 @@ #include "runtime/apple/Util.h" #include "runtime/apple/modules/node/Node.h" #include "runtime/apple/modules/web/Web.h" +#include "runtime/modules/esm/ESModuleSupport.h" #ifdef TARGET_ENGINE_V8 // vendor/v8 is on the include path for V8 builds (see NativeScript/CMakeLists). @@ -38,384 +39,11 @@ extern std::unordered_map napiModuleRegistry; } using namespace nativescript; +using namespace nativescript::esm; using namespace std; namespace { -// Cache for package.json \"type\" field lookups. -// -// Deliberately leaked rather than held in a namespace-scope object. The only -// caller of DeInit() is ~Runtime, which runs from the destructor of the global -// `runtime_` unique_ptr in NativeScript.mm -- i.e. during static destruction at -// exit(). Destruction order between two translation units in the same image is -// unspecified, and here this map was being destroyed first: DeInit() then -// called clear() on a dead unordered_map and freed its already-freed nodes, -// aborting every run with -// "___BUG_IN_CLIENT_OF_LIBMALLOC_POINTER_BEING_FREED_WAS_NOT_ALLOCATED" after -// the suite had finished. A function-local pointer that is never deleted has no -// destruction order to get wrong. -std::unordered_map& modulePackageTypeCache() { - static auto* cache = new std::unordered_map(); - return *cache; -} - -// Strip shebang line from source code (e.g., #!/usr/bin/env node) -std::string StripShebang(const std::string& source) { - if (source.size() >= 2 && source[0] == '#' && source[1] == '!') { - size_t lineEnd = source.find('\n'); - if (lineEnd != std::string::npos) { - return source.substr(lineEnd + 1); - } - return ""; // Entire file is just a shebang - } - return source; -} - -#if defined(TARGET_ENGINE_HERMES) || defined(TARGET_ENGINE_JSC) -std::string RewriteCommonJSDynamicImportsForFallbackEngines( - const std::string& source) { - static const std::regex kDynamicImportPattern( - R"((^|[^A-Za-z0-9_$\.])import\s*\()", - std::regex::ECMAScript | std::regex::multiline); - return std::regex_replace(source, kDynamicImportPattern, - "$1__dynamicImport("); -} - -std::string TrimFallbackESMToken(const std::string& value) { - const auto begin = value.find_first_not_of(" \t\r\n"); - if (begin == std::string::npos) { - return ""; - } - const auto end = value.find_last_not_of(" \t\r\n"); - return value.substr(begin, end - begin + 1); -} - -std::vector SplitFallbackESMList(const std::string& value) { - std::vector parts; - std::stringstream stream(value); - std::string part; - while (std::getline(stream, part, ',')) { - part = TrimFallbackESMToken(part); - if (!part.empty()) { - parts.push_back(part); - } - } - return parts; -} - -std::string EscapeFallbackESMSpecifier(const std::string& specifier) { - std::string escaped; - escaped.reserve(specifier.size()); - for (char c : specifier) { - switch (c) { - case '\\': - escaped += "\\\\"; - break; - case '\'': - escaped += "\\'"; - break; - case '\n': - escaped += "\\n"; - break; - case '\r': - escaped += "\\r"; - break; - default: - escaped += c; - break; - } - } - return escaped; -} - -std::string FallbackESMRequireExpression(const std::string& specifier) { - return "require('" + EscapeFallbackESMSpecifier(specifier) + "')"; -} - -std::string RewriteFallbackESMImportBindings(const std::string& bindings) { - std::string result; - for (const auto& part : SplitFallbackESMList(bindings)) { - static const std::regex kAliasPattern( - R"(^([A-Za-z_$][A-Za-z0-9_$]*)\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)$)"); - std::smatch alias; - if (std::regex_match(part, alias, kAliasPattern)) { - result += (result.empty() ? "" : ", "); - result += alias[1].str() + ": " + alias[2].str(); - } else { - result += (result.empty() ? "" : ", "); - result += part; - } - } - return result; -} - -std::string FallbackESMExportAssignments(const std::string& exports, - const std::string& sourceObject = "") { - std::string result; - for (const auto& part : SplitFallbackESMList(exports)) { - static const std::regex kAliasPattern( - R"(^([A-Za-z_$][A-Za-z0-9_$]*)\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)$)"); - std::smatch alias; - std::string local = part; - std::string exported = part; - if (std::regex_match(part, alias, kAliasPattern)) { - local = alias[1].str(); - exported = alias[2].str(); - } - - if (!result.empty()) { - result += "\n"; - } - result += "exports." + exported + " = "; - result += sourceObject.empty() ? local : sourceObject + "." + local; - result += ";"; - } - return result; -} - -template -std::string RegexReplaceWithFallbackESMCallback(const std::string& source, - const std::regex& pattern, - Callback callback) { - std::string result; - std::sregex_iterator it(source.begin(), source.end(), pattern); - std::sregex_iterator end; - size_t last = 0; - for (; it != end; ++it) { - const std::smatch& match = *it; - result.append(source, last, match.position() - last); - result += callback(match); - last = match.position() + match.length(); - } - result.append(source, last, std::string::npos); - return result; -} - -std::string TransformESModuleForFallbackEngines(const std::string& source) { - std::string result = source; - int tempIndex = 0; - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*import[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*,[ \t]*\*[ \t]+as[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", - std::regex::ECMAScript | std::regex::multiline), - [&](const std::smatch& match) { - std::string module = "__esm_import_" + std::to_string(tempIndex++); - return "const " + module + " = " + - FallbackESMRequireExpression(match[3].str()) + ";\nconst " + - match[1].str() + " = " + module + ".default;\nconst " + - match[2].str() + " = " + module + ";"; - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*import[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*,[ \t]*\{([^}]*)\}[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", - std::regex::ECMAScript | std::regex::multiline), - [&](const std::smatch& match) { - std::string module = "__esm_import_" + std::to_string(tempIndex++); - return "const " + module + " = " + - FallbackESMRequireExpression(match[3].str()) + ";\nconst " + - match[1].str() + " = " + module + ".default;\nconst {" + - RewriteFallbackESMImportBindings(match[2].str()) + "} = " + - module + ";"; - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*import[ \t]+\{([^}]*)\}[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return "const {" + RewriteFallbackESMImportBindings(match[1].str()) + - "} = " + FallbackESMRequireExpression(match[2].str()) + ";"; - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*import[ \t]+\*[ \t]+as[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return "const " + match[1].str() + " = " + - FallbackESMRequireExpression(match[2].str()) + ";"; - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*import[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return "const " + match[1].str() + " = " + - FallbackESMRequireExpression(match[2].str()) + ".default;"; - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex(R"(^[ \t]*import[ \t]+['"]([^'"]+)['"][ \t]*;?)", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return FallbackESMRequireExpression(match[1].str()) + ";"; - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*export[ \t]+\*[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return "Object.assign(exports, " + - FallbackESMRequireExpression(match[1].str()) + ");"; - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*export[ \t]+\{([^}]*)\}[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", - std::regex::ECMAScript | std::regex::multiline), - [&](const std::smatch& match) { - std::string module = "__esm_export_" + std::to_string(tempIndex++); - return "const " + module + " = " + - FallbackESMRequireExpression(match[2].str()) + ";\n" + - FallbackESMExportAssignments(match[1].str(), module); - }); - - result = std::regex_replace( - result, - std::regex(R"(^[ \t]*export[ \t]+default[ \t]+function[ \t]*)", - std::regex::ECMAScript | std::regex::multiline), - "exports.default = function "); - - result = std::regex_replace( - result, - std::regex(R"(^[ \t]*export[ \t]+default[ \t]+class[ \t]*)", - std::regex::ECMAScript | std::regex::multiline), - "exports.default = class "); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*export[ \t]+function[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*\()", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return "exports." + match[1].str() + " = function " + match[1].str() + - "("; - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*export[ \t]+class[ \t]+([A-Za-z_$][A-Za-z0-9_$]*))", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return "exports." + match[1].str() + " = class " + match[1].str(); - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex( - R"(^[ \t]*export[ \t]+(const|let|var)[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*=[ \t]*([^;\r\n]*);?)", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return match[1].str() + " " + match[2].str() + " = " + - match[3].str() + ";\nexports." + match[2].str() + " = " + - match[2].str() + ";"; - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex(R"(^[ \t]*export[ \t]*\{([^}]*)\}[ \t]*;)", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return FallbackESMExportAssignments(match[1].str()); - }); - - result = RegexReplaceWithFallbackESMCallback( - result, - std::regex(R"(^[ \t]*export[ \t]+default[ \t]+([^;\r\n]*);?)", - std::regex::ECMAScript | std::regex::multiline), - [](const std::smatch& match) { - return "exports.default = " + match[1].str() + ";"; - }); - - return RewriteCommonJSDynamicImportsForFallbackEngines(result); -} -#endif - -// Check if path has .cjs extension (explicitly CommonJS) -bool IsCJSModule(const std::string& path) { - return path.size() >= 4 && path.compare(path.size() - 4, 4, ".cjs") == 0; -} - -// Find nearest package.json by walking up from directory -std::string FindNearestPackageJson(const std::filesystem::path& startDir) { - std::filesystem::path current = startDir; - - while (!current.empty() && current != current.root_path()) { - std::filesystem::path packagePath = current / "package.json"; - std::error_code ec; - if (std::filesystem::exists(packagePath, ec) && !ec) { - return packagePath.string(); - } - current = current.parent_path(); - } - - return ""; -} - -// Check if package.json has "type": "module" -bool IsPackageTypeModule(const std::string& packageJsonPath) { - auto& cache = modulePackageTypeCache(); - auto cacheIt = cache.find(packageJsonPath); - if (cacheIt != cache.end()) { - return cacheIt->second; - } - - bool isModule = false; - - std::ifstream file(packageJsonPath); - if (file.is_open()) { - std::string content((std::istreambuf_iterator(file)), - std::istreambuf_iterator()); - file.close(); - - // Simple JSON parsing for "type": "module" - size_t typePos = content.find("\"type\""); - if (typePos != std::string::npos) { - size_t colonPos = content.find(':', typePos + 6); - if (colonPos != std::string::npos) { - size_t valueStart = content.find('"', colonPos + 1); - if (valueStart != std::string::npos) { - size_t valueEnd = content.find('"', valueStart + 1); - if (valueEnd != std::string::npos) { - std::string typeValue = - content.substr(valueStart + 1, valueEnd - valueStart - 1); - isModule = (typeValue == "module"); - } - } - } - } - } - - cache[packageJsonPath] = isModule; - return isModule; -} - -// Determine if a .js file should be treated as ESM based on nearest -// package.json -bool ShouldTreatJsAsESModule(const std::string& path) { - std::filesystem::path filePath(path); - std::string packageJson = FindNearestPackageJson(filePath.parent_path()); - - if (!packageJson.empty()) { - return IsPackageTypeModule(packageJson); - } - - return false; // Default to CommonJS -} - bool PathExistsWithExactCase(const std::filesystem::path& path) { std::error_code ec; if (!std::filesystem::exists(path, ec) || ec) { @@ -832,8 +460,7 @@ void ModuleInternal::DeInit() { v8impl::g_moduleRegistry.clear(); #endif - // Clear the package.json type cache - modulePackageTypeCache().clear(); + ClearPackageTypeCache(); if (m_env != nullptr) { napi_delete_reference(m_env, this->m_requireFunction); @@ -1821,6 +1448,7 @@ napi_value ModuleInternal::LoadESModule(napi_env env, const std::string& path) { std::string wrapped; wrapped.reserve(transformed.length() + 1024); wrapped += MODULE_PROLOGUE; + wrapped += NS_ESM_FALLBACK_MODULE_SHIM; wrapped += transformed; wrapped += MODULE_EPILOGUE; @@ -2101,15 +1729,7 @@ ModuleInternal::ModulePathKind ModuleInternal::GetModulePathKind( #if defined(TARGET_ENGINE_HERMES) || defined(TARGET_ENGINE_JSC) const char* ModuleInternal::MODULE_PROLOGUE = "(function(module, exports, require, __filename, __dirname){ " - "const __dynamicImport = (specifier) => Promise.resolve().then(() => { " - "const __loaded = require(specifier); " - "if (__loaded !== null && (typeof __loaded === 'object' || typeof __loaded " - "=== 'function')) { " - "if (__loaded.__esModule) { return __loaded; } " - "return Object.assign({ default: __loaded }, __loaded); " - "} " - "return { default: __loaded }; " - "}); "; + NS_ESM_FALLBACK_DYNAMIC_IMPORT_SHIM; #else const char* ModuleInternal::MODULE_PROLOGUE = "(function(module, exports, require, __filename, __dirname){ "; diff --git a/NativeScript/runtime/modules/esm/ESModuleSupport.cpp b/NativeScript/runtime/modules/esm/ESModuleSupport.cpp new file mode 100644 index 000000000..6cc92eaab --- /dev/null +++ b/NativeScript/runtime/modules/esm/ESModuleSupport.cpp @@ -0,0 +1,536 @@ +#include "ESModuleSupport.h" + +#include +#include +#include +#include +#include +#include + +namespace nativescript::esm { + +namespace { + +// Deliberately leaked rather than held in a namespace-scope object: the Apple +// runtime clears this cache from ~Runtime, which runs during static destruction +// at exit(). Destruction order between translation units is unspecified, and a +// namespace-scope map was being destroyed before that call, so clear() ran on +// a dead object and aborted the process in libmalloc. A function-local pointer +// that is never deleted has no destruction order to get wrong. +std::unordered_map& PackageTypeCache() { + static auto* cache = new std::unordered_map(); + return *cache; +} + +std::string TrimFallbackESMToken(const std::string& value) { + const auto begin = value.find_first_not_of(" \t\r\n"); + if (begin == std::string::npos) { + return ""; + } + const auto end = value.find_last_not_of(" \t\r\n"); + return value.substr(begin, end - begin + 1); +} + +std::vector SplitFallbackESMList(const std::string& value) { + std::vector parts; + std::stringstream stream(value); + std::string part; + while (std::getline(stream, part, ',')) { + part = TrimFallbackESMToken(part); + if (!part.empty()) { + parts.push_back(part); + } + } + return parts; +} + +std::string EscapeFallbackESMSpecifier(const std::string& specifier) { + std::string escaped; + escaped.reserve(specifier.size()); + for (char c : specifier) { + switch (c) { + case '\\': + escaped += "\\\\"; + break; + case '\'': + escaped += "\\'"; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + default: + escaped += c; + break; + } + } + return escaped; +} + +std::string FallbackESMRequireExpression(const std::string& specifier) { + return "__esm_require('" + EscapeFallbackESMSpecifier(specifier) + "')"; +} + +std::string RewriteFallbackESMImportBindings(const std::string& bindings) { + std::string result; + for (const auto& part : SplitFallbackESMList(bindings)) { + static const std::regex kAliasPattern( + R"(^([A-Za-z_$][A-Za-z0-9_$]*)\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)$)"); + std::smatch alias; + if (std::regex_match(part, alias, kAliasPattern)) { + result += (result.empty() ? "" : ", "); + result += alias[1].str() + ": " + alias[2].str(); + } else { + result += (result.empty() ? "" : ", "); + result += part; + } + } + return result; +} + +std::string FallbackESMExportAssignments(const std::string& exports, + const std::string& sourceObject = "") { + std::string result; + for (const auto& part : SplitFallbackESMList(exports)) { + static const std::regex kAliasPattern( + R"(^([A-Za-z_$][A-Za-z0-9_$]*)\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)$)"); + std::smatch alias; + std::string local = part; + std::string exported = part; + if (std::regex_match(part, alias, kAliasPattern)) { + local = alias[1].str(); + exported = alias[2].str(); + } + + if (!result.empty()) { + result += "\n"; + } + result += "__esm_exports." + exported + " = "; + result += sourceObject.empty() ? local : sourceObject + "." + local; + result += ";"; + } + return result; +} + +template +std::string RegexReplaceWithFallbackESMCallback(const std::string& source, + const std::regex& pattern, + Callback callback) { + std::string result; + std::sregex_iterator it(source.begin(), source.end(), pattern); + std::sregex_iterator end; + size_t last = 0; + for (; it != end; ++it) { + const std::smatch& match = *it; + result.append(source, last, match.position() - last); + result += callback(match); + last = match.position() + match.length(); + } + result.append(source, last, std::string::npos); + return result; +} + +} // namespace + +std::string StripShebang(const std::string& source) { + if (source.size() >= 2 && source[0] == '#' && source[1] == '!') { + size_t lineEnd = source.find('\n'); + if (lineEnd != std::string::npos) { + return source.substr(lineEnd + 1); + } + return ""; + } + return source; +} + +namespace { + +bool IsIdentifierChar(char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '_' || c == '$'; +} + +// Replaces every `import` keyword that `matchLength` accepts. Given the index +// just past the keyword, `matchLength` returns how many further characters the +// replacement swallows, or npos to leave the keyword alone. Plain scanning +// instead of std::regex: bundles run to megabytes and a regex pass over the +// whole buffer takes seconds on device. +template +std::string RewriteImportKeyword(const std::string& source, + const char* replacement, + MatchLength matchLength) { + static const std::string kKeyword = "import"; + std::string result; + result.reserve(source.size() + 256); + size_t last = 0; + size_t pos = source.find(kKeyword); + while (pos != std::string::npos) { + size_t end = pos + kKeyword.size(); + bool startsToken = pos == 0 || !IsIdentifierChar(source[pos - 1]); + size_t extra = + startsToken ? matchLength(source, pos, end) : std::string::npos; + if (extra != std::string::npos) { + result.append(source, last, pos - last); + result += replacement; + end += extra; + last = end; + } + pos = source.find(kKeyword, end); + } + result.append(source, last, std::string::npos); + return result; +} + +} // namespace + +std::string RewriteCommonJSDynamicImportsForFallbackEngines( + const std::string& source) { + return RewriteImportKeyword( + source, "__dynamicImport", + [](const std::string& text, size_t start, size_t end) -> size_t { + // `foo.import(` is a method call, not the operator. + if (start > 0 && text[start - 1] == '.') { + return std::string::npos; + } + size_t cursor = end; + while (cursor < text.size() && + (text[cursor] == ' ' || text[cursor] == '\t' || + text[cursor] == '\r' || text[cursor] == '\n')) { + cursor++; + } + bool isCall = cursor < text.size() && text[cursor] == '('; + return isCall ? 0 : std::string::npos; + }); +} + +std::string RewriteImportMeta(const std::string& source) { + return RewriteImportKeyword( + source, "__importMeta", + [](const std::string& text, size_t, size_t end) -> size_t { + static const std::string kMeta = ".meta"; + if (text.compare(end, kMeta.size(), kMeta) != 0) { + return std::string::npos; + } + size_t after = end + kMeta.size(); + bool endsToken = after >= text.size() || !IsIdentifierChar(text[after]); + return endsToken ? kMeta.size() : std::string::npos; + }); +} + +namespace { + +// True when `line` begins (after indentation) with an import or export +// keyword, i.e. it may open a static module statement. +bool StartsModuleStatement(const std::string& line) { + size_t i = 0; + while (i < line.size() && (line[i] == ' ' || line[i] == '\t')) { + i++; + } + for (const char* keyword : {"import", "export"}) { + size_t length = std::strlen(keyword); + if (line.compare(i, length, keyword) == 0 && + (i + length >= line.size() || !IsIdentifierChar(line[i + length]))) { + return true; + } + } + return false; +} + +int BraceBalance(const std::string& text) { + int balance = 0; + for (char c : text) { + if (c == '{') { + balance++; + } else if (c == '}') { + balance--; + } + } + return balance; +} + +std::string TransformModuleStatement(const std::string& statement, + int& tempIndex); + +} // namespace + +std::string TransformESModuleForFallbackEngines(const std::string& source) { + // Only the handful of lines that open an import/export statement go through + // the regex chain; everything else is copied through untouched. A statement + // whose binding list is split across lines is gathered until its braces + // balance so the multi-line regex forms still see the whole statement. + std::string result; + result.reserve(source.size() + 1024); + int tempIndex = 0; + size_t pos = 0; + while (pos < source.size()) { + size_t lineEnd = source.find('\n', pos); + size_t next = lineEnd == std::string::npos ? source.size() : lineEnd + 1; + std::string line = source.substr(pos, next - pos); + if (!StartsModuleStatement(line)) { + result += line; + pos = next; + continue; + } + std::string statement = line; + int balance = BraceBalance(statement); + while (balance > 0 && next < source.size()) { + size_t continuationEnd = source.find('\n', next); + size_t continuationNext = continuationEnd == std::string::npos + ? source.size() + : continuationEnd + 1; + std::string continuation = source.substr(next, continuationNext - next); + statement += continuation; + balance += BraceBalance(continuation); + next = continuationNext; + } + result += TransformModuleStatement(statement, tempIndex); + pos = next; + } + + result = RewriteImportMeta(result); + return RewriteCommonJSDynamicImportsForFallbackEngines(result); +} + +namespace { + +std::string TransformModuleStatement(const std::string& statement, + int& tempIndex) { + std::string result = statement; + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex( + R"(^[ \t]*import[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*,[ \t]*\*[ \t]+as[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", + std::regex::ECMAScript | std::regex::multiline), + [&](const std::smatch& match) { + std::string module = "__esm_import_" + std::to_string(tempIndex++); + return "const " + module + " = " + + FallbackESMRequireExpression(match[3].str()) + ";\nconst " + + match[1].str() + " = " + module + ".default;\nconst " + + match[2].str() + " = " + module + ";"; + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex( + R"(^[ \t]*import[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*,[ \t]*\{([^}]*)\}[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", + std::regex::ECMAScript | std::regex::multiline), + [&](const std::smatch& match) { + std::string module = "__esm_import_" + std::to_string(tempIndex++); + return "const " + module + " = " + + FallbackESMRequireExpression(match[3].str()) + ";\nconst " + + match[1].str() + " = " + module + ".default;\nconst {" + + RewriteFallbackESMImportBindings(match[2].str()) + + "} = " + module + ";"; + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex( + R"(^[ \t]*import[ \t]+\{([^}]*)\}[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return "const {" + RewriteFallbackESMImportBindings(match[1].str()) + + "} = " + FallbackESMRequireExpression(match[2].str()) + ";"; + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex( + R"(^[ \t]*import[ \t]+\*[ \t]+as[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return "const " + match[1].str() + " = " + + FallbackESMRequireExpression(match[2].str()) + ";"; + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex( + R"(^[ \t]*import[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return "const " + match[1].str() + " = " + + FallbackESMRequireExpression(match[2].str()) + ".default;"; + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex(R"(^[ \t]*import[ \t]+['"]([^'"]+)['"][ \t]*;?)", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return FallbackESMRequireExpression(match[1].str()) + ";"; + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex( + R"(^[ \t]*export[ \t]+\*[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return "Object.assign(__esm_exports, " + + FallbackESMRequireExpression(match[1].str()) + ");"; + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex( + R"(^[ \t]*export[ \t]+\{([^}]*)\}[ \t]+from[ \t]+['"]([^'"]+)['"][ \t]*;?)", + std::regex::ECMAScript | std::regex::multiline), + [&](const std::smatch& match) { + std::string module = "__esm_export_" + std::to_string(tempIndex++); + return "const " + module + " = " + + FallbackESMRequireExpression(match[2].str()) + ";\n" + + FallbackESMExportAssignments(match[1].str(), module); + }); + + result = std::regex_replace( + result, + std::regex(R"(^[ \t]*export[ \t]+default[ \t]+function[ \t]*)", + std::regex::ECMAScript | std::regex::multiline), + "__esm_exports.default = function "); + + result = std::regex_replace( + result, + std::regex(R"(^[ \t]*export[ \t]+default[ \t]+class[ \t]*)", + std::regex::ECMAScript | std::regex::multiline), + "__esm_exports.default = class "); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex( + R"(^[ \t]*export[ \t]+function[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*\()", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return "__esm_exports." + match[1].str() + " = function " + + match[1].str() + "("; + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex(R"(^[ \t]*export[ \t]+class[ \t]+([A-Za-z_$][A-Za-z0-9_$]*))", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return "__esm_exports." + match[1].str() + " = class " + match[1].str(); + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex( + R"(^[ \t]*export[ \t]+(const|let|var)[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*=[ \t]*([^;\r\n]*);?)", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return match[1].str() + " " + match[2].str() + " = " + match[3].str() + + ";\n__esm_exports." + match[2].str() + " = " + match[2].str() + + ";"; + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex(R"(^[ \t]*export[ \t]*\{([^}]*)\}[ \t]*;)", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return FallbackESMExportAssignments(match[1].str()); + }); + + result = RegexReplaceWithFallbackESMCallback( + result, + std::regex(R"(^[ \t]*export[ \t]+default[ \t]+([^;\r\n]*);?)", + std::regex::ECMAScript | std::regex::multiline), + [](const std::smatch& match) { + return "__esm_exports.default = " + match[1].str() + ";"; + }); + + return result; +} + +} // namespace + +bool IsCJSModule(const std::string& path) { + return path.size() >= 4 && path.compare(path.size() - 4, 4, ".cjs") == 0; +} + +std::string FindNearestPackageJson(const std::filesystem::path& startDir) { + std::filesystem::path current = startDir; + + while (!current.empty() && current != current.root_path()) { + std::filesystem::path packagePath = current / "package.json"; + std::error_code ec; + if (std::filesystem::exists(packagePath, ec) && !ec) { + return packagePath.string(); + } + current = current.parent_path(); + } + + return ""; +} + +bool IsPackageTypeModule(const std::string& packageJsonPath) { + auto& cache = PackageTypeCache(); + auto cacheIt = cache.find(packageJsonPath); + if (cacheIt != cache.end()) { + return cacheIt->second; + } + + bool isModule = false; + + std::ifstream file(packageJsonPath); + if (file.is_open()) { + std::string content((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + file.close(); + + // Only the top-level "type" key matters; a full JSON parse is not needed. + size_t typePos = content.find("\"type\""); + if (typePos != std::string::npos) { + size_t colonPos = content.find(':', typePos + 6); + if (colonPos != std::string::npos) { + size_t valueStart = content.find('"', colonPos + 1); + if (valueStart != std::string::npos) { + size_t valueEnd = content.find('"', valueStart + 1); + if (valueEnd != std::string::npos) { + std::string typeValue = + content.substr(valueStart + 1, valueEnd - valueStart - 1); + isModule = (typeValue == "module"); + } + } + } + } + } + + cache[packageJsonPath] = isModule; + return isModule; +} + +bool ShouldTreatJsAsESModule(const std::string& path) { + std::filesystem::path filePath(path); + std::string packageJson = FindNearestPackageJson(filePath.parent_path()); + + if (!packageJson.empty()) { + return IsPackageTypeModule(packageJson); + } + + return false; +} + +bool IsESModulePath(const std::string& path) { + if (path.size() >= 4 && path.compare(path.size() - 4, 4, ".mjs") == 0) { + return true; + } + + if (IsCJSModule(path)) { + return false; + } + + if (path.size() >= 3 && path.compare(path.size() - 3, 3, ".js") == 0) { + return ShouldTreatJsAsESModule(path); + } + + return false; +} + +void ClearPackageTypeCache() { PackageTypeCache().clear(); } + +} // namespace nativescript::esm diff --git a/NativeScript/runtime/modules/esm/ESModuleSupport.h b/NativeScript/runtime/modules/esm/ESModuleSupport.h new file mode 100644 index 000000000..8ee1a95ea --- /dev/null +++ b/NativeScript/runtime/modules/esm/ESModuleSupport.h @@ -0,0 +1,75 @@ +#ifndef NS_RUNTIME_MODULES_ESM_ESMODULESUPPORT_H_ +#define NS_RUNTIME_MODULES_ESM_ESMODULESUPPORT_H_ + +#include +#include + +// Engine-neutral ES module support shared by the Apple and Android runtimes. +// +// Engines without a native module loader (Hermes, JavaScriptCore, PrimJS, and +// every engine on Android, where no host import hooks are wired) evaluate ES +// modules through the CommonJS wrapper: the source is rewritten statement by +// statement into `require`/`exports` form and run as a regular module. The +// rewrite is textual and covers the shapes bundlers emit; it is not a parser. +// +// The rewritten source never touches the wrapper parameters `module`, +// `exports` and `require` directly: bundler output may declare top-level +// variables of the same names inside an ES module (rolldown's CommonJS interop +// emits `var module = { exports: {} }; var exports = module.exports;`), which +// would silently redirect the generated export assignments. It binds instead +// to `__esm_exports` and `__esm_require`, captured by the shims below at +// wrapper entry, plus `__dynamicImport` (replaces `import()`) and +// `__importMeta` (replaces `import.meta`), built from `__filename` and +// `__dirname`. + +// Resolves `import()` through the module-scoped `require`, so relative +// specifiers resolve against the importing module rather than the app root. +#define NS_ESM_FALLBACK_DYNAMIC_IMPORT_SHIM \ + "const __esm_require = require; " \ + "const __dynamicImport = (specifier) => Promise.resolve().then(() => { " \ + "const __loaded = __esm_require(specifier); " \ + "if (__loaded !== null && (typeof __loaded === 'object' || typeof " \ + "__loaded === 'function')) { " \ + "if (__loaded.__esModule) { return __loaded; } " \ + "return Object.assign({ default: __loaded }, __loaded); " \ + "} " \ + "return { default: __loaded }; " \ + "}); " + +#define NS_ESM_FALLBACK_MODULE_SHIM \ + "const __esm_exports = exports; " \ + "const __importMeta = { url: 'file://' + __filename, filename: __filename, " \ + "dirname: __dirname, main: false }; " + +namespace nativescript::esm { + +std::string StripShebang(const std::string& source); + +// `import(` -> `__dynamicImport(` for CommonJS sources evaluated on engines +// whose parser rejects dynamic import or whose host has no import hook. +std::string RewriteCommonJSDynamicImportsForFallbackEngines( + const std::string& source); + +// Full ES module -> CommonJS rewrite: static imports/exports, `import.meta` +// and dynamic imports. The result must be wrapped by the CommonJS prologue +// together with both shims above. +std::string TransformESModuleForFallbackEngines(const std::string& source); + +bool IsCJSModule(const std::string& path); + +std::string FindNearestPackageJson(const std::filesystem::path& startDir); + +// Cached per package.json path; see ClearPackageTypeCache. +bool IsPackageTypeModule(const std::string& packageJsonPath); + +bool ShouldTreatJsAsESModule(const std::string& path); + +// .mjs is always ESM, .cjs never, and .js follows the nearest package.json +// "type" field. +bool IsESModulePath(const std::string& path); + +void ClearPackageTypeCache(); + +} // namespace nativescript::esm + +#endif // NS_RUNTIME_MODULES_ESM_ESMODULESUPPORT_H_ diff --git a/metadata-generator/CMakeLists.txt b/metadata-generator/CMakeLists.txt index 12f4a04aa..586d2f019 100644 --- a/metadata-generator/CMakeLists.txt +++ b/metadata-generator/CMakeLists.txt @@ -100,10 +100,21 @@ target_link_libraries( clang ) -# Set runtime path for libclang +# dyld tries these in order for @rpath/libclang.dylib. The toolchain that +# linked the binary comes first; the stock Xcode and Command Line Tools +# locations keep the binary loadable on machines whose Xcode lives elsewhere +# (a renamed or beta Xcode bundle). build-step-metadata-generator.py covers the +# remaining cases through DYLD_FALLBACK_LIBRARY_PATH. +set(LIBCLANG_RPATHS + "${LIBCLANG_DIR}" + "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib" + "/Library/Developer/CommandLineTools/usr/lib" +) +list(REMOVE_DUPLICATES LIBCLANG_RPATHS) + set_target_properties(${NAME} PROPERTIES BUILD_WITH_INSTALL_RPATH TRUE - INSTALL_RPATH "${LIBCLANG_DIR}" + INSTALL_RPATH "${LIBCLANG_RPATHS}" ) install(TARGETS ${NAME} diff --git a/metadata-generator/build-step-metadata-generator.py b/metadata-generator/build-step-metadata-generator.py index 444b37d9d..32c4a4860 100755 --- a/metadata-generator/build-step-metadata-generator.py +++ b/metadata-generator/build-step-metadata-generator.py @@ -177,6 +177,58 @@ def is_nativescript_source_root(search_path): signature_bindings_cpp_path = default_signature_bindings_path +def libclang_search_dirs(): + """Toolchain lib dirs holding libclang.dylib, active toolchain first. + + The generator links @rpath/libclang.dylib with an rpath pointing at the + toolchain of the machine that built it. That path need not exist on the + machine running the build (Xcode installed under another name, a beta, or + Command Line Tools only), so the active toolchain has to be offered to dyld + as a fallback. + """ + candidates = [] + for env_name in ("TOOLCHAIN_DIR", "DT_TOOLCHAIN_DIR"): + toolchain_dir = env_or_empty(env_name) + if toolchain_dir: + candidates.append(os.path.join(toolchain_dir, "usr", "lib")) + + try: + clang_path = subprocess.check_output( + ["xcrun", "--find", "clang"], + stderr=subprocess.DEVNULL, + universal_newlines=True + ).strip() + except (OSError, subprocess.CalledProcessError): + clang_path = "" + if clang_path: + candidates.append(os.path.join(os.path.dirname(os.path.dirname(clang_path)), "lib")) + + developer_dir = env_or_empty("DEVELOPER_DIR") + if developer_dir: + candidates.append(os.path.join(developer_dir, "Toolchains", "XcodeDefault.xctoolchain", "usr", "lib")) + + candidates.append("/Library/Developer/CommandLineTools/usr/lib") + + result = [] + for candidate in candidates: + if candidate in result: + continue + if os.path.isfile(os.path.join(candidate, "libclang.dylib")): + result.append(candidate) + return result + + +def generator_environment(): + child_env = os.environ.copy() + search_dirs = libclang_search_dirs() + if search_dirs: + existing = child_env.get("DYLD_FALLBACK_LIBRARY_PATH") + if existing: + search_dirs.append(existing) + child_env["DYLD_FALLBACK_LIBRARY_PATH"] = ":".join(search_dirs) + return child_env + + def save_stream_to_file(filename, stream): f = open(filename, "w") f.write(stream) @@ -246,7 +298,8 @@ def generate_metadata(arch): generator_call, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - universal_newlines=True + universal_newlines=True, + env=generator_environment() ) sys.stdout.flush() output_stream_content, error_stream_content = child_process.communicate() diff --git a/platforms/android/build.gradle b/platforms/android/build.gradle index e71babf3f..7811c6745 100644 --- a/platforms/android/build.gradle +++ b/platforms/android/build.gradle @@ -15,6 +15,7 @@ import groovy.json.JsonBuilder import groovy.json.JsonOutput def onlyX86 = project.hasProperty("onlyX86") +def onlyArm64 = project.hasProperty("onlyArm64") def useCCache = !project.hasProperty("noCCache") def hasNdkVersion = project.hasProperty("ndkVersion") @@ -225,6 +226,9 @@ def getAssembleReleaseBuildArguments = { -> if (onlyX86) { arguments.add("-PonlyX86") } + if (onlyArm64) { + arguments.add("-PonlyArm64") + } if (useCCache) { arguments.add("-PuseCCache") } @@ -522,6 +526,9 @@ def getRunTestsBuildArguments = { taskName -> if (onlyX86) { arguments.add("-PonlyX86") } + if (onlyArm64) { + arguments.add("-PonlyArm64") + } if (useCCache) { arguments.add("-PuseCCache") } diff --git a/platforms/android/test-app/runtime/CMakeLists.txt b/platforms/android/test-app/runtime/CMakeLists.txt index ea26287a2..bbbf33337 100644 --- a/platforms/android/test-app/runtime/CMakeLists.txt +++ b/platforms/android/test-app/runtime/CMakeLists.txt @@ -118,6 +118,7 @@ include_directories( # shared runtime modules ${NS_RUNTIME_MODULES_DIR} ${NS_RUNTIME_MODULES_DIR}/url + ${NS_RUNTIME_MODULES_DIR}/esm # shared Node-API headers ${NS_NAPI_DIR}/common ) @@ -143,6 +144,7 @@ if (NS_BINDING STREQUAL "napi") ${NS_RUNTIME_MODULES_DIR}/url/URLSearchParams.cpp ${NS_RUNTIME_MODULES_DIR}/url/URLPattern.cpp ${NS_RUNTIME_MODULES_DIR}/url/ada/ada.cpp + ${NS_RUNTIME_MODULES_DIR}/esm/ESModuleSupport.cpp ) # modules/url: URL, URLSearchParams, URLPattern (backed by vendored ada) else () diff --git a/platforms/android/test-app/runtime/src/main/java/com/tns/Module.java b/platforms/android/test-app/runtime/src/main/java/com/tns/Module.java index 912086414..929dfba69 100644 --- a/platforms/android/test-app/runtime/src/main/java/com/tns/Module.java +++ b/platforms/android/test-app/runtime/src/main/java/com/tns/Module.java @@ -192,28 +192,45 @@ private static File resolveFromFileOrDirectory(String baseDir, String path, File return foundModule; } + // Probe order for extensionless specifiers and directory indexes. It + // matches the Apple runtime so a project resolves the same file on both. + private static final String[] SCRIPT_EXTENSIONS = new String[]{".mjs", ".js", ".cjs"}; + + private static boolean isScriptFile(String fileName) { + for (String extension : SCRIPT_EXTENSIONS) { + if (fileName.endsWith(extension)) { + return true; + } + } + return false; + } + + private static File existingFile(File candidate) { + try { + File canonicalFile = candidate.getCanonicalFile(); + if (canonicalFile.exists() && canonicalFile.isFile()) { + return candidate; + } + } catch (IOException e) { + // treated as missing + } + return null; + } + //tries to load the path as a file, returns null if that's not possible private static File loadAsFile(File path) { - String fallbackExtension; - - boolean isJSFile = path.getName().endsWith(".js"); - boolean isSOFile = path.getName().endsWith(".so"); - boolean isJSONFile = path.getName().endsWith(".json"); + String fileName = path.getName(); + boolean hasKnownExtension = isScriptFile(fileName) || fileName.endsWith(".so") || fileName.endsWith(".json"); - if (isJSFile || isJSONFile || isSOFile) { - fallbackExtension = ""; - } else { - fallbackExtension = ".js"; + if (hasKnownExtension) { + return existingFile(path); } - File foundFile = new File(path.getAbsolutePath() + fallbackExtension); - try { - File canonicalFile = foundFile.getCanonicalFile(); - if (canonicalFile.exists() && canonicalFile.isFile()) { + for (String extension : SCRIPT_EXTENSIONS) { + File foundFile = existingFile(new File(path.getAbsolutePath() + extension)); + if (foundFile != null) { return foundFile; } - } catch (IOException e) { - // return null } return null; @@ -248,14 +265,11 @@ private static File loadAsDirectory(String baseDir, String currentPath, File pat } } - //fallback to index js - foundFile = new File(path, "index.js"); - try { - if (foundFile.getCanonicalFile().exists()) { + for (String extension : SCRIPT_EXTENSIONS) { + foundFile = existingFile(new File(path, "index" + extension)); + if (foundFile != null) { return foundFile; } - } catch (IOException e) { - return null; } //TODO: plamen5kov: add later if necessary diff --git a/platforms/apple/templates/ios/internal/nativescript-build.xcconfig b/platforms/apple/templates/ios/internal/nativescript-build.xcconfig index 8a3fc311e..be9809316 100644 --- a/platforms/apple/templates/ios/internal/nativescript-build.xcconfig +++ b/platforms/apple/templates/ios/internal/nativescript-build.xcconfig @@ -18,9 +18,10 @@ LDPLUSPLUS = $SRCROOT/internal/nsld.sh // * It will be generated on each build, so you can find it after running "ns build ios" in "YOUR_APP/platforms/ios". // NS_DEBUG_METADATA_PATH = $(SRCROOT)/debug-metadata -// Xcode 12 -EXCLUDED_ARCHS_x86_64 = arm64 arm64e -EXCLUDED_ARCHS[sdk=iphonesimulator*] = i386 armv6 armv7 armv7s armv8 $(EXCLUDED_ARCHS_$(NATIVE_ARCH_64_BIT)) +// NativeScript.xcframework ships an arm64-only simulator slice, so x86_64 must +// stay out of simulator builds even though ARCHS_STANDARD lists it and the CLI +// builds simulator targets with ONLY_ACTIVE_ARCH=NO. +EXCLUDED_ARCHS[sdk=iphonesimulator*] = i386 x86_64 armv6 armv7 armv7s armv8 EXCLUDED_ARCHS[sdk=iphoneos*] = i386 armv6 armv7 armv7s armv8 x86_64 EXCLUDED_ARCHS[sdk=macosx*] = i386 armv6 armv7 armv7s armv8 VALIDATE_WORKSPACE = YES diff --git a/scripts/build_npm_ios.sh b/scripts/build_npm_ios.sh index f7bd1bf6b..d03b6311b 100755 --- a/scripts/build_npm_ios.sh +++ b/scripts/build_npm_ios.sh @@ -38,6 +38,18 @@ cp -R "./platforms/apple/templates/ios/." "$STAGING_DIR/framework" cp -R "dist/NativeScript.xcframework" "$STAGING_DIR/framework/internal" cp -R "dist/TKLiveSync.xcframework" "$STAGING_DIR/framework/internal" +# Hermes is linked as its own dynamic framework (@rpath/hermes.framework), so +# the package has to carry it and the app template has to embed it. +if [ "$IOS_VARIANT" = "ios-hermes" ]; then + if [ ! -d "Frameworks/hermes.xcframework" ]; then + echo "Frameworks/hermes.xcframework is missing; run scripts/download_hermes.sh first." >&2 + exit 1 + fi + cp -R "Frameworks/hermes.xcframework" "$STAGING_DIR/framework/internal" + python3 "$SCRIPT_DIR/embed_engine_xcframework.py" \ + "$STAGING_DIR/framework/__PROJECT_NAME__.xcodeproj/project.pbxproj" "hermes.xcframework" +fi + mkdir -p "$STAGING_DIR/framework/internal/metadata-generator-x86_64" cp -R "metadata-generator/dist/x86_64/." "$STAGING_DIR/framework/internal/metadata-generator-x86_64" diff --git a/scripts/embed_engine_xcframework.py b/scripts/embed_engine_xcframework.py new file mode 100755 index 000000000..132bd9e1e --- /dev/null +++ b/scripts/embed_engine_xcframework.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Add an internal xcframework to the app template's Embed Frameworks phase. + +Usage: embed_engine_xcframework.py + +The template project embeds NativeScript.xcframework and TKLiveSync.xcframework +by explicit entries. An engine that ships as its own dynamic framework (Hermes) +must be embedded the same way, or dyld cannot find it at app launch. The new +entries are cloned from the TKLiveSync ones so they land in the same group, +build phase and settings; ids are derived from the framework name so re-running +is idempotent. +""" + +import hashlib +import re +import sys + +TEMPLATE_NAME = "TKLiveSync.xcframework" + + +def pbx_id(seed): + return hashlib.md5(seed.encode("utf-8")).hexdigest()[:24].upper() + + +def main(pbxproj_path, framework_name): + with open(pbxproj_path) as f: + lines = f.read().split("\n") + + if any(framework_name in line for line in lines): + print("{} already referenced in {}".format(framework_name, pbxproj_path)) + return 0 + + template_lines = [line for line in lines if TEMPLATE_NAME in line] + template_ids = sorted(set(re.findall(r"\b([0-9A-F]{24})\b", "\n".join(template_lines)))) + if len(template_ids) != 2: + print("expected the file reference and build file ids for {}, found {}".format( + TEMPLATE_NAME, template_ids), file=sys.stderr) + return 1 + + # The build-file entry references the file reference (`fileRef = `), so + # the id that appears as a fileRef target is the file reference. + joined = "\n".join(template_lines) + file_ref_id = next(i for i in template_ids if re.search(r"fileRef = " + i, joined)) + build_file_id = next(i for i in template_ids if i != file_ref_id) + replacements = { + file_ref_id: pbx_id(framework_name + ":fileRef"), + build_file_id: pbx_id(framework_name + ":buildFile"), + TEMPLATE_NAME: framework_name, + } + + output = [] + for line in lines: + output.append(line) + if TEMPLATE_NAME in line: + clone = line + for old, new in replacements.items(): + clone = clone.replace(old, new) + output.append(clone) + + with open(pbxproj_path, "w") as f: + f.write("\n".join(output)) + print("embedded {} in {}".format(framework_name, pbxproj_path)) + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print(__doc__, file=sys.stderr) + sys.exit(2) + sys.exit(main(sys.argv[1], sys.argv[2]))