@limrun/api 0.45.2 → 0.45.4
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.
- package/CHANGELOG.md +8 -0
- package/http-proxy.d.mts +17 -0
- package/http-proxy.d.mts.map +1 -1
- package/http-proxy.d.ts +17 -0
- package/http-proxy.d.ts.map +1 -1
- package/http-proxy.js +114 -0
- package/http-proxy.js.map +1 -1
- package/http-proxy.mjs +113 -0
- package/http-proxy.mjs.map +1 -1
- package/index.d.mts +2 -1
- package/index.d.mts.map +1 -1
- package/index.d.ts +2 -1
- package/index.d.ts.map +1 -1
- package/index.js +9 -1
- package/index.js.map +1 -1
- package/index.mjs +2 -1
- package/index.mjs.map +1 -1
- package/ios-client.d.mts +11 -0
- package/ios-client.d.mts.map +1 -1
- package/ios-client.d.ts +11 -0
- package/ios-client.d.ts.map +1 -1
- package/ios-client.js +13 -0
- package/ios-client.js.map +1 -1
- package/ios-client.mjs +14 -1
- package/ios-client.mjs.map +1 -1
- package/ios-maestro.d.mts +34 -0
- package/ios-maestro.d.mts.map +1 -0
- package/ios-maestro.d.ts +34 -0
- package/ios-maestro.d.ts.map +1 -0
- package/ios-maestro.js +134 -0
- package/ios-maestro.js.map +1 -0
- package/ios-maestro.mjs +127 -0
- package/ios-maestro.mjs.map +1 -0
- package/ios-shim.js +69 -8
- package/ios-shim.js.map +1 -1
- package/ios-shim.mjs +69 -8
- package/ios-shim.mjs.map +1 -1
- package/package.json +12 -2
- package/src/http-proxy.ts +138 -0
- package/src/index.ts +16 -1
- package/src/ios-client.ts +32 -1
- package/src/ios-maestro.ts +154 -0
- package/src/ios-shim.ts +70 -8
- package/src/tunnel.ts +56 -6
- package/src/version.ts +1 -1
- package/tunnel.d.mts +6 -0
- package/tunnel.d.mts.map +1 -1
- package/tunnel.d.ts +6 -0
- package/tunnel.d.ts.map +1 -1
- package/tunnel.js +53 -6
- package/tunnel.js.map +1 -1
- package/tunnel.mjs +53 -6
- package/tunnel.mjs.map +1 -1
- package/version.d.mts +1 -1
- package/version.d.ts +1 -1
- package/version.js +1 -1
- package/version.mjs +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@limrun/api",
|
|
3
|
-
"version": "0.45.
|
|
3
|
+
"version": "0.45.4",
|
|
4
4
|
"description": "The official TypeScript library for the Limrun API",
|
|
5
5
|
"author": "Limrun <contact@limrun.com>",
|
|
6
6
|
"types": "./index.d.ts",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"https-proxy-agent": "7.0.6",
|
|
35
35
|
"ignore": "^7.0.5",
|
|
36
36
|
"proxy-from-env": "^2.1.0",
|
|
37
|
-
"undici": "7.
|
|
37
|
+
"undici": "7.28.0",
|
|
38
38
|
"ws": "^8.18.3",
|
|
39
39
|
"yauzl": "^3.4.0"
|
|
40
40
|
},
|
|
@@ -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 {
|
|
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 {
|
|
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
|
|
273
|
-
// Non-Limrun calls still delegate to the
|
|
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
|
-
|
|
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 (
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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.
|
|
1
|
+
export const VERSION = '0.45.4'; // 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
|