Skip to content

Commit 3b9492f

Browse files
committed
perf: download bundletool on demand instead of vendoring it
The 32MB bundletool jar shipped with every install, but it is only used to convert and install .aab artifacts on a device - a path most users never hit, since building an aab is gradle's job. Resolve it lazily instead: on first use fetch the pinned release into <profileDir>/bundletool/, verify its sha256, and move it into place atomically under a lock so concurrent CLIs neither race nor download it twice. NS_BUNDLETOOL_PATH overrides the whole flow for offline and air-gapped setups. This needs a streaming download, so httpRequest grows a pipeTo option - it previously ran every response through JSON.stringify, which would corrupt a binary payload. The same call now also sets httpsAgent, since axios selects the agent by protocol and https requests were silently bypassing the configured proxy tunnel. Published package drops from 39.9MB to 9.3MB (61.5MB to 29.0MB unpacked).
1 parent f24cc5d commit 3b9492f

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)