From f7c7102ca3a97ee63ccf6b1623732c77fd376fd5 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Wed, 15 Feb 2023 04:47:19 -0800 Subject: [PATCH] Resolve files to real paths when `unstable_enableSymlinks` (#925) Summary: Pull Request resolved: https://github.com/facebook/metro/pull/925 This makes a minimally invasive change to `metro-resolver` to run source file and asset resolutions through a new `getRealPath` 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_getRealPath` 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: bf4cd09fcc619292558319e6c2abc2faa697a2a9 --- packages/metro-file-map/src/HasteFS.js | 4 ++ packages/metro-file-map/src/flow-types.js | 1 + 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 | 27 +++++++++-- .../DependencyGraph/ModuleResolution.js | 4 ++ 13 files changed, 159 insertions(+), 9 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..4b6b0dfc3d 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)); } + + getRealPath(filePath: Path): Path { + throw new Error('HasteFS.getRealPath() 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..3b7f42395e 100644 --- a/packages/metro-file-map/src/flow-types.js +++ b/packages/metro-file-map/src/flow-types.js @@ -163,6 +163,7 @@ export interface FileSystem { getAllFiles(): Array; getDependencies(file: Path): ?Array; getModuleName(file: Path): ?string; + getRealPath(file: Path): ?string; getSerializableSnapshot(): FileData; getSha1(file: Path): ?string; diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js index 193aadd4cf..08d2270fe7 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; } + getRealPath(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.node instanceof Map) { + return null; + } + return fastPath.resolve(this.#rootDir, result.normalPath); + } + 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 7492c26df8..d01700f0a6 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('getRealPath', () => { + 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.getRealPath(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.getRealPath(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..15a5b12360 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_getRealPath: 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..d616b5b8be 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_getRealPath: filePath => + typeof fileMap[filePath] === 'string' + ? filePath + : fileMap[filePath]?.realPath, + } + : { + doesFileExist: (filePath: string) => + typeof fileMap[filePath] === 'string', + unstable_getRealPath: null, + }), }; } diff --git a/packages/metro-resolver/src/index.js b/packages/metro-resolver/src/index.js index 2d6e2f9762..5c88aef6b8 100644 --- a/packages/metro-resolver/src/index.js +++ b/packages/metro-resolver/src/index.js @@ -20,6 +20,7 @@ export type { FileAndDirCandidates, FileCandidates, FileResolution, + GetRealPath, IsAssetFile, ResolutionContext, Resolution, diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index b6d5c41902..df4ae435d3 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_getRealPath) { + const maybeRealPath = context.unstable_getRealPath(redirectedPath); + if (maybeRealPath != null) { + 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..4b6e20f3a9 100644 --- a/packages/metro-resolver/src/types.js +++ b/packages/metro-resolver/src/types.js @@ -64,6 +64,7 @@ export type PackageInfo = $ReadOnly<{ * Check existence of a single file. */ export type DoesFileExist = (filePath: string) => boolean; +export type GetRealPath = (path: string) => ?string; export type IsAssetFile = (fileName: string) => boolean; /** @@ -135,6 +136,7 @@ export type ResolutionContext = $ReadOnly<{ [platform: string]: $ReadOnlyArray, }>, unstable_enablePackageExports: boolean, + unstable_getRealPath?: ?GetRealPath, }>; export type CustomResolutionContext = $ReadOnly<{ diff --git a/packages/metro/src/node-haste/DependencyGraph.js b/packages/metro/src/node-haste/DependencyGraph.js index b763bcc629..7f2f8bff14 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.getRealPath(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_getRealPath: this._config.resolver.unstable_enableSymlinks + ? path => this._fileSystem.getRealPath(path) + : null, }); } @@ -236,14 +250,19 @@ class DependencyGraph extends EventEmitter { const containerName = splitIndex !== -1 ? filename.slice(0, splitIndex + 4) : filename; - // TODO Calling realpath allows us to get a hash for a given path even when + // Prior to unstable_enableSymlinks: + // Calling realpath allows us to get a hash for a given path even when // it's a symlink to a file, which prevents Metro from crashing in such a // case. However, it doesn't allow Metro to track changes to the target file // of the symlink. We should fix this by implementing a symlink map into // Metro (or maybe by implementing those "extra transformation sources" we've // been talking about for stuff like CSS or WASM). + // + // This is unnecessary with a symlink-aware fileSystem implementation. + const resolvedPath = this._config.resolver.unstable_enableSymlinks + ? containerName + : fs.realpathSync(containerName); - const resolvedPath = fs.realpathSync(containerName); const sha1 = this._fileSystem.getSha1(resolvedPath); if (!sha1) { diff --git a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js index 2a2e86a897..4e69838588 100644 --- a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js +++ b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js @@ -15,6 +15,7 @@ import type { CustomResolver, DoesFileExist, FileCandidates, + GetRealPath, IsAssetFile, Resolution, ResolveAsset, @@ -74,6 +75,7 @@ type Options = $ReadOnly<{ [platform: string]: $ReadOnlyArray, }>, unstable_enablePackageExports: boolean, + unstable_getRealPath: ?GetRealPath, }>; class ModuleResolver { @@ -136,6 +138,7 @@ class ModuleResolver { unstable_conditionNames, unstable_conditionsByPlatform, unstable_enablePackageExports, + unstable_getRealPath, } = this._options; try { @@ -155,6 +158,7 @@ class ModuleResolver { unstable_conditionNames, unstable_conditionsByPlatform, unstable_enablePackageExports, + unstable_getRealPath, customResolverOptions: resolverOptions.customResolverOptions ?? {}, originModulePath: fromModule.path, resolveHasteModule: (name: string) =>