Skip to content

Commit

Permalink
Resolve files to real paths when unstable_enableSymlinks
Browse files Browse the repository at this point in the history
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
  • Loading branch information
robhogan authored and facebook-github-bot committed Feb 13, 2023
1 parent a45c6d8 commit c46a841
Show file tree
Hide file tree
Showing 13 changed files with 153 additions and 7 deletions.
4 changes: 4 additions & 0 deletions packages/metro-file-map/src/HasteFS.js
Expand Up @@ -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.');
}
}
2 changes: 2 additions & 0 deletions packages/metro-file-map/src/flow-types.js
Expand Up @@ -188,6 +188,8 @@ export interface FileSystem {
filter: RegExp,
}>,
): Array<Path>;

realPath(file: Path): ?string;
}

export type Glob = string;
Expand Down
13 changes: 13 additions & 0 deletions packages/metro-file-map/src/lib/TreeFS.js
Expand Up @@ -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]]));
Expand Down
22 changes: 22 additions & 0 deletions packages/metro-file-map/src/lib/__tests__/TreeFS-test.js
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions packages/metro-resolver/package.json
Expand Up @@ -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"
Expand Down
Expand Up @@ -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({
Expand Down
48 changes: 48 additions & 0 deletions 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',
});
});
35 changes: 31 additions & 4 deletions packages/metro-resolver/src/__tests__/utils.js
Expand Up @@ -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.
Expand All @@ -24,15 +27,21 @@ type MockFileMap = {[path: string]: string};
*/
export function createResolutionContext(
fileMap: MockFileMap,
{enableSymlinks}: $ReadOnly<{enableSymlinks?: boolean}> = {},
): $Diff<ResolutionContext, {originModulePath: string}> {
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'],
Expand All @@ -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,
}),
};
}
1 change: 1 addition & 0 deletions packages/metro-resolver/src/index.js
Expand Up @@ -21,6 +21,7 @@ export type {
FileCandidates,
FileResolution,
IsAssetFile,
RealPath,
ResolutionContext,
Resolution,
ResolveAsset,
Expand Down
7 changes: 6 additions & 1 deletion packages/metro-resolver/src/resolve.js
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/metro-resolver/src/types.js
Expand Up @@ -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
Expand Down Expand Up @@ -135,6 +136,7 @@ export type ResolutionContext = $ReadOnly<{
[platform: string]: $ReadOnlyArray<string>,
}>,
unstable_enablePackageExports: boolean,
unstable_realPath?: ?RealPath,
}>;

export type CustomResolutionContext = $ReadOnly<{
Expand Down
18 changes: 16 additions & 2 deletions packages/metro/src/node-haste/DependencyGraph.js
Expand Up @@ -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,
Expand All @@ -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,
});
}

Expand Down
Expand Up @@ -16,6 +16,7 @@ import type {
DoesFileExist,
FileCandidates,
IsAssetFile,
RealPath,
Resolution,
ResolveAsset,
} from 'metro-resolver';
Expand Down Expand Up @@ -74,6 +75,7 @@ type Options<TPackage> = $ReadOnly<{
[platform: string]: $ReadOnlyArray<string>,
}>,
unstable_enablePackageExports: boolean,
unstable_realPath: ?RealPath,
}>;

class ModuleResolver<TPackage: Packageish> {
Expand Down Expand Up @@ -136,6 +138,7 @@ class ModuleResolver<TPackage: Packageish> {
unstable_conditionNames,
unstable_conditionsByPlatform,
unstable_enablePackageExports,
unstable_realPath,
} = this._options;

try {
Expand All @@ -155,6 +158,7 @@ class ModuleResolver<TPackage: Packageish> {
unstable_conditionNames,
unstable_conditionsByPlatform,
unstable_enablePackageExports,
unstable_realPath,
customResolverOptions: resolverOptions.customResolverOptions ?? {},
originModulePath: fromModule.path,
resolveHasteModule: (name: string) =>
Expand Down

0 comments on commit c46a841

Please sign in to comment.