diff --git a/packages/metro/src/HmrServer.js b/packages/metro/src/HmrServer.js index b554240242..e813863ada 100644 --- a/packages/metro/src/HmrServer.js +++ b/packages/metro/src/HmrServer.js @@ -28,6 +28,7 @@ import debounceAsyncQueue from './lib/debounceAsyncQueue'; import formatBundlingError from './lib/formatBundlingError'; import getGraphId from './lib/getGraphId'; import parseBundleOptionsFromBundleRequestUrl from './lib/parseBundleOptionsFromBundleRequestUrl'; +import ProjectRouteMap from './lib/ProjectRouteMap'; import splitBundleOptions from './lib/splitBundleOptions'; import * as transformHelpers from './lib/transformHelpers'; import {Logger} from 'metro-core'; @@ -71,6 +72,7 @@ export default class HmrServer { _bundler: IncrementalBundler; _createModuleId: (path: string) => number; _clientGroups: Map; + _routeMap: ProjectRouteMap; constructor( bundler: IncrementalBundler, @@ -81,6 +83,7 @@ export default class HmrServer { this._bundler = bundler; this._createModuleId = createModuleId; this._clientGroups = new Map(); + this._routeMap = new ProjectRouteMap(config); } onClientConnect: ( @@ -121,19 +124,18 @@ export default class HmrServer { transformOptions.platform, resolverOptions, ); - const resolvedEntryFilePath = resolutionFn( - (this._config.server.unstable_serverRoot ?? this._config.projectRoot) + - '/.', - { - name: entryFile, - data: { - key: entryFile, - asyncType: null, - isESMImport: false, - locs: [], - }, + const absolutePath = this._routeMap.filePathOfUrlDecodedPathname(entryFile); + const rootDir = absolutePath != null ? '/' : this._routeMap.serverRootDir; + const resolvedEntryFile = absolutePath ?? entryFile; + const resolvedEntryFilePath = resolutionFn(rootDir + '/.', { + name: resolvedEntryFile, + data: { + key: resolvedEntryFile, + asyncType: null, + isESMImport: false, + locs: [], }, - ).filePath; + }).filePath; const graphId = getGraphId(resolvedEntryFilePath, transformOptions, { resolverOptions, shallow: graphOptions.shallow, @@ -379,8 +381,7 @@ export default class HmrServer { createModuleId: this._createModuleId, includeAsyncPaths: group.graphOptions.lazy, projectRoot: this._config.projectRoot, - serverRoot: - this._config.server.unstable_serverRoot ?? this._config.projectRoot, + serverRoot: this._routeMap.serverRootDir, }); logger?.point('serialize_end'); diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index c21f619851..1543847d05 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -58,6 +58,7 @@ import formatBundlingError from './lib/formatBundlingError'; import getGraphId from './lib/getGraphId'; import parseBundleOptionsFromBundleRequestUrl from './lib/parseBundleOptionsFromBundleRequestUrl'; import parseJsonBody from './lib/parseJsonBody'; +import ProjectRouteMap from './lib/ProjectRouteMap'; import splitBundleOptions from './lib/splitBundleOptions'; import * as transformHelpers from './lib/transformHelpers'; import {UnableToResolveError} from './node-haste/DependencyGraph/ModuleResolution'; @@ -149,9 +150,7 @@ export default class Server { _reporter: Reporter; _serverOptions: ServerOptions | void; _allowedSuffixesForSourceRequests: ReadonlyArray; - _sourceRequestRoutingMap: ReadonlyArray< - [pathnamePrefix: string, normalizedRootDir: string], - >; + _routeMap: ProjectRouteMap; _fetchTimings: Array; _activeFetchCount: number; @@ -178,13 +177,7 @@ export default class Server { ].map(ext => '.' + ext), ), ]; - this._sourceRequestRoutingMap = [ - ['/[metro-project]/', path.resolve(this._config.projectRoot)], - ...this._config.watchFolders.map((watchFolder, index) => [ - `/[metro-watchFolders]/${index}/`, - path.resolve(watchFolder), - ]), - ]; + this._routeMap = new ProjectRouteMap(config); this._isEnded = false; this._fetchTimings = []; this._activeFetchCount = 0; @@ -260,8 +253,7 @@ export default class Server { sourceMapUrl: serializerOptions.sourceMapUrl, sourceUrl: serializerOptions.sourceUrl, inlineSourceMap: serializerOptions.inlineSourceMap, - serverRoot: - this._config.server.unstable_serverRoot ?? this._config.projectRoot, + serverRoot: this._routeMap.serverRootDir, shouldAddToIgnoreList: (module: Module<>) => this._shouldAddModuleToIgnoreList(module), getSourceUrl: (module: Module<>) => @@ -365,6 +357,8 @@ export default class Server { transformOptions, } = splitBundleOptions(options); + const entryPoint = this._getEntryPointAbsolutePath(entryFile); + const {prepend, graph} = await this._bundler.buildGraph( entryFile, transformOptions, @@ -376,8 +370,6 @@ export default class Server { }, ); - const entryPoint = this._getEntryPointAbsolutePath(entryFile); - return await getRamBundleInfo(entryPoint, prepend, graph, { asyncRequireModulePath: await this._resolveRelativePath( this._config.transformer.asyncRequireModulePath, @@ -406,8 +398,7 @@ export default class Server { sourceMapUrl: serializerOptions.sourceMapUrl, sourceUrl: serializerOptions.sourceUrl, inlineSourceMap: serializerOptions.inlineSourceMap, - serverRoot: - this._config.server.unstable_serverRoot ?? this._config.projectRoot, + serverRoot: this._routeMap.serverRootDir, shouldAddToIgnoreList: (module: Module<>) => this._shouldAddModuleToIgnoreList(module), getSourceUrl: (module: Module<>) => @@ -440,7 +431,7 @@ export default class Server { processModuleFilter: this._config.serializer.processModuleFilter, assetPlugins: this._config.transformer.assetPlugins, platform, - projectRoot: this._getServerRootDir(), + projectRoot: this._routeMap.serverRootDir, publicPath: this._config.transformer.publicPath, }); } @@ -700,36 +691,23 @@ export default class Server { } else if (pathname === '/symbolicate') { await this._symbolicate(req, res); } else { - let handled = false; - for (const [pathnamePrefix, normalizedRootDir] of this - ._sourceRequestRoutingMap) { - if (filePathname.startsWith(pathnamePrefix)) { - const relativeFilePathname = filePathname.substr( - pathnamePrefix.length, - ); - await this._processSourceRequest( - relativeFilePathname, - normalizedRootDir, - res, - ); - handled = true; - break; - } - } - if (!handled) { + const sourceFilePath = + this._routeMap.filePathOfUrlDecodedPathname(filePathname); + if (sourceFilePath != null) { + await this._processSourceRequest(sourceFilePath, res); + } else { next(); } } } async _processSourceRequest( - relativeFilePathname: string, - rootDir: string, + filePath: string, res: ServerResponse, ): Promise { if ( !this._allowedSuffixesForSourceRequests.some(suffix => - relativeFilePathname.endsWith(suffix), + filePath.endsWith(suffix), ) ) { res.writeHead(404); @@ -737,7 +715,6 @@ export default class Server { return; } const depGraph = await this._bundler.getBundler().getDependencyGraph(); - const filePath = path.join(rootDir, relativeFilePathname); try { await depGraph.getOrComputeSha1(filePath); } catch { @@ -745,7 +722,7 @@ export default class Server { res.end(); return; } - const mimeType = mime.lookup(path.basename(relativeFilePathname)); + const mimeType = mime.lookup(path.basename(filePath)); res.setHeader('Content-Type', mimeType); const stream = fs.createReadStream(filePath); stream.pipe(res); @@ -1150,8 +1127,7 @@ export default class Server { sourceMapUrl: serializerOptions.sourceMapUrl, sourceUrl: serializerOptions.sourceUrl, inlineSourceMap: serializerOptions.inlineSourceMap, - serverRoot: - this._config.server.unstable_serverRoot ?? this._config.projectRoot, + serverRoot: this._routeMap.serverRootDir, shouldAddToIgnoreList: (module: Module<>) => this._shouldAddModuleToIgnoreList(module), getSourceUrl: (module: Module<>) => @@ -1390,7 +1366,16 @@ export default class Server { continue; } - const fileAbsolute = path.resolve(this._config.projectRoot, file ?? ''); + const fileAbsolute = + file != null + ? (this._routeMap.filePathOfUrlDecodedPathname(file) ?? + (path.isAbsolute(file) + ? file + : path.resolve(this._config.projectRoot, file))) + : null; + if (fileAbsolute == null) { + continue; + } if (!depGraph.doesFileExist(fileAbsolute)) { debug( 'Skipping code frame for file not in dependency graph.', @@ -1622,33 +1607,6 @@ export default class Server { ); } - _resolveWatchFolderPrefix( - filePath: string, - ): {rootDir: string, filePath: string} | null { - const watchFolderMatch = filePath.match( - /^\.\/\[metro-watchFolders\]\/(\d+)\/(.*)/, - ); - if (watchFolderMatch != null) { - const index = parseInt(watchFolderMatch[1], 10); - const watchFolder = this._config.watchFolders[index]; - if (watchFolder != null) { - return { - rootDir: path.resolve(watchFolder), - filePath: - '.' + path.sep + watchFolderMatch[2].split('/').join(path.sep), - }; - } - } - const projectMatch = filePath.match(/^\.\/\[metro-project\]\/(.*)/); - if (projectMatch != null) { - return { - rootDir: path.resolve(this._config.projectRoot), - filePath: '.' + path.sep + projectMatch[1].split('/').join(path.sep), - }; - } - return null; - } - async _resolveRelativePath( filePath: string, { @@ -1666,14 +1624,17 @@ export default class Server { transformOptions.platform, resolverOptions, ); - const resolved = this._resolveWatchFolderPrefix(filePath); - const rootDir = - resolved != null - ? resolved.rootDir - : relativeTo === 'server' - ? this._getServerRootDir() - : this._config.projectRoot; - const resolvedFilePath = resolved != null ? resolved.filePath : filePath; + let rootDir; + let resolvedFilePath; + if (relativeTo === 'project') { + rootDir = this._config.projectRoot; + resolvedFilePath = filePath; + } else { + const absolutePath = + this._routeMap.filePathOfUrlDecodedPathname(filePath); + rootDir = absolutePath != null ? '/' : this._routeMap.serverRootDir; + resolvedFilePath = absolutePath ?? filePath; + } return resolutionFn(`${rootDir}/.`, { name: resolvedFilePath, data: { @@ -1737,16 +1698,8 @@ export default class Server { sourcePaths: SourcePathsMode.Absolute, }; - _getServerRootDir(): string { - return this._config.server.unstable_serverRoot ?? this._config.projectRoot; - } - _getEntryPointAbsolutePath(entryFile: string): string { - const resolved = this._resolveWatchFolderPrefix(entryFile); - if (resolved != null) { - return path.resolve(resolved.rootDir, resolved.filePath); - } - return path.resolve(this._getServerRootDir(), entryFile); + return path.resolve(this._routeMap.serverRootDir, entryFile); } // Wait for the server to finish initializing. @@ -1771,29 +1724,7 @@ export default class Server { _getModuleSourceUrl(module: Module<>, mode: SourcePathsMode): string { switch (mode) { case SourcePathsMode.ServerUrl: - for (const [pathnamePrefix, normalizedRootDir] of this - ._sourceRequestRoutingMap) { - if (module.path.startsWith(normalizedRootDir + path.sep)) { - const relativePath = module.path.slice( - normalizedRootDir.length + 1, - ); - const relativePathPosix = relativePath - .split(path.sep) - .map(segment => encodeURIComponent(segment)) - .join('/'); - return pathnamePrefix + relativePathPosix; - } - } - // Ordinarily all files should match one of the roots above. If they - // don't, try to preserve useful information, even if fetching the path - // from Metro might fail. - const modulePathPosix = module.path - .split(path.sep) - .map(segment => encodeURIComponent(segment)) - .join('/'); - return modulePathPosix.startsWith('/') - ? modulePathPosix - : '/' + modulePathPosix; + return this._routeMap.urlPathnameOfFilePath(module.path); case SourcePathsMode.Absolute: return module.path; } diff --git a/packages/metro/src/Server/__tests__/Server-test.js b/packages/metro/src/Server/__tests__/Server-test.js index 47d7e6f954..0a30057e26 100644 --- a/packages/metro/src/Server/__tests__/Server-test.js +++ b/packages/metro/src/Server/__tests__/Server-test.js @@ -1350,6 +1350,78 @@ describe('processRequest', () => { expect(result.codeFrame).toBeNull(); }); + test('should return codeFrame when symbolicated file has [metro-project] prefix', async () => { + // Simulate the scenario where source maps use virtual-prefixed + // paths (sourcePaths=serverUrl) by overriding the exploded source + // map to reference /[metro-project]/mybundle.js instead of the + // absolute /root/mybundle.js. Symbolication maps bundle line 2, + // col 18 → source line 1, col 0, and produces file = + // /[metro-project]/mybundle.js. getCodeFrame must resolve this + // virtual prefix back to /root/mybundle.js to read the source. + jest + .spyOn(server, '_explodedSourceMapForBundleOptions') + .mockResolvedValue([ + { + firstLine1Based: 1, + functionMap: null, + path: 'require-js', + map: [], + }, + { + firstLine1Based: 2, + functionMap: null, + path: '/[metro-project]/mybundle.js', + map: [[1, 16, 1, 0]], + }, + ]); + + const response = await makeRequest('/symbolicate', { + headers: {'content-type': 'application/json'}, + data: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true`, + lineNumber: 2, + column: 18, + methodName: 'clientSideMethodName', + }, + ], + }), + }); + + const result = response._getJSON(); + // Symbolication mapped the HTTP URL to the virtual-prefixed source path + expect(result.stack[0].file).toBe('/[metro-project]/mybundle.js'); + expect(result.stack[0].lineNumber).toBe(1); + expect(result.stack[0].column).toBe(0); + // getCodeFrame resolved /[metro-project]/mybundle.js → /root/mybundle.js + expect(result.codeFrame).not.toBeNull(); + expect(result.codeFrame.fileName).toBe('/[metro-project]/mybundle.js'); + expect(result.codeFrame.content).toEqual(expect.any(String)); + }); + + test('should return codeFrame when file is a relative path (resolved against projectRoot)', async () => { + const response = await makeRequest('/symbolicate', { + headers: {'content-type': 'application/json'}, + data: JSON.stringify({ + stack: [ + { + file: 'mybundle.js', + lineNumber: 2, + column: 0, + methodName: 'test', + }, + ], + }), + }); + + const result = response._getJSON(); + expect(result.stack[0].file).toBe('mybundle.js'); + expect(result.codeFrame).not.toBeNull(); + expect(result.codeFrame.fileName).toBe('mybundle.js'); + expect(result.codeFrame.content).toEqual(expect.any(String)); + }); + // TODO: This probably should restore the *original* file before rewrite // or normalisation. test('should leave original file and position when cannot symbolicate (after normalisation and rewriting?)', async () => { @@ -1436,87 +1508,4 @@ describe('processRequest', () => { }, ); }); - - describe('watchFolder prefix resolution', () => { - let watchFolderServer: $FlowFixMe; - - beforeEach(() => { - watchFolderServer = new Server( - mergeConfig(getDefaultValues('/'), { - projectRoot: '/project', - watchFolders: ['/project', '/external/packages'], - resolver: {blockList: []}, - cacheVersion: '', - serializer: { - getRunModuleStatement: moduleId => - `require(${JSON.stringify(moduleId)});`, - polyfillModuleNames: [], - getModulesRunBeforeMainModule: () => ['InitializeCore'], - }, - reporter: require('../../lib/reporting').nullReporter, - } as InputConfigT), - ); - }); - - test('resolves [metro-watchFolders]/N/ prefix against the Nth watch folder', () => { - expect( - watchFolderServer._resolveWatchFolderPrefix( - './[metro-watchFolders]/1/expo-router/entry', - ), - ).toEqual({ - rootDir: '/external/packages', - filePath: './expo-router/entry', - }); - }); - - test('resolves [metro-watchFolders]/0/ prefix against the first watch folder', () => { - expect( - watchFolderServer._resolveWatchFolderPrefix( - './[metro-watchFolders]/0/app/index', - ), - ).toEqual({ - rootDir: '/project', - filePath: './app/index', - }); - }); - - test('resolves [metro-project]/ prefix against projectRoot', () => { - expect( - watchFolderServer._resolveWatchFolderPrefix( - './[metro-project]/src/App', - ), - ).toEqual({ - rootDir: '/project', - filePath: './src/App', - }); - }); - - test('returns null for paths without a recognized prefix', () => { - expect( - watchFolderServer._resolveWatchFolderPrefix('./mybundle'), - ).toBeNull(); - }); - - test('returns null for out-of-bounds watchFolder index', () => { - expect( - watchFolderServer._resolveWatchFolderPrefix( - './[metro-watchFolders]/99/mybundle', - ), - ).toBeNull(); - }); - - test('_getEntryPointAbsolutePath resolves prefixed entry against the corresponding watch folder', () => { - expect( - watchFolderServer._getEntryPointAbsolutePath( - './[metro-watchFolders]/1/expo-router/entry', - ), - ).toBe('/external/packages/expo-router/entry'); - }); - - test('_getEntryPointAbsolutePath resolves non-prefixed entry against server root', () => { - expect(watchFolderServer._getEntryPointAbsolutePath('./mybundle')).toBe( - '/project/mybundle', - ); - }); - }); }); diff --git a/packages/metro/src/__tests__/HmrServer-test.js b/packages/metro/src/__tests__/HmrServer-test.js index 4d6d4398fa..5a749abc1b 100644 --- a/packages/metro/src/__tests__/HmrServer-test.js +++ b/packages/metro/src/__tests__/HmrServer-test.js @@ -130,6 +130,7 @@ describe('HmrServer', () => { unstable_allowRequireContext: false, }, resolver: {platforms: []}, + watchFolders: ['/external/node_modules'], server: { rewriteRequestUrl(requrl) { const rewritten = requrl.replace( @@ -650,6 +651,60 @@ describe('HmrServer', () => { ]); }); + test('should resolve [metro-watchFolders] prefix in entry point', async () => { + await connect( + '/hot?bundleEntry=./[metro-watchFolders]/0/expo-router/entry.js&platform=ios', + ); + + expect(getRevisionByGraphIdMock).toBeCalledWith( + getGraphId( + '/external/node_modules/expo-router/entry.js', + { + customTransformOptions: {}, + dev: true, + minify: false, + platform: 'ios', + type: 'module', + unstable_transformProfile: 'default', + }, + { + shallow: false, + lazy: false, + unstable_allowRequireContext: false, + resolverOptions: { + dev: true, + }, + }, + ), + ); + }); + + test('should resolve [metro-project] prefix in entry point', async () => { + await connect('/hot?bundleEntry=./[metro-project]/src/App.js&platform=ios'); + + expect(getRevisionByGraphIdMock).toBeCalledWith( + getGraphId( + '/root/src/App.js', + { + customTransformOptions: {}, + dev: true, + minify: false, + platform: 'ios', + type: 'module', + unstable_transformProfile: 'default', + }, + { + shallow: false, + lazy: false, + unstable_allowRequireContext: false, + resolverOptions: { + dev: true, + }, + }, + ), + ); + }); + test('should return error messages when there is a transform error', async () => { jest.useRealTimers(); const sendMessage = jest.fn(); diff --git a/packages/metro/src/integration_tests/__tests__/build-test.js b/packages/metro/src/integration_tests/__tests__/build-test.js index 218b424988..a9cc1ca039 100644 --- a/packages/metro/src/integration_tests/__tests__/build-test.js +++ b/packages/metro/src/integration_tests/__tests__/build-test.js @@ -118,6 +118,37 @@ test('allows specifying paths to save bundle and maps', async () => { ); }); +test('builds a bundle from a file in a directory literally named [metro-project]', async () => { + const config = await Metro.loadConfig({ + config: require.resolve('../metro.config.js'), + }); + + const result = await Metro.runBuild(config, { + entry: './[metro-project]/LiteralDir.js', + }); + + expect(execBundle(result.code)).toBe('from-literal-dir'); +}); + +// $FlowFixMe[prop-missing] - test.failing is not in Flow's Jest types +test.failing( + 'runBuild resolves entry against projectRoot, not unstable_serverRoot', + async () => { + const baseConfig = await Metro.loadConfig({ + config: require.resolve('../metro.config.js'), + }); + const config = MetroConfig.mergeConfig(baseConfig, { + server: {unstable_serverRoot: path.resolve(INPUT_PATH, '..')}, + }); + + const result = await Metro.runBuild(config, { + entry: 'TestBundle.js', + }); + + expect(execBundle(result.code)).toBeDefined(); + }, +); + test('(unstable) allows specifying a transform profile', async () => { const config = await Metro.loadConfig({ config: require.resolve('../metro.config.js'), diff --git a/packages/metro/src/integration_tests/__tests__/rambundle-test.js b/packages/metro/src/integration_tests/__tests__/rambundle-test.js index b18867d400..af7e68e188 100644 --- a/packages/metro/src/integration_tests/__tests__/rambundle-test.js +++ b/packages/metro/src/integration_tests/__tests__/rambundle-test.js @@ -19,15 +19,19 @@ const vm = require('vm'); jest.setTimeout(30 * 1000); -test('builds and executes a RAM bundle', async () => { - const config = await Metro.loadConfig({ +let config; + +beforeAll(async () => { + config = await Metro.loadConfig({ config: require.resolve('../metro.config.js'), }); - const bundlePath = path.join(os.tmpdir(), 'rambundle.js'); +}); +async function buildAndExecRamBundle(entry: string): mixed { + const bundlePath = path.join(os.tmpdir(), `rambundle-${Date.now()}.js`); try { await Metro.runBuild(config, { - entry: 'TestBundle.js', + entry, output: ramBundleOutput, out: bundlePath, }); @@ -35,16 +39,26 @@ test('builds and executes a RAM bundle', async () => { const bundleBuffer = fs.readFileSync(bundlePath); const parser = new RamBundleParser(bundleBuffer); - // Create a context with a global nativeRequire function, which reads the - // module code from the RAM bundle and injects it into the VM. const context = vm.createContext({ nativeRequire(id) { vm.runInContext(parser.getModule(id), context); }, }); - expect(vm.runInContext(parser.getStartupCode(), context)).toMatchSnapshot(); + return vm.runInContext(parser.getStartupCode(), context); } finally { - fs.unlinkSync(bundlePath); + if (fs.existsSync(bundlePath)) { + fs.unlinkSync(bundlePath); + } } +} + +test('builds and executes a RAM bundle', async () => { + expect(await buildAndExecRamBundle('TestBundle.js')).toMatchSnapshot(); +}); + +test('rejects [metro-project] virtual prefix in runBuild entry', async () => { + await expect( + buildAndExecRamBundle('./[metro-project]/TestBundle.js'), + ).rejects.toThrow('was not found'); }); diff --git a/packages/metro/src/integration_tests/__tests__/server-test.js b/packages/metro/src/integration_tests/__tests__/server-test.js index 90c36476c8..d933352338 100644 --- a/packages/metro/src/integration_tests/__tests__/server-test.js +++ b/packages/metro/src/integration_tests/__tests__/server-test.js @@ -122,6 +122,71 @@ describe('Metro development server serves bundles via HTTP', () => { ); }); + test('should serve bundles with [metro-watchFolders] entry point', async () => { + expect( + await downloadAndExec( + '/[metro-watchFolders]/1/metro/src/integration_tests/basic_bundle/TestBundle.bundle?platform=ios&dev=true&minify=false', + ), + ).toBeDefined(); + }); + + test('should serve bundles with [metro-project] entry point', async () => { + expect( + await downloadAndExec( + '/[metro-project]/TestBundle.bundle?platform=ios&dev=true&minify=false', + ), + ).toBeDefined(); + }); + + test('[metro-project] source map resolves same modules as non-prefixed', async () => { + const directResponse = await fetchAndClose( + 'http://localhost:' + + httpServer.address().port + + '/TestBundle.map?platform=ios&dev=true&minify=false', + ); + const prefixedResponse = await fetchAndClose( + 'http://localhost:' + + httpServer.address().port + + '/[metro-project]/TestBundle.map?platform=ios&dev=true&minify=false', + ); + expect(directResponse.ok).toBe(true); + expect(prefixedResponse.ok).toBe(true); + const directMap = await directResponse.json(); + const prefixedMap = await prefixedResponse.json(); + expect([...prefixedMap.sources].sort()).toEqual( + [...directMap.sources].sort(), + ); + }); + + test('[metro-watchFolders] source map resolves same modules as non-prefixed', async () => { + const directResponse = await fetchAndClose( + 'http://localhost:' + + httpServer.address().port + + '/TestBundle.map?platform=ios&dev=true&minify=false', + ); + const watchFolderResponse = await fetchAndClose( + 'http://localhost:' + + httpServer.address().port + + '/[metro-watchFolders]/1/metro/src/integration_tests/basic_bundle/TestBundle.map?platform=ios&dev=true&minify=false', + ); + expect(directResponse.ok).toBe(true); + expect(watchFolderResponse.ok).toBe(true); + const directMap = await directResponse.json(); + const watchFolderMap = await watchFolderResponse.json(); + expect([...watchFolderMap.sources].sort()).toEqual( + [...directMap.sources].sort(), + ); + }); + + test('responds with 404 for [metro-watchFolders] with out-of-bounds index', async () => { + const response = await fetchAndClose( + 'http://localhost:' + + httpServer.address().port + + '/[metro-watchFolders]/99/TestBundle.bundle?platform=ios&dev=true&minify=false', + ); + expect(response.status).toBe(404); + }); + test('responds with 404 when the bundle cannot be resolved', async () => { const response = await fetchAndClose( 'http://localhost:' + httpServer.address().port + '/doesnotexist.bundle', diff --git a/packages/metro/src/integration_tests/basic_bundle/[metro-project]/LiteralDir.js b/packages/metro/src/integration_tests/basic_bundle/[metro-project]/LiteralDir.js new file mode 100644 index 0000000000..52fce287ef --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/[metro-project]/LiteralDir.js @@ -0,0 +1,11 @@ +/** + * 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 + */ + +module.exports = 'from-literal-dir'; diff --git a/packages/metro/src/lib/ProjectRouteMap.js b/packages/metro/src/lib/ProjectRouteMap.js new file mode 100644 index 0000000000..45867e10a7 --- /dev/null +++ b/packages/metro/src/lib/ProjectRouteMap.js @@ -0,0 +1,128 @@ +/** + * 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 + */ + +import type {ConfigT} from 'metro-config'; + +import path from 'path'; + +// Matches /[metro-watchFolders]//... and /[metro-project]/... +// Applied after normalizing ./ and bare paths to start with /. +const EXPLICIT_ROUTE_RE = + /^\/(?:\[metro-watchFolders\]\/(\d+)|\[metro-project\])\/(.*)/s; + +/** + * Immutable bidirectional map between URL pathnames and filesystem paths, + * encoding the `[metro-project]` and `[metro-watchFolders]` virtual prefix + * conventions. + */ +export default class ProjectRouteMap { + +serverRootDir: string; + +_projectRootDirPrefix: string; + +_watchFolderDirPrefixes: ReadonlyArray; + +_filePathRoutes: ReadonlyArray<{ + rootDirPrefix: string, + pathnamePrefix: string, + }>; + + constructor(config: ConfigT) { + this.serverRootDir = + config.server.unstable_serverRoot ?? config.projectRoot; + this._projectRootDirPrefix = path.normalize(config.projectRoot + path.sep); + this._watchFolderDirPrefixes = config.watchFolders.map(wf => + path.normalize(wf + path.sep), + ); + this._filePathRoutes = [ + { + rootDirPrefix: this._projectRootDirPrefix, + pathnamePrefix: '/[metro-project]/', + }, + ...this._watchFolderDirPrefixes.map((wfDir, i) => ({ + rootDirPrefix: wfDir, + pathnamePrefix: `/[metro-watchFolders]/${i}/`, + })), + ]; + } + + /** + * Decode a URL pathname and resolve it to an absolute filesystem path. + */ + filePathOfUrlPathname(pathname: string): string | null { + const decoded = pathname + .split('/') + .map(segment => decodeURIComponent(segment)) + .join('/'); + + return this.filePathOfUrlDecodedPathname(decoded); + } + + /** + * Convert a URL pathname or entry-file path to an absolute filesystem path. + * + * Accepts both URL-style (`/[metro-watchFolders]/1/foo`) and entry-file-style + * (`./[metro-watchFolders]/1/foo`) prefixes. + * + * Returns `null` when the pathname does not match a known virtual prefix, + * or for out-of-bounds watchFolder indices. + */ + filePathOfUrlDecodedPathname(pathname: string): string | null { + let normalized = pathname; + if (normalized.startsWith('./')) { + normalized = '/' + normalized.slice(2); + } else if (!normalized.startsWith('/')) { + normalized = '/' + normalized; + } + + const match = EXPLICIT_ROUTE_RE.exec(normalized); + if (match != null) { + const watchFolderIndexStr = match[1]; + const rest = match[2]; + let rootDirPrefix; + if (watchFolderIndexStr != null) { + const index = parseInt(watchFolderIndexStr, 10); + if (index >= this._watchFolderDirPrefixes.length) { + return null; + } + rootDirPrefix = this._watchFolderDirPrefixes[index]; + } else { + rootDirPrefix = this._projectRootDirPrefix; + } + return path.join(rootDirPrefix, rest.split('/').join(path.sep)); + } + + return null; + } + + /** + * Convert an absolute filesystem path to a URL pathname using the first + * matching virtual prefix. + * + * Falls back to the absolute path (as a POSIX-style URL) when the file is + * not under any configured route. + */ + urlPathnameOfFilePath(filePath: string): string { + for (const {rootDirPrefix, pathnamePrefix} of this._filePathRoutes) { + if (filePath.startsWith(rootDirPrefix)) { + return ( + pathnamePrefix + + filePath + .slice(rootDirPrefix.length) + .split(path.sep) + .map(segment => encodeURIComponent(segment)) + .join('/') + ); + } + } + const pathPosix = filePath + .split(path.sep) + .map(segment => encodeURIComponent(segment)) + .join('/'); + return pathPosix.startsWith('/') ? pathPosix : '/' + pathPosix; + } +} diff --git a/packages/metro/src/lib/__tests__/ProjectRouteMap-test.js b/packages/metro/src/lib/__tests__/ProjectRouteMap-test.js new file mode 100644 index 0000000000..9c3a919e83 --- /dev/null +++ b/packages/metro/src/lib/__tests__/ProjectRouteMap-test.js @@ -0,0 +1,166 @@ +/** + * 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 + */ + +import type {InputConfigT} from 'metro-config'; + +import ProjectRouteMap from '../ProjectRouteMap'; +import {mergeConfig} from 'metro-config'; +import path from 'path'; + +const { + getDefaultConfig: {getDefaultValues}, +} = require('metro-config'); + +const config = mergeConfig(getDefaultValues('/project/root'), { + watchFolders: ['/mnt/scratch/node_modules', '/other/watch'], +} as InputConfigT); + +const routeMap = new ProjectRouteMap(config); + +describe('ProjectRouteMap', () => { + describe('serverRootDir', () => { + test('defaults to projectRoot', () => { + expect(routeMap.serverRootDir).toBe('/project/root'); + }); + + test('uses unstable_serverRoot when set', () => { + const map = new ProjectRouteMap( + mergeConfig(getDefaultValues('/project/root'), { + server: {unstable_serverRoot: '/server/root'}, + } as InputConfigT), + ); + expect(map.serverRootDir).toBe('/server/root'); + }); + }); + + describe('filePathOfUrlDecodedPathname', () => { + test('resolves [metro-watchFolders]/N/ prefix', () => { + expect( + routeMap.filePathOfUrlDecodedPathname( + './[metro-watchFolders]/0/expo-router/entry', + ), + ).toBe( + path.join( + path.normalize('/mnt/scratch/node_modules'), + 'expo-router', + 'entry', + ), + ); + }); + + test('resolves URL-style /[metro-watchFolders]/N/ prefix', () => { + expect( + routeMap.filePathOfUrlDecodedPathname( + '/[metro-watchFolders]/0/expo-router/entry', + ), + ).toBe( + path.join( + path.normalize('/mnt/scratch/node_modules'), + 'expo-router', + 'entry', + ), + ); + }); + + test('resolves against the correct watchFolder by index', () => { + expect( + routeMap.filePathOfUrlDecodedPathname( + './[metro-watchFolders]/1/some/module', + ), + ).toBe(path.join(path.normalize('/other/watch'), 'some', 'module')); + }); + + test('resolves [metro-project]/ prefix', () => { + expect( + routeMap.filePathOfUrlDecodedPathname( + './[metro-project]/src/app/index', + ), + ).toBe(path.join(path.normalize('/project/root'), 'src', 'app', 'index')); + }); + + test('returns null for non-prefixed paths', () => { + expect(routeMap.filePathOfUrlDecodedPathname('./src/index')).toBeNull(); + expect(routeMap.filePathOfUrlDecodedPathname('/src/index')).toBeNull(); + expect(routeMap.filePathOfUrlDecodedPathname('./app')).toBeNull(); + }); + + test('returns null for out-of-bounds watchFolder index', () => { + expect( + routeMap.filePathOfUrlDecodedPathname( + '/[metro-watchFolders]/99/foo.js', + ), + ).toBeNull(); + }); + }); + + describe('filePathOfUrlPathname', () => { + test('decodes URL-encoded segments', () => { + expect( + routeMap.filePathOfUrlPathname('/%5Bmetro-project%5D/src/App.js'), + ).toBe(path.join(path.normalize('/project/root'), 'src', 'App.js')); + }); + }); + + describe('urlPathnameOfFilePath', () => { + test('maps file in projectRoot to /[metro-project]/', () => { + expect( + routeMap.urlPathnameOfFilePath( + path.normalize('/project/root') + + path.sep + + 'src' + + path.sep + + 'App.js', + ), + ).toBe('/[metro-project]/src/App.js'); + }); + + test('maps file in watchFolder to /[metro-watchFolders]/N/', () => { + expect( + routeMap.urlPathnameOfFilePath( + path.normalize('/mnt/scratch/node_modules') + + path.sep + + 'expo-router' + + path.sep + + 'entry.js', + ), + ).toBe('/[metro-watchFolders]/0/expo-router/entry.js'); + }); + + test('maps file in second watchFolder', () => { + expect( + routeMap.urlPathnameOfFilePath( + path.normalize('/other/watch') + + path.sep + + 'some' + + path.sep + + 'module.js', + ), + ).toBe('/[metro-watchFolders]/1/some/module.js'); + }); + + test('falls back to absolute path for files outside all routes', () => { + expect( + routeMap.urlPathnameOfFilePath( + path.normalize('/unrelated/path/file.js'), + ), + ).toBe('/unrelated/path/file.js'); + }); + + test('is the inverse of filePathOfUrlDecodedPathname for prefixed paths', () => { + const pathname = '/[metro-watchFolders]/0/expo-router/entry.js'; + const filePath = routeMap.filePathOfUrlDecodedPathname('.' + pathname); + expect(filePath).not.toBeNull(); + if (filePath != null) { + expect(routeMap.urlPathnameOfFilePath(filePath)).toBe(pathname); + } + }); + }); +}); diff --git a/packages/metro/types/HmrServer.d.ts b/packages/metro/types/HmrServer.d.ts index da762e40b3..eb0415bf2d 100644 --- a/packages/metro/types/HmrServer.d.ts +++ b/packages/metro/types/HmrServer.d.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. * * @noformat - * @generated SignedSource<> + * @generated SignedSource<<81f86f56137b8992ca4e56f39628d548>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro/src/HmrServer.js @@ -25,6 +25,8 @@ import type { HmrUpdateMessage, } from 'metro-runtime/src/modules/types'; +import ProjectRouteMap from './lib/ProjectRouteMap'; + export type Client = { optedIntoHMR: boolean; revisionIds: Array; @@ -51,6 +53,7 @@ declare class HmrServer { _bundler: IncrementalBundler; _createModuleId: (path: string) => number; _clientGroups: Map; + _routeMap: ProjectRouteMap; constructor( bundler: IncrementalBundler, createModuleId: (path: string) => number, diff --git a/packages/metro/types/Server.d.ts b/packages/metro/types/Server.d.ts index 0f94dbd03d..119368ee1a 100644 --- a/packages/metro/types/Server.d.ts +++ b/packages/metro/types/Server.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<03b526801403adb05b3b0f6c25b25ed5>> + * @generated SignedSource<<3dd979e3fd1a02a74349437bdbb264f4>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro/src/Server.js @@ -45,6 +45,7 @@ import type {CustomResolverOptions} from 'metro-resolver/private/types'; import type {CustomTransformOptions} from 'metro-transform-worker'; import IncrementalBundler from './IncrementalBundler'; +import ProjectRouteMap from './lib/ProjectRouteMap'; import MultipartResponse from './Server/MultipartResponse'; import {SourcePathsMode} from './shared/types'; import {Logger} from 'metro-core'; @@ -114,9 +115,7 @@ declare class Server { _reporter: Reporter; _serverOptions: ServerOptions | void; _allowedSuffixesForSourceRequests: ReadonlyArray; - _sourceRequestRoutingMap: ReadonlyArray< - [pathnamePrefix: string, normalizedRootDir: string] - >; + _routeMap: ProjectRouteMap; _fetchTimings: Array; _activeFetchCount: number; constructor(config: ConfigT, options?: ServerOptions); @@ -168,11 +167,7 @@ declare class Server { res: ServerResponse, next: ($$PARAM_0$$: null | undefined | Error) => void, ): Promise; - _processSourceRequest( - relativeFilePathname: string, - rootDir: string, - res: ServerResponse, - ): Promise; + _processSourceRequest(filePath: string, res: ServerResponse): Promise; _createRequestProcessor($$PARAM_0$$: { readonly bundleType: 'assets' | 'bundle' | 'map'; readonly createStartEntry: ( @@ -225,9 +220,6 @@ declare class Server { _explodedSourceMapForBundleOptions( bundleOptions: BundleOptions, ): Promise; - _resolveWatchFolderPrefix( - filePath: string, - ): {rootDir: string; filePath: string} | null; _resolveRelativePath( filePath: string, $$PARAM_1$$: Readonly<{ @@ -272,7 +264,6 @@ declare class Server { sourceUrl: null; sourcePaths: SourcePathsMode; }; - _getServerRootDir(): string; _getEntryPointAbsolutePath(entryFile: string): string; ready(): Promise; _shouldAddModuleToIgnoreList(module: Module): boolean; diff --git a/packages/metro/types/lib/ProjectRouteMap.d.ts b/packages/metro/types/lib/ProjectRouteMap.d.ts new file mode 100644 index 0000000000..3426241bde --- /dev/null +++ b/packages/metro/types/lib/ProjectRouteMap.d.ts @@ -0,0 +1,55 @@ +/** + * 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. + * + * @noformat + * @generated SignedSource<<82eea6ba303c71a4471aa94a516ef33b>> + * + * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js + * Original file: packages/metro/src/lib/ProjectRouteMap.js + * To regenerate, run: + * js1 build metro-ts-defs (internal) OR + * yarn run build-ts-defs (OSS) + */ + +import type {ConfigT} from 'metro-config'; +/** + * Immutable bidirectional map between URL pathnames and filesystem paths, + * encoding the `[metro-project]` and `[metro-watchFolders]` virtual prefix + * conventions. + */ +declare class ProjectRouteMap { + readonly serverRootDir: string; + readonly _projectRootDirPrefix: string; + readonly _watchFolderDirPrefixes: ReadonlyArray; + readonly _filePathRoutes: ReadonlyArray<{ + rootDirPrefix: string; + pathnamePrefix: string; + }>; + constructor(config: ConfigT); + /** + * Decode a URL pathname and resolve it to an absolute filesystem path. + */ + filePathOfUrlPathname(pathname: string): string | null; + /** + * Convert a URL pathname or entry-file path to an absolute filesystem path. + * + * Accepts both URL-style (`/[metro-watchFolders]/1/foo`) and entry-file-style + * (`./[metro-watchFolders]/1/foo`) prefixes. + * + * Returns `null` when the pathname does not match a known virtual prefix, + * or for out-of-bounds watchFolder indices. + */ + filePathOfUrlDecodedPathname(pathname: string): string | null; + /** + * Convert an absolute filesystem path to a URL pathname using the first + * matching virtual prefix. + * + * Falls back to the absolute path (as a POSIX-style URL) when the file is + * not under any configured route. + */ + urlPathnameOfFilePath(filePath: string): string; +} +export default ProjectRouteMap;