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