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