diff --git a/android/build.gradle b/android/build.gradle index 705fddc..66ebf6b 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -105,4 +105,5 @@ dependencies { implementation "com.facebook.react:react-android" implementation "com.facebook.react:hermes-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "com.squareup.okhttp3:okhttp:4.9.2" } diff --git a/android/src/main/java/com/webworker/WebWorkerNative.kt b/android/src/main/java/com/webworker/WebWorkerNative.kt index 6b96658..2cf8cc8 100644 --- a/android/src/main/java/com/webworker/WebWorkerNative.kt +++ b/android/src/main/java/com/webworker/WebWorkerNative.kt @@ -35,6 +35,19 @@ object WebWorkerNative { /** Called for worker console output (log, error, warn, info) */ fun onConsole(workerId: String, level: String, message: String) + + /** Called for worker network requests */ + fun onFetch( + workerId: String, + requestId: String, + url: String, + method: String, + headerKeys: Array, + headerValues: Array, + body: ByteArray?, + timeout: Double, + redirect: String + ) } /** @@ -100,6 +113,21 @@ object WebWorkerNative { return nativeIsWorkerRunning(workerId) } + /** + * Send fetch response back to C++ + */ + fun handleFetchResponse( + workerId: String, + requestId: String, + status: Int, + headerKeys: Array, + headerValues: Array, + body: ByteArray?, + error: String? + ) { + nativeHandleFetchResponse(workerId, requestId, status, headerKeys, headerValues, body, error) + } + /** * Clean up all workers and release resources. */ @@ -119,4 +147,13 @@ object WebWorkerNative { private external fun nativeHasWorker(workerId: String): Boolean private external fun nativeIsWorkerRunning(workerId: String): Boolean private external fun nativeCleanup() -} + private external fun nativeHandleFetchResponse( + workerId: String, + requestId: String, + status: Int, + headerKeys: Array, + headerValues: Array, + body: ByteArray?, + error: String? + ) +} \ No newline at end of file diff --git a/android/src/main/java/com/webworker/WebworkerModule.kt b/android/src/main/java/com/webworker/WebworkerModule.kt index 1e6cab7..57ea56e 100644 --- a/android/src/main/java/com/webworker/WebworkerModule.kt +++ b/android/src/main/java/com/webworker/WebworkerModule.kt @@ -5,6 +5,17 @@ import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.module.annotations.ReactModule +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.io.IOException +import java.util.concurrent.TimeUnit /** * React Native TurboModule for WebWorker support. @@ -16,6 +27,8 @@ import com.facebook.react.module.annotations.ReactModule class WebworkerModule(reactContext: ReactApplicationContext) : NativeWebworkerSpec(reactContext), WebWorkerNative.WorkerCallback { + private val client = OkHttpClient() + init { // Initialize the native core with this module as the callback receiver WebWorkerNative.initialize(this) @@ -52,6 +65,91 @@ class WebworkerModule(reactContext: ReactApplicationContext) : }) } + override fun onFetch( + workerId: String, + requestId: String, + url: String, + method: String, + headerKeys: Array, + headerValues: Array, + body: ByteArray?, + timeout: Double, + redirect: String + ) { + val requestBuilder = Request.Builder() + .url(url) + + // Headers + var contentType: MediaType? = null + for (i in headerKeys.indices) { + val key = headerKeys[i] + val value = headerValues[i] + requestBuilder.addHeader(key, value) + if (key.equals("Content-Type", ignoreCase = true)) { + contentType = value.toMediaTypeOrNull() + } + } + + // Body + val requestBody = if (body != null && body.isNotEmpty()) { + body.toRequestBody(contentType) + } else if (method.equals("POST", ignoreCase = true) || method.equals("PUT", ignoreCase = true) || method.equals("PATCH", ignoreCase = true)) { + ByteArray(0).toRequestBody(null) + } else { + null + } + + requestBuilder.method(method, requestBody) + + // Configure client based on options + val requestClient = if (timeout > 0 || redirect != "follow") { + val builder = client.newBuilder() + if (timeout > 0) { + val timeoutMs = timeout.toLong() + builder.callTimeout(timeoutMs, TimeUnit.MILLISECONDS) + builder.readTimeout(timeoutMs, TimeUnit.MILLISECONDS) + builder.connectTimeout(timeoutMs, TimeUnit.MILLISECONDS) + } + if (redirect == "error" || redirect == "manual") { + builder.followRedirects(false) + builder.followSslRedirects(false) + } + builder.build() + } else { + client + } + + requestClient.newCall(requestBuilder.build()).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + WebWorkerNative.handleFetchResponse( + workerId, requestId, 0, emptyArray(), emptyArray(), null, e.message + ) + } + + override fun onResponse(call: Call, response: Response) { + val responseBody = response.body?.bytes() + val headers = response.headers + val keys = mutableListOf() + val values = mutableListOf() + + for (i in 0 until headers.size) { + keys.add(headers.name(i)) + values.add(headers.value(i)) + } + + WebWorkerNative.handleFetchResponse( + workerId, + requestId, + response.code, + keys.toTypedArray(), + values.toTypedArray(), + responseBody, + null + ) + } + }) + } + // ============================================================================ // TurboModule Methods - mirror iOS implementation // ============================================================================ diff --git a/android/src/main/jni/WebWorkerJNI.cpp b/android/src/main/jni/WebWorkerJNI.cpp index 6e82b35..5de5cc6 100644 --- a/android/src/main/jni/WebWorkerJNI.cpp +++ b/android/src/main/jni/WebWorkerJNI.cpp @@ -2,15 +2,13 @@ // WebWorkerJNI.cpp // Thin JNI wrapper around shared C++ WebWorkerCore // -// This is the Android equivalent of ios/Webworker.mm - a minimal platform binding -// that delegates all logic to the shared C++ core. -// #include #include #include #include #include "WebWorkerCore.h" +#include "networking/FetchTypes.h" #define LOG_TAG "WebWorkerJNI" #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) @@ -23,6 +21,7 @@ static jobject gCallbackRef = nullptr; static jmethodID gOnMessageMethod = nullptr; static jmethodID gOnErrorMethod = nullptr; static jmethodID gOnConsoleMethod = nullptr; +static jmethodID gOnFetchMethod = nullptr; // Helper to convert jstring to std::string static std::string jstringToString(JNIEnv* env, jstring jstr) { @@ -53,13 +52,10 @@ static JNIEnv* getJNIEnv() { static void setupCallbacks() { if (!gCore) return; - // Message callback - called when worker posts a message to host + // Message callback gCore->setMessageCallback([](const std::string& workerId, const std::string& message) { JNIEnv* env = getJNIEnv(); - if (env == nullptr || gCallbackRef == nullptr || gOnMessageMethod == nullptr) { - LOGE("Cannot invoke onMessage callback - JNI not ready"); - return; - } + if (env == nullptr || gCallbackRef == nullptr || gOnMessageMethod == nullptr) return; jstring jWorkerId = env->NewStringUTF(workerId.c_str()); jstring jMessage = env->NewStringUTF(message.c_str()); @@ -75,14 +71,10 @@ static void setupCallbacks() { } }); - // Console callback - called for worker console.log/error/etc + // Console callback gCore->setConsoleCallback([](const std::string& workerId, const std::string& level, const std::string& message) { - LOGI("[Worker %s] [%s] %s", workerId.c_str(), level.c_str(), message.c_str()); - JNIEnv* env = getJNIEnv(); - if (env == nullptr || gCallbackRef == nullptr || gOnConsoleMethod == nullptr) { - return; - } + if (env == nullptr || gCallbackRef == nullptr || gOnConsoleMethod == nullptr) return; jstring jWorkerId = env->NewStringUTF(workerId.c_str()); jstring jLevel = env->NewStringUTF(level.c_str()); @@ -100,14 +92,10 @@ static void setupCallbacks() { } }); - // Error callback - called when worker encounters an error + // Error callback gCore->setErrorCallback([](const std::string& workerId, const std::string& error) { - LOGE("[Worker %s] ERROR: %s", workerId.c_str(), error.c_str()); - JNIEnv* env = getJNIEnv(); - if (env == nullptr || gCallbackRef == nullptr || gOnErrorMethod == nullptr) { - return; - } + if (env == nullptr || gCallbackRef == nullptr || gOnErrorMethod == nullptr) return; jstring jWorkerId = env->NewStringUTF(workerId.c_str()); jstring jError = env->NewStringUTF(error.c_str()); @@ -122,6 +110,56 @@ static void setupCallbacks() { env->ExceptionClear(); } }); + + // Fetch callback + gCore->setFetchCallback([](const std::string& workerId, const webworker::FetchRequest& request) { + JNIEnv* env = getJNIEnv(); + if (env == nullptr || gCallbackRef == nullptr || gOnFetchMethod == nullptr) return; + + jstring jWorkerId = env->NewStringUTF(workerId.c_str()); + jstring jRequestId = env->NewStringUTF(request.requestId.c_str()); + jstring jUrl = env->NewStringUTF(request.url.c_str()); + jstring jMethod = env->NewStringUTF(request.method.c_str()); + jstring jRedirect = env->NewStringUTF(request.redirect.c_str()); + jdouble jTimeout = (jdouble)request.timeout; + + jclass strClass = env->FindClass("java/lang/String"); + jobjectArray jHeaderKeys = env->NewObjectArray(request.headers.size(), strClass, nullptr); + jobjectArray jHeaderValues = env->NewObjectArray(request.headers.size(), strClass, nullptr); + + int i = 0; + for (const auto& header : request.headers) { + jstring key = env->NewStringUTF(header.first.c_str()); + jstring value = env->NewStringUTF(header.second.c_str()); + env->SetObjectArrayElement(jHeaderKeys, i, key); + env->SetObjectArrayElement(jHeaderValues, i, value); + env->DeleteLocalRef(key); + env->DeleteLocalRef(value); + i++; + } + + jbyteArray jBody = nullptr; + if (!request.body.empty()) { + jBody = env->NewByteArray(request.body.size()); + env->SetByteArrayRegion(jBody, 0, request.body.size(), (const jbyte*)request.body.data()); + } + + env->CallVoidMethod(gCallbackRef, gOnFetchMethod, jWorkerId, jRequestId, jUrl, jMethod, jHeaderKeys, jHeaderValues, jBody, jTimeout, jRedirect); + + env->DeleteLocalRef(jWorkerId); + env->DeleteLocalRef(jRequestId); + env->DeleteLocalRef(jUrl); + env->DeleteLocalRef(jMethod); + env->DeleteLocalRef(jRedirect); + env->DeleteLocalRef(jHeaderKeys); + env->DeleteLocalRef(jHeaderValues); + if (jBody) env->DeleteLocalRef(jBody); + + if (env->ExceptionCheck()) { + env->ExceptionDescribe(); + env->ExceptionClear(); + } + }); } extern "C" { @@ -137,41 +175,27 @@ Java_com_webworker_WebWorkerNative_nativeInit( jobject thiz, jobject callback ) { - LOGI("Initializing WebWorkerCore"); - - // Clean up any existing core if (gCore) { gCore->terminateAll(); gCore.reset(); } - - // Clean up old callback reference if (gCallbackRef != nullptr) { env->DeleteGlobalRef(gCallbackRef); gCallbackRef = nullptr; } - // Create new core gCore = std::make_shared(); - // Store callback reference and get method IDs if (callback != nullptr) { gCallbackRef = env->NewGlobalRef(callback); - jclass callbackClass = env->GetObjectClass(callback); gOnMessageMethod = env->GetMethodID(callbackClass, "onMessage", "(Ljava/lang/String;Ljava/lang/String;)V"); gOnErrorMethod = env->GetMethodID(callbackClass, "onError", "(Ljava/lang/String;Ljava/lang/String;)V"); gOnConsoleMethod = env->GetMethodID(callbackClass, "onConsole", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V"); - - if (gOnMessageMethod == nullptr || gOnErrorMethod == nullptr || gOnConsoleMethod == nullptr) { - LOGE("Failed to get callback method IDs"); - } + gOnFetchMethod = env->GetMethodID(callbackClass, "onFetch", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;[Ljava/lang/String;[BDLjava/lang/String;)V"); } - // Setup callbacks to route events to Java setupCallbacks(); - - LOGI("WebWorkerCore initialized successfully"); } JNIEXPORT jstring JNICALL @@ -181,21 +205,13 @@ Java_com_webworker_WebWorkerNative_nativeCreateWorker( jstring workerId, jstring script ) { - if (!gCore) { - jclass exClass = env->FindClass("java/lang/RuntimeException"); - env->ThrowNew(exClass, "WebWorkerCore not initialized"); - return nullptr; - } - + if (!gCore) return nullptr; std::string id = jstringToString(env, workerId); std::string scriptStr = jstringToString(env, script); - try { std::string resultId = gCore->createWorker(id, scriptStr); - LOGI("Created worker: %s", resultId.c_str()); return env->NewStringUTF(resultId.c_str()); } catch (const std::exception& e) { - LOGE("Failed to create worker: %s", e.what()); jclass exClass = env->FindClass("java/lang/RuntimeException"); env->ThrowNew(exClass, e.what()); return nullptr; @@ -208,18 +224,8 @@ Java_com_webworker_WebWorkerNative_nativeTerminateWorker( jobject thiz, jstring workerId ) { - if (!gCore) { - return JNI_FALSE; - } - - std::string id = jstringToString(env, workerId); - - bool success = gCore->terminateWorker(id); - if (success) { - LOGI("Terminated worker: %s", id.c_str()); - } - - return success ? JNI_TRUE : JNI_FALSE; + if (!gCore) return JNI_FALSE; + return gCore->terminateWorker(jstringToString(env, workerId)) ? JNI_TRUE : JNI_FALSE; } JNIEXPORT jboolean JNICALL @@ -229,15 +235,8 @@ Java_com_webworker_WebWorkerNative_nativePostMessage( jstring workerId, jstring message ) { - if (!gCore) { - return JNI_FALSE; - } - - std::string id = jstringToString(env, workerId); - std::string msg = jstringToString(env, message); - - bool success = gCore->postMessage(id, msg); - return success ? JNI_TRUE : JNI_FALSE; + if (!gCore) return JNI_FALSE; + return gCore->postMessage(jstringToString(env, workerId), jstringToString(env, message)) ? JNI_TRUE : JNI_FALSE; } JNIEXPORT jstring JNICALL @@ -247,20 +246,11 @@ Java_com_webworker_WebWorkerNative_nativeEvalScript( jstring workerId, jstring script ) { - if (!gCore) { - jclass exClass = env->FindClass("java/lang/RuntimeException"); - env->ThrowNew(exClass, "WebWorkerCore not initialized"); - return nullptr; - } - - std::string id = jstringToString(env, workerId); - std::string scriptStr = jstringToString(env, script); - + if (!gCore) return nullptr; try { - std::string result = gCore->evalScript(id, scriptStr); + std::string result = gCore->evalScript(jstringToString(env, workerId), jstringToString(env, script)); return env->NewStringUTF(result.c_str()); } catch (const std::exception& e) { - LOGE("Failed to evaluate script: %s", e.what()); jclass exClass = env->FindClass("java/lang/RuntimeException"); env->ThrowNew(exClass, e.what()); return nullptr; @@ -272,21 +262,14 @@ Java_com_webworker_WebWorkerNative_nativeCleanup( JNIEnv* env, jobject thiz ) { - LOGI("Cleaning up WebWorkerCore"); - if (gCore) { gCore->terminateAll(); gCore.reset(); } - if (gCallbackRef != nullptr) { env->DeleteGlobalRef(gCallbackRef); gCallbackRef = nullptr; } - - gOnMessageMethod = nullptr; - gOnErrorMethod = nullptr; - gOnConsoleMethod = nullptr; } JNIEXPORT jboolean JNICALL @@ -295,12 +278,8 @@ Java_com_webworker_WebWorkerNative_nativeHasWorker( jobject thiz, jstring workerId ) { - if (!gCore) { - return JNI_FALSE; - } - - std::string id = jstringToString(env, workerId); - return gCore->hasWorker(id) ? JNI_TRUE : JNI_FALSE; + if (!gCore) return JNI_FALSE; + return gCore->hasWorker(jstringToString(env, workerId)) ? JNI_TRUE : JNI_FALSE; } JNIEXPORT jboolean JNICALL @@ -309,12 +288,51 @@ Java_com_webworker_WebWorkerNative_nativeIsWorkerRunning( jobject thiz, jstring workerId ) { - if (!gCore) { - return JNI_FALSE; - } + if (!gCore) return JNI_FALSE; + return gCore->isWorkerRunning(jstringToString(env, workerId)) ? JNI_TRUE : JNI_FALSE; +} - std::string id = jstringToString(env, workerId); - return gCore->isWorkerRunning(id) ? JNI_TRUE : JNI_FALSE; +JNIEXPORT void JNICALL +Java_com_webworker_WebWorkerNative_nativeHandleFetchResponse( + JNIEnv* env, + jobject thiz, + jstring workerId, + jstring requestId, + jint status, + jobjectArray headerKeys, + jobjectArray headerValues, + jbyteArray body, + jstring error +) { + if (!gCore) return; + + webworker::FetchResponse response; + response.requestId = jstringToString(env, requestId); + + std::string errorStr = jstringToString(env, error); + if (!errorStr.empty()) { + response.error = errorStr; + } else { + response.status = status; + + int count = env->GetArrayLength(headerKeys); + for (int i = 0; i < count; i++) { + jstring key = (jstring)env->GetObjectArrayElement(headerKeys, i); + jstring value = (jstring)env->GetObjectArrayElement(headerValues, i); + response.headers[jstringToString(env, key)] = jstringToString(env, value); + env->DeleteLocalRef(key); + env->DeleteLocalRef(value); + } + + if (body != nullptr) { + jsize len = env->GetArrayLength(body); + jbyte* bytes = env->GetByteArrayElements(body, nullptr); + response.body.assign(bytes, bytes + len); + env->ReleaseByteArrayElements(body, bytes, JNI_ABORT); + } + } + + gCore->handleFetchResponse(jstringToString(env, workerId), response); } -} // extern "C" +} // extern "C" \ No newline at end of file diff --git a/cpp/WebWorkerCore.cpp b/cpp/WebWorkerCore.cpp index aaf6b60..339739a 100644 --- a/cpp/WebWorkerCore.cpp +++ b/cpp/WebWorkerCore.cpp @@ -1,4 +1,5 @@ #include "WebWorkerCore.h" +#include "networking/ResponseHostObject.h" #include #include #include @@ -12,7 +13,8 @@ namespace webworker { WebWorkerCore::WebWorkerCore() : messageCallback_(nullptr) , consoleCallback_(nullptr) - , errorCallback_(nullptr) { + , errorCallback_(nullptr) + , fetchCallback_(nullptr) { } WebWorkerCore::~WebWorkerCore() { @@ -33,7 +35,8 @@ std::string WebWorkerCore::createWorker( workerId, messageCallback_, consoleCallback_, - errorCallback_ + errorCallback_, + fetchCallback_ ); if (!worker->loadScript(script)) { @@ -106,6 +109,18 @@ void WebWorkerCore::setErrorCallback(ErrorCallback callback) { errorCallback_ = callback; } +void WebWorkerCore::setFetchCallback(FetchCallback callback) { + fetchCallback_ = callback; +} + +void WebWorkerCore::handleFetchResponse(const std::string& workerId, const FetchResponse& response) { + std::lock_guard lock(workersMutex_); + auto it = workers_.find(workerId); + if (it != workers_.end() && it->second->isRunning()) { + it->second->handleFetchResponse(response); + } +} + bool WebWorkerCore::hasWorker(const std::string& workerId) const { std::lock_guard lock(workersMutex_); return workers_.find(workerId) != workers_.end(); @@ -125,12 +140,14 @@ WorkerRuntime::WorkerRuntime( const std::string& workerId, MessageCallback messageCallback, ConsoleCallback consoleCallback, - ErrorCallback errorCallback + ErrorCallback errorCallback, + FetchCallback fetchCallback ) : workerId_(workerId) , messageCallback_(messageCallback) , consoleCallback_(consoleCallback) - , errorCallback_(errorCallback) { + , errorCallback_(errorCallback) + , fetchCallback_(fetchCallback) { // Start worker thread workerThread_ = std::make_unique(&WorkerRuntime::workerThreadMain, this); @@ -266,8 +283,7 @@ void WorkerRuntime::processTask(Task& task) { // Execute the task task.execute(); - // Drain microtasks after each macrotask (HTML spec requirement) - // This processes all Promise callbacks, queueMicrotask, etc. + // Drain microtasks after each macrotask static_cast(hermesRuntime_.get()) ->drainMicrotasks(); @@ -289,28 +305,24 @@ void WorkerRuntime::setupGlobalScope() { Runtime& runtime = *hermesRuntime_; std::string initScript = R"( - // Worker global scope initialization var self = this; var global = this; var messageHandlers = []; self.onmessage = null; - // postMessage - sends messages to the host self.postMessage = function(message) { if (typeof __nativePostMessageToHost !== 'undefined') { __nativePostMessageToHost(JSON.stringify(message)); } }; - // addEventListener self.addEventListener = function(type, handler) { if (type === 'message' && typeof handler === 'function') { messageHandlers.push(handler); } }; - // removeEventListener self.removeEventListener = function(type, handler) { if (type === 'message') { var index = messageHandlers.indexOf(handler); @@ -320,7 +332,6 @@ void WorkerRuntime::setupGlobalScope() { } }; - // Internal message handler (called from native) self.__handleMessage = function(message) { var data; try { @@ -334,18 +345,16 @@ void WorkerRuntime::setupGlobalScope() { type: 'message' }; - // Call onmessage if set if (typeof self.onmessage === 'function') { self.onmessage(event); } - // Call all registered handlers messageHandlers.forEach(function(handler) { handler(event); }); }; - // Basic console implementation + // Basic console var console = { log: function() { var args = Array.prototype.slice.call(arguments); @@ -387,12 +396,10 @@ void WorkerRuntime::setupGlobalScope() { self.console = console; - // queueMicrotask (uses Promise for Hermes compatibility) self.queueMicrotask = function(callback) { Promise.resolve().then(callback); }; - // close() - request worker termination self.close = function() { if (typeof __nativeRequestClose !== 'undefined') { __nativeRequestClose(); @@ -417,8 +424,6 @@ void WorkerRuntime::installNativeFunctions() { try { Runtime& runtime = *hermesRuntime_; - - // Capture this pointer for callbacks auto* self = this; // __nativePostMessageToHost @@ -467,6 +472,114 @@ void WorkerRuntime::installNativeFunctions() { ); runtime.global().setProperty(runtime, "__nativeRequestClose", requestCloseFunc); + // __nativeFetch + auto fetchFunc = Function::createFromHostFunction( + runtime, + PropNameID::forAscii(runtime, "__nativeFetch"), + 2, + [self](Runtime& rt, const Value& thisVal, const Value* args, size_t count) -> Value { + if (count < 1) return Value::undefined(); + + std::string url = args[0].asString(rt).utf8(rt); + std::string method = "GET"; + std::unordered_map headers; + std::vector bodyData; + double timeout = 0; + std::string redirect = "follow"; + + if (count > 1 && args[1].isObject()) { + Object opts = args[1].asObject(rt); + + if (opts.hasProperty(rt, "method")) { + method = opts.getProperty(rt, "method").asString(rt).utf8(rt); + } + + if (opts.hasProperty(rt, "timeout")) { + timeout = opts.getProperty(rt, "timeout").asNumber(); + } + + if (opts.hasProperty(rt, "redirect")) { + redirect = opts.getProperty(rt, "redirect").asString(rt).utf8(rt); + } + + if (opts.hasProperty(rt, "headers")) { + Object headersObj = opts.getProperty(rt, "headers").asObject(rt); + Array headerNames = headersObj.getPropertyNames(rt); + for (size_t i = 0; i < headerNames.size(rt); ++i) { + String key = headerNames.getValueAtIndex(rt, i).asString(rt); + String value = headersObj.getProperty(rt, key).asString(rt); + headers[key.utf8(rt)] = value.utf8(rt); + } + } + + if (opts.hasProperty(rt, "body")) { + Value bodyVal = opts.getProperty(rt, "body"); + if (bodyVal.isString()) { + std::string bodyStr = bodyVal.asString(rt).utf8(rt); + bodyData.assign(bodyStr.begin(), bodyStr.end()); + } else if (bodyVal.isObject() && bodyVal.asObject(rt).isArrayBuffer(rt)) { + auto arrayBuffer = bodyVal.asObject(rt).getArrayBuffer(rt); + uint8_t* data = arrayBuffer.data(rt); + size_t size = arrayBuffer.size(rt); + bodyData.assign(data, data + size); + } + } + } + + std::string requestId = std::to_string(self->nextRequestId_++); + + auto promiseCtor = rt.global().getPropertyAsFunction(rt, "Promise"); + return promiseCtor.callAsConstructor(rt, Function::createFromHostFunction(rt, PropNameID::forAscii(rt, "executor"), 2, + [self, requestId, url, method, headers, bodyData, timeout, redirect](Runtime& rt, const Value&, const Value* args, size_t) -> Value { + auto resolve = std::make_shared(rt, args[0]); + auto reject = std::make_shared(rt, args[1]); + + self->pendingFetches_[requestId] = {resolve, reject}; + + if (self->fetchCallback_) { + FetchRequest req; + req.requestId = requestId; + req.url = url; + req.method = method; + req.headers = headers; + req.body = bodyData; + req.timeout = timeout; + req.redirect = redirect; + self->fetchCallback_(self->workerId_, req); + } + return Value::undefined(); + } + )); + } + ); + runtime.global().setProperty(runtime, "__nativeFetch", fetchFunc); + + // Fetch API Polyfill + std::string fetchScript = R"( + self.fetch = async function(url, options) { + options = options || {}; + var nativeResponse = await __nativeFetch(url, options); + + return { + status: nativeResponse.status, + ok: nativeResponse.status >= 200 && nativeResponse.status < 300, + headers: nativeResponse.headers, + text: function() { return Promise.resolve(nativeResponse.text()); }, + json: function() { + return Promise.resolve(nativeResponse.text()).then(function(txt) { + return JSON.parse(txt); + }); + }, + arrayBuffer: function() { return Promise.resolve(nativeResponse.arrayBuffer()); } + }; + }; + )"; + + runtime.evaluateJavaScript( + std::make_shared(fetchScript), + "worker-fetch.js" + ); + } catch (const std::exception& e) { if (errorCallback_) { errorCallback_(workerId_, "Exception installing native functions: " + std::string(e.what())); @@ -481,58 +594,38 @@ void WorkerRuntime::installTimerFunctions() { Runtime& runtime = *hermesRuntime_; auto* self = this; - // __nativeScheduleTimer(timerId, delay, repeating, callback) + // __nativeScheduleTimer auto scheduleTimerFunc = Function::createFromHostFunction( runtime, PropNameID::forAscii(runtime, "__nativeScheduleTimer"), 4, [self](Runtime& rt, const Value& thisVal, const Value* args, size_t count) -> Value { - if (count < 4) { - return Value::undefined(); - } + if (count < 4) return Value::undefined(); uint64_t timerId = static_cast(args[0].asNumber()); int64_t delay = static_cast(args[1].asNumber()); bool repeating = args[2].getBool(); - - // Ensure delay is non-negative if (delay < 0) delay = 0; - // Store the callback function auto callback = std::make_shared(rt, args[3]); - - // Create the timer callback using a shared function to avoid code duplication - std::function timerCallback; - - // Use a shared_ptr to allow the callback to reference itself for rescheduling auto callbackHolder = std::make_shared>(); *callbackHolder = [self, callback, timerId, delay, repeating, callbackHolder]() { - if (!self->hermesRuntime_ || !self->running_.load()) { - return; - } - - // Check if timer was cancelled + if (!self->hermesRuntime_ || !self->running_.load()) return; { std::lock_guard lock(self->cancelledTimersMutex_); - if (self->cancelledTimers_.count(timerId) > 0) { - return; - } + if (self->cancelledTimers_.count(timerId) > 0) return; } Runtime& rt = *self->hermesRuntime_; - try { if (callback->isObject() && callback->asObject(rt).isFunction(rt)) { callback->asObject(rt).asFunction(rt).call(rt); } } catch (const JSError& e) { - if (self->errorCallback_) { - self->errorCallback_(self->workerId_, "JSError in timer: " + e.getMessage()); - } + if (self->errorCallback_) self->errorCallback_(self->workerId_, "JSError in timer: " + e.getMessage()); } - // Reschedule if interval and not cancelled if (repeating) { std::lock_guard lock(self->cancelledTimersMutex_); if (self->cancelledTimers_.count(timerId) == 0) { @@ -540,27 +633,22 @@ void WorkerRuntime::installTimerFunctions() { nextTask.type = TaskType::Timer; nextTask.id = timerId; nextTask.execute = *callbackHolder; - self->taskQueue_.enqueueDelayed(std::move(nextTask), - std::chrono::milliseconds(delay)); + self->taskQueue_.enqueueDelayed(std::move(nextTask), std::chrono::milliseconds(delay)); } } }; - // Schedule the initial timer Task task; task.type = TaskType::Timer; task.id = timerId; task.execute = *callbackHolder; - - self->taskQueue_.enqueueDelayed(std::move(task), - std::chrono::milliseconds(delay)); - + self->taskQueue_.enqueueDelayed(std::move(task), std::chrono::milliseconds(delay)); return Value::undefined(); } ); runtime.global().setProperty(runtime, "__nativeScheduleTimer", scheduleTimerFunc); - // __nativeCancelTimer(timerId) + // __nativeCancelTimer auto cancelTimerFunc = Function::createFromHostFunction( runtime, PropNameID::forAscii(runtime, "__nativeCancelTimer"), @@ -575,77 +663,40 @@ void WorkerRuntime::installTimerFunctions() { ); runtime.global().setProperty(runtime, "__nativeCancelTimer", cancelTimerFunc); - // Now add JavaScript wrapper for setTimeout, setInterval, etc. + // Timers JS wrapper std::string timerScript = R"( - // Timer ID counter (start high to avoid conflicts) var __nextTimerId = 1; - - // setTimeout self.setTimeout = function(callback, delay) { if (typeof callback !== 'function') { - if (typeof callback === 'string') { - callback = new Function(callback); - } else { - return 0; - } + if (typeof callback === 'string') callback = new Function(callback); + else return 0; } var timerId = __nextTimerId++; var args = Array.prototype.slice.call(arguments, 2); - var wrappedCallback = function() { - callback.apply(null, args); - }; + var wrappedCallback = function() { callback.apply(null, args); }; __nativeScheduleTimer(timerId, delay || 0, false, wrappedCallback); return timerId; }; - - // clearTimeout - self.clearTimeout = function(timerId) { - if (timerId) { - __nativeCancelTimer(timerId); - } - }; - - // setInterval + self.clearTimeout = function(timerId) { if(timerId) __nativeCancelTimer(timerId); }; self.setInterval = function(callback, delay) { if (typeof callback !== 'function') { - if (typeof callback === 'string') { - callback = new Function(callback); - } else { - return 0; - } + if (typeof callback === 'string') callback = new Function(callback); + else return 0; } var timerId = __nextTimerId++; var args = Array.prototype.slice.call(arguments, 2); - var wrappedCallback = function() { - callback.apply(null, args); - }; + var wrappedCallback = function() { callback.apply(null, args); }; __nativeScheduleTimer(timerId, delay || 0, true, wrappedCallback); return timerId; }; - - // clearInterval (same as clearTimeout) - self.clearInterval = function(timerId) { - self.clearTimeout(timerId); - }; - - // setImmediate (non-standard but common in React Native) + self.clearInterval = function(timerId) { self.clearTimeout(timerId); }; self.setImmediate = function(callback) { var args = Array.prototype.slice.call(arguments, 1); - return self.setTimeout(function() { - callback.apply(null, args); - }, 0); - }; - - // clearImmediate - self.clearImmediate = function(timerId) { - self.clearTimeout(timerId); + return self.setTimeout(function() { callback.apply(null, args); }, 0); }; + self.clearImmediate = function(timerId) { self.clearTimeout(timerId); }; )"; - - runtime.evaluateJavaScript( - std::make_shared(timerScript), - "worker-timers.js" - ); + runtime.evaluateJavaScript(std::make_shared(timerScript), "worker-timers.js"); } catch (const std::exception& e) { if (errorCallback_) { @@ -695,19 +746,68 @@ void WorkerRuntime::handleConsoleLog(const std::string& level, const std::string } } +void WorkerRuntime::handleFetchResponse(const FetchResponse& response) { + if (!running_.load()) return; + + Task task; + task.type = TaskType::Message; // Reusing Message type for generic immediate execution + task.id = nextTaskId_++; + + // Copy response data to be captured by lambda + FetchResponse resp = response; + + task.execute = [this, resp]() { + if (!hermesRuntime_) return; + Runtime& rt = *hermesRuntime_; + + auto it = pendingFetches_.find(resp.requestId); + if (it == pendingFetches_.end()) { + return; // Request not found or already cancelled + } + + auto resolve = it->second.resolve; + auto reject = it->second.reject; + + try { + if (!resp.error.empty()) { + // Reject + if (reject->isObject() && reject->asObject(rt).isFunction(rt)) { + reject->asObject(rt).asFunction(rt).call(rt, String::createFromUtf8(rt, resp.error)); + } + } else { + // Resolve with HostObject + auto hostObject = std::make_shared( + resp.status, + resp.headers, + resp.body + ); + + Object responseObj = Object::createFromHostObject(rt, hostObject); + + if (resolve->isObject() && resolve->asObject(rt).isFunction(rt)) { + resolve->asObject(rt).asFunction(rt).call(rt, responseObj); + } + } + } catch (const JSError& e) { + // Log error? + } + + pendingFetches_.erase(it); + }; + + taskQueue_.enqueue(std::move(task)); +} + + bool WorkerRuntime::loadScript(const std::string& script) { if (!running_.load() && !initialized_.load()) { - // Worker thread hasn't started yet, wait for it while (!initialized_.load()) { std::this_thread::sleep_for(std::chrono::milliseconds(1)); } } - if (!running_.load()) { - return false; - } + if (!running_.load()) return false; - // Set pending script and notify worker thread { std::lock_guard lock(pendingScriptMutex_); pendingScript_ = script; @@ -716,7 +816,6 @@ bool WorkerRuntime::loadScript(const std::string& script) { } pendingScriptCondition_.notify_all(); - // Wait for script to be executed { std::unique_lock lock(pendingScriptMutex_); pendingScriptCondition_.wait(lock, [this] { @@ -728,26 +827,17 @@ bool WorkerRuntime::loadScript(const std::string& script) { } bool WorkerRuntime::postMessage(const std::string& message) { - if (!running_.load()) { - return false; - } + if (!running_.load()) return false; - // Create a message task and enqueue it Task task; task.type = TaskType::Message; task.id = nextTaskId_++; task.execute = [this, message]() { - if (!hermesRuntime_ || !running_.load()) { - return; - } - + if (!hermesRuntime_ || !running_.load()) return; Runtime& runtime = *hermesRuntime_; - auto handleMessageProp = runtime.global().getProperty(runtime, "__handleMessage"); - if (handleMessageProp.isObject() && handleMessageProp.asObject(runtime).isFunction(runtime)) { - auto handleMessage = handleMessageProp.asObject(runtime).asFunction(runtime); - handleMessage.call(runtime, String::createFromUtf8(runtime, message)); + handleMessageProp.asObject(runtime).asFunction(runtime).call(runtime, String::createFromUtf8(runtime, message)); } }; @@ -756,82 +846,48 @@ bool WorkerRuntime::postMessage(const std::string& message) { } std::string WorkerRuntime::evalScript(const std::string& script) { - if (!hermesRuntime_ || !running_.load()) { - throw std::runtime_error("Runtime not available"); - } + if (!hermesRuntime_ || !running_.load()) throw std::runtime_error("Runtime not available"); std::lock_guard lock(runtimeMutex_); Runtime& runtime = *hermesRuntime_; try { - Value result = runtime.evaluateJavaScript( - std::make_shared(script), - "eval.js" - ); - - // Drain microtasks after eval - static_cast(hermesRuntime_.get()) - ->drainMicrotasks(); + Value result = runtime.evaluateJavaScript(std::make_shared(script), "eval.js"); + static_cast(hermesRuntime_.get())->drainMicrotasks(); - // Convert result to string - if (result.isString()) { - return result.asString(runtime).utf8(runtime); - } else if (result.isNumber()) { + if (result.isString()) return result.asString(runtime).utf8(runtime); + else if (result.isNumber()) { double num = result.asNumber(); - if (num == static_cast(num)) { - return std::to_string(static_cast(num)); - } + if (num == static_cast(num)) return std::to_string(static_cast(num)); return std::to_string(num); - } else if (result.isBool()) { - return result.getBool() ? "true" : "false"; - } else if (result.isNull()) { - return "null"; - } else if (result.isUndefined()) { - return "undefined"; - } else if (result.isObject()) { - // Try to stringify objects + } else if (result.isBool()) return result.getBool() ? "true" : "false"; + else if (result.isNull()) return "null"; + else if (result.isUndefined()) return "undefined"; + else if (result.isObject()) { try { auto JSON = runtime.global().getPropertyAsObject(runtime, "JSON"); auto stringify = JSON.getPropertyAsFunction(runtime, "stringify"); auto stringified = stringify.call(runtime, result); - if (stringified.isString()) { - return stringified.asString(runtime).utf8(runtime); - } - } catch (...) { - // Fall through - } + if (stringified.isString()) return stringified.asString(runtime).utf8(runtime); + } catch (...) {} return "[object Object]"; } - return "[unknown]"; - } catch (const JSError& e) { throw std::runtime_error("JSError: " + e.getMessage()); } } void WorkerRuntime::terminate() { - if (!running_.exchange(false)) { - return; - } - - // Signal shutdown + if (!running_.exchange(false)) return; closeRequested_ = true; taskQueue_.shutdown(); - - // Notify any waiting threads pendingScriptCondition_.notify_all(); - - // Wait for worker thread to finish - if (workerThread_ && workerThread_->joinable()) { - workerThread_->join(); - } - - // Clean up runtime + if (workerThread_ && workerThread_->joinable()) workerThread_->join(); { std::lock_guard lock(runtimeMutex_); hermesRuntime_.reset(); } } -} // namespace webworker +} // namespace webworker \ No newline at end of file diff --git a/cpp/WebWorkerCore.h b/cpp/WebWorkerCore.h index b305e3c..4d30dce 100644 --- a/cpp/WebWorkerCore.h +++ b/cpp/WebWorkerCore.h @@ -14,6 +14,7 @@ #include #include "TaskQueue.h" +#include "networking/FetchTypes.h" namespace webworker { @@ -36,6 +37,11 @@ using ConsoleCallback = std::function; +/** + * Callback type for network requests + */ +using FetchCallback = std::function; + /** * WebWorkerCore - Platform-independent worker manager * @@ -60,6 +66,10 @@ class WebWorkerCore { void setMessageCallback(MessageCallback callback); void setConsoleCallback(ConsoleCallback callback); void setErrorCallback(ErrorCallback callback); + void setFetchCallback(FetchCallback callback); + + // Networking + void handleFetchResponse(const std::string& workerId, const FetchResponse& response); // Query bool hasWorker(const std::string& workerId) const; @@ -72,22 +82,19 @@ class WebWorkerCore { MessageCallback messageCallback_; ConsoleCallback consoleCallback_; ErrorCallback errorCallback_; + FetchCallback fetchCallback_; }; /** * WorkerRuntime - Individual worker runtime with its own Hermes instance - * - * Each worker runs in its own thread with a dedicated Hermes runtime. - * Implements proper event loop following the HTML specification model: - * - Macrotasks: setTimeout, setInterval, postMessage, etc. - * - Microtasks: Promise callbacks, queueMicrotask */ class WorkerRuntime { public: WorkerRuntime(const std::string& workerId, MessageCallback messageCallback, ConsoleCallback consoleCallback, - ErrorCallback errorCallback); + ErrorCallback errorCallback, + FetchCallback fetchCallback); ~WorkerRuntime(); // Script execution @@ -97,6 +104,9 @@ class WorkerRuntime { // Messaging bool postMessage(const std::string& message); + // Networking + void handleFetchResponse(const FetchResponse& response); + // Lifecycle void terminate(); @@ -108,7 +118,7 @@ class WorkerRuntime { // Thread management void workerThreadMain(); - // Event loop (replaces the old processMessageQueue) + // Event loop void eventLoop(); void processTask(Task& task); @@ -117,7 +127,7 @@ class WorkerRuntime { void installNativeFunctions(); void installTimerFunctions(); - // Message handling (called from native functions) + // Message handling void handlePostMessageToHost(const std::string& message); void handleConsoleLog(const std::string& level, const std::string& message); @@ -142,8 +152,9 @@ class WorkerRuntime { TaskQueue taskQueue_; std::atomic nextTaskId_{1}; std::atomic nextTimerId_{1}; + std::atomic nextRequestId_{1}; // For fetch requests - // Track cancelled timers for interval cleanup + // Track cancelled timers std::unordered_set cancelledTimers_; std::mutex cancelledTimersMutex_; @@ -158,6 +169,14 @@ class WorkerRuntime { MessageCallback messageCallback_; ConsoleCallback consoleCallback_; ErrorCallback errorCallback_; + FetchCallback fetchCallback_; + + // Fetch promises + struct FetchPromise { + std::shared_ptr resolve; + std::shared_ptr reject; + }; + std::unordered_map pendingFetches_; }; -} // namespace webworker +} // namespace webworker \ No newline at end of file diff --git a/cpp/networking/FetchTypes.h b/cpp/networking/FetchTypes.h new file mode 100644 index 0000000..369540c --- /dev/null +++ b/cpp/networking/FetchTypes.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include + +namespace webworker { + +struct FetchRequest { + std::string requestId; + std::string url; + std::string method; + std::unordered_map headers; + std::vector body; + double timeout; // Request timeout in milliseconds (0 = default/no timeout) + std::string redirect; // "follow", "error", "manual" +}; + +struct FetchResponse { + std::string requestId; + int status; + std::unordered_map headers; + std::vector body; + std::string error; // Non-empty if request failed +}; + +} // namespace webworker diff --git a/cpp/networking/ResponseHostObject.h b/cpp/networking/ResponseHostObject.h new file mode 100644 index 0000000..ec21bbe --- /dev/null +++ b/cpp/networking/ResponseHostObject.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace webworker { + +using namespace facebook::jsi; + +class ResponseHostObject : public HostObject { +public: + ResponseHostObject(int status, + const std::unordered_map& headers, + std::vector data) + : status_(status), headers_(headers), data_(std::move(data)) {} + + Value get(Runtime& rt, const PropNameID& name) override { + std::string prop = name.utf8(rt); + + if (prop == "status") { + return status_; + } + + if (prop == "headers") { + Object headersObj(rt); + for (const auto& pair : headers_) { + headersObj.setProperty(rt, pair.first.c_str(), pair.second.c_str()); + } + return headersObj; + } + + if (prop == "text") { + return Function::createFromHostFunction(rt, name, 0, + [this](Runtime& rt, const Value&, const Value*, size_t) { + std::string textStr(data_.begin(), data_.end()); + return String::createFromUtf8(rt, textStr); + }); + } + + if (prop == "arrayBuffer") { + return Function::createFromHostFunction(rt, name, 0, + [this](Runtime& rt, const Value&, const Value*, size_t) { + auto arrayBuffer = rt.global().getPropertyAsFunction(rt, "ArrayBuffer") + .callAsConstructor(rt, (int)data_.size()) + .getObject(rt).getArrayBuffer(rt); + + if (data_.size() > 0) { + memcpy(arrayBuffer.data(rt), data_.data(), data_.size()); + } + + return arrayBuffer; + }); + } + + return Value::undefined(); + } + +private: + int status_; + std::unordered_map headers_; + std::vector data_; +}; + +} // namespace webworker diff --git a/docs/NETWORKING.md b/docs/NETWORKING.md new file mode 100644 index 0000000..989d988 --- /dev/null +++ b/docs/NETWORKING.md @@ -0,0 +1,163 @@ +# Networking in web workers + +The `react-native-webworker` library includes built-in networking support for your worker threads. Every worker automatically gets the familiar browser `fetch()` API. + +## What it does + +Every worker comes with a complete Fetch API implementation built-in. Behind the scenes, it uses the platform's native networking: + +- **iOS**: Uses `NSURLSession` for HTTP requests +- **Android**: Uses `OkHttp` for HTTP requests + +This gives you good performance and handles all the platform-specific stuff like SSL certificates, proxy settings, and connection reuse. + +## Fetch API + +Workers include a basic Fetch API for making HTTP requests. It's a simplified version compared to the full browser implementation. + +### Basic usage + +Here's how to make requests: + +```typescript +// Simple GET request +const response = await fetch('https://jsonplaceholder.typicode.com/todos/1'); +const data = await response.json(); +console.log(data); + +// POST request with JSON data +const response = await fetch('https://jsonplaceholder.typicode.com/posts', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + title: 'foo', + body: 'bar', + userId: 1 + }) +}); + +if (response.ok) { + const result = await response.json(); + console.log(result); +} +``` + +### Supported options + +Currently, these fetch options work: + +```typescript +const response = await fetch('https://api.example.com/data', { + method: 'POST', // HTTP method like 'GET', 'POST', 'PUT', 'DELETE' + headers: { + 'Authorization': 'Bearer token123', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ key: 'value' }), // String or ArrayBuffer + timeout: 5000, // Request timeout in milliseconds + redirect: 'follow', // 'follow', 'error', or 'manual' (Android only currently) + mode: 'cors' // Accepted but ignored (Native requests are not subject to CORS) +}); +``` + +**Note**: Advanced options like `credentials` and `signal` are not supported yet. + +### Response format + +The `fetch()` function returns a basic response object with these properties: + +```typescript +const response = await fetch('https://api.example.com/data'); + +// Basic response info +console.log(response.status); // 200 +console.log(response.ok); // true (quick way to check if status is 200-299) + +// Get response headers (as a plain object) +console.log(response.headers['content-type']); // 'application/json' + +// Read the response body +const text = await response.text(); // Get as plain text +const json = await response.json(); // Parse as JSON +const arrayBuffer = await response.arrayBuffer(); // Get as ArrayBuffer +``` + +## Handling errors + +Fetch provides basic error handling: + +### Fetch error handling + +```typescript +try { + const response = await fetch('https://api.example.com/data'); + + if (!response.ok) { + // Check for HTTP errors (like 404, 500, etc.) + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + console.log(data); + +} catch (error) { + // Network errors (DNS failure, connection issues, etc.) + console.error('Request failed:', error.message); +} +``` + +## Request body types + +You can send different types of data in your requests: + +### Text and JSON + +```typescript +// Plain text +await fetch('/api', { + method: 'POST', + body: 'Hello World' +}); + +// JSON objects (automatically converted to strings) +await fetch('/api', { + method: 'POST', + body: { message: 'Hello' } // Gets turned into '{"message":"Hello"}' +}); +``` + +### Binary data + +```typescript +// ArrayBuffer +const buffer = new ArrayBuffer(1024); +await fetch('/api/upload', { + method: 'POST', + body: buffer +}); + +// Typed arrays +const uint8Array = new Uint8Array([1, 2, 3, 4]); +await fetch('/api/upload', { + method: 'POST', + body: uint8Array +}); +``` + +## Current limitations + +1. **Limited Fetch Options**: Advanced options like `credentials` and `signal` aren't supported yet. + +2. **No XMLHttpRequest**: The XMLHttpRequest API isn't implemented. + +3. **No AbortController**: Request cancellation isn't available yet. + +4. **No Request/Response Classes**: You can't create `new Request()` or `new Response()` objects - just use the basic `fetch()` function. + +5. **No Headers Class**: Headers are plain JavaScript objects, not Header class instances. + +6. **No FormData**: Multipart form data isn't supported - use JSON or URL-encoded strings instead. + +7. **No WebSocket Support**: Only HTTP requests work, no WebSocket connections. diff --git a/example/src/__tests__/networking.harness.ts b/example/src/__tests__/networking.harness.ts new file mode 100644 index 0000000..cb603b2 --- /dev/null +++ b/example/src/__tests__/networking.harness.ts @@ -0,0 +1,315 @@ +import { describe, it, expect, afterEach } from 'react-native-harness'; +import { Worker } from 'react-native-webworker'; + +// Helper to prevent tests from hanging indefinitely +function withTimeout( + promise: Promise, + ms: number = 5000, + msg: string = 'Operation timed out' +): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error(msg)), ms)), + ]); +} + +describe('Worker Networking', () => { + let worker: Worker; + + afterEach(async () => { + if (worker) { + await worker.terminate(); + } + }); + + it('should support basic fetch GET', async () => { + worker = new Worker({ + script: ` + self.onmessage = async function() { + try { + const response = await fetch('https://jsonplaceholder.typicode.com/todos/1'); + const data = await response.json(); + self.postMessage({ status: 'ok', data: data }); + } catch (e) { + self.postMessage({ status: 'error', error: e.toString() }); + } + }; + `, + }); + + const responsePromise = new Promise((resolve, reject) => { + worker.onmessage = (event) => { + if (event.data.status === 'error') { + reject(new Error(event.data.error)); + } else { + resolve(event.data.data); + } + }; + worker.onerror = reject; + }); + + await worker.postMessage('start'); + + const data = await withTimeout( + responsePromise, + 5000, + 'Fetch GET timed out' + ); + + expect(data).not.toBe(null); + expect(data.id).toBe(1); + }); + + it('should support fetch POST with JSON body', async () => { + worker = new Worker({ + script: ` + self.onmessage = async function() { + try { + const response = await fetch('https://jsonplaceholder.typicode.com/posts', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + title: 'foo', + body: 'bar', + userId: 1 + }) + }); + const data = await response.json(); + self.postMessage({ status: 'ok', data: data }); + } catch (e) { + self.postMessage({ status: 'error', error: e.toString() }); + } + }; + `, + }); + + const responsePromise = new Promise((resolve, reject) => { + worker.onmessage = (event) => { + if (event.data.status === 'error') { + reject(new Error(event.data.error)); + } else { + resolve(event.data.data); + } + }; + worker.onerror = reject; + }); + + await worker.postMessage('start'); + + const data = await withTimeout( + responsePromise, + 5000, + 'Fetch POST timed out' + ); + + expect(data).not.toBe(null); + expect(data.title).toBe('foo'); + expect(data.body).toBe('bar'); + expect(data.userId).toBe(1); + }); + + it('should read response headers', async () => { + worker = new Worker({ + script: ` + self.onmessage = async function() { + try { + const response = await fetch('https://jsonplaceholder.typicode.com/todos/1'); + // Check headers object (it's a plain object in our implementation) + self.postMessage({ status: 'ok', headers: response.headers }); + } catch (e) { + self.postMessage({ status: 'error', error: e.toString() }); + } + }; + `, + }); + + const responsePromise = new Promise((resolve, reject) => { + worker.onmessage = (event) => { + if (event.data.status === 'error') { + reject(new Error(event.data.error)); + } else { + resolve(event.data.headers); + } + }; + worker.onerror = reject; + }); + + await worker.postMessage('start'); + + const headers = await withTimeout( + responsePromise, + 5000, + 'Headers test timed out' + ); + + expect(headers).not.toBe(null); + // Keys might be lower-cased or not depending on platform implementation + const contentType = headers['content-type'] || headers['Content-Type']; + expect(contentType).toBeDefined(); + expect(contentType.includes('application/json')).toBe(true); + }); + + it('should handle fetch errors', async () => { + worker = new Worker({ + script: ` + self.onmessage = async function() { + try { + // Invalid domain + await fetch('https://this-domain-does-not-exist-xyz123.com'); + self.postMessage({ status: 'ok', data: 'should not happen' }); + } catch (e) { + self.postMessage({ status: 'error', error: e.toString() }); + } + }; + `, + }); + + const responsePromise = new Promise((resolve) => { + worker.onmessage = (event) => { + if (event.data.status === 'error') { + resolve(event.data.error); + } else { + resolve('Did not catch error'); + } + }; + worker.onerror = (err) => resolve(err); + }); + + await worker.postMessage('start'); + + const result = await withTimeout( + responsePromise, + 5000, + 'Fetch error test timed out' + ); + // We expect an error string + expect(typeof result).toBe('string'); + expect(result).not.toBe('Did not catch error'); + }); + + it('should support binary data with ArrayBuffer', async () => { + worker = new Worker({ + script: ` + self.onmessage = async function(event) { + try { + const { action, data } = event.data; + if (action === 'send-binary') { + // Create an ArrayBuffer with some test data + const buffer = new ArrayBuffer(8); + const view = new Uint8Array(buffer); + view.set([1, 2, 3, 4, 5, 6, 7, 8]); + + // Send as POST body + const response = await fetch('https://httpbin.org/post', { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream' + }, + body: buffer + }); + + const result = await response.json(); + self.postMessage({ status: 'ok', result: result }); + } else if (action === 'receive-binary') { + // Fetch binary data + const response = await fetch('https://httpbin.org/bytes/8'); + const arrayBuffer = await response.arrayBuffer(); + + // Convert to Uint8Array to check contents + const uint8Array = new Uint8Array(arrayBuffer); + self.postMessage({ status: 'ok', data: Array.from(uint8Array) }); + } + } catch (e) { + self.postMessage({ status: 'error', error: e.toString() }); + } + }; + `, + }); + + const createResponsePromise = () => + new Promise((resolve, reject) => { + worker.onmessage = (event) => { + if (event.data.status === 'error') { + reject(new Error(event.data.error)); + } else { + resolve(event.data); + } + }; + worker.onerror = reject; + }); + + // Test sending binary data + let currentPromise = createResponsePromise(); + await worker.postMessage({ action: 'send-binary', data: null }); + let result = await withTimeout( + currentPromise, + 10000, + 'Binary send test timed out' + ); + + expect(result.result).toBeDefined(); + expect(result.result.data).toBeDefined(); + // httpbin.org returns the data as base64, so we just check it exists + expect(typeof result.result.data).toBe('string'); + + // Test receiving binary data + currentPromise = createResponsePromise(); + await worker.postMessage({ action: 'receive-binary', data: null }); + result = await withTimeout( + currentPromise, + 10000, + 'Binary receive test timed out' + ); + + expect(Array.isArray(result.data)).toBe(true); + expect(result.data.length).toBe(8); + // Should be random bytes, just check they're numbers + result.data.forEach((byte: number) => { + expect(typeof byte).toBe('number'); + expect(byte).toBeGreaterThanOrEqual(0); + expect(byte).toBeLessThanOrEqual(255); + }); + }); + + it('should support request timeout', async () => { + worker = new Worker({ + script: ` + self.onmessage = async function() { + try { + // Request a 2 second delay but set timeout to 100ms + await fetch('https://httpbin.org/delay/2', { + timeout: 100 + }); + self.postMessage({ status: 'ok', data: 'should fail' }); + } catch (e) { + self.postMessage({ status: 'error', error: e.toString() }); + } + }; + `, + }); + + const responsePromise = new Promise((resolve) => { + worker.onmessage = (event) => { + if (event.data.status === 'error') { + resolve(event.data.error); + } else { + resolve('Did not catch error'); + } + }; + worker.onerror = (err) => resolve(err); + }); + + await worker.postMessage('start'); + + const result = await withTimeout( + responsePromise, + 5000, + 'Timeout test failed' + ); + // Expect error string + expect(typeof result).toBe('string'); + expect(result).not.toBe('Did not catch error'); + }); +}); diff --git a/ios/Webworker.mm b/ios/Webworker.mm index a0cfe42..8f107aa 100644 --- a/ios/Webworker.mm +++ b/ios/Webworker.mm @@ -7,6 +7,7 @@ #import "Webworker.h" #import "WebWorkerCore.h" +#import "networking/FetchTypes.h" #import @interface Webworker () { @@ -82,6 +83,82 @@ - (void)setupCallbacks { }); } }); + + // Fetch callback + _core->setFetchCallback([weakSelf](const std::string &workerId, + const webworker::FetchRequest &request) { + Webworker *strongSelf = weakSelf; + if (strongSelf) { + [strongSelf performFetch:request workerId:workerId]; + } + }); +} + +- (void)performFetch:(const webworker::FetchRequest &)request workerId:(std::string)workerId { + NSString *urlString = [NSString stringWithUTF8String:request.url.c_str()]; + NSURL *url = [NSURL URLWithString:urlString]; + NSMutableURLRequest *urlRequest = [NSMutableURLRequest requestWithURL:url]; + + urlRequest.HTTPMethod = [NSString stringWithUTF8String:request.method.c_str()]; + + // Headers + for (const auto &header : request.headers) { + NSString *key = [NSString stringWithUTF8String:header.first.c_str()]; + NSString *value = [NSString stringWithUTF8String:header.second.c_str()]; + [urlRequest setValue:value forHTTPHeaderField:key]; + } + + // Body + if (!request.body.empty()) { + urlRequest.HTTPBody = [NSData dataWithBytes:request.body.data() length:request.body.size()]; + } + + // Timeout + if (request.timeout > 0) { + urlRequest.timeoutInterval = request.timeout / 1000.0; + } + + std::string requestIdStr = request.requestId; + std::string workerIdStr = workerId; + + NSURLSession *session = [NSURLSession sharedSession]; + + // Handle redirect behavior via delegate if needed, but for now using shared session + // sharedSession automatically follows redirects. + // If 'error' or 'manual' is requested, we would need a custom session delegate. + // For this MVP, we will only respect the timeout. + // TODO: Implement custom session delegate for redirect control if strictly required. + + NSURLSessionDataTask *task = [session dataTaskWithRequest:urlRequest completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { + + webworker::FetchResponse fetchResponse; + fetchResponse.requestId = requestIdStr; + + if (error) { + fetchResponse.error = [error.localizedDescription UTF8String]; + } else { + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; + fetchResponse.status = (int)httpResponse.statusCode; + + // Headers + for (NSString *key in httpResponse.allHeaderFields) { + NSString *value = httpResponse.allHeaderFields[key]; + fetchResponse.headers[[key UTF8String]] = [value UTF8String]; + } + + // Body + if (data) { + const uint8_t *bytes = (const uint8_t *)[data bytes]; + fetchResponse.body.assign(bytes, bytes + [data length]); + } + } + + if (self->_core) { + self->_core->handleFetchResponse(workerIdStr, fetchResponse); + } + }]; + + [task resume]; } + (NSString *)moduleName { @@ -223,4 +300,4 @@ - (void)invalidate { return std::make_shared(params); } -@end +@end \ No newline at end of file