@limrun/api 0.45.2 → 0.45.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/http-proxy.d.mts +17 -0
  3. package/http-proxy.d.mts.map +1 -1
  4. package/http-proxy.d.ts +17 -0
  5. package/http-proxy.d.ts.map +1 -1
  6. package/http-proxy.js +114 -0
  7. package/http-proxy.js.map +1 -1
  8. package/http-proxy.mjs +113 -0
  9. package/http-proxy.mjs.map +1 -1
  10. package/index.d.mts +2 -1
  11. package/index.d.mts.map +1 -1
  12. package/index.d.ts +2 -1
  13. package/index.d.ts.map +1 -1
  14. package/index.js +9 -1
  15. package/index.js.map +1 -1
  16. package/index.mjs +2 -1
  17. package/index.mjs.map +1 -1
  18. package/ios-client.d.mts +11 -0
  19. package/ios-client.d.mts.map +1 -1
  20. package/ios-client.d.ts +11 -0
  21. package/ios-client.d.ts.map +1 -1
  22. package/ios-client.js +13 -0
  23. package/ios-client.js.map +1 -1
  24. package/ios-client.mjs +14 -1
  25. package/ios-client.mjs.map +1 -1
  26. package/ios-maestro.d.mts +34 -0
  27. package/ios-maestro.d.mts.map +1 -0
  28. package/ios-maestro.d.ts +34 -0
  29. package/ios-maestro.d.ts.map +1 -0
  30. package/ios-maestro.js +134 -0
  31. package/ios-maestro.js.map +1 -0
  32. package/ios-maestro.mjs +127 -0
  33. package/ios-maestro.mjs.map +1 -0
  34. package/ios-shim.js +69 -8
  35. package/ios-shim.js.map +1 -1
  36. package/ios-shim.mjs +69 -8
  37. package/ios-shim.mjs.map +1 -1
  38. package/package.json +11 -1
  39. package/src/http-proxy.ts +138 -0
  40. package/src/index.ts +16 -1
  41. package/src/ios-client.ts +32 -1
  42. package/src/ios-maestro.ts +154 -0
  43. package/src/ios-shim.ts +70 -8
  44. package/src/tunnel.ts +56 -6
  45. package/src/version.ts +1 -1
  46. package/tunnel.d.mts +6 -0
  47. package/tunnel.d.mts.map +1 -1
  48. package/tunnel.d.ts +6 -0
  49. package/tunnel.d.ts.map +1 -1
  50. package/tunnel.js +53 -6
  51. package/tunnel.js.map +1 -1
  52. package/tunnel.mjs +53 -6
  53. package/tunnel.mjs.map +1 -1
  54. package/version.d.mts +1 -1
  55. package/version.d.ts +1 -1
  56. package/version.js +1 -1
  57. package/version.mjs +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limrun/api",
3
- "version": "0.45.2",
3
+ "version": "0.45.3",
4
4
  "description": "The official TypeScript library for the Limrun API",
5
5
  "author": "Limrun <contact@limrun.com>",
6
6
  "types": "./index.d.ts",
@@ -198,6 +198,16 @@
198
198
  "./ios-client.mjs": {
199
199
  "default": "./ios-client.mjs"
200
200
  },
