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