Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement resolver.unstable_enableSymlinks #925

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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));
}

getRealPath(filePath: Path): Path {
throw new Error('HasteFS.getRealPath() is not implemented.');
}
}
1 change: 1 addition & 0 deletions packages/metro-file-map/src/flow-types.js
Expand Up @@ -163,6 +163,7 @@ export interface FileSystem {
getAllFiles(): Array<Path>;
getDependencies(file: Path): ?Array<string>;
getModuleName(file: Path): ?string;
getRealPath(file: Path): ?string;
getSerializableSnapshot(): FileData;
getSha1(file: Path): ?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;
}

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]]));
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('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(
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_getRealPath: 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_getRealPath: filePath =>
typeof fileMap[filePath] === 'string'
? filePath
: fileMap[filePath]?.realPath,
}
: {
doesFileExist: (filePath: string) =>
typeof fileMap[filePath] === 'string',
unstable_getRealPath: null,
}),
};
}
1 change: 1 addition & 0 deletions packages/metro-resolver/src/index.js
Expand Up @@ -20,6 +20,7 @@ export type {
FileAndDirCandidates,
FileCandidates,
FileResolution,
GetRealPath,
IsAssetFile,
ResolutionContext,
Resolution,
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_getRealPath) {
const maybeRealPath = context.unstable_getRealPath(redirectedPath);
if (maybeRealPath != null) {
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 @@ -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;

/**
Expand Down Expand Up @@ -135,6 +136,7 @@ export type ResolutionContext = $ReadOnly<{
[platform: string]: $ReadOnlyArray<string>,
}>,
unstable_enablePackageExports: boolean,
unstable_getRealPath?: ?GetRealPath,
}>;

export type CustomResolutionContext = $ReadOnly<{
Expand Down
27 changes: 23 additions & 4 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.getRealPath(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_getRealPath: this._config.resolver.unstable_enableSymlinks
? path => this._fileSystem.getRealPath(path)
: null,
});
}

Expand All @@ -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) {
Expand Down
Expand Up @@ -15,6 +15,7 @@ import type {
CustomResolver,
DoesFileExist,
FileCandidates,
GetRealPath,
IsAssetFile,
Resolution,
ResolveAsset,
Expand Down Expand Up @@ -74,6 +75,7 @@ type Options<TPackage> = $ReadOnly<{
[platform: string]: $ReadOnlyArray<string>,
}>,
unstable_enablePackageExports: boolean,
unstable_getRealPath: ?GetRealPath,
}>;

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

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