201
+ "./ios-maestro": {
202
+ "import": "./ios-maestro.mjs",
203
+ "require": "./ios-maestro.js"
204
+ },
205
+ "./ios-maestro.js": {
206
+ "default": "./ios-maestro.js"
207
+ },
208
+ "./ios-maestro.mjs": {
209
+ "default": "./ios-maestro.mjs"
210
+ },
201
211
  "./ios-shim": {
202
212
  "import": "./ios-shim.mjs",
203
213
  "require": "./ios-shim.js"
package/src/http-proxy.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import http from 'http';
2
2
  import https from 'https';
3
+ import { pipeline } from 'stream';
3
4
 
4
5
  export type HttpProxy = {
5
6
  port: number;
@@ -12,6 +13,10 @@ export type StartHttpProxyOptions = {
12
13
  headers?: Record<string, string>;
13
14
  };
14
15
 
16
+ /**
17
+ * Legacy origin-form reverse proxy: forwards every request on the local port
18
+ * to `remoteBaseUrl`. New callers should prefer `startForwardHttpProxy`.
19
+ */
15
20
  export async function startHttpProxy({
16
21
  localPort = 0,
17
22
  remoteBaseUrl,
@@ -58,6 +63,139 @@ export async function startHttpProxy({
58
63
  };
59
64
  }
60
65
 
66
+ export type StartForwardHttpProxyOptions = {
67
+ /** Loopback requests to this port are rewritten to remoteBaseUrl; everything else passes through. */
68
+ matchPort: number;
69
+ remoteBaseUrl: string;
70
+ headers?: Record<string, string>;
71
+ };
72
+
73
+ // Hop-by-hop headers must not be forwarded by an HTTP proxy (RFC 9110 §7.6.1).
74
+ const HOP_BY_HOP_HEADERS = new Set([
75
+ 'connection',
76
+ 'proxy-connection',
77
+ 'keep-alive',
78
+ 'proxy-authorization',
79
+ 'proxy-authenticate',
80
+ 'te',
81
+ 'trailer',
82
+ 'upgrade',
83
+ ]);
84
+
85
+ function withoutHopByHop(headers: http.IncomingHttpHeaders): http.IncomingHttpHeaders {
86
+ return Object.fromEntries(Object.entries(headers).filter(([name]) => !HOP_BY_HOP_HEADERS.has(name)));
87
+ }
88
+
89
+ /**
90
+ * Single-destination HTTP forward proxy (absolute-form request targets, as sent
91
+ * by clients configured with e.g. JVM -Dhttp.proxyHost). Loopback requests on
92
+ * `matchPort` are forwarded to `remoteBaseUrl`; every other target is refused,
93
+ * so the proxy can only ever reach the destination it was configured with.
94
+ */
95
+ export async function startForwardHttpProxy({
96
+ matchPort,
97
+ remoteBaseUrl,
98
+ headers = {},
99
+ }: StartForwardHttpProxyOptions): Promise<HttpProxy> {
100
+ const base = trimTrailingSlashes(remoteBaseUrl);
101
+ // The driver polls the runner continuously, so upstream connections must be
102
+ // reused instead of paying a TCP/TLS handshake per driver call.
103
+ const agents = {
104
+ 'http:': new http.Agent({ keepAlive: true }),
105
+ 'https:': new https.Agent({ keepAlive: true }),
106
+ };
107
+ const server = http.createServer((req, res) => {
108
+ let target: URL | undefined;
109
+ try {
110
+ target = new URL(req.url ?? '');
111
+ } catch {
112
+ // Falls through to the guard below.
113
+ }
114
+ if (!target || (target.protocol !== 'http:' && target.protocol !== 'https:')) {
115
+ res.writeHead(400, { 'content-type': 'text/plain' });
116
+ res.end('Limrun forward proxy only accepts absolute-form request targets.');
117
+ return;
118
+ }
119
+
120
+ // matchPort is always explicit (an ephemeral port or 7001), so a target
121
+ // without a port can never match. Anything else is refused rather than
122
+ // forwarded: the requested host never selects the destination, so this
123
+ // proxy cannot be pointed at another host.
124
+ if (!isLoopbackHost(target.hostname) || Number(target.port) !== matchPort) {
125
+ res.writeHead(403, { 'content-type': 'text/plain' });
126
+ res.end(`Limrun forward proxy only forwards 127.0.0.1:${matchPort}.`);
127
+ return;
128
+ }
129
+
130
+ const upstreamUrl = new URL(`${base}${target.pathname}${target.search}`);
131
+ const transport = upstreamUrl.protocol === 'https:' ? https : http;
132
+ const upstream = transport.request(
133
+ upstreamUrl,
134
+ {
135
+ agent: agents[upstreamUrl.protocol as keyof typeof agents],
136
+ method: req.method,
137
+ headers: {
138
+ ...withoutHopByHop(req.headers),
139
+ host: upstreamUrl.host,
140
+ ...headers,
141
+ },
142
+ },
143
+ (upstreamResponse) => {
144
+ res.writeHead(upstreamResponse.statusCode ?? 502, withoutHopByHop(upstreamResponse.headers));
145
+ // pipeline tears down whichever side is still alive when the other one
146
+ // dies, instead of leaking it or raising an unhandled 'error'.
147
+ pipeline(upstreamResponse, res, () => {});
148
+ },
149
+ );
150
+
151
+ upstream.on('error', (error) => {
152
+ if (res.headersSent) {
153
+ // Mid-stream failure: abort instead of appending error text to a
154
+ // partial body.
155
+ res.destroy(error);
156
+ return;
157
+ }
158
+ res.writeHead(502, { 'content-type': 'text/plain' });
159
+ res.end(error.message);
160
+ });
161
+ // A client that aborts mid-call must not leak the upstream socket.
162
+ res.on('close', () => {
163
+ if (!res.writableFinished) {
164
+ upstream.destroy();
165
+ }
166
+ });
167
+ pipeline(req, upstream, () => {});
168
+ });
169
+
170
+ // HTTPS clients open CONNECT tunnels; we deliberately keep TLS traffic off this
171
+ // proxy (only http.proxyHost is set on the JVM side), so refuse loudly instead
172
+ // of tunneling blind.
173
+ server.on('connect', (_req, socket) => {
174
+ socket.end('HTTP/1.1 501 Not Implemented\r\n\r\nLimrun forward proxy does not tunnel CONNECT.\r\n');
175
+ });
176
+
177
+ await listen(server, 0);
178
+ const address = server.address();
179
+ if (!address || typeof address === 'string') {
180
+ throw new Error('Failed to start HTTP forward proxy.');
181
+ }
182
+ return {
183
+ port: address.port,
184
+ close: async () => {
185
+ await closeServer(server);
186
+ agents['http:'].destroy();
187
+ agents['https:'].destroy();
188
+ },
189
+ };
190
+ }
191
+
192
+ function isLoopbackHost(hostname: string): boolean {
193
+ // URL.hostname keeps brackets around IPv6 hosts.
194
+ return (
195
+ hostname === 'localhost' || hostname === '[::1]' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)
196
+ );
197
+ }
198
+
61
199
  function trimTrailingSlashes(value: string): string {
62
200
  return value.replace(/\/+$/, '');
63
201
  }
package/src/index.ts CHANGED
@@ -8,7 +8,22 @@ export { Limrun, type ClientOptions } from './client';
8
8
  export { PagePromise } from './core/pagination';
9
9
  export * from './instance-client';
10
10
  export * as Ios from './ios-client';
11
- export { startHttpProxy, type HttpProxy, type StartHttpProxyOptions } from './http-proxy';
11
+ export {
12
+ startHttpProxy,
13
+ startForwardHttpProxy,
14
+ type HttpProxy,
15
+ type StartHttpProxyOptions,
16
+ type StartForwardHttpProxyOptions,
17
+ } from './http-proxy';
18
+ export {
19
+ prepareMaestroRun,
20
+ isMaestroRunnerRunning,
21
+ waitForMaestroRunner,
22
+ maestroSpawnOptions,
23
+ MAESTRO_RUNNER_BUNDLE_ID,
24
+ MAESTRO_RUNNER_PORT,
25
+ type MaestroRun,
26
+ } from './ios-maestro';
12
27
  export { buildSettingKeyPattern, parseBuildSettingEntries, validateBuildSettings } from './build-settings';
13
28
  export {
14
29
  exec,
package/src/ios-client.ts CHANGED
@@ -10,7 +10,11 @@ import { createIgnoreFn } from './folder-sync-ignore';
10
10
  import { prepareAppBundlePath, watchAppArchive } from './app-archive';
11
11
  import { downloadFileToLocalPath } from './internal/download-file';
12
12
  import { nodeProxyTransport } from './internal/proxy-transport';
13
- import { startHttpProxy as startLocalHttpProxy } from './http-proxy';
13
+ import {
14
+ startHttpProxy as startLocalHttpProxy,
15
+ startForwardHttpProxy as startLocalForwardHttpProxy,
16
+ type StartForwardHttpProxyOptions,
17
+ } from './http-proxy';
14
18
  import { startXcrunShim as startClientXcrunShim } from './ios-shim';
15
19
 
16
20
  /**
@@ -210,6 +214,8 @@ export type HttpProxyOptions = {
210
214
  localPort: number;
211
215
  };
212
216
 
217
+ export type ForwardHttpProxyOptions = Omit<StartForwardHttpProxyOptions, 'headers'>;
218
+
213
219
  /**
214
220
  * Targets a file operation at an installed app's container instead of the
215
221
  * sandbox staging folder. Mirrors `simctl get_app_container` semantics.
@@ -749,6 +755,16 @@ export type InstanceClient = {
749
755
  */
750
756
  startHttpProxy: (options: HttpProxyOptions) => Promise<number>;
751
757
 
758
+ /**
759
+ * Start a local HTTP forward proxy (for clients driven by JVM/system proxy
760
+ * settings, like Maestro) that rewrites loopback requests on `matchPort` to a
761
+ * port exposed by the iOS instance and passes other targets through.
762
+ *
763
+ * Returns the local port the proxy is listening on. The proxy is closed when
764
+ * the client disconnects.
765
+ */
766
+ startForwardHttpProxy: (options: ForwardHttpProxyOptions) => Promise<number>;
767
+
752
768
  /**
753
769
  * Disconnect from the Limrun instance
754
770
  */
@@ -1861,6 +1877,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1861
1877
  softReset,
1862
1878
  startReverseTunnel,
1863
1879
  startHttpProxy,
1880
+ startForwardHttpProxy,
1864
1881
  disconnect,
1865
1882
  getConnectionState,
1866
1883
  onConnectionStateChange,
@@ -2507,6 +2524,20 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
2507
2524
  return proxy.port;
2508
2525
  };
2509
2526
 
2527
+ const startForwardHttpProxy = async (proxyOptions: ForwardHttpProxyOptions): Promise<number> => {
2528
+ assertPort(proxyOptions.matchPort, 'matchPort', 1, 65535);
2529
+
2530
+ const proxy = await startLocalForwardHttpProxy({
2531
+ matchPort: proxyOptions.matchPort,
2532
+ remoteBaseUrl: proxyOptions.remoteBaseUrl,
2533
+ headers: {
2534
+ authorization: `Bearer ${options.token}`,
2535
+ },
2536
+ });
2537
+ httpProxyCleanups.push(proxy.close);
2538
+ return proxy.port;
2539
+ };
2540
+
2510
2541
  const disconnect = (): void => {
2511
2542
  intentionalDisconnect = true;
2512
2543
  cleanup();
@@ -0,0 +1,154 @@
1
+ import path from 'path';
2
+ import net from 'net';
3
+ import { execFile } from 'child_process';
4
+
5
+ import type { InstanceClient } from './ios-client';
6
+
7
+ export const MAESTRO_RUNNER_BUNDLE_ID = 'dev.mobile.maestro-driver-iosUITests.xctrunner';
8
+ /** Port the patched Maestro XCTest runner listens on inside the simulator. */
9
+ export const MAESTRO_RUNNER_PORT = 22087;
10
+
11
+ // Maestro installs as a .bat/.cmd wrapper on Windows, which spawn can only
12
+ // execute through a shell.
13
+ export const maestroSpawnOptions = { shell: process.platform === 'win32' } as const;
14
+
15
+ export type MaestroRun = {
16
+ maestroVersion: string;
17
+ driverPort: number;
18
+ /** Environment overrides for the `maestro` process; merge over process.env. */
19
+ env: Record<string, string>;
20
+ /** Arguments to place between `maestro test` and the flow arguments. */
21
+ args: string[];
22
+ };
23
+
24
+ /**
25
+ * Wire a locally installed stock Maestro CLI to this instance's remote XCTest
26
+ * runner: starts the xcrun shim and an HTTP forward proxy, and returns the
27
+ * environment and `maestro test` arguments that route the driver traffic to
28
+ * the remote runner. Both are torn down when the client disconnects.
29
+ *
30
+ * Maestro's driver talks plain HTTP to 127.0.0.1:<driverPort>. Nothing listens
31
+ * there: the JVM proxy settings route those requests (absolute-form) to the
32
+ * forward proxy, which rewrites them to the remote runner and passes other
33
+ * targets through.
34
+ */
35
+ export async function prepareMaestroRun(
36
+ client: InstanceClient,
37
+ options: { runnerUrl: string },
38
+ ): Promise<MaestroRun> {
39
+ const [maestroVersion, shimDir] = await Promise.all([detectMaestroVersion(), client.startXcrunShim()]);
40
+ // Maestro 2.5.x hardcodes driver port 7001 and has no --driver-host-port
41
+ // flag; 2.6+ picks a random port unless the flag is passed.
42
+ const withDriverHostPortFlag = supportsDriverHostPort(maestroVersion);
43
+ const driverPort = withDriverHostPortFlag ? await findFreePort() : 7001;
44
+ const proxyPort = await client.startForwardHttpProxy({
45
+ remoteBaseUrl: options.runnerUrl,
46
+ matchPort: driverPort,
47
+ });
48
+ const maestroOpts = [
49
+ process.env['MAESTRO_OPTS'],
50
+ `-Dhttp.proxyHost=127.0.0.1 -Dhttp.proxyPort=${proxyPort} -Dhttp.nonProxyHosts=`,
51
+ ]
52
+ .filter(Boolean)
53
+ .join(' ');
54
+ return {
55
+ maestroVersion,
56
+ driverPort,
57
+ env: {
58
+ MAESTRO_OPTS: maestroOpts,
59
+ PATH: `${shimDir}${path.delimiter}${process.env['PATH'] ?? ''}`,
60
+ USE_XCODE_TEST_RUNNER: '1',
61
+ },
62
+ args: [
63
+ '--platform',
64
+ 'ios',
65
+ '--device',
66
+ client.deviceInfo.udid,
67
+ '--no-reinstall-driver',
68
+ ...(withDriverHostPortFlag ? ['--driver-host-port', String(driverPort)] : []),
69
+ ],
70
+ };
71
+ }
72
+
73
+ /** Poll the remote Maestro XCTest runner until it responds, e.g. right after launching it. */
74
+ export async function waitForMaestroRunner(
75
+ runnerUrl: string,
76
+ token: string,
77
+ timeoutMs = 15000,
78
+ ): Promise<boolean> {
79
+ const deadline = Date.now() + timeoutMs;
80
+ while (Date.now() < deadline) {
81
+ if (await isMaestroRunnerRunning(runnerUrl, token)) {
82
+ return true;
83
+ }
84
+ await new Promise((resolve) => setTimeout(resolve, 500));
85
+ }
86
+ return false;
87
+ }
88
+
89
+ /** Probe the remote Maestro XCTest runner's /status endpoint. */
90
+ export async function isMaestroRunnerRunning(runnerUrl: string, token: string): Promise<boolean> {
91
+ const controller = new AbortController();
92
+ const timeout = setTimeout(() => controller.abort(), 3000);
93
+ try {
94
+ const response = await fetch(`${runnerUrl}/status`, {
95
+ headers: { Authorization: `Bearer ${token}` },
96
+ signal: controller.signal,
97
+ });
98
+ return response.ok;
99
+ } catch {
100
+ return false;
101
+ } finally {
102
+ // Also on failure: a pending timer keeps the event loop alive.
103
+ clearTimeout(timeout);
104
+ }
105
+ }
106
+
107
+ function detectMaestroVersion(): Promise<string> {
108
+ return new Promise((resolve, reject) => {
109
+ execFile('maestro', ['--version'], { encoding: 'utf8', ...maestroSpawnOptions }, (error, stdout) => {
110
+ if (error) {
111
+ reject(
112
+ new Error(
113
+ 'Failed to run `maestro --version`. Install the Maestro CLI first: https://docs.maestro.dev/getting-started/installing-maestro',
114
+ ),
115
+ );
116
+ return;
117
+ }
118
+ // stdout only: update notices can also start with a version string and
119
+ // land on stderr or after the real version.
120
+ const version = stdout
121
+ .split('\n')
122
+ .map((line) => line.trim())
123
+ .find((line) => /^\d+\.\d+\.\d+/.test(line));
124
+ if (!version) {
125
+ reject(new Error('Could not parse the Maestro CLI version from `maestro --version` output.'));
126
+ return;
127
+ }
128
+ resolve(version);
129
+ });
130
+ });
131
+ }
132
+
133
+ function supportsDriverHostPort(version: string): boolean {
134
+ const [major = 0, minor = 0] = version.split('.').map((part) => Number.parseInt(part, 10) || 0);
135
+ return major > 2 || (major === 2 && minor >= 6);
136
+ }
137
+
138
+ // Finds a free port and releases it immediately: Maestro validates that the
139
+ // driver port is bindable, so it must stay unbound on our side.
140
+ function findFreePort(): Promise<number> {
141
+ return new Promise((resolve, reject) => {
142
+ const server = net.createServer();
143
+ server.once('error', reject);
144
+ server.listen(0, '127.0.0.1', () => {
145
+ const address = server.address();
146
+ if (!address || typeof address === 'string') {
147
+ server.close();
148
+ reject(new Error('Failed to find a free port.'));
149
+ return;
150
+ }
151
+ server.close(() => resolve(address.port));
152
+ });
153
+ });
154
+ }
package/src/ios-shim.ts CHANGED
@@ -100,6 +100,9 @@ async function handleShimmedXcrun(
100
100
  udid: string,
101
101
  args: string[],
102
102
  ): Promise<IosShimSimctlResult> {
103
+ if (args[0] === 'devicectl') {
104
+ return devicectlListDevices(args);
105
+ }
103
106
  if (args[0] !== 'simctl') {
104
107
  return { code: 127, stdout: '', stderr: `unsupported xcrun command: ${args.join(' ')}` };
105
108
  }
@@ -148,6 +151,43 @@ async function handleShimmedXcrun(
148
151
  return await client.simctl(simctlArgs).wait();
149
152
  }
150
153
 
154
+ // There are never physical devices behind the shim, so `devicectl list
155
+ // devices` gets an empty device list in the shape Maestro's picker parses.
156
+ // Flags can precede the subcommand (devicectl --json-output <path> list
157
+ // devices), so match on positionals.
158
+ function devicectlListDevices(args: string[]): IosShimSimctlResult {
159
+ const positionals: string[] = [];
160
+ for (let i = 1; i < args.length; i++) {
161
+ if (args[i] === '--json-output') {
162
+ i++;
163
+ continue;
164
+ }
165
+ if (args[i]!.startsWith('-')) continue;
166
+ positionals.push(args[i]!);
167
+ }
168
+ if (positionals[0] !== 'list' || positionals[1] !== 'devices') {
169
+ return { code: 127, stdout: '', stderr: `unsupported devicectl command: ${args.join(' ')}` };
170
+ }
171
+ const payload = JSON.stringify({ result: { devices: [] } });
172
+ const jsonOutIndex = args.indexOf('--json-output');
173
+ const jsonOutPath = jsonOutIndex === -1 ? undefined : args[jsonOutIndex + 1];
174
+ if (jsonOutPath && jsonOutPath !== '-') {
175
+ try {
176
+ fs.writeFileSync(jsonOutPath, payload);
177
+ } catch (error) {
178
+ return {
179
+ code: 1,
180
+ stdout: '',
181
+ stderr: `could not write devicectl output to ${jsonOutPath}: ${
182
+ error instanceof Error ? error.message : String(error)
183
+ }`,
184
+ };
185
+ }
186
+ return { code: 0, stdout: '', stderr: '' };
187
+ }
188
+ return { code: 0, stdout: `${payload}\n`, stderr: '' };
189
+ }
190
+
151
191
  function simctlList(udid: string, simctlArgs: string[]): IosShimSimctlResult {
152
192
  if (!simctlArgs.includes('-j')) {
153
193
  return { code: 64, stdout: '', stderr: 'limrun xcrun shim only supports `xcrun simctl list -j`.' };
@@ -269,8 +309,9 @@ function toSimctlListApps(
269
309
 
270
310
  function xcrunShimSource(options?: { shimUrl: string; udid: string }): string {
271
311
  // Keep the executable tiny: it decides whether this is a Limrun-targeted
272
- // simctl call, then asks the local shim server to perform the real work.
273
- // Non-Limrun calls still delegate to the host xcrun.
312
+ // simctl call (or a devicectl device enumeration), then asks the local shim
313
+ // server to perform the real work. Non-Limrun calls still delegate to the
314
+ // host xcrun.
274
315
  const embeddedShimUrl = options ? JSON.stringify(options.shimUrl) : 'process.env.LIMRUN_XCRUN_SHIM_URL';
275
316
  const embeddedUdid = options ? JSON.stringify(options.udid) : 'process.env.LIMRUN_IOS_UDID';
276
317
  return `#!/usr/bin/env node
@@ -294,7 +335,26 @@ function fail(message) {
294
335
  process.exit(64);
295
336
  }
296
337
 
297
- if (args[0] !== 'simctl') {
338
+ // Physical-device enumeration (e.g. Maestro's device picker) is answered by
339
+ // the shim server; real devicectl does not exist off macOS. Every other
340
+ // devicectl subcommand delegates. Flags can precede the subcommand
341
+ // (devicectl --json-output <path> list devices), so match on positionals.
342
+ function devicectlPositionals(args) {
343
+ const positionals = [];
344
+ for (let i = 1; i < args.length; i++) {
345
+ if (args[i] === '--json-output') {
346
+ i++;
347
+ continue;
348
+ }
349
+ if (args[i].startsWith('-')) continue;
350
+ positionals.push(args[i]);
351
+ }
352
+ return positionals;
353
+ }
354
+ const devicectlSubcommand = args[0] === 'devicectl' ? devicectlPositionals(args) : [];
355
+ const isDevicectlListDevices = devicectlSubcommand[0] === 'list' && devicectlSubcommand[1] === 'devices';
356
+
357
+ if (args[0] !== 'simctl' && !isDevicectlListDevices) {
298
358
  delegate();
299
359
  }
300
360
 
@@ -304,21 +364,23 @@ if (!shimUrl || !udid) {
304
364
  fail('LIMRUN_XCRUN_SHIM_URL and LIMRUN_IOS_UDID are required.');
305
365
  }
306
366
 
307
- const simctlArgs = args.slice(1);
308
- const command = simctlArgs[0];
309
367
  function simctlTarget(command, simctlArgs) {
310
368
  if (command === 'launch') {
311
369
  return simctlArgs.slice(1).find((arg) => !arg.startsWith('-'));
312
370
  }
313
371
  return simctlArgs[1];
314
372
  }
315
- const target = simctlTarget(command, simctlArgs);
316
373
  function isLimrunTarget(value) {
317
374
  return value === udid || value === 'booted';
318
375
  }
319
376
 
320
- if (command !== 'list' && !isLimrunTarget(target)) {
321
- delegate();
377
+ if (args[0] === 'simctl') {
378
+ const simctlArgs = args.slice(1);
379
+ const command = simctlArgs[0];
380
+ const target = simctlTarget(command, simctlArgs);
381
+ if (command !== 'list' && !isLimrunTarget(target)) {
382
+ delegate();
383
+ }
322
384
  }
323
385
 
324
386
  const parsed = new URL(shimUrl);
package/src/tunnel.ts CHANGED
@@ -108,6 +108,12 @@ export interface ReverseTcpTunnelOptions {
108
108
  * @default 16777216
109
109
  */
110
110
  maxPendingBytesPerConnection?: number;
111
+ /**
112
+ * Maximum bytes queued in the WebSocket before local reads pause. Bounds
113
+ * memory when the local service produces data faster than the tunnel drains.
114
+ * @default 4194304
115
+ */
116
+ maxBufferedBytes?: number;
111
117
  /**
112
118
  * Local TCP connect timeout in milliseconds.
113
119
  * @default 10000
@@ -167,6 +173,9 @@ export async function startReverseTcpTunnel(
167
173
  const logLevel = options.logLevel ?? 'info';
168
174
  const maxConnections = options.maxConnections ?? 64;
169
175
  const maxPendingBytesPerConnection = options.maxPendingBytesPerConnection ?? 16 * 1024 * 1024;
176
+ const maxBufferedBytes = options.maxBufferedBytes ?? 4 * 1024 * 1024;
177
+ // Hysteresis: resume well below the pause threshold to avoid flapping.
178
+ const resumeBelowBufferedBytes = maxBufferedBytes / 4;
170
179
  const connectTimeoutMs = options.connectTimeoutMs ?? 10_000;
171
180
 
172
181
  const logger = {
@@ -200,6 +209,8 @@ export async function startReverseTcpTunnel(
200
209
  let connectionState: TunnelConnectionState = 'connecting';
201
210
  let hasResolved = false;
202
211
  let remoteAddress: ReverseTunnel['remoteAddress'] | undefined;
212
+ // The WebSocket is shared by all connections, so backpressure is tunnel-wide.
213
+ let pausedForBackpressure = false;
203
214
 
204
215
  const updateConnectionState = (newState: TunnelConnectionState): void => {
205
216
  if (connectionState !== newState) {
@@ -217,7 +228,31 @@ export async function startReverseTcpTunnel(
217
228
 
218
229
  const sendCloseSignal = (connId: number): void => {
219
230
  if (ws && ws.readyState === WebSocket.OPEN) {
220
- ws.send(encodeConnectionHeader(connId));
231
+ // The drain check on completion keeps resume reachable even when the
232
+ // last frames in the queue are close signals rather than data.
233
+ ws.send(encodeConnectionHeader(connId), () => resumeIfDrained());
234
+ }
235
+ };
236
+
237
+ const pauseForBackpressure = (): void => {
238
+ if (pausedForBackpressure) return;
239
+ pausedForBackpressure = true;
240
+ logger.debug('WebSocket send buffer full, pausing local reads');
241
+ for (const socket of connections.values()) {
242
+ if (!socket.destroyed) socket.pause();
243
+ }
244
+ };
245
+
246
+ // Called from ws.send completion callbacks. Sends and callbacks are 1:1
247
+ // and pausing happens after at least one send is queued, so the callback
248
+ // that observes a drained buffer is guaranteed to exist; no polling needed.
249
+ const resumeIfDrained = (): void => {
250
+ if (!pausedForBackpressure) return;
251
+ if (!ws || ws.bufferedAmount >= resumeBelowBufferedBytes) return;
252
+ pausedForBackpressure = false;
253
+ logger.debug('WebSocket send buffer drained, resuming local reads');
254
+ for (const socket of connections.values()) {
255
+ if (!socket.destroyed) socket.resume();
221
256
  }
222
257
  };
223
258
 
@@ -266,6 +301,7 @@ export async function startReverseTcpTunnel(
266
301
  for (const connId of Array.from(connections.keys())) {
267
302
  removeConnection(connId, false);
268
303
  }
304
+ pausedForBackpressure = false;
269
305
  connections.clear();
270
306
  connecting.clear();
271
307
  pendingPerConn.clear();
@@ -372,6 +408,11 @@ export async function startReverseTcpTunnel(
372
408
  connecting.add(connId);
373
409
 
374
410
  const socket = net.createConnection({ host: localHost, port: localPort });
411
+ // Self-heal a stale pause before applying it to the new socket.
412
+ resumeIfDrained();
413
+ if (pausedForBackpressure) {
414
+ socket.pause();
415
+ }
375
416
  connections.set(connId, socket);
376
417
  const connectTimer = setTimeout(() => {
377
418
  logger.error(`Local TCP connect timed out conn=${connId} after ${connectTimeoutMs}ms`);
@@ -387,13 +428,22 @@ export async function startReverseTcpTunnel(
387
428
  flushPending(connId, socket);
388
429
  });
389
430
 
431
+ const header = encodeConnectionHeader(connId);
432
+ const onSent = (err?: Error): void => {
433
+ if (err) {
434
+ logger.error(`Failed to send conn=${connId} data: ${err.message}`);
435
+ }
436
+ resumeIfDrained();
437
+ };
390
438
  socket.on('data', (chunk: Buffer) => {
391
439
  if (ws && ws.readyState === WebSocket.OPEN) {
392
- ws.send(Buffer.concat([encodeConnectionHeader(connId), chunk]), (err) => {
393
- if (err) {
394
- logger.error(`Failed to send conn=${connId} data: ${err.message}`);
395
- }
396
- });
440
+ const framed = Buffer.allocUnsafe(4 + chunk.length);
441
+ header.copy(framed, 0);
442
+ chunk.copy(framed, 4);
443
+ ws.send(framed, onSent);
444
+ if (!pausedForBackpressure && ws.bufferedAmount > maxBufferedBytes) {
445
+ pauseForBackpressure();
446
+ }
397
447
  }
398
448
  });
399
449
 
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.45.2'; // x-release-please-version
1
+ export const VERSION = '0.45.3'; // x-release-please-version
package/tunnel.d.mts CHANGED
@@ -96,6 +96,12 @@ export interface ReverseTcpTunnelOptions {
96
96
  * @default 16777216
97
97
  */
98
98
  maxPendingBytesPerConnection?: number;
99
+ /**
100
+ * Maximum bytes queued in the WebSocket before local reads pause. Bounds
101
+ * memory when the local service produces data faster than the tunnel drains.
102
+ * @default 4194304
103
+ */
104
+ maxBufferedBytes?: number;
99
105
  /**
100
106
  * Local TCP connect timeout in milliseconds.
101
107
  * @default 10000