Connect an iOS simulator to local services
A destination tunnel sends the connections you declare from the simulator through the machine that runs the tunnel, so the app keeps using the addresses it already has, such as localhost:8000 or api.corp.example. This page covers iOS simulators; for Android emulators, see Connect an Android emulator to local services, which reuses the selector and inspection rules described here.
This page covers traffic from the device to your service. Reaching a server that runs inside the device from outside, such as WebDriverAgent, is the opposite direction and uses targetHttpPortUrlPrefix. See Appium for how that works.
Start a tunnel from an iOS simulator
Declare each destination the app needs as a --selector, and run the tunnel in the background with --detach:
lim ios tunnel \
--selector localhost:8000 \
--selector 10.20.30.40:443 \
--selector "*.staging.example" \
--detach \
--id <instance-id>The app keeps using the literal localhost:8000, and the tunnel connects it to localhost:8000 on the machine running the command. Without --detach, the tunnel runs until you stop the command. --verbose logs every forwarded connection and dial failure; with --detach those lines go to the tunnel log file that tunnel status points to.
Query or stop the tunnel from another process:
lim ios tunnel status --id <instance-id> --json
lim ios tunnel stop --id <instance-id>From the TypeScript SDK, start the tunnel on a device client. The caller owns the returned tunnel; disconnecting the device client does not close it.
const tunnel = await client.startTunnel({
selectors: ['localhost:8000', '10.20.30.40:443', 'api.corp.example', '*.staging.example'],
});
const status = await client.getTunnelStatus();
await client.stopTunnel(tunnel.tunnelId);The Python and Go SDKs do not start destination tunnels. Raw HTTP cannot start one either: it takes the CLI, the TypeScript SDK, or an implementation of the tunnel's multiplexed WebSocket protocol.
Choose selectors
A selector is either an exact TCP destination or a domain:
- Exact
host:port.localhost:8000also captures the loopback aliases127.0.0.1:8000,[::1]:8000, and[::ffff:127.0.0.1]:8000. Literal IP selectors work the same way for non-loopback addresses. Write IPv6 in brackets, as in[::1]:8000. - Domain. An exact name (
api.corp.example) or a*.wildcard (*.staging.example). Domain selectors are intercepted on the instance and dialed from your machine whether or not the name resolves on public DNS, so your DNS,/etc/hosts, and VPN apply. Wildcards are label-bound:*.staging.examplecoversa.staging.examplebut notstaging.example.localhostand IP literals are not domains; declare them ashost:port.
Apps that resolve DNS themselves over HTTPS (DoH) bypass domain interception. For those, declare the resolved IP instead.
Limits and behavior
- A tunnel carries TCP only. CIDRs and UDP are not supported.
- One tunnel accepts up to ten exact
host:portselectors and up to 64 domain selectors. - Ports 1 to 65535 are accepted except port 53.
- One instance has one active destination tunnel, and its selector list cannot change. To add or remove a destination, stop the tunnel and start it again with the complete list.
- A WebSocket loss closes the tunnel's connections. Start a new tunnel to recover.
- An open idle tunnel does not count as instance activity and does not prevent the inactivity timeout.
- A tunnel captures connections the app opens after the tunnel is ready. An app that reached a destination earlier keeps its open connections and can reuse them outside the tunnel, so start the tunnel before you launch the app or open the page, or relaunch the app once the tunnel is ready.
- If the app connects to a selector while its local service is down, only that connection fails; the tunnel and its other selectors stay up. JSON status records the failure as
lastDialFailure. Restart the service at the same destination and the existing tunnel carries later connections.
Clients on @limrun/api 0.48 or older (lim before 0.31) speak an earlier tunnel protocol that current instances reject with invalid_message. Upgrade to use tunnels.
Inspect tunnel traffic
HTTP and HTTPS through a tunnel are inspected by default. Each completed request is printed as one summary line (in the tunnel log file when detached), and requests also stream live to the console's network panel. These flags control inspection on lim ios tunnel, and lim android tunnel takes the same flags:
| Flag | What it does |
|---|---|
--[no-]inspect | Print one HTTP summary per completed request. On by default; --no-inspect turns inspection off. |
--har <path> | Write inspected traffic, with bodies, as HAR 1.2 at this path. |
--har-body-limit <bytes> | Maximum captured bytes per request or response body. Defaults to 10485760. |
--persist | Upload a network log, including bodies, as a session artifact when the tunnel stops or the instance terminates. |
--ttl <seconds> | Lifetime of the persisted network log. Defaults to 259200 (3 days), maximum 2592000 (30 days). Requires --persist. |
This captures a HAR file and keeps a network log for seven days:
lim ios tunnel --selector "*.api.example" --har ./traffic.har --persist --ttl 604800 --id <instance-id>HTTPS is decoded with a certificate authority the device trusts, so apps that pin certificates fail through inspected selectors. Leave the pinned host out of the selectors, or pass --no-inspect to keep TLS end to end. From the TypeScript SDK, pass inspection: { enabled: false } to startTunnel for the same effect.
Connect Expo and Metro
Metro can listen on its usual local port while Expo advertises localhost. Start the tunnel for Metro's port, then start Metro with EXPO_PACKAGER_PROXY_URL pointing at the same address:
METRO_PORT=8081
lim ios tunnel \
--selector "localhost:${METRO_PORT}" \
--detach \
--id <instance-id>
TUNNEL_URL="http://localhost:${METRO_PORT}"
echo "TUNNEL_URL=$TUNNEL_URL"
EXPO_PACKAGER_PROXY_URL="$TUNNEL_URL" \
npx expo start --dev-client --port "$METRO_PORT"EXPO_PACKAGER_PROXY_URL makes Expo use localhost and the declared port in the manifest, bundle URLs, and deep links. Set it inline so it takes precedence over project dotenv values. Keep this shell open for Metro, and copy the printed TUNNEL_URL into a second terminal for the launch command. If port 8081 is taken, pick another port and use the same value in the selector, TUNNEL_URL, and Expo's --port.
Open your development build with that URL:
SCHEME="<scheme from app.json, or exp+<slug>>"
ENCODED_URL="$(node -e 'console.log(encodeURIComponent(process.argv[1]))' "$TUNNEL_URL")"
lim ios open-url \
--id <instance-id> \
"${SCHEME}://expo-development-client/?url=${ENCODED_URL}"For Expo Go, replace --dev-client with --go in the Metro command and open:
lim ios open-url \
--id <instance-id> \
"exp://${TUNNEL_URL#http://}"Use the legacy fixed-port reverse tunnel
lim ios reverse remains for existing workflows that already pick a simulator-facing port in the reserved range 57090 to 57099. New workflows should use the destination tunnel above.
lim ios reverse 57090:57090 # expose local :57090 as simulator :57090
lim ios reverse 57091 --local-host 0.0.0.0The mapping is <remotePort> or <remotePort>:<localPort>. --local-host defaults to 127.0.0.1; non-loopback hosts are meant for debugging. On start, the CLI prints the address the app must connect to. It is an internal listener IP near the simulator, not 127.0.0.1.
From the TypeScript SDK:
const tunnel = await client.startReverseTunnel({
remotePort: 57090, // must be in 57090..57099
localPort: 57090, // defaults to remotePort
localHost: '127.0.0.1', // defaults to 127.0.0.1
});
// The app reaches your service at tunnel.remoteAddress, e.g.
// `${tunnel.remoteAddress.address}:${tunnel.remoteAddress.port}`.
// tunnel.close() to tear it down.The reverse tunnel has these constraints:
- Client-first services only. The app in the simulator opens the connection. Server-first protocols, where the server speaks first, are not supported.
- No automatic reconnect. The tunnel fails closed: if the WebSocket drops, the listener and its connections close. Restart the tunnel to recover.
- Idle tunnels don't keep the instance alive. Opening a tunnel counts as activity once; after that, only traffic through it does. A quiet tunnel lets the inactivity timeout fire as usual, so agents that leave tunnels behind stop accruing instance time.
- Vanished clients lose their port. If the tunnel client stops responding without closing (a suspended VM, a frozen process), the server closes the session and frees the remote port within about two minutes. A port held by a live tunnel stays reserved, and a second tunnel on it fails with
already in use. - TypeScript SDK and CLI only. The Go and Python SDKs do not have it.
- iOS only. On Android, use a destination tunnel or
adb reverseover the ADB tunnel.
Troubleshooting
| Symptom or message | Cause | Fix |
|---|---|---|
Tunnel start fails with invalid_message | The client is @limrun/api 0.48 or older, or lim before 0.31. | Upgrade the CLI or SDK. |
| One connection fails while others work | The local service for that selector is down. JSON status shows it as lastDialFailure. | Restart the service at the same destination; the tunnel carries later connections. |
| A domain selector has no effect | The app resolves DNS over HTTPS, or the wildcard does not cover the bare domain. | Declare the resolved IP as host:port, or add the exact domain next to the wildcard. |
| You need another destination | The selector list of a running tunnel cannot change. | Stop the tunnel and start it again with the complete list. |
| All tunnel connections dropped | The tunnel's WebSocket closed. | Start a new tunnel. |
lim ios reverse fails with already in use | A live reverse tunnel holds that port. | Pick another port from 57090 to 57099, or stop the other tunnel. |
| The app ignores the tunnel | The app opened its connections before the tunnel was ready. | Start the tunnel first, then relaunch the app. |
| An app with certificate pinning fails through the tunnel | Inspection decodes HTTPS with a certificate the pinning app rejects. | Leave that host out of the selectors, or pass --no-inspect. |
Next steps
Was this guide helpful?