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