Llim.run

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:

Apps that resolve DNS themselves over HTTPS (DoH) bypass domain interception. For those, declare the resolved IP instead.

Limits and behavior

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:

FlagWhat it does
--[no-]inspectPrint 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.
--persistUpload 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.0

The 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:

Troubleshooting

Symptom or messageCauseFix
Tunnel start fails with invalid_messageThe client is @limrun/api 0.48 or older, or lim before 0.31.Upgrade the CLI or SDK.
One connection fails while others workThe 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 effectThe 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 destinationThe selector list of a running tunnel cannot change.Stop the tunnel and start it again with the complete list.
All tunnel connections droppedThe tunnel's WebSocket closed.Start a new tunnel.
lim ios reverse fails with already in useA live reverse tunnel holds that port.Pick another port from 57090 to 57099, or stop the other tunnel.
The app ignores the tunnelThe 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 tunnelInspection decodes HTTPS with a certificate the pinning app rejects.Leave that host out of the selectors, or pass --no-inspect.

Next steps

smartphone

Run a simulator

Drive the app once it reaches your service: taps, typing, logs, recordings.

network

Android local services

The same destination tunnel for Android emulators.

flask-conical

Appium

The opposite direction: reach WebDriverAgent inside the simulator through targetHttpPortUrlPrefix.