From d3195fa5282fd2e67d3c362131085f3690b3db38 Mon Sep 17 00:00:00 2001 From: Jean Lauliac Date: Tue, 13 Jun 2017 07:15:49 -0700 Subject: [PATCH] ResolutionRequest: extract module resolution Reviewed By: davidaurelio Differential Revision: D5218015 fbshipit-source-id: 6e34df5913d96a0b518f9403309658ea0b559730 --- .../src/ModuleGraph/node-haste/node-haste.js | 26 +- .../src/node-haste/AssetResolutionCache.js | 2 +- .../src/node-haste/DependencyGraph.js | 44 +- .../DependencyGraph/ModuleResolution.js | 668 ++++++++++++++++++ .../DependencyGraph/ResolutionRequest.js | 577 +-------------- .../__tests__/DependencyGraph-test.js | 6 +- 6 files changed, 735 insertions(+), 588 deletions(-) create mode 100644 packages/metro-bundler/src/node-haste/DependencyGraph/ModuleResolution.js diff --git a/packages/metro-bundler/src/ModuleGraph/node-haste/node-haste.js b/packages/metro-bundler/src/ModuleGraph/node-haste/node-haste.js index 777c1ad6..3ee4a597 100644 --- a/packages/metro-bundler/src/ModuleGraph/node-haste/node-haste.js +++ b/packages/metro-bundler/src/ModuleGraph/node-haste/node-haste.js @@ -32,6 +32,8 @@ const ResolutionRequest = require('../../node-haste/DependencyGraph/ResolutionRe const defaults = require('../../defaults'); +const {ModuleResolver} = require('../../node-haste/DependencyGraph/ModuleResolution'); + import type {Moduleish, Packageish} from '../../node-haste/DependencyGraph/ResolutionRequest'; type ResolveOptions = {| @@ -116,22 +118,28 @@ exports.createResolveFn = function(options: ResolveOptions): ResolveFn { getDirFiles: dirPath => filesByDirNameIndex.getAllFiles(dirPath), platforms, }); + const moduleResolver = new ModuleResolver({ + dirExists: filePath => hasteFS.dirExists(filePath), + doesFileExist: filePath => hasteFS.exists(filePath), + extraNodeModules, + helpers, + moduleCache, + moduleMap: getFakeModuleMap(hasteMap), + preferNativePlatform: true, + resolveAsset: (dirPath, assetName, platform) => + assetResolutionCache.resolve(dirPath, assetName, platform), + sourceExts, + }); + return (id, source, platform, _, callback) => { let resolutionRequest = resolutionRequests[platform]; if (!resolutionRequest) { resolutionRequest = resolutionRequests[platform] = new ResolutionRequest({ - dirExists: filePath => hasteFS.dirExists(filePath), - doesFileExist: filePath => hasteFS.exists(filePath), + moduleResolver, entryPath: '', - extraNodeModules, helpers, - moduleCache, - moduleMap: getFakeModuleMap(hasteMap), platform, - preferNativePlatform: true, - resolveAsset: (dirPath, assetName) => - assetResolutionCache.resolve(dirPath, assetName, platform), - sourceExts, + moduleCache, }); } diff --git a/packages/metro-bundler/src/node-haste/AssetResolutionCache.js b/packages/metro-bundler/src/node-haste/AssetResolutionCache.js index afad46c6..79fa8381 100644 --- a/packages/metro-bundler/src/node-haste/AssetResolutionCache.js +++ b/packages/metro-bundler/src/node-haste/AssetResolutionCache.js @@ -75,7 +75,7 @@ class AssetResolutionCache { resolve( dirPath: string, assetName: string, - platform: ?string, + platform: string | null, ): $ReadOnlyArray { const results = this._assetsByDirPath.get(dirPath); const assets = results.get(assetName); diff --git a/packages/metro-bundler/src/node-haste/DependencyGraph.js b/packages/metro-bundler/src/node-haste/DependencyGraph.js index f3ec3614..6c7a6745 100644 --- a/packages/metro-bundler/src/node-haste/DependencyGraph.js +++ b/packages/metro-bundler/src/node-haste/DependencyGraph.js @@ -33,14 +33,16 @@ const { createActionStartEntry, log, } = require('../Logger'); +const {ModuleResolver} = require('./DependencyGraph/ModuleResolution'); const {EventEmitter} = require('events'); import type {Options as JSTransformerOptions} from '../JSTransformer/worker'; import type {GlobalTransformCache} from '../lib/GlobalTransformCache'; import type {GetTransformCacheKey} from '../lib/TransformCaching'; import type {Reporter} from '../lib/reporting'; -import type {ModuleMap} from './DependencyGraph/ResolutionRequest'; +import type {ModuleMap} from './DependencyGraph/ModuleResolution'; import type {Options as ModuleOptions, TransformCode} from './Module'; +import type Package from './Package'; import type {HasteFS} from './types'; type Options = {| @@ -75,6 +77,7 @@ class DependencyGraph extends EventEmitter { _helpers: DependencyGraphHelpers; _moduleCache: ModuleCache; _moduleMap: ModuleMap; + _moduleResolver: ModuleResolver; _opts: Options; constructor(config: {| @@ -103,6 +106,7 @@ class DependencyGraph extends EventEmitter { this._helpers = new DependencyGraphHelpers(this._opts); this._haste.on('change', this._onHasteChange.bind(this)); this._moduleCache = this._createModuleCache(); + this._createModuleResolver(); } static _createHaste(opts: Options): JestHasteMap { @@ -162,9 +166,30 @@ class DependencyGraph extends EventEmitter { eventsQueue.forEach(({type, filePath}) => this._moduleCache.processFileChange(type, filePath), ); + this._createModuleResolver(); this.emit('change'); } + _createModuleResolver() { + this._moduleResolver = new ModuleResolver({ + dirExists: filePath => { + try { + return fs.lstatSync(filePath).isDirectory(); + } catch (e) {} + return false; + }, + doesFileExist: this._doesFileExist, + extraNodeModules: this._opts.extraNodeModules, + helpers: this._helpers, + moduleCache: this._moduleCache, + moduleMap: this._moduleMap, + preferNativePlatform: this._opts.preferNativePlatform, + resolveAsset: (dirPath, assetName, platform) => + this._assetResolutionCache.resolve(dirPath, assetName, platform), + sourceExts: this._opts.sourceExts, + }); + } + _createModuleCache() { const {_opts} = this; return new ModuleCache( @@ -226,25 +251,12 @@ class DependencyGraph extends EventEmitter { }): Promise> { platform = this._getRequestPlatform(entryPath, platform); const absPath = this._getAbsolutePath(entryPath); - const dirExists = filePath => { - try { - return fs.lstatSync(filePath).isDirectory(); - } catch (e) {} - return false; - }; const req = new ResolutionRequest({ - dirExists, - doesFileExist: this._doesFileExist, + moduleResolver: this._moduleResolver, entryPath: absPath, - extraNodeModules: this._opts.extraNodeModules, helpers: this._helpers, + platform: platform != null ? platform : null, moduleCache: this._moduleCache, - moduleMap: this._moduleMap, - platform, - preferNativePlatform: this._opts.preferNativePlatform, - resolveAsset: (dirPath, assetName) => - this._assetResolutionCache.resolve(dirPath, assetName, platform), - sourceExts: this._opts.sourceExts, }); const response = new ResolutionResponse(options); diff --git a/packages/metro-bundler/src/node-haste/DependencyGraph/ModuleResolution.js b/packages/metro-bundler/src/node-haste/DependencyGraph/ModuleResolution.js new file mode 100644 index 00000000..6cd225ac --- /dev/null +++ b/packages/metro-bundler/src/node-haste/DependencyGraph/ModuleResolution.js @@ -0,0 +1,668 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @flow + * @format + */ + +'use strict'; + +const FileNameResolver = require('./FileNameResolver'); + +const invariant = require('fbjs/lib/invariant'); +const isAbsolutePath = require('absolute-path'); +const path = require('path'); +const util = require('util'); + +import type DependencyGraphHelpers from './DependencyGraphHelpers'; + +export type DirExistsFn = (filePath: string) => boolean; + +/** + * `jest-haste-map`'s interface for ModuleMap. + */ +export type ModuleMap = { + getModule( + name: string, + platform: string | null, + supportsNativePlatform: boolean, + ): ?string, + getPackage( + name: string, + platform: string | null, + supportsNativePlatform: boolean, + ): ?string, +}; + +export type Packageish = { + redirectRequire(toModuleName: string): string | false, + getMain(): string, + +root: string, +}; + +export type Moduleish = { + +path: string, + getPackage(): ?Packageish, +}; + +export type ModuleishCache = { + getPackage( + name: string, + platform?: string, + supportsNativePlatform?: boolean, + ): TPackage, + getModule(path: string): TModule, + getAssetModule(path: string): TModule, +}; + +type Options = {| + +dirExists: DirExistsFn, + +doesFileExist: (filePath: string) => boolean, + +extraNodeModules: ?Object, + +helpers: DependencyGraphHelpers, + +moduleCache: ModuleishCache, + +preferNativePlatform: boolean, + +moduleMap: ModuleMap, + +resolveAsset: ( + dirPath: string, + assetName: string, + platform: string | null, + ) => $ReadOnlyArray, + +sourceExts: Array, +|}; + +/** + * This is a way to describe what files we tried to look for when resolving + * a module name as file. This is mainly used for error reporting, so that + * we can explain why we cannot resolve a module. + */ +type FileCandidates = + // We only tried to resolve a specific asset. + | {|+type: 'asset', +name: string|} + // We attempted to resolve a name as being a source file (ex. JavaScript, + // JSON...), in which case there can be several variants we tried, for + // example `foo.ios.js`, `foo.js`, etc. + | {|+type: 'sources', +fileNames: $ReadOnlyArray|}; + +/** + * This is a way to describe what files we tried to look for when resolving + * a module name as directory. + */ +type DirCandidates = + | {|+type: 'package', +dir: DirCandidates, +file: FileCandidates|} + | {|+type: 'index', +file: FileCandidates|}; + +type Resolution = + | {|+type: 'resolved', +module: TModule|} + | {|+type: 'failed', +candidates: TCandidates|}; + +/** + * It may not be a great pattern to leverage exception just for "trying" things + * out, notably for performance. We should consider replacing these functions + * to be nullable-returning, or being better stucture to the algorithm. + */ +function tryResolveSync(action: () => T, secondaryAction: () => T): T { + try { + return action(); + } catch (error) { + if (error.type !== 'UnableToResolveError') { + throw error; + } + return secondaryAction(); + } +} + +class ModuleResolver { + _options: Options; + + static EMPTY_MODULE: string = require.resolve('./assets/empty-module.js'); + + constructor(options: Options) { + this._options = options; + } + + resolveHasteDependency( + fromModule: TModule, + toModuleName: string, + platform: string | null, + ): TModule { + toModuleName = normalizePath(toModuleName); + + const pck = fromModule.getPackage(); + let realModuleName; + if (pck) { + /* $FlowFixMe: redirectRequire can actually return `false` for + exclusions*/ + realModuleName = (pck.redirectRequire(toModuleName): string); + } else { + realModuleName = toModuleName; + } + + const modulePath = this._options.moduleMap.getModule( + realModuleName, + platform, + /* supportsNativePlatform */ true, + ); + if (modulePath != null) { + const module = this._options.moduleCache.getModule(modulePath); + /* temporary until we strengthen the typing */ + invariant(module.type === 'Module', 'expected Module type'); + return module; + } + + let packageName = realModuleName; + let packagePath; + while (packageName && packageName !== '.') { + packagePath = this._options.moduleMap.getPackage( + packageName, + platform, + /* supportsNativePlatform */ true, + ); + if (packagePath != null) { + break; + } + packageName = path.dirname(packageName); + } + + if (packagePath != null) { + const package_ = this._options.moduleCache.getPackage(packagePath); + /* temporary until we strengthen the typing */ + invariant(package_.type === 'Package', 'expected Package type'); + + const potentialModulePath = path.join( + package_.root, + path.relative(packageName, realModuleName), + ); + return tryResolveSync( + () => + this._loadAsFileOrThrow( + potentialModulePath, + fromModule, + toModuleName, + platform, + ), + () => + this._loadAsDirOrThrow( + potentialModulePath, + fromModule, + toModuleName, + platform, + ), + ); + } + + throw new UnableToResolveError( + fromModule, + toModuleName, + 'Unable to resolve dependency', + ); + } + + _redirectRequire(fromModule: TModule, modulePath: string): string | false { + const pck = fromModule.getPackage(); + if (pck) { + return pck.redirectRequire(modulePath); + } + return modulePath; + } + + _resolveFileOrDir( + fromModule: TModule, + toModuleName: string, + platform: string | null, + ): TModule { + const potentialModulePath = isAbsolutePath(toModuleName) + ? resolveWindowsPath(toModuleName) + : path.join(path.dirname(fromModule.path), toModuleName); + + const realModuleName = this._redirectRequire( + fromModule, + potentialModulePath, + ); + if (realModuleName === false) { + return this._getEmptyModule(fromModule, toModuleName); + } + + return tryResolveSync( + () => + this._loadAsFileOrThrow( + realModuleName, + fromModule, + toModuleName, + platform, + ), + () => + this._loadAsDirOrThrow( + realModuleName, + fromModule, + toModuleName, + platform, + ), + ); + } + + resolveNodeDependency( + fromModule: TModule, + toModuleName: string, + platform: string | null, + ): TModule { + if (isRelativeImport(toModuleName) || isAbsolutePath(toModuleName)) { + return this._resolveFileOrDir(fromModule, toModuleName, platform); + } + const realModuleName = this._redirectRequire(fromModule, toModuleName); + // exclude + if (realModuleName === false) { + return this._getEmptyModule(fromModule, toModuleName); + } + + if (isRelativeImport(realModuleName) || isAbsolutePath(realModuleName)) { + // derive absolute path /.../node_modules/fromModuleDir/realModuleName + const fromModuleParentIdx = + fromModule.path.lastIndexOf('node_modules' + path.sep) + 13; + const fromModuleDir = fromModule.path.slice( + 0, + fromModule.path.indexOf(path.sep, fromModuleParentIdx), + ); + const absPath = path.join(fromModuleDir, realModuleName); + return this._resolveFileOrDir(fromModule, absPath, platform); + } + + const searchQueue = []; + for ( + let currDir = path.dirname(fromModule.path); + currDir !== '.' && currDir !== path.parse(fromModule.path).root; + currDir = path.dirname(currDir) + ) { + const searchPath = path.join(currDir, 'node_modules'); + searchQueue.push(path.join(searchPath, realModuleName)); + } + + const extraSearchQueue = []; + if (this._options.extraNodeModules) { + const {extraNodeModules} = this._options; + const bits = toModuleName.split(path.sep); + const packageName = bits[0]; + if (extraNodeModules[packageName]) { + bits[0] = extraNodeModules[packageName]; + extraSearchQueue.push(path.join.apply(path, bits)); + } + } + + const fullSearchQueue = searchQueue.concat(extraSearchQueue); + for (let i = 0; i < fullSearchQueue.length; ++i) { + const resolvedModule = this._tryResolveNodeDep( + fullSearchQueue[i], + fromModule, + toModuleName, + platform, + ); + if (resolvedModule != null) { + return resolvedModule; + } + } + + const displaySearchQueue = searchQueue + .filter(dirPath => this._options.dirExists(dirPath)) + .concat(extraSearchQueue); + + const hint = displaySearchQueue.length ? ' or in these directories:' : ''; + throw new UnableToResolveError( + fromModule, + toModuleName, + `Module does not exist in the module map${hint}\n` + + displaySearchQueue + .map(searchPath => ` ${path.dirname(searchPath)}\n`) + .join(', ') + + '\n' + + `This might be related to https://github.com/facebook/react-native/issues/4968\n` + + `To resolve try the following:\n` + + ` 1. Clear watchman watches: \`watchman watch-del-all\`.\n` + + ` 2. Delete the \`node_modules\` folder: \`rm -rf node_modules && npm install\`.\n` + + ' 3. Reset packager cache: `rm -fr $TMPDIR/react-*` or `npm start -- --reset-cache`.', + ); + } + + /** + * This is written as a separate function because "try..catch" blocks cause + * the entire surrounding function to be deoptimized. + */ + _tryResolveNodeDep( + searchPath: string, + fromModule: TModule, + toModuleName: string, + platform: string | null, + ): ?TModule { + try { + return tryResolveSync( + () => + this._loadAsFileOrThrow( + searchPath, + fromModule, + toModuleName, + platform, + ), + () => + this._loadAsDirOrThrow( + searchPath, + fromModule, + toModuleName, + platform, + ), + ); + } catch (error) { + if (error.type !== 'UnableToResolveError') { + throw error; + } + return null; + } + } + + /** + * Eventually we'd like to remove all the exception being throw in the middle + * of the resolution algorithm, instead keeping track of tentatives in a + * specific data structure, and building a proper error at the top-level. + * This function is meant to be a temporary proxy for _loadAsFile until + * the callsites switch to that tracking structure. + */ + _loadAsFileOrThrow( + basePath: string, + fromModule: TModule, + toModule: string, + platform: string | null, + ): TModule { + const dirPath = path.dirname(basePath); + const fileNameHint = path.basename(basePath); + const result = this._loadAsFile(dirPath, fileNameHint, platform); + if (result.type === 'resolved') { + return result.module; + } + if (result.candidates.type === 'asset') { + const msg = + `Directory \`${dirPath}' doesn't contain asset ` + + `\`${result.candidates.name}'`; + throw new UnableToResolveError(fromModule, toModule, msg); + } + invariant(result.candidates.type === 'sources', 'invalid candidate type'); + const msg = + `Could not resolve the base path \`${basePath}' into a module. The ` + + `folder \`${dirPath}' was searched for one of these files: ` + + result.candidates.fileNames.map(filePath => `\`${filePath}'`).join(', ') + + '.'; + throw new UnableToResolveError(fromModule, toModule, msg); + } + + _loadAsFile( + dirPath: string, + fileNameHint: string, + platform: string | null, + ): Resolution { + if (this._options.helpers.isAssetFile(fileNameHint)) { + return this._loadAsAssetFile(dirPath, fileNameHint, platform); + } + const {doesFileExist} = this._options; + const resolver = new FileNameResolver({doesFileExist, dirPath}); + const fileName = this._tryToResolveAllFileNames( + resolver, + fileNameHint, + platform, + ); + if (fileName != null) { + const filePath = path.join(dirPath, fileName); + const module = this._options.moduleCache.getModule(filePath); + return {type: 'resolved', module}; + } + const fileNames = resolver.getTentativeFileNames(); + return {type: 'failed', candidates: {type: 'sources', fileNames}}; + } + + _loadAsAssetFile( + dirPath: string, + fileNameHint: string, + platform: string | null, + ): Resolution { + const {resolveAsset} = this._options; + const assetNames = resolveAsset(dirPath, fileNameHint, platform); + const assetName = getArrayLowestItem(assetNames); + if (assetName != null) { + const assetPath = path.join(dirPath, assetName); + return { + type: 'resolved', + module: this._options.moduleCache.getAssetModule(assetPath), + }; + } + return { + type: 'failed', + candidates: {type: 'asset', name: fileNameHint}, + }; + } + + /** + * A particular 'base path' can resolve to a number of possibilities depending + * on the context. For example `foo/bar` could resolve to `foo/bar.ios.js`, or + * to `foo/bar.js`. If can also resolve to the bare path `foo/bar` itself, as + * supported by Node.js resolution. On the other hand it doesn't support + * `foo/bar.ios`, for historical reasons. + */ + _tryToResolveAllFileNames( + resolver: FileNameResolver, + fileNamePrefix: string, + platform: ?string, + ): ?string { + if (resolver.tryToResolveFileName(fileNamePrefix)) { + return fileNamePrefix; + } + const {sourceExts} = this._options; + for (let i = 0; i < sourceExts.length; i++) { + const fileName = this._tryToResolveFileNamesForExt( + fileNamePrefix, + resolver, + sourceExts[i], + platform, + ); + if (fileName != null) { + return fileName; + } + } + return null; + } + + /** + * For a particular extension, ex. `js`, we want to try a few possibilities, + * such as `foo.ios.js`, `foo.native.js`, and of course `foo.js`. + */ + _tryToResolveFileNamesForExt( + fileNamePrefix: string, + resolver: FileNameResolver, + ext: string, + platform: ?string, + ): ?string { + const {preferNativePlatform} = this._options; + if (platform != null) { + const fileName = `${fileNamePrefix}.${platform}.${ext}`; + if (resolver.tryToResolveFileName(fileName)) { + return fileName; + } + } + if (preferNativePlatform) { + const fileName = `${fileNamePrefix}.native.${ext}`; + if (resolver.tryToResolveFileName(fileName)) { + return fileName; + } + } + const fileName = `${fileNamePrefix}.${ext}`; + return resolver.tryToResolveFileName(fileName) ? fileName : null; + } + + _getEmptyModule(fromModule: TModule, toModuleName: string): TModule { + const {moduleCache} = this._options; + const module = moduleCache.getModule(ModuleResolver.EMPTY_MODULE); + if (module != null) { + return module; + } + throw new UnableToResolveError( + fromModule, + toModuleName, + "could not resolve `${ModuleResolver.EMPTY_MODULE}'", + ); + } + + /** + * Same as `_loadAsDir`, but throws instead of returning candidates in case of + * failure. We want to migrate all the callsites to `_loadAsDir` eventually. + */ + _loadAsDirOrThrow( + potentialDirPath: string, + fromModule: TModule, + toModuleName: string, + platform: string | null, + ): TModule { + const result = this._loadAsDir(potentialDirPath, platform); + if (result.type === 'resolved') { + return result.module; + } + if (result.candidates.type === 'package') { + throw new UnableToResolveError( + fromModule, + toModuleName, + `could not resolve \`${potentialDirPath}' as a folder: it contained ` + + 'a package, but its "main" could not be resolved', + ); + } + invariant(result.candidates.type === 'index', 'invalid candidate type'); + throw new UnableToResolveError( + fromModule, + toModuleName, + `could not resolve \`${potentialDirPath}' as a folder: it did not ` + + 'contain a package, nor an index file', + ); + } + + /** + * Try to resolve a potential path as if it was a directory-based module. + * Either this is a directory that contains a package, or that the directory + * contains an index file. If it fails to resolve these options, it returns + * `null` and fills the array of `candidates` that were tried. + * + * For example we could try to resolve `/foo/bar`, that would eventually + * resolve to `/foo/bar/lib/index.ios.js` if we're on platform iOS and that + * `bar` contains a package which entry point is `./lib/index` (or `./lib`). + */ + _loadAsDir( + potentialDirPath: string, + platform: string | null, + ): Resolution { + const packageJsonPath = path.join(potentialDirPath, 'package.json'); + if (this._options.doesFileExist(packageJsonPath)) { + return this._loadAsPackage(packageJsonPath, platform); + } + const result = this._loadAsFile(potentialDirPath, 'index', platform); + if (result.type === 'resolved') { + return result; + } + return { + type: 'failed', + candidates: {type: 'index', file: result.candidates}, + }; + } + + /** + * Right now we just consider it a failure to resolve if we couldn't find the + * file corresponding to the `main` indicated by a package. Argument can be + * made this should be changed so that failing to find the `main` is not a + * resolution failure, but identified instead as a corrupted or invalid + * package (or that a package only supports a specific platform, etc.) + */ + _loadAsPackage( + packageJsonPath: string, + platform: string | null, + ): Resolution { + const package_ = this._options.moduleCache.getPackage(packageJsonPath); + const mainPrefixPath = package_.getMain(); + const dirPath = path.dirname(mainPrefixPath); + const prefixName = path.basename(mainPrefixPath); + const fileResult = this._loadAsFile(dirPath, prefixName, platform); + if (fileResult.type === 'resolved') { + return fileResult; + } + const dirResult = this._loadAsDir(mainPrefixPath, platform); + if (dirResult.type === 'resolved') { + return dirResult; + } + return { + type: 'failed', + candidates: { + type: 'package', + dir: dirResult.candidates, + file: fileResult.candidates, + }, + }; + } +} + +// HasteFS stores paths with backslashes on Windows, this ensures the path is in +// the proper format. Will also add drive letter if not present so `/root` will +// resolve to `C:\root`. Noop on other platforms. +function resolveWindowsPath(modulePath) { + if (path.sep !== '\\') { + return modulePath; + } + return path.resolve(modulePath); +} + +function isRelativeImport(filePath: string) { + return /^[.][.]?(?:[/]|$)/.test(filePath); +} + +function normalizePath(modulePath) { + if (path.sep === '/') { + modulePath = path.normalize(modulePath); + } else if (path.posix) { + modulePath = path.posix.normalize(modulePath); + } + + return modulePath.replace(/\/$/, ''); +} + +function getArrayLowestItem(a: $ReadOnlyArray): string | void { + if (a.length === 0) { + return undefined; + } + let lowest = a[0]; + for (let i = 1; i < a.length; ++i) { + if (a[i] < lowest) { + lowest = a[i]; + } + } + return lowest; +} + +class UnableToResolveError extends Error { + type: string; + from: string; + to: string; + + constructor(fromModule: TModule, toModule: string, message: string) { + super(); + this.from = fromModule.path; + this.to = toModule; + this.message = util.format( + 'Unable to resolve module `%s` from `%s`: %s', + toModule, + fromModule.path, + message, + ); + this.type = this.name = 'UnableToResolveError'; + } +} + +module.exports = { + UnableToResolveError, + ModuleResolver, + isRelativeImport, + tryResolveSync, +}; diff --git a/packages/metro-bundler/src/node-haste/DependencyGraph/ResolutionRequest.js b/packages/metro-bundler/src/node-haste/DependencyGraph/ResolutionRequest.js index 198c8264..4beeb09d 100644 --- a/packages/metro-bundler/src/node-haste/DependencyGraph/ResolutionRequest.js +++ b/packages/metro-bundler/src/node-haste/DependencyGraph/ResolutionRequest.js @@ -13,15 +13,12 @@ 'use strict'; const AsyncTaskGroup = require('../lib/AsyncTaskGroup'); -const FileNameResolver = require('./FileNameResolver'); const MapWithDefaults = require('../lib/MapWithDefaults'); +const ModuleResolution = require('./ModuleResolution'); const debug = require('debug')('RNP:DependencyGraph'); -const util = require('util'); -const path = require('path'); -const realPath = require('path'); -const invariant = require('fbjs/lib/invariant'); const isAbsolutePath = require('absolute-path'); +const path = require('path'); import type DependencyGraphHelpers from './DependencyGraphHelpers'; import type ResolutionResponse from './ResolutionResponse'; @@ -29,24 +26,9 @@ import type { Options as TransformWorkerOptions, } from '../../JSTransformer/worker'; import type {ReadResult, CachedReadResult} from '../Module'; +import type {ModuleResolver} from './ModuleResolution'; -type DirExistsFn = (filePath: string) => boolean; - -/** - * `jest-haste-map`'s interface for ModuleMap. - */ -export type ModuleMap = { - getModule( - name: string, - platform: ?string, - supportsNativePlatform: boolean, - ): ?string, - getPackage( - name: string, - platform: ?string, - supportsNativePlatform: boolean, - ): ?string, -}; +const {UnableToResolveError, isRelativeImport} = ModuleResolution; export type Packageish = { isHaste(): boolean, @@ -78,83 +60,22 @@ export type ModuleishCache = { }; type Options = {| - +dirExists: DirExistsFn, - +doesFileExist: (filePath: string) => boolean, +entryPath: string, - +extraNodeModules: ?Object, +helpers: DependencyGraphHelpers, +moduleCache: ModuleishCache, - +moduleMap: ModuleMap, - +platform: ?string, - +preferNativePlatform: boolean, - +resolveAsset: (dirPath: string, assetName: string) => $ReadOnlyArray, - +sourceExts: Array, + +moduleResolver: ModuleResolver, + +platform: string | null, |}; -/** - * This is a way to describe what files we tried to look for when resolving - * a module name as file. This is mainly used for error reporting, so that - * we can explain why we cannot resolve a module. - */ -type FileCandidates = - // We only tried to resolve a specific asset. - | {|+type: 'asset', +name: string|} - // We attempted to resolve a name as being a source file (ex. JavaScript, - // JSON...), in which case there can be several variants we tried, for - // example `foo.ios.js`, `foo.js`, etc. - | {|+type: 'sources', +fileNames: $ReadOnlyArray|}; - -/** - * This is a way to describe what files we tried to look for when resolving - * a module name as directory. - */ -type DirCandidates = - | {|+type: 'package', +dir: DirCandidates, +file: FileCandidates|} - | {|+type: 'index', +file: FileCandidates|}; - -type Resolution = - | {|+type: 'resolved', +module: TModule|} - | {|+type: 'failed', +candidates: TCandidates|}; - -/** - * It may not be a great pattern to leverage exception just for "trying" things - * out, notably for performance. We should consider replacing these functions - * to be nullable-returning, or being better stucture to the algorithm. - */ -function tryResolveSync(action: () => T, secondaryAction: () => T): T { - try { - return action(); - } catch (error) { - if (error.type !== 'UnableToResolveError') { - throw error; - } - return secondaryAction(); - } -} - class ResolutionRequest { _immediateResolutionCache: {[key: string]: TModule}; _options: Options; - static EMPTY_MODULE: string = require.resolve('./assets/empty-module.js'); - constructor(options: Options) { this._options = options; this._resetResolutionCache(); } - _tryResolve( - action: () => Promise, - secondaryAction: () => ?Promise, - ): Promise { - return action().catch(error => { - if (error.type !== 'UnableToResolveError') { - throw error; - } - return secondaryAction(); - }); - } - resolveDependency(fromModule: TModule, toModuleName: string): TModule { const resHash = resolutionHash(fromModule.path, toModuleName); @@ -168,18 +89,25 @@ class ResolutionRequest { return result; }; + const resolver = this._options.moduleResolver; + const platform = this._options.platform; + if ( !this._options.helpers.isNodeModulesDir(fromModule.path) && !(isRelativeImport(toModuleName) || isAbsolutePath(toModuleName)) ) { - const result = tryResolveSync( - () => this._resolveHasteDependency(fromModule, toModuleName), - () => this._resolveNodeDependency(fromModule, toModuleName), + const result = ModuleResolution.tryResolveSync( + () => + resolver.resolveHasteDependency(fromModule, toModuleName, platform), + () => + resolver.resolveNodeDependency(fromModule, toModuleName, platform), ); return cacheResult(result); } - return cacheResult(this._resolveNodeDependency(fromModule, toModuleName)); + return cacheResult( + resolver.resolveNodeDependency(fromModule, toModuleName, platform), + ); } resolveModuleDependencies( @@ -414,421 +342,6 @@ class ResolutionRequest { return result; } - _resolveHasteDependency(fromModule: TModule, toModuleName: string): TModule { - toModuleName = normalizePath(toModuleName); - - const pck = fromModule.getPackage(); - let realModuleName; - if (pck) { - /* $FlowFixMe: redirectRequire can actually return `false` for - exclusions*/ - realModuleName = (pck.redirectRequire(toModuleName): string); - } else { - realModuleName = toModuleName; - } - - const modulePath = this._options.moduleMap.getModule( - realModuleName, - this._options.platform, - /* supportsNativePlatform */ true, - ); - if (modulePath != null) { - const module = this._options.moduleCache.getModule(modulePath); - /* temporary until we strengthen the typing */ - invariant(module.type === 'Module', 'expected Module type'); - return module; - } - - let packageName = realModuleName; - let packagePath; - while (packageName && packageName !== '.') { - packagePath = this._options.moduleMap.getPackage( - packageName, - this._options.platform, - /* supportsNativePlatform */ true, - ); - if (packagePath != null) { - break; - } - packageName = path.dirname(packageName); - } - - if (packagePath != null) { - const package_ = this._options.moduleCache.getPackage(packagePath); - /* temporary until we strengthen the typing */ - invariant(package_.type === 'Package', 'expected Package type'); - - const potentialModulePath = path.join( - package_.root, - path.relative(packageName, realModuleName), - ); - return tryResolveSync( - () => - this._loadAsFileOrThrow( - potentialModulePath, - fromModule, - toModuleName, - ), - () => - this._loadAsDirOrThrow(potentialModulePath, fromModule, toModuleName), - ); - } - - throw new UnableToResolveError( - fromModule, - toModuleName, - 'Unable to resolve dependency', - ); - } - - _redirectRequire(fromModule: TModule, modulePath: string): string | false { - const pck = fromModule.getPackage(); - if (pck) { - return pck.redirectRequire(modulePath); - } - return modulePath; - } - - _resolveFileOrDir(fromModule: TModule, toModuleName: string): TModule { - const potentialModulePath = isAbsolutePath(toModuleName) - ? resolveWindowsPath(toModuleName) - : path.join(path.dirname(fromModule.path), toModuleName); - - const realModuleName = this._redirectRequire( - fromModule, - potentialModulePath, - ); - if (realModuleName === false) { - return this._getEmptyModule(fromModule, toModuleName); - } - - return tryResolveSync( - () => this._loadAsFileOrThrow(realModuleName, fromModule, toModuleName), - () => this._loadAsDirOrThrow(realModuleName, fromModule, toModuleName), - ); - } - - _resolveNodeDependency(fromModule: TModule, toModuleName: string): TModule { - if (isRelativeImport(toModuleName) || isAbsolutePath(toModuleName)) { - return this._resolveFileOrDir(fromModule, toModuleName); - } - const realModuleName = this._redirectRequire(fromModule, toModuleName); - // exclude - if (realModuleName === false) { - return this._getEmptyModule(fromModule, toModuleName); - } - - if (isRelativeImport(realModuleName) || isAbsolutePath(realModuleName)) { - // derive absolute path /.../node_modules/fromModuleDir/realModuleName - const fromModuleParentIdx = - fromModule.path.lastIndexOf('node_modules' + path.sep) + 13; - const fromModuleDir = fromModule.path.slice( - 0, - fromModule.path.indexOf(path.sep, fromModuleParentIdx), - ); - const absPath = path.join(fromModuleDir, realModuleName); - return this._resolveFileOrDir(fromModule, absPath); - } - - const searchQueue = []; - for ( - let currDir = path.dirname(fromModule.path); - currDir !== '.' && currDir !== realPath.parse(fromModule.path).root; - currDir = path.dirname(currDir) - ) { - const searchPath = path.join(currDir, 'node_modules'); - searchQueue.push(path.join(searchPath, realModuleName)); - } - - const extraSearchQueue = []; - if (this._options.extraNodeModules) { - const {extraNodeModules} = this._options; - const bits = toModuleName.split(path.sep); - const packageName = bits[0]; - if (extraNodeModules[packageName]) { - bits[0] = extraNodeModules[packageName]; - extraSearchQueue.push(path.join.apply(path, bits)); - } - } - - const fullSearchQueue = searchQueue.concat(extraSearchQueue); - for (let i = 0; i < fullSearchQueue.length; ++i) { - const resolvedModule = this._tryResolveNodeDep( - fullSearchQueue[i], - fromModule, - toModuleName, - ); - if (resolvedModule != null) { - return resolvedModule; - } - } - - const displaySearchQueue = searchQueue - .filter(dirPath => this._options.dirExists(dirPath)) - .concat(extraSearchQueue); - - const hint = displaySearchQueue.length ? ' or in these directories:' : ''; - throw new UnableToResolveError( - fromModule, - toModuleName, - `Module does not exist in the module map${hint}\n` + - displaySearchQueue - .map(searchPath => ` ${path.dirname(searchPath)}\n`) - .join(', ') + - '\n' + - `This might be related to https://github.com/facebook/react-native/issues/4968\n` + - `To resolve try the following:\n` + - ` 1. Clear watchman watches: \`watchman watch-del-all\`.\n` + - ` 2. Delete the \`node_modules\` folder: \`rm -rf node_modules && npm install\`.\n` + - ' 3. Reset packager cache: `rm -fr $TMPDIR/react-*` or `npm start -- --reset-cache`.', - ); - } - - /** - * This is written as a separate function because "try..catch" blocks cause - * the entire surrounding function to be deoptimized. - */ - _tryResolveNodeDep( - searchPath: string, - fromModule: TModule, - toModuleName: string, - ): ?TModule { - try { - return tryResolveSync( - () => this._loadAsFileOrThrow(searchPath, fromModule, toModuleName), - () => this._loadAsDirOrThrow(searchPath, fromModule, toModuleName), - ); - } catch (error) { - if (error.type !== 'UnableToResolveError') { - throw error; - } - return null; - } - } - - /** - * Eventually we'd like to remove all the exception being throw in the middle - * of the resolution algorithm, instead keeping track of tentatives in a - * specific data structure, and building a proper error at the top-level. - * This function is meant to be a temporary proxy for _loadAsFile until - * the callsites switch to that tracking structure. - */ - _loadAsFileOrThrow( - basePath: string, - fromModule: TModule, - toModule: string, - ): TModule { - const dirPath = path.dirname(basePath); - const fileNameHint = path.basename(basePath); - const result = this._loadAsFile(dirPath, fileNameHint); - if (result.type === 'resolved') { - return result.module; - } - if (result.candidates.type === 'asset') { - const msg = - `Directory \`${dirPath}' doesn't contain asset ` + - `\`${result.candidates.name}'`; - throw new UnableToResolveError(fromModule, toModule, msg); - } - invariant(result.candidates.type === 'sources', 'invalid candidate type'); - const msg = - `Could not resolve the base path \`${basePath}' into a module. The ` + - `folder \`${dirPath}' was searched for one of these files: ` + - result.candidates.fileNames.map(filePath => `\`${filePath}'`).join(', ') + - '.'; - throw new UnableToResolveError(fromModule, toModule, msg); - } - - _loadAsFile( - dirPath: string, - fileNameHint: string, - ): Resolution { - if (this._options.helpers.isAssetFile(fileNameHint)) { - return this._loadAsAssetFile(dirPath, fileNameHint); - } - const {doesFileExist} = this._options; - const resolver = new FileNameResolver({doesFileExist, dirPath}); - const fileName = this._tryToResolveAllFileNames(resolver, fileNameHint); - if (fileName != null) { - const filePath = path.join(dirPath, fileName); - const module = this._options.moduleCache.getModule(filePath); - return {type: 'resolved', module}; - } - const fileNames = resolver.getTentativeFileNames(); - return {type: 'failed', candidates: {type: 'sources', fileNames}}; - } - - _loadAsAssetFile( - dirPath: string, - fileNameHint: string, - ): Resolution { - const assetNames = this._options.resolveAsset(dirPath, fileNameHint); - const assetName = getArrayLowestItem(assetNames); - if (assetName != null) { - const assetPath = path.join(dirPath, assetName); - return { - type: 'resolved', - module: this._options.moduleCache.getAssetModule(assetPath), - }; - } - return { - type: 'failed', - candidates: {type: 'asset', name: fileNameHint}, - }; - } - - /** - * A particular 'base path' can resolve to a number of possibilities depending - * on the context. For example `foo/bar` could resolve to `foo/bar.ios.js`, or - * to `foo/bar.js`. If can also resolve to the bare path `foo/bar` itself, as - * supported by Node.js resolution. On the other hand it doesn't support - * `foo/bar.ios`, for historical reasons. - */ - _tryToResolveAllFileNames( - resolver: FileNameResolver, - fileNamePrefix: string, - ): ?string { - if (resolver.tryToResolveFileName(fileNamePrefix)) { - return fileNamePrefix; - } - const {sourceExts} = this._options; - for (let i = 0; i < sourceExts.length; i++) { - const fileName = this._tryToResolveFileNamesForExt( - fileNamePrefix, - resolver, - sourceExts[i], - ); - if (fileName != null) { - return fileName; - } - } - return null; - } - - /** - * For a particular extension, ex. `js`, we want to try a few possibilities, - * such as `foo.ios.js`, `foo.native.js`, and of course `foo.js`. - */ - _tryToResolveFileNamesForExt( - fileNamePrefix: string, - resolver: FileNameResolver, - ext: string, - ): ?string { - const {platform, preferNativePlatform} = this._options; - if (platform != null) { - const fileName = `${fileNamePrefix}.${platform}.${ext}`; - if (resolver.tryToResolveFileName(fileName)) { - return fileName; - } - } - if (preferNativePlatform) { - const fileName = `${fileNamePrefix}.native.${ext}`; - if (resolver.tryToResolveFileName(fileName)) { - return fileName; - } - } - const fileName = `${fileNamePrefix}.${ext}`; - return resolver.tryToResolveFileName(fileName) ? fileName : null; - } - - _getEmptyModule(fromModule: TModule, toModuleName: string): TModule { - const {moduleCache} = this._options; - const module = moduleCache.getModule(ResolutionRequest.EMPTY_MODULE); - if (module != null) { - return module; - } - throw new UnableToResolveError( - fromModule, - toModuleName, - "could not resolve `${ResolutionRequest.EMPTY_MODULE}'", - ); - } - - /** - * Same as `_loadAsDir`, but throws instead of returning candidates in case of - * failure. We want to migrate all the callsites to `_loadAsDir` eventually. - */ - _loadAsDirOrThrow( - potentialDirPath: string, - fromModule: TModule, - toModuleName: string, - ): TModule { - const result = this._loadAsDir(potentialDirPath); - if (result.type === 'resolved') { - return result.module; - } - if (result.candidates.type === 'package') { - throw new UnableToResolveError( - fromModule, - toModuleName, - `could not resolve \`${potentialDirPath}' as a folder: it contained ` + - 'a package, but its "main" could not be resolved', - ); - } - invariant(result.candidates.type === 'index', 'invalid candidate type'); - throw new UnableToResolveError( - fromModule, - toModuleName, - `could not resolve \`${potentialDirPath}' as a folder: it did not ` + - 'contain a package, nor an index file', - ); - } - - /** - * Try to resolve a potential path as if it was a directory-based module. - * Either this is a directory that contains a package, or that the directory - * contains an index file. If it fails to resolve these options, it returns - * `null` and fills the array of `candidates` that were tried. - * - * For example we could try to resolve `/foo/bar`, that would eventually - * resolve to `/foo/bar/lib/index.ios.js` if we're on platform iOS and that - * `bar` contains a package which entry point is `./lib/index` (or `./lib`). - */ - _loadAsDir(potentialDirPath: string): Resolution { - const packageJsonPath = path.join(potentialDirPath, 'package.json'); - if (this._options.doesFileExist(packageJsonPath)) { - return this._loadAsPackage(packageJsonPath); - } - const result = this._loadAsFile(potentialDirPath, 'index'); - if (result.type === 'resolved') { - return result; - } - return { - type: 'failed', - candidates: {type: 'index', file: result.candidates}, - }; - } - - /** - * Right now we just consider it a failure to resolve if we couldn't find the - * file corresponding to the `main` indicated by a package. Argument can be - * made this should be changed so that failing to find the `main` is not a - * resolution failure, but identified instead as a corrupted or invalid - * package (or that a package only supports a specific platform, etc.) - */ - _loadAsPackage(packageJsonPath: string): Resolution { - const package_ = this._options.moduleCache.getPackage(packageJsonPath); - const mainPrefixPath = package_.getMain(); - const dirPath = path.dirname(mainPrefixPath); - const prefixName = path.basename(mainPrefixPath); - const fileResult = this._loadAsFile(dirPath, prefixName); - if (fileResult.type === 'resolved') { - return fileResult; - } - const dirResult = this._loadAsDir(mainPrefixPath); - if (dirResult.type === 'resolved') { - return dirResult; - } - return { - type: 'failed', - candidates: { - type: 'package', - dir: dirResult.candidates, - file: fileResult.candidates, - }, - }; - } - _resetResolutionCache() { this._immediateResolutionCache = Object.create(null); } @@ -838,60 +351,4 @@ function resolutionHash(modulePath, depName) { return `${path.resolve(modulePath)}:${depName}`; } -class UnableToResolveError extends Error { - type: string; - from: string; - to: string; - - constructor(fromModule, toModule, message) { - super(); - this.from = fromModule.path; - this.to = toModule; - this.message = util.format( - 'Unable to resolve module `%s` from `%s`: %s', - toModule, - fromModule.path, - message, - ); - this.type = this.name = 'UnableToResolveError'; - } -} - -function normalizePath(modulePath) { - if (path.sep === '/') { - modulePath = path.normalize(modulePath); - } else if (path.posix) { - modulePath = path.posix.normalize(modulePath); - } - - return modulePath.replace(/\/$/, ''); -} - -// HasteFS stores paths with backslashes on Windows, this ensures the path is in -// the proper format. Will also add drive letter if not present so `/root` will -// resolve to `C:\root`. Noop on other platforms. -function resolveWindowsPath(modulePath) { - if (path.sep !== '\\') { - return modulePath; - } - return path.resolve(modulePath); -} - -function isRelativeImport(filePath) { - return /^[.][.]?(?:[/]|$)/.test(filePath); -} - -function getArrayLowestItem(a: $ReadOnlyArray): string | void { - if (a.length === 0) { - return undefined; - } - let lowest = a[0]; - for (let i = 1; i < a.length; ++i) { - if (a[i] < lowest) { - lowest = a[i]; - } - } - return lowest; -} - module.exports = ResolutionRequest; diff --git a/packages/metro-bundler/src/node-haste/__tests__/DependencyGraph-test.js b/packages/metro-bundler/src/node-haste/__tests__/DependencyGraph-test.js index d92eb553..277e2766 100644 --- a/packages/metro-bundler/src/node-haste/__tests__/DependencyGraph-test.js +++ b/packages/metro-bundler/src/node-haste/__tests__/DependencyGraph-test.js @@ -1942,7 +1942,8 @@ describe('DependencyGraph', function() { it( 'should support browser exclude of a package ("' + fieldName + '")', function() { - ResolutionRequest.EMPTY_MODULE = '/root/emptyModule.js'; + require('../DependencyGraph/ModuleResolution').ModuleResolver.EMPTY_MODULE = + '/root/emptyModule.js'; var root = '/root'; setMockFileSystem({ root: { @@ -2020,7 +2021,8 @@ describe('DependencyGraph', function() { it( 'should support browser exclude of a file ("' + fieldName + '")', function() { - ResolutionRequest.EMPTY_MODULE = '/root/emptyModule.js'; + require('../DependencyGraph/ModuleResolution').ModuleResolver.EMPTY_MODULE = + '/root/emptyModule.js'; var root = '/root'; setMockFileSystem({