1import { getExpoHomeDirectory } from '@expo/config/build/getUserState'; 2import { JSONValue } from '@expo/json-file'; 3import spawnAsync from '@expo/spawn-async'; 4import assert from 'assert'; 5import fs from 'fs'; 6import fetch from 'node-fetch'; 7import path from 'path'; 8import slugify from 'slugify'; 9import { Stream } from 'stream'; 10import tar from 'tar'; 11import { promisify } from 'util'; 12 13import { FileSystemCache } from '../api/rest/cache/FileSystemCache'; 14import { wrapFetchWithCache } from '../api/rest/cache/wrapFetchWithCache'; 15import * as Log from '../log'; 16import { createEntryResolver, createFileTransform } from './createFileTransform'; 17import { ensureDirectoryAsync } from './dir'; 18import { CommandError } from './errors'; 19 20const cachedFetch = wrapFetchWithCache( 21 fetch, 22 new FileSystemCache({ 23 cacheDirectory: getCacheFilePath(), 24 // Time to live. How long (in ms) responses remain cached before being automatically ejected. If undefined, responses are never automatically ejected from the cache. 25 // ttl: 1000, 26 }) 27); 28 29export function sanitizeNpmPackageName(name: string): string { 30 // https://github.com/npm/validate-npm-package-name/#naming-rules 31 return ( 32 applyKnownNpmPackageNameRules(name) || 33 applyKnownNpmPackageNameRules(slugify(name)) || 34 // If nothing is left use 'app' like we do in Xcode projects. 35 'app' 36 ); 37} 38 39function applyKnownNpmPackageNameRules(name: string): string | null { 40 // https://github.com/npm/validate-npm-package-name/#naming-rules 41 42 // package name cannot start with '.' or '_'. 43 while (/^(\.|_)/.test(name)) { 44 name = name.substring(1); 45 } 46 47 name = name.toLowerCase().replace(/[^a-zA-Z._\-/@]/g, ''); 48 49 return ( 50 name 51 // .replace(/![a-z0-9-._~]+/g, '') 52 // Remove special characters 53 .normalize('NFD') 54 .replace(/[\u0300-\u036f]/g, '') || null 55 ); 56} 57 58export async function npmViewAsync(...props: string[]): Promise<JSONValue> { 59 const cmd = ['view', ...props, '--json']; 60 const results = (await spawnAsync('npm', cmd)).stdout?.trim(); 61 const cmdString = `npm ${cmd.join(' ')}`; 62 Log.debug('Run:', cmdString); 63 if (!results) { 64 return null; 65 } 66 try { 67 return JSON.parse(results); 68 } catch (error: any) { 69 throw new Error( 70 `Could not parse JSON returned from "${cmdString}".\n\n${results}\n\nError: ${error.message}` 71 ); 72 } 73} 74 75/** Given a package name like `expo` or `expo@beta`, return the registry URL if it exists. */ 76export async function getNpmUrlAsync(packageName: string): Promise<string> { 77 const results = await npmViewAsync(packageName, 'dist.tarball'); 78 79 assert(results, `Could not get npm url for package "${packageName}"`); 80 81 // Fully qualified url returns a string. 82 // Example: 83 // npm view expo-template-bare-minimum@sdk-33 dist.tarball --json 84 if (typeof results === 'string') { 85 return results; 86 } 87 88 // When the tag is arbitrary, the tarball url is an array, return the last value as it's the most recent. 89 // Example: 90 // npm view expo-template-bare-minimum@33 dist.tarball --json 91 if (Array.isArray(results)) { 92 return results[results.length - 1] as string; 93 } 94 95 throw new CommandError( 96 'Expected results of `npm view ...` to be an array or string. Instead found: ' + results 97 ); 98} 99 100// @ts-ignore 101const pipeline = promisify(Stream.pipeline); 102 103export async function downloadAndExtractNpmModuleAsync( 104 npmName: string, 105 props: ExtractProps 106): Promise<void> { 107 const url = await getNpmUrlAsync(npmName); 108 109 Log.debug('Fetch from URL:', url); 110 await extractNpmTarballFromUrlAsync(url, props); 111} 112 113export async function extractLocalNpmTarballAsync( 114 tarFilePath: string, 115 props: ExtractProps 116): Promise<void> { 117 const readStream = fs.createReadStream(tarFilePath); 118 await extractNpmTarballAsync(readStream, props); 119} 120 121type ExtractProps = { 122 name: string; 123 cwd: string; 124 strip?: number; 125 fileList?: string[]; 126}; 127 128function getCacheFilePath() { 129 return path.join(getExpoHomeDirectory(), 'template-cache'); 130} 131 132async function createUrlStreamAsync(url: string) { 133 const response = await cachedFetch(url); 134 if (!response.ok) { 135 throw new Error(`Unexpected response: ${response.statusText}. From url: ${url}`); 136 } 137 138 return response.body; 139} 140 141export async function extractNpmTarballFromUrlAsync( 142 url: string, 143 props: ExtractProps 144): Promise<void> { 145 await extractNpmTarballAsync(await createUrlStreamAsync(url), props); 146} 147 148export async function extractNpmTarballAsync( 149 stream: NodeJS.ReadableStream, 150 props: ExtractProps 151): Promise<void> { 152 const { cwd, strip, name, fileList = [] } = props; 153 154 await ensureDirectoryAsync(cwd); 155 156 await pipeline( 157 stream, 158 tar.extract( 159 { 160 cwd, 161 transform: createFileTransform(name), 162 onentry: createEntryResolver(name), 163 strip: strip ?? 1, 164 }, 165 fileList 166 ) 167 ); 168} 169