diff --git a/CHANGELOG.md b/CHANGELOG.md
index e273da4b1..682ca5129 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
## Unreleased
- added: Added change-server subscription timeout fallback.
+- added: New `privacy` option argument to EdgeIo fetch function for NYM support.
## 2.39.0 (2026-01-16)
diff --git a/android/src/main/java/app/edge/reactnative/core/BundleHTTPServer.java b/android/src/main/java/app/edge/reactnative/core/BundleHTTPServer.java
new file mode 100644
index 000000000..a21c7fc89
--- /dev/null
+++ b/android/src/main/java/app/edge/reactnative/core/BundleHTTPServer.java
@@ -0,0 +1,304 @@
+package app.edge.reactnative.core;
+
+import android.content.Context;
+import android.content.res.AssetManager;
+import android.util.Log;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * A simple HTTP server that serves files from the Android assets folder.
+ * Includes Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers
+ * required for SharedArrayBuffer support (needed by mixFetch web workers).
+ */
+class BundleHTTPServer {
+ private static final String TAG = "BundleHTTPServer";
+ private static final int DEFAULT_PORT = 3693;
+
+ private final Context mContext;
+ private final int mPort;
+ private final String mSecurityToken;
+ private ServerSocket mServerSocket;
+ private ExecutorService mExecutor;
+ private final AtomicBoolean mRunning = new AtomicBoolean(false);
+
+ public BundleHTTPServer(Context context, int port, String securityToken) {
+ mContext = context;
+ mPort = port;
+ mSecurityToken = securityToken;
+ }
+
+ public void start() {
+ if (mRunning.get()) {
+ return;
+ }
+
+ mExecutor = Executors.newCachedThreadPool();
+ mRunning.set(true);
+
+ new Thread(() -> {
+ try {
+ mServerSocket = new ServerSocket(mPort);
+ Log.d(TAG, "HTTP Server ready on localhost:" + mPort);
+
+ while (mRunning.get()) {
+ try {
+ Socket clientSocket = mServerSocket.accept();
+ mExecutor.execute(() -> handleConnection(clientSocket));
+ } catch (IOException e) {
+ if (mRunning.get()) {
+ Log.e(TAG, "Error accepting connection: " + e.getMessage());
+ }
+ }
+ }
+ } catch (IOException e) {
+ Log.e(TAG, "Failed to start HTTP server: " + e.getMessage());
+ }
+ }).start();
+ }
+
+ public void stop() {
+ mRunning.set(false);
+
+ if (mServerSocket != null) {
+ try {
+ mServerSocket.close();
+ } catch (IOException e) {
+ Log.e(TAG, "Error closing server socket: " + e.getMessage());
+ }
+ mServerSocket = null;
+ }
+
+ if (mExecutor != null) {
+ mExecutor.shutdown();
+ mExecutor = null;
+ }
+
+ Log.d(TAG, "HTTP Server stopped");
+ }
+
+ private void handleConnection(Socket clientSocket) {
+ try {
+ BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
+ OutputStream output = clientSocket.getOutputStream();
+
+ // Read the request line
+ String requestLine = reader.readLine();
+ if (requestLine == null || requestLine.isEmpty()) {
+ clientSocket.close();
+ return;
+ }
+
+ // Parse method and path
+ String[] parts = requestLine.split(" ");
+ if (parts.length < 2) {
+ sendResponse(output, 400, "Bad Request", "text/plain", "Bad Request".getBytes());
+ clientSocket.close();
+ return;
+ }
+
+ String method = parts[0];
+ String path = parts[1];
+
+ // Read and discard headers
+ String line;
+ while ((line = reader.readLine()) != null && !line.isEmpty()) {
+ // Just consume headers
+ }
+
+ // Only support GET
+ if (!method.equals("GET")) {
+ sendResponse(output, 405, "Method Not Allowed", "text/plain", "Method Not Allowed".getBytes());
+ clientSocket.close();
+ return;
+ }
+
+ // Remove query parameters
+ int queryIndex = path.indexOf('?');
+ if (queryIndex > 0) {
+ path = path.substring(0, queryIndex);
+ }
+
+ // Serve index.html for root path
+ if (path.equals("/")) {
+ path = "/index.html";
+ }
+
+ // Handle plugin bundle requests (e.g., /plugin/edge-currency-accountbased.bundle/edge-currency-accountbased.js)
+ if (path.startsWith("/plugin/")) {
+ String pluginPath = path.substring("/plugin/".length());
+ servePluginFile(output, pluginPath);
+ clientSocket.close();
+ return;
+ }
+
+ // Remove leading slash and prepend assets path
+ String assetPath = "edge-core-js" + path;
+
+ // Try to read the asset
+ try {
+ AssetManager assetManager = mContext.getAssets();
+ InputStream inputStream = assetManager.open(assetPath);
+ byte[] data = readAllBytes(inputStream);
+ inputStream.close();
+
+ // For index.html, inject the security token so the page can verify itself
+ if (path.equals("/index.html")) {
+ String html = new String(data, "UTF-8");
+ String tokenScript = "";
+ html = html.replace("
", "\n " + tokenScript);
+ data = html.getBytes("UTF-8");
+ }
+
+ String mimeType = getMimeType(path);
+ sendResponse(output, 200, "OK", mimeType, data);
+ } catch (IOException e) {
+ Log.d(TAG, "File not found: " + assetPath);
+ sendResponse(output, 404, "Not Found", "text/plain", "Not Found".getBytes());
+ }
+
+ clientSocket.close();
+ } catch (IOException e) {
+ Log.e(TAG, "Error handling connection: " + e.getMessage());
+ try {
+ clientSocket.close();
+ } catch (IOException ignored) {
+ }
+ }
+ }
+
+ private void servePluginFile(OutputStream output, String pluginPath) throws IOException {
+ AssetManager assetManager = mContext.getAssets();
+ byte[] data = null;
+
+ // Plugin path format: "edge-currency-accountbased.bundle/edge-currency-accountbased.js"
+ // or just: "plugin-bundle.js"
+ // Try multiple asset path patterns
+ String[] pathsToTry;
+
+ if (pluginPath.contains(".bundle/")) {
+ // Extract bundle name and file name
+ String[] parts = pluginPath.split("\\.bundle/");
+ if (parts.length >= 2) {
+ String bundleName = parts[0];
+ String fileName = parts[1];
+ pathsToTry = new String[] {
+ pluginPath, // As-is
+ bundleName + "/" + fileName, // Without .bundle
+ bundleName + ".bundle/" + fileName, // With .bundle as folder
+ fileName // Just the filename
+ };
+ } else {
+ pathsToTry = new String[] { pluginPath };
+ }
+ } else {
+ // Just a filename like "plugin-bundle.js"
+ // Try to find it in a corresponding .bundle folder first
+ String fileName = pluginPath;
+ int dotIndex = fileName.lastIndexOf('.');
+ if (dotIndex > 0) {
+ String baseName = fileName.substring(0, dotIndex);
+ pathsToTry = new String[] {
+ baseName + ".bundle/" + fileName, // e.g., plugin-bundle.bundle/plugin-bundle.js
+ baseName + "/" + fileName, // e.g., plugin-bundle/plugin-bundle.js
+ pluginPath // Just the filename
+ };
+ } else {
+ pathsToTry = new String[] { pluginPath };
+ }
+ }
+
+ for (String assetPath : pathsToTry) {
+ try {
+ InputStream inputStream = assetManager.open(assetPath);
+ data = readAllBytes(inputStream);
+ inputStream.close();
+ Log.d(TAG, "Found plugin at: " + assetPath);
+ break;
+ } catch (IOException e) {
+ // Try next path
+ Log.d(TAG, "Plugin not found at: " + assetPath);
+ }
+ }
+
+ if (data != null) {
+ String mimeType = getMimeType(pluginPath);
+ sendResponse(output, 200, "OK", mimeType, data);
+ } else {
+ Log.e(TAG, "Plugin file not found: " + pluginPath);
+ sendResponse(output, 404, "Not Found", "text/plain", ("Not Found: " + pluginPath).getBytes());
+ }
+ }
+
+ private void sendResponse(OutputStream output, int code, String status, String contentType, byte[] body)
+ throws IOException {
+ StringBuilder headers = new StringBuilder();
+ headers.append("HTTP/1.1 ").append(code).append(" ").append(status).append("\r\n");
+ headers.append("Content-Type: ").append(contentType).append("\r\n");
+ headers.append("Content-Length: ").append(body.length).append("\r\n");
+ headers.append("Connection: close\r\n");
+ headers.append("Server: EdgeCoreBundleServer/1.0\r\n");
+ // Cross-origin isolation headers required for SharedArrayBuffer (needed by mixFetch web workers)
+ headers.append("Cross-Origin-Opener-Policy: same-origin\r\n");
+ headers.append("Cross-Origin-Embedder-Policy: require-corp\r\n");
+ headers.append("\r\n");
+
+ output.write(headers.toString().getBytes("UTF-8"));
+ output.write(body);
+ output.flush();
+ }
+
+ private byte[] readAllBytes(InputStream inputStream) throws IOException {
+ ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+ byte[] chunk = new byte[4096];
+ int bytesRead;
+ while ((bytesRead = inputStream.read(chunk)) != -1) {
+ buffer.write(chunk, 0, bytesRead);
+ }
+ return buffer.toByteArray();
+ }
+
+ private String getMimeType(String path) {
+ String lowerPath = path.toLowerCase();
+ if (lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")) {
+ return "text/html";
+ } else if (lowerPath.endsWith(".js")) {
+ return "application/javascript";
+ } else if (lowerPath.endsWith(".css")) {
+ return "text/css";
+ } else if (lowerPath.endsWith(".json")) {
+ return "application/json";
+ } else if (lowerPath.endsWith(".png")) {
+ return "image/png";
+ } else if (lowerPath.endsWith(".jpg") || lowerPath.endsWith(".jpeg")) {
+ return "image/jpeg";
+ } else if (lowerPath.endsWith(".gif")) {
+ return "image/gif";
+ } else if (lowerPath.endsWith(".svg")) {
+ return "image/svg+xml";
+ } else if (lowerPath.endsWith(".woff")) {
+ return "font/woff";
+ } else if (lowerPath.endsWith(".woff2")) {
+ return "font/woff2";
+ } else if (lowerPath.endsWith(".ttf")) {
+ return "font/ttf";
+ } else if (lowerPath.endsWith(".wasm")) {
+ return "application/wasm";
+ } else if (lowerPath.endsWith(".xml")) {
+ return "application/xml";
+ } else if (lowerPath.endsWith(".txt")) {
+ return "text/plain";
+ }
+ return "application/octet-stream";
+ }
+}
diff --git a/android/src/main/java/app/edge/reactnative/core/EdgeCoreWebView.java b/android/src/main/java/app/edge/reactnative/core/EdgeCoreWebView.java
index 8b64bbc32..cf0282174 100644
--- a/android/src/main/java/app/edge/reactnative/core/EdgeCoreWebView.java
+++ b/android/src/main/java/app/edge/reactnative/core/EdgeCoreWebView.java
@@ -1,6 +1,5 @@
package app.edge.reactnative.core;
-import android.graphics.Bitmap;
import android.webkit.JavascriptInterface;
import android.webkit.WebView;
import android.webkit.WebViewClient;
@@ -9,17 +8,40 @@
import com.facebook.react.uimanager.ThemedReactContext;
import com.facebook.react.uimanager.events.RCTEventEmitter;
import org.json.JSONArray;
+import org.json.JSONException;
+import android.util.Log;
+import java.security.SecureRandom;
class EdgeCoreWebView extends WebView {
- private static final String BASE_URL = "file:///android_asset/";
+ private static final String TAG = "EdgeCoreWebView";
+ private static final String BASE_URL = "http://localhost:3693/";
private final ThemedReactContext mContext;
private final EdgeNative mNative;
+ private BundleHTTPServer mHttpServer;
+ private final String mSecurityToken;
+ private volatile boolean mSecurityVerified = false;
+
+ // Generate a crypto-secure 128-bit hex token
+ private static String generateSecurityToken() {
+ SecureRandom secureRandom = new SecureRandom();
+ byte[] bytes = new byte[16]; // 128 bits = 16 bytes
+ secureRandom.nextBytes(bytes);
+ StringBuilder sb = new StringBuilder(32);
+ for (byte b : bytes) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ }
// react api--------------------------------------------------------------
private String mSource;
public void setSource(String source) {
+ // Only reload if source actually changed (matches iOS behavior)
+ if (source == null ? mSource == null : source.equals(mSource)) {
+ return;
+ }
mSource = source;
visitPage();
}
@@ -41,15 +63,32 @@ public EdgeCoreWebView(ThemedReactContext context) {
mContext = context;
mNative = new EdgeNative(mContext.getFilesDir());
+ // Generate security token for this instance
+ mSecurityToken = generateSecurityToken();
+
+ // Start the HTTP server with security token for serving assets with COOP/COEP headers
+ mHttpServer = new BundleHTTPServer(context, 3693, mSecurityToken);
+ mHttpServer.start();
+
getSettings().setAllowFileAccess(true);
getSettings().setJavaScriptEnabled(true);
setWebViewClient(new Client());
addJavascriptInterface(new JsMethods(), "edgeCore");
+
+ // Launch the core (matches iOS behavior)
+ visitPage();
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
+
+ // Stop the HTTP server when view is detached
+ if (mHttpServer != null) {
+ mHttpServer.stop();
+ mHttpServer = null;
+ }
+
destroy();
}
@@ -57,19 +96,70 @@ protected void onDetachedFromWindow() {
class Client extends WebViewClient {
@Override
- public void onPageStarted(WebView view, String url, Bitmap favicon) {
- if (!BASE_URL.equals(url)) visitPage();
+ public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
+ // Reload on navigation errors (matches iOS didFailProvisionalNavigation behavior)
+ visitPage();
+ }
+
+ @Override
+ public boolean onRenderProcessGone(WebView view, android.webkit.RenderProcessGoneDetail detail) {
+ // Reload if the render process crashes (matches iOS webViewWebContentProcessDidTerminate behavior)
+ visitPage();
+ return true; // We handled the crash by reloading
}
}
class JsMethods {
@JavascriptInterface
public void call(int id, final String name, final String args) {
+ // Handle security verification (id == 0 for special messages)
+ if (id == 0 && name.equals("verifySecurityToken")) {
+ verifySecurityToken(args);
+ return;
+ }
+
+ // Block all other communication until security is verified
+ if (!mSecurityVerified) {
+ Log.w(TAG, "⚠️ WARNING: Blocked message '" + name + "' - BundleHTTPServer security token not yet verified!");
+ return;
+ }
+
mNative.call(name, args, new WebViewPromise(id));
}
+
+ private void verifySecurityToken(String argsJson) {
+ try {
+ JSONArray args = new JSONArray(argsJson);
+ String token = args.getString(0);
+ if (mSecurityToken.equals(token)) {
+ mSecurityVerified = true;
+ Log.i(TAG, "Security token verified successfully");
+ } else {
+ Log.e(TAG, "Security token mismatch! Potential attack detected.");
+ // Destroy the WebView - don't trust it
+ post(() -> {
+ stopLoading();
+ // Send error event before destroying
+ RCTEventEmitter emitter =
+ mContext.getReactApplicationContext().getJSModule(RCTEventEmitter.class);
+ WritableMap event = Arguments.createMap();
+ event.putString("source", "Security verification failed - possible server hijacking");
+ emitter.receiveEvent(getId(), "onScriptError", event);
+ destroy();
+ });
+ }
+ } catch (JSONException e) {
+ Log.e(TAG, "Failed to parse security token", e);
+ }
+ }
@JavascriptInterface
public void postMessage(String message) {
+ // Block until security is verified to prevent malicious page communication
+ if (!mSecurityVerified) {
+ Log.w(TAG, "⚠️ WARNING: Blocked postMessage - BundleHTTPServer security token not yet verified!");
+ return;
+ }
RCTEventEmitter emitter =
mContext.getReactApplicationContext().getJSModule(RCTEventEmitter.class);
WritableMap event = Arguments.createMap();
@@ -79,6 +169,11 @@ public void postMessage(String message) {
@JavascriptInterface
public void scriptError(String source) {
+ // Block until security is verified (matches iOS behavior)
+ if (!mSecurityVerified) {
+ Log.w(TAG, "⚠️ WARNING: Blocked scriptError - BundleHTTPServer security token not yet verified!");
+ return;
+ }
RCTEventEmitter emitter =
mContext.getReactApplicationContext().getJSModule(RCTEventEmitter.class);
WritableMap event = Arguments.createMap();
@@ -90,18 +185,16 @@ public void scriptError(String source) {
// utilities -------------------------------------------------------------
private void visitPage() {
- String source = mSource == null ? BASE_URL + "edge-core-js/edge-core.js" : mSource;
- String html =
- ""
- + ""
- + "edge-core-js"
- + ""
- + "";
- loadDataWithBaseURL(BASE_URL, html, "text/html", null, null);
+ // Load the page from the HTTP server to get COOP/COEP headers
+ // which are required for SharedArrayBuffer support (needed by mixFetch web workers)
+ String url = BASE_URL;
+ if (mSource != null && mSource.contains("localhost:8080")) {
+ url = "http://localhost:8080/";
+ // Auto-verify in debug mode - security token is only for BundleHTTPServer
+ mSecurityVerified = true;
+ Log.i(TAG, "Debug mode detected (localhost:8080) - bypassing BundleHTTPServer security token verification");
+ }
+ loadUrl(url);
}
private class WebViewPromise implements PendingCall {
diff --git a/edge-core-js.podspec b/edge-core-js.podspec
index bbe24aca8..29aeb8825 100644
--- a/edge-core-js.podspec
+++ b/edge-core-js.podspec
@@ -24,6 +24,7 @@ Pod::Spec.new do |s|
"android/src/main/cpp/scrypt/sysendian.h",
"ios/Disklet.swift",
"ios/edge-core-js-Bridging-Header.h",
+ "ios/BundleHTTPServer.swift",
"ios/EdgeCoreWebView.swift",
"ios/EdgeCoreWebViewManager.m",
"ios/EdgeCoreWebViewManager.swift",
@@ -31,7 +32,7 @@ Pod::Spec.new do |s|
"ios/PendingCall.swift"
s.resource_bundles = {
- "edge-core-js" => "android/src/main/assets/edge-core-js/edge-core.js"
+ "edge-core-js" => "android/src/main/assets/edge-core-js/*"
}
s.dependency "React-Core"
diff --git a/ios/BundleHTTPServer.swift b/ios/BundleHTTPServer.swift
new file mode 100644
index 000000000..4e9ea170e
--- /dev/null
+++ b/ios/BundleHTTPServer.swift
@@ -0,0 +1,357 @@
+import Foundation
+import Network
+
+class BundleHTTPServer {
+ private var listener: NWListener?
+ private let port: UInt16
+ private let queue = DispatchQueue(label: "com.edge.bundleserver")
+ private let securityToken: String
+
+ init(port: UInt16 = 3693, securityToken: String) {
+ self.port = port
+ self.securityToken = securityToken
+ }
+
+ func start() {
+ do {
+ let port = NWEndpoint.Port(integerLiteral: self.port)
+ listener = try NWListener(using: .tcp, on: port)
+
+ listener?.stateUpdateHandler = { [weak self] state in
+ switch state {
+ case .ready:
+ print("HTTP Server ready on localhost:\(self?.port ?? 0)")
+ case .failed(let error):
+ print("HTTP Server failed with error: \(error)")
+ case .cancelled:
+ print("HTTP Server was cancelled")
+ default:
+ break
+ }
+ }
+
+ listener?.newConnectionHandler = { [weak self] connection in
+ self?.handleConnection(connection)
+ }
+
+ listener?.start(queue: queue)
+ } catch {
+ print("Failed to start HTTP server: \(error)")
+ }
+ }
+
+ func stop() {
+ listener?.cancel()
+ }
+
+ private func handleConnection(_ connection: NWConnection) {
+ connection.stateUpdateHandler = { state in
+ switch state {
+ case .ready:
+ self.receiveRequest(on: connection)
+ case .failed(let error):
+ print("Connection failed: \(error)")
+ connection.cancel()
+ case .cancelled:
+ break
+ default:
+ break
+ }
+ }
+
+ connection.start(queue: queue)
+ }
+
+ private func receiveRequest(on connection: NWConnection) {
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 2048) { [weak self] content, _, isComplete, error in
+ guard let self = self else { return }
+
+ if let error = error {
+ print("Error receiving request: \(error)")
+ connection.cancel()
+ return
+ }
+
+ guard let content = content, !content.isEmpty else {
+ if isComplete {
+ connection.cancel()
+ }
+ return
+ }
+
+ // Parse the request
+ if let requestString = String(data: content, encoding: .utf8) {
+ let requestLines = requestString.components(separatedBy: "\r\n")
+ if let firstLine = requestLines.first {
+ let components = firstLine.components(separatedBy: " ")
+ if components.count >= 2 {
+ let method = components[0]
+ var path = components[1]
+
+ // Remove query parameters if any
+ if let queryStart = path.firstIndex(of: "?") {
+ path = String(path[..= 2 {
+ let filename = components[0]
+ let fileExtension = components[1]
+ url = bundle.url(forResource: filename, withExtension: fileExtension)
+ }
+ } else {
+ // Otherwise, try to find the resource without an extension
+ url = bundle.url(forResource: path, withExtension: nil)
+ }
+ }
+
+ // If not found in the bundle, check the main bundle directly
+ if url == nil {
+ let components = path.components(separatedBy: ".")
+ if components.count >= 2 {
+ let filename = components[0]
+ let fileExtension = components[1]
+ url = Bundle.main.url(forResource: filename, withExtension: fileExtension)
+ } else {
+ url = Bundle.main.url(forResource: path, withExtension: nil)
+ }
+ }
+
+ if let url = url {
+ do {
+ data = try Data(contentsOf: url)
+ } catch {
+ print("Error reading file: \(error)")
+ }
+ }
+
+ guard var fileData = data else {
+ sendResponse(code: 404, body: "Not Found", connection: connection)
+ return
+ }
+
+ // For index.html, inject the security token so the page can verify itself
+ if path == "index.html",
+ var htmlString = String(data: fileData, encoding: .utf8) {
+ let tokenScript = ""
+ htmlString = htmlString.replacingOccurrences(of: "", with: "\n \(tokenScript)")
+ if let injectedData = htmlString.data(using: .utf8) {
+ fileData = injectedData
+ }
+ }
+
+ let mimeType = mimeTypeForPath(path)
+ let headers = [
+ "HTTP/1.1 200 OK",
+ "Content-Type: \(mimeType)",
+ "Content-Length: \(fileData.count)",
+ "Connection: close",
+ "Server: EdgeCoreBundleServer/1.0",
+ // Cross-origin isolation headers required for SharedArrayBuffer (needed by mixFetch web workers)
+ "Cross-Origin-Opener-Policy: same-origin",
+ "Cross-Origin-Embedder-Policy: require-corp",
+ "\r\n"
+ ].joined(separator: "\r\n")
+
+ let headerData = headers.data(using: .utf8)!
+ let responseData = NSMutableData()
+ responseData.append(headerData)
+ responseData.append(fileData)
+
+ connection.send(content: responseData as Data, completion: .contentProcessed { error in
+ if let error = error {
+ print("Error sending response: \(error)")
+ }
+ connection.cancel()
+ })
+ }
+
+ private func servePluginFile(_ path: String, method: String, connection: NWConnection) {
+ // Only support GET requests
+ guard method == "GET" else {
+ sendResponse(code: 405, body: "Method Not Allowed", connection: connection)
+ return
+ }
+
+ // Get the app's main bundle path - plugins are in edge-core/ subdirectory
+ let bundlePath = Bundle.main.bundlePath
+ let edgeCorePath = (bundlePath as NSString).appendingPathComponent("edge-core")
+ var data: Data?
+
+ // Try multiple path patterns
+ let pathsToTry: [String]
+
+ if path.contains(".bundle/") {
+ // Path like: "edge-currency-accountbased.bundle/edge-currency-accountbased.js"
+ pathsToTry = [path]
+ } else {
+ // Path like: "plugin-bundle.js" - try with .bundle folder too
+ let fileName = (path as NSString).lastPathComponent
+ let baseName = (fileName as NSString).deletingPathExtension
+ pathsToTry = [
+ path, // plugin-bundle.js
+ "\(baseName).bundle/\(fileName)" // plugin-bundle.bundle/plugin-bundle.js
+ ]
+ }
+
+ for relativePath in pathsToTry {
+ // Try edge-core/ subdirectory first (for plugin-bundle.js)
+ let edgeCoreFull = (edgeCorePath as NSString).appendingPathComponent(relativePath)
+ if FileManager.default.fileExists(atPath: edgeCoreFull) {
+ do {
+ data = try Data(contentsOf: URL(fileURLWithPath: edgeCoreFull))
+ print("Found plugin file at: \(edgeCoreFull)")
+ break
+ } catch {
+ print("Error reading file at \(edgeCoreFull): \(error)")
+ }
+ }
+
+ // Fall back to app bundle root (for .bundle/ plugins)
+ if data == nil {
+ let rootFull = (bundlePath as NSString).appendingPathComponent(relativePath)
+ if FileManager.default.fileExists(atPath: rootFull) {
+ do {
+ data = try Data(contentsOf: URL(fileURLWithPath: rootFull))
+ print("Found plugin file at: \(rootFull)")
+ break
+ } catch {
+ print("Error reading file at \(rootFull): \(error)")
+ }
+ }
+ }
+ }
+
+ guard let fileData = data else {
+ print("Plugin file not found: \(path)")
+ sendResponse(code: 404, body: "Not Found: \(path)", connection: connection)
+ return
+ }
+
+ let mimeType = mimeTypeForPath(path)
+ let headers = [
+ "HTTP/1.1 200 OK",
+ "Content-Type: \(mimeType)",
+ "Content-Length: \(fileData.count)",
+ "Connection: close",
+ "Server: EdgeCoreBundleServer/1.0",
+ // Cross-origin isolation headers required for SharedArrayBuffer (needed by mixFetch web workers)
+ "Cross-Origin-Opener-Policy: same-origin",
+ "Cross-Origin-Embedder-Policy: require-corp",
+ "\r\n"
+ ].joined(separator: "\r\n")
+
+ let headerData = headers.data(using: .utf8)!
+ let responseData = NSMutableData()
+ responseData.append(headerData)
+ responseData.append(fileData)
+
+ connection.send(content: responseData as Data, completion: .contentProcessed { error in
+ if let error = error {
+ print("Error sending plugin response: \(error)")
+ }
+ connection.cancel()
+ })
+ }
+
+ private func sendResponse(code: Int, body: String, connection: NWConnection) {
+ var status = ""
+ switch code {
+ case 200: status = "OK"
+ case 400: status = "Bad Request"
+ case 404: status = "Not Found"
+ case 405: status = "Method Not Allowed"
+ default: status = "Internal Server Error"
+ }
+
+ let bodyData = body.data(using: .utf8)!
+ let headers = [
+ "HTTP/1.1 \(code) \(status)",
+ "Content-Type: text/plain",
+ "Content-Length: \(bodyData.count)",
+ "Connection: close",
+ "Server: EdgeCoreBundleServer/1.0",
+ // Cross-origin isolation headers required for SharedArrayBuffer (needed by mixFetch web workers)
+ "Cross-Origin-Opener-Policy: same-origin",
+ "Cross-Origin-Embedder-Policy: require-corp",
+ "\r\n"
+ ].joined(separator: "\r\n")
+
+ let headerData = headers.data(using: .utf8)!
+ let responseData = NSMutableData()
+ responseData.append(headerData)
+ responseData.append(bodyData)
+
+ connection.send(content: responseData as Data, completion: .contentProcessed { error in
+ if let error = error {
+ print("Error sending response: \(error)")
+ }
+ connection.cancel()
+ })
+ }
+
+ private func mimeTypeForPath(_ path: String) -> String {
+ let ext = (path as NSString).pathExtension.lowercased()
+
+ switch ext {
+ case "html", "htm": return "text/html"
+ case "js": return "application/javascript"
+ case "css": return "text/css"
+ case "json": return "application/json"
+ case "png": return "image/png"
+ case "jpg", "jpeg": return "image/jpeg"
+ case "gif": return "image/gif"
+ case "svg": return "image/svg+xml"
+ case "woff": return "font/woff"
+ case "woff2": return "font/woff2"
+ case "ttf": return "font/ttf"
+ case "wasm": return "application/wasm"
+ case "xml": return "application/xml"
+ case "txt": return "text/plain"
+ default: return "application/octet-stream"
+ }
+ }
+}
diff --git a/ios/EdgeCoreWebView.swift b/ios/EdgeCoreWebView.swift
index 642f30a7a..8ee2fbca7 100644
--- a/ios/EdgeCoreWebView.swift
+++ b/ios/EdgeCoreWebView.swift
@@ -1,9 +1,21 @@
import WebKit
+import Foundation
+import Network
class EdgeCoreWebView: RCTView, WKNavigationDelegate, WKScriptMessageHandler {
var native = EdgeNative()
var webView: WKWebView?
-
+ private var httpServer: BundleHTTPServer?
+ private let securityToken: String = EdgeCoreWebView.generateSecurityToken()
+ private var isSecurityVerified: Bool = false
+
+ // Generate a crypto-secure 128-bit hex token
+ private static func generateSecurityToken() -> String {
+ var bytes = [UInt8](repeating: 0, count: 16) // 128 bits = 16 bytes
+ _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
+ return bytes.map { String(format: "%02x", $0) }.joined()
+ }
+
// react api--------------------------------------------------------------
@objc var onMessage: RCTDirectEventBlock?
@@ -36,6 +48,11 @@ class EdgeCoreWebView: RCTView, WKNavigationDelegate, WKScriptMessageHandler {
override init(frame: CGRect) {
super.init(frame: frame)
+ // Start the HTTP server with security token
+ let server = BundleHTTPServer(port: 3693, securityToken: securityToken)
+ server.start()
+ self.httpServer = server
+
// Set up our native bridge:
let configuration = WKWebViewConfiguration()
configuration.userContentController = WKUserContentController()
@@ -64,6 +81,10 @@ class EdgeCoreWebView: RCTView, WKNavigationDelegate, WKScriptMessageHandler {
webView.removeFromSuperview()
self.webView = nil
}
+
+ // Stop the HTTP server when view is removed
+ httpServer?.stop()
+ httpServer = nil
}
// callbacks -------------------------------------------------------------
@@ -91,7 +112,14 @@ class EdgeCoreWebView: RCTView, WKNavigationDelegate, WKScriptMessageHandler {
let name = call[1] as? String,
let args = call[2] as? NSArray
{
+ // Handle special messages (id == 0) including security verification
if id == 0 { return handleMessage(name, args: args) }
+
+ // Block all other communication until security is verified
+ guard isSecurityVerified else {
+ print("⚠️ WARNING: EdgeCoreWebView blocked message '\(name)' - BundleHTTPServer security token not yet verified!")
+ return
+ }
let promise = PendingCall(
resolve: { result in
@@ -116,6 +144,32 @@ class EdgeCoreWebView: RCTView, WKNavigationDelegate, WKScriptMessageHandler {
func handleMessage(
_ name: String, args: NSArray
) {
+ // Security token verification - must happen before any other communication
+ if name == "verifySecurityToken", let token = args[0] as? String {
+ if token == securityToken {
+ isSecurityVerified = true
+ print("EdgeCoreWebView: Security token verified successfully")
+ } else {
+ print("EdgeCoreWebView: Security token mismatch! Potential attack detected.")
+ // Destroy the WebView - don't trust it
+ webView?.stopLoading()
+ if let webView = self.webView {
+ webView.configuration.userContentController
+ .removeScriptMessageHandler(forName: "edgeCore")
+ webView.removeFromSuperview()
+ self.webView = nil
+ }
+ onScriptError?(["source": "Security verification failed - possible server hijacking"])
+ }
+ return
+ }
+
+ // Block all other id==0 messages until security is verified
+ guard isSecurityVerified else {
+ print("⚠️ WARNING: EdgeCoreWebView blocked '\(name)' - BundleHTTPServer security token not yet verified!")
+ return
+ }
+
if name == "postMessage", let message = args[0] as? String {
onMessage?(["message": message])
return
@@ -127,16 +181,7 @@ class EdgeCoreWebView: RCTView, WKNavigationDelegate, WKScriptMessageHandler {
}
func defaultSource() -> String? {
- if let bundleUrl = Bundle.main.url(
- forResource: "edge-core-js",
- withExtension: "bundle"
- ),
- let bundle = Bundle(url: bundleUrl),
- let script = bundle.url(forResource: "edge-core", withExtension: "js")
- {
- return script.absoluteString
- }
- return nil
+ return "http://localhost:3693/edge-core.js"
}
func stringify(_ raw: Any?) -> String {
@@ -154,22 +199,20 @@ class EdgeCoreWebView: RCTView, WKNavigationDelegate, WKScriptMessageHandler {
}
func visitPage() {
- if let src = source ?? defaultSource() {
- webView?.loadHTMLString(
- """
-
-
- edge-core-js
-
-
- """,
- baseURL: Bundle.main.bundleURL
- )
+ // If source is set to webpack dev server (debug mode), load from there
+ // Otherwise load from the local bundle HTTP server
+ let baseUrl: String
+ if let src = source, src.contains("localhost:8080") {
+ baseUrl = "http://localhost:8080/"
+ // Auto-verify in debug mode - security token is only for BundleHTTPServer
+ isSecurityVerified = true
+ print("EdgeCoreWebView: Debug mode detected (localhost:8080) - bypassing BundleHTTPServer security token verification")
+ } else {
+ baseUrl = "http://localhost:3693/"
}
+
+ let url = URL(string: baseUrl)!
+ let request = URLRequest(url: url)
+ webView?.load(request)
}
}
diff --git a/nym-to-core.md b/nym-to-core.md
new file mode 100644
index 000000000..4404a7775
--- /dev/null
+++ b/nym-to-core.md
@@ -0,0 +1,172 @@
+# Add NYM mixFetch support to edge-core-js
+
+## Overview
+
+edge-core-js exposes io.fetch to various plugins which have an opts param that can specify args like corsBypass.
+
+Add a new opts param mixNet?: boolean which if true will use the NYM mixnet via mixFetch
+
+Use the nymtest repo as an example of how to implement mixFetch.
+
+Implement for ios and android (which run in a webview), browser, and nodejs
+
+Use latest version of mix-fetch
+
+Ok to change the WebViewAssetLoader approach system wide
+
+Ensure that the app at least successfull compiles with your changes.
+
+You can test changes with these steps
+
+cd edge-core-js
+yarn prepare
+
+cd ../edge-react-gui
+yarn updot edge-core-js
+
+yarn preapre
+yarn prepare.ios
+yarn ios
+
+## Type Changes
+
+Update `EdgeFetchOptions` in `src/types/types.ts`:
+
+```typescript
+export type EdgeFetchOptions = FetchOptions & {
+ corsBypass?: 'auto' | 'always' | 'never'
+ mixNet?: boolean // Use NYM mixnet for this request
+}
+```
+
+Update `EdgeIo` in `src/types/types.ts`:
+
+```typescript
+export interface EdgeIo {
+ // ... existing props ...
+
+ /**
+ * Initialize the NYM mixnet client. Browser/WebView only.
+ * Must be called before mixNet: true will work in fetch().
+ * Safe to call multiple times (no-op after first success).
+ * Throws on non-browser platforms or if initialization fails.
+ */
+ readonly startMixnet?: () => Promise
+}
+```
+
+## Initialization
+
+Expose an io.startMixnet() which when called would import the wasm and start the webworker and call createMixFetch.
+
+Until createMixFetch returns, any call to io.fetch with `mixNet` set to true will be treated as if `mixNet` were false.
+
+## Build System
+
+Update the build system for edge-core-js so that the webworker and wasm files would be properly copied to the assets for ios and android and used when needed.
+
+Files to copy from `@nymproject/mix-fetch`:
+
+- `*.wasm` (WebAssembly modules)
+- `web-worker-*.js` (Worker scripts)
+
+## Cross-Origin Isolation for Mobile WebViews
+
+mixFetch requires SharedArrayBuffer which needs cross-origin isolation. The WebView must serve content with these headers:
+
+- `Cross-Origin-Opener-Policy: same-origin`
+- `Cross-Origin-Embedder-Policy: require-corp`
+
+Since edge-core-js WebViews use `loadHTMLString`/`loadDataWithBaseURL` (not HTTP), we need to intercept requests:
+
+### Android
+
+Use `WebViewAssetLoader` with `shouldInterceptRequest` to serve local assets with proper headers:
+
+```java
+// In EdgeCoreWebView.java
+import androidx.webkit.WebViewAssetLoader;
+
+WebViewAssetLoader assetLoader = new WebViewAssetLoader.Builder()
+ .addPathHandler("/assets/", new WebViewAssetLoader.AssetsPathHandler(context))
+ .build();
+
+setWebViewClient(new WebViewClient() {
+ @Override
+ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
+ WebResourceResponse response = assetLoader.shouldInterceptRequest(request.getUrl());
+ if (response != null) {
+ Map headers = new HashMap<>(response.getResponseHeaders());
+ headers.put("Cross-Origin-Opener-Policy", "same-origin");
+ headers.put("Cross-Origin-Embedder-Policy", "require-corp");
+ return new WebResourceResponse(
+ response.getMimeType(),
+ response.getEncoding(),
+ response.getStatusCode(),
+ response.getReasonPhrase(),
+ headers,
+ response.getData()
+ );
+ }
+ return null;
+ }
+});
+```
+
+Also need to load via HTTPS scheme for cross-origin isolation to work:
+
+```java
+// Change from file:// to https://appassets.androidplatform.net/
+loadUrl("https://appassets.androidplatform.net/assets/edge-core-js/edge-core.html");
+```
+
+### iOS
+
+Use `WKURLSchemeHandler` to serve local content with proper headers:
+
+```swift
+// Create custom URL scheme handler
+class AssetSchemeHandler: NSObject, WKURLSchemeHandler {
+ func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
+ // Load local asset and respond with COOP/COEP headers
+ let headers = [
+ "Cross-Origin-Opener-Policy": "same-origin",
+ "Cross-Origin-Embedder-Policy": "require-corp",
+ "Content-Type": "application/javascript"
+ ]
+ let response = HTTPURLResponse(
+ url: urlSchemeTask.request.url!,
+ statusCode: 200,
+ httpVersion: "HTTP/1.1",
+ headerFields: headers
+ )!
+ urlSchemeTask.didReceive(response)
+ // ... send asset data ...
+ }
+}
+
+// Register in WKWebViewConfiguration
+configuration.setURLSchemeHandler(AssetSchemeHandler(), forURLScheme: "edge-assets")
+```
+
+## Configuration
+
+Use these init options:
+
+```typescript
+const mixFetchOptions: SetupMixFetchOps = {
+ preferredGateway: '2xU4CBE6QiiYt6EyBXSALwxkNvM7gqJfjHXaMkjiFmYW', // with WSS
+ // preferredNetworkRequester:
+ // "CTDxrcXgrZHWyCWnuCgjpJPghQUcEVz1HkhUr5mGdFnT.3UAww1YWNyVNYNWFQL1LaHYouQtDiXBGK5GiDZgpXkTK@2RFtU5BwxvJJXagAWAEuaPgb5ZVPRoy2542TT93Edw6v",
+ mixFetchOverride: {
+ requestTimeoutMs: 10_000
+ },
+ clientOverride: {
+ traffic: {
+ averagePacketDelayMs: 1,
+ messageSendingAverageDelayMs: 1
+ }
+ },
+ forceTls: true // force WSS
+}
+```
diff --git a/package.json b/package.json
index df3c5c833..ada99d674 100644
--- a/package.json
+++ b/package.json
@@ -80,6 +80,7 @@
"*.{js,jsx,ts,tsx}": "eslint"
},
"dependencies": {
+ "@nymproject/mix-fetch": "^1.4.1",
"aes-js": "^3.1.0",
"base-x": "^4.0.0",
"biggystring": "^4.2.3",
@@ -125,6 +126,7 @@
"babel-plugin-transform-fake-error-class": "^1.0.0",
"buffer": "^6.0.3",
"chai": "^4.2.0",
+ "copy-webpack-plugin": "^13.0.1",
"eslint": "^7.32.0",
"eslint-config-standard-kit": "0.15.1",
"eslint-plugin-flowtype": "^5.2.0",
diff --git a/src/index.html b/src/index.html
new file mode 100644
index 000000000..a01299610
--- /dev/null
+++ b/src/index.html
@@ -0,0 +1,9 @@
+
+
+
+
+ edge-core-js
+
+
+
+
diff --git a/src/io/browser/browser-io.ts b/src/io/browser/browser-io.ts
index efe24f543..628331da3 100644
--- a/src/io/browser/browser-io.ts
+++ b/src/io/browser/browser-io.ts
@@ -2,6 +2,7 @@ import { makeLocalStorageDisklet } from 'disklet'
import { EdgeFetchOptions, EdgeFetchResponse, EdgeIo } from '../../types/types'
import { scrypt } from '../../util/crypto/scrypt'
+import { queuedMixFetch } from '../../util/nym'
import { fetchCorsProxy } from './fetch-cors-proxy'
// Only try CORS proxy/bridge techniques up to 5 times
@@ -43,8 +44,14 @@ export function makeBrowserIo(): EdgeIo {
uri: string,
opts?: EdgeFetchOptions
): Promise {
- const { corsBypass = 'auto' } = opts ?? {}
+ const { corsBypass = 'auto', privacy = 'none' } = opts ?? {}
+ if (privacy === 'nym') {
+ return await queuedMixFetch(uri, {
+ ...opts,
+ mode: 'unsafe-ignore-cors' as RequestMode
+ })
+ }
if (corsBypass === 'always') {
return await fetchCorsProxy(uri, opts)
}
diff --git a/src/io/react-native/react-native-worker.ts b/src/io/react-native/react-native-worker.ts
index 4b691510a..4ed76253e 100644
--- a/src/io/react-native/react-native-worker.ts
+++ b/src/io/react-native/react-native-worker.ts
@@ -16,10 +16,34 @@ import {
EdgeFetchResponse,
EdgeIo
} from '../../types/types'
+import { initMixFetch, queuedMixFetch } from '../../util/nym'
import { hideProperties } from '../hidden-properties'
import { makeNativeBridge } from './native-bridge'
import { WorkerApi, YAOB_THROTTLE_MS } from './react-native-types'
+// Security token verification - must happen before any other communication.
+// The BundleHTTPServer injects this token into index.html. We send it back
+// to native to prove that we were served by the legitimate server.
+// This prevents attacks where a malicious app runs a server on port 3693.
+const securityToken = (window as any).__EDGE_BUNDLE_SECURITY_TOKEN__
+if (typeof securityToken === 'string') {
+ if ((window as any).edgeCore != null) {
+ // Android: call with id=0 for special message handling
+ ;(window as any).edgeCore.call(
+ 0,
+ 'verifySecurityToken',
+ JSON.stringify([securityToken])
+ )
+ } else if ((window as any).webkit?.messageHandlers?.edgeCore != null) {
+ // iOS: postMessage with id=0 for special message handling
+ ;(window as any).webkit.messageHandlers.edgeCore.postMessage([
+ 0,
+ 'verifySecurityToken',
+ [securityToken]
+ ])
+ }
+}
+
// Tracks the status of different URI endpoints for the CORS bouncer:
const endpointCorsState = new Map<
string,
@@ -71,6 +95,35 @@ window.addEdgeCorePlugins = addEdgeCorePlugins
window.nativeBridge = nativeBridge
window.reactBridge = reactBridge
+/**
+ * Convert a file:// URI to a domain-relative path served by our local bundle server.
+ * This is needed because cross-origin isolation (COOP/COEP) blocks file:// loads.
+ * Using domain-relative paths ensures plugins load from the same origin as the page
+ * (localhost:3693 in production, localhost:8080 in debug mode).
+ */
+function convertPluginUri(uri: string): string {
+ // If already an HTTP URI, use as-is (e.g., custom bundles from metro bundler)
+ if (uri.startsWith('http://') || uri.startsWith('https://')) {
+ return uri
+ }
+
+ // Convert file:// URIs to domain-relative paths
+ // Extract the bundle path (e.g., "edge-currency-accountbased.bundle/edge-currency-accountbased.js")
+ const bundleMatch = uri.match(/([^/]+\.bundle\/[^/]+\.js)$/)
+ if (bundleMatch != null) {
+ return `/plugin/${bundleMatch[1]}`
+ }
+
+ // Fallback: try to extract just the filename
+ const fileMatch = uri.match(/([^/]+\.js)$/)
+ if (fileMatch != null) {
+ return `/plugin/${fileMatch[1]}`
+ }
+
+ // If we can't parse it, return as-is (will likely fail, but provides debugging info)
+ return uri
+}
+
function loadPlugins(pluginUris: string[]): void {
const { head } = window.document
if (head == null || pluginUris.length === 0) {
@@ -89,7 +142,7 @@ function loadPlugins(pluginUris: string[]): void {
script.addEventListener('load', handleLoad)
script.charset = 'utf-8'
script.defer = true
- script.src = uri
+ script.src = convertPluginUri(uri)
head.appendChild(script)
}
}
@@ -170,8 +223,25 @@ async function makeIo(): Promise {
uri: string,
opts?: EdgeFetchOptions
): Promise {
- const { corsBypass = 'auto' } = opts ?? {}
+ const { corsBypass = 'auto', privacy = 'none' } = opts ?? {}
+ // temporarily enable mixFetch always for development purposes
+ if (privacy === 'nym') {
+ try {
+ // Ensure mixFetch is initialized before use
+ await initMixFetch()
+ // Use queued fetch to handle mixFetch's one-request-per-host limitation
+ const response = await queuedMixFetch(uri, {
+ ...opts,
+ mode: 'unsafe-ignore-cors' as RequestMode
+ })
+ console.warn('mixFetch response successful:', uri)
+ return response
+ } catch (error) {
+ console.error('mixFetch error:', error)
+ throw error
+ }
+ }
if (corsBypass === 'always') {
return await nativeFetch(uri, opts)
}
diff --git a/src/types/types.ts b/src/types/types.ts
index 78619091b..08d2b8402 100644
--- a/src/types/types.ts
+++ b/src/types/types.ts
@@ -50,6 +50,7 @@ export type EdgeScryptFunction = (
// The subset of the fetch function Edge expects:
export type EdgeFetchOptions = FetchOptions & {
+ privacy?: 'none' | 'nym'
corsBypass?: 'auto' | 'always' | 'never'
}
export type EdgeFetchHeaders = FetchHeaders
diff --git a/src/util/nym.ts b/src/util/nym.ts
new file mode 100644
index 000000000..e452085d3
--- /dev/null
+++ b/src/util/nym.ts
@@ -0,0 +1,120 @@
+import {
+ createMixFetch,
+ mixFetch,
+ SetupMixFetchOps
+} from '@nymproject/mix-fetch'
+
+/**
+ * Configuration options for the NYM mixFetch client.
+ */
+export const mixFetchOptions: SetupMixFetchOps = {
+ preferredGateway: '5rXcNe2a44vXisK3uqLHCzpzvEwcnsijDMU7hg4fcYk8', // with WSS
+ preferredNetworkRequester:
+ '5x6q9UfVHs5AohKMUqeivj7a556kVVy7QwoKige8xHxh.6CFoB3kJaDbYz6oafPJxNxNjzahpT2NtgtytcSyN9EvF@5rXcNe2a44vXisK3uqLHCzpzvEwcnsijDMU7hg4fcYk8',
+ mixFetchOverride: {
+ requestTimeoutMs: 60_000
+ },
+ forceTls: true, // force WSS
+ extra: {}
+}
+
+// MixFetch initialization state
+let mixFetchInitPromise: Promise | null = null
+let mixFetchReady = false
+let mixFetchError: Error | null = null
+
+// Per-host request queue to handle mixFetch's one-request-per-host limitation
+// Maps host -> Promise that resolves when current request completes (for chaining)
+const hostRequestChains = new Map>()
+
+/**
+ * Extract the host:port from a URI for queue keying
+ */
+function getHostKey(uri: string): string {
+ try {
+ const url = new URL(uri)
+ const port =
+ url.port !== '' ? url.port : url.protocol === 'https:' ? '443' : '80'
+ return `${url.hostname}:${port}`
+ } catch {
+ return uri
+ }
+}
+
+/**
+ * Initialize the NYM mixFetch client. Must be called before using mixFetch.
+ * Safe to call multiple times - subsequent calls return the same promise.
+ */
+export async function initMixFetch(): Promise {
+ // Return existing promise if already initializing
+ if (mixFetchInitPromise != null) {
+ return await mixFetchInitPromise
+ }
+
+ // Already initialized successfully
+ if (mixFetchReady) {
+ return
+ }
+
+ // Previous initialization failed - throw the cached error
+ if (mixFetchError != null) {
+ throw mixFetchError
+ }
+
+ mixFetchInitPromise = (async () => {
+ try {
+ console.log('Initializing mixFetch...')
+ await createMixFetch(mixFetchOptions)
+ mixFetchReady = true
+ console.log('mixFetch initialized successfully')
+ } catch (error) {
+ mixFetchError = error as Error
+ console.error('mixFetch initialization failed:', error)
+ throw error
+ }
+ })()
+
+ return await mixFetchInitPromise
+}
+
+/**
+ * Queue-wrapped mixFetch that serializes requests per host.
+ * mixFetch only allows one concurrent request per host, so we chain them.
+ * Key insight: Store our chain promise BEFORE awaiting the previous one.
+ */
+export async function queuedMixFetch(
+ uri: string,
+ opts: RequestInit & { mode?: string }
+): Promise {
+ const hostKey = getHostKey(uri)
+
+ // Get the current chain for this host (or resolved promise if none)
+ const previousChain = hostRequestChains.get(hostKey) ?? Promise.resolve()
+
+ // Create a deferred that we'll resolve when our request completes
+ let resolveChain: () => void = () => {}
+ const ourChain = new Promise(resolve => {
+ resolveChain = resolve
+ })
+
+ // IMMEDIATELY store our chain - this ensures subsequent requests wait for us
+ hostRequestChains.set(hostKey, ourChain)
+
+ // Now wait for the previous request to complete
+ try {
+ await previousChain
+ } catch {
+ // Ignore errors from previous request
+ }
+
+ try {
+ return await mixFetch(uri, opts, mixFetchOptions)
+ } finally {
+ // Signal completion so next request can proceed
+ resolveChain()
+ // Clean up if we're still the chain tail
+ if (hostRequestChains.get(hostKey) === ourChain) {
+ hostRequestChains.delete(hostKey)
+ }
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
index 9c9ea61ce..3d3bd6c95 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,6 +8,8 @@
"moduleResolution": "node",
"target": "es2015",
+ "skipLibCheck": true,
+
"strict": true
},
"exclude": ["lib"]
diff --git a/webpack.config.js b/webpack.config.js
index 776dd6a7e..3c95052d6 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,5 +1,6 @@
const { exec } = require('child_process')
const path = require('path')
+const CopyPlugin = require('copy-webpack-plugin')
const TerserPlugin = require('terser-webpack-plugin')
const webpack = require('webpack')
@@ -32,14 +33,35 @@ const babelOptions = {
}
module.exports = {
- devtool: debug ? 'source-map' : undefined,
+ devtool: 'source-map',
devServer: {
allowedHosts: 'all',
+ headers: {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
+ 'Access-Control-Allow-Headers':
+ 'X-Requested-With, content-type, Authorization',
+ 'Cross-Origin-Resource-Policy': 'cross-origin',
+ // Cross-origin isolation headers required for SharedArrayBuffer (needed by mixFetch web workers)
+ 'Cross-Origin-Opener-Policy': 'same-origin',
+ 'Cross-Origin-Embedder-Policy': 'require-corp'
+ },
hot: false,
- static: bundlePath
+ static: bundlePath,
+ // Proxy /plugin/ requests to BundleHTTPServer since plugins are in the app bundle
+ proxy: [
+ {
+ context: ['/plugin'],
+ target: 'http://localhost:3693',
+ changeOrigin: true
+ }
+ ]
},
entry: './src/io/react-native/react-native-worker.ts',
- mode: debug ? 'development' : 'production',
+ experiments: {
+ asyncWebAssembly: true
+ },
+ mode: 'development',
module: {
rules: [
{
@@ -61,6 +83,10 @@ module.exports = {
loader: 'babel-loader',
options: { presets: ['@babel/preset-env'] }
}
+ },
+ {
+ test: /\.wasm$/,
+ type: 'webassembly/async'
}
]
},
@@ -73,7 +99,33 @@ module.exports = {
},
plugins: [
new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'] }),
- new webpack.ProvidePlugin({ process: ['process'] })
+ new webpack.ProvidePlugin({ process: ['process'] }),
+ // Copy static files and mix-fetch WASM/worker files
+ new CopyPlugin({
+ patterns: [
+ // HTML entry point
+ {
+ from: path.resolve(__dirname, 'src/index.html'),
+ to: 'index.html'
+ },
+ // mix-fetch WASM files for NYM mixnet support
+ {
+ from: path.resolve(
+ __dirname,
+ 'node_modules/@nymproject/mix-fetch/*.wasm'
+ ),
+ to: '[name][ext]'
+ },
+ // mix-fetch web worker files
+ {
+ from: path.resolve(
+ __dirname,
+ 'node_modules/@nymproject/mix-fetch/web-worker-*.js'
+ ),
+ to: '[name][ext]'
+ }
+ ]
+ })
],
performance: { hints: false },
resolve: {
diff --git a/yarn.lock b/yarn.lock
index df8680ad2..c06e7a1ae 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1133,6 +1133,11 @@
"@nodelib/fs.scandir" "2.1.3"
fastq "^1.6.0"
+"@nymproject/mix-fetch@^1.4.1":
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/@nymproject/mix-fetch/-/mix-fetch-1.4.1.tgz#6a923b40e09c4a571fb947297a3afd41cd2d5ea6"
+ integrity sha512-FN5UeCkje6fauCt2pd8kFGFYXj2kaNewEJVKRLWzI2/suxD+J2bmg/YvXBGLWMWglXNA3+YFHA/1Vjh6OGtgig==
+
"@pkgr/utils@^2.3.1":
version "2.4.2"
resolved "https://registry.yarnpkg.com/@pkgr/utils/-/utils-2.4.2.tgz#9e638bbe9a6a6f165580dc943f138fd3309a2cbc"
@@ -2315,6 +2320,17 @@ cookie@0.6.0:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
+copy-webpack-plugin@^13.0.1:
+ version "13.0.1"
+ resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz#fba18c22bcab3633524e1b652580ff4489eddc0d"
+ integrity sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw==
+ dependencies:
+ glob-parent "^6.0.1"
+ normalize-path "^3.0.0"
+ schema-utils "^4.2.0"
+ serialize-javascript "^6.0.2"
+ tinyglobby "^0.2.12"
+
core-js-compat@^3.31.0, core-js-compat@^3.32.2:
version "3.33.0"
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.33.0.tgz#24aa230b228406450b2277b7c8bfebae932df966"
@@ -3064,6 +3080,11 @@ faye-websocket@^0.11.3:
dependencies:
websocket-driver ">=0.5.1"
+fdir@^6.5.0:
+ version "6.5.0"
+ resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
+ integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
+
figures@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af"
@@ -3259,6 +3280,13 @@ glob-parent@^5.1.2, glob-parent@~5.1.2:
dependencies:
is-glob "^4.0.1"
+glob-parent@^6.0.1:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
+ integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
+ dependencies:
+ is-glob "^4.0.3"
+
glob-to-regexp@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
@@ -4605,6 +4633,11 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
+picomatch@^4.0.3:
+ version "4.0.3"
+ resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
+ integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
+
pidtree@^0.3.0:
version "0.3.1"
resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.3.1.tgz#ef09ac2cc0533df1f3250ccf2c4d366b0d12114a"
@@ -5007,6 +5040,16 @@ schema-utils@^4.0.0:
ajv-formats "^2.1.1"
ajv-keywords "^5.1.0"
+schema-utils@^4.2.0:
+ version "4.3.3"
+ resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.3.3.tgz#5b1850912fa31df90716963d45d9121fdfc09f46"
+ integrity sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==
+ dependencies:
+ "@types/json-schema" "^7.0.9"
+ ajv "^8.9.0"
+ ajv-formats "^2.1.1"
+ ajv-keywords "^5.1.0"
+
scrypt-js@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/scrypt-js/-/scrypt-js-2.0.3.tgz#bb0040be03043da9a012a2cea9fc9f852cfc87d4"
@@ -5080,6 +5123,13 @@ serialize-javascript@^6.0.1:
dependencies:
randombytes "^2.1.0"
+serialize-javascript@^6.0.2:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2"
+ integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==
+ dependencies:
+ randombytes "^2.1.0"
+
serve-index@^1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239"
@@ -5504,6 +5554,14 @@ thunky@^1.0.2:
resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d"
integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==
+tinyglobby@^0.2.12:
+ version "0.2.15"
+ resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2"
+ integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==
+ dependencies:
+ fdir "^6.5.0"
+ picomatch "^4.0.3"
+
titleize@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/titleize/-/titleize-3.0.0.tgz#71c12eb7fdd2558aa8a44b0be83b8a76694acd53"