diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..2720197 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,2 @@ +// @generated by expo-module-scripts +module.exports = require('expo-module-scripts/eslintrc.base.js'); diff --git a/.gitignore b/.gitignore index 24a94c7..6fe08be 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ build/ .gradle local.properties *.iml +android/.cxx # BUCK buck-out/ diff --git a/.npmignore b/.npmignore index f58b7f8..ee8ab40 100644 --- a/.npmignore +++ b/.npmignore @@ -1,3 +1,39 @@ # Don't ignore large binaries !android/libnode/bin/*/libnode.so -!ios/NodeMobile.framework/NodeMobile \ No newline at end of file +!ios/NodeMobile.framework/NodeMobile + +# Ignore example apps +# +example/* +benchmark/* +android/build +android/.cxx + +# Xcode +# +ios/build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace + + +# Android/IntelliJ +# +android/build/ +.idea +.gradle +local.properties +*.iml diff --git a/android/build.gradle b/android/build.gradle index 14be607..cb4dbbd 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,56 +1,35 @@ +apply plugin: 'com.android.library' -buildscript { - repositories { - mavenCentral() - } +group = 'com.nodejsmobile.reactnative' +version = '0.1.0' - dependencies { - classpath 'com.android.tools.build:gradle:2.3.0' +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() + +buildscript { + ext.safeExtGet = { prop, fallback -> + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback } } -apply plugin: 'com.android.library' - -def _nodeTargetSdkVersion = ((rootProject?.ext?.properties?.targetSdkVersion) ?: 30) -def _nodeMinSdkVersion = ((rootProject?.ext?.properties?.minSdkVersion) ?: 24) +def _nodeTargetSdkVersion = safeExtGet("targetSdkVersion", 30) +def _nodeMinSdkVersion = safeExtGet("minSdkVersion", 24) def _compileNativeModulesSdkVersion = _nodeMinSdkVersion; def _nodeVersionRequired = 18 if (_compileNativeModulesSdkVersion<24) { // 24 is the minimum sdk version Node is built with. _compileNativeModulesSdkVersion=24; } -def DoesAppAlreadyDefineWantedSTL() { - // Since react-native 0.59.0, the Application already defines libc++_shared as the APP_STL. - // Defining it also in this plugin would lead to a build error when merging assets. - try { - def _reactAndroidPropertiesFile = file("${rootDir}/../node_modules/react-native/ReactAndroid/gradle.properties"); - def _reactAndroidProperties = new Properties() - if (_reactAndroidPropertiesFile.exists()) - { - _reactAndroidPropertiesFile.withInputStream { _reactAndroidProperties.load(it) } - } - def _semver = _reactAndroidProperties.getProperty("VERSION_NAME").tokenize('.'); - if (_semver.size() != 3) { - return false - } - def _major = _semver[0].toInteger() - def _minor = _semver[1].toInteger() - if ( _major > 0 || (_major == 0 && _minor >= 59) ) { - return true - } else { - return false - } - - } catch ( Exception e ) { - return false - } -} -def _isCorrectSTLDefinedByApp = DoesAppAlreadyDefineWantedSTL() android { - compileSdkVersion ((rootProject?.ext?.properties?.compileSdkVersion) ?: 24) - buildToolsVersion ((rootProject?.ext?.properties?.buildToolsVersion) ?: "30.0.2") + namespace "com.nodejsmobile.reactnative" + + compileSdkVersion safeExtGet("compileSdkVersion", 34) + buildToolsVersion safeExtGet("buildToolsVersion", "30.0.2") ndkVersion ((rootProject?.ext?.ndkVersion) ?: "24.0.8215888") @@ -62,9 +41,6 @@ android { externalNativeBuild { cmake { cppFlags "" - if(!_isCorrectSTLDefinedByApp) { - arguments "-DANDROID_STL=c++_shared" - } } } ndk { diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 78cb6c5..305fa86 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,6 +1,3 @@ - - + - \ No newline at end of file diff --git a/android/src/main/cpp/native-lib.cpp b/android/src/main/cpp/native-lib.cpp index f439962..3822405 100644 --- a/android/src/main/cpp/native-lib.cpp +++ b/android/src/main/cpp/native-lib.cpp @@ -4,16 +4,28 @@ #include #include #include +#include #include "node.h" #include "rn-bridge.h" -// cache the environment variable for the thread running node to call into java -JNIEnv* cacheEnvPointer=NULL; +// cache JavaVM for thread attachment and JNI references for performance +static JavaVM* cached_jvm = nullptr; +static jclass cached_class = nullptr; +static jmethodID cached_method = nullptr; + +// Thread-local storage for JNIEnv to avoid repeated attachment overhead +static thread_local JNIEnv* tls_env = nullptr; +static thread_local bool tls_attached = false; + +// Memory usage monitoring and safety limits +static std::atomic attached_thread_count{0}; +static std::atomic peak_attached_threads{0}; +static constexpr int MAX_ATTACHED_THREADS = 32; // Safety limit to prevent memory exhaustion extern "C" JNIEXPORT void JNICALL -Java_com_janeasystems_rn_1nodejs_1mobile_RNNodeJsMobileModule_sendMessageToNodeChannel( +Java_com_nodejsmobile_reactnative_RNNodeJsMobileModule_sendMessageToNodeChannel( JNIEnv *env, jobject /* this */, jstring channelName, @@ -45,7 +57,7 @@ extern "C" int callintoNode(int argc, char *argv[]) extern "C" JNIEXPORT jstring JNICALL -Java_com_janeasystems_rn_1nodejs_1mobile_RNNodeJsMobileModule_getCurrentABIName( +Java_com_nodejsmobile_reactnative_RNNodeJsMobileModule_getCurrentABIName( JNIEnv *env, jobject /* this */) { return env->NewStringUTF(CURRENT_ABI_NAME); @@ -53,7 +65,7 @@ Java_com_janeasystems_rn_1nodejs_1mobile_RNNodeJsMobileModule_getCurrentABIName( extern "C" JNIEXPORT void JNICALL -Java_com_janeasystems_rn_1nodejs_1mobile_RNNodeJsMobileModule_registerNodeDataDirPath( +Java_com_nodejsmobile_reactnative_RNNodeJsMobileModule_registerNodeDataDirPath( JNIEnv *env, jobject /* this */, jstring dataDir) { @@ -64,21 +76,141 @@ Java_com_janeasystems_rn_1nodejs_1mobile_RNNodeJsMobileModule_registerNodeDataDi #define APPNAME "RNBRIDGE" -void rcv_message(const char* channel_name, const char* msg) { - JNIEnv *env=cacheEnvPointer; - if(!env) return; - jclass cls2 = env->FindClass("com/janeasystems/rn_nodejs_mobile/RNNodeJsMobileModule"); // try to find the class - if(cls2 != nullptr) { - jmethodID m_sendMessage = env->GetStaticMethodID(cls2, "sendMessageToApplication", "(Ljava/lang/String;Ljava/lang/String;)V"); // find method - if(m_sendMessage != nullptr) { - jstring java_channel_name=env->NewStringUTF(channel_name); - jstring java_msg=env->NewStringUTF(msg); - env->CallStaticVoidMethod(cls2, m_sendMessage, java_channel_name, java_msg); // call method - env->DeleteLocalRef(java_channel_name); - env->DeleteLocalRef(java_msg); +// Fast path JNI environment getter using thread-local storage +static inline JNIEnv* get_jni_env() { + // Check thread-local cache first + if (tls_env) { + return tls_env; + } + + if (!cached_jvm) { + return nullptr; + } + + JNIEnv* env = nullptr; + jint result = cached_jvm->GetEnv((void**)&env, JNI_VERSION_1_6); + + if (result == JNI_OK) { + // Thread already attached, cache the env + tls_env = env; + tls_attached = false; // We didn't attach it + return env; + } + + if (result == JNI_EDETACHED) { + // Safety check: prevent excessive thread attachment + int current_count = attached_thread_count.load(); + if (current_count >= MAX_ATTACHED_THREADS) { + __android_log_print(ANDROID_LOG_WARN, "RN_BRIDGE_PERF", + "Thread attachment limit reached (%d). Refusing to attach thread %lu", + MAX_ATTACHED_THREADS, (unsigned long)pthread_self()); + return nullptr; + } + + // Need to attach thread + if (cached_jvm->AttachCurrentThread(&env, nullptr) == JNI_OK) { + tls_env = env; + tls_attached = true; // We attached it, so we should detach on thread exit + + // Track memory usage + current_count = attached_thread_count.fetch_add(1) + 1; + int current_peak = peak_attached_threads.load(); + while (current_count > current_peak && + !peak_attached_threads.compare_exchange_weak(current_peak, current_count)) { + // CAS retry loop + } + + __android_log_print(ANDROID_LOG_INFO, "RN_BRIDGE_PERF", + "Thread attached: %lu, total=%d, peak=%d", + (unsigned long)pthread_self(), current_count, peak_attached_threads.load()); + + // Warn if approaching limit + if (current_count > MAX_ATTACHED_THREADS * 0.8) { + __android_log_print(ANDROID_LOG_WARN, "RN_BRIDGE_PERF", + "High thread attachment count: %d/%d. Memory usage may be excessive.", + current_count, MAX_ATTACHED_THREADS); + } + + return env; } } - env->DeleteLocalRef(cls2); + + return nullptr; +} + +// Thread cleanup callback to detach threads we attached +static void thread_cleanup_callback(void* arg) { + if (tls_attached && cached_jvm) { + cached_jvm->DetachCurrentThread(); + int remaining = attached_thread_count.fetch_sub(1) - 1; + __android_log_print(ANDROID_LOG_DEBUG, "RN_BRIDGE_PERF", + "Thread detached: %lu, remaining=%d, peak=%d", + (unsigned long)pthread_self(), remaining, peak_attached_threads.load()); + } + tls_env = nullptr; + tls_attached = false; +} + +// Setup thread cleanup for proper detachment +static pthread_once_t cleanup_key_once = PTHREAD_ONCE_INIT; +static pthread_key_t cleanup_key; + +static void make_cleanup_key() { + pthread_key_create(&cleanup_key, thread_cleanup_callback); +} + +void rcv_message(const char* channel_name, const char* msg) { + auto start_time = std::chrono::high_resolution_clock::now(); + + if (!cached_class || !cached_method) { + __android_log_print(ANDROID_LOG_WARN, "RN_BRIDGE_PERF", "rcv_message: Invalid class/method pointers"); + return; + } + + // Setup thread cleanup on first use + pthread_once(&cleanup_key_once, make_cleanup_key); + pthread_setspecific(cleanup_key, (void*)1); // Mark thread for cleanup + + auto after_checks = std::chrono::high_resolution_clock::now(); + + // Fast JNI environment access + JNIEnv* env = get_jni_env(); + if (!env) { + __android_log_print(ANDROID_LOG_ERROR, "RN_BRIDGE_PERF", "Failed to get JNIEnv"); + return; + } + + auto after_thread_attach = std::chrono::high_resolution_clock::now(); + + // Create Java strings + jstring java_channel_name = env->NewStringUTF(channel_name); + jstring java_msg = env->NewStringUTF(msg); + + auto after_string_creation = std::chrono::high_resolution_clock::now(); + + // Make the call + env->CallStaticVoidMethod(cached_class, cached_method, java_channel_name, java_msg); + + auto after_jni_call = std::chrono::high_resolution_clock::now(); + + // Cleanup local references + env->DeleteLocalRef(java_channel_name); + env->DeleteLocalRef(java_msg); + + auto end_time = std::chrono::high_resolution_clock::now(); + + // Log timing breakdown + auto total_us = std::chrono::duration_cast(end_time - start_time).count(); + auto checks_us = std::chrono::duration_cast(after_checks - start_time).count(); + auto attach_us = std::chrono::duration_cast(after_thread_attach - after_checks).count(); + auto strings_us = std::chrono::duration_cast(after_string_creation - after_thread_attach).count(); + auto jni_call_us = std::chrono::duration_cast(after_jni_call - after_string_creation).count(); + auto cleanup_us = std::chrono::duration_cast(end_time - after_jni_call).count(); + + __android_log_print(ANDROID_LOG_DEBUG, "RN_BRIDGE_PERF", + "rcv_message: total=%ldμs checks=%ldμs attach=%ldμs strings=%ldμs call=%ldμs cleanup=%ldμs tls_hit=%s", + total_us, checks_us, attach_us, strings_us, jni_call_us, cleanup_us, + (attach_us < 5) ? "true" : "false"); // attach_us < 5μs indicates TLS cache hit } // Start threads to redirect stdout and stderr to logcat. @@ -138,7 +270,7 @@ int start_redirecting_stdout_stderr() { //node's libUV requires all arguments being on contiguous memory. extern "C" jint JNICALL -Java_com_janeasystems_rn_1nodejs_1mobile_RNNodeJsMobileModule_startNodeWithArguments( +Java_com_nodejsmobile_reactnative_RNNodeJsMobileModule_startNodeWithArguments( JNIEnv *env, jobject /* this */, jobjectArray arguments, @@ -153,10 +285,16 @@ Java_com_janeasystems_rn_1nodejs_1mobile_RNNodeJsMobileModule_startNodeWithArgum //argc jsize argument_count = env->GetArrayLength(arguments); + // Store references for proper cleanup + jobject* object_refs = new jobject[argument_count]; + const char** string_refs = new const char*[argument_count]; + //Compute byte size need for all arguments in contiguous memory. int c_arguments_size = 0; for (int i = 0; i < argument_count ; i++) { - c_arguments_size += strlen(env->GetStringUTFChars((jstring)env->GetObjectArrayElement(arguments, i), 0)); + object_refs[i] = env->GetObjectArrayElement(arguments, i); + string_refs[i] = env->GetStringUTFChars((jstring)object_refs[i], 0); + c_arguments_size += strlen(string_refs[i]); c_arguments_size++; // for '\0' } @@ -172,7 +310,7 @@ Java_com_janeasystems_rn_1nodejs_1mobile_RNNodeJsMobileModule_startNodeWithArgum //Populate the args_buffer and argv. for (int i = 0; i < argument_count ; i++) { - const char* current_argument = env->GetStringUTFChars((jstring)env->GetObjectArrayElement(arguments, i), 0); + const char* current_argument = string_refs[i]; //Copy current argument to its expected position in args_buffer strncpy(current_args_position, current_argument, strlen(current_argument)); @@ -186,7 +324,30 @@ Java_com_janeasystems_rn_1nodejs_1mobile_RNNodeJsMobileModule_startNodeWithArgum rn_register_bridge_cb(&rcv_message); - cacheEnvPointer=env; + // Store JavaVM for thread attachment + if (!cached_jvm) { + if (env->GetJavaVM(&cached_jvm) != JNI_OK) { + __android_log_print(ANDROID_LOG_ERROR, "RN_BRIDGE_PERF", "Failed to get JavaVM"); + return jint(-1); + } + } + + // Log thread information for debugging + pthread_t current_thread = pthread_self(); + __android_log_print(ANDROID_LOG_INFO, "RN_BRIDGE_PERF", "startNodeWithArguments thread: %lu", (unsigned long)current_thread); + + // Initialize cached JNI references for performance + if (!cached_class) { + jclass local_class = env->FindClass("com/nodejsmobile/reactnative/RNNodeJsMobileModule"); + if (local_class) { + cached_class = (jclass)env->NewGlobalRef(local_class); + cached_method = env->GetStaticMethodID(cached_class, "sendMessageToApplication", "(Ljava/lang/String;Ljava/lang/String;)V"); + env->DeleteLocalRef(local_class); + __android_log_print(ANDROID_LOG_INFO, "RN_BRIDGE_PERF", "Cached JNI references initialized on thread: %lu", (unsigned long)current_thread); + } else { + __android_log_print(ANDROID_LOG_ERROR, "RN_BRIDGE_PERF", "Failed to find RNNodeJsMobileModule class"); + } + } //Start threads to show stdout and stderr in logcat. if (option_redirectOutputToLogcat) { @@ -196,6 +357,24 @@ Java_com_janeasystems_rn_1nodejs_1mobile_RNNodeJsMobileModule_startNodeWithArgum } //Start node, with argc and argv. - return jint(callintoNode(argument_count,argv)); + jint result = jint(callintoNode(argument_count,argv)); + + // Cleanup memory to prevent leaks + for (int i = 0; i < argument_count; i++) { + env->ReleaseStringUTFChars((jstring)object_refs[i], string_refs[i]); + env->DeleteLocalRef(object_refs[i]); + } + delete[] object_refs; + delete[] string_refs; + free(args_buffer); + + // Cleanup cached JNI references + if (cached_class) { + env->DeleteGlobalRef(cached_class); + cached_class = nullptr; + cached_method = nullptr; + } + cached_jvm = nullptr; + return result; } diff --git a/android/src/main/cpp/rn-bridge.cpp b/android/src/main/cpp/rn-bridge.cpp index 54a1b9c..db10f44 100644 --- a/android/src/main/cpp/rn-bridge.cpp +++ b/android/src/main/cpp/rn-bridge.cpp @@ -62,35 +62,48 @@ class Channel { // Add a new message to the channel's queue and notify libuv to // call us back to do the actual message delivery. void queueMessage(char* msg) { + bool should_notify = false; + this->queueMutex.lock(); + bool was_empty = this->messageQueue.empty(); this->messageQueue.push(msg); + // Only notify libuv if queue was empty (no pending callback) + should_notify = was_empty && initialized; this->queueMutex.unlock(); - if (initialized) { + // Batch optimization: only send async notification if queue was empty + // This prevents multiple uv_async_send calls for rapid message bursts + if (should_notify) { uv_async_send(this->queue_uv_handle); } }; - // Process one message at the time, to simplify synchronization between - // threads and minimize lock retention. + // Process messages in batches to reduce libuv callback overhead + // and improve throughput for high-frequency messaging void flushQueue() { - char* message = nullptr; - bool empty = true; + static constexpr int MAX_BATCH_SIZE = 8; // Process up to 8 messages per callback + char* messages[MAX_BATCH_SIZE]; + int batch_count = 0; + bool has_more = false; + // Extract batch of messages under lock this->queueMutex.lock(); - if (!(this->messageQueue.empty())) { - message = this->messageQueue.front(); + while (batch_count < MAX_BATCH_SIZE && !this->messageQueue.empty()) { + messages[batch_count] = this->messageQueue.front(); this->messageQueue.pop(); - empty = this->messageQueue.empty(); + batch_count++; } + has_more = !this->messageQueue.empty(); this->queueMutex.unlock(); - if (message != nullptr) { - this->invokeNodeListener(message); - free(message); + // Process batch outside of lock to minimize lock contention + for (int i = 0; i < batch_count; i++) { + this->invokeNodeListener(messages[i]); + free(messages[i]); } - if (!empty) { + // Schedule next callback only if more messages remain + if (has_more) { uv_async_send(this->queue_uv_handle); } }; diff --git a/android/src/main/java/com/janeasystems/rn_nodejs_mobile/RNNodeJsMobileModule.java b/android/src/main/java/com/janeasystems/rn_nodejs_mobile/RNNodeJsMobileModule.java deleted file mode 100644 index 337de9b..0000000 --- a/android/src/main/java/com/janeasystems/rn_nodejs_mobile/RNNodeJsMobileModule.java +++ /dev/null @@ -1,487 +0,0 @@ - -package com.janeasystems.rn_nodejs_mobile; - -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContextBaseJavaModule; -import com.facebook.react.bridge.ReactMethod; -import com.facebook.react.modules.core.RCTNativeAppEventEmitter; -import com.facebook.react.module.annotations.ReactModule; -import com.facebook.react.bridge.WritableMap; -import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.bridge.ReadableType; -import com.facebook.react.bridge.Arguments; -import com.facebook.react.bridge.LifecycleEventListener; -import javax.annotation.Nullable; -import android.util.Log; - -import android.content.Context; -import android.content.res.AssetManager; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; -import android.content.SharedPreferences; -import android.system.Os; -import android.system.ErrnoException; - -import java.io.*; -import java.util.*; -import java.util.concurrent.Semaphore; - -@ReactModule(name = "RNNodeJsMobile") -public class RNNodeJsMobileModule extends ReactContextBaseJavaModule implements LifecycleEventListener { - - private final ReactApplicationContext reactContext; - private static final String TAG = "NODEJS-RN"; - private static final String NODEJS_PROJECT_DIR = "nodejs-project"; - private static final String NODEJS_BUILTIN_MODULES = "nodejs-builtin_modules"; - private static final String TRASH_DIR = "nodejs-project-trash"; - private static final String SHARED_PREFS = "NODEJS_MOBILE_PREFS"; - private static final String LAST_UPDATED_TIME = "NODEJS_MOBILE_APK_LastUpdateTime"; - private static final String BUILTIN_NATIVE_ASSETS_PREFIX = "nodejs-native-assets-"; - private static final String SYSTEM_CHANNEL = "_SYSTEM_"; - - private static String trashDirPath; - private static String filesDirPath; - private static String nodeJsProjectPath; - private static String builtinModulesPath; - private static String nativeAssetsPath; - - private static long lastUpdateTime = 1; - private static long previousLastUpdateTime = 0; - private static Semaphore initSemaphore = new Semaphore(1); - private static boolean initCompleted = false; - - private static AssetManager assetManager; - - // Flag to indicate if node is ready to receive app events. - private static boolean nodeIsReadyForAppEvents = false; - - static { - System.loadLibrary("nodejs-mobile-react-native-native-lib"); - System.loadLibrary("node"); - } - - // To store the instance when node is started. - public static RNNodeJsMobileModule _instance = null; - - // We just want one instance of node running in the background. - public static boolean _startedNodeAlready = false; - - public RNNodeJsMobileModule(ReactApplicationContext reactContext) { - super(reactContext); - this.reactContext = reactContext; - reactContext.addLifecycleEventListener(this); - filesDirPath = reactContext.getFilesDir().getAbsolutePath(); - - // The paths where we expect the node project assets to be at runtime. - nodeJsProjectPath = filesDirPath + "/" + NODEJS_PROJECT_DIR; - builtinModulesPath = filesDirPath + "/" + NODEJS_BUILTIN_MODULES; - trashDirPath = filesDirPath + "/" + TRASH_DIR; - nativeAssetsPath = BUILTIN_NATIVE_ASSETS_PREFIX + getCurrentABIName(); - - // Sets the TMPDIR environment to the cacheDir, to be used in Node as os.tmpdir - try { - Os.setenv("TMPDIR", reactContext.getCacheDir().getAbsolutePath(), true); - } catch (ErrnoException e) { - e.printStackTrace(); - } - - // Register the filesDir as the Node data dir. - registerNodeDataDirPath(filesDirPath); - - asyncInit(); - } - - private void asyncInit() { - if (wasAPKUpdated()) { - try { - initSemaphore.acquire(); - new Thread(new Runnable() { - @Override - public void run() { - emptyTrash(); - try { - copyNodeJsAssets(); - initCompleted = true; - } catch (IOException e) { - throw new RuntimeException("Node assets copy failed", e); - } - initSemaphore.release(); - emptyTrash(); - } - }).start(); - } catch (InterruptedException ie) { - initSemaphore.release(); - ie.printStackTrace(); - } - } else { - initCompleted = true; - } - } - - @Override - public String getName() { - return "RNNodeJsMobile"; - } - - // Extracts the option to redirect stdout and stderr to logcat - private boolean extractRedirectOutputToLogcatOption(ReadableMap options) - { - final String OPTION_NAME = "redirectOutputToLogcat"; - if( (options != null) && - options.hasKey(OPTION_NAME) && - !options.isNull(OPTION_NAME) && - (options.getType(OPTION_NAME) == ReadableType.Boolean) - ) { - return options.getBoolean(OPTION_NAME); - } else { - // By default, we redirect the process' stdout and stderr to show in logcat - return true; - } - } - - @ReactMethod - public void startNodeWithScript(String script, ReadableMap options) throws Exception { - // A New module instance may have been created due to hot reload. - _instance = this; - if(!_startedNodeAlready) { - _startedNodeAlready = true; - - final boolean redirectOutputToLogcat = extractRedirectOutputToLogcatOption(options); - final String scriptToRun = new String(script); - - new Thread(new Runnable() { - @Override - public void run() { - waitForInit(); - startNodeWithArguments(new String[]{"node", - "-e", - scriptToRun - }, - nodeJsProjectPath + ":" + builtinModulesPath, - redirectOutputToLogcat - ); - } - }).start(); - } - } - - @ReactMethod - public void startNodeProject(final String mainFileName, ReadableMap options) throws Exception { - // A New module instance may have been created due to hot reload. - _instance = this; - if(!_startedNodeAlready) { - _startedNodeAlready = true; - - final boolean redirectOutputToLogcat = extractRedirectOutputToLogcatOption(options); - - new Thread(new Runnable() { - @Override - public void run() { - waitForInit(); - startNodeWithArguments(new String[]{"node", - nodeJsProjectPath + "/" + mainFileName - }, - nodeJsProjectPath + ":" + builtinModulesPath, - redirectOutputToLogcat - ); - } - }).start(); - } - } - - @ReactMethod - public void startNodeProjectWithArgs(final String input, ReadableMap options) throws Exception { - // A New module instance may have been created due to hot reload. - _instance = this; - if(!_startedNodeAlready) { - _startedNodeAlready = true; - - List args = new ArrayList(Arrays.asList(input.split(" "))); - - String absoluteScriptPath = nodeJsProjectPath + "/" + args.get(0); - - // Remove script file name from arguments list - args.remove(0); - - final List command = new ArrayList(); - - command.add("node"); - command.add(absoluteScriptPath); - - command.addAll(args); - - final boolean redirectOutputToLogcat = extractRedirectOutputToLogcatOption(options); - - new Thread(new Runnable() { - @Override - public void run() { - waitForInit(); - startNodeWithArguments( - command.toArray(new String[0]), - nodeJsProjectPath + ":" + builtinModulesPath, - redirectOutputToLogcat - ); - } - }).start(); - } - } - - @ReactMethod - public void sendMessage(String channel, String msg) { - sendMessageToNodeChannel(channel, msg); - } - - // Sends an event through the App Event Emitter. - private void sendEvent(String eventName, - @Nullable WritableMap params) { - reactContext - .getJSModule(RCTNativeAppEventEmitter.class) - .emit(eventName, params); - } - - public static void sendMessageToApplication(String channelName, String msg) { - if (channelName.equals(SYSTEM_CHANNEL)) { - // If it's a system channel call, handle it in the plugin native side. - handleAppChannelMessage(msg); - } else { - // Otherwise, send it to React Native. - sendMessageBackToReact(channelName, msg); - } - } - - @Override - public void onHostPause() { - if (nodeIsReadyForAppEvents) { - sendMessageToNodeChannel(SYSTEM_CHANNEL, "pause"); - } - } - - @Override - public void onHostResume() { - if (nodeIsReadyForAppEvents) { - sendMessageToNodeChannel(SYSTEM_CHANNEL, "resume"); - } - } - - @Override - public void onHostDestroy() { - // Activity `onDestroy` - } - - public static void handleAppChannelMessage(String msg) { - if (msg.equals("ready-for-app-events")) { - nodeIsReadyForAppEvents=true; - } - } - - // Called from JNI when node sends a message through the bridge. - public static void sendMessageBackToReact(String channelName, String msg) { - if (_instance != null) { - final RNNodeJsMobileModule _moduleInstance = _instance; - final String _channelNameToPass = new String(channelName); - final String _msgToPass = new String(msg); - new Thread(new Runnable() { - @Override - public void run() { - WritableMap params = Arguments.createMap(); - params.putString("channelName", _channelNameToPass); - params.putString("message", _msgToPass); - _moduleInstance.sendEvent("nodejs-mobile-react-native-message", params); - } - }).start(); - } - } - - public native void registerNodeDataDirPath(String dataDir); - - public native String getCurrentABIName(); - - public native Integer startNodeWithArguments(String[] arguments, String modulesPath, boolean option_redirectOutputToLogcat); - - public native void sendMessageToNodeChannel(String channelName, String msg); - - private void waitForInit() { - if (!initCompleted) { - try { - initSemaphore.acquire(); - initSemaphore.release(); - } catch (InterruptedException ie) { - initSemaphore.release(); - ie.printStackTrace(); - } - } - } - - private boolean wasAPKUpdated() { - SharedPreferences prefs = this.reactContext.getSharedPreferences(SHARED_PREFS, Context.MODE_PRIVATE); - this.previousLastUpdateTime = prefs.getLong(LAST_UPDATED_TIME, 0); - - try { - PackageInfo packageInfo = this.reactContext.getPackageManager().getPackageInfo(this.reactContext.getPackageName(), 0); - this.lastUpdateTime = packageInfo.lastUpdateTime; - } catch (PackageManager.NameNotFoundException e) { - e.printStackTrace(); - } - return (this.lastUpdateTime != this.previousLastUpdateTime); - } - - private void saveLastUpdateTime() { - SharedPreferences prefs = this.reactContext.getSharedPreferences(SHARED_PREFS, Context.MODE_PRIVATE); - SharedPreferences.Editor editor = prefs.edit(); - editor.putLong(LAST_UPDATED_TIME, this.lastUpdateTime); - editor.commit(); - } - - private void emptyTrash() { - File trash = new File(RNNodeJsMobileModule.trashDirPath); - if (trash.exists()) { - deleteFolderRecursively(trash); - } - } - - // Recursively deletes a folder - private static boolean deleteFolderRecursively(File file) { - try { - boolean res = true; - for (File childFile : file.listFiles()) { - if (childFile.isDirectory()) { - res &= deleteFolderRecursively(childFile); - } else { - res &= childFile.delete(); - } - } - res &= file.delete(); - return res; - } catch (Exception e) { - e.printStackTrace(); - return false; - } - } - - private boolean copyNativeAssetsFrom() throws IOException { - // Load the additional asset folder and files lists - ArrayList nativeDirs = readFileFromAssets(nativeAssetsPath + "/dir.list"); - ArrayList nativeFiles = readFileFromAssets(nativeAssetsPath + "/file.list"); - // Copy additional asset files to project working folder - if (nativeFiles.size() > 0) { - Log.v(TAG, "Building folder hierarchy for " + nativeAssetsPath); - for (String dir : nativeDirs) { - new File(nodeJsProjectPath + "/" + dir).mkdirs(); - } - Log.v(TAG, "Copying assets using file list for " + nativeAssetsPath); - for (String file : nativeFiles) { - String src = nativeAssetsPath + "/" + file; - String dest = nodeJsProjectPath + "/" + file; - copyAsset(src, dest); - } - } else { - Log.v(TAG, "No assets to copy from " + nativeAssetsPath); - } - return true; - } - - - private void copyNodeJsAssets() throws IOException { - assetManager = getReactApplicationContext().getAssets(); - - // If a previous project folder is present, move it to the trash. - File nodeDirReference = new File(nodeJsProjectPath); - if (nodeDirReference.exists()) { - File trash = new File(trashDirPath); - nodeDirReference.renameTo(trash); - } - - // Load the nodejs project's folder and file lists. - ArrayList dirs = readFileFromAssets("dir.list"); - ArrayList files = readFileFromAssets("file.list"); - - // Copy the nodejs project files to the application's data path. - if (dirs.size() > 0 && files.size() > 0) { - Log.d(TAG, "Node assets copy using pre-built lists"); - for (String dir : dirs) { - new File(filesDirPath + "/" + dir).mkdirs(); - } - - for (String file : files) { - String src = file; - String dest = filesDirPath + "/" + file; - copyAsset(src, dest); - } - } else { - Log.d(TAG, "Node assets copy enumerating APK assets"); - copyAssetFolder(NODEJS_PROJECT_DIR, nodeJsProjectPath); - } - - copyNativeAssetsFrom(); - - // Do the builtin-modules copy too. - // If a previous built-in modules folder is present, delete it. - File modulesDirReference = new File(builtinModulesPath); - if (modulesDirReference.exists()) { - deleteFolderRecursively(modulesDirReference); - } - - // Copy the nodejs built-in modules to the application's data path. - copyAssetFolder("builtin_modules", builtinModulesPath); - - saveLastUpdateTime(); - Log.d(TAG, "Node assets copy completed successfully"); - } - - private ArrayList readFileFromAssets(String filename){ - ArrayList lines = new ArrayList(); - try { - BufferedReader reader = new BufferedReader(new InputStreamReader(assetManager.open(filename))); - String line = reader.readLine(); - while (line != null) { - lines.add(line); - line = reader.readLine(); - } - reader.close(); - } catch (FileNotFoundException e) { - Log.d(TAG, "File not found: " + filename); - } catch (IOException e) { - lines = new ArrayList(); - e.printStackTrace(); - } - return lines; - } - - // Recursively copies contents of a folder in assets to a path - private static void copyAssetFolder(String fromAssetPath, String toPath) throws IOException { - String[] files = assetManager.list(fromAssetPath); - boolean res = true; - - if (files.length == 0) { - // If it's a file, it won't have any assets "inside" it. - copyAsset(fromAssetPath, toPath); - } else { - new File(toPath).mkdirs(); - for (String file : files) - copyAssetFolder(fromAssetPath + "/" + file, toPath + "/" + file); - } - } - - private static void copyAsset(String fromAssetPath, String toPath) throws IOException { - InputStream in = null; - OutputStream out = null; - in = assetManager.open(fromAssetPath); - new File(toPath).createNewFile(); - out = new FileOutputStream(toPath); - copyFile(in, out); - in.close(); - in = null; - out.flush(); - out.close(); - out = null; - } - - // Copy file from an input stream to an output stream - private static void copyFile(InputStream in, OutputStream out) throws IOException { - byte[] buffer = new byte[1024]; - int read; - while ((read = in.read(buffer)) != -1) { - out.write(buffer, 0, read); - } - } -} diff --git a/android/src/main/java/com/janeasystems/rn_nodejs_mobile/RNNodeJsMobilePackage.java b/android/src/main/java/com/janeasystems/rn_nodejs_mobile/RNNodeJsMobilePackage.java deleted file mode 100644 index 1d3347d..0000000 --- a/android/src/main/java/com/janeasystems/rn_nodejs_mobile/RNNodeJsMobilePackage.java +++ /dev/null @@ -1,28 +0,0 @@ - -package com.janeasystems.rn_nodejs_mobile; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import com.facebook.react.ReactPackage; -import com.facebook.react.bridge.NativeModule; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.uimanager.ViewManager; -import com.facebook.react.bridge.JavaScriptModule; -public class RNNodeJsMobilePackage implements ReactPackage { - @Override - public List createNativeModules(ReactApplicationContext reactContext) { - return Arrays.asList(new RNNodeJsMobileModule(reactContext)); - } - - // Deprecated from RN 0.47 - public List> createJSModules() { - return Collections.emptyList(); - } - - @Override - public List createViewManagers(ReactApplicationContext reactContext) { - return Collections.emptyList(); - } -} \ No newline at end of file diff --git a/android/src/main/java/com/nodejsmobile/reactnative/RNNodeJsMobileModule.kt b/android/src/main/java/com/nodejsmobile/reactnative/RNNodeJsMobileModule.kt new file mode 100644 index 0000000..47746de --- /dev/null +++ b/android/src/main/java/com/nodejsmobile/reactnative/RNNodeJsMobileModule.kt @@ -0,0 +1,530 @@ +package com.nodejsmobile.reactnative + +import android.util.Log +import android.content.Context +import android.content.res.AssetManager +import android.content.pm.PackageManager +import android.system.Os +import android.system.ErrnoException +import java.io.File +import java.io.IOException +import java.io.FileNotFoundException +import kotlinx.coroutines.launch +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withContext +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.channels.Channel +import java.util.concurrent.Executors +import kotlinx.coroutines.asCoroutineDispatcher + + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.exception.Exceptions +import expo.modules.kotlin.records.Record +import expo.modules.kotlin.records.Field + +class NodeJsOptions : Record { + @Field + val redirectOutputToLogcat: Boolean = true +} + +class RNNodeJsMobileModule : Module() { + private val reactContext: Context + get() = appContext.reactContext ?: throw Exceptions.ReactContextLost() + + private val filesDir: File + get() = reactContext.filesDir + + private val trashDir: File + get() = File(filesDir, TRASH_DIR) + + // The directories where we expect the node project assets to be at runtime. + private val nodeJsProjectDir: File + get() = File(filesDir, NODEJS_PROJECT_DIR) + + private val builtinModulesDir: File + get() = File(filesDir, NODEJS_BUILTIN_MODULES) + + private val nativeAssetsPath: String + get() = "$BUILTIN_NATIVE_ASSETS_PREFIX${getCurrentABIName()}" + private var lastUpdateTime: Long = 1 + private var previousLastUpdateTime: Long = 0 + private val moduleScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val fileCopySemaphore = Semaphore(8) // Limit concurrent file operations + private val initCompletionDeferred = CompletableDeferred() + + // Dedicated thread for Node.js events - ensures order and keeps off main thread + private val nodeEventDispatcher = Executors.newSingleThreadExecutor { r -> + Thread(r, "NodeJS-Event-Dispatcher").apply { + priority = Thread.MAX_PRIORITY // High priority for low latency + } + }.asCoroutineDispatcher() + + private val nodeEventScope = CoroutineScope(SupervisorJob() + nodeEventDispatcher) + + // Channel for lock-free message passing from Node.js thread to event dispatcher + private val messageChannel = Channel(Channel.UNLIMITED) + + private val assetManager: AssetManager + get() = reactContext.assets + + private data class MessageData( + val channelName: String, + val message: String, + val timestamp: Long + ) + + companion object { + const val EVENT_NAME = "nodejs-mobile-react-native-message" + + private const val TAG = "NODEJS-RN" + private const val NODEJS_PROJECT_DIR = "nodejs-project" + private const val NODEJS_BUILTIN_MODULES = "nodejs-builtin_modules" + private const val TRASH_DIR = "nodejs-project-trash" + private const val SHARED_PREFS = "NODEJS_MOBILE_PREFS" + private const val LAST_UPDATED_TIME = "NODEJS_MOBILE_APK_LastUpdateTime" + private const val BUILTIN_NATIVE_ASSETS_PREFIX = "nodejs-native-assets-" + private const val SYSTEM_CHANNEL = "_SYSTEM_" + + // To store the instance when node is started. + var instance: RNNodeJsMobileModule? = null + private set + // We just want one instance of node running in the background. + var startedNodeAlready = false + private set + // Flag to indicate if node is ready to receive app events. + var nodeIsReadyForAppEvents = false + + init { + System.loadLibrary("nodejs-mobile-react-native-native-lib") + System.loadLibrary("node") + } + + @JvmStatic + fun sendMessageToApplication(channelName: String, msg: String) { + val startTime = System.nanoTime() + + if (channelName == SYSTEM_CHANNEL) { + handleAppChannelMessage(msg) + Log.d("RN_BRIDGE_PERF", "System message handled: ${(System.nanoTime() - startTime) / 1000}μs") + } else { + // Direct dispatch to React Native - no main thread involved + instance?.dispatchMessage(channelName, msg, startTime) + ?: Log.w(TAG, "Module instance is null, message dropped") + } + } + + fun handleAppChannelMessage(msg: String) { + if (msg == "ready-for-app-events") { + nodeIsReadyForAppEvents = true + } + } + } + + private fun asyncInit() { + moduleScope.launch { + try { + if (wasAPKUpdated()) { + // Clear any existing trash before starting + trashDir.deleteRecursively() + try { + copyNodeJsAssets() + } catch (e: IOException) { + initCompletionDeferred.completeExceptionally(RuntimeException("Node assets copy failed", e)) + return@launch + } + // Clear trash after successful copy + trashDir.deleteRecursively() + } + // Signal completion - whether we copied assets or not + initCompletionDeferred.complete(Unit) + } catch (e: Exception) { + initCompletionDeferred.completeExceptionally(e) + } + } + } + + + override fun definition() = ModuleDefinition { + Name("RNNodeJsMobile") + + OnCreate { + // Sets the TMPDIR environment to the cacheDir, to be used in Node as os.tmpdir + try { + Os.setenv("TMPDIR", reactContext.cacheDir.absolutePath, true) + } catch (e: ErrnoException) { + e.printStackTrace() + } + + // Register the filesDir as the Node data dir. + registerNodeDataDirPath(filesDir.absolutePath) + + // Start message processor on dedicated thread + startMessageProcessor() + + asyncInit() + } + + OnActivityEntersBackground { + // When the activity goes to background, we send a message to node. + if (nodeIsReadyForAppEvents) { + sendMessage(SYSTEM_CHANNEL, "pause") + } + } + + OnActivityEntersForeground { + // When the activity comes back to foreground, we send a message to node. + if (nodeIsReadyForAppEvents) { + sendMessage(SYSTEM_CHANNEL, "resume") + } + } + + OnDestroy { + // Cancel all coroutines when the module is destroyed + moduleScope.cancel() + messageChannel.cancel() + nodeEventScope.cancel() + nodeEventDispatcher.close() + } + + Constants("EVENT_NAME" to EVENT_NAME) + + Events(EVENT_NAME) + + // Expose the methods to React Native + Function("startNodeWithScript") { script: String, options: NodeJsOptions -> + startNodeWithScript(script, options) + } + Function("startNodeProject") { mainFileName: String, options: NodeJsOptions -> + startNodeProject(mainFileName, options) + } + Function("startNodeProjectWithArgs") { input: String, options: NodeJsOptions -> + startNodeProjectWithArgs(input, options) + } + Function("sendMessage") { channel: String, msg: String -> + sendMessage(channel, msg) + } + } + + // Called from JNI thread - dispatches message off main thread + private fun dispatchMessage(channelName: String, msg: String, startTime: Long) { + val enqueuedTime = System.nanoTime() + + // Try to send without blocking - should always succeed with UNLIMITED capacity + val offered = messageChannel.trySend( + MessageData(channelName, msg, startTime) + ).isSuccess + + if (!offered) { + // Fallback - shouldn't happen with unlimited channel + Log.w(TAG, "Message channel full, using coroutine dispatch") + nodeEventScope.launch { + messageChannel.send(MessageData(channelName, msg, startTime)) + } + } + + Log.d("RN_BRIDGE_PERF", + "Message dispatched: enqueue=${(System.nanoTime() - enqueuedTime) / 1000}μs thread=${Thread.currentThread().name}") + } + + // Message processor running on dedicated thread + private fun startMessageProcessor() { + nodeEventScope.launch { + Log.i(TAG, "Message processor started on thread: ${Thread.currentThread().name}") + + for (msg in messageChannel) { + processMessage(msg) + } + + Log.i(TAG, "Message processor stopped") + } + } + + private fun processMessage(data: MessageData) { + val processStart = System.nanoTime() + + try { + // sendEvent is thread-safe and handles JS thread switching internally + val params = mapOf( + "channelName" to data.channelName, + "message" to data.message + ) + +val sendEventStart = System.nanoTime() + sendEvent(EVENT_NAME, params) +val sendEventTime = System.nanoTime() - sendEventStart + + val totalLatency = System.nanoTime() - data.timestamp + val processTime = System.nanoTime() - processStart + + Log.d("RN_BRIDGE_PERF", + "Message sent: sendEvent=${sendEventTime / 1000}μs process=${processTime / 1000}μs latency=${totalLatency / 1000}μs thread=${Thread.currentThread().name}") + } catch (e: Exception) { + Log.e(TAG, "Error sending event: ${e.message}", e) + } + } + + fun startNodeWithScript(script: String, options: NodeJsOptions) { + // A New module instance may have been created due to hot reload. + instance = this + + if (!startedNodeAlready) { + startedNodeAlready = true + + val redirectOutputToLogcat = options.redirectOutputToLogcat + + moduleScope.launch { + waitForInit() + startNodeWithArguments( + arrayOf("node", "-e", script), + "${nodeJsProjectDir.absolutePath}:${builtinModulesDir.absolutePath}", + redirectOutputToLogcat + ) + } + } + } + + fun startNodeProject(mainFileName: String, options: NodeJsOptions) { + // A New module instance may have been created due to hot reload. + instance = this + + if (!startedNodeAlready) { + startedNodeAlready = true + + val redirectOutputToLogcat = options.redirectOutputToLogcat + + moduleScope.launch { + waitForInit() + startNodeWithArguments( + arrayOf("node", File(nodeJsProjectDir, mainFileName).absolutePath), + "${nodeJsProjectDir.absolutePath}:${builtinModulesDir.absolutePath}", + redirectOutputToLogcat + ) + } + } + } + + fun startNodeProjectWithArgs(input: String, options: NodeJsOptions) { + // A New module instance may have been created due to hot reload. + instance = this + + if (!startedNodeAlready) { + startedNodeAlready = true + + val args = input.split(" ").toMutableList() + val absoluteScriptPath = File(nodeJsProjectDir, args[0]).absolutePath + + // Remove script file name from arguments list + args.removeAt(0) + + val command = mutableListOf().apply { + add("node") + add(absoluteScriptPath) + addAll(args) + } + + val redirectOutputToLogcat = options.redirectOutputToLogcat + + moduleScope.launch { + waitForInit() + startNodeWithArguments( + command.toTypedArray(), + "${nodeJsProjectDir.absolutePath}:${builtinModulesDir.absolutePath}", + redirectOutputToLogcat + ) + } + } + } + + fun sendMessage(channel: String, msg: String) { + sendMessageToNodeChannel(channel, msg) + } + + external fun registerNodeDataDirPath(dataDir: String) + external fun getCurrentABIName(): String + external fun startNodeWithArguments( + arguments: Array, + modulesPath: String, + redirectOutputToLogcat: Boolean + ): Int + external fun sendMessageToNodeChannel(channelName: String, msg: String) + + // Recursively copies contents of a folder in assets to a path with parallel processing + private suspend fun copyAssetFolder(fromAssetPath: String, toDir: File): Unit = withContext(Dispatchers.IO) { + val files = assetManager.list(fromAssetPath) ?: return@withContext + + if (files.isEmpty()) { + // If it's a file, it won't have any assets "inside" it. + fileCopySemaphore.withPermit { + copyAsset(fromAssetPath, toDir) + } + } else { + toDir.mkdirs() + // Process subdirectories and files in parallel + val copyJobs = files.map { file -> + async { + this@RNNodeJsMobileModule.copyAssetFolder("$fromAssetPath/$file", File(toDir, file)) + } + } + copyJobs.awaitAll() + } + } + + private suspend fun copyAsset(fromAssetPath: String, toFile: File) = withContext(Dispatchers.IO) { + try { + assetManager.open(fromAssetPath).use { inputStream -> + toFile.also { it.parentFile?.mkdirs() }.outputStream().buffered().use { outputStream -> + inputStream.copyTo(outputStream, DEFAULT_BUFFER_SIZE) + } + } + } catch (e: IOException) { + e.printStackTrace() + } + } + + + private suspend fun waitForInit() { + // Wait for initialization to complete, handling both success and failure + initCompletionDeferred.await() + } + + private suspend fun wasAPKUpdated(): Boolean = withContext(Dispatchers.IO) { + val prefs = reactContext.getSharedPreferences(SHARED_PREFS, Context.MODE_PRIVATE) + previousLastUpdateTime = prefs.getLong(LAST_UPDATED_TIME, 0) + + try { + val packageInfo = reactContext.packageManager.getPackageInfo(reactContext.packageName, 0) + lastUpdateTime = packageInfo.lastUpdateTime + } catch (e: PackageManager.NameNotFoundException) { + e.printStackTrace() + } + return@withContext lastUpdateTime != previousLastUpdateTime + } + + private suspend fun saveLastUpdateTime() = withContext(Dispatchers.IO) { + val prefs = reactContext.getSharedPreferences(SHARED_PREFS, Context.MODE_PRIVATE) + prefs.edit().apply { + putLong(LAST_UPDATED_TIME, lastUpdateTime) + apply() + } + } + + + private suspend fun copyNativeAssetsFrom(): Boolean = withContext(Dispatchers.IO) { + return@withContext try { + // Load the additional asset folder and files lists in parallel + val nativeDirsDeferred = async { readFileFromAssets("$nativeAssetsPath/dir.list") } + val nativeFilesDeferred = async { readFileFromAssets("$nativeAssetsPath/file.list") } + + val nativeDirs = nativeDirsDeferred.await() + val nativeFiles = nativeFilesDeferred.await() + + // Copy additional asset files to project working folder + if (nativeFiles.isNotEmpty()) { + Log.v(TAG, "Building folder hierarchy for $nativeAssetsPath") + nativeDirs.forEach { dir -> + File(nodeJsProjectDir, dir).mkdirs() + } + Log.v(TAG, "Copying assets using file list for $nativeAssetsPath") + + // Copy files in parallel with limited concurrency + val copyJobs = nativeFiles.map { file -> + async { + fileCopySemaphore.withPermit { + val src = "$nativeAssetsPath/$file" + copyAsset(src, File(nodeJsProjectDir, file)) + } + } + } + copyJobs.awaitAll() + } else { + Log.v(TAG, "No assets to copy from $nativeAssetsPath") + } + true + } catch (e: IOException) { + e.printStackTrace() + false + } + } + + private suspend fun copyNodeJsAssets() = withContext(Dispatchers.IO) { + // If a previous project folder is present, move it to the trash. + if (nodeJsProjectDir.exists()) { + nodeJsProjectDir.renameTo(trashDir) + } + + // Load the nodejs project's folder and file lists in parallel + val dirsDeferred = async { readFileFromAssets("dir.list") } + val filesDeferred = async { readFileFromAssets("file.list") } + + val dirs = dirsDeferred.await() + val files = filesDeferred.await() + + // Copy the nodejs project files to the application's data path. + if (dirs.isNotEmpty() && files.isNotEmpty()) { + Log.d(TAG, "Node assets copy using pre-built lists") + + // Create directories first (sequential - they're dependencies) + dirs.forEach { dir -> + File(filesDir, dir).mkdirs() + } + + // Copy files in parallel with limited concurrency + val fileCopyJobs = files.map { file -> + async { + fileCopySemaphore.withPermit { + copyAsset(file, File(filesDir, file)) + } + } + } + fileCopyJobs.awaitAll() + } else { + Log.d(TAG, "Node assets copy enumerating APK assets") + copyAssetFolder(NODEJS_PROJECT_DIR, nodeJsProjectDir) + } + + // Run native assets and builtin modules copy in parallel + val nativeAssetsJob = async { copyNativeAssetsFrom() } + val builtinModulesJob = async { + // Do the builtin-modules copy too. + // Delete any previous built-in modules folder + builtinModulesDir.deleteRecursively() + // Copy the nodejs built-in modules to the application's data path. + copyAssetFolder("builtin_modules", builtinModulesDir) + } + + nativeAssetsJob.await() + builtinModulesJob.await() + + saveLastUpdateTime() + Log.d(TAG, "Node assets copy completed successfully") + } + + private suspend fun readFileFromAssets(filename: String): List = withContext(Dispatchers.IO) { + return@withContext try { + assetManager.open(filename).use { inputStream -> + inputStream.bufferedReader().useLines { lines -> + lines.toList() + } + } + } catch (_: FileNotFoundException) { + Log.d(TAG, "File not found: $filename") + emptyList() + } catch (e: IOException) { + e.printStackTrace() + emptyList() + } catch (e: Exception) { + // Fallback for any other unexpected exceptions + Log.e(TAG, "Unexpected error reading file: $filename", e) + emptyList() + } + } +} diff --git a/benchmark/.gitattributes b/benchmark/.gitattributes new file mode 100644 index 0000000..d42ff18 --- /dev/null +++ b/benchmark/.gitattributes @@ -0,0 +1 @@ +*.pbxproj -text diff --git a/benchmark/.gitignore b/benchmark/.gitignore new file mode 100644 index 0000000..95bde9c --- /dev/null +++ b/benchmark/.gitignore @@ -0,0 +1,42 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +# native folders are created via expo prebuild +android/ +ios/ + +nodejs-assets/build-native-modules-MacOS-helper-script* diff --git a/benchmark/App.js b/benchmark/App.js new file mode 100644 index 0000000..e10a26d --- /dev/null +++ b/benchmark/App.js @@ -0,0 +1,85 @@ +import { StatusBar } from "expo-status-bar"; +import React from "react"; +import { StyleSheet, Text, View, Button } from "react-native"; +import nodejs from "nodejs-mobile-react-native"; +import { faker } from "@faker-js/faker"; + +const MSG_COUNT = 1000; +const fixtureStart = Date.now(); +faker.seed("nodejs-mobile-test-messages"); +const MESSAGE_FIXTURES = Array.from({ length: MSG_COUNT }, createRandomUser); +const fixtureTime = Date.now() - fixtureStart; + +export default function App() { + const [output, setOutput] = React.useState(""); + const timerRef = React.useRef(0); + + React.useEffect(() => { + nodejs.start("main.js"); + if (!timerRef.current) { + timerRef.current = Date.now(); + } + const subscription = nodejs.channel.addListener( + "message", + (msg) => { + if (msg === "initialized") { + const elapsed = Date.now() - timerRef.current; + timerRef.current = Date.now(); + setOutput(`Node.js initialized in ${elapsed} ms`); + } else if (msg.id === MSG_COUNT) { + setOutput( + (prev) => + `${prev}\nReceived ${msg.id} messages in ${ + Date.now() - timerRef.current + } ms` + ); + } + }, + this + ); + timerRef.current = Date.now(); + for (const msg of MESSAGE_FIXTURES) { + nodejs.channel.send(msg); + } + return () => { + subscription.remove(); + timerRef.current = 0; + setOutput(""); + }; + }, []); + + return ( + + Fixture generation took {fixtureTime} ms + {output} +