1import fs from 'fs';
2import path from 'path';
3import { Stream } from 'stream';
4import temporary from 'tempy';
5import { promisify } from 'util';
6
7import { createCachedFetch, fetchAsync } from '../api/rest/client';
8import { FetchLike, ProgressCallback } from '../api/rest/client.types';
9import { wrapFetchWithProgress } from '../api/rest/wrapFetchWithProgress';
10import { ensureDirectoryAsync } from './dir';
11import { CommandError } from './errors';
12import { extractAsync } from './tar';
13
14const debug = require('debug')('expo:utils:downloadAppAsync') as typeof console.log;
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 = fetchAsync;
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  debug(`Downloading ${url} to ${outputPath}`);
43  const res = await wrapFetchWithProgress(fetchInstance)(url, {
44    timeout: TIMER_DURATION,
45    onProgress,
46  });
47  if (!res.ok) {
48    throw new CommandError(
49      'FILE_DOWNLOAD',
50      `Unexpected response: ${res.statusText}. From url: ${url}`
51    );
52  }
53  return pipeline(res.body, fs.createWriteStream(outputPath));
54}
55
56export async function downloadAppAsync({
57  url,
58  outputPath,
59  extract = false,
60  cacheDirectory,
61  onProgress,
62}: {
63  url: string;
64  outputPath: string;
65  extract?: boolean;
66  cacheDirectory?: string;
67  onProgress?: ProgressCallback;
68}): Promise<void> {
69  if (extract) {
70    // For iOS we download the ipa to a file then pass that file into the extractor.
71    // In the future we should just pipe the `res.body -> tar.extract` directly.
72    // I tried this and it created some weird errors where observing the data stream
73    // would corrupt the file causing tar to fail with `TAR_BAD_ARCHIVE`.
74    const tmpPath = temporary.file({ name: path.basename(outputPath) });
75    await downloadAsync({ url, outputPath: tmpPath, cacheDirectory, onProgress });
76    debug(`Extracting ${tmpPath} to ${outputPath}`);
77    await ensureDirectoryAsync(outputPath);
78    await extractAsync(tmpPath, outputPath);
79  } else {
80    await ensureDirectoryAsync(path.dirname(outputPath));
81    await downloadAsync({ url, outputPath, cacheDirectory, onProgress });
82  }
83}
84