1import fs from 'fs';
2import fetch from 'node-fetch';
3import path from 'path';
4import { Stream } from 'stream';
5import temporary from 'tempy';
6import { promisify } from 'util';
7
8import { createCachedFetch } from '../api/rest/client';
9import { FetchLike, ProgressCallback } from '../api/rest/client.types';
10import { wrapFetchWithProgress } from '../api/rest/wrapFetchWithProgress';
11import { ensureDirectoryAsync } from './dir';
12import { CommandError } from './errors';
13import { extractAsync } from './tar';
14
15const TIMER_DURATION = 30000;
16
17const pipeline = promisify(Stream.pipeline);
18
19async function downloadAsync({
20  url,
21  outputPath,
22  cacheDirectory,
23  onProgress,
24}: {
25  url: string;
26  outputPath: string;
27  cacheDirectory?: string;
28  onProgress?: ProgressCallback;
29}) {
30  let fetchInstance: FetchLike = fetch;
31  if (cacheDirectory) {
32    // Reconstruct the cached fetch since caching could be disabled.
33    fetchInstance = createCachedFetch({
34      // We'll use a 1 week cache for versions so older values get flushed out eventually.
35      ttl: 1000 * 60 * 60 * 24 * 7,
36      // Users can also nuke their `~/.expo` directory to clear the cache.
37      cacheDirectory,
38    });
39  }
40
41  const res = await wrapFetchWithProgress(fetchInstance)(url, {
42    timeout: TIMER_DURATION,
43    onProgress,
44  });
45  if (!res.ok) {
46    throw new CommandError(
47      'FILE_DOWNLOAD',
48      `Unexpected response: ${res.statusText}. From url: ${url}`
49    );
50  }
51  return pipeline(res.body, fs.createWriteStream(outputPath));
52}
53
54export async function downloadAppAsync({
55  url,
56  outputPath,
57  extract = false,
58  cacheDirectory,
59  onProgress,
60}: {
61  url: string;
62  outputPath: string;
63  extract?: boolean;
64  cacheDirectory?: string;
65  onProgress?: ProgressCallback;
66}): Promise<void> {
67  if (extract) {
68    // For iOS we download the ipa to a file then pass that file into the extractor.
69    // In the future we should just pipe the `res.body -> tar.extract` directly.
70    // I tried this and it created some weird errors where observing the data stream
71    // would corrupt the file causing tar to fail with `TAR_BAD_ARCHIVE`.
72    const tmpPath = temporary.file({ name: path.basename(outputPath) });
73    await downloadAsync({ url, outputPath: tmpPath, cacheDirectory, onProgress });
74    await extractAsync(tmpPath, outputPath);
75  } else {
76    await ensureDirectoryAsync(path.dirname(outputPath));
77    await downloadAsync({ url, outputPath, cacheDirectory, onProgress });
78  }
79}
80