UNPKG

metro-transform-worker

Version:

🚇 Transform worker for Metro.

756 lines (672 loc) • 20.5 kB
/** * 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 */ 'use strict'; import type { BabelTransformer, BabelTransformerArgs, CustomTransformOptions, TransformProfile, } from 'metro-babel-transformer'; import type { HermesCompilerResult, Options as HermesCompilerOptions, } from 'metro-hermes-compiler'; import type { BasicSourceMap, FBSourceFunctionMap, MetroSourceMapSegmentTuple, } from 'metro-source-map'; import type {TransformResultDependency} from 'metro/src/DeltaBundler'; import type {AllowOptionalDependencies} from 'metro/src/DeltaBundler/types.flow.js'; import type { DependencyTransformer, DynamicRequiresBehavior, } from 'metro/src/ModuleGraph/worker/collectDependencies'; import typeof CollectDependenciesFn from 'metro/src/ModuleGraph/worker/collectDependencies'; const getMinifier = require('./utils/getMinifier'); const {transformFromAstSync} = require('@babel/core'); const generate = require('@babel/generator').default; const babylon = require('@babel/parser'); const types = require('@babel/types'); const {stableHash} = require('metro-cache'); const getCacheKey = require('metro-cache-key'); const HermesCompiler = require('metro-hermes-compiler'); const { fromRawMappings, toBabelSegments, toSegmentTuple, } = require('metro-source-map'); const metroTransformPlugins = require('metro-transform-plugins'); const countLines = require('metro/src/lib/countLines'); const { InvalidRequireCallError: InternalInvalidRequireCallError, } = require('metro/src/ModuleGraph/worker/collectDependencies'); const generateImportNames = require('metro/src/ModuleGraph/worker/generateImportNames'); const JsFileWrapping = require('metro/src/ModuleGraph/worker/JsFileWrapping'); const nullthrows = require('nullthrows'); type MinifierConfig = $ReadOnly<{[string]: mixed, ...}>; export type MinifierOptions = { code: string, map: ?BasicSourceMap, filename: string, reserved: $ReadOnlyArray<string>, config: MinifierConfig, ... }; export type MinifierResult = { code: string, map?: BasicSourceMap, ... }; export type Minifier = MinifierOptions => | MinifierResult | Promise<MinifierResult>; export type Type = 'script' | 'module' | 'asset'; export type JsTransformerConfig = $ReadOnly<{ assetPlugins: $ReadOnlyArray<string>, assetRegistryPath: string, asyncRequireModulePath: string, babelTransformerPath: string, dynamicDepsInPackages: DynamicRequiresBehavior, enableBabelRCLookup: boolean, enableBabelRuntime: boolean | string, experimentalImportBundleSupport: boolean, globalPrefix: string, hermesParser: boolean, minifierConfig: MinifierConfig, minifierPath: string, optimizationSizeLimit: number, publicPath: string, allowOptionalDependencies: AllowOptionalDependencies, unstable_collectDependenciesPath: string, unstable_dependencyMapReservedName: ?string, unstable_disableModuleWrapping: boolean, unstable_disableNormalizePseudoGlobals: boolean, unstable_compactOutput: boolean, }>; export type {CustomTransformOptions} from 'metro-babel-transformer'; export type JsTransformOptions = $ReadOnly<{ customTransformOptions?: CustomTransformOptions, dev: boolean, experimentalImportSupport?: boolean, hot: boolean, inlinePlatform: boolean, inlineRequires: boolean, minify: boolean, nonInlinedRequires?: $ReadOnlyArray<string>, platform: ?string, runtimeBytecodeVersion: ?number, type: Type, unstable_disableES6Transforms?: boolean, unstable_transformProfile: TransformProfile, }>; export type BytecodeFileType = | 'bytecode/module' | 'bytecode/module/asset' | 'bytecode/script'; opaque type Path = string; type BaseFile = $ReadOnly<{ code: string, filename: Path, inputFileSize: number, }>; type AssetFile = $ReadOnly<{ ...BaseFile, type: 'asset', }>; type JSFileType = 'js/script' | 'js/module' | 'js/module/asset'; type JSFile = $ReadOnly<{ ...BaseFile, ast?: ?BabelNodeFile, type: JSFileType, functionMap: FBSourceFunctionMap | null, }>; type JSONFile = { ...BaseFile, type: Type, }; type TransformationContext = $ReadOnly<{ config: JsTransformerConfig, projectRoot: Path, options: JsTransformOptions, }>; export type JsOutput = $ReadOnly<{ data: $ReadOnly<{ code: string, lineCount: number, map: Array<MetroSourceMapSegmentTuple>, functionMap: ?FBSourceFunctionMap, }>, type: JSFileType, }>; export type BytecodeOutput = $ReadOnly<{ data: HermesCompilerResult, type: BytecodeFileType, }>; type DependencySplitCondition = $PropertyType< $PropertyType<TransformResultDependency, 'data'>, 'splitCondition', >; type TransformResponse = $ReadOnly<{ dependencies: $ReadOnlyArray<TransformResultDependency>, output: $ReadOnlyArray<JsOutput | BytecodeOutput>, }>; function getDynamicDepsBehavior( inPackages: DynamicRequiresBehavior, filename: string, ): DynamicRequiresBehavior { switch (inPackages) { case 'reject': return 'reject'; case 'throwAtRuntime': const isPackage = /(?:^|[/\\])node_modules[/\\]/.test(filename); return isPackage ? inPackages : 'reject'; default: (inPackages: empty); throw new Error( `invalid value for dynamic deps behavior: \`${inPackages}\``, ); } } const minifyCode = async ( config: JsTransformerConfig, projectRoot: string, filename: string, code: string, source: string, map: Array<MetroSourceMapSegmentTuple>, reserved?: $ReadOnlyArray<string> = [], ): Promise<{ code: string, map: Array<MetroSourceMapSegmentTuple>, ... }> => { const sourceMap = fromRawMappings([ {code, source, map, functionMap: null, path: filename}, ]).toMap(undefined, {}); const minify = getMinifier(config.minifierPath); try { const minified = await minify({ code, map: sourceMap, filename, reserved, config: config.minifierConfig, }); return { code: minified.code, map: minified.map ? toBabelSegments(minified.map).map(toSegmentTuple) : [], }; } catch (error) { if (error.constructor.name === 'JS_Parse_Error') { throw new Error( `${error.message} in file ${filename} at ${error.line}:${error.col}`, ); } throw error; } }; const compileToBytecode = ( rawCode: string, type: string, options: HermesCompilerOptions, ): HermesCompilerResult => { let code = rawCode; if (type.startsWith('js/module')) { const index = code.lastIndexOf(')'); code = code.slice(0, index) + ',$$METRO_D[0],$$METRO_D[1],$$METRO_D[2]' + code.slice(index); } return HermesCompiler.compile(code, options); }; const disabledDependencyTransformer: DependencyTransformer<mixed> = { transformSyncRequire: () => void 0, transformImportCall: () => void 0, transformJSResource: () => void 0, transformPrefetch: () => void 0, transformIllegalDynamicRequire: () => void 0, }; class InvalidRequireCallError extends Error { innerError: InternalInvalidRequireCallError; filename: string; constructor(innerError: InternalInvalidRequireCallError, filename: string) { super(`${filename}:${innerError.message}`); this.innerError = innerError; this.filename = filename; } } async function transformJS( file: JSFile, {config, options, projectRoot}: TransformationContext, ): Promise<TransformResponse> { // Transformers can output null ASTs (if they ignore the file). In that case // we need to parse the module source code to get their AST. let ast = file.ast ?? babylon.parse(file.code, {sourceType: 'unambiguous'}); const {importDefault, importAll} = generateImportNames(ast); // Add "use strict" if the file was parsed as a module, and the directive did // not exist yet. const {directives} = ast.program; if ( ast.program.sourceType === 'module' && directives != null && directives.findIndex(d => d.value.value === 'use strict') === -1 ) { directives.push(types.directive(types.directiveLiteral('use strict'))); } // Perform the import-export transform (in case it's still needed), then // fold requires and perform constant folding (if in dev). const plugins = []; const babelPluginOpts = { ...options, inlineableCalls: [importDefault, importAll], importDefault, importAll, }; if (options.experimentalImportSupport === true) { plugins.push([metroTransformPlugins.importExportPlugin, babelPluginOpts]); } if (options.inlineRequires) { plugins.push([ // $FlowFixMe[untyped-import] untyped module require('babel-preset-fbjs/plugins/inline-requires'), { ...babelPluginOpts, ignoredRequires: options.nonInlinedRequires, }, ]); } plugins.push([metroTransformPlugins.inlinePlugin, babelPluginOpts]); ast = nullthrows( transformFromAstSync(ast, '', { ast: true, babelrc: false, code: false, configFile: false, comments: false, filename: file.filename, plugins, sourceMaps: false, // Not-Cloning the input AST here should be safe because other code paths above this call // are mutating the AST as well and no code is depending on the original AST. // However, switching the flag to false caused issues with ES Modules if `experimentalImportSupport` isn't used https://github.com/facebook/metro/issues/641 // either because one of the plugins is doing something funky or Babel messes up some caches. // Make sure to test the above mentioned case before flipping the flag back to false. cloneInputAst: true, }).ast, ); if (!options.dev) { // Run the constant folding plugin in its own pass, avoiding race conditions // with other plugins that have exit() visitors on Program (e.g. the ESM // transform). ast = nullthrows( transformFromAstSync(ast, '', { ast: true, babelrc: false, code: false, configFile: false, comments: false, filename: file.filename, plugins: [ [metroTransformPlugins.constantFoldingPlugin, babelPluginOpts], ], sourceMaps: false, cloneInputAst: false, }).ast, ); } let dependencyMapName = ''; let dependencies; let wrappedAst; // If the module to transform is a script (meaning that is not part of the // dependency graph and it code will just be prepended to the bundle modules), // we need to wrap it differently than a commonJS module (also, scripts do // not have dependencies). if (file.type === 'js/script') { dependencies = []; wrappedAst = JsFileWrapping.wrapPolyfill(ast); } else { try { const opts = { asyncRequireModulePath: config.asyncRequireModulePath, dependencyTransformer: config.unstable_disableModuleWrapping === true ? disabledDependencyTransformer : undefined, dynamicRequires: getDynamicDepsBehavior( config.dynamicDepsInPackages, file.filename, ), inlineableCalls: [importDefault, importAll], keepRequireNames: options.dev, allowOptionalDependencies: config.allowOptionalDependencies, dependencyMapName: config.unstable_dependencyMapReservedName, }; // $FlowFixMe[unsupported-syntax] dynamic require const collectDependencies: CollectDependenciesFn<DependencySplitCondition> = require(config.unstable_collectDependenciesPath); ({ast, dependencies, dependencyMapName} = collectDependencies(ast, opts)); } catch (error) { if (error instanceof InternalInvalidRequireCallError) { throw new InvalidRequireCallError(error, file.filename); } throw error; } if (config.unstable_disableModuleWrapping === true) { wrappedAst = ast; } else { ({ast: wrappedAst} = JsFileWrapping.wrapModule( ast, importDefault, importAll, dependencyMapName, config.globalPrefix, )); } } const minify = options.minify && options.unstable_transformProfile !== 'hermes-canary' && options.unstable_transformProfile !== 'hermes-stable'; const reserved = []; if (config.unstable_dependencyMapReservedName != null) { reserved.push(config.unstable_dependencyMapReservedName); } if ( minify && file.inputFileSize <= config.optimizationSizeLimit && !config.unstable_disableNormalizePseudoGlobals ) { reserved.push( ...metroTransformPlugins.normalizePseudoGlobals(wrappedAst, { reservedNames: reserved, }), ); } const result = generate( wrappedAst, { comments: false, compact: config.unstable_compactOutput, filename: file.filename, retainLines: false, sourceFileName: file.filename, sourceMaps: true, }, file.code, ); let map = result.rawMappings ? result.rawMappings.map(toSegmentTuple) : []; let code = result.code; if (minify) { ({map, code} = await minifyCode( config, projectRoot, file.filename, result.code, file.code, map, reserved, )); } const output = [ { data: { code, lineCount: countLines(code), map, functionMap: file.functionMap, }, type: file.type, }, ]; if (options.runtimeBytecodeVersion != null) { output.push({ data: (compileToBytecode(code, file.type, { sourceURL: file.filename, sourceMap: fromRawMappings([ { code, source: file.code, map, functionMap: null, path: file.filename, }, ]).toString(), }): HermesCompilerResult), type: getBytecodeFileType(file.type), }); } return { dependencies, output, }; } /** * Transforms an asset file */ async function transformAsset( file: AssetFile, context: TransformationContext, ): Promise<TransformResponse> { const assetTransformer = require('./utils/assetTransformer'); const {assetRegistryPath, assetPlugins} = context.config; const result = await assetTransformer.transform( getBabelTransformArgs(file, context), assetRegistryPath, assetPlugins, ); const jsFile = { ...file, type: 'js/module/asset', ast: result.ast, functionMap: null, }; return transformJS(jsFile, context); } /** * Transforms a JavaScript file with Babel before processing the file with * the generic JavaScript transformation. */ async function transformJSWithBabel( file: JSFile, context: TransformationContext, ): Promise<TransformResponse> { const {babelTransformerPath} = context.config; // $FlowFixMe[unsupported-syntax] dynamic require const transformer: BabelTransformer = require(babelTransformerPath); const transformResult = await transformer.transform( getBabelTransformArgs(file, context), ); const jsFile: JSFile = { ...file, ast: transformResult.ast, functionMap: transformResult.functionMap ?? null, }; return await transformJS(jsFile, context); } async function transformJSON( file: JSONFile, {options, config, projectRoot}: TransformationContext, ): Promise<TransformResponse> { let code = config.unstable_disableModuleWrapping === true ? JsFileWrapping.jsonToCommonJS(file.code) : JsFileWrapping.wrapJson(file.code, config.globalPrefix); let map = []; // TODO: When we can reuse transformJS for JSON, we should not derive `minify` separately. const minify = options.minify && options.unstable_transformProfile !== 'hermes-canary' && options.unstable_transformProfile !== 'hermes-stable'; if (minify) { ({map, code} = await minifyCode( config, projectRoot, file.filename, code, file.code, map, )); } let jsType: JSFileType; if (file.type === 'asset') { jsType = 'js/module/asset'; } else if (file.type === 'script') { jsType = 'js/script'; } else { jsType = 'js/module'; } const output = [ { data: {code, lineCount: countLines(code), map, functionMap: null}, type: jsType, }, ]; if (options.runtimeBytecodeVersion != null) { output.push({ data: (compileToBytecode(code, jsType, { sourceURL: file.filename, sourceMap: fromRawMappings([ { code, source: file.code, map, functionMap: null, path: file.filename, }, ]).toString(), }): HermesCompilerResult), type: getBytecodeFileType(jsType), }); } return { dependencies: [], output, }; } /** * Returns the bytecode type for a file type */ function getBytecodeFileType(type: JSFileType): BytecodeFileType { switch (type) { case 'js/module/asset': return 'bytecode/module/asset'; case 'js/script': return 'bytecode/script'; default: (type: 'js/module'); return 'bytecode/module'; } } function getBabelTransformArgs( file: $ReadOnly<{filename: Path, code: string, ...}>, {options, config, projectRoot}: TransformationContext, ): BabelTransformerArgs { return { filename: file.filename, options: { ...options, enableBabelRCLookup: config.enableBabelRCLookup, enableBabelRuntime: config.enableBabelRuntime, globalPrefix: config.globalPrefix, hermesParser: config.hermesParser, // Inline requires are now performed at a secondary step. We cannot // unfortunately remove it from the internal transformer, since this one // is used by other tooling, and this would affect it. inlineRequires: false, nonInlinedRequires: [], projectRoot, publicPath: config.publicPath, }, plugins: [], src: file.code, }; } module.exports = { transform: async ( config: JsTransformerConfig, projectRoot: string, filename: string, data: Buffer, options: JsTransformOptions, ): Promise<TransformResponse> => { const context: TransformationContext = { config, projectRoot, options, }; const sourceCode = data.toString('utf8'); const {unstable_dependencyMapReservedName} = config; if (unstable_dependencyMapReservedName != null) { const position = sourceCode.indexOf(unstable_dependencyMapReservedName); if (position > -1) { throw new SyntaxError( 'Source code contains the reserved string `' + unstable_dependencyMapReservedName + '` at character offset ' + position, ); } } if (filename.endsWith('.json')) { const jsonFile: JSONFile = { filename, inputFileSize: data.length, code: sourceCode, type: options.type, }; return await transformJSON(jsonFile, context); } if (options.type === 'asset') { const file: AssetFile = { filename, inputFileSize: data.length, code: sourceCode, type: options.type, }; return await transformAsset(file, context); } const file: JSFile = { filename, inputFileSize: data.length, code: sourceCode, type: options.type === 'script' ? 'js/script' : 'js/module', functionMap: null, }; return await transformJSWithBabel(file, context); }, getCacheKey: (config: JsTransformerConfig): string => { const { babelTransformerPath, minifierPath, unstable_collectDependenciesPath, ...remainingConfig } = config; const filesKey = getCacheKey([ require.resolve(babelTransformerPath), require.resolve(minifierPath), require.resolve('./utils/getMinifier'), require.resolve('./utils/assetTransformer'), require.resolve(unstable_collectDependenciesPath), require.resolve('metro/src/ModuleGraph/worker/generateImportNames'), require.resolve('metro/src/ModuleGraph/worker/JsFileWrapping'), ...metroTransformPlugins.getTransformPluginCacheKeyFiles(), ]); // $FlowFixMe[unsupported-syntax] const babelTransformer = require(babelTransformerPath); return [ filesKey, stableHash(remainingConfig).toString('hex'), babelTransformer.getCacheKey ? babelTransformer.getCacheKey() : '', ].join('$'); }, };