From c46a84174a4fc522ef9e7838a2f56538d79cfe5e Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Mon, 13 Feb 2023 08:14:52 -0800 Subject: [PATCH] Resolve files to real paths when `unstable_enableSymlinks` Summary: This makes a minimally invasive change to `metro-resolver` to run source file and asset resolutions through a new `realPath` method of `FileSystem`. Custom `resolveRequest` implementations are not affected - for the time being they're expected to take responsibility for returning real paths on their own, but they may now use `content.unstable_realPath` to do so. This is not intended as a final design, but the resolver changes will dovetail into planned DependencyGraph work where we'll need to track non-existent resolution candidates (by their "candidate path", but ultimately resolve to real paths). Changelog: [Experimental] Implement `resolver.unstable_enableSymlinks` Differential Revision: D42847996 fbshipit-source-id: f813e54ff79966d1737fec059cfaf65878877da7 --- packages/metro-file-map/src/HasteFS.js | 4 ++ packages/metro-file-map/src/flow-types.js | 2 + packages/metro-file-map/src/lib/TreeFS.js | 13 +++++ .../src/lib/__tests__/TreeFS-test.js | 22 +++++++++ packages/metro-resolver/package.json | 3 ++ .../src/__tests__/package-exports-test.js | 1 + .../src/__tests__/symlinks-test.js | 48 +++++++++++++++++++ .../metro-resolver/src/__tests__/utils.js | 35 ++++++++++++-- packages/metro-resolver/src/index.js | 1 + packages/metro-resolver/src/resolve.js | 7 ++- packages/metro-resolver/src/types.js | 2 + .../metro/src/node-haste/DependencyGraph.js | 18 ++++++- .../DependencyGraph/ModuleResolution.js | 4 ++ 13 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 packages/metro-resolver/src/__tests__/symlinks-test.js diff --git a/packages/metro-file-map/src/HasteFS.js b/packages/metro-file-map/src/HasteFS.js index 3acd22f27f..fff68d0453 100644 --- a/packages/metro-file-map/src/HasteFS.js +++ b/packages/metro-file-map/src/HasteFS.js @@ -203,4 +203,8 @@ export default class HasteFS implements MutableFileSystem { } return this.#files.get(this._normalizePath(filePath)); } + + realPath(filePath: Path): Path { + throw new Error('HasteFS.realPath() is not implemented.'); + } } diff --git a/packages/metro-file-map/src/flow-types.js b/packages/metro-file-map/src/flow-types.js index 3d87c2d272..e94245cec3 100644 --- a/packages/metro-file-map/src/flow-types.js +++ b/packages/metro-file-map/src/flow-types.js @@ -188,6 +188,8 @@ export interface FileSystem { filter: RegExp, }>, ): Array; + + realPath(file: Path): ?string; } export type Glob = string; diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js index e5b442e782..112b08f4c3 100644 --- a/packages/metro-file-map/src/lib/TreeFS.js +++ b/packages/metro-file-map/src/lib/TreeFS.js @@ -171,6 +171,19 @@ export default class TreeFS implements MutableFileSystem { return files; } + realPath(filePath: Path): ?string { + const normalPath = this._normalizePath(filePath); + const metadata = this.#files.get(normalPath); + if (metadata && metadata[H.SYMLINK] === 0) { + return fastPath.resolve(this.#rootDir, normalPath); + } + const result = this._lookupByNormalPath(normalPath, {follow: true}); + if (!result || result[1] instanceof Map) { + return null; + } + return fastPath.resolve(this.#rootDir, result[0]); + } + addOrModify(filePath: Path, metadata: FileMetaData): void { const normalPath = this._normalizePath(filePath); this.bulkAddOrModify(new Map([[normalPath, metadata]])); diff --git a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js index 448a787ad9..c42463dd2a 100644 --- a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js +++ b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js @@ -86,6 +86,28 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { }); }); + describe('realPath', () => { + test.each([ + [p('/project/foo/link-to-another.js'), p('/project/foo/another.js')], + [p('/project/foo/link-to-bar.js'), p('/project/bar.js')], + [p('link-to-foo/link-to-another.js'), p('/project/foo/another.js')], + [p('/project/root/outside/external.js'), p('/outside/external.js')], + [p('/outside/../project/bar.js'), p('/project/bar.js')], + ])('%s -> %s', (givenPath, expectedRealPath) => + expect(tfs.realPath(givenPath)).toEqual(expectedRealPath), + ); + + test.each([ + [p('/project/foo')], + [p('/project/bar.js/bad-parent')], + [p('/project/root/outside')], + [p('/project/link-to-nowhere')], + [p('/project/not/exists')], + ])('returns null for directories or broken paths: %s', givenPath => + expect(tfs.realPath(givenPath)).toEqual(null), + ); + }); + describe('matchFilesWithContext', () => { test('non-recursive, skipping deep paths', () => { expect( diff --git a/packages/metro-resolver/package.json b/packages/metro-resolver/package.json index 4ec8f7ce5f..6fa9bb5afc 100644 --- a/packages/metro-resolver/package.json +++ b/packages/metro-resolver/package.json @@ -15,6 +15,9 @@ "absolute-path": "^0.0.0", "invariant": "^2.2.4" }, + "devDependencies": { + "invariant": "^2.2.4" + }, "license": "MIT", "engines": { "node": ">=14.17.0" diff --git a/packages/metro-resolver/src/__tests__/package-exports-test.js b/packages/metro-resolver/src/__tests__/package-exports-test.js index 06ae80c209..28db1cf94f 100644 --- a/packages/metro-resolver/src/__tests__/package-exports-test.js +++ b/packages/metro-resolver/src/__tests__/package-exports-test.js @@ -26,6 +26,7 @@ describe('with package exports resolution disabled', () => { }), originModulePath: '/root/src/main.js', unstable_enablePackageExports: false, + unstable_realPath: null, }; expect(Resolver.resolve(context, 'test-pkg', null)).toEqual({ diff --git a/packages/metro-resolver/src/__tests__/symlinks-test.js b/packages/metro-resolver/src/__tests__/symlinks-test.js new file mode 100644 index 0000000000..9096e3436a --- /dev/null +++ b/packages/metro-resolver/src/__tests__/symlinks-test.js @@ -0,0 +1,48 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +'use strict'; + +import type {ResolutionContext} from '../index'; + +const FailedToResolvePathError = require('../errors/FailedToResolvePathError'); +const Resolver = require('../index'); +import {createResolutionContext} from './utils'; + +const fileMap = { + '/root/project/foo.js': '', + '/root/project/baz/index.js': '', + '/root/project/baz.js': {realPath: null}, + '/root/project/link-to-foo.js': {realPath: '/root/project/foo.js'}, +}; + +const CONTEXT: ResolutionContext = { + ...createResolutionContext(fileMap, {enableSymlinks: true}), + originModulePath: '/root/project/foo.js', +}; + +it('resolves to a real path when the chosen candidate is a symlink', () => { + expect(Resolver.resolve(CONTEXT, './link-to-foo', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/project/foo.js', + }); +}); + +it('does not resolve to a broken symlink', () => { + // ./baz.js is a broken link, baz/index.js is real + expect(() => Resolver.resolve(CONTEXT, './baz.js', null)).toThrow( + FailedToResolvePathError, + ); + expect(Resolver.resolve(CONTEXT, './baz', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/project/baz/index.js', + }); +}); diff --git a/packages/metro-resolver/src/__tests__/utils.js b/packages/metro-resolver/src/__tests__/utils.js index fe4425af70..9cd6e8ed51 100644 --- a/packages/metro-resolver/src/__tests__/utils.js +++ b/packages/metro-resolver/src/__tests__/utils.js @@ -10,12 +10,15 @@ */ import type {ResolutionContext} from '../index'; +import invariant from 'invariant'; /** * Data structure approximating a file tree. Should be populated with complete * paths mapping to file contents. */ -type MockFileMap = {[path: string]: string}; +type MockFileMap = $ReadOnly<{ + [path: string]: ?(string | $ReadOnly<{realPath: ?string}>), +}>; /** * Create a new partial `ResolutionContext` object given a mock file structure. @@ -24,15 +27,21 @@ type MockFileMap = {[path: string]: string}; */ export function createResolutionContext( fileMap: MockFileMap, + {enableSymlinks}: $ReadOnly<{enableSymlinks?: boolean}> = {}, ): $Diff { return { allowHaste: true, customResolverOptions: {}, disableHierarchicalLookup: false, - doesFileExist: (filePath: string) => filePath in fileMap, extraNodeModules: null, - getPackage: (packageJsonPath: string) => - JSON.parse(fileMap[packageJsonPath]), + getPackage: (packageJsonPath: string) => { + invariant( + typeof fileMap[packageJsonPath] === 'string', + '%s is not a regular file', + packageJsonPath, + ); + return JSON.parse(fileMap[packageJsonPath]); + }, getPackageForModule: () => null, isAssetFile: () => false, mainFields: ['browser', 'main'], @@ -46,5 +55,23 @@ export function createResolutionContext( unstable_conditionNames: [], unstable_conditionsByPlatform: {}, unstable_enablePackageExports: false, + ...(enableSymlinks === true + ? { + doesFileExist: (filePath: string) => + // Should return false unless realpath(filePath) exists. We mock shallow + // dereferencing. + fileMap[filePath] != null && + (typeof fileMap[filePath] === 'string' || + typeof fileMap[filePath].realPath === 'string'), + unstable_realPath: filePath => + typeof fileMap[filePath] === 'string' + ? filePath + : fileMap[filePath]?.realPath, + } + : { + doesFileExist: (filePath: string) => + typeof fileMap[filePath] === 'string', + unstable_realPath: null, + }), }; } diff --git a/packages/metro-resolver/src/index.js b/packages/metro-resolver/src/index.js index 2d6e2f9762..7dd820d280 100644 --- a/packages/metro-resolver/src/index.js +++ b/packages/metro-resolver/src/index.js @@ -21,6 +21,7 @@ export type { FileCandidates, FileResolution, IsAssetFile, + RealPath, ResolutionContext, Resolution, ResolveAsset, diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index b6d5c41902..3ee93ddd12 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -460,7 +460,12 @@ function resolveSourceFileForExt( if (redirectedPath === false) { return {type: 'empty'}; } - if (context.doesFileExist(redirectedPath)) { + if (context.unstable_realPath) { + const maybeRealPath = context.unstable_realPath(redirectedPath); + if (maybeRealPath) { + return maybeRealPath; + } + } else if (context.doesFileExist(redirectedPath)) { return redirectedPath; } context.candidateExts.push(extension); diff --git a/packages/metro-resolver/src/types.js b/packages/metro-resolver/src/types.js index e14727b2d8..925b085fd8 100644 --- a/packages/metro-resolver/src/types.js +++ b/packages/metro-resolver/src/types.js @@ -65,6 +65,7 @@ export type PackageInfo = $ReadOnly<{ */ export type DoesFileExist = (filePath: string) => boolean; export type IsAssetFile = (fileName: string) => boolean; +export type RealPath = (path: string) => ?string; /** * Given a directory path and the base asset name, return a list of all the @@ -135,6 +136,7 @@ export type ResolutionContext = $ReadOnly<{ [platform: string]: $ReadOnlyArray, }>, unstable_enablePackageExports: boolean, + unstable_realPath?: ?RealPath, }>; export type CustomResolutionContext = $ReadOnly<{ diff --git a/packages/metro/src/node-haste/DependencyGraph.js b/packages/metro/src/node-haste/DependencyGraph.js index b763bcc629..bbf39df940 100644 --- a/packages/metro/src/node-haste/DependencyGraph.js +++ b/packages/metro/src/node-haste/DependencyGraph.js @@ -199,12 +199,23 @@ class DependencyGraph extends EventEmitter { projectRoot: this._config.projectRoot, resolveAsset: (dirPath: string, assetName: string, extension: string) => { const basePath = dirPath + path.sep + assetName; - const assets = [ + let assets = [ basePath + extension, ...this._config.resolver.assetResolutions.map( resolution => basePath + '@' + resolution + 'x' + extension, ), - ].filter(candidate => this._fileSystem.exists(candidate)); + ]; + + if (this._config.resolver.unstable_enableSymlinks) { + assets = assets + .map(candidate => this._fileSystem.realPath(candidate)) + .filter(Boolean); + } else { + assets = assets.filter(candidate => + this._fileSystem.exists(candidate), + ); + } + return assets.length ? assets : null; }, resolveRequest: this._config.resolver.resolveRequest, @@ -214,6 +225,9 @@ class DependencyGraph extends EventEmitter { this._config.resolver.unstable_conditionsByPlatform, unstable_enablePackageExports: this._config.resolver.unstable_enablePackageExports, + unstable_realPath: this._config.resolver.unstable_enableSymlinks + ? path => this._fileSystem.realPath(path) + : null, }); } diff --git a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js index 2a2e86a897..3394d1621f 100644 --- a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js +++ b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js @@ -16,6 +16,7 @@ import type { DoesFileExist, FileCandidates, IsAssetFile, + RealPath, Resolution, ResolveAsset, } from 'metro-resolver'; @@ -74,6 +75,7 @@ type Options = $ReadOnly<{ [platform: string]: $ReadOnlyArray, }>, unstable_enablePackageExports: boolean, + unstable_realPath: ?RealPath, }>; class ModuleResolver { @@ -136,6 +138,7 @@ class ModuleResolver { unstable_conditionNames, unstable_conditionsByPlatform, unstable_enablePackageExports, + unstable_realPath, } = this._options; try { @@ -155,6 +158,7 @@ class ModuleResolver { unstable_conditionNames, unstable_conditionsByPlatform, unstable_enablePackageExports, + unstable_realPath, customResolverOptions: resolverOptions.customResolverOptions ?? {}, originModulePath: fromModule.path, resolveHasteModule: (name: string) =>