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