@expo/build-tools 23.2.0 → 24.1.0
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/dist/common/projectSources.js +4 -2
- package/dist/context.d.ts +2 -0
- package/dist/context.js +2 -0
- package/dist/customBuildContext.d.ts +1 -0
- package/dist/customBuildContext.js +2 -0
- package/dist/generic.js +11 -1
- package/dist/steps/easFunctions.js +2 -0
- package/dist/steps/functions/startAgentDeviceRemoteSession.js +7 -3
- package/dist/steps/functions/startAndroidEmulator.js +21 -0
- package/dist/steps/functions/startAppiumRemoteSession.js +7 -3
- package/dist/steps/functions/startArgentRemoteSession.js +26 -15
- package/dist/steps/functions/startLocalEgress.d.ts +10 -0
- package/dist/steps/functions/startLocalEgress.js +156 -0
- package/dist/steps/functions/startWebPreviewRemoteSession.js +10 -4
- package/dist/steps/functions/uploadToAsc.js +11 -5
- package/dist/steps/utils/appiumCommandSummary.js +3 -0
- package/dist/steps/utils/appiumCommands.generated.d.ts +2 -2
- package/dist/steps/utils/localEgress.d.ts +152 -0
- package/dist/steps/utils/localEgress.js +550 -0
- package/dist/steps/utils/localEgressSession.d.ts +9 -0
- package/dist/steps/utils/localEgressSession.js +40 -0
- package/dist/steps/utils/remoteDeviceRunSession.d.ts +8 -18
- package/dist/steps/utils/remoteDeviceRunSession.js +66 -21
- package/dist/steps/utils/serveSimMetricsRecorder.d.ts +4 -1
- package/dist/steps/utils/serveSimMetricsRecorder.js +14 -6
- package/dist/utils/AndroidEmulatorUtils.d.ts +4 -1
- package/dist/utils/AndroidEmulatorUtils.js +5 -1
- package/dist/utils/logPhase.d.ts +2 -0
- package/dist/utils/logPhase.js +22 -0
- package/package.json +8 -8
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.CHISEL_VERSION = exports.LOCAL_EGRESS_HANDOFF_PATH = exports.LOCAL_EGRESS_USERNAME = exports.LOCAL_EGRESS_PROXY_PORT = exports.LOCAL_EGRESS_PROXY_HOST = void 0;
|
|
7
|
+
exports.getChiselAssetName = getChiselAssetName;
|
|
8
|
+
exports.getChiselDownloadUrl = getChiselDownloadUrl;
|
|
9
|
+
exports.downloadChiselAsync = downloadChiselAsync;
|
|
10
|
+
exports.generateEgressCredentials = generateEgressCredentials;
|
|
11
|
+
exports.createChiselAuthfileContents = createChiselAuthfileContents;
|
|
12
|
+
exports.parseChiselFingerprint = parseChiselFingerprint;
|
|
13
|
+
exports.startChiselServerAsync = startChiselServerAsync;
|
|
14
|
+
exports.parseDefaultRouteInterface = parseDefaultRouteInterface;
|
|
15
|
+
exports.parseNetworkServiceNameForDevice = parseNetworkServiceNameForDevice;
|
|
16
|
+
exports.resolveActiveNetworkServiceNameAsync = resolveActiveNetworkServiceNameAsync;
|
|
17
|
+
exports.buildNetworksetupProxyArgs = buildNetworksetupProxyArgs;
|
|
18
|
+
exports.configureSystemProxyAsync = configureSystemProxyAsync;
|
|
19
|
+
exports.writeLocalEgressHandoffAsync = writeLocalEgressHandoffAsync;
|
|
20
|
+
exports.readLocalEgressHandoffAsync = readLocalEgressHandoffAsync;
|
|
21
|
+
exports.buildEgressRemoteConfigFields = buildEgressRemoteConfigFields;
|
|
22
|
+
exports.registerLocalEgressResources = registerLocalEgressResources;
|
|
23
|
+
exports.stopLocalEgressResourcesAsync = stopLocalEgressResourcesAsync;
|
|
24
|
+
exports.isPortListeningAsync = isPortListeningAsync;
|
|
25
|
+
exports.parseExitIpResponse = parseExitIpResponse;
|
|
26
|
+
exports.fetchExitIpThroughProxyAsync = fetchExitIpThroughProxyAsync;
|
|
27
|
+
exports.collectSimulatorProcessIds = collectSimulatorProcessIds;
|
|
28
|
+
exports.parseDirectSimulatorConnections = parseDirectSimulatorConnections;
|
|
29
|
+
exports.monitorLocalEgressAsync = monitorLocalEgressAsync;
|
|
30
|
+
const downloader_1 = __importDefault(require("@expo/downloader"));
|
|
31
|
+
const eas_build_job_1 = require("@expo/eas-build-job");
|
|
32
|
+
const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
|
|
33
|
+
const node_crypto_1 = require("node:crypto");
|
|
34
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
35
|
+
const node_net_1 = __importDefault(require("node:net"));
|
|
36
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
37
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
38
|
+
const promises_1 = require("node:stream/promises");
|
|
39
|
+
const node_zlib_1 = __importDefault(require("node:zlib"));
|
|
40
|
+
const retry_1 = require("../../utils/retry");
|
|
41
|
+
const remoteDeviceRunSession_1 = require("./remoteDeviceRunSession");
|
|
42
|
+
/**
|
|
43
|
+
* Local egress provides an HTTP(S) proxy through the machine running the EAS CLI.
|
|
44
|
+
* Requests that use this proxy exit through that machine's network.
|
|
45
|
+
*
|
|
46
|
+
* Worker side: a chisel server in reverse mode listens on loopback and is exposed
|
|
47
|
+
* through the session's ngrok domain. When the CLI's egress client connects, chisel
|
|
48
|
+
* opens LOCAL_EGRESS_PROXY_PORT on this host and forwards every connection to the
|
|
49
|
+
* HTTP proxy the CLI runs. The macOS system proxy points at that port. Until the
|
|
50
|
+
* client connects, nothing listens on the port and proxied requests fail.
|
|
51
|
+
*
|
|
52
|
+
* Contract: HTTP(S) and WebSocket requests that honor the system proxy (WebKit,
|
|
53
|
+
* URLSession and other CFNetwork clients) exit from the egress client's network
|
|
54
|
+
* and fail while the client is disconnected. Requests from libraries that bypass
|
|
55
|
+
* the system proxy are not covered and exit from this host. Nothing here enforces
|
|
56
|
+
* routing; the session monitor reports such direct connections instead.
|
|
57
|
+
*/
|
|
58
|
+
exports.LOCAL_EGRESS_PROXY_HOST = '127.0.0.1';
|
|
59
|
+
exports.LOCAL_EGRESS_PROXY_PORT = 8899;
|
|
60
|
+
exports.LOCAL_EGRESS_USERNAME = 'eas';
|
|
61
|
+
exports.LOCAL_EGRESS_HANDOFF_PATH = node_path_1.default.join(node_os_1.default.tmpdir(), 'eas-simulator-local-egress.json');
|
|
62
|
+
exports.CHISEL_VERSION = '1.12.0';
|
|
63
|
+
// sha256 of the release .gz assets, cross-checked against chisel_1.12.0_checksums.txt.
|
|
64
|
+
const CHISEL_SHA256_BY_ASSET = {
|
|
65
|
+
'chisel_1.12.0_darwin_arm64.gz': '707a4b932eea214765146504a0df246cefc415b4297af65a80dd67cf69ba85a9',
|
|
66
|
+
'chisel_1.12.0_darwin_amd64.gz': '4aeae36c867f11c8e8c3f2b913a0e063ea3c6d29e1c14a52ed2e6eef8cfc4395',
|
|
67
|
+
'chisel_1.12.0_linux_amd64.gz': 'f3f180f1d93aa72cce4e6386f98cc06569a0146fbd65eb4423cf83e6434bcfe6',
|
|
68
|
+
'chisel_1.12.0_linux_arm64.gz': '2ec6152cd2c74fe0146d4d79e4e7aa174521368c56e433d55e023a92ea404ec3',
|
|
69
|
+
};
|
|
70
|
+
const CHISEL_STARTUP_TIMEOUT_MS = 15_000;
|
|
71
|
+
const EGRESS_MONITOR_INTERVAL_MS = 2_000;
|
|
72
|
+
const EGRESS_ESCAPE_SCAN_INTERVAL_MS = 5_000;
|
|
73
|
+
const EGRESS_ESCAPE_LOG_LIMIT = 50;
|
|
74
|
+
function getChiselAssetName({ platform, arch, }) {
|
|
75
|
+
const os = platform === 'darwin' ? 'darwin' : platform === 'linux' ? 'linux' : null;
|
|
76
|
+
const cpu = arch === 'arm64' ? 'arm64' : arch === 'x64' ? 'amd64' : null;
|
|
77
|
+
if (!os || !cpu) {
|
|
78
|
+
throw new eas_build_job_1.SystemError(`Local egress is not supported on ${platform}/${arch}. The reverse tunnel binary is available for macOS and Linux on arm64 and x64.`);
|
|
79
|
+
}
|
|
80
|
+
return `chisel_${exports.CHISEL_VERSION}_${os}_${cpu}.gz`;
|
|
81
|
+
}
|
|
82
|
+
function getChiselDownloadUrl(assetName) {
|
|
83
|
+
return `https://github.com/jpillora/chisel/releases/download/v${exports.CHISEL_VERSION}/${assetName}`;
|
|
84
|
+
}
|
|
85
|
+
async function sha256FileAsync(filePath) {
|
|
86
|
+
const hash = (0, node_crypto_1.createHash)('sha256');
|
|
87
|
+
await (0, promises_1.pipeline)(node_fs_1.default.createReadStream(filePath), hash);
|
|
88
|
+
return hash.digest('hex');
|
|
89
|
+
}
|
|
90
|
+
async function downloadChiselAsync({ destinationDir, logger, platform = process.platform, arch = process.arch, }) {
|
|
91
|
+
const assetName = getChiselAssetName({ platform, arch });
|
|
92
|
+
const expectedSha256 = CHISEL_SHA256_BY_ASSET[assetName];
|
|
93
|
+
if (!expectedSha256) {
|
|
94
|
+
throw new eas_build_job_1.SystemError(`No pinned checksum for ${assetName}.`);
|
|
95
|
+
}
|
|
96
|
+
const url = getChiselDownloadUrl(assetName);
|
|
97
|
+
const archivePath = node_path_1.default.join(destinationDir, assetName);
|
|
98
|
+
logger.info(`Downloading ${url}.`);
|
|
99
|
+
await (0, downloader_1.default)(url, archivePath, { retry: 3, timeout: 60_000 });
|
|
100
|
+
const actualSha256 = await sha256FileAsync(archivePath);
|
|
101
|
+
if (actualSha256 !== expectedSha256) {
|
|
102
|
+
throw new eas_build_job_1.SystemError(`Checksum mismatch for ${assetName}: expected ${expectedSha256}, got ${actualSha256}. ` +
|
|
103
|
+
'The download may be corrupted or tampered with; the session was not started.');
|
|
104
|
+
}
|
|
105
|
+
const binaryPath = node_path_1.default.join(destinationDir, 'chisel');
|
|
106
|
+
await (0, promises_1.pipeline)(node_fs_1.default.createReadStream(archivePath), node_zlib_1.default.createGunzip(), node_fs_1.default.createWriteStream(binaryPath));
|
|
107
|
+
await node_fs_1.default.promises.chmod(binaryPath, 0o755);
|
|
108
|
+
return binaryPath;
|
|
109
|
+
}
|
|
110
|
+
function generateEgressCredentials() {
|
|
111
|
+
return { user: exports.LOCAL_EGRESS_USERNAME, password: (0, node_crypto_1.randomBytes)(24).toString('base64url') };
|
|
112
|
+
}
|
|
113
|
+
// Matches 1024-65535: every unprivileged TCP port.
|
|
114
|
+
const UNPRIVILEGED_PORT_PATTERN = '(102[4-9]|10[3-9][0-9]|1[1-9][0-9]{2}|[2-9][0-9]{3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])';
|
|
115
|
+
/**
|
|
116
|
+
* chisel authfile: one user, allowed to open reverse remotes on loopback only.
|
|
117
|
+
* The proxy port carries proxied requests. Any unprivileged loopback port may be
|
|
118
|
+
* forwarded too: the EAS CLI opens one per `--egress-allow localhost:<port>`
|
|
119
|
+
* entry so `127.0.0.1:<port>` inside the simulator reaches that port on the
|
|
120
|
+
* developer's machine, like `adb reverse`. iOS never sends loopback-literal
|
|
121
|
+
* requests to the system proxy, so the proxy alone cannot serve them.
|
|
122
|
+
* Reverse remotes are matched as `R:<interface>:<port>`.
|
|
123
|
+
*/
|
|
124
|
+
function createChiselAuthfileContents({ user, password, port, }) {
|
|
125
|
+
const escapedHost = exports.LOCAL_EGRESS_PROXY_HOST.replace(/\./g, '\\.');
|
|
126
|
+
return JSON.stringify({
|
|
127
|
+
[`${user}:${password}`]: [
|
|
128
|
+
`^R:${escapedHost}:${port}$`,
|
|
129
|
+
`^R:${escapedHost}:${UNPRIVILEGED_PORT_PATTERN}$`,
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
function parseChiselFingerprint(output) {
|
|
134
|
+
const match = /Fingerprint\s+(\S+)/.exec(output);
|
|
135
|
+
return match?.[1] ?? null;
|
|
136
|
+
}
|
|
137
|
+
function isProcessRunning(pid) {
|
|
138
|
+
try {
|
|
139
|
+
process.kill(pid, 0);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function startChiselServerAsync({ chiselPath, controlPort, authfilePath, env, signal, }) {
|
|
147
|
+
const server = (0, remoteDeviceRunSession_1.spawnDetached)({
|
|
148
|
+
command: chiselPath,
|
|
149
|
+
args: [
|
|
150
|
+
'server',
|
|
151
|
+
'--host',
|
|
152
|
+
exports.LOCAL_EGRESS_PROXY_HOST,
|
|
153
|
+
'--port',
|
|
154
|
+
String(controlPort),
|
|
155
|
+
'--reverse',
|
|
156
|
+
'--authfile',
|
|
157
|
+
authfilePath,
|
|
158
|
+
],
|
|
159
|
+
// AUTH adds an unrestricted user even when --authfile is supplied.
|
|
160
|
+
env: { ...env, AUTH: '' },
|
|
161
|
+
});
|
|
162
|
+
try {
|
|
163
|
+
const deadline = Date.now() + CHISEL_STARTUP_TIMEOUT_MS;
|
|
164
|
+
while (Date.now() < deadline) {
|
|
165
|
+
signal?.throwIfAborted();
|
|
166
|
+
if (server.pid === undefined || !isProcessRunning(server.pid)) {
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
const output = server.getOutput();
|
|
170
|
+
const fingerprint = parseChiselFingerprint(output);
|
|
171
|
+
// Chisel prints the fingerprint before binding. This line comes from the
|
|
172
|
+
// child only after its listener succeeds, unlike a probe of a reused port.
|
|
173
|
+
const listening = output
|
|
174
|
+
.split('\n')
|
|
175
|
+
.some(line => line
|
|
176
|
+
.trimEnd()
|
|
177
|
+
.endsWith(`server: Listening on http://${exports.LOCAL_EGRESS_PROXY_HOST}:${controlPort}`));
|
|
178
|
+
if (fingerprint && listening) {
|
|
179
|
+
return { process: server, fingerprint };
|
|
180
|
+
}
|
|
181
|
+
await (0, retry_1.sleepAsync)(250);
|
|
182
|
+
}
|
|
183
|
+
throw new eas_build_job_1.SystemError(`The reverse tunnel server did not start within ${CHISEL_STARTUP_TIMEOUT_MS / 1000}s. Output:\n${server.getOutput() || '<empty>'}`);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
await server.stopAsync();
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function parseDefaultRouteInterface(routeOutput) {
|
|
191
|
+
const match = /^\s*interface:\s*(\S+)/m.exec(routeOutput);
|
|
192
|
+
return match?.[1] ?? null;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* `networksetup -listnetworkserviceorder` prints each service as two lines:
|
|
196
|
+
* "(1) Wi-Fi" followed by "(Hardware Port: Wi-Fi, Device: en0)". Return the
|
|
197
|
+
* service name whose device matches.
|
|
198
|
+
*/
|
|
199
|
+
function parseNetworkServiceNameForDevice(listOutput, device) {
|
|
200
|
+
const lines = listOutput.split('\n').map(line => line.trim());
|
|
201
|
+
for (let i = 0; i < lines.length; i++) {
|
|
202
|
+
const hardwarePortMatch = /^\(Hardware Port: (.+), Device: (\S+)\)$/.exec(lines[i]);
|
|
203
|
+
if (!hardwarePortMatch || hardwarePortMatch[2] !== device) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const serviceLineMatch = /^\(\d+\) (.+)$/.exec(lines[i - 1] ?? '');
|
|
207
|
+
return serviceLineMatch?.[1] ?? hardwarePortMatch[1];
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
async function resolveActiveNetworkServiceNameAsync({ env, }) {
|
|
212
|
+
const routeResult = await (0, turtle_spawn_1.default)('route', ['-n', 'get', 'default'], { env, stdio: 'pipe' });
|
|
213
|
+
const device = parseDefaultRouteInterface(routeResult.stdout);
|
|
214
|
+
if (!device) {
|
|
215
|
+
throw new eas_build_job_1.SystemError('Could not determine the default network interface of the device host, so the system proxy ' +
|
|
216
|
+
'cannot be configured. Output of `route -n get default`:\n' +
|
|
217
|
+
(routeResult.stdout || '<empty>'));
|
|
218
|
+
}
|
|
219
|
+
const listResult = await (0, turtle_spawn_1.default)('networksetup', ['-listnetworkserviceorder'], {
|
|
220
|
+
env,
|
|
221
|
+
stdio: 'pipe',
|
|
222
|
+
});
|
|
223
|
+
const service = parseNetworkServiceNameForDevice(listResult.stdout, device);
|
|
224
|
+
if (!service) {
|
|
225
|
+
throw new eas_build_job_1.SystemError(`Could not find the network service for interface ${device}, so the system proxy cannot be ` +
|
|
226
|
+
'configured. Output of `networksetup -listnetworkserviceorder`:\n' +
|
|
227
|
+
(listResult.stdout || '<empty>'));
|
|
228
|
+
}
|
|
229
|
+
return service;
|
|
230
|
+
}
|
|
231
|
+
function buildNetworksetupProxyArgs({ service, host, port, }) {
|
|
232
|
+
return [
|
|
233
|
+
['-setwebproxy', service, host, String(port)],
|
|
234
|
+
['-setsecurewebproxy', service, host, String(port)],
|
|
235
|
+
];
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Point the macOS system HTTP and HTTPS proxy at the loopback egress port. The
|
|
239
|
+
* simulator reads these settings when it boots, so this must run before
|
|
240
|
+
* `start_ios_simulator`. Existing bypass and automatic proxy settings are left
|
|
241
|
+
* unchanged. Requests that bypass this proxy can leave from this host's address.
|
|
242
|
+
*/
|
|
243
|
+
async function configureSystemProxyAsync({ env, logger, port, signal, }) {
|
|
244
|
+
if (process.env.ENVIRONMENT === 'development') {
|
|
245
|
+
logger.info('Job running outside of EAS, not changing the system proxy.');
|
|
246
|
+
return { service: '<development>' };
|
|
247
|
+
}
|
|
248
|
+
const service = await resolveActiveNetworkServiceNameAsync({ env });
|
|
249
|
+
for (const args of buildNetworksetupProxyArgs({ service, host: exports.LOCAL_EGRESS_PROXY_HOST, port })) {
|
|
250
|
+
signal?.throwIfAborted();
|
|
251
|
+
await (0, turtle_spawn_1.default)('networksetup', args, { env, logger, signal });
|
|
252
|
+
}
|
|
253
|
+
logger.info(`System proxy for "${service}" set to ${exports.LOCAL_EGRESS_PROXY_HOST}:${port}.`);
|
|
254
|
+
return { service };
|
|
255
|
+
}
|
|
256
|
+
async function writeLocalEgressHandoffAsync(handoff, handoffPath = exports.LOCAL_EGRESS_HANDOFF_PATH) {
|
|
257
|
+
await node_fs_1.default.promises.writeFile(handoffPath, JSON.stringify(handoff), {
|
|
258
|
+
encoding: 'utf8',
|
|
259
|
+
mode: 0o600,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
async function readLocalEgressHandoffAsync(handoffPath = exports.LOCAL_EGRESS_HANDOFF_PATH) {
|
|
263
|
+
let raw;
|
|
264
|
+
try {
|
|
265
|
+
raw = await node_fs_1.default.promises.readFile(handoffPath, 'utf8');
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
if (err.code === 'ENOENT') {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
throw err;
|
|
272
|
+
}
|
|
273
|
+
const parsed = JSON.parse(raw);
|
|
274
|
+
if (typeof parsed.url !== 'string' ||
|
|
275
|
+
typeof parsed.token !== 'string' ||
|
|
276
|
+
typeof parsed.fingerprint !== 'string' ||
|
|
277
|
+
typeof parsed.port !== 'number') {
|
|
278
|
+
throw new eas_build_job_1.SystemError(`Local egress handoff at ${handoffPath} is malformed.`);
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
url: parsed.url,
|
|
282
|
+
token: parsed.token,
|
|
283
|
+
fingerprint: parsed.fingerprint,
|
|
284
|
+
port: parsed.port,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/** remoteConfig fields the CLI needs to start the egress client. */
|
|
288
|
+
function buildEgressRemoteConfigFields(handoff) {
|
|
289
|
+
if (!handoff) {
|
|
290
|
+
return {};
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
egressUrl: handoff.url,
|
|
294
|
+
egressToken: handoff.token,
|
|
295
|
+
egressFingerprint: handoff.fingerprint,
|
|
296
|
+
egressPort: handoff.port,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
// The egress step returns before the session ends, so the resources it created
|
|
300
|
+
// are held here and released by the session step or the job's finally block.
|
|
301
|
+
let activeLocalEgressResources;
|
|
302
|
+
function registerLocalEgressResources(stopAsync) {
|
|
303
|
+
if (activeLocalEgressResources) {
|
|
304
|
+
throw new eas_build_job_1.SystemError('Local egress resources are already registered for this job.');
|
|
305
|
+
}
|
|
306
|
+
const controller = new AbortController();
|
|
307
|
+
activeLocalEgressResources = { stopAsync, controller };
|
|
308
|
+
return controller.signal;
|
|
309
|
+
}
|
|
310
|
+
async function stopLocalEgressResourcesAsync(logger) {
|
|
311
|
+
const resources = activeLocalEgressResources;
|
|
312
|
+
if (!resources) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
resources.controller.abort();
|
|
316
|
+
resources.stopping ??= Promise.resolve()
|
|
317
|
+
.then(resources.stopAsync)
|
|
318
|
+
.catch(err => logger.warn({ err }, 'Could not stop a local egress resource.'))
|
|
319
|
+
.finally(() => {
|
|
320
|
+
activeLocalEgressResources = undefined;
|
|
321
|
+
});
|
|
322
|
+
await resources.stopping;
|
|
323
|
+
}
|
|
324
|
+
async function isPortListeningAsync({ host, port, timeoutMs = 1_000, }) {
|
|
325
|
+
return await new Promise(resolve => {
|
|
326
|
+
const socket = node_net_1.default.connect({ host, port });
|
|
327
|
+
const finish = (listening) => {
|
|
328
|
+
socket.destroy();
|
|
329
|
+
resolve(listening);
|
|
330
|
+
};
|
|
331
|
+
socket.setTimeout(timeoutMs, () => finish(false));
|
|
332
|
+
socket.once('connect', () => finish(true));
|
|
333
|
+
socket.once('error', () => finish(false));
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
function parseExitIpResponse(body) {
|
|
337
|
+
const parsed = JSON.parse(body);
|
|
338
|
+
if (typeof parsed.ip !== 'string' || parsed.ip.length === 0) {
|
|
339
|
+
throw new eas_build_job_1.SystemError(`Unexpected exit IP response: ${body}`);
|
|
340
|
+
}
|
|
341
|
+
return parsed.ip;
|
|
342
|
+
}
|
|
343
|
+
async function fetchExitIpThroughProxyAsync({ port, env, }) {
|
|
344
|
+
const result = await (0, turtle_spawn_1.default)('curl', [
|
|
345
|
+
'-sS',
|
|
346
|
+
'--max-time',
|
|
347
|
+
'10',
|
|
348
|
+
'-x',
|
|
349
|
+
`http://${exports.LOCAL_EGRESS_PROXY_HOST}:${port}`,
|
|
350
|
+
'https://api.ipify.org?format=json',
|
|
351
|
+
], { env, stdio: 'pipe' });
|
|
352
|
+
return parseExitIpResponse(result.stdout);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Processes inside the simulator run on the host as descendants of launchd_sim,
|
|
356
|
+
* so `ps -axo pid=,ppid=,comm=` ancestry identifies them. Returns every
|
|
357
|
+
* descendant pid; launchd_sim itself opens no network connections.
|
|
358
|
+
*/
|
|
359
|
+
function collectSimulatorProcessIds(psOutput) {
|
|
360
|
+
const childrenByParent = new Map();
|
|
361
|
+
const roots = [];
|
|
362
|
+
for (const line of psOutput.split('\n')) {
|
|
363
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S.*)$/.exec(line);
|
|
364
|
+
if (!match) {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
const pid = Number(match[1]);
|
|
368
|
+
const parentPid = Number(match[2]);
|
|
369
|
+
const command = match[3].trim();
|
|
370
|
+
const siblings = childrenByParent.get(parentPid) ?? [];
|
|
371
|
+
siblings.push(pid);
|
|
372
|
+
childrenByParent.set(parentPid, siblings);
|
|
373
|
+
if (node_path_1.default.basename(command) === 'launchd_sim') {
|
|
374
|
+
roots.push(pid);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const descendants = [];
|
|
378
|
+
const seen = new Set(roots);
|
|
379
|
+
const queue = [...roots];
|
|
380
|
+
for (let i = 0; i < queue.length; i++) {
|
|
381
|
+
for (const child of childrenByParent.get(queue[i]) ?? []) {
|
|
382
|
+
if (seen.has(child)) {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
seen.add(child);
|
|
386
|
+
descendants.push(child);
|
|
387
|
+
queue.push(child);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return descendants;
|
|
391
|
+
}
|
|
392
|
+
function remoteHostOf(remote) {
|
|
393
|
+
if (remote.startsWith('[')) {
|
|
394
|
+
const end = remote.indexOf(']');
|
|
395
|
+
return end === -1 ? remote : remote.slice(1, end);
|
|
396
|
+
}
|
|
397
|
+
const colon = remote.lastIndexOf(':');
|
|
398
|
+
return colon === -1 ? remote : remote.slice(0, colon);
|
|
399
|
+
}
|
|
400
|
+
function isLoopbackHost(host) {
|
|
401
|
+
return (host === 'localhost' || host.startsWith('127.') || host === '::1' || host === '::ffff:127.0.0.1');
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Parse `lsof -nP -i -F pcPnT` output. Returns the TCP and UDP connections that
|
|
405
|
+
* simulator processes hold to peers outside loopback. Those did not go through
|
|
406
|
+
* the proxy on 127.0.0.1, so they exit from this host instead of the egress
|
|
407
|
+
* client. Listening sockets, unconnected UDP sockets and connections to
|
|
408
|
+
* loopback (the proxy itself) are ignored.
|
|
409
|
+
*/
|
|
410
|
+
function parseDirectSimulatorConnections(lsofOutput, simulatorPids) {
|
|
411
|
+
const connections = [];
|
|
412
|
+
let pid = null;
|
|
413
|
+
let command = '';
|
|
414
|
+
let protocol = null;
|
|
415
|
+
let name = null;
|
|
416
|
+
let tcpState = null;
|
|
417
|
+
const flush = () => {
|
|
418
|
+
if (pid !== null && simulatorPids.has(pid) && protocol && name) {
|
|
419
|
+
const arrow = name.indexOf('->');
|
|
420
|
+
const remote = arrow === -1 ? null : name.slice(arrow + 2);
|
|
421
|
+
const active = protocol !== 'TCP' ||
|
|
422
|
+
tcpState === null ||
|
|
423
|
+
tcpState === 'ESTABLISHED' ||
|
|
424
|
+
tcpState === 'SYN_SENT';
|
|
425
|
+
if (remote && active && !isLoopbackHost(remoteHostOf(remote))) {
|
|
426
|
+
connections.push({ pid, command, protocol, remote });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
protocol = null;
|
|
430
|
+
name = null;
|
|
431
|
+
tcpState = null;
|
|
432
|
+
};
|
|
433
|
+
for (const line of lsofOutput.split('\n')) {
|
|
434
|
+
const field = line[0];
|
|
435
|
+
const value = line.slice(1);
|
|
436
|
+
switch (field) {
|
|
437
|
+
case 'p':
|
|
438
|
+
flush();
|
|
439
|
+
pid = Number(value);
|
|
440
|
+
command = '';
|
|
441
|
+
break;
|
|
442
|
+
case 'c':
|
|
443
|
+
command = value;
|
|
444
|
+
break;
|
|
445
|
+
case 'f':
|
|
446
|
+
flush();
|
|
447
|
+
break;
|
|
448
|
+
case 'P':
|
|
449
|
+
protocol = value;
|
|
450
|
+
break;
|
|
451
|
+
case 'n':
|
|
452
|
+
name = value;
|
|
453
|
+
break;
|
|
454
|
+
case 'T':
|
|
455
|
+
if (value.startsWith('ST=')) {
|
|
456
|
+
tcpState = value.slice('ST='.length);
|
|
457
|
+
}
|
|
458
|
+
break;
|
|
459
|
+
default:
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
flush();
|
|
464
|
+
return connections;
|
|
465
|
+
}
|
|
466
|
+
async function findDirectSimulatorConnectionsAsync({ env, }) {
|
|
467
|
+
const ps = await (0, turtle_spawn_1.default)('ps', ['-axo', 'pid=,ppid=,comm='], { env, stdio: 'pipe' });
|
|
468
|
+
const pids = collectSimulatorProcessIds(ps.stdout);
|
|
469
|
+
if (pids.length === 0) {
|
|
470
|
+
return [];
|
|
471
|
+
}
|
|
472
|
+
let lsofOutput;
|
|
473
|
+
try {
|
|
474
|
+
const lsof = await (0, turtle_spawn_1.default)('lsof', ['-nP', '-i', '-F', 'pcPnT', '-a', '-p', pids.join(',')], {
|
|
475
|
+
env,
|
|
476
|
+
stdio: 'pipe',
|
|
477
|
+
});
|
|
478
|
+
lsofOutput = lsof.stdout;
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
// lsof exits with 1 when none of the processes holds a matching socket, and
|
|
482
|
+
// when one of them exited between `ps` and `lsof`. Its stdout is still valid.
|
|
483
|
+
const result = err;
|
|
484
|
+
if (result.status !== 1) {
|
|
485
|
+
throw err;
|
|
486
|
+
}
|
|
487
|
+
lsofOutput = result.stdout ?? '';
|
|
488
|
+
}
|
|
489
|
+
return parseDirectSimulatorConnections(lsofOutput, new Set(pids));
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Log proxy listener availability, the exit IP observed by a worker request
|
|
493
|
+
* through it, and simulator connections that bypassed the proxy. Neither check
|
|
494
|
+
* verifies that proxied simulator requests reach the egress client. Never
|
|
495
|
+
* rejects: it runs in the background for the whole session.
|
|
496
|
+
*/
|
|
497
|
+
async function monitorLocalEgressAsync({ port, env, logger, signal, }) {
|
|
498
|
+
let connected = false;
|
|
499
|
+
let lastEscapeScanAt = 0;
|
|
500
|
+
let escapeScanBroken = false;
|
|
501
|
+
const reportedEscapes = new Set();
|
|
502
|
+
const lifetimeSignal = activeLocalEgressResources?.controller.signal;
|
|
503
|
+
try {
|
|
504
|
+
while (!signal.aborted && !lifetimeSignal?.aborted) {
|
|
505
|
+
const listening = await isPortListeningAsync({ host: exports.LOCAL_EGRESS_PROXY_HOST, port });
|
|
506
|
+
if (listening && !connected) {
|
|
507
|
+
connected = true;
|
|
508
|
+
logger.info('Local egress proxy listener is available.');
|
|
509
|
+
try {
|
|
510
|
+
const exitIp = await fetchExitIpThroughProxyAsync({ port, env });
|
|
511
|
+
logger.info(`Worker proxy exit-IP check observed ${exitIp}. This does not verify simulator routing.`);
|
|
512
|
+
}
|
|
513
|
+
catch (err) {
|
|
514
|
+
logger.warn({ err }, 'The local egress proxy listener is available, but the worker exit-IP check through it failed.');
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
else if (!listening && connected) {
|
|
518
|
+
connected = false;
|
|
519
|
+
logger.warn('Local egress proxy listener is unavailable. Proxied HTTP(S) requests fail until it returns.');
|
|
520
|
+
}
|
|
521
|
+
if (!escapeScanBroken && Date.now() - lastEscapeScanAt >= EGRESS_ESCAPE_SCAN_INTERVAL_MS) {
|
|
522
|
+
lastEscapeScanAt = Date.now();
|
|
523
|
+
try {
|
|
524
|
+
for (const connection of await findDirectSimulatorConnectionsAsync({ env })) {
|
|
525
|
+
const key = `${connection.pid}|${connection.protocol}|${connection.remote}`;
|
|
526
|
+
if (reportedEscapes.has(key)) {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
reportedEscapes.add(key);
|
|
530
|
+
if (reportedEscapes.size > EGRESS_ESCAPE_LOG_LIMIT) {
|
|
531
|
+
if (reportedEscapes.size === EGRESS_ESCAPE_LOG_LIMIT + 1) {
|
|
532
|
+
logger.warn(`Local egress: more than ${EGRESS_ESCAPE_LOG_LIMIT} direct connections were reported; further ones are not logged.`);
|
|
533
|
+
}
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
logger.warn(`Local egress: ${connection.command} (pid ${connection.pid}) opened a direct ${connection.protocol} connection to ${connection.remote}, bypassing the system proxy. That traffic exits from this worker, not from the egress client.`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
catch (err) {
|
|
540
|
+
escapeScanBroken = true;
|
|
541
|
+
logger.warn({ err }, 'Local egress: could not inspect simulator connections. Direct connections that bypass the proxy will not be reported.');
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
await (0, retry_1.sleepAsync)(EGRESS_MONITOR_INTERVAL_MS);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
logger.warn({ err }, 'Local egress monitoring stopped unexpectedly.');
|
|
549
|
+
}
|
|
550
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type BuildFunction, type BuildStepEnv } from '@expo/steps';
|
|
2
|
+
import { uploadRemoteSessionConfigAsync } from './remoteDeviceRunSession';
|
|
3
|
+
/** Release the pre-boot egress resources even if controller startup or teardown fails. */
|
|
4
|
+
export declare function withLocalEgressSession(fn: NonNullable<BuildFunction['fn']>): NonNullable<BuildFunction['fn']>;
|
|
5
|
+
/** All simulator controllers publish the same egress handoff and run the same monitor. */
|
|
6
|
+
export declare function uploadRemoteSessionConfigWithLocalEgressAsync({ env, signal, ...options }: Parameters<typeof uploadRemoteSessionConfigAsync>[0] & {
|
|
7
|
+
env: BuildStepEnv;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.withLocalEgressSession = withLocalEgressSession;
|
|
4
|
+
exports.uploadRemoteSessionConfigWithLocalEgressAsync = uploadRemoteSessionConfigWithLocalEgressAsync;
|
|
5
|
+
const localEgress_1 = require("./localEgress");
|
|
6
|
+
const remoteDeviceRunSession_1 = require("./remoteDeviceRunSession");
|
|
7
|
+
/** Release the pre-boot egress resources even if controller startup or teardown fails. */
|
|
8
|
+
function withLocalEgressSession(fn) {
|
|
9
|
+
return async (context, args) => {
|
|
10
|
+
try {
|
|
11
|
+
args.signal?.throwIfAborted();
|
|
12
|
+
await fn(context, args);
|
|
13
|
+
}
|
|
14
|
+
finally {
|
|
15
|
+
await (0, localEgress_1.stopLocalEgressResourcesAsync)(context.logger);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** All simulator controllers publish the same egress handoff and run the same monitor. */
|
|
20
|
+
async function uploadRemoteSessionConfigWithLocalEgressAsync({ env, signal, ...options }) {
|
|
21
|
+
// Written by start_local_egress before simulator boot; absent for ordinary sessions.
|
|
22
|
+
const localEgress = await (0, localEgress_1.readLocalEgressHandoffAsync)();
|
|
23
|
+
signal?.throwIfAborted();
|
|
24
|
+
await (0, remoteDeviceRunSession_1.uploadRemoteSessionConfigAsync)({
|
|
25
|
+
...options,
|
|
26
|
+
remoteConfig: { ...options.remoteConfig, ...(0, localEgress_1.buildEgressRemoteConfigFields)(localEgress) },
|
|
27
|
+
});
|
|
28
|
+
if (localEgress && !signal?.aborted) {
|
|
29
|
+
options.logger.info('Local egress: waiting for the EAS CLI egress client to connect. Proxied HTTP(S) ' +
|
|
30
|
+
'requests are unavailable until it does.');
|
|
31
|
+
// The monitor also observes the registered resources' lifetime signal, which
|
|
32
|
+
// withLocalEgressSession aborts on success, failure, or cancellation.
|
|
33
|
+
void (0, localEgress_1.monitorLocalEgressAsync)({
|
|
34
|
+
port: localEgress.port,
|
|
35
|
+
env,
|
|
36
|
+
logger: options.logger,
|
|
37
|
+
signal: signal ?? new AbortController().signal,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -33,22 +33,7 @@ export declare function waitForDeviceRunSessionStoppedAsync({ ctx, deviceRunSess
|
|
|
33
33
|
signal?: AbortSignal;
|
|
34
34
|
idleTimeout?: DeviceRunSessionIdleTimeout;
|
|
35
35
|
}): Promise<void>;
|
|
36
|
-
|
|
37
|
-
* Install ffmpeg when the runtime does not already provide it, so argent's
|
|
38
|
-
* `screen-recording-start` tool can encode a video. The worker images do not
|
|
39
|
-
* ship ffmpeg yet, so without this the tool fails with "`ffmpeg` was not found
|
|
40
|
-
* on PATH" — on macOS (iOS simulators) and Linux (Android emulators) alike.
|
|
41
|
-
*
|
|
42
|
-
* Best-effort by design: screen recording is one optional argent tool, so a
|
|
43
|
-
* failure here is logged and the session continues without it.
|
|
44
|
-
*
|
|
45
|
-
* The whole body is wrapped because the caller runs this in the background with
|
|
46
|
-
* `void`. There is no unhandledRejection handler in the worker, so a rejection
|
|
47
|
-
* escaping here would crash the process and take the live session with it.
|
|
48
|
-
* `spawn` is not an async function and can throw synchronously, which
|
|
49
|
-
* `asyncResult` cannot catch — it only wraps an already-created promise.
|
|
50
|
-
*/
|
|
51
|
-
export declare function ensureFfmpegInstalledAsync({ runtimePlatform, env, logger, }: {
|
|
36
|
+
export declare function ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, }: {
|
|
52
37
|
runtimePlatform: BuildRuntimePlatform;
|
|
53
38
|
env: BuildStepEnv;
|
|
54
39
|
logger: bunyan;
|
|
@@ -104,17 +89,21 @@ export declare function createExpoDeviceHubArgs({ port, turnArgs, packageVersion
|
|
|
104
89
|
turnArgs?: string[];
|
|
105
90
|
packageVersion?: string;
|
|
106
91
|
}): string[];
|
|
92
|
+
export declare function findAvailablePortAsync(): Promise<number>;
|
|
107
93
|
export declare function waitForWebPreviewReadyAsync({ previewServer, serverName, port, timeoutMs, }: {
|
|
108
94
|
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
|
109
95
|
serverName: string;
|
|
110
96
|
port: number;
|
|
111
97
|
timeoutMs: number;
|
|
112
|
-
}): Promise<
|
|
98
|
+
}): Promise<string>;
|
|
113
99
|
export type DeviceWebPreviewHandle = {
|
|
114
100
|
previewUrl: string;
|
|
101
|
+
/** Session token gating the preview. Only serve-sim mints one. */
|
|
102
|
+
previewToken?: string;
|
|
115
103
|
stopAsync: () => Promise<void>;
|
|
116
104
|
};
|
|
117
105
|
export type ServeSimPreviewHandle = DeviceWebPreviewHandle;
|
|
106
|
+
export declare function readServeSimPreviewTokenAsync(udid: string, stateDir?: string): Promise<string | undefined>;
|
|
118
107
|
export declare function startServeSimWithTunnelAsync(ctx: CustomBuildContext, { baseDomain, env, logger, timeoutMs, packageVersion, }: {
|
|
119
108
|
baseDomain: string;
|
|
120
109
|
env: BuildStepEnv;
|
|
@@ -122,7 +111,8 @@ export declare function startServeSimWithTunnelAsync(ctx: CustomBuildContext, {
|
|
|
122
111
|
timeoutMs: number;
|
|
123
112
|
packageVersion?: string;
|
|
124
113
|
}): Promise<ServeSimPreviewHandle>;
|
|
125
|
-
export declare function startExpoDeviceHubWithTunnelAsync(ctx: CustomBuildContext, { baseDomain, env, logger, timeoutMs, packageVersion, }: {
|
|
114
|
+
export declare function startExpoDeviceHubWithTunnelAsync(ctx: CustomBuildContext, { runtimePlatform, baseDomain, env, logger, timeoutMs, packageVersion, }: {
|
|
115
|
+
runtimePlatform: BuildRuntimePlatform;
|
|
126
116
|
baseDomain: string;
|
|
127
117
|
env: BuildStepEnv;
|
|
128
118
|
logger: bunyan;
|