Skip to content

Commit 1eae2f6

Browse files
authored
perf: download bundletool on demand instead of vendoring it (#6097)
1 parent 2eefffe commit 1eae2f6

8 files changed

Lines changed: 439 additions & 227 deletions

File tree

lib/common/http-client.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as _ from "lodash";
22
import { EOL } from "os";
33
import * as util from "util";
4+
import { pipeline } from "stream/promises";
45
import { Server, IProxySettings, IProxyService } from "./declarations";
56
import { injector } from "./yok";
67
import axios from "axios";
@@ -18,12 +19,12 @@ export class HttpClient implements Server.IHttpClient {
1819
constructor(
1920
private $logger: ILogger,
2021
private $proxyService: IProxyService,
21-
private $staticConfig: Config.IStaticConfig
22+
private $staticConfig: Config.IStaticConfig,
2223
) {}
2324

2425
public async httpRequest(
2526
options: any,
26-
proxySettings?: IProxySettings
27+
proxySettings?: IProxySettings,
2728
): Promise<Server.IResponse> {
2829
try {
2930
const result = await this.httpRequestCore(options, proxySettings);
@@ -43,7 +44,7 @@ export class HttpClient implements Server.IHttpClient {
4344
this.$logger.warn(
4445
"%s Retrying request to %s...",
4546
err.message,
46-
options.url || options
47+
options.url || options,
4748
);
4849
const retryResult = await this.httpRequestCore(options, proxySettings);
4950
return {
@@ -59,7 +60,7 @@ export class HttpClient implements Server.IHttpClient {
5960

6061
private async httpRequestCore(
6162
options: any,
62-
proxySettings?: IProxySettings
63+
proxySettings?: IProxySettings,
6364
): Promise<Server.IResponse> {
6465
if (_.isString(options)) {
6566
options = {
@@ -79,7 +80,7 @@ export class HttpClient implements Server.IHttpClient {
7980
cliProxySettings,
8081
options,
8182
headers,
82-
requestProto
83+
requestProto,
8384
);
8485

8586
if (!headers["User-Agent"]) {
@@ -113,7 +114,11 @@ export class HttpClient implements Server.IHttpClient {
113114
method: options.method,
114115
proxy: false,
115116
httpAgent: agent,
117+
// axios picks the agent by protocol, so an https:// request ignores httpAgent
118+
httpsAgent: agent,
116119
data: options.body,
120+
responseType: options.pipeTo ? "stream" : undefined,
121+
onDownloadProgress: options.onDownloadProgress,
117122
}).catch((err) => {
118123
this.$logger.trace("An error occurred while sending the request:", err);
119124
if (err.response) {
@@ -137,9 +142,18 @@ export class HttpClient implements Server.IHttpClient {
137142
if (result) {
138143
this.$logger.trace(
139144
"httpRequest: Done. code = %d",
140-
result.status.toString()
145+
result.status.toString(),
141146
);
142147

148+
if (options.pipeTo) {
149+
await pipeline(result.data, options.pipeTo);
150+
151+
return {
152+
response: result,
153+
headers: result.headers,
154+
};
155+
}
156+
143157
return {
144158
response: result,
145159
body: JSON.stringify(result.data),
@@ -152,7 +166,7 @@ export class HttpClient implements Server.IHttpClient {
152166
if (statusCode === HttpStatusCodes.PROXY_AUTHENTICATION_REQUIRED) {
153167
const clientNameLowerCase = this.$staticConfig.CLIENT_NAME.toLowerCase();
154168
this.$logger.error(
155-
`You can run ${EOL}\t${clientNameLowerCase} proxy set <url> <username> <password>.${EOL}In order to supply ${clientNameLowerCase} with the credentials needed.`
169+
`You can run ${EOL}\t${clientNameLowerCase} proxy set <url> <username> <password>.${EOL}In order to supply ${clientNameLowerCase} with the credentials needed.`,
156170
);
157171
return "Your proxy requires authentication.";
158172
} else if (statusCode === HttpStatusCodes.PAYMENT_REQUIRED) {
@@ -177,7 +191,7 @@ export class HttpClient implements Server.IHttpClient {
177191
} catch (parsingFailed) {
178192
this.$logger.trace(
179193
"Failed to get error from http request: ",
180-
parsingFailed
194+
parsingFailed,
181195
);
182196
return `The server returned unexpected response: ${body}`;
183197
}
@@ -199,7 +213,7 @@ export class HttpClient implements Server.IHttpClient {
199213
cliProxySettings: IProxySettings,
200214
options: any,
201215
headers: any,
202-
requestProto: string
216+
requestProto: string,
203217
): Promise<void> {
204218
const isLocalRequest =
205219
options.host === "localhost" || options.host === "127.0.0.1";

lib/constants.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,15 @@ export const DEBUGGER_ATTACHED_EVENT_NAME = "debuggerAttached";
217217
export const DEBUGGER_DETACHED_EVENT_NAME = "debuggerDetached";
218218
export const VERSION_STRING = "version";
219219
export const INSPECTOR_CACHE_DIRNAME = "ios-inspector";
220+
export const BUNDLETOOL_CACHE_DIRNAME = "bundletool";
221+
export const BUNDLETOOL_VERSION = "1.18.2";
222+
// sha256 of bundletool-all-<BUNDLETOOL_VERSION>.jar as published on GitHub;
223+
// must be updated together with BUNDLETOOL_VERSION or the download is rejected
224+
export const BUNDLETOOL_SHA256 =
225+
"378b5434cd1378bef6b2bc527b8c7f0ff2584b273830335bce54d6d0813c8584";
226+
export const BUNDLETOOL_RELEASES_URL =
227+
"https://github.com/google/bundletool/releases/download";
228+
export const BUNDLETOOL_PATH_ENV_VAR = "NS_BUNDLETOOL_PATH";
220229
export const POST_INSTALL_COMMAND_NAME = "post-install-cli";
221230
const ANDROID_SIGNING_REQUIRED_MESSAGE =
222231
"you need to specify all --key-store-* options.";

lib/services/android/android-bundle-tool-service.ts

Lines changed: 168 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,61 @@
1-
import { resolve, join } from "path";
1+
import { join } from "path";
22
import * as _ from "lodash";
33
import { hasValidAndroidSigning } from "../../common/helpers";
4-
import { IChildProcess, ISysInfo, IErrors } from "../../common/declarations";
4+
import {
5+
IChildProcess,
6+
IErrors,
7+
IFileSystem,
8+
ISettingsService,
9+
ISysInfo,
10+
Server,
11+
} from "../../common/declarations";
512
import {
613
IAndroidBundleToolService,
714
IBuildApksOptions,
815
IInstallApksOptions,
916
} from "../../definitions/android-bundle-tool-service";
17+
import { ITerminalSpinnerService } from "../../definitions/terminal-spinner-service";
18+
import {
19+
BUNDLETOOL_CACHE_DIRNAME,
20+
BUNDLETOOL_PATH_ENV_VAR,
21+
BUNDLETOOL_RELEASES_URL,
22+
BUNDLETOOL_SHA256,
23+
BUNDLETOOL_VERSION,
24+
} from "../../constants";
1025
import { injector } from "../../common/yok";
1126

1227
export class AndroidBundleToolService implements IAndroidBundleToolService {
28+
// a cold download of ~32MB can outlast the default 10s lock, and a second
29+
// process has to keep waiting rather than pull the same jar again
30+
private static LOCK_OPTIONS: ILockOptions = {
31+
stale: 5 * 60 * 1000,
32+
retriesObj: {
33+
retries: 300,
34+
minTimeout: 200,
35+
maxTimeout: 2000,
36+
factor: 1.5,
37+
},
38+
};
39+
1340
private javaPath: string;
14-
private aabToolPath: string;
41+
private bundleToolPathPromise: Promise<string>;
42+
1543
constructor(
1644
private $childProcess: IChildProcess,
1745
private $sysInfo: ISysInfo,
18-
private $errors: IErrors
19-
) {
20-
this.aabToolPath = resolve(
21-
join(__dirname, "../../../vendor/aab-tool/bundletool.jar")
22-
);
23-
}
46+
private $errors: IErrors,
47+
private $fs: IFileSystem,
48+
private $httpClient: Server.IHttpClient,
49+
private $lockService: ILockService,
50+
private $logger: ILogger,
51+
private $settingsService: ISettingsService,
52+
private $terminalSpinnerService: ITerminalSpinnerService,
53+
) {}
2454

2555
public async buildApks(options: IBuildApksOptions): Promise<void> {
2656
if (!hasValidAndroidSigning(options.signingData)) {
2757
this.$errors.fail(
28-
`Unable to build "apks" without a full signing information.`
58+
`Unable to build "apks" without a full signing information.`,
2959
);
3060
}
3161

@@ -46,7 +76,7 @@ export class AndroidBundleToolService implements IAndroidBundleToolService {
4676
]);
4777
if (aabToolResult.exitCode !== 0 && aabToolResult.stderr) {
4878
this.$errors.fail(
49-
`Unable to build "apks" from the provided "aab". Error: ${aabToolResult.stderr}`
79+
`Unable to build "apks" from the provided "aab". Error: ${aabToolResult.stderr}`,
5080
);
5181
}
5282
}
@@ -61,18 +91,18 @@ export class AndroidBundleToolService implements IAndroidBundleToolService {
6191
]);
6292
if (aabToolResult.exitCode !== 0 && aabToolResult.stderr) {
6393
this.$errors.fail(
64-
`Unable to install "apks" on device "${options.deviceId}". Error: ${aabToolResult.stderr}`
94+
`Unable to install "apks" on device "${options.deviceId}". Error: ${aabToolResult.stderr}`,
6595
);
6696
}
6797
}
6898

6999
private async execBundleTool(args: string[]) {
70100
const javaPath = await this.getJavaPath();
71-
const defaultArgs = ["-jar", this.aabToolPath];
101+
const defaultArgs = ["-jar", await this.getBundleToolPath()];
72102

73103
const result = await this.$childProcess.trySpawnFromCloseEvent(
74104
javaPath,
75-
_.concat(defaultArgs, args)
105+
_.concat(defaultArgs, args),
76106
);
77107

78108
return result;
@@ -85,6 +115,130 @@ export class AndroidBundleToolService implements IAndroidBundleToolService {
85115

86116
return this.javaPath;
87117
}
118+
119+
private getBundleToolPath(): Promise<string> {
120+
this.bundleToolPathPromise =
121+
this.bundleToolPathPromise || this.resolveBundleToolPath();
122+
123+
return this.bundleToolPathPromise;
124+
}
125+
126+
private async resolveBundleToolPath(): Promise<string> {
127+
const customPath = process.env[BUNDLETOOL_PATH_ENV_VAR];
128+
if (customPath) {
129+
if (!this.$fs.exists(customPath)) {
130+
this.$errors.fail(
131+
`${BUNDLETOOL_PATH_ENV_VAR} is set to "${customPath}", but no file exists there.`,
132+
);
133+
}
134+
135+
this.$logger.trace(
136+
`Using bundletool from ${BUNDLETOOL_PATH_ENV_VAR}: ${customPath}`,
137+
);
138+
return customPath;
139+
}
140+
141+
const cacheDir = join(
142+
this.$settingsService.getProfileDir(),
143+
BUNDLETOOL_CACHE_DIRNAME,
144+
);
145+
const jarPath = join(cacheDir, `bundletool-all-${BUNDLETOOL_VERSION}.jar`);
146+
this.$fs.ensureDirectoryExists(cacheDir);
147+
148+
if (await this.isExpectedJar(jarPath)) {
149+
return jarPath;
150+
}
151+
152+
return this.$lockService.executeActionWithLock(
153+
async () => {
154+
// whoever held the lock before us may have just downloaded it
155+
if (await this.isExpectedJar(jarPath)) {
156+
return jarPath;
157+
}
158+
159+
await this.downloadBundleTool(jarPath);
160+
return jarPath;
161+
},
162+
join(cacheDir, `bundletool-${BUNDLETOOL_VERSION}.lock`),
163+
AndroidBundleToolService.LOCK_OPTIONS,
164+
);
165+
}
166+
167+
private async isExpectedJar(jarPath: string): Promise<boolean> {
168+
if (!this.$fs.exists(jarPath)) {
169+
return false;
170+
}
171+
172+
const shasum = await this.$fs.getFileShasum(jarPath, {
173+
algorithm: "sha256",
174+
});
175+
if (shasum === BUNDLETOOL_SHA256) {
176+
return true;
177+
}
178+
179+
this.$logger.warn(
180+
`Cached bundletool at "${jarPath}" does not match the expected checksum and will be downloaded again.`,
181+
);
182+
this.$fs.deleteFile(jarPath);
183+
return false;
184+
}
185+
186+
private async downloadBundleTool(jarPath: string): Promise<void> {
187+
const jarName = `bundletool-all-${BUNDLETOOL_VERSION}.jar`;
188+
const url = `${BUNDLETOOL_RELEASES_URL}/${BUNDLETOOL_VERSION}/${jarName}`;
189+
const tempPath = `${jarPath}.download`;
190+
const spinner = this.$terminalSpinnerService.createSpinner();
191+
192+
spinner.start(`Downloading bundletool ${BUNDLETOOL_VERSION}`);
193+
194+
try {
195+
await this.$httpClient.httpRequest({
196+
url,
197+
method: "GET",
198+
// identity keeps Content-Length equal to the real jar size, so the
199+
// progress readout is not skewed by a re-compressed response
200+
headers: { "Accept-Encoding": "identity" },
201+
pipeTo: this.$fs.createWriteStream(tempPath),
202+
onDownloadProgress: (progress: { loaded: number; total?: number }) => {
203+
spinner.text = `Downloading bundletool ${BUNDLETOOL_VERSION} ${this.formatProgress(
204+
progress,
205+
)}`;
206+
},
207+
});
208+
} catch (err) {
209+
spinner.fail(`Failed to download bundletool ${BUNDLETOOL_VERSION}`);
210+
this.$fs.deleteFile(tempPath);
211+
this.$errors.fail(
212+
`Unable to download bundletool from "${url}". ` +
213+
`Set ${BUNDLETOOL_PATH_ENV_VAR} to the path of a local bundletool jar to skip the download. ` +
214+
`Error: ${err.message}`,
215+
);
216+
}
217+
218+
const shasum = await this.$fs.getFileShasum(tempPath, {
219+
algorithm: "sha256",
220+
});
221+
if (shasum !== BUNDLETOOL_SHA256) {
222+
spinner.fail(`Failed to download bundletool ${BUNDLETOOL_VERSION}`);
223+
this.$fs.deleteFile(tempPath);
224+
this.$errors.fail(
225+
`Checksum mismatch for bundletool downloaded from "${url}". ` +
226+
`Expected ${BUNDLETOOL_SHA256}, got ${shasum}.`,
227+
);
228+
}
229+
230+
// rename is atomic, so a concurrent reader never sees a partial jar
231+
this.$fs.rename(tempPath, jarPath);
232+
spinner.succeed(`Downloaded bundletool ${BUNDLETOOL_VERSION}`);
233+
}
234+
235+
private formatProgress(progress: { loaded: number; total?: number }): string {
236+
const toMb = (bytes: number) => (bytes / 1024 / 1024).toFixed(1);
237+
238+
return progress.total
239+
? `${toMb(progress.loaded)}/${toMb(progress.total)} MB`
240+
: `${toMb(progress.loaded)} MB`;
241+
}
88242
}
89243

90244
injector.register("androidBundleToolService", AndroidBundleToolService);

scripts/copy-assets.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ function copyFile(sourcePath, targetPath) {
4848
const source = fs.statSync(sourcePath);
4949
if (fs.existsSync(targetPath)) {
5050
const target = fs.statSync(targetPath);
51-
// vendor/ alone is ~31MB; re-copying it on every build is pure waste
5251
if (target.mtimeMs >= source.mtimeMs && target.size === source.size) {
5352
skipped++;
5453
return;

0 commit comments

Comments
 (0)