diff --git a/WebDriverAgentLib/Routing/FBReverseTunnel.h b/WebDriverAgentLib/Routing/FBReverseTunnel.h new file mode 100644 index 000000000..3d356bd06 --- /dev/null +++ b/WebDriverAgentLib/Routing/FBReverseTunnel.h @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + Optional reverse TCP tunnel for NAT-restricted environments. + + When configured, this module opens an outbound TCP connection to an external + relay server, allowing WDA to be controlled in environments where inbound + connections to port 8100 are not feasible (symmetric NAT, multi-layer + firewalls, cellular networks, VPN tunnels, etc.). + + The tunnel uses an 8-byte header framing protocol (4-byte payload length + + 4-byte request ID, both big-endian) to multiplex HTTP request/response pairs + over a single persistent connection with reliable request-response correlation. + + Connection failures trigger automatic reconnection after a configurable delay. + */ +@interface FBReverseTunnel : NSObject + +/** + Starts the reverse tunnel to the specified relay host and port. + + @param host The relay server hostname or IP address + @param port The relay server port + @param localPort The local WDA HTTP server port to forward requests to + */ ++ (void)startWithHost:(NSString *)host + port:(NSInteger)port + localPort:(NSUInteger)localPort; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Routing/FBReverseTunnel.m b/WebDriverAgentLib/Routing/FBReverseTunnel.m new file mode 100644 index 000000000..d840c1de3 --- /dev/null +++ b/WebDriverAgentLib/Routing/FBReverseTunnel.m @@ -0,0 +1,233 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "FBReverseTunnel.h" +#import +#import +#import +#import "FBLogger.h" + +/** Maximum allowed payload size (matches Appium HTTP server limit) */ +static const uint32_t FBReverseTunnelMaxPayloadSize = 1024 * 1024 * 1024; // 1 GB + +/** Size of the frame header (4-byte length + 4-byte request ID) */ +static const uint32_t FBReverseTunnelHeaderSize = 8; + +/** Receive buffer size for local HTTP forwarding */ +static const size_t FBReverseTunnelRecvBufferSize = 65536; // 64 KB + +/** Initial delay before reconnecting after a connection failure */ +static const uint64_t FBReverseTunnelInitialReconnectDelay = 5; // seconds + +/** Maximum reconnect delay (exponential backoff cap) */ +static const uint64_t FBReverseTunnelMaxReconnectDelay = 60; // seconds + +static NSString *_relayHost; +static NSInteger _relayPort; +static NSUInteger _localPort; +static uint64_t _currentReconnectDelay; + +@implementation FBReverseTunnel + +#pragma mark - Public + ++ (void)startWithHost:(NSString *)host + port:(NSInteger)port + localPort:(NSUInteger)localPort +{ + _relayHost = host; + _relayPort = port; + _localPort = localPort; + _currentReconnectDelay = FBReverseTunnelInitialReconnectDelay; + [self connect]; +} + +#pragma mark - Connection Management + ++ (void)connect +{ + [FBLogger logFmt:@"[ReverseTunnel] Connecting to relay %@:%ld", _relayHost, (long)_relayPort]; + + nw_endpoint_t endpoint = nw_endpoint_create_host( + [_relayHost UTF8String], + [[NSString stringWithFormat:@"%ld", (long)_relayPort] UTF8String] + ); + nw_parameters_t params = nw_parameters_create_secure_tcp( + NW_PARAMETERS_DISABLE_PROTOCOL, + NW_PARAMETERS_DEFAULT_CONFIGURATION + ); + nw_connection_t conn = nw_connection_create(endpoint, params); + nw_connection_set_queue(conn, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)); + + nw_connection_set_state_changed_handler(conn, ^(nw_connection_state_t state, nw_error_t error) { + switch (state) { + case nw_connection_state_ready: + [FBLogger logFmt:@"[ReverseTunnel] Connected to relay"]; + _currentReconnectDelay = FBReverseTunnelInitialReconnectDelay; // reset backoff on success + [self readFrameFromConnection:conn]; + break; + case nw_connection_state_failed: + [FBLogger logFmt:@"[ReverseTunnel] Connection failed: %@, retrying in %llus", + error, FBReverseTunnelReconnectDelay]; + [self scheduleReconnect]; + break; + case nw_connection_state_cancelled: + [FBLogger logFmt:@"[ReverseTunnel] Connection cancelled"]; + break; + case nw_connection_state_waiting: + [FBLogger logFmt:@"[ReverseTunnel] Waiting for network path: %@", error]; + break; + case nw_connection_state_preparing: + [FBLogger logFmt:@"[ReverseTunnel] Preparing..."]; + break; + default: + break; + } + }); + + nw_connection_start(conn); +} + ++ (void)scheduleReconnect +{ + uint64_t delay = _currentReconnectDelay; + [FBLogger logFmt:@"[ReverseTunnel] Reconnecting in %llus (backoff)", delay]; + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), + dispatch_get_global_queue(0, 0), + ^{ [self connect]; } + ); + // Exponential backoff: double the delay, cap at max + _currentReconnectDelay = MIN(_currentReconnectDelay * 2, FBReverseTunnelMaxReconnectDelay); +} + +#pragma mark - Frame Reading (8-byte header: 4-byte length + 4-byte request ID) + ++ (void)readFrameFromConnection:(nw_connection_t)conn +{ + nw_connection_receive(conn, FBReverseTunnelHeaderSize, FBReverseTunnelHeaderSize, ^(dispatch_data_t hdrData, nw_content_context_t ctx, + bool isComplete, nw_error_t error) { + if (error || !hdrData) { + [FBLogger logFmt:@"[ReverseTunnel] Header read error, reconnecting"]; + nw_connection_cancel(conn); + [self scheduleReconnect]; + return; + } + + __block uint32_t payloadLen = 0, reqId = 0; + dispatch_data_apply(hdrData, ^bool(dispatch_data_t region, size_t offset, + const void *buffer, size_t size) { + const uint8_t *b = buffer; + if (size >= FBReverseTunnelHeaderSize) { + payloadLen = (uint32_t)b[0]<<24 | (uint32_t)b[1]<<16 | (uint32_t)b[2]<<8 | b[3]; + reqId = (uint32_t)b[4]<<24 | (uint32_t)b[5]<<16 | (uint32_t)b[6]<<8 | b[7]; + } + return true; + }); + + if (payloadLen == 0 || payloadLen > FBReverseTunnelMaxPayloadSize) { + [FBLogger logFmt:@"[ReverseTunnel] Invalid payload length: %u, skipping", payloadLen]; + [self readFrameFromConnection:conn]; + return; + } + + [self readPayload:payloadLen requestId:reqId fromConnection:conn]; + }); +} + ++ (void)readPayload:(uint32_t)length + requestId:(uint32_t)reqId + fromConnection:(nw_connection_t)conn +{ + nw_connection_receive(conn, length, length, ^(dispatch_data_t bodyData, + nw_content_context_t ctx, + bool isComplete, nw_error_t error) { + if (error || !bodyData) { + [FBLogger logFmt:@"[ReverseTunnel] Payload read error, reconnecting"]; + nw_connection_cancel(conn); + [self scheduleReconnect]; + return; + } + + NSData *requestData = [self extractData:bodyData]; + [self forwardRequest:requestData requestId:reqId throughConnection:conn]; + }); +} + ++ (NSData *)extractData:(dispatch_data_t)dispatchData +{ + NSMutableData *result = [NSMutableData data]; + dispatch_data_apply(dispatchData, ^bool(dispatch_data_t region, size_t offset, + const void *buffer, size_t size) { + [result appendBytes:buffer length:size]; + return true; + }); + return result; +} + +#pragma mark - HTTP Forwarding (POSIX socket to localhost) + ++ (void)forwardRequest:(NSData *)httpRequest + requestId:(uint32_t)reqId + throughConnection:(nw_connection_t)conn +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + int sock = socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_port = htons((uint16_t)_localPort); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + + NSMutableData *response = [NSMutableData data]; + if (connect(sock, (struct sockaddr *)&addr, sizeof(addr)) == 0) { + send(sock, httpRequest.bytes, httpRequest.length, 0); + + uint8_t buf[FBReverseTunnelRecvBufferSize]; + while (1) { + ssize_t n = recv(sock, buf, FBReverseTunnelRecvBufferSize, 0); + if (n <= 0) break; + [response appendBytes:buf length:n]; + } + } else { + const char *err = "HTTP/1.1 502 Bad Gateway\r\n\r\nLocal WDA unreachable"; + [response appendBytes:err length:strlen(err)]; + } + close(sock); + + [self sendResponse:response requestId:reqId throughConnection:conn]; + }); +} + +#pragma mark - Response Framing & Sending + ++ (void)sendResponse:(NSData *)response + requestId:(uint32_t)reqId + throughConnection:(nw_connection_t)conn +{ + uint32_t rLen = (uint32_t)response.length; + uint8_t hdr[8] = { + (rLen>>24)&0xFF, (rLen>>16)&0xFF, (rLen>>8)&0xFF, rLen&0xFF, + (reqId>>24)&0xFF, (reqId>>16)&0xFF, (reqId>>8)&0xFF, reqId&0xFF + }; + + dispatch_data_t hdrOut = dispatch_data_create(hdr, 8, NULL, DISPATCH_DATA_DESTRUCTOR_DEFAULT); + dispatch_data_t bodyOut = dispatch_data_create(response.bytes, response.length, + NULL, DISPATCH_DATA_DESTRUCTOR_DEFAULT); + dispatch_data_t fullOut = dispatch_data_create_concat(hdrOut, bodyOut); + + nw_connection_send(conn, fullOut, NW_CONNECTION_DEFAULT_MESSAGE_CONTEXT, true, + ^(nw_error_t sendError) { + if (sendError) { + [FBLogger logFmt:@"[ReverseTunnel] Send error: %@", sendError]; + return; + } + [self readFrameFromConnection:conn]; + }); +} + +@end diff --git a/WebDriverAgentLib/Routing/FBWebServer.m b/WebDriverAgentLib/Routing/FBWebServer.m index 29a2e16e6..5f270e99a 100644 --- a/WebDriverAgentLib/Routing/FBWebServer.m +++ b/WebDriverAgentLib/Routing/FBWebServer.m @@ -22,6 +22,9 @@ #import "FBUnknownCommands.h" #import "FBConfiguration.h" #import "FBLogger.h" +#import "FBReverseTunnel.h" +#import +#import #import "XCUIDevice+FBHelpers.h" @@ -48,6 +51,7 @@ @interface FBWebServer () @property (atomic, assign) BOOL keepAlive; @property (nonatomic, nullable) FBTCPSocket *screenshotsBroadcaster; @property (nonatomic, nullable, strong) FBMjpegServer *mjpegServer; +@property (nonatomic, strong) nw_path_monitor_t pathMonitor; @end @implementation FBWebServer @@ -74,11 +78,54 @@ - (void)dealloc - (void)startServing { + // Ignore SIGTERM/SIGHUP to survive IDE disconnection (enables wireless operation) + signal(SIGTERM, SIG_IGN); + signal(SIGHUP, SIG_IGN); + [FBLogger logFmt:@"[WDA] SIGTERM/SIGHUP ignored - will survive IDE disconnect"]; + [FBLogger logFmt:@"Built at %s %s", __DATE__, __TIME__]; self.exceptionHandler = [FBExceptionHandler new]; [self startHTTPServer]; [self initScreenshotsBroadcaster]; + // Start reverse tunnel if configured + NSString *relayHost = FBConfiguration.relayHost; + if (relayHost) { + [FBReverseTunnel startWithHost:relayHost + port:FBConfiguration.relayPort + localPort:FBConfiguration.bindingPortRange.location]; + } + + // Network change monitor - restart HTTP server on interface changes + self.pathMonitor = nw_path_monitor_create(); + nw_path_monitor_set_queue(self.pathMonitor, + dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)); + __block BOOL firstUpdate = YES; + __weak typeof(self) weakSelf = self; + nw_path_monitor_set_update_handler(self.pathMonitor, ^(nw_path_t path) { + if (firstUpdate) { + firstUpdate = NO; + return; + } + if (nw_path_get_status(path) == nw_path_status_satisfied) { + [FBLogger logFmt:@"[WDA] Network changed, restarting HTTP server in 2s..."]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC), + dispatch_get_main_queue(), ^{ + __strong typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf && strongSelf.server) { + if (strongSelf.server.isRunning) { + [strongSelf.server stop:NO]; + } + strongSelf.server = nil; + [FBLogger logFmt:@"[WDA] Restarting HTTP server..."]; + [strongSelf startHTTPServer]; + } + }); + } + }); + nw_path_monitor_start(self.pathMonitor); + [FBLogger logFmt:@"[WDA] Network path monitor started"]; + self.keepAlive = YES; NSRunLoop *runLoop = [NSRunLoop mainRunLoop]; while (self.keepAlive && diff --git a/WebDriverAgentLib/Utilities/FBConfiguration.h b/WebDriverAgentLib/Utilities/FBConfiguration.h index e8c7754bf..2e2927a80 100644 --- a/WebDriverAgentLib/Utilities/FBConfiguration.h +++ b/WebDriverAgentLib/Utilities/FBConfiguration.h @@ -141,6 +141,22 @@ extern NSString *const FBSnapshotMaxDepthKey; + (CGFloat)mjpegScalingFactor; + (void)setMjpegScalingFactor:(CGFloat)scalingFactor; +/** + The host address of an external relay server for reverse TCP tunnel. + When set via WDA_RELAY_HOST environment variable, WDA will actively connect + outbound to this relay instead of only listening for inbound connections. + This enables WDA control in NAT-restricted environments (symmetric NAT, + multi-layer firewalls, VPN tunnels) where inbound connections to the device + are not feasible. Returns nil if not configured (default behavior unchanged). + */ ++ (NSString * _Nullable)relayHost; + +/** + The port of the external relay server. + Configured via WDA_RELAY_PORT environment variable. Defaults to 8201. + */ ++ (NSInteger)relayPort; + /** YES if verbose logging is enabled. NO otherwise. */ diff --git a/WebDriverAgentLib/Utilities/FBConfiguration.m b/WebDriverAgentLib/Utilities/FBConfiguration.m index dcd1a62e3..ebddd3046 100644 --- a/WebDriverAgentLib/Utilities/FBConfiguration.m +++ b/WebDriverAgentLib/Utilities/FBConfiguration.m @@ -26,6 +26,7 @@ static NSUInteger const DefaultStartingPort = 8100; static NSUInteger const DefaultMjpegServerPort = 9100; static NSUInteger const DefaultPortRange = 100; +static NSUInteger const DefaultRelayPort = 8201; static char const *const controllerPrefBundlePath = "/System/Library/PrivateFrameworks/TextInput.framework/TextInput"; static NSString *const controllerClassName = @"TIPreferencesController"; @@ -181,6 +182,24 @@ + (void)setMjpegShouldFixOrientation:(BOOL)enabled { FBMjpegShouldFixOrientation = enabled; } ++ (NSString *)relayHost +{ + NSString *host = NSProcessInfo.processInfo.environment[@"WDA_RELAY_HOST"]; + if (host && [host length] > 0) { + return host; + } + return nil; +} + ++ (NSInteger)relayPort +{ + NSString *port = NSProcessInfo.processInfo.environment[@"WDA_RELAY_PORT"]; + if (port && [port length] > 0) { + return [port integerValue]; + } + return DefaultRelayPort; +} + + (BOOL)verboseLoggingEnabled { return [NSProcessInfo.processInfo.environment[@"VERBOSE_LOGGING"] boolValue]; diff --git a/docs/reverse-tunnel.md b/docs/reverse-tunnel.md new file mode 100644 index 000000000..ad2d5732e --- /dev/null +++ b/docs/reverse-tunnel.md @@ -0,0 +1,106 @@ +# Reverse TCP Tunnel for NAT-Restricted Environments + +## Overview + +WebDriverAgent (WDA) normally listens on a TCP port (default 8100) for incoming +HTTP connections. This works when the test client can reach the iOS device +directly, but fails in NAT-restricted environments where inbound connections to +the device are blocked — for example: + +- iOS devices on cellular networks (symmetric NAT) +- Devices behind corporate firewalls with no port forwarding +- Multi-layer VPN tunnels +- Remote device farms without direct network access + +The reverse tunnel feature solves this by having WDA initiate an **outbound** TCP +connection to an external relay server. The relay accepts normal HTTP requests +from Appium clients and forwards them to WDA through the tunnel. + +## Architecture + +``` +Appium Client ──HTTP──▶ Relay Server ◀──TCP (reverse tunnel)── WDA on iOS +(any network) (public IP) (behind NAT) +``` + +1. A relay server runs on a publicly accessible host +2. WDA starts and connects **outbound** to the relay +3. The Appium client sends HTTP requests to the relay +4. The relay forwards requests to WDA through the tunnel and returns responses + +## Configuration + +Set these environment variables **before** launching WDA: + +| Variable | Required | Description | +|---|---|---| +| `WDA_RELAY_HOST` | Yes | Hostname or IP of the relay server | +| `WDA_RELAY_PORT` | No | Relay port (default: 8201) | + +When `WDA_RELAY_HOST` is not set, the reverse tunnel is completely inactive and +WDA behaves exactly as before (zero impact on existing usage). + +### Example: Xcode test launch + +```bash +WDA_RELAY_HOST=relay.example.com WDA_RELAY_PORT=8201 \ + xcodebuild test-without-building \ + -project WebDriverAgent.xcodeproj \ + -scheme WebDriverAgentRunner \ + -destination 'id=DEVICE_UDID' +``` + +### Example: Appium client configuration + +Point `appium:webDriverAgentUrl` at the relay server: + +```json +{ + "appium:webDriverAgentUrl": "http://relay.example.com:8100" +} +``` + +## Running the Relay Server + +A reference relay server implementation is provided at +[`docs/wda-relay-server.mjs`](wda-relay-server.mjs). + +```bash +# Default ports: relay on 8201, HTTP proxy on 8100 +node docs/wda-relay-server.mjs + +# Custom ports +node docs/wda-relay-server.mjs 9201 9100 +``` + +The relay listens on two ports: +- **Relay port** (default 8201): Accepts the reverse TCP connection from WDA +- **Proxy port** (default 8100): Accepts normal HTTP requests from Appium clients + +## Protocol + +The tunnel uses an 8-byte header framing protocol over a single persistent TCP +connection: + +``` +┌──────────────────┬──────────────────┬─────────────┐ +│ Payload Length │ Request ID │ Payload │ +│ (4 bytes, BE) │ (4 bytes, BE) │ (variable) │ +└──────────────────┴──────────────────┴─────────────┘ +``` + +- **Payload Length**: Big-endian uint32, size of the payload in bytes +- **Request ID**: Big-endian uint32, correlates requests with responses +- **Payload**: Raw HTTP request or response bytes + +Request IDs allow reliable request-response correlation even if responses arrive +out of order. + +## Resilience + +- **Automatic reconnection** with exponential backoff (5s → 10s → 20s → ... → 60s cap) +- Backoff resets to 5s after a successful connection +- **SIGTERM/SIGHUP handling**: WDA ignores these signals to survive IDE + disconnection, enabling wireless-only operation +- **Network monitoring**: WDA monitors network path changes and automatically + restarts the HTTP server on interface transitions (e.g., WiFi ↔ cellular) diff --git a/docs/wda-relay-server.mjs b/docs/wda-relay-server.mjs new file mode 100644 index 000000000..54db5130b --- /dev/null +++ b/docs/wda-relay-server.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +/** + * WDA Reverse Tunnel Relay Server + * + * This server acts as a bridge between WDA (running on an iOS device behind NAT) + * and HTTP clients. WDA connects outbound to this relay; HTTP clients connect + * to localhost:8100 as usual. + * + * Protocol (between relay and WDA): + * [4-byte big-endian payload length][4-byte big-endian request ID][payload] + * Request payload: raw HTTP request (method + headers + body) + * Response payload: raw HTTP response (status + headers + body) + * + * Usage: + * WDA_RELAY_HOST= WDA_RELAY_PORT=8201 xcodebuild test-without-building ... + * node wda-relay-server.mjs # relay on 8201, proxy on 8100 + * node wda-relay-server.mjs 9201 9100 # custom ports + */ + +import net from 'node:net'; +import http from 'node:http'; + +const DEFAULT_RELAY_PORT = 8201; +const DEFAULT_PROXY_PORT = 8100; + +const RELAY_PORT = parseInt(process.argv[2]) || DEFAULT_RELAY_PORT; +const PROXY_PORT = parseInt(process.argv[3]) || DEFAULT_PROXY_PORT; + +let wdaSocket = null; +const pendingRequests = new Map(); // reqId -> http.ServerResponse +let requestCounter = 0; + +// --- Relay server: accepts reverse connection from WDA --- +const relayServer = net.createServer((socket) => { + console.log(`[relay] WDA connected from ${socket.remoteAddress}`); + wdaSocket = socket; + + let buffer = Buffer.alloc(0); + + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + + // 8-byte header: 4-byte payload length + 4-byte request ID + while (buffer.length >= 8) { + const payloadLen = buffer.readUInt32BE(0); + const reqId = buffer.readUInt32BE(4); + if (buffer.length < 8 + payloadLen) break; + + const payload = buffer.subarray(8, 8 + payloadLen); + buffer = buffer.subarray(8 + payloadLen); + + // Route response back to the matching HTTP request + const res = pendingRequests.get(reqId); + if (res) { + pendingRequests.delete(reqId); + + const text = payload.toString(); + const headerEnd = text.indexOf('\r\n\r\n'); + if (headerEnd !== -1) { + const statusMatch = text.match(/^HTTP\/\d\.\d (\d+)/); + const statusCode = statusMatch ? parseInt(statusMatch[1]) : 200; + const body = payload.subarray(headerEnd + 4); + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(body); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(payload); + } + } else { + console.warn(`[relay] No pending request for reqId ${reqId}`); + } + } + }); + + socket.on('close', () => { + console.log('[relay] WDA disconnected'); + wdaSocket = null; + }); + + socket.on('error', (err) => { + console.error('[relay] Socket error:', err.message); + wdaSocket = null; + }); +}); + +// --- HTTP proxy: accepts normal WDA API requests --- +const proxyServer = http.createServer((req, res) => { + if (!wdaSocket || wdaSocket.destroyed) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'WDA not connected to relay' })); + return; + } + + const body = []; + req.on('data', (chunk) => body.push(chunk)); + req.on('end', () => { + const bodyBuf = Buffer.concat(body); + const httpReq = `${req.method} ${req.url} HTTP/1.1\r\nHost: localhost\r\n` + + Object.entries(req.headers).map(([k, v]) => `${k}: ${v}`).join('\r\n') + + '\r\n\r\n' + bodyBuf.toString(); + + const reqBuf = Buffer.from(httpReq); + const reqId = requestCounter++; + + // 8-byte header: 4-byte payload length + 4-byte request ID + const hdrBuf = Buffer.alloc(8); + hdrBuf.writeUInt32BE(reqBuf.length, 0); + hdrBuf.writeUInt32BE(reqId, 4); + + pendingRequests.set(reqId, res); + + try { + wdaSocket.write(Buffer.concat([hdrBuf, reqBuf])); + } catch (err) { + pendingRequests.delete(reqId); + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Failed to forward request' })); + } + }); +}); + +relayServer.listen(RELAY_PORT, () => { + console.log(`[relay] Waiting for WDA on port ${RELAY_PORT}`); +}); + +proxyServer.listen(PROXY_PORT, () => { + console.log(`[proxy] HTTP proxy on port ${PROXY_PORT}`); + console.log(`\nUsage: set WDA_RELAY_HOST and WDA_RELAY_PORT env vars when launching WDA`); + console.log(` WDA_RELAY_HOST= WDA_RELAY_PORT=${RELAY_PORT} xcodebuild test-without-building ...`); + console.log(` curl http://localhost:${PROXY_PORT}/status`); +});