diff --git a/packages/metro-file-map/src/HasteFS.js b/packages/metro-file-map/src/HasteFS.js index d83dd3b2d0..fd70918513 100644 --- a/packages/metro-file-map/src/HasteFS.js +++ b/packages/metro-file-map/src/HasteFS.js @@ -72,6 +72,10 @@ export default class HasteFS implements MutableFileSystem { if (visitResult.dependencies != null) { metadata[H.DEPENDENCIES] = visitResult.dependencies; } + + if (visitResult.symlinkTarget != null) { + metadata[H.SYMLINK] = visitResult.symlinkTarget; + } } getSerializableSnapshot(): FileData { @@ -93,6 +97,16 @@ export default class HasteFS implements MutableFileSystem { return (fileMetadata && fileMetadata[H.SIZE]) ?? null; } + getSymlinkTarget(file: Path): ?string { + const fileMetadata = this._getFileData(file); + if (fileMetadata == null) { + return null; + } + return typeof fileMetadata[H.SYMLINK] === 'string' + ? fileMetadata[H.SYMLINK] + : null; + } + getType(file: Path): ?('f' | 'l') { const fileMetadata = this._getFileData(file); if (fileMetadata == null) { diff --git a/packages/metro-file-map/src/__tests__/index-test.js b/packages/metro-file-map/src/__tests__/index-test.js index f985c1bdb2..68532057b4 100644 --- a/packages/metro-file-map/src/__tests__/index-test.js +++ b/packages/metro-file-map/src/__tests__/index-test.js @@ -39,7 +39,14 @@ jest.mock('../crawlers/watchman', () => jest.fn(options => { const path = require('path'); - const {previousState, ignore, rootDir, roots, computeSha1} = options; + const { + previousState, + ignore, + rootDir, + roots, + computeSha1, + includeSymlinks, + } = options; const list = mockChangedFiles || mockFs; const removedFiles = new Map(); const changedFiles = new Map(); @@ -54,7 +61,18 @@ jest.mock('../crawlers/watchman', () => const relativeFilePath = path.relative(rootDir, file); if (list[file]) { const hash = computeSha1 ? mockHashContents(list[file]) : null; - changedFiles.set(relativeFilePath, ['', 32, 42, 0, [], hash, 0]); + const isSymlink = typeof list[file].link === 'string'; + if (!isSymlink || includeSymlinks) { + changedFiles.set(relativeFilePath, [ + '', + 32, + 42, + 0, + [], + hash, + isSymlink ? 1 : 0, + ]); + } } else { const fileData = previousState.files.get(relativeFilePath); if (fileData) { @@ -86,7 +104,7 @@ jest.mock('../watchers/WatchmanWatcher', () => mockWatcherConstructor); let mockChangedFiles; let mockFs; -jest.mock('graceful-fs', () => ({ +jest.mock('fs', () => ({ existsSync: jest.fn(path => { // A file change can be triggered by writing into the // mockChangedFiles object. @@ -119,6 +137,20 @@ jest.mock('graceful-fs', () => ({ expect(options).toBe(require('v8').serialize ? undefined : 'utf8'); mockFs[path] = data; }), + promises: { + readlink: jest.fn(async path => { + const entry = mockFs[path]; + if (!entry) { + const error = new Error(`Cannot read path '${path}'.`); + error.code = 'ENOENT'; + throw error; + } + if (typeof entry.link !== 'string') { + throw new Error(`Not a symlink: '${path}'.`); + } + return entry.link; + }), + }, })); const object = data => Object.assign(Object.create(null), data); @@ -181,6 +213,9 @@ describe('HasteMap', () => { [path.join('/', 'project', 'video', 'video.mp4')]: Buffer.from([ 0xfa, 0xce, 0xb0, 0x0c, ]).toString(), + [path.join('/', 'project', 'fruits', 'LinkToStrawberry.js')]: { + link: 'Strawberry.js', + }, }); mockClocks = createMap({ fruits: 'c:fake-clock:1', @@ -211,6 +246,7 @@ describe('HasteMap', () => { cacheContent = null; defaultConfig = { + enableSymlinks: false, extensions: ['js', 'json'], hasteImplModulePath: require.resolve('./haste_impl.js'), healthCheck: { @@ -511,126 +547,161 @@ describe('HasteMap', () => { }); describe('builds a haste map on a fresh cache with SHA-1s', () => { - it.each([false, true])('uses watchman: %s', async useWatchman => { - const node = require('../crawlers/node'); - - node.mockImplementation(options => { - // The node crawler returns "null" for the SHA-1. - const changedFiles = createMap({ - [path.join('fruits', 'Banana.js')]: [ - 'Banana', - 32, - 42, - 0, - 'Strawberry', - null, - 0, - ], - [path.join('fruits', 'Pear.js')]: [ - 'Pear', - 32, - 42, - 0, - 'Banana\0Strawberry', - null, - 0, - ], - [path.join('fruits', 'Strawberry.js')]: [ - 'Strawberry', - 32, - 42, - 0, - '', - null, - 0, - ], - [path.join('fruits', '__mocks__', 'Pear.js')]: [ - '', - 32, - 42, - 0, - 'Melon', - null, - 0, - ], - [path.join('vegetables', 'Melon.js')]: [ - 'Melon', - 32, - 42, - 0, - '', - null, - 0, - ], + it.each([ + // `enableSymlinks` is currently not permitted with `useWatchman` + [false, false], + [false, true], + [true, false], + ])( + 'uses watchman: %s, symlinks enabled: %s', + async (useWatchman, enableSymlinks) => { + const node = require('../crawlers/node'); + + node.mockImplementation(options => { + // The node crawler returns "null" for the SHA-1. + const changedFiles = createMap({ + [path.join('fruits', 'Banana.js')]: [ + 'Banana', + 32, + 42, + 0, + 'Strawberry', + null, + 0, + ], + [path.join('fruits', 'Pear.js')]: [ + 'Pear', + 32, + 42, + 0, + 'Banana\0Strawberry', + null, + 0, + ], + [path.join('fruits', 'Strawberry.js')]: [ + 'Strawberry', + 32, + 42, + 0, + '', + null, + 0, + ], + [path.join('fruits', '__mocks__', 'Pear.js')]: [ + '', + 32, + 42, + 0, + 'Melon', + null, + 0, + ], + [path.join('vegetables', 'Melon.js')]: [ + 'Melon', + 32, + 42, + 0, + '', + null, + 0, + ], + ...(enableSymlinks + ? { + [path.join('fruits', 'LinkToStrawberry.js')]: [ + '', + 32, + 42, + 0, + '', + null, + 1, + ], + } + : null), + }); + + return Promise.resolve({ + changedFiles, + removedFiles: new Map(), + }); }); - return Promise.resolve({ - changedFiles, - removedFiles: new Map(), + const hasteMap = new HasteMap({ + ...defaultConfig, + computeSha1: true, + maxWorkers: 1, + enableSymlinks, + useWatchman, }); - }); - - const hasteMap = new HasteMap({ - ...defaultConfig, - computeSha1: true, - maxWorkers: 1, - useWatchman, - }); - await hasteMap.build(); - - expect(cacheContent.files).toEqual( - createMap({ - [path.join('fruits', 'Banana.js')]: [ - 'Banana', - 32, - 42, - 1, - 'Strawberry', - '7772b628e422e8cf59c526be4bb9f44c0898e3d1', - 0, - ], - [path.join('fruits', 'Pear.js')]: [ - 'Pear', - 32, - 42, - 1, - 'Banana\0Strawberry', - '89d0c2cc11dcc5e1df50b8af04ab1b597acfba2f', - 0, - ], - [path.join('fruits', 'Strawberry.js')]: [ - 'Strawberry', - 32, - 42, - 1, - '', - 'e8aa38e232b3795f062f1d777731d9240c0f8c25', - 0, - ], - [path.join('fruits', '__mocks__', 'Pear.js')]: [ - '', - 32, - 42, - 1, - 'Melon', - '8d40afbb6e2dc78e1ba383b6d02cafad35cceef2', - 0, - ], - [path.join('vegetables', 'Melon.js')]: [ - 'Melon', - 32, - 42, - 1, - '', - 'f16ccf6f2334ceff2ddb47628a2c5f2d748198ca', - 0, - ], - }), - ); + await hasteMap.build(); + + expect(cacheContent.files).toEqual( + createMap({ + [path.join('fruits', 'Banana.js')]: [ + 'Banana', + 32, + 42, + 1, + 'Strawberry', + '7772b628e422e8cf59c526be4bb9f44c0898e3d1', + 0, + ], + [path.join('fruits', 'Pear.js')]: [ + 'Pear', + 32, + 42, + 1, + 'Banana\0Strawberry', + '89d0c2cc11dcc5e1df50b8af04ab1b597acfba2f', + 0, + ], + [path.join('fruits', 'Strawberry.js')]: [ + 'Strawberry', + 32, + 42, + 1, + '', + 'e8aa38e232b3795f062f1d777731d9240c0f8c25', + 0, + ], + [path.join('fruits', '__mocks__', 'Pear.js')]: [ + '', + 32, + 42, + 1, + 'Melon', + '8d40afbb6e2dc78e1ba383b6d02cafad35cceef2', + 0, + ], + [path.join('vegetables', 'Melon.js')]: [ + 'Melon', + 32, + 42, + 1, + '', + 'f16ccf6f2334ceff2ddb47628a2c5f2d748198ca', + 0, + ], + ...(enableSymlinks + ? { + [path.join('fruits', 'LinkToStrawberry.js')]: [ + '', + 32, + 42, + 1, + '', + null, + 'Strawberry.js', + ], + } + : null), + }), + ); - expect(deepNormalize(await hasteMap.read())).toEqual(cacheContent); - }); + expect(deepNormalize(await hasteMap.read())).toEqual(cacheContent); + }, + ); }); it('does not crawl native files even if requested to do so', async () => { @@ -843,8 +914,8 @@ describe('HasteMap', () => { // and it should write a new cache expect(mockCacheManager.write).toHaveBeenCalledTimes(1); - // The first run should access the file system five times for the files in - // the system. + // The first run should access the file system five times for the regular + // files in the system. expect(fs.readFileSync.mock.calls.length).toBe(5); fs.readFileSync.mockClear(); @@ -1188,6 +1259,7 @@ describe('HasteMap', () => { enableHastePackages: true, filePath: path.join('/', 'project', 'fruits', 'Banana.js'), hasteImplModulePath: undefined, + readLink: false, rootDir: path.join('/', 'project'), }, ], @@ -1199,6 +1271,7 @@ describe('HasteMap', () => { enableHastePackages: true, filePath: path.join('/', 'project', 'fruits', 'Pear.js'), hasteImplModulePath: undefined, + readLink: false, rootDir: path.join('/', 'project'), }, ], @@ -1210,6 +1283,7 @@ describe('HasteMap', () => { enableHastePackages: true, filePath: path.join('/', 'project', 'fruits', 'Strawberry.js'), hasteImplModulePath: undefined, + readLink: false, rootDir: path.join('/', 'project'), }, ], @@ -1221,6 +1295,7 @@ describe('HasteMap', () => { enableHastePackages: true, filePath: path.join('/', 'project', 'fruits', '__mocks__', 'Pear.js'), hasteImplModulePath: undefined, + readLink: false, rootDir: path.join('/', 'project'), }, ], @@ -1232,6 +1307,7 @@ describe('HasteMap', () => { enableHastePackages: true, filePath: path.join('/', 'project', 'vegetables', 'Melon.js'), hasteImplModulePath: undefined, + readLink: false, rootDir: path.join('/', 'project'), }, ], diff --git a/packages/metro-file-map/src/__tests__/worker-test.js b/packages/metro-file-map/src/__tests__/worker-test.js index 0a9b47bece..90720bd41e 100644 --- a/packages/metro-file-map/src/__tests__/worker-test.js +++ b/packages/metro-file-map/src/__tests__/worker-test.js @@ -10,11 +10,11 @@ import H from '../constants'; import {worker} from '../worker'; -import * as fs from 'graceful-fs'; +import * as fs from 'fs'; import * as path from 'path'; import * as vm from 'vm'; -jest.mock('graceful-fs', () => { +jest.mock('fs', () => { const path = require('path'); const mockFs = { [path.join('/project', 'fruits', 'Banana.js')]: ` @@ -28,6 +28,9 @@ jest.mock('graceful-fs', () => { [path.join('/project', 'fruits', 'Strawberry.js')]: ` // Strawberry! `, + [path.join('/project', 'fruits', 'LinkToStrawberry.js')]: { + link: path.join('.', 'Strawberry.js'), + }, [path.join('/project', 'fruits', 'apple.png')]: Buffer.from([ 137, 80, 78, 71, 13, 10, 26, 10, ]), @@ -40,14 +43,30 @@ jest.mock('graceful-fs', () => { }; return { - ...jest.createMockFromModule('graceful-fs'), + ...jest.createMockFromModule('fs'), readFileSync: jest.fn((path, options) => { - if (mockFs[path]) { - return options === 'utf8' ? mockFs[path] : Buffer.from(mockFs[path]); + const entry = mockFs[path]; + if (entry) { + if (typeof entry.link === 'string') { + throw new Error('Tried to call readFile on a symlink'); + } + return options === 'utf8' ? entry : Buffer.from(entry); } - throw new Error(`Cannot read path '${path}'.`); }), + promises: { + readlink: jest.fn(async path => { + const entry = mockFs[path]; + if (entry) { + if (typeof entry.link === 'string') { + return entry.link; + } else { + throw new Error('Tried to call readlink on a non-symlink'); + } + } + throw new Error(`Cannot read path '${path}'.`); + }), + }, }; }); @@ -221,6 +240,26 @@ describe('worker', () => { expect(fs.readFile).not.toHaveBeenCalled(); }); + it('calls readLink and returns symlink target when readLink=true', async () => { + expect( + await worker({ + computeDependencies: false, + filePath: path.join('/project', 'fruits', 'LinkToStrawberry.js'), + readLink: true, + rootDir, + }), + ).toEqual({ + dependencies: undefined, + id: undefined, + module: undefined, + sha1: undefined, + symlinkTarget: path.join('.', 'Strawberry.js'), + }); + + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.promises.readlink).toHaveBeenCalled(); + }); + it('can be loaded directly without transpilation', async () => { const code = await jest .requireActual('fs') diff --git a/packages/metro-file-map/src/flow-types.js b/packages/metro-file-map/src/flow-types.js index 3df6bb42f6..ee7d658307 100644 --- a/packages/metro-file-map/src/flow-types.js +++ b/packages/metro-file-map/src/flow-types.js @@ -161,6 +161,7 @@ export interface FileSystem { getModuleName(file: Path): ?string; getSerializableSnapshot(): FileData; getSha1(file: Path): ?string; + getSymlinkTarget(file: Path): ?string; getType(file: Path): ?('f' | 'l'); matchFiles(pattern: RegExp | string): Array; @@ -241,6 +242,7 @@ export type VisitMetadata = { hasteId?: string, sha1?: ?string, dependencies?: string, + symlinkTarget?: string, }; export type WatchmanClockSpec = @@ -253,6 +255,7 @@ export type WorkerMessage = $ReadOnly<{ computeSha1: boolean, dependencyExtractor?: ?string, enableHastePackages: boolean, + readLink: boolean, rootDir: string, filePath: string, hasteImplModulePath?: ?string, @@ -263,4 +266,5 @@ export type WorkerMetadata = $ReadOnly<{ id?: ?string, module?: ?ModuleMetaData, sha1?: ?string, + symlinkTarget?: ?string, }>; diff --git a/packages/metro-file-map/src/index.js b/packages/metro-file-map/src/index.js index c45c41ecf4..2f8ae55515 100644 --- a/packages/metro-file-map/src/index.js +++ b/packages/metro-file-map/src/index.js @@ -142,7 +142,7 @@ export type { // This should be bumped whenever a code change to `metro-file-map` itself // would cause a change to the cache data structure and/or content (for a given // filesystem state and build parameters). -const CACHE_BREAKER = '3'; +const CACHE_BREAKER = '4'; const CHANGE_INTERVAL = 30; const NODE_MODULES = path.sep + 'node_modules' + path.sep; @@ -556,16 +556,23 @@ export default class HasteMap extends EventEmitter { }; const relativeFilePath = fastPath.relative(rootDir, filePath); - const moduleName = fileSystem.getModuleName(relativeFilePath); + const fileType = fileSystem.getType(relativeFilePath); - if (moduleName == null) { + if (fileType == null) { throw new Error( 'metro-file-map: File to process was not found in the haste map.', ); } const computeSha1 = - this._options.computeSha1 && fileSystem.getSha1(relativeFilePath) == null; + this._options.computeSha1 && + fileType === 'f' && + fileSystem.getSha1(relativeFilePath) == null; + + const readLink = + this._options.enableSymlinks && + fileType === 'l' && + fileSystem.getSymlinkTarget(relativeFilePath) == null; // Callback called when the response from the worker is successful. const workerReply = (metadata: WorkerMetadata) => { @@ -585,6 +592,11 @@ export default class HasteMap extends EventEmitter { if (computeSha1) { result.sha1 = metadata.sha1; } + + if (metadata.symlinkTarget != null) { + result.symlinkTarget = metadata.symlinkTarget; + } + fileSystem.setVisitMetadata(relativeFilePath, result); }; @@ -615,7 +627,7 @@ export default class HasteMap extends EventEmitter { // If we retain all files in the virtual HasteFS representation, we avoid // reading them if they aren't important (node_modules). if (this._options.retainAllFiles && filePath.includes(NODE_MODULES)) { - if (computeSha1) { + if (computeSha1 || readLink) { return this._getWorker(workerOptions) .worker({ computeDependencies: false, @@ -624,11 +636,33 @@ export default class HasteMap extends EventEmitter { enableHastePackages: false, filePath, hasteImplModulePath: null, + readLink, rootDir, }) .then(workerReply, workerError); } + return null; + } + // Symlink Haste modules, Haste packages or mocks are not supported - read + // the target if requested and return early. + if (fileType === 'l') { + if (readLink) { + // If we only need to read a link, it's more efficient to do it in-band + // (with async file IO) than to have the overhead of worker IO. + return this._getWorker({forceInBand: true}) + .worker({ + computeDependencies: false, + computeSha1: false, + dependencyExtractor: null, + enableHastePackages: false, + filePath, + hasteImplModulePath: null, + readLink, + rootDir, + }) + .then(workerReply, workerError); + } return null; } @@ -673,6 +707,7 @@ export default class HasteMap extends EventEmitter { enableHastePackages: true, filePath, hasteImplModulePath: this._options.hasteImplModulePath, + readLink: false, rootDir, }) .then(workerReply, workerError); diff --git a/packages/metro-file-map/src/worker.js b/packages/metro-file-map/src/worker.js index 81edc489e2..c48993ad1d 100644 --- a/packages/metro-file-map/src/worker.js +++ b/packages/metro-file-map/src/worker.js @@ -19,6 +19,7 @@ const dependencyExtractor = require('./lib/dependencyExtractor'); const excludedExtensions = require('./workerExclusionList'); const {createHash} = require('crypto'); const fs = require('graceful-fs'); +const {promises: fsPromises} = require('fs'); const path = require('path'); const PACKAGE_JSON = path.sep + 'package.json'; @@ -53,11 +54,13 @@ async function worker( let id /*: WorkerMetadata['id'] */; let module /*: WorkerMetadata['module'] */; let sha1 /*: WorkerMetadata['sha1'] */; + let symlinkTarget /*: WorkerMetadata['symlinkTarget'] */; const { computeDependencies, computeSha1, enableHastePackages, + readLink, rootDir, filePath, } = data; @@ -115,7 +118,11 @@ async function worker( sha1 = sha1hex(getContent()); } - return {dependencies, id, module, sha1}; + if (readLink) { + symlinkTarget = await fsPromises.readlink(filePath); + } + + return {dependencies, id, module, sha1, symlinkTarget}; } module.exports = {