1import fs from 'fs';
2import type { AssetData, AssetDataWithoutFiles } from 'metro';
3import path from 'path';
4
5import { Log } from '../log';
6
7export function persistMetroAssetsAsync(
8  assets: readonly AssetData[],
9  {
10    platform,
11    outputDirectory,
12    basePath,
13  }: {
14    platform: string;
15    outputDirectory: string;
16    basePath?: string;
17  }
18) {
19  const files = assets.reduce<Record<string, string>>((acc, asset) => {
20    const validScales = new Set(filterPlatformAssetScales(platform, asset.scales));
21
22    asset.scales.forEach((scale, idx) => {
23      if (!validScales.has(scale)) {
24        return;
25      }
26      const src = asset.files[idx];
27      const dest = path.join(outputDirectory, getAssetLocalPath(asset, { scale, basePath }));
28      acc[src] = dest;
29    });
30    return acc;
31  }, {});
32
33  return copyAll(files);
34}
35
36function copyAll(filesToCopy: Record<string, string>) {
37  const queue = Object.keys(filesToCopy);
38  if (queue.length === 0) {
39    return;
40  }
41
42  Log.log(`Copying ${queue.length} asset files`);
43  return new Promise<void>((resolve, reject) => {
44    const copyNext = (error?: NodeJS.ErrnoException) => {
45      if (error) {
46        return reject(error);
47      }
48      if (queue.length) {
49        // queue.length === 0 is checked in previous branch, so this is string
50        const src = queue.shift() as string;
51        const dest = filesToCopy[src];
52        copy(src, dest, copyNext);
53      } else {
54        Log.log('Persisted assets');
55        resolve();
56      }
57    };
58    copyNext();
59  });
60}
61
62function copy(src: string, dest: string, callback: (error: NodeJS.ErrnoException) => void): void {
63  fs.mkdir(path.dirname(dest), { recursive: true }, (err?) => {
64    if (err) {
65      callback(err);
66      return;
67    }
68    fs.createReadStream(src).pipe(fs.createWriteStream(dest)).on('finish', callback);
69  });
70}
71
72const ALLOWED_SCALES: { [key: string]: number[] } = {
73  ios: [1, 2, 3],
74};
75
76function filterPlatformAssetScales(platform: string, scales: readonly number[]): readonly number[] {
77  const whitelist: number[] = ALLOWED_SCALES[platform];
78  if (!whitelist) {
79    return scales;
80  }
81  const result = scales.filter((scale) => whitelist.includes(scale));
82  if (!result.length && scales.length) {
83    // No matching scale found, but there are some available. Ideally we don't
84    // want to be in this situation and should throw, but for now as a fallback
85    // let's just use the closest larger image
86    const maxScale = whitelist[whitelist.length - 1];
87    for (const scale of scales) {
88      if (scale > maxScale) {
89        result.push(scale);
90        break;
91      }
92    }
93
94    // There is no larger scales available, use the largest we have
95    if (!result.length) {
96      result.push(scales[scales.length - 1]);
97    }
98  }
99  return result;
100}
101
102function getAssetLocalPath(
103  asset: AssetDataWithoutFiles,
104  { basePath, scale }: { basePath?: string; scale: number }
105): string {
106  const suffix = scale === 1 ? '' : `@${scale}x`;
107  const fileName = `${asset.name + suffix}.${asset.type}`;
108
109  const adjustedHttpServerLocation = stripAssetPrefix(asset.httpServerLocation, basePath);
110  return path.join(
111    // Assets can have relative paths outside of the project root.
112    // Replace `../` with `_` to make sure they don't end up outside of
113    // the expected assets directory.
114    adjustedHttpServerLocation.replace(/^\/+/g, '').replace(/\.\.\//g, '_'),
115    fileName
116  );
117}
118
119export function stripAssetPrefix(path: string, basePath?: string) {
120  path = path.replace(/\/assets\?export_path=(.*)/, '$1');
121
122  // TODO: Windows?
123  if (basePath) {
124    return path.replace(/^\/+/g, '').replace(
125      new RegExp(
126        `^${basePath
127          .replace(/^\/+/g, '')
128          .replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
129          .replace(/-/g, '\\x2d')}`,
130        'g'
131      ),
132      ''
133    );
134  }
135  return path;
136}
137